diff --git a/.githooks/_reject_guard.sh b/.githooks/_reject_guard.sh new file mode 100644 index 00000000..b46a814e --- /dev/null +++ b/.githooks/_reject_guard.sh @@ -0,0 +1,56 @@ +# shellcheck shell=bash +# Shared cherry-pick reject guard. SOURCED (never executed directly) by both +# .githooks/prepare-commit-msg and .githooks/commit-msg. Git only executes files whose +# names exactly match a hook name, so this underscore-prefixed helper is ignored by git. +# +# Recovers the upstream SHA of an in-progress cherry-pick and HARD-BLOCKS (returns 1) when +# the triage ledger marks it "Won't merge". No-op for anything that is not a cherry-pick. +# Fail-safe: only a confirmed, non-overridden reject returns 1; every other path returns 0. +# +# Override an intentional re-pick with either: +# SH_CHERRYPICK_ALLOW_REJECT=1 git cherry-pick -x +# git config sh.cherrypick.blockRejects false # warn-only for this repo + +sh_cherrypick_reject_guard() { + local msgfile="${1:-}" + local gd up_sha root triage py reason rc + + gd="$(git rev-parse --absolute-git-dir 2>/dev/null)" || return 0 + + up_sha="" + if [ -f "$gd/CHERRY_PICK_HEAD" ]; then + up_sha="$(tr -d '[:space:]' <"$gd/CHERRY_PICK_HEAD" 2>/dev/null)" + fi + if [ -z "$up_sha" ] && [ -n "$msgfile" ] && [ -f "$msgfile" ]; then + up_sha="$(sed -n 's/.*cherry picked from commit \([0-9a-f]\{7,40\}\).*/\1/p' "$msgfile" | tail -1)" + fi + [ -z "$up_sha" ] && return 0 # not a cherry-pick -> no-op (normal commits untouched) + + root="$(git rev-parse --show-toplevel 2>/dev/null)" || return 0 + triage="$root/scripts/triage.py" + [ -f "$triage" ] || return 0 + py="$(command -v python3 || command -v python 2>/dev/null)" + [ -n "$py" ] || return 0 + + reason="$("$py" "$triage" check-reject "$up_sha" 2>/dev/null)" + rc=$? + [ "$rc" -eq 3 ] || return 0 # rc 0 = ok; rc 2 = ledger error -> fail-safe; only rc 3 blocks + + echo "" >&2 + echo "sh-cherrypick: BLOCKED — ${up_sha:0:12} is marked \"Won't merge\" in the triage ledger." >&2 + echo " reason: ${reason:-}" >&2 + + if [ "${SH_CHERRYPICK_ALLOW_REJECT:-}" = "1" ]; then + echo " override: SH_CHERRYPICK_ALLOW_REJECT=1 — allowing this pick." >&2 + return 0 + fi + if [ "$(git config --bool sh.cherrypick.blockRejects 2>/dev/null || echo true)" = "false" ]; then + echo " override: sh.cherrypick.blockRejects=false — warn-only, allowing." >&2 + return 0 + fi + + echo " To re-evaluate intentionally: SH_CHERRYPICK_ALLOW_REJECT=1 git cherry-pick -x " >&2 + echo " or repo-wide warn-only: git config sh.cherrypick.blockRejects false" >&2 + echo " Recover this pick: git cherry-pick --abort (or --skip)" >&2 + return 1 +} diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 00000000..9d01427d --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# sh-cherrypick commit-msg: SECONDARY reject backstop. +# +# commit-msg does NOT fire on a clean `git cherry-pick` auto-commit (that path is caught by +# prepare-commit-msg + post-commit); it does fire on the `git commit`-backed path such as +# `git cherry-pick --continue`. On that path prepare-commit-msg already runs first and blocks, +# so this hook is defense-in-depth against git versions/config where prepare-commit-msg is +# bypassed. Shares the exact same guard logic. +# +# Fail-safe: the only non-zero exit is the deliberate reject block; normal commits are no-ops. +set -uo pipefail + +# shellcheck source=_reject_guard.sh +. "$(dirname "$0")/_reject_guard.sh" + +sh_cherrypick_reject_guard "${1:-}" || exit 1 +exit 0 diff --git a/.githooks/post-commit b/.githooks/post-commit new file mode 100755 index 00000000..bc45e2ee --- /dev/null +++ b/.githooks/post-commit @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# sh-cherrypick post-commit: after a `git cherry-pick -x` lands, record the upstream SHA +# (recovered from the `(cherry picked from commit )` trailer) + the new local SHA into +# an UNTRACKED .git-local journal. Never edits tracked files. No-op for normal commits. +# Fail-safe: any internal error exits 0 so a commit is never aborted here. +set -uo pipefail + +{ + msg="$(git log -1 --format=%B 2>/dev/null)" || exit 0 + up_sha="$(printf '%s\n' "$msg" \ + | sed -n 's/.*cherry picked from commit \([0-9a-f]\{7,40\}\).*/\1/p' | tail -1)" + [ -z "$up_sha" ] && exit 0 # not a cherry-pick (or no -x) -> leave normal commits untouched + + local_sha="$(git rev-parse HEAD 2>/dev/null)" || exit 0 + gd="$(git rev-parse --absolute-git-dir 2>/dev/null)" || exit 0 + journal="$gd/sh-cherrypick-journal" + ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + tab="$(printf '\t')" + + if ! { [ -f "$journal" ] && grep -q "^${up_sha}${tab}" "$journal" 2>/dev/null; }; then + printf '%s\t%s\t%s\n' "$up_sha" "$local_sha" "$ts" >> "$journal" + fi + + n="$(grep -c . "$journal" 2>/dev/null || echo '?')" + echo "sh-cherrypick: journaled ${up_sha:0:12} (${n} pending) — run: make triage-reconcile" >&2 +} || true + +exit 0 diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 00000000..9eb24917 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# sh-cherrypick pre-push SHIM. +# +# core.hooksPath is a SINGLE directory, not a search path. Pointing this repo at .githooks/ +# SHADOWS the machine-global hook dir (~/.config/git/hooks), which holds the MANDATORY Sea +# Haven security pre-push gate. To avoid silently disabling that gate, this shim re-execs the +# global pre-push, passing through args + stdin and preserving its exit code. +GLOBAL="${SH_GLOBAL_HOOKS:-$HOME/.config/git/hooks}/pre-push" + +if [ -x "$GLOBAL" ]; then + exec "$GLOBAL" "$@" +fi + +echo "sh-cherrypick: no global pre-push at $GLOBAL — nothing to delegate to." >&2 +exit 0 diff --git a/.githooks/prepare-commit-msg b/.githooks/prepare-commit-msg new file mode 100755 index 00000000..98622bf7 --- /dev/null +++ b/.githooks/prepare-commit-msg @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# sh-cherrypick prepare-commit-msg: PRIMARY reject backstop for a raw `git cherry-pick`. +# +# This (not commit-msg) is the hook that fires on a CLEAN cherry-pick auto-commit. Git runs +# prepare-commit-msg + post-commit on a clean pick but SKIPS pre-commit/commit-msg; commit-msg +# only fires on the `git commit`-backed path (`--continue`, normal commits). So the hard-block +# for known-reject picks lives here to cover clean picks too. +# +# Fail-safe: the only non-zero exit is the deliberate reject block; normal commits are no-ops. +set -uo pipefail + +# shellcheck source=_reject_guard.sh +. "$(dirname "$0")/_reject_guard.sh" + +sh_cherrypick_reject_guard "${1:-}" || exit 1 +exit 0 diff --git a/Makefile b/Makefile index d1268071..57ca0e49 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ -.PHONY: all format format-check lint test tests integration_tests help run dev +.PHONY: all format format-check lint test tests integration_tests help run dev \ + install-hooks triage-sync triage-reconcile triage-render triage-check # Default target executed when no arguments are given to make. all: help @@ -53,6 +54,25 @@ format: format-check: uv run ruff format $(PYTHON_FILES) --check +###################### +# UPSTREAM SYNC / CHERRY-PICK TRIAGE +###################### + +install-hooks: + bash scripts/install-hooks.sh + +triage-sync: + python3 scripts/triage.py sync + +triage-reconcile: + python3 scripts/triage.py reconcile + +triage-render: + python3 scripts/triage.py generate + +triage-check: + python3 scripts/triage.py generate --check + ###################### # HELP ###################### @@ -66,3 +86,8 @@ help: @echo 'lint - run linters' @echo 'test - run unit tests' @echo 'integration_tests - run integration tests' + @echo 'install-hooks - install cherry-pick triage git hooks (per clone)' + @echo 'triage-sync - fetch upstream + add new dev..upstream/main commits as untriaged' + @echo 'triage-reconcile - drain cherry-pick journal into the triage ledger' + @echo 'triage-render - regenerate docs/upstream-sync/triage.md' + @echo 'triage-check - fail if triage.md is stale vs triage.jsonl (CI)' diff --git a/docs/upstream-sync/cherry-pick-hook-plan.md b/docs/upstream-sync/cherry-pick-hook-plan.md new file mode 100644 index 00000000..d9357403 --- /dev/null +++ b/docs/upstream-sync/cherry-pick-hook-plan.md @@ -0,0 +1,436 @@ +# Cherry-pick triage-ledger sync — design plan + +Status: **design only** (nothing here is installed or wired yet). This document is the +build spec for a git-hook mechanism that keeps the upstream-sync triage ledger in sync +during `git cherry-pick -x`, identically for a human at the terminal and for Claude Code +driving git. Companion runbook: `../../CHERRYPICK.md`. + +--- + +## 1. Problem statement and the "no cherry-pick hook exists" reality + +This is a long-lived fork of `langchain-ai/open-swe` (remote `upstream`). We pull upstream +commits one at a time via `git cherry-pick -x `. A human keeps a triage ledger at +`docs/upstream-sync/triage.md` recording, **per upstream SHA**, a disposition: + +- **Landed** — cherry-picked into the fork. +- **Won't-merge** — already-in-dev / regression / tooling-rejected (a decided *no*). +- **Deferred→\** — parked for later on a named branch. +- **Untriaged** — seen but not yet decided. + +The ledger keys every row on the **upstream SHA** because it is stable; cherry-pick rewrites +the SHA locally, so the local SHA is not a durable key. + +We want two behaviors during a cherry-pick: + +1. **Auto-land:** when a pick succeeds, move that upstream SHA into the *Landed* section + from wherever it currently sits. +2. **Reject-warning:** when someone cherry-picks a SHA the ledger marks *Won't-merge*, warn + them as early as possible (ideally before the change is applied). + +### The hard reality + +**Git has no `pre-cherry-pick` or `post-cherry-pick` hook.** The only hooks that fire during +a cherry-pick are, per successfully-applied commit: + +``` +prepare-commit-msg → commit-msg → post-commit +``` + +There is **no** native hook at the *start* of a cherry-pick and **no** hook that sees the +list of SHAs about to be picked. Everything below is designed around that fact — we do not +invent a hook that does not exist. + +> **Build correction (verified on git 2.50.1).** A *clean* cherry-pick auto-commit runs only +> `prepare-commit-msg` **and** `post-commit` — it **skips** `pre-commit` and `commit-msg`. +> `commit-msg` fires only on the `git commit`-backed path (a normal commit, or +> `git cherry-pick --continue` after a conflict). So the reject **hard-block must live in +> `prepare-commit-msg`** (fires on every pick, clean or resolved), with `commit-msg` kept only +> as a secondary backstop. The original plan named `commit-msg` as the primary gate; that would +> silently miss every clean pick. `prepare-commit-msg` returning non-zero aborts the commit +> cleanly and leaves `CHERRY_PICK_HEAD` in place, so `--abort/--skip/--continue` still recover. + +Two signals are load-bearing: + +- **`.git/CHERRY_PICK_HEAD`** exists *while a pick is in progress* and contains the **full + upstream SHA** being applied. It is present at `prepare-commit-msg` and `commit-msg` time + (before the commit object is finalized) and is **gone** by `post-commit`. +- **The `-x` trailer** `(cherry picked from commit )` is written into the commit + message by `git cherry-pick -x`. It is present in the message file at `commit-msg` time and + is recoverable from `git log -1 --format=%B` at `post-commit` time. + +So `commit-msg` is the **earliest gate that can act with knowledge of the upstream SHA**, and +`post-commit` is the **only reliable "the pick actually landed" signal**. + +--- + +## 2. Chosen architecture + +Two hooks plus a thin wrapper and a small Python CLI. Division of labour: + +| Component | Fires / runs | Job | Touches tracked files? | +|---|---|---|---| +| `.githooks/prepare-commit-msg` | per pick (incl. clean auto-commit), before commit object is finalized | Behavior #2 **primary** hard-block: recover upstream SHA, block if ledger says *Won't-merge* (override to allow) | No | +| `.githooks/commit-msg` | `git commit`-backed path only (`--continue`, normal commit) | Behavior #2 **secondary** backstop (same guard); clean picks skip this hook | No | +| `.githooks/_reject_guard.sh` | sourced by both hooks above | Shared reject-guard function (not a hook — git ignores non-hook-named files) | No | +| `.githooks/post-commit` | per pick, after commit object exists | Behavior #1: recover upstream SHA + new local SHA, append to an **untracked** `.git/` journal | No | +| `scripts/triage.py reconcile` | end of a pick run (auto under wrapper, one command otherwise) | Drain journal → set those SHAs to *Landed* in `triage.jsonl` → regenerate `triage.md` → stage both | Yes (once, as a follow-up commit) | +| `scripts/git-cp` (wrapper / `git cp` alias) | user-invoked instead of raw cherry-pick | True pre-warning: check ledger **before** any tree change; then `cherry-pick -x`; then auto-`reconcile` | via reconcile | +| `.githooks/pre-push` | on push | **Shim** that re-invokes the global Sea Haven security `pre-push` (see §6) | No | + +**Why hooks never edit the tracked ledger directly:** editing `triage.md`/`triage.jsonl` +inside a hook during a multi-pick sequence leaves the tracked file dirty *between* picks +(see §4). We avoid that entirely — hooks only ever append to an untracked `.git/`-local +journal; the tracked ledger is mutated exactly once, by an explicit `reconcile`, as its own +commit. + +### End-to-end: single pick + +``` +$ git cp -x A # wrapper (recommended). Raw `git cherry-pick -x A` also works. + │ + │ (wrapper) pre-check A against triage.jsonl + │ └─ A is Won't-merge → print reason, require --force to proceed ◄─ true pre-warning + │ + ├─ git cherry-pick -x A + │ │ applies A's diff to index/worktree + │ ├─ prepare-commit-msg (unused) + │ ├─ commit-msg CHERRY_PICK_HEAD=A present → look up A + │ │ └─ Won't-merge? loud stderr warning (exit 0 by default) + │ │ finalize commit object A' with -x trailer + │ └─ post-commit CHERRY_PICK_HEAD gone; parse -x trailer → A + │ append "AA'landed" to .git/sh-cherrypick-journal + │ + └─ (wrapper) scripts/triage.py reconcile + drain journal → triage.jsonl: A→landed (local_sha=A') + regenerate triage.md → git add both → report "1 landed; commit the ledger" +``` + +Raw `git cherry-pick -x A` runs everything except the wrapper's pre-check and the auto-reconcile; +`post-commit` still journals, and it prints `N pick(s) journaled — run: make triage-reconcile`. + +### End-to-end: multi-pick sequence `git cp -x A B C` + +``` +wrapper pre-check A,B,C ── any Won't-merge? → list them, require --force + │ +git cherry-pick -x A B C (git sequencer) + A → commit-msg(warn?) → post-commit → journal: A A' + B → commit-msg(warn?) → post-commit → journal: B B' + C → commit-msg(warn?) → post-commit → journal: C C' + │ (tracked ledger NEVER touched mid-sequence → no dirty-tree hazard, §4) + │ +wrapper → scripts/triage.py reconcile + drain {A,B,C} → triage.jsonl all→landed → regenerate triage.md → stage → one report +``` + +If the sequence stops on a conflict at B: A is already journaled. The user resolves and +`git cherry-pick --continue` (B and C journal as they land). Because reconcile is journal-driven +and idempotent, running it after the sequence finally completes lands exactly A, B, C once. +Under raw cherry-pick the user runs `make triage-reconcile` at the end; the journal survived the +conflict pause because it lives in `.git/`, untouched by the sequencer. + +--- + +## 3. Ledger data model — machine source of truth + generated markdown (**recommended**) + +**Decision: `triage.jsonl` is the source of truth; `triage.md` is generated from it.** +Do *not* have hooks parse/edit the human markdown table. + +- **`docs/upstream-sync/triage.jsonl`** — one JSON object per line, the canonical record. +- **`docs/upstream-sync/triage.md`** — generated view with a `` + banner, rendered by `scripts/triage.py render`. Grouped into the same sections/columns the + human ledger uses today (`| sha | #pr | subject | reason |`). + +Record schema (one line): + +```json +{"sha":"","pr":123,"subject":"...","disposition":"landed", + "reason":"...","deferred_branch":null,"local_sha":"", + "updated":"2026-07-02T00:00:00Z"} +``` + +`disposition ∈ {landed, wont-merge, deferred, untriaged}`; `deferred_branch` set only when +`deferred`. Render maps `deferred` rows into a `Deferred→` subsection. + +### Why not edit the markdown directly + +- Editing a human-formatted markdown table from a shell hook is the fragile path the brief + warns about: alignment, escaped pipes in subjects, multi-line reasons, section boundaries, + and hand-edits all break naive `sed`/`awk`. One malformed edit corrupts the ledger. +- JSONL is append/patch-friendly, trivially greppable (`grep '"sha":""'` for the + reject-check), has clean line-oriented diffs, and is mutated safely by a tiny Python with + real JSON parsing. The repo already has a Python `scripts/` dir and `uv`, so a + `scripts/triage.py` CLI is idiomatic here and far more robust than shell string-surgery. +- **JSONL over TSV:** dispositions carry structure (`deferred_branch`, `local_sha`, `pr`) and + `reason`/`subject` are free text that can contain tabs — TSV would need escaping rules JSONL + gives for free. +- Humans still get a readable, reviewable `triage.md` in PRs; they just edit it through + `triage.jsonl` (directly, or via `scripts/triage.py set …`). A `make triage-check` in + CI fails if `triage.md` is stale vs `triage.jsonl`, so the generated view can never drift. + +**Migration from today's markdown ledger:** a one-shot `scripts/triage.py import triage.md` +parses the current hand-written table into `triage.jsonl`, after which `triage.md` becomes a +generated artifact. This is a build task, not a runtime dependency. + +--- + +## 4. Behavior #1 — auto-move to Landed, step by step + +**SHA recovery.** `post-commit` runs after the commit object exists and `CHERRY_PICK_HEAD` is +already gone, so recover the upstream SHA from the `-x` trailer: + +```bash +msg="$(git log -1 --format=%B)" +up_sha="$(printf '%s\n' "$msg" | sed -n 's/.*cherry picked from commit \([0-9a-f]\{40\}\).*/\1/p' | tail -1)" +[ -z "$up_sha" ] && exit 0 # not a cherry-pick (or no -x) → no-op, normal commits are untouched +local_sha="$(git rev-parse HEAD)" +``` + +**Journal, don't edit.** Append to an **untracked** journal and dedupe: + +``` +.git/sh-cherrypick-journal # \t\t, one line per pick +``` + +The journal lives under `.git/` — outside version control and outside the working tree — so +it is invisible to `git status`, never conflicts with an incoming pick, and survives conflict +pauses in a sequence. `post-commit` does nothing else. + +**Multi-pick dirty-tree handling (the crux).** If the hook instead edited the tracked ledger +in place, every intermediate pick would leave `docs/upstream-sync/triage.*` modified and +unstaged. Analysis: + +- It would **not** hard-break the sequence: cherry-pick applies the *next* commit's diff to + the index, and upstream commits never touch our fork-only `docs/upstream-sync/` files, so a + dirty ledger is a file the incoming pick doesn't care about — git allows that. +- But it is still the wrong design: `git status` is polluted mid-run, the ledger edits get + interleaved with pick state, and — worst case — folding a ledger edit into a cherry-picked + commit would pollute the pristine `-x` provenance we depend on. There is also no reliable + in-hook signal for "this is the last pick" (`.git/sequencer/` is torn down racily, and a + single `git cherry-pick A` may never create a sequencer dir at all), so a hook cannot know + when to do the "final" reconcile. + +So the tracked ledger is mutated **once**, outside any hook, by `reconcile`: + +``` +scripts/triage.py reconcile + read .git/sh-cherrypick-journal + for each (upstream_sha, local_sha): triage.jsonl[sha].disposition = landed + triage.jsonl[sha].local_sha = local_sha + render triage.md from triage.jsonl + git add docs/upstream-sync/triage.jsonl docs/upstream-sync/triage.md + truncate the journal + print summary (does NOT commit — the human/agent commits the ledger separately) +``` + +`reconcile` runs automatically as the last step of the `git cp` wrapper (fully hands-off for +wrapper users and for Claude Code when it calls the wrapper). For raw `git cherry-pick`, +`post-commit` prints `N pick(s) journaled — run: make triage-reconcile`, and the user runs it +once at the end. Reconcile is idempotent: a drained journal reconciles to a no-op, and +re-landing an already-landed SHA is a no-op, so double-running is safe. + +The ledger update lands as its **own** commit, keeping cherry-picked commits pristine. + +--- + +## 5. Behavior #2 — block on known-reject: honest verdict + +> **Locked decision (overrides the recommendation below): HARD-BLOCK by default.** Picking a +> *Won't-merge* SHA is blocked by both the `git cp` pre-apply check and the +> `prepare-commit-msg` hook (the plan text below still discusses warn-only as an option; the +> shipped default is block). Documented overrides: `git cp --force`, env +> `SH_CHERRYPICK_ALLOW_REJECT=1`, or repo-wide `git config sh.cherrypick.blockRejects false`. + + +**Constraint:** by `commit-msg` the pick's diff is already staged in the index/worktree; by +`post-commit` the commit exists. **No hook can warn *before* the change is applied to the +tree** — the earliest a hook sees the SHA is `commit-msg`, and by then the diff is staged (the +commit just isn't finalized). A *true* pre-application warning is impossible with hooks alone. + +Three honest options: + +- **(a) `commit-msg` hard-block (`exit 1`).** Aborts the commit cleanly: the commit object is + not created, `CHERRY_PICK_HEAD` stays, the index holds the applied diff, and the user + recovers with `git cherry-pick --abort` / `--skip` / `--continue`. In a sequence it stops at + that commit. Downsides: it's *post-apply* (tree already changed), and hard-blocking a decided + SHA that the maintainer legitimately wants to re-pick is annoying and, if the hook ever + misfires, wedges a pick. +- **(b) `commit-msg` warn-only (`exit 0`).** Loud stderr warning with the recorded reason, but + the commit proceeds. Never wedges anything. Downside: also post-apply, and easy to miss in a + multi-pick scroll. +- **(c) Wrapper pre-check.** `git cp` reads `triage.jsonl` **before** calling cherry-pick and + refuses (or prompts) on a *Won't-merge* SHA — a **genuine pre-apply** warning, before any + tree change. Downside: only fires when people use the wrapper; raw `git cherry-pick` bypasses + it. + +**Recommendation: hook + wrapper — do both, with these defaults.** + +1. **`scripts/git-cp` wrapper (primary, true pre-warning).** Before invoking cherry-pick, look + up every requested SHA in `triage.jsonl`. If any is `wont-merge`, print the SHA, subject, + and reason and **abort** unless `--force` is passed. This is the real "stop before you apply + a known-no" guard, and it is the path we point both humans (`CHERRYPICK.md`) and Claude Code + at. Expose it as `git cp` via `git config alias.cp '!bash scripts/git-cp'`. +2. **`.githooks/commit-msg` backstop (catches raw `git cherry-pick`).** Recover the upstream + SHA from `CHERRY_PICK_HEAD` (fallback: the `-x` trailer in message file `$1`); if + `triage.jsonl` marks it `wont-merge`, print a loud stderr warning with the reason. + **Default: warn and `exit 0` (non-blocking).** Opt-in hard-block via + `git config sh.cherrypick.blockRejects true` for anyone who wants option (a). Blocking is + off by default so the hook can never wedge a legitimate pick or a normal commit. + +Rationale: the wrapper gives the *real* pre-warning we actually want; the hook guarantees that +even a raw `git cherry-pick` (or Claude Code shelling straight to git) still gets a visible +signal, without ever risking a wedged pick by default. + +--- + +## 6. Installation / bootstrapping via `core.hooksPath` + +Hooks live in an in-repo, version-controlled **`.githooks/`** so they're shared. Activation is +per-clone: + +```bash +git config core.hooksPath .githooks +``` + +Git does **not** auto-adopt a repo's `core.hooksPath` (that would let a clone run arbitrary +code on checkout), so this one line is unavoidable per clone. Automate/ship it via: + +- **`scripts/install-hooks.sh`** — sets `core.hooksPath .githooks`, `chmod +x .githooks/*`, + and prints the global-hook note below. +- **`make hooks`** target calling that script; optionally invoke it from `make install` so a + standard setup wires hooks too. Document the one line in `CHERRYPICK.md` / `README`. + +### CRITICAL: collision with the global Sea Haven security `pre-push` + +This machine has a **global** hook path already configured: + +``` +core.hooksPath = ~/.config/git/hooks # holds the mandatory Sea Haven security pre-push gate +``` + +`core.hooksPath` is **a single directory, not a search path** — a repo-level +`core.hooksPath=.githooks` **overrides** the global one for this repo and would **silently +disable the security `pre-push` gate** here. That is a compliance violation, not a cosmetic +issue. + +**Mitigation (required): ship a `pre-push` shim in `.githooks/` that re-invokes the global +hook.** + +```bash +# .githooks/pre-push +#!/usr/bin/env bash +GLOBAL="${SH_GLOBAL_HOOKS:-$HOME/.config/git/hooks}/pre-push" +[ -x "$GLOBAL" ] && exec "$GLOBAL" "$@" +exit 0 # no global hook present → succeed, don't block the push +``` + +`install-hooks.sh` must detect a pre-existing global `core.hooksPath`, confirm the shim is in +place, and warn loudly if the global security hook exists but the shim is missing. If Sea Haven +ever adds more global hooks, add a matching shim for each (or a generic dispatcher that execs +every same-named hook under the global dir). This keeps the security gate intact while the +cherry-pick hooks run. + +--- + +## 7. Failure modes and safety guarantees + +- **Never abort/corrupt a normal commit.** Every hook first checks the cherry-pick signal + (`CHERRY_PICK_HEAD` for `commit-msg`, the `-x` trailer for `post-commit`) and no-ops + instantly otherwise. A plain `git commit` never reaches ledger logic. +- **Fail safe.** All hooks run `set -uo pipefail` and wrap their body so any internal error + (missing/mangled `triage.jsonl`, no Python, unreadable journal) results in `exit 0` — a hook + failure must never abort the user's git operation. The only path that can exit non-zero is + the *opt-in* `blockRejects` block, and that is a clean, recoverable cherry-pick abort. +- **No half-written tracked ledger.** Hooks only append to the untracked `.git/` journal; the + tracked ledger changes solely inside `reconcile`, which writes `triage.jsonl` + + regenerated `triage.md` atomically (write temp → `os.replace`) and stages them. A crash + mid-reconcile leaves the journal intact (source of truth for a re-run) and the tracked files + either fully old or fully new. +- **No mid-sequence dirty-tree hazard** (see §4): the tracked ledger is never touched during a + multi-pick run. +- **Idempotent + crash-tolerant.** Journal lines are deduped by upstream SHA; reconcile on a + drained journal is a no-op; re-landing an already-landed SHA is a no-op. Safe to run twice, + and safe across a conflict pause (journal survives in `.git/`). +- **Malformed source of truth.** If `triage.jsonl` fails to parse, `reconcile` aborts *without + writing* and leaves the journal intact; `commit-msg`/`post-commit` degrade to a stderr note + and `exit 0`. +- **Unknown SHA.** A picked SHA absent from the ledger is journaled and, on reconcile, inserted + as a new `landed` row (subject/PR backfilled from `git show`), so the ledger self-heals + instead of silently dropping the pick. +- **Missing Python / hooks not installed.** If `core.hooksPath` isn't set, nothing runs and + cherry-pick behaves normally (ledger just goes stale until someone reconciles) — no breakage. + +--- + +## 8. File/directory layout and implementation checklist + +### Files to create + +``` +.githooks/ + prepare-commit-msg # behavior #2 PRIMARY hard-block (fires on clean picks too) + commit-msg # behavior #2 secondary backstop (git commit / --continue path) + _reject_guard.sh # shared guard sourced by the two hooks above (not a hook itself) + post-commit # behavior #1: recover SHA from -x trailer, append to .git journal + pre-push # SHIM → global Sea Haven security pre-push (§6) +docs/upstream-sync/ + cherry-pick-hook-plan.md # this document + triage.jsonl # SOURCE OF TRUTH (created by the import step) + triage.md # GENERATED view (do-not-edit banner) +scripts/ + triage.py # CLI: import | set | check-reject | reconcile | render | lint + git-cp # wrapper: pre-check ledger → cherry-pick -x → reconcile + install-hooks.sh # sets core.hooksPath=.githooks, verifies pre-push shim vs global +# runtime, untracked (add to .gitignore is unnecessary — it lives under .git/): +.git/sh-cherrypick-journal +``` + +### `scripts/triage.py` subcommands + +- `import ` — one-shot migration of the current hand-written ledger → `triage.jsonl`. +- `set --disposition … [--pr … --subject … --reason … --branch …]` — human/agent edit path. +- `check-reject ` — exit non-zero + print reason iff `wont-merge` (used by hook + wrapper). +- `reconcile` — drain `.git/sh-cherrypick-journal` → mark landed → render → stage → summarize. +- `render [--check]` — regenerate `triage.md`; `--check` fails if stale (for CI). +- `lint` — validate `triage.jsonl` schema/dispositions. + +### Makefile targets + +- `hooks` → `bash scripts/install-hooks.sh` +- `triage-reconcile` → `uv run scripts/triage.py reconcile` +- `triage-render` → `uv run scripts/triage.py render` +- `triage-check` → `uv run scripts/triage.py render --check` (wire into CI) + +### Build checklist (ordered, no re-deciding required) + +1. **Ledger model.** Write `scripts/triage.py` with the schema in §3; implement `render` + (grouped sections + `| sha | #pr | subject | reason |`, do-not-edit banner) and `lint`. +2. **Migrate.** `triage.py import docs/upstream-sync/triage.md` → commit `triage.jsonl` + + regenerated `triage.md`; from here `triage.md` is generated. +3. **post-commit hook.** `-x` trailer recovery (§4), append `\t\t` to + `.git/sh-cherrypick-journal` (deduped), no-op on non-cherry-pick, `exit 0` on any error, + print the "run reconcile" reminder. +4. **reconcile.** Implement `triage.py reconcile` (drain → land → atomic render → stage → + truncate journal → summary; idempotent; self-heal unknown SHAs). +5. **commit-msg hook.** Recover SHA from `CHERRY_PICK_HEAD` (fallback `-x` trailer in `$1`); + `check-reject`; default warn + `exit 0`; opt-in `sh.cherrypick.blockRejects` → `exit 1`. +6. **git-cp wrapper + alias.** Pre-check all SHAs (abort on `wont-merge` unless `--force`) → + `git cherry-pick -x "$@"` → `triage.py reconcile`. Ship `alias.cp` setup in install script. +7. **pre-push shim (§6).** `.githooks/pre-push` execs the global security hook; make it the + first thing `install-hooks.sh` verifies. +8. **install-hooks.sh + make hooks.** Set `core.hooksPath=.githooks`, `chmod +x`, set `git cp` + alias, detect/reconcile the global-hooksPath collision, warn if the security shim is missing. +9. **Docs.** Update `CHERRYPICK.md` to lead with `git cp`, note the one-time `make hooks`, and + explain the warn/block behavior. Add `make triage-check` to CI so `triage.md` never drifts. +10. **Tests.** Cover: single pick lands; multi-pick sequence lands all once; conflict-pause then + `--continue` still lands correctly; reject warning fires (warn and block modes); normal + non-cherry-pick commit is untouched; hook errors never abort git; reconcile is idempotent; + `pre-push` shim delegates to the global security hook. + +### Open items for the human to confirm before build + +- **Default reject policy:** warn-only (recommended) vs block-by-default. Plan assumes warn-only + with opt-in block. +- **Branch target for the reconcile commit:** picks land on `chore/cherry-pick-*` off `dev` + (per `CHERRYPICK.md`); confirm the ledger commit rides the same branch/PR. diff --git a/docs/upstream-sync/triage.jsonl b/docs/upstream-sync/triage.jsonl new file mode 100644 index 00000000..79ea4071 --- /dev/null +++ b/docs/upstream-sync/triage.jsonl @@ -0,0 +1,53 @@ +{"_meta": {"last_synced": "73b7d1c0", "last_synced_date": "2026-07-02"}} +{"sha": "0b76afdc", "pr": 1653, "subject": "reviews block agenda, sticky headers, diff scroll", "disposition": "landed", "reason": "", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "7530653b", "pr": 1655, "subject": "ResizeObserver settle for review scroll-to", "disposition": "landed", "reason": "", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "23bd4a63", "pr": 1660, "subject": "top padding to sticky review block header", "disposition": "landed", "reason": "", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "9e5a1924", "pr": 1656, "subject": "purge expired thread_wakeup crons", "disposition": "landed", "reason": "", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "63eb9a08", "pr": 1661, "subject": "sidebar filter popover border tokens", "disposition": "landed", "reason": "", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "bc7ce591", "pr": 1668, "subject": "preserve dashboard redirect after login", "disposition": "landed", "reason": "", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "f32e492a", "pr": 1637, "subject": "return to thread after plan approval", "disposition": "landed", "reason": "", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "7ee3e057", "pr": 1636, "subject": "make plan view mobile friendly", "disposition": "landed", "reason": "", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "6575c327", "pr": 1654, "subject": "disable React StrictMode", "disposition": "landed", "reason": "kept fork's `PwaUpdateProvider`", "branch": "cherry-pick-upstream", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "c3292d82", "pr": 1611, "subject": "bake sfw binary into sandbox image", "disposition": "wont-merge", "reason": "already in dev", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "48bf712b", "pr": 1609, "subject": "show message timestamps", "disposition": "wont-merge", "reason": "already in dev", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "85c0f63e", "pr": 1620, "subject": "clickable shared PR header", "disposition": "wont-merge", "reason": "already in dev", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "db2ae58e", "pr": 1643, "subject": "pre-bundle shiki/@pierre deps", "disposition": "wont-merge", "reason": "already in dev", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "1d9da064", "pr": 1662, "subject": "bump astral-sh/setup-uv", "disposition": "wont-merge", "reason": "dev ahead (v8.2.0, `checkout@v7`)", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "83cb40a0", "pr": 1616, "subject": "update langsmith sdk to 0.9.3", "disposition": "wont-merge", "reason": "regression — dev has 0.9.6", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "00906401", "pr": 1610, "subject": "editable plan mode", "disposition": "wont-merge", "reason": "regression — dev plan-mode supersedes", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "e5a29eca", "pr": 1613, "subject": "plan links in PR descriptions", "disposition": "wont-merge", "reason": "regression — dev has async plan-ref", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "e1d85526", "pr": 1645, "subject": "switch ui to pnpm", "disposition": "wont-merge", "reason": "tooling — fork keeps bun", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "f5670f24", "pr": 1639, "subject": "require bun for ui agent work", "disposition": "deferred", "reason": "clean new file, aligned", "branch": "PR1", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "209132d3", "pr": 1621, "subject": "durable interrupt dispatch + completion webhook", "disposition": "deferred", "reason": "investigate first — may be applied", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "02bb4dfd", "pr": 1658, "subject": "don't attach loopback run-complete webhooks", "disposition": "deferred", "reason": "", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "29015fad", "pr": 1614, "subject": "gate workflow pushes with approval", "disposition": "deferred", "reason": "", "branch": "durable-dispatch", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "546042a4", "pr": 1652, "subject": "add workflow approval UI", "disposition": "deferred", "reason": "", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "ae04b72b", "pr": 1635, "subject": "publish plans from sandbox files", "disposition": "deferred", "reason": "", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "c03a6be7", "pr": 1634, "subject": "keep plan guidance high-level", "disposition": "deferred", "reason": "", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "96cceb74", "pr": 1632, "subject": "notify Slack on plan approval", "disposition": "deferred", "reason": "", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "2f56d754", "pr": 1618, "subject": "omit plan link when no plan exists", "disposition": "deferred", "reason": "likely regression", "branch": "plan-approval", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "ee224d3e", "pr": 1650, "subject": "add Slack reaction tool", "disposition": "deferred", "reason": "", "branch": "slack-tooling", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "747ce4bb", "pr": 1638, "subject": "add Slack breakout thread tool", "disposition": "deferred", "reason": "", "branch": "slack-tooling", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "27d90ef1", "pr": 1633, "subject": "include Slack channel context in prompts", "disposition": "deferred", "reason": "", "branch": "slack-tooling", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "4cd5fa5c", "pr": 1629, "subject": "avoid recapping Slack replies", "disposition": "deferred", "reason": "", "branch": "slack-tooling", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "92dbf6f9", "pr": 1630, "subject": "update Slack trace reply on web handoff", "disposition": "deferred", "reason": "", "branch": "slack-tooling", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "bb36448b", "pr": 1627, "subject": "surface Slack thread errors", "disposition": "deferred", "reason": "", "branch": "slack-tooling", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "73b7d1c0", "pr": 1678, "subject": "fix OpenAI Responses reasoning replay", "disposition": "deferred", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "5f7c2f46", "pr": 1674, "subject": "fix Fireworks Gateway base URL", "disposition": "deferred", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "702ef908", "pr": 1673, "subject": "dedicated LangSmith gateway API key", "disposition": "deferred", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "e9dc6e01", "pr": 1671, "subject": "opt-in LangSmith LLM Gateway routing", "disposition": "deferred", "reason": "", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "289f5e3a", "pr": 1651, "subject": "add Sonnet 5 to model picker", "disposition": "landed", "reason": "already in dev; added Bedrock family fallback fix (c16fb915)", "branch": "gateway-routing", "local_sha": null, "updated": "2026-07-03T00:19:50Z"} +{"sha": "5da3d0c6", "pr": 1624, "subject": "post reviewer resolution notes verbatim", "disposition": "deferred", "reason": "", "branch": "reviewer-misc", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "69148f54", "pr": 1612, "subject": "add PR trace resolution", "disposition": "deferred", "reason": "", "branch": "reviewer-misc", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "6d125526", "pr": 1625, "subject": "stop wrapping installs in sfw", "disposition": "deferred", "reason": "", "branch": "reviewer-misc", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "320bb39a", "pr": 1657, "subject": "opt-in tracemalloc for aiohttp sessions", "disposition": "deferred", "reason": "", "branch": "reviewer-misc", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "baf0c248", "pr": 1617, "subject": "filter & grouping menu in threads sidebar", "disposition": "deferred", "reason": "~998 LOC", "branch": "own branch", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "f29868ff", "pr": 1615, "subject": "recover thread work as patch", "disposition": "deferred", "reason": "~495 LOC", "branch": "own branch", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "8e0788dc", "pr": 1631, "subject": "show queued dashboard follow-ups", "disposition": "deferred", "reason": "", "branch": "own branch", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "9c601ca1", "pr": 1648, "subject": "add Stagehand-powered browser subagent", "disposition": "deferred", "reason": "", "branch": "own branch", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "8c944381", "pr": 1622, "subject": "restore forced tool call", "disposition": "deferred", "reason": "", "branch": "own branch", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "5dc360d8", "pr": 1619, "subject": "bump langgraph-checkpoint 4.1.0→4.1.1", "disposition": "untriaged", "reason": "", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "4f913198", "pr": 1647, "subject": "widen split review diffs", "disposition": "untriaged", "reason": "", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "20f63e8c", "pr": 1646, "subject": "install missing deps before verification", "disposition": "untriaged", "reason": "", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "89f886e2", "pr": 1642, "subject": "request actions read for sandbox logs", "disposition": "untriaged", "reason": "", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} +{"sha": "2f237b53", "pr": 1626, "subject": "fall back to vision model for image threads", "disposition": "untriaged", "reason": "", "branch": "", "local_sha": null, "updated": "2026-07-02T00:00:00Z"} diff --git a/docs/upstream-sync/triage.md b/docs/upstream-sync/triage.md new file mode 100644 index 00000000..8c353489 --- /dev/null +++ b/docs/upstream-sync/triage.md @@ -0,0 +1,66 @@ + + +# Upstream triage ledger + +Commits on `upstream/main` (langchain-ai/open-swe) not yet in `dev`, and the decision on each. +Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred rows are provisional +— re-inspect before picking. See the fork-maintenance runbook in `CLAUDE.md`. + +**Last synced `upstream/main`:** `73b7d1c0` (2026-07-02) + +| sha | pr | subject | decision | why | branch | +|---|---|---|---|---|---| +| `0b76afdc` | #1653 | reviews block agenda, sticky headers, diff scroll | Landed | | cherry-pick-upstream | +| `7530653b` | #1655 | ResizeObserver settle for review scroll-to | Landed | | cherry-pick-upstream | +| `23bd4a63` | #1660 | top padding to sticky review block header | Landed | | cherry-pick-upstream | +| `9e5a1924` | #1656 | purge expired thread_wakeup crons | Landed | | cherry-pick-upstream | +| `63eb9a08` | #1661 | sidebar filter popover border tokens | Landed | | cherry-pick-upstream | +| `bc7ce591` | #1668 | preserve dashboard redirect after login | Landed | | cherry-pick-upstream | +| `f32e492a` | #1637 | return to thread after plan approval | Landed | | cherry-pick-upstream | +| `7ee3e057` | #1636 | make plan view mobile friendly | Landed | | cherry-pick-upstream | +| `6575c327` | #1654 | disable React StrictMode | Landed | kept fork's `PwaUpdateProvider` | cherry-pick-upstream | +| `289f5e3a` | #1651 | add Sonnet 5 to model picker | Landed | already in dev; added Bedrock family fallback fix (c16fb915) | gateway-routing | +| `c3292d82` | #1611 | bake sfw binary into sandbox image | Won't merge | already in dev | | +| `48bf712b` | #1609 | show message timestamps | Won't merge | already in dev | | +| `85c0f63e` | #1620 | clickable shared PR header | Won't merge | already in dev | | +| `db2ae58e` | #1643 | pre-bundle shiki/@pierre deps | Won't merge | already in dev | | +| `1d9da064` | #1662 | bump astral-sh/setup-uv | Won't merge | dev ahead (v8.2.0, `checkout@v7`) | | +| `83cb40a0` | #1616 | update langsmith sdk to 0.9.3 | Won't merge | regression — dev has 0.9.6 | | +| `00906401` | #1610 | editable plan mode | Won't merge | regression — dev plan-mode supersedes | | +| `e5a29eca` | #1613 | plan links in PR descriptions | Won't merge | regression — dev has async plan-ref | | +| `e1d85526` | #1645 | switch ui to pnpm | Won't merge | tooling — fork keeps bun | | +| `f5670f24` | #1639 | require bun for ui agent work | Deferred | clean new file, aligned | PR1 | +| `209132d3` | #1621 | durable interrupt dispatch + completion webhook | Deferred | investigate first — may be applied | durable-dispatch | +| `02bb4dfd` | #1658 | don't attach loopback run-complete webhooks | Deferred | | durable-dispatch | +| `29015fad` | #1614 | gate workflow pushes with approval | Deferred | | durable-dispatch | +| `546042a4` | #1652 | add workflow approval UI | Deferred | | plan-approval | +| `ae04b72b` | #1635 | publish plans from sandbox files | Deferred | | plan-approval | +| `c03a6be7` | #1634 | keep plan guidance high-level | Deferred | | plan-approval | +| `96cceb74` | #1632 | notify Slack on plan approval | Deferred | | plan-approval | +| `2f56d754` | #1618 | omit plan link when no plan exists | Deferred | likely regression | plan-approval | +| `ee224d3e` | #1650 | add Slack reaction tool | Deferred | | slack-tooling | +| `747ce4bb` | #1638 | add Slack breakout thread tool | Deferred | | slack-tooling | +| `27d90ef1` | #1633 | include Slack channel context in prompts | Deferred | | slack-tooling | +| `4cd5fa5c` | #1629 | avoid recapping Slack replies | Deferred | | slack-tooling | +| `92dbf6f9` | #1630 | update Slack trace reply on web handoff | Deferred | | slack-tooling | +| `bb36448b` | #1627 | surface Slack thread errors | Deferred | | slack-tooling | +| `73b7d1c0` | #1678 | fix OpenAI Responses reasoning replay | Deferred | | gateway-routing | +| `5f7c2f46` | #1674 | fix Fireworks Gateway base URL | Deferred | | gateway-routing | +| `702ef908` | #1673 | dedicated LangSmith gateway API key | Deferred | | gateway-routing | +| `e9dc6e01` | #1671 | opt-in LangSmith LLM Gateway routing | Deferred | | gateway-routing | +| `5da3d0c6` | #1624 | post reviewer resolution notes verbatim | Deferred | | reviewer-misc | +| `69148f54` | #1612 | add PR trace resolution | Deferred | | reviewer-misc | +| `6d125526` | #1625 | stop wrapping installs in sfw | Deferred | | reviewer-misc | +| `320bb39a` | #1657 | opt-in tracemalloc for aiohttp sessions | Deferred | | reviewer-misc | +| `baf0c248` | #1617 | filter & grouping menu in threads sidebar | Deferred | ~998 LOC | own branch | +| `f29868ff` | #1615 | recover thread work as patch | Deferred | ~495 LOC | own branch | +| `8e0788dc` | #1631 | show queued dashboard follow-ups | Deferred | | own branch | +| `9c601ca1` | #1648 | add Stagehand-powered browser subagent | Deferred | | own branch | +| `8c944381` | #1622 | restore forced tool call | Deferred | | own branch | +| `5dc360d8` | #1619 | bump langgraph-checkpoint 4.1.0→4.1.1 | Untriaged | | | +| `4f913198` | #1647 | widen split review diffs | Untriaged | | | +| `20f63e8c` | #1646 | install missing deps before verification | Untriaged | | | +| `89f886e2` | #1642 | request actions read for sandbox logs | Untriaged | | | +| `2f237b53` | #1626 | fall back to vision model for image threads | Untriaged | | | + +_Maintenance: after a `git sync`, add new `dev..upstream/main` SHAs as **Untriaged** (edit `triage.jsonl`) and bump "Last synced". A successful `git cherry-pick -x` auto-moves the row to **Landed** via the `post-commit` journal + `make triage-reconcile`._ diff --git a/scripts/git-cp b/scripts/git-cp new file mode 100755 index 00000000..823b93a5 --- /dev/null +++ b/scripts/git-cp @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# `git cp` wrapper — the real pre-apply guard the hooks can't be. +# +# git cp ... pre-check ledger, cherry-pick -x, auto-reconcile +# git cp --force ... pick even known "Won't merge" SHAs (documented override) +# git cp --continue|--abort|--skip|--quit forwarded to git cherry-pick +# +# Behaviour: +# 1. Resolve requested SHAs (single, list, or A^..B range) BEFORE touching the tree. +# 2. If any is disposition "wont-merge", print SHA + reason and ABORT unless --force. +# 3. git cherry-pick -x (ensures -x is present exactly once). +# 4. On success, run `scripts/triage.py reconcile` to land the picks in the ledger. +set -uo pipefail + +root="$(git rev-parse --show-toplevel 2>/dev/null)" || { + echo "git cp: not a git repository" >&2 + exit 1 +} +triage="$root/scripts/triage.py" +py="$(command -v python3 || command -v python 2>/dev/null)" + +force=0 +control=0 +passthru=() +picks=() + +for arg in "$@"; do + case "$arg" in + --force | --allow-reject) + force=1 + ;; + --continue | --abort | --skip | --quit) + control=1 + passthru+=("$arg") + ;; + -*) + passthru+=("$arg") + ;; + *) + passthru+=("$arg") + picks+=("$arg") + ;; + esac +done + +reconcile() { + if [ -n "$py" ] && [ -f "$triage" ]; then + "$py" "$triage" reconcile + else + echo "git cp: python/triage.py unavailable — skipping auto-reconcile" >&2 + fi +} + +ensure_x() { + # Prepend -x unless the caller already passed it. + for a in "${passthru[@]}"; do + [ "$a" = "-x" ] && return 0 + done + passthru=("-x" "${passthru[@]}") +} + +# Sequencer control verbs: forward and (for --continue) reconcile whatever landed. +if [ "$control" -eq 1 ]; then + git cherry-pick "${passthru[@]}" + rc=$? + if [ "$rc" -eq 0 ]; then + reconcile + fi + exit "$rc" +fi + +# Pre-apply reject check. +if [ -n "$py" ] && [ -f "$triage" ] && [ "${#picks[@]}" -gt 0 ]; then + resolved=() + for tok in "${picks[@]}"; do + if [[ "$tok" == *..* ]]; then + while IFS= read -r s; do + [ -n "$s" ] && resolved+=("$s") + done < <(git rev-list --reverse "$tok" 2>/dev/null) + else + s="$(git rev-parse --verify --quiet "${tok}^{commit}" 2>/dev/null)" && resolved+=("$s") + fi + done + + rejects=() + for s in ${resolved[@]+"${resolved[@]}"}; do + reason="$("$py" "$triage" check-reject "$s" 2>/dev/null)" + if [ "$?" -eq 3 ]; then + rejects+=("${s:0:12} $reason") + fi + done + + if [ "${#rejects[@]}" -gt 0 ]; then + echo "git cp: the following SHA(s) are marked \"Won't merge\" in the triage ledger:" >&2 + for r in "${rejects[@]}"; do + echo " ⛔ $r" >&2 + done + if [ "$force" -eq 0 ]; then + echo "" >&2 + echo "Refusing to cherry-pick a known-reject. To override intentionally: git cp --force ..." >&2 + exit 1 + fi + echo " --force set — proceeding anyway." >&2 + fi +fi + +ensure_x + +# --force must also satisfy the commit-msg backstop, so allow rejects through the hook too. +if [ "$force" -eq 1 ]; then + export SH_CHERRYPICK_ALLOW_REJECT=1 +fi + +git cherry-pick "${passthru[@]}" +rc=$? + +if [ "$rc" -eq 0 ]; then + reconcile +else + echo "" >&2 + echo "git cp: cherry-pick stopped (rc=$rc). Resolve, then: git cp --continue (or --abort/--skip)." >&2 + echo " Landed picks are journaled; reconcile runs on --continue or via make triage-reconcile." >&2 +fi +exit "$rc" diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100755 index 00000000..e84077b2 --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Install the cherry-pick triage hooks for THIS clone. +# +# What it does (per-clone; git never auto-adopts a repo's core.hooksPath): +# 0. FIRST verify the Sea Haven global security pre-push still fires through our shim. +# 1. chmod +x the hooks + scripts. +# 2. git config core.hooksPath .githooks +# 3. git config alias.cp -> scripts/git-cp +# +# Safe to run repeatedly. +set -euo pipefail + +root="$(git rev-parse --show-toplevel)" +cd "$root" + +global_dir="${SH_GLOBAL_HOOKS:-$HOME/.config/git/hooks}" +global_pp="$global_dir/pre-push" +shim="$root/.githooks/pre-push" + +echo "==> [1/4] Verifying the Sea Haven global security pre-push will still fire..." +if [ -x "$global_pp" ]; then + if [ ! -f "$shim" ]; then + echo "FATAL: global security pre-push exists ($global_pp) but the shim ($shim) is MISSING." >&2 + echo " Setting core.hooksPath=.githooks would SHADOW and silently disable the security" >&2 + echo " gate. Refusing to install. Restore .githooks/pre-push first." >&2 + exit 1 + fi + if ! grep -q "$global_dir" "$shim" && ! grep -q 'SH_GLOBAL_HOOKS' "$shim"; then + echo "FATAL: shim ($shim) does not appear to delegate to the global hook dir. Refusing." >&2 + exit 1 + fi + if ! grep -q 'exec "\$GLOBAL"' "$shim"; then + echo "FATAL: shim ($shim) does not exec the global pre-push. Refusing." >&2 + exit 1 + fi + echo " OK: .githooks/pre-push shim re-execs $global_pp — security gate preserved." +else + echo " WARN: no global security pre-push found at $global_pp." + echo " If you expected the Sea Haven security gate, investigate BEFORE pushing." +fi + +echo "==> [2/4] Marking hooks + scripts executable..." +chmod +x "$root/.githooks/"* 2>/dev/null || true +chmod +x "$root/scripts/git-cp" "$root/scripts/install-hooks.sh" 2>/dev/null || true + +echo "==> [3/4] Pointing core.hooksPath at .githooks..." +git config core.hooksPath .githooks + +echo "==> [4/4] Installing the 'git cp' alias..." +git config alias.cp '!bash "$(git rev-parse --show-toplevel)/scripts/git-cp"' + +echo "" +echo "Done. This clone now:" +echo " - journals cherry-picks (post-commit) and blocks known-rejects (commit-msg)" +echo " - runs 'git cp' as the guarded cherry-pick wrapper" +echo " - STILL runs the global security pre-push via the .githooks/pre-push shim" diff --git a/scripts/triage.py b/scripts/triage.py new file mode 100755 index 00000000..c5d56838 --- /dev/null +++ b/scripts/triage.py @@ -0,0 +1,615 @@ +#!/usr/bin/env python3 +"""Upstream cherry-pick triage ledger tool. + +Source of truth is ``docs/upstream-sync/triage.jsonl`` (one JSON object per line); +``docs/upstream-sync/triage.md`` is a GENERATED, human-readable view of it. + +Subcommands: + migrate one-shot: parse the hand-written markdown ledger -> triage.jsonl + generate [--check] render triage.jsonl -> triage.md (--check: fail if md is stale) + reconcile drain .git journal -> mark SHAs landed -> render -> git add + sync add new base..ref commits (default dev..upstream/main) as untriaged + lookup print a SHA's disposition (+ reason) + check-reject exit 3 iff the SHA is disposition "wont-merge" (used by hooks) + set ... edit a row (disposition / pr / subject / reason / branch) + +Stdlib only. Hooks call this without uv, so keep it dependency-free. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from datetime import UTC, datetime +from pathlib import Path + +DISPOSITIONS = ("landed", "wont-merge", "deferred", "untriaged") + +DISPOSITION_LABELS = { + "landed": "Landed", + "wont-merge": "Won't merge", + "deferred": "Deferred", + "untriaged": "Untriaged", +} + +# Normalized label text -> disposition. Keys are lowercased with apostrophes stripped. +_LABEL_TO_DISPOSITION = { + "landed": "landed", + "wont merge": "wont-merge", + "wontmerge": "wont-merge", + "wont-merge": "wont-merge", + "deferred": "deferred", + "untriaged": "untriaged", +} + +GENERATED_BANNER = ( + "" +) + +PREAMBLE = ( + "# Upstream triage ledger\n" + "\n" + "Commits on `upstream/main` (langchain-ai/open-swe) not yet in `dev`, and the decision on each.\n" + "Rows key on the **upstream SHA** (stable across local cherry-picks). Deferred rows are provisional\n" + "— re-inspect before picking. See the fork-maintenance runbook in `CLAUDE.md`.\n" +) + +MAINTENANCE_NOTE = ( + "_Maintenance: after a `git sync`, add new `dev..upstream/main` SHAs as **Untriaged** " + '(edit `triage.jsonl`) and bump "Last synced". A successful `git cherry-pick -x` auto-moves ' + "the row to **Landed** via the `post-commit` journal + `make triage-reconcile`._\n" +) + +JOURNAL_NAME = "sh-cherrypick-journal" + + +# --------------------------------------------------------------------------- paths + + +def repo_root() -> Path: + try: + out = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=True, + ) + return Path(out.stdout.strip()) + except Exception: + return Path(__file__).resolve().parents[1] + + +def git_dir() -> Path | None: + try: + out = subprocess.run( + ["git", "rev-parse", "--absolute-git-dir"], + capture_output=True, + text=True, + check=True, + ) + return Path(out.stdout.strip()) + except Exception: + return None + + +def jsonl_path(root: Path) -> Path: + return root / "docs" / "upstream-sync" / "triage.jsonl" + + +def md_path(root: Path) -> Path: + return root / "docs" / "upstream-sync" / "triage.md" + + +# ------------------------------------------------------------------------- records + + +def _now_iso() -> str: + return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def load_ledger(path: Path) -> tuple[dict, list[dict]]: + """Return (meta, records). First line may be a ``{"_meta": {...}}`` object.""" + meta: dict = {} + records: list[dict] = [] + if not path.exists(): + return meta, records + for lineno, raw in enumerate(path.read_text().splitlines(), start=1): + line = raw.strip() + if not line: + continue + try: + obj = json.loads(line) + except json.JSONDecodeError as exc: + raise ValueError(f"{path}:{lineno}: invalid JSON: {exc}") from exc + if "_meta" in obj: + meta = obj["_meta"] + continue + records.append(obj) + return meta, records + + +def write_ledger(path: Path, meta: dict, records: list[dict]) -> None: + lines = [json.dumps({"_meta": meta}, ensure_ascii=False)] + for rec in records: + lines.append(json.dumps(rec, ensure_ascii=False)) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text("\n".join(lines) + "\n") + os.replace(tmp, path) + + +def sha_match(a: str, b: str) -> bool: + """Prefix match either direction — ledger holds short SHAs, journal holds full SHAs.""" + a = a.lower() + b = b.lower() + return a.startswith(b) or b.startswith(a) + + +def find_record(records: list[dict], sha: str) -> dict | None: + for rec in records: + if sha_match(rec.get("sha", ""), sha): + return rec + return None + + +# ------------------------------------------------------------------------- render + + +def _disposition_rank(disp: str) -> int: + try: + return DISPOSITIONS.index(disp) + except ValueError: + return len(DISPOSITIONS) + + +def render_md(meta: dict, records: list[dict]) -> str: + last_synced = meta.get("last_synced", "") + last_synced_date = meta.get("last_synced_date", "") + synced_line = "" + if last_synced: + synced_line = f"**Last synced `upstream/main`:** `{last_synced}`" + if last_synced_date: + synced_line += f" ({last_synced_date})" + synced_line += "\n" + + ordered = sorted( + enumerate(records), + key=lambda item: (_disposition_rank(item[1].get("disposition", "")), item[0]), + ) + + rows = [ + "| sha | pr | subject | decision | why | branch |", + "|---|---|---|---|---|---|", + ] + for _, rec in ordered: + sha = rec.get("sha", "") + pr = rec.get("pr") + pr_cell = f"#{pr}" if pr not in (None, "") else "" + subject = rec.get("subject", "") or "" + decision = DISPOSITION_LABELS.get(rec.get("disposition", ""), rec.get("disposition", "")) + why = rec.get("reason", "") or "" + branch = rec.get("branch", "") or "" + cells = [f"`{sha}`", pr_cell, subject, decision, why, branch] + rows.append("| " + " | ".join(cells) + " |") + + parts = [GENERATED_BANNER, "", PREAMBLE.rstrip("\n"), ""] + if synced_line: + parts.append(synced_line.rstrip("\n")) + parts.append("") + parts.append("\n".join(rows)) + parts.append("") + parts.append(MAINTENANCE_NOTE.rstrip("\n")) + return "\n".join(parts) + "\n" + + +# ------------------------------------------------------------------------ migrate + + +_ROW_RE = re.compile(r"^\|(.+)\|\s*$") + + +def _norm_label(text: str) -> str: + return re.sub(r"[^a-z\- ]", "", text.strip().lower()).strip() + + +def parse_md_ledger(text: str) -> tuple[dict, list[dict]]: + meta: dict = {} + records: list[dict] = [] + + m = re.search(r"Last synced.*?`([0-9a-f]{6,40})`\s*(?:\(([^)]*)\))?", text) + if m: + meta["last_synced"] = m.group(1) + if m.group(2): + meta["last_synced_date"] = m.group(2) + + for raw in text.splitlines(): + m = _ROW_RE.match(raw) + if not m: + continue + cells = [c.strip() for c in m.group(1).split("|")] + if len(cells) < 6: + continue + first = cells[0].strip("`").strip().lower() + # skip header + separator rows + if first in ("sha", "") or set(cells[0]) <= {"-", " "}: + continue + if not re.fullmatch(r"[0-9a-f]{7,40}", first): + continue + sha = first + pr_raw = cells[1].lstrip("#").strip() + pr = int(pr_raw) if pr_raw.isdigit() else None + subject = cells[2].strip() + disposition = _LABEL_TO_DISPOSITION.get(_norm_label(cells[3])) + if disposition is None: + raise ValueError(f"unrecognized decision {cells[3]!r} for {sha}") + reason = cells[4].strip() + branch = cells[5].strip() + updated = ( + f"{meta.get('last_synced_date')}T00:00:00Z" + if meta.get("last_synced_date") + else _now_iso() + ) + records.append( + { + "sha": sha, + "pr": pr, + "subject": subject, + "disposition": disposition, + "reason": reason, + "branch": branch, + "local_sha": None, + "updated": updated, + } + ) + return meta, records + + +# ------------------------------------------------------------------- subcommands + + +def cmd_migrate(args: argparse.Namespace) -> int: + root = repo_root() + src = Path(args.source) + if not src.exists(): + print(f"error: {src} not found", file=sys.stderr) + return 2 + meta, records = parse_md_ledger(src.read_text()) + out = jsonl_path(root) + out.parent.mkdir(parents=True, exist_ok=True) + write_ledger(out, meta, records) + print(f"migrated {len(records)} rows -> {out}") + md_path(root).write_text(render_md(meta, records)) + print(f"rendered {md_path(root)}") + return 0 + + +def cmd_generate(args: argparse.Namespace) -> int: + root = repo_root() + jl = jsonl_path(root) + try: + meta, records = load_ledger(jl) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + rendered = render_md(meta, records) + md = md_path(root) + if args.check: + current = md.read_text() if md.exists() else "" + if current != rendered: + print( + f"error: {md} is stale vs {jl}. Run `make triage-render` and commit.", + file=sys.stderr, + ) + return 1 + print(f"ok: {md.name} is up to date with {jl.name}") + return 0 + md.write_text(rendered) + print(f"rendered {md}") + return 0 + + +def _read_journal(journal: Path) -> list[tuple[str, str, str]]: + entries: list[tuple[str, str, str]] = [] + seen: set[str] = set() + if not journal.exists(): + return entries + for raw in journal.read_text().splitlines(): + line = raw.strip() + if not line: + continue + parts = line.split("\t") + up = parts[0] + local = parts[1] if len(parts) > 1 else "" + ts = parts[2] if len(parts) > 2 else _now_iso() + if up in seen: + # keep the latest entry for a given upstream sha + entries = [e for e in entries if e[0] != up] + seen.add(up) + entries.append((up, local, ts)) + return entries + + +def cmd_reconcile(args: argparse.Namespace) -> int: + root = repo_root() + gd = git_dir() + if gd is None: + print("error: not a git repository", file=sys.stderr) + return 2 + journal = gd / JOURNAL_NAME + entries = _read_journal(journal) + if not entries: + print("reconcile: journal empty — nothing to do") + return 0 + + jl = jsonl_path(root) + try: + meta, records = load_ledger(jl) + except ValueError as exc: + print(f"error: {exc} — aborting without writing; journal preserved", file=sys.stderr) + return 2 + + landed = 0 + inserted = 0 + for up_sha, local_sha, ts in entries: + rec = find_record(records, up_sha) + if rec is None: + subject = "" + if local_sha: + try: + subject = subprocess.run( + ["git", "log", "-1", "--format=%s", local_sha], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + except Exception: + subject = "" + records.append( + { + "sha": up_sha[:8], + "pr": None, + "subject": subject, + "disposition": "landed", + "reason": "", + "branch": "", + "local_sha": local_sha or None, + "updated": ts, + } + ) + inserted += 1 + landed += 1 + continue + already = rec.get("disposition") == "landed" and rec.get("local_sha") == (local_sha or None) + if already: + continue + rec["disposition"] = "landed" + rec["local_sha"] = local_sha or None + rec["updated"] = ts + landed += 1 + + write_ledger(jl, meta, records) + md = md_path(root) + md.write_text(render_md(meta, records)) + + # stage the tracked ledger; drain the journal + try: + subprocess.run(["git", "add", str(jl), str(md)], check=True) + except Exception as exc: # noqa: BLE001 + print(f"warning: `git add` failed ({exc}); files updated but not staged", file=sys.stderr) + journal.write_text("") + + print( + f"reconcile: {landed} landed ({inserted} new), staged {jl.name} + {md.name}, " + "journal drained. Commit the ledger separately." + ) + return 0 + + +def cmd_lookup(args: argparse.Namespace) -> int: + root = repo_root() + try: + _meta, records = load_ledger(jsonl_path(root)) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + rec = find_record(records, args.sha) + if rec is None: + print("unknown") + return 0 + disp = rec.get("disposition", "") + reason = rec.get("reason", "") + print(f"{disp}\t{reason}") + return 0 + + +def cmd_check_reject(args: argparse.Namespace) -> int: + """Exit 3 iff SHA is disposition 'wont-merge'. Exit 0 otherwise. Exit 2 on ledger error.""" + root = repo_root() + try: + _meta, records = load_ledger(jsonl_path(root)) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + rec = find_record(records, args.sha) + if rec is not None and rec.get("disposition") == "wont-merge": + print(rec.get("reason", "") or "(no reason recorded)") + return 3 + return 0 + + +def cmd_set(args: argparse.Namespace) -> int: + root = repo_root() + jl = jsonl_path(root) + try: + meta, records = load_ledger(jl) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + rec = find_record(records, args.sha) + if rec is None: + rec = { + "sha": args.sha[:8], + "pr": None, + "subject": "", + "disposition": "untriaged", + "reason": "", + "branch": "", + "local_sha": None, + "updated": _now_iso(), + } + records.append(rec) + if args.disposition: + if args.disposition not in DISPOSITIONS: + print(f"error: disposition must be one of {DISPOSITIONS}", file=sys.stderr) + return 2 + rec["disposition"] = args.disposition + if args.pr is not None: + rec["pr"] = args.pr + if args.subject is not None: + rec["subject"] = args.subject + if args.reason is not None: + rec["reason"] = args.reason + if args.branch is not None: + rec["branch"] = args.branch + rec["updated"] = _now_iso() + write_ledger(jl, meta, records) + md_path(root).write_text(render_md(meta, records)) + print(f"updated {rec['sha']} -> {rec['disposition']}") + return 0 + + +def _diverged_commits(base: str, ref: str) -> list[tuple[str, int | None, str]]: + """Return ``[(short_sha, pr, subject)]`` for non-merge commits in ``base..ref``. + + Newest first, matching ``git log`` default order. ``pr`` is parsed from a + trailing ``(#1234)`` in the subject, else ``None``. + """ + out = subprocess.run( + ["git", "log", "--no-merges", "--format=%h%x09%s", f"{base}..{ref}"], + capture_output=True, + text=True, + check=True, + ).stdout + commits: list[tuple[str, int | None, str]] = [] + for line in out.splitlines(): + if "\t" not in line: + continue + sha, subject = line.split("\t", 1) + prs = re.findall(r"\(#(\d+)\)", subject) + pr = int(prs[-1]) if prs else None + commits.append((sha.strip(), pr, subject.strip())) + return commits + + +def cmd_sync(args: argparse.Namespace) -> int: + root = repo_root() + ref = args.ref + if not args.no_fetch: + remote = ref.split("/", 1)[0] if "/" in ref else "upstream" + try: + subprocess.run(["git", "fetch", remote], check=True) + except Exception as exc: # noqa: BLE001 + print(f"error: `git fetch {remote}` failed: {exc}", file=sys.stderr) + return 2 + try: + commits = _diverged_commits(args.base, ref) + except subprocess.CalledProcessError as exc: + print(f"error: `git log {args.base}..{ref}` failed: {exc}", file=sys.stderr) + return 2 + + jl = jsonl_path(root) + try: + meta, records = load_ledger(jl) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + added = 0 + # Append oldest-first so freshly-discovered rows read chronologically. + for sha, pr, subject in reversed(commits): + if find_record(records, sha) is not None: + continue + records.append( + { + "sha": sha[:8], + "pr": pr, + "subject": subject, + "disposition": "untriaged", + "reason": "", + "branch": "", + "local_sha": None, + "updated": _now_iso(), + } + ) + added += 1 + + try: + tip = subprocess.run( + ["git", "rev-parse", "--short", ref], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + meta["last_synced"] = tip + meta["last_synced_date"] = datetime.now(UTC).strftime("%Y-%m-%d") + except Exception: # noqa: BLE001 + pass + + write_ledger(jl, meta, records) + md_path(root).write_text(render_md(meta, records)) + print( + f"sync: {added} new untriaged from {args.base}..{ref}; " + f"last synced -> {meta.get('last_synced', '?')} ({len(records)} rows total). " + "Triage the new rows, then commit triage.jsonl + triage.md." + ) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("migrate", help="parse hand-written triage.md -> triage.jsonl (one-time)") + p.add_argument("source", help="path to the current triage.md") + p.set_defaults(func=cmd_migrate) + + p = sub.add_parser("generate", help="render triage.jsonl -> triage.md") + p.add_argument("--check", action="store_true", help="fail if triage.md is stale (for CI)") + p.set_defaults(func=cmd_generate) + + p = sub.add_parser("reconcile", help="drain journal, mark landed, render, stage") + p.set_defaults(func=cmd_reconcile) + + p = sub.add_parser("sync", help="add new base..ref commits as untriaged") + p.add_argument("--base", default="dev", help="base branch already in the fork (default: dev)") + p.add_argument("--ref", default="upstream/main", help="upstream ref (default: upstream/main)") + p.add_argument("--no-fetch", action="store_true", help="skip `git fetch` of the remote") + p.set_defaults(func=cmd_sync) + + p = sub.add_parser("lookup", help="print a SHA's disposition") + p.add_argument("sha") + p.set_defaults(func=cmd_lookup) + + p = sub.add_parser("check-reject", help="exit 3 iff SHA is 'wont-merge'") + p.add_argument("sha") + p.set_defaults(func=cmd_check_reject) + + p = sub.add_parser("set", help="edit or add a ledger row") + p.add_argument("sha") + p.add_argument("--disposition", choices=DISPOSITIONS) + p.add_argument("--pr", type=int) + p.add_argument("--subject") + p.add_argument("--reason") + p.add_argument("--branch") + p.set_defaults(func=cmd_set) + + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main())