@ulysses-ai/create-workspace 0.14.0-beta.3 → 0.15.0-beta.1

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 (50) 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/agents/reviewer.md +1 -1
  5. package/template/.claude/hooks/pre-compact.mjs +1 -1
  6. package/template/.claude/hooks/repo-write-detection.mjs +2 -2
  7. package/template/.claude/hooks/session-start.mjs +10 -7
  8. package/template/.claude/hooks/subagent-start.mjs +3 -3
  9. package/template/.claude/recipes/migrate-from-notion.md +6 -6
  10. package/template/.claude/rules/coherent-revisions.md +2 -2
  11. package/template/.claude/rules/local-dev-environment.md.skip +2 -2
  12. package/template/.claude/rules/memory-guidance.md +23 -14
  13. package/template/.claude/rules/token-economics.md.skip +2 -2
  14. package/template/.claude/rules/work-item-tracking.md +1 -1
  15. package/template/.claude/rules/workspace-structure.md +36 -15
  16. package/template/.claude/scripts/build-workspace-context.mjs +712 -0
  17. package/template/.claude/scripts/capture-context.mjs +217 -0
  18. package/template/.claude/scripts/generate-claude-local.mjs +104 -0
  19. package/template/.claude/scripts/migrate-canonical-priority.mjs +108 -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/sweep-references.mjs +177 -0
  23. package/template/.claude/skills/aside/SKILL.md +49 -44
  24. package/template/.claude/skills/braindump/SKILL.md +25 -19
  25. package/template/.claude/skills/build-docs-site/SKILL.md +1 -1
  26. package/template/.claude/skills/build-docs-site/checklists/framing.md +1 -1
  27. package/template/.claude/skills/complete-work/SKILL.md +91 -3
  28. package/template/.claude/skills/handoff/SKILL.md +31 -30
  29. package/template/.claude/skills/maintenance/SKILL.md +90 -22
  30. package/template/.claude/skills/pause-work/SKILL.md +1 -1
  31. package/template/.claude/skills/promote/SKILL.md +18 -8
  32. package/template/.claude/skills/release/SKILL.md +20 -13
  33. package/template/.claude/skills/start-work/SKILL.md +1 -1
  34. package/template/.claude/skills/workspace-init/SKILL.md +12 -12
  35. package/template/.claude/skills/workspace-update/SKILL.md +7 -1
  36. package/template/CLAUDE.md.tmpl +4 -3
  37. package/template/_gitignore +1 -0
  38. package/template/workspace.json.tmpl +3 -2
  39. package/template/.claude/hooks/_bash-output-advisory.test.mjs +0 -88
  40. package/template/.claude/hooks/_utils.test.mjs +0 -99
  41. package/template/.claude/lib/freshness.test.mjs +0 -175
  42. package/template/.claude/lib/registry-check.test.mjs +0 -130
  43. package/template/.claude/lib/session-frontmatter.test.mjs +0 -242
  44. package/template/.claude/scripts/build-shared-context-index.mjs +0 -212
  45. package/template/.claude/scripts/build-shared-context-index.test.mjs +0 -318
  46. package/template/.claude/scripts/migrate-claude-md-freshness-include.test.mjs +0 -54
  47. package/template/.claude/scripts/migrate-session-layout.test.mjs +0 -144
  48. package/template/.claude/scripts/sync-tasks.test.mjs +0 -350
  49. package/template/.claude/scripts/trackers/github-issues.test.mjs +0 -190
  50. package/template/.claude/scripts/trackers/interface.test.mjs +0 -40
