@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.
- package/lib/init.mjs +12 -25
- package/lib/scaffold.mjs +3 -2
- package/package.json +1 -1
- package/template/.claude/rules/memory-guidance.md +3 -0
- package/template/.claude/scripts/build-workspace-context.mjs +370 -23
- package/template/.claude/scripts/migrate-canonical-priority.mjs +108 -0
- package/template/.claude/skills/complete-work/SKILL.md +88 -0
- package/template/.claude/skills/maintenance/SKILL.md +79 -11
- package/template/.claude/skills/release/SKILL.md +3 -0
- package/template/.claude/skills/workspace-update/SKILL.md +7 -1
- package/template/workspace.json.tmpl +1 -0
- package/template/.claude/hooks/_bash-output-advisory.test.mjs +0 -88
- package/template/.claude/hooks/_utils.test.mjs +0 -99
- package/template/.claude/lib/freshness.test.mjs +0 -175
- package/template/.claude/lib/registry-check.test.mjs +0 -130
- package/template/.claude/lib/session-frontmatter.test.mjs +0 -242
- package/template/.claude/scripts/build-workspace-context.test.mjs +0 -633
- package/template/.claude/scripts/capture-context.test.mjs +0 -383
- package/template/.claude/scripts/generate-claude-local.test.mjs +0 -184
- package/template/.claude/scripts/migrate-claude-md-freshness-include.test.mjs +0 -54
- package/template/.claude/scripts/migrate-session-layout.test.mjs +0 -144
- package/template/.claude/scripts/migrate-to-workspace-context.test.mjs +0 -325
- package/template/.claude/scripts/sweep-references.test.mjs +0 -184
- package/template/.claude/scripts/sync-tasks.test.mjs +0 -350
- package/template/.claude/scripts/trackers/github-issues.test.mjs +0 -190
- 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
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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, '
|
|
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
|
@@ -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
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
.
|
|
200
|
-
|
|
201
|
-
|
|
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(
|
|
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
|
-
|
|
218
|
-
lines.push(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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
|
+
}
|