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
+188
View File
@@ -0,0 +1,188 @@
const PLACEMENTS = ['now', 'next', 'later', 'parking_lot', 'rejected', 'duplicate', 'needs_clarification']
const RISKS = ['low', 'medium', 'high', 'dangerous']
const systemInstruction = `You are BuildPulse Scope Guardian.
Your job is to classify new ideas for a solo AI-assisted builder.
You must protect the current project scope.
You are not trying to make the project bigger.
You are trying to decide where the idea safely belongs.
Classify the idea into one of:
- now
- next
- later
- parking_lot
- rejected
- duplicate
- needs_clarification
Rules:
- If the idea is required for the current goal, suggest now.
- If useful soon but not required now, suggest next.
- If valid but not soon, suggest later.
- If exciting but distracting, risky, or too large, suggest parking_lot.
- If not useful, unsafe, or contrary to the product goal, suggest rejected.
- If it overlaps existing features or parked ideas, suggest duplicate.
- If there is not enough information, suggest needs_clarification.
Always provide:
- suggested_placement
- scope_risk
- confidence_score
- reason
- smallest_safe_version
- suggested_title
- suggested_description
If suggesting a feature, include acceptance criteria.
If suggesting parking_lot, include parking reason.
If duplicate, identify likely duplicate.
If needs_clarification, ask one clear question.
Return strict JSON only.
Do not include markdown.
Do not include prose outside JSON.`
const responseShape = {
suggested_placement: 'parking_lot',
scope_risk: 'high',
confidence_score: 0.82,
reason: 'This requires real agent integration, which is out of scope for v0.2.',
smallest_safe_version: 'Add a manual source/agent dropdown to Pulse events.',
suggested_title: 'OpenClaw live agent tracking',
suggested_description: 'Display live agent status from OpenClaw inside BuildPulse.',
suggested_acceptance_criteria: [],
suggested_parking_reason: 'Belongs to Agent Pulse v1.0+.',
duplicate_check: {
is_duplicate: true,
duplicate_type: 'parking_lot',
duplicate_id: 'parked_real_agent_pulse',
reason: 'Real agent ingestion already exists in Parking Lot.',
},
clarifying_question: null,
}
const cleanJsonText = (text) =>
String(text || '')
.trim()
.replace(/^```json\s*/i, '')
.replace(/^```\s*/i, '')
.replace(/```$/i, '')
.trim()
const asString = (value, fallback = '') => (typeof value === 'string' ? value.trim() : fallback)
const clampConfidence = (value) => {
const number = Number(value)
if (!Number.isFinite(number)) return 0.5
return Math.max(0, Math.min(1, number))
}
const stringArray = (value) => (Array.isArray(value) ? value.filter((item) => typeof item === 'string').map((item) => item.trim()).filter(Boolean) : [])
export const validateRecommendation = (raw) => {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new Error('AI response was not a JSON object.')
}
const suggestedPlacement = asString(raw.suggested_placement)
const scopeRisk = asString(raw.scope_risk)
if (!PLACEMENTS.includes(suggestedPlacement)) {
throw new Error(`AI response used invalid placement: ${suggestedPlacement || 'missing'}`)
}
if (!RISKS.includes(scopeRisk)) {
throw new Error(`AI response used invalid scope risk: ${scopeRisk || 'missing'}`)
}
const duplicate = raw.duplicate_check && typeof raw.duplicate_check === 'object' && !Array.isArray(raw.duplicate_check) ? raw.duplicate_check : {}
return {
suggested_placement: suggestedPlacement,
scope_risk: scopeRisk,
confidence_score: clampConfidence(raw.confidence_score),
reason: asString(raw.reason, 'No reason provided.'),
smallest_safe_version: asString(raw.smallest_safe_version, 'Define the smallest manual version before building.'),
suggested_title: asString(raw.suggested_title, 'Untitled idea'),
suggested_description: asString(raw.suggested_description, ''),
suggested_acceptance_criteria: stringArray(raw.suggested_acceptance_criteria),
suggested_parking_reason: asString(raw.suggested_parking_reason, ''),
duplicate_check: {
is_duplicate: Boolean(duplicate.is_duplicate),
duplicate_type: ['feature', 'parking_lot'].includes(duplicate.duplicate_type) ? duplicate.duplicate_type : null,
duplicate_id: asString(duplicate.duplicate_id) || null,
reason: asString(duplicate.reason) || null,
},
clarifying_question: asString(raw.clarifying_question) || null,
}
}
export const buildTriagePrompt = ({ rawIdea, optionalContext, appContext }) =>
[
systemInstruction,
'',
'Current compact BuildPulse context:',
JSON.stringify(appContext, null, 2),
'',
'Raw idea:',
rawIdea,
optionalContext ? `\nOptional user context:\n${optionalContext}` : '',
'',
'Return JSON matching this shape exactly. Use only allowed placement and risk values:',
JSON.stringify(responseShape, null, 2),
].join('\n')
const callGemini = async ({ prompt }) => {
const apiKey = process.env.BUILDPULSE_AI_API_KEY || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
if (!apiKey) {
throw new Error('No Gemini API key configured. Set GEMINI_API_KEY or BUILDPULSE_AI_API_KEY on the server.')
}
const model = process.env.BUILDPULSE_AI_MODEL || 'gemini-2.5-flash'
const endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent`
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), Number(process.env.BUILDPULSE_AI_TIMEOUT_MS || 20000))
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify({
contents: [
{
role: 'user',
parts: [{ text: prompt }],
},
],
generationConfig: {
temperature: 0.2,
responseMimeType: 'application/json',
},
}),
signal: controller.signal,
})
const payload = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(payload?.error?.message || `Gemini request failed with ${response.status}`)
}
const text = payload?.candidates?.[0]?.content?.parts?.map((part) => part.text || '').join('')
if (!text) throw new Error('Gemini returned no text.')
return JSON.parse(cleanJsonText(text))
} finally {
clearTimeout(timeout)
}
}
export const triageIdea = async ({ rawIdea, optionalContext, appContext }) => {
const idea = asString(rawIdea)
if (!idea) throw new Error('raw_idea is required.')
const prompt = buildTriagePrompt({ rawIdea: idea, optionalContext: asString(optionalContext), appContext: appContext || {} })
const provider = process.env.BUILDPULSE_AI_PROVIDER || 'gemini'
if (provider !== 'gemini') {
throw new Error(`Unsupported BuildPulse AI provider: ${provider}. v0.2 ships with gemini only.`)
}
const rawRecommendation = await callGemini({ prompt })
return validateRecommendation(rawRecommendation)
}
+20
View File
@@ -2,6 +2,7 @@ import express from 'express'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { checkBackendHealth, fetchStoredAppState, persistAppState } from './appwriteBackend.mjs' import { checkBackendHealth, fetchStoredAppState, persistAppState } from './appwriteBackend.mjs'
import { triageIdea } from './aiTriage.mjs'
const app = express() const app = express()
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
@@ -45,6 +46,25 @@ app.put('/api/state', async (req, res) => {
} }
}) })
app.post('/api/ai/triage-idea', async (req, res) => {
const rawIdea = req.body?.raw_idea
if (typeof rawIdea !== 'string' || !rawIdea.trim()) {
res.status(400).json({ ok: false, error: 'raw_idea is required.' })
return
}
try {
const recommendation = await triageIdea({
rawIdea,
optionalContext: typeof req.body?.optional_context === 'string' ? req.body.optional_context : '',
appContext: req.body?.app_context && typeof req.body.app_context === 'object' ? req.body.app_context : {},
})
res.json({ ok: true, provider: process.env.BUILDPULSE_AI_PROVIDER || 'gemini', recommendation })
} catch (error) {
res.status(502).json({ ok: false, error: error?.message || 'AI triage failed.' })
}
})
app.use(express.static(distDir)) app.use(express.static(distDir))
app.get('/{*path}', (_req, res) => { app.get('/{*path}', (_req, res) => {
+419 -12
View File
@@ -2,10 +2,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import type { ChangeEvent } from 'react' import type { ChangeEvent } from 'react'
import './index.css' import './index.css'
import { createAgentSessionPrompt, createMarkdownPackage, createJsonExport, createPulseJsonl } from './features/export/exporters' import { createAgentSessionPrompt, createMarkdownPackage, createJsonExport, createPulseJsonl } from './features/export/exporters'
import { loadAppState, replaceAppState, saveAppState, validateAppState } from './store/storage' import { loadAppState, normalizeAppState, replaceAppState, saveAppState } from './store/storage'
import { fetchBackendHealth, fetchRemoteState, pushRemoteState } from './store/remote' import { fetchBackendHealth, fetchRemoteState, pushRemoteState, triageIdeaWithAi } from './store/remote'
import { FEATURE_COLUMNS, FEATURE_PRIORITIES, FEATURE_STATUSES, PULSE_TYPES, RISK_LEVELS } from './store/types' import { AI_PLACEMENTS, 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 type { AiPlacement, AiRecommendation, AppState, Feature, FeatureColumn, ParkingLotItem, PulseEvent, RiskLevel, TabKey } from './store/types'
import { arrayToLines, formatDateTime, linesToArray, nowIso, slugify } from './utils/format' import { arrayToLines, formatDateTime, linesToArray, nowIso, slugify } from './utils/format'
const TABS: Array<{ key: TabKey; label: string }> = [ const TABS: Array<{ key: TabKey; label: string }> = [
@@ -46,6 +46,27 @@ const initialPulseDraft = {
traceId: '', 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> = { const columnLabels: Record<FeatureColumn, string> = {
now: 'Now', now: 'Now',
next: 'Next', next: 'Next',
@@ -83,6 +104,19 @@ function App() {
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null) const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null)
const [syncAction, setSyncAction] = useState<'idle' | 'refreshing' | 'pushing'>('idle') const [syncAction, setSyncAction] = useState<'idle' | 'refreshing' | 'pushing'>('idle')
const [selectedStatusCardTitle, setSelectedStatusCardTitle] = useState('Project Cockpit') 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 hasHydratedRemote = useRef(false)
const initialLocalStateRef = useRef(appState) const initialLocalStateRef = useRef(appState)
@@ -98,9 +132,10 @@ function App() {
const remoteState = await fetchRemoteState() const remoteState = await fetchRemoteState()
if (cancelled) return if (cancelled) return
if (remoteState && validateAppState(remoteState)) { const normalizedRemoteState = normalizeAppState(remoteState)
setAppState(remoteState) if (normalizedRemoteState) {
saveAppState(remoteState) setAppState(normalizedRemoteState)
saveAppState(normalizedRemoteState)
setStatusMessage('Loaded state from Appwrite on the Unraid server.') setStatusMessage('Loaded state from Appwrite on the Unraid server.')
} else { } else {
await pushRemoteState(initialLocalStateRef.current) await pushRemoteState(initialLocalStateRef.current)
@@ -330,9 +365,10 @@ function App() {
if (!health.ok) throw new Error('Backend health check failed.') if (!health.ok) throw new Error('Backend health check failed.')
if (remoteState && validateAppState(remoteState)) { const normalizedRemoteState = normalizeAppState(remoteState)
setAppState(remoteState) if (normalizedRemoteState) {
saveAppState(remoteState) setAppState(normalizedRemoteState)
saveAppState(normalizedRemoteState)
setBackendMode('appwrite') setBackendMode('appwrite')
setSyncStatus('synced') setSyncStatus('synced')
setLastSyncedAt(nowIso()) 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) => { const openFeatureHandoff = (featureId: string) => {
setPromptFeatureId(featureId) setPromptFeatureId(featureId)
setPromptTarget('OpenClaw') setPromptTarget('OpenClaw')
@@ -597,11 +868,12 @@ function App() {
try { try {
const text = await file.text() const text = await file.text()
const parsed = JSON.parse(text) const parsed = JSON.parse(text)
if (!validateAppState(parsed)) { const normalized = normalizeAppState(parsed)
if (!normalized) {
setStatusMessage('Import failed: invalid BuildPulse schema or unsupported version.') setStatusMessage('Import failed: invalid BuildPulse schema or unsupported version.')
return return
} }
setAppState(replaceAppState(parsed)) setAppState(replaceAppState(normalized))
setStatusMessage('Import complete. State replaced cleanly.') setStatusMessage('Import complete. State replaced cleanly.')
} catch { } catch {
setStatusMessage('Import failed: invalid JSON.') setStatusMessage('Import failed: invalid JSON.')
@@ -728,6 +1000,135 @@ function App() {
</button> </button>
</aside> </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' && ( {activeTab === 'feature-plan' && (
<> <>
<header className="hero-card"> <header className="hero-card">
@@ -969,6 +1370,9 @@ function App() {
<h2>Feature Plan</h2> <h2>Feature Plan</h2>
<p>Lead with focus, not overview. This is the calm what now? screen.</p> <p>Lead with focus, not overview. This is the calm what now? screen.</p>
</div> </div>
<button type="button" onClick={() => openTriage()}>
Triage Idea with AI
</button>
</div> </div>
{selectedFeature && ( {selectedFeature && (
@@ -1205,6 +1609,9 @@ function App() {
<h2>Parking Lot</h2> <h2>Parking Lot</h2>
<p>This is where useful distractions go so they do not hijack the build.</p> <p>This is where useful distractions go so they do not hijack the build.</p>
</div> </div>
<button type="button" onClick={() => openTriage()}>
Triage Idea with AI
</button>
</div> </div>
<div className="editor-grid"> <div className="editor-grid">
<section className="card editor-card"> <section className="card editor-card">
+21
View File
@@ -125,6 +125,7 @@ export const createJsonExport = (state: AppState) =>
features: state.features, features: state.features,
parking_lot: state.parking_lot, parking_lot: state.parking_lot,
pulses: state.pulses, pulses: state.pulses,
ai_recommendations: state.ai_recommendations,
settings: state.settings, settings: state.settings,
}, },
null, null,
@@ -136,6 +137,7 @@ export const createPulseJsonl = (state: AppState) => state.pulses.map((pulse) =>
export const createMarkdownPackage = (state: AppState) => { export const createMarkdownPackage = (state: AppState) => {
const grouped = groupFeatures(state.features) const grouped = groupFeatures(state.features)
const recentPulses = sortPulsesNewestFirst(state.pulses).slice(0, 8) 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` 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._']), : ['_No pulse events yet._']),
].join('\n') ].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 = [ const claudeContext = [
'# AI Coding Context', '# AI Coding Context',
'', '',
@@ -208,6 +221,13 @@ export const createMarkdownPackage = (state: AppState) => {
.join('\n') .join('\n')
: '_No recent pulse events yet._', : '_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', '## Instructions for AI Developer',
'- Only work on the selected feature.', '- Only work on the selected feature.',
'- Do not implement Parking Lot items.', '- Do not implement Parking Lot items.',
@@ -220,6 +240,7 @@ export const createMarkdownPackage = (state: AppState) => {
'FEATURE_PLAN.md': featurePlan.join('\n'), 'FEATURE_PLAN.md': featurePlan.join('\n'),
'PARKING_LOT.md': parkingLot, 'PARKING_LOT.md': parkingLot,
'PULSE_LOG.md': pulseLog, 'PULSE_LOG.md': pulseLog,
'AI_RECOMMENDATIONS.md': aiRecommendations,
'CLAUDE_CONTEXT.md': claudeContext, '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' const seedDate = '2026-05-06T00:00:00+02:00'
export const createSeedState = (): AppState => ({ export const createSeedState = (): AppState => ({
schema_version: '0.1.0', schema_version: '0.2.0',
project: { project: {
id: 'project_buildpulse', id: 'project_buildpulse',
name: 'BuildPulse', name: 'BuildPulse',
@@ -137,6 +137,7 @@ export const createSeedState = (): AppState => ({
trace_id: 'session_seed', trace_id: 'session_seed',
}, },
], ],
ai_recommendations: [],
settings: { settings: {
theme: 'light', theme: 'light',
default_agent_id: 'jimmi', default_agent_id: 'jimmi',
+78
View File
@@ -1205,3 +1205,81 @@ body {
width: min(100% - 4rem, 100%); 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 API_BASE = import.meta.env.VITE_BUILDPULSE_API_BASE || ''
const REQUEST_TIMEOUT_MS = Number(import.meta.env.VITE_BUILDPULSE_API_TIMEOUT_MS || 5000) const REQUEST_TIMEOUT_MS = Number(import.meta.env.VITE_BUILDPULSE_API_TIMEOUT_MS || 5000)
@@ -55,3 +55,16 @@ export const fetchBackendHealth = async () =>
collectionId: string collectionId: string
documentPresent: boolean documentPresent: boolean
}>('/api/health') }>('/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 { createSeedState } from '../features/project/projectDefaults'
import { FEATURE_COLUMNS, PULSE_TYPES, RISK_LEVELS, SCHEMA_VERSION, STORAGE_KEY } from './types' import {
import type { AppState } from './types' 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> => const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value) 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[]) => const hasRequiredStrings = (value: Record<string, unknown>, keys: string[]) =>
keys.every((key) => typeof value[key] === 'string' && value[key]) keys.every((key) => typeof value[key] === 'string' && value[key])
export const validateAppState = (value: unknown): value is AppState => { const normalizeRecommendation = (value: unknown): AiRecommendation | null => {
if (!isObject(value)) return false if (!isObject(value)) return null
if (value.schema_version !== SCHEMA_VERSION) return false if (!hasRequiredStrings(value, ['id', 'created_at', 'raw_idea', 'suggested_placement', 'scope_risk'])) return null
if (!isObject(value.project) || !hasRequiredStrings(value.project, ['id', 'name'])) return false if (!AI_PLACEMENTS.includes(value.suggested_placement as (typeof AI_PLACEMENTS)[number])) return null
if (!Array.isArray(value.features) || !Array.isArray(value.parking_lot) || !Array.isArray(value.pulses)) return false 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 ( if (
value.features.some( value.features.some(
(feature) => (feature) =>
@@ -21,7 +74,7 @@ export const validateAppState = (value: unknown): value is AppState => {
!FEATURE_COLUMNS.includes(feature.column as (typeof FEATURE_COLUMNS)[number]), !FEATURE_COLUMNS.includes(feature.column as (typeof FEATURE_COLUMNS)[number]),
) )
) { ) {
return false return null
} }
if ( if (
value.parking_lot.some( 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])), (typeof item.risk_level === 'string' && !RISK_LEVELS.includes(item.risk_level as (typeof RISK_LEVELS)[number])),
) )
) { ) {
return false return null
} }
if ( if (
value.pulses.some( 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]), !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 => { export const loadAppState = (): AppState => {
if (typeof window === 'undefined') return createSeedState() if (typeof window === 'undefined') return createSeedState()
const raw = window.localStorage.getItem(STORAGE_KEY) const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return createSeedState() if (!raw) return createSeedState()
try { try {
const parsed = JSON.parse(raw) const normalized = normalizeAppState(JSON.parse(raw))
if (validateAppState(parsed)) return parsed if (normalized) return normalized
} catch { } catch {
// fall through to seed state // fall through to seed state
} }
+40 -2
View File
@@ -1,5 +1,6 @@
export const STORAGE_KEY = 'buildpulse.appState.v1' export const STORAGE_KEY = 'buildpulse.v1'
export const SCHEMA_VERSION = '0.1.0' 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 const FEATURE_COLUMNS = ['now', 'next', 'later', 'done'] as const
export type FeatureColumn = (typeof FEATURE_COLUMNS)[number] export type FeatureColumn = (typeof FEATURE_COLUMNS)[number]
@@ -36,6 +37,12 @@ export const PULSE_TYPES = [
] as const ] as const
export type PulseType = (typeof PULSE_TYPES)[number] 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 { export interface Project {
id: string id: string
name: string name: string
@@ -85,6 +92,36 @@ export interface PulseEvent {
trace_id?: string 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 { export interface Settings {
theme: 'light' theme: 'light'
default_agent_id: string default_agent_id: string
@@ -96,6 +133,7 @@ export interface AppState {
features: Feature[] features: Feature[]
parking_lot: ParkingLotItem[] parking_lot: ParkingLotItem[]
pulses: PulseEvent[] pulses: PulseEvent[]
ai_recommendations: AiRecommendation[]
settings: Settings settings: Settings
} }