feat(sdk): reduce context prompt sizes with truncation and cache-friendly ordering (#1615)
* chore: add v1.31.0 npm known-issue notice to issue template config Adds a top-priority contact link to the issue template chooser so users are redirected to the Discussions announcement before opening a duplicate issue about v1.31.0 not being on npm. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(sdk): reduce context prompt sizes with truncation and cache-friendly ordering (#1614) - Reorder prompt assembly in PromptFactory to place stable content (role, workflow, phase instructions) before variable content (.planning/ files), enabling Anthropic prompt caching at 0.1x input cost on cache hits - Add markdown-aware truncation for oversized context files (headings + first paragraphs preserved, rest omitted with line counts) - Add ROADMAP.md milestone extraction to inject only the current milestone instead of the full roadmap - Export truncation utilities from SDK public API - 60 new + updated tests covering truncation, milestone extraction, cache-friendly ordering, and ContextEngine integration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
3
.github/ISSUE_TEMPLATE/config.yml
vendored
3
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,5 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: "⚠️ v1.31.0 not on npm yet (known issue — workaround inside)"
|
||||
url: https://github.com/gsd-build/get-shit-done/discussions
|
||||
about: v1.31.0 was not published to npm due to a hardware failure. Read the pinned announcement for the workaround before opening an issue.
|
||||
- name: Discord Community
|
||||
url: https://discord.gg/gsd
|
||||
about: Ask questions and get help from the community
|
||||
|
||||
@@ -184,6 +184,90 @@ describe('ContextEngine', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('context truncation', () => {
|
||||
it('truncates files exceeding maxContentLength', async () => {
|
||||
const largeContent = Array.from({ length: 100 }, (_, i) =>
|
||||
`## Section ${i}\n\nFirst paragraph.\n\nLong detail ${'x'.repeat(200)}.`
|
||||
).join('\n\n');
|
||||
|
||||
await createPlanningDir(projectDir, {
|
||||
'STATE.md': '# State',
|
||||
'ROADMAP.md': '# Roadmap',
|
||||
'CONTEXT.md': largeContent,
|
||||
});
|
||||
|
||||
const engine = new ContextEngine(projectDir, undefined, { maxContentLength: 500 });
|
||||
const files = await engine.resolveContextFiles(PhaseType.Plan);
|
||||
|
||||
// CONTEXT.md should be truncated
|
||||
expect(files.context!.length).toBeLessThan(largeContent.length);
|
||||
expect(files.context).toContain('[...');
|
||||
});
|
||||
|
||||
it('does not truncate files below threshold', async () => {
|
||||
await createPlanningDir(projectDir, {
|
||||
'STATE.md': '# State\nproject: test',
|
||||
'ROADMAP.md': '# Roadmap\nphase 01',
|
||||
'CONTEXT.md': '# Context\nstack: node',
|
||||
});
|
||||
|
||||
const engine = new ContextEngine(projectDir);
|
||||
const files = await engine.resolveContextFiles(PhaseType.Plan);
|
||||
|
||||
expect(files.context).toBe('# Context\nstack: node');
|
||||
});
|
||||
|
||||
it('never truncates STATE.md (not in truncatable list)', async () => {
|
||||
const largeState = `# State\n\n${'x'.repeat(20000)}`;
|
||||
await createPlanningDir(projectDir, {
|
||||
'STATE.md': largeState,
|
||||
});
|
||||
|
||||
const engine = new ContextEngine(projectDir, undefined, { maxContentLength: 100 });
|
||||
const files = await engine.resolveContextFiles(PhaseType.Execute);
|
||||
|
||||
expect(files.state).toBe(largeState);
|
||||
});
|
||||
|
||||
it('extracts current milestone from ROADMAP.md when state is available', async () => {
|
||||
const roadmap = `# Roadmap
|
||||
|
||||
## Milestone 1: Setup
|
||||
### Phase 01
|
||||
Setup content.
|
||||
|
||||
## Milestone 2: Build
|
||||
### Phase 02
|
||||
Build content.`;
|
||||
|
||||
await createPlanningDir(projectDir, {
|
||||
'STATE.md': 'Current Milestone: Build',
|
||||
'ROADMAP.md': roadmap,
|
||||
'CONTEXT.md': '# Context',
|
||||
});
|
||||
|
||||
const engine = new ContextEngine(projectDir);
|
||||
const files = await engine.resolveContextFiles(PhaseType.Plan);
|
||||
|
||||
expect(files.roadmap).toContain('## Milestone 2: Build');
|
||||
expect(files.roadmap).not.toContain('### Phase 01');
|
||||
});
|
||||
|
||||
it('respects custom truncation options', async () => {
|
||||
const content = '## Heading\n\nParagraph.\n\nMore.\n' + 'x'.repeat(500);
|
||||
await createPlanningDir(projectDir, {
|
||||
'STATE.md': '# State',
|
||||
'ROADMAP.md': '# Roadmap',
|
||||
'CONTEXT.md': content,
|
||||
});
|
||||
|
||||
// Low threshold forces truncation
|
||||
const engine = new ContextEngine(projectDir, undefined, { maxContentLength: 50 });
|
||||
const files = await engine.resolveContextFiles(PhaseType.Plan);
|
||||
expect(files.context!.length).toBeLessThan(content.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PHASE_FILE_MANIFEST', () => {
|
||||
it('covers all phase types', () => {
|
||||
for (const phase of Object.values(PhaseType)) {
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
* only needs STATE.md + config.json (minimal). Research needs STATE.md +
|
||||
* ROADMAP.md + CONTEXT.md. Plan needs all files. Verify needs STATE.md +
|
||||
* ROADMAP.md + REQUIREMENTS.md + PLAN/SUMMARY files.
|
||||
*
|
||||
* Context reduction (issue #1614):
|
||||
* - Large files are truncated to keep prompts cache-friendly
|
||||
* - ROADMAP.md is narrowed to the current milestone when possible
|
||||
* - Truncation preserves headings + first paragraph per section
|
||||
*/
|
||||
|
||||
import { readFile, access } from 'node:fs/promises';
|
||||
@@ -14,6 +19,12 @@ import { constants } from 'node:fs';
|
||||
import type { ContextFiles } from './types.js';
|
||||
import { PhaseType } from './types.js';
|
||||
import type { GSDLogger } from './logger.js';
|
||||
import {
|
||||
truncateMarkdown,
|
||||
extractCurrentMilestone,
|
||||
DEFAULT_TRUNCATION_OPTIONS,
|
||||
type TruncationOptions,
|
||||
} from './context-truncation.js';
|
||||
|
||||
// ─── File manifest per phase ─────────────────────────────────────────────────
|
||||
|
||||
@@ -64,16 +75,21 @@ const PHASE_FILE_MANIFEST: Record<PhaseType, FileSpec[]> = {
|
||||
export class ContextEngine {
|
||||
private readonly planningDir: string;
|
||||
private readonly logger?: GSDLogger;
|
||||
private readonly truncation: TruncationOptions;
|
||||
|
||||
constructor(projectDir: string, logger?: GSDLogger) {
|
||||
constructor(projectDir: string, logger?: GSDLogger, truncation?: Partial<TruncationOptions>) {
|
||||
this.planningDir = join(projectDir, '.planning');
|
||||
this.logger = logger;
|
||||
this.truncation = { ...DEFAULT_TRUNCATION_OPTIONS, ...truncation };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve context files appropriate for the given phase type.
|
||||
* Reads each file defined in the phase manifest, returning undefined
|
||||
* for missing optional files and warning for missing required files.
|
||||
*
|
||||
* Files exceeding the truncation threshold are reduced to headings +
|
||||
* first paragraphs. ROADMAP.md is narrowed to the current milestone.
|
||||
*/
|
||||
async resolveContextFiles(phaseType: PhaseType): Promise<ContextFiles> {
|
||||
const manifest = PHASE_FILE_MANIFEST[phaseType];
|
||||
@@ -94,6 +110,40 @@ export class ContextEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply context reduction: milestone extraction then truncation
|
||||
if (result.roadmap && result.state) {
|
||||
const before = result.roadmap.length;
|
||||
result.roadmap = extractCurrentMilestone(result.roadmap, result.state);
|
||||
if (result.roadmap.length < before) {
|
||||
this.logger?.debug?.('ROADMAP.md narrowed to current milestone', {
|
||||
before,
|
||||
after: result.roadmap.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate oversized files (skip config.json — structured data, not markdown)
|
||||
const truncatable: Array<{ key: keyof ContextFiles; filename: string }> = [
|
||||
{ key: 'roadmap', filename: 'ROADMAP.md' },
|
||||
{ key: 'context', filename: 'CONTEXT.md' },
|
||||
{ key: 'research', filename: 'RESEARCH.md' },
|
||||
{ key: 'requirements', filename: 'REQUIREMENTS.md' },
|
||||
{ key: 'plan', filename: 'PLAN.md' },
|
||||
{ key: 'summary', filename: 'SUMMARY.md' },
|
||||
];
|
||||
|
||||
for (const { key, filename } of truncatable) {
|
||||
const raw = result[key];
|
||||
if (raw && raw.length > this.truncation.maxContentLength) {
|
||||
const before = raw.length;
|
||||
result[key] = truncateMarkdown(raw, filename, this.truncation);
|
||||
this.logger?.debug?.(`${filename} truncated`, {
|
||||
before,
|
||||
after: result[key]!.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
163
sdk/src/context-truncation.test.ts
Normal file
163
sdk/src/context-truncation.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
truncateMarkdown,
|
||||
extractCurrentMilestone,
|
||||
DEFAULT_TRUNCATION_OPTIONS,
|
||||
} from './context-truncation.js';
|
||||
|
||||
// ─── truncateMarkdown ───────────────────────────────────────────────────────
|
||||
|
||||
describe('truncateMarkdown', () => {
|
||||
it('returns content unchanged when below threshold', () => {
|
||||
const content = '# Title\n\nShort content.';
|
||||
const result = truncateMarkdown(content, 'TEST.md');
|
||||
expect(result).toBe(content);
|
||||
});
|
||||
|
||||
it('truncates content above threshold, keeping headings and first paragraphs', () => {
|
||||
const sections = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
sections.push(`## Section ${i}\n\nFirst paragraph of section ${i}.\n\nSecond paragraph with lots of detail.\nMore detail here.\nEven more detail.`);
|
||||
}
|
||||
const content = `# Title\n\n${sections.join('\n\n')}`;
|
||||
const result = truncateMarkdown(content, 'BIG.md', { maxContentLength: 100 });
|
||||
|
||||
// Headings preserved
|
||||
expect(result).toContain('# Title');
|
||||
expect(result).toContain('## Section 0');
|
||||
expect(result).toContain('## Section 19');
|
||||
|
||||
// First paragraphs preserved
|
||||
expect(result).toContain('First paragraph of section 0.');
|
||||
expect(result).toContain('First paragraph of section 19.');
|
||||
|
||||
// Second paragraphs omitted
|
||||
expect(result).not.toContain('Second paragraph');
|
||||
expect(result).not.toContain('More detail here.');
|
||||
|
||||
// Truncation markers present
|
||||
expect(result).toContain('[...');
|
||||
expect(result).toContain('lines omitted]');
|
||||
expect(result).toContain('[Truncated: read .planning/BIG.md for full content]');
|
||||
});
|
||||
|
||||
it('preserves YAML frontmatter entirely', () => {
|
||||
const content = `---\nphase: "01"\nstatus: active\n---\n\n# Title\n\nParagraph 1.\n\nParagraph 2.\n${'x'.repeat(10000)}`;
|
||||
const result = truncateMarkdown(content, 'STATE.md', { maxContentLength: 100 });
|
||||
|
||||
expect(result).toContain('---\nphase: "01"\nstatus: active\n---');
|
||||
expect(result).toContain('# Title');
|
||||
expect(result).toContain('Paragraph 1.');
|
||||
});
|
||||
|
||||
it('is smaller than original when truncated', () => {
|
||||
const longContent = Array.from({ length: 200 }, (_, i) =>
|
||||
`## Section ${i}\n\nFirst paragraph.\n\nLong detail paragraph ${'x'.repeat(100)}.`
|
||||
).join('\n\n');
|
||||
|
||||
const result = truncateMarkdown(longContent, 'HUGE.md', { maxContentLength: 100 });
|
||||
expect(result.length).toBeLessThan(longContent.length);
|
||||
});
|
||||
|
||||
it('handles content with no headings', () => {
|
||||
const content = `First line.\n\nSecond paragraph.\n\nThird paragraph.\n${'x'.repeat(10000)}`;
|
||||
const result = truncateMarkdown(content, 'FLAT.md', { maxContentLength: 100 });
|
||||
|
||||
// Should still truncate — first paragraph kept
|
||||
expect(result).toContain('First line.');
|
||||
expect(result.length).toBeLessThan(content.length);
|
||||
});
|
||||
|
||||
it('default threshold is 8192 characters', () => {
|
||||
expect(DEFAULT_TRUNCATION_OPTIONS.maxContentLength).toBe(8192);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── extractCurrentMilestone ────────────────────────────────────────────────
|
||||
|
||||
describe('extractCurrentMilestone', () => {
|
||||
const makeRoadmap = () => `# Project Roadmap
|
||||
|
||||
## Milestone 1: Foundation
|
||||
### Phase 01: Setup
|
||||
Requirements for setup.
|
||||
### Phase 02: Core
|
||||
Requirements for core.
|
||||
|
||||
## Milestone 2: Features
|
||||
### Phase 03: Auth
|
||||
Requirements for auth.
|
||||
### Phase 04: API
|
||||
Requirements for API.
|
||||
|
||||
## Milestone 3: Polish
|
||||
### Phase 05: UI
|
||||
Requirements for UI.`;
|
||||
|
||||
it('returns full roadmap when no state provided', () => {
|
||||
const roadmap = makeRoadmap();
|
||||
expect(extractCurrentMilestone(roadmap)).toBe(roadmap);
|
||||
});
|
||||
|
||||
it('returns full roadmap when milestone not found in state', () => {
|
||||
const roadmap = makeRoadmap();
|
||||
const state = '# State\nstatus: active';
|
||||
expect(extractCurrentMilestone(roadmap, state)).toBe(roadmap);
|
||||
});
|
||||
|
||||
it('extracts current milestone section by name', () => {
|
||||
const roadmap = makeRoadmap();
|
||||
const state = 'Current Milestone: Features';
|
||||
const result = extractCurrentMilestone(roadmap, state);
|
||||
|
||||
expect(result).toContain('## Milestone 2: Features');
|
||||
expect(result).toContain('### Phase 03: Auth');
|
||||
expect(result).toContain('### Phase 04: API');
|
||||
|
||||
// Other milestones omitted
|
||||
expect(result).not.toContain('### Phase 01: Setup');
|
||||
expect(result).not.toContain('### Phase 05: UI');
|
||||
expect(result).toContain('other milestone(s) omitted');
|
||||
});
|
||||
|
||||
it('matches milestone name case-insensitively', () => {
|
||||
const roadmap = makeRoadmap();
|
||||
const state = 'current milestone: features';
|
||||
const result = extractCurrentMilestone(roadmap, state);
|
||||
|
||||
expect(result).toContain('## Milestone 2: Features');
|
||||
expect(result).not.toContain('### Phase 01: Setup');
|
||||
});
|
||||
|
||||
it('matches milestone from "milestone:" field in state', () => {
|
||||
const roadmap = makeRoadmap();
|
||||
const state = '# State\nmilestone: Foundation\nphase: 01';
|
||||
const result = extractCurrentMilestone(roadmap, state);
|
||||
|
||||
expect(result).toContain('## Milestone 1: Foundation');
|
||||
expect(result).toContain('### Phase 01: Setup');
|
||||
expect(result).not.toContain('### Phase 03: Auth');
|
||||
});
|
||||
|
||||
it('matches milestone from Current Position block', () => {
|
||||
const roadmap = makeRoadmap();
|
||||
const state = `# State
|
||||
|
||||
## Current Position
|
||||
milestone: Polish
|
||||
phase: 05`;
|
||||
const result = extractCurrentMilestone(roadmap, state);
|
||||
|
||||
expect(result).toContain('## Milestone 3: Polish');
|
||||
expect(result).toContain('### Phase 05: UI');
|
||||
expect(result).not.toContain('### Phase 01: Setup');
|
||||
});
|
||||
|
||||
it('preserves roadmap title in output', () => {
|
||||
const roadmap = makeRoadmap();
|
||||
const state = 'Current Milestone: Features';
|
||||
const result = extractCurrentMilestone(roadmap, state);
|
||||
|
||||
expect(result).toContain('# Project Roadmap');
|
||||
});
|
||||
});
|
||||
233
sdk/src/context-truncation.ts
Normal file
233
sdk/src/context-truncation.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Context truncation — reduces large .planning/ files to cache-friendly sizes.
|
||||
*
|
||||
* Two strategies:
|
||||
* 1. Markdown-aware truncation: keeps headings + first paragraph per section,
|
||||
* replaces the rest with a pointer to the full file.
|
||||
* 2. Milestone extraction: pulls only the current milestone from ROADMAP.md.
|
||||
*
|
||||
* All functions are pure — no I/O, no side effects.
|
||||
*/
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TruncationOptions {
|
||||
/** Max content length in characters before truncation kicks in. Default: 8192 */
|
||||
maxContentLength: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_TRUNCATION_OPTIONS: TruncationOptions = {
|
||||
maxContentLength: 8192,
|
||||
};
|
||||
|
||||
// ─── Markdown-aware truncation ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Truncate markdown content while preserving structure.
|
||||
*
|
||||
* Strategy: keep YAML frontmatter, all headings, and the first paragraph under
|
||||
* each heading. Collapse everything else with a line count summary.
|
||||
*
|
||||
* Returns the original content unchanged if below maxContentLength.
|
||||
*/
|
||||
export function truncateMarkdown(
|
||||
content: string,
|
||||
filename: string,
|
||||
options: TruncationOptions = DEFAULT_TRUNCATION_OPTIONS,
|
||||
): string {
|
||||
if (content.length <= options.maxContentLength) return content;
|
||||
|
||||
const lines = content.split('\n');
|
||||
const kept: string[] = [];
|
||||
let inFrontmatter = false;
|
||||
let frontmatterDone = false;
|
||||
let currentSectionLines = 0;
|
||||
let paragraphKept = false;
|
||||
let omittedLines = 0;
|
||||
let inParagraph = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Handle YAML frontmatter (preserve entirely)
|
||||
if (i === 0 && line.trim() === '---') {
|
||||
inFrontmatter = true;
|
||||
kept.push(line);
|
||||
continue;
|
||||
}
|
||||
if (inFrontmatter) {
|
||||
kept.push(line);
|
||||
if (line.trim() === '---') {
|
||||
inFrontmatter = false;
|
||||
frontmatterDone = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Heading — always keep, reset paragraph tracking
|
||||
if (/^#{1,6}\s/.test(line)) {
|
||||
if (omittedLines > 0) {
|
||||
kept.push(`[... ${omittedLines} lines omitted]`);
|
||||
omittedLines = 0;
|
||||
}
|
||||
kept.push(line);
|
||||
currentSectionLines = 0;
|
||||
paragraphKept = false;
|
||||
inParagraph = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Empty line — paragraph boundary
|
||||
if (line.trim() === '') {
|
||||
if (inParagraph && !paragraphKept) {
|
||||
// End of first paragraph — mark it kept
|
||||
paragraphKept = true;
|
||||
}
|
||||
if (!paragraphKept || currentSectionLines === 0) {
|
||||
kept.push(line);
|
||||
} else {
|
||||
omittedLines++;
|
||||
}
|
||||
inParagraph = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Content line
|
||||
currentSectionLines++;
|
||||
if (!paragraphKept) {
|
||||
// Still in the first paragraph — keep it
|
||||
kept.push(line);
|
||||
inParagraph = true;
|
||||
} else {
|
||||
omittedLines++;
|
||||
}
|
||||
}
|
||||
|
||||
if (omittedLines > 0) {
|
||||
kept.push(`[... ${omittedLines} lines omitted]`);
|
||||
}
|
||||
|
||||
const totalOmitted = lines.length - kept.length;
|
||||
if (totalOmitted > 0) {
|
||||
kept.push('');
|
||||
kept.push(`[Truncated: read .planning/${filename} for full content]`);
|
||||
}
|
||||
|
||||
return kept.join('\n');
|
||||
}
|
||||
|
||||
// ─── Milestone extraction ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the current milestone section from a ROADMAP.md.
|
||||
*
|
||||
* Parses STATE.md to find the current milestone name, then extracts only
|
||||
* that milestone's section from the roadmap. Falls back to full content
|
||||
* if the milestone can't be identified or found.
|
||||
*/
|
||||
export function extractCurrentMilestone(
|
||||
roadmapContent: string,
|
||||
stateContent?: string,
|
||||
): string {
|
||||
if (!stateContent) return roadmapContent;
|
||||
|
||||
// Find current milestone from STATE.md
|
||||
// Patterns: "Current Milestone: X", "milestone: X", "## Current Position" block
|
||||
const milestonePatterns = [
|
||||
/current\s*milestone\s*:\s*(.+)/i,
|
||||
/^milestone\s*:\s*(.+)/im,
|
||||
/##\s*current\s*position[\s\S]*?milestone\s*:\s*(.+)/i,
|
||||
];
|
||||
|
||||
let milestoneName: string | undefined;
|
||||
for (const pattern of milestonePatterns) {
|
||||
const match = stateContent.match(pattern);
|
||||
if (match) {
|
||||
milestoneName = match[1].trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!milestoneName) return roadmapContent;
|
||||
|
||||
// Find the milestone section in roadmap
|
||||
// Look for heading containing the milestone name
|
||||
const lines = roadmapContent.split('\n');
|
||||
let sectionStart = -1;
|
||||
let sectionEnd = lines.length;
|
||||
let sectionHeadingLevel = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const headingMatch = lines[i].match(/^(#{1,6})\s+(.+)/);
|
||||
if (!headingMatch) continue;
|
||||
|
||||
const level = headingMatch[1].length;
|
||||
const title = headingMatch[2];
|
||||
|
||||
if (sectionStart === -1) {
|
||||
// Looking for the milestone heading
|
||||
if (title.toLowerCase().includes(milestoneName.toLowerCase())) {
|
||||
sectionStart = i;
|
||||
sectionHeadingLevel = level;
|
||||
}
|
||||
} else {
|
||||
// Found start — look for next heading at same or higher level
|
||||
if (level <= sectionHeadingLevel) {
|
||||
sectionEnd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sectionStart === -1) return roadmapContent;
|
||||
|
||||
// Extract preamble (everything before first milestone heading at the same level)
|
||||
const preamble: string[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const headingMatch = lines[i].match(/^(#{1,6})\s/);
|
||||
if (headingMatch && headingMatch[1].length === sectionHeadingLevel && i !== sectionStart) {
|
||||
// Hit another milestone-level heading before our section
|
||||
if (i < sectionStart) {
|
||||
break; // preamble ends at first milestone heading
|
||||
}
|
||||
}
|
||||
if (i < sectionStart) {
|
||||
// Keep top-level title and intro
|
||||
if (i === 0 || lines[i].match(/^#\s/) || !lines[i].match(/^#{1,6}\s/)) {
|
||||
preamble.push(lines[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const milestoneSection = lines.slice(sectionStart, sectionEnd).join('\n');
|
||||
const otherMilestones = countOtherMilestones(lines, sectionHeadingLevel, sectionStart);
|
||||
|
||||
const result = [
|
||||
...preamble,
|
||||
'',
|
||||
milestoneSection,
|
||||
];
|
||||
|
||||
if (otherMilestones > 0) {
|
||||
result.push('');
|
||||
result.push(`[${otherMilestones} other milestone(s) omitted — read .planning/ROADMAP.md for full roadmap]`);
|
||||
}
|
||||
|
||||
return result.join('\n').trim();
|
||||
}
|
||||
|
||||
function countOtherMilestones(
|
||||
lines: string[],
|
||||
headingLevel: number,
|
||||
excludeIndex: number,
|
||||
): number {
|
||||
let count = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (i === excludeIndex) continue;
|
||||
const match = lines[i].match(/^(#{1,6})\s/);
|
||||
if (match && match[1].length === headingLevel) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -296,6 +296,8 @@ export { GSDEventStream } from './event-stream.js';
|
||||
export type { EventStreamContext } from './event-stream.js';
|
||||
export { ContextEngine, PHASE_FILE_MANIFEST } from './context-engine.js';
|
||||
export type { FileSpec } from './context-engine.js';
|
||||
export { truncateMarkdown, extractCurrentMilestone, DEFAULT_TRUNCATION_OPTIONS } from './context-truncation.js';
|
||||
export type { TruncationOptions } from './context-truncation.js';
|
||||
export { getToolsForPhase, PHASE_AGENT_MAP, PHASE_DEFAULT_TOOLS } from './tool-scoping.js';
|
||||
export { PromptFactory, extractBlock, extractSteps, PHASE_WORKFLOW_MAP } from './phase-prompt.js';
|
||||
export { GSDLogger } from './logger.js';
|
||||
|
||||
@@ -154,6 +154,11 @@ describe('PromptFactory', () => {
|
||||
expect(prompt).toContain('# State');
|
||||
expect(prompt).toContain('# Roadmap');
|
||||
expect(prompt).toContain('## Phase Instructions');
|
||||
|
||||
// Cache-friendly ordering (#1614): stable prefix before variable context
|
||||
const phaseInstrIdx = prompt.indexOf('## Phase Instructions');
|
||||
const contextIdx = prompt.indexOf('## Context');
|
||||
expect(phaseInstrIdx).toBeLessThan(contextIdx);
|
||||
});
|
||||
|
||||
it('assembles plan prompt with all context files', async () => {
|
||||
|
||||
@@ -102,8 +102,13 @@ export class PromptFactory {
|
||||
return sanitizePrompt(buildExecutorPrompt(plan, agentDef));
|
||||
}
|
||||
|
||||
// Prompt assembly order is cache-optimized (#1614):
|
||||
// Stable prefix (deterministic per phase type) → cached by Anthropic at 0.1x cost
|
||||
// Variable suffix (.planning/ files) → uncached, changes per project/run
|
||||
const sections: string[] = [];
|
||||
|
||||
// ── STABLE PREFIX (cacheable across runs for the same phase type) ──
|
||||
|
||||
// ── Agent role ──
|
||||
const agentDef = await this.loadAgentDef(phaseType);
|
||||
if (agentDef) {
|
||||
@@ -131,18 +136,20 @@ export class PromptFactory {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase-specific instructions (hardcoded per phase type — stable) ──
|
||||
const phaseInstructions = this.getPhaseInstructions(phaseType);
|
||||
if (phaseInstructions) {
|
||||
sections.push(`## Phase Instructions\n\n${phaseInstructions}`);
|
||||
}
|
||||
|
||||
// ── VARIABLE SUFFIX (project-specific, changes per run) ──
|
||||
|
||||
// ── Context files ──
|
||||
const contextSection = this.formatContextFiles(contextFiles);
|
||||
if (contextSection) {
|
||||
sections.push(contextSection);
|
||||
}
|
||||
|
||||
// ── Phase-specific instructions ──
|
||||
const phaseInstructions = this.getPhaseInstructions(phaseType);
|
||||
if (phaseInstructions) {
|
||||
sections.push(`## Phase Instructions\n\n${phaseInstructions}`);
|
||||
}
|
||||
|
||||
return sanitizePrompt(sections.join('\n\n'));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user