@ulysses-ai/create-workspace 0.17.0-beta.0 → 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 (45) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/template/.claude/hooks/_utils.mjs +1 -1
  4. package/template/.claude/hooks/repo-write-detection.mjs +161 -64
  5. package/template/.claude/hooks/session-start.mjs +35 -1
  6. package/template/.claude/hooks/subagent-start.mjs +89 -22
  7. package/template/.claude/lib/session-frontmatter.mjs +28 -0
  8. package/template/.claude/rules/coherent-revisions.md +1 -1
  9. package/template/.claude/rules/forge-operations.md +37 -93
  10. package/template/.claude/rules/git-conventions.md +16 -11
  11. package/template/.claude/rules/goal-driven-work.md +8 -416
  12. package/template/.claude/rules/honest-pushback.md +37 -37
  13. package/template/.claude/rules/memory-guidance.md +43 -90
  14. package/template/.claude/rules/superpowers-workflow.md.skip +1 -1
  15. package/template/.claude/rules/work-item-tracking.md +30 -72
  16. package/template/.claude/rules/workspace-structure.md +36 -94
  17. package/template/.claude/scripts/build-workspace-context.mjs +61 -16
  18. package/template/.claude/scripts/chat-record.mjs +282 -0
  19. package/template/.claude/scripts/cleanup-work-session.mjs +257 -68
  20. package/template/.claude/scripts/context-footprint.mjs +282 -0
  21. package/template/.claude/scripts/forges/github.mjs +45 -0
  22. package/template/.claude/scripts/forges/gitlab.mjs +3 -2
  23. package/template/.claude/scripts/forges/interface.mjs +12 -0
  24. package/template/.claude/scripts/generate-claude-local.mjs +21 -2
  25. package/template/.claude/scripts/migrate-sessions.mjs +1571 -0
  26. package/template/.claude/scripts/migrate-to-workspace-context.mjs +7 -2
  27. package/template/.claude/scripts/task-worktree.mjs +525 -0
  28. package/template/.claude/scripts/workspace-diagnostics.mjs +654 -0
  29. package/template/.claude/skills/braindump/SKILL.md +11 -4
  30. package/template/.claude/skills/build-docs-site/SKILL.md +5 -5
  31. package/template/.claude/skills/build-docs-site/templates/spec.md.tmpl +1 -1
  32. package/template/.claude/skills/complete-work/SKILL.md +229 -219
  33. package/template/.claude/skills/context-placement/SKILL.md +199 -0
  34. package/template/.claude/skills/goal-driven-work/SKILL.md +459 -0
  35. package/template/.claude/skills/handoff/SKILL.md +11 -4
  36. package/template/.claude/skills/maintenance/SKILL.md +7 -0
  37. package/template/.claude/skills/migrate-sessions/SKILL.md +70 -0
  38. package/template/.claude/skills/pause-work/SKILL.md +9 -1
  39. package/template/.claude/skills/release/SKILL.md +44 -108
  40. package/template/.claude/skills/start-work/SKILL.md +89 -7
  41. package/template/.claude/skills/workspace-init/SKILL.md +3 -1
  42. package/template/.claude/skills/workspace-update/SKILL.md +4 -0
  43. package/template/CLAUDE.md.tmpl +19 -2
  44. package/template/_gitignore +9 -0
  45. package/template/workspace.json.tmpl +3 -2
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env node
2
+ // Measure the always-loaded context footprint of a workspace, and price a
3
+ // proposed addition before it is written.
4
+ //
5
+ // Every unconditional rule and every locked context file is a permanent tax on
6
+ // every session in this workspace — and, for anything shipped in the template,
7
+ // on every downstream workspace too. That cost is invisible at the moment
8
+ // someone decides where to put a durable fact, which is how the rules directory
9
+ // silently grew to 112 KB (gh:136, gh:138). This script makes the cost visible
10
+ // at the decision point. The `context-placement` skill and the
11
+ // `memory-guidance` rule both require running it before writing to an
12
+ // always-loaded destination.
13
+ //
14
+ // Reads only. Writes nothing. Makes no network calls.
15
+ //
16
+ // Usage:
17
+ // node context-footprint.mjs --root <dir>
18
+ // node context-footprint.mjs --root <dir> --json
19
+ // node context-footprint.mjs --root <dir> --add <bytes> --as <destination>
20
+ //
21
+ // Destinations for --as: rule, rule-scoped, locked, shared, team-member,
22
+ // memory, skill, nowhere.
23
+
24
+ import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from 'node:fs';
25
+ import { dirname, join, relative, resolve, sep } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ function isMainModule(metaUrl) {
29
+ if (!process.argv[1]) return false;
30
+ try {
31
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
32
+ } catch { return false; }
33
+ }
34
+
35
+ // A rough heuristic, not a tokenizer. Good enough to tell 200 bytes from 14 KB,
36
+ // which is the only distinction the placement decision actually turns on.
37
+ const BYTES_PER_TOKEN = 4;
38
+ const CONTEXT_WINDOW = 200000;
39
+
40
+ // Cost model per destination. Kept as data rather than a switch so the skill's
41
+ // routing table and this script cannot drift apart independently — the notes
42
+ // below are the same sentences the skill quotes.
43
+ const DESTINATIONS = {
44
+ 'rule': {
45
+ alwaysLoadedCost: (n) => n,
46
+ note: 'Unconditional rules load at launch at the same priority as CLAUDE.md, in every session — and in every downstream workspace that inherits the file.',
47
+ },
48
+ 'rule-scoped': {
49
+ alwaysLoadedCost: () => 0,
50
+ note: 'A .claude/rules/*.md carrying a paths: array of globs loads only when Claude reads a matching file. Zero always-loaded cost.',
51
+ },
52
+ 'locked': {
53
+ alwaysLoadedCost: (n) => n,
54
+ note: 'Locked files are concatenated verbatim into workspace-context/canonical.md, which every session loads in full.',
55
+ },
56
+ 'shared': {
57
+ alwaysLoadedCost: () => 120,
58
+ note: 'Only the generated index line is always loaded; the body is read when the topic comes up.',
59
+ },
60
+ 'team-member': {
61
+ alwaysLoadedCost: () => 120,
62
+ note: 'One index line, and only for that user — loaded via their gitignored CLAUDE.local.md.',
63
+ },
64
+ 'memory': {
65
+ alwaysLoadedCost: () => 100,
66
+ note: 'One MEMORY.md pointer line is always loaded; the memory body is read on demand.',
67
+ },
68
+ 'skill': {
69
+ alwaysLoadedCost: () => 200,
70
+ note: 'Only the frontmatter description is always loaded; the skill body loads when invoked.',
71
+ },
72
+ 'nowhere': {
73
+ alwaysLoadedCost: () => 0,
74
+ note: 'Already covered elsewhere. The cheapest and most common correct answer.',
75
+ },
76
+ };
77
+
78
+ function sizeOf(absPath) {
79
+ try { return statSync(absPath).size; } catch { return null; }
80
+ }
81
+
82
+ function toPosix(p) {
83
+ return p.split(sep).join('/');
84
+ }
85
+
86
+ /**
87
+ * Follow @-imports out of `absFile`, depth-first.
88
+ *
89
+ * Imports resolve against the *importing file's* directory, not the workspace
90
+ * root and emphatically not process.cwd() — a script that resolves against cwd
91
+ * is how gh:142 happened. `visited` is keyed on the resolved absolute path so a
92
+ * cycle (A imports B imports A) terminates and each file is counted once.
93
+ */
94
+ function resolveImports(absFile, visited, missing) {
95
+ const out = [];
96
+ let text;
97
+ try { text = readFileSync(absFile, 'utf8'); } catch { return out; }
98
+ for (const rawLine of text.split(/\r?\n/)) {
99
+ const m = /^@(\S+)$/.exec(rawLine.trim());
100
+ if (!m) continue;
101
+ const target = resolve(dirname(absFile), m[1]);
102
+ if (visited.has(target)) continue;
103
+ if (!existsSync(target)) {
104
+ // A workspace may legitimately reference an optional file it does not
105
+ // have (local-only-template-freshness.md, CODEBASE.md). Not an error —
106
+ // but record it so the caller can see the reference is dangling.
107
+ missing.push(m[1]);
108
+ continue;
109
+ }
110
+ visited.add(target);
111
+ out.push(target);
112
+ out.push(...resolveImports(target, visited, missing));
113
+ }
114
+ return out;
115
+ }
116
+
117
+ function collectRules(absRoot) {
118
+ const rulesDir = join(absRoot, '.claude', 'rules');
119
+ if (!existsSync(rulesDir)) return [];
120
+ return readdirSync(rulesDir)
121
+ .filter((n) => n.endsWith('.md') && !n.endsWith('.md.skip'))
122
+ .sort()
123
+ .map((n) => join(rulesDir, n));
124
+ }
125
+
126
+ /**
127
+ * Measure the always-loaded set under `root`.
128
+ *
129
+ * CLAUDE.local.md and anything it imports are reported separately under
130
+ * `local`: they are per-user and gitignored, so folding them into the shared
131
+ * total would overstate what the team actually pays.
132
+ */
133
+ function measure({ root = '.' } = {}) {
134
+ const absRoot = resolve(root);
135
+ const files = [];
136
+ const missingImports = [];
137
+
138
+ const claudeMd = join(absRoot, 'CLAUDE.md');
139
+ if (existsSync(claudeMd)) {
140
+ const visited = new Set([claudeMd]);
141
+ files.push({ abs: claudeMd, kind: 'claude-md' });
142
+ for (const imp of resolveImports(claudeMd, visited, missingImports)) {
143
+ files.push({ abs: imp, kind: 'import' });
144
+ }
145
+ }
146
+
147
+ for (const r of collectRules(absRoot)) files.push({ abs: r, kind: 'rule' });
148
+
149
+ const entries = [];
150
+ let totalBytes = 0;
151
+ for (const f of files) {
152
+ const bytes = sizeOf(f.abs);
153
+ if (bytes === null) continue;
154
+ totalBytes += bytes;
155
+ entries.push({ path: toPosix(relative(absRoot, f.abs)), bytes, kind: f.kind });
156
+ }
157
+ entries.sort((a, b) => b.bytes - a.bytes);
158
+
159
+ const localEntries = [];
160
+ let localBytes = 0;
161
+ const localMd = join(absRoot, 'CLAUDE.local.md');
162
+ if (existsSync(localMd)) {
163
+ const visited = new Set([localMd]);
164
+ const localMissing = [];
165
+ const localFiles = [localMd, ...resolveImports(localMd, visited, localMissing)];
166
+ for (const abs of localFiles) {
167
+ const bytes = sizeOf(abs);
168
+ if (bytes === null) continue;
169
+ localBytes += bytes;
170
+ localEntries.push({ path: toPosix(relative(absRoot, abs)), bytes, kind: 'local' });
171
+ }
172
+ localEntries.sort((a, b) => b.bytes - a.bytes);
173
+ }
174
+
175
+ const totalTokens = Math.round(totalBytes / BYTES_PER_TOKEN);
176
+ return {
177
+ root: absRoot,
178
+ totalBytes,
179
+ totalTokens,
180
+ percentOfWindow: Number(((totalTokens / CONTEXT_WINDOW) * 100).toFixed(1)),
181
+ files: entries,
182
+ missingImports,
183
+ local: { totalBytes: localBytes, files: localEntries },
184
+ };
185
+ }
186
+
187
+ function projectCost(measurement, addedBytes, destination) {
188
+ const dest = DESTINATIONS[destination];
189
+ if (!dest) throw new Error(`unknown destination: ${destination}`);
190
+ const delta = dest.alwaysLoadedCost(addedBytes);
191
+ const newTotalBytes = measurement.totalBytes + delta;
192
+ const newTokens = Math.round(newTotalBytes / BYTES_PER_TOKEN);
193
+ return {
194
+ destination,
195
+ addedBytes,
196
+ alwaysLoadedDelta: delta,
197
+ newTotalBytes,
198
+ newPercentOfWindow: Number(((newTokens / CONTEXT_WINDOW) * 100).toFixed(1)),
199
+ note: dest.note,
200
+ };
201
+ }
202
+
203
+ function parseArgs(argv) {
204
+ const args = { root: '.', json: false, add: null, as: null };
205
+ const rest = argv.slice(2);
206
+ for (let i = 0; i < rest.length; i += 1) {
207
+ const a = rest[i];
208
+ if (a === '--root') { args.root = rest[++i]; continue; }
209
+ if (a === '--json') { args.json = true; continue; }
210
+ if (a === '--add') { args.add = Number(rest[++i]); continue; }
211
+ if (a === '--as') { args.as = rest[++i]; continue; }
212
+ throw new Error(`unknown argument: ${a}`);
213
+ }
214
+ if (args.add !== null && args.as === null) {
215
+ throw new Error('--add requires --as <destination>');
216
+ }
217
+ if (args.as !== null && args.add === null) {
218
+ throw new Error('--as requires --add <bytes>');
219
+ }
220
+ if (args.add !== null && !Number.isFinite(args.add)) {
221
+ throw new Error('--add expects a number of bytes');
222
+ }
223
+ if (args.as !== null && !DESTINATIONS[args.as]) {
224
+ throw new Error(
225
+ `unknown destination: ${args.as}. Valid: ${Object.keys(DESTINATIONS).join(', ')}`,
226
+ );
227
+ }
228
+ return args;
229
+ }
230
+
231
+ function renderHuman(m, projection) {
232
+ const lines = [];
233
+ for (const f of m.files) {
234
+ lines.push(`${String(f.bytes).padStart(7)} ${f.kind.padEnd(9)} ${f.path}`);
235
+ }
236
+ lines.push('-'.repeat(60));
237
+ lines.push(
238
+ `${String(m.totalBytes).padStart(7)} TOTAL ~${m.totalTokens} tokens, ` +
239
+ `${m.percentOfWindow}% of a ${CONTEXT_WINDOW / 1000}k window`,
240
+ );
241
+ if (m.local.totalBytes > 0) {
242
+ lines.push(`${String(m.local.totalBytes).padStart(7)} local (per-user, not counted above)`);
243
+ }
244
+ if (m.missingImports.length > 0) {
245
+ lines.push(` dangling @-imports: ${m.missingImports.join(', ')}`);
246
+ }
247
+ if (projection) {
248
+ lines.push('');
249
+ lines.push(
250
+ `+${projection.addedBytes} B as "${projection.destination}" ` +
251
+ `=> +${projection.alwaysLoadedDelta} B always-loaded`,
252
+ );
253
+ lines.push(
254
+ `${m.totalBytes} B (${m.percentOfWindow}%) -> ` +
255
+ `${projection.newTotalBytes} B (${projection.newPercentOfWindow}%)`,
256
+ );
257
+ lines.push(projection.note);
258
+ }
259
+ return lines.join('\n');
260
+ }
261
+
262
+ function main() {
263
+ const args = parseArgs(process.argv);
264
+ const m = measure({ root: args.root });
265
+ const projection = args.add !== null ? projectCost(m, args.add, args.as) : null;
266
+ if (args.json) {
267
+ process.stdout.write(JSON.stringify({ ...m, projection }, null, 2) + '\n');
268
+ } else {
269
+ process.stdout.write(renderHuman(m, projection) + '\n');
270
+ }
271
+ }
272
+
273
+ if (isMainModule(import.meta.url)) {
274
+ try {
275
+ main();
276
+ } catch (err) {
277
+ process.stderr.write(`context-footprint: ${err.message}\n`);
278
+ process.exit(2);
279
+ }
280
+ }
281
+
282
+ export { measure, projectCost, parseArgs, resolveImports, DESTINATIONS, BYTES_PER_TOKEN, CONTEXT_WINDOW };
@@ -110,6 +110,34 @@ export function createGithubAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
110
110
  };
