agentic-dev 0.2.12 → 0.2.13

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 (48) hide show
  1. package/.agent/prd.json +29 -0
  2. package/.agent/progress.txt +1 -0
  3. package/.agent/prompt.md +21 -0
  4. package/.agent/ralph-loop-state.json +13 -0
  5. package/.agent/ralph-supervisor-state.json +12 -0
  6. package/.agent/ralph-supervisor.sh +238 -0
  7. package/.agent/ralph.sh +305 -0
  8. package/.agent/runs/README.md +7 -0
  9. package/.agent/sdd-build-ast-audit.json +13 -0
  10. package/.claude/CLAUDE.md +44 -0
  11. package/.claude/agentic-dev.json +3 -0
  12. package/.claude/agents/ai-dev.md +27 -0
  13. package/.claude/agents/backend-dev.md +26 -0
  14. package/.claude/agents/db-dev.md +26 -0
  15. package/.claude/agents/devops.md +27 -0
  16. package/.claude/agents/frontend-dev.md +25 -0
  17. package/.claude/agents/github-ops.md +25 -0
  18. package/.claude/agents/test-dev.md +26 -0
  19. package/.claude/agents/uiux-designer.md +25 -0
  20. package/.claude/settings.json +49 -0
  21. package/.claude/settings.local.json +8 -0
  22. package/.claude/skills/sdd/SKILL.md +189 -0
  23. package/.claude/skills/sdd/agents/openai.yaml +4 -0
  24. package/.claude/skills/sdd/references/section-map.md +67 -0
  25. package/.claude/workspace-config.json +3 -0
  26. package/.codex/agentic-dev.json +3 -0
  27. package/.codex/agents/README.md +22 -0
  28. package/.codex/agents/api.toml +11 -0
  29. package/.codex/agents/architecture.toml +11 -0
  30. package/.codex/agents/ci.toml +11 -0
  31. package/.codex/agents/gitops.toml +11 -0
  32. package/.codex/agents/orchestrator.toml +11 -0
  33. package/.codex/agents/quality.toml +11 -0
  34. package/.codex/agents/runtime.toml +11 -0
  35. package/.codex/agents/security.toml +11 -0
  36. package/.codex/agents/specs.toml +11 -0
  37. package/.codex/agents/ui.toml +11 -0
  38. package/.codex/config.toml +46 -0
  39. package/.codex/skills/SKILL.md +13 -0
  40. package/.codex/skills/sdd/SKILL.md +189 -0
  41. package/.codex/skills/sdd/agents/openai.yaml +4 -0
  42. package/.codex/skills/sdd/references/section-map.md +67 -0
  43. package/README.md +69 -58
  44. package/bin/agentic-dev.mjs +162 -11
  45. package/lib/github.mjs +246 -0
  46. package/lib/orchestration-assets.mjs +249 -0
  47. package/lib/scaffold.mjs +89 -0
  48. package/package.json +5 -2
@@ -8,15 +8,18 @@ import {
8
8
  DEFAULT_TEMPLATE_OWNER,
9
9
  ensureTargetDir,
10
10
  fetchTemplateRepos,
11
+ finalizeRepositoryGit,
11
12
  installTemplateRepo,
12
13
  parseArgs,
13
14
  resolveTemplateRepo,
15
+ resolveGitHubOrchestration,
14
16
  usage,
15
17
  } from "../lib/scaffold.mjs";
16
18
 
17
19
  const DEFAULT_TARGET_DIR = ".";
18
20
  const DEFAULT_APP_MODE = "fullstack";
19
21
  const DEFAULT_AI_PROVIDERS = ["codex", "claude"];
22
+ const DEFAULT_PROVIDER_PROFILES = ["codex-subscription", "claude-subscription"];
20
23
 
