feat: ship one-tap feature handoffs

This commit is contained in:
OpenClaw Bot
2026-05-11 13:19:22 +02:00
parent de1855838e
commit 34413ffafa
7 changed files with 133 additions and 35 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ 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.
Current release line:
- v0.3.2 — target-specific handoff presets plus cleaner focused-handoff prep
- v0.4.0 — one-tap feature handoffs with target-specific briefs and optional INTENT logging
Personal runtime target:
- `build.friborg.uk`
+15 -11
View File
@@ -58,21 +58,25 @@ Potential features:
- Required vs optional features
- Release readiness view
## v0.4 — Session Prompt Generator
## v0.4 — Handoff Workflow Hardening
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:
- Start 30-minute session from feature
- Generate Claude Code/Codex prompt
- Include do-not-touch list
- Include acceptance criteria
- Include recent pulse context
- End-session summary template
Current shipped slices:
- v0.3.1 — focused handoff shortcuts
- v0.3.2 — target-specific handoff presets
- v0.4.0 — one-tap feature handoffs
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.
Planned slices:
- v0.4.1 — preview/edit before copy + optional INTENT pulse controls
- 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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "buildpulse",
"private": true,
"version": "0.3.2",
"version": "0.4.0",
"type": "module",
"scripts": {
"api": "node --env-file=../.env server/index.mjs",
+54 -5
View File
@@ -118,6 +118,7 @@ function App() {
const [pulseFeatureFilter, setPulseFeatureFilter] = useState('all')
const [pulseSourceFilter, setPulseSourceFilter] = useState('all')
const [promptTarget, setPromptTarget] = useState<(typeof PROMPT_TARGETS)[number]>('OpenClaw')
const [logIntentOnHandoff, setLogIntentOnHandoff] = useState(true)
const [promptFeatureId, setPromptFeatureId] = useState('')
const [backendMode, setBackendMode] = useState<'connecting' | 'appwrite' | 'local-cache'>('connecting')
const [syncStatus, setSyncStatus] = useState<'connecting' | 'synced' | 'pending' | 'syncing' | 'degraded'>('connecting')
@@ -1203,7 +1204,7 @@ function App() {
}
}
const copyFocusedHandoff = async (featureId?: string, target = promptTarget) => {
const copyFocusedHandoff = async (featureId?: string, target = promptTarget, shouldLogIntent = logIntentOnHandoff) => {
const resolvedFeatureId = featureId ?? selectedFeature?.id ?? (promptFeatureId || groupedFeatures.now[0]?.id)
const prompt = createAgentSessionPrompt(appState, {
featureId: resolvedFeatureId || undefined,
@@ -1218,7 +1219,36 @@ function App() {
setPromptTarget(target)
const feature = resolvedFeatureId ? appState.features.find((entry) => entry.id === resolvedFeatureId) : null
setStatusMessage(feature ? `Focused AI handoff for “${feature.title}” copied.` : 'Focused AI handoff copied.')
if (feature && shouldLogIntent) {
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],
}))
}
setStatusMessage(
feature
? `${target} handoff for “${feature.title}” copied${shouldLogIntent ? ' and INTENT logged' : ''}.`
: `${target} handoff copied${shouldLogIntent ? ' and INTENT logged' : ''}.`,
)
} catch {
setStatusMessage('Clipboard copy failed. Browser said no.')
}
@@ -1355,7 +1385,7 @@ function App() {
<div className="app-shell">
<header className="mobile-shell-header card">
<div>
<p className="eyebrow">BuildPulse v0.3.1</p>
<p className="eyebrow">BuildPulse v0.4.0</p>
<h1>{appState.project.name}</h1>
<p className="hero-goal compact-goal">
<strong>Current goal:</strong> {appState.project.current_goal || 'Classify new ideas before they become work.'}
@@ -1683,8 +1713,27 @@ function App() {
<button type="button" className="ghost small" onClick={() => openFeatureHandoff(selectedFeature.id)}>
Prepare AI Handoff
</button>
</div>
<label className="tap-chip">
<input
type="checkbox"
checked={logIntentOnHandoff}
onChange={(event) => setLogIntentOnHandoff(event.target.checked)}
/>
<span>Log INTENT pulse when copying handoff</span>
</label>
<div className="button-inline-row">
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'OpenClaw')}>
Copy AI Handoff
Copy for OpenClaw
</button>
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'Claude Code')}>
Copy for Claude Code
</button>
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'Codex')}>
Copy for Codex
</button>
<button type="button" className="ghost small" onClick={() => void copyFocusedHandoff(selectedFeature.id, 'Generic Agent')}>
Copy Generic Brief
</button>
</div>
</div>
@@ -1958,7 +2007,7 @@ function App() {
<section className="card functionality-hero">
<div>
<p className="eyebrow">Secondary status</p>
<h3>{appState.project.name} is now a local-first v0.3 cockpit with Appwrite sync, release-planning structure, and faster handoff prep.</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>
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>
+45 -7
View File
@@ -39,6 +39,8 @@ const renderFeature = (feature: Feature) => {
const sortPulsesNewestFirst = (pulses: PulseEvent[]) =>
[...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.',
@@ -94,6 +96,29 @@ export const createAgentSessionPrompt = (
const relatedPulses = sortPulsesNewestFirst(state.pulses)
.filter((pulse) => (focusFeature ? pulse.feature_id === focusFeature.id : true))
.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) => {
if (!feature) return '- No specific feature selected yet. Choose the smallest useful next move.'
@@ -138,15 +163,28 @@ export const createAgentSessionPrompt = (
'## Next Up',
grouped.next.length ? grouped.next.map((feature) => `- ${feature.title} (${feature.status})`).join('\n') : '- Nothing queued yet.',
'',
'## Current Release Context',
'## Current Phase + Release Context',
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',
state.parking_lot.length
? state.parking_lot.map((item) => `- ${item.title} ${item.reason_parked || item.description || 'Parked for later.'}`).join('\n')
: '- Nothing parked yet.',
'## Relevant Blockers',
releaseBlockers.length
? releaseBlockers.map((pulse) => `- ${formatDateTime(pulse.timestamp)} · ${getFeatureLabel(state.features, pulse.feature_id)} · ${pulse.message}`).join('\n')
: '- 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',
relatedPulses.length
+10 -10
View File
@@ -129,15 +129,15 @@ export const createSeedState = (): AppState => ({
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: [
'Feature detail includes a one-click copy handoff action.',
'Status detail can copy a focused handoff without detouring through export archaeology.',
'Prompt generation still respects release, phase, parking-lot, and recent-pulse guardrails.',
'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.',
'Copy actions can optionally log an INTENT pulse without triggering any live execution.',
],
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',
notes: 'First concrete v0.4 slice before deeper prompt presets or session templates.',
notes: 'v0.4.0 focuses on one-tap feature handoffs before preview/edit and result-capture slices.',
created_at: seedDate,
updated_at: seedDate,
},
@@ -145,17 +145,17 @@ export const createSeedState = (): AppState => ({
features: [
{
id: 'feature_focused_handoff_shortcuts',
title: 'Focused handoff shortcuts',
description: 'Let operators copy a sharp AI handoff directly from feature and status detail surfaces.',
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 includes a one-click copy handoff action.',
'Status detail can copy a focused handoff without navigating the whole Export screen.',
'Copied prompt still respects the selected feature or first active Now item.',
'Feature detail exposes separate copy actions for OpenClaw, Claude Code, Codex, and Generic.',
'Copied prompt includes release context, blockers, and forbidden-work warnings.',
'Copy can optionally log an INTENT pulse for the chosen target.',
],
scope_notes: 'This is the first concrete v0.4 step: less archaeology before a coding session starts.',
scope_notes: 'This is the v0.4.0 loop: pick a feature, copy the right brief in one tap, then let the agent work.',
phase_id: 'phase_session_handoff',
release_id: 'release_v040_focused_handoffs',
release_role: 'required',
+7
View File
@@ -426,6 +426,9 @@ pre {
.tap-chip {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 0.45rem;
margin-top: 0.15rem;
border-radius: 999px;
padding: 0.22rem 0.65rem;
@@ -435,6 +438,10 @@ pre {
letter-spacing: 0.02em;
}
.tap-chip input {
accent-color: #818cf8;
}
.feature-signal-row {
font-size: 0.82rem;
color: #9fb4d9;