feat: add AI idea placement triage
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import express from 'express'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { checkBackendHealth, fetchStoredAppState, persistAppState } from './appwriteBackend.mjs'
|
||||
import { triageIdea } from './aiTriage.mjs'
|
||||
|
||||
const app = express()
|
||||
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.get('/{*path}', (_req, res) => {
|
||||
|
||||
+419
-12
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user