@ulysses-ai/create-workspace 0.16.0-beta.1 → 0.18.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.
Files changed (53) hide show
  1. package/README.md +5 -5
  2. package/lib/init.mjs +19 -0
  3. package/package.json +1 -1
  4. package/template/.claude/hooks/_utils.mjs +1 -1
  5. package/template/.claude/hooks/repo-write-detection.mjs +161 -64
  6. package/template/.claude/hooks/session-end.mjs +68 -2
  7. package/template/.claude/hooks/session-start.mjs +35 -1
  8. package/template/.claude/hooks/subagent-start.mjs +89 -22
  9. package/template/.claude/lib/session-frontmatter.mjs +28 -0
  10. package/template/.claude/rules/coherent-revisions.md +1 -1
  11. package/template/.claude/rules/config-review.md.skip +29 -0
  12. package/template/.claude/rules/forge-operations.md +51 -0
  13. package/template/.claude/rules/git-conventions.md +16 -11
  14. package/template/.claude/rules/goal-driven-work.md +8 -403
  15. package/template/.claude/rules/honest-pushback.md +37 -37
  16. package/template/.claude/rules/memory-guidance.md +43 -90
  17. package/template/.claude/rules/superpowers-workflow.md.skip +1 -1
  18. package/template/.claude/rules/work-item-tracking.md +30 -72
  19. package/template/.claude/rules/workspace-structure.md +49 -69
  20. package/template/.claude/scripts/build-workspace-context.mjs +61 -16
  21. package/template/.claude/scripts/chat-record.mjs +282 -0
  22. package/template/.claude/scripts/cleanup-work-session.mjs +363 -36
  23. package/template/.claude/scripts/context-footprint.mjs +282 -0
  24. package/template/.claude/scripts/forges/github.mjs +255 -0
  25. package/template/.claude/scripts/forges/gitlab.mjs +20 -0
  26. package/template/.claude/scripts/forges/interface.mjs +125 -0
  27. package/template/.claude/scripts/generate-claude-local.mjs +21 -2
  28. package/template/.claude/scripts/migrate-sessions.mjs +1571 -0
  29. package/template/.claude/scripts/migrate-to-workspace-context.mjs +7 -2
  30. package/template/.claude/scripts/task-worktree.mjs +525 -0
  31. package/template/.claude/scripts/workspace-diagnostics.mjs +654 -0
  32. package/template/.claude/settings.json +5 -13
  33. package/template/.claude/skills/braindump/SKILL.md +11 -4
  34. package/template/.claude/skills/build-docs-site/SKILL.md +5 -5
  35. package/template/.claude/skills/build-docs-site/templates/spec.md.tmpl +1 -1
  36. package/template/.claude/skills/complete-work/SKILL.md +255 -215
  37. package/template/.claude/skills/context-placement/SKILL.md +199 -0
  38. package/template/.claude/skills/goal-driven-work/SKILL.md +459 -0
  39. package/template/.claude/skills/handoff/SKILL.md +11 -4
  40. package/template/.claude/skills/maintenance/SKILL.md +39 -6
  41. package/template/.claude/skills/migrate-sessions/SKILL.md +70 -0
  42. package/template/.claude/skills/pause-work/SKILL.md +33 -8
  43. package/template/.claude/skills/release/SKILL.md +44 -108
  44. package/template/.claude/skills/start-work/SKILL.md +89 -7
  45. package/template/.claude/skills/workspace-init/SKILL.md +34 -0
  46. package/template/.claude/skills/workspace-update/SKILL.md +4 -0
  47. package/template/.claudeignore +3 -0
  48. package/template/CLAUDE.md.tmpl +20 -2
  49. package/template/CODEBASE.md.tmpl +13 -0
  50. package/template/_gitignore +9 -0
  51. package/template/repo-claude.md.tmpl +10 -0
  52. package/template/workspace.json.tmpl +5 -3
  53. package/template/.claude/hooks/worktree-create.mjs +0 -53
package/README.md CHANGED
@@ -74,11 +74,11 @@ Rules say what's safe. Skills say how to do the recurring things. Hooks notice w
74
74
 
75
75
  Four things, in the order you'll touch them:
76
76
 
77
- 1. **A workflow lifecycle that survives chat boundaries.** `/start-work` provisions a session — branch, worktree, tracker — atomically. `/pause-work` and `/sync-work` checkpoint mid-stream. `/complete-work` rebases, synthesizes release notes, opens PRs, and tears down. The same session resumes cleanly in a fresh chat.
77
+ 1. **A workflow lifecycle that survives chat boundaries.** `/start-work` provisions a session — branch, worktree, tracker — atomically. `/pause-work` and `/sync-work` checkpoint mid-stream. `/complete-work` rebases, opens PRs, merges, and tears down. The same session resumes cleanly in a fresh chat.
78
78
 
