Skip to content

feat(backlog): add first-class duplicate status with duplicate_of_id link - #289

Draft
tstapler wants to merge 1430 commits into
mainfrom
backlog/stapler-squad-backlog-duplicate-status
Draft

feat(backlog): add first-class duplicate status with duplicate_of_id link#289
tstapler wants to merge 1430 commits into
mainfrom
backlog/stapler-squad-backlog-duplicate-status

Conversation

@tstapler

Copy link
Copy Markdown
Owner

Summary

Adds a first-class duplicate backlog status with a duplicate_of_id link, so triage can mark an item as a duplicate of another with a queryable, structured relationship — instead of the current archive + free-text-note workaround. Includes a mark_duplicate MCP tool for agent self-service, list-view exclusion matching archived/done, and distinct UI treatment across all status-aware surfaces.

Context

Three separate backlog items independently described the same install-service.sh .zshrc-sourcing bug (discovered via 3 separate GitHub-issue-sync imports), and resolving the overlap required manual cross-referencing in free-text notes rather than a structured relationship. This closes backlog item 4f03de7b-3fca-4f3a-84cb-8c6c5abede50.

Changes

  • State machine (session/backlog.go): BacklogStatusDuplicate, new transition edges (idea|refining|ready|in_progress|review → duplicate, duplicate → idea reopen; done → duplicate deliberately excluded), a new TransitionGuard branch with 3 sentinel errors (empty/self-reference/invalid-or-chained target).
  • Data model: duplicate_of_id as a plain optional string (ent schema + proto), matching the existing archived_at bare-field convention rather than a self-referential edge.
  • Atomic write (session/ent_repository_backlog.go): status + duplicate_of_id committed in one ent .Where(StatusEQ(...))-guarded update, closing a pre-existing TOCTOU race on this method; reopening a duplicate clears the stale link.
  • mark_duplicate MCP tool (server/mcp/tools_backlog.go): session-item authorization check, not-found-vs-infra-error disambiguation for both the item and its target, best-effort note append.
  • List exclusion: duplicate items excluded from default/active views, matching archived/done.
  • Frontend: distinct duplicate badge (own theme tokens, all 6 themes, WCAG AA 4.5:1+) across BacklogItemBadge, BacklogItemDetail, the backlog table + filter chips, and BacklogItemCard's action spec; BacklogItemDetail resolves a client-side "Duplicate of: <title>" link with loading/resolved/missing states.
  • Docs: triage guidance updated to point at mark_duplicate instead of archive+note.
  • Tests: new Go unit/integration tests for every state-machine branch, the atomic write, optimistic concurrency, and mark_duplicate's error paths; new Jest/RTL tests for all 4 frontend surfaces and the 3-state link resolution.

Impact

  • Scope: session/backlog.go, ent schema, proto, server/services/backlog_service.go, server/mcp/tools_backlog.go, 4 frontend components + theme files.
  • Breaking Changes: none — purely additive (new enum value, new nullable column, new proto fields). The Repository.TransitionBacklogItemStatus signature gained a 5th parameter (opts *TransitionOptions); all 7 existing call sites were updated to pass nil.
  • Performance: neutral — one additional conditional predicate on an existing indexed-status update; no new queries on the hot list-read path.
  • Dependencies: none added (no FSM library, no contrast-checking library — see research/build-vs-buy.md).

