@ulysses-ai/create-workspace 0.13.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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/bin/create.mjs +79 -0
  4. package/lib/git.mjs +26 -0
  5. package/lib/init.mjs +129 -0
  6. package/lib/payload.mjs +44 -0
  7. package/lib/prompts.mjs +113 -0
  8. package/lib/scaffold.mjs +84 -0
  9. package/lib/upgrade.mjs +42 -0
  10. package/package.json +43 -0
  11. package/template/.claude/agents/aside-researcher.md +48 -0
  12. package/template/.claude/agents/implementer.md +39 -0
  13. package/template/.claude/agents/researcher.md +40 -0
  14. package/template/.claude/agents/reviewer.md +47 -0
  15. package/template/.claude/hooks/_utils.mjs +196 -0
  16. package/template/.claude/hooks/_utils.test.mjs +99 -0
  17. package/template/.claude/hooks/post-compact.mjs +7 -0
  18. package/template/.claude/hooks/pre-compact.mjs +34 -0
  19. package/template/.claude/hooks/repo-write-detection.mjs +107 -0
  20. package/template/.claude/hooks/session-end.mjs +91 -0
  21. package/template/.claude/hooks/session-start.mjs +150 -0
  22. package/template/.claude/hooks/subagent-start.mjs +44 -0
  23. package/template/.claude/hooks/workspace-update-check.mjs +42 -0
  24. package/template/.claude/hooks/worktree-create.mjs +53 -0
  25. package/template/.claude/lib/session-frontmatter.mjs +265 -0
  26. package/template/.claude/lib/session-frontmatter.test.mjs +242 -0
  27. package/template/.claude/recipes/migrate-from-notion.md +120 -0
  28. package/template/.claude/rules/agent-rules.md.skip +32 -0
  29. package/template/.claude/rules/cloud-infrastructure.md.skip +15 -0
  30. package/template/.claude/rules/coherent-revisions.md +24 -0
  31. package/template/.claude/rules/documentation.md.skip +13 -0
  32. package/template/.claude/rules/git-conventions.md +34 -0
  33. package/template/.claude/rules/honest-pushback.md +56 -0
  34. package/template/.claude/rules/local-dev-environment.md.skip +60 -0
  35. package/template/.claude/rules/memory-guidance.md +26 -0
  36. package/template/.claude/rules/product-integrity.md.skip +24 -0
  37. package/template/.claude/rules/scope-guard.md.skip +22 -0
  38. package/template/.claude/rules/superpowers-workflow.md.skip +22 -0
  39. package/template/.claude/rules/token-economics.md.skip +31 -0
  40. package/template/.claude/rules/work-item-tracking.md +90 -0
  41. package/template/.claude/rules/workspace-structure.md +69 -0
  42. package/template/.claude/scripts/add-repo-to-session.mjs +78 -0
  43. package/template/.claude/scripts/cleanup-work-session.mjs +108 -0
  44. package/template/.claude/scripts/create-work-session.mjs +124 -0
  45. package/template/.claude/scripts/migrate-open-work.mjs +91 -0
  46. package/template/.claude/scripts/migrate-session-layout.mjs +236 -0
  47. package/template/.claude/scripts/migrate-session-layout.test.mjs +144 -0
  48. package/template/.claude/scripts/trackers/github-issues.mjs +170 -0
  49. package/template/.claude/scripts/trackers/github-issues.test.mjs +190 -0
  50. package/template/.claude/scripts/trackers/interface.mjs +25 -0
  51. package/template/.claude/scripts/trackers/interface.test.mjs +40 -0
  52. package/template/.claude/settings.json +107 -0
  53. package/template/.claude/skills/aside/SKILL.md +125 -0
  54. package/template/.claude/skills/braindump/SKILL.md +96 -0
  55. package/template/.claude/skills/build-docs-site/SKILL.md +323 -0
  56. package/template/.claude/skills/build-docs-site/checklists/framing.md +221 -0
  57. package/template/.claude/skills/build-docs-site/checklists/pitfalls.md +228 -0
  58. package/template/.claude/skills/build-docs-site/checklists/review.md +130 -0
  59. package/template/.claude/skills/build-docs-site/scripts/bulk-fill-migration.py +393 -0
  60. package/template/.claude/skills/build-docs-site/scripts/forbidden-word-grep.mjs +159 -0
  61. package/template/.claude/skills/build-docs-site/scripts/leak-grep.mjs +328 -0
  62. package/template/.claude/skills/build-docs-site/templates/custom.css.tmpl +212 -0
  63. package/template/.claude/skills/build-docs-site/templates/docusaurus.config.ts.tmpl +95 -0
  64. package/template/.claude/skills/build-docs-site/templates/primitives/Arrow.tsx +87 -0
  65. package/template/.claude/skills/build-docs-site/templates/primitives/Box.tsx +90 -0
  66. package/template/.claude/skills/build-docs-site/templates/primitives/DiagramContainer.tsx +46 -0
  67. package/template/.claude/skills/build-docs-site/templates/primitives/Region.tsx +68 -0
  68. package/template/.claude/skills/build-docs-site/templates/primitives/SectionTitle.tsx +42 -0
  69. package/template/.claude/skills/build-docs-site/templates/primitives/tokens.ts +67 -0
  70. package/template/.claude/skills/build-docs-site/templates/sidebars.ts.tmpl +89 -0
  71. package/template/.claude/skills/build-docs-site/templates/spec.md.tmpl +119 -0
  72. package/template/.claude/skills/complete-work/SKILL.md +369 -0
  73. package/template/.claude/skills/handoff/SKILL.md +98 -0
  74. package/template/.claude/skills/maintenance/SKILL.md +116 -0
  75. package/template/.claude/skills/pause-work/SKILL.md +98 -0
  76. package/template/.claude/skills/promote/SKILL.md +77 -0
  77. package/template/.claude/skills/release/SKILL.md +126 -0
  78. package/template/.claude/skills/setup-tracker/SKILL.md +117 -0
  79. package/template/.claude/skills/start-work/SKILL.md +234 -0
  80. package/template/.claude/skills/sync-work/SKILL.md +73 -0
  81. package/template/.claude/skills/workspace-init/SKILL.md +420 -0
  82. package/template/.claude/skills/workspace-update/SKILL.md +108 -0
  83. package/template/.mcp.json +12 -0
  84. package/template/CLAUDE.md.tmpl +32 -0
  85. package/template/_gitignore +28 -0
  86. package/template/workspace.json.tmpl +15 -0
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env node
2
+ // One-shot migrator: move session content from launcher-side paths into
3
+ // each session's workspace worktree, collapse the .gitignore exception
4
+ // block, bump templateVersion, and clean main. Idempotent per session.
5
+ //
6
+ // Exports:
7
+ // migrateSession(root, sessionName) — per-session branch migration
8
+ // migrateMain(root) — main-side gitignore + rm --cached + version bump
9
+ //
10
+ // CLI:
11
+ // node migrate-session-layout.mjs --session-name NAME
12
+ // node migrate-session-layout.mjs --all
13
+ // node migrate-session-layout.mjs --main
14
+ // node migrate-session-layout.mjs --all --main (runs all sessions then main)
15
+ // Optional: --root PATH to override auto-detection
16
+ import { execSync } from 'child_process';
17
+ import {
18
+ readFileSync,
19
+ writeFileSync,
20
+ existsSync,
21
+ readdirSync,
22
+ copyFileSync,
23
+ statSync,
24
+ } from 'fs';
25
+ import { join } from 'path';
26
+ import {
27
+ getWorkspaceRoot,
28
+ getWorkspacePaths,
29
+ getMainRoot,
30
+ readJSON,
31
+ } from '../hooks/_utils.mjs';
32
+
33
+ export function migrateSession(root, sessionName) {
34
+ const { workSessionsDir } = getWorkspacePaths(root);
35
+ const sessionFolder = join(workSessionsDir, sessionName);
36
+ const worktree = join(sessionFolder, 'workspace');
37
+
38
+ if (!existsSync(worktree)) {
39
+ return { status: 'no-worktree', sessionName };
40
+ }
41
+
42
+ const inWorktreeTracker = join(worktree, 'session.md');
43
+
44
+ // Quick idempotency check: if the tracker is already in-worktree AND no
45
+ // ghosts remain in the branch tree, we're done.
46
+ if (existsSync(inWorktreeTracker) && listGhostsInBranch(worktree).length === 0) {
47
+ return { status: 'already-migrated', sessionName };
48
+ }
49
+
50
+ // Copy launcher-side tracker into the worktree (skip if already there)
51
+ const launcherTracker = join(sessionFolder, 'session.md');
52
+ if (existsSync(launcherTracker) && !existsSync(inWorktreeTracker)) {
53
+ copyFileSync(launcherTracker, inWorktreeTracker);
54
+ }
55
+
56
+ // Copy launcher-side specs and plans into the worktree (skip-if-exists)
57
+ const copiedDocs = [];
58
+ if (existsSync(sessionFolder)) {
59
+ for (const entry of readdirSync(sessionFolder)) {
60
+ if (entry === 'workspace') continue;
61
+ if (!/^(design|plan)-.*\.md$/.test(entry)) continue;
62
+ const src = join(sessionFolder, entry);
63
+ if (!statSync(src).isFile()) continue;
64
+ const dst = join(worktree, entry);
65
+ if (!existsSync(dst)) {
66
+ copyFileSync(src, dst);
67
+ copiedDocs.push(entry);
68
+ }
69
+ }
70
+ }
71
+
72
+ // Stage new files at the top of the worktree
73
+ const toAdd = [];
74
+ if (existsSync(inWorktreeTracker)) toAdd.push('session.md');
75
+ for (const entry of readdirSync(worktree)) {
76
+ if (/^(design|plan)-.*\.md$/.test(entry)) toAdd.push(entry);
77
+ }
78
+ if (toAdd.length > 0) {
79
+ execSync(`git add ${toAdd.map((f) => `"${f}"`).join(' ')}`, {
80
+ cwd: worktree,
81
+ stdio: 'pipe',
82
+ });
83
+ }
84
+
85
+ // Remove cross-contamination ghosts that this branch inherited from main
86
+ const ghosts = listGhostsInBranch(worktree);
87
+ for (const ghost of ghosts) {
88
+ execSync(`git rm "${ghost}"`, { cwd: worktree, stdio: 'pipe' });
89
+ }
90
+
91
+ // Only commit if there are staged changes
92
+ const staged = execSync('git diff --cached --name-only', { cwd: worktree })
93
+ .toString()
94
+ .trim();
95
+ if (staged.length === 0) {
96
+ return { status: 'no-changes', sessionName };
97
+ }
98
+ execSync('git commit -m "chore: migrate session content into worktree"', {
99
+ cwd: worktree,
100
+ stdio: 'pipe',
101
+ });
102
+ return {
103
+ status: 'migrated',
104
+ sessionName,
105
+ added: toAdd,
106
+ removedGhosts: ghosts,
107
+ copiedDocs,
108
+ };
109
+ }
110
+
111
+ function listGhostsInBranch(worktree) {
112
+ const out = execSync(
113
+ 'git ls-files "work-sessions/*/session.md" "work-sessions/*/design-*.md" "work-sessions/*/plan-*.md"',
114
+ { cwd: worktree }
115
+ ).toString();
116
+ return out.split('\n').filter(Boolean);
117
+ }
118
+
119
+ /**
120
+ * Collapse the "Work sessions" block in .gitignore to a single
121
+ * `work-sessions/` line. Line-based to avoid regex brittleness against the
122
+ * multi-line commented version that mentions `work-sessions/**` inline.
123
+ */
124
+ function collapseGitignoreBlock(content) {
125
+ const lines = content.split('\n');
126
+ const startIdx = lines.findIndex((l) => /^# Work sessions\b/.test(l));
127
+ if (startIdx === -1) {
128
+ throw new Error('Work sessions block not found in .gitignore');
129
+ }
130
+ let endIdx = -1;
131
+ for (let i = startIdx + 1; i < lines.length; i++) {
132
+ if (/^!work-sessions\/\*\/plan-\*\.md\s*$/.test(lines[i])) {
133
+ endIdx = i;
134
+ break;
135
+ }
136
+ }
137
+ if (endIdx === -1) {
138
+ throw new Error('End of work-sessions block (!…plan-*.md) not found in .gitignore');
139
+ }
140
+ const replacement = [
141
+ '# Work sessions — the folder is local-only at the workspace root.',
142
+ "# Session content (tracker, specs, plans) lives inside each session's",
143
+ '# workspace worktree at the top of the session branch, not on main.',
144
+ 'work-sessions/',
145
+ ];
146
+ lines.splice(startIdx, endIdx - startIdx + 1, ...replacement);
147
+ return lines.join('\n');
148
+ }
149
+
150
+ export function migrateMain(root) {
151
+ // Collapse the .gitignore block
152
+ const gitignorePath = join(root, '.gitignore');
153
+ const current = readFileSync(gitignorePath, 'utf-8');
154
+ const collapsed = collapseGitignoreBlock(current);
155
+ if (collapsed === current) {
156
+ throw new Error('Gitignore collapse produced no change');
157
+ }
158
+ writeFileSync(gitignorePath, collapsed);
159
+
160
+ // git rm --cached every launcher-tracked tracker/spec/plan file
161
+ const listed = execSync(
162
+ 'git ls-files "work-sessions/*/session.md" "work-sessions/*/design-*.md" "work-sessions/*/plan-*.md"',
163
+ { cwd: root }
164
+ )
165
+ .toString()
166
+ .split('\n')
167
+ .filter(Boolean);
168
+ for (const path of listed) {
169
+ execSync(`git rm --cached "${path}"`, { cwd: root, stdio: 'pipe' });
170
+ }
171
+
172
+ // Bump templateVersion in workspace.json
173
+ const wsPath = join(root, 'workspace.json');
174
+ const ws = readJSON(wsPath);
175
+ if (ws?.workspace) {
176
+ ws.workspace.templateVersion = '0.9.0';
177
+ writeFileSync(wsPath, JSON.stringify(ws, null, 2) + '\n');
178
+ }
179
+
180
+ // Stage and commit
181
+ execSync('git add .gitignore workspace.json', { cwd: root, stdio: 'pipe' });
182
+ execSync(
183
+ 'git commit -m "chore: migrate workspace to in-worktree session layout"',
184
+ { cwd: root, stdio: 'pipe' }
185
+ );
186
+
187
+ return { status: 'migrated', removed: listed };
188
+ }
189
+
190
+ // CLI entry
191
+ if (import.meta.url === `file://${process.argv[1]}`) {
192
+ const args = process.argv.slice(2);
193
+ const getArg = (name) => {
194
+ const i = args.indexOf(`--${name}`);
195
+ return i >= 0 && args[i + 1] ? args[i + 1] : null;
196
+ };
197
+
198
+ // The script lives in either the launcher's .claude/scripts/ or a session
199
+ // worktree's .claude/scripts/. getWorkspaceRoot returns the script's
200
+ // grandparent; when that's a worktree, promote it to the launcher via
201
+ // the .active-session.json pointer.
202
+ const inferred = getWorkspaceRoot(import.meta.url);
203
+ const root = getArg('root') || getMainRoot(inferred);
204
+
205
+ const runAll = args.includes('--all');
206
+ const runMain = args.includes('--main');
207
+ const name = getArg('session-name');
208
+
209
+ const results = {};
210
+
211
+ if (runAll) {
212
+ const { workSessionsDir } = getWorkspacePaths(root);
213
+ const perSession = [];
214
+ if (existsSync(workSessionsDir)) {
215
+ for (const entry of readdirSync(workSessionsDir)) {
216
+ perSession.push(migrateSession(root, entry));
217
+ }
218
+ }
219
+ results.sessions = perSession;
220
+ } else if (name) {
221
+ results.session = migrateSession(root, name);
222
+ }
223
+
224
+ if (runMain) {
225
+ results.main = migrateMain(root);
226
+ }
227
+
228
+ if (!runAll && !runMain && !name) {
229
+ console.error(
230
+ 'Usage: migrate-session-layout.mjs (--session-name NAME | --all) [--main] [--root PATH]'
231
+ );
232
+ process.exit(1);
233
+ }
234
+
235
+ console.log(JSON.stringify(results, null, 2));
236
+ }
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ // Tests for migrate-session-layout.mjs
3
+ // Run: node .claude/scripts/migrate-session-layout.test.mjs
4
+ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from 'fs';
5
+ import { tmpdir } from 'os';
6
+ import { join } from 'path';
7
+ import { execSync } from 'child_process';
8
+ import { migrateSession, migrateMain } from './migrate-session-layout.mjs';
9
+
10
+ let failed = 0;
11
+ let passed = 0;
12
+
13
+ function assertEq(actual, expected, msg) {
14
+ const a = JSON.stringify(actual);
15
+ const e = JSON.stringify(expected);
16
+ if (a === e) { passed++; }
17
+ else {
18
+ failed++;
19
+ console.error(` FAIL: ${msg}\n expected: ${e}\n actual: ${a}`);
20
+ }
21
+ }
22
+
23
+ function assertTrue(cond, msg) {
24
+ if (cond) passed++;
25
+ else { failed++; console.error(` FAIL: ${msg}`); }
26
+ }
27
+
28
+ // Spin up a fake workspace with a session worktree on a session branch.
29
+ // Uses real git so we test end-to-end.
30
+ function buildFixture() {
31
+ const root = mkdtempSync(join(tmpdir(), 'migrator-test-'));
32
+ execSync('git init -q -b main', { cwd: root });
33
+ execSync('git config user.email test@example.com', { cwd: root });
34
+ execSync('git config user.name Test', { cwd: root });
35
+
36
+ writeFileSync(join(root, 'workspace.json'), JSON.stringify({
37
+ workspace: { workSessionsDir: 'work-sessions', templateVersion: '0.8.0' },
38
+ }, null, 2) + '\n');
39
+
40
+ const oldGitignore = [
41
+ '# Work sessions — the folder and worktrees are local, but the session tracker,',
42
+ '# specs, and plans are tracked so durable thinking travels across machines.',
43
+ 'work-sessions/**',
44
+ '!work-sessions/*/',
45
+ '!work-sessions/*/session.md',
46
+ '!work-sessions/*/design-*.md',
47
+ '!work-sessions/*/plan-*.md',
48
+ '',
49
+ ].join('\n');
50
+ writeFileSync(join(root, '.gitignore'), oldGitignore);
51
+ execSync('git add -A && git commit -q -m "init"', { cwd: root });
52
+
53
+ // Session "demo" — tracker + spec exist at launcher-side paths, tracked on main.
54
+ mkdirSync(join(root, 'work-sessions', 'demo'), { recursive: true });
55
+ writeFileSync(join(root, 'work-sessions', 'demo', 'session.md'),
56
+ '---\ntype: session-tracker\nname: demo\nstatus: active\n---\n\nbody\n');
57
+ writeFileSync(join(root, 'work-sessions', 'demo', 'design-demo.md'),
58
+ '---\ntopic: demo\n---\n\nspec body\n');
59
+ execSync('git add work-sessions/demo/session.md work-sessions/demo/design-demo.md && git commit -q -m "add demo tracker and spec"',
60
+ { cwd: root });
61
+
62
+ // Create branch + worktree for demo. Branch inherits the tracker.
63
+ execSync('git branch feature/demo main', { cwd: root });
64
+ execSync('git worktree add -q work-sessions/demo/workspace feature/demo', { cwd: root });
65
+
66
+ return root;
67
+ }
68
+
69
+ // migrateSession copies launcher tracker+spec into worktree, removes ghosts,
70
+ // and commits on the session branch.
71
+ {
72
+ const root = buildFixture();
73
+ try {
74
+ const result = migrateSession(root, 'demo');
75
+ assertEq(result.status, 'migrated', 'first migration returns migrated');
76
+
77
+ const inWorktreeTracker = join(root, 'work-sessions', 'demo', 'workspace', 'session.md');
78
+ assertTrue(existsSync(inWorktreeTracker), 'tracker exists at worktree top');
79
+
80
+ const inWorktreeSpec = join(root, 'work-sessions', 'demo', 'workspace', 'design-demo.md');
81
+ assertTrue(existsSync(inWorktreeSpec), 'spec exists at worktree top');
82
+
83
+ const worktree = join(root, 'work-sessions', 'demo', 'workspace');
84
+ const listed = execSync('git ls-files', { cwd: worktree }).toString();
85
+ assertTrue(listed.includes('\nsession.md\n') || listed.startsWith('session.md\n'),
86
+ 'top-level session.md is tracked on branch');
87
+ assertTrue(listed.includes('design-demo.md'),
88
+ 'top-level design-demo.md is tracked on branch');
89
+ assertTrue(!listed.includes('work-sessions/demo/session.md'),
90
+ 'branch no longer tracks ghost work-sessions/demo/session.md');
91
+
92
+ const log = execSync('git log --oneline feature/demo', { cwd: root }).toString();
93
+ assertTrue(log.includes('migrate session content into worktree'),
94
+ 'branch has migration commit');
95
+ } finally {
96
+ rmSync(root, { recursive: true, force: true });
97
+ }
98
+ }
99
+
100
+ // Idempotency: re-running on an already-migrated session is a no-op.
101
+ {
102
+ const root = buildFixture();
103
+ try {
104
+ migrateSession(root, 'demo');
105
+ const result = migrateSession(root, 'demo');
106
+ assertEq(result.status, 'already-migrated', 'second run reports already-migrated');
107
+ } finally {
108
+ rmSync(root, { recursive: true, force: true });
109
+ }
110
+ }
111
+
112
+ // migrateMain updates .gitignore, git rm --cached's tracker paths, bumps
113
+ // templateVersion, and commits on main.
114
+ {
115
+ const root = buildFixture();
116
+ try {
117
+ migrateSession(root, 'demo');
118
+ const result = migrateMain(root);
119
+ assertEq(result.status, 'migrated', 'main migration returns migrated');
120
+
121
+ const gi = readFileSync(join(root, '.gitignore'), 'utf-8');
122
+ assertTrue(gi.includes('work-sessions/'), '.gitignore has work-sessions/ line');
123
+ assertTrue(!gi.includes('!work-sessions/*/session.md'),
124
+ '.gitignore exception removed');
125
+
126
+ const mainTracked = execSync('git ls-files', { cwd: root }).toString();
127
+ assertTrue(!mainTracked.includes('work-sessions/demo/session.md'),
128
+ 'main no longer tracks work-sessions/demo/session.md');
129
+ assertTrue(!mainTracked.includes('work-sessions/demo/design-demo.md'),
130
+ 'main no longer tracks work-sessions/demo/design-demo.md');
131
+
132
+ const ws = JSON.parse(readFileSync(join(root, 'workspace.json'), 'utf-8'));
133
+ assertEq(ws.workspace.templateVersion, '0.9.0', 'templateVersion bumped to 0.9.0');
134
+
135
+ const mainLog = execSync('git log --oneline main -1', { cwd: root }).toString();
136
+ assertTrue(mainLog.includes('migrate workspace to in-worktree session layout'),
137
+ 'main has migration commit');
138
+ } finally {
139
+ rmSync(root, { recursive: true, force: true });
140
+ }
141
+ }
142
+
143
+ console.log(`Passed: ${passed}, Failed: ${failed}`);
144
+ if (failed > 0) process.exit(1);
@@ -0,0 +1,170 @@
1
+ // GitHub Issues adapter. Wraps the `gh` CLI via an injectable spawnFn.
2
+ // Issue IDs are opaque strings of the form "gh:N" — adapters outside this file
3
+ // don't need to parse them; routing is handled by interface.mjs.
4
+
5
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
6
+ import { AlreadyAssignedError } from './interface.mjs';
7
+
8
+ const ISSUE_FIELDS = 'number,title,body,state,assignees,labels,milestone,url,createdAt,updatedAt';
9
+
10
+ const STANDARD_LABELS = [
11
+ { name: 'bug', color: 'd73a4a' },
12
+ { name: 'feat', color: 'a2eeef' },
13
+ { name: 'chore', color: 'cfd3d7' },
14
+ { name: 'P1', color: 'b60205' },
15
+ { name: 'P2', color: 'fbca04' },
16
+ { name: 'P3', color: '0e8a16' },
17
+ ];
18
+
19
+ export function createGithubAdapter(config, { spawnFn = nodeSpawnSync } = {}) {
20
+ const repo = resolveRepo(config, spawnFn);
21
+ let loginCache = null;
22
+
23
+ function gh(args, { input } = {}) {
24
+ const result = spawnFn('gh', args, {
25
+ input,
26
+ encoding: 'utf-8',
27
+ stdio: input !== undefined ? ['pipe', 'pipe', 'pipe'] : ['inherit', 'pipe', 'pipe'],
28
+ });
29
+ if (result.status !== 0) {
30
+ throw new Error(`gh ${args.join(' ')} failed: ${(result.stderr || '').trim()}`);
31
+ }
32
+ return result.stdout || '';
33
+ }
34
+
35
+ function getLogin() {
36
+ if (loginCache) return loginCache;
37
+ loginCache = gh(['api', 'user', '--jq', '.login']).trim();
38
+ return loginCache;
39
+ }
40
+
41
+ function normalize(raw) {
42
+ return {
43
+ id: `gh:${raw.number}`,
44
+ number: raw.number,
45
+ title: raw.title,
46
+ body: raw.body || '',
47
+ state: (raw.state || '').toLowerCase() === 'closed' ? 'closed' : 'open',
48
+ assignees: (raw.assignees || []).map(a => a.login),
49
+ labels: (raw.labels || []).map(l => l.name),
50
+ milestone: raw.milestone?.title ?? null,
51
+ url: raw.url,
52
+ createdAt: raw.createdAt,
53
+ updatedAt: raw.updatedAt,
54
+ };
55
+ }
56
+
57
+ function parseIssueNumber(issueId) {
58
+ const m = issueId.match(/^gh:(\d+)$/);
59
+ if (!m) throw new Error(`Not a GitHub issue ID: ${issueId}`);
60
+ return parseInt(m[1], 10);
61
+ }
62
+
63
+ async function listAssignedToMe() {
64
+ const me = getLogin();
65
+ const stdout = gh(['issue', 'list', '--repo', repo, '--assignee', me, '--state', 'open', '--json', ISSUE_FIELDS]);
66
+ return JSON.parse(stdout).map(normalize);
67
+ }
68
+
69
+ async function listUnassigned() {
70
+ const stdout = gh(['issue', 'list', '--repo', repo, '--search', 'no:assignee', '--state', 'open', '--json', ISSUE_FIELDS]);
71
+ return JSON.parse(stdout).map(normalize);
72
+ }
73
+
74
+ async function getIssue(issueId) {
75
+ const num = parseIssueNumber(issueId);
76
+ const stdout = gh(['issue', 'view', String(num), '--repo', repo, '--json', ISSUE_FIELDS]);
77
+ return normalize(JSON.parse(stdout));
78
+ }
79
+
80
+ async function claim(issueId) {
81
+ const me = getLogin();
82
+ const issue = await getIssue(issueId);
83
+ const others = issue.assignees.filter(a => a !== me);
84
+ if (others.length > 0) {
85
+ throw new AlreadyAssignedError(issueId, others);
86
+ }
87
+ if (!issue.assignees.includes(me)) {
88
+ gh(['issue', 'edit', String(issue.number), '--repo', repo, '--add-assignee', me]);
89
+ return getIssue(issueId);
90
+ }
91
+ return issue;
92
+ }
93
+
94
+ async function createIssue({ title, body = '', labels = [], milestone = null }) {
95
+ const args = ['issue', 'create', '--repo', repo, '--title', title, '--body-file', '-'];
96
+ if (labels.length > 0) args.push('--label', labels.join(','));
97
+ if (milestone) args.push('--milestone', milestone);
98
+ const stdout = gh(args, { input: body });
99
+ const m = stdout.match(/\/issues\/(\d+)/);
100
+ if (!m) throw new Error(`Could not parse issue number from: ${stdout.trim()}`);
101
+ return getIssue(`gh:${m[1]}`);
102
+ }
103
+
104
+ async function comment(issueId, body) {
105
+ const num = parseIssueNumber(issueId);
106
+ gh(['issue', 'comment', String(num), '--repo', repo, '--body-file', '-'], { input: body });
107
+ }
108
+
109
+ async function closeIssue(issueId, { comment: commentBody } = {}) {
110
+ const num = parseIssueNumber(issueId);
111
+ if (commentBody) {
112
+ gh(['issue', 'comment', String(num), '--repo', repo, '--body-file', '-'], { input: commentBody });
113
+ }
114
+ gh(['issue', 'close', String(num), '--repo', repo]);
115
+ }
116
+
117
+ async function ensureLabels() {
118
+ for (const { name, color } of STANDARD_LABELS) {
119
+ gh(['label', 'create', name, '--repo', repo, '--color', color, '--force']);
120
+ }
121
+ }
122
+
123
+ async function ensureMilestone({ title, description = '', dueOn = null } = {}) {
124
+ if (!title) throw new Error('ensureMilestone: title is required');
125
+ const listStdout = gh(['api', `repos/${repo}/milestones?state=all&per_page=100`]);
126
+ const existing = JSON.parse(listStdout).find(m => m.title === title);
127
+ if (existing) return normalizeMilestone(existing);
128
+
129
+ const args = ['api', `repos/${repo}/milestones`, '-X', 'POST', '-f', `title=${title}`];
130
+ if (description) args.push('-f', `description=${description}`);
131
+ if (dueOn) args.push('-f', `due_on=${dueOn}`);
132
+ const created = JSON.parse(gh(args));
133
+ return normalizeMilestone(created);
134
+ }
135
+
136
+ return {
137
+ listAssignedToMe,
138
+ listUnassigned,
139
+ getIssue,
140
+ claim,
141
+ createIssue,
142
+ comment,
143
+ closeIssue,
144
+ ensureLabels,
145
+ ensureMilestone,
146
+ get identity() { return `github-issues:${repo}`; },
147
+ };
148
+ }
149
+
150
+ function normalizeMilestone(raw) {
151
+ return {
152
+ number: raw.number,
153
+ title: raw.title,
154
+ description: raw.description || '',
155
+ state: raw.state, // 'open' | 'closed'
156
+ dueOn: raw.due_on || null,
157
+ url: raw.html_url,
158
+ };
159
+ }
160
+
161
+ function resolveRepo(config, spawnFn) {
162
+ if (config.repo && config.repo !== 'auto') return config.repo;
163
+ const result = spawnFn('git', ['remote', 'get-url', 'origin'], { encoding: 'utf-8' });
164
+ if (result.status !== 0) {
165
+ throw new Error(`git remote get-url failed: ${(result.stderr || '').trim()}`);
166
+ }
167
+ const m = result.stdout.trim().match(/github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/);
168
+ if (!m) throw new Error(`Cannot parse GitHub remote: ${result.stdout.trim()}`);
169
+ return `${m[1]}/${m[2]}`;
170
+ }