@ulysses-ai/create-workspace 0.22.0-beta.0 → 0.23.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.
@@ -2,14 +2,19 @@
2
2
  // Unit tests for upgrade.mjs
3
3
  // Run: node lib/upgrade.test.mjs
4
4
  //
5
- // Covers the tracked-payload warning: .workspace-update/ is gitignored from
6
- // v0.19.0, but a workspace upgraded from an older template may still carry a
7
- // tracked payload from a previous run — --upgrade must say so.
5
+ // Covers the tracked-payload warning (.workspace-update/ is gitignored
6
+ // from v0.19.0, but a workspace upgraded from an older template may still
7
+ // carry a tracked payload), the workspace-update skill bootstrap (--upgrade
8
+ // installs the payload's copy so the workspace never runs an outdated
9
+ // flow), and baseline reconstruction for pre-baseline workspaces (gh:186)
10
+ // — the tarball fetch is injected so the suite never touches the network.
8
11
  import { upgradeWorkspace } from './upgrade.mjs';
9
- import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync } from 'fs';
12
+ import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync, readFileSync } from 'fs';
10
13
  import { join } from 'path';
11
14
  import { tmpdir } from 'os';
12
15
  import { execSync, spawnSync } from 'child_process';
16
+ import { createHash } from 'crypto';
17
+ import { gzipSync } from 'zlib';
13
18
 
14
19
  let failed = 0;
15
20
  let passed = 0;
@@ -20,29 +25,36 @@ function check(label, ok) {
20
25
  }
21
26
  }
22
27
 
23
- function captureConsole(fn) {
28
+ async function captureConsole(fn) {
24
29
  const out = { log: [], error: [] };
25
30
  const origLog = console.log;
26
31
  const origError = console.error;
27
32
  console.log = (...a) => { out.log.push(a.join(' ')); };
28
33
  console.error = (...a) => { out.error.push(a.join(' ')); };
29
34
  try {
30
- return { result: fn(), out };
35
+ return { result: await fn(), out };
31
36
  } finally {
32
37
  console.log = origLog;
33
38
  console.error = origError;
34
39
  }
35
40
  }
36
41
 
37
- function buildWorkspace() {
42
+ function buildWorkspace({ templateVersion = '0.15.0', baseline = false } = {}) {
38
43
  const root = mkdtempSync(join(tmpdir(), 'upgrade-test-'));
39
44
  execSync('git init -q -b main', { cwd: root, stdio: 'pipe' });
40
45
  execSync('git config user.email test@example.com', { cwd: root, stdio: 'pipe' });
41
46
  execSync('git config user.name Test', { cwd: root, stdio: 'pipe' });
42
47
  writeFileSync(join(root, 'workspace.json'), JSON.stringify({
43
- workspace: { name: 'demo', initialized: true, templateVersion: '0.15.0' },
48
+ workspace: { name: 'demo', initialized: true, templateVersion },
44
49
  repos: {},
45
50
  }, null, 2) + '\n');
51
+ if (baseline) {
52
+ mkdirSync(join(root, '.claude'), { recursive: true });
53
+ writeFileSync(join(root, '.claude', '.template-baseline.json'), JSON.stringify({
54
+ templateVersion,
55
+ files: { '.claude/hooks/a.mjs': '0'.repeat(64) },
56
+ }) + '\n');
57
+ }
46
58
  execSync('git add workspace.json', { cwd: root, stdio: 'pipe' });
47
59
  execSync('git commit -q -m init', { cwd: root, stdio: 'pipe' });
48
60
  return root;
@@ -55,20 +67,75 @@ function lsFilesPayload(root) {
55
67
  }).stdout.split('\n').filter(Boolean);
56
68
  }
57
69
 
70
+ // ---- fixture tarballs (old live-name layout and new inert layout) ----
71
+
72
+ function tarEntry(name, content, type = '0') {
73
+ const body = Buffer.from(content, 'utf8');
74
+ const header = Buffer.alloc(512);
75
+ header.write(name, 0, 100, 'utf8');
76
+ header.write(body.length.toString(8).padStart(11, '0') + '\0', 124, 'ascii');
77
+ header.write(type, 156, 'ascii');
78
+ header.write('ustar', 257, 'ascii');
79
+ header.write('00', 263, 'ascii');
80
+ let sum = 0;
81
+ for (const byte of header) sum += byte;
82
+ header.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 'ascii');
83
+ const pad = Buffer.alloc((512 - (body.length % 512)) % 512);
84
+ return Buffer.concat([header, body, pad]);
85
+ }
86
+
87
+ function makeTarball(entries) {
88
+ return gzipSync(Buffer.concat([
89
+ ...entries.map(([name, content, type]) => tarEntry(name, content, type)),
90
+ Buffer.alloc(1024),
91
+ ]));
92
+ }
93
+
94
+ const sha = (s) => createHash('sha256').update(s).digest('hex');
95
+
96
+ // A v0.18-era package: every path rooted under package/ (npm's layout),
97
+ // template/ still carried the live names.
98
+ const OLD_LAYOUT_TARBALL = makeTarball([
99
+ ['package/package.json', '{"name":"@ulysses-ai/create-workspace"}\n'],
100
+ ['package/template/CLAUDE.md.tmpl', '## Workspace: {{project-name}}\n'],
101
+ ['package/template/.claude/hooks/session-start.mjs', '// hooks v0.15\n'],
102
+ ['package/template/.claude/scripts/old.mjs', '// script\n'],
103
+ ['package/template/.mcp.json', '{"mcpServers":{}}\n'],
104
+ ['package/template/.claudeignore', 'scratch/\n'],
105
+ ]);
106
+
107
+ // A v0.19-on package: template/ stores the inert names.
108
+ const NEW_LAYOUT_TARBALL = makeTarball([
109
+ ['package/template/_claude/hooks/session-start.mjs', '// hooks v0.19\n'],
110
+ ['package/template/_claude/scripts/new.mjs', '// script\n'],
111
+ ['package/template/_mcp.json', '{"mcpServers":{}}\n'],
112
+ ['package/template/.claudeignore', 'scratch/\n'],
113
+ ]);
114
+
115
+ // Injected fetch results: null means "not fetchable", a throwing fetch must
116
+ // never be reached.
117
+ const noTarball = async () => null;
118
+ const mustNotFetch = async () => { throw new Error('fetchTarball must not be called'); };
119
+
58
120
  console.log('# upgrade');