79
79
  2. **Parallel work sessions you can run from separate terminals.** Each session lives in its own folder under `work-sessions/{name}/` with its own workspace worktree and nested project worktrees. Two sessions can't collide on a branch or a working directory.
80
80
 
81
- 3. **Multi-repo support with versioning across repos.** A workspace wraps your project repos rather than replacing them. Each session can span one repo or many. `/release` synthesizes versioned release docs across the repos that contributed.
81
+ 3. **Multi-repo support with versioning across repos.** A workspace wraps your project repos rather than replacing them. Each session can span one repo or many. `/release` cuts a versioned release per repo — bump, tag, forge release with notes generated from merged PRs.
82
82
 
83
83
  4. **Shared context with a locked layer that stays in the window.** `shared-context/locked/` is loaded every turn and injected into subagents. Team truths arrive in the model's context window without anyone remembering to paste them.
84
84
 
@@ -88,9 +88,9 @@ Four things, in the order you'll touch them:
88
88
 
89
89
  A scaffolded workspace with:
90
90
 
91
- - **14 skills** covering the workflow lifecycle, releases, handoffs, and maintenance
92
- - **8 active rules** + **8 optional `.skip` rules** for behaviors you can opt into
93
- - **10 hooks** for SessionStart, SubagentStart, PreCompact, WorktreeCreate, and the rest of the small set the conventions rely on
91
+ - **17 skills** covering the workflow lifecycle, releases, handoffs, and maintenance
92
+ - **9 active rules** + **9 optional `.skip` rules** for behaviors you can opt into
93
+ - **9 hooks** for SessionStart, SubagentStart, PreCompact, and the rest of the small set the conventions rely on
94
94
  - A **`shared-context/`** memory system with three visibility levels: locked (team truths), root (team-visible ephemerals), user-scoped (personal)
95
95
  - Conventions for **multi-repo work sessions** with isolated git worktrees, parallelizable from separate terminals
96
96
 
package/lib/init.mjs CHANGED
@@ -105,6 +105,25 @@ export async function initWorkspace(targetDir) {
105
105
  }
106
106
  }
107
107
 
