@ulysses-ai/create-workspace 0.15.0-beta.0 → 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 (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 +3 -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
package/lib/init.mjs CHANGED
@@ -50,37 +50,24 @@ export async function initWorkspace(targetDir) {
50
50
  console.log(` Installed ${dir}`);
51
51
  }
52
52
 
53
- // Create shared-context structure. We always want shared-context/ and
54
- // shared-context/locked/ to exist after init, even if the payload ships
55
- // no files for them yet locked/ is referenced by CLAUDE.md.tmpl via
56
- // the @shared-context/locked/ import directive.
57
- const payloadContext = join(payloadDir, 'shared-context');
58
- const targetContext = join(targetDir, 'shared-context');
59
- if (existsSync(payloadContext) && !existsSync(targetContext)) {
60
- cpSync(payloadContext, targetContext, { recursive: true });
61
- console.log(' Created shared-context/');
62
- }
63
- ensureDir(join(targetDir, 'shared-context', 'locked'));
53
+ // Ensure workspace-context/locked/ exists. canonical.md and index.md are
54
+ // generated later by /workspace-init via build-workspace-context.mjs;
55
+ // this just guarantees the destination directory is in place.
56
+ ensureDir(join(targetDir, 'workspace-context', 'locked'));
64
57
 
65
58
  // Everything else (repos/, work-sessions/, workspace-scratchpad/) is
66
59
  // lazy-created by scripts and hooks when they first need to write.
67
60
  // We intentionally do NOT pre-create these dirs — they get made on demand.
68
61
 
69
- // Create workspace.json if missing
62
+ // Create workspace.json from template if missing. Single source of truth
63
+ // for shape is template/workspace.json.tmpl — mirrors the CLAUDE.md.tmpl
64
+ // handling below.
70
65
  if (!existsSync(workspaceJsonPath)) {
71
- writeFileSync(workspaceJsonPath, JSON.stringify({
72
- workspace: {
73
- name,
74
- templateVersion: toVersion,
75
- scratchpadDir: 'workspace-scratchpad',
76
- workSessionsDir: 'work-sessions',
77
- sharedContextDir: 'shared-context',
78
- releaseNotesDir: 'release-notes',
79
- subagentContextMaxBytes: 10240,
80
- greeting: `Welcome back to ${name}.`,
81
- },
82
- repos: {},
83
- }, null, 2) + '\n');
66
+ const wsTmplPath = join(payloadDir, 'workspace.json.tmpl');
67
+ const wsTmpl = readFileSync(wsTmplPath, 'utf-8').replace(/\{\{project-name\}\}/g, name);
68
+ const workspaceConfig = JSON.parse(wsTmpl);
69
+ workspaceConfig.workspace.templateVersion = toVersion;
70
+ writeFileSync(workspaceJsonPath, JSON.stringify(workspaceConfig, null, 2) + '\n');
84
71
  console.log(' Created workspace.json');
85
72
  }
86
73
 
package/lib/scaffold.mjs CHANGED
@@ -20,10 +20,11 @@ export async function scaffold(answers) {
20
20
  },
21
21
  });
22
22
 
23
- // Ensure shared-context/locked/ exists so the CLAUDE.md import resolves.
23
+ // Ensure workspace-context/locked/ exists. canonical.md and index.md are
24
+ // generated later by /workspace-init via build-workspace-context.mjs;
24
25
  // repos/, work-sessions/, and workspace-scratchpad/ are lazy-created when
25
26
  // scripts and hooks first need them — we do NOT pre-create them here.
26
- mkdirSync(join(directory, 'shared-context', 'locked'), { recursive: true });
27
+ mkdirSync(join(directory, 'workspace-context', 'locked'), { recursive: true });
27
28
 
28
29
  // Rename _gitignore to .gitignore
29
30
  const gitignoreSrc = join(directory, '_gitignore');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulysses-ai/create-workspace",
3
- "version": "0.15.0-beta.0",
3
+ "version": "0.15.0-beta.1",
4
4
  "description": "A workspace convention for Claude Code: sessions, handoffs, and shared context as files in git",
