@ulysses-ai/create-workspace 0.14.0-beta.1 → 0.15.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 (49) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/template/.claude/agents/reviewer.md +1 -1
  4. package/template/.claude/hooks/pre-compact.mjs +1 -1
  5. package/template/.claude/hooks/repo-write-detection.mjs +2 -2
  6. package/template/.claude/hooks/session-start.mjs +10 -7
  7. package/template/.claude/hooks/subagent-start.mjs +3 -3
  8. package/template/.claude/hooks/version-freshness-check.mjs +30 -0
  9. package/template/.claude/lib/freshness.mjs +75 -0
  10. package/template/.claude/lib/freshness.test.mjs +175 -0
  11. package/template/.claude/lib/registry-check.mjs +106 -0
  12. package/template/.claude/lib/registry-check.test.mjs +130 -0
  13. package/template/.claude/recipes/migrate-from-notion.md +6 -6
  14. package/template/.claude/rules/coherent-revisions.md +2 -2
  15. package/template/.claude/rules/local-dev-environment.md.skip +2 -2
  16. package/template/.claude/rules/memory-guidance.md +54 -1
  17. package/template/.claude/rules/token-economics.md.skip +2 -2
  18. package/template/.claude/rules/work-item-tracking.md +1 -1
  19. package/template/.claude/rules/workspace-structure.md +36 -13
  20. package/template/.claude/scripts/build-workspace-context.mjs +365 -0
  21. package/template/.claude/scripts/build-workspace-context.test.mjs +633 -0
  22. package/template/.claude/scripts/capture-context.mjs +217 -0
  23. package/template/.claude/scripts/capture-context.test.mjs +383 -0
  24. package/template/.claude/scripts/generate-claude-local.mjs +104 -0
  25. package/template/.claude/scripts/generate-claude-local.test.mjs +184 -0
  26. package/template/.claude/scripts/migrate-claude-md-freshness-include.mjs +30 -0
  27. package/template/.claude/scripts/migrate-claude-md-freshness-include.test.mjs +54 -0
  28. package/template/.claude/scripts/migrate-open-work.mjs +1 -1
  29. package/template/.claude/scripts/migrate-to-workspace-context.mjs +520 -0
  30. package/template/.claude/scripts/migrate-to-workspace-context.test.mjs +325 -0
  31. package/template/.claude/scripts/sweep-references.mjs +177 -0
  32. package/template/.claude/scripts/sweep-references.test.mjs +184 -0
  33. package/template/.claude/settings.json +6 -0
  34. package/template/.claude/skills/aside/SKILL.md +49 -44
  35. package/template/.claude/skills/braindump/SKILL.md +25 -19
  36. package/template/.claude/skills/build-docs-site/SKILL.md +1 -1
  37. package/template/.claude/skills/build-docs-site/checklists/framing.md +1 -1
  38. package/template/.claude/skills/complete-work/SKILL.md +3 -3
  39. package/template/.claude/skills/handoff/SKILL.md +31 -30
  40. package/template/.claude/skills/maintenance/SKILL.md +57 -15
  41. package/template/.claude/skills/pause-work/SKILL.md +1 -1
  42. package/template/.claude/skills/promote/SKILL.md +18 -8
  43. package/template/.claude/skills/release/SKILL.md +17 -13
  44. package/template/.claude/skills/start-work/SKILL.md +1 -1
  45. package/template/.claude/skills/workspace-init/SKILL.md +12 -12
  46. package/template/.claude/skills/workspace-update/SKILL.md +16 -0
  47. package/template/CLAUDE.md.tmpl +5 -3
  48. package/template/_gitignore +3 -3
  49. package/template/workspace.json.tmpl +3 -2
package/README.md CHANGED
@@ -90,7 +90,7 @@ A scaffolded workspace with:
90
90
 
91
91
  - **14 skills** covering the workflow lifecycle, releases, handoffs, and maintenance
92
92
  - **7 active rules** + **8 optional `.skip` rules** for behaviors you can opt into
93
- - **9 hooks** for SessionStart, SubagentStart, PreCompact, WorktreeCreate, and the rest of the small set the conventions rely on
93
+ - **10 hooks** for SessionStart, SubagentStart, PreCompact, WorktreeCreate, and the rest of the small set the conventions rely on
94
94
  - A **`shared-context/`** memory system with three visibility levels: locked (team truths), root (team-visible ephemerals), user-scoped (personal)
