* ReportContract validates the work-report markdown shape described in
* `docs/always-on/02-pilotdeck-always-on-rewrite-plan.md` §8.
*
* Unlike PlanContract this contract is forgiving — the runtime will fall back
* by appending placeholders so downstream tooling (UI, history) can still
* render the report. Each fallback adds an entry to the "Notes" section.
*/
import type { AlwaysOnDiscoveryOutcome, WorkspaceStrategyId } from "../protocol/types.js";
export type ReportMetadata = {
runId: string;
planId: string;
startedAt: string;
finishedAt: string;
outcome: AlwaysOnDiscoveryOutcome;
workspaceStrategy: WorkspaceStrategyId;
workspaceHandle: string;
};
export type ReportParseResult = {
title: string;
metadata: ReportMetadata;
sections: Record<string, string>;
fallbacks: string[];
rawContent: string;
};
export const REPORT_METADATA_FIRST_LINE = "Always-On Discovery Run Report";
export const REPORT_REQUIRED_SECTIONS: ReadonlyArray<string> = [
"Plan Reference",
"Steps Performed",
"Files Changed",
"Command Output",
"Verification Results",
"Follow-ups",
"Notes",
];
export type BuildFallbackReportInput = {
metadata: ReportMetadata;
title: string;
reason: string;
partial?: string;
};
export function buildFallbackReport(input: BuildFallbackReportInput): string {
const { metadata, title, reason } = input;
const lines = [
`# ${title} - Work Report`,
"",
`> ${REPORT_METADATA_FIRST_LINE}`,
`> runId: ${metadata.runId}`,
`> planId: ${metadata.planId}`,
`> startedAt: ${metadata.startedAt}`,
`> finishedAt: ${metadata.finishedAt}`,
`> outcome: ${metadata.outcome}`,
`> workspaceStrategy: ${metadata.workspaceStrategy}`,
`> workspaceHandle: ${metadata.workspaceHandle}`,
"",
"## Plan Reference",
"(unavailable: report tool was not invoked)",
"",
"## Steps Performed",
"(empty)",
"",
"## Files Changed",
"(none recorded)",
"",
"## Command Output",
"(none recorded)",
"",
"## Verification Results",
"- [ ] (unverified) - report tool was not invoked",
"",
"## Follow-ups",
"- Investigate why AlwaysOnReportTool was not called.",
"",
"## Notes",
`- fallback: ${reason}`,
];
if (input.partial && input.partial.trim().length > 0) {
lines.push("", "## Partial Tool Payload", input.partial.trim());
}
return lines.join("\n") + "\n";
}
export function parseReportMarkdown(
content: string,
metadata: ReportMetadata,
): ReportParseResult {
const fallbacks: string[] = [];
const normalized = content.replace(/\r\n/g, "\n");
const lines = normalized.split("\n");
let cursor = 0;
while (cursor < lines.length && lines[cursor].trim().length === 0) cursor += 1;
const titleLine = lines[cursor] ?? "";
let title: string;
if (titleLine.startsWith("# ")) {
title = titleLine.slice(2).trim();
cursor += 1;
} else {
title = "Always-On Discovery Run";
fallbacks.push("title-missing");
}
while (cursor < lines.length && lines[cursor].trim().length === 0) cursor += 1;
while (cursor < lines.length && lines[cursor].startsWith(">")) {
cursor += 1;
}
while (cursor < lines.length && lines[cursor].trim().length === 0) cursor += 1;
const sections: Record<string, string> = {};
let currentSection: string | null = null;
let buffer: string[] = [];
const flush = (): void => {
if (currentSection !== null) {
sections[currentSection] = buffer.join("\n").replace(/\s+$/u, "");
}
currentSection = null;
buffer = [];
};
for (; cursor < lines.length; cursor += 1) {
const line = lines[cursor];
if (line.startsWith("## ")) {
flush();
currentSection = line.slice(3).trim();
buffer = [];
continue;
}
if (currentSection !== null) {
buffer.push(line);
}
}
flush();
for (const section of REPORT_REQUIRED_SECTIONS) {
if (!Object.prototype.hasOwnProperty.call(sections, section)) {
sections[section] = section === "Notes" ? `- fallback: section-missing(${section})` : "(empty)";
fallbacks.push(`section-missing(${section})`);
}
}
if (fallbacks.length > 0) {
const noteLines = sections.Notes.split("\n");
for (const fallback of fallbacks) {
const entry = `- fallback: ${fallback}`;
if (!noteLines.includes(entry)) {
noteLines.push(entry);
}
}
sections.Notes = noteLines.join("\n");
}
return {
title,
metadata,
sections,
fallbacks,
rawContent: rebuildReport(title, metadata, sections),
};
}
export function rebuildReport(
title: string,
metadata: ReportMetadata,
sections: Record<string, string>,
): string {
const lines = [
`# ${title}`,
"",
`> ${REPORT_METADATA_FIRST_LINE}`,
`> runId: ${metadata.runId}`,
`> planId: ${metadata.planId}`,
`> startedAt: ${metadata.startedAt}`,
`> finishedAt: ${metadata.finishedAt}`,
`> outcome: ${metadata.outcome}`,
`> workspaceStrategy: ${metadata.workspaceStrategy}`,
`> workspaceHandle: ${metadata.workspaceHandle}`,
];
for (const section of REPORT_REQUIRED_SECTIONS) {
lines.push("", `## ${section}`, sections[section] ?? "(empty)");
}
for (const key of Object.keys(sections)) {
if (!REPORT_REQUIRED_SECTIONS.includes(key)) {
lines.push("", `## ${key}`, sections[key]);
}
}
return lines.join("\n") + "\n";
}