@ulysses-ai/create-workspace 0.14.0-beta.3 → 0.15.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/.claude/agents/reviewer.md +1 -1
- package/template/.claude/hooks/pre-compact.mjs +1 -1
- package/template/.claude/hooks/repo-write-detection.mjs +2 -2
- package/template/.claude/hooks/session-start.mjs +10 -7
- package/template/.claude/hooks/subagent-start.mjs +3 -3
- package/template/.claude/recipes/migrate-from-notion.md +6 -6
- package/template/.claude/rules/coherent-revisions.md +2 -2
- package/template/.claude/rules/local-dev-environment.md.skip +2 -2
- package/template/.claude/rules/memory-guidance.md +20 -14
- package/template/.claude/rules/token-economics.md.skip +2 -2
- package/template/.claude/rules/work-item-tracking.md +1 -1
- package/template/.claude/rules/workspace-structure.md +36 -15
- package/template/.claude/scripts/build-workspace-context.mjs +365 -0
- package/template/.claude/scripts/build-workspace-context.test.mjs +633 -0
- package/template/.claude/scripts/capture-context.mjs +217 -0
- package/template/.claude/scripts/capture-context.test.mjs +383 -0
- package/template/.claude/scripts/generate-claude-local.mjs +104 -0
- package/template/.claude/scripts/generate-claude-local.test.mjs +184 -0
- package/template/.claude/scripts/migrate-open-work.mjs +1 -1
- package/template/.claude/scripts/migrate-to-workspace-context.mjs +520 -0
- package/template/.claude/scripts/migrate-to-workspace-context.test.mjs +325 -0
- package/template/.claude/scripts/sweep-references.mjs +177 -0
- package/template/.claude/scripts/sweep-references.test.mjs +184 -0
- package/template/.claude/skills/aside/SKILL.md +49 -44
- package/template/.claude/skills/braindump/SKILL.md +25 -19
- package/template/.claude/skills/build-docs-site/SKILL.md +1 -1
- package/template/.claude/skills/build-docs-site/checklists/framing.md +1 -1
- package/template/.claude/skills/complete-work/SKILL.md +3 -3
- package/template/.claude/skills/handoff/SKILL.md +31 -30
- package/template/.claude/skills/maintenance/SKILL.md +18 -18
- package/template/.claude/skills/pause-work/SKILL.md +1 -1
- package/template/.claude/skills/promote/SKILL.md +18 -8
- package/template/.claude/skills/release/SKILL.md +17 -13
- package/template/.claude/skills/start-work/SKILL.md +1 -1
- package/template/.claude/skills/workspace-init/SKILL.md +12 -12
- package/template/CLAUDE.md.tmpl +4 -3
- package/template/_gitignore +1 -0
- package/template/workspace.json.tmpl +2 -2
- package/template/.claude/scripts/build-shared-context-index.mjs +0 -212
- package/template/.claude/scripts/build-shared-context-index.test.mjs +0 -318
|
@@ -249,7 +249,7 @@ If not: ask "Ready to start implementing, or want to brainstorm first?"
|
|
|
249
249
|
|
|
250
250
|
When /start-work is called after work has already begun:
|
|
251
251
|
|
|
252
|
-
1. Detect uncommitted changes in `repos/` or `
|
|
252
|
+
1. Detect uncommitted changes in `repos/` or `workspace-context/`
|
|
253
253
|
2. "It looks like you've already been working. Let me formalize this."
|
|
254
254
|
3. If changes are on a default branch: stash → create session → pop stash
|
|
255
255
|
4. If changes are already on a feature branch: create workspace worktree and nest the existing project worktree(s) under it, or create a fresh session if the work is small enough to re-apply
|
|
@@ -68,7 +68,7 @@ Ask the user:
|
|
|
68
68
|
|
|
69
69
|
This determines whether documentation extraction is part of the plan.
|
|
70
70
|
|
|
71
|
-
**Important:** If the user indicates a source, ask: "Has any content already been extracted from this source? (e.g., rules or context files already pulled down from Notion)" Check `
|
|
71
|
+
**Important:** If the user indicates a source, ask: "Has any content already been extracted from this source? (e.g., rules or context files already pulled down from Notion)" Check `workspace-context/` and `.claude/rules/` for files that appear to contain extracted content. If content already exists from a source, mark it as "already extracted" and skip re-fetching unless the user explicitly wants a refresh.
|
|
72
72
|
|
|
73
73
|
### Step 4: Present the plan
|
|
74
74
|
|
|
@@ -134,7 +134,7 @@ List all `.md.skip` files in `.claude/rules/`:
|
|
|
134
134
|
For each documentation source identified in Step 3:
|
|
135
135
|
|
|
136
136
|
**Before fetching from any source, check if content was already extracted:**
|
|
137
|
-
- Scan `
|
|
137
|
+
- Scan `workspace-context/` and `.claude/rules/` for files that reference the source (e.g., files mentioning Notion page IDs, Confluence URLs, etc.)
|
|
138
138
|
- If found: "Content from {source} appears to already be in {files}. Skip re-fetching? [Y/n]"
|
|
139
139
|
- If the user confirms skip: move on to the next source
|
|
140
140
|
- If the user wants a refresh: proceed with extraction but note which files will be updated
|
|
@@ -145,7 +145,7 @@ For sources that need extraction:
|
|
|
145
145
|
- **Track access failures** — if a source is unreachable, note it but don't stop
|
|
146
146
|
- For rules/conventions found: write to `.claude/rules/{rule-name}.md`
|
|
147
147
|
- For project context/decisions: stage for Step 10 (locked knowledge)
|
|
148
|
-
- For handoffs/active work: write to `
|
|
148
|
+
- For handoffs/active work: write to `workspace-context/team-member/{user}/` as ephemeral
|
|
149
149
|
|
|
150
150
|
**Commit:** `git commit -m "feat: extract rules and context from documentation sources"`
|
|
151
151
|
|
|
@@ -181,7 +181,7 @@ Write `workspace-scratchpad/chat-history-manifest.json`:
|
|
|
181
181
|
3. Update the manifest entry status to "processed"
|
|
182
182
|
4. Synthesize findings into shared context:
|
|
183
183
|
- Decisions and architecture → stage for locked context (Step 10)
|
|
184
|
-
- Work-in-progress context → `
|
|
184
|
+
- Work-in-progress context → `workspace-context/team-member/{user}/` as ephemeral handoffs
|
|
185
185
|
- Patterns and conventions → candidate rules for `.claude/rules/`
|
|
186
186
|
|
|
187
187
|
Present a summary: "Found {N} prior sessions. Extracted {M} decisions, {K} handoffs, {P} convention candidates."
|
|
@@ -199,7 +199,7 @@ rm -f workspace-scratchpad/chat-history-manifest.json
|
|
|
199
199
|
|
|
200
200
|
Read CLAUDE.md.bak for non-documentation content worth keeping:
|
|
201
201
|
- Local coding conventions → `.claude/rules/` (new rule files)
|
|
202
|
-
- Project-specific notes → `
|
|
202
|
+
- Project-specific notes → `workspace-context/shared/locked/` or `workspace-context/team-member/{user}/`
|
|
203
203
|
- Repo paths → verify they match workspace.json
|
|
204
204
|
|
|
205
205
|
**Commit:** `git commit -m "feat: preserve local preferences as rules and context"`
|
|
@@ -207,7 +207,7 @@ Read CLAUDE.md.bak for non-documentation content worth keeping:
|
|
|
207
207
|
### Step 10: Create locked team knowledge
|
|
208
208
|
|
|
209
209
|
Combine content from Steps 7, 8, 9, and existing auto-memory into locked context:
|
|
210
|
-
- For each piece of stable knowledge: write to `
|
|
210
|
+
- For each piece of stable knowledge: write to `workspace-context/shared/locked/{topic}.md`
|
|
211
211
|
- Keep locked context lean — target <10KB total
|
|
212
212
|
- One topic per file, proper frontmatter
|
|
213
213
|
- Only lock what the team needs every session. Everything else is ephemeral.
|
|
@@ -269,7 +269,7 @@ Save to `.claude/settings.local.json`:
|
|
|
269
269
|
|
|
270
270
|
### Step 14: Clean root directory
|
|
271
271
|
|
|
272
|
-
The workspace root should contain ONLY template structure: CLAUDE.md, workspace.json, .gitignore, and the standard directories (`.claude/`, `
|
|
272
|
+
The workspace root should contain ONLY template structure: CLAUDE.md, workspace.json, .gitignore, and the standard directories (`.claude/`, `workspace-context/`). The `repos/`, `work-sessions/`, and `workspace-scratchpad/` directories are lazy-created the first time something writes to them — they won't exist yet unless a repo has already been cloned.
|
|
273
273
|
|
|
274
274
|
Move everything else to `workspace-scratchpad/unmigrated/`:
|
|
275
275
|
|
|
@@ -299,8 +299,8 @@ Report: "Moved {N} items to workspace-scratchpad/unmigrated/: {list}."
|
|
|
299
299
|
|
|
300
300
|
Read EVERY created and activated file:
|
|
301
301
|
- Every `.claude/rules/*.md` (not .skip)
|
|
302
|
-
- Every `
|
|
303
|
-
- Every `
|
|
302
|
+
- Every `workspace-context/shared/locked/*.md`
|
|
303
|
+
- Every `workspace-context/team-member/{user}/*.md`
|
|
304
304
|
|
|
305
305
|
Check for:
|
|
306
306
|
- References to removed services or files
|
|
@@ -333,8 +333,8 @@ git remote -v
|
|
|
333
333
|
```bash
|
|
334
334
|
git rebase origin/main
|
|
335
335
|
```
|
|
336
|
-
If conflicts arise (e.g.,
|
|
337
|
-
- For
|
|
336
|
+
If conflicts arise (e.g., workspace-context differs between your init and what's already committed), STOP and present them. These are legitimate merge decisions — your extracted content vs what your teammate committed. Help the user resolve each conflict:
|
|
337
|
+
- For workspace-context files: the remote version likely has the teammate's extractions. Merge both perspectives or keep the more complete version.
|
|
338
338
|
- For rules: if both sides activated different optional rules, keep both.
|
|
339
339
|
- For CLAUDE.md: use the remote version (it's the established one), add any local customizations the user wants.
|
|
340
340
|
|
|
@@ -408,7 +408,7 @@ This session is done. Start a fresh Claude Code session and run /start-work to b
|
|
|
408
408
|
- **Don't suggest starting work at the end.** Tell the user to restart Claude Code and run /start-work in a fresh session.
|
|
409
409
|
- The verification step (Step 16) is mandatory — read every file, check thoroughly.
|
|
410
410
|
- **Build manifests before long operations.** Chat history scanning (Step 8) and worktree formalization (Step 11) can be interrupted by auto-compaction. Write a manifest to `workspace-scratchpad/` before starting so progress survives.
|
|
411
|
-
- **Never re-fetch content that already exists.** Always check
|
|
411
|
+
- **Never re-fetch content that already exists.** Always check workspace-context and rules for existing extractions before accessing external sources.
|
|
412
412
|
- This skill is idempotent — safe to run if interrupted and restarted.
|
|
413
413
|
|
|
414
414
|
## Notes
|
package/template/CLAUDE.md.tmpl
CHANGED
|
@@ -7,14 +7,15 @@ This is a claude-workspace. All conventions are defined in .claude/rules/.
|
|
|
7
7
|
- `/start-work` to create or resume a work session
|
|
8
8
|
- Each session is a self-contained folder at `work-sessions/{name}/` containing the worktree, nested project worktrees, and the session tracker
|
|
9
9
|
- From root: only `local-only-*` and `workspace-scratchpad/` are writable
|
|
10
|
-
-
|
|
10
|
+
- Team knowledge lives in `workspace-context/`
|
|
11
11
|
|
|
12
12
|
## Workspace Config
|
|
13
13
|
@workspace.json
|
|
14
14
|
@local-only-template-freshness.md
|
|
15
15
|
|
|
16
|
-
## Team Knowledge
|
|
17
|
-
@
|
|
16
|
+
## Team Knowledge
|
|
17
|
+
@workspace-context/canonical.md
|
|
18
|
+
@workspace-context/index.md
|
|
18
19
|
|
|
19
20
|
## Skills
|
|
20
21
|
- `/workspace-init` — first-time workspace setup (clone repos, install template, activate rules)
|
package/template/_gitignore
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
"versionCheck": { "ambient": true },
|
|
6
6
|
"scratchpadDir": "workspace-scratchpad",
|
|
7
7
|
"workSessionsDir": "work-sessions",
|
|
8
|
-
"
|
|
9
|
-
"releaseNotesDir": "release-notes",
|
|
8
|
+
"workspaceContextDir": "workspace-context",
|
|
9
|
+
"releaseNotesDir": "workspace-context/release-notes",
|
|
10
10
|
"subagentContextMaxBytes": 10240,
|
|
11
11
|
"greeting": "Welcome back to {{project-name}}.",
|
|
12
12
|
"releaseMode": "per-repo",
|
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Generate shared-context/index.md by walking shared-context/ and reading frontmatter.
|
|
3
|
-
//
|
|
4
|
-
// Source of truth is the filesystem. Hand edits to index.md are overwritten.
|
|
5
|
-
//
|
|
6
|
-
// Usage:
|
|
7
|
-
// node build-shared-context-index.mjs --write [--root <workspace-root>]
|
|
8
|
-
// node build-shared-context-index.mjs --check [--root <workspace-root>]
|
|
9
|
-
//
|
|
10
|
-
// --write regenerates shared-context/index.md.
|
|
11
|
-
// --check exits 0 if the on-disk index matches what would be generated, 1 otherwise.
|
|
12
|
-
|
|
13
|
-
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
14
|
-
import { join, relative, sep } from 'node:path';
|
|
15
|
-
import { parseSessionContent } from '../lib/session-frontmatter.mjs';
|
|
16
|
-
|
|
17
|
-
const INDEX_FILENAME = 'index.md';
|
|
18
|
-
const LOCKED_DIR = 'locked';
|
|
19
|
-
const IGNORE_FILENAME = '.indexignore';
|
|
20
|
-
|
|
21
|
-
function parseArgs(argv) {
|
|
22
|
-
const args = { mode: null, root: process.cwd() };
|
|
23
|
-
for (let i = 2; i < argv.length; i++) {
|
|
24
|
-
const a = argv[i];
|
|
25
|
-
if (a === '--write') args.mode = 'write';
|
|
26
|
-
else if (a === '--check') args.mode = 'check';
|
|
27
|
-
else if (a === '--root') args.root = argv[++i];
|
|
28
|
-
}
|
|
29
|
-
if (!args.mode) {
|
|
30
|
-
throw new Error('Specify --write or --check');
|
|
31
|
-
}
|
|
32
|
-
return args;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function walkMarkdown(dir) {
|
|
36
|
-
const out = [];
|
|
37
|
-
for (const name of readdirSync(dir)) {
|
|
38
|
-
const full = join(dir, name);
|
|
39
|
-
const st = statSync(full);
|
|
40
|
-
if (st.isDirectory()) {
|
|
41
|
-
out.push(...walkMarkdown(full));
|
|
42
|
-
} else if (st.isFile() && name.endsWith('.md')) {
|
|
43
|
-
out.push(full);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return out;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function readDescription(filePath) {
|
|
50
|
-
let frontmatter = {};
|
|
51
|
-
let body = '';
|
|
52
|
-
try {
|
|
53
|
-
const parsed = parseSessionContent(readFileSync(filePath, 'utf-8'));
|
|
54
|
-
frontmatter = parsed.fields || {};
|
|
55
|
-
body = parsed.body || '';
|
|
56
|
-
} catch {
|
|
57
|
-
body = readFileSync(filePath, 'utf-8');
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
if (typeof frontmatter.description === 'string' && frontmatter.description.trim()) {
|
|
61
|
-
return frontmatter.description.trim();
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const stripped = body.replace(/^#.*$/m, '').trim();
|
|
65
|
-
const firstParagraph = stripped.split(/\n\s*\n/, 1)[0] || '';
|
|
66
|
-
const firstSentence = firstParagraph.replace(/\n/g, ' ').match(/[^.!?]+[.!?]/);
|
|
67
|
-
if (firstSentence) {
|
|
68
|
-
const candidate = firstSentence[0].trim();
|
|
69
|
-
if (candidate.length > 0 && candidate.length <= 200) return candidate;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const filename = filePath.split(sep).pop() || '';
|
|
73
|
-
return filename.replace(/\.md$/, '').replace(/-/g, ' ');
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function classify(relativePath) {
|
|
77
|
-
const parts = relativePath.split(sep);
|
|
78
|
-
if (parts.length === 1) return { group: '__root__', sortKey: parts[0] };
|
|
79
|
-
if (parts[0] === LOCKED_DIR) return { group: LOCKED_DIR, sortKey: parts.slice(1).join('/') };
|
|
80
|
-
return { group: parts[0], sortKey: parts.slice(1).join('/') };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function readIgnorePrefixes(sharedContextDir) {
|
|
84
|
-
const ignorePath = join(sharedContextDir, IGNORE_FILENAME);
|
|
85
|
-
if (!existsSync(ignorePath)) return [];
|
|
86
|
-
return readFileSync(ignorePath, 'utf-8')
|
|
87
|
-
.split('\n')
|
|
88
|
-
.map((line) => line.replace(/#.*/, '').trim())
|
|
89
|
-
.filter((line) => line.length > 0);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function isIgnored(relativePath, prefixes) {
|
|
93
|
-
for (const prefix of prefixes) {
|
|
94
|
-
if (relativePath === prefix) return true;
|
|
95
|
-
if (relativePath.startsWith(prefix.endsWith('/') ? prefix : prefix + '/')) return true;
|
|
96
|
-
}
|
|
97
|
-
return false;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function buildEntries(workspaceRoot) {
|
|
101
|
-
const sharedContextDir = join(workspaceRoot, 'shared-context');
|
|
102
|
-
if (!existsSync(sharedContextDir)) return [];
|
|
103
|
-
|
|
104
|
-
const ignorePrefixes = readIgnorePrefixes(sharedContextDir);
|
|
105
|
-
|
|
106
|
-
const files = walkMarkdown(sharedContextDir).filter((f) => {
|
|
107
|
-
const rel = relative(sharedContextDir, f).split(sep).join('/');
|
|
108
|
-
if (rel === INDEX_FILENAME) return false;
|
|
109
|
-
if (isIgnored(rel, ignorePrefixes)) return false;
|
|
110
|
-
return true;
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
return files
|
|
114
|
-
.map((file) => {
|
|
115
|
-
const rel = relative(sharedContextDir, file);
|
|
116
|
-
const { group, sortKey } = classify(rel);
|
|
117
|
-
return {
|
|
118
|
-
group,
|
|
119
|
-
sortKey,
|
|
120
|
-
relativePath: rel.split(sep).join('/'),
|
|
121
|
-
description: readDescription(file),
|
|
122
|
-
};
|
|
123
|
-
})
|
|
124
|
-
.sort((a, b) => {
|
|
125
|
-
const groupOrder = (g) =>
|
|
126
|
-
g === LOCKED_DIR ? 0 : g === '__root__' ? 1 : 2;
|
|
127
|
-
const ga = groupOrder(a.group);
|
|
128
|
-
const gb = groupOrder(b.group);
|
|
129
|
-
if (ga !== gb) return ga - gb;
|
|
130
|
-
if (a.group !== b.group) return a.group.localeCompare(b.group);
|
|
131
|
-
return a.sortKey.localeCompare(b.sortKey);
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function groupHeading(group) {
|
|
136
|
-
if (group === LOCKED_DIR) return 'Locked (team truths, always loaded)';
|
|
137
|
-
if (group === '__root__') return 'Team-shared (root)';
|
|
138
|
-
return group;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function renderIndex(entries, generatedAt) {
|
|
142
|
-
const lines = [
|
|
143
|
-
'---',
|
|
144
|
-
'type: index',
|
|
145
|
-
`generated: ${generatedAt}`,
|
|
146
|
-
'---',
|
|
147
|
-
'',
|
|
148
|
-
'# shared-context — index',
|
|
149
|
-
'',
|
|
150
|
-
'> Auto-generated by `.claude/scripts/build-shared-context-index.mjs`. Hand edits will be overwritten — update the source files instead.',
|
|
151
|
-
'',
|
|
152
|
-
];
|
|
153
|
-
|
|
154
|
-
let currentGroup = null;
|
|
155
|
-
for (const entry of entries) {
|
|
156
|
-
if (entry.group !== currentGroup) {
|
|
157
|
-
if (currentGroup !== null) lines.push('');
|
|
158
|
-
lines.push(`## ${groupHeading(entry.group)}`, '');
|
|
159
|
-
currentGroup = entry.group;
|
|
160
|
-
}
|
|
161
|
-
lines.push(`- [${entry.relativePath}](${entry.relativePath}) — ${entry.description}`);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (entries.length === 0) {
|
|
165
|
-
lines.push('_(no shared-context files yet)_');
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return lines.join('\n') + '\n';
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function fingerprint(content) {
|
|
172
|
-
return content
|
|
173
|
-
.split('\n')
|
|
174
|
-
.filter((line) => !line.startsWith('generated:'))
|
|
175
|
-
.join('\n');
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function main() {
|
|
179
|
-
const args = parseArgs(process.argv);
|
|
180
|
-
const indexPath = join(args.root, 'shared-context', INDEX_FILENAME);
|
|
181
|
-
const entries = buildEntries(args.root);
|
|
182
|
-
const generatedAt = new Date().toISOString();
|
|
183
|
-
const fresh = renderIndex(entries, generatedAt);
|
|
184
|
-
|
|
185
|
-
if (args.mode === 'check') {
|
|
186
|
-
if (!existsSync(indexPath)) {
|
|
187
|
-
process.stdout.write(JSON.stringify({ status: 'missing' }) + '\n');
|
|
188
|
-
process.exit(1);
|
|
189
|
-
}
|
|
190
|
-
const onDisk = readFileSync(indexPath, 'utf-8');
|
|
191
|
-
if (fingerprint(onDisk) === fingerprint(fresh)) {
|
|
192
|
-
process.stdout.write(JSON.stringify({ status: 'current', entries: entries.length }) + '\n');
|
|
193
|
-
process.exit(0);
|
|
194
|
-
}
|
|
195
|
-
process.stdout.write(JSON.stringify({ status: 'stale', entries: entries.length }) + '\n');
|
|
196
|
-
process.exit(1);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
if (args.mode === 'write') {
|
|
200
|
-
writeFileSync(indexPath, fresh);
|
|
201
|
-
process.stdout.write(
|
|
202
|
-
JSON.stringify({ status: 'written', entries: entries.length, path: indexPath }) + '\n',
|
|
203
|
-
);
|
|
204
|
-
process.exit(0);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
209
|
-
main();
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
export { buildEntries, renderIndex, fingerprint, readDescription };
|
|
@@ -1,318 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Unit tests for build-shared-context-index.mjs
|
|
3
|
-
// Run: node template/.claude/scripts/build-shared-context-index.test.mjs
|
|
4
|
-
|
|
5
|
-
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
6
|
-
import { tmpdir } from 'node:os';
|
|
7
|
-
import { join } from 'node:path';
|
|
8
|
-
import { buildEntries, renderIndex, fingerprint, readDescription } from './build-shared-context-index.mjs';
|
|
9
|
-
|
|
10
|
-
let failed = 0;
|
|
11
|
-
let passed = 0;
|
|
12
|
-
|
|
13
|
-
function assert(cond, msg) {
|
|
14
|
-
if (cond) { passed++; } else { failed++; console.error(` FAIL: ${msg}`); }
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function assertEq(actual, expected, msg) {
|
|
18
|
-
const a = JSON.stringify(actual);
|
|
19
|
-
const e = JSON.stringify(expected);
|
|
20
|
-
if (a === e) { passed++; } else {
|
|
21
|
-
failed++;
|
|
22
|
-
console.error(` FAIL: ${msg}\n expected: ${e}\n actual: ${a}`);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function setupFixture() {
|
|
27
|
-
const root = mkdtempSync(join(tmpdir(), 'sc-index-test-'));
|
|
28
|
-
mkdirSync(join(root, 'shared-context'), { recursive: true });
|
|
29
|
-
mkdirSync(join(root, 'shared-context', 'locked'), { recursive: true });
|
|
30
|
-
mkdirSync(join(root, 'shared-context', 'alice'), { recursive: true });
|
|
31
|
-
return root;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function cleanup(root) {
|
|
35
|
-
rmSync(root, { recursive: true, force: true });
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
console.log('# readDescription priority');
|
|
39
|
-
|
|
40
|
-
{
|
|
41
|
-
const root = setupFixture();
|
|
42
|
-
writeFileSync(
|
|
43
|
-
join(root, 'shared-context', 'with-fm.md'),
|
|
44
|
-
`---
|
|
45
|
-
state: locked
|
|
46
|
-
type: reference
|
|
47
|
-
description: Frontmatter description wins.
|
|
48
|
-
updated: 2026-04-25
|
|
49
|
-
---
|
|
50
|
-
|
|
51
|
-
# Title
|
|
52
|
-
|
|
53
|
-
This is the body.
|
|
54
|
-
`,
|
|
55
|
-
);
|
|
56
|
-
const desc = readDescription(join(root, 'shared-context', 'with-fm.md'));
|
|
57
|
-
assertEq(desc, 'Frontmatter description wins.', 'frontmatter description preferred');
|
|
58
|
-
cleanup(root);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
{
|
|
62
|
-
const root = setupFixture();
|
|
63
|
-
writeFileSync(
|
|
64
|
-
join(root, 'shared-context', 'no-fm-desc.md'),
|
|
65
|
-
`---
|
|
66
|
-
state: ephemeral
|
|
67
|
-
type: braindump
|
|
68
|
-
updated: 2026-04-25
|
|
69
|
-
---
|
|
70
|
-
|
|
71
|
-
# Some Title
|
|
72
|
-
|
|
73
|
-
This is the first paragraph that should be used. It has multiple sentences.
|
|
74
|
-
|
|
75
|
-
This is a second paragraph.
|
|
76
|
-
`,
|
|
77
|
-
);
|
|
78
|
-
const desc = readDescription(join(root, 'shared-context', 'no-fm-desc.md'));
|
|
79
|
-
assertEq(desc, 'This is the first paragraph that should be used.', 'first sentence fallback');
|
|
80
|
-
cleanup(root);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
{
|
|
84
|
-
const root = setupFixture();
|
|
85
|
-
writeFileSync(
|
|
86
|
-
join(root, 'shared-context', 'no-frontmatter.md'),
|
|
87
|
-
`# Bare File
|
|
88
|
-
|
|
89
|
-
Content without frontmatter at all.
|
|
90
|
-
`,
|
|
91
|
-
);
|
|
92
|
-
const desc = readDescription(join(root, 'shared-context', 'no-frontmatter.md'));
|
|
93
|
-
assertEq(desc, 'Content without frontmatter at all.', 'works without frontmatter');
|
|
94
|
-
cleanup(root);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
{
|
|
98
|
-
const root = setupFixture();
|
|
99
|
-
writeFileSync(join(root, 'shared-context', 'empty-body.md'), `---
|
|
100
|
-
type: index
|
|
101
|
-
---
|
|
102
|
-
`);
|
|
103
|
-
const desc = readDescription(join(root, 'shared-context', 'empty-body.md'));
|
|
104
|
-
assertEq(desc, 'empty body', 'filename slug fallback');
|
|
105
|
-
cleanup(root);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
console.log('# buildEntries grouping & sort');
|
|
109
|
-
|
|
110
|
-
{
|
|
111
|
-
const root = setupFixture();
|
|
112
|
-
writeFileSync(
|
|
113
|
-
join(root, 'shared-context', 'locked', 'project-status.md'),
|
|
114
|
-
`---
|
|
115
|
-
description: Project status here.
|
|
116
|
-
---
|
|
117
|
-
|
|
118
|
-
body
|
|
119
|
-
`,
|
|
120
|
-
);
|
|
121
|
-
writeFileSync(
|
|
122
|
-
join(root, 'shared-context', 'locked', 'naming.md'),
|
|
123
|
-
`---
|
|
124
|
-
description: Naming convention.
|
|
125
|
-
---
|
|
126
|
-
|
|
127
|
-
body
|
|
128
|
-
`,
|
|
129
|
-
);
|
|
130
|
-
writeFileSync(
|
|
131
|
-
join(root, 'shared-context', 'inventory.md'),
|
|
132
|
-
`---
|
|
133
|
-
description: Top-level inventory.
|
|
134
|
-
---
|
|
135
|
-
|
|
136
|
-
body
|
|
137
|
-
`,
|
|
138
|
-
);
|
|
139
|
-
writeFileSync(
|
|
140
|
-
join(root, 'shared-context', 'alice', 'notes.md'),
|
|
141
|
-
`---
|
|
142
|
-
description: Alice notes.
|
|
143
|
-
---
|
|
144
|
-
|
|
145
|
-
body
|
|
146
|
-
`,
|
|
147
|
-
);
|
|
148
|
-
|
|
149
|
-
const entries = buildEntries(root);
|
|
150
|
-
assertEq(entries.length, 4, '4 entries');
|
|
151
|
-
assertEq(entries[0].group, 'locked', 'locked first');
|
|
152
|
-
assertEq(entries[0].sortKey, 'naming.md', 'alphabetical within locked (naming before project)');
|
|
153
|
-
assertEq(entries[1].sortKey, 'project-status.md', 'second locked entry');
|
|
154
|
-
assertEq(entries[2].group, '__root__', 'root group second');
|
|
155
|
-
assertEq(entries[3].group, 'alice', 'user dir last');
|
|
156
|
-
|
|
157
|
-
cleanup(root);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
console.log('# renderIndex output');
|
|
161
|
-
|
|
162
|
-
{
|
|
163
|
-
const entries = [
|
|
164
|
-
{ group: 'locked', sortKey: 'a.md', relativePath: 'locked/a.md', description: 'A.' },
|
|
165
|
-
{ group: '__root__', sortKey: 'b.md', relativePath: 'b.md', description: 'B.' },
|
|
166
|
-
{ group: 'alice', sortKey: 'c.md', relativePath: 'alice/c.md', description: 'C.' },
|
|
167
|
-
];
|
|
168
|
-
const output = renderIndex(entries, '2026-04-25T00:00:00Z');
|
|
169
|
-
assert(output.includes('## Locked (team truths, always loaded)'), 'has locked heading');
|
|
170
|
-
assert(output.includes('## Team-shared (root)'), 'has team-shared heading');
|
|
171
|
-
assert(output.includes('## alice'), 'has user dir heading');
|
|
172
|
-
assert(output.includes('- [locked/a.md](locked/a.md) — A.'), 'links use relative path');
|
|
173
|
-
assert(output.includes('generated: 2026-04-25T00:00:00Z'), 'frontmatter has generated timestamp');
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
{
|
|
177
|
-
const output = renderIndex([], '2026-04-25T00:00:00Z');
|
|
178
|
-
assert(output.includes('_(no shared-context files yet)_'), 'empty state');
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
console.log('# fingerprint ignores generated line');
|
|
182
|
-
|
|
183
|
-
{
|
|
184
|
-
const a = `---
|
|
185
|
-
type: index
|
|
186
|
-
generated: 2026-04-25T00:00:00Z
|
|
187
|
-
---
|
|
188
|
-
|
|
189
|
-
body
|
|
190
|
-
`;
|
|
191
|
-
const b = `---
|
|
192
|
-
type: index
|
|
193
|
-
generated: 2026-04-26T00:00:00Z
|
|
194
|
-
---
|
|
195
|
-
|
|
196
|
-
body
|
|
197
|
-
`;
|
|
198
|
-
assertEq(fingerprint(a), fingerprint(b), 'different generated, same body → same fingerprint');
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
{
|
|
202
|
-
const a = `---
|
|
203
|
-
generated: 2026-04-25T00:00:00Z
|
|
204
|
-
---
|
|
205
|
-
|
|
206
|
-
body one
|
|
207
|
-
`;
|
|
208
|
-
const b = `---
|
|
209
|
-
generated: 2026-04-25T00:00:00Z
|
|
210
|
-
---
|
|
211
|
-
|
|
212
|
-
body two
|
|
213
|
-
`;
|
|
214
|
-
assert(fingerprint(a) !== fingerprint(b), 'different body → different fingerprint');
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
console.log('# missing shared-context dir');
|
|
218
|
-
|
|
219
|
-
{
|
|
220
|
-
const root = mkdtempSync(join(tmpdir(), 'sc-index-empty-'));
|
|
221
|
-
const entries = buildEntries(root);
|
|
222
|
-
assertEq(entries, [], 'no shared-context dir yields empty entries');
|
|
223
|
-
cleanup(root);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
console.log('# .indexignore prefix excludes');
|
|
227
|
-
|
|
228
|
-
{
|
|
229
|
-
const root = setupFixture();
|
|
230
|
-
mkdirSync(join(root, 'shared-context', 'archive'), { recursive: true });
|
|
231
|
-
writeFileSync(
|
|
232
|
-
join(root, 'shared-context', 'archive', 'old1.md'),
|
|
233
|
-
`---
|
|
234
|
-
description: Archived 1.
|
|
235
|
-
---
|
|
236
|
-
body
|
|
237
|
-
`,
|
|
238
|
-
);
|
|
239
|
-
writeFileSync(
|
|
240
|
-
join(root, 'shared-context', 'archive', 'old2.md'),
|
|
241
|
-
`---
|
|
242
|
-
description: Archived 2.
|
|
243
|
-
---
|
|
244
|
-
body
|
|
245
|
-
`,
|
|
246
|
-
);
|
|
247
|
-
writeFileSync(
|
|
248
|
-
join(root, 'shared-context', 'live.md'),
|
|
249
|
-
`---
|
|
250
|
-
description: Live file.
|
|
251
|
-
---
|
|
252
|
-
body
|
|
253
|
-
`,
|
|
254
|
-
);
|
|
255
|
-
writeFileSync(join(root, 'shared-context', '.indexignore'), 'archive/\n# comment\n\n');
|
|
256
|
-
|
|
257
|
-
const entries = buildEntries(root);
|
|
258
|
-
assertEq(entries.length, 1, 'archive/ excluded by .indexignore');
|
|
259
|
-
assertEq(entries[0].relativePath, 'live.md', 'only live.md survives');
|
|
260
|
-
cleanup(root);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
{
|
|
264
|
-
const root = setupFixture();
|
|
265
|
-
writeFileSync(
|
|
266
|
-
join(root, 'shared-context', 'a.md'),
|
|
267
|
-
`---
|
|
268
|
-
description: A.
|
|
269
|
-
---
|
|
270
|
-
body
|
|
271
|
-
`,
|
|
272
|
-
);
|
|
273
|
-
writeFileSync(
|
|
274
|
-
join(root, 'shared-context', 'b.md'),
|
|
275
|
-
`---
|
|
276
|
-
description: B.
|
|
277
|
-
---
|
|
278
|
-
body
|
|
279
|
-
`,
|
|
280
|
-
);
|
|
281
|
-
writeFileSync(join(root, 'shared-context', '.indexignore'), 'a.md\n');
|
|
282
|
-
const entries = buildEntries(root);
|
|
283
|
-
assertEq(entries.length, 1, 'specific file excluded by .indexignore');
|
|
284
|
-
assertEq(entries[0].relativePath, 'b.md', 'only b.md survives');
|
|
285
|
-
cleanup(root);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
console.log('# index.md is excluded from its own entries');
|
|
289
|
-
|
|
290
|
-
{
|
|
291
|
-
const root = setupFixture();
|
|
292
|
-
writeFileSync(
|
|
293
|
-
join(root, 'shared-context', 'index.md'),
|
|
294
|
-
`---
|
|
295
|
-
type: index
|
|
296
|
-
---
|
|
297
|
-
|
|
298
|
-
# index
|
|
299
|
-
`,
|
|
300
|
-
);
|
|
301
|
-
writeFileSync(
|
|
302
|
-
join(root, 'shared-context', 'real.md'),
|
|
303
|
-
`---
|
|
304
|
-
description: Real file.
|
|
305
|
-
---
|
|
306
|
-
|
|
307
|
-
body
|
|
308
|
-
`,
|
|
309
|
-
);
|
|
310
|
-
const entries = buildEntries(root);
|
|
311
|
-
assertEq(entries.length, 1, 'only the non-index file');
|
|
312
|
-
assertEq(entries[0].relativePath, 'real.md', 'index.md excluded');
|
|
313
|
-
cleanup(root);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
console.log('');
|
|
317
|
-
console.log(`${passed} passed, ${failed} failed`);
|
|
318
|
-
process.exit(failed > 0 ? 1 : 0);
|