guardian-framework 0.1.56 → 0.1.58

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.55", SCHEMA_VERSION = "0.1";
2227
+ var _filename, _dirname, MANIFEST_FILE = "guardian-manifest.json", FRAMEWORK_VERSION = "0.1.58", 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.56", 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.58", 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.55";
14211
+ var VERSION = "0.1.58";
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.55", SCHEMA_VERSION = "0.1";
1922
+ var _filename, _dirname, MANIFEST_FILE = "guardian-manifest.json", FRAMEWORK_VERSION = "0.1.58", 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.56", 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.58", 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.56",
3
+ "version": "0.1.58",
4
4
  "description": "Token-optimized agentic framework scaffolder with pi-first architecture",
5
5
  "type": "module",
6
6
  "main": "dist/exports.js",
@@ -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",
@@ -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
+ }
@@ -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 "./forge-adapter.ts";
26
35
 
27
36
  // ── Validator Scripts ──
28
37
 
@@ -38,6 +47,29 @@ const VALIDATOR_SCRIPTS: Record<string, string> = {
38
47
 
39
48
  // ── Helpers ──
40
49
 
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;
62
+ }
63
+ if (!inQuote && ch === " ") {
64
+ if (current) { tokens.push(current); current = ""; }
65
+ continue;
66
+ }
67
+ current += ch;
68
+ }
69
+ if (current) tokens.push(current);
70
+ return tokens;
71
+ }
72
+
41
73
  function runScript(cwd: string, script: string): { exitCode: number; stdout: string } {
42
74
  try {
43
75
  const stdout = execSync(`bash -c "${script}"`, { cwd, timeout: 120_000, encoding: "utf-8" });
@@ -48,123 +80,6 @@ function runScript(cwd: string, script: string): { exitCode: number; stdout: str
48
80
  }
49
81
  }
50
82
 
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
145
- }
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
- };
157
- }
158
- } catch {
159
- // ignore
160
- }
161
-
162
- return {
163
- content: "Issue content not available.",
164
- source: issueId,
165
- };
166
- }
167
-
168
83
  // ── Types ──
169
84
 
