@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.
- package/package.json +1 -1
- package/template/.claude/agents/reviewer.md +1 -1
- package/template/.claude/hooks/pre-compact.mjs +1 -1
- package/template/.claude/hooks/repo-write-detection.mjs +2 -2
- package/template/.claude/hooks/session-start.mjs +10 -7
- package/template/.claude/hooks/subagent-start.mjs +3 -3
- package/template/.claude/recipes/migrate-from-notion.md +6 -6
- package/template/.claude/rules/coherent-revisions.md +2 -2
- package/template/.claude/rules/local-dev-environment.md.skip +2 -2
- package/template/.claude/rules/memory-guidance.md +20 -14
- package/template/.claude/rules/token-economics.md.skip +2 -2
- package/template/.claude/rules/work-item-tracking.md +1 -1
- package/template/.claude/rules/workspace-structure.md +36 -15
- package/template/.claude/scripts/build-workspace-context.mjs +365 -0
- package/template/.claude/scripts/build-workspace-context.test.mjs +633 -0
- package/template/.claude/scripts/capture-context.mjs +217 -0
- package/template/.claude/scripts/capture-context.test.mjs +383 -0
- package/template/.claude/scripts/generate-claude-local.mjs +104 -0
- package/template/.claude/scripts/generate-claude-local.test.mjs +184 -0
- package/template/.claude/scripts/migrate-open-work.mjs +1 -1
- package/template/.claude/scripts/migrate-to-workspace-context.mjs +520 -0
- package/template/.claude/scripts/migrate-to-workspace-context.test.mjs +325 -0
- package/template/.claude/scripts/sweep-references.mjs +177 -0
- package/template/.claude/scripts/sweep-references.test.mjs +184 -0
- package/template/.claude/skills/aside/SKILL.md +49 -44
- package/template/.claude/skills/braindump/SKILL.md +25 -19
- package/template/.claude/skills/build-docs-site/SKILL.md +1 -1
- package/template/.claude/skills/build-docs-site/checklists/framing.md +1 -1
- package/template/.claude/skills/complete-work/SKILL.md +3 -3
- package/template/.claude/skills/handoff/SKILL.md +31 -30
- package/template/.claude/skills/maintenance/SKILL.md +18 -18
- package/template/.claude/skills/pause-work/SKILL.md +1 -1
- package/template/.claude/skills/promote/SKILL.md +18 -8
- package/template/.claude/skills/release/SKILL.md +17 -13
- package/template/.claude/skills/start-work/SKILL.md +1 -1
- package/template/.claude/skills/workspace-init/SKILL.md +12 -12
- package/template/CLAUDE.md.tmpl +4 -3
- package/template/_gitignore +1 -0
- package/template/workspace.json.tmpl +2 -2
- package/template/.claude/scripts/build-shared-context-index.mjs +0 -212
- package/template/.claude/scripts/build-shared-context-index.test.mjs +0 -318
|
@@ -0,0 +1,633 @@
|
|
|
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);
|