Compare commits

...

5 Commits

Author SHA1 Message Date
OpenClaw Bot 0962548217 fix: unblock clarification retries and refresh handoff UX 2026-05-11 23:07:56 +02:00
OpenClaw Bot ec85b8e4d7 feat: add editable handoff preview flow 2026-05-11 22:49:52 +02:00
OpenClaw Bot 34413ffafa feat: ship one-tap feature handoffs 2026-05-11 13:19:22 +02:00
OpenClaw Bot de1855838e feat: add target-specific handoff presets 2026-05-11 12:48:36 +02:00
OpenClaw Bot 204e5ee64a feat: add focused handoff shortcuts 2026-05-11 12:14:19 +02:00
7 changed files with 501 additions and 50 deletions
+3
View File
@@ -3,6 +3,9 @@
BuildPulse is a calm planning cockpit for AI-assisted product building. BuildPulse is a calm planning cockpit for AI-assisted product building.
It helps capture features, park distracting ideas, log progress as Pulse events, and export clean project context for AI coding agents such as Claude Code, Codex, OpenCode, OpenClaw, or future autonomous agents. It helps capture features, park distracting ideas, log progress as Pulse events, and export clean project context for AI coding agents such as Claude Code, Codex, OpenCode, OpenClaw, or future autonomous agents.
Current release line:
- v0.4.1 — preview/edit-before-copy handoffs with cleaner INTENT controls and tighter mobile UX
Personal runtime target: Personal runtime target:
- `build.friborg.uk` - `build.friborg.uk`
+15 -11
View File
@@ -58,21 +58,25 @@ Potential features:
- Required vs optional features - Required vs optional features
- Release readiness view - Release readiness view
## v0.4 — Session Prompt Generator ## v0.4 — Handoff Workflow Hardening
Goal: Goal:
Generate clean prompts for AI coding agents. Turn a chosen feature into a sharp, target-specific AI coding brief with as little friction as possible.
Potential features: Current shipped slices:
- Start 30-minute session from feature - v0.3.1 — focused handoff shortcuts
- Generate Claude Code/Codex prompt - v0.3.2 — target-specific handoff presets
- Include do-not-touch list - v0.4.0 — one-tap feature handoffs
- Include acceptance criteria - v0.4.1 — preview/edit before copy + explicit INTENT controls
- Include recent pulse context
- End-session summary template
Note: Planned slices:
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.4.2 — paste agent result into RESULT/BLOCKER/TEST_RESULT pulses
- v0.4.3 — session modes (30-minute, feature-based, bugfix, QA review)
Core rules:
- Keep handoff generation close to the feature decision surface.
- Include release/phase context, blockers, parking-lot warnings, and return format.
- Do not add live execution, telemetry, router logic, or agent streaming in this phase.
## v0.5 — Local/Cloud AI Assistant ## v0.5 — Local/Cloud AI Assistant
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "buildpulse", "name": "buildpulse",
"private": true, "private": true,
"version": "0.3.0", "version": "0.4.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"api": "node --env-file=../.env server/index.mjs", "api": "node --env-file=../.env server/index.mjs",
+294 -15
View File
@@ -119,6 +119,10 @@ function App() {
const [pulseSourceFilter, setPulseSourceFilter] = useState('all') const [pulseSourceFilter, setPulseSourceFilter] = useState('all')
const [promptTarget, setPromptTarget] = useState<(typeof PROMPT_TARGETS)[number]>('OpenClaw') const [promptTarget, setPromptTarget] = useState<(typeof PROMPT_TARGETS)[number]>('OpenClaw')
const [promptFeatureId, setPromptFeatureId] = useState('') const [promptFeatureId, setPromptFeatureId] = useState('')
const [handoffPreviewOpen, setHandoffPreviewOpen] = useState(false)
const [handoffPreviewTarget, setHandoffPreviewTarget] = useState<(typeof PROMPT_TARGETS)[number]>('OpenClaw')
const [handoffPreviewFeatureId, setHandoffPreviewFeatureId] = useState('')
const [handoffPreviewDraft, setHandoffPreviewDraft] = useState('')
const [backendMode, setBackendMode] = useState<'connecting' | 'appwrite' | 'local-cache'>('connecting') const [backendMode, setBackendMode] = useState<'connecting' | 'appwrite' | 'local-cache'>('connecting')
const [syncStatus, setSyncStatus] = useState<'connecting' | 'synced' | 'pending' | 'syncing' | 'degraded'>('connecting') const [syncStatus, setSyncStatus] = useState<'connecting' | 'synced' | 'pending' | 'syncing' | 'degraded'>('connecting')
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null) const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null)
@@ -139,6 +143,7 @@ function App() {
const [triageError, setTriageError] = useState('') const [triageError, setTriageError] = useState('')
const [triageBatchStatus, setTriageBatchStatus] = useState<'idle' | 'running'>('idle') const [triageBatchStatus, setTriageBatchStatus] = useState<'idle' | 'running'>('idle')
const [triageEditMode, setTriageEditMode] = useState(false) const [triageEditMode, setTriageEditMode] = useState(false)
const [triageClarificationAnswer, setTriageClarificationAnswer] = useState('')
const [triageSavedMessage, setTriageSavedMessage] = useState('') const [triageSavedMessage, setTriageSavedMessage] = useState('')
const [showManualFeatureEditor, setShowManualFeatureEditor] = useState(false) const [showManualFeatureEditor, setShowManualFeatureEditor] = useState(false)
const [showManualParkingEditor, setShowManualParkingEditor] = useState(false) const [showManualParkingEditor, setShowManualParkingEditor] = useState(false)
@@ -230,6 +235,11 @@ function App() {
[appState.features, selectedFeatureId], [appState.features, selectedFeatureId],
) )
const handoffPreviewFeature = useMemo(
() => appState.features.find((feature) => feature.id === handoffPreviewFeatureId) ?? null,
[appState.features, handoffPreviewFeatureId],
)
const selectedParkingItem = useMemo( const selectedParkingItem = useMemo(
() => appState.parking_lot.find((item) => item.id === selectedParkingId) ?? null, () => appState.parking_lot.find((item) => item.id === selectedParkingId) ?? null,
[appState.parking_lot, selectedParkingId], [appState.parking_lot, selectedParkingId],
@@ -647,6 +657,7 @@ function App() {
setTriageOpen(true) setTriageOpen(true)
setTriageError('') setTriageError('')
setTriageEditMode(false) setTriageEditMode(false)
setTriageClarificationAnswer('')
setTriageSavedMessage('') setTriageSavedMessage('')
setTriageStatus(triageRecommendation && !seed ? 'ready' : 'idle') setTriageStatus(triageRecommendation && !seed ? 'ready' : 'idle')
if (seed) { if (seed) {
@@ -662,6 +673,7 @@ function App() {
setTriageStatus('idle') setTriageStatus('idle')
setTriageError('') setTriageError('')
setTriageEditMode(false) setTriageEditMode(false)
setTriageClarificationAnswer('')
} }
const openDecisionPulse = (pulseId?: string | null) => { const openDecisionPulse = (pulseId?: string | null) => {
@@ -732,7 +744,7 @@ function App() {
.map((pulse) => ({ message: pulse.message, evidence_refs: pulse.evidence_refs })), .map((pulse) => ({ message: pulse.message, evidence_refs: pulse.evidence_refs })),
}) })
const runAiTriage = async () => { const runAiTriage = async (optionalContextOverride?: string) => {
const rawIdea = triageDraft.rawIdea.trim() const rawIdea = triageDraft.rawIdea.trim()
if (!rawIdea) { if (!rawIdea) {
setTriageError('Write the idea first. The scope goblin needs bait.') setTriageError('Write the idea first. The scope goblin needs bait.')
@@ -740,6 +752,8 @@ function App() {
return return
} }
const optionalContext = optionalContextOverride ?? triageDraft.optionalContext.trim()
setTriageStatus('loading') setTriageStatus('loading')
setTriageError('') setTriageError('')
setTriageSavedMessage('') setTriageSavedMessage('')
@@ -748,7 +762,7 @@ function App() {
try { try {
const recommendation = await triageIdeaWithAi({ const recommendation = await triageIdeaWithAi({
raw_idea: rawIdea, raw_idea: rawIdea,
optional_context: triageDraft.optionalContext.trim(), optional_context: optionalContext,
app_context: buildAiTriageContext(), app_context: buildAiTriageContext(),
}) })
const timestamp = nowIso() const timestamp = nowIso()
@@ -756,7 +770,7 @@ function App() {
id: `rec_${slugify(rawIdea)}_${timestamp.replace(/[^0-9]/g, '')}`, id: `rec_${slugify(rawIdea)}_${timestamp.replace(/[^0-9]/g, '')}`,
created_at: timestamp, created_at: timestamp,
raw_idea: rawIdea, raw_idea: rawIdea,
optional_context: triageDraft.optionalContext.trim(), optional_context: optionalContext,
context_summary: `${appState.project.name}: ${appState.project.current_goal}`, context_summary: `${appState.project.name}: ${appState.project.current_goal}`,
...recommendation, ...recommendation,
user_decision: 'pending', user_decision: 'pending',
@@ -777,6 +791,7 @@ function App() {
}) })
setTriageStatus('ready') setTriageStatus('ready')
setTriageEditMode(false) setTriageEditMode(false)
setTriageClarificationAnswer('')
setStatusMessage(`AI suggested ${placementLabels[fullRecommendation.suggested_placement]} with ${fullRecommendation.scope_risk} scope risk.`) setStatusMessage(`AI suggested ${placementLabels[fullRecommendation.suggested_placement]} with ${fullRecommendation.scope_risk} scope risk.`)
} catch (error) { } catch (error) {
setTriageStatus('error') setTriageStatus('error')
@@ -1194,6 +1209,28 @@ function App() {
setStatusMessage('Pulse deleted.') setStatusMessage('Pulse deleted.')
} }
const answerClarifyingQuestion = async () => {
const answer = triageClarificationAnswer.trim()
if (!answer) {
setTriageError('Give the AI a real answer first.')
setTriageStatus('error')
return
}
const mergedContext = [triageDraft.optionalContext.trim(), `Clarification answer: ${answer}`]
.filter(Boolean)
.join('\n\n')
setTriageDraft((current) => ({
...current,
optionalContext: mergedContext,
}))
setTriageRecommendation(null)
setTriageEditMode(false)
setStatusMessage('Re-running triage with your clarification…')
await runAiTriage(mergedContext)
}
const copyMarkdown = async (filename: string) => { const copyMarkdown = async (filename: string) => {
try { try {
await navigator.clipboard.writeText(markdownPackage[filename as keyof typeof markdownPackage]) await navigator.clipboard.writeText(markdownPackage[filename as keyof typeof markdownPackage])
@@ -1203,15 +1240,110 @@ function App() {
} }
} }
const copySessionPrompt = async () => { const buildHandoffPrompt = (featureId?: string, target = promptTarget) => {
const resolvedFeatureId = featureId ?? selectedFeature?.id ?? (promptFeatureId || groupedFeatures.now[0]?.id)
return {
resolvedFeatureId,
prompt: createAgentSessionPrompt(appState, {
featureId: resolvedFeatureId || undefined,
target,
}),
}
}
const logHandoffIntent = (featureId: string, target: (typeof PROMPT_TARGETS)[number]) => {
const feature = appState.features.find((entry) => entry.id === featureId)
if (!feature) return null
const timestamp = nowIso()
const pulse: PulseEvent = {
id: `pulse_${Date.now().toString(36)}`,
timestamp,
project_id: appState.project.id,
feature_id: feature.id,
source: 'buildpulse',
agent_id: target,
pulse_type: 'INTENT',
message: `Generated ${target} handoff for feature “${feature.title}”.`,
confidence_score: 0.9,
evidence_refs: [
'Feature-detail handoff action',
feature.release_id ? `Release: ${appState.releases.find((release) => release.id === feature.release_id)?.name || feature.release_id}` : 'No linked release',
feature.phase_id ? `Phase: ${appState.phases.find((phase) => phase.id === feature.phase_id)?.title || feature.phase_id}` : 'No linked phase',
],
}
setAppState((current) => ({
...current,
pulses: [pulse, ...current.pulses],
}))
return feature
}
const copyFocusedHandoff = async (featureId?: string, target = promptTarget, shouldLogIntent = false, promptOverride?: string) => {
const { resolvedFeatureId, prompt } = buildHandoffPrompt(featureId, target)
const finalPrompt = promptOverride ?? prompt
try { try {
await navigator.clipboard.writeText(sessionPrompt) await navigator.clipboard.writeText(finalPrompt)
setStatusMessage('AI session prompt copied to clipboard.') if (resolvedFeatureId) {
setPromptFeatureId(resolvedFeatureId)
}
setPromptTarget(target)
let feature = resolvedFeatureId ? appState.features.find((entry) => entry.id === resolvedFeatureId) : null
if (resolvedFeatureId && shouldLogIntent) {
feature = logHandoffIntent(resolvedFeatureId, target) ?? feature
}
setStatusMessage(
feature
? `${target} handoff for “${feature.title}” copied${shouldLogIntent ? ' and INTENT logged' : ''}.`
: `${target} handoff copied${shouldLogIntent ? ' and INTENT logged' : ''}.`,
)
return true
} catch { } catch {
setStatusMessage('Clipboard copy failed. Browser said no.') setStatusMessage('Clipboard copy failed. Browser said no.')
return false
} }
} }
const openHandoffPreview = (featureId?: string, target = promptTarget) => {
const { resolvedFeatureId, prompt } = buildHandoffPrompt(featureId, target)
if (resolvedFeatureId) {
setPromptFeatureId(resolvedFeatureId)
setHandoffPreviewFeatureId(resolvedFeatureId)
}
setHandoffPreviewTarget(target)
setHandoffPreviewDraft(prompt)
setHandoffPreviewOpen(true)
setStatusMessage('Preview the handoff, tweak it if needed, then copy when ready.')
}
const syncHandoffPreviewTarget = (target: (typeof PROMPT_TARGETS)[number]) => {
const { prompt } = buildHandoffPrompt(handoffPreviewFeatureId || undefined, handoffPreviewTarget)
if (handoffPreviewDraft.trim() !== prompt.trim()) {
const shouldReplace = window.confirm('Switching target will replace your current edits with the new preset. Continue?')
if (!shouldReplace) return
}
const { prompt: nextPrompt } = buildHandoffPrompt(handoffPreviewFeatureId || undefined, target)
setHandoffPreviewTarget(target)
setHandoffPreviewDraft(nextPrompt)
}
const resetHandoffPreview = () => {
if (!handoffPreviewFeatureId) return
const { prompt } = buildHandoffPrompt(handoffPreviewFeatureId, handoffPreviewTarget)
setHandoffPreviewDraft(prompt)
setStatusMessage('Handoff preview reset to the current preset.')
}
const copySessionPrompt = async () => {
await copyFocusedHandoff(promptFeatureId || undefined, promptTarget, false)
}
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
@@ -1307,7 +1439,7 @@ function App() {
tab: 'export' as TabKey, tab: 'export' as TabKey,
operatorNote: 'Use this when another agent or coding session needs clean context without archaeology.', operatorNote: 'Use this when another agent or coding session needs clean context without archaeology.',
evidence: ['JSON export preserves full app state.', 'JSONL export carries pulse history.', 'Markdown package includes agent-facing project, feature, parking, pulse, and context files.'], evidence: ['JSON export preserves full app state.', 'JSONL export carries pulse history.', 'Markdown package includes agent-facing project, feature, parking, pulse, and context files.'],
next: 'Add a “copy focused handoff” button directly on each capability detail.', next: 'Add target-specific prompt presets so OpenClaw, Codex, and Claude get sharper default briefs.',
}, },
{ {
title: 'Appwrite Sync', title: 'Appwrite Sync',
@@ -1339,7 +1471,7 @@ function App() {
<div className="app-shell"> <div className="app-shell">
<header className="mobile-shell-header card"> <header className="mobile-shell-header card">
<div> <div>
<p className="eyebrow">BuildPulse v0.3.0</p> <p className="eyebrow">BuildPulse v0.4.1</p>
<h1>{appState.project.name}</h1> <h1>{appState.project.name}</h1>
<p className="hero-goal compact-goal"> <p className="hero-goal compact-goal">
<strong>Current goal:</strong> {appState.project.current_goal || 'Classify new ideas before they become work.'} <strong>Current goal:</strong> {appState.project.current_goal || 'Classify new ideas before they become work.'}
@@ -1525,16 +1657,20 @@ function App() {
<div className="triage-step-card triage-decision-box"> <div className="triage-step-card triage-decision-box">
<p className="eyebrow">Step 3 of 3</p> <p className="eyebrow">Step 3 of 3</p>
<h3>Choose action</h3> <h3>{triageNeedsClarification ? 'Clarify before deciding' : 'Choose action'}</h3>
<p>AI advises. You decide.</p> <p>{triageNeedsClarification ? 'Answer the question, then re-run triage with the missing detail.' : 'AI advises. You decide.'}</p>
<div className="button-row triage-action-stack"> <div className="button-row triage-action-stack">
{triageNeedsClarification ? ( {triageNeedsClarification ? (
<> <>
<button type="button" onClick={() => setTriageEditMode(true)}>Answer Question</button> <button type="button" className="primary" onClick={() => {
setTriageEditMode(true)
setStatusMessage('Answer the clarification question below, then re-run triage.')
}}>Answer + Retry</button>
<button type="button" className="ghost" onClick={() => { <button type="button" className="ghost" onClick={() => {
setTriageRecommendation(null) setTriageRecommendation(null)
setTriageStatus('idle') setTriageStatus('idle')
setTriageEditMode(false) setTriageEditMode(false)
setTriageClarificationAnswer('')
}}>Edit Idea</button> }}>Edit Idea</button>
<button type="button" className="danger small" onClick={rejectTriageRecommendation}>Reject</button> <button type="button" className="danger small" onClick={rejectTriageRecommendation}>Reject</button>
</> </>
@@ -1560,7 +1696,32 @@ function App() {
{triageEditMode && ( {triageEditMode && (
<div className="triage-step-card triage-edit-fields form-grid"> <div className="triage-step-card triage-edit-fields form-grid">
<p className="eyebrow">Edit before saving</p> <p className="eyebrow">{triageNeedsClarification ? 'Answer and refine' : 'Edit before saving'}</p>
{triageNeedsClarification && (
<>
<div className="triage-callout warning full-span">
<strong>Answer the question</strong>
<p>{triageRecommendation?.clarifying_question || 'Clarify the idea, then re-run triage.'}</p>
</div>
<label className="full-span">
Your answer
<textarea
rows={4}
value={triageClarificationAnswer}
onChange={(event) => setTriageClarificationAnswer(event.target.value)}
placeholder="Add the missing detail so the AI can classify the idea properly."
/>
</label>
<div className="button-inline-row full-span">
<button type="button" onClick={() => void answerClarifyingQuestion()} disabled={triageStatus === 'loading'}>
{triageStatus === 'loading' ? 'Re-running…' : 'Re-run with Answer'}
</button>
<button type="button" className="ghost small" onClick={() => setTriageEditMode(false)}>
Hide Clarification
</button>
</div>
</>
)}
<label> <label>
Placement Placement
<select value={triageEditable.placement} onChange={(event) => setTriageEditable((current) => ({ ...current, placement: event.target.value as AiPlacement }))}> <select value={triageEditable.placement} onChange={(event) => setTriageEditable((current) => ({ ...current, placement: event.target.value as AiPlacement }))}>
@@ -1603,6 +1764,95 @@ function App() {
</div> </div>
)} )}
{handoffPreviewOpen && handoffPreviewFeature && (
<div className="editor-sheet-backdrop" role="presentation">
<section className="card editor-sheet handoff-sheet" role="dialog" aria-modal="true" aria-label="Handoff preview">
<div className="section-heading compact">
<div>
<p className="eyebrow">v0.4.1 Handoff preview</p>
<h3>{handoffPreviewFeature.title}</h3>
<p>Choose the target, tweak the brief if needed, then copy it cleanly.</p>
</div>
<button type="button" className="ghost small" onClick={() => setHandoffPreviewOpen(false)}>
Close
</button>
</div>
<div className="sheet-summary-grid handoff-preview-grid">
<div className="focus-panel">
<h4>Preview context</h4>
<div className="focus-badges">
<span className="pill">{handoffPreviewTarget}</span>
<span className="pill">{handoffPreviewFeature.status}</span>
<span className={`pill ${handoffPreviewFeature.priority}`}>{handoffPreviewFeature.priority}</span>
{handoffPreviewFeature.release_role && <span className="pill">{handoffPreviewFeature.release_role}</span>}
</div>
<p><strong>Phase:</strong> {appState.phases.find((phase) => phase.id === handoffPreviewFeature.phase_id)?.title || 'No phase linked'}</p>
<p><strong>Release:</strong> {appState.releases.find((release) => release.id === handoffPreviewFeature.release_id)?.name || 'No release linked'}</p>
<p><strong>Acceptance criteria:</strong> {handoffPreviewFeature.acceptance_criteria.length ? handoffPreviewFeature.acceptance_criteria.join('; ') : 'None yet'}</p>
<p><strong>Scope guard:</strong> {handoffPreviewFeature.scope_notes || 'No scope notes yet.'}</p>
</div>
<div className="focus-panel">
<h4>Handoff controls</h4>
<div className="button-inline-row">
{PROMPT_TARGETS.map((target) => (
<button
key={target}
type="button"
className={handoffPreviewTarget === target ? 'primary small' : 'ghost small'}
onClick={() => syncHandoffPreviewTarget(target)}
>
{target}
</button>
))}
</div>
<p className="tap-hint">Preview mode is the safer path when you want to edit wording or create a clean INTENT pulse.</p>
<p className="tap-hint">Switching targets resets the draft to that targets preset.</p>
<div className="button-inline-row">
<button type="button" className="ghost small" onClick={resetHandoffPreview}>Reset to preset</button>
<button type="button" className="ghost small" onClick={() => openFeatureHandoff(handoffPreviewFeature.id)}>Open in Export</button>
</div>
</div>
</div>
<label className="full-span handoff-preview-editor">
<span>Editable handoff brief</span>
<textarea
rows={18}
value={handoffPreviewDraft}
onChange={(event) => setHandoffPreviewDraft(event.target.value)}
/>
</label>
<div className="button-row sheet-actions">
<button
type="button"
className="ghost"
onClick={async () => {
const ok = await copyFocusedHandoff(handoffPreviewFeature.id, handoffPreviewTarget, false, handoffPreviewDraft)
if (ok) setHandoffPreviewOpen(false)
}}
>
Copy Handoff
</button>
<button
type="button"
className="primary"
onClick={async () => {
const ok = await copyFocusedHandoff(handoffPreviewFeature.id, handoffPreviewTarget, true, handoffPreviewDraft)
if (ok) setHandoffPreviewOpen(false)
}}
>
Copy + INTENT Pulse
</button>
<button type="button" className="ghost" onClick={() => setHandoffPreviewOpen(false)}>
Cancel
</button>
</div>
</section>
</div>
)}
{featureSheetOpen && ( {featureSheetOpen && (
<div className="editor-sheet-backdrop" role="presentation"> <div className="editor-sheet-backdrop" role="presentation">
<section className="card editor-sheet" role="dialog" aria-modal="true" aria-label={selectedFeature ? 'Feature details' : 'Manual feature'}> <section className="card editor-sheet" role="dialog" aria-modal="true" aria-label={selectedFeature ? 'Feature details' : 'Manual feature'}>
@@ -1665,7 +1915,26 @@ function App() {
Log Feature Pulse Log Feature Pulse
</button> </button>
<button type="button" className="ghost small" onClick={() => openFeatureHandoff(selectedFeature.id)}> <button type="button" className="ghost small" onClick={() => openFeatureHandoff(selectedFeature.id)}>
Prepare AI Handoff Open in Export
</button>
<button type="button" className="primary small" onClick={() => openHandoffPreview(selectedFeature.id, 'OpenClaw')}>
Preview / Edit Handoff
</button>
</div>
<p className="tap-hint">Preview is the safer path. Quick-copy is for when you already know the target and do not need edits.</p>
<p className="tap-hint">Quick copy targets</p>
<div className="button-inline-row">
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'OpenClaw', false)}>
Copy for OpenClaw
</button>
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'Claude Code', false)}>
Copy for Claude Code
</button>
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'Codex', false)}>
Copy for Codex
</button>
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'Generic Agent', false)}>
Copy Generic Brief
</button> </button>
</div> </div>
</div> </div>
@@ -1939,7 +2208,7 @@ function App() {
<section className="card functionality-hero"> <section className="card functionality-hero">
<div> <div>
<p className="eyebrow">Secondary status</p> <p className="eyebrow">Secondary status</p>
<h3>{appState.project.name} is now a local-first v0.3 cockpit with Appwrite sync and release-planning structure.</h3> <h3>{appState.project.name} is now a local-first v0.4 cockpit with release structure, AI triage, and one-tap handoff prep.</h3>
<p> <p>
The planning loop works from browser storage first, then syncs to Appwrite for the deployed Unraid runtime. If sync degrades, the cockpit should still stay usable locally. The planning loop works from browser storage first, then syncs to Appwrite for the deployed Unraid runtime. If sync degrades, the cockpit should still stay usable locally.
</p> </p>
@@ -2029,7 +2298,17 @@ function App() {
<h3>{selectedStatusCard.title}</h3> <h3>{selectedStatusCard.title}</h3>
<p>{selectedStatusCard.operatorNote}</p> <p>{selectedStatusCard.operatorNote}</p>
</div> </div>
<span className={`pill functionality-${selectedStatusCard.status}`}>{selectedStatusCard.status}</span> <div className="button-inline-row">
{selectedStatusCard.title === 'AI Handoff + Export' && (
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(undefined, 'OpenClaw')}>
Copy Focused Handoff
</button>
)}
<button type="button" className="ghost small" onClick={() => setActiveTab(selectedStatusCard.tab)}>
{selectedStatusCard.action}
</button>
<span className={`pill functionality-${selectedStatusCard.status}`}>{selectedStatusCard.status}</span>
</div>
</div> </div>
<div className="functionality-detail-grid"> <div className="functionality-detail-grid">
<article> <article>
+90 -11
View File
@@ -39,6 +39,47 @@ 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))
const isFeatureDone = (feature: Feature) => feature.status === 'done' || feature.column === 'done'
const targetPresets: Record<string, { intro: string; workingStyle: string[]; deliverBack: string[] }> = {
OpenClaw: {
intro: 'Work like a proactive operator: act, verify, and keep the slice small enough to ship cleanly.',
workingStyle: [
'Use local repo evidence before making claims.',
'Prefer direct fixes plus a quick verification gate.',
'Keep follow-up notes short and operational.',
],
deliverBack: ['What changed', 'Files touched', 'How you verified it', 'Any blocker or parked follow-up'],
},
'Claude Code': {
intro: 'Work like a careful coding pair: make the smallest coherent change, explain tradeoffs briefly, and leave the tree tidy.',
workingStyle: [
'Prefer narrowly scoped edits over broad rewrites.',
'Call out any assumption that could affect correctness.',
'End with concise verification notes and next-risk if any.',
],
deliverBack: ['Summary', 'Files changed', 'Verification', 'Risks or follow-ups'],
},
Codex: {
intro: 'Work like a focused implementation run: minimal scope, crisp diffs, and explicit evidence before claiming success.',
workingStyle: [
'Bias toward direct implementation over long discussion.',
'Preserve existing behavior unless the task explicitly changes it.',
'Name the exact verification step you ran.',
],
deliverBack: ['Changes made', 'Touched files', 'Verification run', 'Remaining follow-up'],
},
'Generic Agent': {
intro: 'Deliver one clean, well-bounded improvement without drifting into adjacent ideas.',
workingStyle: [
'Stay anchored to the focus feature.',
'Avoid speculative architecture work.',
'Return with concrete evidence, not just intention.',
],
deliverBack: ['What changed', 'Files touched', 'How you verified it', 'Any blocker or parked follow-up'],
},
}
export const createAgentSessionPrompt = ( export const createAgentSessionPrompt = (
state: AppState, state: AppState,
options?: { options?: {
@@ -48,12 +89,36 @@ export const createAgentSessionPrompt = (
) => { ) => {
const grouped = groupFeatures(state.features) const grouped = groupFeatures(state.features)
const target = options?.target || 'AI coding agent' const target = options?.target || 'AI coding agent'
const preset = targetPresets[target] || targetPresets['Generic Agent']
const focusFeature = options?.featureId ? state.features.find((feature) => feature.id === options.featureId) ?? null : grouped.now[0] ?? null const focusFeature = options?.featureId ? state.features.find((feature) => feature.id === options.featureId) ?? null : grouped.now[0] ?? null
const focusRelease = focusFeature?.release_id ? state.releases.find((release) => release.id === focusFeature.release_id) ?? null : null const focusRelease = focusFeature?.release_id ? state.releases.find((release) => release.id === focusFeature.release_id) ?? null : null
const focusPhase = focusFeature?.phase_id ? state.phases.find((phase) => phase.id === focusFeature.phase_id) ?? null : null const focusPhase = focusFeature?.phase_id ? state.phases.find((phase) => phase.id === focusFeature.phase_id) ?? null : null
const relatedPulses = sortPulsesNewestFirst(state.pulses) const relatedPulses = sortPulsesNewestFirst(state.pulses)
.filter((pulse) => (focusFeature ? pulse.feature_id === focusFeature.id : true)) .filter((pulse) => (focusFeature ? pulse.feature_id === focusFeature.id : true))
.slice(0, 6) .slice(0, 6)
const releaseFeatures = focusRelease
? state.features.filter((feature) => feature.release_id === focusRelease.id)
: []
const requiredReleaseFeatures = focusRelease
? releaseFeatures.filter((feature) => focusRelease.required_feature_ids.includes(feature.id))
: []
const completedRequiredFeatures = requiredReleaseFeatures.filter(isFeatureDone)
const remainingRequiredFeatures = requiredReleaseFeatures.filter((feature) => !isFeatureDone(feature))
const releaseBlockers = sortPulsesNewestFirst(state.pulses)
.filter((pulse) => {
if (pulse.pulse_type !== 'BLOCKER') return false
if (focusFeature && pulse.feature_id === focusFeature.id) return true
if (!focusRelease) return false
return releaseFeatures.some((feature) => feature.id === pulse.feature_id)
})
.slice(0, 4)
const forbiddenWarnings = [
...(focusRelease?.forbidden_feature_titles || []).map((title) => `${title} — forbidden in this release.`),
...state.parking_lot.map((item) => `${item.title}${item.reason_parked || item.description || 'Parked for later.'}`),
]
const readinessSummary = focusRelease
? `Required done: ${completedRequiredFeatures.length}/${requiredReleaseFeatures.length || focusRelease.required_feature_ids.length || 0}`
: 'No release linked to this feature yet.'
const renderFeatureBlock = (feature: Feature | null) => { const renderFeatureBlock = (feature: Feature | null) => {
if (!feature) return '- No specific feature selected yet. Choose the smallest useful next move.' if (!feature) return '- No specific feature selected yet. Choose the smallest useful next move.'
@@ -75,6 +140,7 @@ export const createAgentSessionPrompt = (
return [ return [
`You are the ${target} for ${state.project.name}.`, `You are the ${target} for ${state.project.name}.`,
'', '',
preset.intro,
'Read the project context below and ship the smallest high-quality improvement that satisfies the focus feature without scope creep.', 'Read the project context below and ship the smallest high-quality improvement that satisfies the focus feature without scope creep.',
'', '',
'## Project', '## Project',
@@ -97,15 +163,28 @@ export const createAgentSessionPrompt = (
'## Next Up', '## Next Up',
grouped.next.length ? grouped.next.map((feature) => `- ${feature.title} (${feature.status})`).join('\n') : '- Nothing queued yet.', grouped.next.length ? grouped.next.map((feature) => `- ${feature.title} (${feature.status})`).join('\n') : '- Nothing queued yet.',
'', '',
'## Current Release Context', '## Current Phase + Release Context',
focusRelease focusRelease
? [`- Release: ${focusRelease.name}`, `- Goal: ${focusRelease.goal}`, `- Status: ${focusRelease.status}`, `- Definition of done: ${focusRelease.definition_of_done.length ? focusRelease.definition_of_done.join('; ') : '—'}`].join('\n') ? [
: '- No release linked to this feature yet.', `- Phase: ${focusPhase?.title || '—'} (${focusPhase?.status || '—'})`,
`- Release: ${focusRelease.name}`,
`- Goal: ${focusRelease.goal}`,
`- Status: ${focusRelease.status}`,
`- Release readiness: ${readinessSummary}`,
`- Remaining required features: ${remainingRequiredFeatures.length ? remainingRequiredFeatures.map((feature) => feature.title).join('; ') : 'None'}`,
`- Definition of done: ${focusRelease.definition_of_done.length ? focusRelease.definition_of_done.join('; ') : '—'}`,
].join('\n')
: `- Phase: ${focusPhase?.title || '—'} (${focusPhase?.status || '—'})\n- No release linked to this feature yet.`,
'', '',
'## Parking Lot / Do Not Implement Yet', '## Relevant Blockers',
state.parking_lot.length releaseBlockers.length
? state.parking_lot.map((item) => `- ${item.title} ${item.reason_parked || item.description || 'Parked for later.'}`).join('\n') ? releaseBlockers.map((pulse) => `- ${formatDateTime(pulse.timestamp)} · ${getFeatureLabel(state.features, pulse.feature_id)} · ${pulse.message}`).join('\n')
: '- Nothing parked yet.', : '- No current blocker pulses for this feature or release.',
'',
'## Parking Lot / Forbidden Work',
forbiddenWarnings.length
? forbiddenWarnings.map((item) => `- ${item}`).join('\n')
: '- Nothing parked or forbidden right now.',
'', '',
'## Recent Relevant Pulses', '## Recent Relevant Pulses',
relatedPulses.length relatedPulses.length
@@ -118,11 +197,11 @@ export const createAgentSessionPrompt = (
'- Preserve working behavior and avoid needless rewrites.', '- Preserve working behavior and avoid needless rewrites.',
'- Prefer the smallest shippable step with clear evidence.', '- Prefer the smallest shippable step with clear evidence.',
'', '',
'## Working Style',
...preset.workingStyle.map((item) => `- ${item}`),
'',
'## Deliver back', '## Deliver back',
'- What changed', ...preset.deliverBack.map((item) => `- ${item}`),
'- Files touched',
'- How you verified it',
'- Any blocker or follow-up you intentionally parked',
].join('\n') ].join('\n')
} }
+43 -6
View File
@@ -10,7 +10,7 @@ export const createSeedState = (): AppState => ({
one_line_pitch: 'A calm planning cockpit for AI-assisted product building.', one_line_pitch: 'A calm planning cockpit for AI-assisted product building.',
description: description:
'BuildPulse helps capture features, park distracting ideas, log progress as Pulse events, and export clean context for AI coding agents.', 'BuildPulse helps capture features, park distracting ideas, log progress as Pulse events, and export clean context for AI coding agents.',
current_goal: 'Ship v0.3 with Phases, Releases, and clear release-readiness signals.', current_goal: 'Sharpen v0.4 handoff workflows so coding agents get the right context fast.',
notes: 'First dogfood project: BuildPulse manages BuildPulse.', notes: 'First dogfood project: BuildPulse manages BuildPulse.',
created_at: seedDate, created_at: seedDate,
updated_at: seedDate, updated_at: seedDate,
@@ -40,9 +40,9 @@ export const createSeedState = (): AppState => ({
id: 'phase_structured_release_planning', id: 'phase_structured_release_planning',
title: 'Phase 3: Structured Release Planning', title: 'Phase 3: Structured Release Planning',
goal: 'Make releases concrete: what is required, what is forbidden, and how close the build is to ready.', goal: 'Make releases concrete: what is required, what is forbidden, and how close the build is to ready.',
status: 'active', status: 'done',
order: 3, order: 3,
notes: 'This is the v0.3 step.', notes: 'v0.3 shipped with phases, releases, and readiness signals.',
created_at: seedDate, created_at: seedDate,
updated_at: seedDate, updated_at: seedDate,
}, },
@@ -50,9 +50,9 @@ export const createSeedState = (): AppState => ({
id: 'phase_session_handoff', id: 'phase_session_handoff',
title: 'Phase 4: Session Handoff', title: 'Phase 4: Session Handoff',
goal: 'Give agents cleaner, more targeted context packages for implementation sessions.', goal: 'Give agents cleaner, more targeted context packages for implementation sessions.',
status: 'upcoming', status: 'active',
order: 4, order: 4,
notes: 'Planned v0.4 direction.', notes: 'Current v0.4 step: faster, more focused handoff workflows for coding sessions.',
created_at: seedDate, created_at: seedDate,
updated_at: seedDate, updated_at: seedDate,
}, },
@@ -118,13 +118,50 @@ export const createSeedState = (): AppState => ({
'Local/cloud model router', 'Local/cloud model router',
'Session prompt generator', 'Session prompt generator',
], ],
status: 'shipped',
notes: 'Shipped. Release planning structure is live; still no live integrations.',
created_at: seedDate,
updated_at: seedDate,
},
{
id: 'release_v040_focused_handoffs',
phase_id: 'phase_session_handoff',
name: 'v0.4 — Focused Session Handoffs',
goal: 'Cut the friction between choosing a feature and giving a coding agent a sharp, minimal brief.',
definition_of_done: [
'Each feature detail exposes one-tap copy actions for OpenClaw, Claude Code, Codex, and a generic brief.',
'Prompt generation includes phase, release, readiness, blockers, parking-lot, and forbidden-work guardrails.',
'Preview/edit before copy is available with explicit INTENT pulse controls and mobile-safe actions.',
],
required_feature_ids: ['export_screen', 'feature_focused_handoff_shortcuts'],
optional_feature_ids: ['pulse_log_screen'],
forbidden_feature_titles: ['Live OpenClaw/Hermes Agent Status', 'WebSocket agent telemetry', 'GitHub / Gitea sync'],
status: 'in_progress', status: 'in_progress',
notes: 'Keep this on planning structure only. No live integrations yet.', notes: 'v0.4.1 adds preview/edit before copy and cleaner INTENT controls; result capture comes next.',
created_at: seedDate, created_at: seedDate,
updated_at: seedDate, updated_at: seedDate,
}, },
], ],
features: [ features: [
{
id: 'feature_focused_handoff_shortcuts',
title: 'One-tap target-specific handoffs',
description: 'Let operators copy a sharp AI handoff for OpenClaw, Claude Code, Codex, or a generic agent directly from feature detail.',
column: 'now',
priority: 'must',
status: 'building',
acceptance_criteria: [
'Feature detail exposes separate copy actions for OpenClaw, Claude Code, Codex, and Generic.',
'Preview/edit is available before copy, with target switching and reset-to-preset behavior.',
'INTENT pulse logging is explicit from the preview flow instead of hidden behind a toggle.',
],
scope_notes: 'This is the v0.4 loop: quick-copy when you know the target, preview/edit when you want a safer brief, then let the agent work.',
phase_id: 'phase_session_handoff',
release_id: 'release_v040_focused_handoffs',
release_role: 'required',
created_at: seedDate,
updated_at: seedDate,
},
{ {
id: 'feature_plan_screen', id: 'feature_plan_screen',
title: 'Feature Plan screen', title: 'Feature Plan screen',
+55 -6
View File
@@ -263,6 +263,31 @@ textarea {
padding: 1rem; padding: 1rem;
} }
.handoff-sheet {
max-width: 1080px;
}
.handoff-preview-grid {
align-items: start;
}
.handoff-preview-editor {
display: grid;
gap: 0.55rem;
}
.handoff-preview-editor span {
font-size: 0.85rem;
color: #9fb9d6;
}
.handoff-preview-editor textarea {
min-height: 22rem;
font-family: 'IBM Plex Mono', 'SFMono-Regular', ui-monospace, monospace;
font-size: 0.85rem;
line-height: 1.45;
}
.focus-panel h4 { .focus-panel h4 {
margin: 0 0 0.65rem; margin: 0 0 0.65rem;
} }
@@ -300,26 +325,43 @@ textarea {
button, button,
.import-label { .import-label {
border: 0; border: 0;
border-radius: 14px; border-radius: 10px;
background: linear-gradient(135deg, #60a5fa, #818cf8); background: linear-gradient(135deg, #60a5fa, #818cf8);
color: white; color: white;
padding: 0.8rem 1rem; padding: 0.8rem 1rem;
transition: transform 120ms ease, opacity 120ms ease; font-weight: 600;
letter-spacing: 0.01em;
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.2);
transition: transform 120ms ease, opacity 120ms ease, box-shadow 120ms ease, background 120ms ease;
} }
button:hover, button:hover,
.import-label:hover { .import-label:hover {
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.24);
}
button.primary {
background: linear-gradient(135deg, #67e8f9, #818cf8);
color: #06111c;
} }
button.ghost, button.ghost,
.import-label { .import-label {
background: rgba(30, 41, 59, 0.84); background: rgba(18, 25, 40, 0.88);
border: 1px solid rgba(148, 163, 184, 0.16); border: 1px solid rgba(148, 163, 184, 0.18);
color: #dbe7ff;
box-shadow: none;
}
button.ghost:hover,
.import-label:hover {
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.22);
} }
button.danger { button.danger {
background: linear-gradient(135deg, #f97316, #ef4444); background: linear-gradient(135deg, #f97316, #ef4444);
color: white;
} }
button.small { button.small {
@@ -426,6 +468,9 @@ pre {
.tap-chip { .tap-chip {
align-self: flex-start; align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 0.45rem;
margin-top: 0.15rem; margin-top: 0.15rem;
border-radius: 999px; border-radius: 999px;
padding: 0.22rem 0.65rem; padding: 0.22rem 0.65rem;
@@ -435,6 +480,10 @@ pre {
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
.tap-chip input {
accent-color: #818cf8;
}
.feature-signal-row { .feature-signal-row {
font-size: 0.82rem; font-size: 0.82rem;
color: #9fb4d9; color: #9fb4d9;
@@ -914,7 +963,7 @@ body::before {
overscroll-behavior-x: contain; overscroll-behavior-x: contain;
scrollbar-width: none; scrollbar-width: none;
border: 1px solid rgba(148, 163, 184, 0.2); border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 999px; border-radius: 18px;
background: rgba(3, 7, 18, 0.78); background: rgba(3, 7, 18, 0.78);
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.28); box-shadow: 0 18px 36px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
@@ -926,7 +975,7 @@ body::before {
.tab { .tab {
min-width: 8.5rem; min-width: 8.5rem;
border-radius: 999px; border-radius: 14px;
border-color: transparent; border-color: transparent;
background: transparent; background: transparent;
color: #9fb0c9; color: #9fb0c9;