59
121
 
60
- // 1. Clean workspace: payload staged, no tracked-payload warning, and the
61
- // freshly staged payload is not tracked.
122
+ // 1. Clean workspace with a baseline: payload staged, no tracked-payload
123
+ // warning, no fetch, no reconstruction staged, and the freshly staged
124
+ // payload is not tracked.
62
125
  {
63
- const root = buildWorkspace();
126
+ const root = buildWorkspace({ baseline: true });
64
127
  try {
65
- const { out } = captureConsole(() => upgradeWorkspace(root));
128
+ const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: mustNotFetch }));
66
129
  const stderr = out.error.join('\n');
67
130
  check('no tracked-payload warning on a clean workspace',
68
131
  !stderr.includes('git rm -r --cached'));
69
132
  check('payload staged', existsSync(join(root, '.workspace-update', '.manifest.json')));
70
133
  check('freshly staged payload is not tracked', lsFilesPayload(root).length === 0);
71
134
  check('staging reported', out.log.join('\n').includes('Staged template payload'));
135
+ check('skipped reconstruction says why',
136
+ out.log.join('\n').includes('Template baseline already present'));
137
+ check('no reconstructed baseline staged when one exists',
138
+ !existsSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json')));
72
139
  } finally {
73
140
  rmSync(root, { recursive: true, force: true });
74
141
  }
@@ -77,7 +144,7 @@ console.log('# upgrade');
77
144
  // 2. Workspace tracking .workspace-update/ from a pre-v0.19 upgrade: the
78
145
  // warning fires and names the untrack command.
79
146
  {
80
- const root = buildWorkspace();
147
+ const root = buildWorkspace({ baseline: true });
81
148
  try {
82
149
  // Simulate the old behavior: a payload committed tracked.
83
150
  const payload = join(root, '.workspace-update');
@@ -87,7 +154,7 @@ console.log('# upgrade');
87
154
  execSync('git commit -q -m "track payload"', { cwd: root, stdio: 'pipe' });
88
155
  check('fixture really tracks the payload', lsFilesPayload(root).length > 0);
89
156
 
90
- const { out } = captureConsole(() => upgradeWorkspace(root));
157
+ const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: mustNotFetch }));
91
158
  const stderr = out.error.join('\n');
