@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.
@@ -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;
@@ -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 artifactRoot = artifactRootFor(config.worktreePath, config.workItemId);
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 = qaArtifactRelativePath(config.workItemId, "qa.evidence.json");
302
- const relativeFindingsPath = qaArtifactRelativePath(config.workItemId, "qa.findings.json");
303
- const relativeReportPath = qaArtifactRelativePath(config.workItemId, "qa.report.md");
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 when applicable.",
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 summary = "All checks passed. forge-bridge QA completed.";
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
- command: request.command,
509
- exit_code: 0,
510
- status: "pass",
511
- summary: `${request.provider} QA completed successfully.`
512
- }
513
- ],
514
- browser_checks: [],
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: "pass",
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: "pass",
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, and console/network notes.
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.caea7216568f";
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.caea7216568f";
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.caea7216568f",
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",