@ulysses-ai/create-workspace 0.15.0-beta.0 → 0.15.0-beta.2

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 (26) hide show
  1. package/lib/init.mjs +12 -25
  2. package/lib/scaffold.mjs +3 -2
  3. package/package.json +1 -1
  4. package/template/.claude/rules/memory-guidance.md +30 -0
  5. package/template/.claude/scripts/build-workspace-context.mjs +370 -23
  6. package/template/.claude/scripts/migrate-canonical-priority.mjs +108 -0
  7. package/template/.claude/skills/complete-work/SKILL.md +88 -0
  8. package/template/.claude/skills/maintenance/SKILL.md +79 -11
  9. package/template/.claude/skills/release/SKILL.md +3 -0
  10. package/template/.claude/skills/workspace-update/SKILL.md +7 -1
  11. package/template/workspace.json.tmpl +1 -0
  12. package/template/.claude/hooks/_bash-output-advisory.test.mjs +0 -88
  13. package/template/.claude/hooks/_utils.test.mjs +0 -99
  14. package/template/.claude/lib/freshness.test.mjs +0 -175
  15. package/template/.claude/lib/registry-check.test.mjs +0 -130
  16. package/template/.claude/lib/session-frontmatter.test.mjs +0 -242
  17. package/template/.claude/scripts/build-workspace-context.test.mjs +0 -633
  18. package/template/.claude/scripts/capture-context.test.mjs +0 -383
  19. package/template/.claude/scripts/generate-claude-local.test.mjs +0 -184
  20. package/template/.claude/scripts/migrate-claude-md-freshness-include.test.mjs +0 -54
  21. package/template/.claude/scripts/migrate-session-layout.test.mjs +0 -144
  22. package/template/.claude/scripts/migrate-to-workspace-context.test.mjs +0 -325
  23. package/template/.claude/scripts/sweep-references.test.mjs +0 -184
  24. package/template/.claude/scripts/sync-tasks.test.mjs +0 -350
  25. package/template/.claude/scripts/trackers/github-issues.test.mjs +0 -190
  26. package/template/.claude/scripts/trackers/interface.test.mjs +0 -40
