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 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
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user