@ulysses-ai/create-workspace 0.13.0-beta.2 → 0.14.0-beta.3
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/README.md +15 -3
- package/package.json +1 -1
- package/template/.claude/hooks/_bash-output-advisory.test.mjs +88 -0
- package/template/.claude/hooks/bash-output-advisory.mjs +77 -0
- package/template/.claude/hooks/version-freshness-check.mjs +30 -0
- package/template/.claude/lib/freshness.mjs +75 -0
- package/template/.claude/lib/freshness.test.mjs +175 -0
- package/template/.claude/lib/registry-check.mjs +106 -0
- package/template/.claude/lib/registry-check.test.mjs +130 -0
- package/template/.claude/rules/memory-guidance.md +47 -0
- package/template/.claude/rules/task-list-mirroring.md +69 -0
- package/template/.claude/rules/token-economics.md.skip +23 -8
- package/template/.claude/rules/workspace-structure.md +2 -0
- package/template/.claude/scripts/build-shared-context-index.mjs +212 -0
- package/template/.claude/scripts/build-shared-context-index.test.mjs +318 -0
- package/template/.claude/scripts/migrate-claude-md-freshness-include.mjs +30 -0
- package/template/.claude/scripts/migrate-claude-md-freshness-include.test.mjs +54 -0
- package/template/.claude/scripts/sync-tasks.mjs +234 -0
- package/template/.claude/scripts/sync-tasks.test.mjs +350 -0
- package/template/.claude/settings.json +20 -9
- package/template/.claude/skills/braindump/SKILL.md +15 -0
- package/template/.claude/skills/build-docs-site/SKILL.md +1 -1
- package/template/.claude/skills/build-docs-site/checklists/pitfalls.md +4 -4
- package/template/.claude/skills/complete-work/SKILL.md +47 -55
- package/template/.claude/skills/handoff/SKILL.md +15 -0
- package/template/.claude/skills/maintenance/SKILL.md +49 -7
- package/template/.claude/skills/pause-work/SKILL.md +25 -4
- package/template/.claude/skills/release/SKILL.md +59 -43
- package/template/.claude/skills/start-work/SKILL.md +34 -2
- package/template/.claude/skills/workspace-update/SKILL.md +16 -0
- package/template/CLAUDE.md.tmpl +1 -0
- package/template/_gitignore +2 -3
- package/template/workspace.json.tmpl +1 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Unit tests for registry-check.mjs
|
|
3
|
+
// Run: node template/.claude/lib/registry-check.test.mjs
|
|
4
|
+
import { compareVersions } from './registry-check.mjs';
|
|
5
|
+
|
|
6
|
+
let failed = 0;
|
|
7
|
+
let passed = 0;
|
|
8
|
+
|
|
9
|
+
function assertEq(actual, expected, msg) {
|
|
10
|
+
const a = JSON.stringify(actual);
|
|
11
|
+
const e = JSON.stringify(expected);
|
|
12
|
+
if (a === e) { passed++; } else {
|
|
13
|
+
failed++;
|
|
14
|
+
console.error(` FAIL: ${msg}\n expected: ${e}\n actual: ${a}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
console.log('# compareVersions');
|
|
19
|
+
assertEq(compareVersions('0.13.0', '0.14.0'), -1, 'older minor');
|
|
20
|
+
assertEq(compareVersions('0.14.0', '0.13.0'), 1, 'newer minor');
|
|
21
|
+
assertEq(compareVersions('0.13.0', '0.13.0'), 0, 'equal');
|
|
22
|
+
assertEq(compareVersions('1.0.0', '0.99.99'), 1, 'newer major');
|
|
23
|
+
assertEq(compareVersions('0.14.0-beta.1', '0.14.0'), -1, 'prerelease < release');
|
|
24
|
+
assertEq(compareVersions('0.14.0', '0.14.0-beta.1'), 1, 'release > prerelease');
|
|
25
|
+
assertEq(compareVersions('0.14.0-beta.1', '0.14.0-beta.2'), -1, 'prerelease numeric ordering');
|
|
26
|
+
assertEq(compareVersions('0.14.0-beta.10', '0.14.0-beta.2'), 1, 'prerelease numeric not lexical');
|
|
27
|
+
assertEq(compareVersions('0.14.0-beta.5', '0.14.0-beta.5'), 0, 'equal prerelease');
|
|
28
|
+
assertEq(compareVersions('0.14.0-alpha.1', '0.14.0-beta.1'),-1, 'alpha < beta lexical');
|
|
29
|
+
|
|
30
|
+
import { getLatestVersion } from './registry-check.mjs';
|
|
31
|
+
|
|
32
|
+
console.log('\n# getLatestVersion');
|
|
33
|
+
|
|
34
|
+
// Helper: build a fake fetch
|
|
35
|
+
function fakeFetch(response) {
|
|
36
|
+
return async () => response;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Success path
|
|
40
|
+
{
|
|
41
|
+
const fakeOk = fakeFetch({
|
|
42
|
+
ok: true,
|
|
43
|
+
json: async () => ({ version: '0.14.0', name: '@ulysses-ai/create-workspace' }),
|
|
44
|
+
});
|
|
45
|
+
const result = await getLatestVersion({ fetchFn: fakeOk });
|
|
46
|
+
assertEq(result, { version: '0.14.0', error: null }, 'success returns version');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Non-2xx response
|
|
50
|
+
{
|
|
51
|
+
const fake404 = fakeFetch({ ok: false, status: 404, statusText: 'Not Found' });
|
|
52
|
+
const result = await getLatestVersion({ fetchFn: fake404 });
|
|
53
|
+
assertEq(result.version, null, 'non-2xx version is null');
|
|
54
|
+
assertEq(typeof result.error, 'string', 'non-2xx returns error string');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Malformed body (no version field)
|
|
58
|
+
{
|
|
59
|
+
const fakeBad = fakeFetch({ ok: true, json: async () => ({ name: 'foo' }) });
|
|
60
|
+
const result = await getLatestVersion({ fetchFn: fakeBad });
|
|
61
|
+
assertEq(result.version, null, 'missing version field is null');
|
|
62
|
+
assertEq(typeof result.error, 'string', 'missing version returns error');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Network error (fetch throws)
|
|
66
|
+
{
|
|
67
|
+
const fakeThrow = async () => { throw new Error('ECONNREFUSED'); };
|
|
68
|
+
const result = await getLatestVersion({ fetchFn: fakeThrow });
|
|
69
|
+
assertEq(result.version, null, 'thrown error returns null version');
|
|
70
|
+
assertEq(result.error.includes('ECONNREFUSED'), true, 'thrown error message preserved');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
import { readCache, writeCache } from './registry-check.mjs';
|
|
74
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
|
75
|
+
import { join } from 'path';
|
|
76
|
+
import { tmpdir } from 'os';
|
|
77
|
+
|
|
78
|
+
console.log('\n# readCache / writeCache');
|
|
79
|
+
|
|
80
|
+
function withTempDir(fn) {
|
|
81
|
+
const dir = mkdtempSync(join(tmpdir(), 'reg-cache-test-'));
|
|
82
|
+
try { fn(dir); } finally { rmSync(dir, { recursive: true, force: true }); }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// readCache: missing file returns null
|
|
86
|
+
withTempDir((dir) => {
|
|
87
|
+
const result = readCache(join(dir, 'missing.json'));
|
|
88
|
+
assertEq(result, null, 'missing file returns null');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// readCache: malformed JSON returns null
|
|
92
|
+
withTempDir((dir) => {
|
|
93
|
+
const path = join(dir, 'bad.json');
|
|
94
|
+
writeFileSync(path, '{not json');
|
|
95
|
+
const result = readCache(path);
|
|
96
|
+
assertEq(result, null, 'malformed JSON returns null');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// readCache: valid file returns parsed object
|
|
100
|
+
withTempDir((dir) => {
|
|
101
|
+
const path = join(dir, 'good.json');
|
|
102
|
+
writeFileSync(path, JSON.stringify({ latestVersion: '0.14.0', checkedAt: '2026-04-24T21:00:00Z' }));
|
|
103
|
+
const result = readCache(path);
|
|
104
|
+
assertEq(result, { latestVersion: '0.14.0', checkedAt: '2026-04-24T21:00:00Z' }, 'valid file parsed');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// readCache: missing required fields returns null
|
|
108
|
+
withTempDir((dir) => {
|
|
109
|
+
const path = join(dir, 'partial.json');
|
|
110
|
+
writeFileSync(path, JSON.stringify({ checkedAt: '2026-04-24T21:00:00Z' }));
|
|
111
|
+
const result = readCache(path);
|
|
112
|
+
assertEq(result, null, 'missing latestVersion returns null');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// writeCache + readCache round-trip
|
|
116
|
+
withTempDir((dir) => {
|
|
117
|
+
const path = join(dir, 'rt.json');
|
|
118
|
+
writeCache(path, { latestVersion: '0.14.0', checkedAt: '2026-04-24T21:00:00Z' });
|
|
119
|
+
assertEq(readCache(path), { latestVersion: '0.14.0', checkedAt: '2026-04-24T21:00:00Z' }, 'round-trip equal');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// writeCache creates parent dir if missing
|
|
123
|
+
withTempDir((dir) => {
|
|
124
|
+
const path = join(dir, 'nested', 'deep', 'cache.json');
|
|
125
|
+
writeCache(path, { latestVersion: '0.14.0', checkedAt: '2026-04-24T21:00:00Z' });
|
|
126
|
+
assertEq(readCache(path)?.latestVersion, '0.14.0', 'parent dir created');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
130
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
@@ -24,3 +24,50 @@ When a work session is active:
|
|
|
24
24
|
- Decisions and progress from this session → update the session tracker body at `work-sessions/{name}/workspace/session.md` (consumed by /complete-work)
|
|
25
25
|
- Patterns, corrections, and insights that apply beyond this session → auto-memory (persists across all sessions)
|
|
26
26
|
- Don't duplicate: if something is already in the session tracker, don't also save it to auto-memory
|
|
27
|
+
|
|
28
|
+
## Shared-Context Frontmatter
|
|
29
|
+
|
|
30
|
+
Every shared-context file should have YAML frontmatter. The fields below are conventions, not all required.
|
|
31
|
+
|
|
32
|
+
**Standard fields:**
|
|
33
|
+
|
|
34
|
+
- `state` — `locked` (team truth) or `ephemeral` (working context). Locked files live under `locked/`; ephemeral files live elsewhere.
|
|
35
|
+
- `lifecycle` — for ephemeral files: `active` (still relevant) or `resolved` (handled, kept for record).
|
|
36
|
+
- `type` — kind of content: `reference`, `braindump`, `handoff`, `research`, `design`, `index`, `promoted`.
|
|
37
|
+
- `topic` — kebab-case slug matching the filename (without `.md`).
|
|
38
|
+
- `author` — username scope owner. Required for `{user}/` files.
|
|
39
|
+
- `updated` — ISO date of last meaningful edit. `/maintenance` flags stale `lifecycle: active` files based on this.
|
|
40
|
+
|
|
41
|
+
**Index-feeding field:**
|
|
42
|
+
|
|
43
|
+
- `description` — one-line summary, used verbatim by `shared-context/index.md`. When omitted, the index falls back to the first sentence of the body, then the filename slug. Adding a `description:` to a file with a weak fallback is the cheapest way to improve the index.
|
|
44
|
+
|
|
45
|
+
**Optional confidence marker:**
|
|
46
|
+
|
|
47
|
+
- `confidence` — `high` | `medium` | `low`. Apply to research, design, and exploration files where the conclusions might still shift. Skip on locked files (locked = high by definition) and on workflow artifacts like handoffs and braindumps. The frontmatter integrity check in `/maintenance` validates the value if present.
|
|
48
|
+
|
|
49
|
+
**Example for a research file:**
|
|
50
|
+
|
|
51
|
+
```yaml
|
|
52
|
+
---
|
|
53
|
+
state: ephemeral
|
|
54
|
+
lifecycle: active
|
|
55
|
+
type: research
|
|
56
|
+
topic: vector-search-evaluation
|
|
57
|
+
description: Evaluation of FAISS for shared-context — concluded NL index is sufficient at our scale.
|
|
58
|
+
author: alex
|
|
59
|
+
confidence: medium
|
|
60
|
+
updated: 2026-04-25
|
|
61
|
+
---
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Shared-Context Index
|
|
65
|
+
|
|
66
|
+
`shared-context/index.md` is auto-generated by `.claude/scripts/build-shared-context-index.mjs`. It's a one-line catalog of every shared-context file (excluding paths in `shared-context/.indexignore`). The script regenerates from frontmatter on demand:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
node .claude/scripts/build-shared-context-index.mjs --check --root . # exits 1 if stale
|
|
70
|
+
node .claude/scripts/build-shared-context-index.mjs --write --root . # regenerate
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`/maintenance` checks the index for staleness in audit mode and regenerates in cleanup mode. Hand edits to `index.md` are overwritten — update source files (or their `description:` frontmatter) instead.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Task List Mirroring
|
|
2
|
+
|
|
3
|
+
The Claude Code `TodoWrite` checklist is a live mirror of the workspace lifecycle. The durable backing store is a `## Tasks` section in `session.md`, round-tripped by `.claude/scripts/sync-tasks.mjs`. This rule defines the contract.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
`TodoWrite` is conversation-scoped — it disappears at the end of every chat. To make it useful for multi-chat work sessions, it needs a durable store that survives chat restarts, machine switches, and pause/resume cycles. `session.md` already lives on the session branch and travels with `git push`/`git pull`, so it's the natural home.
|
|
8
|
+
|
|
9
|
+
## The `## Tasks` schema
|
|
10
|
+
|
|
11
|
+
Anchored after `## Pre-session context` (if present) and before `## Progress`:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
## Tasks
|
|
15
|
+
|
|
16
|
+
> Linked: gh:42 — Auth timeout on mobile
|
|
17
|
+
|
|
18
|
+
- [x] Start work
|
|
19
|
+
- [x] Reproduce on iOS Safari
|
|
20
|
+
- [ ] Identify race condition in token refresh
|
|
21
|
+
- [ ] Complete work
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- The heading is exactly `## Tasks`.
|
|
25
|
+
- Optional first line is a blockquote `> Linked: {workItem-id} — {issue-title}`. Present iff `workItem:` is set in frontmatter.
|
|
26
|
+
- Each task is one checkbox line. Three statuses: `- [ ]` pending, `- [-]` in_progress, `- [x]` completed. No nesting.
|
|
27
|
+
- The bookends `Start work` and `Complete work` are always present, at positions 1 and N.
|
|
28
|
+
|
|
29
|
+
The `- [-]` marker is non-standard GFM — GitHub's web renderer shows it as literal text rather than a checkbox. That's acceptable because `session.md` lives on session branches and is mostly read in editors (where Obsidian, JetBrains, and similar renderers do treat `[-]` as in-progress). The tradeoff buys lossless `in_progress` round-trip across chats.
|
|
30
|
+
|
|
31
|
+
## Helper invocations
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Read: emits TodoWrite-shaped JSON.
|
|
35
|
+
node .claude/scripts/sync-tasks.mjs --read <session.md>
|
|
36
|
+
|
|
37
|
+
# Write: takes TodoWrite-shaped JSON on stdin, rewrites the section atomically.
|
|
38
|
+
node .claude/scripts/sync-tasks.mjs --write <session.md>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The helper enforces bookends on write — Claude doesn't need to remember to include them, and any misplacement is silently corrected.
|
|
42
|
+
|
|
43
|
+
## When to flush (write to disk)
|
|
44
|
+
|
|
45
|
+
**Lifecycle moments — always:**
|
|
46
|
+
- `/start-work` (blank): seed `## Tasks` with bookends + (if linked) tracker reference.
|
|
47
|
+
- `/start-work` (resume): read-only — populate `TodoWrite` from `## Tasks`.
|
|
48
|
+
- `/pause-work`: flush before the auto-commit.
|
|
49
|
+
- `/complete-work`: flush before release-note synthesis.
|
|
50
|
+
- `/handoff`, `/braindump`: include a snapshot of current `TodoWrite` state in the captured artifact.
|
|
51
|
+
|
|
52
|
+
**Mid-session — after meaningful change:**
|
|
53
|
+
- A new task was added.
|
|
54
|
+
- A task moved to `completed`.
|
|
55
|
+
- A task moved to `in_progress`.
|
|
56
|
+
|
|
57
|
+
Trivial edits (renaming, reordering) do **not** trigger a flush — they ride on the next lifecycle commit.
|
|
58
|
+
|
|
59
|
+
## What NOT to do
|
|
60
|
+
|
|
61
|
+
- Do not edit the `## Tasks` section by hand or with `Edit` — always go through the helper. Manual edits will be overwritten and may miss the bookend invariant.
|
|
62
|
+
- Do not add nested checkboxes — `TodoWrite` is flat, and the round-trip ignores nesting.
|
|
63
|
+
- Do not omit the bookends — the helper auto-inserts them, but explicit is better than implicit.
|
|
64
|
+
- Do not flush every keystroke — that creates pointless file churn. Flush on meaningful change or lifecycle moment.
|
|
65
|
+
- Do not flush from inside a subagent — subagents have ephemeral context; only the main agent maintains the canonical `TodoWrite` state for the session.
|
|
66
|
+
|
|
67
|
+
## Concurrency model
|
|
68
|
+
|
|
69
|
+
Multi-chat-on-same-session is rare but possible. Each chat maintains its own `TodoWrite`; flushes write the file and the last writer wins. This matches the existing `## Progress` model. If you notice a divergence, the disk version is authoritative — restart your chat to reseed.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Token Economics
|
|
2
2
|
|
|
3
|
-
Activate this rule for token-aware behavior — cost-conscious model selection, context efficiency, and waste
|
|
3
|
+
Activate this rule for token-aware behavior — cost-conscious model selection, context efficiency, command discipline, and waste detection.
|
|
4
4
|
|
|
5
5
|
## Model Selection
|
|
6
6
|
|
|
@@ -18,14 +18,29 @@ Activate this rule for token-aware behavior — cost-conscious model selection,
|
|
|
18
18
|
- If a tool result is large and mostly irrelevant, note what matters and move on
|
|
19
19
|
- Prefer exact file paths over glob searches when you know where things are
|
|
20
20
|
|
|
21
|
-
##
|
|
21
|
+
## Command Discipline
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
23
|
+
The single biggest source of session bloat is bash output you didn't bound. The model has to read every line that comes back, and there's no after-the-fact way to truncate it.
|
|
24
|
+
|
|
25
|
+
- Don't run unbounded commands. Pipe to `head`, `tail`, or `grep` first.
|
|
26
|
+
- For test runs, prefer scoped invocations: `npm test path/to/file.test.mjs` over bare `npm test`. If you only need failures, pipe through `grep -E "FAIL|✗|Error"`.
|
|
27
|
+
- For `find` and `grep -r` / `rg -r`, always supply a constraint (`-name`, `-path`, `--include`, `-g`). Bare `find ~/` against home is almost never what you want.
|
|
28
|
+
- For inspecting a large file, write to a tmp file and grep into it rather than `cat`ing the whole thing into context.
|
|
29
|
+
- For long-running processes (servers, watchers), use `run_in_background: true` and stream with the Monitor tool — don't let stdout pile up in a single tool result.
|
|
30
|
+
- Do not claim a task is done before running its tests. The cost of a forgotten test run is a follow-up cycle that bloats the session more than the test would have.
|
|
31
|
+
|
|
32
|
+
## Ghost-Token Detection
|
|
33
|
+
|
|
34
|
+
"Ghost tokens" are context spend you can't see in any single tool result — structural bloat that accumulates across the session. Watch for:
|
|
35
|
+
|
|
36
|
+
- **Unused skills.** A loaded skill that you never invoke costs input tokens every turn. If a workspace ships skills you don't use in the current task, that's silent overhead.
|
|
37
|
+
- **Oversized locked context.** `shared-context/locked/` is injected into every session and every subagent. If it grows past 5% of the active model's context window, flag it (yellow); past 15%, treat as red. Absolute byte count is a weak proxy — duplicated coverage and stale references matter more.
|
|
38
|
+
- **Unused MCP servers.** Each MCP server's tool list is in your prompt whether you call it or not. If a server is registered but never used in this workspace's flow, surface it for cleanup.
|
|
39
|
+
- **Resolved discussions still in context.** If the conversation has worked through a long debate that's now settled, that text is still occupying the window. Suggest `/braindump` to offload it into a file.
|
|
40
|
+
- **Subagent context bloat.** A subagent prompt that ships more context than the task requires wastes tokens twice — once for the subagent, once for the dispatching model that wrote the prompt.
|
|
27
41
|
|
|
28
42
|
## Compaction Awareness
|
|
29
43
|
|
|
30
|
-
- When approaching compaction threshold, prioritize capturing over continuing
|
|
31
|
-
- After compaction, avoid re-reading files that were already discussed — check shared-context first
|
|
44
|
+
- When approaching the compaction threshold, prioritize capturing over continuing. A `/braindump` or `/handoff` *before* compaction preserves reasoning that gets summarized away.
|
|
45
|
+
- After compaction, avoid re-reading files that were already discussed — check `shared-context/` and the session tracker first.
|
|
46
|
+
- The `PreCompact` hook will prompt for capture; treat that prompt as a real checkpoint, not a dismissable nag.
|
|
@@ -15,6 +15,8 @@ This workspace follows the claude-workspace convention. All paths are relative t
|
|
|
15
15
|
| `work-sessions/{name}/workspace/repos/` | Real directory holding nested project worktrees for this session | No (gitignored) |
|
|
16
16
|
| `work-sessions/{name}/workspace/repos/{repo}/` | Project worktree nested inside the workspace worktree | No (gitignored) |
|
|
17
17
|
| `shared-context/` | Shared memory — handoffs, braindumps, team knowledge | Yes |
|
|
18
|
+
| `shared-context/index.md` | Auto-generated catalog of every shared-context file (one-line per file) | Yes |
|
|
19
|
+
| `shared-context/.indexignore` | Path prefixes to exclude from `index.md` (e.g., archived release notes) | Yes |
|
|
18
20
|
| `shared-context/locked/` | Team truths — loaded every session, injected into subagents | Yes |
|
|
19
21
|
| `shared-context/{user}/` | User-scoped working context — default for all captures | Yes |
|
|
20
22
|
| `workspace-scratchpad/` | Disposable workspace-scoped files — session log, hook debug output | No (gitignored, lazy) |
|
|
@@ -0,0 +1,212 @@
|
|
|
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 };
|