@ulysses-ai/create-workspace 0.23.3-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/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 +221 -2
- 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/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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ulysses-ai/create-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0-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": {
|
package/template/CLAUDE.md.tmpl
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,49 @@ 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
|
+
|
|
53
96
|
/**
|
|
54
97
|
* Defensive normalization for a session tracker's `repos` field. A fresh
|
|
55
98
|
* tracker written by create-work-session always holds an array, but an
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
updateSessionTracker,
|
|
16
16
|
sessionFolderPath,
|
|
17
17
|
timeAgo,
|
|
18
|
+
configSummary,
|
|
18
19
|
} from './_utils.mjs';
|
|
19
20
|
import { reconcile, readSessionRegistry, resolveChatName } from '../scripts/chat-record.mjs';
|
|
20
21
|
|
|
@@ -35,6 +36,15 @@ if (!config) {
|
|
|
35
36
|
|
|
36
37
|
lines.push(`Workspace: ${config.workspace?.name || 'unnamed'}`);
|
|
37
38
|
|
|
39
|
+
// One compact config summary (gh:196). CLAUDE.md no longer imports
|
|
40
|
+
// workspace.json, so this line is what a session sees of the config without
|
|
41
|
+
// reading the file — it names the file so sessions know where the values
|
|
42
|
+
// live, and skills that need more than these fields read it on demand. Keep
|
|
43
|
+
// it small: every session pays for it, which is exactly why the import was
|
|
44
|
+
// dropped. configSummary (_utils.mjs) prints invalid/absent states rather
|
|
45
|
+
// than inventing defaults the scripts don't apply.
|
|
46
|
+
lines.push(`Workspace config (workspace.json): ${configSummary(config)}`);
|
|
47
|
+
|
|
38
48
|
// Keep this chat's record in step with its identity (gh:132). The record is
|
|
39
49
|
// keyed on sessionId and filed under the chat name, so a rename moves the file
|
|
40
50
|
// and its drawer rather than orphaning them.
|
|
@@ -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`;
|
|
19
|
-
`repo` is adapter-specific — for GitHub, the slug where
|
|
20
|
-
from the git remote.
|
|
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
|
|
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" | "
|
|
29
|
-
// --add-task, "removed" | "unchanged" for --remove-task
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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,
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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:
|
|
204
|
-
// two repos in a multi-repo task, and
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
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
|
-
|
|
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 ? '
|
|
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
|
|
231
|
-
&& (
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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/,
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// workspace
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
// entry
|
|
76
|
-
//
|
|
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 }:
|
|
121
|
-
// with the payload's CLAUDE.md.tmpl merged in —
|
|
122
|
-
// lines updated, the workspace's own lines (custom
|
|
123
|
-
// entries, sections) kept —
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
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
|
-
/**
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
}
|