feat: plan_snapshot field on ClaudeJob + architecture doc (#23)

* feat: add plan_snapshot field to ClaudeJob schema

Nullable String? column on claude_jobs captures the task's
implementation_plan at claim time — immutable baseline for drift detection.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: update ClaudeJob lifecycle with plan_snapshot

Document state machine snapshot capture/reset, plan_snapshot field
rationale, and drift-detection baseline semantics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor: remove duplicate header labels on backlog page

Both the product H1 + description in the page header and the
"Product Backlog" panel-title in the PBI panel duplicated info
already visible in the NavBar. Removed both, keeping the right-aligned
action bars (activate/sprint/settings, plus filters/+PBI) intact.

PanelNavBar component is unchanged — Stories and Taken panels keep
their titles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-30 19:43:47 +02:00 committed by GitHub
parent 8877ea469d
commit 794f7afd2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 89 additions and 94 deletions

View file

@ -92,14 +92,8 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Product header */} {/* Product header — actions only; product-naam zit al in NavBar */}
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-between"> <div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-end">
<div>
<h1 className="font-medium text-foreground">{product.name}</h1>
{product.description && (
<p className="text-xs text-muted-foreground mt-0.5">{product.description}</p>
)}
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{user?.active_product_id !== id && ( {user?.active_product_id !== id && (
<ActivateProductButton productId={id} isDemo={isDemo} redirectTo={`/products/${id}`} /> <ActivateProductButton productId={id} isDemo={isDemo} redirectTo={`/products/${id}`} />

View file

@ -24,7 +24,6 @@ import { toast } from 'sonner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
import { useSelectionStore } from '@/stores/selection-store' import { useSelectionStore } from '@/stores/selection-store'
import { usePlannerStore } from '@/stores/planner-store' import { usePlannerStore } from '@/stores/planner-store'
import { useBacklogStore } from '@/stores/backlog-store' import { useBacklogStore } from '@/stores/backlog-store'
@ -330,92 +329,87 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<PanelNavBar <div className="flex items-center justify-end gap-2 px-4 py-2 border-b border-border bg-surface-container-low shrink-0">
title="Product Backlog" {filterPriority !== 'all' && (
actions={ <button
<> onClick={() => setFilterPriority('all')}
{filterPriority !== 'all' && ( className="flex items-center gap-1 text-xs text-primary hover:underline"
<button aria-label="Wis prioriteitsfilter"
onClick={() => setFilterPriority('all')} >
className="flex items-center gap-1 text-xs text-primary hover:underline" <Badge className={cn('text-xs', PRIORITY_COLORS[filterPriority])}>
aria-label="Wis prioriteitsfilter" {PRIORITY_LABELS[filterPriority]}
> </Badge>
<Badge className={cn('text-xs', PRIORITY_COLORS[filterPriority])}> <span>×</span>
{PRIORITY_LABELS[filterPriority]} </button>
</Badge> )}
<span>×</span> {filterStatus !== 'all' && (
</button> <button
)} onClick={() => setFilterStatus('all')}
{filterStatus !== 'all' && ( className="flex items-center gap-1 text-xs text-primary hover:underline"
<button aria-label="Wis statusfilter"
onClick={() => setFilterStatus('all')} >
className="flex items-center gap-1 text-xs text-primary hover:underline" <Badge className={cn('text-xs', PBI_STATUS_COLORS[filterStatus])}>
aria-label="Wis statusfilter" {PBI_STATUS_LABELS[filterStatus]}
> </Badge>
<Badge className={cn('text-xs', PBI_STATUS_COLORS[filterStatus])}> <span>×</span>
{PBI_STATUS_LABELS[filterStatus]} </button>
</Badge> )}
<span>×</span> <Popover>
</button> <PopoverTrigger
)} render={
<Popover> <Button variant="outline" size="sm" className="h-7 text-xs">
<PopoverTrigger {`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`}
render={ </Button>
<Button variant="outline" size="sm" className="h-7 text-xs"> }
{`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`} />
</Button> <PopoverContent align="end" className="w-72 space-y-4">
} <FilterPills
/> label="Sorteren op"
<PopoverContent align="end" className="w-72 space-y-4"> options={SORT_OPTIONS}
<FilterPills value={sortMode}
label="Sorteren op" onChange={setSortMode}
options={SORT_OPTIONS} />
value={sortMode} <FilterPills
onChange={setSortMode} label="Prioriteit"
/> options={PRIORITY_OPTIONS}
<FilterPills value={filterPriority}
label="Prioriteit" onChange={setFilterPriority}
options={PRIORITY_OPTIONS} />
value={filterPriority} <FilterPills
onChange={setFilterPriority} label="Status"
/> options={STATUS_OPTIONS}
<FilterPills value={filterStatus}
label="Status" onChange={setFilterStatus}
options={STATUS_OPTIONS} />
value={filterStatus} <div className="flex justify-end pt-1 border-t border-border">
onChange={setFilterStatus}
/>
<div className="flex justify-end pt-1 border-t border-border">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
disabled={activeFilterCount === 0}
onClick={() => {
setFilterPriority('all')
setFilterStatus('all')
setSortMode('priority')
}}
>
Wis filters
</Button>
</div>
</PopoverContent>
</Popover>
<DemoTooltip show={isDemo}>
<Button <Button
type="button"
variant="ghost"
size="sm" size="sm"
className="h-7 text-xs" className="h-7 text-xs"
disabled={isDemo} disabled={activeFilterCount === 0}
onClick={() => !isDemo && setDialogState({ mode: 'create', productId, defaultPriority: 2 })} onClick={() => {
setFilterPriority('all')
setFilterStatus('all')
setSortMode('priority')
}}
> >
+ PBI Wis filters
</Button> </Button>
</DemoTooltip> </div>
</> </PopoverContent>
} </Popover>
/> <DemoTooltip show={isDemo}>
<Button
size="sm"
className="h-7 text-xs"
disabled={isDemo}
onClick={() => !isDemo && setDialogState({ mode: 'create', productId, defaultPriority: 2 })}
>
+ PBI
</Button>
</DemoTooltip>
</div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{pbis.length === 0 ? ( {pbis.length === 0 ? (

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 488 KiB

After

Width:  |  Height:  |  Size: 490 KiB

Before After
Before After

View file

@ -1120,12 +1120,15 @@ Developers kunnen vanuit de Task Detail Dialog een lokale Claude Code-sessie ins
### State machine ### State machine
``` ```
QUEUED → CLAIMED → RUNNING → DONE QUEUED → CLAIMED (snapshot capture) → RUNNING → DONE
→ FAILED → FAILED
→ CANCELLED (door user) → CANCELLED (door user)
CLAIMED → QUEUED (stale claim cleanup, >30min) CLAIMED → QUEUED (stale claim cleanup, >30min; snapshot gewist)
QUEUED → CLAIMED (re-claim na stale reset; snapshot refreshed)
``` ```
**Snapshot-rationale:** bij atomic claim schrijft `wait_for_job` de dan-actuele `task.implementation_plan` naar `claude_jobs.plan_snapshot`. Dit veld blijft bevroren terwijl de job loopt — ook als een gebruiker `update_task_plan` aanroept. Zo kan een toekomstige verify-tool drift detecteren tussen de baseline (snapshot) en de actuele plan. Jobs zonder snapshot (NULL) zijn aangemaakt vóór deze feature en worden als "no baseline" gemarkeerd.
### ClaudeJob model ### ClaudeJob model
``` ```
@ -1134,6 +1137,7 @@ claude_jobs
status: ClaudeJobStatus (QUEUED|CLAIMED|RUNNING|DONE|FAILED|CANCELLED) status: ClaudeJobStatus (QUEUED|CLAIMED|RUNNING|DONE|FAILED|CANCELLED)
claimed_by_token_id (FK → api_tokens, nullable) claimed_by_token_id (FK → api_tokens, nullable)
claimed_at, started_at, finished_at claimed_at, started_at, finished_at
plan_snapshot: String? — bevroren snapshot van task.implementation_plan bij claim
branch, summary, error branch, summary, error
@@index([user_id, status]) @@index([user_id, status])
@@index([task_id, status]) @@index([task_id, status])

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "claude_jobs" ADD COLUMN "plan_snapshot" TEXT;

View file

@ -260,6 +260,7 @@ model ClaudeJob {
claimed_at DateTime? claimed_at DateTime?
started_at DateTime? started_at DateTime?
finished_at DateTime? finished_at DateTime?
plan_snapshot String?
branch String? branch String?
summary String? summary String?
error String? error String?