111
111
  }
112
112
 
113
+ // Listing merged PRs is how /release proves the unreleased-notes pile is
114
+ // complete rather than merely empty (gh:89). `search` takes gh's raw search
115
+ // syntax so callers can bound by merge date without this adapter growing a
116
+ // date-range vocabulary of its own.
117
+ async function prList({ state = 'merged', base, search, limit = 100, repo }) {
118
+ const target = repoFor(repo);
119
+ const args = [
120
+ 'pr', 'list', '--repo', target,
121
+ '--state', state,
122
+ '--limit', String(limit),
123
+ '--json', 'number,title,url,headRefName,baseRefName,mergedAt,state',
124
+ ];
125
+ if (base) args.push('--base', base);
126
+ if (search) args.push('--search', search);
127
+ const stdout = ghOrThrow(args).trim();
128
+ const raw = stdout ? JSON.parse(stdout) : [];
129
+ return raw.map((p) => ({
130
+ id: `${target}#${p.number}`,
131
+ number: p.number,
132
+ title: p.title,
133
+ url: p.url,
134
+ headRefName: p.headRefName,
135
+ baseRefName: p.baseRefName,
136
+ mergedAt: p.mergedAt,
137
+ state: p.state,
138
+ }));
139
+ }
140
+
113
141
  async function releaseView({ tag, repo }) {
114
142
  if (!tag) throw new Error('releaseView: tag is required');
115
143
  const target = repoFor(repo);
@@ -132,6 +160,21 @@ export function createGithubAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
132
160
  };
