@ulysses-ai/create-workspace 0.21.0-beta.0 → 0.23.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/lib/init.mjs +9 -0
- package/lib/init.test.mjs +75 -0
- package/lib/payload.mjs +170 -2
- package/lib/payload.test.mjs +158 -3
- package/lib/scaffold.mjs +8 -0
- package/lib/scaffold.test.mjs +20 -0
- package/lib/upgrade.mjs +148 -6
- package/lib/upgrade.test.mjs +319 -15
- package/package.json +1 -1
- package/template/_claude/rules/forge-operations.md +27 -6
- package/template/_claude/scripts/chat-record.mjs +51 -4
- package/template/_claude/scripts/classify-update.mjs +474 -38
- package/template/_claude/scripts/cleanup-work-session.mjs +64 -3
- package/template/_claude/scripts/forges/gitlab.mjs +450 -18
- package/template/_claude/scripts/forges/interface.mjs +39 -6
- package/template/_claude/scripts/maintenance-audit.mjs +0 -0
- package/template/_claude/scripts/merge-mode.mjs +96 -12
- package/template/_claude/scripts/migrate-sessions.mjs +232 -24
- package/template/_claude/scripts/task-pr.mjs +52 -13
- package/template/_claude/scripts/task-worktree.mjs +79 -10
- package/template/_claude/scripts/template-baseline.mjs +239 -0
- package/template/_claude/scripts/trackers/gitlab-issues.mjs +276 -0
- package/template/_claude/scripts/trackers/interface.mjs +3 -0
- package/template/_claude/skills/complete-work/SKILL.md +7 -4
- package/template/_claude/skills/migrate-sessions/SKILL.md +20 -4
- package/template/_claude/skills/release/SKILL.md +24 -8
- package/template/_claude/skills/setup-tracker/SKILL.md +46 -12
- package/template/_claude/skills/start-work/SKILL.md +15 -2
- package/template/_claude/skills/workspace-init/SKILL.md +6 -0
- package/template/_claude/skills/workspace-update/SKILL.md +63 -27
|
@@ -16,14 +16,15 @@
|
|
|
16
16
|
//
|
|
17
17
|
// Usage:
|
|
18
18
|
// node task-pr.mjs --create --root <launcher> --branch <branch>
|
|
19
|
-
// [--work-item gh:N] [--chat <name> | --repo <r> ...]
|
|
19
|
+
// [--work-item gh:N|gl:N] [--chat <name> | --repo <r> ...]
|
|
20
20
|
// --body-file <forge-repo>=<path> ... [--out <file>]
|
|
21
21
|
// [--force-with-lease]
|
|
22
22
|
// node task-pr.mjs --merge --root <launcher> --prs <json-from-create>
|
|
23
|
-
// [--work-item gh:N]
|
|
23
|
+
// [--work-item gh:N|gl:N]
|
|
24
24
|
//
|
|
25
25
|
// Every repo of the task resolves to a merge mode (mergeModeFor below):
|
|
26
|
-
// "forge" — its origin is
|
|
26
|
+
// "forge" — its origin is forge-hosted (github.com, gitlab.com, or the
|
|
27
|
+
// configured self-managed GitLab host) — or "local" — no
|
|
27
28
|
// origin at all, or an explicit "local" override in workspace.json
|
|
28
29
|
// (repos.{repo}.merge / workspace.merge), the escape hatch for a clone
|
|
29
30
|
// whose origin is a third-party upstream nobody here may push to. An
|
|
@@ -39,7 +40,9 @@
|
|
|
39
40
|
// body file is required per forge repo alone; one given for a local repo
|
|
40
41
|
// is ignored). The PR title is the linked issue's title when --work-item
|
|
41
42
|
// is given, else the branch's first commit subject; the body is the
|
|
42
|
-
// repo's body file with `Closes <ref>`
|
|
43
|
+
// repo's body file with a closing reference appended — `Closes <ref>`
|
|
44
|
+
// when the tracker's forge matches the PR's, else the issue's URL (a
|
|
45
|
+
// GitHub `owner/repo#N` reference cannot resolve on GitLab). Prints
|
|
43
46
|
// `{ prs, empty, pushed }` — prs holds forge entries (mode "forge") and
|
|
44
47
|
// local entries (mode "local") alike, every entry carrying its commit
|
|
45
48
|
// count — and, with --out, writes the same JSON to a file — a mid-run
|
|
@@ -77,7 +80,7 @@ import { resolve, dirname } from 'node:path';
|
|
|
77
80
|
import { spawnSync } from 'node:child_process';
|
|
78
81
|
import { fileURLToPath } from 'node:url';
|
|
79
82
|
import { taskWorktreePath, defaultBranchFor } from './task-worktree.mjs';
|
|
80
|
-
import { WORKSPACE_REPO, repoDirFor, readWorkspace, parseForgeRemote, mergeModeFor } from './merge-mode.mjs';
|
|
83
|
+
import { WORKSPACE_REPO, repoDirFor, readWorkspace, parseForgeRemote, forgeHosts, perRepoForge, mergeModeFor } from './merge-mode.mjs';
|
|
81
84
|
import { readRecord } from './chat-record.mjs';
|
|
82
85
|
import { createForge } from './forges/interface.mjs';
|
|
83
86
|
import { createTracker } from './trackers/interface.mjs';
|
|
@@ -97,6 +100,33 @@ function assertForgeEnabled(ws) {
|
|
|
97
100
|
}
|
|
98
101
|
}
|
|
99
102
|
|
|
103
|
+
// perRepoForge (from merge-mode.mjs, re-exported below) builds each repo's
|
|
104
|
+
// forge config; see its definition there for why the origin wins over the
|
|
105
|
+
// workspace-level type.
|
|
106
|
+
|
|
107
|
+
// Which forge an issues adapter's references resolve on — `owner/repo#N`
|
|
108
|
+
// from github-issues only closes on GitHub, `group/sub#N` from
|
|
109
|
+
// gitlab-issues only on GitLab.
|
|
110
|
+
const TRACKER_FORGE = { 'github-issues': 'github', 'gitlab-issues': 'gitlab' };
|
|
111
|
+
|
|
112
|
+
// The closing line for a PR body. Same forge as the tracker: the native
|
|
113
|
+
// reference (issueRef). Cross-forge — a GitHub tracker's issue referenced
|
|
114
|
+
// from a GitLab MR, or the reverse — cannot use it, since the target forge
|
|
115
|
+
// cannot resolve the reference; the line carries the issue's URL instead.
|
|
116
|
+
// As `Closes {url}` when the tracker mints canonical URLs (issueUrl —
|
|
117
|
+
// both forges close issues referenced by full URL); otherwise as a bare
|
|
118
|
+
// `Refs {url}` on the URL getIssue already returned, which references the
|
|
119
|
+
// issue without claiming close semantics the target forge may not honor.
|
|
120
|
+
function closingLineFor(tracker, issue, workItem, target, trackerConfig) {
|
|
121
|
+
const ref = tracker.issueRef(workItem, { fromRepo: `${target.owner}/${target.name}` });
|
|
122
|
+
const trackerForge = trackerConfig?.type ? TRACKER_FORGE[trackerConfig.type] : undefined;
|
|
123
|
+
if (!trackerForge || !target.forge || trackerForge === target.forge) {
|
|
124
|
+
return `Closes ${ref}`;
|
|
125
|
+
}
|
|
126
|
+
if (typeof tracker.issueUrl === 'function') return `Closes ${tracker.issueUrl(workItem)}`;
|
|
127
|
+
return issue?.url ? `Refs ${issue.url}` : `Closes ${ref}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
100
130
|
// Branch names become refs, refspecs, and (via the slug) paths; git's own
|
|
101
131
|
// format check is the authority, and its --branch variant also rejects
|
|
102
132
|
// names a later git call could mistake for an option. It needs no
|
|
@@ -236,7 +266,7 @@ async function createPrs(args, deps) {
|
|
|
236
266
|
// still complete with forge operations disabled.
|
|
237
267
|
if (forgeActive.length > 0) assertForgeEnabled(ws);
|
|
238
268
|
for (const t of forgeActive) {
|
|
239
|
-
Object.assign(t, parseForgeRemote(gitOut(deps.gitFn, t.worktree, ['remote', 'get-url', 'origin'])));
|
|
269
|
+
Object.assign(t, parseForgeRemote(gitOut(deps.gitFn, t.worktree, ['remote', 'get-url', 'origin']), { hosts: forgeHosts(ws) }));
|
|
240
270
|
}
|
|
241
271
|
for (const t of forgeActive) {
|
|
242
272
|
const bodyFile = args.bodyFiles.get(t.repo);
|
|
@@ -249,11 +279,12 @@ async function createPrs(args, deps) {
|
|
|
249
279
|
}
|
|
250
280
|
|
|
251
281
|
let issueTitle = null;
|
|
252
|
-
let
|
|
282
|
+
let closingLines = null;
|
|
253
283
|
if (args.workItem && ws.workspace?.tracker) {
|
|
254
284
|
const tracker = deps.trackerFactory(ws.workspace.tracker);
|
|
255
|
-
|
|
256
|
-
|
|
285
|
+
const issue = await tracker.getIssue(args.workItem);
|
|
286
|
+
issueTitle = issue.title;
|
|
287
|
+
closingLines = new Map(forgeActive.map((t) => [t.repo, closingLineFor(tracker, issue, args.workItem, t, ws.workspace.tracker)]));
|
|
257
288
|
}
|
|
258
289
|
|
|
259
290
|
// Local entries are recorded up front, before anything can fail: they are
|
|
@@ -273,7 +304,7 @@ async function createPrs(args, deps) {
|
|
|
273
304
|
}
|
|
274
305
|
|
|
275
306
|
for (const t of forgeActive) {
|
|
276
|
-
const forge = deps.forgeFactory(
|
|
307
|
+
const forge = deps.forgeFactory(perRepoForge(ws, t));
|
|
277
308
|
// Idempotency: a re-run must not open a second PR for a branch that
|
|
278
309
|
// already has one open against the same base. Reuse it as-is —
|
|
279
310
|
// re-titling or re-bodying an existing PR is a decision, not a
|
|
@@ -282,10 +313,11 @@ async function createPrs(args, deps) {
|
|
|
282
313
|
.find((p) => p.headRefName === args.branch && p.baseRefName === t.defaultBranch);
|
|
283
314
|
const title = issueTitle ?? firstCommitSubject(deps.gitFn, t, args.branch);
|
|
284
315
|
let body = readFileSync(args.bodyFiles.get(t.repo), 'utf8').replace(/\s*$/, '');
|
|
285
|
-
if (
|
|
316
|
+
if (closingLines) body = `${body}\n\n${closingLines.get(t.repo)}\n`;
|
|
286
317
|
const pr = existing ?? await forge.prCreate({ title, body, head: args.branch, base: t.defaultBranch });
|
|
287
318
|
prs.push({
|
|
288
319
|
repo: t.repo, mode: 'forge', owner: t.owner, name: t.name,
|
|
320
|
+
forge: t.forge, host: t.host,
|
|
289
321
|
number: pr.number, id: pr.id, url: pr.url, isWorkspace: t.isWorkspace, commits: t.commits,
|
|
290
322
|
});
|
|
291
323
|
}
|
|
@@ -375,7 +407,14 @@ async function mergePrs(args, deps) {
|
|
|
375
407
|
}
|
|
376
408
|
}
|
|
377
409
|
if (prs.some((p) => modeOf(p) === 'forge')) assertForgeEnabled(ws);
|
|
378
|
-
|
|
410
|
+
// Entries carry their repo's forge and host since GitLab support landed;
|
|
411
|
+
// older files predate the fields and fall back to the workspace block —
|
|
412
|
+
// the GitHub default those runs were built under.
|
|
413
|
+
const forgeFor = (p) => deps.forgeFactory({
|
|
414
|
+
...(ws.workspace?.forge ?? {}),
|
|
415
|
+
...(p.forge ? { type: p.forge, host: p.host } : {}),
|
|
416
|
+
repo: `${p.owner}/${p.name}`,
|
|
417
|
+
});
|
|
379
418
|
|
|
380
419
|
// State first, so a re-run knows what an earlier run already finished.
|
|
381
420
|
// A PR the forge reports MERGED is done; anything else is offered to
|
|
@@ -552,4 +591,4 @@ if (isMainModule(import.meta.url)) {
|
|
|
552
591
|
}
|
|
553
592
|
|
|
554
593
|
export { run, parseArgs };
|
|
555
|
-
export { parseForgeRemote, mergeModeFor } from './merge-mode.mjs';
|
|
594
|
+
export { parseForgeRemote, mergeModeFor, perRepoForge, forgeConfigForRepo } from './merge-mode.mjs';
|
|
@@ -23,19 +23,28 @@
|
|
|
23
23
|
// chat name it also consults the chat record, which is the only place
|
|
24
24
|
// open tasks are listed. /complete-work uses that to pick its flow.
|
|
25
25
|
//
|
|
26
|
+
// Long-lived lane chats sit at the launcher across BOTH lifecycles, so
|
|
27
|
+
// detection also matches the chat's session id against each old session's
|
|
28
|
+
// chatSessions frontmatter — a chat that drove a work-sessions/{name}/
|
|
29
|
+
// session by path is findable from the launcher too (gh:188). The id comes
|
|
30
|
+
// from --session-id, from the named chat record's sessionId, or from
|
|
31
|
+
// $CLAUDE_CODE_SESSION_ID, in that order. Tasks AND sessions at once
|
|
32
|
+
// report model "mixed" and let the operator pick.
|
|
33
|
+
//
|
|
26
34
|
// Usage:
|
|
27
35
|
// node task-worktree.mjs --root <dir> --create --repo <r> --branch <b> [--base <ref>]
|
|
28
36
|
// node task-worktree.mjs --root <dir> --remove --repo <r> --branch <b> [--force] [--delete-branch]
|
|
29
|
-
// node task-worktree.mjs --root <dir> --detect [--cwd <dir>] [--chat <name>]
|
|
37
|
+
// node task-worktree.mjs --root <dir> --detect [--cwd <dir>] [--chat <name>] [--session-id <id>]
|
|
30
38
|
|
|
31
39
|
import {
|
|
32
|
-
readFileSync, writeFileSync, existsSync, mkdirSync, statSync,
|
|
40
|
+
readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync,
|
|
33
41
|
} from 'node:fs';
|
|
34
42
|
import { realpathSync } from 'node:fs';
|
|
35
43
|
import { join, resolve, relative, sep, isAbsolute, basename, dirname } from 'node:path';
|
|
36
44
|
import { spawnSync } from 'node:child_process';
|
|
37
45
|
import { fileURLToPath } from 'node:url';
|
|
38
46
|
import { readRecord } from './chat-record.mjs';
|
|
47
|
+
import { readSessionFields } from '../lib/session-frontmatter.mjs';
|
|
39
48
|
import { WORKSPACE_REPO, repoDirFor, mergeModeFor } from './merge-mode.mjs';
|
|
40
49
|
|
|
41
50
|
function isMainModule(metaUrl) {
|
|
@@ -192,9 +201,11 @@ function ensureWorkspaceExcluded(gitFn, rootDir) {
|
|
|
192
201
|
* Create (or return) the task worktree for {branch} in {repo}.
|
|
193
202
|
*
|
|
194
203
|
* Idempotent: a worktree already at the path on the same branch is a
|
|
195
|
-
* success — /start-work is not guaranteed to run exactly once per task
|
|
196
|
-
*
|
|
197
|
-
*
|
|
204
|
+
* success — /start-work is not guaranteed to run exactly once per task,
|
|
205
|
+
* and adopting another chat's task re-runs --create over the owner's
|
|
206
|
+
* existing worktree, which must be reused (created: false), never
|
|
207
|
+
* duplicated or failed. A path held by anything else is a collision and
|
|
208
|
+
* refuses rather than guessing. Three creation cases, in order:
|
|
198
209
|
* - the local branch exists (a prior remove kept it) → check it out,
|
|
199
210
|
* keeping its commits;
|
|
200
211
|
* - only refs/remotes/origin/{branch} exists → the task was started on
|
|
@@ -382,6 +393,58 @@ function matchingTasks(rootDir, chat, branch) {
|
|
|
382
393
|
return tasks.length > 0 ? tasks : null;
|
|
383
394
|
}
|
|
384
395
|
|
|
396
|
+
// The sessions a chat id has driven, from each tracker's chatSessions. A
|
|
397
|
+
// lane chat at the launcher works a session by path, so cwd can never say
|
|
398
|
+
// which — the id the SessionStart hook registered is the only link. All
|
|
399
|
+
// matches come back (a chat can hop across sessions over days); the caller
|
|
400
|
+
// asks the operator when there is more than one. Archived sessions sit at
|
|
401
|
+
// work-sessions/.archived/{name}/workspace/ — one level too deep for this
|
|
402
|
+
// scan, so they never surface.
|
|
403
|
+
function matchingSessions(rootReal, sessionId) {
|
|
404
|
+
if (!sessionId) return null;
|
|
405
|
+
const sessionsDir = sessionsDirFor(rootReal);
|
|
406
|
+
let names;
|
|
407
|
+
try {
|
|
408
|
+
names = readdirSync(sessionsDir).sort();
|
|
409
|
+
} catch {
|
|
410
|
+
return null; // no sessions dir — nothing to match
|
|
411
|
+
}
|
|
412
|
+
const out = [];
|
|
413
|
+
for (const name of names) {
|
|
414
|
+
const tracker = join(sessionsDir, name, 'workspace', 'session.md');
|
|
415
|
+
if (!existsSync(tracker)) continue;
|
|
416
|
+
let fields;
|
|
417
|
+
try {
|
|
418
|
+
fields = readSessionFields(tracker);
|
|
419
|
+
} catch {
|
|
420
|
+
continue; // an unparseable tracker is evidence about the tracker, not this chat
|
|
421
|
+
}
|
|
422
|
+
const chats = Array.isArray(fields.chatSessions) ? fields.chatSessions : [];
|
|
423
|
+
if (!chats.some((c) => c && c.id === sessionId)) continue;
|
|
424
|
+
out.push({
|
|
425
|
+
name,
|
|
426
|
+
branch: typeof fields.branch === 'string' ? fields.branch : null,
|
|
427
|
+
workItem: typeof fields.workItem === 'string' ? fields.workItem : null,
|
|
428
|
+
status: typeof fields.status === 'string' ? fields.status : null,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
return out.length > 0 ? out : null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Which session id detection matches sessions by: an explicit argument
|
|
435
|
+
// beats the named chat record's sessionId (the record IS this chat's id,
|
|
436
|
+
// keyed stably where a name is renameable), which beats the ambient
|
|
437
|
+
// $CLAUDE_CODE_SESSION_ID — the hook's id, present even when the hook's
|
|
438
|
+
// `Chat record:` line was compacted away.
|
|
439
|
+
function resolveSessionId(rootDir, chat, sessionId, env) {
|
|
440
|
+
if (sessionId) return sessionId;
|
|
441
|
+
if (chat) {
|
|
442
|
+
const rec = readRecord(rootDir, chat);
|
|
443
|
+
if (rec && rec.sessionId) return rec.sessionId;
|
|
444
|
+
}
|
|
445
|
+
return (env && env.CLAUDE_CODE_SESSION_ID) || null;
|
|
446
|
+
}
|
|
447
|
+
|
|
385
448
|
// One task worktree's detect payload, or null when {path} does not really
|
|
386
449
|
// hold a task: a stale plain directory under .claude/worktrees/ would
|
|
387
450
|
// otherwise detect through cwd and resolve to the repo around it — for the
|
|
@@ -408,10 +471,12 @@ function taskWorktreeInfo(gitFn, rootReal, repo, path, chat) {
|
|
|
408
471
|
* Tell a skill which lifecycle the current directory is under, in order:
|
|
409
472
|
* session (cwd in the old model's tree), task (cwd in a task worktree,
|
|
410
473
|
* enriched with the chat's matching tasks when {chat} is given), then —
|
|
411
|
-
* because
|
|
412
|
-
* task
|
|
474
|
+
* because lane chats run at the launcher, where cwd is just the root —
|
|
475
|
+
* task if the named chat's record has open tasks, session if the chat's
|
|
476
|
+
* session id appears in any tracker's chatSessions, "mixed" when both.
|
|
477
|
+
* Else none.
|
|
413
478
|
*/
|
|
414
|
-
function detectWorkModel(cwd, root, { chat = null, gitFn = spawnSync } = {}) {
|
|
479
|
+
function detectWorkModel(cwd, root, { chat = null, sessionId = null, gitFn = spawnSync, env = process.env } = {}) {
|
|
415
480
|
const rootReal = realPath(resolve(root));
|
|
416
481
|
const cwdReal = realPath(resolve(cwd));
|
|
417
482
|
|
|
@@ -441,7 +506,10 @@ function detectWorkModel(cwd, root, { chat = null, gitFn = spawnSync } = {}) {
|
|
|
441
506
|
}
|
|
442
507
|
|
|
443
508
|
const tasks = matchingTasks(rootReal, chat, null);
|
|
509
|
+
const sessions = matchingSessions(rootReal, resolveSessionId(rootReal, chat, sessionId, env));
|
|
510
|
+
if (tasks && sessions) return { model: 'mixed', tasks, sessions };
|
|
444
511
|
if (tasks) return { model: 'task', source: 'chat-record', tasks };
|
|
512
|
+
if (sessions) return { model: 'session', source: 'chat-sessions', sessions };
|
|
445
513
|
|
|
446
514
|
return { model: 'none' };
|
|
447
515
|
}
|
|
@@ -454,6 +522,7 @@ const VALUE_FLAGS = new Map([
|
|
|
454
522
|
['--base', 'base'],
|
|
455
523
|
['--cwd', 'cwd'],
|
|
456
524
|
['--chat', 'chat'],
|
|
525
|
+
['--session-id', 'sessionId'],
|
|
457
526
|
]);
|
|
458
527
|
|
|
459
528
|
// A repo name becomes a path segment under repos/ — one segment only, no
|
|
@@ -468,7 +537,7 @@ function isRepoSegment(repo) {
|
|
|
468
537
|
}
|
|
469
538
|
|
|
470
539
|
function parseArgs(argv) {
|
|
471
|
-
const args = { root: '.', mode: null, repo: null, branch: null, base: null, cwd: null, chat: null, force: false, deleteBranch: false };
|
|
540
|
+
const args = { root: '.', mode: null, repo: null, branch: null, base: null, cwd: null, chat: null, sessionId: null, force: false, deleteBranch: false };
|
|
472
541
|
const rest = argv.slice(2);
|
|
473
542
|
for (let i = 0; i < rest.length; i += 1) {
|
|
474
543
|
const a = rest[i];
|
|
@@ -513,7 +582,7 @@ function main() {
|
|
|
513
582
|
} else if (args.mode === 'remove') {
|
|
514
583
|
out = removeTaskWorktree(args.root, { repo: args.repo, branch: args.branch, force: args.force, deleteBranch: args.deleteBranch });
|
|
515
584
|
} else {
|
|
516
|
-
out = detectWorkModel(args.cwd || process.cwd(), args.root, { chat: args.chat });
|
|
585
|
+
out = detectWorkModel(args.cwd || process.cwd(), args.root, { chat: args.chat, sessionId: args.sessionId });
|
|
517
586
|
}
|
|
518
587
|
process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
|
|
519
588
|
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Template baseline: the sha256 of every verbatim-installed file the template
|
|
3
|
+
// last shipped, recorded at <root>/.claude/.template-baseline.json. It is the
|
|
4
|
+
// third input of /workspace-update's three-way classification (workspace vs
|
|
5
|
+
// payload vs baseline), so a file the template changed since the installed
|
|
6
|
+
// version reads as an update to apply in batch — not a local edit to negotiate
|
|
7
|
+
// file by file (gh:183).
|
|
8
|
+
//
|
|
9
|
+
// Shape:
|
|
10
|
+
// { "templateVersion": "0.21.0",
|
|
11
|
+
// "files": { ".claude/skills/workspace-update/SKILL.md": "<sha256>", … } }
|
|
12
|
+
// Keys are root-relative posix paths covering the verbatim-installed roots
|
|
13
|
+
// (.claude/**, .mcp.json, .claudeignore). The file is committed with the
|
|
14
|
+
// workspace — every machine that pulls gets the same template files, so it
|
|
15
|
+
// gets the same baseline.
|
|
16
|
+
//
|
|
17
|
+
// Written by `--init` scaffolding (lib/init.mjs), by /workspace-init once the
|
|
18
|
+
// remaining components are installed, and at the end of every /workspace-update
|
|
19
|
+
// (classify-update.mjs --write-baseline). Interactive scaffolding writes it
|
|
20
|
+
// from the template tree (lib/scaffold.mjs).
|
|
21
|
+
//
|
|
22
|
+
// The rule every entry follows: record the hash of the payload's content —
|
|
23
|
+
// what the template last shipped — never the workspace's on-disk bytes. A
|
|
24
|
+
// file the user kept despite a template change therefore keeps the PAYLOAD
|
|
25
|
+
// hash: the next update sees workspace ≠ baseline with payload == baseline
|
|
26
|
+
// and reports the file as a purely local edit (informational, not re-asked
|
|
27
|
+
// per file) until the template touches it again, and a deliberate divergence
|
|
28
|
+
// is never silently adopted as the new baseline. One exception: an unapplied
|
|
29
|
+
// update — the workspace still holds the OLD baseline content while the
|
|
30
|
+
// payload ships something new (the user declined the `updated` batch) — keeps
|
|
31
|
+
// the old entry, so the file re-presents as `updated` next time instead of
|
|
32
|
+
// being filed away as a local edit. The invariant that matters either way: a
|
|
33
|
+
// file the user never touched (workspace == baseline) is never reported as a
|
|
34
|
+
// local edit.
|
|
35
|
+
//
|
|
36
|
+
// All hashes are CRLF-normalized for text files (see hashBytes): a Windows
|
|
37
|
+
// autocrlf checkout stores CRLF where the payload carries LF, and byte-exact
|
|
38
|
+
// hashing would read every such file as locally modified. Files containing
|
|
39
|
+
// NUL bytes hash byte-exact.
|
|
40
|
+
//
|
|
41
|
+
// Not covered: *.test.mjs (the tarball never ships them; a workspace's test
|
|
42
|
+
// files came from a dev checkout and age independently — classify-update
|
|
43
|
+
// reports them as staleTests instead), machine-local files
|
|
44
|
+
// (.claude/settings.local.json, .claude/.active-session.json), and anything
|
|
45
|
+
// under .claude/worktrees/.
|
|
46
|
+
|
|
47
|
+
import { createHash } from 'node:crypto';
|
|
48
|
+
import {
|
|
49
|
+
existsSync,
|
|
50
|
+
mkdirSync,
|
|
51
|
+
readFileSync,
|
|
52
|
+
readdirSync,
|
|
53
|
+
statSync,
|
|
54
|
+
writeFileSync,
|
|
55
|
+
} from 'node:fs';
|
|
56
|
+
import { dirname, join, resolve } from 'node:path';
|
|
57
|
+
|
|
58
|
+
export const BASELINE_PATH = '.claude/.template-baseline.json';
|
|
59
|
+
|
|
60
|
+
// Where --upgrade stages a RECONSTRUCTED baseline (built from the installed
|
|
61
|
+
// version's npm tarball) inside the payload. It never lands in the launcher:
|
|
62
|
+
// with a remote, /workspace-update classifies inside a task worktree that
|
|
63
|
+
// cannot see launcher-only files, and an untracked launcher baseline would
|
|
64
|
+
// also dirty the launcher against the incoming PR. The payload travels to
|
|
65
|
+
// the worktree by absolute path, so the baseline rides along.
|
|
66
|
+
export const RECONSTRUCTED_BASELINE_NAME = '.template-baseline.reconstructed.json';
|
|
67
|
+
|
|
68
|
+
// [source name, installed name] pairs for the verbatim-installed roots. The
|
|
69
|
+
// staged payload carries the live names; the template tree stores .claude/ and
|
|
70
|
+
// .mcp.json under the inert names _claude/ and _mcp.json, so scaffold passes
|
|
71
|
+
// the inert pairs.
|
|
72
|
+
export const LIVE_PAIRS = [
|
|
73
|
+
['.claude', '.claude'],
|
|
74
|
+
['.mcp.json', '.mcp.json'],
|
|
75
|
+
['.claudeignore', '.claudeignore'],
|
|
76
|
+
];
|
|
77
|
+
export const INERT_PAIRS = [
|
|
78
|
+
['_claude', '.claude'],
|
|
79
|
+
['_mcp.json', '.mcp.json'],
|
|
80
|
+
['.claudeignore', '.claudeignore'],
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
// Machine-local or per-workspace files that never belong in the baseline.
|
|
84
|
+
const OWNED_PATHS = new Set([
|
|
85
|
+
'.claude/settings.local.json',
|
|
86
|
+
'.claude/.active-session.json',
|
|
87
|
+
BASELINE_PATH,
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
// Entire nested worktrees live under .claude/worktrees/ — never walked.
|
|
91
|
+
const SKIP_DIRS = new Set(['worktrees']);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The content hash used by the baseline and by classification: sha256 with
|
|
95
|
+
* CRLF normalized to LF for text files, byte-exact for binary (anything
|
|
96
|
+
* containing a NUL byte — git's own text/binary heuristic). Both the payload
|
|
97
|
+
* and the workspace side hash through this, so a git autocrlf checkout that
|
|
98
|
+
* stores CRLF where the payload ships LF classifies as identical instead of
|
|
99
|
+
* reading every file as locally modified.
|
|
100
|
+
*/
|
|
101
|
+
export function hashBytes(bytes) {
|
|
102
|
+
let body = bytes;
|
|
103
|
+
if (!bytes.includes(0)) {
|
|
104
|
+
// latin1 round-trips bytes 1:1 — safe on text that isn't valid UTF-8 too.
|
|
105
|
+
body = Buffer.from(bytes.toString('latin1').replace(/\r\n/g, '\n'), 'latin1');
|
|
106
|
+
}
|
|
107
|
+
return createHash('sha256').update(body).digest('hex');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function* walkFiles(dir, prefix = '') {
|
|
111
|
+
let entries;
|
|
112
|
+
try {
|
|
113
|
+
entries = readdirSync(dir).sort();
|
|
114
|
+
} catch {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
for (const name of entries) {
|
|
118
|
+
if (prefix === '.claude' && SKIP_DIRS.has(name)) continue;
|
|
119
|
+
const rel = prefix ? `${prefix}/${name}` : name;
|
|
120
|
+
const full = join(dir, name);
|
|
121
|
+
let st;
|
|
122
|
+
try { st = statSync(full); } catch { continue; }
|
|
123
|
+
if (st.isDirectory()) yield* walkFiles(full, rel);
|
|
124
|
+
else if (st.isFile()) yield rel;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Read a baseline JSON file at an explicit path. Returns
|
|
130
|
+
* { templateVersion, files, reconstructed } or null when absent or
|
|
131
|
+
* unparseable — a corrupt baseline is treated exactly like a missing one
|
|
132
|
+
* (gh:186) so classification falls back to the two-way behavior rather than
|
|
133
|
+
* failing the update, and reconstruction is not blocked by a broken file.
|
|
134
|
+
*/
|
|
135
|
+
export function readBaselineFile(path) {
|
|
136
|
+
if (!existsSync(path)) return null;
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
139
|
+
if (!parsed || typeof parsed !== 'object' || typeof parsed.files !== 'object' || parsed.files === null) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
templateVersion: typeof parsed.templateVersion === 'string' ? parsed.templateVersion : null,
|
|
144
|
+
files: parsed.files,
|
|
145
|
+
reconstructed: parsed.reconstructed === true,
|
|
146
|
+
};
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Read the baseline at <root>/.claude/.template-baseline.json. Returns the
|
|
154
|
+
* parsed baseline or null when absent (workspaces older than the baseline's
|
|
155
|
+
* introduction) or unparseable.
|
|
156
|
+
*/
|
|
157
|
+
export function readBaseline(root) {
|
|
158
|
+
return readBaselineFile(join(resolve(root), BASELINE_PATH));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Build a baseline from a template source directory (a staged payload with
|
|
163
|
+
* live names, or the template tree via INERT_PAIRS). `version` overrides the
|
|
164
|
+
* payload manifest's templateVersion — pass it when the source has no
|
|
165
|
+
* .manifest.json (the template tree).
|
|
166
|
+
*/
|
|
167
|
+
export function buildBaseline(sourceDir, { pairs = LIVE_PAIRS, version = null } = {}) {
|
|
168
|
+
const absSource = resolve(sourceDir);
|
|
169
|
+
const files = {};
|
|
170
|
+
for (const [sourceName, installedName] of pairs) {
|
|
171
|
+
const src = join(absSource, sourceName);
|
|
172
|
+
if (!existsSync(src)) continue;
|
|
173
|
+
let st;
|
|
174
|
+
try { st = statSync(src); } catch { continue; }
|
|
175
|
+
if (st.isFile()) {
|
|
176
|
+
if (!OWNED_PATHS.has(installedName) && !installedName.endsWith('.test.mjs')) {
|
|
177
|
+
files[installedName] = hashBytes(readFileSync(src));
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
for (const rel of walkFiles(src, installedName)) {
|
|
182
|
+
if (OWNED_PATHS.has(rel) || rel.endsWith('.test.mjs')) continue;
|
|
183
|
+
files[rel] = hashBytes(readFileSync(join(absSource, sourceName, rel.slice(installedName.length + 1))));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
let templateVersion = version;
|
|
187
|
+
if (templateVersion === null) {
|
|
188
|
+
try {
|
|
189
|
+
const manifest = JSON.parse(readFileSync(join(absSource, '.manifest.json'), 'utf8'));
|
|
190
|
+
if (typeof manifest.templateVersion === 'string') templateVersion = manifest.templateVersion;
|
|
191
|
+
} catch {
|
|
192
|
+
// No manifest (or unreadable) — caller should pass `version`.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { templateVersion, files: Object.fromEntries(Object.keys(files).sort().map((k) => [k, files[k]])) };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Write the baseline for the workspace at `root`. Returns the written object.
|
|
200
|
+
*
|
|
201
|
+
* Refuses to write an empty baseline: a missing source directory or one that
|
|
202
|
+
* yields zero verbatim files throws rather than clobbering an existing good
|
|
203
|
+
* baseline with `{files:{}}` (which would make the next update classify every
|
|
204
|
+
* template change as a local edit).
|
|
205
|
+
*
|
|
206
|
+
* Before writing, entries carried over from the previous baseline are kept as
|
|
207
|
+
* they were for unapplied updates: a file whose workspace content still
|
|
208
|
+
* matches the old baseline while the payload ships something new (a declined
|
|
209
|
+
* `updated` batch) keeps the OLD entry, so the next update still offers the
|
|
210
|
+
* change instead of filing the file away as a local edit. The previous
|
|
211
|
+
* baseline defaults to <root>/.claude/.template-baseline.json; pass
|
|
212
|
+
* opts.previous to override (the worktree flow reads the payload's
|
|
213
|
+
* reconstructed baseline when the root has none of its own).
|
|
214
|
+
*/
|
|
215
|
+
export function writeBaseline(root, sourceDir, opts = {}) {
|
|
216
|
+
const absRoot = resolve(root);
|
|
217
|
+
const baseline = buildBaseline(sourceDir, opts);
|
|
218
|
+
if (Object.keys(baseline.files).length === 0) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`No verbatim template files found under ${resolve(sourceDir)} — refusing to write an empty baseline`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const previous = 'previous' in opts ? opts.previous : readBaseline(absRoot);
|
|
224
|
+
if (previous) {
|
|
225
|
+
for (const rel of Object.keys(baseline.files)) {
|
|
226
|
+
const oldHash = previous.files[rel];
|
|
227
|
+
if (typeof oldHash !== 'string' || oldHash === baseline.files[rel]) continue;
|
|
228
|
+
const installed = join(absRoot, rel);
|
|
229
|
+
if (existsSync(installed) && hashBytes(readFileSync(installed)) === oldHash) {
|
|
230
|
+
// Unapplied update: the workspace never took the payload's change.
|
|
231
|
+
baseline.files[rel] = oldHash;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const dest = join(absRoot, BASELINE_PATH);
|
|
236
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
237
|
+
writeFileSync(dest, JSON.stringify(baseline, null, 2) + '\n');
|
|
238
|
+
return baseline;
|
|
239
|
+
}
|