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
62 changes: 59 additions & 3 deletions src/devloop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,18 @@ export function hasPassingMatrix(review: string, count: number) {
).every((r) => r.test(review));
}

export function reportFraming(specText: string, slug: string) {
const title = reportTitle(specText) ?? titleFromSlug(slug);
return {
title,
subtitle:
sectionLead(specText, "Outcome") ??
sectionLead(specText, "Problem") ??
sectionLead(specText, "Acceptance criteria") ??
`Outcome, review findings, and residual risk for ${title}.`,
};
}

export function findingsHash(review: string) {
const body =
review.match(/^## Findings\s*\n([\s\S]*?)(?:\n##\s+|$)/m)?.[1] ?? "";
Expand Down Expand Up @@ -505,6 +517,7 @@ export async function runDevloop(
await synthesizeReport(runner, repo, {
slug,
spec: runSpec,
specText,
sourceSpec: spec,
sourceRepo,
worktree: repo,
Expand Down Expand Up @@ -937,6 +950,7 @@ async function synthesizeReport(
input: {
slug: string;
spec: string;
specText: string;
sourceSpec: string;
sourceRepo: string;
worktree: string;
Expand All @@ -956,6 +970,7 @@ async function synthesizeReport(
reviews: string;
},
) {
const framing = reportFraming(input.specText, input.slug);
const metadata = `Result: ${input.status}
Passes: ${input.pass} / ${input.max}
Repository: ${repo}
Expand All @@ -975,8 +990,8 @@ Reviews:
${input.reviews}`;
const body =
input.format === "html"
? `Write the report to ${input.report} as valid standalone HTML. Use a readable document layout with embedded CSS, a compact metadata table at the top, and substantive sections after it. Include these visible section headings: Metadata, The shape of the problem, What was built, What the review caught (and why it mattered), What to remember next time, Residual risk, Pointers. Do not optimize away substance: explain the decisions, tradeoffs, evidence, and transferable lessons clearly enough that the reader learns from the run.`
: `Write the report to ${input.report} in markdown with these headings: Metadata, The shape of the problem, What was built, What the review caught (and why it mattered), What to remember next time, Residual risk, Pointers. Do not optimize away substance: explain the decisions, tradeoffs, evidence, and transferable lessons clearly enough that the reader learns from the run.`;
? `Write the report to ${input.report} as valid standalone HTML. Use a readable document layout with embedded CSS, set the HTML <title> to the report title, render the report title and subtitle before Metadata, render a topical three-line haiku immediately after the subtitle, use a compact metadata table, and add substantive sections after it. Include these visible section headings: Metadata, The shape of the problem, What was built, What the review caught (and why it mattered), What to remember next time, Residual risk, Pointers. Do not optimize away substance: explain the decisions, tradeoffs, evidence, and transferable lessons clearly enough that the reader learns from the run.`
: `Write the report to ${input.report} in markdown. Start with the report title as the H1, put the subtitle directly below it, put a topical three-line haiku immediately after the subtitle, then include these headings: Metadata, The shape of the problem, What was built, What the review caught (and why it mattered), What to remember next time, Residual risk, Pointers. Do not optimize away substance: explain the decisions, tradeoffs, evidence, and transferable lessons clearly enough that the reader learns from the run.`;
const sessionFile = path.join(
repo,
`.codex/sessions/${input.slug}-claude.id`,
Expand All @@ -1002,13 +1017,54 @@ ${input.reviews}`;
"--add-dir",
repo,
],
`You are writing a learning-oriented post-mortem for a developer who just ran a Codex/Claude devloop.\n\nMetadata to render at the top exactly and visibly:\n${metadata}\n\nInputs:\n- spec: ${input.spec}\n- track: ${input.track}\nReview files:\n${input.reviews}\n- final status: ${input.status}\n- passes used: ${input.pass} / ${input.max}\n- base: ${input.base}, starting branch: ${input.initialBranch}, final branch: ${input.branch}, local commit: ${input.commit || "none"}\n\n${body}\n\nStyle:\n- Human readable, not ornamental.\n- Preserve useful substance over brevity.\n- Teach the why: symptom, root cause, principle, decision, tradeoff, and evidence.\n- No emoji.\n`,
`You are writing a learning-oriented post-mortem for a developer who just ran a Codex/Claude devloop.\n\nReport framing to render visibly near the top, before Metadata:\nTitle: ${framing.title}\nSubtitle: ${framing.subtitle}\nHaiku: Compose a three-line haiku, 5/7/5 syllables if possible, about this specific work.\nHaiku topic: ${framing.title} - ${framing.subtitle}\n\nUse that exact title and subtitle. The subtitle must be specific to this work, not a generic or hard-coded tagline. The haiku must be topical, concrete, and rendered immediately after the subtitle before Metadata.\n\nMetadata to render exactly and visibly:\n${metadata}\n\nInputs:\n- spec: ${input.spec}\n- track: ${input.track}\nReview files:\n${input.reviews}\n- final status: ${input.status}\n- passes used: ${input.pass} / ${input.max}\n- base: ${input.base}, starting branch: ${input.initialBranch}, final branch: ${input.branch}, local commit: ${input.commit || "none"}\n\n${body}\n\nStyle:\n- Human readable, not ornamental.\n- Preserve useful substance over brevity.\n- Teach the why: symptom, root cause, principle, decision, tradeoff, and evidence.\n- No emoji.\n`,
path.join(repo, `.codex/logs/${input.slug}-report.log`),
"report",
);
if (!session) await writeLine(sessionFile, next);
}

function reportTitle(specText: string) {
for (const line of specText.split(/\r?\n/)) {
const match = line.match(/^#\s+(.+)$/);
const title = cleanReportText(match?.[1] ?? "");
if (title) return title;
}
return undefined;
}

function sectionLead(
specText: string,
heading: "Outcome" | "Problem" | "Acceptance criteria",
) {
const lines = specText.split(/\r?\n/);
const headingPattern = new RegExp(`^##\\s+${heading}\\s*$`, "i");
const start = lines.findIndex((line) => headingPattern.test(line.trim()));
if (start < 0) return undefined;
for (const line of lines.slice(start + 1)) {
if (/^##\s+/.test(line)) return undefined;
const text = cleanReportText(line.replace(/^([-*]|\d+[.)])\s+/, ""));
if (text) return text;
}
return undefined;
}

function cleanReportText(value: string) {
const text = value.trim().replace(/\s+/g, " ");
if (!text || text === "..." || /^<.*>$/.test(text)) return undefined;
return text;
}

function titleFromSlug(slug: string) {
return (
slug
.split("-")
.filter(Boolean)
.map((part) => part[0]!.toUpperCase() + part.slice(1))
.join(" ") || "Devloop Report"
);
}

function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
Expand Down
32 changes: 31 additions & 1 deletion tests/devloop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { parseArgs, parseCriteria, parseVerdict, runDevloop, welcome, type Event, type Options } from "../src/devloop.ts";
import { parseArgs, parseCriteria, parseVerdict, reportFraming, runDevloop, welcome, type Event, type Options } from "../src/devloop.ts";

const root = await mkdtemp(path.join(tmpdir(), "devloop-test."));
let oldPath = process.env.PATH ?? "";
Expand Down Expand Up @@ -47,6 +47,30 @@ describe("parsing", () => {
expect(parseVerdict("No verdict here\n")).toBe("");
});

test("derives report framing from the spec", () => {
expect(
reportFraming(
"# Add chat retries\n\n## Problem\nUsers lose messages when the transport flakes.\n\n## Outcome\nFailed sends retry without duplicating messages.\n",
"chat-retries",
),
).toEqual({
title: "Add chat retries",
subtitle: "Failed sends retry without duplicating messages.",
});
expect(reportFraming("# Config fallback\n\n## Problem\n- Missing config crashes startup.\n", "config-fallback")).toEqual({
title: "Config fallback",
subtitle: "Missing config crashes startup.",
});
expect(reportFraming("# <Concise title>\n\n## Acceptance criteria\n1. First useful criterion.\n", "fallback-slug")).toEqual({
title: "Fallback Slug",
subtitle: "First useful criterion.",
});
expect(reportFraming("# <Concise title>\n\n## Outcome\n<The observable end state>\n", "fallback-slug")).toEqual({
title: "Fallback Slug",
subtitle: "Outcome, review findings, and residual risk for Fallback Slug.",
});
});

test("renders a useful default screen", () => {
expect(welcome()).toContain("____/ /__");
expect(welcome()).toContain("Common commands:");
Expand Down Expand Up @@ -92,6 +116,12 @@ describe("loop", () => {
expect(reportPrompt).toContain(`Worktree: ${worktree}`);
expect(reportPrompt).toContain(`Local commit: ${result.commit}`);
expect(reportPrompt).toContain("Commit message: feat: change");
expect(reportPrompt).toContain("Title: Fixture spec");
expect(reportPrompt).toContain("Subtitle: The loop runs deterministically under test.");
expect(reportPrompt).toContain("Haiku: Compose a three-line haiku");
expect(reportPrompt).toContain("Haiku topic: Fixture spec - The loop runs deterministically under test.");
expect(reportPrompt).toContain("rendered immediately after the subtitle before Metadata");
expect(reportPrompt).toContain("The subtitle must be specific to this work");
expect(events).toContainEqual({ type: "done", id: "naming", ok: true, detail: "feat/change" });
expect(events).toContainEqual({ type: "done", id: "worktree", ok: true, detail: worktree });
expect(events.some((event) => event.type === "gate" && event.name === "acceptance criteria" && event.ok)).toBe(true);
Expand Down