@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.
@@ -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
- ```bash
224
- # For each repo in the tracker's repos with a GitHub remote:
225
- cd work-sessions/{session-name}/workspace/repos/{repo}
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
- # Workspace PR — from the workspace worktree
229
- cd work-sessions/{session-name}/workspace
230
- gh pr create --title "context: {session-name} work session" --body "..."
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
- ```bash
259
- # For each project PR:
260
- gh pr merge {pr-number} --merge
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
- # Workspace PR:
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
- # Pull all repos to their default branches
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 — `gh release view $version_tag` to see what shipped.
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 `gh run list` up to 5 times with a 3-second backoff before giving up. Once the run is found, attach with `gh run watch` so the maintainer sees progress live alongside the unified summary. Append `|| true` to the watch command so a workflow 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.
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
- ```bash
325
- # Retry up to 5 times with 3-second backoff — the run takes a moment to register.
326
- for i in 1 2 3 4 5; do
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
- if [ -z "$run_id" ]; then
339
- echo "Warning: no publish workflow run found for $version_tag after 15s. Investigate via 'gh run list'."
340
- else
341
- gh run watch "$run_id" --exit-status --repo {org}/{repo} || true
342
- fi
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 `gh run watch` exit code (success when the watch returned 0, failure otherwise). 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.
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. Stale context
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
- ### 8. Context reconciliation
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
- ### 9. Canonical budget triage
138
+ ### 10. Canonical budget triage
125
139
 
126
- This step runs only when the post-regen `--check` from step 8 still reports `selectionStatus: 'over-budget'`. If the regular regen pass cleared the budget — or if `--check` was already `ok`, `trimmed`, or `stubbed` after step 8 — skip this step entirely.
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
- ### 10. Health metrics
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 9.
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
- ```bash
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
- # Workspace repo — from the workspace worktree
104
- gh pr create --draft --title "context: {session-name} (paused)" --body "Workspace context for paused session."
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