@@ -0,0 +1,712 @@
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
+ // Canonical files honor a configurable byte budget. Each locked file declares
13
+ // `priority: critical | reference` in its frontmatter (default: critical).
14
+ // Section-level `<!-- canonical:trim --> ... <!-- canonical:end-trim -->` markers
15
+ // fence droppable spans inside reference files. When canonical body bytes exceed
16
+ // the budget, the builder trims reference files first, then stubs them, in that
17
+ // deterministic order. Critical files are never modified.
18
+ //
19
+ // Usage:
20
+ // node build-workspace-context.mjs --write [--root <workspace-root>]
21
+ // node build-workspace-context.mjs --check [--root <workspace-root>]
22
+ //
23
+ // --write regenerates all three artifacts.
24
+ // --check exit codes:
25
+ // 0 — all artifacts current and canonical within budget
26
+ // 1 — at least one artifact missing or stale (regenerate via --write)
27
+ // 2 — artifacts current, but canonical body exceeds budget after trimming and stubbing
28
+ // Stale wins over over-budget when both apply.
29
+
30
+ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, realpathSync } from 'node:fs';
31
+ import { join, relative, sep } from 'node:path';
32
+ import { spawnSync } from 'node:child_process';
33
+ import { fileURLToPath } from 'node:url';
34
+ import { parseSessionContent } from '../lib/session-frontmatter.mjs';
35
+
36
+ function isMainModule(metaUrl) {
37
+ if (!process.argv[1]) return false;
38
+ try {
39
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
40
+ } catch { return false; }
41
+ }
42
+
43
+ export const DEFAULT_CANONICAL_BUDGET = 40960;
44
+
45
+ const WC_DIR = 'workspace-context';
46
+ const SHARED_DIR = 'shared';
47
+ const LOCKED_DIR = 'locked';
48
+ const TEAM_MEMBER_DIR = 'team-member';
49
+ const INDEX_FILENAME = 'index.md';
50
+ const CANONICAL_FILENAME = 'canonical.md';
51
+ const IGNORE_FILENAME = '.indexignore';
52
+
53
+ function parseArgs(argv) {
54
+ const args = { mode: null, root: process.cwd() };
55
+ for (let i = 2; i < argv.length; i++) {
56
+ const a = argv[i];
57
+ if (a === '--write') args.mode = 'write';
58
+ else if (a === '--check') args.mode = 'check';
59
+ else if (a === '--root') args.root = argv[++i];
60
+ }
61
+ if (!args.mode) throw new Error('Specify --write or --check');
62
+ return args;
63
+ }
64
+
65
+ function walkMarkdown(dir) {
66
+ const out = [];
67
+ if (!existsSync(dir)) return out;
68
+ for (const name of readdirSync(dir)) {
69
+ const full = join(dir, name);
70
+ const st = statSync(full);
71
+ if (st.isDirectory()) out.push(...walkMarkdown(full));
72
+ else if (st.isFile() && name.endsWith('.md')) out.push(full);
73
+ }
74
+ return out;
75
+ }
76
+
77
+ function readDescription(filePath) {
78
+ let frontmatter = {};
79
+ let body = '';
80
+ try {
81
+ const parsed = parseSessionContent(readFileSync(filePath, 'utf-8'));
82
+ frontmatter = parsed.fields || {};
83
+ body = parsed.body || '';
84
+ } catch {
85
+ body = readFileSync(filePath, 'utf-8');
86
+ }
87
+
88
+ if (typeof frontmatter.description === 'string' && frontmatter.description.trim()) {
89
+ return frontmatter.description.trim();
90
+ }
91
+ const stripped = body.replace(/^#.*$/m, '').trim();
92
+ const firstParagraph = stripped.split(/\n\s*\n/, 1)[0] || '';
93
+ const firstSentence = firstParagraph.replace(/\n/g, ' ').match(/[^.!?]+[.!?]/);
94
+ if (firstSentence) {
95
+ const candidate = firstSentence[0].trim();
96
+ if (candidate.length > 0 && candidate.length <= 200) return candidate;
97
+ }
98
+ const filename = filePath.split(sep).pop() || '';
99
+ return filename.replace(/\.md$/, '').replace(/^(braindump|handoff|research)_/, '').replace(/-/g, ' ');
100
+ }
101
+
102
+ function readIgnorePrefixes(wcDir) {
103
+ const ignorePath = join(wcDir, IGNORE_FILENAME);
104
+ if (!existsSync(ignorePath)) return [];
105
+ return readFileSync(ignorePath, 'utf-8')
106
+ .split('\n')
107
+ .map((l) => l.replace(/#.*/, '').trim())
108
+ .filter((l) => l.length > 0);
109
+ }
110
+
111
+ function isIgnored(relativePath, prefixes) {
112
+ for (const prefix of prefixes) {
113
+ if (relativePath === prefix) return true;
114
+ if (relativePath.startsWith(prefix.endsWith('/') ? prefix : prefix + '/')) return true;
115
+ }
116
+ return false;
117
+ }
118
+
119
+ function gitIgnoredPaths(workspaceRoot, paths) {
120
+ if (paths.length === 0) return new Set();
121
+ const result = spawnSync('git', ['check-ignore', '--stdin'], {
122
+ cwd: workspaceRoot,
123
+ input: paths.join('\n'),
124
+ encoding: 'utf-8',
125
+ });
126
+ if (result.error || (result.status !== 0 && result.status !== 1)) return new Set();
127
+ return new Set(
128
+ result.stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0),
129
+ );
130
+ }
131
+
132
+ function stripFrontmatter(content) {
133
+ if (!content.startsWith('---\n')) return content;
134
+ const end = content.indexOf('\n---\n', 4);
135
+ if (end === -1) return content;
136
+ return content.slice(end + 5).replace(/^\n+/, '');
137
+ }
138
+
139
+ function describeAndPath(filePath, wcRoot) {
140
+ const rel = relative(wcRoot, filePath).split(sep).join('/');
141
+ return { rel, description: readDescription(filePath) };
142
+ }
143
+
144
+ // ---------- shared index ----------
145
+
146
+ function buildSharedIndex(workspaceRoot) {
147
+ const wcRoot = join(workspaceRoot, WC_DIR);
148
+ const sharedDir = join(wcRoot, SHARED_DIR);
149
+ if (!existsSync(sharedDir)) return [];
150
+
151
+ const ignorePrefixes = readIgnorePrefixes(wcRoot);
152
+ const candidates = walkMarkdown(sharedDir);
153
+ const candidatePaths = candidates.map((f) => relative(workspaceRoot, f).split(sep).join('/'));
154
+ const gitIgnored = gitIgnoredPaths(workspaceRoot, candidatePaths);
155
+
156
+ const entries = [];
157
+ for (let i = 0; i < candidates.length; i++) {
158
+ const f = candidates[i];
159
+ const relToWC = relative(wcRoot, f).split(sep).join('/');
160
+ if (relToWC === INDEX_FILENAME || relToWC === CANONICAL_FILENAME) continue;
161
+ if (isIgnored(relToWC, ignorePrefixes)) continue;
162
+ if (gitIgnored.has(candidatePaths[i])) continue;
163
+ const isLocked = relToWC.startsWith(`${SHARED_DIR}/${LOCKED_DIR}/`);
164
+ const { description } = describeAndPath(f, wcRoot);
165
+ entries.push({ rel: relToWC, isLocked, description });
166
+ }
167
+ entries.sort((a, b) => {
168
+ if (a.isLocked !== b.isLocked) return a.isLocked ? -1 : 1;
169
+ return a.rel.localeCompare(b.rel);
170
+ });
171
+ return entries;
172
+ }
173
+
174
+ function renderSharedIndex(entries, generatedAt) {
175
+ const lines = [
176
+ '---',
177
+ 'type: index',
178
+ `generated: ${generatedAt}`,
179
+ '---',
180
+ '',
181
+ '# workspace-context — index',
182
+ '',
183
+ '> Auto-generated by `.claude/scripts/build-workspace-context.mjs`. Hand edits will be overwritten — update source files instead.',
184
+ '',
185
+ ];
186
+ const locked = entries.filter((e) => e.isLocked);
187
+ const other = entries.filter((e) => !e.isLocked);
188
+ if (locked.length > 0) {
189
+ lines.push('## Canonical (in CLAUDE.md context verbatim)', '');
190
+ for (const e of locked) lines.push(`- [${e.rel}](${e.rel}) — ${e.description}`);
191
+ lines.push('');
192
+ }
193
+ if (other.length > 0) {
194
+ lines.push('## Shared', '');
195
+ for (const e of other) lines.push(`- [${e.rel}](${e.rel}) — ${e.description}`);
196
+ lines.push('');
197
+ }
198
+ if (entries.length === 0) {
199
+ lines.push('_(no shared workspace-context files yet)_', '');
200
+ }
201
+ return lines.join('\n');
202
+ }
203
+
204
+ // ---------- canonical concat ----------
205
+
206
+ const TRIM_OPEN_RE = /<!--\s*canonical:trim\s*-->/g;
207
+ const TRIM_CLOSE_RE = /<!--\s*canonical:end-trim\s*-->/g;
208
+ const TRIM_BLOCK_RE = /<!--\s*canonical:trim\s*-->[\s\S]*?<!--\s*canonical:end-trim\s*-->\n?/g;
209
+ const ANY_MARKER_RE = /<!--\s*canonical:(?:end-)?trim\s*-->\n?/g;
210
+
211
+ /**
212
+ * Validate that canonical:trim markers are well-formed in `body`. Throws an
213
+ * Error if an opener has no matching closer, or if a second opener appears
214
+ * before the corresponding end-trim. Used by extractCanonicalVariants.
215
+ */
216
+ function validateTrimMarkers(body, name) {
217
+ let depth = 0;
218
+ let idx = 0;
219
+ while (idx < body.length) {
220
+ TRIM_OPEN_RE.lastIndex = idx;
221
+ TRIM_CLOSE_RE.lastIndex = idx;
222
+ const openMatch = TRIM_OPEN_RE.exec(body);
223
+ const closeMatch = TRIM_CLOSE_RE.exec(body);
224
+ const openAt = openMatch ? openMatch.index : -1;
225
+ const closeAt = closeMatch ? closeMatch.index : -1;
226
+ if (openAt === -1 && closeAt === -1) break;
227
+ if (openAt !== -1 && (closeAt === -1 || openAt < closeAt)) {
228
+ if (depth > 0) {
229
+ throw new Error(`canonical:trim parse error in ${name}: nested opener at offset ${openAt}`);
230
+ }
231
+ depth = 1;
232
+ idx = openAt + openMatch[0].length;
233
+ } else {
234
+ if (depth === 0) {
235
+ throw new Error(`canonical:trim parse error in ${name}: unmatched end-trim at offset ${closeAt}`);
236
+ }
237
+ depth = 0;
238
+ idx = closeAt + closeMatch[0].length;
239
+ }
240
+ }
241
+ if (depth !== 0) {
242
+ throw new Error(`canonical:trim parse error in ${name}: unmatched opener (no end-trim before EOF)`);
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Collapse runs of three or more consecutive newlines to two so that variant
248
+ * outputs do not grow extra blank lines after marker removal.
249
+ */
250
+ function collapseBlankLines(s) {
251
+ return s.replace(/\n{3,}/g, '\n\n');
252
+ }
253
+
254
+ /**
255
+ * Compute the three body variants for a single locked file.
256
+ *
257
+ * Inputs: { name, rawContent } where rawContent is the file as read from disk
258
+ * (frontmatter still present). Output: { full, trimmed, stub } — each is the
259
+ * body string used for canonical rendering when that variant is selected.
260
+ *
261
+ * - `full` — frontmatter stripped, all `canonical:trim`/`canonical:end-trim`
262
+ * marker lines removed but their content kept.
263
+ * - `trimmed` — fenced spans (markers + content) replaced with a one-line
264
+ * breadcrumb. Consecutive breadcrumbs collapse to one. Bare markers are
265
+ * stripped defensively.
266
+ * - `stub` — the entire body is replaced with a one-line breadcrumb pointing
267
+ * to the source file.
268
+ *
269
+ * Throws if markers are malformed (unmatched opener or nested opener).
270
+ */
271
+ export function extractCanonicalVariants({ name, rawContent }) {
272
+ const body = stripFrontmatter(rawContent);
273
+ validateTrimMarkers(body, name);
274
+
275
+ // full: drop all marker lines but keep content.
276
+ const full = collapseBlankLines(body.replace(ANY_MARKER_RE, '')).trimEnd();
277
+
278
+ // trimmed: replace fenced spans with breadcrumb, collapse consecutive
279
+ // breadcrumbs, then strip any remaining bare markers (defensive).
280
+ const breadcrumb = `> _Trimmed for canonical budget — see \`shared/locked/${name}.md\` for full content._\n`;
281
+ let trimmed = body.replace(TRIM_BLOCK_RE, breadcrumb);
282
+ // Collapse consecutive identical breadcrumb lines into one.
283
+ const breadcrumbLine = breadcrumb.trimEnd();
284
+ const escapedBreadcrumb = breadcrumbLine.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
285
+ const consecutiveBreadcrumbs = new RegExp(`(?:${escapedBreadcrumb}\\n)+`, 'g');
286
+ trimmed = trimmed.replace(consecutiveBreadcrumbs, breadcrumb);
287
+ trimmed = trimmed.replace(ANY_MARKER_RE, '');
288
+ trimmed = collapseBlankLines(trimmed).trimEnd();
289
+
290
+ const stub = `> _Dropped for canonical budget — see \`shared/locked/${name}.md\`._`;
291
+
292
+ return { full, trimmed, stub };
293
+ }
294
+
295
+ /**
296
+ * Read `workspace.canonicalBudgetBytes` from `workspace.json`.
297
+ * Returns:
298
+ * - The integer value when set to a non-negative integer.
299
+ * - 0 when set to 0 or a negative number (treated as disabled).
300
+ * - DEFAULT_CANONICAL_BUDGET when the field is absent or workspace.json is missing.
301
+ * - DEFAULT_CANONICAL_BUDGET (with a stderr warning) when workspace.json fails to parse.
302
+ */
303
+ export function readWorkspaceBudget(workspaceRoot) {
304
+ const path = join(workspaceRoot, 'workspace.json');
305
+ if (!existsSync(path)) return DEFAULT_CANONICAL_BUDGET;
306
+ let parsed;
307
+ try {
308
+ parsed = JSON.parse(readFileSync(path, 'utf-8'));
309
+ } catch (err) {
310
+ process.stderr.write(`warning: failed to parse workspace.json (${err.message}); using default canonical budget\n`);
311
+ return DEFAULT_CANONICAL_BUDGET;
312
+ }
313
+ const ws = parsed && typeof parsed === 'object' ? parsed.workspace : null;
314
+ if (!ws || typeof ws !== 'object') return DEFAULT_CANONICAL_BUDGET;
315
+ if (!('canonicalBudgetBytes' in ws)) return DEFAULT_CANONICAL_BUDGET;
316
+ const v = ws.canonicalBudgetBytes;
317
+ if (typeof v !== 'number' || !Number.isFinite(v)) return DEFAULT_CANONICAL_BUDGET;
318
+ if (v <= 0) return 0;
319
+ return Math.floor(v);
320
+ }
321
+
322
+ /**
323
+ * Render just the per-item body of canonical (no frontmatter, no header).
324
+ * Pure function used both by selectCanonicalContent (to measure body bytes)
325
+ * and by renderCanonical (to emit the final document).
326
+ */
327
+ export function renderCanonicalBody(resolvedItems) {
328
+ if (resolvedItems.length === 0) return '';
329
+ const parts = [];
330
+ for (const item of resolvedItems) {
331
+ parts.push(`## ${item.name}\n\n${item.content}\n`);
332
+ }
333
+ return parts.join('\n');
334
+ }
335
+
336
+ /**
337
+ * Pick a canonical resolution that fits the budget when possible.
338
+ *
339
+ * Items shape: { name, priority, full, trimmed, stub }.
340
+ * Returns { resolvedItems, selection } where selection has:
341
+ * - status: 'ok' | 'trimmed' | 'stubbed' | 'over-budget'
342
+ * - budgetBytes: number or null (null when budget is disabled)
343
+ * - currentBytes: body bytes after the chosen resolution
344
+ * - overBy?: number (present when status === 'over-budget')
345
+ * - trimmedFiles, stubbedFiles: names of items resolved that way
346
+ *
347
+ * Algorithm (deterministic, four stages):
348
+ * 1. All items → full. If body ≤ budget: status `ok`.
349
+ * 2. Reference items → trimmed; critical → full. If ≤ budget: `trimmed`.
350
+ * 3. Reference items → stub; critical → full. If ≤ budget: `stubbed`.
351
+ * 4. Keep stage-3 resolution; status `over-budget`, overBy populated.
352
+ *
353
+ * Special cases:
354
+ * - budgetBytes <= 0: stage 1 always wins, selection.budgetBytes = null.
355
+ * - No reference items present and stage 1 fails: status `over-budget`,
356
+ * no transformation possible. Stderr warning is emitted.
357
+ */
358
+ export function selectCanonicalContent(items, budgetBytes, opts) {
359
+ const measure = opts && typeof opts.measureBodyBytes === 'function'
360
+ ? opts.measureBodyBytes
361
+ : (resolved) => Buffer.byteLength(renderCanonicalBody(resolved), 'utf-8');
362
+
363
+ const resolveAt = (stage) => items.map((item) => {
364
+ if (stage === 1) return { name: item.name, priority: item.priority, content: item.full };
365
+ if (item.priority === 'reference') {
366
+ return {
367
+ name: item.name,
368
+ priority: item.priority,
369
+ content: stage === 2 ? item.trimmed : item.stub,
370
+ };
371
+ }
372
+ return { name: item.name, priority: item.priority, content: item.full };
373
+ });
374
+
375
+ // Disabled-budget path.
376
+ if (!Number.isFinite(budgetBytes) || budgetBytes <= 0) {
377
+ const resolved = resolveAt(1);
378
+ return {
379
+ resolvedItems: resolved,
380
+ selection: {
381
+ status: 'ok',
382
+ budgetBytes: null,
383
+ currentBytes: measure(resolved),
384
+ trimmedFiles: [],
385
+ stubbedFiles: [],
386
+ },
387
+ };
388
+ }
389
+
390
+ // Stage 1: full.
391
+ const stage1 = resolveAt(1);
392
+ const stage1Bytes = measure(stage1);
393
+ if (stage1Bytes <= budgetBytes) {
394
+ return {
395
+ resolvedItems: stage1,
396
+ selection: {
397
+ status: 'ok',
398
+ budgetBytes,
399
+ currentBytes: stage1Bytes,
400
+ trimmedFiles: [],
401
+ stubbedFiles: [],
402
+ },
403
+ };
404
+ }
405
+
406
+ const referenceNames = items.filter((i) => i.priority === 'reference').map((i) => i.name);
407
+
408
+ // No reference files exist → cannot trim. Report over-budget at stage 1.
409
+ if (referenceNames.length === 0) {
410
+ process.stderr.write('warning: no priority:reference files exist — consider demoting one via /maintenance cleanup\n');
411
+ return {
412
+ resolvedItems: stage1,
413
+ selection: {
414
+ status: 'over-budget',
415
+ budgetBytes,
416
+ currentBytes: stage1Bytes,
417
+ overBy: stage1Bytes - budgetBytes,
418
+ trimmedFiles: [],
419
+ stubbedFiles: [],
420
+ },
421
+ };
422
+ }
423
+
424
+ // Stage 2: trim reference files.
425
+ const stage2 = resolveAt(2);
426
+ const stage2Bytes = measure(stage2);
427
+ if (stage2Bytes <= budgetBytes) {
428
+ return {
429
+ resolvedItems: stage2,
430
+ selection: {
431
+ status: 'trimmed',
432
+ budgetBytes,
433
+ currentBytes: stage2Bytes,
434
+ trimmedFiles: referenceNames,
435
+ stubbedFiles: [],
436
+ },
437
+ };
438
+ }
439
+
440
+ // Stage 3: stub reference files.
441
+ const stage3 = resolveAt(3);
442
+ const stage3Bytes = measure(stage3);
443
+ if (stage3Bytes <= budgetBytes) {
444
+ return {
445
+ resolvedItems: stage3,
446
+ selection: {
447
+ status: 'stubbed',
448
+ budgetBytes,
449
+ currentBytes: stage3Bytes,
450
+ trimmedFiles: [],
451
+ stubbedFiles: referenceNames,
452
+ },
453
+ };
454
+ }
455
+
456
+ // Stage 4: still over. Keep stage-3 resolution.
457
+ return {
458
+ resolvedItems: stage3,
459
+ selection: {
460
+ status: 'over-budget',
461
+ budgetBytes,
462
+ currentBytes: stage3Bytes,
463
+ overBy: stage3Bytes - budgetBytes,
464
+ trimmedFiles: [],
465
+ stubbedFiles: referenceNames,
466
+ },
467
+ };
468
+ }
469
+
470
+ function buildCanonical(workspaceRoot) {
471
+ const lockedDir = join(workspaceRoot, WC_DIR, SHARED_DIR, LOCKED_DIR);
472
+ if (!existsSync(lockedDir)) return [];
473
+ const files = walkMarkdown(lockedDir).filter((f) => !f.endsWith('.keep')).sort();
474
+ const items = [];
475
+ for (const f of files) {
476
+ const name = f.split(sep).pop().replace(/\.md$/, '');
477
+ const rawContent = readFileSync(f, 'utf-8');
478
+ let priority = 'critical';
479
+ try {
480
+ const parsed = parseSessionContent(rawContent);
481
+ const fields = parsed.fields || {};
482
+ if (typeof fields.priority === 'string' && fields.priority.trim()) {
483
+ priority = fields.priority.trim();
484
+ }
485
+ } catch {
486
+ // No parseable frontmatter — keep default 'critical'.
487
+ }
488
+ const variants = extractCanonicalVariants({ name, rawContent });
489
+ if (priority === 'reference' && variants.trimmed === variants.full) {
490
+ process.stderr.write(`warning: reference file '${name}' has no canonical:trim markers; trimming is a no-op for it\n`);
491
+ }
492
+ items.push({ name, priority, full: variants.full, trimmed: variants.trimmed, stub: variants.stub });
493
+ }
494
+ return items;
495
+ }
496
+
497
+ function summarizeSelection(selection) {
498
+ if (selection.status === 'ok') return 'full';
499
+ if (selection.status === 'trimmed') return `${selection.trimmedFiles.length} reference files trimmed`;
500
+ if (selection.status === 'stubbed') return `${selection.stubbedFiles.length} reference files stubbed`;
501
+ return `over budget by ${selection.overBy} bytes`;
502
+ }
503
+
504
+ function renderCanonical(resolvedItems, selection, generatedAt) {
505
+ const showBudget = selection && selection.budgetBytes !== null && selection.budgetBytes !== undefined;
506
+ const fmLines = ['---', 'type: canonical', `generated: ${generatedAt}`];
507
+ if (showBudget) {
508
+ fmLines.push(`budget: ${selection.budgetBytes}`);
509
+ fmLines.push(`status: ${selection.status}`);
510
+ }
511
+ fmLines.push('---', '');
512
+
513
+ const lines = [
514
+ ...fmLines,
515
+ '# workspace-context — canonical truths',
516
+ '',
517
+ '> Auto-generated concatenation of `shared/locked/*.md`. Hand edits will be overwritten — update source files instead.',
518
+ ];
519
+ if (showBudget) {
520
+ lines.push(
521
+ `> Budget: ${selection.budgetBytes} bytes (body); current: ${selection.currentBytes} bytes; status: ${selection.status} (${summarizeSelection(selection)}).`,
522
+ );
523
+ }
524
+ lines.push('');
525
+
526
+ if (resolvedItems.length === 0) {
527
+ lines.push('_(no canonical entries yet — promote one via `/release`)_', '');
528
+ return lines.join('\n');
529
+ }
530
+
531
+ const body = renderCanonicalBody(resolvedItems);
532
+ // body already ends with \n (each item ends in \n, joined by \n). Append directly.
533
+ return lines.join('\n') + body;
534
+ }
535
+
536
+ // ---------- team-member indexes ----------
537
+
538
+ function buildTeamMemberIndex(workspaceRoot, user) {
539
+ const userDir = join(workspaceRoot, WC_DIR, TEAM_MEMBER_DIR, user);
540
+ if (!existsSync(userDir)) return [];
541
+
542
+ const candidates = walkMarkdown(userDir).filter(
543
+ (f) => f.split(sep).pop() !== INDEX_FILENAME,
544
+ );
545
+ const candidatePaths = candidates.map((f) => relative(workspaceRoot, f).split(sep).join('/'));
546
+ const gitIgnored = gitIgnoredPaths(workspaceRoot, candidatePaths);
547
+
548
+ const entries = [];
549
+ for (let i = 0; i < candidates.length; i++) {
550
+ if (gitIgnored.has(candidatePaths[i])) continue;
551
+ const relToUserDir = relative(userDir, candidates[i]).split(sep).join('/');
552
+ const description = readDescription(candidates[i]);
553
+ entries.push({ rel: relToUserDir, description });
554
+ }
555
+ entries.sort((a, b) => a.rel.localeCompare(b.rel));
556
+ return entries;
557
+ }
558
+
559
+ function renderTeamMemberIndex(user, entries, generatedAt) {
560
+ const lines = [
561
+ '---',
562
+ 'type: index',
563
+ `generated: ${generatedAt}`,
564
+ '---',
565
+ '',
566
+ `# ${user}'s context`,
567
+ '',
568
+ '> Auto-generated by `.claude/scripts/build-workspace-context.mjs`. Hand edits will be overwritten.',
569
+ '',
570
+ ];
571
+ for (const e of entries) {
572
+ lines.push(`- [${e.rel}](${e.rel}) — ${e.description}`);
573
+ }
574
+ if (entries.length === 0) {
575
+ lines.push('_(no personal context files yet)_');
576
+ }
577
+ return lines.join('\n') + '\n';
578
+ }
579
+
580
+ function listTeamMembers(workspaceRoot) {
581
+ const tmDir = join(workspaceRoot, WC_DIR, TEAM_MEMBER_DIR);
582
+ if (!existsSync(tmDir)) return [];
583
+ return readdirSync(tmDir)
584
+ .filter((name) => {
585
+ const full = join(tmDir, name);
586
+ return statSync(full).isDirectory();
587
+ })
588
+ .sort();
589
+ }
590
+
591
+ // ---------- orchestration ----------
592
+
593
+ function fingerprint(content) {
594
+ return content
595
+ .split('\n')
596
+ .filter((l) => !l.startsWith('generated:'))
597
+ .join('\n');
598
+ }
599
+
600
+ function regenerateAll(workspaceRoot, generatedAt) {
601
+ const wcRoot = join(workspaceRoot, WC_DIR);
602
+ if (!existsSync(wcRoot)) return [];
603
+
604
+ const out = [];
605
+ const sharedEntries = buildSharedIndex(workspaceRoot);
606
+ out.push({
607
+ path: join(wcRoot, INDEX_FILENAME),
608
+ label: 'index.md',
609
+ content: renderSharedIndex(sharedEntries, generatedAt) + '\n',
610
+ });
611
+
612
+ const canonicalItems = buildCanonical(workspaceRoot);
613
+ const budget = readWorkspaceBudget(workspaceRoot);
614
+ const { resolvedItems, selection } = selectCanonicalContent(canonicalItems, budget, {
615
+ measureBodyBytes: (resolved) => Buffer.byteLength(renderCanonicalBody(resolved), 'utf-8'),
616
+ });
617
+ out.push({
618
+ path: join(wcRoot, CANONICAL_FILENAME),
619
+ label: 'canonical.md',
620
+ content: renderCanonical(resolvedItems, selection, generatedAt) + '\n',
621
+ selection,
622
+ });
623
+
624
+ for (const user of listTeamMembers(workspaceRoot)) {
625
+ const entries = buildTeamMemberIndex(workspaceRoot, user);
626
+ out.push({
627
+ path: join(wcRoot, TEAM_MEMBER_DIR, user, INDEX_FILENAME),
628
+ label: `team-member/${user}/index.md`,
629
+ content: renderTeamMemberIndex(user, entries, generatedAt),
630
+ });
631
+ }
632
+
633
+ return out;
634
+ }
635
+
636
+ /**
637
+ * CLI entry point.
638
+ *
639
+ * Exit codes for `--check`:
640
+ * 0 — all artifacts current and canonical body is within budget.
641
+ * 1 — at least one artifact is missing or stale on disk. Run `--write`.
642
+ * 2 — artifacts are current but canonical body exceeds budget after
643
+ * trimming and stubbing eligible reference files. Triage via
644
+ * `/maintenance cleanup`. If both stale and over-budget, exit 1.
645
+ *
646
+ * `--write` always exits 0 on successful regeneration; over-budget is
647
+ * surfaced as a stderr warning during selection but does not block the write.
648
+ */
649
+ function main() {
650
+ const args = parseArgs(process.argv);
651
+ const generatedAt = new Date().toISOString();
652
+ const artifacts = regenerateAll(args.root, generatedAt);
653
+
654
+ if (args.mode === 'check') {
655
+ const stale = [];
656
+ const missing = [];
657
+ for (const a of artifacts) {
658
+ if (!existsSync(a.path)) { missing.push(a.label); continue; }
659
+ const onDisk = readFileSync(a.path, 'utf-8');
660
+ if (fingerprint(onDisk) !== fingerprint(a.content)) stale.push(a.label);
661
+ }
662
+
663
+ const canonicalArtifact = artifacts.find((a) => a.label === 'canonical.md');
664
+ const sel = canonicalArtifact ? canonicalArtifact.selection : null;
665
+ const canonicalBlock = sel ? {
666
+ budget: sel.budgetBytes,
667
+ current: sel.currentBytes,
668
+ ...(sel.overBy !== undefined ? { overBy: sel.overBy } : {}),
669
+ selectionStatus: sel.status,
670
+ trimmedFiles: sel.trimmedFiles,
671
+ stubbedFiles: sel.stubbedFiles,
672
+ } : null;
673
+
674
+ if (missing.length === 0 && stale.length === 0) {
675
+ const overBudget = sel && sel.status === 'over-budget';
676
+ const payload = { status: 'current', missing: [], stale: [] };
677
+ if (canonicalBlock) payload.canonical = canonicalBlock;
678
+ payload.artifacts = artifacts.length;
679
+ process.stdout.write(JSON.stringify(payload) + '\n');
680
+ process.exit(overBudget ? 2 : 0);
681
+ }
682
+
683
+ const payload = { status: 'stale', missing, stale };
684
+ if (canonicalBlock) payload.canonical = canonicalBlock;
685
+ process.stdout.write(JSON.stringify(payload) + '\n');
686
+ process.exit(1);
687
+ }
688
+
689
+ if (args.mode === 'write') {
690
+ for (const a of artifacts) writeFileSync(a.path, a.content);
691
+ process.stdout.write(
692
+ JSON.stringify({ status: 'written', artifacts: artifacts.map((a) => a.label) }) + '\n',
693
+ );
694
+ process.exit(0);
695
+ }
696
+ }
697
+
698
+ if (isMainModule(import.meta.url)) main();
699
+
700
+ export {
701
+ buildSharedIndex,
702
+ renderSharedIndex,
703
+ buildCanonical,
704
+ renderCanonical,
705
+ buildTeamMemberIndex,
706
+ renderTeamMemberIndex,
707
+ listTeamMembers,
708
+ regenerateAll,
709
+ fingerprint,
710
+ readDescription,
711
+ stripFrontmatter,
712
+ };