guardian-framework 0.1.57 → 0.1.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +3 -3
- package/dist/exports.js +2 -2
- package/package.json +1 -1
- package/templates/pi/extensions/architect-lib/forge-adapter.ts +266 -0
- package/templates/pi/extensions/architect-lib/helpers.ts +250 -76
- package/templates/pi/extensions/architect.ts +38 -3
- package/templates/pi/extensions/pipeline.ts +64 -143
- 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.59", SCHEMA_VERSION = "0.1";
|
|
2228
2228
|
var init_manifest = __esm(() => {
|
|
2229
2229
|
_filename = fileURLToPath(import.meta.url);
|
|
2230
2230
|
_dirname = path3.dirname(_filename);
|
|
@@ -2303,7 +2303,7 @@ __export(exports_package, {
|
|
|
2303
2303
|
bin: () => bin,
|
|
2304
2304
|
author: () => author
|
|
2305
2305
|
});
|
|
2306
|
-
var name = "guardian-framework", version = "0.1.
|
|
2306
|
+
var name = "guardian-framework", version = "0.1.59", description = "Token-optimized agentic framework scaffolder with pi-first architecture", type = "module", main = "dist/exports.js", exports, types = "dist/exports.d.ts", bin, files, engines, scripts, publishConfig, pi, repository, homepage = "https://github.com/arman-jalili/guardian-framework#readme", bugs, dependencies, devDependencies, keywords, author = "Arman Wolkensteiner-Jalili", license = "MIT", package_default;
|
|
2307
2307
|
var init_package = __esm(() => {
|
|
2308
2308
|
exports = {
|
|
2309
2309
|
".": {
|
|
@@ -14208,7 +14208,7 @@ async function runVerify(targetDir = process.cwd()) {
|
|
|
14208
14208
|
|
|
14209
14209
|
// src/index.ts
|
|
14210
14210
|
init_manifest();
|
|
14211
|
-
var VERSION = "0.1.
|
|
14211
|
+
var VERSION = "0.1.59";
|
|
14212
14212
|
async function runCli() {
|
|
14213
14213
|
const args = parseArgs({
|
|
14214
14214
|
allowPositionals: true,
|
package/dist/exports.js
CHANGED
|
@@ -1919,7 +1919,7 @@ function calculateTokenStats(targetDir, manifest) {
|
|
|
1919
1919
|
lastCalculatedAt: new Date().toISOString()
|
|
1920
1920
|
};
|
|
1921
1921
|
}
|
|
1922
|
-
var _filename, _dirname, MANIFEST_FILE = "guardian-manifest.json", FRAMEWORK_VERSION = "0.1.
|
|
1922
|
+
var _filename, _dirname, MANIFEST_FILE = "guardian-manifest.json", FRAMEWORK_VERSION = "0.1.59", SCHEMA_VERSION = "0.1";
|
|
1923
1923
|
var init_manifest = __esm(() => {
|
|
1924
1924
|
_filename = fileURLToPath(import.meta.url);
|
|
1925
1925
|
_dirname = path.dirname(_filename);
|
|
@@ -1963,7 +1963,7 @@ __export(exports_package, {
|
|
|
1963
1963
|
bin: () => bin,
|
|
1964
1964
|
author: () => author
|
|
1965
1965
|
});
|
|
1966
|
-
var name = "guardian-framework", version = "0.1.
|
|
1966
|
+
var name = "guardian-framework", version = "0.1.59", description = "Token-optimized agentic framework scaffolder with pi-first architecture", type = "module", main = "dist/exports.js", exports, types = "dist/exports.d.ts", bin, files, engines, scripts, publishConfig, pi, repository, homepage = "https://github.com/arman-jalili/guardian-framework#readme", bugs, dependencies, devDependencies, keywords, author = "Arman Wolkensteiner-Jalili", license = "MIT", package_default;
|
|
1967
1967
|
var init_package = __esm(() => {
|
|
1968
1968
|
exports = {
|
|
1969
1969
|
".": {
|
package/package.json
CHANGED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forge Adapter — Centralized forge abstraction for GitHub and GitLab.
|
|
3
|
+
*
|
|
4
|
+
* All forge-specific operations (auth, repo, issues, MR/PR) go through this module.
|
|
5
|
+
* Use `readRepoTool()` to determine which forge is active, then call the appropriate
|
|
6
|
+
* helper — null returned / empty string means "not available / not supported".
|
|
7
|
+
*
|
|
8
|
+
* Canonical Reference: .pi/extensions/architect-lib/helpers.ts
|
|
9
|
+
* Last Sync: 2026-07-09
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
// ── Forge Detection ──
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Read repoTool from guardian-manifest.json (defaults to "gh")
|
|
20
|
+
*/
|
|
21
|
+
export function readRepoTool(cwd: string): string {
|
|
22
|
+
try {
|
|
23
|
+
const manifestPath = join(cwd, "guardian-manifest.json");
|
|
24
|
+
if (existsSync(manifestPath)) {
|
|
25
|
+
const raw = readFileSync(manifestPath, "utf-8");
|
|
26
|
+
const manifest = JSON.parse(raw) as { repoTool?: string };
|
|
27
|
+
if (manifest.repoTool === "glab") return "glab";
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// fall through to default
|
|
31
|
+
}
|
|
32
|
+
return "gh";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Read repository slug from guardian-manifest.json
|
|
37
|
+
*/
|
|
38
|
+
export function readRepository(cwd: string): string | null {
|
|
39
|
+
try {
|
|
40
|
+
const manifestPath = join(cwd, "guardian-manifest.json");
|
|
41
|
+
if (existsSync(manifestPath)) {
|
|
42
|
+
const raw = readFileSync(manifestPath, "utf-8");
|
|
43
|
+
const manifest = JSON.parse(raw) as {
|
|
44
|
+
repository?: string;
|
|
45
|
+
templateContext?: { repository?: string };
|
|
46
|
+
};
|
|
47
|
+
if (manifest.repository) return manifest.repository;
|
|
48
|
+
if (manifest.templateContext?.repository)
|
|
49
|
+
return manifest.templateContext.repository;
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// ignore
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get forge base URL. For GitLab, detects self-hosted instances via glab config.
|
|
59
|
+
*/
|
|
60
|
+
export function getGitBaseUrl(repoTool: string): string {
|
|
61
|
+
if (repoTool === "glab") {
|
|
62
|
+
try {
|
|
63
|
+
const uri = execSync("glab config get gitlab_uri 2>/dev/null", {
|
|
64
|
+
encoding: "utf-8",
|
|
65
|
+
}).trim();
|
|
66
|
+
if (uri) {
|
|
67
|
+
const normalized = uri.replace(/\/+$/, "");
|
|
68
|
+
// Ensure URL has a scheme, default to https://
|
|
69
|
+
if (!/^https?:\/\//i.test(normalized)) {
|
|
70
|
+
return `https://${normalized}`;
|
|
71
|
+
}
|
|
72
|
+
return normalized;
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// fall through to default
|
|
76
|
+
}
|
|
77
|
+
return "https://gitlab.com";
|
|
78
|
+
}
|
|
79
|
+
return "https://github.com";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function commandExists(cmd: string): boolean {
|
|
83
|
+
try {
|
|
84
|
+
execSync(`command -v ${cmd}`, { stdio: "ignore" });
|
|
85
|
+
return true;
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Run a bash script and return exit code + stdout.
|
|
93
|
+
*/
|
|
94
|
+
export function runScript(cwd: string, script: string): { exitCode: number; stdout: string } {
|
|
95
|
+
try {
|
|
96
|
+
const stdout = execSync(`bash -c "${script}"`, { cwd, timeout: 120_000, encoding: "utf-8" });
|
|
97
|
+
return { exitCode: 0, stdout };
|
|
98
|
+
} catch (e: unknown) {
|
|
99
|
+
const err = e as { status?: number; stdout?: string; message?: string };
|
|
100
|
+
return { exitCode: err.status ?? 1, stdout: err.stdout ?? err.message ?? "" };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Auth Validation ──
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Verify forge CLI is authenticated AND has access to the configured repository.
|
|
108
|
+
* Returns true only if both checks pass.
|
|
109
|
+
*/
|
|
110
|
+
export function verifyForgeAccess(cwd: string): { authenticated: boolean; projectAccessible: boolean } {
|
|
111
|
+
const repoTool = readRepoTool(cwd);
|
|
112
|
+
const repository = readRepository(cwd);
|
|
113
|
+
const result = { authenticated: false, projectAccessible: false };
|
|
114
|
+
|
|
115
|
+
if (!commandExists(repoTool)) return result;
|
|
116
|
+
|
|
117
|
+
// Check CLI auth status
|
|
118
|
+
const authResult = runScript(
|
|
119
|
+
cwd,
|
|
120
|
+
repoTool === "glab" ? "glab auth status 2>/dev/null" : "gh auth status 2>/dev/null",
|
|
121
|
+
);
|
|
122
|
+
if (authResult.exitCode !== 0) return result;
|
|
123
|
+
result.authenticated = true;
|
|
124
|
+
|
|
125
|
+
// Verify project-level access
|
|
126
|
+
if (repository) {
|
|
127
|
+
const projectCheck = runScript(
|
|
128
|
+
cwd,
|
|
129
|
+
repoTool === "glab"
|
|
130
|
+
? `glab api "projects/${repository}" --method GET 2>/dev/null | jq -r '.id // empty'`
|
|
131
|
+
: `gh api "repos/${repository}" --method GET 2>/dev/null | jq -r '.id // empty'`,
|
|
132
|
+
);
|
|
133
|
+
if (projectCheck.exitCode === 0 && projectCheck.stdout.trim().length > 0) {
|
|
134
|
+
result.projectAccessible = true;
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
// No repository configured — auth is sufficient
|
|
138
|
+
result.projectAccessible = true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── PR/MR Operations ──
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Check if a PR (GitHub) or MR (GitLab) exists for the given branch.
|
|
148
|
+
*/
|
|
149
|
+
export function forgePrExists(cwd: string, branch: string): boolean {
|
|
150
|
+
const repoTool = readRepoTool(cwd);
|
|
151
|
+
try {
|
|
152
|
+
if (repoTool === "glab") {
|
|
153
|
+
const result = execSync(
|
|
154
|
+
`glab mr list --source-branch "${branch}" --output json 2>/dev/null`,
|
|
155
|
+
{ cwd, encoding: "utf-8" },
|
|
156
|
+
);
|
|
157
|
+
const parsed = JSON.parse(result.trim() || "[]");
|
|
158
|
+
return Array.isArray(parsed) && parsed.length > 0;
|
|
159
|
+
}
|
|
160
|
+
const result = execSync(
|
|
161
|
+
`gh pr list --head "${branch}" --json number --jq "length" 2>/dev/null`,
|
|
162
|
+
{ cwd, encoding: "utf-8" },
|
|
163
|
+
);
|
|
164
|
+
return parseInt(result.trim()) > 0;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Check if a PR (GitHub) or MR (GitLab) for the given branch is merged.
|
|
172
|
+
*/
|
|
173
|
+
export function forgePrMerged(cwd: string, branch: string): boolean {
|
|
174
|
+
const repoTool = readRepoTool(cwd);
|
|
175
|
+
try {
|
|
176
|
+
if (repoTool === "glab") {
|
|
177
|
+
const result = execSync(
|
|
178
|
+
`glab mr list --source-branch "${branch}" --output json 2>/dev/null`,
|
|
179
|
+
{ cwd, encoding: "utf-8" },
|
|
180
|
+
);
|
|
181
|
+
const parsed = JSON.parse(result.trim() || "[]") as { state?: string }[];
|
|
182
|
+
return Array.isArray(parsed) && parsed.some((mr) => mr.state === "merged");
|
|
183
|
+
}
|
|
184
|
+
const result = execSync(
|
|
185
|
+
`gh pr list --head "${branch}" --json state,mergedAt --jq ".[] | select(.state==\"MERGED\") | .mergedAt" 2>/dev/null`,
|
|
186
|
+
{ cwd, encoding: "utf-8" },
|
|
187
|
+
);
|
|
188
|
+
return result.trim().length > 0;
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Issue Operations ──
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Fetch issue content from remote forge or fallback to local file.
|
|
198
|
+
*/
|
|
199
|
+
export function fetchIssueContent(
|
|
200
|
+
cwd: string,
|
|
201
|
+
issueId: string,
|
|
202
|
+
remoteIssueId?: string | null,
|
|
203
|
+
): { content: string; source: string } {
|
|
204
|
+
const repository = readRepository(cwd);
|
|
205
|
+
const repoTool = readRepoTool(cwd);
|
|
206
|
+
const baseUrl = getGitBaseUrl(repoTool);
|
|
207
|
+
|
|
208
|
+
if (remoteIssueId && repository) {
|
|
209
|
+
try {
|
|
210
|
+
let result;
|
|
211
|
+
if (repoTool === "glab") {
|
|
212
|
+
result = runScript(
|
|
213
|
+
cwd,
|
|
214
|
+
`glab issue view ${remoteIssueId} --repo ${repository} --output json`,
|
|
215
|
+
);
|
|
216
|
+
if (result.exitCode === 0 && result.stdout) {
|
|
217
|
+
const parsed = JSON.parse(result.stdout) as {
|
|
218
|
+
title?: string;
|
|
219
|
+
description?: string;
|
|
220
|
+
};
|
|
221
|
+
if (parsed.description) {
|
|
222
|
+
return {
|
|
223
|
+
content: parsed.description,
|
|
224
|
+
source: `Remote issue: ${baseUrl}/${repository}/issues/${remoteIssueId}`,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} else {
|
|
229
|
+
result = runScript(
|
|
230
|
+
cwd,
|
|
231
|
+
`gh issue view ${remoteIssueId} --repo ${repository} --json title,body`,
|
|
232
|
+
);
|
|
233
|
+
if (result.exitCode === 0 && result.stdout) {
|
|
234
|
+
const parsed = JSON.parse(result.stdout) as { title?: string; body?: string };
|
|
235
|
+
if (parsed.body) {
|
|
236
|
+
return {
|
|
237
|
+
content: parsed.body,
|
|
238
|
+
source: `Remote issue: ${baseUrl}/${repository}/issues/${remoteIssueId}`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
// fallback to local file
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Fallback to local file
|
|
249
|
+
const issueFilename = `${issueId}.md`.replace(/\//g, "-");
|
|
250
|
+
const issuePath = join(cwd, ".pi/issues", issueFilename);
|
|
251
|
+
try {
|
|
252
|
+
if (existsSync(issuePath)) {
|
|
253
|
+
return {
|
|
254
|
+
content: readFileSync(issuePath, "utf-8"),
|
|
255
|
+
source: `Local file: .pi/issues/${issueFilename}`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
} catch {
|
|
259
|
+
// ignore
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
content: "Issue content not available.",
|
|
264
|
+
source: issueId,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { execFileSync, execSync } from "node:child_process";
|
|
2
|
-
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import type { ExtensionContext, EpicState, ModuleComponent, ArchitectureSlice } from "./types.ts";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { ExtensionContext, EpicState, ModuleComponent, ArchitectureSlice, RoadmapPhase, RoadmapState, RoadmapPhaseModule } from "./types.ts";
|
|
5
|
+
import {
|
|
6
|
+
readRepoTool as adapterReadRepoTool,
|
|
7
|
+
readRepository as adapterReadRepository,
|
|
8
|
+
getGitBaseUrl as adapterGetGitBaseUrl,
|
|
9
|
+
commandExists as adapterCommandExists,
|
|
10
|
+
runScript as adapterRunScript,
|
|
11
|
+
verifyForgeAccess,
|
|
12
|
+
} from "./forge-adapter.ts";
|
|
5
13
|
// ── Constants ──
|
|
6
14
|
|
|
7
15
|
export const EPIC_STATE_KEY = ".pi/.guardian-epic-state.json";
|
|
8
16
|
export const ARCH_MODULES_DIR = ".pi/architecture/modules";
|
|
9
17
|
export const ISSUES_DIR = ".pi/issues";
|
|
18
|
+
export const ROADMAP_FILE = ".pi/architecture/implementation-roadmap.md";
|
|
19
|
+
export const ROADMAP_STATE_FILE = ".pi/.guardian-roadmap-state.json";
|
|
10
20
|
|
|
11
21
|
// ── Helpers ──
|
|
12
22
|
|
|
@@ -14,77 +24,14 @@ export function log(ctx: ExtensionContext, message: string, level = "info") {
|
|
|
14
24
|
ctx.ui.notify(message, level);
|
|
15
25
|
}
|
|
16
26
|
|
|
17
|
-
|
|
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",
|
|
@@ -23,6 +23,15 @@
|
|
|
23
23
|
import { execSync } from "node:child_process";
|
|
24
24
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
25
25
|
import { dirname, join } from "node:path";
|
|
26
|
+
import {
|
|
27
|
+
fetchIssueContent,
|
|
28
|
+
forgePrExists as adapterForgePrExists,
|
|
29
|
+
forgePrMerged as adapterForgePrMerged,
|
|
30
|
+
readRepoTool,
|
|
31
|
+
readRepository,
|
|
32
|
+
getGitBaseUrl,
|
|
33
|
+
runScript,
|
|
34
|
+
} from "./architect-lib/forge-adapter.ts";
|
|
26
35
|
|
|
27
36
|
// ── Validator Scripts ──
|
|
28
37
|
|
|
@@ -38,131 +47,27 @@ const VALIDATOR_SCRIPTS: Record<string, string> = {
|
|
|
38
47
|
|
|
39
48
|
// ── Helpers ──
|
|
40
49
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const manifestPath = join(cwd, "guardian-manifest.json");
|
|
54
|
-
if (existsSync(manifestPath)) {
|
|
55
|
-
const raw = readFileSync(manifestPath, "utf-8");
|
|
56
|
-
const manifest = JSON.parse(raw) as {
|
|
57
|
-
repository?: string;
|
|
58
|
-
templateContext?: { repository?: string };
|
|
59
|
-
};
|
|
60
|
-
if (manifest.repository) return manifest.repository;
|
|
61
|
-
if (manifest.templateContext?.repository) return manifest.templateContext.repository;
|
|
62
|
-
}
|
|
63
|
-
} catch {
|
|
64
|
-
// ignore
|
|
65
|
-
}
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function readRepoTool(cwd: string): string {
|
|
70
|
-
try {
|
|
71
|
-
const manifestPath = join(cwd, "guardian-manifest.json");
|
|
72
|
-
if (existsSync(manifestPath)) {
|
|
73
|
-
const raw = readFileSync(manifestPath, "utf-8");
|
|
74
|
-
const manifest = JSON.parse(raw) as { repoTool?: string };
|
|
75
|
-
if (manifest.repoTool === "glab") return "glab";
|
|
76
|
-
}
|
|
77
|
-
} catch {
|
|
78
|
-
// fall through to default
|
|
79
|
-
}
|
|
80
|
-
return "gh";
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function getGitBaseUrl(repoTool: string): string {
|
|
84
|
-
if (repoTool === "glab") {
|
|
85
|
-
try {
|
|
86
|
-
const uri = execSync("glab config get gitlab_uri 2>/dev/null", {
|
|
87
|
-
encoding: "utf-8",
|
|
88
|
-
}).trim();
|
|
89
|
-
if (uri) return uri.replace(/\/+$/, "");
|
|
90
|
-
} catch {
|
|
91
|
-
// fall through to default
|
|
92
|
-
}
|
|
93
|
-
return "https://gitlab.com";
|
|
94
|
-
}
|
|
95
|
-
return "https://github.com";
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Fetch issue content from remote (gh or glab) or fallback to local file
|
|
99
|
-
function fetchIssueContent(
|
|
100
|
-
cwd: string,
|
|
101
|
-
issueId: string,
|
|
102
|
-
remoteIssueId?: string | null,
|
|
103
|
-
): { content: string; source: string } {
|
|
104
|
-
const repository = readRepository(cwd);
|
|
105
|
-
const repoTool = readRepoTool(cwd);
|
|
106
|
-
const baseUrl = getGitBaseUrl(repoTool);
|
|
107
|
-
|
|
108
|
-
if (remoteIssueId && repository) {
|
|
109
|
-
try {
|
|
110
|
-
let result;
|
|
111
|
-
if (repoTool === "glab") {
|
|
112
|
-
result = runScript(
|
|
113
|
-
cwd,
|
|
114
|
-
`glab issue view ${remoteIssueId} --repo ${repository} --output json`,
|
|
115
|
-
);
|
|
116
|
-
if (result.exitCode === 0 && result.stdout) {
|
|
117
|
-
const parsed = JSON.parse(result.stdout) as {
|
|
118
|
-
title?: string;
|
|
119
|
-
description?: string;
|
|
120
|
-
};
|
|
121
|
-
if (parsed.description) {
|
|
122
|
-
return {
|
|
123
|
-
content: parsed.description,
|
|
124
|
-
source: `Remote issue: ${baseUrl}/${repository}/issues/${remoteIssueId}`,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
} else {
|
|
129
|
-
result = runScript(
|
|
130
|
-
cwd,
|
|
131
|
-
`gh issue view ${remoteIssueId} --repo ${repository} --json title,body`,
|
|
132
|
-
);
|
|
133
|
-
if (result.exitCode === 0 && result.stdout) {
|
|
134
|
-
const parsed = JSON.parse(result.stdout) as { title?: string; body?: string };
|
|
135
|
-
if (parsed.body) {
|
|
136
|
-
return {
|
|
137
|
-
content: parsed.body,
|
|
138
|
-
source: `Remote issue: ${baseUrl}/${repository}/issues/${remoteIssueId}`,
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
} catch {
|
|
144
|
-
// fallback to local file
|
|
50
|
+
/**
|
|
51
|
+
* Parse CLI-style arguments with quote awareness.
|
|
52
|
+
* Handles double-quoted strings so that `--name "My Epic"` yields ["--name", "My Epic"].
|
|
53
|
+
*/
|
|
54
|
+
function parseArgs(raw: string): string[] {
|
|
55
|
+
const tokens: string[] = [];
|
|
56
|
+
let current = "";
|
|
57
|
+
let inQuote = false;
|
|
58
|
+
for (const ch of raw) {
|
|
59
|
+
if (ch === '"') {
|
|
60
|
+
inQuote = !inQuote;
|
|
61
|
+
continue;
|
|
145
62
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const issueFilename = `${issueId}.md`.replace(/\//g, "-");
|
|
150
|
-
const issuePath = join(cwd, ".pi/issues", issueFilename);
|
|
151
|
-
try {
|
|
152
|
-
if (existsSync(issuePath)) {
|
|
153
|
-
return {
|
|
154
|
-
content: readFileSync(issuePath, "utf-8"),
|
|
155
|
-
source: `Local file: .pi/issues/${issueFilename}`,
|
|
156
|
-
};
|
|
63
|
+
if (!inQuote && ch === " ") {
|
|
64
|
+
if (current) { tokens.push(current); current = ""; }
|
|
65
|
+
continue;
|
|
157
66
|
}
|
|
158
|
-
|
|
159
|
-
// ignore
|
|
67
|
+
current += ch;
|
|
160
68
|
}
|
|
161
|
-
|
|
162
|
-
return
|
|
163
|
-
content: "Issue content not available.",
|
|
164
|
-
source: issueId,
|
|
165
|
-
};
|
|
69
|
+
if (current) tokens.push(current);
|
|
70
|
+
return tokens;
|
|
166
71
|
}
|
|
167
72
|
|
|
168
73
|
// ── Types ──
|
|
@@ -330,9 +235,19 @@ function statusLine(state: PipelineState | null): string {
|
|
|
330
235
|
|
|
331
236
|
class PipelineManager {
|
|
332
237
|
private state: PipelineState | null;
|
|
238
|
+
private repoTool: string;
|
|
333
239
|
|
|
334
240
|
constructor(private cwd: string) {
|
|
335
241
|
this.state = loadPipelineState(cwd);
|
|
242
|
+
this.repoTool = readRepoTool(cwd);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Reload pipeline state from disk, discarding any cached in-memory state.
|
|
247
|
+
* Use this when another extension (e.g., architect) may have written state directly.
|
|
248
|
+
*/
|
|
249
|
+
private reloadFromDisk(): void {
|
|
250
|
+
this.state = loadPipelineState(this.cwd);
|
|
336
251
|
}
|
|
337
252
|
|
|
338
253
|
/**
|
|
@@ -352,8 +267,8 @@ class PipelineManager {
|
|
|
352
267
|
|
|
353
268
|
// Check ground truth
|
|
354
269
|
const branchExists = this.gitBranchExists(branch);
|
|
355
|
-
const prMerged = this.
|
|
356
|
-
const prExists = this.
|
|
270
|
+
const prMerged = this.forgePrMerged(branch);
|
|
271
|
+
const prExists = this.forgePrExists(branch);
|
|
357
272
|
const hasCommits = branchExists && this.branchHasCommits(branch);
|
|
358
273
|
|
|
359
274
|
if (prMerged) {
|
|
@@ -400,7 +315,7 @@ class PipelineManager {
|
|
|
400
315
|
}
|
|
401
316
|
|
|
402
317
|
// If all items done, update overall status
|
|
403
|
-
if (this.state.results.every((r) => r.status === "done")
|
|
318
|
+
if (this.state.results.length === this.state.items.length && this.state.results.length > 0 && this.state.results.every((r) => r.status === "done")) {
|
|
404
319
|
this.state.status = "done";
|
|
405
320
|
this.state.currentItemIndex = this.state.items.length;
|
|
406
321
|
this.state.currentStepIndex = 0;
|
|
@@ -442,18 +357,18 @@ class PipelineManager {
|
|
|
442
357
|
} catch { return false; }
|
|
443
358
|
}
|
|
444
359
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
360
|
+
/**
|
|
361
|
+
* Check if a PR/MR exists for the given branch, dispatching by repoTool.
|
|
362
|
+
*/
|
|
363
|
+
private forgePrExists(branch: string): boolean {
|
|
364
|
+
return adapterForgePrExists(this.cwd, branch);
|
|
450
365
|
}
|
|
451
366
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
367
|
+
/**
|
|
368
|
+
* Check if a PR/MR for the given branch is merged, dispatching by repoTool.
|
|
369
|
+
*/
|
|
370
|
+
private forgePrMerged(branch: string): boolean {
|
|
371
|
+
return adapterForgePrMerged(this.cwd, branch);
|
|
457
372
|
}
|
|
458
373
|
|
|
459
374
|
/**
|
|
@@ -507,16 +422,16 @@ class PipelineManager {
|
|
|
507
422
|
return { verified: true, reason: "Commits found on branch" };
|
|
508
423
|
}
|
|
509
424
|
case "create-mr": {
|
|
510
|
-
if (!this.
|
|
511
|
-
return { verified: false, reason: `No PR found for branch ${branch}. Create a PR first.` };
|
|
425
|
+
if (!this.forgePrExists(branch)) {
|
|
426
|
+
return { verified: false, reason: `No PR/MR found for branch ${branch}. Create a PR/MR first.` };
|
|
512
427
|
}
|
|
513
|
-
return { verified: true, reason: "PR exists" };
|
|
428
|
+
return { verified: true, reason: "PR/MR exists" };
|
|
514
429
|
}
|
|
515
430
|
case "merge": {
|
|
516
|
-
if (!this.
|
|
517
|
-
return { verified: false, reason: `PR for ${branch} is not merged yet.` };
|
|
431
|
+
if (!this.forgePrMerged(branch)) {
|
|
432
|
+
return { verified: false, reason: `PR/MR for ${branch} is not merged yet.` };
|
|
518
433
|
}
|
|
519
|
-
return { verified: true, reason: "PR merged" };
|
|
434
|
+
return { verified: true, reason: "PR/MR merged" };
|
|
520
435
|
}
|
|
521
436
|
default:
|
|
522
437
|
return { verified: true, reason: "Unknown step — skipping verification" };
|
|
@@ -785,9 +700,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
785
700
|
if (!manager) manager = new PipelineManager(ctx.cwd);
|
|
786
701
|
const state = manager.getState();
|
|
787
702
|
|
|
788
|
-
// pi passes args as a string.
|
|
703
|
+
// pi passes args as a string. Parse into tokens (quote-aware).
|
|
789
704
|
const raw = typeof args === "string" ? args : "";
|
|
790
|
-
const tokens = raw
|
|
705
|
+
const tokens = parseArgs(raw);
|
|
791
706
|
const action = tokens[0];
|
|
792
707
|
|
|
793
708
|
// Status
|
|
@@ -917,6 +832,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
917
832
|
parameters: { type: "object", properties: {} },
|
|
918
833
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
919
834
|
if (!manager) manager = new PipelineManager(ctx.cwd);
|
|
835
|
+
manager.reloadFromDisk();
|
|
920
836
|
const state = manager.getState();
|
|
921
837
|
if (!state) {
|
|
922
838
|
return { content: [{ type: "text" as const, text: "No active pipeline." }] };
|
|
@@ -938,6 +854,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
938
854
|
},
|
|
939
855
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
940
856
|
if (!manager) manager = new PipelineManager(ctx.cwd);
|
|
857
|
+
manager.reloadFromDisk();
|
|
941
858
|
const state = manager.getState();
|
|
942
859
|
if (!state || state.status !== "running") {
|
|
943
860
|
return { content: [{ type: "text" as const, text: "No running pipeline." }] };
|
|
@@ -1037,6 +954,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1037
954
|
},
|
|
1038
955
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1039
956
|
if (!manager) manager = new PipelineManager(ctx.cwd);
|
|
957
|
+
manager.reloadFromDisk();
|
|
1040
958
|
const state = manager.getState();
|
|
1041
959
|
if (!state || state.status !== "running") {
|
|
1042
960
|
return { content: [{ type: "text" as const, text: "No running pipeline." }] };
|
|
@@ -1085,6 +1003,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1085
1003
|
},
|
|
1086
1004
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1087
1005
|
if (!manager) manager = new PipelineManager(ctx.cwd);
|
|
1006
|
+
manager.reloadFromDisk();
|
|
1088
1007
|
|
|
1089
1008
|
const name = (params.name as string) || "pipeline";
|
|
1090
1009
|
const items = ((params.items as string) || "")
|
|
@@ -1133,6 +1052,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1133
1052
|
},
|
|
1134
1053
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1135
1054
|
if (!manager) manager = new PipelineManager(ctx.cwd);
|
|
1055
|
+
manager.reloadFromDisk();
|
|
1136
1056
|
const state = manager.getState();
|
|
1137
1057
|
if (!state || state.status !== "running") {
|
|
1138
1058
|
return { content: [{ type: "text" as const, text: "No running pipeline." }] };
|
|
@@ -1213,6 +1133,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1213
1133
|
parameters: { type: "object", properties: {} },
|
|
1214
1134
|
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
1215
1135
|
if (!manager) manager = new PipelineManager(ctx.cwd);
|
|
1136
|
+
manager.reloadFromDisk();
|
|
1216
1137
|
const state = manager.getState();
|
|
1217
1138
|
if (!state || state.status !== "running") {
|
|
1218
1139
|
return { content: [{ type: "text" as const, text: "No running pipeline." }] };
|
|
@@ -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
|
*)
|