@ulysses-ai/create-workspace 0.17.0-beta.0 → 0.19.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 (117) hide show
  1. package/README.md +3 -3
  2. package/lib/init.mjs +4 -1
  3. package/lib/payload.mjs +18 -1
  4. package/lib/payload.test.mjs +55 -0
  5. package/lib/scaffold.mjs +23 -6
  6. package/lib/scaffold.test.mjs +59 -0
  7. package/package.json +1 -1
  8. package/template/CLAUDE.md.tmpl +19 -2
  9. package/template/{.claude → _claude}/hooks/_utils.mjs +1 -1
  10. package/template/_claude/hooks/repo-write-detection.mjs +204 -0
  11. package/template/{.claude → _claude}/hooks/session-start.mjs +35 -1
  12. package/template/_claude/hooks/subagent-start.mjs +111 -0
  13. package/template/{.claude → _claude}/lib/session-frontmatter.mjs +28 -0
  14. package/template/{.claude → _claude}/rules/coherent-revisions.md +1 -1
  15. package/template/_claude/rules/forge-operations.md +57 -0
  16. package/template/_claude/rules/git-conventions.md +39 -0
  17. package/template/_claude/rules/goal-driven-work.md +24 -0
  18. package/template/_claude/rules/honest-pushback.md +56 -0
  19. package/template/_claude/rules/memory-guidance.md +66 -0
  20. package/template/{.claude → _claude}/rules/superpowers-workflow.md.skip +1 -1
  21. package/template/{.claude → _claude}/rules/task-list-mirroring.md +6 -0
  22. package/template/_claude/rules/work-item-tracking.md +48 -0
  23. package/template/_claude/rules/workspace-structure.md +79 -0
  24. package/template/{.claude → _claude}/scripts/build-workspace-context.mjs +86 -30
  25. package/template/_claude/scripts/chat-record.mjs +315 -0
  26. package/template/_claude/scripts/cleanup-work-session.mjs +436 -0
  27. package/template/_claude/scripts/context-footprint.mjs +391 -0
  28. package/template/{.claude → _claude}/scripts/forges/github.mjs +46 -0
  29. package/template/{.claude → _claude}/scripts/forges/gitlab.mjs +3 -2
  30. package/template/{.claude → _claude}/scripts/forges/interface.mjs +13 -0
  31. package/template/{.claude → _claude}/scripts/generate-claude-local.mjs +21 -2
  32. package/template/_claude/scripts/migrate-sessions.mjs +1571 -0
  33. package/template/{.claude → _claude}/scripts/migrate-to-workspace-context.mjs +7 -2
  34. package/template/_claude/scripts/task-pr.mjs +447 -0
  35. package/template/_claude/scripts/task-worktree.mjs +525 -0
  36. package/template/{.claude → _claude}/scripts/trackers/github-issues.mjs +11 -0
  37. package/template/{.claude → _claude}/scripts/trackers/interface.mjs +8 -0
  38. package/template/_claude/scripts/workspace-diagnostics.mjs +654 -0
  39. package/template/{.claude → _claude}/skills/braindump/SKILL.md +12 -4
  40. package/template/{.claude → _claude}/skills/build-docs-site/SKILL.md +5 -5
  41. package/template/{.claude → _claude}/skills/build-docs-site/templates/spec.md.tmpl +1 -1
  42. package/template/_claude/skills/complete-work/SKILL.md +452 -0
  43. package/template/_claude/skills/context-placement/SKILL.md +202 -0
  44. package/template/{.claude/rules/goal-driven-work.md → _claude/skills/goal-driven-work/SKILL.md} +46 -19
  45. package/template/{.claude → _claude}/skills/handoff/SKILL.md +12 -4
  46. package/template/{.claude → _claude}/skills/maintenance/SKILL.md +56 -17
  47. package/template/_claude/skills/migrate-sessions/SKILL.md +70 -0
  48. package/template/{.claude → _claude}/skills/pause-work/SKILL.md +9 -1
  49. package/template/_claude/skills/release/SKILL.md +91 -0
  50. package/template/{.claude → _claude}/skills/start-work/SKILL.md +89 -7
  51. package/template/{.claude → _claude}/skills/workspace-init/SKILL.md +3 -1
  52. package/template/{.claude → _claude}/skills/workspace-update/SKILL.md +4 -0
  53. package/template/_gitignore +9 -0
  54. package/template/workspace.json.tmpl +4 -3
  55. package/template/.claude/hooks/repo-write-detection.mjs +0 -107
  56. package/template/.claude/hooks/subagent-start.mjs +0 -44
  57. package/template/.claude/rules/forge-operations.md +0 -107
  58. package/template/.claude/rules/git-conventions.md +0 -34
  59. package/template/.claude/rules/honest-pushback.md +0 -56
  60. package/template/.claude/rules/memory-guidance.md +0 -109
  61. package/template/.claude/rules/work-item-tracking.md +0 -90
  62. package/template/.claude/rules/workspace-structure.md +0 -137
  63. package/template/.claude/scripts/cleanup-work-session.mjs +0 -247
  64. package/template/.claude/skills/complete-work/SKILL.md +0 -498
  65. package/template/.claude/skills/release/SKILL.md +0 -151
  66. /package/template/{.claude → _claude}/agents/aside-researcher.md +0 -0
  67. /package/template/{.claude → _claude}/agents/implementer.md +0 -0
  68. /package/template/{.claude → _claude}/agents/researcher.md +0 -0
  69. /package/template/{.claude → _claude}/agents/reviewer.md +0 -0
  70. /package/template/{.claude → _claude}/hooks/bash-output-advisory.mjs +0 -0
  71. /package/template/{.claude → _claude}/hooks/post-compact.mjs +0 -0
  72. /package/template/{.claude → _claude}/hooks/pre-compact.mjs +0 -0
  73. /package/template/{.claude → _claude}/hooks/session-end.mjs +0 -0
  74. /package/template/{.claude → _claude}/hooks/version-freshness-check.mjs +0 -0
  75. /package/template/{.claude → _claude}/hooks/workspace-update-check.mjs +0 -0
  76. /package/template/{.claude → _claude}/lib/freshness.mjs +0 -0
  77. /package/template/{.claude → _claude}/lib/registry-check.mjs +0 -0
  78. /package/template/{.claude → _claude}/lib/require-node.mjs +0 -0
  79. /package/template/{.claude → _claude}/recipes/migrate-from-notion.md +0 -0
  80. /package/template/{.claude → _claude}/rules/agent-rules.md.skip +0 -0
  81. /package/template/{.claude → _claude}/rules/cloud-infrastructure.md.skip +0 -0
  82. /package/template/{.claude → _claude}/rules/config-review.md.skip +0 -0
  83. /package/template/{.claude → _claude}/rules/documentation.md.skip +0 -0
  84. /package/template/{.claude → _claude}/rules/local-dev-environment.md.skip +0 -0
  85. /package/template/{.claude → _claude}/rules/product-integrity.md.skip +0 -0
  86. /package/template/{.claude → _claude}/rules/scope-guard.md.skip +0 -0
  87. /package/template/{.claude → _claude}/rules/token-economics.md.skip +0 -0
  88. /package/template/{.claude → _claude}/scripts/add-repo-to-session.mjs +0 -0
  89. /package/template/{.claude → _claude}/scripts/capture-context.mjs +0 -0
  90. /package/template/{.claude → _claude}/scripts/create-work-session.mjs +0 -0
  91. /package/template/{.claude → _claude}/scripts/migrate-canonical-priority.mjs +0 -0
  92. /package/template/{.claude → _claude}/scripts/migrate-claude-md-freshness-include.mjs +0 -0
  93. /package/template/{.claude → _claude}/scripts/migrate-open-work.mjs +0 -0
  94. /package/template/{.claude → _claude}/scripts/migrate-session-layout.mjs +0 -0
  95. /package/template/{.claude → _claude}/scripts/sweep-references.mjs +0 -0
  96. /package/template/{.claude → _claude}/scripts/sync-tasks.mjs +0 -0
  97. /package/template/{.claude → _claude}/settings.json +0 -0
  98. /package/template/{.claude → _claude}/skills/aside/SKILL.md +0 -0
  99. /package/template/{.claude → _claude}/skills/build-docs-site/checklists/framing.md +0 -0
  100. /package/template/{.claude → _claude}/skills/build-docs-site/checklists/pitfalls.md +0 -0
  101. /package/template/{.claude → _claude}/skills/build-docs-site/checklists/review.md +0 -0
  102. /package/template/{.claude → _claude}/skills/build-docs-site/scripts/bulk-fill-migration.py +0 -0
  103. /package/template/{.claude → _claude}/skills/build-docs-site/scripts/forbidden-word-grep.mjs +0 -0
  104. /package/template/{.claude → _claude}/skills/build-docs-site/scripts/leak-grep.mjs +0 -0
  105. /package/template/{.claude → _claude}/skills/build-docs-site/templates/custom.css.tmpl +0 -0
  106. /package/template/{.claude → _claude}/skills/build-docs-site/templates/docusaurus.config.ts.tmpl +0 -0
  107. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Arrow.tsx +0 -0
  108. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Box.tsx +0 -0
  109. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/DiagramContainer.tsx +0 -0
  110. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Region.tsx +0 -0
  111. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/SectionTitle.tsx +0 -0
  112. /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/tokens.ts +0 -0
  113. /package/template/{.claude → _claude}/skills/build-docs-site/templates/sidebars.ts.tmpl +0 -0
  114. /package/template/{.claude → _claude}/skills/promote/SKILL.md +0 -0
  115. /package/template/{.claude → _claude}/skills/setup-tracker/SKILL.md +0 -0
  116. /package/template/{.claude → _claude}/skills/sync-work/SKILL.md +0 -0
  117. /package/template/{.mcp.json → _mcp.json} +0 -0
