Agent workflows

This page is the shared operating manual for humans and LLM agents. Codebase work has exactly three top-level actions: create or groom an issue, create a PR, or review/update an existing PR. Everything else on this page is a supporting check inside one of those actions.

The three actions

Do not invent a fourth agent workflow. Preparing, authoring a Restate handler, creating a legal workflow, running Markdown lint, checking GitOps, and consulting the councils are subroutines inside one of these three actions.

Create or groom an issue

Planning lives in GitHub issues, not local notes. For a brand-new issue, read the narrowest relevant docs, code, and tests first, then open the issue with grounded scope, test-driven steps, and the real files in blast radius. For an existing issue, read it from the opening comment forward, follow how the ask evolved, and comment the extra plan detail that a future worktree will start from.

If the issue encodes a decision worth keeping past the work, lift it into code, a doc, or the glossary when the PR lands; the issue is the working space, and the repository is the durable record.

Create a PR

The canonical ship path is gitops.md: branch, push, open a PR, enable squash auto-merge. Do not commit directly to main.

Before changing files:

  1. Rebase or otherwise confirm the branch is current with origin/main.

  2. Prepare the task checkout and its host development state before the first edit:

    cargo run -p cli -- dev worktree-env up --branch <topic-branch>
    

    The CLI fetches origin/main. When a harness exported NAVIGATOR_WORKTREE_PATH (or a compatible native worktree path), it creates the topic branch in that supplied checkout. With no supplied checkout, it creates .worktrees/<topic> beside the primary checkout. Continue in the task checkout path the command prints; never create a nested worktree by hand. Keep new work out of the primary checkout and unrelated PR worktrees.

  3. Read CLAUDE.md, AGENTS.md, and the most specific docs from index.md.

  4. Read glossary.md before using domain nouns.

  5. Read access-model.md before touching roles, participation, OPA, sessions, or visibility.

  6. Check the working tree with git status --short --branch; never overwrite user changes.

  7. Pick the narrowest docs and code path that actually cover the task.

  8. If the task changes English marketing or public Foundation prose, update the matching Spanish surface in the same PR according to i18n.md; do not leave Spanish as a follow-up.

If the decision is architectural, legal-copy, or client-facing, use the relevant council in agent-decision-councils.md after reading the facts.

When a dirty tree is ready to land:

  1. Survey every change: git status --porcelain, git diff, git diff --staged, and untracked files.

  2. Group paths by concern. One commit should have one blast radius.

  3. Run the gate that matches the changed files before committing. If any Markdown files changed, run the Markdown gate across the workspace so CI-only wrap issues are caught locally:

    cargo run -p cli --quiet -- validate .
    

    Always run the client-data gate — it guards the shipped data surfaces on every PR:

    cargo run -p cli --quiet -- validate-no-client-data .
    
  4. If the PR changes Rust files or build/runtime configuration, run the full Rust gate:

    cargo fmt
    cargo clippy --workspace --all-targets -- -D warnings
    cargo test --workspace
    

    Then verify coverage locally — do not infer it from a green test run. Codecov is a hard merge gate (codecov.yml fails the PR when patch coverage drops below 90%), and a passing cargo test does not prove the number: CI measures it by re-running the same pass under cargo llvm-cov --workspace --lcov, and any test that skips in that pass contributes nothing. Harness-gated browser/e2e tests (the new_client_or_skip suites — reask_flow, browser_e2e, …) skip without chromedriver, exactly as they do in the coverage job, so code whose only exercise is such a test shows uncovered even though the suite is green. Spin up only what CI's coverage job spins up — Docker for the testcontainer DB (plus the OPA binary the policy tests shell out to), the same minimal topology, no KIND stack — and measure the changed lines before you push. A covering test that needs the full KIND stack (Keycloak, Garage, Restate, a browser) skips in that job just like the e2e, so it won't count either:

    cargo llvm-cov --workspace --lcov --output-path lcov.info   # the same pass CI uploads to Codecov
    

    Give every handler, route, and branch a non-gated covering test — drive it through the router against a testcontainer DB (see test-database.md) so it runs on every push — and keep the browser e2e as the live-walkthrough proof, not as the only coverage. If a line is genuinely unreachable in a test (a 500 infrastructure arm, a local-dev auth fallback), say so in the PR rather than letting it quietly fail the gate.

  5. Stage explicit paths for each group, not git add -A.

  6. Use Conventional Commit subjects; use the PR title as the squash-merge commit title.

  7. For any change to public or portal UI, always capture a live screenshot from the running app — boot web against the persistent KIND deps (the fixture is usually already up; see RUNBOOK.md) and capture with headless Chrome. Save it under /tmp/navigator-screenshots/ and embed it in the PR description via the pr-image-upload skill to get a URL that renders — a raw <img src="/tmp/..."> renders broken on github.com. Do not self-host the artifact on a remote branch or commit it to the tree. Rendering tests are not a substitute for seeing the page served. If the page requires login, use the authenticated worktree flow in RUNBOOK.md: dev grant-staff must target the same DATABASE_URL as the web process, and the browser session should come from Keycloak rather than hand-written cookies.

  8. Push and open a PR against main; GitHub's merge queue lands it after the required checks pass. CI enables auto-merge on open, which enqueues the PR — do not run gh pr merge yourself, and leave the decision of when it lands to the merge queue.

  9. Clean up task-owned local resources before ending the session. See Resource cleanup.

