From 8218a3417e695ba15494a4afb0be589aa257990e Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Sun, 10 May 2026 20:04:49 +0200 Subject: [PATCH] feat: ship buildpulse 0.3.0 phases and releases --- package.json | 2 +- src/App.tsx | 1131 ++++++++++++++++++----- src/features/export/exporters.ts | 41 + src/features/project/projectDefaults.ts | 125 ++- src/index.css | 214 ++++- src/store/remote.ts | 9 +- src/store/storage.ts | 69 +- src/store/types.ts | 52 +- 8 files changed, 1400 insertions(+), 243 deletions(-) diff --git a/package.json b/package.json index 4a148bb..35844f3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "buildpulse", "private": true, - "version": "0.2.1", + "version": "0.3.0", "type": "module", "scripts": { "api": "node --env-file=../.env server/index.mjs", diff --git a/src/App.tsx b/src/App.tsx index c04f7c0..341f114 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,12 +4,13 @@ import './index.css' import { createAgentSessionPrompt, createMarkdownPackage, createJsonExport, createPulseJsonl } from './features/export/exporters' import { loadAppState, normalizeAppState, replaceAppState, saveAppState } from './store/storage' import { fetchBackendHealth, fetchRemoteState, pushRemoteState, triageIdeaWithAi } from './store/remote' -import { AI_PLACEMENTS, FEATURE_COLUMNS, FEATURE_PRIORITIES, FEATURE_STATUSES, PULSE_TYPES, RISK_LEVELS } from './store/types' -import type { AiPlacement, AiRecommendation, AppState, Feature, FeatureColumn, ParkingLotItem, PulseEvent, RiskLevel, TabKey } from './store/types' +import { AI_PLACEMENTS, FEATURE_COLUMNS, FEATURE_PRIORITIES, FEATURE_STATUSES, PHASE_STATUSES, PULSE_TYPES, RELEASE_ROLES, RELEASE_STATUSES, RISK_LEVELS } from './store/types' +import type { AiPlacement, AiRecommendation, AppState, Feature, FeatureColumn, ParkingLotItem, PhaseStatus, ProjectPhase, PulseEvent, Release, ReleaseRole, ReleaseStatus, RiskLevel, TabKey } from './store/types' import { arrayToLines, formatDateTime, linesToArray, nowIso, slugify } from './utils/format' const TABS: Array<{ key: TabKey; label: string }> = [ { key: 'feature-plan', label: 'Plan' }, + { key: 'roadmap', label: 'Roadmap' }, { key: 'parking-lot', label: 'Park' }, { key: 'pulse-log', label: 'Pulse' }, { key: 'export', label: 'Export' }, @@ -26,6 +27,23 @@ const initialFeatureDraft = { status: 'idea' as (typeof FEATURE_STATUSES)[number], acceptanceCriteria: '', scopeNotes: '', + phaseId: '', + releaseId: '', + releaseRole: 'required' as ReleaseRole, +} + +const releaseStatusLabels: Record = { + not_ready: 'Not ready', + in_progress: 'In progress', + testing: 'Testing', + ready_to_ship: 'Ready to ship', + shipped: 'Shipped', +} + +const phaseStatusLabels: Record = { + upcoming: 'Upcoming', + active: 'Active', + done: 'Done', } const initialParkingDraft = { @@ -92,6 +110,7 @@ function App() { const [selectedFeatureId, setSelectedFeatureId] = useState(null) const [selectedParkingId, setSelectedParkingId] = useState(null) const [selectedPulseId, setSelectedPulseId] = useState(null) + const [selectedReleaseId, setSelectedReleaseId] = useState(() => loadAppState().releases[0]?.id ?? null) const [featureDraft, setFeatureDraft] = useState(initialFeatureDraft) const [parkingDraft, setParkingDraft] = useState(initialParkingDraft) const [pulseDraft, setPulseDraft] = useState(initialPulseDraft) @@ -118,6 +137,7 @@ function App() { }) const [triageStatus, setTriageStatus] = useState<'idle' | 'loading' | 'ready' | 'error' | 'saved'>('idle') const [triageError, setTriageError] = useState('') + const [triageBatchStatus, setTriageBatchStatus] = useState<'idle' | 'running'>('idle') const [triageEditMode, setTriageEditMode] = useState(false) const [triageSavedMessage, setTriageSavedMessage] = useState('') const [showManualFeatureEditor, setShowManualFeatureEditor] = useState(false) @@ -125,6 +145,7 @@ function App() { const [showManualPulseEditor, setShowManualPulseEditor] = useState(false) const hasHydratedRemote = useRef(false) const initialLocalStateRef = useRef(appState) + const triageStatusRef = useRef(null) useEffect(() => { saveAppState(appState) @@ -189,6 +210,12 @@ function App() { return () => window.clearTimeout(timer) }, [appState, backendMode]) + useEffect(() => { + if (!triageOpen) return + if (!['loading', 'ready', 'error', 'saved'].includes(triageStatus)) return + triageStatusRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, [triageOpen, triageStatus, triageRecommendation]) + const groupedFeatures = useMemo( () => FEATURE_COLUMNS.reduce>((acc, column) => { @@ -213,6 +240,72 @@ function App() { [appState.pulses, selectedPulseId], ) + const sortedPhases = useMemo( + () => [...appState.phases].sort((a, b) => a.order - b.order), + [appState.phases], + ) + + const releasesByPhase = useMemo( + () => + sortedPhases.map((phase) => ({ + phase, + releases: appState.releases.filter((release) => release.phase_id === phase.id), + })), + [appState.releases, sortedPhases], + ) + + const effectiveSelectedReleaseId = selectedReleaseId ?? appState.releases[0]?.id ?? null + + const selectedRelease = useMemo( + () => appState.releases.find((release) => release.id === effectiveSelectedReleaseId) ?? null, + [appState.releases, effectiveSelectedReleaseId], + ) + + const selectedReleasePhase = useMemo( + () => (selectedRelease ? appState.phases.find((phase) => phase.id === selectedRelease.phase_id) ?? null : null), + [appState.phases, selectedRelease], + ) + + const selectedReleaseRequiredFeatures = useMemo( + () => + selectedRelease + ? selectedRelease.required_feature_ids + .map((featureId) => appState.features.find((feature) => feature.id === featureId) ?? null) + .filter((feature): feature is Feature => Boolean(feature)) + : [], + [appState.features, selectedRelease], + ) + + const selectedReleaseOptionalFeatures = useMemo( + () => + selectedRelease + ? selectedRelease.optional_feature_ids + .map((featureId) => appState.features.find((feature) => feature.id === featureId) ?? null) + .filter((feature): feature is Feature => Boolean(feature)) + : [], + [appState.features, selectedRelease], + ) + + const selectedReleaseRequiredDoneCount = selectedReleaseRequiredFeatures.filter((feature) => feature.status === 'done').length + const selectedReleaseProgressPercent = selectedReleaseRequiredFeatures.length + ? Math.round((selectedReleaseRequiredDoneCount / selectedReleaseRequiredFeatures.length) * 100) + : 0 + const selectedReleaseBlockers = selectedReleaseRequiredFeatures.filter((feature) => feature.status !== 'done') + const forbiddenFeatureWarnings = useMemo(() => { + if (!selectedRelease) return [] + const forbiddenTitles = new Set(selectedRelease.forbidden_feature_titles.map((entry) => entry.toLowerCase().trim())) + return appState.features.filter((feature) => forbiddenTitles.has(feature.title.toLowerCase().trim())) + }, [appState.features, selectedRelease]) + + const selectedReleaseRecentPulses = useMemo(() => { + if (!selectedRelease) return [] + const relatedFeatureIds = new Set([...selectedRelease.required_feature_ids, ...selectedRelease.optional_feature_ids]) + return [...appState.pulses] + .filter((pulse) => pulse.feature_id && relatedFeatureIds.has(pulse.feature_id)) + .sort((a, b) => b.timestamp.localeCompare(a.timestamp)) + .slice(0, 6) + }, [appState.pulses, selectedRelease]) + const parkingByRisk = useMemo( () => PARKING_RISK_ORDER.map((risk) => ({ @@ -239,6 +332,27 @@ function App() { const triageIsFeaturePlacement = triagePlacement === 'now' || triagePlacement === 'next' || triagePlacement === 'later' const triageNeedsClarification = triagePlacement === 'needs_clarification' const triageSuggestedFuturePlacement = triagePlacement === 'later' ? 'Later' : triagePlacement === 'next' ? 'Next' : 'v0.3+' + const featureSheetOpen = showManualFeatureEditor || Boolean(selectedFeature) + const parkingSheetOpen = showManualParkingEditor || Boolean(selectedParkingItem) + const pulseSheetOpen = showManualPulseEditor || Boolean(selectedPulse) + const triageStatusLabel = + triageStatus === 'loading' + ? 'Analyzing with AI…' + : triageStatus === 'ready' + ? 'Recommendation ready' + : triageStatus === 'saved' + ? 'Decision logged' + : triageStatus === 'error' + ? 'AI triage failed' + : 'Waiting for input' + const triageStatusTone = + triageStatus === 'error' + ? 'status-degraded' + : triageStatus === 'saved' || triageStatus === 'ready' + ? 'status-healthy' + : triageStatus === 'loading' + ? 'status-connecting' + : '' const markdownPackage = useMemo(() => createMarkdownPackage(appState), [appState]) const sessionPrompt = useMemo( @@ -257,6 +371,136 @@ function App() { })) } + const updatePhase = (phaseId: string, field: keyof ProjectPhase, value: string | number) => { + setAppState((current) => ({ + ...current, + phases: current.phases.map((phase) => + phase.id === phaseId + ? { + ...phase, + [field]: value, + updated_at: nowIso(), + } + : phase, + ), + })) + } + + const updateRelease = (releaseId: string, field: keyof Release, value: string | string[]) => { + setAppState((current) => ({ + ...current, + releases: current.releases.map((release) => + release.id === releaseId + ? { + ...release, + [field]: value, + updated_at: nowIso(), + } + : release, + ), + })) + } + + const addPhase = () => { + const timestamp = nowIso() + const nextOrder = appState.phases.length + 1 + const phaseId = `phase_${Date.now().toString(36)}` + setAppState((current) => ({ + ...current, + phases: [ + ...current.phases, + { + id: phaseId, + title: `Phase ${nextOrder}: New phase`, + goal: 'Describe the stage goal.', + status: 'upcoming', + order: nextOrder, + notes: '', + created_at: timestamp, + updated_at: timestamp, + }, + ], + })) + setStatusMessage('New phase added.') + } + + const addRelease = (phaseId?: string) => { + const targetPhaseId = phaseId || appState.phases[0]?.id + if (!targetPhaseId) { + setStatusMessage('Add a phase first, then a release.') + return + } + + const timestamp = nowIso() + const releaseId = `release_${Date.now().toString(36)}` + setAppState((current) => ({ + ...current, + releases: [ + ...current.releases, + { + id: releaseId, + phase_id: targetPhaseId, + name: 'New release', + goal: 'Describe the concrete build target.', + definition_of_done: [], + required_feature_ids: [], + optional_feature_ids: [], + forbidden_feature_titles: [], + status: 'not_ready', + notes: '', + created_at: timestamp, + updated_at: timestamp, + }, + ], + })) + setSelectedReleaseId(releaseId) + setStatusMessage('New release added.') + } + + const toggleFeatureReleaseLink = (releaseId: string, featureId: string, role: ReleaseRole, checked: boolean) => { + setAppState((current) => { + const targetRelease = current.releases.find((release) => release.id === releaseId) + if (!targetRelease) return current + + const appendUnique = (items: string[], id: string) => Array.from(new Set([...items, id])) + const stripId = (items: string[], id: string) => items.filter((entry) => entry !== id) + + return { + ...current, + releases: current.releases.map((release) => { + if (release.id !== releaseId) return release + return { + ...release, + required_feature_ids: + role === 'required' + ? checked + ? appendUnique(release.required_feature_ids, featureId) + : stripId(release.required_feature_ids, featureId) + : stripId(release.required_feature_ids, featureId), + optional_feature_ids: + role === 'optional' + ? checked + ? appendUnique(release.optional_feature_ids, featureId) + : stripId(release.optional_feature_ids, featureId) + : stripId(release.optional_feature_ids, featureId), + updated_at: nowIso(), + } + }), + features: current.features.map((feature) => + feature.id === featureId + ? { + ...feature, + phase_id: checked ? targetRelease.phase_id : feature.release_id === releaseId ? undefined : feature.phase_id, + release_id: checked ? releaseId : feature.release_id === releaseId ? undefined : feature.release_id, + release_role: checked ? role : feature.release_id === releaseId ? undefined : feature.release_role, + updated_at: nowIso(), + } + : feature, + ), + } + }) + } + const beginFeatureEdit = (feature: Feature) => { setSelectedFeatureId(feature.id) setFeatureDraft({ @@ -267,6 +511,9 @@ function App() { status: feature.status, acceptanceCriteria: arrayToLines(feature.acceptance_criteria), scopeNotes: feature.scope_notes, + phaseId: feature.phase_id || '', + releaseId: feature.release_id || '', + releaseRole: feature.release_role || 'required', }) } @@ -298,6 +545,9 @@ function App() { status: featureDraft.status, acceptance_criteria: acceptanceCriteria, scope_notes: featureDraft.scopeNotes.trim(), + phase_id: featureDraft.phaseId || undefined, + release_id: featureDraft.releaseId || undefined, + release_role: featureDraft.releaseId ? featureDraft.releaseRole : undefined, updated_at: timestamp, } : feature, @@ -319,6 +569,9 @@ function App() { status: featureDraft.status, acceptance_criteria: acceptanceCriteria, scope_notes: featureDraft.scopeNotes.trim(), + phase_id: featureDraft.phaseId || undefined, + release_id: featureDraft.releaseId || undefined, + release_role: featureDraft.releaseId ? featureDraft.releaseRole : undefined, created_at: timestamp, updated_at: timestamp, }, @@ -447,10 +700,9 @@ function App() { 'AI classifies new ideas into Now, Next, Later, Parking Lot, Rejected, Duplicate, or Needs Clarification.', 'User accepts, edits, or rejects the recommendation.', 'Every accepted or rejected decision is logged as a DECISION pulse.', + 'Phases and releases can structure planning and readiness, but not live agent telemetry.', ], out_of_scope: [ - 'phases', - 'releases', 'agent integration', 'OpenClaw or Hermes integration', 'local/cloud model router', @@ -491,6 +743,7 @@ function App() { setTriageStatus('loading') setTriageError('') setTriageSavedMessage('') + setStatusMessage('AI triage is running…') try { const recommendation = await triageIdeaWithAi({ @@ -532,6 +785,66 @@ function App() { } } + const runAiTriageForParkingLot = async () => { + if (!appState.parking_lot.length) { + setStatusMessage('Nothing in Parking Lot to triage yet.') + return + } + + setTriageBatchStatus('running') + setStatusMessage(`Running AI triage across ${appState.parking_lot.length} parked idea${appState.parking_lot.length === 1 ? '' : 's'}…`) + + let successCount = 0 + let failureCount = 0 + const newRecommendations: AiRecommendation[] = [] + + try { + for (const item of appState.parking_lot) { + try { + const recommendation = await triageIdeaWithAi({ + raw_idea: item.title, + optional_context: [item.description, item.reason_parked].filter(Boolean).join('\n\n'), + app_context: buildAiTriageContext(), + }) + const timestamp = nowIso() + newRecommendations.push({ + id: `rec_${slugify(item.title)}_${timestamp.replace(/[^0-9]/g, '')}_${successCount}`, + created_at: timestamp, + raw_idea: item.title, + optional_context: [item.description, item.reason_parked].filter(Boolean).join('\n\n'), + context_summary: `${appState.project.name}: ${appState.project.current_goal}`, + ...recommendation, + user_decision: 'pending', + created_feature_id: null, + created_parking_item_id: item.id, + decision_pulse_id: null, + }) + successCount += 1 + } catch { + failureCount += 1 + } + } + + if (newRecommendations.length) { + setAppState((current) => ({ + ...current, + ai_recommendations: [ + ...newRecommendations, + ...current.ai_recommendations.filter((entry) => !newRecommendations.some((candidate) => candidate.id === entry.id)), + ], + })) + } + + setStatusMessage( + failureCount + ? `AI triage-all finished: ${successCount} analyzed, ${failureCount} failed. Recommendations were saved for review.` + : `AI triage-all finished: ${successCount} parked idea${successCount === 1 ? '' : 's'} analyzed.`, + ) + } finally { + setTriageBatchStatus('idle') + } + } + const createTriageDecisionPulse = (recommendation: AiRecommendation, decision: string, featureId?: string) => { const timestamp = nowIso() return { @@ -592,6 +905,9 @@ function App() { status: 'idea', acceptance_criteria: linesToArray(triageEditable.acceptanceCriteria), scope_notes: [`AI triage: ${triageRecommendation.reason}`, `Smallest safe version: ${triageRecommendation.smallest_safe_version}`].join('\n'), + triaged_at: triageRecommendation.created_at, + triage_trace_id: triageRecommendation.id, + triage_confidence_score: triageRecommendation.confidence_score, created_at: timestamp, updated_at: timestamp, }, @@ -637,6 +953,9 @@ function App() { reason_parked: triageEditable.parkingReason.trim() || triageRecommendation.reason, possible_future_placement: triageSuggestedFuturePlacement, risk_level: triageEditable.risk, + triaged_at: triageRecommendation.created_at, + triage_trace_id: triageRecommendation.id, + triage_confidence_score: triageRecommendation.confidence_score, created_at: timestamp, updated_at: timestamp, }, @@ -942,6 +1261,18 @@ function App() { evidence: ['Four columns: Now, Next, Later, Done.', 'Cards show priority, status, acceptance criteria count, and linked pulse activity.', 'Selected features expose focus notes, criteria, recent pulses, handoff, and pulse actions.'], next: 'Add a readiness checklist that highlights missing acceptance criteria before work starts.', }, + { + title: 'Release Planning', + status: appState.releases.length ? 'active' : 'ready', + description: 'Phases and releases answer what larger stage the project is in, what release matters now, and what must stay forbidden until later.', + signal: selectedRelease ? `${selectedRelease.name} · ${releaseStatusLabels[selectedRelease.status]}` : 'No release selected', + metric: `${appState.releases.length} releases`, + action: 'Open roadmap', + tab: 'roadmap' as TabKey, + operatorNote: 'Use this when the board needs structure beyond Now / Next / Later and you want explicit readiness instead of gut feel.', + evidence: ['Phases are ordered and editable.', 'Releases track goal, definition of done, required features, optional features, and forbidden work.', 'A readiness view surfaces blockers, recent pulses, and forbidden warnings.'], + next: 'Add release-specific pulse templates once the manual workflow proves useful.', + }, { title: 'Parking Lot', status: appState.parking_lot.length ? 'active' : 'ready', @@ -1008,7 +1339,7 @@ function App() {
-

BuildPulse v0.2.1

+

BuildPulse v0.3.0

{appState.project.name}

Current goal: {appState.project.current_goal || 'Classify new ideas before they become work.'} @@ -1058,6 +1389,13 @@ function App() { 3. Decision logged

+
+ {triageStatusLabel} + {triageRecommendation?.created_at && Analyzed {formatDateTime(triageRecommendation.created_at)}} + {triageStatus === 'loading' && Gemini is working. The result will appear below automatically.} + {triageStatus === 'error' && triageError && {triageError}} +
+ {triageStatus === 'saved' ? (
{triageStatus === 'error' &&

{triageError}

} + {triageStatus === 'loading' &&

AI is analyzing scope, duplicates, and safest placement…

}
)} @@ -1151,6 +1490,7 @@ function App() {

{triageEditable.title}

{triageEditable.description || 'No description supplied.'}

+ Triaged at: {formatDateTime(triageRecommendation.created_at)} Suggested placement: {placementLabels[triagePlacement]} {triageIsFeaturePlacement && Suggested column: {placementLabels[triagePlacement]}} {!triageIsFeaturePlacement && Suggested parking reason: {triageEditable.parkingReason || triageRecommendation.suggested_parking_reason || triageRecommendation.reason}} @@ -1263,6 +1603,325 @@ function App() {
)} + {featureSheetOpen && ( +
+
+
+
+

{selectedFeature ? 'Feature focus' : 'Manual feature'}

+

{selectedFeature ? selectedFeature.title : 'Add Feature'}

+

{selectedFeature ? (selectedFeature.description || 'No description yet.') : 'Use this when you deliberately want to bypass AI triage.'}

+
+ +
+ + {selectedFeature && ( +
+
+
+ {selectedFeature.priority} + {selectedFeature.status} + {columnLabels[selectedFeature.column]} + {selectedFeature.release_role && {selectedFeature.release_role}} +
+

Acceptance Criteria

+ {selectedFeature.acceptance_criteria.length ? ( +
    + {selectedFeature.acceptance_criteria.map((criterion) => ( +
  • {criterion}
  • + ))} +
+ ) : ( +

No criteria yet.

+ )} +
+
+

Scope Notes

+

{selectedFeature.scope_notes || 'No scope notes yet.'}

+ {(selectedFeature.phase_id || selectedFeature.release_id) && ( +

+ Roadmap: {appState.phases.find((phase) => phase.id === selectedFeature.phase_id)?.title || 'No phase'} + {' · '} + {appState.releases.find((release) => release.id === selectedFeature.release_id)?.name || 'No release'} +

+ )} + {selectedFeature.triaged_at && ( +

+ AI triaged: {formatDateTime(selectedFeature.triaged_at)} + {typeof selectedFeature.triage_confidence_score === 'number' && ` · ${Math.round(selectedFeature.triage_confidence_score * 100)}% confidence`} +

+ )} +
+ + +
+
+
+ )} + +
+ + + + +