@@ -0,0 +1,391 @@
1
+ #!/usr/bin/env node
2
+ // Measure the always-loaded context footprint of a workspace, price a proposed
3
+ // addition before it is written, and check the total against a budget.
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
+ // A rule whose frontmatter declares `paths:` is conditional — Claude Code loads
15
+ // it only when a file matching one of its globs is read — so it is listed under
16
+ // `conditional`, excluded from the total, and priced at zero by the
17
+ // `rule-scoped` destination.
18
+ //
19
+ // The budget is `workspace.alwaysLoadedBudgetBytes` from <root>/workspace.json
20
+ // (absent means no budget); `--budget <bytes>` overrides it. With a budget set,
21
+ // human output ends with a `BUDGET <total>/<budget> bytes — ok|OVER` line, JSON
22
+ // gains `budget` and `overBudget`, a projection reports whether the addition
23
+ // lands over, and the process exits 1 when the measured total is over.
24
+ //
25
+ // Reads only. Writes nothing. Makes no network calls.
26
+ //
27
+ // Usage:
28
+ // node context-footprint.mjs --root <dir>
29
+ // node context-footprint.mjs --root <dir> --json
30
+ // node context-footprint.mjs --root <dir> --budget <bytes>
31
+ // node context-footprint.mjs --root <dir> --add <bytes> --as <destination>
32
+ //
33
+ // Destinations for --as: rule, rule-scoped, locked, shared, team-member,
34
+ // memory, skill, nowhere.
35
+
36
+ import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from 'node:fs';
37
+ import { dirname, join, relative, resolve, sep } from 'node:path';
38
+ import { fileURLToPath } from 'node:url';
39
+
40
+ function isMainModule(metaUrl) {
41
+ if (!process.argv[1]) return false;
42
+ try {
43
+ return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
44
+ } catch { return false; }
45
+ }
46
+
47
+ // A rough heuristic, not a tokenizer. Good enough to tell 200 bytes from 14 KB,
48
+ // which is the only distinction the placement decision actually turns on.
49
+ const BYTES_PER_TOKEN = 4;
50
+ const CONTEXT_WINDOW = 200000;
51
+
52
+ // Cost model per destination. Kept as data rather than a switch so the skill's
53
+ // routing table and this script cannot drift apart independently — the notes
54
+ // below are the same sentences the skill quotes.
55
+ const DESTINATIONS = {
56
+ 'rule': {
57
+ alwaysLoadedCost: (n) => n,
58
+ 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.',
59
+ },
60
+ 'rule-scoped': {
61
+ alwaysLoadedCost: () => 0,
62
+ note: 'A .claude/rules/*.md carrying a paths: array of globs loads only when Claude reads a matching file. Zero always-loaded cost.',
63
+ },
64
+ 'locked': {
65
+ alwaysLoadedCost: (n) => n,
66
+ note: 'Locked files are concatenated verbatim into workspace-context/canonical.md, which every session loads in full.',
67
+ },
68
+ 'shared': {
69
+ alwaysLoadedCost: () => 120,
70
+ note: 'Only the generated index line is always loaded; the body is read when the topic comes up.',
71
+ },
72
+ 'team-member': {
73
+ alwaysLoadedCost: () => 120,
74
+ note: 'One index line, and only for that user — loaded via their gitignored CLAUDE.local.md.',
75
+ },
76
+ 'memory': {
77
+ alwaysLoadedCost: () => 100,
78
+ note: 'One MEMORY.md pointer line is always loaded; the memory body is read on demand.',
79
+ },
80
+ 'skill': {
81
+ alwaysLoadedCost: () => 200,
82
+ note: 'Only the frontmatter description is always loaded; the skill body loads when invoked.',
83
+ },
84
+ 'nowhere': {
85
+ alwaysLoadedCost: () => 0,
86
+ note: 'Already covered elsewhere. The cheapest and most common correct answer.',
87
+ },
88
+ };
89
+
90
+ function sizeOf(absPath) {
91
+ try { return statSync(absPath).size; } catch { return null; }
92
+ }
93
+
94
+ function toPosix(p) {
95
+ return p.split(sep).join('/');
96
+ }
97
+
98
+ /**
99
+ * Follow @-imports out of `absFile`, depth-first.
100
+ *
101
+ * Imports resolve against the *importing file's* directory, not the workspace
102
+ * root and emphatically not process.cwd() — a script that resolves against cwd
103
+ * is how gh:142 happened. `visited` is keyed on the resolved absolute path so a
104
+ * cycle (A imports B imports A) terminates and each file is counted once.
105
+ */
106
+ function resolveImports(absFile, visited, missing) {
107
+ const out = [];
108
+ let text;
109
+ try { text = readFileSync(absFile, 'utf8'); } catch { return out; }
110
+ for (const rawLine of text.split(/\r?\n/)) {
111
+ const m = /^@(\S+)$/.exec(rawLine.trim());
112
+ if (!m) continue;
113
+ const target = resolve(dirname(absFile), m[1]);
114
+ if (visited.has(target)) continue;
115
+ if (!existsSync(target)) {
116
+ // A workspace may legitimately reference an optional file it does not
117
+ // have (local-only-template-freshness.md, CODEBASE.md). Not an error —
118
+ // but record it so the caller can see the reference is dangling.
119
+ missing.push(m[1]);
120
+ continue;
121
+ }
122
+ visited.add(target);
123
+ out.push(target);
124
+ out.push(...resolveImports(target, visited, missing));
125
+ }
126
+ return out;
127
+ }
128
+
129
+ function collectRules(absRoot) {
130
+ const rulesDir = join(absRoot, '.claude', 'rules');
131
+ if (!existsSync(rulesDir)) return [];
132
+ return readdirSync(rulesDir)
133
+ .filter((n) => n.endsWith('.md') && !n.endsWith('.md.skip'))
134
+ .sort()
135
+ .map((n) => join(rulesDir, n));
136
+ }
137
+
138
+ /**
139
+ * Does this text carry YAML frontmatter with a top-level `paths:` key?
140
+ *
141
+ * Frontmatter on rules is a flat, hand-written key block, so a deliberately
142
+ * naive scan — opening `---` line, closing `---` line, any top-level `paths:`
143
+ * key between them — is enough. A YAML library would buy fidelity the one
144
+ * decision this feeds (conditional vs always-loaded) never needs.
145
+ */
146
+ function frontmatterHasPaths(text) {
147
+ const lines = /^---\r?\n/.test(text) ? text.split(/\r?\n/) : null;
148
+ if (!lines) return false;
149
+ const close = lines.findIndex((line, i) => i > 0 && (line === '---' || line === '...'));
150
+ if (close === -1) return false; // no closing delimiter: not frontmatter
151
+ return lines.slice(1, close).some((line) => /^paths:/.test(line));
152
+ }
153
+
154
+ function ruleIsConditional(absRule) {
155
+ try {
156
+ return frontmatterHasPaths(readFileSync(absRule, 'utf8'));
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * The always-loaded budget from <root>/workspace.json, or null when the file or
164
+ * the `workspace.alwaysLoadedBudgetBytes` field is absent. A present but
165
+ * malformed workspace.json throws — silently ignoring a corrupt config would
166
+ * report "no budget" for a workspace that tried to set one.
167
+ */
168
+ function readBudget(absRoot) {
169
+ const configPath = join(absRoot, 'workspace.json');
170
+ if (!existsSync(configPath)) return null;
171
+ const raw = JSON.parse(readFileSync(configPath, 'utf8'));
172
+ const budget = raw?.workspace?.alwaysLoadedBudgetBytes;
173
+ return typeof budget === 'number' && Number.isFinite(budget) && budget >= 0 ? budget : null;
174
+ }
175
+
176
+ /**
177
+ * Measure the always-loaded set under `root`.
178
+ *
179
+ * Three groups, kept separate because they cost different things:
180
+ * - `files` / `totalBytes` — CLAUDE.md, its @-imports, and unconditional
181
+ * rules: what every session pays, and the number the budget judges.
182
+ * - `conditional` — rules with `paths:` frontmatter, reported with kind
183
+ * `rule-scoped` but excluded from the total: they load only when Claude
184
+ * touches a file matching one of their globs.
185
+ * - `local` — CLAUDE.local.md and its imports: per-user and gitignored, so
186
+ * folding them into the shared total would overstate what the team pays.
187
+ */
188
+ function measure({ root = '.' } = {}) {
189
+ const absRoot = resolve(root);
190
+ const always = [];
191
+ const conditional = [];
192
+ const missingImports = [];
193
+
194
+ const claudeMd = join(absRoot, 'CLAUDE.md');
195
+ if (existsSync(claudeMd)) {
196
+ const visited = new Set([claudeMd]);
197
+ always.push({ abs: claudeMd, kind: 'claude-md' });
198
+ for (const imp of resolveImports(claudeMd, visited, missingImports)) {
199
+ always.push({ abs: imp, kind: 'import' });
200
+ }
201
+ }
202
+
203
+ for (const r of collectRules(absRoot)) {
204
+ if (ruleIsConditional(r)) conditional.push({ abs: r, kind: 'rule-scoped' });
205
+ else always.push({ abs: r, kind: 'rule' });
206
+ }
207
+
208
+ const toEntries = (group) => group
209
+ .map((f) => {
210
+ const bytes = sizeOf(f.abs);
211
+ return bytes === null ? null : { path: toPosix(relative(absRoot, f.abs)), bytes, kind: f.kind };
212
+ })
213
+ .filter((e) => e !== null)
214
+ .sort((a, b) => b.bytes - a.bytes);
215
+
216
+ const entries = toEntries(always);
217
+ const conditionalEntries = toEntries(conditional);
218
+
219
+ const localEntries = [];
220
+ let localBytes = 0;
221
+ const localMd = join(absRoot, 'CLAUDE.local.md');
222
+ if (existsSync(localMd)) {
223
+ const visited = new Set([localMd]);
224
+ const localMissing = [];
225
+ const localFiles = [localMd, ...resolveImports(localMd, visited, localMissing)];
226
+ for (const abs of localFiles) {
227
+ const bytes = sizeOf(abs);
228
+ if (bytes === null) continue;
229
+ localBytes += bytes;
230
+ localEntries.push({ path: toPosix(relative(absRoot, abs)), bytes, kind: 'local' });
231
+ }
232
+ localEntries.sort((a, b) => b.bytes - a.bytes);
233
+ }
234
+
235
+ const totalBytes = entries.reduce((sum, e) => sum + e.bytes, 0);
236
+ const totalTokens = Math.round(totalBytes / BYTES_PER_TOKEN);
237
+ return {
238
+ root: absRoot,
239
+ totalBytes,
240
+ totalTokens,
241
+ percentOfWindow: Number(((totalTokens / CONTEXT_WINDOW) * 100).toFixed(1)),
242
+ files: entries,
243
+ conditional: {
244
+ totalBytes: conditionalEntries.reduce((sum, e) => sum + e.bytes, 0),
245
+ files: conditionalEntries,
246
+ },
247
+ missingImports,
248
+ local: { totalBytes: localBytes, files: localEntries },
249
+ };
250
+ }
251
+
252
+ function projectCost(measurement, addedBytes, destination, budgetBytes = null) {
253
+ const dest = DESTINATIONS[destination];
254
+ if (!dest) throw new Error(`unknown destination: ${destination}`);
255
+ const delta = dest.alwaysLoadedCost(addedBytes);
256
+ const newTotalBytes = measurement.totalBytes + delta;
257
+ const newTokens = Math.round(newTotalBytes / BYTES_PER_TOKEN);
258
+ const projection = {
259
+ destination,
260
+ addedBytes,
261
+ alwaysLoadedDelta: delta,
262
+ newTotalBytes,
263
+ newPercentOfWindow: Number(((newTokens / CONTEXT_WINDOW) * 100).toFixed(1)),
264
+ note: dest.note,
265
+ };
266
+ if (budgetBytes !== null) {
267
+ projection.budgetBytes = budgetBytes;
268
+ projection.overBudgetAfter = newTotalBytes > budgetBytes;
269
+ }
270
+ return projection;
271
+ }
272
+
273
+ function parseArgs(argv) {
274
+ const args = { root: '.', json: false, add: null, as: null, budget: null };
275
+ const rest = argv.slice(2);
276
+ for (let i = 0; i < rest.length; i += 1) {
277
+ const a = rest[i];
278
+ if (a === '--root') { args.root = rest[++i]; continue; }
279
+ if (a === '--json') { args.json = true; continue; }
280
+ if (a === '--add') { args.add = Number(rest[++i]); continue; }
281
+ if (a === '--as') { args.as = rest[++i]; continue; }
282
+ if (a === '--budget') { args.budget = Number(rest[++i]); continue; }
283
+ throw new Error(`unknown argument: ${a}`);
284
+ }
285
+ if (args.add !== null && args.as === null) {
286
+ throw new Error('--add requires --as <destination>');
287
+ }
288
+ if (args.as !== null && args.add === null) {
289
+ throw new Error('--as requires --add <bytes>');
290
+ }
291
+ if (args.add !== null && !Number.isFinite(args.add)) {
292
+ throw new Error('--add expects a number of bytes');
293
+ }
294
+ if (args.as !== null && !DESTINATIONS[args.as]) {
295
+ throw new Error(
296
+ `unknown destination: ${args.as}. Valid: ${Object.keys(DESTINATIONS).join(', ')}`,
297
+ );
298
+ }
299
+ if (args.budget !== null && (!Number.isFinite(args.budget) || args.budget < 0)) {
300
+ throw new Error('--budget expects a non-negative number of bytes');
301
+ }
302
+ return args;
303
+ }
304
+
305
+ function renderHuman(m, budget, projection) {
306
+ const row = (bytes, label, rest) =>
307
+ `${String(bytes).padStart(7)} ${label.padEnd(12)} ${rest}`;
308
+ const lines = [];
309
+ for (const f of m.files) {
310
+ lines.push(row(f.bytes, f.kind, f.path));
311
+ }
312
+ lines.push('-'.repeat(60));
313
+ lines.push(
314
+ row(m.totalBytes, 'TOTAL',
315
+ `~${m.totalTokens} tokens, ${m.percentOfWindow}% of a ${CONTEXT_WINDOW / 1000}k window`),
316
+ );
317
+ if (m.local.totalBytes > 0) {
318
+ lines.push(row(m.local.totalBytes, 'local', '(per-user, not counted above)'));
319
+ }
320
+ if (m.conditional.totalBytes > 0) {
321
+ lines.push('');
322
+ lines.push('conditional (loads only on matching paths, not counted above):');
323
+ for (const f of m.conditional.files) {
324
+ lines.push(row(f.bytes, f.kind, f.path));
325
+ }
326
+ lines.push(row(m.conditional.totalBytes, 'scoped', '(conditional rules, not counted above)'));
327
+ }
328
+ if (m.missingImports.length > 0) {
329
+ lines.push(` dangling @-imports: ${m.missingImports.join(', ')}`);
330
+ }
331
+ if (budget) {
332
+ lines.push('');
333
+ lines.push(`BUDGET ${m.totalBytes}/${budget.bytes} bytes — ${budget.overBudget ? 'OVER' : 'ok'}`);
334
+ }
335
+ if (projection) {
336
+ lines.push('');
337
+ lines.push(
338
+ `+${projection.addedBytes} B as "${projection.destination}" ` +
339
+ `=> +${projection.alwaysLoadedDelta} B always-loaded`,
340
+ );
341
+ lines.push(
342
+ `${m.totalBytes} B (${m.percentOfWindow}%) -> ` +
343
+ `${projection.newTotalBytes} B (${projection.newPercentOfWindow}%)`,
344
+ );
345
+ if (projection.overBudgetAfter !== undefined) {
346
+ lines.push(
347
+ projection.overBudgetAfter
348
+ ? `over budget: ${projection.newTotalBytes} > ${projection.budgetBytes} B`
349
+ : `within budget: ${projection.newTotalBytes} / ${projection.budgetBytes} B`,
350
+ );
351
+ }
352
+ lines.push(projection.note);
353
+ }
354
+ return lines.join('\n');
355
+ }
356
+
357
+ function main() {
358
+ const args = parseArgs(process.argv);
359
+ const m = measure({ root: args.root });
360
+ const budgetBytes = args.budget !== null ? args.budget : readBudget(m.root);
361
+ const budget = budgetBytes === null
362
+ ? null
363
+ : { bytes: budgetBytes, overBudget: m.totalBytes > budgetBytes };
364
+ const projection = args.add !== null ? projectCost(m, args.add, args.as, budgetBytes) : null;
365
+ if (args.json) {
366
+ process.stdout.write(
367
+ JSON.stringify(
368
+ { ...m, budget: budgetBytes, overBudget: budget ? budget.overBudget : false, projection },
369
+ null,
370
+ 2,
371
+ ) + '\n',
372
+ );
373
+ } else {
374
+ process.stdout.write(renderHuman(m, budget, projection) + '\n');
375
+ }
376
+ if (budget?.overBudget) process.exitCode = 1;
377
+ }
378
+
379
+ if (isMainModule(import.meta.url)) {
380
+ try {
381
+ main();
382
+ } catch (err) {
383
+ process.stderr.write(`context-footprint: ${err.message}\n`);
384
+ process.exit(2);
385
+ }
386
+ }
387
+
388
+ export {
389
+ measure, projectCost, parseArgs, resolveImports, readBudget, frontmatterHasPaths,
390
+ DESTINATIONS, BYTES_PER_TOKEN, CONTEXT_WINDOW,
391
+ };
@@ -110,6 +110,35 @@ 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, head, 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 (head) args.push('--head', head);
127
+ if (search) args.push('--search', search);
128
+ const stdout = ghOrThrow(args).trim();
129
+ const raw = stdout ? JSON.parse(stdout) : [];
130
+ return raw.map((p) => ({
131
+ id: `${target}#${p.number}`,
132
+ number: p.number,
133
+ title: p.title,
134
+ url: p.url,
135
+ headRefName: p.headRefName,
136
+ baseRefName: p.baseRefName,
137
+ mergedAt: p.mergedAt,
138
+ state: p.state,
139
+ }));
140
+ }
141
+
113
142
  async function releaseView({ tag, repo }) {
114
143
  if (!tag) throw new Error('releaseView: tag is required');
115
144
  const target = repoFor(repo);
@@ -132,6 +161,21 @@ export function createGithubAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
132
161
  };
133
162
  }
