@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,217 @@
1
+ #!/usr/bin/env node
2
+ // Centralized capture helper for /braindump, /handoff, /aside, /promote.
3
+ // Mechanizes the path math, naming convention, and frontmatter so skills
4
+ // don't have to duplicate it (and can't drift from each other).
5
+ //
6
+ // Usage:
7
+ // echo "<body>" | node capture-context.mjs \
8
+ // --type braindump|handoff|research \
9
+ // --topic kebab-case-slug \
10
+ // --scope shared|team-member \
11
+ // [--user alice] # required when --scope team-member
12
+ // [--description "..."] # one-line summary, used by index
13
+ // [--variant aside] # arbitrary frontmatter marker
14
+ // [--local-only] # prefix filename with local-only- (gitignored)
15
+ // [--update] # overwrite if file exists; default appends -2, -3, ...
16
+ // [--root <path>] # workspace root (defaults to cwd)
17
+ // [--print-only] # don't write; just print the planned path
18
+ //
19
+ // Path layout:
20
+ // shared scope: workspace-context/shared/{type}_{topic}.md
21
+ // team-member scope: workspace-context/team-member/{user}/{type}_{topic}.md
22
+ // With --local-only, the file basename is prefixed `local-only-` (e.g.
23
+ // `local-only-research_my-topic.md`), so the gitignore pattern keeps it
24
+ // out of git.
25
+ //
26
+ // Stdin: the markdown body to write under the frontmatter. Required unless
27
+ // --print-only is passed.
28
+ //
29
+ // Stdout: a single line with the absolute path of the written (or planned) file.
30
+
31
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, realpathSync } from 'node:fs';
32
+ import { dirname, join, resolve } from 'node:path';
33
+ import { fileURLToPath } from 'node:url';
34
+
35
+ function isMainModule(metaUrl) {
36
+ if (!process.argv[1]) return false;
37
+ try {
38
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
39
+ } catch { return false; }
40
+ }
41
+
42
+ const VALID_TYPES = new Set(['braindump', 'handoff', 'research']);
43
+ const VALID_SCOPES = new Set(['shared', 'team-member']);
44
+
45
+ const WC_DIR = 'workspace-context';
46
+ const SHARED_DIR = 'shared';
47
+ const TEAM_MEMBER_DIR = 'team-member';
48
+
49
+ function parseArgs(argv) {
50
+ const args = {
51
+ type: null,
52
+ topic: null,
53
+ scope: null,
54
+ user: null,
55
+ description: null,
56
+ variant: null,
57
+ localOnly: false,
58
+ update: false,
59
+ root: process.cwd(),
60
+ printOnly: false,
61
+ };
62
+ for (let i = 2; i < argv.length; i++) {
63
+ const a = argv[i];
64
+ if (a === '--type') args.type = argv[++i];
65
+ else if (a === '--topic') args.topic = argv[++i];
66
+ else if (a === '--scope') args.scope = argv[++i];
67
+ else if (a === '--user') args.user = argv[++i];
68
+ else if (a === '--description') args.description = argv[++i];
69
+ else if (a === '--variant') args.variant = argv[++i];
70
+ else if (a === '--local-only') args.localOnly = true;
71
+ else if (a === '--update') args.update = true;
72
+ else if (a === '--root') args.root = argv[++i];
73
+ else if (a === '--print-only') args.printOnly = true;
74
+ else throw new Error(`Unknown arg: ${a}`);
75
+ }
76
+ return args;
77
+ }
78
+
79
+ function validate(args) {
80
+ if (!args.type || !VALID_TYPES.has(args.type)) {
81
+ throw new Error(`--type must be one of: ${[...VALID_TYPES].join(', ')}`);
82
+ }
83
+ if (!args.topic || !/^[a-z0-9][a-z0-9-]*$/.test(args.topic)) {
84
+ throw new Error('--topic must be kebab-case (lowercase letters, digits, hyphens)');
85
+ }
86
+ if (!args.scope || !VALID_SCOPES.has(args.scope)) {
87
+ throw new Error(`--scope must be one of: ${[...VALID_SCOPES].join(', ')}`);
88
+ }
89
+ if (args.scope === 'team-member' && !args.user) {
90
+ throw new Error('--user is required when --scope is team-member');
91
+ }
92
+ if (args.scope === 'team-member' && !/^[A-Za-z0-9_-]+$/.test(args.user)) {
93
+ throw new Error('--user must be alphanumeric (with optional - or _)');
94
+ }
95
+ }
96
+
97
+ function computeDir(args) {
98
+ if (args.scope === 'shared') {
99
+ return join(args.root, WC_DIR, SHARED_DIR);
100
+ }
101
+ return join(args.root, WC_DIR, TEAM_MEMBER_DIR, args.user);
102
+ }
103
+
104
+ function computeBaseFilename(args) {
105
+ const prefix = args.localOnly ? 'local-only-' : '';
106
+ return `${prefix}${args.type}_${args.topic}.md`;
107
+ }
108
+
109
+ function resolveCollision(dir, baseFilename, update) {
110
+ const initial = join(dir, baseFilename);
111
+ if (update) return initial;
112
+ if (!existsSync(initial)) return initial;
113
+
114
+ const dot = baseFilename.lastIndexOf('.');
115
+ const stem = baseFilename.slice(0, dot);
116
+ const ext = baseFilename.slice(dot);
117
+ for (let i = 2; i < 1000; i++) {
118
+ const candidate = join(dir, `${stem}-${i}${ext}`);
119
+ if (!existsSync(candidate)) return candidate;
120
+ }
121
+ throw new Error(`Could not find unique filename for ${baseFilename} (1000+ collisions)`);
122
+ }
123
+
124
+ function todayISO() {
125
+ return new Date().toISOString().slice(0, 10);
126
+ }
127
+
128
+ function buildFrontmatter(args) {
129
+ const fm = {
130
+ state: 'ephemeral',
131
+ lifecycle: 'active',
132
+ type: args.type,
133
+ topic: args.topic,
134
+ };
135
+ if (args.scope === 'team-member') fm.author = args.user;
136
+ if (args.variant) fm.variant = args.variant;
137
+ if (args.description) fm.description = args.description;
138
+ fm.updated = todayISO();
139
+ return fm;
140
+ }
141
+
142
+ function renderFrontmatter(fm) {
143
+ const lines = ['---'];
144
+ for (const [k, v] of Object.entries(fm)) {
145
+ lines.push(`${k}: ${v}`);
146
+ }
147
+ lines.push('---');
148
+ return lines.join('\n');
149
+ }
150
+
151
+ function readStdinSync() {
152
+ try {
153
+ return readFileSync(0, 'utf-8');
154
+ } catch {
155
+ return '';
156
+ }
157
+ }
158
+
159
+ function ensureDir(dir) {
160
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
161
+ }
162
+
163
+ function plan(args) {
164
+ validate(args);
165
+ const dir = computeDir(args);
166
+ const baseFilename = computeBaseFilename(args);
167
+ const filePath = resolveCollision(dir, baseFilename, args.update);
168
+ return { dir, filePath, frontmatter: buildFrontmatter(args) };
169
+ }
170
+
171
+ function write(args, body) {
172
+ const planned = plan(args);
173
+ ensureDir(planned.dir);
174
+ const content = renderFrontmatter(planned.frontmatter) + '\n\n' + body.replace(/^\n+/, '');
175
+ const finalContent = content.endsWith('\n') ? content : content + '\n';
176
+ writeFileSync(planned.filePath, finalContent);
177
+ return planned.filePath;
178
+ }
179
+
180
+ function main() {
181
+ const args = parseArgs(process.argv);
182
+ args.root = resolve(args.root);
183
+
184
+ if (args.printOnly) {
185
+ const planned = plan(args);
186
+ process.stdout.write(planned.filePath + '\n');
187
+ return;
188
+ }
189
+
190
+ const body = readStdinSync();
191
+ if (!body || !body.trim()) {
192
+ throw new Error('No body content on stdin (use --print-only to skip writing)');
193
+ }
194
+ const filePath = write(args, body);
195
+ process.stdout.write(filePath + '\n');
196
+ }
197
+
198
+ if (isMainModule(import.meta.url)) {
199
+ try {
200
+ main();
201
+ } catch (err) {
202
+ process.stderr.write(`capture-context: ${err.message}\n`);
203
+ process.exit(1);
204
+ }
205
+ }
206
+
207
+ export {
208
+ parseArgs,
209
+ validate,
210
+ computeDir,
211
+ computeBaseFilename,
212
+ resolveCollision,
213
+ buildFrontmatter,
214
+ renderFrontmatter,
215
+ plan,
216
+ write,
217
+ };
@@ -0,0 +1,383 @@
1
+ #!/usr/bin/env node
2
+ // Unit tests for capture-context.mjs
3
+ // Run: node template/.claude/scripts/capture-context.test.mjs
4
+
5
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { spawnSync } from 'node:child_process';
9
+ import {
10
+ parseArgs,
11
+ validate,
12
+ computeDir,
13
+ computeBaseFilename,
14
+ resolveCollision,
15
+ buildFrontmatter,
16
+ renderFrontmatter,
17
+ plan,
18
+ write,
19
+ } from './capture-context.mjs';
20
+
21
+ let failed = 0;
22
+ let passed = 0;
23
+
24
+ function assert(cond, msg) {
25
+ if (cond) { passed++; } else { failed++; console.error(` FAIL: ${msg}`); }
26
+ }
27
+
28
+ function assertEq(actual, expected, msg) {
29
+ const a = JSON.stringify(actual);
30
+ const e = JSON.stringify(expected);
31
+ if (a === e) { passed++; } else {
32
+ failed++;
33
+ console.error(` FAIL: ${msg}\n expected: ${e}\n actual: ${a}`);
34
+ }
35
+ }
36
+
37
+ function assertThrows(fn, matcher, msg) {
38
+ try {
39
+ fn();
40
+ failed++;
41
+ console.error(` FAIL: ${msg} (expected throw)`);
42
+ } catch (e) {
43
+ if (matcher.test(e.message)) { passed++; }
44
+ else { failed++; console.error(` FAIL: ${msg}\n error: ${e.message}`); }
45
+ }
46
+ }
47
+
48
+ function setupRoot() {
49
+ return mkdtempSync(join(tmpdir(), 'cap-test-'));
50
+ }
51
+
52
+ function cleanup(root) {
53
+ rmSync(root, { recursive: true, force: true });
54
+ }
55
+
56
+ console.log('# validate');
57
+
58
+ {
59
+ assertThrows(
60
+ () => validate({ type: null, topic: 'x', scope: 'shared' }),
61
+ /--type must be one of/,
62
+ 'rejects missing type',
63
+ );
64
+ }
65
+
66
+ {
67
+ assertThrows(
68
+ () => validate({ type: 'memo', topic: 'x', scope: 'shared' }),
69
+ /--type must be one of/,
70
+ 'rejects invalid type',
71
+ );
72
+ }
73
+
74
+ {
75
+ assertThrows(
76
+ () => validate({ type: 'braindump', topic: 'BadCase', scope: 'shared' }),
77
+ /kebab-case/,
78
+ 'rejects non-kebab-case topic',
79
+ );
80
+ }
81
+
82
+ {
83
+ assertThrows(
84
+ () => validate({ type: 'braindump', topic: 'x', scope: 'invalid' }),
85
+ /--scope must be one of/,
86
+ 'rejects invalid scope',
87
+ );
88
+ }
89
+
90
+ {
91
+ assertThrows(
92
+ () => validate({ type: 'braindump', topic: 'x', scope: 'team-member' }),
93
+ /--user is required/,
94
+ 'rejects team-member scope without user',
95
+ );
96
+ }
97
+
98
+ {
99
+ assertThrows(
100
+ () => validate({ type: 'braindump', topic: 'x', scope: 'team-member', user: 'bad/name' }),
101
+ /--user must be alphanumeric/,
102
+ 'rejects user with slash',
103
+ );
104
+ }
105
+
106
+ {
107
+ // valid input doesn't throw
108
+ validate({ type: 'braindump', topic: 'my-topic', scope: 'shared' });
109
+ validate({ type: 'handoff', topic: 'my-topic', scope: 'team-member', user: 'alice' });
110
+ passed += 2;
111
+ }
112
+
113
+ console.log('# computeDir');
114
+
115
+ {
116
+ const sharedDir = computeDir({ scope: 'shared', root: '/ws' });
117
+ assertEq(sharedDir, '/ws/workspace-context/shared', 'shared scope path');
118
+
119
+ const userDir = computeDir({ scope: 'team-member', user: 'alice', root: '/ws' });
120
+ assertEq(userDir, '/ws/workspace-context/team-member/alice', 'team-member scope path');
121
+ }
122
+
123
+ console.log('# computeBaseFilename');
124
+
125
+ {
126
+ assertEq(
127
+ computeBaseFilename({ type: 'braindump', topic: 'my-thing', localOnly: false }),
128
+ 'braindump_my-thing.md',
129
+ 'standard naming',
130
+ );
131
+ assertEq(
132
+ computeBaseFilename({ type: 'research', topic: 'my-thing', localOnly: true }),
133
+ 'local-only-research_my-thing.md',
134
+ 'local-only prefix applied',
135
+ );
136
+ assertEq(
137
+ computeBaseFilename({ type: 'handoff', topic: 'workstream-x', localOnly: false }),
138
+ 'handoff_workstream-x.md',
139
+ 'handoff prefix',
140
+ );
141
+ }
142
+
143
+ console.log('# resolveCollision');
144
+
145
+ {
146
+ const root = setupRoot();
147
+ const path1 = resolveCollision(root, 'braindump_x.md', false);
148
+ assertEq(path1, join(root, 'braindump_x.md'), 'no collision returns base');
149
+ cleanup(root);
150
+ }
151
+
152
+ {
153
+ const root = setupRoot();
154
+ writeFileSync(join(root, 'braindump_x.md'), 'first');
155
+ const path2 = resolveCollision(root, 'braindump_x.md', false);
156
+ assertEq(path2, join(root, 'braindump_x-2.md'), 'first collision → -2');
157
+ writeFileSync(path2, 'second');
158
+ const path3 = resolveCollision(root, 'braindump_x.md', false);
159
+ assertEq(path3, join(root, 'braindump_x-3.md'), 'second collision → -3');
160
+ cleanup(root);
161
+ }
162
+
163
+ {
164
+ const root = setupRoot();
165
+ writeFileSync(join(root, 'braindump_x.md'), 'first');
166
+ const path = resolveCollision(root, 'braindump_x.md', true);
167
+ assertEq(path, join(root, 'braindump_x.md'), '--update returns base even when exists');
168
+ cleanup(root);
169
+ }
170
+
171
+ console.log('# buildFrontmatter');
172
+
173
+ {
174
+ const fm = buildFrontmatter({
175
+ type: 'braindump',
176
+ topic: 'my-thing',
177
+ scope: 'shared',
178
+ });
179
+ assertEq(fm.state, 'ephemeral', 'state ephemeral');
180
+ assertEq(fm.lifecycle, 'active', 'lifecycle active');
181
+ assertEq(fm.type, 'braindump', 'type set');
182
+ assertEq(fm.topic, 'my-thing', 'topic set');
183
+ assert(!('author' in fm), 'no author for shared scope');
184
+ assert(/^\d{4}-\d{2}-\d{2}$/.test(fm.updated), 'updated is ISO date');
185
+ }
186
+
187
+ {
188
+ const fm = buildFrontmatter({
189
+ type: 'research',
190
+ topic: 'idea',
191
+ scope: 'team-member',
192
+ user: 'alice',
193
+ variant: 'aside',
194
+ description: 'A neat idea.',
195
+ });
196
+ assertEq(fm.author, 'alice', 'author from --user');
197
+ assertEq(fm.variant, 'aside', 'variant included');
198
+ assertEq(fm.description, 'A neat idea.', 'description included');
199
+ }
200
+
201
+ console.log('# renderFrontmatter');
202
+
203
+ {
204
+ const out = renderFrontmatter({
205
+ state: 'ephemeral',
206
+ type: 'braindump',
207
+ topic: 'x',
208
+ });
209
+ assertEq(
210
+ out,
211
+ '---\nstate: ephemeral\ntype: braindump\ntopic: x\n---',
212
+ 'frontmatter rendered with --- fences',
213
+ );
214
+ }
215
+
216
+ console.log('# plan');
217
+
218
+ {
219
+ const root = setupRoot();
220
+ const planned = plan({
221
+ type: 'braindump',
222
+ topic: 'my-topic',
223
+ scope: 'team-member',
224
+ user: 'alice',
225
+ root,
226
+ localOnly: false,
227
+ update: false,
228
+ });
229
+ assertEq(
230
+ planned.filePath,
231
+ join(root, 'workspace-context', 'team-member', 'alice', 'braindump_my-topic.md'),
232
+ 'planned path includes type prefix and user dir',
233
+ );
234
+ cleanup(root);
235
+ }
236
+
237
+ {
238
+ const root = setupRoot();
239
+ const args = {
240
+ type: 'research',
241
+ topic: 'idea',
242
+ scope: 'team-member',
243
+ user: 'alice',
244
+ root,
245
+ localOnly: true,
246
+ update: false,
247
+ variant: 'aside',
248
+ };
249
+ const planned = plan(args);
250
+ assertEq(
251
+ planned.filePath,
252
+ join(root, 'workspace-context', 'team-member', 'alice', 'local-only-research_idea.md'),
253
+ 'local-only flag prefixes filename',
254
+ );
255
+ cleanup(root);
256
+ }
257
+
258
+ console.log('# write end-to-end');
259
+
260
+ {
261
+ const root = setupRoot();
262
+ const filePath = write(
263
+ {
264
+ type: 'braindump',
265
+ topic: 'my-topic',
266
+ scope: 'team-member',
267
+ user: 'alice',
268
+ description: 'Captured my topic.',
269
+ root,
270
+ localOnly: false,
271
+ update: false,
272
+ },
273
+ '## Section\n\nBody text.\n',
274
+ );
275
+ assert(existsSync(filePath), 'file written');
276
+ const content = readFileSync(filePath, 'utf-8');
277
+ assert(content.startsWith('---\n'), 'starts with frontmatter');
278
+ assert(content.includes('type: braindump'), 'frontmatter has type');
279
+ assert(content.includes('topic: my-topic'), 'frontmatter has topic');
280
+ assert(content.includes('author: alice'), 'frontmatter has author');
281
+ assert(content.includes('description: Captured my topic.'), 'frontmatter has description');
282
+ assert(content.includes('## Section\n\nBody text.'), 'body preserved');
283
+ cleanup(root);
284
+ }
285
+
286
+ {
287
+ // collision: second write becomes -2
288
+ const root = setupRoot();
289
+ const args = {
290
+ type: 'braindump',
291
+ topic: 'topic',
292
+ scope: 'shared',
293
+ root,
294
+ localOnly: false,
295
+ update: false,
296
+ };
297
+ const path1 = write(args, 'first body');
298
+ const path2 = write(args, 'second body');
299
+ assert(path1 !== path2, 'collision creates new path');
300
+ assert(path2.endsWith('braindump_topic-2.md'), 'second is -2');
301
+ cleanup(root);
302
+ }
303
+
304
+ {
305
+ // --update: overwrites existing
306
+ const root = setupRoot();
307
+ const args = {
308
+ type: 'braindump',
309
+ topic: 'topic',
310
+ scope: 'shared',
311
+ root,
312
+ localOnly: false,
313
+ update: false,
314
+ };
315
+ const path1 = write(args, 'first body');
316
+ const path2 = write({ ...args, update: true }, 'updated body');
317
+ assertEq(path1, path2, '--update overwrites same path');
318
+ const content = readFileSync(path1, 'utf-8');
319
+ assert(content.includes('updated body'), 'content overwritten');
320
+ assert(!content.includes('first body'), 'old content gone');
321
+ cleanup(root);
322
+ }
323
+
324
+ console.log('# CLI end-to-end');
325
+
326
+ {
327
+ const root = setupRoot();
328
+ const scriptPath = new URL('./capture-context.mjs', import.meta.url).pathname;
329
+
330
+ const result = spawnSync(
331
+ 'node',
332
+ [scriptPath, '--type', 'braindump', '--topic', 'cli-test', '--scope', 'shared', '--root', root],
333
+ { input: '## Body\n\nFrom stdin.\n', encoding: 'utf-8' },
334
+ );
335
+ assertEq(result.status, 0, 'CLI exits 0');
336
+ const printedPath = result.stdout.trim();
337
+ assert(printedPath.endsWith('braindump_cli-test.md'), 'CLI prints path with prefix');
338
+ const content = readFileSync(printedPath, 'utf-8');
339
+ assert(content.includes('From stdin.'), 'stdin body written');
340
+ cleanup(root);
341
+ }
342
+
343
+ {
344
+ // --print-only: no stdin needed, no file written
345
+ const root = setupRoot();
346
+ const scriptPath = new URL('./capture-context.mjs', import.meta.url).pathname;
347
+ const result = spawnSync(
348
+ 'node',
349
+ [
350
+ scriptPath,
351
+ '--type', 'handoff',
352
+ '--topic', 'no-write',
353
+ '--scope', 'team-member',
354
+ '--user', 'bob',
355
+ '--root', root,
356
+ '--print-only',
357
+ ],
358
+ { encoding: 'utf-8' },
359
+ );
360
+ assertEq(result.status, 0, '--print-only exits 0');
361
+ const printedPath = result.stdout.trim();
362
+ assert(printedPath.endsWith('handoff_no-write.md'), 'planned path printed');
363
+ assert(!existsSync(printedPath), 'no file written in print-only mode');
364
+ cleanup(root);
365
+ }
366
+
367
+ {
368
+ // bad arg
369
+ const root = setupRoot();
370
+ const scriptPath = new URL('./capture-context.mjs', import.meta.url).pathname;
371
+ const result = spawnSync(
372
+ 'node',
373
+ [scriptPath, '--type', 'invalid', '--topic', 'x', '--scope', 'shared', '--root', root],
374
+ { input: 'body', encoding: 'utf-8' },
375
+ );
376
+ assertEq(result.status, 1, 'invalid type exits 1');
377
+ assert(result.stderr.includes('--type must be one of'), 'error message piped to stderr');
378
+ cleanup(root);
379
+ }
380
+
381
+ console.log('');
382
+ console.log(`${passed} passed, ${failed} failed`);
383
+ process.exit(failed > 0 ? 1 : 0);
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ // Write CLAUDE.local.md at the workspace root with the per-user import.
3
+ //
4
+ // CLAUDE.local.md is gitignored — it carries user-scoped context that
5
+ // shouldn't propagate across team members. The single source of identity
6
+ // is `.claude/settings.local.json` → `workspace.user`.
7
+ //
8
+ // Usage:
9
+ // node generate-claude-local.mjs [--root <path>] [--force]
10
+ //
11
+ // Behavior:
12
+ // - Reads workspace.user from .claude/settings.local.json
13
+ // - Refuses to overwrite an existing CLAUDE.local.md unless --force
14
+ // - Idempotent: same input → same content
15
+ //
16
+ // Exit codes:
17
+ // 0 — wrote (or would have written) the file
18
+ // 1 — error (missing settings, settings missing user, refusal to overwrite)
19
+
20
+ import { readFileSync, writeFileSync, existsSync, realpathSync } from 'node:fs';
21
+ import { join, resolve } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ function isMainModule(metaUrl) {
25
+ if (!process.argv[1]) return false;
26
+ try {
27
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
28
+ } catch { return false; }
29
+ }
30
+
31
+ function parseArgs(argv) {
32
+ const args = { root: process.cwd(), force: false };
33
+ for (let i = 2; i < argv.length; i++) {
34
+ const a = argv[i];
35
+ if (a === '--root') args.root = argv[++i];
36
+ else if (a === '--force') args.force = true;
37
+ else throw new Error(`Unknown arg: ${a}`);
38
+ }
39
+ return args;
40
+ }
41
+
42
+ function readWorkspaceUser(root) {
43
+ const settingsPath = join(root, '.claude', 'settings.local.json');
44
+ if (!existsSync(settingsPath)) {
45
+ throw new Error(`Missing ${settingsPath} — run /workspace-init first`);
46
+ }
47
+ let parsed;
48
+ try {
49
+ parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
50
+ } catch (e) {
51
+ throw new Error(`Could not parse ${settingsPath}: ${e.message}`);
52
+ }
53
+ const user = parsed?.workspace?.user;
54
+ if (!user || typeof user !== 'string') {
55
+ throw new Error(`workspace.user not set in ${settingsPath}`);
56
+ }
57
+ if (!/^[A-Za-z0-9_-]+$/.test(user)) {
58
+ throw new Error(`workspace.user "${user}" must be alphanumeric (with optional - or _)`);
59
+ }
60
+ return user;
61
+ }
62
+
63
+ function renderClaudeLocal(user) {
64
+ return `## My Context
65
+ @workspace-context/team-member/${user}/index.md
66
+ `;
67
+ }
68
+
69
+ function generateClaudeLocal(root, { force = false } = {}) {
70
+ const user = readWorkspaceUser(root);
71
+ const target = join(root, 'CLAUDE.local.md');
72
+ const content = renderClaudeLocal(user);
73
+
74
+ if (existsSync(target) && !force) {
75
+ const existing = readFileSync(target, 'utf-8');
76
+ if (existing === content) {
77
+ return { path: target, status: 'unchanged' };
78
+ }
79
+ throw new Error(
80
+ `${target} already exists with different content. Re-run with --force to overwrite.`,
81
+ );
82
+ }
83
+
84
+ writeFileSync(target, content);
85
+ return { path: target, status: existsSync(target) ? 'written' : 'written' };
86
+ }
87
+
88
+ function main() {
89
+ const args = parseArgs(process.argv);
90
+ const root = resolve(args.root);
91
+ const result = generateClaudeLocal(root, { force: args.force });
92
+ process.stdout.write(JSON.stringify(result) + '\n');
93
+ }
94
+
95
+ if (isMainModule(import.meta.url)) {
96
+ try {
97
+ main();
98
+ } catch (err) {
99
+ process.stderr.write(`generate-claude-local: ${err.message}\n`);
100
+ process.exit(1);
101
+ }
102
+ }
103
+
104
+ export { readWorkspaceUser, renderClaudeLocal, generateClaudeLocal };