@ulysses-ai/create-workspace 0.14.0-beta.1 → 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/README.md +1 -1
- 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/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/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 +54 -1
- 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 -13
- 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-claude-md-freshness-include.mjs +30 -0
- package/template/.claude/scripts/migrate-claude-md-freshness-include.test.mjs +54 -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/settings.json +6 -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 +57 -15
- 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/skills/workspace-update/SKILL.md +16 -0
- package/template/CLAUDE.md.tmpl +5 -3
- package/template/_gitignore +3 -3
- package/template/workspace.json.tmpl +3 -2
|
@@ -47,12 +47,12 @@ For each Notion page that contains project memory, context, or key decisions:
|
|
|
47
47
|
```
|
|
48
48
|
Fetch the page content using the Notion MCP server.
|
|
49
49
|
Determine scope:
|
|
50
|
-
- Architecture decisions, tech stack, stable conventions →
|
|
51
|
-
- Active project state, current priorities →
|
|
50
|
+
- Architecture decisions, tech stack, stable conventions → workspace-context/shared/locked/
|
|
51
|
+
- Active project state, current priorities → workspace-context/team-member/{user}/
|
|
52
52
|
- Historical context no longer relevant → skip
|
|
53
53
|
|
|
54
54
|
For each section:
|
|
55
|
-
- Create a
|
|
55
|
+
- Create a workspace-context file with proper frontmatter:
|
|
56
56
|
---
|
|
57
57
|
state: locked (or ephemeral)
|
|
58
58
|
lifecycle: active
|
|
@@ -72,7 +72,7 @@ For each Notion page that contains session handoffs or context transfers:
|
|
|
72
72
|
```
|
|
73
73
|
Fetch the page content using the Notion MCP server.
|
|
74
74
|
For recent/active handoffs only (skip stale ones):
|
|
75
|
-
- Create
|
|
75
|
+
- Create workspace-context/team-member/{user}/{handoff-name}.md
|
|
76
76
|
- Use handoff frontmatter format with type: handoff
|
|
77
77
|
- Extract: status, key decisions, next steps, open questions
|
|
78
78
|
- Set lifecycle: active (or paused if the work is suspended)
|
|
@@ -84,7 +84,7 @@ Check `CLAUDE.md.bak` for local preferences that aren't Notion-dependent:
|
|
|
84
84
|
- Repo paths, coding conventions, project-specific notes
|
|
85
85
|
- Add these to appropriate places:
|
|
86
86
|
- Coding conventions → `.claude/rules/` (new rule file)
|
|
87
|
-
- Project notes → `
|
|
87
|
+
- Project notes → `workspace-context/shared/locked/` or `workspace-context/team-member/{user}/`
|
|
88
88
|
- Repo paths → already in `workspace.json`
|
|
89
89
|
|
|
90
90
|
### 6. Remove Notion dependency
|
|
@@ -108,7 +108,7 @@ Verify the workspace works without Notion:
|
|
|
108
108
|
### 7. Commit
|
|
109
109
|
|
|
110
110
|
```bash
|
|
111
|
-
git add .claude/rules/
|
|
111
|
+
git add .claude/rules/ workspace-context/
|
|
112
112
|
git commit -m "chore: migrate Notion content to local files"
|
|
113
113
|
```
|
|
114
114
|
|
|
@@ -6,7 +6,7 @@ This applies to all written output:
|
|
|
6
6
|
- Project documentation
|
|
7
7
|
- Specs and design docs
|
|
8
8
|
- Code and logic changes including comments
|
|
9
|
-
-
|
|
9
|
+
- Workspace-context files (handoffs, braindumps, locked truths)
|
|
10
10
|
- Release notes
|
|
11
11
|
- Open questions
|
|
12
12
|
- Commit messages
|
|
@@ -18,7 +18,7 @@ Injected revisions create fragmented, hard-to-follow output where the seams betw
|
|
|
18
18
|
## In Practice
|
|
19
19
|
|
|
20
20
|
- When updating a section of a document, rewrite the entire section — not just the changed sentences
|
|
21
|
-
- When updating a
|
|
21
|
+
- When updating a workspace-context file, rewrite it as a fresh snapshot of current understanding
|
|
22
22
|
- When synthesizing multiple sources into release notes, write the narrative from scratch — don't concatenate
|
|
23
23
|
- When revising code with comments, ensure the comments tell a coherent story, not a changelog
|
|
24
24
|
- Small, isolated edits (fixing a typo, updating a single value) are fine — this rule targets substantive revisions
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Local Dev Environment
|
|
2
2
|
|
|
3
|
-
Maintain a local-only document at `
|
|
3
|
+
Maintain a local-only document at `workspace-context/shared/locked/local-only-dev-environment.md` that captures machine-specific development context. This file is gitignored (local-only prefix) but positioned in locked for maximum visibility — Claude sees it every turn.
|
|
4
4
|
|
|
5
5
|
## What to Record
|
|
6
6
|
|
|
@@ -17,7 +17,7 @@ When you discover any of the following during work, add it to the local dev envi
|
|
|
17
17
|
|
|
18
18
|
When you encounter a local development detail worth preserving:
|
|
19
19
|
|
|
20
|
-
1. Check if `
|
|
20
|
+
1. Check if `workspace-context/shared/locked/local-only-dev-environment.md` exists
|
|
21
21
|
2. If not, create it with this structure:
|
|
22
22
|
```markdown
|
|
23
23
|
---
|
|
@@ -15,7 +15,7 @@ When working in this workspace, pay attention to and save memories about:
|
|
|
15
15
|
|
|
16
16
|
- Temporary debugging state
|
|
17
17
|
- File contents (re-read them instead)
|
|
18
|
-
- Anything already captured in a
|
|
18
|
+
- Anything already captured in a workspace-context file
|
|
19
19
|
- Anything documented in .claude/rules/
|
|
20
20
|
|
|
21
21
|
## Session-Scoped vs Cross-Session
|
|
@@ -24,3 +24,56 @@ 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
|
+
## Workspace-Context Frontmatter
|
|
29
|
+
|
|
30
|
+
Every workspace-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 `shared/locked/`; ephemeral files live elsewhere under `shared/` or `team-member/{user}/`.
|
|
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`, `canonical`, `promoted`.
|
|
37
|
+
- `topic` — kebab-case slug matching the filename (after the type prefix, when one is present).
|
|
38
|
+
- `author` — username scope owner. Required for `team-member/{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 `workspace-context/index.md` and per-user team-member indexes. When omitted, the index falls back to the first sentence of the body, then the filename slug (with the `braindump_`/`handoff_`/`research_` prefix stripped). 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 workspace-context — concluded NL index is sufficient at our scale.
|
|
58
|
+
author: alex
|
|
59
|
+
confidence: medium
|
|
60
|
+
updated: 2026-04-25
|
|
61
|
+
---
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Workspace-Context Auto-Generated Files
|
|
65
|
+
|
|
66
|
+
A single generator at `.claude/scripts/build-workspace-context.mjs` produces three artifacts in one pass:
|
|
67
|
+
|
|
68
|
+
- `workspace-context/index.md` — navigation catalog of everything under `shared/` (locked files first, then the rest). Imported by the workspace-level `CLAUDE.md`.
|
|
69
|
+
- `workspace-context/canonical.md` — verbatim concatenation of `shared/locked/*.md` so team truths are loaded into every session prompt. Also imported by `CLAUDE.md`.
|
|
70
|
+
- `workspace-context/team-member/{user}/index.md` — per-user navigation catalog, one per team member. Imported by each user's gitignored `CLAUDE.local.md`.
|
|
71
|
+
|
|
72
|
+
Gitignored files (e.g. anything matching `local-only-*`) are excluded automatically, and `workspace-context/.indexignore` adds path-prefix excludes for tracked files that shouldn't appear in the shared index (e.g. archived release notes).
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
node .claude/scripts/build-workspace-context.mjs --check --root . # exits 1 if any artifact is stale or missing
|
|
76
|
+
node .claude/scripts/build-workspace-context.mjs --write --root . # regenerate all three
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`/maintenance` checks staleness in audit mode and regenerates in cleanup mode. Hand edits to `index.md`, `canonical.md`, or any `team-member/{user}/index.md` are overwritten — update source files (or their `description:` frontmatter) instead.
|
|
@@ -34,7 +34,7 @@ The single biggest source of session bloat is bash output you didn't bound. The
|
|
|
34
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
35
|
|
|
36
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.** `
|
|
37
|
+
- **Oversized locked context.** `workspace-context/shared/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
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
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
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.
|
|
@@ -42,5 +42,5 @@ The single biggest source of session bloat is bash output you didn't bound. The
|
|
|
42
42
|
## Compaction Awareness
|
|
43
43
|
|
|
44
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 `
|
|
45
|
+
- After compaction, avoid re-reading files that were already discussed — check `workspace-context/` and the session tracker first.
|
|
46
46
|
- The `PreCompact` hook will prompt for capture; treat that prompt as a real checkpoint, not a dismissable nag.
|
|
@@ -69,7 +69,7 @@ The value is the adapter-prefixed issue ID. This survives adapter swaps — repl
|
|
|
69
69
|
|
|
70
70
|
## When NOT to maintain local state
|
|
71
71
|
|
|
72
|
-
- Do not create, write to, or read `
|
|
72
|
+
- Do not create, write to, or read `workspace-context/open-work.md`. That file is deprecated.
|
|
73
73
|
- Do not write ticket state into `session.md` frontmatter beyond the `workItem:` pointer. Status, assignment, milestone, and labels live in the tracker.
|
|
74
74
|
- Do not cache issue bodies locally. Always fetch via `tracker.getIssue(id)` when the content is needed.
|
|
75
75
|
|
|
@@ -14,25 +14,35 @@ This workspace follows the claude-workspace convention. All paths are relative t
|
|
|
14
14
|
| `work-sessions/{name}/workspace/plan-*.md` | Plans for this session — consumed into release notes by /complete-work | Yes — on the session branch |
|
|
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
|
-
| `
|
|
18
|
-
| `
|
|
19
|
-
| `
|
|
17
|
+
| `workspace-context/` | Team knowledge and per-user context | Yes |
|
|
18
|
+
| `workspace-context/shared/` | Team-visible content — handoffs, braindumps, research, references | Yes |
|
|
19
|
+
| `workspace-context/shared/locked/` | Canonical team truths — auto-concatenated into `canonical.md` and loaded into every session | Yes |
|
|
20
|
+
| `workspace-context/team-member/{user}/` | Per-user working context — default destination for personal captures | Yes |
|
|
21
|
+
| `workspace-context/index.md` | Auto-generated navigation catalog of `shared/` (locked first, then ephemerals) | Yes |
|
|
22
|
+
| `workspace-context/canonical.md` | Auto-generated verbatim concatenation of `shared/locked/*.md` | Yes |
|
|
23
|
+
| `workspace-context/team-member/{user}/index.md` | Auto-generated per-user navigation catalog | Yes |
|
|
24
|
+
| `workspace-context/.indexignore` | Path prefixes to exclude from `index.md` (e.g., archived release notes) | Yes |
|
|
25
|
+
| `workspace-context/release-notes/` | Per-branch release-note artifacts — `unreleased/` and `archive/` | Yes |
|
|
20
26
|
| `workspace-scratchpad/` | Disposable workspace-scoped files — session log, hook debug output | No (gitignored, lazy) |
|
|
27
|
+
| `CLAUDE.md` | Workspace launcher prompt — imports `canonical.md` and `index.md` | Yes |
|
|
28
|
+
| `CLAUDE.local.md` | Per-user prompt — imports `team-member/{user}/index.md` | No (gitignored) |
|
|
21
29
|
| `.claude/` | Claude Code configuration — rules, agents, skills, hooks, scripts, lib | Yes (except settings.local.json) |
|
|
22
30
|
|
|
23
31
|
Session content (tracker, specs, plans) lives at the top of each session's workspace worktree. It is tracked on the session branch, not on main. Pushing the session branch carries durable session thinking across machines. When `/complete-work` finalizes the session, it synthesizes the content into release notes and removes the files from the branch before the final PR so main's top level stays free of session artifacts.
|
|
24
32
|
|
|
25
|
-
##
|
|
33
|
+
## Workspace-Context Levels
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|-------|-----------------|----------|
|
|
29
|
-
| `locked/` | Team truths — always loaded, injected into subagents | Promoted by /release |
|
|
30
|
-
| Root | Team-visible ephemerals — cross-team handoffs, post-release leftovers | Explicit choice |
|
|
31
|
-
| `{user}/` | Ongoing personal context — persists across work sessions | Default for captures |
|
|
35
|
+
Three layers, in increasing trust order:
|
|
32
36
|
|
|
33
|
-
|
|
37
|
+
| Level | Path | What lives there | How it gets there |
|
|
38
|
+
|-------|------|-----------------|--------------------|
|
|
39
|
+
| Personal | `team-member/{user}/` | Per-user braindumps, handoffs, research notes | Default destination for `/braindump`, `/handoff`, `/aside` |
|
|
40
|
+
| Shared | `shared/` (root) | Team-visible ephemerals — cross-team handoffs, post-release leftovers, references | Explicit choice via `--scope shared` or `/promote` |
|
|
41
|
+
| Canonical | `shared/locked/` | Promoted truths — naming conventions, post-release discipline, project status | Promoted by `/release` (or `/promote` with explicit locked target) |
|
|
34
42
|
|
|
35
|
-
|
|
43
|
+
Canonical content is verbatim-loaded into every session via `CLAUDE.md` → `@workspace-context/canonical.md`. Personal content is loaded only for the active user via the gitignored `CLAUDE.local.md`.
|
|
44
|
+
|
|
45
|
+
Inflight session state lives inside the session worktree at `work-sessions/{name}/workspace/session.md`, not in `workspace-context/`. Workspace-context is for knowledge that outlives any individual session.
|
|
36
46
|
|
|
37
47
|
## Spec and Plan Locations — MANDATORY OVERRIDE
|
|
38
48
|
|
|
@@ -49,7 +59,7 @@ If a spec/plan already exists for the current session, version it: `design-{topi
|
|
|
49
59
|
|
|
50
60
|
`/complete-work` reads specs and plans from the worktree to synthesize release notes, then removes them in a dedicated commit before the final PR so main's tree stays pristine.
|
|
51
61
|
|
|
52
|
-
## Naming Conventions
|
|
62
|
+
## File Naming Conventions
|
|
53
63
|
|
|
54
64
|
- Session folders: `work-sessions/{session-name}/`
|
|
55
65
|
- Workspace worktrees: `work-sessions/{session-name}/workspace/`
|
|
@@ -57,7 +67,19 @@ If a spec/plan already exists for the current session, version it: `design-{topi
|
|
|
57
67
|
- Session trackers: `work-sessions/{session-name}/workspace/session.md`
|
|
58
68
|
- Specs: `design-{topic}.md` (top of worktree)
|
|
59
69
|
- Plans: `plan-{topic}.md` (top of worktree)
|
|
60
|
-
|
|
70
|
+
|
|
71
|
+
For ephemeral content under `shared/` and `team-member/{user}/`, the filename prefix signals the type:
|
|
72
|
+
|
|
73
|
+
| Skill | Filename prefix |
|
|
74
|
+
|-------|-----------------|
|
|
75
|
+
| `/braindump` | `braindump_{topic}.md` |
|
|
76
|
+
| `/handoff` | `handoff_{topic}.md` |
|
|
77
|
+
| `/aside` (full mode, dispatches researcher) | `research_{topic}.md` |
|
|
78
|
+
| `/aside --quick` | `braindump_{topic}.md` (with `variant: aside` in frontmatter) |
|
|
79
|
+
| `/promote` | preserves source prefix |
|
|
80
|
+
| `/release` | strips prefix when locking — `shared/locked/` files use bare names since location signals the type |
|
|
81
|
+
|
|
82
|
+
Local-only personal drafts get an additional `local-only-` prefix (e.g., `local-only-braindump_x.md`) which keeps them gitignored until promoted.
|
|
61
83
|
|
|
62
84
|
## Rules
|
|
63
85
|
|
|
@@ -67,3 +89,4 @@ If a spec/plan already exists for the current session, version it: `design-{topi
|
|
|
67
89
|
- Source clones at `repos/{repo-name}/` stay on their default branch — never checkout a feature branch there.
|
|
68
90
|
- `workspace-scratchpad/` is for disposable files only — session log, hook debug output, temporary pointers.
|
|
69
91
|
- Project worktrees are nested inside the workspace worktree's real `repos/` directory — no symlink.
|
|
92
|
+
- Hand edits to `index.md`, `canonical.md`, or any per-user `team-member/{user}/index.md` are overwritten by `build-workspace-context.mjs`. Update source files (or their `description:` frontmatter) instead.
|
|
@@ -0,0 +1,365 @@
|
|
|
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
|
+
// Usage:
|
|
13
|
+
// node build-workspace-context.mjs --write [--root <workspace-root>]
|
|
14
|
+
// node build-workspace-context.mjs --check [--root <workspace-root>]
|
|
15
|
+
//
|
|
16
|
+
// --write regenerates all three artifacts.
|
|
17
|
+
// --check exits 0 if everything matches, 1 if any is stale or missing. Reports per-file status.
|
|
18
|
+
|
|
19
|
+
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, realpathSync } from 'node:fs';
|
|
20
|
+
import { join, relative, sep } from 'node:path';
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { parseSessionContent } from '../lib/session-frontmatter.mjs';
|
|
24
|
+
|
|
25
|
+
function isMainModule(metaUrl) {
|
|
26
|
+
if (!process.argv[1]) return false;
|
|
27
|
+
try {
|
|
28
|
+
return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
|
|
29
|
+
} catch { return false; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const WC_DIR = 'workspace-context';
|
|
33
|
+
const SHARED_DIR = 'shared';
|
|
34
|
+
const LOCKED_DIR = 'locked';
|
|
35
|
+
const TEAM_MEMBER_DIR = 'team-member';
|
|
36
|
+
const INDEX_FILENAME = 'index.md';
|
|
37
|
+
const CANONICAL_FILENAME = 'canonical.md';
|
|
38
|
+
const IGNORE_FILENAME = '.indexignore';
|
|
39
|
+
|
|
40
|
+
function parseArgs(argv) {
|
|
41
|
+
const args = { mode: null, root: process.cwd() };
|
|
42
|
+
for (let i = 2; i < argv.length; i++) {
|
|
43
|
+
const a = argv[i];
|
|
44
|
+
if (a === '--write') args.mode = 'write';
|
|
45
|
+
else if (a === '--check') args.mode = 'check';
|
|
46
|
+
else if (a === '--root') args.root = argv[++i];
|
|
47
|
+
}
|
|
48
|
+
if (!args.mode) throw new Error('Specify --write or --check');
|
|
49
|
+
return args;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function walkMarkdown(dir) {
|
|
53
|
+
const out = [];
|
|
54
|
+
if (!existsSync(dir)) return out;
|
|
55
|
+
for (const name of readdirSync(dir)) {
|
|
56
|
+
const full = join(dir, name);
|
|
57
|
+
const st = statSync(full);
|
|
58
|
+
if (st.isDirectory()) out.push(...walkMarkdown(full));
|
|
59
|
+
else if (st.isFile() && name.endsWith('.md')) out.push(full);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readDescription(filePath) {
|
|
65
|
+
let frontmatter = {};
|
|
66
|
+
let body = '';
|
|
67
|
+
try {
|
|
68
|
+
const parsed = parseSessionContent(readFileSync(filePath, 'utf-8'));
|
|
69
|
+
frontmatter = parsed.fields || {};
|
|
70
|
+
body = parsed.body || '';
|
|
71
|
+
} catch {
|
|
72
|
+
body = readFileSync(filePath, 'utf-8');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (typeof frontmatter.description === 'string' && frontmatter.description.trim()) {
|
|
76
|
+
return frontmatter.description.trim();
|
|
77
|
+
}
|
|
78
|
+
const stripped = body.replace(/^#.*$/m, '').trim();
|
|
79
|
+
const firstParagraph = stripped.split(/\n\s*\n/, 1)[0] || '';
|
|
80
|
+
const firstSentence = firstParagraph.replace(/\n/g, ' ').match(/[^.!?]+[.!?]/);
|
|
81
|
+
if (firstSentence) {
|
|
82
|
+
const candidate = firstSentence[0].trim();
|
|
83
|
+
if (candidate.length > 0 && candidate.length <= 200) return candidate;
|
|
84
|
+
}
|
|
85
|
+
const filename = filePath.split(sep).pop() || '';
|
|
86
|
+
return filename.replace(/\.md$/, '').replace(/^(braindump|handoff|research)_/, '').replace(/-/g, ' ');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readIgnorePrefixes(wcDir) {
|
|
90
|
+
const ignorePath = join(wcDir, IGNORE_FILENAME);
|
|
91
|
+
if (!existsSync(ignorePath)) return [];
|
|
92
|
+
return readFileSync(ignorePath, 'utf-8')
|
|
93
|
+
.split('\n')
|
|
94
|
+
.map((l) => l.replace(/#.*/, '').trim())
|
|
95
|
+
.filter((l) => l.length > 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isIgnored(relativePath, prefixes) {
|
|
99
|
+
for (const prefix of prefixes) {
|
|
100
|
+
if (relativePath === prefix) return true;
|
|
101
|
+
if (relativePath.startsWith(prefix.endsWith('/') ? prefix : prefix + '/')) return true;
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function gitIgnoredPaths(workspaceRoot, paths) {
|
|
107
|
+
if (paths.length === 0) return new Set();
|
|
108
|
+
const result = spawnSync('git', ['check-ignore', '--stdin'], {
|
|
109
|
+
cwd: workspaceRoot,
|
|
110
|
+
input: paths.join('\n'),
|
|
111
|
+
encoding: 'utf-8',
|
|
112
|
+
});
|
|
113
|
+
if (result.error || (result.status !== 0 && result.status !== 1)) return new Set();
|
|
114
|
+
return new Set(
|
|
115
|
+
result.stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function stripFrontmatter(content) {
|
|
120
|
+
if (!content.startsWith('---\n')) return content;
|
|
121
|
+
const end = content.indexOf('\n---\n', 4);
|
|
122
|
+
if (end === -1) return content;
|
|
123
|
+
return content.slice(end + 5).replace(/^\n+/, '');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function describeAndPath(filePath, wcRoot) {
|
|
127
|
+
const rel = relative(wcRoot, filePath).split(sep).join('/');
|
|
128
|
+
return { rel, description: readDescription(filePath) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------- shared index ----------
|
|
132
|
+
|
|
133
|
+
function buildSharedIndex(workspaceRoot) {
|
|
134
|
+
const wcRoot = join(workspaceRoot, WC_DIR);
|
|
135
|
+
const sharedDir = join(wcRoot, SHARED_DIR);
|
|
136
|
+
if (!existsSync(sharedDir)) return [];
|
|
137
|
+
|
|
138
|
+
const ignorePrefixes = readIgnorePrefixes(wcRoot);
|
|
139
|
+
const candidates = walkMarkdown(sharedDir);
|
|
140
|
+
const candidatePaths = candidates.map((f) => relative(workspaceRoot, f).split(sep).join('/'));
|
|
141
|
+
const gitIgnored = gitIgnoredPaths(workspaceRoot, candidatePaths);
|
|
142
|
+
|
|
143
|
+
const entries = [];
|
|
144
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
145
|
+
const f = candidates[i];
|
|
146
|
+
const relToWC = relative(wcRoot, f).split(sep).join('/');
|
|
147
|
+
if (relToWC === INDEX_FILENAME || relToWC === CANONICAL_FILENAME) continue;
|
|
148
|
+
if (isIgnored(relToWC, ignorePrefixes)) continue;
|
|
149
|
+
if (gitIgnored.has(candidatePaths[i])) continue;
|
|
150
|
+
const isLocked = relToWC.startsWith(`${SHARED_DIR}/${LOCKED_DIR}/`);
|
|
151
|
+
const { description } = describeAndPath(f, wcRoot);
|
|
152
|
+
entries.push({ rel: relToWC, isLocked, description });
|
|
153
|
+
}
|
|
154
|
+
entries.sort((a, b) => {
|
|
155
|
+
if (a.isLocked !== b.isLocked) return a.isLocked ? -1 : 1;
|
|
156
|
+
return a.rel.localeCompare(b.rel);
|
|
157
|
+
});
|
|
158
|
+
return entries;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function renderSharedIndex(entries, generatedAt) {
|
|
162
|
+
const lines = [
|
|
163
|
+
'---',
|
|
164
|
+
'type: index',
|
|
165
|
+
`generated: ${generatedAt}`,
|
|
166
|
+
'---',
|
|
167
|
+
'',
|
|
168
|
+
'# workspace-context — index',
|
|
169
|
+
'',
|
|
170
|
+
'> Auto-generated by `.claude/scripts/build-workspace-context.mjs`. Hand edits will be overwritten — update source files instead.',
|
|
171
|
+
'',
|
|
172
|
+
];
|
|
173
|
+
const locked = entries.filter((e) => e.isLocked);
|
|
174
|
+
const other = entries.filter((e) => !e.isLocked);
|
|
175
|
+
if (locked.length > 0) {
|
|
176
|
+
lines.push('## Canonical (in CLAUDE.md context verbatim)', '');
|
|
177
|
+
for (const e of locked) lines.push(`- [${e.rel}](${e.rel}) — ${e.description}`);
|
|
178
|
+
lines.push('');
|
|
179
|
+
}
|
|
180
|
+
if (other.length > 0) {
|
|
181
|
+
lines.push('## Shared', '');
|
|
182
|
+
for (const e of other) lines.push(`- [${e.rel}](${e.rel}) — ${e.description}`);
|
|
183
|
+
lines.push('');
|
|
184
|
+
}
|
|
185
|
+
if (entries.length === 0) {
|
|
186
|
+
lines.push('_(no shared workspace-context files yet)_', '');
|
|
187
|
+
}
|
|
188
|
+
return lines.join('\n');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------- canonical concat ----------
|
|
192
|
+
|
|
193
|
+
function buildCanonical(workspaceRoot) {
|
|
194
|
+
const lockedDir = join(workspaceRoot, WC_DIR, SHARED_DIR, LOCKED_DIR);
|
|
195
|
+
if (!existsSync(lockedDir)) return [];
|
|
196
|
+
return walkMarkdown(lockedDir)
|
|
197
|
+
.filter((f) => !f.endsWith('.keep'))
|
|
198
|
+
.sort()
|
|
199
|
+
.map((f) => ({
|
|
200
|
+
name: f.split(sep).pop().replace(/\.md$/, ''),
|
|
201
|
+
content: stripFrontmatter(readFileSync(f, 'utf-8')).trimEnd(),
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function renderCanonical(items, generatedAt) {
|
|
206
|
+
const lines = [
|
|
207
|
+
'---',
|
|
208
|
+
'type: canonical',
|
|
209
|
+
`generated: ${generatedAt}`,
|
|
210
|
+
'---',
|
|
211
|
+
'',
|
|
212
|
+
'# workspace-context — canonical truths',
|
|
213
|
+
'',
|
|
214
|
+
'> Auto-generated concatenation of `shared/locked/*.md`. Hand edits will be overwritten — update source files instead.',
|
|
215
|
+
'',
|
|
216
|
+
];
|
|
217
|
+
for (const item of items) {
|
|
218
|
+
lines.push(`## ${item.name}`, '', item.content, '');
|
|
219
|
+
}
|
|
220
|
+
if (items.length === 0) {
|
|
221
|
+
lines.push('_(no canonical entries yet — promote one via `/release`)_', '');
|
|
222
|
+
}
|
|
223
|
+
return lines.join('\n');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------- team-member indexes ----------
|
|
227
|
+
|
|
228
|
+
function buildTeamMemberIndex(workspaceRoot, user) {
|
|
229
|
+
const userDir = join(workspaceRoot, WC_DIR, TEAM_MEMBER_DIR, user);
|
|
230
|
+
if (!existsSync(userDir)) return [];
|
|
231
|
+
|
|
232
|
+
const candidates = walkMarkdown(userDir).filter(
|
|
233
|
+
(f) => f.split(sep).pop() !== INDEX_FILENAME,
|
|
234
|
+
);
|
|
235
|
+
const candidatePaths = candidates.map((f) => relative(workspaceRoot, f).split(sep).join('/'));
|
|
236
|
+
const gitIgnored = gitIgnoredPaths(workspaceRoot, candidatePaths);
|
|
237
|
+
|
|
238
|
+
const entries = [];
|
|
239
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
240
|
+
if (gitIgnored.has(candidatePaths[i])) continue;
|
|
241
|
+
const relToUserDir = relative(userDir, candidates[i]).split(sep).join('/');
|
|
242
|
+
const description = readDescription(candidates[i]);
|
|
243
|
+
entries.push({ rel: relToUserDir, description });
|
|
244
|
+
}
|
|
245
|
+
entries.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
246
|
+
return entries;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function renderTeamMemberIndex(user, entries, generatedAt) {
|
|
250
|
+
const lines = [
|
|
251
|
+
'---',
|
|
252
|
+
'type: index',
|
|
253
|
+
`generated: ${generatedAt}`,
|
|
254
|
+
'---',
|
|
255
|
+
'',
|
|
256
|
+
`# ${user}'s context`,
|
|
257
|
+
'',
|
|
258
|
+
'> Auto-generated by `.claude/scripts/build-workspace-context.mjs`. Hand edits will be overwritten.',
|
|
259
|
+
'',
|
|
260
|
+
];
|
|
261
|
+
for (const e of entries) {
|
|
262
|
+
lines.push(`- [${e.rel}](${e.rel}) — ${e.description}`);
|
|
263
|
+
}
|
|
264
|
+
if (entries.length === 0) {
|
|
265
|
+
lines.push('_(no personal context files yet)_');
|
|
266
|
+
}
|
|
267
|
+
return lines.join('\n') + '\n';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function listTeamMembers(workspaceRoot) {
|
|
271
|
+
const tmDir = join(workspaceRoot, WC_DIR, TEAM_MEMBER_DIR);
|
|
272
|
+
if (!existsSync(tmDir)) return [];
|
|
273
|
+
return readdirSync(tmDir)
|
|
274
|
+
.filter((name) => {
|
|
275
|
+
const full = join(tmDir, name);
|
|
276
|
+
return statSync(full).isDirectory();
|
|
277
|
+
})
|
|
278
|
+
.sort();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---------- orchestration ----------
|
|
282
|
+
|
|
283
|
+
function fingerprint(content) {
|
|
284
|
+
return content
|
|
285
|
+
.split('\n')
|
|
286
|
+
.filter((l) => !l.startsWith('generated:'))
|
|
287
|
+
.join('\n');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function regenerateAll(workspaceRoot, generatedAt) {
|
|
291
|
+
const wcRoot = join(workspaceRoot, WC_DIR);
|
|
292
|
+
if (!existsSync(wcRoot)) return [];
|
|
293
|
+
|
|
294
|
+
const out = [];
|
|
295
|
+
const sharedEntries = buildSharedIndex(workspaceRoot);
|
|
296
|
+
out.push({
|
|
297
|
+
path: join(wcRoot, INDEX_FILENAME),
|
|
298
|
+
label: 'index.md',
|
|
299
|
+
content: renderSharedIndex(sharedEntries, generatedAt) + '\n',
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const canonicalItems = buildCanonical(workspaceRoot);
|
|
303
|
+
out.push({
|
|
304
|
+
path: join(wcRoot, CANONICAL_FILENAME),
|
|
305
|
+
label: 'canonical.md',
|
|
306
|
+
content: renderCanonical(canonicalItems, generatedAt) + '\n',
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
for (const user of listTeamMembers(workspaceRoot)) {
|
|
310
|
+
const entries = buildTeamMemberIndex(workspaceRoot, user);
|
|
311
|
+
out.push({
|
|
312
|
+
path: join(wcRoot, TEAM_MEMBER_DIR, user, INDEX_FILENAME),
|
|
313
|
+
label: `team-member/${user}/index.md`,
|
|
314
|
+
content: renderTeamMemberIndex(user, entries, generatedAt),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function main() {
|
|
322
|
+
const args = parseArgs(process.argv);
|
|
323
|
+
const generatedAt = new Date().toISOString();
|
|
324
|
+
const artifacts = regenerateAll(args.root, generatedAt);
|
|
325
|
+
|
|
326
|
+
if (args.mode === 'check') {
|
|
327
|
+
const stale = [];
|
|
328
|
+
const missing = [];
|
|
329
|
+
for (const a of artifacts) {
|
|
330
|
+
if (!existsSync(a.path)) { missing.push(a.label); continue; }
|
|
331
|
+
const onDisk = readFileSync(a.path, 'utf-8');
|
|
332
|
+
if (fingerprint(onDisk) !== fingerprint(a.content)) stale.push(a.label);
|
|
333
|
+
}
|
|
334
|
+
if (missing.length === 0 && stale.length === 0) {
|
|
335
|
+
process.stdout.write(JSON.stringify({ status: 'current', artifacts: artifacts.length }) + '\n');
|
|
336
|
+
process.exit(0);
|
|
337
|
+
}
|
|
338
|
+
process.stdout.write(JSON.stringify({ status: 'stale', missing, stale }) + '\n');
|
|
339
|
+
process.exit(1);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (args.mode === 'write') {
|
|
343
|
+
for (const a of artifacts) writeFileSync(a.path, a.content);
|
|
344
|
+
process.stdout.write(
|
|
345
|
+
JSON.stringify({ status: 'written', artifacts: artifacts.map((a) => a.label) }) + '\n',
|
|
346
|
+
);
|
|
347
|
+
process.exit(0);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (isMainModule(import.meta.url)) main();
|
|
352
|
+
|
|
353
|
+
export {
|
|
354
|
+
buildSharedIndex,
|
|
355
|
+
renderSharedIndex,
|
|
356
|
+
buildCanonical,
|
|
357
|
+
renderCanonical,
|
|
358
|
+
buildTeamMemberIndex,
|
|
359
|
+
renderTeamMemberIndex,
|
|
360
|
+
listTeamMembers,
|
|
361
|
+
regenerateAll,
|
|
362
|
+
fingerprint,
|
|
363
|
+
readDescription,
|
|
364
|
+
stripFrontmatter,
|
|
365
|
+
};
|