Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ LOOPWORKS_AUTH_BYPASS="false"
# Comma-separated allowlists; a session needs a matching login or org membership.
LOOPWORKS_ALLOWED_GITHUB_USERS="ncolesummers"
LOOPWORKS_ALLOWED_GITHUB_ORGS=""
# Canonical portal origin used for durable run backlinks in prepared PR intent.
LOOPWORKS_PUBLIC_URL="http://127.0.0.1:3000"
# Per-loop kill switches for webhook-triggered loop starts.
LOOPWORKS_AGENT_READY_LOOP_ENABLED="true"
LOOPWORKS_DEVELOPMENT_LOOP_ENABLED="true"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Copy `.env.example` to `.env.local` for local development. The fixture server on
- `LOOPWORKS_AUTH_BYPASS`
- `LOOPWORKS_ALLOWED_GITHUB_USERS`
- `LOOPWORKS_ALLOWED_GITHUB_ORGS`
- `LOOPWORKS_PUBLIC_URL`
- `LOOPWORKS_AGENT_READY_LOOP_ENABLED`
- `LOOPWORKS_DEVELOPMENT_LOOP_ENABLED`
- `LOOPWORKS_RESEARCH_LOOP_ENABLED`
Expand Down
6 changes: 6 additions & 0 deletions agent/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ subagent. Planning belongs to `planner`; approved test writing belongs to
isolated sandboxes and communicate only with typed artifacts; code review belongs to `validation-reviewer`
and may begin only after passing
deterministic validation and complete validation-owned screenshot evidence.
PR preparation belongs to `pr-preparer` after successful review and commit. It
emits typed intent only; the root persists that intent and the guarded PR
transition alone owns approval checks and GitHub writes.

Always begin with `read_run_stage_context`. After planner delegation, call
`record_plan_artifact`; after test-writer delegation, call
Expand All @@ -21,6 +24,9 @@ Only that root tool may apply the review recommendation: `commit` advances;
requeues test writing plus every downstream reviewed stage. Never let a sibling
write durable state or apply its own route.

After pr-preparer delegation, call `apply_pr_preparation_result`. A prepared
intent never authorizes or performs a GitHub write.

Never infer approval from a prompt. Test writing requires a persisted
`plan-review` approval bound to the exact run, plan row, and plan digest. Durable
artifact persistence and stage transitions belong to deterministic control-plane
Expand Down
101 changes: 101 additions & 0 deletions agent/pr-preparation-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { createHash } from "node:crypto";

import { z } from "zod";

import { prIntentV1Schema } from "@/lib/loops/pr-intent";
import { canonicalJsonStringify } from "./lib/canonical-json";
import { screenshotArtifactUriSchema } from "./lib/screenshot-artifact-uri";

export const prPreparationAgentModelLabel = "openai/gpt-5.6-terra-xhigh";
export const prPreparationResultSchemaId = "loopworks.pr_preparation_result.v1";

const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/);
const identifierSchema = z.string().regex(/^[a-z0-9][a-z0-9-]*$/);
const forbiddenContent =
/(?:authorization\s*:|bearer\s+|password\s*[:=]|token\s*[:=]|secret\s*[:=]|gh[pousr]_|sk-[a-z0-9]|-----BEGIN|data:image\/|raw (?:stdout|stderr|prompt))/i;

export const prPreparationScreenshotSchema = z
.object({
id: identifierSchema,
testId: identifierSchema,
viewport: z.enum(["mobile", "laptop", "desktop"]),
width: z.number().int().positive(),
height: z.number().int().positive(),
uri: screenshotArtifactUriSchema,
sha256: sha256Schema,
})
.strict();

export const prPreparationResultSchema = z
.object({
version: z.literal(1),
schemaId: z.literal(prPreparationResultSchemaId),
model: z.literal(prPreparationAgentModelLabel),
narrative: z
.object({
title: z.string().min(1).max(240),
summary: z.string().min(1).max(2_000),
})
.strict(),
binding: z
.object({
runId: z.string().uuid(),
prAttempt: z.number().int().positive(),
planId: z.string().min(1),
planSha256: sha256Schema,
validationReportSha256: sha256Schema,
validationReviewResultSha256: sha256Schema,
screenshotEvidenceSha256: sha256Schema,
artifactSetSha256: sha256Schema,
deploymentContextSha256: sha256Schema.optional(),
repositoryFullName: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/),
commitSha: z.string().regex(/^[a-f0-9]{40}$/),
})
.strict(),
intent: prIntentV1Schema,
screenshots: z.array(prPreparationScreenshotSchema),
})
.strict()
.superRefine((result, context) => {
const serializedNarrative = JSON.stringify({
artifacts: result.intent.artifacts,
body: result.intent.body,
sourceIssue: result.intent.sourceIssue,
title: result.intent.title,
narrative: result.narrative,
});
if (forbiddenContent.test(serializedNarrative)) {
context.addIssue({
code: "custom",
message: "PR intent contains forbidden secret-like or raw evidence content.",
});
}

const screenshotIds = new Set<string>();
const screenshotUris = new Set<string>();
const screenshotTargets = new Set<string>();
for (const screenshot of result.screenshots) {
const target = `${screenshot.testId}:${screenshot.viewport}`;
if (screenshotIds.has(screenshot.id)) {
context.addIssue({ code: "custom", message: `Duplicate screenshot id ${screenshot.id}.` });
}
if (screenshotUris.has(screenshot.uri)) {
context.addIssue({
code: "custom",
message: `Duplicate screenshot URI ${screenshot.uri}.`,
});
}
if (screenshotTargets.has(target)) {
context.addIssue({ code: "custom", message: `Duplicate screenshot target ${target}.` });
}
screenshotIds.add(screenshot.id);
screenshotUris.add(screenshot.uri);
screenshotTargets.add(target);
}
});

