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
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,26 +1,44 @@
# Auth.js session secret; generate with `npx auth secret` or `openssl rand -base64 32`.
AUTH_SECRET="replace-with-auth-secret"
# GitHub OAuth app credentials for SSO sign-in.
AUTH_GITHUB_ID="replace-with-github-oauth-client-id"
AUTH_GITHUB_SECRET="replace-with-github-oauth-client-secret"
# "true" skips GitHub SSO authorization entirely. Local development only.
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=""
# Per-loop kill switches for webhook-triggered loop starts.
LOOPWORKS_AGENT_READY_LOOP_ENABLED="true"
LOOPWORKS_DEVELOPMENT_LOOP_ENABLED="true"
LOOPWORKS_RESEARCH_LOOP_ENABLED="true"
# "fixtures" serves canned portal data (non-production only); empty uses the database.
LOOPWORKS_PORTAL_DATA_MODE=""
# HMAC secret for signing test-run receipts passed between subagents and the orchestrator.
LOOPWORKS_EVE_TEST_RECEIPT_SECRET="replace-with-test-receipt-secret"
# "true" makes these subagents replay recorded fixtures instead of calling the model.
LOOPWORKS_EVE_TEST_WRITER_FIXTURE_MODE="false"
LOOPWORKS_EVE_IMPLEMENTER_FIXTURE_MODE="false"
# Pino log level: trace|debug|info|warn|error|fatal.
LOG_LEVEL="info"
# Default matches the local Postgres bootstrap; demo seeding refuses non-local databases.
DATABASE_URL="postgres://loopworks:loopworks@127.0.0.1:5432/loopworks"
# Standard OTLP exporter config; leave OTEL_EXPORTER_OTLP_ENDPOINT empty to disable export.
OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
OTEL_EXPORTER_OTLP_ENDPOINT=""
# Auth headers for the traces/metrics backend, e.g. "Authorization=Bearer <token>".
OTEL_EXPORTER_OTLP_TRACES_HEADERS=""
OTEL_EXPORTER_OTLP_METRICS_HEADERS=""
OTEL_SERVICE_NAME="loopworks"
# Extra key=value resource pairs, e.g. "deployment.environment=dev".
OTEL_RESOURCE_ATTRIBUTES=""
# GitHub App used for webhook intake and PR automation.
GITHUB_APP_ID=""
# App PEM key; escape newlines as \n when set on a single line.
GITHUB_APP_PRIVATE_KEY=""
# Must match the webhook secret configured on the GitHub App.
GITHUB_WEBHOOK_SECRET="dev-webhook-secret"
# Vercel API token and team for deployment visibility; leave empty to disable.
VERCEL_ACCESS_TOKEN=""
VERCEL_TEAM_ID=""
VERCEL_TEAM_SLUG=""
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Copy `.env.example` to `.env.local` for local development. The fixture server on
- `LOOPWORKS_PORTAL_DATA_MODE`
- `LOOPWORKS_EVE_TEST_RECEIPT_SECRET`
- `LOOPWORKS_EVE_TEST_WRITER_FIXTURE_MODE`
- `LOOPWORKS_EVE_IMPLEMENTER_FIXTURE_MODE`
- `LOG_LEVEL`
- `DATABASE_URL`
- `OTEL_EXPORTER_OTLP_PROTOCOL`
Expand Down
178 changes: 178 additions & 0 deletions agent/implementation-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { createHash } from "node:crypto";

import { z } from "zod";

import { canonicalJsonStringify } from "./lib/canonical-json";

export const implementationAgentModelLabel = "openai/gpt-5.6-terra-xhigh";
export const implementationResultSchemaId = "loopworks.implementation_result.v1";
export const maxImplementationPatchBytes = 512 * 1024;

const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/);
const identifierSchema = z.string().regex(/^[a-z0-9][a-z0-9-]*$/);

export function computeImplementationDigest(value: unknown): string {
return createHash("sha256").update(canonicalJsonStringify(value)).digest("hex");
}

const forbiddenProductionSegments = new Set([
".git",
".loopworks",
"node_modules",
"test",
"tests",
"eval",
"evals",
"fixture",
"fixtures",
"story",
"stories",
".github",
".circleci",
]);

const forbiddenProductionFilePattern =
/^(?:package\.json|bun\.lockb?|pnpm-lock\.yaml|package-lock\.json|yarn\.lock|AGENTS\.md|CLAUDE\.md|\.env(?:\..*)?)$/i;

