Merge remote-tracking branch 'origin/main' into cursor/improvise-2mqz-download-frames

Co-authored-by: Ed Langley <fiddlerwoaroof@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-09-27 17:18:31 +00:00
31 changed files with 1107 additions and 299 deletions
+61 -6
View File
@@ -5,13 +5,19 @@ description: Wait for a GitHub PR's checks to finish before merging or reporting
# Waiting on a PR
"Watching a PR" means tracking **both** CI checks **and** GitHub comments/reviews.
A PR is not ready to merge just because checks are green — review feedback must be
read and answered first.
## CI Checks
```
scripts/pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec>]
scripts/pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec>] [--reviews]
```
```
exit 0 every check COMPLETED, none failed, and MERGEABLE with a state of
CLEAN, UNSTABLE or HAS_HOOKS
exit 0 every check COMPLETED, none failed, MERGEABLE with a state of
CLEAN, UNSTABLE or HAS_HOOKS, and reviews fetched (if --reviews)
exit 1 a check failed (under any mergeability, BLOCKED included), or the
PR is not mergeable (CONFLICTING, DIRTY, BEHIND, DRAFT, or a state
it does not know)
@@ -21,21 +27,70 @@ exit 3 timed out with checks still running, mergeability still UNKNOWN,
exit 4 usage or environment error
exit 5 the PR is MERGED or CLOSED, at the head the watch pinned; it ends on
the first poll that sees it closed, which may be mid-wait
exit 6 checks green/mergeable, but pr-reviews failed (fetch error, not "no reviews")
```
**`--reviews`** (default): after checks complete, print all reviews and comments
via `scripts/pr-reviews`. Use `--no-reviews` to skip this. The reviews are shown
but do not change the exit code — answering them is your responsibility.
**Exit 0 now means mergeable, not only green.** GitHub computes mergeability
lazily and reports `UNKNOWN` for a while after the base moves. It also reports
`BLOCKED` for the moment before a required check that `needs:` another job has
registered. The watch waits out both, so you do not need to re-check
`mergeable` by hand after it. **Exit 3 is not a pass.** It may be a required
check that never ran. Why each
state counts the way it does: `context/code/tooling.md`, "pr-watch and
mergeability" (improvise-edpl).
check that never ran. Why each state counts the way it does:
`context/code/tooling.md`, "pr-watch and mergeability" (improvise-edpl).
**Pass `--expect <sha>` whenever you know which commit you are asking about.**
It is the difference between "this PR is green" and "the thing I pushed is
green", and a PR here was once merged at a head that predated its own fix.
## Reviews and Comments
Reviews are a **separate API surface** from the comment thread. Codex/ChatGPT
reviews arrive as *reviews*, not comments — checking only the comment thread
misses them entirely. `pr-watch --reviews` (the default) shows all three:
| Surface | What it is |
|---|---|
| `[REVIEW]` | The review's own body (pulls/{n}/reviews) |
| `[INLINE]` | A comment on a line of the diff (pulls/{n}/comments) |
| `[COMMENT]` | A comment on the PR thread (issues/{n}/comments) |
To fetch reviews directly: `./scripts/pr-reviews <n>` — **do not pipe it**. The
script's `$?` must be read directly; piping or chaining loses the exit code.
Redirect to a file if you need to process it.
### Review Obligations
Before treating a PR as done or merging:
1. **Read all review feedback** — reviews against an earlier head may still apply
2. **Answer substantive feedback with attribution** — every reply must include
the attribution line (see below)
3. **Re-check after each push** that new reviews target the current head SHA
4. **Don't wait indefinitely on Copilot/Codex** — reviews arrive as quota allows;
an absent review and an unrequested one look identical. The adversarial review
that gates a merge is dispatched by the orchestrator; external reviews are
read *in addition*, not instead.
### GitHub Attribution (Hard Rule)
**Every GitHub write must include an attribution line.** Posts go out under the
repository owner's account — an unattributed post reads as the human's words.
Open the body with: `_Posted by <Agent Stack> (<model>)_`
Examples:
- `_Posted by Claude (Opus 4.5)_`
- `_Posted by Cursor Agent (claude-sonnet-5)_`
A thread is not "answered" until the reply carries this attribution.
See AGENTS.md "GitHub Attribution" for the full rule and "Shipping a wave" for
Copilot request mechanics and how to answer declined findings.
## Do not write the loop yourself
Four signals look like "finished" and are not:
+46 -257
View File
@@ -1,273 +1,62 @@
#!/bin/bash
#!/usr/bin/env bash
# Prepare a Claude Code on the web container for work on this repository.
#
# Section 0 runs everywhere, cloud or not: it asserts that the `bd` on PATH
# is the one flake.nix pins. Everything after it is cloud-only setup.
# This hook delegates to the shared `scripts/agent-bootstrap.sh`, which works
# for any coding agent stack (Claude Code, Cursor, etc.). This file exists
# because Claude Code expects hooks at `.claude/hooks/session-start.sh`.
#
# Three things are slow or wrong by default in a fresh cloud session:
# For local checkouts, only the bd pin assertion runs.
# For cloud sessions (CLAUDE_CODE_REMOTE=true), full bootstrap runs.
#
# 1. `bd` is not installed, so the `bd prime` hook and every `bd` command
# in AGENTS.md silently do nothing.
# 2. `.beads/issues.jsonl` is a stale export. An agent that reads it picks
# work that is already done — this has happened. The authoritative
# tracker is the Dolt database on the git remote.
# 3. The Rust workspace is uncompiled, so the first `cargo test` pays a
# cold build before it reports anything.
#
# The tracker is re-synced on every session, not just when the database is
# missing: cloud containers reuse a cached workspace, so "already present"
# does not mean "still current", and a silently ageing tracker is the exact
# failure this hook exists to prevent. Installing bd and warming the build
# do check first, being genuinely one-time.
#
# Nothing here writes to a tracked file, stages anything, or creates a commit.
# See also: scripts/agent-bootstrap.sh (the stack-agnostic implementation)
set -uo pipefail
ROOT="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
cd "$ROOT" || exit 0
BOOTSTRAP="$ROOT/scripts/agent-bootstrap.sh"
log() { printf '[session-start] %s\n' "$*"; }
# ── 0. The bd on PATH must be the bd flake.nix pins ────────────────────────
# `flake.nix` pins beads so that one build of `bd` touches this repo. A git
# worktree does not load that pin — a fresh `.envrc` is unallowed, so a
# process there falls through to whatever `bd` the user's profile happens to
# carry. Today those agree; nothing enforces that they keep agreeing.
#
# The failure that matters is silent: writing .beads/issues.jsonl with a
# skewed build empties the dependency graph and reports success. Nobody
# notices until they ask what blocks what. So the mismatch is turned into a
# loud one here rather than trusted. Reading with a skewed bd is safe — it
# is the mutating commands that corrupt — so this blocks nothing, it shouts.
#
# The expected version is read from flake.nix, not written down twice: a
# hard-coded copy would go stale the first time the pin moves, and a stale
# assertion is worse than none.
beads_pin() {
sed -n 's|.*beads\.url[[:space:]]*=[[:space:]]*"github:[^/]*/[^/]*/v\{0,1\}\([^"]*\)".*|\1|p' \
flake.nix 2>/dev/null | head -1
}
bd_running_version() {
bd version 2>/dev/null | sed -n 's/^bd version \([0-9][0-9.]*\).*/\1/p' | head -1
}
bd_pin_banner() {
{
printf '\n[session-start] ===== BEADS VERSION SKEW =====\n'
printf '[session-start] %s\n' "$@"
printf '[session-start] =============================\n\n'
} >&2
}
# Prints the banner and returns 1 when the two disagree, or when either one
# cannot be read — an unreadable answer is a rotted assertion, not a pass.
assert_bd_pin() {
command -v bd >/dev/null 2>&1 || return 0 # nothing on PATH to assert about
pinned=$(beads_pin)
running=$(bd_running_version)
case "$pinned" in
[0-9]*.[0-9]*.[0-9]*) ;;
*)
bd_pin_banner "cannot read the beads pin out of flake.nix" \
"read: '${pinned:-<nothing>}'" \
"The pin moved to a ref this hook cannot parse (a branch or a" \
"commit, perhaps). Teach beads_pin() to read it, or the check" \
"silently protects nothing."
return 1
;;
esac
if [ -z "$running" ]; then
bd_pin_banner "cannot read the version of the bd on PATH" \
"bd: $(command -v bd)" \
"'bd version' did not answer in the expected shape. Until it does," \
"nothing here can tell whether that build matches the pin."
return 1
fi
[ "$running" = "$pinned" ] && return 0
bd_pin_banner "the bd on PATH is NOT the bd this repo pins" \
"pinned (flake.nix): $pinned" \
"running: $running ($(command -v bd))" \
"" \
"Reading is safe. Do NOT run a mutating bd command — create, update," \
"close, dep, dolt push. Version skew between builds empties the" \
"dependency graph in .beads/issues.jsonl and reports success." \
"" \
"Fix now: nix develop (in this directory), then re-run the" \
" command; or 'direnv allow' here." \
"Fix for good: worktrees are created fresh per dispatch, so allowing" \
" them one at a time never catches the next one. Put the" \
" repo root under [whitelist] prefix in" \
" ~/.config/direnv/direnv.toml."
return 1
}
pin_status=0
assert_bd_pin || pin_status=2
# Local checkouts have their own setup; this is for the web containers.
if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
exit "$pin_status"
fi
# Install a verified database at .beads/embeddeddolt.
#
# The staged copy lives on another filesystem, so it is brought alongside the
# live one FIRST. Every step after that is a same-filesystem rename, which
# either happens or does not — no half-finished copy. The previous database is
# removed only once the new one is in place, so no failure path leaves the repo
# without a tracker, and every move is checked: an unchecked one would let the
# new database land *inside* the old directory and still report success.
install_tracker() {
staged=$1
live=.beads/embeddeddolt
incoming=$live.incoming
previous=$live.previous
rm -rf "$incoming" "$previous" || return 1
cp -rf "$staged" "$incoming" || { rm -rf "$incoming"; return 1; }
if [ -d "$live" ] && ! mv "$live" "$previous"; then
rm -rf "$incoming"
return 1
fi
if mv "$incoming" "$live"; then
rm -rf "$previous"
return 0
fi
# Could not put the new one in place; restore what was there.
if [ -d "$previous" ] && ! mv "$previous" "$live"; then
log "ERROR: tracker left at $previous — move it back to $live by hand"
fi
rm -rf "$incoming"
return 1
}
# ── 1. bd on PATH ──────────────────────────────────────────────────────────
# The embedded Dolt engine needs CGO, so this builds from source (~3 min).
# The container image is cached after the hook, so it is paid once.
export PATH="$PATH:/root/go/bin:$HOME/go/bin"
# Pinned to a commit rather than `main`, and downloaded before it is run:
# piping a moving URL straight into a shell executes code nobody has seen,
# and leaves nothing to inspect afterwards. The installer stays at
# /tmp/bd-install.sh so what actually ran can be read.
#
# To take a newer beads, resolve the peeled commit of its tag:
# git ls-remote --tags https://github.com/steveyegge/beads | grep 'v1.3.0^{}'
BD_INSTALLER_REF=f45b249ce6b40ba62aecc03949e6371e8f7c79d8 # tag v1.3.0
BD_INSTALLER_URL="https://raw.githubusercontent.com/steveyegge/beads/$BD_INSTALLER_REF/scripts/install.sh"
if ! command -v bd >/dev/null 2>&1; then
log "installing bd (beads v1.3.0)…"
if ! curl -fsSL "$BD_INSTALLER_URL" -o /tmp/bd-install.sh 2>/tmp/bd-install.log; then
log "WARNING: could not download the bd installer; see /tmp/bd-install.log."
elif bash /tmp/bd-install.sh >>/tmp/bd-install.log 2>&1; then
log "bd installed"
else
log "WARNING: bd install failed; see /tmp/bd-install.log (the script that"
log " ran is at /tmp/bd-install.sh). Issue tracking unavailable."
fi
fi
# Forward the Claude env file variable to the generic name the bootstrap uses
if [ -n "${CLAUDE_ENV_FILE:-}" ]; then
# Single quotes are deliberate: $PATH and $HOME must be written literally so
# they expand when the session sources this, not now.
# shellcheck disable=SC2016
echo 'export PATH="$PATH:/root/go/bin:$HOME/go/bin"' >> "$CLAUDE_ENV_FILE"
fi
command -v bd >/dev/null 2>&1 && bd metrics off >/dev/null 2>&1
# bd may have arrived only just now, in which case §0 had nothing to check.
# Re-check, but only if §0 stayed quiet, so the banner is never printed twice.
if [ "$pin_status" -eq 0 ]; then
assert_bd_pin || pin_status=2
export AGENT_ENV_FILE="$CLAUDE_ENV_FILE"
fi
# ── 2. Sync the issue tracker from the Dolt remote ─────────────────────────
# `bd init` in-place rewrites .beads/config.yaml and commits, so the sync
# happens in a throwaway directory and only the database is moved back.
# That directory must be named after the database (see .beads/metadata.json),
# since bd derives the database name from it.
if command -v bd >/dev/null 2>&1; then
DB_NAME="$(jq -r '.dolt_database // "improvise"' .beads/metadata.json 2>/dev/null || echo improvise)"
ORIGIN="$(git remote get-url origin 2>/dev/null || true)"
case "$ORIGIN" in
git@github.com:*) ORIGIN="https://github.com/${ORIGIN#git@github.com:}" ;;
esac
ORIGIN="${ORIGIN%.git}"
# The mktemp is part of the condition on purpose. Unchecked, a failure
# leaves TMP empty, "$TMP/$DB_NAME" becomes an absolute path at the
# filesystem root, and the cleanup at the end degrades to `rm -rf ""`.
# Short-circuiting also means no temp directory is created when there is
# no origin to sync from.
if [ -n "$ORIGIN" ] && TMP=$(mktemp -d) && [ -d "$TMP" ]; then
log "syncing issue tracker from $ORIGIN …"
STAGE="$TMP/$DB_NAME"
mkdir -p "$STAGE"
# `bd init` exits non-zero when the remote is unreachable but STILL leaves
# a .beads/embeddeddolt behind — an empty database. Testing only for the
# directory would swap that over a good tracker, so check the status and
# confirm the result actually carries issues. An empty remote tracker is
# indistinguishable from a failed clone here; keeping what we already have
# is the safe way to be wrong.
sync_ok=0
if (
cd "$STAGE" && git init -q . &&
BD_NON_INTERACTIVE=1 bd init --non-interactive --skip-agents --skip-hooks -q \
--remote "git+$ORIGIN"
) >/tmp/bd-sync.log 2>&1 && [ -d "$STAGE/.beads/embeddeddolt" ]; then
listing="$( (cd "$STAGE" && bd list --all 2>/dev/null) || true )"
case "$listing" in
"" | "No issues found."*) ;;
*) sync_ok=1 ;;
esac
# For local checkouts, only verify the bd pin (no install, no sync)
if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
# Inline the pin-check only, since the full bootstrap is cloud-only
cd "$ROOT" || exit 0
beads_pin() {
sed -n 's|.*beads\.url[[:space:]]*=[[:space:]]*"github:[^/]*/[^/]*/v\{0,1\}\([^"]*\)".*|\1|p' \
flake.nix 2>/dev/null | head -1
}
bd_running_version() {
bd version 2>/dev/null | sed -n 's/^bd version \([0-9][0-9.]*\).*/\1/p' | head -1
}
if command -v bd >/dev/null 2>&1; then
pinned=$(beads_pin)
running=$(bd_running_version)
# Fail closed: if we can't parse either version, warn rather than silently pass
if [ -z "$pinned" ]; then
printf '[session-start] WARNING: cannot parse beads pin from flake.nix\n' >&2
printf '[session-start] The pin check is skipped, but mutating bd commands may be unsafe.\n' >&2
exit 2
fi
if [ "$sync_ok" -eq 1 ] && install_tracker "$STAGE/.beads/embeddeddolt"; then
log "issue tracker synced — 'bd ready' now reflects the remote, not issues.jsonl"
elif [ -d .beads/embeddeddolt ]; then
log "WARNING: tracker sync failed (see /tmp/bd-sync.log); kept the previous database."
log " It may be out of date — verify any bead against the code."
else
log "WARNING: tracker sync failed; see /tmp/bd-sync.log."
log " .beads/issues.jsonl is a STALE export — verify any bead against the code."
if [ -z "$running" ]; then
printf '[session-start] WARNING: cannot parse bd version from "bd version"\n' >&2
printf '[session-start] bd: %s\n' "$(command -v bd)" >&2
printf '[session-start] The pin check is skipped, but mutating bd commands may be unsafe.\n' >&2
exit 2
fi
if [ "$running" != "$pinned" ]; then
printf '\n[session-start] ===== BEADS VERSION SKEW =====\n' >&2
printf '[session-start] pinned (flake.nix): %s\n' "$pinned" >&2
printf '[session-start] running: %s (%s)\n' "$running" "$(command -v bd)" >&2
printf '[session-start] Reading is safe. Do NOT run mutating bd commands.\n' >&2
printf '[session-start] Fix: nix develop, or direnv allow.\n' >&2
printf '[session-start] =============================\n\n' >&2
exit 2
fi
rm -rf "$TMP"
elif [ -n "$ORIGIN" ]; then
log "WARNING: could not create a temporary directory; skipped the tracker sync."
log " .beads/issues.jsonl is a STALE export — verify any bead against the code."
fi
exit 0
fi
# ── 3. Warm the build so the first cargo test is not a cold compile ────────
if command -v cargo >/dev/null 2>&1; then
log "warming cargo build…"
if cargo build --workspace --tests >/tmp/cargo-warm.log 2>&1; then
log "workspace built"
else
log "WARNING: cargo build failed; see /tmp/cargo-warm.log"
fi
fi
# ── 4. Environment facts worth knowing up front ────────────────────────────
cat <<'NOTES'
[session-start] Environment notes (see context/cloud-agents.md):
• Use the preinstalled cargo toolchain for ordinary work. `nix develop`
also works, but only after scripts/nix-seed-flake-inputs.sh — GitHub
tarballs are not served here, so the locked inputs are seeded over git.
• `bd dolt push` is blocked outbound (403). Bead status changes stay local.
• Never run a bare `bd init` in this repo: it rewrites AGENTS.md, CLAUDE.md
and .claude/settings.json, and commits. Use --skip-agents --skip-hooks.
• `bd dolt push <remote>` ignores its argument and pushes to origin.
NOTES
# Non-zero only when §0 found version skew; the setup above still ran.
exit "$pin_status"
# Cloud session: run the full bootstrap
exec "$BOOTSTRAP"
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/beads
+5
View File
@@ -0,0 +1,5 @@
{
"name": "Improvise",
"install": "nix-daemon >/tmp/nix-daemon.log 2>&1 & ./scripts/agent-bootstrap.sh",
"start": "nix-daemon &"
}
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/beads
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/beads-merge
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/code-review
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/kb
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/pr-watch
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/wave
+70
View File
@@ -0,0 +1,70 @@
name: Cloud agent smoke
on:
pull_request:
paths:
- ".claude/**"
- ".cursor/**"
- "scripts/agent-bootstrap.sh"
- "scripts/nix-seed-flake-inputs.sh"
- ".github/workflows/cloud-agent-smoke.yml"
jobs:
claude:
name: Claude entry point + bead
runs-on: [self-hosted, nixos, microvm]
steps:
- uses: actions/checkout@v7
- name: Bootstrap through Claude session-start hook
shell: bash
env:
CLAUDE_CODE_REMOTE: "true"
run: |
if ! ./.claude/hooks/session-start.sh; then
echo "=== /tmp/nix-daemon.log ==="
cat /tmp/nix-daemon.log 2>/dev/null || true
echo "=== /tmp/nix-seed.log ==="
cat /tmp/nix-seed.log 2>/dev/null || true
echo "=== /tmp/bd-install.log ==="
cat /tmp/bd-install.log 2>/dev/null || true
exit 1
fi
- name: Create, read back, and close a smoke-test bead
shell: bash
run: |
set -euo pipefail
out="$(bd create "Cloud agent smoke: Claude entry point" --description="Ephemeral local-only bead used to verify the Claude cloud bootstrap entry point." --type=task --priority=4 --json)"
id="$(printf '%s' "$out" | jq -r '.id // .issue.id // empty')"
test -n "$id"
bd show "$id"
bd close "$id" --reason="Cloud-agent smoke test completed"
bd show "$id" | grep -qi closed
cursor:
name: Cursor install entry point + bead
runs-on: [self-hosted, nixos, microvm]
steps:
- uses: actions/checkout@v7
- name: Bootstrap through Cursor install command
shell: bash
run: |
nix-daemon >/tmp/nix-daemon.log 2>&1 &
if ! ./scripts/agent-bootstrap.sh; then
echo "=== /tmp/nix-daemon.log ==="
cat /tmp/nix-daemon.log 2>/dev/null || true
echo "=== /tmp/nix-seed.log ==="
cat /tmp/nix-seed.log 2>/dev/null || true
echo "=== /tmp/bd-install.log ==="
cat /tmp/bd-install.log 2>/dev/null || true
exit 1
fi
- name: Create, read back, and close a smoke-test bead
shell: bash
run: |
set -euo pipefail
out="$(bd create "Cloud agent smoke: Cursor entry point" --description="Ephemeral local-only bead used to verify the Cursor cloud bootstrap entry point." --type=task --priority=4 --json)"
id="$(printf '%s' "$out" | jq -r '.id // .issue.id // empty')"
test -n "$id"
bd show "$id"
bd close "$id" --reason="Cloud-agent smoke test completed"
bd show "$id" | grep -qi closed
+85 -2
View File
@@ -13,8 +13,10 @@
force-push. The rule, the message convention and the incidents are
`.agents/skills/wave/agent-rules.md`, rule 8.
- always start responses with bananaS!
- there is a prolog MCP available: load a session from `kb` at the root of the repository at the beginning of
every session, and query it as you work. **Reading only** — the MCP must never write `kb.pl`.
- **Knowledge base: use `scripts/kb`, not Prolog MCP.** The Prolog MCP is optional and not
available in all agent stacks (Cursor cloud agents do not have it). All agents must use
`scripts/kb add|replace|rm|query|check` for KB operations — see the `kb` skill at
`.agents/skills/kb/SKILL.md`. Reading `prolog-sessions/kb.pl` directly is fine for queries.
- save structured facts as you go — work done, interesting locations, important decisions — with
`scripts/kb add` (see the `kb` skill). Write a fact when you learn it; there is no reason to batch.
- query the knowledge base as needed and use Prolog reasoning when it makes sense.
@@ -25,6 +27,87 @@
This project uses **bd** (beads) for issue tracking. Run `bd prime` for full workflow context.
## Multi-Stack Agent Support
This repo supports multiple coding-agent stacks: Claude Code, Cursor (IDE and cloud), and
potentially others. All stacks share the same operational setup:
| Component | Location | Notes |
|---|---|---|
| Toolchain | `nix develop` / `direnv` | **Required.** Rust from flake, wasm target, beads pin, trealla |
| Skills | `.agents/skills/` | Canonical source; `.claude/skills/` and `.cursor/skills/` symlink here |
| Session bootstrap | `scripts/agent-bootstrap.sh` | Seeds nix inputs, installs bd, syncs tracker, warms cargo |
| Knowledge base | `scripts/kb` | **All stacks use this**, not Prolog MCP |
| Issue tracker | `bd` CLI | Synced from Dolt remote at session start |
### Toolchain Requirement
**Agents must work inside `nix develop` (or direnv) for this repo.** Bare system cargo that
fails `edition2024` or missing wasm target is a misconfigured session. The bootstrap seeds
nix flake inputs automatically (GitHub tarballs are blocked in cloud), so `nix develop`
should work after install completes.
Run commands inside the dev shell:
```bash
nix develop --command cargo test
nix develop --command cargo build --workspace
```
Or enter an interactive shell: `nix develop`
**Claude Code**: `.claude/hooks/session-start.sh` calls the bootstrap automatically.
**Cursor cloud**: `.cursor/environment.json` runs the bootstrap via `install`. Skills are
discovered from `.cursor/skills/` (symlinks to `.agents/skills/`).
**Local agents**: Run `scripts/agent-bootstrap.sh` manually, or use `direnv allow` for
automatic dev shell activation.
See `context/cloud-agents.md` for environment-specific notes (nix, network, etc.).
### GitHub Attribution (Hard Rule)
**Every GitHub write (PR, comment, review reply) must include an attribution line identifying
the AI agent.** Posts go out under the repository owner's account, and an unattributed post
reads as the human owner's words to everyone who sees it.
**Format:** Open the body with an italicized attribution line:
```
_Posted by <Agent Stack> (<model>)_
```
Examples:
- `_Posted by Claude (Opus 4.5)_`
- `_Posted by Cursor Agent (claude-sonnet-5)_`
- `_Posted by Claude Code_`
For PR bodies, the `🤖 Generated with …` footer that some tools add covers this. For
comments and review replies, add the line yourself — there is no automatic footer.
**This applies to:** PR creation, PR updates, issue comments, review comments, and review
replies. A thread is not "answered" until the reply carries this attribution.
### PR Tracking: Checks AND Reviews
For any PR an agent owns or is working on, **track both CI checks and GitHub comments/reviews**.
The `pr-watch` skill (`.agents/skills/pr-watch/SKILL.md`) is the agent-facing recipe.
**`scripts/pr-watch`** now prints reviews after checks are green (use `--no-reviews` to skip).
Reviews are a separate API surface from comments — Codex/ChatGPT reviews arrive as *reviews*,
not comments, and are easy to miss if you only read the comment thread.
1. **Run `scripts/pr-watch <n>`** — waits for checks, then prints all reviews/comments
2. **Read all review feedback** — reviews against an earlier head may still apply
3. **Answer substantive feedback with attribution** before merging (see format above)
4. **Re-check after each push** that new reviews target the current head SHA
5. **Don't wait indefinitely on Copilot/Codex** — reviews arrive as quota allows
To fetch reviews directly: `./scripts/pr-reviews <n>` — **do not pipe it** (exit code trap).
See the `pr-watch` skill for the full recipe, and "Shipping a wave" below for Copilot
request mechanics and how to answer declined findings.
## Quick Reference
```bash
+82 -13
View File
@@ -1,11 +1,30 @@
# Cloud Agents (LLM Reference)
Environment notes for agents running in Claude Code on the web. Local
checkouts are unaffected — the session hook exits early unless
Environment notes for agents running in cloud containers — Claude Code on the web,
Cursor cloud agents, and similar setups. Local checkouts are unaffected.
## Session Bootstrap
All agent stacks use the same bootstrap script: `scripts/agent-bootstrap.sh`. It:
1. **Seeds nix flake inputs** (GitHub tarballs are blocked; seeds over git instead)
2. Verifies the `bd` version matches the flake.nix pin
3. Installs `bd` if missing
4. Syncs the Dolt issue tracker from the git remote
5. Warms the cargo build **inside `nix develop`**
**After bootstrap, `nix develop` works.** This is the required toolchain — bare system
cargo that fails `edition2024` or missing wasm target is a misconfigured session.
**Claude Code**: `.claude/hooks/session-start.sh` calls the bootstrap automatically when
`CLAUDE_CODE_REMOTE=true`.
`.claude/hooks/session-start.sh` handles the setup automatically. This doc
is the "why", and the record of what bites when it doesn't run.
**Cursor cloud**: `.cursor/environment.json` runs the bootstrap via `install`. Skills are
in `.cursor/skills/` (symlinks to `.agents/skills/`).
**Other stacks**: Run `scripts/agent-bootstrap.sh` manually after checkout.
This doc is the "why", and the record of what bites when setup doesn't run.
---
@@ -60,7 +79,8 @@ same if you need to re-sync by hand; never run it in the working tree.
| Thing | Status | Use instead |
|---|---|---|
| `nix develop` **unseeded** | Fails: flake inputs are GitHub **tarballs**, which are not served here | Run `scripts/nix-seed-flake-inputs.sh` first — see below. Or just use the preinstalled `cargo`/`rustc`, the same stable toolchain the flake pins |
| `nix develop` **without bootstrap** | Fails: flake inputs are GitHub **tarballs**, which are blocked | Bootstrap seeds inputs automatically; if it failed, run `scripts/nix-seed-flake-inputs.sh` manually |
| Bare system cargo | Fails: `edition2024`, missing wasm target | **Always use `nix develop`** — this is the required toolchain |
| `bd dolt push` | Fails: 403 outbound | Nothing; bead status changes stay local. Say so rather than implying the tracker was updated |
| `git+ssh://` Dolt remote in `config.yaml` | No ssh client in the container | `git+https://<origin>`, which the hook passes explicitly |
| `git push`, GitHub API | **Work normally** | — |
@@ -70,13 +90,13 @@ starting by hand). Reads of public GitHub repos over the git protocol work —
it is specifically the `/archive/*.tar.gz` and `codeload` endpoints that are
refused.
### Making `nix develop` work
### How nix develop works after bootstrap
`scripts/nix-seed-flake-inputs.sh` makes the devShell work **without touching
`flake.nix` or `flake.lock`**. Verified end to end: `rustc 1.98.1`, `cargo`,
`crate2nix` in the shell, `git status` clean.
The bootstrap runs `scripts/nix-seed-flake-inputs.sh` automatically, which seeds
the flake inputs over git **without touching `flake.nix` or `flake.lock`**.
After bootstrap, `nix develop` should work. If it fails, check `/tmp/nix-seed.log`.
Why it is needed, and why the obvious fixes are not available:
Why the seeding is needed:
- Nix's `github:` fetcher wants a tarball. GitHub traffic **bypasses the
environment's domain allowlist entirely** and goes through a proxy that
@@ -105,9 +125,9 @@ around it — a mismatch fails loudly. Do not instead rewrite the lock's nodes
to `git` type: carrying the old `narHash` onto a different fetcher is exactly
the claim the lock exists to make.
It is not in the session-start hook because it is expensive (minutes, plus
several GB of store once the devShell builds) and most work does not need the
devShell. Run it when you do.
The bootstrap runs this automatically. It takes a few minutes and several GB of
store once the devShell builds. If bootstrap reported a seed failure, check
`/tmp/nix-seed.log` and re-run `scripts/nix-seed-flake-inputs.sh` manually.
Two alternatives if per-container seeding is unattractive: push the inputs to
a binary cache with `nix flake archive` and point sessions at it with
@@ -143,3 +163,52 @@ environment, where egress leaves through your own network boundary.
reorder or fold commits (`.agents/skills/wave/agent-rules.md`, rule 8).
- Keep `context/repo-map.md` and `context/design-principles.md` current —
they are the two docs agents are expected to maintain by hand.
---
## PR Tracking
For any PR you own, **track both CI checks and GitHub comments/reviews**:
- **`scripts/pr-watch <n>`**: Waits for checks, then prints all reviews/comments (default).
Reviews are a separate API surface — Codex/ChatGPT reviews arrive as *reviews* not comments.
- **Answer feedback with attribution** before marking work done (see below).
- **Re-check after push** that reviews target the current head SHA.
The `pr-watch` skill (`.agents/skills/pr-watch/SKILL.md`) is the agent-facing recipe.
See AGENTS.md "PR Tracking: Checks AND Reviews" and "Shipping a wave" for the full workflow.
---
## GitHub Attribution (Hard Rule)
**Every GitHub write (PR, comment, review reply) must include an attribution line.**
Posts go out under the repository owner's account — an unattributed post reads as the
human's words.
Open the body with: `_Posted by <Agent Stack> (<model>)_`
Examples:
- `_Posted by Claude (Opus 4.5)_`
- `_Posted by Cursor Agent (claude-sonnet-5)_`
A thread is not "answered" until the reply carries this attribution. See AGENTS.md
"GitHub Attribution" for the full rule.
---
## Knowledge Base
**Use `scripts/kb`, not Prolog MCP.** The Prolog MCP is not available in all agent stacks
(Cursor cloud agents do not have it). All agents must use `scripts/kb` for KB operations:
```bash
scripts/kb check # kb_consistent, exactly what CI runs
scripts/kb query '<goal>' # run a goal; exit 1 if it fails
scripts/kb add <delta.pl> # add clauses; refuses if a key already exists
scripts/kb replace <delta.pl> # add, overwriting existing keys
scripts/kb rm <keys.pl> # remove by key; refuses if a key is absent
```
Reading `prolog-sessions/kb.pl` directly for queries is fine. See the `kb` skill at
`.agents/skills/kb/SKILL.md` for the full interface.
+25
View File
@@ -1739,3 +1739,28 @@ must name both lines of a duplicate; `ClaimError` carries only the id, so the
loader keeps the line numbers. The virtual views `_records` and `_drill` are
not yet in `VIRTUAL_IDS`. improvise-p003 item 1 asks for them, and they arrive
with the ADR text fix (improvise-cq1q.1) rather than ahead of it.
## 21. A display beside the key (ADR 007 clause 2, improvise-cq1q.3)
`Category`, `Item` and `View` each carry a private `display`. It starts equal to
the key and changes only through `set_display` / `set_item_display`, which move
no key. Until the re-key (improvise-cq1q.4), nothing in production calls those
setters, so `display == name` everywhere and behaviour does not change. The
split exists so that the re-key changes what goes *into* the key, and nothing a
person sees moves with it.
**The key travels beside the display, never in place of it.** `CategoryRow`,
`TileEntry` and `ViewSummary` each gain a `display` field. The key field
(`name`, `category`) stays exactly what it was, because both front ends compose
commands from it: `resolve.rs` builds `SetAxis`, `RemoveItem` and the rest out of
those strings. Carrying the display *in* the key field would make every
command name a key that does not exist once the two differ. That is the gap
improvise-cq1q.11 names, and cq1q.7 later turns these key fields into ids. The
active-view marker in both view panels still compares keys for the same reason.
**What this first slice covers:** the category panel's headers, the tile bar and
the view list, in both front ends. Item rows, the grid frame, the page bar,
records-mode labels, CSV export headers, status messages and the view title
still show keys. They follow in later slices of this step, because the grid's
label tuples are also cell coordinates (`cell_key`, `innermost_group`), so their
display has to be added at projection time rather than in place.
+26
View File
@@ -1998,3 +1998,29 @@ Rival, from the same review: strip the leading `_` before the space step, so
that ` Draft` becomes `_draft`. This test fails (`left: "_draft"`), and so does
`generated_ids_are_distinct_well_formed_and_never_reserved`. Before this test,
the proptest was the only thing that saw the rival.
## Displays beside keys (ADR 007 clause 2, improvise-cq1q.3, first slice)
Each renderer test below builds a projection whose display differs from its key.
The test-only way to get one is `set_display`, since no production path makes
them differ yet. Every test failed before the change, with the key drawn where
the display should be: `left: "Region"` against `right: "Sales Area"` in the
projection and wasm tests, and a missing "Sales Area" or "Overview" in the TUI
buffers.
### ui::project::tests::the_category_panel_shows_a_display_and_keeps_the_key_commands_name
It finds the row by `name == "Region"` before asserting on the display. A rival
that writes the display into `name` fails the lookup, which covers the half that
keeps commands working. `_Measure` is checked to fall back to its own key.
### ui::view_panel::tests::a_view_row_draws_its_display_and_marks_the_active_view_by_its_key
Rival: the active marker compares `summary.display == active_view`. This test
fails on that rival (no `▶ Overview`), because the active view is a key
(`ViewState::active_view`) and its display differs from it.
### render::tests::the_panels_paint_displays_and_keep_keys_for_what_a_command_names
The browser half of the same three panels. It also asserts that the view row is
still `active` when found by key.
@@ -137,6 +137,7 @@ mod tests {
fn cat_name_works_for_both_variants() {
let header = CatTreeEntry::Category {
name: "Region".into(),
display: "Region".into(),
item_count: 2,
expanded: false,
axis: crate::view::Axis::None,
@@ -426,6 +426,11 @@ fn non_item(category: &str, item: &str) -> CellId {
}
}
/// What a person sees for a category key; the key itself when no category holds it.
fn category_display<'a>(model: &'a Model, name: &'a str) -> &'a str {
model.category(name).map_or(name, |c| c.display())
}
/// The categories the category panel draws: **every** category, virtual ones
/// included, with their items and their axis (improvise-vjms, gotcha 7).
pub fn category_rows(model: &Model, view: &View) -> Vec<CategoryRow> {
@@ -434,6 +439,7 @@ pub fn category_rows(model: &Model, view: &View) -> Vec<CategoryRow> {
.into_iter()
.map(|name| CategoryRow {
name: name.to_string(),
display: category_display(model, name).to_string(),
items: model.effective_item_names(name),
// `try_axis_of`, not `axis_of`: a view that has never seen a category
// has no entry and `axis_of` panics. Unseen shows as `None`.
@@ -463,6 +469,7 @@ pub fn view_summaries(app: &App) -> Vec<ViewSummary> {
.iter()
.map(|(name, view)| ViewSummary {
name: name.clone(),
display: view.display().to_string(),
axes: view
.category_axes
.iter()
@@ -484,6 +491,7 @@ pub fn tiles_of(model: &Model, view: &View) -> Vec<TileEntry> {
.into_iter()
.map(|cat| TileEntry {
category: cat.to_string(),
display: category_display(model, cat).to_string(),
axis: view.try_axis_of(cat).unwrap_or(Axis::None),
kind: model
.category(cat)
@@ -1793,6 +1801,46 @@ mod tests {
app
}
/// One category and the active view each shown under a display that differs from their key.
fn app_with_displays_unlike_their_keys() -> App {
let mut app = app_with_data();
let wb = &mut app.model_state.workbook;
wb.model
.category_mut("Region")
.unwrap()
.set_display("Sales Area");
wb.active_view_mut().set_display("Overview");
app
}
#[test]
fn the_category_panel_shows_a_display_and_keeps_the_key_commands_name() {
let app = app_with_displays_unlike_their_keys();
let rows = category_rows(&app.model_state.workbook.model, app.view());
let region = rows.iter().find(|r| r.name == "Region").unwrap();
assert_eq!(region.display, "Sales Area");
let measure = rows.iter().find(|r| r.name == "_Measure").unwrap();
assert_eq!(measure.display, "_Measure");
}
#[test]
fn a_tile_shows_a_display_and_keeps_the_key_commands_name() {
let app = app_with_displays_unlike_their_keys();
let tiles = tiles_of(&app.model_state.workbook.model, app.view());
let region = tiles.iter().find(|t| t.category == "Region").unwrap();
assert_eq!(region.display, "Sales Area");
}
#[test]
fn the_view_list_shows_a_display_and_keeps_the_key_commands_name() {
let app = app_with_displays_unlike_their_keys();
let active = app.model_state.workbook.active_view.clone();
let summaries = view_summaries(&app);
let summary = summaries.iter().find(|s| s.name == active).unwrap();
assert_eq!(summary.display, "Overview");
assert_ne!(summary.name, "Overview");
}
/// A pivot-shaped default view plus a records-shaped second one, so a
/// switch between them crosses the boundary `settled_mode` watches.
fn app_with_a_records_shaped_second_view() -> App {
+45 -2
View File
@@ -8,19 +8,27 @@ pub type ItemId = usize;
pub struct Item {
pub id: ItemId,
pub name: String,
/// What a person sees; the key until a rename (ADR 007 clause 2).
display: String,
/// Parent group name, if any
pub group: Option<String>,
}
impl Item {
pub fn new(id: ItemId, name: impl Into<String>) -> Self {
let name = name.into();
Self {
id,
name: name.into(),
display: name.clone(),
name,
group: None,
}
}
pub fn display(&self) -> &str {
&self.display
}
pub fn with_group(mut self, group: impl Into<String>) -> Self {
self.group = Some(group.into());
self
@@ -129,6 +137,8 @@ impl CategoryKind {
pub struct Category {
pub id: CategoryId,
pub name: String,
/// What a person sees; the key until a rename (ADR 007 clause 2).
display: String,
/// Items in insertion order
pub items: IndexMap<String, Item>,
/// Named groups (hierarchy nodes)
@@ -141,9 +151,11 @@ pub struct Category {
impl Category {
pub fn new(id: CategoryId, name: impl Into<String>) -> Self {
let name = name.into();
Self {
id,
name: name.into(),
display: name.clone(),
name,
items: IndexMap::new(),
groups: Vec::new(),
next_item_id: 0,
@@ -151,6 +163,23 @@ impl Category {
}
}
pub fn display(&self) -> &str {
&self.display
}
/// Changes what a person sees and nothing else: the key does not move.
pub fn set_display(&mut self, display: impl Into<String>) {
self.display = display.into();
}
/// Changes an item's display; false when the category has no such item.
pub fn set_item_display(&mut self, item: &str, display: impl Into<String>) -> bool {
self.items
.get_mut(item)
.map(|i| i.display = display.into())
.is_some()
}
pub fn with_kind(mut self, kind: CategoryKind) -> Self {
self.kind = kind;
self
@@ -264,6 +293,20 @@ mod tests {
Category::new(0, "Region")
}
#[test]
fn a_display_starts_as_the_key_and_changing_it_moves_no_key() {
let mut c = cat();
c.add_item("East");
assert_eq!(c.display(), "Region");
assert_eq!(c.items["East"].display(), "East");
c.set_display("Sales Area");
assert!(c.set_item_display("East", "Eastern"));
assert_eq!((c.name.as_str(), c.display()), ("Region", "Sales Area"));
assert_eq!(c.items["East"].display(), "Eastern");
assert_eq!(c.items["East"].name, "East");
assert!(!c.set_item_display("West", "Western"), "no such item");
}
/// improvise-2lh: call sites ask `of_name` rather than compare literals,
/// so both directions of the mapping must agree.
#[test]
+24 -1
View File
@@ -41,6 +41,8 @@ pub enum GroupFold {
#[derive(Clone)]
pub struct View {
pub name: String,
/// What a person sees; the key until a rename (ADR 007 clause 2).
display: String,
/// Axis assignment for each category
pub category_axes: IndexMap<String, Axis>,
/// For page axis: selected item per category
@@ -65,6 +67,7 @@ impl std::fmt::Debug for View {
use std::collections::{BTreeMap, BTreeSet};
let View {
name,
display,
category_axes,
page_selections,
hidden_items,
@@ -86,6 +89,7 @@ impl std::fmt::Debug for View {
.collect();
f.debug_struct("View")
.field("name", name)
.field("display", display)
.field("category_axes", category_axes)
.field("page_selections", &pages)
.field("hidden_items", &sorted(hidden_items))
@@ -103,9 +107,20 @@ impl View {
VIRTUAL_VIEW_NAMES.contains(&self.name.as_str())
}
pub fn display(&self) -> &str {
&self.display
}
/// Changes what a person sees and nothing else: the key does not move.
pub fn set_display(&mut self, display: impl Into<String>) {
self.display = display.into();
}
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
Self {
name: name.into(),
display: name.clone(),
name,
category_axes: IndexMap::new(),
page_selections: HashMap::new(),
hidden_items: HashMap::new(),
@@ -375,6 +390,14 @@ mod tests {
use crate::view::Axis;
use std::num::NonZeroUsize;
#[test]
fn a_view_display_starts_as_the_key_and_changing_it_moves_no_key() {
let mut v = View::new("Default");
assert_eq!(v.display(), "Default");
v.set_display("Overview");
assert_eq!((v.name.as_str(), v.display()), ("Default", "Overview"));
}
/// improvise-vw47: the count is the starting guess, never the answer.
/// What a taken name costs: `context/test-evidence/core.md`.
#[test]
+3
View File
@@ -514,6 +514,7 @@ mod tests {
live.fold(&Command::Project(Projection::CategoryTree(
CategoryTreeUpdate::Categories(vec![CategoryRow {
name: "Region".into(),
display: "Region".into(),
items: vec!["North".into(), "South".into()],
axis: Axis::Row,
}]),
@@ -523,11 +524,13 @@ mod tests {
])));
live.fold(&Command::Project(Projection::TileBar(vec![TileEntry {
category: "Region".into(),
display: "Region".into(),
kind: crate::CategoryKind::Regular,
axis: Axis::Row,
}])));
live.fold(&Command::Project(Projection::ViewList(vec![ViewSummary {
name: "Default".into(),
display: "Default".into(),
axes: vec![("Region".into(), Axis::Row)],
}])));
live.fold(&Command::SetSelected { row: 1, col: 1 });
+2
View File
@@ -556,10 +556,12 @@ mod tests {
Command::Project(Projection::Formulas(vec!["Profit = Revenue - Cost".into()])),
Command::Project(Projection::ViewList(vec![ViewSummary {
name: "Default".into(),
display: "Default".into(),
axes: vec![("Region".into(), Axis::Row)],
}])),
Command::Project(Projection::TileBar(vec![TileEntry {
category: "Region".into(),
display: "Region".into(),
kind: crate::CategoryKind::Regular,
axis: Axis::Page,
}])),
+13
View File
@@ -759,6 +759,8 @@ impl WidgetCache for GridCache {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CategoryRow {
pub name: String,
/// What the panel shows; `name` stays what a command names (ADR 007).
pub display: String,
pub items: Vec<String>,
pub axis: Axis,
}
@@ -857,6 +859,8 @@ impl WidgetCache for FormulaCache {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ViewSummary {
pub name: String,
/// What the view panel shows; `name` stays what a command names.
pub display: String,
/// `(category, axis)` for the categories this view displays.
pub axes: Vec<(String, Axis)>,
}
@@ -881,6 +885,8 @@ impl WidgetCache for ViewListCache {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TileEntry {
pub category: String,
/// What the tile shows; `category` stays what a command names.
pub display: String,
pub axis: Axis,
pub kind: CategoryKind,
}
@@ -1072,6 +1078,7 @@ mod tests {
fn tile(category: &str, axis: Axis, kind: CategoryKind) -> TileEntry {
TileEntry {
category: category.to_string(),
display: category.to_string(),
axis,
kind,
}
@@ -1156,6 +1163,7 @@ mod tests {
fn cat(name: &str, items: &[&str], axis: Axis) -> CategoryRow {
CategoryRow {
name: name.into(),
display: name.into(),
items: items.iter().map(|s| (*s).to_string()).collect(),
axis,
}
@@ -1801,12 +1809,14 @@ mod tests {
let views = ViewListCache::folded(&[vec![ViewSummary {
name: "Default".into(),
display: "Default".into(),
axes: vec![("Region".into(), Axis::Row)],
}]]);
assert_eq!(views.views[0].name, "Default");
let tiles = TileBarCache::folded(&[vec![TileEntry {
category: "Region".into(),
display: "Region".into(),
axis: Axis::Page,
kind: crate::CategoryKind::Regular,
}]]);
@@ -2201,10 +2211,12 @@ mod tests {
Projection::Formulas(vec!["Profit = Revenue - Cost".into()]),
Projection::ViewList(vec![ViewSummary {
name: "Default".into(),
display: "Default".into(),
axes: vec![("Region".into(), Axis::None)],
}]),
Projection::TileBar(vec![TileEntry {
category: "Region".into(),
display: "Region".into(),
axis: Axis::Page,
kind: crate::CategoryKind::Regular,
}]),
@@ -2242,6 +2254,7 @@ mod tests {
Projection::Formulas(vec!["Profit = Revenue - Cost".into()]),
Projection::TileBar(vec![TileEntry {
category: "Region".into(),
display: "Region".into(),
axis: Axis::Page,
kind: crate::CategoryKind::Regular,
}]),
+10
View File
@@ -1948,11 +1948,13 @@ mod tests {
crate::CategoryTreeUpdate::Categories(vec![
crate::CategoryRow {
name: "Region".into(),
display: "Region".into(),
items: vec!["North".into(), "South".into()],
axis: Axis::Row,
},
crate::CategoryRow {
name: "Product".into(),
display: "Product".into(),
items: vec!["Shirts".into()],
axis: Axis::Column,
},
@@ -2031,11 +2033,13 @@ mod tests {
state.tiles.tiles = vec![
crate::TileEntry {
category: "Region".into(),
display: "Region".into(),
axis: Axis::Row,
kind: crate::CategoryKind::Regular,
},
crate::TileEntry {
category: "Product".into(),
display: "Product".into(),
axis: Axis::Column,
kind: crate::CategoryKind::Regular,
},
@@ -2390,10 +2394,12 @@ mod tests {
state.fold(&Command::Project(Projection::ViewList(vec![
crate::ViewSummary {
name: "Default".into(),
display: "Default".into(),
axes: vec![],
},
crate::ViewSummary {
name: "Plan".into(),
display: "Plan".into(),
axes: vec![],
},
])));
@@ -2673,6 +2679,7 @@ mod tests {
.into_iter()
.map(|name| crate::CategoryRow {
name: name.into(),
display: name.into(),
items: vec!["North".into(), "South".into()],
axis: Axis::Row,
})
@@ -2691,6 +2698,7 @@ mod tests {
.into_iter()
.map(|name| crate::ViewSummary {
name: name.into(),
display: name.into(),
axes: Vec::new(),
})
.collect();
@@ -2698,6 +2706,7 @@ mod tests {
.into_iter()
.map(|category| crate::TileEntry {
category: category.into(),
display: category.into(),
axis: Axis::Row,
kind: crate::CategoryKind::Regular,
})
@@ -3818,6 +3827,7 @@ mod tests {
let mut tiles = in_mode(AppMode::TileSelect);
tiles.tiles.tiles = vec![crate::TileEntry {
category: "Region".into(),
display: "Region".into(),
axis: improvise_core::view::Axis::Row,
kind: crate::CategoryKind::Regular,
}];
+5 -1
View File
@@ -11,9 +11,10 @@ use crate::render::{CategoryTreeCache, ViewSummary};
/// so the entry list *is* what the panel draws.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CatTreeEntry {
/// Category header row: name, item count, expanded?
/// Category header row: name (the key), what it shows, item count, expanded?
Category {
name: String,
display: String,
item_count: usize,
expanded: bool,
axis: Axis,
@@ -49,6 +50,7 @@ pub fn flatten_cat_tree(
let is_expanded = expanded.contains(&row.name);
entries.push(CatTreeEntry::Category {
name: row.name.clone(),
display: row.display.clone(),
item_count: row.items.len(),
expanded: is_expanded,
axis: row.axis,
@@ -94,6 +96,7 @@ mod tests {
.iter()
.map(|(name, items)| CategoryRow {
name: (*name).to_string(),
display: (*name).to_string(),
items: items.iter().map(|i| i.to_string()).collect(),
axis: Axis::None,
})
@@ -112,6 +115,7 @@ mod tests {
tree,
vec![CatTreeEntry::Category {
name: "Region".into(),
display: "Region".into(),
item_count: 2,
expanded: false,
axis: Axis::None,
+46 -4
View File
@@ -361,7 +361,7 @@ fn view_rows(state: &ClientState) -> Option<Vec<RenderViewRow>> {
.iter()
.enumerate()
.map(|(i, summary)| RenderViewRow {
name: summary.name.clone(),
name: summary.display.clone(),
axes: improvise_protocol::viewmodel::view_axis_summary(summary),
active: summary.name == state.view.active_view,
selected: i == state.view.view_panel_cursor,
@@ -396,7 +396,7 @@ fn tile_row(state: &ClientState) -> Vec<RenderTile> {
.iter()
.enumerate()
.map(|(i, tile)| RenderTile {
category: tile.category.clone(),
category: tile.display.clone(),
axis: tile.axis.plain_label(),
selected: selecting && i == state.view.tile_cat_idx,
})
@@ -468,12 +468,13 @@ impl RenderCatRow {
fn of(entry: &CatTreeEntry, selected: bool) -> Self {
match entry {
CatTreeEntry::Category {
name,
display,
item_count,
expanded,
axis,
..
} => Self {
label: name.clone(),
label: display.clone(),
is_category: true,
axis: axis.initial().to_string(),
item_count: Some(*item_count),
@@ -806,11 +807,13 @@ mod tests {
CategoryTreeUpdate::Categories(vec![
CategoryRow {
name: "Region".into(),
display: "Region".into(),
items: vec!["North".into(), "South".into()],
axis: Axis::Row,
},
CategoryRow {
name: "Measure".into(),
display: "Measure".into(),
items: vec!["Revenue".into()],
axis: Axis::Column,
},
@@ -843,6 +846,39 @@ mod tests {
);
}
#[test]
fn the_panels_paint_displays_and_keep_keys_for_what_a_command_names() {
let mut client = init();
let state = client.state_mut();
state.fold(&Command::Project(Projection::CategoryTree(
CategoryTreeUpdate::Categories(vec![CategoryRow {
name: "Region".into(),
display: "Sales Area".into(),
items: vec!["North".into()],
axis: Axis::Row,
}]),
)));
state.fold(&Command::Project(Projection::ViewList(vec![ViewSummary {
name: "Default".into(),
display: "Overview".into(),
axes: vec![],
}])));
state.fold(&Command::Project(Projection::TileBar(vec![TileEntry {
category: "Region".into(),
display: "Sales Area".into(),
axis: Axis::Row,
kind: improvise_protocol::CategoryKind::Regular,
}])));
state.view.view_panel_open = true;
state.view.active_view = "Default".into();
let model = client.model();
assert_eq!(model.categories[0].label, "Sales Area");
assert_eq!(model.tiles[0].category, "Sales Area");
let views = model.views.expect("the view panel is open");
assert_eq!(views[0].name, "Overview");
assert!(views[0].active, "the active view is still found by its key");
}
fn client_with_panels() -> Client {
let mut client = init();
client
@@ -850,10 +886,12 @@ mod tests {
.fold(&Command::Project(Projection::ViewList(vec![
ViewSummary {
name: "Default".into(),
display: "Default".into(),
axes: vec![("Region".into(), Axis::Row), ("_Dim".into(), Axis::Column)],
},
ViewSummary {
name: "Plan".into(),
display: "Plan".into(),
axes: vec![],
},
])));
@@ -867,11 +905,13 @@ mod tests {
.fold(&Command::Project(Projection::TileBar(vec![
TileEntry {
category: "Region".into(),
display: "Region".into(),
axis: Axis::Row,
kind: improvise_protocol::CategoryKind::Regular,
},
TileEntry {
category: "Product".into(),
display: "Product".into(),
axis: Axis::None,
kind: improvise_protocol::CategoryKind::Regular,
},
@@ -1192,11 +1232,13 @@ mod tests {
.fold(&Command::Project(Projection::TileBar(vec![
TileEntry {
category: "Quarter".into(),
display: "Quarter".into(),
axis: Axis::Page,
kind: improvise_protocol::CategoryKind::Regular,
},
TileEntry {
category: "Region".into(),
display: "Region".into(),
axis: Axis::Column,
kind: improvise_protocol::CategoryKind::Regular,
},
+320
View File
@@ -0,0 +1,320 @@
#!/usr/bin/env bash
# Stack-agnostic agent bootstrap for this repository.
#
# This script prepares any coding agent (Claude Code, Cursor, etc.) to work
# on this repo. It handles:
# 1. Nix flake input seeding (so `nix develop` works in cloud environments)
# 2. bd (beads) installation and pin verification
# 3. Issue tracker sync from the Dolt remote
# 4. Optional cargo workspace warming (inside nix develop)
#
# Usage:
# scripts/agent-bootstrap.sh [--skip-cargo-warm] [--skip-nix-seed]
#
# The script is idempotent and safe to re-run. Nothing here writes to tracked
# files, stages anything, or creates a commit.
#
# IMPORTANT: This repo requires `nix develop` (or direnv) for builds/tests.
# Bare system cargo that fails edition2024 is a misconfigured session.
#
# See also: `.claude/hooks/session-start.sh` (Claude Code wrapper),
# `.cursor/environment.json` (Cursor cloud config)
set -uo pipefail
SKIP_CARGO_WARM=0
SKIP_NIX_SEED=0
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-cargo-warm) SKIP_CARGO_WARM=1; shift ;;
--skip-nix-seed) SKIP_NIX_SEED=1; shift ;;
*) shift ;;
esac
done
ROOT="${AGENT_PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}}"
cd "$ROOT" || exit 0
log() { printf '[agent-bootstrap] %s\n' "$*"; }
nix_status=0
ensure_nix_daemon() {
command -v nix >/dev/null 2>&1 || return 1
# Single-user Nix does not need a daemon; multi-user/cloud installs do.
# If the store already answers, leave the environment alone.
if nix --extra-experimental-features 'nix-command' store ping >/dev/null 2>&1; then
return 0
fi
if ! command -v nix-daemon >/dev/null 2>&1; then
log "ERROR: Nix is installed, but the store is unavailable and nix-daemon is not on PATH"
return 1
fi
log "starting nix-daemon for bootstrap…"
nix-daemon >/tmp/nix-daemon.log 2>&1 &
# The daemon is started asynchronously; wait briefly for its socket before
# invoking the seed/build steps that depend on it.
for _ in {1..50}; do
if nix --extra-experimental-features 'nix-command' store ping --store daemon >/dev/null 2>&1; then
return 0
fi
sleep 0.1
done
log "ERROR: nix-daemon did not become ready; see /tmp/nix-daemon.log"
return 1
}
# Start the daemon during cloud-agent installation, before any required Nix
# operation. This is harmless for single-user Nix because nix-store --ping
# succeeds without starting a daemon.
if [ "$SKIP_NIX_SEED" -eq 0 ] || [ "$SKIP_CARGO_WARM" -eq 0 ]; then
if ! ensure_nix_daemon; then
nix_status=1
fi
fi
# ── 0. Seed nix flake inputs (required for cloud environments) ─────────────
# GitHub tarball endpoints are blocked in cloud containers. This seeds the
# flake inputs over git so `nix develop` can build the dev shell.
if [ "$SKIP_NIX_SEED" -eq 0 ]; then
if [ "$nix_status" -ne 0 ]; then
log "ERROR: cannot seed flake inputs because Nix is unavailable"
elif ! command -v nix >/dev/null 2>&1; then
log "ERROR: nix not available; required flake input seeding cannot run"
nix_status=1
else
# Set SSL cert for proxy environments
if [ -z "${NIX_SSL_CERT_FILE:-}" ] && [ -r /root/.ccr/ca-bundle.crt ]; then
export NIX_SSL_CERT_FILE=/root/.ccr/ca-bundle.crt
fi
if [ ! -f "$ROOT/scripts/nix-seed-flake-inputs.sh" ]; then
log "ERROR: required scripts/nix-seed-flake-inputs.sh is missing"
nix_status=1
else
log "seeding nix flake inputs (GitHub tarballs blocked in cloud)…"
if "$ROOT/scripts/nix-seed-flake-inputs.sh" >/tmp/nix-seed.log 2>&1; then
log "flake inputs seeded"
else
log "ERROR: nix-seed-flake-inputs.sh failed; see /tmp/nix-seed.log"
log " 'nix develop' is not validated for this session."
nix_status=1
fi
fi
fi
fi
# ── 1. The bd on PATH must be the bd flake.nix pins ────────────────────────
beads_pin() {
sed -n 's|.*beads\.url[[:space:]]*=[[:space:]]*"github:[^/]*/[^/]*/v\{0,1\}\([^"]*\)".*|\1|p' \
flake.nix 2>/dev/null | head -1
}
bd_running_version() {
bd version 2>/dev/null | sed -n 's/^bd version \([0-9][0-9.]*\).*/\1/p' | head -1
}
bd_pin_banner() {
{
printf '\n[agent-bootstrap] ===== BEADS VERSION SKEW =====\n'
printf '[agent-bootstrap] %s\n' "$@"
printf '[agent-bootstrap] =============================\n\n'
} >&2
}
assert_bd_pin() {
command -v bd >/dev/null 2>&1 || return 0
pinned=$(beads_pin)
running=$(bd_running_version)
case "$pinned" in
[0-9]*.[0-9]*.[0-9]*) ;;
*)
bd_pin_banner "cannot read the beads pin out of flake.nix" \
"read: '${pinned:-<nothing>}'" \
"The pin moved to a ref this hook cannot parse (a branch or a" \
"commit, perhaps). Teach beads_pin() to read it, or the check" \
"silently protects nothing."
return 1
;;
esac
if [ -z "$running" ]; then
bd_pin_banner "cannot read the version of the bd on PATH" \
"bd: $(command -v bd)" \
"'bd version' did not answer in the expected shape. Until it does," \
"nothing here can tell whether that build matches the pin."
return 1
fi
[ "$running" = "$pinned" ] && return 0
bd_pin_banner "the bd on PATH is NOT the bd this repo pins" \
"pinned (flake.nix): $pinned" \
"running: $running ($(command -v bd))" \
"" \
"Reading is safe. Do NOT run a mutating bd command — create, update," \
"close, dep, dolt push. Version skew between builds empties the" \
"dependency graph in .beads/issues.jsonl and reports success." \
"" \
"Fix now: nix develop (in this directory), then re-run the" \
" command; or 'direnv allow' here." \
"Fix for good: worktrees are created fresh per dispatch, so allowing" \
" them one at a time never catches the next one. Put the" \
" repo root under [whitelist] prefix in" \
" ~/.config/direnv/direnv.toml."
return 1
}
pin_status=0
assert_bd_pin || pin_status=2
# Install a verified database at .beads/embeddeddolt.
install_tracker() {
staged=$1
live=.beads/embeddeddolt
incoming=$live.incoming
previous=$live.previous
rm -rf "$incoming" "$previous" || return 1
cp -rf "$staged" "$incoming" || { rm -rf "$incoming"; return 1; }
if [ -d "$live" ] && ! mv -f "$live" "$previous"; then
rm -rf "$incoming"
return 1
fi
if mv -f "$incoming" "$live"; then
rm -rf "$previous"
return 0
fi
if [ -d "$previous" ] && ! mv -f "$previous" "$live"; then
log "ERROR: tracker left at $previous — move it back to $live by hand"
fi
rm -rf "$incoming"
return 1
}
# ── 2. bd on PATH ──────────────────────────────────────────────────────────
export PATH="$PATH:/root/go/bin:$HOME/go/bin"
BD_INSTALLER_REF=f45b249ce6b40ba62aecc03949e6371e8f7c79d8 # tag v1.3.0
BD_INSTALLER_URL="https://raw.githubusercontent.com/steveyegge/beads/$BD_INSTALLER_REF/scripts/install.sh"
if ! command -v bd >/dev/null 2>&1; then
log "installing bd (beads v1.3.0)…"
if ! curl -fsSL "$BD_INSTALLER_URL" -o /tmp/bd-install.sh 2>/tmp/bd-install.log; then
log "WARNING: could not download the bd installer; see /tmp/bd-install.log."
elif bash /tmp/bd-install.sh >>/tmp/bd-install.log 2>&1; then
log "bd installed"
else
log "WARNING: bd install failed; see /tmp/bd-install.log (the script that"
log " ran is at /tmp/bd-install.sh). Issue tracking unavailable."
fi
fi
# Export PATH for the calling environment if AGENT_ENV_FILE is set
if [ -n "${AGENT_ENV_FILE:-${CLAUDE_ENV_FILE:-}}" ]; then
env_file="${AGENT_ENV_FILE:-${CLAUDE_ENV_FILE}}"
echo 'export PATH="$PATH:/root/go/bin:$HOME/go/bin"' >> "$env_file"
fi
if [ "$pin_status" -eq 0 ]; then
assert_bd_pin || pin_status=2
fi
# Do not run any tracker/config mutation through a bd binary whose version does
# not match the repository pin.
if [ "$pin_status" -eq 0 ] && command -v bd >/dev/null 2>&1; then
bd metrics off >/dev/null 2>&1 || true
fi
# ── 3. Sync the issue tracker from the Dolt remote ─────────────────────────
if [ "$pin_status" -eq 0 ] && command -v bd >/dev/null 2>&1; then
DB_NAME="$(jq -r '.dolt_database // "improvise"' .beads/metadata.json 2>/dev/null || echo improvise)"
ORIGIN="$(git remote get-url origin 2>/dev/null || true)"
case "$ORIGIN" in
git@github.com:*) ORIGIN="https://github.com/${ORIGIN#git@github.com:}" ;;
esac
ORIGIN="${ORIGIN%.git}"
if [ -n "$ORIGIN" ] && TMP=$(mktemp -d) && [ -d "$TMP" ]; then
log "syncing issue tracker from $ORIGIN …"
STAGE="$TMP/$DB_NAME"
mkdir -p "$STAGE"
sync_ok=0
if (
cd "$STAGE" && git init -q . &&
BD_NON_INTERACTIVE=1 bd init --non-interactive --skip-agents --skip-hooks -q \
--remote "git+$ORIGIN"
) >/tmp/bd-sync.log 2>&1 && [ -d "$STAGE/.beads/embeddeddolt" ]; then
listing="$( (cd "$STAGE" && bd list --all 2>/dev/null) || true )"
case "$listing" in
"" | "No issues found."*) ;;
*) sync_ok=1 ;;
esac
fi
if [ "$sync_ok" -eq 1 ] && install_tracker "$STAGE/.beads/embeddeddolt"; then
log "issue tracker synced — 'bd ready' now reflects the remote, not issues.jsonl"
elif [ -d .beads/embeddeddolt ]; then
log "WARNING: tracker sync failed (see /tmp/bd-sync.log); kept the previous database."
log " It may be out of date — verify any bead against the code."
else
log "WARNING: tracker sync failed; see /tmp/bd-sync.log."
log " .beads/issues.jsonl is a STALE export — verify any bead against the code."
fi
rm -rf "$TMP"
elif [ -n "$ORIGIN" ]; then
log "WARNING: could not create a temporary directory; skipped the tracker sync."
log " .beads/issues.jsonl is a STALE export — verify any bead against the code."
fi
fi
# ── 4. Warm the build inside nix develop (optional) ────────────────────────
# Uses nix develop to ensure the correct toolchain (rust from flake, wasm target, etc.)
if [ "$SKIP_CARGO_WARM" -eq 0 ]; then
if [ "$nix_status" -ne 0 ]; then
log "ERROR: skipping required cargo warm because Nix bootstrap already failed"
elif ! command -v nix >/dev/null 2>&1; then
log "ERROR: nix not available; required cargo warm cannot run"
nix_status=1
else
log "warming cargo build inside nix develop…"
NIX_FLAGS=(--extra-experimental-features 'nix-command flakes')
if nix "${NIX_FLAGS[@]}" develop --command cargo build --workspace --tests >/tmp/cargo-warm.log 2>&1; then
log "workspace built (nix develop toolchain)"
else
log "ERROR: cargo build failed; see /tmp/cargo-warm.log"
log " Required nix develop validation failed."
nix_status=1
fi
fi
fi
# ── 5. Environment notes ───────────────────────────────────────────────────
cat <<'NOTES'
[agent-bootstrap] Environment notes (see context/cloud-agents.md):
• This repo REQUIRES `nix develop` (or direnv) for builds and tests.
Bare system cargo that fails edition2024 is a misconfigured session.
• The bootstrap seeded flake inputs over git (GitHub tarballs are blocked
in cloud). If nix develop still fails, check /tmp/nix-seed.log.
• `bd dolt push` is blocked outbound (403). Bead status changes stay local.
• Never run a bare `bd init` in this repo: it rewrites AGENTS.md, CLAUDE.md
and .claude/settings.json, and commits. Use --skip-agents --skip-hooks.
• Use `scripts/kb` for knowledge base operations, not Prolog MCP.
• Run commands inside `nix develop` or prefix with `nix develop --command`.
NOTES
exit_status="$nix_status"
if [ "$pin_status" -ne 0 ]; then
exit_status="$pin_status"
fi
exit "$exit_status"
+46 -9
View File
@@ -5,36 +5,47 @@ set -euo pipefail
usage() {
cat >&2 <<'EOF'
usage: pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec>]
usage: pr-watch <pr-number> [--expect <sha>] [--timeout <sec>] [--interval <sec>] [--reviews|--no-reviews]
--expect <sha> fail if the PR head is not this (prefix ok) at any poll
--timeout <sec> give up after this long (default 1800)
--interval <sec> seconds between polls (default 20)
--reviews after checks complete, print reviews/comments (default)
--no-reviews skip the reviews/comments printout
exit 0 every check COMPLETED, none failed, and GitHub says MERGEABLE with a
state of CLEAN, UNSTABLE or HAS_HOOKS
state of CLEAN, UNSTABLE or HAS_HOOKS (and reviews fetched if --reviews)
exit 1 a check failed, or the PR is not mergeable (any other known state)
exit 2 the head moved during the wait, or is not --expect
exit 3 timed out with checks still running, or mergeability still UNKNOWN,
or still BLOCKED with every registered check green
exit 4 usage or environment error
exit 5 the PR is MERGED or CLOSED, so there is nothing left to wait for
exit 6 checks green/mergeable, but pr-reviews failed (fetch error, not "no reviews")
After exit 0, reviews and comments are printed (unless --no-reviews). Reviews are
a separate API surface from comments; Codex/ChatGPT reviews arrive as *reviews*
and are easy to miss if you only read the comment thread.
EOF
exit 4
}
PR=""; EXPECT=""; TIMEOUT=1800; INTERVAL=20
PR=""; EXPECT=""; TIMEOUT=1800; INTERVAL=20; SHOW_REVIEWS=1
while [ $# -gt 0 ]; do
case "$1" in
--expect) EXPECT="${2:-}"; shift 2 ;;
--timeout) TIMEOUT="${2:-}"; shift 2 ;;
--interval) INTERVAL="${2:-}"; shift 2 ;;
-h|--help) usage ;;
*) [ -z "$PR" ] || usage; PR="$1"; shift ;;
--expect) EXPECT="${2:-}"; shift 2 ;;
--timeout) TIMEOUT="${2:-}"; shift 2 ;;
--interval) INTERVAL="${2:-}"; shift 2 ;;
--reviews) SHOW_REVIEWS=1; shift ;;
--no-reviews) SHOW_REVIEWS=0; shift ;;
-h|--help) usage ;;
*) [ -z "$PR" ] || usage; PR="$1"; shift ;;
esac
done
[ -n "$PR" ] || usage
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# `gh` is shell-aliased to a different tool in this repo's shells, so the name
# is never trusted: a real gh is located explicitly.
fetch() {
@@ -130,4 +141,30 @@ echo "pr-watch: head=$head $mergeable/$state $total checks, $failed failed"
# An allowlist, so a state GitHub adds later fails closed. DIRTY and BLOCKED
# can wear a MERGEABLE flag: context/code/tooling.md, "pr-watch and mergeability".
[ "$mergeable" = MERGEABLE ] || exit 1
case "$state" in CLEAN|UNSTABLE|HAS_HOOKS) exit 0 ;; *) exit 1 ;; esac
case "$state" in CLEAN|UNSTABLE|HAS_HOOKS) ;; *) exit 1 ;; esac
# ── Reviews and comments ───────────────────────────────────────────────────
# Checks are green and mergeable. Show reviews/comments so the agent sees any
# feedback that needs answering before merge. This does not change the exit
# code for success -- answering reviews is your responsibility. However, if
# pr-reviews itself fails (usage error, network, etc.), we warn and exit 6.
if [ "$SHOW_REVIEWS" -eq 1 ]; then
echo ""
echo "pr-watch: fetching reviews and comments (--no-reviews to skip)…"
reviews_exit=0
if [ -n "${PR_WATCH_FIXTURE_DIR:-}" ]; then
PR_REVIEWS_FIXTURE_DIR="$PR_WATCH_FIXTURE_DIR" "$SCRIPT_DIR/pr-reviews" "$PR" || reviews_exit=$?
else
"$SCRIPT_DIR/pr-reviews" "$PR" || reviews_exit=$?
fi
echo ""
if [ "$reviews_exit" -eq 0 ]; then
echo "pr-watch: reviews shown above. Answer substantive feedback before merging."
else
echo "pr-watch: WARNING: pr-reviews failed (exit $reviews_exit). Reviews may not be shown." >&2
echo "pr-watch: Run './scripts/pr-reviews $PR' manually to fetch reviews." >&2
exit 6
fi
fi
exit 0
+71
View File
@@ -20,8 +20,39 @@ run_case() {
[ "$got" = "$want" ] && ok "$name" || bad "$name" "$got" "$want"
}
# Like run_case but writes output to a caller-provided file.
# Caller must NOT use command substitution (subshell loses ok/bad counters).
# $1 name, $2 expected exit, $3 fixture dir, $4 output file, rest: extra args
run_case_capture() {
local name="$1" want="$2" dir="$3" outfile="$4"; shift 4
PR_WATCH_FIXTURE_DIR="$dir" "$WATCH" 1 --interval 0 --timeout 1 "$@" >"$outfile" 2>&1
local got=$?
[ "$got" = "$want" ] && ok "$name" || bad "$name" "$got" "$want"
}
mk() { mkdir -p "$1"; cat > "$1/$2"; }
# Create empty pr-reviews fixtures so pr-watch can call pr-reviews in fixture mode.
# $1 dir, $2 head (must match the pr-watch fixture's headRefOid)
mk_reviews_empty() {
local dir="$1" head="$2"
mkdir -p "$dir"
printf '{"headRefOid":"%s","reviews":[]}\n' "$head" > "$dir/last.pr.json"
echo '[]' > "$dir/last.inline.json"
echo '[]' > "$dir/last.issue.json"
}
# Create pr-reviews fixtures with a review. $1 dir, $2 head, $3 review commit
mk_reviews_with_review() {
local dir="$1" head="$2" review_commit="$3"
mkdir -p "$dir"
cat > "$dir/last.pr.json" <<EOF
{"headRefOid":"$head","reviews":[{"submittedAt":"2026-09-25T12:00:00Z","state":"COMMENTED","author":{"login":"copilot"},"commit":{"oid":"$review_commit"},"body":"Test review body"}]}
EOF
echo '[]' > "$dir/last.inline.json"
echo '[]' > "$dir/last.issue.json"
}
# The state a fresh push is in: the PR exists, no check has registered yet.
# A watcher keyed on "nothing pending" calls this finished and green.
mk "$work/none" last.json <<'EOF'
@@ -43,8 +74,43 @@ mk "$work/green" last.json <<'EOF'
{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"},
{"name":"skipped","status":"COMPLETED","conclusion":"SKIPPED"}]}
EOF
mk_reviews_empty "$work/green" "aaaaaaaaaaaa"
run_case "all completed and green" 0 "$work/green"
# ── Reviews integration ────────────────────────────────────────────────────
# pr-watch now calls pr-reviews after checks are green. Test that it works.
mk "$work/green-reviews" last.json <<'EOF'
{"headRefOid":"aaaaaaaaaaaa","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","statusCheckRollup":[
{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}]}
EOF
mk_reviews_with_review "$work/green-reviews" "aaaaaaaaaaaa" "aaaaaaaaaaaa"
run_case_capture "green with reviews shows review output" 0 "$work/green-reviews" "$work/out1.txt"
if grep -q "pr-reviews:" "$work/out1.txt"; then
ok "green with reviews prints pr-reviews output"
else
bad "green with reviews prints pr-reviews output" "no pr-reviews output" "pr-reviews: in output"
fi
# --no-reviews skips the review printout
run_case_capture "--no-reviews skips review output" 0 "$work/green-reviews" "$work/out2.txt" --no-reviews
if grep -q "pr-reviews:" "$work/out2.txt"; then
bad "--no-reviews skips review output" "pr-reviews in output" "no pr-reviews"
else
ok "--no-reviews actually skips review output"
fi
# pr-reviews failure: if pr-reviews fails, pr-watch exits 6 (not 0)
# Create a green fixture with MALFORMED review fixtures so pr-reviews fails
mk "$work/green-bad-reviews" last.json <<'EOF'
{"headRefOid":"aaaaaaaaaaaa","mergeable":"MERGEABLE","mergeStateStatus":"CLEAN","statusCheckRollup":[
{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}]}
EOF
# Create malformed JSON that will cause jq to fail in pr-reviews
echo 'NOT VALID JSON' > "$work/green-bad-reviews/last.pr.json"
echo '[]' > "$work/green-bad-reviews/last.inline.json"
echo '[]' > "$work/green-bad-reviews/last.issue.json"
run_case "pr-reviews failure exits 6" 6 "$work/green-bad-reviews"
mk "$work/red" last.json <<'EOF'
{"headRefOid":"aaaaaaaaaaaa","mergeable":"MERGEABLE","mergeStateStatus":"UNSTABLE","statusCheckRollup":[
{"name":"build","status":"COMPLETED","conclusion":"FAILURE"}]}
@@ -112,6 +178,10 @@ for m in MERGEABLE CONFLICTING UNKNOWN; do
fi
sv="\"$s\""; [ "$s" = null ] && sv=null
state "$work/table-$m-$s" last.json "\"$m\"" "$sv" "$green_rollup"
# Add review fixtures for cases that exit 0 (pr-watch calls pr-reviews)
if [ "$want" = 0 ]; then
mk_reviews_empty "$work/table-$m-$s" "aaaaaaaaaaaa"
fi
run_case "table: $m/$s" "$want" "$work/table-$m-$s" --timeout 0
done
done
@@ -148,6 +218,7 @@ gap_state "$work/gap" 1.json MERGEABLE BLOCKED "$not_yet | $build_running"
gap_state "$work/gap" 2.json MERGEABLE BLOCKED "$not_yet"
gap_state "$work/gap" 3.json MERGEABLE BLOCKED "$harness_running"
gap_state "$work/gap" last.json MERGEABLE CLEAN '.'
mk_reviews_empty "$work/gap" "aaaaaaaaaaaa"
run_case "the needs-gap before the required check registers is not a failure" 0 "$work/gap" --timeout 60
gap_state "$work/gap-never" last.json MERGEABLE BLOCKED "$not_yet"
+27 -2
View File
@@ -73,14 +73,15 @@ impl PanelContent for CategoryContent {
match &self.tree[index] {
CatTreeEntry::Category {
name,
display,
item_count,
expanded,
axis,
..
} => {
let indicator = if *expanded { "▼" } else { "▶" };
let (axis_str, axis_style) = axis_display(*axis);
let name_part = format!("{indicator} {name} ({item_count})");
let name_part = format!("{indicator} {display} ({item_count})");
let axis_part = format!(" [{axis_str}]");
buf.set_string(inner.x, y, &name_part, base_style);
@@ -114,6 +115,7 @@ mod tests {
let cache = CategoryTreeCache {
categories: vec![CategoryRow {
name: name.to_string(),
display: name.to_string(),
axis: Axis::Row,
items: vec!["x".to_string()],
}],
@@ -129,6 +131,29 @@ mod tests {
buf.content()[31..59].iter().map(|c| c.symbol()).collect()
}
#[test]
fn a_category_header_draws_its_display_not_its_key() {
let cache = CategoryTreeCache {
categories: vec![CategoryRow {
name: "Region".to_string(),
display: "Sales Area".to_string(),
axis: Axis::Row,
items: vec!["x".to_string()],
}],
};
let area = Rect::new(0, 0, 30, 4);
let mut buf = Buffer::empty(area);
Panel::new(
CategoryContent::new(&cache, &Default::default()),
&AppMode::Normal,
0,
)
.render(area, &mut buf);
let row: String = buf.content()[31..59].iter().map(|c| c.symbol()).collect();
assert!(row.contains("Sales Area"), "{row}");
assert!(!row.contains("Region"), "{row}");
}
/// improvise-8l0c — `context/test-evidence/tui.md`.
#[test]
fn the_axis_tag_sits_beside_a_multibyte_category_name() {
+18 -1
View File
@@ -79,7 +79,7 @@ impl<'a> Widget for TileBar<'a> {
.iter()
.map(|tile| {
let (axis_symbol, _) = TileBar::axis_display(tile.axis);
format!(" [{} {axis_symbol}] ", tile.category)
format!(" [{} {axis_symbol}] ", tile.display)
})
.collect();
let widths: Vec<u16> = labels.iter().map(|l| l.width() as u16).collect();
@@ -190,6 +190,23 @@ mod tests {
assert_eq!(was, now);
}
#[test]
fn a_tile_draws_its_display_not_its_key() {
let mut app = app_with_two_categories();
let wb = &mut app.model_state.workbook;
wb.model
.category_mut("Region")
.unwrap()
.set_display("Sales Area");
let cache = project::tile_bar_of(&app);
let area = Rect::new(0, 0, 80, 1);
let mut buf = Buffer::empty(area);
TileBar::new(&cache, &AppMode::Normal, 0).render(area, &mut buf);
let text = buf_text(&buf);
assert!(text.contains("Sales Area"), "{text}");
assert!(!text.contains("Region"), "{text}");
}
/// Pins the rendered line. The equivalence test above says the *inputs*
/// are unchanged by the migration; this says the drawing of them is.
#[test]
+21 -1
View File
@@ -62,7 +62,7 @@ impl PanelContent for ViewContent<'_> {
};
let prefix = if is_active_view { "▶ " } else { " " };
let name_text = format!("{prefix}{}", summary.name);
let name_text = format!("{prefix}{}", summary.display);
let y = inner.y + index as u16;
buf.set_string(inner.x, y, &name_text, style);
@@ -98,6 +98,7 @@ mod tests {
let cache = ViewListCache {
views: vec![ViewSummary {
name: view_name.to_string(),
display: view_name.to_string(),
axes: vec![("Region".to_string(), Axis::Row)],
}],
};
@@ -119,6 +120,25 @@ mod tests {
);
}
#[test]
fn a_view_row_draws_its_display_and_marks_the_active_view_by_its_key() {
use crate::protocol::ViewSummary;
use crate::ui::panel::Panel;
use ratatui::{buffer::Buffer, widgets::Widget};
let cache = ViewListCache {
views: vec![ViewSummary {
name: "Default".to_string(),
display: "Overview".to_string(),
axes: vec![],
}],
};
let area = Rect::new(0, 0, 30, 4);
let mut buf = Buffer::empty(area);
Panel::new(ViewContent::new(&cache, "Default"), &AppMode::Normal, 0).render(area, &mut buf);
let row: String = buf.content()[31..59].iter().map(|c| c.symbol()).collect();
assert!(row.contains("▶ Overview"), "{row}");
}
fn app_with_two_views() -> App {
let mut wb = Workbook::new("Test");
wb.add_category("Region").unwrap();