133
161
  }
134
162
 
163
+ // Creating the release is where release notes now come from: with
164
+ // --generate-notes the forge builds them from merged PR titles, so the
165
+ // workspace keeps no notes files of its own (gh:157).
166
+ async function releaseCreate({ tag, target, title, generateNotes = true, repo }) {
167
+ if (!tag) throw new Error('releaseCreate: tag is required');
168
+ const args = ['release', 'create', tag, '--repo', repoFor(repo)];
169
+ if (target) args.push('--target', target);
170
+ if (title) args.push('--title', title);
171
+ if (generateNotes) args.push('--generate-notes');
172
+ const stdout = ghOrThrow(args).trim();
173
+ // gh prints the release URL on success; sometimes preceded by warnings.
174
+ const url = stdout.split('\n').filter(Boolean).pop();
175
+ return { url, tag };
176
+ }
177
+
135
178
  async function workflowRunFind({ workflow, branch, repo, limit = 1 }) {
136
179
  if (!workflow) throw new Error('workflowRunFind: workflow is required');
137
180
  const target = repoFor(repo);
@@ -181,7 +224,9 @@ export function createGithubAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
181
224
  prCreate,
182
225
  prMerge,
183
226
  prView,
227
+ prList,
184
228
  releaseView,
229
+ releaseCreate,
185
230
  workflowRunFind,
186
231
  workflowRunWatch,
187
232
  get identity() { return `github:${defaultRepo}`; },
@@ -6,8 +6,9 @@
6
6
  //
7
7
  // When implemented, this adapter wraps the `glab` CLI the same way
8
8
  // github.mjs wraps `gh`: same method surface (prCreate, prMerge, prView,
9
- // releaseView, workflowRunFind, workflowRunWatch), same spawnFn-injectable
10
- // shape for testability, same error types from interface.mjs.
9
+ // prList, releaseView, releaseCreate, workflowRunFind, workflowRunWatch),
10
+ // same spawnFn-injectable shape for testability, same error types from
11
+ // interface.mjs.
11
12
 
12
13
  import { ForgeError } from './interface.mjs';
13
14
 
@@ -17,9 +17,21 @@
17
17
  // prView({ id, repo?, json? })
18
18
  // → { id, url, state, mergeable, mergeStateStatus, reviewDecision, title }
19
19
  // json may name additional fields to pass through
20
+ // prList({ state = 'merged', base?, search?, limit = 100, repo? })
21
+ // → [{ id, number, title, url, headRefName, baseRefName, mergedAt, state }]
22
+ // `search` passes through the forge's own search syntax (e.g.
23
+ // 'merged:>2026-01-01'), so callers can bound a window without this
24
+ // interface growing a date vocabulary.
20
25
  // releaseView({ tag, repo? })
21
26
  // → { tag, url, name, publishedAt }
22
27
  // throws ReleaseNotFound if the tag has no release
28
+ // releaseCreate({ tag, target?, title?, generateNotes = true, repo? })
29
+ // → { url, tag }
30
+ // target: commitish the tag points at (default: the repo's default
31
+ // branch head); title: release name (default: the tag)
32
+ // generateNotes: when true (the default) the forge generates the
33
+ // release notes from merged PRs — this is the only notes mechanism
34
+ // the workspace ships.
23
35
  // workflowRunFind({ workflow, branch, repo?, limit = 1 })
24
36
  // → { runId, status, conclusion, url } | null
25
37
  // workflowRunWatch({ runId, repo?, exitStatus = false })
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { readFileSync, writeFileSync, existsSync, realpathSync } from 'node:fs';
21
21
  import { join, resolve } from 'node:path';
22
+ import { spawnSync } from 'node:child_process';
22
23
  import { fileURLToPath } from 'node:url';
23
24
 
24
25
  function isMainModule(metaUrl) {
@@ -85,11 +86,29 @@ function generateClaudeLocal(root, { force = false } = {}) {
85
86
  return { path: target, status: existsSync(target) ? 'written' : 'written' };
86
87
  }
87
88
 
89
+ // The per-user index CLAUDE.local.md imports is generated, not tracked
90
+ // (gh:132) — so on a fresh clone it does not exist yet, and writing an
91
+ // importer for a missing file leaves a dangling @-import. Regenerate the
92
+ // workspace-context artifacts here, where the importer is created, rather
93
+ // than leaving the gap for /workspace-init to remember.
94
+ function ensurePerUserIndex(root) {
95
+ const builder = join(root, '.claude', 'scripts', 'build-workspace-context.mjs');
96
+ if (!existsSync(builder)) return { regenerated: false, reason: 'builder not present' };
97
+ const r = spawnSync(process.execPath, [builder, '--write', '--root', root], {
98
+ encoding: 'utf-8',
99
+ });
100
+ if (r.status !== 0) {
101
+ return { regenerated: false, reason: (r.stderr || '').trim() || `exit ${r.status}` };
102
+ }
103
+ return { regenerated: true };
104
+ }
105
+
88
106
  function main() {
89
107
  const args = parseArgs(process.argv);
90
108
  const root = resolve(args.root);
91
109
  const result = generateClaudeLocal(root, { force: args.force });
92
- process.stdout.write(JSON.stringify(result) + '\n');
110
+ const index = ensurePerUserIndex(root);
111
+ process.stdout.write(JSON.stringify({ ...result, index }) + '\n');
93
112
  }
94
113
 
95
114
  if (isMainModule(import.meta.url)) {
@@ -101,4 +120,4 @@ if (isMainModule(import.meta.url)) {
101
120
  }
102
121
  }
103
122
 
104
- export { readWorkspaceUser, renderClaudeLocal, generateClaudeLocal };
123
+ export { readWorkspaceUser, renderClaudeLocal, generateClaudeLocal, ensurePerUserIndex };