@loucompanion/forge-bridge 0.1.1-dev.32d4dd5874ee → 0.1.1-dev.4f31cdd58cac

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.
@@ -19,6 +19,24 @@ export interface CompletionToolServerConfig {
19
19
  readonly workItemId: string;
20
20
  readonly phase: CompletionPhase;
21
21
  readonly producedBy?: string;
22
+ readonly redactionSecrets?: readonly string[];
23
+ readonly developCycle?: boolean;
24
+ }
25
+ export interface CompletionToolCallResult {
26
+ readonly content: readonly {
27
+ readonly type: "text";
28
+ readonly text: string;
29
+ }[];
30
+ readonly structuredContent: CompletionArtifactReference & {
31
+ readonly gate: {
32
+ readonly phase: CompletionPhase;
33
+ readonly status: string;
34
+ readonly allowed: boolean;
35
+ readonly reason: string;
36
+ };
37
+ readonly nextPhase: CompletionPhase | "dev_test_ready";
38
+ readonly nextInstruction: string;
39
+ };
22
40
  }
23
41
  export interface CompletionArtifactReference {
24
42
  readonly phase: CompletionPhase;
@@ -32,6 +50,7 @@ export interface CompletionArtifactReference {
32
50
  }
33
51
  export declare function completionPhaseFromDispatch(dispatch: RunOnceDispatchPacket): CompletionPhase;
34
52
  export declare function normalizeCompletionPhase(value: string | null | undefined): CompletionPhase;
53
+ export declare function usesDevelopCycleCompletion(dispatch: RunOnceDispatchPacket): boolean;
35
54
  export declare function completionToolNamesForPhase(phase: CompletionPhase): readonly string[];
36
55
  export declare function expectedCompletionToolName(phase: CompletionPhase): string;
37
56
  export declare function artifactRootFor(worktreePath: string, workItemId: string): string;
@@ -42,6 +61,18 @@ export declare function buildCompletionToolServerArgs(config: CompletionToolServ
42
61
  export declare function codexMcpConfigArgs(runtime: CompletionToolRuntime): readonly string[];
43
62
  export declare function cursorMcpServerConfig(runtime: CompletionToolRuntime): unknown;
44
63
  export declare function completionPromptForPhase(phase: CompletionPhase): string;
64
+ export declare function completionPromptForDevelopCycle(phase: CompletionPhase): string;
65
+ export declare function completionToolNamesForServer(config: CompletionToolServerConfig): readonly string[];
66
+ export declare class CompletionToolServerSession {
67
+ private readonly config;
68
+ private currentPhase;
69
+ private completed;
70
+ constructor(config: CompletionToolServerConfig);
71
+ get instructions(): string;
72
+ toolDefinitions(): readonly unknown[];
73
+ callTool(toolName: string, input: unknown): Promise<CompletionToolCallResult>;
74
+ private advanceAfterPass;
75
+ }
45
76
  export declare function writeCompletionArtifact(config: CompletionToolServerConfig, toolName: string, input: unknown): Promise<CompletionArtifactReference>;
46
77
  export declare function readCompletionArtifact(worktreePath: string, workItemId: string, phase: CompletionPhase): Promise<RunnerDispatchArtifact | null>;
47
78
  export declare function runCompletionMcpServer(config: CompletionToolServerConfig): Promise<void>;
@@ -52,3 +83,4 @@ export declare function parseCompletionMcpArgs(argv: readonly string[]): {
52
83
  readonly ok: false;
53
84
  readonly error: string;
54
85
  };
86
+ export declare function completionRedactionEnv(secrets: readonly string[]): Readonly<Record<string, string>>;
@@ -2,7 +2,10 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { redactSecrets } from "../shared/redaction/redaction.js";
5
6
  const toolServerName = "forge";
7
+ const completionRedactionSecretsEnv = "FORGE_COMPLETION_REDACTION_SECRETS";
8
+ const completionPhaseOrder = ["implementation", "self_review", "validation"];
6
9
  const phaseFiles = {
7
10
  implementation: {
8
11
  jsonFileName: "implementation.completeness.json",
@@ -30,6 +33,15 @@ export function normalizeCompletionPhase(value) {
30
33
  }
31
34
  return "implementation";
32
35
  }
36
+ export function usesDevelopCycleCompletion(dispatch) {
37
+ const kind = dispatch.cycleRun.kind.trim().toLowerCase();
38
+ const role = dispatch.execution.role.trim().toLowerCase();
39
+ const lane = dispatch.execution.lane.trim().toLowerCase();
40
+ return kind === "develop"
41
+ && role === "develop"
42
+ && lane === "develop"
43
+ && completionPhaseFromDispatch(dispatch) === "implementation";
44
+ }
33
45
  export function completionToolNamesForPhase(phase) {
34
46
  const base = phase === "implementation"
35
47
  ? "complete_implementation"
@@ -58,7 +70,8 @@ export async function prepareCompletionToolRuntime(options) {
58
70
  worktreePath,
59
71
  workItemId,
60
72
  phase,
61
- producedBy: `${options.dispatch.execution.provider}-cli`
73
+ producedBy: `${options.dispatch.execution.provider}-cli`,
74
+ developCycle: usesDevelopCycleCompletion(options.dispatch)
62
75
  });
63
76
  const runtime = {
64
77
  serverName: toolServerName,
@@ -73,7 +86,7 @@ export async function prepareCompletionToolRuntime(options) {
73
86
  return await prepareCursorCompletionToolRuntime(worktreePath, runtime);
74
87
  }
75
88
  export function buildCompletionToolServerArgs(config) {
76
- return [
89
+ const args = [
77
90
  "mcp",
78
91
  "--worktree",
79
92
  path.resolve(config.worktreePath),
@@ -84,6 +97,10 @@ export function buildCompletionToolServerArgs(config) {
84
97
  "--produced-by",
85
98
  config.producedBy ?? "forge-completion-tool"
86
99
  ];
100
+ if (config.developCycle) {
101
+ args.push("--develop-cycle");
102
+ }
103
+ return args;
87
104
  }
88
105
  export function codexMcpConfigArgs(runtime) {
89
106
  const prefix = `mcp_servers.${runtime.serverName}`;
@@ -114,20 +131,105 @@ Do not write phase artifacts by hand. When the phase is complete, call \`${toolN
114
131
  That tool writes \`.forge/work-items/{workItemId}/artifacts/${artifactName}\` and returns the artifact reference that Forge will mirror through the existing phase-artifact pipeline.
115
132
  If the work cannot pass, call the same tool with a non-pass status and explicit blockers instead of reporting completion in prose only.`;
116
133
  }
134
+ export function completionPromptForDevelopCycle(phase) {
135
+ return `Forge completion tools are available through the MCP server named \`${toolServerName}\`.
136
+ Do not write phase artifacts by hand. The tool server enforces phase order and starts this run in \`${phase}\`.
137
+ Complete the dev cycle in order inside this same provider session:
138
+ 1. Call \`forge.complete_implementation(...)\` when implementation evidence is ready.
139
+ 2. Follow the returned self-review instruction, then call \`forge.complete_self_review(...)\`.
140
+ 3. Follow the returned validation instruction, then call \`forge.complete_validation(...)\`.
141
+ Each tool writes the canonical artifact under \`.forge/work-items/{workItemId}/artifacts/\` and returns the next-phase instruction Forge expects you to follow.
142
+ If the current phase cannot pass, call the current phase tool with a non-pass status and explicit blockers instead of reporting completion in prose only.`;
143
+ }
144
+ export function completionToolNamesForServer(config) {
145
+ return config.developCycle
146
+ ? completionPhaseOrder.flatMap((phase) => completionToolNamesForPhase(phase))
147
+ : completionToolNamesForPhase(config.phase);
148
+ }
149
+ export class CompletionToolServerSession {
150
+ config;
151
+ currentPhase;
152
+ completed = false;
153
+ constructor(config) {
154
+ this.config = config;
155
+ this.currentPhase = config.phase;
156
+ }
157
+ get instructions() {
158
+ return this.config.developCycle
159
+ ? completionPromptForDevelopCycle(this.currentPhase)
160
+ : completionPromptForPhase(this.currentPhase);
161
+ }
162
+ toolDefinitions() {
163
+ return completionToolNamesForServer(this.config).map((name) => toolDefinition(name, phaseFromToolName(name)));
164
+ }
165
+ async callTool(toolName, input) {
166
+ const requestedPhase = phaseFromToolName(toolName);
167
+ if (this.completed) {
168
+ throw new Error("Forge develop cycle is already complete; no more phase completion calls are accepted.");
169
+ }
170
+ if (requestedPhase !== this.currentPhase) {
171
+ throw new Error(`Tool ${toolName} is out of order. Current Forge phase is ${this.currentPhase}.`);
172
+ }
173
+ const completedPhase = this.currentPhase;
174
+ const reference = await writeCompletionArtifact({ ...this.config, phase: completedPhase }, toolName, input);
175
+ const status = reference.status.trim().toLowerCase();
176
+ const pass = status === "pass";
177
+ const nextPhase = pass
178
+ ? this.advanceAfterPass(completedPhase)
179
+ : completedPhase;
180
+ const nextInstruction = completionToolNextInstruction(completedPhase, status, nextPhase);
181
+ const reason = pass
182
+ ? `${completedPhase} completion artifact accepted.`
183
+ : `${completedPhase} completion artifact recorded with status ${status}; rework is required before phase progression.`;
184
+ return {
185
+ content: [
186
+ {
187
+ type: "text",
188
+ text: `Forge recorded ${reference.phase} completion artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}). ${nextInstruction}`
189
+ }
190
+ ],
191
+ structuredContent: {
192
+ ...reference,
193
+ gate: {
194
+ phase: completedPhase,
195
+ status,
196
+ allowed: pass,
197
+ reason
198
+ },
199
+ nextPhase,
200
+ nextInstruction
201
+ }
202
+ };
203
+ }
204
+ advanceAfterPass(phase) {
205
+ const next = nextPhaseAfter(phase);
206
+ if (!this.config.developCycle) {
207
+ return next ?? "dev_test_ready";
208
+ }
209
+ if (next) {
210
+ this.currentPhase = next;
211
+ return next;
212
+ }
213
+ this.completed = true;
214
+ return "dev_test_ready";
215
+ }
216
+ }
117
217
  export async function writeCompletionArtifact(config, toolName, input) {
118
218
  const phase = phaseFromToolName(toolName);
119
219
  if (phase !== config.phase) {
120
220
  throw new Error(`Tool ${toolName} is not allowed for phase ${config.phase}.`);
121
221
  }
122
222
  const payload = asRecord(input, "completion payload");
123
- validatePayloadShape(phase, payload);
124
- validateOptionalWorkItemId(payload, config.workItemId);
125
- const producedBy = optionalString(payload, "produced_by")
126
- ?? optionalString(payload, "producedBy")
223
+ const status = normalizeCompletionStatus(requiredString(payload, "status"));
224
+ const validationPayload = { ...payload, status };
225
+ validatePayloadShape(phase, validationPayload);
226
+ validateOptionalWorkItemId(validationPayload, config.workItemId);
227
+ const redactedPayload = redactSecrets(validationPayload, config.redactionSecrets ?? []);
228
+ const producedBy = optionalString(redactedPayload, "produced_by")
229
+ ?? optionalString(redactedPayload, "producedBy")
127
230
  ?? config.producedBy
128
231
  ?? "forge-completion-tool";
129
- const status = requiredString(payload, "status").trim().toLowerCase();
130
- const artifactWithoutHash = normalizeArtifactPayload(payload, {
232
+ const artifactWithoutHash = normalizeArtifactPayload(redactedPayload, {
131
233
  phase,
132
234
  workItemId: config.workItemId,
133
235
  producedBy,
@@ -139,7 +241,7 @@ export async function writeCompletionArtifact(config, toolName, input) {
139
241
  content_hash: contentHash
140
242
  };
141
243
  const artifactJson = `${JSON.stringify(artifact, null, 2)}\n`;
142
- const report = reportMarkdown(payload, phase, status, producedBy, contentHash);
244
+ const report = reportMarkdown(redactedPayload, phase, status, producedBy, contentHash);
143
245
  const artifactPath = completionArtifactJsonPath(config.worktreePath, config.workItemId, phase);
144
246
  const reportPath = completionArtifactReportPath(config.worktreePath, config.workItemId, phase);
145
247
  await fs.mkdir(path.dirname(artifactPath), { recursive: true });
@@ -176,6 +278,7 @@ export async function readCompletionArtifact(worktreePath, workItemId, phase) {
176
278
  };
177
279
  }
178
280
  export async function runCompletionMcpServer(config) {
281
+ const session = new CompletionToolServerSession(config);
179
282
  process.stdin.setEncoding("utf8");
180
283
  let buffer = "";
181
284
  for await (const chunk of process.stdin) {
@@ -185,14 +288,14 @@ export async function runCompletionMcpServer(config) {
185
288
  const rawLine = buffer.slice(0, newlineIndex).trim();
186
289
  buffer = buffer.slice(newlineIndex + 1);
187
290
  if (rawLine.length > 0) {
188
- await handleJsonRpcLine(config, rawLine);
291
+ await handleJsonRpcLine(session, rawLine);
189
292
  }
190
293
  newlineIndex = buffer.indexOf("\n");
191
294
  }
192
295
  }
193
296
  const trailing = buffer.trim();
194
297
  if (trailing.length > 0) {
195
- await handleJsonRpcLine(config, trailing);
298
+ await handleJsonRpcLine(session, trailing);
196
299
  }
197
300
  }
198
301
  export function parseCompletionMcpArgs(argv) {
@@ -200,6 +303,7 @@ export function parseCompletionMcpArgs(argv) {
200
303
  let workItemId = null;
201
304
  let phase = "implementation";
202
305
  let producedBy;
306
+ let developCycle = false;
203
307
  for (let index = 1; index < argv.length; index += 1) {
204
308
  const arg = argv[index];
205
309
  if (arg === "--worktree") {
@@ -219,6 +323,9 @@ export function parseCompletionMcpArgs(argv) {
219
323
  else if (arg === "--produced-by") {
220
324
  producedBy = argv[++index] ?? undefined;
221
325
  }
326
+ else if (arg === "--develop-cycle") {
327
+ developCycle = true;
328
+ }
222
329
  else {
223
330
  return { ok: false, error: `Unknown mcp argument: ${arg}` };
224
331
  }
@@ -232,10 +339,18 @@ export function parseCompletionMcpArgs(argv) {
232
339
  worktreePath: path.resolve(worktreePath),
233
340
  workItemId,
234
341
  phase,
235
- producedBy
342
+ producedBy,
343
+ redactionSecrets: parseCompletionRedactionSecrets(process.env[completionRedactionSecretsEnv]),
344
+ developCycle
236
345
  }
237
346
  };
238
347
  }
348
+ export function completionRedactionEnv(secrets) {
349
+ const filtered = secrets.filter((secret) => secret.length > 0);
350
+ return filtered.length > 0
351
+ ? { [completionRedactionSecretsEnv]: JSON.stringify(filtered) }
352
+ : {};
353
+ }
239
354
  async function prepareCursorCompletionToolRuntime(worktreePath, runtime) {
240
355
  const cursorDir = path.join(worktreePath, ".cursor");
241
356
  const configPath = path.join(cursorDir, "mcp.json");
@@ -263,7 +378,7 @@ async function prepareCursorCompletionToolRuntime(worktreePath, runtime) {
263
378
  }
264
379
  };
265
380
  }
266
- async function handleJsonRpcLine(config, rawLine) {
381
+ async function handleJsonRpcLine(session, rawLine) {
267
382
  let message;
268
383
  try {
269
384
  message = JSON.parse(rawLine);
@@ -281,14 +396,14 @@ async function handleJsonRpcLine(config, rawLine) {
281
396
  return;
282
397
  }
283
398
  try {
284
- const result = await handleJsonRpcRequest(config, request.method, request.params);
399
+ const result = await handleJsonRpcRequest(session, request.method, request.params);
285
400
  writeJsonRpc({ jsonrpc: "2.0", id: request.id, result });
286
401
  }
287
402
  catch (error) {
288
403
  writeJsonRpc({ jsonrpc: "2.0", id: request.id, error: rpcError(-32000, errorMessage(error)) });
289
404
  }
290
405
  }
291
- async function handleJsonRpcRequest(config, method, params) {
406
+ async function handleJsonRpcRequest(session, method, params) {
292
407
  if (method === "initialize") {
293
408
  const initializeParams = params && typeof params === "object" && !Array.isArray(params)
294
409
  ? params
@@ -302,7 +417,7 @@ async function handleJsonRpcRequest(config, method, params) {
302
417
  name: "forge-completion-tools",
303
418
  version: "0.1.0"
304
419
  },
305
- instructions: completionPromptForPhase(config.phase)
420
+ instructions: session.instructions
306
421
  };
307
422
  }
308
423
  if (method === "ping") {
@@ -310,23 +425,14 @@ async function handleJsonRpcRequest(config, method, params) {
310
425
  }
311
426
  if (method === "tools/list") {
312
427
  return {
313
- tools: completionToolNamesForPhase(config.phase).map((name) => toolDefinition(name, config.phase))
428
+ tools: session.toolDefinitions()
314
429
  };
315
430
  }
316
431
  if (method === "tools/call") {
317
432
  const call = asRecord(params, "tools/call params");
318
433
  const name = requiredString(call, "name");
319
434
  const args = call.arguments ?? {};
320
- const reference = await writeCompletionArtifact(config, name, args);
321
- return {
322
- content: [
323
- {
324
- type: "text",
325
- text: `Forge recorded ${reference.phase} completion artifact ${path.relative(reference.repoPath, reference.path)} (${reference.status}, ${reference.contentHash}).`
326
- }
327
- ],
328
- structuredContent: reference
329
- };
435
+ return await session.callTool(name, args);
330
436
  }
331
437
  throw new Error(`Unsupported MCP method: ${method}`);
332
438
  }
@@ -337,6 +443,22 @@ function toolDefinition(name, phase) {
337
443
  inputSchema: toolInputSchema(phase)
338
444
  };
339
445
  }
446
+ function nextPhaseAfter(phase) {
447
+ const index = completionPhaseOrder.indexOf(phase);
448
+ return index >= 0 ? completionPhaseOrder[index + 1] ?? null : null;
449
+ }
450
+ function completionToolNextInstruction(completedPhase, status, nextPhase) {
451
+ if (status !== "pass") {
452
+ return `Stay in ${completedPhase}, address the recorded blockers, then call ${expectedCompletionToolName(completedPhase)} again.`;
453
+ }
454
+ if (nextPhase === "self_review") {
455
+ return "Implementation accepted. Begin self-review: inspect the diff, apply safe fixes, and call forge.complete_self_review with review evidence.";
456
+ }
457
+ if (nextPhase === "validation") {
458
+ return "Self-review accepted. Begin validation: run deterministic checks, prove acceptance criteria, and call forge.complete_validation with command evidence.";
459
+ }
460
+ return "Validation accepted. Forge has the structured evidence needed to move this work item to Dev Test.";
461
+ }
340
462
  function toolDescription(phase) {
341
463
  if (phase === "implementation") {
342
464
  return "Complete Forge implementation by writing implementation.completeness.json with scoped changed-file, verification, acceptance-criteria, gap, and next-phase evidence.";
@@ -347,69 +469,213 @@ function toolDescription(phase) {
347
469
  return "Complete Forge validation by writing validation.verdict.json with command, build/test/lint/design, drift, acceptance-criteria, and ready-for-dev-test evidence.";
348
470
  }
349
471
  function toolInputSchema(phase) {
350
- const base = {
351
- type: "object",
352
- additionalProperties: true,
353
- properties: {
354
- status: { type: "string", enum: ["pass", "blocked", "fail", "failed"] },
355
- summary: { type: "string" },
356
- report_markdown: { type: "string" },
357
- blockers: { type: "array" }
472
+ const baseProperties = {
473
+ status: {
474
+ type: "string",
475
+ enum: ["pass", "needs_rework", "fail", "failed"],
476
+ description: "Use pass only when the phase evidence satisfies Forge's gate. Use needs_rework/fail/failed with explicit blockers or findings otherwise."
358
477
  },
359
- required: ["status", "summary"]
478
+ summary: { type: "string", description: "Concise phase summary." },
479
+ report_markdown: { type: "string", description: "Optional human-readable report. Forge will generate a fallback report if omitted." },
480
+ blockers: {
481
+ type: "array",
482
+ description: "Required for non-pass status unless findings, failures, known_gaps, remaining_risks, or commands provide explicit evidence.",
483
+ items: evidenceObjectSchema(["reason"], {
484
+ reason: { type: "string" },
485
+ owner: { type: "string" },
486
+ next_step: { type: "string" }
487
+ })
488
+ }
360
489
  };
361
490
  if (phase === "implementation") {
362
- return {
363
- ...base,
364
- properties: {
365
- ...base.properties,
366
- plan: { type: "array" },
367
- changed_files: { type: "array" },
368
- tests_added_or_updated: { type: "array" },
369
- docs_added_or_updated: { type: "array" },
370
- verification: { type: "array" },
371
- acceptance_criteria: { type: "array" },
372
- known_gaps: { type: "array" },
373
- next_phase_context: {}
491
+ return objectToolSchema({
492
+ ...baseProperties,
493
+ plan: {
494
+ type: "array",
495
+ description: "Required for pass. Ordered implementation steps with status.",
496
+ items: evidenceObjectSchema(["step", "status"], {
497
+ step: { type: "string" },
498
+ status: { type: "string" },
499
+ evidence: { type: "string" }
500
+ })
374
501
  },
375
- required: ["status", "summary"]
376
- };
502
+ changed_files: {
503
+ type: "array",
504
+ description: "Required for pass. Must be non-empty unless known_gaps explains why.",
505
+ items: evidenceObjectSchema(["path", "summary"], {
506
+ path: { type: "string" },
507
+ summary: { type: "string" }
508
+ })
509
+ },
510
+ tests_added_or_updated: {
511
+ type: "array",
512
+ description: "Required for pass. Use an object explaining why no tests were needed for docs/config-only work if empty would be ambiguous.",
513
+ items: evidenceObjectSchema(["path", "summary"], {
514
+ path: { type: "string" },
515
+ summary: { type: "string" }
516
+ })
517
+ },
518
+ docs_added_or_updated: {
519
+ type: "array",
520
+ description: "Required for pass. Use an object explaining why no docs changed when appropriate.",
521
+ items: evidenceObjectSchema(["path", "summary"], {
522
+ path: { type: "string" },
523
+ summary: { type: "string" }
524
+ })
525
+ },
526
+ verification: {
527
+ type: "array",
528
+ description: "Implementation verification evidence. Objects should include command, exit_code, status, and summary when command-based.",
529
+ items: commandEvidenceSchema()
530
+ },
531
+ acceptance_criteria: acceptanceCriteriaSchema(),
532
+ known_gaps: {
533
+ type: "array",
534
+ description: "Required for pass, even when empty. If changed_files/tests/docs arrays are empty, explain why here.",
535
+ items: evidenceObjectSchema(["summary"], {
536
+ summary: { type: "string" },
537
+ impact: { type: "string" },
538
+ follow_up: { type: "string" }
539
+ })
540
+ },
541
+ next_phase_context: {
542
+ description: "Required for pass. Context the self-review phase should carry forward."
543
+ }
544
+ }, ["status", "summary"], "For status pass, include plan, changed_files, tests_added_or_updated, docs_added_or_updated, acceptance_criteria, known_gaps, and next_phase_context. Acceptance criteria entries must use status: satisfied plus evidence.");
377
545
  }
378
546
  if (phase === "self_review") {
379
- return {
380
- ...base,
381
- properties: {
382
- ...base.properties,
383
- reviewed_diff: { type: "boolean" },
384
- findings: { type: "array" },
385
- security_review: { type: "object" },
386
- edge_case_review: { type: "object" },
387
- design_review: { type: "object" },
388
- deterministic_comparison: { type: "object" },
389
- fixes_applied: { type: "array" },
390
- remaining_risks: { type: "array" }
547
+ return objectToolSchema({
548
+ ...baseProperties,
549
+ reviewed_diff: { type: "boolean", description: "Required for pass. True after reviewing the implementation diff." },
550
+ findings: {
551
+ type: "array",
552
+ description: "Required for pass, even when empty. Include finding objects for issues discovered during review.",
553
+ items: evidenceObjectSchema(["severity", "summary", "status"], {
554
+ severity: { type: "string" },
555
+ summary: { type: "string" },
556
+ status: { type: "string" },
557
+ file: { type: "string" }
558
+ })
391
559
  },
392
- required: ["status", "summary"]
393
- };
394
- }
395
- return {
396
- ...base,
397
- properties: {
398
- ...base.properties,
399
- commands: { type: "array" },
400
- acceptance_criteria: { type: "array" },
401
- build_status: { type: "string" },
402
- test_status: { type: "string" },
403
- lint_status: { type: "string" },
404
- design_status: { type: "string" },
405
- drift_checks: { type: "array" },
406
- ready_for_dev_test: { type: "boolean" }
560
+ security_review: reviewSectionSchema(),
561
+ edge_case_review: reviewSectionSchema(),
562
+ design_review: {
563
+ ...reviewSectionSchema(),
564
+ properties: {
565
+ ...reviewSectionProperties(),
566
+ screenshots: { type: "array", items: evidenceObjectSchema(["path"], { path: { type: "string" }, summary: { type: "string" } }) }
567
+ }
568
+ },
569
+ deterministic_comparison: {
570
+ type: "object",
571
+ description: "Required for pass. Evidence that the reviewed diff matches the intended deterministic change.",
572
+ additionalProperties: true,
573
+ properties: {
574
+ status: { type: "string" },
575
+ command_or_tool: { type: "string" },
576
+ notes: { type: "string" }
577
+ }
578
+ },
579
+ fixes_applied: {
580
+ type: "array",
581
+ description: "Required for pass, even when empty.",
582
+ items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, path: { type: "string" } })
583
+ },
584
+ remaining_risks: {
585
+ type: "array",
586
+ description: "Required for pass, even when empty.",
587
+ items: evidenceObjectSchema(["summary"], { summary: { type: "string" }, mitigation: { type: "string" } })
588
+ }
589
+ }, ["status", "summary"], "For status pass, include reviewed_diff, findings, security_review, edge_case_review, design_review, deterministic_comparison, fixes_applied, and remaining_risks.");
590
+ }
591
+ return objectToolSchema({
592
+ ...baseProperties,
593
+ commands: {
594
+ type: "array",
595
+ description: "Required for pass. Include at least one deterministic validation command with exit_code: 0 and status: pass.",
596
+ items: commandEvidenceSchema()
597
+ },
598
+ acceptance_criteria: acceptanceCriteriaSchema(),
599
+ build_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
600
+ test_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
601
+ lint_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
602
+ design_status: { type: "string", description: "Required for pass. pass/fail/needs_rework/not_applicable." },
603
+ drift_checks: {
604
+ type: "array",
605
+ description: "Required for pass. Deterministic checks for unintended drift.",
606
+ items: evidenceObjectSchema(["command_or_tool", "status", "summary"], {
607
+ command_or_tool: { type: "string" },
608
+ status: { type: "string" },
609
+ summary: { type: "string" }
610
+ })
407
611
  },
408
- required: ["status", "summary"]
612
+ ready_for_dev_test: { type: "boolean", description: "Required for pass and must be true." }
613
+ }, ["status", "summary"], "For status pass, commands must include at least one passing deterministic command, acceptance criteria entries must use status: satisfied plus evidence, and ready_for_dev_test must be true.");
614
+ }
615
+ function objectToolSchema(properties, required, description) {
616
+ return {
617
+ type: "object",
618
+ additionalProperties: true,
619
+ description,
620
+ properties,
621
+ required: [...required]
622
+ };
623
+ }
624
+ function evidenceObjectSchema(required, properties) {
625
+ return {
626
+ type: "object",
627
+ additionalProperties: true,
628
+ properties,
629
+ required: [...required]
409
630
  };
410
631
  }
632
+ function commandEvidenceSchema() {
633
+ return evidenceObjectSchema(["command", "exit_code", "status", "summary"], {
634
+ command: { type: "string" },
635
+ exit_code: { type: "number" },
636
+ status: { type: "string" },
637
+ summary: { type: "string" }
638
+ });
639
+ }
640
+ function acceptanceCriteriaSchema() {
641
+ return {
642
+ type: "array",
643
+ description: "Required for pass. Every entry must be an object with status: satisfied and non-empty evidence.",
644
+ items: evidenceObjectSchema(["criterion", "evidence", "status"], {
645
+ criterion: { type: "string" },
646
+ evidence: { type: "string" },
647
+ status: { type: "string", enum: ["satisfied"] }
648
+ })
649
+ };
650
+ }
651
+ function reviewSectionSchema() {
652
+ return {
653
+ type: "object",
654
+ description: "Required for pass. Use status pass/fail/needs_rework/not_applicable plus notes.",
655
+ additionalProperties: true,
656
+ properties: reviewSectionProperties()
657
+ };
658
+ }
659
+ function reviewSectionProperties() {
660
+ return {
661
+ status: { type: "string" },
662
+ notes: { type: "string" }
663
+ };
664
+ }
665
+ function normalizeCompletionStatus(value) {
666
+ const normalized = value.trim().toLowerCase().replace(/-/g, "_");
667
+ const canonical = normalized === "blocked" || normalized === "needsrework"
668
+ ? "needs_rework"
669
+ : normalized === "failed"
670
+ ? "fail"
671
+ : normalized;
672
+ if (canonical !== "pass" && canonical !== "fail" && canonical !== "needs_rework") {
673
+ throw new Error("status must be pass, fail, or needs_rework.");
674
+ }
675
+ return canonical;
676
+ }
411
677
  function validatePayloadShape(phase, payload) {
412
- const status = requiredString(payload, "status").trim().toLowerCase();
678
+ const status = normalizeCompletionStatus(requiredString(payload, "status"));
413
679
  if (!status) {
414
680
  throw new Error("status is required.");
415
681
  }
@@ -504,6 +770,21 @@ function parseStrictCompletionPhase(value) {
504
770
  }
505
771
  return null;
506
772
  }
773
+ function parseCompletionRedactionSecrets(raw) {
774
+ if (!raw?.trim()) {
775
+ return [];
776
+ }
777
+ try {
778
+ const parsed = JSON.parse(raw);
779
+ if (!Array.isArray(parsed)) {
780
+ return [];
781
+ }
782
+ return parsed.filter((value) => typeof value === "string" && value.length > 0);
783
+ }
784
+ catch {
785
+ return [];
786
+ }
787
+ }
507
788
  function phaseFromToolName(toolName) {
508
789
  const normalized = toolName.trim().toLowerCase();
509
790
  if (normalized === "forge.complete_implementation" || normalized === "complete_implementation") {
@@ -11,6 +11,7 @@ export interface ProviderProcessRunRequest {
11
11
  readonly command: string;
12
12
  readonly args: readonly string[];
13
13
  readonly cwd: string;
14
+ readonly env?: Readonly<Record<string, string>>;
14
15
  }
15
16
  export interface ProviderProcessRunResult {
16
17
  readonly exitCode: number;