108
+ // Set up .claudeignore
109
+ const payloadClaudeignore = join(payloadDir, '.claudeignore');
110
+ const claudeignorePath = join(targetDir, '.claudeignore');
111
+ if (existsSync(payloadClaudeignore)) {
112
+ if (existsSync(claudeignorePath)) {
113
+ const existing = readFileSync(claudeignorePath, 'utf-8');
114
+ const template = readFileSync(payloadClaudeignore, 'utf-8');
115
+ const existingLines = new Set(existing.split('\n').map(l => l.trim()));
116
+ const newLines = template.split('\n').filter(l => l.trim() && !existingLines.has(l.trim()));
117
+ if (newLines.length > 0) {
118
+ writeFileSync(claudeignorePath, existing.trimEnd() + '\n\n# From workspace template\n' + newLines.join('\n') + '\n');
119
+ console.log(' Merged template entries into .claudeignore');
120
+ }
121
+ } else {
122
+ cpSync(payloadClaudeignore, claudeignorePath);
123
+ console.log(' Created .claudeignore');
124
+ }
125
+ }
126
+
108
127
  console.log(`
109
128
  Workspace initialized (v${toVersion}).
110
129
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulysses-ai/create-workspace",
3
- "version": "0.16.0-beta.1",
3
+ "version": "0.18.0-beta.0",
4
4
  "description": "A workspace convention for Claude Code: sessions, handoffs, and shared context as files in git",
5
5
  "keywords": [
6
6
  "claude",
@@ -150,7 +150,7 @@ export function createSessionTracker(root, sessionName, fields, body) {
150
150
 
151
151
  /**
152
152
  * Delete the entire work-sessions/{name}/ folder. Used by /complete-work
153
- * after the session is finalized and archived into release notes.
153
+ * after the session is finalized and its artifacts promoted or discarded.
154
154
  * Caller is responsible for any git bookkeeping (branch deletes, prunes).
155
155
  */
156
156
  export function deleteSessionFolder(root, sessionName) {
@@ -6,7 +6,11 @@
6
6
  // Workspace worktree: work-sessions/{name}/workspace/
7
7
  // Project worktree: work-sessions/{name}/workspace/repos/{repo}/
8
8
  // Bare clone: repos/{repo}/ (at workspace root)
9
- import { join, basename } from 'path';
9
+ // Task worktree: repos/{repo}/.claude/worktrees/{slug}/ (gh:132)
10
+ // Workspace task worktree: {root}/.claude/worktrees/{slug}/ (gh:146)
11
+ import { join, basename, resolve, relative, sep, isAbsolute, dirname } from 'path';
12
+ import { realpathSync } from 'fs';
13
+ import { fileURLToPath } from 'url';
10
14
  import {
11
15
  getWorkspaceRoot,
12
16
  readStdin,
@@ -17,91 +21,184 @@ import {
17
21
  getWorkspacePaths,
18
22
  } from './_utils.mjs';
19
23
 
20
- const root = getWorkspaceRoot(import.meta.url);
21
- const input = await readStdin();
22
- const toolName = input.tool_name || '';
24
+ // .native resolves Windows 8.3 short names; the plain fallback covers
25
+ // filesystems where the native binding is unavailable.
26
+ function realPath(p) {
27
+ try { return realpathSync.native(p); } catch { /* fall through */ }
28
+ try { return realpathSync(p); } catch { /* fall through */ }
29
+ return resolve(p);
30
+ }
23
31
 
24
- if (!['Bash', 'Edit', 'Write'].includes(toolName)) {
25
- respond();
26
- process.exit(0);
32
+ // Realpaths BOTH sides: Node resolves the module URL through symlinks
33
+ // (import.meta.url is the real path) while argv[1] stays exactly as the
34
+ // host passed it — a bare comparison silently disables the hook whenever
35
+ // the hook is reached through a symlinked workspace.
36
+ function isMainModule(metaUrl) {
37
+ if (!process.argv[1]) return false;
38
+ try {
39
+ return realPath(fileURLToPath(metaUrl)) === realPath(process.argv[1]);
40
+ } catch { return false; }
27
41
  }
28
42
 
29
- const toolInput = input.tool_input || {};
30
- const paths = [toolInput.file_path, toolInput.command, toolInput.path]
31
- .filter(Boolean)
32
- .join(' ')
33
- .replace(/\\/g, '/');
34
-
35
- // If we're in a workspace worktree, check for out-of-session repo writes
36
- const pointer = getActiveSessionPointer(root);
37
- if (pointer) {
38
- const mainRoot = pointer.rootPath || root;
39
- const config = readJSON(join(mainRoot, 'workspace.json'));
40
- const tracker = readSessionTracker(mainRoot, pointer.name);
41
-
42
- if (tracker && config?.repos) {
43
- // Find references to repos inside work-sessions/{name}/workspace/repos/{repo}/
44
- // and also the workspace-root repos/{repo}/ for direct writes.
45
- const wtMatch = paths.match(/work-sessions\/[^/\s]+\/workspace\/repos\/([^/\s]+)/);
46
- const cloneMatch = paths.match(/(?:^|\s|\/)repos\/([^/\s]+)/);
47
- const targetRepo = wtMatch ? wtMatch[1] : (cloneMatch ? cloneMatch[1] : null);
48
- if (targetRepo) {
49
- const sessionRepos = tracker.repos || [];
50
- if (config.repos[targetRepo] && !sessionRepos.includes(targetRepo)) {
51
- respond(`You're about to write to ${targetRepo}, which isn't part of this session. Consider adding it first so changes land on the session branch.`);
52
- process.exit(0);
53
- }
54
- }
43
+ // realpath the deepest existing ancestor and reattach the missing tail:
44
+ // the file being written may not exist yet, but the worktree directory it
45
+ // lands in does. When no ancestor exists at all, no symlink can be in
46
+ // play, so the resolved input is returned as-is.
47
+ function realPathDeepest(p) {
48
+ let cur = p;
49
+ const tail = [];
50
+ for (;;) {
51
+ try { return join(realpathSync.native(cur), ...tail); } catch { /* climb */ }
52
+ const parent = dirname(cur);
53
+ if (parent === cur) return resolve(p);
54
+ tail.unshift(basename(cur));
55
+ cur = parent;
55
56
  }
57
+ }
56
58
 
57
- respond();
58
- process.exit(0);
59
+ // relative() output is "inside" when it is a plain descent — not '', '..',
60
+ // a '..'-prefixed climb, or a cross-volume absolute path (Windows drives).
61
+ function isDescent(rel) {
62
+ return rel !== '' && rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
59
63
  }
60
64
 
61
- // We're at the main workspace root — restrict writes
65
+ /**
66
+ * True when filePath sits inside repos/{repo}/.claude/worktrees/{slug}/ —
67
+ * the task model's project work area (gh:132) — or inside
68
+ * {root}/.claude/worktrees/{slug}/, the workspace repo's own task
69
+ * worktrees (gh:146). Task chats run at the workspace root and edit there
70
+ * by design, so these writes are legitimate and must not trip the "you're
71
+ * on main" repo/template warnings. The worktrees directory itself and any
72
+ * other {root}/.claude/... path stay warnable.
73
+ *
74
+ * Pure path arithmetic (realpath + relative + segment split), so it is
75
+ * sep-safe on Windows, symlink-safe on macOS, and never stats the file
76
+ * itself.
77
+ */
78
+ export function isTaskWorktreeWrite(root, filePath) {
79
+ if (!root || !filePath) return false;
80
+ const rootReal = realPath(resolve(root));
81
+ const fileReal = realPathDeepest(resolve(filePath));
62
82
 
63
- const { scratchpadDir } = getWorkspacePaths(root);
64
- const scratchpadName = scratchpadDir.slice(root.length + 1); // "workspace-scratchpad"
83
+ // The workspace repo's own worktrees: {root}/.claude/worktrees/{slug}/…
84
+ const relRoot = relative(rootReal, fileReal);
85
+ if (isDescent(relRoot)) {
86
+ const rootParts = relRoot.split(sep);
87
+ if (rootParts.length >= 3 && rootParts[0] === '.claude' && rootParts[1] === 'worktrees') return true;
88
+ }
65
89
 
66
- // Allow writes to the workspace scratchpad
67
- if (paths.includes(scratchpadName)) {
68
- respond();
69
- process.exit(0);
90
+ // Project worktrees: repos/{repo}/.claude/worktrees/{slug}/…
91
+ const rel = relative(realPath(join(rootReal, 'repos')), fileReal);
92
+ if (!isDescent(rel)) return false;
93
+ const parts = rel.split(sep);
94
+ return parts.length >= 4 && parts[1] === '.claude' && parts[2] === 'worktrees';
70
95
  }
71
96
 
72
- // Allow writes to local-only-* files
73
- const filePathArg = toolInput.file_path || '';
74
- if (basename(filePathArg).startsWith('local-only-')) {
75
- respond();
76
- process.exit(0);
77
- }
97
+ async function main() {
98
+ const root = getWorkspaceRoot(import.meta.url);
99
+ const input = await readStdin();
100
+ const toolName = input.tool_name || '';
78
101
 
79
- // For Bash commands, check if the command targets allowed paths
80
- if (toolName === 'Bash') {
81
- const cmd = toolInput.command || '';
82
- if (/^\s*(git|ls|cat|head|tail|grep|rg|find|echo|pwd|cd|which|node\s+-c)\b/.test(cmd)) {
102
+ if (!['Bash', 'Edit', 'Write'].includes(toolName)) {
83
103
  respond();
84
104
  process.exit(0);
85
105
  }
86
- if (cmd.includes(scratchpadName) || cmd.includes('local-only-')) {
106
+
107
+ const toolInput = input.tool_input || {};
108
+ const paths = [toolInput.file_path, toolInput.command, toolInput.path]
109
+ .filter(Boolean)
110
+ .join(' ')
111
+ .replace(/\\/g, '/');
112
+
113
+ // If we're in a workspace worktree, check for out-of-session repo writes
114
+ const pointer = getActiveSessionPointer(root);
115
+ if (pointer) {
116
+ const mainRoot = pointer.rootPath || root;
117
+ const config = readJSON(join(mainRoot, 'workspace.json'));
118
+ const tracker = readSessionTracker(mainRoot, pointer.name);
119
+
120
+ if (tracker && config?.repos) {
121
+ // Find references to repos inside work-sessions/{name}/workspace/repos/{repo}/
122
+ // and also the workspace-root repos/{repo}/ for direct writes.
123
+ const wtMatch = paths.match(/work-sessions\/[^/\s]+\/workspace\/repos\/([^/\s]+)/);
124
+ const cloneMatch = paths.match(/(?:^|\s|\/)repos\/([^/\s]+)/);
125
+ const targetRepo = wtMatch ? wtMatch[1] : (cloneMatch ? cloneMatch[1] : null);
126
+ if (targetRepo) {
127
+ const sessionRepos = tracker.repos || [];
128
+ if (config.repos[targetRepo] && !sessionRepos.includes(targetRepo)) {
129
+ respond(`You're about to write to ${targetRepo}, which isn't part of this session. Consider adding it first so changes land on the session branch.`);
130
+ process.exit(0);
131
+ }
132
+ }
133
+ }
134
+
87
135
  respond();
88
136
  process.exit(0);
89
137
  }
90
- // Allow helper script invocations from the workspace root
91
- if (/node\s+.*\.claude\/scripts\//.test(cmd)) {
138
+
139
+ // We're at the main workspace root — restrict writes
140
+
141
+ const { scratchpadDir } = getWorkspacePaths(root);
142
+ const scratchpadName = scratchpadDir.slice(root.length + 1); // "workspace-scratchpad"
143
+
144
+ // Allow writes to the workspace scratchpad
145
+ if (paths.includes(scratchpadName)) {
92
146
  respond();
93
147
  process.exit(0);
94
148
  }
95
- }
96
149
 
97
- // Check if this write targets repos/, workspace-context/, work-sessions/, or template files
98
- const isRepoWrite = /(?:^|[\s/])repos\//.test(paths) || paths.includes('work-sessions/');
99
- const isContextWrite = paths.includes('workspace-context/') && !basename(filePathArg).startsWith('local-only-');
100
- const isTemplateWrite = paths.includes('.claude/') && !paths.includes(scratchpadName);
150
+ // Allow writes to local-only-* files
151
+ const filePathArg = toolInput.file_path || '';
152
+ if (basename(filePathArg).startsWith('local-only-')) {
153
+ respond();
154
+ process.exit(0);
155
+ }
101
156
 
102
- if (isRepoWrite || isContextWrite || isTemplateWrite) {
103
- respond("You're on main. All work should happen in a workspace worktree. Run /start-work to create or resume a work session.");
104
- process.exit(0);
157
+ // Task-model writes: repos/{repo}/.claude/worktrees/{slug}/ is the task
158
+ // model's work area at the root (gh:132). Those tokens are stripped from
159
+ // what the warning checks judge — an Edit/Write inside a worktree leaves
160
+ // nothing to judge and is exempt, while a Bash command that ALSO touches
161
+ // the source clone or template files is judged on, and warned about,
162
+ // everything else it names.
163
+ const taskTokens = toolName === 'Bash'
164
+ ? String(toolInput.command || '').split(/\s+/).map((t) => t.replace(/^["']+|["']+$/g, ''))
165
+ : [toolInput.file_path, toolInput.path].filter(Boolean);
166
+ const judged = taskTokens
167
+ .filter((t) => !isTaskWorktreeWrite(root, t))
168
+ .join(' ')
169
+ .replace(/\\/g, '/');
170
+
171
+ // For Bash commands, check if the command targets allowed paths
172
+ if (toolName === 'Bash') {
173
+ const cmd = toolInput.command || '';
174
+ if (/^\s*(git|ls|cat|head|tail|grep|rg|find|echo|pwd|cd|which|node\s+-c)\b/.test(cmd)) {
175
+ respond();
176
+ process.exit(0);
177
+ }
178
+ if (cmd.includes(scratchpadName) || cmd.includes('local-only-')) {
179
+ respond();
180
+ process.exit(0);
181
+ }
182
+ // Allow helper script invocations from the workspace root
183
+ if (/node\s+.*\.claude\/scripts\//.test(cmd)) {
184
+ respond();
185
+ process.exit(0);
186
+ }
187
+ }
188
+
189
+ // Check if this write targets repos/, workspace-context/, work-sessions/, or template files
190
+ const isRepoWrite = /(?:^|[\s/])repos\//.test(judged) || judged.includes('work-sessions/');
191
+ const isContextWrite = judged.includes('workspace-context/') && !basename(filePathArg).startsWith('local-only-');
192
+ const isTemplateWrite = judged.includes('.claude/') && !judged.includes(scratchpadName);
193
+
194
+ if (isRepoWrite || isContextWrite || isTemplateWrite) {
195
+ respond("You're on main. All work should happen in a workspace worktree. Run /start-work to create or resume a work session.");
196
+ process.exit(0);
197
+ }
198
+
199
+ respond();
105
200
  }
106
201
 
107
- respond();
202
+ if (isMainModule(import.meta.url)) {
203
+ await main();
204
+ }
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // SessionEnd hook — mark this chat's `ended` timestamp in the session
3
- // tracker and append a small safety-net note to the session.md body.
4
- import { appendFileSync, mkdirSync, existsSync } from 'fs';
3
+ // tracker, append a small safety-net note to the session.md body, and
4
+ // write a disk-durable reflection record if the session.md ## Progress
5
+ // section contains heuristic correction-pattern sentences.
6
+ import { appendFileSync, mkdirSync, existsSync, readFileSync, writeFileSync } from 'fs';
5
7
  import { join } from 'path';
6
8
  import { execSync } from 'child_process';
7
9
  import {
@@ -71,6 +73,70 @@ if (pointer && sessionId) {
71
73
  } catch {
72
74
  // Non-fatal
73
75
  }
76
+
77
+ // Disk-durable reflection sub-step (BP-10).
78
+ // Read the ## Progress section of session.md (last 2000 chars max),
79
+ // scan for heuristic correction-pattern sentences, and write any
80
+ // candidates to workspace-scratchpad/session-reflect.json. The file
81
+ // is gitignored (workspace-scratchpad/ is in _gitignore). We do NOT
82
+ // emit additionalContext for this — per the canonical known limitation,
83
+ // additionalContext does not always reach Claude. Disk is the primary
84
+ // durable output.
85
+ try {
86
+ const sessionContent = readFileSync(trackerPath, 'utf8');
87
+
88
+ // Extract ## Progress section — find the section and take last 2000 chars.
89
+ const progressMatch = sessionContent.match(/^## Progress\s*\n([\s\S]*?)(?=\n^##|\s*$)/m);
90
+ const progressText = progressMatch
91
+ ? progressMatch[1].slice(-2000)
92
+ : sessionContent.slice(-2000);
93
+
94
+ // Split into sentences on '. ' or '.\n' boundaries.
95
+ const sentences = progressText
96
+ .split(/(?<=\.)\s+|\n/)
97
+ .map(s => s.trim())
98
+ .filter(s => s.length > 10);
99
+
100
+ // Correction-pattern keywords — case-insensitive.
101
+ const correctionPatterns = [
102
+ /actually/i,
103
+ /instead of/i,
104
+ /the right way is/i,
105
+ /i was wrong/i,
106
+ /correction:/i,
107
+ ];
108
+
109
+ const candidates = sentences
110
+ .filter(sentence => correctionPatterns.some(re => re.test(sentence)))
111
+ .map(text => ({ text, source: '## Progress' }));
112
+
113
+ if (candidates.length > 0) {
114
+ if (!existsSync(scratchpadDir)) mkdirSync(scratchpadDir, { recursive: true });
115
+ const reflectPath = join(scratchpadDir, 'session-reflect.json');
116
+
117
+ // Read existing records to append (create-or-append pattern).
118
+ let records = [];
119
+ if (existsSync(reflectPath)) {
120
+ try {
121
+ records = JSON.parse(readFileSync(reflectPath, 'utf8'));
122
+ if (!Array.isArray(records)) records = [records];
123
+ } catch {
124
+ records = [];
125
+ }
126
+ }
127
+
128
+ records.push({
129
+ sessionId: sessionId || `ts-${Date.now()}`,
130
+ date: new Date().toISOString(),
131
+ workSession: pointer.name,
132
+ candidates,
133
+ });
134
+
135
+ writeFileSync(reflectPath, JSON.stringify(records, null, 2), 'utf8');
136
+ }
137
+ } catch {
138
+ // Non-fatal — reflection is best-effort.
139
+ }
74
140
  }
75
141
  }
76
142
  }
@@ -16,6 +16,7 @@ import {
16
16
  sessionFolderPath,
17
17
  timeAgo,
18
18
  } from './_utils.mjs';
19
+ import { reconcile, readSessionRegistry, resolveChatName } from '../scripts/chat-record.mjs';
19
20
 
20
21
  const root = getWorkspaceRoot(import.meta.url);
21
22
  const input = await readStdin();
@@ -34,6 +35,40 @@ if (!config) {
34
35
 
35
36
  lines.push(`Workspace: ${config.workspace?.name || 'unnamed'}`);
36
37
 
38
+ // Keep this chat's record in step with its identity (gh:132). The record is
39
+ // keyed on sessionId and filed under the chat name, so a rename moves the file
40
+ // and its drawer rather than orphaning them.
41
+ //
42
+ // No live set is passed, deliberately: the registry lists running processes,
43
+ // not chats that exist, so a chat the operator merely closed is
44
+ // indistinguishable from one that is gone. Pruning here would delete the scope
45
+ // and open tasks of every chat not currently open.
46
+ if (chatId) {
47
+ try {
48
+ const registry = readSessionRegistry();
49
+ const me = registry.find((r) => r.sessionId === chatId);
50
+ // The registry only knows names for chats it has seen. An unnamed chat
51
+ // keeps whatever name its record already carries rather than being
52
+ // relabeled with its raw UUID; only a truly new chat falls back to the id.
53
+ const name = resolveChatName(root, { sessionId: chatId, registryName: me?.name });
54
+ const res = reconcile(root, { sessionId: chatId, name });
55
+ if (res.renamed) {
56
+ lines.push(`Chat renamed: ${res.renamed.from} -> ${res.renamed.to} (record and drawer moved)`);
57
+ }
58
+ // Name the record so a skill can find its own chat's state without
59
+ // guessing (gh:132) — /start-work's task flow keys off this line.
60
+ lines.push(`Chat record: ${name}`);
61
+ } catch {
62
+ // The chat record is a convenience, not a precondition for a session.
63
+ // A failure here must never stop Claude from starting.
64
+ }
65
+
66
+ // Name the workspace root so skills can address it without deriving it
67
+ // from git internals — which resolve to the source clone, not the
68
+ // launcher, when a chat runs from inside a task worktree (gh:132).
69
+ lines.push(`Workspace root: ${root}`);
70
+ }
71
+
37
72
  // If we're inside a workspace worktree, its .claude/.active-session.json
38
73
  // tells us which session this is.
39
74
  const pointer = getActiveSessionPointer(root);
@@ -117,7 +152,6 @@ if (trackers.length > 0) {
117
152
  // Surface team-shared workspace context (secondary)
118
153
  // Only scan shared/ — locked/ is now a sub-dir of shared/ and is included
119
154
  // naturally. team-member/ is per-user (loaded via CLAUDE.local.md).
120
- // release-notes/ is operational, not knowledge.
121
155
  if (existsSync(sharedDir)) {
122
156
  const entries = [];
123
157
 
@@ -1,44 +1,111 @@
1
1
  #!/usr/bin/env node
2
- // SubagentStart hook — inject workspace-context/shared/locked/ into subagent context
3
- import { readdirSync, readFileSync, existsSync } from 'fs';
4
- import { join, basename } from 'path';
2
+ // SubagentStart hook — give subagents the workspace's canonical truths.
3
+ //
4
+ // Subagents do not load CLAUDE.md, so without this they start with no team context.
5
+ // They do, however, get a full model context window and their own file tools, so the
6
+ // right shape is not "paste everything" — it is: inline the short constraints that
7
+ // should frame every task, and hand over a pointer for the long reference material.
8
+ // That follows the just-in-time guidance and keeps the injection stable as canon grows.
9
+ import { readdirSync, readFileSync, existsSync, statSync } from 'fs';
10
+ import { join, basename, relative, sep } from 'path';
5
11
  import { getWorkspaceRoot, readJSON, respond } from './_utils.mjs';
12
+ import {
13
+ readDescription,
14
+ gitIgnoredPaths,
15
+ stripFrontmatter,
16
+ isLocalOnlyName,
17
+ } from '../scripts/build-workspace-context.mjs';
6
18
 
7
19
  const root = getWorkspaceRoot(import.meta.url);
8
20
  const config = readJSON(join(root, 'workspace.json'));
21
+ const lockedRel = 'workspace-context/shared/locked';
9
22
  const lockedDir = join(root, 'workspace-context', 'shared', 'locked');
10
23
 
11
- const maxBytes = config?.workspace?.subagentContextMaxBytes || 10240;
24
+ // Per-file ceiling for inlining. Files above it become pointers regardless of headroom,
25
+ // so one long document cannot crowd out every short constraint.
26
+ const inlineMax = config?.workspace?.subagentInlineMaxBytes || 8192;
27
+ // Total ceiling as a backstop. Overflow demotes the largest inlined files to pointers.
28
+ const totalMax = config?.workspace?.subagentContextMaxBytes || 32768;
12
29
 
13
30
  if (!existsSync(lockedDir)) {
14
31
  respond();
15
32
  process.exit(0);
16
33
  }
17
34
 
18
- const files = readdirSync(lockedDir)
19
- .filter(f => f.endsWith('.md') && f !== '.keep')
20
- .sort();
21
-
22
- if (files.length === 0) {
35
+ let names = [];
36
+ try {
37
+ names = readdirSync(lockedDir).filter((f) => f.endsWith('.md') && f !== '.keep').sort();
38
+ } catch {
23
39
  respond();
24
40
  process.exit(0);
25
41
  }
26
42
 
27
- let context = '';
28
- for (const file of files) {
29
- const name = basename(file, '.md');
30
- const content = readFileSync(join(lockedDir, file), 'utf-8');
31
- context += `\n--- ${name} ---\n${content}\n`;
43
+ // canonical.md excludes gitignored files; this path must match it. Without the filter a
44
+ // local-only-*.md dropped into shared/locked/ is broadcast to every subagent.
45
+ const relPaths = names.map((n) => relative(root, join(lockedDir, n)).split(sep).join('/'));
46
+ let ignored = new Set();
47
+ try {
48
+ ignored = gitIgnoredPaths(root, relPaths);
49
+ } catch {
50
+ /* filter unavailable — fall through with nothing ignored */
51
+ }
52
+
53
+ const entries = [];
54
+ for (let i = 0; i < names.length; i++) {
55
+ // gitIgnoredPaths fails open when git is unavailable; local-only-* is excluded by
56
+ // name as well so a non-git workspace cannot broadcast a private file to subagents.
57
+ if (ignored.has(relPaths[i]) || isLocalOnlyName(names[i])) continue;
58
+ const file = join(lockedDir, names[i]);
59
+ let content = '';
60
+ let size = 0;
61
+ try {
62
+ content = readFileSync(file, 'utf-8');
63
+ size = statSync(file).size;
64
+ } catch {
65
+ continue;
66
+ }
67
+ entries.push({
68
+ name: basename(names[i], '.md'),
69
+ path: `${lockedRel}/${names[i]}`,
70
+ description: readDescription(file),
71
+ content,
72
+ size,
73
+ inline: size <= inlineMax,
74
+ });
75
+ }
76
+
77
+ if (entries.length === 0) {
78
+ respond();
79
+ process.exit(0);
32
80
  }
33
81
 
34
- if (Buffer.byteLength(context) > maxBytes) {
35
- const summary = files.map(f => {
36
- const content = readFileSync(join(lockedDir, f), 'utf-8');
37
- const firstLine = content.split('\n').find(l => l.trim() && !l.startsWith('---'))?.replace(/^#*\s*/, '') || '';
38
- return `- ${basename(f, '.md')}: ${firstLine}`;
39
- }).join('\n');
82
+ const render = () => {
83
+ const inlined = entries.filter((e) => e.inline);
84
+ const pointers = entries.filter((e) => !e.inline);
85
+ const parts = [];
86
+ if (inlined.length > 0) {
87
+ parts.push('Canonical workspace context (team truths that apply to every task):');
88
+ for (const e of inlined) parts.push(`\n--- ${e.name} (${e.path}) ---\n${stripFrontmatter(e.content).trim()}`);
89
+ }
90
+ if (pointers.length > 0) {
91
+ parts.push(
92
+ '\nAlso canonical, not inlined here — read the file if your task touches it:',
93
+ ...pointers.map((e) => `- ${e.name} — ${e.description} [${e.path}, ${e.size} bytes]`),
94
+ );
95
+ }
96
+ return parts.join('\n');
97
+ };
40
98
 
41
- context = `[Locked shared context exceeds ${maxBytes} byte limit (${Buffer.byteLength(context)} bytes). Summary of ${files.length} files:]\n${summary}\n[Read individual files from workspace-context/shared/locked/ if you need full content.]`;
99
+ // Demote largest-first until the total fits. Short constraints survive; long reference
100
+ // material degrades to a pointer, which is what it should have been anyway.
101
+ let out = render();
102
+ while (
103
+ Buffer.byteLength(out) > totalMax &&
104
+ entries.some((e) => e.inline)
105
+ ) {
106
+ const biggest = entries.filter((e) => e.inline).sort((a, b) => b.size - a.size)[0];
107
+ biggest.inline = false;
108
+ out = render();
42
109
  }
43
110
 
44
- respond(context);
111
+ respond(out);
@@ -263,3 +263,31 @@ export function writeSessionFile(filePath, fields, body = '') {
263
263
  export function readSessionFields(filePath) {
264
264
  return readSessionFile(filePath).fields;
265
265
  }
266
+
267
+ // This module is a LIBRARY, not a CLI. Several skills say "update the tracker
268
+ // via the session-frontmatter helper", and the natural reading of that — given
269
+ // that everything else a skill invokes (sync-tasks.mjs, create-work-session.mjs,
270
+ // build-workspace-context.mjs) is a real CLI — is to run this file with flags.
271
+ //
272
+ // Without this guard, Node imports the module, finds no side effects, ignores
273
+ // the arguments and EXITS 0. The caller sees success, the frontmatter is
274
+ // untouched, and a `cmd || fallback` idiom never fires because exit 0 is
275
+ // success. That silently dropped a `workItem:` linkage during the gh:89
276
+ // session and was only caught by re-reading the file (gh:143).
277
+ //
278
+ // So: fail loudly and say what to call instead.
279
+ if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
280
+ process.stderr.write(
281
+ 'session-frontmatter.mjs is a library, not a CLI — it takes no arguments.\n' +
282
+ '\n' +
283
+ 'Import it instead:\n' +
284
+ ' node --input-type=module -e \'\n' +
285
+ ' import { updateSessionFile } from "./.claude/lib/session-frontmatter.mjs";\n' +
286
+ ' updateSessionFile("session.md", { workItem: "gh:42" });\n' +
287
+ ' \'\n' +
288
+ '\n' +
289
+ 'Exports: parseSessionContent, updateSessionContent, updateSessionFile,\n' +
290
+ 'readSessionFile, readSessionFields, writeSessionFile.\n',
291
+ );
292
+ process.exit(2);
293
+ }
@@ -19,6 +19,6 @@ Injected revisions create fragmented, hard-to-follow output where the seams betw
19
19
 
20
20
  - When updating a section of a document, rewrite the entire section — not just the changed sentences
21
21
  - When updating a workspace-context file, rewrite it as a fresh snapshot of current understanding
22
- - When synthesizing multiple sources into release notes, write the narrative from scratch — don't concatenate
22
+ - When synthesizing multiple sources into a PR body or summary, 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