170
85
  type ExtensionContext = {
@@ -330,9 +245,19 @@ function statusLine(state: PipelineState | null): string {
330
245
 
331
246
  class PipelineManager {
332
247
  private state: PipelineState | null;
248
+ private repoTool: string;
333
249
 
334
250
  constructor(private cwd: string) {
335
251
  this.state = loadPipelineState(cwd);
252
+ this.repoTool = readRepoTool(cwd);
253
+ }
254
+
255
+ /**
256
+ * Reload pipeline state from disk, discarding any cached in-memory state.
257
+ * Use this when another extension (e.g., architect) may have written state directly.
258
+ */
259
+ private reloadFromDisk(): void {
260
+ this.state = loadPipelineState(this.cwd);
336
261
  }
337
262
 
338
263
  /**
@@ -352,8 +277,8 @@ class PipelineManager {
352
277
 
353
278
  // Check ground truth
354
279
  const branchExists = this.gitBranchExists(branch);
355
- const prMerged = this.ghPrMerged(branch);
356
- const prExists = this.ghPrExists(branch);
280
+ const prMerged = this.forgePrMerged(branch);
281
+ const prExists = this.forgePrExists(branch);
357
282
  const hasCommits = branchExists && this.branchHasCommits(branch);
358
283
 
359
284
  if (prMerged) {
@@ -400,7 +325,7 @@ class PipelineManager {
400
325
  }
401
326
 
402
327
  // If all items done, update overall status
403
- if (this.state.results.every((r) => r.status === "done") && this.state.items.length > 0) {
328
+ if (this.state.results.length === this.state.items.length && this.state.results.length > 0 && this.state.results.every((r) => r.status === "done")) {
404
329
  this.state.status = "done";
405
330
  this.state.currentItemIndex = this.state.items.length;
406
331
  this.state.currentStepIndex = 0;
@@ -442,18 +367,18 @@ class PipelineManager {
442
367
  } catch { return false; }
443
368
  }
444
369
 
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; }
370
+ /**
371
+ * Check if a PR/MR exists for the given branch, dispatching by repoTool.
372
+ */
373
+ private forgePrExists(branch: string): boolean {
374
+ return adapterForgePrExists(this.cwd, branch);
450
375
  }
451
376
 
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; }
377
+ /**
378
+ * Check if a PR/MR for the given branch is merged, dispatching by repoTool.
379
+ */
380
+ private forgePrMerged(branch: string): boolean {
381
+ return adapterForgePrMerged(this.cwd, branch);
457
382
  }
458
383
 
459
384
  /**
@@ -507,16 +432,16 @@ class PipelineManager {
507
432
  return { verified: true, reason: "Commits found on branch" };
508
433
  }
509
434
  case "create-mr": {
510
- if (!this.ghPrExists(branch)) {
511
- return { verified: false, reason: `No PR found for branch ${branch}. Create a PR first.` };
435
+ if (!this.forgePrExists(branch)) {
436
+ return { verified: false, reason: `No PR/MR found for branch ${branch}. Create a PR/MR first.` };
512
437
  }
513
- return { verified: true, reason: "PR exists" };
438
+ return { verified: true, reason: "PR/MR exists" };
514
439
  }
515
440
  case "merge": {
516
- if (!this.ghPrMerged(branch)) {
517
- return { verified: false, reason: `PR for ${branch} is not merged yet.` };
441
+ if (!this.forgePrMerged(branch)) {
442
+ return { verified: false, reason: `PR/MR for ${branch} is not merged yet.` };
518
443
  }
519
- return { verified: true, reason: "PR merged" };
444
+ return { verified: true, reason: "PR/MR merged" };
520
445
  }
521
446
  default:
522
447
  return { verified: true, reason: "Unknown step — skipping verification" };
@@ -785,9 +710,9 @@ export default function (pi: ExtensionAPI) {
785
710
  if (!manager) manager = new PipelineManager(ctx.cwd);
786
711
  const state = manager.getState();
787
712
 
788
- // pi passes args as a string. Split into tokens.
713
+ // pi passes args as a string. Parse into tokens (quote-aware).
789
714
  const raw = typeof args === "string" ? args : "";
790
- const tokens = raw.split(/\s+/).filter(Boolean);
715
+ const tokens = parseArgs(raw);
791
716
  const action = tokens[0];
792
717
 
793
718
  // Status
@@ -917,6 +842,7 @@ export default function (pi: ExtensionAPI) {
917
842
  parameters: { type: "object", properties: {} },
918
843
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
919
844
  if (!manager) manager = new PipelineManager(ctx.cwd);
845
+ manager.reloadFromDisk();
920
846
  const state = manager.getState();
921
847
  if (!state) {
922
848
  return { content: [{ type: "text" as const, text: "No active pipeline." }] };
@@ -938,6 +864,7 @@ export default function (pi: ExtensionAPI) {
938
864
  },
939
865
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
940
866
  if (!manager) manager = new PipelineManager(ctx.cwd);
867
+ manager.reloadFromDisk();
941
868
  const state = manager.getState();
942
869
  if (!state || state.status !== "running") {
943
870
  return { content: [{ type: "text" as const, text: "No running pipeline." }] };
@@ -1037,6 +964,7 @@ export default function (pi: ExtensionAPI) {
1037
964
  },
1038
965
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1039
966
  if (!manager) manager = new PipelineManager(ctx.cwd);
967
+ manager.reloadFromDisk();
1040
968
  const state = manager.getState();
1041
969
  if (!state || state.status !== "running") {
1042
970
  return { content: [{ type: "text" as const, text: "No running pipeline." }] };
@@ -1085,6 +1013,7 @@ export default function (pi: ExtensionAPI) {
1085
1013
  },
1086
1014
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1087
1015
  if (!manager) manager = new PipelineManager(ctx.cwd);
1016
+ manager.reloadFromDisk();
1088
1017
 
1089
1018
  const name = (params.name as string) || "pipeline";
1090
1019
  const items = ((params.items as string) || "")
@@ -1133,6 +1062,7 @@ export default function (pi: ExtensionAPI) {
1133
1062
  },
1134
1063
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
1135
1064
  if (!manager) manager = new PipelineManager(ctx.cwd);
1065
+ manager.reloadFromDisk();
1136
1066
  const state = manager.getState();
1137
1067
  if (!state || state.status !== "running") {
1138
1068
  return { content: [{ type: "text" as const, text: "No running pipeline." }] };
@@ -1213,6 +1143,7 @@ export default function (pi: ExtensionAPI) {
1213
1143
  parameters: { type: "object", properties: {} },
1214
1144
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
1215
1145
  if (!manager) manager = new PipelineManager(ctx.cwd);
1146
+ manager.reloadFromDisk();
1216
1147
  const state = manager.getState();
1217
1148
  if (!state || state.status !== "running") {
1218
1149
  return { content: [{ type: "text" as const, text: "No running pipeline." }] };
@@ -1,5 +1,5 @@
1
1
  {
2
- "timestamp": "2026-07-09T08:50:55Z",
2
+ "timestamp": "2026-07-09T09:03:43Z",
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
  *)