95
95
  - Conventions for **multi-repo work sessions** with isolated git worktrees, parallelizable from separate terminals
96
96
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulysses-ai/create-workspace",
3
- "version": "0.14.0-beta.1",
3
+ "version": "0.15.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",
@@ -27,7 +27,7 @@ You are reviewing code changes. Your job is to find problems, not to fix them.
27
27
  3. **Edge cases** — are there scenarios not handled?
28
28
  4. **Test coverage** — are tests covering the right scenarios? Are they testing behavior, not implementation?
29
29
  5. **Security** — any injection, XSS, or auth issues?
30
- 6. **Consistency** — does it match existing patterns in shared-context/locked/?
30
+ 6. **Consistency** — does it match existing patterns in workspace-context/shared/locked/?
31
31
 
32
32
  ## What NOT to review
33
33
  - Style preferences (defer to linters/formatters)
@@ -30,5 +30,5 @@ if (pointer) {
30
30
  respond(`Context is about to be compacted. No session tracker found for "${pointer.name}". Use /handoff to capture progress before context is lost.`);
31
31
  }
32
32
  } else {
33
- respond(`Context is about to be compacted — earlier conversation details will be lost.\n\nIf this session produced decisions or progress worth keeping:\n /braindump [name] — capture discussion and reasoning\n /handoff [name] — capture workstream state and next steps\n\nFiles in shared-context/ will persist. Conversation details won't.`);
33
+ respond(`Context is about to be compacted — earlier conversation details will be lost.\n\nIf this session produced decisions or progress worth keeping:\n /braindump [name] — capture discussion and reasoning\n /handoff [name] — capture workstream state and next steps\n\nFiles in workspace-context/ will persist. Conversation details won't.`);
34
34
  }
@@ -94,9 +94,9 @@ if (toolName === 'Bash') {
94
94
  }
95
95
  }
96
96
 
97
- // Check if this write targets repos/, shared-context/, work-sessions/, or template files
97
+ // Check if this write targets repos/, workspace-context/, work-sessions/, or template files
98
98
  const isRepoWrite = /(?:^|[\s/])repos\//.test(paths) || paths.includes('work-sessions/');
99
- const isContextWrite = paths.includes('shared-context/') && !basename(filePathArg).startsWith('local-only-');
99
+ const isContextWrite = paths.includes('workspace-context/') && !basename(filePathArg).startsWith('local-only-');
100
100
  const isTemplateWrite = paths.includes('.claude/') && !paths.includes(scratchpadName);
101
101
 
