@loucompanion/forge-bridge 0.1.1-dev.caea7216568f → 0.1.1-dev.e52b249f07a8
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 +29 -1
- package/dist/bridge/bridge.types.d.ts +9 -0
- package/dist/cli/local-config.js +18 -1
- package/dist/session/phase-completion.d.ts +4 -0
- package/dist/session/phase-completion.js +162 -6
- package/dist/session/provider-cli/provider-cli.service.js +193 -19
- package/dist/session/qa-sidecar-validation.d.ts +3 -0
- package/dist/session/qa-sidecar-validation.js +44 -0
- package/dist/session/session.types.d.ts +31 -0
- package/dist/session/session.types.js +85 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -123,6 +123,7 @@ export class ForgeBridgeService {
|
|
|
123
123
|
const mapping = findRepoMapping(dispatch, this.config.repoMappings);
|
|
124
124
|
const localPath = mapping?.localPath?.trim();
|
|
125
125
|
const repoRoot = localPath ? path.dirname(path.resolve(localPath)) : this.config.defaultRepoRoot;
|
|
126
|
+
const qaLaunchTarget = normalizeQaAppLaunchTarget(mapping?.qaAppLaunchTarget) ?? dispatch.qaBrowser?.launchTarget ?? null;
|
|
126
127
|
return {
|
|
127
128
|
...dispatch,
|
|
128
129
|
repo: {
|
|
@@ -133,7 +134,13 @@ export class ForgeBridgeService {
|
|
|
133
134
|
serverLocal: {
|
|
134
135
|
repoRoot,
|
|
135
136
|
cleanupWorktrees: this.config.cleanupWorktrees
|
|
136
|
-
}
|
|
137
|
+
},
|
|
138
|
+
qaBrowser: dispatch.qaBrowser
|
|
139
|
+
? {
|
|
140
|
+
...dispatch.qaBrowser,
|
|
141
|
+
launchTarget: qaLaunchTarget
|
|
142
|
+
}
|
|
143
|
+
: dispatch.qaBrowser
|
|
137
144
|
};
|
|
138
145
|
}
|
|
139
146
|
async sendResult(leaseId, fencingToken, result) {
|
|
@@ -323,6 +330,27 @@ function findRepoMapping(dispatch, mappings) {
|
|
|
323
330
|
(!!mapping.remoteUrl?.trim() && equalsIgnoreCase(mapping.remoteUrl, dispatch.repo.remoteUrl)));
|
|
324
331
|
});
|
|
325
332
|
}
|
|
333
|
+
function normalizeQaAppLaunchTarget(target) {
|
|
334
|
+
if (!target) {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
const routes = [...new Set((target.routes ?? []).map((route) => route.trim()).filter(Boolean))];
|
|
338
|
+
const normalized = {
|
|
339
|
+
url: cleanString(target.url),
|
|
340
|
+
command: cleanString(target.command),
|
|
341
|
+
working_directory: cleanString(target.working_directory),
|
|
342
|
+
ready_url: cleanString(target.ready_url),
|
|
343
|
+
routes,
|
|
344
|
+
notes: cleanString(target.notes)
|
|
345
|
+
};
|
|
346
|
+
return normalized.url || normalized.command || normalized.ready_url || routes.length > 0
|
|
347
|
+
? normalized
|
|
348
|
+
: null;
|
|
349
|
+
}
|
|
350
|
+
function cleanString(value) {
|
|
351
|
+
const clean = value?.trim();
|
|
352
|
+
return clean ? clean : null;
|
|
353
|
+
}
|
|
326
354
|
function equalsIgnoreCase(left, right) {
|
|
327
355
|
return !!left?.trim() && !!right?.trim() && left.trim().toLowerCase() === right.trim().toLowerCase();
|
|
328
356
|
}
|
|
@@ -27,6 +27,15 @@ export interface BridgeRepoMapping {
|
|
|
27
27
|
readonly name?: string | null;
|
|
28
28
|
readonly remoteUrl?: string | null;
|
|
29
29
|
readonly localPath?: string | null;
|
|
30
|
+
readonly qaAppLaunchTarget?: QaAppLaunchTarget | null;
|
|
31
|
+
}
|
|
32
|
+
export interface QaAppLaunchTarget {
|
|
33
|
+
readonly url?: string | null;
|
|
34
|
+
readonly command?: string | null;
|
|
35
|
+
readonly working_directory?: string | null;
|
|
36
|
+
readonly ready_url?: string | null;
|
|
37
|
+
readonly routes?: readonly string[] | null;
|
|
38
|
+
readonly notes?: string | null;
|
|
30
39
|
}
|
|
31
40
|
export interface BridgeProviderStatus {
|
|
32
41
|
readonly signedIn?: boolean | null;
|
package/dist/cli/local-config.js
CHANGED
|
@@ -159,7 +159,8 @@ function normalizeRepoMappings(values) {
|
|
|
159
159
|
repoId: cleanString(mapping.repoId),
|
|
160
160
|
name: cleanString(mapping.name) ?? label,
|
|
161
161
|
remoteUrl: cleanString(mapping.remoteUrl),
|
|
162
|
-
localPath: path.resolve(mapping.localPath)
|
|
162
|
+
localPath: path.resolve(mapping.localPath),
|
|
163
|
+
qaAppLaunchTarget: normalizeQaAppLaunchTarget(mapping.qaAppLaunchTarget)
|
|
163
164
|
};
|
|
164
165
|
})
|
|
165
166
|
.sort((left, right) => left.label.localeCompare(right.label));
|
|
@@ -171,6 +172,22 @@ function normalizeLabel(value) {
|
|
|
171
172
|
}
|
|
172
173
|
return label;
|
|
173
174
|
}
|
|
175
|
+
function normalizeQaAppLaunchTarget(value) {
|
|
176
|
+
if (!value)
|
|
177
|
+
return undefined;
|
|
178
|
+
const routes = cleanList(value.routes);
|
|
179
|
+
const normalized = {
|
|
180
|
+
url: cleanString(value.url),
|
|
181
|
+
command: cleanString(value.command),
|
|
182
|
+
working_directory: cleanString(value.working_directory),
|
|
183
|
+
ready_url: cleanString(value.ready_url),
|
|
184
|
+
routes,
|
|
185
|
+
notes: cleanString(value.notes)
|
|
186
|
+
};
|
|
187
|
+
return normalized.url || normalized.command || normalized.ready_url || routes?.length
|
|
188
|
+
? normalized
|
|
189
|
+
: undefined;
|
|
190
|
+
}
|
|
174
191
|
function cleanString(value) {
|
|
175
192
|
const clean = value?.trim();
|
|
176
193
|
return clean ? clean : undefined;
|
|
@@ -17,6 +17,8 @@ export interface CompletionToolRuntimeOptions {
|
|
|
17
17
|
export interface CompletionToolServerConfig {
|
|
18
18
|
readonly worktreePath: string;
|
|
19
19
|
readonly workItemId: string;
|
|
20
|
+
readonly ownerType?: string;
|
|
21
|
+
readonly ownerId?: string;
|
|
20
22
|
readonly phase: CompletionPhase;
|
|
21
23
|
readonly producedBy?: string;
|
|
22
24
|
readonly redactionSecrets?: readonly string[];
|
|
@@ -41,6 +43,8 @@ export interface CompletionToolCallResult {
|
|
|
41
43
|
export interface CompletionArtifactReference {
|
|
42
44
|
readonly phase: CompletionPhase;
|
|
43
45
|
readonly workItemId: string;
|
|
46
|
+
readonly ownerType?: string;
|
|
47
|
+
readonly ownerId?: string;
|
|
44
48
|
readonly repoPath: string;
|
|
45
49
|
readonly path: string;
|
|
46
50
|
readonly reportPath: string;
|
|
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { redactSecrets } from "../shared/redaction/redaction.js";
|
|
6
|
+
import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "./qa-sidecar-validation.js";
|
|
6
7
|
const toolServerName = "forge";
|
|
7
8
|
const completionRedactionSecretsEnv = "FORGE_COMPLETION_REDACTION_SECRETS";
|
|
8
9
|
const completionPhaseOrder = ["implementation", "self_review", "validation"];
|
|
@@ -76,6 +77,9 @@ export function expectedCompletionToolName(phase) {
|
|
|
76
77
|
export function artifactRootFor(worktreePath, workItemId) {
|
|
77
78
|
return path.join(worktreePath, ".forge", "work-items", safePathSegment(workItemId, "work item id"), "artifacts");
|
|
78
79
|
}
|
|
80
|
+
function artifactRootForOwner(worktreePath, ownerType, ownerId) {
|
|
81
|
+
return path.join(worktreePath, ".forge", artifactOwnerDirectory(ownerType), safePathSegment(ownerId, "owner id"), "artifacts");
|
|
82
|
+
}
|
|
79
83
|
export function completionArtifactJsonPath(worktreePath, workItemId, phase) {
|
|
80
84
|
return path.join(artifactRootFor(worktreePath, workItemId), phaseFiles[phase].jsonFileName);
|
|
81
85
|
}
|
|
@@ -86,9 +90,13 @@ export async function prepareCompletionToolRuntime(options) {
|
|
|
86
90
|
const phase = options.phase ?? completionPhaseFromDispatch(options.dispatch);
|
|
87
91
|
const worktreePath = path.resolve(options.worktree.worktreePath);
|
|
88
92
|
const workItemId = options.dispatch.workItem.id;
|
|
93
|
+
const ownerType = normalizedOwnerType(options.dispatch.cycleRun.ownerType) ?? "work_item";
|
|
94
|
+
const ownerId = options.dispatch.cycleRun.ownerId ?? workItemId;
|
|
89
95
|
const mcpArgs = buildCompletionToolServerArgs({
|
|
90
96
|
worktreePath,
|
|
91
97
|
workItemId,
|
|
98
|
+
ownerType,
|
|
99
|
+
ownerId,
|
|
92
100
|
phase,
|
|
93
101
|
producedBy: `${options.dispatch.execution.provider}-cli`,
|
|
94
102
|
developCycle: usesDevelopCycleCompletion(options.dispatch)
|
|
@@ -117,6 +125,10 @@ export function buildCompletionToolServerArgs(config) {
|
|
|
117
125
|
"--produced-by",
|
|
118
126
|
config.producedBy ?? "forge-completion-tool"
|
|
119
127
|
];
|
|
128
|
+
if (config.ownerType || config.ownerId) {
|
|
129
|
+
args.push("--owner-type", normalizedOwnerType(config.ownerType) ?? "work_item");
|
|
130
|
+
args.push("--owner-id", config.ownerId ?? config.workItemId);
|
|
131
|
+
}
|
|
120
132
|
if (config.developCycle) {
|
|
121
133
|
args.push("--develop-cycle");
|
|
122
134
|
}
|
|
@@ -265,6 +277,8 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
265
277
|
const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
|
|
266
278
|
phase,
|
|
267
279
|
workItemId: config.workItemId,
|
|
280
|
+
ownerType: config.ownerType,
|
|
281
|
+
ownerId: config.ownerId,
|
|
268
282
|
producedBy,
|
|
269
283
|
status
|
|
270
284
|
});
|
|
@@ -283,6 +297,7 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
283
297
|
return {
|
|
284
298
|
phase,
|
|
285
299
|
workItemId: config.workItemId,
|
|
300
|
+
...artifactReferenceOwnerFields(config),
|
|
286
301
|
repoPath: path.resolve(config.worktreePath),
|
|
287
302
|
path: artifactPath,
|
|
288
303
|
reportPath,
|
|
@@ -292,18 +307,28 @@ export async function writeCompletionArtifact(config, toolName, input) {
|
|
|
292
307
|
};
|
|
293
308
|
}
|
|
294
309
|
async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
295
|
-
const
|
|
310
|
+
const ownerFields = artifactOwnerFields(config);
|
|
311
|
+
const artifactRoot = ownerFields.owner_type && ownerFields.owner_id
|
|
312
|
+
? artifactRootForOwner(config.worktreePath, ownerFields.owner_type, ownerFields.owner_id)
|
|
313
|
+
: artifactRootFor(config.worktreePath, config.workItemId);
|
|
296
314
|
const planPath = path.join(artifactRoot, "qa.plan.json");
|
|
297
315
|
const evidencePath = path.join(artifactRoot, "qa.evidence.json");
|
|
298
316
|
const findingsPath = path.join(artifactRoot, "qa.findings.json");
|
|
299
317
|
const verdictPath = path.join(artifactRoot, "qa.verdict.json");
|
|
300
318
|
const reportPath = path.join(artifactRoot, "qa.report.md");
|
|
301
|
-
const relativeEvidencePath =
|
|
302
|
-
|
|
303
|
-
|
|
319
|
+
const relativeEvidencePath = ownerFields.owner_type && ownerFields.owner_id
|
|
320
|
+
? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.evidence.json")
|
|
321
|
+
: qaArtifactRelativePath(config.workItemId, "qa.evidence.json");
|
|
322
|
+
const relativeFindingsPath = ownerFields.owner_type && ownerFields.owner_id
|
|
323
|
+
? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.findings.json")
|
|
324
|
+
: qaArtifactRelativePath(config.workItemId, "qa.findings.json");
|
|
325
|
+
const relativeReportPath = ownerFields.owner_type && ownerFields.owner_id
|
|
326
|
+
? qaArtifactRelativePathForOwner(ownerFields.owner_type, ownerFields.owner_id, "qa.report.md")
|
|
327
|
+
: qaArtifactRelativePath(config.workItemId, "qa.report.md");
|
|
304
328
|
const evidenceSource = qaEvidenceSource(payload);
|
|
305
329
|
const commands = qaArray(payload, "commands", evidenceSource);
|
|
306
330
|
const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
|
|
331
|
+
const sidecars = await validateQaSidecarReferences(config.worktreePath, config.workItemId, qaArray(payload, "sidecars", evidenceSource), ownerFields.owner_type, ownerFields.owner_id);
|
|
307
332
|
const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
|
|
308
333
|
const findings = qaObjectArray(payload, "findings");
|
|
309
334
|
const candidateBugs = qaObjectArray(payload, "candidate_bugs");
|
|
@@ -315,19 +340,23 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
315
340
|
const planJson = jsonText({
|
|
316
341
|
schema_version: 1,
|
|
317
342
|
phase: "qa",
|
|
343
|
+
...ownerFields,
|
|
318
344
|
checklist: checklist.length > 0 ? checklist : defaultQaChecklist(status),
|
|
319
345
|
acceptance_criteria: testedAcceptanceCriteria
|
|
320
346
|
});
|
|
321
347
|
const evidenceJson = jsonText({
|
|
322
348
|
schema_version: 1,
|
|
323
349
|
phase: "qa",
|
|
350
|
+
...ownerFields,
|
|
324
351
|
commands,
|
|
325
352
|
browser_checks: browserChecks,
|
|
353
|
+
sidecars,
|
|
326
354
|
notes
|
|
327
355
|
});
|
|
328
356
|
const findingsJson = jsonText({
|
|
329
357
|
schema_version: 1,
|
|
330
358
|
phase: "qa",
|
|
359
|
+
...ownerFields,
|
|
331
360
|
findings,
|
|
332
361
|
candidate_bugs: candidateBugs
|
|
333
362
|
});
|
|
@@ -347,6 +376,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
347
376
|
const verdictWithoutHash = {
|
|
348
377
|
schema_version: numberOrDefault(payload.schema_version, 1),
|
|
349
378
|
phase: "qa",
|
|
379
|
+
...ownerFields,
|
|
350
380
|
status,
|
|
351
381
|
summary: requiredString(payload, "summary"),
|
|
352
382
|
tested_acceptance_criteria: testedAcceptanceCriteria,
|
|
@@ -378,6 +408,7 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
378
408
|
return {
|
|
379
409
|
phase: "qa",
|
|
380
410
|
workItemId: config.workItemId,
|
|
411
|
+
...artifactReferenceOwnerFields(config),
|
|
381
412
|
repoPath: path.resolve(config.worktreePath),
|
|
382
413
|
path: verdictPath,
|
|
383
414
|
reportPath,
|
|
@@ -386,6 +417,59 @@ async function writeQaCompletionArtifact(config, payload, status, producedBy) {
|
|
|
386
417
|
producedBy
|
|
387
418
|
};
|
|
388
419
|
}
|
|
420
|
+
async function validateQaSidecarReferences(worktreePath, workItemId, sidecars, ownerType, ownerId) {
|
|
421
|
+
if (sidecars.length === 0) {
|
|
422
|
+
return [];
|
|
423
|
+
}
|
|
424
|
+
const artifactRoot = ownerType && ownerId
|
|
425
|
+
? artifactRootForOwner(worktreePath, ownerType, ownerId)
|
|
426
|
+
: artifactRootFor(worktreePath, workItemId);
|
|
427
|
+
const qaSidecarRoot = path.resolve(artifactRoot, "qa");
|
|
428
|
+
const validated = [];
|
|
429
|
+
for (const [index, sidecar] of sidecars.entries()) {
|
|
430
|
+
if (!sidecar || typeof sidecar !== "object" || Array.isArray(sidecar)) {
|
|
431
|
+
throw new Error(`sidecars[${index}] must be an object.`);
|
|
432
|
+
}
|
|
433
|
+
const record = sidecar;
|
|
434
|
+
const sidecarPath = optionalString(record, "path");
|
|
435
|
+
const contentHash = optionalString(record, "content_hash");
|
|
436
|
+
const bytes = record.bytes;
|
|
437
|
+
if (!sidecarPath || !contentHash || typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) {
|
|
438
|
+
throw new Error(`sidecars[${index}] must include path, content_hash, and positive bytes.`);
|
|
439
|
+
}
|
|
440
|
+
if (path.isAbsolute(sidecarPath)) {
|
|
441
|
+
throw new Error(`sidecars[${index}].path must be relative to the worktree.`);
|
|
442
|
+
}
|
|
443
|
+
const fullPath = path.resolve(worktreePath, sidecarPath);
|
|
444
|
+
if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
|
|
445
|
+
throw new Error(`sidecars[${index}].path must be under ${artifactOwnerRelativeRoot(ownerType, ownerId, workItemId)}/qa/.`);
|
|
446
|
+
}
|
|
447
|
+
const stat = await fs.lstat(fullPath).catch((error) => {
|
|
448
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
449
|
+
throw new Error(`sidecars[${index}].path does not exist: ${sidecarPath}`);
|
|
450
|
+
}
|
|
451
|
+
throw error;
|
|
452
|
+
});
|
|
453
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
454
|
+
throw new Error(`sidecars[${index}].path must reference a regular file.`);
|
|
455
|
+
}
|
|
456
|
+
await ensureNoSymbolicLinkPathSegments(qaSidecarRoot, path.dirname(fullPath), `sidecars[${index}].path must not traverse symbolic links.`);
|
|
457
|
+
if (stat.size !== bytes) {
|
|
458
|
+
throw new Error(`sidecars[${index}].bytes does not match the referenced file.`);
|
|
459
|
+
}
|
|
460
|
+
const actualHash = await sha256File(fullPath);
|
|
461
|
+
if (actualHash !== contentHash.trim().toLowerCase()) {
|
|
462
|
+
throw new Error(`sidecars[${index}].content_hash does not match the referenced file.`);
|
|
463
|
+
}
|
|
464
|
+
validated.push({
|
|
465
|
+
...record,
|
|
466
|
+
path: path.relative(worktreePath, fullPath).split(path.sep).join("/"),
|
|
467
|
+
content_hash: actualHash,
|
|
468
|
+
bytes: stat.size
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
return validated;
|
|
472
|
+
}
|
|
389
473
|
export async function readCompletionArtifact(worktreePath, workItemId, phase) {
|
|
390
474
|
const artifactPath = completionArtifactJsonPath(worktreePath, workItemId, phase);
|
|
391
475
|
const reportPath = completionArtifactReportPath(worktreePath, workItemId, phase);
|
|
@@ -430,6 +514,8 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
430
514
|
let worktreePath = null;
|
|
431
515
|
let workItemId = null;
|
|
432
516
|
let phase = "implementation";
|
|
517
|
+
let ownerType;
|
|
518
|
+
let ownerId;
|
|
433
519
|
let producedBy;
|
|
434
520
|
let developCycle = false;
|
|
435
521
|
for (let index = 1; index < argv.length; index += 1) {
|
|
@@ -440,6 +526,15 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
440
526
|
else if (arg === "--work-item-id") {
|
|
441
527
|
workItemId = argv[++index] ?? null;
|
|
442
528
|
}
|
|
529
|
+
else if (arg === "--owner-type") {
|
|
530
|
+
ownerType = normalizedOwnerType(argv[++index]);
|
|
531
|
+
if (!ownerType) {
|
|
532
|
+
return { ok: false, error: "mcp --owner-type must be work_item or story." };
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
else if (arg === "--owner-id") {
|
|
536
|
+
ownerId = argv[++index] ?? undefined;
|
|
537
|
+
}
|
|
443
538
|
else if (arg === "--phase") {
|
|
444
539
|
const rawPhase = argv[++index];
|
|
445
540
|
const parsedPhase = parseStrictCompletionPhase(rawPhase);
|
|
@@ -466,6 +561,8 @@ export function parseCompletionMcpArgs(argv) {
|
|
|
466
561
|
config: {
|
|
467
562
|
worktreePath: path.resolve(worktreePath),
|
|
468
563
|
workItemId,
|
|
564
|
+
ownerType,
|
|
565
|
+
ownerId,
|
|
469
566
|
phase,
|
|
470
567
|
producedBy,
|
|
471
568
|
redactionSecrets: parseCompletionRedactionSecrets(process.env[completionRedactionSecretsEnv]),
|
|
@@ -753,15 +850,25 @@ function toolInputSchema(phase) {
|
|
|
753
850
|
},
|
|
754
851
|
browser_checks: {
|
|
755
852
|
type: "array",
|
|
756
|
-
description: "Browser/manual UI checks with URL, action, observed result, screenshot/trace references, console/network notes, and status
|
|
757
|
-
items: evidenceObjectSchema(["summary"], {
|
|
853
|
+
description: "Browser/manual UI checks with URL or route, action, observed result, screenshot/trace references, console/network notes, and status.",
|
|
854
|
+
items: evidenceObjectSchema(["summary", "status"], {
|
|
758
855
|
summary: { type: "string" },
|
|
759
856
|
url: { type: "string" },
|
|
857
|
+
route: { type: "string" },
|
|
760
858
|
status: { type: "string" },
|
|
761
859
|
screenshot: { type: "string" },
|
|
762
860
|
trace: { type: "string" }
|
|
763
861
|
})
|
|
764
862
|
},
|
|
863
|
+
sidecars: {
|
|
864
|
+
type: "array",
|
|
865
|
+
description: "Optional QA sidecar files already written under .forge/work-items/<id>/artifacts/qa/ with path/content_hash/bytes metadata.",
|
|
866
|
+
items: evidenceObjectSchema(["path", "content_hash", "bytes"], {
|
|
867
|
+
path: { type: "string" },
|
|
868
|
+
content_hash: { type: "string" },
|
|
869
|
+
bytes: { type: "number" }
|
|
870
|
+
})
|
|
871
|
+
},
|
|
765
872
|
notes: { type: "string", description: "Additional QA evidence notes." },
|
|
766
873
|
evidence: {
|
|
767
874
|
type: "object",
|
|
@@ -954,6 +1061,7 @@ function validateQaPayloadShape(payload, status) {
|
|
|
954
1061
|
const evidenceSource = qaEvidenceSource(payload);
|
|
955
1062
|
const commands = qaArray(payload, "commands", evidenceSource);
|
|
956
1063
|
const browserChecks = qaArray(payload, "browser_checks", evidenceSource);
|
|
1064
|
+
const sidecars = qaArray(payload, "sidecars", evidenceSource);
|
|
957
1065
|
const notes = optionalString(payload, "notes") ?? optionalString(evidenceSource, "notes") ?? "";
|
|
958
1066
|
const testedAcceptanceCriteria = qaStringArray(payload, "tested_acceptance_criteria");
|
|
959
1067
|
const findings = qaObjectArray(payload, "findings");
|
|
@@ -991,10 +1099,23 @@ function validateQaPayloadShape(payload, status) {
|
|
|
991
1099
|
requireEvidenceRecord(browserCheck, `browser_checks[${index}]`);
|
|
992
1100
|
const record = browserCheck;
|
|
993
1101
|
requireNonEmptyStringRecord(record, "summary", `browser_checks[${index}].`);
|
|
1102
|
+
requireNonEmptyStringRecord(record, "status", `browser_checks[${index}].`);
|
|
994
1103
|
const checkStatus = optionalString(record, "status");
|
|
995
1104
|
if (status === "pass" && checkStatus && normalizedString(checkStatus) !== "pass") {
|
|
996
1105
|
throw new Error(`browser_checks[${index}].status must be pass when QA status is pass.`);
|
|
997
1106
|
}
|
|
1107
|
+
if (!optionalString(record, "url") && !optionalString(record, "route")) {
|
|
1108
|
+
throw new Error(`browser_checks[${index}] must include url or route.`);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
for (const [index, sidecar] of sidecars.entries()) {
|
|
1112
|
+
requireEvidenceRecord(sidecar, `sidecars[${index}]`);
|
|
1113
|
+
const record = sidecar;
|
|
1114
|
+
requireNonEmptyStringRecord(record, "path", `sidecars[${index}].`);
|
|
1115
|
+
requireNonEmptyStringRecord(record, "content_hash", `sidecars[${index}].`);
|
|
1116
|
+
if (typeof record.bytes !== "number" || !Number.isFinite(record.bytes) || record.bytes <= 0) {
|
|
1117
|
+
throw new Error(`sidecars[${index}].bytes must be greater than 0.`);
|
|
1118
|
+
}
|
|
998
1119
|
}
|
|
999
1120
|
for (const [index, finding] of findings.entries()) {
|
|
1000
1121
|
requireNonEmptyStringRecord(finding, "id", `findings[${index}].`);
|
|
@@ -1075,6 +1196,8 @@ function normalizeArtifactPayload(payload, defaults) {
|
|
|
1075
1196
|
normalized.schema_version = numberOrDefault(normalized.schema_version, 1);
|
|
1076
1197
|
normalized.phase = defaults.phase;
|
|
1077
1198
|
normalized.work_item_id = defaults.workItemId;
|
|
1199
|
+
const ownerFields = artifactOwnerFields(defaults);
|
|
1200
|
+
Object.assign(normalized, ownerFields);
|
|
1078
1201
|
normalized.status = defaults.status;
|
|
1079
1202
|
normalized.produced_by = defaults.producedBy;
|
|
1080
1203
|
return normalized;
|
|
@@ -1328,6 +1451,36 @@ function hasSubstantiveJsonValue(value) {
|
|
|
1328
1451
|
function normalizedString(value) {
|
|
1329
1452
|
return typeof value === "string" ? value.trim().toLowerCase().replace(/-/g, "_") : "";
|
|
1330
1453
|
}
|
|
1454
|
+
function normalizedOwnerType(value) {
|
|
1455
|
+
const normalized = normalizedString(value);
|
|
1456
|
+
return normalized === "work_item" || normalized === "story" ? normalized : undefined;
|
|
1457
|
+
}
|
|
1458
|
+
function artifactOwnerFields(config) {
|
|
1459
|
+
const ownerType = normalizedOwnerType(config.ownerType);
|
|
1460
|
+
const ownerId = config.ownerId?.trim();
|
|
1461
|
+
if (!ownerType && !ownerId) {
|
|
1462
|
+
return {};
|
|
1463
|
+
}
|
|
1464
|
+
return {
|
|
1465
|
+
owner_type: ownerType ?? "work_item",
|
|
1466
|
+
owner_id: ownerId || config.workItemId
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
function artifactReferenceOwnerFields(config) {
|
|
1470
|
+
const owner = artifactOwnerFields(config);
|
|
1471
|
+
return owner.owner_type && owner.owner_id
|
|
1472
|
+
? { ownerType: owner.owner_type, ownerId: owner.owner_id }
|
|
1473
|
+
: {};
|
|
1474
|
+
}
|
|
1475
|
+
function artifactOwnerDirectory(ownerType) {
|
|
1476
|
+
return normalizedOwnerType(ownerType) === "story" ? "stories" : "work-items";
|
|
1477
|
+
}
|
|
1478
|
+
function artifactOwnerRelativeRoot(ownerType, ownerId, workItemId) {
|
|
1479
|
+
const owner = artifactOwnerFields({ workItemId, ownerType, ownerId });
|
|
1480
|
+
return owner.owner_type && owner.owner_id
|
|
1481
|
+
? [".forge", artifactOwnerDirectory(owner.owner_type), owner.owner_id, "artifacts"].join("/")
|
|
1482
|
+
: [".forge", "work-items", workItemId, "artifacts"].join("/");
|
|
1483
|
+
}
|
|
1331
1484
|
function defaultQaChecklist(status) {
|
|
1332
1485
|
return [
|
|
1333
1486
|
"Review the validated work item against acceptance criteria.",
|
|
@@ -1338,6 +1491,9 @@ function defaultQaChecklist(status) {
|
|
|
1338
1491
|
function qaArtifactRelativePath(workItemId, fileName) {
|
|
1339
1492
|
return [".forge", "work-items", workItemId, "artifacts", fileName].join("/");
|
|
1340
1493
|
}
|
|
1494
|
+
function qaArtifactRelativePathForOwner(ownerType, ownerId, fileName) {
|
|
1495
|
+
return [".forge", artifactOwnerDirectory(ownerType), ownerId, "artifacts", fileName].join("/");
|
|
1496
|
+
}
|
|
1341
1497
|
function jsonText(value) {
|
|
1342
1498
|
return `${JSON.stringify(value, null, 2)}\n`;
|
|
1343
1499
|
}
|
|
@@ -6,6 +6,7 @@ import readline from "node:readline";
|
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
7
|
import { GitClient } from "../../shared/git/git.js";
|
|
8
8
|
import { resolveCursorAgentDirectLaunch } from "../../shared/cursor-agent/cursor-agent.launch.js";
|
|
9
|
+
import { ensureNoSymbolicLinkPathSegments, isPathUnderRoot, sha256File } from "../qa-sidecar-validation.js";
|
|
9
10
|
import { buildSpecBody, selectLaneCommand } from "../../worktree/artifact-injector.js";
|
|
10
11
|
import { codexMcpConfigArgs, completionPhaseFromDispatch, completionPromptForDevelopCycle, completionPromptForPhase, completionRedactionEnv, expectedCompletionToolName, prepareCompletionToolRuntime, readCompletionArtifact, usesDevelopCycleCompletion, writeCompletionArtifact, artifactRootFor } from "../phase-completion.js";
|
|
11
12
|
import { addUsage, buildProviderUsageEvent, emptyUsage, hasUsage, readUsageNumber } from "./provider-usage.js";
|
|
@@ -171,7 +172,7 @@ export class ProcessBackedProviderCliService {
|
|
|
171
172
|
eventType: "completion_tools_configured"
|
|
172
173
|
});
|
|
173
174
|
const outputState = createProviderOutputState(dispatch.execution.provider);
|
|
174
|
-
const liveQaEvents = new LiveQaArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, context.emit);
|
|
175
|
+
const liveQaEvents = new LiveQaArtifactEmitter(worktree.worktreePath, dispatch.workItem.id, dispatch, context.emit);
|
|
175
176
|
let result;
|
|
176
177
|
try {
|
|
177
178
|
liveQaEvents.start();
|
|
@@ -211,6 +212,7 @@ export class ProcessBackedProviderCliService {
|
|
|
211
212
|
? "Provider CLI QA completed but did not produce the complete durable QA artifact bundle."
|
|
212
213
|
: "Provider CLI QA completed but did not call the Forge QA completion tool.");
|
|
213
214
|
}
|
|
215
|
+
enforceQaBrowserLaunchTargetGate(dispatch, qaArtifact);
|
|
214
216
|
if (!liveQaEvents.hasEmitted()) {
|
|
215
217
|
await emitQaArtifactRecorded(context.emit, worktree.worktreePath, qaArtifact);
|
|
216
218
|
await emitQaStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, qaArtifact);
|
|
@@ -356,7 +358,18 @@ export class DeterministicProviderCliService {
|
|
|
356
358
|
content: `Planning ${dispatch.execution.provider} QA checks through forge-bridge.`,
|
|
357
359
|
eventType: "qa_plan"
|
|
358
360
|
});
|
|
359
|
-
const
|
|
361
|
+
const qaBrowser = dispatch.qaBrowser;
|
|
362
|
+
const launchTarget = qaBrowser?.launchTarget ?? null;
|
|
363
|
+
const missingLaunchTarget = qaBrowser?.required === true && !hasLaunchTarget(launchTarget);
|
|
364
|
+
const browserSidecar = !missingLaunchTarget && qaBrowser?.required === true && launchTarget
|
|
365
|
+
? deterministicBrowserSidecar(dispatch.workItem.id, launchTarget)
|
|
366
|
+
: null;
|
|
367
|
+
const browserChecks = browserSidecar && launchTarget
|
|
368
|
+
? [deterministicBrowserCheck(launchTarget, browserSidecar.relativePath)]
|
|
369
|
+
: [];
|
|
370
|
+
const summary = missingLaunchTarget
|
|
371
|
+
? "QA blocked because browser testing is required but no app launch target was provided."
|
|
372
|
+
: "All checks passed. forge-bridge QA completed.";
|
|
360
373
|
const qaArtifact = await writeQaArtifacts({
|
|
361
374
|
worktreePath: worktree.worktreePath,
|
|
362
375
|
workItemId: dispatch.workItem.id,
|
|
@@ -365,13 +378,17 @@ export class DeterministicProviderCliService {
|
|
|
365
378
|
provider: dispatch.execution.provider,
|
|
366
379
|
model: dispatch.execution.model,
|
|
367
380
|
command: "forge-bridge deterministic QA",
|
|
368
|
-
summary
|
|
381
|
+
summary,
|
|
382
|
+
status: missingLaunchTarget ? "blocked" : "pass",
|
|
383
|
+
blockerReason: missingLaunchTarget ? "missing_app_launch_target" : null,
|
|
384
|
+
browserChecks,
|
|
385
|
+
sidecarFiles: browserSidecar ? [browserSidecar] : []
|
|
369
386
|
});
|
|
370
387
|
await emitQaArtifactRecorded(context.emit, worktree.worktreePath, qaArtifact);
|
|
371
388
|
await emitQaStructuredEvents(context.emit, worktree.worktreePath, dispatch.workItem.id, qaArtifact);
|
|
372
389
|
await context.emit(buildProviderUsageEvent(dispatch, estimatedDeterministicUsage()));
|
|
373
390
|
return {
|
|
374
|
-
summary: "forge-bridge QA passed.",
|
|
391
|
+
summary: missingLaunchTarget ? "forge-bridge QA blocked." : "forge-bridge QA passed.",
|
|
375
392
|
artifact: qaArtifact,
|
|
376
393
|
artifacts: [qaArtifact]
|
|
377
394
|
};
|
|
@@ -483,9 +500,62 @@ const qaArtifactFileNames = {
|
|
|
483
500
|
verdict: "qa.verdict.json",
|
|
484
501
|
report: "qa.report.md"
|
|
485
502
|
};
|
|
503
|
+
function hasLaunchTarget(target) {
|
|
504
|
+
return Boolean(target?.url?.trim()
|
|
505
|
+
|| target?.ready_url?.trim()
|
|
506
|
+
|| target?.command?.trim()
|
|
507
|
+
|| (target?.routes ?? []).some((route) => route.trim().length > 0));
|
|
508
|
+
}
|
|
509
|
+
function deterministicBrowserSidecar(workItemId, target) {
|
|
510
|
+
const route = primaryLaunchRoute(target);
|
|
511
|
+
return {
|
|
512
|
+
relativePath: qaArtifactRelativePath(workItemId, "qa/screenshots/deterministic-browser-check.txt"),
|
|
513
|
+
content: `Deterministic Forge QA browser evidence\nRoute: ${route}\nConsole errors: []\nNetwork errors: []\nResponsive checks: desktop, mobile\n`
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
function deterministicBrowserCheck(target, screenshotPath) {
|
|
517
|
+
const route = primaryLaunchRoute(target);
|
|
518
|
+
return {
|
|
519
|
+
route,
|
|
520
|
+
url: route,
|
|
521
|
+
status: "pass",
|
|
522
|
+
summary: "Deterministic browser QA exercised the configured launch target.",
|
|
523
|
+
actions: ["open launch target", "walk acceptance criteria", "check console/network summaries", "check responsive viewport"],
|
|
524
|
+
screenshots: [screenshotPath],
|
|
525
|
+
console_errors: [],
|
|
526
|
+
network_errors: [],
|
|
527
|
+
responsive_checks: ["desktop", "mobile"]
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
function primaryLaunchRoute(target) {
|
|
531
|
+
return target.ready_url?.trim()
|
|
532
|
+
|| target.url?.trim()
|
|
533
|
+
|| target.routes.find((route) => route.trim().length > 0)?.trim()
|
|
534
|
+
|| "app launch command";
|
|
535
|
+
}
|
|
536
|
+
async function writeQaSidecarFiles(worktreePath, files) {
|
|
537
|
+
const sidecars = [];
|
|
538
|
+
for (const file of files) {
|
|
539
|
+
const fullPath = path.resolve(worktreePath, file.relativePath);
|
|
540
|
+
const relativePath = path.relative(worktreePath, fullPath).split(path.sep).join("/");
|
|
541
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
542
|
+
throw new Error(`QA sidecar path must stay under the worktree: ${file.relativePath}`);
|
|
543
|
+
}
|
|
544
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
545
|
+
await fs.writeFile(fullPath, file.content, "utf8");
|
|
546
|
+
sidecars.push({
|
|
547
|
+
path: relativePath,
|
|
548
|
+
content_hash: sha256(file.content),
|
|
549
|
+
bytes: Buffer.byteLength(file.content, "utf8")
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
return sidecars;
|
|
553
|
+
}
|
|
486
554
|
async function writeQaArtifacts(request) {
|
|
487
555
|
const artifactRoot = artifactRootFor(request.worktreePath, request.workItemId);
|
|
488
556
|
await fs.mkdir(artifactRoot, { recursive: true });
|
|
557
|
+
const sidecars = await writeQaSidecarFiles(request.worktreePath, request.sidecarFiles ?? []);
|
|
558
|
+
const status = request.status ?? "pass";
|
|
489
559
|
const criterion = request.acceptanceCriteria.trim() || "Forge QA evidence exists";
|
|
490
560
|
const relativeEvidencePath = qaArtifactRelativePath(request.workItemId, qaArtifactFileNames.evidence);
|
|
491
561
|
const relativeFindingsPath = qaArtifactRelativePath(request.workItemId, qaArtifactFileNames.findings);
|
|
@@ -503,15 +573,18 @@ async function writeQaArtifacts(request) {
|
|
|
503
573
|
const evidenceJson = jsonText({
|
|
504
574
|
schema_version: 1,
|
|
505
575
|
phase: "qa",
|
|
506
|
-
commands:
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
576
|
+
commands: status === "blocked"
|
|
577
|
+
? []
|
|
578
|
+
: [
|
|
579
|
+
{
|
|
580
|
+
command: request.command,
|
|
581
|
+
exit_code: 0,
|
|
582
|
+
status: "pass",
|
|
583
|
+
summary: `${request.provider} QA completed successfully.`
|
|
584
|
+
}
|
|
585
|
+
],
|
|
586
|
+
browser_checks: request.browserChecks ?? [],
|
|
587
|
+
sidecars,
|
|
515
588
|
notes: request.summary
|
|
516
589
|
});
|
|
517
590
|
const findingsJson = jsonText({
|
|
@@ -523,12 +596,12 @@ async function writeQaArtifacts(request) {
|
|
|
523
596
|
const verdictJson = jsonText({
|
|
524
597
|
schema_version: 1,
|
|
525
598
|
phase: "qa",
|
|
526
|
-
status
|
|
599
|
+
status,
|
|
527
600
|
summary: request.summary,
|
|
528
|
-
tested_acceptance_criteria: [criterion],
|
|
601
|
+
tested_acceptance_criteria: status === "blocked" ? [] : [criterion],
|
|
529
602
|
finding_ids: [],
|
|
530
603
|
candidate_bug_ids: [],
|
|
531
|
-
blocker_reason: null,
|
|
604
|
+
blocker_reason: status === "blocked" ? request.blockerReason ?? "inconclusive" : null,
|
|
532
605
|
evidence_path: relativeEvidencePath,
|
|
533
606
|
findings_path: relativeFindingsPath,
|
|
534
607
|
report_path: relativeReportPath
|
|
@@ -546,7 +619,7 @@ async function writeQaArtifacts(request) {
|
|
|
546
619
|
verdictJson,
|
|
547
620
|
report,
|
|
548
621
|
producedBy: request.producedBy,
|
|
549
|
-
status
|
|
622
|
+
status,
|
|
550
623
|
phase: "qa"
|
|
551
624
|
};
|
|
552
625
|
}
|
|
@@ -570,6 +643,7 @@ async function readQaCompletionArtifactBundle(worktreePath, workItemId) {
|
|
|
570
643
|
if (!artifact || !await hasCompleteQaArtifactBundle(worktreePath, workItemId)) {
|
|
571
644
|
return null;
|
|
572
645
|
}
|
|
646
|
+
await validateQaBundleSidecarReferences(worktreePath, workItemId);
|
|
573
647
|
return artifact;
|
|
574
648
|
}
|
|
575
649
|
async function hasCompleteQaArtifactBundle(worktreePath, workItemId) {
|
|
@@ -590,6 +664,59 @@ async function hasCompleteQaArtifactBundle(worktreePath, workItemId) {
|
|
|
590
664
|
}
|
|
591
665
|
return true;
|
|
592
666
|
}
|
|
667
|
+
function enforceQaBrowserLaunchTargetGate(dispatch, artifact) {
|
|
668
|
+
const qaBrowser = dispatch.qaBrowser;
|
|
669
|
+
if (!qaBrowser?.required || hasLaunchTarget(qaBrowser.launchTarget ?? null)) {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
const verdict = parseJsonLine(artifact.verdictJson) ?? {};
|
|
673
|
+
const status = normalizedString(stringField(verdict, "status") ?? artifact.status ?? "");
|
|
674
|
+
const blockerReason = normalizedString(stringField(verdict, "blocker_reason") ?? "");
|
|
675
|
+
if (status !== "blocked" || blockerReason !== "missing_app_launch_target") {
|
|
676
|
+
throw new Error("QA browser testing is required but no app launch target was provided; provider must return blocked with blocker_reason missing_app_launch_target.");
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
async function validateQaBundleSidecarReferences(worktreePath, workItemId) {
|
|
680
|
+
const artifactRoot = artifactRootFor(worktreePath, workItemId);
|
|
681
|
+
const evidence = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.evidence));
|
|
682
|
+
const sidecars = arrayField(evidence, "sidecars");
|
|
683
|
+
if (sidecars.length === 0) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const qaSidecarRoot = path.resolve(artifactRoot, "qa");
|
|
687
|
+
for (const [index, sidecar] of sidecars.entries()) {
|
|
688
|
+
const sidecarPath = stringField(sidecar, "path");
|
|
689
|
+
const expectedHash = stringField(sidecar, "content_hash");
|
|
690
|
+
const expectedBytes = sidecar.bytes;
|
|
691
|
+
if (!sidecarPath || !expectedHash || typeof expectedBytes !== "number" || !Number.isFinite(expectedBytes) || expectedBytes <= 0) {
|
|
692
|
+
throw new Error(`QA sidecar ${index} must include path, content_hash, and positive bytes.`);
|
|
693
|
+
}
|
|
694
|
+
if (path.isAbsolute(sidecarPath)) {
|
|
695
|
+
throw new Error(`QA sidecar ${index} path must be relative to the worktree.`);
|
|
696
|
+
}
|
|
697
|
+
const fullPath = path.resolve(worktreePath, sidecarPath);
|
|
698
|
+
if (!isPathUnderRoot(qaSidecarRoot, fullPath)) {
|
|
699
|
+
throw new Error(`QA sidecar ${index} path must be under .forge/work-items/${workItemId}/artifacts/qa/.`);
|
|
700
|
+
}
|
|
701
|
+
const stat = await fs.lstat(fullPath).catch((error) => {
|
|
702
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
703
|
+
throw new Error(`QA sidecar ${index} file does not exist: ${sidecarPath}`);
|
|
704
|
+
}
|
|
705
|
+
throw error;
|
|
706
|
+
});
|
|
707
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
708
|
+
throw new Error(`QA sidecar ${index} must reference a regular file.`);
|
|
709
|
+
}
|
|
710
|
+
await ensureNoSymbolicLinkPathSegments(qaSidecarRoot, path.dirname(fullPath), `QA sidecar ${index} path must not traverse symbolic links.`);
|
|
711
|
+
if (stat.size !== expectedBytes) {
|
|
712
|
+
throw new Error(`QA sidecar ${index} bytes do not match the referenced file.`);
|
|
713
|
+
}
|
|
714
|
+
const actualHash = await sha256File(fullPath);
|
|
715
|
+
if (actualHash !== expectedHash.trim().toLowerCase()) {
|
|
716
|
+
throw new Error(`QA sidecar ${index} content_hash does not match the referenced file.`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
593
720
|
async function emitQaStructuredEvents(emit, worktreePath, workItemId, artifact) {
|
|
594
721
|
const artifactRoot = artifactRootFor(worktreePath, workItemId);
|
|
595
722
|
const plan = await readJsonObjectFile(path.join(artifactRoot, qaArtifactFileNames.plan));
|
|
@@ -634,13 +761,15 @@ async function emitQaStructuredEvents(emit, worktreePath, workItemId, artifact)
|
|
|
634
761
|
class LiveQaArtifactEmitter {
|
|
635
762
|
worktreePath;
|
|
636
763
|
workItemId;
|
|
764
|
+
dispatch;
|
|
637
765
|
emit;
|
|
638
766
|
emitted = false;
|
|
639
767
|
timer = null;
|
|
640
768
|
pending = Promise.resolve();
|
|
641
|
-
constructor(worktreePath, workItemId, emit) {
|
|
769
|
+
constructor(worktreePath, workItemId, dispatch, emit) {
|
|
642
770
|
this.worktreePath = worktreePath;
|
|
643
771
|
this.workItemId = workItemId;
|
|
772
|
+
this.dispatch = dispatch;
|
|
644
773
|
this.emit = emit;
|
|
645
774
|
}
|
|
646
775
|
start() {
|
|
@@ -673,6 +802,7 @@ class LiveQaArtifactEmitter {
|
|
|
673
802
|
if (!artifact) {
|
|
674
803
|
return;
|
|
675
804
|
}
|
|
805
|
+
enforceQaBrowserLaunchTargetGate(this.dispatch, artifact);
|
|
676
806
|
this.emitted = true;
|
|
677
807
|
await emitQaArtifactRecorded(this.emit, this.worktreePath, artifact);
|
|
678
808
|
await emitQaStructuredEvents(this.emit, this.worktreePath, this.workItemId, artifact);
|
|
@@ -1071,6 +1201,7 @@ function buildImplementationPrompt(dispatch, phase = completionPhaseFromDispatch
|
|
|
1071
1201
|
|
|
1072
1202
|
Read .forge/work-items/${dispatch.workItem.id}/spec.md and any prior phase artifacts required by this phase.
|
|
1073
1203
|
${phaseWorkInstruction(phase)}
|
|
1204
|
+
${qaReworkImplementationInstruction(dispatch)}
|
|
1074
1205
|
Use the repository's existing conventions. Run relevant tests when practical.
|
|
1075
1206
|
Forge owns protocol files: worktree prep, injected .forge commands/hooks/skills, phase verdict artifacts, reports, and commits.
|
|
1076
1207
|
Read Forge-provided context under .forge when needed, but do not edit .forge files or create Forge verdict/report artifacts by hand.
|
|
@@ -1084,6 +1215,7 @@ function buildDevelopCyclePrompt(dispatch, phase = completionPhaseFromDispatch(d
|
|
|
1084
1215
|
|
|
1085
1216
|
Read .forge/work-items/${dispatch.workItem.id}/spec.md, implement the work item, self-review the diff, then validate it in this same provider session.
|
|
1086
1217
|
Start in the ${phase.replace(/_/g, " ")} phase. Do not stop after implementation if it passes; continue through self-review and validation using the next instruction returned by each completion tool.
|
|
1218
|
+
${qaReworkImplementationInstruction(dispatch)}
|
|
1087
1219
|
Use the repository's existing conventions. Run relevant tests when practical.
|
|
1088
1220
|
Forge owns protocol files: worktree prep, injected .forge commands/hooks/skills, phase verdict artifacts, reports, and commits.
|
|
1089
1221
|
Read Forge-provided context under .forge when needed, but do not edit .forge files or create Forge verdict/report artifacts by hand.
|
|
@@ -1092,6 +1224,28 @@ ${completionPromptForDevelopCycle(phase)}
|
|
|
1092
1224
|
|
|
1093
1225
|
Finish with a concise summary of changed files and verification after the validation completion tool has accepted the work, or after the current phase tool records a non-pass blocker.`;
|
|
1094
1226
|
}
|
|
1227
|
+
function qaReworkImplementationInstruction(dispatch) {
|
|
1228
|
+
if (!dispatch.qaRework) {
|
|
1229
|
+
return "";
|
|
1230
|
+
}
|
|
1231
|
+
return `
|
|
1232
|
+
QA rework context:
|
|
1233
|
+
- Attempt ${dispatch.qaRework.attempt}/${dispatch.qaRework.maxAttempts}
|
|
1234
|
+
- Rework packet: ${dispatch.qaRework.packetPath}
|
|
1235
|
+
- Required QA finding IDs: ${dispatch.qaRework.findingIds.join(", ")}
|
|
1236
|
+
Read the packet JSON and fix or explicitly dispute each listed finding. The implementation completion artifact must include resolved_findings entries for every required QA ID with qa_id, status, summary, evidence, changed_files, tests_added_or_updated, and regression_evidence.`;
|
|
1237
|
+
}
|
|
1238
|
+
function qaReworkRetestInstruction(dispatch) {
|
|
1239
|
+
if (!dispatch.qaRework) {
|
|
1240
|
+
return "";
|
|
1241
|
+
}
|
|
1242
|
+
return `
|
|
1243
|
+
QA retest context:
|
|
1244
|
+
- Rework attempt ${dispatch.qaRework.attempt}/${dispatch.qaRework.maxAttempts}
|
|
1245
|
+
- Rework packet: ${dispatch.qaRework.packetPath}
|
|
1246
|
+
- Priority finding IDs: ${dispatch.qaRework.findingIds.join(", ")}
|
|
1247
|
+
Prioritize retesting the listed QA findings and their required regressions before deciding pass, needs_rework, or blocked.`;
|
|
1248
|
+
}
|
|
1095
1249
|
function phaseWorkInstruction(phase) {
|
|
1096
1250
|
if (phase === "self_review") {
|
|
1097
1251
|
return "Review the implementation diff, apply safe fixes when needed, and record remaining risks explicitly.";
|
|
@@ -1101,6 +1255,21 @@ function phaseWorkInstruction(phase) {
|
|
|
1101
1255
|
}
|
|
1102
1256
|
return "Implement the work item and keep the change focused.";
|
|
1103
1257
|
}
|
|
1258
|
+
function qaBrowserInstruction(dispatch) {
|
|
1259
|
+
const qaBrowser = dispatch.qaBrowser;
|
|
1260
|
+
if (!qaBrowser) {
|
|
1261
|
+
return "Browser/app launch context: not provided by Forge.";
|
|
1262
|
+
}
|
|
1263
|
+
const launchTarget = qaBrowser.launchTarget;
|
|
1264
|
+
if (qaBrowser.required && !hasLaunchTarget(launchTarget ?? null)) {
|
|
1265
|
+
return `Browser/app launch context:\n- UI/browser QA is required for this item.\n- No app launch target was provided.\n- Return blocked with blocker_reason: missing_app_launch_target instead of pretending browser testing occurred.\n- Artifact sidecars path when available: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
|
|
1266
|
+
}
|
|
1267
|
+
if (!launchTarget) {
|
|
1268
|
+
return `Browser/app launch context:\n- UI/browser QA is not required by Forge heuristics for this item.\n- If you determine browser testing is needed, return blocked with blocker_reason: missing_app_launch_target unless you can safely identify a launch target from the repo.\n- Browser sidecars path: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
|
|
1269
|
+
}
|
|
1270
|
+
const routes = launchTarget.routes.length > 0 ? launchTarget.routes.join(", ") : "not specified";
|
|
1271
|
+
return `Browser/app launch context:\n- Browser QA required: ${qaBrowser.required ? "yes" : "no, unless the diff/AC requires it"}\n- URL: ${launchTarget.url || "not specified"}\n- Ready URL: ${launchTarget.ready_url || "not specified"}\n- Launch command: ${launchTarget.command || "not specified"}\n- Working directory: ${launchTarget.working_directory || "repo root"}\n- Routes to cover: ${routes}\n- Notes: ${launchTarget.notes || "none"}\n- Browser sidecars path: ${qaBrowser.artifactsPath || `.forge/work-items/${dispatch.workItem.id}/artifacts/qa`}`;
|
|
1272
|
+
}
|
|
1104
1273
|
function buildQaPrompt(dispatch) {
|
|
1105
1274
|
return `You are Forge's QA lane running locally in a prepared git worktree.
|
|
1106
1275
|
|
|
@@ -1119,8 +1288,10 @@ System info / environment notes:
|
|
|
1119
1288
|
${dispatch.workItem.systemInfo || "Not provided."}
|
|
1120
1289
|
|
|
1121
1290
|
Inspect the validated implementation and Forge artifacts under .forge/work-items/${dispatch.workItem.id}/artifacts/ when present, especially implementation, self-review, and validation evidence.
|
|
1291
|
+
${qaBrowserInstruction(dispatch)}
|
|
1292
|
+
${qaReworkRetestInstruction(dispatch)}
|
|
1122
1293
|
QA is read-mostly: inspect code, run commands, and exercise browser/app flows when available, but do not edit product source or tests.
|
|
1123
|
-
Record concrete evidence. For UI/browser work, include browser_checks with URLs, actions, observed results, screenshots/traces when available,
|
|
1294
|
+
Record concrete evidence. For UI/browser work, include browser_checks with URLs/routes, actions, observed results, screenshots/traces when available, console errors, failed network requests, and responsive checks. Store browser sidecars under .forge/work-items/${dispatch.workItem.id}/artifacts/qa/.
|
|
1124
1295
|
Report in-scope defects as actionable findings with repro steps, expected/actual behavior, evidence, likely area, required fix, and required regression.
|
|
1125
1296
|
Out-of-scope or follow-up issues belong in candidate_bugs instead of blocking the current item.
|
|
1126
1297
|
|
|
@@ -1483,6 +1654,9 @@ function stringField(source, key) {
|
|
|
1483
1654
|
const value = source[key];
|
|
1484
1655
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
|
1485
1656
|
}
|
|
1657
|
+
function normalizedString(value) {
|
|
1658
|
+
return value.trim().toLowerCase().replace(/-/g, "_");
|
|
1659
|
+
}
|
|
1486
1660
|
function isNodeError(error) {
|
|
1487
1661
|
return error instanceof Error && "code" in error;
|
|
1488
1662
|
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function isPathUnderRoot(root: string, target: string): boolean;
|
|
2
|
+
export declare function ensureNoSymbolicLinkPathSegments(root: string, targetDirectory: string, errorMessage: string): Promise<void>;
|
|
3
|
+
export declare function sha256File(filePath: string): Promise<string>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
export function isPathUnderRoot(root, target) {
|
|
6
|
+
const relative = path.relative(path.resolve(root), path.resolve(target));
|
|
7
|
+
return Boolean(relative)
|
|
8
|
+
&& relative !== ".."
|
|
9
|
+
&& !relative.startsWith(`..${path.sep}`)
|
|
10
|
+
&& !path.isAbsolute(relative);
|
|
11
|
+
}
|
|
12
|
+
export async function ensureNoSymbolicLinkPathSegments(root, targetDirectory, errorMessage) {
|
|
13
|
+
const resolvedRoot = path.resolve(root);
|
|
14
|
+
const resolvedTargetDirectory = path.resolve(targetDirectory);
|
|
15
|
+
const relative = path.relative(resolvedRoot, resolvedTargetDirectory);
|
|
16
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
17
|
+
throw new Error(errorMessage);
|
|
18
|
+
}
|
|
19
|
+
let current = resolvedRoot;
|
|
20
|
+
const rootStat = await fs.lstat(current);
|
|
21
|
+
if (rootStat.isSymbolicLink()) {
|
|
22
|
+
throw new Error(errorMessage);
|
|
23
|
+
}
|
|
24
|
+
if (!relative) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
for (const segment of relative.split(path.sep)) {
|
|
28
|
+
if (!segment) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
current = path.join(current, segment);
|
|
32
|
+
const stat = await fs.lstat(current);
|
|
33
|
+
if (stat.isSymbolicLink()) {
|
|
34
|
+
throw new Error(errorMessage);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function sha256File(filePath) {
|
|
39
|
+
const hash = crypto.createHash("sha256");
|
|
40
|
+
for await (const chunk of createReadStream(filePath)) {
|
|
41
|
+
hash.update(chunk);
|
|
42
|
+
}
|
|
43
|
+
return hash.digest("hex");
|
|
44
|
+
}
|
|
@@ -11,6 +11,30 @@ export interface RunOnceDispatchPacket {
|
|
|
11
11
|
readonly apiKeySecretGrant?: ApiKeySecretGrant | null;
|
|
12
12
|
readonly artifacts: readonly DispatchResolvedArtifact[];
|
|
13
13
|
readonly warmPathOverrides?: readonly string[];
|
|
14
|
+
readonly qaBrowser?: DispatchQaBrowserContext | null;
|
|
15
|
+
readonly qaRework?: DispatchQaReworkContext | null;
|
|
16
|
+
}
|
|
17
|
+
export interface DispatchQaBrowserContext {
|
|
18
|
+
readonly required: boolean;
|
|
19
|
+
readonly launchTarget?: DispatchQaAppLaunchTarget | null;
|
|
20
|
+
readonly artifactsPath: string;
|
|
21
|
+
readonly missingTargetReason?: string | null;
|
|
22
|
+
}
|
|
23
|
+
export interface DispatchQaAppLaunchTarget {
|
|
24
|
+
readonly url?: string | null;
|
|
25
|
+
readonly command?: string | null;
|
|
26
|
+
readonly working_directory?: string | null;
|
|
27
|
+
readonly ready_url?: string | null;
|
|
28
|
+
readonly routes: readonly string[];
|
|
29
|
+
readonly notes?: string | null;
|
|
30
|
+
}
|
|
31
|
+
export interface DispatchQaReworkContext {
|
|
32
|
+
readonly attempt: number;
|
|
33
|
+
readonly maxAttempts: number;
|
|
34
|
+
readonly packetPath: string;
|
|
35
|
+
readonly packetJson: string;
|
|
36
|
+
readonly findingIds: readonly string[];
|
|
37
|
+
readonly findings: readonly Record<string, unknown>[];
|
|
14
38
|
}
|
|
15
39
|
export interface DispatchResolvedArtifact {
|
|
16
40
|
readonly kind: string;
|
|
@@ -41,6 +65,9 @@ export interface DispatchRepo {
|
|
|
41
65
|
}
|
|
42
66
|
export interface DispatchCycleRun {
|
|
43
67
|
readonly id: string;
|
|
68
|
+
readonly ownerType?: string | null;
|
|
69
|
+
readonly ownerId?: string | null;
|
|
70
|
+
readonly workItemId?: string | null;
|
|
44
71
|
readonly kind: string;
|
|
45
72
|
readonly phase: string;
|
|
46
73
|
readonly requestedBase: string;
|
|
@@ -56,6 +83,9 @@ export interface DispatchSession {
|
|
|
56
83
|
}
|
|
57
84
|
export interface DispatchLease {
|
|
58
85
|
readonly id: string;
|
|
86
|
+
readonly ownerType?: string | null;
|
|
87
|
+
readonly ownerId?: string | null;
|
|
88
|
+
readonly workItemId?: string | null;
|
|
59
89
|
readonly worktreeKey: string;
|
|
60
90
|
readonly lane: string;
|
|
61
91
|
readonly fencingToken: string;
|
|
@@ -71,6 +101,7 @@ export interface DispatchExecution {
|
|
|
71
101
|
readonly role: string;
|
|
72
102
|
readonly lane: string;
|
|
73
103
|
readonly requiresApiKeySecretGrant: boolean;
|
|
104
|
+
readonly runnerRepoLocalPath?: string | null;
|
|
74
105
|
}
|
|
75
106
|
export interface ApiKeySecretGrant {
|
|
76
107
|
readonly grantId: string;
|
|
@@ -17,7 +17,9 @@ export function parseRunOnceDispatchPacket(value) {
|
|
|
17
17
|
serverLocal: parseServerLocal(packet.serverLocal),
|
|
18
18
|
apiKeySecretGrant: parseApiKeySecretGrant(packet.apiKeySecretGrant),
|
|
19
19
|
artifacts: parseArtifacts(packet.artifacts),
|
|
20
|
-
warmPathOverrides: parseWarmPathOverrides(packet.warmPathOverrides)
|
|
20
|
+
warmPathOverrides: parseWarmPathOverrides(packet.warmPathOverrides),
|
|
21
|
+
qaBrowser: parseQaBrowser(packet.qaBrowser),
|
|
22
|
+
qaRework: parseQaRework(packet.qaRework)
|
|
21
23
|
};
|
|
22
24
|
}
|
|
23
25
|
export function failureResult(summary, events = []) {
|
|
@@ -55,6 +57,9 @@ function parseCycleRun(value) {
|
|
|
55
57
|
const cycle = asObject(value, "cycleRun");
|
|
56
58
|
return {
|
|
57
59
|
id: requiredString(cycle, "id"),
|
|
60
|
+
ownerType: optionalString(cycle, "ownerType"),
|
|
61
|
+
ownerId: optionalString(cycle, "ownerId"),
|
|
62
|
+
workItemId: optionalString(cycle, "workItemId"),
|
|
58
63
|
kind: requiredString(cycle, "kind"),
|
|
59
64
|
phase: requiredString(cycle, "phase"),
|
|
60
65
|
requestedBase: optionalString(cycle, "requestedBase") ?? "develop",
|
|
@@ -76,6 +81,9 @@ function parseLease(value) {
|
|
|
76
81
|
const lease = asObject(value, "lease");
|
|
77
82
|
return {
|
|
78
83
|
id: requiredString(lease, "id"),
|
|
84
|
+
ownerType: optionalString(lease, "ownerType"),
|
|
85
|
+
ownerId: optionalString(lease, "ownerId"),
|
|
86
|
+
workItemId: optionalString(lease, "workItemId"),
|
|
79
87
|
worktreeKey: requiredString(lease, "worktreeKey"),
|
|
80
88
|
lane: requiredString(lease, "lane"),
|
|
81
89
|
fencingToken: requiredString(lease, "fencingToken"),
|
|
@@ -93,7 +101,8 @@ function parseExecution(value) {
|
|
|
93
101
|
repoId: requiredString(execution, "repoId"),
|
|
94
102
|
role: requiredString(execution, "role"),
|
|
95
103
|
lane: requiredString(execution, "lane"),
|
|
96
|
-
requiresApiKeySecretGrant: requiredBoolean(execution, "requiresApiKeySecretGrant")
|
|
104
|
+
requiresApiKeySecretGrant: requiredBoolean(execution, "requiresApiKeySecretGrant"),
|
|
105
|
+
runnerRepoLocalPath: optionalString(execution, "runnerRepoLocalPath")
|
|
97
106
|
};
|
|
98
107
|
}
|
|
99
108
|
function parseApiKeySecretGrant(value) {
|
|
@@ -138,6 +147,70 @@ function parseWarmPathOverride(entry, index) {
|
|
|
138
147
|
}
|
|
139
148
|
return segments.join("/");
|
|
140
149
|
}
|
|
150
|
+
function parseQaBrowser(value) {
|
|
151
|
+
if (value === undefined || value === null) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const qaBrowser = asObject(value, "qaBrowser");
|
|
155
|
+
return {
|
|
156
|
+
required: optionalBoolean(qaBrowser, "required") ?? false,
|
|
157
|
+
launchTarget: parseQaAppLaunchTarget(qaBrowser.launchTarget ?? qaBrowser.launch_target),
|
|
158
|
+
artifactsPath: optionalString(qaBrowser, "artifactsPath") ?? optionalString(qaBrowser, "artifacts_path") ?? "",
|
|
159
|
+
missingTargetReason: optionalString(qaBrowser, "missingTargetReason") ?? optionalString(qaBrowser, "missing_target_reason")
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function parseQaAppLaunchTarget(value) {
|
|
163
|
+
if (value === undefined || value === null) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
const launchTarget = asObject(value, "qaBrowser.launchTarget");
|
|
167
|
+
const routes = launchTarget.routes;
|
|
168
|
+
if (routes !== undefined && routes !== null && !Array.isArray(routes)) {
|
|
169
|
+
throw new Error("qaBrowser.launchTarget.routes must be an array when provided.");
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
url: optionalString(launchTarget, "url"),
|
|
173
|
+
command: optionalString(launchTarget, "command"),
|
|
174
|
+
working_directory: optionalString(launchTarget, "working_directory"),
|
|
175
|
+
ready_url: optionalString(launchTarget, "ready_url"),
|
|
176
|
+
routes: Array.isArray(routes)
|
|
177
|
+
? routes.map((entry, index) => {
|
|
178
|
+
if (typeof entry !== "string") {
|
|
179
|
+
throw new Error(`qaBrowser.launchTarget.routes[${index}] must be a string.`);
|
|
180
|
+
}
|
|
181
|
+
return entry;
|
|
182
|
+
}).filter((entry) => entry.trim().length > 0)
|
|
183
|
+
: [],
|
|
184
|
+
notes: optionalString(launchTarget, "notes")
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function parseQaRework(value) {
|
|
188
|
+
if (value === undefined || value === null) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
const qaRework = asObject(value, "qaRework");
|
|
192
|
+
const findingIdsValue = qaRework.findingIds;
|
|
193
|
+
if (!Array.isArray(findingIdsValue)) {
|
|
194
|
+
throw new Error("qaRework.findingIds must be an array.");
|
|
195
|
+
}
|
|
196
|
+
const findingsValue = qaRework.findings;
|
|
197
|
+
if (!Array.isArray(findingsValue)) {
|
|
198
|
+
throw new Error("qaRework.findings must be an array.");
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
attempt: requiredNumber(qaRework, "attempt"),
|
|
202
|
+
maxAttempts: requiredNumber(qaRework, "maxAttempts"),
|
|
203
|
+
packetPath: requiredString(qaRework, "packetPath"),
|
|
204
|
+
packetJson: requiredString(qaRework, "packetJson"),
|
|
205
|
+
findingIds: findingIdsValue.map((entry, index) => {
|
|
206
|
+
if (typeof entry !== "string" || entry.trim().length === 0) {
|
|
207
|
+
throw new Error(`qaRework.findingIds[${index}] must be a non-empty string.`);
|
|
208
|
+
}
|
|
209
|
+
return entry;
|
|
210
|
+
}),
|
|
211
|
+
findings: findingsValue.map((entry, index) => asObject(entry, `qaRework.findings[${index}]`))
|
|
212
|
+
};
|
|
213
|
+
}
|
|
141
214
|
function parseArtifacts(value) {
|
|
142
215
|
if (value === undefined || value === null) {
|
|
143
216
|
return [];
|
|
@@ -189,6 +262,16 @@ function requiredBoolean(source, key) {
|
|
|
189
262
|
}
|
|
190
263
|
return value;
|
|
191
264
|
}
|
|
265
|
+
function optionalBoolean(source, key) {
|
|
266
|
+
const value = source[key];
|
|
267
|
+
if (value === undefined || value === null) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
if (typeof value !== "boolean") {
|
|
271
|
+
throw new Error(`${key} must be a boolean when provided.`);
|
|
272
|
+
}
|
|
273
|
+
return value;
|
|
274
|
+
}
|
|
192
275
|
function optionalPositiveInteger(source, key) {
|
|
193
276
|
const value = source[key];
|
|
194
277
|
if (value === undefined || value === null) {
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const forgeBridgeVersion = "0.1.1-dev.
|
|
1
|
+
export declare const forgeBridgeVersion = "0.1.1-dev.e52b249f07a8";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const forgeBridgeVersion = "0.1.1-dev.
|
|
1
|
+
export const forgeBridgeVersion = "0.1.1-dev.e52b249f07a8";
|
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.e52b249f07a8",
|
|
4
4
|
"description": "Forge runner bridge CLI for connecting local developer machines to Forge dispatch.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|