feat: add AI session prompt export
This commit is contained in:
@@ -32,6 +32,7 @@ BuildPulse v0.1 includes:
|
|||||||
- Parking Lot screen
|
- Parking Lot screen
|
||||||
- Pulse Log screen
|
- Pulse Log screen
|
||||||
- Export screen
|
- Export screen
|
||||||
|
- AI session prompt export for coding-agent handoff
|
||||||
- Appwrite-backed persistence on the Unraid server
|
- Appwrite-backed persistence on the Unraid server
|
||||||
- Local cache fallback for resilience during backend hiccups
|
- Local cache fallback for resilience during backend hiccups
|
||||||
- Pulse-shaped event records
|
- Pulse-shaped event records
|
||||||
@@ -76,7 +77,7 @@ This means the data shape should be future-compatible from day one.
|
|||||||
1. Feature Plan
|
1. Feature Plan
|
||||||
2. Parking Lot
|
2. Parking Lot
|
||||||
3. Pulse Log
|
3. Pulse Log
|
||||||
4. Export
|
4. Export + AI Session Prompt
|
||||||
|
|
||||||
## Intended First Use
|
## Intended First Use
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ Potential features:
|
|||||||
- Include recent pulse context
|
- Include recent pulse context
|
||||||
- End-session summary template
|
- End-session summary template
|
||||||
|
|
||||||
|
Note:
|
||||||
|
BuildPulse now has a lightweight export-side session prompt generator in v0.1.x so handoff quality improves before the fuller v0.4 workflow arrives.
|
||||||
|
|
||||||
## v0.5 — Local/Cloud AI Assistant
|
## v0.5 — Local/Cloud AI Assistant
|
||||||
|
|
||||||
Goal:
|
Goal:
|
||||||
|
|||||||
+91
-26
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { ChangeEvent } from 'react'
|
import type { ChangeEvent } from 'react'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import { createMarkdownPackage, createJsonExport, createPulseJsonl } from './features/export/exporters'
|
import { createAgentSessionPrompt, createMarkdownPackage, createJsonExport, createPulseJsonl } from './features/export/exporters'
|
||||||
import { loadAppState, replaceAppState, saveAppState, validateAppState } from './store/storage'
|
import { loadAppState, replaceAppState, saveAppState, validateAppState } from './store/storage'
|
||||||
import { fetchRemoteState, pushRemoteState } from './store/remote'
|
import { fetchRemoteState, pushRemoteState } from './store/remote'
|
||||||
import { FEATURE_COLUMNS, FEATURE_PRIORITIES, FEATURE_STATUSES, PULSE_TYPES, RISK_LEVELS } from './store/types'
|
import { FEATURE_COLUMNS, FEATURE_PRIORITIES, FEATURE_STATUSES, PULSE_TYPES, RISK_LEVELS } from './store/types'
|
||||||
@@ -15,6 +15,8 @@ const TABS: Array<{ key: TabKey; label: string }> = [
|
|||||||
{ key: 'export', label: 'Export' },
|
{ key: 'export', label: 'Export' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const PROMPT_TARGETS = ['OpenClaw', 'Claude Code', 'Codex', 'Generic Agent'] as const
|
||||||
|
|
||||||
const initialFeatureDraft = {
|
const initialFeatureDraft = {
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
@@ -74,6 +76,8 @@ function App() {
|
|||||||
const [pulseTypeFilter, setPulseTypeFilter] = useState('all')
|
const [pulseTypeFilter, setPulseTypeFilter] = useState('all')
|
||||||
const [pulseFeatureFilter, setPulseFeatureFilter] = useState('all')
|
const [pulseFeatureFilter, setPulseFeatureFilter] = useState('all')
|
||||||
const [pulseSourceFilter, setPulseSourceFilter] = useState('all')
|
const [pulseSourceFilter, setPulseSourceFilter] = useState('all')
|
||||||
|
const [promptTarget, setPromptTarget] = useState<(typeof PROMPT_TARGETS)[number]>('OpenClaw')
|
||||||
|
const [promptFeatureId, setPromptFeatureId] = useState('')
|
||||||
const [backendMode, setBackendMode] = useState<'connecting' | 'appwrite' | 'local-cache'>('connecting')
|
const [backendMode, setBackendMode] = useState<'connecting' | 'appwrite' | 'local-cache'>('connecting')
|
||||||
const hasHydratedRemote = useRef(false)
|
const hasHydratedRemote = useRef(false)
|
||||||
const initialLocalStateRef = useRef(appState)
|
const initialLocalStateRef = useRef(appState)
|
||||||
@@ -167,6 +171,10 @@ function App() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const markdownPackage = useMemo(() => createMarkdownPackage(appState), [appState])
|
const markdownPackage = useMemo(() => createMarkdownPackage(appState), [appState])
|
||||||
|
const sessionPrompt = useMemo(
|
||||||
|
() => createAgentSessionPrompt(appState, { featureId: promptFeatureId || undefined, target: promptTarget }),
|
||||||
|
[appState, promptFeatureId, promptTarget],
|
||||||
|
)
|
||||||
|
|
||||||
const updateProject = (field: keyof AppState['project'], value: string) => {
|
const updateProject = (field: keyof AppState['project'], value: string) => {
|
||||||
setAppState((current) => ({
|
setAppState((current) => ({
|
||||||
@@ -459,6 +467,15 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copySessionPrompt = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(sessionPrompt)
|
||||||
|
setStatusMessage('AI session prompt copied to clipboard.')
|
||||||
|
} catch {
|
||||||
|
setStatusMessage('Clipboard copy failed. Browser said no.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleImport = async (event: ChangeEvent<HTMLInputElement>) => {
|
const handleImport = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
@@ -974,32 +991,80 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card list-card">
|
<div className="list-stack">
|
||||||
<div className="section-heading compact">
|
<section className="card list-card">
|
||||||
<div>
|
<div className="section-heading compact">
|
||||||
<h3>Markdown Package</h3>
|
<div>
|
||||||
<p>`CLAUDE_CONTEXT.md` is the decision-boundary file. Keep it sharp.</p>
|
<h3>AI Session Prompt</h3>
|
||||||
</div>
|
<p>Pick a focus feature and hand a sharp brief to your coding agent instead of pasting the whole kitchen sink.</p>
|
||||||
</div>
|
|
||||||
<div className="list-stack markdown-list">
|
|
||||||
{Object.entries(markdownPackage).map(([filename, content]) => (
|
|
||||||
<div key={filename} className="markdown-card">
|
|
||||||
<div className="item-card-header">
|
|
||||||
<strong>{filename}</strong>
|
|
||||||
<div className="button-inline-row">
|
|
||||||
<button type="button" className="ghost small" onClick={() => copyMarkdown(filename)}>
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
<button type="button" className="ghost small" onClick={() => downloadText(filename, content)}>
|
|
||||||
Download
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<pre>{content}</pre>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
<div className="filter-row">
|
||||||
</section>
|
<label>
|
||||||
|
Target
|
||||||
|
<select value={promptTarget} onChange={(event) => setPromptTarget(event.target.value as (typeof PROMPT_TARGETS)[number])}>
|
||||||
|
{PROMPT_TARGETS.map((target) => (
|
||||||
|
<option key={target} value={target}>
|
||||||
|
{target}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Focus Feature
|
||||||
|
<select value={promptFeatureId} onChange={(event) => setPromptFeatureId(event.target.value)}>
|
||||||
|
<option value="">Auto-pick first Now feature</option>
|
||||||
|
{appState.features.map((feature) => (
|
||||||
|
<option key={feature.id} value={feature.id}>
|
||||||
|
{feature.title} · {columnLabels[feature.column]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="button-inline-row">
|
||||||
|
<button type="button" onClick={copySessionPrompt}>
|
||||||
|
Copy Prompt
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ghost" onClick={() => downloadText('AI_SESSION_PROMPT.md', sessionPrompt)}>
|
||||||
|
Download Prompt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="markdown-card">
|
||||||
|
<div className="item-card-header">
|
||||||
|
<strong>AI_SESSION_PROMPT.md</strong>
|
||||||
|
</div>
|
||||||
|
<pre>{sessionPrompt}</pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card list-card">
|
||||||
|
<div className="section-heading compact">
|
||||||
|
<div>
|
||||||
|
<h3>Markdown Package</h3>
|
||||||
|
<p>`CLAUDE_CONTEXT.md` is the decision-boundary file. Keep it sharp.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="list-stack markdown-list">
|
||||||
|
{Object.entries(markdownPackage).map(([filename, content]) => (
|
||||||
|
<div key={filename} className="markdown-card">
|
||||||
|
<div className="item-card-header">
|
||||||
|
<strong>{filename}</strong>
|
||||||
|
<div className="button-inline-row">
|
||||||
|
<button type="button" className="ghost small" onClick={() => copyMarkdown(filename)}>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ghost small" onClick={() => downloadText(filename, content)}>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<pre>{content}</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -39,6 +39,83 @@ const renderFeature = (feature: Feature) => {
|
|||||||
const sortPulsesNewestFirst = (pulses: PulseEvent[]) =>
|
const sortPulsesNewestFirst = (pulses: PulseEvent[]) =>
|
||||||
[...pulses].sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
[...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) =>
|
export const createJsonExport = (state: AppState) =>
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user