@loucompanion/forge-bridge 0.1.1-dev.8dcbc6245e51 → 0.1.1-dev.9acab970e5d1
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/bridge/bridge.service.js +74 -9
- package/dist/bridge/bridge.types.d.ts +15 -0
- package/dist/cli/cli.service.d.ts +27 -2
- package/dist/cli/cli.service.js +406 -16
- package/dist/cli/local-config.d.ts +3 -0
- package/dist/cli/local-config.js +26 -3
- package/dist/session/index.d.ts +2 -0
- package/dist/session/index.js +1 -0
- package/dist/session/internals.d.ts +5 -3
- package/dist/session/internals.js +2 -1
- package/dist/session/phase-completion.d.ts +111 -0
- package/dist/session/phase-completion.js +2350 -0
- package/dist/session/provider-cli/index.d.ts +2 -2
- package/dist/session/provider-cli/index.js +1 -1
- package/dist/session/provider-cli/provider-cli.base.d.ts +2 -2
- package/dist/session/provider-cli/provider-cli.service.d.ts +48 -3
- package/dist/session/provider-cli/provider-cli.service.js +1870 -41
- package/dist/session/provider-cli/provider-cli.types.d.ts +2 -0
- package/dist/session/provider-cli/provider-usage.d.ts +25 -0
- package/dist/session/provider-cli/provider-usage.js +64 -0
- package/dist/session/qa-sidecar-validation.d.ts +3 -0
- package/dist/session/qa-sidecar-validation.js +44 -0
- package/dist/session/session.service.js +25 -11
- package/dist/session/session.types.d.ts +55 -0
- package/dist/session/session.types.js +119 -2
- package/dist/shared/cursor-agent/cursor-agent.launch.d.ts +9 -0
- package/dist/shared/cursor-agent/cursor-agent.launch.js +66 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worktree/artifact-injector.d.ts +2 -1
- package/dist/worktree/artifact-injector.js +38 -9
- package/dist/worktree/worktree.service.d.ts +2 -0
- package/dist/worktree/worktree.service.js +111 -5
- package/dist/worktree/worktree.types.d.ts +2 -0
- package/package.json +1 -1
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
const DEBUG_OPERATIONAL_SKILLS = new Set(["debug-repro", "debug-root-cause", "debug-fix", "debug-regression"]);
|
|
5
|
+
const DEBUG_OPERATIONAL_HOOKS = new Set(["debug-gate", "debug-tool-hook", "debug-rework"]);
|
|
4
6
|
export async function injectWorktreeArtifacts(worktreePath, artifacts) {
|
|
5
7
|
for (const artifact of artifacts) {
|
|
6
8
|
await writeArtifact(worktreePath, artifact);
|
|
7
9
|
}
|
|
8
10
|
}
|
|
11
|
+
export function filterArtifactsForLane(artifacts, lane, phase) {
|
|
12
|
+
if (lane.trim().toLowerCase() !== "debug") {
|
|
13
|
+
return artifacts;
|
|
14
|
+
}
|
|
15
|
+
const commandSlug = resolveCommandSlug(lane, phase).toLowerCase();
|
|
16
|
+
return artifacts.filter((artifact) => shouldInjectDebugArtifact(artifact, commandSlug));
|
|
17
|
+
}
|
|
9
18
|
export async function writeArtifact(worktreePath, artifact) {
|
|
10
19
|
const writtenHash = sha256(artifact.body);
|
|
11
20
|
if (writtenHash !== artifact.contentHash.toLowerCase()) {
|
|
@@ -15,11 +24,13 @@ export async function writeArtifact(worktreePath, artifact) {
|
|
|
15
24
|
const scope = safeArtifactSegment("scope", artifact.scope);
|
|
16
25
|
const slug = safeArtifactSegment("slug", artifact.slug);
|
|
17
26
|
const forgeRoot = path.resolve(worktreePath, ".forge");
|
|
18
|
-
const
|
|
19
|
-
assertInside(forgeRoot,
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
const scopeDir = path.resolve(forgeRoot, kindFolder, scope);
|
|
28
|
+
assertInside(forgeRoot, scopeDir, "Artifact directory");
|
|
29
|
+
const filePath = normalizedArtifactKind(artifact.kind) === "skill"
|
|
30
|
+
? path.resolve(scopeDir, slug, "SKILL.md")
|
|
31
|
+
: path.resolve(scopeDir, `${slug}.md`);
|
|
22
32
|
assertInside(forgeRoot, filePath, "Artifact file");
|
|
33
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
23
34
|
await fs.writeFile(filePath, artifact.body, "utf8");
|
|
24
35
|
}
|
|
25
36
|
export function selectLaneCommand(artifacts, lane, phase) {
|
|
@@ -27,14 +38,15 @@ export function selectLaneCommand(artifacts, lane, phase) {
|
|
|
27
38
|
return artifacts.find((artifact) => normalizedArtifactKind(artifact.kind) === "command" && artifact.slug.trim().toLowerCase() === slug);
|
|
28
39
|
}
|
|
29
40
|
export function resolveCommandSlug(lane, phase) {
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
32
|
-
return "
|
|
41
|
+
const normalizedLane = lane.trim().toLowerCase();
|
|
42
|
+
if (normalizedLane === "debug") {
|
|
43
|
+
return "debug-prompt";
|
|
33
44
|
}
|
|
45
|
+
const normalizedPhase = phase.trim().toLowerCase().replace(/-/g, "_");
|
|
34
46
|
if (normalizedPhase === "rework") {
|
|
35
47
|
return "rework-prompt";
|
|
36
48
|
}
|
|
37
|
-
switch (
|
|
49
|
+
switch (normalizedLane) {
|
|
38
50
|
case "architect":
|
|
39
51
|
return "architect-prompt";
|
|
40
52
|
case "qa":
|
|
@@ -43,12 +55,29 @@ export function resolveCommandSlug(lane, phase) {
|
|
|
43
55
|
return "dev-prompt";
|
|
44
56
|
}
|
|
45
57
|
}
|
|
46
|
-
|
|
58
|
+
function shouldInjectDebugArtifact(artifact, commandSlug) {
|
|
59
|
+
if (artifact.scope.trim().toLowerCase() !== "operational") {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const slug = artifact.slug.trim().toLowerCase();
|
|
63
|
+
switch (normalizedArtifactKind(artifact.kind)) {
|
|
64
|
+
case "command":
|
|
65
|
+
return slug === commandSlug;
|
|
66
|
+
case "skill":
|
|
67
|
+
return DEBUG_OPERATIONAL_SKILLS.has(slug);
|
|
68
|
+
case "hook":
|
|
69
|
+
return DEBUG_OPERATIONAL_HOOKS.has(slug);
|
|
70
|
+
default:
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export function buildSpecBody(laneCommand, workItem, phase = "implementation") {
|
|
47
75
|
const prompt = laneCommand.body.replaceAll("{workItemId}", workItem.id);
|
|
48
76
|
return `---
|
|
49
77
|
work_item_id: ${workItem.id}
|
|
50
78
|
status: approved
|
|
51
79
|
lane: ${laneCommand.lane ?? "develop"}
|
|
80
|
+
phase: ${phase}
|
|
52
81
|
command_slug: ${laneCommand.slug}
|
|
53
82
|
---
|
|
54
83
|
|
|
@@ -7,6 +7,8 @@ export declare class GitWorktreeService implements WorktreeService {
|
|
|
7
7
|
prepare(request: WorktreePrepareRequest): Promise<WorktreeHandle>;
|
|
8
8
|
release(handle: WorktreeHandle): Promise<void>;
|
|
9
9
|
private resolveRepoPath;
|
|
10
|
+
private findExistingRepoPath;
|
|
11
|
+
private resolveMatchingRepo;
|
|
10
12
|
private ensureBaseBranch;
|
|
11
13
|
private gitRefExists;
|
|
12
14
|
private ensureGitIdentity;
|
|
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { GitClient } from "../shared/git/git.js";
|
|
4
4
|
import { ensureDirectory, isSubPath, resolveExistingDirectoryPath, safeSegment } from "../shared/path/path.js";
|
|
5
|
-
import { injectWorktreeArtifacts } from "./artifact-injector.js";
|
|
5
|
+
import { filterArtifactsForLane, injectWorktreeArtifacts } from "./artifact-injector.js";
|
|
6
6
|
import { warmWorktreeDependencies } from "./dependency-warmer.js";
|
|
7
7
|
export class GitWorktreeService {
|
|
8
8
|
git;
|
|
@@ -18,7 +18,7 @@ export class GitWorktreeService {
|
|
|
18
18
|
const shortId = request.workItem.id.replace(/-/g, "").slice(0, 12);
|
|
19
19
|
const kind = safeSegment(request.workItem.kind, "work-item");
|
|
20
20
|
const branchName = normalizeBranchName(`${kind}/${shortId}`, "work-item/unknown");
|
|
21
|
-
const worktreeRoot =
|
|
21
|
+
const worktreeRoot = resolveWorktreeRoot(allowedRoot, repoPath);
|
|
22
22
|
const worktreePath = path.join(worktreeRoot, kind, shortId);
|
|
23
23
|
const resolvedWorktreeParent = await ensureDirectory(path.dirname(worktreePath));
|
|
24
24
|
if (!isSubPath(allowedRoot, resolvedWorktreeParent)) {
|
|
@@ -37,8 +37,9 @@ export class GitWorktreeService {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
await this.ensureGitIdentity(resolvedWorktreePath);
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
const artifacts = filterArtifactsForLane(request.artifacts, request.lane, request.phase);
|
|
41
|
+
if (artifacts.length > 0) {
|
|
42
|
+
await injectWorktreeArtifacts(resolvedWorktreePath, artifacts);
|
|
42
43
|
}
|
|
43
44
|
const dependencyWarm = await warmWorktreeDependencies({
|
|
44
45
|
baseCheckoutPath: repoPath,
|
|
@@ -78,7 +79,11 @@ export class GitWorktreeService {
|
|
|
78
79
|
if (!repo.cloneOnDemand) {
|
|
79
80
|
throw new Error("Repo has no server-local path and cloneOnDemand is disabled.");
|
|
80
81
|
}
|
|
81
|
-
const
|
|
82
|
+
const discovered = await this.findExistingRepoPath(repo, allowedRoot);
|
|
83
|
+
if (discovered) {
|
|
84
|
+
return discovered;
|
|
85
|
+
}
|
|
86
|
+
const repoPath = path.join(managedRepoRoot(allowedRoot), `${safeSegment(repo.name, "repo")}-${repo.id.replace(/-/g, "")}`);
|
|
82
87
|
if (!(await gitMetadataExists(repoPath, allowedRoot))) {
|
|
83
88
|
const repoPathExists = await pathExists(repoPath);
|
|
84
89
|
if (repoPathExists) {
|
|
@@ -109,6 +114,46 @@ export class GitWorktreeService {
|
|
|
109
114
|
}
|
|
110
115
|
return resolved;
|
|
111
116
|
}
|
|
117
|
+
async findExistingRepoPath(repo, allowedRoot) {
|
|
118
|
+
const expectedRemote = normalizeRemote(repo.remoteUrl);
|
|
119
|
+
const rawName = repo.name.trim();
|
|
120
|
+
const nameCandidates = uniqueStrings([
|
|
121
|
+
isSinglePathSegment(rawName) ? path.join(allowedRoot, rawName) : "",
|
|
122
|
+
path.join(allowedRoot, safeSegment(repo.name, "repo"))
|
|
123
|
+
]);
|
|
124
|
+
for (const candidate of nameCandidates) {
|
|
125
|
+
const resolved = await this.resolveMatchingRepo(candidate, allowedRoot, expectedRemote);
|
|
126
|
+
if (resolved) {
|
|
127
|
+
return resolved;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (expectedRemote.length > 0) {
|
|
131
|
+
const entries = await fs.readdir(allowedRoot, { withFileTypes: true }).catch(() => []);
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
if (!entry.isDirectory() || entry.name === ".forge-worktrees") {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const resolved = await this.resolveMatchingRepo(path.join(allowedRoot, entry.name), allowedRoot, expectedRemote);
|
|
137
|
+
if (resolved) {
|
|
138
|
+
return resolved;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
async resolveMatchingRepo(candidate, allowedRoot, expectedRemote) {
|
|
145
|
+
if (!(await gitMetadataExists(candidate, allowedRoot))) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
if (expectedRemote.length > 0) {
|
|
149
|
+
const origin = await this.git.run(candidate, ["remote", "get-url", "origin"]).catch(() => "");
|
|
150
|
+
if (normalizeRemote(origin) !== expectedRemote) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const resolved = await resolveExistingDirectoryPath(candidate);
|
|
155
|
+
return isSubPath(allowedRoot, resolved) ? resolved : null;
|
|
156
|
+
}
|
|
112
157
|
async ensureBaseBranch(repoPath, baseBranch) {
|
|
113
158
|
if (await this.gitRefExists(repoPath, `refs/heads/${baseBranch}`)) {
|
|
114
159
|
return;
|
|
@@ -132,6 +177,15 @@ export class GitWorktreeService {
|
|
|
132
177
|
await this.git.run(repoPath, ["config", "user.name", "Forge Bridge"]);
|
|
133
178
|
}
|
|
134
179
|
}
|
|
180
|
+
function managedRepoRoot(allowedRoot) {
|
|
181
|
+
return path.join(allowedRoot, ".forge-worktrees", "_repos");
|
|
182
|
+
}
|
|
183
|
+
function resolveWorktreeRoot(allowedRoot, repoPath) {
|
|
184
|
+
const baseName = path.basename(repoPath);
|
|
185
|
+
return isSubPath(managedRepoRoot(allowedRoot), repoPath)
|
|
186
|
+
? path.join(allowedRoot, ".forge-worktrees", baseName)
|
|
187
|
+
: path.join(path.dirname(repoPath), ".forge-worktrees", baseName);
|
|
188
|
+
}
|
|
135
189
|
function isCloneableRemote(remoteUrl) {
|
|
136
190
|
const trimmed = remoteUrl?.trim() ?? "";
|
|
137
191
|
if (trimmed.length === 0 || trimmed.startsWith("local://")) {
|
|
@@ -194,3 +248,55 @@ async function directoryExists(inputPath) {
|
|
|
194
248
|
const stat = await fs.stat(inputPath).catch(() => null);
|
|
195
249
|
return stat?.isDirectory() ?? false;
|
|
196
250
|
}
|
|
251
|
+
function uniqueStrings(values) {
|
|
252
|
+
const seen = new Set();
|
|
253
|
+
const result = [];
|
|
254
|
+
for (const value of values) {
|
|
255
|
+
if (!value || seen.has(value)) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
seen.add(value);
|
|
259
|
+
result.push(value);
|
|
260
|
+
}
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
function isSinglePathSegment(value) {
|
|
264
|
+
return value.length > 0 && value !== "." && value !== ".." && !value.includes("/") && !value.includes("\\");
|
|
265
|
+
}
|
|
266
|
+
function normalizeRemote(remoteUrl) {
|
|
267
|
+
const trimmed = remoteUrl?.trim() ?? "";
|
|
268
|
+
if (trimmed.length === 0) {
|
|
269
|
+
return "";
|
|
270
|
+
}
|
|
271
|
+
const gitHubSsh = /^git@github\.com:(.+)$/i.exec(trimmed);
|
|
272
|
+
if (gitHubSsh) {
|
|
273
|
+
return stripGitSuffix(`https://github.com/${gitHubSsh[1]}`).toLowerCase();
|
|
274
|
+
}
|
|
275
|
+
if (path.isAbsolute(trimmed)) {
|
|
276
|
+
return normalizeLocalPath(trimmed);
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
const url = new URL(trimmed);
|
|
280
|
+
if (url.protocol === "file:") {
|
|
281
|
+
return normalizeLocalPath(normalizeFileUrlPath(url));
|
|
282
|
+
}
|
|
283
|
+
url.username = "";
|
|
284
|
+
url.password = "";
|
|
285
|
+
url.hash = "";
|
|
286
|
+
return stripGitSuffix(url.toString().replace(/\/$/, "")).toLowerCase();
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return stripGitSuffix(trimmed.replace(/\\/g, "/")).toLowerCase();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function stripGitSuffix(value) {
|
|
293
|
+
return value.replace(/\.git$/i, "").replace(/\/$/, "");
|
|
294
|
+
}
|
|
295
|
+
function normalizeFileUrlPath(url) {
|
|
296
|
+
const pathname = decodeURIComponent(url.pathname);
|
|
297
|
+
return /^\/[A-Za-z]:[\\/]/.test(pathname) ? pathname.slice(1) : pathname;
|
|
298
|
+
}
|
|
299
|
+
function normalizeLocalPath(value) {
|
|
300
|
+
const normalized = path.resolve(value).replace(/\\/g, "/");
|
|
301
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
302
|
+
}
|
|
@@ -4,6 +4,8 @@ export interface WorktreePrepareRequest {
|
|
|
4
4
|
readonly repo: DispatchRepo;
|
|
5
5
|
readonly workItem: DispatchWorkItem;
|
|
6
6
|
readonly baseBranch: string;
|
|
7
|
+
readonly lane: string;
|
|
8
|
+
readonly phase: string;
|
|
7
9
|
readonly allowedRepoRoot: string;
|
|
8
10
|
readonly cleanupWorktrees: boolean;
|
|
9
11
|
readonly artifacts: readonly DispatchResolvedArtifact[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loucompanion/forge-bridge",
|
|
3
|
-
"version": "0.1.1-dev.
|
|
3
|
+
"version": "0.1.1-dev.9acab970e5d1",
|
|
4
4
|
"description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|