@ulysses-ai/create-workspace 0.23.3-beta.0 → 0.24.1-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.
Files changed (35) hide show
  1. package/package.json +2 -1
  2. package/template/CLAUDE.md.tmpl +2 -2
  3. package/template/_claude/hooks/_utils.mjs +70 -0
  4. package/template/_claude/hooks/session-start.mjs +21 -1
  5. package/template/_claude/rules/work-item-tracking.md +6 -3
  6. package/template/_claude/rules/workspace-structure.md +1 -1
  7. package/template/_claude/scripts/chat-record.mjs +43 -26
  8. package/template/_claude/scripts/classify-update.mjs +194 -32
  9. package/template/_claude/scripts/forges/github.mjs +51 -1
  10. package/template/_claude/scripts/forges/gitlab.mjs +69 -0
  11. package/template/_claude/scripts/forges/interface.mjs +19 -2
  12. package/template/_claude/scripts/maintenance-audit.mjs +325 -3
  13. package/template/_claude/scripts/merge-mode.mjs +22 -1
  14. package/template/_claude/scripts/release-leak-audit.mjs +480 -0
  15. package/template/_claude/scripts/task-pr.mjs +316 -72
  16. package/template/_claude/scripts/task-worktree.mjs +9 -1
  17. package/template/_claude/scripts/template-merge.mjs +21 -4
  18. package/template/_claude/scripts/template-modifications.mjs +152 -0
  19. package/template/_claude/scripts/trackers/github-issues.mjs +110 -1
  20. package/template/_claude/scripts/trackers/gitlab-issues.mjs +221 -2
  21. package/template/_claude/scripts/trackers/interface.mjs +47 -0
  22. package/template/_claude/skills/braindump/SKILL.md +1 -1
  23. package/template/_claude/skills/complete-work/SKILL.md +22 -12
  24. package/template/_claude/skills/handoff/SKILL.md +1 -1
  25. package/template/_claude/skills/maintenance/SKILL.md +8 -3
  26. package/template/_claude/skills/release/SKILL.md +20 -6
  27. package/template/_claude/skills/setup-tracker/SKILL.md +24 -1
  28. package/template/_claude/skills/start-work/SKILL.md +23 -5
  29. package/template/_claude/skills/workspace-init/SKILL.md +51 -9
  30. package/template/_claude/skills/workspace-update/SKILL.md +23 -15
  31. package/template/workspace.json.tmpl +2 -1
  32. package/lib/init.test.mjs +0 -75
  33. package/lib/payload.test.mjs +0 -210
  34. package/lib/scaffold.test.mjs +0 -79
  35. package/lib/upgrade.test.mjs +0 -547
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulysses-ai/create-workspace",
3
- "version": "0.23.3-beta.0",
3
+ "version": "0.24.1-beta.0",
4
4
  "description": "A workspace convention for Claude Code: sessions, handoffs, and shared context as files in git",