92
159
  check('tracked-payload warning fires', stderr.includes('.workspace-update/') && stderr.includes('tracked by git'));
93
160
  check('warning names the untrack command', stderr.includes('git rm -r --cached .workspace-update'));
@@ -104,7 +171,7 @@ console.log('# upgrade');
104
171
  workspace: { name: 'demo', initialized: true, templateVersion: '0.15.0' },
105
172
  repos: {},
106
173
  }, null, 2) + '\n');
107
- const { out } = captureConsole(() => upgradeWorkspace(root));
174
+ const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: noTarball }));
108
175
  check('no warning outside a git repo', !out.error.join('\n').includes('tracked by git'));
109
176
  check('payload staged outside a git repo', existsSync(join(root, '.workspace-update', '.manifest.json')));
110
177
  } finally {
@@ -112,6 +179,243 @@ console.log('# upgrade');
112
179
  }
113
180
  }
114
181
 
182
+ // 4. --upgrade installs the payload's workspace-update skill, REPLACING
183
+ // whatever old copy the workspace carries. An old SKILL.md that matches
184
+ // neither the payload's copy nor any known-shipped copy is the operator's
185
+ // own edit — it is backed up before the swap (gh:186).
186
+ {
187
+ const root = buildWorkspace({ baseline: true });
188
+ try {
189
+ // An outdated installed skill with a stale extra file.
190
+ const oldSkill = join(root, '.claude', 'skills', 'workspace-update');
191
+ mkdirSync(oldSkill, { recursive: true });
192
+ writeFileSync(join(oldSkill, 'SKILL.md'), '# old flow\n');
193
+ writeFileSync(join(oldSkill, 'OLD-EXTRA.md'), 'stale\n');
194
+
195
+ const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: mustNotFetch }));
196
+ const newSkill = readFileSync(join(oldSkill, 'SKILL.md'), 'utf8');
197
+ check('installed skill is the payload copy, not the old one',
198
+ newSkill === readFileSync(join(root, '.workspace-update', '.claude', 'skills', 'workspace-update', 'SKILL.md'), 'utf8')
199
+ && newSkill !== '# old flow\n');
200
+ check('stale files inside the old skill do not survive', !existsSync(join(oldSkill, 'OLD-EXTRA.md')));
201
+ check('skill install reported', out.log.join('\n').includes('workspace-update skill'));
202
+ check('a locally modified old skill is backed up',
203
+ readFileSync(join(oldSkill, 'SKILL.md.local-backup'), 'utf8') === '# old flow\n');
204
+ check('backup reported', out.log.join('\n').includes('SKILL.md.local-backup'));
205
+ // The staged payload itself is unchanged: its skill directory stays.
206
+ check('payload still carries the skill directory',
207
+ existsSync(join(root, '.workspace-update', '.claude', 'skills', 'workspace-update', 'SKILL.md')));
208
+ check('no staging directory left behind',
209
+ !existsSync(join(root, '.claude', 'skills', '.workspace-update.incoming')));
210
+ } finally {
211
+ rmSync(root, { recursive: true, force: true });
212
+ }
213
+ }
214
+
215
+ // 4b. A PRISTINE old skill — its bytes match what the installed version
216
+ // shipped (the reconstructed baseline records the hash) — replaces
217
+ // without a backup: nothing of the operator's is at risk.
218
+ {
219
+ // The tarball of the installed version shipped exactly this skill.
220
+ const PRISTINE_TARBALL = makeTarball([
221
+ ['package/template/.claude/skills/workspace-update/SKILL.md', '# pristine old flow\n'],
222
+ ['package/template/.claudeignore', 'scratch/\n'],
223
+ ]);
224
+ const root = buildWorkspace({ templateVersion: '0.15.0' });
225
+ try {
226
+ const oldSkill = join(root, '.claude', 'skills', 'workspace-update');
227
+ mkdirSync(oldSkill, { recursive: true });
228
+ writeFileSync(join(oldSkill, 'SKILL.md'), '# pristine old flow\n');
229
+ await captureConsole(() => upgradeWorkspace(root, { fetchTarball: async () => PRISTINE_TARBALL }));
230
+ check('pristine old skill replaced without a backup',
231
+ !existsSync(join(oldSkill, 'SKILL.md.local-backup'))
232
+ && readFileSync(join(oldSkill, 'SKILL.md'), 'utf8') !== '# pristine old flow\n');
233
+ } finally {
234
+ rmSync(root, { recursive: true, force: true });
235
+ }
236
+
237
+ // The same workspace but with the operator's own edit on top: backed up.
238
+ const editedRoot = buildWorkspace({ templateVersion: '0.15.0' });
239
+ try {
240
+ const oldSkill = join(editedRoot, '.claude', 'skills', 'workspace-update');
241
+ mkdirSync(oldSkill, { recursive: true });
242
+ writeFileSync(join(oldSkill, 'SKILL.md'), '# my customized flow\n');
243
+ await captureConsole(() => upgradeWorkspace(editedRoot, { fetchTarball: async () => PRISTINE_TARBALL }));
244
+ check('a customized old skill is backed up when the shipped hash is known',
245
+ readFileSync(join(oldSkill, 'SKILL.md.local-backup'), 'utf8') === '# my customized flow\n');
246
+ } finally {
247
+ rmSync(editedRoot, { recursive: true, force: true });
248
+ }
249
+ }
250
+
251
+ // 4c. A copy that fails partway leaves the installed skill in place — the
252
+ // swap happens only after the staging copy succeeded.
253
+ {
254
+ const root = buildWorkspace({ baseline: true });
255
+ try {
256
+ const oldSkill = join(root, '.claude', 'skills', 'workspace-update');
257
+ mkdirSync(oldSkill, { recursive: true });
258
+ writeFileSync(join(oldSkill, 'SKILL.md'), '# old flow\n');
259
+ const { out } = await captureConsole(() => upgradeWorkspace(root, {
260
+ fetchTarball: mustNotFetch,
261
+ copySkill: () => { throw new Error('disk full'); },
262
+ }));
263
+ check('a failing copy keeps the installed skill',
264
+ readFileSync(join(oldSkill, 'SKILL.md'), 'utf8') === '# old flow\n');
265
+ check('a failing copy warns', out.error.join('\n').includes('could not install the current workspace-update skill'));
266
+ check('a failing copy leaves no staging directory',
267
+ !existsSync(join(root, '.claude', 'skills', '.workspace-update.incoming')));
268
+ } finally {
269
+ rmSync(root, { recursive: true, force: true });
270
+ }
271
+ }
272
+
273
+ // 5. No baseline + old live-name tarball: reconstruction stages a baseline
274
+ // for the INSTALLED version INSIDE THE PAYLOAD — never the launcher —
275
+ // marked reconstructed (gh:186).
276
+ {
277
+ const root = buildWorkspace({ templateVersion: '0.15.0' });
278
+ try {
279
+ const { out } = await captureConsole(() => upgradeWorkspace(root, {
280
+ fetchTarball: async (version) => (version === '0.15.0' ? OLD_LAYOUT_TARBALL : null),
281
+ }));
282
+ const baselinePath = join(root, '.workspace-update', '.template-baseline.reconstructed.json');
283
+ check('reconstructed baseline staged in the payload', existsSync(baselinePath));
284
+ check('launcher gets no new baseline file',
285
+ !existsSync(join(root, '.claude', '.template-baseline.json')));
286
+ const baseline = JSON.parse(readFileSync(baselinePath, 'utf8'));
287
+ check('baseline records the installed version', baseline.templateVersion === '0.15.0');
288
+ check('baseline is marked reconstructed', baseline.reconstructed === true);
289
+ check('live-name layout hashes map to installed keys',
290
+ baseline.files['.claude/hooks/session-start.mjs'] === sha('// hooks v0.15\n'));
291
+ check('.mcp.json baselined from the old live name',
292
+ baseline.files['.mcp.json'] === sha('{"mcpServers":{}}\n'));
293
+ check('non-verbatim payload roots are not baselined',
294
+ !Object.keys(baseline.files).some((k) => k.endsWith('.tmpl') || k.startsWith('package')));
295
+ check('reconstruction reported', out.log.join('\n').includes('Reconstructed template baseline'));
296
+ check('reconstruction names its staged location',
297
+ out.log.join('\n').includes('.template-baseline.reconstructed.json'));
298
+ } finally {
299
+ rmSync(root, { recursive: true, force: true });
300
+ }
301
+ }
302
+
303
+ // 6. No baseline + new inert-name tarball: same reconstruction through the
304
+ // _claude/_mcp.json layout, staged in the payload the same way.
305
+ {
306
+ const root = buildWorkspace({ templateVersion: '0.19.0' });
307
+ try {
308
+ await captureConsole(() => upgradeWorkspace(root, {
309
+ fetchTarball: async () => NEW_LAYOUT_TARBALL,
310
+ }));
311
+ const baseline = JSON.parse(
312
+ readFileSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json'), 'utf8'));
313
+ check('inert-name layout maps to installed keys',
314
+ baseline.files['.claude/hooks/session-start.mjs'] === sha('// hooks v0.19\n'));
315
+ check('_mcp.json baselined as .mcp.json',
316
+ baseline.files['.mcp.json'] === sha('{"mcpServers":{}}\n'));
317
+ check('baseline marked reconstructed', baseline.reconstructed === true);
318
+ check('launcher still has no baseline of its own',
319
+ !existsSync(join(root, '.claude', '.template-baseline.json')));
320
+ } finally {
321
+ rmSync(root, { recursive: true, force: true });
322
+ }
323
+ }
324
+
325
+ // 7. Fetch failure and unknown version: warn and continue — the upgrade
326
+ // itself never fails, and the payload plus skill still land.
327
+ {
328
+ const root = buildWorkspace({ templateVersion: '0.15.0' });
329
+ try {
330
+ const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: noTarball }));
331
+ const stderr = out.error.join('\n');
332
+ check('fetch failure warns about the missing baseline',
333
+ stderr.includes('no template baseline written'));
334
+ check('fetch failure names the per-file fallback',
335
+ stderr.includes('ask about every changed file'));
336
+ check('no baseline written on fetch failure',
337
+ !existsSync(join(root, '.claude', '.template-baseline.json')));
338
+ check('no reconstructed baseline staged on fetch failure',
339
+ !existsSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json')));
340
+ check('upgrade continues after fetch failure',
341
+ existsSync(join(root, '.workspace-update', '.manifest.json'))
342
+ && existsSync(join(root, '.claude', 'skills', 'workspace-update', 'SKILL.md')));
343
+ } finally {
344
+ rmSync(root, { recursive: true, force: true });
345
+ }
346
+ }
347
+
348
+ // 8. A corrupt tarball is a warning, not a crash.
349
+ {
350
+ const root = buildWorkspace({ templateVersion: '0.15.0' });
351
+ try {
352
+ const { out } = await captureConsole(() => upgradeWorkspace(root, {
353
+ fetchTarball: async () => Buffer.from('not a tarball at all'),
354
+ }));
355
+ check('corrupt tarball warns', out.error.join('\n').includes('no template baseline written'));
356
+ check('corrupt tarball does not fail the upgrade',
357
+ existsSync(join(root, '.workspace-update', '.manifest.json')));
358
+ } finally {
359
+ rmSync(root, { recursive: true, force: true });
360
+ }
361
+ }
362
+
363
+ // 9. No usable templateVersion: named warning, no network attempt.
364
+ {
365
+ const root = buildWorkspace({ templateVersion: null });
366
+ try {
367
+ writeFileSync(join(root, 'workspace.json'), JSON.stringify({
368
+ workspace: { name: 'demo', initialized: true },
369
+ repos: {},
370
+ }, null, 2) + '\n');
371
+ const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: mustNotFetch }));
372
+ check('missing templateVersion warns', out.error.join('\n').includes('no templateVersion'));
373
+ check('no baseline written without a version',
374
+ !existsSync(join(root, '.claude', '.template-baseline.json')));
375
+ } finally {
376
+ rmSync(root, { recursive: true, force: true });
377
+ }
378
+ }
379
+
380
+ // 10. An existing baseline is left alone: no fetch, no rewrite, nothing
381
+ // staged in the payload.
382
+ {
383
+ const root = buildWorkspace({ baseline: true });
384
+ try {
385
+ const before = readFileSync(join(root, '.claude', '.template-baseline.json'), 'utf8');
386
+ const { out } = await captureConsole(() => upgradeWorkspace(root, { fetchTarball: mustNotFetch }));
387
+ check('existing baseline untouched',
388
+ readFileSync(join(root, '.claude', '.template-baseline.json'), 'utf8') === before);
389
+ check('reconstruction not reported', !out.log.join('\n').includes('Reconstructed'));
390
+ check('no reconstructed baseline staged alongside',
391
+ !existsSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json')));
392
+ } finally {
393
+ rmSync(root, { recursive: true, force: true });
394
+ }
395
+ }
396
+
397
+ // 11. A CORRUPT baseline file is as good as none: reconstruction runs and
398
+ // stages its result in the payload; the broken launcher file itself is
399
+ // left exactly as it was (gh:186).
400
+ {
401
+ const root = buildWorkspace({ templateVersion: '0.15.0' });
402
+ try {
403
+ mkdirSync(join(root, '.claude'), { recursive: true });
404
+ writeFileSync(join(root, '.claude', '.template-baseline.json'), '{ not json at all\n');
405
+ const { out } = await captureConsole(() => upgradeWorkspace(root, {
406
+ fetchTarball: async () => OLD_LAYOUT_TARBALL,
407
+ }));
408
+ check('a corrupt baseline does not block reconstruction',
409
+ existsSync(join(root, '.workspace-update', '.template-baseline.reconstructed.json')));
410
+ check('the corrupt file is left as it was',
411
+ readFileSync(join(root, '.claude', '.template-baseline.json'), 'utf8') === '{ not json at all\n');
412
+ check('reconstruction reported despite the corrupt file',
413
+ out.log.join('\n').includes('Reconstructed template baseline'));
414
+ } finally {
415
+ rmSync(root, { recursive: true, force: true });
416
+ }
417
+ }
418
+
115
419
  if (failed > 0) {
116
420
  console.error(`${failed} check(s) failed, ${passed} passed`);
117
421
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulysses-ai/create-workspace",
3
- "version": "0.22.0-beta.0",
3
+ "version": "0.23.0-beta.0",
4
4
  "description": "A workspace convention for Claude Code: sessions, handoffs, and shared context as files in git",
5
5
  "keywords": [
6
6
  "claude",
@@ -25,11 +25,31 @@ adapter takes an injectable `spawnFn`, so tests mock subprocesses rather than ru
25
25
 
26
26
  ## Configuration
27
27
 
28
- `workspace.json` → `workspace.forge`: `{ "type": "github" }`. `type` names the adapter module;
29
- `github` is the default and the only complete one, `gitlab.mjs` is a stub that throws
30
- `NOT_IMPLEMENTED`. Optional `repo` is an `owner/name` slug; unset or `"auto"` resolves from the
31
- git `origin` remote. An absent `workspace.forge` is treated as `{ type: 'github' }`; setting it
32
- to `false` makes every adapter method throw `FORGE_DISABLED`.
28
+ `workspace.json` → `workspace.forge`: `{ "type": "github" | "gitlab" }`. `type` names the adapter
29
+ module — `github.mjs` wraps `gh`, `gitlab.mjs` wraps `glab`; both implement the same method
30
+ surface. With no `type`, the adapter is picked from the target repo's host: gitlab.com (or the
31
+ configured `host`) selects GitLab, anything else stays GitHub — which is how one workspace can
32
+ mix GitHub and GitLab repos, each origin naming its own forge. Optional `repo` is the project
33
+ slug (`owner/name` on GitHub, `group/sub/project` at any depth on GitLab); unset or `"auto"`
34
+ resolves from the git `origin` remote. A self-managed GitLab sets `host` — glab receives it as
35
+ `GITLAB_HOST` (and `--hostname` on `glab api`), always, so an exported foreign `GITLAB_HOST`
36
+ never leaks in. One host per workspace: the origin-based selection recognizes gitlab.com plus
37
+ exactly this host, so a workspace spanning two self-managed instances can't be expressed. An
38
+ origin with an explicit non-default port keeps the port in its host, so `host` carries it too
39
+ (`gitlab.example.com:8443`); default ports drop (`github.com:443` reads as github.com). An
40
+ absent `workspace.forge` is treated as `{ type: 'github' }`; setting it to `false` makes every
41
+ adapter method throw `FORGE_DISABLED`.
42
+
43
+ GitLab gaps, by design: `releaseCreate` with `generateNotes: true` throws `NOT_SUPPORTED` —
44
+ GitLab cannot generate notes from merged MRs, so callers (the `/release` skill does) assemble
45
+ the merged-MR list themselves and pass it as `notes` with `generateNotes: false`. Workflow runs
46
+ map onto pipelines: `workflowRunFind` ignores the workflow name (one pipeline per ref) and
47
+ `workflowRunWatch` polls instead of streaming, treating a `manual` job as still running —
48
+ approving it resumes the pipeline. `reviewDecision` is always empty, and `mergeStateStatus`
49
+ carries GitLab's `detailed_merge_status` vocabulary (`not_approvable`, `ci_still_running`, …),
50
+ not GitHub's — treat it as opaque rather than comparing GitHub strings. `prMerge` disables
51
+ glab's auto-merge (which queues the merge behind a passing pipeline and still exits 0) and
52
+ verifies the MR actually landed, throwing `MergeRejected` otherwise.
33
53
 
34
54
  ## Deliberate exceptions — do not "fix" these
35
55
 
@@ -40,7 +60,8 @@ alone:
40
60
  See `work-item-tracking.md`. The two abstractions are intentionally separate.
41
61
  - **`/setup-tracker` repo configuration** — `gh repo view --json hasIssuesEnabled` and
42
62
  `gh api repos/{slug} -X PATCH -f has_issues=true` are GitHub-API-specific setup, not
43
- cross-cutting operations. A GitLab user's setup flow differs entirely.
63
+ cross-cutting operations. GitLab projects have issues on by default, so its setup flow
64
+ needs no equivalent check.
44
65
  - **`gh repo view` as a remote-type probe** — a one-line capability check, not an operation.
45
66
  - **`gh repo create`** — an interactive one-off when a workspace has no remote.
46
67
  - **Manual recovery prose** — `gh run rerun`, `gh run view`, `gh release view` in `/release`
@@ -21,6 +21,7 @@
21
21
  // node chat-record.mjs --root <dir> --read <chat-name>
22
22
  // node chat-record.mjs --root <dir> --reconcile --session-id <id> --name <n>
23
23
  // node chat-record.mjs --root <dir> --whoami
24
+ // node chat-record.mjs --root <dir> --owner <work-item>
24
25
  // node chat-record.mjs --root <dir> --add-task --chat <n> --branch <b> [--repo <r>] [--work-item <id>]
25
26
  // node chat-record.mjs --root <dir> --remove-task --chat <n> (--work-item <id> | --branch <b>) [--repo <r>]
26
27
  //
@@ -31,11 +32,14 @@
31
32
  // should read `action`. --whoami prints this chat's record name by matching
32
33
  // $CLAUDE_CODE_SESSION_ID against the records' sessionId, nothing and exit
33
34
  // 1 when there is no match: the `Chat record:` hook line can be missing
34
- // after context compaction, and this is the recovery path.
35
+ // after context compaction, and this is the recovery path. --owner prints
36
+ // the chat a work item's open task belongs to as `{ chat, branch, repos }`,
37
+ // and nothing with exit 1 when no record lists it — /start-work uses it to
38
+ // offer adopting a task another chat started (gh:188).
35
39
 
36
40
  import {
37
41
  readFileSync, writeFileSync, existsSync, mkdirSync,
38
- readdirSync, renameSync, rmSync, realpathSync,
42
+ readdirSync, renameSync, rmSync, realpathSync, statSync,
39
43
  } from 'node:fs';
40
44
  import { join, resolve } from 'node:path';
41
45
  import { homedir } from 'node:os';
@@ -241,6 +245,36 @@ function whoami(root, { env = process.env } = {}) {
241
245
  return mine ? mine.chat : null;
242
246
  }
243
247
 
248
+ // The chat a work item's open task belongs to (gh:188). /start-work on an
249
+ // issue that already has a task needs the owner before it can ask the
250
+ // operator whether to adopt the task or leave it — and the record name
251
+ // returned here is the session-registry name SendMessage reaches a chat
252
+ // by, so it doubles as the address for "ask the owner". Records are scanned
253
+ // most-recently-modified first, so a stray duplicate answers with the chat
254
+ // that touched its record last. Null when no record lists the work item.
255
+ function findOwner(root, workItem) {
256
+ if (!workItem) return null;
257
+ const dir = chatsDir(root);
258
+ let names;
259
+ try {
260
+ names = readdirSync(dir).filter((n) => n.endsWith('.json'));
261
+ } catch {
262
+ return null; // no records at all
263
+ }
264
+ names.sort((a, b) => statSync(join(dir, b)).mtimeMs - statSync(join(dir, a)).mtimeMs);
265
+ for (const name of names) {
266
+ const rec = readRecord(root, name.slice(0, -5));
267
+ const hits = rec && Array.isArray(rec.tasks)
268
+ ? rec.tasks.filter((t) => (t.workItem ?? null) === workItem)
269
+ : [];
270
+ if (hits.length === 0) continue;
271
+ // One issue is one task: a shared branch across repos. All matching
272
+ // entries are that task's repos.
273
+ return { chat: rec.chat, branch: hits[0].branch, repos: hits.map((t) => t.repo) };
274
+ }
275
+ return null;
276
+ }
277
+
244
278
  // Scope is what a chat declares it owns. The Aug 26 coordination burst had
245
279
  // sessions declaring this by hand in chat messages; recording it makes it
246
280
  // answerable without asking.
@@ -262,6 +296,7 @@ function parseArgs(argv) {
262
296
  if (a === '--read') { args.mode = 'read'; args.chat = rest[++i]; continue; }
263
297
  if (a === '--reconcile') { args.mode = 'reconcile'; continue; }
264
298
  if (a === '--whoami') { args.mode = 'whoami'; continue; }
299
+ if (a === '--owner') { args.mode = 'owner'; args.workItem = rest[++i]; continue; }
265
300
  if (a === '--add-task') { args.mode = 'add-task'; continue; }
266
301
  if (a === '--remove-task') { args.mode = 'remove-task'; continue; }
267
302
  if (a === '--chat') { args.chat = rest[++i]; continue; }
@@ -272,7 +307,10 @@ function parseArgs(argv) {
272
307
  if (a === '--name') { args.name = rest[++i]; continue; }
273
308
  throw new Error(`unknown argument: ${a}`);
274
309
  }
275
- if (!args.mode) throw new Error('one of --list, --read <chat>, --reconcile, --whoami is required');
310
+ if (!args.mode) throw new Error('one of --list, --read <chat>, --reconcile, --whoami, --owner <work-item> is required');
311
+ if (args.mode === 'owner' && !args.workItem) {
312
+ throw new Error('--owner requires a work item');
313
+ }
276
314
  if (args.mode === 'reconcile' && (!args.sessionId || !args.name)) {
277
315
  throw new Error('--reconcile requires --session-id and --name');
278
316
  }
@@ -296,6 +334,15 @@ function main() {
296
334
  process.stdout.write(`${name}\n`);
297
335
  return;
298
336
  }
337
+ if (args.mode === 'owner') {
338
+ // Same exit-1 contract as --whoami: no owning chat prints nothing, and
339
+ // the caller (a /start-work adoption check) treats that as "no task
340
+ // exists for this issue yet".
341
+ const owner = findOwner(args.root, args.workItem);
342
+ if (owner === null) process.exit(1);
343
+ process.stdout.write(`${JSON.stringify(owner, null, 2)}\n`);
344
+ return;
345
+ }
299
346
  let out;
300
347
  if (args.mode === 'list') out = listRecords(args.root);
301
348
  else if (args.mode === 'read') out = readRecord(args.root, args.chat);
@@ -319,5 +366,5 @@ if (isMainModule(import.meta.url)) {
319
366
  export {
320
367
  recordPath, drawerPath, emptyRecord, readRecord, writeRecord,
321
368
  listRecords, reconcile, parseArgs, readSessionRegistry, resolveChatName,
322
- addTask, removeTask, setScope, whoami, CHATS_DIR,
369
+ addTask, removeTask, setScope, whoami, findOwner, CHATS_DIR,
323
370
  };