@ulysses-ai/create-workspace 0.14.0-beta.3 → 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 (41) hide show
  1. package/package.json +1 -1
  2. package/template/.claude/agents/reviewer.md +1 -1
  3. package/template/.claude/hooks/pre-compact.mjs +1 -1
  4. package/template/.claude/hooks/repo-write-detection.mjs +2 -2
  5. package/template/.claude/hooks/session-start.mjs +10 -7
  6. package/template/.claude/hooks/subagent-start.mjs +3 -3
  7. package/template/.claude/recipes/migrate-from-notion.md +6 -6
  8. package/template/.claude/rules/coherent-revisions.md +2 -2
  9. package/template/.claude/rules/local-dev-environment.md.skip +2 -2
  10. package/template/.claude/rules/memory-guidance.md +20 -14
  11. package/template/.claude/rules/token-economics.md.skip +2 -2
  12. package/template/.claude/rules/work-item-tracking.md +1 -1
  13. package/template/.claude/rules/workspace-structure.md +36 -15
  14. package/template/.claude/scripts/build-workspace-context.mjs +365 -0
  15. package/template/.claude/scripts/build-workspace-context.test.mjs +633 -0
  16. package/template/.claude/scripts/capture-context.mjs +217 -0
  17. package/template/.claude/scripts/capture-context.test.mjs +383 -0
  18. package/template/.claude/scripts/generate-claude-local.mjs +104 -0
  19. package/template/.claude/scripts/generate-claude-local.test.mjs +184 -0
  20. package/template/.claude/scripts/migrate-open-work.mjs +1 -1
  21. package/template/.claude/scripts/migrate-to-workspace-context.mjs +520 -0
  22. package/template/.claude/scripts/migrate-to-workspace-context.test.mjs +325 -0
  23. package/template/.claude/scripts/sweep-references.mjs +177 -0
  24. package/template/.claude/scripts/sweep-references.test.mjs +184 -0
  25. package/template/.claude/skills/aside/SKILL.md +49 -44
  26. package/template/.claude/skills/braindump/SKILL.md +25 -19
  27. package/template/.claude/skills/build-docs-site/SKILL.md +1 -1
  28. package/template/.claude/skills/build-docs-site/checklists/framing.md +1 -1
  29. package/template/.claude/skills/complete-work/SKILL.md +3 -3
  30. package/template/.claude/skills/handoff/SKILL.md +31 -30
  31. package/template/.claude/skills/maintenance/SKILL.md +18 -18
  32. package/template/.claude/skills/pause-work/SKILL.md +1 -1
  33. package/template/.claude/skills/promote/SKILL.md +18 -8
  34. package/template/.claude/skills/release/SKILL.md +17 -13
  35. package/template/.claude/skills/start-work/SKILL.md +1 -1
  36. package/template/.claude/skills/workspace-init/SKILL.md +12 -12
  37. package/template/CLAUDE.md.tmpl +4 -3
  38. package/template/_gitignore +1 -0
  39. package/template/workspace.json.tmpl +2 -2
  40. package/template/.claude/scripts/build-shared-context-index.mjs +0 -212
  41. package/template/.claude/scripts/build-shared-context-index.test.mjs +0 -318
