@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
|
@@ -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 };
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// GitHub forge adapter. Wraps the `gh` CLI via an injectable spawnFn so
|
|
2
|
+
// tests can mock without spawning a real subprocess (mirrors the pattern
|
|
3
|
+
// in trackers/github-issues.mjs).
|
|
4
|
+
//
|
|
5
|
+
// All operations target a single default `repo` resolved at construction
|
|
6
|
+
// time from `config.repo` (e.g. `"owner/name"`) or from the local git
|
|
7
|
+
// origin remote when `config.repo` is unset or `"auto"`. Per-call `repo`
|
|
8
|
+
// overrides allow targeting a different repo when needed.
|
|
9
|
+
|
|
10
|
+
import '../../lib/require-node.mjs';
|
|
11
|
+
import { spawnSync as nodeSpawnSync } from 'node:child_process';
|
|
12
|
+
import {
|
|
13
|
+
PrNotFound,
|
|
14
|
+
ReleaseNotFound,
|
|
15
|
+
WorkflowNotFound,
|
|
16
|
+
MergeRejected,
|
|
17
|
+
} from './interface.mjs';
|
|
18
|
+
|
|
19
|
+
const PR_VIEW_FIELDS = 'number,url,state,title,mergeable,mergeStateStatus,reviewDecision,headRefName,baseRefName,isDraft,mergedAt';
|
|
20
|
+
|
|
21
|
+
export function createGithubAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
|
|
22
|
+
const defaultRepo = resolveRepo(config, spawnFn);
|
|
23
|
+
|
|
24
|
+
function gh(args, { input } = {}) {
|
|
25
|
+
const result = spawnFn('gh', args, {
|
|
26
|
+
input,
|
|
27
|
+
encoding: 'utf-8',
|
|
28
|
+
stdio: input !== undefined ? ['pipe', 'pipe', 'pipe'] : ['inherit', 'pipe', 'pipe'],
|
|
29
|
+
});
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ghOrThrow(args, opts) {
|
|
34
|
+
const result = gh(args, opts);
|
|
35
|
+
if (result.status !== 0) {
|
|
36
|
+
throw new Error(`gh ${args.join(' ')} failed: ${(result.stderr || '').trim()}`);
|
|
37
|
+
}
|
|
38
|
+
return result.stdout || '';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function repoFor(override) {
|
|
42
|
+
return override || defaultRepo;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function prCreate({ title, body = '', draft = false, base, head, repo }) {
|
|
46
|
+
if (!title) throw new Error('prCreate: title is required');
|
|
47
|
+
const args = ['pr', 'create', '--repo', repoFor(repo), '--title', title, '--body-file', '-'];
|
|
48
|
+
if (draft) args.push('--draft');
|
|
49
|
+
if (base) args.push('--base', base);
|
|
50
|
+
if (head) args.push('--head', head);
|
|
51
|
+
const stdout = ghOrThrow(args, { input: body }).trim();
|
|
52
|
+
// gh pr create prints the PR URL on success; sometimes preceded by warnings.
|
|
53
|
+
const url = stdout.split('\n').filter(Boolean).pop();
|
|
54
|
+
const m = url.match(/\/pull\/(\d+)/);
|
|
55
|
+
if (!m) throw new Error(`Could not parse PR number from gh output: ${stdout}`);
|
|
56
|
+
const number = parseInt(m[1], 10);
|
|
57
|
+
return { id: `${repoFor(repo)}#${number}`, url, number };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function prMerge({ id, strategy = 'merge', deleteBranch = false, repo }) {
|
|
61
|
+
if (!id) throw new Error('prMerge: id is required');
|
|
62
|
+
const { number, repo: parsedRepo } = parsePrId(id, repoFor(repo));
|
|
63
|
+
const args = ['pr', 'merge', String(number), '--repo', parsedRepo];
|
|
64
|
+
switch (strategy) {
|
|
65
|
+
case 'merge': args.push('--merge'); break;
|
|
66
|
+
case 'squash': args.push('--squash'); break;
|
|
67
|
+
case 'rebase': args.push('--rebase'); break;
|
|
68
|
+
default: throw new Error(`prMerge: unknown strategy: ${strategy}`);
|
|
69
|
+
}
|
|
70
|
+
if (deleteBranch) args.push('--delete-branch');
|
|
71
|
+
const result = gh(args);
|
|
72
|
+
if (result.status !== 0) {
|
|
73
|
+
const stderr = (result.stderr || '').trim();
|
|
74
|
+
// Distinguish "not found" from "rejected" so callers can react.
|
|
75
|
+
if (/not\s+found|could\s+not\s+resolve/i.test(stderr)) {
|
|
76
|
+
throw new PrNotFound(id);
|
|
77
|
+
}
|
|
78
|
+
throw new MergeRejected(id, stderr || 'gh pr merge exited non-zero');
|
|
79
|
+
}
|
|
80
|
+
return { merged: true, url: `https://github.com/${parsedRepo}/pull/${number}` };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function prView({ id, repo, json }) {
|
|
84
|
+
if (!id) throw new Error('prView: id is required');
|
|
85
|
+
const { number, repo: parsedRepo } = parsePrId(id, repoFor(repo));
|
|
86
|
+
const fields = json || PR_VIEW_FIELDS;
|
|
87
|
+
const result = gh(['pr', 'view', String(number), '--repo', parsedRepo, '--json', fields]);
|
|
88
|
+
if (result.status !== 0) {
|
|
89
|
+
const stderr = (result.stderr || '').trim();
|
|
90
|
+
if (/not\s+found|could\s+not\s+resolve/i.test(stderr)) {
|
|
91
|
+
throw new PrNotFound(id);
|
|
92
|
+
}
|
|
93
|
+
throw new Error(`gh pr view failed: ${stderr}`);
|
|
94
|
+
}
|
|
95
|
+
const raw = JSON.parse(result.stdout);
|
|
96
|
+
return {
|
|
97
|
+
id,
|
|
98
|
+
number: raw.number,
|
|
99
|
+
url: raw.url,
|
|
100
|
+
state: raw.state,
|
|
101
|
+
title: raw.title,
|
|
102
|
+
mergeable: raw.mergeable,
|
|
103
|
+
mergeStateStatus: raw.mergeStateStatus,
|
|
104
|
+
reviewDecision: raw.reviewDecision,
|
|
105
|
+
headRefName: raw.headRefName,
|
|
106
|
+
baseRefName: raw.baseRefName,
|
|
107
|
+
isDraft: raw.isDraft,
|
|
108
|
+
mergedAt: raw.mergedAt,
|
|
109
|
+
_raw: raw,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
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
|
+
|
|
141
|
+
async function releaseView({ tag, repo }) {
|
|
142
|
+
if (!tag) throw new Error('releaseView: tag is required');
|
|
143
|
+
const target = repoFor(repo);
|
|
144
|
+
const result = gh(['release', 'view', tag, '--repo', target, '--json', 'name,tagName,url,publishedAt,isDraft,isPrerelease']);
|
|
145
|
+
if (result.status !== 0) {
|
|
146
|
+
const stderr = (result.stderr || '').trim();
|
|
147
|
+
if (/release\s+not\s+found|not\s+found/i.test(stderr)) {
|
|
148
|
+
throw new ReleaseNotFound(tag);
|
|
149
|
+
}
|
|
150
|
+
throw new Error(`gh release view failed: ${stderr}`);
|
|
151
|
+
}
|
|
152
|
+
const raw = JSON.parse(result.stdout);
|
|
153
|
+
return {
|
|
154
|
+
tag: raw.tagName,
|
|
155
|
+
name: raw.name,
|
|
156
|
+
url: raw.url,
|
|
157
|
+
publishedAt: raw.publishedAt,
|
|
158
|
+
isDraft: raw.isDraft,
|
|
159
|
+
isPrerelease: raw.isPrerelease,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
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
|
+
|
|
178
|
+
async function workflowRunFind({ workflow, branch, repo, limit = 1 }) {
|
|
179
|
+
if (!workflow) throw new Error('workflowRunFind: workflow is required');
|
|
180
|
+
const target = repoFor(repo);
|
|
181
|
+
const args = [
|
|
182
|
+
'run', 'list',
|
|
183
|
+
'--repo', target,
|
|
184
|
+
'--workflow', workflow,
|
|
185
|
+
'--limit', String(limit),
|
|
186
|
+
'--json', 'databaseId,status,conclusion,url,headBranch,createdAt',
|
|
187
|
+
];
|
|
188
|
+
if (branch) args.push('--branch', branch);
|
|
189
|
+
const result = gh(args);
|
|
190
|
+
if (result.status !== 0) {
|
|
191
|
+
throw new Error(`gh run list failed: ${(result.stderr || '').trim()}`);
|
|
192
|
+
}
|
|
193
|
+
const runs = JSON.parse(result.stdout);
|
|
194
|
+
if (runs.length === 0) return null;
|
|
195
|
+
const first = runs[0];
|
|
196
|
+
return {
|
|
197
|
+
runId: String(first.databaseId),
|
|
198
|
+
status: first.status,
|
|
199
|
+
conclusion: first.conclusion,
|
|
200
|
+
url: first.url,
|
|
201
|
+
branch: first.headBranch,
|
|
202
|
+
createdAt: first.createdAt,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function workflowRunWatch({ runId, repo, exitStatus = false }) {
|
|
207
|
+
if (!runId) throw new Error('workflowRunWatch: runId is required');
|
|
208
|
+
const target = repoFor(repo);
|
|
209
|
+
const args = ['run', 'watch', String(runId), '--repo', target];
|
|
210
|
+
if (exitStatus) args.push('--exit-status');
|
|
211
|
+
const result = gh(args);
|
|
212
|
+
if (result.status === 0) return { exitCode: 0 };
|
|
213
|
+
// Distinguish "couldn't find" from "ran but failed".
|
|
214
|
+
const stderr = (result.stderr || '').trim();
|
|
215
|
+
if (/not\s+found|could\s+not\s+find/i.test(stderr)) {
|
|
216
|
+
throw new WorkflowNotFound({ runId });
|
|
217
|
+
}
|
|
218
|
+
// `--exit-status` makes gh exit non-zero on workflow failure; surface
|
|
219
|
+
// that without throwing so callers can record the failure URL.
|
|
220
|
+
return { exitCode: result.status, stderr };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
prCreate,
|
|
225
|
+
prMerge,
|
|
226
|
+
prView,
|
|
227
|
+
prList,
|
|
228
|
+
releaseView,
|
|
229
|
+
releaseCreate,
|
|
230
|
+
workflowRunFind,
|
|
231
|
+
workflowRunWatch,
|
|
232
|
+
get identity() { return `github:${defaultRepo}`; },
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// "owner/name#NUMBER" or just NUMBER (defaulting to the adapter's repo).
|
|
237
|
+
function parsePrId(id, fallbackRepo) {
|
|
238
|
+
if (typeof id === 'number') return { number: id, repo: fallbackRepo };
|
|
239
|
+
const m1 = String(id).match(/^(?<repo>[^#\s]+\/[^#\s]+)#(?<number>\d+)$/);
|
|
240
|
+
if (m1) return { number: parseInt(m1.groups.number, 10), repo: m1.groups.repo };
|
|
241
|
+
const m2 = String(id).match(/^#?(\d+)$/);
|
|
242
|
+
if (m2) return { number: parseInt(m2[1], 10), repo: fallbackRepo };
|
|
243
|
+
throw new Error(`Unparseable PR id: ${id}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function resolveRepo(config, spawnFn) {
|
|
247
|
+
if (config?.repo && config.repo !== 'auto') return config.repo;
|
|
248
|
+
const result = spawnFn('git', ['remote', 'get-url', 'origin'], { encoding: 'utf-8' });
|
|
249
|
+
if (result.status !== 0) {
|
|
250
|
+
throw new Error(`git remote get-url failed: ${(result.stderr || '').trim()}`);
|
|
251
|
+
}
|
|
252
|
+
const m = result.stdout.trim().match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
253
|
+
if (!m) throw new Error(`Cannot parse GitHub remote: ${result.stdout.trim()}`);
|
|
254
|
+
return `${m[1]}/${m[2]}`;
|
|
255
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// GitLab forge adapter — stub. Reserved as the next sibling of github.mjs
|
|
2
|
+
// so the discovery pattern stays uniform: workspaces opting into GitLab
|
|
3
|
+
// set `workspace.forge.type: gitlab` in workspace.json and get a clear
|
|
4
|
+
// "not implemented" error pointing at the gap, rather than silently
|
|
5
|
+
// routing through GitHub.
|
|
6
|
+
//
|
|
7
|
+
// When implemented, this adapter wraps the `glab` CLI the same way
|
|
8
|
+
// github.mjs wraps `gh`: same method surface (prCreate, prMerge, prView,
|
|
9
|
+
// prList, releaseView, releaseCreate, workflowRunFind, workflowRunWatch),
|
|
10
|
+
// same spawnFn-injectable shape for testability, same error types from
|
|
11
|
+
// interface.mjs.
|
|
12
|
+
|
|
13
|
+
import { ForgeError } from './interface.mjs';
|
|
14
|
+
|
|
15
|
+
export function createGitlabAdapter(config /* , options */) {
|
|
16
|
+
throw new ForgeError(
|
|
17
|
+
'GitLab forge adapter is not implemented yet. Set workspace.forge.type to "github", or contribute a glab-based adapter at .claude/scripts/forges/gitlab.mjs following the shape of github.mjs.',
|
|
18
|
+
'NOT_IMPLEMENTED',
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Forge adapter interface. Skills import only from this module.
|
|
2
|
+
//
|
|
3
|
+
// Where `trackers/` covers issue lifecycle (issues, comments, labels,
|
|
4
|
+
// milestones), `forges/` covers cross-cutting repo-host operations:
|
|
5
|
+
// pull requests, releases, and workflow runs. The two abstractions are
|
|
6
|
+
// intentionally separate — a workspace could in principle mix
|
|
7
|
+
// (e.g. `github-issues` tracker + `gitlab` forge), though the common
|
|
8
|
+
// case is forge = same host as tracker.
|
|
9
|
+
//
|
|
10
|
+
// Method contracts:
|
|
11
|
+
//
|
|
12
|
+
// prCreate({ title, body, draft = false, base, head, repo? })
|
|
13
|
+
// → { id, url, number }
|
|
14
|
+
// prMerge({ id, strategy = 'merge', deleteBranch = false, repo? })
|
|
15
|
+
// → { merged: true, url }
|
|
16
|
+
// strategy: 'merge' | 'squash' | 'rebase'
|
|
17
|
+
// prView({ id, repo?, json? })
|
|
18
|
+
// → { id, url, state, mergeable, mergeStateStatus, reviewDecision, title }
|
|
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.
|
|
25
|
+
// releaseView({ tag, repo? })
|
|
26
|
+
// → { tag, url, name, publishedAt }
|
|
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.
|
|
35
|
+
// workflowRunFind({ workflow, branch, repo?, limit = 1 })
|
|
36
|
+
// → { runId, status, conclusion, url } | null
|
|
37
|
+
// workflowRunWatch({ runId, repo?, exitStatus = false })
|
|
38
|
+
// → { exitCode }
|
|
39
|
+
// exitStatus: when true, the underlying command exits non-zero on
|
|
40
|
+
// workflow failure; the adapter still returns the exit code rather
|
|
41
|
+
// than throwing — callers decide how to handle a failed run.
|
|
42
|
+
//
|
|
43
|
+
// `repo` defaults: each adapter resolves a default repo at construction
|
|
44
|
+
// time (e.g. from `workspace.forge.repo` or the local git origin remote);
|
|
45
|
+
// callers pass `repo` only when targeting a different one.
|
|
46
|
+
//
|
|
47
|
+
// All methods are async and may throw `ForgeError` subclasses on
|
|
48
|
+
// adapter-detectable failures. Raw spawn failures throw `Error`.
|
|
49
|
+
|
|
50
|
+
import '../../lib/require-node.mjs';
|
|
51
|
+
import { createGithubAdapter } from './github.mjs';
|
|
52
|
+
import { createGitlabAdapter } from './gitlab.mjs';
|
|
53
|
+
|
|
54
|
+
export class ForgeError extends Error {
|
|
55
|
+
constructor(message, code) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = 'ForgeError';
|
|
58
|
+
this.code = code;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class PrNotFound extends ForgeError {
|
|
63
|
+
constructor(id) {
|
|
64
|
+
super(`Pull request not found: ${id}`, 'PR_NOT_FOUND');
|
|
65
|
+
this.name = 'PrNotFound';
|
|
66
|
+
this.id = id;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class ReleaseNotFound extends ForgeError {
|
|
71
|
+
constructor(tag) {
|
|
72
|
+
super(`Release not found for tag: ${tag}`, 'RELEASE_NOT_FOUND');
|
|
73
|
+
this.name = 'ReleaseNotFound';
|
|
74
|
+
this.tag = tag;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class WorkflowNotFound extends ForgeError {
|
|
79
|
+
constructor(query) {
|
|
80
|
+
super(`Workflow run not found: ${JSON.stringify(query)}`, 'WORKFLOW_NOT_FOUND');
|
|
81
|
+
this.name = 'WorkflowNotFound';
|
|
82
|
+
this.query = query;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class MergeRejected extends ForgeError {
|
|
87
|
+
constructor(id, reason) {
|
|
88
|
+
super(`Merge rejected for ${id}: ${reason}`, 'MERGE_REJECTED');
|
|
89
|
+
this.name = 'MergeRejected';
|
|
90
|
+
this.id = id;
|
|
91
|
+
this.reason = reason;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// createForge takes the `workspace.forge` config block. If the block is
|
|
96
|
+
// absent (`undefined`/`null`), default to GitHub — this matches the
|
|
97
|
+
// migration story documented in `.claude/rules/forge-operations.md`:
|
|
98
|
+
// existing workspaces predate the field, so an unset value means
|
|
99
|
+
// "behave as you always have." A workspace that wants to opt out of
|
|
100
|
+
// forge operations entirely should set `workspace.forge: false`;
|
|
101
|
+
// callers passing `false` will get a no-op throw on every method.
|
|
102
|
+
export function createForge(config, options = {}) {
|
|
103
|
+
if (config === false) {
|
|
104
|
+
throw new ForgeError(
|
|
105
|
+
'Forge operations disabled — set workspace.forge in workspace.json to enable.',
|
|
106
|
+
'FORGE_DISABLED',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
const resolved = config ?? { type: 'github' };
|
|
110
|
+
if (typeof resolved !== 'object') {
|
|
111
|
+
throw new ForgeError(
|
|
112
|
+
`Invalid workspace.forge config: expected object, got ${typeof resolved}`,
|
|
113
|
+
'INVALID_CONFIG',
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const type = resolved.type ?? 'github';
|
|
117
|
+
switch (type) {
|
|
118
|
+
case 'github':
|
|
119
|
+
return createGithubAdapter(resolved, options);
|
|
120
|
+
case 'gitlab':
|
|
121
|
+
return createGitlabAdapter(resolved, options);
|
|
122
|
+
default:
|
|
123
|
+
throw new ForgeError(`Unknown forge type: ${type}`, 'UNKNOWN_TYPE');
|
|
124
|
+
}
|
|
125
|
+
}
|