@ulysses-ai/create-workspace 0.23.2-beta.0 → 0.24.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/template/CLAUDE.md.tmpl +2 -2
- package/template/_claude/hooks/_utils.mjs +43 -0
- package/template/_claude/hooks/session-start.mjs +10 -0
- package/template/_claude/rules/work-item-tracking.md +6 -3
- package/template/_claude/rules/workspace-structure.md +1 -1
- package/template/_claude/scripts/chat-record.mjs +43 -26
- package/template/_claude/scripts/classify-update.mjs +194 -32
- package/template/_claude/scripts/forges/github.mjs +51 -1
- package/template/_claude/scripts/forges/gitlab.mjs +69 -0
- package/template/_claude/scripts/forges/interface.mjs +19 -2
- package/template/_claude/scripts/maintenance-audit.mjs +325 -3
- package/template/_claude/scripts/merge-mode.mjs +22 -1
- package/template/_claude/scripts/migrate-sessions.mjs +212 -34
- package/template/_claude/scripts/release-leak-audit.mjs +480 -0
- package/template/_claude/scripts/task-pr.mjs +316 -72
- package/template/_claude/scripts/task-worktree.mjs +9 -1
- package/template/_claude/scripts/template-merge.mjs +21 -4
- package/template/_claude/scripts/template-modifications.mjs +152 -0
- package/template/_claude/scripts/trackers/github-issues.mjs +110 -1
- package/template/_claude/scripts/trackers/gitlab-issues.mjs +238 -5
- package/template/_claude/scripts/trackers/interface.mjs +47 -0
- package/template/_claude/skills/braindump/SKILL.md +1 -1
- package/template/_claude/skills/complete-work/SKILL.md +22 -12
- package/template/_claude/skills/handoff/SKILL.md +1 -1
- package/template/_claude/skills/maintenance/SKILL.md +8 -3
- package/template/_claude/skills/migrate-sessions/SKILL.md +20 -13
- package/template/_claude/skills/release/SKILL.md +20 -6
- package/template/_claude/skills/setup-tracker/SKILL.md +24 -1
- package/template/_claude/skills/start-work/SKILL.md +23 -5
- package/template/_claude/skills/workspace-update/SKILL.md +23 -15
- package/template/workspace.json.tmpl +2 -1
- package/lib/init.test.mjs +0 -75
- package/lib/payload.test.mjs +0 -210
- package/lib/scaffold.test.mjs +0 -79
- package/lib/upgrade.test.mjs +0 -547
|
@@ -9,7 +9,7 @@ Apply a staged template update to an initialized workspace. The CLI (`npx @ulyss
|
|
|
9
9
|
|
|
10
10
|
## Prerequisites
|
|
11
11
|
|
|
12
|
-
- `workspace.json`
|
|
12
|
+
- Read `workspace.json` (the file at the workspace root — the SessionStart hook's `Workspace config (workspace.json):` line is only a summary) and check `workspace.initialized` is `true`
|
|
13
13
|
- If not initialized, check whether initialization was committed but never merged — the workspace-init flow ends with its commits merged to the default branch, so an unmerged init branch explains a missing flag:
|
|
14
14
|
```bash
|
|
15
15
|
git log --all --format=%H -S'"initialized": true' -- workspace.json
|
|
@@ -54,14 +54,19 @@ It runs from the payload precisely so workspaces that don't have it installed ye
|
|
|
54
54
|
- `new` — no installed counterpart and no baseline entry; safe to batch-apply (Step 3) behind one confirmation
|
|
55
55
|
- `identical` — installed file already equals the payload; skip silently
|
|
56
56
|
- `updated` — installed file still holds the baseline content while the payload ships something new: a pure template change the user never touched. Batched with `new` behind one confirmation
|
|
57
|
-
- `differs` — installed file matches neither the payload nor the baseline, and the template changed it since the baseline too: a local edit that meets a template change. Needs a per-file decision — Step 3 merges these three-way when a merge base is staged
|
|
57
|
+
- `differs` — installed file matches neither the payload nor the baseline, and the template changed it since the baseline too: a local edit that meets a template change. Needs a per-file decision — Step 3 merges these three-way when a merge base is staged. A path registered in `.claude/template-modifications.json` (see the registry note below) enters as `{ file, reason }`, its reason saying why the file diverges — show it at every decision about the file
|
|
58
58
|
- `config` — `.mcp.json` and `.claude/settings.json`: JSON the workspace owns jointly with the template — never compared by content, never batch-copied. Each entry carries a key-level diff (`added` keys the template ships, `workspaceOnly` keys only the workspace has, `changed` keys with different values; nested paths like `mcpServers/{server}`), element diffs for array-valued keys (`arrays`: `{ path, added, workspaceOnly }` element lists for `hooks/{event}`, `permissions/allow`/`deny`), or a `notInstalled` / `unparseable` flag. Merged key by key in Step 3.
|
|
59
|
-
- `localOnly` — installed file differs from the payload, but the payload equals the baseline: these are local edits to files the template didn't touch. Informational only — never asked about, never applied
|
|
60
|
-
- `deletedLocally` — the baseline records the file and the payload still ships it, but it is missing from the workspace (deleted locally, or declined at install time). Step 3 asks once whether to restore the list
|
|
59
|
+
- `localOnly` — installed file differs from the payload, but the payload equals the baseline: these are local edits to files the template didn't touch. Informational only — never asked about, never applied; registered entries carry their reason, shown in the summary
|
|
60
|
+
- `deletedLocally` — the baseline records the file and the payload still ships it, but it is missing from the workspace (deleted locally, or declined at install time). Step 3 asks once whether to restore the list; registered entries carry their reason, shown with that ask
|
|
61
61
|
- `activated` — the payload ships `rules/{name}.md.skip` while the workspace keeps `{name}.md` active: the rule was deliberately activated. Nothing to install — the active rule stays.
|
|
62
|
-
- `removed` — installed file with no counterpart in the payload. The config files above never appear here (the template dropping one hands it to the workspace). Gitignored paths, `.claude/worktrees/`, and files
|
|
62
|
+
- `removed` — installed file with no counterpart in the payload. The config files above never appear here (the template dropping one hands it to the workspace). Gitignored paths, `.claude/worktrees/`, and files claimed in `.claude/template-modifications.json` → `localFiles` (an array of `.claude/`-relative paths or globs the workspace owns; the legacy `workspace.json` → `workspace.localFiles` array still counts) are excluded automatically. An entry is a plain path, `{ file, referencedBy }` — a hook a workspace-only `settings.json` entry still registers (the paths say where) — or `{ file, userOwned: true }` — no baseline record, so the template never shipped it and it is the workspace's own; any of these may carry `reason` when the file is registered in the registry's `modifications` map.
|
|
63
63
|
- `staleTests` — `*.test.mjs` files under `.claude/` the payload doesn't carry. The package never ships tests, so these came from a dev checkout and no update refreshes them (Step 3 offers removal).
|
|
64
64
|
- `implicitDefaults` — `workspace.json` keys whose absence carried a default in the version being upgraded from. Today: `canonicalBudgetBytes`, an implicit 40960-byte budget between v0.15.0-beta.1 and v0.19.0-beta.0 (before v0.15 there was no budget at all; since v0.19 absent means off). An upgrade from inside that window into a workspace.json that never set the key reports `{ key, value, reason }` — Step 3's workspace.json step writes the value explicitly so trimming doesn't silently stop.
|
|
65
|
+
- `staleModifications` — `{ file, reason }` entries from the registry whose installed file already equals the payload: the template's version was taken at some point, so the recorded reason describes nothing. `file` is the `.claude/`-prefixed path; the registry key is that path with the prefix stripped. Step 3 offers to drop them.
|
|
66
|
+
- `legacyKeys` — template-modification data still sitting in `workspace.json` (`workspace.localFiles`, `workspace.templateModifications`) instead of the registry file. Step 3 offers the migration.
|
|
67
|
+
- `ignoredKeys` — registry keys that escape `.claude/` (`../CLAUDE.md`, absolute paths): the registry covers `.claude/` paths only — root files (`CLAUDE.md`, `.mcp.json`, `.claudeignore`) are handled by their own merge paths — so these are reported for the operator to fix rather than sitting silently inert.
|
|
68
|
+
|
|
69
|
+
The classification also consults `.claude/template-modifications.json`, the workspace's registry of deliberate divergences from the template — `{ "localFiles": [...], "modifications": { "<path>": "<reason>" } }` with paths relative to `.claude/` (backslashes and leading `./` or `.claude/` are normalized on read). The template never ships or overwrites this file. `localFiles` are paths or globs the workspace owns outright (never offered by an update); `modifications` records why edited template files are kept as they are — those files still update (three-way merge when both sides changed), the reason just travels with every decision about them. If the output carries `modificationsError`, the registry doesn't parse — STOP: report the error and ask the operator to fix the JSON before any per-file decision. Classification has already failed closed (every `removed` entry is marked `unverifiable: true`, because the file's ownership claims cannot be read), so nothing is offered as a plain removal while it is broken; never invent reasons or exclusions, and after the fix re-run the classifier.
|
|
65
70
|
|
|
66
71
|
Content is compared with line endings normalized (CRLF ≡ LF; binary files byte-exact), so a Windows autocrlf checkout does not read as locally modified.
|
|
67
72
|
|
|
@@ -102,27 +107,29 @@ Batch the safe cases, ask on the rest:
|
|
|
102
107
|
```bash
|
|
103
108
|
node {payload}/.claude/scripts/template-merge.mjs --root . --payload {payload} --baseline {baseline}
|
|
104
109
|
```
|
|
105
|
-
(`{baseline}` resolves as in Step 2; `--files a,b` re-runs a subset, `--out <dir>` redirects the output.) It merges each `differs` file with `git merge-file` — the workspace copy as "local", the staged base as the common ancestor, the payload's copy as "template" — and prints `{ merged: [{path, conflicts: 0, out}], conflicted: [{path, conflicts: N, out}], noBase: [path], errors: [...] }`, writing the merged text under `{payload}/.merged/` mirroring each path. It never writes a workspace file; applying is this skill's job, on approval:
|
|
106
|
-
- **Clean merges (`merged`):** show the diff workspace→merged for each file (`git diff --no-index` the workspace file against its `out`), then ONE batched confirmation — "Apply these {N} clean merges? [Y/n]" — listing the files. On yes, copy each `out` file to the same path in the workspace (the update worktree in the remote flow).
|
|
107
|
-
- **Conflicts (`conflicted`):** one file at a time. Show the conflict hunks (`<<<<<<< local` … `>>>>>>> template`), propose a resolution, and write the resolved file only after the operator's yes for THAT file. Never resolve a conflict silently, and never delegate resolution to a subagent or any unattended step without the operator approving each file.
|
|
108
|
-
- **`noBase`** (no staged base for the path, or its hash does not match the baseline — the base is not this workspace's ancestor): fall back to the two-way ask — "Your version of {file} differs from the template's. Show diff? [y/N]" — then apply, keep, or merge by hand per the user's decision. Expect a previously declined update here: its baseline entry keeps the older version's hash (Step 4's rule), so the newly staged base no longer validates and the file takes this per-file decision.
|
|
110
|
+
(`{baseline}` resolves as in Step 2; `--files a,b` re-runs a subset, `--out <dir>` redirects the output.) It merges each `differs` file with `git merge-file` — the workspace copy as "local", the staged base as the common ancestor, the payload's copy as "template" — and prints `{ merged: [{path, conflicts: 0, out}], conflicted: [{path, conflicts: N, out}], noBase: [path], errors: [...] }`, writing the merged text under `{payload}/.merged/` mirroring each path. Entries in `merged`/`conflicted` carry `reason` when the file is registered in `.claude/template-modifications.json`, and a registered `noBase` path prints as `{ path, reason }` — show that reason at the decision it belongs to. It never writes a workspace file; applying is this skill's job, on approval:
|
|
111
|
+
- **Clean merges (`merged`):** show the diff workspace→merged for each file (`git diff --no-index` the workspace file against its `out`), with its registered reason when it has one, then ONE batched confirmation — "Apply these {N} clean merges? [Y/n]" — listing the files. On yes, copy each `out` file to the same path in the workspace (the update worktree in the remote flow).
|
|
112
|
+
- **Conflicts (`conflicted`):** one file at a time. Show the conflict hunks (`<<<<<<< local` … `>>>>>>> template`) with the file's registered reason when it has one, propose a resolution, and write the resolved file only after the operator's yes for THAT file. Never resolve a conflict silently, and never delegate resolution to a subagent or any unattended step without the operator approving each file.
|
|
113
|
+
- **`noBase`** (no staged base for the path, or its hash does not match the baseline — the base is not this workspace's ancestor): fall back to the two-way ask — "Your version of {file} differs from the template's{ — registered reason}. Show diff? [y/N]" — then apply, keep, or merge by hand per the user's decision. Expect a previously declined update here: its baseline entry keeps the older version's hash (Step 4's rule), so the newly staged base no longer validates and the file takes this per-file decision.
|
|
114
|
+
Whenever the operator's decision KEEPS their local version — a `noBase` "keep", a conflict resolution that keeps the local wording — and the path is not yet registered, offer: "Record why you keep {file} in .claude/template-modifications.json? [y/N]" — on yes, add `"<path>": "<their reason>"` to its `modifications` map (path relative to `.claude/`, creating the file if absent), so the next update shows the reason instead of re-asking blind.
|
|
109
115
|
When the payload carries no `.template-base/` at all (the CLI could not fetch the installed version — offline or unpublished; the helper reports `templateBase: null`), every `differs` file takes the `noBase` path — say so once in the report.
|
|
110
116
|
- **Config files (`config`):** `.mcp.json` and `.claude/settings.json` are never copied wholesale — a batch copy wipes the workspace's own MCP servers and settings. Merge each entry key by key (values from `{payload}/{path}` and the workspace's copy): add every `added` key, keep every `workspaceOnly` key untouched, and for each `changed` key ask — "Template changed `{key}` in `{path}`. Take the template's, keep yours, or inspect?" Array-valued keys merge as a union, no ask: keep the workspace's elements in place and append each `arrays` entry's `added` elements (`workspaceOnly` elements are already in place, listed for visibility). `notInstalled` — ask once: "Install {path} from the template? [Y/n]" (never install a config silently); `unparseable` means broken JSON on one side — show the file and ask, never merge blind.
|
|
111
117
|
- **Local-only edits (`localOnly`):** nothing to decide — these are your local edits to files the template hasn't changed since the last update. List them in the summary (so the edits are visible) and move on; do not ask about them.
|
|
112
|
-
- **Deleted locally (`deletedLocally`):** "These {N} files exist in the template and its baseline but not in your workspace — deleted locally (or never installed). Restore from the template? [Y/n]" — one confirmation for the whole list. Restoring installs the payload's version of each.
|
|
113
|
-
- **Removed in template (`removed`):** a plain path — "Template removed {file}. Delete locally? [y/N]" (conservative default). An entry `{ file, referencedBy }` is a hook still registered in `.claude/settings.json`: ask once — "Template removed {file}, which your settings.json still references via {refs}. Remove the file and those settings entries together? [Y/n]" — never delete the file and leave a settings entry pointing at nothing. An entry `{ file, userOwned: true }` was never shipped by the template (no baseline record): do not offer deletion — suggest claiming it in
|
|
118
|
+
- **Deleted locally (`deletedLocally`):** "These {N} files exist in the template and its baseline but not in your workspace — deleted locally (or never installed). Restore from the template? [Y/n]" — one confirmation for the whole list, showing each registered reason (a registered deletion is usually deliberate). Restoring installs the payload's version of each.
|
|
119
|
+
- **Removed in template (`removed`):** a plain path — "Template removed {file}. Delete locally? [y/N]" (conservative default). An entry `{ file, referencedBy }` is a hook still registered in `.claude/settings.json`: ask once — "Template removed {file}, which your settings.json still references via {refs}. Remove the file and those settings entries together? [Y/n]" — never delete the file and leave a settings entry pointing at nothing. An entry `{ file, userOwned: true }` was never shipped by the template (no baseline record): do not offer deletion — suggest claiming it in `.claude/template-modifications.json` → `localFiles` instead, showing the exact entry (`"localFiles": ["skills/my-skill/**"]`, or `["rules/my-rule.md"]` for a single file) so future updates skip it. An entry carrying `reason` shows it with its ask — the registry already says why this file is what it is. An entry carrying `unverifiable: true` means the registry didn't parse and its ownership claims were unreadable — that is Step 2's stop; never offer deletion for one until the JSON is fixed and the classification re-run.
|
|
114
120
|
- **Activated rules (`activated`):** keep the active file, install nothing — report: "Rule {name} is active here and optional in the template; your activation is preserved."
|
|
115
121
|
- **Stale tests (`staleTests`):** "These {N} test files under .claude/ came from a dev checkout — the package never ships them, so updates can't refresh them (tests live in the template repo). Remove them? [Y/n]" — one confirmation for the whole list.
|
|
122
|
+
- **Template-modification registry (`legacyKeys` + `staleModifications`):** when `legacyKeys` is non-empty, offer once — "Move your localFiles/templateModifications out of workspace.json into `.claude/template-modifications.json`? [Y/n]". On yes, write the registry in the workspace being updated (the update worktree in the remote flow): merge the legacy values into its `localFiles` array and `modifications` map (paths `.claude/`-relative; create the file if absent) and remove the legacy keys from `workspace.json` there — both edits ride the update's commit, so the rest of the team gets the migration with the update. When `staleModifications` is non-empty, offer once — "These {N} modification entries match the template again ({files}, each with its reason shown) — drop them? [Y/n]"; on yes remove those paths from the registry's `modifications` map. `ignoredKeys` entries cover nothing — mention them in the report so the operator can drop or fix them. Never edit the registry without the operator's yes.
|
|
116
123
|
- **Hook migration (.sh to .mjs):** Detect old `.sh` hooks in `.claude/hooks/` that have `.mjs` replacements in the payload. Offer: "Hook {name}.sh has a .mjs replacement in the update. Replace and update settings.json commands? [Y/n]" — this is a one-time migration for workspaces upgrading from pre-0.2.0
|
|
117
124
|
|
|
118
125
|
Also handle these non-component files from the payload:
|
|
119
126
|
|
|
120
|
-
- **workspace.json keys:** First apply any `implicitDefaults` from Step 2 — write the reported key and value into the workspace.json being updated and tell the operator: "kept your previous canonical trimming (40 KB) explicitly; remove the key to turn it off." Then compare the `workspace` object of the payload's `workspace.json.tmpl` with the installed `workspace.json`, key by key. For each key the template ships that the workspace lacks, show its template default and ask before adding. Never remove an existing key just because the template no longer ships it. `canonicalBudgetBytes` is opt-in since v0.19 — leave an existing value alone and mention it can be removed to turn the budget off.
|
|
127
|
+
- **workspace.json keys:** First apply any `implicitDefaults` from Step 2 — write the reported key and value into the workspace.json being updated and tell the operator: "kept your previous canonical trimming (40 KB) explicitly; remove the key to turn it off." Then compare the `workspace` object of the payload's `workspace.json.tmpl` with the installed `workspace.json`, key by key. For each key the template ships that the workspace lacks, show its template default and ask before adding. Never remove an existing key just because the template no longer ships it — the one exception is the legacy `localFiles`/`templateModifications` keys, which the registry migration above removes with the operator's yes. `canonicalBudgetBytes` is opt-in since v0.19 — leave an existing value alone and mention it can be removed to turn the budget off.
|
|
121
128
|
- **CLAUDE.md:** If `{payload}/CLAUDE.md.tmpl` exists, merge — never regenerate from scratch:
|
|
122
129
|
```bash
|
|
123
130
|
node {payload}/.claude/scripts/classify-update.mjs --root . --payload {payload} --merge-claude-md
|
|
124
131
|
```
|
|
125
|
-
The command prints JSON `{ claudeMd, missingIncludes }`. `claudeMd` is the merged CLAUDE.md: template-owned lines take the template's new versions (skill-list entries match by their `/name`), while lines the template doesn't have — the workspace's own skill entries, custom bullets, whole sections — are kept in place. `missingIncludes` lists the `@{file}` include lines the merged file carries whose targets don't exist here (machine-local `local-only-*` targets are exempt — expected absent, never reported). Two consequences to watch in the diff: an edit made directly to a template-owned skill line is replaced by the template's new wording, and a template prose line that was reworded locally survives alongside the new template line (it may appear twice). The merge keeps the current file's line endings. Show the user the diff against the current CLAUDE.md before writing the merged result, and act on each `missingIncludes` entry — ask "The merged CLAUDE.md includes `{file}`, which doesn't exist here. Create the stub, or leave the include out?" — never write a dangling include silently. (The two JSON configs are the `config` list's, not this block's.)
|
|
132
|
+
The command prints JSON `{ claudeMd, droppedIncludes, missingIncludes }`. `claudeMd` is the merged CLAUDE.md: template-owned lines take the template's new versions (skill-list entries match by their `/name`), while lines the template doesn't have — the workspace's own skill entries, custom bullets, whole sections — are kept in place. `droppedIncludes` names retired `@{file}` includes the workspace still carries but this payload no longer ships — today `@workspace.json`, whose config summary the SessionStart hook now injects instead (gh:196); the line is dropped from the merged result, so name the drop when you show the diff and let the operator's yes (or refusal) on the merged file decide it. `missingIncludes` lists the `@{file}` include lines the merged file carries whose targets don't exist here (machine-local `local-only-*` targets are exempt — expected absent, never reported). Two consequences to watch in the diff: an edit made directly to a template-owned skill line is replaced by the template's new wording, and a template prose line that was reworded locally survives alongside the new template line (it may appear twice). The merge keeps the current file's line endings. Show the user the diff against the current CLAUDE.md before writing the merged result, and act on each `missingIncludes` entry — ask "The merged CLAUDE.md includes `{file}`, which doesn't exist here. Create the stub, or leave the include out?" — never write a dangling include silently. (The two JSON configs are the `config` list's, not this block's.)
|
|
126
133
|
- **.gitignore:** Merge new entries from the payload's `_gitignore` into the existing `.gitignore` — do not remove user-added lines. An ignore pattern does not untrack already-committed files: if the workspace still tracks the per-machine catalogs the template now ignores (`git ls-files -- 'workspace-context/team-member/*/index.md'`), untrack them (`git rm -r --cached 'workspace-context/team-member/*/index.md'`), or every machine's regenerations keep dirtying pulls.
|
|
127
134
|
|
|
128
135
|
### Step 4: Update version and write the baseline
|
|
@@ -229,13 +236,14 @@ Report: "Workspace updated to v{templateVersion}. Restart Claude Code if rules o
|
|
|
229
236
|
|
|
230
237
|
### Step 8: Session-model migration nudge
|
|
231
238
|
|
|
232
|
-
After the update is applied, if the sessions directory (`workspace.workSessionsDir`, default `work-sessions/`) has entries and `workspace.sessionModel` is not `"task"`, append one line to the report: "This workspace still has {N} session(s) under the session model — `/migrate-sessions` can inventory and drain them and switch to the task model whenever you're ready." Suggest only; the operator decides whether and when.
|
|
239
|
+
After the update is applied, read `workspace.json` (Step 4 just rewrote its `templateVersion`, so the file is in hand): if the sessions directory (`workspace.workSessionsDir`, default `work-sessions/`) has entries and `workspace.sessionModel` is not `"task"`, append one line to the report: "This workspace still has {N} session(s) under the session model — `/migrate-sessions` can inventory and drain them and switch to the task model whenever you're ready." Suggest only; the operator decides whether and when.
|
|
233
240
|
|
|
234
241
|
## Notes
|
|
235
242
|
|
|
236
243
|
- The CLI (`npx @ulysses-ai/create-workspace --upgrade`) stages the payload, installs the current copy of this skill into `.claude/skills/workspace-update/` (so an outdated installed flow never processes a new payload; a locally customised SKILL.md is backed up as `SKILL.md.local-backup` first), stages a reconstructed baseline inside the payload as `.template-baseline.reconstructed.json` when the workspace has none, and stages the installed version's template files as `.template-base/` (the merge base `differs` files are merged against) whenever that tarball could be fetched — the launcher itself gets no new untracked files, and Step 7 restores the launcher's skill copy before the post-merge pull, rebuilds the per-user catalogs the pull may delete, and removes the update worktree with its branch
|
|
237
244
|
- Never overwrites without asking — `new` and `updated` files are batched behind one confirmation; `differs` files are three-way merged against the staged base when there is one (clean merges batch behind one confirmation, conflicts are resolved one file at a time with the operator's yes, files without a base fall back to the per-file ask); `config` files (`.mcp.json`, `.claude/settings.json`) are merged key by key (array-valued keys union-merged) and never copied wholesale; `localOnly` files are never asked about (local edits to files the template didn't touch)
|
|
238
|
-
- Preserves local modifications, custom content, the workspace's own MCP servers and settings, existing `workspace.json` keys, and deliberately activated rules
|
|
245
|
+
- Preserves local modifications, custom content, the workspace's own MCP servers and settings, existing `workspace.json` keys (the legacy `localFiles`/`templateModifications` keys excepted — they migrate to the registry with the operator's yes), and deliberately activated rules
|
|
246
|
+
- `.claude/template-modifications.json` is the workspace's own registry of deliberate divergences — `localFiles` it owns outright (never offered), `modifications` it keeps with a reason (still updated, reason shown at each decision). The template never ships or overwrites the file; this skill reads it, offers to record a reason when the operator keeps an edit, migrates the legacy `workspace.json` keys into it, offers to drop entries that no longer describe the file, and stops on a registry that doesn't parse until the operator fixes the JSON
|
|
239
247
|
- The template baseline (`.claude/.template-baseline.json`) is what separates `updated`, `differs`, and `localOnly`: entries hold the payload hash of the last-shipped content (unapplied updates keep the older entry), so a deliberately kept local edit stays visible across updates while an untouched file never prompts
|
|
240
248
|
- The launcher's default branch takes a template-update commit only when the workspace repo has no remote; with ANY remote, the update lands through a task worktree, a branch, and a PR/MR — the default branch is never pushed directly
|
|
241
249
|
- Can be run multiple times safely (idempotent) — if `.workspace-update/` doesn't exist, it reports no payload and exits
|
package/lib/init.test.mjs
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Test for init.mjs (--init scaffolding)
|
|
3
|
-
// Run: node lib/init.test.mjs
|
|
4
|
-
//
|
|
5
|
-
// --init stages the template payload, installs the bootstrap skills plus
|
|
6
|
-
// hooks/scripts/lib, and must leave a template baseline behind: the sha256 of
|
|
7
|
-
// every verbatim payload file, so the first /workspace-update classifies
|
|
8
|
-
// three ways instead of treating every template change as a local edit
|
|
9
|
-
// (gh:183).
|
|
10
|
-
import { initWorkspace } from './init.mjs';
|
|
11
|
-
import { createHash } from 'node:crypto';
|
|
12
|
-
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
|
|
13
|
-
import { join } from 'path';
|
|
14
|
-
import { tmpdir } from 'os';
|
|
15
|
-
import { fileURLToPath } from 'url';
|
|
16
|
-
import { dirname } from 'path';
|
|
17
|
-
|
|
18
|
-
let failed = 0;
|
|
19
|
-
let passed = 0;
|
|
20
|
-
function check(label, ok) {
|
|
21
|
-
if (ok) { passed++; } else {
|
|
22
|
-
failed++;
|
|
23
|
-
console.error(` FAIL: ${label}`);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
28
|
-
const pkgVersion = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')).version;
|
|
29
|
-
const sha = (p) => createHash('sha256').update(readFileSync(p)).digest('hex');
|
|
30
|
-
|
|
31
|
-
const root = mkdtempSync(join(tmpdir(), 'init-test-'));
|
|
32
|
-
|
|
33
|
-
try {
|
|
34
|
-
await initWorkspace(root);
|
|
35
|
-
|
|
36
|
-
const baselinePath = join(root, '.claude', '.template-baseline.json');
|
|
37
|
-
check('baseline written at .claude/.template-baseline.json', existsSync(baselinePath));
|
|
38
|
-
const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
|
|
39
|
-
|
|
40
|
-
check(`baseline records the payload version (${pkgVersion})`, baseline.templateVersion === pkgVersion);
|
|
41
|
-
check(
|
|
42
|
-
'baseline covers the bootstrap skills --init installs',
|
|
43
|
-
typeof baseline.files['.claude/skills/workspace-update/SKILL.md'] === 'string'
|
|
44
|
-
&& typeof baseline.files['.claude/skills/workspace-init/SKILL.md'] === 'string',
|
|
45
|
-
);
|
|
46
|
-
check(
|
|
47
|
-
'baseline covers hooks and scripts',
|
|
48
|
-
typeof baseline.files['.claude/hooks/session-start.mjs'] === 'string'
|
|
49
|
-
&& typeof baseline.files['.claude/scripts/classify-update.mjs'] === 'string',
|
|
50
|
-
);
|
|
51
|
-
check(
|
|
52
|
-
'baseline covers the standalone verbatim files',
|
|
53
|
-
typeof baseline.files['.mcp.json'] === 'string' && typeof baseline.files['.claudeignore'] === 'string',
|
|
54
|
-
);
|
|
55
|
-
check(
|
|
56
|
-
'entry hash matches the installed file byte-for-byte',
|
|
57
|
-
baseline.files['.claude/scripts/classify-update.mjs'] === sha(join(root, '.claude', 'scripts', 'classify-update.mjs')),
|
|
58
|
-
);
|
|
59
|
-
check(
|
|
60
|
-
'baseline never covers tests (the tarball ships none)',
|
|
61
|
-
Object.keys(baseline.files).every((k) => !k.endsWith('.test.mjs')),
|
|
62
|
-
);
|
|
63
|
-
check(
|
|
64
|
-
'baseline never covers machine-local files',
|
|
65
|
-
!('.claude/settings.local.json' in baseline.files) && !('.claude/.active-session.json' in baseline.files),
|
|
66
|
-
);
|
|
67
|
-
} finally {
|
|
68
|
-
rmSync(root, { recursive: true, force: true });
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (failed > 0) {
|
|
72
|
-
console.error(`${failed} check(s) failed, ${passed} passed`);
|
|
73
|
-
process.exit(1);
|
|
74
|
-
}
|
|
75
|
-
console.log(`${passed} checks passed`);
|
package/lib/payload.test.mjs
DELETED
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Unit tests for payload.mjs
|
|
3
|
-
// Run: node lib/payload.test.mjs
|
|
4
|
-
//
|
|
5
|
-
// The staged .workspace-update/ payload is an on-disk format shared with
|
|
6
|
-
// the /workspace-update skill already installed in existing workspaces,
|
|
7
|
-
// which reads .workspace-update/.claude/... and .workspace-update/.mcp.json.
|
|
8
|
-
// These tests pin that layout even though template/ itself stores the
|
|
9
|
-
// protected paths under the inert names _claude/ and _mcp.json. The
|
|
10
|
-
// published-tarball reader (baseline reconstruction's input) is covered
|
|
11
|
-
// here too, against fixture tarballs built in-memory — no network.
|
|
12
|
-
import { stagePayload, cleanPayload, extractTarballEntries, fetchPackageTarball } from './payload.mjs';
|
|
13
|
-
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'fs';
|
|
14
|
-
import { join } from 'path';
|
|
15
|
-
import { tmpdir } from 'os';
|
|
16
|
-
import { gzipSync } from 'zlib';
|
|
17
|
-
|
|
18
|
-
let failed = 0;
|
|
19
|
-
let passed = 0;
|
|
20
|
-
function check(label, ok) {
|
|
21
|
-
if (ok) { passed++; } else {
|
|
22
|
-
failed++;
|
|
23
|
-
console.error(` FAIL: ${label}`);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const root = mkdtempSync(join(tmpdir(), 'payload-test-'));
|
|
28
|
-
|
|
29
|
-
try {
|
|
30
|
-
const { payloadDir, toVersion } = stagePayload(root, { action: 'init' });
|
|
31
|
-
|
|
32
|
-
check('payload staged at .workspace-update/', existsSync(payloadDir));
|
|
33
|
-
check('manifest written', existsSync(join(payloadDir, '.manifest.json')));
|
|
34
|
-
check('returns the package version', typeof toVersion === 'string' && toVersion.length > 0);
|
|
35
|
-
|
|
36
|
-
// The payload keeps the live names older workspaces' skills expect
|
|
37
|
-
check('.claude/ staged under live name', existsSync(join(payloadDir, '.claude')));
|
|
38
|
-
check('.claude/skills/workspace-update/SKILL.md staged', existsSync(join(payloadDir, '.claude', 'skills', 'workspace-update', 'SKILL.md')));
|
|
39
|
-
check('.claude/settings.json staged', existsSync(join(payloadDir, '.claude', 'settings.json')));
|
|
40
|
-
check('.mcp.json staged under live name', existsSync(join(payloadDir, '.mcp.json')));
|
|
41
|
-
check('_gitignore stays inert (skills merge it under that name)', existsSync(join(payloadDir, '_gitignore')));
|
|
42
|
-
|
|
43
|
-
// The template's inert names must not leak into the payload
|
|
44
|
-
check('no _claude/ in payload', !existsSync(join(payloadDir, '_claude')));
|
|
45
|
-
check('no _mcp.json in payload', !existsSync(join(payloadDir, '_mcp.json')));
|
|
46
|
-
|
|
47
|
-
// cleanPayload removes the staging directory
|
|
48
|
-
cleanPayload(root);
|
|
49
|
-
check('cleanPayload removes .workspace-update/', !existsSync(payloadDir));
|
|
50
|
-
} finally {
|
|
51
|
-
rmSync(root, { recursive: true, force: true });
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// ---- extractTarballEntries ----
|
|
55
|
-
|
|
56
|
-
// Minimal ustar writer: 512-byte header + padded body, matching what npm's
|
|
57
|
-
// tarballs carry for paths under 100 chars.
|
|
58
|
-
function tarEntry(name, content, type = '0') {
|
|
59
|
-
const body = Buffer.from(content, 'utf8');
|
|
60
|
-
const header = Buffer.alloc(512);
|
|
61
|
-
header.write(name, 0, 100, 'utf8');
|
|
62
|
-
header.write(body.length.toString(8).padStart(11, '0') + '\0', 124, 'ascii');
|
|
63
|
-
header.write(type, 156, 'ascii');
|
|
64
|
-
header.write('ustar', 257, 'ascii');
|
|
65
|
-
header.write('00', 263, 'ascii');
|
|
66
|
-
let sum = 0;
|
|
67
|
-
for (const byte of header) sum += byte;
|
|
68
|
-
header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 'ascii');
|
|
69
|
-
const pad = Buffer.alloc((512 - (body.length % 512)) % 512);
|
|
70
|
-
return Buffer.concat([header, body, pad]);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function makeTarball(entries) {
|
|
74
|
-
return gzipSync(Buffer.concat([
|
|
75
|
-
...entries.map(([name, content, type]) => tarEntry(name, content, type)),
|
|
76
|
-
Buffer.alloc(1024),
|
|
77
|
-
]));
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// A GNU long-name record ('L') followed by the entry it names.
|
|
81
|
-
function makeTarballWithLongName(longPath, content) {
|
|
82
|
-
const blocks = [
|
|
83
|
-
tarEntry('././@LongLink', longPath, 'L'),
|
|
84
|
-
tarEntry(longPath.slice(0, 20), content),
|
|
85
|
-
Buffer.alloc(1024),
|
|
86
|
-
];
|
|
87
|
-
return gzipSync(Buffer.concat(blocks));
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// A PAX extended header ('x') carrying a path override, then the entry.
|
|
91
|
-
function makeTarballWithPaxPath(path, content) {
|
|
92
|
-
const record = `${path.length + 20} path=${path}\n`;
|
|
93
|
-
const blocks = [
|
|
94
|
-
tarEntry('PaxHeaders/x', record, 'x'),
|
|
95
|
-
tarEntry('short-name', content),
|
|
96
|
-
Buffer.alloc(1024),
|
|
97
|
-
];
|
|
98
|
-
return gzipSync(Buffer.concat(blocks));
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
{
|
|
102
|
-
const dest = mkdtempSync(join(tmpdir(), 'payload-tar-'));
|
|
103
|
-
try {
|
|
104
|
-
const files = extractTarballEntries(makeTarball([
|
|
105
|
-
['template/_claude/hooks/a.mjs', '// a\n'],
|
|
106
|
-
['template/_claude/rules', '', '5'],
|
|
107
|
-
['template/_mcp.json', '{"mcpServers":{}}\n'],
|
|
108
|
-
['template/.claudeignore', 'scratch/\n'],
|
|
109
|
-
['package/package.json', '{"version":"9.9.9"}\n'],
|
|
110
|
-
['README.md', 'not under the prefix\n'],
|
|
111
|
-
]), dest, { prefix: 'template/' });
|
|
112
|
-
check('extracts the three prefixed files', files === 3);
|
|
113
|
-
check('nested file content round-trips',
|
|
114
|
-
readFileSync(join(dest, '_claude', 'hooks', 'a.mjs'), 'utf8') === '// a\n');
|
|
115
|
-
check('directory entry materializes', existsSync(join(dest, '_claude', 'rules')));
|
|
116
|
-
check('entries outside the prefix are not extracted',
|
|
117
|
-
!existsSync(join(dest, '..', 'package')) && !existsSync(join(dest, 'package')));
|
|
118
|
-
check('unprefixed top-level files are not extracted', !existsSync(join(dest, 'README.md')));
|
|
119
|
-
|
|
120
|
-
// A traversal attempt inside the prefix is skipped, not written.
|
|
121
|
-
const guardDest = mkdtempSync(join(tmpdir(), 'payload-tar-'));
|
|
122
|
-
extractTarballEntries(makeTarball([
|
|
123
|
-
['template/../escape.txt', 'nope\n'],
|
|
124
|
-
['template/ok.txt', 'fine\n'],
|
|
125
|
-
]), guardDest, { prefix: 'template/' });
|
|
126
|
-
check('.. paths are refused', !existsSync(join(guardDest, '..', 'escape.txt')));
|
|
127
|
-
check('sibling of .. entry still extracted', existsSync(join(guardDest, 'ok.txt')));
|
|
128
|
-
rmSync(guardDest, { recursive: true, force: true });
|
|
129
|
-
|
|
130
|
-
// GNU long names and PAX path overrides both resolve to the real name.
|
|
131
|
-
const longPath = `template/_claude/scripts/${'very-long-name-'.repeat(8)}.mjs`;
|
|
132
|
-
const longDest = mkdtempSync(join(tmpdir(), 'payload-tar-'));
|
|
133
|
-
extractTarballEntries(makeTarballWithLongName(longPath, '// long\n'), longDest, { prefix: 'template/' });
|
|
134
|
-
check('GNU long-name entries extract under their full path',
|
|
135
|
-
readFileSync(join(longDest, '_claude', 'scripts', `${'very-long-name-'.repeat(8)}.mjs`), 'utf8') === '// long\n');
|
|
136
|
-
rmSync(longDest, { recursive: true, force: true });
|
|
137
|
-
|
|
138
|
-
const paxDest = mkdtempSync(join(tmpdir(), 'payload-tar-'));
|
|
139
|
-
extractTarballEntries(makeTarballWithPaxPath('template/_claude/pax.mjs', '// pax\n'), paxDest, { prefix: 'template/' });
|
|
140
|
-
check('PAX path overrides extract under the overridden path',
|
|
141
|
-
readFileSync(join(paxDest, '_claude', 'pax.mjs'), 'utf8') === '// pax\n');
|
|
142
|
-
rmSync(paxDest, { recursive: true, force: true });
|
|
143
|
-
|
|
144
|
-
// A PAX override that belongs to a NON-file entry (a symlink) dies with
|
|
145
|
-
// it: the file after the link extracts under its own header name, never
|
|
146
|
-
// under the leaked override.
|
|
147
|
-
const leakDest = mkdtempSync(join(tmpdir(), 'payload-tar-'));
|
|
148
|
-
const record = `${'template/_claude/leaked.mjs'.length + 20} path=template/_claude/leaked.mjs\n`;
|
|
149
|
-
extractTarballEntries(makeTarball([
|
|
150
|
-
['PaxHeaders/x', record, 'x'],
|
|
151
|
-
['target', 'template/elsewhere.mjs', '2'],
|
|
152
|
-
['template/_claude/own-name.mjs', '// own\n'],
|
|
153
|
-
]), leakDest, { prefix: 'template/' });
|
|
154
|
-
check('a PAX name followed by a non-file entry does not leak',
|
|
155
|
-
!existsSync(join(leakDest, '_claude', 'leaked.mjs'))
|
|
156
|
-
&& !existsSync(join(leakDest, '_claude', 'elsewhere.mjs')));
|
|
157
|
-
check('the entry after a discarded override uses its own name',
|
|
158
|
-
readFileSync(join(leakDest, '_claude', 'own-name.mjs'), 'utf8') === '// own\n');
|
|
159
|
-
rmSync(leakDest, { recursive: true, force: true });
|
|
160
|
-
|
|
161
|
-
// Tar names written on Windows carry '\' separators — treated as
|
|
162
|
-
// directory separators like '/'.
|
|
163
|
-
const bsDest = mkdtempSync(join(tmpdir(), 'payload-tar-'));
|
|
164
|
-
extractTarballEntries(makeTarball([
|
|
165
|
-
['template\\_claude\\hooks\\win.mjs', '// win\n'],
|
|
166
|
-
]), bsDest, { prefix: 'template/' });
|
|
167
|
-
check('backslash-separated entry names extract as directories',
|
|
168
|
-
readFileSync(join(bsDest, '_claude', 'hooks', 'win.mjs'), 'utf8') === '// win\n');
|
|
169
|
-
rmSync(bsDest, { recursive: true, force: true });
|
|
170
|
-
} finally {
|
|
171
|
-
rmSync(dest, { recursive: true, force: true });
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// fetchPackageTarball rejects non-semver versions before any network or
|
|
176
|
-
// npm call — a workspace.json value is user input.
|
|
177
|
-
{
|
|
178
|
-
const bogus = await fetchPackageTarball('0.15.0; rm -rf /');
|
|
179
|
-
check('non-semver version fetches nothing', bogus === null);
|
|
180
|
-
const alsoBogus = await fetchPackageTarball('');
|
|
181
|
-
check('empty version fetches nothing', alsoBogus === null);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// The registry fetch is bounded: a hung connection must not stall an
|
|
185
|
-
// upgrade indefinitely (reconstruction falls back to asking per file).
|
|
186
|
-
{
|
|
187
|
-
const realFetch = globalThis.fetch;
|
|
188
|
-
const tarball = makeTarball([['package/template/.claudeignore', 'x/\n']]);
|
|
189
|
-
let seenInit = null;
|
|
190
|
-
globalThis.fetch = async (url, init) => {
|
|
191
|
-
seenInit = { url, init };
|
|
192
|
-
return { ok: true, arrayBuffer: async () => tarball };
|
|
193
|
-
};
|
|
194
|
-
try {
|
|
195
|
-
const bytes = await fetchPackageTarball('0.15.0');
|
|
196
|
-
check('registry fetch returns the tarball bytes', bytes !== null && bytes.equals(tarball));
|
|
197
|
-
check('registry fetch targets the versioned tarball URL',
|
|
198
|
-
seenInit !== null && seenInit.url.endsWith('/create-workspace-0.15.0.tgz'));
|
|
199
|
-
check('registry fetch carries an abort timeout signal',
|
|
200
|
-
seenInit !== null && seenInit.init?.signal instanceof AbortSignal);
|
|
201
|
-
} finally {
|
|
202
|
-
globalThis.fetch = realFetch;
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (failed > 0) {
|
|
207
|
-
console.error(`${failed} check(s) failed, ${passed} passed`);
|
|
208
|
-
process.exit(1);
|
|
209
|
-
}
|
|
210
|
-
console.log(`${passed} checks passed`);
|
package/lib/scaffold.test.mjs
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// End-to-end test for scaffold.mjs
|
|
3
|
-
// Run: node lib/scaffold.test.mjs
|
|
4
|
-
//
|
|
5
|
-
// The template stores .gitignore, .claude/, and .mcp.json under the inert
|
|
6
|
-
// names _gitignore, _claude/, and _mcp.json (Claude Code protects the live
|
|
7
|
-
// names from headless edits). scaffold() must install every one under its
|
|
8
|
-
// live name and leave no inert leftovers behind.
|
|
9
|
-
import { scaffold } from './scaffold.mjs';
|
|
10
|
-
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
|
|
11
|
-
import { join } from 'path';
|
|
12
|
-
import { tmpdir } from 'os';
|
|
13
|
-
|
|
14
|
-
let failed = 0;
|
|
15
|
-
let passed = 0;
|
|
16
|
-
function check(label, ok) {
|
|
17
|
-
if (ok) { passed++; } else {
|
|
18
|
-
failed++;
|
|
19
|
-
console.error(` FAIL: ${label}`);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const directory = mkdtempSync(join(tmpdir(), 'scaffold-test-'));
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
await scaffold({
|
|
27
|
-
name: 'demo',
|
|
28
|
-
directory,
|
|
29
|
-
repos: [],
|
|
30
|
-
userName: 'tester',
|
|
31
|
-
activateRules: [],
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
// Live names installed
|
|
35
|
-
check('.claude/ directory installed', existsSync(join(directory, '.claude')));
|
|
36
|
-
check('.claude/settings.json installed', existsSync(join(directory, '.claude', 'settings.json')));
|
|
37
|
-
check('.claude/settings.local.json written', existsSync(join(directory, '.claude', 'settings.local.json')));
|
|
38
|
-
check('.claude/rules/ installed', existsSync(join(directory, '.claude', 'rules')));
|
|
39
|
-
check('.claude/rules/coherent-revisions.md installed', existsSync(join(directory, '.claude', 'rules', 'coherent-revisions.md')));
|
|
40
|
-
check('.mcp.json installed', existsSync(join(directory, '.mcp.json')));
|
|
41
|
-
check('.gitignore installed', existsSync(join(directory, '.gitignore')));
|
|
42
|
-
|
|
43
|
-
// .mcp.json is the template's payload, unmodified
|
|
44
|
-
const mcp = JSON.parse(readFileSync(join(directory, '.mcp.json'), 'utf8'));
|
|
45
|
-
check('.mcp.json parses with mcpServers object', typeof mcp.mcpServers === 'object' && mcp.mcpServers !== null);
|
|
46
|
-
|
|
47
|
-
// Inert names are gone
|
|
48
|
-
check('no _claude/ leftover', !existsSync(join(directory, '_claude')));
|
|
49
|
-
check('no _mcp.json leftover', !existsSync(join(directory, '_mcp.json')));
|
|
50
|
-
check('no _gitignore leftover', !existsSync(join(directory, '_gitignore')));
|
|
51
|
-
|
|
52
|
-
// Template baseline: hashes of the verbatim-installed files, for three-way
|
|
53
|
-
// update classification (gh:183)
|
|
54
|
-
const baselinePath = join(directory, '.claude', '.template-baseline.json');
|
|
55
|
-
check('template baseline written', existsSync(baselinePath));
|
|
56
|
-
const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
|
|
57
|
-
const pkgVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
58
|
-
check(`baseline records the package version (${pkgVersion})`, baseline.templateVersion === pkgVersion);
|
|
59
|
-
check(
|
|
60
|
-
'baseline covers rules installed verbatim from the template',
|
|
61
|
-
typeof baseline.files['.claude/rules/coherent-revisions.md'] === 'string',
|
|
62
|
-
);
|
|
63
|
-
check(
|
|
64
|
-
'baseline covers .mcp.json via its inert source name',
|
|
65
|
-
typeof baseline.files['.mcp.json'] === 'string' && typeof baseline.files['.claudeignore'] === 'string',
|
|
66
|
-
);
|
|
67
|
-
check(
|
|
68
|
-
'baseline never covers tests or machine-local files',
|
|
69
|
-
Object.keys(baseline.files).every((k) => !k.endsWith('.test.mjs') && k !== '.claude/settings.local.json'),
|
|
70
|
-
);
|
|
71
|
-
} finally {
|
|
72
|
-
rmSync(directory, { recursive: true, force: true });
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (failed > 0) {
|
|
76
|
-
console.error(`${failed} check(s) failed, ${passed} passed`);
|
|
77
|
-
process.exit(1);
|
|
78
|
-
}
|
|
79
|
-
console.log(`${passed} checks passed`);
|