@ulysses-ai/create-workspace 0.16.0-beta.1 → 0.17.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 +2 -2
- package/lib/init.mjs +19 -0
- package/package.json +1 -1
- package/template/.claude/hooks/session-end.mjs +68 -2
- package/template/.claude/rules/config-review.md.skip +29 -0
- package/template/.claude/rules/forge-operations.md +107 -0
- package/template/.claude/rules/goal-driven-work.md +13 -0
- package/template/.claude/rules/workspace-structure.md +38 -0
- package/template/.claude/scripts/cleanup-work-session.mjs +164 -26
- package/template/.claude/scripts/forges/github.mjs +210 -0
- package/template/.claude/scripts/forges/gitlab.mjs +19 -0
- package/template/.claude/scripts/forges/interface.mjs +113 -0
- package/template/.claude/settings.json +5 -13
- package/template/.claude/skills/complete-work/SKILL.md +67 -37
- package/template/.claude/skills/maintenance/SKILL.md +32 -6
- package/template/.claude/skills/pause-work/SKILL.md +24 -7
- package/template/.claude/skills/workspace-init/SKILL.md +32 -0
- package/template/.claudeignore +3 -0
- package/template/CLAUDE.md.tmpl +1 -0
- package/template/CODEBASE.md.tmpl +13 -0
- package/template/repo-claude.md.tmpl +10 -0
- package/template/workspace.json.tmpl +2 -1
- package/template/.claude/hooks/worktree-create.mjs +0 -53
|
@@ -0,0 +1,210 @@
|
|
|
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
|
+
async function releaseView({ tag, repo }) {
|
|
114
|
+
if (!tag) throw new Error('releaseView: tag is required');
|
|
115
|
+
const target = repoFor(repo);
|
|
116
|
+
const result = gh(['release', 'view', tag, '--repo', target, '--json', 'name,tagName,url,publishedAt,isDraft,isPrerelease']);
|
|
117
|
+
if (result.status !== 0) {
|
|
118
|
+
const stderr = (result.stderr || '').trim();
|
|
119
|
+
if (/release\s+not\s+found|not\s+found/i.test(stderr)) {
|
|
120
|
+
throw new ReleaseNotFound(tag);
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`gh release view failed: ${stderr}`);
|
|
123
|
+
}
|
|
124
|
+
const raw = JSON.parse(result.stdout);
|
|
125
|
+
return {
|
|
126
|
+
tag: raw.tagName,
|
|
127
|
+
name: raw.name,
|
|
128
|
+
url: raw.url,
|
|
129
|
+
publishedAt: raw.publishedAt,
|
|
130
|
+
isDraft: raw.isDraft,
|
|
131
|
+
isPrerelease: raw.isPrerelease,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function workflowRunFind({ workflow, branch, repo, limit = 1 }) {
|
|
136
|
+
if (!workflow) throw new Error('workflowRunFind: workflow is required');
|
|
137
|
+
const target = repoFor(repo);
|
|
138
|
+
const args = [
|
|
139
|
+
'run', 'list',
|
|
140
|
+
'--repo', target,
|
|
141
|
+
'--workflow', workflow,
|
|
142
|
+
'--limit', String(limit),
|
|
143
|
+
'--json', 'databaseId,status,conclusion,url,headBranch,createdAt',
|
|
144
|
+
];
|
|
145
|
+
if (branch) args.push('--branch', branch);
|
|
146
|
+
const result = gh(args);
|
|
147
|
+
if (result.status !== 0) {
|
|
148
|
+
throw new Error(`gh run list failed: ${(result.stderr || '').trim()}`);
|
|
149
|
+
}
|
|
150
|
+
const runs = JSON.parse(result.stdout);
|
|
151
|
+
if (runs.length === 0) return null;
|
|
152
|
+
const first = runs[0];
|
|
153
|
+
return {
|
|
154
|
+
runId: String(first.databaseId),
|
|
155
|
+
status: first.status,
|
|
156
|
+
conclusion: first.conclusion,
|
|
157
|
+
url: first.url,
|
|
158
|
+
branch: first.headBranch,
|
|
159
|
+
createdAt: first.createdAt,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function workflowRunWatch({ runId, repo, exitStatus = false }) {
|
|
164
|
+
if (!runId) throw new Error('workflowRunWatch: runId is required');
|
|
165
|
+
const target = repoFor(repo);
|
|
166
|
+
const args = ['run', 'watch', String(runId), '--repo', target];
|
|
167
|
+
if (exitStatus) args.push('--exit-status');
|
|
168
|
+
const result = gh(args);
|
|
169
|
+
if (result.status === 0) return { exitCode: 0 };
|
|
170
|
+
// Distinguish "couldn't find" from "ran but failed".
|
|
171
|
+
const stderr = (result.stderr || '').trim();
|
|
172
|
+
if (/not\s+found|could\s+not\s+find/i.test(stderr)) {
|
|
173
|
+
throw new WorkflowNotFound({ runId });
|
|
174
|
+
}
|
|
175
|
+
// `--exit-status` makes gh exit non-zero on workflow failure; surface
|
|
176
|
+
// that without throwing so callers can record the failure URL.
|
|
177
|
+
return { exitCode: result.status, stderr };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
prCreate,
|
|
182
|
+
prMerge,
|
|
183
|
+
prView,
|
|
184
|
+
releaseView,
|
|
185
|
+
workflowRunFind,
|
|
186
|
+
workflowRunWatch,
|
|
187
|
+
get identity() { return `github:${defaultRepo}`; },
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// "owner/name#NUMBER" or just NUMBER (defaulting to the adapter's repo).
|
|
192
|
+
function parsePrId(id, fallbackRepo) {
|
|
193
|
+
if (typeof id === 'number') return { number: id, repo: fallbackRepo };
|
|
194
|
+
const m1 = String(id).match(/^(?<repo>[^#\s]+\/[^#\s]+)#(?<number>\d+)$/);
|
|
195
|
+
if (m1) return { number: parseInt(m1.groups.number, 10), repo: m1.groups.repo };
|
|
196
|
+
const m2 = String(id).match(/^#?(\d+)$/);
|
|
197
|
+
if (m2) return { number: parseInt(m2[1], 10), repo: fallbackRepo };
|
|
198
|
+
throw new Error(`Unparseable PR id: ${id}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function resolveRepo(config, spawnFn) {
|
|
202
|
+
if (config?.repo && config.repo !== 'auto') return config.repo;
|
|
203
|
+
const result = spawnFn('git', ['remote', 'get-url', 'origin'], { encoding: 'utf-8' });
|
|
204
|
+
if (result.status !== 0) {
|
|
205
|
+
throw new Error(`git remote get-url failed: ${(result.stderr || '').trim()}`);
|
|
206
|
+
}
|
|
207
|
+
const m = result.stdout.trim().match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
208
|
+
if (!m) throw new Error(`Cannot parse GitHub remote: ${result.stdout.trim()}`);
|
|
209
|
+
return `${m[1]}/${m[2]}`;
|
|
210
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
// releaseView, workflowRunFind, workflowRunWatch), same spawnFn-injectable
|
|
10
|
+
// shape for testability, same error types from interface.mjs.
|
|
11
|
+
|
|
12
|
+
import { ForgeError } from './interface.mjs';
|
|
13
|
+
|
|
14
|
+
export function createGitlabAdapter(config /* , options */) {
|
|
15
|
+
throw new ForgeError(
|
|
16
|
+
'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.',
|
|
17
|
+
'NOT_IMPLEMENTED',
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
// releaseView({ tag, repo? })
|
|
21
|
+
// → { tag, url, name, publishedAt }
|
|
22
|
+
// throws ReleaseNotFound if the tag has no release
|
|
23
|
+
// workflowRunFind({ workflow, branch, repo?, limit = 1 })
|
|
24
|
+
// → { runId, status, conclusion, url } | null
|
|
25
|
+
// workflowRunWatch({ runId, repo?, exitStatus = false })
|
|
26
|
+
// → { exitCode }
|
|
27
|
+
// exitStatus: when true, the underlying command exits non-zero on
|
|
28
|
+
// workflow failure; the adapter still returns the exit code rather
|
|
29
|
+
// than throwing — callers decide how to handle a failed run.
|
|
30
|
+
//
|
|
31
|
+
// `repo` defaults: each adapter resolves a default repo at construction
|
|
32
|
+
// time (e.g. from `workspace.forge.repo` or the local git origin remote);
|
|
33
|
+
// callers pass `repo` only when targeting a different one.
|
|
34
|
+
//
|
|
35
|
+
// All methods are async and may throw `ForgeError` subclasses on
|
|
36
|
+
// adapter-detectable failures. Raw spawn failures throw `Error`.
|
|
37
|
+
|
|
38
|
+
import '../../lib/require-node.mjs';
|
|
39
|
+
import { createGithubAdapter } from './github.mjs';
|
|
40
|
+
import { createGitlabAdapter } from './gitlab.mjs';
|
|
41
|
+
|
|
42
|
+
export class ForgeError extends Error {
|
|
43
|
+
constructor(message, code) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = 'ForgeError';
|
|
46
|
+
this.code = code;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class PrNotFound extends ForgeError {
|
|
51
|
+
constructor(id) {
|
|
52
|
+
super(`Pull request not found: ${id}`, 'PR_NOT_FOUND');
|
|
53
|
+
this.name = 'PrNotFound';
|
|
54
|
+
this.id = id;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class ReleaseNotFound extends ForgeError {
|
|
59
|
+
constructor(tag) {
|
|
60
|
+
super(`Release not found for tag: ${tag}`, 'RELEASE_NOT_FOUND');
|
|
61
|
+
this.name = 'ReleaseNotFound';
|
|
62
|
+
this.tag = tag;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class WorkflowNotFound extends ForgeError {
|
|
67
|
+
constructor(query) {
|
|
68
|
+
super(`Workflow run not found: ${JSON.stringify(query)}`, 'WORKFLOW_NOT_FOUND');
|
|
69
|
+
this.name = 'WorkflowNotFound';
|
|
70
|
+
this.query = query;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class MergeRejected extends ForgeError {
|
|
75
|
+
constructor(id, reason) {
|
|
76
|
+
super(`Merge rejected for ${id}: ${reason}`, 'MERGE_REJECTED');
|
|
77
|
+
this.name = 'MergeRejected';
|
|
78
|
+
this.id = id;
|
|
79
|
+
this.reason = reason;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// createForge takes the `workspace.forge` config block. If the block is
|
|
84
|
+
// absent (`undefined`/`null`), default to GitHub — this matches the
|
|
85
|
+
// migration story documented in `.claude/rules/forge-operations.md`:
|
|
86
|
+
// existing workspaces predate the field, so an unset value means
|
|
87
|
+
// "behave as you always have." A workspace that wants to opt out of
|
|
88
|
+
// forge operations entirely should set `workspace.forge: false`;
|
|
89
|
+
// callers passing `false` will get a no-op throw on every method.
|
|
90
|
+
export function createForge(config, options = {}) {
|
|
91
|
+
if (config === false) {
|
|
92
|
+
throw new ForgeError(
|
|
93
|
+
'Forge operations disabled — set workspace.forge in workspace.json to enable.',
|
|
94
|
+
'FORGE_DISABLED',
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const resolved = config ?? { type: 'github' };
|
|
98
|
+
if (typeof resolved !== 'object') {
|
|
99
|
+
throw new ForgeError(
|
|
100
|
+
`Invalid workspace.forge config: expected object, got ${typeof resolved}`,
|
|
101
|
+
'INVALID_CONFIG',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const type = resolved.type ?? 'github';
|
|
105
|
+
switch (type) {
|
|
106
|
+
case 'github':
|
|
107
|
+
return createGithubAdapter(resolved, options);
|
|
108
|
+
case 'gitlab':
|
|
109
|
+
return createGitlabAdapter(resolved, options);
|
|
110
|
+
default:
|
|
111
|
+
throw new ForgeError(`Unknown forge type: ${type}`, 'UNKNOWN_TYPE');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -92,25 +92,17 @@
|
|
|
92
92
|
}
|
|
93
93
|
]
|
|
94
94
|
}
|
|
95
|
-
],
|
|
96
|
-
"WorktreeCreate": [
|
|
97
|
-
{
|
|
98
|
-
"hooks": [
|
|
99
|
-
{
|
|
100
|
-
"type": "command",
|
|
101
|
-
"command": "node \"$(if command -v cygpath >/dev/null 2>&1; then cygpath -u \"${CLAUDE_PROJECT_DIR:-$PWD}\"; else echo \"${CLAUDE_PROJECT_DIR:-$PWD}\"; fi)\"/.claude/hooks/worktree-create.mjs",
|
|
102
|
-
"timeout": 5000,
|
|
103
|
-
"statusMessage": "Checking for stale worktrees..."
|
|
104
|
-
}
|
|
105
|
-
]
|
|
106
|
-
}
|
|
107
95
|
]
|
|
108
96
|
},
|
|
97
|
+
"worktree": {
|
|
98
|
+
"baseRef": "head"
|
|
99
|
+
},
|
|
109
100
|
"permissions": {
|
|
110
101
|
"allow": [
|
|
111
102
|
"Bash(git:*)",
|
|
112
103
|
"Bash(ls:*)"
|
|
113
|
-
]
|
|
104
|
+
],
|
|
105
|
+
"deny": []
|
|
114
106
|
},
|
|
115
107
|
"enabledPlugins": {
|
|
116
108
|
"playwright@claude-plugins-official": false
|
|
@@ -218,16 +218,27 @@ The push shape is the same as 9a — what differs is the merge mechanics in Step
|
|
|
218
218
|
|
|
219
219
|
#### Step 10a: GitHub remotes — create PRs, unified summary, merge
|
|
220
220
|
|
|
221
|
-
Create one PR per project repo plus one workspace PR
|
|
221
|
+
Create one PR per project repo plus one workspace PR. PR operations go through the forge adapter (`.claude/scripts/forges/interface.mjs`), not `gh` directly — see `.claude/rules/forge-operations.md` for the contract. The adapter resolves the target repo from `workspace.forge.repo` or the local git remote.
|
|
222
222
|
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
gh pr create --title "{type}: {description}" --body "..."
|
|
223
|
+
```javascript
|
|
224
|
+
import { createForge } from './.claude/scripts/forges/interface.mjs';
|
|
225
|
+
import { readFileSync } from 'node:fs';
|
|
227
226
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
227
|
+
const ws = JSON.parse(readFileSync('workspace.json', 'utf-8'));
|
|
228
|
+
const forge = createForge(ws.workspace?.forge);
|
|
229
|
+
|
|
230
|
+
// For each repo in the tracker's repos with a GitHub remote, from
|
|
231
|
+
// work-sessions/{session-name}/workspace/repos/{repo}:
|
|
232
|
+
const projectPr = await forge.prCreate({
|
|
233
|
+
title: `${type}: ${description}`,
|
|
234
|
+
body: prBody, // synthesized release notes + verification section
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Workspace PR — from the workspace worktree:
|
|
238
|
+
const workspacePr = await forge.prCreate({
|
|
239
|
+
title: `context: ${sessionName} work session`,
|
|
240
|
+
body: workspacePrBody,
|
|
241
|
+
});
|
|
231
242
|
```
|
|
232
243
|
|
|
233
244
|
Present unified summary:
|
|
@@ -254,15 +265,21 @@ WORKSPACE: {workspace-name}
|
|
|
254
265
|
Merge all? [Y/n]
|
|
255
266
|
```
|
|
256
267
|
|
|
257
|
-
If yes — merge all PRs atomically:
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
268
|
+
If yes — merge all PRs atomically through the forge adapter:
|
|
269
|
+
|
|
270
|
+
```javascript
|
|
271
|
+
// For each project PR returned from Step 10a's prCreate calls:
|
|
272
|
+
await forge.prMerge({ id: projectPr.id, strategy: 'squash', deleteBranch: true });
|
|
273
|
+
|
|
274
|
+
// Workspace PR:
|
|
275
|
+
await forge.prMerge({ id: workspacePr.id, strategy: 'squash', deleteBranch: true });
|
|
276
|
+
```
|
|
261
277
|
|
|
262
|
-
|
|
263
|
-
gh pr merge {workspace-pr-number} --merge
|
|
278
|
+
`strategy: 'squash'` matches the workspace convention from `post-release-discipline` (`create-ulysses-workspace` requires linear history, so squash is the only strategy that merges cleanly; squash also lifts the PR body into the commit message). `deleteBranch: true` cleans the remote feature branch on success.
|
|
264
279
|
|
|
265
|
-
|
|
280
|
+
Then pull all repos to their default branches (still plain git):
|
|
281
|
+
|
|
282
|
+
```bash
|
|
266
283
|
# For each repo in the tracker's repos:
|
|
267
284
|
cd repos/{repo} && git pull origin {repo-branch}
|
|
268
285
|
cd {main-workspace-root} && git pull origin main
|
|
@@ -274,7 +291,7 @@ The next three sub-substeps run only when the session branch starts with `releas
|
|
|
274
291
|
|
|
275
292
|
Derive the version tag from the branch name by stripping the `release/` prefix (so `release/v0.15.0-beta.0` yields `v0.15.0-beta.0`). For each project repo whose `package.json` declares a `version` field, verify that version matches the derived tag. The workspace repo is **never** tagged — only project repos with publishable `package.json` files get tagged, since the tag triggers `.github/workflows/publish.yml` in that project repo. If a project repo's `package.json` version doesn't match the release tag, skip that repo with a warning rather than failing the whole completion flow — the mismatch usually means `/release` was run against a different version than the branch name suggests, and the user needs to investigate before publishing.
|
|
276
293
|
|
|
277
|
-
Before tagging, preflight against origin: if `v{version}` already exists remotely, surface the conflict to the user with three explicit recovery options — **Reuse** (skip to 10a.2 if the existing tag points at the right commit), **Replace** (`git push origin --delete v{version}` then re-run 10a.1), or **Investigate** (`gh release view v{version}` to see what shipped). Do **not** silently force-push the tag; an existing tag means a published artifact, and overwriting it without confirmation can corrupt the npm registry's view of the release history.
|
|
294
|
+
Before tagging, preflight against origin: if `v{version}` already exists remotely, surface the conflict to the user with three explicit recovery options — **Reuse** (skip to 10a.2 if the existing tag points at the right commit), **Replace** (`git push origin --delete v{version}` then re-run 10a.1), or **Investigate** (`forge.releaseView({ tag: 'v{version}', repo: '{org}/{repo}' })` — or `gh release view v{version}` as a manual fallback — to see what shipped). Do **not** silently force-push the tag; an existing tag means a published artifact, and overwriting it without confirmation can corrupt the npm registry's view of the release history.
|
|
278
295
|
|
|
279
296
|
If the tag is absent on origin, tag the merge commit (HEAD on `{default-branch}` after the prior `git pull origin {default-branch}`) and push the tag. The tag push triggers `.github/workflows/publish.yml`.
|
|
280
297
|
|
|
@@ -305,7 +322,8 @@ for repo in {project-repos-with-package-json}; do
|
|
|
305
322
|
# Tag exists. Surface to user with three options:
|
|
306
323
|
# 1. Reuse — skip to 10a.2 if the existing tag points at the right commit.
|
|
307
324
|
# 2. Replace — `git push origin --delete $version_tag` then re-run 10a.1.
|
|
308
|
-
# 3. Investigate —
|
|
325
|
+
# 3. Investigate — call forge.releaseView({ tag: '$version_tag' }) — or
|
|
326
|
+
# `gh release view $version_tag` as a manual fallback — to see what shipped.
|
|
309
327
|
# Do NOT silently force-push.
|
|
310
328
|
echo "Tag $version_tag already exists on origin. Aborting with recovery options."
|
|
311
329
|
return 1
|
|
@@ -319,27 +337,39 @@ done
|
|
|
319
337
|
|
|
320
338
|
**Step 10a.2: Watch the publish workflow (release sessions only)**
|
|
321
339
|
|
|
322
|
-
For each project repo tagged in 10a.1, find and follow the `publish.yml` workflow run on GitHub. The workflow takes a moment to register against the new tag — poll
|
|
340
|
+
For each project repo tagged in 10a.1, find and follow the `publish.yml` workflow run on GitHub. The workflow takes a moment to register against the new tag — poll up to 5 times with a 3-second backoff before giving up. Once the run is found, attach with `workflowRunWatch` so the maintainer sees progress live alongside the unified summary. The adapter's `exitStatus: true` makes the underlying `gh run watch --exit-status` exit non-zero on workflow failure; the adapter returns the exit code via `res.exitCode` instead of throwing, so a failure does **not** abort the rest of `/complete-work` — the maintainer still needs to see the unified summary, including the failure URL, to decide whether to rerun, redo the release, or roll the tag back. If no run registers within the retry window, log a warning with the manual investigation command and continue.
|
|
323
341
|
|
|
324
|
-
```
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
run_id=$(gh run list \
|
|
328
|
-
--repo {org}/{repo} \
|
|
329
|
-
--workflow publish.yml \
|
|
330
|
-
--branch "$version_tag" \
|
|
331
|
-
--limit 1 \
|
|
332
|
-
--json databaseId \
|
|
333
|
-
--jq '.[0].databaseId')
|
|
334
|
-
if [ -n "$run_id" ]; then break; fi
|
|
335
|
-
sleep 3
|
|
336
|
-
done
|
|
342
|
+
```javascript
|
|
343
|
+
import { createForge } from './.claude/scripts/forges/interface.mjs';
|
|
344
|
+
import { readFileSync } from 'node:fs';
|
|
337
345
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
346
|
+
const ws = JSON.parse(readFileSync('workspace.json', 'utf-8'));
|
|
347
|
+
const forge = createForge(ws.workspace?.forge);
|
|
348
|
+
|
|
349
|
+
// Retry up to 5 times with 3-second backoff — the run takes a moment to register.
|
|
350
|
+
let run = null;
|
|
351
|
+
for (let i = 0; i < 5; i++) {
|
|
352
|
+
run = await forge.workflowRunFind({
|
|
353
|
+
workflow: 'publish.yml',
|
|
354
|
+
branch: versionTag, // e.g. 'v0.15.0-beta.0'
|
|
355
|
+
repo: `${org}/${repo}`,
|
|
356
|
+
limit: 1,
|
|
357
|
+
});
|
|
358
|
+
if (run) break;
|
|
359
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (!run) {
|
|
363
|
+
console.warn(`Warning: no publish workflow run found for ${versionTag} after 15s. Investigate via 'gh run list --workflow publish.yml --branch ${versionTag}'.`);
|
|
364
|
+
} else {
|
|
365
|
+
const result = await forge.workflowRunWatch({
|
|
366
|
+
runId: run.runId,
|
|
367
|
+
repo: `${org}/${repo}`,
|
|
368
|
+
exitStatus: true,
|
|
369
|
+
});
|
|
370
|
+
// result.exitCode === 0 on success; non-zero on workflow failure (NOT thrown).
|
|
371
|
+
// result.exitCode and run.url feed into the unified summary in Step 10a.3.
|
|
372
|
+
}
|
|
343
373
|
```
|
|
344
374
|
|
|
345
375
|
**Step 10a.3: Update the unified summary (release sessions only)**
|
|
@@ -354,7 +384,7 @@ PUBLISH ({repo}):
|
|
|
354
384
|
Published: {dist-tag}@{version} on npm
|
|
355
385
|
```
|
|
356
386
|
|
|
357
|
-
Pull `Status` from the
|
|
387
|
+
Pull `Status` from the watch result's `exitCode` (success when `result.exitCode === 0`, failure otherwise). Pull `Workflow` from `run.url` captured in 10a.2. Pull `Published: {dist-tag}@{version}` from the workflow's published-package output if available; if the workflow failed before publishing, omit the `Published:` line and rely on `Status: failure` plus the workflow URL to point the maintainer at the failure.
|
|
358
388
|
|
|
359
389
|
#### Step 10b: Local / bare / other remotes — local merge flow
|
|
360
390
|
|
|
@@ -108,22 +108,36 @@ Report one of:
|
|
|
108
108
|
|
|
109
109
|
Active recommendations. Flags problems and suggests fixes, but asks before acting.
|
|
110
110
|
|
|
111
|
-
### 7.
|
|
111
|
+
### 7. Component age check
|
|
112
|
+
|
|
113
|
+
Scan the following file sets for a YAML frontmatter `updated:` field:
|
|
114
|
+
- `.claude/rules/*.md` (active rules only — `.md.skip` files are included too, since the rule content can still drift)
|
|
115
|
+
- `.claude/skills/*/SKILL.md`
|
|
116
|
+
- `.claude/agents/*.md`
|
|
117
|
+
- `.claude/hooks/*.mjs`
|
|
118
|
+
|
|
119
|
+
For each file that has an `updated:` field, compute the age in days from today. If the age exceeds 180 days, flag the file as a stale component candidate. Print the file name, the `updated:` date, and the age in days so the contributor knows how far the file has drifted.
|
|
120
|
+
|
|
121
|
+
Files without an `updated:` field are skipped — the check is opt-in and activates the discipline incrementally as contributors add frontmatter to the files they own. To start tracking a file, add `updated: <today>` to its frontmatter; the check will surface it if it goes stale.
|
|
122
|
+
|
|
123
|
+
When stale candidates are found, surface them as warnings in the output format and link to `config-review.md.skip` (in `.claude/rules/`) as the opt-in rule that documents the review cadence and rationale.
|
|
124
|
+
|
|
125
|
+
### 8. Stale context
|
|
112
126
|
- Ephemeral files not updated in 7+ days — suggest resolve, update, or archive
|
|
113
127
|
- `work-sessions/{name}/` folders whose worktrees are gone — suggest cleanup
|
|
114
128
|
- Session trackers whose branches have been merged — suggest `/complete-work` post-flight cleanup
|
|
115
129
|
- Braindumps that overlap significantly — suggest merging (e.g., "workspace-branching.md and persistent-work-sessions.md cover the same topic")
|
|
116
130
|
- Handoffs referencing deleted branches — suggest resolve or remove
|
|
117
131
|
|
|
118
|
-
###
|
|
132
|
+
### 9. Context reconciliation
|
|
119
133
|
- Read recent workspace-context writes (last session or last N files by updated date)
|
|
120
134
|
- For each, scan other workspace-context files for references that are now stale
|
|
121
135
|
- Surface: "{file} says X but {newer-file} now says Y. Update {file}?"
|
|
122
136
|
- This is the capture-time cross-check, run retroactively instead of inline
|
|
123
137
|
|
|
124
|
-
###
|
|
138
|
+
### 10. Canonical budget triage
|
|
125
139
|
|
|
126
|
-
This step runs only when the post-regen `--check` from step
|
|
140
|
+
This step runs only when the post-regen `--check` from step 9 still reports `selectionStatus: 'over-budget'`. If the regular regen pass cleared the budget — or if `--check` was already `ok`, `trimmed`, or `stubbed` after step 9 — skip this step entirely.
|
|
127
141
|
|
|
128
142
|
The rest of cleanup is suggestion-list-with-confirmation: surface a candidate, ask before applying, move on. Triage is the one meaningfully more interactive surface in `/maintenance`. It runs as a small REPL: present the budget state and a triage menu, take one action, re-run `--check`, present the menu again with the new state. No suggestion is auto-applied; every action is the user's choice.
|
|
129
143
|
|
|
@@ -168,7 +182,19 @@ For each chosen action:
|
|
|
168
182
|
|
|
169
183
|
Trim markers and demotions only matter for `priority: reference` files — `<!-- canonical:trim -->` spans on a `priority: critical` file are inert until the file is demoted. The triage flow never auto-decides which file to demote or which section to wrap; it surfaces the data, presents options, and waits.
|
|
170
184
|
|
|
171
|
-
###
|
|
185
|
+
### 11. Forge configuration
|
|
186
|
+
|
|
187
|
+
Read `workspace.json`. If `workspace.tracker?.type === 'github-issues'` and `workspace.forge` is unset, emit a notice (not an error):
|
|
188
|
+
|
|
189
|
+
```
|
|
190
|
+
ℹ workspace.json has tracker.type='github-issues' but no workspace.forge field.
|
|
191
|
+
Skills default to GitHub forge operations; add `"forge": {"type": "github"}`
|
|
192
|
+
to workspace.json to make the choice explicit. See .claude/rules/forge-operations.md.
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
This is migration guidance for workspaces created before the `forge` field landed — the field is back-compat with a sensible default, so the unset case is not a bug, just an opportunity to make the implicit explicit. If `workspace.forge.type` is set to a value with no adapter at `.claude/scripts/forges/{type}.mjs`, that IS an error and goes in the Issues section.
|
|
196
|
+
|
|
197
|
+
### 12. Health metrics
|
|
172
198
|
- Canonical budget — read from the same `--check` invocation as step 5. Reported as `current / budget` bytes with the selection status (e.g., `full`, `2 reference files trimmed`). Over-budget cases are deferred to the cleanup triage flow rather than re-reported here.
|
|
173
199
|
- Number of ephemeral files — flag if accumulating without resolution
|
|
174
200
|
- Session log stats (if `workspace-scratchpad/session-log.jsonl` exists):
|
|
@@ -215,7 +241,7 @@ OK (5):
|
|
|
215
241
|
5. Check git state (worktrees, branches, remotes)
|
|
216
242
|
6. Run `node .claude/scripts/build-workspace-context.mjs --check --root .` — capture status. Exit `0` = clean and within budget, `1` = artifact missing or stale, `2` = artifacts current but canonical body over budget. The `canonical` block in the JSON output drives both the audit budget line and the cleanup triage decision.
|
|
217
243
|
7. Read session-log.jsonl if it exists
|
|
218
|
-
8. If cleanup mode: regenerate the workspace-context auto-files if stale (index.md, canonical.md, per-user team-member indexes); compare files pairwise for overlap; scan for stale cross-references. If post-regen `--check` reports `over-budget`, enter the canonical-budget triage flow described in cleanup step
|
|
244
|
+
8. If cleanup mode: regenerate the workspace-context auto-files if stale (index.md, canonical.md, per-user team-member indexes); compare files pairwise for overlap; scan for stale cross-references. If post-regen `--check` reports `over-budget`, enter the canonical-budget triage flow described in cleanup step 10.
|
|
219
245
|
9. Compile and present findings grouped by severity
|
|
220
246
|
|
|
221
247
|
## Notes
|
|
@@ -95,16 +95,33 @@ git push -u origin {branch}
|
|
|
95
95
|
|
|
96
96
|
### Step 7: Create draft PRs
|
|
97
97
|
|
|
98
|
-
|
|
99
|
-
# For each repo in the tracker's repos:
|
|
100
|
-
cd work-sessions/{session-name}/workspace/repos/{repo}
|
|
101
|
-
gh pr create --draft --title "WIP: {description}" --body "Work in progress. Session paused."
|
|
98
|
+
PR creation goes through the forge adapter (`.claude/scripts/forges/interface.mjs`), not directly through `gh` — see `.claude/rules/forge-operations.md` for the contract and why. The adapter resolves the target repo from `workspace.forge.repo` or the local git remote.
|
|
102
99
|
|
|
103
|
-
|
|
104
|
-
|
|
100
|
+
```javascript
|
|
101
|
+
import { createForge } from './.claude/scripts/forges/interface.mjs';
|
|
102
|
+
import { readFileSync } from 'node:fs';
|
|
103
|
+
|
|
104
|
+
const ws = JSON.parse(readFileSync('workspace.json', 'utf-8'));
|
|
105
|
+
const forge = createForge(ws.workspace?.forge);
|
|
106
|
+
|
|
107
|
+
// For each repo in the tracker's repos, from work-sessions/{session-name}/workspace/repos/{repo}:
|
|
108
|
+
const projectPr = await forge.prCreate({
|
|
109
|
+
title: `WIP: ${description}`,
|
|
110
|
+
body: 'Work in progress. Session paused.',
|
|
111
|
+
draft: true,
|
|
112
|
+
});
|
|
113
|
+
console.log(projectPr.url);
|
|
114
|
+
|
|
115
|
+
// Workspace repo — from the workspace worktree:
|
|
116
|
+
const workspacePr = await forge.prCreate({
|
|
117
|
+
title: `context: ${sessionName} (paused)`,
|
|
118
|
+
body: 'Workspace context for paused session.',
|
|
119
|
+
draft: true,
|
|
120
|
+
});
|
|
121
|
+
console.log(workspacePr.url);
|
|
105
122
|
```
|
|
106
123
|
|
|
107
|
-
If PRs already exist, update them to draft status if needed.
|
|
124
|
+
If PRs already exist, update them to draft status if needed (use `gh pr ready --undo` directly until a `forge.prSetDraft` method lands — that's tracked as a future forge adapter extension, not blocking here).
|
|
108
125
|
|
|
109
126
|
### Step 8: Confirm
|
|
110
127
|
|