@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.
- package/README.md +5 -5
- package/lib/init.mjs +19 -0
- package/package.json +1 -1
- package/template/.claude/hooks/_utils.mjs +1 -1
- package/template/.claude/hooks/repo-write-detection.mjs +161 -64
- package/template/.claude/hooks/session-end.mjs +68 -2
- package/template/.claude/hooks/session-start.mjs +35 -1
- package/template/.claude/hooks/subagent-start.mjs +89 -22
- package/template/.claude/lib/session-frontmatter.mjs +28 -0
- package/template/.claude/rules/coherent-revisions.md +1 -1
- package/template/.claude/rules/config-review.md.skip +29 -0
- package/template/.claude/rules/forge-operations.md +51 -0
- package/template/.claude/rules/git-conventions.md +16 -11
- package/template/.claude/rules/goal-driven-work.md +8 -403
- package/template/.claude/rules/honest-pushback.md +37 -37
- package/template/.claude/rules/memory-guidance.md +43 -90
- package/template/.claude/rules/superpowers-workflow.md.skip +1 -1
- package/template/.claude/rules/work-item-tracking.md +30 -72
- package/template/.claude/rules/workspace-structure.md +49 -69
- package/template/.claude/scripts/build-workspace-context.mjs +61 -16
- package/template/.claude/scripts/chat-record.mjs +282 -0
- package/template/.claude/scripts/cleanup-work-session.mjs +363 -36
- package/template/.claude/scripts/context-footprint.mjs +282 -0
- package/template/.claude/scripts/forges/github.mjs +255 -0
- package/template/.claude/scripts/forges/gitlab.mjs +20 -0
- package/template/.claude/scripts/forges/interface.mjs +125 -0
- package/template/.claude/scripts/generate-claude-local.mjs +21 -2
- package/template/.claude/scripts/migrate-sessions.mjs +1571 -0
- package/template/.claude/scripts/migrate-to-workspace-context.mjs +7 -2
- package/template/.claude/scripts/task-worktree.mjs +525 -0
- package/template/.claude/scripts/workspace-diagnostics.mjs +654 -0
- package/template/.claude/settings.json +5 -13
- package/template/.claude/skills/braindump/SKILL.md +11 -4
- package/template/.claude/skills/build-docs-site/SKILL.md +5 -5
- package/template/.claude/skills/build-docs-site/templates/spec.md.tmpl +1 -1
- package/template/.claude/skills/complete-work/SKILL.md +255 -215
- package/template/.claude/skills/context-placement/SKILL.md +199 -0
- package/template/.claude/skills/goal-driven-work/SKILL.md +459 -0
- package/template/.claude/skills/handoff/SKILL.md +11 -4
- package/template/.claude/skills/maintenance/SKILL.md +39 -6
- package/template/.claude/skills/migrate-sessions/SKILL.md +70 -0
- package/template/.claude/skills/pause-work/SKILL.md +33 -8
- package/template/.claude/skills/release/SKILL.md +44 -108
- package/template/.claude/skills/start-work/SKILL.md +89 -7
- package/template/.claude/skills/workspace-init/SKILL.md +34 -0
- package/template/.claude/skills/workspace-update/SKILL.md +4 -0
- package/template/.claudeignore +3 -0
- package/template/CLAUDE.md.tmpl +20 -2
- package/template/CODEBASE.md.tmpl +13 -0
- package/template/_gitignore +9 -0
- package/template/repo-claude.md.tmpl +10 -0
- package/template/workspace.json.tmpl +5 -3
- 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,
|
|
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`
|
|
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
|
-
- **
|
|
92
|
-
- **
|
|
93
|
-
- **
|
|
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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
const
|
|
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
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
//
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
4
|
-
|
|
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 —
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|