feat: triage step (PRD-0008)#267
Conversation
…n (PRD-0008) Freeze the V1↔V2 triage contract (ADR-0051 / IMPL-0024 M1): - migration 024: rebuild finding.exception_reason CHECK to add `unexploitable` (table rebuild with FK off so the workspace child rows aren't cascade-deleted); add a nullable JSON `triage` column to sidebar_state. - ExceptionReason += unexploitable; IssueStage += triaging, triage_verdict, unexploitable (backend models + frontend api/client.ts). - TriageOutput schema (+ nested reachability/exploitability/report/checks) with a coherent verdict↔recommended_close invariant; report_triager registered in AGENT_OUTPUT_SCHEMAS. - SidebarState.triage section + repo_sidebar persistence + _map_triage (disjoint from `evidence`, overwrites on re-run). - issue_derivation: triaging / triage_verdict / unexploitable stages; an untriaged `new` finding never derives to a remediation stage (the Plan gate). - Remove the enricher's new→triaged auto-advance (ADR-0040 §9 amendment); triaged now means "verdict confirmed real." Deviation from IMPL-0024 §3.1: the sidebar store is columnar, so the triage section needs a migration (it claimed none) — done in 024. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…c eval (PRD-0008)
- triage_synthesizer: pure function projecting enricher + exposure output to a
TriageOutput verdict (ADR-0051 §3). Reachability-first so a reachable finding
is never false-cleared even when the enricher abstained; needs_review when
confidence < 0.70 or exploitability unknown (§9).
- triage_runner.run_triage: reuses the agent-execution machinery — scanner runs
enricher → exposure → synthesis, dual-persisting the verdict (a
triage_synthesizer agent_run card + sidebar.triage); report dispatches to
report_triager (wired in M3). Never advances Finding.status.
- POST /api/findings/{id}/triage: ensures a non-status-advancing workspace
(the Plan gate) and runs triage in the background. (Deviation from IMPL-0024
§3.2's /workspaces/{id}/triage path — finding-scoped so it can create a
workspace without flipping status; documented on the endpoint.)
- context_builder.create_workspace gains advance_status (default True); triage
passes False so the finding stays `new` until a real verdict is confirmed.
- Deterministic eval (ADR-0050 Lane 1, $0, keyless): triage_synthesizer.jsonl +
run_triage_synthesis_eval with the asymmetric false-clear HARD gate; a teeth
test proves the gate fails a mislabeled real finding.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nch, live eval (PRD-0008) - report_triager: the first read-only-tools triage producer (ADR-0051 §4/§8). A Pydantic AI agent with ONLY the `read` tool — no edit/bash/gh(push)/mcp, so it can never mutate the repo, close, or reply. Bounded request budget so a loopy model can't run away (a breach → failed run → Retry, never a clear). System prompt enforces: recommend + draft a reply (human sends), and needs_review when the cited code can't be located (never a confident false_positive). - Executor: a read-only-tools path runs report_triager and persists via the shared _finalize_run (map_and_upsert → _map_triage → sidebar.triage). - Normalizer: source=report is force-tagged source_type='report' so triage routes it to the report triager (pure _resolve_source_type helper). - Registry: report_triager chip. - Eval: report_triager.jsonl (live, key-gated) + run_report_triager_eval with the tool_trace (read-only), false-clear, and abstention HARD gates; keyless tool-surface + source-tagging tests guard the trust boundary in CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- IssueStageChip: triaging (cyan pulse), triage_verdict ("Review verdict",
awaiting tone), unexploitable (green, shield icon — distinct from
false_positive's report icon so verdict = colour + icon + label). Fixed 3
stale Cyberdeck-migration tests in this file (cd-loader, 9.5px sm).
- IssueSidePanel: a `triage` section (SPTriage) — verdict banner (colour +
icon + label + confidence as word + %), progressive-disclosure ProofChecks
(<details>), reachability graph incl. the calm "No path found" state,
report claim-vs-code side-by-side, and an editable drafted reply that is
never auto-sent. Verdict-dependent footer (real → Open workspace to
remediate; needs_review → remediate/close; close → two-way ClosePicker with
radio semantics, pre-selecting recommended_close). Triage closes reopen to
`new` (untriaged). New sections use cd-* tokens (per the panel gotcha).
- api/client.ts already carries the contract types (M1).
- 8 new panel tests: all four verdict banners, the no-path state, the real
footer, the two-way picker, the unexploitable close, report claim-compare +
editable-not-auto-sent reply, and Reopen on a triage close.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- IssueRow: run_triage action (untriaged Todo, outline) + review_verdict
(triage_verdict → opens the panel); the Todo action is gated by status —
`new` shows Run triage, `triaged` shows Start (the Plan gate at row level).
- IssuesPage: handleActivate branches untriaged (Todo + `new`) → run triage
(POST /findings/{id}/triage, no push guard), else → remediation Start. The
panel gets onRunTriage (untriaged) + onStart (remediate); the panel's todo
footer is now status-aware too.
- api/client: runTriage(findingId).
- ImportDialog: source=report accepts free-text report prose (wrapped as a
single ingest item) for the report-triage path.
- DashboardPage: a triage payoff line ("Cliff cleared N without a fix — …·M
real"), reading the same findings query the Issues page uses.
- Test fixture: status option + triage stages in the stage→section map.
- Fixed the stale Cyberdeck-migration tests in the triage-touched files
(IssueRow focus-ring, IssuesPage severity dropdown) and updated the
Start→Run-triage assertions for the new gate. Remaining 16 frontend failures
are the pre-existing baseline in untouched files (IssueDeltaChip, IssuesHeader,
IssueFilterChip, onboardingFlow, IntegrationSettings) — documented in the PR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tokens (PRD-0008)
Use the canonical --cd-{green,red,amber}-soft tint tokens (and the bare
--cd-{green,red,amber} accents) for the verdict banner + proof-check rows
instead of invented --cd-*-dim fallbacks. Confirms the M6 conformance sweep:
verdict = colour + icon + label, aria-live stage chips, radio semantics on the
close picker, text-labelled reachability nodes, sentence-case labels, no glow /
▸ / 2px arbitrary borders.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ew follow-up) Self-review found the executor's report_triager dispatch relied only on the live eval (real LLM). Add a keyless TestModel test of run_report_triager so the read-only agent's run + tool registration + TriageOutput validation are covered in CI without a key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds triage contracts, a read-only report triager agent, a deterministic scanner triage synthesizer, persistence and migration for sidebar.triage, a POST /findings/{id}/triage API, eval runners/datasets, and frontend UI/actions to run and review triage verdicts. ChangesTriage Verdict Synthesis and User Confirmation Flow
Estimated code review effort 🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Actionable comments posted: 9
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
backend/cliff/agents/sidebar_mapper.py (1)
55-63:⚠️ Potential issue | 🟠 Major | ⚡ Quick winMake triage writes replace, not deep-merge, to avoid stale verdict payloads.
map_and_upsertdeep-merges dicts for every sidebar field, so a re-triage keeps old keys that are missing in the new output. That breaks the idempotent overwrite behavior documented for triage and can persist stale contract data.Proposed fix
@@ for field_name in SidebarStateUpdate.model_fields: new_val = getattr(partial, field_name) if new_val is not None: # For dict fields, deep-merge existing and new existing_val = getattr(existing, field_name, None) if existing else None - if isinstance(existing_val, dict) and isinstance(new_val, dict): + if ( + field_name != "triage" + and isinstance(existing_val, dict) + and isinstance(new_val, dict) + ): merged = {**existing_val, **new_val} merged_data[field_name] = merged else: merged_data[field_name] = new_valAlso applies to: 175-183
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/cliff/agents/sidebar_mapper.py` around lines 55 - 63, The current logic in map_and_upsert (the loop using SidebarStateUpdate.model_fields that builds merged_data from partial and existing) deep-merges dict fields which preserves stale keys; change this to always replace dict values from partial (i.e., if new_val is not None assign new_val directly to merged_data[field_name]) instead of merging with existing_val so triage writes are idempotent; apply the same replacement change to the second identical block around the other occurrence referenced (lines ~175-183) to ensure both update paths use replace-not-merge behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/cliff/agents/runtime/triage_synthesizer.py`:
- Around line 134-136: The field verdict is declared too loosely as str (and a
type: ignore[arg-type] was added later) — tighten it to a Literal union of the
exact allowed string values so static typing and model validation are enforced;
import typing.Literal and replace verdict: str with verdict: Literal[...allowed
values...], ensure all places that assign to verdict (and any tests) use one of
those literal values, then remove the type: ignore[arg-type] suppression; also
check nearby fields exploitability and confidence for consistent typing and
update any other occurrences referenced in this file that rely on the loose
verdict type.
In `@backend/cliff/api/routes/agent_execution.py`:
- Around line 475-484: The reused workspace may have a missing or invalid
workspace_dir; before scheduling triage, verify workspace.workspace_dir is
present and points to an existing directory (e.g., os.path.exists or
pathlib.Path.exists). If the check fails, call
context_builder.create_workspace(db, finding, repo_url=repo_url,
advance_status=False) to create a fresh workspace and use that one instead of
the stale row; keep using list_workspaces and the same repo_url resolution logic
and ensure you log the replacement and only proceed to schedule the background
triage with a valid workspace_dir.
In `@backend/cliff/evals/cases.py`:
- Around line 50-51: Change the Expected.verdict annotation from a loose "str |
None" to a Literal union of the exact triage verdict strings used by the triage
pipeline (the Expected class's verdict field), e.g.
Literal['accept','reject','flag','needs_more_info'] (replace with your actual
labels); import Literal from typing (or typing_extensions if required) and keep
the None option if null is allowed (Literal[...] | None). This makes the
pydantic-backed Expected model validate golden verdicts strictly and fail fast
on bad JSONL rows.
In `@backend/cliff/evals/runners.py`:
- Around line 317-320: The loop that writes staged files uses the untrusted
variable rel directly, allowing absolute paths or “..” to escape the temp
workspace; update the staging logic (the loop handling case.files with variables
rel, text, tmp and the write to target.write_text) to reject or sanitize unsafe
paths: for each rel, compute target = (Path(tmp) / rel).resolve() and compare it
to the resolved workspace root (Path(tmp).resolve()), and if target is not
inside that root (i.e., target.resolve() does not start with the workspace root
+ path separator) then skip or raise; only then create parent dirs and
write_text. Ensure you handle absolute rels and “..” segments this way so files
cannot be written outside the temp workspace.
In `@backend/cliff/models/issue_derivation.py`:
- Around line 231-236: The current logic returns a failed triage verdict before
checking for in-flight triage, hiding active runs; modify the new-status branch
to prioritize running triage by either moving the
_triage_running(latest_runs_by_type) check above the _triage_failed(...) check
or by gating the failed branch with not _triage_running(latest_runs_by_type).
Update the code paths that call _triage_failed(latest_runs_by_type) and
_has_triage_verdict(sidebar) (and the out(...) return) so that active triage
detected by _triage_running(...) will return out("in_progress", "triaging")
instead of being overshadowed by a stale failure.
In `@backend/tests/test_routes_agent_execution.py`:
- Around line 902-903: Replace the flaky time-based wait with deterministic
synchronization: remove await asyncio.sleep(0.05) and instead create an
asyncio.Event (or similar synchronization primitive), arrange for mock_run to
set that event when it is awaited (e.g., via mock_run.side_effect / async
side_effect that calls event.set()), then await event.wait() before calling
mock_run.assert_awaited_once(); this ensures the test waits precisely for
mock_run to be invoked rather than relying on a fixed sleep.
In `@frontend/src/components/issues/IssueSidePanel.tsx`:
- Around line 2096-2097: The button label in the PrimaryButton JSX
(icon="check", onClick={submit}, disabled={!reason || reject.isPending}) uses
title case and an ampersand; change the text from "Confirm & move to Done" to
sentence case "Confirm and move to done" to comply with the UI sentence-case
rule for labels in components under frontend/src; update that string only so
reject.isPending still toggles to 'Closing…' and no other behavior changes.
- Around line 2069-2093: IssueSidePanel exposes TRIAGE_CLOSE_OPTIONS as a
radiogroup but lacks keyboard semantics; implement roving tabindex and arrow-key
navigation so arrow keys move selection and managed focus updates aria-checked
and calls setPicked. Add a ref array for the option buttons in the
IssueSidePanel component, set tabIndex=0 only for the currently selected option
(others -1), and handle onKeyDown (Left/Up = prev, Right/Down = next, Home =
first, End = last) to call setPicked(newValue) and focus the corresponding ref;
ensure click still works and aria-checked reflects reason.
In `@frontend/src/pages/IssuesPage.tsx`:
- Around line 340-355: The handler currently only prevents remediation for stage
=== 'todo' && status === 'new', so findings with derived.stage ===
'triage_verdict' fall through to remediateFinding and may start pipelines
prematurely; update handleActivate (the const handleActivate useCallback) to
additionally detect when finding.derived?.stage === 'triage_verdict' and avoid
calling remediateFinding — either call triageFinding or return early (depending
on desired UX) so triage_verdict rows are not sent to
remediateFinding/runAllPipeline automatically.
---
Outside diff comments:
In `@backend/cliff/agents/sidebar_mapper.py`:
- Around line 55-63: The current logic in map_and_upsert (the loop using
SidebarStateUpdate.model_fields that builds merged_data from partial and
existing) deep-merges dict fields which preserves stale keys; change this to
always replace dict values from partial (i.e., if new_val is not None assign
new_val directly to merged_data[field_name]) instead of merging with
existing_val so triage writes are idempotent; apply the same replacement change
to the second identical block around the other occurrence referenced (lines
~175-183) to ensure both update paths use replace-not-merge behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 5f6484ac-0b19-46f4-828e-5a1beb88d177
📒 Files selected for processing (48)
backend/cliff/agents/executor.pybackend/cliff/agents/registry.pybackend/cliff/agents/runtime/report_triager.pybackend/cliff/agents/runtime/triage_synthesizer.pybackend/cliff/agents/schemas.pybackend/cliff/agents/sidebar_mapper.pybackend/cliff/agents/triage_runner.pybackend/cliff/api/routes/agent_execution.pybackend/cliff/db/migrations/024_triage_contract.sqlbackend/cliff/db/repo_sidebar.pybackend/cliff/evals/__init__.pybackend/cliff/evals/cases.pybackend/cliff/evals/runners.pybackend/cliff/integrations/normalizer.pybackend/cliff/models/__init__.pybackend/cliff/models/finding.pybackend/cliff/models/issue_derivation.pybackend/cliff/workspace/context_builder.pybackend/tests/agents/conftest.pybackend/tests/agents/eval/README.mdbackend/tests/agents/eval/report_triager.jsonlbackend/tests/agents/eval/triage_synthesizer.jsonlbackend/tests/agents/test_evals_report_triager.pybackend/tests/agents/test_evals_triage_synthesizer.pybackend/tests/agents/test_report_triager.pybackend/tests/agents/test_triage_runner.pybackend/tests/agents/test_triage_synthesizer.pybackend/tests/api/openapi_snapshot.jsonbackend/tests/db/test_repo_sidebar_triage.pybackend/tests/test_issue_derivation.pybackend/tests/test_migration_024.pybackend/tests/test_routes_agent_execution.pybackend/tests/test_routes_findings_reject.pybackend/tests/test_sidebar_mapper.pybackend/tests/test_status_advance.pybackend/tests/test_triage_schema.pyfrontend/src/api/client.tsfrontend/src/components/ImportDialog.tsxfrontend/src/components/issues/IssueRow.tsxfrontend/src/components/issues/IssueSidePanel.tsxfrontend/src/components/issues/IssueStageChip.tsxfrontend/src/components/issues/__tests__/IssueRow.test.tsxfrontend/src/components/issues/__tests__/IssueSidePanel.test.tsxfrontend/src/components/issues/__tests__/IssueStageChip.test.tsxfrontend/src/pages/DashboardPage.tsxfrontend/src/pages/IssuesPage.tsxfrontend/src/pages/__tests__/IssuesPage.test.tsxfrontend/src/test/fixtures/finding.ts
…, a11y, dedup
Backend:
- triage_synthesizer: type `verdict` as TriageVerdict + drop the forbidden
`# type: ignore[arg-type]` (the literal now type-checks).
- evals/cases: constrain `Expected.verdict` to the verdict literal so a
malformed golden row fails in load_cases (noqa TC001 — Pydantic needs the
import at runtime, the finding.py convention).
- POST /findings/{id}/triage: guard a reused workspace's missing
workspace_dir (400 now instead of a background failure).
- evals/runners: confine staged `case.files` to the temp workspace
(reject absolute / `..` paths).
- issue_derivation: an in-flight re-triage now beats a stale failure in the
`new` branch (running checked before failed) + a regression test.
- test: replace the fixed sleep in the triage endpoint test with a
deterministic poll.
Frontend:
- IssuesPage.handleActivate: a `triage_verdict` finding opens the panel for
review instead of falling through to remediation (defense-in-depth gate).
- IssueSidePanel: full WAI-ARIA radio semantics on the close picker (roving
tabindex + arrow-key nav + focus follows); sentence-case "Confirm & close";
extract the shared reject-with-reason mutation into useRejectWithReason so
RejectFooter + TriageClosePicker share one implementation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks for the review — all 10 findings (9 CodeRabbit + 1 Baz) addressed in 63855a6. Mapping: Backend
Frontend
Gates after the fixes: backend |
…utside-diff)
CodeRabbit flagged the map_and_upsert merge in the context of triage: a re-run
should fully replace the single-producer `triage` section, not shallow-merge,
so a stale key from a prior run (e.g. a `report` block from an earlier report
triage) can't survive a later scanner re-triage. Add a `_REPLACE_SECTIONS` set
({"triage"}) — multi-producer sections (evidence) still deep-merge. Covered by
a mapper test asserting wholesale replacement + sibling preservation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…audit) QA screenshot pass + a Cyberdeck/UX-0008 conformance audit of the live triage UI surfaced two real gaps: - The report verdict's `ai_slop_signals` (and the duplicate / PoC-present flags) were never rendered — SPTriage showed claim-vs-code + the drafted reply but dropped the slop signals UX-0008 Story 5 requires. Add a ReportSignals block (amber-flagged signal list + PoC/duplicate line), verified live + covered by the report panel test. - `.cd-chip` had no `:focus-visible` style, so the two-way close picker's radiogroup fell back to the browser default outline instead of the Cyberdeck green ring. Add `.cd-chip:focus-visible` (matches `.cd-btn`). (The local QA staging helper is not committed.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Manual QA + UI audit (closes DoD #4)Booted the full stack (backend :8000 + Vite :5173) against a seeded demo DB covering every triage state, and walked the UI in Chrome. All states render correctly in Cyberdeck dark mode. Verified states (screenshots reviewed):
Audit findings (both fixed in 8ea4168):
Gates after the fixes: frontend Note: the screenshots were captured + reviewed interactively; the capture tool surfaces them inline rather than to a committable path, so they aren't attached as files here. |
Two small craft fixes from a critique + audit pass over the triage UI:
- SPTriage empty state now teaches the next action ("Run triage to see
Cliff's read on this finding.") instead of the bare "No triage verdict
yet." — empty states should point at the action, and the footer's
"Run triage" button backs it up.
- Add a Cyberdeck green :focus-visible ring to native <summary> elements
so keyboard focus on the triage proof / reachability / claim-vs-code
disclosures reads consistently with .cd-btn / .cd-chip instead of the
browser default ("practice what you preach" accessibility).
No behavior change; triage component + cyberdeck CSS tests stay green
(the 16 pre-existing Cyberdeck-migration failures in onboarding/settings/
header tests are unrelated and out of scope, confirmed unchanged by these
edits).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User description
Summary
Implements the Triage step (PRD-0008) end to end — a user-initiated, human-confirmed verdict (
real/unexploitable/false_positive/needs_review) in front of the remediation flow, for both scanner findings and inbound vulnerability reports. Built to ADR-0051 + IMPL-0024, on the existing Issues page / side panel (no new page, noStageRail).Docs: PRD-0008 · UX-0008 · ADR-0051 (amends ADR-0040 §9, closes ADR-0050 Q#1) · IMPL-0024 (all in the private
cliff-osumbrella).What landed (by milestone)
024_triage_contract.sql(rebuildfinding.exception_reasonCHECK to addunexploitable, FK-off so childworkspacerows aren't cascade-deleted; addsidebar_state.triageJSON column).ExceptionReason+=unexploitable;IssueStage+=triaging/triage_verdict/unexploitable(backend +api/client.ts).TriageOutputschema with a coherentverdict↔recommended_closeinvariant.SidebarState.triage+_map_triage(disjoint fromevidence, overwrites on re-run).issue_derivationtriage stages (an untriagednewfinding never derives to a remediation stage — the Plan gate). Removed the enricher'snew→triagedauto-advance (ADR-0040 §9 amendment) —triagednow means "verdict confirmed real."triage_synthesizer(pure, deterministic, $0) projecting enricher + exposure output → verdict (ADR-0051 §3 matrix + theneeds_review<0.70/unknown threshold), reachability-first so a reachable finding is never false-cleared.triage_runner.run_triagereuses the agent-execution machinery (enricher→exposure→synthesis), dual-persisting the verdict (atriage_synthesizeragent_run card +sidebar.triage) and never advancingFinding.status.POST /api/findings/{id}/triage. Deterministic eval (triage_synthesizer.jsonl) with the asymmetric false-clear HARD gate.report_triagerPA agent — the first read-only-tools triage producer (only thereadtool; no edit/bash/gh/mcp; bounded request budget; never auto-rejects). Executor read-only-tools path. Normalizersource=reportbranch (_resolve_source_type). Registry chip. Live eval (report_triager.jsonl, key-gated) with tool_trace + false-clear + abstention HARD gates; keyless trust-boundary tests in CI.IssueSidePaneltriage section: verdict banner (colour+icon+label + confidence as word + %), progressive-disclosureProofChecks(<details>), reachability graph incl. the calm "No path found" state, report claim-vs-code side-by-side, editable drafted reply never auto-sent. Verdict-dependent footer + two-wayClosePicker(radio semantics, pre-selectsrecommended_close).IssueStageChiptriage stages (unexploitable=shield, distinct fromfalse_positive=report).IssueRowrun_triage(untriaged) /review_verdict(opens panel) actions, status-gated Todo action.IssuesPageroutes untriaged→triage / triaged→remediate.ImportDialogaccepts report prose forsource=report. Dashboard triage payoff line. Reopen-with-evidence sends triage closes back to untriagednew.--cd-*-softtints), aria-live chips, radio semantics, text-labelled reachability nodes, no glow/▸/2px borders.Deviations from the plan (called out per the build rules)
sidebar_state.triageneeds a migration. IMPL-0024 §3.1 claimed the sidebar triage section needed none ("per-workspace JSON context"). The livesidebar_statestore is columnar (one JSON column per section, e.g.pull_requestfrom migration 007), so a column add is required — done in024_triage_contract.sql.POST /api/findings/{id}/triage), not IMPL-0024 §3.2's/workspaces/{id}/triage. Triage must create a non-status-advancing workspace (the finding staysnewuntil arealverdict is confirmed — the gate). A workspace-scoped path would require the workspace to pre-exist, and creating it via the standard remediation path flips the finding toin_progress, defeating the gate.context_builder.create_workspacegained anadvance_statusflag (defaultTrue) for this.Test plan
cd backend && uv run pytest -m 'not e2e'(keyless / CI-representative): 1475 passed, 34 skipped.cd backend && uv run ruff check cliff/ tests/: clean.cd frontend && npx tsc --noEmit: clean;npm run build: clean.cd frontend && npm test: all triage-touched files green (IssueStageChip, IssueRow, IssuesPage, IssueSidePanel). 483 passed. See "Pre-existing baseline" below for the 16 remaining failures in untouched files.Coverage written TDD-first: the full synthesis mapping matrix + abstention threshold; migration row-preservation + no-cascade-delete +
unexploitableaccepted /bogusrejected +sidebar_state.triage; the derivation gate ("Plan unreachable without arealverdict"); the removed enricher auto-advance;TriageOutputround-trip + pairing;_map_triagedisjoint-from-evidence + repo round-trip; the triage run path (dual-persist + idempotent re-run + no status advance); the report triager read-only tool surface + report-source tagging + a keyless TestModel agent run; panel renders all four verdicts + the "No path found" state + claim-compare + editable-not-auto-sent reply; row/chip/footer triage actions.QA evidence
report_triagereval ran the agent over the report dataset — it read the cited code with the read tool and returnedreal(SQLi with PoC),false_positive(claim contradicted by a safe parser), andneeds_review(cites nonexistent code). All HARD gates held (tool_trace read-only, no false-clear, abstention). passed in ~38s.triage_synthesizereval verified the full ADR-0051 §3 matrix + the asymmetric false-clear gate (a teeth test confirms the gate fails a mislabeled real finding). $0, keyless.GET /api/agents/chipsreturns thereport_triagerchip and/api/findings/{finding_id}/triageis in the live OpenAPI.Known QA gap (transparent): interactive browser screenshots of the Issues page / side panel (each verdict) / dashboard line were not captured in this autonomous run — the QA here is automated + eval-based + boot-level. Happy to capture the before/after screenshots on request.
Self-review
/security-review: the skill's auto-diff ran againstmain(the worktree/cwd mismatch produced an empty diff), so I conducted the review manually againstorigin/main...HEAD. The one new attack surface is thereport_triagertool boundary — correctly read-only and workspace-confined (the existingescapes_workspaceguard refuses a report's cited path that escapes the workspace), never-auto-reject, bounded budget. No injection (parameterized SQL, static migration), no HTML sinks (React-escaped, nodangerouslySetInnerHTML), no traversal/exec. No high/medium findings.run_report_triagerTestModel test (the executor's report path had only relied on the live eval).Pre-existing baseline (not introduced here)
origin/mainships 16 failing frontend tests from a completed Cyberdeck migration (stale tests asserting the deprecated Material-3 design —IssueDeltaChip×8,IssuesHeader×4,IssueFilterChip×2,onboardingFlow×1,IntegrationSettings×1). Per maintainer guidance these untouched-file failures are left as the documented baseline; the 5 stale tests in triage-touched files (IssueStageChip ×3, IssueRow ×1, IssuesPage ×1) were refreshed to current Cyberdeck output as part of this PR.AI disclosure
This change was implemented by Claude Code (Opus 4.8) under maintainer direction, TDD-first.
🤖 Generated with Claude Code
Generated description
Below is a concise technical summary of the changes proposed in this PR:
Deliver the triage step by chaining
triage_runner(enricher + exposure + deterministictriage_synthesizeror the new read-onlyreport_triager) into the agent executor, persisting every verdict intosidebar.triage, and gating finding status changes/migrations so only a human-confirmedrealverdict advances remediation. Update the API surface (POST /api/findings/{id}/triage), schema/enums, and frontend (Issues list, side panel, dashboard, import flow) so the UI reflects the triage stages, closes, and payoff messaging tied to the new contract.finding_enricher+exposure_analyzerthroughtriage_runner, persisting deterministictriage_synthesizeroutput plus the read-onlyreport_triagerintosidebar.triage, enforcing the newunexploitable/triageschema + migration constraints, and guarding the triage endpoint/workspace creation so the finding staysnewuntil a human confirmsrealbefore remediation.Modified files (26)
Latest Contributors(1)
Modified files (1)
Latest Contributors(1)
Modified files (8)
Latest Contributors(1)
IssueRow, showing the triage section (verdict banner, proof checks, reachability, claim/model evidence, drafted reply), introducing the two-way close picker, reusing triage-focusedIssueStageChipvisuals, and adding dashboard payoff messaging plus keyboard-friendly focus styles so the triage gate and closes read clearly everywhere.Modified files (14)
Latest Contributors(1)