feat: add AI idea placement triage

This commit is contained in:
OpenClaw Bot
2026-05-09 20:53:15 +02:00
parent 5909337f64
commit cfb6b06a08
9 changed files with 859 additions and 29 deletions
+419 -12
View File
@@ -2,10 +2,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import type { ChangeEvent } from 'react'
import './index.css'
import { createAgentSessionPrompt, createMarkdownPackage, createJsonExport, createPulseJsonl } from './features/export/exporters'
import { loadAppState, replaceAppState, saveAppState, validateAppState } from './store/storage'
import { fetchBackendHealth, fetchRemoteState, pushRemoteState } from './store/remote'
import { FEATURE_COLUMNS, FEATURE_PRIORITIES, FEATURE_STATUSES, PULSE_TYPES, RISK_LEVELS } from './store/types'
import type { AppState, Feature, FeatureColumn, ParkingLotItem, PulseEvent, RiskLevel, TabKey } from './store/types'
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 { arrayToLines, formatDateTime, linesToArray, nowIso, slugify } from './utils/format'
const TABS: Array<{ key: TabKey; label: string }> = [
@@ -46,6 +46,27 @@ const initialPulseDraft = {
traceId: '',
}
const initialTriageDraft = {
rawIdea: '',
optionalContext: '',
}
const placementLabels: Record<AiPlacement, string> = {
now: 'Now',
next: 'Next',
later: 'Later',
parking_lot: 'Parking Lot',
rejected: 'Rejected',
duplicate: 'Duplicate',
needs_clarification: 'Needs Clarification',
}
const placementToFeatureColumn = (placement: AiPlacement): FeatureColumn =>
placement === 'now' || placement === 'next' || placement === 'later' ? placement : 'next'
const riskToPriority = (risk: RiskLevel): (typeof FEATURE_PRIORITIES)[number] =>
risk === 'low' ? 'should' : risk === 'medium' ? 'could' : 'later'
const columnLabels: Record<FeatureColumn, string> = {
now: 'Now',
next: 'Next',
@@ -83,6 +104,19 @@ function App() {
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null)
const [syncAction, setSyncAction] = useState<'idle' | 'refreshing' | 'pushing'>('idle')
const [selectedStatusCardTitle, setSelectedStatusCardTitle] = useState('Project Cockpit')
const [triageOpen, setTriageOpen] = useState(false)
const [triageDraft, setTriageDraft] = useState(initialTriageDraft)
const [triageRecommendation, setTriageRecommendation] = useState<AiRecommendation | null>(null)
const [triageEditable, setTriageEditable] = useState({
placement: 'parking_lot' as AiPlacement,
risk: 'medium' as RiskLevel,
title: '',
description: '',
acceptanceCriteria: '',
parkingReason: '',
})
const [triageStatus, setTriageStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
const [triageError, setTriageError] = useState('')
const hasHydratedRemote = useRef(false)
const initialLocalStateRef = useRef(appState)
@@ -98,9 +132,10 @@ function App() {
const remoteState = await fetchRemoteState()
if (cancelled) return
if (remoteState && validateAppState(remoteState)) {
setAppState(remoteState)
saveAppState(remoteState)
const normalizedRemoteState = normalizeAppState(remoteState)
if (normalizedRemoteState) {
setAppState(normalizedRemoteState)
saveAppState(normalizedRemoteState)
setStatusMessage('Loaded state from Appwrite on the Unraid server.')
} else {
await pushRemoteState(initialLocalStateRef.current)
@@ -330,9 +365,10 @@ function App() {
if (!health.ok) throw new Error('Backend health check failed.')
if (remoteState && validateAppState(remoteState)) {
setAppState(remoteState)
saveAppState(remoteState)
const normalizedRemoteState = normalizeAppState(remoteState)
if (normalizedRemoteState) {
setAppState(normalizedRemoteState)
saveAppState(normalizedRemoteState)
setBackendMode('appwrite')
setSyncStatus('synced')
setLastSyncedAt(nowIso())
@@ -370,6 +406,241 @@ function App() {
}
}
const openTriage = (seed = '') => {
setTriageOpen(true)
setTriageError('')
setTriageStatus(triageRecommendation ? 'ready' : 'idle')
if (seed) {
setTriageDraft((current) => ({ ...current, rawIdea: seed }))
}
}
const buildAiTriageContext = () => ({
project: {
name: appState.project.name,
one_line_pitch: appState.project.one_line_pitch,
current_goal: appState.project.current_goal,
},
current_scope: {
in_scope: [
'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.',
],
out_of_scope: [
'phases',
'releases',
'agent integration',
'OpenClaw or Hermes integration',
'local/cloud model router',
'multi-project support',
'automatic feature building',
],
},
features: FEATURE_COLUMNS.reduce<Record<FeatureColumn, Array<{ id: string; title: string; description: string; scope_notes: string }>>>((acc, column) => {
acc[column] = groupedFeatures[column].map((feature) => ({
id: feature.id,
title: feature.title,
description: feature.description,
scope_notes: feature.scope_notes,
}))
return acc
}, { now: [], next: [], later: [], done: [] }),
parking_lot: appState.parking_lot.map((item) => ({
id: item.id,
title: item.title,
description: item.description,
reason_parked: item.reason_parked,
})),
recent_decisions: [...appState.pulses]
.filter((pulse) => pulse.pulse_type === 'DECISION')
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
.slice(0, 5)
.map((pulse) => ({ message: pulse.message, evidence_refs: pulse.evidence_refs })),
})
const runAiTriage = async () => {
const rawIdea = triageDraft.rawIdea.trim()
if (!rawIdea) {
setTriageError('Write the idea first. The scope goblin needs bait.')
setTriageStatus('error')
return
}
setTriageStatus('loading')
setTriageError('')
try {
const recommendation = await triageIdeaWithAi({
raw_idea: rawIdea,
optional_context: triageDraft.optionalContext.trim(),
app_context: buildAiTriageContext(),
})
const timestamp = nowIso()
const fullRecommendation: AiRecommendation = {
id: `rec_${slugify(rawIdea)}_${timestamp.replace(/[^0-9]/g, '')}`,
created_at: timestamp,
raw_idea: rawIdea,
optional_context: triageDraft.optionalContext.trim(),
context_summary: `${appState.project.name}: ${appState.project.current_goal}`,
...recommendation,
user_decision: 'pending',
created_feature_id: null,
created_parking_item_id: null,
decision_pulse_id: null,
}
setTriageRecommendation(fullRecommendation)
saveTriageRecommendation(fullRecommendation)
setTriageEditable({
placement: fullRecommendation.suggested_placement,
risk: fullRecommendation.scope_risk,
title: fullRecommendation.suggested_title,
description: fullRecommendation.suggested_description,
acceptanceCriteria: fullRecommendation.suggested_acceptance_criteria.join('\n'),
parkingReason: fullRecommendation.suggested_parking_reason || fullRecommendation.reason,
})
setTriageStatus('ready')
setStatusMessage(`AI suggested ${placementLabels[fullRecommendation.suggested_placement]} with ${fullRecommendation.scope_risk} scope risk.`)
} catch (error) {
setTriageStatus('error')
setTriageError(error instanceof Error ? error.message : 'AI triage failed.')
setStatusMessage('AI triage failed. Manual planning still works.')
}
}
const createTriageDecisionPulse = (recommendation: AiRecommendation, decision: string, featureId?: string) => {
const timestamp = nowIso()
return {
id: `pulse_${Date.now().toString(36)}`,
timestamp,
project_id: appState.project.id,
feature_id: featureId,
source: 'ai_triage',
agent_id: 'buildpulse_ai',
pulse_type: 'DECISION' as const,
message: `AI triaged idea “${recommendation.raw_idea}” as ${placementLabels[recommendation.suggested_placement]} with ${recommendation.scope_risk} scope risk. User decision: ${decision}.`,
confidence_score: recommendation.confidence_score,
evidence_refs: [
'BuildPulse v0.2 AI Idea Placement',
appState.project.current_goal || 'Current project goal',
recommendation.reason,
`Smallest safe version: ${recommendation.smallest_safe_version}`,
],
trace_id: recommendation.id,
}
}
const saveTriageRecommendation = (recommendation: AiRecommendation) => {
setAppState((current) => ({
...current,
ai_recommendations: [recommendation, ...current.ai_recommendations.filter((entry) => entry.id !== recommendation.id)],
}))
}
const acceptTriageAsFeature = () => {
if (!triageRecommendation) return
const title = triageEditable.title.trim() || triageRecommendation.suggested_title
const timestamp = nowIso()
const featureId = `feature_${slugify(title)}_${Date.now().toString(36)}`
const pulse = createTriageDecisionPulse(triageRecommendation, 'accepted_as_feature', featureId)
const updatedRecommendation: AiRecommendation = {
...triageRecommendation,
suggested_placement: triageEditable.placement,
scope_risk: triageEditable.risk,
suggested_title: title,
suggested_description: triageEditable.description.trim(),
suggested_acceptance_criteria: linesToArray(triageEditable.acceptanceCriteria),
user_decision: 'accepted_as_feature',
created_feature_id: featureId,
created_parking_item_id: null,
decision_pulse_id: pulse.id,
}
setAppState((current) => ({
...current,
features: [
{
id: featureId,
title,
description: triageEditable.description.trim(),
column: placementToFeatureColumn(triageEditable.placement),
priority: riskToPriority(triageEditable.risk),
status: 'idea',
acceptance_criteria: linesToArray(triageEditable.acceptanceCriteria),
scope_notes: [`AI triage: ${triageRecommendation.reason}`, `Smallest safe version: ${triageRecommendation.smallest_safe_version}`].join('\n'),
created_at: timestamp,
updated_at: timestamp,
},
...current.features,
],
pulses: [pulse, ...current.pulses],
ai_recommendations: [updatedRecommendation, ...current.ai_recommendations.filter((entry) => entry.id !== updatedRecommendation.id)],
}))
setSelectedFeatureId(featureId)
setActiveTab('feature-plan')
setStatusMessage(`Accepted AI triage as feature “${title}”. DECISION pulse logged.`)
}
const acceptTriageAsParkingLot = () => {
if (!triageRecommendation) return
const title = triageEditable.title.trim() || triageRecommendation.suggested_title
const timestamp = nowIso()
const parkingId = `parked_${slugify(title)}_${Date.now().toString(36)}`
const pulse = createTriageDecisionPulse(triageRecommendation, 'accepted_as_parking_lot')
const updatedRecommendation: AiRecommendation = {
...triageRecommendation,
suggested_placement: 'parking_lot',
scope_risk: triageEditable.risk,
suggested_title: title,
suggested_description: triageEditable.description.trim(),
suggested_parking_reason: triageEditable.parkingReason.trim(),
user_decision: 'accepted_as_parking_lot',
created_feature_id: null,
created_parking_item_id: parkingId,
decision_pulse_id: pulse.id,
}
setAppState((current) => ({
...current,
parking_lot: [
{
id: parkingId,
title,
description: triageEditable.description.trim(),
reason_parked: triageEditable.parkingReason.trim() || triageRecommendation.reason,
possible_future_placement: triageEditable.placement === 'later' ? 'Later' : 'v0.3+',
risk_level: triageEditable.risk,
created_at: timestamp,
updated_at: timestamp,
},
...current.parking_lot,
],
pulses: [pulse, ...current.pulses],
ai_recommendations: [updatedRecommendation, ...current.ai_recommendations.filter((entry) => entry.id !== updatedRecommendation.id)],
}))
setSelectedParkingId(parkingId)
setActiveTab('parking-lot')
setStatusMessage(`Accepted AI triage as Parking Lot item “${title}”. DECISION pulse logged.`)
}
const rejectTriageRecommendation = () => {
if (!triageRecommendation) return
const pulse = createTriageDecisionPulse(triageRecommendation, 'rejected')
const updatedRecommendation: AiRecommendation = {
...triageRecommendation,
user_decision: 'rejected',
decision_pulse_id: pulse.id,
}
setAppState((current) => ({
...current,
pulses: [pulse, ...current.pulses],
ai_recommendations: [updatedRecommendation, ...current.ai_recommendations.filter((entry) => entry.id !== updatedRecommendation.id)],
}))
setStatusMessage('Rejected AI triage recommendation. DECISION pulse logged; no feature created.')
}
const openFeatureHandoff = (featureId: string) => {
setPromptFeatureId(featureId)
setPromptTarget('OpenClaw')
@@ -597,11 +868,12 @@ function App() {
try {
const text = await file.text()
const parsed = JSON.parse(text)
if (!validateAppState(parsed)) {
const normalized = normalizeAppState(parsed)
if (!normalized) {
setStatusMessage('Import failed: invalid BuildPulse schema or unsupported version.')
return
}
setAppState(replaceAppState(parsed))
setAppState(replaceAppState(normalized))
setStatusMessage('Import complete. State replaced cleanly.')
} catch {
setStatusMessage('Import failed: invalid JSON.')
@@ -728,6 +1000,135 @@ function App() {
</button>
</aside>
{triageOpen && (
<section className="card triage-panel" aria-label="AI Idea Placement">
<div className="section-heading compact">
<div>
<p className="eyebrow">BuildPulse v0.2</p>
<h2>AI Idea Placement</h2>
<p>AI advises where a raw idea belongs. You decide what gets saved.</p>
</div>
<button type="button" className="ghost small" onClick={() => setTriageOpen(false)}>
Close
</button>
</div>
<div className="triage-layout">
<div className="triage-input">
<label>
Raw idea
<textarea
rows={4}
value={triageDraft.rawIdea}
onChange={(event) => setTriageDraft((current) => ({ ...current, rawIdea: event.target.value }))}
placeholder="Example: Add live WebSocket agent telemetry"
/>
</label>
<label>
Optional context
<textarea
rows={3}
value={triageDraft.optionalContext}
onChange={(event) => setTriageDraft((current) => ({ ...current, optionalContext: event.target.value }))}
placeholder="Anything the scope guardian should know. Keep it short."
/>
</label>
<div className="button-inline-row">
<button type="button" onClick={() => void runAiTriage()} disabled={triageStatus === 'loading'}>
{triageStatus === 'loading' ? 'Analyzing…' : 'Analyze Idea'}
</button>
<button
type="button"
className="ghost"
onClick={() => {
setTriageDraft(initialTriageDraft)
setTriageRecommendation(null)
setTriageStatus('idle')
setTriageError('')
}}
>
Clear
</button>
</div>
{triageStatus === 'error' && <p className="triage-error">{triageError}</p>}
</div>
<div className="triage-result">
{triageRecommendation ? (
<>
<div className="triage-result-header">
<span className="pill status-healthy">{placementLabels[triageRecommendation.suggested_placement]}</span>
<span className={`pill risk-${triageRecommendation.scope_risk}`}>{triageRecommendation.scope_risk} risk</span>
<span className="pill">{Math.round(triageRecommendation.confidence_score * 100)}% confidence</span>
</div>
<div className="triage-callout">
<strong>Reason</strong>
<p>{triageRecommendation.reason}</p>
<strong>Smallest safe version</strong>
<p>{triageRecommendation.smallest_safe_version}</p>
</div>
{triageRecommendation.duplicate_check.is_duplicate && (
<div className="triage-callout warning">
<strong>Possible duplicate</strong>
<p>{triageRecommendation.duplicate_check.reason || 'AI detected overlap with an existing item.'}</p>
<small>{triageRecommendation.duplicate_check.duplicate_type}: {triageRecommendation.duplicate_check.duplicate_id}</small>
</div>
)}
{triageRecommendation.clarifying_question && (
<div className="triage-callout warning">
<strong>Clarifying question</strong>
<p>{triageRecommendation.clarifying_question}</p>
</div>
)}
<div className="form-grid">
<label>
Placement
<select value={triageEditable.placement} onChange={(event) => setTriageEditable((current) => ({ ...current, placement: event.target.value as AiPlacement }))}>
{AI_PLACEMENTS.map((placement) => (
<option key={placement} value={placement}>{placementLabels[placement]}</option>
))}
</select>
</label>
<label>
Scope risk
<select value={triageEditable.risk} onChange={(event) => setTriageEditable((current) => ({ ...current, risk: event.target.value as RiskLevel }))}>
{RISK_LEVELS.map((risk) => (
<option key={risk} value={risk}>{risk}</option>
))}
</select>
</label>
<label className="full-span">
Suggested title
<input value={triageEditable.title} onChange={(event) => setTriageEditable((current) => ({ ...current, title: event.target.value }))} />
</label>
<label className="full-span">
Suggested description
<textarea rows={3} value={triageEditable.description} onChange={(event) => setTriageEditable((current) => ({ ...current, description: event.target.value }))} />
</label>
<label className="full-span">
Acceptance criteria, if saved as feature
<textarea rows={4} value={triageEditable.acceptanceCriteria} onChange={(event) => setTriageEditable((current) => ({ ...current, acceptanceCriteria: event.target.value }))} />
</label>
<label className="full-span">
Parking reason, if parked
<textarea rows={3} value={triageEditable.parkingReason} onChange={(event) => setTriageEditable((current) => ({ ...current, parkingReason: event.target.value }))} />
</label>
</div>
<div className="button-row">
<button type="button" onClick={acceptTriageAsFeature}>Accept as Feature</button>
<button type="button" onClick={acceptTriageAsParkingLot}>Accept as Parking Lot</button>
<button type="button" className="danger" onClick={rejectTriageRecommendation}>Reject + Log Decision</button>
</div>
</>
) : (
<div className="empty-state">No recommendation yet. Feed the scope guardian one idea, not the whole haunted roadmap.</div>
)}
</div>
</div>
</section>
)}
{activeTab === 'feature-plan' && (
<>
<header className="hero-card">
@@ -969,6 +1370,9 @@ function App() {
<h2>Feature Plan</h2>
<p>Lead with focus, not overview. This is the calm what now? screen.</p>
</div>
<button type="button" onClick={() => openTriage()}>
Triage Idea with AI
</button>
</div>
{selectedFeature && (
@@ -1205,6 +1609,9 @@ function App() {
<h2>Parking Lot</h2>
<p>This is where useful distractions go so they do not hijack the build.</p>
</div>
<button type="button" onClick={() => openTriage()}>
Triage Idea with AI
</button>
</div>
<div className="editor-grid">
<section className="card editor-card">