feat: ship buildpulse 0.3.0 phases and releases

This commit is contained in:
OpenClaw Bot
2026-05-10 20:04:49 +02:00
parent cc63348344
commit 8218a3417e
8 changed files with 1400 additions and 243 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "buildpulse", "name": "buildpulse",
"private": true, "private": true,
"version": "0.2.1", "version": "0.3.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"api": "node --env-file=../.env server/index.mjs", "api": "node --env-file=../.env server/index.mjs",
+907 -224
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -49,6 +49,8 @@ export const createAgentSessionPrompt = (
const grouped = groupFeatures(state.features) const grouped = groupFeatures(state.features)
const target = options?.target || 'AI coding agent' const target = options?.target || 'AI coding agent'
const focusFeature = options?.featureId ? state.features.find((feature) => feature.id === options.featureId) ?? null : grouped.now[0] ?? null const focusFeature = options?.featureId ? state.features.find((feature) => feature.id === options.featureId) ?? null : grouped.now[0] ?? null
const focusRelease = focusFeature?.release_id ? state.releases.find((release) => release.id === focusFeature.release_id) ?? null : null
const focusPhase = focusFeature?.phase_id ? state.phases.find((phase) => phase.id === focusFeature.phase_id) ?? null : null
const relatedPulses = sortPulsesNewestFirst(state.pulses) const relatedPulses = sortPulsesNewestFirst(state.pulses)
.filter((pulse) => (focusFeature ? pulse.feature_id === focusFeature.id : true)) .filter((pulse) => (focusFeature ? pulse.feature_id === focusFeature.id : true))
.slice(0, 6) .slice(0, 6)
@@ -62,6 +64,9 @@ export const createAgentSessionPrompt = (
`- Column: ${columnLabels[feature.column]}`, `- Column: ${columnLabels[feature.column]}`,
`- Priority: ${feature.priority}`, `- Priority: ${feature.priority}`,
`- Status: ${feature.status}`, `- Status: ${feature.status}`,
`- Phase: ${focusPhase?.title || '—'}`,
`- Release: ${focusRelease?.name || '—'}`,
`- Release role: ${feature.release_role || '—'}`,
`- Acceptance criteria: ${feature.acceptance_criteria.length ? feature.acceptance_criteria.join('; ') : 'None yet'}`, `- Acceptance criteria: ${feature.acceptance_criteria.length ? feature.acceptance_criteria.join('; ') : 'None yet'}`,
`- Scope notes: ${feature.scope_notes || '—'}`, `- Scope notes: ${feature.scope_notes || '—'}`,
].join('\n') ].join('\n')
@@ -92,6 +97,11 @@ export const createAgentSessionPrompt = (
'## Next Up', '## Next Up',
grouped.next.length ? grouped.next.map((feature) => `- ${feature.title} (${feature.status})`).join('\n') : '- Nothing queued yet.', grouped.next.length ? grouped.next.map((feature) => `- ${feature.title} (${feature.status})`).join('\n') : '- Nothing queued yet.',
'', '',
'## Current Release Context',
focusRelease
? [`- Release: ${focusRelease.name}`, `- Goal: ${focusRelease.goal}`, `- Status: ${focusRelease.status}`, `- Definition of done: ${focusRelease.definition_of_done.length ? focusRelease.definition_of_done.join('; ') : '—'}`].join('\n')
: '- No release linked to this feature yet.',
'',
'## Parking Lot / Do Not Implement Yet', '## Parking Lot / Do Not Implement Yet',
state.parking_lot.length state.parking_lot.length
? state.parking_lot.map((item) => `- ${item.title}${item.reason_parked || item.description || 'Parked for later.'}`).join('\n') ? state.parking_lot.map((item) => `- ${item.title}${item.reason_parked || item.description || 'Parked for later.'}`).join('\n')
@@ -122,6 +132,8 @@ export const createJsonExport = (state: AppState) =>
schema_version: state.schema_version, schema_version: state.schema_version,
exported_at: new Date().toISOString(), exported_at: new Date().toISOString(),
project: state.project, project: state.project,
phases: state.phases,
releases: state.releases,
features: state.features, features: state.features,
parking_lot: state.parking_lot, parking_lot: state.parking_lot,
pulses: state.pulses, pulses: state.pulses,
@@ -141,6 +153,29 @@ export const createMarkdownPackage = (state: AppState) => {
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`
const phasesAndReleases = [
'# Phases and Releases',
'',
...state.phases
.sort((a, b) => a.order - b.order)
.flatMap((phase) => {
const releases = state.releases.filter((release) => release.phase_id === phase.id)
return [
`## ${phase.title}`,
`- Goal: ${phase.goal}`,
`- Status: ${phase.status}`,
`- Notes: ${phase.notes || '—'}`,
...(releases.length
? releases.map(
(release) =>
` - **${release.name}** — ${release.status}\n - Goal: ${release.goal}\n - DoD: ${release.definition_of_done.length ? release.definition_of_done.join('; ') : '—'}\n - Required features: ${release.required_feature_ids.length ? release.required_feature_ids.join(', ') : '—'}\n - Optional features: ${release.optional_feature_ids.length ? release.optional_feature_ids.join(', ') : '—'}\n - Forbidden: ${release.forbidden_feature_titles.length ? release.forbidden_feature_titles.join('; ') : '—'}`,
)
: ['- No releases in this phase yet.']),
'',
]
}),
].join('\n')
const featurePlan = ['# Feature Plan'] const featurePlan = ['# Feature Plan']
;(['now', 'next', 'later', 'done'] as FeatureColumn[]).forEach((column) => { ;(['now', 'next', 'later', 'done'] as FeatureColumn[]).forEach((column) => {
featurePlan.push(`\n## ${columnLabels[column]}`) featurePlan.push(`\n## ${columnLabels[column]}`)
@@ -195,6 +230,11 @@ export const createMarkdownPackage = (state: AppState) => {
'## Current Goal', '## Current Goal',
state.project.current_goal || '—', state.project.current_goal || '—',
'', '',
'## Active Phase / Release Structure',
state.releases.length
? state.releases.map((release) => `- ${release.name} (${release.status}) — ${release.goal}`).join('\n')
: '_No releases yet._',
'',
'## Active Features', '## Active Features',
grouped.now.length ? grouped.now.map(renderFeature).join('\n\n') : '_None yet._', grouped.now.length ? grouped.now.map(renderFeature).join('\n\n') : '_None yet._',
'', '',
@@ -237,6 +277,7 @@ export const createMarkdownPackage = (state: AppState) => {
return { return {
'PROJECT_SUMMARY.md': projectSummary, 'PROJECT_SUMMARY.md': projectSummary,
'PHASES_AND_RELEASES.md': phasesAndReleases,
'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,
+123 -2
View File
@@ -3,18 +3,127 @@ 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.2.0', schema_version: '0.3.0',
project: { project: {
id: 'project_buildpulse', id: 'project_buildpulse',
name: 'BuildPulse', name: 'BuildPulse',
one_line_pitch: 'A calm planning cockpit for AI-assisted product building.', one_line_pitch: 'A calm planning cockpit for AI-assisted product building.',
description: description:
'BuildPulse helps capture features, park distracting ideas, log progress as Pulse events, and export clean context for AI coding agents.', 'BuildPulse helps capture features, park distracting ideas, log progress as Pulse events, and export clean context for AI coding agents.',
current_goal: 'Ship v0.1 with Feature Plan, Parking Lot, Pulse Log, and Export.', current_goal: 'Ship v0.3 with Phases, Releases, and clear release-readiness signals.',
notes: 'First dogfood project: BuildPulse manages BuildPulse.', notes: 'First dogfood project: BuildPulse manages BuildPulse.',
created_at: seedDate, created_at: seedDate,
updated_at: seedDate, updated_at: seedDate,
}, },
phases: [
{
id: 'phase_manual_cockpit',
title: 'Phase 1: Manual Cockpit',
goal: 'Prove the small planning cockpit works without turning into fake enterprise sludge.',
status: 'done',
order: 1,
notes: 'Feature Plan, Parking Lot, Pulse Log, and Export shipped first.',
created_at: seedDate,
updated_at: seedDate,
},
{
id: 'phase_ai_idea_placement',
title: 'Phase 2: AI Idea Placement',
goal: 'Turn raw ideas into guided decisions with explicit accept, park, or reject outcomes.',
status: 'done',
order: 2,
notes: 'AI triage and DECISION pulse logging are proven before release planning expands.',
created_at: seedDate,
updated_at: seedDate,
},
{
id: 'phase_structured_release_planning',
title: 'Phase 3: Structured Release Planning',
goal: 'Make releases concrete: what is required, what is forbidden, and how close the build is to ready.',
status: 'active',
order: 3,
notes: 'This is the v0.3 step.',
created_at: seedDate,
updated_at: seedDate,
},
{
id: 'phase_session_handoff',
title: 'Phase 4: Session Handoff',
goal: 'Give agents cleaner, more targeted context packages for implementation sessions.',
status: 'upcoming',
order: 4,
notes: 'Planned v0.4 direction.',
created_at: seedDate,
updated_at: seedDate,
},
{
id: 'phase_local_cloud_ai_assistant',
title: 'Phase 5: Local/Cloud AI Assistant',
goal: 'Blend local and remote AI assistance without turning the cockpit into router spaghetti.',
status: 'upcoming',
order: 5,
notes: 'Planned v0.5 direction.',
created_at: seedDate,
updated_at: seedDate,
},
{
id: 'phase_agent_pulse_integration',
title: 'Phase 6: Agent Pulse Integration',
goal: 'Ingest agent activity only after the manual cockpit and release planning are stable.',
status: 'upcoming',
order: 6,
notes: 'This stays out of v0.3 on purpose.',
created_at: seedDate,
updated_at: seedDate,
},
],
releases: [
{
id: 'release_v021_ai_triage',
phase_id: 'phase_ai_idea_placement',
name: 'v0.2.1 — AI Triage Flow Hardening',
goal: 'Make AI triage feel like the default idea-intake path from raw idea to DECISION pulse.',
definition_of_done: [
'AI recommends a placement with risk and smallest safe version.',
'User can accept, park, or reject without losing context.',
'Accepted or rejected triage creates a DECISION pulse.',
'Mobile layout stays readable during the triage flow.',
],
required_feature_ids: ['feature_plan_screen', 'parking_lot_screen', 'pulse_log_screen'],
optional_feature_ids: ['export_screen'],
forbidden_feature_titles: ['Live OpenClaw/Hermes Agent Status', 'WebSocket agent telemetry'],
status: 'shipped',
notes: 'This is the verified gate before v0.3 begins.',
created_at: seedDate,
updated_at: seedDate,
},
{
id: 'release_v030_phases_releases',
phase_id: 'phase_structured_release_planning',
name: 'v0.3 — Phases and Releases',
goal: 'Add release planning structure so the cockpit can answer what phase we are in, what release we are building, and what is forbidden until later.',
definition_of_done: [
'Phases exist as explicit project stages.',
'Releases track goal, definition of done, required features, optional features, and forbidden work.',
'Features can be linked to a phase and a release with required/optional role.',
'A release readiness view shows progress, blockers, recent pulses, and forbidden warnings.',
],
required_feature_ids: ['feature_plan_screen', 'parking_lot_screen', 'pulse_log_screen', 'export_screen'],
optional_feature_ids: [],
forbidden_feature_titles: [
'Live OpenClaw/Hermes Agent Status',
'OpenClaw / Hermes integration',
'WebSocket agent telemetry',
'GitHub / Gitea sync',
'Local/cloud model router',
'Session prompt generator',
],
status: 'in_progress',
notes: 'Keep this on planning structure only. No live integrations yet.',
created_at: seedDate,
updated_at: seedDate,
},
],
features: [ features: [
{ {
id: 'feature_plan_screen', id: 'feature_plan_screen',
@@ -29,6 +138,9 @@ export const createSeedState = (): AppState => ({
'User can edit feature details without clutter.', 'User can edit feature details without clutter.',
], ],
scope_notes: 'This is the home screen. It should answer “what now?” immediately.', scope_notes: 'This is the home screen. It should answer “what now?” immediately.',
phase_id: 'phase_structured_release_planning',
release_id: 'release_v030_phases_releases',
release_role: 'required',
created_at: seedDate, created_at: seedDate,
updated_at: seedDate, updated_at: seedDate,
}, },
@@ -44,6 +156,9 @@ export const createSeedState = (): AppState => ({
'Risk and future placement are visible.', 'Risk and future placement are visible.',
], ],
scope_notes: 'Parking is success behavior, not failure.', scope_notes: 'Parking is success behavior, not failure.',
phase_id: 'phase_structured_release_planning',
release_id: 'release_v030_phases_releases',
release_role: 'required',
created_at: seedDate, created_at: seedDate,
updated_at: seedDate, updated_at: seedDate,
}, },
@@ -59,6 +174,9 @@ export const createSeedState = (): AppState => ({
'Pulses can link to features optionally.', 'Pulses can link to features optionally.',
], ],
scope_notes: 'Manual in v0.1. No live agent ingestion yet.', scope_notes: 'Manual in v0.1. No live agent ingestion yet.',
phase_id: 'phase_structured_release_planning',
release_id: 'release_v030_phases_releases',
release_role: 'required',
created_at: seedDate, created_at: seedDate,
updated_at: seedDate, updated_at: seedDate,
}, },
@@ -74,6 +192,9 @@ export const createSeedState = (): AppState => ({
'Markdown export includes CLAUDE_CONTEXT.md.', 'Markdown export includes CLAUDE_CONTEXT.md.',
], ],
scope_notes: 'Handoff quality matters more than bells and whistles.', scope_notes: 'Handoff quality matters more than bells and whistles.',
phase_id: 'phase_structured_release_planning',
release_id: 'release_v030_phases_releases',
release_role: 'required',
created_at: seedDate, created_at: seedDate,
updated_at: seedDate, updated_at: seedDate,
}, },
+206 -8
View File
@@ -418,6 +418,23 @@ pre {
gap: 0.65rem; gap: 0.65rem;
} }
.tap-hint {
margin: 0.45rem 0 0;
color: #8fb3cf;
font-size: 0.9rem;
}
.tap-chip {
align-self: flex-start;
margin-top: 0.15rem;
border-radius: 999px;
padding: 0.22rem 0.65rem;
background: rgba(96, 165, 250, 0.1);
color: #93c5fd;
font-size: 0.75rem;
letter-spacing: 0.02em;
}
.feature-signal-row { .feature-signal-row {
font-size: 0.82rem; font-size: 0.82rem;
color: #9fb4d9; color: #9fb4d9;
@@ -1291,6 +1308,9 @@ body {
align-items: flex-start; align-items: flex-start;
gap: 1rem; gap: 1rem;
padding: 1rem; padding: 1rem;
background:
radial-gradient(circle at top right, rgba(45, 212, 191, 0.12), transparent 28%),
linear-gradient(180deg, rgba(13, 19, 36, 0.98), rgba(11, 16, 32, 0.92));
} }
.mobile-shell-header h1 { .mobile-shell-header h1 {
@@ -1309,6 +1329,9 @@ body {
gap: 0.45rem; gap: 0.45rem;
color: #b7c4db; color: #b7c4db;
white-space: nowrap; white-space: nowrap;
padding: 0.35rem 0.55rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
} }
.status-dot, .status-dot,
@@ -1454,6 +1477,29 @@ body {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.triage-status-banner,
.triage-meta-line,
.triage-loading-note {
color: #b7c4db;
}
.triage-status-banner {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
align-items: center;
margin-bottom: 1rem;
padding: 0.75rem 0.9rem;
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: rgba(3, 7, 18, 0.42);
}
.triage-meta-line,
.triage-loading-note {
margin: 0;
}
.triage-step-card, .triage-step-card,
.recommendation-card, .recommendation-card,
.suggested-item-card, .suggested-item-card,
@@ -1578,9 +1624,49 @@ body {
margin-top: 1rem; margin-top: 1rem;
} }
.editor-sheet-backdrop {
position: fixed;
inset: 0;
z-index: 75;
display: grid;
place-items: end center;
padding: 1rem;
background: rgba(2, 6, 23, 0.64);
backdrop-filter: blur(10px);
}
.editor-sheet {
width: min(980px, 100%);
max-height: calc(100vh - 2rem);
overflow: auto;
display: grid;
gap: 1rem;
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.98), rgba(7, 11, 22, 0.98)),
radial-gradient(circle at top, rgba(99, 102, 241, 0.12), transparent 40%);
}
.sheet-summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.sheet-summary-grid.single-column {
grid-template-columns: 1fr;
}
.sheet-actions {
position: sticky;
bottom: 0;
padding-top: 0.75rem;
background: linear-gradient(180deg, rgba(11, 16, 32, 0), rgba(11, 16, 32, 0.94) 22%);
}
@media (max-width: 860px) { @media (max-width: 860px) {
.app-shell { .app-shell {
width: min(100% - 1rem, 100%); width: min(100% - 1rem, 100%);
padding-bottom: 5.5rem;
} }
.mobile-shell-header, .mobile-shell-header,
@@ -1605,7 +1691,8 @@ body {
.triage-recommendation-grid, .triage-recommendation-grid,
.accordion-board, .accordion-board,
.focus-grid { .focus-grid,
.sheet-summary-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1614,20 +1701,33 @@ body {
} }
.tab-bar { .tab-bar {
position: sticky; position: fixed;
left: 0.5rem;
right: 0.5rem;
bottom: 0.5rem; bottom: 0.5rem;
top: auto; top: auto;
z-index: 50; z-index: 50;
margin-top: 1rem; margin-top: 1rem;
padding: 0.35rem;
background: rgba(8, 12, 24, 0.94);
box-shadow: 0 20px 48px rgba(2, 6, 23, 0.45);
backdrop-filter: blur(16px);
} }
.triage-modal-backdrop { .triage-modal-backdrop,
.editor-sheet-backdrop {
padding: 0.5rem; padding: 0.5rem;
} }
.triage-modal { .triage-modal,
.editor-sheet {
max-height: calc(100vh - 1rem); max-height: calc(100vh - 1rem);
} }
.editor-sheet {
width: 100%;
border-radius: 24px 24px 18px 18px;
}
} }
@media (max-width: 520px) { @media (max-width: 520px) {
@@ -1637,6 +1737,42 @@ body {
.card { .card {
padding: 1rem; padding: 1rem;
border-radius: 20px;
background: rgba(10, 15, 28, 0.92);
box-shadow: 0 10px 28px rgba(2, 6, 23, 0.22);
backdrop-filter: blur(8px);
}
.plan-home-card {
background:
linear-gradient(180deg, rgba(12, 18, 34, 0.98), rgba(10, 15, 28, 0.96)),
radial-gradient(circle at top left, rgba(96, 165, 250, 0.1), transparent 34%);
}
.parking-first-list {
background:
linear-gradient(180deg, rgba(14, 21, 34, 0.98), rgba(10, 15, 28, 0.96)),
radial-gradient(circle at top right, rgba(251, 146, 60, 0.08), transparent 28%);
}
.pulse-timeline-card {
background:
linear-gradient(180deg, rgba(12, 18, 34, 0.98), rgba(10, 15, 28, 0.96)),
radial-gradient(circle at top center, rgba(45, 212, 191, 0.08), transparent 28%);
}
.item-card {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(148, 163, 184, 0.12);
}
.item-card:nth-child(odd) {
background: rgba(255, 255, 255, 0.045);
}
.tap-chip {
background: rgba(255, 255, 255, 0.06);
color: #c7d2fe;
} }
.button-row, .button-row,
@@ -1647,13 +1783,36 @@ body {
.tab-bar { .tab-bar {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
border-radius: 18px; border-radius: 20px;
} }
.tab { .tab {
min-height: 2.4rem; min-height: 3rem;
padding: 0.65rem 0.2rem; padding: 0.7rem 0.2rem;
font-size: 0.78rem; font-size: 0.8rem;
}
.editor-sheet-backdrop,
.triage-modal-backdrop {
padding: 0;
place-items: end stretch;
}
.editor-sheet,
.triage-modal {
width: 100%;
max-height: 100vh;
border-radius: 22px 22px 0 0;
padding-bottom: 1.25rem;
}
.sheet-actions {
padding-bottom: 0.25rem;
}
.item-card {
padding: 0.9rem;
border-radius: 16px;
} }
} }
@@ -1721,3 +1880,42 @@ select {
font-size: 0.78rem; font-size: 0.78rem;
} }
} }
.roadmap-hero-card,
.roadmap-phase-card,
.roadmap-release-card {
display: grid;
gap: 1rem;
}
.compact-roadmap-grid {
align-items: start;
}
.roadmap-phase-item,
.roadmap-release-detail {
display: grid;
gap: 0.9rem;
}
.roadmap-readiness-grid,
.roadmap-release-feature-links {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.checklist-stack {
display: grid;
gap: 0.45rem;
}
.checkbox-row {
display: flex;
gap: 0.6rem;
align-items: flex-start;
}
.roadmap-badges {
flex-wrap: wrap;
}
+5 -4
View File
@@ -2,10 +2,11 @@ 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)
const AI_REQUEST_TIMEOUT_MS = Number(import.meta.env.VITE_BUILDPULSE_AI_TIMEOUT_MS || 45000)
const request = async <T>(path: string, init?: RequestInit): Promise<T> => { const request = async <T>(path: string, init?: RequestInit, timeoutMs = REQUEST_TIMEOUT_MS): Promise<T> => {
const controller = new AbortController() const controller = new AbortController()
const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) const timeout = window.setTimeout(() => controller.abort(), timeoutMs)
let response: Response let response: Response
try { try {
@@ -19,7 +20,7 @@ const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
}) })
} catch (error) { } catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') { if (error instanceof DOMException && error.name === 'AbortError') {
throw new Error(`BuildPulse API request timed out after ${REQUEST_TIMEOUT_MS}ms`, { cause: error }) throw new Error(`BuildPulse API request to ${path} timed out after ${timeoutMs}ms`, { cause: error })
} }
throw error throw error
} finally { } finally {
@@ -64,7 +65,7 @@ export const triageIdeaWithAi = async (payload: {
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', { 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', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}) }, AI_REQUEST_TIMEOUT_MS)
return response.recommendation return response.recommendation
} }
+68 -1
View File
@@ -3,13 +3,16 @@ import {
AI_DECISIONS, AI_DECISIONS,
AI_PLACEMENTS, AI_PLACEMENTS,
FEATURE_COLUMNS, FEATURE_COLUMNS,
PHASE_STATUSES,
PULSE_TYPES, PULSE_TYPES,
RELEASE_ROLES,
RELEASE_STATUSES,
RISK_LEVELS, RISK_LEVELS,
SCHEMA_VERSION, SCHEMA_VERSION,
STORAGE_KEY, STORAGE_KEY,
SUPPORTED_SCHEMA_VERSIONS, SUPPORTED_SCHEMA_VERSIONS,
} from './types' } from './types'
import type { AiRecommendation, AppState } from './types' import type { AiRecommendation, AppState, ProjectPhase, Release } 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)
@@ -59,6 +62,47 @@ const normalizeRecommendation = (value: unknown): AiRecommendation | null => {
} }
} }
const normalizePhase = (value: unknown): ProjectPhase | null => {
if (!isObject(value)) return null
if (!hasRequiredStrings(value, ['id', 'title', 'goal'])) return null
if (typeof value.status !== 'string' || !PHASE_STATUSES.includes(value.status as (typeof PHASE_STATUSES)[number])) return null
return {
id: value.id as string,
title: value.title as string,
goal: value.goal as string,
status: value.status as ProjectPhase['status'],
order: typeof value.order === 'number' ? value.order : 0,
notes: typeof value.notes === 'string' ? value.notes : '',
created_at: typeof value.created_at === 'string' ? value.created_at : new Date().toISOString(),
updated_at: typeof value.updated_at === 'string' ? value.updated_at : new Date().toISOString(),
}
}
const normalizeRelease = (value: unknown): Release | null => {
if (!isObject(value)) return null
if (!hasRequiredStrings(value, ['id', 'phase_id', 'name', 'goal'])) return null
if (typeof value.status !== 'string' || !RELEASE_STATUSES.includes(value.status as (typeof RELEASE_STATUSES)[number])) return null
const toStrings = (items: unknown) =>
Array.isArray(items) ? items.filter((item): item is string => typeof item === 'string' && Boolean(item.trim())) : []
return {
id: value.id as string,
phase_id: value.phase_id as string,
name: value.name as string,
goal: value.goal as string,
definition_of_done: toStrings(value.definition_of_done),
required_feature_ids: toStrings(value.required_feature_ids),
optional_feature_ids: toStrings(value.optional_feature_ids),
forbidden_feature_titles: toStrings(value.forbidden_feature_titles),
status: value.status as Release['status'],
notes: typeof value.notes === 'string' ? value.notes : '',
created_at: typeof value.created_at === 'string' ? value.created_at : new Date().toISOString(),
updated_at: typeof value.updated_at === 'string' ? value.updated_at : new Date().toISOString(),
}
}
export const normalizeAppState = (value: unknown): AppState | null => { export const normalizeAppState = (value: unknown): AppState | null => {
if (!isObject(value)) return null if (!isObject(value)) return null
if (!SUPPORTED_SCHEMA_VERSIONS.includes(value.schema_version as (typeof SUPPORTED_SCHEMA_VERSIONS)[number])) return null if (!SUPPORTED_SCHEMA_VERSIONS.includes(value.schema_version as (typeof SUPPORTED_SCHEMA_VERSIONS)[number])) return null
@@ -100,10 +144,33 @@ export const normalizeAppState = (value: unknown): AppState | null => {
const recommendations = Array.isArray(value.ai_recommendations) const recommendations = Array.isArray(value.ai_recommendations)
? value.ai_recommendations.map(normalizeRecommendation).filter((entry): entry is AiRecommendation => Boolean(entry)) ? value.ai_recommendations.map(normalizeRecommendation).filter((entry): entry is AiRecommendation => Boolean(entry))
: [] : []
const seedState = createSeedState()
const phases = Array.isArray(value.phases)
? value.phases.map(normalizePhase).filter((entry): entry is ProjectPhase => Boolean(entry))
: seedState.phases
const releases = Array.isArray(value.releases)
? value.releases.map(normalizeRelease).filter((entry): entry is Release => Boolean(entry))
: seedState.releases
const normalizedFeatures = value.features.map((feature) => {
if (!isObject(feature)) return feature
const releaseRole = typeof feature.release_role === 'string' && RELEASE_ROLES.includes(feature.release_role as (typeof RELEASE_ROLES)[number])
? feature.release_role
: undefined
return {
...feature,
phase_id: typeof feature.phase_id === 'string' ? feature.phase_id : undefined,
release_id: typeof feature.release_id === 'string' ? feature.release_id : undefined,
release_role: releaseRole,
}
})
return { return {
...(value as unknown as AppState), ...(value as unknown as AppState),
schema_version: SCHEMA_VERSION, schema_version: SCHEMA_VERSION,
phases,
releases,
features: normalizedFeatures as AppState['features'],
ai_recommendations: recommendations, ai_recommendations: recommendations,
} }
} }
+49 -3
View File
@@ -1,6 +1,6 @@
export const STORAGE_KEY = 'buildpulse.v1' export const STORAGE_KEY = 'buildpulse.v1'
export const SCHEMA_VERSION = '0.2.0' export const SCHEMA_VERSION = '0.3.0'
export const SUPPORTED_SCHEMA_VERSIONS = ['0.1.0', '0.2.0'] as const export const SUPPORTED_SCHEMA_VERSIONS = ['0.1.0', '0.2.0', '0.2.1', '0.3.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]
@@ -23,6 +23,15 @@ export type FeatureStatus = (typeof FEATURE_STATUSES)[number]
export const RISK_LEVELS = ['low', 'medium', 'high', 'dangerous'] as const export const RISK_LEVELS = ['low', 'medium', 'high', 'dangerous'] as const
export type RiskLevel = (typeof RISK_LEVELS)[number] export type RiskLevel = (typeof RISK_LEVELS)[number]
export const PHASE_STATUSES = ['upcoming', 'active', 'done'] as const
export type PhaseStatus = (typeof PHASE_STATUSES)[number]
export const RELEASE_STATUSES = ['not_ready', 'in_progress', 'testing', 'ready_to_ship', 'shipped'] as const
export type ReleaseStatus = (typeof RELEASE_STATUSES)[number]
export const RELEASE_ROLES = ['required', 'optional'] as const
export type ReleaseRole = (typeof RELEASE_ROLES)[number]
export const PULSE_TYPES = [ export const PULSE_TYPES = [
'INTENT', 'INTENT',
'ACTION', 'ACTION',
@@ -54,6 +63,32 @@ export interface Project {
updated_at: string updated_at: string
} }
export interface ProjectPhase {
id: string
title: string
goal: string
status: PhaseStatus
order: number
notes: string
created_at: string
updated_at: string
}
export interface Release {
id: string
phase_id: string
name: string
goal: string
definition_of_done: string[]
required_feature_ids: string[]
optional_feature_ids: string[]
forbidden_feature_titles: string[]
status: ReleaseStatus
notes: string
created_at: string
updated_at: string
}
export interface Feature { export interface Feature {
id: string id: string
title: string title: string
@@ -63,6 +98,12 @@ export interface Feature {
status: FeatureStatus status: FeatureStatus
acceptance_criteria: string[] acceptance_criteria: string[]
scope_notes: string scope_notes: string
phase_id?: string
release_id?: string
release_role?: ReleaseRole
triaged_at?: string
triage_trace_id?: string
triage_confidence_score?: number
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -74,6 +115,9 @@ export interface ParkingLotItem {
reason_parked: string reason_parked: string
possible_future_placement: string possible_future_placement: string
risk_level: RiskLevel risk_level: RiskLevel
triaged_at?: string
triage_trace_id?: string
triage_confidence_score?: number
created_at: string created_at: string
updated_at: string updated_at: string
} }
@@ -130,6 +174,8 @@ export interface Settings {
export interface AppState { export interface AppState {
schema_version: string schema_version: string
project: Project project: Project
phases: ProjectPhase[]
releases: Release[]
features: Feature[] features: Feature[]
parking_lot: ParkingLotItem[] parking_lot: ParkingLotItem[]
pulses: PulseEvent[] pulses: PulseEvent[]
@@ -137,4 +183,4 @@ export interface AppState {
settings: Settings settings: Settings
} }
export type TabKey = 'status' | 'feature-plan' | 'parking-lot' | 'pulse-log' | 'export' export type TabKey = 'status' | 'feature-plan' | 'roadmap' | 'parking-lot' | 'pulse-log' | 'export'