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