21
24
  const GITHUB_AUTH_CHOICES = [
22
25
  {
@@ -72,6 +75,28 @@ const AI_PROVIDER_CHOICES = [
72
75
  },
73
76
  ];
74
77
 
78
+ const PROVIDER_PROFILE_CHOICES = [
79
+ { label: "codex-subscription", value: "codex-subscription", description: "Codex subscription/runtime access" },
80
+ { label: "codex-api", value: "codex-api", description: "Codex API access" },
81
+ { label: "claude-subscription", value: "claude-subscription", description: "Claude subscription/runtime access" },
82
+ { label: "claude-api", value: "claude-api", description: "Claude API access" },
83
+ { label: "openai-api", value: "openai-api", description: "OpenAI API access" },
84
+ { label: "ollama-self-hosted", value: "ollama-self-hosted", description: "Local Ollama runtime" },
85
+ ];
86
+
87
+ const GITHUB_PROJECT_MODE_CHOICES = [
88
+ {
89
+ label: "create-if-missing",
90
+ value: "create-if-missing",
91
+ description: "Use an existing GitHub Project with the same title, otherwise create it",
92
+ },
93
+ {
94
+ label: "use-existing",
95
+ value: "use-existing",
96
+ description: "Require an existing GitHub Project and fail if it does not exist",
97
+ },
98
+ ];
99
+
75
100
  const CONFIRM_CHOICES = [
76
101
  {
77
102
  label: "Proceed",
@@ -128,6 +153,16 @@ function uniqueProviders(providers) {
128
153
  : [];
129
154
  }
130
155
 
156
+ function normalizeProviderProfiles(providerProfiles) {
157
+ const normalized = Array.isArray(providerProfiles)
158
+ ? providerProfiles.map((profile) => profile.trim()).filter(Boolean)
159
+ : [];
160
+ if (normalized.length > 0) {
161
+ return [...new Set(normalized)];
162
+ }
163
+ return [...DEFAULT_PROVIDER_PROFILES];
164
+ }
165
+
131
166
  function hydrateOptions(options) {
132
167
  const state = {
133
168
  targetDir: (options.targetDir || DEFAULT_TARGET_DIR).trim() || DEFAULT_TARGET_DIR,
@@ -137,6 +172,10 @@ function hydrateOptions(options) {
137
172
  githubPat: options.githubPat || "",
138
173
  appMode: (options.appMode || DEFAULT_APP_MODE).trim() || DEFAULT_APP_MODE,
139
174
  aiProviders: normalizeProviders(options.aiProviders),
175
+ providerProfiles: normalizeProviderProfiles(options.providerProfiles),
176
+ githubRepositoryInput: (options.githubRepositoryInput || "").trim(),
177
+ githubProjectMode: (options.githubProjectMode || "create-if-missing").trim() || "create-if-missing",
178
+ githubProjectTitle: (options.githubProjectTitle || "").trim(),
140
179
  force: Boolean(options.force),
141
180
  skipBootstrap: Boolean(options.skipBootstrap),
142
181
  owner: options.owner || DEFAULT_TEMPLATE_OWNER,
@@ -145,6 +184,12 @@ function hydrateOptions(options) {
145
184
  if (!state.projectName) {
146
185
  state.projectName = inferProjectName(state.targetDir);
147
186
  }
187
+ if (!state.githubRepositoryInput) {
188
+ state.githubRepositoryInput = state.projectName;
189
+ }
190
+ if (!state.githubProjectTitle) {
191
+ state.githubProjectTitle = `${state.projectName} Delivery`;
192
+ }
148
193
 
149
194
  return state;
150
195
  }
@@ -166,6 +211,7 @@ function sanitizeStateForInstall(state) {
166
211
  targetDir: (state.targetDir || DEFAULT_TARGET_DIR).trim() || DEFAULT_TARGET_DIR,
167
212
  projectName: (state.projectName || inferProjectName(state.targetDir)).trim(),
168
213
  aiProviders: normalizeProviders(state.aiProviders),
214
+ providerProfiles: normalizeProviderProfiles(state.providerProfiles),
169
215
  };
170
216
  }
171
217
 
@@ -193,6 +239,13 @@ function buildSteps(state, repos) {
193
239
  description: "Written into scaffold metadata and Claude workspace nickname.",
194
240
  placeholder: inferProjectName(state.targetDir),
195
241
  },
242
+ {
243
+ key: "githubRepositoryInput",
244
+ type: "text",
245
+ label: "GitHub repository",
246
+ description: "Use owner/name, a GitHub URL, or just a repo name to create/use the repository.",
247
+ placeholder: state.projectName,
248
+ },
196
249
  {
197
250
  key: "githubAuthMode",
198
251
  type: "single",
@@ -213,6 +266,20 @@ function buildSteps(state, repos) {
213
266
  }
214
267
 
215
268
  steps.push(
269
+ {
270
+ key: "githubProjectMode",
271
+ type: "single",
272
+ label: "GitHub project mode",
273
+ description: "Choose whether the orchestration must reuse an existing GitHub Project or create one if needed.",
274
+ choices: GITHUB_PROJECT_MODE_CHOICES,
275
+ },
276
+ {
277
+ key: "githubProjectTitle",
278
+ type: "text",
279
+ label: "GitHub project title",
280
+ description: "This project is used for SDD task orchestration.",
281
+ placeholder: `${state.projectName} Delivery`,
282
+ },
216
283
  {
217
284
  key: "template",
218
285
  type: "single",
@@ -227,11 +294,18 @@ function buildSteps(state, repos) {
227
294
  description: "Choose how much of the bootstrap flow should run after clone.",
228
295
  choices: APP_MODE_CHOICES,
229
296
  },
297
+ {
298
+ key: "providerProfiles",
299
+ type: "multi",
300
+ label: "AI runtime profiles",
301
+ description: "Use Space to toggle provider/runtime profiles.",
302
+ choices: PROVIDER_PROFILE_CHOICES,
303
+ },
230
304
  {
231
305
  key: "aiProviders",
232
306
  type: "multi",
233
- label: "AI providers",
234
- description: "Use Space to toggle. At least one provider must stay selected.",
307
+ label: "Workspace agent surfaces",
308
+ description: "Use Space to toggle Codex/Claude workspace assets.",
235
309
  choices: AI_PROVIDER_CHOICES,
236
310
  },
237
311
  );
@@ -275,6 +349,12 @@ function stepValue(state, step) {
275
349
  if (step.key === "projectName") {
276
350
  return state.projectName || step.placeholder || inferProjectName(state.targetDir);
277
351
  }
352
+ if (step.key === "githubRepositoryInput") {
353
+ return state.githubRepositoryInput || step.placeholder || state.projectName;
354
+ }
355
+ if (step.key === "githubProjectTitle") {
356
+ return state.githubProjectTitle || step.placeholder || `${state.projectName} Delivery`;
357
+ }
278
358
  if (step.key === "githubPat") {
279
359
  return state.githubPat || "";
280
360
  }
@@ -356,10 +436,14 @@ function executionPreview(state, selectedRepo) {
356
436
  const lines = [
357
437
  `Project directory: ${path.resolve(process.cwd(), state.targetDir)}`,
358
438
  `Project name: ${state.projectName}`,
439
+ `GitHub repository: ${state.githubRepositoryInput}`,
440
+ `GitHub project mode: ${state.githubProjectMode}`,
441
+ `GitHub project title: ${state.githubProjectTitle}`,
359
442
  `GitHub auth mode: ${state.githubAuthMode}`,
360
443
  `Template repo: ${selectedRepo?.name || state.template}`,
361
444
  `App mode: ${state.appMode}`,
362
445
  `AI providers: ${state.aiProviders.join(", ")}`,
446
+ `Provider profiles: ${state.providerProfiles.join(", ")}`,
363
447
  `Allow non-empty directory: ${state.force ? "yes" : "no"}`,
364
448
  ];
365
449
 
@@ -368,14 +452,17 @@ function executionPreview(state, selectedRepo) {
368
452
  }
369
453
 
370
454
  lines.push("Run plan:");
371
- lines.push(` 1. Clone ${selectedRepo?.name || state.template}`);
372
- lines.push(" 2. Copy .env.example to .env if needed");
373
- lines.push(" 3. Run pnpm install");
455
+ lines.push(` 1. Ensure GitHub repo ${state.githubRepositoryInput}`);
456
+ lines.push(` 2. Ensure GitHub Project ${state.githubProjectTitle}`);
457
+ lines.push(` 3. Clone ${selectedRepo?.name || state.template}`);
458
+ lines.push(" 4. Install shared sub-agent assets and orchestration workflow");
459
+ lines.push(" 5. Copy .env.example to .env if needed");
460
+ lines.push(" 6. Run pnpm install");
374
461
  if (state.appMode === "backend") {
375
- lines.push(" 4. Skip browser install and parity bootstrap");
462
+ lines.push(" 7. Skip browser install and parity bootstrap");
376
463
  } else {
377
- lines.push(" 4. Install Playwright Chromium for the default frontend target");
378
- lines.push(" 5. Run frontend parity bootstrap");
464
+ lines.push(" 7. Install Playwright Chromium for the default frontend target");
465
+ lines.push(" 8. Run frontend parity bootstrap");
379
466
  }
380
467
 
381
468
  return lines;
@@ -464,6 +551,22 @@ function validateStepValue(step, state, value) {
464
551
  return trimmed;
465
552
  }
466
553
 
554
+ if (step.key === "githubRepositoryInput") {
555
+ const trimmed = value.trim();
556
+ if (!trimmed) {
557
+ throw new Error("GitHub repository cannot be empty.");
558
+ }
559
+ return trimmed;
560
+ }
561
+
562
+ if (step.key === "githubProjectTitle") {
563
+ const trimmed = value.trim();
564
+ if (!trimmed) {
565
+ throw new Error("GitHub project title cannot be empty.");
566
+ }
567
+ return trimmed;
568
+ }
569
+
467
570
  if (step.key === "githubPat") {
468
571
  const trimmed = value.trim();
469
572
  if (!trimmed) {
@@ -480,6 +583,14 @@ function validateStepValue(step, state, value) {
480
583
  return providers;
481
584
  }
482
585
 
586
+ if (step.key === "providerProfiles") {
587
+ const profiles = normalizeProviderProfiles(value);
588
+ if (profiles.length === 0) {
589
+ throw new Error("Select at least one provider profile.");
590
+ }
591
+ return profiles;
592
+ }
593
+
483
594
  if (step.key === "force" && value === false) {
484
595
  throw new Error("Prompt cancelled");
485
596
  }
@@ -603,14 +714,22 @@ async function runWizard(initialState, repos) {
603
714
  }
604
715
 
605
716
  if (step.type === "multi" && chunk === " ") {
606
- const selected = new Set(uniqueProviders(state.aiProviders));
717
+ const selected = new Set(
718
+ step.key === "providerProfiles"
719
+ ? normalizeProviderProfiles(state.providerProfiles)
720
+ : uniqueProviders(state.aiProviders),
721
+ );
607
722
  const currentChoice = step.choices[cursors.get(step.key)];
608
723
  if (selected.has(currentChoice.value)) {
609
724
  selected.delete(currentChoice.value);
610
725
  } else {
611
726
  selected.add(currentChoice.value);
612
727
  }
613
- state.aiProviders = [...selected];
728
+ if (step.key === "providerProfiles") {
729
+ state.providerProfiles = [...selected];
730
+ } else {
731
+ state.aiProviders = [...selected];
732
+ }
614
733
  rerender();
615
734
  return undefined;
616
735
  }
@@ -698,6 +817,10 @@ async function promptForMultiChoice(rl, label, choices, defaults) {
698
817
  }
699
818
  }
700
819
 
820
+ async function promptMultiProfiles(rl, label, choices, defaults) {
821
+ return promptForMultiChoice(rl, label, choices, defaults);
822
+ }
823
+
701
824
  function printFallbackPlan(state, repos) {
702
825
  const selectedRepo = resolveTemplateRepo(state.template, repos);
703
826
  console.log("");
@@ -719,6 +842,9 @@ async function promptLinearly(initialState, repos) {
719
842
  const projectNameAnswer = await rl.question(`Project name [${state.projectName || defaultProjectName}]: `);
720
843
  state.projectName = projectNameAnswer.trim() || state.projectName || defaultProjectName;
721
844
 
845
+ const repoAnswer = await rl.question(`GitHub repository [${state.githubRepositoryInput || state.projectName}]: `);
846
+ state.githubRepositoryInput = repoAnswer.trim() || state.githubRepositoryInput || state.projectName;
847
+
722
848
  state.githubAuthMode = await promptForChoice(rl, "GitHub auth mode", GITHUB_AUTH_CHOICES);
723
849
  if (state.githubAuthMode === "pat") {
724
850
  state.githubPat = (await rl.question("GitHub PAT: ")).trim();
@@ -727,8 +853,19 @@ async function promptLinearly(initialState, repos) {
727
853
  }
728
854
  }
729
855
 
856
+ state.githubProjectMode = await promptForChoice(rl, "GitHub project mode", GITHUB_PROJECT_MODE_CHOICES);
857
+ const projectTitleAnswer = await rl.question(`GitHub project title [${state.githubProjectTitle || `${state.projectName} Delivery`}]: `);
858
+ state.githubProjectTitle =
859
+ projectTitleAnswer.trim() || state.githubProjectTitle || `${state.projectName} Delivery`;
860
+
730
861
  state.template = await promptForChoice(rl, `Template repo from ${state.owner}`, buildTemplateChoices(repos));
731
862
  state.appMode = await promptForChoice(rl, "App mode", APP_MODE_CHOICES);
863
+ state.providerProfiles = await promptMultiProfiles(
864
+ rl,
865
+ "AI runtime profiles",
866
+ PROVIDER_PROFILE_CHOICES,
867
+ state.providerProfiles,
868
+ );
732
869
  state.aiProviders = await promptForMultiChoice(rl, "AI providers", AI_PROVIDER_CHOICES, state.aiProviders);
733
870
 
734
871
  if (!state.force && directoryHasUserFiles(state.targetDir)) {
@@ -789,6 +926,12 @@ function validateNonInteractiveState(state) {
789
926
  if (!state.projectName) {
790
927
  state.projectName = inferProjectName(state.targetDir);
791
928
  }
929
+ if (!state.githubRepositoryInput) {
930
+ state.githubRepositoryInput = state.projectName;
931
+ }
932
+ if (!state.githubProjectTitle) {
933
+ state.githubProjectTitle = `${state.projectName} Delivery`;
934
+ }
792
935
  if (state.githubAuthMode === "pat" && !state.githubPat) {
793
936
  throw new Error("`--github-auth pat` requires `--github-pat`.");
794
937
  }
@@ -843,6 +986,7 @@ async function main() {
843
986
  : await promptLinearly(state, repos);
844
987
 
845
988
  applyRuntimeGitHubAuth(resolvedState);
989
+ const orchestration = await resolveGitHubOrchestration(resolvedState);
846
990
  const selectedRepo = resolveTemplateRepo(resolvedState.template, repos);
847
991
  const destinationRoot = ensureTargetDir(resolvedState.targetDir, {
848
992
  force: resolvedState.force,
@@ -850,9 +994,16 @@ async function main() {
850
994
  const result = installTemplateRepo({
851
995
  destinationRoot: path.resolve(destinationRoot),
852
996
  templateRepo: selectedRepo,
853
- setupSelections: resolvedState,
997
+ setupSelections: {
998
+ ...resolvedState,
999
+ ...orchestration,
1000
+ },
854
1001
  skipBootstrap: resolvedState.skipBootstrap,
855
1002
  });
1003
+ finalizeRepositoryGit(path.resolve(destinationRoot), {
1004
+ ...resolvedState,
1005
+ ...orchestration,
1006
+ });
856
1007
  printSuccess(result);
857
1008
  } catch (error) {
858
1009
  console.error(error.message);
package/lib/github.mjs ADDED
@@ -0,0 +1,246 @@
1
+ import process from "node:process";
2
+ import { spawnSync } from "node:child_process";
3
+
4
+ function ghToken() {
5
+ const envToken =
6
+ process.env.GH_TOKEN || process.env.GITHUB_TOKEN || process.env.AGENTIC_GITHUB_TOKEN || "";
7
+ if (envToken) {
8
+ return envToken;
9
+ }
10
+
11
+ const result = spawnSync("gh", ["auth", "token"], {
12
+ encoding: "utf-8",
13
+ stdio: ["ignore", "pipe", "ignore"],
14
+ });
15
+ if (result.status === 0) {
16
+ return (result.stdout || "").trim();
17
+ }
18
+ return "";
19
+ }
20
+
21
+ async function githubRequest(url, { method = "GET", body } = {}) {
22
+ const token = ghToken();
23
+ if (!token) {
24
+ throw new Error("GitHub authentication is required. Set GH_TOKEN, GITHUB_TOKEN, or AGENTIC_GITHUB_TOKEN.");
25
+ }
26
+
27
+ const response = await fetch(url, {
28
+ method,
29
+ headers: {
30
+ Accept: "application/vnd.github+json",
31
+ "Content-Type": "application/json",
32
+ "User-Agent": "agentic-dev",
33
+ Authorization: `Bearer ${token}`,
34
+ },
35
+ body: body ? JSON.stringify(body) : undefined,
36
+ });
37
+
38
+ if (!response.ok) {
39
+ const payload = await response.text();
40
+ throw new Error(`GitHub API request failed (${response.status}): ${payload}`);
41
+ }
42
+
43
+ if (response.status === 204) {
44
+ return null;
45
+ }
46
+ return response.json();
47
+ }
48
+
49
+ async function githubGraphql(query, variables = {}) {
50
+ const token = ghToken();
51
+ if (!token) {
52
+ throw new Error("GitHub authentication is required. Set GH_TOKEN, GITHUB_TOKEN, or AGENTIC_GITHUB_TOKEN.");
53
+ }
54
+
55
+ const response = await fetch("https://api.github.com/graphql", {
56
+ method: "POST",
57
+ headers: {
58
+ Accept: "application/vnd.github+json",
59
+ "Content-Type": "application/json",
60
+ "User-Agent": "agentic-dev",
61
+ Authorization: `Bearer ${token}`,
62
+ },
63
+ body: JSON.stringify({ query, variables }),
64
+ });
65
+
66
+ const payload = await response.json();
67
+ if (!response.ok || payload.errors) {
68
+ throw new Error(`GitHub GraphQL request failed: ${JSON.stringify(payload.errors || payload)}`);
69
+ }
70
+ return payload.data;
71
+ }
72
+
73
+ export function normalizeRepoInput(input, { fallbackOwner = "say828", fallbackName = "agentic-service" } = {}) {
74
+ const normalized = (input || "").trim();
75
+ if (!normalized) {
76
+ return {
77
+ owner: fallbackOwner,
78
+ name: fallbackName,
79
+ slug: `${fallbackOwner}/${fallbackName}`,
80
+ cloneUrl: `https://github.com/${fallbackOwner}/${fallbackName}.git`,
81
+ htmlUrl: `https://github.com/${fallbackOwner}/${fallbackName}`,
82
+ };
83
+ }
84
+
85
+ const withoutGit = normalized.replace(/\.git$/, "");
86
+ const httpsMatch = withoutGit.match(/^https:\/\/github\.com\/([^/]+)\/([^/]+)$/);
87
+ if (httpsMatch) {
88
+ const [, owner, name] = httpsMatch;
89
+ return {
90
+ owner,
91
+ name,
92
+ slug: `${owner}/${name}`,
93
+ cloneUrl: `https://github.com/${owner}/${name}.git`,
94
+ htmlUrl: `https://github.com/${owner}/${name}`,
95
+ };
96
+ }
97
+
98
+ const slugMatch = withoutGit.match(/^([^/]+)\/([^/]+)$/);
99
+ if (slugMatch) {
100
+ const [, owner, name] = slugMatch;
101
+ return {
102
+ owner,
103
+ name,
104
+ slug: `${owner}/${name}`,
105
+ cloneUrl: `https://github.com/${owner}/${name}.git`,
106
+ htmlUrl: `https://github.com/${owner}/${name}`,
107
+ };
108
+ }
109
+
110
+ return {
111
+ owner: fallbackOwner,
112
+ name: withoutGit,
113
+ slug: `${fallbackOwner}/${withoutGit}`,
114
+ cloneUrl: `https://github.com/${fallbackOwner}/${withoutGit}.git`,
115
+ htmlUrl: `https://github.com/${fallbackOwner}/${withoutGit}`,
116
+ };
117
+ }
118
+
119
+ export async function getAuthenticatedViewer() {
120
+ const data = await githubGraphql(`
121
+ query ViewerIdentity {
122
+ viewer {
123
+ login
124
+ id
125
+ }
126
+ }
127
+ `);
128
+ return data.viewer;
129
+ }
130
+
131
+ export async function ensureGitHubRepository(repoInput, { visibility = "private" } = {}) {
132
+ const viewer = await getAuthenticatedViewer();
133
+ const target = normalizeRepoInput(repoInput, {
134
+ fallbackOwner: viewer.login,
135
+ });
136
+
137
+ try {
138
+ const existing = await githubRequest(`https://api.github.com/repos/${target.owner}/${target.name}`);
139
+ return {
140
+ owner: existing.owner.login,
141
+ name: existing.name,
142
+ slug: existing.full_name,
143
+ cloneUrl: existing.clone_url,
144
+ htmlUrl: existing.html_url,
145
+ created: false,
146
+ };
147
+ } catch (error) {
148
+ const message = String(error.message || error);
149
+ if (!message.includes("(404)")) {
150
+ throw error;
151
+ }
152
+ }
153
+
154
+ if (target.owner !== viewer.login) {
155
+ throw new Error(
156
+ `Repository ${target.slug} does not exist. Automatic creation currently supports only the authenticated user namespace (${viewer.login}).`,
157
+ );
158
+ }
159
+
160
+ const created = await githubRequest("https://api.github.com/user/repos", {
161
+ method: "POST",
162
+ body: {
163
+ name: target.name,
164
+ private: visibility !== "public",
165
+ auto_init: false,
166
+ },
167
+ });
168
+
169
+ return {
170
+ owner: created.owner.login,
171
+ name: created.name,
172
+ slug: created.full_name,
173
+ cloneUrl: created.clone_url,
174
+ htmlUrl: created.html_url,
175
+ created: true,
176
+ };
177
+ }
178
+
179
+ export async function ensureGitHubProject({ ownerLogin, title, mode = "create-if-missing" }) {
180
+ const data = await githubGraphql(
181
+ `
182
+ query OwnerProjects($login: String!) {
183
+ user(login: $login) {
184
+ id
185
+ projectsV2(first: 50) {
186
+ nodes {
187
+ id
188
+ title
189
+ number
190
+ url
191
+ }
192
+ }
193
+ }
194
+ }
195
+ `,
196
+ { login: ownerLogin },
197
+ );
198
+
199
+ const owner = data.user;
200
+ if (!owner) {
201
+ throw new Error(`Unable to load GitHub projects for ${ownerLogin}.`);
202
+ }
203
+
204
+ const existing = owner.projectsV2.nodes.find((project) => project.title === title);
205
+ if (existing) {
206
+ return {
207
+ id: existing.id,
208
+ title: existing.title,
209
+ number: existing.number,
210
+ url: existing.url,
211
+ created: false,
212
+ };
213
+ }
214
+
215
+ if (mode !== "create-if-missing") {
216
+ throw new Error(`GitHub project not found: ${title}`);
217
+ }
218
+
219
+ const created = await githubGraphql(
220
+ `
221
+ mutation CreateProject($ownerId: ID!, $title: String!) {
222
+ createProjectV2(input: { ownerId: $ownerId, title: $title }) {
223
+ projectV2 {
224
+ id
225
+ title
226
+ number
227
+ url
228
+ }
229
+ }
230
+ }
231
+ `,
232
+ {
233
+ ownerId: owner.id,
234
+ title,
235
+ },
236
+ );
237
+
238
+ const project = created.createProjectV2.projectV2;
239
+ return {
240
+ id: project.id,
241
+ title: project.title,
242
+ number: project.number,
243
+ url: project.url,
244
+ created: true,
245
+ };
246
+ }