guardian-framework 0.1.57 → 0.1.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2224,7 +2224,7 @@ function calculateTokenStats(targetDir, manifest) {
2224
2224
  lastCalculatedAt: new Date().toISOString()
2225
2225
  };
2226
2226
  }
2227
- var _filename, _dirname, MANIFEST_FILE = "guardian-manifest.json", FRAMEWORK_VERSION = "0.1.57", SCHEMA_VERSION = "0.1";
2227
+ var _filename, _dirname, MANIFEST_FILE = "guardian-manifest.json", FRAMEWORK_VERSION = "0.1.59", SCHEMA_VERSION = "0.1";
2228
2228
  var init_manifest = __esm(() => {
2229
2229
  _filename = fileURLToPath(import.meta.url);
2230
2230
  _dirname = path3.dirname(_filename);
@@ -2303,7 +2303,7 @@ __export(exports_package, {
2303
2303
  bin: () => bin,
2304
2304
  author: () => author
2305
2305
  });
2306
- var name = "guardian-framework", version = "0.1.57", description = "Token-optimized agentic framework scaffolder with pi-first architecture", type = "module", main = "dist/exports.js", exports, types = "dist/exports.d.ts", bin, files, engines, scripts, publishConfig, pi, repository, homepage = "https://github.com/arman-jalili/guardian-framework#readme", bugs, dependencies, devDependencies, keywords, author = "Arman Wolkensteiner-Jalili", license = "MIT", package_default;
2306
+ var name = "guardian-framework", version = "0.1.59", description = "Token-optimized agentic framework scaffolder with pi-first architecture", type = "module", main = "dist/exports.js", exports, types = "dist/exports.d.ts", bin, files, engines, scripts, publishConfig, pi, repository, homepage = "https://github.com/arman-jalili/guardian-framework#readme", bugs, dependencies, devDependencies, keywords, author = "Arman Wolkensteiner-Jalili", license = "MIT", package_default;
2307
2307
  var init_package = __esm(() => {
2308
2308
  exports = {
2309
2309
  ".": {
@@ -14208,7 +14208,7 @@ async function runVerify(targetDir = process.cwd()) {
14208
14208
 
14209
14209
  // src/index.ts
14210
14210
  init_manifest();
14211
- var VERSION = "0.1.57";
14211
+ var VERSION = "0.1.59";
14212
14212
  async function runCli() {
14213
14213
  const args = parseArgs({
14214
14214
  allowPositionals: true,
package/dist/exports.js CHANGED
@@ -1919,7 +1919,7 @@ function calculateTokenStats(targetDir, manifest) {
1919
1919
  lastCalculatedAt: new Date().toISOString()
1920
1920
  };
1921
1921
  }
1922
- var _filename, _dirname, MANIFEST_FILE = "guardian-manifest.json", FRAMEWORK_VERSION = "0.1.57", SCHEMA_VERSION = "0.1";
1922
+ var _filename, _dirname, MANIFEST_FILE = "guardian-manifest.json", FRAMEWORK_VERSION = "0.1.59", SCHEMA_VERSION = "0.1";
1923
1923
  var init_manifest = __esm(() => {
1924
1924
  _filename = fileURLToPath(import.meta.url);
1925
1925
  _dirname = path.dirname(_filename);
@@ -1963,7 +1963,7 @@ __export(exports_package, {
1963
1963
  bin: () => bin,
1964
1964
  author: () => author
1965
1965
  });
1966
- var name = "guardian-framework", version = "0.1.57", description = "Token-optimized agentic framework scaffolder with pi-first architecture", type = "module", main = "dist/exports.js", exports, types = "dist/exports.d.ts", bin, files, engines, scripts, publishConfig, pi, repository, homepage = "https://github.com/arman-jalili/guardian-framework#readme", bugs, dependencies, devDependencies, keywords, author = "Arman Wolkensteiner-Jalili", license = "MIT", package_default;
1966
+ var name = "guardian-framework", version = "0.1.59", description = "Token-optimized agentic framework scaffolder with pi-first architecture", type = "module", main = "dist/exports.js", exports, types = "dist/exports.d.ts", bin, files, engines, scripts, publishConfig, pi, repository, homepage = "https://github.com/arman-jalili/guardian-framework#readme", bugs, dependencies, devDependencies, keywords, author = "Arman Wolkensteiner-Jalili", license = "MIT", package_default;
1967
1967
  var init_package = __esm(() => {
1968
1968
  exports = {
1969
1969
  ".": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guardian-framework",
3
- "version": "0.1.57",
3
+ "version": "0.1.59",
4
4
  "description": "Token-optimized agentic framework scaffolder with pi-first architecture",
5
5
  "type": "module",
6
6
  "main": "dist/exports.js",
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Forge Adapter — Centralized forge abstraction for GitHub and GitLab.
3
+ *
4
+ * All forge-specific operations (auth, repo, issues, MR/PR) go through this module.
5
+ * Use `readRepoTool()` to determine which forge is active, then call the appropriate
6
+ * helper — null returned / empty string means "not available / not supported".
7
+ *
8
+ * Canonical Reference: .pi/extensions/architect-lib/helpers.ts
9
+ * Last Sync: 2026-07-09
10
+ */
11
+
12
+ import { execFileSync, execSync } from "node:child_process";
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ // ── Forge Detection ──
17
+
18
+ /**
19
+ * Read repoTool from guardian-manifest.json (defaults to "gh")
20
+ */
21
+ export function readRepoTool(cwd: string): string {
22
+ try {
23
+ const manifestPath = join(cwd, "guardian-manifest.json");
24
+ if (existsSync(manifestPath)) {
25
+ const raw = readFileSync(manifestPath, "utf-8");
26
+ const manifest = JSON.parse(raw) as { repoTool?: string };
27
+ if (manifest.repoTool === "glab") return "glab";
28
+ }
29
+ } catch {
30
+ // fall through to default
31
+ }
32
+ return "gh";
33
+ }
34
+
35
+ /**
36
+ * Read repository slug from guardian-manifest.json
37
+ */
38
+ export function readRepository(cwd: string): string | null {
39
+ try {
40
+ const manifestPath = join(cwd, "guardian-manifest.json");
41
+ if (existsSync(manifestPath)) {
42
+ const raw = readFileSync(manifestPath, "utf-8");
43
+ const manifest = JSON.parse(raw) as {
44
+ repository?: string;
45
+ templateContext?: { repository?: string };
46
+ };
47
+ if (manifest.repository) return manifest.repository;
48
+ if (manifest.templateContext?.repository)
49
+ return manifest.templateContext.repository;
50
+ }
51
+ } catch {
52
+ // ignore
53
+ }
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * Get forge base URL. For GitLab, detects self-hosted instances via glab config.
59
+ */
60
+ export function getGitBaseUrl(repoTool: string): string {
61
+ if (repoTool === "glab") {
62
+ try {
63
+ const uri = execSync("glab config get gitlab_uri 2>/dev/null", {
64
+ encoding: "utf-8",
65
+ }).trim();
66
+ if (uri) {
67
+ const normalized = uri.replace(/\/+$/, "");
68
+ // Ensure URL has a scheme, default to https://
69
+ if (!/^https?:\/\//i.test(normalized)) {
70
+ return `https://${normalized}`;
71
+ }
72
+ return normalized;
73
+ }
74
+ } catch {
75
+ // fall through to default
76
+ }
77
+ return "https://gitlab.com";
78
+ }
79
+ return "https://github.com";
80
+ }
81
+
82
+ export function commandExists(cmd: string): boolean {
83
+ try {
84
+ execSync(`command -v ${cmd}`, { stdio: "ignore" });
85
+ return true;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Run a bash script and return exit code + stdout.
93
+ */
94
+ export function runScript(cwd: string, script: string): { exitCode: number; stdout: string } {
95
+ try {
96
+ const stdout = execSync(`bash -c "${script}"`, { cwd, timeout: 120_000, encoding: "utf-8" });
97
+ return { exitCode: 0, stdout };
98
+ } catch (e: unknown) {
99
+ const err = e as { status?: number; stdout?: string; message?: string };
100
+ return { exitCode: err.status ?? 1, stdout: err.stdout ?? err.message ?? "" };
101
+ }
102
+ }
103
+
104
+ // ── Auth Validation ──
105
+
106
+ /**
107
+ * Verify forge CLI is authenticated AND has access to the configured repository.
108
+ * Returns true only if both checks pass.
109
+ */
110
+ export function verifyForgeAccess(cwd: string): { authenticated: boolean; projectAccessible: boolean } {
111
+ const repoTool = readRepoTool(cwd);
112
+ const repository = readRepository(cwd);
113
+ const result = { authenticated: false, projectAccessible: false };
114
+
115
+ if (!commandExists(repoTool)) return result;
116
+
117
+ // Check CLI auth status
118
+ const authResult = runScript(
119
+ cwd,
120
+ repoTool === "glab" ? "glab auth status 2>/dev/null" : "gh auth status 2>/dev/null",
121
+ );
122
+ if (authResult.exitCode !== 0) return result;
123
+ result.authenticated = true;
124
+
125
+ // Verify project-level access
126
+ if (repository) {
127
+ const projectCheck = runScript(
128
+ cwd,
129
+ repoTool === "glab"
130
+ ? `glab api "projects/${repository}" --method GET 2>/dev/null | jq -r '.id // empty'`
131
+ : `gh api "repos/${repository}" --method GET 2>/dev/null | jq -r '.id // empty'`,
132
+ );
133
+ if (projectCheck.exitCode === 0 && projectCheck.stdout.trim().length > 0) {
134
+ result.projectAccessible = true;
135
+ }
136
+ } else {
137
+ // No repository configured — auth is sufficient
138
+ result.projectAccessible = true;
139
+ }
140
+
141
+ return result;
142
+ }
143
+
144
+ // ── PR/MR Operations ──
145
+
146
+ /**
147
+ * Check if a PR (GitHub) or MR (GitLab) exists for the given branch.
148
+ */
149
+ export function forgePrExists(cwd: string, branch: string): boolean {
150
+ const repoTool = readRepoTool(cwd);
151
+ try {
152
+ if (repoTool === "glab") {
153
+ const result = execSync(
154
+ `glab mr list --source-branch "${branch}" --output json 2>/dev/null`,
155
+ { cwd, encoding: "utf-8" },
156
+ );
157
+ const parsed = JSON.parse(result.trim() || "[]");
158
+ return Array.isArray(parsed) && parsed.length > 0;
159
+ }
160
+ const result = execSync(
161
+ `gh pr list --head "${branch}" --json number --jq "length" 2>/dev/null`,
162
+ { cwd, encoding: "utf-8" },
163
+ );
164
+ return parseInt(result.trim()) > 0;
165
+ } catch {
166
+ return false;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Check if a PR (GitHub) or MR (GitLab) for the given branch is merged.
172
+ */
173
+ export function forgePrMerged(cwd: string, branch: string): boolean {
174
+ const repoTool = readRepoTool(cwd);
175
+ try {
176
+ if (repoTool === "glab") {
177
+ const result = execSync(
178
+ `glab mr list --source-branch "${branch}" --output json 2>/dev/null`,
179
+ { cwd, encoding: "utf-8" },
180
+ );
181
+ const parsed = JSON.parse(result.trim() || "[]") as { state?: string }[];
182
+ return Array.isArray(parsed) && parsed.some((mr) => mr.state === "merged");
183
+ }
184
+ const result = execSync(
185
+ `gh pr list --head "${branch}" --json state,mergedAt --jq ".[] | select(.state==\"MERGED\") | .mergedAt" 2>/dev/null`,
186
+ { cwd, encoding: "utf-8" },
187
+ );
188
+ return result.trim().length > 0;
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
193
+
194
+ // ── Issue Operations ──
195
+
196
+ /**
197
+ * Fetch issue content from remote forge or fallback to local file.
198
+ */
199
+ export function fetchIssueContent(
200
+ cwd: string,
201
+ issueId: string,
202
+ remoteIssueId?: string | null,
203
+ ): { content: string; source: string } {
204
+ const repository = readRepository(cwd);
205
+ const repoTool = readRepoTool(cwd);
206
+ const baseUrl = getGitBaseUrl(repoTool);
207
+
208
+ if (remoteIssueId && repository) {
209
+ try {
210
+ let result;
211
+ if (repoTool === "glab") {
212
+ result = runScript(
213
+ cwd,
214
+ `glab issue view ${remoteIssueId} --repo ${repository} --output json`,
215
+ );
216
+ if (result.exitCode === 0 && result.stdout) {
217
+ const parsed = JSON.parse(result.stdout) as {
218
+ title?: string;
219
+ description?: string;
220
+ };
221
+ if (parsed.description) {
222
+ return {
223
+ content: parsed.description,
224
+ source: `Remote issue: ${baseUrl}/${repository}/issues/${remoteIssueId}`,
225
+ };
226
+ }
227
+ }
228
+ } else {
229
+ result = runScript(
230
+ cwd,
231
+ `gh issue view ${remoteIssueId} --repo ${repository} --json title,body`,
232
+ );
233
+ if (result.exitCode === 0 && result.stdout) {
234
+ const parsed = JSON.parse(result.stdout) as { title?: string; body?: string };
235
+ if (parsed.body) {
236
+ return {
237
+ content: parsed.body,
238
+ source: `Remote issue: ${baseUrl}/${repository}/issues/${remoteIssueId}`,
239
+ };
240
+ }
241
+ }
242
+ }
243
+ } catch {
244
+ // fallback to local file
245
+ }
246
+ }
247
+
248
+ // Fallback to local file
249
+ const issueFilename = `${issueId}.md`.replace(/\//g, "-");
250
+ const issuePath = join(cwd, ".pi/issues", issueFilename);
251
+ try {
252
+ if (existsSync(issuePath)) {
253
+ return {
254
+ content: readFileSync(issuePath, "utf-8"),
255
+ source: `Local file: .pi/issues/${issueFilename}`,
256
+ };
257
+ }
258
+ } catch {
259
+ // ignore
260
+ }
261
+
262
+ return {
263
+ content: "Issue content not available.",
264
+ source: issueId,
265
+ };
266
+ }
@@ -1,12 +1,22 @@
1
1
  import { execFileSync, execSync } from "node:child_process";
2
- import { existsSync, readFileSync, readdirSync } from "node:fs";
3
- import { join } from "node:path";
4
- import type { ExtensionContext, EpicState, ModuleComponent, ArchitectureSlice } from "./types.ts";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import type { ExtensionContext, EpicState, ModuleComponent, ArchitectureSlice, RoadmapPhase, RoadmapState, RoadmapPhaseModule } from "./types.ts";
5
+ import {
6
+ readRepoTool as adapterReadRepoTool,
7
+ readRepository as adapterReadRepository,
8
+ getGitBaseUrl as adapterGetGitBaseUrl,
9
+ commandExists as adapterCommandExists,
10
+ runScript as adapterRunScript,
11
+ verifyForgeAccess,
12
+ } from "./forge-adapter.ts";
5
13
  // ── Constants ──
6
14
 
7
15
  export const EPIC_STATE_KEY = ".pi/.guardian-epic-state.json";
8
16
  export const ARCH_MODULES_DIR = ".pi/architecture/modules";
9
17
  export const ISSUES_DIR = ".pi/issues";
18
+ export const ROADMAP_FILE = ".pi/architecture/implementation-roadmap.md";
19
+ export const ROADMAP_STATE_FILE = ".pi/.guardian-roadmap-state.json";
10
20
 
11
21
  // ── Helpers ──
12
22
 
@@ -14,77 +24,14 @@ export function log(ctx: ExtensionContext, message: string, level = "info") {
14
24
  ctx.ui.notify(message, level);
15
25
  }
16
26
 
17
- export function runScript(cwd: string, script: string): { exitCode: number; stdout: string } {
18
- try {
19
- const stdout = execSync(`bash -c "${script}"`, { cwd, timeout: 120_000, encoding: "utf-8" });
20
- return { exitCode: 0, stdout };
21
- } catch (e: unknown) {
22
- const err = e as { status?: number; stdout?: string; message?: string };
23
- return { exitCode: err.status ?? 1, stdout: err.stdout ?? err.message ?? "" };
24
- }
25
- }
26
-
27
- // Read repoTool from guardian-manifest.json (defaults to "gh")
28
- export function readRepoTool(cwd: string): string {
29
- try {
30
- const manifestPath = join(cwd, "guardian-manifest.json");
31
- if (existsSync(manifestPath)) {
32
- const raw = readFileSync(manifestPath, "utf-8");
33
- const manifest = JSON.parse(raw) as { repoTool?: string };
34
- if (manifest.repoTool === "glab") return "glab";
35
- }
36
- } catch {
37
- // fall through to default
38
- }
39
- return "gh";
40
- }
41
-
42
- // Read the repository slug (owner/repo) from guardian-manifest.json
43
- export function readRepository(cwd: string): string | null {
44
- try {
45
- const manifestPath = join(cwd, "guardian-manifest.json");
46
- if (existsSync(manifestPath)) {
47
- const raw = readFileSync(manifestPath, "utf-8");
48
- const manifest = JSON.parse(raw) as {
49
- repository?: string;
50
- templateContext?: { repository?: string };
51
- };
52
- if (manifest.repository) return manifest.repository;
53
- if (manifest.templateContext?.repository)
54
- return manifest.templateContext.repository;
55
- }
56
- } catch {
57
- // ignore
58
- }
59
- return null;
60
- }
61
-
62
- /**
63
- * Get Git platform base URL. For GitLab, tries to detect self-hosted instances.
64
- */
65
- export function getGitBaseUrl(repoTool: string): string {
66
- if (repoTool === "glab") {
67
- try {
68
- const uri = execSync("glab config get gitlab_uri 2>/dev/null", {
69
- encoding: "utf-8",
70
- }).trim();
71
- if (uri) return uri.replace(/\/+$/, "");
72
- } catch {
73
- // fall through to default
74
- }
75
- return "https://gitlab.com";
76
- }
77
- return "https://github.com";
78
- }
27
+ // These functions are now centralized in ../forge-adapter.ts
28
+ // We re-export them here for backward compatibility with architect.ts
79
29
 
80
- export function commandExists(cmd: string): boolean {
81
- try {
82
- execSync(`command -v ${cmd}`, { stdio: "ignore" });
83
- return true;
84
- } catch {
85
- return false;
86
- }
87
- }
30
+ export const runScript = adapterRunScript;
31
+ export const readRepoTool = adapterReadRepoTool;
32
+ export const readRepository = adapterReadRepository;
33
+ export const getGitBaseUrl = adapterGetGitBaseUrl;
34
+ export const commandExists = adapterCommandExists;
88
35
 
89
36
  // Try to create a remote GitHub/GitLab issue via the shell script wrapper.
90
37
  // Uses execFileSync to avoid shell quoting issues with nested commands.
@@ -167,15 +114,37 @@ export function ensureRemoteRepo(
167
114
  // Check if remote already exists via git remote
168
115
  const remoteCheck = runScript(cwd, "git remote get-url origin 2>/dev/null");
169
116
  if (remoteCheck.exitCode === 0) {
170
- return repository;
117
+ // Validate existing origin: check it's reachable and matches the manifest repo
118
+ const remoteUrl = remoteCheck.stdout.trim();
119
+ const repoSlug = repository.replace(/^https?:\/\//, "").replace(/^git@/, "");
120
+
121
+ // Check reachability
122
+ const reachable = runScript(cwd, "git ls-remote origin HEAD 2>/dev/null");
123
+ if (reachable.exitCode !== 0) {
124
+ console.warn(`Origin remote exists but is not reachable (${remoteUrl}). Will re-create.`);
125
+ // Remove and re-create below
126
+ runScript(cwd, "git remote remove origin 2>/dev/null");
127
+ } else {
128
+ // Verify remote URL contains the manifest repository path
129
+ if (remoteUrl.includes(repository.replace(/^https?:\/\//, "").replace(/^git@/, "").replace(/\.git$/, ""))
130
+ || remoteUrl.includes(repository)) {
131
+ return repository;
132
+ }
133
+ console.warn(`Existing origin points to a different repository. Re-creating.`);
134
+ runScript(cwd, "git remote remove origin 2>/dev/null");
135
+ }
171
136
  }
172
137
 
173
138
  // Remote not configured locally — ensure the remote repo exists on GitHub/GitLab
174
139
  if (repoTool === "gh") {
175
- runScript(
140
+ const createResult = runScript(
176
141
  cwd,
177
142
  `gh repo create "${repository}" --private --description "Epic: ${epicName}" 2>&1`,
178
143
  );
144
+ if (createResult.exitCode !== 0) {
145
+ console.warn(`Failed to create GitHub repo: ${createResult.stdout}`);
146
+ return "";
147
+ }
179
148
  // Remove stale origin if it exists but points nowhere useful
180
149
  runScript(cwd, "git remote remove origin 2>/dev/null");
181
150
  const httpsUrl = `https://github.com/${repository}.git`;
@@ -185,10 +154,14 @@ export function ensureRemoteRepo(
185
154
 
186
155
  // GitLab path — detect self-hosted base URL from glab config
187
156
  const glabBaseUrl = getGitBaseUrl("glab");
188
- runScript(
157
+ const createResult = runScript(
189
158
  cwd,
190
159
  `glab repo create "${repository}" --private --description "Epic: ${epicName}" 2>&1`,
191
160
  );
161
+ if (createResult.exitCode !== 0) {
162
+ console.warn(`Failed to create GitLab project: ${createResult.stdout}`);
163
+ return "";
164
+ }
192
165
  runScript(cwd, "git remote remove origin 2>/dev/null");
193
166
  const httpsUrl = `${glabBaseUrl}/${repository}.git`;
194
167
  runScript(cwd, `git remote add origin "${httpsUrl}"`);
@@ -375,3 +348,204 @@ export function findNextLogicalSlice(cwd: string, moduleFiles: string[]): Archit
375
348
  return null;
376
349
  }
377
350
 
351
+ // ── Roadmap Functions ──
352
+
353
+ export function parseRoadmap(cwd: string): RoadmapPhase[] {
354
+ const path = join(cwd, ROADMAP_FILE);
355
+ if (!existsSync(path)) return [];
356
+ const content = readFileSync(path, "utf-8");
357
+ const lines = content.split("\n");
358
+ const phases: RoadmapPhase[] = [];
359
+ let currentPhase: Partial<RoadmapPhase> | null = null;
360
+ let inModules = false;
361
+ let inDeps = false;
362
+ let inMigrations = false;
363
+ let inCriteria = false;
364
+
365
+ for (let i = 0; i < lines.length; i++) {
366
+ const line = lines[i];
367
+ const trimmed = line.trim();
368
+
369
+ // Detect phase heading: ## Phase N: Name (Days X-Y)
370
+ const phaseMatch = trimmed.match(/^## Phase (\d+):\s*(.+?)\s*\((Days\s*[^)]+)\)?$/i);
371
+ if (phaseMatch) {
372
+ if (currentPhase && currentPhase.title) {
373
+ phases.push({
374
+ index: currentPhase.index!,
375
+ title: currentPhase.title!,
376
+ goal: currentPhase.goal || "",
377
+ days: currentPhase.days || "",
378
+ modules: currentPhase.modules || [],
379
+ completedModules: currentPhase.completedModules || [],
380
+ dependencies: currentPhase.dependencies || [],
381
+ migrations: currentPhase.migrations || [],
382
+ criteria: currentPhase.criteria || [],
383
+ status: "pending",
384
+ });
385
+ }
386
+ currentPhase = {
387
+ index: parseInt(phaseMatch[1], 10),
388
+ title: phaseMatch[2].trim(),
389
+ days: phaseMatch[3].trim(),
390
+ modules: [],
391
+ completedModules: [],
392
+ dependencies: [],
393
+ migrations: [],
394
+ criteria: [],
395
+ };
396
+ inModules = false; inDeps = false; inMigrations = false; inCriteria = false;
397
+ continue;
398
+ }
399
+
400
+ if (!currentPhase) continue;
401
+
402
+ // Goal line
403
+ const goalMatch = trimmed.match(/^\*\*Goal:\*\*\s*(.+)/i);
404
+ if (goalMatch) {
405
+ currentPhase.goal = goalMatch[1].trim();
406
+ continue;
407
+ }
408
+
409
+ // Section headers
410
+ if (trimmed.match(/^###\s+Modules?/i)) {
411
+ inModules = true; inDeps = false; inMigrations = false; inCriteria = false; continue;
412
+ }
413
+ if (trimmed.match(/^###\s+Dependencies?/i)) {
414
+ inModules = false; inDeps = true; inMigrations = false; inCriteria = false; continue;
415
+ }
416
+ if (trimmed.match(/^###\s+Database\s+Migrations?/i)) {
417
+ inModules = false; inDeps = false; inMigrations = true; inCriteria = false; continue;
418
+ }
419
+ if (trimmed.match(/^###\s+Acceptance\s+Criteria/i)) {
420
+ inModules = false; inDeps = false; inMigrations = false; inCriteria = true; continue;
421
+ }
422
+
423
+ // Exit section on next ### heading
424
+ if (trimmed.startsWith("###") && !trimmed.match(/^###\s+(Modules?|Dependencies?|Database\s+Migrations?|Acceptance\s+Criteria)/i)) {
425
+ inModules = false; inDeps = false; inMigrations = false; inCriteria = false;
426
+ }
427
+
428
+ // Parse module table rows: | Module | Deliverables | Doc |
429
+ if (inModules && trimmed.startsWith("|") && !trimmed.startsWith("|---") && !trimmed.startsWith("| Module")) {
430
+ const parts = trimmed.split("|").map((p: string) => p.trim()).filter(Boolean);
431
+ if (parts.length >= 2) {
432
+ currentPhase.modules!.push({
433
+ name: parts[0],
434
+ deliverables: parts[1] || "",
435
+ doc: parts[2] || `.pi/architecture/modules/${parts[0].toLowerCase().replace(/[^a-z0-9]+/g, "-")}.md`,
436
+ });
437
+ }
438
+ }
439
+
440
+ // Parse dependencies: list items
441
+ if (inDeps && trimmed.startsWith("-")) {
442
+ currentPhase.dependencies!.push(trimmed.replace(/^[-\s]+/, "").trim());
443
+ }
444
+
445
+ // Parse migrations: - NNN_name: description
446
+ if (inMigrations && trimmed.startsWith("-")) {
447
+ currentPhase.migrations!.push(trimmed.replace(/^[-\s]+/, "").trim());
448
+ }
449
+
450
+ // Parse acceptance criteria: - [ ] item
451
+ if (inCriteria && trimmed.startsWith("- [")) {
452
+ currentPhase.criteria!.push(trimmed.replace(/^\[-\s*\]\s*/, "").trim());
453
+ }
454
+ }
455
+
456
+ // Push last phase
457
+ if (currentPhase && currentPhase.title) {
458
+ phases.push({
459
+ index: currentPhase.index!,
460
+ title: currentPhase.title!,
461
+ goal: currentPhase.goal || "",
462
+ days: currentPhase.days || "",
463
+ modules: currentPhase.modules || [],
464
+ completedModules: currentPhase.completedModules || [],
465
+ dependencies: currentPhase.dependencies || [],
466
+ migrations: currentPhase.migrations || [],
467
+ criteria: currentPhase.criteria || [],
468
+ status: "pending",
469
+ });
470
+ }
471
+
472
+ // Restore status from saved state
473
+ const saved = loadRoadmapState(cwd);
474
+ if (saved) {
475
+ for (const phase of phases) {
476
+ const savedPhase = saved.phases.find((p: RoadmapPhase) => p.index === phase.index);
477
+ if (savedPhase) phase.status = savedPhase.status;
478
+ }
479
+ }
480
+
481
+ return phases;
482
+ }
483
+
484
+ export function saveRoadmapState(cwd: string, state: RoadmapState): void {
485
+ const dir = dirname(join(cwd, ROADMAP_STATE_FILE));
486
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
487
+ writeFileSync(join(cwd, ROADMAP_STATE_FILE), JSON.stringify(state, null, 2));
488
+ }
489
+
490
+ export function loadRoadmapState(cwd: string): RoadmapState | null {
491
+ const p = join(cwd, ROADMAP_STATE_FILE);
492
+ if (!existsSync(p)) return null;
493
+ try {
494
+ return JSON.parse(readFileSync(p, "utf-8"));
495
+ } catch {
496
+ return null;
497
+ }
498
+ }
499
+
500
+ export function formatRoadmapStatus(phases: RoadmapPhase[]): string {
501
+ if (phases.length === 0) {
502
+ return "No implementation-roadmap.md found in .pi/architecture/.";
503
+ }
504
+ const lines = ["## Implementation Roadmap", ""];
505
+ for (const phase of phases) {
506
+ const icon = phase.status === "done" ? "✅" : phase.status === "in_progress" ? "🔄" : "⏳";
507
+ const completedModules = phase.completedModules?.length || 0;
508
+ lines.push(`### Phase ${phase.index}: ${phase.title} ${icon}`);
509
+ lines.push(`**Goal:** ${phase.goal}`);
510
+ lines.push(`**Days:** ${phase.days}`);
511
+ lines.push(`**Status:** ${phase.status}`);
512
+ lines.push(`**Modules (${completedModules}/${phase.modules.length}):** ${phase.modules.map((m: RoadmapPhaseModule) => m.name).join(", ")}`);
513
+ if (phase.migrations.length > 0) {
514
+ lines.push(`**Migrations:** ${phase.migrations.length}`);
515
+ }
516
+ if (phase.criteria.length > 0) {
517
+ lines.push(`**Criteria:** ${phase.criteria.length}`);
518
+ }
519
+ if (phase.dependencies.length > 0) {
520
+ lines.push(`**Depends on:** ${phase.dependencies.join(", ")}`);
521
+ }
522
+ lines.push("");
523
+ }
524
+
525
+ const done = phases.filter((p) => p.status === "done").length;
526
+ const inProgress = phases.filter((p) => p.status === "in_progress").length;
527
+ lines.push(`**Overall:** ${done}/${phases.length} phases done, ${inProgress} in progress`);
528
+ return lines.join("\n");
529
+ }
530
+
531
+ export function getNextPendingPhase(phases: RoadmapPhase[]): RoadmapPhase | null {
532
+ for (const phase of phases) {
533
+ if (phase.status !== "done") {
534
+ // Check dependencies
535
+ const depsMet = phase.dependencies.every((dep: string) => {
536
+ // Dependency format: "Phase N" or "Phase N: Name"
537
+ const depMatch = dep.match(/Phase\s+(\d+)/i);
538
+ if (depMatch) {
539
+ const depIdx = parseInt(depMatch[1], 10);
540
+ const depPhase = phases.find((p: RoadmapPhase) => p.index === depIdx);
541
+ return depPhase && depPhase.status === "done";
542
+ }
543
+ return true; // non-phase dependency treated as met
544
+ });
545
+ if (!depsMet) return null; // can't start yet
546
+ return phase;
547
+ }
548
+ }
549
+ return null; // all done
550
+ }
551
+
@@ -35,6 +35,31 @@ import {
35
35
  generateProofingMarkdown,
36
36
  } from "./architect-lib/generators";
37
37
 
38
+ // ── Helpers ──
39
+
40
+ /**
41
+ * Parse CLI-style arguments with quote awareness.
42
+ * Handles double-quoted strings so that `--epic "My Epic"` yields ["--epic", "My Epic"].
43
+ */
44
+ function parseArgs(raw: string): string[] {
45
+ const tokens: string[] = [];
46
+ let current = "";
47
+ let inQuote = false;
48
+ for (const ch of raw) {
49
+ if (ch === '"') {
50
+ inQuote = !inQuote;
51
+ continue;
52
+ }
53
+ if (!inQuote && ch === " ") {
54
+ if (current) { tokens.push(current); current = ""; }
55
+ continue;
56
+ }
57
+ current += ch;
58
+ }
59
+ if (current) tokens.push(current);
60
+ return tokens;
61
+ }
62
+
38
63
  // ── Epic State Persistence ──
39
64
 
40
65
  const EPIC_STATE_KEY = ".pi/.guardian-epic-state.json";
@@ -127,8 +152,18 @@ class EpicManager {
127
152
  repoTool === "glab" ? "glab auth status 2>/dev/null" : "gh auth status 2>/dev/null",
128
153
  );
129
154
  if (authCheck.exitCode === 0) {
130
- remoteRepo = ensureRemoteRepo(this.cwd, targetRepo, name, repoTool);
131
- hasRemote = remoteRepo.length > 0;
155
+ // Verify project-level access, not just global auth
156
+ const projectAccessCheck = repository ? runScript(
157
+ this.cwd,
158
+ repoTool === "glab"
159
+ ? `glab api "projects/${repository}" --method GET 2>/dev/null | jq -r '.id // empty'`
160
+ : `gh api "repos/${repository}" --method GET 2>/dev/null | jq -r '.id // empty'`,
161
+ ) : { exitCode: 0, stdout: "" };
162
+
163
+ if (projectAccessCheck.exitCode === 0 && projectAccessCheck.stdout.trim().length > 0) {
164
+ remoteRepo = ensureRemoteRepo(this.cwd, targetRepo, name, repoTool);
165
+ hasRemote = remoteRepo.length > 0;
166
+ }
132
167
  }
133
168
  }
134
169
 
@@ -301,7 +336,7 @@ export default function (pi: ExtensionAPI) {
301
336
  handler: async (args, ctx) => {
302
337
  if (!manager) manager = new EpicManager(ctx.cwd);
303
338
  const raw = typeof args === "string" ? args : "";
304
- const tokens = raw ? raw.split(/\s+/).filter(Boolean) : [];
339
+ const tokens = raw ? parseArgs(raw) : [];
305
340
  if (tokens.length === 0) {
306
341
  ctx.ui.notify(
307
342
  "Usage: /architect [--epic Name] [--tracking-issue N] | --roadmap | --phase \"Phase 1\" | --phase-status | --phase-done <N> | --phase-module-done <N> \"Module\" | status | next-epic | abort",
@@ -23,6 +23,15 @@
23
23
  import { execSync } from "node:child_process";
24
24
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
25
25
  import { dirname, join } from "node:path";
26
+ import {
27
+ fetchIssueContent,
28
+ forgePrExists as adapterForgePrExists,
29
+ forgePrMerged as adapterForgePrMerged,
30
+ readRepoTool,
31
+ readRepository,
32
+ getGitBaseUrl,
33
+ runScript,
34
+ } from "./architect-lib/forge-adapter.ts";
26
35
 
27
36
  // ── Validator Scripts ──
28
37
 
@@ -38,131 +47,27 @@ const VALIDATOR_SCRIPTS: Record<string, string> = {
38
47
 
39
48
  // ── Helpers ──
40
49
 
41
- function runScript(cwd: string, script: string): { exitCode: number; stdout: string } {
42
- try {
43
- const stdout = execSync(`bash -c "${script}"`, { cwd, timeout: 120_000, encoding: "utf-8" });
44
- return { exitCode: 0, stdout };
45
- } catch (e: unknown) {
46
- const err = e as { status?: number; stdout?: string; message?: string };
47
- return { exitCode: err.status ?? 1, stdout: err.stdout ?? err.message ?? "" };
48
- }
49
- }
50
-
51
- function readRepository(cwd: string): string | null {
52
- try {
53
- const manifestPath = join(cwd, "guardian-manifest.json");
54
- if (existsSync(manifestPath)) {
55
- const raw = readFileSync(manifestPath, "utf-8");
56
- const manifest = JSON.parse(raw) as {
57
- repository?: string;
58
- templateContext?: { repository?: string };
59
- };
60
- if (manifest.repository) return manifest.repository;
61
- if (manifest.templateContext?.repository) return manifest.templateContext.repository;
62
- }
63
- } catch {
64
- // ignore
65
- }
66
- return null;
67
- }
68
-
69
- function readRepoTool(cwd: string): string {
70
- try {
71
- const manifestPath = join(cwd, "guardian-manifest.json");
72
- if (existsSync(manifestPath)) {
73
- const raw = readFileSync(manifestPath, "utf-8");
74
- const manifest = JSON.parse(raw) as { repoTool?: string };
75
- if (manifest.repoTool === "glab") return "glab";
76
- }
77
- } catch {
78
- // fall through to default
79
- }
80
- return "gh";
81
- }
82
-
83
- function getGitBaseUrl(repoTool: string): string {
84
- if (repoTool === "glab") {
85
- try {
86
- const uri = execSync("glab config get gitlab_uri 2>/dev/null", {
87
- encoding: "utf-8",
88
- }).trim();
89
- if (uri) return uri.replace(/\/+$/, "");
90
- } catch {
91
- // fall through to default
92
- }
93
- return "https://gitlab.com";
94
- }
95
- return "https://github.com";
96
- }
97
-
98
- // Fetch issue content from remote (gh or glab) or fallback to local file
99
- function fetchIssueContent(
100
- cwd: string,
101
- issueId: string,
102
- remoteIssueId?: string | null,
103
- ): { content: string; source: string } {
104
- const repository = readRepository(cwd);
105
- const repoTool = readRepoTool(cwd);
106
- const baseUrl = getGitBaseUrl(repoTool);
107
-
108
- if (remoteIssueId && repository) {
109
- try {
110
- let result;
111
- if (repoTool === "glab") {
112
- result = runScript(
113
- cwd,
114
- `glab issue view ${remoteIssueId} --repo ${repository} --output json`,
115
- );
116
- if (result.exitCode === 0 && result.stdout) {
117
- const parsed = JSON.parse(result.stdout) as {
118
- title?: string;
119
- description?: string;
120
- };
121
- if (parsed.description) {
122
- return {
123
- content: parsed.description,
124
- source: `Remote issue: ${baseUrl}/${repository}/issues/${remoteIssueId}`,
125
- };
126
- }
127
- }
128
- } else {
129
- result = runScript(
130
- cwd,
131
- `gh issue view ${remoteIssueId} --repo ${repository} --json title,body`,
132
- );
133
- if (result.exitCode === 0 && result.stdout) {
134
- const parsed = JSON.parse(result.stdout) as { title?: string; body?: string };
135
- if (parsed.body) {
136
- return {
137
- content: parsed.body,
138
- source: `Remote issue: ${baseUrl}/${repository}/issues/${remoteIssueId}`,
139
- };
140
- }
141
- }
142
- }
143
- } catch {
144
- // fallback to local file
50
+ /**
51
+ * Parse CLI-style arguments with quote awareness.
52
+ * Handles double-quoted strings so that `--name "My Epic"` yields ["--name", "My Epic"].
53
+ */
54
+ function parseArgs(raw: string): string[] {
55
+ const tokens: string[] = [];
56
+ let current = "";
57
+ let inQuote = false;
58
+ for (const ch of raw) {
59
+ if (ch === '"') {
60
+ inQuote = !inQuote;
61
+ continue;
145
62
  }
146
- }
147
-
148
- // Fallback to local file
149
- const issueFilename = `${issueId}.md`.replace(/\//g, "-");
150
- const issuePath = join(cwd, ".pi/issues", issueFilename);
151
- try {
152
- if (existsSync(issuePath)) {
153
- return {
154
- content: readFileSync(issuePath, "utf-8"),
155
- source: `Local file: .pi/issues/${issueFilename}`,
156
- };
63
+ if (!inQuote && ch === " ") {
64
+ if (current) { tokens.push(current); current = ""; }
65
+ continue;
157
66
  }
158
- } catch {
159
- // ignore
67
+ current += ch;
160
68
  }
161
-
162
- return {
163
- content: "Issue content not available.",
164
- source: issueId,
165
- };
69
+ if (current) tokens.push(current);
70
+ return tokens;
166
71
  }
167
72
 
168
73
  // ── Types ──
@@ -330,9 +235,19 @@ function statusLine(state: PipelineState | null): string {
330
235
 
331
236
  class PipelineManager {
332
237
  private state: PipelineState | null;
238
+ private repoTool: string;
333
239
 
334
240
  constructor(private cwd: string) {
335
241
  this.state = loadPipelineState(cwd);
242
+ this.repoTool = readRepoTool(cwd);
243
+ }
244
+
245
+ /**
246
+ * Reload pipeline state from disk, discarding any cached in-memory state.
247
+ * Use this when another extension (e.g., architect) may have written state directly.
248
+ */
249
+ private reloadFromDisk(): void {
250
+ this.state = loadPipelineState(this.cwd);
336
251
  }
337
252
 
338
253
  /**
@@ -352,8 +267,8 @@ class PipelineManager {
352
267
 
353
268
  // Check ground truth
354
269
  const branchExists = this.gitBranchExists(branch);
355
- const prMerged = this.ghPrMerged(branch);
356
- const prExists = this.ghPrExists(branch);
270
+ const prMerged = this.forgePrMerged(branch);
271
+ const prExists = this.forgePrExists(branch);
357
272
  const hasCommits = branchExists && this.branchHasCommits(branch);
358
273
 
359
274
  if (prMerged) {
@@ -400,7 +315,7 @@ class PipelineManager {
400
315
  }
401
316
 
402
317
  // If all items done, update overall status
403
- if (this.state.results.every((r) => r.status === "done") && this.state.items.length > 0) {
318
+ if (this.state.results.length === this.state.items.length && this.state.results.length > 0 && this.state.results.every((r) => r.status === "done")) {
404
319
  this.state.status = "done";
405
320
  this.state.currentItemIndex = this.state.items.length;
406
321
  this.state.currentStepIndex = 0;
@@ -442,18 +357,18 @@ class PipelineManager {
442
357
  } catch { return false; }
443
358
  }
444
359
 
445
- private ghPrExists(branch: string): boolean {
446
- try {
447
- const result = execSync(`gh pr list --head "${branch}" --json number --jq "length" 2>/dev/null`, { cwd: this.cwd, encoding: "utf-8" });
448
- return parseInt(result.trim()) > 0;
449
- } catch { return false; }
360
+ /**
361
+ * Check if a PR/MR exists for the given branch, dispatching by repoTool.
362
+ */
363
+ private forgePrExists(branch: string): boolean {
364
+ return adapterForgePrExists(this.cwd, branch);
450
365
  }
451
366
 
452
- private ghPrMerged(branch: string): boolean {
453
- try {
454
- const result = execSync(`gh pr list --head "${branch}" --json state,mergedAt --jq ".[] | select(.state==\"MERGED\") | .mergedAt" 2>/dev/null`, { cwd: this.cwd, encoding: "utf-8" });
455
- return result.trim().length > 0;
456
- } catch { return false; }
367
+ /**
368
+ * Check if a PR/MR for the given branch is merged, dispatching by repoTool.
369
+ */
370
+ private forgePrMerged(branch: string): boolean {
371
+ return adapterForgePrMerged(this.cwd, branch);
457
372
  }
458
373
 
459
374
  /**
@@ -507,16 +422,16 @@ class PipelineManager {
507
422
  return { verified: true, reason: "Commits found on branch" };
508
423
  }
509
424
  case "create-mr": {
510
- if (!this.ghPrExists(branch)) {
511
- return { verified: false, reason: `No PR found for branch ${branch}. Create a PR first.` };
425
+ if (!this.forgePrExists(branch)) {
426
+ return { verified: false, reason: `No PR/MR found for branch ${branch}. Create a PR/MR first.` };
512
427
  }
513
- return { verified: true, reason: "PR exists" };
428
+ return { verified: true, reason: "PR/MR exists" };
514
429
  }
515
430
  case "merge": {
516
- if (!this.ghPrMerged(branch)) {
517
- return { verified: false, reason: `PR for ${branch} is not merged yet.` };
431
+ if (!this.forgePrMerged(branch)) {
432
+ return { verified: false, reason: `PR/MR for ${branch} is not merged yet.` };
518
433
  }
519
- return { verified: true, reason: "PR merged" };
434
+ return { verified: true, reason: "PR/MR merged" };
520
435
  }
521
436
  default:
522
437
  return { verified: true, reason: "Unknown step — skipping verification" };
@@ -785,9 +700,9 @@ export default function (pi: ExtensionAPI) {
785
700
  if (!manager) manager = new PipelineManager(ctx.cwd);
786
701
  const state = manager.getState();
787
702
 
788
- // pi passes args as a string. Split into tokens.
703
+ // pi passes args as a string. Parse into tokens (quote-aware).
789
704
  const raw = typeof args === "string" ? args : "";
790
- const tokens = raw.split(/\s+/).filter(Boolean);
705
+ const tokens = parseArgs(raw);
791
706
  const action = tokens[0];
792
707
 
793
708
  // Status
@@ -917,6 +832,7 @@ export default function (pi: ExtensionAPI) {
917
832
  parameters: { type: "object", properties: {} },
918
833
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
919
834
  if (!manager) manager = new PipelineManager(ctx.cwd);
835
+ manager.reloadFromDisk();
920
836
  const state = manager.getState();
921
837
  if (!state) {
922
838
  return { content: [{ type: "text" as const, text: "No active pipeline." }] };
@@ -938,6 +854,7 @@ export default function (pi: ExtensionAPI) {
938
854
  },
939
855
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
940
856
  if (!manager) manager = new PipelineManager(ctx.cwd);
857
+ manager.reloadFromDisk();
941
858
  const state = manager.getState();
942
859
  if (!state || state.status !== "running") {
943
860
  return { content: [{ type: "text" as const, text: "No running pipeline." }] };
@@ -1037,6 +954,7 @@ export default function (pi: ExtensionAPI) {
1037
954
  },
1038
955
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1039
956
  if (!manager) manager = new PipelineManager(ctx.cwd);
957
+ manager.reloadFromDisk();
1040
958
  const state = manager.getState();
1041
959
  if (!state || state.status !== "running") {
1042
960
  return { content: [{ type: "text" as const, text: "No running pipeline." }] };
@@ -1085,6 +1003,7 @@ export default function (pi: ExtensionAPI) {
1085
1003
  },
1086
1004
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1087
1005
  if (!manager) manager = new PipelineManager(ctx.cwd);
1006
+ manager.reloadFromDisk();
1088
1007
 
1089
1008
  const name = (params.name as string) || "pipeline";
1090
1009
  const items = ((params.items as string) || "")
@@ -1133,6 +1052,7 @@ export default function (pi: ExtensionAPI) {
1133
1052
  },
1134
1053
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1135
1054
  if (!manager) manager = new PipelineManager(ctx.cwd);
1055
+ manager.reloadFromDisk();
1136
1056
  const state = manager.getState();
1137
1057
  if (!state || state.status !== "running") {
1138
1058
  return { content: [{ type: "text" as const, text: "No running pipeline." }] };
@@ -1213,6 +1133,7 @@ export default function (pi: ExtensionAPI) {
1213
1133
  parameters: { type: "object", properties: {} },
1214
1134
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
1215
1135
  if (!manager) manager = new PipelineManager(ctx.cwd);
1136
+ manager.reloadFromDisk();
1216
1137
  const state = manager.getState();
1217
1138
  if (!state || state.status !== "running") {
1218
1139
  return { content: [{ type: "text" as const, text: "No running pipeline." }] };
@@ -1,5 +1,5 @@
1
1
  {
2
- "timestamp": "2026-07-09T08:58:48Z",
2
+ "timestamp": "2026-07-09T10:08:06Z",
3
3
  "mode": "all",
4
4
  "stages_run": [],
5
5
  "summary": {
@@ -138,16 +138,14 @@ case "$PLATFORM" in
138
138
  [[ -n "$TITLE" ]] && ARGS+=(--title "$TITLE")
139
139
  [[ -n "$MILESTONE" ]] && ARGS+=(--milestone "$MILESTONE")
140
140
 
141
- # Body: strip YAML frontmatter from --body-file, then write to temp file for glab
141
+ # Body: strip YAML frontmatter from --body-file, then pass as inline --description
142
+ # (glab does not support --description-file, so we load into a string)
142
143
  if [[ -n "$BODY_FILE" && -f "$BODY_FILE" ]]; then
143
144
  STRIPPED_BODY=$(awk '/^---$/{n++; next} n>=2{print}' "$BODY_FILE")
144
145
  if [[ -n "$STRIPPED_BODY" ]]; then
145
- TMP_BODY=$(mktemp /tmp/guardian-issue-body-XXXXXX 2>/dev/null) || TMP_BODY="/tmp/guardian-issue-body-$$.md"
146
- echo "$STRIPPED_BODY" > "$TMP_BODY"
147
- ARGS+=(--description-file "$TMP_BODY")
148
- trap "rm -f '$TMP_BODY'" EXIT
146
+ ARGS+=(--description "$STRIPPED_BODY")
149
147
  else
150
- ARGS+=(--description-file "$BODY_FILE")
148
+ ARGS+=(--description "$(cat "$BODY_FILE")")
151
149
  fi
152
150
  elif [[ -n "$BODY" ]]; then
153
151
  ARGS+=(--description "$BODY")
@@ -42,7 +42,23 @@ Relates to #$EPIC_ID" 2>/dev/null
42
42
  echo "Linked GitHub issue #$ISSUE_ID to #$EPIC_ID"
43
43
  ;;
44
44
  gitlab)
45
- glab issue update "$ISSUE_ID" --milestone "$EPIC_ID" 2>/dev/null
45
+ # Use GitLab API to link issue to epic (--milestone is semantically wrong).
46
+ # API: POST /groups/:group/epics/:epic_iid/issues/:issue_iid
47
+ # We derive the group from the project path.
48
+ if command -v jq &>/dev/null && [[ -n "${CI_PROJECT_ID:-}" || -n "${GITLAB_PROJECT_ID:-}" ]]; then
49
+ local pid="${CI_PROJECT_ID:-${GITLAB_PROJECT_ID:-}}"
50
+ glab api "projects/$pid" --method GET 2>/dev/null | jq -r '.namespace.full_path // empty' | read -r group_path
51
+ if [[ -n "$group_path" ]]; then
52
+ glab api --method POST "groups/$group_path/epics/$EPIC_ID/issues/$ISSUE_ID" 2>/dev/null || {
53
+ # Fallback: add a note referencing the epic
54
+ glab issue note "$ISSUE_ID" --message "Part of epic #$EPIC_ID" 2>/dev/null || true
55
+ }
56
+ else
57
+ glab issue note "$ISSUE_ID" --message "Part of epic #$EPIC_ID" 2>/dev/null || true
58
+ fi
59
+ else
60
+ glab issue note "$ISSUE_ID" --message "Part of epic #$EPIC_ID" 2>/dev/null || true
61
+ fi
46
62
  echo "Linked GitLab issue #$ISSUE_ID to epic #$EPIC_ID"
47
63
  ;;
48
64
  *)