Reviewer Notes

  • Focus areas: the atomic-write gating (toStatus == BacklogStatusDuplicate on SetDuplicateOfID) and the mark_duplicate authorization check are the two places a pre-mortem/adversarial review caught real bugs before they shipped — worth a close look. The BacklogItemDetail 3-state link-resolution effect (loading/resolved/missing, with a forId-tagged reset to avoid a stale-data flicker across item navigation) is the trickiest frontend bit.
  • Known limitations (explicitly documented in project_plans/duplicate-backlog-status/implementation/plan.md's Risk Control section, not silently introduced):
    • UpdateBacklogItem has an identical pre-existing TOCTOU race to the one this PR fixes in TransitionBacklogItemStatus — out of scope here, flagged as a follow-up.
    • No handling for in-flight work/review sessions when their item is marked duplicate mid-transition.
    • A low-probability second-order TOCTOU on chain-prevention (the target's own status could change between the guard-check read and the write).
  • Follow-up tasks:
    • AC13's backfill is not yet done. The three motivating items (10128af0-e1eb-47bc-9016-3af8fde83b4d, 1dc7ff10-326c-4276-a70f-eb8869713593, and the 67de6c7b* item) should be resolved via the shipped mark_duplicate tool as adoption proof — deferred because the stapler-squad MCP connection disconnected mid-session and hadn't reconnected by ship time. Exact steps are already written in plan.md's Task 7.2.1a-bis/b/c.
    • report_progress/request_review calls for this backlog item's 13 criteria are also pending the same MCP reconnection — this PR was created via gh pr create directly per the fallback instruction ("if the MCP server is unavailable, continue your work ... record completed criteria in your commit messages"), which the commit message does in detail.
    • Extending check-theme-contrast.ts to cover the new duplicate token pair (currently verified by hand-computed, independently-re-checked ratios only) — low priority, noted in plan.md.
  • Rollback procedure: standard revert via PR close + revert commit. No data cleanup needed (additive nullable column).
  • Feature flag: not gated — purely additive, no existing behavior changes for non-duplicate items.

Related

  • Closes 4f03de7b-3fca-4f3a-84cb-8c6c5abede50
  • Related: 5f6975a8-79df-481b-86bb-da9a3340117f (archive/notes RPCs not MCP-exposed — narrower, separate gap)

tstapler and others added 30 commits May 19, 2026 10:38
…I support (#84)

* chore(sdd): add planning artifacts for session-hibernation

State machine redesign + hibernation feature planning:
- requirements.md: 4 epics (state machine, async creation, sub-status visibility, hibernation)
- research/: stack, features, architecture, pitfalls
- implementation/plan.md: 4 epics, 18 stories, 36 tasks
- implementation/validation.md: 78 tests, 100% AC coverage
- decisions/ADR-001: state machine redesign (5-state model)
- decisions/ADR-002: hibernation checkpoint storage

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

* chore(sdd): update session-hibernation plan with TransitionDef architecture

- Go type system research added (research/go-type-system-state-machine.md)
- plan.md updated: state machine uses []TransitionDef with Guard/After hooks
  instead of flat map[Status][]Status
- Added exhaustive linter task to Epic 1
- Added ent DB migration task with old→new integer remapping
- Clean iota renumbering: Creating=0, Active=1, Paused=2, Stopped=3, Hibernated=4

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

* chore(sdd): add interface redesign story to session-hibernation plan

Epic 1 Story 1.3: Instance Interface Redesign
- Predicate methods (IsActive, IsHibernated, IsPaused, etc.)
- Rename setStatus → loadStatus, restrict to deserialization only
- Eliminate transitionTo fallback pattern (setStatus bypass)
- Add context to transitionTo for guard cancellation
- Update InstanceReader interface with typed Status and predicates
- Remove NeedsApproval as lifecycle state (demoted to sub-status)
- Replace RecoverFromStopped with normal Resume via state machine

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

* refactor(session): replace 7-state status enum with 5-state model

Creating=0, Active=1, Paused=2, Stopped=3, Hibernated=4. Deprecated
aliases Running/Ready/Loading kept temporarily for compile compatibility.
Adds _statusSentinel guard against iota reordering.

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

* fix(session): exclude Hibernated sessions from auto-resume and health recovery

Deserialization wires tmux object for hibernated sessions without calling
Start(). Health checker early-bails with IsHealthy=true for hibernated
sessions. Prevents accidental wakeup on server restart.

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

* refactor(session): add typed predicates and clean up status mutation API

Adds IsCreating/IsActive/IsPaused/IsStopped/IsHibernated predicates and
GetLifecycleStatus(). Renames setStatus→loadStatus with strict scope
restriction. Adds context.Context to transitionTo. Removes
RecoverFromStopped() in favor of normal state machine path.
Updates InstanceReader interface with typed predicates.

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

* refactor(session): replace transition map with TransitionDef table (guard+after hooks)

Introduces TransitionDef struct with Guard and After hook fields for
pre/post-transition choreography. Builds O(1) transitionIndex at init
time. Provides CanTransition() for reachability checks. Removes old
allowedTransitions map and NeedsApproval/Running/Ready transitions.
Updates state machine tests to cover 5-state model.

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

* refactor(session): replace Running/Ready/Loading/NeedsApproval with new 5-state constants

Replaces all == Running, == Ready, case Running:, case Ready:,
case NeedsApproval: guards across session/ and server/ with canonical
Active/Creating. Adds || i.IsHibernated() to tmux operation guards.
Removes deprecated alias constants. Updates all tests to use new names.

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

* feat(session): add idempotent DB migration for status integer remapping

Adds runStatusRemap() which uses a sentinel-offset technique (+100) to
safely remap old 7-value status integers to the new 5-state model without
value collisions. Wired into NewEntRepository() before session loading.
Migration is a no-op on empty or already-migrated databases.

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

* fix(session): eliminate all remaining deprecated Running/Ready alias usages

Replaces session.Running and session.Ready references in server/services,
pkg/events, server/analytics, session/workspace, and tests/demo with
session.Active. Fixes ProtoToStatus to use integer literals for legacy
wire values (READY=2, NEEDS_APPROVAL=5, LOADING=3) to avoid duplicate
case errors from allow_alias. Removes unused _statusSentinel constant.
All lint checks pass (make lint: exit 0).

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

* feat(session): make CreateSession return immediately with Creating status

The CreateSession RPC now saves the instance with Creating status and
returns immediately, then spawns a goroutine to call instance.Start().
On success the goroutine sets Active status; on failure it sets Stopped
and records the error in creation_progress.

Also adds the creation_progress = 51 proto field to the Session message
and populates it from Instance.CreationProgress in instance_adapter.go.
Adds ForceStatus() public method for error-recovery paths that cannot
use the normal state-machine transition.

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

* feat(proto): add creation_progress field to Session message

Generated Go and TypeScript bindings from the proto change in the
previous commit. Session.creation_progress (field 51) is now accessible
as Session.CreationProgress in Go and session.creationProgress in
TypeScript.

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

* feat(config): add HibernationConfig with checkpoint dir and idle timeout

* feat(session): add hibernation checkpoint writer package

* feat(ui): show sub-status chip (processing/needs_approval/error) on Active sessions

Adds SubStatusChip component that renders inline on Active session rows and cards,
showing fine-grained activity (Thinking…, Needs Approval, Error, Tests Failing,
Rate Limited). Uses vanilla-extract CSS with theme tokens. Returns null for IDLE
and UNSPECIFIED to keep the UI clean when no noteworthy sub-status is present.

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

* feat(session): wire hibernation/resume side-effects into TransitionDef hooks

* feat(ui): show spinner and progress text for Creating sessions

- SessionCard: render spinner + creationProgress text when status is Creating
- SessionCard: guard terminal snapshot display behind ACTIVE status
- SessionActionsOverflow: disable Pause/Restart actions while Creating
- Fix RUNNING→ACTIVE enum references (same wire value via allow_alias)
- Cast to number to bypass TS duplicate-value narrowing for allow_alias enums

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

* feat(session): add HibernateSession and ResumeHibernatedSession RPCs

* feat(session): add idle hibernation sweeper with configurable timeout

* fix(ui): fix TS errors from allow_alias duplicate enum values and add hibernate status support

- SessionActionsOverflow: use ACTIVE only (not RUNNING) to avoid TS2367 from allow_alias
- SessionActionsOverflow: add onHibernate/onResumeFromHibernation menu items
- SessionCard: map HIBERNATED status to statusPaused (no distinct style yet)
- SessionCard: add "Hibernated" display text for HIBERNATED status

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

* fix(ui): use correct SubStatus.NEEDS_APPROVAL enum value in SessionList filter

SubStatus enum in proto-es uses NEEDS_APPROVAL, not SUB_STATUS_NEEDS_APPROVAL.

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

* feat(ui): add Hibernate/Resume actions and hibernated status badge

Wire hibernateSession/resumeHibernatedSession RPCs into the UI:
- SessionActionsOverflow already had the menu items; now wires through
- SessionRow/SessionCard accept onHibernate/onResumeFromHibernation props
- SessionList threads both callbacks down to row and card views
- PaneSplitRenderer pulls hibernate callbacks from SessionServiceContext
- SessionServiceContext exposes hibernateSession/resumeHibernatedSession
- SessionRow status dot gains 'hibernated' data-status value (idle color)
- SessionCard getStatusText returns "Hibernated" for HIBERNATED status

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

* fix(session): eliminate data race in async CreateSession handler

InstanceToProto was called after goroutine launch, racing with the
goroutine's first write to instance.CreationProgress. Snapshot the
proto before spawning the goroutine so the response is always a clean
Creating-status snapshot.

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

* fix(ui): add Backlog and Features to mobile bottom nav

BottomNav had its own hardcoded lists that didn't reflect NAV_PAGES.
Backlog was missing from primaryItems; Features was missing from
moreItems entirely.

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

* refactor(ui): derive BottomNav lists from NAV_PAGES via bottomNavPrimary flag

BottomNav maintained its own parallel hardcoded item lists that diverged
from NAV_PAGES. Adding or reordering a nav item required updating two
places.

Adds `bottomNavPrimary` to NavPage. Primary bar items are marked in
NAV_PAGES; everything mobile-visible without that flag flows
automatically into the More sheet. Exports BOTTOM_NAV_PRIMARY and
BOTTOM_NAV_MORE for BottomNav to consume.

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

* chore(sdd): add planning artifacts for ai-rule-generation

- requirements.md: 8 FRs across 4 user stories, 4 surfaces
- research/: stack, features, architecture, pitfalls (4 agents)
- implementation/plan.md: 6 epics, 17+ stories, 35 tasks
- implementation/adversarial-review.md: 2 blocking issues resolved
- implementation/validation.md: 35 tests, 8/8 FRs covered
- decisions/ADR-001: RulePromptBuilder + AIClient interface design

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

* feat(ai-rules): add CLIAIClient with multi-backend factory

Extends the AIClient abstraction to support locally installed AI CLI
tools (claude, gemini, opencode) as backends alongside the Anthropic
HTTP API. The executor.ShortLivedCmd primitive provides context
cancellation, timeout, and audit logging for all subprocess calls.

NewBestAvailableAIClient selects automatically by priority:
  1. Anthropic HTTP API (ANTHROPIC_API_KEY)
  2. claude CLI (--print mode, stdin delivery)
  3. gemini CLI
  4. opencode CLI

Adding a new agent CLI requires one CLIAgentSpec entry in
knownCLIAgents — no other changes needed.

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

* fix(ai-rules): prefer CLI agents over Anthropic HTTP API

CLIs manage their own authentication (API keys, login state) so they
require no extra configuration in stapler-squad. Anthropic HTTP API
is now a last-resort fallback for environments with no CLI installed.

New priority: claude CLI → gemini → opencode → Anthropic HTTP API

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

* feat(ai-rules): Epic 2 — useGenerateRule hook + SuggestedRuleCard component

Backend (Epic 1 completion):
- server/services/ai_interfaces.go: RulePromptBuilder, AIClient, RulePromptContext
- server/services/rule_prompt_builder.go: DefaultRulePromptBuilder with secret redaction
- server/services/anthropic_client.go: AnthropicAIClient (HTTP fallback)
- server/services/rules_service.go: GenerateSuggestedRule handler, parseSuggestions,
  attachConflictInfo, buildPromptContext
- server/services/approval_handler.go: redact secrets before analytics recording
- config/config.go: AnthropicAPIKey field + ANTHROPIC_API_KEY env override

Frontend (Epic 2):
- web-app/src/lib/hooks/useGenerateRule.ts: wraps GenerateSuggestedRule RPC,
  manages suggestions[], loading, error, cancel (AbortController), 60s timeout
- web-app/src/components/sessions/SuggestedRuleCard.tsx: shared card with
  confidence badge, conflict/shadow warnings, editable fields, Accept & Discard
- web-app/src/components/sessions/SuggestedRuleCard.css.ts: vanilla-extract styles

Tests: 13 Go unit + 21 frontend (9 hook + 12 card), all passing

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

* feat(ai-rules): Epics 3-6 — all four suggestion surfaces

Epic 3 (Rules page): "Generate Suggestions" button on ApprovalRulesPanel
triggers analytics-gap suggestions; renders SuggestedRuleCard list with
per-card discard and accept→refresh.

Epic 4 (Review queue): "Create Rule" button on pending approval items
opens a modal with SuggestedRuleCard pre-filled from the item's command.

Epic 5 (Analytics panel): "Suggest Rule" buttons replace "Add rule →"
links on coverage-gap rows; inline SuggestedRuleCard expands below the
active row without a modal.

Epic 6 (Command sample): Collapsible "Generate from command" section in
the rule creation form pre-fills fields from COMMAND_SAMPLE suggestion,
respecting user-touched fields via a Set<keyof RuleFormState> ref.

Tests: 37 ApprovalRulesPanel + 17 ReviewQueuePanel + 10 ApprovalAnalyticsPanel
All passing. Zero new TypeScript errors.

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

* fix(ai-rules): address code review findings — security, accessibility, test quality

- Security: reject auto_allow/allow rules with overbroad commandPattern (.*/.+/empty)
- Security: wrap CommandPreview in <command> XML delimiters to prevent prompt injection
- Security: unify redaction sentinel strings into constants (redactedSecret/redactedPrompt)
- Refactor: NewBestAvailableAIClient accepts []CLIAgentSpec param (no global var mutation)
- Refactor: consolidate duplicate decisionString/riskLevelString into single canonical copy
- Refactor: remove unused SeedExamples field from RulePromptContext
- Accessibility: confidence badge adds text label (High/Medium/Low) for WCAG 1.4.1
- Accessibility: aria-live region for generate loading state in ApprovalRulesPanel
- Accessibility: aria-label on filter input in ApprovalAnalyticsPanel
- Accessibility: both modals in ReviewQueuePanel use createPortal to escape transforms
- CSS: replace hardcoded zIndex:1000 with zIndex.modal from theme contract
- CSS: replace inline var(--warning-bg) strings with vanilla-extract vars.*
- React: useEffect cleanup aborts in-flight request on unmount in useGenerateRule
- React: friendly timeout/cancellation error messages distinguish the two cases
- React: void on unhandled generateRule() Promise in ReviewQueuePanel
- React: rulesRef prevents stale closure in SuggestedRuleCard handleAccept
- React: all Suggest Rule buttons disabled while generation is in-flight
- Tests: parseSuggestions tests for markdown-fenced and malformed input
- Tests: fix vacuous >= 0 gap count assertion -> exact count
- Tests: replace time.Sleep with require.Eventually polling
- Tests: data-testid attributes on buttons; getByTestId in tests
- Tests: assert SuggestionSource enum in RPC call arguments
- Registry: add GenerateSuggestedRule entry + update lastModified timestamps

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
State machine redesign + hibernation feature planning:
- requirements.md: 4 epics (state machine, async creation, sub-status visibility, hibernation)
- research/: stack, features, architecture, pitfalls
- implementation/plan.md: 4 epics, 18 stories, 36 tasks
- implementation/validation.md: 78 tests, 100% AC coverage
- decisions/ADR-001: state machine redesign (5-state model)
- decisions/ADR-002: hibernation checkpoint storage

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

- Go type system research added (research/go-type-system-state-machine.md)
- plan.md updated: state machine uses []TransitionDef with Guard/After hooks
  instead of flat map[Status][]Status
- Added exhaustive linter task to Epic 1
- Added ent DB migration task with old→new integer remapping
- Clean iota renumbering: Creating=0, Active=1, Paused=2, Stopped=3, Hibernated=4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Epic 1 Story 1.3: Instance Interface Redesign
- Predicate methods (IsActive, IsHibernated, IsPaused, etc.)
- Rename setStatus → loadStatus, restrict to deserialization only
- Eliminate transitionTo fallback pattern (setStatus bypass)
- Add context to transitionTo for guard cancellation
- Update InstanceReader interface with typed Status and predicates
- Remove NeedsApproval as lifecycle state (demoted to sub-status)
- Replace RecoverFromStopped with normal Resume via state machine

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Creating=0, Active=1, Paused=2, Stopped=3, Hibernated=4. Deprecated
aliases Running/Ready/Loading kept temporarily for compile compatibility.
Adds _statusSentinel guard against iota reordering.

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

Replaces session.Running and session.Ready references in server/services,
pkg/events, server/analytics, session/workspace, and tests/demo with
session.Active. Fixes ProtoToStatus to use integer literals for legacy
wire values (READY=2, NEEDS_APPROVAL=5, LOADING=3) to avoid duplicate
case errors from allow_alias. Removes unused _statusSentinel constant.
All lint checks pass (make lint: exit 0).

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

The CreateSession RPC now saves the instance with Creating status and
returns immediately, then spawns a goroutine to call instance.Start().
On success the goroutine sets Active status; on failure it sets Stopped
and records the error in creation_progress.

Also adds the creation_progress = 51 proto field to the Session message
and populates it from Instance.CreationProgress in instance_adapter.go.
Adds ForceStatus() public method for error-recovery paths that cannot
use the normal state-machine transition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Generated Go and TypeScript bindings from the proto change in the
previous commit. Session.creation_progress (field 51) is now accessible
as Session.CreationProgress in Go and session.creationProgress in
TypeScript.

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

Backend (Epic 1 completion):
- server/services/ai_interfaces.go: RulePromptBuilder, AIClient, RulePromptContext
- server/services/rule_prompt_builder.go: DefaultRulePromptBuilder with secret redaction
- server/services/anthropic_client.go: AnthropicAIClient (HTTP fallback)
- server/services/rules_service.go: GenerateSuggestedRule handler, parseSuggestions,
  attachConflictInfo, buildPromptContext
- server/services/approval_handler.go: redact secrets before analytics recording
- config/config.go: AnthropicAPIKey field + ANTHROPIC_API_KEY env override

Frontend (Epic 2):
- web-app/src/lib/hooks/useGenerateRule.ts: wraps GenerateSuggestedRule RPC,
  manages suggestions[], loading, error, cancel (AbortController), 60s timeout
- web-app/src/components/sessions/SuggestedRuleCard.tsx: shared card with
  confidence badge, conflict/shadow warnings, editable fields, Accept & Discard
- web-app/src/components/sessions/SuggestedRuleCard.css.ts: vanilla-extract styles

Tests: 13 Go unit + 21 frontend (9 hook + 12 card), all passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(analytics): program detail panel with subcommand drill-down

Add DB-backed time-windowed analytics queries and an inline program
detail panel so operators can see exactly which sub-operations are
causing escalations before writing a rule.

Backend (Go):
- Add compound index on (command_program, created_at) to ent schema
- Replace full-table-scan ListAnalytics with time-windowed
  ListAnalyticsSince (WHERE created_at >= ?) — AC-1, AC-2
- Add GetSubcommandBreakdown aggregation query using ent GroupBy — AC-4
- Add ListRecentCommandsByProgram returning last N command previews — AC-5
- Add GetSubcommandTrend returning per-day counts — AC-6
- Add GetProgramAnalytics ConnectRPC method returning SubcommandBreakdown,
  ExampleCommands, RuleCoverage, DailyTrend — AC-7

Frontend (React/TypeScript):
- New ProgramDetailPanel component with subcommand frequency table
  (count, %, decision breakdown), example commands, rule coverage
  summary, trend sparklines, and "Add rule →" links — AC-8 through AC-13
- New useProgramAnalytics hook with AbortController cleanup
- ApprovalAnalyticsPanel: clicking program row opens inline detail panel
- ApprovalRulesPanel: fix panel crush in flex container (flexShrink: 0),
  use window.location.search in useEffect for URL param pre-fill
  (avoids useSearchParams/Suspense issues in Next.js static export)

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

* fix: address copilot review comments on analytics drill-down

- Fix 1: exclude NULL command_subcategory rows in GetSubcommandBreakdown
  to avoid sql.ScanSlice scan errors on nullable GROUP BY columns
- Fix 2: replace strings.Fields tokenizer in coveredSubcommands() with
  regexp.Compile + synthetic "<program> <subcommand>" matching so
  regex-style patterns (e.g. \bgit\b.*\bpush\b) work correctly
- Fix 3: add TestGetProgramAnalytics_ReturnsExpectedFields unit test
  covering window_days=7 and non-nil response fields
- Fix 4: add escapeRegex() helper in ApprovalRulesPanel and use it when
  prefilling commandPattern to avoid metacharacter injection; switch word
  boundaries from \b to (?:^|\s)/(?:\s|$) for hyphenated program names
- Fix 5: add e.stopPropagation() on Suggest Rule button and "add manually"
  link so clicking them does not toggle the parent <tr> drill-down row
- Fix 6: add tabIndex, role=button, aria-expanded, aria-label, and
  onKeyDown (Enter/Space) to the clickable <tr> for keyboard accessibility
- Fix 7: call setData(null) before setIsLoading(false) in error path of
  useProgramAnalytics to clear stale data on refresh failure
- Fix 8: render per-program daily trend sparkline in ProgramDetailPanel;
  note that trend data is per-program not per-subcommand (backend limit)
- Fix 9: thread caller context through LoadProgramWindow,
  GetSubcommandBreakdown, and ListRecentCommands instead of context.Background()

* feat(rules): add criteria_programs/subcommands to custom rules, deprecate regex prefill

Extend custom/runtime approval rules with structured CommandCriteria matching
(Programs + Subcommands) as an alternative to regex command_pattern.

- ent schema: add optional JSON fields criteria_programs and criteria_subcommands
- proto ApprovalRuleProto: fields 15/16 for criteria_programs/subcommands
- session/repository.go + ent_repository.go: thread new fields through domain model and DB upsert
- rules_store.go: RuleSpec gains CriteriaPrograms/CriteriaSubcommands; specsToRules() populates rule.Criteria when programs are set; reload/upsert pass fields through
- rules_service.go: specToProto/ruleToSpec/UpsertApprovalRule thread the new fields; coveredSubcommands() checks criteria-based rules directly without synthetic regex matching
- ApprovalRulesPanel.tsx: RuleFormState gains criteriaPrograms/criteriaSubcommands; form inputs added with hint text; URL param pre-fill now populates criteria fields instead of commandPattern regex; match info column shows criteria chips
- useApprovalRules.ts: passes criteriaPrograms/criteriaSubcommands to proto request

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fuse.js was running synchronously on every keystroke in the omnibar,
causing a 931ms long task during typing. Three changes fix this:

- Add 150ms debouncedInput in Omnibar — sessionSearchQuery,
  displayedSessionResults, and displayedRepoEntries all read from it
  instead of the raw input value, so Fuse only fires after typing pauses
- Add selectActiveSessionsSortedByUpdatedAt (createSelector) to
  sessionsSlice, removing the inline .filter().sort() that ran on every
  omnibar render
- Add browser profiling phase (Phase 5) to perf:make-it-faster command

Update Omnibar.discovery test to assert debounced behaviour.
- Auto-trigger triage on item creation (R1); skip via skipTriage flag
- Vagueness detection: prompt users to refine items with <80 char description and no AC before triage (R2)
- TriageReviewPanel: diff view of current vs suggested AC, task checklist with estimates/categories, apply/skip/undo actions (R4, R5)
- Apply triage suggestions: updates AC list and transitions item to "ready" in one action (R5)
- INPUT_REQUIRED notification with deep-link when triage completes (R6)
- Trigger Triage button for manual re-triage and error recovery
- Fix backlog page infinite reload loop caused by unstable useBacklogService return object; add regression tests (T-STAB-001, T-STAB-002)
- Double-trigger guard prevents duplicate triage sessions
- Triage prompt instructs AI to return structured tasks[] array for implementation plan
Sessions spawned via the MCP create_session tool now always carry the
source:mcp tag so they can be filtered distinctly in the session list.
… sessions

Adds a complete session supervision system so automated sessions (backlog
triage/work/review and MCP-created) run fully unattended:

- Universal driver wiring: SessionDriver now runs for all automated session
  creation paths. MCP create_session gains the driver call; backlog sessions
  already flowed through CreateDirectorySession which was wired earlier.

- Startup dialog handling: driver answers the Claude Code trust-folder safety
  check and directory-access dialogs on every poll tick before the initial
  prompt is sent.

- Inactivity detection: after the initial prompt is sent, if the session stays
  in Ready state with no output change for 10 minutes it is considered stuck
  and restarted (driverTotalTimeout raised to 25 min as a safety net).

- Exit detection: unexpected Stopped status after the initial prompt triggers
  a retry. One-shot sessions (backlog:triage, backlog:review) are skipped.
  Sessions that ran for > 5 minutes are treated as normal completions.

- Auto-retry with JSONL continuation: on first failure the session is restarted
  with a prompt built from its JSONL conversation history (last 10 messages,
  last assistant turn truncated to 500 chars). On second failure the session is
  added to the ReviewQueue (ReasonStale) for human attention.

- Idempotency guard: StartSessionDriver uses atomic.Bool.CompareAndSwap so a
  second call on the same instance is a no-op.

- Panic recovery: defer/recover inside runSessionDriverWithPrompt protects both
  the initial and retry driver goroutines from crashing the server.

Also fixes the omnibar path display: long paths now wrap instead of truncating.
…gger git-status bursts

PerfFix-1: WorkspaceService.findInstance called LoadInstances() on every GetVCSStatus,
GetWorkspaceInfo, GetWorkspace, and ListWorkspaceTargets RPC. LoadInstances()
re-hydrates all sessions from disk via FromInstanceData→Instance.Start, spawning PTY
processes and running `tmux ls` for each session — holding the global fork lock and
causing ~41.7B cycles / 808 mutex events under profiling.

Fix: add LiveInstanceFinder interface satisfied by SessionService, wire it in
NewSessionService, and replace the single findInstance method with findInstanceFast
(tries O(1) live-poller lookup first, falls back to LoadInstances only for new sessions
not yet in the poller). SwitchWorkspace also migrated: SaveInstances accepts a
single-element slice, so the full LoadInstances call for the list was unnecessary.

PerfFix-2: ReviewQueuePoller.AddInstance now calls PrimeDirtyCacheJitter() on each
new session. Sessions added in a burst (e.g. startup) previously had synchronized
15s dirty-cache TTLs, causing all git-status subprocesses to expire and launch
simultaneously. Jitter spreads them uniformly across the window.

PerfFix-3: forbidigo lint rule guards against future per-request LoadInstances calls
with path exclusions for the known-good sites.
HistoryLinker.correlateSession had an early-return guard:
  if inst.HasClaudeSession() { return }
This prevented UUID updates when a user ran /clear in Claude Code,
which closes the old JSONL and opens a new one with a different UUID.
On cold restore the instance would resume the pre-/clear conversation
(or fail with "No conversation found" → silent restart via
recoverFromStaleResume) instead of starting fresh as the user intended.

Fix: add force bool parameter to correlateSession.
- ScanAll() (triggered by fsnotify on JSONL creation and on startup)
  calls correlateSession(inst, true) for ALL instances, including
  already-linked ones, so UUID changes are detected and stored promptly.
- scanAllSessions() (5s polling loop) calls correlateSession(inst, false)
  to preserve the existing performance optimization of skipping
  already-linked sessions on every tick.
- Start() now calls ScanAll() instead of scanAllSessions() for the
  initial startup scan, catching UUID changes that occurred while the
  service was down.

Backoff is bypassed when force=true; it still applies to unlinked
sessions in polling mode.
…ervice

- session/instance_claude.go: add stateMutex.RLock to HasClaudeSession and
  GetClaudeSession; add stateMutex.Lock to SetClaudeSession (data race fix)
- session/history_linker.go: eliminate TOCTOU in ScanAll by holding a single
  write lock for both backoff clear and snapshot copy
- session/history_linker_test.go: rewrite AlreadyLinked test to exercise
  correlateSession(force=false) skip path; add regression test for force=true
  UUID update after /clear (was BLOCKER gap)
- session/git/worktree.go: export isDirtyCacheTTL → IsDirtyCacheTTL
- session/git/worktree_git.go: update references to IsDirtyCacheTTL
- session/git_worktree_manager.go: reference git.IsDirtyCacheTTL constant;
  add PrimeDirtyCacheJitter() to GitManager interface
- server/services/workspace_service.go: correct O(1) → O(N) in LiveInstanceFinder
  godoc; remove blank line breaking GetWorkspace godoc
- server/services/workspace_service_test.go: add stubLiveFinder and tests for
  fast-path hit and live-finder-nil fallback to storage
- server/server.go: document known startup race in HistoryLinker.Start comment
Renames the PTY multiplexer binary and all references from claude-mux to
ssq-mux for naming consistency. Includes code, comments, docs, config,
installer scripts, and .goreleaser.yaml (also adds ssq-hooks binary entry).
Introduces WorkflowEngine interface to replace the inline validTransitions
map in backlog.go, making transition policy explicit and testable.

Adds BacklogStatusEvent as an append-only ent entity that records every
status transition (from, to, triggered_by, created_at) with an index on
(item_id, created_at) for efficient history queries.

Also hardens Storage.AddInstance to distinguish constraint errors from other
persistence failures on upsert.
github-actions Bot and others added 29 commits June 1, 2026 06:37
Fixes CI fmt-check failure. Files were unformatted in main; gofmt -w applied
with no logic changes.

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

Benchmarks: wrap all four baseline push steps in a 3-attempt retry loop
(git pull --rebase --autostash && git push) so rapid consecutive pushes no
longer cause a permanent rejected-push failure.

Demo GIFs: add permissions: contents: write to the publish-demos job so
github-actions[bot] can commit the recorded GIF files to main.

Release Please: add commit-search-body: false to release-please-config.json
so the conventional commit parser does not attempt to parse commit message
bodies for breaking-change footers. Without this, commit bodies containing
Go source code (e.g. make([]T, 0, n)) cause an unexpected-token parse error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…perf (#98)

* fix(verify): resolve sdd:6-verify findings

Go:
- pruneRepoCache: implement LRU trim — after TTL eviction, sort remaining
  entries by accessedAtNs and delete coldest until count ≤ repoCacheMaxEntries.
  Previously the cap was advertised but not enforced (keys slice was dead code).
- openRepoEntry: wrap PlainOpenWithOptions error with path context; re-check
  cache after pruning to eliminate redundant PlainOpen under contention.
- findMergeBase: propagate non-ErrObjectNotFound CommitObject errors instead
  of silently continuing; skip only missing objects from shallow clones.

TypeScript:
- useSessionNotifications: extract approvalId before object literal, removing
  two non-null assertions on optional-chain result.
- useSessionNotifications: move dedup map prune before lastShown read so the
  captured value reflects post-prune state.
- useSessionNotifications.test: type makeEvent with TestNotificationEvent
  interface instead of any.

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

* refactor(vcs): move repo/diff caches onto GoGitVCSReader; release mutex before OS calls

Move repoCache, repoCacheSize, and diffStatCache from package-level globals to
GoGitVCSReader struct fields. GoGitVCSReader{} remains valid — sync.Map and
int64 are zero-value safe. This isolates cache state per reader instance,
preventing cross-test contamination when multiple GoGitVCSReader values exist
in the same process (e.g. concurrent test runs or benchmarks).

pruneRepoCache, openRepoEntry, and openWorktree are now methods on
*GoGitVCSReader; all callers within the file use g. prefix.

HasUncommitted: release the per-repo mutex before the working-tree stat loop
and hasUntrackedFiles directory walk. These phases call only os.Lstat and
os.ReadDir — no go-git access — so holding the packfile-reader mutex was
blocking concurrent VCS operations on the same repo unnecessarily. Index entry
fields are captured as a plain []trackedFile slice before the unlock.

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

* feat(insights): time range filter, session detail drawer, cost projections, perf

R1 — Time range filter: preset buttons (Today/7d/30d/90d/All/Custom) + custom
date picker persisted via URL search params; from/to wired through to
GetInsightsSummary and WatchInsights RPCs.

R2 — Per-session detail drawer: slide-over panel (createPortal) showing
session metadata, tools breakdown, and skill activations; click any session
row to open; Escape/backdrop closes.

R3 — Cost projections: useProjectedCost computes projected monthly spend from
daily buckets (requires ≥7 days of data); useBudgetThreshold persists a
localStorage budget threshold with SSR hydration guard; warning banner + card
styling when projected spend exceeds threshold.

R4 — Skeleton loaders: InsightsDashboardSkeleton shown immediately on mount
before first RPC response; chart dynamic() imports use Skeleton as loading
placeholder; Suspense boundary added for useSearchParams.

R5 — Virtual scrolling: TableVirtuoso (react-virtuoso) for >50 session rows;
scroll position preserved across live updates.

R6 — Smooth live updates: surgical per-session state patch on WatchInsights
events; exponential backoff reconnect (1s→30s cap) replaces silent drop;
chart data transforms wrapped in useMemo to prevent recharts re-renders.

R7 — Session table search/filter: Fuse.js path search + model family dropdown
+ clear button; filter state preserved across live update cycles.

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

* fix(insights): address review comments — date tz, UTC month bucketing, TableVirtuoso cells, test hydration assertion

- InsightsDashboard: serialize date to URL using local timezone (not UTC toISOString)
- TimeRangeFilter: parse URL date params as local T00:00:00/T23:59:59 (not UTC midnight)
- useProjectedCost: compare bucket dates using UTC methods to match server-side UTC midnight keys
- SessionsTable: return cells (not <tr>) from TableVirtuoso itemContent; use components.TableRow for row-level props
- useBudgetThreshold test: assert initial isHydrated is false (not just typeof boolean)

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

* chore: add planning artifacts for insights enhancement

* fix(insights): code quality, test coverage, and UX improvements

Code quality:
- Extract fmtCost/fmtTokens/fmtPct/fmtDate/shortId to insightsFormatters.ts;
  standardize fmtPct to toFixed(1) across all consumers
- Name retry delay magic numbers: INITIAL_RETRY_DELAY_MS / MAX_RETRY_DELAY_MS
- Log WatchInsights stream errors to console instead of silently swallowing

Correctness:
- Filter surgical stream update events against active from/to time range;
  sessions outside the current filter window no longer appear in live patches

Tests (+4):
- useProjectedCost: February non-leap (28 days), leap year (29 days),
  UTC month boundary exclusion
- useBudgetThreshold: invalid localStorage value (non-numeric) returns null

UX:
- ProjectedCostCard: show inline error when budget input is ≤ 0 or NaN
- TimeRangeFilter: show range-error message when from > to (custom range)
- InsightsDashboard: map raw gRPC error codes to user-friendly messages
- SessionDetailDrawer: add aria-describedby + srOnly description for a11y

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

* fix(insights): remove last duplicate fmtCost in ProjectedCostCard, import from insightsFormatters

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
The release-please failure is caused by missing PR creation permission
(GitHub Actions not permitted to create PRs), not commit body parsing.
The parse errors in the logs are debug messages, not failures.

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

React hooks must be called unconditionally. DailySpendChart, ModelBreakdownChart,
and ModelOverTimeChart all called useMemo after an early return on empty data.
Move the memo calls above the early return — they are cheap no-ops on empty
arrays and the behaviour is identical.

SessionsTable: add displayName to the React.forwardRef TableBody component
passed to Virtuoso so the react/display-name lint rule is satisfied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The react/display-name rule cannot infer a display name for React.forwardRef
calls used as values in an object literal inside useMemo. Setting .displayName
after the fact is not visible to the lint rule at that location. Using
eslint-disable-next-line is the correct suppression for this inert pattern —
Virtuoso uses this TableBody ref purely for scroll container sizing, not for
React DevTools identification.

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

Adds BacklogStatusDuplicate to the state machine, a duplicate_of_id link
field (ent schema + proto), atomic optimistic-concurrency guarded writes,
a mark_duplicate MCP tool, list-view exclusion, and distinct badge/link
UI across all 4 status-aware frontend surfaces (all 6 themes).

Closes the structural gap where triage could only archive+free-text-note
a duplicate item, losing the queryable link to its canonical counterpart
(the install-service.sh .zshrc-sourcing bug was independently filed 3x
before this feature existed).

Satisfies all 13 acceptance criteria on backlog item 4f03de7b-3fca-4f3a-
84cb-8c6c5abede50:
1. BacklogStatusDuplicate const (session/backlog.go)
2. validTransitions: idea/refining/ready/in_progress/review -> duplicate,
   duplicate -> idea reopen; done -> duplicate deliberately excluded
3. CanTransitionBacklog unit test coverage for all new/rejected edges
4. duplicate_of_id ent schema field (Optional string, no self-edge)
5. duplicate_of_id proto field on BacklogItem + TransitionBacklogItem-
   StatusRequest
6. TransitionGuard rejects empty/self-referencing/nonexistent-or-already-
   duplicate targets via 3 sentinel errors (ErrDuplicateOfRequired,
   ErrDuplicateOfSelf, ErrDuplicateOfInvalidTarget)
7. Atomic status+duplicate_of_id write via ent .Where(StatusEQ(...)),
   returning ErrPreconditionFailed on a stale write; reopen clears the
   stale duplicate_of_id
8. mark_duplicate MCP tool: session-item authorization check, not-found
   vs infra-error disambiguation for both ids, best-effort note append
9. ListBacklogItems excludes duplicate from default/active results
10. Distinct duplicate badge (own theme tokens, all 6 themes, WCAG AA
    4.5:1+) across BacklogItemBadge, BacklogItemDetail, the backlog
    table+filter chips, and BacklogItemCard's action spec
11. BacklogItemDetail resolves a client-side "Duplicate of: <title>"
    link with loading/resolved/missing states, degrading gracefully
12. Full Go + Jest suites pass, including new tests for every branch
    above
13. Triage guidance updated to point at mark_duplicate; backfilling the
    3 motivating items is deferred pending stapler-squad MCP
    reconnection (disconnected mid-session) - see PR description

Planning artifacts (requirements, 6-dimension research, plan with 2
adversarial repair passes, 2 ADRs, validation/pre-mortem/consistency
docs) and a 3-layer post-implementation code review (idioms,
architecture, correctness/security) are included under
project_plans/duplicate-backlog-status/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyEaDztJJD2uk9k6oW6WuK
@tstapler

Copy link
Copy Markdown
Owner Author

⚠️ Merge-conflict root cause: this worktree's main predates origin/main by 2722 commits — pre-existing environment issue, not caused by this feature

GitHub reports this PR as conflicting with main. I investigated before attempting any rebase, since the conflict footprint is far larger than this PR's actual diff (36 files touched by this feature) — the conflict spans hundreds of unrelated files across the whole codebase (omnibar, terminal, notifications, pane layout, CI workflows, etc.).

Root cause, confirmed via git branch -vv:

main   4eca0ed34 [origin/main: ahead 1429, behind 2722] chore(bench): update frontend throughput baseline [skip ci]

This worktree's local main ref is 1429 commits ahead of and 2722 commits behind the actual origin/main (tstapler/stapler-squad) — a genuine two-way divergence, not simple staleness. The "ahead" side looks like local-only automated chore(bench): ... baseline [skip ci] commits (4 of the most recent 5 commits on local main) that were never pushed/merged upstream. Since this feature branch (and presumably other branches created from this same worktree/workspace around the same time) forked from that stale local main, any PR from this environment will show the same spurious conflict against GitHub's real main until local main is resynced with origin/main.

I did not attempt to resolve this myself — rebasing across ~2700 commits of unrelated drift (with hundreds of add/add conflicts in files this PR never touches) is well outside this feature's scope and carries real risk of corrupting other active work sharing this workspace. This needs a human decision on how to resync (likely: fast-forward/reset local main to origin/main in the shared workspace location, then rebase this branch on top) rather than something I should do unilaterally against a shared branch ref.

This PR's own diff is unaffected by this issuegit diff main (this worktree's local main) cleanly shows exactly the 36 files this feature touches, all tests pass, and the full sdd:6-verify review (idioms/architecture/security) found zero blockers. The conflict is purely a base-branch-sync problem, not a defect in this PR's code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants