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 <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`,
@@ -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("# <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:");
@@ -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);
