@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.
- package/README.md +3 -3
- package/lib/init.mjs +4 -1
- package/lib/payload.mjs +18 -1
- package/lib/payload.test.mjs +55 -0
- package/lib/scaffold.mjs +23 -6
- package/lib/scaffold.test.mjs +59 -0
- package/package.json +1 -1
- package/template/CLAUDE.md.tmpl +19 -2
- package/template/{.claude → _claude}/hooks/_utils.mjs +1 -1
- package/template/_claude/hooks/repo-write-detection.mjs +204 -0
- package/template/{.claude → _claude}/hooks/session-start.mjs +35 -1
- package/template/_claude/hooks/subagent-start.mjs +111 -0
- package/template/{.claude → _claude}/lib/session-frontmatter.mjs +28 -0
- package/template/{.claude → _claude}/rules/coherent-revisions.md +1 -1
- package/template/_claude/rules/forge-operations.md +57 -0
- package/template/_claude/rules/git-conventions.md +39 -0
- package/template/_claude/rules/goal-driven-work.md +24 -0
- package/template/_claude/rules/honest-pushback.md +56 -0
- package/template/_claude/rules/memory-guidance.md +66 -0
- package/template/{.claude → _claude}/rules/superpowers-workflow.md.skip +1 -1
- package/template/{.claude → _claude}/rules/task-list-mirroring.md +6 -0
- package/template/_claude/rules/work-item-tracking.md +48 -0
- package/template/_claude/rules/workspace-structure.md +79 -0
- package/template/{.claude → _claude}/scripts/build-workspace-context.mjs +86 -30
- package/template/_claude/scripts/chat-record.mjs +315 -0
- package/template/_claude/scripts/cleanup-work-session.mjs +436 -0
- package/template/_claude/scripts/context-footprint.mjs +391 -0
- package/template/{.claude → _claude}/scripts/forges/github.mjs +46 -0
- package/template/{.claude → _claude}/scripts/forges/gitlab.mjs +3 -2
- package/template/{.claude → _claude}/scripts/forges/interface.mjs +13 -0
- package/template/{.claude → _claude}/scripts/generate-claude-local.mjs +21 -2
- package/template/_claude/scripts/migrate-sessions.mjs +1571 -0
- package/template/{.claude → _claude}/scripts/migrate-to-workspace-context.mjs +7 -2
- package/template/_claude/scripts/task-pr.mjs +447 -0
- package/template/_claude/scripts/task-worktree.mjs +525 -0
- package/template/{.claude → _claude}/scripts/trackers/github-issues.mjs +11 -0
- package/template/{.claude → _claude}/scripts/trackers/interface.mjs +8 -0
- package/template/_claude/scripts/workspace-diagnostics.mjs +654 -0
- package/template/{.claude → _claude}/skills/braindump/SKILL.md +12 -4
- package/template/{.claude → _claude}/skills/build-docs-site/SKILL.md +5 -5
- package/template/{.claude → _claude}/skills/build-docs-site/templates/spec.md.tmpl +1 -1
- package/template/_claude/skills/complete-work/SKILL.md +452 -0
- package/template/_claude/skills/context-placement/SKILL.md +202 -0
- package/template/{.claude/rules/goal-driven-work.md → _claude/skills/goal-driven-work/SKILL.md} +46 -19
- package/template/{.claude → _claude}/skills/handoff/SKILL.md +12 -4
- package/template/{.claude → _claude}/skills/maintenance/SKILL.md +56 -17
- package/template/_claude/skills/migrate-sessions/SKILL.md +70 -0
- package/template/{.claude → _claude}/skills/pause-work/SKILL.md +9 -1
- package/template/_claude/skills/release/SKILL.md +91 -0
- package/template/{.claude → _claude}/skills/start-work/SKILL.md +89 -7
- package/template/{.claude → _claude}/skills/workspace-init/SKILL.md +3 -1
- package/template/{.claude → _claude}/skills/workspace-update/SKILL.md +4 -0
- package/template/_gitignore +9 -0
- package/template/workspace.json.tmpl +4 -3
- package/template/.claude/hooks/repo-write-detection.mjs +0 -107
- package/template/.claude/hooks/subagent-start.mjs +0 -44
- package/template/.claude/rules/forge-operations.md +0 -107
- package/template/.claude/rules/git-conventions.md +0 -34
- package/template/.claude/rules/honest-pushback.md +0 -56
- package/template/.claude/rules/memory-guidance.md +0 -109
- package/template/.claude/rules/work-item-tracking.md +0 -90
- package/template/.claude/rules/workspace-structure.md +0 -137
- package/template/.claude/scripts/cleanup-work-session.mjs +0 -247
- package/template/.claude/skills/complete-work/SKILL.md +0 -498
- package/template/.claude/skills/release/SKILL.md +0 -151
- /package/template/{.claude → _claude}/agents/aside-researcher.md +0 -0
- /package/template/{.claude → _claude}/agents/implementer.md +0 -0
- /package/template/{.claude → _claude}/agents/researcher.md +0 -0
- /package/template/{.claude → _claude}/agents/reviewer.md +0 -0
- /package/template/{.claude → _claude}/hooks/bash-output-advisory.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/post-compact.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/pre-compact.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/session-end.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/version-freshness-check.mjs +0 -0
- /package/template/{.claude → _claude}/hooks/workspace-update-check.mjs +0 -0
- /package/template/{.claude → _claude}/lib/freshness.mjs +0 -0
- /package/template/{.claude → _claude}/lib/registry-check.mjs +0 -0
- /package/template/{.claude → _claude}/lib/require-node.mjs +0 -0
- /package/template/{.claude → _claude}/recipes/migrate-from-notion.md +0 -0
- /package/template/{.claude → _claude}/rules/agent-rules.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/cloud-infrastructure.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/config-review.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/documentation.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/local-dev-environment.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/product-integrity.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/scope-guard.md.skip +0 -0
- /package/template/{.claude → _claude}/rules/token-economics.md.skip +0 -0
- /package/template/{.claude → _claude}/scripts/add-repo-to-session.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/capture-context.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/create-work-session.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-canonical-priority.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-claude-md-freshness-include.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-open-work.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/migrate-session-layout.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/sweep-references.mjs +0 -0
- /package/template/{.claude → _claude}/scripts/sync-tasks.mjs +0 -0
- /package/template/{.claude → _claude}/settings.json +0 -0
- /package/template/{.claude → _claude}/skills/aside/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/checklists/framing.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/checklists/pitfalls.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/checklists/review.md +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/scripts/bulk-fill-migration.py +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/scripts/forbidden-word-grep.mjs +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/scripts/leak-grep.mjs +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/custom.css.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/docusaurus.config.ts.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Arrow.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Box.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/DiagramContainer.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/Region.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/SectionTitle.tsx +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/primitives/tokens.ts +0 -0
- /package/template/{.claude → _claude}/skills/build-docs-site/templates/sidebars.ts.tmpl +0 -0
- /package/template/{.claude → _claude}/skills/promote/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/setup-tracker/SKILL.md +0 -0
- /package/template/{.claude → _claude}/skills/sync-work/SKILL.md +0 -0
- /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),
|
|
10
|
-
// shape for testability, same error types from
|
|
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
|
-
|
|
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 };
|