export function isAllowedProductionArtifactPath(path: string): boolean {
const normalized = path.trim().replaceAll("\\", "/");
const segments = normalized.split("/");
const filename = segments.at(-1) ?? "";
return (
normalized.length > 0 &&
!normalized.startsWith("/") &&
!/^[A-Za-z]:\//.test(normalized) &&
!normalized.includes("//") &&
!/[;&|`$<>\n\r\0]/.test(normalized) &&
!segments.some((segment) => segment === "." || segment === "..") &&
!segments.some((segment) => forbiddenProductionSegments.has(segment.toLowerCase())) &&
!forbiddenProductionFilePattern.test(filename) &&
!/\.(?:test|spec|stories)\.[^/]+$/i.test(filename)
);
}

function patchHeaderPaths(content: string): string[] {
const paths = new Set<string>();
for (const line of content.split(/\r?\n/)) {
const diffMatch = line.match(/^diff --git a\/(.+) b\/(.+)$/);
if (diffMatch) {
if (diffMatch[1]) paths.add(diffMatch[1]);
if (diffMatch[2]) paths.add(diffMatch[2]);
continue;
}
const headerMatch = line.match(/^(?:---|\+\+\+) (?:[ab]\/(.+)|\/dev\/null)$/);
if (headerMatch?.[1]) paths.add(headerMatch[1]);
}
return [...paths];
}

const outputReferenceSchema = z
.object({
uri: z.string().min(1),
sha256: sha256Schema,
byteCount: z.number().int().nonnegative(),
redacted: z.literal(true),
})
.strict();

const greenEvidenceSchema = z
.object({
id: identifierSchema,
testId: identifierSchema,
acceptanceCriterionIds: z.array(identifierSchema).min(1),
command: z.string().min(1),
testPath: z.string().min(1),
outcome: z.literal("pass"),
exitCode: z.literal(0),
durationMs: z.number().int().nonnegative(),
executionReceipt: sha256Schema,
outputReference: outputReferenceSchema,
})
.strict();

export const implementationResultSchema = z
.object({
version: z.literal(1),
schemaId: z.literal(implementationResultSchemaId),
model: z.literal(implementationAgentModelLabel),
binding: z
.object({
planId: z.string().min(1),
planSha256: sha256Schema,
testPlanSha256: sha256Schema,
testPatchSha256: sha256Schema,
fixturesSha256: sha256Schema,
repositoryFullName: z.string().min(1),
commitSha: z.string().regex(/^[a-f0-9]{40}$/),
})
.strict(),
patch: z
.object({
format: z.literal("unified-diff"),
content: z.string().min(1),
sha256: sha256Schema,
byteCount: z.number().int().positive().max(maxImplementationPatchBytes),
paths: z.array(z.string().refine(isAllowedProductionArtifactPath)).min(1),
})
.strict(),
greenEvidence: z.array(greenEvidenceSchema).min(1),
validationEvidence: z
.object({
command: z.literal("bun run validate"),
outcome: z.literal("pass"),
exitCode: z.literal(0),
durationMs: z.number().int().nonnegative(),
executionReceipt: sha256Schema,
outputReference: outputReferenceSchema,
})
.strict(),
})
.strict()
.superRefine((result, ctx) => {
for (const [label, values] of [
["evidence", result.greenEvidence.map(({ id }) => id)],
["test", result.greenEvidence.map(({ testId }) => testId)],
] as const) {
const duplicate = values.find((value, index) => values.indexOf(value) !== index);
if (duplicate) ctx.addIssue({ code: "custom", message: `Duplicate ${label} ${duplicate}.` });
}

const parsedPaths = patchHeaderPaths(result.patch.content);
const declaredPaths = new Set(result.patch.paths);
if (parsedPaths.length === 0) {
ctx.addIssue({ code: "custom", message: "Patch must contain parseable diff headers." });
}
if (
/^(?:rename|copy) (?:from|to) |^GIT binary patch$|^(?:new file mode|old mode|new mode) 120000$/m.test(
result.patch.content,
)
) {
ctx.addIssue({ code: "custom", message: "Patch contains a forbidden operation." });
}
for (const path of parsedPaths) {
if (!declaredPaths.has(path) || !isAllowedProductionArtifactPath(path)) {
ctx.addIssue({ code: "custom", message: `Patch contains unsafe path ${path}.` });
}
}
for (const path of declaredPaths) {
if (!parsedPaths.includes(path)) {
ctx.addIssue({ code: "custom", message: `Declared path ${path} is absent from patch.` });
}
}
if (Buffer.byteLength(result.patch.content) !== result.patch.byteCount) {
ctx.addIssue({ code: "custom", message: "Patch byte count does not match content." });
}
if (createHash("sha256").update(result.patch.content).digest("hex") !== result.patch.sha256) {
ctx.addIssue({ code: "custom", message: "Patch digest does not match content." });
}
});

export type ImplementationResult = z.infer<typeof implementationResultSchema>;

export function createImplementationArtifactContractMetadata() {
return {
expectedImplementationResultSchemaId: implementationResultSchemaId,
implementationMetadataKind: "implementation_result_contract" as const,
implementationVersion: 1 as const,
};
}
106 changes: 106 additions & 0 deletions agent/implementation-fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { createHash } from "node:crypto";

import { createPlanningAgentSeedPlan } from "./planning-agent";
import {
computeTestPlanDigest,
redTestEvidenceSchemaId,
testPlanSchemaId,
} from "./test-writing-agent";

const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");

export function createImplementationFixtureHandoff() {
const plan = createPlanningAgentSeedPlan({
body: [
"## Acceptance Criteria",
"- The exact hashed test patch turns green with a scoped production patch.",
"- Test-writing fixtures and seed data are reused unchanged.",
"- Future model, prompt, and tool changes have eval coverage.",
].join("\n"),
issueNumber: 48,
labels: ["area:agents"],
milestone: null,
repositoryFullName: "ncolesummers/loopworks",
repositoryRevision: { commitSha: "a".repeat(40), ref: "main" },
title: "Implementation subagent for the development loop",
});
const testPatch = [
"diff --git a/tests/unit/agent/implementation-fixture.test.ts b/tests/unit/agent/implementation-fixture.test.ts",
"new file mode 100644",
"--- /dev/null",
"+++ b/tests/unit/agent/implementation-fixture.test.ts",
"@@ -0,0 +1 @@",
"+expect(implementationReady).toBe(true);",
].join("\n");
const testPlan = {
version: 1 as const,
schemaId: testPlanSchemaId,
plan: {
id: plan.identity.id,
sha256: plan.identity.sha256,
repositoryFullName: plan.issue.repositoryFullName,
commitSha: plan.repositoryRevision?.commitSha ?? "a".repeat(40),
},
acceptanceCriteria: plan.issue.acceptanceCriteria.map((text, index) => ({
id: `ac-${index + 1}`,
text,
})),
tests: [
{
id: "test-implementation-green",
acceptanceCriterionIds: ["ac-1", "ac-2", "ac-3"],
type: "unit" as const,
path: "tests/unit/agent/implementation-fixture.test.ts",
command: "bun run test -- tests/unit/agent/implementation-fixture.test.ts",
steps: ["Run the exact focused implementation fixture."],
expectedFailure: {
kind: "assertion" as const,
message: "expected false to be true",
},
fixtureIds: ["approved-plan"],
},
],
fixtures: [
{
id: "approved-plan",
kind: "fixture" as const,
description: "Approved-plan fixture authored by the test-writing stage.",
data: { approved: true },
},
],
patch: {
format: "unified-diff" as const,
content: testPatch,
sha256: sha256(testPatch),
byteCount: Buffer.byteLength(testPatch),
paths: ["tests/unit/agent/implementation-fixture.test.ts"],
},
};
const redEvidence = {
version: 1 as const,
schemaId: redTestEvidenceSchemaId,
planId: plan.identity.id,
planSha256: plan.identity.sha256,
testPlanSha256: computeTestPlanDigest(testPlan),
results: [
{
id: "red-implementation-green",
testId: "test-implementation-green",
acceptanceCriterionIds: ["ac-1", "ac-2", "ac-3"],
command: "bun run test -- tests/unit/agent/implementation-fixture.test.ts",
outcome: "expected_failure" as const,
exitCode: 1,
durationMs: 10,
expectedAssertion: "expected false to be true",
executionReceipt: "b".repeat(64),
outputReference: {
uri: "artifact://fixture/red.log",
sha256: "c".repeat(64),
byteCount: 10,
redacted: true as const,
},
},
],
};
return { plan, redEvidence, testPlan };
}
7 changes: 4 additions & 3 deletions agent/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ You are Loopworks' neutral stage orchestrator.

Read durable run state and delegate exactly one stage to the matching declared
subagent. Planning belongs to `planner`; approved test writing belongs to
`test-writer`. Stage subagents have isolated sandboxes and communicate only with
typed artifacts.
`test-writer`; development belongs to `implementer`. Stage subagents have
isolated sandboxes and communicate only with typed artifacts.

Always begin with `read_run_stage_context`. After planner delegation, call
`record_plan_artifact`; after test-writer delegation, call
`apply_test_writing_result`. A subagent response alone never changes durable
`apply_test_writing_result`; after implementer delegation, call
`apply_implementation_result`. A subagent response alone never changes durable
state.

Never infer approval from a prompt. Test writing requires a persisted
Expand Down
Loading
Loading