diff --git a/src/devloop.ts b/src/devloop.ts
index 9ef8c10..9ac3323 100644
--- a/src/devloop.ts
+++ b/src/devloop.ts
@@ -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] ?? "";
@@ -505,6 +517,7 @@ export async function runDevloop(
await synthesizeReport(runner, repo, {
slug,
spec: runSpec,
+ specText,
sourceSpec: spec,
sourceRepo,
worktree: repo,
@@ -937,6 +950,7 @@ async function synthesizeReport(
input: {
slug: string;
spec: string;
+ specText: string;
sourceSpec: string;
sourceRepo: string;
worktree: string;
@@ -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}
@@ -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
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`,
@@ -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));
}
diff --git a/tests/devloop.test.ts b/tests/devloop.test.ts
index aa81a66..14a4c66 100644
--- a/tests/devloop.test.ts
+++ b/tests/devloop.test.ts
@@ -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 ?? "";
@@ -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("# \n\n## Acceptance criteria\n1. First useful criterion.\n", "fallback-slug")).toEqual({
+ title: "Fallback Slug",
+ subtitle: "First useful criterion.",
+ });
+ expect(reportFraming("# \n\n## Outcome\n\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:");
@@ -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);