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:
@@ -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
@@ -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"
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/beads
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Improvise",
|
||||
"install": "nix-daemon >/tmp/nix-daemon.log 2>&1 & ./scripts/agent-bootstrap.sh",
|
||||
"start": "nix-daemon &"
|
||||
}
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/beads
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/beads-merge
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/code-review
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/kb
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/pr-watch
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../.agents/skills/wave
|
||||
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
}])),
|
||||
|
||||
@@ -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,
|
||||
}]),
|
||||
|
||||
@@ -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,
|
||||
}];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Executable
+320
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user