5
5
  "keywords": [
6
6
  "claude",
@@ -34,6 +34,7 @@ Every workspace-context file should have YAML frontmatter. The fields below are
34
34
  - `state` — `locked` (team truth) or `ephemeral` (working context). Locked files live under `shared/locked/`; ephemeral files live elsewhere under `shared/` or `team-member/{user}/`.
35
35
  - `lifecycle` — for ephemeral files: `active` (still relevant) or `resolved` (handled, kept for record).
36
36
  - `type` — kind of content: `reference`, `braindump`, `handoff`, `research`, `design`, `index`, `canonical`, `promoted`.
37
+ - `priority` — for locked files only: `critical` (always loaded into canonical) or `reference` (eligible for trim/stub under canonical budget pressure). Default when absent is `critical`. See `build-workspace-context.mjs` for selection semantics.
37
38
  - `topic` — kebab-case slug matching the filename (after the type prefix, when one is present).
38
39
  - `author` — username scope owner. Required for `team-member/{user}/` files.
39
40
  - `updated` — ISO date of last meaningful edit. `/maintenance` flags stale `lifecycle: active` files based on this.
@@ -71,6 +72,8 @@ A single generator at `.claude/scripts/build-workspace-context.mjs` produces thr
71
72
 
72
73
  Gitignored files (e.g. anything matching `local-only-*`) are excluded automatically, and `workspace-context/.indexignore` adds path-prefix excludes for tracked files that shouldn't appear in the shared index (e.g. archived release notes).
73
74
 
75
+ When `workspace-context/canonical.md` exceeds `workspace.canonicalBudgetBytes` (default 40960), the builder honors per-file `priority` and section-level `<!-- canonical:trim --> ... <!-- canonical:end-trim -->` markers to fit: `priority: reference` files are trimmed first, then stubbed, while `priority: critical` files are always included in full. `/maintenance` audits the budget and offers a triage flow when over.
76
+
74
77
  ```bash
75
78
  node .claude/scripts/build-workspace-context.mjs --check --root . # exits 1 if any artifact is stale or missing
76
79
  node .claude/scripts/build-workspace-context.mjs --write --root . # regenerate all three
@@ -9,12 +9,23 @@
9
9
  // Source of truth: the filesystem. Hand edits are overwritten on regeneration.
10
10
  // Gitignored files are excluded automatically. .indexignore adds prefix excludes.
11
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
+ //
12
19
  // Usage:
13
20
  // node build-workspace-context.mjs --write [--root <workspace-root>]
14
21
  // node build-workspace-context.mjs --check [--root <workspace-root>]
15
22
  //
16
23
  // --write regenerates all three artifacts.
17
- // --check exits 0 if everything matches, 1 if any is stale or missing. Reports per-file status.
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.
18
29
 
19
30
  import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, realpathSync } from 'node:fs';
20
31
  import { join, relative, sep } from 'node:path';
@@ -29,6 +40,8 @@ function isMainModule(metaUrl) {
29
40
  } catch { return false; }
30
41
  }
31
42
 
43
+ export const DEFAULT_CANONICAL_BUDGET = 40960;
44
+
32
45
  const WC_DIR = 'workspace-context';
33
46
  const SHARED_DIR = 'shared';
34
47
  const LOCKED_DIR = 'locked';
@@ -190,37 +203,334 @@ function renderSharedIndex(entries, generatedAt) {
190
203
 
191
204
  // ---------- canonical concat ----------
192
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
+
193
470
  function buildCanonical(workspaceRoot) {
194
471
  const lockedDir = join(workspaceRoot, WC_DIR, SHARED_DIR, LOCKED_DIR);
195
472
  if (!existsSync(lockedDir)) return [];
196
- return walkMarkdown(lockedDir)
197
- .filter((f) => !f.endsWith('.keep'))
198
- .sort()
199
- .map((f) => ({
200
- name: f.split(sep).pop().replace(/\.md$/, ''),
201
- content: stripFrontmatter(readFileSync(f, 'utf-8')).trimEnd(),
202
- }));
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`;
203
502
  }
204
503
 
205
- function renderCanonical(items, generatedAt) {
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
+
206
513
  const lines = [
207
- '---',
208
- 'type: canonical',
209
- `generated: ${generatedAt}`,
210
- '---',
211
- '',
514
+ ...fmLines,
212
515
  '# workspace-context — canonical truths',
213
516
  '',
214
517
  '> Auto-generated concatenation of `shared/locked/*.md`. Hand edits will be overwritten — update source files instead.',
215
- '',
216
518
  ];
217
- for (const item of items) {
218
- lines.push(`## ${item.name}`, '', item.content, '');
519
+ if (showBudget) {
520
+ lines.push(
521
+ `> Budget: ${selection.budgetBytes} bytes (body); current: ${selection.currentBytes} bytes; status: ${selection.status} (${summarizeSelection(selection)}).`,
522
+ );
219
523
  }
220
- if (items.length === 0) {
524
+ lines.push('');
525
+
526
+ if (resolvedItems.length === 0) {
221
527
  lines.push('_(no canonical entries yet — promote one via `/release`)_', '');
528
+ return lines.join('\n');
222
529
  }
223
- return lines.join('\n');
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;
224
534
  }
225
535
 
226
536
  // ---------- team-member indexes ----------
@@ -300,10 +610,15 @@ function regenerateAll(workspaceRoot, generatedAt) {
300
610
  });
301
611
 
302
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
+ });
303
617
  out.push({
304
618
  path: join(wcRoot, CANONICAL_FILENAME),
305
619
  label: 'canonical.md',
306
- content: renderCanonical(canonicalItems, generatedAt) + '\n',
620
+ content: renderCanonical(resolvedItems, selection, generatedAt) + '\n',
621
+ selection,
307
622
  });
308
623
 
