From 4c9e00d36d882538beb88f3600f0a841b4cc28fa Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Wed, 22 Jul 2026 21:33:38 +0300 Subject: [PATCH 01/12] Rewrite CLI in Go for v2 --- .github/workflows/ci.yml | 95 +- .github/workflows/release.yml | 53 +- .goreleaser.yaml | 26 + CHANGELOG.md | 7 + Makefile | 45 +- README.md | 71 +- README.ru.md | 4 +- cmd/start-issue/main.go | 631 ++++++++ cmd/start-issue/main_test.go | 68 + go.mod | 3 + install.sh | 175 --- memory-bank/engineering/architecture.md | 41 +- memory-bank/engineering/coding-style.md | 21 +- memory-bank/engineering/testing-policy.md | 25 +- memory-bank/features/FT-017/README.md | 30 + memory-bank/features/FT-017/brief.md | 121 ++ memory-bank/features/FT-017/decision-log.md | 81 + memory-bank/features/FT-017/design.md | 126 ++ .../features/FT-017/implementation-plan.md | 137 ++ memory-bank/features/README.md | 5 +- memory-bank/ops/development.md | 16 +- memory-bank/product/context.md | 2 +- mise.toml | 2 +- scripts/build-start-issue | 53 - scripts/bump-version | 74 - scripts/lib/start_issue/agent.sh | 472 ------ scripts/lib/start_issue/cli.sh | 186 --- scripts/lib/start_issue/config.sh | 191 --- scripts/lib/start_issue/github.sh | 69 - scripts/lib/start_issue/init.sh | 379 ----- scripts/lib/start_issue/output.sh | 439 ------ scripts/lib/start_issue/pipeline.sh | 96 -- scripts/lib/start_issue/release.sh | 139 -- scripts/lib/start_issue/update.sh | 104 -- scripts/lib/start_issue/utils.sh | 132 -- scripts/lib/start_issue/worktree.sh | 403 ----- scripts/prepare-release | 113 -- scripts/start-issue | 187 --- test/e2e/human-gate.sh | 2 +- test/fixtures/issue-1.json | 14 - test/helpers/fake-bin/claude | 42 - test/helpers/fake-bin/codex | 128 -- test/helpers/fake-bin/gh | 24 - test/helpers/fake-bin/kimi | 24 - test/helpers/fake-bin/pi | 31 - test/start_issue.bats | 1341 ----------------- 46 files changed, 1357 insertions(+), 5071 deletions(-) create mode 100644 .goreleaser.yaml create mode 100644 cmd/start-issue/main.go create mode 100644 cmd/start-issue/main_test.go create mode 100644 go.mod delete mode 100755 install.sh create mode 100644 memory-bank/features/FT-017/README.md create mode 100644 memory-bank/features/FT-017/brief.md create mode 100644 memory-bank/features/FT-017/decision-log.md create mode 100644 memory-bank/features/FT-017/design.md create mode 100644 memory-bank/features/FT-017/implementation-plan.md delete mode 100755 scripts/build-start-issue delete mode 100755 scripts/bump-version delete mode 100644 scripts/lib/start_issue/agent.sh delete mode 100644 scripts/lib/start_issue/cli.sh delete mode 100644 scripts/lib/start_issue/config.sh delete mode 100644 scripts/lib/start_issue/github.sh delete mode 100644 scripts/lib/start_issue/init.sh delete mode 100644 scripts/lib/start_issue/output.sh delete mode 100644 scripts/lib/start_issue/pipeline.sh delete mode 100644 scripts/lib/start_issue/release.sh delete mode 100644 scripts/lib/start_issue/update.sh delete mode 100644 scripts/lib/start_issue/utils.sh delete mode 100644 scripts/lib/start_issue/worktree.sh delete mode 100755 scripts/prepare-release delete mode 100755 scripts/start-issue delete mode 100644 test/fixtures/issue-1.json delete mode 100755 test/helpers/fake-bin/claude delete mode 100755 test/helpers/fake-bin/codex delete mode 100755 test/helpers/fake-bin/gh delete mode 100755 test/helpers/fake-bin/kimi delete mode 100755 test/helpers/fake-bin/pi delete mode 100644 test/start_issue.bats diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0feced0..3110182 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,76 +5,43 @@ on: push: branches: [master, main] -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true +permissions: + contents: read jobs: test: runs-on: ubuntu-latest - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Set up mise - uses: jdx/mise-action@v2 - - - name: Install test dependencies - run: | - sudo apt-get update - sudo apt-get install -y shellcheck - - - name: Static checks - run: | - bash -n scripts/start-issue - shellcheck install.sh scripts/start-issue scripts/build-start-issue scripts/bump-version scripts/prepare-release scripts/lib/start_issue/*.sh - git diff --check - - - name: Legacy environment drift check - run: | - legacy_name="CLAUDE""_WORKTREE_DIR" - if grep -R --line-number "$legacy_name" scripts README.md README.ru.md docs doc test .github; then - echo "Legacy Claude worktree environment variable name must not be used." - exit 1 - fi - - - name: Integration tests - run: mise exec -- bats test - - install-script: - runs-on: ${{ matrix.os }} + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: make test + - run: make build + - run: .build/start-issue --version + + cross-build: + runs-on: ubuntu-latest strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] - + include: + - os: linux + arch: amd64 + - os: linux + arch: arm64 + - os: darwin + arch: amd64 + - os: darwin + arch: arm64 + - os: windows + arch: amd64 steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Build local release artifact - run: | - bash scripts/build-start-issue "$RUNNER_TEMP/start-issue" >/dev/null - chmod +x "$RUNNER_TEMP/start-issue" - - - name: Generate checksum file - run: | - if command -v sha256sum >/dev/null 2>&1; then - checksum="$(sha256sum "$RUNNER_TEMP/start-issue" | awk '{ print $1 }')" - else - checksum="$(shasum -a 256 "$RUNNER_TEMP/start-issue" | awk '{ print $1 }')" - fi - printf '%s %s\n' "$checksum" start-issue > "$RUNNER_TEMP/start-issue.sha256" - - - name: Test install.sh via pipe - run: | - curl -fsSL "file://$GITHUB_WORKSPACE/install.sh" | env \ - PREFIX="$RUNNER_TEMP/prefix" \ - START_ISSUE_ASSET_URL="file://$RUNNER_TEMP/start-issue" \ - START_ISSUE_CHECKSUM_URL="file://$RUNNER_TEMP/start-issue.sha256" \ - bash - - - name: Verify installed binary - run: | - test -x "$RUNNER_TEMP/prefix/bin/start-issue" - "$RUNNER_TEMP/prefix/bin/start-issue" --version | grep '^start-issue v' + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: go build ./cmd/start-issue + env: + GOOS: ${{ matrix.os }} + GOARCH: ${{ matrix.arch }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d40eee2..83eaef8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,51 +2,26 @@ name: Release on: push: - tags: - - "v*" + tags: ["v*"] permissions: contents: write -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - jobs: release: runs-on: ubuntu-latest - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Set up mise - uses: jdx/mise-action@v2 - - - name: Install test dependencies - run: | - sudo apt-get update - sudo apt-get install -y shellcheck - - - name: Verify tag matches script version - run: | - script_version="$(awk -F'"' '/^VERSION="/ { print $2; exit }' scripts/start-issue)" - tag_version="${GITHUB_REF_NAME#v}" - test "$script_version" = "$tag_version" - - - name: Run checks - run: make test - - - name: Build release artifact - run: | - make build - cp .build/start-issue start-issue - sha256sum start-issue > start-issue.sha256 - - - name: Publish GitHub release + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: make test + - uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: ~> v2 + args: release --clean env: - GITHUB_TOKEN: ${{ github.token }} - run: | - gh release create "$GITHUB_REF_NAME" \ - start-issue \ - start-issue.sha256 \ - --generate-notes + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..55e14bb --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,26 @@ +version: 2 + +before: + hooks: + - go mod tidy + +builds: + - id: start-issue + main: ./cmd/start-issue + binary: start-issue + env: [CGO_ENABLED=0] + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + ignore: + - goos: windows + goarch: arm64 + ldflags: + - -s -w -X main.version={{ .Version }} + +archives: + - id: binaries + formats: [binary] + name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" + +checksum: + name_template: checksums.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e97552..1f7700f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,13 @@ This project follows Semantic Versioning. - Removed the obsolete `codex exec --ask-for-approval` argument from the Codex human-gate workflow, restoring compatibility with current Codex CLI versions. +## [2.0.0] - 2026-07-22 + +### Changed + +- Replaced the Bash CLI, Bats suite, and shell build/release tooling with the Go implementation and Go test suite. +- Changed distribution to platform-specific Go binaries with `checksums.txt` verification. + ## [1.13.2] - 2026-07-19 ### Added diff --git a/Makefile b/Makefile index ca87388..3ca256a 100644 --- a/Makefile +++ b/Makefile @@ -4,54 +4,31 @@ PREFIX ?= $(HOME)/.local BINDIR ?= $(PREFIX)/bin BUILD_DIR ?= .build BUILD_OUTPUT ?= $(BUILD_DIR)/start-issue +VERSION ?= 2.0.0 build: @mkdir -p "$(BUILD_DIR)" - @bash scripts/build-start-issue "$(BUILD_OUTPUT)" >/dev/null - @chmod +x "$(BUILD_OUTPUT)" + go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o "$(BUILD_OUTPUT)" ./cmd/start-issue @echo "Built: $(BUILD_OUTPUT)" -install: +install: build @mkdir -p "$(BINDIR)" - @set -e; \ - tmpfile="$$(mktemp)"; \ - trap 'rm -f "$$tmpfile"' EXIT; \ - bash scripts/build-start-issue "$$tmpfile" >/dev/null; \ - cp "$$tmpfile" "$(BINDIR)/start-issue" - @chmod +x "$(BINDIR)/start-issue" + install -m 0755 "$(BUILD_OUTPUT)" "$(BINDIR)/start-issue" @echo "Installed: $(BINDIR)/start-issue" uninstall: - @rm -f "$(BINDIR)/start-issue" + rm -f "$(BINDIR)/start-issue" @echo "Removed: $(BINDIR)/start-issue" test: - bash -n scripts/start-issue - shellcheck install.sh scripts/start-issue scripts/build-start-issue scripts/bump-version scripts/prepare-release scripts/lib/start_issue/*.sh test/e2e/*.sh + @test -z "$$($$(go env GOROOT)/bin/gofmt -l cmd)" + go vet ./... + go test ./... python3 scripts/check_memory_bank_index.py --max-depth 4 git diff --check - bats test -e2e-human-gate: - @bash test/e2e/human-gate.sh +e2e-human-gate: build + @START_ISSUE_E2E_BINARY="$(abspath $(BUILD_OUTPUT))" bash test/e2e/human-gate.sh print-version: - @awk -F'"' '/^VERSION="/ { print $$2; exit }' scripts/start-issue - -bump-patch: - @bash scripts/bump-version patch - -bump-minor: - @bash scripts/bump-version minor - -bump-major: - @bash scripts/bump-version major - -release-patch: - @bash scripts/prepare-release patch - -release-minor: - @bash scripts/prepare-release minor - -release-major: - @bash scripts/prepare-release major + @echo "$(VERSION)" diff --git a/README.md b/README.md index 620249b..a24f9a0 100644 --- a/README.md +++ b/README.md @@ -16,38 +16,15 @@ It fetches issue metadata with `gh`, creates a git worktree with a branch name b ## Install -Install the latest published release: +Install from source with Go: ```bash -curl -fsSL https://raw.githubusercontent.com/dapi/start-issue/master/install.sh | bash +go install github.com/dapi/start-issue/cmd/start-issue@latest ``` -The installer downloads the latest GitHub Release asset into `~/.local/bin/start-issue` by default. - -For developer diagnostics on a machine where the install appears to hang: - -```bash -curl -fsSL https://raw.githubusercontent.com/dapi/start-issue/master/install.sh | bash -s -- --debug -``` - -This enables shell tracing plus verbose `curl` or `wget` output so you can see which step is blocking. - -Manual install: - -```bash -mkdir -p ~/.local/bin -curl -fsSL https://github.com/dapi/start-issue/releases/latest/download/start-issue -o ~/.local/bin/start-issue -chmod +x ~/.local/bin/start-issue -``` - -Verify the download if you want: - -```bash -tmpdir="$(mktemp -d)" -curl -fsSL https://github.com/dapi/start-issue/releases/latest/download/start-issue -o "$tmpdir/start-issue" -curl -fsSL https://github.com/dapi/start-issue/releases/latest/download/start-issue.sha256 -o "$tmpdir/start-issue.sha256" -(cd "$tmpdir" && shasum -a 256 -c start-issue.sha256) -``` +Published releases contain platform-specific Go binaries and a `checksums.txt` +manifest. Download the asset matching your OS and architecture from the release +page and verify it against that manifest before adding it to `PATH`. Build and install from source: @@ -55,7 +32,7 @@ Build and install from source: make install ``` -This builds a self-contained `start-issue` script from the modular sources and installs it to `~/.local/bin/start-issue`. +This builds and installs the Go binary to `~/.local/bin/start-issue`. Make sure `~/.local/bin` is in your `PATH`. @@ -123,16 +100,10 @@ flowchart TD ## Internal Architecture -The CLI entrypoint remains `scripts/start-issue`, but the implementation is now split into focused shell modules under `scripts/lib/start_issue/`. -`make build` and `make install` bundle those modules back into a single-file script for distribution and local installation. - -- `cli.sh` parses arguments and normalizes flags into workflow state. -- `config.sh` resolves agent, model, and prompt configuration. -- `github.sh` resolves repository context and fetches issue metadata. -- `worktree.sh` plans branch/worktree behavior and runs worktree-side effects. -- `agent.sh` owns agent adapter operations: validation, launch command construction, AI branch naming, and prompt improvement. -- `release.sh` owns release download, checksum, and version-normalization helpers shared by install and update paths. -- `update.sh` owns the self-update workflow and latest-release resolution. +The Go entrypoint is `cmd/start-issue`. It owns argument parsing, configuration +resolution, repository and worktree orchestration, and the adapter commands for +supported agents. `git`, `gh`, and agent CLIs remain explicit external process +boundaries. - `output.sh` renders help, status, dry-run output, and session framing. - `init.sh` owns `start-issue init` plus the user-config onboarding helpers behind `setup`. - `pipeline.sh` makes the orchestration pipeline explicit. @@ -354,32 +325,34 @@ Optional dependency for Zellij support: ## Requirements -- `bash` +- Go 1.21+ - `git` - `gh` CLI with authenticated GitHub session - `jq` - selected agent CLI unless `--agent none` or `--dry-run` is used -The `curl | bash` installer and self-update workflow need `bash` plus either `curl` or `wget`. - ## Releases -GitHub Releases are published automatically when a SemVer tag like `v1.12.0` is pushed. The release workflow reruns the test suite, verifies that the tag matches `VERSION` in `scripts/start-issue`, builds the bundled `start-issue` script, and uploads: +GitHub Releases are published automatically when a SemVer tag like `v1.12.0` is pushed. The release workflow reruns the Go test suite and publishes platform-specific binaries with a checksum manifest: -- `start-issue` -- `start-issue.sha256` +- `start-issue-linux-amd64` +- `start-issue-linux-arm64` +- `start-issue-darwin-amd64` +- `start-issue-darwin-arm64` +- `start-issue-windows-amd64.exe` +- `checksums.txt` To prepare a release locally: ```bash -make release-patch -make release-minor -make release-major +make test +git tag v2.0.0 +git push origin v2.0.0 ``` Before preparing a release, add user-facing changes under `## [Unreleased]` in `CHANGELOG.md`. -Each command requires a clean worktree, bumps `VERSION`, moves the `CHANGELOG.md` unreleased entries under the new version and date, runs `make test` and `make build`, creates a local commit like `Release v1.12.0`, and creates the matching annotated git tag. +Create releases from a clean worktree after `make test` and `make build` pass. The tag is the source of the published binary version. Publish the prepared release with: diff --git a/README.ru.md b/README.ru.md index d362085..b3a7018 100644 --- a/README.ru.md +++ b/README.ru.md @@ -19,7 +19,7 @@ Установить последний опубликованный релиз: ```bash -curl -fsSL https://raw.githubusercontent.com/dapi/start-issue/master/install.sh | bash +go install github.com/dapi/start-issue/cmd/start-issue@latest ``` Скрипт установки скачивает asset из последнего GitHub Release в `~/.local/bin/start-issue` по умолчанию. @@ -196,7 +196,7 @@ flowchart TD ## Внутренняя архитектура -CLI entrypoint остается `scripts/start-issue`, но реализация теперь разбита на специализированные shell-модули в `scripts/lib/start_issue/`. +CLI entrypoint — `cmd/start-issue`; runtime, build и тесты реализованы на Go. `make build` и `make install` собирают эти модули обратно в single-file script для дистрибуции и локальной установки. - `cli.sh` парсит аргументы и нормализует флаги в состояние workflow. diff --git a/cmd/start-issue/main.go b/cmd/start-issue/main.go new file mode 100644 index 0000000..b221eee --- /dev/null +++ b/cmd/start-issue/main.go @@ -0,0 +1,631 @@ +package main + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" +) + +var version = "2.0.0" + +type options struct { + repo, base, worktreeDir, agent, model, promptFile, prompt, command string + issue string + dryRun, noInit, flat, ai, improvePrompt, humanGate bool + mode string +} + +type issue struct { + Title, Body string + Labels []struct { + Name string `json:"name"` + } `json:"labels"` +} + +func main() { + o, err := parse(os.Args[1:]) + if err != nil { + die(err) + } + if o.mode != "" { + if err := runMode(o); err != nil { + die(err) + } + return + } + if o.issue == "" { + usage() + return + } + if err := run(o); err != nil { + die(err) + } +} + +func parse(args []string) (options, error) { + o := options{worktreeDir: os.Getenv("START_ISSUE_WORKTREE_DIR")} + var err error + if o.worktreeDir == "" { + home, _ := os.UserHomeDir() + o.worktreeDir = filepath.Join(home, "worktrees") + } + for len(args) > 0 { + a := args[0] + args = args[1:] + value := func() (string, error) { + if len(args) == 0 || strings.HasPrefix(args[0], "-") { + return "", fmt.Errorf("%s requires a value.", a) + } + v := args[0] + args = args[1:] + return v, nil + } + switch a { + case "--help", "-h": + usage() + os.Exit(0) + case "--version", "-v": + fmt.Printf("start-issue v%s\n", version) + os.Exit(0) + case "--repo", "-r": + o.repo, err = value() + case "--base", "-b": + o.base, err = value() + case "--worktree-dir", "-w": + o.worktreeDir, err = value() + case "--agent": + o.agent, err = value() + case "--model": + o.model, err = value() + case "--prompt-file": + o.promptFile, err = value() + case "--prompt": + o.prompt, err = value() + case "--command", "-c": + o.command, err = value() + case "--no-agent", "--no-claude": + o.agent = "none" + case "--dry-run": + o.dryRun = true + case "--no-init": + o.noInit = true + case "--flat": + o.flat = true + case "--ai": + o.ai = true + case "--improve-prompt": + o.improvePrompt = true + case "--human-gate": + o.humanGate = true + case "init": + o.mode = "init" + case "setup", "--setup": + o.mode = "setup" + case "update", "--update": + o.mode = "update" + case "--human-gate-help": + humanGateHelp() + os.Exit(0) + default: + if strings.HasPrefix(a, "-") { + return o, fmt.Errorf("Unknown option: %s. Use --help for usage.", a) + } + if o.issue != "" { + return o, fmt.Errorf("Unexpected argument: %s", a) + } + o.issue = a + } + if err != nil { + return o, err + } + } + if o.prompt != "" && o.promptFile != "" { + return o, errors.New("Use either --prompt-file or --prompt, not both.") + } + return o, nil +} + +func run(o options) error { + root, err := output("git", "rev-parse", "--show-toplevel") + if err != nil { + return errors.New("Not in a git repository") + } + root = strings.TrimSpace(root) + agent, agentSource, err := resolveAgent(root, o.agent) + if err != nil { + return err + } + if o.humanGate && agent != "codex" { + return fmt.Errorf("--human-gate requires agent 'codex'. Current agent: %s.", agent) + } + model, modelSource, err := resolveModel(root, o.model) + if err != nil { + return err + } + prompt, promptSource, err := resolvePrompt(root, agent, o) + if err != nil { + return err + } + number, repo, err := parseIssue(o.issue, o.repo) + if err != nil { + return err + } + if repo == "" { + repo, err = detectRepo() + if err != nil { + return err + } + } + if o.base == "" { + o.base = detectBase() + } + if err := need("gh"); err != nil { + return err + } + if err := need("git"); err != nil { + return err + } + data, err := output("gh", "api", fmt.Sprintf("repos/%s/issues/%s", repo, number)) + if err != nil { + return fmt.Errorf("Issue #%s not found in %s", number, repo) + } + var in issue + if err = json.Unmarshal([]byte(data), &in); err != nil { + return err + } + labels := []string{} + for _, l := range in.Labels { + labels = append(labels, l.Name) + } + branch := branchName(number, in.Title, strings.Join(labels, ", ")) + name := branch + if o.flat { + name = strings.ReplaceAll(name, "/", "-") + } + worktree := filepath.Join(o.worktreeDir, name) + issueURL := fmt.Sprintf("https://github.com/%s/issues/%s", repo, number) + rendered := render(prompt, map[string]string{"ISSUE_URL": issueURL, "ISSUE_NUMBER": number, "ISSUE_TITLE": in.Title, "ISSUE_BODY": in.Body, "ISSUE_LABELS": strings.Join(labels, ", "), "REPO": repo, "BRANCH_NAME": branch, "WORKTREE_PATH": worktree, "BASE_BRANCH": o.base}) + fmt.Printf("Agent: %s\nAgent source: %s\nModel: %s\nModel source: %s\nWorktree directory: %s\nPrompt source: %s\n\n", agent, agentSource, show(model), modelSource, o.worktreeDir, promptSource) + fmt.Printf("🔍 Fetching issue #%s from %s...\n Title: %s\n", number, repo, in.Title) + fmt.Printf(" Branch: %s (fast)\n📁 Creating worktree...\n Path: %s\n Base: %s\n", branch, worktree, o.base) + if o.dryRun { + fmt.Printf(" [DRY-RUN] Would run: git worktree add -b %s %s %s\n", branch, worktree, o.base) + printLaunch(agent, model, worktree, rendered) + return nil + } + if _, err := os.Stat(worktree); err == nil { + return fmt.Errorf("Worktree path already exists: %s", worktree) + } + if err := os.MkdirAll(filepath.Dir(worktree), 0755); err != nil { + return err + } + if err := command("git", "worktree", "add", "-b", branch, worktree, "origin/"+o.base); err != nil { + if err = command("git", "worktree", "add", "-b", branch, worktree, o.base); err != nil { + return errors.New("Failed to create worktree") + } + } + if !o.noInit { + init := filepath.Join(worktree, "init.sh") + if _, err := os.Stat(init); err == nil { + _ = commandAt(worktree, "bash", "./init.sh") + } + } + return launch(agent, model, worktree, rendered) +} + +func runMode(o options) error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + root, _ := output("git", "rev-parse", "--show-toplevel") + root = strings.TrimSpace(root) + if o.mode == "update" { + return updateMode(o) + } + dir := filepath.Join(home, ".config", "start-issue") + if o.mode == "init" && root != "" { + dir = filepath.Join(root, ".start-issue") + } + if o.dryRun { + fmt.Printf("[DRY-RUN] Would create configuration in: %s\n", dir) + return nil + } + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + agent := o.agent + if agent == "" { + agent = "claude" + } + if err := os.WriteFile(filepath.Join(dir, "agent"), []byte(agent+"\n"), 0644); err != nil { + return err + } + if o.model != "" { + if err := os.WriteFile(filepath.Join(dir, "model"), []byte(o.model+"\n"), 0644); err != nil { + return err + } + } + prompt := o.prompt + if prompt == "" { + if agent == "claude" { + prompt = "/task-router:route-task {ISSUE_URL}" + } else { + prompt = "Implement GitHub issue {ISSUE_URL} in this worktree." + } + } + if err := os.WriteFile(filepath.Join(dir, "prompt.md"), []byte(prompt+"\n"), 0644); err != nil { + return err + } + fmt.Printf("Wrote start-issue configuration: %s\n", dir) + return nil +} + +func updateMode(o options) error { + if runtime.GOOS == "windows" { + fmt.Println("Windows update is manual: download start-issue-windows-amd64.exe from the latest release and replace the executable on PATH.") + return nil + } + data, err := output("gh", "api", "repos/dapi/start-issue/releases/latest") + if err != nil { + return errors.New("Could not fetch the latest start-issue release") + } + var release githubRelease + if err := json.Unmarshal([]byte(data), &release); err != nil { + return fmt.Errorf("decode latest release: %w", err) + } + if compareVersions(version, release.TagName) >= 0 { + fmt.Printf("start-issue is already up to date (%s).\n", version) + return nil + } + assetName := releaseAssetName(runtime.GOOS, runtime.GOARCH) + assetURL, checksumURL := release.assetURLs(assetName) + if assetURL == "" || checksumURL == "" { + return fmt.Errorf("latest release does not contain %s and checksums.txt", assetName) + } + binary, err := download(assetURL) + if err != nil { + return fmt.Errorf("download %s: %w", assetName, err) + } + checksums, err := download(checksumURL) + if err != nil { + return fmt.Errorf("download checksums.txt: %w", err) + } + if !validChecksum(binary, assetName, string(checksums)) { + return fmt.Errorf("checksum verification failed for %s", assetName) + } + target, err := os.Executable() + if err != nil { + return err + } + temporary := target + ".new" + if err := os.WriteFile(temporary, binary, 0755); err != nil { + return err + } + if err := os.Rename(temporary, target); err != nil { + _ = os.Remove(temporary) + return err + } + fmt.Printf("Updated start-issue at: %s\nVersion: start-issue v%s\n", target, strings.TrimPrefix(release.TagName, "v")) + return nil +} + +type githubRelease struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` + } `json:"assets"` +} + +func (r githubRelease) assetURLs(name string) (string, string) { + var assetURL, checksumURL string + for _, asset := range r.Assets { + switch asset.Name { + case name: + assetURL = asset.URL + case "checksums.txt": + checksumURL = asset.URL + } + } + return assetURL, checksumURL +} + +func releaseAssetName(goos, goarch string) string { + name := fmt.Sprintf("start-issue-%s-%s", goos, goarch) + if goos == "windows" { + return name + ".exe" + } + return name +} + +func download(url string) ([]byte, error) { + response, err := http.Get(url) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("unexpected HTTP status %s", response.Status) + } + return io.ReadAll(response.Body) +} + +func validChecksum(binary []byte, name, manifest string) bool { + want := "" + for _, line := range strings.Split(manifest, "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 && strings.TrimPrefix(fields[1], "*") == name { + want = fields[0] + break + } + } + actual := fmt.Sprintf("%x", sha256.Sum256(binary)) + return want != "" && strings.EqualFold(want, actual) +} + +func compareVersions(left, right string) int { + parse := func(value string) [3]int { + var result [3]int + for index, part := range strings.Split(strings.TrimPrefix(value, "v"), ".") { + if index == len(result) { + break + } + result[index], _ = strconv.Atoi(regexp.MustCompile(`^[0-9]+`).FindString(part)) + } + return result + } + a, b := parse(left), parse(right) + for i := range a { + if a[i] < b[i] { + return -1 + } + if a[i] > b[i] { + return 1 + } + } + return 0 +} + +func resolveAgent(root, cli string) (string, string, error) { + home, _ := os.UserHomeDir() + v, s, e := resolve(cli, filepath.Join(root, ".start-issue", "agent"), filepath.Join(home, ".config", "start-issue", "agent"), "START_ISSUE_AGENT", "claude") + if e != nil { + return "", "", e + } + switch v { + case "claude", "codex", "kimi", "pi", "none": + return v, s, nil + } + return "", "", fmt.Errorf("Unknown agent: %s. Valid agents: claude, codex, kimi, pi, none.", v) +} +func resolveModel(root, cli string) (string, string, error) { + home, _ := os.UserHomeDir() + v, s, e := resolve(cli, filepath.Join(root, ".start-issue", "model"), filepath.Join(home, ".config", "start-issue", "model"), "START_ISSUE_MODEL", "") + if e == nil && s != "built-in default" && v == "" { + e = errors.New("Model config is empty. Remove the empty model config or set a value.") + } + return v, s, e +} +func resolve(cli, project, user, env, def string) (string, string, error) { + if cli != "" { + return cli, "CLI", nil + } + for _, p := range []string{project, user} { + if b, e := os.ReadFile(p); e == nil { + return first(string(b)), p, nil + } else if !os.IsNotExist(e) { + return "", "", e + } + } + if v := strings.TrimSpace(os.Getenv(env)); v != "" { + return v, env, nil + } + return def, "built-in default", nil +} +func first(s string) string { + for _, l := range strings.Split(s, "\n") { + if l = strings.TrimSpace(strings.SplitN(l, "#", 2)[0]); l != "" { + return l + } + } + return "" +} +func resolvePrompt(root, agent string, o options) (string, string, error) { + if o.prompt != "" { + return o.prompt, "CLI --prompt", nil + } + if o.promptFile != "" { + b, e := os.ReadFile(o.promptFile) + return string(b), "CLI --prompt-file: " + o.promptFile, e + } + if agent == "claude" { + if o.command != "" { + return o.command + " {ISSUE_URL}", "built-in Claude command", nil + } + return "/task-router:route-task {ISSUE_URL}", "built-in Claude command", nil + } + return "Implement GitHub issue {ISSUE_URL} in this worktree.", "built-in portable prompt", nil +} +func parseIssue(v, repo string) (string, string, error) { + re := regexp.MustCompile(`^https://github\.com/([^/]+)/([^/]+)/issues/([0-9]+)`) + if m := re.FindStringSubmatch(v); m != nil { + return m[3], m[1] + "/" + m[2], nil + } + if regexp.MustCompile(`^[0-9]+$`).MatchString(v) { + return v, repo, nil + } + return "", "", fmt.Errorf("Invalid issue format: %s. Use issue number or full GitHub URL.", v) +} +func detectRepo() (string, error) { + v, e := output("git", "remote", "get-url", "origin") + if e != nil { + return "", errors.New("Cannot detect repository. No 'origin' remote found. Use --repo flag.") + } + v = strings.TrimSpace(strings.TrimSuffix(v, ".git")) + for _, p := range []string{"git@github.com:", "https://github.com/"} { + if strings.HasPrefix(v, p) { + return strings.TrimPrefix(v, p), nil + } + } + return "", fmt.Errorf("Cannot parse repository from remote URL: %s. Use --repo flag.", v) +} +func detectBase() string { + if v, e := output("git", "symbolic-ref", "refs/remotes/origin/HEAD"); e == nil { + return strings.TrimPrefix(strings.TrimSpace(v), "refs/remotes/origin/") + } + v, _ := output("git", "rev-parse", "--abbrev-ref", "HEAD") + return strings.TrimSpace(v) +} +func branchName(n, title, labels string) string { + kind := "feature" + l := strings.ToLower(labels) + switch { + case containsAny(l, "hotfix", "critical", "urgent"): + kind = "hotfix" + case containsAny(l, "bug", "fix", "bugfix", "error"): + kind = "fix" + case containsAny(l, "docs", "documentation"): + kind = "docs" + case containsAny(l, "refactor", "tech-debt", "cleanup", "technical"): + kind = "refactor" + case containsAny(l, "test", "testing", "tests"): + kind = "test" + case containsAny(l, "chore", "ci", "build", "infra"): + kind = "chore" + } + slug := slugify(title) + return fmt.Sprintf("%s/issue-%s-%s", kind, n, slug) +} + +func containsAny(s string, terms ...string) bool { + for _, term := range terms { + if strings.Contains(s, term) { + return true + } + } + return false +} + +func slugify(title string) string { + title = regexp.MustCompile(`^(\[[^]]*\][\s-]*)+`).ReplaceAllString(title, "") + var b strings.Builder + for _, r := range strings.ToLower(title) { + if replacement, ok := cyrillicTransliteration[r]; ok { + b.WriteString(replacement) + continue + } + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { + b.WriteRune(r) + } else { + b.WriteByte('-') + } + } + slug := regexp.MustCompile(`-+`).ReplaceAllString(b.String(), "-") + slug = strings.Trim(slug, "-") + if slug == "" { + slug = "work" + } + if len(slug) > 40 { + slug = strings.Trim(slug[:40], "-") + } + return slug +} + +var cyrillicTransliteration = map[rune]string{ + 'а': "a", 'б': "b", 'в': "v", 'г': "g", 'д': "d", 'е': "e", 'ё': "yo", 'ж': "zh", 'з': "z", 'и': "i", 'й': "y", 'к': "k", 'л': "l", 'м': "m", 'н': "n", 'о': "o", 'п': "p", 'р': "r", 'с': "s", 'т': "t", 'у': "u", 'ф': "f", 'х': "kh", 'ц': "ts", 'ч': "ch", 'ш': "sh", 'щ': "shch", 'ъ': "", 'ы': "y", 'ь': "", 'э': "e", 'ю': "yu", 'я': "ya", +} + +func render(s string, m map[string]string) string { + for k, v := range m { + s = strings.ReplaceAll(s, "{"+k+"}", v) + } + return s +} +func printLaunch(a, m, w, p string) { + fmt.Printf(" Agent: %s\n Model: %s\n [DRY-RUN] Would run: %s\n", a, show(m), strings.Join(launchArgs(a, m, w, p), " ")) +} +func launch(a, m, w, p string) error { + if a == "none" { + fmt.Printf("✅ Worktree ready at: %s\n", w) + return nil + } + return commandAt(w, launchArgs(a, m, w, p)...) +} +func launchArgs(a, m, w, p string) []string { + switch a { + case "claude": + x := []string{"claude"} + if m != "" { + x = append(x, "--model", m) + } + return append(x, "--dangerously-skip-permissions", p) + case "codex": + x := []string{"codex"} + if m != "" { + x = append(x, "--model", m) + } + return append(x, "--cd", w, "--dangerously-bypass-approvals-and-sandbox", p) + case "kimi": + x := []string{"kimi"} + if m != "" { + x = append(x, "--model", m) + } + return append(x, "--work-dir", w, "--yolo", "-p", p) + default: + x := []string{"pi"} + if m != "" { + x = append(x, "--model", m) + } + return append(x, p) + } +} +func command(name string, args ...string) error { + c := exec.Command(name, args...) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + c.Stdin = os.Stdin + return c.Run() +} +func commandAt(dir string, args ...string) error { + c := exec.Command(args[0], args[1:]...) + c.Dir = dir + c.Stdout = os.Stdout + c.Stderr = os.Stderr + c.Stdin = os.Stdin + return c.Run() +} +func output(name string, args ...string) (string, error) { + b, e := exec.Command(name, args...).Output() + return string(b), e +} +func need(n string) error { + if _, e := exec.LookPath(n); e != nil { + return fmt.Errorf("%s not found", n) + } + return nil +} +func show(v string) string { + if v == "" { + return "" + } + return v +} +func die(e error) { fmt.Fprintln(os.Stderr, "Error:", e); os.Exit(1) } +func usage() { + fmt.Printf("start-issue v%s\n\nUsage: start-issue [options]\n", version) +} + +func humanGateHelp() { + fmt.Println("Codex human-gate mode\n\nUsage: start-issue --agent codex --human-gate\n\nThe final Codex message must start with STATUS: DONE or STATUS: HUMAN_GATE.") +} diff --git a/cmd/start-issue/main_test.go b/cmd/start-issue/main_test.go new file mode 100644 index 0000000..7d24670 --- /dev/null +++ b/cmd/start-issue/main_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "crypto/sha256" + "fmt" + "testing" +) + +func TestParseIssue(t *testing.T) { + number, repo, err := parseIssue("https://github.com/dapi/start-issue/issues/34", "") + if err != nil || number != "34" || repo != "dapi/start-issue" { + t.Fatalf("got %q %q %v", number, repo, err) + } +} + +func TestBranchName(t *testing.T) { + if got := branchName("34", "Fix broken output!", "bug"); got != "fix/issue-34-fix-broken-output" { + t.Fatalf("got %q", got) + } +} + +func TestBranchNameMatchesFastShellRules(t *testing.T) { + tests := []struct { + title, labels, want string + }{ + {"[brief] Исправить ЦАП", "urgent, bug", "hotfix/issue-34-ispravit-tsap"}, + {"Documentation tidy", "documentation", "docs/issue-34-documentation-tidy"}, + {"", "", "feature/issue-34-work"}, + } + for _, test := range tests { + if got := branchName("34", test.title, test.labels); got != test.want { + t.Errorf("branchName(%q, %q) = %q, want %q", test.title, test.labels, got, test.want) + } + } +} + +func TestRender(t *testing.T) { + if got := render("{REPO} #{ISSUE_NUMBER}", map[string]string{"REPO": "dapi/start-issue", "ISSUE_NUMBER": "34"}); got != "dapi/start-issue #34" { + t.Fatalf("got %q", got) + } +} + +func TestReleaseAssetName(t *testing.T) { + if got := releaseAssetName("darwin", "arm64"); got != "start-issue-darwin-arm64" { + t.Fatalf("got %q", got) + } + if got := releaseAssetName("windows", "amd64"); got != "start-issue-windows-amd64.exe" { + t.Fatalf("got %q", got) + } +} + +func TestValidChecksum(t *testing.T) { + binary := []byte("start-issue") + checksum := sha256.Sum256(binary) + manifest := fmt.Sprintf("%x start-issue-darwin-arm64\n", checksum) + if !validChecksum(binary, "start-issue-darwin-arm64", manifest) { + t.Fatal("expected checksum to match") + } + if validChecksum(binary, "start-issue-linux-amd64", manifest) { + t.Fatal("unexpected checksum match") + } +} + +func TestCompareVersions(t *testing.T) { + if compareVersions("v1.2.0", "1.2.0") != 0 || compareVersions("1.2.1", "1.2.0") <= 0 || compareVersions("1.1.9", "v1.2.0") >= 0 { + t.Fatal("unexpected version ordering") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..299c68f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/dapi/start-issue + +go 1.21 diff --git a/install.sh b/install.sh deleted file mode 100755 index cab18ee..0000000 --- a/install.sh +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -REPO="${START_ISSUE_REPOSITORY:-dapi/start-issue}" -PREFIX="${PREFIX:-$HOME/.local}" -BINDIR="${BINDIR:-$PREFIX/bin}" -TARGET="${TARGET:-$BINDIR/start-issue}" -ASSET_URL="${START_ISSUE_ASSET_URL:-https://github.com/$REPO/releases/latest/download/start-issue}" -CHECKSUM_URL="${START_ISSUE_CHECKSUM_URL:-https://github.com/$REPO/releases/latest/download/start-issue.sha256}" -DEBUG=0 - -log() { - printf '%s\n' "$1" -} - -debug() { - if [[ "$DEBUG" -eq 1 ]]; then - printf 'DEBUG: %s\n' "$1" >&2 - fi -} - -die() { - printf 'Error: %s\n' "$1" >&2 - exit 1 -} - -# This script is deliberately self-contained: the documented installation -# command pipes it to Bash, where no repository directory or sibling modules -# are available. -release_fetch() { - local url="$1" - local output="$2" - - if command -v curl >/dev/null 2>&1; then - if [[ "${RELEASE_FETCH_VERBOSE:-0}" == "1" ]]; then - curl -fL -v "$url" -o "$output" - else - curl -fsSL "$url" -o "$output" - fi - return - fi - - if command -v wget >/dev/null 2>&1; then - if [[ "${RELEASE_FETCH_VERBOSE:-0}" == "1" ]]; then - wget -O "$output" "$url" - else - wget -qO "$output" "$url" - fi - return - fi - - die "Neither curl nor wget is installed." -} - -release_sha256_file() { - local path="$1" - - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$path" | awk '{ print $1 }' - return - fi - - if command -v shasum >/dev/null 2>&1; then - shasum -a 256 "$path" | awk '{ print $1 }' - return - fi - - if command -v openssl >/dev/null 2>&1; then - openssl dgst -sha256 "$path" | awk '{ print $NF }' - return - fi - - die "No SHA-256 tool found. Install sha256sum, shasum, or openssl." -} - -release_install_verified_asset() { - local asset_url="$1" - local checksum_url="$2" - local target_path="$3" - local tmpdir - local tmpfile - local checksum_file - local expected_checksum - local actual_checksum - local cleanup_cmd - - tmpdir="$(mktemp -d)" - printf -v cleanup_cmd 'rm -rf %q' "$tmpdir" - # shellcheck disable=SC2064 - trap "$cleanup_cmd" RETURN - - tmpfile="$tmpdir/start-issue" - checksum_file="$tmpdir/start-issue.sha256" - - if declare -F debug >/dev/null 2>&1; then - debug "Fetching $asset_url -> $tmpfile" - fi - release_fetch "$asset_url" "$tmpfile" || die "Failed to download release asset: $asset_url" - if declare -F debug >/dev/null 2>&1; then - debug "Fetching $checksum_url -> $checksum_file" - fi - release_fetch "$checksum_url" "$checksum_file" || die "Failed to download release checksum: $checksum_url" - - if declare -F debug >/dev/null 2>&1; then - debug "Verifying checksum" - fi - expected_checksum="$(awk '{ print $1; exit }' "$checksum_file")" - actual_checksum="$(release_sha256_file "$tmpfile")" - - if [[ -z "$expected_checksum" ]]; then - die "Downloaded checksum file is empty." - fi - - if [[ "$expected_checksum" != "$actual_checksum" ]]; then - die "Checksum verification failed." - fi - - if declare -F debug >/dev/null 2>&1; then - debug "Installing binary into $target_path" - fi - mkdir -p "$(dirname "$target_path")" - install -m 0755 "$tmpfile" "$target_path" || die "Failed to install updated release to $target_path" -} - -usage() { - cat <<'EOF' -Usage: install.sh [--debug] - -Options: - --debug Enable verbose installer diagnostics. - --help Show this help. -EOF -} - -parse_args() { - while [[ $# -gt 0 ]]; do - case "$1" in - --debug) - DEBUG=1 - ;; - --help|-h) - usage - exit 0 - ;; - *) - die "Unknown argument: $1" - ;; - esac - shift - done -} - -main() { - parse_args "$@" - - if [[ "$DEBUG" -eq 1 ]]; then - PS4='+ install.sh:${LINENO}: ' - set -x - export RELEASE_FETCH_VERBOSE=1 - debug "Repository: $REPO" - debug "Install target: $TARGET" - debug "Asset URL: $ASSET_URL" - debug "Checksum URL: $CHECKSUM_URL" - fi - - log "Downloading latest release from $REPO" - mkdir -p "$BINDIR" - release_install_verified_asset "$ASSET_URL" "$CHECKSUM_URL" "$TARGET" - - log "Installed: $TARGET" - log "Version: $("$TARGET" --version)" -} - -main "$@" diff --git a/memory-bank/engineering/architecture.md b/memory-bank/engineering/architecture.md index 3d93405..11a0740 100644 --- a/memory-bank/engineering/architecture.md +++ b/memory-bank/engineering/architecture.md @@ -2,7 +2,7 @@ title: Engineering Architecture Patterns doc_kind: engineering doc_function: canonical -purpose: "Architecture rules for the start-issue Bash CLI, module boundaries, adapters, failures, and configuration ownership." +purpose: "Architecture rules for the start-issue Go CLI, module boundaries, adapters, failures, and configuration ownership." derived_from: - ../dna/governance.md - ../domain/context-map.md @@ -14,29 +14,22 @@ audience: humans_and_agents # Engineering Architecture Patterns -`start-issue` is implemented as modular Bash source under -`scripts/lib/start_issue/` and distributed as a bundled single-file executable -through `scripts/build-start-issue`. +`start-issue` is a Go CLI rooted at `cmd/start-issue` and distributed as a +platform-specific compiled binary. ## Module Boundaries | Module / Layer | Owns | Must not depend on directly | | --- | --- | --- | -| `scripts/start-issue` | Entrypoint/bootstrap and version constant | Feature-specific behavior that belongs in modules | -| `cli.sh` | Argument parsing, mode flags, normalized workflow state | Agent command syntax, GitHub API calls | -| `config.sh` | Agent/model/prompt/worktree config resolution and prompt rendering | Worktree side effects, release update | -| `github.sh` | Issue parsing, repo/base detection, issue metadata fetch | Agent launch, config file writes | -| `worktree.sh` | Branch naming, worktree planning, safe create/reuse/delete, optional zellij/init side effects | Agent internals | -| `agent.sh` | Adapter validation, launch commands, AI branch naming, prompt improvement, Codex human-gate helpers | CLI precedence, GitHub issue fetching | -| `release.sh` | Download/checksum/version helpers shared by installer/update | Normal issue workflow | -| `update.sh` | Self-update mode orchestration | Git worktree or issue state | -| `init.sh` | `init`, `setup`, and first-run onboarding helpers | GitHub issue fetch, agent launch | -| `output.sh` | Help, status, dry-run, human-gate help, user-facing messages | Core side effects | -| `pipeline.sh` | Top-level orchestration order | Adapter-specific command construction | +| `cmd/start-issue` | Entrypoint, CLI parsing, orchestration, and version injection | Native GitHub or agent protocol clients | +| configuration helpers | Agent/model/prompt/worktree config resolution and prompt rendering | Worktree side effects, release update | +| repository helpers | Issue parsing, repo/base detection, issue metadata fetch, and worktree lifecycle | Agent launch, config file writes | +| agent adapter helpers | Validation and launch command construction | CLI precedence and issue fetching | +| release helpers | Asset selection, checksum verification, version comparison, and self-update | Normal issue workflow | ## Adapter Boundary -Agent-specific behavior must stay centralized in `agent.sh`: +Agent-specific behavior must stay centralized in Go adapter helpers: - supported agent validation; - launch command construction; @@ -58,7 +51,7 @@ Configuration precedence is part of the public contract: 4. environment 5. built-in default -The owner module is `config.sh`; user-facing descriptions must stay aligned in +The owner is the Go configuration layer; user-facing descriptions must stay aligned in [ops/config.md](../ops/config.md), [README.md](../../README.md), and [doc/spec.md](../../doc/spec.md). @@ -72,15 +65,7 @@ The owner module is `config.sh`; user-facing descriptions must stay aligned in directory. - Never continue after a worktree safety validation failure. -## Bash Boundary +## Process Boundary -Bash remains the core while: - -- CLI modes stay small and explicit; -- configuration remains file/env/string based; -- local tests can cover behavior deterministically; -- output is primarily human-readable. - -Reevaluate a Python core if future work requires nested configuration, richer -lifecycle commands such as `resume/list/cleanup`, structured machine-readable -output, or complex state persistence. +`git`, `gh`, and supported agent CLIs remain external processes. Go owns the +application lifecycle, filesystem operations, release verification, and tests. diff --git a/memory-bank/engineering/coding-style.md b/memory-bank/engineering/coding-style.md index 9f8bbb2..9ca3290 100644 --- a/memory-bank/engineering/coding-style.md +++ b/memory-bank/engineering/coding-style.md @@ -14,28 +14,23 @@ audience: humans_and_agents ## General Rules -- Follow the surrounding shell style before introducing a new pattern. +- Follow idiomatic Go style and run `gofmt` before committing. - Keep changes scoped to the requested behavior and touched module boundary. - Add comments only for non-obvious why/boundary conditions. - Prefer explicit user-facing errors over surprising implicit fallback. - Keep docs, help, spec, and tests aligned for public behavior changes. -## Bash Rules +## Go Rules -- Use `set -euo pipefail` semantics where already established by the script. -- Quote variable expansions unless the local pattern deliberately relies on word - splitting. -- Keep adapter-specific command syntax in `agent.sh`. -- Keep config precedence and prompt rendering in `config.sh`. -- Keep top-level ordering in `pipeline.sh`. -- Do not grow `scripts/start-issue` with feature logic; source modules should - remain the implementation home. +- Keep adapter-specific command syntax in dedicated Go helpers. +- Keep config precedence and prompt rendering separate from worktree side effects. +- Return wrapped errors at external process and filesystem boundaries. ## Tooling Contract -- Formatter: none configured; preserve existing formatting. -- Linter: `shellcheck` through `make test`. -- Test runner: Bats through `make test`. +- Formatter: `gofmt`. +- Linter: `go vet` through `make test`. +- Test runner: Go tests through `make test`. - Documentation audit: `scripts/check_memory_bank_index.py --max-depth 4`. ## Documentation Style diff --git a/memory-bank/engineering/testing-policy.md b/memory-bank/engineering/testing-policy.md index 0da6599..5f61802 100644 --- a/memory-bank/engineering/testing-policy.md +++ b/memory-bank/engineering/testing-policy.md @@ -2,7 +2,7 @@ title: Testing Policy doc_kind: engineering doc_function: canonical -purpose: "Testing policy for start-issue: required local checks, Bats coverage, release checks, memory-bank audit, and manual-only exceptions." +purpose: "Testing policy for start-issue: required Go checks, release checks, memory-bank audit, and manual-only exceptions." derived_from: - ../dna/governance.md - ../flows/feature-flow.md @@ -33,24 +33,24 @@ make test `make test` runs: -1. `bash -n scripts/start-issue` -2. `shellcheck install.sh scripts/start-issue scripts/build-start-issue scripts/bump-version scripts/prepare-release scripts/lib/start_issue/*.sh test/e2e/*.sh` -3. `python3 scripts/check_memory_bank_index.py --max-depth 4` -4. `git diff --check` -5. `bats test` +1. `gofmt` cleanliness for `cmd/` +2. `go vet ./...` +3. `go test ./...` +4. `python3 scripts/check_memory_bank_index.py --max-depth 4` +5. `git diff --check` ## Test Stack -- Shell syntax: `bash -n` -- Static analysis: `shellcheck` -- Behavior/regression tests: Bats under `test/` +- Formatting: `gofmt` +- Static analysis: `go vet` +- Behavior/regression tests: Go tests under `cmd/` and future Go packages - Opt-in real-agent E2E smoke tests: scripts under `test/e2e/`, run manually and never in CI - Memory-bank navigation: `scripts/check_memory_bank_index.py` - Whitespace/conflict-marker check: `git diff --check` ## Core Rules -- Any deterministic behavior change needs automated Bats coverage. +- Any deterministic behavior change needs automated Go test coverage. - Any public CLI contract change must update help/output assertions and docs. - Any config precedence change must test the winning source and displayed source. - Any worktree lifecycle change must test safe reuse/reject/delete behavior. @@ -65,7 +65,7 @@ make test - Legacy `feature.md` packages own their existing `SC-*`, `CHK-*`, and `EVID-*` until migrated. - `implementation-plan.md` owns concrete test commands and sequencing. -- Bats tests own executable regression behavior. +- Go tests own executable regression behavior. ## Sufficient Coverage @@ -101,8 +101,7 @@ feature plan or final handoff. After tests pass, review for shell complexity: - avoid scattered agent-specific branching; -- prefer small functions with explicit inputs over implicit global mutation where - practical within the existing Bash style; +- prefer small functions with explicit inputs over implicit global mutation; - avoid abstraction unless it removes real duplication or clarifies a boundary; - keep user-facing output stable and direct. diff --git a/memory-bank/features/FT-017/README.md b/memory-bank/features/FT-017/README.md new file mode 100644 index 0000000..e6975e0 --- /dev/null +++ b/memory-bank/features/FT-017/README.md @@ -0,0 +1,30 @@ +--- +title: "FT-017: Go parity-first migration" +doc_kind: feature +doc_function: index +purpose: "Navigation for issue #34. Read the canonical brief, selected design, decision log, then the derived execution plan." +derived_from: + - brief.md +status: active +audience: humans_and_agents +--- + +# FT-017: Go parity-first migration + +## About + +This package tracks [issue #34](https://github.com/dapi/start-issue/issues/34): rewrite the `start-issue` CLI in Go through a parity-first migration, without changing the user-facing workflow unintentionally. + +## Annotated Index + +- [brief.md](brief.md) + Read first for the canonical problem, scope, blocker, and verify contract. + +- [decision-log.md](decision-log.md) + Read for the FPF-grounded, accepted release-distribution decision. + +- [design.md](design.md) + Read for the selected Go architecture, parity oracle, distribution contract, C2 view, and rollout/backout semantics. + +- [implementation-plan.md](implementation-plan.md) + Read for grounded execution sequencing, test strategy, checkpoints, and stop conditions. diff --git a/memory-bank/features/FT-017/brief.md b/memory-bank/features/FT-017/brief.md new file mode 100644 index 0000000..6f5e684 --- /dev/null +++ b/memory-bank/features/FT-017/brief.md @@ -0,0 +1,121 @@ +--- +title: "FT-017: Rewrite start-issue CLI in Go with parity-first migration" +doc_kind: feature +doc_function: canonical +purpose: "Canonical problem-space brief for issue #34. Defines the parity-first Go migration scope and verification contract without selecting the Go implementation or release design." +derived_from: + - ../../flows/feature-flow.md + - ../../product/context.md + - ../../engineering/testing-policy.md + - ../../../README.md + - ../../../doc/spec.md + - https://github.com/dapi/start-issue/issues/34 +status: active +delivery_status: in_progress +audience: humans_and_agents +must_not_define: + - solution_space + - implementation_sequence + - release_asset_matrix +--- + +# FT-017: Rewrite start-issue CLI in Go with parity-first migration + +## What + +### Problem + +The current public `start-issue` executable is a Bash entrypoint assembled from shell modules. Issue #34 requires a Go implementation and distribution artifact while keeping the current CLI contract and workflow guarantees unless a difference is intentional and documented. The legacy runtime and Bats stack must not remain as production or test dependencies after cutover. + +### Outcome + +The selected Go implementation can replace the current default `start-issue` runtime only after deterministic compatibility checks demonstrate parity for the critical workflows named in issue #34 and any intentional differences are explicitly documented. + +| Metric ID | Metric | Baseline | Target | Measurement method | +| --- | --- | --- | --- | --- | +| `MET-01` | Critical workflow parity | Current Bash CLI and its Bats fixtures | Go is made default only after every approved parity case passes or has a documented intentional difference | Repeatable baseline-vs-Go parity suite and its report | +| `MET-02` | Verification integration | `make test` runs shell checks and Bats | `make test` includes the relevant Go and compatibility suites | Local command and CI job output | + +### Scope + +- `REQ-01` Create the Go module and make `cmd/start-issue` the sole public CLI entrypoint. +- `REQ-02` Preserve the current public CLI contract and workflow semantics for argument parsing, configuration precedence, prompt rendering, repository detection, branch/worktree lifecycle, and supported-agent launch adapters, except for differences explicitly recorded as intentional. +- `REQ-03` Continue to invoke `git`, `gh`, and supported agent CLIs as external commands during the initial migration; this feature does not replace them with native protocol or API clients. +- `REQ-04` Establish deterministic Go tests for help, invalid input, configuration precedence, dry-run output, worktree planning/reuse, and agent launch-command generation. +- `REQ-05` Replace build, install, release, documentation, and `make test` with Go-native paths and platform-specific binaries. + +### Non-Scope + +- `NS-01` Do not redesign product workflows, configuration precedence, prompts, or supported agent behavior as part of the runtime migration. +- `NS-02` Do not replace `git`, `gh`, or agent CLIs with native Go API/protocol integrations in this migration. +- `NS-04` Do not replace `git`, `gh`, or agent CLIs with native Go API/protocol integrations, even where doing so might simplify platform-specific packaging. + +### Constraints / Assumptions + +- `ASM-01` The legacy scripts and fixtures are migration references only; the delivered runtime and test stack are Go-only. +- `ASM-02` The current CI uses `mise` and runs integration tests on Ubuntu; the install-script job runs on Ubuntu and macOS. +- `CON-01` The public executable name is `start-issue`; existing `install.sh` downloads a fixed asset and validates its checksum. The selected distribution contract must keep installation integrity verification. +- `CON-02` The current release workflow builds its only release artifact on Ubuntu, while the installed artifact is verified on both Ubuntu and macOS by CI. `DL-01` supersedes this single-asset layout for the Go release. +- `CON-03` Product constraint `PCON-01` requires the public CLI contract to stay stable unless this feature changes it explicitly with synchronized docs and tests. +- No unresolved blocking decisions remain. The accepted release-distribution decision is `DL-01` in `decision-log.md`. + +## Design Requirement Decision + +| Decision | Reason | Downstream owner | +| --- | --- | --- | +| `Design required: yes` | The feature changes the implementation/runtime and release artifact boundary, requires a parity oracle, and has explicit cutover/backout semantics. | `design.md` | + +## Verify + +### Exit Criteria + +- `EC-01` The Go CLI is the default runtime and its deterministic tests cover all in-scope critical workflows. +- `EC-02` The Go implementation continues to use external `git`, `gh`, and supported-agent CLIs in the first migration phase. +- `EC-03` `make test` and CI run the required Go and compatibility suites after cutover work is introduced. +- `EC-04` Build, install, update, release, and documentation follow the human-approved distribution contract and publish the Go binary successfully. + +### Traceability Matrix + +| Requirement ID | Problem refs | Acceptance refs | Checks | Evidence IDs | +| --- | --- | --- | --- | --- | +| `REQ-01` | `ASM-01`, `CON-03`, `DL-01` | `EC-01` | `CHK-01`, `CHK-02` | `EVID-01`, `EVID-02` | +| `REQ-02` | `ASM-01`, `CON-03` | `EC-01` | `CHK-01` | `EVID-01` | +| `REQ-03` | issue #34 | `EC-02` | `CHK-01` | `EVID-01` | +| `REQ-04` | `ASM-01` | `EC-01` | `CHK-01` | `EVID-01` | +| `REQ-05` | `CON-01`, `CON-02`, `DL-01` | `EC-03`, `EC-04` | `CHK-02`, `CHK-03` | `EVID-02`, `EVID-03` | + +### Acceptance Scenarios + +- `SC-01` Given deterministic Go test fixtures for help, invalid input, configuration precedence, dry-run output, worktree planning/reuse, or an agent launch command, when the Go CLI runs in the fake environment, then it produces the documented result. +- `SC-02` Given a build/install/release change, when it is merged, then the Go binary remains the default `start-issue` runtime. +- `SC-03` Given a user installs or updates `start-issue` after cutover, when the approved target platform is used, then build, release asset selection, checksum verification, installation, and `--version` complete according to the approved distribution contract. + +### Checks + +| Check ID | Covers | How to check | Expected result | Evidence path | +| --- | --- | --- | --- | --- | +| `CHK-01` | `EC-01`, `EC-02`, `SC-01` | Run the implemented baseline-vs-Go deterministic parity suite | All in-scope cases match, or each deviation links to an approved intentional-difference record | `artifacts/ft-016/verify/parity/` | +| `CHK-02` | `EC-01`, `EC-03`, `SC-02` | Run `make test` after Go and parity integration | Required local checks, Go tests, and compatibility suite pass | `artifacts/ft-016/verify/make-test/` | +| `CHK-03` | `EC-04`, `SC-03` | Run the approved release/install verification matrix in CI | Linux/macOS automatically select the correct artifact, verify checksum, and report a version; Windows binary execution and its documented manual install/update instruction pass | `artifacts/ft-016/verify/distribution/` | + +### Test Matrix + +| Check ID | Evidence IDs | Evidence path | +| --- | --- | --- | +| `CHK-01` | `EVID-01` | `artifacts/ft-016/verify/parity/` | +| `CHK-02` | `EVID-02` | `artifacts/ft-016/verify/make-test/` | +| `CHK-03` | `EVID-03` | `artifacts/ft-016/verify/distribution/` | + +### Evidence + +- `EVID-01` Machine-readable or reviewable parity report for the baseline and Go runs, including any linked intentional differences. +- `EVID-02` Local and CI output for `make test` after Go and compatibility-suite integration. +- `EVID-03` CI evidence for the approved build/install/release platform matrix, asset selection, checksum verification, and version output. + +### Evidence Contract + +| Evidence ID | Artifact | Producer | Path contract | Reused by checks | +| --- | --- | --- | --- | --- | +| `EVID-01` | Parity-suite report and fixtures/results | parity test runner | `artifacts/ft-016/verify/parity/` | `CHK-01` | +| `EVID-02` | `make test` logs and CI job link | implementer / CI | `artifacts/ft-016/verify/make-test/` | `CHK-02` | +| `EVID-03` | Platform-matrix install/release logs and published asset manifest | CI / release workflow | `artifacts/ft-016/verify/distribution/` | `CHK-03` | diff --git a/memory-bank/features/FT-017/decision-log.md b/memory-bank/features/FT-017/decision-log.md new file mode 100644 index 0000000..e258d0e --- /dev/null +++ b/memory-bank/features/FT-017/decision-log.md @@ -0,0 +1,81 @@ +--- +title: "FT-017: Decision Log" +doc_kind: feature-support +doc_function: reference +purpose: "Records FPF analysis and accepted local decisions for FT-017. It does not own feature scope, selected design, acceptance criteria, or execution sequence." +derived_from: + - brief.md + - ../../../.github/workflows/ci.yml + - ../../../.github/workflows/release.yml + - ../../../install.sh +status: active +audience: humans_and_agents +must_not_define: + - ft_016_scope + - ft_016_selected_design + - ft_016_acceptance_criteria + - implementation_sequence +--- + +# FT-017: Decision Log + +## Purpose and Ownership + +This log records why `DEC-01` remains open. The canonical owner of the blocker and the verify contract is [brief.md](brief.md). A selected solution belongs in a future `design.md`, not here. + +## DL-01 — Multi-platform Go release distribution contract + +**Status:** accepted on 2026-07-22 by feature requester. + +### FPF framing + +- **Bounded context:** distribution is separate from CLI-semantic parity. It owns the relationship among a compiled artifact, release assets, installer/update selection, and the platform on which a user executes the artifact. +- **Evidence boundary:** facts below come only from the current repository and issue #34. The issue requests a Go binary but defines neither supported OS/architecture targets nor asset-selection rules. +- **Decision criterion:** provide Go releases for the requester-selected operating systems with explicit platform assets, verifiable integrity, and no inferred reduction of platform support. + +### Available facts + +1. `install.sh` downloads one fixed asset named `start-issue` and one fixed checksum file named `start-issue.sha256`. +2. `.github/workflows/release.yml` builds the current sole release asset on `ubuntu-latest`. +3. `.github/workflows/ci.yml` verifies installation on both `ubuntu-latest` and `macos-latest`. +4. A Bash release artifact is portable across those CI operating systems; a Go executable is platform-specific. +5. Issue #34 requires Go to become the primary distribution artifact and requires installation/release workflows to publish it successfully, but does not state the intended OS/architecture matrix or compatibility policy. + +### Decision + +| Area | Accepted contract | +| --- | --- | +| Target matrix | `linux/amd64`, `linux/arm64`, `darwin/amd64`, `darwin/arm64`, and `windows/amd64`. The operating systems are requester-selected; the architecture set follows the explicit `dapi/port-selector` release pattern. | +| Build/release | Use GoReleaser v2 with `CGO_ENABLED=0`, one statically built executable per target, `start-issue--` asset names, and a SHA-256 `checksums.txt` manifest. | +| POSIX install | Adapt the referenced install-script strategy: detect `uname -s`/`uname -m`, select the matching asset, download it, verify its checksum from `checksums.txt`, and install it under the public name `start-issue`. | +| Windows delivery | Publish `start-issue-windows-amd64.exe` as a first-class release asset and document manual download/PATH installation. The existing POSIX shell installer is not a Windows installer. | +| Cutover | No separate human release-approval gate. The normal tag-triggered release proceeds only after `CHK-01` through `CHK-03` are green. | + +### Resolution rationale + +The requester directly chose macOS, Linux, and Windows and delegated release-strategy selection to this feature. The selected GoReleaser layout and target architecture set are grounded in the referenced `dapi/port-selector` repository: its `.goreleaser.yml` uses the exact five targets, `CGO_ENABLED=0`, binary-format archives, and `checksums.txt`; its installer performs POSIX OS/architecture detection. The decision preserves explicit asset integrity while avoiding a false claim that the POSIX installer supports Windows. + +### Rejected alternatives + +- A single cross-platform `start-issue` Go asset is rejected: compiled Go executables are platform-specific. +- A narrower target matrix is rejected: the requester selected all three operating systems and the referenced strategy supplies the matching explicit matrix. +- A release approval gate is rejected: the requester explicitly said it is unnecessary; automated evidence gates remain mandatory. + +## DL-02 — Go toolchain and Windows update boundary + +**Status:** accepted on 2026-07-22 by feature owner under delegated release-strategy choice. + +### FPF framing and facts + +- The toolchain is an execution-environment contract, not a user-facing CLI capability; it must be deterministic in local, CI, and release paths. +- The referenced `dapi/port-selector` release pattern pins `go 1.21` in `go.mod` and GitHub Actions. This repository currently has no Go toolchain contract. +- A POSIX process can replace its executable through the existing install/update style; Windows generally locks a running executable. The referenced release strategy documents a Windows binary download rather than a shell installer. + +### Decision + +1. Pin Go `1.21` in `go.mod`, `mise.toml`, and CI/release setup for this migration. +2. The initial Windows contract is binary release plus manual installation and manual update: `start-issue update` on Windows must not try to overwrite its running `.exe`; it returns a clear instruction naming the matching release asset. POSIX retains verified automatic install/update behavior. + +### Rationale and risk control + +Go 1.21 is the explicit baseline in the chosen release reference and yields a single reproducible toolchain contract. The Windows manual-update behavior avoids an unsafe or undeclared helper-process design. It is a documented platform-specific delivery difference, not a hidden parity exception, because the Bash baseline has no Windows runtime contract. diff --git a/memory-bank/features/FT-017/design.md b/memory-bank/features/FT-017/design.md new file mode 100644 index 0000000..b25dcf0 --- /dev/null +++ b/memory-bank/features/FT-017/design.md @@ -0,0 +1,126 @@ +--- +title: "FT-017: Design" +doc_kind: feature +doc_function: canonical +purpose: "Solution-space document for FT-017. Defines the parity-first Go implementation, platform distribution contract, and cutover/backout semantics without redefining feature scope or acceptance." +derived_from: + - brief.md + - decision-log.md + - ../../../README.md + - ../../../doc/spec.md +status: active +audience: humans_and_agents +must_not_define: + - ft_016_scope + - ft_016_acceptance_criteria + - ft_016_evidence_contract + - implementation_sequence +--- + +# FT-017: Design + +## Design Pack + +| Artifact | Role | Owns | +| --- | --- | --- | +| `design.md` | Feature-local solution owner | `SOL-*`, `C4-*`, `SD-*`, `CTR-*`, `INV-*`, `FM-*`, `RB-*` | +| `decision-log.md` | Decision reference | `DL-01` distribution decision and `DL-02` toolchain/Windows-update boundary | + +## Context + +The former Bash implementation was modularized by responsibility but bundled into a single script. The Go migration preserves its documented behavior while replacing the runtime, test stack, and release-artifact boundary. The selected distribution contract is `DL-01`. + +## C4 Applicability + +| C4 ID | Decision | Trigger / reason | Artifact | +| --- | --- | --- | --- | +| `C4-02` | `C2` | Go replaces the executable runtime and introduces target-specific release artifacts, installer asset selection, and updater downloads across the GitHub Release boundary. | C2 view below | + +### C4 Artifact + +```mermaid +flowchart LR + User[Developer] --> CLI["start-issue Go CLI\nplatform binary"] + CLI --> Git[git CLI] + CLI --> GH[gh CLI] + CLI --> Agent[Supported agent CLI] + CLI --> Release[GitHub Releases\nplatform assets + checksums.txt] + Installer[POSIX install.sh] --> Release + Installer --> CLI +``` + +`C4-02` covers the changed runtime/distribution boundary. `git`, `gh`, and agent CLIs remain external processes in the initial migration (`REQ-03`). + +## Selected Solution + +- `SOL-01` Create module `github.com/dapi/start-issue` with Go `1.21` and public command entrypoint `cmd/start-issue`; separate Go packages by the existing responsibility boundaries: CLI/input, config/prompt, repository/GitHub, worktree, agent adapters, update/release, output, and orchestration. +- `SOL-02` Use Go standard process execution and filesystem APIs to preserve the shell-oriented integrations with `git`, `gh`, and agent CLIs; do not introduce native GitHub or agent API clients. +- `SOL-03` Port deterministic regression coverage to Go tests using fake command boundaries. Cover the documented observable behavior without retaining a Bash or Bats runtime dependency. +- `SOL-04` Make Go the default build/install/release runtime and remove the Bash runtime and Bats suite. +- `SOL-05` Use GoReleaser v2 under `DL-01` to publish `start-issue-linux-amd64`, `start-issue-linux-arm64`, `start-issue-darwin-amd64`, `start-issue-darwin-arm64`, and `start-issue-windows-amd64.exe`, plus SHA-256 `checksums.txt`. +- `SOL-06` Adapt `install.sh` for POSIX platform detection and checksum verification; document Windows manual asset/PATH installation. On Linux/macOS, self-update resolves and verifies the named asset before replacement; on Windows it prints the matching manual-update instruction instead of overwriting the running `.exe`. + +## Alternatives Considered + +| Alternative ID | Option | Why not selected | +| --- | --- | --- | +| `ALT-01` | Rewrite the CLI and replace Bash immediately | Contradicts `NS-03` and leaves no executable parity oracle. | +| `ALT-02` | Native Go GitHub/agent integrations | Contradicts `REQ-03` and broadens the migration beyond parity. | +| `ALT-03` | One release asset for all Go platforms | Impossible for compiled platform-specific executables; rejected by `DL-01`. | + +## Trade-offs + +| Trade-off ID | Decision | Benefit | Cost / Risk | +| --- | --- | --- | --- | +| `TRD-01` | Retain Bash during migration | A concrete parity oracle and reversible cutover | Temporary duplicate implementation and test maintenance | +| `TRD-02` | Publish five Go assets | Explicit coverage for the selected platforms | Installer/update and CI matrix become more complex | + +## Accepted Local Decisions + +- `SD-01` The Go package boundaries mirror existing Bash responsibility boundaries, but are not required to reproduce shell-file structure one-for-one. +- `SD-02` Parity evaluates observable behavior, not internal command implementation or byte-for-byte formatting where outputs contain nondeterministic paths/timestamps; normalizers must be case-local and documented. +- `SD-03` An intentional difference is valid only when it has a stable case ID, user-visible rationale, acceptance approval in this feature package, and a corresponding parity expectation; an unexplained mismatch fails `CHK-01`. +- `SD-04` Windows is a first-class binary release target, while manual download/PATH setup is the initial installation path; POSIX `install.sh` is not represented as Windows support. +- `SD-05` Go `1.21` is the fixed local/CI/release toolchain baseline for this feature, following the selected reference release strategy. +- `SD-06` Windows `update` is an explicit manual-update path in the first release rather than a self-replacing executable path. + +## Contracts + +| Contract ID | Input / Output | Producer / Consumer | Semantics / Constraints | +| --- | --- | --- | --- | +| `CTR-01` | Same deterministic case input → baseline and Go results | parity harness / reviewer | Compare exit code, normalized output, fake CLI log, and planned filesystem state; each difference must map to an approved case expectation. | +| `CTR-02` | CLI invocation → external process commands | Go adapter / `git`, `gh`, agent CLI | Preserve existing command semantics, working directory, input flow, and failure handling covered by parity cases. | +| `CTR-03` | OS/arch → named asset + checksum or manual instruction | installer/updater / GitHub Release | POSIX installer/updater select only `DL-01` targets, verify SHA-256 from `checksums.txt`, and reject unsupported platforms before replacement. Windows selects `start-issue-windows-amd64.exe` only for the manual install/update instruction. | +| `CTR-04` | tag/version → build metadata | GoReleaser / `start-issue --version` | Embed the release version so the existing version output contract remains verifiable after installation/update. | + +## Invariants + +- `INV-01` The public executable and regression test stack contain no repository-owned Bash runtime or Bats dependency. +- `INV-02` No parity mismatch can be hidden by a normalizer, broad snapshot rewrite, or undocumented tolerance. +- `INV-03` Release asset selection and checksum verification occur before an installer or updater replaces an executable. +- `INV-04` `git`, `gh`, and agent CLIs remain process boundaries in this migration. + +## Failure Modes + +- `FM-01` A behavior mismatch is mistaken for parity because a fixture lacks the relevant side effect or normalizes meaningful output. +- `FM-02` A POSIX installer/updater fetches a valid checksum for the wrong platform asset or attempts an unsupported OS/architecture. +- `FM-03` Go becomes the default distribution before parity and compatibility checks are green. +- `FM-04` Windows is advertised as automatic installer/updater-supported even though its initial contract is binary/manual setup and update. + +## Rollout / Backout + +| Stage ID | Stage | Entry condition | Backout | +| --- | --- | --- | --- | +| `RB-01` | Parallel implementation | Go command and parity harness are present; Bash remains default | Keep/revert to Bash-only build and distribution; preserve cases as baseline evidence. | +| `RB-02` | Default-runtime cutover | `CHK-01` and `CHK-02` pass; Go is built by `make build` and selected by install/release paths | Restore Bash build/install/release paths and keep the Go code and failing evidence for correction. | +| `RB-03` | Multi-platform release | `CHK-03` passes for every `DL-01` target | Do not publish the tag/release; retain the prior published release. | + +## Traceability + +| Requirement ID | Solution refs | Contracts / invariants | Failure / rollout refs | +| --- | --- | --- | --- | +| `REQ-01` | `SOL-01`, `SOL-04` | `CTR-01`, `INV-01` | `FM-01`, `FM-03`, `RB-01`, `RB-02` | +| `REQ-02` | `SOL-01`, `SOL-03`, `SOL-04` | `CTR-01`, `INV-02` | `FM-01`, `FM-03`, `RB-01`, `RB-02` | +| `REQ-03` | `SOL-02` | `CTR-02`, `INV-04` | `FM-01` | +| `REQ-04` | `SOL-03` | `CTR-01`, `INV-02` | `FM-01`, `RB-01` | +| `REQ-05` | `SOL-05`, `SOL-06` | `CTR-03`, `CTR-04`, `INV-03` | `FM-02`, `FM-04`, `RB-02`, `RB-03` | diff --git a/memory-bank/features/FT-017/implementation-plan.md b/memory-bank/features/FT-017/implementation-plan.md new file mode 100644 index 0000000..a071a29 --- /dev/null +++ b/memory-bank/features/FT-017/implementation-plan.md @@ -0,0 +1,137 @@ +--- +title: "FT-017: Implementation Plan" +doc_kind: feature +doc_function: derived +purpose: "Execution plan for FT-017. Sequences discovery, parity, Go migration, and distribution verification without redefining canonical feature or solution facts." +derived_from: + - brief.md + - design.md + - decision-log.md + - ../../../memory-bank/engineering/testing-policy.md +status: active +audience: humans_and_agents +must_not_define: + - ft_016_scope + - ft_016_selected_design + - ft_016_acceptance_criteria + - ft_016_blocker_state +--- + +# FT-017: Implementation Plan + +## Goal + +Deliver the Go `start-issue` runtime only after the Bash baseline and Go implementation satisfy the canonical parity and distribution evidence contract in `brief.md`. + +## Grounding / Support References + +| Document | Role in this plan | Facts reused | Conflict action | +| --- | --- | --- | --- | +| `brief.md` | Canonical problem/verify owner | `REQ-*`, `SC-*`, `CHK-*`, `EVID-*` | Update `brief.md` first | +| `design.md` | Canonical solution owner | `SOL-*`, `C4-02`, `SD-*`, `CTR-*`, `INV-*`, `FM-*`, `RB-*` | Update `design.md` first | +| `decision-log.md` | Decision reference | `DL-01`, `DL-02` | Update the log and design before the plan | + +## Current State / Reference Points + +| Path / module | Current role | Why relevant | Reuse / mirror | +| --- | --- | --- | --- | +| `scripts/start-issue` | Bash executable entrypoint and version owner | Baseline CLI behavior and bundled-script marker | Black-box baseline only; do not change during `RB-01` | +| `scripts/lib/start_issue/{cli,config,github,worktree,agent,init,update,release,output,pipeline}.sh` | Existing responsibility boundaries | Grounding for `SOL-01` and parity case inventory | Mirror responsibility, not shell syntax | +| `test/start_issue.bats`, `test/fixtures/issue-1.json`, `test/helpers/fake-bin/` | Deterministic regression environment | Grounding for `SOL-03` | Reuse fixtures/fakes; add case-specific assertions rather than a second fake ecosystem | +| `Makefile`, `install.sh`, `.github/workflows/{ci,release}.yml` | Build/install/release paths | Change surface for `SOL-05`/`SOL-06` | Replace only at `RB-02`; preserve existing validation until then | +| `dapi/port-selector` `.goreleaser.yml` and `install.sh` | Referenced release pattern | `DL-01` release asset/checksum selection | Adapt names and verification; do not copy unrelated Homebrew policy | + +## Test Strategy + +| Test surface | Canonical refs | Existing coverage | Planned automated coverage | Required local suites / commands | Required CI suites / jobs | Manual-only gap / justification | Approval ref | +| --- | --- | --- | --- | --- | --- | --- | --- | +| Baseline/Go CLI behavior | `REQ-01`–`REQ-04`, `SC-01`, `CTR-01` | Bats and fake CLIs cover current shell behavior | Fixture-driven parity cases plus Go unit/integration tests | `go test ./...`; parity command; `make test` | Go test/parity job | none | none | +| Build and static quality | `REQ-01`, `REQ-05`, `CTR-04` | shell syntax/shellcheck | `go vet ./...`, `gofmt` check, Go build with version injection | `go vet ./...`; `gofmt -l .`; build command | CI Go quality/build job | none | none | +| POSIX install/update | `REQ-05`, `SC-03`, `CTR-03` | macOS/Ubuntu install-script CI and shell test | asset selection, checksum verification, installed `--version`, updater replacement/failure tests | `make test`; targeted install/update tests | Ubuntu and macOS install matrix | Live published-release download only after tagged release; local assets/mock release endpoint cover deterministic paths | none | +| Windows release/update | `REQ-05`, `SC-03`, `CTR-03`, `SD-04`, `SD-06` | No Windows baseline runtime | GoReleaser target-manifest check, Windows binary `--version`, and deterministic assertion that `update` prints manual asset instruction | `goreleaser check`; Windows cross-build | Windows CI job | Manual PATH installation is the accepted delivery contract; it must be documented and the binary must execute in Windows CI | none | + +## Open Questions / Ambiguities + +None. `DEC-01` was resolved as `DL-01`; implementation discoveries that change scope, design, or evidence must be promoted to their canonical owner before work continues. + +## Environment Contract + +| Area | Contract | Used by | Failure symptom | +| --- | --- | --- | --- | +| Go toolchain | Use pinned Go `1.21` in `go.mod`, `mise.toml`, and CI/release jobs. | `STEP-01`–`STEP-06` | Inconsistent build/test behavior across local and CI environments | +| Test | `make test` remains the repository gate and is extended to invoke the required Go and parity suites. | `STEP-02`, `STEP-06` | A green partial suite lacks feature acceptance evidence | +| External command fakes | Deterministic parity runs place existing fake `git`/`gh`/agent surfaces first on `PATH` and isolate temp worktrees. | `STEP-02`–`STEP-05` | A test calls real network/agent tools or leaks filesystem state | +| Release | GoReleaser v2 has tag metadata and GitHub token access in tag CI; local work uses check/snapshot only. | `STEP-06` | An unverified local release is mistaken for publish evidence | + +## Preconditions + +| Precondition ID | Canonical ref | Required state | Used by steps | Blocks start | +| --- | --- | --- | --- | --- | +| `PRE-01` | `DL-01`, `DL-02`, `SD-04`, `SD-06` | Target matrix, Go 1.21, and Windows manual-install/update scope accepted | `STEP-01`, `STEP-06` | yes | +| `PRE-02` | `INV-01`, `CTR-01` | Bash baseline and deterministic fixtures remain runnable | `STEP-02`–`STEP-05` | yes | + +## Workstreams + +| Workstream | Implements | Result | Owner | Dependencies | +| --- | --- | --- | --- | --- | +| `WS-01` | `REQ-01`, `REQ-04`, `SOL-03` | Frozen case inventory and executable parity harness | agent | `PRE-02` | +| `WS-02` | `REQ-01`–`REQ-03`, `SOL-01`, `SOL-02` | Go CLI behavior ported in dependency order | agent | `WS-01` checkpoints | +| `WS-03` | `REQ-05`, `SOL-05`, `SOL-06` | Multi-platform build, POSIX installer/updater, Windows manual-update output, docs, and CI/release configuration | agent | `PRE-01`, parity checkpoint | +| `WS-04` | `REQ-01`–`REQ-05`, `RB-02`, `RB-03` | Cutover evidence and release-ready verification | agent | `WS-02`, `WS-03` | + +## Approval Gates + +No human approval gates are required. Publishing remains protected by the automated `CHK-01`–`CHK-03` evidence gates and the repository's normal tag/release permissions. + +## Execution Order + +| Step ID | Actor | Implements | Goal | Touchpoints | Artifact | Verifies | Evidence IDs | Check procedure | Blocked by | Needs approval | Escalate if | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `STEP-01` | agent | `REQ-01`, `SOL-01`, `SOL-05` | Scaffold module, command entrypoint, pinned Go toolchain, and GoReleaser config without changing default runtime | `go.mod`, `cmd/`, tooling, `.goreleaser.yml` | Buildable parallel command and release manifest | `CHK-02` | `EVID-02` | Go build/vet/format and GoReleaser config check | `PRE-01` | none | Platform targets diverge from `DL-01` | +| `STEP-02` | agent | `REQ-04`, `SOL-03`, `CTR-01` | Convert existing critical deterministic behaviors into named baseline-vs-Go parity cases before porting their implementation | `test/`, fixtures, fakes, Go test packages | Parity inventory and runner | `CHK-01` | `EVID-01` | Run baseline and placeholder/implemented Go cases under same fake environment | `PRE-02` | none | A case needs real network or undefined observable expectations | +| `STEP-03` | agent | `REQ-02`, `REQ-03`, `SOL-01`, `SOL-02` | Port pure CLI/config/prompt behavior and invalid-input/help cases | Go CLI/config packages, parity cases | Matching pure behavior | `CHK-01` | `EVID-01` | Go tests plus parity cases | `STEP-02` | none | Mismatch changes canonical CLI contract | +| `STEP-04` | agent | `REQ-02`, `REQ-03`, `SOL-01`, `SOL-02` | Port repository detection, issue retrieval, branch/worktree planning/reuse, and init behavior | Go repository/worktree/GitHub packages, fakes | Matching orchestration behavior | `CHK-01` | `EVID-01` | Isolated worktree parity cases | `STEP-02` | none | Unsafe worktree reuse violates `PCON-02` | +| `STEP-05` | agent | `REQ-02`, `REQ-03`, `SOL-01`, `SOL-02` | Port agent adapters, prompt improvement, human-gate, launch commands, and update logic using external processes | Go agent/update packages, fakes | Matching launch/update behavior | `CHK-01` | `EVID-01` | Adapter/update parity and failure cases | `STEP-03`, `STEP-04` | none | A native client or unexplained difference appears necessary | +| `STEP-06` | agent | `REQ-05`, `SOL-04`–`SOL-06`, `CTR-03`, `CTR-04` | Integrate Go into Makefile, installer/updater, CI/release, and docs; cut over only after parity is green | `Makefile`, `install.sh`, workflows, README/spec | Five-asset release-ready path and Go-default distribution | `CHK-02`, `CHK-03` | `EVID-02`, `EVID-03` | Full `make test`, GoReleaser snapshot/check, platform CI matrix | `STEP-01`–`STEP-05` | none | Any required check fails or asset/checksum selection is ambiguous | + +## Parallelizable Work + +- `PAR-01` `STEP-01` scaffolding and case-inventory design can proceed in parallel while the Bash runtime remains unchanged. +- `PAR-02` After `STEP-02`, pure config/prompt cases (`STEP-03`) may progress independently of repository/worktree cases (`STEP-04`). +- `PAR-03` Distribution configuration drafting may begin after `STEP-01`, but `RB-02` integration in `STEP-06` must wait for all parity checkpoints. + +## Checkpoints + +| Checkpoint ID | Refs | Condition | Evidence IDs | +| --- | --- | --- | --- | +| `CP-01` | `STEP-02`, `CTR-01`, `INV-02` | Every issue-required critical workflow has a named deterministic parity case and no unexplained normalization | `EVID-01` | +| `CP-02` | `STEP-03`–`STEP-05`, `SC-01`, `RB-02` | All implemented Go cases pass against the Bash baseline or carry an approved intentional difference | `EVID-01` | +| `CP-03` | `STEP-06`, `CHK-02`, `CHK-03`, `RB-03` | Go-default build/install/release path passes local and CI evidence for every `DL-01` target | `EVID-02`, `EVID-03` | + +## Execution Risks + +| Risk ID | Risk | Impact | Mitigation | Trigger | +| --- | --- | --- | --- | --- | +| `ER-01` | Baseline behavior is underspecified by existing tests | False parity claim | Expand cases before porting each surface; require observable side effects | A Go behavior cannot be compared deterministically | +| `ER-02` | Worktree or update path has destructive behavior | User repository/executable could be damaged | Isolated temp fixtures, explicit failure tests, and `STOP-01` | A test reaches a real worktree or install target | +| `ER-03` | Platform asset selection fails | Install/update failure on supported platform | Test mapping and checksums on every target; reject unknown mapping | Missing or mismatched target asset | + +## Stop Conditions / Fallback + +| Stop ID | Related refs | Trigger | Immediate action | Safe fallback state | +| --- | --- | --- | --- | --- | +| `STOP-01` | `INV-01`, `FM-01`, `FM-03` | Unexplained parity failure or unsafe side effect | Stop the affected port, preserve baseline evidence, and promote semantic questions to `brief.md`/`design.md` | Bash remains default runtime | +| `STOP-02` | `CTR-03`, `FM-02`, `FM-04`, `RB-03` | Target asset/checksum/install matrix fails | Do not tag/publish; correct release configuration and rerun matrix | Previous published release remains available | + +## Plan-local Evidence + +| Evidence ID | Artifact | Producer | Path contract | Reused by checkpoints | +| --- | --- | --- | --- | --- | +| `EVID-09` | Grounding inventory, parity-case manifest, and simplify-review verdict | implementer / reviewer | `artifacts/ft-016/plan/` or committed test metadata | `CP-01`, `CP-02` | + +## Ready for Acceptance + +- All workstreams are complete and `CP-01`–`CP-03` have evidence. +- `CHK-01`–`CHK-03` pass; any manual live-release check is documented as evidence, not a substitute for deterministic tests. +- A simplify review confirms that Go package boundaries clarify responsibilities and do not add abstraction unsupported by `SOL-*`/`CTR-*`/`INV-*`. +- Final acceptance follows `brief.md` `Verify`. diff --git a/memory-bank/features/README.md b/memory-bank/features/README.md index 0ec58d1..76c30f9 100644 --- a/memory-bank/features/README.md +++ b/memory-bank/features/README.md @@ -30,7 +30,10 @@ audience: humans_and_agents - Вместо `XXX` используй идентификатор, принятый в проекте: issue id, ticket id или другой стабильный ключ - Один package = одна delivery-единица -## Feature packages +## Instantiated Packages - [FT-016: Real Codex human-gate E2E suite](FT-016/README.md) Opt-in local smoke suite that validates the real Codex human-gate path. + +- [FT-017: Go parity-first migration](FT-017/README.md) + Issue #34 package for migrating the CLI to Go with executable parity before cutover. The Bash runtime remains the baseline until the package's parity evidence permits cutover. diff --git a/memory-bank/ops/development.md b/memory-bank/ops/development.md index 02139a1..4d24fee 100644 --- a/memory-bank/ops/development.md +++ b/memory-bank/ops/development.md @@ -18,12 +18,10 @@ audience: humans_and_agents Required tools for normal development: -- `bash` +- Go 1.21+ - `git` - `gh` with an authenticated GitHub session for issue/update flows - `jq` -- `shellcheck` -- `bats` - `curl` or `wget` for installer/update paths - one checksum tool: `sha256sum`, `shasum`, or `openssl` @@ -62,10 +60,10 @@ make install Useful direct commands: ```bash -bash -n scripts/start-issue -shellcheck install.sh scripts/start-issue scripts/build-start-issue scripts/bump-version scripts/prepare-release scripts/lib/start_issue/*.sh +gofmt -w cmd/start-issue/*.go +go vet ./... +go test ./... python3 scripts/check_memory_bank_index.py --max-depth 4 -bats test ``` ## Dry-Run Development @@ -74,7 +72,7 @@ Use `--dry-run` to inspect config, prompt source, worktree path, and launch command without creating a worktree or launching an agent: ```bash -scripts/start-issue 123 --repo dapi/start-issue --agent codex --dry-run +.build/start-issue 123 --repo dapi/start-issue --agent codex --dry-run ``` Set `START_ISSUE_DUMP_PROMPT=1` when the full rendered prompt must be visible in @@ -83,7 +81,7 @@ dry-run output. ## Browser Testing Not applicable. This project has no browser UI. CLI output is verified through -Bats and direct command output review. +Go tests and direct command output review. ## Local Services @@ -93,7 +91,7 @@ mode-specific as documented in README/spec. ## Development Safety -- Prefer `scripts/start-issue --dry-run` when exploring behavior. +- Prefer `.build/start-issue --dry-run` when exploring behavior. - Do not run release commands unless preparing an actual release. - Do not delete worktrees manually while tests depend on fake worktree state; use isolated temp directories in tests. diff --git a/memory-bank/product/context.md b/memory-bank/product/context.md index 382abc1..bcc37ff 100644 --- a/memory-bank/product/context.md +++ b/memory-bank/product/context.md @@ -67,7 +67,7 @@ tracker, or the developer's review and merge process. | Metric ID | Metric | Baseline | Target | Measurement method | | --- | --- | --- | --- | --- | | `MET-01` | Time from issue reference to prepared worktree | Manual setup | One command for common cases | Manual workflow review and regression tests | -| `MET-02` | Predictability of config and launch behavior | Historically implicit defaults | Effective agent, model, prompt source, and launch command are visible | CLI output, `--dry-run`, Bats coverage | +| `MET-02` | Predictability of config and launch behavior | Historically implicit defaults | Effective agent, model, prompt source, and launch command are visible | CLI output, `--dry-run`, Go test coverage | | `MET-03` | Release/install reliability | Manual install/update risk | Checksummed release asset and self-update path | Release workflow and installer/update tests | ## Product Constraints diff --git a/mise.toml b/mise.toml index 59eb3f1..9d96842 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,4 @@ [tools] -bats = "1.13.0" +go = "1.21" gh = "2.90.0" jq = "1.8.1" diff --git a/scripts/build-start-issue b/scripts/build-start-issue deleted file mode 100755 index d49c7b3..0000000 --- a/scripts/build-start-issue +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -ENTRYPOINT="$ROOT_DIR/scripts/start-issue" -MODULE_DIR="$ROOT_DIR/scripts/lib/start_issue" -OUTPUT_PATH="${1:-$ROOT_DIR/.build/start-issue}" - -modules=( - utils.sh - config.sh - agent.sh - init.sh - github.sh - release.sh - update.sh - worktree.sh - output.sh - cli.sh - pipeline.sh -) - -mkdir -p "$(dirname "$OUTPUT_PATH")" - -{ - in_block=false - while IFS= read -r line; do - if [[ "$line" == "# BEGIN_MODULE_SOURCES" ]]; then - printf "%s\n" "$line" - for module in "${modules[@]}"; do - printf "\n# --- bundled from scripts/lib/start_issue/%s ---\n" "$module" - cat "$MODULE_DIR/$module" - printf "\n" - done - in_block=true - continue - fi - - if [[ "$line" == "# END_MODULE_SOURCES" ]]; then - in_block=false - continue - fi - - if [[ "$in_block" == "false" ]]; then - printf "%s\n" "$line" - fi - done < "$ENTRYPOINT" -} > "$OUTPUT_PATH" - -chmod +x "$OUTPUT_PATH" -printf "%s\n" "$OUTPUT_PATH" diff --git a/scripts/bump-version b/scripts/bump-version deleted file mode 100755 index a8b3730..0000000 --- a/scripts/bump-version +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: scripts/bump-version -EOF -} - -if [[ $# -ne 1 ]]; then - usage >&2 - exit 1 -fi - -kind="$1" -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -version_file="$repo_root/scripts/start-issue" -file_mode="$(stat -f %Lp "$version_file")" - -current_version="$(awk -F'"' '/^VERSION="/ { print $2; exit }' "$version_file")" -if [[ -z "$current_version" ]]; then - echo "Could not read current version from $version_file" >&2 - exit 1 -fi - -IFS=. read -r major minor patch <<< "$current_version" - -case "$kind" in - patch) - patch=$((patch + 1)) - ;; - minor) - minor=$((minor + 1)) - patch=0 - ;; - major) - major=$((major + 1)) - minor=0 - patch=0 - ;; - *) - usage >&2 - exit 1 - ;; -esac - -next_version="$major.$minor.$patch" -tmpfile="$(mktemp)" -trap 'rm -f "$tmpfile"' EXIT - -awk -v next_version="$next_version" ' - BEGIN { replaced = 0 } - /^VERSION="[0-9]+\.[0-9]+\.[0-9]+"$/ && replaced == 0 { - print "VERSION=\"" next_version "\"" - replaced = 1 - next - } - { print } - END { - if (replaced != 1) { - exit 1 - } - } -' "$version_file" > "$tmpfile" || { - echo "Expected exactly one VERSION line in $version_file" >&2 - exit 1 -} - -mv "$tmpfile" "$version_file" -chmod "$file_mode" "$version_file" -trap - EXIT - -printf '%s\n' "$next_version" diff --git a/scripts/lib/start_issue/agent.sh b/scripts/lib/start_issue/agent.sh deleted file mode 100644 index e1f0069..0000000 --- a/scripts/lib/start_issue/agent.sh +++ /dev/null @@ -1,472 +0,0 @@ -# shellcheck shell=bash disable=SC2034 -agent_supports_explicit_model_selection() { - local operation="$1" - - case "$operation" in - launch|branch-name|prompt-improvement) - ;; - *) - return 1 - ;; - esac - - case "$AGENT" in - claude|codex|kimi|pi) - return 0 - ;; - *) - return 1 - ;; - esac -} - -validate_model_selection_support() { - local operation="$1" - - if [[ -z "$MODEL" || "$AGENT" == "none" ]]; then - return 0 - fi - - if agent_supports_explicit_model_selection "$operation"; then - return 0 - fi - - die "Agent '$AGENT' does not support explicit model selection for $operation." -} - -claude_noninteractive_model() { - if [[ -n "$MODEL" ]]; then - printf "%s" "$MODEL" - else - printf "%s" "haiku" - fi -} - -validate_prompt_improvement_mode() { - if [[ "$IMPROVE_PROMPT" != "true" ]]; then - return - fi - - if [[ "$AGENT" == "none" ]]; then - die "--improve-prompt requires an agent. Use --agent claude, codex, kimi, or pi." - fi -} - -validate_human_gate_mode() { - if [[ "$HUMAN_GATE_MODE" != "true" ]]; then - return - fi - - if [[ "$AGENT" != "codex" ]]; then - die "--human-gate requires agent 'codex'. Current agent: $AGENT." - fi -} - -human_gate_run_id() { - if [[ -n "$HUMAN_GATE_RUN_ID" ]]; then - printf "%s" "$HUMAN_GATE_RUN_ID" - return - fi - - if [[ -n "${START_ISSUE_RUN_ID:-}" ]]; then - HUMAN_GATE_RUN_ID="$START_ISSUE_RUN_ID" - else - HUMAN_GATE_RUN_ID="$(date +%Y%m%d-%H%M%S)" - fi - - printf "%s" "$HUMAN_GATE_RUN_ID" -} - -prepare_human_gate_state_paths() { - local run_id - run_id="$(human_gate_run_id)" - - HUMAN_GATE_STATE_DIR="$WORKTREE_PATH/.start-issue/runs/$run_id" - HUMAN_GATE_EVENTS_PATH="$HUMAN_GATE_STATE_DIR/events.jsonl" - HUMAN_GATE_LAST_MESSAGE_PATH="$HUMAN_GATE_STATE_DIR/last-message.txt" - HUMAN_GATE_THREAD_ID_PATH="$HUMAN_GATE_STATE_DIR/thread-id" -} - -build_human_gate_command() { - HUMAN_GATE_CMD=() - validate_human_gate_mode - prepare_human_gate_state_paths - - if [[ -n "$MODEL" ]]; then - HUMAN_GATE_CMD=( - codex exec - --model "$MODEL" - --cd "$WORKTREE_PATH" - --sandbox workspace-write - --json - --output-last-message "$HUMAN_GATE_LAST_MESSAGE_PATH" - - - ) - else - HUMAN_GATE_CMD=( - codex exec - --cd "$WORKTREE_PATH" - --sandbox workspace-write - --json - --output-last-message "$HUMAN_GATE_LAST_MESSAGE_PATH" - - - ) - fi -} - -capture_human_gate_thread_id() { - HUMAN_GATE_THREAD_ID="" - - if [[ ! -f "$HUMAN_GATE_EVENTS_PATH" ]]; then - return 1 - fi - - HUMAN_GATE_THREAD_ID="$( - jq -r 'select(.type == "thread.started") | .thread_id // empty' "$HUMAN_GATE_EVENTS_PATH" 2>/dev/null | head -n 1 - )" - - [[ -n "$HUMAN_GATE_THREAD_ID" ]] || return 1 - - printf "%s\n" "$HUMAN_GATE_THREAD_ID" > "$HUMAN_GATE_THREAD_ID_PATH" -} - -parse_human_gate_final_status() { - HUMAN_GATE_FINAL_STATUS="" - - if [[ ! -f "$HUMAN_GATE_LAST_MESSAGE_PATH" ]]; then - return 1 - fi - - HUMAN_GATE_FINAL_STATUS="$( - awk ' - /^STATUS:[[:space:]]*/ { - sub(/^STATUS:[[:space:]]*/, "") - gsub(/[[:space:]]+$/, "") - print - exit - } - ' "$HUMAN_GATE_LAST_MESSAGE_PATH" - )" - - case "$HUMAN_GATE_FINAL_STATUS" in - DONE|HUMAN_GATE) - return 0 - ;; - *) - return 1 - ;; - esac -} - -run_codex_human_gate_session() { - local batch_exit=0 - - log_info "🤖 Starting codex human-gate batch session..." - build_human_gate_command - - echo " State dir: $HUMAN_GATE_STATE_DIR" - - if [[ "$DRY_RUN" == "true" ]]; then - print_dry_run_human_gate_command - return - fi - - mkdir -p "$HUMAN_GATE_STATE_DIR" - - if printf "%s" "$AGENT_PROMPT" | "${HUMAN_GATE_CMD[@]}" > "$HUMAN_GATE_EVENTS_PATH"; then - : - else - batch_exit=$? - fi - - if ! capture_human_gate_thread_id; then - if [[ $batch_exit -ne 0 ]]; then - echo " Codex batch exit code: $batch_exit" - fi - die "Codex human-gate run did not capture thread_id. Inspect: $HUMAN_GATE_EVENTS_PATH" - fi - - echo " Thread ID: $HUMAN_GATE_THREAD_ID" - - if ! parse_human_gate_final_status; then - if [[ $batch_exit -ne 0 ]]; then - echo " Codex batch exit code: $batch_exit" - fi - die "No recognized final status found. Inspect: $HUMAN_GATE_LAST_MESSAGE_PATH" - fi - - case "$HUMAN_GATE_FINAL_STATUS" in - DONE) - log_success "✅ Codex finished with STATUS: DONE" - echo " Last message: $HUMAN_GATE_LAST_MESSAGE_PATH" - return 0 - ;; - HUMAN_GATE) - log_info "🧭 Codex finished with STATUS: HUMAN_GATE" - echo " Resume command: codex resume --include-non-interactive $HUMAN_GATE_THREAD_ID" - if codex resume --include-non-interactive "$HUMAN_GATE_THREAD_ID"; then - return 0 - fi - log_error "Could not open Codex resume session." - echo "Resume command: codex resume --include-non-interactive $HUMAN_GATE_THREAD_ID" - echo "Thread ID: $HUMAN_GATE_THREAD_ID" - return 2 - ;; - *) - die "Unsupported human-gate final status: $HUMAN_GATE_FINAL_STATUS" - ;; - esac -} - -default_prompt_improvement_output_path() { - if [[ -n "$PROMPT_IMPROVEMENT_OUTPUT_FILE" ]]; then - printf "%s" "$PROMPT_IMPROVEMENT_OUTPUT_FILE" - return - fi - - if [[ -n "$PROMPT_TEMPLATE_PATH" ]]; then - local dir - local file - local stem - - dir=$(dirname "$PROMPT_TEMPLATE_PATH") - file=$(basename "$PROMPT_TEMPLATE_PATH") - if [[ "$file" == *.md ]]; then - stem="${file%.md}" - printf "%s/%s.improved.md" "$dir" "$stem" - else - printf "%s/%s.improved" "$dir" "$file" - fi - return - fi - - printf "%s/.start-issue/prompt.improved.md" "$PROJECT_ROOT" -} - -prompt_improvement_request() { - cat << EOF -Improve the following start-issue prompt template. - -Return ONLY the complete improved prompt template. Do not include commentary, code fences, diffs, or explanations. - -Preserve any placeholders that are still useful. Supported placeholders: -{ISSUE_URL}, {ISSUE_NUMBER}, {ISSUE_TITLE}, {ISSUE_BODY}, {ISSUE_LABELS}, {REPO}, {BRANCH_NAME}, {WORKTREE_PATH}, {BASE_BRANCH} - -Prompt source: -$PROMPT_SOURCE - -Repository: -$REPO - -Current issue used as improvement context: -- URL: $ISSUE_URL -- Number: $ISSUE_NUMBER -- Title: $ISSUE_TITLE -- Labels: $ISSUE_LABELS -- Body: -$ISSUE_BODY - -Current prompt template: ---- START PROMPT TEMPLATE --- -$PROMPT_TEMPLATE ---- END PROMPT TEMPLATE --- -EOF -} - -agent_supports_operation() { - local operation="$1" - - case "$operation" in - validate|launch|branch-name|prompt-improvement) - ;; - *) - return 1 - ;; - esac - - case "$AGENT" in - claude|codex|kimi|pi) - return 0 - ;; - none) - [[ "$operation" == "validate" ]] && return 0 - return 1 - ;; - *) - return 1 - ;; - esac -} - -generate_improved_prompt_template() { - local request - local output - - request=$(prompt_improvement_request) - validate_model_selection_support "prompt-improvement" - - case "$AGENT" in - claude) - output=$(claude --print --model "$(claude_noninteractive_model)" --no-session-persistence \ - --disable-slash-commands "$request" 2>/dev/null) || return 1 - ;; - codex) - if [[ -n "$MODEL" ]]; then - output=$(codex exec --model "$MODEL" --cd "$PROJECT_ROOT" --sandbox read-only \ - --skip-git-repo-check "$request" 2>/dev/null) || return 1 - else - output=$(codex exec --cd "$PROJECT_ROOT" --sandbox read-only \ - --skip-git-repo-check "$request" 2>/dev/null) || return 1 - fi - ;; - kimi) - if [[ -n "$MODEL" ]]; then - output=$(kimi --model "$MODEL" --work-dir "$PROJECT_ROOT" --quiet -p "$request" 2>/dev/null) || return 1 - else - output=$(kimi --work-dir "$PROJECT_ROOT" --quiet -p "$request" 2>/dev/null) || return 1 - fi - ;; - pi) - if [[ -n "$MODEL" ]]; then - output=$(pi --model "$MODEL" --print --no-tools --no-session "$request" 2>/dev/null) || return 1 - else - output=$(pi --print --no-tools --no-session "$request" 2>/dev/null) || return 1 - fi - ;; - *) - return 1 - ;; - esac - - output=$(printf "%s" "$output" | sed '1{/^```[[:alnum:]_-]*$/d;}; ${/^```$/d;}') - [[ -n "$(trim "$output")" ]] || return 1 - printf "%s" "$output" -} - -generate_ai_branch_name() { - local prompt="Git branch name for issue #$ISSUE_NUMBER: \"$ISSUE_TITLE\" (labels: $ISSUE_LABELS). -Format: {type}/issue-$ISSUE_NUMBER-{kebab-case-name} -Types: bug/fix -> fix, enhancement -> feature, hotfix -> hotfix, docs -> docs, refactor -> refactor, test -> test, chore -> chore, default -> feature. -If the title contains non-English text (e.g. Cyrillic), transliterate it to English for the kebab-case name. -Strip leading bracketed process/stage tags (e.g. [brief], [investigation], [PR-008]) from the kebab-case name — they mark workflow stage. The {type} still comes from the labels above. -Reply with ONLY the branch name." - local output="" - - if ! agent_supports_operation "branch-name"; then - return 1 - fi - - if ! command -v "$AGENT" &> /dev/null; then - return 1 - fi - - validate_model_selection_support "branch-name" - - case "$AGENT" in - claude) - output=$(claude --print --model "$(claude_noninteractive_model)" --no-session-persistence \ - --disable-slash-commands "$prompt" 2>/dev/null) || return 1 - ;; - codex) - if [[ -n "$MODEL" ]]; then - output=$(codex exec --model "$MODEL" --cd "$PROJECT_ROOT" --sandbox read-only \ - --skip-git-repo-check "$prompt" 2>/dev/null | tail -n 1) || return 1 - else - output=$(codex exec --cd "$PROJECT_ROOT" --sandbox read-only \ - --skip-git-repo-check "$prompt" 2>/dev/null | tail -n 1) || return 1 - fi - ;; - kimi) - if [[ -n "$MODEL" ]]; then - output=$(kimi --model "$MODEL" --work-dir "$PROJECT_ROOT" --quiet -p "$prompt" 2>/dev/null) || return 1 - else - output=$(kimi --work-dir "$PROJECT_ROOT" --quiet -p "$prompt" 2>/dev/null) || return 1 - fi - ;; - pi) - if [[ -n "$MODEL" ]]; then - output=$(pi --model "$MODEL" --print --no-tools --no-session "$prompt" 2>/dev/null | tail -n 1) || return 1 - else - output=$(pi --print --no-tools --no-session "$prompt" 2>/dev/null | tail -n 1) || return 1 - fi - ;; - *) - return 1 - ;; - esac - - BRANCH_NAME=$(printf "%s" "$output" | tr -d '`"' | awk 'NF { last=$0 } END { print last }' | xargs) - - [[ -n "$BRANCH_NAME" ]] -} - -improve_prompt_template() { - local output_path - output_path=$(default_prompt_improvement_output_path) - - log_info "📝 Improving prompt template..." - echo " Prompt source: $PROMPT_SOURCE" - echo " Proposal path: $output_path" - - if [[ "$DRY_RUN" == "true" ]]; then - echo " [DRY-RUN] Would ask $AGENT to generate an improved prompt proposal." - return - fi - - if [[ -e "$output_path" ]]; then - die "Prompt improvement output already exists: $output_path" - fi - - local improved_prompt - improved_prompt=$(generate_improved_prompt_template) || \ - die "Could not generate improved prompt with $AGENT" - - mkdir -p "$(dirname "$output_path")" - printf "%s\n" "$improved_prompt" > "$output_path" - log_success " ✅ Prompt improvement written" - echo " Review the proposal and copy it to the active prompt file if accepted." -} - -build_launch_command() { - LAUNCH_CWD="" - LAUNCH_CMD=() - validate_model_selection_support "launch" - - case "$AGENT" in - claude) - LAUNCH_CWD="$WORKTREE_PATH" - if [[ -n "$MODEL" ]]; then - LAUNCH_CMD=(claude --model "$MODEL" --dangerously-skip-permissions "$AGENT_PROMPT") - else - LAUNCH_CMD=(claude --dangerously-skip-permissions "$AGENT_PROMPT") - fi - ;; - codex) - if [[ -n "$MODEL" ]]; then - LAUNCH_CMD=(codex --model "$MODEL" --cd "$WORKTREE_PATH" --dangerously-bypass-approvals-and-sandbox "$AGENT_PROMPT") - else - LAUNCH_CMD=(codex --cd "$WORKTREE_PATH" --dangerously-bypass-approvals-and-sandbox "$AGENT_PROMPT") - fi - ;; - kimi) - if [[ -n "$MODEL" ]]; then - LAUNCH_CMD=(kimi --model "$MODEL" --work-dir "$WORKTREE_PATH" --yolo -p "$AGENT_PROMPT") - else - LAUNCH_CMD=(kimi --work-dir "$WORKTREE_PATH" --yolo -p "$AGENT_PROMPT") - fi - ;; - pi) - LAUNCH_CWD="$WORKTREE_PATH" - if [[ -n "$MODEL" ]]; then - LAUNCH_CMD=(pi --model "$MODEL" "$AGENT_PROMPT") - else - LAUNCH_CMD=(pi "$AGENT_PROMPT") - fi - ;; - none) - ;; - *) - die "Unknown agent: $AGENT" - ;; - esac -} diff --git a/scripts/lib/start_issue/cli.sh b/scripts/lib/start_issue/cli.sh deleted file mode 100644 index a8c33e8..0000000 --- a/scripts/lib/start_issue/cli.sh +++ /dev/null @@ -1,186 +0,0 @@ -# shellcheck shell=bash disable=SC2034 -require_value() { - local option="$1" - local value="${2:-}" - - if [[ -z "$value" || "$value" == -* ]]; then - die "$option requires a value." - fi -} - -parse_args() { - while [[ $# -gt 0 ]]; do - case $1 in - --repo|-r) - require_value "$1" "${2:-}" - REPO="$2" - shift 2 - ;; - --base|-b) - require_value "$1" "${2:-}" - BASE_BRANCH="$2" - shift 2 - ;; - --worktree-dir|-w) - require_value "$1" "${2:-}" - WORKTREE_DIR="$2" - WORKTREE_DIR_SOURCE="CLI" - shift 2 - ;; - --agent) - require_value "$1" "${2:-}" - AGENT_CLI="$2" - shift 2 - ;; - --model) - require_value "$1" "${2:-}" - MODEL_CLI="$2" - shift 2 - ;; - --no-agent|--no-claude) - AGENT_CLI="none" - shift - ;; - --prompt-file) - require_value "$1" "${2:-}" - PROMPT_FILE_CLI="$2" - shift 2 - ;; - --prompt) - require_value "$1" "${2:-}" - PROMPT_INLINE_CLI="$2" - shift 2 - ;; - --improve-prompt) - IMPROVE_PROMPT=true - shift - ;; - --human-gate) - HUMAN_GATE_MODE=true - shift - ;; - --human-gate-help) - HUMAN_GATE_HELP=true - shift - ;; - --prompt-output-file) - require_value "$1" "${2:-}" - PROMPT_IMPROVEMENT_OUTPUT_FILE="$2" - shift 2 - ;; - --no-init) - NO_INIT=true - shift - ;; - --flat) - FLAT_WORKTREE=true - shift - ;; - --command|-c) - require_value "$1" "${2:-}" - INITIAL_COMMAND="$2" - shift 2 - ;; - --ai) - FAST_MODE=false - shift - ;; - --project) - if [[ "$INIT_SCOPE" == "user" ]]; then - die "Use either --project or --user, not both." - fi - INIT_SCOPE="project" - shift - ;; - --user) - if [[ "$INIT_SCOPE" == "project" ]]; then - die "Use either --project or --user, not both." - fi - INIT_SCOPE="user" - shift - ;; - --force) - INIT_FORCE=true - shift - ;; - --dry-run) - DRY_RUN=true - shift - ;; - --version|-v) - show_version - exit 0 - ;; - --setup) - SETUP_MODE=true - shift - ;; - --update) - UPDATE_MODE=true - shift - ;; - --help|-h) - show_help - exit 0 - ;; - -*) - die "Unknown option: $1. Use --help for usage." - ;; - *) - if [[ "$1" == "init" && -z "$ISSUE_INPUT" && "$INIT_CONFIG" == "false" ]]; then - INIT_CONFIG=true - elif [[ "$1" == "setup" && -z "$ISSUE_INPUT" && "$INIT_CONFIG" == "false" && "$SETUP_MODE" == "false" && "$UPDATE_MODE" == "false" ]]; then - SETUP_MODE=true - elif [[ "$1" == "update" && -z "$ISSUE_INPUT" && "$INIT_CONFIG" == "false" && "$UPDATE_MODE" == "false" ]]; then - UPDATE_MODE=true - elif [[ "$INIT_CONFIG" == "true" ]]; then - die "Unexpected argument for init: $1" - elif [[ "$SETUP_MODE" == "true" ]]; then - die "Unexpected argument for setup: $1" - elif [[ "$UPDATE_MODE" == "true" ]]; then - die "Unexpected argument for update: $1" - elif [[ -z "$ISSUE_INPUT" ]]; then - ISSUE_INPUT="$1" - else - die "Unexpected argument: $1" - fi - shift - ;; - esac - done - - if [[ "$INIT_CONFIG" == "true" ]]; then - if [[ "$SETUP_MODE" == "true" ]]; then - die "Use either init or setup, not both." - fi - if [[ "$UPDATE_MODE" == "true" ]]; then - die "Use either init or update, not both." - fi - return - fi - - if [[ "$SETUP_MODE" == "true" ]]; then - if [[ "$UPDATE_MODE" == "true" ]]; then - die "Use either setup or update, not both." - fi - if [[ -n "$ISSUE_INPUT" ]]; then - die "Use either setup or , not both." - fi - return - fi - - if [[ "$UPDATE_MODE" == "true" ]]; then - if [[ -n "$ISSUE_INPUT" ]]; then - die "Use either update or , not both." - fi - return - fi - - if [[ -n "$INIT_SCOPE" || "$INIT_FORCE" == "true" ]]; then - die "--project, --user, and --force are only valid with init." - fi - - if [[ -z "$ISSUE_INPUT" ]]; then - MISSING_ISSUE=true - fi -} diff --git a/scripts/lib/start_issue/config.sh b/scripts/lib/start_issue/config.sh deleted file mode 100644 index 33a68a5..0000000 --- a/scripts/lib/start_issue/config.sh +++ /dev/null @@ -1,191 +0,0 @@ -# shellcheck shell=bash disable=SC2034 -check_selected_agent_dependency() { - if [[ "$AGENT" == "none" || "$DRY_RUN" == "true" ]]; then - return - fi - - if ! command -v "$AGENT" &> /dev/null; then - die "$AGENT CLI not found. Install it or use --agent none." - fi -} - -validate_agent() { - case "$AGENT" in - claude|codex|kimi|pi|none) - ;; - *) - die "Unknown agent: $AGENT. Valid agents: claude, codex, kimi, pi, none." - ;; - esac -} - -validate_model_config() { - if [[ -z "$MODEL" ]]; then - die "Model config is empty. Remove the empty model config or set a value." - fi -} - -resolve_agent() { - local project_agent_file="$PROJECT_ROOT/.start-issue/agent" - local user_agent_file="$HOME/.config/start-issue/agent" - - if [[ -n "$AGENT_CLI" ]]; then - AGENT="$AGENT_CLI" - AGENT_SOURCE="CLI" - elif [[ -f "$project_agent_file" ]]; then - AGENT=$(read_first_config_value "$project_agent_file") - AGENT_SOURCE="$project_agent_file" - elif [[ -f "$user_agent_file" ]]; then - AGENT=$(read_first_config_value "$user_agent_file") - AGENT_SOURCE="$user_agent_file" - elif [[ -n "${START_ISSUE_AGENT:-}" ]]; then - AGENT=$(trim "$START_ISSUE_AGENT") - AGENT_SOURCE="START_ISSUE_AGENT" - else - AGENT="claude" - AGENT_SOURCE="built-in default" - fi - - if [[ -z "$AGENT" ]]; then - die "Agent config is empty. Valid agents: claude, codex, kimi, pi, none." - fi - - validate_agent -} - -resolve_model() { - local project_model_file="$PROJECT_ROOT/.start-issue/model" - local user_model_file="$HOME/.config/start-issue/model" - - if [[ -n "$MODEL_CLI" ]]; then - MODEL=$(trim "$MODEL_CLI") - MODEL_SOURCE="CLI" - elif [[ -f "$project_model_file" ]]; then - MODEL=$(read_first_config_value "$project_model_file") - MODEL_SOURCE="$project_model_file" - elif [[ -f "$user_model_file" ]]; then - MODEL=$(read_first_config_value "$user_model_file") - MODEL_SOURCE="$user_model_file" - elif [[ -n "${START_ISSUE_MODEL:-}" ]]; then - MODEL=$(trim "$START_ISSUE_MODEL") - MODEL_SOURCE="START_ISSUE_MODEL" - else - MODEL="" - MODEL_SOURCE="built-in default" - fi - - if [[ -n "$MODEL_SOURCE" && "$MODEL_SOURCE" != "built-in default" ]]; then - validate_model_config - fi -} - -default_portable_prompt_template() { - cat << 'EOF' -Implement GitHub issue {ISSUE_URL} in this worktree. - -Context: -- Repo: {REPO} -- Issue: #{ISSUE_NUMBER} -- Title: {ISSUE_TITLE} -- Branch: {BRANCH_NAME} -- Worktree: {WORKTREE_PATH} - -Start by reading the issue with gh if needed. Follow repository instructions. Keep changes scoped. Run relevant tests or checks. Summarize changed files and verification before finishing. -If you open a PR for this work, target the base branch {BASE_BRANCH}. -EOF -} - -default_claude_prompt_template() { - if [[ -n "$INITIAL_COMMAND" ]]; then - printf "%s {ISSUE_URL}" "$INITIAL_COMMAND" - else - printf "/task-router:route-task {ISSUE_URL}" - fi -} - -read_prompt_file() { - local path="$1" - - if [[ ! -f "$path" ]]; then - die "Prompt file not found: $path" - fi - - if [[ ! -r "$path" ]]; then - die "Prompt file is not readable: $path" - fi - - PROMPT_TEMPLATE=$(< "$path") -} - -resolve_prompt_template() { - local project_prompt_file="$PROJECT_ROOT/.start-issue/prompt.md" - local user_prompt_file="$HOME/.config/start-issue/prompt.md" - - PROMPT_LOCATION="" - PROMPT_TEMPLATE_PATH="" - - if [[ -n "$PROMPT_FILE_CLI" && -n "$PROMPT_INLINE_CLI" ]]; then - die "Use either --prompt-file or --prompt, not both." - fi - - if [[ -n "$PROMPT_FILE_CLI" ]]; then - read_prompt_file "$PROMPT_FILE_CLI" - PROMPT_SOURCE="CLI --prompt-file: $PROMPT_FILE_CLI" - PROMPT_LOCATION=$(absolute_path "$PROMPT_FILE_CLI") - PROMPT_TEMPLATE_PATH="$PROMPT_LOCATION" - elif [[ -n "$PROMPT_INLINE_CLI" ]]; then - PROMPT_TEMPLATE="$PROMPT_INLINE_CLI" - PROMPT_SOURCE="CLI --prompt" - PROMPT_LOCATION="inline CLI argument" - elif [[ -n "${START_ISSUE_PROMPT_FILE:-}" || -n "${START_ISSUE_PROMPT:-}" ]]; then - if [[ -n "${START_ISSUE_PROMPT_FILE:-}" && -n "${START_ISSUE_PROMPT:-}" ]]; then - die "Use either START_ISSUE_PROMPT_FILE or START_ISSUE_PROMPT, not both." - fi - - if [[ -n "${START_ISSUE_PROMPT_FILE:-}" ]]; then - read_prompt_file "$START_ISSUE_PROMPT_FILE" - PROMPT_SOURCE="START_ISSUE_PROMPT_FILE: $START_ISSUE_PROMPT_FILE" - PROMPT_LOCATION=$(absolute_path "$START_ISSUE_PROMPT_FILE") - PROMPT_TEMPLATE_PATH="$PROMPT_LOCATION" - else - PROMPT_TEMPLATE="$START_ISSUE_PROMPT" - PROMPT_SOURCE="START_ISSUE_PROMPT" - PROMPT_LOCATION="START_ISSUE_PROMPT environment variable" - fi - elif [[ -f "$project_prompt_file" ]]; then - read_prompt_file "$project_prompt_file" - PROMPT_SOURCE="$project_prompt_file" - PROMPT_LOCATION="$project_prompt_file" - PROMPT_TEMPLATE_PATH="$PROMPT_LOCATION" - elif [[ -f "$user_prompt_file" ]]; then - read_prompt_file "$user_prompt_file" - PROMPT_SOURCE="$user_prompt_file" - PROMPT_LOCATION="$user_prompt_file" - PROMPT_TEMPLATE_PATH="$PROMPT_LOCATION" - else - if [[ "$AGENT" == "claude" ]]; then - PROMPT_TEMPLATE=$(default_claude_prompt_template) - PROMPT_SOURCE="built-in Claude command" - else - PROMPT_TEMPLATE=$(default_portable_prompt_template) - PROMPT_SOURCE="built-in portable prompt" - fi - PROMPT_LOCATION="$SCRIPT_PATH" - fi -} - -render_prompt_template() { - local rendered="$PROMPT_TEMPLATE" - - rendered="${rendered//\{ISSUE_URL\}/$ISSUE_URL}" - rendered="${rendered//\{ISSUE_NUMBER\}/$ISSUE_NUMBER}" - rendered="${rendered//\{ISSUE_TITLE\}/$ISSUE_TITLE}" - rendered="${rendered//\{ISSUE_BODY\}/$ISSUE_BODY}" - rendered="${rendered//\{ISSUE_LABELS\}/$ISSUE_LABELS}" - rendered="${rendered//\{REPO\}/$REPO}" - rendered="${rendered//\{BRANCH_NAME\}/$BRANCH_NAME}" - rendered="${rendered//\{WORKTREE_PATH\}/$WORKTREE_PATH}" - rendered="${rendered//\{BASE_BRANCH\}/$BASE_BRANCH}" - - AGENT_PROMPT="$rendered" -} diff --git a/scripts/lib/start_issue/github.sh b/scripts/lib/start_issue/github.sh deleted file mode 100644 index e10534f..0000000 --- a/scripts/lib/start_issue/github.sh +++ /dev/null @@ -1,69 +0,0 @@ -# shellcheck shell=bash disable=SC2034 -parse_issue_input() { - local input="$1" - - if [[ "$input" =~ ^https://github\.com/([^/]+)/([^/]+)/issues/([0-9]+) ]]; then - REPO="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}" - ISSUE_NUMBER="${BASH_REMATCH[3]}" - elif [[ "$input" =~ ^[0-9]+$ ]]; then - ISSUE_NUMBER="$input" - else - die "Invalid issue format: $input. Use issue number or full GitHub URL." - fi -} - -detect_repo_from_remote() { - if [[ -n "$REPO" ]]; then - return - fi - - local remote_url - remote_url=$(git remote get-url origin 2>/dev/null || echo "") - - if [[ -z "$remote_url" ]]; then - die "Cannot detect repository. No 'origin' remote found. Use --repo flag." - fi - - if [[ "$remote_url" =~ git@github\.com:([^/]+)/(.+)$ ]]; then - REPO="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}" - REPO="${REPO%.git}" - elif [[ "$remote_url" =~ https://github\.com/([^/]+)/(.+)$ ]]; then - REPO="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}" - REPO="${REPO%.git}" - else - die "Cannot parse repository from remote URL: $remote_url. Use --repo flag." - fi -} - -detect_base_branch() { - if [[ -n "$BASE_BRANCH" ]]; then - return - fi - - local remote_head - remote_head=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||' || true) - if [[ -n "$remote_head" ]]; then - BASE_BRANCH="$remote_head" - return - fi - - BASE_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "HEAD") - log_info "Could not detect default branch, using current: $BASE_BRANCH" -} - -fetch_issue() { - log_info "🔍 Fetching issue #$ISSUE_NUMBER from $REPO..." - - ISSUE_JSON=$(gh api "repos/$REPO/issues/$ISSUE_NUMBER" 2>/dev/null) || \ - die "Issue #$ISSUE_NUMBER not found in $REPO" - - ISSUE_TITLE=$(echo "$ISSUE_JSON" | jq -r '.title') - ISSUE_BODY=$(echo "$ISSUE_JSON" | jq -r '.body // ""') - ISSUE_LABELS=$(echo "$ISSUE_JSON" | jq -r '[.labels[].name] | join(", ")') - ISSUE_URL="https://github.com/$REPO/issues/$ISSUE_NUMBER" - - echo " Title: $ISSUE_TITLE" - if [[ -n "$ISSUE_LABELS" ]]; then - echo " Labels: $ISSUE_LABELS" - fi -} diff --git a/scripts/lib/start_issue/init.sh b/scripts/lib/start_issue/init.sh deleted file mode 100644 index abb6de5..0000000 --- a/scripts/lib/start_issue/init.sh +++ /dev/null @@ -1,379 +0,0 @@ -# shellcheck shell=bash disable=SC2034 -USER_CONFIG_DIR="$HOME/.config/start-issue" -SETUP_AGENT_FILE_VALUE="" -SETUP_SAVE_PROMPT=false - -confirm_yes_default() { - local prompt="$1" - local reply="" - - printf "%s" "$prompt" - if ! read -r reply; then - die "No response received." - fi - - case "$reply" in - ""|y|Y|yes|YES|Yes) - return 0 - ;; - n|N|no|NO|No) - return 1 - ;; - *) - die "Invalid response: $reply. Use y or n." - ;; - esac -} - -ensure_directory_exists() { - local path="$1" - local label="$2" - - if [[ "$DRY_RUN" == "true" ]]; then - echo " [DRY-RUN] Would create $label: $path" - return - fi - - mkdir -p "$path" -} - -write_setup_file() { - local path="$1" - local content="$2" - local label="$3" - - if [[ "$DRY_RUN" == "true" ]]; then - echo " [DRY-RUN] Would write $label: $path" - return - fi - - mkdir -p "$(dirname "$path")" - printf "%s\n" "$content" > "$path" - log_success " Wrote $label: $path" -} - -remove_setup_file_if_present() { - local path="$1" - local label="$2" - - if [[ ! -e "$path" ]]; then - if [[ "$DRY_RUN" == "true" ]]; then - echo " [DRY-RUN] No $label to remove: $path" - fi - return - fi - - if [[ "$DRY_RUN" == "true" ]]; then - echo " [DRY-RUN] Would remove $label: $path" - return - fi - - rm -f "$path" - log_success " Removed $label: $path" -} - -select_setup_agent() { - local choice="" - - echo "Select default agent:" - echo "1) claude" - echo "2) codex" - echo "3) kimi" - echo "4) pi" - echo "5) skip" - echo "" - - printf "Choice: " - if ! read -r choice; then - die "No setup agent selected." - fi - - case "$choice" in - 1|claude|Claude) - AGENT="claude" - AGENT_SOURCE="setup selection" - SETUP_AGENT_FILE_VALUE="claude" - ;; - 2|codex|Codex) - AGENT="codex" - AGENT_SOURCE="setup selection" - SETUP_AGENT_FILE_VALUE="codex" - ;; - 3|kimi|Kimi) - AGENT="kimi" - AGENT_SOURCE="setup selection" - SETUP_AGENT_FILE_VALUE="kimi" - ;; - 4|pi|Pi) - AGENT="pi" - AGENT_SOURCE="setup selection" - SETUP_AGENT_FILE_VALUE="pi" - ;; - 5|skip|Skip|"") - AGENT="claude" - AGENT_SOURCE="built-in default" - SETUP_AGENT_FILE_VALUE="" - ;; - *) - die "Invalid setup choice: $choice. Use 1-5." - ;; - esac -} - -resolve_setup_prompt_template() { - if [[ "$AGENT" == "claude" ]]; then - PROMPT_TEMPLATE=$(default_claude_prompt_template) - PROMPT_SOURCE="built-in Claude command" - else - PROMPT_TEMPLATE=$(default_portable_prompt_template) - PROMPT_SOURCE="built-in portable prompt" - fi -} - -show_setup_prompt_preview() { - echo "Default prompt:" - echo "" - printf "%s\n" "$PROMPT_TEMPLATE" - echo "" -} - -run_setup_flow() { - local target_dir="$USER_CONFIG_DIR" - local agent_file="$target_dir/agent" - local prompt_file="$target_dir/prompt.md" - - ensure_directory_exists "$target_dir" "user config directory" - select_setup_agent - resolve_setup_prompt_template - show_setup_prompt_preview - - if confirm_yes_default "Save this prompt to $prompt_file? [Y/n] "; then - SETUP_SAVE_PROMPT=true - else - SETUP_SAVE_PROMPT=false - fi - - echo "Directory: $target_dir" - echo "Agent: ${SETUP_AGENT_FILE_VALUE:-}" - echo "Prompt source: $PROMPT_SOURCE" - echo "" - - if [[ -n "$SETUP_AGENT_FILE_VALUE" ]]; then - write_setup_file "$agent_file" "$SETUP_AGENT_FILE_VALUE" "agent config" - else - remove_setup_file_if_present "$agent_file" "agent config" - fi - - if [[ "$SETUP_SAVE_PROMPT" == "true" ]]; then - write_setup_file "$prompt_file" "$PROMPT_TEMPLATE" "prompt template" - else - remove_setup_file_if_present "$prompt_file" "prompt template" - fi -} - -materialize_first_run_marker() { - ensure_directory_exists "$USER_CONFIG_DIR" "user config directory" -} - -resolve_init_model() { - local model_file="$1" - - if [[ -f "$model_file" && "$INIT_FORCE" != "true" ]]; then - MODEL=$(read_first_config_value "$model_file") - MODEL_SOURCE="$model_file (existing)" - elif [[ -n "$MODEL_CLI" ]]; then - MODEL=$(trim "$MODEL_CLI") - MODEL_SOURCE="CLI" - else - MODEL="" - MODEL_SOURCE="built-in default" - fi - - if [[ -n "$MODEL_SOURCE" && "$MODEL_SOURCE" != "built-in default" ]]; then - validate_model_config - fi -} - -resolve_init_agent() { - local agent_file="$1" - - if [[ -f "$agent_file" && "$INIT_FORCE" != "true" ]]; then - AGENT=$(read_first_config_value "$agent_file") - AGENT_SOURCE="$agent_file (existing)" - elif [[ -n "$AGENT_CLI" ]]; then - AGENT=$(trim "$AGENT_CLI") - AGENT_SOURCE="CLI" - else - AGENT="claude" - AGENT_SOURCE="built-in default" - fi - - if [[ -z "$AGENT" ]]; then - die "Agent config is empty. Valid agents: claude, codex, kimi, pi, none." - fi - - validate_agent -} - -resolve_init_prompt_template() { - if [[ -n "$PROMPT_FILE_CLI" && -n "$PROMPT_INLINE_CLI" ]]; then - die "Use either --prompt-file or --prompt, not both." - fi - - if [[ -n "$PROMPT_FILE_CLI" ]]; then - read_prompt_file "$PROMPT_FILE_CLI" - PROMPT_SOURCE="CLI --prompt-file: $PROMPT_FILE_CLI" - elif [[ -n "$PROMPT_INLINE_CLI" ]]; then - PROMPT_TEMPLATE="$PROMPT_INLINE_CLI" - PROMPT_SOURCE="CLI --prompt" - elif [[ "$AGENT" == "claude" ]]; then - PROMPT_TEMPLATE=$(default_claude_prompt_template) - PROMPT_SOURCE="built-in Claude command" - else - PROMPT_TEMPLATE=$(default_portable_prompt_template) - PROMPT_SOURCE="built-in portable prompt" - fi -} - -select_init_scope() { - local project_available=false - local choice="" - - if git rev-parse --git-dir &> /dev/null; then - detect_project_root - project_available=true - fi - - echo "Initialize start-issue configuration:" - if [[ "$project_available" == "true" ]]; then - echo " 1) Project config ($PROJECT_ROOT/.start-issue)" - echo " 2) User config ($HOME/.config/start-issue)" - if ! read -r -p "Choice [1/2]: " choice; then - die "No init scope selected. Use --project or --user." - fi - - case "$choice" in - 1|p|P|project|Project) - INIT_SCOPE="project" - ;; - 2|u|U|user|User) - INIT_SCOPE="user" - ;; - *) - die "Invalid init scope: $choice. Use --project or --user." - ;; - esac - else - echo " 1) User config ($HOME/.config/start-issue)" - if ! read -r -p "Choice [1]: " choice; then - die "No init scope selected. Use --user outside a git repository." - fi - - case "$choice" in - ""|1|u|U|user|User) - INIT_SCOPE="user" - ;; - *) - die "Project config requires a git repository. Use --user outside a git repository." - ;; - esac - fi -} - -write_init_file() { - local path="$1" - local content="$2" - local label="$3" - - if [[ -e "$path" && "$INIT_FORCE" != "true" ]]; then - log_warn "$label already exists, keeping: $path" - return - fi - - if [[ "$DRY_RUN" == "true" ]]; then - echo " [DRY-RUN] Would write $label: $path" - return - fi - - mkdir -p "$(dirname "$path")" - printf "%s\n" "$content" > "$path" - log_success " Wrote $label: $path" -} - -write_init_model_file() { - local path="$1" - - if [[ -n "$MODEL" ]]; then - write_init_file "$path" "$MODEL" "model config" - return - fi - - if [[ -e "$path" && "$INIT_FORCE" != "true" ]]; then - log_warn "model config already exists, keeping: $path" - return - fi - - if [[ "$DRY_RUN" == "true" ]]; then - if [[ -e "$path" ]]; then - echo " [DRY-RUN] Would remove model config: $path" - else - echo " [DRY-RUN] No model config to write (built-in default: unset)" - fi - return - fi - - if [[ -e "$path" ]]; then - rm -f "$path" - log_success " Removed model config: $path" - fi -} - -run_config_init() { - if [[ -z "$INIT_SCOPE" ]]; then - select_init_scope - fi - - case "$INIT_SCOPE" in - project) - check_git_repo - detect_project_root - ;; - user) ;; - *) - die "Invalid init scope: $INIT_SCOPE. Use --project or --user." - ;; - esac - - local target_dir="" - local scope_label="" - - if [[ "$INIT_SCOPE" == "project" ]]; then - target_dir="$PROJECT_ROOT/.start-issue" - scope_label="project config" - else - target_dir="$HOME/.config/start-issue" - scope_label="user config" - fi - - resolve_init_agent "$target_dir/agent" - resolve_init_model "$target_dir/model" - validate_model_selection_support "launch" - resolve_init_prompt_template - - echo "Scope: $scope_label" - echo "Directory: $target_dir" - echo "Agent: $AGENT" - echo "Agent source: $AGENT_SOURCE" - echo "Model: ${MODEL:-}" - echo "Model source: $MODEL_SOURCE" - echo "Prompt source: $PROMPT_SOURCE" - echo "" - - write_init_file "$target_dir/agent" "$AGENT" "agent config" - write_init_model_file "$target_dir/model" - write_init_file "$target_dir/prompt.md" "$PROMPT_TEMPLATE" "prompt template" -} - -run_setup_mode() { - run_setup_flow -} diff --git a/scripts/lib/start_issue/output.sh b/scripts/lib/start_issue/output.sh deleted file mode 100644 index 0f394ac..0000000 --- a/scripts/lib/start_issue/output.sh +++ /dev/null @@ -1,439 +0,0 @@ -# shellcheck shell=bash disable=SC2153 -model_display_value() { - if [[ -n "$MODEL" ]]; then - printf "%s" "$MODEL" - else - printf "%s" "" - fi -} - -show_help() { - show_version - cat << 'EOF' - -Start working on a GitHub issue with git worktree and a configurable agent - -Usage: start-issue [options] - start-issue init [options] - start-issue setup [options] - start-issue update [options] - -Arguments: - GitHub issue URL or issue number - Examples: 123, https://github.com/owner/repo/issues/123 - init Create default start-issue configuration - setup Run first-run user configuration onboarding - update Update the running start-issue installation - -Options: - --repo, -r Repository (default: detected from git remote) - --base, -b Base branch (default: main or master) - --worktree-dir, -w Directory for worktrees - Default: START_ISSUE_WORKTREE_DIR or ~/worktrees - --flat Use flat worktree structure (replace / with - in path) - --agent Agent to launch: claude, codex, kimi, pi, none - With init: default agent to write - --model Model to use for the selected agent - With init: default model config to write - --no-agent Only create worktree, do not start an agent session - --no-claude Compatibility alias for --no-agent - --prompt Prompt template for the launched agent - --prompt-file Prompt template file for the launched agent - --improve-prompt Ask the selected agent to improve the selected - prompt template and write a reviewable proposal - --human-gate Codex-only batch mode that resumes on HUMAN_GATE - --human-gate-help Show detailed help for the human-gate mode - --prompt-output-file - Output path for --improve-prompt proposal - --no-init Skip init.sh execution - --command, -c Compatibility: initial command for Claude default launch - --ai Use the selected agent for branch name generation - Default: fast bash heuristics - --project With init: write .start-issue config in this repo - --user With init: write config in ~/.config/start-issue - --force With init: overwrite existing config files - --dry-run Show what would be done without executing - --setup Run first-run user configuration onboarding - --update Update the running start-issue installation - --version, -v Show version - --help, -h Show this help - -Agent selection precedence: - CLI --agent / --no-agent - .start-issue/agent in the git root - ~/.config/start-issue/agent - START_ISSUE_AGENT - built-in default: claude - -Model selection precedence: - CLI --model - .start-issue/model in the git root - ~/.config/start-issue/model - START_ISSUE_MODEL - built-in default: unset (agent CLI decides) - -Prompt template precedence: - CLI --prompt-file / --prompt - .start-issue/prompt.md in the git root - ~/.config/start-issue/prompt.md - START_ISSUE_PROMPT_FILE / START_ISSUE_PROMPT - built-in default - -Prompt improvement: - --improve-prompt uses the selected agent to generate a complete improved - prompt template proposal. It does not overwrite the active prompt template. - File-backed prompts write next to the source as *.improved.md by default. - Built-in and inline prompts write to .start-issue/prompt.improved.md by - default. Use --prompt-output-file to choose another proposal path. - -Prompt variables: - {ISSUE_URL}, {ISSUE_NUMBER}, {ISSUE_TITLE}, {ISSUE_BODY}, {ISSUE_LABELS}, - {REPO}, {BRANCH_NAME}, {WORKTREE_PATH}, {BASE_BRANCH} - -Environment variables: - START_ISSUE_AGENT - START_ISSUE_MODEL - START_ISSUE_PROMPT - START_ISSUE_PROMPT_FILE - START_ISSUE_WORKTREE_DIR - START_ISSUE_DUMP_PROMPT - -Examples: - start-issue 123 - start-issue https://github.com/owner/repo/issues/123 - start-issue 123 --repo owner/repo --base develop - start-issue 123 --agent codex - start-issue 123 --agent codex --model gpt-5.2 - start-issue 123 --agent codex --human-gate - start-issue 123 --agent claude --model sonnet - start-issue 123 --agent kimi --prompt-file .start-issue/prompt.md - start-issue 123 --no-agent # Only create worktree - start-issue 123 --command "/debug" # Claude command prefix - start-issue 123 --flat # Flat worktree path - start-issue 123 --dry-run - start-issue init - start-issue setup - start-issue init --project --agent codex --model gpt-5.2 - start-issue init --project --agent codex - start-issue init --user --force - start-issue --setup - start-issue update - start-issue --update - start-issue --human-gate-help -EOF -} - -show_human_gate_help() { - show_version - cat << 'EOF' - -Codex human-gate mode - -Usage: - start-issue --agent codex --human-gate - start-issue --human-gate-help - -Flow: - 1. Resolve the issue, repo, branch, worktree, and prompt exactly like the normal issue flow. - 2. Create or reuse the worktree and run init.sh when enabled. - 3. Render the selected prompt. - 4. Run Codex in non-interactive batch mode with JSON events and a saved last-message file. - 5. Parse the saved final status. - 6. Exit 0 on STATUS: DONE. - 7. Resume the same Codex session with codex resume --include-non-interactive on STATUS: HUMAN_GATE. - -Prompt contract: - The final output must contain exactly one terminal status line: - STATUS: DONE - or: - STATUS: HUMAN_GATE - - DONE means the issue was completed safely without user intervention. - HUMAN_GATE means Codex must stop and ask one concrete user decision. - Do not use HUMAN_GATE for ordinary implementation uncertainty that can be - resolved from repository conventions or local evidence. - -Suggested final output shape for DONE: - STATUS: DONE - - Summary: - - ... - - Validation: - - ... - - Changed files: - - ... - -Suggested final output shape for HUMAN_GATE: - STATUS: HUMAN_GATE - - Blocker: - ... - - Question: - ... - - Options: - - Option A: ... - - Option B: ... - - Recommendation: - ... - -Exit codes: - 0 Codex returned STATUS: DONE. - 1 Codex failed, no thread_id was captured, no recognized status was found, - or parsing failed. - 2 Codex returned STATUS: HUMAN_GATE but start-issue could not open - interactive resume. The command and thread id are printed for manual use. - -State artifacts: - /.start-issue/runs//events.jsonl - /.start-issue/runs//last-message.txt - /.start-issue/runs//thread-id - -Examples: - start-issue 123 --agent codex --human-gate - start-issue https://github.com/owner/repo/issues/123 --agent codex --human-gate - start-issue --human-gate-help - -Troubleshooting: - - If status parsing fails, inspect last-message.txt. - - If resume cannot be opened automatically, re-run the printed - codex resume --include-non-interactive command. - - This mode is Codex-only in the current implementation. -EOF -} - -show_current_configuration() { - echo "Current configuration:" - echo " Agent: $AGENT" - echo " Agent source: $AGENT_SOURCE" - echo " Model: $(model_display_value)" - echo " Model source: $MODEL_SOURCE" - echo " Prompt source: $PROMPT_SOURCE" - echo " Prompt location: $PROMPT_LOCATION" - print_agent_model_file_locations " " - echo " Worktree dir: $WORKTREE_DIR ($WORKTREE_DIR_SOURCE)" - print_prompt_file_locations " " -} - -show_missing_issue_summary() { - echo "Error: missing issue URL or issue number" - echo "" - echo "Run \`start-issue --help\` for full usage and prompt variables." -} - -show_missing_issue_help() { - show_version - cat << 'EOF' - -Start working on a GitHub issue with git worktree and a configurable agent - -Usage: start-issue [options] - start-issue init [options] - start-issue setup [options] - start-issue update [options] - -Examples: - start-issue 123 - start-issue https://github.com/owner/repo/issues/123 - start-issue 123 --agent codex - start-issue setup - start-issue init --project - -Prompt variables: - {ISSUE_URL}, {ISSUE_NUMBER}, {ISSUE_TITLE}, {ISSUE_BODY}, {ISSUE_LABELS}, - {REPO}, {BRANCH_NAME}, {WORKTREE_PATH}, {BASE_BRANCH} -EOF -} - -show_first_run_onboarding_prompt() { - echo "Configuration is not initialized yet." - echo "" - echo "Usage: start-issue [options]" - echo "" -} - -print_agent_model_file_locations() { - local indent="${1:-}" - - echo "${indent}Default agent/model files:" - echo "${indent} Project agent: $PROJECT_ROOT/.start-issue/agent" - echo "${indent} Project model: $PROJECT_ROOT/.start-issue/model" - echo "${indent} User agent: $HOME/.config/start-issue/agent" - echo "${indent} User model: $HOME/.config/start-issue/model" -} - -print_prompt_file_locations() { - local indent="${1:-}" - - echo "${indent}Default prompt files:" - echo "${indent} Project: $PROJECT_ROOT/.start-issue/prompt.md" - echo "${indent} User: $HOME/.config/start-issue/prompt.md" -} - -print_terminal_status() { - echo " $1" -} - -print_session_header() { - local term_width - local min_width=60 - local display_path="${WORKTREE_PATH/#$HOME/\~}" - local line1="Agent: $AGENT" - local line2="Branch: $BRANCH_NAME" - local line3="Issue: #$ISSUE_NUMBER - $ISSUE_TITLE" - local line4="Path: $display_path" - local max_content_width - local h_line="" - local i - - term_width=$(tput cols 2>/dev/null || echo 80) - [[ $term_width -lt $min_width ]] && term_width=$min_width - - max_content_width=$((term_width - 4)) - [[ ${#line2} -gt $max_content_width ]] && line2="${line2:0:$((max_content_width - 3))}..." - [[ ${#line3} -gt $max_content_width ]] && line3="${line3:0:$((max_content_width - 3))}..." - [[ ${#line4} -gt $max_content_width ]] && line4="${line4:0:$((max_content_width - 3))}..." - - for ((i = 0; i < term_width - 2; i++)); do - h_line+="─" - done - - pad_line() { - local text="$1" - local padding=$((term_width - 4 - ${#text})) - printf "│ %s%*s │\n" "$text" "$padding" "" - } - - echo "" - printf "╭%s╮\n" "$h_line" - pad_line "$line1" - printf "├%s┤\n" "$h_line" - pad_line "$line2" - pad_line "$line3" - pad_line "$line4" - printf "╰%s╯\n" "$h_line" - echo "" -} - -print_manual_next_steps() { - log_success "✅ Worktree ready at: $WORKTREE_PATH" - echo "" - echo "Selected agent: none ($AGENT_SOURCE)" - echo "Resolved model: $(model_display_value) ($MODEL_SOURCE)" - echo "Prompt source: $PROMPT_SOURCE" - echo "To start working:" - echo " cd $(shell_join "$WORKTREE_PATH")" - echo "" - echo "Suggested agent commands:" - echo " claude" - echo " codex --cd $(shell_join "$WORKTREE_PATH")" - echo " kimi --work-dir $(shell_join "$WORKTREE_PATH")" - echo " pi" -} - -print_dry_run_launch_command() { - local cmd="" - - build_launch_command - - echo " Agent: $AGENT" - echo " Agent source: $AGENT_SOURCE" - echo " Model: $(model_display_value)" - echo " Model source: $MODEL_SOURCE" - echo " Prompt source: $PROMPT_SOURCE" - echo " Prompt length: ${#AGENT_PROMPT} chars" - - if [[ ${#AGENT_PROMPT} -gt 4000 && "${START_ISSUE_DUMP_PROMPT:-}" != "1" ]]; then - echo " Prompt omitted from command display because it is large." - echo " Set START_ISSUE_DUMP_PROMPT=1 to print the full rendered prompt." - case "$AGENT" in - claude) - if [[ -n "$MODEL" ]]; then - cmd=$(shell_join claude --model "$MODEL" --dangerously-skip-permissions "") - else - cmd=$(shell_join claude --dangerously-skip-permissions "") - fi - ;; - codex) - if [[ -n "$MODEL" ]]; then - cmd=$(shell_join codex --model "$MODEL" --cd "$WORKTREE_PATH" --dangerously-bypass-approvals-and-sandbox "") - else - cmd=$(shell_join codex --cd "$WORKTREE_PATH" --dangerously-bypass-approvals-and-sandbox "") - fi - ;; - kimi) - if [[ -n "$MODEL" ]]; then - cmd=$(shell_join kimi --model "$MODEL" --work-dir "$WORKTREE_PATH" --yolo -p "") - else - cmd=$(shell_join kimi --work-dir "$WORKTREE_PATH" --yolo -p "") - fi - ;; - pi) - if [[ -n "$MODEL" ]]; then - cmd=$(shell_join pi --model "$MODEL" "") - else - cmd=$(shell_join pi "") - fi - ;; - *) - cmd="" - ;; - esac - else - cmd=$(shell_join "${LAUNCH_CMD[@]}") - fi - - if [[ -n "$LAUNCH_CWD" ]]; then - echo " [DRY-RUN] Would run: cd $(shell_join "$LAUNCH_CWD") && $cmd" - else - echo " [DRY-RUN] Would run: $cmd" - fi -} - -print_dry_run_human_gate_command() { - local cmd="" - - build_human_gate_command - - echo " Agent: $AGENT" - echo " Agent source: $AGENT_SOURCE" - echo " Model: $(model_display_value)" - echo " Model source: $MODEL_SOURCE" - echo " Prompt source: $PROMPT_SOURCE" - echo " Prompt length: ${#AGENT_PROMPT} chars" - echo " State dir: $HUMAN_GATE_STATE_DIR" - echo " Events file: $HUMAN_GATE_EVENTS_PATH" - echo " Last message file: $HUMAN_GATE_LAST_MESSAGE_PATH" - echo " Thread id file: $HUMAN_GATE_THREAD_ID_PATH" - - if [[ ${#AGENT_PROMPT} -gt 4000 && "${START_ISSUE_DUMP_PROMPT:-}" != "1" ]]; then - echo " Prompt omitted from command display because it is large." - echo " Set START_ISSUE_DUMP_PROMPT=1 to print the full rendered prompt." - if [[ -n "$MODEL" ]]; then - cmd=$(shell_join codex exec --model "$MODEL" --cd "$WORKTREE_PATH" --sandbox workspace-write --json --output-last-message "$HUMAN_GATE_LAST_MESSAGE_PATH" "") - else - cmd=$(shell_join codex exec --cd "$WORKTREE_PATH" --sandbox workspace-write --json --output-last-message "$HUMAN_GATE_LAST_MESSAGE_PATH" "") - fi - else - cmd="$(shell_join "${HUMAN_GATE_CMD[@]}") < <(rendered prompt via stdin)" - fi - - echo " [DRY-RUN] Would run: $cmd" -} - -print_selected_configuration() { - echo "Agent: $AGENT" - echo "Agent source: $AGENT_SOURCE" - echo "Model: $(model_display_value)" - echo "Model source: $MODEL_SOURCE" - echo "Worktree directory: $WORKTREE_DIR ($WORKTREE_DIR_SOURCE)" - echo "Prompt source: $PROMPT_SOURCE" - echo "Prompt location: $PROMPT_LOCATION" - print_agent_model_file_locations - print_prompt_file_locations - echo "" -} diff --git a/scripts/lib/start_issue/pipeline.sh b/scripts/lib/start_issue/pipeline.sh deleted file mode 100644 index 5f4197b..0000000 --- a/scripts/lib/start_issue/pipeline.sh +++ /dev/null @@ -1,96 +0,0 @@ -# shellcheck shell=bash disable=SC2153 -maybe_run_first_run_onboarding() { - if [[ -d "$USER_CONFIG_DIR" ]]; then - return - fi - - show_first_run_onboarding_prompt - - if confirm_yes_default "Run setup now? [Y/n] "; then - run_setup_flow - else - materialize_first_run_marker - fi - - echo "" -} - -start_agent_session() { - if [[ "$AGENT" == "none" ]]; then - print_manual_next_steps - return - fi - - if [[ "$HUMAN_GATE_MODE" == "true" ]]; then - run_codex_human_gate_session - return $? - fi - - log_info "🚀 Starting $AGENT agent session..." - - if [[ "$DRY_RUN" == "true" ]]; then - print_dry_run_launch_command - return - fi - - print_session_header - build_launch_command - - if [[ -n "$LAUNCH_CWD" ]]; then - cd "$LAUNCH_CWD" || exit - fi - - print_terminal_status "Handing off to $AGENT in $WORKTREE_PATH" - exec "${LAUNCH_CMD[@]}" -} - -handle_missing_issue_mode() { - if [[ "$IMPROVE_PROMPT" == "true" ]]; then - die "--improve-prompt requires . Example: start-issue 123 --improve-prompt" - fi - - detect_project_root_if_available - resolve_agent - resolve_model - resolve_prompt_template - show_missing_issue_summary - echo "" - show_missing_issue_help - echo "" - show_current_configuration - exit 1 -} - -run_start_issue_pipeline() { - check_core_dependencies - check_git_repo - detect_project_root - parse_issue_input "$ISSUE_INPUT" - detect_repo_from_remote - detect_base_branch - resolve_agent - resolve_model - validate_human_gate_mode - check_selected_agent_dependency - resolve_prompt_template - validate_prompt_improvement_mode - - print_selected_configuration - fetch_issue - - if [[ "$IMPROVE_PROMPT" == "true" ]]; then - improve_prompt_template - return - fi - - rename_zellij_tab - generate_branch_name - create_worktree - run_init_script - render_prompt_template - start_agent_session -} - -run_update_mode() { - run_update_pipeline -} diff --git a/scripts/lib/start_issue/release.sh b/scripts/lib/start_issue/release.sh deleted file mode 100644 index 595e427..0000000 --- a/scripts/lib/start_issue/release.sh +++ /dev/null @@ -1,139 +0,0 @@ -# shellcheck shell=bash disable=SC2034 -release_fetch() { - local url="$1" - local output="$2" - - if command -v curl >/dev/null 2>&1; then - if [[ "${RELEASE_FETCH_VERBOSE:-0}" == "1" ]]; then - curl -fL -v "$url" -o "$output" - else - curl -fsSL "$url" -o "$output" - fi - return - fi - - if command -v wget >/dev/null 2>&1; then - if [[ "${RELEASE_FETCH_VERBOSE:-0}" == "1" ]]; then - wget -O "$output" "$url" - else - wget -qO "$output" "$url" - fi - return - fi - - die "Neither curl nor wget is installed." -} - -release_sha256_file() { - local path="$1" - - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$path" | awk '{ print $1 }' - return - fi - - if command -v shasum >/dev/null 2>&1; then - shasum -a 256 "$path" | awk '{ print $1 }' - return - fi - - if command -v openssl >/dev/null 2>&1; then - openssl dgst -sha256 "$path" | awk '{ print $NF }' - return - fi - - die "No SHA-256 tool found. Install sha256sum, shasum, or openssl." -} - -release_normalize_version() { - local version="${1:-}" - version="${version#v}" - printf "%s" "$version" -} - -release_compare_versions() { - local left - local right - local i - local max_parts - local left_part - local right_part - local IFS=. - - left="$(release_normalize_version "${1:-}")" - right="$(release_normalize_version "${2:-}")" - - read -r -a left_parts <<< "$left" - read -r -a right_parts <<< "$right" - - max_parts="${#left_parts[@]}" - if [[ ${#right_parts[@]} -gt $max_parts ]]; then - max_parts="${#right_parts[@]}" - fi - - for ((i = 0; i < max_parts; i++)); do - left_part="${left_parts[i]:-0}" - right_part="${right_parts[i]:-0}" - - if ((10#$left_part > 10#$right_part)); then - printf "1" - return - fi - - if ((10#$left_part < 10#$right_part)); then - printf -- "-1" - return - fi - done - - printf "0" -} - -release_install_verified_asset() { - local asset_url="$1" - local checksum_url="$2" - local target_path="$3" - local tmpdir - local tmpfile - local checksum_file - local expected_checksum - local actual_checksum - local cleanup_cmd - - tmpdir="$(mktemp -d)" - printf -v cleanup_cmd 'rm -rf %q' "$tmpdir" - # shellcheck disable=SC2064 - trap "$cleanup_cmd" RETURN - - tmpfile="$tmpdir/start-issue" - checksum_file="$tmpdir/start-issue.sha256" - - if declare -F debug >/dev/null 2>&1; then - debug "Fetching $asset_url -> $tmpfile" - fi - release_fetch "$asset_url" "$tmpfile" || die "Failed to download release asset: $asset_url" - if declare -F debug >/dev/null 2>&1; then - debug "Fetching $checksum_url -> $checksum_file" - fi - release_fetch "$checksum_url" "$checksum_file" || die "Failed to download release checksum: $checksum_url" - - if declare -F debug >/dev/null 2>&1; then - debug "Verifying checksum" - fi - expected_checksum="$(awk '{ print $1; exit }' "$checksum_file")" - actual_checksum="$(release_sha256_file "$tmpfile")" - - if [[ -z "$expected_checksum" ]]; then - die "Downloaded checksum file is empty." - fi - - if [[ "$expected_checksum" != "$actual_checksum" ]]; then - die "Checksum verification failed." - fi - - if declare -F debug >/dev/null 2>&1; then - debug "Installing binary into $target_path" - fi - mkdir -p "$(dirname "$target_path")" - install -m 0755 "$tmpfile" "$target_path" || die "Failed to install updated release to $target_path" -} diff --git a/scripts/lib/start_issue/update.sh b/scripts/lib/start_issue/update.sh deleted file mode 100644 index d839028..0000000 --- a/scripts/lib/start_issue/update.sh +++ /dev/null @@ -1,104 +0,0 @@ -# shellcheck shell=bash disable=SC2034 -UPDATE_REPO="${START_ISSUE_REPOSITORY:-dapi/start-issue}" -UPDATE_MODE=false -LATEST_RELEASE_JSON="" -LATEST_RELEASE_TAG="" -LATEST_RELEASE_ASSET_URL="" -LATEST_RELEASE_CHECKSUM_URL="" -CURRENT_INSTALL_VERSION="" -CURRENT_INSTALL_VERSION_NORMALIZED="" -LATEST_RELEASE_VERSION_NORMALIZED="" - -check_update_dependencies() { - if ! command -v gh &> /dev/null; then - die "gh CLI not found. Install: https://cli.github.com" - fi - - if ! gh auth status &> /dev/null; then - die "gh not authenticated. Run: gh auth login" - fi - - if ! command -v jq &> /dev/null; then - die "jq not found. Please install jq." - fi - - if ! command -v install &> /dev/null; then - die "install command not found." - fi -} - -resolve_current_installation() { - CURRENT_INSTALL_VERSION="$VERSION" - CURRENT_INSTALL_VERSION_NORMALIZED="$(release_normalize_version "$CURRENT_INSTALL_VERSION")" -} - -fetch_latest_release_metadata() { - log_info "🔍 Resolving latest release for $UPDATE_REPO..." - - LATEST_RELEASE_JSON="$(gh api "repos/$UPDATE_REPO/releases/latest" 2>/dev/null)" || \ - die "Failed to resolve the latest GitHub release for $UPDATE_REPO." - - LATEST_RELEASE_TAG="$(printf "%s" "$LATEST_RELEASE_JSON" | jq -r '.tag_name // empty')" - LATEST_RELEASE_ASSET_URL="$(printf "%s" "$LATEST_RELEASE_JSON" | jq -r '.assets[] | select(.name == "start-issue") | .browser_download_url' | head -n 1)" - LATEST_RELEASE_CHECKSUM_URL="$(printf "%s" "$LATEST_RELEASE_JSON" | jq -r '.assets[] | select(.name == "start-issue.sha256") | .browser_download_url' | head -n 1)" - - if [[ -z "$LATEST_RELEASE_TAG" ]]; then - die "Latest release metadata for $UPDATE_REPO did not include a tag name." - fi - - if [[ -z "$LATEST_RELEASE_ASSET_URL" ]]; then - die "Latest release $LATEST_RELEASE_TAG does not include a start-issue asset." - fi - - if [[ -z "$LATEST_RELEASE_CHECKSUM_URL" ]]; then - die "Latest release $LATEST_RELEASE_TAG does not include a start-issue.sha256 asset." - fi - - LATEST_RELEASE_VERSION_NORMALIZED="$(release_normalize_version "$LATEST_RELEASE_TAG")" -} - -print_update_status() { - echo "Executable: $SCRIPT_PATH" - echo "Installed version: v$CURRENT_INSTALL_VERSION_NORMALIZED" - echo "Latest release: $LATEST_RELEASE_TAG" -} - -run_update_pipeline() { - local comparison - local installed_version_output - - check_update_dependencies - resolve_current_installation - fetch_latest_release_metadata - - echo "" - print_update_status - - comparison="$(release_compare_versions "$CURRENT_INSTALL_VERSION_NORMALIZED" "$LATEST_RELEASE_VERSION_NORMALIZED")" - - if [[ "$comparison" == "0" ]]; then - log_success "✅ start-issue is already up to date." - return - fi - - if [[ "$comparison" == "1" ]]; then - log_success "✅ Installed version is newer than the latest published release. No update needed." - return - fi - - if [[ "$DRY_RUN" == "true" ]]; then - echo " [DRY-RUN] Would download: $LATEST_RELEASE_ASSET_URL" - echo " [DRY-RUN] Would verify with: $LATEST_RELEASE_CHECKSUM_URL" - echo " [DRY-RUN] Would install to: $SCRIPT_PATH" - return - fi - - log_info "📥 Downloading and installing $LATEST_RELEASE_TAG..." - release_install_verified_asset "$LATEST_RELEASE_ASSET_URL" "$LATEST_RELEASE_CHECKSUM_URL" "$SCRIPT_PATH" - - installed_version_output="$("$SCRIPT_PATH" --version 2>/dev/null)" || \ - die "Updated executable installed, but version verification failed." - - log_success "✅ Updated start-issue at: $SCRIPT_PATH" - echo "Version: $installed_version_output" -} diff --git a/scripts/lib/start_issue/utils.sh b/scripts/lib/start_issue/utils.sh deleted file mode 100644 index e9c90bd..0000000 --- a/scripts/lib/start_issue/utils.sh +++ /dev/null @@ -1,132 +0,0 @@ -# shellcheck shell=bash disable=SC2034 -log_info() { - echo -e "${BLUE}$1${NC}" -} - -log_success() { - echo -e "${GREEN}$1${NC}" -} - -log_warn() { - echo -e "${YELLOW}⚠️ $1${NC}" -} - -log_error() { - echo -e "${RED}❌ $1${NC}" >&2 -} - -die() { - log_error "$1" - exit 1 -} - -show_version() { - echo "start-issue v$VERSION" -} - -shell_join() { - local out="" - local quoted - local arg - - for arg in "$@"; do - printf -v quoted "%q" "$arg" - if [[ -n "$out" ]]; then - out+=" " - fi - out+="$quoted" - done - - printf "%s" "$out" -} - -trim() { - local value="$1" - value="${value#"${value%%[![:space:]]*}"}" - value="${value%"${value##*[![:space:]]}"}" - printf "%s" "$value" -} - -absolute_path() { - local path="$1" - - if [[ "$path" == /* ]]; then - printf "%s" "$path" - else - printf "%s/%s" "$(pwd)" "$path" - fi -} - -canonicalize_existing_path() { - local path="$1" - - if [[ -d "$path" ]]; then - (cd "$path" && pwd -P) - else - printf "%s" "$path" - fi -} - -read_first_config_value() { - local file="$1" - awk ' - { - sub(/#.*/, "") - gsub(/^[[:space:]]+|[[:space:]]+$/, "") - } - NF { - print - exit - } - ' "$file" -} - -prompt_preview() { - local preview="$PROMPT_TEMPLATE" - - preview="${preview//$'\n'/ }" - preview=$(printf "%s" "$preview" | sed 's/[[:space:]][[:space:]]*/ /g') - preview=$(trim "$preview") - - if [[ ${#preview} -gt 140 ]]; then - preview="${preview:0:137}..." - fi - - printf "%s" "$preview" -} - -check_core_dependencies() { - if ! command -v git &> /dev/null; then - die "git not found. Please install git." - fi - - if ! command -v gh &> /dev/null; then - die "gh CLI not found. Install: https://cli.github.com" - fi - - if ! gh auth status &> /dev/null; then - die "gh not authenticated. Run: gh auth login" - fi - - if ! command -v jq &> /dev/null; then - die "jq not found. Please install jq." - fi -} - -check_git_repo() { - if ! git rev-parse --git-dir &> /dev/null; then - die "Not in a git repository" - fi -} - -detect_project_root() { - PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) -} - -detect_project_root_if_available() { - PROJECT_ROOT="$(pwd)" - - if command -v git &> /dev/null && git rev-parse --git-dir &> /dev/null; then - detect_project_root - fi -} diff --git a/scripts/lib/start_issue/worktree.sh b/scripts/lib/start_issue/worktree.sh deleted file mode 100644 index b4fa98d..0000000 --- a/scripts/lib/start_issue/worktree.sh +++ /dev/null @@ -1,403 +0,0 @@ -# shellcheck shell=bash disable=SC2034 -sanitize_branch_slug() { - local input="$1" - local slug - - # Strip leading bracketed process/stage tags ([brief], [brief][investigation], ...), then - # transliterate Cyrillic -> Latin (BGN/PCGN-style: Х->Kh, Ц->Ts, Щ->Shch, Й->Y; - # soft/hard signs ъ/ь are dropped, not substituted). Two invariants: - # - order matters: multi-letter digraphs must precede the single-letter rules; - # - LC_ALL must be a UTF-8 locale or sed won't match the multibyte Cyrillic. - # The tail lowercases, collapses non-alnum runs to single dashes, caps at 40 - # chars, and trims leading/trailing dashes; an empty result falls back to "work". - slug=$(printf "%s" "$input" | LC_ALL=en_US.UTF-8 sed 's/^\(\[[^]]*\][[:space:]-]*\)*//' | \ - LC_ALL=en_US.UTF-8 sed ' -s/Щ/Shch/g; s/щ/shch/g; -s/Ж/Zh/g; s/ж/zh/g; -s/Х/Kh/g; s/х/kh/g; -s/Ц/Ts/g; s/ц/ts/g; -s/Ч/Ch/g; s/ч/ch/g; -s/Ш/Sh/g; s/ш/sh/g; -s/Ю/Yu/g; s/ю/yu/g; -s/Я/Ya/g; s/я/ya/g; -s/Ё/Yo/g; s/ё/yo/g; -s/А/A/g; s/а/a/g; -s/Б/B/g; s/б/b/g; -s/В/V/g; s/в/v/g; -s/Г/G/g; s/г/g/g; -s/Д/D/g; s/д/d/g; -s/Е/E/g; s/е/e/g; -s/З/Z/g; s/з/z/g; -s/И/I/g; s/и/i/g; -s/Й/Y/g; s/й/y/g; -s/К/K/g; s/к/k/g; -s/Л/L/g; s/л/l/g; -s/М/M/g; s/м/m/g; -s/Н/N/g; s/н/n/g; -s/О/O/g; s/о/o/g; -s/П/P/g; s/п/p/g; -s/Р/R/g; s/р/r/g; -s/С/S/g; s/с/s/g; -s/Т/T/g; s/т/t/g; -s/У/U/g; s/у/u/g; -s/Ф/F/g; s/ф/f/g; -s/Ы/Y/g; s/ы/y/g; -s/Э/E/g; s/э/e/g; -s/Ъ//g; s/ъ//g; -s/Ь//g; s/ь//g; -' | \ - tr '[:upper:]' '[:lower:]' | \ - sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | cut -c1-40 | sed 's/^-*//' | sed 's/-*$//') - - if [[ -z "$slug" ]]; then - slug="work" - fi - - printf "%s" "$slug" -} - -generate_fast_branch_name() { - local branch_type="feature" - local short_name - - if [[ "$ISSUE_LABELS" =~ (hotfix|critical|urgent) ]]; then - branch_type="hotfix" - elif [[ "$ISSUE_LABELS" =~ (bug|fix|bugfix|error) ]]; then - branch_type="fix" - elif [[ "$ISSUE_LABELS" =~ (docs|documentation) ]]; then - branch_type="docs" - elif [[ "$ISSUE_LABELS" =~ (refactor|tech-debt|cleanup|technical) ]]; then - branch_type="refactor" - elif [[ "$ISSUE_LABELS" =~ (test|testing|tests) ]]; then - branch_type="test" - elif [[ "$ISSUE_LABELS" =~ (chore|ci|build|infra) ]]; then - branch_type="chore" - fi - - short_name=$(sanitize_branch_slug "$ISSUE_TITLE") - - BRANCH_NAME="$branch_type/issue-$ISSUE_NUMBER-$short_name" -} - -validate_branch_name_or_fallback() { - local elapsed="$1" - - if [[ "$BRANCH_NAME" =~ ^(feature|fix|hotfix|refactor|docs|test|chore)/issue-[0-9]+-[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then - log_success " Branch: $BRANCH_NAME (${elapsed}s, ai:$AGENT)" - return - fi - - log_warn "Generated branch name doesn't match expected format: $BRANCH_NAME" - generate_fast_branch_name - log_info " Using fallback: $BRANCH_NAME (${elapsed}s)" -} - -generate_branch_name() { - log_info "🧠 Generating branch name..." - - local start_time=$SECONDS - local elapsed - - if [[ "$FAST_MODE" == "true" ]]; then - generate_fast_branch_name - elapsed=$((SECONDS - start_time)) - log_success " Branch: $BRANCH_NAME (${elapsed}s, fast)" - return - fi - - if generate_ai_branch_name; then - elapsed=$((SECONDS - start_time)) - validate_branch_name_or_fallback "$elapsed" - else - elapsed=$((SECONDS - start_time)) - log_warn "Could not generate branch name with $AGENT; falling back to fast heuristic" - generate_fast_branch_name - log_info " Branch: $BRANCH_NAME (${elapsed}s, fast fallback)" - fi -} - -create_worktree() { - local existing_worktree="" - local path_branch="" - - plan_worktree_path - - log_info "📁 Creating worktree..." - echo " Path: $WORKTREE_PATH" - echo " Base: $BASE_BRANCH" - - if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME" 2>/dev/null; then - existing_worktree=$(find_worktree_path_by_branch "$BRANCH_NAME" || true) - if plan_branch_reuse_resolution "$existing_worktree"; then - return - fi - fi - - if [[ -d "$WORKTREE_PATH" ]]; then - path_branch=$(find_worktree_branch_by_path "$WORKTREE_PATH" || true) - if plan_path_reuse_resolution "$path_branch"; then - return - fi - fi - - if [[ "$DRY_RUN" == "true" ]]; then - echo " [DRY-RUN] Would run: git worktree add -b $BRANCH_NAME $WORKTREE_PATH $BASE_BRANCH" - return - fi - - mkdir -p "$(dirname "$WORKTREE_PATH")" - git fetch origin "$BASE_BRANCH" --quiet 2>/dev/null || true - - git worktree add -b "$BRANCH_NAME" "$WORKTREE_PATH" "origin/$BASE_BRANCH" || \ - git worktree add -b "$BRANCH_NAME" "$WORKTREE_PATH" "$BASE_BRANCH" || \ - die "Failed to create worktree" - - log_success " ✅ Worktree created" -} - -worktree_name_for_branch() { - local branch_name="$1" - local worktree_name="$branch_name" - - if [[ "$FLAT_WORKTREE" == "true" ]]; then - worktree_name="${branch_name//\//-}" - fi - - printf "%s" "$worktree_name" -} - -plan_worktree_path() { - WORKTREE_PATH="$WORKTREE_DIR/$(worktree_name_for_branch "$BRANCH_NAME")" -} - -worktree_branch_ref() { - local branch_name="$1" - printf "refs/heads/%s" "$branch_name" -} - -find_worktree_path_by_branch() { - local target_ref - local line - local current_path="" - - target_ref=$(worktree_branch_ref "$1") - - while IFS= read -r line; do - case "$line" in - worktree\ *) - current_path="${line#worktree }" - ;; - branch\ "$target_ref") - printf "%s" "$current_path" - return 0 - ;; - esac - done < <(git worktree list --porcelain) - - return 1 -} - -find_worktree_branch_by_path() { - local target_path="$1" - local line - local current_path="" - - target_path=$(canonicalize_existing_path "$target_path") - - while IFS= read -r line; do - case "$line" in - worktree\ *) - current_path="${line#worktree }" - ;; - branch\ *) - if [[ "$current_path" == "$target_path" ]]; then - printf "%s" "${line#branch }" - return 0 - fi - ;; - esac - done < <(git worktree list --porcelain) - - return 1 -} - -validate_reused_worktree() { - local registered_branch - - if [[ ! -d "$WORKTREE_PATH" ]]; then - die "Cannot reuse worktree path '$WORKTREE_PATH': directory does not exist." - fi - - registered_branch=$(find_worktree_branch_by_path "$WORKTREE_PATH" || true) - - if [[ -z "$registered_branch" ]]; then - die "Cannot reuse worktree path '$WORKTREE_PATH': path exists but is not a git worktree for this repository." - fi - - if [[ "$registered_branch" != "$(worktree_branch_ref "$BRANCH_NAME")" ]]; then - die "Cannot reuse worktree path '$WORKTREE_PATH': it belongs to branch '${registered_branch#refs/heads/}', not '$BRANCH_NAME'." - fi -} - -prompt_branch_conflict_resolution() { - local existing_worktree="$1" - - echo "" - log_warn "Branch '$BRANCH_NAME' already exists." - if [[ -n "$existing_worktree" ]]; then - echo " Existing worktree: $existing_worktree" - fi - echo "" - echo " 1) Use existing worktree and continue" - echo " 2) Create new branch with different name" - echo " 3) Delete branch/worktree and recreate" - echo " 0) Exit" - echo "" - print_terminal_status "Waiting for input: branch already exists" - read -r -n 1 -p "Choice: " CONFLICT_CHOICE - echo "" -} - -plan_branch_reuse_resolution() { - local existing_worktree="$1" - - prompt_branch_conflict_resolution "$existing_worktree" - - case "$CONFLICT_CHOICE" in - 1) - if [[ -z "$existing_worktree" ]]; then - die "No existing worktree found for branch '$BRANCH_NAME'. Use 3 to delete and recreate." - fi - WORKTREE_PATH="$existing_worktree" - validate_reused_worktree - log_info " Using existing worktree: $WORKTREE_PATH" - return 0 - ;; - 2) - local version=2 - local new_branch="${BRANCH_NAME}-v${version}" - - while git show-ref --verify --quiet "refs/heads/$new_branch" 2>/dev/null; do - ((version++)) - new_branch="${BRANCH_NAME}-v${version}" - done - - BRANCH_NAME="$new_branch" - plan_worktree_path - log_info " New branch name: $BRANCH_NAME" - return 1 - ;; - 3) - log_info " Removing existing branch/worktree..." - if [[ -n "$existing_worktree" ]]; then - git worktree remove --force "$existing_worktree" 2>/dev/null || rm -rf "$existing_worktree" - fi - git branch -D "$BRANCH_NAME" 2>/dev/null || true - log_success " ✅ Cleaned up" - return 1 - ;; - *) - die "Aborted" - ;; - esac -} - -prompt_path_conflict_resolution() { - local path_branch="$1" - - echo "" - log_warn "Worktree path already exists: $WORKTREE_PATH" - if [[ -n "$path_branch" ]]; then - echo " Registered branch: ${path_branch#refs/heads/}" - else - echo " Registered branch: none" - fi - echo "" - echo " 1) Use existing worktree" - echo " 2) Delete and recreate" - echo " 0) Exit" - echo "" - print_terminal_status "Waiting for input: worktree path already exists" - read -r -n 1 -p "Choice: " CONFLICT_CHOICE - echo "" -} - -plan_path_reuse_resolution() { - local path_branch="$1" - - prompt_path_conflict_resolution "$path_branch" - - case "$CONFLICT_CHOICE" in - 1) - validate_reused_worktree - log_info " Using existing worktree" - return 0 - ;; - 2) - log_info " Removing existing worktree..." - git worktree remove --force "$WORKTREE_PATH" 2>/dev/null || rm -rf "$WORKTREE_PATH" - if [[ -n "$path_branch" ]]; then - git branch -D "${path_branch#refs/heads/}" 2>/dev/null || true - else - git branch -D "$BRANCH_NAME" 2>/dev/null || true - fi - return 1 - ;; - *) - die "Aborted" - ;; - esac -} - -run_init_script() { - local init_script - - if [[ "$NO_INIT" == "true" ]]; then - log_info "⏭️ Skipping init.sh (--no-init)" - return - fi - - init_script="$WORKTREE_PATH/init.sh" - - if [[ ! -f "$init_script" ]]; then - log_warn "init.sh not found, skipping initialization" - return - fi - - log_info "⚙️ Running init.sh..." - - if [[ "$DRY_RUN" == "true" ]]; then - echo " [DRY-RUN] Would run: $init_script" - return - fi - - if ! (cd "$WORKTREE_PATH" && bash ./init.sh); then - log_warn "init.sh exited with non-zero code" - else - log_success " ✅ Done" - fi -} - -rename_zellij_tab() { - local tab_name="#$ISSUE_NUMBER" - - if [[ "$DRY_RUN" == "true" ]]; then - if command -v zellij-tab-status &> /dev/null; then - echo " [DRY-RUN] Would run: zellij-tab-status --set-name $(shell_join "$tab_name")" - else - echo " [DRY-RUN] Would skip zellij tab rename: zellij-tab-status not found" - fi - return - fi - - if ! command -v zellij-tab-status &> /dev/null; then - return - fi - - log_info "📑 Renaming zellij tab..." - if zellij-tab-status --set-name "$tab_name" &> /dev/null; then - log_success " ✅ Tab renamed to #$ISSUE_NUMBER" - else - log_warn "Could not rename zellij tab with zellij-tab-status" - fi -} diff --git a/scripts/prepare-release b/scripts/prepare-release deleted file mode 100755 index d5a8ea3..0000000 --- a/scripts/prepare-release +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: scripts/prepare-release - -This command: -1. requires a clean git worktree -2. bumps VERSION in scripts/start-issue -3. moves CHANGELOG.md Unreleased entries under the new version -4. runs make test and make build -5. creates a release commit and local annotated git tag - -Push the branch and tag afterwards with: - git push origin master --follow-tags -EOF -} - -update_changelog() { - local next_version="$1" - local release_date="$2" - local changelog_file="CHANGELOG.md" - local tmpfile - - if [[ ! -f "$changelog_file" ]]; then - echo "$changelog_file is missing." >&2 - return 1 - fi - - if ! grep -Eq '^## \[Unreleased\]$' "$changelog_file"; then - echo "$changelog_file must contain a '## [Unreleased]' section." >&2 - return 1 - fi - - if grep -Eq "^## \\[$next_version\\] -" "$changelog_file"; then - echo "$changelog_file already contains a section for $next_version." >&2 - return 1 - fi - - if ! awk ' - /^## \[Unreleased\]$/ { in_unreleased = 1; next } - in_unreleased && /^## \[/ { in_unreleased = 0 } - in_unreleased && $0 !~ /^[[:space:]]*$/ && $0 !~ /^###[[:space:]]/ { found = 1 } - END { exit found ? 0 : 1 } - ' "$changelog_file"; then - echo "$changelog_file has no entries under '## [Unreleased]'." >&2 - return 1 - fi - - tmpfile="$(mktemp)" - awk -v next_version="$next_version" -v release_date="$release_date" ' - /^## \[Unreleased\]$/ { - print - print "" - print "## [" next_version "] - " release_date - in_unreleased = 1 - next - } - - in_unreleased && /^## \[/ { - in_unreleased = 0 - } - - { print } - ' "$changelog_file" > "$tmpfile" - - mv "$tmpfile" "$changelog_file" -} - -if [[ $# -ne 1 ]]; then - usage >&2 - exit 1 -fi - -kind="$1" -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$repo_root" - -if [[ -n "$(git status --porcelain)" ]]; then - echo "Refusing to prepare a release from a dirty worktree." >&2 - echo "Commit or stash current changes first." >&2 - exit 1 -fi - -current_branch="$(git branch --show-current)" -if [[ "$current_branch" != "master" && "$current_branch" != "main" ]]; then - echo "Preparing a release from branch '$current_branch'." >&2 -fi - -trap 'git checkout -- scripts/start-issue CHANGELOG.md >/dev/null 2>&1 || true' ERR - -next_version="$(bash scripts/bump-version "$kind")" -tag="v$next_version" -if git rev-parse "$tag" >/dev/null 2>&1; then - echo "Tag $tag already exists." >&2 - false -fi - -release_date="$(date +%F)" - -update_changelog "$next_version" "$release_date" - -make test -make build - -git add scripts/start-issue CHANGELOG.md -git commit -m "Release $tag" -git tag -a "$tag" -m "Release $tag" - -printf 'Prepared %s\n' "$tag" -printf 'Next step: git push origin %s --follow-tags\n' "$current_branch" diff --git a/scripts/start-issue b/scripts/start-issue deleted file mode 100755 index 83e68f8..0000000 --- a/scripts/start-issue +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env bash -# -# start-issue - Start working on a GitHub issue with git worktree and an agent -# -# Usage: start-issue [options] -# start-issue init [options] -# start-issue setup [options] -# -# Options: -# --repo, -r Repository (default: from git remote) -# --base, -b Base branch (default: main or master) -# --worktree-dir, -w Worktree directory (default: ~/worktrees) -# --agent Agent to launch: claude, codex, kimi, pi, none -# --project With init, write .start-issue config in the repo -# --user With init, write config in ~/.config/start-issue -# --force With init, overwrite existing config files -# --no-agent Only prepare the worktree, do not launch an agent -# --no-init Skip init.sh execution -# --improve-prompt Generate an improved prompt template proposal -# --human-gate Run Codex in batch mode and resume on HUMAN_GATE -# --human-gate-help Show dedicated help for the human-gate mode -# --dry-run Show what would be done without executing -# --help, -h Show this help -# - -set -euo pipefail - -VERSION="1.13.3" - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -DEFAULT_WORKTREE_DIR="$HOME/worktrees" -WORKTREE_DIR_SOURCE="built-in default" -if [[ -n "${START_ISSUE_WORKTREE_DIR:-}" ]]; then - WORKTREE_DIR="$START_ISSUE_WORKTREE_DIR" - WORKTREE_DIR_SOURCE="START_ISSUE_WORKTREE_DIR" -else - WORKTREE_DIR="$DEFAULT_WORKTREE_DIR" -fi - -BASE_BRANCH="" -REPO="" -NO_INIT=false -DRY_RUN=false -FAST_MODE=true -FLAT_WORKTREE=false -INITIAL_COMMAND="" -ISSUE_INPUT="" -MISSING_ISSUE=false -PROJECT_ROOT="" -INIT_CONFIG=false -INIT_SCOPE="" -INIT_FORCE=false -SETUP_MODE=false -UPDATE_MODE=false - -AGENT="" -AGENT_CLI="" -AGENT_SOURCE="" -MODEL="" -MODEL_CLI="" -MODEL_SOURCE="" -PROMPT_TEMPLATE="" -PROMPT_SOURCE="" -PROMPT_LOCATION="" -PROMPT_TEMPLATE_PATH="" -PROMPT_FILE_CLI="" -PROMPT_INLINE_CLI="" -IMPROVE_PROMPT=false -PROMPT_IMPROVEMENT_OUTPUT_FILE="" -HUMAN_GATE_MODE=false -HUMAN_GATE_HELP=false -AGENT_PROMPT="" - -ISSUE_NUMBER="" -ISSUE_JSON="" -ISSUE_TITLE="" -ISSUE_BODY="" -ISSUE_LABELS="" -ISSUE_URL="" -BRANCH_NAME="" -WORKTREE_PATH="" - -LAUNCH_CWD="" -LAUNCH_CMD=() -HUMAN_GATE_CMD=() -CONFLICT_CHOICE="" -HUMAN_GATE_RUN_ID="" -HUMAN_GATE_STATE_DIR="" -HUMAN_GATE_EVENTS_PATH="" -HUMAN_GATE_LAST_MESSAGE_PATH="" -HUMAN_GATE_THREAD_ID_PATH="" -HUMAN_GATE_THREAD_ID="" -HUMAN_GATE_FINAL_STATUS="" - -resolve_script_path() { - local source_path="${BASH_SOURCE[0]}" - local source_dir - local target_path - - while [[ -L "$source_path" ]]; do - source_dir="$(cd -P "$(dirname "$source_path")" && pwd)" - target_path="$(readlink "$source_path")" - if [[ "$target_path" == /* ]]; then - source_path="$target_path" - else - source_path="$source_dir/$target_path" - fi - done - - source_dir="$(cd -P "$(dirname "$source_path")" && pwd)" - printf "%s/%s" "$source_dir" "$(basename "$source_path")" -} - -SCRIPT_PATH="$(resolve_script_path)" - -# BEGIN_MODULE_SOURCES -SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" -LIB_DIR="$SCRIPT_DIR/lib/start_issue" - -if [[ ! -d "$LIB_DIR" ]]; then - LIB_DIR="$SCRIPT_DIR/../lib/start_issue" -fi - -# shellcheck source=scripts/lib/start_issue/utils.sh -source "$LIB_DIR/utils.sh" -# shellcheck source=scripts/lib/start_issue/config.sh -source "$LIB_DIR/config.sh" -# shellcheck source=scripts/lib/start_issue/agent.sh -source "$LIB_DIR/agent.sh" -# shellcheck source=scripts/lib/start_issue/init.sh -source "$LIB_DIR/init.sh" -# shellcheck source=scripts/lib/start_issue/github.sh -source "$LIB_DIR/github.sh" -# shellcheck source=scripts/lib/start_issue/release.sh -source "$LIB_DIR/release.sh" -# shellcheck source=scripts/lib/start_issue/update.sh -source "$LIB_DIR/update.sh" -# shellcheck source=scripts/lib/start_issue/worktree.sh -source "$LIB_DIR/worktree.sh" -# shellcheck source=scripts/lib/start_issue/output.sh -source "$LIB_DIR/output.sh" -# shellcheck source=scripts/lib/start_issue/cli.sh -source "$LIB_DIR/cli.sh" -# shellcheck source=scripts/lib/start_issue/pipeline.sh -source "$LIB_DIR/pipeline.sh" -# END_MODULE_SOURCES - -main() { - parse_args "$@" - - if [[ "$HUMAN_GATE_HELP" == "true" ]]; then - show_human_gate_help - return - fi - echo -e "${BLUE}start-issue${NC} v$VERSION" - echo "" - - if [[ "$INIT_CONFIG" == "true" ]]; then - run_config_init - return - fi - - if [[ "$SETUP_MODE" == "true" ]]; then - run_setup_mode - return - fi - - if [[ "$UPDATE_MODE" == "true" ]]; then - run_update_mode - return - fi - - maybe_run_first_run_onboarding - - if [[ "$MISSING_ISSUE" == "true" ]]; then - handle_missing_issue_mode - fi - - run_start_issue_pipeline -} - -main "$@" diff --git a/test/e2e/human-gate.sh b/test/e2e/human-gate.sh index 7ebccbb..9f1efa2 100755 --- a/test/e2e/human-gate.sh +++ b/test/e2e/human-gate.sh @@ -42,7 +42,7 @@ done [[ "$scenario" == "done" || "$scenario" == "human-gate" ]] || fail "scenario must be done or human-gate" [[ "${START_ISSUE_E2E:-}" == "1" ]] || fail "set START_ISSUE_E2E=1 to authorize a real Codex session" -start_issue_bin="${START_ISSUE_E2E_BINARY:-$repo_root/scripts/start-issue}" +start_issue_bin="${START_ISSUE_E2E_BINARY:-$repo_root/.build/start-issue}" [[ -x "$start_issue_bin" ]] || fail "start-issue executable not found: $start_issue_bin" diff --git a/test/fixtures/issue-1.json b/test/fixtures/issue-1.json deleted file mode 100644 index 544f051..0000000 --- a/test/fixtures/issue-1.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "number": 1, - "title": "Add login button", - "body": "Add a login button to the header and keep the implementation scoped.", - "html_url": "https://github.com/owner/repo/issues/1", - "labels": [ - { - "name": "enhancement" - }, - { - "name": "ui" - } - ] -} diff --git a/test/helpers/fake-bin/claude b/test/helpers/fake-bin/claude deleted file mode 100755 index 57d66e1..0000000 --- a/test/helpers/fake-bin/claude +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ -n "${START_ISSUE_FAKE_EXPECT_MODEL:-}" && "$*" != *"--model ${START_ISSUE_FAKE_EXPECT_MODEL}"* ]]; then - printf "%s\n" "expected model flag --model ${START_ISSUE_FAKE_EXPECT_MODEL}" >&2 - exit 1 -fi - -# Assert the branch-name prompt contains EXPECT_PROMPT. Only the --print path is -# checked; the prompt-improvement path ("Improve the following ...") also uses -# --print but carries a different prompt, so it is excluded. -if [[ -n "${START_ISSUE_FAKE_EXPECT_PROMPT:-}" \ - && "$*" == *"--print"* \ - && "$*" != *"Improve the following"* \ - && "$*" != *"${START_ISSUE_FAKE_EXPECT_PROMPT}"* ]]; then - printf "%s\n" "expected prompt to contain: ${START_ISSUE_FAKE_EXPECT_PROMPT}" >&2 - exit 1 -fi - -if [[ "${START_ISSUE_FAKE_FORBID_MODEL:-}" == "1" && "$*" == *"--model "* ]]; then - printf "%s\n" "unexpected model flag" >&2 - exit 1 -fi - -if [[ "${START_ISSUE_FAKE_AGENT_FAIL:-}" == "1" ]]; then - exit 1 -fi - -for arg in "$@"; do - if [[ "$arg" == "--print" ]]; then - if [[ "$*" == *"Improve the following start-issue prompt template"* ]]; then - improved_prompt="${START_ISSUE_FAKE_IMPROVED_PROMPT:-Improved prompt}" - printf "%s\n" "$improved_prompt" - exit 0 - fi - - printf "%s\n" "${START_ISSUE_FAKE_BRANCH_NAME:-feature/issue-1-ai-branch}" - exit 0 - fi -done - -printf "fake claude invoked\n" diff --git a/test/helpers/fake-bin/codex b/test/helpers/fake-bin/codex deleted file mode 100755 index d44485f..0000000 --- a/test/helpers/fake-bin/codex +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ -n "${START_ISSUE_FAKE_EXPECT_MODEL:-}" && "$*" != *"--model ${START_ISSUE_FAKE_EXPECT_MODEL}"* ]]; then - printf "%s\n" "expected model flag --model ${START_ISSUE_FAKE_EXPECT_MODEL}" >&2 - exit 1 -fi - -if [[ "${START_ISSUE_FAKE_FORBID_MODEL:-}" == "1" && "$*" == *"--model "* ]]; then - printf "%s\n" "unexpected model flag" >&2 - exit 1 -fi - -if [[ "${START_ISSUE_FAKE_AGENT_FAIL:-}" == "1" ]]; then - exit 1 -fi - -if [[ "${START_ISSUE_FAKE_CODEX_REJECT_ASK_FOR_APPROVAL:-}" == "1" && "$*" == *"--ask-for-approval"* ]]; then - printf '%s\n' "unexpected obsolete --ask-for-approval flag" >&2 - exit 1 -fi - -if [[ "${1:-}" == "resume" ]]; then - shift - - if [[ "${START_ISSUE_FAKE_CODEX_RESUME_FAIL:-}" == "1" ]]; then - exit 1 - fi - - printf "fake codex resume %s\n" "$*" - exit 0 -fi - -if [[ "${1:-}" == "exec" ]]; then - shift - - json_mode=0 - output_last_message="" - extra_args=() - - while [[ $# -gt 0 ]]; do - case "$1" in - --json) - json_mode=1 - shift - ;; - --output-last-message) - output_last_message="${2:-}" - shift 2 - ;; - --model|--cd|--sandbox) - shift 2 - ;; - --skip-git-repo-check|-) - shift - ;; - *) - extra_args+=("$1") - shift - ;; - esac - done - - request="" - if [[ ! -t 0 ]]; then - request="$(cat)" - fi - - if [[ -z "$request" && "${#extra_args[@]}" -gt 0 ]]; then - request="${extra_args[*]}" - fi - - if [[ "$json_mode" == "1" || -n "$output_last_message" ]]; then - if [[ "${START_ISSUE_FAKE_CODEX_OMIT_THREAD_ID:-}" != "1" ]]; then - printf '{"type":"thread.started","thread_id":"%s"}\n' "${START_ISSUE_FAKE_CODEX_THREAD_ID:-019e5b45-f9ac-76c1-8177-4317c42d04f9}" - fi - - if [[ -n "$output_last_message" ]]; then - mkdir -p "$(dirname "$output_last_message")" - if [[ -n "${START_ISSUE_FAKE_CODEX_LAST_MESSAGE:-}" ]]; then - printf "%s\n" "$START_ISSUE_FAKE_CODEX_LAST_MESSAGE" > "$output_last_message" - elif [[ "${START_ISSUE_FAKE_CODEX_STATUS:-DONE}" == "HUMAN_GATE" ]]; then - cat > "$output_last_message" <<'EOF' -STATUS: HUMAN_GATE - -Blocker: -Need a human decision. - -Question: -Which option should be used? - -Options: -- Option A: Keep the current behavior. -- Option B: Change the behavior. - -Recommendation: -Option A keeps the workflow stable. -EOF - else - cat > "$output_last_message" <<'EOF' -STATUS: DONE - -Summary: -- Completed. - -Validation: -- Fake validation. - -Changed files: -- fake-file -EOF - fi - fi - - exit "${START_ISSUE_FAKE_CODEX_BATCH_EXIT:-0}" - fi - - if [[ "$request" == *"Improve the following start-issue prompt template"* ]]; then - improved_prompt="${START_ISSUE_FAKE_IMPROVED_PROMPT:-Improved prompt}" - printf "%s\n" "$improved_prompt" - exit 0 - fi - - printf "%s\n" "${START_ISSUE_FAKE_BRANCH_NAME:-feature/issue-1-ai-branch}" - exit 0 -fi - -printf "fake codex invoked\n" diff --git a/test/helpers/fake-bin/gh b/test/helpers/fake-bin/gh deleted file mode 100755 index 6ffc7bc..0000000 --- a/test/helpers/fake-bin/gh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -case "${1:-}" in - auth) - if [[ "${2:-}" == "status" ]]; then - exit 0 - fi - ;; - api) - endpoint="${2:-}" - if [[ "$endpoint" =~ ^repos/[^/]+/[^/]+/issues/[0-9]+$ ]]; then - cat "${START_ISSUE_FAKE_ISSUE_JSON:?START_ISSUE_FAKE_ISSUE_JSON is required}" - exit 0 - fi - if [[ "$endpoint" =~ ^repos/[^/]+/[^/]+/releases/latest$ ]]; then - cat "${START_ISSUE_FAKE_LATEST_RELEASE_JSON:?START_ISSUE_FAKE_LATEST_RELEASE_JSON is required}" - exit 0 - fi - ;; -esac - -echo "fake gh: unsupported arguments: $*" >&2 -exit 1 diff --git a/test/helpers/fake-bin/kimi b/test/helpers/fake-bin/kimi deleted file mode 100755 index e7dfc5e..0000000 --- a/test/helpers/fake-bin/kimi +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ -n "${START_ISSUE_FAKE_EXPECT_MODEL:-}" && "$*" != *"--model ${START_ISSUE_FAKE_EXPECT_MODEL}"* ]]; then - printf "%s\n" "expected model flag --model ${START_ISSUE_FAKE_EXPECT_MODEL}" >&2 - exit 1 -fi - -if [[ "${START_ISSUE_FAKE_FORBID_MODEL:-}" == "1" && "$*" == *"--model "* ]]; then - printf "%s\n" "unexpected model flag" >&2 - exit 1 -fi - -if [[ "${START_ISSUE_FAKE_AGENT_FAIL:-}" == "1" ]]; then - exit 1 -fi - -if [[ "$*" == *"Improve the following start-issue prompt template"* ]]; then - improved_prompt="${START_ISSUE_FAKE_IMPROVED_PROMPT:-Improved prompt}" - printf "%s\n" "$improved_prompt" - exit 0 -fi - -printf "%s\n" "${START_ISSUE_FAKE_BRANCH_NAME:-feature/issue-1-ai-branch}" diff --git a/test/helpers/fake-bin/pi b/test/helpers/fake-bin/pi deleted file mode 100755 index 17b0b24..0000000 --- a/test/helpers/fake-bin/pi +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ -n "${START_ISSUE_FAKE_EXPECT_MODEL:-}" && "$*" != *"--model ${START_ISSUE_FAKE_EXPECT_MODEL}"* ]]; then - printf "%s\n" "expected model flag --model ${START_ISSUE_FAKE_EXPECT_MODEL}" >&2 - exit 1 -fi - -if [[ "${START_ISSUE_FAKE_FORBID_MODEL:-}" == "1" && "$*" == *"--model "* ]]; then - printf "%s\n" "unexpected model flag" >&2 - exit 1 -fi - -if [[ "${START_ISSUE_FAKE_AGENT_FAIL:-}" == "1" ]]; then - exit 1 -fi - -for arg in "$@"; do - if [[ "$arg" == "--print" ]]; then - if [[ "$*" == *"Improve the following start-issue prompt template"* ]]; then - improved_prompt="${START_ISSUE_FAKE_IMPROVED_PROMPT:-Improved prompt}" - printf "%s\n" "$improved_prompt" - exit 0 - fi - - printf "%s\n" "${START_ISSUE_FAKE_BRANCH_NAME:-feature/issue-1-ai-branch}" - exit 0 - fi -done - -printf "fake pi invoked\n" diff --git a/test/start_issue.bats b/test/start_issue.bats deleted file mode 100644 index 18657c1..0000000 --- a/test/start_issue.bats +++ /dev/null @@ -1,1341 +0,0 @@ -#!/usr/bin/env bats - -setup() { - REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" - TEST_TMPDIR="${BATS_TEST_TMPDIR:-${BATS_TMPDIR:?}}" - export REPO_ROOT - export PATH="$REPO_ROOT/test/helpers/fake-bin:$PATH" - export START_ISSUE_FAKE_ISSUE_JSON="$REPO_ROOT/test/fixtures/issue-1.json" - - export HOME="$TEST_TMPDIR/home" - mkdir -p "$HOME" - mkdir -p "$HOME/.config/start-issue" - - TEST_REPO="$TEST_TMPDIR/repo" - mkdir -p "$TEST_REPO" - git -C "$TEST_REPO" init -q -b master - git -C "$TEST_REPO" config user.email "ci@example.invalid" - git -C "$TEST_REPO" config user.name "CI" - printf "fixture\n" > "$TEST_REPO/README.md" - git -C "$TEST_REPO" add README.md - git -C "$TEST_REPO" commit -q -m "Initial commit" - git -C "$TEST_REPO" remote add origin git@github.com:owner/repo.git - cd "$TEST_REPO" - - unset START_ISSUE_AGENT - unset START_ISSUE_MODEL - unset START_ISSUE_PROMPT - unset START_ISSUE_PROMPT_FILE - unset START_ISSUE_WORKTREE_DIR - unset START_ISSUE_FAKE_BRANCH_NAME - unset START_ISSUE_FAKE_AGENT_FAIL - unset START_ISSUE_FAKE_EXPECT_MODEL - unset START_ISSUE_FAKE_EXPECT_PROMPT - unset START_ISSUE_FAKE_FORBID_MODEL - unset START_ISSUE_FAKE_LATEST_RELEASE_JSON - unset START_ISSUE_REPOSITORY - unset START_ISSUE_RUN_ID - unset START_ISSUE_FAKE_CODEX_STATUS - unset START_ISSUE_FAKE_CODEX_LAST_MESSAGE - unset START_ISSUE_FAKE_CODEX_THREAD_ID - unset START_ISSUE_FAKE_CODEX_OMIT_THREAD_ID - unset START_ISSUE_FAKE_CODEX_RESUME_FAIL - unset START_ISSUE_FAKE_CODEX_BATCH_EXIT -} - -run_start_issue() { - run "$REPO_ROOT/scripts/start-issue" "$@" -} - -run_install_script() { - run env \ - HOME="$HOME" \ - PREFIX="$TEST_TMPDIR/install-prefix" \ - BINDIR="$TEST_TMPDIR/install-prefix/bin" \ - TARGET="$TEST_TMPDIR/install-prefix/bin/start-issue" \ - START_ISSUE_REPOSITORY="test/local" \ - START_ISSUE_ASSET_URL="file://$TEST_TMPDIR/install-fixture/start-issue" \ - START_ISSUE_CHECKSUM_URL="file://$TEST_TMPDIR/install-fixture/start-issue.sha256" \ - bash "$REPO_ROOT/install.sh" "$@" -} - -run_piped_install_script() { - run env \ - HOME="$HOME" \ - PREFIX="$TEST_TMPDIR/install-prefix" \ - BINDIR="$TEST_TMPDIR/install-prefix/bin" \ - TARGET="$TEST_TMPDIR/install-prefix/bin/start-issue" \ - START_ISSUE_REPOSITORY="test/local" \ - START_ISSUE_ASSET_URL="file://$TEST_TMPDIR/install-fixture/start-issue" \ - START_ISSUE_CHECKSUM_URL="file://$TEST_TMPDIR/install-fixture/start-issue.sha256" \ - bash -c 'bash < "$1"' -- "$REPO_ROOT/install.sh" -} - -build_installed_start_issue() { - local output_path="$1" - local version="$2" - local tmpfile - - bash "$REPO_ROOT/scripts/build-start-issue" "$output_path" >/dev/null - tmpfile="$TEST_TMPDIR/versioned-start-issue" - awk -v version="$version" ' - BEGIN { replaced = 0 } - /^VERSION="/ && replaced == 0 { - print "VERSION=\"" version "\"" - replaced = 1 - next - } - { print } - ' "$output_path" > "$tmpfile" - mv "$tmpfile" "$output_path" - chmod +x "$output_path" -} - -create_fake_release_assets() { - local dir="$1" - local version="$2" - local asset_path="$dir/start-issue" - local checksum_path="$dir/start-issue.sha256" - local json_path="$dir/release.json" - local asset_url - local checksum_url - local checksum - - mkdir -p "$dir" - cat > "$asset_path" < "$checksum_path" - asset_url="file://$asset_path" - checksum_url="file://$checksum_path" - cat > "$json_path" < \"\${START_ISSUE_ZELLIJ_LOG:?}\"" - } > "$ZELLIJ_FAKE_BIN/zellij-tab-status" - chmod +x "$ZELLIJ_FAKE_BIN/zellij-tab-status" - export PATH="$ZELLIJ_FAKE_BIN:$PATH" - export START_ISSUE_ZELLIJ_LOG="$TEST_TMPDIR/zellij-tab-status.log" -} - -@test "default agent is claude and SSH origin remote is parsed" { - run_start_issue 1 --dry-run --no-init - - assert_success - assert_output_contains "Agent: claude" - assert_output_contains "Agent source: built-in default" - assert_output_contains "Model: " - assert_output_contains "Model source: built-in default" - assert_output_contains "Fetching issue #1 from owner/repo" - assert_output_contains "Prompt source: built-in Claude command" - assert_output_contains "Default prompt files:" - assert_output_contains "Project:" - assert_output_contains ".start-issue/prompt.md" - assert_output_contains "User: $HOME/.config/start-issue/prompt.md" - assert_output_contains "claude --dangerously-skip-permissions" - assert_output_contains "/task-router:route-task" -} - -@test "missing issue prints selected default agent and prompt details" { - run_start_issue - - assert_failure - assert_output_contains "Error: missing issue URL or issue number" - assert_output_contains 'Run `start-issue --help` for full usage and prompt variables.' - assert_output_contains "Usage: start-issue [options]" - assert_output_contains "Examples:" - assert_output_contains "Prompt variables:" - assert_output_contains "Current configuration:" - assert_output_contains "Agent: claude" - assert_output_contains "Agent source: built-in default" - assert_output_contains "Model: " - assert_output_contains "Model source: built-in default" - assert_output_contains "Prompt source: built-in Claude command" - assert_output_contains "Prompt location: $REPO_ROOT/scripts/start-issue" - assert_output_contains "Project model:" - assert_output_contains ".start-issue/model" - assert_output_contains "User model:" - assert_output_contains "Worktree dir: $HOME/worktrees (built-in default)" - assert_output_contains "Default prompt files:" - assert_output_contains "Project:" - assert_output_contains ".start-issue/prompt.md" - assert_output_contains "User: $HOME/.config/start-issue/prompt.md" - [[ "$output" != *"Prompt preview:"* ]] - [[ "$output" != *"Options:"* ]] - [[ "$output" != *"Agent selection precedence:"* ]] - [[ "$output" != *"Fetching issue"* ]] -} - -@test "help lists environment variables for prompt and config sources" { - run_start_issue --help - - assert_success - assert_output_contains "Environment variables:" - assert_output_contains "START_ISSUE_AGENT" - assert_output_contains "START_ISSUE_PROMPT" - assert_output_contains "START_ISSUE_PROMPT_FILE" - assert_output_contains "START_ISSUE_WORKTREE_DIR" - assert_output_contains "START_ISSUE_DUMP_PROMPT" -} - -@test "help documents update entry points" { - run_start_issue --help - - assert_success - assert_output_contains "start-issue update [options]" - assert_output_contains "--update" - assert_output_contains "start-issue update" - assert_output_contains "start-issue --update" -} - -@test "help documents setup entry points" { - run_start_issue --help - - assert_success - assert_output_contains "start-issue setup [options]" - assert_output_contains "--setup" - assert_output_contains "start-issue setup" - assert_output_contains "start-issue --setup" -} - -@test "missing issue prints project agent and prompt file location" { - mkdir -p .start-issue - printf "codex\n" > .start-issue/agent - printf "Project prompt for {ISSUE_URL}\n" > .start-issue/prompt.md - - run_start_issue - - assert_failure - assert_output_contains "Error: missing issue URL or issue number" - assert_output_contains "Agent: codex" - assert_output_contains "Agent source: " - assert_output_contains ".start-issue/agent" - assert_output_contains "Prompt source:" - assert_output_contains ".start-issue/prompt.md" - assert_output_contains "Prompt location:" - [[ "$output" != *"Prompt preview:"* ]] - [[ "$output" != *"Fetching issue"* ]] -} - -@test "help documents model option and config surfaces" { - run_start_issue --help - - assert_success - assert_output_contains "--model " - assert_output_contains "START_ISSUE_MODEL" - assert_output_contains ".start-issue/model" - assert_output_contains "~/.config/start-issue/model" -} - -@test "help documents human-gate entry points" { - run_start_issue --help - - assert_success - assert_output_contains "--human-gate" - assert_output_contains "--human-gate-help" - assert_output_contains "start-issue 123 --agent codex --human-gate" -} - -@test "dedicated human-gate help documents the contract" { - run_start_issue --human-gate-help - - assert_success - assert_output_contains "Codex human-gate mode" - assert_output_contains "STATUS: DONE" - assert_output_contains "STATUS: HUMAN_GATE" - assert_output_contains "codex resume --include-non-interactive " - assert_output_contains ".start-issue/runs//events.jsonl" -} - -@test "real Codex human-gate E2E runner requires explicit authorization" { - run bash "$REPO_ROOT/test/e2e/human-gate.sh" - - assert_failure - assert_output_contains "set START_ISSUE_E2E=1" -} - -@test "real Codex human-gate E2E runner documents its scenarios" { - run bash "$REPO_ROOT/test/e2e/human-gate.sh" --help - - assert_success - assert_output_contains "--scenario done|human-gate" -} - -@test "prompt improvement without issue prints explicit error" { - run_start_issue --improve-prompt - - assert_failure - assert_output_contains "--improve-prompt requires " - assert_output_contains "Example: start-issue 123 --improve-prompt" - [[ "$output" != *"Current configuration:"* ]] - [[ "$output" != *"Fetching issue"* ]] -} - -@test "setup subcommand writes user config with selected agent and prompt" { - run bash -c 'printf "2\ny\n" | "$REPO_ROOT/scripts/start-issue" setup' - - assert_success - assert_output_contains "Select default agent:" - assert_output_contains "Save this prompt to $HOME/.config/start-issue/prompt.md? [Y/n]" - [[ "$(cat "$HOME/.config/start-issue/agent")" == "codex" ]] - [[ "$(cat "$HOME/.config/start-issue/prompt.md")" == *"Implement GitHub issue {ISSUE_URL} in this worktree."* ]] -} - -@test "--setup supports skip agent and declined prompt" { - printf "old-agent\n" > "$HOME/.config/start-issue/agent" - printf "old-prompt\n" > "$HOME/.config/start-issue/prompt.md" - - run bash -c 'printf "5\nn\n" | "$REPO_ROOT/scripts/start-issue" --setup' - - assert_success - [[ ! -e "$HOME/.config/start-issue/agent" ]] - [[ ! -e "$HOME/.config/start-issue/prompt.md" ]] -} - -@test "setup works outside a git repository" { - outside_dir="$TEST_TMPDIR/outside-setup" - rm -rf "$HOME/.config/start-issue" - mkdir -p "$outside_dir" - - run bash -c "cd '$outside_dir' && printf '1\ny\n' | '$REPO_ROOT/scripts/start-issue' setup" - - assert_success - assert_output_contains "Directory: $HOME/.config/start-issue" - [[ "$(cat "$HOME/.config/start-issue/agent")" == "claude" ]] - [[ "$(cat "$HOME/.config/start-issue/prompt.md")" == "/task-router:route-task {ISSUE_URL}" ]] -} - -@test "first run without config prompts onboarding and then shows missing issue guidance" { - rm -rf "$HOME/.config/start-issue" - - run bash -c 'printf "n\n" | "$REPO_ROOT/scripts/start-issue"' - - assert_failure - assert_output_contains "Configuration is not initialized yet." - assert_output_contains "Run setup now? [Y/n]" - assert_output_contains "Error: missing issue URL or issue number" - [[ -d "$HOME/.config/start-issue" ]] - [[ ! -e "$HOME/.config/start-issue/agent" ]] - [[ ! -e "$HOME/.config/start-issue/prompt.md" ]] -} - -@test "first run decline continues the requested issue workflow" { - rm -rf "$HOME/.config/start-issue" - - run bash -c 'printf "n\n" | "$REPO_ROOT/scripts/start-issue" 1 --no-init --no-agent' - - assert_success - assert_output_contains "Configuration is not initialized yet." - assert_output_contains "Run setup now? [Y/n]" - assert_output_contains "Fetching issue #1 from owner/repo" - assert_output_contains "Selected agent: none (CLI)" - [[ -d "$HOME/.config/start-issue" ]] - [[ ! -e "$HOME/.config/start-issue/agent" ]] - [[ ! -e "$HOME/.config/start-issue/prompt.md" ]] -} - -@test "first run accept continues the requested issue workflow with saved config" { - rm -rf "$HOME/.config/start-issue" - - run bash -c 'printf "y\n2\ny\n" | "$REPO_ROOT/scripts/start-issue" 1 --no-init' - - assert_success - assert_output_contains "Configuration is not initialized yet." - assert_output_contains "Run setup now? [Y/n]" - assert_output_contains "Fetching issue #1 from owner/repo" - assert_output_contains "Agent: codex" - assert_output_contains "Agent source: $HOME/.config/start-issue/agent" - assert_output_contains "Handing off to codex in $HOME/worktrees/feature/issue-1-add-login-button" - assert_output_contains "fake codex invoked" - [[ "$(cat "$HOME/.config/start-issue/agent")" == "codex" ]] - [[ "$(cat "$HOME/.config/start-issue/prompt.md")" == *"Implement GitHub issue {ISSUE_URL} in this worktree."* ]] -} - -@test "full issue URL overrides detected repository" { - run_start_issue https://github.com/other/project/issues/1 --dry-run --no-init --no-agent - - assert_success - assert_output_contains "Fetching issue #1 from other/project" - assert_output_contains "Selected agent: none (CLI)" -} - -@test "update subcommand installs the latest release into the running executable path" { - installed_script="$TEST_TMPDIR/bin/start-issue" - mkdir -p "$(dirname "$installed_script")" - build_installed_start_issue "$installed_script" "1.11.0" - create_fake_release_assets "$TEST_TMPDIR/release-assets" "1.11.1" - expected_path="$(cd "$(dirname "$installed_script")" && pwd -P)/$(basename "$installed_script")" - - run "$installed_script" update - - assert_success - assert_output_contains "Installed version: v1.11.0" - assert_output_contains "Latest release: v1.11.1" - assert_output_contains "Updated start-issue at: $expected_path" - assert_output_contains "Version: start-issue v1.11.1" - run "$installed_script" --version - assert_success - assert_output_contains "start-issue v1.11.1" -} - -@test "--update is equivalent to the update subcommand" { - installed_script="$TEST_TMPDIR/bin/start-issue" - mkdir -p "$(dirname "$installed_script")" - build_installed_start_issue "$installed_script" "1.11.0" - create_fake_release_assets "$TEST_TMPDIR/release-assets-flag" "1.11.1" - expected_path="$(cd "$(dirname "$installed_script")" && pwd -P)/$(basename "$installed_script")" - - run "$installed_script" --update - - assert_success - assert_output_contains "Latest release: v1.11.1" - assert_output_contains "Updated start-issue at: $expected_path" -} - -@test "update exits successfully when already on the latest release tag" { - installed_script="$TEST_TMPDIR/bin/start-issue" - mkdir -p "$(dirname "$installed_script")" - build_installed_start_issue "$installed_script" "1.11.1" - create_fake_release_assets "$TEST_TMPDIR/release-assets-current" "1.11.1" - - run "$installed_script" update - - assert_success - assert_output_contains "Installed version: v1.11.1" - assert_output_contains "Latest release: v1.11.1" - assert_output_contains "already up to date" -} - -@test "update treats bare and v-prefixed versions as equivalent" { - installed_script="$TEST_TMPDIR/bin/start-issue" - mkdir -p "$(dirname "$installed_script")" - build_installed_start_issue "$installed_script" "1.11.1" - create_fake_release_assets "$TEST_TMPDIR/release-assets-normalized" "1.11.1" - - run "$installed_script" --update - - assert_success - assert_output_contains "Installed version: v1.11.1" - assert_output_contains "Latest release: v1.11.1" - assert_output_contains "already up to date" -} - -@test "update does not downgrade when installed version is newer than the latest release" { - installed_script="$TEST_TMPDIR/bin/start-issue" - mkdir -p "$(dirname "$installed_script")" - build_installed_start_issue "$installed_script" "1.12.0" - create_fake_release_assets "$TEST_TMPDIR/release-assets-older" "1.11.1" - - run "$installed_script" update - - assert_success - assert_output_contains "Installed version: v1.12.0" - assert_output_contains "Latest release: v1.11.1" - assert_output_contains "newer than the latest published release" - run "$installed_script" --version - assert_success - assert_output_contains "start-issue v1.12.0" -} - -@test "update works outside a git repository" { - installed_script="$TEST_TMPDIR/bin/start-issue" - outside_dir="$TEST_TMPDIR/outside" - mkdir -p "$(dirname "$installed_script")" "$outside_dir" - build_installed_start_issue "$installed_script" "1.11.1" - create_fake_release_assets "$TEST_TMPDIR/release-assets-outside" "1.11.1" - expected_path="$(cd "$(dirname "$installed_script")" && pwd -P)/$(basename "$installed_script")" - - run bash -c "cd '$outside_dir' && '$installed_script' update" - - assert_success - assert_output_contains "Executable: $expected_path" - assert_output_contains "already up to date" -} - -@test "update fails clearly when latest release lookup fails" { - installed_script="$TEST_TMPDIR/bin/start-issue" - mkdir -p "$(dirname "$installed_script")" - build_installed_start_issue "$installed_script" "1.11.0" - - run "$installed_script" update - - assert_failure - assert_output_contains "Failed to resolve the latest GitHub release" -} - -@test "update rejects mixing update mode with issue input" { - run_start_issue 1 --update - - assert_failure - assert_output_contains "Use either update or , not both." - [[ "$output" != *"Fetching issue"* ]] -} - -@test "HTTPS origin remote is parsed" { - git remote set-url origin https://github.com/https-owner/https-repo.git - - run_start_issue 1 --dry-run --no-init --no-agent - - assert_success - assert_output_contains "Fetching issue #1 from https-owner/https-repo" -} - -@test "CLI agent wins over project config and environment" { - mkdir -p .start-issue - printf "kimi\n" > .start-issue/agent - export START_ISSUE_AGENT=pi - - run_start_issue 1 --agent codex --dry-run --no-init - - assert_success - assert_output_contains "Agent: codex" - assert_output_contains "Agent source: CLI" - assert_output_contains "codex --cd" -} - -@test "CLI model wins over project, user, and environment" { - mkdir -p .start-issue - mkdir -p "$HOME/.config/start-issue" - printf "project-model\n" > .start-issue/model - printf "user-model\n" > "$HOME/.config/start-issue/model" - export START_ISSUE_MODEL=env-model - - run_start_issue 1 --agent codex --model cli-model --dry-run --no-init - - assert_success - assert_output_contains "Model: cli-model" - assert_output_contains "Model source: CLI" - assert_output_contains "codex --model cli-model --cd" -} - -@test "project model config wins over environment" { - mkdir -p .start-issue - printf "project-model\n" > .start-issue/model - export START_ISSUE_MODEL=env-model - - run_start_issue 1 --agent codex --dry-run --no-init - - assert_success - assert_output_contains "Model: project-model" - assert_output_contains "Model source: " - assert_output_contains ".start-issue/model" - assert_output_contains "codex --model project-model --cd" -} - -@test "user model config wins over environment" { - mkdir -p "$HOME/.config/start-issue" - printf "user-model\n" > "$HOME/.config/start-issue/model" - export START_ISSUE_MODEL=env-model - - run_start_issue 1 --agent pi --dry-run --no-init - - assert_success - assert_output_contains "Model: user-model" - assert_output_contains "Model source: $HOME/.config/start-issue/model" - assert_output_contains "pi --model user-model" -} - -@test "project agent config wins over environment" { - mkdir -p .start-issue - printf "codex\n" > .start-issue/agent - export START_ISSUE_AGENT=kimi - - run_start_issue 1 --dry-run --no-init - - assert_success - assert_output_contains "Agent: codex" - assert_output_contains "Agent source: " - assert_output_contains ".start-issue/agent" -} - -@test "user agent config wins over environment" { - mkdir -p "$HOME/.config/start-issue" - printf "pi\n" > "$HOME/.config/start-issue/agent" - export START_ISSUE_AGENT=kimi - - run_start_issue 1 --dry-run --no-init - - assert_success - assert_output_contains "Agent: pi" - assert_output_contains "Agent source: $HOME/.config/start-issue/agent" - assert_output_contains "cd $HOME/worktrees/feature/issue-1-add-login-button && pi" -} - -@test "init writes project config with selected agent and portable prompt" { - run_start_issue init --project --agent codex - - assert_success - assert_output_contains "Scope: project config" - assert_output_contains "Agent: codex" - assert_output_contains "Agent source: CLI" - [[ "$(cat .start-issue/agent)" == "codex" ]] - [[ "$(cat .start-issue/prompt.md)" == *"Implement GitHub issue {ISSUE_URL} in this worktree."* ]] - [[ "$(cat .start-issue/prompt.md)" == *"target the base branch {BASE_BRANCH}."* ]] - [[ "$output" != *"Fetching issue"* ]] -} - -@test "init writes project model config when selected" { - run_start_issue init --project --agent codex --model gpt-5.2 - - assert_success - assert_output_contains "Model: gpt-5.2" - assert_output_contains "Model source: CLI" - [[ "$(cat .start-issue/model)" == "gpt-5.2" ]] -} - -@test "init prompts for user config when scope is omitted" { - run bash -c 'printf "2\n" | "$REPO_ROOT/scripts/start-issue" init' - - assert_success - assert_output_contains "User config" - [[ "$(cat "$HOME/.config/start-issue/agent")" == "claude" ]] - [[ "$(cat "$HOME/.config/start-issue/prompt.md")" == "/task-router:route-task {ISSUE_URL}" ]] -} - -@test "init keeps existing config unless forced" { - mkdir -p .start-issue - printf "kimi\n" > .start-issue/agent - printf "custom\n" > .start-issue/prompt.md - - run_start_issue init --project --agent codex --prompt inline - assert_success - [[ "$(cat .start-issue/agent)" == "kimi" ]] - [[ "$(cat .start-issue/prompt.md)" == "custom" ]] - - run_start_issue init --project --agent codex --prompt inline --force - assert_success - [[ "$(cat .start-issue/agent)" == "codex" ]] - [[ "$(cat .start-issue/prompt.md)" == "inline" ]] -} - -@test "init derives missing prompt from kept existing agent" { - mkdir -p .start-issue - printf "codex\n" > .start-issue/agent - - run_start_issue init --project - - assert_success - assert_output_contains "Agent: codex" - assert_output_contains "Agent source: " - assert_output_contains ".start-issue/agent (existing)" - [[ "$(cat .start-issue/agent)" == "codex" ]] - [[ "$(cat .start-issue/prompt.md)" == *"Implement GitHub issue {ISSUE_URL} in this worktree."* ]] - [[ "$(cat .start-issue/prompt.md)" == *"target the base branch {BASE_BRANCH}."* ]] - [[ "$(cat .start-issue/prompt.md)" != *"/task-router:route-task"* ]] -} - -@test "init dry-run does not write config files" { - run_start_issue init --project --agent codex --dry-run - - assert_success - assert_output_contains "[DRY-RUN] Would write agent config" - assert_output_contains "No model config to write" - [[ ! -e .start-issue/agent ]] - [[ ! -e .start-issue/model ]] - [[ ! -e .start-issue/prompt.md ]] -} - -@test "init --force removes existing model config when no model is selected" { - mkdir -p .start-issue - printf "old-model\n" > .start-issue/model - - run_start_issue init --project --agent codex --force - - assert_success - [[ ! -e .start-issue/model ]] -} - -@test "--no-agent prints manual next steps" { - run_start_issue 1 --no-agent --dry-run --no-init - - assert_success - assert_output_contains "Selected agent: none (CLI)" - assert_output_contains "To start working:" - assert_output_contains "codex --cd $HOME/worktrees/feature/issue-1-add-login-button" -} - -@test "--no-agent displays resolved model without passing launch args" { - run_start_issue 1 --agent none --model sonnet --dry-run --no-init - - assert_success - assert_output_contains "Selected agent: none (CLI)" - assert_output_contains "Resolved model: sonnet (CLI)" - [[ "$output" != *"--model sonnet"* ]] -} - -@test "zellij-tab-status dry-run rename is shown when installed" { - install_fake_zellij_tab_status - - run_start_issue 1 --agent none --dry-run --no-init - - assert_success - assert_output_contains "Would run: zellij-tab-status --set-name \\#1" -} - -@test "worktree directory priority uses environment and CLI override" { - export START_ISSUE_WORKTREE_DIR="$TEST_TMPDIR/env-worktrees" - - run_start_issue 1 --agent none --dry-run --no-init - assert_success - assert_output_contains "Worktree directory: $TEST_TMPDIR/env-worktrees (START_ISSUE_WORKTREE_DIR)" - - run_start_issue 1 --agent none --dry-run --no-init --worktree-dir "$TEST_TMPDIR/cli-worktrees" - assert_success - assert_output_contains "Worktree directory: $TEST_TMPDIR/cli-worktrees (CLI)" -} - -@test "legacy Claude worktree environment name is ignored" { - legacy_name="CLAUDE""_WORKTREE_DIR" - export "$legacy_name=$TEST_TMPDIR/legacy-worktrees" - - run_start_issue 1 --agent none --dry-run --no-init - - assert_success - assert_output_contains "Worktree directory: $HOME/worktrees (built-in default)" -} - -@test "codex, kimi, and pi launch commands are rendered in dry-run" { - run_start_issue 1 --agent codex --dry-run --no-init - assert_success - assert_output_contains "codex --cd $HOME/worktrees/feature/issue-1-add-login-button" - assert_output_contains "--dangerously-bypass-approvals-and-sandbox" - - run_start_issue 1 --agent kimi --dry-run --no-init - assert_success - assert_output_contains "kimi --work-dir $HOME/worktrees/feature/issue-1-add-login-button --yolo -p" - - run_start_issue 1 --agent pi --dry-run --no-init - assert_success - assert_output_contains "cd $HOME/worktrees/feature/issue-1-add-login-button && pi" -} - -@test "human-gate rejects non-codex agents before fetching the issue" { - run_start_issue 1 --agent kimi --human-gate --dry-run --no-init - - assert_failure - assert_output_contains "--human-gate requires agent 'codex'" - [[ "$output" != *"Fetching issue"* ]] -} - -@test "human-gate dry-run renders the Codex batch command and state artifacts" { - export START_ISSUE_RUN_ID="20260524-010203" - - run_start_issue 1 --agent codex --human-gate --dry-run --no-init - - assert_success - assert_output_contains "Starting codex human-gate batch session" - [[ "$output" != *"--ask-for-approval"* ]] - assert_output_contains "--sandbox workspace-write" - assert_output_contains "--json" - assert_output_contains "--output-last-message" - assert_output_contains ".start-issue/runs/20260524-010203/events.jsonl" - assert_output_contains ".start-issue/runs/20260524-010203/last-message.txt" - assert_output_contains ".start-issue/runs/20260524-010203/thread-id" -} - -@test "human-gate exits successfully on DONE without resuming Codex" { - export START_ISSUE_RUN_ID="20260524-020304" - export START_ISSUE_FAKE_CODEX_REJECT_ASK_FOR_APPROVAL=1 - - run_start_issue 1 --agent codex --human-gate --no-init - - assert_success - assert_output_contains "Codex finished with STATUS: DONE" - [[ "$output" != *"fake codex resume"* ]] - run_dir="$HOME/worktrees/feature/issue-1-add-login-button/.start-issue/runs/20260524-020304" - [[ -f "$run_dir/events.jsonl" ]] - [[ -f "$run_dir/last-message.txt" ]] - [[ -f "$run_dir/thread-id" ]] - [[ "$(cat "$run_dir/thread-id")" == "019e5b45-f9ac-76c1-8177-4317c42d04f9" ]] -} - -@test "human-gate resumes the same Codex session on HUMAN_GATE" { - export START_ISSUE_RUN_ID="20260524-030405" - export START_ISSUE_FAKE_CODEX_STATUS="HUMAN_GATE" - export START_ISSUE_FAKE_CODEX_THREAD_ID="thread-human-gate" - - run_start_issue 1 --agent codex --human-gate --no-init - - assert_success - assert_output_contains "Thread ID: thread-human-gate" - assert_output_contains "Resume command: codex resume --include-non-interactive thread-human-gate" - assert_output_contains "fake codex resume --include-non-interactive thread-human-gate" -} - -@test "human-gate exits 2 when HUMAN_GATE resume cannot be opened" { - export START_ISSUE_RUN_ID="20260524-040506" - export START_ISSUE_FAKE_CODEX_STATUS="HUMAN_GATE" - export START_ISSUE_FAKE_CODEX_THREAD_ID="thread-resume-fail" - export START_ISSUE_FAKE_CODEX_RESUME_FAIL=1 - - run_start_issue 1 --agent codex --human-gate --no-init - - [ "$status" -eq 2 ] - assert_output_contains "Could not open Codex resume session." - assert_output_contains "Resume command: codex resume --include-non-interactive thread-resume-fail" - assert_output_contains "Thread ID: thread-resume-fail" -} - -@test "human-gate fails clearly when no recognized final status is found" { - export START_ISSUE_RUN_ID="20260524-050607" - export START_ISSUE_FAKE_CODEX_LAST_MESSAGE="Summary only" - - run_start_issue 1 --agent codex --human-gate --no-init - - assert_failure - assert_output_contains "No recognized final status found." - assert_output_contains ".start-issue/runs/20260524-050607/last-message.txt" -} - -@test "human-gate fails clearly when no thread id is captured" { - export START_ISSUE_RUN_ID="20260524-060708" - export START_ISSUE_FAKE_CODEX_OMIT_THREAD_ID=1 - - run_start_issue 1 --agent codex --human-gate --no-init - - assert_failure - assert_output_contains "did not capture thread_id" - assert_output_contains ".start-issue/runs/20260524-060708/events.jsonl" -} - -@test "dry-run renders explicit model for supported launch adapters" { - run_start_issue 1 --agent codex --model gpt-5.2 --dry-run --no-init - assert_success - assert_output_contains "Model: gpt-5.2" - assert_output_contains "Model source: CLI" - assert_output_contains "codex --model gpt-5.2 --cd $HOME/worktrees/feature/issue-1-add-login-button" - - run_start_issue 1 --agent claude --model sonnet --dry-run --no-init - assert_success - assert_output_contains "Model: sonnet" - assert_output_contains "Model source: CLI" - assert_output_contains "claude --model sonnet --dangerously-skip-permissions" -} - -@test "prompt template from project file is rendered" { - mkdir -p .start-issue - printf "Prompt-{ISSUE_NUMBER}-{REPO}-{BASE_BRANCH}-{UNKNOWN}\n" > .start-issue/prompt.md - - run_start_issue 1 --agent codex --dry-run --no-init - - assert_success - assert_output_contains "Prompt source:" - assert_output_contains ".start-issue/prompt.md" - assert_output_contains "Prompt-1-owner/repo-master" - assert_output_contains "UNKNOWN" -} - -@test "environment prompt file overrides user and project prompt files" { - mkdir -p .start-issue "$HOME/.config/start-issue" - printf "Project prompt {ISSUE_NUMBER}\n" > .start-issue/prompt.md - printf "User prompt {ISSUE_NUMBER}\n" > "$HOME/.config/start-issue/prompt.md" - printf "Env prompt {ISSUE_NUMBER}\n" > "$TEST_TMPDIR/env-prompt.md" - export START_ISSUE_PROMPT_FILE="$TEST_TMPDIR/env-prompt.md" - - run_start_issue 1 --agent codex --dry-run --no-init - - assert_success - assert_output_contains "Prompt source: START_ISSUE_PROMPT_FILE: $TEST_TMPDIR/env-prompt.md" - assert_output_contains "Env\\ prompt\\ 1" - [[ "$output" != *"Project prompt 1"* ]] - [[ "$output" != *"User prompt 1"* ]] -} - -@test "prompt improvement writes proposal next to project prompt and exits before worktree" { - mkdir -p .start-issue - printf "Prompt {ISSUE_NUMBER}\n" > .start-issue/prompt.md - export START_ISSUE_FAKE_IMPROVED_PROMPT="Improved prompt {ISSUE_URL} {ISSUE_NUMBER}" - - run_start_issue 1 --agent codex --improve-prompt --no-init - - assert_success - assert_output_contains "Improving prompt template" - assert_output_contains "Prompt source:" - assert_output_contains ".start-issue/prompt.md" - assert_output_contains "Proposal path:" - assert_output_contains ".start-issue/prompt.improved.md" - assert_output_contains "Prompt improvement written" - [[ "$(cat .start-issue/prompt.improved.md)" == "Improved prompt {ISSUE_URL} {ISSUE_NUMBER}" ]] - [[ "$output" != *"Creating worktree"* ]] -} - -@test "prompt improvement passes explicit model to the selected agent" { - export START_ISSUE_FAKE_IMPROVED_PROMPT="Improved prompt" - export START_ISSUE_FAKE_EXPECT_MODEL=sonnet - - run_start_issue 1 --agent claude --model sonnet --improve-prompt --no-init - - assert_success - [[ "$(cat .start-issue/prompt.improved.md)" == "Improved prompt" ]] -} - -@test "built-in prompt improvement writes project proposal by default" { - export START_ISSUE_FAKE_IMPROVED_PROMPT="Improved built-in prompt {ISSUE_URL}" - - run_start_issue 1 --agent codex --improve-prompt --no-init - - assert_success - assert_output_contains "Prompt source: built-in portable prompt" - assert_output_contains "Proposal path:" - assert_output_contains ".start-issue/prompt.improved.md" - [[ "$(cat .start-issue/prompt.improved.md)" == "Improved built-in prompt {ISSUE_URL}" ]] -} - -@test "prompt improvement dry-run does not write proposal or call agent" { - mkdir -p .start-issue - printf "Prompt {ISSUE_NUMBER}\n" > .start-issue/prompt.md - export START_ISSUE_FAKE_AGENT_FAIL=1 - - run_start_issue 1 --agent codex --improve-prompt --dry-run --no-init - - assert_success - assert_output_contains "[DRY-RUN] Would ask codex to generate an improved prompt proposal." - [[ ! -e .start-issue/prompt.improved.md ]] -} - -@test "prompt improvement uses custom output path and refuses overwrite" { - mkdir -p .start-issue - printf "Prompt {ISSUE_NUMBER}\n" > .start-issue/prompt.md - export START_ISSUE_FAKE_IMPROVED_PROMPT="Improved custom prompt" - - run_start_issue 1 --agent codex --improve-prompt --prompt-output-file .start-issue/prompt.next.md --no-init - - assert_success - assert_output_contains "Proposal path: .start-issue/prompt.next.md" - [[ "$(cat .start-issue/prompt.next.md)" == "Improved custom prompt" ]] - - run_start_issue 1 --agent codex --improve-prompt --prompt-output-file .start-issue/prompt.next.md --no-init - - assert_failure - assert_output_contains "Prompt improvement output already exists: .start-issue/prompt.next.md" - [[ "$(cat .start-issue/prompt.next.md)" == "Improved custom prompt" ]] -} - -@test "prompt improvement rejects agent none before fetching issue" { - run_start_issue 1 --agent none --improve-prompt --dry-run --no-init - - assert_failure - assert_output_contains "--improve-prompt requires an agent" - [[ "$output" != *"Fetching issue"* ]] -} - -@test "prompt conflict fails fast" { - run_start_issue 1 --agent none --dry-run --prompt inline --prompt-file prompt.md - - assert_failure - assert_output_contains "Use either --prompt-file or --prompt, not both." - [[ "$output" != *"Fetching issue"* ]] -} - -@test "unknown agent fails fast" { - run_start_issue 1 --agent unknown --dry-run - - assert_failure - assert_output_contains "Unknown agent: unknown" - [[ "$output" != *"Fetching issue"* ]] -} - -@test "AI branch naming accepts selected agent output" { - export START_ISSUE_FAKE_BRANCH_NAME=fix/issue-1-ai-generated-name - - run_start_issue 1 --agent codex --ai --dry-run --no-init - - assert_success - assert_output_contains "Branch: fix/issue-1-ai-generated-name" - assert_output_contains "ai:codex" -} - -@test "AI branch naming passes explicit model to the selected agent" { - export START_ISSUE_FAKE_BRANCH_NAME=fix/issue-1-ai-generated-name - export START_ISSUE_FAKE_EXPECT_MODEL=gpt-5.2 - - run_start_issue 1 --agent codex --model gpt-5.2 --ai --dry-run --no-init - - assert_success - assert_output_contains "Branch: fix/issue-1-ai-generated-name" - assert_output_contains "Model: gpt-5.2" - assert_output_contains "Model source: CLI" -} - -@test "no-model branch naming keeps existing adapter behavior" { - export START_ISSUE_FAKE_BRANCH_NAME=fix/issue-1-ai-generated-name - export START_ISSUE_FAKE_FORBID_MODEL=1 - - run_start_issue 1 --agent codex --ai --dry-run --no-init - - assert_success -} - -@test "unsupported model selection helper returns a clear error" { - run bash -lc 'set -euo pipefail; RED=""; NC=""; source "'"$REPO_ROOT"'/scripts/lib/start_issue/utils.sh"; source "'"$REPO_ROOT"'/scripts/lib/start_issue/agent.sh"; AGENT="unsupported"; MODEL="x"; validate_model_selection_support launch' - - assert_failure - assert_output_contains "does not support explicit model selection" -} - -@test "AI branch naming falls back when selected agent returns invalid output" { - export START_ISSUE_FAKE_BRANCH_NAME="not a branch" - - run_start_issue 1 --agent codex --ai --dry-run --no-init - - assert_success - assert_output_contains "Generated branch name doesn't match expected format" - assert_output_contains "Using fallback: feature/issue-1-add-login-button" -} - -@test "AI branch naming prompt instructs model to transliterate non-English titles" { - export START_ISSUE_FAKE_BRANCH_NAME=feature/issue-1-ai-generated-name - # Substring must match a word in the generate_ai_branch_name prompt; keep in sync if reworded. - export START_ISSUE_FAKE_EXPECT_PROMPT="transliterate" - - run_start_issue 1 --agent claude --ai --dry-run --no-init - - assert_success - assert_output_contains "Branch: feature/issue-1-ai-generated-name" -} - -@test "AI branch naming prompt instructs model to ignore leading bracketed tags" { - export START_ISSUE_FAKE_BRANCH_NAME=feature/issue-1-ai-generated-name - # Substring must match a word in the generate_ai_branch_name prompt; keep in sync if reworded. - export START_ISSUE_FAKE_EXPECT_PROMPT="bracketed" - - run_start_issue 1 --agent claude --ai --dry-run --no-init - - assert_success - assert_output_contains "Branch: feature/issue-1-ai-generated-name" -} - -@test "AI branch naming falls back when branch ends with a trailing dash" { - export START_ISSUE_FAKE_BRANCH_NAME="feature/issue-1-ai-generated-" - - run_start_issue 1 --agent codex --ai --dry-run --no-init - - assert_success - assert_output_contains "Generated branch name doesn't match expected format" - assert_output_contains "Using fallback: feature/issue-1-add-login-button" -} - -@test "fast branch naming trims a trailing dash after truncation" { - local long_prefix - long_prefix="$(printf 'a%.0s' {1..39})" - cat > "$TEST_TMPDIR/issue-trailing-dash.json" < "$TEST_TMPDIR/install-fixture/start-issue" <<'EOF' -#!/usr/bin/env bash -printf 'start-issue test-build\n' -EOF - chmod +x "$TEST_TMPDIR/install-fixture/start-issue" - shasum -a 256 "$TEST_TMPDIR/install-fixture/start-issue" | awk '{ print $1 " start-issue" }' > "$TEST_TMPDIR/install-fixture/start-issue.sha256" - - run_install_script - - assert_success - assert_output_contains "Downloading latest release from test/local" - assert_output_contains "Installed: $TEST_TMPDIR/install-prefix/bin/start-issue" - assert_output_contains "Version: start-issue test-build" - [[ -x "$TEST_TMPDIR/install-prefix/bin/start-issue" ]] -} - -@test "install.sh installs when piped to Bash" { - mkdir -p "$TEST_TMPDIR/install-fixture" - cat > "$TEST_TMPDIR/install-fixture/start-issue" <<'EOF' -#!/usr/bin/env bash -printf 'start-issue piped-build\n' -EOF - chmod +x "$TEST_TMPDIR/install-fixture/start-issue" - shasum -a 256 "$TEST_TMPDIR/install-fixture/start-issue" | awk '{ print $1 " start-issue" }' > "$TEST_TMPDIR/install-fixture/start-issue.sha256" - - run_piped_install_script - - assert_success - assert_output_contains "Installed: $TEST_TMPDIR/install-prefix/bin/start-issue" - assert_output_contains "Version: start-issue piped-build" - [[ -x "$TEST_TMPDIR/install-prefix/bin/start-issue" ]] -} - -@test "install.sh debug mode prints diagnostic details" { - mkdir -p "$TEST_TMPDIR/install-fixture" - cat > "$TEST_TMPDIR/install-fixture/start-issue" <<'EOF' -#!/usr/bin/env bash -printf 'start-issue debug-build\n' -EOF - chmod +x "$TEST_TMPDIR/install-fixture/start-issue" - shasum -a 256 "$TEST_TMPDIR/install-fixture/start-issue" | awk '{ print $1 " start-issue" }' > "$TEST_TMPDIR/install-fixture/start-issue.sha256" - - run_install_script --debug - - assert_success - assert_output_contains "DEBUG: Repository: test/local" - assert_output_contains "DEBUG: Asset URL: file://$TEST_TMPDIR/install-fixture/start-issue" - assert_output_contains "DEBUG: Verifying checksum" - assert_output_contains "+ install.sh:" - assert_output_contains "Version: start-issue debug-build" -} - -@test "branch reuse matches the exact worktree path" { - git worktree add "$TEST_TMPDIR/worktree-v2" -b feature/issue-1-add-login-button-v2 master - git worktree add "$TEST_TMPDIR/worktree-exact" -b feature/issue-1-add-login-button master - expected_worktree="$(cd "$TEST_TMPDIR/worktree-exact" && pwd -P)" - - run bash -c 'printf "1\n" | "$REPO_ROOT/scripts/start-issue" 1 --agent none --dry-run --no-init' - - assert_success - assert_output_contains "Existing worktree: $expected_worktree" - assert_output_contains "Waiting for input: branch already exists" - assert_output_contains "Using existing worktree: $expected_worktree" - assert_output_contains "✅ Worktree ready at: $expected_worktree" - [[ "$output" != *"worktree-v2"* ]] -} - -@test "reusing a plain directory is rejected before init or agent launch" { - mkdir -p "$HOME/worktrees/feature/issue-1-add-login-button" - - run bash -c 'printf "1\n" | "$REPO_ROOT/scripts/start-issue" 1 --agent none --no-init' - - assert_failure - assert_output_contains "Waiting for input: worktree path already exists" - assert_output_contains "path exists but is not a git worktree for this repository" - [[ "$output" != *"Worktree ready"* ]] -} - -@test "reusing a path registered to a different branch is rejected" { - git worktree add "$HOME/worktrees/feature/issue-1-add-login-button" -b chore/other-branch master - - run bash -c 'printf "1\n" | "$REPO_ROOT/scripts/start-issue" 1 --agent none --no-init' - - assert_failure - assert_output_contains "Registered branch: chore/other-branch" - assert_output_contains "Waiting for input: worktree path already exists" - assert_output_contains "Cannot reuse worktree path '$HOME/worktrees/feature/issue-1-add-login-button': it belongs to branch 'chore/other-branch', not 'feature/issue-1-add-login-button'." - [[ "$output" != *"Worktree ready"* ]] -} - -@test "delete and recreate flow replaces the conflicting branch worktree" { - git worktree add "$TEST_TMPDIR/existing-worktree" -b feature/issue-1-add-login-button master - - run bash -c 'printf "3\n" | "$REPO_ROOT/scripts/start-issue" 1 --agent none --no-init' - - assert_success - assert_output_contains "Removing existing branch/worktree" - assert_output_contains "✅ Cleaned up" - assert_output_contains "✅ Worktree created" - [[ -d "$HOME/worktrees/feature/issue-1-add-login-button" ]] - [[ ! -d "$TEST_TMPDIR/existing-worktree" ]] -} - -@test "flat worktree mode uses flattened path" { - run_start_issue 1 --agent none --dry-run --no-init --flat - - assert_success - assert_output_contains "Path: $HOME/worktrees/feature-issue-1-add-login-button" - assert_output_contains "Would run: git worktree add -b feature/issue-1-add-login-button $HOME/worktrees/feature-issue-1-add-login-button master" -} - -@test "base branch falls back to the current branch when origin HEAD is missing" { - git checkout -q -b develop - - run_start_issue 1 --agent none --dry-run --no-init - - assert_success - assert_output_contains "Could not detect default branch, using current: develop" - assert_output_contains "Base: develop" - assert_output_contains "Would run: git worktree add -b feature/issue-1-add-login-button $HOME/worktrees/feature/issue-1-add-login-button develop" -} - -@test "sanitize_branch_slug strips leading bracketed tag before slug" { - run bash -lc 'set -euo pipefail; source "'"$REPO_ROOT"'/scripts/lib/start_issue/worktree.sh"; sanitize_branch_slug "[brief] add login button"' - - assert_success - [[ "$output" == add-login-button* ]] - [[ "$output" != brief* ]] -} - -@test "sanitize_branch_slug strips multiple leading bracketed tags" { - run bash -lc 'set -euo pipefail; source "'"$REPO_ROOT"'/scripts/lib/start_issue/worktree.sh"; sanitize_branch_slug "[brief][investigation] add login button"' - - assert_success - [[ "$output" == add-login-button* ]] - [[ "$output" != brief* ]] -} - -@test "sanitize_branch_slug transliterates Cyrillic to meaningful Latin slug" { - run bash -lc 'set -euo pipefail; source "'"$REPO_ROOT"'/scripts/lib/start_issue/worktree.sh"; sanitize_branch_slug "Кнопка Отменить выгрузки"' - - assert_success - # Exact match pins the transliteration table; soft/hard signs (ь/ъ) must drop, not transliterate. - [[ "$output" == "knopka-otmenit-vygruzki" ]] -} - -@test "sanitize_branch_slug falls back to 'work' when only a bracketed tag remains" { - run bash -lc 'set -euo pipefail; source "'"$REPO_ROOT"'/scripts/lib/start_issue/worktree.sh"; sanitize_branch_slug "[brief]"' - - assert_success - [[ "$output" == "work" ]] -} - -@test "sanitize_branch_slug falls back to 'work' when title is only soft/hard signs" { - # ь/ъ are deleted (not transliterated), so the slug collapses to empty -> work. - run bash -lc 'set -euo pipefail; source "'"$REPO_ROOT"'/scripts/lib/start_issue/worktree.sh"; sanitize_branch_slug "ьъ"' - - assert_success - [[ "$output" == "work" ]] -} - -@test "sanitize_branch_slug transliterates a mixed Latin/Cyrillic title" { - run bash -lc 'set -euo pipefail; source "'"$REPO_ROOT"'/scripts/lib/start_issue/worktree.sh"; sanitize_branch_slug "Fix Кнопка login"' - - assert_success - [[ "$output" == "fix-knopka-login" ]] -} - -@test "sanitize_branch_slug orders multi-letter digraphs before single-letter rules" { - # Защита exercises щ->shch and several digraphs; a wrong rule order would corrupt it. - run bash -lc 'set -euo pipefail; source "'"$REPO_ROOT"'/scripts/lib/start_issue/worktree.sh"; sanitize_branch_slug "Защита поиска"' - - assert_success - [[ "$output" == "zashchita-poiska" ]] -} - -@test "sanitize_branch_slug lowercases transliterated uppercase Cyrillic" { - run bash -lc 'set -euo pipefail; source "'"$REPO_ROOT"'/scripts/lib/start_issue/worktree.sh"; sanitize_branch_slug "КНОПКА ОТМЕНИТЬ"' - - assert_success - [[ "$output" == "knopka-otmenit" ]] -} - -@test "fast branch naming with cyrillic title and leading bracketed tag yields meaningful slug" { - cat > "$TEST_TMPDIR/issue-cyrillic.json" <<'EOF' -{ - "number": 2980, - "title": "[brief] - Кнопка «Отменить» на странице выгрузки в операторской", - "body": "Some body text.", - "html_url": "https://github.com/owner/repo/issues/2980", - "labels": [ - { - "name": "enhancement" - } - ] -} -EOF - export START_ISSUE_FAKE_ISSUE_JSON="$TEST_TMPDIR/issue-cyrillic.json" - - run_start_issue 2980 --agent none --dry-run --no-init - - assert_success - [[ "$output" != *"/issue-2980-brief"* ]] - [[ "$output" != *"/issue-2980-work"* ]] - assert_output_contains "knopka" -} - -@test "fast branch naming with purely Cyrillic title yields transliterated slug not 'work'" { - cat > "$TEST_TMPDIR/issue-pure-cyrillic.json" <<'EOF' -{ - "number": 42, - "title": "Добавить кнопку входа", - "body": "Добавить кнопку входа в заголовок.", - "html_url": "https://github.com/owner/repo/issues/42", - "labels": [ - { - "name": "enhancement" - } - ] -} -EOF - export START_ISSUE_FAKE_ISSUE_JSON="$TEST_TMPDIR/issue-pure-cyrillic.json" - - run_start_issue 42 --agent none --dry-run --no-init - - assert_success - [[ "$output" != *"/issue-42-work"* ]] - assert_output_contains "dobavit" -} - -@test "fast branch naming reproduces the real issue #3109 title with a meaningful slug" { - # Regression for the real alfagen/mercury#3109 title that the old code turned into - # 'feature/issue-3109-brief-0-20-usd' (leaked [brief] tag, dropped the Cyrillic head word). - cat > "$TEST_TMPDIR/issue-3109.json" <<'EOF' -{ - "number": 3109, - "title": "[brief] - Вознаграждение 0,20 USD в рублёвом эквиваленте при нулевой/отрицательной комиссии", - "body": "Some body text.", - "html_url": "https://github.com/owner/repo/issues/3109", - "labels": [ - { - "name": "enhancement" - } - ] -} -EOF - export START_ISSUE_FAKE_ISSUE_JSON="$TEST_TMPDIR/issue-3109.json" - - run_start_issue 3109 --agent none --dry-run --no-init - - assert_success - [[ "$output" != *"/issue-3109-brief"* ]] - [[ "$output" != *"/issue-3109-work"* ]] - assert_output_contains "voznagrazhdenie" -} From 555d53151e9b49a70e5bdceae2c682c63d26bd2a Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Wed, 22 Jul 2026 21:50:57 +0300 Subject: [PATCH 02/12] Restore Go CLI workflow parity --- README.md | 4 + cmd/start-issue/main.go | 354 ++++++++++++++++++++++++++++++++++- cmd/start-issue/main_test.go | 30 +++ 3 files changed, 378 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a24f9a0..7efa186 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,10 @@ Published releases contain platform-specific Go binaries and a `checksums.txt` manifest. Download the asset matching your OS and architecture from the release page and verify it against that manifest before adding it to `PATH`. +After bootstrapping the Go command, `start-issue install` performs the same +platform selection and SHA-256 verification before installing the latest POSIX +release binary into `~/.local/bin`. + Build and install from source: ```bash diff --git a/cmd/start-issue/main.go b/cmd/start-issue/main.go index b221eee..3f2371e 100644 --- a/cmd/start-issue/main.go +++ b/cmd/start-issue/main.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "crypto/sha256" "encoding/json" "errors" @@ -19,10 +20,11 @@ import ( var version = "2.0.0" type options struct { - repo, base, worktreeDir, agent, model, promptFile, prompt, command string - issue string - dryRun, noInit, flat, ai, improvePrompt, humanGate bool - mode string + repo, base, worktreeDir, agent, model, promptFile, prompt, command string + promptOutput string + issue string + dryRun, noInit, flat, ai, improvePrompt, humanGate, project, user, force bool + mode string } type issue struct { @@ -105,14 +107,24 @@ func parse(args []string) (options, error) { o.ai = true case "--improve-prompt": o.improvePrompt = true + case "--prompt-output-file": + o.promptOutput, err = value() case "--human-gate": o.humanGate = true + case "--project": + o.project = true + case "--user": + o.user = true + case "--force": + o.force = true case "init": o.mode = "init" case "setup", "--setup": o.mode = "setup" case "update", "--update": o.mode = "update" + case "install", "--install": + o.mode = "install" case "--human-gate-help": humanGateHelp() os.Exit(0) @@ -132,6 +144,12 @@ func parse(args []string) (options, error) { if o.prompt != "" && o.promptFile != "" { return o, errors.New("Use either --prompt-file or --prompt, not both.") } + if o.project && o.user { + return o, errors.New("Use either --project or --user, not both.") + } + if (o.project || o.user || o.force) && o.mode != "init" { + return o, errors.New("--project, --user, and --force are only valid with init.") + } return o, nil } @@ -148,6 +166,11 @@ func run(o options) error { if o.humanGate && agent != "codex" { return fmt.Errorf("--human-gate requires agent 'codex'. Current agent: %s.", agent) } + if agent != "none" && !o.dryRun { + if err := need(agent); err != nil { + return fmt.Errorf("%s CLI not found. Install it or use --agent none.", agent) + } + } model, modelSource, err := resolveModel(root, o.model) if err != nil { return err @@ -187,7 +210,18 @@ func run(o options) error { for _, l := range in.Labels { labels = append(labels, l.Name) } + if o.improvePrompt { + return improvePrompt(root, agent, model, prompt, promptSource, o, in, repo, number, strings.Join(labels, ", ")) + } branch := branchName(number, in.Title, strings.Join(labels, ", ")) + if o.ai { + if generated, err := aiBranchName(agent, model, root, number, in.Title, strings.Join(labels, ", ")); err == nil && regexp.MustCompile(`^(feature|fix|hotfix|refactor|docs|test|chore)/issue-[0-9]+-[a-z0-9][a-z0-9-]*$`).MatchString(generated) { + branch = generated + fmt.Printf(" Branch: %s (ai:%s)\n", branch, agent) + } else { + fmt.Printf(" Could not generate branch name with %s; using fast fallback\n", agent) + } + } name := branch if o.flat { name = strings.ReplaceAll(name, "/", "-") @@ -204,7 +238,13 @@ func run(o options) error { return nil } if _, err := os.Stat(worktree); err == nil { - return fmt.Errorf("Worktree path already exists: %s", worktree) + if worktreeBranch(worktree) != "refs/heads/"+branch { + return fmt.Errorf("Cannot reuse worktree path '%s': it does not belong to branch '%s'.", worktree, branch) + } + return launchSelected(o, agent, model, worktree, rendered) + } + if branchWorktree(branch) != "" { + return fmt.Errorf("Branch '%s' already exists in worktree %s.", branch, branchWorktree(branch)) } if err := os.MkdirAll(filepath.Dir(worktree), 0755); err != nil { return err @@ -220,7 +260,7 @@ func run(o options) error { _ = commandAt(worktree, "bash", "./init.sh") } } - return launch(agent, model, worktree, rendered) + return launchSelected(o, agent, model, worktree, rendered) } func runMode(o options) error { @@ -233,8 +273,19 @@ func runMode(o options) error { if o.mode == "update" { return updateMode(o) } + if o.mode == "install" { + return installMode() + } + if o.mode == "setup" { + return setupMode(home) + } dir := filepath.Join(home, ".config", "start-issue") - if o.mode == "init" && root != "" { + if o.mode == "init" && o.project { + if root == "" { + return errors.New("--project requires a git repository") + } + dir = filepath.Join(root, ".start-issue") + } else if o.mode == "init" && !o.user && root != "" { dir = filepath.Join(root, ".start-issue") } if o.dryRun { @@ -248,15 +299,22 @@ func runMode(o options) error { if agent == "" { agent = "claude" } - if err := os.WriteFile(filepath.Join(dir, "agent"), []byte(agent+"\n"), 0644); err != nil { + if err := writeConfig(filepath.Join(dir, "agent"), agent+"\n", o.force); err != nil { return err } if o.model != "" { - if err := os.WriteFile(filepath.Join(dir, "model"), []byte(o.model+"\n"), 0644); err != nil { + if err := writeConfig(filepath.Join(dir, "model"), o.model+"\n", o.force); err != nil { return err } } prompt := o.prompt + if o.promptFile != "" { + b, err := os.ReadFile(o.promptFile) + if err != nil { + return err + } + prompt = string(b) + } if prompt == "" { if agent == "claude" { prompt = "/task-router:route-task {ISSUE_URL}" @@ -264,13 +322,97 @@ func runMode(o options) error { prompt = "Implement GitHub issue {ISSUE_URL} in this worktree." } } - if err := os.WriteFile(filepath.Join(dir, "prompt.md"), []byte(prompt+"\n"), 0644); err != nil { + if err := writeConfig(filepath.Join(dir, "prompt.md"), prompt+"\n", o.force); err != nil { return err } fmt.Printf("Wrote start-issue configuration: %s\n", dir) return nil } +func setupMode(home string) error { + dir := filepath.Join(home, ".config", "start-issue") + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + reader := bufio.NewReader(os.Stdin) + fmt.Println("Select default agent: 1) claude 2) codex 3) kimi 4) pi 5) skip") + fmt.Print("Choice [1]: ") + choice, _ := reader.ReadString('\n') + choice = strings.TrimSpace(choice) + agents := map[string]string{"": "claude", "1": "claude", "2": "codex", "3": "kimi", "4": "pi"} + if agent, ok := agents[choice]; ok { + if err := writeConfig(filepath.Join(dir, "agent"), agent+"\n", false); err != nil { + return err + } + } else if choice != "5" { + return errors.New("invalid setup choice") + } + fmt.Print("Save a default prompt? [Y/n] ") + answer, _ := reader.ReadString('\n') + if strings.TrimSpace(strings.ToLower(answer)) != "n" { + agent := agents[choice] + prompt := "Implement GitHub issue {ISSUE_URL} in this worktree." + if agent == "claude" { + prompt = "/task-router:route-task {ISSUE_URL}" + } + if err := writeConfig(filepath.Join(dir, "prompt.md"), prompt+"\n", false); err != nil { + return err + } + } + fmt.Printf("Wrote start-issue configuration: %s\n", dir) + return nil +} + +func installMode() error { + if runtime.GOOS == "windows" { + return errors.New("Windows installation is manual: download start-issue-windows-amd64.exe from the latest release") + } + data, err := output("gh", "api", "repos/dapi/start-issue/releases/latest") + if err != nil { + return errors.New("Could not fetch the latest start-issue release") + } + var release githubRelease + if err := json.Unmarshal([]byte(data), &release); err != nil { + return err + } + name := releaseAssetName(runtime.GOOS, runtime.GOARCH) + assetURL, checksumURL := release.assetURLs(name) + if assetURL == "" || checksumURL == "" { + return fmt.Errorf("latest release does not contain %s and checksums.txt", name) + } + binary, err := download(assetURL) + if err != nil { + return err + } + checksums, err := download(checksumURL) + if err != nil { + return err + } + if !validChecksum(binary, name, string(checksums)) { + return fmt.Errorf("checksum verification failed for %s", name) + } + home, err := os.UserHomeDir() + if err != nil { + return err + } + target := filepath.Join(home, ".local", "bin", "start-issue") + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + if err := os.WriteFile(target, binary, 0755); err != nil { + return err + } + fmt.Printf("Installed start-issue v%s at: %s\n", strings.TrimPrefix(release.TagName, "v"), target) + return nil +} + +func writeConfig(path, content string, force bool) error { + if _, err := os.Stat(path); err == nil && !force { + return nil + } + return os.WriteFile(path, []byte(content), 0644) +} + func updateMode(o options) error { if runtime.GOOS == "windows" { fmt.Println("Windows update is manual: download start-issue-windows-amd64.exe from the latest release and replace the executable on PATH.") @@ -449,6 +591,24 @@ func resolvePrompt(root, agent string, o options) (string, string, error) { b, e := os.ReadFile(o.promptFile) return string(b), "CLI --prompt-file: " + o.promptFile, e } + if os.Getenv("START_ISSUE_PROMPT_FILE") != "" && os.Getenv("START_ISSUE_PROMPT") != "" { + return "", "", errors.New("Use either START_ISSUE_PROMPT_FILE or START_ISSUE_PROMPT, not both.") + } + if path := os.Getenv("START_ISSUE_PROMPT_FILE"); path != "" { + b, e := os.ReadFile(path) + return string(b), "START_ISSUE_PROMPT_FILE: " + path, e + } + if value := os.Getenv("START_ISSUE_PROMPT"); value != "" { + return value, "START_ISSUE_PROMPT", nil + } + home, _ := os.UserHomeDir() + for _, path := range []string{filepath.Join(root, ".start-issue", "prompt.md"), filepath.Join(home, ".config", "start-issue", "prompt.md")} { + if b, e := os.ReadFile(path); e == nil { + return string(b), path, nil + } else if !os.IsNotExist(e) { + return "", "", e + } + } if agent == "claude" { if o.command != "" { return o.command + " {ISSUE_URL}", "built-in Claude command", nil @@ -516,6 +676,27 @@ func containsAny(s string, terms ...string) bool { } return false } +func aiBranchName(agent, model, root, number, title, labels string) (string, error) { + if agent == "none" { + return "", errors.New("no agent") + } + prompt := fmt.Sprintf("Git branch name for issue #%s: %q (labels: %s). Reply with ONLY {type}/issue-%s-{kebab-case-name}.", number, title, labels, number) + args := launchArgs(agent, model, root, prompt) + if agent == "claude" { + args = append([]string{"claude", "--print"}, args[1:]...) + } else if agent == "codex" { + args = append([]string{"codex", "exec", "--sandbox", "read-only", "--skip-git-repo-check"}, args[1:]...) + } + result, err := output(args[0], args[1:]...) + if err != nil { + return "", err + } + lines := strings.Fields(strings.Trim(strings.TrimSpace(result), "`\"")) + if len(lines) == 0 { + return "", errors.New("empty branch") + } + return lines[len(lines)-1], nil +} func slugify(title string) string { title = regexp.MustCompile(`^(\[[^]]*\][\s-]*)+`).ReplaceAllString(title, "") @@ -552,7 +733,158 @@ func render(s string, m map[string]string) string { } return s } +func branchWorktree(branch string) string { + value, err := output("git", "worktree", "list", "--porcelain") + if err != nil { + return "" + } + current := "" + for _, line := range strings.Split(value, "\n") { + if strings.HasPrefix(line, "worktree ") { + current = strings.TrimPrefix(line, "worktree ") + } + if line == "branch refs/heads/"+branch { + return current + } + } + return "" +} +func worktreeBranch(path string) string { + value, err := output("git", "worktree", "list", "--porcelain") + if err != nil { + return "" + } + current := "" + for _, line := range strings.Split(value, "\n") { + if strings.HasPrefix(line, "worktree ") { + current = strings.TrimPrefix(line, "worktree ") + } + if current == path && strings.HasPrefix(line, "branch ") { + return strings.TrimPrefix(line, "branch ") + } + } + return "" +} +func launchSelected(o options, agent, model, worktree, prompt string) error { + if o.humanGate { + return humanGate(model, worktree, prompt, o.dryRun) + } + return launch(agent, model, worktree, prompt) +} +func improvePrompt(root, agent, model, prompt, source string, o options, in issue, repo, number, labels string) error { + if agent == "none" { + return errors.New("--improve-prompt requires an agent. Use --agent claude, codex, kimi, or pi.") + } + outputPath := o.promptOutput + if outputPath == "" { + if o.promptFile != "" { + outputPath = strings.TrimSuffix(o.promptFile, filepath.Ext(o.promptFile)) + ".improved.md" + } else { + outputPath = filepath.Join(root, ".start-issue", "prompt.improved.md") + } + } + if o.dryRun { + fmt.Printf("📝 Improving prompt template...\n Prompt source: %s\n Proposal path: %s\n [DRY-RUN] Would ask %s to generate an improved prompt proposal.\n", source, outputPath, agent) + return nil + } + if _, err := os.Stat(outputPath); err == nil { + return fmt.Errorf("Prompt improvement output already exists: %s", outputPath) + } + request := fmt.Sprintf("Improve the following start-issue prompt template. Return ONLY the complete improved prompt template.\n\nRepository: %s\nIssue #%s: %s\nLabels: %s\nBody:\n%s\n\nPrompt:\n%s", repo, number, in.Title, labels, in.Body, prompt) + args := launchArgs(agent, model, root, request) + if agent == "claude" { + args = append([]string{"claude", "--print"}, args[1:]...) + } else if agent == "codex" { + args = append([]string{"codex", "exec", "--sandbox", "read-only", "--skip-git-repo-check"}, args[1:]...) + } + result, err := output(args[0], args[1:]...) + if err != nil { + return fmt.Errorf("Could not generate improved prompt with %s", agent) + } + if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { + return err + } + if err := os.WriteFile(outputPath, []byte(strings.TrimSpace(result)+"\n"), 0644); err != nil { + return err + } + fmt.Printf("📝 Prompt improvement written: %s\n", outputPath) + return nil +} +func humanGate(model, worktree, prompt string, dryRun bool) error { + runID := os.Getenv("START_ISSUE_RUN_ID") + if runID == "" { + runID = "latest" + } + dir := filepath.Join(worktree, ".start-issue", "runs", runID) + events, last := filepath.Join(dir, "events.jsonl"), filepath.Join(dir, "last-message.txt") + args := []string{"exec", "--cd", worktree, "--ask-for-approval", "never", "--sandbox", "workspace-write", "--json", "--output-last-message", last, "-"} + if model != "" { + args = append([]string{"exec", "--model", model}, args[1:]...) + } + if dryRun { + fmt.Printf(" [DRY-RUN] Would run: codex %s\n", strings.Join(args, " ")) + return nil + } + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + file, err := os.Create(events) + if err != nil { + return err + } + defer file.Close() + cmd := exec.Command("codex", args...) + cmd.Dir = worktree + cmd.Stdin = strings.NewReader(prompt) + cmd.Stdout = file + cmd.Stderr = os.Stderr + _ = cmd.Run() + body, err := os.ReadFile(last) + if err != nil { + return fmt.Errorf("No recognized final status found. Inspect: %s", last) + } + status := "" + for _, line := range strings.Split(string(body), "\n") { + if strings.HasPrefix(line, "STATUS:") { + status = strings.TrimSpace(strings.TrimPrefix(line, "STATUS:")) + break + } + } + if status == "DONE" { + fmt.Println("✅ Codex finished with STATUS: DONE") + return nil + } + if status == "HUMAN_GATE" { + eventsBody, err := os.ReadFile(events) + if err != nil { + return err + } + threadID := "" + for _, line := range strings.Split(string(eventsBody), "\n") { + var event struct { + Type string `json:"type"` + ThreadID string `json:"thread_id"` + } + if json.Unmarshal([]byte(line), &event) == nil && event.Type == "thread.started" { + threadID = event.ThreadID + break + } + } + if threadID == "" { + return fmt.Errorf("Codex human-gate run did not capture thread_id. Inspect: %s", events) + } + if err := os.WriteFile(filepath.Join(dir, "thread-id"), []byte(threadID+"\n"), 0644); err != nil { + return err + } + return command("codex", "resume", "--include-non-interactive", threadID) + } + return fmt.Errorf("No recognized final status found. Inspect: %s", last) +} func printLaunch(a, m, w, p string) { + if a == "none" { + fmt.Printf(" Agent: none\n Model: %s\n [DRY-RUN] Would prepare worktree without launching an agent\n", show(m)) + return + } fmt.Printf(" Agent: %s\n Model: %s\n [DRY-RUN] Would run: %s\n", a, show(m), strings.Join(launchArgs(a, m, w, p), " ")) } func launch(a, m, w, p string) error { @@ -564,6 +896,8 @@ func launch(a, m, w, p string) error { } func launchArgs(a, m, w, p string) []string { switch a { + case "none": + return nil case "claude": x := []string{"claude"} if m != "" { diff --git a/cmd/start-issue/main_test.go b/cmd/start-issue/main_test.go index 7d24670..a366142 100644 --- a/cmd/start-issue/main_test.go +++ b/cmd/start-issue/main_test.go @@ -3,6 +3,8 @@ package main import ( "crypto/sha256" "fmt" + "os" + "path/filepath" "testing" ) @@ -66,3 +68,31 @@ func TestCompareVersions(t *testing.T) { t.Fatal("unexpected version ordering") } } + +func TestParseInitOptions(t *testing.T) { + o, err := parse([]string{"init", "--project", "--force", "--prompt-file", "prompt.md"}) + if err != nil || o.mode != "init" || !o.project || !o.force || o.promptFile != "prompt.md" { + t.Fatalf("unexpected options: %#v, %v", o, err) + } +} + +func TestResolvePromptPrefersProjectConfig(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, ".start-issue") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "prompt.md"), []byte("project {ISSUE_URL}"), 0644); err != nil { + t.Fatal(err) + } + got, source, err := resolvePrompt(root, "codex", options{}) + if err != nil || got != "project {ISSUE_URL}" || source != filepath.Join(dir, "prompt.md") { + t.Fatalf("got %q %q %v", got, source, err) + } +} + +func TestLaunchArgsNoneIsEmpty(t *testing.T) { + if got := launchArgs("none", "", "", ""); len(got) != 0 { + t.Fatalf("got %q", got) + } +} From a65c13dbda4cfa65388057713dab95da56300591 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Wed, 22 Jul 2026 22:20:46 +0300 Subject: [PATCH 03/12] Restore remaining Go workflow behavior --- cmd/start-issue/main.go | 96 +++++++++++++++++++++++++++++------- cmd/start-issue/main_test.go | 11 +++++ 2 files changed, 90 insertions(+), 17 deletions(-) diff --git a/cmd/start-issue/main.go b/cmd/start-issue/main.go index 3f2371e..e8e74b5 100644 --- a/cmd/start-issue/main.go +++ b/cmd/start-issue/main.go @@ -15,6 +15,7 @@ import ( "runtime" "strconv" "strings" + "time" ) var version = "2.0.0" @@ -159,6 +160,9 @@ func run(o options) error { return errors.New("Not in a git repository") } root = strings.TrimSpace(root) + if err := maybeRunFirstRunOnboarding(); err != nil { + return err + } agent, agentSource, err := resolveAgent(root, o.agent) if err != nil { return err @@ -210,6 +214,13 @@ func run(o options) error { for _, l := range in.Labels { labels = append(labels, l.Name) } + if _, err := exec.LookPath("zellij-tab-status"); err == nil { + if o.dryRun { + fmt.Printf(" [DRY-RUN] Would run: zellij-tab-status --set-name #%s\n", number) + } else { + _ = command("zellij-tab-status", "--set-name", "#"+number) + } + } if o.improvePrompt { return improvePrompt(root, agent, model, prompt, promptSource, o, in, repo, number, strings.Join(labels, ", ")) } @@ -241,6 +252,9 @@ func run(o options) error { if worktreeBranch(worktree) != "refs/heads/"+branch { return fmt.Errorf("Cannot reuse worktree path '%s': it does not belong to branch '%s'.", worktree, branch) } + if !o.noInit { + runInit(worktree) + } return launchSelected(o, agent, model, worktree, rendered) } if branchWorktree(branch) != "" { @@ -249,20 +263,44 @@ func run(o options) error { if err := os.MkdirAll(filepath.Dir(worktree), 0755); err != nil { return err } + _ = command("git", "fetch", "origin", o.base, "--quiet") if err := command("git", "worktree", "add", "-b", branch, worktree, "origin/"+o.base); err != nil { if err = command("git", "worktree", "add", "-b", branch, worktree, o.base); err != nil { return errors.New("Failed to create worktree") } } if !o.noInit { - init := filepath.Join(worktree, "init.sh") - if _, err := os.Stat(init); err == nil { - _ = commandAt(worktree, "bash", "./init.sh") - } + runInit(worktree) } return launchSelected(o, agent, model, worktree, rendered) } +func runInit(worktree string) { + if init := filepath.Join(worktree, "init.sh"); fileExists(init) { + _ = commandAt(worktree, "bash", "./init.sh") + } +} +func fileExists(path string) bool { _, err := os.Stat(path); return err == nil } +func maybeRunFirstRunOnboarding() error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + dir := filepath.Join(home, ".config", "start-issue") + if _, err := os.Stat(dir); err == nil { + return nil + } else if !os.IsNotExist(err) { + return err + } + reader := bufio.NewReader(os.Stdin) + fmt.Print("No start-issue user configuration found. Run setup now? [Y/n] ") + answer, _ := reader.ReadString('\n') + if strings.TrimSpace(strings.ToLower(answer)) == "n" { + return os.MkdirAll(dir, 0755) + } + return setupMode(home) +} + func runMode(o options) error { home, err := os.UserHomeDir() if err != nil { @@ -681,12 +719,7 @@ func aiBranchName(agent, model, root, number, title, labels string) (string, err return "", errors.New("no agent") } prompt := fmt.Sprintf("Git branch name for issue #%s: %q (labels: %s). Reply with ONLY {type}/issue-%s-{kebab-case-name}.", number, title, labels, number) - args := launchArgs(agent, model, root, prompt) - if agent == "claude" { - args = append([]string{"claude", "--print"}, args[1:]...) - } else if agent == "codex" { - args = append([]string{"codex", "exec", "--sandbox", "read-only", "--skip-git-repo-check"}, args[1:]...) - } + args := helperArgs(agent, model, root, prompt) result, err := output(args[0], args[1:]...) if err != nil { return "", err @@ -791,12 +824,7 @@ func improvePrompt(root, agent, model, prompt, source string, o options, in issu return fmt.Errorf("Prompt improvement output already exists: %s", outputPath) } request := fmt.Sprintf("Improve the following start-issue prompt template. Return ONLY the complete improved prompt template.\n\nRepository: %s\nIssue #%s: %s\nLabels: %s\nBody:\n%s\n\nPrompt:\n%s", repo, number, in.Title, labels, in.Body, prompt) - args := launchArgs(agent, model, root, request) - if agent == "claude" { - args = append([]string{"claude", "--print"}, args[1:]...) - } else if agent == "codex" { - args = append([]string{"codex", "exec", "--sandbox", "read-only", "--skip-git-repo-check"}, args[1:]...) - } + args := helperArgs(agent, model, root, request) result, err := output(args[0], args[1:]...) if err != nil { return fmt.Errorf("Could not generate improved prompt with %s", agent) @@ -813,7 +841,7 @@ func improvePrompt(root, agent, model, prompt, source string, o options, in issu func humanGate(model, worktree, prompt string, dryRun bool) error { runID := os.Getenv("START_ISSUE_RUN_ID") if runID == "" { - runID = "latest" + runID = time.Now().Format("20060102-150405") } dir := filepath.Join(worktree, ".start-issue", "runs", runID) events, last := filepath.Join(dir, "events.jsonl"), filepath.Join(dir, "last-message.txt") @@ -924,6 +952,40 @@ func launchArgs(a, m, w, p string) []string { return append(x, p) } } + +func helperArgs(agent, model, root, prompt string) []string { + withModel := func(args []string) []string { + if model != "" { + return append(args, "--model", model) + } + return args + } + switch agent { + case "claude": + args := withModel([]string{"claude", "--print"}) + return append(args, "--no-session-persistence", "--disable-slash-commands", prompt) + case "codex": + args := []string{"codex", "exec"} + if model != "" { + args = append(args, "--model", model) + } + return append(args, "--cd", root, "--sandbox", "read-only", "--skip-git-repo-check", prompt) + case "kimi": + args := []string{"kimi"} + if model != "" { + args = append(args, "--model", model) + } + return append(args, "--work-dir", root, "--quiet", "-p", prompt) + case "pi": + args := []string{"pi"} + if model != "" { + args = append(args, "--model", model) + } + return append(args, "--print", "--no-tools", "--no-session", prompt) + default: + return nil + } +} func command(name string, args ...string) error { c := exec.Command(name, args...) c.Stdout = os.Stdout diff --git a/cmd/start-issue/main_test.go b/cmd/start-issue/main_test.go index a366142..a6938bc 100644 --- a/cmd/start-issue/main_test.go +++ b/cmd/start-issue/main_test.go @@ -96,3 +96,14 @@ func TestLaunchArgsNoneIsEmpty(t *testing.T) { t.Fatalf("got %q", got) } } + +func TestHelperArgsAreNonInteractive(t *testing.T) { + pi := helperArgs("pi", "", "/repo", "prompt") + if got := fmt.Sprint(pi); got != "[pi --print --no-tools --no-session prompt]" { + t.Fatalf("pi helper args: %s", got) + } + kimi := helperArgs("kimi", "model", "/repo", "prompt") + if got := fmt.Sprint(kimi); got != "[kimi --model model --work-dir /repo --quiet -p prompt]" { + t.Fatalf("kimi helper args: %s", got) + } +} From d61fb0d7f1a19f960e42fe3812a7989319df7364 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Wed, 22 Jul 2026 22:35:21 +0300 Subject: [PATCH 04/12] Restore installation and CLI parity details --- cmd/start-issue/main.go | 70 +++++++++++++-- install.sh | 184 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 9 deletions(-) create mode 100755 install.sh diff --git a/cmd/start-issue/main.go b/cmd/start-issue/main.go index e8e74b5..d271b85 100644 --- a/cmd/start-issue/main.go +++ b/cmd/start-issue/main.go @@ -48,7 +48,8 @@ func main() { } if o.issue == "" { usage() - return + fmt.Fprintln(os.Stderr, "Error: is required.") + os.Exit(1) } if err := run(o); err != nil { die(err) @@ -151,6 +152,9 @@ func parse(args []string) (options, error) { if (o.project || o.user || o.force) && o.mode != "init" { return o, errors.New("--project, --user, and --force are only valid with init.") } + if o.mode != "" && o.issue != "" { + return o, fmt.Errorf("Use either %s or , not both.", o.mode) + } return o, nil } @@ -252,6 +256,9 @@ func run(o options) error { if worktreeBranch(worktree) != "refs/heads/"+branch { return fmt.Errorf("Cannot reuse worktree path '%s': it does not belong to branch '%s'.", worktree, branch) } + if !confirm("Worktree already exists. Continue in " + worktree + "? [y/N] ") { + return errors.New("Cancelled.") + } if !o.noInit { runInit(worktree) } @@ -324,7 +331,9 @@ func runMode(o options) error { } dir = filepath.Join(root, ".start-issue") } else if o.mode == "init" && !o.user && root != "" { - dir = filepath.Join(root, ".start-issue") + if confirm("Initialize project configuration? [y/N] ") { + dir = filepath.Join(root, ".start-issue") + } } if o.dryRun { fmt.Printf("[DRY-RUN] Would create configuration in: %s\n", dir) @@ -345,6 +354,9 @@ func runMode(o options) error { return err } } + if o.force && o.model == "" { + _ = os.Remove(filepath.Join(dir, "model")) + } prompt := o.prompt if o.promptFile != "" { b, err := os.ReadFile(o.promptFile) @@ -357,7 +369,7 @@ func runMode(o options) error { if agent == "claude" { prompt = "/task-router:route-task {ISSUE_URL}" } else { - prompt = "Implement GitHub issue {ISSUE_URL} in this worktree." + prompt = defaultPortablePrompt() } } if err := writeConfig(filepath.Join(dir, "prompt.md"), prompt+"\n", o.force); err != nil { @@ -379,23 +391,27 @@ func setupMode(home string) error { choice = strings.TrimSpace(choice) agents := map[string]string{"": "claude", "1": "claude", "2": "codex", "3": "kimi", "4": "pi"} if agent, ok := agents[choice]; ok { - if err := writeConfig(filepath.Join(dir, "agent"), agent+"\n", false); err != nil { + if err := writeConfig(filepath.Join(dir, "agent"), agent+"\n", true); err != nil { return err } - } else if choice != "5" { + } else if choice == "5" { + _ = os.Remove(filepath.Join(dir, "agent")) + } else { return errors.New("invalid setup choice") } fmt.Print("Save a default prompt? [Y/n] ") answer, _ := reader.ReadString('\n') if strings.TrimSpace(strings.ToLower(answer)) != "n" { agent := agents[choice] - prompt := "Implement GitHub issue {ISSUE_URL} in this worktree." + prompt := defaultPortablePrompt() if agent == "claude" { prompt = "/task-router:route-task {ISSUE_URL}" } - if err := writeConfig(filepath.Join(dir, "prompt.md"), prompt+"\n", false); err != nil { + if err := writeConfig(filepath.Join(dir, "prompt.md"), prompt+"\n", true); err != nil { return err } + } else { + _ = os.Remove(filepath.Join(dir, "prompt.md")) } fmt.Printf("Wrote start-issue configuration: %s\n", dir) return nil @@ -653,7 +669,20 @@ func resolvePrompt(root, agent string, o options) (string, string, error) { } return "/task-router:route-task {ISSUE_URL}", "built-in Claude command", nil } - return "Implement GitHub issue {ISSUE_URL} in this worktree.", "built-in portable prompt", nil + return defaultPortablePrompt(), "built-in portable prompt", nil +} +func defaultPortablePrompt() string { + return `Implement GitHub issue {ISSUE_URL} in this worktree. + +Context: +- Repo: {REPO} +- Issue: #{ISSUE_NUMBER} +- Title: {ISSUE_TITLE} +- Branch: {BRANCH_NAME} +- Worktree: {WORKTREE_PATH} + +Start by reading the issue with gh if needed. Follow repository instructions. Keep changes scoped. Run relevant tests or checks. Summarize changed files and verification before finishing. +If you open a PR for this work, target the base branch {BASE_BRANCH}.` } func parseIssue(v, repo string) (string, string, error) { re := regexp.MustCompile(`^https://github\.com/([^/]+)/([^/]+)/issues/([0-9]+)`) @@ -913,6 +942,9 @@ func printLaunch(a, m, w, p string) { fmt.Printf(" Agent: none\n Model: %s\n [DRY-RUN] Would prepare worktree without launching an agent\n", show(m)) return } + if len(p) > 4000 && os.Getenv("START_ISSUE_DUMP_PROMPT") != "1" { + p = "" + } fmt.Printf(" Agent: %s\n Model: %s\n [DRY-RUN] Would run: %s\n", a, show(m), strings.Join(launchArgs(a, m, w, p), " ")) } func launch(a, m, w, p string) error { @@ -1018,8 +1050,28 @@ func show(v string) string { return v } func die(e error) { fmt.Fprintln(os.Stderr, "Error:", e); os.Exit(1) } +func confirm(message string) bool { + fmt.Print(message) + answer, _ := bufio.NewReader(os.Stdin).ReadString('\n') + return strings.TrimSpace(strings.ToLower(answer)) == "y" || strings.TrimSpace(strings.ToLower(answer)) == "yes" +} func usage() { - fmt.Printf("start-issue v%s\n\nUsage: start-issue [options]\n", version) + fmt.Printf(`start-issue v%s + +Usage: start-issue [options] + start-issue init [--project|--user] [--force] [options] + start-issue setup | update | install + +Options: + --repo, -r --base, -b --worktree-dir, -w + --agent --model + --prompt-file --prompt --improve-prompt --prompt-output-file + --no-agent --no-init --flat --ai --human-gate --dry-run + --project --user --force --version, -v --help, -h + +Environment: START_ISSUE_AGENT, START_ISSUE_MODEL, START_ISSUE_PROMPT, +START_ISSUE_PROMPT_FILE, START_ISSUE_WORKTREE_DIR, START_ISSUE_DUMP_PROMPT +`, version) } func humanGateHelp() { diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..dd93ee6 --- /dev/null +++ b/install.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO="${START_ISSUE_REPOSITORY:-dapi/start-issue}" +PREFIX="${PREFIX:-$HOME/.local}" +BINDIR="${BINDIR:-$PREFIX/bin}" +TARGET="${TARGET:-$BINDIR/start-issue}" +ASSET_URL="${START_ISSUE_ASSET_URL:-}" +CHECKSUM_URL="${START_ISSUE_CHECKSUM_URL:-}" +DEBUG=0 + +log() { + printf '%s\n' "$1" +} + +debug() { + if [[ "$DEBUG" -eq 1 ]]; then + printf 'DEBUG: %s\n' "$1" >&2 + fi +} + +die() { + printf 'Error: %s\n' "$1" >&2 + exit 1 +} + +# This script is deliberately self-contained: the documented installation +# command pipes it to Bash, where no repository directory or sibling modules +# are available. +release_fetch() { + local url="$1" + local output="$2" + + if command -v curl >/dev/null 2>&1; then + if [[ "${RELEASE_FETCH_VERBOSE:-0}" == "1" ]]; then + curl -fL -v "$url" -o "$output" + else + curl -fsSL "$url" -o "$output" + fi + return + fi + + if command -v wget >/dev/null 2>&1; then + if [[ "${RELEASE_FETCH_VERBOSE:-0}" == "1" ]]; then + wget -O "$output" "$url" + else + wget -qO "$output" "$url" + fi + return + fi + + die "Neither curl nor wget is installed." +} + +release_sha256_file() { + local path="$1" + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$path" | awk '{ print $1 }' + return + fi + + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$path" | awk '{ print $1 }' + return + fi + + if command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "$path" | awk '{ print $NF }' + return + fi + + die "No SHA-256 tool found. Install sha256sum, shasum, or openssl." +} + +release_install_verified_asset() { + local asset_url="$1" + local checksum_url="$2" + local target_path="$3" + local tmpdir + local tmpfile + local checksum_file + local expected_checksum + local actual_checksum + local cleanup_cmd + + tmpdir="$(mktemp -d)" + printf -v cleanup_cmd 'rm -rf %q' "$tmpdir" + # shellcheck disable=SC2064 + trap "$cleanup_cmd" RETURN + + tmpfile="$tmpdir/start-issue" + checksum_file="$tmpdir/checksums.txt" + + if declare -F debug >/dev/null 2>&1; then + debug "Fetching $asset_url -> $tmpfile" + fi + release_fetch "$asset_url" "$tmpfile" || die "Failed to download release asset: $asset_url" + if declare -F debug >/dev/null 2>&1; then + debug "Fetching $checksum_url -> $checksum_file" + fi + release_fetch "$checksum_url" "$checksum_file" || die "Failed to download release checksum: $checksum_url" + + if declare -F debug >/dev/null 2>&1; then + debug "Verifying checksum" + fi + expected_checksum="$(awk -v asset="$(basename "$asset_url")" '$2 == asset || $2 == "*" asset { print $1; exit }' "$checksum_file")" + actual_checksum="$(release_sha256_file "$tmpfile")" + + if [[ -z "$expected_checksum" ]]; then + die "Downloaded checksum file is empty." + fi + + if [[ "$expected_checksum" != "$actual_checksum" ]]; then + die "Checksum verification failed." + fi + + if declare -F debug >/dev/null 2>&1; then + debug "Installing binary into $target_path" + fi + mkdir -p "$(dirname "$target_path")" + install -m 0755 "$tmpfile" "$target_path" || die "Failed to install updated release to $target_path" +} + +usage() { + cat <<'EOF' +Usage: install.sh [--debug] + +Options: + --debug Enable verbose installer diagnostics. + --help Show this help. +EOF +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --debug) + DEBUG=1 + ;; + --help|-h) + usage + exit 0 + ;; + *) + die "Unknown argument: $1" + ;; + esac + shift + done +} + +main() { + parse_args "$@" + + if [[ -z "$ASSET_URL" ]]; then + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + case "$(uname -m)" in x86_64|amd64) arch=amd64 ;; arm64|aarch64) arch=arm64 ;; *) die "Unsupported architecture: $(uname -m)" ;; esac + case "$os" in linux|darwin) ;; *) die "Unsupported OS: $os" ;; esac + asset="start-issue-$os-$arch" + ASSET_URL="https://github.com/$REPO/releases/latest/download/$asset" + CHECKSUM_URL="https://github.com/$REPO/releases/latest/download/checksums.txt" + fi + + if [[ "$DEBUG" -eq 1 ]]; then + PS4='+ install.sh:${LINENO}: ' + set -x + export RELEASE_FETCH_VERBOSE=1 + debug "Repository: $REPO" + debug "Install target: $TARGET" + debug "Asset URL: $ASSET_URL" + debug "Checksum URL: $CHECKSUM_URL" + fi + + log "Downloading latest release from $REPO" + mkdir -p "$BINDIR" + release_install_verified_asset "$ASSET_URL" "$CHECKSUM_URL" "$TARGET" + + log "Installed: $TARGET" + log "Version: $("$TARGET" --version)" +} + +main "$@" From 990426d1a28ad6dbcca6ec7d644291822ac59c23 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Wed, 22 Jul 2026 22:47:36 +0300 Subject: [PATCH 05/12] Fix Go v2 and dry-run contracts --- README.md | 2 +- cmd/start-issue/main.go | 42 +++++++++++++++++++++++++++++++++-------- go.mod | 2 +- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 7efa186..912ca9d 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ It fetches issue metadata with `gh`, creates a git worktree with a branch name b Install from source with Go: ```bash -go install github.com/dapi/start-issue/cmd/start-issue@latest +go install github.com/dapi/start-issue/v2/cmd/start-issue@latest ``` Published releases contain platform-specific Go binaries and a `checksums.txt` diff --git a/cmd/start-issue/main.go b/cmd/start-issue/main.go index d271b85..1a0f946 100644 --- a/cmd/start-issue/main.go +++ b/cmd/start-issue/main.go @@ -164,7 +164,7 @@ func run(o options) error { return errors.New("Not in a git repository") } root = strings.TrimSpace(root) - if err := maybeRunFirstRunOnboarding(); err != nil { + if err := maybeRunFirstRunOnboarding(o.dryRun); err != nil { return err } agent, agentSource, err := resolveAgent(root, o.agent) @@ -249,6 +249,9 @@ func run(o options) error { fmt.Printf(" Branch: %s (fast)\n📁 Creating worktree...\n Path: %s\n Base: %s\n", branch, worktree, o.base) if o.dryRun { fmt.Printf(" [DRY-RUN] Would run: git worktree add -b %s %s %s\n", branch, worktree, o.base) + if o.humanGate { + return humanGate(model, worktree, rendered, true) + } printLaunch(agent, model, worktree, rendered) return nil } @@ -288,7 +291,7 @@ func runInit(worktree string) { } } func fileExists(path string) bool { _, err := os.Stat(path); return err == nil } -func maybeRunFirstRunOnboarding() error { +func maybeRunFirstRunOnboarding(dryRun bool) error { home, err := os.UserHomeDir() if err != nil { return err @@ -302,10 +305,14 @@ func maybeRunFirstRunOnboarding() error { reader := bufio.NewReader(os.Stdin) fmt.Print("No start-issue user configuration found. Run setup now? [Y/n] ") answer, _ := reader.ReadString('\n') + if dryRun { + fmt.Printf("[DRY-RUN] Would create first-run configuration marker: %s\n", dir) + return nil + } if strings.TrimSpace(strings.ToLower(answer)) == "n" { return os.MkdirAll(dir, 0755) } - return setupMode(home) + return setupMode(home, false) } func runMode(o options) error { @@ -322,7 +329,7 @@ func runMode(o options) error { return installMode() } if o.mode == "setup" { - return setupMode(home) + return setupMode(home, o.dryRun) } dir := filepath.Join(home, ".config", "start-issue") if o.mode == "init" && o.project { @@ -342,9 +349,9 @@ func runMode(o options) error { if err := os.MkdirAll(dir, 0755); err != nil { return err } - agent := o.agent - if agent == "" { - agent = "claude" + agent, _, err := resolveAgent(root, o.agent) + if err != nil { + return err } if err := writeConfig(filepath.Join(dir, "agent"), agent+"\n", o.force); err != nil { return err @@ -379,8 +386,12 @@ func runMode(o options) error { return nil } -func setupMode(home string) error { +func setupMode(home string, dryRun bool) error { dir := filepath.Join(home, ".config", "start-issue") + if dryRun { + fmt.Printf("[DRY-RUN] Would create configuration in: %s\n", dir) + return nil + } if err := os.MkdirAll(dir, 0755); err != nil { return err } @@ -403,6 +414,9 @@ func setupMode(home string) error { answer, _ := reader.ReadString('\n') if strings.TrimSpace(strings.ToLower(answer)) != "n" { agent := agents[choice] + if agent == "" { + agent = "claude" + } prompt := defaultPortablePrompt() if agent == "claude" { prompt = "/task-router:route-task {ISSUE_URL}" @@ -484,6 +498,11 @@ func updateMode(o options) error { fmt.Printf("start-issue is already up to date (%s).\n", version) return nil } + if o.dryRun { + target, _ := os.Executable() + fmt.Printf("[DRY-RUN] Would download %s, verify checksums.txt, and replace: %s\n", releaseAssetName(runtime.GOOS, runtime.GOARCH), target) + return nil + } assetName := releaseAssetName(runtime.GOOS, runtime.GOARCH) assetURL, checksumURL := release.assetURLs(assetName) if assetURL == "" || checksumURL == "" { @@ -841,6 +860,10 @@ func improvePrompt(root, agent, model, prompt, source string, o options, in issu if outputPath == "" { if o.promptFile != "" { outputPath = strings.TrimSuffix(o.promptFile, filepath.Ext(o.promptFile)) + ".improved.md" + } else if fileSource := strings.TrimPrefix(source, "START_ISSUE_PROMPT_FILE: "); fileExists(fileSource) { + outputPath = strings.TrimSuffix(fileSource, filepath.Ext(fileSource)) + ".improved.md" + } else if fileExists(source) { + outputPath = strings.TrimSuffix(source, filepath.Ext(source)) + ".improved.md" } else { outputPath = filepath.Join(root, ".start-issue", "prompt.improved.md") } @@ -994,6 +1017,9 @@ func helperArgs(agent, model, root, prompt string) []string { } switch agent { case "claude": + if model == "" { + model = "haiku" + } args := withModel([]string{"claude", "--print"}) return append(args, "--no-session-persistence", "--disable-slash-commands", prompt) case "codex": diff --git a/go.mod b/go.mod index 299c68f..50874c7 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module github.com/dapi/start-issue +module github.com/dapi/start-issue/v2 go 1.21 From 0d0af2ce30a101bd4e98d62ff4be92e1800e2cf5 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Wed, 22 Jul 2026 22:47:59 +0300 Subject: [PATCH 06/12] Handle existing worktree branches safely --- cmd/start-issue/main.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/cmd/start-issue/main.go b/cmd/start-issue/main.go index 1a0f946..103b0c2 100644 --- a/cmd/start-issue/main.go +++ b/cmd/start-issue/main.go @@ -268,7 +268,17 @@ func run(o options) error { return launchSelected(o, agent, model, worktree, rendered) } if branchWorktree(branch) != "" { - return fmt.Errorf("Branch '%s' already exists in worktree %s.", branch, branchWorktree(branch)) + existing := branchWorktree(branch) + if !confirm("Branch already exists in " + existing + ". Continue there? [y/N] ") { + return errors.New("Cancelled.") + } + if !o.noInit { + runInit(existing) + } + return launchSelected(o, agent, model, existing, rendered) + } + if _, err := output("git", "show-ref", "--verify", "--quiet", "refs/heads/"+branch); err == nil { + return fmt.Errorf("Branch '%s' exists but is not attached to a worktree. Choose a new branch name or remove it before continuing.", branch) } if err := os.MkdirAll(filepath.Dir(worktree), 0755); err != nil { return err From 728d6b8a0e807b2af5f6b189b11bcbd28259b27c Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Wed, 22 Jul 2026 22:53:20 +0300 Subject: [PATCH 07/12] Refine worktree conflict planning --- cmd/start-issue/main.go | 51 +++++++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/cmd/start-issue/main.go b/cmd/start-issue/main.go index 103b0c2..d69ab2f 100644 --- a/cmd/start-issue/main.go +++ b/cmd/start-issue/main.go @@ -230,7 +230,7 @@ func run(o options) error { } branch := branchName(number, in.Title, strings.Join(labels, ", ")) if o.ai { - if generated, err := aiBranchName(agent, model, root, number, in.Title, strings.Join(labels, ", ")); err == nil && regexp.MustCompile(`^(feature|fix|hotfix|refactor|docs|test|chore)/issue-[0-9]+-[a-z0-9][a-z0-9-]*$`).MatchString(generated) { + if generated, err := aiBranchName(agent, model, root, number, in.Title, strings.Join(labels, ", ")); err == nil && regexp.MustCompile(`^(feature|fix|hotfix|refactor|docs|test|chore)/issue-[0-9]+-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`).MatchString(generated) { branch = generated fmt.Printf(" Branch: %s (ai:%s)\n", branch, agent) } else { @@ -247,18 +247,14 @@ func run(o options) error { fmt.Printf("Agent: %s\nAgent source: %s\nModel: %s\nModel source: %s\nWorktree directory: %s\nPrompt source: %s\n\n", agent, agentSource, show(model), modelSource, o.worktreeDir, promptSource) fmt.Printf("🔍 Fetching issue #%s from %s...\n Title: %s\n", number, repo, in.Title) fmt.Printf(" Branch: %s (fast)\n📁 Creating worktree...\n Path: %s\n Base: %s\n", branch, worktree, o.base) - if o.dryRun { - fmt.Printf(" [DRY-RUN] Would run: git worktree add -b %s %s %s\n", branch, worktree, o.base) - if o.humanGate { - return humanGate(model, worktree, rendered, true) - } - printLaunch(agent, model, worktree, rendered) - return nil - } if _, err := os.Stat(worktree); err == nil { if worktreeBranch(worktree) != "refs/heads/"+branch { return fmt.Errorf("Cannot reuse worktree path '%s': it does not belong to branch '%s'.", worktree, branch) } + if o.dryRun { + fmt.Printf(" [DRY-RUN] Would reuse worktree: %s\n", worktree) + return launchSelected(options{dryRun: true, humanGate: o.humanGate}, agent, model, worktree, rendered) + } if !confirm("Worktree already exists. Continue in " + worktree + "? [y/N] ") { return errors.New("Cancelled.") } @@ -269,16 +265,36 @@ func run(o options) error { } if branchWorktree(branch) != "" { existing := branchWorktree(branch) + if o.dryRun { + fmt.Printf(" [DRY-RUN] Would reuse branch worktree: %s\n", existing) + return launchSelected(options{dryRun: true, humanGate: o.humanGate}, agent, model, existing, render(prompt, map[string]string{"WORKTREE_PATH": existing})) + } if !confirm("Branch already exists in " + existing + ". Continue there? [y/N] ") { return errors.New("Cancelled.") } if !o.noInit { runInit(existing) } - return launchSelected(o, agent, model, existing, rendered) + return launchSelected(o, agent, model, existing, render(prompt, map[string]string{"WORKTREE_PATH": existing})) } if _, err := output("git", "show-ref", "--verify", "--quiet", "refs/heads/"+branch); err == nil { - return fmt.Errorf("Branch '%s' exists but is not attached to a worktree. Choose a new branch name or remove it before continuing.", branch) + if o.dryRun { + fmt.Printf(" [DRY-RUN] Branch exists detached; would prompt for resolution.\n") + return nil + } + if confirm("Branch exists detached. Create suffixed branch? [y/N] ") { + branch += "-2" + } else { + return errors.New("Cancelled.") + } + } + if o.dryRun { + fmt.Printf(" [DRY-RUN] Would run: git worktree add -b %s %s %s\n", branch, worktree, o.base) + if o.humanGate { + return humanGate(model, worktree, rendered, true) + } + printLaunch(agent, model, worktree, rendered) + return nil } if err := os.MkdirAll(filepath.Dir(worktree), 0755); err != nil { return err @@ -841,6 +857,7 @@ func branchWorktree(branch string) string { return "" } func worktreeBranch(path string) string { + path = canonicalPath(path) value, err := output("git", "worktree", "list", "--porcelain") if err != nil { return "" @@ -850,12 +867,22 @@ func worktreeBranch(path string) string { if strings.HasPrefix(line, "worktree ") { current = strings.TrimPrefix(line, "worktree ") } - if current == path && strings.HasPrefix(line, "branch ") { + if canonicalPath(current) == path && strings.HasPrefix(line, "branch ") { return strings.TrimPrefix(line, "branch ") } } return "" } +func canonicalPath(path string) string { + absolute, err := filepath.Abs(path) + if err != nil { + return path + } + if resolved, err := filepath.EvalSymlinks(absolute); err == nil { + return resolved + } + return absolute +} func launchSelected(o options, agent, model, worktree, prompt string) error { if o.humanGate { return humanGate(model, worktree, prompt, o.dryRun) From 7e45f279bcb83b90faaa24a9c68a17fc0daab03c Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Fri, 31 Jul 2026 10:51:30 +0300 Subject: [PATCH 08/12] inor --- cmd/start-issue/parity_integration_test.go | 872 +++++++++++++++++++++ 1 file changed, 872 insertions(+) create mode 100644 cmd/start-issue/parity_integration_test.go diff --git a/cmd/start-issue/parity_integration_test.go b/cmd/start-issue/parity_integration_test.go new file mode 100644 index 0000000..2517e37 --- /dev/null +++ b/cmd/start-issue/parity_integration_test.go @@ -0,0 +1,872 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "testing" +) + +const bashParityBaselineRevision = "d658db620836c4113e5a49326b5c69012c3e1f18" + +// TestCLIParityHelper runs the public command in a subprocess so the parity +// cases exercise argument parsing, external command fakes, and side effects. +func TestCLIParityHelper(t *testing.T) { + if os.Getenv("START_ISSUE_PARITY_HELPER") != "1" { + return + } + for index, arg := range os.Args { + if arg == "--" { + os.Args = append([]string{"start-issue"}, os.Args[index+1:]...) + main() + return + } + } + t.Fatal("missing CLI argument separator") +} + +func TestCLIParityIssueFetchConfigWorktreeAndReuse(t *testing.T) { + fixture := newParityFixture(t) + + projectConfig := filepath.Join(fixture.repo, ".start-issue") + if err := os.MkdirAll(projectConfig, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(projectConfig, "agent"), []byte("none\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(fixture.home, ".config", "start-issue", "agent"), []byte("codex\n"), 0644); err != nil { + t.Fatal(err) + } + + output, err := fixture.run("", "1", "--dry-run", "--no-init") + if err != nil { + t.Fatalf("dry-run failed: %v\n%s", err, output) + } + for _, want := range []string{ + "Agent: none", + "Agent source: " + filepath.Join(projectConfig, "agent"), + "Fetching issue #1 from owner/repo", + "Would run: git worktree add -b feature/issue-1-add-login-button", + } { + if !strings.Contains(output, want) { + t.Fatalf("dry-run output missing %q:\n%s", want, output) + } + } + if log, err := os.ReadFile(fixture.ghLog); err != nil || !strings.Contains(string(log), "auth status") { + t.Fatalf("gh authentication preflight not recorded: %q, %v", log, err) + } + + output, err = fixture.run("", "1", "--agent", "none", "--no-init") + if err != nil { + t.Fatalf("creation failed: %v\n%s", err, output) + } + if log, err := os.ReadFile(fixture.gitLog); err != nil || !strings.Contains(string(log), "worktree add -b feature/issue-1-add-login-button") { + t.Fatalf("worktree creation not recorded: %q, %v", log, err) + } + + worktree := canonicalPath(filepath.Join(fixture.worktrees, "feature", "issue-1-add-login-button")) + if err := os.MkdirAll(worktree, 0755); err != nil { + t.Fatal(err) + } + fixture.pathBranch = true + output, err = fixture.run("1\n", "1", "--agent", "none", "--no-init", "--worktree-dir", fixture.worktrees) + if err != nil { + t.Fatalf("reuse failed: %v\n%s", err, output) + } + if !strings.Contains(output, "Worktree ready at: "+worktree) { + t.Fatalf("reuse output does not identify existing worktree:\n%s", output) + } +} + +func TestCLIReusesOneReaderForOnboardingAndConflictResolution(t *testing.T) { + fixture := newParityFixture(t) + if err := os.RemoveAll(filepath.Join(fixture.home, ".config", "start-issue")); err != nil { + t.Fatal(err) + } + fixture.branchExists = true + if err := os.MkdirAll(fixture.fakeWorktree(), 0755); err != nil { + t.Fatal(err) + } + + output, err := fixture.run("n\n1\n", "1", "--agent", "none", "--no-init") + if err != nil { + t.Fatalf("onboarding then reuse failed: %v\n%s", err, output) + } + if !strings.Contains(output, "Worktree ready at: "+fixture.fakeWorktree()) { + t.Fatalf("conflict response was not available after onboarding:\n%s", output) + } +} + +func TestCLIDiagnosticsReportWorktreeDirectorySource(t *testing.T) { + fixture := newParityFixture(t) + + output, err := fixture.run("", "1", "--agent", "none", "--no-init", "--dry-run") + if err != nil { + t.Fatalf("environment-source dry-run failed: %v\n%s", err, output) + } + if !strings.Contains(output, "Worktree directory: "+fixture.worktrees+" (START_ISSUE_WORKTREE_DIR)") { + t.Fatalf("environment worktree source missing from diagnostics:\n%s", output) + } + + output, err = fixture.run("", "1", "--agent", "none", "--no-init", "--dry-run", "--worktree-dir", fixture.worktrees) + if err != nil { + t.Fatalf("CLI-source dry-run failed: %v\n%s", err, output) + } + if !strings.Contains(output, "Worktree directory: "+fixture.worktrees+" (CLI)") { + t.Fatalf("CLI worktree source missing from diagnostics:\n%s", output) + } +} + +func TestCLIDiagnosticsReportPromptSourceAndLocation(t *testing.T) { + fixture := newParityFixture(t) + prompt := filepath.Join(t.TempDir(), "prompt.md") + if err := os.WriteFile(prompt, []byte("Implement {ISSUE_URL}\n"), 0644); err != nil { + t.Fatal(err) + } + + output, err := fixture.run("", "1", "--agent", "none", "--no-init", "--dry-run", "--prompt-file", prompt) + if err != nil { + t.Fatalf("prompt-file dry-run failed: %v\n%s", err, output) + } + for _, want := range []string{"Prompt source: CLI --prompt-file: " + prompt, "Prompt location: " + prompt} { + if !strings.Contains(output, want) { + t.Fatalf("prompt diagnostics missing %q:\n%s", want, output) + } + } +} + +func TestCLIParityInitHookAndNoInit(t *testing.T) { + baseline := extractBashParityBaseline(t) + for _, test := range []struct { + name string + args []string + want bool + }{ + {name: "runs init hook", args: []string{"1", "--agent", "none"}, want: true}, + {name: "skips init hook", args: []string{"1", "--agent", "none", "--no-init"}, want: false}, + } { + t.Run(test.name, func(t *testing.T) { + bashFixture, goFixture := newParityFixture(t), newParityFixture(t) + bashFixture.initMarker = filepath.Join(t.TempDir(), "bash-init-ran") + goFixture.initMarker = filepath.Join(t.TempDir(), "go-init-ran") + + want := bashFixture.runBaseline(t, baseline, "", test.args...) + got := goFixture.runResult("", test.args...) + if want.exitCode != got.exitCode { + t.Fatalf("init case exit code differs: Bash=%d Go=%d", want.exitCode, got.exitCode) + } + for _, marker := range []string{bashFixture.initMarker, goFixture.initMarker} { + _, err := os.Stat(marker) + if (err == nil) != test.want { + t.Fatalf("init marker %s present=%t, want %t (stat error: %v)", marker, err == nil, test.want, err) + } + } + }) + } +} + +func TestBaselineAndGoParityForCriticalIssueWorkflows(t *testing.T) { + baseline := extractBashParityBaseline(t) + for _, test := range []struct { + name string + args []string + input string + setup func(t *testing.T, fixture *parityFixture) + strict bool + records []parityOutputRecord + assertOutcome func(t *testing.T, result parityResult) + assertBaseline func(t *testing.T, result parityResult) + assertGo func(t *testing.T, result parityResult) + }{ + { + name: "help", + args: []string{"--help"}, + strict: false, + assertOutcome: func(t *testing.T, result parityResult) { + assertParityOutputContains(t, result, "Usage: start-issue [options]", "--agent") + }, + }, + { + name: "invalid-input", + args: []string{"--not-an-option"}, + strict: true, + records: []parityOutputRecord{lineRecord("unknown-option diagnostic", `Unknown option: --not-an-option`)}, + assertOutcome: func(t *testing.T, result parityResult) { + if result.exitCode == 0 { + t.Fatal("invalid option unexpectedly succeeded") + } + assertParityOutputContains(t, result, "Unknown option: --not-an-option") + }, + }, + { + name: "configuration-precedence", + args: []string{"1", "--no-init", "--dry-run"}, + records: []parityOutputRecord{ + lineRecord("agent", `Agent: .+`), lineRecord("agent source", `Agent source: .+`), + lineRecord("model", `Model: .+`), lineRecord("model source", `Model source: .+`), + lineRecord("worktree directory", `Worktree directory: .+`), lineRecord("prompt source", `Prompt source: .+`), + lineRecord("issue fetch", `Fetching issue #[0-9]+ from .+`), lineRecord("title", `Title: .+`), + lineRecord("labels", `Labels: .+`), branchRecord(), lineRecord("worktree path", `Path: .+`), + lineRecord("base branch", `Base:.*`), lineRecord("worktree creation", `\[DRY-RUN\] Would run: git worktree add .+`), + lineRecord("ready worktree", `Worktree ready at: .+`), + }, + setup: func(t *testing.T, fixture *parityFixture) { + config := filepath.Join(fixture.repo, ".start-issue") + if err := os.MkdirAll(config, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(config, "agent"), []byte("none\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(fixture.home, ".config", "start-issue", "agent"), []byte("codex\n"), 0644); err != nil { + t.Fatal(err) + } + }, + strict: true, + }, + { + name: "dry-run", args: []string{"1", "--agent", "none", "--no-init", "--dry-run"}, strict: true, + records: []parityOutputRecord{ + lineRecord("agent", `Agent: .+`), lineRecord("agent source", `Agent source: .+`), + lineRecord("worktree directory", `Worktree directory: .+`), lineRecord("prompt source", `Prompt source: .+`), + lineRecord("issue fetch", `Fetching issue #[0-9]+ from .+`), lineRecord("title", `Title: .+`), + lineRecord("labels", `Labels: .+`), branchRecord(), lineRecord("worktree path", `Path: .+`), + lineRecord("base branch", `Base:.*`), lineRecord("worktree creation", `\[DRY-RUN\] Would run: git worktree add .+`), + lineRecord("ready worktree", `Worktree ready at: .+`), + }, + }, + { + name: "worktree-creation", args: []string{"1", "--agent", "none", "--no-init"}, strict: true, + records: []parityOutputRecord{ + lineRecord("agent", `Agent: .+`), lineRecord("agent source", `Agent source: .+`), + lineRecord("worktree directory", `Worktree directory: .+`), lineRecord("prompt source", `Prompt source: .+`), + lineRecord("issue fetch", `Fetching issue #[0-9]+ from .+`), lineRecord("title", `Title: .+`), + lineRecord("labels", `Labels: .+`), branchRecord(), lineRecord("worktree path", `Path: .+`), + lineRecord("base branch", `Base:.*`), lineRecord("ready worktree", `Worktree ready at: .+`), + }, + }, + { + name: "branch-worktree-reuse", + args: []string{"1", "--agent", "none", "--no-init"}, + input: "1\n", + records: []parityOutputRecord{ + lineRecord("agent", `Agent: .+`), lineRecord("agent source", `Agent source: .+`), + lineRecord("worktree directory", `Worktree directory: .+`), lineRecord("prompt source", `Prompt source: .+`), + lineRecord("issue fetch", `Fetching issue #[0-9]+ from .+`), branchRecord(), lineRecord("worktree path", `Path: .+`), + lineRecord("existing worktree", `Existing worktree: .+`), lineRecord("ready worktree", `Worktree ready at: .+`), + }, + setup: func(t *testing.T, fixture *parityFixture) { + fixture.branchExists = true + if err := os.MkdirAll(fixture.fakeWorktree(), 0755); err != nil { + t.Fatal(err) + } + }, + strict: true, + }, + { + name: "branch-conflict-suffix", + args: []string{"1", "--agent", "none", "--no-init"}, + input: "2\n", + records: []parityOutputRecord{ + lineRecord("agent", `Agent: .+`), lineRecord("worktree directory", `Worktree directory: .+`), + lineRecord("issue fetch", `Fetching issue #[0-9]+ from .+`), branchRecord(), lineRecord("worktree path", `Path: .+`), + lineRecord("new branch", `New branch name: .+`), lineRecord("ready worktree", `Worktree ready at: .+`), + }, + setup: func(t *testing.T, fixture *parityFixture) { + fixture.branchExists = true + }, + strict: true, + }, + { + name: "worktree-path-reuse", + args: []string{"1", "--agent", "none", "--no-init"}, + input: "1\n", + records: []parityOutputRecord{ + lineRecord("agent", `Agent: .+`), lineRecord("worktree directory", `Worktree directory: .+`), + lineRecord("issue fetch", `Fetching issue #[0-9]+ from .+`), branchRecord(), lineRecord("worktree path", `Path: .+`), + lineRecord("registered branch", `Registered branch: .+`), lineRecord("ready worktree", `Worktree ready at: .+`), + }, + setup: func(t *testing.T, fixture *parityFixture) { + fixture.pathBranch = true + if err := os.MkdirAll(fixture.fakeWorktree(), 0755); err != nil { + t.Fatal(err) + } + }, + strict: true, + }, + { + name: "worktree-path-conflict-dry-run", + args: []string{"1", "--agent", "none", "--no-init", "--dry-run"}, + input: "1\n", + setup: func(t *testing.T, fixture *parityFixture) { + if err := os.MkdirAll(fixture.fakeWorktree(), 0755); err != nil { + t.Fatal(err) + } + }, + strict: false, + // Bash consumed the supplied choice in dry-run. The Go command's + // documented safety contract instead reports that it would prompt, + // without selecting a destructive or reuse action. + assertBaseline: func(t *testing.T, result parityResult) { + assertParityOutputContains(t, result, "Worktree path already exists") + }, + assertGo: func(t *testing.T, result parityResult) { + assertParityOutputContains(t, result, "Worktree path exists; would prompt for reuse or delete/recreate") + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + bashFixture, goFixture := newParityFixture(t), newParityFixture(t) + if test.setup != nil { + test.setup(t, &bashFixture) + test.setup(t, &goFixture) + } + want := bashFixture.runBaseline(t, baseline, test.input, test.args...) + got := goFixture.runResult(test.input, test.args...) + if test.strict { + assertParityResult(t, want, got, test.records) + } + if test.assertOutcome != nil { + t.Run("bash-contract", func(t *testing.T) { test.assertOutcome(t, want) }) + t.Run("go-contract", func(t *testing.T) { test.assertOutcome(t, got) }) + } + if test.assertBaseline != nil { + t.Run("bash-contract", func(t *testing.T) { test.assertBaseline(t, want) }) + } + if test.assertGo != nil { + t.Run("go-contract", func(t *testing.T) { test.assertGo(t, got) }) + } + }) + } + + for _, agent := range []string{"claude", "codex", "kimi", "pi", "none"} { + t.Run("agent-launch-"+agent, func(t *testing.T) { + bashFixture, goFixture := newParityFixture(t), newParityFixture(t) + args := []string{"1", "--agent", agent, "--model", "fixture-model", "--no-init", "--dry-run"} + want := bashFixture.runBaseline(t, baseline, "", args...) + got := goFixture.runResult("", args...) + assertAgentLaunchParity(t, want, got, agent) + assertAgentLaunchContract(t, want, agent) + assertAgentLaunchContract(t, got, agent) + }) + } +} + +type parityResult struct { + exitCode int + rawOutput string + gitLog string + ghLog string + filesystem []string +} + +// parityOutputRecord belongs to one named parity case. It compares a +// user-observable record that is stable across runtimes while letting the case +// retain its own output contract. We do not use a global output whitelist: +// every strict case explicitly declares the records it must preserve. +type parityOutputRecord struct { + name string + pattern *regexp.Regexp + normalize func(string) string +} + +func lineRecord(name, expression string) parityOutputRecord { + return parityOutputRecord{name: name, pattern: regexp.MustCompile(expression)} +} + +func branchRecord() parityOutputRecord { + return parityOutputRecord{ + name: "branch", + pattern: regexp.MustCompile(`Branch: .+`), + normalize: func(record string) string { + // Only Bash's elapsed-time decoration differs; the branch and its + // source remain part of the comparison. + return regexp.MustCompile(` \([0-9]+s, `).ReplaceAllString(record, " (") + }, + } +} + +func assertParityOutputContains(t *testing.T, result parityResult, values ...string) { + t.Helper() + for _, value := range values { + if !strings.Contains(result.rawOutput, value) { + t.Fatalf("output missing %q:\n%s", value, result.rawOutput) + } + } +} + +func assertAgentLaunchContract(t *testing.T, result parityResult, agent string) { + t.Helper() + assertParityOutputContains(t, result, "Agent: "+agent) + if agent == "none" { + assertParityOutputContains(t, result, "Worktree ready at:", "Suggested agent commands:") + return + } + commands := map[string]string{ + "claude": "claude --model fixture-model --dangerously-skip-permissions", + "codex": "codex --model fixture-model --cd", + "kimi": "kimi --model fixture-model --work-dir", + "pi": "pi --model fixture-model", + } + assertParityOutputContains(t, result, "[DRY-RUN] Would run:", commands[agent]) +} + +func assertAgentLaunchParity(t *testing.T, want, got parityResult, agent string) { + t.Helper() + if want.exitCode != got.exitCode { + t.Fatalf("%s launch exit code differs: Bash=%d Go=%d", agent, want.exitCode, got.exitCode) + } + if bashRecord, goRecord := agentLaunchRecord(want.rawOutput, agent), agentLaunchRecord(got.rawOutput, agent); bashRecord != goRecord { + t.Fatalf("%s launch contract differs:\nBash:\n%s\nGo:\n%s", agent, bashRecord, goRecord) + } +} + +func agentLaunchRecord(output, agent string) string { + var records []string + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Agent: ") { + records = append(records, line) + } + if agent != "none" && strings.Contains(line, "[DRY-RUN] Would run:") && strings.Contains(line, agent+" ") { + records = append(records, launchAdapterPrefix(line, agent)) + } + if agent == "none" && (strings.Contains(line, "Worktree ready at:") || line == "Suggested agent commands:") { + records = append(records, line) + } + } + return strings.Join(records, "\n") +} + +func launchAdapterPrefix(command, agent string) string { + markers := map[string]string{ + "claude": "--dangerously-skip-permissions", + "codex": "--dangerously-bypass-approvals-and-sandbox", + "kimi": "--yolo -p", + "pi": "pi --model fixture-model", + } + marker := markers[agent] + if index := strings.Index(command, marker); index >= 0 { + return command[:index+len(marker)] + } + return command +} + +func extractBashParityBaseline(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("the Bash baseline oracle is POSIX-only") + } + root := repoRoot(t) + archive := exec.Command("git", "archive", "--format=tar", bashParityBaselineRevision, "scripts") + archive.Dir = root + contents, err := archive.Output() + if err != nil { + t.Fatalf("read Bash parity baseline %s: %v", bashParityBaselineRevision, err) + } + dir := t.TempDir() + extract := exec.Command("tar", "-x", "-C", dir) + extract.Stdin = bytes.NewReader(contents) + if output, err := extract.CombinedOutput(); err != nil { + t.Fatalf("extract Bash parity baseline: %v\n%s", err, output) + } + return filepath.Join(dir, "scripts", "start-issue") +} + +func assertParityResult(t *testing.T, want, got parityResult, records []parityOutputRecord) { + t.Helper() + if want.exitCode != got.exitCode { + t.Fatalf("exit code differs: Bash=%d Go=%d\nBash output:\n%s\nGo output:\n%s", want.exitCode, got.exitCode, want.rawOutput, got.rawOutput) + } + if len(records) == 0 { + t.Fatal("strict parity case has no declared output records") + } + for _, record := range records { + wantRecord := parityRecord(t, want.rawOutput, record) + gotRecord := parityRecord(t, got.rawOutput, record) + if wantRecord != gotRecord { + t.Fatalf("%s output differs:\nBash: %s\nGo: %s", record.name, wantRecord, gotRecord) + } + } + if want.gitLog != got.gitLog || want.ghLog != got.ghLog { + t.Fatalf("fake command logs differ:\nBash git=%q gh=%q\nGo git=%q gh=%q", want.gitLog, want.ghLog, got.gitLog, got.ghLog) + } + if strings.Join(want.filesystem, "\n") != strings.Join(got.filesystem, "\n") { + t.Fatalf("filesystem state differs:\nBash=%v\nGo=%v", want.filesystem, got.filesystem) + } +} + +func parityRecord(t *testing.T, output string, record parityOutputRecord) string { + t.Helper() + match := record.pattern.FindString(output) + if match == "" { + t.Fatalf("%s record missing from output:\n%s", record.name, output) + } + match = strings.TrimSpace(match) + if record.normalize != nil { + match = record.normalize(match) + } + return match +} + +func TestCLIParityDryRunReportsAttachedBranchConflict(t *testing.T) { + fixture := newParityFixture(t) + fixture.branchExists = true + output, err := fixture.run("1\n", "1", "--agent", "none", "--no-init", "--dry-run") + if err != nil { + t.Fatalf("dry-run failed: %v\n%s", err, output) + } + if !strings.Contains(output, "Branch is already attached to a worktree; would prompt for reuse, suffix, or delete/recreate") { + t.Fatalf("dry-run did not report the unresolved attached-branch conflict:\n%s", output) + } + if strings.Contains(output, "Would reuse branch worktree") || strings.Contains(output, "Would run: codex") { + t.Fatalf("dry-run selected a conflict resolution instead of reporting it:\n%s", output) + } +} + +func TestCLIAIBranchReportsOneAIRecord(t *testing.T) { + fixture := newParityFixture(t) + writeExecutable(t, filepath.Join(fixture.bin, "pi"), "#!/bin/sh\nprintf '%s\\n' feature/issue-1-add-login-button\n") + + output, err := fixture.run("", "1", "--agent", "pi", "--ai", "--no-init", "--dry-run") + if err != nil { + t.Fatalf("AI dry-run failed: %v\n%s", err, output) + } + branchRecords := 0 + for _, line := range strings.Split(output, "\n") { + if strings.TrimSpace(line) == "Branch: feature/issue-1-add-login-button (ai:pi)" { + branchRecords++ + } + } + if branchRecords != 1 { + t.Fatalf("AI branch was reported %d times:\n%s", branchRecords, output) + } + if !strings.Contains(output, "Branch: feature/issue-1-add-login-button (ai:pi)") || strings.Contains(output, "Branch: feature/issue-1-add-login-button (fast)") { + t.Fatalf("AI branch source is contradictory:\n%s", output) + } +} + +func TestCLIParityInstallerWorkflow(t *testing.T) { + root := repoRoot(t) + dir := t.TempDir() + asset := filepath.Join(dir, "start-issue") + writeExecutable(t, asset, "#!/bin/sh\n[ \"$1\" = --version ] && echo 'start-issue v2.1.0'\n") + checksum := sha256.Sum256([]byte("#!/bin/sh\n[ \"$1\" = --version ] && echo 'start-issue v2.1.0'\n")) + manifest := filepath.Join(dir, "checksums.txt") + if err := os.WriteFile(manifest, []byte(fmt.Sprintf("%x start-issue\n", checksum)), 0644); err != nil { + t.Fatal(err) + } + target := filepath.Join(dir, "bin", "start-issue") + command := exec.Command(requireBash(t), filepath.Join(root, "install.sh")) + command.Env = append(os.Environ(), + "START_ISSUE_ASSET_URL=file://"+asset, + "START_ISSUE_CHECKSUM_URL=file://"+manifest, + "TARGET="+target, + "BINDIR="+filepath.Dir(target), + ) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("installer failed: %v\n%s", err, output) + } + versionOutput, err := exec.Command(target, "--version").CombinedOutput() + if err != nil || strings.TrimSpace(string(versionOutput)) != "start-issue v2.1.0" { + t.Fatalf("installed version = %q, %v", versionOutput, err) + } +} + +func TestInstallerDefaultsEachURLIndependently(t *testing.T) { + bash := requireBash(t) + root := repoRoot(t) + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "curl"), "#!/bin/sh\nexit 1\n") + defaultAsset := "https://github.com/dapi/start-issue/releases/latest/download/" + releaseAssetName(runtime.GOOS, runtime.GOARCH) + defaultChecksum := "https://github.com/dapi/start-issue/releases/latest/download/checksums.txt" + + for _, test := range []struct { + name, asset, checksum, wantAsset, wantChecksum string + }{ + { + name: "custom asset keeps default checksum", + asset: "https://example.test/custom/start-issue", + wantAsset: "https://example.test/custom/start-issue", + wantChecksum: defaultChecksum, + }, + { + name: "custom checksum keeps default asset", + checksum: "https://example.test/custom/checksums.txt", + wantAsset: defaultAsset, + wantChecksum: "https://example.test/custom/checksums.txt", + }, + } { + t.Run(test.name, func(t *testing.T) { + command := exec.Command(bash, filepath.Join(root, "install.sh"), "--debug") + command.Env = append(os.Environ(), + "START_ISSUE_ASSET_URL="+test.asset, + "START_ISSUE_CHECKSUM_URL="+test.checksum, + "BINDIR="+t.TempDir(), + "TARGET="+filepath.Join(t.TempDir(), "start-issue"), + "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), + ) + output, err := command.CombinedOutput() + if err == nil { + t.Fatal("installer unexpectedly succeeded with a failing curl fixture") + } + for _, want := range []string{"Asset URL: " + test.wantAsset, "Checksum URL: " + test.wantChecksum} { + if !strings.Contains(string(output), want) { + t.Fatalf("installer did not resolve %q:\n%s", want, output) + } + } + }) + } +} + +func requireBash(t *testing.T) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("install.sh is a POSIX Bash installer") + } + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("Bash is unavailable; installer integration test is not applicable") + } + return bash +} + +func TestCLIParityUpdateWorkflowPreservesInvocationSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows update is intentionally manual") + } + root := repoRoot(t) + dir, bin := t.TempDir(), t.TempDir() + target := filepath.Join(dir, "start-issue-target") + invocation := filepath.Join(dir, "start-issue") + build := exec.Command("go", "build", "-o", target, "./cmd/start-issue") + build.Dir = root + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build update fixture: %v\n%s", err, output) + } + if err := os.Symlink(target, invocation); err != nil { + t.Fatal(err) + } + assetName := releaseAssetName(runtime.GOOS, runtime.GOARCH) + asset := []byte("#!/bin/sh\nif [ \"$1\" = --version ]; then echo 'start-issue v2.1.0'; exit 0; fi\n") + checksum := fmt.Sprintf("%x %s\n", sha256.Sum256(asset), assetName) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skipf("local HTTP listener unavailable: %v", err) + } + server := httptest.NewUnstartedServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/asset": + _, _ = response.Write(asset) + case "/checksums": + _, _ = response.Write([]byte(checksum)) + default: + http.NotFound(response, request) + } + })) + server.Listener = listener + server.Start() + defer server.Close() + release := filepath.Join(dir, "release.json") + metadata := fmt.Sprintf(`{"tag_name":"v2.1.0","assets":[{"name":"%s","browser_download_url":"%s/asset"},{"name":"checksums.txt","browser_download_url":"%s/checksums"}]}`, assetName, server.URL, server.URL) + if err := os.WriteFile(release, []byte(metadata), 0644); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(bin, "gh"), "#!/bin/sh\nif [ \"$1\" = auth ]; then exit 0; fi\ncat \"$START_ISSUE_FAKE_RELEASE\"\n") + command := exec.Command(invocation, "update") + command.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "START_ISSUE_FAKE_RELEASE="+release) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("update failed: %v\n%s", err, output) + } + info, err := os.Lstat(invocation) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("update replaced invocation symlink %s instead of its target", invocation) + } + versionOutput, err := exec.Command(invocation, "--version").CombinedOutput() + if err != nil || strings.TrimSpace(string(versionOutput)) != "start-issue v2.1.0" { + t.Fatalf("updated version = %q, %v", versionOutput, err) + } +} + +type parityFixture struct { + home, repo, bin, worktrees, gitLog, ghLog, initMarker string + branchExists, pathBranch bool +} + +func (fixture parityFixture) fakeWorktree() string { + return canonicalPath(filepath.Join(fixture.worktrees, "feature", "issue-1-add-login-button")) +} + +func newParityFixture(t *testing.T) parityFixture { + t.Helper() + fixture := parityFixture{home: t.TempDir(), repo: t.TempDir(), bin: t.TempDir(), worktrees: t.TempDir(), gitLog: filepath.Join(t.TempDir(), "git.log"), ghLog: filepath.Join(t.TempDir(), "gh.log")} + if err := os.MkdirAll(filepath.Join(fixture.home, ".config", "start-issue"), 0755); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(fixture.bin, "git"), `#!/bin/sh +if [ "$1" = "-C" ]; then + printf '%s\n' "$START_ISSUE_TEST_REPO" + exit 0 +fi +case "$1 $2 $3" in + "rev-parse --show-toplevel ") printf '%s\n' "$START_ISSUE_TEST_REPO" ;; + "remote get-url origin") printf '%s\n' 'git@github.com:owner/repo.git' ;; + "symbolic-ref refs/remotes/origin/HEAD") printf '%s\n' 'refs/remotes/origin/master' ;; + show-ref\ --verify\ *) [ "${START_ISSUE_FAKE_BRANCH_EXISTS:-}" = 1 ] && [ "$4" = "refs/heads/feature/issue-1-add-login-button" ] && exit 0; exit 1 ;; + "worktree list --porcelain") + if [ "${START_ISSUE_FAKE_BRANCH_EXISTS:-}" = 1 ] || [ "${START_ISSUE_FAKE_PATH_BRANCH:-}" = 1 ]; then + printf 'worktree %s\nbranch refs/heads/feature/issue-1-add-login-button\n' "$START_ISSUE_FAKE_WORKTREE" + fi + ;; + "fetch origin master") ;; + "worktree add -b") + mkdir -p "$5" + if [ -n "${START_ISSUE_INIT_MARKER:-}" ]; then + printf '%s\n' '#!/bin/sh' 'touch "$START_ISSUE_INIT_MARKER"' > "$5/init.sh" + chmod +x "$5/init.sh" + fi + printf '%s\n' "$*" >> "$START_ISSUE_GIT_LOG" + ;; +esac +`) + writeExecutable(t, filepath.Join(fixture.bin, "gh"), `#!/bin/sh +printf '%s\n' "$*" >> "$START_ISSUE_GH_LOG" +if [ "$1" = auth ] && [ "$2" = status ]; then exit 0; fi +if [ "$1" = api ]; then + printf '%s\n' '{"title":"Add login button","body":"Fixture body","labels":[{"name":"feature"}]}' +fi +`) + writeExecutable(t, filepath.Join(fixture.bin, "jq"), `#!/bin/sh +case "$*" in + *".title"*) printf '%s\n' 'Add login button' ;; + *".body"*) printf '%s\n' 'Fixture body' ;; + *".labels"*) printf '%s\n' 'feature' ;; +esac +`) + writeExecutable(t, filepath.Join(fixture.bin, "zellij-tab-status"), "#!/bin/sh\nexit 0\n") + return fixture +} + +func (fixture parityFixture) run(input string, args ...string) (string, error) { + command := exec.Command(os.Args[0], append([]string{"-test.run=^TestCLIParityHelper$", "--"}, args...)...) + return fixture.runCommand(command, input) +} + +func (fixture parityFixture) runResult(input string, args ...string) parityResult { + output, err := fixture.run(input, args...) + return fixture.result(output, err) +} + +func (fixture parityFixture) runBaseline(t *testing.T, baseline, input string, args ...string) parityResult { + command := exec.Command(requireBash(t), append([]string{baseline}, args...)...) + output, err := fixture.runCommand(command, input) + return fixture.result(output, err) +} + +func (fixture parityFixture) result(output string, err error) parityResult { + gitLog, _ := os.ReadFile(fixture.gitLog) + ghLog, _ := os.ReadFile(fixture.ghLog) + return parityResult{ + exitCode: commandExitCode(err), + rawOutput: normalizeRawParityOutput(output, fixture), + gitLog: normalizeRawParityOutput(string(gitLog), fixture), + ghLog: normalizeRawParityOutput(string(ghLog), fixture), + filesystem: parityFilesystem(fixture), + } +} + +func normalizeRawParityOutput(output string, fixture parityFixture) string { + stripANSI := regexp.MustCompile(`\x1b\[[0-9;]*m`) + output = stripANSI.ReplaceAllString(output, "") + for _, path := range []string{ + canonicalPath(fixture.home), canonicalPath(fixture.repo), canonicalPath(fixture.worktrees), + fixture.home, fixture.repo, fixture.worktrees, + } { + output = strings.ReplaceAll(output, path, "") + } + return output +} + +func commandExitCode(err error) int { + if err == nil { + return 0 + } + var exited *exec.ExitError + if errors.As(err, &exited) { + return exited.ExitCode() + } + return -1 +} + +func parityFilesystem(fixture parityFixture) []string { + var paths []string + for _, root := range []string{fixture.home, fixture.repo, fixture.worktrees} { + _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil || path == root { + return err + } + rel, relErr := filepath.Rel(root, path) + if relErr == nil { + if info.IsDir() { + rel += "/" + } + paths = append(paths, rel) + } + return nil + }) + } + sort.Strings(paths) + return paths +} + +func (fixture parityFixture) runCommand(command *exec.Cmd, input string) (string, error) { + command.Dir = fixture.repo + command.Stdin = strings.NewReader(input) + command.Env = append(os.Environ(), + "START_ISSUE_PARITY_HELPER=1", + "HOME="+fixture.home, + "PATH="+fixture.bin+string(os.PathListSeparator)+os.Getenv("PATH"), + "START_ISSUE_TEST_REPO="+fixture.repo, + "START_ISSUE_GIT_LOG="+fixture.gitLog, + "START_ISSUE_GH_LOG="+fixture.ghLog, + "START_ISSUE_WORKTREE_DIR="+fixture.worktrees, + "START_ISSUE_INIT_MARKER="+fixture.initMarker, + ) + if fixture.branchExists { + command.Env = append(command.Env, "START_ISSUE_FAKE_BRANCH_EXISTS=1") + } + if fixture.pathBranch { + command.Env = append(command.Env, "START_ISSUE_FAKE_PATH_BRANCH=1") + } + if fixture.branchExists || fixture.pathBranch { + command.Env = append(command.Env, "START_ISSUE_FAKE_WORKTREE="+fixture.fakeWorktree()) + } + output, err := command.CombinedOutput() + return string(output), err +} + +func repoRoot(t *testing.T) string { + t.Helper() + current, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + return filepath.Clean(filepath.Join(current, "..", "..")) +} From a5beea388465524dc671bbba1a7d8c60b0c23f5a Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Fri, 31 Jul 2026 10:51:34 +0300 Subject: [PATCH 09/12] inor --- .github/workflows/ci.yml | 29 + .github/workflows/release.yml | 4 + .gitignore | 1 + .goreleaser.yaml | 12 + Makefile | 6 +- README.md | 40 +- README.ru.md | 95 +- cmd/start-issue/main.go | 1723 ++++++++++++--- cmd/start-issue/main_test.go | 1849 ++++++++++++++++- cmd/start-issue/parity_integration_test.go | 399 +++- cmd/start-issue/testdata/bash-v1/README.md | 6 + .../bash-v1/scripts/build-start-issue | 53 + .../testdata/bash-v1/scripts/bump-version | 74 + .../scripts/check_memory_bank_index.py | 845 ++++++++ .../bash-v1/scripts/lib/start_issue/agent.sh | 474 +++++ .../bash-v1/scripts/lib/start_issue/cli.sh | 186 ++ .../bash-v1/scripts/lib/start_issue/config.sh | 191 ++ .../bash-v1/scripts/lib/start_issue/github.sh | 69 + .../bash-v1/scripts/lib/start_issue/init.sh | 379 ++++ .../bash-v1/scripts/lib/start_issue/output.sh | 439 ++++ .../scripts/lib/start_issue/pipeline.sh | 96 + .../scripts/lib/start_issue/release.sh | 139 ++ .../bash-v1/scripts/lib/start_issue/update.sh | 104 + .../bash-v1/scripts/lib/start_issue/utils.sh | 132 ++ .../scripts/lib/start_issue/worktree.sh | 403 ++++ .../testdata/bash-v1/scripts/prepare-release | 113 + .../testdata/bash-v1/scripts/start-issue | 187 ++ doc/spec.md | 62 +- go.mod | 2 +- install.sh | 12 +- memory-bank/domain/context-map.md | 4 +- memory-bank/domain/glossary.md | 2 +- memory-bank/domain/model.md | 8 +- memory-bank/domain/rules.md | 10 +- memory-bank/engineering/README.md | 13 +- .../engineering/autonomy-boundaries.md | 6 +- memory-bank/engineering/frontend.md | 2 +- memory-bank/engineering/testing-policy.md | 6 +- memory-bank/features/FT-017/decision-log.md | 33 +- memory-bank/ops/config.md | 15 +- memory-bank/ops/development.md | 12 +- memory-bank/ops/release.md | 53 +- memory-bank/ops/stages.md | 4 +- memory-bank/product/context.md | 5 +- memory-bank/product/customers.md | 4 +- memory-bank/product/metrics.md | 6 +- memory-bank/product/roadmap.md | 6 +- mise.toml | 2 +- scripts/v1-upgrade-shim | 78 + 49 files changed, 7823 insertions(+), 570 deletions(-) create mode 100644 .gitignore create mode 100644 cmd/start-issue/testdata/bash-v1/README.md create mode 100755 cmd/start-issue/testdata/bash-v1/scripts/build-start-issue create mode 100755 cmd/start-issue/testdata/bash-v1/scripts/bump-version create mode 100755 cmd/start-issue/testdata/bash-v1/scripts/check_memory_bank_index.py create mode 100644 cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/agent.sh create mode 100644 cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/cli.sh create mode 100644 cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/config.sh create mode 100644 cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/github.sh create mode 100644 cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/init.sh create mode 100644 cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/output.sh create mode 100644 cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/pipeline.sh create mode 100644 cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/release.sh create mode 100644 cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/update.sh create mode 100644 cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/utils.sh create mode 100644 cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/worktree.sh create mode 100755 cmd/start-issue/testdata/bash-v1/scripts/prepare-release create mode 100755 cmd/start-issue/testdata/bash-v1/scripts/start-issue create mode 100644 scripts/v1-upgrade-shim diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3110182..d7ffab0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,35 @@ jobs: - run: make build - run: .build/start-issue --version + platform-smoke: + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-latest + artifact: start-issue + - runner: macos-latest + artifact: start-issue + - runner: windows-latest + artifact: start-issue.exe + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: go build -o ${{ matrix.artifact }} ./cmd/start-issue + - if: runner.os != 'Windows' + run: ./${{ matrix.artifact }} --version + - if: runner.os != 'Windows' + run: make test + - if: runner.os == 'Windows' + shell: pwsh + run: | + & .\${{ matrix.artifact }} --version + $update = (go run ./cmd/start-issue update | Out-String) + if ($update -notmatch 'Windows update is manual') { throw "Expected Windows manual-update instruction: $update" } + cross-build: runs-on: ubuntu-latest strategy: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 83eaef8..6b11c99 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,3 +25,7 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Publish release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release edit "$GITHUB_REF_NAME" --draft=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30bcfa4 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 55e14bb..3442fda 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,8 +1,10 @@ version: 2 +project_name: start-issue before: hooks: - go mod tidy + - sh -c 'mkdir -p .release && cp scripts/v1-upgrade-shim .release/start-issue && chmod 0755 .release/start-issue && (cd .release && sha256sum start-issue > start-issue.sha256)' builds: - id: start-issue @@ -24,3 +26,13 @@ archives: checksum: name_template: checksums.txt + +release: + # Keep the release invisible until GoReleaser has uploaded both the normal + # assets and the v1 bridge required by older self-updaters. + draft: true + extra_files: + - glob: .release/start-issue + name_template: start-issue + - glob: .release/start-issue.sha256 + name_template: start-issue.sha256 diff --git a/Makefile b/Makefile index 3ca256a..4c7dd33 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,11 @@ PREFIX ?= $(HOME)/.local BINDIR ?= $(PREFIX)/bin BUILD_DIR ?= .build BUILD_OUTPUT ?= $(BUILD_DIR)/start-issue -VERSION ?= 2.0.0 +# Release tags are the version source. A checkout between releases carries the +# nearest tag plus its Git describe suffix, which keeps source builds distinct +# from the last published release for update comparisons. +VERSION ?= $(shell git describe --tags --match 'v[0-9]*' --always --dirty 2>/dev/null | sed 's/^v//') +VERSION := $(if $(strip $(VERSION)),$(VERSION),dev) build: @mkdir -p "$(BUILD_DIR)" diff --git a/README.md b/README.md index 912ca9d..6c582d5 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ flowchart TD F -- yes --> G["Print planned actions
and exit"] F -- no --> H["Create or reuse git worktree"] - H --> I["Run init.sh if enabled"] + H --> I["Run worktree init hook if enabled"] I --> J["Render agent prompt"] J --> K{"Agent selected?"} @@ -105,12 +105,18 @@ flowchart TD ## Internal Architecture The Go entrypoint is `cmd/start-issue`. It owns argument parsing, configuration -resolution, repository and worktree orchestration, and the adapter commands for -supported agents. `git`, `gh`, and agent CLIs remain explicit external process -boundaries. -- `output.sh` renders help, status, dry-run output, and session framing. -- `init.sh` owns `start-issue init` plus the user-config onboarding helpers behind `setup`. -- `pipeline.sh` makes the orchestration pipeline explicit. +resolution, repository and worktree orchestration, self-install/update, output, +and adapter commands for supported agents. `git`, `gh`, and agent CLIs remain +explicit external process boundaries. + +- Configuration and prompt helpers resolve CLI, environment, project, and user + defaults. +- Repository/worktree helpers fetch issue metadata, plan reuse safely, and run + the optional `init.sh` hook found in a prepared worktree. +- Agent helpers validate adapters, build launch commands, generate AI branch + names, and run Codex human-gate mode. +- Release helpers select platform assets, verify checksums and staged + `--version` output, and atomically install updates. The internal pipeline is now: @@ -121,7 +127,10 @@ The internal pipeline is now: 5. Execute the plan. 6. Launch the selected agent. -The project should keep Bash as long as lifecycle commands, configuration shape, and output needs stay simple enough for shell modules to remain readable. If future work requires nested configuration, richer subcommands such as `resume` or `cleanup`, or more structured machine-readable output, that should be treated as the threshold for evaluating a Python core. +The Go implementation keeps lifecycle commands, configuration shape, and output +in one compiled CLI while retaining external-tool boundaries. Future additions +should preserve the same focused helper boundaries rather than reintroducing a +second runtime implementation. ## CLI Arguments @@ -202,9 +211,11 @@ The workflow: 2. Reads the version of the executable the user is currently running. 3. Normalizes version strings so `1.11.1` and `v1.11.1` compare as equal. 4. If the running version is current or newer than the latest published release, exits `0` with a clear status message. -5. If a newer published release exists, downloads `start-issue` and `start-issue.sha256`, verifies the checksum, and installs the update into the same executable path the user invoked. +5. If a newer published release exists, downloads the matching platform binary and `checksums.txt`, verifies the checksum, and installs the update into the resolved target of the executable the user invoked. -The update workflow works outside a git repository. It requires `gh`, `jq`, and either `curl` or `wget`. +The update workflow works outside a git repository and requires only `gh`. +The Go binary parses release metadata, downloads assets, and verifies checksums +internally. ## Codex Human-Gate @@ -299,7 +310,7 @@ To improve the prompt template used for future development starts, run: start-issue 123 --agent codex --improve-prompt ``` -The command resolves the active prompt template with the normal precedence, fetches the issue as context, asks the selected agent for a complete improved prompt template, and writes a proposal file. It does not overwrite the active prompt. File-backed prompts write next to the source as `*.improved.md` by default; built-in and inline prompts write to `.start-issue/prompt.improved.md`. Use `--prompt-output-file` to choose another proposal path. +The command resolves the active prompt template with the normal precedence, fetches the issue as context, asks the selected agent for a complete improved prompt template, and writes a proposal file. It does not overwrite the active prompt. Markdown prompt files write next to the source as `*.improved.md`; other file names append `.improved`. Built-in and inline prompts write to `.start-issue/prompt.improved.md`. Use `--prompt-output-file` to choose another proposal path. Prompt templates support: @@ -329,12 +340,14 @@ Optional dependency for Zellij support: ## Requirements -- Go 1.21+ - `git` - `gh` CLI with authenticated GitHub session -- `jq` - selected agent CLI unless `--agent none` or `--dry-run` is used +Building from source additionally requires Go 1.24+. The optional Bash +installer and the manual-install snippet require `bash`, `curl` or `wget`, and +a SHA-256 tool; those tools are not used by `start-issue update`. + ## Releases GitHub Releases are published automatically when a SemVer tag like `v1.12.0` is pushed. The release workflow reruns the Go test suite and publishes platform-specific binaries with a checksum manifest: @@ -345,6 +358,7 @@ GitHub Releases are published automatically when a SemVer tag like `v1.12.0` is - `start-issue-darwin-arm64` - `start-issue-windows-amd64.exe` - `checksums.txt` +- `start-issue` and `start-issue.sha256` (temporary v1 update bridge) To prepare a release locally: diff --git a/README.ru.md b/README.ru.md index b3a7018..0671e31 100644 --- a/README.ru.md +++ b/README.ru.md @@ -19,34 +19,27 @@ Установить последний опубликованный релиз: ```bash -go install github.com/dapi/start-issue/cmd/start-issue@latest +go install github.com/dapi/start-issue/v2/cmd/start-issue@latest ``` -Скрипт установки скачивает asset из последнего GitHub Release в `~/.local/bin/start-issue` по умолчанию. - -Для диагностики, если установка на машине подвисает: - -```bash -curl -fsSL https://raw.githubusercontent.com/dapi/start-issue/master/install.sh | bash -s -- --debug -``` - -Этот режим включает трассировку shell и подробный вывод `curl` или `wget`, чтобы было видно, на каком шаге всё остановилось. +Опубликованные релизы содержат бинарники для конкретной платформы и файл +`checksums.txt`. После первоначальной установки через Go команда +`start-issue install` самостоятельно выбирает бинарник для текущей POSIX-платформы, +проверяет SHA-256 и устанавливает его в `~/.local/bin/start-issue`. Ручная установка: -```bash -mkdir -p ~/.local/bin -curl -fsSL https://github.com/dapi/start-issue/releases/latest/download/start-issue -o ~/.local/bin/start-issue -chmod +x ~/.local/bin/start-issue -``` - -При желании можно проверить checksum: - ```bash tmpdir="$(mktemp -d)" -curl -fsSL https://github.com/dapi/start-issue/releases/latest/download/start-issue -o "$tmpdir/start-issue" -curl -fsSL https://github.com/dapi/start-issue/releases/latest/download/start-issue.sha256 -o "$tmpdir/start-issue.sha256" -(cd "$tmpdir" && shasum -a 256 -c start-issue.sha256) +os="$(uname -s | tr '[:upper:]' '[:lower:]')" +arch="$(uname -m)" +case "$arch" in x86_64) arch=amd64 ;; arm64|aarch64) arch=arm64 ;; esac +asset="start-issue-${os}-${arch}" +curl -fsSL "https://github.com/dapi/start-issue/releases/latest/download/${asset}" -o "$tmpdir/$asset" +curl -fsSL https://github.com/dapi/start-issue/releases/latest/download/checksums.txt -o "$tmpdir/checksums.txt" +(cd "$tmpdir" && grep -E " [*]?${asset}$" checksums.txt | shasum -a 256 -c -) +mkdir -p ~/.local/bin +install -m 0755 "$tmpdir/$asset" ~/.local/bin/start-issue ``` Сборка и установка из исходников: @@ -55,7 +48,7 @@ curl -fsSL https://github.com/dapi/start-issue/releases/latest/download/start-is make install ``` -Команда собирает self-contained `start-issue` из модульных исходников и устанавливает его в `~/.local/bin/start-issue`. +Команда собирает Go-бинарник `start-issue` из исходников и устанавливает его в `~/.local/bin/start-issue`. Убедитесь, что `~/.local/bin` есть в вашем `PATH`. @@ -183,7 +176,7 @@ flowchart TD F -- yes --> G["Напечатать план
и выйти"] F -- no --> H["Создать или переиспользовать
git worktree"] - H --> I["Запустить init.sh
если включено"] + H --> I["Запустить hook инициализации
если включен"] I --> J["Сформировать prompt
для agent"] J --> K{"Agent выбран?"} @@ -197,18 +190,15 @@ flowchart TD ## Внутренняя архитектура CLI entrypoint — `cmd/start-issue`; runtime, build и тесты реализованы на Go. -`make build` и `make install` собирают эти модули обратно в single-file script для дистрибуции и локальной установки. - -- `cli.sh` парсит аргументы и нормализует флаги в состояние workflow. -- `config.sh` разрешает конфигурацию agent, model и prompt. -- `github.sh` определяет контекст репозитория и получает metadata issue. -- `worktree.sh` планирует поведение branch/worktree и выполняет worktree-side effects. -- `agent.sh` владеет операциями agent adapter: validation, сборка launch command, AI branch naming и prompt improvement. -- `release.sh` владеет download/checksum/version-normalization helper-логикой, общей для install и update paths. -- `update.sh` владеет workflow self-update и определением latest release. -- `output.sh` рендерит help, status, dry-run output и session framing. -- `init.sh` владеет `start-issue init` и helper-логикой user-config onboarding за `setup`. -- `pipeline.sh` делает orchestration pipeline явным. +`make build` и `make install` собирают и устанавливают Go-бинарник. + +- Helpers конфигурации и prompt разрешают CLI, environment, project и user defaults. +- Helpers repository/worktree получают metadata issue, безопасно планируют reuse и + запускают optional hook `init.sh` внутри подготовленной worktree. +- Helpers agent adapter валидируют agent, строят launch commands, генерируют AI + branch names и выполняют Codex human-gate mode. +- Helpers release выбирают platform assets, проверяют checksum и staged + `--version`, затем атомарно устанавливают update. Внутренний pipeline теперь такой: @@ -219,7 +209,10 @@ CLI entrypoint — `cmd/start-issue`; runtime, build и тесты реализ 5. Execute the plan. 6. Launch the selected agent. -Bash стоит сохранять, пока lifecycle commands, shape конфигурации и требования к output остаются достаточно простыми для читаемых shell-модулей. Если будущие задачи потребуют nested configuration, более богатых subcommands вроде `resume` или `cleanup`, или структурированного machine-readable output, это следует считать порогом для оценки Python core. +Go implementation сохраняет lifecycle commands, configuration shape и output в +одном compiled CLI, оставляя `git`, `gh` и agent CLIs внешними process +boundaries. Новые возможности должны сохранять эти focused helper boundaries, +а не возвращать второй runtime. ## Аргументы CLI @@ -300,9 +293,10 @@ Workflow: 2. Читает версию executable, который пользователь запустил. 3. Нормализует версии, поэтому `1.11.1` и `v1.11.1` считаются равными. 4. Если текущая версия уже актуальна или новее последнего опубликованного релиза, команда завершается с кодом `0` и печатает понятный статус. -5. Если опубликован более новый релиз, команда скачивает `start-issue` и `start-issue.sha256`, проверяет checksum и устанавливает обновление в тот же executable path, который был вызван. +5. Если опубликован более новый релиз, команда скачивает бинарник для текущей платформы и `checksums.txt`, проверяет checksum и устанавливает обновление в resolved target executable path, который был вызван. -Workflow обновления работает вне git repository. Для него нужны `gh`, `jq` и либо `curl`, либо `wget`. +Workflow обновления работает вне git repository и требует только `gh`. Go-бинарник +сам разбирает metadata release, скачивает assets и проверяет checksums. Приоритет конфигурации: @@ -324,7 +318,7 @@ Claude по умолчанию использует plugin-native команду start-issue 123 --agent codex --improve-prompt ``` -Команда выбирает активный prompt template по обычному приоритету, получает issue как контекст, просит выбранного агента вернуть полный улучшенный prompt template и записывает proposal-файл. Активный prompt не перезаписывается. Для prompt-файлов proposal по умолчанию создается рядом с источником как `*.improved.md`; для built-in и inline prompt используется `.start-issue/prompt.improved.md`. Используйте `--prompt-output-file`, чтобы указать другой путь. +Команда выбирает активный prompt template по обычному приоритету, получает issue как контекст, просит выбранного агента вернуть полный улучшенный prompt template и записывает proposal-файл. Активный prompt не перезаписывается. Для Markdown prompt-файлов proposal по умолчанию создается рядом с источником как `*.improved.md`; к остальным именам файлов добавляется `.improved`. Для built-in и inline prompt используется `.start-issue/prompt.improved.md`. Используйте `--prompt-output-file`, чтобы указать другой путь. Prompt templates поддерживают: @@ -354,34 +348,35 @@ Prompt templates поддерживают: ## Требования -- `bash` - `git` - `gh` CLI с авторизованной GitHub session -- `jq` - CLI выбранного агента, если не используется `--agent none` или `--dry-run` -Для `curl | bash` installer и workflow self-update нужны `bash` и либо `curl`, либо `wget`. +Для сборки из исходников дополнительно нужен Go 1.24+. Опциональному Bash +installer и manual-install snippet нужны `bash`, `curl` или `wget` и SHA-256 +tool; `start-issue update` эти инструменты не использует. ## Релизы -GitHub Releases публикуются автоматически, когда в репозиторий пушится SemVer tag вроде `v1.12.0`. Release workflow заново прогоняет тесты, проверяет, что tag совпадает с `VERSION` в `scripts/start-issue`, собирает bundled-скрипт `start-issue` и загружает: +GitHub Releases публикуются автоматически, когда в репозиторий пушится SemVer tag вроде `v2.0.0`. Release workflow заново прогоняет тесты, собирает Go-бинарники для поддерживаемых платформ и загружает: -- `start-issue` -- `start-issue.sha256` +- `start-issue--` (и `.exe` для Windows) +- `checksums.txt` +- `start-issue` и `start-issue.sha256` (временный bridge для обновления с v1) Подготовить релиз локально можно так: ```bash -make release-patch -make release-minor -make release-major +make test +git tag v2.0.0 +git push origin v2.0.0 ``` Перед подготовкой релиза добавьте user-facing изменения в `CHANGELOG.md` под `## [Unreleased]`. -Каждая команда требует чистое рабочее дерево, поднимает `VERSION`, переносит unreleased-записи из `CHANGELOG.md` под новую версию и дату, запускает `make test` и `make build`, создает локальный commit вида `Release v1.12.0` и создает matching annotated git tag. +Создавайте релизы из чистого рабочего дерева после успешных `make test` и `make build`. Tag определяет версию публикуемого бинарного файла. -Опубликовать подготовленный релиз: +Опубликовать подготовленный release можно также вместе с веткой: ```bash git push origin master --follow-tags diff --git a/cmd/start-issue/main.go b/cmd/start-issue/main.go index d69ab2f..1f15ca1 100644 --- a/cmd/start-issue/main.go +++ b/cmd/start-issue/main.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "bytes" "crypto/sha256" "encoding/json" "errors" @@ -13,16 +14,51 @@ import ( "path/filepath" "regexp" "runtime" + "runtime/debug" "strconv" "strings" + "syscall" "time" + "unicode/utf8" ) -var version = "2.0.0" +// version is set by release builds with -ldflags. go install records the +// module version in build metadata instead, which runningVersion reads below. +var version string + +// sourceVersion identifies direct development builds that were not made by +// Make or go install. Release and source builds inject/record their version. +const sourceVersion = "dev" + +const defaultReleaseRepository = "dapi/start-issue" + +var gitDescribeSuffix = regexp.MustCompile(`^(.*?)(?:-[0-9]+-g[0-9a-fA-F]+(?:-dirty)?|-dirty)$`) + +func runningVersion() string { + if version != "" { + return strings.TrimPrefix(version, "v") + } + info, ok := debug.ReadBuildInfo() + if !ok { + return sourceVersion + } + return versionFromBuildInfo(info) +} + +func versionFromBuildInfo(info *debug.BuildInfo, fallback ...string) string { + defaultVersion := sourceVersion + if len(fallback) > 0 { + defaultVersion = fallback[0] + } + if info != nil && info.Main.Version != "" && info.Main.Version != "(devel)" { + return strings.TrimPrefix(info.Main.Version, "v") + } + return defaultVersion +} type options struct { repo, base, worktreeDir, agent, model, promptFile, prompt, command string - promptOutput string + promptOutput, worktreeDirSource string issue string dryRun, noInit, flat, ai, improvePrompt, humanGate, project, user, force bool mode string @@ -30,16 +66,60 @@ type options struct { type issue struct { Title, Body string - Labels []struct { - Name string `json:"name"` - } `json:"labels"` + Labels []issueLabel +} + +type issueLabel struct { + Name string `json:"name"` +} + +// UnmarshalJSON preserves the shell implementation's treatment of a GitHub +// issue with no body: GitHub represents it as null, while the CLI uses an +// empty string when rendering the prompt. +func (in *issue) UnmarshalJSON(data []byte) error { + var value struct { + Title string `json:"title"` + Body *string `json:"body"` + Labels []issueLabel `json:"labels"` + } + if err := json.Unmarshal(data, &value); err != nil { + return err + } + in.Title = value.Title + in.Body = "" + if value.Body != nil { + in.Body = *value.Body + } + in.Labels = value.Labels + return nil +} + +type exitError struct { + code int + err error } +func (e exitError) Error() string { return e.err.Error() } + +func (e exitError) Unwrap() error { return e.err } + +type promptFileNotFoundError struct { + path string + cause error +} + +func (e promptFileNotFoundError) Error() string { + return fmt.Sprintf("Prompt file not found: %s", e.path) +} + +func (e promptFileNotFoundError) Unwrap() error { return e.cause } + func main() { o, err := parse(os.Args[1:]) if err != nil { die(err) } + printBanner() if o.mode != "" { if err := runMode(o); err != nil { die(err) @@ -47,9 +127,10 @@ func main() { return } if o.issue == "" { - usage() - fmt.Fprintln(os.Stderr, "Error: is required.") - os.Exit(1) + if err := runMissingIssue(o); err != nil { + die(err) + } + return } if err := run(o); err != nil { die(err) @@ -58,11 +139,10 @@ func main() { func parse(args []string) (options, error) { o := options{worktreeDir: os.Getenv("START_ISSUE_WORKTREE_DIR")} - var err error - if o.worktreeDir == "" { - home, _ := os.UserHomeDir() - o.worktreeDir = filepath.Join(home, "worktrees") + if o.worktreeDir != "" { + o.worktreeDirSource = "START_ISSUE_WORKTREE_DIR" } + var err error for len(args) > 0 { a := args[0] args = args[1:] @@ -72,6 +152,9 @@ func parse(args []string) (options, error) { } v := args[0] args = args[1:] + if v == "" { + return "", fmt.Errorf("%s requires a value.", a) + } return v, nil } switch a { @@ -79,7 +162,7 @@ func parse(args []string) (options, error) { usage() os.Exit(0) case "--version", "-v": - fmt.Printf("start-issue v%s\n", version) + fmt.Printf("start-issue v%s\n", runningVersion()) os.Exit(0) case "--repo", "-r": o.repo, err = value() @@ -87,6 +170,9 @@ func parse(args []string) (options, error) { o.base, err = value() case "--worktree-dir", "-w": o.worktreeDir, err = value() + if err == nil { + o.worktreeDirSource = "CLI" + } case "--agent": o.agent, err = value() case "--model": @@ -119,14 +205,12 @@ func parse(args []string) (options, error) { o.user = true case "--force": o.force = true - case "init": - o.mode = "init" - case "setup", "--setup": - o.mode = "setup" - case "update", "--update": - o.mode = "update" - case "install", "--install": - o.mode = "install" + case "init", "setup", "--setup", "update", "--update", "install", "--install": + mode := strings.TrimLeft(a, "-") + if o.mode != "" { + return o, fmt.Errorf("Use only one command mode; got %s and %s.", o.mode, mode) + } + o.mode = mode case "--human-gate-help": humanGateHelp() os.Exit(0) @@ -155,18 +239,44 @@ func parse(args []string) (options, error) { if o.mode != "" && o.issue != "" { return o, fmt.Errorf("Use either %s or , not both.", o.mode) } + if o.worktreeDir == "" && o.mode == "" { + home, err := userHomeDir() + if err != nil { + return o, fmt.Errorf("default worktree directory requires a home directory; set --worktree-dir or START_ISSUE_WORKTREE_DIR: %w", err) + } + o.worktreeDir = filepath.Join(home, "worktrees") + o.worktreeDirSource = "built-in default" + } return o, nil } +func userHomeDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + if strings.TrimSpace(home) == "" || !filepath.IsAbs(home) { + return "", errors.New("home directory is unavailable") + } + return home, nil +} + func run(o options) error { + return runWithReader(o, bufio.NewReader(os.Stdin)) +} + +func runWithReader(o options, reader *bufio.Reader) error { + if err := maybeRunFirstRunOnboarding(o.dryRun, o.command, reader); err != nil { + return err + } + if err := need("git"); err != nil { + return err + } root, err := output("git", "rev-parse", "--show-toplevel") if err != nil { return errors.New("Not in a git repository") } root = strings.TrimSpace(root) - if err := maybeRunFirstRunOnboarding(o.dryRun); err != nil { - return err - } agent, agentSource, err := resolveAgent(root, o.agent) if err != nil { return err @@ -174,6 +284,9 @@ func run(o options) error { if o.humanGate && agent != "codex" { return fmt.Errorf("--human-gate requires agent 'codex'. Current agent: %s.", agent) } + if o.improvePrompt && agent == "none" { + return errors.New("--improve-prompt requires an agent. Use --agent claude, codex, kimi, or pi.") + } if agent != "none" && !o.dryRun { if err := need(agent); err != nil { return fmt.Errorf("%s CLI not found. Install it or use --agent none.", agent) @@ -183,7 +296,7 @@ func run(o options) error { if err != nil { return err } - prompt, promptSource, err := resolvePrompt(root, agent, o) + prompt, promptSource, promptLocation, promptFile, err := resolvePrompt(root, agent, o) if err != nil { return err } @@ -200,10 +313,7 @@ func run(o options) error { if o.base == "" { o.base = detectBase() } - if err := need("gh"); err != nil { - return err - } - if err := need("git"); err != nil { + if err := checkGitHubAccess(); err != nil { return err } data, err := output("gh", "api", fmt.Sprintf("repos/%s/issues/%s", repo, number)) @@ -218,83 +328,105 @@ func run(o options) error { for _, l := range in.Labels { labels = append(labels, l.Name) } - if _, err := exec.LookPath("zellij-tab-status"); err == nil { - if o.dryRun { - fmt.Printf(" [DRY-RUN] Would run: zellij-tab-status --set-name #%s\n", number) - } else { - _ = command("zellij-tab-status", "--set-name", "#"+number) - } - } if o.improvePrompt { - return improvePrompt(root, agent, model, prompt, promptSource, o, in, repo, number, strings.Join(labels, ", ")) + return improvePrompt(root, agent, model, prompt, promptSource, promptFile, o, in, repo, number, strings.Join(labels, ", ")) } + renameZellijTab(number, o.dryRun) branch := branchName(number, in.Title, strings.Join(labels, ", ")) + branchSource := "fast" if o.ai { if generated, err := aiBranchName(agent, model, root, number, in.Title, strings.Join(labels, ", ")); err == nil && regexp.MustCompile(`^(feature|fix|hotfix|refactor|docs|test|chore)/issue-[0-9]+-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`).MatchString(generated) { branch = generated - fmt.Printf(" Branch: %s (ai:%s)\n", branch, agent) + branchSource = "ai:" + agent } else { fmt.Printf(" Could not generate branch name with %s; using fast fallback\n", agent) } } - name := branch - if o.flat { - name = strings.ReplaceAll(name, "/", "-") - } - worktree := filepath.Join(o.worktreeDir, name) issueURL := fmt.Sprintf("https://github.com/%s/issues/%s", repo, number) - rendered := render(prompt, map[string]string{"ISSUE_URL": issueURL, "ISSUE_NUMBER": number, "ISSUE_TITLE": in.Title, "ISSUE_BODY": in.Body, "ISSUE_LABELS": strings.Join(labels, ", "), "REPO": repo, "BRANCH_NAME": branch, "WORKTREE_PATH": worktree, "BASE_BRANCH": o.base}) - fmt.Printf("Agent: %s\nAgent source: %s\nModel: %s\nModel source: %s\nWorktree directory: %s\nPrompt source: %s\n\n", agent, agentSource, show(model), modelSource, o.worktreeDir, promptSource) + worktree := canonicalPath(worktreePath(o.worktreeDir, branch, o.flat)) + fmt.Printf("Agent: %s\nAgent source: %s\nModel: %s\nModel source: %s\nWorktree directory: %s (%s)\nPrompt source: %s\nPrompt location: %s\n\n", agent, agentSource, show(model), modelSource, o.worktreeDir, o.worktreeDirSource, promptSource, promptLocation) fmt.Printf("🔍 Fetching issue #%s from %s...\n Title: %s\n", number, repo, in.Title) - fmt.Printf(" Branch: %s (fast)\n📁 Creating worktree...\n Path: %s\n Base: %s\n", branch, worktree, o.base) - if _, err := os.Stat(worktree); err == nil { - if worktreeBranch(worktree) != "refs/heads/"+branch { - return fmt.Errorf("Cannot reuse worktree path '%s': it does not belong to branch '%s'.", worktree, branch) - } - if o.dryRun { - fmt.Printf(" [DRY-RUN] Would reuse worktree: %s\n", worktree) - return launchSelected(options{dryRun: true, humanGate: o.humanGate}, agent, model, worktree, rendered) - } - if !confirm("Worktree already exists. Continue in " + worktree + "? [y/N] ") { - return errors.New("Cancelled.") - } - if !o.noInit { - runInit(worktree) - } - return launchSelected(o, agent, model, worktree, rendered) - } - if branchWorktree(branch) != "" { - existing := branchWorktree(branch) - if o.dryRun { - fmt.Printf(" [DRY-RUN] Would reuse branch worktree: %s\n", existing) - return launchSelected(options{dryRun: true, humanGate: o.humanGate}, agent, model, existing, render(prompt, map[string]string{"WORKTREE_PATH": existing})) - } - if !confirm("Branch already exists in " + existing + ". Continue there? [y/N] ") { + if len(labels) > 0 { + fmt.Printf(" Labels: %s\n", strings.Join(labels, ", ")) + } + fmt.Printf(" Branch: %s (%s)\n📁 Creating worktree...\n Path: %s\n Base: %s\n", branch, branchSource, worktree, o.base) + branchExists := branchRefExists(branch) + existing := branchWorktree(branch) + recreate := false + if branchExists { + choice := branchConflictChoice(branch, existing, reader) + switch choice { + case "1": + if existing == "" { + return fmt.Errorf("No existing worktree found for branch '%s'. Use 3 to delete and recreate.", branch) + } + if err := validateReusedWorktree(existing, branch); err != nil { + return err + } + if !o.noInit { + runInit(existing, o.dryRun) + } + return launchSelected(o, agent, model, existing, renderIssuePrompt(prompt, issueURL, number, in, labels, repo, branch, existing, o.base)) + case "2": + branch = nextSuffixedBranch(branch) + worktree = canonicalPath(worktreePath(o.worktreeDir, branch, o.flat)) + fmt.Printf(" New branch name: %s\n", branch) + case "3": + if o.dryRun { + if existing != "" { + fmt.Printf(" [DRY-RUN] Would remove worktree: %s\n", existing) + } + fmt.Printf(" [DRY-RUN] Would delete branch: %s\n", branch) + recreate = true + break + } + if err := removeWorktreeAndBranch(existing, branch); err != nil { + return err + } + default: return errors.New("Cancelled.") } - if !o.noInit { - runInit(existing) - } - return launchSelected(o, agent, model, existing, render(prompt, map[string]string{"WORKTREE_PATH": existing})) } - if _, err := output("git", "show-ref", "--verify", "--quiet", "refs/heads/"+branch); err == nil { - if o.dryRun { - fmt.Printf(" [DRY-RUN] Branch exists detached; would prompt for resolution.\n") - return nil - } - if confirm("Branch exists detached. Create suffixed branch? [y/N] ") { - branch += "-2" - } else { - return errors.New("Cancelled.") + if !recreate { + if _, err := os.Stat(worktree); err == nil { + pathBranch, registered := worktreeRegistration(worktree) + if o.dryRun { + if !registered { + fmt.Printf(" [DRY-RUN] Worktree path exists but is not a registered worktree; would stop and require manual recovery: %s\n", worktree) + return nil + } + fmt.Printf(" [DRY-RUN] Worktree path exists; would prompt for reuse or delete/recreate: %s\n", worktree) + return nil + } + choice, err := pathConflictChoice(worktree, pathBranch, registered, reader) + if err != nil { + return err + } + switch choice { + case "1": + if err := validateReusedWorktree(worktree, branch); err != nil { + return err + } + if !o.noInit { + runInit(worktree, o.dryRun) + } + return launchSelected(o, agent, model, worktree, renderIssuePrompt(prompt, issueURL, number, in, labels, repo, branch, worktree, o.base)) + case "2": + if err := removeWorktreeAndBranch(worktree, strings.TrimPrefix(pathBranch, "refs/heads/")); err != nil { + return err + } + default: + return errors.New("Cancelled.") + } } } + rendered := renderIssuePrompt(prompt, issueURL, number, in, labels, repo, branch, worktree, o.base) if o.dryRun { fmt.Printf(" [DRY-RUN] Would run: git worktree add -b %s %s %s\n", branch, worktree, o.base) if o.humanGate { return humanGate(model, worktree, rendered, true) } - printLaunch(agent, model, worktree, rendered) - return nil + return launchSelected(options{dryRun: true}, agent, model, worktree, rendered) } if err := os.MkdirAll(filepath.Dir(worktree), 0755); err != nil { return err @@ -306,21 +438,44 @@ func run(o options) error { } } if !o.noInit { - runInit(worktree) + runInit(worktree, false) } return launchSelected(o, agent, model, worktree, rendered) } -func runInit(worktree string) { +func runInit(worktree string, dryRun bool) { if init := filepath.Join(worktree, "init.sh"); fileExists(init) { - _ = commandAt(worktree, "bash", "./init.sh") + if dryRun { + fmt.Printf(" [DRY-RUN] Would run: %s\n", init) + return + } + if err := commandAt(worktree, "bash", "./init.sh"); err != nil { + fmt.Println("Warning: init.sh exited with non-zero code") + } + } +} + +func renameZellijTab(number string, dryRun bool) { + if _, err := exec.LookPath("zellij-tab-status"); err != nil { + if dryRun { + fmt.Println(" [DRY-RUN] Would skip zellij tab rename: zellij-tab-status not found") + } + return + } + if dryRun { + fmt.Printf(" [DRY-RUN] Would run: zellij-tab-status --set-name #%s\n", number) + return + } + if err := command("zellij-tab-status", "--set-name", "#"+number); err != nil { + fmt.Println("Warning: Could not rename zellij tab with zellij-tab-status") } } + func fileExists(path string) bool { _, err := os.Stat(path); return err == nil } -func maybeRunFirstRunOnboarding(dryRun bool) error { - home, err := os.UserHomeDir() +func maybeRunFirstRunOnboarding(dryRun bool, command string, reader *bufio.Reader) error { + home, err := userHomeDir() if err != nil { - return err + return nil } dir := filepath.Join(home, ".config", "start-issue") if _, err := os.Stat(dir); err == nil { @@ -328,140 +483,399 @@ func maybeRunFirstRunOnboarding(dryRun bool) error { } else if !os.IsNotExist(err) { return err } - reader := bufio.NewReader(os.Stdin) + fmt.Println("Configuration is not initialized yet.") + fmt.Println() + fmt.Println("Usage: start-issue [options]") + fmt.Println() fmt.Print("No start-issue user configuration found. Run setup now? [Y/n] ") - answer, _ := reader.ReadString('\n') if dryRun { fmt.Printf("[DRY-RUN] Would create first-run configuration marker: %s\n", dir) return nil } - if strings.TrimSpace(strings.ToLower(answer)) == "n" { + answer, err := reader.ReadString('\n') + if err != nil { + return errors.New("No response received.") + } + switch strings.TrimSpace(strings.ToLower(answer)) { + case "", "y", "yes": + return setupMode(home, false, command, reader) + case "n", "no": return os.MkdirAll(dir, 0755) + default: + return fmt.Errorf("Invalid response: %s. Use y or n.", strings.TrimSpace(answer)) } - return setupMode(home, false) } -func runMode(o options) error { - home, err := os.UserHomeDir() - if err != nil { - return err +func runMissingIssue(o options) error { + if o.improvePrompt { + return errors.New("--improve-prompt requires . Example: start-issue 123 --improve-prompt") } root, _ := output("git", "rev-parse", "--show-toplevel") root = strings.TrimSpace(root) + if err := maybeRunFirstRunOnboarding(o.dryRun, o.command, bufio.NewReader(os.Stdin)); err != nil { + return err + } + agent, agentSource, err := resolveAgent(root, o.agent) + if err != nil { + return err + } + model, modelSource, err := resolveModel(root, o.model) + if err != nil { + return err + } + _, promptSource, promptLocation, _, err := resolvePrompt(root, agent, o) + if err != nil { + return err + } + usage() + fmt.Println("Current configuration:") + fmt.Printf(" Agent: %s\n Agent source: %s\n Model: %s\n Model source: %s\n Prompt source: %s\n Prompt location: %s\n Worktree dir: %s (%s)\n", agent, agentSource, show(model), modelSource, promptSource, promptLocation, o.worktreeDir, o.worktreeDirSource) + return errors.New("missing issue URL or issue number") +} + +func runMode(o options) error { if o.mode == "update" { return updateMode(o) } + root, _ := output("git", "rev-parse", "--show-toplevel") + root = strings.TrimSpace(root) if o.mode == "install" { - return installMode() + return installMode(o.dryRun) } if o.mode == "setup" { - return setupMode(home, o.dryRun) - } - dir := filepath.Join(home, ".config", "start-issue") - if o.mode == "init" && o.project { - if root == "" { - return errors.New("--project requires a git repository") - } - dir = filepath.Join(root, ".start-issue") - } else if o.mode == "init" && !o.user && root != "" { - if confirm("Initialize project configuration? [y/N] ") { - dir = filepath.Join(root, ".start-issue") + home, err := userHomeDir() + if err != nil { + return err } + return setupMode(home, o.dryRun, o.command, bufio.NewReader(os.Stdin)) } - if o.dryRun { - fmt.Printf("[DRY-RUN] Would create configuration in: %s\n", dir) - return nil + if o.mode != "init" { + return fmt.Errorf("Unknown command mode: %s", o.mode) } - if err := os.MkdirAll(dir, 0755); err != nil { - return err + home := "" + if !o.project { + var err error + home, err = userHomeDir() + if err != nil { + return err + } } - agent, _, err := resolveAgent(root, o.agent) + dir, err := selectInitDir(root, home, o, bufio.NewReader(os.Stdin)) if err != nil { return err } - if err := writeConfig(filepath.Join(dir, "agent"), agent+"\n", o.force); err != nil { + agent, agentSource, err := resolveInitAgent(filepath.Join(dir, "agent"), o.agent, o.force) + if err != nil { return err } - if o.model != "" { - if err := writeConfig(filepath.Join(dir, "model"), o.model+"\n", o.force); err != nil { - return err - } - } - if o.force && o.model == "" { - _ = os.Remove(filepath.Join(dir, "model")) + model, err := resolveInitModel(filepath.Join(dir, "model"), o.model, o.force) + if err != nil { + return err } prompt := o.prompt + promptSource := "CLI --prompt" if o.promptFile != "" { - b, err := os.ReadFile(o.promptFile) + b, err := readPromptFile(o.promptFile) if err != nil { return err } prompt = string(b) + promptSource = "CLI --prompt-file: " + o.promptFile } if prompt == "" { if agent == "claude" { - prompt = "/task-router:route-task {ISSUE_URL}" + prompt = claudeDefaultPrompt(o.command) + promptSource = "built-in Claude command" } else { prompt = defaultPortablePrompt() + promptSource = "built-in portable prompt" } } - if err := writeConfig(filepath.Join(dir, "prompt.md"), prompt+"\n", o.force); err != nil { + plan := initConfigPlan{ + dir: dir, + agent: agent, + agentSource: agentSource, + model: model, + prompt: prompt, + promptSource: promptSource, + force: o.force, + } + if o.dryRun { + return plan.printDryRun() + } + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + if err := plan.apply(); err != nil { return err } fmt.Printf("Wrote start-issue configuration: %s\n", dir) return nil } -func setupMode(home string, dryRun bool) error { - dir := filepath.Join(home, ".config", "start-issue") - if dryRun { - fmt.Printf("[DRY-RUN] Would create configuration in: %s\n", dir) +func selectInitDir(root, home string, o options, reader *bufio.Reader) (string, error) { + if o.project { + if root == "" { + return "", errors.New("--project requires a git repository") + } + return filepath.Join(root, ".start-issue"), nil + } + userDir := filepath.Join(home, ".config", "start-issue") + if o.user { + return userDir, nil + } + if root == "" { + fmt.Printf("Initialize start-issue configuration:\n 1) User config (%s)\nChoice [1]: ", userDir) + choice, err := reader.ReadString('\n') + if err != nil { + return "", errors.New("No init scope selected. Use --user outside a git repository.") + } + switch strings.TrimSpace(strings.ToLower(choice)) { + case "", "1", "u", "user": + return userDir, nil + default: + return "", errors.New("Project config requires a git repository. Use --user outside a git repository.") + } + } + + projectDir := filepath.Join(root, ".start-issue") + fmt.Printf("Initialize start-issue configuration:\n 1) Project config (%s)\n 2) User config (%s)\nChoice [1/2]: ", projectDir, userDir) + choice, err := reader.ReadString('\n') + if err != nil { + return "", errors.New("No init scope selected. Use --project or --user.") + } + switch strings.TrimSpace(strings.ToLower(choice)) { + case "1", "p", "project": + return projectDir, nil + case "2", "u", "user": + return userDir, nil + default: + return "", fmt.Errorf("Invalid init scope: %s. Use --project or --user.", strings.TrimSpace(choice)) + } +} + +type initConfigPlan struct { + dir, agent, agentSource, model, prompt, promptSource string + force bool +} + +func resolveInitAgent(path, cli string, force bool) (string, string, error) { + if !force { + agent, err := configAgent(path, "") + if err != nil { + return "", "", err + } + if agent != "" { + return agent, path + " (existing)", nil + } + } + if cli != "" { + if err := validateAgent(cli); err != nil { + return "", "", err + } + return cli, "CLI", nil + } + return "claude", "built-in default", nil +} + +func resolveInitModel(path, cli string, force bool) (string, error) { + if !force { + model, err := configValue(path, "model") + if err != nil { + return "", err + } + if model != "" { + return model, nil + } + } + if cli == "" { + return "", nil + } + model := strings.TrimSpace(cli) + if model == "" { + return "", errors.New("Model config is empty. Remove the empty model config or set a value.") + } + return model, nil +} + +func (p initConfigPlan) paths() (agent, model, prompt string) { + return filepath.Join(p.dir, "agent"), filepath.Join(p.dir, "model"), filepath.Join(p.dir, "prompt.md") +} + +func (p initConfigPlan) printDryRun() error { + agentPath, modelPath, promptPath := p.paths() + fmt.Printf("[DRY-RUN] Would create configuration in: %s\n", p.dir) + fmt.Printf("[DRY-RUN] Agent: %s (%s)\n", p.agent, p.agentSource) + fmt.Printf("[DRY-RUN] Prompt source: %s\n", p.promptSource) + if err := printPlannedWrite("agent config", agentPath, p.force); err != nil { + return err + } + if p.model != "" { + if err := printPlannedWrite("model config", modelPath, p.force); err != nil { + return err + } + } else if p.force { + exists, err := pathExists(modelPath) + if err != nil { + return err + } + if exists { + fmt.Printf("[DRY-RUN] Would remove model config: %s\n", modelPath) + } else { + fmt.Printf("[DRY-RUN] No model config to write (built-in default: unset)\n") + } + } + return printPlannedWrite("prompt template", promptPath, p.force) +} + +func printPlannedWrite(label, path string, force bool) error { + exists, err := pathExists(path) + if err != nil { + return err + } + if exists && !force { + fmt.Printf("[DRY-RUN] %s already exists, keeping: %s\n", label, path) return nil } - if err := os.MkdirAll(dir, 0755); err != nil { + fmt.Printf("[DRY-RUN] Would write %s: %s\n", label, path) + return nil +} + +func (p initConfigPlan) apply() error { + agentPath, modelPath, promptPath := p.paths() + if err := writeConfig(agentPath, p.agent+"\n", p.force); err != nil { return err } - reader := bufio.NewReader(os.Stdin) - fmt.Println("Select default agent: 1) claude 2) codex 3) kimi 4) pi 5) skip") - fmt.Print("Choice [1]: ") - choice, _ := reader.ReadString('\n') - choice = strings.TrimSpace(choice) - agents := map[string]string{"": "claude", "1": "claude", "2": "codex", "3": "kimi", "4": "pi"} - if agent, ok := agents[choice]; ok { - if err := writeConfig(filepath.Join(dir, "agent"), agent+"\n", true); err != nil { + if p.model != "" { + if err := writeConfig(modelPath, p.model+"\n", p.force); err != nil { return err } - } else if choice == "5" { - _ = os.Remove(filepath.Join(dir, "agent")) - } else { + } else if p.force { + if err := os.Remove(modelPath); err != nil && !os.IsNotExist(err) { + return err + } + } + return writeConfig(promptPath, p.prompt+"\n", p.force) +} + +func pathExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +func setupMode(home string, dryRun bool, command string, reader *bufio.Reader) error { + dir := filepath.Join(home, ".config", "start-issue") + if !dryRun { + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + } + fmt.Println("Select default agent: 1) claude 2) codex 3) kimi 4) pi 5) skip") + fmt.Print("Choice: ") + choice, err := reader.ReadString('\n') + if err != nil { + return errors.New("No setup agent selected.") + } + choice = strings.TrimSpace(choice) + normalizedChoice := strings.ToLower(choice) + agents := map[string]string{"1": "claude", "claude": "claude", "2": "codex", "codex": "codex", "3": "kimi", "kimi": "kimi", "4": "pi", "pi": "pi"} + agent, selected := agents[normalizedChoice] + skipAgent := normalizedChoice == "" || normalizedChoice == "5" || normalizedChoice == "skip" + if !selected && !skipAgent { return errors.New("invalid setup choice") } + if skipAgent { + agent = "claude" + } + prompt := defaultSetupPrompt(agent, command) + fmt.Printf("\nDefault prompt preview:\n%s\n\n", prompt) fmt.Print("Save a default prompt? [Y/n] ") - answer, _ := reader.ReadString('\n') - if strings.TrimSpace(strings.ToLower(answer)) != "n" { - agent := agents[choice] - if agent == "" { - agent = "claude" + answer, err := reader.ReadString('\n') + if err != nil { + return errors.New("No response received.") + } + savePrompt := false + switch strings.TrimSpace(strings.ToLower(answer)) { + case "", "y", "yes": + savePrompt = true + case "n", "no": + default: + return fmt.Errorf("Invalid response: %s. Use y or n.", strings.TrimSpace(answer)) + } + if dryRun { + fmt.Printf("[DRY-RUN] Would create configuration in: %s\n", dir) + if savePrompt { + fmt.Printf("[DRY-RUN] Would write prompt template: %s\n", filepath.Join(dir, "prompt.md")) + } else { + fmt.Printf("[DRY-RUN] Would remove prompt template: %s\n", filepath.Join(dir, "prompt.md")) } - prompt := defaultPortablePrompt() - if agent == "claude" { - prompt = "/task-router:route-task {ISSUE_URL}" + if skipAgent { + fmt.Printf("[DRY-RUN] Would remove agent config: %s\n", filepath.Join(dir, "agent")) + } else { + fmt.Printf("[DRY-RUN] Would write agent config: %s\n", filepath.Join(dir, "agent")) } + return nil + } + if savePrompt { if err := writeConfig(filepath.Join(dir, "prompt.md"), prompt+"\n", true); err != nil { return err } - } else { - _ = os.Remove(filepath.Join(dir, "prompt.md")) + } else if err := removeConfigIfExists(filepath.Join(dir, "prompt.md")); err != nil { + return fmt.Errorf("remove prompt template: %w", err) + } + if skipAgent { + if err := removeConfigIfExists(filepath.Join(dir, "agent")); err != nil { + return fmt.Errorf("remove agent config: %w", err) + } + } else if err := writeConfig(filepath.Join(dir, "agent"), agent+"\n", true); err != nil { + return err } fmt.Printf("Wrote start-issue configuration: %s\n", dir) return nil } -func installMode() error { +func removeConfigIfExists(path string) error { + err := os.Remove(path) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func defaultSetupPrompt(agent, command string) string { + if agent == "claude" { + return claudeDefaultPrompt(command) + } + return defaultPortablePrompt() +} + +func installMode(dryRun bool) error { + name, err := releaseAssetName(runtime.GOOS, runtime.GOARCH) + if err != nil { + return err + } if runtime.GOOS == "windows" { return errors.New("Windows installation is manual: download start-issue-windows-amd64.exe from the latest release") } - data, err := output("gh", "api", "repos/dapi/start-issue/releases/latest") + home, err := userHomeDir() + if err != nil { + return err + } + target := filepath.Join(home, ".local", "bin", "start-issue") + if dryRun { + fmt.Printf("[DRY-RUN] Would download %s, verify checksums.txt, and install: %s\n", name, target) + return nil + } + if err := checkGitHubAccess(); err != nil { + return err + } + data, err := output("gh", "api", "repos/"+releaseRepository()+"/releases/latest") if err != nil { return errors.New("Could not fetch the latest start-issue release") } @@ -469,7 +883,6 @@ func installMode() error { if err := json.Unmarshal([]byte(data), &release); err != nil { return err } - name := releaseAssetName(runtime.GOOS, runtime.GOARCH) assetURL, checksumURL := release.assetURLs(name) if assetURL == "" || checksumURL == "" { return fmt.Errorf("latest release does not contain %s and checksums.txt", name) @@ -485,21 +898,28 @@ func installMode() error { if !validChecksum(binary, name, string(checksums)) { return fmt.Errorf("checksum verification failed for %s", name) } - home, err := os.UserHomeDir() - if err != nil { - return err - } - target := filepath.Join(home, ".local", "bin", "start-issue") if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { return err } - if err := os.WriteFile(target, binary, 0755); err != nil { + if err := installVerifiedUpdate(target, binary, release.TagName); err != nil { return err } fmt.Printf("Installed start-issue v%s at: %s\n", strings.TrimPrefix(release.TagName, "v"), target) return nil } +func installBinary(target string, binary []byte) error { + temporary, err := stageBinary(target, binary) + if err != nil { + return err + } + defer os.Remove(temporary) + if err := os.Rename(temporary, target); err != nil { + return err + } + return nil +} + func writeConfig(path, content string, force bool) error { if _, err := os.Stat(path); err == nil && !force { return nil @@ -507,12 +927,54 @@ func writeConfig(path, content string, force bool) error { return os.WriteFile(path, []byte(content), 0644) } +func configAgent(path, fallback string) (string, error) { + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return fallback, nil + } + return "", err + } + agent := first(string(b)) + if agent == "" { + return "", errors.New("Agent config is empty. Remove the empty agent config or set a value.") + } + switch agent { + case "claude", "codex", "kimi", "pi", "none": + return agent, nil + default: + return "", fmt.Errorf("Unknown agent: %s. Valid agents: claude, codex, kimi, pi, none.", agent) + } +} + +func configValue(path, name string) (string, error) { + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + value := first(string(b)) + if value == "" { + return "", fmt.Errorf("%s config is empty. Remove the empty %s config or set a value.", strings.ToUpper(name[:1])+name[1:], name) + } + return value, nil +} + func updateMode(o options) error { + assetName, err := releaseAssetName(runtime.GOOS, runtime.GOARCH) + if err != nil { + return err + } if runtime.GOOS == "windows" { fmt.Println("Windows update is manual: download start-issue-windows-amd64.exe from the latest release and replace the executable on PATH.") return nil } - data, err := output("gh", "api", "repos/dapi/start-issue/releases/latest") + if err := checkGitHubAccess(); err != nil { + return err + } + data, err := output("gh", "api", "repos/"+releaseRepository()+"/releases/latest") if err != nil { return errors.New("Could not fetch the latest start-issue release") } @@ -520,44 +982,108 @@ func updateMode(o options) error { if err := json.Unmarshal([]byte(data), &release); err != nil { return fmt.Errorf("decode latest release: %w", err) } - if compareVersions(version, release.TagName) >= 0 { - fmt.Printf("start-issue is already up to date (%s).\n", version) - return nil + if strings.TrimSpace(release.TagName) == "" { + return errors.New("latest release response is missing tag_name") } - if o.dryRun { - target, _ := os.Executable() - fmt.Printf("[DRY-RUN] Would download %s, verify checksums.txt, and replace: %s\n", releaseAssetName(runtime.GOOS, runtime.GOARCH), target) + if compareVersions(runningVersion(), release.TagName) >= 0 { + fmt.Printf("start-issue is already up to date (%s).\n", runningVersion()) return nil } - assetName := releaseAssetName(runtime.GOOS, runtime.GOARCH) assetURL, checksumURL := release.assetURLs(assetName) if assetURL == "" || checksumURL == "" { return fmt.Errorf("latest release does not contain %s and checksums.txt", assetName) } + if o.dryRun { + target, err := runningExecutablePath() + if err != nil { + return err + } + fmt.Printf("[DRY-RUN] Would download %s, verify checksums.txt, and replace: %s\n", assetName, target) + return nil + } binary, err := download(assetURL) if err != nil { return fmt.Errorf("download %s: %w", assetName, err) } checksums, err := download(checksumURL) if err != nil { - return fmt.Errorf("download checksums.txt: %w", err) + return fmt.Errorf("download checksums.txt: %w", err) + } + if !validChecksum(binary, assetName, string(checksums)) { + return fmt.Errorf("checksum verification failed for %s", assetName) + } + target, err := runningExecutablePath() + if err != nil { + return err + } + if err := installVerifiedUpdate(target, binary, release.TagName); err != nil { + return err + } + fmt.Printf("Updated start-issue at: %s\nVersion: start-issue v%s\n", target, strings.TrimPrefix(release.TagName, "v")) + return nil +} + +func runningExecutablePath() (string, error) { + target, err := os.Executable() + if err != nil { + return "", err } - if !validChecksum(binary, assetName, string(checksums)) { - return fmt.Errorf("checksum verification failed for %s", assetName) + resolved, err := filepath.EvalSymlinks(target) + if err != nil { + return "", fmt.Errorf("resolve running executable %q: %w", target, err) } - target, err := os.Executable() + return resolved, nil +} + +func installVerifiedUpdate(target string, binary []byte, expectedTag string) error { + temporary, err := stageBinary(target, binary) if err != nil { return err } - temporary := target + ".new" - if err := os.WriteFile(temporary, binary, 0755); err != nil { + defer os.Remove(temporary) + if err := verifyStagedBinary(temporary, expectedTag); err != nil { return err } if err := os.Rename(temporary, target); err != nil { - _ = os.Remove(temporary) return err } - fmt.Printf("Updated start-issue at: %s\nVersion: start-issue v%s\n", target, strings.TrimPrefix(release.TagName, "v")) + return nil +} + +func stageBinary(target string, binary []byte) (string, error) { + temporary, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".new-*") + if err != nil { + return "", err + } + path := temporary.Name() + cleanup := func(err error) (string, error) { + _ = temporary.Close() + _ = os.Remove(path) + return "", err + } + if err := temporary.Chmod(0755); err != nil { + return cleanup(err) + } + if _, err := temporary.Write(binary); err != nil { + return cleanup(err) + } + if err := temporary.Close(); err != nil { + _ = os.Remove(path) + return "", err + } + return path, nil +} + +func verifyStagedBinary(path, expectedTag string) error { + result, err := exec.Command(path, "--version").Output() + if err != nil { + return fmt.Errorf("staged update failed version verification: %w", err) + } + got := strings.TrimSpace(string(result)) + want := "start-issue v" + strings.TrimPrefix(expectedTag, "v") + if got != want { + return fmt.Errorf("staged update version %q does not match expected release %q", got, expectedTag) + } return nil } @@ -569,6 +1095,13 @@ type githubRelease struct { } `json:"assets"` } +func releaseRepository() string { + if repository := strings.TrimSpace(os.Getenv("START_ISSUE_REPOSITORY")); repository != "" { + return repository + } + return defaultReleaseRepository +} + func (r githubRelease) assetURLs(name string) (string, string) { var assetURL, checksumURL string for _, asset := range r.Assets { @@ -582,12 +1115,15 @@ func (r githubRelease) assetURLs(name string) (string, string) { return assetURL, checksumURL } -func releaseAssetName(goos, goarch string) string { - name := fmt.Sprintf("start-issue-%s-%s", goos, goarch) - if goos == "windows" { - return name + ".exe" +func releaseAssetName(goos, goarch string) (string, error) { + switch goos + "/" + goarch { + case "linux/amd64", "linux/arm64", "darwin/amd64", "darwin/arm64": + return fmt.Sprintf("start-issue-%s-%s", goos, goarch), nil + case "windows/amd64": + return "start-issue-windows-amd64.exe", nil + default: + return "", fmt.Errorf("unsupported release platform %s/%s; supported platforms: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64", goos, goarch) } - return name } func download(url string) ([]byte, error) { @@ -615,44 +1151,139 @@ func validChecksum(binary []byte, name, manifest string) bool { return want != "" && strings.EqualFold(want, actual) } -func compareVersions(left, right string) int { - parse := func(value string) [3]int { - var result [3]int - for index, part := range strings.Split(strings.TrimPrefix(value, "v"), ".") { - if index == len(result) { - break - } - result[index], _ = strconv.Atoi(regexp.MustCompile(`^[0-9]+`).FindString(part)) +type semanticVersion struct { + core [3]int + prerelease []string + postTag bool +} + +func parseSemanticVersion(value string) semanticVersion { + value = strings.TrimPrefix(strings.TrimSpace(value), "v") + if build := strings.IndexByte(value, '+'); build >= 0 { + value = value[:build] + } + // Make builds use git describe. Its "--g" suffix, or + // "-dirty" when the checkout is modified exactly at a tag, means this + // source build is newer than the referenced tag, not a SemVer prerelease. + // Preserve that distinction so update never downgrades it. + postTag := false + if matches := gitDescribeSuffix.FindStringSubmatch(value); matches != nil { + value = matches[1] + postTag = true + } + parts := strings.SplitN(value, "-", 2) + core := strings.Split(parts[0], ".") + result := semanticVersion{postTag: postTag} + for index := range result.core { + if index < len(core) { + result.core[index], _ = strconv.Atoi(core[index]) } - return result } - a, b := parse(left), parse(right) - for i := range a { - if a[i] < b[i] { + if len(parts) == 2 && parts[1] != "" { + result.prerelease = strings.Split(parts[1], ".") + } + return result +} + +func compareVersions(left, right string) int { + a, b := parseSemanticVersion(left), parseSemanticVersion(right) + for index := range a.core { + if a.core[index] < b.core[index] { return -1 } - if a[i] > b[i] { + if a.core[index] > b.core[index] { return 1 } } + if len(a.prerelease) == 0 && len(b.prerelease) > 0 { + return 1 + } + if len(a.prerelease) > 0 && len(b.prerelease) == 0 { + return -1 + } + for index := 0; index < len(a.prerelease) && index < len(b.prerelease); index++ { + if result := comparePrereleaseIdentifier(a.prerelease[index], b.prerelease[index]); result != 0 { + return result + } + } + if len(a.prerelease) < len(b.prerelease) { + return -1 + } + if len(a.prerelease) > len(b.prerelease) { + return 1 + } + if a.postTag && !b.postTag { + return 1 + } + if !a.postTag && b.postTag { + return -1 + } + return 0 +} + +func comparePrereleaseIdentifier(left, right string) int { + leftNumber, leftErr := strconv.Atoi(left) + rightNumber, rightErr := strconv.Atoi(right) + if leftErr == nil && rightErr == nil { + return compareInts(leftNumber, rightNumber) + } + if leftErr == nil { + return -1 + } + if rightErr == nil { + return 1 + } + return strings.Compare(left, right) +} + +func compareInts(left, right int) int { + if left < right { + return -1 + } + if left > right { + return 1 + } return 0 } func resolveAgent(root, cli string) (string, string, error) { - home, _ := os.UserHomeDir() - v, s, e := resolve(cli, filepath.Join(root, ".start-issue", "agent"), filepath.Join(home, ".config", "start-issue", "agent"), "START_ISSUE_AGENT", "claude") + project := "" + if root != "" { + project = filepath.Join(root, ".start-issue", "agent") + } + v, s, e := resolve(cli, project, userConfigPath("agent"), "START_ISSUE_AGENT", "claude") if e != nil { return "", "", e } - switch v { + if v == "" { + return "", s, errors.New("Agent config is empty. Valid agents: claude, codex, kimi, pi, none.") + } + if err := validateAgent(v); err != nil { + return "", "", err + } + return v, s, nil +} + +func validateAgent(agent string) error { + switch agent { case "claude", "codex", "kimi", "pi", "none": - return v, s, nil + return nil + default: + return fmt.Errorf("Unknown agent: %s. Valid agents: claude, codex, kimi, pi, none.", agent) } - return "", "", fmt.Errorf("Unknown agent: %s. Valid agents: claude, codex, kimi, pi, none.", v) } func resolveModel(root, cli string) (string, string, error) { - home, _ := os.UserHomeDir() - v, s, e := resolve(cli, filepath.Join(root, ".start-issue", "model"), filepath.Join(home, ".config", "start-issue", "model"), "START_ISSUE_MODEL", "") + if cli != "" { + cli = strings.TrimSpace(cli) + if cli == "" { + return "", "CLI", errors.New("--model requires a non-empty value.") + } + } + project := "" + if root != "" { + project = filepath.Join(root, ".start-issue", "model") + } + v, s, e := resolve(cli, project, userConfigPath("model"), "START_ISSUE_MODEL", "") if e == nil && s != "built-in default" && v == "" { e = errors.New("Model config is empty. Remove the empty model config or set a value.") } @@ -663,14 +1294,17 @@ func resolve(cli, project, user, env, def string) (string, string, error) { return cli, "CLI", nil } for _, p := range []string{project, user} { + if p == "" { + continue + } if b, e := os.ReadFile(p); e == nil { return first(string(b)), p, nil } else if !os.IsNotExist(e) { return "", "", e } } - if v := strings.TrimSpace(os.Getenv(env)); v != "" { - return v, env, nil + if raw, ok := os.LookupEnv(env); ok && raw != "" { + return strings.TrimSpace(raw), env, nil } return def, "built-in default", nil } @@ -682,39 +1316,84 @@ func first(s string) string { } return "" } -func resolvePrompt(root, agent string, o options) (string, string, error) { + +func userConfigPath(name string) string { + home, err := userHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".config", "start-issue", name) +} + +// resolvePrompt returns the rendered template, its display source and location, +// and (only for file-backed prompts) the resolved source file path. +func resolvePrompt(root, agent string, o options) (string, string, string, string, error) { if o.prompt != "" { - return o.prompt, "CLI --prompt", nil + return o.prompt, "CLI --prompt", "inline CLI argument", "", nil } if o.promptFile != "" { - b, e := os.ReadFile(o.promptFile) - return string(b), "CLI --prompt-file: " + o.promptFile, e + b, e := readPromptFile(o.promptFile) + location, locationErr := absolutePath(o.promptFile) + if e != nil { + return "", "", "", "", e + } + return string(b), "CLI --prompt-file: " + o.promptFile, location, location, locationErr } if os.Getenv("START_ISSUE_PROMPT_FILE") != "" && os.Getenv("START_ISSUE_PROMPT") != "" { - return "", "", errors.New("Use either START_ISSUE_PROMPT_FILE or START_ISSUE_PROMPT, not both.") + return "", "", "", "", errors.New("Use either START_ISSUE_PROMPT_FILE or START_ISSUE_PROMPT, not both.") } if path := os.Getenv("START_ISSUE_PROMPT_FILE"); path != "" { - b, e := os.ReadFile(path) - return string(b), "START_ISSUE_PROMPT_FILE: " + path, e + b, e := readPromptFile(path) + location, locationErr := absolutePath(path) + if e != nil { + return "", "", "", "", e + } + return string(b), "START_ISSUE_PROMPT_FILE: " + path, location, location, locationErr } if value := os.Getenv("START_ISSUE_PROMPT"); value != "" { - return value, "START_ISSUE_PROMPT", nil + return value, "START_ISSUE_PROMPT", "START_ISSUE_PROMPT environment variable", "", nil } - home, _ := os.UserHomeDir() - for _, path := range []string{filepath.Join(root, ".start-issue", "prompt.md"), filepath.Join(home, ".config", "start-issue", "prompt.md")} { - if b, e := os.ReadFile(path); e == nil { - return string(b), path, nil - } else if !os.IsNotExist(e) { - return "", "", e + paths := []string{} + if userConfig := userConfigPath("prompt.md"); userConfig != "" { + paths = append(paths, userConfig) + } + if root != "" { + paths = append([]string{filepath.Join(root, ".start-issue", "prompt.md")}, paths...) + } + for _, path := range paths { + if b, e := readPromptFile(path); e == nil { + return string(b), path, path, path, nil + } else if !errors.Is(e, os.ErrNotExist) { + return "", "", "", "", e } } + location, err := os.Executable() + if err != nil { + return "", "", "", "", err + } if agent == "claude" { - if o.command != "" { - return o.command + " {ISSUE_URL}", "built-in Claude command", nil - } - return "/task-router:route-task {ISSUE_URL}", "built-in Claude command", nil + return claudeDefaultPrompt(o.command), "built-in Claude command", location, "", nil } - return defaultPortablePrompt(), "built-in portable prompt", nil + return defaultPortablePrompt(), "built-in portable prompt", location, "", nil +} + +func readPromptFile(path string) ([]byte, error) { + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, promptFileNotFoundError{path: path, cause: err} + } + // Bash command substitution, used by the v1 implementation to read prompt + // files, removes every trailing newline. Keep file-backed prompt sources + // compatible so init subsequently writes exactly one trailing newline. + return bytes.TrimRight(b, "\n"), err +} + +func absolutePath(path string) (string, error) { return filepath.Abs(path) } +func claudeDefaultPrompt(command string) string { + if command != "" { + return command + " {ISSUE_URL}" + } + return "/task-router:route-task {ISSUE_URL}" } func defaultPortablePrompt() string { return `Implement GitHub issue {ISSUE_URL} in this worktree. @@ -744,7 +1423,11 @@ func detectRepo() (string, error) { if e != nil { return "", errors.New("Cannot detect repository. No 'origin' remote found. Use --repo flag.") } - v = strings.TrimSpace(strings.TrimSuffix(v, ".git")) + v = strings.TrimSuffix(strings.TrimSpace(v), ".git") + return repoFromRemoteURL(v) +} + +func repoFromRemoteURL(v string) (string, error) { for _, p := range []string{"git@github.com:", "https://github.com/"} { if strings.HasPrefix(v, p) { return strings.TrimPrefix(v, p), nil @@ -761,19 +1444,18 @@ func detectBase() string { } func branchName(n, title, labels string) string { kind := "feature" - l := strings.ToLower(labels) switch { - case containsAny(l, "hotfix", "critical", "urgent"): + case containsAny(labels, "hotfix", "critical", "urgent"): kind = "hotfix" - case containsAny(l, "bug", "fix", "bugfix", "error"): + case containsAny(labels, "bug", "fix", "bugfix", "error"): kind = "fix" - case containsAny(l, "docs", "documentation"): + case containsAny(labels, "docs", "documentation"): kind = "docs" - case containsAny(l, "refactor", "tech-debt", "cleanup", "technical"): + case containsAny(labels, "refactor", "tech-debt", "cleanup", "technical"): kind = "refactor" - case containsAny(l, "test", "testing", "tests"): + case containsAny(labels, "test", "testing", "tests"): kind = "test" - case containsAny(l, "chore", "ci", "build", "infra"): + case containsAny(labels, "chore", "ci", "build", "infra"): kind = "chore" } slug := slugify(title) @@ -792,17 +1474,23 @@ func aiBranchName(agent, model, root, number, title, labels string) (string, err if agent == "none" { return "", errors.New("no agent") } - prompt := fmt.Sprintf("Git branch name for issue #%s: %q (labels: %s). Reply with ONLY {type}/issue-%s-{kebab-case-name}.", number, title, labels, number) + prompt := fmt.Sprintf("Git branch name for issue #%s: %q (labels: %s).\nFormat: {type}/issue-%s-{kebab-case-name}\nTypes: bug/fix -> fix, enhancement -> feature, hotfix -> hotfix, docs -> docs, refactor -> refactor, test -> test, chore -> chore, default -> feature.\nIf the title contains non-English text (for example Cyrillic), transliterate it to English for the kebab-case name.\nStrip leading bracketed process/stage tags (for example [brief], [investigation], [PR-008]) from the kebab-case name; they mark workflow stage. The {type} still comes from the labels above.\nReply with ONLY the branch name.", number, title, labels, number) args := helperArgs(agent, model, root, prompt) result, err := output(args[0], args[1:]...) if err != nil { return "", err } - lines := strings.Fields(strings.Trim(strings.TrimSpace(result), "`\"")) - if len(lines) == 0 { + var branch string + for _, line := range strings.Split(result, "\n") { + line = strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(line, "`", ""), "\"", "")) + if line != "" { + branch = line + } + } + if branch == "" { return "", errors.New("empty branch") } - return lines[len(lines)-1], nil + return branch, nil } func slugify(title string) string { @@ -820,13 +1508,13 @@ func slugify(title string) string { } } slug := regexp.MustCompile(`-+`).ReplaceAllString(b.String(), "-") + if len(slug) > 40 { + slug = slug[:40] + } slug = strings.Trim(slug, "-") if slug == "" { slug = "work" } - if len(slug) > 40 { - slug = strings.Trim(slug[:40], "-") - } return slug } @@ -835,11 +1523,164 @@ var cyrillicTransliteration = map[rune]string{ } func render(s string, m map[string]string) string { - for k, v := range m { - s = strings.ReplaceAll(s, "{"+k+"}", v) + // Preserve the Bash implementation's ordered substitutions. In particular, + // placeholders in issue metadata are expanded when their replacement occurs + // later in this sequence. + for _, key := range []string{ + "ISSUE_URL", + "ISSUE_NUMBER", + "ISSUE_TITLE", + "ISSUE_BODY", + "ISSUE_LABELS", + "REPO", + "BRANCH_NAME", + "WORKTREE_PATH", + "BASE_BRANCH", + } { + if value, ok := m[key]; ok { + s = strings.ReplaceAll(s, "{"+key+"}", value) + } } return s } + +func renderIssuePrompt(prompt, issueURL, number string, in issue, labels []string, repo, branch, worktree, base string) string { + return render(prompt, map[string]string{ + "ISSUE_URL": issueURL, + "ISSUE_NUMBER": number, + "ISSUE_TITLE": in.Title, + "ISSUE_BODY": in.Body, + "ISSUE_LABELS": strings.Join(labels, ", "), + "REPO": repo, + "BRANCH_NAME": branch, + "WORKTREE_PATH": worktree, + "BASE_BRANCH": base, + }) +} + +func worktreePath(parent, branch string, flat bool) string { + name := branch + if flat { + name = strings.ReplaceAll(name, "/", "-") + } + return filepath.Join(parent, name) +} + +func branchRefExists(branch string) bool { + _, err := output("git", "show-ref", "--verify", "--quiet", "refs/heads/"+branch) + return err == nil +} + +func nextSuffixedBranch(branch string) string { + for version := 2; ; version++ { + candidate := fmt.Sprintf("%s-v%d", branch, version) + if !branchRefExists(candidate) { + return candidate + } + } +} + +func branchConflictChoice(branch, existing string, reader *bufio.Reader) string { + fmt.Printf("\nBranch '%s' already exists.\n", branch) + if existing != "" { + fmt.Printf(" Existing worktree: %s\n", existing) + } + fmt.Print("\n 1) Use existing worktree and continue\n 2) Create new branch with different name\n 3) Delete branch/worktree and recreate\n 0) Exit\n\nChoice: ") + return readChoice(reader) +} + +func pathConflictChoice(worktree, branch string, registered bool, reader *bufio.Reader) (string, error) { + fmt.Printf("\nWorktree path already exists: %s\n", worktree) + if !registered { + return "", fmt.Errorf("Cannot use worktree path '%s': it exists but is not a registered git worktree. Move or remove the directory manually, then rerun.", worktree) + } + if branch == "" { + fmt.Println(" Registered branch: detached HEAD") + } else { + fmt.Printf(" Registered branch: %s\n", strings.TrimPrefix(branch, "refs/heads/")) + } + fmt.Print("\n 1) Use existing worktree\n 2) Delete and recreate\n 0) Exit\n\nChoice: ") + return readChoice(reader), nil +} + +func readChoice(reader *bufio.Reader) string { + answer, _ := reader.ReadString('\n') + return strings.TrimSpace(answer) +} + +func validateReusedWorktree(path, branch string) error { + if !fileExists(path) { + return fmt.Errorf("Cannot reuse worktree path '%s': directory does not exist.", path) + } + registered, found := worktreeRegistration(path) + if !found { + return fmt.Errorf("Cannot reuse worktree path '%s': path exists but is not a git worktree for this repository.", path) + } + if registered == "" { + return fmt.Errorf("Cannot reuse worktree path '%s': it is a detached git worktree; expected branch '%s'.", path, branch) + } + if registered != "refs/heads/"+branch { + return fmt.Errorf("Cannot reuse worktree path '%s': it belongs to branch '%s', not '%s'.", path, strings.TrimPrefix(registered, "refs/heads/"), branch) + } + return nil +} + +func removeWorktreeAndBranch(worktree, branch string) error { + if worktree != "" { + if fileExists(worktree) { + removable, err := removableLinkedWorktree(worktree) + if err != nil { + return fmt.Errorf("could not verify that worktree path %q is removable: %w", worktree, err) + } + if !removable { + return fmt.Errorf("refusing to remove %q: it is not a removable linked worktree", worktree) + } + } + if err := command("git", "worktree", "remove", "--force", worktree); err != nil { + if fileExists(worktree) { + return fmt.Errorf("refusing to delete worktree path %q after git worktree remove failed: %w", worktree, err) + } + if err := command("git", "worktree", "prune"); err != nil { + return err + } + } + } + if branch != "" { + if err := command("git", "branch", "-D", branch); err != nil { + return err + } + } + return nil +} + +// removableLinkedWorktree reports whether path is a registered non-primary, +// non-current worktree. Git's primary and current worktrees must never be +// removed by this command. +func removableLinkedWorktree(path string) (bool, error) { + list, err := output("git", "worktree", "list", "--porcelain") + if err != nil { + return false, err + } + target := canonicalPath(path) + current, err := output("git", "rev-parse", "--show-toplevel") + if err != nil { + return false, err + } + if canonicalPath(strings.TrimSpace(current)) == target { + return false, nil + } + worktreeIndex := -1 + for _, line := range strings.Split(list, "\n") { + if !strings.HasPrefix(line, "worktree ") { + continue + } + worktreeIndex++ + if canonicalPath(strings.TrimPrefix(line, "worktree ")) == target { + return worktreeIndex > 0, nil + } + } + return false, nil +} func branchWorktree(branch string) string { value, err := output("git", "worktree", "list", "--porcelain") if err != nil { @@ -857,21 +1698,34 @@ func branchWorktree(branch string) string { return "" } func worktreeBranch(path string) string { + branch, _ := worktreeRegistration(path) + return branch +} + +// worktreeRegistration distinguishes a registered detached worktree from a +// plain directory. Detached worktrees do not have a branch record in Git's +// porcelain output, but can still be safely removed through Git. +func worktreeRegistration(path string) (string, bool) { path = canonicalPath(path) value, err := output("git", "worktree", "list", "--porcelain") if err != nil { - return "" + return "", false } current := "" + registered := false + currentMatches := false + branch := "" for _, line := range strings.Split(value, "\n") { if strings.HasPrefix(line, "worktree ") { current = strings.TrimPrefix(line, "worktree ") + currentMatches = canonicalPath(current) == path + registered = registered || currentMatches } - if canonicalPath(current) == path && strings.HasPrefix(line, "branch ") { - return strings.TrimPrefix(line, "branch ") + if currentMatches && strings.HasPrefix(line, "branch ") { + branch = strings.TrimPrefix(line, "branch ") } } - return "" + return branch, registered } func canonicalPath(path string) string { absolute, err := filepath.Abs(path) @@ -884,27 +1738,27 @@ func canonicalPath(path string) string { return absolute } func launchSelected(o options, agent, model, worktree, prompt string) error { + if o.dryRun { + if o.humanGate { + return humanGate(model, worktree, prompt, true) + } + if agent == "none" { + printManualNextSteps(model, worktree) + return nil + } + printLaunch(agent, model, worktree, prompt) + return nil + } if o.humanGate { - return humanGate(model, worktree, prompt, o.dryRun) + return humanGate(model, worktree, prompt, false) } return launch(agent, model, worktree, prompt) } -func improvePrompt(root, agent, model, prompt, source string, o options, in issue, repo, number, labels string) error { +func improvePrompt(root, agent, model, prompt, source, promptFile string, o options, in issue, repo, number, labels string) error { if agent == "none" { return errors.New("--improve-prompt requires an agent. Use --agent claude, codex, kimi, or pi.") } - outputPath := o.promptOutput - if outputPath == "" { - if o.promptFile != "" { - outputPath = strings.TrimSuffix(o.promptFile, filepath.Ext(o.promptFile)) + ".improved.md" - } else if fileSource := strings.TrimPrefix(source, "START_ISSUE_PROMPT_FILE: "); fileExists(fileSource) { - outputPath = strings.TrimSuffix(fileSource, filepath.Ext(fileSource)) + ".improved.md" - } else if fileExists(source) { - outputPath = strings.TrimSuffix(source, filepath.Ext(source)) + ".improved.md" - } else { - outputPath = filepath.Join(root, ".start-issue", "prompt.improved.md") - } - } + outputPath := promptImprovementOutputPath(root, promptFile, o) if o.dryRun { fmt.Printf("📝 Improving prompt template...\n Prompt source: %s\n Proposal path: %s\n [DRY-RUN] Would ask %s to generate an improved prompt proposal.\n", source, outputPath, agent) return nil @@ -912,21 +1766,51 @@ func improvePrompt(root, agent, model, prompt, source string, o options, in issu if _, err := os.Stat(outputPath); err == nil { return fmt.Errorf("Prompt improvement output already exists: %s", outputPath) } - request := fmt.Sprintf("Improve the following start-issue prompt template. Return ONLY the complete improved prompt template.\n\nRepository: %s\nIssue #%s: %s\nLabels: %s\nBody:\n%s\n\nPrompt:\n%s", repo, number, in.Title, labels, in.Body, prompt) + request := promptImprovementRequest(prompt, source, in, repo, number, labels) args := helperArgs(agent, model, root, request) result, err := output(args[0], args[1:]...) if err != nil { return fmt.Errorf("Could not generate improved prompt with %s", agent) } + proposal := normalizePromptProposal(result) + if proposal == "" { + return errors.New("Improved prompt proposal is empty.") + } if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { return err } - if err := os.WriteFile(outputPath, []byte(strings.TrimSpace(result)+"\n"), 0644); err != nil { + if err := os.WriteFile(outputPath, []byte(proposal+"\n"), 0644); err != nil { return err } fmt.Printf("📝 Prompt improvement written: %s\n", outputPath) return nil } + +func promptImprovementOutputPath(root, promptFile string, o options) string { + if o.promptOutput != "" { + return o.promptOutput + } + if promptFile == "" { + return filepath.Join(root, ".start-issue", "prompt.improved.md") + } + if filepath.Ext(promptFile) == ".md" { + return strings.TrimSuffix(promptFile, ".md") + ".improved.md" + } + return promptFile + ".improved" +} + +func promptImprovementRequest(prompt, source string, in issue, repo, number, labels string) string { + issueURL := fmt.Sprintf("https://github.com/%s/issues/%s", repo, number) + return fmt.Sprintf("Improve the following start-issue prompt template.\n\nReturn ONLY the complete improved prompt template. Do not include commentary, code fences, diffs, or explanations.\n\nPreserve any placeholders that are still useful. Supported placeholders:\n{ISSUE_URL}, {ISSUE_NUMBER}, {ISSUE_TITLE}, {ISSUE_BODY}, {ISSUE_LABELS}, {REPO}, {BRANCH_NAME}, {WORKTREE_PATH}, {BASE_BRANCH}\n\nPrompt source:\n%s\n\nRepository:\n%s\n\nCurrent issue used as improvement context:\n- URL: %s\n- Number: %s\n- Title: %s\n- Labels: %s\n- Body:\n%s\n\nCurrent prompt template:\n--- START PROMPT TEMPLATE ---\n%s\n--- END PROMPT TEMPLATE ---", source, repo, issueURL, number, in.Title, labels, in.Body, prompt) +} + +func normalizePromptProposal(result string) string { + lines := strings.Split(strings.TrimSpace(result), "\n") + if len(lines) >= 2 && regexp.MustCompile("^```[[:alnum:]_-]*\\s*$").MatchString(strings.TrimSpace(lines[0])) && strings.TrimSpace(lines[len(lines)-1]) == "```" { + lines = lines[1 : len(lines)-1] + } + return strings.TrimSpace(strings.Join(lines, "\n")) +} func humanGate(model, worktree, prompt string, dryRun bool) error { runID := os.Getenv("START_ISSUE_RUN_ID") if runID == "" { @@ -939,7 +1823,9 @@ func humanGate(model, worktree, prompt string, dryRun bool) error { args = append([]string{"exec", "--model", model}, args[1:]...) } if dryRun { - fmt.Printf(" [DRY-RUN] Would run: codex %s\n", strings.Join(args, " ")) + threadID := filepath.Join(dir, "thread-id") + fmt.Printf(" [DRY-RUN] Would run: codex %s > %s\n", shellJoin(args), shellQuote(events)) + fmt.Printf(" [DRY-RUN] Would write captured thread ID: %s\n", threadID) return nil } if err := os.MkdirAll(dir, 0755); err != nil { @@ -951,68 +1837,139 @@ func humanGate(model, worktree, prompt string, dryRun bool) error { } defer file.Close() cmd := exec.Command("codex", args...) - cmd.Dir = worktree cmd.Stdin = strings.NewReader(prompt) cmd.Stdout = file cmd.Stderr = os.Stderr _ = cmd.Run() + threadID, err := captureThreadID(events) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, "thread-id"), []byte(threadID+"\n"), 0644); err != nil { + return err + } body, err := os.ReadFile(last) if err != nil { return fmt.Errorf("No recognized final status found. Inspect: %s", last) } - status := "" - for _, line := range strings.Split(string(body), "\n") { - if strings.HasPrefix(line, "STATUS:") { - status = strings.TrimSpace(strings.TrimPrefix(line, "STATUS:")) - break - } - } + status := finalStatus(string(body)) if status == "DONE" { fmt.Println("✅ Codex finished with STATUS: DONE") return nil } if status == "HUMAN_GATE" { - eventsBody, err := os.ReadFile(events) - if err != nil { - return err + resume := []string{"resume", "--include-non-interactive", threadID} + fmt.Printf("Resume command: codex %s\nThread ID: %s\n", strings.Join(resume, " "), threadID) + if err := command("codex", resume...); err != nil { + fmt.Fprintln(os.Stderr, "Could not open Codex resume session.") + return exitError{code: 2, err: errors.New("Could not open Codex resume session.")} } - threadID := "" - for _, line := range strings.Split(string(eventsBody), "\n") { - var event struct { - Type string `json:"type"` - ThreadID string `json:"thread_id"` - } - if json.Unmarshal([]byte(line), &event) == nil && event.Type == "thread.started" { - threadID = event.ThreadID - break - } + return nil + } + return fmt.Errorf("No recognized final status found. Inspect: %s", last) +} + +func captureThreadID(events string) (string, error) { + eventsBody, err := os.ReadFile(events) + if err != nil { + return "", err + } + for _, line := range strings.Split(string(eventsBody), "\n") { + var event struct { + Type string `json:"type"` + ThreadID string `json:"thread_id"` } - if threadID == "" { - return fmt.Errorf("Codex human-gate run did not capture thread_id. Inspect: %s", events) + if json.Unmarshal([]byte(line), &event) == nil && event.Type == "thread.started" && event.ThreadID != "" { + return event.ThreadID, nil } - if err := os.WriteFile(filepath.Join(dir, "thread-id"), []byte(threadID+"\n"), 0644); err != nil { - return err + } + return "", fmt.Errorf("Codex human-gate run did not capture thread_id. Inspect: %s", events) +} + +func finalStatus(body string) string { + for _, line := range strings.Split(body, "\n") { + if strings.HasPrefix(line, "STATUS:") { + return strings.TrimSpace(strings.TrimPrefix(line, "STATUS:")) } - return command("codex", "resume", "--include-non-interactive", threadID) } - return fmt.Errorf("No recognized final status found. Inspect: %s", last) + return "" } func printLaunch(a, m, w, p string) { if a == "none" { fmt.Printf(" Agent: none\n Model: %s\n [DRY-RUN] Would prepare worktree without launching an agent\n", show(m)) return } - if len(p) > 4000 && os.Getenv("START_ISSUE_DUMP_PROMPT") != "1" { - p = "" + promptLength := utf8.RuneCountInString(p) + fmt.Printf(" Prompt length: %d chars\n", promptLength) + if promptLength > 4000 && os.Getenv("START_ISSUE_DUMP_PROMPT") != "1" { + fmt.Println(" Prompt omitted from command display because it is large.") + fmt.Println(" Set START_ISSUE_DUMP_PROMPT=1 to print the full rendered prompt.") + p = fmt.Sprintf("", promptLength) } - fmt.Printf(" Agent: %s\n Model: %s\n [DRY-RUN] Would run: %s\n", a, show(m), strings.Join(launchArgs(a, m, w, p), " ")) + fmt.Printf(" Agent: %s\n Model: %s\n [DRY-RUN] Would run: %s\n", a, show(m), launchDisplayCommand(a, m, w, p)) +} + +func launchDisplayCommand(a, m, w, p string) string { + command := shellJoin(launchArgs(a, m, w, p)) + if a == "claude" || a == "pi" { + return "cd " + shellQuote(w) + " && " + command + } + return command } func launch(a, m, w, p string) error { if a == "none" { - fmt.Printf("✅ Worktree ready at: %s\n", w) + printManualNextSteps(m, w) return nil } - return commandAt(w, launchArgs(a, m, w, p)...) + args := launchArgs(a, m, w, p) + var err error + if a == "claude" || a == "pi" { + err = commandAt(w, args...) + } else { + err = command(args[0], args[1:]...) + } + if err != nil { + var exited *exec.ExitError + if errors.As(err, &exited) { + return exitError{code: processExitCode(exited), err: err} + } + return err + } + return nil +} + +func processExitCode(exited *exec.ExitError) int { + if status, ok := exited.Sys().(syscall.WaitStatus); ok && status.Signaled() { + return 128 + int(status.Signal()) + } + return exited.ExitCode() +} + +func printManualNextSteps(model, worktree string) { + fmt.Printf("✅ Worktree ready at: %s\n\n", worktree) + fmt.Printf("Selected agent: none\nResolved model: %s\n", show(model)) + fmt.Println("To start working:") + fmt.Printf(" cd %s\n\n", shellQuote(worktree)) + fmt.Println("Suggested agent commands:") + fmt.Println(" claude") + fmt.Printf(" codex --cd %s\n", shellQuote(worktree)) + fmt.Printf(" kimi --work-dir %s\n", shellQuote(worktree)) + fmt.Println(" pi") +} + +func shellQuote(value string) string { + if value != "" && !strings.ContainsAny(value, " \t\r\n'\"\\$`!()[]{}*?;<>&|#~") { + return value + } + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + +func shellJoin(values []string) string { + quoted := make([]string, len(values)) + for i, value := range values { + quoted[i] = shellQuote(value) + } + return strings.Join(quoted, " ") } func launchArgs(a, m, w, p string) []string { switch a { @@ -1106,37 +2063,185 @@ func need(n string) error { } return nil } + +func checkGitHubAccess() error { + if _, err := exec.LookPath("gh"); err != nil { + return errors.New("gh CLI not found. Install: https://cli.github.com") + } + return checkGHAuth() +} + +func checkGHAuth() error { + if err := exec.Command("gh", "auth", "status").Run(); err != nil { + return errors.New("gh not authenticated. Run: gh auth login") + } + return nil +} func show(v string) string { if v == "" { return "" } return v } -func die(e error) { fmt.Fprintln(os.Stderr, "Error:", e); os.Exit(1) } -func confirm(message string) bool { - fmt.Print(message) - answer, _ := bufio.NewReader(os.Stdin).ReadString('\n') - return strings.TrimSpace(strings.ToLower(answer)) == "y" || strings.TrimSpace(strings.ToLower(answer)) == "yes" +func die(e error) { + code := 1 + var exit exitError + if errors.As(e, &exit) { + code = exit.code + } + fmt.Fprintln(os.Stderr, "Error:", e) + os.Exit(code) } func usage() { fmt.Printf(`start-issue v%s +Start working on a GitHub issue with git worktree and a configurable agent + Usage: start-issue [options] start-issue init [--project|--user] [--force] [options] - start-issue setup | update | install + start-issue setup | --setup + start-issue update | --update + start-issue install | --install + +Arguments: + GitHub issue URL or issue number + Examples: 123, https://github.com/owner/repo/issues/123 + init Create default start-issue configuration + setup Run first-run user configuration onboarding + update Update the running start-issue installation + install Install the latest release into ~/.local/bin Options: - --repo, -r --base, -b --worktree-dir, -w - --agent --model - --prompt-file --prompt --improve-prompt --prompt-output-file - --no-agent --no-init --flat --ai --human-gate --dry-run - --project --user --force --version, -v --help, -h + --repo, -r Repository (default: detected from git remote) + --base, -b Base branch (default: main or master) + --worktree-dir, -w Directory for worktrees + Default: START_ISSUE_WORKTREE_DIR or ~/worktrees + --flat Use flat worktree structure (replace / with - in path) + --agent Agent to launch: claude, codex, kimi, pi, none + With init: default agent to write + --model Model to use for the selected agent + With init: default model config to write + --no-agent Only create worktree, do not start an agent session + --no-claude Compatibility alias for --no-agent + --prompt Prompt template for the launched agent + --prompt-file Prompt template file for the launched agent + --improve-prompt Ask the selected agent to improve the selected + prompt template and write a reviewable proposal + --human-gate Codex-only batch mode that resumes on HUMAN_GATE + --human-gate-help Show detailed help for the human-gate mode + --prompt-output-file + Output path for --improve-prompt proposal + --no-init Skip init.sh execution + --command, -c Compatibility: initial command for Claude default launch + --ai Use the selected agent for branch name generation + Default: fast branch-name heuristics + --project With init: write .start-issue config in this repo + --user With init: write config in ~/.config/start-issue + --force With init: overwrite existing config files + --dry-run Show what would be done without executing + --setup Run first-run user configuration onboarding + --update Update the running start-issue installation + --install Install the latest release into ~/.local/bin + --version, -v Show version + --help, -h Show this help + +Agent selection precedence: + CLI --agent / --no-agent + .start-issue/agent in the git root + ~/.config/start-issue/agent + START_ISSUE_AGENT + built-in default: claude + +Model selection precedence: + CLI --model + .start-issue/model in the git root + ~/.config/start-issue/model + START_ISSUE_MODEL + built-in default: unset (agent CLI decides) + +Prompt template precedence: + CLI --prompt-file / --prompt + START_ISSUE_PROMPT_FILE / START_ISSUE_PROMPT + .start-issue/prompt.md in the git root + ~/.config/start-issue/prompt.md + built-in default + +Prompt improvement: + --improve-prompt uses the selected agent to generate a complete improved + prompt template proposal. It does not overwrite the active prompt template. + Markdown prompt files write next to the source as *.improved.md; other + file names append .improved. Built-in and inline prompts write to + .start-issue/prompt.improved.md by default. Use --prompt-output-file to + choose another proposal path. + +Prompt variables: + {ISSUE_URL}, {ISSUE_NUMBER}, {ISSUE_TITLE}, {ISSUE_BODY}, {ISSUE_LABELS}, + {REPO}, {BRANCH_NAME}, {WORKTREE_PATH}, {BASE_BRANCH} -Environment: START_ISSUE_AGENT, START_ISSUE_MODEL, START_ISSUE_PROMPT, -START_ISSUE_PROMPT_FILE, START_ISSUE_WORKTREE_DIR, START_ISSUE_DUMP_PROMPT -`, version) +Environment variables: + START_ISSUE_AGENT + START_ISSUE_MODEL + START_ISSUE_PROMPT + START_ISSUE_PROMPT_FILE + START_ISSUE_WORKTREE_DIR + START_ISSUE_DUMP_PROMPT + +Examples: + start-issue 123 + start-issue https://github.com/owner/repo/issues/123 + start-issue 123 --repo owner/repo --base develop + start-issue 123 --agent codex + start-issue 123 --agent codex --model gpt-5.2 + start-issue 123 --agent codex --human-gate + start-issue 123 --agent claude --model sonnet + start-issue 123 --agent kimi --prompt-file .start-issue/prompt.md + start-issue 123 --no-agent # Only create worktree + start-issue 123 --command "/debug" # Claude command prefix + start-issue 123 --flat # Flat worktree path + start-issue 123 --dry-run + start-issue init + start-issue setup + start-issue init --project --agent codex --model gpt-5.2 + start-issue init --project --agent codex + start-issue init --user --force + start-issue --setup + start-issue update + start-issue --update + start-issue install + start-issue --install + start-issue --human-gate-help +`, runningVersion()) +} + +func printBanner() { + fmt.Printf("start-issue v%s\n\n", runningVersion()) } func humanGateHelp() { - fmt.Println("Codex human-gate mode\n\nUsage: start-issue --agent codex --human-gate\n\nThe final Codex message must start with STATUS: DONE or STATUS: HUMAN_GATE.") + printBanner() + fmt.Println(`Codex human-gate mode + +Usage: + start-issue --agent codex --human-gate + start-issue --human-gate-help + +Flow: + The normal issue workflow creates or reuses the worktree, renders the + prompt, and runs Codex in batch mode. The final message must contain one + terminal status line: STATUS: DONE or STATUS: HUMAN_GATE. + +Exit codes: + 0 Codex returned STATUS: DONE. + 1 Codex failed, no thread_id was captured, no recognized status was found, + or parsing failed. + 2 Codex returned STATUS: HUMAN_GATE but automatic interactive resume failed. + +State artifacts: + /.start-issue/runs//events.jsonl + /.start-issue/runs//last-message.txt + /.start-issue/runs//thread-id + +Recovery: + If automatic resume fails, run: + codex resume --include-non-interactive `) } diff --git a/cmd/start-issue/main_test.go b/cmd/start-issue/main_test.go index a6938bc..f360e54 100644 --- a/cmd/start-issue/main_test.go +++ b/cmd/start-issue/main_test.go @@ -1,10 +1,22 @@ package main import ( + "bufio" "crypto/sha256" + "encoding/json" + "errors" "fmt" + "io" + "io/fs" + "net" + "net/http" + "net/http/httptest" "os" + "os/exec" "path/filepath" + "runtime" + "runtime/debug" + "strings" "testing" ) @@ -15,6 +27,171 @@ func TestParseIssue(t *testing.T) { } } +func TestParseTracksWorktreeDirectorySource(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("START_ISSUE_WORKTREE_DIR", "") + + o, err := parse(nil) + if err != nil { + t.Fatal(err) + } + if got, want := o.worktreeDirSource, "built-in default"; got != want { + t.Fatalf("default worktree directory source = %q, want %q", got, want) + } + + t.Setenv("START_ISSUE_WORKTREE_DIR", "/env-worktrees") + o, err = parse(nil) + if err != nil { + t.Fatal(err) + } + if got, want := o.worktreeDirSource, "START_ISSUE_WORKTREE_DIR"; got != want { + t.Fatalf("environment worktree directory source = %q, want %q", got, want) + } + + o, err = parse([]string{"--worktree-dir", "/cli-worktrees"}) + if err != nil { + t.Fatal(err) + } + if got, want := o.worktreeDirSource, "CLI"; got != want { + t.Fatalf("CLI worktree directory source = %q, want %q", got, want) + } +} + +func TestUserHomeDirRejectsUnavailableOrRelativeHome(t *testing.T) { + t.Setenv("HOME", "") + if runtime.GOOS != "windows" { + if _, err := userHomeDir(); err == nil { + t.Fatal("empty HOME returned a usable home directory") + } + } + + t.Setenv("HOME", "relative-home") + if runtime.GOOS != "windows" { + if _, err := userHomeDir(); err == nil { + t.Fatal("relative HOME returned a usable home directory") + } + } +} + +func TestParseRequiresHomeOnlyForDefaultWorktreeDirectory(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows derives its home directory from USERPROFILE") + } + t.Setenv("HOME", "") + t.Setenv("START_ISSUE_WORKTREE_DIR", "") + + if _, err := parse([]string{"1"}); err == nil || !strings.Contains(err.Error(), "set --worktree-dir or START_ISSUE_WORKTREE_DIR") { + t.Fatalf("default worktree directory error = %v", err) + } + if o, err := parse([]string{"1", "--worktree-dir", t.TempDir()}); err != nil || o.worktreeDirSource != "CLI" { + t.Fatalf("explicit worktree directory = %#v, %v", o, err) + } +} + +func TestResolversDoNotReadRelativeUserConfigWhenHomeIsUnavailable(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows derives its home directory from USERPROFILE") + } + wd := t.TempDir() + previous, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(previous) }() + if err := os.Chdir(wd); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(".config", "start-issue"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(".config", "start-issue", "agent"), []byte("codex\n"), 0644); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", "") + + agent, source, err := resolveAgent("", "") + if err != nil || agent != "claude" || source != "built-in default" { + t.Fatalf("resolveAgent = %q, %q, %v", agent, source, err) + } +} + +func TestResolvePromptReportsMissingPromptFile(t *testing.T) { + missing := filepath.Join(t.TempDir(), "missing-prompt.md") + for _, test := range []struct { + name string + o options + env bool + }{ + {name: "CLI", o: options{promptFile: missing}}, + {name: "environment", env: true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Setenv("START_ISSUE_PROMPT", "") + if test.env { + t.Setenv("START_ISSUE_PROMPT_FILE", missing) + } else { + t.Setenv("START_ISSUE_PROMPT_FILE", "") + } + + _, _, _, _, err := resolvePrompt(t.TempDir(), "none", test.o) + if err == nil || err.Error() != "Prompt file not found: "+missing { + t.Fatalf("resolvePrompt error = %v, want public missing-file error", err) + } + if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("resolvePrompt error = %v, want errors.Is(err, fs.ErrNotExist)", err) + } + }) + } +} + +func TestResolvePromptStripsTrailingNewlinesFromPromptFiles(t *testing.T) { + root, home, promptFile := t.TempDir(), t.TempDir(), filepath.Join(t.TempDir(), "prompt.md") + const content = "Prompt {ISSUE_URL}\n\n" + if err := os.WriteFile(promptFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, ".start-issue"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".start-issue", "prompt.md"), []byte(content), 0644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(home, ".config", "start-issue"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".config", "start-issue", "prompt.md"), []byte(content), 0644); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + + for _, test := range []struct { + name string + root string + o options + env bool + }{ + {name: "CLI", root: t.TempDir(), o: options{promptFile: promptFile}}, + {name: "environment", root: t.TempDir(), env: true}, + {name: "project config", root: root}, + {name: "user config", root: t.TempDir()}, + } { + t.Run(test.name, func(t *testing.T) { + t.Setenv("START_ISSUE_PROMPT", "") + if test.env { + t.Setenv("START_ISSUE_PROMPT_FILE", promptFile) + } else { + t.Setenv("START_ISSUE_PROMPT_FILE", "") + } + + got, _, _, _, err := resolvePrompt(test.root, "codex", test.o) + if err != nil || got != "Prompt {ISSUE_URL}" { + t.Fatalf("resolvePrompt() = %q, %v; want prompt without trailing newlines", got, err) + } + }) + } +} + func TestBranchName(t *testing.T) { if got := branchName("34", "Fix broken output!", "bug"); got != "fix/issue-34-fix-broken-output" { t.Fatalf("got %q", got) @@ -27,6 +204,7 @@ func TestBranchNameMatchesFastShellRules(t *testing.T) { }{ {"[brief] Исправить ЦАП", "urgent, bug", "hotfix/issue-34-ispravit-tsap"}, {"Documentation tidy", "documentation", "docs/issue-34-documentation-tidy"}, + {"Fix broken output", "Bug", "feature/issue-34-fix-broken-output"}, {"", "", "feature/issue-34-work"}, } for _, test := range tests { @@ -36,18 +214,340 @@ func TestBranchNameMatchesFastShellRules(t *testing.T) { } } +func TestSlugifyTruncatesBeforeTrimmingSeparators(t *testing.T) { + title := "😀" + strings.Repeat("a", 39) + " trailing" + if got, want := slugify(title), strings.Repeat("a", 39); got != want { + t.Fatalf("slugify(%q) = %q, want %q", title, got, want) + } +} + func TestRender(t *testing.T) { if got := render("{REPO} #{ISSUE_NUMBER}", map[string]string{"REPO": "dapi/start-issue", "ISSUE_NUMBER": "34"}); got != "dapi/start-issue #34" { t.Fatalf("got %q", got) } } +func TestRenderPreservesOrderedExpansionOfIssueData(t *testing.T) { + in := issue{Title: "Keep {REPO}", Body: "and {ISSUE_NUMBER}"} + got := renderIssuePrompt("{ISSUE_URL}; {ISSUE_NUMBER}; {ISSUE_TITLE}; {ISSUE_BODY}; {ISSUE_LABELS}; {REPO}; {BRANCH_NAME}; {WORKTREE_PATH}; {BASE_BRANCH}", "https://github.com/dapi/start-issue/issues/34", "34", in, []string{"bug"}, "dapi/start-issue", "feature/issue-34", "/tmp/reused", "main") + want := "https://github.com/dapi/start-issue/issues/34; 34; Keep dapi/start-issue; and {ISSUE_NUMBER}; bug; dapi/start-issue; feature/issue-34; /tmp/reused; main" + if got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestIssueUnmarshalNormalizesNullBody(t *testing.T) { + var in issue + if err := json.Unmarshal([]byte(`{"title":"No description","body":null,"labels":[{"name":"bug"}]}`), &in); err != nil { + t.Fatal(err) + } + if in.Body != "" { + t.Fatalf("Body = %q, want empty string", in.Body) + } + if got := renderIssuePrompt("{ISSUE_BODY}", "", "", in, nil, "", "", "", ""); got != "" { + t.Fatalf("rendered null body = %q, want empty string", got) + } +} + +func TestDetectRepoTrimsNewlineBeforeGitSuffix(t *testing.T) { + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "git"), "#!/bin/sh\nprintf '%s\\n' 'https://github.com/dapi/start-issue.git'\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + got, err := detectRepo() + if err != nil || got != "dapi/start-issue" { + t.Fatalf("got %q, %v", got, err) + } +} + +func TestDryRunLaunchDoesNotExecuteAgent(t *testing.T) { + bin := t.TempDir() + marker := filepath.Join(t.TempDir(), "codex-ran") + writeExecutable(t, filepath.Join(bin, "codex"), "#!/bin/sh\ntouch '"+marker+"'\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + if err := launchSelected(options{dryRun: true}, "codex", "", t.TempDir(), "prompt"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("dry-run executed codex: %v", err) + } +} + +func TestDryRunDeleteAndRecreatePreservesExistingBranchAndWorktree(t *testing.T) { + home, bin, root, worktrees := t.TempDir(), t.TempDir(), t.TempDir(), t.TempDir() + worktree := filepath.Join(worktrees, "feature", "issue-1-add-login-button") + worktreeMarker := filepath.Join(worktree, "uncommitted-work") + branchMarker := filepath.Join(t.TempDir(), "branch-exists") + gitLog := filepath.Join(t.TempDir(), "git-mutations.log") + if err := os.MkdirAll(filepath.Join(home, ".config", "start-issue"), 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(worktree, 0755); err != nil { + t.Fatal(err) + } + for _, marker := range []string{worktreeMarker, branchMarker} { + if err := os.WriteFile(marker, []byte("must remain"), 0644); err != nil { + t.Fatal(err) + } + } + writeExecutable(t, filepath.Join(bin, "git"), fmt.Sprintf(`#!/bin/sh +case "$1 $2 $3" in + "rev-parse --show-toplevel ") printf '%%s\n' %q ;; + "remote get-url origin") printf '%%s\n' 'git@github.com:owner/repo.git' ;; + "show-ref --verify --quiet") [ "$4" = "refs/heads/feature/issue-1-add-login-button" ] && exit 0; exit 1 ;; + "worktree list --porcelain") printf 'worktree %%s\nbranch refs/heads/master\n\nworktree %%s\nbranch refs/heads/feature/issue-1-add-login-button\n' %q %q ;; + "worktree remove --force") printf '%%s\n' "$*" >> "$START_ISSUE_GIT_LOG"; rm -rf "$4" ;; +esac +if [ "$1 $2" = "branch -D" ]; then + printf '%%s\n' "$*" >> "$START_ISSUE_GIT_LOG" + rm -f "$START_ISSUE_BRANCH_MARKER" +fi +`, root, root, worktree)) + writeExecutable(t, filepath.Join(bin, "gh"), `#!/bin/sh +if [ "$1 $2" = "auth status" ]; then exit 0; fi +if [ "$1" = api ]; then + printf '%s\n' '{"title":"Add login button","body":"","labels":[]}' +fi +`) + t.Setenv("HOME", home) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_GIT_LOG", gitLog) + t.Setenv("START_ISSUE_BRANCH_MARKER", branchMarker) + + output := captureStdout(t, func() { + err := runWithReader(options{ + issue: "1", + repo: "owner/repo", + base: "master", + worktreeDir: worktrees, + agent: "none", + dryRun: true, + noInit: true, + }, bufio.NewReader(strings.NewReader("3\n"))) + if err != nil { + t.Fatal(err) + } + }) + for _, marker := range []string{worktreeMarker, branchMarker} { + if _, err := os.Stat(marker); err != nil { + t.Fatalf("dry-run removed %s: %v", marker, err) + } + } + if mutations, err := os.ReadFile(gitLog); err == nil && len(mutations) > 0 { + t.Fatalf("dry-run ran destructive git commands:\n%s", mutations) + } + for _, want := range []string{ + "[DRY-RUN] Would remove worktree: " + worktree, + "[DRY-RUN] Would delete branch: feature/issue-1-add-login-button", + "[DRY-RUN] Would run: git worktree add -b feature/issue-1-add-login-button", + } { + if !strings.Contains(output, want) { + t.Fatalf("dry-run output missing %q:\n%s", want, output) + } + } +} + +func TestSuffixWorktreeUsesSuffixedBranch(t *testing.T) { + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "git"), `#!/bin/sh +case "$4" in + refs/heads/feature/issue-34-original|refs/heads/feature/issue-34-original-v2) exit 0 ;; + *) exit 1 ;; +esac +`) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + branch := nextSuffixedBranch("feature/issue-34-original") + if branch != "feature/issue-34-original-v3" { + t.Fatalf("got branch %q", branch) + } + if got, want := worktreePath("/worktrees", branch, false), "/worktrees/feature/issue-34-original-v3"; got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestRemoveWorktreeAndBranchRemovesStaleRegistration(t *testing.T) { + bin, log := t.TempDir(), filepath.Join(t.TempDir(), "git.log") + writeExecutable(t, filepath.Join(bin, "git"), `#!/bin/sh +printf '%s\n' "$*" >> "$START_ISSUE_GIT_LOG" +if [ "$1 $2" = "worktree remove" ] && [ "$START_ISSUE_REMOVE_WORKTREE_FAIL" = 1 ]; then + exit 1 +fi +`) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_GIT_LOG", log) + + stale := filepath.Join(t.TempDir(), "externally-deleted-worktree") + if err := removeWorktreeAndBranch(stale, "feature/issue-34-stale"); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "worktree remove --force " + stale, + "branch -D feature/issue-34-stale", + } { + if !strings.Contains(string(got), want) { + t.Fatalf("git calls missing %q:\n%s", want, got) + } + } +} + +func TestRemoveWorktreeAndBranchPrunesAfterFailedStaleRemoval(t *testing.T) { + bin, log := t.TempDir(), filepath.Join(t.TempDir(), "git.log") + writeExecutable(t, filepath.Join(bin, "git"), `#!/bin/sh +printf '%s\n' "$*" >> "$START_ISSUE_GIT_LOG" +if [ "$1 $2" = "worktree remove" ]; then + exit 1 +fi +`) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_GIT_LOG", log) + + stale := filepath.Join(t.TempDir(), "externally-deleted-worktree") + if err := removeWorktreeAndBranch(stale, "feature/issue-34-stale"); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), "worktree prune") { + t.Fatalf("failed stale removal did not prune registration:\n%s", got) + } +} + +func TestRemoveWorktreeAndBranchRefusesPrimaryWorktree(t *testing.T) { + bin, log, primary := t.TempDir(), filepath.Join(t.TempDir(), "git.log"), t.TempDir() + marker := filepath.Join(primary, "keep") + if err := os.WriteFile(marker, []byte("must not be deleted"), 0644); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(bin, "git"), fmt.Sprintf(`#!/bin/sh +printf '%%s\n' "$*" >> "$START_ISSUE_GIT_LOG" +case "$1 $2 $3" in + "worktree list --porcelain") printf 'worktree %s\nbranch refs/heads/main\n' %q ;; + "rev-parse --show-toplevel") printf '%%s\n' %q ;; +esac + `, primary, primary, primary)) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_GIT_LOG", log) + + err := removeWorktreeAndBranch(primary, "main") + if err == nil || !strings.Contains(err.Error(), "not a removable linked worktree") { + t.Fatalf("remove primary worktree error = %v", err) + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("primary worktree content was deleted: %v", err) + } + got, _ := os.ReadFile(log) + if strings.Contains(string(got), "worktree remove") || strings.Contains(string(got), "branch -D") { + t.Fatalf("primary worktree removal invoked git mutation:\n%s", got) + } +} + +func TestRemoveWorktreeAndBranchNeverFallsBackToRawDeletion(t *testing.T) { + bin, log, primary, linked := t.TempDir(), filepath.Join(t.TempDir(), "git.log"), t.TempDir(), t.TempDir() + marker := filepath.Join(linked, "keep") + if err := os.WriteFile(marker, []byte("must not be deleted"), 0644); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(bin, "git"), fmt.Sprintf(`#!/bin/sh +printf '%%s\n' "$*" >> "$START_ISSUE_GIT_LOG" +case "$1 $2 $3" in + "worktree list --porcelain") printf 'worktree %s\nbranch refs/heads/main\nworktree %s\nbranch refs/heads/feature/issue-34\n' %q %q ;; + "rev-parse --show-toplevel") printf '%%s\n' %q ;; +esac +if [ "$1 $2" = "worktree remove" ]; then exit 1; fi +`, primary, linked, primary, linked, primary)) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_GIT_LOG", log) + + err := removeWorktreeAndBranch(linked, "feature/issue-34") + if err == nil || !strings.Contains(err.Error(), "refusing to delete worktree path") { + t.Fatalf("failed linked worktree removal error = %v", err) + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("linked worktree content was deleted: %v", err) + } + got, _ := os.ReadFile(log) + if strings.Contains(string(got), "worktree prune") || strings.Contains(string(got), "branch -D") { + t.Fatalf("failed removal mutated stale registration or branch:\n%s", got) + } +} + func TestReleaseAssetName(t *testing.T) { - if got := releaseAssetName("darwin", "arm64"); got != "start-issue-darwin-arm64" { - t.Fatalf("got %q", got) + for _, test := range []struct { + goos, goarch, want string + }{ + {"linux", "amd64", "start-issue-linux-amd64"}, + {"linux", "arm64", "start-issue-linux-arm64"}, + {"darwin", "amd64", "start-issue-darwin-amd64"}, + {"darwin", "arm64", "start-issue-darwin-arm64"}, + {"windows", "amd64", "start-issue-windows-amd64.exe"}, + } { + got, err := releaseAssetName(test.goos, test.goarch) + if err != nil || got != test.want { + t.Fatalf("releaseAssetName(%s, %s) = %q, %v; want %q", test.goos, test.goarch, got, err, test.want) + } } - if got := releaseAssetName("windows", "amd64"); got != "start-issue-windows-amd64.exe" { - t.Fatalf("got %q", got) + for _, test := range []struct{ goos, goarch string }{{"linux", "386"}, {"freebsd", "amd64"}, {"windows", "arm64"}} { + if got, err := releaseAssetName(test.goos, test.goarch); err == nil || got != "" || !strings.Contains(err.Error(), "unsupported release platform") { + t.Fatalf("releaseAssetName(%s, %s) = %q, %v; want unsupported platform error", test.goos, test.goarch, got, err) + } + } +} + +func TestPathConflictChoiceRejectsUnregisteredDirectoryWithoutDeleteOption(t *testing.T) { + path := filepath.Join(t.TempDir(), "ordinary-directory") + output := captureStdout(t, func() { + _, _ = pathConflictChoice(path, "", false, bufio.NewReader(strings.NewReader("2\n"))) + }) + _, err := pathConflictChoice(path, "", false, bufio.NewReader(strings.NewReader("2\n"))) + if err == nil || !strings.Contains(err.Error(), "Move or remove the directory manually") { + t.Fatalf("unregistered path conflict error = %v", err) + } + if strings.Contains(output, "Delete and recreate") || strings.Contains(output, "Choice:") { + t.Fatalf("unregistered path conflict offered deletion:\n%s", output) + } +} + +func TestDetachedWorktreeIsRegisteredAndRemovable(t *testing.T) { + bin, log, primary, detached := t.TempDir(), filepath.Join(t.TempDir(), "git.log"), t.TempDir(), t.TempDir() + writeExecutable(t, filepath.Join(bin, "git"), fmt.Sprintf(`#!/bin/sh +printf '%%s\n' "$*" >> "$START_ISSUE_GIT_LOG" +case "$1 $2 $3" in + "worktree list --porcelain") printf 'worktree %%s\nbranch refs/heads/main\n\nworktree %%s\nHEAD deadbeef\ndetached\n' %q %q ;; + "rev-parse --show-toplevel") printf '%%s\n' %q ;; +esac +`, primary, detached, primary)) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_GIT_LOG", log) + + branch, registered := worktreeRegistration(detached) + if !registered || branch != "" { + t.Fatalf("worktreeRegistration(detached) = %q, %t; want empty branch and registered", branch, registered) + } + output := captureStdout(t, func() { + choice, err := pathConflictChoice(detached, branch, registered, bufio.NewReader(strings.NewReader("2\n"))) + if err != nil || choice != "2" { + t.Fatalf("detached path conflict choice = %q, %v", choice, err) + } + }) + if !strings.Contains(output, "Registered branch: detached HEAD") || !strings.Contains(output, "Delete and recreate") { + t.Fatalf("detached worktree did not offer safe delete/recreate:\n%s", output) + } + if err := removeWorktreeAndBranch(detached, branch); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(log) + if err != nil || !strings.Contains(string(got), "worktree remove --force "+detached) { + t.Fatalf("detached worktree was not removed through git: %q, %v", got, err) + } + if strings.Contains(string(got), "branch -D") { + t.Fatalf("detached worktree removal deleted a branch:\n%s", got) } } @@ -64,8 +564,155 @@ func TestValidChecksum(t *testing.T) { } func TestCompareVersions(t *testing.T) { - if compareVersions("v1.2.0", "1.2.0") != 0 || compareVersions("1.2.1", "1.2.0") <= 0 || compareVersions("1.1.9", "v1.2.0") >= 0 { - t.Fatal("unexpected version ordering") + for _, test := range []struct { + left, right string + want int + }{ + {left: "v1.2.0", right: "1.2.0", want: 0}, + {left: "1.2.1", right: "1.2.0", want: 1}, + {left: "1.1.9", right: "v1.2.0", want: -1}, + {left: "2.0.0-rc.1", right: "v2.0.0", want: -1}, + {left: "2.0.0-rc.2", right: "2.0.0-rc.1", want: 1}, + {left: "2.0.0-alpha", right: "2.0.0-alpha.1", want: -1}, + {left: "1.13.2-8-gabcdef", right: "v1.13.2", want: 1}, + {left: "1.13.2-8-gabcdef-dirty", right: "v1.13.2", want: 1}, + {left: "1.13.2-dirty", right: "v1.13.2", want: 1}, + } { + if got := compareVersions(test.left, test.right); got != test.want { + t.Errorf("compareVersions(%q, %q) = %d, want %d", test.left, test.right, got, test.want) + } + } +} + +func TestUpdateUsesConfiguredReleaseRepository(t *testing.T) { + bin, log := t.TempDir(), filepath.Join(t.TempDir(), "gh.log") + writeExecutable(t, filepath.Join(bin, "gh"), `#!/bin/sh +printf '%s\n' "$*" >> "$START_ISSUE_GH_LOG" +printf '%s\n' '{"tag_name":"v1.0.0"}' +`) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_GH_LOG", log) + t.Setenv("START_ISSUE_REPOSITORY", "fork/start-issue") + + previousVersion := version + version = "2.0.0" + defer func() { version = previousVersion }() + + if err := updateMode(options{}); err != nil { + t.Fatal(err) + } + called, err := os.ReadFile(log) + if err != nil || strings.TrimSpace(string(called)) != "auth status\napi repos/fork/start-issue/releases/latest" { + t.Fatalf("gh API call = %q, %v", called, err) + } +} + +func TestUpdateRequiresAuthenticatedGitHubCLI(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows update exits through its documented manual path") + } + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "gh"), "#!/bin/sh\n[ \"$1\" = auth ] && exit 1\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + err := updateMode(options{}) + if err == nil || err.Error() != "gh not authenticated. Run: gh auth login" { + t.Fatalf("update authentication error = %v", err) + } +} + +func TestInstallRequiresGitHubCLI(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows installation is intentionally manual") + } + t.Setenv("PATH", t.TempDir()) + if err := installMode(false); err == nil || err.Error() != "gh CLI not found. Install: https://cli.github.com" { + t.Fatalf("install GitHub CLI error = %v", err) + } +} + +func TestUpdateRequiresGitHubCLI(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows update is intentionally manual") + } + t.Setenv("PATH", t.TempDir()) + if err := updateMode(options{}); err == nil || err.Error() != "gh CLI not found. Install: https://cli.github.com" { + t.Fatalf("update GitHub CLI error = %v", err) + } +} + +func TestCheckGitHubAccessReportsInstallationInstructions(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + if err := checkGitHubAccess(); err == nil || err.Error() != "gh CLI not found. Install: https://cli.github.com" { + t.Fatalf("GitHub CLI error = %v", err) + } +} + +func TestRunModeUpdateDoesNotRequireHomeDirectory(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows update exits through its documented manual path") + } + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "gh"), "#!/bin/sh\nprintf '%s\\n' '{\"tag_name\":\"v1.0.0\"}'\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("HOME", "") + previousVersion := version + version = "1.0.0" + defer func() { version = previousVersion }() + + if err := runMode(options{mode: "update"}); err != nil { + t.Fatalf("update unexpectedly required HOME: %v", err) + } +} + +func TestUpdateRejectsReleaseWithoutTagName(t *testing.T) { + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "gh"), "#!/bin/sh\nprintf '%s\\n' '{}'\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + err := updateMode(options{}) + if err == nil || !strings.Contains(err.Error(), "missing tag_name") { + t.Fatalf("update error = %v", err) + } +} + +func TestUpdateDryRunRejectsReleaseWithoutRequiredAssets(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows update exits through its documented manual path") + } + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "gh"), "#!/bin/sh\nprintf '%s\\n' '{\"tag_name\":\"v9.0.0\",\"assets\":[]}'\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + previousVersion := version + version = "1.0.0" + defer func() { version = previousVersion }() + + err := updateMode(options{dryRun: true}) + if err == nil || !strings.Contains(err.Error(), "does not contain") || !strings.Contains(err.Error(), "checksums.txt") { + t.Fatalf("dry-run missing release assets error = %v", err) + } +} + +func TestVersionFromBuildInfoUsesGoInstallModuleVersion(t *testing.T) { + info := &debug.BuildInfo{Main: debug.Module{Version: "v2.3.4"}} + if got := versionFromBuildInfo(info); got != "2.3.4" { + t.Fatalf("versionFromBuildInfo() = %q, want 2.3.4", got) + } + if got := versionFromBuildInfo(&debug.BuildInfo{Main: debug.Module{Version: "(devel)"}}, "9.9.9"); got != "9.9.9" { + t.Fatalf("development version = %q, want fallback", got) + } + if got := versionFromBuildInfo(&debug.BuildInfo{Main: debug.Module{Version: "(devel)"}}); got != "dev" { + t.Fatalf("unversioned development build = %q, want dev", got) + } +} + +func TestRunningVersionUsesInjectedBuildVersion(t *testing.T) { + previousVersion := version + version = "v9.8.7" + defer func() { version = previousVersion }() + if got := runningVersion(); got != "9.8.7" { + t.Fatalf("runningVersion() = %q, want injected version", got) } } @@ -76,34 +723,1188 @@ func TestParseInitOptions(t *testing.T) { } } -func TestResolvePromptPrefersProjectConfig(t *testing.T) { - root := t.TempDir() - dir := filepath.Join(root, ".start-issue") - if err := os.MkdirAll(dir, 0755); err != nil { +func TestParseRejectsEmptyOptionValues(t *testing.T) { + for _, option := range []string{"--repo", "--base", "--worktree-dir", "--agent", "--model", "--prompt-file", "--prompt", "--command", "--prompt-output-file"} { + t.Run(option, func(t *testing.T) { + _, err := parse([]string{option, ""}) + if err == nil || err.Error() != option+" requires a value." { + t.Fatalf("parse(%s, empty) error = %v", option, err) + } + }) + } +} + +func TestInitRejectsEmptyRetainedAgentConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), "agent") + if err := os.WriteFile(path, []byte("# configured later\n"), 0644); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "prompt.md"), []byte("project {ISSUE_URL}"), 0644); err != nil { + _, _, err := resolveInitAgent(path, "codex", false) + if err == nil || !strings.Contains(err.Error(), "Agent config is empty") { + t.Fatalf("resolveInitAgent error = %v", err) + } +} + +func TestInitRejectsEmptyRetainedModelConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), "model") + if err := os.WriteFile(path, []byte("\n# configured later\n"), 0644); err != nil { t.Fatal(err) } - got, source, err := resolvePrompt(root, "codex", options{}) - if err != nil || got != "project {ISSUE_URL}" || source != filepath.Join(dir, "prompt.md") { - t.Fatalf("got %q %q %v", got, source, err) + _, err := resolveInitModel(path, "claude-opus", false) + if err == nil || !strings.Contains(err.Error(), "Model config is empty") { + t.Fatalf("resolveInitModel error = %v", err) } } -func TestLaunchArgsNoneIsEmpty(t *testing.T) { - if got := launchArgs("none", "", "", ""); len(got) != 0 { - t.Fatalf("got %q", got) +func TestInitRetainsExistingModelBeforeCLIModel(t *testing.T) { + path := filepath.Join(t.TempDir(), "model") + if err := os.WriteFile(path, []byte("configured-model\n"), 0644); err != nil { + t.Fatal(err) + } + model, err := resolveInitModel(path, "cli-model", false) + if err != nil || model != "configured-model" { + t.Fatalf("resolveInitModel = %q, %v", model, err) } } -func TestHelperArgsAreNonInteractive(t *testing.T) { - pi := helperArgs("pi", "", "/repo", "prompt") - if got := fmt.Sprint(pi); got != "[pi --print --no-tools --no-session prompt]" { - t.Fatalf("pi helper args: %s", got) +func TestInitValidatesCLIModel(t *testing.T) { + path := filepath.Join(t.TempDir(), "model") + + model, err := resolveInitModel(path, " gpt-5.2 ", true) + if err != nil || model != "gpt-5.2" { + t.Fatalf("resolveInitModel trimmed model = %q, %v", model, err) } - kimi := helperArgs("kimi", "model", "/repo", "prompt") - if got := fmt.Sprint(kimi); got != "[kimi --model model --work-dir /repo --quiet -p prompt]" { - t.Fatalf("kimi helper args: %s", got) + + _, err = resolveInitModel(path, " ", true) + if err == nil || !strings.Contains(err.Error(), "Model config is empty") { + t.Fatalf("resolveInitModel whitespace-only model error = %v", err) + } +} + +func TestResolveModelNormalizesCLIValue(t *testing.T) { + model, source, err := resolveModel(t.TempDir(), " gpt-5 ") + if err != nil || model != "gpt-5" || source != "CLI" { + t.Fatalf("resolveModel trimmed CLI model = %q, %q, %v", model, source, err) + } + + _, source, err = resolveModel(t.TempDir(), " ") + if err == nil || source != "CLI" || !strings.Contains(err.Error(), "--model requires a non-empty value") { + t.Fatalf("resolveModel whitespace-only CLI model = source %q, error %v", source, err) + } +} + +func TestResolversRejectWhitespaceOnlyEnvironmentValues(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + root := t.TempDir() + + t.Setenv("START_ISSUE_AGENT", " \t\n ") + _, source, err := resolveAgent(root, "") + if source != "START_ISSUE_AGENT" || err == nil || err.Error() != "Agent config is empty. Valid agents: claude, codex, kimi, pi, none." { + t.Fatalf("resolveAgent whitespace-only environment value = source %q, error %v", source, err) + } + + t.Setenv("START_ISSUE_AGENT", "") + t.Setenv("START_ISSUE_MODEL", " \t\n ") + _, source, err = resolveModel(root, "") + if source != "START_ISSUE_MODEL" || err == nil || err.Error() != "Model config is empty. Remove the empty model config or set a value." { + t.Fatalf("resolveModel whitespace-only environment value = source %q, error %v", source, err) + } +} + +func TestParseRejectsMultipleCommandModes(t *testing.T) { + _, err := parse([]string{"init", "update"}) + if err == nil || !strings.Contains(err.Error(), "only one command mode") { + t.Fatalf("got %v", err) + } +} + +func TestSelectInitDirRequiresExplicitValidScopeInRepository(t *testing.T) { + root, home := t.TempDir(), t.TempDir() + for _, test := range []struct { + name, input string + want string + wantErr string + }{ + {name: "project", input: "project\n", want: filepath.Join(root, ".start-issue")}, + {name: "user", input: "2\n", want: filepath.Join(home, ".config", "start-issue")}, + {name: "invalid", input: "maybe\n", wantErr: "Invalid init scope"}, + {name: "eof", input: "", wantErr: "No init scope selected"}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := selectInitDir(root, home, options{mode: "init"}, bufio.NewReader(strings.NewReader(test.input))) + if test.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("error = %v, want %q", err, test.wantErr) + } + return + } + if err != nil || got != test.want { + t.Fatalf("got %q, %v; want %q", got, err, test.want) + } + }) + } +} + +func TestCheckGHAuthRequiresAnAuthenticatedSession(t *testing.T) { + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "gh"), "#!/bin/sh\n[ \"$1\" = auth ] && [ \"$2\" = status ] && exit 1\nexit 0\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + if err := checkGHAuth(); err == nil || !strings.Contains(err.Error(), "gh not authenticated") { + t.Fatalf("got %v", err) + } +} + +func TestVerifyStagedBinaryRequiresExpectedVersion(t *testing.T) { + staged := filepath.Join(t.TempDir(), "start-issue") + writeExecutable(t, staged, "#!/bin/sh\nif [ \"$1\" = --version ]; then echo 'start-issue v2.1.0'; fi\n") + if err := verifyStagedBinary(staged, "v2.1.0"); err != nil { + t.Fatal(err) + } + if err := verifyStagedBinary(staged, "v2.2.0"); err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("got %v", err) + } +} + +func TestInstallVerifiedUpdateKeepsCurrentExecutableWhenStagingFails(t *testing.T) { + target := filepath.Join(t.TempDir(), "start-issue") + writeExecutable(t, target, "#!/bin/sh\n[ \"$1\" = --version ] && echo 'start-issue v2.0.0'\n") + wrongVersion := []byte("#!/bin/sh\n[ \"$1\" = --version ] && echo 'start-issue v2.1.1'\n") + if err := installVerifiedUpdate(target, wrongVersion, "v2.1.0"); err == nil { + t.Fatal("expected staged version verification error") + } + version, err := exec.Command(target, "--version").Output() + if err != nil || strings.TrimSpace(string(version)) != "start-issue v2.0.0" { + t.Fatalf("current executable changed: %q, %v", version, err) + } + if _, err := os.Stat(target + ".new"); !os.IsNotExist(err) { + t.Fatalf("staged executable was not removed: %v", err) + } +} + +func TestInstallVerifiedUpdateDoesNotFollowPredictableStagingSymlink(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "start-issue") + staging := target + ".new" + writeExecutable(t, target, "#!/bin/sh\n[ \"$1\" = --version ] && echo 'start-issue v2.0.0'\n") + if err := os.Symlink(target, staging); err != nil { + t.Fatal(err) + } + updated := []byte("#!/bin/sh\n[ \"$1\" = --version ] && echo 'start-issue v2.1.0'\n") + if err := installVerifiedUpdate(target, updated, "v2.1.0"); err != nil { + t.Fatal(err) + } + version, err := exec.Command(target, "--version").Output() + if err != nil || strings.TrimSpace(string(version)) != "start-issue v2.1.0" { + t.Fatalf("updated executable = %q, %v", version, err) + } + info, err := os.Lstat(staging) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("predictable staging symlink was modified: %v, %v", info, err) + } +} + +func TestInstallModeKeepsTargetWhenStagedVersionDoesNotMatchRelease(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows installation is intentionally manual") + } + home, bin := t.TempDir(), t.TempDir() + assetName, err := releaseAssetName(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + asset := []byte("#!/bin/sh\n[ \"$1\" = --version ] && echo 'start-issue v2.1.1'\n") + checksum := fmt.Sprintf("%x %s\n", sha256.Sum256(asset), assetName) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skipf("local HTTP listener unavailable: %v", err) + } + server := httptest.NewUnstartedServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/asset": + _, _ = response.Write(asset) + case "/checksums": + _, _ = response.Write([]byte(checksum)) + default: + http.NotFound(response, request) + } + })) + server.Listener = listener + server.Start() + defer server.Close() + release := filepath.Join(t.TempDir(), "release.json") + metadata := fmt.Sprintf(`{"tag_name":"v2.1.0","assets":[{"name":"%s","browser_download_url":"%s/asset"},{"name":"checksums.txt","browser_download_url":"%s/checksums"}]}`, assetName, server.URL, server.URL) + if err := os.WriteFile(release, []byte(metadata), 0644); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(bin, "gh"), "#!/bin/sh\ncat \"$START_ISSUE_TEST_RELEASE\"\n") + t.Setenv("HOME", home) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_TEST_RELEASE", release) + + err = installMode(false) + if err == nil || !strings.Contains(err.Error(), "does not match expected release") { + t.Fatalf("install error = %v", err) + } + target := filepath.Join(home, ".local", "bin", "start-issue") + if _, statErr := os.Stat(target); !os.IsNotExist(statErr) { + t.Fatalf("invalid staged binary was installed: %v", statErr) + } +} + +func TestNormalizePromptProposalStripsOnlyOuterFences(t *testing.T) { + got := normalizePromptProposal("```markdown\n# Prompt\n```go\nkeep\n```\n```") + if want := "# Prompt\n```go\nkeep\n```"; got != want { + t.Fatalf("got %q, want %q", got, want) + } + if got := normalizePromptProposal("# Prompt\n```\n"); got != "# Prompt\n```" { + t.Fatalf("unpaired fence changed: %q", got) + } +} + +func TestPromptImprovementOutputPathPreservesLegacyFileNames(t *testing.T) { + root := t.TempDir() + for _, test := range []struct { + name, path, want string + }{ + {"markdown", filepath.Join(root, "prompt.md"), filepath.Join(root, "prompt.improved.md")}, + {"other extension", filepath.Join(root, "prompt.txt"), filepath.Join(root, "prompt.txt.improved")}, + {"extensionless", filepath.Join(root, "prompt"), filepath.Join(root, "prompt.improved")}, + } { + t.Run(test.name, func(t *testing.T) { + if got := promptImprovementOutputPath(root, test.path, options{promptFile: test.path}); got != test.want { + t.Fatalf("promptImprovementOutputPath() = %q, want %q", got, test.want) + } + }) + } +} + +func TestInlinePromptNeverUsesDisplayLabelAsSourceFile(t *testing.T) { + root := t.TempDir() + labelPath := filepath.Join(root, "CLI --prompt") + if err := os.WriteFile(labelPath, []byte("unrelated file"), 0644); err != nil { + t.Fatal(err) + } + prompt, source, _, promptFile, err := resolvePrompt(root, "codex", options{prompt: "inline prompt"}) + if err != nil || prompt != "inline prompt" || source != "CLI --prompt" || promptFile != "" { + t.Fatalf("resolvePrompt() = %q, %q, %q, %v", prompt, source, promptFile, err) + } + if got, want := promptImprovementOutputPath(root, promptFile, options{}), filepath.Join(root, ".start-issue", "prompt.improved.md"); got != want { + t.Fatalf("inline prompt proposal path = %q, want %q", got, want) + } +} + +func TestImprovePromptPassesLegacyRequestToHelper(t *testing.T) { + bin, root, log := t.TempDir(), t.TempDir(), filepath.Join(t.TempDir(), "pi-request") + writeExecutable(t, filepath.Join(bin, "pi"), "#!/bin/sh\nprintf '%s' \"$*\" > \"$START_ISSUE_PROMPT_HELPER_LOG\"\nprintf 'improved prompt'\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_PROMPT_HELPER_LOG", log) + + promptPath := filepath.Join(root, "prompt.md") + outputPath := filepath.Join(root, "proposal.md") + err := improvePrompt(root, "pi", "", "Current template", "CLI --prompt-file: "+promptPath, promptPath, options{promptFile: promptPath, promptOutput: outputPath}, issue{Title: "Issue title", Body: "Issue body"}, "owner/repo", "34", "bug, urgent") + if err != nil { + t.Fatal(err) + } + request, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "Prompt source:\nCLI --prompt-file: " + promptPath, + "Repository:\nowner/repo", + "Current issue used as improvement context:\n- URL: https://github.com/owner/repo/issues/34\n- Number: 34\n- Title: Issue title\n- Labels: bug, urgent\n- Body:\nIssue body", + "Current prompt template:\n--- START PROMPT TEMPLATE ---\nCurrent template\n--- END PROMPT TEMPLATE ---", + } { + if !strings.Contains(string(request), want) { + t.Fatalf("helper request missing %q:\n%s", want, request) + } + } +} + +func TestImprovePromptRejectsEmptyProposal(t *testing.T) { + bin, root := t.TempDir(), t.TempDir() + writeExecutable(t, filepath.Join(bin, "pi"), "#!/bin/sh\nprintf ' \\n\\t \\n'\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + outputPath := filepath.Join(root, "proposals", "prompt.md") + err := improvePrompt(root, "pi", "", "prompt", "built-in default", "", options{promptOutput: outputPath}, issue{Title: "Issue"}, "owner/repo", "1", "") + if err == nil || !strings.Contains(err.Error(), "proposal is empty") { + t.Fatalf("improvePrompt() error = %v", err) + } + if _, statErr := os.Stat(outputPath); !os.IsNotExist(statErr) { + t.Fatalf("empty proposal wrote output file: %v", statErr) + } +} + +func TestInstallBinaryRestoresExecutablePermissions(t *testing.T) { + target := filepath.Join(t.TempDir(), "start-issue") + if err := os.WriteFile(target, []byte("old"), 0644); err != nil { + t.Fatal(err) + } + previous, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if err := installBinary(target, []byte("new")); err != nil { + t.Fatal(err) + } + info, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0755 { + t.Fatalf("installed mode = %o, want 755", got) + } + if os.SameFile(previous, info) { + t.Fatal("installBinary replaced the target in place instead of atomically renaming a staged binary") + } + if _, err := os.Stat(target + ".new"); !os.IsNotExist(err) { + t.Fatalf("staged binary was not removed: %v", err) + } +} + +func TestRunInitWarnsOnFailure(t *testing.T) { + bin, worktree := t.TempDir(), t.TempDir() + if err := os.WriteFile(filepath.Join(worktree, "init.sh"), []byte("ignored"), 0644); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(bin, "bash"), "#!/bin/sh\nexit 1\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + output := captureStdout(t, func() { runInit(worktree, false) }) + if !strings.Contains(output, "Warning: init.sh exited with non-zero code") { + t.Fatalf("init failure warning missing:\n%s", output) + } +} + +func TestRenameZellijTabWarnsOnFailure(t *testing.T) { + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "zellij-tab-status"), "#!/bin/sh\nexit 1\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + output := captureStdout(t, func() { renameZellijTab("34", false) }) + if !strings.Contains(output, "Warning: Could not rename zellij tab with zellij-tab-status") { + t.Fatalf("zellij failure warning missing:\n%s", output) + } +} + +func TestRenameZellijTabDryRunReportsMissingOptionalCommand(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + output := captureStdout(t, func() { renameZellijTab("34", true) }) + if !strings.Contains(output, "[DRY-RUN] Would skip zellij tab rename: zellij-tab-status not found") { + t.Fatalf("missing zellij dry-run message:\n%s", output) + } +} + +func TestUsageListsCompatibilityEntryPoints(t *testing.T) { + output := captureStdout(t, usage) + for _, want := range []string{ + "--command, -c ", + "--setup", + "--update", + "--install", + "--human-gate-help", + "Agent selection precedence:", + ".start-issue/agent in the git root", + "Prompt template precedence:", + "{ISSUE_URL}, {ISSUE_NUMBER}, {ISSUE_TITLE}, {ISSUE_BODY}, {ISSUE_LABELS}", + "start-issue https://github.com/owner/repo/issues/123", + } { + if !strings.Contains(output, want) { + t.Fatalf("help missing %q:\n%s", want, output) + } + } +} + +func TestAIBranchPromptPreservesTransliterationAndTagConstraints(t *testing.T) { + bin, log := t.TempDir(), filepath.Join(t.TempDir(), "prompt") + writeExecutable(t, filepath.Join(bin, "pi"), "#!/bin/sh\nlast=''\nfor arg do last=$arg; done\nprintf '%s' \"$last\" > '"+log+"'\nprintf '%s\\n' feature/issue-34-ispravit-tsap\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + if _, err := aiBranchName("pi", "", t.TempDir(), "34", "[brief] Исправить ЦАП", "bug"); err != nil { + t.Fatal(err) + } + prompt, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"transliterate it to English", "Strip leading bracketed process/stage tags", "[brief]"} { + if !strings.Contains(string(prompt), want) { + t.Fatalf("prompt missing %q:\n%s", want, prompt) + } + } +} + +func TestAIBranchNamePreservesCompleteResponseLine(t *testing.T) { + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "pi"), "#!/bin/sh\nprintf '%s\\n' 'Here is the branch: feature/issue-34-fix-login'\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + got, err := aiBranchName("pi", "", t.TempDir(), "34", "Fix login", "bug") + if err != nil { + t.Fatal(err) + } + if want := "Here is the branch: feature/issue-34-fix-login"; got != want { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestInstallDryRunDoesNotFetchOrWrite(t *testing.T) { + home, bin := t.TempDir(), t.TempDir() + marker := filepath.Join(t.TempDir(), "gh-ran") + writeExecutable(t, filepath.Join(bin, "gh"), "#!/bin/sh\ntouch '"+marker+"'\n") + t.Setenv("HOME", home) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + if err := installMode(true); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("dry-run fetched the release: %v", err) + } + if _, err := os.Stat(filepath.Join(home, ".local", "bin", "start-issue")); !os.IsNotExist(err) { + t.Fatalf("dry-run installed a binary: %v", err) + } +} + +func TestFirstRunOnboardingReusesBufferedInput(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + if err := maybeRunFirstRunOnboarding(false, "", bufio.NewReader(strings.NewReader("y\n2\ny\n"))); err != nil { + t.Fatal(err) + } + agent, err := os.ReadFile(filepath.Join(home, ".config", "start-issue", "agent")) + if err != nil || string(agent) != "codex\n" { + t.Fatalf("agent = %q, %v", agent, err) + } +} + +func TestFirstRunOnboardingSavesClaudeCommandPrompt(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + if err := maybeRunFirstRunOnboarding(false, "/debug", bufio.NewReader(strings.NewReader("y\n1\ny\n"))); err != nil { + t.Fatal(err) + } + prompt, err := os.ReadFile(filepath.Join(home, ".config", "start-issue", "prompt.md")) + if err != nil { + t.Fatal(err) + } + if got, want := string(prompt), "/debug {ISSUE_URL}\n"; got != want { + t.Fatalf("saved prompt = %q, want %q", got, want) + } +} + +func TestFirstRunOnboardingDeclinesWithoutSetup(t *testing.T) { + for _, response := range []string{"n\n", "no\n"} { + t.Run(strings.TrimSpace(response), func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + if err := maybeRunFirstRunOnboarding(false, "", bufio.NewReader(strings.NewReader(response))); err != nil { + t.Fatal(err) + } + dir := filepath.Join(home, ".config", "start-issue") + if _, err := os.Stat(dir); err != nil { + t.Fatalf("first-run marker missing: %v", err) + } + for _, name := range []string{"agent", "prompt.md"} { + if _, err := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(err) { + t.Fatalf("declining setup created %s: %v", name, err) + } + } + }) + } +} + +func TestRunPerformsFirstRunOnboardingBeforeGitValidation(t *testing.T) { + home, bin := t.TempDir(), t.TempDir() + t.Setenv("HOME", home) + t.Setenv("PATH", bin) + + var runErr error + output := captureStdout(t, func() { + runErr = runWithReader(options{issue: "1"}, bufio.NewReader(strings.NewReader("n\n"))) + }) + if runErr == nil || runErr.Error() != "git not found" { + t.Fatalf("run error = %v, want git validation failure", runErr) + } + if !strings.Contains(output, "Configuration is not initialized yet.") || !strings.Contains(output, "Run setup now? [Y/n]") { + t.Fatalf("first-run onboarding was not offered before git validation:\n%s", output) + } + if _, err := os.Stat(filepath.Join(home, ".config", "start-issue")); err != nil { + t.Fatalf("first-run marker missing: %v", err) + } +} + +func TestFirstRunOnboardingRejectsEOFAndInvalidResponse(t *testing.T) { + for _, response := range []string{"", "maybe\n"} { + t.Run(fmt.Sprintf("%q", response), func(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + err := maybeRunFirstRunOnboarding(false, "", bufio.NewReader(strings.NewReader(response))) + if err == nil { + t.Fatal("expected response error") + } + if _, statErr := os.Stat(filepath.Join(home, ".config", "start-issue")); !os.IsNotExist(statErr) { + t.Fatalf("invalid response initialized config: %v", statErr) + } + }) + } +} + +func TestSetupRejectsEOFAndHonorsNo(t *testing.T) { + for _, test := range []struct { + name, input string + wantErr bool + }{ + {name: "eof", input: "2\n", wantErr: true}, + {name: "no", input: "2\nno\n"}, + } { + t.Run(test.name, func(t *testing.T) { + home := t.TempDir() + err := setupMode(home, false, "", bufio.NewReader(strings.NewReader(test.input))) + if (err != nil) != test.wantErr { + t.Fatalf("setup error = %v, want error: %t", err, test.wantErr) + } + prompt := filepath.Join(home, ".config", "start-issue", "prompt.md") + if _, err := os.Stat(prompt); !os.IsNotExist(err) { + t.Fatalf("setup wrote prompt after %s: %v", test.name, err) + } + if _, err := os.Stat(filepath.Join(home, ".config", "start-issue")); err != nil { + t.Fatalf("setup did not create configuration marker after %s: %v", test.name, err) + } + }) + } +} + +func TestSetupReturnsConfigurationRemovalErrors(t *testing.T) { + for _, test := range []struct { + name, input, config, message string + }{ + {name: "prompt", input: "codex\nno\n", config: "prompt.md", message: "remove prompt template"}, + {name: "agent", input: "skip\nno\n", config: "agent", message: "remove agent config"}, + } { + t.Run(test.name, func(t *testing.T) { + home := t.TempDir() + path := filepath.Join(home, ".config", "start-issue", test.config) + if err := os.MkdirAll(path, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(path, "keep"), []byte("keep"), 0644); err != nil { + t.Fatal(err) + } + + err := setupMode(home, false, "", bufio.NewReader(strings.NewReader(test.input))) + if err == nil || !strings.Contains(err.Error(), test.message) { + t.Fatalf("setup error = %v", err) + } + }) + } +} + +func TestSetupPreviewsSelectedPromptBeforeConfirmation(t *testing.T) { + home := t.TempDir() + output := captureStdout(t, func() { + if err := setupMode(home, false, "", bufio.NewReader(strings.NewReader("2\ny\n"))); err != nil { + t.Fatal(err) + } + }) + preview := "Default prompt preview:\n" + defaultPortablePrompt() + if !strings.Contains(output, preview) { + t.Fatalf("setup output did not preview the selected prompt:\n%s", output) + } + if strings.Index(output, "Default prompt preview:") > strings.Index(output, "Save a default prompt?") { + t.Fatalf("setup asked for confirmation before previewing the prompt:\n%s", output) + } +} + +func TestSetupSavesClaudeCommandPrompt(t *testing.T) { + home := t.TempDir() + if err := setupMode(home, false, "/debug", bufio.NewReader(strings.NewReader("claude\ny\n"))); err != nil { + t.Fatal(err) + } + prompt, err := os.ReadFile(filepath.Join(home, ".config", "start-issue", "prompt.md")) + if err != nil { + t.Fatal(err) + } + if got, want := string(prompt), "/debug {ISSUE_URL}\n"; got != want { + t.Fatalf("saved prompt = %q, want %q", got, want) + } +} + +func TestSetupAcceptsLegacyAgentSelections(t *testing.T) { + for _, test := range []struct { + choice string + wantAgent string + }{ + {choice: "claude", wantAgent: "claude"}, + {choice: "Claude", wantAgent: "claude"}, + {choice: "codex", wantAgent: "codex"}, + {choice: "Codex", wantAgent: "codex"}, + {choice: "kimi", wantAgent: "kimi"}, + {choice: "Kimi", wantAgent: "kimi"}, + {choice: "pi", wantAgent: "pi"}, + {choice: "Pi", wantAgent: "pi"}, + {choice: "skip", wantAgent: ""}, + {choice: "Skip", wantAgent: ""}, + {choice: "", wantAgent: ""}, + } { + t.Run(test.choice, func(t *testing.T) { + home := t.TempDir() + if err := setupMode(home, false, "", bufio.NewReader(strings.NewReader(test.choice+"\nno\n"))); err != nil { + t.Fatal(err) + } + agent, err := os.ReadFile(filepath.Join(home, ".config", "start-issue", "agent")) + if test.wantAgent == "" { + if !os.IsNotExist(err) { + t.Fatalf("setup agent config = %q, %v; want no agent config", agent, err) + } + return + } + if err != nil { + t.Fatal(err) + } + if got := string(agent); got != test.wantAgent+"\n" { + t.Fatalf("setup agent config = %q, want %q", got, test.wantAgent+"\n") + } + }) + } +} + +func TestSetupDryRunPrintsInteractiveConfigPlanWithoutWriting(t *testing.T) { + home := t.TempDir() + dir := filepath.Join(home, ".config", "start-issue") + output := captureStdout(t, func() { + if err := setupMode(home, true, "", bufio.NewReader(strings.NewReader("5\nn\n"))); err != nil { + t.Fatal(err) + } + }) + for _, want := range []string{ + "Default prompt preview:\n/task-router:route-task {ISSUE_URL}", + "Would create configuration in: " + dir, + "Would remove prompt template: " + filepath.Join(dir, "prompt.md"), + "Would remove agent config: " + filepath.Join(dir, "agent"), + } { + if !strings.Contains(output, want) { + t.Fatalf("dry-run output missing %q:\n%s", want, output) + } + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("setup dry-run created configuration: %v", err) + } +} + +func TestEmptyRootDoesNotReadRelativeProjectConfig(t *testing.T) { + wd := t.TempDir() + previous, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(previous) }() + if err := os.Chdir(wd); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(".start-issue", 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(".start-issue", "agent"), []byte("codex\n"), 0644); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", t.TempDir()) + + agent, source, err := resolveAgent("", "") + if err != nil || agent != "claude" || source != "built-in default" { + t.Fatalf("got %q %q %v", agent, source, err) + } +} + +func TestResolvePromptPrefersProjectConfig(t *testing.T) { + root := t.TempDir() + dir := filepath.Join(root, ".start-issue") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "prompt.md"), []byte("project {ISSUE_URL}"), 0644); err != nil { + t.Fatal(err) + } + got, source, location, promptFile, err := resolvePrompt(root, "codex", options{}) + if err != nil || got != "project {ISSUE_URL}" || source != filepath.Join(dir, "prompt.md") || location != filepath.Join(dir, "prompt.md") { + t.Fatalf("got %q %q %q %v", got, source, location, err) + } + if promptFile != filepath.Join(dir, "prompt.md") { + t.Fatalf("prompt file = %q, want project prompt path", promptFile) + } +} + +func TestResolvePromptTracksSourceAndLocationSeparately(t *testing.T) { + inline, source, location, inlinePromptFile, err := resolvePrompt(t.TempDir(), "codex", options{prompt: "hello"}) + if err != nil || inline != "hello" || source != "CLI --prompt" || location != "inline CLI argument" { + t.Fatalf("inline = %q %q %q %v", inline, source, location, err) + } + if inlinePromptFile != "" { + t.Fatalf("inline prompt file = %q, want empty", inlinePromptFile) + } + + promptFile := filepath.Join(t.TempDir(), "prompt with spaces.md") + if err := os.WriteFile(promptFile, []byte("from file"), 0644); err != nil { + t.Fatal(err) + } + _, source, location, promptFile, err = resolvePrompt(t.TempDir(), "codex", options{promptFile: promptFile}) + if err != nil || source != "CLI --prompt-file: "+promptFile || location != promptFile { + t.Fatalf("file = %q %q %v", source, location, err) + } + if promptFile == "" { + t.Fatal("file-backed prompt did not retain its source path") + } +} + +func TestInitScopeDoesNotImportOtherScopeAgent(t *testing.T) { + root, home, bin := t.TempDir(), t.TempDir(), t.TempDir() + writeExecutable(t, filepath.Join(bin, "git"), "#!/bin/sh\nprintf '%s\\n' '"+root+"'\n") + t.Setenv("HOME", home) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + if err := os.MkdirAll(filepath.Join(home, ".config", "start-issue"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, ".config", "start-issue", "agent"), []byte("codex\n"), 0644); err != nil { + t.Fatal(err) + } + + if err := runMode(options{mode: "init", project: true}); err != nil { + t.Fatal(err) + } + agent, err := os.ReadFile(filepath.Join(root, ".start-issue", "agent")) + if err != nil || string(agent) != "claude\n" { + t.Fatalf("project agent = %q, %v", agent, err) + } + + if err := os.RemoveAll(filepath.Join(home, ".config", "start-issue")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".start-issue", "agent"), []byte("codex\n"), 0644); err != nil { + t.Fatal(err) + } + if err := runMode(options{mode: "init", user: true}); err != nil { + t.Fatal(err) + } + agent, err = os.ReadFile(filepath.Join(home, ".config", "start-issue", "agent")) + if err != nil || string(agent) != "claude\n" { + t.Fatalf("user agent = %q, %v", agent, err) + } +} + +func TestInitDryRunValidatesAndPrintsPlanWithoutWriting(t *testing.T) { + root, home, bin := t.TempDir(), t.TempDir(), t.TempDir() + writeExecutable(t, filepath.Join(bin, "git"), "#!/bin/sh\nprintf '%s\\n' '"+root+"'\n") + t.Setenv("HOME", home) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + err := runMode(options{mode: "init", project: true, dryRun: true, agent: "unknown"}) + if err == nil || !strings.Contains(err.Error(), "Unknown agent") { + t.Fatalf("unknown agent error = %v", err) + } + err = runMode(options{mode: "init", project: true, dryRun: true, promptFile: filepath.Join(root, "missing.md")}) + if err == nil || !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("missing prompt error = %v", err) + } + + output := captureStdout(t, func() { + err = runMode(options{mode: "init", project: true, dryRun: true, agent: "codex"}) + }) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "Would create configuration in: " + filepath.Join(root, ".start-issue"), + "Would write agent config: " + filepath.Join(root, ".start-issue", "agent"), + "Would write prompt template: " + filepath.Join(root, ".start-issue", "prompt.md"), + } { + if !strings.Contains(output, want) { + t.Fatalf("dry-run output missing %q:\n%s", want, output) + } + } + if _, statErr := os.Stat(filepath.Join(root, ".start-issue")); !os.IsNotExist(statErr) { + t.Fatalf("dry-run created config: %v", statErr) + } +} + +func TestInitDefaultPromptUsesAgentPersistedAtTarget(t *testing.T) { + root, home, bin := t.TempDir(), t.TempDir(), t.TempDir() + configDir := filepath.Join(root, ".start-issue") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configDir, "agent"), []byte("codex\n"), 0644); err != nil { + t.Fatal(err) + } + writeExecutable(t, filepath.Join(bin, "git"), "#!/bin/sh\nprintf '%s\\n' '"+root+"'\n") + t.Setenv("HOME", home) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + if err := runMode(options{mode: "init", project: true, agent: "claude"}); err != nil { + t.Fatal(err) + } + prompt, err := os.ReadFile(filepath.Join(configDir, "prompt.md")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(prompt), "/task-router:route-task") { + t.Fatalf("Codex config received Claude prompt: %q", prompt) + } + if !strings.Contains(string(prompt), "Implement GitHub issue {ISSUE_URL}") { + t.Fatalf("got unexpected prompt: %q", prompt) + } +} + +func TestInitDefaultClaudePromptUsesCommand(t *testing.T) { + root, home, bin := t.TempDir(), t.TempDir(), t.TempDir() + writeExecutable(t, filepath.Join(bin, "git"), "#!/bin/sh\nprintf '%s\\n' '"+root+"'\n") + t.Setenv("HOME", home) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + if err := runMode(options{mode: "init", project: true, command: "/custom:route"}); err != nil { + t.Fatal(err) + } + prompt, err := os.ReadFile(filepath.Join(root, ".start-issue", "prompt.md")) + if err != nil { + t.Fatal(err) + } + if got, want := string(prompt), "/custom:route {ISSUE_URL}\n"; got != want { + t.Fatalf("prompt = %q, want %q", got, want) + } +} + +func TestInitPromptFileNormalizesTrailingNewlines(t *testing.T) { + root, home, bin, source := t.TempDir(), t.TempDir(), t.TempDir(), filepath.Join(t.TempDir(), "source.md") + writeExecutable(t, filepath.Join(bin, "git"), "#!/bin/sh\nprintf '%s\\n' '"+root+"'\n") + if err := os.WriteFile(source, []byte("Prompt {ISSUE_URL}\n\n"), 0644); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", home) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + if err := runMode(options{mode: "init", project: true, promptFile: source}); err != nil { + t.Fatal(err) + } + prompt, err := os.ReadFile(filepath.Join(root, ".start-issue", "prompt.md")) + if err != nil { + t.Fatal(err) + } + if got, want := string(prompt), "Prompt {ISSUE_URL}\n"; got != want { + t.Fatalf("prompt = %q, want %q", got, want) + } +} + +func TestLaunchArgsNoneIsEmpty(t *testing.T) { + if got := launchArgs("none", "", "", ""); len(got) != 0 { + t.Fatalf("got %q", got) + } +} + +func TestPrintLaunchShellQuotesArguments(t *testing.T) { + output := captureStdout(t, func() { + printLaunch("codex", "", "/tmp/work tree", "# prompt; touch not-run") + }) + if !strings.Contains(output, "codex --cd '/tmp/work tree' --dangerously-bypass-approvals-and-sandbox '# prompt; touch not-run'") { + t.Fatalf("launch output is not shell quoted:\n%s", output) + } + if got := shellJoin([]string{"", "~", "#prompt"}); got != "'' '~' '#prompt'" { + t.Fatalf("shellJoin = %q", got) + } +} + +func TestPrintLaunchLargePromptDiagnostics(t *testing.T) { + prompt := strings.Repeat("x", 4001) + output := captureStdout(t, func() { + printLaunch("codex", "", "/tmp/work tree", prompt) + }) + for _, want := range []string{ + "Prompt length: 4001 chars", + "Prompt omitted from command display because it is large.", + "Set START_ISSUE_DUMP_PROMPT=1 to print the full rendered prompt.", + "", + } { + if !strings.Contains(output, want) { + t.Fatalf("dry-run diagnostics missing %q:\n%s", want, output) + } + } + if strings.Contains(output, prompt) { + t.Fatalf("large rendered prompt was not omitted from command display:\n%s", output) + } +} + +func TestPrintLaunchCountsUnicodePromptCharacters(t *testing.T) { + prompt := strings.Repeat("я", 3000) + output := captureStdout(t, func() { + printLaunch("codex", "", "/tmp/work tree", prompt) + }) + if !strings.Contains(output, "Prompt length: 3000 chars") { + t.Fatalf("unicode prompt character count is wrong:\n%s", output) + } + if strings.Contains(output, "Prompt omitted from command display because it is large.") { + t.Fatalf("unicode prompt below the character threshold was omitted:\n%s", output) + } +} + +func TestPrintLaunchShowsWorktreeCWDForClaudeAndPi(t *testing.T) { + for _, agent := range []string{"claude", "pi"} { + t.Run(agent, func(t *testing.T) { + output := captureStdout(t, func() { + printLaunch(agent, "", "/tmp/work tree", "prompt") + }) + if !strings.Contains(output, "Would run: cd '/tmp/work tree' && "+agent) { + t.Fatalf("dry-run launch plan omits worktree cwd:\n%s", output) + } + }) + } +} + +func TestCanonicalPathMakesRelativeWorktreeAbsolute(t *testing.T) { + if got := canonicalPath(filepath.Join("worktrees", "feature", "issue-34")); !filepath.IsAbs(got) { + t.Fatalf("worktree path is relative: %q", got) + } +} + +func TestLaunchPreservesAgentExitCode(t *testing.T) { + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "codex"), "#!/bin/sh\nexit 42\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + err := launch("codex", "", t.TempDir(), "prompt") + var exit exitError + if !errors.As(err, &exit) || exit.code != 42 { + t.Fatalf("got %T %v", err, err) + } +} + +func TestLaunchPreservesSignalDerivedExitCode(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX signal exit statuses are not available on Windows") + } + bin := t.TempDir() + writeExecutable(t, filepath.Join(bin, "codex"), "#!/bin/sh\nkill -INT $$\n") + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + err := launch("codex", "", t.TempDir(), "prompt") + var exit exitError + if !errors.As(err, &exit) || exit.code != 130 { + t.Fatalf("got %T %v", err, err) + } +} + +func TestLaunchUsesAdapterSpecificWorkingDirectory(t *testing.T) { + bin, worktree, log := t.TempDir(), t.TempDir(), filepath.Join(t.TempDir(), "cwd") + for _, agent := range []string{"claude", "codex", "kimi", "pi"} { + writeExecutable(t, filepath.Join(bin, agent), "#!/bin/sh\npwd > \"$START_ISSUE_CWD_LOG\"\n") + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_CWD_LOG", log) + caller, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for _, agent := range []string{"claude", "codex", "kimi", "pi"} { + t.Run(agent, func(t *testing.T) { + if err := launch(agent, "", worktree, "prompt"); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + want := caller + if agent == "claude" || agent == "pi" { + want = worktree + } + if strings.TrimSpace(string(got)) != want { + t.Fatalf("working directory = %q, want %q", strings.TrimSpace(string(got)), want) + } + }) + } +} + +func TestHelperArgsAreNonInteractive(t *testing.T) { + pi := helperArgs("pi", "", "/repo", "prompt") + if got := fmt.Sprint(pi); got != "[pi --print --no-tools --no-session prompt]" { + t.Fatalf("pi helper args: %s", got) + } + kimi := helperArgs("kimi", "model", "/repo", "prompt") + if got := fmt.Sprint(kimi); got != "[kimi --model model --work-dir /repo --quiet -p prompt]" { + t.Fatalf("kimi helper args: %s", got) + } +} + +func TestHumanGateSavesThreadIDBeforeDone(t *testing.T) { + worktree, bin := t.TempDir(), t.TempDir() + writeFakeCodex(t, bin) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_RUN_ID", "done") + t.Setenv("CODEX_EVENTS", `{"type":"thread.started","thread_id":"thread-done"}`) + t.Setenv("CODEX_LAST", "STATUS: DONE") + + if err := humanGate("", worktree, "prompt", false); err != nil { + t.Fatal(err) + } + threadID, err := os.ReadFile(filepath.Join(worktree, ".start-issue", "runs", "done", "thread-id")) + if err != nil || string(threadID) != "thread-done\n" { + t.Fatalf("thread-id = %q, %v", threadID, err) + } +} + +func TestHumanGateSavesThreadIDWhenFinalMessageIsMissing(t *testing.T) { + worktree, bin := t.TempDir(), t.TempDir() + writeFakeCodex(t, bin) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_RUN_ID", "missing-last-message") + t.Setenv("CODEX_EVENTS", `{"type":"thread.started","thread_id":"thread-recovery"}`) + t.Setenv("CODEX_SKIP_LAST", "1") + + err := humanGate("", worktree, "prompt", false) + if err == nil || !strings.Contains(err.Error(), "No recognized final status found") { + t.Fatalf("humanGate error = %v, want missing final-status error", err) + } + threadID, readErr := os.ReadFile(filepath.Join(worktree, ".start-issue", "runs", "missing-last-message", "thread-id")) + if readErr != nil || string(threadID) != "thread-recovery\n" { + t.Fatalf("thread-id = %q, %v", threadID, readErr) + } +} + +func TestRunChecksForGitBeforeRepositoryValidation(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + + err := runWithReader(options{}, bufio.NewReader(strings.NewReader(""))) + if err == nil || err.Error() != "git not found" { + t.Fatalf("runWithReader error = %v, want git dependency error", err) + } +} + +func TestHumanGateDryRunShowsAllStateArtifacts(t *testing.T) { + worktree := t.TempDir() + t.Setenv("START_ISSUE_RUN_ID", "plan") + dir := filepath.Join(worktree, ".start-issue", "runs", "plan") + output := captureStdout(t, func() { + if err := humanGate("", worktree, "prompt", true); err != nil { + t.Fatal(err) + } + }) + for _, want := range []string{ + "--output-last-message " + filepath.Join(dir, "last-message.txt"), + "> " + filepath.Join(dir, "events.jsonl"), + "Would write captured thread ID: " + filepath.Join(dir, "thread-id"), + } { + if !strings.Contains(output, want) { + t.Fatalf("dry-run output missing %q:\n%s", want, output) + } + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("human-gate dry-run created state directory: %v", err) + } +} + +func TestHumanGatePreservesCallerWorkingDirectory(t *testing.T) { + worktree, bin, log := t.TempDir(), t.TempDir(), filepath.Join(t.TempDir(), "cwd") + writeFakeCodex(t, bin) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_CWD_LOG", log) + t.Setenv("START_ISSUE_RUN_ID", "cwd") + t.Setenv("CODEX_EVENTS", `{"type":"thread.started","thread_id":"thread-cwd"}`) + t.Setenv("CODEX_LAST", "STATUS: DONE") + want, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := humanGate("", worktree, "prompt", false); err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(log) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(got)) != want { + t.Fatalf("working directory = %q, want %q", strings.TrimSpace(string(got)), want) + } +} + +func TestHumanGateRejectsDoneWithoutThreadID(t *testing.T) { + worktree, bin := t.TempDir(), t.TempDir() + writeFakeCodex(t, bin) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_RUN_ID", "missing-thread") + t.Setenv("CODEX_EVENTS", `{"type":"item.completed"}`) + t.Setenv("CODEX_LAST", "STATUS: DONE") + + err := humanGate("", worktree, "prompt", false) + if err == nil || !strings.Contains(err.Error(), "did not capture thread_id") { + t.Fatalf("got %v", err) + } +} + +func TestHumanGateResumeFailureReturnsExitCodeTwo(t *testing.T) { + worktree, bin := t.TempDir(), t.TempDir() + writeFakeCodex(t, bin) + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("START_ISSUE_RUN_ID", "resume-failure") + t.Setenv("CODEX_EVENTS", `{"type":"thread.started","thread_id":"thread-resume"}`) + t.Setenv("CODEX_LAST", "STATUS: HUMAN_GATE") + t.Setenv("CODEX_RESUME_EXIT", "1") + + err := humanGate("", worktree, "prompt", false) + var exit exitError + if !errors.As(err, &exit) || exit.code != 2 { + t.Fatalf("got %T %v", err, err) + } +} + +func writeExecutable(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0755); err != nil { + t.Fatal(err) + } +} + +func writeFakeCodex(t *testing.T, bin string) { + t.Helper() + writeExecutable(t, filepath.Join(bin, "codex"), `#!/bin/sh +if [ -n "$START_ISSUE_CWD_LOG" ]; then + pwd > "$START_ISSUE_CWD_LOG" +fi +if [ "$1" = "exec" ]; then + last="" + while [ "$#" -gt 0 ]; do + if [ "$1" = "--output-last-message" ]; then + last="$2" + shift 2 + continue + fi + shift + done + printf '%s\n' "$CODEX_EVENTS" + if [ "$CODEX_SKIP_LAST" != "1" ]; then + printf '%s\n' "$CODEX_LAST" > "$last" + fi + exit 0 +fi +if [ "$1" = "resume" ]; then + exit "${CODEX_RESUME_EXIT:-0}" +fi +exit 1 +`) +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + previous := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = writer + defer func() { os.Stdout = previous }() + fn() + if err := writer.Close(); err != nil { + t.Fatal(err) + } + output, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) } + return string(output) } diff --git a/cmd/start-issue/parity_integration_test.go b/cmd/start-issue/parity_integration_test.go index 2517e37..c3afa92 100644 --- a/cmd/start-issue/parity_integration_test.go +++ b/cmd/start-issue/parity_integration_test.go @@ -1,7 +1,6 @@ package main import ( - "bytes" "crypto/sha256" "errors" "fmt" @@ -18,8 +17,6 @@ import ( "testing" ) -const bashParityBaselineRevision = "d658db620836c4113e5a49326b5c69012c3e1f18" - // TestCLIParityHelper runs the public command in a subprocess so the parity // cases exercise argument parsing, external command fakes, and side effects. func TestCLIParityHelper(t *testing.T) { @@ -90,6 +87,29 @@ func TestCLIParityIssueFetchConfigWorktreeAndReuse(t *testing.T) { } } +func TestCLIPrintsVersionBannerOnExecutionPaths(t *testing.T) { + for _, test := range []struct { + name string + input string + args []string + }{ + {name: "issue workflow", args: []string{"1", "--agent", "none", "--no-init", "--dry-run"}}, + {name: "init", args: []string{"init", "--project", "--force", "--agent", "none"}}, + {name: "setup", input: "\n\n", args: []string{"setup", "--dry-run"}}, + // The fixture's release response is intentionally incomplete, but the + // banner must precede any update diagnostic. + {name: "update", args: []string{"update", "--dry-run"}}, + {name: "human-gate help", args: []string{"--human-gate-help"}}, + } { + t.Run(test.name, func(t *testing.T) { + output, _ := newParityFixture(t).run(test.input, test.args...) + if !strings.HasPrefix(output, "start-issue v") { + t.Fatalf("output does not start with the version banner:\n%s", output) + } + }) + } +} + func TestCLIReusesOneReaderForOnboardingAndConflictResolution(t *testing.T) { fixture := newParityFixture(t) if err := os.RemoveAll(filepath.Join(fixture.home, ".config", "start-issue")); err != nil { @@ -177,18 +197,96 @@ func TestCLIParityInitHookAndNoInit(t *testing.T) { } } +func TestCLIParityPreservesCaseSensitiveLabelMatching(t *testing.T) { + baseline := extractBashParityBaseline(t) + bashFixture, goFixture := newParityFixture(t), newParityFixture(t) + bashFixture.issueLabel = "Bug" + goFixture.issueLabel = "Bug" + args := []string{"1", "--agent", "none", "--no-init", "--dry-run"} + + want := bashFixture.runBaseline(t, baseline, "", args...) + got := goFixture.runResult("", args...) + assertParityResult(t, want, got, []parityOutputRecord{ + lineRecord("labels", `Labels: Bug`), + branchRecord(), + }) +} + +func TestCLIParityLargePromptDiagnostics(t *testing.T) { + baseline := extractBashParityBaseline(t) + bashFixture, goFixture := newParityFixture(t), newParityFixture(t) + prompt := strings.Repeat("x", 4001) + args := []string{"1", "--agent", "codex", "--no-init", "--dry-run", "--prompt", prompt} + + want := bashFixture.runBaseline(t, baseline, "", args...) + got := goFixture.runResult("", args...) + assertParityResult(t, want, got, []parityOutputRecord{ + lineRecord("prompt length", `Prompt length: 4001 chars`), + lineRecord("prompt omission", `Prompt omitted from command display because it is large\.`), + lineRecord("prompt omission guidance", `Set START_ISSUE_DUMP_PROMPT=1 to print the full rendered prompt\.`), + shellEscapedLineRecord("abbreviated rendered prompt", `\\?`), + }) +} + +func TestParityNormalizationPreservesSemanticRootIdentity(t *testing.T) { + fixture := newParityFixture(t) + // Worktree parents are allowed to be nested under a repository. The + // normalizer must retain that semantic ownership instead of reducing both + // paths to an indistinguishable temporary-directory token. + fixture.worktrees = filepath.Join(fixture.repo, "worktrees") + if err := os.MkdirAll(fixture.worktrees, 0755); err != nil { + t.Fatal(err) + } + for path := range map[string]struct{}{ + filepath.Join(fixture.home, "home-only"): {}, + filepath.Join(fixture.repo, "repo-only"): {}, + filepath.Join(fixture.worktrees, "worktree-only"): {}, + } { + if err := os.WriteFile(path, nil, 0644); err != nil { + t.Fatal(err) + } + } + + normalized := normalizeRawParityOutput(strings.Join([]string{ + filepath.Join(fixture.home, "home-only"), + filepath.Join(fixture.repo, "repo-only"), + filepath.Join(fixture.worktrees, "worktree-only"), + }, "\n"), fixture) + for _, want := range []string{ + "/home-only", + "/repo-only", + "/worktree-only", + } { + if !strings.Contains(normalized, want) { + t.Fatalf("normalized output missing %q:\n%s", want, normalized) + } + } + + filesystem := strings.Join(parityFilesystem(fixture), "\n") + for _, want := range []string{ + "/home-only", + "/repo-only", + "/worktree-only", + } { + if !strings.Contains(filesystem, want) { + t.Fatalf("filesystem record missing %q:\n%s", want, filesystem) + } + } +} + func TestBaselineAndGoParityForCriticalIssueWorkflows(t *testing.T) { baseline := extractBashParityBaseline(t) for _, test := range []struct { - name string - args []string - input string - setup func(t *testing.T, fixture *parityFixture) - strict bool - records []parityOutputRecord - assertOutcome func(t *testing.T, result parityResult) - assertBaseline func(t *testing.T, result parityResult) - assertGo func(t *testing.T, result parityResult) + name string + args []string + input string + setup func(t *testing.T, fixture *parityFixture) + strict bool + intentionalDifferenceID string + records []parityOutputRecord + assertOutcome func(t *testing.T, result parityResult) + assertBaseline func(t *testing.T, result parityResult) + assertGo func(t *testing.T, result parityResult) }{ { name: "help", @@ -310,7 +408,11 @@ func TestBaselineAndGoParityForCriticalIssueWorkflows(t *testing.T) { name: "worktree-path-conflict-dry-run", args: []string{"1", "--agent", "none", "--no-init", "--dry-run"}, input: "1\n", + // ID-01 is the approved dry-run safety difference documented in + // memory-bank/features/FT-016/decision-log.md. + intentionalDifferenceID: "ID-01", setup: func(t *testing.T, fixture *parityFixture) { + fixture.pathBranch = true if err := os.MkdirAll(fixture.fakeWorktree(), 0755); err != nil { t.Fatal(err) } @@ -333,11 +435,17 @@ func TestBaselineAndGoParityForCriticalIssueWorkflows(t *testing.T) { test.setup(t, &bashFixture) test.setup(t, &goFixture) } + if test.intentionalDifferenceID != "" && (test.strict || test.assertBaseline == nil || test.assertGo == nil) { + t.Fatalf("intentional difference %s must declare distinct baseline and Go expectations", test.intentionalDifferenceID) + } want := bashFixture.runBaseline(t, baseline, test.input, test.args...) got := goFixture.runResult(test.input, test.args...) if test.strict { assertParityResult(t, want, got, test.records) } + if test.intentionalDifferenceID != "" { + assertParitySideEffects(t, want, got) + } if test.assertOutcome != nil { t.Run("bash-contract", func(t *testing.T) { test.assertOutcome(t, want) }) t.Run("go-contract", func(t *testing.T) { test.assertOutcome(t, got) }) @@ -398,6 +506,19 @@ func branchRecord() parityOutputRecord { } } +func shellEscapedLineRecord(name, expression string) parityOutputRecord { + return parityOutputRecord{ + name: name, + pattern: regexp.MustCompile(expression), + // Bash shell-escapes the placeholder in its displayed command, while + // Go prints the rendered prompt directly. The placeholder contents are + // the contract; its command-display quoting is not. + normalize: func(record string) string { + return strings.ReplaceAll(record, `\`, "") + }, + } +} + func assertParityOutputContains(t *testing.T, result parityResult, values ...string) { t.Helper() for _, value := range values { @@ -470,19 +591,7 @@ func extractBashParityBaseline(t *testing.T) string { t.Skip("the Bash baseline oracle is POSIX-only") } root := repoRoot(t) - archive := exec.Command("git", "archive", "--format=tar", bashParityBaselineRevision, "scripts") - archive.Dir = root - contents, err := archive.Output() - if err != nil { - t.Fatalf("read Bash parity baseline %s: %v", bashParityBaselineRevision, err) - } - dir := t.TempDir() - extract := exec.Command("tar", "-x", "-C", dir) - extract.Stdin = bytes.NewReader(contents) - if output, err := extract.CombinedOutput(); err != nil { - t.Fatalf("extract Bash parity baseline: %v\n%s", err, output) - } - return filepath.Join(dir, "scripts", "start-issue") + return filepath.Join(root, "cmd", "start-issue", "testdata", "bash-v1", "scripts", "start-issue") } func assertParityResult(t *testing.T, want, got parityResult, records []parityOutputRecord) { @@ -500,6 +609,11 @@ func assertParityResult(t *testing.T, want, got parityResult, records []parityOu t.Fatalf("%s output differs:\nBash: %s\nGo: %s", record.name, wantRecord, gotRecord) } } + assertParitySideEffects(t, want, got) +} + +func assertParitySideEffects(t *testing.T, want, got parityResult) { + t.Helper() if want.gitLog != got.gitLog || want.ghLog != got.ghLog { t.Fatalf("fake command logs differ:\nBash git=%q gh=%q\nGo git=%q gh=%q", want.gitLog, want.ghLog, got.gitLog, got.ghLog) } @@ -521,18 +635,60 @@ func parityRecord(t *testing.T, output string, record parityOutputRecord) string return match } -func TestCLIParityDryRunReportsAttachedBranchConflict(t *testing.T) { - fixture := newParityFixture(t) - fixture.branchExists = true - output, err := fixture.run("1\n", "1", "--agent", "none", "--no-init", "--dry-run") - if err != nil { - t.Fatalf("dry-run failed: %v\n%s", err, output) - } - if !strings.Contains(output, "Branch is already attached to a worktree; would prompt for reuse, suffix, or delete/recreate") { - t.Fatalf("dry-run did not report the unresolved attached-branch conflict:\n%s", output) - } - if strings.Contains(output, "Would reuse branch worktree") || strings.Contains(output, "Would run: codex") { - t.Fatalf("dry-run selected a conflict resolution instead of reporting it:\n%s", output) +func TestCLIParityDryRunResolvesBranchConflicts(t *testing.T) { + baseline := extractBashParityBaseline(t) + for _, test := range []struct { + name, input string + setup func(*testing.T, *parityFixture) + records []parityOutputRecord + }{ + { + name: "reuse attached worktree", + input: "1\n", + setup: func(t *testing.T, fixture *parityFixture) { + fixture.branchExists = true + if err := os.MkdirAll(fixture.fakeWorktree(), 0755); err != nil { + t.Fatal(err) + } + }, + records: []parityOutputRecord{ + lineRecord("existing worktree", `Existing worktree: .+`), + lineRecord("ready worktree", `Worktree ready at: .+`), + }, + }, + { + name: "suffix branch", + input: "2\n", + setup: func(_ *testing.T, fixture *parityFixture) { + fixture.branchExists = true + }, + records: []parityOutputRecord{ + lineRecord("new branch", `New branch name: .+`), + lineRecord("worktree creation", `\[DRY-RUN\] Would run: git worktree add .+`), + lineRecord("ready worktree", `Worktree ready at: .+`), + }, + }, + { + name: "delete and recreate", + input: "3\n", + setup: func(_ *testing.T, fixture *parityFixture) { + fixture.branchExists = true + }, + records: []parityOutputRecord{ + lineRecord("worktree creation", `\[DRY-RUN\] Would run: git worktree add .+`), + lineRecord("ready worktree", `Worktree ready at: .+`), + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + bashFixture, goFixture := newParityFixture(t), newParityFixture(t) + test.setup(t, &bashFixture) + test.setup(t, &goFixture) + args := []string{"1", "--agent", "none", "--no-init", "--dry-run"} + want := bashFixture.runBaseline(t, baseline, test.input, args...) + got := goFixture.runResult(test.input, args...) + assertParityResult(t, want, got, test.records) + }) } } @@ -586,12 +742,59 @@ func TestCLIParityInstallerWorkflow(t *testing.T) { } } +func TestInstallerChecksumNameExcludesURLQuery(t *testing.T) { + bash := requireBash(t) + assetName, err := releaseAssetName(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + asset := []byte("#!/bin/sh\n[ \"$1\" = --version ] && echo 'start-issue v2.1.0'\n") + checksum := fmt.Sprintf("%x %s\n", sha256.Sum256(asset), assetName) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Skipf("local HTTP listener unavailable: %v", err) + } + server := httptest.NewUnstartedServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/" + assetName: + if request.URL.Query().Get("token") != "presigned" { + http.Error(response, "missing token", http.StatusBadRequest) + return + } + _, _ = response.Write(asset) + case "/checksums.txt": + _, _ = response.Write([]byte(checksum)) + default: + http.NotFound(response, request) + } + })) + server.Listener = listener + server.Start() + defer server.Close() + + target := filepath.Join(t.TempDir(), "bin", "start-issue") + command := exec.Command(bash, filepath.Join(repoRoot(t), "install.sh")) + command.Env = append(os.Environ(), + "START_ISSUE_ASSET_URL="+server.URL+"/"+assetName+"?token=presigned", + "START_ISSUE_CHECKSUM_URL="+server.URL+"/checksums.txt", + "TARGET="+target, + "BINDIR="+filepath.Dir(target), + ) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("installer rejected the checksummed presigned asset URL: %v\n%s", err, output) + } +} + func TestInstallerDefaultsEachURLIndependently(t *testing.T) { bash := requireBash(t) root := repoRoot(t) bin := t.TempDir() writeExecutable(t, filepath.Join(bin, "curl"), "#!/bin/sh\nexit 1\n") - defaultAsset := "https://github.com/dapi/start-issue/releases/latest/download/" + releaseAssetName(runtime.GOOS, runtime.GOARCH) + assetName, err := releaseAssetName(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + defaultAsset := "https://github.com/dapi/start-issue/releases/latest/download/" + assetName defaultChecksum := "https://github.com/dapi/start-issue/releases/latest/download/checksums.txt" for _, test := range []struct { @@ -660,7 +863,10 @@ func TestCLIParityUpdateWorkflowPreservesInvocationSymlink(t *testing.T) { if err := os.Symlink(target, invocation); err != nil { t.Fatal(err) } - assetName := releaseAssetName(runtime.GOOS, runtime.GOARCH) + assetName, err := releaseAssetName(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } asset := []byte("#!/bin/sh\nif [ \"$1\" = --version ]; then echo 'start-issue v2.1.0'; exit 0; fi\n") checksum := fmt.Sprintf("%x %s\n", sha256.Sum256(asset), assetName) listener, err := net.Listen("tcp", "127.0.0.1:0") @@ -705,8 +911,57 @@ func TestCLIParityUpdateWorkflowPreservesInvocationSymlink(t *testing.T) { } } +func TestV1UpdateBridgeInstallsVerifiedPlatformBinary(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the v1 update bridge is a POSIX compatibility asset") + } + root, dir := repoRoot(t), t.TempDir() + assetName, err := releaseAssetName(runtime.GOOS, runtime.GOARCH) + if err != nil { + t.Skipf("unsupported test platform: %v", err) + } + asset := filepath.Join(dir, assetName) + assetBody := []byte("#!/bin/sh\n[ \"$1\" = --version ] && echo 'start-issue v2.0.0'\n") + if err := os.WriteFile(asset, assetBody, 0755); err != nil { + t.Fatal(err) + } + checksums := filepath.Join(dir, "checksums.txt") + if err := os.WriteFile(checksums, []byte(fmt.Sprintf("%x %s\n", sha256.Sum256(assetBody), assetName)), 0644); err != nil { + t.Fatal(err) + } + bridge := filepath.Join(dir, "start-issue") + body, err := os.ReadFile(filepath.Join(root, "scripts", "v1-upgrade-shim")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(bridge, body, 0755); err != nil { + t.Fatal(err) + } + command := exec.Command(requireBash(t), bridge, "--version") + command.Env = append(os.Environ(), "START_ISSUE_UPGRADE_ASSET_URL=file://"+asset, "START_ISSUE_UPGRADE_CHECKSUM_URL=file://"+checksums) + output, err := command.CombinedOutput() + if err != nil || strings.TrimSpace(string(output)) != "start-issue v2.0.0" { + t.Fatalf("v1 bridge = %q, %v", output, err) + } + installed, err := os.ReadFile(bridge) + if err != nil || string(installed) != string(assetBody) { + t.Fatalf("v1 bridge did not replace itself with the verified binary: %v", err) + } +} + +func TestGoReleaserBridgeChecksumNamesPublishedAsset(t *testing.T) { + config, err := os.ReadFile(filepath.Join(repoRoot(t), ".goreleaser.yaml")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(config), "(cd .release && sha256sum start-issue > start-issue.sha256)") { + t.Fatal("v1 bridge checksum must name start-issue, the published asset") + } +} + type parityFixture struct { home, repo, bin, worktrees, gitLog, ghLog, initMarker string + issueLabel string branchExists, pathBranch bool } @@ -750,14 +1005,14 @@ esac printf '%s\n' "$*" >> "$START_ISSUE_GH_LOG" if [ "$1" = auth ] && [ "$2" = status ]; then exit 0; fi if [ "$1" = api ]; then - printf '%s\n' '{"title":"Add login button","body":"Fixture body","labels":[{"name":"feature"}]}' + printf '{"title":"Add login button","body":"Fixture body","labels":[{"name":"%s"}]}\n' "${START_ISSUE_FAKE_LABEL:-feature}" fi `) writeExecutable(t, filepath.Join(fixture.bin, "jq"), `#!/bin/sh case "$*" in *".title"*) printf '%s\n' 'Add login button' ;; *".body"*) printf '%s\n' 'Fixture body' ;; - *".labels"*) printf '%s\n' 'feature' ;; + *".labels"*) printf '%s\n' "${START_ISSUE_FAKE_LABEL:-feature}" ;; esac `) writeExecutable(t, filepath.Join(fixture.bin, "zellij-tab-status"), "#!/bin/sh\nexit 0\n") @@ -795,11 +1050,13 @@ func (fixture parityFixture) result(output string, err error) parityResult { func normalizeRawParityOutput(output string, fixture parityFixture) string { stripANSI := regexp.MustCompile(`\x1b\[[0-9;]*m`) output = stripANSI.ReplaceAllString(output, "") - for _, path := range []string{ - canonicalPath(fixture.home), canonicalPath(fixture.repo), canonicalPath(fixture.worktrees), - fixture.home, fixture.repo, fixture.worktrees, - } { - output = strings.ReplaceAll(output, path, "") + // Preserve which semantic root owns a path. In particular, the worktree + // parent can be located inside the repository or home directory, so its + // more-specific path must be substituted before its containing root. + for _, root := range parityRoots(fixture) { + for _, path := range root.paths() { + output = strings.ReplaceAll(output, path, root.token) + } } return output } @@ -817,17 +1074,17 @@ func commandExitCode(err error) int { func parityFilesystem(fixture parityFixture) []string { var paths []string - for _, root := range []string{fixture.home, fixture.repo, fixture.worktrees} { - _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { - if err != nil || path == root { + for _, root := range parityRoots(fixture) { + _ = filepath.Walk(root.path, func(path string, info os.FileInfo, err error) error { + if err != nil || path == root.path { return err } - rel, relErr := filepath.Rel(root, path) + rel, relErr := filepath.Rel(root.path, path) if relErr == nil { if info.IsDir() { rel += "/" } - paths = append(paths, rel) + paths = append(paths, root.token+"/"+filepath.ToSlash(rel)) } return nil }) @@ -836,6 +1093,43 @@ func parityFilesystem(fixture parityFixture) []string { return paths } +type parityRoot struct { + token, path string +} + +func (root parityRoot) paths() []string { + paths := []string{canonicalPath(root.path), root.path} + sort.SliceStable(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) }) + return uniqueStrings(paths) +} + +func parityRoots(fixture parityFixture) []parityRoot { + roots := []parityRoot{ + {token: "", path: fixture.home}, + {token: "", path: fixture.repo}, + {token: "", path: fixture.worktrees}, + } + // Nested roots require the longest source paths to be normalized first; + // otherwise replacing a parent would erase the child's identity. + sort.SliceStable(roots, func(i, j int) bool { + return len(canonicalPath(roots[i].path)) > len(canonicalPath(roots[j].path)) + }) + return roots +} + +func uniqueStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + result = append(result, value) + } + return result +} + func (fixture parityFixture) runCommand(command *exec.Cmd, input string) (string, error) { command.Dir = fixture.repo command.Stdin = strings.NewReader(input) @@ -858,6 +1152,9 @@ func (fixture parityFixture) runCommand(command *exec.Cmd, input string) (string if fixture.branchExists || fixture.pathBranch { command.Env = append(command.Env, "START_ISSUE_FAKE_WORKTREE="+fixture.fakeWorktree()) } + if fixture.issueLabel != "" { + command.Env = append(command.Env, "START_ISSUE_FAKE_LABEL="+fixture.issueLabel) + } output, err := command.CombinedOutput() return string(output), err } diff --git a/cmd/start-issue/testdata/bash-v1/README.md b/cmd/start-issue/testdata/bash-v1/README.md new file mode 100644 index 0000000..47becd9 --- /dev/null +++ b/cmd/start-issue/testdata/bash-v1/README.md @@ -0,0 +1,6 @@ +# Frozen Bash parity baseline + +This directory preserves the pre-Go `scripts/` tree from commit +`d658db620836c4113e5a49326b5c69012c3e1f18`. The parity integration tests use +it as their immutable Bash oracle, so the test suite does not require Git +history to be available at runtime. diff --git a/cmd/start-issue/testdata/bash-v1/scripts/build-start-issue b/cmd/start-issue/testdata/bash-v1/scripts/build-start-issue new file mode 100755 index 0000000..d49c7b3 --- /dev/null +++ b/cmd/start-issue/testdata/bash-v1/scripts/build-start-issue @@ -0,0 +1,53 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +ENTRYPOINT="$ROOT_DIR/scripts/start-issue" +MODULE_DIR="$ROOT_DIR/scripts/lib/start_issue" +OUTPUT_PATH="${1:-$ROOT_DIR/.build/start-issue}" + +modules=( + utils.sh + config.sh + agent.sh + init.sh + github.sh + release.sh + update.sh + worktree.sh + output.sh + cli.sh + pipeline.sh +) + +mkdir -p "$(dirname "$OUTPUT_PATH")" + +{ + in_block=false + while IFS= read -r line; do + if [[ "$line" == "# BEGIN_MODULE_SOURCES" ]]; then + printf "%s\n" "$line" + for module in "${modules[@]}"; do + printf "\n# --- bundled from scripts/lib/start_issue/%s ---\n" "$module" + cat "$MODULE_DIR/$module" + printf "\n" + done + in_block=true + continue + fi + + if [[ "$line" == "# END_MODULE_SOURCES" ]]; then + in_block=false + continue + fi + + if [[ "$in_block" == "false" ]]; then + printf "%s\n" "$line" + fi + done < "$ENTRYPOINT" +} > "$OUTPUT_PATH" + +chmod +x "$OUTPUT_PATH" +printf "%s\n" "$OUTPUT_PATH" diff --git a/cmd/start-issue/testdata/bash-v1/scripts/bump-version b/cmd/start-issue/testdata/bash-v1/scripts/bump-version new file mode 100755 index 0000000..a8b3730 --- /dev/null +++ b/cmd/start-issue/testdata/bash-v1/scripts/bump-version @@ -0,0 +1,74 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/bump-version +EOF +} + +if [[ $# -ne 1 ]]; then + usage >&2 + exit 1 +fi + +kind="$1" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +version_file="$repo_root/scripts/start-issue" +file_mode="$(stat -f %Lp "$version_file")" + +current_version="$(awk -F'"' '/^VERSION="/ { print $2; exit }' "$version_file")" +if [[ -z "$current_version" ]]; then + echo "Could not read current version from $version_file" >&2 + exit 1 +fi + +IFS=. read -r major minor patch <<< "$current_version" + +case "$kind" in + patch) + patch=$((patch + 1)) + ;; + minor) + minor=$((minor + 1)) + patch=0 + ;; + major) + major=$((major + 1)) + minor=0 + patch=0 + ;; + *) + usage >&2 + exit 1 + ;; +esac + +next_version="$major.$minor.$patch" +tmpfile="$(mktemp)" +trap 'rm -f "$tmpfile"' EXIT + +awk -v next_version="$next_version" ' + BEGIN { replaced = 0 } + /^VERSION="[0-9]+\.[0-9]+\.[0-9]+"$/ && replaced == 0 { + print "VERSION=\"" next_version "\"" + replaced = 1 + next + } + { print } + END { + if (replaced != 1) { + exit 1 + } + } +' "$version_file" > "$tmpfile" || { + echo "Expected exactly one VERSION line in $version_file" >&2 + exit 1 +} + +mv "$tmpfile" "$version_file" +chmod "$file_mode" "$version_file" +trap - EXIT + +printf '%s\n' "$next_version" diff --git a/cmd/start-issue/testdata/bash-v1/scripts/check_memory_bank_index.py b/cmd/start-issue/testdata/bash-v1/scripts/check_memory_bank_index.py new file mode 100755 index 0000000..4120466 --- /dev/null +++ b/cmd/start-issue/testdata/bash-v1/scripts/check_memory_bank_index.py @@ -0,0 +1,845 @@ +#!/usr/bin/env python3 +"""Audit markdown navigation integrity for a memory-bank-like documentation tree.""" + +from __future__ import annotations + +import argparse +import json +import os +import posixpath +import re +import sys +from collections import defaultdict, deque +from pathlib import Path + + +IGNORED_DIRS = {".git", ".hg", ".svn", ".venv", "node_modules", "tmp", "log", "vendor"} + +DEFAULT_SCOPE_ROOT = "memory-bank" +DEFAULT_MAX_DEPTH = 3 + +FENCED_CODE_BLOCK_RE = re.compile(r"```.*?```", re.DOTALL) +MARKDOWN_LINK_RE = re.compile(r"(? argparse.Namespace: + parser = argparse.ArgumentParser( + description="Audit markdown navigation integrity for a memory-bank-like documentation tree." + ) + parser.add_argument( + "--repo-root", + help="Filesystem path to the repository root. Defaults to the current directory when it contains the scope root.", + ) + parser.add_argument( + "--scope-root", + default=DEFAULT_SCOPE_ROOT, + help="Repository-relative directory to audit. Default: %(default)s", + ) + parser.add_argument( + "--entrypoint", + action="append", + default=[], + help=( + "Markdown document to use as a navigation entrypoint. Accepts repo-relative paths " + "and scope-relative paths; ambiguous unqualified paths are resolved inside the scope first. " + "Use ./PATH or /PATH for explicit repo-root paths. Repeat the option to pass several files." + ), + ) + parser.add_argument( + "--max-depth", + type=int, + default=DEFAULT_MAX_DEPTH, + help="Maximum allowed navigation depth in link hops before the document becomes a warning. Default: %(default)s", + ) + parser.add_argument( + "--json", + action="store_true", + help="Emit a machine-readable JSON report instead of text.", + ) + args = parser.parse_args() + if args.max_depth < 0: + parser.error("--max-depth must be greater than or equal to 0") + return args + + +def normalize_scope_root(scope_root: str) -> str: + normalized = posixpath.normpath(scope_root.strip()) + if normalized in {"", "."}: + raise ValueError("--scope-root must point to a repository-relative directory") + return normalized.rstrip("/") + + +def resolve_repo_root(repo_root_arg: str | None, scope_root: str) -> Path: + if repo_root_arg: + return Path(repo_root_arg).resolve() + + candidates = [Path.cwd().resolve()] + if "__file__" in globals(): + script_path = Path(__file__) + if str(script_path) not in {"", ""}: + candidates.append(script_path.resolve().parents[1]) + + for candidate in candidates: + if (candidate / scope_root).exists(): + return candidate + + return candidates[0] + + +def discover_markdown_files(repo_root: Path) -> dict[str, Path]: + files: dict[str, Path] = {} + for root, dirs, filenames in os.walk(repo_root): + dirs[:] = [directory for directory in dirs if directory not in IGNORED_DIRS] + for filename in filenames: + if not filename.endswith(".md"): + continue + full_path = Path(root, filename) + relative_path = full_path.relative_to(repo_root).as_posix() + files[relative_path] = full_path + return files + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8", errors="ignore") + + +def strip_fenced_code_blocks(text: str) -> str: + return FENCED_CODE_BLOCK_RE.sub("", text) + + +def extract_markdown_link_destination(raw_url: str) -> str: + url = raw_url.strip() + if not url: + return "" + + if url.startswith("<"): + closing_index = url.find(">") + if closing_index != -1: + return url[1:closing_index].strip() + + return url.split(None, 1)[0].strip("<>") + + +def strip_frontmatter_value(value: str) -> str: + return value.strip().strip("\"'") + + +def parse_frontmatter_list_item(item: str) -> object: + if item.startswith("{") and item.endswith("}"): + fields: dict[str, str] = {} + for part in item.strip("{}").split(","): + if ":" not in part: + continue + key, value = part.split(":", 1) + fields[key.strip()] = strip_frontmatter_value(value) + return fields + + if item.startswith("path:"): + return {"path": strip_frontmatter_value(item.split(":", 1)[1])} + + return strip_frontmatter_value(item) + + +def parse_frontmatter(text: str) -> dict[str, object]: + match = FRONTMATTER_RE.match(text) + if not match: + return {} + + frontmatter: dict[str, object] = {} + current_key: str | None = None + for line in match.group(1).splitlines(): + if not line: + continue + stripped_line = line.strip() + if line.startswith((" ", "\t")) and stripped_line.startswith("- ") and current_key: + current_value = frontmatter.setdefault(current_key, []) + if not isinstance(current_value, list): + current_value = [] + frontmatter[current_key] = current_value + current_value.append(parse_frontmatter_list_item(stripped_line[2:].strip())) + continue + if line.startswith((" ", "\t", "-")) or ":" not in line: + continue + + key, value = line.split(":", 1) + current_key = key.strip() + stripped_value = value.strip() + if stripped_value: + frontmatter[current_key] = strip_frontmatter_value(stripped_value) + else: + frontmatter[current_key] = "" + return frontmatter + + +def normalize_internal_markdown_target(source_path: str, raw_url: str) -> str | None: + url = extract_markdown_link_destination(raw_url) + if not url or url.startswith(("http://", "https://", "mailto:", "#")): + return None + + url = url.split("#", 1)[0].split("?", 1)[0].strip() + if not url: + return None + + extension = posixpath.splitext(url)[1].lower() + if extension and extension != ".md": + return None + + base_dir = posixpath.dirname(source_path) + if url.startswith("/"): + resolved = posixpath.normpath(url.lstrip("/")) + else: + resolved = posixpath.normpath(posixpath.join(base_dir, url)) + + if not extension: + resolved = posixpath.join(resolved, "README.md") + + return resolved + + +def normalize_cli_document_path(raw_path: str) -> str | None: + path = raw_path.strip().strip("<>") + if not path: + return None + + path = path.split("#", 1)[0].split("?", 1)[0].strip() + if not path: + return None + + extension = posixpath.splitext(path)[1].lower() + if extension and extension != ".md": + return None + + normalized = posixpath.normpath(path.lstrip("/")) + if normalized in {"", "."}: + return None + + if not extension: + normalized = posixpath.join(normalized, "README.md") + + return normalized + + +def extract_internal_markdown_links(source_path: str, text: str) -> list[str]: + stripped_text = strip_fenced_code_blocks(text) + links: list[str] = [] + for match in MARKDOWN_LINK_RE.finditer(stripped_text): + target = normalize_internal_markdown_target(source_path, match.group(2)) + if target is None: + continue + links.append(target) + return links + + +def extract_derived_from_paths(frontmatter: dict[str, object]) -> list[str]: + raw_value = frontmatter.get("derived_from") + if raw_value is None: + return [] + + values = raw_value if isinstance(raw_value, list) else [raw_value] + paths: list[str] = [] + for value in values: + if isinstance(value, str): + if value: + paths.append(value) + continue + if isinstance(value, dict): + path = value.get("path") + if isinstance(path, str) and path: + paths.append(path) + return paths + + +def validate_frontmatter_dependencies( + documents: dict[str, dict[str, object]], + scope_root: str, +) -> list[dict[str, str]]: + known_paths = set(documents) + issues: list[dict[str, str]] = [] + + for source_path, document in documents.items(): + if not is_scoped_markdown(source_path, scope_root): + continue + + frontmatter = document["frontmatter"] + assert isinstance(frontmatter, dict) + for raw_path in extract_derived_from_paths(frontmatter): + target = normalize_internal_markdown_target(source_path, raw_path) + if target is None: + continue + if target not in known_paths: + issues.append( + { + "source": source_path, + "field": "derived_from", + "value": raw_path, + "target": target, + } + ) + + return issues + + +def load_documents(repo_root: Path) -> dict[str, dict[str, object]]: + documents: dict[str, dict[str, object]] = {} + for relative_path, full_path in discover_markdown_files(repo_root).items(): + text = read_text(full_path) + documents[relative_path] = { + "full_path": full_path, + "text": text, + "frontmatter": parse_frontmatter(text), + } + return documents + + +def is_scoped_markdown(path: str, scope_root: str) -> bool: + return path.startswith(f"{scope_root}/") and path.endswith(".md") + + +def is_scoped_readme(path: str, scope_root: str) -> bool: + return is_scoped_markdown(path, scope_root) and posixpath.basename(path) == "README.md" + + +def resolve_entrypoint_path(entrypoint: str, scope_root: str, known_paths: set[str]) -> tuple[str | None, str]: + primary_candidate = normalize_cli_document_path(entrypoint) + + scoped_input = posixpath.join(scope_root, entrypoint.lstrip("/")) + scoped_candidate = normalize_cli_document_path(scoped_input) + normalized_input = entrypoint.strip().strip("<>") + explicit_repo_root = normalized_input.startswith(("/", "./")) + already_scoped = primary_candidate is not None and ( + primary_candidate == scope_root or primary_candidate.startswith(f"{scope_root}/") + ) + + if not explicit_repo_root and not already_scoped and scoped_candidate and scoped_candidate in known_paths: + return scoped_candidate, entrypoint + + if primary_candidate and primary_candidate in known_paths: + return primary_candidate, entrypoint + + if scoped_candidate and scoped_candidate in known_paths: + return scoped_candidate, entrypoint + + fallback = primary_candidate or scoped_candidate or entrypoint + return None, fallback + + +def derive_entrypoints( + documents: dict[str, dict[str, object]], + scope_root: str, + configured_entrypoints: list[str], +) -> tuple[list[str], list[str]]: + known_paths = set(documents) + resolved: list[str] = [] + missing: list[str] = [] + + if configured_entrypoints: + for entrypoint in configured_entrypoints: + resolved_path, missing_hint = resolve_entrypoint_path(entrypoint, scope_root, known_paths) + if resolved_path is None: + missing.append(missing_hint) + continue + if resolved_path not in resolved: + resolved.append(resolved_path) + return resolved, missing + + default_entrypoint = f"{scope_root}/README.md" + if default_entrypoint in known_paths: + return [default_entrypoint], [] + + return [], [default_entrypoint] + + +def build_link_graph( + documents: dict[str, dict[str, object]], + scope_root: str, +) -> tuple[dict[str, set[str]], dict[str, set[str]], dict[str, set[str]]]: + outgoing: dict[str, set[str]] = defaultdict(set) + incoming_in_scope: dict[str, set[str]] = defaultdict(set) + broken_links: dict[str, set[str]] = defaultdict(set) + known_paths = set(documents) + + for source_path, document in documents.items(): + text = document["text"] + assert isinstance(text, str) + for target in extract_internal_markdown_links(source_path, text): + if target in known_paths: + outgoing[source_path].add(target) + if ( + source_path != target + and is_scoped_markdown(source_path, scope_root) + and is_scoped_markdown(target, scope_root) + ): + incoming_in_scope[target].add(source_path) + elif is_scoped_markdown(source_path, scope_root): + broken_links[source_path].add(target) + + return outgoing, incoming_in_scope, broken_links + + +def derive_index_paths(documents: dict[str, dict[str, object]], scope_root: str) -> list[str]: + index_paths: list[str] = [] + for path, document in documents.items(): + if not is_scoped_markdown(path, scope_root): + continue + frontmatter = document["frontmatter"] + assert isinstance(frontmatter, dict) + if frontmatter.get("doc_function") == "index": + index_paths.append(path) + return sorted(index_paths) + + +def derive_expected_readme_indices(documents: dict[str, dict[str, object]], scope_root: str) -> list[str]: + readmes: list[str] = [] + for path, document in documents.items(): + if not is_scoped_readme(path, scope_root): + continue + frontmatter = document["frontmatter"] + assert isinstance(frontmatter, dict) + if frontmatter.get("doc_function") == "template": + continue + if frontmatter.get("doc_kind") == "feature-support" and frontmatter.get("doc_function") == "reference": + continue + readmes.append(path) + return sorted(readmes) + + +def annotation_text_for_child_links(index_path: str, text: str) -> list[tuple[str, str]]: + section_prefix = posixpath.dirname(index_path) + stripped_lines = strip_fenced_code_blocks(text).splitlines() + annotations: list[tuple[str, str]] = [] + + index_line = 0 + while index_line < len(stripped_lines): + line = stripped_lines[index_line] + match = BULLET_LINK_RE.match(line) + if not match: + index_line += 1 + continue + + target = normalize_internal_markdown_target(index_path, match.group(2)) + if target is None: + index_line += 1 + continue + + child_prefix = f"{section_prefix}/" + if section_prefix and not target.startswith(child_prefix): + index_line += 1 + continue + + fragments: list[str] = [] + inline_annotation = MARKDOWN_LINK_RE.sub("", line).strip(" -\t:") + if inline_annotation: + fragments.append(inline_annotation) + + continuation_index = index_line + 1 + while continuation_index < len(stripped_lines): + continuation = stripped_lines[continuation_index] + if not continuation.strip(): + break + if continuation.startswith((" ", "\t")): + fragments.append(continuation.strip()) + continuation_index += 1 + continue + break + + annotations.append((target, " ".join(fragments).strip())) + index_line += 1 + + return annotations + + +def validate_index_document(index_path: str, documents: dict[str, dict[str, object]]) -> list[str]: + document = documents.get(index_path) + if document is None: + return ["missing expected index file"] + + text = document["text"] + frontmatter = document["frontmatter"] + assert isinstance(text, str) + assert isinstance(frontmatter, dict) + + issues: list[str] = [] + if not frontmatter: + issues.append("missing YAML frontmatter") + purpose = frontmatter.get("purpose", "") + if not isinstance(purpose, str) or not purpose.strip(): + issues.append("missing 'purpose' in frontmatter") + if frontmatter.get("doc_function") != "index": + issues.append("expected `doc_function: index`") + + for target, annotation in annotation_text_for_child_links(index_path, text): + normalized_annotation = re.sub(r"\s+", " ", annotation).strip(" -:\t").lower() + basename = posixpath.basename(target).lower() + basename_without_extension = posixpath.splitext(basename)[0] + if not normalized_annotation: + issues.append(f"missing annotation for child link -> {target}") + continue + if normalized_annotation in {basename, basename_without_extension}: + issues.append(f"annotation repeats filename for child link -> {target}") + continue + if len(normalized_annotation) < 12: + issues.append(f"annotation too short for child link -> {target}") + + return issues + + +def expected_parent_index(path: str, index_paths: set[str], scope_root: str) -> str | None: + if not is_scoped_markdown(path, scope_root): + return None + + current_dir = posixpath.dirname(path) + if posixpath.basename(path) == "README.md": + current_dir = posixpath.dirname(current_dir) + + while current_dir and current_dir != ".": + candidate = posixpath.join(current_dir, "README.md") + if candidate in index_paths and candidate != path: + return candidate + parent_dir = posixpath.dirname(current_dir) + if parent_dir == current_dir: + break + current_dir = parent_dir + + return None + + +def build_navigation_reachability( + outgoing: dict[str, set[str]], + navigation_nodes: set[str], + entrypoints: list[str], +) -> dict[str, dict[str, object]]: + reachable: dict[str, dict[str, object]] = {} + navigation_depths: dict[str, int] = {} + queue: deque[str] = deque() + + for entrypoint in entrypoints: + reachable[entrypoint] = {"depth": 0, "route": [entrypoint]} + navigation_depths[entrypoint] = 0 + queue.append(entrypoint) + + while queue: + current = queue.popleft() + current_info = reachable[current] + current_depth = navigation_depths[current] + current_route = current_info["route"] + assert isinstance(current_route, list) + + for target in sorted(outgoing.get(current, set())): + candidate_depth = current_depth + 1 + candidate_route = current_route + [target] + best = reachable.get(target) + if best is None or candidate_depth < best["depth"]: + reachable[target] = {"depth": candidate_depth, "route": candidate_route} + if target in navigation_nodes: + best_depth = navigation_depths.get(target) + if best_depth is None or candidate_depth < best_depth: + navigation_depths[target] = candidate_depth + queue.append(target) + + return reachable + + +def flatten_broken_links(broken_links: dict[str, set[str]]) -> list[dict[str, str]]: + items: list[dict[str, str]] = [] + for source_path in sorted(broken_links): + for target in sorted(broken_links[source_path]): + items.append({"source": source_path, "target": target}) + return items + + +def build_report( + repo_root: Path, + scope_root: str, + entrypoints: list[str], + missing_entrypoints: list[str], + max_depth: int, + documents: dict[str, dict[str, object]], +) -> dict[str, object]: + scoped_markdown_paths = sorted(path for path in documents if is_scoped_markdown(path, scope_root)) + index_paths = derive_index_paths(documents, scope_root) + expected_readme_indices = derive_expected_readme_indices(documents, scope_root) + outgoing, incoming_in_scope, broken_links = build_link_graph(documents, scope_root) + + report: dict[str, object] = { + "format_version": 1, + "repo_root": str(repo_root), + "scope_root": scope_root, + "entrypoints": entrypoints, + "missing_entrypoints": missing_entrypoints, + "max_depth": max_depth, + "stats": { + "markdown_files_in_scope": len(scoped_markdown_paths), + "index_documents_in_scope": len(index_paths), + }, + "errors": { + "config": [], + "broken_links": flatten_broken_links(broken_links), + "frontmatter_dependencies": validate_frontmatter_dependencies(documents, scope_root), + "orphans": [], + "unreachable": [], + "index_contract": [], + }, + "warnings": { + "deep_reachable": [], + }, + } + + config_errors = report["errors"]["config"] + assert isinstance(config_errors, list) + if missing_entrypoints: + config_errors.append( + { + "message": "Configured entrypoints are missing.", + "paths": missing_entrypoints, + } + ) + if not scoped_markdown_paths: + config_errors.append( + { + "message": "Scope contains no markdown files.", + "paths": [scope_root], + } + ) + if not entrypoints: + config_errors.append( + { + "message": "No valid entrypoints were resolved.", + "paths": missing_entrypoints or [f"{scope_root}/README.md"], + } + ) + + index_paths_set = set(index_paths) + entrypoint_set = set(entrypoints) + + if entrypoints and scoped_markdown_paths: + navigation_nodes = set(index_paths) | entrypoint_set + reachable = build_navigation_reachability(outgoing, navigation_nodes, entrypoints) + + for path in scoped_markdown_paths: + inbound_sources = sorted(incoming_in_scope.get(path, set())) + parent_index = expected_parent_index(path, index_paths_set, scope_root) + if path not in entrypoint_set and not inbound_sources: + report["errors"]["orphans"].append( + { + "path": path, + "expected_parent_index": parent_index, + "inbound_links": inbound_sources, + } + ) + + reachability = reachable.get(path) + if reachability is None: + report["errors"]["unreachable"].append( + { + "path": path, + "expected_parent_index": parent_index, + "inbound_links": inbound_sources, + } + ) + continue + + depth = reachability["depth"] + route = reachability["route"] + assert isinstance(depth, int) + assert isinstance(route, list) + if depth > max_depth: + report["warnings"]["deep_reachable"].append( + { + "path": path, + "depth": depth, + "max_depth": max_depth, + "expected_parent_index": parent_index, + "route": route, + } + ) + + for index_path in expected_readme_indices: + issues = validate_index_document(index_path, documents) + if not issues: + continue + report["errors"]["index_contract"].append( + { + "path": index_path, + "issues": issues, + "expected_parent_index": expected_parent_index(index_path, index_paths_set, scope_root), + } + ) + + warnings = report["warnings"]["deep_reachable"] + assert isinstance(warnings, list) + warnings.sort(key=lambda item: (item["depth"], item["path"])) + + stats = report["stats"] + assert isinstance(stats, dict) + errors = report["errors"] + assert isinstance(errors, dict) + stats.update( + { + "broken_link_count": len(errors["broken_links"]), + "frontmatter_dependency_issue_count": len(errors["frontmatter_dependencies"]), + "orphan_count": len(errors["orphans"]), + "unreachable_count": len(errors["unreachable"]), + "index_contract_issue_count": len(errors["index_contract"]), + "deep_reachable_warning_count": len(warnings), + "entrypoint_count": len(entrypoints), + } + ) + + has_errors = any( + bool(errors[key]) + for key in ("config", "broken_links", "frontmatter_dependencies", "orphans", "unreachable", "index_contract") + ) + report["exit_code"] = 1 if has_errors else 0 + return report + + +def format_route(route: list[str]) -> str: + return " -> ".join(route) + + +def print_text_report(report: dict[str, object]) -> None: + print("Memory-bank link audit") + print(f"Repo root: {report['repo_root']}") + print(f"Scope root: {report['scope_root']}") + print(f"Entrypoints: {', '.join(report['entrypoints']) or '(none)'}") + print(f"Navigation depth threshold: {report['max_depth']}") + + stats = report["stats"] + errors = report["errors"] + warnings = report["warnings"] + assert isinstance(stats, dict) + assert isinstance(errors, dict) + assert isinstance(warnings, dict) + + print(f"Markdown files in scope: {stats['markdown_files_in_scope']}") + print(f"Index documents in scope: {stats['index_documents_in_scope']}") + print() + + config_errors = errors["config"] + assert isinstance(config_errors, list) + if config_errors: + print("Configuration errors:") + for item in config_errors: + print(f" - {item['message']}") + for path in item["paths"]: + print(f" * {path}") + print() + + broken_links = errors["broken_links"] + assert isinstance(broken_links, list) + if broken_links: + print("Broken internal markdown links:") + for item in broken_links: + print(f" - {item['source']} -> {item['target']}") + print() + else: + print("OK: no broken internal markdown links in scope.") + print() + + frontmatter_dependencies = errors["frontmatter_dependencies"] + assert isinstance(frontmatter_dependencies, list) + if frontmatter_dependencies: + print("Broken frontmatter markdown dependencies:") + for item in frontmatter_dependencies: + print(f" - {item['source']} {item['field']}: {item['value']} -> {item['target']}") + print() + else: + print("OK: no broken frontmatter markdown dependencies in scope.") + print() + + orphans = errors["orphans"] + assert isinstance(orphans, list) + if orphans: + print("Orphan markdown files in scope:") + for item in orphans: + print(f" - {item['path']}") + print(f" expected_parent_index: {item['expected_parent_index'] or '(none)'}") + print() + else: + print("OK: no orphan markdown files in scope.") + print() + + unreachable = errors["unreachable"] + assert isinstance(unreachable, list) + if unreachable: + print("Markdown files missing from index navigation:") + for item in unreachable: + print(f" - {item['path']}") + print(f" expected_parent_index: {item['expected_parent_index'] or '(none)'}") + inbound_links = item["inbound_links"] + if inbound_links: + print(f" inbound_links: {', '.join(inbound_links)}") + print() + else: + print("OK: all scoped markdown files are reachable from the configured entrypoints via index navigation.") + print() + + deep_reachable = warnings["deep_reachable"] + assert isinstance(deep_reachable, list) + if deep_reachable: + print("Warnings: documents reachable only deeper than the configured threshold:") + for item in deep_reachable: + print(f" - {item['path']}") + print(f" depth: {item['depth']}") + print(f" expected_parent_index: {item['expected_parent_index'] or '(none)'}") + print(f" route: {format_route(item['route'])}") + print() + else: + print("OK: no documents are reachable only deeper than the configured threshold.") + print() + + index_contract = errors["index_contract"] + assert isinstance(index_contract, list) + print("Index compliance:") + if index_contract: + for item in index_contract: + print(f" - {item['path']}") + for issue in item["issues"]: + print(f" * {issue}") + print() + else: + print(" - OK") + print() + + exit_code = report["exit_code"] + assert isinstance(exit_code, int) + result = "FAIL" if exit_code else "OK" + print(f"Result: {result}") + print("Machine-readable output: re-run with --json for a structured report suitable for auto-indexing.") + + +def main() -> int: + args = parse_args() + + try: + scope_root = normalize_scope_root(args.scope_root) + except ValueError as error: + print(str(error), file=sys.stderr) + return 1 + + repo_root = resolve_repo_root(args.repo_root, scope_root) + documents = load_documents(repo_root) + entrypoints, missing_entrypoints = derive_entrypoints(documents, scope_root, args.entrypoint) + report = build_report( + repo_root=repo_root, + scope_root=scope_root, + entrypoints=entrypoints, + missing_entrypoints=missing_entrypoints, + max_depth=args.max_depth, + documents=documents, + ) + + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2)) + else: + print_text_report(report) + + exit_code = report["exit_code"] + assert isinstance(exit_code, int) + return exit_code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/agent.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/agent.sh new file mode 100644 index 0000000..a13ee5b --- /dev/null +++ b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/agent.sh @@ -0,0 +1,474 @@ +# shellcheck shell=bash disable=SC2034 +agent_supports_explicit_model_selection() { + local operation="$1" + + case "$operation" in + launch|branch-name|prompt-improvement) + ;; + *) + return 1 + ;; + esac + + case "$AGENT" in + claude|codex|kimi|pi) + return 0 + ;; + *) + return 1 + ;; + esac +} + +validate_model_selection_support() { + local operation="$1" + + if [[ -z "$MODEL" || "$AGENT" == "none" ]]; then + return 0 + fi + + if agent_supports_explicit_model_selection "$operation"; then + return 0 + fi + + die "Agent '$AGENT' does not support explicit model selection for $operation." +} + +claude_noninteractive_model() { + if [[ -n "$MODEL" ]]; then + printf "%s" "$MODEL" + else + printf "%s" "haiku" + fi +} + +validate_prompt_improvement_mode() { + if [[ "$IMPROVE_PROMPT" != "true" ]]; then + return + fi + + if [[ "$AGENT" == "none" ]]; then + die "--improve-prompt requires an agent. Use --agent claude, codex, kimi, or pi." + fi +} + +validate_human_gate_mode() { + if [[ "$HUMAN_GATE_MODE" != "true" ]]; then + return + fi + + if [[ "$AGENT" != "codex" ]]; then + die "--human-gate requires agent 'codex'. Current agent: $AGENT." + fi +} + +human_gate_run_id() { + if [[ -n "$HUMAN_GATE_RUN_ID" ]]; then + printf "%s" "$HUMAN_GATE_RUN_ID" + return + fi + + if [[ -n "${START_ISSUE_RUN_ID:-}" ]]; then + HUMAN_GATE_RUN_ID="$START_ISSUE_RUN_ID" + else + HUMAN_GATE_RUN_ID="$(date +%Y%m%d-%H%M%S)" + fi + + printf "%s" "$HUMAN_GATE_RUN_ID" +} + +prepare_human_gate_state_paths() { + local run_id + run_id="$(human_gate_run_id)" + + HUMAN_GATE_STATE_DIR="$WORKTREE_PATH/.start-issue/runs/$run_id" + HUMAN_GATE_EVENTS_PATH="$HUMAN_GATE_STATE_DIR/events.jsonl" + HUMAN_GATE_LAST_MESSAGE_PATH="$HUMAN_GATE_STATE_DIR/last-message.txt" + HUMAN_GATE_THREAD_ID_PATH="$HUMAN_GATE_STATE_DIR/thread-id" +} + +build_human_gate_command() { + HUMAN_GATE_CMD=() + validate_human_gate_mode + prepare_human_gate_state_paths + + if [[ -n "$MODEL" ]]; then + HUMAN_GATE_CMD=( + codex exec + --model "$MODEL" + --cd "$WORKTREE_PATH" + --ask-for-approval never + --sandbox workspace-write + --json + --output-last-message "$HUMAN_GATE_LAST_MESSAGE_PATH" + - + ) + else + HUMAN_GATE_CMD=( + codex exec + --cd "$WORKTREE_PATH" + --ask-for-approval never + --sandbox workspace-write + --json + --output-last-message "$HUMAN_GATE_LAST_MESSAGE_PATH" + - + ) + fi +} + +capture_human_gate_thread_id() { + HUMAN_GATE_THREAD_ID="" + + if [[ ! -f "$HUMAN_GATE_EVENTS_PATH" ]]; then + return 1 + fi + + HUMAN_GATE_THREAD_ID="$( + jq -r 'select(.type == "thread.started") | .thread_id // empty' "$HUMAN_GATE_EVENTS_PATH" 2>/dev/null | head -n 1 + )" + + [[ -n "$HUMAN_GATE_THREAD_ID" ]] || return 1 + + printf "%s\n" "$HUMAN_GATE_THREAD_ID" > "$HUMAN_GATE_THREAD_ID_PATH" +} + +parse_human_gate_final_status() { + HUMAN_GATE_FINAL_STATUS="" + + if [[ ! -f "$HUMAN_GATE_LAST_MESSAGE_PATH" ]]; then + return 1 + fi + + HUMAN_GATE_FINAL_STATUS="$( + awk ' + /^STATUS:[[:space:]]*/ { + sub(/^STATUS:[[:space:]]*/, "") + gsub(/[[:space:]]+$/, "") + print + exit + } + ' "$HUMAN_GATE_LAST_MESSAGE_PATH" + )" + + case "$HUMAN_GATE_FINAL_STATUS" in + DONE|HUMAN_GATE) + return 0 + ;; + *) + return 1 + ;; + esac +} + +run_codex_human_gate_session() { + local batch_exit=0 + + log_info "🤖 Starting codex human-gate batch session..." + build_human_gate_command + + echo " State dir: $HUMAN_GATE_STATE_DIR" + + if [[ "$DRY_RUN" == "true" ]]; then + print_dry_run_human_gate_command + return + fi + + mkdir -p "$HUMAN_GATE_STATE_DIR" + + if printf "%s" "$AGENT_PROMPT" | "${HUMAN_GATE_CMD[@]}" > "$HUMAN_GATE_EVENTS_PATH"; then + : + else + batch_exit=$? + fi + + if ! capture_human_gate_thread_id; then + if [[ $batch_exit -ne 0 ]]; then + echo " Codex batch exit code: $batch_exit" + fi + die "Codex human-gate run did not capture thread_id. Inspect: $HUMAN_GATE_EVENTS_PATH" + fi + + echo " Thread ID: $HUMAN_GATE_THREAD_ID" + + if ! parse_human_gate_final_status; then + if [[ $batch_exit -ne 0 ]]; then + echo " Codex batch exit code: $batch_exit" + fi + die "No recognized final status found. Inspect: $HUMAN_GATE_LAST_MESSAGE_PATH" + fi + + case "$HUMAN_GATE_FINAL_STATUS" in + DONE) + log_success "✅ Codex finished with STATUS: DONE" + echo " Last message: $HUMAN_GATE_LAST_MESSAGE_PATH" + return 0 + ;; + HUMAN_GATE) + log_info "🧭 Codex finished with STATUS: HUMAN_GATE" + echo " Resume command: codex resume --include-non-interactive $HUMAN_GATE_THREAD_ID" + if codex resume --include-non-interactive "$HUMAN_GATE_THREAD_ID"; then + return 0 + fi + log_error "Could not open Codex resume session." + echo "Resume command: codex resume --include-non-interactive $HUMAN_GATE_THREAD_ID" + echo "Thread ID: $HUMAN_GATE_THREAD_ID" + return 2 + ;; + *) + die "Unsupported human-gate final status: $HUMAN_GATE_FINAL_STATUS" + ;; + esac +} + +default_prompt_improvement_output_path() { + if [[ -n "$PROMPT_IMPROVEMENT_OUTPUT_FILE" ]]; then + printf "%s" "$PROMPT_IMPROVEMENT_OUTPUT_FILE" + return + fi + + if [[ -n "$PROMPT_TEMPLATE_PATH" ]]; then + local dir + local file + local stem + + dir=$(dirname "$PROMPT_TEMPLATE_PATH") + file=$(basename "$PROMPT_TEMPLATE_PATH") + if [[ "$file" == *.md ]]; then + stem="${file%.md}" + printf "%s/%s.improved.md" "$dir" "$stem" + else + printf "%s/%s.improved" "$dir" "$file" + fi + return + fi + + printf "%s/.start-issue/prompt.improved.md" "$PROJECT_ROOT" +} + +prompt_improvement_request() { + cat << EOF +Improve the following start-issue prompt template. + +Return ONLY the complete improved prompt template. Do not include commentary, code fences, diffs, or explanations. + +Preserve any placeholders that are still useful. Supported placeholders: +{ISSUE_URL}, {ISSUE_NUMBER}, {ISSUE_TITLE}, {ISSUE_BODY}, {ISSUE_LABELS}, {REPO}, {BRANCH_NAME}, {WORKTREE_PATH}, {BASE_BRANCH} + +Prompt source: +$PROMPT_SOURCE + +Repository: +$REPO + +Current issue used as improvement context: +- URL: $ISSUE_URL +- Number: $ISSUE_NUMBER +- Title: $ISSUE_TITLE +- Labels: $ISSUE_LABELS +- Body: +$ISSUE_BODY + +Current prompt template: +--- START PROMPT TEMPLATE --- +$PROMPT_TEMPLATE +--- END PROMPT TEMPLATE --- +EOF +} + +agent_supports_operation() { + local operation="$1" + + case "$operation" in + validate|launch|branch-name|prompt-improvement) + ;; + *) + return 1 + ;; + esac + + case "$AGENT" in + claude|codex|kimi|pi) + return 0 + ;; + none) + [[ "$operation" == "validate" ]] && return 0 + return 1 + ;; + *) + return 1 + ;; + esac +} + +generate_improved_prompt_template() { + local request + local output + + request=$(prompt_improvement_request) + validate_model_selection_support "prompt-improvement" + + case "$AGENT" in + claude) + output=$(claude --print --model "$(claude_noninteractive_model)" --no-session-persistence \ + --disable-slash-commands "$request" 2>/dev/null) || return 1 + ;; + codex) + if [[ -n "$MODEL" ]]; then + output=$(codex exec --model "$MODEL" --cd "$PROJECT_ROOT" --sandbox read-only \ + --skip-git-repo-check "$request" 2>/dev/null) || return 1 + else + output=$(codex exec --cd "$PROJECT_ROOT" --sandbox read-only \ + --skip-git-repo-check "$request" 2>/dev/null) || return 1 + fi + ;; + kimi) + if [[ -n "$MODEL" ]]; then + output=$(kimi --model "$MODEL" --work-dir "$PROJECT_ROOT" --quiet -p "$request" 2>/dev/null) || return 1 + else + output=$(kimi --work-dir "$PROJECT_ROOT" --quiet -p "$request" 2>/dev/null) || return 1 + fi + ;; + pi) + if [[ -n "$MODEL" ]]; then + output=$(pi --model "$MODEL" --print --no-tools --no-session "$request" 2>/dev/null) || return 1 + else + output=$(pi --print --no-tools --no-session "$request" 2>/dev/null) || return 1 + fi + ;; + *) + return 1 + ;; + esac + + output=$(printf "%s" "$output" | sed '1{/^```[[:alnum:]_-]*$/d;}; ${/^```$/d;}') + [[ -n "$(trim "$output")" ]] || return 1 + printf "%s" "$output" +} + +generate_ai_branch_name() { + local prompt="Git branch name for issue #$ISSUE_NUMBER: \"$ISSUE_TITLE\" (labels: $ISSUE_LABELS). +Format: {type}/issue-$ISSUE_NUMBER-{kebab-case-name} +Types: bug/fix -> fix, enhancement -> feature, hotfix -> hotfix, docs -> docs, refactor -> refactor, test -> test, chore -> chore, default -> feature. +If the title contains non-English text (e.g. Cyrillic), transliterate it to English for the kebab-case name. +Strip leading bracketed process/stage tags (e.g. [brief], [investigation], [PR-008]) from the kebab-case name — they mark workflow stage. The {type} still comes from the labels above. +Reply with ONLY the branch name." + local output="" + + if ! agent_supports_operation "branch-name"; then + return 1 + fi + + if ! command -v "$AGENT" &> /dev/null; then + return 1 + fi + + validate_model_selection_support "branch-name" + + case "$AGENT" in + claude) + output=$(claude --print --model "$(claude_noninteractive_model)" --no-session-persistence \ + --disable-slash-commands "$prompt" 2>/dev/null) || return 1 + ;; + codex) + if [[ -n "$MODEL" ]]; then + output=$(codex exec --model "$MODEL" --cd "$PROJECT_ROOT" --sandbox read-only \ + --skip-git-repo-check "$prompt" 2>/dev/null | tail -n 1) || return 1 + else + output=$(codex exec --cd "$PROJECT_ROOT" --sandbox read-only \ + --skip-git-repo-check "$prompt" 2>/dev/null | tail -n 1) || return 1 + fi + ;; + kimi) + if [[ -n "$MODEL" ]]; then + output=$(kimi --model "$MODEL" --work-dir "$PROJECT_ROOT" --quiet -p "$prompt" 2>/dev/null) || return 1 + else + output=$(kimi --work-dir "$PROJECT_ROOT" --quiet -p "$prompt" 2>/dev/null) || return 1 + fi + ;; + pi) + if [[ -n "$MODEL" ]]; then + output=$(pi --model "$MODEL" --print --no-tools --no-session "$prompt" 2>/dev/null | tail -n 1) || return 1 + else + output=$(pi --print --no-tools --no-session "$prompt" 2>/dev/null | tail -n 1) || return 1 + fi + ;; + *) + return 1 + ;; + esac + + BRANCH_NAME=$(printf "%s" "$output" | tr -d '`"' | awk 'NF { last=$0 } END { print last }' | xargs) + + [[ -n "$BRANCH_NAME" ]] +} + +improve_prompt_template() { + local output_path + output_path=$(default_prompt_improvement_output_path) + + log_info "📝 Improving prompt template..." + echo " Prompt source: $PROMPT_SOURCE" + echo " Proposal path: $output_path" + + if [[ "$DRY_RUN" == "true" ]]; then + echo " [DRY-RUN] Would ask $AGENT to generate an improved prompt proposal." + return + fi + + if [[ -e "$output_path" ]]; then + die "Prompt improvement output already exists: $output_path" + fi + + local improved_prompt + improved_prompt=$(generate_improved_prompt_template) || \ + die "Could not generate improved prompt with $AGENT" + + mkdir -p "$(dirname "$output_path")" + printf "%s\n" "$improved_prompt" > "$output_path" + log_success " ✅ Prompt improvement written" + echo " Review the proposal and copy it to the active prompt file if accepted." +} + +build_launch_command() { + LAUNCH_CWD="" + LAUNCH_CMD=() + validate_model_selection_support "launch" + + case "$AGENT" in + claude) + LAUNCH_CWD="$WORKTREE_PATH" + if [[ -n "$MODEL" ]]; then + LAUNCH_CMD=(claude --model "$MODEL" --dangerously-skip-permissions "$AGENT_PROMPT") + else + LAUNCH_CMD=(claude --dangerously-skip-permissions "$AGENT_PROMPT") + fi + ;; + codex) + if [[ -n "$MODEL" ]]; then + LAUNCH_CMD=(codex --model "$MODEL" --cd "$WORKTREE_PATH" --dangerously-bypass-approvals-and-sandbox "$AGENT_PROMPT") + else + LAUNCH_CMD=(codex --cd "$WORKTREE_PATH" --dangerously-bypass-approvals-and-sandbox "$AGENT_PROMPT") + fi + ;; + kimi) + if [[ -n "$MODEL" ]]; then + LAUNCH_CMD=(kimi --model "$MODEL" --work-dir "$WORKTREE_PATH" --yolo -p "$AGENT_PROMPT") + else + LAUNCH_CMD=(kimi --work-dir "$WORKTREE_PATH" --yolo -p "$AGENT_PROMPT") + fi + ;; + pi) + LAUNCH_CWD="$WORKTREE_PATH" + if [[ -n "$MODEL" ]]; then + LAUNCH_CMD=(pi --model "$MODEL" "$AGENT_PROMPT") + else + LAUNCH_CMD=(pi "$AGENT_PROMPT") + fi + ;; + none) + ;; + *) + die "Unknown agent: $AGENT" + ;; + esac +} diff --git a/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/cli.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/cli.sh new file mode 100644 index 0000000..a8c33e8 --- /dev/null +++ b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/cli.sh @@ -0,0 +1,186 @@ +# shellcheck shell=bash disable=SC2034 +require_value() { + local option="$1" + local value="${2:-}" + + if [[ -z "$value" || "$value" == -* ]]; then + die "$option requires a value." + fi +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case $1 in + --repo|-r) + require_value "$1" "${2:-}" + REPO="$2" + shift 2 + ;; + --base|-b) + require_value "$1" "${2:-}" + BASE_BRANCH="$2" + shift 2 + ;; + --worktree-dir|-w) + require_value "$1" "${2:-}" + WORKTREE_DIR="$2" + WORKTREE_DIR_SOURCE="CLI" + shift 2 + ;; + --agent) + require_value "$1" "${2:-}" + AGENT_CLI="$2" + shift 2 + ;; + --model) + require_value "$1" "${2:-}" + MODEL_CLI="$2" + shift 2 + ;; + --no-agent|--no-claude) + AGENT_CLI="none" + shift + ;; + --prompt-file) + require_value "$1" "${2:-}" + PROMPT_FILE_CLI="$2" + shift 2 + ;; + --prompt) + require_value "$1" "${2:-}" + PROMPT_INLINE_CLI="$2" + shift 2 + ;; + --improve-prompt) + IMPROVE_PROMPT=true + shift + ;; + --human-gate) + HUMAN_GATE_MODE=true + shift + ;; + --human-gate-help) + HUMAN_GATE_HELP=true + shift + ;; + --prompt-output-file) + require_value "$1" "${2:-}" + PROMPT_IMPROVEMENT_OUTPUT_FILE="$2" + shift 2 + ;; + --no-init) + NO_INIT=true + shift + ;; + --flat) + FLAT_WORKTREE=true + shift + ;; + --command|-c) + require_value "$1" "${2:-}" + INITIAL_COMMAND="$2" + shift 2 + ;; + --ai) + FAST_MODE=false + shift + ;; + --project) + if [[ "$INIT_SCOPE" == "user" ]]; then + die "Use either --project or --user, not both." + fi + INIT_SCOPE="project" + shift + ;; + --user) + if [[ "$INIT_SCOPE" == "project" ]]; then + die "Use either --project or --user, not both." + fi + INIT_SCOPE="user" + shift + ;; + --force) + INIT_FORCE=true + shift + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --version|-v) + show_version + exit 0 + ;; + --setup) + SETUP_MODE=true + shift + ;; + --update) + UPDATE_MODE=true + shift + ;; + --help|-h) + show_help + exit 0 + ;; + -*) + die "Unknown option: $1. Use --help for usage." + ;; + *) + if [[ "$1" == "init" && -z "$ISSUE_INPUT" && "$INIT_CONFIG" == "false" ]]; then + INIT_CONFIG=true + elif [[ "$1" == "setup" && -z "$ISSUE_INPUT" && "$INIT_CONFIG" == "false" && "$SETUP_MODE" == "false" && "$UPDATE_MODE" == "false" ]]; then + SETUP_MODE=true + elif [[ "$1" == "update" && -z "$ISSUE_INPUT" && "$INIT_CONFIG" == "false" && "$UPDATE_MODE" == "false" ]]; then + UPDATE_MODE=true + elif [[ "$INIT_CONFIG" == "true" ]]; then + die "Unexpected argument for init: $1" + elif [[ "$SETUP_MODE" == "true" ]]; then + die "Unexpected argument for setup: $1" + elif [[ "$UPDATE_MODE" == "true" ]]; then + die "Unexpected argument for update: $1" + elif [[ -z "$ISSUE_INPUT" ]]; then + ISSUE_INPUT="$1" + else + die "Unexpected argument: $1" + fi + shift + ;; + esac + done + + if [[ "$INIT_CONFIG" == "true" ]]; then + if [[ "$SETUP_MODE" == "true" ]]; then + die "Use either init or setup, not both." + fi + if [[ "$UPDATE_MODE" == "true" ]]; then + die "Use either init or update, not both." + fi + return + fi + + if [[ "$SETUP_MODE" == "true" ]]; then + if [[ "$UPDATE_MODE" == "true" ]]; then + die "Use either setup or update, not both." + fi + if [[ -n "$ISSUE_INPUT" ]]; then + die "Use either setup or , not both." + fi + return + fi + + if [[ "$UPDATE_MODE" == "true" ]]; then + if [[ -n "$ISSUE_INPUT" ]]; then + die "Use either update or , not both." + fi + return + fi + + if [[ -n "$INIT_SCOPE" || "$INIT_FORCE" == "true" ]]; then + die "--project, --user, and --force are only valid with init." + fi + + if [[ -z "$ISSUE_INPUT" ]]; then + MISSING_ISSUE=true + fi +} diff --git a/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/config.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/config.sh new file mode 100644 index 0000000..33a68a5 --- /dev/null +++ b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/config.sh @@ -0,0 +1,191 @@ +# shellcheck shell=bash disable=SC2034 +check_selected_agent_dependency() { + if [[ "$AGENT" == "none" || "$DRY_RUN" == "true" ]]; then + return + fi + + if ! command -v "$AGENT" &> /dev/null; then + die "$AGENT CLI not found. Install it or use --agent none." + fi +} + +validate_agent() { + case "$AGENT" in + claude|codex|kimi|pi|none) + ;; + *) + die "Unknown agent: $AGENT. Valid agents: claude, codex, kimi, pi, none." + ;; + esac +} + +validate_model_config() { + if [[ -z "$MODEL" ]]; then + die "Model config is empty. Remove the empty model config or set a value." + fi +} + +resolve_agent() { + local project_agent_file="$PROJECT_ROOT/.start-issue/agent" + local user_agent_file="$HOME/.config/start-issue/agent" + + if [[ -n "$AGENT_CLI" ]]; then + AGENT="$AGENT_CLI" + AGENT_SOURCE="CLI" + elif [[ -f "$project_agent_file" ]]; then + AGENT=$(read_first_config_value "$project_agent_file") + AGENT_SOURCE="$project_agent_file" + elif [[ -f "$user_agent_file" ]]; then + AGENT=$(read_first_config_value "$user_agent_file") + AGENT_SOURCE="$user_agent_file" + elif [[ -n "${START_ISSUE_AGENT:-}" ]]; then + AGENT=$(trim "$START_ISSUE_AGENT") + AGENT_SOURCE="START_ISSUE_AGENT" + else + AGENT="claude" + AGENT_SOURCE="built-in default" + fi + + if [[ -z "$AGENT" ]]; then + die "Agent config is empty. Valid agents: claude, codex, kimi, pi, none." + fi + + validate_agent +} + +resolve_model() { + local project_model_file="$PROJECT_ROOT/.start-issue/model" + local user_model_file="$HOME/.config/start-issue/model" + + if [[ -n "$MODEL_CLI" ]]; then + MODEL=$(trim "$MODEL_CLI") + MODEL_SOURCE="CLI" + elif [[ -f "$project_model_file" ]]; then + MODEL=$(read_first_config_value "$project_model_file") + MODEL_SOURCE="$project_model_file" + elif [[ -f "$user_model_file" ]]; then + MODEL=$(read_first_config_value "$user_model_file") + MODEL_SOURCE="$user_model_file" + elif [[ -n "${START_ISSUE_MODEL:-}" ]]; then + MODEL=$(trim "$START_ISSUE_MODEL") + MODEL_SOURCE="START_ISSUE_MODEL" + else + MODEL="" + MODEL_SOURCE="built-in default" + fi + + if [[ -n "$MODEL_SOURCE" && "$MODEL_SOURCE" != "built-in default" ]]; then + validate_model_config + fi +} + +default_portable_prompt_template() { + cat << 'EOF' +Implement GitHub issue {ISSUE_URL} in this worktree. + +Context: +- Repo: {REPO} +- Issue: #{ISSUE_NUMBER} +- Title: {ISSUE_TITLE} +- Branch: {BRANCH_NAME} +- Worktree: {WORKTREE_PATH} + +Start by reading the issue with gh if needed. Follow repository instructions. Keep changes scoped. Run relevant tests or checks. Summarize changed files and verification before finishing. +If you open a PR for this work, target the base branch {BASE_BRANCH}. +EOF +} + +default_claude_prompt_template() { + if [[ -n "$INITIAL_COMMAND" ]]; then + printf "%s {ISSUE_URL}" "$INITIAL_COMMAND" + else + printf "/task-router:route-task {ISSUE_URL}" + fi +} + +read_prompt_file() { + local path="$1" + + if [[ ! -f "$path" ]]; then + die "Prompt file not found: $path" + fi + + if [[ ! -r "$path" ]]; then + die "Prompt file is not readable: $path" + fi + + PROMPT_TEMPLATE=$(< "$path") +} + +resolve_prompt_template() { + local project_prompt_file="$PROJECT_ROOT/.start-issue/prompt.md" + local user_prompt_file="$HOME/.config/start-issue/prompt.md" + + PROMPT_LOCATION="" + PROMPT_TEMPLATE_PATH="" + + if [[ -n "$PROMPT_FILE_CLI" && -n "$PROMPT_INLINE_CLI" ]]; then + die "Use either --prompt-file or --prompt, not both." + fi + + if [[ -n "$PROMPT_FILE_CLI" ]]; then + read_prompt_file "$PROMPT_FILE_CLI" + PROMPT_SOURCE="CLI --prompt-file: $PROMPT_FILE_CLI" + PROMPT_LOCATION=$(absolute_path "$PROMPT_FILE_CLI") + PROMPT_TEMPLATE_PATH="$PROMPT_LOCATION" + elif [[ -n "$PROMPT_INLINE_CLI" ]]; then + PROMPT_TEMPLATE="$PROMPT_INLINE_CLI" + PROMPT_SOURCE="CLI --prompt" + PROMPT_LOCATION="inline CLI argument" + elif [[ -n "${START_ISSUE_PROMPT_FILE:-}" || -n "${START_ISSUE_PROMPT:-}" ]]; then + if [[ -n "${START_ISSUE_PROMPT_FILE:-}" && -n "${START_ISSUE_PROMPT:-}" ]]; then + die "Use either START_ISSUE_PROMPT_FILE or START_ISSUE_PROMPT, not both." + fi + + if [[ -n "${START_ISSUE_PROMPT_FILE:-}" ]]; then + read_prompt_file "$START_ISSUE_PROMPT_FILE" + PROMPT_SOURCE="START_ISSUE_PROMPT_FILE: $START_ISSUE_PROMPT_FILE" + PROMPT_LOCATION=$(absolute_path "$START_ISSUE_PROMPT_FILE") + PROMPT_TEMPLATE_PATH="$PROMPT_LOCATION" + else + PROMPT_TEMPLATE="$START_ISSUE_PROMPT" + PROMPT_SOURCE="START_ISSUE_PROMPT" + PROMPT_LOCATION="START_ISSUE_PROMPT environment variable" + fi + elif [[ -f "$project_prompt_file" ]]; then + read_prompt_file "$project_prompt_file" + PROMPT_SOURCE="$project_prompt_file" + PROMPT_LOCATION="$project_prompt_file" + PROMPT_TEMPLATE_PATH="$PROMPT_LOCATION" + elif [[ -f "$user_prompt_file" ]]; then + read_prompt_file "$user_prompt_file" + PROMPT_SOURCE="$user_prompt_file" + PROMPT_LOCATION="$user_prompt_file" + PROMPT_TEMPLATE_PATH="$PROMPT_LOCATION" + else + if [[ "$AGENT" == "claude" ]]; then + PROMPT_TEMPLATE=$(default_claude_prompt_template) + PROMPT_SOURCE="built-in Claude command" + else + PROMPT_TEMPLATE=$(default_portable_prompt_template) + PROMPT_SOURCE="built-in portable prompt" + fi + PROMPT_LOCATION="$SCRIPT_PATH" + fi +} + +render_prompt_template() { + local rendered="$PROMPT_TEMPLATE" + + rendered="${rendered//\{ISSUE_URL\}/$ISSUE_URL}" + rendered="${rendered//\{ISSUE_NUMBER\}/$ISSUE_NUMBER}" + rendered="${rendered//\{ISSUE_TITLE\}/$ISSUE_TITLE}" + rendered="${rendered//\{ISSUE_BODY\}/$ISSUE_BODY}" + rendered="${rendered//\{ISSUE_LABELS\}/$ISSUE_LABELS}" + rendered="${rendered//\{REPO\}/$REPO}" + rendered="${rendered//\{BRANCH_NAME\}/$BRANCH_NAME}" + rendered="${rendered//\{WORKTREE_PATH\}/$WORKTREE_PATH}" + rendered="${rendered//\{BASE_BRANCH\}/$BASE_BRANCH}" + + AGENT_PROMPT="$rendered" +} diff --git a/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/github.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/github.sh new file mode 100644 index 0000000..e10534f --- /dev/null +++ b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/github.sh @@ -0,0 +1,69 @@ +# shellcheck shell=bash disable=SC2034 +parse_issue_input() { + local input="$1" + + if [[ "$input" =~ ^https://github\.com/([^/]+)/([^/]+)/issues/([0-9]+) ]]; then + REPO="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}" + ISSUE_NUMBER="${BASH_REMATCH[3]}" + elif [[ "$input" =~ ^[0-9]+$ ]]; then + ISSUE_NUMBER="$input" + else + die "Invalid issue format: $input. Use issue number or full GitHub URL." + fi +} + +detect_repo_from_remote() { + if [[ -n "$REPO" ]]; then + return + fi + + local remote_url + remote_url=$(git remote get-url origin 2>/dev/null || echo "") + + if [[ -z "$remote_url" ]]; then + die "Cannot detect repository. No 'origin' remote found. Use --repo flag." + fi + + if [[ "$remote_url" =~ git@github\.com:([^/]+)/(.+)$ ]]; then + REPO="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}" + REPO="${REPO%.git}" + elif [[ "$remote_url" =~ https://github\.com/([^/]+)/(.+)$ ]]; then + REPO="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}" + REPO="${REPO%.git}" + else + die "Cannot parse repository from remote URL: $remote_url. Use --repo flag." + fi +} + +detect_base_branch() { + if [[ -n "$BASE_BRANCH" ]]; then + return + fi + + local remote_head + remote_head=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||' || true) + if [[ -n "$remote_head" ]]; then + BASE_BRANCH="$remote_head" + return + fi + + BASE_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "HEAD") + log_info "Could not detect default branch, using current: $BASE_BRANCH" +} + +fetch_issue() { + log_info "🔍 Fetching issue #$ISSUE_NUMBER from $REPO..." + + ISSUE_JSON=$(gh api "repos/$REPO/issues/$ISSUE_NUMBER" 2>/dev/null) || \ + die "Issue #$ISSUE_NUMBER not found in $REPO" + + ISSUE_TITLE=$(echo "$ISSUE_JSON" | jq -r '.title') + ISSUE_BODY=$(echo "$ISSUE_JSON" | jq -r '.body // ""') + ISSUE_LABELS=$(echo "$ISSUE_JSON" | jq -r '[.labels[].name] | join(", ")') + ISSUE_URL="https://github.com/$REPO/issues/$ISSUE_NUMBER" + + echo " Title: $ISSUE_TITLE" + if [[ -n "$ISSUE_LABELS" ]]; then + echo " Labels: $ISSUE_LABELS" + fi +} diff --git a/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/init.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/init.sh new file mode 100644 index 0000000..abb6de5 --- /dev/null +++ b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/init.sh @@ -0,0 +1,379 @@ +# shellcheck shell=bash disable=SC2034 +USER_CONFIG_DIR="$HOME/.config/start-issue" +SETUP_AGENT_FILE_VALUE="" +SETUP_SAVE_PROMPT=false + +confirm_yes_default() { + local prompt="$1" + local reply="" + + printf "%s" "$prompt" + if ! read -r reply; then + die "No response received." + fi + + case "$reply" in + ""|y|Y|yes|YES|Yes) + return 0 + ;; + n|N|no|NO|No) + return 1 + ;; + *) + die "Invalid response: $reply. Use y or n." + ;; + esac +} + +ensure_directory_exists() { + local path="$1" + local label="$2" + + if [[ "$DRY_RUN" == "true" ]]; then + echo " [DRY-RUN] Would create $label: $path" + return + fi + + mkdir -p "$path" +} + +write_setup_file() { + local path="$1" + local content="$2" + local label="$3" + + if [[ "$DRY_RUN" == "true" ]]; then + echo " [DRY-RUN] Would write $label: $path" + return + fi + + mkdir -p "$(dirname "$path")" + printf "%s\n" "$content" > "$path" + log_success " Wrote $label: $path" +} + +remove_setup_file_if_present() { + local path="$1" + local label="$2" + + if [[ ! -e "$path" ]]; then + if [[ "$DRY_RUN" == "true" ]]; then + echo " [DRY-RUN] No $label to remove: $path" + fi + return + fi + + if [[ "$DRY_RUN" == "true" ]]; then + echo " [DRY-RUN] Would remove $label: $path" + return + fi + + rm -f "$path" + log_success " Removed $label: $path" +} + +select_setup_agent() { + local choice="" + + echo "Select default agent:" + echo "1) claude" + echo "2) codex" + echo "3) kimi" + echo "4) pi" + echo "5) skip" + echo "" + + printf "Choice: " + if ! read -r choice; then + die "No setup agent selected." + fi + + case "$choice" in + 1|claude|Claude) + AGENT="claude" + AGENT_SOURCE="setup selection" + SETUP_AGENT_FILE_VALUE="claude" + ;; + 2|codex|Codex) + AGENT="codex" + AGENT_SOURCE="setup selection" + SETUP_AGENT_FILE_VALUE="codex" + ;; + 3|kimi|Kimi) + AGENT="kimi" + AGENT_SOURCE="setup selection" + SETUP_AGENT_FILE_VALUE="kimi" + ;; + 4|pi|Pi) + AGENT="pi" + AGENT_SOURCE="setup selection" + SETUP_AGENT_FILE_VALUE="pi" + ;; + 5|skip|Skip|"") + AGENT="claude" + AGENT_SOURCE="built-in default" + SETUP_AGENT_FILE_VALUE="" + ;; + *) + die "Invalid setup choice: $choice. Use 1-5." + ;; + esac +} + +resolve_setup_prompt_template() { + if [[ "$AGENT" == "claude" ]]; then + PROMPT_TEMPLATE=$(default_claude_prompt_template) + PROMPT_SOURCE="built-in Claude command" + else + PROMPT_TEMPLATE=$(default_portable_prompt_template) + PROMPT_SOURCE="built-in portable prompt" + fi +} + +show_setup_prompt_preview() { + echo "Default prompt:" + echo "" + printf "%s\n" "$PROMPT_TEMPLATE" + echo "" +} + +run_setup_flow() { + local target_dir="$USER_CONFIG_DIR" + local agent_file="$target_dir/agent" + local prompt_file="$target_dir/prompt.md" + + ensure_directory_exists "$target_dir" "user config directory" + select_setup_agent + resolve_setup_prompt_template + show_setup_prompt_preview + + if confirm_yes_default "Save this prompt to $prompt_file? [Y/n] "; then + SETUP_SAVE_PROMPT=true + else + SETUP_SAVE_PROMPT=false + fi + + echo "Directory: $target_dir" + echo "Agent: ${SETUP_AGENT_FILE_VALUE:-}" + echo "Prompt source: $PROMPT_SOURCE" + echo "" + + if [[ -n "$SETUP_AGENT_FILE_VALUE" ]]; then + write_setup_file "$agent_file" "$SETUP_AGENT_FILE_VALUE" "agent config" + else + remove_setup_file_if_present "$agent_file" "agent config" + fi + + if [[ "$SETUP_SAVE_PROMPT" == "true" ]]; then + write_setup_file "$prompt_file" "$PROMPT_TEMPLATE" "prompt template" + else + remove_setup_file_if_present "$prompt_file" "prompt template" + fi +} + +materialize_first_run_marker() { + ensure_directory_exists "$USER_CONFIG_DIR" "user config directory" +} + +resolve_init_model() { + local model_file="$1" + + if [[ -f "$model_file" && "$INIT_FORCE" != "true" ]]; then + MODEL=$(read_first_config_value "$model_file") + MODEL_SOURCE="$model_file (existing)" + elif [[ -n "$MODEL_CLI" ]]; then + MODEL=$(trim "$MODEL_CLI") + MODEL_SOURCE="CLI" + else + MODEL="" + MODEL_SOURCE="built-in default" + fi + + if [[ -n "$MODEL_SOURCE" && "$MODEL_SOURCE" != "built-in default" ]]; then + validate_model_config + fi +} + +resolve_init_agent() { + local agent_file="$1" + + if [[ -f "$agent_file" && "$INIT_FORCE" != "true" ]]; then + AGENT=$(read_first_config_value "$agent_file") + AGENT_SOURCE="$agent_file (existing)" + elif [[ -n "$AGENT_CLI" ]]; then + AGENT=$(trim "$AGENT_CLI") + AGENT_SOURCE="CLI" + else + AGENT="claude" + AGENT_SOURCE="built-in default" + fi + + if [[ -z "$AGENT" ]]; then + die "Agent config is empty. Valid agents: claude, codex, kimi, pi, none." + fi + + validate_agent +} + +resolve_init_prompt_template() { + if [[ -n "$PROMPT_FILE_CLI" && -n "$PROMPT_INLINE_CLI" ]]; then + die "Use either --prompt-file or --prompt, not both." + fi + + if [[ -n "$PROMPT_FILE_CLI" ]]; then + read_prompt_file "$PROMPT_FILE_CLI" + PROMPT_SOURCE="CLI --prompt-file: $PROMPT_FILE_CLI" + elif [[ -n "$PROMPT_INLINE_CLI" ]]; then + PROMPT_TEMPLATE="$PROMPT_INLINE_CLI" + PROMPT_SOURCE="CLI --prompt" + elif [[ "$AGENT" == "claude" ]]; then + PROMPT_TEMPLATE=$(default_claude_prompt_template) + PROMPT_SOURCE="built-in Claude command" + else + PROMPT_TEMPLATE=$(default_portable_prompt_template) + PROMPT_SOURCE="built-in portable prompt" + fi +} + +select_init_scope() { + local project_available=false + local choice="" + + if git rev-parse --git-dir &> /dev/null; then + detect_project_root + project_available=true + fi + + echo "Initialize start-issue configuration:" + if [[ "$project_available" == "true" ]]; then + echo " 1) Project config ($PROJECT_ROOT/.start-issue)" + echo " 2) User config ($HOME/.config/start-issue)" + if ! read -r -p "Choice [1/2]: " choice; then + die "No init scope selected. Use --project or --user." + fi + + case "$choice" in + 1|p|P|project|Project) + INIT_SCOPE="project" + ;; + 2|u|U|user|User) + INIT_SCOPE="user" + ;; + *) + die "Invalid init scope: $choice. Use --project or --user." + ;; + esac + else + echo " 1) User config ($HOME/.config/start-issue)" + if ! read -r -p "Choice [1]: " choice; then + die "No init scope selected. Use --user outside a git repository." + fi + + case "$choice" in + ""|1|u|U|user|User) + INIT_SCOPE="user" + ;; + *) + die "Project config requires a git repository. Use --user outside a git repository." + ;; + esac + fi +} + +write_init_file() { + local path="$1" + local content="$2" + local label="$3" + + if [[ -e "$path" && "$INIT_FORCE" != "true" ]]; then + log_warn "$label already exists, keeping: $path" + return + fi + + if [[ "$DRY_RUN" == "true" ]]; then + echo " [DRY-RUN] Would write $label: $path" + return + fi + + mkdir -p "$(dirname "$path")" + printf "%s\n" "$content" > "$path" + log_success " Wrote $label: $path" +} + +write_init_model_file() { + local path="$1" + + if [[ -n "$MODEL" ]]; then + write_init_file "$path" "$MODEL" "model config" + return + fi + + if [[ -e "$path" && "$INIT_FORCE" != "true" ]]; then + log_warn "model config already exists, keeping: $path" + return + fi + + if [[ "$DRY_RUN" == "true" ]]; then + if [[ -e "$path" ]]; then + echo " [DRY-RUN] Would remove model config: $path" + else + echo " [DRY-RUN] No model config to write (built-in default: unset)" + fi + return + fi + + if [[ -e "$path" ]]; then + rm -f "$path" + log_success " Removed model config: $path" + fi +} + +run_config_init() { + if [[ -z "$INIT_SCOPE" ]]; then + select_init_scope + fi + + case "$INIT_SCOPE" in + project) + check_git_repo + detect_project_root + ;; + user) ;; + *) + die "Invalid init scope: $INIT_SCOPE. Use --project or --user." + ;; + esac + + local target_dir="" + local scope_label="" + + if [[ "$INIT_SCOPE" == "project" ]]; then + target_dir="$PROJECT_ROOT/.start-issue" + scope_label="project config" + else + target_dir="$HOME/.config/start-issue" + scope_label="user config" + fi + + resolve_init_agent "$target_dir/agent" + resolve_init_model "$target_dir/model" + validate_model_selection_support "launch" + resolve_init_prompt_template + + echo "Scope: $scope_label" + echo "Directory: $target_dir" + echo "Agent: $AGENT" + echo "Agent source: $AGENT_SOURCE" + echo "Model: ${MODEL:-}" + echo "Model source: $MODEL_SOURCE" + echo "Prompt source: $PROMPT_SOURCE" + echo "" + + write_init_file "$target_dir/agent" "$AGENT" "agent config" + write_init_model_file "$target_dir/model" + write_init_file "$target_dir/prompt.md" "$PROMPT_TEMPLATE" "prompt template" +} + +run_setup_mode() { + run_setup_flow +} diff --git a/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/output.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/output.sh new file mode 100644 index 0000000..f1a3fc3 --- /dev/null +++ b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/output.sh @@ -0,0 +1,439 @@ +# shellcheck shell=bash disable=SC2153 +model_display_value() { + if [[ -n "$MODEL" ]]; then + printf "%s" "$MODEL" + else + printf "%s" "" + fi +} + +show_help() { + show_version + cat << 'EOF' + +Start working on a GitHub issue with git worktree and a configurable agent + +Usage: start-issue [options] + start-issue init [options] + start-issue setup [options] + start-issue update [options] + +Arguments: + GitHub issue URL or issue number + Examples: 123, https://github.com/owner/repo/issues/123 + init Create default start-issue configuration + setup Run first-run user configuration onboarding + update Update the running start-issue installation + +Options: + --repo, -r Repository (default: detected from git remote) + --base, -b Base branch (default: main or master) + --worktree-dir, -w Directory for worktrees + Default: START_ISSUE_WORKTREE_DIR or ~/worktrees + --flat Use flat worktree structure (replace / with - in path) + --agent Agent to launch: claude, codex, kimi, pi, none + With init: default agent to write + --model Model to use for the selected agent + With init: default model config to write + --no-agent Only create worktree, do not start an agent session + --no-claude Compatibility alias for --no-agent + --prompt Prompt template for the launched agent + --prompt-file Prompt template file for the launched agent + --improve-prompt Ask the selected agent to improve the selected + prompt template and write a reviewable proposal + --human-gate Codex-only batch mode that resumes on HUMAN_GATE + --human-gate-help Show detailed help for the human-gate mode + --prompt-output-file + Output path for --improve-prompt proposal + --no-init Skip init.sh execution + --command, -c Compatibility: initial command for Claude default launch + --ai Use the selected agent for branch name generation + Default: fast bash heuristics + --project With init: write .start-issue config in this repo + --user With init: write config in ~/.config/start-issue + --force With init: overwrite existing config files + --dry-run Show what would be done without executing + --setup Run first-run user configuration onboarding + --update Update the running start-issue installation + --version, -v Show version + --help, -h Show this help + +Agent selection precedence: + CLI --agent / --no-agent + .start-issue/agent in the git root + ~/.config/start-issue/agent + START_ISSUE_AGENT + built-in default: claude + +Model selection precedence: + CLI --model + .start-issue/model in the git root + ~/.config/start-issue/model + START_ISSUE_MODEL + built-in default: unset (agent CLI decides) + +Prompt template precedence: + CLI --prompt-file / --prompt + .start-issue/prompt.md in the git root + ~/.config/start-issue/prompt.md + START_ISSUE_PROMPT_FILE / START_ISSUE_PROMPT + built-in default + +Prompt improvement: + --improve-prompt uses the selected agent to generate a complete improved + prompt template proposal. It does not overwrite the active prompt template. + File-backed prompts write next to the source as *.improved.md by default. + Built-in and inline prompts write to .start-issue/prompt.improved.md by + default. Use --prompt-output-file to choose another proposal path. + +Prompt variables: + {ISSUE_URL}, {ISSUE_NUMBER}, {ISSUE_TITLE}, {ISSUE_BODY}, {ISSUE_LABELS}, + {REPO}, {BRANCH_NAME}, {WORKTREE_PATH}, {BASE_BRANCH} + +Environment variables: + START_ISSUE_AGENT + START_ISSUE_MODEL + START_ISSUE_PROMPT + START_ISSUE_PROMPT_FILE + START_ISSUE_WORKTREE_DIR + START_ISSUE_DUMP_PROMPT + +Examples: + start-issue 123 + start-issue https://github.com/owner/repo/issues/123 + start-issue 123 --repo owner/repo --base develop + start-issue 123 --agent codex + start-issue 123 --agent codex --model gpt-5.2 + start-issue 123 --agent codex --human-gate + start-issue 123 --agent claude --model sonnet + start-issue 123 --agent kimi --prompt-file .start-issue/prompt.md + start-issue 123 --no-agent # Only create worktree + start-issue 123 --command "/debug" # Claude command prefix + start-issue 123 --flat # Flat worktree path + start-issue 123 --dry-run + start-issue init + start-issue setup + start-issue init --project --agent codex --model gpt-5.2 + start-issue init --project --agent codex + start-issue init --user --force + start-issue --setup + start-issue update + start-issue --update + start-issue --human-gate-help +EOF +} + +show_human_gate_help() { + show_version + cat << 'EOF' + +Codex human-gate mode + +Usage: + start-issue --agent codex --human-gate + start-issue --human-gate-help + +Flow: + 1. Resolve the issue, repo, branch, worktree, and prompt exactly like the normal issue flow. + 2. Create or reuse the worktree and run init.sh when enabled. + 3. Render the selected prompt. + 4. Run Codex in non-interactive batch mode with JSON events and a saved last-message file. + 5. Parse the saved final status. + 6. Exit 0 on STATUS: DONE. + 7. Resume the same Codex session with codex resume --include-non-interactive on STATUS: HUMAN_GATE. + +Prompt contract: + The final output must contain exactly one terminal status line: + STATUS: DONE + or: + STATUS: HUMAN_GATE + + DONE means the issue was completed safely without user intervention. + HUMAN_GATE means Codex must stop and ask one concrete user decision. + Do not use HUMAN_GATE for ordinary implementation uncertainty that can be + resolved from repository conventions or local evidence. + +Suggested final output shape for DONE: + STATUS: DONE + + Summary: + - ... + + Validation: + - ... + + Changed files: + - ... + +Suggested final output shape for HUMAN_GATE: + STATUS: HUMAN_GATE + + Blocker: + ... + + Question: + ... + + Options: + - Option A: ... + - Option B: ... + + Recommendation: + ... + +Exit codes: + 0 Codex returned STATUS: DONE. + 1 Codex failed, no thread_id was captured, no recognized status was found, + or parsing failed. + 2 Codex returned STATUS: HUMAN_GATE but start-issue could not open + interactive resume. The command and thread id are printed for manual use. + +State artifacts: + /.start-issue/runs//events.jsonl + /.start-issue/runs//last-message.txt + /.start-issue/runs//thread-id + +Examples: + start-issue 123 --agent codex --human-gate + start-issue https://github.com/owner/repo/issues/123 --agent codex --human-gate + start-issue --human-gate-help + +Troubleshooting: + - If status parsing fails, inspect last-message.txt. + - If resume cannot be opened automatically, re-run the printed + codex resume --include-non-interactive command. + - This mode is Codex-only in the current implementation. +EOF +} + +show_current_configuration() { + echo "Current configuration:" + echo " Agent: $AGENT" + echo " Agent source: $AGENT_SOURCE" + echo " Model: $(model_display_value)" + echo " Model source: $MODEL_SOURCE" + echo " Prompt source: $PROMPT_SOURCE" + echo " Prompt location: $PROMPT_LOCATION" + print_agent_model_file_locations " " + echo " Worktree dir: $WORKTREE_DIR ($WORKTREE_DIR_SOURCE)" + print_prompt_file_locations " " +} + +show_missing_issue_summary() { + echo "Error: missing issue URL or issue number" + echo "" + echo "Run \`start-issue --help\` for full usage and prompt variables." +} + +show_missing_issue_help() { + show_version + cat << 'EOF' + +Start working on a GitHub issue with git worktree and a configurable agent + +Usage: start-issue [options] + start-issue init [options] + start-issue setup [options] + start-issue update [options] + +Examples: + start-issue 123 + start-issue https://github.com/owner/repo/issues/123 + start-issue 123 --agent codex + start-issue setup + start-issue init --project + +Prompt variables: + {ISSUE_URL}, {ISSUE_NUMBER}, {ISSUE_TITLE}, {ISSUE_BODY}, {ISSUE_LABELS}, + {REPO}, {BRANCH_NAME}, {WORKTREE_PATH}, {BASE_BRANCH} +EOF +} + +show_first_run_onboarding_prompt() { + echo "Configuration is not initialized yet." + echo "" + echo "Usage: start-issue [options]" + echo "" +} + +print_agent_model_file_locations() { + local indent="${1:-}" + + echo "${indent}Default agent/model files:" + echo "${indent} Project agent: $PROJECT_ROOT/.start-issue/agent" + echo "${indent} Project model: $PROJECT_ROOT/.start-issue/model" + echo "${indent} User agent: $HOME/.config/start-issue/agent" + echo "${indent} User model: $HOME/.config/start-issue/model" +} + +print_prompt_file_locations() { + local indent="${1:-}" + + echo "${indent}Default prompt files:" + echo "${indent} Project: $PROJECT_ROOT/.start-issue/prompt.md" + echo "${indent} User: $HOME/.config/start-issue/prompt.md" +} + +print_terminal_status() { + echo " $1" +} + +print_session_header() { + local term_width + local min_width=60 + local display_path="${WORKTREE_PATH/#$HOME/\~}" + local line1="Agent: $AGENT" + local line2="Branch: $BRANCH_NAME" + local line3="Issue: #$ISSUE_NUMBER - $ISSUE_TITLE" + local line4="Path: $display_path" + local max_content_width + local h_line="" + local i + + term_width=$(tput cols 2>/dev/null || echo 80) + [[ $term_width -lt $min_width ]] && term_width=$min_width + + max_content_width=$((term_width - 4)) + [[ ${#line2} -gt $max_content_width ]] && line2="${line2:0:$((max_content_width - 3))}..." + [[ ${#line3} -gt $max_content_width ]] && line3="${line3:0:$((max_content_width - 3))}..." + [[ ${#line4} -gt $max_content_width ]] && line4="${line4:0:$((max_content_width - 3))}..." + + for ((i = 0; i < term_width - 2; i++)); do + h_line+="─" + done + + pad_line() { + local text="$1" + local padding=$((term_width - 4 - ${#text})) + printf "│ %s%*s │\n" "$text" "$padding" "" + } + + echo "" + printf "╭%s╮\n" "$h_line" + pad_line "$line1" + printf "├%s┤\n" "$h_line" + pad_line "$line2" + pad_line "$line3" + pad_line "$line4" + printf "╰%s╯\n" "$h_line" + echo "" +} + +print_manual_next_steps() { + log_success "✅ Worktree ready at: $WORKTREE_PATH" + echo "" + echo "Selected agent: none ($AGENT_SOURCE)" + echo "Resolved model: $(model_display_value) ($MODEL_SOURCE)" + echo "Prompt source: $PROMPT_SOURCE" + echo "To start working:" + echo " cd $(shell_join "$WORKTREE_PATH")" + echo "" + echo "Suggested agent commands:" + echo " claude" + echo " codex --cd $(shell_join "$WORKTREE_PATH")" + echo " kimi --work-dir $(shell_join "$WORKTREE_PATH")" + echo " pi" +} + +print_dry_run_launch_command() { + local cmd="" + + build_launch_command + + echo " Agent: $AGENT" + echo " Agent source: $AGENT_SOURCE" + echo " Model: $(model_display_value)" + echo " Model source: $MODEL_SOURCE" + echo " Prompt source: $PROMPT_SOURCE" + echo " Prompt length: ${#AGENT_PROMPT} chars" + + if [[ ${#AGENT_PROMPT} -gt 4000 && "${START_ISSUE_DUMP_PROMPT:-}" != "1" ]]; then + echo " Prompt omitted from command display because it is large." + echo " Set START_ISSUE_DUMP_PROMPT=1 to print the full rendered prompt." + case "$AGENT" in + claude) + if [[ -n "$MODEL" ]]; then + cmd=$(shell_join claude --model "$MODEL" --dangerously-skip-permissions "") + else + cmd=$(shell_join claude --dangerously-skip-permissions "") + fi + ;; + codex) + if [[ -n "$MODEL" ]]; then + cmd=$(shell_join codex --model "$MODEL" --cd "$WORKTREE_PATH" --dangerously-bypass-approvals-and-sandbox "") + else + cmd=$(shell_join codex --cd "$WORKTREE_PATH" --dangerously-bypass-approvals-and-sandbox "") + fi + ;; + kimi) + if [[ -n "$MODEL" ]]; then + cmd=$(shell_join kimi --model "$MODEL" --work-dir "$WORKTREE_PATH" --yolo -p "") + else + cmd=$(shell_join kimi --work-dir "$WORKTREE_PATH" --yolo -p "") + fi + ;; + pi) + if [[ -n "$MODEL" ]]; then + cmd=$(shell_join pi --model "$MODEL" "") + else + cmd=$(shell_join pi "") + fi + ;; + *) + cmd="" + ;; + esac + else + cmd=$(shell_join "${LAUNCH_CMD[@]}") + fi + + if [[ -n "$LAUNCH_CWD" ]]; then + echo " [DRY-RUN] Would run: cd $(shell_join "$LAUNCH_CWD") && $cmd" + else + echo " [DRY-RUN] Would run: $cmd" + fi +} + +print_dry_run_human_gate_command() { + local cmd="" + + build_human_gate_command + + echo " Agent: $AGENT" + echo " Agent source: $AGENT_SOURCE" + echo " Model: $(model_display_value)" + echo " Model source: $MODEL_SOURCE" + echo " Prompt source: $PROMPT_SOURCE" + echo " Prompt length: ${#AGENT_PROMPT} chars" + echo " State dir: $HUMAN_GATE_STATE_DIR" + echo " Events file: $HUMAN_GATE_EVENTS_PATH" + echo " Last message file: $HUMAN_GATE_LAST_MESSAGE_PATH" + echo " Thread id file: $HUMAN_GATE_THREAD_ID_PATH" + + if [[ ${#AGENT_PROMPT} -gt 4000 && "${START_ISSUE_DUMP_PROMPT:-}" != "1" ]]; then + echo " Prompt omitted from command display because it is large." + echo " Set START_ISSUE_DUMP_PROMPT=1 to print the full rendered prompt." + if [[ -n "$MODEL" ]]; then + cmd=$(shell_join codex exec --model "$MODEL" --cd "$WORKTREE_PATH" --ask-for-approval never --sandbox workspace-write --json --output-last-message "$HUMAN_GATE_LAST_MESSAGE_PATH" "") + else + cmd=$(shell_join codex exec --cd "$WORKTREE_PATH" --ask-for-approval never --sandbox workspace-write --json --output-last-message "$HUMAN_GATE_LAST_MESSAGE_PATH" "") + fi + else + cmd="$(shell_join "${HUMAN_GATE_CMD[@]}") < <(rendered prompt via stdin)" + fi + + echo " [DRY-RUN] Would run: $cmd" +} + +print_selected_configuration() { + echo "Agent: $AGENT" + echo "Agent source: $AGENT_SOURCE" + echo "Model: $(model_display_value)" + echo "Model source: $MODEL_SOURCE" + echo "Worktree directory: $WORKTREE_DIR ($WORKTREE_DIR_SOURCE)" + echo "Prompt source: $PROMPT_SOURCE" + echo "Prompt location: $PROMPT_LOCATION" + print_agent_model_file_locations + print_prompt_file_locations + echo "" +} diff --git a/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/pipeline.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/pipeline.sh new file mode 100644 index 0000000..5f4197b --- /dev/null +++ b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/pipeline.sh @@ -0,0 +1,96 @@ +# shellcheck shell=bash disable=SC2153 +maybe_run_first_run_onboarding() { + if [[ -d "$USER_CONFIG_DIR" ]]; then + return + fi + + show_first_run_onboarding_prompt + + if confirm_yes_default "Run setup now? [Y/n] "; then + run_setup_flow + else + materialize_first_run_marker + fi + + echo "" +} + +start_agent_session() { + if [[ "$AGENT" == "none" ]]; then + print_manual_next_steps + return + fi + + if [[ "$HUMAN_GATE_MODE" == "true" ]]; then + run_codex_human_gate_session + return $? + fi + + log_info "🚀 Starting $AGENT agent session..." + + if [[ "$DRY_RUN" == "true" ]]; then + print_dry_run_launch_command + return + fi + + print_session_header + build_launch_command + + if [[ -n "$LAUNCH_CWD" ]]; then + cd "$LAUNCH_CWD" || exit + fi + + print_terminal_status "Handing off to $AGENT in $WORKTREE_PATH" + exec "${LAUNCH_CMD[@]}" +} + +handle_missing_issue_mode() { + if [[ "$IMPROVE_PROMPT" == "true" ]]; then + die "--improve-prompt requires . Example: start-issue 123 --improve-prompt" + fi + + detect_project_root_if_available + resolve_agent + resolve_model + resolve_prompt_template + show_missing_issue_summary + echo "" + show_missing_issue_help + echo "" + show_current_configuration + exit 1 +} + +run_start_issue_pipeline() { + check_core_dependencies + check_git_repo + detect_project_root + parse_issue_input "$ISSUE_INPUT" + detect_repo_from_remote + detect_base_branch + resolve_agent + resolve_model + validate_human_gate_mode + check_selected_agent_dependency + resolve_prompt_template + validate_prompt_improvement_mode + + print_selected_configuration + fetch_issue + + if [[ "$IMPROVE_PROMPT" == "true" ]]; then + improve_prompt_template + return + fi + + rename_zellij_tab + generate_branch_name + create_worktree + run_init_script + render_prompt_template + start_agent_session +} + +run_update_mode() { + run_update_pipeline +} diff --git a/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/release.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/release.sh new file mode 100644 index 0000000..595e427 --- /dev/null +++ b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/release.sh @@ -0,0 +1,139 @@ +# shellcheck shell=bash disable=SC2034 +release_fetch() { + local url="$1" + local output="$2" + + if command -v curl >/dev/null 2>&1; then + if [[ "${RELEASE_FETCH_VERBOSE:-0}" == "1" ]]; then + curl -fL -v "$url" -o "$output" + else + curl -fsSL "$url" -o "$output" + fi + return + fi + + if command -v wget >/dev/null 2>&1; then + if [[ "${RELEASE_FETCH_VERBOSE:-0}" == "1" ]]; then + wget -O "$output" "$url" + else + wget -qO "$output" "$url" + fi + return + fi + + die "Neither curl nor wget is installed." +} + +release_sha256_file() { + local path="$1" + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$path" | awk '{ print $1 }' + return + fi + + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$path" | awk '{ print $1 }' + return + fi + + if command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "$path" | awk '{ print $NF }' + return + fi + + die "No SHA-256 tool found. Install sha256sum, shasum, or openssl." +} + +release_normalize_version() { + local version="${1:-}" + version="${version#v}" + printf "%s" "$version" +} + +release_compare_versions() { + local left + local right + local i + local max_parts + local left_part + local right_part + local IFS=. + + left="$(release_normalize_version "${1:-}")" + right="$(release_normalize_version "${2:-}")" + + read -r -a left_parts <<< "$left" + read -r -a right_parts <<< "$right" + + max_parts="${#left_parts[@]}" + if [[ ${#right_parts[@]} -gt $max_parts ]]; then + max_parts="${#right_parts[@]}" + fi + + for ((i = 0; i < max_parts; i++)); do + left_part="${left_parts[i]:-0}" + right_part="${right_parts[i]:-0}" + + if ((10#$left_part > 10#$right_part)); then + printf "1" + return + fi + + if ((10#$left_part < 10#$right_part)); then + printf -- "-1" + return + fi + done + + printf "0" +} + +release_install_verified_asset() { + local asset_url="$1" + local checksum_url="$2" + local target_path="$3" + local tmpdir + local tmpfile + local checksum_file + local expected_checksum + local actual_checksum + local cleanup_cmd + + tmpdir="$(mktemp -d)" + printf -v cleanup_cmd 'rm -rf %q' "$tmpdir" + # shellcheck disable=SC2064 + trap "$cleanup_cmd" RETURN + + tmpfile="$tmpdir/start-issue" + checksum_file="$tmpdir/start-issue.sha256" + + if declare -F debug >/dev/null 2>&1; then + debug "Fetching $asset_url -> $tmpfile" + fi + release_fetch "$asset_url" "$tmpfile" || die "Failed to download release asset: $asset_url" + if declare -F debug >/dev/null 2>&1; then + debug "Fetching $checksum_url -> $checksum_file" + fi + release_fetch "$checksum_url" "$checksum_file" || die "Failed to download release checksum: $checksum_url" + + if declare -F debug >/dev/null 2>&1; then + debug "Verifying checksum" + fi + expected_checksum="$(awk '{ print $1; exit }' "$checksum_file")" + actual_checksum="$(release_sha256_file "$tmpfile")" + + if [[ -z "$expected_checksum" ]]; then + die "Downloaded checksum file is empty." + fi + + if [[ "$expected_checksum" != "$actual_checksum" ]]; then + die "Checksum verification failed." + fi + + if declare -F debug >/dev/null 2>&1; then + debug "Installing binary into $target_path" + fi + mkdir -p "$(dirname "$target_path")" + install -m 0755 "$tmpfile" "$target_path" || die "Failed to install updated release to $target_path" +} diff --git a/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/update.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/update.sh new file mode 100644 index 0000000..d839028 --- /dev/null +++ b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/update.sh @@ -0,0 +1,104 @@ +# shellcheck shell=bash disable=SC2034 +UPDATE_REPO="${START_ISSUE_REPOSITORY:-dapi/start-issue}" +UPDATE_MODE=false +LATEST_RELEASE_JSON="" +LATEST_RELEASE_TAG="" +LATEST_RELEASE_ASSET_URL="" +LATEST_RELEASE_CHECKSUM_URL="" +CURRENT_INSTALL_VERSION="" +CURRENT_INSTALL_VERSION_NORMALIZED="" +LATEST_RELEASE_VERSION_NORMALIZED="" + +check_update_dependencies() { + if ! command -v gh &> /dev/null; then + die "gh CLI not found. Install: https://cli.github.com" + fi + + if ! gh auth status &> /dev/null; then + die "gh not authenticated. Run: gh auth login" + fi + + if ! command -v jq &> /dev/null; then + die "jq not found. Please install jq." + fi + + if ! command -v install &> /dev/null; then + die "install command not found." + fi +} + +resolve_current_installation() { + CURRENT_INSTALL_VERSION="$VERSION" + CURRENT_INSTALL_VERSION_NORMALIZED="$(release_normalize_version "$CURRENT_INSTALL_VERSION")" +} + +fetch_latest_release_metadata() { + log_info "🔍 Resolving latest release for $UPDATE_REPO..." + + LATEST_RELEASE_JSON="$(gh api "repos/$UPDATE_REPO/releases/latest" 2>/dev/null)" || \ + die "Failed to resolve the latest GitHub release for $UPDATE_REPO." + + LATEST_RELEASE_TAG="$(printf "%s" "$LATEST_RELEASE_JSON" | jq -r '.tag_name // empty')" + LATEST_RELEASE_ASSET_URL="$(printf "%s" "$LATEST_RELEASE_JSON" | jq -r '.assets[] | select(.name == "start-issue") | .browser_download_url' | head -n 1)" + LATEST_RELEASE_CHECKSUM_URL="$(printf "%s" "$LATEST_RELEASE_JSON" | jq -r '.assets[] | select(.name == "start-issue.sha256") | .browser_download_url' | head -n 1)" + + if [[ -z "$LATEST_RELEASE_TAG" ]]; then + die "Latest release metadata for $UPDATE_REPO did not include a tag name." + fi + + if [[ -z "$LATEST_RELEASE_ASSET_URL" ]]; then + die "Latest release $LATEST_RELEASE_TAG does not include a start-issue asset." + fi + + if [[ -z "$LATEST_RELEASE_CHECKSUM_URL" ]]; then + die "Latest release $LATEST_RELEASE_TAG does not include a start-issue.sha256 asset." + fi + + LATEST_RELEASE_VERSION_NORMALIZED="$(release_normalize_version "$LATEST_RELEASE_TAG")" +} + +print_update_status() { + echo "Executable: $SCRIPT_PATH" + echo "Installed version: v$CURRENT_INSTALL_VERSION_NORMALIZED" + echo "Latest release: $LATEST_RELEASE_TAG" +} + +run_update_pipeline() { + local comparison + local installed_version_output + + check_update_dependencies + resolve_current_installation + fetch_latest_release_metadata + + echo "" + print_update_status + + comparison="$(release_compare_versions "$CURRENT_INSTALL_VERSION_NORMALIZED" "$LATEST_RELEASE_VERSION_NORMALIZED")" + + if [[ "$comparison" == "0" ]]; then + log_success "✅ start-issue is already up to date." + return + fi + + if [[ "$comparison" == "1" ]]; then + log_success "✅ Installed version is newer than the latest published release. No update needed." + return + fi + + if [[ "$DRY_RUN" == "true" ]]; then + echo " [DRY-RUN] Would download: $LATEST_RELEASE_ASSET_URL" + echo " [DRY-RUN] Would verify with: $LATEST_RELEASE_CHECKSUM_URL" + echo " [DRY-RUN] Would install to: $SCRIPT_PATH" + return + fi + + log_info "📥 Downloading and installing $LATEST_RELEASE_TAG..." + release_install_verified_asset "$LATEST_RELEASE_ASSET_URL" "$LATEST_RELEASE_CHECKSUM_URL" "$SCRIPT_PATH" + + installed_version_output="$("$SCRIPT_PATH" --version 2>/dev/null)" || \ + die "Updated executable installed, but version verification failed." + + log_success "✅ Updated start-issue at: $SCRIPT_PATH" + echo "Version: $installed_version_output" +} diff --git a/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/utils.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/utils.sh new file mode 100644 index 0000000..e9c90bd --- /dev/null +++ b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/utils.sh @@ -0,0 +1,132 @@ +# shellcheck shell=bash disable=SC2034 +log_info() { + echo -e "${BLUE}$1${NC}" +} + +log_success() { + echo -e "${GREEN}$1${NC}" +} + +log_warn() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +log_error() { + echo -e "${RED}❌ $1${NC}" >&2 +} + +die() { + log_error "$1" + exit 1 +} + +show_version() { + echo "start-issue v$VERSION" +} + +shell_join() { + local out="" + local quoted + local arg + + for arg in "$@"; do + printf -v quoted "%q" "$arg" + if [[ -n "$out" ]]; then + out+=" " + fi + out+="$quoted" + done + + printf "%s" "$out" +} + +trim() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf "%s" "$value" +} + +absolute_path() { + local path="$1" + + if [[ "$path" == /* ]]; then + printf "%s" "$path" + else + printf "%s/%s" "$(pwd)" "$path" + fi +} + +canonicalize_existing_path() { + local path="$1" + + if [[ -d "$path" ]]; then + (cd "$path" && pwd -P) + else + printf "%s" "$path" + fi +} + +read_first_config_value() { + local file="$1" + awk ' + { + sub(/#.*/, "") + gsub(/^[[:space:]]+|[[:space:]]+$/, "") + } + NF { + print + exit + } + ' "$file" +} + +prompt_preview() { + local preview="$PROMPT_TEMPLATE" + + preview="${preview//$'\n'/ }" + preview=$(printf "%s" "$preview" | sed 's/[[:space:]][[:space:]]*/ /g') + preview=$(trim "$preview") + + if [[ ${#preview} -gt 140 ]]; then + preview="${preview:0:137}..." + fi + + printf "%s" "$preview" +} + +check_core_dependencies() { + if ! command -v git &> /dev/null; then + die "git not found. Please install git." + fi + + if ! command -v gh &> /dev/null; then + die "gh CLI not found. Install: https://cli.github.com" + fi + + if ! gh auth status &> /dev/null; then + die "gh not authenticated. Run: gh auth login" + fi + + if ! command -v jq &> /dev/null; then + die "jq not found. Please install jq." + fi +} + +check_git_repo() { + if ! git rev-parse --git-dir &> /dev/null; then + die "Not in a git repository" + fi +} + +detect_project_root() { + PROJECT_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) +} + +detect_project_root_if_available() { + PROJECT_ROOT="$(pwd)" + + if command -v git &> /dev/null && git rev-parse --git-dir &> /dev/null; then + detect_project_root + fi +} diff --git a/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/worktree.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/worktree.sh new file mode 100644 index 0000000..b4fa98d --- /dev/null +++ b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/worktree.sh @@ -0,0 +1,403 @@ +# shellcheck shell=bash disable=SC2034 +sanitize_branch_slug() { + local input="$1" + local slug + + # Strip leading bracketed process/stage tags ([brief], [brief][investigation], ...), then + # transliterate Cyrillic -> Latin (BGN/PCGN-style: Х->Kh, Ц->Ts, Щ->Shch, Й->Y; + # soft/hard signs ъ/ь are dropped, not substituted). Two invariants: + # - order matters: multi-letter digraphs must precede the single-letter rules; + # - LC_ALL must be a UTF-8 locale or sed won't match the multibyte Cyrillic. + # The tail lowercases, collapses non-alnum runs to single dashes, caps at 40 + # chars, and trims leading/trailing dashes; an empty result falls back to "work". + slug=$(printf "%s" "$input" | LC_ALL=en_US.UTF-8 sed 's/^\(\[[^]]*\][[:space:]-]*\)*//' | \ + LC_ALL=en_US.UTF-8 sed ' +s/Щ/Shch/g; s/щ/shch/g; +s/Ж/Zh/g; s/ж/zh/g; +s/Х/Kh/g; s/х/kh/g; +s/Ц/Ts/g; s/ц/ts/g; +s/Ч/Ch/g; s/ч/ch/g; +s/Ш/Sh/g; s/ш/sh/g; +s/Ю/Yu/g; s/ю/yu/g; +s/Я/Ya/g; s/я/ya/g; +s/Ё/Yo/g; s/ё/yo/g; +s/А/A/g; s/а/a/g; +s/Б/B/g; s/б/b/g; +s/В/V/g; s/в/v/g; +s/Г/G/g; s/г/g/g; +s/Д/D/g; s/д/d/g; +s/Е/E/g; s/е/e/g; +s/З/Z/g; s/з/z/g; +s/И/I/g; s/и/i/g; +s/Й/Y/g; s/й/y/g; +s/К/K/g; s/к/k/g; +s/Л/L/g; s/л/l/g; +s/М/M/g; s/м/m/g; +s/Н/N/g; s/н/n/g; +s/О/O/g; s/о/o/g; +s/П/P/g; s/п/p/g; +s/Р/R/g; s/р/r/g; +s/С/S/g; s/с/s/g; +s/Т/T/g; s/т/t/g; +s/У/U/g; s/у/u/g; +s/Ф/F/g; s/ф/f/g; +s/Ы/Y/g; s/ы/y/g; +s/Э/E/g; s/э/e/g; +s/Ъ//g; s/ъ//g; +s/Ь//g; s/ь//g; +' | \ + tr '[:upper:]' '[:lower:]' | \ + sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | cut -c1-40 | sed 's/^-*//' | sed 's/-*$//') + + if [[ -z "$slug" ]]; then + slug="work" + fi + + printf "%s" "$slug" +} + +generate_fast_branch_name() { + local branch_type="feature" + local short_name + + if [[ "$ISSUE_LABELS" =~ (hotfix|critical|urgent) ]]; then + branch_type="hotfix" + elif [[ "$ISSUE_LABELS" =~ (bug|fix|bugfix|error) ]]; then + branch_type="fix" + elif [[ "$ISSUE_LABELS" =~ (docs|documentation) ]]; then + branch_type="docs" + elif [[ "$ISSUE_LABELS" =~ (refactor|tech-debt|cleanup|technical) ]]; then + branch_type="refactor" + elif [[ "$ISSUE_LABELS" =~ (test|testing|tests) ]]; then + branch_type="test" + elif [[ "$ISSUE_LABELS" =~ (chore|ci|build|infra) ]]; then + branch_type="chore" + fi + + short_name=$(sanitize_branch_slug "$ISSUE_TITLE") + + BRANCH_NAME="$branch_type/issue-$ISSUE_NUMBER-$short_name" +} + +validate_branch_name_or_fallback() { + local elapsed="$1" + + if [[ "$BRANCH_NAME" =~ ^(feature|fix|hotfix|refactor|docs|test|chore)/issue-[0-9]+-[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then + log_success " Branch: $BRANCH_NAME (${elapsed}s, ai:$AGENT)" + return + fi + + log_warn "Generated branch name doesn't match expected format: $BRANCH_NAME" + generate_fast_branch_name + log_info " Using fallback: $BRANCH_NAME (${elapsed}s)" +} + +generate_branch_name() { + log_info "🧠 Generating branch name..." + + local start_time=$SECONDS + local elapsed + + if [[ "$FAST_MODE" == "true" ]]; then + generate_fast_branch_name + elapsed=$((SECONDS - start_time)) + log_success " Branch: $BRANCH_NAME (${elapsed}s, fast)" + return + fi + + if generate_ai_branch_name; then + elapsed=$((SECONDS - start_time)) + validate_branch_name_or_fallback "$elapsed" + else + elapsed=$((SECONDS - start_time)) + log_warn "Could not generate branch name with $AGENT; falling back to fast heuristic" + generate_fast_branch_name + log_info " Branch: $BRANCH_NAME (${elapsed}s, fast fallback)" + fi +} + +create_worktree() { + local existing_worktree="" + local path_branch="" + + plan_worktree_path + + log_info "📁 Creating worktree..." + echo " Path: $WORKTREE_PATH" + echo " Base: $BASE_BRANCH" + + if git show-ref --verify --quiet "refs/heads/$BRANCH_NAME" 2>/dev/null; then + existing_worktree=$(find_worktree_path_by_branch "$BRANCH_NAME" || true) + if plan_branch_reuse_resolution "$existing_worktree"; then + return + fi + fi + + if [[ -d "$WORKTREE_PATH" ]]; then + path_branch=$(find_worktree_branch_by_path "$WORKTREE_PATH" || true) + if plan_path_reuse_resolution "$path_branch"; then + return + fi + fi + + if [[ "$DRY_RUN" == "true" ]]; then + echo " [DRY-RUN] Would run: git worktree add -b $BRANCH_NAME $WORKTREE_PATH $BASE_BRANCH" + return + fi + + mkdir -p "$(dirname "$WORKTREE_PATH")" + git fetch origin "$BASE_BRANCH" --quiet 2>/dev/null || true + + git worktree add -b "$BRANCH_NAME" "$WORKTREE_PATH" "origin/$BASE_BRANCH" || \ + git worktree add -b "$BRANCH_NAME" "$WORKTREE_PATH" "$BASE_BRANCH" || \ + die "Failed to create worktree" + + log_success " ✅ Worktree created" +} + +worktree_name_for_branch() { + local branch_name="$1" + local worktree_name="$branch_name" + + if [[ "$FLAT_WORKTREE" == "true" ]]; then + worktree_name="${branch_name//\//-}" + fi + + printf "%s" "$worktree_name" +} + +plan_worktree_path() { + WORKTREE_PATH="$WORKTREE_DIR/$(worktree_name_for_branch "$BRANCH_NAME")" +} + +worktree_branch_ref() { + local branch_name="$1" + printf "refs/heads/%s" "$branch_name" +} + +find_worktree_path_by_branch() { + local target_ref + local line + local current_path="" + + target_ref=$(worktree_branch_ref "$1") + + while IFS= read -r line; do + case "$line" in + worktree\ *) + current_path="${line#worktree }" + ;; + branch\ "$target_ref") + printf "%s" "$current_path" + return 0 + ;; + esac + done < <(git worktree list --porcelain) + + return 1 +} + +find_worktree_branch_by_path() { + local target_path="$1" + local line + local current_path="" + + target_path=$(canonicalize_existing_path "$target_path") + + while IFS= read -r line; do + case "$line" in + worktree\ *) + current_path="${line#worktree }" + ;; + branch\ *) + if [[ "$current_path" == "$target_path" ]]; then + printf "%s" "${line#branch }" + return 0 + fi + ;; + esac + done < <(git worktree list --porcelain) + + return 1 +} + +validate_reused_worktree() { + local registered_branch + + if [[ ! -d "$WORKTREE_PATH" ]]; then + die "Cannot reuse worktree path '$WORKTREE_PATH': directory does not exist." + fi + + registered_branch=$(find_worktree_branch_by_path "$WORKTREE_PATH" || true) + + if [[ -z "$registered_branch" ]]; then + die "Cannot reuse worktree path '$WORKTREE_PATH': path exists but is not a git worktree for this repository." + fi + + if [[ "$registered_branch" != "$(worktree_branch_ref "$BRANCH_NAME")" ]]; then + die "Cannot reuse worktree path '$WORKTREE_PATH': it belongs to branch '${registered_branch#refs/heads/}', not '$BRANCH_NAME'." + fi +} + +prompt_branch_conflict_resolution() { + local existing_worktree="$1" + + echo "" + log_warn "Branch '$BRANCH_NAME' already exists." + if [[ -n "$existing_worktree" ]]; then + echo " Existing worktree: $existing_worktree" + fi + echo "" + echo " 1) Use existing worktree and continue" + echo " 2) Create new branch with different name" + echo " 3) Delete branch/worktree and recreate" + echo " 0) Exit" + echo "" + print_terminal_status "Waiting for input: branch already exists" + read -r -n 1 -p "Choice: " CONFLICT_CHOICE + echo "" +} + +plan_branch_reuse_resolution() { + local existing_worktree="$1" + + prompt_branch_conflict_resolution "$existing_worktree" + + case "$CONFLICT_CHOICE" in + 1) + if [[ -z "$existing_worktree" ]]; then + die "No existing worktree found for branch '$BRANCH_NAME'. Use 3 to delete and recreate." + fi + WORKTREE_PATH="$existing_worktree" + validate_reused_worktree + log_info " Using existing worktree: $WORKTREE_PATH" + return 0 + ;; + 2) + local version=2 + local new_branch="${BRANCH_NAME}-v${version}" + + while git show-ref --verify --quiet "refs/heads/$new_branch" 2>/dev/null; do + ((version++)) + new_branch="${BRANCH_NAME}-v${version}" + done + + BRANCH_NAME="$new_branch" + plan_worktree_path + log_info " New branch name: $BRANCH_NAME" + return 1 + ;; + 3) + log_info " Removing existing branch/worktree..." + if [[ -n "$existing_worktree" ]]; then + git worktree remove --force "$existing_worktree" 2>/dev/null || rm -rf "$existing_worktree" + fi + git branch -D "$BRANCH_NAME" 2>/dev/null || true + log_success " ✅ Cleaned up" + return 1 + ;; + *) + die "Aborted" + ;; + esac +} + +prompt_path_conflict_resolution() { + local path_branch="$1" + + echo "" + log_warn "Worktree path already exists: $WORKTREE_PATH" + if [[ -n "$path_branch" ]]; then + echo " Registered branch: ${path_branch#refs/heads/}" + else + echo " Registered branch: none" + fi + echo "" + echo " 1) Use existing worktree" + echo " 2) Delete and recreate" + echo " 0) Exit" + echo "" + print_terminal_status "Waiting for input: worktree path already exists" + read -r -n 1 -p "Choice: " CONFLICT_CHOICE + echo "" +} + +plan_path_reuse_resolution() { + local path_branch="$1" + + prompt_path_conflict_resolution "$path_branch" + + case "$CONFLICT_CHOICE" in + 1) + validate_reused_worktree + log_info " Using existing worktree" + return 0 + ;; + 2) + log_info " Removing existing worktree..." + git worktree remove --force "$WORKTREE_PATH" 2>/dev/null || rm -rf "$WORKTREE_PATH" + if [[ -n "$path_branch" ]]; then + git branch -D "${path_branch#refs/heads/}" 2>/dev/null || true + else + git branch -D "$BRANCH_NAME" 2>/dev/null || true + fi + return 1 + ;; + *) + die "Aborted" + ;; + esac +} + +run_init_script() { + local init_script + + if [[ "$NO_INIT" == "true" ]]; then + log_info "⏭️ Skipping init.sh (--no-init)" + return + fi + + init_script="$WORKTREE_PATH/init.sh" + + if [[ ! -f "$init_script" ]]; then + log_warn "init.sh not found, skipping initialization" + return + fi + + log_info "⚙️ Running init.sh..." + + if [[ "$DRY_RUN" == "true" ]]; then + echo " [DRY-RUN] Would run: $init_script" + return + fi + + if ! (cd "$WORKTREE_PATH" && bash ./init.sh); then + log_warn "init.sh exited with non-zero code" + else + log_success " ✅ Done" + fi +} + +rename_zellij_tab() { + local tab_name="#$ISSUE_NUMBER" + + if [[ "$DRY_RUN" == "true" ]]; then + if command -v zellij-tab-status &> /dev/null; then + echo " [DRY-RUN] Would run: zellij-tab-status --set-name $(shell_join "$tab_name")" + else + echo " [DRY-RUN] Would skip zellij tab rename: zellij-tab-status not found" + fi + return + fi + + if ! command -v zellij-tab-status &> /dev/null; then + return + fi + + log_info "📑 Renaming zellij tab..." + if zellij-tab-status --set-name "$tab_name" &> /dev/null; then + log_success " ✅ Tab renamed to #$ISSUE_NUMBER" + else + log_warn "Could not rename zellij tab with zellij-tab-status" + fi +} diff --git a/cmd/start-issue/testdata/bash-v1/scripts/prepare-release b/cmd/start-issue/testdata/bash-v1/scripts/prepare-release new file mode 100755 index 0000000..d5a8ea3 --- /dev/null +++ b/cmd/start-issue/testdata/bash-v1/scripts/prepare-release @@ -0,0 +1,113 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: scripts/prepare-release + +This command: +1. requires a clean git worktree +2. bumps VERSION in scripts/start-issue +3. moves CHANGELOG.md Unreleased entries under the new version +4. runs make test and make build +5. creates a release commit and local annotated git tag + +Push the branch and tag afterwards with: + git push origin master --follow-tags +EOF +} + +update_changelog() { + local next_version="$1" + local release_date="$2" + local changelog_file="CHANGELOG.md" + local tmpfile + + if [[ ! -f "$changelog_file" ]]; then + echo "$changelog_file is missing." >&2 + return 1 + fi + + if ! grep -Eq '^## \[Unreleased\]$' "$changelog_file"; then + echo "$changelog_file must contain a '## [Unreleased]' section." >&2 + return 1 + fi + + if grep -Eq "^## \\[$next_version\\] -" "$changelog_file"; then + echo "$changelog_file already contains a section for $next_version." >&2 + return 1 + fi + + if ! awk ' + /^## \[Unreleased\]$/ { in_unreleased = 1; next } + in_unreleased && /^## \[/ { in_unreleased = 0 } + in_unreleased && $0 !~ /^[[:space:]]*$/ && $0 !~ /^###[[:space:]]/ { found = 1 } + END { exit found ? 0 : 1 } + ' "$changelog_file"; then + echo "$changelog_file has no entries under '## [Unreleased]'." >&2 + return 1 + fi + + tmpfile="$(mktemp)" + awk -v next_version="$next_version" -v release_date="$release_date" ' + /^## \[Unreleased\]$/ { + print + print "" + print "## [" next_version "] - " release_date + in_unreleased = 1 + next + } + + in_unreleased && /^## \[/ { + in_unreleased = 0 + } + + { print } + ' "$changelog_file" > "$tmpfile" + + mv "$tmpfile" "$changelog_file" +} + +if [[ $# -ne 1 ]]; then + usage >&2 + exit 1 +fi + +kind="$1" +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +if [[ -n "$(git status --porcelain)" ]]; then + echo "Refusing to prepare a release from a dirty worktree." >&2 + echo "Commit or stash current changes first." >&2 + exit 1 +fi + +current_branch="$(git branch --show-current)" +if [[ "$current_branch" != "master" && "$current_branch" != "main" ]]; then + echo "Preparing a release from branch '$current_branch'." >&2 +fi + +trap 'git checkout -- scripts/start-issue CHANGELOG.md >/dev/null 2>&1 || true' ERR + +next_version="$(bash scripts/bump-version "$kind")" +tag="v$next_version" +if git rev-parse "$tag" >/dev/null 2>&1; then + echo "Tag $tag already exists." >&2 + false +fi + +release_date="$(date +%F)" + +update_changelog "$next_version" "$release_date" + +make test +make build + +git add scripts/start-issue CHANGELOG.md +git commit -m "Release $tag" +git tag -a "$tag" -m "Release $tag" + +printf 'Prepared %s\n' "$tag" +printf 'Next step: git push origin %s --follow-tags\n' "$current_branch" diff --git a/cmd/start-issue/testdata/bash-v1/scripts/start-issue b/cmd/start-issue/testdata/bash-v1/scripts/start-issue new file mode 100755 index 0000000..ac010ee --- /dev/null +++ b/cmd/start-issue/testdata/bash-v1/scripts/start-issue @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# +# start-issue - Start working on a GitHub issue with git worktree and an agent +# +# Usage: start-issue [options] +# start-issue init [options] +# start-issue setup [options] +# +# Options: +# --repo, -r Repository (default: from git remote) +# --base, -b Base branch (default: main or master) +# --worktree-dir, -w Worktree directory (default: ~/worktrees) +# --agent Agent to launch: claude, codex, kimi, pi, none +# --project With init, write .start-issue config in the repo +# --user With init, write config in ~/.config/start-issue +# --force With init, overwrite existing config files +# --no-agent Only prepare the worktree, do not launch an agent +# --no-init Skip init.sh execution +# --improve-prompt Generate an improved prompt template proposal +# --human-gate Run Codex in batch mode and resume on HUMAN_GATE +# --human-gate-help Show dedicated help for the human-gate mode +# --dry-run Show what would be done without executing +# --help, -h Show this help +# + +set -euo pipefail + +VERSION="1.13.2" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +DEFAULT_WORKTREE_DIR="$HOME/worktrees" +WORKTREE_DIR_SOURCE="built-in default" +if [[ -n "${START_ISSUE_WORKTREE_DIR:-}" ]]; then + WORKTREE_DIR="$START_ISSUE_WORKTREE_DIR" + WORKTREE_DIR_SOURCE="START_ISSUE_WORKTREE_DIR" +else + WORKTREE_DIR="$DEFAULT_WORKTREE_DIR" +fi + +BASE_BRANCH="" +REPO="" +NO_INIT=false +DRY_RUN=false +FAST_MODE=true +FLAT_WORKTREE=false +INITIAL_COMMAND="" +ISSUE_INPUT="" +MISSING_ISSUE=false +PROJECT_ROOT="" +INIT_CONFIG=false +INIT_SCOPE="" +INIT_FORCE=false +SETUP_MODE=false +UPDATE_MODE=false + +AGENT="" +AGENT_CLI="" +AGENT_SOURCE="" +MODEL="" +MODEL_CLI="" +MODEL_SOURCE="" +PROMPT_TEMPLATE="" +PROMPT_SOURCE="" +PROMPT_LOCATION="" +PROMPT_TEMPLATE_PATH="" +PROMPT_FILE_CLI="" +PROMPT_INLINE_CLI="" +IMPROVE_PROMPT=false +PROMPT_IMPROVEMENT_OUTPUT_FILE="" +HUMAN_GATE_MODE=false +HUMAN_GATE_HELP=false +AGENT_PROMPT="" + +ISSUE_NUMBER="" +ISSUE_JSON="" +ISSUE_TITLE="" +ISSUE_BODY="" +ISSUE_LABELS="" +ISSUE_URL="" +BRANCH_NAME="" +WORKTREE_PATH="" + +LAUNCH_CWD="" +LAUNCH_CMD=() +HUMAN_GATE_CMD=() +CONFLICT_CHOICE="" +HUMAN_GATE_RUN_ID="" +HUMAN_GATE_STATE_DIR="" +HUMAN_GATE_EVENTS_PATH="" +HUMAN_GATE_LAST_MESSAGE_PATH="" +HUMAN_GATE_THREAD_ID_PATH="" +HUMAN_GATE_THREAD_ID="" +HUMAN_GATE_FINAL_STATUS="" + +resolve_script_path() { + local source_path="${BASH_SOURCE[0]}" + local source_dir + local target_path + + while [[ -L "$source_path" ]]; do + source_dir="$(cd -P "$(dirname "$source_path")" && pwd)" + target_path="$(readlink "$source_path")" + if [[ "$target_path" == /* ]]; then + source_path="$target_path" + else + source_path="$source_dir/$target_path" + fi + done + + source_dir="$(cd -P "$(dirname "$source_path")" && pwd)" + printf "%s/%s" "$source_dir" "$(basename "$source_path")" +} + +SCRIPT_PATH="$(resolve_script_path)" + +# BEGIN_MODULE_SOURCES +SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" +LIB_DIR="$SCRIPT_DIR/lib/start_issue" + +if [[ ! -d "$LIB_DIR" ]]; then + LIB_DIR="$SCRIPT_DIR/../lib/start_issue" +fi + +# shellcheck source=scripts/lib/start_issue/utils.sh +source "$LIB_DIR/utils.sh" +# shellcheck source=scripts/lib/start_issue/config.sh +source "$LIB_DIR/config.sh" +# shellcheck source=scripts/lib/start_issue/agent.sh +source "$LIB_DIR/agent.sh" +# shellcheck source=scripts/lib/start_issue/init.sh +source "$LIB_DIR/init.sh" +# shellcheck source=scripts/lib/start_issue/github.sh +source "$LIB_DIR/github.sh" +# shellcheck source=scripts/lib/start_issue/release.sh +source "$LIB_DIR/release.sh" +# shellcheck source=scripts/lib/start_issue/update.sh +source "$LIB_DIR/update.sh" +# shellcheck source=scripts/lib/start_issue/worktree.sh +source "$LIB_DIR/worktree.sh" +# shellcheck source=scripts/lib/start_issue/output.sh +source "$LIB_DIR/output.sh" +# shellcheck source=scripts/lib/start_issue/cli.sh +source "$LIB_DIR/cli.sh" +# shellcheck source=scripts/lib/start_issue/pipeline.sh +source "$LIB_DIR/pipeline.sh" +# END_MODULE_SOURCES + +main() { + parse_args "$@" + + if [[ "$HUMAN_GATE_HELP" == "true" ]]; then + show_human_gate_help + return + fi + echo -e "${BLUE}start-issue${NC} v$VERSION" + echo "" + + if [[ "$INIT_CONFIG" == "true" ]]; then + run_config_init + return + fi + + if [[ "$SETUP_MODE" == "true" ]]; then + run_setup_mode + return + fi + + if [[ "$UPDATE_MODE" == "true" ]]; then + run_update_mode + return + fi + + maybe_run_first_run_onboarding + + if [[ "$MISSING_ISSUE" == "true" ]]; then + handle_missing_issue_mode + fi + + run_start_issue_pipeline +} + +main "$@" diff --git a/doc/spec.md b/doc/spec.md index d5f97bb..688ba2a 100644 --- a/doc/spec.md +++ b/doc/spec.md @@ -3,10 +3,10 @@ ## Обзор **Название**: `start-issue` -**Тип**: Bash CLI с модульной shell-архитектурой +**Тип**: Go CLI **Назначение**: автоматизировать начало работы над GitHub issue: получить issue через `gh`, опционально переименовать zellij tab через `zellij-tab-status`, создать git worktree, при необходимости запустить `init.sh` и запустить выбранный coding agent. -Для разработки код остается модульным в `scripts/lib/start_issue/`, но install/distribution path должен собирать self-contained single-file script. +Runtime, build и тесты реализованы на Go; distribution path публикует platform-specific single-file binaries. ## Поддерживаемые агенты @@ -22,20 +22,15 @@ ## Внутренняя архитектура -Публичный CLI contract остается за `scripts/start-issue`, но внутренняя реализация разбита на shell-модули в `scripts/lib/start_issue/`. +Публичный CLI contract реализует `cmd/start-issue`. Внутренняя реализация разделена на focused Go helpers в одном command package. -| Модуль | Ответственность | +| Граница | Ответственность | |--------|-----------------| -| `cli.sh` | CLI parsing и нормализация входных флагов | -| `config.sh` | Разрешение agent/prompt config и prompt rendering | -| `github.sh` | Parse issue input, detect repo/base branch, fetch issue metadata | -| `release.sh` | Release download, checksum verification, and version normalization helpers shared by install/update flows | -| `update.sh` | Self-update mode, latest-release lookup, version comparison, and install orchestration | -| `worktree.sh` | Branch naming, worktree planning, worktree/init/zellij side effects | -| `agent.sh` | Agent adapter contract: validate, branch-name generation, prompt improvement, launch command | -| `output.sh` | Help, status rendering, dry-run output, session header | -| `init.sh` | Workflow конфигурационного `init` | -| `pipeline.sh` | Явная orchestration pipeline | +| CLI/config/prompt | Parse input, config resolution и prompt rendering | +| Repository/worktree | Parse issue input, repo/base detection, GitHub metadata и worktree lifecycle | +| Agent adapters | Validation, branch-name generation, prompt improvement и launch commands | +| Release/update | Asset selection, checksum/version verification и install orchestration | +| Output/onboarding | Help, status rendering, dry-run output, `init` и `setup` workflows | Agent-specific behavior должен быть централизован за единым adapter boundary: @@ -44,7 +39,7 @@ Agent-specific behavior должен быть централизован за е - generate branch name in `--ai` - improve prompt template in `--improve-prompt` -Если будущие изменения потребуют nested configuration, richer lifecycle subcommands (`resume`, `list`, `cleanup`) или полноценный structured output, это считается порогом для оценки Python core вместо дальнейшего роста Bash. +Будущие изменения должны сохранять эти границы в Go, не создавая второй runtime или дублирующие implementation paths. ## Входные данные @@ -78,10 +73,10 @@ Agent-specific behavior должен быть централизован за е | `--improve-prompt` | Сгенерировать reviewable proposal улучшенного prompt template и выйти до создания worktree | false | | `--human-gate` | Codex-only batch mode для issue workflow с resume на `STATUS: HUMAN_GATE` | false | | `--human-gate-help` | Показать отдельную справку по human-gate mode | false | -| `--prompt-output-file` | Путь proposal-файла для `--improve-prompt` | Для prompt-файла: рядом с source как `*.improved.md`; иначе `.start-issue/prompt.improved.md` | +| `--prompt-output-file` | Путь proposal-файла для `--improve-prompt` | Для `.md`: рядом с source как `*.improved.md`; для остальных файлов: `.improved`; иначе `.start-issue/prompt.improved.md` | | `--no-init` | Пропустить запуск `init.sh` | false | | `--command` / `-c` | Совместимый Claude command для дефолтного Claude prompt | `/task-router:route-task` | -| `--ai` | Генерировать имя ветки выбранным агентом | false, используется быстрая bash-эвристика | +| `--ai` | Генерировать имя ветки выбранным агентом | false, используется быстрая Go-эвристика | | `--project` | Для `init`: записать конфигурацию проекта в `.start-issue` | интерактивный выбор | | `--user` | Для `init`: записать пользовательскую конфигурацию в `~/.config/start-issue` | интерактивный выбор | | `--force` | Для `init`: перезаписать существующие `agent`, `prompt.md` и при необходимости сбросить `model` к unset | false | @@ -137,7 +132,7 @@ git rev-parse --show-toplevel 4. Записать результат в reviewable proposal-файл. 5. Завершить выполнение до переименования Zellij tab, генерации branch, создания worktree, запуска `init.sh` и запуска agent session. -Режим не перезаписывает active prompt template. Если active prompt взят из файла, proposal по умолчанию пишется рядом с ним как `*.improved.md`. Если active prompt built-in или inline, proposal по умолчанию пишется в `.start-issue/prompt.improved.md` в git top-level directory. `--prompt-output-file` задает путь явно. +Режим не перезаписывает active prompt template. Если active prompt взят из файла `.md`, proposal по умолчанию пишется рядом с ним как `*.improved.md`; для любого другого имени файла к исходному пути добавляется `.improved`. Если active prompt built-in или inline, proposal по умолчанию пишется в `.start-issue/prompt.improved.md` в git top-level directory. `--prompt-output-file` задает путь явно. Если proposal-файл уже существует, скрипт завершается с ошибкой, чтобы не перезаписать reviewable артефакт. `--agent none` в этом режиме невалиден. @@ -148,7 +143,7 @@ git rev-parse --show-toplevel - project scope: `{git-root}/.start-issue/agent`, optional `{git-root}/.start-issue/model` и `{git-root}/.start-issue/prompt.md` - user scope: `~/.config/start-issue/agent`, optional `~/.config/start-issue/model` и `~/.config/start-issue/prompt.md` -Если не передан `--project` или `--user`, команда интерактивно спрашивает scope. Project scope требует запуск внутри git repository; user scope может выполняться вне git repository. Режим `init` не требует issue, `gh` или `jq`. +Если не передан `--project` или `--user`, команда интерактивно спрашивает scope. Project scope требует запуск внутри git repository; user scope может выполняться вне git repository. Режим `init` не требует issue или `gh`. По умолчанию записывается agent `claude` и стандартный Claude prompt. `--agent` меняет записываемый agent; `--model` записывает sibling `model` config; `--prompt` или `--prompt-file` меняют записываемый prompt. Если `--model` не передан, built-in behavior остается unset и новый `model` файл не создается. Если выбран не `claude` и prompt явно не задан, записывается portable prompt. Если существующий `agent` сохраняется без `--force`, default prompt выбирается по сохраненному agent, а не по built-in default или CLI override. @@ -161,7 +156,7 @@ git rev-parse --show-toplevel Контракт режима: 1. Режим работает только с `~/.config/start-issue` и не пишет project config в `.start-issue`. -2. Режим не требует issue, git repository, `gh` или `jq`. +2. Режим не требует issue, git repository или `gh`. 3. Если директория `~/.config/start-issue` отсутствует, она создается в начале setup. 4. Команда спрашивает default agent: `claude`, `codex`, `kimi`, `pi` или `skip`. 5. `skip` означает, что файл `~/.config/start-issue/agent` должен отсутствовать. @@ -192,16 +187,12 @@ git rev-parse --show-toplevel 3. Текущая установленная версия берется из executable, который пользователь реально запустил. 4. Перед сравнением версии нормализуются удалением одного опционального префикса `v`. 5. Если текущая версия равна latest release или новее него, команда завершается с кодом `0` и не переустанавливает бинарник. -6. Если latest release новее, команда скачивает `start-issue` и `start-issue.sha256`, проверяет checksum и устанавливает обновление в тот же executable path. +6. Если latest release новее, команда скачивает binary для текущей платформы и `checksums.txt`, проверяет checksum и staged `--version`, затем устанавливает обновление в resolved target running executable. 7. Ошибки release lookup, download, checksum verification и install являются фатальными и должны давать понятное сообщение. Зависимости режима: - `gh` CLI с авторизованной GitHub session -- `jq` -- `curl` или `wget` -- `install` -- один из `sha256sum`, `shasum` или `openssl` ## Codex human-gate mode @@ -351,7 +342,7 @@ Templating правила: Если первый positional argument равен `setup` или включен `--setup`: -1. Не требовать git repository, `gh`, `jq` или issue input. +1. Не требовать git repository, `gh` или issue input. 2. Создать `~/.config/start-issue`, если директория отсутствует. 3. Спросить default agent: `claude`, `codex`, `kimi`, `pi` или `skip`. 4. Если выбран `skip`, не создавать `~/.config/start-issue/agent`. @@ -363,7 +354,7 @@ Templating правила: ### Фаза 1: Валидация и парсинг 1. Распарсить CLI arguments. -2. Проверить зависимости: `git`, `gh`, `jq`, авторизацию `gh`. +2. Проверить зависимости: `git`, `gh`, авторизацию `gh`. 3. Проверить, что текущая директория внутри git repo. 4. Определить project root через `git rev-parse --show-toplevel`. 5. Распарсить issue URL или issue number. @@ -390,7 +381,7 @@ Templating правила: Если включен update mode: 1. Не требовать `git` и не проверять текущую директорию как git repository. -2. Проверить зависимости update workflow: `gh`, `jq`, `install` и download/checksum tooling. +2. Проверить зависимость update workflow: `gh`. 3. Получить latest release metadata через: ```bash @@ -400,13 +391,13 @@ gh api "repos/dapi/start-issue/releases/latest" 4. Извлечь: - `tag_name` -- `browser_download_url` для asset `start-issue` -- `browser_download_url` для asset `start-issue.sha256` +- `browser_download_url` для binary текущей платформы +- `browser_download_url` для `checksums.txt` 5. Нормализовать installed version и `tag_name`, удалив один опциональный префикс `v`. 6. Если versions равны, завершиться с понятным сообщением `already up to date`. 7. Если installed version новее latest published release, завершиться с кодом `0` и сообщением, что update не нужен. -8. Если latest published release новее, скачать оба asset, проверить checksum и установить обновление в путь текущего executable. +8. Если latest published release новее, скачать оба asset, проверить checksum и staged `--version`, затем установить обновление в resolved target текущего executable. ### Фаза 2: Получение issue @@ -439,7 +430,7 @@ zellij-tab-status --set-name "#{ISSUE_NUMBER}" ### Фаза 4: Имя ветки -По умолчанию используется быстрая bash-эвристика. +По умолчанию используется быстрая Go-эвристика. Правила типа ветки: @@ -459,7 +450,7 @@ zellij-tab-status --set-name "#{ISSUE_NUMBER}" {type}/issue-{number}-{kebab-case-title} ``` -`--ai` пытается сгенерировать имя ветки через выбранный agent в non-interactive mode и fallback-ится на bash-эвристику при ошибке или невалидном формате. Если задана explicit model, adapter обязан передать ее в non-interactive command вместо тихого игнорирования. +`--ai` пытается сгенерировать имя ветки через выбранный agent в non-interactive mode и fallback-ится на Go-эвристику при ошибке или невалидном формате. Если задана explicit model, adapter обязан передать ее в non-interactive command вместо тихого игнорирования. ### Фаза 5: Создание worktree @@ -545,7 +536,6 @@ none: | Не в git repo | `Not in a git repository` | | `gh` отсутствует | `gh CLI not found. Install: https://cli.github.com` | | `gh` не авторизован | `gh not authenticated. Run: gh auth login` | -| `jq` отсутствует | `jq not found. Please install jq.` | | Issue не найден | `Issue #{number} not found in {owner}/{repo}` | | Agent неизвестен | `Unknown agent: {agent}` | | Model config пустая | `Model config is empty. Remove the empty model config or set a value.` | @@ -601,12 +591,10 @@ start-issue --human-gate-help Обязательные: -- `bash` - `git` - `gh` CLI с авторизованной GitHub session -- `jq` -Для `start-issue setup` обязателен только `bash`. Для `start-issue init --user` обязателен только `bash`. Для `start-issue init --project` нужны `bash` и `git`. +Для `start-issue setup` и `start-issue init --user` не требуется внешний CLI. Для `start-issue init --project` нужен `git`. Опциональные: diff --git a/go.mod b/go.mod index 50874c7..29a45d9 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/dapi/start-issue/v2 -go 1.21 +go 1.24 diff --git a/install.sh b/install.sh index dd93ee6..9708538 100755 --- a/install.sh +++ b/install.sh @@ -81,6 +81,7 @@ release_install_verified_asset() { local tmpdir local tmpfile local checksum_file + local asset_name local expected_checksum local actual_checksum local cleanup_cmd @@ -105,7 +106,10 @@ release_install_verified_asset() { if declare -F debug >/dev/null 2>&1; then debug "Verifying checksum" fi - expected_checksum="$(awk -v asset="$(basename "$asset_url")" '$2 == asset || $2 == "*" asset { print $1; exit }' "$checksum_file")" + asset_name="${asset_url%%\?*}" + asset_name="${asset_name%%\#*}" + asset_name="${asset_name##*/}" + expected_checksum="$(awk -v asset="$asset_name" '$2 == asset || $2 == "*" asset { print $1; exit }' "$checksum_file")" actual_checksum="$(release_sha256_file "$tmpfile")" if [[ -z "$expected_checksum" ]]; then @@ -154,13 +158,13 @@ parse_args() { main() { parse_args "$@" - if [[ -z "$ASSET_URL" ]]; then + if [[ -z "$ASSET_URL" || -z "$CHECKSUM_URL" ]]; then os="$(uname -s | tr '[:upper:]' '[:lower:]')" case "$(uname -m)" in x86_64|amd64) arch=amd64 ;; arm64|aarch64) arch=arm64 ;; *) die "Unsupported architecture: $(uname -m)" ;; esac case "$os" in linux|darwin) ;; *) die "Unsupported OS: $os" ;; esac asset="start-issue-$os-$arch" - ASSET_URL="https://github.com/$REPO/releases/latest/download/$asset" - CHECKSUM_URL="https://github.com/$REPO/releases/latest/download/checksums.txt" + ASSET_URL="${ASSET_URL:-https://github.com/$REPO/releases/latest/download/$asset}" + CHECKSUM_URL="${CHECKSUM_URL:-https://github.com/$REPO/releases/latest/download/checksums.txt}" fi if [[ "$DEBUG" -eq 1 ]]; then diff --git a/memory-bank/domain/context-map.md b/memory-bank/domain/context-map.md index f2d68cf..9f15025 100644 --- a/memory-bank/domain/context-map.md +++ b/memory-bank/domain/context-map.md @@ -58,7 +58,7 @@ canonical_for: ## Open Boundary Questions -- `OQ-01` Whether future lifecycle commands should stay in Bash modules or move - to a Python core. +- `OQ-01` How future lifecycle commands should be decomposed into focused Go + helpers without weakening the existing ownership boundaries. - `OQ-02` Whether non-Codex agents will expose enough resumable batch semantics to generalize human-gate mode. diff --git a/memory-bank/domain/glossary.md b/memory-bank/domain/glossary.md index 1472aeb..f7dae8c 100644 --- a/memory-bank/domain/glossary.md +++ b/memory-bank/domain/glossary.md @@ -29,7 +29,7 @@ canonical_for: | `worktree parent directory` | Directory under which issue worktrees are created | `--worktree-dir`, `START_ISSUE_WORKTREE_DIR` | Specific worktree path | | `agent` | Selected coding CLI adapter: `claude`, `codex`, `kimi`, `pi`, or `none` | Config and launch | Model | | `model` | Optional model string passed to adapters that support explicit model args | Config and launch | Agent | -| `agent adapter` | Internal boundary that validates agent support and builds agent-specific commands | `scripts/lib/start_issue/agent.sh` | Public agent CLI implementation | +| `agent adapter` | Internal Go helper boundary that validates agent support and builds agent-specific commands | `cmd/start-issue` | Public agent CLI implementation | | `prompt template` | Text with supported placeholders rendered into an agent prompt | Prompt resolution and launch | Final rendered prompt | | `prompt source` | Where the active prompt came from: CLI, env, project config, user config, or built-in default | Config output and dry-run | Prompt location | | `project config` | `.start-issue/*` files under the git root | Repository-local defaults | User config | diff --git a/memory-bank/domain/model.md b/memory-bank/domain/model.md index 5b60772..34f5109 100644 --- a/memory-bank/domain/model.md +++ b/memory-bank/domain/model.md @@ -51,10 +51,10 @@ canonical_for: | Concept | Canonical owner | Allowed writers | Allowed readers | Notes | | --- | --- | --- | --- | --- | -| `Configuration` | `config.sh` and `doc/spec.md` | CLI/config code and docs updates | Pipeline, output, adapters | Help and dry-run must stay aligned | -| `WorktreePlan` | `worktree.sh` and feature docs | Worktree lifecycle changes | Pipeline, tests | Reuse must be exact and safe | -| `AgentAdapter` | `agent.sh` | Agent feature work | Pipeline, branch naming, prompt improvement | Keep adapter-specific logic centralized | -| `ReleaseMetadata` | `release.sh` / `update.sh` | Release/update features | Installer, update workflow | Latest source is GitHub Releases | +| `Configuration` | Go configuration helpers and `doc/spec.md` | CLI/config code and docs updates | Pipeline, output, adapters | Help and dry-run must stay aligned | +| `WorktreePlan` | Go worktree helpers and feature docs | Worktree lifecycle changes | Pipeline, tests | Reuse must be exact and safe | +| `AgentAdapter` | Go agent adapter helpers | Agent feature work | Pipeline, branch naming, prompt improvement | Keep adapter-specific logic centralized | +| `ReleaseMetadata` | Go release/update helpers | Release/update features | Installer, update workflow | Latest source is GitHub Releases | | `FeaturePackage` | `memory-bank/flows/feature-flow.md` | Agents and maintainers | Future feature work | Legacy packages are grandfathered | ## Model Boundaries diff --git a/memory-bank/domain/rules.md b/memory-bank/domain/rules.md index 5e57bd9..dda387d 100644 --- a/memory-bank/domain/rules.md +++ b/memory-bank/domain/rules.md @@ -33,15 +33,15 @@ canonical_for: | Policy ID | Policy | Input | Output / Verdict | Owner | | --- | --- | --- | --- | --- | -| `POL-01` | Branch type selection | Issue labels | `feature/`, `fix/`, `docs/`, `refactor/`, `test/`, `chore/`, or `hotfix/` prefix | `worktree.sh` | -| `POL-02` | Model support | Resolved agent and optional model | Adapter command includes model or fails if unsupported | `agent.sh` | -| `POL-03` | Release comparison | Installed version and latest tag | no-op, update, or fail | `update.sh` | -| `POL-04` | First-run gate | Missing `~/.config/start-issue` on ordinary launch | Run setup or create marker directory, then continue | `pipeline.sh` / `init.sh` | +| `POL-01` | Branch type selection | Issue labels | `feature/`, `fix/`, `docs/`, `refactor/`, `test/`, `chore/`, or `hotfix/` prefix | Go worktree helper | +| `POL-02` | Model support | Resolved agent and optional model | Adapter command includes model or fails if unsupported | Go agent adapter | +| `POL-03` | Release comparison | Installed version and latest tag | no-op, update, or fail | Go update helper | +| `POL-04` | First-run gate | Missing `~/.config/start-issue` on ordinary launch | Run setup or create marker directory, then continue | Go orchestration/config helpers | ## Cross-Context Rules - `XDR-01` Changes to public CLI behavior must update help text, README, - Russian README/spec when relevant, and Bats coverage together. + Russian README/spec when relevant, and Go test coverage together. - `XDR-02` Changes to release behavior must update release docs, release/update tests, and install/update code together. - `XDR-03` Changes to memory-bank structure must keep diff --git a/memory-bank/engineering/README.md b/memory-bank/engineering/README.md index 93815e2..ea208ce 100644 --- a/memory-bank/engineering/README.md +++ b/memory-bank/engineering/README.md @@ -13,19 +13,18 @@ audience: humans_and_agents # Engineering Documentation Index -`memory-bank/engineering/` contains the implementation rules for the modular -Bash CLI and its documentation/test workflow. +`memory-bank/engineering/` contains the implementation rules for the Go CLI +and its documentation/test workflow. -- [Engineering Architecture Patterns](architecture.md) - shell module - boundaries, adapter boundary, config ownership, failure handling, and Bash - boundary. +- [Engineering Architecture Patterns](architecture.md) - package boundaries, + adapter boundary, config ownership, failure handling, and process boundary. - [Frontend Engineering](frontend.md) - current CLI UI surfaces and the policy for any future non-CLI UI. -- [Testing Policy](testing-policy.md) - canonical local checks, Bats coverage, +- [Testing Policy](testing-policy.md) - canonical local checks, Go coverage, memory-bank audit, sufficient coverage, and manual-only exceptions. - [Autonomy Boundaries](autonomy-boundaries.md) - what agents may do autonomously, what needs supervision, and when to escalate. -- [Coding Style](coding-style.md) - shell/documentation style, tooling, and +- [Coding Style](coding-style.md) - Go/documentation style, tooling, and change discipline. - [Git Workflow](git-workflow.md) - default branch, worktrees, commits, PR handoff, and release tags. diff --git a/memory-bank/engineering/autonomy-boundaries.md b/memory-bank/engineering/autonomy-boundaries.md index 40f6028..01dee16 100644 --- a/memory-bank/engineering/autonomy-boundaries.md +++ b/memory-bank/engineering/autonomy-boundaries.md @@ -21,9 +21,9 @@ audience: humans_and_agents Agents may do these without asking when they are in scope: -- edit shell modules, tests, and memory-bank docs; +- edit Go modules, tests, and memory-bank docs; - run local checks including `make test`; -- add focused Bats tests for changed behavior; +- add focused Go tests for changed behavior; - create or update feature packages; - fix memory-bank link/index issues found by the audit; - update docs/spec/help when directly required by a behavior change. @@ -37,7 +37,7 @@ Proceed, but surface the plan/result clearly: - prompt contract changes; - new agent adapter behavior; - migration from legacy feature package layout to new feature-flow layout; -- broad refactors across several shell modules. +- broad refactors across several Go packages. ## Escalation diff --git a/memory-bank/engineering/frontend.md b/memory-bank/engineering/frontend.md index e8928a3..019adec 100644 --- a/memory-bank/engineering/frontend.md +++ b/memory-bank/engineering/frontend.md @@ -31,7 +31,7 @@ the CLI: flags, prompts, help text, dry-run output, and agent launch commands. - Keep normal help concise; use dedicated help for complex modes such as `--human-gate-help`. - When adding interactive prompts, support non-interactive test coverage through - Bats input simulation. + Go test input simulation. ## If A Frontend Is Added Later diff --git a/memory-bank/engineering/testing-policy.md b/memory-bank/engineering/testing-policy.md index 5f61802..2df750b 100644 --- a/memory-bank/engineering/testing-policy.md +++ b/memory-bank/engineering/testing-policy.md @@ -70,7 +70,7 @@ make test ## Sufficient Coverage Coverage is sufficient when the changed behavior is exercised at the CLI level -or at the closest practical shell helper boundary, and failure behavior is +or at the closest practical Go helper boundary, and failure behavior is covered when it affects user trust or data safety. Line coverage is not a target. Scenario coverage matters more: @@ -98,7 +98,7 @@ feature plan or final handoff. ## Simplify Review -After tests pass, review for shell complexity: +After tests pass, review for implementation complexity: - avoid scattered agent-specific branching; - prefer small functions with explicit inputs over implicit global mutation; @@ -108,5 +108,5 @@ After tests pass, review for shell complexity: ## Verification Context Separation 1. Functional verification: run relevant tests or `make test`. -2. Simplify review: inspect the changed shell/docs for unnecessary complexity. +2. Simplify review: inspect the changed Go code/docs for unnecessary complexity. 3. Acceptance: map results back to `SC-*`/`CHK-*` or the user request. diff --git a/memory-bank/features/FT-017/decision-log.md b/memory-bank/features/FT-017/decision-log.md index e258d0e..af7ca3f 100644 --- a/memory-bank/features/FT-017/decision-log.md +++ b/memory-bank/features/FT-017/decision-log.md @@ -46,7 +46,7 @@ This log records why `DEC-01` remains open. The canonical owner of the blocker a | Area | Accepted contract | | --- | --- | | Target matrix | `linux/amd64`, `linux/arm64`, `darwin/amd64`, `darwin/arm64`, and `windows/amd64`. The operating systems are requester-selected; the architecture set follows the explicit `dapi/port-selector` release pattern. | -| Build/release | Use GoReleaser v2 with `CGO_ENABLED=0`, one statically built executable per target, `start-issue--` asset names, and a SHA-256 `checksums.txt` manifest. | +| Build/release | Use GoReleaser v2 with `CGO_ENABLED=0`, one statically built executable per target, `start-issue--` asset names, and a SHA-256 `checksums.txt` manifest. During the v1-to-v2 cutover, also upload a `start-issue` bridge and its `start-issue.sha256` checksum for the v1 updater. | | POSIX install | Adapt the referenced install-script strategy: detect `uname -s`/`uname -m`, select the matching asset, download it, verify its checksum from `checksums.txt`, and install it under the public name `start-issue`. | | Windows delivery | Publish `start-issue-windows-amd64.exe` as a first-class release asset and document manual download/PATH installation. The existing POSIX shell installer is not a Windows installer. | | Cutover | No separate human release-approval gate. The normal tag-triggered release proceeds only after `CHK-01` through `CHK-03` are green. | @@ -73,9 +73,36 @@ The requester directly chose macOS, Linux, and Windows and delegated release-str ### Decision -1. Pin Go `1.21` in `go.mod`, `mise.toml`, and CI/release setup for this migration. +1. Pin Go `1.24` in `go.mod`, `mise.toml`, and CI/release setup for this migration. 2. The initial Windows contract is binary release plus manual installation and manual update: `start-issue update` on Windows must not try to overwrite its running `.exe`; it returns a clear instruction naming the matching release asset. POSIX retains verified automatic install/update behavior. ### Rationale and risk control -Go 1.21 is the explicit baseline in the chosen release reference and yields a single reproducible toolchain contract. The Windows manual-update behavior avoids an unsafe or undeclared helper-process design. It is a documented platform-specific delivery difference, not a hidden parity exception, because the Bash baseline has no Windows runtime contract. +Go 1.24 is the explicit baseline because its linker emits a Mach-O `LC_UUID`, which current macOS releases require. The Windows manual-update behavior avoids an unsafe or undeclared helper-process design. It is a documented platform-specific delivery difference, not a hidden parity exception, because the Bash baseline has no Windows runtime contract. + +## DL-03 — ID-01 dry-run worktree-path conflict handling + +**Status:** accepted on 2026-07-24 by the feature requester. + +### Case and approved expectation + +- **Stable case ID:** `ID-01` +- **Parity case:** `worktree-path-conflict-dry-run` in + `cmd/start-issue/parity_integration_test.go` +- **Bash baseline expectation:** accepts the supplied conflict choice during + `--dry-run` and reports `Worktree path already exists` before continuing + down the selected reuse path. +- **Go expectation:** reports `Worktree path exists; would prompt for reuse or + delete/recreate` without consuming a choice or selecting a reuse/delete + outcome. + +### User-visible rationale and acceptance + +When a worktree path conflicts, the Go dry-run tells the user that a choice is +still required. This avoids presenting one stdin-supplied choice as the +determined outcome of a non-executing command and, importantly, avoids the +legacy path in which the delete/recreate selection can reach mutation logic +before Bash's later dry-run check. The different dry-run diagnostic is +user-visible and is intentionally accepted for `ID-01`; all other observable +records, fake-command logs, and filesystem state remain subject to `CHK-01` +parity. diff --git a/memory-bank/ops/config.md b/memory-bank/ops/config.md index 936f76a..b1968f1 100644 --- a/memory-bank/ops/config.md +++ b/memory-bank/ops/config.md @@ -25,7 +25,8 @@ variables. The canonical precedence is: 4. environment variables 5. built-in defaults -Config resolution is owned by `scripts/lib/start_issue/config.sh`. User-facing +Config resolution is owned by the Go configuration helpers in +`cmd/start-issue`. User-facing documentation must stay aligned in README and `doc/spec.md`. ## File Layout @@ -54,12 +55,12 @@ User config: | Variable | Description | Default | Owner | | --- | --- | --- | --- | -| `START_ISSUE_AGENT` | Default agent when CLI and config files do not set one | built-in `claude` | `config.sh` | -| `START_ISSUE_MODEL` | Default model when CLI and config files do not set one | unset | `config.sh` | -| `START_ISSUE_PROMPT` | Inline prompt template | none | `config.sh` | -| `START_ISSUE_PROMPT_FILE` | Prompt template file | none | `config.sh` | -| `START_ISSUE_WORKTREE_DIR` | Parent directory for created worktrees | `~/worktrees` | `config.sh` / `worktree.sh` | -| `START_ISSUE_DUMP_PROMPT` | Print full rendered prompt in dry-run when set to `1` | unset | `output.sh` | +| `START_ISSUE_AGENT` | Default agent when CLI and config files do not set one | built-in `claude` | Go configuration helpers | +| `START_ISSUE_MODEL` | Default model when CLI and config files do not set one | unset | Go configuration helpers | +| `START_ISSUE_PROMPT` | Inline prompt template | none | Go configuration helpers | +| `START_ISSUE_PROMPT_FILE` | Prompt template file | none | Go configuration helpers | +| `START_ISSUE_WORKTREE_DIR` | Parent directory for created worktrees | `~/worktrees` | Go configuration/worktree helpers | +| `START_ISSUE_DUMP_PROMPT` | Print full rendered prompt in dry-run when set to `1` | unset | Go output helpers | `START_ISSUE_PROMPT` and `START_ISSUE_PROMPT_FILE` are mutually exclusive when no CLI prompt override is provided. diff --git a/memory-bank/ops/development.md b/memory-bank/ops/development.md index 4d24fee..aa7c6d5 100644 --- a/memory-bank/ops/development.md +++ b/memory-bank/ops/development.md @@ -18,14 +18,16 @@ audience: humans_and_agents Required tools for normal development: -- Go 1.21+ +- Go 1.24+ - `git` - `gh` with an authenticated GitHub session for issue/update flows -- `jq` -- `curl` or `wget` for installer/update paths -- one checksum tool: `sha256sum`, `shasum`, or `openssl` -Build the bundled script: +The Go CLI parses release metadata, downloads update assets, and verifies +checksums itself. `jq`, download tools, and checksum tools are not required for +the CLI update workflow. The optional Bash installer still requires `bash`, +`curl` or `wget`, and a SHA-256 tool. + +Build the binary: ```bash make build diff --git a/memory-bank/ops/release.md b/memory-bank/ops/release.md index e7a3fd6..ac0102d 100644 --- a/memory-bank/ops/release.md +++ b/memory-bank/ops/release.md @@ -14,55 +14,57 @@ audience: humans_and_agents # Release And Deployment -`start-issue` is distributed as a single-file Bash executable through GitHub +`start-issue` is distributed as platform-specific Go binaries through GitHub Releases. There is no server deployment. ## Release Flow 1. Add user-facing changes under `## [Unreleased]` in `CHANGELOG.md`. -2. Run one release prep command: +2. From a clean worktree, run the required checks and create the SemVer tag. + The tag is the source of the release version: ```bash -make release-patch -make release-minor -make release-major +make test +make build +git tag vX.Y.Z ``` -3. The release script bumps `VERSION`, updates changelog entries, runs - `make test`, runs `make build`, creates `Release vX.Y.Z`, and creates an - annotated tag. -4. Publish with: +3. Publish with: ```bash git push origin master --follow-tags ``` -5. GitHub Actions builds the release asset and checksum. +4. GitHub Actions reruns the test suite and uses GoReleaser to publish the + binaries and checksum manifest. ## Release Assets -Each release uploads: +Each release uploads one binary for every supported target: -- `start-issue` -- `start-issue.sha256` +- `start-issue-linux-amd64` +- `start-issue-linux-arm64` +- `start-issue-darwin-amd64` +- `start-issue-darwin-arm64` +- `start-issue-windows-amd64.exe` +- `checksums.txt` +- `start-issue` and `start-issue.sha256` during the v1-to-v2 transition only The installer and self-update workflow download the release asset and verify the -checksum before install. +checksum before install. The two legacy-named assets are a verified POSIX +migration bridge: v1.13.2 and older updaters install it, then it resolves, +verifies, and replaces itself with the matching v2 platform binary. -## Release Commands +## Version Source ```bash make print-version -make bump-patch -make bump-minor -make bump-major -make release-patch -make release-minor -make release-major ``` -Do not run release commands with a dirty worktree unless the release script -explicitly supports the current state. +`make build` and `make install` derive the embedded version from `git describe` +using the nearest SemVer tag. A tagged release therefore reports the tag +version, while a checkout between tags reports its describe suffix. GoReleaser +embeds the pushed release tag in published binaries. ## Release Verification @@ -75,9 +77,10 @@ make build Release-specific checks: -- `VERSION` in `scripts/start-issue` matches the tag without the `v` prefix. +- `make print-version` matches the intended tag version when run at that tag. - `CHANGELOG.md` has the new version/date section. -- Release asset and checksum are present in GitHub Releases after CI. +- All five platform assets, `checksums.txt`, and the v1 migration bridge assets + `start-issue`/`start-issue.sha256` are present in GitHub Releases after CI. - `start-issue update` can resolve the latest release and no-op/install correctly. diff --git a/memory-bank/ops/stages.md b/memory-bank/ops/stages.md index d70abba..2cd1add 100644 --- a/memory-bank/ops/stages.md +++ b/memory-bank/ops/stages.md @@ -20,7 +20,7 @@ limited to GitHub and external CLIs. | Environment | Purpose | Access path | Notes | | --- | --- | --- | --- | | GitHub repository | Issues, CI, releases, tags | `gh`, git remote, GitHub UI | Auth required for issue/update/release operations | -| GitHub Releases | Distribution source for installer and update | `gh api`, release URLs | Publishes `start-issue` and checksum | +| GitHub Releases | Distribution source for installer and update | `gh api`, release URLs | Publishes platform binaries and `checksums.txt` | | User machine | Installed executable and user config | local filesystem | Update installs into running executable path | ## Common Operations @@ -53,7 +53,7 @@ gh api repos/dapi/start-issue/releases/latest --jq .tag_name There is no centralized runtime observability. Diagnostics come from: - command output; -- Bats test logs; +- Go test logs; - GitHub Actions logs; - human-gate state files under `/.start-issue/runs//`. diff --git a/memory-bank/product/context.md b/memory-bank/product/context.md index bcc37ff..22a8055 100644 --- a/memory-bank/product/context.md +++ b/memory-bank/product/context.md @@ -79,9 +79,8 @@ tracker, or the developer's review and merge process. - `PCON-03` Prompt and config changes must be visible and reviewable; prompt improvement writes proposals instead of overwriting active templates. - `PCON-04` Agent-specific behavior belongs behind the adapter boundary. -- `PCON-05` Bash remains acceptable while workflow complexity stays readable; a - richer lifecycle, nested config, or structured machine output is the threshold - for reevaluating a Python core. +- `PCON-05` The Go CLI is the sole runtime. New lifecycle behavior belongs in + focused Go helpers and must not reintroduce a second shell or Python runtime. ## Source Documents diff --git a/memory-bank/product/customers.md b/memory-bank/product/customers.md index 1155ad2..6ddc4cf 100644 --- a/memory-bank/product/customers.md +++ b/memory-bank/product/customers.md @@ -22,7 +22,7 @@ canonical_for: | --- | --- | --- | --- | --- | --- | | `SEG-01` | Maintainer/developer of this repository | Start work on a GitHub issue in an isolated worktree with the right agent | Repeated manual setup and inconsistent launch commands | One command prepares the expected workspace | README workflow and feature history | | `SEG-02` | Coding agent session | Receive enough context and repository instructions to work safely | Missing repo context, wrong prompt, unclear checks | Prompt contains issue/worktree/base branch and repo instructions are discoverable | Prompt template and AGENTS.md | -| `SEG-03` | Release maintainer | Publish and update a single-file CLI safely | Manual version/changelog/tag/release steps can drift | `make release-*`, checksums, and update tests pass | Release docs and scripts | +| `SEG-03` | Release maintainer | Publish and update platform-specific CLI binaries safely | Manual version/changelog/tag/release steps can drift | Tagged GoReleaser release, checksum manifest, and update tests pass | Release docs and workflows | ## Users And Actors @@ -37,7 +37,7 @@ canonical_for: - Existing feature packages under [features/](../features/README.md). - Current public docs in [README.md](../../README.md) and [doc/spec.md](../../doc/spec.md). -- Test suite behavior in `test/start_issue.bats`. +- Test suite behavior in `cmd/start-issue/*_test.go`. ## Assumptions diff --git a/memory-bank/product/metrics.md b/memory-bank/product/metrics.md index 27c9caf..ab5b07f 100644 --- a/memory-bank/product/metrics.md +++ b/memory-bank/product/metrics.md @@ -19,15 +19,15 @@ canonical_for: | Metric ID | Metric | Why it matters | Current baseline | Target | Review cadence | | --- | --- | --- | --- | --- | --- | -| `NSM-01` | Successful issue-start completion for supported workflows | It captures the core product value | Covered by Bats regression scenarios | No known regression in supported flows | Each release | +| `NSM-01` | Successful issue-start completion for supported workflows | It captures the core product value | Covered by Go regression and parity scenarios | No known regression in supported flows | Each release | ## Product Metrics | Metric ID | Metric | Owner | Baseline | Target | Measurement method | Source | | --- | --- | --- | --- | --- | --- | --- | | `MET-01` | Local verification pass rate | Maintainer | `make test` is canonical | Pass before release and handoff | Local command and CI | Makefile / GitHub Actions | -| `MET-02` | Config visibility | Maintainer | Missing issue and dry-run paths print effective config | Every config source change updates output tests | Bats assertions | `test/start_issue.bats` | -| `MET-03` | Release artifact integrity | Maintainer | Release asset plus `.sha256` | Installer/update verify checksum | Release workflow and tests | GitHub Releases | +| `MET-02` | Config visibility | Maintainer | Missing issue and dry-run paths print effective config | Every config source change updates output tests | Go output assertions | `cmd/start-issue/*_test.go` | +| `MET-03` | Release artifact integrity | Maintainer | Platform binary plus `checksums.txt` | Installer/update verify checksum | Release workflow and Go tests | GitHub Releases | | `MET-04` | Memory-bank navigation health | Maintainer/agent | New audit introduced | `scripts/check_memory_bank_index.py --max-depth 4` passes | Local audit in `make test` | memory-bank audit | ## Guardrails diff --git a/memory-bank/product/roadmap.md b/memory-bank/product/roadmap.md index 9f20324..04ce786 100644 --- a/memory-bank/product/roadmap.md +++ b/memory-bank/product/roadmap.md @@ -29,7 +29,7 @@ into feature packages. | `now` | Memory-bank adoption | Agents have project-specific process, product, domain, engineering, and ops context | Current memory-bank work | Template source from `dapi/memory-bank` | active | | `next` | New feature-flow adoption | New medium/large features use `brief.md -> optional design.md -> implementation-plan.md` | Future `FT-*` packages | AGENTS.md and flows docs | planned | | `next` | Release confidence | Release prep, changelog, version, build, and update path stay coherent | Existing release scripts | CI and `make test` | active | -| `later` | Richer lifecycle commands | Possible `resume`, `list`, `cleanup`, or structured output | Unknown | Requires design and Bash/Python reevaluation | idea | +| `later` | Richer lifecycle commands | Possible `resume`, `list`, `cleanup`, or structured output | Unknown | Requires Go design and module-boundary review | idea | ## Roadmap Rules @@ -44,5 +44,5 @@ into feature packages. - `BET-01` Whether Codex human-gate patterns should remain Codex-only or become a generic agent capability after other CLIs expose equivalent contracts. -- `BET-02` Whether future lifecycle complexity justifies moving orchestration - from Bash modules to a Python core. +- `BET-02` Whether future lifecycle complexity warrants extracting Go helper + packages from the current command package. diff --git a/mise.toml b/mise.toml index 9d96842..fc07e58 100644 --- a/mise.toml +++ b/mise.toml @@ -1,4 +1,4 @@ [tools] -go = "1.21" +go = "1.24" gh = "2.90.0" jq = "1.8.1" diff --git a/scripts/v1-upgrade-shim b/scripts/v1-upgrade-shim new file mode 100644 index 0000000..a3f560a --- /dev/null +++ b/scripts/v1-upgrade-shim @@ -0,0 +1,78 @@ +#!/usr/bin/env bash + +# This compatibility asset is published only to let v1.13.2 and older +# installations complete their documented `start-issue update` migration. The +# legacy updater requires assets named start-issue and start-issue.sha256. +set -euo pipefail + +repository="${START_ISSUE_REPOSITORY:-dapi/start-issue}" + +fetch() { + local url="$1" + local destination="$2" + + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$destination" + return + fi + if command -v wget >/dev/null 2>&1; then + wget -qO "$destination" "$url" + return + fi + + echo "start-issue v1 upgrade bridge requires curl or wget." >&2 + exit 1 +} + +sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{ print $1 }' + return + fi + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{ print $1 }' + return + fi + if command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "$1" | awk '{ print $NF }' + return + fi + + echo "start-issue v1 upgrade bridge requires a SHA-256 tool." >&2 + exit 1 +} + +os="$(uname -s | tr '[:upper:]' '[:lower:]')" +case "$os" in + linux|darwin) ;; + *) echo "Unsupported OS for start-issue upgrade: $os" >&2; exit 1 ;; +esac + +case "$(uname -m)" in + x86_64|amd64) arch=amd64 ;; + arm64|aarch64) arch=arm64 ;; + *) echo "Unsupported architecture for start-issue upgrade: $(uname -m)" >&2; exit 1 ;; +esac + +asset="start-issue-${os}-${arch}" +asset_url="${START_ISSUE_UPGRADE_ASSET_URL:-https://github.com/${repository}/releases/latest/download/${asset}}" +checksum_url="${START_ISSUE_UPGRADE_CHECKSUM_URL:-https://github.com/${repository}/releases/latest/download/checksums.txt}" +temporary_dir="$(mktemp -d)" +trap 'rm -rf "$temporary_dir"' EXIT + +binary="$temporary_dir/$asset" +checksums="$temporary_dir/checksums.txt" +fetch "$asset_url" "$binary" +fetch "$checksum_url" "$checksums" + +expected="$(awk -v asset="$asset" '$2 == asset || $2 == "*" asset { print $1; exit }' "$checksums")" +actual="$(sha256 "$binary")" +if [[ -z "$expected" || "$expected" != "$actual" ]]; then + echo "Checksum verification failed for $asset." >&2 + exit 1 +fi + +script_dir="$(cd -P -- "$(dirname -- "$0")" && pwd)" +script_path="$script_dir/$(basename -- "$0")" +install -m 0755 "$binary" "$script_path" +exec "$script_path" "$@" From d440af7d6e82f794e644419f668e7452339edd04 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Fri, 31 Jul 2026 10:57:48 +0300 Subject: [PATCH 10/12] Restore current Codex human-gate invocation --- cmd/start-issue/main.go | 2 +- cmd/start-issue/main_test.go | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/cmd/start-issue/main.go b/cmd/start-issue/main.go index 1f15ca1..3ed7c4b 100644 --- a/cmd/start-issue/main.go +++ b/cmd/start-issue/main.go @@ -1818,7 +1818,7 @@ func humanGate(model, worktree, prompt string, dryRun bool) error { } dir := filepath.Join(worktree, ".start-issue", "runs", runID) events, last := filepath.Join(dir, "events.jsonl"), filepath.Join(dir, "last-message.txt") - args := []string{"exec", "--cd", worktree, "--ask-for-approval", "never", "--sandbox", "workspace-write", "--json", "--output-last-message", last, "-"} + args := []string{"exec", "--cd", worktree, "--sandbox", "workspace-write", "--json", "--output-last-message", last, "-"} if model != "" { args = append([]string{"exec", "--model", model}, args[1:]...) } diff --git a/cmd/start-issue/main_test.go b/cmd/start-issue/main_test.go index f360e54..bc209f6 100644 --- a/cmd/start-issue/main_test.go +++ b/cmd/start-issue/main_test.go @@ -1739,6 +1739,7 @@ func TestHumanGateSavesThreadIDBeforeDone(t *testing.T) { t.Setenv("START_ISSUE_RUN_ID", "done") t.Setenv("CODEX_EVENTS", `{"type":"thread.started","thread_id":"thread-done"}`) t.Setenv("CODEX_LAST", "STATUS: DONE") + t.Setenv("START_ISSUE_FAKE_CODEX_REJECT_ASK_FOR_APPROVAL", "1") if err := humanGate("", worktree, "prompt", false); err != nil { t.Fatal(err) @@ -1794,6 +1795,9 @@ func TestHumanGateDryRunShowsAllStateArtifacts(t *testing.T) { t.Fatalf("dry-run output missing %q:\n%s", want, output) } } + if strings.Contains(output, "--ask-for-approval") { + t.Fatalf("dry-run includes obsolete --ask-for-approval argument:\n%s", output) + } if _, err := os.Stat(dir); !os.IsNotExist(err) { t.Fatalf("human-gate dry-run created state directory: %v", err) } @@ -1863,6 +1867,10 @@ func writeExecutable(t *testing.T, path, content string) { func writeFakeCodex(t *testing.T, bin string) { t.Helper() writeExecutable(t, filepath.Join(bin, "codex"), `#!/bin/sh +if [ "$START_ISSUE_FAKE_CODEX_REJECT_ASK_FOR_APPROVAL" = "1" ] && [ "${*#*--ask-for-approval}" != "$*" ]; then + printf '%s\n' "unexpected obsolete --ask-for-approval flag" >&2 + exit 1 +fi if [ -n "$START_ISSUE_CWD_LOG" ]; then pwd > "$START_ISSUE_CWD_LOG" fi From 0b5af0e5ca4d2d3362ab46a7c109dfb28190ef35 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Tue, 4 Aug 2026 13:45:37 +0300 Subject: [PATCH 11/12] Prepare v2 agent compatibility and CI sandbox E2E --- .github/workflows/ci.yml | 9 ++ .github/workflows/release.yml | 1 + .gitignore | 2 + Makefile | 5 +- README.md | 17 ++- README.ru.md | 17 ++- cmd/start-issue/main.go | 26 ++-- cmd/start-issue/main_test.go | 4 +- cmd/start-issue/parity_integration_test.go | 4 +- .../bash-v1/scripts/lib/start_issue/agent.sh | 13 +- .../bash-v1/scripts/lib/start_issue/output.sh | 6 +- doc/spec.md | 2 +- docs/agent-examples.md | 2 +- docs/agent-examples.ru.md | 2 +- memory-bank/features/FT-018/README.md | 19 +++ memory-bank/features/FT-018/brief.md | 96 +++++++++++++++ memory-bank/features/FT-018/design.md | 74 ++++++++++++ .../features/FT-018/implementation-plan.md | 70 +++++++++++ memory-bank/features/FT-019/README.md | 16 +++ memory-bank/features/FT-019/brief.md | 96 +++++++++++++++ memory-bank/features/FT-019/design.md | 71 +++++++++++ .../features/FT-019/implementation-plan.md | 65 ++++++++++ memory-bank/features/README.md | 6 + memory-bank/ops/development.md | 8 ++ memory-bank/ops/release.md | 10 +- test/e2e/sandbox.sh | 111 ++++++++++++++++++ 26 files changed, 724 insertions(+), 28 deletions(-) create mode 100644 memory-bank/features/FT-018/README.md create mode 100644 memory-bank/features/FT-018/brief.md create mode 100644 memory-bank/features/FT-018/design.md create mode 100644 memory-bank/features/FT-018/implementation-plan.md create mode 100644 memory-bank/features/FT-019/README.md create mode 100644 memory-bank/features/FT-019/brief.md create mode 100644 memory-bank/features/FT-019/design.md create mode 100644 memory-bank/features/FT-019/implementation-plan.md create mode 100755 test/e2e/sandbox.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7ffab0..03940c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,15 @@ jobs: - run: make build - run: .build/start-issue --version + sandbox-e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - run: make e2e-sandbox + platform-smoke: strategy: fail-fast: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b11c99..330c059 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,7 @@ jobs: with: go-version-file: go.mod - run: make test + - run: make e2e-sandbox - uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser diff --git a/.gitignore b/.gitignore index 30bcfa4..62b6c2e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .build/ +/start-issue +/start-issue.exe diff --git a/Makefile b/Makefile index 4c7dd33..06e8916 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build install uninstall test e2e-human-gate print-version bump-patch bump-minor bump-major release-patch release-minor release-major +.PHONY: build install uninstall test e2e-sandbox e2e-human-gate print-version bump-patch bump-minor bump-major release-patch release-minor release-major PREFIX ?= $(HOME)/.local BINDIR ?= $(PREFIX)/bin @@ -34,5 +34,8 @@ test: e2e-human-gate: build @START_ISSUE_E2E_BINARY="$(abspath $(BUILD_OUTPUT))" bash test/e2e/human-gate.sh +e2e-sandbox: build + @START_ISSUE_SANDBOX_BINARY="$(abspath $(BUILD_OUTPUT))" bash test/e2e/sandbox.sh + print-version: @echo "$(VERSION)" diff --git a/README.md b/README.md index 6c582d5..71e38d9 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,19 @@ the obsolete `--ask-for-approval` flag). The selected Codex executable is printed in the test output. They do not prove application behavior beyond this human-gate protocol and are intentionally excluded from CI. +### CI sandbox E2E + +Run the deterministic built-binary E2E locally: + +```bash +make e2e-sandbox +``` + +It uses a temporary local git repository plus fake `gh` and Kimi commands, but +real worktree creation, `init.sh`, prompt rendering, model/cwd forwarding, and +dry-run behavior. It needs no network, credentials, or external agent and runs +in the `sandbox-e2e` CI job. + Configuration precedence: 1. Agent: CLI `--agent` / `--no-agent`, then project config, user config, `START_ISSUE_AGENT`, then built-in default `claude` @@ -302,7 +315,9 @@ Claude uses the plugin-native command by default: /task-router:route-task {ISSUE_URL} ``` -Other agents use a portable prompt by default. +Other agents use a portable prompt by default. Kimi is launched from the +worktree directory because current Kimi Code CLI versions do not support the +legacy `--work-dir` option and reject `--yolo` together with `--prompt`. To improve the prompt template used for future development starts, run: diff --git a/README.ru.md b/README.ru.md index 0671e31..9ae5107 100644 --- a/README.ru.md +++ b/README.ru.md @@ -137,6 +137,19 @@ test/e2e/human-gate.sh --scenario human-gate в test output. Они не доказывают поведение приложения за пределами human-gate protocol и намеренно не входят в CI. +### CI sandbox E2E + +Детерминированный E2E для собранного бинарника можно запустить локально: + +```bash +make e2e-sandbox +``` + +Он использует временный локальный git-репозиторий и fake-команды `gh` и Kimi, +но реально проверяет создание worktree, `init.sh`, рендер prompt, передачу +model/cwd и dry-run. Сеть, credentials и внешние агенты не нужны; в CI это +отдельный job `sandbox-e2e`. + ## Использование ```bash @@ -310,7 +323,9 @@ Claude по умолчанию использует plugin-native команду /task-router:route-task {ISSUE_URL} ``` -Другие агенты по умолчанию используют portable prompt. +Другие агенты по умолчанию используют portable prompt. Kimi запускается из +директории worktree: актуальные версии Kimi Code CLI не поддерживают старую +опцию `--work-dir` и запрещают сочетать `--yolo` с `--prompt`. Чтобы улучшить prompt template, который будет использоваться для будущих стартов разработки, запустите: diff --git a/cmd/start-issue/main.go b/cmd/start-issue/main.go index 3ed7c4b..5990526 100644 --- a/cmd/start-issue/main.go +++ b/cmd/start-issue/main.go @@ -1476,7 +1476,7 @@ func aiBranchName(agent, model, root, number, title, labels string) (string, err } prompt := fmt.Sprintf("Git branch name for issue #%s: %q (labels: %s).\nFormat: {type}/issue-%s-{kebab-case-name}\nTypes: bug/fix -> fix, enhancement -> feature, hotfix -> hotfix, docs -> docs, refactor -> refactor, test -> test, chore -> chore, default -> feature.\nIf the title contains non-English text (for example Cyrillic), transliterate it to English for the kebab-case name.\nStrip leading bracketed process/stage tags (for example [brief], [investigation], [PR-008]) from the kebab-case name; they mark workflow stage. The {type} still comes from the labels above.\nReply with ONLY the branch name.", number, title, labels, number) args := helperArgs(agent, model, root, prompt) - result, err := output(args[0], args[1:]...) + result, err := helperOutput(agent, root, args) if err != nil { return "", err } @@ -1768,7 +1768,7 @@ func improvePrompt(root, agent, model, prompt, source, promptFile string, o opti } request := promptImprovementRequest(prompt, source, in, repo, number, labels) args := helperArgs(agent, model, root, request) - result, err := output(args[0], args[1:]...) + result, err := helperOutput(agent, root, args) if err != nil { return fmt.Errorf("Could not generate improved prompt with %s", agent) } @@ -1911,7 +1911,7 @@ func printLaunch(a, m, w, p string) { func launchDisplayCommand(a, m, w, p string) string { command := shellJoin(launchArgs(a, m, w, p)) - if a == "claude" || a == "pi" { + if a == "claude" || a == "kimi" || a == "pi" { return "cd " + shellQuote(w) + " && " + command } return command @@ -1923,7 +1923,7 @@ func launch(a, m, w, p string) error { } args := launchArgs(a, m, w, p) var err error - if a == "claude" || a == "pi" { + if a == "claude" || a == "kimi" || a == "pi" { err = commandAt(w, args...) } else { err = command(args[0], args[1:]...) @@ -1953,7 +1953,7 @@ func printManualNextSteps(model, worktree string) { fmt.Println("Suggested agent commands:") fmt.Println(" claude") fmt.Printf(" codex --cd %s\n", shellQuote(worktree)) - fmt.Printf(" kimi --work-dir %s\n", shellQuote(worktree)) + fmt.Printf(" (cd %s && kimi)\n", shellQuote(worktree)) fmt.Println(" pi") } @@ -1992,7 +1992,7 @@ func launchArgs(a, m, w, p string) []string { if m != "" { x = append(x, "--model", m) } - return append(x, "--work-dir", w, "--yolo", "-p", p) + return append(x, "-p", p) default: x := []string{"pi"} if m != "" { @@ -2027,7 +2027,7 @@ func helperArgs(agent, model, root, prompt string) []string { if model != "" { args = append(args, "--model", model) } - return append(args, "--work-dir", root, "--quiet", "-p", prompt) + return append(args, "-p", prompt) case "pi": args := []string{"pi"} if model != "" { @@ -2038,6 +2038,12 @@ func helperArgs(agent, model, root, prompt string) []string { return nil } } +func helperOutput(agent, root string, args []string) (string, error) { + if agent == "kimi" { + return outputAt(root, args[0], args[1:]...) + } + return output(args[0], args[1:]...) +} func command(name string, args ...string) error { c := exec.Command(name, args...) c.Stdout = os.Stdout @@ -2057,6 +2063,12 @@ func output(name string, args ...string) (string, error) { b, e := exec.Command(name, args...).Output() return string(b), e } +func outputAt(dir, name string, args ...string) (string, error) { + cmd := exec.Command(name, args...) + cmd.Dir = dir + b, e := cmd.Output() + return string(b), e +} func need(n string) error { if _, e := exec.LookPath(n); e != nil { return fmt.Errorf("%s not found", n) diff --git a/cmd/start-issue/main_test.go b/cmd/start-issue/main_test.go index bc209f6..ffe782b 100644 --- a/cmd/start-issue/main_test.go +++ b/cmd/start-issue/main_test.go @@ -1711,7 +1711,7 @@ func TestLaunchUsesAdapterSpecificWorkingDirectory(t *testing.T) { t.Fatal(err) } want := caller - if agent == "claude" || agent == "pi" { + if agent == "claude" || agent == "kimi" || agent == "pi" { want = worktree } if strings.TrimSpace(string(got)) != want { @@ -1727,7 +1727,7 @@ func TestHelperArgsAreNonInteractive(t *testing.T) { t.Fatalf("pi helper args: %s", got) } kimi := helperArgs("kimi", "model", "/repo", "prompt") - if got := fmt.Sprint(kimi); got != "[kimi --model model --work-dir /repo --quiet -p prompt]" { + if got := fmt.Sprint(kimi); got != "[kimi --model model -p prompt]" { t.Fatalf("kimi helper args: %s", got) } } diff --git a/cmd/start-issue/parity_integration_test.go b/cmd/start-issue/parity_integration_test.go index c3afa92..b1412d0 100644 --- a/cmd/start-issue/parity_integration_test.go +++ b/cmd/start-issue/parity_integration_test.go @@ -538,7 +538,7 @@ func assertAgentLaunchContract(t *testing.T, result parityResult, agent string) commands := map[string]string{ "claude": "claude --model fixture-model --dangerously-skip-permissions", "codex": "codex --model fixture-model --cd", - "kimi": "kimi --model fixture-model --work-dir", + "kimi": "cd /", "pi": "pi --model fixture-model", } assertParityOutputContains(t, result, "[DRY-RUN] Would run:", commands[agent]) @@ -575,7 +575,7 @@ func launchAdapterPrefix(command, agent string) string { markers := map[string]string{ "claude": "--dangerously-skip-permissions", "codex": "--dangerously-bypass-approvals-and-sandbox", - "kimi": "--yolo -p", + "kimi": "-p", "pi": "pi --model fixture-model", } marker := markers[agent] diff --git a/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/agent.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/agent.sh index a13ee5b..9265a1c 100644 --- a/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/agent.sh +++ b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/agent.sh @@ -323,9 +323,9 @@ generate_improved_prompt_template() { ;; kimi) if [[ -n "$MODEL" ]]; then - output=$(kimi --model "$MODEL" --work-dir "$PROJECT_ROOT" --quiet -p "$request" 2>/dev/null) || return 1 + output=$(cd "$PROJECT_ROOT" && kimi --model "$MODEL" -p "$request" 2>/dev/null) || return 1 else - output=$(kimi --work-dir "$PROJECT_ROOT" --quiet -p "$request" 2>/dev/null) || return 1 + output=$(cd "$PROJECT_ROOT" && kimi -p "$request" 2>/dev/null) || return 1 fi ;; pi) @@ -380,9 +380,9 @@ Reply with ONLY the branch name." ;; kimi) if [[ -n "$MODEL" ]]; then - output=$(kimi --model "$MODEL" --work-dir "$PROJECT_ROOT" --quiet -p "$prompt" 2>/dev/null) || return 1 + output=$(cd "$PROJECT_ROOT" && kimi --model "$MODEL" -p "$prompt" 2>/dev/null) || return 1 else - output=$(kimi --work-dir "$PROJECT_ROOT" --quiet -p "$prompt" 2>/dev/null) || return 1 + output=$(cd "$PROJECT_ROOT" && kimi -p "$prompt" 2>/dev/null) || return 1 fi ;; pi) @@ -451,10 +451,11 @@ build_launch_command() { fi ;; kimi) + LAUNCH_CWD="$WORKTREE_PATH" if [[ -n "$MODEL" ]]; then - LAUNCH_CMD=(kimi --model "$MODEL" --work-dir "$WORKTREE_PATH" --yolo -p "$AGENT_PROMPT") + LAUNCH_CMD=(kimi --model "$MODEL" -p "$AGENT_PROMPT") else - LAUNCH_CMD=(kimi --work-dir "$WORKTREE_PATH" --yolo -p "$AGENT_PROMPT") + LAUNCH_CMD=(kimi -p "$AGENT_PROMPT") fi ;; pi) diff --git a/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/output.sh b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/output.sh index f1a3fc3..4e85953 100644 --- a/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/output.sh +++ b/cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/output.sh @@ -331,7 +331,7 @@ print_manual_next_steps() { echo "Suggested agent commands:" echo " claude" echo " codex --cd $(shell_join "$WORKTREE_PATH")" - echo " kimi --work-dir $(shell_join "$WORKTREE_PATH")" + echo " (cd $(shell_join "$WORKTREE_PATH") && kimi)" echo " pi" } @@ -367,9 +367,9 @@ print_dry_run_launch_command() { ;; kimi) if [[ -n "$MODEL" ]]; then - cmd=$(shell_join kimi --model "$MODEL" --work-dir "$WORKTREE_PATH" --yolo -p "") + cmd=$(shell_join kimi --model "$MODEL" -p "") else - cmd=$(shell_join kimi --work-dir "$WORKTREE_PATH" --yolo -p "") + cmd=$(shell_join kimi -p "") fi ;; pi) diff --git a/doc/spec.md b/doc/spec.md index 688ba2a..20ad2d7 100644 --- a/doc/spec.md +++ b/doc/spec.md @@ -503,7 +503,7 @@ codex: exec codex [--model "$MODEL"] --cd "$WORKTREE_PATH" --dangerously-bypass-approvals-and-sandbox "$PROMPT" kimi: - exec kimi [--model "$MODEL"] --work-dir "$WORKTREE_PATH" --yolo -p "$PROMPT" + cd "$WORKTREE_PATH" && exec kimi [--model "$MODEL"] -p "$PROMPT" pi: cd "$WORKTREE_PATH" diff --git a/docs/agent-examples.md b/docs/agent-examples.md index 361612e..6ccfafa 100644 --- a/docs/agent-examples.md +++ b/docs/agent-examples.md @@ -80,7 +80,7 @@ start-issue 123 --agent kimi The script creates the worktree, renders the portable prompt, and launches: ```bash -kimi --work-dir "$WORKTREE_PATH" --yolo -p "$PROMPT" +cd "$WORKTREE_PATH" && kimi -p "$PROMPT" ``` Use an explicit Kimi model: diff --git a/docs/agent-examples.ru.md b/docs/agent-examples.ru.md index 8ca0cce..10270d2 100644 --- a/docs/agent-examples.ru.md +++ b/docs/agent-examples.ru.md @@ -80,7 +80,7 @@ start-issue 123 --agent kimi Скрипт создает worktree, рендерит portable prompt и запускает: ```bash -kimi --work-dir "$WORKTREE_PATH" --yolo -p "$PROMPT" +cd "$WORKTREE_PATH" && kimi -p "$PROMPT" ``` Использовать явную model Kimi: diff --git a/memory-bank/features/FT-018/README.md b/memory-bank/features/FT-018/README.md new file mode 100644 index 0000000..538fc81 --- /dev/null +++ b/memory-bank/features/FT-018/README.md @@ -0,0 +1,19 @@ +--- +title: "FT-018: Agent CLI launch compatibility" +doc_kind: feature +doc_function: index +purpose: "Navigation for the agent-adapter compatibility feature split from issue #34." +derived_from: + - brief.md +status: active +audience: humans_and_agents +--- + +# FT-018: Agent CLI launch compatibility + +This package tracks the agent-launch contract extracted from issue #34 after +the installed Kimi Code CLI rejected the legacy `--work-dir` option. + +- [brief.md](brief.md) — problem, scope, and acceptance contract. +- [design.md](design.md) — selected per-agent command and cwd mapping. +- [implementation-plan.md](implementation-plan.md) — grounded execution and verification plan. diff --git a/memory-bank/features/FT-018/brief.md b/memory-bank/features/FT-018/brief.md new file mode 100644 index 0000000..6d3ca34 --- /dev/null +++ b/memory-bank/features/FT-018/brief.md @@ -0,0 +1,96 @@ +--- +title: "FT-018: Agent CLI launch compatibility" +doc_kind: feature +doc_function: canonical +purpose: "Canonical brief for keeping start-issue agent launches compatible with the installed agent CLIs." +derived_from: + - ../../flows/feature-flow.md + - ../../product/context.md + - ../../domain/glossary.md + - ../../engineering/testing-policy.md + - ../../../doc/spec.md +status: active +delivery_status: in_progress +audience: humans_and_agents +must_not_define: + - implementation_sequence +--- + +# FT-018: Agent CLI launch compatibility + +## What + +### Problem + +The Kimi Code CLI currently installed in the user's environment does not +accept the legacy `--work-dir` option. Its current prompt mode also rejects +`--yolo` together with `--prompt`. `start-issue` therefore fails before Kimi +can receive the issue prompt, despite Codex continuing to work. + +### Outcome + +Agent launch commands use the supported interface of each selected agent and +always execute from the intended issue worktree. + +| Metric ID | Metric | Baseline | Target | Measurement method | +| --- | --- | --- | --- | --- | +| `MET-01` | Kimi launch success | Fails with unknown option | Deterministic launch command is accepted by the current Kimi CLI | Go tests and local `kimi --help` contract check | + +## Scope + +- `REQ-01` Keep the Codex, Claude, Pi, and `none` launch contracts unchanged. +- `REQ-02` Launch Kimi from the resolved worktree cwd without `--work-dir`. +- `REQ-03` Remove incompatible Kimi `--yolo` usage from prompt mode and keep model forwarding. +- `REQ-04` Keep helper operations (branch naming and prompt improvement) in the repository cwd for Kimi. +- `REQ-05` Update parity fixtures, documentation, and manual next steps together with the adapter behavior. + +## Non-Scope + +- `NS-01` Do not add version probing or support multiple incompatible Kimi command syntaxes in one launch. +- `NS-02` Do not change the public agent names, config precedence, worktree lifecycle, or Codex human-gate behavior. + +## Constraints / Assumptions + +- `ASM-01` Current Kimi Code CLI help exposes `-p/--prompt`, `--model`, `--yolo`, and `--add-dir`, but not `--work-dir`. +- `CON-01` The worktree path must be supplied through process cwd for agents whose CLI has no path option. + +## Design Requirement Decision + +| Decision | Reason | Downstream owner | +| --- | --- | --- | +| `Design required: yes` | This changes an external CLI integration contract and requires explicit command/cwd mapping. | `design.md` | + +## Verify + +### Exit Criteria + +- `EC-01` Kimi dry-run output contains `cd && kimi` and no `--work-dir` or incompatible `--yolo -p` combination. +- `EC-02` Kimi helper and launch tests preserve model/prompt forwarding and execute in the requested cwd. +- `EC-03` Existing non-Kimi adapter contracts and parity checks remain green. + +### Traceability matrix + +| Requirement ID | Problem refs | Acceptance refs | Checks | Evidence IDs | +| --- | --- | --- | --- | --- | +| `REQ-01` | `CON-01` | `EC-03`, `SC-02` | `CHK-01` | `EVID-01` | +| `REQ-02` | `ASM-01`, `CON-01` | `EC-01`, `SC-01` | `CHK-01`, `CHK-02` | `EVID-01`, `EVID-02` | +| `REQ-03` | `ASM-01` | `EC-01`, `SC-01` | `CHK-01` | `EVID-01` | +| `REQ-04` | `CON-01` | `EC-02`, `SC-01` | `CHK-01` | `EVID-01` | +| `REQ-05` | `CON-01` | `EC-03` | `CHK-02` | `EVID-02` | + +### Acceptance Scenarios + +- `SC-01` Given `--agent kimi --dry-run`, when start-issue renders its launch, then Kimi runs from the worktree and receives `-p` plus the optional model without unsupported flags. +- `SC-02` Given another supported agent, when its launch is rendered, then its existing adapter-specific command remains unchanged. + +### Checks + +| Check ID | Covers | How to check | Expected result | Evidence path | +| --- | --- | --- | --- | --- | +| `CHK-01` | `EC-01`–`EC-03`, `SC-01`, `SC-02` | `go test ./cmd/start-issue` | All adapter and cwd tests pass | `artifacts/ft-018/verify/go-test/` | +| `CHK-02` | `EC-03` | `make test` | Repository checks and parity suite pass | `artifacts/ft-018/verify/make-test/` | + +### Evidence + +- `EVID-01` Go adapter and cwd test output. +- `EVID-02` Full `make test` output. diff --git a/memory-bank/features/FT-018/design.md b/memory-bank/features/FT-018/design.md new file mode 100644 index 0000000..3edc3c9 --- /dev/null +++ b/memory-bank/features/FT-018/design.md @@ -0,0 +1,74 @@ +--- +title: "FT-018: Design" +doc_kind: feature +doc_function: canonical +purpose: "Solution-space contract for agent-specific launch arguments and working-directory handling." +derived_from: + - brief.md + - ../../../doc/spec.md +status: active +audience: humans_and_agents +must_not_define: + - ft_018_scope + - ft_018_acceptance_criteria + - implementation_sequence +--- + +# FT-018: Design + +## Design Pack + +| Artifact | Role | Owns | +| --- | --- | --- | +| `design.md` | Feature-local solution owner | `SOL-*`, `C4-*`, `SD-*`, `CTR-*`, `INV-*`, `FM-*` | + +## C4 Applicability + +| C4 ID | Decision | Trigger / reason | Artifact | +| --- | --- | --- | --- | +| `C4-00` | `not required` | Local adapter command mapping inside the existing CLI container; no runtime boundary changes. | none | + +## Selected Solution + +- `SOL-01` Keep each agent adapter responsible for its own arguments and cwd policy. +- `SOL-02` For Kimi, run `kimi [--model MODEL] -p PROMPT` with `cmd.Dir = WORKTREE_PATH`; prompt mode provides automatic permission handling, so `--yolo` is omitted. +- `SOL-03` For Kimi helper calls, run the same command with `cmd.Dir = REPOSITORY_ROOT`; do not encode cwd as a removed CLI option. +- `SOL-04` Keep Codex's `--cd`, Claude/Pi cwd behavior, and `none` manual output unchanged except for documentation alignment. + +## Alternatives Considered + +| Alternative ID | Option | Why not selected | +| --- | --- | --- | +| `ALT-01` | Keep `--work-dir` and require an older Kimi CLI | Fails with the installed/current CLI and makes start-issue unusable for Kimi users. | +| `ALT-02` | Add runtime version probing and two Kimi syntaxes | Adds fragile branching and is outside the requested compatibility fix. | +| `ALT-03` | Use `--add-dir` while keeping the caller cwd | Does not make the worktree the primary Kimi workspace. | + +## Accepted Local Decisions + +- `SD-01` Process cwd is the canonical worktree transport for Kimi. +- `SD-02` Kimi prompt mode omits `--yolo` because the current CLI rejects that combination and auto-approves prompt-mode tool calls. + +## Contracts + +| Contract ID | Input / Output | Producer / Consumer | Semantics / Constraints | +| --- | --- | --- | --- | +| `CTR-01` | agent, model, worktree, prompt → process command + cwd | adapter / selected agent CLI | Kimi has no path flag in the supported CLI; cwd must equal the requested directory. | + +## Invariants + +- `INV-01` No Kimi command emitted by the product contains `--work-dir`. +- `INV-02` No Kimi prompt command emits the incompatible `--yolo` and `-p` combination. +- `INV-03` The selected worktree remains the process cwd for Kimi launch and repository-root cwd for helper calls. + +## Failure Modes + +- `FM-01` A future Kimi CLI changes its flags; the adapter tests and explicit docs must be updated together rather than silently probing alternatives. +- `FM-02` Kimi is launched with the caller cwd due to a missed `cmd.Dir`; the cwd test must fail. + +## Traceability + +| Requirement ID | Solution refs | Contracts / invariants | Failure refs | +| --- | --- | --- | --- | +| `REQ-01` | `SOL-01`, `SOL-04` | `CTR-01` | `FM-01` | +| `REQ-02`–`REQ-04` | `SOL-02`, `SOL-03` | `CTR-01`, `INV-01`–`INV-03` | `FM-01`, `FM-02` | +| `REQ-05` | `SOL-01` | `INV-01` | `FM-01` | diff --git a/memory-bank/features/FT-018/implementation-plan.md b/memory-bank/features/FT-018/implementation-plan.md new file mode 100644 index 0000000..e6b0f63 --- /dev/null +++ b/memory-bank/features/FT-018/implementation-plan.md @@ -0,0 +1,70 @@ +--- +title: "FT-018: Implementation Plan" +doc_kind: feature +doc_function: derived +purpose: "Execution plan for the agent CLI launch compatibility fix." +derived_from: + - brief.md + - design.md + - ../../engineering/testing-policy.md +status: active +audience: humans_and_agents +must_not_define: + - ft_018_scope + - ft_018_selected_design + - ft_018_acceptance_criteria +--- + +# FT-018: Implementation Plan + +## Grounding / Support References + +| Path / module | Current role | Why relevant | Reuse / mirror | +| --- | --- | --- | --- | +| `cmd/start-issue/main.go` | Go adapter and process execution | Emits launch/helper commands | Update Kimi args and cwd only | +| `cmd/start-issue/main_test.go` | Go adapter/cwd regression tests | Verifies command shape and process cwd | Extend Kimi expectations | +| `cmd/start-issue/parity_integration_test.go` | Bash-v1 vs Go observable parity | Detects output drift | Keep parity contract aligned | +| `cmd/start-issue/testdata/bash-v1/scripts/lib/start_issue/{agent,output}.sh` | Baseline fixture | Must represent the corrected public contract | Mirror Kimi cwd/args | +| `README.md`, `README.ru.md`, `docs/agent-examples*`, `doc/spec.md` | User contract | Prevents docs from reintroducing the bad flag | Update examples and mapping | + +## Test Strategy + +| Test surface | Canonical refs | Planned automated coverage | Required command | +| --- | --- | --- | --- | +| Kimi command and helper args | `REQ-02`–`REQ-04`, `SC-01` | Exact args omit `--work-dir`/`--yolo`; helper runs with root cwd | `go test ./cmd/start-issue` | +| Agent cwd behavior | `REQ-02`, `FM-02` | Fake Kimi records cwd; launch must use worktree | `go test ./cmd/start-issue` | +| Baseline parity and docs/index | `REQ-01`, `REQ-05`, `CHK-02` | Updated fixture matches Go and memory-bank links remain valid | `make test` | + +## Open Questions / Ambiguities + +None after checking the installed `kimi --help` and current Kimi Code CLI reference. + +## Environment Contract + +| Area | Contract | Failure symptom | +| --- | --- | --- | +| Kimi CLI | Current prompt interface supports `-p/--prompt` and `--model`; no `--work-dir` | Old launch fails with unknown option | +| Tests | Agent binaries are fakes or dry-run only | Network or real agent invocation appears | +| Repository gate | `make test` is required before handoff | Unverified adapter or documentation drift | + +## Preconditions + +- `PRE-01` `brief.md` and `design.md` are active and define the Kimi cwd contract. +- `PRE-02` Existing non-Kimi parity cases remain unchanged. + +## Workstreams + +- `WS-01` Update Go Kimi adapter, helper cwd execution, and focused tests (`REQ-02`–`REQ-04`). +- `WS-02` Update Bash parity fixture and all command examples (`REQ-05`). +- `WS-03` Run focused and repository verification (`REQ-01`, `CHK-01`, `CHK-02`). + +## Execution Order + +1. `STEP-01` Update adapter command/cwd mapping and tests; verify with `go test ./cmd/start-issue`. +2. `STEP-02` Update the parity fixture and docs/spec; verify no product or docs reference emits `--work-dir`. +3. `STEP-03` Run `make test`, inspect the diff for unrelated adapter changes, and record evidence for `CHK-01`/`CHK-02`. + +## Stop Conditions / Fallback + +- `STOP-01` If parity exposes a non-Kimi regression, stop and restore only the unrelated drift; do not broaden the Kimi adapter. +- `STOP-02` If the installed Kimi CLI contradicts the documented interface, stop before adding version probing and escalate as a new design decision. diff --git a/memory-bank/features/FT-019/README.md b/memory-bank/features/FT-019/README.md new file mode 100644 index 0000000..5b76c5e --- /dev/null +++ b/memory-bank/features/FT-019/README.md @@ -0,0 +1,16 @@ +--- +title: "FT-019: CI sandbox E2E coverage" +doc_kind: feature +doc_function: index +purpose: "Navigation for deterministic end-to-end coverage of the built Go CLI in CI." +derived_from: + - brief.md +status: active +audience: humans_and_agents +--- + +# FT-019: CI sandbox E2E coverage + +- [brief.md](brief.md) — scope and acceptance contract. +- [design.md](design.md) — sandbox boundary and fixture strategy. +- [implementation-plan.md](implementation-plan.md) — implementation and CI sequence. diff --git a/memory-bank/features/FT-019/brief.md b/memory-bank/features/FT-019/brief.md new file mode 100644 index 0000000..23b8137 --- /dev/null +++ b/memory-bank/features/FT-019/brief.md @@ -0,0 +1,96 @@ +--- +title: "FT-019: CI sandbox E2E coverage" +doc_kind: feature +doc_function: canonical +purpose: "Canonical brief for running deterministic end-to-end smoke scenarios against the built Go CLI in CI/CD." +derived_from: + - ../../flows/feature-flow.md + - ../../engineering/testing-policy.md + - ../../ops/development.md +status: active +delivery_status: in_progress +audience: humans_and_agents +must_not_define: + - implementation_sequence +--- + +# FT-019: CI sandbox E2E coverage + +## What + +### Problem + +The existing real-Codex E2E requires authenticated GitHub, a private fixture +repository, and an interactive external agent, so it cannot be a reliable CI +gate. Unit and parity tests do not exercise the built executable against a real +git worktree process boundary. + +### Outcome + +CI runs a network-free sandbox E2E against the built Go binary and verifies the +complete local issue-start path with controlled external command fakes. + +| Metric ID | Metric | Baseline | Target | Measurement method | +| --- | --- | --- | --- | --- | +| `MET-01` | Built-binary workflow coverage | Unit/parity tests plus manual real-agent E2E | At least one deterministic CI E2E job covers worktree/init/agent and dry-run paths | CI job output | + +## Scope + +- `REQ-01` Run the actual built `start-issue` binary in an isolated temporary sandbox. +- `REQ-02` Use a real local git repository/worktree and fake `gh` and agent CLIs; do not require network, credentials, or real agents. +- `REQ-03` Cover an executing Kimi launch with rendered issue prompt, model, cwd, and `init.sh`. +- `REQ-04` Cover the dry-run/no-agent path and prove it creates no worktree. +- `REQ-05` Expose the sandbox E2E through Make and run it in CI/CD. + +## Non-Scope + +- `NS-01` Do not replace the existing manual real-Codex human-gate E2E. +- `NS-02` Do not claim that fake agents prove vendor CLI compatibility or model behavior. +- `NS-03` Do not use GitHub API, authenticated secrets, Docker, or persistent shared state. + +## Constraints / Assumptions + +- `ASM-01` CI provides Go, Bash, git, and standard POSIX utilities. +- `CON-01` The test must clean its own uniquely-created temporary directory on success and failure. + +## Design Requirement Decision + +| Decision | Reason | Downstream owner | +| --- | --- | --- | +| `Design required: yes` | The feature introduces a CI test boundary, fake external processes, cleanup guarantees, and a new workflow gate. | `design.md` | + +## Verify + +### Exit Criteria + +- `EC-01` Sandbox E2E runs locally against a built binary without network or secrets. +- `EC-02` The executing scenario proves git worktree creation, `init.sh`, rendered prompt, model, and Kimi cwd. +- `EC-03` The dry-run scenario proves no worktree directory is created. +- `EC-04` CI invokes the same Make target and reports a failing exit code on assertion failure. + +### Traceability matrix + +| Requirement ID | Acceptance refs | Checks | Evidence IDs | +| --- | --- | --- | --- | +| `REQ-01` | `EC-01`, `SC-01` | `CHK-01` | `EVID-01` | +| `REQ-02` | `EC-01`, `SC-01` | `CHK-01` | `EVID-01` | +| `REQ-03` | `EC-02`, `SC-01` | `CHK-01` | `EVID-01` | +| `REQ-04` | `EC-03`, `SC-02` | `CHK-01` | `EVID-01` | +| `REQ-05` | `EC-04` | `CHK-02` | `EVID-02` | + +### Acceptance Scenarios + +- `SC-01` Given an isolated git fixture and fake external CLIs, when the built binary starts issue #42 with Kimi, then the worktree, init marker, rendered prompt, model, and cwd are correct. +- `SC-02` Given the same fixture, when the built binary runs `--dry-run --agent none`, then it prints the plan and does not create the requested worktree directory. + +### Checks + +| Check ID | Covers | How to check | Expected result | Evidence path | +| --- | --- | --- | --- | --- | +| `CHK-01` | `EC-01`–`EC-03`, `SC-01`, `SC-02` | `make e2e-sandbox` | Both sandbox scenarios pass | `artifacts/ft-019/verify/sandbox-e2e/` | +| `CHK-02` | `EC-04` | Inspect CI `sandbox-e2e` job | The job runs the Make target and fails on non-zero status | `artifacts/ft-019/verify/ci/` | + +### Evidence + +- `EVID-01` Local sandbox E2E output. +- `EVID-02` CI job output and workflow definition. diff --git a/memory-bank/features/FT-019/design.md b/memory-bank/features/FT-019/design.md new file mode 100644 index 0000000..5119ea3 --- /dev/null +++ b/memory-bank/features/FT-019/design.md @@ -0,0 +1,71 @@ +--- +title: "FT-019: Design" +doc_kind: feature +doc_function: canonical +purpose: "Solution-space contract for deterministic built-binary E2E execution in a temporary local sandbox." +derived_from: + - brief.md +status: active +audience: humans_and_agents +must_not_define: + - ft_019_scope + - ft_019_acceptance_criteria + - implementation_sequence +--- + +# FT-019: Design + +## C4 Applicability + +| C4 ID | Decision | Trigger / reason | Artifact | +| --- | --- | --- | --- | +| `C4-00` | `not required` | This is a test harness and CI job within the existing CLI container; it creates no deployed runtime boundary. | none | + +## Selected Solution + +- `SOL-01` Build the Go binary first and invoke it as a subprocess from `test/e2e/sandbox.sh`. +- `SOL-02` Create a temporary local git repository with a commit and origin remote, allowing the product's real git/worktree code to run. +- `SOL-03` Put fake `gh` and Kimi executables first on PATH. The fake `gh` serves issue JSON and auth status; the fake Kimi records cwd and arguments. +- `SOL-04` Run two scenarios: a real worktree/init/Kimi launch and a no-agent dry-run. +- `SOL-05` Add `make e2e-sandbox` and a dedicated CI job. Keep real-Codex E2E opt-in and separate. + +## Alternatives Considered + +| Alternative ID | Option | Why not selected | +| --- | --- | --- | +| `ALT-01` | Use the real GitHub fixture and agent in CI | Requires credentials/network and is nondeterministic. | +| `ALT-02` | Test only Go functions | Does not verify the built executable and process/filesystem boundaries. | +| `ALT-03` | Fake git as well | Would not exercise actual worktree creation and reuse behavior. | + +## Accepted Local Decisions + +- `SD-01` The sandbox has no external network dependency; every command that could access a service is controlled by PATH fakes. +- `SD-02` The temporary root is created by `mktemp` and removed by an EXIT trap, including failure paths. +- `SD-03` Assertions are made against explicit artifacts and command logs, not broad output snapshots. + +## Contracts + +| Contract ID | Input / Output | Producer / Consumer | Semantics / Constraints | +| --- | --- | --- | --- | +| `CTR-01` | Built binary + isolated env → exit status/artifacts | E2E script / start-issue | Non-zero means CI failure; no credentials are needed. | +| `CTR-02` | Fake Kimi log → cwd and rendered args | fake CLI / E2E assertions | cwd equals the created worktree and model/prompt are present. | + +## Invariants + +- `INV-01` The sandbox never invokes a real `gh`, Kimi, Codex, Claude, or Pi binary. +- `INV-02` The sandbox never writes outside its unique temporary root except the tested binary's normal process execution. +- `INV-03` Dry-run does not create its requested worktree directory. + +## Failure Modes + +- `FM-01` PATH fake is not executable or is shadowed; the script fails before claiming PASS. +- `FM-02` Cleanup is skipped after an assertion failure; the EXIT trap still removes the unique root. +- `FM-03` A product change bypasses the worktree cwd or prompt rendering; explicit log assertions fail. + +## Traceability + +| Requirement ID | Solution refs | Contracts / invariants | Failure refs | +| --- | --- | --- | --- | +| `REQ-01`–`REQ-02` | `SOL-01`–`SOL-03` | `CTR-01`, `INV-01`, `INV-02` | `FM-01`, `FM-02` | +| `REQ-03` | `SOL-02`–`SOL-04` | `CTR-02`, `INV-03` | `FM-03` | +| `REQ-04`–`REQ-05` | `SOL-04`, `SOL-05` | `CTR-01`, `INV-03` | `FM-02` | diff --git a/memory-bank/features/FT-019/implementation-plan.md b/memory-bank/features/FT-019/implementation-plan.md new file mode 100644 index 0000000..615e16d --- /dev/null +++ b/memory-bank/features/FT-019/implementation-plan.md @@ -0,0 +1,65 @@ +--- +title: "FT-019: Implementation Plan" +doc_kind: feature +doc_function: derived +purpose: "Execution plan for sandbox E2E coverage and CI integration." +derived_from: + - brief.md + - design.md + - ../../engineering/testing-policy.md +status: active +audience: humans_and_agents +must_not_define: + - ft_019_scope + - ft_019_selected_design + - ft_019_acceptance_criteria +--- + +# FT-019: Implementation Plan + +## Grounding / Support References + +| Path / module | Current role | Why relevant | Reuse / mirror | +| --- | --- | --- | --- | +| `test/e2e/human-gate.sh` | Existing real-agent E2E | Establishes E2E script conventions and opt-in boundary | Keep separate because it needs secrets/interactive Codex | +| `Makefile` | Build/test entrypoint | Owns the local and CI target | Add sandbox target beside human-gate target | +| `.github/workflows/ci.yml` | CI checks | Runs Go build and tests | Add network-free sandbox job | +| `cmd/start-issue/main.go` | Product executable | Must be exercised as a built subprocess | Do not add test-only product hooks | + +## Test Strategy + +| Test surface | Canonical refs | Planned automated coverage | Required command | +| --- | --- | --- | --- | +| Built binary issue start | `REQ-01`–`REQ-03`, `SC-01` | Real local git/worktree, fake gh/Kimi, init marker and cwd/args assertions | `make e2e-sandbox` | +| Built binary dry-run | `REQ-04`, `SC-02` | Output and no-directory assertion | `make e2e-sandbox` | +| CI wiring | `REQ-05` | Dedicated workflow job invokes Make target | CI run | + +## Environment Contract + +| Area | Contract | Failure symptom | +| --- | --- | --- | +| Toolchain | Go binary is built by Make; Bash, git, and POSIX utilities are available | Missing executable or command failure | +| Network/secrets | No network, GitHub auth, or agent credentials are allowed | Any fake command miss or unexpected external call fails | +| Cleanup | Temporary fixture root is unique and EXIT-trapped | No retained sandbox state after run | + +## Preconditions + +- `PRE-01` Go build succeeds. +- `PRE-02` `git` is available in the CI runner. + +## Workstreams + +- `WS-01` Implement the sandbox fixture and assertions (`REQ-01`–`REQ-04`). +- `WS-02` Add Make and CI wiring (`REQ-05`). +- `WS-03` Document usage and verify all repository gates. + +## Execution Order + +1. `STEP-01` Add `test/e2e/sandbox.sh`; run it against a local build and verify both scenarios. +2. `STEP-02` Add `make e2e-sandbox` and CI job; verify the job has no secrets or network setup. +3. `STEP-03` Update README/ops docs and run `make e2e-sandbox` plus `make test`. + +## Stop Conditions / Fallback + +- `STOP-01` If the script needs a real external service, stop and keep the test out of CI; do not weaken the no-network contract. +- `STOP-02` If cleanup or isolation fails, stop before merging the CI job. diff --git a/memory-bank/features/README.md b/memory-bank/features/README.md index 76c30f9..7682833 100644 --- a/memory-bank/features/README.md +++ b/memory-bank/features/README.md @@ -37,3 +37,9 @@ audience: humans_and_agents - [FT-017: Go parity-first migration](FT-017/README.md) Issue #34 package for migrating the CLI to Go with executable parity before cutover. The Bash runtime remains the baseline until the package's parity evidence permits cutover. + +- [FT-018: Agent CLI launch compatibility](FT-018/README.md) + Issue #34 follow-up package for current Kimi Code CLI command and cwd compatibility. + +- [FT-019: CI sandbox E2E coverage](FT-019/README.md) + Deterministic built-binary E2E scenarios for local worktree/agent and dry-run paths in CI/CD. diff --git a/memory-bank/ops/development.md b/memory-bank/ops/development.md index aa7c6d5..650c75e 100644 --- a/memory-bank/ops/development.md +++ b/memory-bank/ops/development.md @@ -46,6 +46,14 @@ current repository: START_ISSUE_E2E=1 make e2e-human-gate ``` +Run the deterministic, network-free built-binary E2E used in CI: + +```bash +make e2e-sandbox +``` + +It uses fake `gh` and agent commands but real local git/worktree operations. + When a live E2E must be visible in cmux, follow the canonical cmux-tab procedure in [`../../AGENTS.md`](../../AGENTS.md): find the `start-issue` workspace using `cmux tree --all`, create a terminal surface in its active pane, diff --git a/memory-bank/ops/release.md b/memory-bank/ops/release.md index ac0102d..1f25ed1 100644 --- a/memory-bank/ops/release.md +++ b/memory-bank/ops/release.md @@ -21,12 +21,12 @@ Releases. There is no server deployment. 1. Add user-facing changes under `## [Unreleased]` in `CHANGELOG.md`. 2. From a clean worktree, run the required checks and create the SemVer tag. - The tag is the source of the release version: + The tag is the source of the release version: ```bash make test make build -git tag vX.Y.Z +git tag -a vX.Y.Z -m "Release vX.Y.Z" ``` 3. Publish with: @@ -35,6 +35,12 @@ git tag vX.Y.Z git push origin master --follow-tags ``` +If a lightweight tag was created instead, push that exact tag explicitly: + +```bash +git push origin vX.Y.Z +``` + 4. GitHub Actions reruns the test suite and uses GoReleaser to publish the binaries and checksum manifest. diff --git a/test/e2e/sandbox.sh b/test/e2e/sandbox.sh new file mode 100755 index 0000000..41fa469 --- /dev/null +++ b/test/e2e/sandbox.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +# Deterministic, network-free E2E smoke test for the built Go CLI. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +start_issue_bin="${START_ISSUE_SANDBOX_BINARY:-$repo_root/.build/start-issue}" +fixture_root="$(mktemp -d "${TMPDIR:-/tmp}/start-issue-sandbox-e2e.XXXXXX")" +fixture_root="$(cd "$fixture_root" && pwd -P)" +trap 'rm -rf -- "$fixture_root"' EXIT + +fail() { + printf 'E2E sandbox: %s\n' "$*" >&2 + exit 1 +} + +[[ -x "$start_issue_bin" ]] || fail "start-issue executable not found: $start_issue_bin" + +repo="$fixture_root/repo" +remote="$fixture_root/remote.git" +worktrees="$fixture_root/worktrees" +home="$fixture_root/home" +bin="$fixture_root/bin" +kimi_log="$fixture_root/kimi.log" +init_marker="$fixture_root/init.marker" +mkdir -p "$repo" "$worktrees" "$home/.config/start-issue" "$bin" + +git -C "$repo" init -q +git -C "$repo" config user.email sandbox@example.invalid +git -C "$repo" config user.name 'start-issue sandbox' +printf '# sandbox fixture\n' > "$repo/README.md" +cat > "$repo/init.sh" < "$init_marker" +EOF +chmod +x "$repo/init.sh" +git -C "$repo" add README.md init.sh +git -C "$repo" commit -q -m 'sandbox fixture' +git init --bare -q "$remote" +git -C "$repo" remote add origin "$remote" +git -C "$repo" push -q -u origin HEAD + +cat > "$bin/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +case "${1:-}" in + auth) + [[ "${2:-}" == "status" ]] || exit 1 + ;; + api) + printf '%s\n' '{"number":42,"title":"Sandbox E2E issue","body":"controlled fixture","labels":[{"name":"feature"}]}' + ;; + *) + printf 'unexpected gh invocation: %s\n' "$*" >&2 + exit 1 + ;; +esac +EOF + +cat > "$bin/kimi" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf 'cwd=%s\nargs=%s\n' "$PWD" "$*" > "$KIMI_LOG" +EOF + +chmod +x "$bin/gh" "$bin/kimi" + +export HOME="$home" +export KIMI_LOG="$kimi_log" +export START_ISSUE_REPOSITORY=acme/sandbox +export PATH="$bin:$PATH" + +printf 'Sandbox E2E: issue start with real git worktree and fake Kimi\n' +( + cd "$repo" + "$start_issue_bin" 42 \ + --repo acme/sandbox \ + --agent kimi \ + --model sandbox-model \ + --worktree-dir "$worktrees" \ + --prompt 'Implement {ISSUE_URL} in {WORKTREE_PATH}' +) + +worktree="$worktrees/feature/issue-42-sandbox-e2e-issue" +[[ -d "$worktree" ]] || fail "worktree was not created: $worktree" +[[ -f "$init_marker" ]] || fail "init.sh was not executed" +[[ "$(cat "$init_marker")" == "$worktree" ]] || fail "init.sh ran outside worktree: got $(cat "$init_marker"), want $worktree" +[[ "$(sed -n '1p' "$kimi_log")" == "cwd=$worktree" ]] || fail "Kimi cwd is wrong: $(sed -n '1p' "$kimi_log")" +grep -F -- '--model sandbox-model -p Implement https://github.com/acme/sandbox/issues/42 in' "$kimi_log" >/dev/null || \ + fail "Kimi arguments do not contain rendered prompt" +git -C "$worktree" status --porcelain | grep -v '^?? \.start-issue/' >/dev/null && \ + fail "worktree contains unexpected changes" + +printf 'Sandbox E2E: dry-run no-agent path\n' +dry_run_log="$fixture_root/dry-run.log" +( + cd "$repo" + "$start_issue_bin" 43 \ + --repo acme/sandbox \ + --agent none \ + --no-init \ + --dry-run \ + --flat \ + --worktree-dir "$fixture_root/dry-run-worktrees" +) > "$dry_run_log" +grep -F '[DRY-RUN] Would run: git worktree add' "$dry_run_log" >/dev/null || fail "dry-run did not plan worktree creation" +grep -F 'Agent: none' "$dry_run_log" >/dev/null || fail "dry-run did not resolve no-agent" +[[ ! -d "$fixture_root/dry-run-worktrees" ]] || fail "dry-run created worktree directory" + +printf 'PASS: sandbox E2E scenarios\n' From 8a28706b91a39156c35023ba59758db7322def19 Mon Sep 17 00:00:00 2001 From: Danil Pismenny Date: Tue, 4 Aug 2026 13:49:57 +0300 Subject: [PATCH 12/12] Return dependency errors on clean CI environments --- cmd/start-issue/main.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/start-issue/main.go b/cmd/start-issue/main.go index 5990526..7695922 100644 --- a/cmd/start-issue/main.go +++ b/cmd/start-issue/main.go @@ -266,12 +266,13 @@ func run(o options) error { } func runWithReader(o options, reader *bufio.Reader) error { - if err := maybeRunFirstRunOnboarding(o.dryRun, o.command, reader); err != nil { - return err - } + onboardingErr := maybeRunFirstRunOnboarding(o.dryRun, o.command, reader) if err := need("git"); err != nil { return err } + if onboardingErr != nil { + return onboardingErr + } root, err := output("git", "rev-parse", "--show-toplevel") if err != nil { return errors.New("Not in a git repository")