@@ -0,0 +1,365 @@
1
+ #!/usr/bin/env node
2
+ // Generate workspace-context auto-files from filesystem state.
3
+ //
4
+ // One pass, three artifacts:
5
+ // workspace-context/index.md — navigation surface (shared/ + shared/locked/)
6
+ // workspace-context/canonical.md — full-content concat of shared/locked/*.md
7
+ // workspace-context/team-member/{user}/index.md — per-user navigation
8
+ //
9
+ // Source of truth: the filesystem. Hand edits are overwritten on regeneration.
10
+ // Gitignored files are excluded automatically. .indexignore adds prefix excludes.
11
+ //
12
+ // Usage:
13
+ // node build-workspace-context.mjs --write [--root <workspace-root>]
14
+ // node build-workspace-context.mjs --check [--root <workspace-root>]
15
+ //
16
+ // --write regenerates all three artifacts.
17
+ // --check exits 0 if everything matches, 1 if any is stale or missing. Reports per-file status.
18
+
19
+ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, realpathSync } from 'node:fs';
20
+ import { join, relative, sep } from 'node:path';
21
+ import { spawnSync } from 'node:child_process';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { parseSessionContent } from '../lib/session-frontmatter.mjs';
24
+
25
+ function isMainModule(metaUrl) {
26
+ if (!process.argv[1]) return false;
27
+ try {
28
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
29
+ } catch { return false; }
30
+ }
31
+
32
+ const WC_DIR = 'workspace-context';
33
+ const SHARED_DIR = 'shared';
34
+ const LOCKED_DIR = 'locked';
35
+ const TEAM_MEMBER_DIR = 'team-member';
36
+ const INDEX_FILENAME = 'index.md';
37
+ const CANONICAL_FILENAME = 'canonical.md';
38
+ const IGNORE_FILENAME = '.indexignore';
39
+
40
+ function parseArgs(argv) {
41
+ const args = { mode: null, root: process.cwd() };
42
+ for (let i = 2; i < argv.length; i++) {
43
+ const a = argv[i];
44
+ if (a === '--write') args.mode = 'write';
45
+ else if (a === '--check') args.mode = 'check';
46
+ else if (a === '--root') args.root = argv[++i];
47
+ }
48
+ if (!args.mode) throw new Error('Specify --write or --check');
49
+ return args;
50
+ }
51
+
52
+ function walkMarkdown(dir) {
53
+ const out = [];
54
+ if (!existsSync(dir)) return out;
55
+ for (const name of readdirSync(dir)) {
56
+ const full = join(dir, name);
57
+ const st = statSync(full);
58
+ if (st.isDirectory()) out.push(...walkMarkdown(full));
59
+ else if (st.isFile() && name.endsWith('.md')) out.push(full);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ function readDescription(filePath) {
65
+ let frontmatter = {};
66
+ let body = '';
67
+ try {
68
+ const parsed = parseSessionContent(readFileSync(filePath, 'utf-8'));
69
+ frontmatter = parsed.fields || {};
70
+ body = parsed.body || '';
71
+ } catch {
72
+ body = readFileSync(filePath, 'utf-8');
73
+ }
74
+
75
+ if (typeof frontmatter.description === 'string' && frontmatter.description.trim()) {
76
+ return frontmatter.description.trim();
77
+ }
78
+ const stripped = body.replace(/^#.*$/m, '').trim();
79
+ const firstParagraph = stripped.split(/\n\s*\n/, 1)[0] || '';
80
+ const firstSentence = firstParagraph.replace(/\n/g, ' ').match(/[^.!?]+[.!?]/);
81
+ if (firstSentence) {
82
+ const candidate = firstSentence[0].trim();
83
+ if (candidate.length > 0 && candidate.length <= 200) return candidate;
84
+ }
85
+ const filename = filePath.split(sep).pop() || '';
86
+ return filename.replace(/\.md$/, '').replace(/^(braindump|handoff|research)_/, '').replace(/-/g, ' ');
87
+ }
88
+
89
+ function readIgnorePrefixes(wcDir) {
90
+ const ignorePath = join(wcDir, IGNORE_FILENAME);
91
+ if (!existsSync(ignorePath)) return [];
92
+ return readFileSync(ignorePath, 'utf-8')
93
+ .split('\n')
94
+ .map((l) => l.replace(/#.*/, '').trim())
95
+ .filter((l) => l.length > 0);
96
+ }
97
+
98
+ function isIgnored(relativePath, prefixes) {
99
+ for (const prefix of prefixes) {
100
+ if (relativePath === prefix) return true;
101
+ if (relativePath.startsWith(prefix.endsWith('/') ? prefix : prefix + '/')) return true;
102
+ }
103
+ return false;
104
+ }
105
+
106
+ function gitIgnoredPaths(workspaceRoot, paths) {
107
+ if (paths.length === 0) return new Set();
108
+ const result = spawnSync('git', ['check-ignore', '--stdin'], {
109
+ cwd: workspaceRoot,
110
+ input: paths.join('\n'),
111
+ encoding: 'utf-8',
112
+ });
113
+ if (result.error || (result.status !== 0 && result.status !== 1)) return new Set();
114
+ return new Set(
115
+ result.stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0),
116
+ );
117
+ }
118
+
119
+ function stripFrontmatter(content) {
120
+ if (!content.startsWith('---\n')) return content;
121
+ const end = content.indexOf('\n---\n', 4);
122
+ if (end === -1) return content;
123
+ return content.slice(end + 5).replace(/^\n+/, '');
124
+ }
125
+
126
+ function describeAndPath(filePath, wcRoot) {
127
+ const rel = relative(wcRoot, filePath).split(sep).join('/');
128
+ return { rel, description: readDescription(filePath) };
129
+ }
130
+
131
+ // ---------- shared index ----------
132
+
133
+ function buildSharedIndex(workspaceRoot) {
134
+ const wcRoot = join(workspaceRoot, WC_DIR);
135
+ const sharedDir = join(wcRoot, SHARED_DIR);
136
+ if (!existsSync(sharedDir)) return [];
137
+
138
+ const ignorePrefixes = readIgnorePrefixes(wcRoot);
139
+ const candidates = walkMarkdown(sharedDir);
140
+ const candidatePaths = candidates.map((f) => relative(workspaceRoot, f).split(sep).join('/'));
141
+ const gitIgnored = gitIgnoredPaths(workspaceRoot, candidatePaths);
142
+
143
+ const entries = [];
144
+ for (let i = 0; i < candidates.length; i++) {
145
+ const f = candidates[i];
146
+ const relToWC = relative(wcRoot, f).split(sep).join('/');
147
+ if (relToWC === INDEX_FILENAME || relToWC === CANONICAL_FILENAME) continue;
148
+ if (isIgnored(relToWC, ignorePrefixes)) continue;
149
+ if (gitIgnored.has(candidatePaths[i])) continue;
150
+ const isLocked = relToWC.startsWith(`${SHARED_DIR}/${LOCKED_DIR}/`);
151
+ const { description } = describeAndPath(f, wcRoot);
152
+ entries.push({ rel: relToWC, isLocked, description });
153
+ }
154
+ entries.sort((a, b) => {
155
+ if (a.isLocked !== b.isLocked) return a.isLocked ? -1 : 1;
156
+ return a.rel.localeCompare(b.rel);
157
+ });
158
+ return entries;
159
+ }
160
+
161
+ function renderSharedIndex(entries, generatedAt) {
162
+ const lines = [
163
+ '---',
164
+ 'type: index',
165
+ `generated: ${generatedAt}`,
166
+ '---',
167
+ '',
168
+ '# workspace-context — index',
169
+ '',
170
+ '> Auto-generated by `.claude/scripts/build-workspace-context.mjs`. Hand edits will be overwritten — update source files instead.',
171
+ '',
172
+ ];
173
+ const locked = entries.filter((e) => e.isLocked);
174
+ const other = entries.filter((e) => !e.isLocked);
175
+ if (locked.length > 0) {
176
+ lines.push('## Canonical (in CLAUDE.md context verbatim)', '');
177
+ for (const e of locked) lines.push(`- [${e.rel}](${e.rel}) — ${e.description}`);
178
+ lines.push('');
179
+ }
180
+ if (other.length > 0) {
181
+ lines.push('## Shared', '');
182
+ for (const e of other) lines.push(`- [${e.rel}](${e.rel}) — ${e.description}`);
183
+ lines.push('');
184
+ }
185
+ if (entries.length === 0) {
186
+ lines.push('_(no shared workspace-context files yet)_', '');
187
+ }
188
+ return lines.join('\n');
189
+ }
190
+
191
+ // ---------- canonical concat ----------
192
+
193
+ function buildCanonical(workspaceRoot) {
194
+ const lockedDir = join(workspaceRoot, WC_DIR, SHARED_DIR, LOCKED_DIR);
195
+ if (!existsSync(lockedDir)) return [];
196
+ return walkMarkdown(lockedDir)
197
+ .filter((f) => !f.endsWith('.keep'))
198
+ .sort()
199
+ .map((f) => ({
200
+ name: f.split(sep).pop().replace(/\.md$/, ''),
201
+ content: stripFrontmatter(readFileSync(f, 'utf-8')).trimEnd(),
202
+ }));
203
+ }
204
+
205
+ function renderCanonical(items, generatedAt) {
206
+ const lines = [
207
+ '---',
208
+ 'type: canonical',
209
+ `generated: ${generatedAt}`,
210
+ '---',
211
+ '',
212
+ '# workspace-context — canonical truths',
213
+ '',
214
+ '> Auto-generated concatenation of `shared/locked/*.md`. Hand edits will be overwritten — update source files instead.',
215
+ '',
216
+ ];
217
+ for (const item of items) {
218
+ lines.push(`## ${item.name}`, '', item.content, '');
219
+ }
220
+ if (items.length === 0) {
221
+ lines.push('_(no canonical entries yet — promote one via `/release`)_', '');
222
+ }
223
+ return lines.join('\n');
224
+ }
225
+
226
+ // ---------- team-member indexes ----------
227
+
228
+ function buildTeamMemberIndex(workspaceRoot, user) {
229
+ const userDir = join(workspaceRoot, WC_DIR, TEAM_MEMBER_DIR, user);
230
+ if (!existsSync(userDir)) return [];
231
+
232
+ const candidates = walkMarkdown(userDir).filter(
233
+ (f) => f.split(sep).pop() !== INDEX_FILENAME,
234
+ );
235
+ const candidatePaths = candidates.map((f) => relative(workspaceRoot, f).split(sep).join('/'));
236
+ const gitIgnored = gitIgnoredPaths(workspaceRoot, candidatePaths);
237
+
238
+ const entries = [];
239
+ for (let i = 0; i < candidates.length; i++) {
240
+ if (gitIgnored.has(candidatePaths[i])) continue;
241
+ const relToUserDir = relative(userDir, candidates[i]).split(sep).join('/');
242
+ const description = readDescription(candidates[i]);
243
+ entries.push({ rel: relToUserDir, description });
244
+ }
245
+ entries.sort((a, b) => a.rel.localeCompare(b.rel));
246
+ return entries;
247
+ }
248
+
249
+ function renderTeamMemberIndex(user, entries, generatedAt) {
250
+ const lines = [
251
+ '---',
252
+ 'type: index',
253
+ `generated: ${generatedAt}`,
254
+ '---',
255
+ '',
256
+ `# ${user}'s context`,
257
+ '',
258
+ '> Auto-generated by `.claude/scripts/build-workspace-context.mjs`. Hand edits will be overwritten.',
259
+ '',
260
+ ];
261
+ for (const e of entries) {
262
+ lines.push(`- [${e.rel}](${e.rel}) — ${e.description}`);
263
+ }
264
+ if (entries.length === 0) {
265
+ lines.push('_(no personal context files yet)_');
266
+ }
267
+ return lines.join('\n') + '\n';
268
+ }
269
+
270
+ function listTeamMembers(workspaceRoot) {
271
+ const tmDir = join(workspaceRoot, WC_DIR, TEAM_MEMBER_DIR);
272
+ if (!existsSync(tmDir)) return [];
273
+ return readdirSync(tmDir)
274
+ .filter((name) => {
275
+ const full = join(tmDir, name);
276
+ return statSync(full).isDirectory();
277
+ })
278
+ .sort();
279
+ }
280
+
281
+ // ---------- orchestration ----------
282
+
283
+ function fingerprint(content) {
284
+ return content
285
+ .split('\n')
286
+ .filter((l) => !l.startsWith('generated:'))
287
+ .join('\n');
288
+ }
289
+
290
+ function regenerateAll(workspaceRoot, generatedAt) {
291
+ const wcRoot = join(workspaceRoot, WC_DIR);
292
+ if (!existsSync(wcRoot)) return [];
293
+
294
+ const out = [];
295
+ const sharedEntries = buildSharedIndex(workspaceRoot);
296
+ out.push({
297
+ path: join(wcRoot, INDEX_FILENAME),
298
+ label: 'index.md',
299
+ content: renderSharedIndex(sharedEntries, generatedAt) + '\n',
300
+ });
301
+
302
+ const canonicalItems = buildCanonical(workspaceRoot);
303
+ out.push({
304
+ path: join(wcRoot, CANONICAL_FILENAME),
305
+ label: 'canonical.md',
306
+ content: renderCanonical(canonicalItems, generatedAt) + '\n',
307
+ });
308
+
309
+ for (const user of listTeamMembers(workspaceRoot)) {
310
+ const entries = buildTeamMemberIndex(workspaceRoot, user);
311
+ out.push({
312
+ path: join(wcRoot, TEAM_MEMBER_DIR, user, INDEX_FILENAME),
313
+ label: `team-member/${user}/index.md`,
314
+ content: renderTeamMemberIndex(user, entries, generatedAt),
315
+ });
316
+ }
317
+
318
+ return out;
319
+ }
320
+
321
+ function main() {
322
+ const args = parseArgs(process.argv);
323
+ const generatedAt = new Date().toISOString();
324
+ const artifacts = regenerateAll(args.root, generatedAt);
325
+
326
+ if (args.mode === 'check') {
327
+ const stale = [];
328
+ const missing = [];
329
+ for (const a of artifacts) {
330
+ if (!existsSync(a.path)) { missing.push(a.label); continue; }
331
+ const onDisk = readFileSync(a.path, 'utf-8');
332
+ if (fingerprint(onDisk) !== fingerprint(a.content)) stale.push(a.label);
333
+ }
334
+ if (missing.length === 0 && stale.length === 0) {
335
+ process.stdout.write(JSON.stringify({ status: 'current', artifacts: artifacts.length }) + '\n');
336
+ process.exit(0);
337
+ }
338
+ process.stdout.write(JSON.stringify({ status: 'stale', missing, stale }) + '\n');
339
+ process.exit(1);
340
+ }
341
+
342
+ if (args.mode === 'write') {
343
+ for (const a of artifacts) writeFileSync(a.path, a.content);
344
+ process.stdout.write(
345
+ JSON.stringify({ status: 'written', artifacts: artifacts.map((a) => a.label) }) + '\n',
346
+ );
347
+ process.exit(0);
348
+ }
349
+ }
350
+
351
+ if (isMainModule(import.meta.url)) main();
352
+
353
+ export {
354
+ buildSharedIndex,
355
+ renderSharedIndex,
356
+ buildCanonical,
357
+ renderCanonical,
358
+ buildTeamMemberIndex,
359
+ renderTeamMemberIndex,
360
+ listTeamMembers,
361
+ regenerateAll,
362
+ fingerprint,
363
+ readDescription,
364
+ stripFrontmatter,
365
+ };