If the work should become multiple PRs, decide that before committing. Use the Engineering Council for real sequencing questions.

Grouping changes into commits

Partition the changed paths into the smallest set of coherent commits — each commit one reviewable concern with a single blast radius. Reviewers read commits, so a clean grouping is the deliverable.

Keep in one commit when the paths share a concern: a route handler, its view, and the test that covers it (TDD — the test lands with the code it covers); a migration and the entity that depends on it; a generated artifact and its source (docs/erd.svg with docs/erd.md). Split apart when the blast radius differs: a crate bump versus a vendored-asset swap; a behavior-preserving refactor versus the behavior change built on it; two unrelated fixes found in one sitting; CI or tooling (.github/workflows/, .agents/skills/) versus product code; docs-only edits unless the doc documents that exact code change. While grouping, drop history the change leaves behind — "we used to…" narration, a deprecated-but-kept flag, a dangling reference to a removed file — because code describes the present and git history holds the past.

Write each subject as a Conventional Commit: <type>(<scope>): <subject>, imperative mood, lower-case start, no trailing period, ≤72 chars. <scope> is the crate or area (web, store, cli, views, deps, mcp). Append ! after the type/scope or add a BREAKING CHANGE: body trailer for a breaking change. The PR title is the squash-merge commit subject, so write it as the Conventional Commit you want in main's history.

typewhen
feata new capability or user-visible behavior
fixa bug fix
refactorbehavior-preserving restructuring (rename, move, extract)
docsdocs, prose, or README only
testtests added or changed in isolation (usually folded into feat)
choretooling, deps, skills, housekeeping
ci.github/workflows/ and CI plumbing
perfa performance improvement
styleformatting only, no code change
buildbuild system, Containerfiles, or Cargo manifests (non-dep)

GitHub CLI authentication

gh drives every GitHub action here — gh pr create, PR review, and gh image uploads. Check auth before any remote step:

gh auth status --hostname github.com

If auth is missing, use the browser device-code flow with SSH as the git protocol:

gh auth login --hostname github.com --git-protocol ssh --web

Relay the one-time code gh prints verbatim — never invent or transform it — and keep the login session alive until the user finishes the browser step, then re-run gh auth status to confirm.

Review or update a PR

A PR review is not complete until every reviewer comment has been adjudicated against the real code and answered. main is merge-only and lands via auto-merge once CI is green, so an unanswered — or answered-but-unresolved — comment is what quietly keeps a green PR from merging. The deliverable is not "here is what I think"; it is "every open thread is closed or has a decision on it, and the change was checked against running code." The flow has eleven steps; do them in order, each assumes the prior one.

  1. Identify the PR — explicit number/URL if given, else the current branch's PR.
  2. Read the PR — metadata, the full diff, then the changed files at the head commit.
  3. Assess independently — form your own correctness + quality view before reading any comment.
  4. Bring up the PR worktree + right KIND environment — review against code that runs at this commit, not just the diff.
  5. Collect every comment — inline, summary, and review bodies, from every reviewer.
  6. Adjudicate each comment against the real running code — valid, invalid, or valid-but-won't-fix.
  7. Ask the user, per actionable comment, whether to fix it (recommendation first).
  8. Apply the approved fixes — prove each against the running app, with its covering test.
  9. Resolve every comment — reply via gh, then mark real threads resolved. Mandatory.
  10. Update the branch from main when it is behind and that blocks the merge.
  11. Report — findings, every verdict, fix shas, branch state, and that all threads are closed.

