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("# <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);