5
5
  "keywords": [
6
6
  "claude",
@@ -31,6 +31,7 @@
31
31
  "files": [
32
32
  "bin/",
33
33
  "lib/",
34
+ "!lib/**/*.test.mjs",
34
35
  "template/"
35
36
  ],
36
37
  "scripts": {
@@ -11,7 +11,7 @@ This is a claude-workspace. All conventions are defined in .claude/rules/.
11
11
  - Run `/rename` to label this chat before `/clear` — keeps the chat findable in your CLI history after the context is wiped
12
12
 
13
13
  ## Workspace Config
14
- @workspace.json
14
+ Config lives in `workspace.json` — read it when a skill needs a value.
15
15
  @local-only-template-freshness.md
16
16
 
17
17
  ## Team Knowledge
@@ -52,4 +52,4 @@ Drop from the summary:
52
52
  - Exploratory reads that did not lead to a change
53
53
  - Conversational asides
54
54
 
55
- Files in `workspace-context/`, the session tracker (`session.md`) or chat drawer (`workspace-scratchpad/chats/{chat}/`), and any `design-*.md`/`plan-*.md` survive compaction — they're on disk. Re-read them on demand instead of trying to keep their contents in summary.
55
+ Files in `workspace-context/`, the session tracker (`session.md`) or chat drawer (`workspace-scratchpad/chats/{chat}/`), and any `design-*.md`/`plan-*.md` survive compaction — they're on disk; re-read them on demand.
@@ -50,6 +50,76 @@ export function getWorkspacePaths(root) {
50
50
  };
51
51
  }
52
52
 
53
+ const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
54
+
55
+ /**
56
+ * The `Workspace config (workspace.json):` summary line session-start injects
57
+ * (gh:196): sessionModel, tracker, forge, and the repo names (primary
58
+ * marked). The labels mirror what the scripts reading workspace.json actually
59
+ * do — no invented defaults: an absent tracker is `off`, a tracker object
60
+ * without a type (or a non-object in its place) is `invalid`, an absent
61
+ * forge type leaves each repo's origin to pick its adapter, and `repos` that
62
+ * isn't a plain object is `invalid` rather than a silent empty manifest.
63
+ * sessionModel defaults to "session", matching how /start-work routes absent
64
+ * config.
65
+ */
66
+ export function configSummary(config) {
67
+ const w = config?.workspace || {};
68
+ const model = typeof w.sessionModel === 'string' && w.sessionModel ? w.sessionModel : 'session';
69
+ const parts = [`sessionModel: ${model}`];
70
+
71
+ let tracker;
72
+ if (!w.tracker) tracker = 'off';
73
+ else if (!isPlainObject(w.tracker)) tracker = 'invalid';
74
+ else if (typeof w.tracker.type !== 'string' || !w.tracker.type) tracker = 'invalid (no type)';
75
+ else tracker = w.tracker.repo ? `${w.tracker.type} on ${w.tracker.repo}` : w.tracker.type;
76
+ parts.push(`tracker: ${tracker}`);
77
+
78
+ let forge;
79
+ if (w.forge === false) forge = 'off';
80
+ else if (!isPlainObject(w.forge)) forge = w.forge ? 'invalid' : 'auto (from origin)';
81
+ else if (typeof w.forge.type !== 'string' || !w.forge.type) forge = 'auto (from origin)';
82
+ else forge = w.forge.type;
83
+ parts.push(`forge: ${forge}`);
84
+
85
+ let repos;
86
+ if (!isPlainObject(config?.repos)) repos = config?.repos ? 'invalid' : 'none';
87
+ else {
88
+ const names = Object.keys(config.repos);
89
+ repos = names.length === 0 ? 'none'
90
+ : names.map((n) => (config.repos[n]?.primary ? `${n} (primary)` : n)).join(', ');
91
+ }
92
+ parts.push(`repos: ${repos}`);
93
+ return parts.join(' | ');
94
+ }
95
+
96
+ /**
97
+ * The one line session-start prints when this machine's per-user setup is
98
+ * incomplete (gh:130), or null when nothing is missing. A clone of a
99
+ * workspace a teammate already initialized is the common case: repos/ is
100
+ * empty and there is no user identity yet. /workspace-init handles that as
101
+ * a join — it no longer refuses an initialized workspace — so the hint says
102
+ * "join" there and keeps the plain "initialize" wording for a workspace
103
+ * that was never initialized.
104
+ *
105
+ * @param {object} s
106
+ * @param {boolean} s.initialized workspace.json → workspace.initialized
107
+ * @param {string[]} s.missingRepos repos in workspace.json absent under repos/
108
+ * @param {boolean} s.hasUser settings.local.json carries workspace.user
109
+ * @param {boolean} s.hasClaudeLocal CLAUDE.local.md exists
110
+ */
111
+ export function setupHint({ initialized, missingRepos = [], hasUser, hasClaudeLocal }) {
112
+ const gaps = [];
113
+ if (missingRepos.length > 0) gaps.push(`missing repos: ${missingRepos.join(', ')}`);
114
+ if (!hasUser) gaps.push('no user set in .claude/settings.local.json');
115
+ else if (!hasClaudeLocal) gaps.push('no CLAUDE.local.md');
116
+ if (gaps.length === 0) return null;
117
+ const action = initialized
118
+ ? 'Run /workspace-init to join this workspace — it sets up only what this machine is missing'
119
+ : 'Run /workspace-init to initialize this workspace';
120
+ return `Setup incomplete (${gaps.join('; ')}). ${action}.`;
121
+ }
122
+
53
123
  /**
54
124
  * Defensive normalization for a session tracker's `repos` field. A fresh
55
125
  * tracker written by create-work-session always holds an array, but an
@@ -15,6 +15,8 @@ import {
15
15
  updateSessionTracker,
16
16
  sessionFolderPath,
17
17
  timeAgo,
18
+ configSummary,
19
+ setupHint,
18
20
  } from './_utils.mjs';
19
21
  import { reconcile, readSessionRegistry, resolveChatName } from '../scripts/chat-record.mjs';
20
22
 
@@ -35,6 +37,15 @@ if (!config) {
35
37
 
36
38
  lines.push(`Workspace: ${config.workspace?.name || 'unnamed'}`);
37
39
 
40
+ // One compact config summary (gh:196). CLAUDE.md no longer imports
41
+ // workspace.json, so this line is what a session sees of the config without
42
+ // reading the file — it names the file so sessions know where the values
43
+ // live, and skills that need more than these fields read it on demand. Keep
44
+ // it small: every session pays for it, which is exactly why the import was
45
+ // dropped. configSummary (_utils.mjs) prints invalid/absent states rather
46
+ // than inventing defaults the scripts don't apply.
47
+ lines.push(`Workspace config (workspace.json): ${configSummary(config)}`);
48
+
38
49
  // Keep this chat's record in step with its identity (gh:132). The record is
39
50
  // keyed on sessionId and filed under the chat name, so a rename moves the file
40
51
  // and its drawer rather than orphaning them.
@@ -115,7 +126,16 @@ for (const name of repoNames) {
115
126
  missing.push(name);
116
127
  }
117
128
  }
118
- if (missing.length > 0) lines.push(`Missing repos: ${missing.join(', ')}. Run /workspace-init to clone them.`);
129
+ // A teammate's fresh clone of an initialized workspace has no repos/ and no
130
+ // user identity; point it at /workspace-init's join flow (gh:130).
131
+ const localSettings = readJSON(join(root, '.claude', 'settings.local.json'));
132
+ const hint = setupHint({
133
+ initialized: config.workspace?.initialized === true,
134
+ missingRepos: missing,
135
+ hasUser: typeof localSettings?.workspace?.user === 'string' && localSettings.workspace.user !== '',
136
+ hasClaudeLocal: existsSync(join(root, 'CLAUDE.local.md')),
137
+ });
138
+ if (hint) lines.push(hint);
119
139
  if (existing.length > 0) lines.push(`Repos synced: ${existing.join(', ')}`);
120
140
 
121
141
  // Surface active work sessions
@@ -15,9 +15,12 @@ humans and Claude read the same list in the same place.
15
15
  ## Configuration
16
16
 
17
17
  `workspace.json` → `workspace.tracker`: `{ "type": "github-issues", "repo": "owner/name" }`.
18
- `type` names the adapter at `.claude/scripts/trackers/{type}.mjs`; only `github-issues` ships.
19
- `repo` is adapter-specific — for GitHub, the slug where issues live, or `"auto"` to resolve
20
- from the git remote. No `workspace.tracker` means tracking is disabled, and skills fall back
18
+ `type` names the adapter at `.claude/scripts/trackers/{type}.mjs`; the template ships
19
+ `github-issues` and `gitlab-issues`. `repo` is adapter-specific — for GitHub, the slug where
20
+ issues live, or `"auto"` to resolve from the git remote. Epics are configured with `epics`
21
+ (label mode by default, `"native"` for GitLab Premium group epics) and `epicLabelPrefix`
22
+ (default `epic:`) — an issue carries exactly one epic, and assigning an unknown epic name
23
+ throws. No `workspace.tracker` means tracking is disabled, and skills fall back
21
24
  to a blank describe-the-work flow rather than fabricating a local mirror.
22
25
 
23
26
  ## Session linkage
@@ -28,7 +28,7 @@ Canonical loads verbatim into every session (`CLAUDE.md` → `@workspace-context
28
28
 
29
29
  ## Dynamic context loading (hooks)
30
30
 
31
- - **`session-start.mjs`** (`SessionStart`): injects the workspace name, a `Chat record:` line naming this chat's record, and a `Workspace root:` line with the launcher's absolute path (git-derived roots land on the source clone from inside a task worktree); with an active session pointer, also the session's name, branch, work item, and shared-context catalog.
31
+ - **`session-start.mjs`** (`SessionStart`): injects the workspace name, a `Workspace config (workspace.json):` line, a `Chat record:` line for this chat, and a `Workspace root:` line with the launcher's path (git-derived roots land on the source clone in a task worktree); with an active session, also its name, branch, work item, and shared-context catalog.
32
32
  - **`subagent-start.mjs`** (`SubagentStart`): gives subagents the canonical truths they miss (subagents do not load `CLAUDE.md`) — locked files under `workspace.subagentInlineMaxBytes` (8192) are inlined with frontmatter stripped, larger ones become pointers, and past `workspace.subagentContextMaxBytes` (32768) the largest demote first. Gitignored and `local-only-*` files are excluded.
33
33
 
34
34
  Both are Node.js scripts — cross-platform, no shell dependency.
@@ -23,19 +23,19 @@
23
23
  // node chat-record.mjs --root <dir> --whoami
24
24
  // node chat-record.mjs --root <dir> --owner <work-item>
25
25
  // node chat-record.mjs --root <dir> --add-task --chat <n> --branch <b> [--repo <r>] [--work-item <id>]
26
- // node chat-record.mjs --root <dir> --remove-task --chat <n> (--work-item <id> | --branch <b>) [--repo <r>]
26
+ // node chat-record.mjs --root <dir> --remove-task --chat <n> (--work-item <id> [--branch <b>] | --branch <b>) [--repo <r>]
27
27
  //
28
- // --add-task / --remove-task report `action`: "added" | "updated" for
29
- // --add-task, "removed" | "unchanged" for --remove-task. They also keep the
30
- // older `updated` boolean / `removed` count fields — `updated` means "an
31
- // existing entry was replaced", not "anything changed" — but new callers
32
- // should read `action`. --whoami prints this chat's record name by matching
28
+ // --add-task / --remove-task report `action`: "added" | "unchanged" for
29
+ // --add-task, "removed" | "unchanged" for --remove-task, which also reports
30
+ // a `removed` count — removing by work item without a branch takes every
31
+ // branch of the item. --whoami prints this chat's record name by matching
33
32
  // $CLAUDE_CODE_SESSION_ID against the records' sessionId, nothing and exit
34
33
  // 1 when there is no match: the `Chat record:` hook line can be missing
35
34
  // after context compaction, and this is the recovery path. --owner prints
36
- // the chat a work item's open task belongs to as `{ chat, branch, repos }`,
37
- // and nothing with exit 1 when no record lists it — /start-work uses it to
38
- // offer adopting a task another chat started (gh:188).
35
+ // the chat a work item's open task belongs to as `{ chat, branches }` — one
36
+ // `{ branch, repos }` per branch the item is open on — and nothing with
37
+ // exit 1 when no record lists it: /start-work uses it to offer adopting a
38
+ // task another chat started (gh:188).
39
39
 
40
40
  import {
41
41
  readFileSync, writeFileSync, existsSync, mkdirSync,
@@ -200,35 +200,43 @@ function resolveChatName(root, { sessionId, registryName = null } = {}) {
200
200
  // merges — nothing about it is durable except the issue and the commits, so
201
201
  // the record holds only the pointer, never a copy of the issue.
202
202
  //
203
- // Identity is workItem + repo: the same issue can legitimately be open against
204
- // two repos in a multi-repo task, and re-recording the same one must update
205
- // rather than duplicate. /start-work is not always run exactly once. A task
206
- // recorded without a tracker (gh:173) carries `workItem: null` and is keyed
207
- // by branch + repo instead — the branch is the only identity it has — so
208
- // both add and remove take the branch for those entries.
203
+ // Identity is workItem + repo + branch (gh:206). The same issue can
204
+ // legitimately be open against two repos in a multi-repo task, and equally
205
+ // on two branches of the same repo — a second attempt, a scoped alternative
206
+ // — each with its own worktree and its own PR, so a different branch is
207
+ // added alongside rather than replacing what was there, and re-recording
208
+ // the same triple is a no-op. /start-work is not always run exactly once. A
209
+ // task recorded without a tracker (gh:173) carries `workItem: null` and is
210
+ // keyed by branch + repo instead — the branch is the only identity it has —
211
+ // so add always takes the branch, and remove by branch alone touches only
212
+ // those tracker-less entries.
209
213
  function addTask(root, chatName, { workItem = null, branch, repo = null } = {}) {
210
214
  if (!branch) throw new Error('addTask: branch is required');
211
215
  const rec = readRecord(root, chatName);
212
216
  if (!rec) throw new Error(`addTask: no chat record for "${chatName}"`);
213
217
  const i = rec.tasks.findIndex(
214
- (t) => (t.workItem ?? null) === workItem && (t.repo ?? null) === repo
215
- && (workItem !== null || t.branch === branch),
218
+ (t) => (t.workItem ?? null) === workItem && (t.repo ?? null) === repo && t.branch === branch,
216
219
  );
217
- const task = { workItem, branch, repo };
218
- if (i >= 0) rec.tasks[i] = task;
219
- else rec.tasks.push(task);
220
+ if (i < 0) rec.tasks.push({ workItem, branch, repo });
220
221
  writeRecord(root, rec);
221
- return { record: rec, action: i >= 0 ? 'updated' : 'added', updated: i >= 0 };
222
+ return { record: rec, action: i >= 0 ? 'unchanged' : 'added' };
222
223
  }
223
224
 
225
+ // Removal selects by the same identity, one dimension at a time: a given
226
+ // work item matches only its own entries (absent, only tracker-less ones),
227
+ // while a given branch or repo narrows within whatever the work item
228
+ // selected — an omitted branch or repo matches any. `--work-item` with
229
+ // neither takes every branch of the item across repos; `--branch` narrows
230
+ // the removal to that one branch.
224
231
  function removeTask(root, chatName, { workItem = null, branch = null, repo = null } = {}) {
225
232
  if (workItem === null && !branch) throw new Error('removeTask: workItem or branch is required');
226
233
  const rec = readRecord(root, chatName);
227
234
  if (!rec) throw new Error(`removeTask: no chat record for "${chatName}"`);
228
235
  const before = rec.tasks.length;
229
236
  rec.tasks = rec.tasks.filter(
230
- (t) => !((t.workItem ?? null) === workItem && (t.repo ?? null) === repo
231
- && (workItem !== null || t.branch === branch)),
237
+ (t) => !((t.workItem ?? null) === workItem
238
+ && (branch === null || t.branch === branch)
239
+ && (repo === null || (t.repo ?? null) === repo)),
232
240
  );
233
241
  writeRecord(root, rec);
234
242
  return { record: rec, action: before - rec.tasks.length > 0 ? 'removed' : 'unchanged', removed: before - rec.tasks.length };
@@ -252,6 +260,11 @@ function whoami(root, { env = process.env } = {}) {
252
260
  // by, so it doubles as the address for "ask the owner". Records are scanned
253
261
  // most-recently-modified first, so a stray duplicate answers with the chat
254
262
  // that touched its record last. Null when no record lists the work item.
263
+ //
264
+ // One issue may be open on several branches (gh:206), so the answer carries
265
+ // one `{ branch, repos }` per branch — a multi-repo task is several entries
266
+ // sharing a branch — rather than one flattened branch that would hide the
267
+ // rest behind whichever entry happened to come first.
255
268
  function findOwner(root, workItem) {
256
269
  if (!workItem) return null;
257
270
  const dir = chatsDir(root);
@@ -268,9 +281,13 @@ function findOwner(root, workItem) {
268
281
  ? rec.tasks.filter((t) => (t.workItem ?? null) === workItem)
269
282
  : [];
270
283
  if (hits.length === 0) continue;
271
- // One issue is one task: a shared branch across repos. All matching
272
- // entries are that task's repos.
273
- return { chat: rec.chat, branch: hits[0].branch, repos: hits.map((t) => t.repo) };
284
+ const branches = [];
285
+ for (const t of hits) {
286
+ const b = branches.find((x) => x.branch === t.branch);
287
+ if (b) { if (!b.repos.includes(t.repo)) b.repos.push(t.repo); }
288
+ else branches.push({ branch: t.branch, repos: [t.repo] });
289
+ }
290
+ return { chat: rec.chat, branches };
274
291
  }
275
292
  return null;
276
293
  }
@@ -18,7 +18,10 @@
18
18
  // Pass it explicitly in the worktree flow, where <root> is the
19
19
  // worktree and the launcher's baseline may not be reachable.
20
20
  //
21
- // The default mode prints JSON with these lists:
21
+ // The default mode prints JSON with these lists; unless a bullet says
22
+ // otherwise an entry is a plain path string — entries in differs, localOnly,
23
+ // deletedLocally, and removed may instead be { file, reason } objects when
24
+ // the registry claims the path (gh:194, marker paragraph below).
22
25
  // new — no installed counterpart and no baseline entry; safe to
23
26
  // batch-apply after one confirm
24
27
  // identical — installed file already equals the payload
@@ -64,16 +67,23 @@
64
67
  // the config files above (the template dropping one hands it
65
68
  // to the workspace, it never deletes user content),
66
69
  // *.test.mjs (see staleTests), anything gitignored
67
- // (machine-local), paths under .claude/worktrees/, and entries
68
- // of workspace.json → workspace.localFiles (array of
69
- // .claude/-relative paths or globs for files this workspace
70
- // owns) (gh:180). Two markers refine the per-file offer
71
- // (gh:190): `{ file, referencedBy }` — a removed hook that a
72
- // workspace-only settings.json entry still registers (the
73
- // config-diff paths); the skill removes file and settings
74
- // entry together. `{ file, userOwned: true }` — no baseline
75
- // entry, so the template never shipped it: the workspace's
76
- // own, offered a workspace.localFiles entry, not deletion.
70
+ // (machine-local), paths under .claude/worktrees/,
71
+ // .claude/template-modifications.json (the workspace's own
72
+ // registry, never shipped), and the registry's `localFiles`
73
+ // entries (.claude/-relative paths or globs for files this
74
+ // workspace owns; the legacy workspace.json →
75
+ // workspace.localFiles array still counts, gh:180/gh:194).
76
+ // Markers refine the per-file offer: `{ file, referencedBy }`
77
+ // (gh:190) — a removed hook that a workspace-only
78
+ // settings.json entry still registers (the config-diff
79
+ // paths); the skill removes file and settings entry together.
80
+ // `{ file, userOwned: true }` — no baseline entry, so the
81
+ // template never shipped it: the workspace's own, offered a
82
+ // localFiles entry in the registry, not deletion.
83
+ // `{ file, unverifiable: true }` — the registry file doesn't
84
+ // parse, so its ownership claims cannot be read and no
85
+ // removal can be proven unowned; the skill stops and asks
86
+ // for the JSON to be fixed first (gh:194).
77
87
  // staleTests — *.test.mjs files under .claude/ with no payload counterpart.
78
88
  // The npm tarball ships no tests, so these came from a dev
79
89
  // checkout and are never updated by /workspace-update; the
@@ -86,6 +96,39 @@
86
96
  // An upgrade from inside that window into a workspace.json
87
97
  // without the key reports { key, value, reason } so the skill
88
98
  // writes the value explicitly (gh:190).
99
+ // staleModifications — registry entries whose installed file now equals
100
+ // the payload: the workspace already took the template's
101
+ // version, so the recorded reason describes nothing. Each
102
+ // reports { file, reason } so the skill can offer dropping
103
+ // the entry; file is the .claude/-prefixed path — the
104
+ // registry key is that path with the prefix stripped — and a
105
+ // key naming a directory (or normalizing away) never reports,
106
+ // only regular files compare (gh:194).
107
+ // legacyKeys — template-modification data still sitting in workspace.json
108
+ // (`workspace.localFiles`, `workspace.templateModifications`)
109
+ // instead of .claude/template-modifications.json; the skill
110
+ // offers the migration (gh:194).
111
+ // ignoredKeys — registry keys that escape .claude/ ('../CLAUDE.md',
112
+ // absolute paths, drive letters): the registry covers .claude/
113
+ // paths only — root files (CLAUDE.md, .mcp.json,
114
+ // .claudeignore) are handled by their own merge paths — so
115
+ // these report for the operator to fix instead of sitting
116
+ // silently inert (gh:194).
117
+ //
118
+ // Entries in differs, localOnly, deletedLocally, and removed carry the
119
+ // workspace's registered modification reason (gh:194): a path listed in
120
+ // .claude/template-modifications.json's `modifications` map (read through
121
+ // template-modifications.mjs, which also honors the legacy workspace.json
122
+ // keys for one release) becomes { file, reason } in those lists — reason
123
+ // undefined entries stay plain paths, and a removed entry's existing
124
+ // referencedBy/userOwned markers keep their fields alongside `reason`.
125
+ // `modificationsError`, present only when the registry file doesn't parse,
126
+ // names the parse error: reasons and localFiles exclusions from the file are
127
+ // then unavailable (legacy keys still apply) and classification FAILS
128
+ // CLOSED — every removed entry is marked `unverifiable: true`, because the
129
+ // file's ownership claims cannot be read, and the skill stops and asks the
130
+ // operator to fix the JSON before any per-file decision. Nothing is applied
131
+ // or deleted silently.
89
132
  //
90
133
  // Plus `hasBaseline`: whether a usable baseline was found, `baselineSource`
91
134
  // (which file it came from) and `baselineReconstructed`. The default
@@ -117,15 +160,18 @@
117
160
  // present as `updated` again next time; see
118
161
  // template-baseline.mjs. Throws rather than writing an
119
162
  // empty baseline.
120
- // --merge-claude-md print JSON { claudeMd, missingIncludes }: CLAUDE.md
121
- // with the payload's CLAUDE.md.tmpl merged in — template
122
- // lines updated, the workspace's own lines (custom skill
123
- // entries, sections) kept — plus the `@{path}` include
124
- // lines the merged file carries whose targets don't
125
- // exist at the root (machine-local local-only-* targets
126
- // exempt). The skill shows the diff against the current
127
- // file before writing, and asks on each missing include
128
- // instead of leaving it dangling (gh:190).
163
+ // --merge-claude-md print JSON { claudeMd, droppedIncludes, missingIncludes }:
164
+ // CLAUDE.md with the payload's CLAUDE.md.tmpl merged in —
165
+ // template lines updated, the workspace's own lines (custom
166
+ // skill entries, sections) kept — minus the retired
167
+ // @-includes the workspace still carries but this payload
168
+ // no longer ships (droppedIncludes names them, gh:196) —
169
+ // plus the `@{path}` include lines the merged file carries
170
+ // whose targets don't exist at the root (machine-local
171
+ // local-only-* targets exempt). The skill shows the diff
172
+ // against the current file before writing, and asks on
173
+ // each missing include instead of leaving it dangling
174
+ // (gh:190).
129
175
 
130
176
  import {
131
177
  existsSync,
@@ -145,6 +191,15 @@ import {
145
191
  readBaselineFile,
146
192
  writeBaseline,
147
193
  } from './template-baseline.mjs';
194
+ // The workspace's template-modification registry (gh:194): localFiles
195
+ // exclusions, registered modification reasons, keys that escape .claude/,
196
+ // the legacy workspace.json keys still present, and a parse error when the
197
+ // file is broken. The module documents the shape, the path normalization,
198
+ // and the one-release legacy fallback.
199
+ import {
200
+ readTemplateModifications,
201
+ TEMPLATE_MODIFICATIONS_PATH,
202
+ } from './template-modifications.mjs';
148
203
 
149
204
  function isMainModule(metaUrl) {
150
205
  if (!process.argv[1]) return false;
@@ -308,18 +363,37 @@ function* walkInstalledFiles(absRoot) {
308
363
  }
309
364
  }
310
365
 
311
- /** workspace.json → workspace.localFiles, normalized to .claude/-relative globs. */
312
- function readLocalFiles(absRoot) {
313
- const configPath = join(absRoot, 'workspace.json');
366
+ /**
367
+ * A classification path's registered modification reason, or undefined.
368
+ * Registry keys are .claude/-relative, so only paths under .claude/ can be
369
+ * registered — .mcp.json and .claudeignore live outside it and route to
370
+ * their own lists anyway.
371
+ */
372
+ function registeredReason(modifications, rel) {
373
+ if (!rel.startsWith('.claude/')) return undefined;
374
+ const reason = modifications[rel.slice('.claude/'.length)];
375
+ return typeof reason === 'string' ? reason : undefined;
376
+ }
377
+
378
+ /** Attach registered reasons to a list of plain-path entries (gh:194). */
379
+ function withReasons(list, modifications) {
380
+ return list.map((rel) => {
381
+ const reason = registeredReason(modifications, rel);
382
+ return reason === undefined ? rel : { file: rel, reason };
383
+ });
384
+ }
385
+
386
+ /**
387
+ * A registry key may name a directory or a path that is gone; only a regular
388
+ * file that exists can be content-compared, so the stale check skips
389
+ * everything else rather than reading (and crashing on) a directory
390
+ * (gh:194 review).
391
+ */
392
+ function isRegularFile(path) {
314
393
  try {
315
- const config = JSON.parse(readFileSync(configPath, 'utf8'));
316
- const entries = config?.workspace?.localFiles;
317
- if (!Array.isArray(entries)) return [];
318
- return entries
319
- .filter((e) => typeof e === 'string' && e.length > 0)
320
- .map((e) => e.replace(/^(\.claude\/)+/, ''));
394
+ return statSync(path).isFile();
321
395
  } catch {
322
- return [];
396
+ return false;
323
397
  }
324
398
  }
325
399
 
@@ -345,6 +419,10 @@ function isOwnedByWorkspace(rel, localFiles) {
345
419
  if (rel === '.claude/settings.local.json' || rel === '.claude/.active-session.json' || rel === BASELINE_PATH) {
346
420
  return true;
347
421
  }
422
+ // The template-modification registry is workspace-owned state the template
423
+ // never ships — like the baseline, its absence from the payload is not a
424
+ // removal (gh:194).
425
+ if (rel === TEMPLATE_MODIFICATIONS_PATH) return true;
348
426
  if (!rel.startsWith('.claude/')) return false;
349
427
  const claudeRel = rel.slice('.claude/'.length);
350
428
  return localFiles.some((pattern) => globMatches(pattern, claudeRel));
@@ -425,6 +503,7 @@ export function classifyUpdate({ root, payload, baseline: baselineArg = null })
425
503
  const payloadFiles = [...walkFiles(absPayload)].filter(isClassified);
426
504
  const payloadSet = new Set(payloadFiles);
427
505
  const { baseline, source } = resolveBaseline({ root: absRoot, payload: absPayload, baseline: baselineArg });
506
+ const mods = readTemplateModifications(absRoot);
428
507
 
429
508
  const result = {
430
509
  new: [],
@@ -438,10 +517,14 @@ export function classifyUpdate({ root, payload, baseline: baselineArg = null })
438
517
  removed: [],
439
518
  staleTests: [],
440
519
  implicitDefaults: [],
520
+ staleModifications: [],
521
+ legacyKeys: mods.legacyKeys,
522
+ ignoredKeys: mods.ignoredKeys,
441
523
  hasBaseline: baseline !== null,
442
524
  baselineSource: source,
443
525
  baselineReconstructed: baseline !== null && baseline.reconstructed === true,
444
526
  };
527
+ if (mods.parseError !== null) result.modificationsError = mods.parseError;
445
528
  for (const rel of payloadFiles) {
446
529
  // Jointly-owned JSON configs never compare by content — the config
447
530
  // list carries a key-level diff for the skill to merge instead.
@@ -494,7 +577,7 @@ export function classifyUpdate({ root, payload, baseline: baselineArg = null })
494
577
 
495
578
  // Removed: installed verbatim-managed files with no payload counterpart.
496
579
  const skipSet = new Set(payloadFiles);
497
- const localFiles = readLocalFiles(absRoot);
580
+ const localFiles = mods.localFiles;
498
581
  const installedFiles = [...walkInstalledFiles(absRoot)];
499
582
  const gitignored = gitIgnoredPaths(absRoot, installedFiles);
500
583
  for (const rel of installedFiles) {
@@ -529,6 +612,46 @@ export function classifyUpdate({ root, payload, baseline: baselineArg = null })
529
612
  }
530
613
  }
531
614
  linkRemovedHooks(result, absRoot);
615
+
616
+ // Registered reasons ride along wherever a local divergence is visible
617
+ // (gh:194): differs/localOnly/deletedLocally entries become { file, reason }
618
+ // for registered paths, and a removed entry — plain, referencedBy, or
619
+ // userOwned — gains the same reason field.
620
+ result.differs = withReasons(result.differs, mods.modifications);
621
+ result.localOnly = withReasons(result.localOnly, mods.modifications);
622
+ result.deletedLocally = withReasons(result.deletedLocally, mods.modifications);
623
+ result.removed = result.removed.map((entry) => {
624
+ const file = typeof entry === 'string' ? entry : entry.file;
625
+ const reason = registeredReason(mods.modifications, file);
626
+ if (reason === undefined) return entry;
627
+ return typeof entry === 'string' ? { file, reason } : { ...entry, reason };
628
+ });
629
+
630
+ // A registry that doesn't parse leaves its ownership claims unreadable, so
631
+ // no removal can be proven unowned: fail closed — mark every removed entry
632
+ // unverifiable and let the skill stop and ask for the JSON to be fixed
633
+ // before any per-file decision (gh:194 review).
634
+ if (mods.parseError !== null) {
635
+ result.removed = result.removed.map((entry) => (typeof entry === 'string'
636
+ ? { file: entry, unverifiable: true }
637
+ : { ...entry, unverifiable: true }));
638
+ }
639
+
640
+ // Stale registrations: the installed file now equals the payload's copy, so
641
+ // the workspace already holds the template's version and the recorded
642
+ // reason describes nothing. The skill offers to drop them. A path missing
643
+ // on either side never reports — a registered deletion is a live decision
644
+ // (it explains a deletedLocally entry), not a stale one — and neither does
645
+ // a key naming a directory: only regular files on both sides compare.
646
+ result.staleModifications = Object.entries(mods.modifications)
647
+ .filter(([key]) => {
648
+ const installed = join(absRoot, `.claude/${key}`);
649
+ const inPayload = join(absPayload, `.claude/${key}`);
650
+ if (!isRegularFile(installed) || !isRegularFile(inPayload)) return false;
651
+ return hashBytes(readFileSync(installed)) === hashBytes(readFileSync(inPayload));
652
+ })
653
+ .map(([key, reason]) => ({ file: `.claude/${key}`, reason }));
654
+
532
655
  result.implicitDefaults = implicitDefaults(absRoot, absPayload);
533
656
  return result;
534
657
  }
@@ -580,6 +703,41 @@ function linkRemovedHooks(result, absRoot) {
580
703
 
581
704
  // ---------- CLAUDE.md merge ----------
582
705
 
706
+ // @-includes the template has retired. The merge keeps "lines the template
707
+ // doesn't have" — a workspace's `@workspace.json` line would otherwise survive
708
+ // every update forever. A retired include is dropped only when THIS payload no
709
+ // longer ships it; an older payload whose template still carries the line
710
+ // merges it like any other template line. The SessionStart hook now supplies
711
+ // the config summary the import existed for (gh:196).
712
+ const RETIRED_INCLUDES = ['workspace.json'];
713
+
714
+ function hasIncludeLine(text, name) {
715
+ return text.split(/\r?\n/).some((l) => l.trim() === `@${name}`);
716
+ }
717
+
718
+ /** Every `@{name}` include line removed from `text`, preserving its EOLs. */
719
+ function dropIncludeLines(text, name) {
720
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
721
+ return text.split(/\r?\n/).filter((l) => l.trim() !== `@${name}`).join(eol);
722
+ }
723
+
724
+ /**
725
+ * Retired includes still present in the workspace's CLAUDE.md: each is named
726
+ * in the output so the skill can call the drop out, and stripped from the
727
+ * current text before the merge so the merged result loses the line.
728
+ */
729
+ function stripRetiredIncludes(currentText, nextText) {
730
+ const dropped = [];
731
+ let stripped = currentText;
732
+ for (const name of RETIRED_INCLUDES) {
733
+ if (!hasIncludeLine(currentText, name)) continue;
734
+ if (hasIncludeLine(nextText, name)) continue; // this payload still ships it
735
+ dropped.push(name);
736
+ stripped = dropIncludeLines(stripped, name);
737
+ }
738
+ return { stripped, dropped };
739
+ }
740
+
583
741
  /**
584
742
  * Split markdown into blocks: the preamble (heading null) plus one block per
585
743
  * `## ` heading. Deeper headings belong to their enclosing section, and `## `
@@ -764,12 +922,16 @@ function mergeClaudeMdMode(args) {
764
922
  const next = readFileSync(tmplPath, 'utf8').replace(/\{\{project-name\}\}/g, name);
765
923
  const claudeMdPath = join(absRoot, 'CLAUDE.md');
766
924
  const current = existsSync(claudeMdPath) ? readFileSync(claudeMdPath, 'utf8') : '';
767
- const claudeMd = mergeClaudeMd(current, next);
925
+ const { stripped, dropped } = stripRetiredIncludes(current, next);
926
+ const claudeMd = mergeClaudeMd(stripped, next);
768
927
  // The gained-@include check is deterministic, not something to eyeball in
769
928
  // the diff: every include line whose target is absent here is reported so
770
929
  // the skill asks (stub or omit) instead of writing it silently (gh:190).
930
+ // A retired include the workspace still carried is the mirror decision —
931
+ // reported as dropped so the diff the operator approves says so (gh:196).
771
932
  process.stdout.write(JSON.stringify({
772
933
  claudeMd,
934
+ droppedIncludes: dropped,
773
935
  missingIncludes: missingIncludes(absRoot, claudeMd),
774
936
  }, null, 2) + '\n');
775
937
  }