release: v0.2.0 - deterministic retrieval, tracing, live GitHub intake - #4
Merged
cheneeheng merged 30 commits intoAug 12, 2026
Merged
Conversation
The plan document for the v1 patch iteration that was already implemented and released as v0.1.2 — the checkpoint allowlist, the SDK credential fix, and the first-review race. It was written and applied but never committed alongside the code it describes, which left the planning family missing its last artifact and the v2 files pointing at a document that was not in the repo. Generated with [Claude Code](https://claude.com/claude-code) by CEH
…y lookup Implements ITER_01_v2, and adds the four v2 plan documents. `data/policies/index.yaml` maps each change_type to the policy files that govern it, plus whole-token case-folded keyword overrides against the classifier's touched_areas. Retrieval reads those files and splits them on their `##` headings. No embedding model, no vector store, no ingest step. RAG earns its place when, after metadata narrowing, the candidate set still exceeds what fits in context, or when disambiguation inside a category is genuinely semantic rather than categorical. Neither holds at a three-document corpus keyed by a closed enum, so this was unpaid-for machinery. The threshold for revisiting it is documented in the plan rather than left implicit, and it is about policy text per category — not policy count. Two things improve as a side effect. Retrieval is now deterministic, so eval_routes is a real regression test rather than a sample. And specialists get the *complete* set of headings for the relevant files instead of a top-6 slice, so a policy can no longer be missed for ranking seventh — which matters because index.yaml routes config and data_handling changes to data-handling.md, whose DATA-* chunks the security specialist owns. Validation runs in the FastAPI lifespan, not on first request: an unmapped change_type, a missing file, or a heading without a parseable policy id fails the *process*. change_type is a closed enum the classifier's structured output already validates, so a missing key is a config error, not a condition to survive mid-review. A runtime "unknown change_type -> empty list + WARN" branch was the obvious alternative and is worse: unreachable in a correct install, and unreachable branches are what this codebase deletes rather than pragmas. Removing PolicyChunk.distance is the one narrowing in the v2 family, and it makes pre-v2 checkpoints undeserializable on resume. There is deliberately no migration — this is local single-user demo data — so GATE_STATE_VERSION is stamped into checkpoints.db and checked at startup, and the fix is deleting the file. That is a decision a reviewer should see stated, not discover as a crash. `intake/store.py` goes with `intake/ingest.py`: the plan describes it as the thread registry, but that is `app.state.reviews` — store.py was the Chroma accessor and has no purpose left. See DECISION_LOG entry 17. Testing: 132 pass at 100% statement and branch coverage; ruff and mypy --strict clean. tests/unit/test_retrieval.py covers each change_type, docs resolving to an empty set, the keyword union, whole-token matching (`auth` hits "auth module", misses "author metadata"), dedup/order stability, and every startup-validation failure. Generated with [Claude Code](https://claude.com/claude-code) by CEH
Implements ITER_02_v2. Spans are written in application code with real semantic conventions and exported over OTLP/HTTP; Langfuse is addressed purely as an endpoint, so pointing the same exporter at Jaeger or Tempo changes nothing. Standing up a collector plus trace store plus query UI was considered and rejected: three moving parts for a project whose constraint is a single-command local run, and raw OTel has no LLM semantics — tokens, cost, prompt capture — which is exactly the schema work Langfuse already did. This is the point of the iteration: the README *claims* low-risk PRs never touch Sonnet. A trace filtered on gate.risk.level=low with zero Sonnet spans *proves* it. The run span closes with gate.route.taken, gate.loop_count.final, gate.cost.investigator_usd and gate.specialists.timed_out for the same reason. Three traps are designed for here rather than debugged later: - The graph run outlives its HTTP request. POST returns as soon as the background task is spawned, so parenting node spans under the request span would attach a multi-minute run to a span that ended in milliseconds — and under a BatchSpanProcessor that parent may already be exported. gate.review.run is a *root* span opened inside the background task, carrying a link to the request span whose context was captured at handler time. - OTel context lives in contextvars that do not follow into LangGraph's parallel branches, so a naive @traced_node makes both specialist spans trace roots. The run span's context is captured where the run span opens and passed to each node explicitly. Capturing it inside `intake` is the tempting shortcut and is wrong: intake is itself a child, so every later node would parent to it. - BatchSpanProcessor buffers, so an unflushed exit drops the tail of every trace — which reads in the UI as runs that end mid-graph. The lifespan drains its tracked background runs *before* provider.shutdown(); reversed, an in-flight run writes to a dead provider and loses its tail anyway. The kill switch holds the provider in this module rather than installing it globally. `trace.set_tracer_provider` is guarded by a process-wide Once, so the second call — GATE_OTEL_ENABLED=false after any enabled run, or a second app instance — is refused with a log warning and no effect. A test caught that; see DECISION_LOG entry 19. The app must run with the Langfuse container down, because a reviewer cloning the repo should not need Docker to see the gate work. LLM spans carry gate.llm.effort and gate.llm.structured_output_method because that pairing is what silently broke the specialists in v1 — recorded, the next occurrence is a filter query rather than a debugging session. Thinking tokens get their own attribute instead of being folded into gen_ai.usage.output_tokens, which stays exactly as the API reports it; burying them would make the two unreconcilable against a bill. Testing: 152 pass at 100% statement and branch coverage; ruff and mypy --strict clean. tests/unit/test_tracing.py asserts against an in-memory exporter that the two parallel specialist spans share the run span as parent, that the run span is a root carrying a link rather than a child, and that a disabled-tracing run emits zero spans and still reaches a verdict. conftest now disables tracing by default — a live exporter spent its full retry budget on every lifespan teardown, which cost a minute of wall clock per suite run. Not verified against a real Langfuse. Generated with [Claude Code](https://claude.com/claude-code) by CEH
Implements ITER_03_v2. `POST /api/reviews` now takes `pr_url` as an alternative to `pr_id`, with exactly one required. A `PRSource` protocol sits behind it, and the protocol having *two* methods is the load-bearing decision: they split exactly where the request/background boundary already falls. `resolve` is cheap, runs in the handler, and raises the HTTP-mapped errors — a typo'd URL is a 422, a missing repo a 404, an under-scoped token a 403, all *before* a review thread exists. `load` is slow, runs in the background task, and fails by returning an EscalationRecord, because by then a thread exists to carry the failure and nobody is waiting on the response. A single `load()` cannot express that: it forces either the whole live path into the handler (a POST that blocks on a clone, which the polling UI exists to avoid) or every failure into an escalation (a typo'd URL creating a review row). The protocol is also the seam that absorbs the one genuine unknown here — the GitHub MCP server's exact tool names and response shapes. If they differ, one class changes. MCP supplies the structured surface: PR metadata, the paginated changed-file list, the diff. The repo *tree* is a shallow clone at head_sha, because pulling hundreds of files one at a time through tool calls to satisfy read_file/grep_repo would be absurd. The files endpoint pages at 30 by default, and a single unpaginated call silently reviews a subset of a large PR — a compliance gate that misses files is worse than one that refuses, so pagination runs to a short page and GATE_GH_MAX_FILES escalates rather than truncating. The clone directory is named by head_sha and nothing else. Branch names legitimately contain `/` and can contain `..`; using one as a path component is a traversal bug waiting to happen, and a 40-char hex SHA validated by regex cannot escape. That directory becomes the investigator's sandbox root — injected by the adapter, never derived from pr_id. This severs the coupling the v1 pr_id check was defending against, so that check moves onto the fixture adapter where it belongs; a live pr_id legitimately contains `/` and `#`. One graph edge is added, and it is worth being explicit that it *is* a topology change. Both new escalation reasons are raised before the graph has a diff, and reconcile's rule 1 only fires after classify, risk and the specialists have run. Routing a diff-less review that far means asking Haiku to classify nothing and Sonnet to review nothing — spend and noise on a review whose outcome is already decided. So intake short-circuits to the human gate. The graph still *runs*: a failed load is seeded into the initial state and invoked normally, which keeps the failed review checkpointed, resumable, traced under the same run span, and reachable through the same /pending and /decision endpoints. A second, non-graph failure path would have cost a parallel implementation of the review lifecycle. The event-loop preflight moves to gate.runtime and is now shared: the Agent SDK, the clone, and (next iteration) semgrep all fail identically on a SelectorEventLoop with a bare NotImplementedError and an empty message. Token scope is stated and deliberate: read-only, contents:read + pull_requests:read. No write scopes — the gate never comments, labels or merges. Testing: 195 pass at 100% statement and branch coverage; ruff and mypy --strict clean. Explicitly a stub suite, not record/replay: tests/fixtures/github holds payloads captured once by hand and replayed through the real adapter. Covered are URL parsing against six malformed forms, three-page pagination, the pr_too_large and source_unavailable escalations, 429 backoff, SHA-regex rejection, LRU eviction, the preflight's 403/404 before thread creation, and the short-circuit reaching the human gate with a model factory that raises — "no spend" is the point of that edge, so it is asserted rather than inferred from a route string. The clone runs against a local bare repo, so the default suite still makes no network call. The one live smoke test is network-marked and excluded. Not verified against a real GitHub MCP server; its tool names are assumed. Generated with [Claude Code](https://claude.com/claude-code) by CEH
Implements ITER_04_v2, the v2 MVP terminator, and carries the cross-cutting documentation for all four iterations. The investigator's ITER_03 toolset was read-only exploration — read_file, grep_repo, list_dir — and its findings were only as good as what grep surfaced, which for a security and licensing gate is thin. Semgrep gives the harness an actual analysis capability, added as a fourth *tool inside the existing loop* rather than a new graph node: the architectural claim that LangGraph sees one node and the Agent SDK owns the inner loop is reinforced, not diluted. `rules/` is committed and pinned, and its rules map to policy ids — a rule matching new outbound egress for SEC-01, PII reaching a logger for DATA-03 — which is what makes the output *citable* rather than generic lint noise. `--config auto` is never used: it fetches rules over the network at run time, which breaks the harness's no-network sandbox and makes results non-deterministic between runs. The config path is the absolute directory `paths.py` resolved, never the literal `rules/`, since a relative config path re-introduces the CWD assumption the asset resolver exists to kill. A failed run still increments semgrep_runs and still spends a tool call. Otherwise a broken scanner is free to retry, and the agent — told the tool is available — burns its turn budget on it instead of falling back to read_file. Semgrep is also not exempt from the tool-call budget just because it is deterministic. Findings reach the re-run specialists as rule/path/line evidence, so a specialist can raise severity to `block` with something behind it. The merge_findings reducer is what makes that raise visible: the re-run's batch replaces that specialist's prior findings, so the pre-semgrep needs_more_context note does not linger beside the evidence-backed block. The UI keys its empty state off semgrep_runs, not off the list. static_analysis defaults to [], so an empty list alone cannot distinguish "ran and found nothing" from "never ran" — and that is exactly what a reviewer needs, because one means the change is clean and the other means the evidence is missing. Finding is deliberately *not* given a matching `source` field. Semgrep output is evidence the investigator reports; a Finding stays a specialist's policy judgment citing a policy_id. Blurring the two would make "which policy does this violate" unanswerable for machine-generated hits. Semgrep ships as an optional extra rather than a core dependency. The plan's fallback condition (no wheel for a supported interpreter) does not fire — 1.172.0 has one — but it is a large install and the harness already degrades cleanly without it, through the same path that covers a missing binary. `uv sync` stays light and every semgrep branch is unit-tested against a stubbed subprocess, so no test depends on the extra. See DECISION_LOG entry 20. Documentation for the whole v2 family lands here rather than being split across the four commits: the README demo script is rewritten in this iteration anyway, and the prose in README, CLAUDE.md, .env.example and docs/guide interleaves all four topics too tightly to divide cleanly. The demo is now four beats, with the live GitHub PR as the one that could not exist in v1, and setup is two steps instead of three. Testing: 221 pass at 100% statement and branch coverage; ruff and mypy --strict clean. Every degradation path — missing binary, timeout, non-zero exit, unparseable JSON, unsupported event loop, absent ruleset — is exercised against a stubbed subprocess rather than left to the llm run, which does not count toward coverage. The committed rules were verified to fire on fixture 003 (DATA-03 and SEC-01). The wheel was verified to bundle rules/ and data/policies/index.yaml. Not verified: any run against the real Anthropic API, the real GitHub MCP server, or a live Langfuse container. Generated with [Claude Code](https://claude.com/claude-code) by CEH
…leset Two subsystems added in v2 had their reasoning only in module docstrings and commit messages, which is the wrong place for it: both are non-obvious enough that a reader needs the foundations before the code makes sense. tracing-design.md covers the OTel instrumentation — the span tree, the three traps it is designed around (a run that outlives its request, context lost across task boundaries, buffered spans dropped on shutdown), why the kill switch cannot be one line, and the limitations that are still unverified. It opens with three facts about spans, because every trap follows from the third one: a span with no parent starts a new trace, so a lost parent is silent rather than visible. semgrep-rules.md covers the investigator's fourth tool from zero — what semgrep matches that grep cannot, the pattern syntax, the four combinators with a worked narrowing, the gate's four rules read out loud, and the maintenance cost a hand-written ruleset carries. Both are engineering notes, not part of the user/operator guide; each points at the operator pages that own its configuration. Generated with [Claude Code](https://claude.com/claude-code) by CEH
The guide still described the v1 system in places: live GitHub intake was undocumented for reviewers, tracing was absent from the page named Monitoring, and several pages contradicted each other on the investigator's tool set and the escalation vocabulary. Corrected facts, each checked against source rather than against the design notes: - index.md claimed pull requests come "from fixture directories on disk, not from live GitHub", and its glossary described the deleted vector store as "embedded once". - OP-02 listed three investigator tools where there are four, so the documented tool-call budget did not account for run_semgrep, and it claimed the sandbox is always a fixture's repo/ directory rather than the injected workspace root. - OP-04 stated that specialist and classifier spend "is not estimated anywhere". Every model call has carried a cost attribute since tracing landed. - HT-02 and HT-04 listed three escalation reasons where the enum has five. New content: - HT-01 gains a live pull-request procedure with its preflight errors, and the Investigation panel now documents Budget and Static analysis, including the distinction between "scanned, found nothing" and "never scanned". - OP-04 gains a Traces section: the span tree, the attributes worth filtering on, the query that verifies the risk gate, and four trace-level symptoms. - reference.md gains Policy retrieval and Span vocabulary sections, so an operator can write a Langfuse filter query without reading the source. Generated with [Claude Code](https://claude.com/claude-code) by CEH
An audit of ITER_01_v2..ITER_04_v2 against the shipped code turned up three real gaps and two misleading comments. - The run span never carried `gate.pr.source`, so live and fixture runs could not be told apart with one Langfuse filter (ITER_03_v2 §04). It was only on the MCP tool span, which fixture runs never open. - `classify` and both specialists still used the blunt head+tail `truncate_diff`, not the per-file-aware cut ITER_03_v2 §06 names them in. The live path already truncated per-file at load, so this only bit an oversized fixture — but the nodes are what the plan specifies. - The `run_semgrep` child span recorded no hit count (ITER_04_v2 §04). `tool_span` now yields its span so the tool can attach one; duration was already free from the span itself. - Comments in `state.py` and `build.py` pointed at `saver.with_allowlist(...)`, which is the silent no-op CLAUDE.md warns about, not the `use_state_allowlist(saver)` the code actually calls. - `.env.example` omitted `GATE_SEMGREP_RULES_DIR`. `risk_score` keeps the blunt truncation on purpose: the plan names the classifier and specialists and nothing else. See DECISION_LOG entry 21 for that call and two others. Generated with [Claude Code](https://claude.com/claude-code) by CEH
Two findings from the v2 plan-compliance audit were left standing on purpose, and a decision that is only in the log is easy to lose. - `risk_score` still uses the blunt diff truncation, because ITER_03_v2 §06 names the classifier and specialists and widening the change would be scope creep. - The registered branch of `submit_decision`'s lookup has never been exercised. The ternary form hides it — coverage.py records no arc for a conditional expression — so `fail_under = 100` is green without proving that path. The same blind spot applies to every other ternary in `src/gate`; this is the one that was found, not the only one. Deliberately carries no plan frontmatter: the review skill resolves `depends_on` chains across SKELETON/ITER stems, and an `artifact:` header here would put a backlog in that path as if it were a section spec. Generated with [Claude Code](https://claude.com/claude-code) by CEH
ITER_03_v2 moved PR loading ahead of `ainvoke` — the `intake` node fetches nothing now, it only records what the adapter seeded. The demo still called `graph.ainvoke(initial_state(pr_id, thread_id))`, so every beat reviewed an empty diff. `examples/` is outside the coverage source, so the default suite could not see it. The script now uses the same seam the API does: `select_source` -> `resolve` -> `load` -> seed `ReviewState` -> `ainvoke`. With that in place the rest of the v2 family became visible from the demo too: - each beat runs under a `review_run_span`, so `trace_id` is real and a Langfuse deep-link prints (omitted when tracing is off) - the investigation line carries `semgrep_runs`, and static-analysis hits print per rule; ran-but-empty is distinguished from never-ran, the same way the UI panel does it - beat 4 (a live PR by URL, `--pr-url` to override) joins the list, skipped with a printed reason when GATE_GITHUB_TOKEN is unset, so the default run stays offline and deterministic README beat 4 gained the headless command now that it exists. Verified: ruff clean; the fixture adapter hands back a 1484-char diff, two changed files and a workspace path where the old code passed "". Not run end to end — that spends API credit. Refs .agents_workspace/DECISION_LOG.md entry 22 Generated with [Claude Code](https://claude.com/claude-code) by CEH
The diagram set still showed Chroma, a three-tool investigator, and an intake node that loaded its own PR. Updated to what is actually built: - system context drops the vector store, gains the GitHub MCP server, the clone cache and Langfuse over OTLP - components show sources/, retrieval, workspace, telemetry and the semgrep tool; ingest is gone - graph topology gains the intake short-circuit, the only edge v2 added - new diagram for the investigator sandbox (four tools, injected workspace root, committed ruleset) - new sequence diagram for live-intake failure containment — the reason PRSource has two methods, with resolve failures landing as HTTP before a thread exists and load failures as escalations - lifecycle sequence shows the resolve/load split and the run span as a root linked to the request span - data model gains PRRef, StaticFinding, trace_id, source, workspace_path and semgrep_runs Key Decisions: the Chroma entry is marked superseded rather than rewritten, the 2026-07-23 investigator entry carries an "extended by" pointer now that it says three tools, and four v2 entries are appended — deterministic lookup, OTLP to self-hosted Langfuse, the PRSource split plus short-circuit, and semgrep as the fourth tool. Verified: all eight blocks render with mermaid-cli 11. Generated with [Claude Code](https://claude.com/claude-code) by CEH
ITER_01_v2 §04 said `intake/store.py` was the session-scoped thread registry and must survive the Chroma removal. It was not — it was the Chroma accessor, and the registry is `app.state.reviews`. Following the paragraph literally would have left a module importing `langchain_chroma` after its only dependency was deleted, which is why the implementation diverged from it (DECISION_LOG entry 17) and the plan has been wrong since. ITER_01_v2 now states what `store.py` was and where the registry actually lives, keeping the original sentence inline so the correction is auditable rather than a silent rewrite. ITER_03_v2 §04 names `app.state.reviews` directly. `revised:` bumped on both. v1 ITER_05 §04 still lists `intake/store.py` among shipped modules, which was accurate at v1 and is left alone. Generated with [Claude Code](https://claude.com/claude-code) by CEH
The suite was already green at 100% statement and branch coverage, so these are the three cases that metric cannot speak to. - Tracing kill switch: ITER_02_v2 §04 asks for "zero spans and still reaches a verdict", and test_tracing.py claimed it in its docstring but only asserted a no-op provider. The new integration test installs an *exporting* provider first and lets the kill switch overwrite it, so zero spans proves the switch won rather than that nothing was listening. The run reaches verdict(auto_approved). - B-02: the registered branch of submit_decision's lookup had no test. Coverage records no arc for a conditional expression, so both halves of a ternary read as covered once the line runs. Restored the if/else — the form that fails fail_under = 100 loudly — and added a test whose registration carries a pr_id and source that differ from the checkpoint's, asserted on the run span. Killed the mutant: forcing the fallback branch fails the test. - The demo has no coverage floor at all (examples/ is outside the coverage source), which is how it shipped reviewing an empty diff. tests/integration/test_demo_example.py loads the script by path and pins the seeding: a real diff, the changed-file list, and a workspace path that exists, before the graph is invoked. BACKLOG B-02 marked closed with how; B-01 still stands. 226 passed, 100% statement and branch coverage, ruff and mypy --strict clean. The 14 llm/network evals were not run — they cost money. Generated with [Claude Code](https://claude.com/claude-code) by CEH
The comment explaining why the "View trace" link is omitted rather than rendered disabled sat above prTitleLink, which has nothing to do with tracing. Comment text unchanged; no behaviour change. Generated with [Claude Code](https://claude.com/claude-code) by CEH
ITER_04_v2 §03/§04 ask CI to install the pinned scanner wherever the interpreter supports it, and no leg ever did — so nothing proved the pin resolves. semgrep 1.172.0 ships cp312/cp313/cp314 wheels, so both legs qualify. Safe for the default suite: every semgrep path there is stubbed against a fake subprocess, and test_asyncio_is_used_directly_so_the_stubs_above _are_honest guards that the stubs stay honest. Generated with [Claude Code](https://claude.com/claude-code) by CEH
ITER_03_v2 §06 introduced per-file-aware truncation but named only "the classifier and specialists", so risk_score kept the blunt head+tail cut and could be handed a diff sliced mid-hunk. The spec wording was the thing that was wrong, not the omission: the strategy is node-independent, so §06 now names every node that reads the diff and carries a dated clarification rather than letting the code drift ahead of the plan. Closes BACKLOG B-01. No shipped fixture is near GATE_MAX_DIFF_CHARS (20 000), so no eval expectation moves. Generated with [Claude Code](https://claude.com/claude-code) by CEH
Decision log entries 23 and 24, and the backlog they leave behind. Entry 23 explains why two ITER_04_v2 §04 items were left rather than closed silently: vendoring semgrep-registry rules imports LGPL-2.1 content into an Apache-2.0 repo, and a trace screenshot needs a live Langfuse container plus a paid run — fabricating one would misrepresent the artifact. Entry 24 covers the fourth audit: the CI semgrep gap, and B-01 closed on an explicit decision that the plan, not the code, was the thing to correct. Open: B-03, B-04. Closed: B-01, B-02. Generated with [Claude Code](https://claude.com/claude-code) by CEH
ITER_04_v2 §04 requires the wheel install path to list its required variables inline, because an installed wheel ships no .env.example to copy. The paragraph said "with the required variables set in the environment" and named none, which leaves a reader of that path with nothing to act on. It now names ANTHROPIC_API_KEY and GATE_GITHUB_TOKEN and says the rest of the Configuration table has working defaults. Found by the fifth plan-compliance audit of the v2 family, recorded as DECISION_LOG entry 25. That audit found nothing else: src/gate, the UI, the policy corpus, the ruleset, packaging, .env.example and CI all match every sections_changed entry across ITER_01_v2..ITER_04_v2. Backlog B-03 and B-04 stay open on the reasoning already recorded in entries 23 and 24. Generated with [Claude Code](https://claude.com/claude-code) by CEH
The user-operator-guide skill gained a page-furniture phase. Bring the guide up to it. Every page below the index now carries a breadcrumb, and each subfolder page ends with a prev/next/index footer. The two chains stay separate: HT-04 and OP-05 end at the index rather than linking across. The index lists every page grouped by subfolder with a "read this when" line, and keeps no footer of its own -- it is the hub. Two of the new rules caught real faults. The When/Prerequisites/Time blocks were separated by single newlines, which a renderer collapses into one run-on paragraph; they are bullet lists now. And OP-01 had a Verify paragraph sitting between steps 4 and 5 of the post-install check, which restarts the numbering at 1; step 5 moved above it. Prose stays wrapped at 100 columns, against the skill's new no-hard-wrap rule. Every Markdown file in this repo wraps that way, and reflowing only the guide would split the docs tree between two conventions. DECISION_LOG entry 26 has the reasoning. Generated with [Claude Code](https://claude.com/claude-code) by CEH
Reading the tracing module raises three questions the code did not answer, and one of the answers turned out to be wrong in the design note. - `SpanContext` and `Context` are unrelated types that share a word. One is a span's identity and feeds a Link; the other is the container that sets a parent. Named in the module docstring and in tracing-design.md §1. - `current_request_context` must be called in the handler and returns an identity rather than a container. Both constraints are now in its docstring, alongside why `is_valid` is what makes the kill switch degrade without an enabled check. - `tracer()` reads the module's own `_provider`; the call site now says why rather than leaving it to the comment 50 lines up. The correction: the span tree drew `gate.investigator.run` as a child of `gate.node.investigate`. It is a peer. `run_investigation` overrides an *intact* ambient parent, which is the one place `context=` is not repairing a lost one — so the tree was teaching the misreading that would delete it. The tree also omitted `gate.llm._InvestigationAnswer`. Verified by exporting the real tree in memory, not by reading the code. That shape was unasserted, so a test now pins it, confirmed to fail when the argument is removed. The stale line references the docstrings shifted are updated too. Generated with [Claude Code](https://claude.com/claude-code) by CEH
The reference named four review-state fields in a sentence and left the
other fourteen to be discovered from a live response. Since that object is
both the body of GET /api/reviews/{thread_id} and what a checkpoint holds,
a reader had no way to know which fields a failed load still populates, or
which have more than one writer.
Adds a field table with the writing step, the nested object shapes, and the
two channels whose merge rules are not last-write-wins.
Generated with [Claude Code](https://claude.com/claude-code) by CEH
The rest of .agents_workspace/ is tracked — ARCHITECTURE.md, DECISION_LOG.md and planning/ — so handoff notes were the only part of that record that did not survive a clone. The directory is empty today, so nothing enters the repository until the next handoff is written. Generated with [Claude Code](https://claude.com/claude-code) by CEH
Documentation drift from 394be9a: that commit made `tool_span` yield its span so `run_semgrep` could attach `gate.semgrep.rule_hits`, but the reference's span vocabulary was never given a row for it. Also states the guardrail order in the tool closure as a contract — three properties follow from statement order alone and were readable only in the code — and adds the missing ruleset directory to the failure reasons behind `semgrep unavailable`. Generated with [Claude Code](https://claude.com/claude-code) by CEH
A graph run that raised was caught in `_spawn`, logged as `graph_run_error` and swallowed — correct, since a detached run must not crash the app, but it left no verdict, no interrupt and no checkpoint saying why. `_derive_status` read that shape as `running`, so the UI polled a dead review indefinitely and the reason — usually a missing or rejected ANTHROPIC_API_KEY — stayed in the server log. The failure is now recorded in a session-scoped `app.state.review_errors` map, ranked between `done` and `awaiting_human` (a run that died at the gate cannot be resumed), and returned as `error` on the detail response. The page shows a red `failed` badge, states the reason in the existing banner, and stops polling. The empty-values branch of `get_review` derives its status too, which is what covers a run that died before its first checkpoint. Deliberately in memory, not in the checkpoint: persisting it would mean a new EscalationReason member, an allowlist entry and a state-version argument to back a UI banner in a single-user demo. Documented in the reference instead. Also splits the review-source picker onto one row per source. Side by side the two radios read as one setting with four controls. Generated with [Claude Code](https://claude.com/claude-code) by CEH
Every UI poll is an HTTP request and every request is its own single-span trace, so the detail view's cadence is trace volume (tracing-design §7.2). A review parked at the gate for four minutes cost ~240 request traces around ~12 spans of real work, burying the review trace in the list. Nothing changes at the gate until the reviewer acts, so poll every 10s there and keep 2s elsewhere: the same four minutes now cost ~48. This is the half of §7.2 that touches no OTel surface. `excluded_urls` stays unapplied — the suggested regex also swallows POST /decision, and filtering spans before §7.3 confirms any span arrives would give a missing trace two causes at once. Also closes §7.1 by stating the limit where it bites rather than fixing it: the review page now prints the `gate.thread_id` to search beside the View trace link, which reaches only the leg the review started on. Not swapped for a Langfuse search URL — the configured base is a bare host with no project segment, so that would stack a second unverified guess on the deep-link. Generated with [Claude Code](https://claude.com/claude-code) by CEH
Generated with [Claude Code](https://claude.com/claude-code) by CEH
Spans never reached Langfuse. Four defects stood in the way, each with the same symptom from the application side: the review completes perfectly and the backend stays empty. - The endpoint default was wrong. Probing 3.225.2, POST /api/public/otel returns 404 and /api/public/otel/v1/traces returns 200 with auth and 401 without. Langfuse's integration docs name the bare path for self-hosted v3.22.0+; on this image it does not exist. - The compose stack ran three services. Langfuse v3 ingests asynchronously through Redis and S3-compatible storage, so the worker, redis and minio are load-bearing. Rebuilt from upstream's file at the pinned tag rather than hand-written. - Its ClickHouse healthcheck probed localhost, which busybox wget resolves to [::1]; the container has no IPv6, so a healthy database failed forever and depends_on held web and worker in Created. - ENCRYPTION_KEY was unquoted, so YAML read 64 zeros as a number and Docker rendered "0". The worker crash-looped on Langfuse's 64-hex check while web started anyway. The review page now links Langfuse's home page and prints the gate.thread_id search term instead of deep-linking a trace. The URL shape belongs to Langfuse, not to us — it already moved once, and the search term reaches both legs of a review that paused at the human gate, which the deep link never could. tracing-design.md 7.3 closes with the probe table; 7.1 records the link decision; the new section 9 documents the stack and the deferred move to Langfuse v4. Generated with [Claude Code](https://claude.com/claude-code) by CEH
Three corrections found sweeping the repo for the v0.2.0 release, none of them code: - The README's Install section still said the project "stays unpublished until it is useful for productive work, which means live GitHub intake" — the very thing v2 shipped — while the note at the foot of the same file already declared the hold lifted. The two now agree: the hold is lifted, publishing is a separate decision, and this release does not take it. - HT-03 said the demo script covers "the three fixture beats only" and that the live beat "needs the server and the UI". examples/demo.py has had a `live --pr-url` subcommand since 6df31ef. The page now documents four beats and the headless live invocation. - publish.yml.disabled exempted PYSEC-2026-311 in chromadb, a package v2 removed. A stale --ignore-vuln is worse than none: it silently exempts whatever id gets reused. Replaced with the three mcp advisories that actually apply to the current lock, each annotated with why it does not reach this code and what retires the exemption. Also corrects the ci.yml comment describing the default pytest selection, which predates the `network` marker. Generated with [Claude Code](https://claude.com/claude-code) by CEH
Cuts the v2 plan family as v0.2.0: live GitHub pull-request intake by URL, OpenTelemetry tracing over OTLP to a self-hosted Langfuse, deterministic policy retrieval in place of the vector store, and semgrep as the investigator's fourth tool. MINOR, not PATCH: the release adds a request field, a graph edge, three subsystems and five state fields. It is not MAJOR only because the project is pre-1.0 — the one narrowing change, the removal of PolicyChunk.distance, is what GATE_STATE_VERSION=2 exists to refuse an old checkpoint over. pyproject.toml is the only manifest; uv.lock follows it. The lock also moves cryptography 49.0.0 -> 50.0.0 (PYSEC-2026-3552) and langgraph-checkpoint-sqlite 3.1.0 -> 3.1.1 (PYSEC-2026-3636), both fixes inside the existing constraints. It cannot move mcp off its three advisories: semgrep==1.172.0, the pinned extra and the current latest release, pins mcp==1.23.3 exactly. All three are server-side and this project is an stdio client only — see DECISION_LOG entry 32. Verified: ruff, ruff format, mypy --strict, 228 tests at 100% statement and branch coverage, and `uv build` producing a 0.2.0 wheel that bundles the policy corpus, fixtures, UI and semgrep rules. Generated with [Claude Code](https://claude.com/claude-code) by CEH
v0.2.0 lifted the hold that kept the project off PyPI — the stated condition was live GitHub intake, which it ships — but deliberately did not act on it. Readers of the changelog had no way to tell whether that meant "soon" or "never"; the Install sections say only that publishing is a separate decision. Records the intent under [Unreleased], with what actually remains: the one-time Trusted Publishing setup on pypi.org and renaming publish.yml.disabled back to .yml. Generated with [Claude Code](https://claude.com/claude-code) by CEH
cheneeheng
force-pushed
the
feat/v2-deterministic-retrieval-tracing-live-intake
branch
from
August 12, 2026 21:13
b6dfbc5 to
8673433
Compare
cheneeheng
deleted the
feat/v2-deterministic-retrieval-tracing-live-intake
branch
August 12, 2026 21:22
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Releases v0.2.0 — the v2 plan family: live GitHub pull-request intake by URL, OpenTelemetry
tracing over OTLP to a self-hosted Langfuse, deterministic policy retrieval in place of the
vector store, and semgrep as the investigator's fourth tool. Merging this PR releases v0.2.0,
which will then be tagged on
main.Why
Four subsystems, one release:
POST /api/reviewsnow acceptspr_urlas an alternative topr_id. APRSourceadapter splits work at the request/background seam:resolveruns inthe handler and maps a bad URL, missing repo, or under-scoped token to 422/404/403 before a
review thread exists;
loadruns in the background and fails by escalation instead. Metadataand diff come from the GitHub MCP server; the repo tree is a shallow clone at
head_shathatbecomes the investigator's sandbox.
README's cost-gate claims into evidence: filter traces on
gate.risk.level=lowand there areno Sonnet spans.
ingestion step are gone, replaced by a
change_type-> file lookup validated at startup.Embedding search did not earn its place at a three-document corpus keyed by a closed enum.
run_semgrep— a fourth read-only investigator tool over a committed pinned ruleset whoserules map to policy ids.
Bump level
MINOR (0.1.2 -> 0.2.0). Adds a request field, a graph edge, three subsystems, and five state
fields. Not MAJOR only because the project is pre-1.0; the one narrowing change (removing
PolicyChunk.distance) is whatGATE_STATE_VERSION=2exists to refuse an old checkpoint over.Security
pip-auditover the locked runtime dependencies found 5 vulnerabilities:cryptography49.0.0 -> 50.0.0 (PYSEC-2026-3552),langgraph-checkpoint-sqlite3.1.0 -> 3.1.1 (PYSEC-2026-3636).mcp1.23.3 (PYSEC-2026-3481/-3482/-3483) and cannot be upgraded:semgrep==1.172.0, the pinned static-analysis extra and the current latest semgrep release,pins
mcp==1.23.3exactly. All three advisories are in the package's server half; thisproject imports
ClientSessionandstdio_clientonly and never runs an MCP server, so noneof the vulnerable code paths are reachable. Documented as exemptions in
.github/workflows/publish.yml.disabled, replacing achromadbexemption that went dead whenv2 removed Chroma. Full reasoning in
.agents_workspace/DECISION_LOG.mdentry 32.PyPI publication
Coming within the next few patch releases. v0.2.0 lifted the hold — the stated condition was
live GitHub intake, which this PR ships — but did not act on it. What remains is the one-time
Trusted Publishing setup on pypi.org and renaming
.github/workflows/publish.yml.disabledbackto
.yml. The same note is recorded under[Unreleased]inCHANGELOG.md.How
PRSourceprotocol has two methods on purpose (resolvecheap/handler,loadslow/background) — collapsing them would force either a POST that blocks on a clone or a typo'd URL
creating a review row.
validate_policy_index()runs in the lifespan and fails the process on an unmapped enummember, a missing file, or an unparseable
policy_id— a runtime fallback would beunreachable in a correct install.
run_semgrepuses the committedrules/ruleset resolved to an absolute path, never--config auto(would fetch rules over the network and break the no-network sandbox / resultdeterminism). A failed run still increments
semgrep_runsand spends a tool call.Testing
All run locally on Windows, all green:
uv run ruff check .anduv run ruff format --check .— cleanuv run mypy src(strict) — no issues in 40 source filesuv run pytest --cov— 228 passed, 14 deselected, 100% statement and branch coverage ofsrc/gate(fail_under = 100enforced)uv build— produces a 0.2.0 wheel; verified it bundles the policy corpus, sample PRfixtures, UI, and semgrep rules under
gate/_bundled/Not run:
pytest -m llm(real Anthropic API, costs money) andpytest -m network(live GitHub,needs a token). Stating this plainly rather than implying full coverage.
Known open items (not blocking this release)
rules/ships no vendored semgrep registry rules, held on an LGPL-2.1 licensingquestion since the repo is Apache-2.0.
plus a paid run.
Both documented in
.agents_workspace/planning/v2/BACKLOG.md.Checklist
any/@ts-ignore/# type: ignoreintroducedGATE_STATE_VERSION=2refuses old checkpoints)Generated with Claude Code by CEH