309
624
  for (const user of listTeamMembers(workspaceRoot)) {
@@ -318,6 +633,19 @@ function regenerateAll(workspaceRoot, generatedAt) {
318
633
  return out;
319
634
  }
320
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
+ */
321
649
  function main() {
322
650
  const args = parseArgs(process.argv);
323
651
  const generatedAt = new Date().toISOString();
@@ -331,11 +659,30 @@ function main() {
331
659
  const onDisk = readFileSync(a.path, 'utf-8');
332
660
  if (fingerprint(onDisk) !== fingerprint(a.content)) stale.push(a.label);
333
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
+
334
674
  if (missing.length === 0 && stale.length === 0) {
335
- process.stdout.write(JSON.stringify({ status: 'current', artifacts: artifacts.length }) + '\n');
336
- process.exit(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);
337
681
  }
338
- process.stdout.write(JSON.stringify({ status: 'stale', missing, stale }) + '\n');
682
+
683
+ const payload = { status: 'stale', missing, stale };
684
+ if (canonicalBlock) payload.canonical = canonicalBlock;
685
+ process.stdout.write(JSON.stringify(payload) + '\n');
339
686
  process.exit(1);
340
687
  }
341
688
 
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ // Idempotent migrator: back-fill `priority: critical` on locked workspace-context
3
+ // files that lack the field. Default-to-critical preserves existing behavior —
4
+ // no surprise drops on upgrade.
5
+ //
6
+ // Usage:
7
+ // node migrate-canonical-priority.mjs [--root <path>]
8
+ //
9
+ // Walks <root>/workspace-context/shared/locked/*.md. For each file:
10
+ // - Skip non-.md files and files without parseable frontmatter (warn to stderr).
11
+ // - If `priority` is already set (any value), leave the file untouched.
12
+ // - Otherwise add `priority: critical` losslessly via updateSessionContent.
13
+ //
14
+ // Returns { status, files: { applied, unchanged } } where status is 'applied'
15
+ // when at least one file was modified, else 'noop'. Always exits 0 — idempotent
16
+ // migrations don't fail.
17
+
18
+ import {
19
+ existsSync,
20
+ readFileSync,
21
+ writeFileSync,
22
+ readdirSync,
23
+ statSync,
24
+ realpathSync,
25
+ } from 'node:fs';
26
+ import { join, resolve } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { parseSessionContent, updateSessionContent } from '../lib/session-frontmatter.mjs';
29
+
30
+ function isMainModule(metaUrl) {
31
+ if (!process.argv[1]) return false;
32
+ try {
33
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
34
+ } catch { return false; }
35
+ }
36
+
37
+ function parseArgs(argv) {
38
+ const args = { root: process.cwd() };
39
+ for (let i = 2; i < argv.length; i++) {
40
+ const a = argv[i];
41
+ if (a === '--root') args.root = argv[++i];
42
+ else throw new Error(`Unknown arg: ${a}`);
43
+ }
44
+ return args;
45
+ }
46
+
47
+ export function migrateCanonicalPriority({ root }) {
48
+ const absRoot = resolve(root);
49
+ const lockedDir = join(absRoot, 'workspace-context', 'shared', 'locked');
50
+ const files = { applied: [], unchanged: [] };
51
+
52
+ if (!existsSync(lockedDir)) {
53
+ return { status: 'noop', files };
54
+ }
55
+
56
+ let entries;
57
+ try {
58
+ entries = readdirSync(lockedDir).sort();
59
+ } catch {
60
+ return { status: 'noop', files };
61
+ }
62
+
63
+ for (const name of entries) {
64
+ if (!name.endsWith('.md')) continue;
65
+ const full = join(lockedDir, name);
66
+ let st;
67
+ try { st = statSync(full); } catch { continue; }
68
+ if (!st.isFile()) continue;
69
+
70
+ const raw = readFileSync(full, 'utf-8');
71
+ let parsed;
72
+ try {
73
+ parsed = parseSessionContent(raw);
74
+ } catch {
75
+ console.error(`warning: skipping ${full}: no parseable frontmatter`);
76
+ continue;
77
+ }
78
+
79
+ if (parsed?.fields?.priority !== undefined) {
80
+ files.unchanged.push(name);
81
+ continue;
82
+ }
83
+
84
+ const updated = updateSessionContent(raw, { priority: 'critical' });
85
+ writeFileSync(full, updated);
86
+ files.applied.push(name);
87
+ }
88
+
89
+ return {
90
+ status: files.applied.length > 0 ? 'applied' : 'noop',
91
+ files,
92
+ };
93
+ }
94
+
95
+ function main() {
96
+ const args = parseArgs(process.argv);
97
+ const result = migrateCanonicalPriority({ root: args.root });
98
+ process.stdout.write(JSON.stringify(result) + '\n');
99
+ }
100
+
101
+ if (isMainModule(import.meta.url)) {
102
+ try {
103
+ main();
104
+ } catch (err) {
105
+ process.stderr.write(`migrate-canonical-priority: ${err.message}\n`);
106
+ process.exit(1);
107
+ }
108
+ }