Step 1 — Identify the PR

If the user named a PR (a bare number, a URL), use it. If they did not — a plain "review PR" — default to the PR for the current branch; do not ask "which PR?". The branch is the answer:

gh repo view --json nameWithOwner -q .nameWithOwner   # the {owner}/{repo} for this checkout
gh pr view --json number -q .number                   # the PR whose head is the CURRENT branch

Only fall back to asking when the lookup genuinely can't decide: there is no PR for the branch (offer to create a PR if the tree has unshipped work), or on main/detached/in another worktree — then list the open PRs (gh pr list --json number,title,headRefName) and ask, unless the preceding conversation already pins one. Pass the resolved slug to every gh call below as --repo <owner>/<repo>.

Step 2 — Read the PR

gh pr view <N> --repo <slug> \
  --json title,body,state,author,baseRefName,headRefName,additions,deletions,changedFiles,mergeable,reviewDecision
gh pr diff <N> --repo <slug>     # full diff; scope to the files you care about if it is large

The diff is the claim; the files at the head commit are the truth. Read the real files, not just the patch hunks — a comment can be wrong because of context outside the hunk.

Step 3 — Assess independently first

Before you read a single comment, form your own view so the bots don't anchor you. Focus on what breaks or rots:

Write this up as your own findings; you reconcile it with the comments in Step 6.

Step 4 — Bring up the PR worktree and right KIND environment

Adjudicate against the code as it runs at the head commit, not just as it reads in the diff. Materialize a dedicated worktree on the PR branch, fetching the PR ref first so the command works from a fresh checkout and for fork PRs:

git fetch origin pull/<N>/head:pr-<N>
cargo run -p cli -- dev worktree-env up --branch pr-<N>

The same command works from a primary checkout or an agent-supplied worktree. Continue in the printed task checkout path; that checkout owns the PR branch, host port, and .devx descriptor. The current dependency tier and database are shared across worktrees.

worktree-env up brings up the shared KIND dependency tier if it is not already reachable, migrates its navigator database, and picks a stable per-worktree web port in .devx/env. Boot web against it (see RUNBOOK.md) so that when you reach a comment you can actually run the covering test and click through the affected page. web is a compiled binary — restart it after any fix, or you are looking at the old build.

worktree-env runs host web and the in-cluster workflows-service against the same shared navigator database, so it can exercise Restate-backed flows. That agreement also means parallel worktrees share migrations and application rows; choose fixtures deliberately and do not mistake a distinct host port for data isolation. This live surface is what Steps 6 and 8 adjudicate and prove against; it is not optional context, it is how the review stays grounded.

Step 5 — Collect every comment

There are three comment surfaces on a GitHub PR; pull all three — reviewers split findings across them (Greptile, for one, puts unplaceable findings in its summary, not inline):

# (a) inline review comments — anchored to file + line, these form resolvable threads
gh api --paginate repos/<slug>/pulls/<N>/comments \
  --jq '.[] | {id, user: .user.login, path, line, original_line, diff_hunk, in_reply_to_id, body}'
# (b) issue/PR-level comments — top-level, includes review SUMMARIES (the Greptile overview)
gh api --paginate repos/<slug>/issues/<N>/comments --jq '.[] | {id, user: .user.login, body}'
# (c) review bodies — the approve/request-changes top notes
gh pr view <N> --repo <slug> --json reviews -q '.reviews[] | {author: .author.login, state, body}'

--paginate is load-bearing: without it gh api returns only the first 30 items and silently drops the rest — and a PR with more than 30 inline comments is exactly where leaving one unanswered is easiest. Read summaries in full — they carry P-rated findings and "comments outside diff" that never became inline threads. We wire Greptile and human reviewers today (plus whatever bot is added later); treat every distinct finding as a comment to adjudicate, wherever it lives.