102
102
  if (isRepoWrite || isContextWrite || isTemplateWrite) {
@@ -22,7 +22,8 @@ const input = await readStdin();
22
22
  const config = readJSON(join(root, 'workspace.json'));
23
23
 
24
24
  const chatId = input.session_id || null;
25
- const contextDir = join(root, 'shared-context');
25
+ const contextDir = join(root, 'workspace-context');
26
+ const sharedDir = join(contextDir, 'shared');
26
27
  const reposDir = join(root, 'repos');
27
28
  const lines = [];
28
29
 
@@ -113,8 +114,11 @@ if (trackers.length > 0) {
113
114
  lines.push('Use /start-work to resume a session or start new work.');
114
115
  }
115
116
 
116
- // Surface shared context (secondary)
117
- if (existsSync(contextDir)) {
117
+ // Surface team-shared workspace context (secondary)
118
+ // Only scan shared/ — locked/ is now a sub-dir of shared/ and is included
119
+ // naturally. team-member/ is per-user (loaded via CLAUDE.local.md).
120
+ // release-notes/ is operational, not knowledge.
121
+ if (existsSync(sharedDir)) {
118
122
  const entries = [];
119
123
 
120
124
  function scanDir(dir, depth = 0) {
@@ -123,11 +127,10 @@ if (existsSync(contextDir)) {
123
127
  const fullPath = join(dir, entry);
124
128
  const stat = statSync(fullPath);
125
129
  if (stat.isDirectory()) {
126
- if (entry === 'locked' || entry === '.keep') continue;
130
+ if (entry === '.keep') continue;
127
131
  scanDir(fullPath, depth + 1);
128
132
  } else if (entry.endsWith('.md') && entry !== '.keep' && !entry.startsWith('local-only-')) {
129
133
  const relPath = relative(contextDir, fullPath);
130
- if (relPath.startsWith('locked/')) continue;
131
134
  const content = readFileSync(fullPath, 'utf-8');
132
135
  const topicMatch = content.match(/^topic:\s*(.+)$/m);
133
136
  const lifecycleMatch = content.match(/^lifecycle:\s*(.+)$/m);
@@ -139,10 +142,10 @@ if (existsSync(contextDir)) {
139
142
  }
140
143
  }
141
144
 
142
- scanDir(contextDir);
145
+ scanDir(sharedDir);
143
146
  if (entries.length > 0) {
144
147
  lines.push('');
145
- lines.push('Shared context:');
148
+ lines.push('Workspace context:');
146
149
  lines.push(...entries);
147
150
  }
148
151
  }
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
- // SubagentStart hook — inject shared-context/locked/ into subagent context
2
+ // SubagentStart hook — inject workspace-context/shared/locked/ into subagent context
3
3
  import { readdirSync, readFileSync, existsSync } from 'fs';
4
4
  import { join, basename } from 'path';
5
5
  import { getWorkspaceRoot, readJSON, respond } from './_utils.mjs';
6
6
 
7
7
  const root = getWorkspaceRoot(import.meta.url);
8
8
  const config = readJSON(join(root, 'workspace.json'));
9
- const lockedDir = join(root, 'shared-context', 'locked');
9
+ const lockedDir = join(root, 'workspace-context', 'shared', 'locked');
10
10
 
11
11
  const maxBytes = config?.workspace?.subagentContextMaxBytes || 10240;
12
12
 
@@ -38,7 +38,7 @@ if (Buffer.byteLength(context) > maxBytes) {
38
38
  return `- ${basename(f, '.md')}: ${firstLine}`;
39
39
  }).join('\n');
40
40
 
41
- context = `[Locked shared context exceeds ${maxBytes} byte limit (${Buffer.byteLength(context)} bytes). Summary of ${files.length} files:]\n${summary}\n[Read individual files from shared-context/locked/ if you need full content.]`;
41
+ context = `[Locked shared context exceeds ${maxBytes} byte limit (${Buffer.byteLength(context)} bytes). Summary of ${files.length} files:]\n${summary}\n[Read individual files from workspace-context/shared/locked/ if you need full content.]`;
42
42
  }
43
43
 
44
44
  respond(context);
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ // SessionStart hook — refresh the template freshness banner if cache is stale.
3
+ // Gated on workspace.versionCheck.ambient (default true; will flip to false at v1.0).
4
+ // Always silent: surfaces only via the local-only-template-freshness.md banner,
5
+ // which CLAUDE.md includes via @ syntax. Never blocks session start.
6
+ import { getWorkspaceRoot, readJSON, respond } from './_utils.mjs';
7
+ import { refreshIfStale } from '../lib/freshness.mjs';
8
+
9
+ const TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
10
+
11
+ const root = getWorkspaceRoot(import.meta.url);
12
+ const config = readJSON(`${root}/workspace.json`);
13
+
14
+ // Default lifecycle: ambient=true during beta, missing-block fallback flips
15
+ // to false at v1.0 by editing this default.
16
+ const AMBIENT_DEFAULT = true;
17
+ const ambient = config?.workspace?.versionCheck?.ambient ?? AMBIENT_DEFAULT;
18
+
19
+ if (!ambient) {
20
+ respond();
21
+ process.exit(0);
22
+ }
23
+
24
+ try {
25
+ await refreshIfStale({ workspaceRoot: root, ttlMs: TTL_MS });
26
+ } catch {
27
+ // Hooks must never block session start. The banner reflects last-known
28
+ // state; the user sees stale but never broken.
29
+ }
30
+ respond();
@@ -0,0 +1,75 @@
1
+ import './require-node.mjs';
2
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { compareVersions, getLatestVersion, readCache, writeCache } from './registry-check.mjs';
5
+
6
+ const BANNER_FILENAME = 'local-only-template-freshness.md';
7
+ const CACHE_FILENAME = '.version-check.json';
8
+
9
+ /**
10
+ * Refresh the version cache if stale, then write or delete the banner file
11
+ * based on a comparison of workspace.templateVersion to the latest npm version.
12
+ *
13
+ * Returns:
14
+ * { status: 'outdated', current, latest, checkedAt }
15
+ * { status: 'current', current, latest, checkedAt }
16
+ * { status: 'unknown', current, latest: null, checkedAt: null }
17
+ * { skipped: 'uninitialized' }
18
+ *
19
+ * Pure I/O is parameterized via fetchFn / nowFn for testability.
20
+ */
21
+ export async function refreshIfStale({
22
+ workspaceRoot,
23
+ ttlMs,
24
+ fetchFn = fetch,
25
+ nowFn = () => new Date(),
26
+ }) {
27
+ const wsConfigPath = join(workspaceRoot, 'workspace.json');
28
+ let wsConfig;
29
+ try {
30
+ wsConfig = JSON.parse(readFileSync(wsConfigPath, 'utf-8'));
31
+ } catch {
32
+ return { skipped: 'no-workspace-json' };
33
+ }
34
+ const current = wsConfig?.workspace?.templateVersion;
35
+ if (!current || current === '0.0.0') {
36
+ return { skipped: 'uninitialized' };
37
+ }
38
+
39
+ const scratchpadDir = wsConfig?.workspace?.scratchpadDir || 'workspace-scratchpad';
40
+ const cachePath = join(workspaceRoot, scratchpadDir, CACHE_FILENAME);
41
+ const bannerPath = join(workspaceRoot, BANNER_FILENAME);
42
+
43
+ let cache = readCache(cachePath);
44
+ const now = nowFn();
45
+ const cacheAgeMs = cache?.checkedAt ? now.getTime() - new Date(cache.checkedAt).getTime() : Infinity;
46
+ const stale = cacheAgeMs > ttlMs;
47
+
48
+ if (stale) {
49
+ const fresh = await getLatestVersion({ fetchFn });
50
+ if (fresh.version) {
51
+ cache = { latestVersion: fresh.version, checkedAt: now.toISOString() };
52
+ writeCache(cachePath, cache);
53
+ }
54
+ // On fetch error, keep whatever cache we already had (could be null).
55
+ }
56
+
57
+ if (!cache) {
58
+ // No cache and offline (or fetch error). Leave banner alone — we have no
59
+ // basis to write or delete it.
60
+ return { status: 'unknown', current, latest: null, checkedAt: null };
61
+ }
62
+
63
+ const latest = cache.latestVersion;
64
+ const cmp = compareVersions(current, latest);
65
+ if (cmp < 0) {
66
+ writeFileSync(
67
+ bannerPath,
68
+ `## Template update available\n\nYour workspace is on template v${current}; latest on npm is v${latest}.\nRun \`/maintenance\` for details or \`npx @ulysses-ai/create-workspace --upgrade\` to apply.\n`,
69
+ );
70
+ return { status: 'outdated', current, latest, checkedAt: cache.checkedAt };
71
+ } else {
72
+ if (existsSync(bannerPath)) unlinkSync(bannerPath);
73
+ return { status: 'current', current, latest, checkedAt: cache.checkedAt };
74
+ }
75
+ }
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env node
2
+ // Unit tests for freshness.mjs
3
+ // Run: node template/.claude/lib/freshness.test.mjs
4
+ import { refreshIfStale } from './freshness.mjs';
5
+ import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync, mkdirSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { tmpdir } from 'os';
8
+
9
+ let failed = 0;
10
+ let passed = 0;
11
+ function assertEq(actual, expected, msg) {
12
+ const a = JSON.stringify(actual);
13
+ const e = JSON.stringify(expected);
14
+ if (a === e) { passed++; } else {
15
+ failed++;
16
+ console.error(` FAIL: ${msg}\n expected: ${e}\n actual: ${a}`);
17
+ }
18
+ }
19
+
20
+ function setupWorkspace({ templateVersion, ambientBlock = '', cache = null } = {}) {
21
+ const root = mkdtempSync(join(tmpdir(), 'freshness-test-'));
22
+ const wsConfig = {
23
+ workspace: {
24
+ name: 'test',
25
+ scratchpadDir: 'workspace-scratchpad',
26
+ templateVersion,
27
+ ...(ambientBlock ? { versionCheck: { ambient: ambientBlock === 'on' } } : {}),
28
+ },
29
+ repos: {},
30
+ };
31
+ writeFileSync(join(root, 'workspace.json'), JSON.stringify(wsConfig));
32
+ if (cache) {
33
+ const cacheDir = join(root, 'workspace-scratchpad');
34
+ mkdirSync(cacheDir, { recursive: true });
35
+ writeFileSync(join(cacheDir, '.version-check.json'), JSON.stringify(cache));
36
+ }
37
+ return root;
38
+ }
39
+
40
+ function fakeFetchOk(version) {
41
+ return async () => ({ ok: true, json: async () => ({ version }) });
42
+ }
43
+
44
+ const fakeFetchErr = async () => { throw new Error('offline'); };
45
+
46
+ console.log('# refreshIfStale');
47
+
48
+ // Outdated workspace, fresh fetch, banner written
49
+ {
50
+ const root = setupWorkspace({ templateVersion: '0.13.0' });
51
+ const result = await refreshIfStale({
52
+ workspaceRoot: root,
53
+ ttlMs: 86400000,
54
+ fetchFn: fakeFetchOk('0.14.0'),
55
+ nowFn: () => new Date('2026-04-24T21:00:00Z'),
56
+ });
57
+ assertEq(result.status, 'outdated', 'outdated status');
58
+ assertEq(result.current, '0.13.0', 'current version reported');
59
+ assertEq(result.latest, '0.14.0', 'latest version reported');
60
+ assertEq(existsSync(join(root, 'local-only-template-freshness.md')), true, 'banner file created');
61
+ const banner = readFileSync(join(root, 'local-only-template-freshness.md'), 'utf-8');
62
+ assertEq(banner.includes('v0.13.0'), true, 'banner mentions current');
63
+ assertEq(banner.includes('v0.14.0'), true, 'banner mentions latest');
64
+ rmSync(root, { recursive: true, force: true });
65
+ }
66
+
67
+ // Up-to-date workspace, banner deleted if present
68
+ {
69
+ const root = setupWorkspace({ templateVersion: '0.14.0' });
70
+ // Pre-existing banner from when it was outdated
71
+ writeFileSync(join(root, 'local-only-template-freshness.md'), '## stale banner');
72
+ await refreshIfStale({
73
+ workspaceRoot: root,
74
+ ttlMs: 86400000,
75
+ fetchFn: fakeFetchOk('0.14.0'),
76
+ nowFn: () => new Date('2026-04-24T21:00:00Z'),
77
+ });
78
+ assertEq(existsSync(join(root, 'local-only-template-freshness.md')), false, 'banner deleted when current');
79
+ rmSync(root, { recursive: true, force: true });
80
+ }
81
+
82
+ // Fresh cache, no fetch happens
83
+ {
84
+ const root = setupWorkspace({
85
+ templateVersion: '0.13.0',
86
+ cache: { latestVersion: '0.14.0', checkedAt: '2026-04-24T20:00:00Z' },
87
+ });
88
+ let fetchCalled = false;
89
+ await refreshIfStale({
90
+ workspaceRoot: root,
91
+ ttlMs: 86400000,
92
+ fetchFn: async () => { fetchCalled = true; return { ok: true, json: async () => ({ version: '0.14.0' }) }; },
93
+ nowFn: () => new Date('2026-04-24T21:00:00Z'), // 1h after cache
94
+ });
95
+ assertEq(fetchCalled, false, 'fresh cache skips fetch');
96
+ assertEq(existsSync(join(root, 'local-only-template-freshness.md')), true, 'banner still written from cache');
97
+ rmSync(root, { recursive: true, force: true });
98
+ }
99
+
100
+ // Stale cache triggers fetch
101
+ {
102
+ const root = setupWorkspace({
103
+ templateVersion: '0.13.0',
104
+ cache: { latestVersion: '0.13.5', checkedAt: '2026-04-20T20:00:00Z' },
105
+ });
106
+ let fetchCalled = false;
107
+ await refreshIfStale({
108
+ workspaceRoot: root,
109
+ ttlMs: 86400000,
110
+ fetchFn: async () => { fetchCalled = true; return { ok: true, json: async () => ({ version: '0.14.0' }) }; },
111
+ nowFn: () => new Date('2026-04-24T21:00:00Z'),
112
+ });
113
+ assertEq(fetchCalled, true, 'stale cache triggers fetch');
114
+ rmSync(root, { recursive: true, force: true });
115
+ }
116
+
117
+ // Stale cache + offline: keep cached value, return unknown only if no cache
118
+ {
119
+ const root = setupWorkspace({
120
+ templateVersion: '0.13.0',
121
+ cache: { latestVersion: '0.13.9', checkedAt: '2026-04-20T20:00:00Z' },
122
+ });
123
+ const result = await refreshIfStale({
124
+ workspaceRoot: root,
125
+ ttlMs: 86400000,
126
+ fetchFn: fakeFetchErr,
127
+ nowFn: () => new Date('2026-04-24T21:00:00Z'),
128
+ });
129
+ assertEq(result.status, 'outdated', 'falls back to cached value when offline');
130
+ assertEq(result.latest, '0.13.9', 'cached value used');
131
+ rmSync(root, { recursive: true, force: true });
132
+ }
133
+
134
+ // No cache + offline: status unknown
135
+ {
136
+ const root = setupWorkspace({ templateVersion: '0.13.0' });
137
+ const result = await refreshIfStale({
138
+ workspaceRoot: root,
139
+ ttlMs: 86400000,
140
+ fetchFn: fakeFetchErr,
141
+ nowFn: () => new Date('2026-04-24T21:00:00Z'),
142
+ });
143
+ assertEq(result.status, 'unknown', 'unknown when no cache and offline');
144
+ rmSync(root, { recursive: true, force: true });
145
+ }
146
+
147
+ // Uninitialized workspace (templateVersion missing)
148
+ {
149
+ const root = mkdtempSync(join(tmpdir(), 'freshness-test-'));
150
+ writeFileSync(join(root, 'workspace.json'), JSON.stringify({ workspace: {}, repos: {} }));
151
+ const result = await refreshIfStale({
152
+ workspaceRoot: root,
153
+ ttlMs: 86400000,
154
+ fetchFn: fakeFetchOk('0.14.0'),
155
+ nowFn: () => new Date('2026-04-24T21:00:00Z'),
156
+ });
157
+ assertEq(result.skipped, 'uninitialized', 'uninitialized workspace skipped');
158
+ rmSync(root, { recursive: true, force: true });
159
+ }
160
+
161
+ // templateVersion 0.0.0 treated as uninitialized
162
+ {
163
+ const root = setupWorkspace({ templateVersion: '0.0.0' });
164
+ const result = await refreshIfStale({
165
+ workspaceRoot: root,
166
+ ttlMs: 86400000,
167
+ fetchFn: fakeFetchOk('0.14.0'),
168
+ nowFn: () => new Date('2026-04-24T21:00:00Z'),
169
+ });
170
+ assertEq(result.skipped, 'uninitialized', '0.0.0 treated as uninitialized');
171
+ rmSync(root, { recursive: true, force: true });
172
+ }
173
+
174
+ console.log(`\n${passed} passed, ${failed} failed`);
175
+ process.exit(failed > 0 ? 1 : 0);
@@ -0,0 +1,106 @@
1
+ import './require-node.mjs';
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
3
+ import { dirname } from 'path';
4
+
5
+ /**
6
+ * SemVer 2.0 comparison limited to the formats this scaffolder publishes:
7
+ * `x.y.z` and `x.y.z-prerelease.N`. Returns -1, 0, or 1.
8
+ *
9
+ * Rules:
10
+ * - Compare major, minor, patch numerically.
11
+ * - A pre-release version is older than the same x.y.z without a tag.
12
+ * - Pre-release identifiers compare per-identifier; numeric identifiers
13
+ * compare numerically (so `beta.10 > beta.2`), non-numeric lexically.
14
+ */
15
+ export function compareVersions(a, b) {
16
+ if (a === b) return 0;
17
+ const [aBase, aPre] = a.split('-', 2);
18
+ const [bBase, bPre] = b.split('-', 2);
19
+ const aParts = aBase.split('.').map(Number);
20
+ const bParts = bBase.split('.').map(Number);
21
+ for (let i = 0; i < 3; i++) {
22
+ if ((aParts[i] || 0) < (bParts[i] || 0)) return -1;
23
+ if ((aParts[i] || 0) > (bParts[i] || 0)) return 1;
24
+ }
25
+ if (!aPre && !bPre) return 0;
26
+ if (!aPre && bPre) return 1;
27
+ if (aPre && !bPre) return -1;
28
+ const aIds = aPre.split('.');
29
+ const bIds = bPre.split('.');
30
+ const len = Math.max(aIds.length, bIds.length);
31
+ for (let i = 0; i < len; i++) {
32
+ const ai = aIds[i];
33
+ const bi = bIds[i];
34
+ if (ai === undefined) return -1;
35
+ if (bi === undefined) return 1;
36
+ const aNum = /^\d+$/.test(ai);
37
+ const bNum = /^\d+$/.test(bi);
38
+ if (aNum && bNum) {
39
+ const an = Number(ai), bn = Number(bi);
40
+ if (an < bn) return -1;
41
+ if (an > bn) return 1;
42
+ } else if (aNum && !bNum) {
43
+ return -1;
44
+ } else if (!aNum && bNum) {
45
+ return 1;
46
+ } else {
47
+ if (ai < bi) return -1;
48
+ if (ai > bi) return 1;
49
+ }
50
+ }
51
+ return 0;
52
+ }
53
+
54
+ const REGISTRY_URL = 'https://registry.npmjs.org/@ulysses-ai/create-workspace/latest';
55
+ const DEFAULT_TIMEOUT_MS = 3000;
56
+
57
+ /**
58
+ * Fetch the latest version of the scaffolder from the npm registry.
59
+ * Returns { version, error } — exactly one of them is non-null.
60
+ *
61
+ * Caller injects fetchFn for testing. Default uses global fetch (Node 18+).
62
+ */
63
+ export async function getLatestVersion({ fetchFn = fetch, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
64
+ const controller = new AbortController();
65
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
66
+ try {
67
+ const res = await fetchFn(REGISTRY_URL, { signal: controller.signal });
68
+ if (!res.ok) {
69
+ return { version: null, error: `registry returned ${res.status} ${res.statusText || ''}`.trim() };
70
+ }
71
+ const body = await res.json();
72
+ if (typeof body?.version !== 'string') {
73
+ return { version: null, error: 'registry response missing version field' };
74
+ }
75
+ return { version: body.version, error: null };
76
+ } catch (err) {
77
+ return { version: null, error: err?.message || String(err) };
78
+ } finally {
79
+ clearTimeout(timer);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Read the version cache file. Returns the parsed object if it has a string
85
+ * `latestVersion` field; otherwise null. Treats missing file, malformed JSON,
86
+ * and shape mismatches all as "no cache".
87
+ */
88
+ export function readCache(path) {
89
+ if (!existsSync(path)) return null;
90
+ try {
91
+ const data = JSON.parse(readFileSync(path, 'utf-8'));
92
+ if (typeof data?.latestVersion !== 'string') return null;
93
+ return data;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Write the version cache file, creating parent directories as needed.
101
+ */
102
+ export function writeCache(path, data) {
103
+ const dir = dirname(path);
104
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
105
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
106
+ }
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ // Unit tests for registry-check.mjs
3
+ // Run: node template/.claude/lib/registry-check.test.mjs
4
+ import { compareVersions } from './registry-check.mjs';
5
+
6
+ let failed = 0;
7
+ let passed = 0;
8
+
9
+ function assertEq(actual, expected, msg) {
10
+ const a = JSON.stringify(actual);
11
+ const e = JSON.stringify(expected);
12
+ if (a === e) { passed++; } else {
13
+ failed++;
14
+ console.error(` FAIL: ${msg}\n expected: ${e}\n actual: ${a}`);
15
+ }
16
+ }
17
+
18
+ console.log('# compareVersions');
19
+ assertEq(compareVersions('0.13.0', '0.14.0'), -1, 'older minor');
20
+ assertEq(compareVersions('0.14.0', '0.13.0'), 1, 'newer minor');
21
+ assertEq(compareVersions('0.13.0', '0.13.0'), 0, 'equal');
22
+ assertEq(compareVersions('1.0.0', '0.99.99'), 1, 'newer major');
23
+ assertEq(compareVersions('0.14.0-beta.1', '0.14.0'), -1, 'prerelease < release');
24
+ assertEq(compareVersions('0.14.0', '0.14.0-beta.1'), 1, 'release > prerelease');
25
+ assertEq(compareVersions('0.14.0-beta.1', '0.14.0-beta.2'), -1, 'prerelease numeric ordering');
26
+ assertEq(compareVersions('0.14.0-beta.10', '0.14.0-beta.2'), 1, 'prerelease numeric not lexical');
27
+ assertEq(compareVersions('0.14.0-beta.5', '0.14.0-beta.5'), 0, 'equal prerelease');
28
+ assertEq(compareVersions('0.14.0-alpha.1', '0.14.0-beta.1'),-1, 'alpha < beta lexical');
29
+
30
+ import { getLatestVersion } from './registry-check.mjs';
31
+
32
+ console.log('\n# getLatestVersion');
33
+
34
+ // Helper: build a fake fetch
35
+ function fakeFetch(response) {
36
+ return async () => response;
37
+ }
38
+
39
+ // Success path
40
+ {
41
+ const fakeOk = fakeFetch({
42
+ ok: true,
43
+ json: async () => ({ version: '0.14.0', name: '@ulysses-ai/create-workspace' }),
44
+ });
45
+ const result = await getLatestVersion({ fetchFn: fakeOk });
46
+ assertEq(result, { version: '0.14.0', error: null }, 'success returns version');
47
+ }
48
+
49
+ // Non-2xx response
50
+ {
51
+ const fake404 = fakeFetch({ ok: false, status: 404, statusText: 'Not Found' });
52
+ const result = await getLatestVersion({ fetchFn: fake404 });
53
+ assertEq(result.version, null, 'non-2xx version is null');
54
+ assertEq(typeof result.error, 'string', 'non-2xx returns error string');
55
+ }
56
+
57
+ // Malformed body (no version field)
58
+ {
59
+ const fakeBad = fakeFetch({ ok: true, json: async () => ({ name: 'foo' }) });
60
+ const result = await getLatestVersion({ fetchFn: fakeBad });
61
+ assertEq(result.version, null, 'missing version field is null');
62
+ assertEq(typeof result.error, 'string', 'missing version returns error');
63
+ }
64
+
65
+ // Network error (fetch throws)
66
+ {
67
+ const fakeThrow = async () => { throw new Error('ECONNREFUSED'); };
68
+ const result = await getLatestVersion({ fetchFn: fakeThrow });
69
+ assertEq(result.version, null, 'thrown error returns null version');
70
+ assertEq(result.error.includes('ECONNREFUSED'), true, 'thrown error message preserved');
71
+ }
72
+
73
+ import { readCache, writeCache } from './registry-check.mjs';
74
+ import { mkdtempSync, rmSync, writeFileSync } from 'fs';
75
+ import { join } from 'path';
76
+ import { tmpdir } from 'os';
77
+
78
+ console.log('\n# readCache / writeCache');
79
+
80
+ function withTempDir(fn) {
81
+ const dir = mkdtempSync(join(tmpdir(), 'reg-cache-test-'));
82
+ try { fn(dir); } finally { rmSync(dir, { recursive: true, force: true }); }
83
+ }
84
+
85
+ // readCache: missing file returns null
86
+ withTempDir((dir) => {
87
+ const result = readCache(join(dir, 'missing.json'));
88
+ assertEq(result, null, 'missing file returns null');
89
+ });
90
+
91
+ // readCache: malformed JSON returns null
92
+ withTempDir((dir) => {
93
+ const path = join(dir, 'bad.json');
94
+ writeFileSync(path, '{not json');
95
+ const result = readCache(path);
96
+ assertEq(result, null, 'malformed JSON returns null');
97
+ });
98
+
99
+ // readCache: valid file returns parsed object
100
+ withTempDir((dir) => {
101
+ const path = join(dir, 'good.json');
102
+ writeFileSync(path, JSON.stringify({ latestVersion: '0.14.0', checkedAt: '2026-04-24T21:00:00Z' }));
103
+ const result = readCache(path);
104
+ assertEq(result, { latestVersion: '0.14.0', checkedAt: '2026-04-24T21:00:00Z' }, 'valid file parsed');
105
+ });
106
+
107
+ // readCache: missing required fields returns null
108
+ withTempDir((dir) => {
109
+ const path = join(dir, 'partial.json');
110
+ writeFileSync(path, JSON.stringify({ checkedAt: '2026-04-24T21:00:00Z' }));
111
+ const result = readCache(path);
112
+ assertEq(result, null, 'missing latestVersion returns null');
113
+ });
114
+
115
+ // writeCache + readCache round-trip
116
+ withTempDir((dir) => {
117
+ const path = join(dir, 'rt.json');
118
+ writeCache(path, { latestVersion: '0.14.0', checkedAt: '2026-04-24T21:00:00Z' });
119
+ assertEq(readCache(path), { latestVersion: '0.14.0', checkedAt: '2026-04-24T21:00:00Z' }, 'round-trip equal');
120
+ });
121
+
122
+ // writeCache creates parent dir if missing
123
+ withTempDir((dir) => {
124
+ const path = join(dir, 'nested', 'deep', 'cache.json');
125
+ writeCache(path, { latestVersion: '0.14.0', checkedAt: '2026-04-24T21:00:00Z' });
126
+ assertEq(readCache(path)?.latestVersion, '0.14.0', 'parent dir created');
127
+ });
128
+
129
+ console.log(`\n${passed} passed, ${failed} failed`);
130
+ process.exit(failed > 0 ? 1 : 0);