Files
buildpulse/src/features/export/exporters.ts
T
2026-05-07 16:02:35 +02:00

226 lines
8.4 KiB
TypeScript

import type { AppState, Feature, FeatureColumn, PulseEvent } from '../../store/types'
import { formatDateTime } from '../../utils/format'
const columnLabels: Record<FeatureColumn, string> = {
now: 'Now',
next: 'Next',
later: 'Later',
done: 'Done',
}
const groupFeatures = (features: Feature[]) => ({
now: features.filter((feature) => feature.column === 'now'),
next: features.filter((feature) => feature.column === 'next'),
later: features.filter((feature) => feature.column === 'later'),
done: features.filter((feature) => feature.column === 'done'),
})
const getFeatureLabel = (features: Feature[], featureId?: string) => {
if (!featureId) return '—'
return features.find((feature) => feature.id === featureId)?.title ?? `${featureId} (missing feature)`
}
const renderFeature = (feature: Feature) => {
const criteria = feature.acceptance_criteria.length
? feature.acceptance_criteria.map((item) => ` - ${item}`).join('\n')
: ' - None yet'
return [
`- **${feature.title}**`,
` - Description: ${feature.description || '—'}`,
` - Priority: ${feature.priority}`,
` - Status: ${feature.status}`,
' - Acceptance Criteria:',
criteria,
` - Scope Notes: ${feature.scope_notes || '—'}`,
].join('\n')
}
const sortPulsesNewestFirst = (pulses: PulseEvent[]) =>
[...pulses].sort((a, b) => b.timestamp.localeCompare(a.timestamp))
export const createAgentSessionPrompt = (
state: AppState,
options?: {
featureId?: string
target?: string
},
) => {
const grouped = groupFeatures(state.features)
const target = options?.target || 'AI coding agent'
const focusFeature = options?.featureId ? state.features.find((feature) => feature.id === options.featureId) ?? null : grouped.now[0] ?? null
const relatedPulses = sortPulsesNewestFirst(state.pulses)
.filter((pulse) => (focusFeature ? pulse.feature_id === focusFeature.id : true))
.slice(0, 6)
const renderFeatureBlock = (feature: Feature | null) => {
if (!feature) return '- No specific feature selected yet. Choose the smallest useful next move.'
return [
`- Title: ${feature.title}`,
`- Description: ${feature.description || '—'}`,
`- Column: ${columnLabels[feature.column]}`,
`- Priority: ${feature.priority}`,
`- Status: ${feature.status}`,
`- Acceptance criteria: ${feature.acceptance_criteria.length ? feature.acceptance_criteria.join('; ') : 'None yet'}`,
`- Scope notes: ${feature.scope_notes || '—'}`,
].join('\n')
}
return [
`You are the ${target} for ${state.project.name}.`,
'',
'Read the project context below and ship the smallest high-quality improvement that satisfies the focus feature without scope creep.',
'',
'## Project',
`- Name: ${state.project.name}`,
`- Pitch: ${state.project.one_line_pitch || '—'}`,
`- Current goal: ${state.project.current_goal || '—'}`,
`- Notes: ${state.project.notes || '—'}`,
'',
'## Focus Feature',
renderFeatureBlock(focusFeature),
'',
'## Other Active Features',
grouped.now.filter((feature) => feature.id !== focusFeature?.id).length
? grouped.now
.filter((feature) => feature.id !== focusFeature?.id)
.map((feature) => `- ${feature.title} (${feature.status}, ${feature.priority})`)
.join('\n')
: '- None beyond the focus feature.',
'',
'## Next Up',
grouped.next.length ? grouped.next.map((feature) => `- ${feature.title} (${feature.status})`).join('\n') : '- Nothing queued yet.',
'',
'## Parking Lot / Do Not Implement Yet',
state.parking_lot.length
? state.parking_lot.map((item) => `- ${item.title}${item.reason_parked || item.description || 'Parked for later.'}`).join('\n')
: '- Nothing parked yet.',
'',
'## Recent Relevant Pulses',
relatedPulses.length
? relatedPulses.map((pulse) => `- ${formatDateTime(pulse.timestamp)} · ${pulse.pulse_type} · ${pulse.message}`).join('\n')
: '- No relevant pulse events yet.',
'',
'## Rules',
'- Stay inside the focus feature unless a tiny supporting fix is required.',
'- Do not implement Parking Lot items.',
'- Preserve working behavior and avoid needless rewrites.',
'- Prefer the smallest shippable step with clear evidence.',
'',
'## Deliver back',
'- What changed',
'- Files touched',
'- How you verified it',
'- Any blocker or follow-up you intentionally parked',
].join('\n')
}
export const createJsonExport = (state: AppState) =>
JSON.stringify(
{
schema_version: state.schema_version,
exported_at: new Date().toISOString(),
project: state.project,
features: state.features,
parking_lot: state.parking_lot,
pulses: state.pulses,
settings: state.settings,
},
null,
2,
)
export const createPulseJsonl = (state: AppState) => state.pulses.map((pulse) => JSON.stringify(pulse)).join('\n')
export const createMarkdownPackage = (state: AppState) => {
const grouped = groupFeatures(state.features)
const recentPulses = sortPulsesNewestFirst(state.pulses).slice(0, 8)
const projectSummary = `# Project Summary\n\n## Name\n${state.project.name}\n\n## One-Line Pitch\n${state.project.one_line_pitch || '—'}\n\n## Description\n${state.project.description || '—'}\n\n## Current Goal\n${state.project.current_goal || '—'}\n\n## Notes\n${state.project.notes || '—'}\n`
const featurePlan = ['# Feature Plan']
;(['now', 'next', 'later', 'done'] as FeatureColumn[]).forEach((column) => {
featurePlan.push(`\n## ${columnLabels[column]}`)
const features = grouped[column]
if (!features.length) {
featurePlan.push('\n_No features in this column yet._')
return
}
featurePlan.push('', ...features.map(renderFeature))
})
const parkingLot = [
'# Parking Lot',
'',
...(state.parking_lot.length
? state.parking_lot.map(
(item) =>
`- **${item.title}**\n - Description: ${item.description || '—'}\n - Reason parked: ${item.reason_parked || '—'}\n - Possible future placement: ${item.possible_future_placement || '—'}\n - Risk level: ${item.risk_level}`,
)
: ['_No parked ideas yet._']),
].join('\n')
const pulseLog = [
'# Pulse Log',
'',
...(sortPulsesNewestFirst(state.pulses).length
? sortPulsesNewestFirst(state.pulses).map(
(pulse) =>
`- **${formatDateTime(pulse.timestamp)}** — ${pulse.pulse_type}\n - Feature: ${getFeatureLabel(state.features, pulse.feature_id)}\n - Source/Agent: ${pulse.source} / ${pulse.agent_id}\n - Message: ${pulse.message}\n - Confidence: ${pulse.confidence_score}\n - Evidence: ${pulse.evidence_refs.length ? pulse.evidence_refs.join('; ') : '—'}`,
)
: ['_No pulse events yet._']),
].join('\n')
const claudeContext = [
'# AI Coding Context',
'',
'## Project',
`${state.project.name}${state.project.one_line_pitch || '—'}`,
'',
'## Current Goal',
state.project.current_goal || '—',
'',
'## Active Features',
grouped.now.length ? grouped.now.map(renderFeature).join('\n\n') : '_None yet._',
'',
'## Next Features',
grouped.next.length ? grouped.next.map(renderFeature).join('\n\n') : '_None yet._',
'',
'## Done Features',
grouped.done.length ? grouped.done.map(renderFeature).join('\n\n') : '_None yet._',
'',
'## Parking Lot / Do Not Implement Yet',
state.parking_lot.length
? state.parking_lot
.map((item) => `- ${item.title}${item.reason_parked || item.description || 'Parked for later.'}`)
.join('\n')
: '_No parked ideas yet._',
'',
'## Recent Pulse Events',
recentPulses.length
? recentPulses
.map(
(pulse) =>
`- ${formatDateTime(pulse.timestamp)}${pulse.pulse_type}${pulse.message} (${getFeatureLabel(state.features, pulse.feature_id)})`,
)
.join('\n')
: '_No recent pulse events yet._',
'',
'## Instructions for AI Developer',
'- Only work on the selected feature.',
'- Do not implement Parking Lot items.',
'- Preserve working behavior.',
'- Report changes and test steps.',
].join('\n')
return {
'PROJECT_SUMMARY.md': projectSummary,
'FEATURE_PLAN.md': featurePlan.join('\n'),
'PARKING_LOT.md': parkingLot,
'PULSE_LOG.md': pulseLog,
'CLAUDE_CONTEXT.md': claudeContext,
}
}