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 +3 -3
- package/dist/exports.js +2 -2
- package/package.json +1 -1
- package/templates/pi/extensions/architect-lib/helpers.ts +250 -76
- package/templates/pi/extensions/architect.ts +38 -3
- package/templates/pi/extensions/forge-adapter.ts +266 -0
- package/templates/pi/extensions/pipeline.ts +69 -138
- package/templates/pi/preflight_report.json +1 -1
- package/templates/pi/scripts/git/create-tracking-issue.sh +4 -6
- package/templates/pi/scripts/git/link-issue-to-epic.sh +17 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,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
|
-
|
|
18
|
-
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
|
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.
|
|
356
|
-
const prExists = this.
|
|
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")
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
713
|
+
// pi passes args as a string. Parse into tokens (quote-aware).
|
|
789
714
|
const raw = typeof args === "string" ? args : "";
|
|
790
|
-
const tokens = raw
|
|
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." }] };
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
*)
|