@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.
Files changed (30) hide show
  1. package/lib/init.mjs +9 -0
  2. package/lib/init.test.mjs +75 -0
  3. package/lib/payload.mjs +170 -2
  4. package/lib/payload.test.mjs +158 -3
  5. package/lib/scaffold.mjs +8 -0
  6. package/lib/scaffold.test.mjs +20 -0
  7. package/lib/upgrade.mjs +148 -6
  8. package/lib/upgrade.test.mjs +319 -15
  9. package/package.json +1 -1
  10. package/template/_claude/rules/forge-operations.md +27 -6
  11. package/template/_claude/scripts/chat-record.mjs +51 -4
  12. package/template/_claude/scripts/classify-update.mjs +474 -38
  13. package/template/_claude/scripts/cleanup-work-session.mjs +64 -3
  14. package/template/_claude/scripts/forges/gitlab.mjs +450 -18
  15. package/template/_claude/scripts/forges/interface.mjs +39 -6
  16. package/template/_claude/scripts/maintenance-audit.mjs +0 -0
  17. package/template/_claude/scripts/merge-mode.mjs +96 -12
  18. package/template/_claude/scripts/migrate-sessions.mjs +232 -24
  19. package/template/_claude/scripts/task-pr.mjs +52 -13
  20. package/template/_claude/scripts/task-worktree.mjs +79 -10
  21. package/template/_claude/scripts/template-baseline.mjs +239 -0
  22. package/template/_claude/scripts/trackers/gitlab-issues.mjs +276 -0
  23. package/template/_claude/scripts/trackers/interface.mjs +3 -0
  24. package/template/_claude/skills/complete-work/SKILL.md +7 -4
  25. package/template/_claude/skills/migrate-sessions/SKILL.md +20 -4
  26. package/template/_claude/skills/release/SKILL.md +24 -8
  27. package/template/_claude/skills/setup-tracker/SKILL.md +46 -12
  28. package/template/_claude/skills/start-work/SKILL.md +15 -2
  29. package/template/_claude/skills/workspace-init/SKILL.md +6 -0
  30. 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 a forge-hosted owner/name — or "local" — no
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>` appended. Prints
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 issueRefs = null;
282
+ let closingLines = null;
253
283
  if (args.workItem && ws.workspace?.tracker) {
254
284
  const tracker = deps.trackerFactory(ws.workspace.tracker);
255
- issueTitle = (await tracker.getIssue(args.workItem)).title;
256
- issueRefs = new Map(forgeActive.map((t) => [t.repo, tracker.issueRef(args.workItem, { fromRepo: `${t.owner}/${t.name}` })]));
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({ ...(ws.workspace?.forge ?? {}), repo: `${t.owner}/${t.name}` });
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 (issueRefs) body = `${body}\n\nCloses ${issueRefs.get(t.repo)}\n`;
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
- const forgeFor = (p) => deps.forgeFactory({ ...(ws.workspace?.forge ?? {}), repo: `${p.owner}/${p.name}` });
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. A
196
- * path held by anything else is a collision and refuses rather than
197
- * guessing. Three creation cases, in order:
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 task chats run at the launcher, where cwd is just the root —
412
- * task again if the named chat's record has open tasks. Else none.
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
+ }