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">
+21
View File
@@ -125,6 +125,7 @@ export const createJsonExport = (state: AppState) =>
features: state.features,
parking_lot: state.parking_lot,
pulses: state.pulses,
ai_recommendations: state.ai_recommendations,
settings: state.settings,
},
null,
@@ -136,6 +137,7 @@ export const createPulseJsonl = (state: AppState) => state.pulses.map((pulse) =>
export const createMarkdownPackage = (state: AppState) => {
const grouped = groupFeatures(state.features)
const recentPulses = sortPulsesNewestFirst(state.pulses).slice(0, 8)
const recentRecommendations = [...state.ai_recommendations].sort((a, b) => b.created_at.localeCompare(a.created_at)).slice(0, 6)
const projectSummary = `# Project Summary\n\n## Name\n${state.project.name}\n\n## One-Line Pitch\n${state.project.one_line_pitch || '—'}\n\n## Description\n${state.project.description || '—'}\n\n## Current Goal\n${state.project.current_goal || '—'}\n\n## Notes\n${state.project.notes || '—'}\n`
@@ -173,6 +175,17 @@ export const createMarkdownPackage = (state: AppState) => {
: ['_No pulse events yet._']),
].join('\n')
const aiRecommendations = [
'# AI Recommendations',
'',
...(recentRecommendations.length
? recentRecommendations.map(
(recommendation) =>
`- **${recommendation.suggested_title}** — ${recommendation.suggested_placement} / ${recommendation.scope_risk} risk\n - Decision: ${recommendation.user_decision}\n - Reason: ${recommendation.reason}\n - Smallest safe version: ${recommendation.smallest_safe_version}`,
)
: ['_No AI recommendations yet._']),
].join('\n')
const claudeContext = [
'# AI Coding Context',
'',
@@ -208,6 +221,13 @@ export const createMarkdownPackage = (state: AppState) => {
.join('\n')
: '_No recent pulse events yet._',
'',
'## Recent AI Triage Recommendations',
recentRecommendations.length
? recentRecommendations
.map((recommendation) => `- ${recommendation.suggested_title}${recommendation.suggested_placement}, ${recommendation.scope_risk} risk, decision: ${recommendation.user_decision}`)
.join('\n')
: '_No AI recommendations yet._',
'',
'## Instructions for AI Developer',
'- Only work on the selected feature.',
'- Do not implement Parking Lot items.',
@@ -220,6 +240,7 @@ export const createMarkdownPackage = (state: AppState) => {
'FEATURE_PLAN.md': featurePlan.join('\n'),
'PARKING_LOT.md': parkingLot,
'PULSE_LOG.md': pulseLog,
'AI_RECOMMENDATIONS.md': aiRecommendations,
'CLAUDE_CONTEXT.md': claudeContext,
}
}
+2 -1
View File
@@ -3,7 +3,7 @@ import type { AppState } from '../../store/types'
const seedDate = '2026-05-06T00:00:00+02:00'
export const createSeedState = (): AppState => ({
schema_version: '0.1.0',
schema_version: '0.2.0',
project: {
id: 'project_buildpulse',
name: 'BuildPulse',
@@ -137,6 +137,7 @@ export const createSeedState = (): AppState => ({
trace_id: 'session_seed',
},
],
ai_recommendations: [],
settings: {
theme: 'light',
default_agent_id: 'jimmi',
+78
View File
@@ -1205,3 +1205,81 @@ body {
width: min(100% - 4rem, 100%);
}
}
.triage-panel {
border-color: rgba(103, 232, 249, 0.3);
position: relative;
overflow: hidden;
}
.triage-panel::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at 12% 0%, rgba(103, 232, 249, 0.12), transparent 28%);
pointer-events: none;
}
.triage-panel > * {
position: relative;
z-index: 1;
}
.triage-layout {
display: grid;
grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.1fr);
gap: 1rem;
}
.triage-input,
.triage-result,
.triage-callout {
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 18px;
background: rgba(3, 7, 18, 0.46);
padding: 1rem;
}
.triage-input,
.triage-result {
display: grid;
gap: 0.9rem;
align-content: start;
}
.triage-result-header {
display: flex;
gap: 0.55rem;
flex-wrap: wrap;
}
.triage-callout strong {
display: block;
margin-bottom: 0.35rem;
}
.triage-callout p,
.triage-callout small {
color: #b7c4db;
margin: 0 0 0.8rem;
}
.triage-callout p:last-child {
margin-bottom: 0;
}
.triage-callout.warning {
border-color: rgba(250, 204, 21, 0.34);
background: rgba(120, 53, 15, 0.22);
}
.triage-error {
margin: 0;
color: #fecaca;
}
@media (max-width: 900px) {
.triage-layout {
grid-template-columns: 1fr;
}
}
+14 -1
View File
@@ -1,4 +1,4 @@
import type { AppState } from './types'
import type { AiRecommendation, AppState } from './types'
const API_BASE = import.meta.env.VITE_BUILDPULSE_API_BASE || ''
const REQUEST_TIMEOUT_MS = Number(import.meta.env.VITE_BUILDPULSE_API_TIMEOUT_MS || 5000)
@@ -55,3 +55,16 @@ export const fetchBackendHealth = async () =>
collectionId: string
documentPresent: boolean
}>('/api/health')
export const triageIdeaWithAi = async (payload: {
raw_idea: string
optional_context: string
app_context: unknown
}) => {
const response = await request<{ ok: boolean; provider: string; recommendation: Omit<AiRecommendation, 'id' | 'created_at' | 'raw_idea' | 'optional_context' | 'context_summary' | 'user_decision' | 'created_feature_id' | 'created_parking_item_id' | 'decision_pulse_id'> }>('/api/ai/triage-idea', {
method: 'POST',
body: JSON.stringify(payload),
})
return response.recommendation
}
+77 -13
View File
@@ -1,6 +1,15 @@
import { createSeedState } from '../features/project/projectDefaults'
import { FEATURE_COLUMNS, PULSE_TYPES, RISK_LEVELS, SCHEMA_VERSION, STORAGE_KEY } from './types'
import type { AppState } from './types'
import {
AI_DECISIONS,
AI_PLACEMENTS,
FEATURE_COLUMNS,
PULSE_TYPES,
RISK_LEVELS,
SCHEMA_VERSION,
STORAGE_KEY,
SUPPORTED_SCHEMA_VERSIONS,
} from './types'
import type { AiRecommendation, AppState } from './types'
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -8,11 +17,55 @@ const isObject = (value: unknown): value is Record<string, unknown> =>
const hasRequiredStrings = (value: Record<string, unknown>, keys: string[]) =>
keys.every((key) => typeof value[key] === 'string' && value[key])
export const validateAppState = (value: unknown): value is AppState => {
if (!isObject(value)) return false
if (value.schema_version !== SCHEMA_VERSION) return false
if (!isObject(value.project) || !hasRequiredStrings(value.project, ['id', 'name'])) return false
if (!Array.isArray(value.features) || !Array.isArray(value.parking_lot) || !Array.isArray(value.pulses)) return false
const normalizeRecommendation = (value: unknown): AiRecommendation | null => {
if (!isObject(value)) return null
if (!hasRequiredStrings(value, ['id', 'created_at', 'raw_idea', 'suggested_placement', 'scope_risk'])) return null
if (!AI_PLACEMENTS.includes(value.suggested_placement as (typeof AI_PLACEMENTS)[number])) return null
if (!RISK_LEVELS.includes(value.scope_risk as (typeof RISK_LEVELS)[number])) return null
const duplicate = isObject(value.duplicate_check) ? value.duplicate_check : {}
const userDecision = typeof value.user_decision === 'string' && AI_DECISIONS.includes(value.user_decision as (typeof AI_DECISIONS)[number])
? value.user_decision
: 'pending'
return {
id: value.id as string,
created_at: value.created_at as string,
raw_idea: value.raw_idea as string,
optional_context: typeof value.optional_context === 'string' ? value.optional_context : '',
context_summary: typeof value.context_summary === 'string' ? value.context_summary : '',
suggested_placement: value.suggested_placement as AiRecommendation['suggested_placement'],
scope_risk: value.scope_risk as AiRecommendation['scope_risk'],
confidence_score: typeof value.confidence_score === 'number' ? value.confidence_score : 0.5,
reason: typeof value.reason === 'string' ? value.reason : '',
smallest_safe_version: typeof value.smallest_safe_version === 'string' ? value.smallest_safe_version : '',
suggested_title: typeof value.suggested_title === 'string' ? value.suggested_title : 'Untitled idea',
suggested_description: typeof value.suggested_description === 'string' ? value.suggested_description : '',
suggested_acceptance_criteria: Array.isArray(value.suggested_acceptance_criteria)
? value.suggested_acceptance_criteria.filter((item): item is string => typeof item === 'string')
: [],
suggested_parking_reason: typeof value.suggested_parking_reason === 'string' ? value.suggested_parking_reason : '',
duplicate_check: {
is_duplicate: Boolean(duplicate.is_duplicate),
duplicate_type: duplicate.duplicate_type === 'feature' || duplicate.duplicate_type === 'parking_lot' ? duplicate.duplicate_type : null,
duplicate_id: typeof duplicate.duplicate_id === 'string' ? duplicate.duplicate_id : null,
reason: typeof duplicate.reason === 'string' ? duplicate.reason : null,
},
clarifying_question: typeof value.clarifying_question === 'string' ? value.clarifying_question : null,
user_decision: userDecision as AiRecommendation['user_decision'],
created_feature_id: typeof value.created_feature_id === 'string' ? value.created_feature_id : null,
created_parking_item_id: typeof value.created_parking_item_id === 'string' ? value.created_parking_item_id : null,
decision_pulse_id: typeof value.decision_pulse_id === 'string' ? value.decision_pulse_id : null,
}
}
export const normalizeAppState = (value: unknown): AppState | null => {
if (!isObject(value)) return null
if (!SUPPORTED_SCHEMA_VERSIONS.includes(value.schema_version as (typeof SUPPORTED_SCHEMA_VERSIONS)[number])) return null
if (!isObject(value.project) || !hasRequiredStrings(value.project, ['id', 'name'])) return null
if (!Array.isArray(value.features) || !Array.isArray(value.parking_lot) || !Array.isArray(value.pulses)) return null
if (!isObject(value.settings)) return null
if (
value.features.some(
(feature) =>
@@ -21,7 +74,7 @@ export const validateAppState = (value: unknown): value is AppState => {
!FEATURE_COLUMNS.includes(feature.column as (typeof FEATURE_COLUMNS)[number]),
)
) {
return false
return null
}
if (
value.parking_lot.some(
@@ -31,7 +84,7 @@ export const validateAppState = (value: unknown): value is AppState => {
(typeof item.risk_level === 'string' && !RISK_LEVELS.includes(item.risk_level as (typeof RISK_LEVELS)[number])),
)
) {
return false
return null
}
if (
value.pulses.some(
@@ -41,19 +94,30 @@ export const validateAppState = (value: unknown): value is AppState => {
!PULSE_TYPES.includes(pulse.pulse_type as (typeof PULSE_TYPES)[number]),
)
) {
return false
return null
}
const recommendations = Array.isArray(value.ai_recommendations)
? value.ai_recommendations.map(normalizeRecommendation).filter((entry): entry is AiRecommendation => Boolean(entry))
: []
return {
...(value as unknown as AppState),
schema_version: SCHEMA_VERSION,
ai_recommendations: recommendations,
}
return true
}
export const validateAppState = (value: unknown): value is AppState => normalizeAppState(value) !== null
export const loadAppState = (): AppState => {
if (typeof window === 'undefined') return createSeedState()
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return createSeedState()
try {
const parsed = JSON.parse(raw)
if (validateAppState(parsed)) return parsed
const normalized = normalizeAppState(JSON.parse(raw))
if (normalized) return normalized
} catch {
// fall through to seed state
}
+40 -2
View File
@@ -1,5 +1,6 @@
export const STORAGE_KEY = 'buildpulse.appState.v1'
export const SCHEMA_VERSION = '0.1.0'
export const STORAGE_KEY = 'buildpulse.v1'
export const SCHEMA_VERSION = '0.2.0'
export const SUPPORTED_SCHEMA_VERSIONS = ['0.1.0', '0.2.0'] as const
export const FEATURE_COLUMNS = ['now', 'next', 'later', 'done'] as const
export type FeatureColumn = (typeof FEATURE_COLUMNS)[number]
@@ -36,6 +37,12 @@ export const PULSE_TYPES = [
] as const
export type PulseType = (typeof PULSE_TYPES)[number]
export const AI_PLACEMENTS = ['now', 'next', 'later', 'parking_lot', 'rejected', 'duplicate', 'needs_clarification'] as const
export type AiPlacement = (typeof AI_PLACEMENTS)[number]
export const AI_DECISIONS = ['pending', 'accepted_as_feature', 'accepted_as_parking_lot', 'rejected'] as const
export type AiDecision = (typeof AI_DECISIONS)[number]
export interface Project {
id: string
name: string
@@ -85,6 +92,36 @@ export interface PulseEvent {
trace_id?: string
}
export interface AiDuplicateCheck {
is_duplicate: boolean
duplicate_type: 'feature' | 'parking_lot' | null
duplicate_id: string | null
reason: string | null
}
export interface AiRecommendation {
id: string
created_at: string
raw_idea: string
optional_context: string
context_summary: string
suggested_placement: AiPlacement
scope_risk: RiskLevel
confidence_score: number
reason: string
smallest_safe_version: string
suggested_title: string
suggested_description: string
suggested_acceptance_criteria: string[]
suggested_parking_reason: string
duplicate_check: AiDuplicateCheck
clarifying_question: string | null
user_decision: AiDecision
created_feature_id: string | null
created_parking_item_id: string | null
decision_pulse_id: string | null
}
export interface Settings {
theme: 'light'
default_agent_id: string
@@ -96,6 +133,7 @@ export interface AppState {
features: Feature[]
parking_lot: ParkingLotItem[]
pulses: PulseEvent[]
ai_recommendations: AiRecommendation[]
settings: Settings
}