134
163
 
164
+ // Creating the release is where release notes now come from: with
165
+ // --generate-notes the forge builds them from merged PR titles, so the
166
+ // workspace keeps no notes files of its own (gh:157).
167
+ async function releaseCreate({ tag, target, title, generateNotes = true, repo }) {
168
+ if (!tag) throw new Error('releaseCreate: tag is required');
169
+ const args = ['release', 'create', tag, '--repo', repoFor(repo)];
170
+ if (target) args.push('--target', target);
171
+ if (title) args.push('--title', title);
172
+ if (generateNotes) args.push('--generate-notes');
173
+ const stdout = ghOrThrow(args).trim();
174
+ // gh prints the release URL on success; sometimes preceded by warnings.
175
+ const url = stdout.split('\n').filter(Boolean).pop();
176
+ return { url, tag };
177
+ }
178
+
135
179
  async function workflowRunFind({ workflow, branch, repo, limit = 1 }) {
136
180
  if (!workflow) throw new Error('workflowRunFind: workflow is required');
137
181
  const target = repoFor(repo);
@@ -181,7 +225,9 @@ export function createGithubAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
181
225
  prCreate,
182
226
  prMerge,
183
227
  prView,
228
+ prList,
184
229
  releaseView,
230
+ releaseCreate,
185
231
  workflowRunFind,
186
232
  workflowRunWatch,
187
233
  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,22 @@
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?, head?, search?, limit = 100, repo? })
21
+ // → [{ id, number, title, url, headRefName, baseRefName, mergedAt, state }]
22
+ // `base`/`head` filter by target/source branch (e.g. the open PR for a
23
+ // task branch); `search` passes through the forge's own search syntax
24
+ // (e.g. 'merged:>2026-01-01'), so callers can bound a window without
25
+ // this interface growing a date vocabulary.
20
26
  // releaseView({ tag, repo? })
21
27
  // → { tag, url, name, publishedAt }
22
28
  // throws ReleaseNotFound if the tag has no release
29
+ // releaseCreate({ tag, target?, title?, generateNotes = true, repo? })
30
+ // → { url, tag }
31
+ // target: commitish the tag points at (default: the repo's default
32
+ // branch head); title: release name (default: the tag)
33
+ // generateNotes: when true (the default) the forge generates the
34
+ // release notes from merged PRs — this is the only notes mechanism
35
+ // the workspace ships.
23
36
  // workflowRunFind({ workflow, branch, repo?, limit = 1 })
24
37
  // → { runId, status, conclusion, url } | null
25
38
  // 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 };