diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da93a43..94831e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,11 +69,13 @@ jobs: - name: Check installer symlink safety run: ./scripts/check-install-symlinks.sh - - name: Check gs sync runs gentle-ai sync - run: bash scripts/test-gs-sync.sh - - - name: Check gs launches what gs sync updates - run: bash tests/gs_test.sh + - name: Check gs (Go) + working-directory: gs + run: | + go version + test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; } + go vet ./... + go test ./... - name: Check Pi model profiles sync run: python3 tests/sync_pi_model_profiles_test.py diff --git a/README.md b/README.md index 1bdd008..87afcde 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ dotfiles/ ├── claude/ # Claude Code statusline y themes ├── docs/ # Referencias operativas y keymaps ├── ghostty/ # Config, perfiles visuales, muxy legado y shaders +├── gs/ # gs: binario Go (sync, doctor, worktrees) → ~/.local/bin/gs ├── herdr/ # Config base y perfiles visuales ├── pi/ # Themes de Pi ├── fonts/ # Fuente RefactorIA y script de regeneración diff --git a/bin/gs b/bin/gs deleted file mode 100755 index 4b66779..0000000 --- a/bin/gs +++ /dev/null @@ -1,640 +0,0 @@ -#!/usr/bin/env bash -# gs - gentle-shell launcher plus one disposable worktree per PR for gentle-ai, gentle-shell and engram. -# Never pushes, merges, force-deletes, or edits settings. -set -euo pipefail - -CLONES="$HOME/dev/gentle" -TREES="$HOME/dev/worktrees" -REPOS="gentle-ai gentle-shell engram" -# Mac-only app clones (~/dev/gentle): synced by gs sync on non-workshop hosts, -# never touched on workshop machines. -MAC_APP_REPOS="gentle-dot gentle-shell-desktop" - -die() { printf 'gs: %s\n' "$*" >&2; exit 1; } - -usage() { - cat <<'EOF' -gs - gentle-shell on main, plus one disposable worktree per PR - - gs [pi args...] Open gentle-shell (--isolated) in the current directory. - Consumer: always the main build from gs sync. - Workshop: inside a gentle-shell clone or worktree, Pi - loads that path (gs run); elsewhere the synced clone. - Pi subcommands pass through to the isolated home: - gs install npm: (e.g. npm:pi-claude-bridge), - gs list. Run them outside a - gentle-shell checkout, or they go through gs run. - - gs pr [repo] One step to review PR : worktree (reused and - updated if it exists), then Pi on it for shell PRs - gs issue [repo] One step to start issue : race check (stops if the - issue is closed, assigned to someone other than you, - or referenced by an open PR; GS_FORCE=1 skips it), - branch /- from origin/main, - then Pi on it for shell issues - - gs new New branch from fresh origin/main - gs review Check out someone else's PR to test or review it - gs base Clean origin/main worktree, to compare failures - gs run [pi args...] Run gentle-shell --isolated with this worktree as --package-root - gs sync Bring gentle-shell, gentle-ai and engram to main, run - gentle-ai sync for managed configs, and check compatibility - (workshop: local clones; elsewhere: GitHub) - Workshop: gentle-ai sync --agents claude-code, then - gentle-shell --isolated setup (re-provisions the isolated home) - GS_SYNC_AGENTS=a,b scopes gentle-ai sync to those runtimes; - GS_SKIP_AI_SYNC=1 skips both steps (binaries only) - Then pulls the dotfiles clone (clean, on main only); the - shell profile reads it in place. GS_SKIP_DOTFILES=1 - skips this step - gs ls List open worktrees - gs done Remove the current worktree and its merged local branch - (drops odd/tasks records; refuses if real work would be lost) - gs help Show this help - - : ai | shell | engram (or the full name) - [repo] is optional inside a clone or worktree of that repo. - -Fastest start: - gs pr shell 1503 -> review a PR: Pi opens on it with a review kickoff - gs issue ai 1234 -> take an issue: branch ready, kickoff prompt, nothing claimed - gs done -> inside the worktree, after the merge - (claim, comment, label, push and merge stay yours) - Each session: one worktree, Pi loads the package from it, /reload picks up edits. - Sessions share the isolated home ~/.gentle-shell/agent (--isolated, never - --link) and subagents use the main clone. Workshop sync never runs pi update. - gs sync is separate: run it with no other session open. - -gs and gs sync share one source of truth: workshop launches the clone it syncs; -consumer launches the package behind `gentle-shell` on PATH, the one -`gentle-shell upgrade --channel main` replaces. gs sync prints "gs opens: ", -compares it with GitHub main, and exits 1 with INCOMPLETE when any step failed -or the launch target is stale. - -Machines (gs sync detects the mode): - agent-gentle workshop: clean clones in ~/dev/gentle; pr, issue, new, review, run, done - Mac, agent-dev consumer: daily innit/barbatdev/refactoria work on gentle main, no clones; - only gs sync (then restart Pi) and plain gs in the repo you work on - -Daily flow (agent-gentle): - gs sync -> morning, with no session open - gs issue shell 1503 -> one terminal per issue or PR (gs pr shell ); Pi opens there - claim the issue yourself; edit; /reload to see it live in that session only - fails? gs base shell in another terminal: same on main means not yours - commit, push, PR: yours - waiting on CI or review -> next gs issue ... in another terminal; gs ls shows them all - after the merge -> gs done inside the worktree - session closed? -> same gs issue/pr again reuses the worktree and reopens Pi -EOF -} - -repo_name() { - case "${1:-}" in - ai|gentle-ai) echo gentle-ai ;; - shell|gentle-shell) echo gentle-shell ;; - engram) echo engram ;; - dot|gentle-dot) echo gentle-dot ;; - desktop|gentle-shell-desktop) echo gentle-shell-desktop ;; - *) die "unknown repo '${1:-}' (use: ai, shell, engram, dot, desktop)" ;; - esac -} - -clone_of() { - local repo=$1 - [ -d "$CLONES/$repo/.git" ] || die "missing clean clone $CLONES/$repo" - git -C "$CLONES/$repo" fetch -q --prune origin - echo "$CLONES/$repo" -} - -install_deps() { - if [ -f "$1/pnpm-lock.yaml" ]; then (cd "$1" && pnpm install --frozen-lockfile); fi -} - -finish() { - printf '\nready: %s\n cd %s\n' "$1" "$1" -} - -# Herdr naming convention: when gs runs inside a Herdr pane (HERDR_ENV=1), -# rename the workspace, tab, and pane to " - pr/is #" so each worktree -# session is identifiable in the sidebar. Best-effort and silent: gs must never -# fail because Herdr is absent, unreachable, or rejects a label. -herdr_rename() { - [ "${HERDR_ENV:-}" = 1 ] || return 0 - command -v herdr >/dev/null 2>&1 || return 0 - local label=$1 - herdr workspace rename "${HERDR_WORKSPACE_ID:-}" "$label" >/dev/null 2>&1 || true - herdr tab rename "${HERDR_TAB_ID:-}" "$label" >/dev/null 2>&1 || true - herdr pane rename "${HERDR_PANE_ID:-}" "$label" >/dev/null 2>&1 || true -} - -# Plain gs (no worktree): label only the tab with the repo name. Same guard, -# same silence; outside a git repo it does nothing. -herdr_tab_repo() { - [ "${HERDR_ENV:-}" = 1 ] || return 0 - command -v herdr >/dev/null 2>&1 || return 0 - local url name - url=$(git remote get-url origin 2>/dev/null) || return 0 - name=$(basename "$url"); name=${name%.git} - herdr tab rename "${HERDR_TAB_ID:-}" "$name" >/dev/null 2>&1 || true -} - -cmd_new() { - [ $# -eq 2 ] || die "usage: gs new " - local repo clone path - repo=$(repo_name "$1"); clone=$(clone_of "$repo"); path="$TREES/$repo-$2" - [ -e "$path" ] && die "$path already exists" - git -C "$clone" worktree add -q -b "$2" "$path" origin/main - herdr_rename "$repo - $2" - install_deps "$path"; finish "$path" -} - -cmd_review() { - [ $# -eq 2 ] || die "usage: gs review " - local repo clone path - repo=$(repo_name "$1"); clone=$(clone_of "$repo"); path="$TREES/$repo-pr-$2" - [ -e "$path" ] && die "$path already exists (gs done inside it first)" - git -C "$clone" worktree add -q --detach "$path" origin/main - (cd "$path" && gh pr checkout "$2" -R "Gentleman-Programming/$repo" -b "pr-$2") - herdr_rename "$repo - pr #$2" - install_deps "$path"; finish "$path" -} - -# Repo from the current directory's origin, for the optional [repo] argument. -repo_here() { - local url; url=$(git remote get-url origin 2>/dev/null) || die "give the repo (ai, shell, engram) or run it inside one" - case "$url" in - */gentle-ai|*/gentle-ai.git) echo gentle-ai ;; - */gentle-shell|*/gentle-shell.git|*/gentle-pi|*/gentle-pi.git) echo gentle-shell ;; - */engram|*/engram.git) echo engram ;; - */gentle-dot|*/gentle-dot.git) echo gentle-dot ;; - */gentle-shell-desktop|*/gentle-shell-desktop.git) echo gentle-shell-desktop ;; - *) die "cannot tell the repo from $url; give it: ai, shell, engram, dot, desktop" ;; - esac -} - -# Sets REPO and NUM from "[repo] ". -repo_and_number() { - case $# in - 1) REPO=$(repo_here); NUM=$1 ;; - 2) REPO=$(repo_name "$1"); NUM=$2 ;; - *) die "usage: gs $CMD [repo] " ;; - esac - case "$NUM" in ''|*[!0-9]*) die "'$NUM' is not a number" ;; esac -} - -# Opens Pi on a gentle-shell worktree with the kickoff prompt $2; other repos -# only print where to go and the prompt to paste. -open_or_finish() { - if [ "$REPO" = gentle-shell ]; then cd "$1" && cmd_run "$2" - else finish "$1"; printf ' kickoff: %s\n' "$2"; fi -} - -pr_prompt() { - # Same cortex-only rule as issue_prompt: the cortex variant is mandatory. - printf 'Review Gentleman-Programming/%s PR #%s using the cortex-maintainer-delivery skill, loading it first (never the plain maintainer-delivery skill): read the PR, its linked issue, CI status and diff; run its preflight and race check; then report scope, risks, and what to test in Spanish. Do not comment, push, or merge.' "$1" "$2" -} - -issue_prompt() { - # Plan-first: the session presents the kickoff and STOPS. Implementation - # starts only after the user explicitly approves the plan (agreed workflow; - # the previous "then work test-first" wording made sessions auto-implement). - # The CORTEX variant of the skill is mandatory, referenced by name (no legacy - # ~/.pi/agent path: sessions run on the isolated home): - # there is also a plain maintainer-delivery skill and the session must not - # pick that one. - printf 'Take Gentleman-Programming/%s issue #%s using the cortex-maintainer-delivery skill, loading it first (never the plain maintainer-delivery skill): run its preflight, read the issue and repo conventions, then present a kickoff plan (expected behavior, approach, out of scope, branch %s) in Spanish and STOP. Do not implement, edit files, or commit: implementation starts only after the user explicitly approves the plan. Claiming, commenting, pushing, and opening the PR stay mine.' "$1" "$2" "$3" -} - -cmd_pr() { - CMD="pr"; repo_and_number "$@" - local path="$TREES/$REPO-pr-$NUM" - if [ -e "$path" ]; then - [ -z "$(git -C "$path" status --porcelain)" ] || die "$path has local changes; commit, drop, or gs done first" - (cd "$path" && gh pr checkout "$NUM" -R "Gentleman-Programming/$REPO" -b "pr-$NUM") - herdr_rename "$REPO - pr #$NUM" - install_deps "$path" - else - cmd_review "$REPO" "$NUM" - fi - open_or_finish "$path" "$(pr_prompt "$REPO" "$NUM")" -} - -cmd_issue() { - CMD="issue"; repo_and_number "$@" - local gh_repo="Gentleman-Programming/$REPO" info state assignees title labels prs prefix slug branch path - info=$(gh issue view "$NUM" -R "$gh_repo" --json state,assignees,title,labels \ - --jq '[.state, ([.assignees[].login]|join(",")), .title, ([.labels[].name]|join(","))]|join("\u001f")') \ - || die "cannot read $gh_repo#$NUM" - # \x1f, not \t: read collapses consecutive IFS *whitespace* tabs, so empty - # assignees/labels fields shift the title into the assignees check. - IFS=$'\x1f' read -r state assignees title labels <<<"$info" - path="$TREES/$REPO-issue-$NUM" - if [ -e "$path" ]; then - printf 'gs: reusing %s\n' "$path" >&2 - herdr_rename "$REPO - is #$NUM" - open_or_finish "$path" "$(issue_prompt "$REPO" "$NUM" "$(git -C "$path" branch --show-current)")"; return - fi - if [ "${GS_FORCE:-}" != 1 ]; then - [ "$state" = OPEN ] || die "$gh_repo#$NUM is $state (GS_FORCE=1 to continue)" - # Your own assignment is your claim, not a race: only others block. - local me others="" - me=$(gh api user --jq .login 2>/dev/null || true) - others=$(printf '%s' "$assignees" | tr ',' '\n' | grep -vxF -- "${me:-}" | paste -sd, - || true) - [ -z "$others" ] || die "$gh_repo#$NUM is assigned to $others (GS_FORCE=1 to continue)" - prs=$(gh pr list -R "$gh_repo" --state all --search "$NUM in:title,body" --json number,state,title \ - --jq '.[] | " #\(.number) \(.state) \(.title)"') - # Only open PRs mean someone may be working on it now; merged/closed ones - # are history (long issues ship in slices), so they are listed, not fatal. - local open_prs done_prs - open_prs=$(printf '%s\n' "$prs" | grep -E '^ *#[0-9]+ OPEN ' || true) - done_prs=$(printf '%s\n' "$prs" | grep -vE '^ *#[0-9]+ OPEN |^$' || true) - [ -z "$open_prs" ] || die "Open PRs may already work on #$NUM (GS_FORCE=1 to continue): -$open_prs" - [ -z "$done_prs" ] || printf 'gs: #%s already has merged or closed PRs:\n%s\n' "$NUM" "$done_prs" >&2 - fi - case ",$labels," in - *,type:feature,*) prefix=feat ;; - *,type:docs,*) prefix=docs ;; - *,type:refactor,*) prefix=refactor ;; - *,type:chore,*) prefix=chore ;; - *) prefix=fix ;; - esac - slug=$(printf '%s' "$title" | sed -E 's/^[A-Za-z]+( [a-z]+)?: *//' | tr '[:upper:]' '[:lower:]' \ - | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//' | cut -c1-40 | sed -E 's/-+$//') - branch="$prefix/$NUM-${slug:-issue}" - git -C "$CLONES/$REPO" worktree add -q -b "$branch" "$path" origin/main - herdr_rename "$REPO - is #$NUM" - install_deps "$path" - printf 'gs: %s#%s "%s" on branch %s\n' "$gh_repo" "$NUM" "$title" "$branch" >&2 - open_or_finish "$path" "$(issue_prompt "$REPO" "$NUM" "$branch")" -} - -cmd_base() { - [ $# -eq 1 ] || die "usage: gs base " - local repo clone path - repo=$(repo_name "$1"); clone=$(clone_of "$repo"); path="$TREES/$repo-base" - if [ -e "$path" ]; then - [ -z "$(git -C "$path" status --porcelain)" ] || die "$path has local changes; base must stay clean" - git -C "$path" checkout -q --detach origin/main - else - git -C "$clone" worktree add -q --detach "$path" origin/main - fi - install_deps "$path"; finish "$path" -} - -cmd_run() { - local root - root=$(git rev-parse --show-toplevel 2>/dev/null) || die "run it inside a gentle-shell worktree" - grep -q '"name": *"gentle-pi"' "$root/package.json" 2>/dev/null || die "$root is not gentle-shell" - [ -d "$root/node_modules" ] || die "no node_modules in $root (pnpm install --frozen-lockfile)" - printf 'gs: gentle-shell @ %s (%s); subagents use main\n' "$(git -C "$root" rev-parse --short HEAD)" "$root" >&2 - cd "$root" && exec node "$root/bin/gentle-shell.mjs" --isolated --package-root "$root" "$@" -} - -# Workshop (agent-gentle): clean clones in ~/dev/gentle, Pi loads the gentle-shell clone. -# Consumer (Mac, agent-dev): no clones; Pi uses git:gentle-pi@main, binaries built from GitHub main. -is_workshop() { - git -C "$CLONES/gentle-shell" remote get-url origin 2>/dev/null | grep -q 'Gentleman-Programming/gentle-shell' -} - -# resolve_link PATH — physical path of PATH after following every symlink -# (portable: macOS readlink has no -f on older releases). -resolve_link() { - local p=$1 dir link - while [ -L "$p" ]; do - dir=$(cd -P "$(dirname "$p")" && pwd); link=$(readlink "$p") - case "$link" in /*) p=$link ;; *) p="$dir/$link" ;; esac - done - printf '%s/%s\n' "$(cd -P "$(dirname "$p")" && pwd)" "$(basename "$p")" -} - -# shell_root — the single gentle-shell package that gs launches and gs sync checks. -# Workshop: the clean clone. Consumer: the package behind `gentle-shell` on PATH, -# which is exactly what `gentle-shell upgrade --channel main` replaces. The -# launcher loads its extensions and its bundled Pi from this root, so launching -# any other copy runs stale code even after a successful sync. -shell_root() { - if is_workshop; then echo "$CLONES/gentle-shell"; return 0; fi - local bin; bin=$(command -v gentle-shell 2>/dev/null) || return 1 - dirname "$(dirname "$(resolve_link "$bin")")" -} - -# Labels of failed sync steps; any entry makes gs sync report INCOMPLETE and exit 1. -SYNC_FAILED="" -sync_failed() { SYNC_FAILED="$SYNC_FAILED $1;"; } - -# run_quiet LABEL CMD... — one-line ok/FAILED status; full output only on failure. -run_quiet() { - local label="$1"; shift - local out; out=$(mktemp) - if "$@" >"$out" 2>&1; then - printf ' %-34s ok\n' "$label" - else - printf ' %-34s FAILED\n' "$label" - sed 's/^/ /' "$out" - sync_failed "$label" - fi - rm -f "$out" -} - -# Installed versions, tolerant to missing binaries (used for before/after diffs). -# The `|| true` is load-bearing: with pipefail, a missing binary (127) in the -# pipeline would kill the whole script under set -e before any output (this bit -# nan, where gentle-ai is not installed yet). -shell_version() { gentle-shell --version 2>/dev/null | awk 'NR==1{print $2}' || true; } -ai_version() { "$HOME/.local/bin/gentle-ai" --version 2>/dev/null | awk '{print $2}' || true; } -engram_version() { "$HOME/.local/bin/engram" version 2>/dev/null | awk '{print $2}' || true; } - -# changed_part NAME BEFORE AFTER — "name (old -> new)" when the version moved. -# An if without else always returns 0: under set -e, a plain "changed=$(...)" -# assignment must not inherit a failure when nothing changed. -changed_part() { - if [ -n "$2" ] && [ -n "$3" ] && [ "$2" != "$3" ]; then printf ' %s (%s -> %s)' "$1" "$2" "$3"; fi -} - -# sync_dotfiles — pull the dotfiles clone; ~/.zshrc and the profile link into it, -# so hosts never keep hand-edited copies. Sets dot_change (" dotfiles (a -> b)"). -# Never fails the sync: every problem is a status line. -sync_dotfiles() { - local dir="${CORTEX_DOTFILES_DIR:-$HOME/dev/barbatdev/cortex/cortex-dotfiles}" - local branch old new out - if [ "${GS_SKIP_DOTFILES:-}" = 1 ]; then - printf ' %-34s skipped (GS_SKIP_DOTFILES=1)\n' dotfiles; return 0 - fi - if [ ! -d "$dir/.git" ]; then - printf ' %-34s skipped (no clone at %s)\n' dotfiles "$dir"; return 0 - fi - # Never touch work in progress: only a clean clone on main follows origin. - if [ -n "$(git -C "$dir" status --porcelain 2>/dev/null)" ]; then - printf ' %-34s skipped (%s has local changes)\n' dotfiles "$dir"; return 0 - fi - branch=$(git -C "$dir" branch --show-current 2>/dev/null || true) - if [ "$branch" != main ]; then - printf ' %-34s skipped (%s is on %s, not main)\n' dotfiles "$dir" "${branch:-detached HEAD}"; return 0 - fi - old=$(git -C "$dir" rev-parse --short HEAD) - if ! out=$(git -C "$dir" pull -q --ff-only origin main 2>&1); then - printf ' %-34s FAILED\n' dotfiles; printf '%s\n' "$out" | sed 's/^/ /'; sync_failed dotfiles; return 0 - fi - new=$(git -C "$dir" rev-parse --short HEAD) - printf ' %-34s ok\n' dotfiles - [ "$old" != "$new" ] && dot_change=" dotfiles ($old -> $new)" - return 0 -} - -cmd_sync() { - local ai_files="" dot_change="" repo shell bin="$HOME/.local/share/gentle-ai/main" reg="$HOME/.pi/gentle-ai/dev-binary.json" name - # The dev-binary registration is install-level state: the official launcher - # reads it from ~/.pi/gentle-ai even in the isolated home (gentle-shell - # lib/gentle-shell-launcher.ts UNMIGRATED_CONFIG_FILES), so it stays there. - # Versions before the sync power the closing message: restart only on change. - local shell_before ai_before engram_before - shell_before=$(shell_version); ai_before=$(ai_version); engram_before=$(engram_version) - # Stale GOROOT/GOBIN from a removed version manager breaks every go build. - if [ -n "${GOROOT:-}" ] && [ ! -d "$GOROOT" ]; then unset GOROOT; fi - if [ -n "${GOBIN:-}" ] && [ ! -d "$(dirname "$GOBIN")" ]; then unset GOBIN; fi - # gentle-ai's beta upgrade (run by gentle-shell upgrade) does its own `go install` - # of the pre-v3 Engram module path. With a go env GOBIN of ~/.local/bin that build - # overwrites the engram symlink with Engram v1, so every child of the sync installs - # into ~/go/bin instead; gs's own builds name their GOBIN explicitly. - export GOBIN="$HOME/go/bin" - mkdir -p "$bin" "$HOME/.local/bin" "$(dirname "$reg")" - if is_workshop; then - # Workshop runs the Pi bundled in the clone (node_modules): no legacy - # ~/.pi/agent runtime on PATH and no pi update. - printf 'gs sync — %s — workshop (local clones)\n' "$(date '+%Y-%m-%d %H:%M')" - for repo in $REPOS; do - [ -d "$CLONES/$repo/.git" ] || die "missing clean clone $CLONES/$repo" - # Untracked files (.engram/, odd/ left by sessions run in the clone) survive - # checkout and pull untouched; only tracked edits would be at risk. - [ -z "$(git -C "$CLONES/$repo" status --porcelain --untracked-files=no)" ] || die "$CLONES/$repo has local changes; clean clones only" - git -C "$CLONES/$repo" checkout -q main - git -C "$CLONES/$repo" fetch -q --tags origin - git -C "$CLONES/$repo" pull -q --ff-only origin main - printf '%-13s %s\n' "$repo" "$(git -C "$CLONES/$repo" log -1 --format='%h %cs %s')" - done - (cd "$CLONES/gentle-ai" && GOBIN="$bin" go install ./cmd/gentle-ai) - (cd "$CLONES/engram" && GOBIN="$bin" go install ./cmd/engram) - (cd "$CLONES/gentle-shell" && pnpm install --frozen-lockfile --silent) - (cd "$CLONES/engram/plugin/pi" && npm install --no-save --no-audit --no-fund --omit=peer --silent) - shell="$CLONES/gentle-shell" - else - printf 'gs sync — %s — consumer (official update channels)\n' "$(date '+%Y-%m-%d %H:%M')" - command -v gentle-shell >/dev/null 2>&1 || die "consumer sync needs gentle-shell on PATH; install it first" - # pi may live in its managed home instead of PATH (managed install schema). - command -v pi >/dev/null 2>&1 || export PATH="$HOME/.pi/agent/bin:$PATH" - # pi self-update cannot handle every installation schema (e.g. bun-compiled - # /opt/pi binaries): a failure there must not abort the sync, the official - # main channel below is the actual update path for Shell and AI. - run_quiet 'pi update' pi update - # Official main channel: builds Shell + AI from the exact main SHAs and - # records the channel state, so later upgrades are no-ops when current. - # Only gentle-shell >= 4.1.0 parses --channel; older launches keep their - # registry maturity policies happy and just skip this step. - if [ "$(gentle-shell --version 2>/dev/null | awk 'NR==1{print $2}')" = "4.0.0" ]; then - printf ' %-34s skipped (4.0.0 cannot follow the main channel yet; needs >= 4.1.0)\n' 'gentle-shell upgrade (main)' - else - # Transient failures (GitHub API rate limit, network) must not abort the - # rest of the sync: extensions, engram and the compat check still run. - run_quiet 'gentle-shell upgrade (main)' gentle-shell upgrade --channel main - fi - run_quiet 'pi update --extensions' pi update --extensions - # Only remaining homemade piece: the engram core binary (no official channel yet). - run_quiet 'engram (go install @main)' env GOPROXY=direct GOBIN="$bin" go install github.com/Gentleman-Programming/engram/v3/cmd/engram@main - # Legacy pi-managed checkout from before the main channel: nothing updates it - # any more and gs no longer launches it. Kept on disk, never deleted here. - local legacy="$HOME/.pi/agent/git/github.com/Gentleman-Programming/gentle-pi" - [ -d "$legacy" ] && printf 'WARN: stale legacy checkout %s is unused (gs launches the gentle-shell on PATH); remove it when convenient\n' "$legacy" - fi - shell=$(shell_root) || die "gentle-shell not found on PATH after sync" - # Mac-only app clones: sync when present, never fail if absent. - local app_synced="" - for repo in $MAC_APP_REPOS; do - [ -d "$CLONES/$repo/.git" ] || continue - [ -z "$(git -C "$CLONES/$repo" status --porcelain)" ] || { printf ' %-34s skipped (%s has local changes)\n' "$repo" "$repo"; continue; } - git -C "$CLONES/$repo" checkout -q main 2>/dev/null || true - git -C "$CLONES/$repo" fetch -q --tags origin - git -C "$CLONES/$repo" pull -q --ff-only origin main - app_synced="$app_synced $repo@$(git -C "$CLONES/$repo" log -1 --format=%h)" - done - for name in gentle-ai engram; do - if [ -e "$HOME/.local/bin/$name" ] && [ ! -L "$HOME/.local/bin/$name" ]; then - mv "$HOME/.local/bin/$name" "$HOME/.local/bin/$name.pre-gw" - printf 'kept old %s as ~/.local/bin/%s.pre-gw\n' "$name" "$name" - fi - ln -sfn "$bin/$name" "$HOME/.local/bin/$name" - done - # The integrations need `engram instance-id` (Engram >= v2.0.0-rc.11); a pre-v3 - # build on PATH blocks every memory read, so the sync must not report success. - "$HOME/.local/bin/engram" instance-id >/dev/null 2>&1 || sync_failed 'engram lacks instance-id (pre-v3)' - local ai_bin - if is_workshop; then - local tmp; tmp=$(mktemp "$(dirname "$reg")/.dev-binary.XXXXXX") - printf '{"schema":"gentle-pi.dev-binary/v1","path":"%s"}\n' "$bin/gentle-ai" > "$tmp" - chmod 600 "$tmp"; mv -f "$tmp" "$reg" - ai_bin="$bin/gentle-ai" - else - # The official main channel owns the dev-binary registration; reuse its path. - ai_bin=$(sed -n 's/.*"path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$reg" 2>/dev/null | head -1) - [ -x "$ai_bin" ] || die "gentle-ai binary not found in $reg; run: gentle-shell upgrade --channel main" - fi - ln -sfn "$ai_bin" "$HOME/.local/bin/gentle-ai" - ln -sfn "$bin/engram" "$HOME/.local/bin/engram" - # gentle-ai sync and gentle-shell setup must use the Engram built above: without - # it, gentle-ai's beta channel runs its own go install of the pre-v3 module path, - # which fails. Set here so the sync never depends on the calling shell's env. - export GENTLE_AI_ENGRAM_BIN="${GENTLE_AI_ENGRAM_BIN:-$bin/engram}" - - # New binaries are not enough: gentle-ai sync materializes the managed configs - # (agents, skills, plugins, engram instructions) every runtime reads. - # GS_SYNC_AGENTS scopes runtimes per host (local env). Workshop defaults to - # claude-code (Shell runs on the isolated home, provisioned below by - # gentle-shell setup); consumer empty = gentle-ai default. - local sync_agents="${GS_SYNC_AGENTS:-}" - is_workshop && sync_agents="${GS_SYNC_AGENTS:-claude-code}" - if [ "${GS_SKIP_AI_SYNC:-}" = 1 ]; then - printf ' %-34s skipped (GS_SKIP_AI_SYNC=1)\n' 'gentle-ai sync' - is_workshop && printf ' %-34s skipped (GS_SKIP_AI_SYNC=1)\n' 'gentle-shell setup (isolated home)' - else - local ai_sync=(sync) - [ -n "$sync_agents" ] && ai_sync+=(--agents "$sync_agents") - # Like run_quiet, but keeps the output: its file count feeds the restart hint. - local ai_out; ai_out=$(mktemp) - if "$ai_bin" "${ai_sync[@]}" >"$ai_out" 2>&1; then - printf ' %-34s ok\n' 'gentle-ai sync' - else - printf ' %-34s FAILED\n' 'gentle-ai sync' - sed 's/^/ /' "$ai_out" - sync_failed 'gentle-ai sync' - fi - ai_files=$(sed -n 's/^Sync actions executed: \([0-9][0-9]*\) files* changed.*/\1/p' "$ai_out" | head -1) - rm -f "$ai_out" - # Official re-provisioning of the isolated home (~/.gentle-shell/agent) from the clone. - is_workshop && run_quiet 'gentle-shell setup (isolated home)' \ - node "$CLONES/gentle-shell/bin/gentle-shell.mjs" --isolated setup - fi - sync_dotfiles - # gs sync is the only upgrade path; older homemade scripts drift the hosts apart. - for name in "$HOME/.local/bin/gentle-maintainer-upgrade" "$HOME/.local/bin/gentle-sync.sh" \ - /usr/bin/gentle-sync.sh /usr/local/bin/gentle-upgrade.sh /bin/gentle-upgrade.sh; do - [ -e "$name" ] && printf 'WARN: legacy upgrade script %s; use gs sync only and remove it\n' "$name" - done - - # Versions after the sync decide the closing message: restart only on change. - local shell_after ai_after engram_after changed - shell_after=$(shell_version); ai_after=$(ai_version); engram_after=$(engram_version) - changed="$(changed_part shell "$shell_before" "$shell_after")$(changed_part gentle-ai "$ai_before" "$ai_after")$(changed_part engram "$engram_before" "$engram_after")" - printf '\nversions: shell %s | gentle-ai %s | engram %s\n' "${shell_after:-?}" "${ai_after:-?}" "${engram_after:-?}" - # The launch target must be the package this sync just updated: ask it directly. - local launched - launched=$( (node "$shell/bin/gentle-shell.mjs" --version 2>/dev/null || true) | awk 'NR==1{print $2}') - printf 'gs opens: %s (%s)\n' "${launched:-?}" "$shell" - if [ -z "$launched" ]; then - sync_failed "gs launch target $shell unreadable" - elif [ -n "$shell_after" ] && [ "$launched" != "$shell_after" ]; then - sync_failed "gs launch target is $launched but gentle-shell on PATH is $shell_after" - fi - # Consumer builds carry the main SHA (x.y.z-main.): compare with GitHub. - if ! is_workshop; then - local built remote - built=$(printf '%s' "$launched" | sed -n 's/.*-main\.\([0-9a-f]\{7,\}\)$/\1/p') - remote=$(git ls-remote "${GS_SHELL_REMOTE:-https://github.com/Gentleman-Programming/gentle-pi}" refs/heads/main 2>/dev/null | awk '{print $1}' || true) - if [ -z "$built" ]; then - sync_failed "gentle-shell $launched is not a main-channel build" - elif [ -z "$remote" ]; then - printf 'WARN: could not read gentle-pi main from GitHub; freshness unverified\n' - elif [ "${remote#"$built"}" = "$remote" ]; then - sync_failed "gentle-shell behind main (built ${built}, main ${remote:0:12})" - fi - fi - [ -n "$app_synced" ] && printf 'app repos:%s\n' "$app_synced" - for name in gentle-ai engram; do - [ "$(command -v "$name")" = "$HOME/.local/bin/$name" ] || printf 'WARN: %s on PATH is %s, not our build\n' "$name" "$(command -v "$name")" - done - local emits reads - emits=$( ("$ai_bin" review status --cwd "$shell" --contract gentle-ai.review-integration/v2 \ - --agent claude-code --next-transition 2>/dev/null || true) | grep -o 'review-integration\.status/v[0-9]*' | head -1 | sed 's#.*/##' || true) - reads=$( (grep -rhoE 'review-integration\.status/v[0-9]+' "$shell/lib" "$shell/extensions" 2>/dev/null || true) \ - | sed 's#.*/##' | sort -uV | tr '\n' ' ' || true) - if [ -z "$emits" ] || [ -z "$reads" ]; then - printf 'compat: UNVERIFIED (emits=%s reads=%s)\n' "${emits:-?}" "${reads:-?}" - elif printf '%s' "$reads" | tr ' ' '\n' | grep -qx "$emits"; then - printf 'compat: OK (gentle-ai emits %s, gentle-shell reads %s)\n' "$emits" "$reads" - else - printf 'compat: INCOMPATIBLE (gentle-ai emits %s, gentle-shell reads %s)\n' "$emits" "$reads" - fi - if [ -n "$ai_files" ] && [ "$ai_files" -gt 0 ]; then - changed="$changed managed configs ($ai_files files)" - fi - changed="$changed$dot_change" - if [ -n "$SYNC_FAILED" ]; then - printf 'INCOMPLETE — failed:%s\n Some components may be stale; fix the above and run gs sync again.\n' "$SYNC_FAILED" - [ -n "$changed" ] && printf 'changed anyway:%s\n Restart Pi to pick them up.\n' "$changed" - return 1 - elif [ -n "$changed" ]; then - printf 'changed:%s\n Restart Pi to pick them up.\n' "$changed" - else - printf 'nothing changed — no Pi restart needed.\n' - fi -} - -cmd_ls() { - local repo - for repo in $REPOS $MAC_APP_REPOS; do - [ -d "$CLONES/$repo/.git" ] && git -C "$CLONES/$repo" worktree list | grep -v "^$CLONES/$repo " || true - done -} - -cmd_done() { - local root; root=$(git rev-parse --show-toplevel 2>/dev/null) || die "run it inside a worktree" - case "$root" in "$TREES"/*) ;; *) die "$root is not a gs worktree" ;; esac - # Harness task records (odd/tasks/*) never ship; drop them if they are the only leftovers. - local other; other=$(git -C "$root" status --porcelain | grep -v '^?? odd/tasks/' || true) - [ -z "$other" ] || die "uncommitted changes in $root: -$other" - git -C "$root" status --porcelain | sed -n 's/^?? //p' | while read -r f; do rm -rf "${root:?}/$f"; done - git -C "$root" fetch -q --prune origin - if [ -n "$(git -C "$root" log --oneline HEAD --not --remotes)" ] && - ! git -C "$root" ls-remote origin 'refs/pull/*/head' | grep -q "^$(git -C "$root" rev-parse HEAD)"; then - die "commits not on any remote in $root; push or drop them yourself first" - fi - local main branch; main=$(git -C "$root" worktree list --porcelain | sed -n '1s/^worktree //p') - branch=$(git -C "$root" branch --show-current) - git -C "$main" worktree remove "$root" - printf 'removed %s\n' "$root" - if [ -n "$branch" ]; then - if git -C "$main" merge-base --is-ancestor "$branch" origin/main 2>/dev/null && git -C "$main" branch -D "$branch" >/dev/null 2>&1; then - printf 'deleted local branch %s (already in origin/main)\n' "$branch" - else printf 'kept local branch %s (not fully in main; delete it yourself if the PR was squash-merged)\n' "$branch"; fi - fi - printf 'now run: cd %s\n' "$main" -} - -cmd_launch() { - local root here - # Workshop dogfooding: inside any gentle-shell checkout (clone or worktree), - # Pi loads the package from that path, exactly like gs run. - if is_workshop && here=$(git rev-parse --show-toplevel 2>/dev/null) && - grep -q '"name": *"gentle-pi"' "$here/package.json" 2>/dev/null; then - herdr_tab_repo - cmd_run "$@" - fi - root=$(shell_root) || die "gentle-shell not on PATH (install it, then run: gs sync)" - [ -f "$root/bin/gentle-shell.mjs" ] || die "gentle-shell not found at $root (run: gs sync)" - herdr_tab_repo - exec node "$root/bin/gentle-shell.mjs" --isolated "$@" -} - -case "${1:-}" in - pr) shift; cmd_pr "$@" ;; - issue) shift; cmd_issue "$@" ;; - new) shift; cmd_new "$@" ;; - review) shift; cmd_review "$@" ;; - base) shift; cmd_base "$@" ;; - run) shift; cmd_run "$@" ;; - sync) cmd_sync ;; - ls) cmd_ls ;; - done) cmd_done ;; - help|-h|--help) usage ;; - *) cmd_launch "$@" ;; -esac diff --git a/bin/gs-official b/bin/gs-official deleted file mode 100755 index d8303b2..0000000 --- a/bin/gs-official +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env bash -# gs-official — minimal launcher for the official Gentle Shell (agent-dev only). -# -# Usage: -# gs [args...] Run `gentle-shell` with the arguments forwarded unchanged. -# gs sync Update Gentle Shell, external Gentle AI and Engram from main, -# then sync Gentle AI assets for Claude Code only. -# gs help Show this help (`gs --help` and `gs -h` also work). -# -# The legacy bin/gs (Mac/workshop) is a separate script and is not affected. - -set -uo pipefail - -AI_MODULE="github.com/gentleman-programming/gentle-ai/v4/cmd/gentle-ai@main" -ENGRAM_MODULE="github.com/Gentleman-Programming/engram/v3/cmd/engram@main" -# Only gentle-ai and engram binaries inside this directory may be replaced. -EXPECTED_AI_BIN="$HOME/.local/bin" - -usage() { - sed -n '2,10s/^# \{0,1\}//p' "$0" -} - -die() { - echo "gs: $*" >&2 - exit 1 -} - -# Run one step; on failure report it and exit with the step's status. -step() { - "$@" - local status=$? - if [ "$status" -ne 0 ]; then - echo "gs: '$*' failed with exit $status (no fallback attempted)" >&2 - exit "$status" - fi -} - -# Resolve a binary that `gs sync` may replace and print its real directory. -# Refuses anything that is not a user-owned regular file in EXPECTED_AI_BIN. -owned_bin_dir() { - local name="$1" path dir expected_dir - path="$(command -v "$name")" || die "$name not found in PATH" - dir="$(cd "$(dirname "$path")" && pwd -P)" - expected_dir="$(cd "$EXPECTED_AI_BIN" 2>/dev/null && pwd -P)" \ - || die "expected $name directory $EXPECTED_AI_BIN does not exist" - - [ -L "$path" ] && die "$path is a symlink; refusing to replace it" - [ "$dir" = "$expected_dir" ] \ - || die "$path is not in the expected directory $EXPECTED_AI_BIN; refusing to replace it" - [ -f "$path" ] && [ -x "$path" ] || die "$path is not an executable file" - [ -O "$path" ] || die "$path is not owned by the current user" - [ -w "$dir" ] || die "$dir is not writable" - echo "$dir" -} - -# Verify every tool and every replaced target before changing anything. -preflight() { - command -v gentle-shell >/dev/null 2>&1 || die "gentle-shell not found in PATH" - command -v go >/dev/null 2>&1 || die "go not found in PATH" - AI_DIR="$(owned_bin_dir gentle-ai)" || exit 1 - AI_PATH="$(command -v gentle-ai)" - ENGRAM_DIR="$(owned_bin_dir engram)" || exit 1 - ENGRAM_PATH="$(command -v engram)" -} - -sync_main() { - preflight - echo "==> gentle-shell upgrade --channel main" - step gentle-shell upgrade --channel main - echo "==> go install $AI_MODULE (GOBIN=$AI_DIR)" - step env GOBIN="$AI_DIR" go install "$AI_MODULE" - echo "==> $AI_PATH sync --agents claude-code" - step "$AI_PATH" sync --agents claude-code - echo "==> go install $ENGRAM_MODULE (GOBIN=$ENGRAM_DIR)" - step env GOBIN="$ENGRAM_DIR" go install "$ENGRAM_MODULE" - - # Source builds from main report version "dev"; the build info revision is - # what identifies the installed main commit. - echo "==> installed versions" - step gentle-shell --version - step "$AI_PATH" version - step go version -m "$AI_PATH" - step "$ENGRAM_PATH" version - step go version -m "$ENGRAM_PATH" - - # The running server keeps the old binary until its service restarts. - echo "==> Engram binary updated; restart the Engram service to run it" -} - -case "${1:-}" in - sync) - [ "$#" -eq 1 ] || { echo "gs: sync takes no arguments" >&2; exit 2; } - sync_main - ;; - help | -h | --help) - usage - ;; - *) - exec gentle-shell "$@" - ;; -esac diff --git a/docs/gentle-official-migration.md b/docs/gentle-official-migration.md index 866da45..9b6adc1 100644 --- a/docs/gentle-official-migration.md +++ b/docs/gentle-official-migration.md @@ -1,5 +1,7 @@ # Migración a Gentle oficial: retiro del stack legacy +> Nota (2026-11-04): `bin/gs` y `bin/gs-official` fueron reemplazados por un único binario Go, `gs/` ([gs.md](gs.md)). Las menciones de abajo son historia. + Runbook para retirar el stack Gentle legacy (launchers, wrappers, declaraciones de Pi, agentes y Orca) de un equipo **sin perder memoria, conversaciones ni credenciales**, y dejarlo listo para una instalación oficial posterior. Documenta lo que se hizo en `agent-dev`, el estado real y el plan para los demás equipos. > **Estado honesto (2026-10-31):** el usuario confirmó que el instalador oficial terminó los 11 pasos en `agent-dev`: instaló Pi/Shell, verificó paquetes/comandos/Gentle AI, compiló Gentle AI desde `main`, instaló Shell desde `main`, guardó el canal y ejecutó setup. **El canal requerido es siempre `main`, no release.** El usuario mostró un arranque de Shell con extensiones cargadas y aviso de falta de modelos; decidió autenticarse otra vez. También confirmó setup de Claude Code completado (1 agente, 8 componentes), con aviso de integración Pi CodeGraph pendiente. Claude Code ya reportó instrucciones cargadas, detección de proyecto Engram y consulta real CodeGraph; la lectura de memorias anteriores y registro de sesión no se probaron. El usuario confirmó login y modelos disponibles en Shell. Los perfiles de macOS fueron restaurados y verificados por hash en el home aislado; el usuario confirmó que cargaron perfectamente en la interfaz. RefactorIA fue copiado al home aislado y verificado por hash; selección y comprobación visual pendientes. Quedan residuos preservados a propósito (ver [Residuos](#residuos-preservados)); no se afirma que el equipo esté "en cero". @@ -22,7 +24,7 @@ go version -m ~/.local/bin/engram | grep 'engram/v3' - **No usar `@latest`**: en Go resuelve al último tag de release, no a `main`. - `go version -m` muestra una pseudo-versión como `v3.6.1-0.20261030201044-7cf4ed29b6f9`. No es un pin: los últimos 12 caracteres son el commit de `main` que se compiló. Compararlo con `git ls-remote https://github.com/Gentleman-Programming/engram.git refs/heads/main`. - Reemplazar el binario no reinicia el servidor que ya está corriendo; el operador decide cuándo reiniciarlo. -- `gs sync` ([`bin/gs-official`](../bin/gs-official)) ya incluye este paso: instala Engram desde `main` en `~/.local/bin` y recuerda reiniciar el servicio. +- `gs sync` ([`gs/`](../gs), ver [gs.md](gs.md)) ya incluye este paso: instala Engram desde `main` en `~/.local/bin` y recuerda reiniciar el servicio. Estado observado en la Mac (2026-10-31): `~/.local/bin/engram` es el único Engram en el `PATH`, el LaunchAgent `com.gentleman-programming.engram` ejecuta ese binario y su commit `7cf4ed29b6f9` coincidía con la punta de `main`. Homebrew no tenía Engram instalado. El LaunchAgent de cloud-sync ejecuta `python3`, no un binario de Engram. diff --git a/docs/gs-official.md b/docs/gs-official.md deleted file mode 100644 index 0aac083..0000000 --- a/docs/gs-official.md +++ /dev/null @@ -1,46 +0,0 @@ -# gs-official: official Gentle Shell launcher (agent-dev only) - -`bin/gs-official` is deployed as `gs` on the migrated agent-dev host only. It launches the official `gentle-shell` and keeps Gentle Shell, the external Gentle AI and Engram on `main`. - -> The legacy `bin/gs` (Mac and workshop) is unchanged and is not replaced by this script. - -## Quick path - -| Command | What it does | -|---------|--------------| -| `gs [args...]` | Runs `gentle-shell "$@"` via `exec`. Arguments and exit status pass through unchanged. No `--link` or `--package-root` is added. | -| `gs sync` | Updates Gentle Shell, Gentle AI and Engram from `main`, then syncs Claude Code assets. Takes no arguments. | -| `gs help` | Shows usage (`-h` and `--help` also work). | - -## What `gs sync` runs - -Steps run in order and stop at the first failure. The failing command's output is shown, and `gs` exits with that command's exit status. There is no fallback to the stable channel. - -1. Preflight. Nothing changes until every check passes: - - `gentle-shell` and `go` are on `PATH`. - - `gentle-ai` resolves to `~/.local/bin/gentle-ai` and `engram` resolves to `~/.local/bin/engram`. - - Each file is a regular executable, not a symlink, and the current user owns it. - - `~/.local/bin` is writable. -2. `gentle-shell upgrade --channel main` -3. `GOBIN=~/.local/bin go install github.com/gentleman-programming/gentle-ai/v4/cmd/gentle-ai@main` -4. `~/.local/bin/gentle-ai sync --agents claude-code` (Claude Code only) -5. `GOBIN=~/.local/bin go install github.com/Gentleman-Programming/engram/v3/cmd/engram@main` -6. Version report: `gentle-shell --version`, `gentle-ai version`, `go version -m ~/.local/bin/gentle-ai`, `engram version`, `go version -m ~/.local/bin/engram` -7. Reminder to restart the Engram service. `gs sync` replaces the binary only; the running server keeps the old one until the operator restarts it (systemd user unit on Linux, LaunchAgent on macOS). - -## Details - -- **Main is not a persistent channel.** `go install ...@main` builds the current `main` once. It does not pin anything for later, so each `gs sync` installs `@main` again. -- **Identifying the installed commit.** A build from source may report its version as `dev` or as a pseudo-version. Use the `go version -m` output (the module pseudo-version and `vcs.revision`, when present) to confirm which `main` commit is installed. Do not read a semantic version number as proof that you are on `main`. -- **Fail-closed target.** If `gentle-ai` or `engram` resolves anywhere other than `~/.local/bin` (for example, a package-managed copy or a symlink), `gs sync` refuses to run. This keeps it from updating a different copy than the one on your `PATH`. Both binaries are required: on a host without `~/.local/bin/engram`, `gs sync` refuses before updating anything, including Gentle Shell and Gentle AI. Install Engram from `main` there first. -- **Never `@latest`.** In Go, `@latest` resolves to the newest release tag, not `main`. -- **Out of scope.** No Engram service restarts, no Engram data migrations, no pi self-update, no dotfiles pulls, and no custom clones. Credentials, memories, and conversations are never touched. -- **PATH setup.** Putting `~/.local/bin` and the deployed `gs` on `PATH` for fresh zsh sessions is handled by a separate agent-dev drop-in, not by the shared zsh profile. - -## Tests - -```bash -python3 -m unittest discover -s tests -p gs_official_test.py -``` - -The tests use a temporary `HOME` and `PATH` shims. They make no network calls and do not touch real binaries. diff --git a/docs/gs-sync.md b/docs/gs-sync.md deleted file mode 100644 index 4bce75b..0000000 --- a/docs/gs-sync.md +++ /dev/null @@ -1,129 +0,0 @@ -# gs and gs sync: one gentle-shell install - -`gs` launches exactly the gentle-shell package that `gs sync` updates and checks: - -| Mode | Detected when | `gs sync` updates | `gs` launches | -|------|---------------|-------------------|---------------| -| workshop (agent-gentle) | `~/dev/gentle/gentle-shell` is a clone of Gentleman-Programming/gentle-shell | that clone (`git pull`, `pnpm install`) | inside a gentle-shell clone or worktree: that path (`--package-root`, like `gs run`); elsewhere: the synced clone | -| consumer (standard workstations) | anything else | `gentle-shell upgrade --channel main` | the package behind `gentle-shell` on PATH (symlinks resolved) | - -The launcher loads its extensions and its bundled Pi from its own package root, -so the package it starts from decides what you see. - -## Isolated home, never `--link` - -Every launch (`gs`, `gs run`, and the Pi session opened by `gs pr` / `gs issue`) -passes `--isolated`, so Shell uses the official isolated home -`~/.gentle-shell/agent`; from a clone or worktree it also passes -`--package-root `. `gs` never passes `--link` and never loads `~/.pi/agent`. - -Workshop `gs sync` also stays off the legacy Pi home: - -- It does not add `~/.pi/agent/bin` to PATH and does not run `pi update` or - `pi update --extensions`; Shell runs the Pi bundled in the clone - (`node_modules/@earendil-works/pi-coding-agent`). -- It registers Gentle AI main in `~/.pi/gentle-ai/dev-binary.json` (file - `0600`, atomic write) with - `{"schema":"gentle-pi.dev-binary/v1","path":"~/.local/share/gentle-ai/main/gentle-ai"}`. - This path is install-level state: the official launcher reads it there even - for the isolated home, so it is not moved to `~/.gentle-shell/gentle-ai`. -- It no longer warns about `gentle-pi`/`gentle-engram` in the isolated home's - `settings.json`: the official Shell declares and requires them there, and the - `--package-root` take-over loads the clone instead of the published gentle-pi. -- It runs `gentle-ai sync --agents claude-code` (Claude Code only; never the - legacy `pi` agent nor `opencode`). `GS_SYNC_AGENTS=a,b` overrides the agents. -- It then runs `node ~/dev/gentle/gentle-shell/bin/gentle-shell.mjs --isolated setup` - (`gentle-shell setup (isolated home)`), the official re-provisioning of - `~/.gentle-shell/agent`. A failure marks the sync `INCOMPLETE` and shows its output. -- `GS_SKIP_AI_SYNC=1` skips both steps and prints a `skipped` line for each. - -The `gs pr` / `gs issue` kickoff prompts name the `cortex-maintainer-delivery` -skill without a home path. Consumer sync keeps its official update steps -(`pi update`, `gentle-shell upgrade --channel main`, `pi update --extensions`) -and runs `gentle-ai sync` with `--agents` only when `GS_SYNC_AGENTS` is set. - -## Why this changed - -Before, consumer `gs` started -`~/.pi/agent/git/github.com/Gentleman-Programming/gentle-pi/bin/gentle-shell.mjs`. -That checkout came from an older setup in which Pi tracked `git:gentle-pi` in -`~/.pi/agent/settings.json`. Once that entry was removed, nothing updated the -checkout, while `gs sync` upgraded the global npm package behind `gentle-shell`. -Result: `gentle-shell --version` showed the new main build, but `gs` opened 4.3.0 -without the profiles tabs (#2588, #2599) or the agent tool cards (#2616). - -## What gs sync reports now - -- `gs opens: ()`: the version reported by the launch target itself. -- If the launch target differs from `gentle-shell` on PATH, or (consumer) its - `-main.` differs from GitHub `gentle-pi` main, or any step fails, it prints - `INCOMPLETE — failed: ...` and exits 1. It never says "no Pi restart needed" then. -- If GitHub cannot be reached, it warns `freshness unverified` without failing. -- It warns when the legacy checkout still exists. It never deletes it. - -## Aligning another machine - -Run these on each machine. A machine counts as aligned only after step 3 passes there. - -1. Pull the dotfiles so `gs` has this fix: `git -C ~/dev/barbatdev/cortex/cortex-dotfiles pull --ff-only` - (or let `gs sync` do it on a clean clone on `main`, then run `gs sync` again). -2. Close Pi sessions and run `gs sync`. It must end without `INCOMPLETE`. -3. Check the public entry point: - ```bash - gs --version | head -1 # must match the line below - gentle-shell --version | head -1 - git ls-remote https://github.com/Gentleman-Programming/gentle-pi refs/heads/main - ``` - On consumer machines the `-main.` suffix must be a prefix of the remote SHA. -4. Optional cleanup, once step 3 passes: remove the unused legacy checkout - `~/.pi/agent/git/github.com/Gentleman-Programming/gentle-pi` - (confirm first that `~/.pi/agent/settings.json` does not list `git:github.com/Gentleman-Programming/gentle-pi`). -5. Open `gs`, then the models screen: profiles appear as tabs. - -## Startup auto-setup is off - -`zsh/cortex-dotfiles.zsh` exports `GENTLE_SHELL_NO_AUTO_SETUP=1` by default. -On startup, gentle-shell would otherwise reprovision companions, and the gentle-ai -bundled with it (v4.3.1) replaces the active `engram` with v1. - -- Tradeoff: companions are no longer provisioned automatically. -- Opt back in for one invocation with `GENTLE_SHELL_NO_AUTO_SETUP=0 gs`, or export - the override from `local/env.zsh`. -- Explicit setup and installer lifecycle steps are not covered by this startup - guard and may still install Engram v1 until the upstream fix ships. Check - `engram version` afterwards. Existing terminals need `export GENTLE_SHELL_NO_AUTO_SETUP=1` - or a profile reload; new shells inherit the default. - -Check: `scripts/test-engram-auto-setup.sh`. - -## gentle-ai uses our Engram binary - -On the beta channel, `gentle-ai sync` and installs run -`go install github.com/Gentleman-Programming/engram/cmd/engram@main`. That is the -pre-v3 module path: it fails (`missing .../cmd/engram/go.mod`) or installs -Engram v1 over the v3 binary. `GENTLE_AI_ENGRAM_BIN` makes gentle-ai use a given -binary instead of installing one. - -`gs sync` sets `GENTLE_AI_ENGRAM_BIN` to the Engram it just built before it runs -`gentle-ai sync` and `gentle-shell --isolated setup`, so the sync does not depend -on the calling shell (a shell opened before a dotfiles update, or a host that -does not load them). A value already set in the environment wins. -`zsh/cortex-dotfiles.zsh` also exports it for `gentle-ai` runs outside `gs sync`. - -`GENTLE_AI_ENGRAM_BIN` does not cover `gentle-ai upgrade` on the beta channel -(run by `gentle-shell upgrade`): it still runs that `go install` and writes into -the effective `GOBIN`. On a host with `go env -w GOBIN=~/.local/bin`, the v1 -build replaced the `~/.local/bin/engram` symlink. `gs sync` therefore exports -`GOBIN=~/go/bin` for every step it runs; its own builds set `GOBIN` explicitly. -To protect runs outside `gs sync` as well, keep `go env GOBIN` off `~/.local/bin`. - -After relinking, `gs sync` runs `~/.local/bin/engram instance-id` and reports -`INCOMPLETE — failed: engram lacks instance-id (pre-v3)` when that fails. - -Check: `engram version` reports 3.x after `gs sync` and after starting Pi. - -## Tests - -`tests/gs_test.sh` runs offline in a throwaway HOME with stub binaries and covers -the launcher (consumer and workshop, `gs run`, `gs pr`, `gs issue`), the isolated -registration, and the sync result messages. diff --git a/docs/gs.md b/docs/gs.md new file mode 100644 index 0000000..955bf25 --- /dev/null +++ b/docs/gs.md @@ -0,0 +1,65 @@ +# gs + +`gs` is one Go binary (source: [`gs/`](../gs)) that behaves the same on every host. It replaces the former `bin/gs` and `bin/gs-official` scripts. + +## Install + +`./install.sh` builds it into `~/.local/bin/gs`. After that, `gs sync` rebuilds it from the dotfiles clone, but only while that clone is clean and on `main`. A feature branch is never installed as `gs`. + +```bash +cd gs && go build -trimpath -buildvcs=false -o ~/.local/bin/gs . # manual build +``` + +## Hosts + +| Host | Mode | Detected by | +|------|------|-------------| +| Mac, agent-dev | consumer | no clone at `~/dev/gentle/gentle-shell` | +| agent-gentle | workshop | `~/dev/gentle/gentle-shell` has origin `Gentleman-Programming/gentle-shell` | + +Plain `gs [args]` runs the official `gentle-shell` with the arguments unchanged in consumer mode. In workshop mode it opens the clone with `--isolated`, or with `--package-root ` when run from inside a gentle-shell checkout (same as `gs run`). + +## gs sync + +1. Preflight: `gh auth status`, `gh auth token`, `go`, and in consumer mode `gentle-shell`. If any of these fails, nothing is changed. +2. Every later step runs with `GOBIN=~/.local/share/gs/quarantine`, `GH_TOKEN`/`GITHUB_TOKEN` from `gh`, `GENTLE_AI_ENGRAM_BIN=~/.local/bin/engram` and `GENTLE_SHELL_NO_AUTO_SETUP=1`. Any `go install` run by gentle-shell or gentle-ai lands in the quarantine dir and can never replace the binaries gs manages. +3. Sources: + - Consumer: the main commit of `gentle-ai` and `engram`, resolved through `gh api`. + - Workshop: clean clones in `~/dev/gentle`, fast-forwarded to `origin/main`. +4. Consumer only: `gentle-shell upgrade --channel main`, but only when the shell build is behind `gentle-pi` main. That upgrade asks GitHub without a token (unauthenticated rate limit), so it is skipped when there is nothing to do. +5. `gentle-ai` and `engram` are built from the pinned commit into a staging dir. + - Before installing, the build must pass a check: `engram instance-id` for engram, and `version` for both. + - Then the binary is installed in `~/.local/bin` as a regular file through an atomic rename. A symlink at that path is replaced, never followed. +6. Workshop only: `pnpm install` in gentle-shell, `npm install` in the engram Pi plugin, and registration of `~/.pi/gentle-ai/dev-binary.json` (mode 0600). +7. `gentle-ai sync --agents claude-code`. `GS_SYNC_AGENTS=a,b` changes the agents and `GS_SKIP_AI_SYNC=1` skips this step. Workshop then runs `gentle-shell --isolated setup`. +8. Restarts the Engram service (LaunchAgent `com.gentleman-programming.engram` or the systemd user unit `engram.service`) when Engram changed or a running engram uses an old binary. Agent MCP clients still need `/mcp` → reconnect. +9. Pulls the dotfiles clone (clean, on `main`; `GS_SKIP_DOTFILES=1` skips), rebuilds `gs` from it, and on consumer hosts pulls the clean `gentle-dot` and `gentle-shell-desktop` clones. +10. Runs the `gs doctor` checks. Any failure prints `INCOMPLETE — failed: …` and exits 1. + +## gs doctor + +`gs doctor` changes nothing. It fails when: + +- `gentle-ai` or `engram` is missing in `~/.local/bin`, is a symlink, or is not the copy `PATH` resolves. +- `engram instance-id` fails (Engram older than v2.0.0-rc.11). +- `gh` is not authenticated. +- `gentle-shell` is not on `PATH`. + +It warns about: + +- `go env GOBIN` set to `~/.local/bin`. +- Stray copies (`~/go/bin/`, `~/.local/bin/.pre-gw`). +- Anything left in the quarantine dir. +- engram processes that still run an old binary. + +## Worktree commands + +These work the same as the former script: `gs pr`, `gs issue`, `gs new`, `gs review`, `gs base`, `gs run`, `gs ls`, `gs done`. Run `gs help` for the details. `gs done` now also drops untracked `odd/tasks/` records when the whole `odd/` directory is untracked. + +## Tests + +```bash +cd gs && go test ./... +``` + +The tests use a temporary `HOME` and shell stubs on `PATH` (`gh`, `go`, `gentle-shell`, `launchctl`, `systemctl`). They make no network calls and never touch real binaries or services. diff --git a/gs/doctor.go b/gs/doctor.go new file mode 100644 index 0000000..1396ddd --- /dev/null +++ b/gs/doctor.go @@ -0,0 +1,208 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" +) + +// report collects ok/WARN/FAIL lines; any FAIL makes the command exit 1. +type report struct { + env *Env + fails []string +} + +func (r *report) ok(name, detail string) { r.line("ok ", name, detail) } +func (r *report) warn(name, detail string) { r.line("WARN", name, detail) } +func (r *report) fail(name, detail string) { + r.fails = append(r.fails, name) + r.line("FAIL", name, detail) +} + +func (r *report) line(status, name, detail string) { + if detail == "" { + fmt.Fprintf(r.env.Stdout, "%s %s\n", status, name) + return + } + fmt.Fprintf(r.env.Stdout, "%s %s: %s\n", status, name, detail) +} + +func (r *report) err(what string) error { + if len(r.fails) == 0 { + return nil + } + return failf("%s: %d check(s) failed: %s", what, len(r.fails), strings.Join(r.fails, ", ")) +} + +// cmdDoctor reports every piece of this host that differs from what gs installs. +// It changes nothing. +func cmdDoctor(e *Env) error { + r := &report{env: e} + checkHost(e, r) + return r.err("gs doctor") +} + +// checkHost is shared by gs doctor and the final verification of gs sync. +func checkHost(e *Env, r *report) { + for _, b := range Managed { + checkManaged(e, r, b.Name) + } + if out, err := e.Output("", e.Target("engram"), "instance-id"); err != nil { + r.fail("engram instance-id", "fails (Engram older than v2.0.0-rc.11): "+firstLine(out)) + } else { + r.ok("engram instance-id", "") + } + if out, err := e.Output("", "gh", "auth", "status"); err != nil { + r.fail("gh auth", "gh is not authenticated: "+firstLine(out)) + } else { + r.ok("gh auth", "") + } + if p, err := exec.LookPath("gentle-shell"); err != nil { + r.fail("gentle-shell", "not on PATH") + } else { + v, _ := e.StdoutOf("", p, "--version") + r.ok("gentle-shell", firstLine(v)) + } + if gobin, _ := e.StdoutOf("", "go", "env", "GOBIN"); samePath(gobin, e.BinDir()) { + r.warn("go env GOBIN", gobin+": any `go install` replaces gs-managed binaries; run: go env -u GOBIN") + } + for _, b := range Managed { + for _, p := range strayCopies(e, b.Name) { + r.warn("stray copy "+p, "not used by gs; remove it") + } + } + if entries, _ := os.ReadDir(e.Quarantine()); len(entries) > 0 { + names := make([]string, 0, len(entries)) + for _, en := range entries { + names = append(names, en.Name()) + } + r.warn("quarantine", "a sync step tried to install "+strings.Join(names, ", ")+" ("+e.Quarantine()+")") + } + for _, p := range staleEngramProcesses(e) { + r.warn("engram process "+p, "runs an old binary; restart it (gs sync restarts the service; agents: /mcp reconnect)") + } +} + +// checkManaged requires PATH to resolve name to our regular file in BinDir. +func checkManaged(e *Env, r *report, name string) { + target := e.Target(name) + fi, err := os.Lstat(target) + switch { + case err != nil: + r.fail(name, target+" is missing (run: gs sync)") + return + case fi.Mode()&os.ModeSymlink != 0: + dest, _ := os.Readlink(target) + r.fail(name, target+" is a symlink to "+dest+"; gs installs regular files (run: gs sync)") + return + case !fi.Mode().IsRegular() || fi.Mode()&0o111 == 0: + r.fail(name, target+" is not an executable file") + return + } + if p, err := exec.LookPath(name); err != nil || !samePath(p, target) { + r.fail(name+" on PATH", fmt.Sprintf("resolves to %q, not %s", p, target)) + return + } + v, _ := e.StdoutOf("", target, "version") + r.ok(name, firstLine(v)) +} + +// strayCopies are other builds of a managed binary that could shadow it later. +func strayCopies(e *Env, name string) []string { + var out []string + for _, p := range []string{ + filepath.Join(e.Home, "go", "bin", name), + e.Target(name) + ".pre-gw", + } { + if _, err := os.Lstat(p); err == nil { + out = append(out, p) + } + } + return out +} + +// staleEngramProcesses lists running engram processes whose executable is not +// the current ~/.local/bin/engram (deleted or replaced since they started). +func staleEngramProcesses(e *Env) []string { + pids, err := e.StdoutOf("", "pgrep", "-f", "engram (serve|mcp)") + if err != nil || pids == "" { + return nil + } + want, err := inode(e.Target("engram")) + if err != nil { + return nil + } + var stale []string + for _, pid := range strings.Fields(pids) { + if _, err := strconv.Atoi(pid); err != nil { + continue + } + got, ok := processInode(e, pid) + if ok && got != want { + stale = append(stale, pid) + } + } + return stale +} + +func processInode(e *Env, pid string) (uint64, bool) { + if runtime.GOOS == "linux" { + link, err := os.Readlink("/proc/" + pid + "/exe") + if err != nil { + return 0, false + } + if strings.HasSuffix(link, " (deleted)") { + return 0, true + } + ino, err := inode(link) + return ino, err == nil + } + out, err := e.StdoutOf("", "lsof", "-a", "-p", pid, "-d", "txt", "-Fi") + if err != nil { + return 0, false + } + for _, l := range strings.Split(out, "\n") { + if strings.HasPrefix(l, "i") { + n, err := strconv.ParseUint(l[1:], 10, 64) + return n, err == nil + } + } + return 0, false +} + +func inode(path string) (uint64, error) { + fi, err := os.Stat(path) + if err != nil { + return 0, err + } + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return 0, fmt.Errorf("no inode for %s", path) + } + return uint64(st.Ino), nil +} + +func samePath(a, b string) bool { + if a == "" || b == "" { + return false + } + ra, err1 := filepath.EvalSymlinks(filepath.Dir(a)) + rb, err2 := filepath.EvalSymlinks(filepath.Dir(b)) + if err1 != nil || err2 != nil { + return filepath.Clean(a) == filepath.Clean(b) + } + return filepath.Join(ra, filepath.Base(a)) == filepath.Join(rb, filepath.Base(b)) +} + +func firstLine(s string) string { + s = strings.TrimSpace(s) + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} diff --git a/gs/doctor_test.go b/gs/doctor_test.go new file mode 100644 index 0000000..db16e73 --- /dev/null +++ b/gs/doctor_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDoctorHealthyHostPasses(t *testing.T) { + s := newSandbox(t) + s.healthy() + if err := cmdDoctor(s.env); err != nil { + t.Fatalf("healthy host must pass: %v\n%s", err, s.out.String()) + } + contains(t, s.out.String(), "ok engram") + contains(t, s.out.String(), "ok engram instance-id") + notContains(t, s.out.String(), "FAIL") +} + +func TestDoctorFailsOnSymlinkedEngram(t *testing.T) { + s := newSandbox(t) + s.healthy() + real := filepath.Join(s.home, "elsewhere", "engram") + s.script(real, engramBody("3.6.2", false)) + os.Remove(s.env.Target("engram")) + if err := os.Symlink(real, s.env.Target("engram")); err != nil { + t.Fatal(err) + } + if cmdDoctor(s.env) == nil { + t.Fatal("symlinked engram must fail doctor") + } + contains(t, s.out.String(), "FAIL engram") + contains(t, s.out.String(), "is a symlink") +} + +func TestDoctorFailsOnPreV3Engram(t *testing.T) { + s := newSandbox(t) + s.healthy() + s.script(s.env.Target("engram"), engramBody("1.20.1", true)) + if cmdDoctor(s.env) == nil { + t.Fatal("pre-v3 engram must fail doctor") + } + contains(t, s.out.String(), "FAIL engram instance-id") +} + +func TestDoctorFailsWhenGhIsNotAuthenticated(t *testing.T) { + s := newSandbox(t) + s.healthy() + s.stub("gh", `exit 1`) + if cmdDoctor(s.env) == nil { + t.Fatal("unauthenticated gh must fail doctor") + } + contains(t, s.out.String(), "FAIL gh auth") +} + +func TestDoctorWarnsOnStrayCopiesAndGOBIN(t *testing.T) { + s := newSandbox(t) + s.healthy() + s.script(filepath.Join(s.home, "go", "bin", "engram"), engramBody("1.20.1", true)) + s.script(s.env.Target("gentle-ai")+".pre-gw", aiBody("1")) + t.Setenv("GOBIN", s.env.BinDir()) + if err := cmdDoctor(s.env); err != nil { + t.Fatalf("warnings alone must not fail doctor: %v\n%s", err, s.out.String()) + } + contains(t, s.out.String(), "WARN stray copy "+filepath.Join(s.home, "go", "bin", "engram")) + contains(t, s.out.String(), "WARN stray copy "+s.env.Target("gentle-ai")+".pre-gw") + contains(t, s.out.String(), "WARN go env GOBIN") +} + +func TestDoctorFailsWhenPathResolvesAnotherCopy(t *testing.T) { + s := newSandbox(t) + s.healthy() + s.stub("engram", engramBody("1.20.1", true)) + if cmdDoctor(s.env) == nil { + t.Fatal("engram resolving outside ~/.local/bin must fail") + } + contains(t, s.out.String(), "FAIL engram on PATH") +} diff --git a/gs/env.go b/gs/env.go new file mode 100644 index 0000000..1e1c0f2 --- /dev/null +++ b/gs/env.go @@ -0,0 +1,137 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// Org is the GitHub organization of every Gentle repository gs manages. +const Org = "Gentleman-Programming" + +// Managed binaries: gs is their only installer and places them as regular +// files in BinDir. Nothing else may replace them (see quarantine in sync.go). +var Managed = []Binary{ + {Name: "gentle-ai", Repo: "gentle-ai", Module: "github.com/gentleman-programming/gentle-ai/v4", Pkg: "cmd/gentle-ai"}, + {Name: "engram", Repo: "engram", Module: "github.com/Gentleman-Programming/engram/v3", Pkg: "cmd/engram"}, +} + +// Binary is one Go binary gs builds from a pinned commit. +type Binary struct { + Name string + Repo string + Module string + Pkg string +} + +// ImportPath is the `go install` path without a version. +func (b Binary) ImportPath() string { return b.Module + "/" + b.Pkg } + +// Env holds every path gs reads or writes; tests point Home at a temp dir. +type Env struct { + Home string + Stdout io.Writer + Stderr io.Writer + // Extra environment for child processes, "KEY=VALUE". + ChildEnv []string +} + +func newEnv() *Env { + home, err := os.UserHomeDir() + if err != nil || home == "" { + fmt.Fprintln(os.Stderr, "gs: cannot resolve HOME") + os.Exit(1) + } + return &Env{Home: home, Stdout: os.Stdout, Stderr: os.Stderr} +} + +func (e *Env) BinDir() string { return filepath.Join(e.Home, ".local", "bin") } +func (e *Env) StateDir() string { return filepath.Join(e.Home, ".local", "share", "gs") } +func (e *Env) Clones() string { return filepath.Join(e.Home, "dev", "gentle") } +func (e *Env) Trees() string { return filepath.Join(e.Home, "dev", "worktrees") } +func (e *Env) Quarantine() string { return filepath.Join(e.StateDir(), "quarantine") } + +// DotfilesDir is the dotfiles clone gs sync pulls (CORTEX_DOTFILES_DIR overrides). +func (e *Env) DotfilesDir() string { + if d := os.Getenv("CORTEX_DOTFILES_DIR"); d != "" { + return d + } + return filepath.Join(e.Home, "dev", "barbatdev", "cortex", "cortex-dotfiles") +} + +// Target is where a managed binary lives. +func (e *Env) Target(name string) string { return filepath.Join(e.BinDir(), name) } + +// environ is the child environment: ours plus ChildEnv overrides (last wins). +func (e *Env) environ() []string { + env := os.Environ() + if len(e.ChildEnv) == 0 { + return env + } + keys := map[string]bool{} + for _, kv := range e.ChildEnv { + keys[strings.SplitN(kv, "=", 2)[0]] = true + } + out := make([]string, 0, len(env)+len(e.ChildEnv)) + for _, kv := range env { + if !keys[strings.SplitN(kv, "=", 2)[0]] { + out = append(out, kv) + } + } + return append(out, e.ChildEnv...) +} + +// Output runs a command and returns its trimmed combined output. +func (e *Env) Output(dir, name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Env = e.environ() + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + err := cmd.Run() + return strings.TrimSpace(buf.String()), err +} + +// Stdout runs a command and returns only its trimmed stdout. +func (e *Env) StdoutOf(dir, name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Env = e.environ() + out, err := cmd.Output() + return strings.TrimSpace(string(out)), err +} + +// Run runs a command attached to the terminal. +func (e *Env) Run(dir, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = dir + cmd.Env = e.environ() + cmd.Stdin = os.Stdin + cmd.Stdout = e.Stdout + cmd.Stderr = e.Stderr + return cmd.Run() +} + +// Fail is a user-facing error: gs prints "gs: " and exits 1. +type Fail string + +func (f Fail) Error() string { return string(f) } + +func failf(format string, a ...any) error { return Fail(fmt.Sprintf(format, a...)) } + +// indent prefixes every line of s for nested command output. +func indent(s, prefix string) string { + if s == "" { + return "" + } + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + for i, l := range lines { + lines[i] = prefix + l + } + return strings.Join(lines, "\n") + "\n" +} diff --git a/gs/go.mod b/gs/go.mod new file mode 100644 index 0000000..976b3b9 --- /dev/null +++ b/gs/go.mod @@ -0,0 +1,3 @@ +module github.com/barbatdev/cortex-dotfiles/gs + +go 1.22 diff --git a/gs/helpers_test.go b/gs/helpers_test.go new file mode 100644 index 0000000..674be2a --- /dev/null +++ b/gs/helpers_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// sandbox is a throwaway HOME plus a stub dir first on PATH. Stubs are shell +// scripts, so tests run offline and never touch real binaries. +type sandbox struct { + t *testing.T + home string + stubs string + calls string + out bytes.Buffer + env *Env +} + +func newSandbox(t *testing.T) *sandbox { + t.Helper() + root := t.TempDir() + s := &sandbox{t: t, home: filepath.Join(root, "home"), stubs: filepath.Join(root, "stubs"), calls: filepath.Join(root, "calls")} + for _, d := range []string{s.stubs, filepath.Join(s.home, ".local", "bin")} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("HOME", s.home) + t.Setenv("PATH", s.stubs+":"+filepath.Join(s.home, ".local", "bin")+":/usr/bin:/bin:/usr/sbin:/sbin") + t.Setenv("CALLS", s.calls) + t.Setenv("GOBIN", "") + s.env = &Env{Home: s.home, Stdout: &s.out, Stderr: &s.out} + return s +} + +// script writes an executable shell script at path. +func (s *sandbox) script(path, body string) { + s.t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + s.t.Fatal(err) + } + if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + s.t.Fatal(err) + } +} + +func (s *sandbox) stub(name, body string) { s.script(filepath.Join(s.stubs, name), body) } + +// engramBody is a v3 engram stub; preV3 makes instance-id fail like Engram v1. +func engramBody(version string, preV3 bool) string { + id := "echo abc" + if preV3 { + id = "echo 'unknown command'; exit 1" + } + return `case "$1" in + version) echo "engram ` + version + `" ;; + instance-id) ` + id + ` ;; + serve|mcp) exit 0 ;; +esac` +} + +func aiBody(version string) string { + return `case "$1" in + --version|version) echo "gentle-ai ` + version + `" ;; + *) echo "gentle-ai $*" >> "$CALLS" ;; +esac` +} + +// healthy installs everything doctor expects on a good host. +func (s *sandbox) healthy() { + s.script(s.env.Target("engram"), engramBody("3.6.2", false)) + s.script(s.env.Target("gentle-ai"), aiBody("4.3.2")) + s.stub("gh", `[ "$1 $2" = "auth status" ] && exit 0; [ "$1 $2" = "auth token" ] && { echo tok; exit 0; }; echo "gh $*" >> "$CALLS"`) + s.stub("gentle-shell", `[ "$1" = --version ] && echo "gentle-shell 4.5.1-main.abcdef123456"; exit 0`) + s.stub("go", `[ "$1 $2" = "env GOBIN" ] && { echo "${GOBIN:-}"; exit 0; }; echo "go $* GOBIN=${GOBIN:-}" >> "$CALLS"`) + s.stub("pgrep", `exit 1`) + // Never reach the real service managers from a test. + s.stub("launchctl", `echo "launchctl $*" >> "$CALLS"; exit 0`) + s.stub("systemctl", `echo "systemctl $*" >> "$CALLS"; exit 0`) + s.stub("lsof", `exit 1`) +} + +func (s *sandbox) readCalls() string { + b, _ := os.ReadFile(s.calls) + return string(b) +} + +func contains(t *testing.T, got, want string) { + t.Helper() + if !strings.Contains(got, want) { + t.Fatalf("want output containing %q, got:\n%s", want, got) + } +} + +func notContains(t *testing.T, got, bad string) { + t.Helper() + if strings.Contains(got, bad) { + t.Fatalf("output must not contain %q, got:\n%s", bad, got) + } +} diff --git a/gs/main.go b/gs/main.go new file mode 100644 index 0000000..b82772b --- /dev/null +++ b/gs/main.go @@ -0,0 +1,106 @@ +// Command gs keeps gentle-shell, gentle-ai and Engram on main the same way on +// every host, and opens one disposable worktree per PR or issue. +// It never pushes, merges, force-deletes, or edits settings. +package main + +import ( + "errors" + "fmt" + "os" +) + +func main() { + e := newEnv() + if err := dispatch(e, os.Args[1:]); err != nil { + var f Fail + if errors.As(err, &f) { + fmt.Fprintf(os.Stderr, "gs: %s\n", f) + } else { + fmt.Fprintf(os.Stderr, "gs: %v\n", err) + } + os.Exit(1) + } +} + +func dispatch(e *Env, args []string) error { + cmd, rest := "", []string{} + if len(args) > 0 { + cmd, rest = args[0], args[1:] + } + switch cmd { + case "sync": + return cmdSync(e) + case "doctor": + return cmdDoctor(e) + case "pr": + return cmdPr(e, rest) + case "issue": + return cmdIssue(e, rest) + case "new": + return cmdNew(e, rest) + case "review": + return cmdReview(e, rest) + case "base": + return cmdBase(e, rest) + case "run": + return cmdRun(e, rest) + case "ls": + return cmdLs(e) + case "done": + return cmdDone(e) + case "help", "-h", "--help": + fmt.Fprint(e.Stdout, usage) + return nil + } + return cmdLaunch(e, args) +} + +const usage = `gs - gentle-shell on main, plus one disposable worktree per PR + + gs [args...] Open gentle-shell. Consumer (Mac, agent-dev): the + official gentle-shell on PATH, arguments unchanged. + Workshop (agent-gentle): inside a gentle-shell clone or + worktree, Pi loads that path (gs run); elsewhere the + synced clone, --isolated. + + gs sync Bring gentle-shell, gentle-ai and engram to main the + same way on every host: + - gentle-ai and engram are built by gs from one pinned + commit (GitHub main through gh; workshop: the clean + clones in ~/dev/gentle), verified (engram instance-id) + and installed in ~/.local/bin as regular files + - every other step runs with GOBIN in a quarantine dir, + so no other installer can replace them + - gentle-shell upgrade runs only when behind main + - gentle-ai sync --agents claude-code (GS_SYNC_AGENTS=a,b + to change, GS_SKIP_AI_SYNC=1 to skip); workshop also + runs gentle-shell --isolated setup + - restarts the Engram service when Engram changed + - pulls the dotfiles clone (clean, on main only; + GS_SKIP_DOTFILES=1 skips) + Ends with the gs doctor checks; any failure prints + INCOMPLETE and exits 1. + gs doctor Report everything on this host that differs from what + gs installs; changes nothing + + gs pr [repo] Review PR : worktree (reused and updated if it + exists), then Pi on it for shell PRs + gs issue [repo] Start issue : race check (stops if the issue is + closed, assigned to someone other than you, or + referenced by an open PR; GS_FORCE=1 skips it), branch + /- from origin/main, then Pi on it for + shell issues + gs new New branch from fresh origin/main + gs review Check out someone else's PR to test or review it + gs base Clean origin/main worktree, to compare failures + gs run [pi args...] Run gentle-shell --isolated with this worktree as --package-root + gs ls List open worktrees + gs done Remove the current worktree and its merged local branch + (drops odd/tasks records; refuses if real work would be lost) + gs help Show this help + + : ai | shell | engram | dot | desktop (or the full name) + [repo] is optional inside a clone or worktree of that repo. + GitHub access always goes through the authenticated gh. + Never pushes, merges, force-deletes, or edits settings. +` diff --git a/gs/sync.go b/gs/sync.go new file mode 100644 index 0000000..4b6b1f8 --- /dev/null +++ b/gs/sync.go @@ -0,0 +1,527 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" +) + +// syncRun carries one gs sync: step results and what changed. +type syncRun struct { + e *Env + failed []string + changed []string +} + +func (s *syncRun) step(label string, err error, out string) bool { + if err == nil { + fmt.Fprintf(s.e.Stdout, " %-34s ok\n", label) + return true + } + fmt.Fprintf(s.e.Stdout, " %-34s FAILED\n", label) + fmt.Fprint(s.e.Stdout, indent(out, " ")) + if out == "" { + fmt.Fprint(s.e.Stdout, indent(err.Error(), " ")) + } + s.failed = append(s.failed, label) + return false +} + +func (s *syncRun) skip(label, why string) { + fmt.Fprintf(s.e.Stdout, " %-34s skipped (%s)\n", label, why) +} + +// cmdSync brings gentle-shell, gentle-ai and Engram to main. +// +// Determinism rules: +// - gentle-ai and engram are built by gs only, from the commit resolved once +// (GitHub main through gh, or the clean local clone on workshop hosts), and +// land in ~/.local/bin as regular files through an atomic rename. +// - Every other step runs with GOBIN pointed at a quarantine dir, so a +// `go install` done by gentle-shell or gentle-ai can never replace them. +// - The sync ends with the same checks as gs doctor; any failure is INCOMPLETE. +func cmdSync(e *Env) error { + s := &syncRun{e: e} + ws := isWorkshop(e) + mode := "consumer (GitHub main through gh)" + if ws { + mode = "workshop (local clones)" + } + fmt.Fprintf(e.Stdout, "gs sync — %s\n", mode) + + token, err := preflight(e, ws) + if err != nil { + return err + } + // Children of the sync: GitHub through our gh token, Engram through our + // binary, stray Go installs into quarantine. + os.RemoveAll(e.Quarantine()) + if err := os.MkdirAll(e.Quarantine(), 0o755); err != nil { + return err + } + e.ChildEnv = append(e.ChildEnv, + "GOBIN="+e.Quarantine(), + "GH_TOKEN="+token, "GITHUB_TOKEN="+token, + "GENTLE_AI_ENGRAM_BIN="+e.Target("engram"), + "GENTLE_SHELL_NO_AUTO_SETUP=1", + ) + + before := versions(e) + var sources map[string]source + if ws { + sources = s.pullClones() + } else { + sources = s.resolveMain() + s.upgradeShell(before["gentle-shell"]) + } + + engramChanged := false + for _, b := range Managed { + src, ok := sources[b.Name] + if !ok { + continue + } + changed, err := s.buildAndInstall(b, src) + if err == nil && changed && b.Name == "engram" { + engramChanged = true + } + } + if ws { + s.workshopDeps() + } + s.aiSync(ws) + if engramChanged || len(staleEngramProcesses(e)) > 0 { + restartEngramService(s) + } + if s.syncDotfiles() { + s.rebuildSelf() + } + if !ws { + s.syncAppRepos() + } + + after := versions(e) + for _, name := range []string{"gentle-shell", "gentle-ai", "engram"} { + if before[name] != "" && after[name] != "" && before[name] != after[name] { + s.changed = append(s.changed, fmt.Sprintf("%s (%s -> %s)", name, before[name], after[name])) + } + } + fmt.Fprintf(e.Stdout, "\nversions: shell %s | gentle-ai %s | engram %s\n", orQ(after["gentle-shell"]), orQ(after["gentle-ai"]), orQ(after["engram"])) + if !ws { + s.checkShellFresh(after["gentle-shell"]) + } + + fmt.Fprintln(e.Stdout, "\nchecks:") + r := &report{env: e} + checkHost(e, r) + s.failed = append(s.failed, r.fails...) + + if len(s.failed) > 0 { + fmt.Fprintf(e.Stdout, "\nINCOMPLETE — failed: %s\n", strings.Join(s.failed, "; ")) + if len(s.changed) > 0 { + fmt.Fprintf(e.Stdout, "changed anyway: %s\n Restart Pi to pick them up.\n", strings.Join(s.changed, ", ")) + } + return failf("sync incomplete") + } + if len(s.changed) > 0 { + fmt.Fprintf(e.Stdout, "\nchanged: %s\n Restart Pi to pick them up.\n", strings.Join(s.changed, ", ")) + } else { + fmt.Fprintln(e.Stdout, "\nnothing changed — no Pi restart needed.") + } + return nil +} + +// preflight checks every tool before anything changes and returns the gh token. +func preflight(e *Env, ws bool) (string, error) { + if out, err := e.Output("", "gh", "auth", "status"); err != nil { + return "", failf("gh auth: gh is not authenticated (run: gh auth login): %s", firstLine(out)) + } + token, err := e.StdoutOf("", "gh", "auth", "token") + if err != nil || token == "" { + return "", failf("gh auth: cannot read the gh token") + } + if _, err := exec.LookPath("go"); err != nil { + return "", failf("go not found on PATH") + } + if !ws { + if _, err := exec.LookPath("gentle-shell"); err != nil { + return "", failf("gentle-shell not found on PATH; install it first") + } + } + if err := os.MkdirAll(e.BinDir(), 0o755); err != nil { + return "", err + } + return token, nil +} + +// source is where a managed binary is built from. +type source struct { + sha string // pinned commit + clone string // workshop: local clone dir (built with go build); empty = go install @sha +} + +func (s *syncRun) resolveMain() map[string]source { + out := map[string]source{} + for _, b := range Managed { + sha, err := s.e.StdoutOf("", "gh", "api", "repos/"+Org+"/"+b.Repo+"/commits/main", "--jq", ".sha") + if err != nil || len(sha) < 7 { + s.step(b.Name+" main commit", fmt.Errorf("gh api did not return %s main", b.Repo), sha) + continue + } + out[b.Name] = source{sha: sha} + } + return out +} + +// buildAndInstall builds b into a staging dir, verifies it, then atomically +// replaces ~/.local/bin/. Reports whether the installed bytes changed. +func (s *syncRun) buildAndInstall(b Binary, src source) (bool, error) { + label := fmt.Sprintf("%s build (%s)", b.Name, short(src.sha)) + stage, err := os.MkdirTemp(s.e.StateDir(), "build-") + if err != nil { + os.MkdirAll(s.e.StateDir(), 0o755) + if stage, err = os.MkdirTemp(s.e.StateDir(), "build-"); err != nil { + s.step(label, err, "") + return false, err + } + } + defer os.RemoveAll(stage) + built := filepath.Join(stage, b.Name) + var out string + if src.clone != "" { + out, err = s.e.Output(src.clone, "go", "build", "-trimpath", "-o", built, "./"+b.Pkg) + } else { + s.e.ChildEnv = append(s.e.ChildEnv, "GOBIN="+stage) + out, err = s.e.Output("", "go", "install", b.ImportPath()+"@"+src.sha) + s.e.ChildEnv = append(s.e.ChildEnv, "GOBIN="+s.e.Quarantine()) + } + if err == nil { + err = verifyBuilt(s.e, b, built) + if err != nil { + out = err.Error() + } + } + if !s.step(label, err, out) { + return false, err + } + changed, err := installAtomic(built, s.e.Target(b.Name)) + if err != nil { + s.step(b.Name+" install", err, "") + return false, err + } + return changed, nil +} + +// verifyBuilt rejects a build before it can replace the installed binary. +func verifyBuilt(e *Env, b Binary, path string) error { + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("build produced no %s", path) + } + if b.Name == "engram" { + if out, err := e.Output("", path, "instance-id"); err != nil { + return fmt.Errorf("built engram has no instance-id (pre-v3): %s", firstLine(out)) + } + } + if _, err := e.Output("", path, "version"); err != nil { + return fmt.Errorf("built %s does not run", b.Name) + } + return nil +} + +// installAtomic copies src next to dst and renames it over dst, so dst is +// always either the old or the new regular file. A symlink at dst is replaced +// by the file, never followed. +func installAtomic(src, dst string) (bool, error) { + if same, _ := sameContent(src, dst); same { + if fi, err := os.Lstat(dst); err == nil && fi.Mode().IsRegular() { + return false, nil + } + } + tmp, err := os.CreateTemp(filepath.Dir(dst), "."+filepath.Base(dst)+".gs-") + if err != nil { + return false, err + } + defer os.Remove(tmp.Name()) + in, err := os.Open(src) + if err != nil { + tmp.Close() + return false, err + } + _, err = io.Copy(tmp, in) + in.Close() + if err == nil { + err = tmp.Chmod(0o755) + } + if err == nil { + err = tmp.Sync() + } + if cerr := tmp.Close(); err == nil { + err = cerr + } + if err != nil { + return false, err + } + return true, os.Rename(tmp.Name(), dst) +} + +func sameContent(a, b string) (bool, error) { + ha, err := fileSHA(a) + if err != nil { + return false, err + } + hb, err := fileSHA(b) + if err != nil { + return false, err + } + return ha == hb, nil +} + +func fileSHA(p string) (string, error) { + f, err := os.Open(p) // follows symlinks: content comparison only + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// aiSync materializes the managed configs every runtime reads. +// GS_SYNC_AGENTS scopes runtimes (default claude-code); GS_SKIP_AI_SYNC=1 skips. +func (s *syncRun) aiSync(ws bool) { + if os.Getenv("GS_SKIP_AI_SYNC") == "1" { + s.skip("gentle-ai sync", "GS_SKIP_AI_SYNC=1") + return + } + agents := os.Getenv("GS_SYNC_AGENTS") + if agents == "" { + agents = "claude-code" + } + out, err := s.e.Output("", s.e.Target("gentle-ai"), "sync", "--agents", agents) + if s.step("gentle-ai sync", err, out) { + if n := syncedFiles(out); n > 0 { + s.changed = append(s.changed, fmt.Sprintf("managed configs (%d files)", n)) + } + } + if ws { + shell := filepath.Join(s.e.Clones(), "gentle-shell") + out, err := s.e.Output(shell, "node", filepath.Join(shell, "bin", "gentle-shell.mjs"), "--isolated", "setup") + s.step("gentle-shell setup (isolated home)", err, out) + } +} + +func syncedFiles(out string) int { + for _, l := range strings.Split(out, "\n") { + if rest, ok := strings.CutPrefix(strings.TrimSpace(l), "Sync actions executed: "); ok { + n, _ := strconv.Atoi(strings.Fields(rest)[0]) + return n + } + } + return 0 +} + +// restartEngramService restarts the Engram server so it runs the new binary. +// Agent MCP children keep the old one until reconnected (/mcp). +func restartEngramService(s *syncRun) { + label := "engram service restart" + if runtime.GOOS == "darwin" { + list, _ := s.e.StdoutOf("", "launchctl", "list") + if !strings.Contains(list, "com.gentleman-programming.engram") { + s.skip(label, "no LaunchAgent com.gentleman-programming.engram") + return + } + out, err := s.e.Output("", "launchctl", "kickstart", "-k", "gui/"+strconv.Itoa(os.Getuid())+"/com.gentleman-programming.engram") + s.step(label, err, out) + return + } + if _, err := exec.LookPath("systemctl"); err != nil { + s.skip(label, "no systemctl") + return + } + if out, err := s.e.Output("", "systemctl", "--user", "cat", "engram.service"); err != nil { + s.skip(label, "no engram.service: "+firstLine(out)) + return + } + out, err := s.e.Output("", "systemctl", "--user", "restart", "engram.service") + s.step(label, err, out) +} + +// syncDotfiles pulls the dotfiles clone when it is clean and on main, and +// reports whether the clone is now current main (gs may rebuild from it). +func (s *syncRun) syncDotfiles() bool { + const label = "dotfiles" + dir := s.e.DotfilesDir() + switch { + case os.Getenv("GS_SKIP_DOTFILES") == "1": + s.skip(label, "GS_SKIP_DOTFILES=1") + return false + case !isDir(filepath.Join(dir, ".git")): + s.skip(label, "no clone at "+dir) + return false + } + if st, _ := s.e.StdoutOf(dir, "git", "status", "--porcelain"); st != "" { + s.skip(label, dir+" has local changes") + return false + } + if br, _ := s.e.StdoutOf(dir, "git", "branch", "--show-current"); br != "main" { + s.skip(label, fmt.Sprintf("%s is on %q, not main", dir, br)) + return false + } + old, _ := s.e.StdoutOf(dir, "git", "rev-parse", "--short", "HEAD") + out, err := s.e.Output(dir, "git", "pull", "-q", "--ff-only", "origin", "main") + if !s.step(label, err, out) { + return false + } + if cur, _ := s.e.StdoutOf(dir, "git", "rev-parse", "--short", "HEAD"); cur != old { + s.changed = append(s.changed, fmt.Sprintf("dotfiles (%s -> %s)", old, cur)) + } + return true +} + +// rebuildSelf builds gs from the dotfiles clone (current main) and installs it +// as ~/.local/bin/gs. -trimpath and -buildvcs=false make the bytes depend only +// on the source and the toolchain, so an unchanged source changes nothing. +func (s *syncRun) rebuildSelf() { + const label = "gs (self)" + stage, err := os.MkdirTemp(s.e.StateDir(), "build-") + if err != nil { + s.step(label, err, "") + return + } + defer os.RemoveAll(stage) + built := filepath.Join(stage, "gs") + out, err := s.e.Output(filepath.Join(s.e.DotfilesDir(), "gs"), "go", "build", "-trimpath", "-buildvcs=false", "-o", built, ".") + if !s.step(label, err, out) { + return + } + changed, err := installAtomic(built, s.e.Target("gs")) + if err != nil { + s.step("gs (self) install", err, "") + return + } + if changed { + s.changed = append(s.changed, "gs (rebuilt)") + } +} + +// MacAppRepos are synced on consumer hosts when their clean clone exists. +var MacAppRepos = []string{"gentle-dot", "gentle-shell-desktop"} + +func (s *syncRun) syncAppRepos() { + for _, repo := range MacAppRepos { + dir := filepath.Join(s.e.Clones(), repo) + if !isDir(filepath.Join(dir, ".git")) { + continue + } + label := repo + if st, _ := s.e.StdoutOf(dir, "git", "status", "--porcelain"); st != "" { + s.skip(label, repo+" has local changes") + continue + } + s.e.Output(dir, "git", "checkout", "-q", "main") + out, err := s.e.Output(dir, "git", "pull", "-q", "--ff-only", "--tags", "origin", "main") + s.step(label, err, out) + } +} + +// upgradeShell runs `gentle-shell upgrade --channel main` only when the shell +// is behind GitHub main. The upgrade asks the GitHub API without a token (60 +// requests/hour per IP), so it must not run when there is nothing to do. +func (s *syncRun) upgradeShell(version string) { + const label = "gentle-shell upgrade (main)" + built := mainBuildSHA(version) + if remote := s.shellMain(); built != "" && remote != "" && strings.HasPrefix(remote, built) { + s.skip(label, "already at main "+built) + return + } + out, err := s.e.Output("", "gentle-shell", "upgrade", "--channel", "main") + if err != nil && strings.Contains(out, "HTTP 403") { + out += "\n(gentle-shell queries GitHub without a token: unauthenticated rate limit; gs sync again later)" + } + s.step(label, err, out) +} + +func (s *syncRun) shellMain() string { + sha, err := s.e.StdoutOf("", "gh", "api", "repos/"+Org+"/gentle-pi/commits/main", "--jq", ".sha") + if err != nil { + return "" + } + return sha +} + +// mainBuildSHA extracts from a main-channel version x.y.z-main.. +func mainBuildSHA(version string) string { + if i := strings.LastIndex(version, "-main."); i >= 0 { + return version[i+len("-main."):] + } + return "" +} + +// checkShellFresh compares the main-channel build SHA with GitHub main (gh). +func (s *syncRun) checkShellFresh(version string) { + built := mainBuildSHA(version) + if built == "" { + s.failed = append(s.failed, fmt.Sprintf("gentle-shell %q is not a main-channel build", version)) + return + } + remote := s.shellMain() + if remote == "" { + fmt.Fprintln(s.e.Stdout, "WARN: could not read gentle-pi main through gh; freshness unverified") + return + } + if !strings.HasPrefix(remote, built) { + s.failed = append(s.failed, fmt.Sprintf("gentle-shell behind main (built %s, main %s)", built, short(remote))) + } +} + +// versions reads installed versions for the before/after summary. +func versions(e *Env) map[string]string { + v := map[string]string{} + if out, err := e.StdoutOf("", "gentle-shell", "--version"); err == nil { + v["gentle-shell"] = field(firstLine(out), 1) + } + if out, err := e.StdoutOf("", e.Target("gentle-ai"), "--version"); err == nil { + v["gentle-ai"] = field(firstLine(out), 1) + } + if out, err := e.StdoutOf("", e.Target("engram"), "version"); err == nil { + v["engram"] = field(firstLine(out), 1) + } + return v +} + +func field(s string, i int) string { + f := strings.Fields(s) + if i < len(f) { + return f[i] + } + return "" +} + +func short(sha string) string { + if len(sha) > 12 { + return sha[:12] + } + return sha +} + +func orQ(s string) string { + if s == "" { + return "?" + } + return s +} + +func isDir(p string) bool { + fi, err := os.Stat(p) + return err == nil && fi.IsDir() +} diff --git a/gs/sync_test.go b/gs/sync_test.go new file mode 100644 index 0000000..b6a02b9 --- /dev/null +++ b/gs/sync_test.go @@ -0,0 +1,230 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const ( + aiSHA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + engramSHA = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + shellSHA = "abcdef1234567890abcdef1234567890abcdef12" +) + +// consumerHost is a Mac/agent-dev style host: no clones, gentle-shell on PATH. +// The go stub "builds" stubs into $GOBIN; ENGRAM_BUILD_PRE_V3=1 builds a v1. +func consumerHost(t *testing.T) *sandbox { + s := newSandbox(t) + s.healthy() + t.Setenv("GS_SKIP_DOTFILES", "1") + s.stub("gh", `case "$*" in + "auth status") exit 0 ;; + "auth token") echo tok ;; + "api repos/Gentleman-Programming/gentle-ai/commits/main --jq .sha") echo `+aiSHA+` ;; + "api repos/Gentleman-Programming/engram/commits/main --jq .sha") echo `+engramSHA+` ;; + "api repos/Gentleman-Programming/gentle-pi/commits/main --jq .sha") echo `+shellSHA+` ;; + *) echo "gh $*" >> "$CALLS" ;; +esac`) + s.stub("go", `[ "$1 $2" = "env GOBIN" ] && { echo "${GOBIN:-}"; exit 0; } +echo "go $* GOBIN=$GOBIN" >> "$CALLS" +[ "$1" = install ] || exit 0 +mkdir -p "$GOBIN" +case "$2" in + *engram*) if [ "${ENGRAM_BUILD_PRE_V3:-}" = 1 ]; then id="exit 1"; v=1.20.1; else id="echo abc"; v=3.7.0; fi + printf '#!/bin/sh\ncase "$1" in version) echo "engram %s";; instance-id) %s;; esac\n' "$v" "$id" > "$GOBIN/engram" ;; + *gentle-ai*) printf '#!/bin/sh\ncase "$1" in --version|version) echo "gentle-ai 4.4.0";; *) echo "gentle-ai $* engram=$GENTLE_AI_ENGRAM_BIN GOBIN=$GOBIN" >> "$CALLS";; esac\n' > "$GOBIN/gentle-ai" ;; +esac +chmod +x "$GOBIN"/*`) + // gentle-shell reports the version in $HOME/shellver; upgrade moves it to main. + ver := filepath.Join(s.home, "shellver") + os.WriteFile(ver, []byte("4.5.1-main.111111111111\n"), 0o644) + s.stub("gentle-shell", `case "$1" in + --version) echo "gentle-shell $(cat "$HOME/shellver")" ;; + upgrade) echo "upgrade GOBIN=$GOBIN token=${GH_TOKEN:-}" >> "$CALLS" + [ -n "${UPGRADE_STRAY:-}" ] && { mkdir -p "$GOBIN"; echo v1 > "$GOBIN/engram"; } + [ "${UPGRADE_RC:-0}" = 0 ] && echo "4.5.1-main.abcdef123456" > "$HOME/shellver" + exit "${UPGRADE_RC:-0}" ;; +esac`) + return s +} + +func runSync(s *sandbox) error { + s.out.Reset() + return cmdSync(s.env) +} + +func fileBody(t *testing.T, p string) string { + t.Helper() + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func TestSyncBuildsPinnedMainCommitsAsRegularFiles(t *testing.T) { + s := consumerHost(t) + if err := runSync(s); err != nil { + t.Fatalf("sync failed: %v\n%s", err, s.out.String()) + } + calls := s.readCalls() + contains(t, calls, "go install github.com/gentleman-programming/gentle-ai/v4/cmd/gentle-ai@"+aiSHA) + contains(t, calls, "go install github.com/Gentleman-Programming/engram/v3/cmd/engram@"+engramSHA) + for _, n := range []string{"engram", "gentle-ai"} { + fi, err := os.Lstat(s.env.Target(n)) + if err != nil || !fi.Mode().IsRegular() { + t.Fatalf("%s must be a regular file: %v", n, err) + } + } + contains(t, fileBody(t, s.env.Target("engram")), "engram 3.7.0") + contains(t, s.out.String(), "engram (3.6.2 -> 3.7.0)") +} + +func TestSyncReplacesSymlinkedBinaries(t *testing.T) { + s := consumerHost(t) + real := filepath.Join(s.home, "elsewhere", "engram") + s.script(real, engramBody("3.6.2", false)) + os.Remove(s.env.Target("engram")) + os.Symlink(real, s.env.Target("engram")) + if err := runSync(s); err != nil { + t.Fatalf("sync failed: %v\n%s", err, s.out.String()) + } + fi, _ := os.Lstat(s.env.Target("engram")) + if fi.Mode()&os.ModeSymlink != 0 { + t.Fatal("engram must no longer be a symlink") + } + contains(t, fileBody(t, real), "engram 3.6.2") // the link target is left alone +} + +func TestSyncQuarantinesStrayInstallsFromOtherSteps(t *testing.T) { + s := consumerHost(t) + t.Setenv("UPGRADE_STRAY", "1") + t.Setenv("GOBIN", s.env.BinDir()) // the host's own go env pointing at ~/.local/bin + err := runSync(s) + contains(t, s.readCalls(), "upgrade GOBIN="+s.env.Quarantine()) + contains(t, fileBody(t, s.env.Target("engram")), "engram 3.7.0") + contains(t, s.out.String(), "WARN quarantine") + if err != nil { + t.Fatalf("a quarantined stray install is a warning, not a failure: %v\n%s", err, s.out.String()) + } +} + +func TestSyncNeverInstallsAPreV3Engram(t *testing.T) { + s := consumerHost(t) + t.Setenv("ENGRAM_BUILD_PRE_V3", "1") + if runSync(s) == nil { + t.Fatal("a pre-v3 engram build must fail the sync") + } + contains(t, s.out.String(), "INCOMPLETE") + contains(t, s.out.String(), "engram build") + contains(t, fileBody(t, s.env.Target("engram")), "engram 3.6.2") // old one kept +} + +func TestSyncGentleAiUsesOurEngramAndQuarantinedGOBIN(t *testing.T) { + s := consumerHost(t) + if err := runSync(s); err != nil { + t.Fatalf("sync failed: %v\n%s", err, s.out.String()) + } + contains(t, s.readCalls(), "gentle-ai sync --agents claude-code engram="+s.env.Target("engram")+" GOBIN="+s.env.Quarantine()) +} + +func TestSyncPassesGhTokenToGentleShell(t *testing.T) { + s := consumerHost(t) + runSync(s) + contains(t, s.readCalls(), "token=tok") +} + +func TestSyncFailedUpgradeIsIncompleteButBinariesStillUpdate(t *testing.T) { + s := consumerHost(t) + t.Setenv("UPGRADE_RC", "1") + if runSync(s) == nil { + t.Fatal("failed upgrade must make sync fail") + } + contains(t, s.out.String(), "gentle-shell upgrade (main)") + contains(t, s.out.String(), "INCOMPLETE") + contains(t, fileBody(t, s.env.Target("engram")), "engram 3.7.0") +} + +func TestSyncRefusesBeforeChangingAnythingWithoutGh(t *testing.T) { + s := consumerHost(t) + s.stub("gh", `exit 1`) + err := runSync(s) + if err == nil { + t.Fatal("sync without gh auth must fail") + } + if strings.Contains(s.readCalls(), "go install") || strings.Contains(s.readCalls(), "upgrade") { + t.Fatalf("nothing may run before preflight passes:\n%s", s.readCalls()) + } + contains(t, err.Error(), "gh auth") +} + +func TestSyncRestartsEngramServiceWhenEngramChanged(t *testing.T) { + s := consumerHost(t) + s.stub("systemctl", `echo "systemctl $*" >> "$CALLS"; exit 0`) + s.stub("launchctl", `echo "launchctl $*" >> "$CALLS"; [ "$1" = list ] && echo "1 0 com.gentleman-programming.engram"; exit 0`) + if err := runSync(s); err != nil { + t.Fatalf("sync failed: %v\n%s", err, s.out.String()) + } + calls := s.readCalls() + if !strings.Contains(calls, "systemctl --user restart engram.service") && !strings.Contains(calls, "launchctl kickstart -k") { + t.Fatalf("engram service must be restarted:\n%s", calls) + } +} + +func TestSyncSecondRunChangesNothing(t *testing.T) { + s := consumerHost(t) + runSync(s) + if err := runSync(s); err != nil { + t.Fatalf("second sync failed: %v\n%s", err, s.out.String()) + } + contains(t, s.out.String(), "nothing changed") +} + +func TestSyncSkipsShellUpgradeWhenAlreadyOnMain(t *testing.T) { + s := consumerHost(t) + os.WriteFile(filepath.Join(s.home, "shellver"), []byte("4.5.1-main.abcdef123456\n"), 0o644) + if err := runSync(s); err != nil { + t.Fatalf("sync failed: %v\n%s", err, s.out.String()) + } + notContains(t, s.readCalls(), "upgrade") + contains(t, s.out.String(), "skipped (already at main abcdef123456)") +} + +// dotfilesClone is a clean dotfiles clone on main with a bare origin. +func dotfilesClone(t *testing.T, s *sandbox) string { + dir := filepath.Join(s.home, "dotfiles") + os.MkdirAll(filepath.Join(dir, "gs"), 0o755) + git(t, dir, "init", "-q", "-b", "main") + os.WriteFile(filepath.Join(dir, "gs", "main.go"), []byte("package main\n"), 0o644) + git(t, dir, "add", ".") + git(t, dir, "commit", "-q", "-m", "init") + bare := filepath.Join(s.home, "dotfiles.git") + git(t, dir, "clone", "-q", "--bare", dir, bare) + git(t, dir, "remote", "add", "origin", bare) + git(t, dir, "fetch", "-q", "origin") + t.Setenv("CORTEX_DOTFILES_DIR", dir) + t.Setenv("GS_SKIP_DOTFILES", "") + return dir +} + +func TestSyncRebuildsItselfFromTheDotfilesCloneOnMain(t *testing.T) { + s := consumerHost(t) + dir := dotfilesClone(t, s) + prev := strings.TrimPrefix(fileBody(t, filepath.Join(s.stubs, "go")), "#!/bin/sh\n") + s.stub("go", `if [ "$1" = build ]; then echo "go $*" >> "$CALLS"; printf '#!/bin/sh\necho gs-built\n' > "$5"; chmod +x "$5"; exit 0; fi +`+prev) + if err := runSync(s); err != nil { + t.Fatalf("sync failed: %v\n%s", err, s.out.String()) + } + contains(t, s.readCalls(), "go build -trimpath -buildvcs=false -o") + contains(t, fileBody(t, s.env.Target("gs")), "gs-built") + // A feature branch is never installed as gs. + os.Remove(s.env.Target("gs")) + git(t, dir, "checkout", "-q", "-b", "feat/x") + runSync(s) + if exists(s.env.Target("gs")) { + t.Fatal("gs must not be rebuilt from a branch other than main") + } +} diff --git a/gs/workshop.go b/gs/workshop.go new file mode 100644 index 0000000..6548570 --- /dev/null +++ b/gs/workshop.go @@ -0,0 +1,102 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Repos are the Gentle repositories gs manages clones and worktrees for. +var Repos = []string{"gentle-ai", "gentle-shell", "engram"} + +// isWorkshop: a host with clean clones in ~/dev/gentle (agent-gentle). Pi +// loads the gentle-shell clone and binaries build from the local clones. +func isWorkshop(e *Env) bool { + url, err := e.StdoutOf("", "git", "-C", filepath.Join(e.Clones(), "gentle-shell"), "remote", "get-url", "origin") + return err == nil && strings.Contains(url, Org+"/gentle-shell") +} + +// pullClones fast-forwards every clean clone to origin/main and returns the +// build sources pinned to the resulting HEADs. +func (s *syncRun) pullClones() map[string]source { + out := map[string]source{} + for _, repo := range Repos { + dir := filepath.Join(s.e.Clones(), repo) + label := repo + " clone" + if !isDir(filepath.Join(dir, ".git")) { + s.step(label, fmt.Errorf("missing clean clone %s", dir), "") + continue + } + // Untracked files (.engram/, odd/ left by sessions) survive checkout and + // pull untouched; only tracked edits would be at risk. + if st, _ := s.e.StdoutOf(dir, "git", "status", "--porcelain", "--untracked-files=no"); st != "" { + s.step(label, fmt.Errorf("%s has local changes; clean clones only", dir), "") + continue + } + var err error + var msg string + for _, args := range [][]string{ + {"checkout", "-q", "main"}, + {"fetch", "-q", "--tags", "origin"}, + {"pull", "-q", "--ff-only", "origin", "main"}, + } { + if msg, err = s.e.Output(dir, "git", args...); err != nil { + break + } + } + if !s.step(label, err, msg) { + continue + } + sha, _ := s.e.StdoutOf(dir, "git", "rev-parse", "HEAD") + for _, b := range Managed { + if b.Repo == repo { + out[b.Name] = source{sha: sha, clone: dir} + } + } + } + return out +} + +// workshopDeps installs the clones' JS dependencies and registers our +// gentle-ai where the gentle-shell launcher reads it. +func (s *syncRun) workshopDeps() { + shell := filepath.Join(s.e.Clones(), "gentle-shell") + out, err := s.e.Output(shell, "pnpm", "install", "--frozen-lockfile", "--silent") + s.step("gentle-shell pnpm install", err, out) + plugin := filepath.Join(s.e.Clones(), "engram", "plugin", "pi") + if isDir(plugin) { + out, err := s.e.Output(plugin, "npm", "install", "--no-save", "--no-audit", "--no-fund", "--omit=peer", "--silent") + s.step("engram pi plugin npm install", err, out) + } + s.step("gentle-ai dev-binary registration", registerDevBinary(s.e), "") +} + +// DevBinaryRegistration is install-level state the official launcher reads +// from ~/.pi/gentle-ai even in the isolated home. +func (e *Env) DevBinaryRegistration() string { + return filepath.Join(e.Home, ".pi", "gentle-ai", "dev-binary.json") +} + +func registerDevBinary(e *Env) error { + reg := e.DevBinaryRegistration() + if err := os.MkdirAll(filepath.Dir(reg), 0o755); err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(reg), ".dev-binary.") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + _, err = fmt.Fprintf(tmp, "{\"schema\":\"gentle-pi.dev-binary/v1\",\"path\":%q}\n", e.Target("gentle-ai")) + if err == nil { + err = tmp.Chmod(0o600) + } + if cerr := tmp.Close(); err == nil { + err = cerr + } + if err != nil { + return err + } + return os.Rename(tmp.Name(), reg) +} diff --git a/gs/workshop_test.go b/gs/workshop_test.go new file mode 100644 index 0000000..e994a4a --- /dev/null +++ b/gs/workshop_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func git(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.email=t@t", "-c", "user.name=t"}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +// workshopHost has clean clones of the three repos with a bare origin each, +// under a path that keeps the Gentleman-Programming name. +func workshopHost(t *testing.T) *sandbox { + s := newSandbox(t) + s.healthy() + t.Setenv("GS_SKIP_DOTFILES", "1") + origins := filepath.Join(filepath.Dir(s.home), Org) + for _, r := range Repos { + c := filepath.Join(s.env.Clones(), r) + os.MkdirAll(c, 0o755) + git(t, c, "init", "-q", "-b", "main") + git(t, c, "commit", "-q", "--allow-empty", "-m", "init") + bare := filepath.Join(origins, r+".git") + git(t, c, "clone", "-q", "--bare", c, bare) + git(t, c, "remote", "add", "origin", bare) + git(t, c, "fetch", "-q", "origin") + } + os.MkdirAll(filepath.Join(s.env.Clones(), "engram", "plugin", "pi"), 0o755) + s.stub("go", `[ "$1 $2" = "env GOBIN" ] && { echo "${GOBIN:-}"; exit 0; } +echo "go $* pwd=$(pwd)" >> "$CALLS" +[ "$1" = build ] || exit 0 +out="$4"; mkdir -p "$(dirname "$out")" +case "$out" in + */engram) printf '#!/bin/sh\ncase "$1" in version) echo "engram 3.8.0";; instance-id) echo x;; esac\n' > "$out" ;; + */gentle-ai) printf '#!/bin/sh\ncase "$1" in --version|version) echo "gentle-ai 4.5.0";; *) echo "gentle-ai $*" >> "$CALLS";; esac\n' > "$out" ;; +esac +chmod +x "$out"`) + for _, b := range []string{"pnpm", "npm", "node"} { + s.stub(b, `echo "`+b+` $*" >> "$CALLS"`) + } + return s +} + +func TestWorkshopSyncBuildsFromLocalClones(t *testing.T) { + s := workshopHost(t) + if err := runSync(s); err != nil { + t.Fatalf("workshop sync failed: %v\n%s", err, s.out.String()) + } + calls := s.readCalls() + contains(t, s.out.String(), "workshop (local clones)") + contains(t, calls, "go build -trimpath -o") + contains(t, calls, "/home/dev/gentle/engram\n") + notContains(t, calls, "go install") + notContains(t, calls, "upgrade") + contains(t, fileBody(t, s.env.Target("engram")), "engram 3.8.0") + contains(t, calls, "gentle-shell.mjs --isolated setup") + reg := fileBody(t, s.env.DevBinaryRegistration()) + contains(t, reg, `"path":"`+s.env.Target("gentle-ai")+`"`) + if fi, _ := os.Stat(s.env.DevBinaryRegistration()); fi.Mode().Perm() != 0o600 { + t.Fatalf("registration must be 0600, is %v", fi.Mode().Perm()) + } +} + +func TestWorkshopSyncRefusesDirtyClone(t *testing.T) { + s := workshopHost(t) + c := filepath.Join(s.env.Clones(), "engram") + os.WriteFile(filepath.Join(c, "f"), []byte("x"), 0o644) + git(t, c, "add", "f") + if runSync(s) == nil { + t.Fatal("a dirty clone must fail the sync") + } + contains(t, s.out.String(), "has local changes; clean clones only") + contains(t, fileBody(t, s.env.Target("engram")), "engram 3.6.2") +} diff --git a/gs/worktree.go b/gs/worktree.go new file mode 100644 index 0000000..2292227 --- /dev/null +++ b/gs/worktree.go @@ -0,0 +1,520 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" +) + +// repoName maps a short or full repo name to its GitHub name. +func repoName(arg string) (string, error) { + switch arg { + case "ai", "gentle-ai": + return "gentle-ai", nil + case "shell", "gentle-shell": + return "gentle-shell", nil + case "engram": + return "engram", nil + case "dot", "gentle-dot": + return "gentle-dot", nil + case "desktop", "gentle-shell-desktop": + return "gentle-shell-desktop", nil + } + return "", failf("unknown repo '%s' (use: ai, shell, engram, dot, desktop)", arg) +} + +// repoHere is the repo of the current directory's origin. +func repoHere(e *Env) (string, error) { + url, err := e.StdoutOf("", "git", "remote", "get-url", "origin") + if err != nil { + return "", failf("give the repo (ai, shell, engram) or run it inside one") + } + base := strings.TrimSuffix(filepath.Base(url), ".git") + switch base { + case "gentle-ai", "gentle-shell", "engram", "gentle-dot", "gentle-shell-desktop": + return base, nil + case "gentle-pi": + return "gentle-shell", nil + } + return "", failf("cannot tell the repo from %s; give it: ai, shell, engram, dot, desktop", url) +} + +func cloneOf(e *Env, repo string) (string, error) { + dir := filepath.Join(e.Clones(), repo) + if !isDir(filepath.Join(dir, ".git")) { + return "", failf("missing clean clone %s", dir) + } + if out, err := e.Output(dir, "git", "fetch", "-q", "--prune", "origin"); err != nil { + return "", failf("git fetch in %s failed: %s", dir, out) + } + return dir, nil +} + +func installDeps(e *Env, path string) error { + if _, err := os.Stat(filepath.Join(path, "pnpm-lock.yaml")); err != nil { + return nil + } + return e.Run(path, "pnpm", "install", "--frozen-lockfile") +} + +func finish(e *Env, path string) { + fmt.Fprintf(e.Stdout, "\nready: %s\n cd %s\n", path, path) +} + +func gitRun(e *Env, dir string, args ...string) error { + if out, err := e.Output(dir, "git", args...); err != nil { + return failf("git %s: %s", strings.Join(args, " "), out) + } + return nil +} + +// herdrRename labels the Herdr workspace, tab and pane " - pr/is #". +// Best-effort and silent: gs never fails because Herdr is absent. +func herdrRename(e *Env, label string) { + if os.Getenv("HERDR_ENV") != "1" { + return + } + if _, err := exec.LookPath("herdr"); err != nil { + return + } + e.Output("", "herdr", "workspace", "rename", os.Getenv("HERDR_WORKSPACE_ID"), label) + e.Output("", "herdr", "tab", "rename", os.Getenv("HERDR_TAB_ID"), label) + e.Output("", "herdr", "pane", "rename", os.Getenv("HERDR_PANE_ID"), label) +} + +// herdrTabRepo labels only the tab with the current repo name. +func herdrTabRepo(e *Env) { + if os.Getenv("HERDR_ENV") != "1" { + return + } + if _, err := exec.LookPath("herdr"); err != nil { + return + } + url, err := e.StdoutOf("", "git", "remote", "get-url", "origin") + if err != nil { + return + } + e.Output("", "herdr", "tab", "rename", os.Getenv("HERDR_TAB_ID"), strings.TrimSuffix(filepath.Base(url), ".git")) +} + +func cmdNew(e *Env, args []string) error { + if len(args) != 2 { + return failf("usage: gs new ") + } + repo, err := repoName(args[0]) + if err != nil { + return err + } + clone, err := cloneOf(e, repo) + if err != nil { + return err + } + path := filepath.Join(e.Trees(), repo+"-"+args[1]) + if exists(path) { + return failf("%s already exists", path) + } + if err := gitRun(e, clone, "worktree", "add", "-q", "-b", args[1], path, "origin/main"); err != nil { + return err + } + herdrRename(e, repo+" - "+args[1]) + if err := installDeps(e, path); err != nil { + return err + } + finish(e, path) + return nil +} + +func cmdReview(e *Env, args []string) error { + if len(args) != 2 { + return failf("usage: gs review ") + } + repo, err := repoName(args[0]) + if err != nil { + return err + } + if err := reviewWorktree(e, repo, args[1]); err != nil { + return err + } + finish(e, filepath.Join(e.Trees(), repo+"-pr-"+args[1])) + return nil +} + +func reviewWorktree(e *Env, repo, num string) error { + clone, err := cloneOf(e, repo) + if err != nil { + return err + } + path := filepath.Join(e.Trees(), repo+"-pr-"+num) + if exists(path) { + return failf("%s already exists (gs done inside it first)", path) + } + if err := gitRun(e, clone, "worktree", "add", "-q", "--detach", path, "origin/main"); err != nil { + return err + } + if err := e.Run(path, "gh", "pr", "checkout", num, "-R", Org+"/"+repo, "-b", "pr-"+num); err != nil { + return failf("gh pr checkout %s failed", num) + } + herdrRename(e, repo+" - pr #"+num) + return installDeps(e, path) +} + +// repoAndNumber parses "[repo] ". +func repoAndNumber(e *Env, cmd string, args []string) (string, string, error) { + var repo, num string + var err error + switch len(args) { + case 1: + repo, err = repoHere(e) + num = args[0] + case 2: + repo, err = repoName(args[0]) + num = args[1] + default: + return "", "", failf("usage: gs %s [repo] ", cmd) + } + if err != nil { + return "", "", err + } + if _, err := strconv.ParseUint(num, 10, 64); err != nil { + return "", "", failf("'%s' is not a number", num) + } + return repo, num, nil +} + +func prPrompt(repo, num string) string { + // The cortex variant of the skill is mandatory. + return fmt.Sprintf("Review %s/%s PR #%s using the cortex-maintainer-delivery skill, loading it first (never the plain maintainer-delivery skill): read the PR, its linked issue, CI status and diff; run its preflight and race check; then report scope, risks, and what to test in Spanish. Do not comment, push, or merge.", Org, repo, num) +} + +func issuePrompt(repo, num, branch string) string { + // Plan-first: the session presents the kickoff and STOPS until the user + // approves the plan. The cortex variant of the skill is mandatory. + return fmt.Sprintf("Take %s/%s issue #%s using the cortex-maintainer-delivery skill, loading it first (never the plain maintainer-delivery skill): run its preflight, read the issue and repo conventions, then present a kickoff plan (expected behavior, approach, out of scope, branch %s) in Spanish and STOP. Do not implement, edit files, or commit: implementation starts only after the user explicitly approves the plan. Claiming, commenting, pushing, and opening the PR stay mine.", Org, repo, num, branch) +} + +// openOrFinish opens Pi on a gentle-shell worktree with a kickoff prompt; +// other repos print where to go and the prompt to paste. +func openOrFinish(e *Env, repo, path, prompt string) error { + if repo == "gentle-shell" { + if err := os.Chdir(path); err != nil { + return err + } + return cmdRun(e, []string{prompt}) + } + finish(e, path) + fmt.Fprintf(e.Stdout, " kickoff: %s\n", prompt) + return nil +} + +func cmdPr(e *Env, args []string) error { + repo, num, err := repoAndNumber(e, "pr", args) + if err != nil { + return err + } + path := filepath.Join(e.Trees(), repo+"-pr-"+num) + if exists(path) { + if st, _ := e.StdoutOf(path, "git", "status", "--porcelain"); st != "" { + return failf("%s has local changes; commit, drop, or gs done first", path) + } + if err := e.Run(path, "gh", "pr", "checkout", num, "-R", Org+"/"+repo, "-b", "pr-"+num); err != nil { + return failf("gh pr checkout %s failed", num) + } + herdrRename(e, repo+" - pr #"+num) + if err := installDeps(e, path); err != nil { + return err + } + } else if err := reviewWorktree(e, repo, num); err != nil { + return err + } + return openOrFinish(e, repo, path, prPrompt(repo, num)) +} + +var ( + titlePrefix = regexp.MustCompile(`^[A-Za-z]+( [a-z]+)?: *`) + nonSlug = regexp.MustCompile(`[^a-z0-9]+`) +) + +// issueSlug turns an issue title into a branch slug of at most 40 chars. +func issueSlug(title string) string { + s := strings.ToLower(titlePrefix.ReplaceAllString(title, "")) + s = strings.Trim(nonSlug.ReplaceAllString(s, "-"), "-") + if len(s) > 40 { + s = s[:40] + } + s = strings.TrimRight(s, "-") + if s == "" { + return "issue" + } + return s +} + +func branchPrefix(labels []string) string { + for _, want := range []struct{ label, prefix string }{ + {"type:feature", "feat"}, {"type:docs", "docs"}, {"type:refactor", "refactor"}, {"type:chore", "chore"}, + } { + for _, l := range labels { + if l == want.label { + return want.prefix + } + } + } + return "fix" +} + +func cmdIssue(e *Env, args []string) error { + repo, num, err := repoAndNumber(e, "issue", args) + if err != nil { + return err + } + ghRepo := Org + "/" + repo + info, err := e.StdoutOf("", "gh", "issue", "view", num, "-R", ghRepo, "--json", "state,assignees,title,labels", + "--jq", `[.state, ([.assignees[].login]|join(",")), .title, ([.labels[].name]|join(","))]|join("\u001f")`) + if err != nil { + return failf("cannot read %s#%s", ghRepo, num) + } + parts := strings.SplitN(info, "\x1f", 4) + for len(parts) < 4 { + parts = append(parts, "") + } + state, assignees, title, labels := parts[0], splitComma(parts[1]), parts[2], splitComma(parts[3]) + path := filepath.Join(e.Trees(), repo+"-issue-"+num) + if exists(path) { + fmt.Fprintf(e.Stderr, "gs: reusing %s\n", path) + herdrRename(e, repo+" - is #"+num) + branch, _ := e.StdoutOf(path, "git", "branch", "--show-current") + return openOrFinish(e, repo, path, issuePrompt(repo, num, branch)) + } + if os.Getenv("GS_FORCE") != "1" { + if state != "OPEN" { + return failf("%s#%s is %s (GS_FORCE=1 to continue)", ghRepo, num, state) + } + // Your own assignment is your claim, not a race: only others block. + me, _ := e.StdoutOf("", "gh", "api", "user", "--jq", ".login") + var others []string + for _, a := range assignees { + if a != me { + others = append(others, a) + } + } + if len(others) > 0 { + return failf("%s#%s is assigned to %s (GS_FORCE=1 to continue)", ghRepo, num, strings.Join(others, ",")) + } + prs, _ := e.StdoutOf("", "gh", "pr", "list", "-R", ghRepo, "--state", "all", "--search", num+" in:title,body", + "--json", "number,state,title", "--jq", `.[] | " #\(.number) \(.state) \(.title)"`) + var open, closed []string + for _, l := range strings.Split(prs, "\n") { + switch { + case strings.TrimSpace(l) == "": + case regexp.MustCompile(`^ *#[0-9]+ OPEN `).MatchString(l): + open = append(open, l) + default: + closed = append(closed, l) + } + } + // Only open PRs mean someone may be working on it now; merged/closed + // ones are history (long issues ship in slices). + if len(open) > 0 { + return failf("Open PRs may already work on #%s (GS_FORCE=1 to continue):\n%s", num, strings.Join(open, "\n")) + } + if len(closed) > 0 { + fmt.Fprintf(e.Stderr, "gs: #%s already has merged or closed PRs:\n%s\n", num, strings.Join(closed, "\n")) + } + } + branch := branchPrefix(labels) + "/" + num + "-" + issueSlug(title) + if err := gitRun(e, filepath.Join(e.Clones(), repo), "worktree", "add", "-q", "-b", branch, path, "origin/main"); err != nil { + return err + } + herdrRename(e, repo+" - is #"+num) + if err := installDeps(e, path); err != nil { + return err + } + fmt.Fprintf(e.Stderr, "gs: %s#%s %q on branch %s\n", ghRepo, num, title, branch) + return openOrFinish(e, repo, path, issuePrompt(repo, num, branch)) +} + +func cmdBase(e *Env, args []string) error { + if len(args) != 1 { + return failf("usage: gs base ") + } + repo, err := repoName(args[0]) + if err != nil { + return err + } + clone, err := cloneOf(e, repo) + if err != nil { + return err + } + path := filepath.Join(e.Trees(), repo+"-base") + if exists(path) { + if st, _ := e.StdoutOf(path, "git", "status", "--porcelain"); st != "" { + return failf("%s has local changes; base must stay clean", path) + } + if err := gitRun(e, path, "checkout", "-q", "--detach", "origin/main"); err != nil { + return err + } + } else if err := gitRun(e, clone, "worktree", "add", "-q", "--detach", path, "origin/main"); err != nil { + return err + } + if err := installDeps(e, path); err != nil { + return err + } + finish(e, path) + return nil +} + +func cmdLs(e *Env) error { + for _, repo := range append(append([]string{}, Repos...), MacAppRepos...) { + dir := filepath.Join(e.Clones(), repo) + if !isDir(filepath.Join(dir, ".git")) { + continue + } + out, _ := e.StdoutOf(dir, "git", "worktree", "list") + for _, l := range strings.Split(out, "\n") { + if l != "" && !strings.HasPrefix(l, dir+" ") { + fmt.Fprintln(e.Stdout, l) + } + } + } + return nil +} + +func cmdDone(e *Env) error { + root, err := e.StdoutOf("", "git", "rev-parse", "--show-toplevel") + if err != nil { + return failf("run it inside a worktree") + } + trees, _ := filepath.EvalSymlinks(e.Trees()) + if !strings.HasPrefix(root, e.Trees()+"/") && (trees == "" || !strings.HasPrefix(root, trees+"/")) { + return failf("%s is not a gs worktree", root) + } + // Harness task records (odd/tasks/*) never ship; drop them if they are the only leftovers. + st, _ := e.StdoutOf(root, "git", "status", "--porcelain", "--untracked-files=all") + var other, untracked []string + for _, l := range strings.Split(st, "\n") { + switch { + case l == "": + case strings.HasPrefix(l, "?? odd/tasks/"): + untracked = append(untracked, strings.TrimPrefix(l, "?? ")) + default: + other = append(other, l) + } + } + if len(other) > 0 { + return failf("uncommitted changes in %s:\n%s", root, strings.Join(other, "\n")) + } + for _, f := range untracked { + os.RemoveAll(filepath.Join(root, f)) + } + if err := gitRun(e, root, "fetch", "-q", "--prune", "origin"); err != nil { + return err + } + if local, _ := e.StdoutOf(root, "git", "log", "--oneline", "HEAD", "--not", "--remotes"); local != "" { + head, _ := e.StdoutOf(root, "git", "rev-parse", "HEAD") + refs, _ := e.StdoutOf(root, "git", "ls-remote", "origin", "refs/pull/*/head") + if head == "" || !strings.Contains(refs, head) { + return failf("commits not on any remote in %s; push or drop them yourself first", root) + } + } + list, _ := e.StdoutOf(root, "git", "worktree", "list", "--porcelain") + main := strings.TrimPrefix(strings.SplitN(list, "\n", 2)[0], "worktree ") + branch, _ := e.StdoutOf(root, "git", "branch", "--show-current") + if err := gitRun(e, main, "worktree", "remove", root); err != nil { + return err + } + fmt.Fprintf(e.Stdout, "removed %s\n", root) + if branch != "" { + _, ancErr := e.Output(main, "git", "merge-base", "--is-ancestor", branch, "origin/main") + if ancErr == nil && gitRun(e, main, "branch", "-D", branch) == nil { + fmt.Fprintf(e.Stdout, "deleted local branch %s (already in origin/main)\n", branch) + } else { + fmt.Fprintf(e.Stdout, "kept local branch %s (not fully in main; delete it yourself if the PR was squash-merged)\n", branch) + } + } + fmt.Fprintf(e.Stdout, "now run: cd %s\n", main) + return nil +} + +// isShellCheckout reports whether root is a gentle-shell (gentle-pi) package. +func isShellCheckout(root string) bool { + b, err := os.ReadFile(filepath.Join(root, "package.json")) + return err == nil && regexp.MustCompile(`"name": *"gentle-pi"`).Match(b) +} + +// cmdRun runs gentle-shell --isolated with this worktree as --package-root. +func cmdRun(e *Env, args []string) error { + root, err := e.StdoutOf("", "git", "rev-parse", "--show-toplevel") + if err != nil { + return failf("run it inside a gentle-shell worktree") + } + if !isShellCheckout(root) { + return failf("%s is not gentle-shell", root) + } + if !isDir(filepath.Join(root, "node_modules")) { + return failf("no node_modules in %s (pnpm install --frozen-lockfile)", root) + } + head, _ := e.StdoutOf(root, "git", "rev-parse", "--short", "HEAD") + fmt.Fprintf(e.Stderr, "gs: gentle-shell @ %s (%s); subagents use main\n", head, root) + return execIn(e, root, "node", append([]string{filepath.Join(root, "bin", "gentle-shell.mjs"), "--isolated", "--package-root", root}, args...)) +} + +// cmdLaunch is plain `gs [args]`. +// Workshop: inside a gentle-shell checkout Pi loads that path (gs run); +// elsewhere the synced clone, isolated. Consumer: the official gentle-shell on +// PATH with the arguments unchanged. +func cmdLaunch(e *Env, args []string) error { + ws := isWorkshop(e) + if ws { + if here, err := e.StdoutOf("", "git", "rev-parse", "--show-toplevel"); err == nil && isShellCheckout(here) { + herdrTabRepo(e) + return cmdRun(e, args) + } + root := filepath.Join(e.Clones(), "gentle-shell") + if _, err := os.Stat(filepath.Join(root, "bin", "gentle-shell.mjs")); err != nil { + return failf("gentle-shell not found at %s (run: gs sync)", root) + } + herdrTabRepo(e) + return execIn(e, "", "node", append([]string{filepath.Join(root, "bin", "gentle-shell.mjs"), "--isolated"}, args...)) + } + if _, err := exec.LookPath("gentle-shell"); err != nil { + return failf("gentle-shell not on PATH (install it, then run: gs sync)") + } + herdrTabRepo(e) + return execIn(e, "", "gentle-shell", args) +} + +// execIn replaces gs with the command (exit status and signals pass through). +// ExecHook lets tests observe it instead. +var ExecHook func(dir, name string, args []string) error + +func execIn(e *Env, dir, name string, args []string) error { + if ExecHook != nil { + return ExecHook(dir, name, args) + } + path, err := exec.LookPath(name) + if err != nil { + return failf("%s not found on PATH", name) + } + if dir != "" { + if err := os.Chdir(dir); err != nil { + return err + } + } + return syscall.Exec(path, append([]string{name}, args...), e.environ()) +} + +func exists(p string) bool { + _, err := os.Lstat(p) + return err == nil +} + +func splitComma(s string) []string { + if s == "" { + return nil + } + return strings.Split(s, ",") +} diff --git a/gs/worktree_test.go b/gs/worktree_test.go new file mode 100644 index 0000000..505ab09 --- /dev/null +++ b/gs/worktree_test.go @@ -0,0 +1,210 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +type execCall struct { + dir, name string + args []string +} + +func captureExec(t *testing.T) *[]execCall { + var calls []execCall + ExecHook = func(dir, name string, args []string) error { + calls = append(calls, execCall{dir, name, args}) + return nil + } + t.Cleanup(func() { ExecHook = nil }) + return &calls +} + +func chdir(t *testing.T, dir string) { + t.Helper() + old, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(old) }) +} + +// shellCheckout makes dir a gentle-shell package with node_modules. +func shellCheckout(t *testing.T, dir string) { + os.MkdirAll(filepath.Join(dir, "bin"), 0o755) + os.MkdirAll(filepath.Join(dir, "node_modules"), 0o755) + os.WriteFile(filepath.Join(dir, "package.json"), []byte(`{"name": "gentle-pi"}`), 0o644) + os.WriteFile(filepath.Join(dir, "bin", "gentle-shell.mjs"), []byte(""), 0o755) +} + +func TestConsumerLaunchExecsGentleShellWithArgsUnchanged(t *testing.T) { + s := newSandbox(t) + s.healthy() + calls := captureExec(t) + chdir(t, s.home) + if err := dispatch(s.env, []string{"-c", "x"}); err != nil { + t.Fatal(err) + } + if len(*calls) != 1 || (*calls)[0].name != "gentle-shell" || strings.Join((*calls)[0].args, " ") != "-c x" { + t.Fatalf("want exec gentle-shell -c x, got %+v", *calls) + } +} + +func TestConsumerLaunchWithoutGentleShellFails(t *testing.T) { + s := newSandbox(t) + captureExec(t) + chdir(t, s.home) + err := dispatch(s.env, nil) + if err == nil || !strings.Contains(err.Error(), "gentle-shell not on PATH") { + t.Fatalf("want not-on-PATH failure, got %v", err) + } +} + +func TestWorkshopLaunchOpensCloneIsolated(t *testing.T) { + s := workshopHost(t) + shellCheckout(t, filepath.Join(s.env.Clones(), "gentle-shell")) + calls := captureExec(t) + chdir(t, s.home) + if err := dispatch(s.env, []string{"--model", "m"}); err != nil { + t.Fatal(err) + } + c := (*calls)[0] + want := filepath.Join(s.env.Clones(), "gentle-shell", "bin", "gentle-shell.mjs") + " --isolated --model m" + if c.name != "node" || strings.Join(c.args, " ") != want { + t.Fatalf("want node %s, got %+v", want, c) + } +} + +func TestWorkshopLaunchInsideShellWorktreeLoadsThatPath(t *testing.T) { + s := workshopHost(t) + wt := filepath.Join(s.env.Trees(), "gentle-shell-pr-7") + os.MkdirAll(wt, 0o755) + git(t, wt, "init", "-q") + shellCheckout(t, wt) + calls := captureExec(t) + chdir(t, wt) + if err := dispatch(s.env, nil); err != nil { + t.Fatal(err) + } + args := strings.Join((*calls)[0].args, " ") + contains(t, args, "--isolated --package-root ") + contains(t, args, "gentle-shell-pr-7") + notContains(t, args, "--link") +} + +func TestRunOutsideShellCheckoutFails(t *testing.T) { + s := newSandbox(t) + captureExec(t) + dir := filepath.Join(s.home, "other") + os.MkdirAll(dir, 0o755) + git(t, dir, "init", "-q") + chdir(t, dir) + err := cmdRun(s.env, nil) + if err == nil || !strings.Contains(err.Error(), "is not gentle-shell") { + t.Fatalf("want not-gentle-shell failure, got %v", err) + } +} + +func TestIssueSlugAndPrefix(t *testing.T) { + cases := map[string]string{ + "fix: Engram v1 replaces the binary!": "engram-v1-replaces-the-binary", + "fix(gs): keeps scope": "fix-gs-keeps-scope", + "feat: a very long title that goes on and on past forty chars": "a-very-long-title-that-goes-on-and-on-pa", + "???": "issue", + } + for in, want := range cases { + if got := issueSlug(in); got != want { + t.Errorf("issueSlug(%q) = %q, want %q", in, got, want) + } + } + if branchPrefix([]string{"bug", "type:docs"}) != "docs" || branchPrefix(nil) != "fix" { + t.Error("branch prefix from labels") + } +} + +// issueHost is a workshop host whose gh answers issue 9 with the given fields. +func issueHost(t *testing.T, assignees, prs string) *sandbox { + s := workshopHost(t) + s.stub("gh", `case "$1 $2" in + "auth status") exit 0 ;; + "auth token") echo tok ;; + "issue view") printf 'OPEN\037`+assignees+`\037fix: slow sync\037type:feature\n' ;; + "api user") echo me ;; + "pr list") printf '`+prs+`' ;; +esac`) + captureExec(t) + return s +} + +func TestIssueAssignedOnlyToYouPasses(t *testing.T) { + s := issueHost(t, "me", "") + if err := cmdIssue(s.env, []string{"ai", "9"}); err != nil { + t.Fatalf("own assignment must pass: %v", err) + } + contains(t, s.out.String(), "feat/9-slow-sync") + contains(t, s.out.String(), "kickoff: Take Gentleman-Programming/gentle-ai issue #9 using the cortex-maintainer-delivery skill") +} + +func TestIssueAssignedToOthersStopsAndNamesOnlyThem(t *testing.T) { + s := issueHost(t, "me,ana", "") + err := cmdIssue(s.env, []string{"ai", "9"}) + if err == nil || !strings.Contains(err.Error(), "is assigned to ana (GS_FORCE=1 to continue)") { + t.Fatalf("want assigned-to-ana failure, got %v", err) + } +} + +func TestIssueWithOpenPRStopsAndClosedPRsOnlyWarn(t *testing.T) { + s := issueHost(t, "", ` #5 MERGED feat: slice one (#9)\n #7 OPEN feat: slice two (#9)\n`) + err := cmdIssue(s.env, []string{"ai", "9"}) + if err == nil || !strings.Contains(err.Error(), "#7 OPEN feat: slice two (#9)") || strings.Contains(err.Error(), "#5 MERGED") { + t.Fatalf("want open-PR failure naming only #7, got %v", err) + } + s = issueHost(t, "", ` #5 MERGED feat: slice one (#9)\n`) + if err := cmdIssue(s.env, []string{"ai", "9"}); err != nil { + t.Fatalf("only merged PRs must continue: %v", err) + } + contains(t, s.out.String(), "already has merged or closed PRs") +} + +func TestPrPromptNamesCortexSkill(t *testing.T) { + p := prPrompt("gentle-shell", "3") + contains(t, p, "cortex-maintainer-delivery skill") + notContains(t, p, ".pi/agent") +} + +func TestDoneRefusesUncommittedWork(t *testing.T) { + s := workshopHost(t) + clone := filepath.Join(s.env.Clones(), "gentle-ai") + wt := filepath.Join(s.env.Trees(), "gentle-ai-x") + git(t, clone, "worktree", "add", "-q", "-b", "x", wt, "origin/main") + os.WriteFile(filepath.Join(wt, "work.go"), []byte("x"), 0o644) + os.MkdirAll(filepath.Join(wt, "odd", "tasks"), 0o755) + os.WriteFile(filepath.Join(wt, "odd", "tasks", "t.md"), []byte("x"), 0o644) + chdir(t, wt) + err := cmdDone(s.env) + if err == nil || !strings.Contains(err.Error(), "work.go") { + t.Fatalf("want refusal naming work.go, got %v", err) + } + if !exists(wt) { + t.Fatal("worktree must survive a refusal") + } +} + +func TestDoneRemovesMergedWorktreeAndDropsTaskRecords(t *testing.T) { + s := workshopHost(t) + clone := filepath.Join(s.env.Clones(), "gentle-ai") + wt := filepath.Join(s.env.Trees(), "gentle-ai-y") + git(t, clone, "worktree", "add", "-q", "-b", "y", wt, "origin/main") + os.MkdirAll(filepath.Join(wt, "odd", "tasks"), 0o755) + os.WriteFile(filepath.Join(wt, "odd", "tasks", "t.md"), []byte("x"), 0o644) + chdir(t, wt) + if err := cmdDone(s.env); err != nil { + t.Fatalf("done failed: %v\n%s", err, s.out.String()) + } + if exists(wt) { + t.Fatal("worktree must be removed") + } + contains(t, s.out.String(), "deleted local branch y (already in origin/main)") +} diff --git a/install.sh b/install.sh index 808ccb6..1521792 100755 --- a/install.sh +++ b/install.sh @@ -601,6 +601,13 @@ else fi run_or_plan "chmod +x $DOTFILES/claude/statusline.sh" chmod +x "$DOTFILES/claude/statusline.sh" run_or_plan "chmod +x agent-state CLI" chmod +x "$DOTFILES/zsh/scripts/agent-state.sh" +# gs is a Go binary built from this clone; gs sync rebuilds it from main later. +if command -v go &>/dev/null; then + run_or_plan "build gs into ~/.local/bin/gs" sh -c \ + "mkdir -p '$HOME/.local/bin' && cd '$DOTFILES/gs' && go build -trimpath -buildvcs=false -o '$HOME/.local/bin/.gs.new' . && mv -f '$HOME/.local/bin/.gs.new' '$HOME/.local/bin/gs'" +else + echo " ! go unavailable; gs not built (install Go, then rerun ./install.sh)" +fi if [[ "$PLATFORM" == "Darwin" ]]; then run_or_plan "chmod +x sketchybar scripts" chmod +x "$DOTFILES/sketchybar/sketchybarrc" "$DOTFILES/sketchybar/plugins"/*.sh diff --git a/odd/tasks/gs-go-binary.md b/odd/tasks/gs-go-binary.md new file mode 100644 index 0000000..b69d459 --- /dev/null +++ b/odd/tasks/gs-go-binary.md @@ -0,0 +1,30 @@ +# gs-go-binary + +Objective: replace `bin/gs` and `bin/gs-official` with one deterministic Go binary, identical on Mac local, agent-dev and agent-gentle. +Branch: `feat/gs-go` · Delivery: ask-on-risk · Test runner: `go test ./...` (plus `tests/gs_test.sh` as behavior reference until retired). + +## Specs + +- S1 — One binary for everything: "vamos a generar un binario determinista para esto con go, no puede ser que mute así esto". Scope answer: "todo" — every current `gs` command (`sync`, `pr`, `issue`, `new`, `review`, `base`, `run`, `ls`, `done`, launch) moves to Go. +- S2 — Same behavior on the 3 machines: "tenemos que dejar todo funcionando bien en las 3 maquinas." (macos local, agent-dev, agent-gentle). No per-host script variants (`gs-official` vs `bin/gs`). +- S3 — Deterministic install layout: one way to place `gentle-ai` and `engram` in `~/.local/bin` (decided in L4: regular files placed by gs through an atomic rename); stray installers (`go install` from gentle-ai upgrade) must never replace them; sync verifies the final state and fails loudly (e.g. `engram instance-id` must succeed). +- S4 — GitHub access always through authenticated `gh`: "nosotros acá siempre tenemos que usar gh para estas cosas. Es nuestro entorno controlado y siempre estamos con gh autenticado". +- S5 — `gs doctor` (assumption, proposed in L2 and accepted with "todo"): report in one run every piece out of place on that machine. + +## Tasks + +| ID | Specs | Route | Status | Commit | +|----|-------|-------|--------|--------| +| T1 | S1,S3,S5 | inline | done | e9cd571 | Go module skeleton, manifest/state model, `gs doctor` | +| T2 | S1,S3,S4 | inline | done | e9cd571, 93e5d20 | `gs sync` (consumer + workshop) with atomic swap and final verification | +| T3 | S1,S4 | inline | done | 05216ad | worktree commands: `new`, `review`, `pr`, `issue`, `base`, `ls`, `done` | +| T4 | S1 | inline | done | 05216ad | launch / `run` (Pi passthrough) | +| T5 | S2 | inline | code done, rollout pending | (this commit) | install via `install.sh`, retire `bin/gs` + `bin/gs-official`, roll out to the 3 machines | + +## Log + +- L1 (2026-11-04, user, verbatim): "vamos a generar un binario determinista para esto con go, no puede ser que mute así esto somos unos penes. Yo porque hace meses que estoy cone sto y vos porque no podes resolver algo que es una bobada comparada con cosas re jodidas que hacemos" +- L2 (2026-11-04): proposal accepted with scope "todo": manifest, atomic swap, gh for GitHub, final verification, `gs doctor`, replacing both scripts. +- L3 (2026-11-04): context — Mac and agent-dev run `gs-official` (refuses symlinks), agent-gentle runs `bin/gs` (creates symlinks); running the wrong one broke the hosts today. gentle-shell strips GH_TOKEN/GITHUB_TOKEN from children, so its upgrade 403s on the unauthenticated limit. +- L4 (2026-11-04): decisions — gentle-ai/engram as regular files in ~/.local/bin (gs-official model, the one Mac/agent-dev ran); consumer launch = exec gentle-shell args unchanged (gs-official); gentle-ai sync --agents claude-code everywhere; no pi update; our GENTLE_AI_ENGRAM_BIN always wins. gentle-shell's main-channel.mjs fetches GitHub with no Authorization header, so gs skips its upgrade when already on main (checked via gh). +- L5 (2026-11-04): evidence — `go test ./...` green (doctor, sync consumer+workshop, worktree, launch); real Mac run of the new gs sync built pinned commits, installed regular files, restarted Engram, all doctor checks ok; only gentle-shell upgrade 403 (unauthenticated limit). Engram mirror pending (MCP disconnected this session). diff --git a/scripts/migrate-mac-official.sh b/scripts/migrate-mac-official.sh index d462b05..f6bd160 100755 --- a/scripts/migrate-mac-official.sh +++ b/scripts/migrate-mac-official.sh @@ -93,7 +93,7 @@ if [ -f "$BACKUP/refactoria.json" ]; then else blocker "missing $BACKUP/refactoria.json" fi -for f in bin/gs-official zsh/scripts/official-gentle-path.zsh; do +for f in gs/main.go zsh/scripts/official-gentle-path.zsh; do [ -f "$DOT/$f" ] || blocker "missing $DOT/$f" done for link in "$LBIN/gs" "$SHELL_SNIPPET_DIR/official-gentle-path.zsh"; do @@ -249,14 +249,14 @@ run "$LBIN/gentle-ai" install --agent claude-code #endregion #region 8. Dotfiles links -step "8. Link gs-official and official-gentle-path.zsh" +step "8. Build gs and link official-gentle-path.zsh" for link in "$LBIN/gs" "$SHELL_SNIPPET_DIR/official-gentle-path.zsh"; do if [ "$APPLY" -eq 1 ] && { [ -e "$link" ] || [ -L "$link" ]; }; then fail "$link already exists; refusing" fi done run mkdir -p "$SHELL_SNIPPET_DIR" -run ln -s "$DOT/bin/gs-official" "$LBIN/gs" +run sh -c "cd '$DOT/gs' && go build -trimpath -buildvcs=false -o '$LBIN/gs' ." run ln -s "$DOT/zsh/scripts/official-gentle-path.zsh" "$SHELL_SNIPPET_DIR/official-gentle-path.zsh" #endregion diff --git a/scripts/test-gs-sync.sh b/scripts/test-gs-sync.sh deleted file mode 100644 index ea39dcb..0000000 --- a/scripts/test-gs-sync.sh +++ /dev/null @@ -1,129 +0,0 @@ -#!/bin/bash -# Run the real `gs sync` (consumer mode) with an isolated HOME and PATH shims: -# no network, no host installs. Checks that the binaries step is followed by -# `gentle-ai sync`, scoped by GS_SYNC_AGENTS and skippable with GS_SKIP_AI_SYNC. -set -euo pipefail - -repo_root="$(cd "$(dirname "$0")/.." && pwd)" -ws="$(mktemp -d)" -trap 'rm -rf "$ws"' EXIT - -fail() { printf 'FAIL: %s\n%s\n' "$1" "${2:-}" >&2; exit 1; } - -home="$ws/home" -shims="$ws/shims" -log="$ws/calls.log" -mkdir -p "$home/.pi/gentle-ai" "$shims" - -# One shim script, dispatched by name; every call is logged. -cat >"$shims/_shim" <>"$log" -case "\$name \$1" in - "gentle-shell --version") echo "gentle-shell 4.2.0-main.abc1234def56" ;; - node\ *) echo "gentle-shell 4.2.0-main.abc1234def56" ;; - "go install") - for arg in "\$@"; do case "\$arg" in *cmd/engram@*) - printf '#!/bin/sh\necho "engram 3.0.0"\n' >"\$GOBIN/engram"; chmod +x "\$GOBIN/engram" ;; - esac; done ;; - "gentle-ai --version") echo "gentle-ai 4.2.1" ;; - "gentle-ai sync") [ -n "\${FAKE_AI_SYNC_FAIL:-}" ] && { echo "Error: partial sync"; exit 1; }; echo "managed sync executed"; [ -n "\${FAKE_AI_SYNC_CHANGED:-}" ] && echo "Sync actions executed: \$FAKE_AI_SYNC_CHANGED files changed"; true ;; -esac -exit 0 -EOF -chmod +x "$shims/_shim" -for name in gentle-shell pi go home-manager node; do ln -s _shim "$shims/$name"; done -ai_dir="$ws/ai"; mkdir -p "$ai_dir"; ln -s "$shims/_shim" "$ai_dir/gentle-ai" -printf '{"schema":"gentle-pi.dev-binary/v1","path":"%s"}\n' "$ai_dir/gentle-ai" >"$home/.pi/gentle-ai/dev-binary.json" - -run_sync() { - : >"$log" - env -i HOME="$home" PATH="$shims:/usr/bin:/bin" TERM=dumb GS_SHELL_REMOTE="$ws/no-remote" "$@" \ - bash "$repo_root/bin/gs" sync 2>&1 -} -ai_sync_calls() { grep '^gentle-ai|sync' "$log" || true; } - -# 1. Default: gentle-ai sync runs once, unscoped, after the binaries step. -out=$(run_sync) || fail 'gs sync exited non-zero' "$out" -[[ "$(ai_sync_calls)" == 'gentle-ai|sync' ]] || fail 'expected one unscoped gentle-ai sync' "$(cat "$log")" -[[ "$out" == *'gentle-ai sync'*'ok'* ]] || fail 'missing gentle-ai sync status line' "$out" -engram_line=$(grep -n 'cmd/engram@main' "$log" | cut -d: -f1) -sync_line=$(grep -n '^gentle-ai|sync' "$log" | cut -d: -f1) -(( engram_line < sync_line )) || fail 'gentle-ai sync ran before the binaries step' "$(cat "$log")" - -[[ "$out" == *'nothing changed'* ]] || fail 'no-op sync should not ask for a Pi restart' "$out" - -# 1b. Config changes from gentle-ai sync also ask for a Pi restart. -out=$(run_sync FAKE_AI_SYNC_CHANGED=3) || fail 'gs sync exited non-zero' "$out" -[[ "$out" == *'changed: managed configs (3 files)'*'Restart Pi'* ]] || fail 'config changes not reported' "$out" - -# 2. GS_SYNC_AGENTS scopes the runtimes for this host. -out=$(run_sync GS_SYNC_AGENTS=claude-code,opencode,pi) || fail 'scoped gs sync exited non-zero' "$out" -[[ "$(ai_sync_calls)" == 'gentle-ai|sync --agents claude-code,opencode,pi' ]] || fail 'agents scope not forwarded' "$(cat "$log")" - -# 3. GS_SKIP_AI_SYNC=1 keeps the binaries-only behavior. -out=$(run_sync GS_SKIP_AI_SYNC=1) || fail 'skipped gs sync exited non-zero' "$out" -[[ -z "$(ai_sync_calls)" ]] || fail 'gentle-ai sync ran despite GS_SKIP_AI_SYNC=1' "$(cat "$log")" -[[ "$out" == *'gentle-ai sync'*'skipped (GS_SKIP_AI_SYNC=1)'* ]] || fail 'missing skip status line' "$out" - -# 4. A failing gentle-ai sync is reported with its output, gs sync goes on to the -# closing report, and then exits 1 with INCOMPLETE instead of claiming success. -out=$(run_sync FAKE_AI_SYNC_FAIL=1) && fail 'gs sync exited 0 despite a failed gentle-ai sync' "$out" -[[ "$out" == *'INCOMPLETE — failed: gentle-ai sync'* ]] || fail 'failed step not in INCOMPLETE summary' "$out" -[[ "$out" != *'no Pi restart needed'* ]] || fail 'claimed no restart needed after a failure' "$out" -[[ "$out" == *'gentle-ai sync'*'FAILED'*'Error: partial sync'* ]] || fail 'failure output not surfaced' "$out" -[[ "$out" == *'versions:'* ]] || fail 'sync stopped before the closing report' "$out" - -# 5. Legacy upgrade scripts are flagged so only gs sync remains. -mkdir -p "$home/.local/bin"; printf '#!/bin/sh\n' >"$home/.local/bin/gentle-maintainer-upgrade" -out=$(run_sync) || fail 'gs sync exited non-zero with legacy script' "$out" -[[ "$out" == *"WARN: legacy upgrade script $home/.local/bin/gentle-maintainer-upgrade"* ]] || fail 'legacy script not flagged' "$out" -[[ -f "$home/.local/bin/gentle-maintainer-upgrade" ]] || fail 'legacy script was removed' "$out" - -# 6. Dotfiles step. A real local clone with a bare origin, so pull works offline. -dot="$home/dev/barbatdev/cortex/cortex-dotfiles" -origin="$ws/origin.git" -g() { git -c user.name=t -c user.email=t@t -c init.defaultBranch=main "$@"; } -g init -q --bare "$origin" -g clone -q "$origin" "$dot" 2>/dev/null -mkdir -p "$dot/zsh" && echo 'profile v1' >"$dot/zsh/cortex-dotfiles.zsh" -g -C "$dot" add -A && g -C "$dot" commit -qm v1 && g -C "$dot" push -q origin HEAD:main -dot_status() { grep -E '^ dotfiles ' <<<"$1" || true; } - -# 6a. GS_SKIP_DOTFILES=1 skips everything. -out=$(run_sync GS_SKIP_DOTFILES=1) || fail 'gs sync failed with GS_SKIP_DOTFILES' "$out" -[[ "$(dot_status "$out")" == *'skipped (GS_SKIP_DOTFILES=1)'* ]] || fail 'dotfiles skip line missing' "$out" - -# 6b. No clone: skip and keep going. -out=$(run_sync CORTEX_DOTFILES_DIR="$ws/nowhere") || fail 'gs sync failed without clone' "$out" -[[ "$(dot_status "$out")" == *"skipped (no clone at $ws/nowhere)"* ]] || fail 'no-clone skip missing' "$out" -[[ "$out" == *'versions:'* ]] || fail 'sync stopped after missing clone' "$out" - -# 6c. Upstream moves; the clean clone on main is pulled and the hint names it. -up="$ws/upstream"; g clone -q "$origin" "$up" -echo 'profile v2' >"$up/zsh/cortex-dotfiles.zsh"; g -C "$up" commit -qam v2 && g -C "$up" push -q origin HEAD:main -old=$(git -C "$dot" rev-parse --short HEAD); new=$(git -C "$up" rev-parse --short HEAD) -out=$(run_sync) || fail 'gs sync failed on dotfiles pull' "$out" -[[ "$(git -C "$dot" rev-parse --short HEAD)" == "$new" ]] || fail 'clone was not pulled' "$out" -[[ "$out" == *"dotfiles ($old -> $new)"* ]] || fail 'restart hint misses dotfiles change' "$out" -# 6d. The profile is read in place: sync installs no shell files. -[[ ! -e "$home/.config/fish" ]] || fail 'sync created fish config' "$(ls -la "$home/.config")" - -# 6e. Second run: nothing to pull. -out=$(run_sync) || fail 'second gs sync failed' "$out" -[[ "$(dot_status "$out")" == *'ok'* ]] || fail 'second run should be a no-op' "$out" -[[ "$out" != *'dotfiles ('* ]] || fail 'no-op pull reported as change' "$out" - -# 6f. Local changes or another branch: clone untouched. -echo dirty >>"$dot/zsh/cortex-dotfiles.zsh" -head=$(git -C "$dot" rev-parse HEAD) -out=$(run_sync) || fail 'gs sync failed with dirty clone' "$out" -[[ "$(dot_status "$out")" == *"skipped ($dot has local changes)"* ]] || fail 'dirty skip missing' "$out" -[[ "$(tail -1 "$dot/zsh/cortex-dotfiles.zsh")" == dirty && "$(git -C "$dot" rev-parse HEAD)" == "$head" ]] || fail 'dirty clone was touched' "$out" -git -C "$dot" checkout -q -- . && git -C "$dot" checkout -q -b topic -out=$(run_sync) || fail 'gs sync failed on topic branch' "$out" -[[ "$(dot_status "$out")" == *"skipped ($dot is on topic, not main)"* ]] || fail 'branch skip missing' "$out" -git -C "$dot" checkout -q main - -echo "gs sync checks passed" diff --git a/tests/gs_official_test.py b/tests/gs_official_test.py deleted file mode 100644 index ff0c42e..0000000 --- a/tests/gs_official_test.py +++ /dev/null @@ -1,188 +0,0 @@ -"""Public-CLI tests for bin/gs-official using a temp HOME and PATH shims.""" - -import os -import subprocess -import tempfile -import unittest -from pathlib import Path - -SCRIPT = Path(__file__).resolve().parent.parent / "bin" / "gs-official" - -SHIM = """#!/bin/sh -echo "{name} $*" >> "$GS_TEST_LOG" -{body} -""" - - -class GsOfficialTest(unittest.TestCase): - def setUp(self): - self.tmp = tempfile.TemporaryDirectory() - self.home = Path(self.tmp.name) / "home" - self.shims = Path(self.tmp.name) / "shims" - self.ai_bin = self.home / ".local" / "bin" - self.log = Path(self.tmp.name) / "calls.log" - for d in (self.shims, self.ai_bin): - d.mkdir(parents=True) - self.log.touch() - self.shim(self.shims, "gentle-shell", - 'case "$1" in --version) echo "gentle-shell 9.9.9";; esac') - self.shim(self.shims, "go", - 'if [ "$1" = version ]; then echo " build vcs.revision=abc123"; fi') - self.shim(self.ai_bin, "gentle-ai", - 'if [ "$1" = version ]; then echo "gentle-ai dev"; fi') - self.shim(self.ai_bin, "engram", - 'if [ "$1" = version ]; then echo "engram 3.0.0-dev"; fi') - - def tearDown(self): - self.tmp.cleanup() - - def shim(self, directory, name, body): - path = directory / name - path.write_text(SHIM.format(name=name, body=body)) - path.chmod(0o755) - return path - - def run_gs(self, *args, path=None): - env = { - "HOME": str(self.home), - "PATH": path or f"{self.ai_bin}:{self.shims}:/usr/bin:/bin", - "GS_TEST_LOG": str(self.log), - } - return subprocess.run([str(SCRIPT), *args], env=env, - capture_output=True, text=True) - - def calls(self): - return self.log.read_text().splitlines() - - # S5: default launch forwards arguments unchanged and preserves exit status. - def test_launch_forwards_args_and_exit_status(self): - self.shim(self.shims, "gentle-shell", 'echo out; echo err >&2; exit 7') - result = self.run_gs("--model", "a b", "x") - self.assertEqual((result.returncode, result.stdout, result.stderr), - (7, "out\n", "err\n")) - self.assertEqual(self.calls(), ["gentle-shell --model a b x"]) - - def test_launch_without_args_adds_no_flags(self): - result = self.run_gs() - self.assertEqual(result.returncode, 0) - self.assertEqual(self.calls(), ["gentle-shell "]) - - def test_help_is_documented_and_launches_nothing(self): - result = self.run_gs("--help") - self.assertEqual(result.returncode, 0) - self.assertIn("gs sync", result.stdout) - self.assertEqual(self.calls(), []) - - # S3/S4/S2: sync updates both from main and syncs Claude Code only. - def test_sync_runs_main_updates_then_claude_code_sync(self): - result = self.run_gs("sync") - self.assertEqual(result.returncode, 0, result.stderr) - ai = self.ai_bin / "gentle-ai" - engram = self.ai_bin / "engram" - self.assertEqual(self.calls(), [ - "gentle-shell upgrade --channel main", - "go install github.com/gentleman-programming/gentle-ai/v4/cmd/gentle-ai@main", - "gentle-ai sync --agents claude-code", - "go install github.com/Gentleman-Programming/engram/v3/cmd/engram@main", - "gentle-shell --version", - "gentle-ai version", - f"go version -m {ai}", - "engram version", - f"go version -m {engram}", - ]) - self.assertIn("vcs.revision=abc123", result.stdout) - self.assertIn("gentle-shell 9.9.9", result.stdout) - self.assertIn("engram 3.0.0-dev", result.stdout) - self.assertIn("restart the Engram service", result.stdout) - - def test_sync_installs_into_resolved_gentle_ai_dir(self): - self.shim(self.shims, "go", - 'if [ "$1" = install ]; then echo "GOBIN=$GOBIN" >> "$GS_TEST_LOG"; fi') - self.run_gs("sync") - self.assertIn(f"GOBIN={self.ai_bin.resolve()}", self.calls()) - - def test_sync_rejects_extra_args(self): - result = self.run_gs("sync", "--force") - self.assertEqual(result.returncode, 2) - self.assertIn("takes no arguments", result.stderr) - self.assertEqual(self.calls(), []) - - # Preflight: refuse to replace binaries outside the expected owned bin. - def test_sync_refuses_symlinked_gentle_ai(self): - real = self.shim(self.shims, "gentle-ai-real", "") - (self.ai_bin / "gentle-ai").unlink() - (self.ai_bin / "gentle-ai").symlink_to(real) - result = self.run_gs("sync") - self.assertEqual(result.returncode, 1) - self.assertIn("symlink", result.stderr) - self.assertEqual(self.calls(), []) - - def test_sync_refuses_gentle_ai_outside_expected_bin(self): - (self.ai_bin / "gentle-ai").unlink() - self.shim(self.shims, "gentle-ai", "") - result = self.run_gs("sync") - self.assertEqual(result.returncode, 1) - self.assertIn("expected", result.stderr) - self.assertEqual(self.calls(), []) - - def test_sync_fails_when_go_missing(self): - (self.shims / "go").unlink() - result = self.run_gs("sync") - self.assertEqual(result.returncode, 1) - self.assertIn("go not found", result.stderr) - self.assertEqual(self.calls(), []) - - # S3: failures are fail-fast with no stable fallback. - def test_sync_fails_fast_on_shell_upgrade_failure(self): - self.shim(self.shims, "gentle-shell", - 'echo upgrade-broke >&2; exit 5') - result = self.run_gs("sync") - self.assertEqual(result.returncode, 5) - self.assertIn("upgrade-broke", result.stderr) - self.assertIn("'gentle-shell upgrade --channel main' failed with exit 5", result.stderr) - self.assertEqual(self.calls(), ["gentle-shell upgrade --channel main"]) - - def test_sync_fails_fast_on_go_install_failure(self): - self.shim(self.shims, "go", 'exit 3') - result = self.run_gs("sync") - self.assertEqual(result.returncode, 3) - self.assertNotIn("gentle-ai sync --agents claude-code", self.calls()) - - - # Engram follows main like Gentle AI, with the same fail-closed target. - def test_sync_refuses_symlinked_engram(self): - real = self.shim(self.shims, "engram-real", "") - (self.ai_bin / "engram").unlink() - (self.ai_bin / "engram").symlink_to(real) - result = self.run_gs("sync") - self.assertEqual(result.returncode, 1) - self.assertIn("engram is a symlink", result.stderr) - self.assertEqual(self.calls(), []) - - def test_sync_refuses_engram_outside_expected_bin(self): - (self.ai_bin / "engram").unlink() - self.shim(self.shims, "engram", "") - result = self.run_gs("sync") - self.assertEqual(result.returncode, 1) - self.assertIn("engram is not in the expected directory", result.stderr) - self.assertEqual(self.calls(), []) - - def test_sync_fails_when_engram_missing(self): - (self.ai_bin / "engram").unlink() - result = self.run_gs("sync") - self.assertEqual(result.returncode, 1) - self.assertIn("engram not found", result.stderr) - self.assertEqual(self.calls(), []) - - def test_sync_fails_fast_on_engram_install_failure(self): - self.shim(self.shims, "go", - 'case "$*" in *engram*) echo engram-broke >&2; exit 4;; esac') - result = self.run_gs("sync") - self.assertEqual(result.returncode, 4) - self.assertIn("engram-broke", result.stderr) - self.assertNotIn("gentle-shell --version", self.calls()) - self.assertNotIn("restart the Engram service", result.stdout) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/gs_test.sh b/tests/gs_test.sh deleted file mode 100755 index 2a39921..0000000 --- a/tests/gs_test.sh +++ /dev/null @@ -1,305 +0,0 @@ -#!/usr/bin/env bash -# Regression tests for bin/gs: launch target and sync result share one source of truth. -# Runs fully offline in a throwaway HOME with stub binaries. Usage: tests/gs_test.sh -set -uo pipefail - -GS="${GS_BIN:-$(cd "$(dirname "$0")/.." && pwd)/bin/gs}" -REAL_PATH="/usr/bin:/bin:/usr/sbin:/sbin" -fails=0 - -ok() { printf 'ok %s\n' "$1"; } -bad() { printf 'FAIL %s\n' "$1"; fails=$((fails + 1)); } -check() { if eval "$2"; then ok "$1"; else bad "$1"; printf '%s\n' "$OUT" | sed 's/^/ | /'; fi; } - -# Fake package: bin/gentle-shell.mjs prints its version and records argv. -make_pkg() { # dir version - mkdir -p "$1/bin" - printf '#!/bin/sh\nexit 0\n' > "$1/bin/gentle-shell.mjs" - printf '%s\n' "$2" > "$1/VERSION" -} - -setup() { # mode: consumer | workshop - T=$(cd "$(mktemp -d)" && pwd -P); export HOME="$T/home"; S="$T/stubs"; mkdir -p "$HOME/.local/bin" "$S" - export PATH="$S:$HOME/.local/bin:$REAL_PATH" GS_SKIP_DOTFILES=1 GS_SKIP_AI_SYNC=1 - export GS_SHELL_REMOTE="$T/remote" CALLS="$T/calls" - # node stub: "node /bin/gentle-shell.mjs ARGS" -> version or argv log. - cat > "$S/node" <<'EOF' -#!/bin/sh -pkg=$(dirname "$(dirname "$1")"); shift -if [ "${1:-}" = --version ]; then printf 'gentle-shell %s\npi 1.4.0\n' "$(cat "$pkg/VERSION")"; exit 0; fi -echo "launch $pkg $*" >> "$CALLS" -[ "${2:-}" = setup ] && exit "${SETUP_RC:-0}" -exit 0 -EOF - printf '#!/bin/sh\necho "go PATH=$PATH" >> "$CALLS"\n' > "$S/go" - for b in pnpm npm; do printf '#!/bin/sh\nexit 0\n' > "$S/$b"; done - printf '#!/bin/sh\necho "pi $*" >> "$CALLS"\necho "PATH=$PATH" >> "$CALLS"\n' > "$S/pi" - # Legacy pi-managed checkout that must never be launched again. - make_pkg "$HOME/.pi/agent/git/github.com/Gentleman-Programming/gentle-pi" 4.3.0 - # gentle-ai binary registered by the main channel. - mkdir -p "$HOME/.pi/gentle-ai" "$HOME/.local/share/gentle-ai/main" - printf '#!/bin/sh\n[ "$1" = --version ] && echo "gentle-ai 1.0.0"\nexit 0\n' > "$HOME/ai" - chmod +x "$HOME/ai" - printf '{"path":"%s"}\n' "$HOME/ai" > "$HOME/.pi/gentle-ai/dev-binary.json" - # Engram built by gs sync; ENGRAM_PRE_V3=1 makes it a pre-v3 build without instance-id. - printf '#!/bin/sh\ncase "$1" in\n version) echo "engram 3.6.1" ;;\n instance-id) [ "${ENGRAM_PRE_V3:-}" = 1 ] && exit 1; echo abc ;;\nesac\nexit 0\n' > "$HOME/.local/share/gentle-ai/main/engram" - chmod +x "$HOME/.local/share/gentle-ai/main/engram" - if [ "$1" = consumer ]; then - NPM="$HOME/.local/lib/node_modules/gentle-pi" - make_pkg "$NPM" 4.5.1-main.feee99d88dae - ln -s ../lib/node_modules/gentle-pi/bin/gentle-shell.mjs "$HOME/.local/bin/gentle-shell" - # PATH gentle-shell stub: version from the package; upgrade bumps it. - cat > "$S/gentle-shell-impl" <> "$CALLS"; [ -n "\${UPGRADE_TO:-}" ] && echo "\$UPGRADE_TO" > "$NPM/VERSION"; exit "\${UPGRADE_RC:-0}" ;; -esac -EOF - chmod +x "$S/gentle-shell-impl" - # The PATH entry is the symlink into the npm package; make it executable via the stub. - printf '#!/bin/sh\nexec %s "$@"\n' "$S/gentle-shell-impl" > "$NPM/bin/gentle-shell.mjs" - else - mkdir -p "$HOME/dev/gentle" - for r in gentle-shell gentle-ai engram; do - git init -q "$HOME/dev/gentle/$r" - git -C "$HOME/dev/gentle/$r" remote add origin "https://github.com/Gentleman-Programming/$r" - done - make_pkg "$HOME/dev/gentle/gentle-shell" 4.6.0 - fi - chmod +x "$S"/* 2>/dev/null; find "$T" -name gentle-shell.mjs -exec chmod +x {} + -} - -remote_main() { # sha: fake `git ls-remote` source for the freshness check - git init -q "$GS_SHELL_REMOTE" 2>/dev/null - git -C "$GS_SHELL_REMOTE" -c user.email=t@t -c user.name=t commit -q --allow-empty -m x - git -C "$GS_SHELL_REMOTE" update-ref refs/heads/main "$1" -} -sha_of() { git -C "$GS_SHELL_REMOTE" rev-parse HEAD; } - -# --- launcher ----------------------------------------------------------------- - -setup consumer -OUT=$("$GS" --foo 2>&1); L=$(cat "$CALLS" 2>/dev/null) -check "consumer: gs launches the package behind gentle-shell on PATH, isolated" \ - '[[ "$L" == "launch $NPM --isolated --foo" ]]' -check "consumer: gs never launches the legacy pi git checkout" '[[ "$L" != *".pi/agent/git"* ]]' - -setup consumer; rm "$HOME/.local/bin/gentle-shell" -OUT=$("$GS" 2>&1); rc=$? -check "consumer: no gentle-shell on PATH fails instead of opening the legacy copy" \ - '[ $rc -ne 0 ] && [[ "$OUT" == *"not on PATH"* ]] && [ ! -e "$CALLS" ]' - -setup workshop -OUT=$("$GS" x 2>&1); L=$(cat "$CALLS" 2>/dev/null) -check "workshop: gs launches the clean clone, isolated" '[[ "$L" == "launch $HOME/dev/gentle/gentle-shell --isolated x" ]]' - -# A gentle-shell worktree the operator is working in (dogfooding target). -make_worktree() { - W="$HOME/dev/worktrees/gentle-shell-wip"; make_pkg "$W" 9.9.9-wip - git init -q "$W"; mkdir -p "$W/node_modules" - printf '{\n "name": "gentle-pi"\n}\n' > "$W/package.json" -} - -setup workshop; make_worktree -OUT=$(cd "$W/bin" && "$GS" y 2>&1); L=$(cat "$CALLS" 2>/dev/null) -check "workshop: inside a gentle-shell worktree gs loads that path" \ - '[[ "$L" == "launch $W --isolated --package-root $W y" ]]' - -setup consumer; make_worktree -OUT=$(cd "$W" && "$GS" 2>&1); L=$(cat "$CALLS" 2>/dev/null) -check "consumer: inside a gentle-shell checkout gs still opens the main build" \ - '[[ "$L" == "launch $NPM --isolated" ]]' - -setup workshop; make_worktree -OUT=$(cd "$W" && "$GS" run --z 2>&1); L=$(cat "$CALLS" 2>/dev/null) -check "workshop: gs run uses the isolated home with this worktree as --package-root" \ - '[[ "$L" == "launch $W --isolated --package-root $W --z" ]]' - -# gs pr / gs issue on an existing shell worktree: Pi opens on it with the kickoff. -gh_stub() { - cat > "$S/gh" <<'EOF' -#!/bin/sh -case "$1 $2" in - "issue view") printf 'OPEN\037\037fix: thing\037type:bug\n' ;; -esac -exit 0 -EOF - chmod +x "$S/gh" -} -setup workshop; gh_stub -W="$HOME/dev/worktrees/gentle-shell-pr-7"; make_pkg "$W" 9.9.9-pr -git init -q "$W"; mkdir -p "$W/node_modules"; printf '{\n "name": "gentle-pi"\n}\n' > "$W/package.json" -git -C "$W" add -A; git -C "$W" -c user.email=t@t -c user.name=t commit -q -m pr -OUT=$("$GS" pr shell 7 2>&1); L=$(cat "$CALLS" 2>/dev/null) -check "workshop: gs pr opens the worktree isolated, never --link" \ - '[[ "$L" == "launch $W --isolated --package-root $W Review Gentleman-Programming/gentle-shell PR #7 "* ]] && [[ "$L" != *--link* ]]' -check "workshop: gs pr kickoff names the cortex skill without a legacy home path" \ - '[[ "$L" == *"cortex-maintainer-delivery skill"* ]] && [[ "$L" != *".pi/agent"* ]]' - -setup workshop; gh_stub -W="$HOME/dev/worktrees/gentle-shell-issue-8"; make_pkg "$W" 9.9.9-is -git init -q "$W"; mkdir -p "$W/node_modules"; printf '{\n "name": "gentle-pi"\n}\n' > "$W/package.json" -git -C "$W" add -A; git -C "$W" -c user.email=t@t -c user.name=t commit -q -m is -OUT=$("$GS" issue shell 8 2>&1); L=$(cat "$CALLS" 2>/dev/null) -check "workshop: gs issue reuses the worktree and opens it isolated, never --link" \ - '[[ "$OUT" == *"reusing $W"* ]] && [[ "$L" == "launch $W --isolated --package-root $W Take Gentleman-Programming/gentle-shell issue #8 "* ]] && [[ "$L" != *--link* ]]' -check "workshop: gs issue kickoff names the cortex skill without a legacy home path" \ - '[[ "$L" == *"cortex-maintainer-delivery skill"* ]] && [[ "$L" != *".pi/agent"* ]]' - -# gs issue race check: only someone else's assignment blocks a new worktree. -race_stub() { - cat > "$S/gh" <> "$CALLS"; printf '%b' "${2:-}" ;; -esac -exit 0 -EOF - chmod +x "$S/gh" -} -setup workshop; race_stub me -OUT=$("$GS" issue shell 9 2>&1); L=$(cat "$CALLS" 2>/dev/null) -check "workshop: gs issue assigned only to you passes the race check" \ - '[[ "$OUT" != *"is assigned"* ]] && [[ "$L" == *"pr list"* ]]' -setup workshop; race_stub other -OUT=$("$GS" issue shell 9 2>&1); rc=$? -check "workshop: gs issue assigned to someone else still stops" \ - '[ "$rc" = 1 ] && [[ "$OUT" == *"#9 is assigned to other (GS_FORCE=1 to continue)"* ]]' -setup workshop; race_stub me,other -OUT=$("$GS" issue shell 9 2>&1); rc=$? -check "workshop: gs issue shared with someone else stops and names only them" \ - '[ "$rc" = 1 ] && [[ "$OUT" == *"#9 is assigned to other (GS_FORCE=1 to continue)"* ]]' - -# PRs that mention the issue: only open ones block; merged/closed ones are listed. -setup workshop; race_stub "" ' #5 MERGED feat: slice one (#9)\n #6 CLOSED docs: dropped (#9)\n' -OUT=$("$GS" issue shell 9 2>&1); rc=$? -check "workshop: gs issue with only merged or closed PRs continues and lists them" \ - '[[ "$OUT" != *"GS_FORCE=1 to continue"* ]] && [[ "$OUT" == *"#9 already has merged or closed PRs"* ]] && [[ "$OUT" == *"#5 MERGED feat: slice one (#9)"* ]] && [[ "$OUT" == *"#6 CLOSED docs: dropped (#9)"* ]]' -setup workshop; race_stub "" ' #5 MERGED feat: slice one (#9)\n #7 OPEN feat: slice two (#9)\n' -OUT=$("$GS" issue shell 9 2>&1); rc=$? -check "workshop: gs issue with an open PR still stops and names only the open ones" \ - '[ "$rc" = 1 ] && [[ "$OUT" == *"Open PRs may already work on #9 (GS_FORCE=1 to continue):"* ]] && [[ "$OUT" == *"#7 OPEN feat: slice two (#9)"* ]] && [[ "$OUT" != *"#5 MERGED"* ]]' - -# --- sync (workshop) ---------------------------------------------------------- - -setup workshop -# Clones need an origin with main; the path keeps the Gentleman-Programming name. -O="$T/Gentleman-Programming"; mkdir -p "$O" -for r in gentle-shell gentle-ai engram; do - c="$HOME/dev/gentle/$r" - git -C "$c" -c user.email=t@t -c user.name=t commit -q --allow-empty -m init - git -C "$c" branch -M main - git clone -q --bare "$c" "$O/$r.git"; git -C "$c" remote set-url origin "$O/$r.git" - git -C "$c" fetch -q origin -done -git -C "$HOME/dev/gentle/gentle-shell" checkout -q --detach -mkdir -p "$HOME/dev/gentle/engram/plugin/pi" -rm "$S/pi" # workshop needs no Pi runtime of its own on PATH -mkdir -p "$HOME/dev/gentle/gentle-shell/.engram" "$HOME/dev/gentle/gentle-shell/odd/checks" -touch "$HOME/dev/gentle/gentle-shell/.engram/x" "$HOME/dev/gentle/gentle-shell/odd/checks/y" -OUT=$("$GS" sync 2>&1); rc=$? -check "workshop sync: untracked session files and detached HEAD do not block it" \ - '[[ "$OUT" != *"clean clones only"* ]] && [ "$(git -C "$HOME/dev/gentle/gentle-shell" branch --show-current)" = main ] && [ -e "$HOME/dev/gentle/gentle-shell/.engram/x" ]' -REG="$HOME/.pi/gentle-ai/dev-binary.json" -check "workshop sync: registers gentle-ai main where the launcher reads it (~/.pi/gentle-ai, file 0600)" \ - '[ "$(cat "$REG")" = "{\"schema\":\"gentle-pi.dev-binary/v1\",\"path\":\"$HOME/.local/share/gentle-ai/main/gentle-ai\"}" ] && [ "$(stat -c %a "$REG" 2>/dev/null || stat -f %Lp "$REG")" = 600 ]' -check "workshop sync: writes no registration into the isolated config home" \ - '[ ! -e "$HOME/.gentle-shell/gentle-ai/dev-binary.json" ]' -check "workshop sync: never runs pi update nor adds ~/.pi/agent/bin to PATH" \ - '! grep -q "^pi " "$CALLS" 2>/dev/null && grep -q "^go PATH=" "$CALLS" && ! grep -q "PATH=.*\.pi/agent/bin" "$CALLS"' -mkdir -p "$HOME/.gentle-shell/agent"; printf '{"packages":["npm:gentle-pi","npm:gentle-engram"]}\n' > "$HOME/.gentle-shell/agent/settings.json" -OUT=$("$GS" sync 2>&1) -check "workshop sync: isolated home packages the official Shell requires raise no warning" \ - '[[ "$OUT" != *"WARN:"*"settings.json"* ]]' -# S8: workshop gentle-ai sync is Claude Code only, then the isolated home is provisioned. -AI="$HOME/.local/share/gentle-ai/main/gentle-ai" -printf '#!/bin/sh\n[ "$1" = --version ] && { echo "gentle-ai 1.0.0"; exit 0; }\necho "gentle-ai $*" >> "$CALLS"\n' > "$AI"; chmod +x "$AI" -SHELL_CLONE="$HOME/dev/gentle/gentle-shell" -: > "$CALLS"; OUT=$(env -u GS_SKIP_AI_SYNC "$GS" sync 2>&1); rc=$? -check "workshop sync: gentle-ai sync targets claude-code only, then gentle-shell setup on the isolated home" \ - '[ $rc -eq 0 ] && [ "$(grep -E "^gentle-ai sync|--isolated setup" "$CALLS")" = "$(printf "gentle-ai sync --agents claude-code\nlaunch %s --isolated setup" "$SHELL_CLONE")" ] && [[ "$OUT" == *"gentle-shell setup (isolated home)"*ok* ]]' -# gentle-ai and gentle-shell setup must use the Engram gs just built, even when the -# calling shell never exported GENTLE_AI_ENGRAM_BIN (else beta installs the pre-v3 path). -printf '#!/bin/sh\n[ "$1" = --version ] && { echo "gentle-ai 1.0.0"; exit 0; }\necho "gentle-ai engram=${GENTLE_AI_ENGRAM_BIN:-unset}" >> "$CALLS"\n' > "$AI" -cp "$S/node" "$T/node.real" -printf '#!/bin/sh\necho "setup engram=${GENTLE_AI_ENGRAM_BIN:-unset}" >> "$CALLS"\nexec %s "$@"\n' "$T/node.real" > "$S/node" -: > "$CALLS"; OUT=$(env -u GS_SKIP_AI_SYNC -u GENTLE_AI_ENGRAM_BIN "$GS" sync 2>&1) -EB="$HOME/.local/share/gentle-ai/main/engram" -check "workshop sync: gentle-ai sync and gentle-shell setup get GENTLE_AI_ENGRAM_BIN on the built engram" \ - 'grep -qx "gentle-ai engram=$EB" "$CALLS" && grep -qx "setup engram=$EB" "$CALLS"' -: > "$CALLS"; OUT=$(env -u GS_SKIP_AI_SYNC GENTLE_AI_ENGRAM_BIN=/opt/e "$GS" sync 2>&1) -check "workshop sync: an explicit GENTLE_AI_ENGRAM_BIN wins" \ - 'grep -qx "gentle-ai engram=/opt/e" "$CALLS" && grep -qx "setup engram=/opt/e" "$CALLS"' -cp "$T/node.real" "$S/node" -printf '#!/bin/sh\n[ "$1" = --version ] && { echo "gentle-ai 1.0.0"; exit 0; }\necho "gentle-ai $*" >> "$CALLS"\n' > "$AI" -: > "$CALLS"; OUT=$(env -u GS_SKIP_AI_SYNC GS_SYNC_AGENTS=x "$GS" sync 2>&1) -check "workshop sync: GS_SYNC_AGENTS overrides the claude-code default" \ - 'grep -qx "gentle-ai sync --agents x" "$CALLS"' -: > "$CALLS"; OUT=$(GS_SKIP_AI_SYNC=1 "$GS" sync 2>&1) -check "workshop sync: GS_SKIP_AI_SYNC=1 skips gentle-ai sync and gentle-shell setup" \ - '! grep -qE "^gentle-ai sync|--isolated setup" "$CALLS" && [[ "$OUT" == *"gentle-ai sync"*"skipped (GS_SKIP_AI_SYNC=1)"* ]] && [[ "$OUT" == *"gentle-shell setup (isolated home)"*"skipped (GS_SKIP_AI_SYNC=1)"* ]]' -OUT=$(env -u GS_SKIP_AI_SYNC SETUP_RC=1 "$GS" sync 2>&1); rc=$? -check "workshop sync: failed gentle-shell setup is INCOMPLETE, exit 1" \ - '[ $rc -eq 1 ] && [[ "$OUT" == *"INCOMPLETE"*"gentle-shell setup (isolated home)"* ]]' -rm "$HOME/.gentle-shell/agent/settings.json" -echo tracked >> "$HOME/dev/gentle/gentle-shell/VERSION"; git -C "$HOME/dev/gentle/gentle-shell" add VERSION -OUT=$("$GS" sync 2>&1); rc=$? -check "workshop sync: tracked edits in a clone still stop it" '[ $rc -ne 0 ] && [[ "$OUT" == *"clean clones only"* ]]' - -# --- sync (consumer) ---------------------------------------------------------- - -setup consumer; remote_main HEAD; main=$(sha_of) -OUT=$(UPGRADE_TO="4.5.2-main.${main:0:12}" "$GS" sync 2>&1); rc=$? -check "sync: reports the version gs opens, from the same package" \ - '[ $rc -eq 0 ] && [[ "$OUT" == *"gs opens: 4.5.2-main.${main:0:12} ($NPM)"* ]]' -check "sync: a real update asks for a restart" '[[ "$OUT" == *"shell (4.5.1-main.feee99d88dae -> 4.5.2-main"* ]]' -check "consumer sync: still runs pi update and pi update --extensions" \ - 'grep -qx "pi update" "$CALLS" && grep -qx "pi update --extensions" "$CALLS"' -check "sync: warns about the unused legacy checkout without deleting it" \ - '[[ "$OUT" == *"stale legacy checkout"* ]] && [ -d "$HOME/.pi/agent/git/github.com/Gentleman-Programming/gentle-pi" ]' - -setup consumer; remote_main HEAD -printf '#!/bin/sh\n[ "$1" = --version ] && { echo "gentle-ai 1.0.0"; exit 0; }\necho "gentle-ai $*" >> "$CALLS"\n' > "$HOME/ai" -OUT=$(env -u GS_SKIP_AI_SYNC "$GS" sync 2>&1) -check "consumer sync: still runs bare gentle-ai sync (no --agents) and no gentle-shell setup" \ - 'grep -qx "gentle-ai sync" "$CALLS" && ! grep -q -- "--isolated setup" "$CALLS" && [[ "$OUT" != *"gentle-shell setup"* ]]' - -setup consumer; remote_main HEAD; main=$(sha_of) -echo "4.5.2-main.${main:0:12}" > "$NPM/VERSION" -OUT=$("$GS" sync 2>&1); rc=$? -check "sync: current and unchanged says no restart needed" \ - '[ $rc -eq 0 ] && [[ "$OUT" == *"no Pi restart needed"* ]]' - -setup consumer; remote_main HEAD -OUT=$(UPGRADE_RC=1 "$GS" sync 2>&1); rc=$? -check "sync: failed upgrade is INCOMPLETE, exit 1, never 'no restart needed'" \ - '[ $rc -eq 1 ] && [[ "$OUT" == *"INCOMPLETE"*"gentle-shell upgrade (main)"* ]] && [[ "$OUT" != *"no Pi restart needed"* ]]' - -setup consumer; remote_main HEAD -OUT=$("$GS" sync 2>&1); rc=$? -check "sync: launch target behind GitHub main is INCOMPLETE" \ - '[ $rc -eq 1 ] && [[ "$OUT" == *"gentle-shell behind main (built feee99d88dae"* ]]' - -setup consumer; rm -rf "$GS_SHELL_REMOTE"; export GS_SHELL_REMOTE="$T/missing" -OUT=$("$GS" sync 2>&1); rc=$? -check "sync: unreachable GitHub only warns that freshness is unverified" \ - '[ $rc -eq 0 ] && [[ "$OUT" == *"freshness unverified"* ]]' - -# gentle-ai's beta upgrade runs `go install` of the pre-v3 Engram module path; with a -# go env GOBIN of ~/.local/bin it overwrote the engram symlink with Engram v1. -setup consumer; remote_main HEAD -: > "$CALLS"; OUT=$(GOBIN="$HOME/.local/bin" "$GS" sync 2>&1) -check "sync: go installs run by the upgrade land in ~/go/bin, never ~/.local/bin" \ - 'grep -qx "upgrade GOBIN=$HOME/go/bin" "$CALLS"' - -setup consumer; remote_main HEAD; main=$(sha_of) -echo "4.5.2-main.${main:0:12}" > "$NPM/VERSION" -OUT=$(ENGRAM_PRE_V3=1 "$GS" sync 2>&1); rc=$? -check "sync: an engram without instance-id (pre-v3) is INCOMPLETE, exit 1" \ - '[ $rc -eq 1 ] && [[ "$OUT" == *"INCOMPLETE"*"engram lacks instance-id (pre-v3)"* ]]' - -printf '\n%s\n' "$([ $fails -eq 0 ] && echo 'all passed' || echo "$fails failed")" -exit $fails