// zense-harness module: spec version diff → change summary, spec markdown rendering, on-disk spec sync, fullscreen default (moved verbatim from index.ts — see AGENTS.md map) import { existsSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { type ThemeColor } from "@earendil-works/pi-coding-agent"; import { zenseDir, type Spec } from "./types.ts"; // ----------------------------------------------------------------------------- spec version change summary (module scope — exported for unit tests) /** Field-by-field diff between prev and next spec as human-readable summary lines. * Re-spec loop requirement: when eval/review fails and a new spec version is committed, * the signer must see exactly what changed — never re-present an identical spec silently. * Criteria diffed by id: added (+) / removed (−) / changed (~ text/check); list fields * (approach/scope/constraints/specDebt) diffed as sets. No changes at all → one warning * line (callers use it to detect identical specs). */ export const buildSpecChanges = (prev: Spec, next: Spec): string[] => { const lines: string[] = []; if (prev.title !== next.title) lines.push(`title: "${prev.title}" → "${next.title}"`); if (prev.intent !== next.intent) lines.push(`intent: changed (read the new text in the spec below)`); const listDiff = (label: string, a: string[], b: string[]) => { for (const x of b.filter((x) => !a.includes(x))) lines.push(`${label} +: ${x}`); for (const x of a.filter((x) => !b.includes(x))) lines.push(`${label} −: ${x}`); }; listDiff("approach", prev.approach ?? [], next.approach ?? []); listDiff("scope", prev.scope ?? [], next.scope ?? []); listDiff("constraints", prev.constraints ?? [], next.constraints ?? []); const prevById = new Map((prev.criteria ?? []).map((c) => [c.id, c])); const nextById = new Map((next.criteria ?? []).map((c) => [c.id, c])); for (const [id, c] of nextById) { const p = prevById.get(id); if (!p) lines.push(`criteria +: ${id}: ${c.text} (check: ${c.check})`); else if (p.text !== c.text || p.check !== c.check) lines.push( `criteria ~: ${id}` + (p.text !== c.text ? ` text: "${p.text}" → "${c.text}"` : "") + (p.check !== c.check ? ` check: "${p.check}" → "${c.check}"` : ""), ); } for (const [id, c] of prevById) if (!nextById.has(id)) lines.push(`criteria −: ${id}: ${c.text}`); listDiff("specDebt", prev.specDebt ?? [], next.specDebt ?? []); return lines.length ? lines : [`⚠️ No changes from v${prev.version} — the new spec is identical to the previous version`]; }; /** Group the flat changesFrom lines (from buildSpecChanges) into render-friendly sections. * Parses only the original line prefixes (persisted format untouched). Group order fixed by * CHANGE_GROUPS; empty groups hidden; ⚠️ identical-spec warnings render verbatim without a * heading; ungroupable lines land in `### Other` last. */ export const CHANGE_GROUPS: ReadonlyArray<[string, (line: string) => boolean]> = [ ["Title & intent", (l) => l.startsWith("title:") || l.startsWith("intent:")], ["Approach", (l) => l.startsWith("approach ")], ["Scope", (l) => l.startsWith("scope ")], ["Constraints", (l) => l.startsWith("constraints ")], ["Criteria", (l) => l.startsWith("criteria ")], ["Spec debt", (l) => l.startsWith("specDebt ")], ]; /** Bucket change lines by CHANGE_GROUPS (fixed order) — shared by groupSpecChanges (archive .md) * and renderSpecChangesTui (sign dialog) so the grouping logic never drifts. */ const bucketSpecChanges = (lines: string[]) => { const buckets = new Map(); const warns: string[] = []; const other: string[] = []; for (const line of lines) { if (line.startsWith("⚠️")) { warns.push(line); continue; } const group = CHANGE_GROUPS.find(([, match]) => match(line)); if (group) { const bucket = buckets.get(group[0]) ?? []; bucket.push(line); buckets.set(group[0], bucket); } else other.push(line); } return { buckets, warns, other }; }; export const groupSpecChanges = (lines: string[]): string => { const { buckets, warns, other } = bucketSpecChanges(lines); const numbered = (items: string[]) => items.map((x, i) => `${i + 1}. ${x}`).join("\n"); const parts: string[] = []; if (warns.length) parts.push(warns.join("\n")); for (const [heading] of CHANGE_GROUPS) { const items = buckets.get(heading); if (items?.length) parts.push(`### ${heading}\n${numbered(items)}`); } if (other.length) parts.push(`### Other\n${numbered(other)}`); return parts.join("\n\n"); }; /** Theme color for each change direction: + add = success / − remove = error / ~ change = warning */ export type ChangeMarkRole = Extract; /** Render the "## Changes in v{N}" section for the sign dialog (TUI only — the archive .md * keeps plain-text label+marker via groupSpecChanges since files can't show color): * items drop the redundant heading label ("approach +: x" → "1. x") and get colored by * direction instead. Grouping identical to the archive via bucketSpecChanges. * Returns unwrapped lines; [] when the spec has no changesFrom. */ export const renderSpecChangesTui = (spec: Spec, color: (role: ChangeMarkRole, text: string) => string): string[] => { if (!spec.changesFrom?.length) return []; const { buckets, warns, other } = bucketSpecChanges(spec.changesFrom); const ROLE = { "+": "success", "−": "error", "~": "warning" } as const; // "