Step 6 — Adjudicate each comment against the running code

First filter the Step 5a set to thread roots — comments where in_reply_to_id is null. The non-null ones are existing replies, not new findings; keep them for context (they tell you whether a thread is already answered) but only roots enter the loop. For each finding, do not take the reviewer's word for it — open the cited file at the head commit and, where the claim is about behavior, reproduce it against the live env from Step 4:

State your verdict on each with the evidence, so the user decides from facts, not from a bot's confidence.

Step 7 — Ask the user whether to fix

For every comment you classified Valid (and any won't-fix you're unsure of), ask the user whether to apply the fix, recommendation first. Do not silently fix or silently skip — the user decides what lands. Invalid / false-positive comments need no fix question, but they still get a reply in Step 9 explaining why you're not acting.

Step 8 — Apply the approved fixes, proven against the running app

On the PR worktree from Step 4, fix exactly what was approved and prove it — don't reply "fixed" on the strength of a re-read:

git add <paths> && git commit -m "test(web): exercise the real client-DRI guard (Greptile P2 on #<N>)"
git push origin HEAD:<headRefName>

Step 9 — Resolve every comment (REQUIRED)

This is the point of the action. A PR is not reviewed until every comment has a reply and every thread you handled is marked resolved. Replying is not enough: branch protection requires conversations to be resolved, so an answered-but-unresolved thread still blocks auto-merge — the PR sits green but unmergeable. Do both, in order: reply, then resolve.

Reply to inline threads (use the comment id from Step 5a):

gh api repos/<slug>/pulls/<N>/comments/<comment_id>/replies -f body='Fixed in <sha> — <one line>.'
# or, for a won't-fix / false positive:
gh api repos/<slug>/pulls/<N>/comments/<comment_id>/replies \
  -f body='Acknowledged, not fixing — <rationale with file:line evidence>.'

Reply to summary-only findings (no inline thread) with a top-level comment that names which finding it answers:

gh pr comment <N> --repo <slug> --body 'Fixed the P2 "<finding title>" from the summary in <sha>: <what changed>.'

Mark real review threads resolved — REST replies don't flip the resolved flag; that's a GraphQL mutation. List thread ids, then resolve each one you've answered:

gh api graphql -f query='
query($owner:String!,$repo:String!,$pr:Int!){
  repository(owner:$owner,name:$repo){ pullRequest(number:$pr){
    reviewThreads(first:100){
      pageInfo{ hasNextPage endCursor }
      nodes{ id isResolved comments(first:1){ nodes{ databaseId author{login} path line } } } } } }
}' -F owner=<owner> -F repo=<repo> -F pr=<N>

gh api graphql -f query='mutation($id:ID!){ resolveReviewThread(input:{threadId:$id}){ thread{ isResolved } } }' \
  -F id=<threadId>

Match each thread to the finding by its first comment's databaseId — the same id from Step 5a, so it maps exactly. Don't match on author + path alone: several comments by one reviewer on one file collide, and you risk resolving the wrong thread while a real one stays open and blocks the merge. If pageInfo.hasNextPage is true, fetch the next page (reviewThreads(first:100, after:<endCursor>)) before assuming you have them all. Resolve only after a thread is genuinely handled; leave one open only when you are deferring to the user and they haven't decided — and say so in the reply, since you are knowingly leaving the merge blocked.

Step 10 — Update the branch from main when it's behind

A PR can be fully reviewed, every thread resolved, CI green — and still not merge because the branch has fallen behind main and branch protection requires the head to be up to date. Clearing that is part of the review:

gh pr view <N> --repo <slug> --json mergeStateStatus,mergeable -q '{mergeState: .mergeStateStatus, mergeable}'

Only ever update the branch under review, never with a force-push, and never someone else's branch without saying so in the report. After an update, CI re-runs and auto-merge lands it when green.

Step 11 — Report