@@ -1,633 +0,0 @@
1
- #!/usr/bin/env node
2
- // Unit tests for build-workspace-context.mjs
3
- // Run: node template/.claude/scripts/build-workspace-context.test.mjs
4
-
5
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync } 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
- buildSharedIndex,
11
- renderSharedIndex,
12
- buildCanonical,
13
- renderCanonical,
14
- buildTeamMemberIndex,
15
- renderTeamMemberIndex,
16
- listTeamMembers,
17
- regenerateAll,
18
- fingerprint,
19
- readDescription,
20
- stripFrontmatter,
21
- } from './build-workspace-context.mjs';
22
-
23
- let failed = 0;
24
- let passed = 0;
25
-
26
- function assert(cond, msg) {
27
- if (cond) { passed++; } else { failed++; console.error(` FAIL: ${msg}`); }
28
- }
29
-
30
- function assertEq(actual, expected, msg) {
31
- const a = JSON.stringify(actual);
32
- const e = JSON.stringify(expected);
33
- if (a === e) { passed++; } else {
34
- failed++;
35
- console.error(` FAIL: ${msg}\n expected: ${e}\n actual: ${a}`);
36
- }
37
- }
38
-
39
- function setupFixture() {
40
- const root = mkdtempSync(join(tmpdir(), 'wc-test-'));
41
- mkdirSync(join(root, 'workspace-context', 'shared', 'locked'), { recursive: true });
42
- mkdirSync(join(root, 'workspace-context', 'team-member'), { recursive: true });
43
- return root;
44
- }
45
-
46
- function cleanup(root) {
47
- rmSync(root, { recursive: true, force: true });
48
- }
49
-
50
- function gitInit(root) {
51
- spawnSync('git', ['init', '-q'], { cwd: root });
52
- spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: root });
53
- spawnSync('git', ['config', 'user.name', 'Test'], { cwd: root });
54
- }
55
-
56
- console.log('# readDescription priority');
57
-
58
- {
59
- const root = setupFixture();
60
- writeFileSync(
61
- join(root, 'workspace-context', 'shared', 'with-fm.md'),
62
- `---
63
- state: locked
64
- type: reference
65
- description: Frontmatter description wins.
66
- updated: 2026-04-25
67
- ---
68
-
69
- # Title
70
-
71
- This is the body.
72
- `,
73
- );
74
- const desc = readDescription(join(root, 'workspace-context', 'shared', 'with-fm.md'));
75
- assertEq(desc, 'Frontmatter description wins.', 'frontmatter description preferred');
76
- cleanup(root);
77
- }
78
-
79
- {
80
- const root = setupFixture();
81
- writeFileSync(
82
- join(root, 'workspace-context', 'shared', 'no-fm-desc.md'),
83
- `---
84
- state: ephemeral
85
- type: braindump
86
- updated: 2026-04-25
87
- ---
88
-
89
- # Some Title
90
-
91
- This is the first paragraph that should be used. It has multiple sentences.
92
-
93
- This is a second paragraph.
94
- `,
95
- );
96
- const desc = readDescription(join(root, 'workspace-context', 'shared', 'no-fm-desc.md'));
97
- assertEq(desc, 'This is the first paragraph that should be used.', 'first sentence fallback');
98
- cleanup(root);
99
- }
100
-
101
- {
102
- const root = setupFixture();
103
- writeFileSync(
104
- join(root, 'workspace-context', 'shared', 'no-frontmatter.md'),
105
- `# Bare File
106
-
107
- Content without frontmatter at all.
108
- `,
109
- );
110
- const desc = readDescription(join(root, 'workspace-context', 'shared', 'no-frontmatter.md'));
111
- assertEq(desc, 'Content without frontmatter at all.', 'works without frontmatter');
112
- cleanup(root);
113
- }
114
-
115
- {
116
- const root = setupFixture();
117
- writeFileSync(join(root, 'workspace-context', 'shared', 'empty-body.md'), `---
118
- type: index
119
- ---
120
- `);
121
- const desc = readDescription(join(root, 'workspace-context', 'shared', 'empty-body.md'));
122
- assertEq(desc, 'empty body', 'filename slug fallback');
123
- cleanup(root);
124
- }
125
-
126
- {
127
- // prefix-stripping: braindump_/handoff_/research_ removed from filename fallback
128
- const root = setupFixture();
129
- writeFileSync(join(root, 'workspace-context', 'shared', 'braindump_topic-x.md'), `---
130
- type: braindump
131
- ---
132
- `);
133
- const desc = readDescription(join(root, 'workspace-context', 'shared', 'braindump_topic-x.md'));
134
- assertEq(desc, 'topic x', 'braindump_ prefix stripped from slug fallback');
135
- cleanup(root);
136
- }
137
-
138
- console.log('# stripFrontmatter');
139
-
140
- {
141
- const out = stripFrontmatter(`---
142
- type: reference
143
- ---
144
-
145
- body content here
146
- `);
147
- assertEq(out.trimEnd(), 'body content here', 'frontmatter stripped');
148
- }
149
-
150
- {
151
- const out = stripFrontmatter('no frontmatter here\n');
152
- assertEq(out, 'no frontmatter here\n', 'pass-through when no frontmatter');
153
- }
154
-
155
- {
156
- // unterminated frontmatter — return as-is rather than corrupt the file
157
- const input = `---
158
- type: reference
159
- no closing fence
160
- `;
161
- assertEq(stripFrontmatter(input), input, 'unterminated frontmatter passes through');
162
- }
163
-
164
- console.log('# buildSharedIndex grouping & sort');
165
-
166
- {
167
- const root = setupFixture();
168
- writeFileSync(
169
- join(root, 'workspace-context', 'shared', 'locked', 'project-status.md'),
170
- `---
171
- description: Project status here.
172
- ---
173
-
174
- body
175
- `,
176
- );
177
- writeFileSync(
178
- join(root, 'workspace-context', 'shared', 'locked', 'naming.md'),
179
- `---
180
- description: Naming convention.
181
- ---
182
-
183
- body
184
- `,
185
- );
186
- writeFileSync(
187
- join(root, 'workspace-context', 'shared', 'inventory.md'),
188
- `---
189
- description: Top-level inventory.
190
- ---
191
-
192
- body
193
- `,
194
- );
195
- writeFileSync(
196
- join(root, 'workspace-context', 'shared', 'braindump_idea.md'),
197
- `---
198
- description: Captured idea.
199
- ---
200
-
201
- body
202
- `,
203
- );
204
-
205
- const entries = buildSharedIndex(root);
206
- assertEq(entries.length, 4, '4 shared entries');
207
- assertEq(entries[0].isLocked, true, 'locked first');
208
- assertEq(entries[0].rel, 'shared/locked/naming.md', 'alphabetical within locked (naming before project)');
209
- assertEq(entries[1].rel, 'shared/locked/project-status.md', 'second locked entry');
210
- assertEq(entries[2].isLocked, false, 'shared (non-locked) after locked');
211
- assertEq(entries[2].rel, 'shared/braindump_idea.md', 'alphabetical within shared');
212
- assertEq(entries[3].rel, 'shared/inventory.md', 'second shared entry');
213
-
214
- cleanup(root);
215
- }
216
-
217
- {
218
- // index.md and canonical.md exclude themselves from the index
219
- const root = setupFixture();
220
- writeFileSync(join(root, 'workspace-context', 'index.md'), '---\ntype: index\n---\n');
221
- writeFileSync(join(root, 'workspace-context', 'canonical.md'), '---\ntype: canonical\n---\n');
222
- writeFileSync(
223
- join(root, 'workspace-context', 'shared', 'real.md'),
224
- `---
225
- description: Real file.
226
- ---
227
- body
228
- `,
229
- );
230
- const entries = buildSharedIndex(root);
231
- assertEq(entries.length, 1, 'auto-gens not counted');
232
- assertEq(entries[0].rel, 'shared/real.md', 'only real shared file');
233
- cleanup(root);
234
- }
235
-
236
- {
237
- // missing shared/ dir → empty list, no crash
238
- const root = mkdtempSync(join(tmpdir(), 'wc-empty-'));
239
- mkdirSync(join(root, 'workspace-context'), { recursive: true });
240
- const entries = buildSharedIndex(root);
241
- assertEq(entries, [], 'no shared/ dir yields empty entries');
242
- cleanup(root);
243
- }
244
-
245
- console.log('# .indexignore prefix excludes');
246
-
247
- {
248
- const root = setupFixture();
249
- mkdirSync(join(root, 'workspace-context', 'shared', 'archived'), { recursive: true });
250
- writeFileSync(
251
- join(root, 'workspace-context', 'shared', 'live.md'),
252
- `---
253
- description: Live.
254
- ---
255
- body
256
- `,
257
- );
258
- writeFileSync(
259
- join(root, 'workspace-context', 'shared', 'archived', 'old.md'),
260
- `---
261
- description: Old.
262
- ---
263
- body
264
- `,
265
- );
266
- writeFileSync(
267
- join(root, 'workspace-context', '.indexignore'),
268
- 'shared/archived/\n# comment\n\n',
269
- );
270
- const entries = buildSharedIndex(root);
271
- assertEq(entries.length, 1, 'archived prefix excluded');
272
- assertEq(entries[0].rel, 'shared/live.md', 'only live.md survives');
273
- cleanup(root);
274
- }
275
-
276
- console.log('# .gitignore filtering');
277
-
278
- {
279
- // local-only-* gitignored at workspace root → filtered out
280
- const root = setupFixture();
281
- gitInit(root);
282
- writeFileSync(join(root, '.gitignore'), 'local-only-*\n');
283
- writeFileSync(
284
- join(root, 'workspace-context', 'shared', 'local-only-draft.md'),
285
- `---
286
- description: Draft.
287
- ---
288
- body
289
- `,
290
- );
291
- writeFileSync(
292
- join(root, 'workspace-context', 'shared', 'public.md'),
293
- `---
294
- description: Public.
295
- ---
296
- body
297
- `,
298
- );
299
- const entries = buildSharedIndex(root);
300
- assertEq(entries.length, 1, 'gitignored local-only-* excluded');
301
- assertEq(entries[0].rel, 'shared/public.md', 'only public file survives');
302
- cleanup(root);
303
- }
304
-
305
- {
306
- // non-git workspace still works (no filter applied)
307
- const root = setupFixture();
308
- writeFileSync(
309
- join(root, 'workspace-context', 'shared', 'a.md'),
310
- `---
311
- description: A.
312
- ---
313
- body
314
- `,
315
- );
316
- const entries = buildSharedIndex(root);
317
- assertEq(entries.length, 1, 'non-git workspace still works');
318
- cleanup(root);
319
- }
320
-
321
- console.log('# renderSharedIndex output');
322
-
323
- {
324
- const entries = [
325
- { rel: 'shared/locked/a.md', isLocked: true, description: 'A.' },
326
- { rel: 'shared/b.md', isLocked: false, description: 'B.' },
327
- ];
328
- const out = renderSharedIndex(entries, '2026-04-27T00:00:00Z');
329
- assert(out.includes('## Canonical (in CLAUDE.md context verbatim)'), 'has canonical heading');
330
- assert(out.includes('## Shared'), 'has shared heading');
331
- assert(out.includes('- [shared/locked/a.md](shared/locked/a.md) — A.'), 'locked entry rendered');
332
- assert(out.includes('- [shared/b.md](shared/b.md) — B.'), 'shared entry rendered');
333
- assert(out.includes('generated: 2026-04-27T00:00:00Z'), 'frontmatter has generated timestamp');
334
- }
335
-
336
- {
337
- const out = renderSharedIndex([], '2026-04-27T00:00:00Z');
338
- assert(out.includes('_(no shared workspace-context files yet)_'), 'empty state rendered');
339
- }
340
-
341
- {
342
- // only-locked: no Shared heading
343
- const out = renderSharedIndex(
344
- [{ rel: 'shared/locked/x.md', isLocked: true, description: 'X.' }],
345
- '2026-04-27T00:00:00Z',
346
- );
347
- assert(out.includes('## Canonical'), 'canonical heading present');
348
- assert(!out.includes('## Shared'), 'no shared heading when no non-locked entries');
349
- }
350
-
351
- console.log('# buildCanonical concat');
352
-
353
- {
354
- const root = setupFixture();
355
- writeFileSync(
356
- join(root, 'workspace-context', 'shared', 'locked', 'a-naming.md'),
357
- `---
358
- type: reference
359
- description: Naming.
360
- ---
361
-
362
- # Naming
363
-
364
- Use kebab-case.
365
- `,
366
- );
367
- writeFileSync(
368
- join(root, 'workspace-context', 'shared', 'locked', 'b-status.md'),
369
- `---
370
- type: reference
371
- description: Status.
372
- ---
373
-
374
- # Status
375
-
376
- Beta.
377
- `,
378
- );
379
- const items = buildCanonical(root);
380
- assertEq(items.length, 2, '2 canonical items');
381
- assertEq(items[0].name, 'a-naming', 'sorted alphabetically');
382
- assert(items[0].content.includes('Use kebab-case.'), 'frontmatter stripped, body preserved');
383
- assert(!items[0].content.startsWith('---'), 'no frontmatter in concat output');
384
- cleanup(root);
385
- }
386
-
387
- {
388
- // missing locked/ dir → empty list
389
- const root = mkdtempSync(join(tmpdir(), 'wc-no-locked-'));
390
- mkdirSync(join(root, 'workspace-context', 'shared'), { recursive: true });
391
- const items = buildCanonical(root);
392
- assertEq(items, [], 'no locked/ dir yields empty canonical');
393
- cleanup(root);
394
- }
395
-
396
- console.log('# renderCanonical output');
397
-
398
- {
399
- const items = [
400
- { name: 'naming', content: 'Use kebab-case.' },
401
- { name: 'status', content: 'Beta.' },
402
- ];
403
- const out = renderCanonical(items, '2026-04-27T00:00:00Z');
404
- assert(out.includes('## naming'), 'section header from name');
405
- assert(out.includes('## status'), 'second section header');
406
- assert(out.includes('Use kebab-case.'), 'body included');
407
- assert(out.includes('type: canonical'), 'frontmatter type set');
408
- }
409
-
410
- {
411
- const out = renderCanonical([], '2026-04-27T00:00:00Z');
412
- assert(out.includes('_(no canonical entries yet'), 'empty state rendered');
413
- }
414
-
415
- console.log('# buildTeamMemberIndex per-user');
416
-
417
- {
418
- const root = setupFixture();
419
- mkdirSync(join(root, 'workspace-context', 'team-member', 'alice'), { recursive: true });
420
- writeFileSync(
421
- join(root, 'workspace-context', 'team-member', 'alice', 'index.md'),
422
- '---\ntype: index\n---\n',
423
- );
424
- writeFileSync(
425
- join(root, 'workspace-context', 'team-member', 'alice', 'braindump_thoughts.md'),
426
- `---
427
- description: Some thoughts.
428
- ---
429
- body
430
- `,
431
- );
432
- writeFileSync(
433
- join(root, 'workspace-context', 'team-member', 'alice', 'handoff_proj.md'),
434
- `---
435
- description: Project handoff.
436
- ---
437
- body
438
- `,
439
- );
440
- const entries = buildTeamMemberIndex(root, 'alice');
441
- assertEq(entries.length, 2, 'index.md excluded, two entries remain');
442
- assertEq(entries[0].rel, 'braindump_thoughts.md', 'alphabetical, paths relative to user dir');
443
- assertEq(entries[1].rel, 'handoff_proj.md', 'second entry');
444
- cleanup(root);
445
- }
446
-
447
- {
448
- // gitignore still applies under team-member/
449
- const root = setupFixture();
450
- gitInit(root);
451
- writeFileSync(join(root, '.gitignore'), 'local-only-*\n');
452
- mkdirSync(join(root, 'workspace-context', 'team-member', 'bob'), { recursive: true });
453
- writeFileSync(
454
- join(root, 'workspace-context', 'team-member', 'bob', 'local-only-draft.md'),
455
- `---
456
- description: Local.
457
- ---
458
- body
459
- `,
460
- );
461
- writeFileSync(
462
- join(root, 'workspace-context', 'team-member', 'bob', 'shared-thought.md'),
463
- `---
464
- description: Shareable.
465
- ---
466
- body
467
- `,
468
- );
469
- const entries = buildTeamMemberIndex(root, 'bob');
470
- assertEq(entries.length, 1, 'gitignored local-only-* excluded from per-user index');
471
- assertEq(entries[0].rel, 'shared-thought.md', 'public file survives');
472
- cleanup(root);
473
- }
474
-
475
- {
476
- // missing user dir → []
477
- const root = setupFixture();
478
- const entries = buildTeamMemberIndex(root, 'ghost');
479
- assertEq(entries, [], 'missing user dir yields empty');
480
- cleanup(root);
481
- }
482
-
483
- console.log('# renderTeamMemberIndex output');
484
-
485
- {
486
- const out = renderTeamMemberIndex(
487
- 'alice',
488
- [{ rel: 'braindump_x.md', description: 'X.' }],
489
- '2026-04-27T00:00:00Z',
490
- );
491
- assert(out.includes("# alice's context"), 'user-specific heading');
492
- assert(out.includes('- [braindump_x.md](braindump_x.md) — X.'), 'entry rendered');
493
- }
494
-
495
- {
496
- const out = renderTeamMemberIndex('alice', [], '2026-04-27T00:00:00Z');
497
- assert(out.includes('_(no personal context files yet)_'), 'empty state rendered');
498
- }
499
-
500
- console.log('# listTeamMembers');
501
-
502
- {
503
- const root = setupFixture();
504
- mkdirSync(join(root, 'workspace-context', 'team-member', 'alice'), { recursive: true });
505
- mkdirSync(join(root, 'workspace-context', 'team-member', 'bob'), { recursive: true });
506
- // file at team-member/ level should be ignored (not a dir)
507
- writeFileSync(join(root, 'workspace-context', 'team-member', 'README.md'), '# readme\n');
508
- const users = listTeamMembers(root);
509
- assertEq(users, ['alice', 'bob'], 'sorted user dirs only');
510
- cleanup(root);
511
- }
512
-
513
- {
514
- // missing team-member/ → []
515
- const root = mkdtempSync(join(tmpdir(), 'wc-no-tm-'));
516
- mkdirSync(join(root, 'workspace-context'), { recursive: true });
517
- const users = listTeamMembers(root);
518
- assertEq(users, [], 'no team-member dir yields empty');
519
- cleanup(root);
520
- }
521
-
522
- console.log('# regenerateAll orchestration');
523
-
524
- {
525
- const root = setupFixture();
526
- mkdirSync(join(root, 'workspace-context', 'team-member', 'alice'), { recursive: true });
527
- writeFileSync(
528
- join(root, 'workspace-context', 'shared', 'locked', 'naming.md'),
529
- `---
530
- description: Naming.
531
- ---
532
- body
533
- `,
534
- );
535
- writeFileSync(
536
- join(root, 'workspace-context', 'team-member', 'alice', 'note.md'),
537
- `---
538
- description: Note.
539
- ---
540
- body
541
- `,
542
- );
543
- const out = regenerateAll(root, '2026-04-27T00:00:00Z');
544
- assertEq(out.length, 3, '3 artifacts: index, canonical, alice/index');
545
- assertEq(out[0].label, 'index.md', 'index first');
546
- assertEq(out[1].label, 'canonical.md', 'canonical second');
547
- assertEq(out[2].label, 'team-member/alice/index.md', 'per-user third');
548
- cleanup(root);
549
- }
550
-
551
- {
552
- // missing workspace-context root → no artifacts (no crash)
553
- const root = mkdtempSync(join(tmpdir(), 'wc-bare-'));
554
- const out = regenerateAll(root, '2026-04-27T00:00:00Z');
555
- assertEq(out, [], 'missing wcRoot yields empty plan');
556
- cleanup(root);
557
- }
558
-
559
- console.log('# fingerprint ignores generated line');
560
-
561
- {
562
- const a = `---
563
- type: index
564
- generated: 2026-04-25T00:00:00Z
565
- ---
566
-
567
- body
568
- `;
569
- const b = `---
570
- type: index
571
- generated: 2026-04-26T00:00:00Z
572
- ---
573
-
574
- body
575
- `;
576
- assertEq(fingerprint(a), fingerprint(b), 'different generated, same body → same fingerprint');
577
- }
578
-
579
- {
580
- const a = `---
581
- generated: 2026-04-27T00:00:00Z
582
- ---
583
-
584
- body one
585
- `;
586
- const b = `---
587
- generated: 2026-04-27T00:00:00Z
588
- ---
589
-
590
- body two
591
- `;
592
- assert(fingerprint(a) !== fingerprint(b), 'different body → different fingerprint');
593
- }
594
-
595
- console.log('# CLI --check / --write end-to-end');
596
-
597
- {
598
- const root = setupFixture();
599
- writeFileSync(
600
- join(root, 'workspace-context', 'shared', 'locked', 'rule.md'),
601
- `---
602
- description: Rule.
603
- ---
604
- body
605
- `,
606
- );
607
-
608
- const scriptPath = new URL('./build-workspace-context.mjs', import.meta.url).pathname;
609
-
610
- // --check should report stale (or missing — index/canonical not yet on disk)
611
- const check1 = spawnSync('node', [scriptPath, '--check', '--root', root], { encoding: 'utf-8' });
612
- assertEq(check1.status, 1, '--check exits 1 when artifacts missing');
613
- const check1Out = JSON.parse(check1.stdout);
614
- assertEq(check1Out.status, 'stale', '--check reports stale');
615
- assert(check1Out.missing.includes('index.md'), 'index.md flagged missing');
616
- assert(check1Out.missing.includes('canonical.md'), 'canonical.md flagged missing');
617
-
618
- // --write generates them
619
- const write = spawnSync('node', [scriptPath, '--write', '--root', root], { encoding: 'utf-8' });
620
- assertEq(write.status, 0, '--write exits 0');
621
- const writeOut = JSON.parse(write.stdout);
622
- assertEq(writeOut.status, 'written', '--write reports written');
623
-
624
- // --check now passes
625
- const check2 = spawnSync('node', [scriptPath, '--check', '--root', root], { encoding: 'utf-8' });
626
- assertEq(check2.status, 0, '--check exits 0 after --write');
627
-
628
- cleanup(root);
629
- }
630
-
631
- console.log('');
632
- console.log(`${passed} passed, ${failed} failed`);
633
- process.exit(failed > 0 ? 1 : 0);