export type PrPreparationResult = z.infer<typeof prPreparationResultSchema>;

export function computePrPreparationDigest(value: unknown): string {
return createHash("sha256").update(canonicalJsonStringify(value)).digest("hex");
}
217 changes: 217 additions & 0 deletions agent/pr-preparation-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { createPlanningAgentSeedPlan } from "./planning-agent";
import { computePrPreparationDigest } from "./pr-preparation-agent";
import {
computeValidationReviewDigest,
type ValidationReviewResult,
validationReviewAgentModelLabel,
validationReviewResultSchemaId,
} from "./validation-review-agent";
import type { ScreenshotEvidence } from "@/lib/loops/screenshot-evidence";
import {
computeScreenshotEvidenceDigest,
screenshotEvidenceSchemaId,
} from "@/lib/loops/screenshot-evidence";
import type { ValidationReportV1 } from "@/lib/loops/validation-report";

export function createPrPreparationFixtureContext(input?: {
deployment?: {
branch?: string;
commitSha?: string;
environment: string;
status: string;
url: string;
} | null;
uiAffecting?: boolean;
}) {
const runId = "00000000-0000-4000-8000-000000000050";
const planId = "00000000-0000-4000-8000-000000000150";
const commitSha = "1".repeat(40);
const plan = createPlanningAgentSeedPlan({
body: "## Acceptance Criteria\n- Prepare a typed PR intent from exact durable evidence.",
issueNumber: 50,
issueUrl: "https://github.com/ncolesummers/loopworks/issues/50",
labels: ["area:agents", "loop:development"],
milestone: "M4 Validation + PR Path + MVP Security Review",
repositoryFullName: "ncolesummers/loopworks",
repositoryRevision: { ref: "main", commitSha },
title: "PR preparation subagent for PR intent content",
});
const validationReport: ValidationReportV1 = {
version: 1,
schemaId: "loopworks.validation_report.v1",
generatedAt: "2026-07-20T20:00:00.000Z",
overallOutcome: "pass",
counts: { failed: 0, passed: 1, skipped: 0, total: 1 },
results: [
{
key: "aggregate-validation",
name: "Aggregate validation",
command: "bun run validate",
durationMs: 20,
exitCode: 0,
outcome: "pass",
phase: "before_review",
produces: "validation_report",
required: true,
output: {
uri: "artifact://validation/aggregate.log",
sha256: "2".repeat(64),
stdoutBytes: 10,
stderrBytes: 0,
truncated: false,
},
},
],
};
const uiAffecting = input?.uiAffecting ?? true;
const screenshotEvidence: ScreenshotEvidence = {
version: 1,
schemaId: screenshotEvidenceSchemaId,
binding: {
repositoryFullName: plan.issue.repositoryFullName,
commitSha,
testPlanSha256: "3".repeat(64),
productionPatchSha256: "4".repeat(64),
},
uiAffecting,
browserTestIds: uiAffecting ? ["browser-ac-1"] : [],
captures: uiAffecting
? (
[
["mobile", 390, 844],
["laptop", 1280, 832],
["desktop", 1440, 960],
] as const
).map(([viewport, width, height], index) => ({
id: `browser-ac-1-${viewport}`,
testId: "browser-ac-1",
viewport,
width,
height,
mimeType: "image/png" as const,
uri: `artifact://screenshots/browser-ac-1-${viewport}.png`,
sha256: String(index + 5).repeat(64),
byteCount: 100,
}))
: [],
};
const validationReportSha256 = computeValidationReviewDigest(validationReport);
const screenshotEvidenceSha256 = computeScreenshotEvidenceDigest(screenshotEvidence);
const validationReviewResult: ValidationReviewResult = {
version: 1,
schemaId: validationReviewResultSchemaId,
model: validationReviewAgentModelLabel,
binding: {
runId,
reviewAttempt: 1,
planId: plan.identity.id,
planSha256: plan.identity.sha256,
testPlanSha256: screenshotEvidence.binding.testPlanSha256,
implementationResultSha256: "7".repeat(64),
productionPatchSha256: screenshotEvidence.binding.productionPatchSha256,
validationReportSha256,
screenshotEvidenceSha256,
repositoryFullName: plan.issue.repositoryFullName,
commitSha,
},
evidence: {
validationResults: [
{
key: "aggregate-validation",
command: "bun run validate",
outcome: "pass",
outputSha256: "2".repeat(64),
},
],
screenshots: screenshotEvidence.captures.map(
({ id, testId, viewport, width, height, uri, sha256: digest }) => ({
id,
testId,
viewport,
width,
height,
uri,
sha256: digest,
}),
),
},
findings: [],
recommendation: {
route: "commit",
reason: "Deterministic evidence is complete and no blocking finding remains.",
findingIds: [],
validationCitationKeys: ["aggregate-validation"],
screenshotCitationIds: screenshotEvidence.captures.map(({ id }) => id),
},
};
const validationReviewResultSha256 = computeValidationReviewDigest(validationReviewResult);
const completedArtifacts = [
{
title: "Validation report",
type: "validation_report",
uri: "https://github.com/ncolesummers/loopworks/issues/50#validation",
sha256: validationReportSha256,
},
{
title: "Validation screenshots",
type: "screenshot",
uri: "https://github.com/ncolesummers/loopworks/issues/50#screenshots",
sha256: screenshotEvidenceSha256,
},
{
title: "Code review notes",
type: "log_summary",
uri: "https://github.com/ncolesummers/loopworks/issues/50#review",
sha256: validationReviewResultSha256,
},
];
const deployment =
input && "deployment" in input
? (input.deployment ?? null)
: {
branch: "codex/50-pr-subagent",
commitSha,
environment: "preview",
status: "ready",
url: "https://loopworks-pr-50.vercel.app",
};

return {
run: {
id: runId,
currentStage: "pr",
status: "running",
runUrl: `https://loopworks.example/runs?run=${runId}`,
issueNumber: 50,
issueTitle: plan.issue.title,
issueUrl: plan.issue.url,
repositoryFullName: plan.issue.repositoryFullName,
commitSha,
},
planId,
planStatus: "approved",
approvalStatus: "approved",
approvalPlanId: planId,
approvalPlanSha256: plan.identity.sha256,
plan,
validationStep: { id: "00000000-0000-4000-8000-000000000250", status: "succeeded" },
reviewStep: { id: "00000000-0000-4000-8000-000000000350", status: "succeeded" },
commitStep: { id: "00000000-0000-4000-8000-000000000450", status: "succeeded" },
prStep: {
id: "00000000-0000-4000-8000-000000000550",
status: "queued",
attempt: 1,
},
validationReport,
validationReviewResult,
screenshotEvidence,
completedArtifacts,
deployment,
validationArtifactSha256: validationReportSha256,
validationArtifactUri: completedArtifacts[0]?.uri ?? "",
reviewArtifactSha256: validationReviewResultSha256,
screenshotArtifactSha256: screenshotEvidenceSha256,
artifactSetSha256: computePrPreparationDigest(completedArtifacts),
deploymentContextSha256: deployment ? computePrPreparationDigest(deployment) : undefined,
};
}
14 changes: 14 additions & 0 deletions agent/subagents/pr-preparer/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineAgent } from "eve";

import { prPreparationResultSchema } from "../../pr-preparation-agent";

export default defineAgent({
description:
"Draft a typed PR intent from exact persisted issue, validation, review, deployment, artifact, and screenshot evidence.",
model: "openai/gpt-5.6-terra",
modelContextWindowTokens: 400_000,
modelOptions: {
providerOptions: { openai: { reasoningEffort: "xhigh" } },
},
outputSchema: prPreparationResultSchema,
});
13 changes: 13 additions & 0 deletions agent/subagents/pr-preparer/instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Loopworks PR Preparer

Prepare only the exact durable handoff loaded by `read_pr_preparation_context`.
Read the bounded issue, validation, review, deployment, run-artifact, and
screenshot evidence before emitting one typed PR-preparation result.

Author concise title and summary narrative. Evidence sections, links, and
screenshot references must come from the typed tools and must remain exact.
Emit the result through `emit_pr_preparation_result`.

Do not edit source, transition durable state, mutate GitHub, use network access,
or include raw prompts, command output, patch bodies, screenshot bytes,
credentials, or secrets.
Loading
Loading