@ulysses-ai/create-workspace 0.14.0-beta.1 → 0.14.0-beta.3
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/README.md +1 -1
- package/package.json +1 -1
- package/template/.claude/hooks/version-freshness-check.mjs +30 -0
- package/template/.claude/lib/freshness.mjs +75 -0
- package/template/.claude/lib/freshness.test.mjs +175 -0
- package/template/.claude/lib/registry-check.mjs +106 -0
- package/template/.claude/lib/registry-check.test.mjs +130 -0
- package/template/.claude/rules/memory-guidance.md +47 -0
- package/template/.claude/rules/workspace-structure.md +2 -0
- package/template/.claude/scripts/build-shared-context-index.mjs +212 -0
- package/template/.claude/scripts/build-shared-context-index.test.mjs +318 -0
- package/template/.claude/scripts/migrate-claude-md-freshness-include.mjs +30 -0
- package/template/.claude/scripts/migrate-claude-md-freshness-include.test.mjs +54 -0
- package/template/.claude/settings.json +6 -0
- package/template/.claude/skills/maintenance/SKILL.md +49 -7
- package/template/.claude/skills/workspace-update/SKILL.md +16 -0
- package/template/CLAUDE.md.tmpl +1 -0
- package/template/_gitignore +2 -3
- package/template/workspace.json.tmpl +1 -0
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
|
-
- **
|
|
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
|
@@ -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);
|
|
@@ -24,3 +24,50 @@ When a work session is active:
|
|
|
24
24
|
- Decisions and progress from this session → update the session tracker body at `work-sessions/{name}/workspace/session.md` (consumed by /complete-work)
|
|
25
25
|
- Patterns, corrections, and insights that apply beyond this session → auto-memory (persists across all sessions)
|
|
26
26
|
- Don't duplicate: if something is already in the session tracker, don't also save it to auto-memory
|
|
27
|
+
|
|
28
|
+
## Shared-Context Frontmatter
|
|
29
|
+
|
|
30
|
+
Every shared-context file should have YAML frontmatter. The fields below are conventions, not all required.
|
|
31
|
+
|
|
32
|
+
**Standard fields:**
|
|
33
|
+
|
|
34
|
+
- `state` — `locked` (team truth) or `ephemeral` (working context). Locked files live under `locked/`; ephemeral files live elsewhere.
|
|
35
|
+
- `lifecycle` — for ephemeral files: `active` (still relevant) or `resolved` (handled, kept for record).
|
|
36
|
+
- `type` — kind of content: `reference`, `braindump`, `handoff`, `research`, `design`, `index`, `promoted`.
|
|
37
|
+
- `topic` — kebab-case slug matching the filename (without `.md`).
|
|
38
|
+
- `author` — username scope owner. Required for `{user}/` files.
|
|
39
|
+
- `updated` — ISO date of last meaningful edit. `/maintenance` flags stale `lifecycle: active` files based on this.
|
|
40
|
+
|
|
41
|
+
**Index-feeding field:**
|
|
42
|
+
|
|
43
|
+
- `description` — one-line summary, used verbatim by `shared-context/index.md`. When omitted, the index falls back to the first sentence of the body, then the filename slug. Adding a `description:` to a file with a weak fallback is the cheapest way to improve the index.
|
|
44
|
+
|
|
45
|
+
**Optional confidence marker:**
|
|
46
|
+
|
|
47
|
+
- `confidence` — `high` | `medium` | `low`. Apply to research, design, and exploration files where the conclusions might still shift. Skip on locked files (locked = high by definition) and on workflow artifacts like handoffs and braindumps. The frontmatter integrity check in `/maintenance` validates the value if present.
|
|
48
|
+
|
|
49
|
+
**Example for a research file:**
|
|
50
|
+
|
|
51
|
+
```yaml
|
|
52
|
+
---
|
|
53
|
+
state: ephemeral
|
|
54
|
+
lifecycle: active
|
|
55
|
+
type: research
|
|
56
|
+
topic: vector-search-evaluation
|
|
57
|
+
description: Evaluation of FAISS for shared-context — concluded NL index is sufficient at our scale.
|
|
58
|
+
author: alex
|
|
59
|
+
confidence: medium
|
|
60
|
+
updated: 2026-04-25
|
|
61
|
+
---
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Shared-Context Index
|
|
65
|
+
|
|
66
|
+
`shared-context/index.md` is auto-generated by `.claude/scripts/build-shared-context-index.mjs`. It's a one-line catalog of every shared-context file (excluding paths in `shared-context/.indexignore`). The script regenerates from frontmatter on demand:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
node .claude/scripts/build-shared-context-index.mjs --check --root . # exits 1 if stale
|
|
70
|
+
node .claude/scripts/build-shared-context-index.mjs --write --root . # regenerate
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`/maintenance` checks the index for staleness in audit mode and regenerates in cleanup mode. Hand edits to `index.md` are overwritten — update source files (or their `description:` frontmatter) instead.
|
|
@@ -15,6 +15,8 @@ This workspace follows the claude-workspace convention. All paths are relative t
|
|
|
15
15
|
| `work-sessions/{name}/workspace/repos/` | Real directory holding nested project worktrees for this session | No (gitignored) |
|
|
16
16
|
| `work-sessions/{name}/workspace/repos/{repo}/` | Project worktree nested inside the workspace worktree | No (gitignored) |
|
|
17
17
|
| `shared-context/` | Shared memory — handoffs, braindumps, team knowledge | Yes |
|
|
18
|
+
| `shared-context/index.md` | Auto-generated catalog of every shared-context file (one-line per file) | Yes |
|
|
19
|
+
| `shared-context/.indexignore` | Path prefixes to exclude from `index.md` (e.g., archived release notes) | Yes |
|
|
18
20
|
| `shared-context/locked/` | Team truths — loaded every session, injected into subagents | Yes |
|
|
19
21
|
| `shared-context/{user}/` | User-scoped working context — default for all captures | Yes |
|
|
20
22
|
| `workspace-scratchpad/` | Disposable workspace-scoped files — session log, hook debug output | No (gitignored, lazy) |
|