Summarize: your independent findings, every comment and its verdict (valid / invalid / won't-fix), what was fixed (with shas) and how you proved it against the running app, whether the branch needed updating from main, and confirmation that every thread now has a reply and the handled ones are resolved. Call out anything still open and why.

Reviewing operates on an existing PR: turning a dirty tree into a PR is Create a PR; shipping to prod is the prod-deploy flow. Every fix you push clears the gate (fmt / clippy / test / validate-no-client-data / Markdown lint) and ships with its covering test.

Supporting checks

Markdown lint

Use the workspace CLI, not a separate Markdown linter:

cargo run -p cli --quiet -- validate <path>

validate classifies each file automatically, so ordinary docs get the prose rules and notation templates get the N-family rules. It walks the whole tree — root files such as AGENTS.md and CLAUDE.md are checked as prose — and folds in the typed event pass and a .yaml/.yml parse over the same walk. Bare validate defaults to ..

CI runs the repository-wide classified pass on every pull request update:

cargo build -p cli --quiet
./target/debug/navigator validate .

That command builds the Neon Law Navigator CLI, checks every included Markdown file in the visible repository tree, and applies the Template superset to files under templates/ or any Markdown file with questionnaire: or workflow: frontmatter.

No client data in the repo

The repository holds the firm's OWN data and synthetic fixtures — never a client's. Only the firm's own contact addresses (@neonlaw.com / @neonlaw.org) and Nick Shook's (shook.family) may ship; every other email must be a reserved synthetic domain (example.com, or a *.example / *.invalid / *.test / *.localhost address, per RFC 2606 / RFC 6761), and phone numbers do not belong in shipped data at all.

The gate scans only the surfaces that ship data to people — store/seeds, templates, and web/content — because that is where a real client's email or phone would leak. Source and test code carry synthetic fixtures (a@b.com, a nick@gmail.com negative test) and are reviewed by humans and this checklist, not by the domain allowlist. Run it locally, and let CI run it on every pull request update:

cargo run -p cli --quiet -- validate-no-client-data .

It reports each finding as path:line: NCD-EMAIL/NCD-PHONE and exits non-zero on any, so the ci gate fails fast — next to validate / dev i18n, never a fourth CI workflow. When adding data that legitimately needs a non-firm address, use a reserved synthetic domain rather than a real one.

Use this path when adding a new matter type or extending a template's workflow. Do not solve legal workflows with a one-off router handler when a template + questionnaire + workflow can express the matter.

  1. Write the composition .feature first in features/tests/features/.
  2. Create or edit the template under templates/forms/... or templates/neon_law/<product>/....
  3. Add new questions to store/seeds/Question.yaml.
  4. Compose the workflow from documented step prefixes in notation-authoring.md.
  5. Add reusable StepKind and dispatch code only when the existing step registry cannot express the work.
  6. Put every external or non-deterministic side effect behind Restate durability.
  7. Add tests in the same commit as the implementation.

The core rule is still: the Template declares; Restate runs.

Restate handler authoring

The full architecture is durable-workflows.md. For Rust handler code, the one replay-safety rule is load-bearing:

Every non-deterministic act belongs inside ctx.run(...).name("stable-name").

That includes clocks, randomness, UUIDs, database writes, object storage, network calls, and third-party APIs. The handler body may replay; ctx.run journals the result so replay reuses it instead of re-executing the side effect.

Use terminal errors for invalid input that can never succeed later. Use retryable errors for infrastructure failures. Do not use native tokio::spawn, join_all, or channels for journaled steps inside a Restate handler; use Restate SDK sequencing/combinators or keep the steps sequential.

GitOps and deploy

The branch-to-prod path is:

  1. PR merges by squash into main.
  2. release-tag.yml cuts a YY.M.D tag.
  3. deploy.yml publishes all images to ghcr.io.
  4. An operator rolls GKE onto the dated tag.

Read gitops.md, gke-prod.md, and cloud-operations.md before changing CI, release, deploy, cluster, or production secret behavior.

Always roll navigator-web, workflows-service, and the optional navigator-git Deployment together. A version skew between the public web surface, durable worker, and git writer is a production risk.

Resource cleanup

Neon Law Navigator is a large Rust monorepo; agents should assume disk and memory are scarce. Before ending a create-PR or review/update-PR session, clean up resources created for that task.

For Cargo builds:

For Docker, KIND, and browser e2e:

Measure before and after cleanup when disk pressure is part of the task (df -h ., docker system df, or both), and report anything left running or left on disk.

Maintenance support