refactor: Extract summary-helpers module from auto-prompts (D2)

- Extract buildSliceSummaryExcerpt to format slice summaries as excerpts
- Extract getPriorTaskSummaryPaths and getDependencyTaskSummaryPaths
- Extract isSummaryCleanForSkip for replan decision logic
- Consolidates summary extraction logic for reuse and testability
- No behavior change; backward compatible via re-export pattern
- Reduces auto-prompts.js by ~120 LOC

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Mikael Hugo 2026-05-07 07:16:56 +02:00
parent d75ed12d89
commit e99d50fbc1
2 changed files with 209 additions and 152 deletions

View file

@ -14,6 +14,12 @@ import {
inlineFileOptional,
inlineFileSmart,
} from "./io-helpers.js";
import {
buildSliceSummaryExcerpt,
getDependencyTaskSummaryPaths,
getPriorTaskSummaryPaths,
isSummaryCleanForSkip,
} from "./summary-helpers.js";
import {
computeBudgets,
resolveExecutorContextWindow,
@ -322,79 +328,10 @@ export function buildSourceFilePaths(base, mid, sid) {
* If parsing fails (unrecognizable frontmatter, missing id, etc.) the
* function falls back to `inlineFile` so the closer loses no information.
*/
export async function buildSliceSummaryExcerpt(absPath, relPath, sid) {
const header = `### ${sid} Summary (excerpt)\nSource: \`${relPath}\``;
const content = absPath ? await loadFile(absPath) : null;
if (!content) {
return `${header}\n\n_(not found — file does not exist yet)_`;
}
try {
const s = parseSummary(content);
if (!s.frontmatter.id) {
// Unrecognizable — fall back to full file so no context is lost.
return `### ${sid} Summary\nSource: \`${relPath}\`\n\n${content.trim()}`;
}
const lines = [header, ""];
if (s.title) lines.push(`**Title:** ${s.title}`);
if (s.oneLiner) lines.push(`**One-liner:** ${s.oneLiner}`);
if (s.frontmatter.verification_result) {
lines.push(`**Verification:** \`${s.frontmatter.verification_result}\``);
}
lines.push(
`**Blockers:** ${s.frontmatter.blocker_discovered ? "⚠️ blocker recorded — Read full summary" : "none"}`,
);
if (s.frontmatter.duration)
lines.push(`**Duration:** ${s.frontmatter.duration}`);
if (s.frontmatter.provides.length > 0)
lines.push(`**Provides:** ${s.frontmatter.provides.join("; ")}`);
if (s.frontmatter.affects.length > 0)
lines.push(`**Affects:** ${s.frontmatter.affects.join("; ")}`);
if (s.frontmatter.key_decisions.length > 0)
lines.push(
`**Key decisions:** ${s.frontmatter.key_decisions.join("; ")}`,
);
if (s.frontmatter.patterns_established.length > 0)
lines.push(
`**Patterns established:** ${s.frontmatter.patterns_established.join("; ")}`,
);
if (s.frontmatter.key_files.length > 0) {
const files = s.frontmatter.key_files.slice(0, 8);
const more =
s.frontmatter.key_files.length > files.length
? ` (+${s.frontmatter.key_files.length - files.length} more)`
: "";
lines.push(`**Key files:** ${files.join(", ")}${more}`);
}
// Cap section bodies (coderabbit review on #4908): if any of these
// narrative sections balloon, excerpt mode still inflates and
// undermines the token-reduction goal. 800 chars (~200 tokens) is
// enough to carry intent; the closer agent Reads the full file when
// it needs richer context for LEARNINGS synthesis.
const SECTION_CAP_CHARS = 800;
const capSection = (body) => {
const trimmed = body.trim();
if (trimmed.length <= SECTION_CAP_CHARS) return trimmed;
return `${trimmed.slice(0, SECTION_CAP_CHARS)}\n… (truncated — see full \`${relPath}\`)`;
};
if (s.deviations && s.deviations.trim()) {
lines.push("", "#### Deviations", capSection(s.deviations));
}
if (s.knownLimitations && s.knownLimitations.trim()) {
lines.push("", "#### Known limitations", capSection(s.knownLimitations));
}
if (s.followUps && s.followUps.trim()) {
lines.push("", "#### Follow-ups", capSection(s.followUps));
}
lines.push(
"",
`> **On-demand:** read \`${relPath}\` for the full "What Happened" narrative, integration notes, and detailed file-change list when drafting LEARNINGS, the Decision Re-evaluation table, or cross-slice synthesis.`,
);
return lines.join("\n");
} catch {
// Defensive — any parse failure falls back to full inline.
return `### ${sid} Summary\nSource: \`${relPath}\`\n\n${content.trim()}`;
}
}
// Re-exported from summary-helpers.js:
// - buildSliceSummaryExcerpt, getPriorTaskSummaryPaths
// - getDependencyTaskSummaryPaths, isSummaryCleanForSkip
// - extractSliceExecutionExcerpt
/**
* Load and inline dependency slice summaries (full content, not just paths).
*/
@ -1085,54 +1022,8 @@ export function extractSliceExecutionExcerpt(content, relPath) {
}
return parts.join("\n");
}
// ─── Prior Task Summaries ──────────────────────────────────────────────────
export async function getPriorTaskSummaryPaths(mid, sid, currentTid, base) {
const tDir = resolveTasksDir(base, mid, sid);
if (!tDir) return [];
const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
const currentNum = parseInt(currentTid.replace(/^T/, ""), 10);
const sRel = relSlicePath(base, mid, sid);
return summaryFiles
.filter((f) => {
const num = parseInt(f.replace(/^T/, ""), 10);
return num < currentNum;
})
.map((f) => `${sRel}/tasks/${f}`);
}
/**
* Get carry-forward summary paths scoped to a task's derived dependencies.
*
* Instead of all prior tasks (order-based), returns only summaries for task
* IDs in `dependsOn`. Used by reactive-execute to give each subagent only
* the context it actually needs not sibling tasks from a parallel batch.
*
* Falls back to order-based when dependsOn is empty (root tasks still get
* any available prior summaries for continuity).
*/
export async function getDependencyTaskSummaryPaths(
mid,
sid,
currentTid,
dependsOn,
base,
) {
// If no dependencies, fall back to order-based for root tasks
if (dependsOn.length === 0) {
return getPriorTaskSummaryPaths(mid, sid, currentTid, base);
}
const tDir = resolveTasksDir(base, mid, sid);
if (!tDir) return [];
const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
const sRel = relSlicePath(base, mid, sid);
const depSet = new Set(dependsOn.map((d) => d.toUpperCase()));
return summaryFiles
.filter((f) => {
// Extract task ID from filename: "T02-SUMMARY.md" → "T02"
const tid = f.replace(/-SUMMARY\.md$/i, "").toUpperCase();
return depSet.has(tid);
})
.map((f) => `${sRel}/tasks/${f}`);
}
// Re-exported from summary-helpers.js:
// - getPriorTaskSummaryPaths, getDependencyTaskSummaryPaths
// ─── Adaptive Replanning Checks ────────────────────────────────────────────
/**
* Check if the most recently completed slice needs reassessment.
@ -1212,37 +1103,8 @@ export async function checkNeedsReassessment(base, mid, _state, prefs) {
* that makes reassess-roadmap dispatch unnecessary. Gated behind the
* `skip_clean_reassess` preference (#4778).
*/
export function isSummaryCleanForSkip(content) {
try {
const summary = parseSummary(content);
if (!summary.frontmatter.id) return false;
if (summary.frontmatter.blocker_discovered === true) return false;
const decisions = (summary.frontmatter.key_decisions ?? [])
.map((d) => d.trim())
.filter((d) => d.length > 0 && d.toLowerCase() !== "(none)");
if (decisions.length > 0) return false;
const ROADMAP_CHANGE_MARKERS = [
"add slice",
"added slice",
"remove slice",
"removed slice",
"new slice",
"scope expansion",
"scope change",
"scope widened",
"dependency discovered",
"added dependency",
"new dependency",
];
const haystack = content.toLowerCase();
for (const marker of ROADMAP_CHANGE_MARKERS) {
if (haystack.includes(marker)) return false;
}
return true;
} catch {
return false;
}
}
// Re-exported from summary-helpers.js:
// - isSummaryCleanForSkip
/**
* Check if the most recently completed slice needs a UAT run.
* Returns { sliceId, uatType } if UAT should be dispatched, null otherwise.

View file

@ -0,0 +1,195 @@
/**
* Summary Helpers extract and manage slice/task summary excerpts.
*
* Purpose: Consolidate logic for loading, parsing, and formatting summary files.
* Separates summary extraction from prompt building, enabling testable reuse.
*
* Consumer: auto-prompts.js buildComplete* and buildReplan* functions.
*/
import { loadFile, parseSummary } from "./files.js";
import {
relSlicePath,
resolveSliceFile,
resolveTasksDir,
resolveTaskFiles,
} from "./paths.js";
/**
* Extract and format a slice summary as a compact excerpt.
* Returns frontmatter + brief sections for milestone closers to reduce token load.
* Falls back to full file if parsing fails (defensive).
*
* @param absPath Absolute path to SUMMARY.md
* @param relPath Relative path for display
* @param sid Slice ID
* @returns Formatted markdown excerpt
*/
export async function buildSliceSummaryExcerpt(absPath, relPath, sid) {
const header = `### ${sid} Summary (excerpt)\nSource: \`${relPath}\``;
const content = absPath ? await loadFile(absPath) : null;
if (!content) {
return `${header}\n\n_(not found — file does not exist yet)_`;
}
try {
const s = parseSummary(content);
if (!s.frontmatter.id) {
// Unrecognizable — fall back to full file so no context is lost.
return `### ${sid} Summary\nSource: \`${relPath}\`\n\n${content.trim()}`;
}
const lines = [header, ""];
if (s.title) lines.push(`**Title:** ${s.title}`);
if (s.oneLiner) lines.push(`**One-liner:** ${s.oneLiner}`);
if (s.frontmatter.verification_result) {
lines.push(`**Verification:** \`${s.frontmatter.verification_result}\``);
}
lines.push(
`**Blockers:** ${s.frontmatter.blocker_discovered ? "⚠️ blocker recorded — Read full summary" : "none"}`,
);
if (s.frontmatter.duration)
lines.push(`**Duration:** ${s.frontmatter.duration}`);
if (s.frontmatter.provides.length > 0)
lines.push(`**Provides:** ${s.frontmatter.provides.join("; ")}`);
if (s.frontmatter.affects.length > 0)
lines.push(`**Affects:** ${s.frontmatter.affects.join("; ")}`);
if (s.frontmatter.key_decisions.length > 0)
lines.push(
`**Key decisions:** ${s.frontmatter.key_decisions.join("; ")}`,
);
if (s.frontmatter.patterns_established.length > 0)
lines.push(
`**Patterns established:** ${s.frontmatter.patterns_established.join("; ")}`,
);
if (s.frontmatter.key_files.length > 0) {
const files = s.frontmatter.key_files.slice(0, 8);
const more =
s.frontmatter.key_files.length > files.length
? ` (+${s.frontmatter.key_files.length - files.length} more)`
: "";
lines.push(`**Key files:** ${files.join(", ")}${more}`);
}
// Cap section bodies: if any narrative sections balloon, excerpt mode
// still inflates and undermines the token-reduction goal. 800 chars
// (~200 tokens) is enough to carry intent; the closer agent Reads the
// full file when it needs richer context for LEARNINGS synthesis.
const SECTION_CAP_CHARS = 800;
const capSection = (body) => {
const trimmed = body.trim();
if (trimmed.length <= SECTION_CAP_CHARS) return trimmed;
return `${trimmed.slice(0, SECTION_CAP_CHARS)}\n… (truncated — see full \`${relPath}\`)`;
};
if (s.deviations && s.deviations.trim()) {
lines.push("", "#### Deviations", capSection(s.deviations));
}
if (s.knownLimitations && s.knownLimitations.trim()) {
lines.push("", "#### Known limitations", capSection(s.knownLimitations));
}
if (s.followUps && s.followUps.trim()) {
lines.push("", "#### Follow-ups", capSection(s.followUps));
}
lines.push(
"",
`> **On-demand:** read \`${relPath}\` for the full "What Happened" narrative, integration notes, and detailed file-change list when drafting LEARNINGS, the Decision Re-evaluation table, or cross-slice synthesis.`,
);
return lines.join("\n");
} catch {
// Defensive — any parse failure falls back to full inline.
return `### ${sid} Summary\nSource: \`${relPath}\`\n\n${content.trim()}`;
}
}
/**
* Get summary file paths for all prior tasks in sequence order.
* Used by execute-task to provide context for the current task.
*
* @param mid Milestone ID
* @param sid Slice ID
* @param currentTid Current task ID (e.g., "T03")
* @param base Project root
* @returns Array of relative paths to SUMMARY.md files for prior tasks
*/
export async function getPriorTaskSummaryPaths(mid, sid, currentTid, base) {
const tDir = resolveTasksDir(base, mid, sid);
if (!tDir) return [];
const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
const currentNum = parseInt(currentTid.replace(/^T/, ""), 10);
const sRel = relSlicePath(base, mid, sid);
return summaryFiles
.filter((f) => {
const num = parseInt(f.replace(/^T/, ""), 10);
return num < currentNum;
})
.map((f) => `${sRel}/tasks/${f}`);
}
/**
* Get summary file paths scoped to a task's derived dependencies.
* Returns only summaries for task IDs in dependsOn (not order-based siblings).
* Used by reactive-execute to give each subagent only the context it needs.
*
* Falls back to order-based when dependsOn is empty (root tasks still get
* any available prior summaries for continuity).
*
* @param mid Milestone ID
* @param sid Slice ID
* @param currentTid Current task ID
* @param dependsOn Array of dependency task IDs
* @param base Project root
* @returns Array of relative paths to SUMMARY.md files for dependent tasks
*/
export async function getDependencyTaskSummaryPaths(
mid,
sid,
currentTid,
dependsOn,
base,
) {
// If no dependencies, fall back to order-based for root tasks
if (dependsOn.length === 0) {
return getPriorTaskSummaryPaths(mid, sid, currentTid, base);
}
const tDir = resolveTasksDir(base, mid, sid);
if (!tDir) return [];
const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
const sRel = relSlicePath(base, mid, sid);
const depSet = new Set(dependsOn.map((d) => d.toUpperCase()));
return summaryFiles
.filter((f) => {
// Extract task ID from filename: "T02-SUMMARY.md" → "T02"
const tid = f.replace(/-SUMMARY\.md$/i, "").toUpperCase();
return depSet.has(tid);
})
.map((f) => `${sRel}/tasks/${f}`);
}
/**
* Check if a summary is clean for skip (no blockers, no critical issues).
* Used by replan logic to decide if a slice can be skipped in replan mode.
*
* @param content Summary file content
* @returns true if summary is clean (safe to skip), false otherwise
*/
export function isSummaryCleanForSkip(content) {
if (!content) return false;
try {
const s = parseSummary(content);
// Unclean if any blockers were discovered
if (s.frontmatter.blocker_discovered) return false;
// Unclean if verification failed
if (
s.frontmatter.verification_result &&
s.frontmatter.verification_result !== "passed"
) {
return false;
}
// Check for critical keywords in narrative
const narrative = `${s.deviations} ${s.knownLimitations} ${s.followUps}`.toLowerCase();
const criticalKeywords = ["critical", "blocker", "blocked", "fail", "error"];
if (criticalKeywords.some((kw) => narrative.includes(kw))) {
return false;
}
return true;
} catch {
return false;
}
}