From 675135b77e57506dc3f083ae8a81db49941efbaf Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Sat, 11 Jul 2026 07:33:41 -0700 Subject: [PATCH] Initial Hop alpha --- .gitignore | 10 + README.md | 215 ++++ cmd/hop/main.go | 11 + go.mod | 18 + go.sum | 23 + internal/hop/cli.go | 466 +++++++++ internal/hop/db.go | 1498 ++++++++++++++++++++++++++++ internal/hop/git.go | 1033 +++++++++++++++++++ internal/hop/id.go | 28 + internal/hop/model.go | 155 +++ internal/hop/service.go | 1008 +++++++++++++++++++ internal/hop/service_smoke_test.go | 578 +++++++++++ internal/hop/skill.go | 102 ++ internal/hop/skill_test.go | 63 ++ internal/hop/util.go | 177 ++++ skills/hop/SKILL.md | 106 ++ skills/hop/agents/openai.yaml | 4 + skills/hop/embed.go | 9 + skills/hop/references/protocol.md | 108 ++ 19 files changed, 5612 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cmd/hop/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/hop/cli.go create mode 100644 internal/hop/db.go create mode 100644 internal/hop/git.go create mode 100644 internal/hop/id.go create mode 100644 internal/hop/model.go create mode 100644 internal/hop/service.go create mode 100644 internal/hop/service_smoke_test.go create mode 100644 internal/hop/skill.go create mode 100644 internal/hop/skill_test.go create mode 100644 internal/hop/util.go create mode 100644 skills/hop/SKILL.md create mode 100644 skills/hop/agents/openai.yaml create mode 100644 skills/hop/embed.go create mode 100644 skills/hop/references/protocol.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..411ac70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.DS_Store + +# Hop's local state and isolated workspaces. +/.hop/ + +# Local build and verification artifacts. +/hop +/SHA256SUMS +/coverage.out +*.test diff --git a/README.md b/README.md new file mode 100644 index 0000000..5be49ff --- /dev/null +++ b/README.md @@ -0,0 +1,215 @@ +# Hop + +Hop is an experimental prompt-native version-control kernel for coding agents. + +Every instruction becomes an immutable state before it is delivered. Agent work produces checkpoint and proposal states beneath it. Landing a proposal creates a new accepted state without moving the user’s Git branch or checkout. + +```text +A0 accepted +├─ P1 prompt +│ └─ C1 checkpoint +│ └─ R1 proposal +└─ P2 prompt + └─ R2 proposal + +A0 + R1 → A1 accepted +``` + +Git provides source-tree storage, diffs, worktrees, and interoperability. Hop’s prompt-state graph, evidence, and acceptance history live separately in `.hop/hop.db`. + +## Current status + +This repository contains the first local alpha kernel. It supports: + +- Existing and unborn Git repositories +- Pre-delivery prompt states +- Isolated detached worktrees per attempt +- Immutable checkpoints and proposals +- Checks bound to exact source trees +- Conservative path-level conflict detection +- Three-tree composition of disjoint proposals +- Optional validation on the final integrated tree +- Compare-and-swap acceptance +- Forward-only undo of the latest accepted transition +- Git-compatible accepted commits under `refs/hop/accepted` +- SQLite WAL state graph and machine-readable JSON output +- Embedded, installable vendor-neutral agent skill + +It does not yet include an agent-process adapter, project knowledge, claims, remote synchronization, a GUI, or semantic merging. + +## How the pieces fit + +```text +Human/controller + │ records exact prompt + ▼ +hop start ──> prompt state + isolated workspace + │ + ▼ + agent + Hop skill + edit → check → propose + │ + ▼ +Human/controller review → land → accepted state +``` + +The controller owns prompt delivery and acceptance. The skill teaches the agent how to behave inside the assigned state. The Hop kernel computes and stores trees, evidence, ancestry, conflicts, and accepted history. A skill alone cannot guarantee that an initial prompt was saved before the agent saw it; `hop start` or a future process adapter must create that boundary first. + +## Build + +Requires Go 1.26+ and Git. + +```bash +go build -o hop ./cmd/hop +./hop help +``` + +## Quick start + +Install the embedded skill for Codex. By default this writes to `${CODEX_HOME:-~/.codex}/skills/hop`: + +```bash +hop skill install +``` + +Export it to another agent’s skills directory with: + +```bash +hop skill install --path /path/to/agent/skills +``` + +Initialize Hop without changing the current Git branch, working tree, or index: + +```bash +hop init +``` + +Create a prompt state and isolated workspace. The string passed to `hop start` must be the same instruction delivered to the agent: + +```bash +PROMPT='Use $hop. Add password reset emails' +hop start --agent codex "$PROMPT" +``` + +Pass the prompt as one quoted argument; Hop preserves its bytes, including leading/trailing whitespace and newlines. Hop prints the prompt-state ID, task/attempt IDs, workspace, and environment. The prompt is durable before the command returns, so it is then safe to deliver to an agent. + +Load the environment and enter the isolated workspace using the returned prompt-state ID: + +```bash +eval "$(hop env P_...)" +``` + +Now launch Codex, Claude Code, or another harness from that workspace and deliver `$PROMPT`. Explicitly invoking `$hop` is the most reliable way to load the skill. The skill verifies the Hop state, confines edits to the workspace, records exact-tree checks, and freezes the result as a proposal. + +After the agent edits the printed workspace: + +```bash +hop check P_... -- go test ./... +hop propose --summary "Added password reset emails" P_... +hop land R_... -- go test ./... +``` + +The command supplied to `land` runs in a temporary worktree containing the exact final tree that would become accepted. If it fails, `refs/hop/accepted` and the SQLite accepted head do not move. + +Inspect the project: + +```bash +hop status +hop graph +hop state P_... +hop diff R_... +hop history +hop doctor +``` + +Inspect or hand the skill to a harness without installing it: + +```bash +hop skill print +``` + +Create a follow-up prompt in an existing attempt: + +```bash +hop prompt --from P_... "Use Resend instead of SendGrid" +``` + +Hop first captures the workspace as a checkpoint, then writes the follow-up prompt as its child before returning. + +Load the returned prompt state with `eval "$(hop env P_...)"` before delivering that exact follow-up to the agent. + +Undo the latest accepted transition without rewriting history: + +```bash +hop undo +``` + +## Parallel work + +Two prompts started from the same accepted state receive independent worktrees: + +```bash +hop start --agent codex "Add a health endpoint" +hop start --agent claude "Add an account empty state" +``` + +If their changed paths are disjoint, both proposals can land in either order. The second proposal is composed onto the latest accepted tree and may be validated there. + +If both proposals changed the same path since their shared base, Hop blocks the stale proposal even when Git could textually merge it. The proposal remains addressable for review or a future reconciliation prompt. + +## State model + +| Kind | Prefix | Meaning | +|---|---:|---| +| Accepted | `A_` | Canonical project revision | +| Prompt | `P_` | Exact instruction and pre-effect context | +| Checkpoint | `C_` | Immutable workspace progress | +| Proposal | `R_` | Frozen candidate result | +| Failed | `F_` | Terminal failed execution state | +| Cancelled | `X_` | Terminal cancelled execution state | + +Each Hop state references an immutable Git tree and synthetic commit. Multiple prompt states can reference the same tree while remaining distinct occurrences. + +Source objects are pinned beneath `refs/hop/states/*`, preventing Git garbage collection from deleting states referenced by SQLite. Accepted source history is mirrored at `refs/hop/accepted` and can be exported as ordinary Git commits later. + +## Storage + +```text +.hop/ +├── hop.db SQLite state graph and audit log +├── workspaces/ isolated attempt worktrees +├── integration/ temporary final-state validation worktrees +└── accept.lock short-lived acceptance serialization lock +``` + +The repository’s `.git/info/exclude` receives `.hop/`; the public `.gitignore`, current branch, and real Git index are left alone. + +Initialization refuses to proceed if `.hop` is already tracked as user-owned project source. + +## JSON protocol + +Add `--json` anywhere: + +```bash +hop --json status +hop start --agent codex --json "Add password reset" +``` + +Successful output follows: + +```json +{ + "ok": true, + "data": {} +} +``` + +The JSON shape is an alpha contract and may evolve before the first tagged release. + +## Safety boundary + +Hop currently treats any shared changed path as a conflict. Disjoint files can still conflict behaviorally, so manual acceptance remains the default and final-tree validation is strongly recommended. + +Agent-reported scope and test claims are never used as the source of truth. Hop computes source trees and changed paths itself. + +Prompt text and check output are currently stored locally in SQLite without encryption or redaction. Do not put credentials or sensitive secrets into this alpha ledger. diff --git a/cmd/hop/main.go b/cmd/hop/main.go new file mode 100644 index 0000000..c4fe403 --- /dev/null +++ b/cmd/hop/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "github.com/hop-vcs/hop/internal/hop" +) + +func main() { + os.Exit(hop.RunCLI(os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8464255 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module github.com/hop-vcs/hop + +go 1.26 + +require modernc.org/sqlite v1.39.1 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.36.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7aee46f --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4= +modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= diff --git a/internal/hop/cli.go b/internal/hop/cli.go new file mode 100644 index 0000000..90ad76e --- /dev/null +++ b/internal/hop/cli.go @@ -0,0 +1,466 @@ +package hop + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "strings" +) + +const usageText = `Hop — prompt-native version control + +Usage: + hop init [path] + hop prompt [--from STATE] [--agent NAME] "instruction" + hop checkpoint STATE + hop check STATE -- COMMAND [ARG...] + hop propose [--summary TEXT] STATE + hop accept STATE [-- COMMAND [ARG...]] + hop status + hop graph + hop state STATE + hop env STATE + hop diff STATE + hop history + hop undo + hop doctor [--repair] + hop skill install [--path SKILLS_DIR] [--force] + hop skill print + hop version + +Add --json anywhere for machine-readable output. +` + +const Version = "0.1.0-alpha.1" + +func RunCLI(args []string, stdout, stderr io.Writer) int { + jsonOutput, args := removeFlag(args, "--json") + if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" { + fmt.Fprint(stdout, usageText) + return 0 + } + + ctx := context.Background() + command := args[0] + commandArgs := args[1:] + if command == "version" || command == "--version" { + if jsonOutput { + writeJSON(stdout, map[string]any{"ok": true, "version": Version}) + } else { + fmt.Fprintf(stdout, "hop %s\n", Version) + } + return 0 + } + if command == "skill" { + return runSkillCLI(commandArgs, jsonOutput, stdout, stderr) + } + + if command == "init" { + path := "." + if len(commandArgs) > 1 { + fmt.Fprintln(stderr, "hop init accepts at most one path") + return 2 + } + if len(commandArgs) == 1 { + path = commandArgs[0] + } + service, state, err := InitProject(ctx, path) + if err != nil { + return printCLIError(err, jsonOutput, stdout, stderr) + } + defer service.Close() + if jsonOutput { + writeJSON(stdout, map[string]any{"ok": true, "root": service.Root, "state": state}) + } else { + fmt.Fprintf(stdout, "Initialized Hop at %s\nAccepted %s · tree %s\n", service.Root, state.ID, shortHash(state.SourceTree)) + } + return 0 + } + + service, err := OpenProject(".") + if err != nil { + return printCLIError(err, jsonOutput, stdout, stderr) + } + defer service.Close() + + var value any + switch command { + case "prompt", "start": + fs := flag.NewFlagSet("prompt", flag.ContinueOnError) + fs.SetOutput(stderr) + from := fs.String("from", "", "continue an existing attempt") + agent := fs.String("agent", "", "agent or harness name") + if err := fs.Parse(commandArgs); err != nil { + return 2 + } + if len(fs.Args()) != 1 || strings.TrimSpace(fs.Args()[0]) == "" { + fmt.Fprintln(stderr, "exactly one non-empty prompt argument is required; quote prompts containing spaces") + return 2 + } + message := fs.Args()[0] + result, err := service.CreatePrompt(ctx, message, *from, *agent) + if err != nil { + return printCLIError(err, jsonOutput, stdout, stderr) + } + value = result + if !jsonOutput { + if result.Checkpoint != nil { + fmt.Fprintf(stdout, "Checkpointed %s before the follow-up\n", result.Checkpoint.ID) + } + fmt.Fprintf(stdout, "Created prompt state %s before delivery\nWorkspace: %s\n", result.Prompt.ID, result.Workspace) + fmt.Fprintf(stdout, "Environment: HOP_ROOT=%s HOP_STATE_ID=%s HOP_TASK_ID=%s HOP_ATTEMPT_ID=%s\n", service.Root, result.Prompt.ID, result.Task.ID, result.Attempt.ID) + } + + case "checkpoint": + if len(commandArgs) != 1 { + fmt.Fprintln(stderr, "usage: hop checkpoint STATE") + return 2 + } + state, err := service.Checkpoint(ctx, commandArgs[0]) + if err != nil { + return printCLIError(err, jsonOutput, stdout, stderr) + } + value = state + if !jsonOutput { + fmt.Fprintf(stdout, "Created checkpoint %s · tree %s\n", state.ID, shortHash(state.SourceTree)) + } + + case "check": + stateID, argv, ok := splitCommand(commandArgs) + if !ok { + fmt.Fprintln(stderr, "usage: hop check STATE -- COMMAND [ARG...]") + return 2 + } + check, err := service.RunCheck(ctx, stateID, argv) + value = check + if !jsonOutput { + fmt.Fprintf(stdout, "$ %s\n%s", shellQuote(check.Command), check.Output) + if check.Output != "" && !strings.HasSuffix(check.Output, "\n") { + fmt.Fprintln(stdout) + } + fmt.Fprintf(stdout, "Check %s · exit %d · tree %s\n", check.ID, check.ExitCode, shortHash(check.TreeHash)) + } + if err != nil { + return printCLIError(err, jsonOutput, stdout, stderr) + } + + case "propose", "submit": + fs := flag.NewFlagSet("propose", flag.ContinueOnError) + fs.SetOutput(stderr) + summary := fs.String("summary", "", "result summary") + if err := fs.Parse(commandArgs); err != nil { + return 2 + } + if len(fs.Args()) != 1 { + fmt.Fprintln(stderr, "usage: hop propose [--summary TEXT] STATE") + return 2 + } + result, err := service.Propose(ctx, fs.Args()[0], *summary) + if err != nil { + return printCLIError(err, jsonOutput, stdout, stderr) + } + value = result + if !jsonOutput { + fmt.Fprintf(stdout, "Created proposal %s · tree %s · %d matching checks\n", result.Proposal.ID, shortHash(result.Proposal.SourceTree), len(result.Checks)) + } + + case "accept", "land": + stateID, argv, ok := splitOptionalCommand(commandArgs) + if !ok { + fmt.Fprintln(stderr, "usage: hop accept STATE [-- COMMAND [ARG...]]") + return 2 + } + result, err := service.Accept(ctx, stateID, argv) + value = result + if err != nil { + return printCLIError(err, jsonOutput, stdout, stderr) + } + if !jsonOutput { + fmt.Fprintf(stdout, "Accepted as %s · tree %s\n", result.State.ID, shortHash(result.State.SourceTree)) + if result.Check == nil { + fmt.Fprintln(stdout, "No final-state validation command was supplied; acceptance was manual.") + } + for _, warning := range result.Warnings { + fmt.Fprintf(stderr, "Warning: %s\n", warning) + } + } + + case "status": + status, err := service.Status(ctx) + if err != nil { + return printCLIError(err, jsonOutput, stdout, stderr) + } + value = status + if !jsonOutput { + fmt.Fprintf(stdout, "Accepted: %s · tree %s\n", status.AcceptedHead.ID, shortHash(status.AcceptedHead.SourceTree)) + if len(status.Attempts) == 0 { + fmt.Fprintln(stdout, "No attempts yet.") + } + for _, attempt := range status.Attempts { + fmt.Fprintf(stdout, "%s %-10s head=%s %s\n", attempt.ID, attempt.Status, attempt.HeadStateID, attempt.Workspace) + } + } + + case "graph": + rows, err := service.Graph(ctx) + if err != nil { + return printCLIError(err, jsonOutput, stdout, stderr) + } + value = rows + if !jsonOutput { + for _, row := range rows { + parents := make([]string, 0, len(row.Parents)) + for _, parent := range row.Parents { + parents = append(parents, parent.Role+"="+parent.StateID) + } + label := row.State.Summary + if label == "" { + label = row.State.Prompt + } + fmt.Fprintf(stdout, "%-10s %-28s %-50s %s\n", row.State.Kind, row.State.ID, strings.Join(parents, " "), label) + } + } + + case "state", "inspect": + if len(commandArgs) != 1 { + fmt.Fprintln(stderr, "usage: hop state STATE") + return 2 + } + state, err := service.State(ctx, commandArgs[0]) + if err != nil { + return printCLIError(err, jsonOutput, stdout, stderr) + } + value = state + if !jsonOutput { + fmt.Fprintf(stdout, "%s %s\nTree: %s\nCommit: %s\nDigest: %s\n", state.Kind, state.ID, state.SourceTree, state.GitCommit, state.Digest) + if state.Prompt != "" { + fmt.Fprintf(stdout, "Prompt: %s\n", state.Prompt) + } + if state.Summary != "" { + fmt.Fprintf(stdout, "Summary: %s\n", state.Summary) + } + for _, parent := range state.Parents { + fmt.Fprintf(stdout, "Parent: %-18s %s\n", parent.Role, parent.StateID) + } + } + + case "env": + if len(commandArgs) != 1 { + fmt.Fprintln(stderr, "usage: hop env STATE") + return 2 + } + result, err := service.EnvironmentForState(ctx, commandArgs[0]) + if err != nil { + return printCLIError(err, jsonOutput, stdout, stderr) + } + value = result + if !jsonOutput { + for _, name := range []string{"HOP_ROOT", "HOP_STATE_ID", "HOP_TASK_ID", "HOP_ATTEMPT_ID", "HOP_WORKSPACE"} { + fmt.Fprintf(stdout, "export %s=%s\n", name, shellQuote([]string{result.Variables[name]})) + } + fmt.Fprintf(stdout, "cd %s\n", shellQuote([]string{result.Workspace})) + } + + case "diff": + if len(commandArgs) != 1 { + fmt.Fprintln(stderr, "usage: hop diff STATE") + return 2 + } + diff, err := service.Diff(ctx, commandArgs[0]) + if err != nil { + return printCLIError(err, jsonOutput, stdout, stderr) + } + value = map[string]string{"state": commandArgs[0], "diff": diff} + if !jsonOutput { + fmt.Fprint(stdout, diff) + } + + case "history": + states, err := service.History(ctx) + if err != nil { + return printCLIError(err, jsonOutput, stdout, stderr) + } + value = states + if !jsonOutput { + for _, state := range states { + fmt.Fprintf(stdout, "%s %s %s\n", state.ID, shortHash(state.SourceTree), state.Summary) + } + } + + case "undo": + if len(commandArgs) != 0 { + fmt.Fprintln(stderr, "usage: hop undo") + return 2 + } + state, err := service.Undo(ctx) + var committed *CommittedStateError + if err != nil && !errors.As(err, &committed) { + return printCLIError(err, jsonOutput, stdout, stderr) + } + if committed != nil { + state = committed.State + value = map[string]any{"state": state, "warnings": []string{committed.Error()}} + } else { + value = state + } + if !jsonOutput { + fmt.Fprintf(stdout, "Created forward undo state %s · tree %s\n", state.ID, shortHash(state.SourceTree)) + if committed != nil { + fmt.Fprintf(stderr, "Warning: %s\n", committed.Error()) + } + } + + case "doctor": + fs := flag.NewFlagSet("doctor", flag.ContinueOnError) + fs.SetOutput(stderr) + repair := fs.Bool("repair", false, "repair the internal accepted ref") + if err := fs.Parse(commandArgs); err != nil || len(fs.Args()) != 0 { + return 2 + } + report, err := service.Doctor(ctx, *repair) + if err != nil { + return printCLIError(err, jsonOutput, stdout, stderr) + } + value = report + if !jsonOutput { + if report.OK { + fmt.Fprintf(stdout, "Hop project is healthy · %s\n", report.AcceptedState) + } else { + for _, problem := range report.Problems { + fmt.Fprintf(stdout, "Problem: %s\n", problem) + } + } + } + + default: + fmt.Fprintf(stderr, "unknown command %q\n\n%s", command, usageText) + return 2 + } + + if jsonOutput { + writeJSON(stdout, map[string]any{"ok": true, "data": value}) + } + return 0 +} + +func runSkillCLI(args []string, jsonOutput bool, stdout, stderr io.Writer) int { + if len(args) == 0 { + fmt.Fprintln(stderr, "usage: hop skill install [--path SKILLS_DIR] [--force] | hop skill print") + return 2 + } + switch args[0] { + case "install": + fs := flag.NewFlagSet("skill install", flag.ContinueOnError) + fs.SetOutput(stderr) + path := fs.String("path", "", "parent skills directory") + force := fs.Bool("force", false, "update an existing Hop skill") + if err := fs.Parse(args[1:]); err != nil || len(fs.Args()) != 0 { + return 2 + } + result, err := InstallSkill(*path, *force) + if err != nil { + return printCLIError(err, jsonOutput, stdout, stderr) + } + if jsonOutput { + writeJSON(stdout, map[string]any{"ok": true, "data": result}) + } else { + fmt.Fprintf(stdout, "Installed Hop skill at %s\n", result.Path) + } + return 0 + case "print": + if len(args) != 1 { + fmt.Fprintln(stderr, "usage: hop skill print") + return 2 + } + contents, err := EmbeddedSkillText() + if err != nil { + return printCLIError(err, jsonOutput, stdout, stderr) + } + if jsonOutput { + writeJSON(stdout, map[string]any{"ok": true, "data": map[string]string{"skill": contents}}) + } else { + fmt.Fprint(stdout, contents) + } + return 0 + default: + fmt.Fprintf(stderr, "unknown skill command %q\n", args[0]) + return 2 + } +} + +func printCLIError(err error, jsonOutput bool, stdout, stderr io.Writer) int { + code := 1 + var conflict *ConflictError + var stale *StaleHeadError + var failed *CheckFailedError + switch { + case errors.As(err, &conflict): + code = 20 + case errors.As(err, &stale): + code = 21 + case errors.As(err, &failed): + code = 22 + } + if jsonOutput { + writeJSON(stdout, map[string]any{"ok": false, "error": err.Error(), "exit_code": code}) + } else { + fmt.Fprintf(stderr, "hop: %v\n", err) + if conflict != nil && len(conflict.Paths) > 0 { + fmt.Fprintln(stderr, "Overlapping paths:") + for _, path := range conflict.Paths { + fmt.Fprintf(stderr, " %s\n", path) + } + } + } + return code +} + +func removeFlag(args []string, wanted string) (bool, []string) { + found := false + filtered := make([]string, 0, len(args)) + for i, arg := range args { + if arg == "--" { + filtered = append(filtered, args[i:]...) + break + } + if arg == wanted { + found = true + continue + } + filtered = append(filtered, arg) + } + return found, filtered +} + +func splitCommand(args []string) (string, []string, bool) { + if len(args) < 3 || args[1] != "--" { + return "", nil, false + } + return args[0], args[2:], len(args[2:]) > 0 +} + +func splitOptionalCommand(args []string) (string, []string, bool) { + if len(args) == 1 { + return args[0], nil, true + } + if len(args) >= 3 && args[1] == "--" { + return args[0], args[2:], true + } + return "", nil, false +} + +func writeJSON(w io.Writer, value any) { + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + _ = encoder.Encode(value) +} + +func shortHash(hash string) string { + if len(hash) <= 12 { + return hash + } + return hash[:12] +} diff --git a/internal/hop/db.go b/internal/hop/db.go new file mode 100644 index 0000000..99cd319 --- /dev/null +++ b/internal/hop/db.go @@ -0,0 +1,1498 @@ +package hop + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +const schemaVersion = 1 + +var ( + ErrNotFound = errors.New("hop: not found") + ErrNotInitialized = errors.New("hop: repository is not initialized") + ErrAlreadyInitialized = errors.New("hop: repository is already initialized") + ErrAttemptHeadChanged = errors.New("hop: attempt head changed") + ErrAcceptedHeadChanged = errors.New("hop: accepted head changed") +) + +// HeadChangedError reports a failed compare-and-swap of an attempt or the +// repository's accepted head. Callers may use errors.Is with +// ErrAttemptHeadChanged or ErrAcceptedHeadChanged. +type HeadChangedError struct { + Scope string `json:"scope"` + Expected string `json:"expected"` + Actual string `json:"actual"` +} + +func (e *HeadChangedError) Error() string { + return fmt.Sprintf("hop: %s head changed: expected %q, found %q", e.Scope, e.Expected, e.Actual) +} + +func (e *HeadChangedError) Unwrap() error { + if e.Scope == "accepted" { + return ErrAcceptedHeadChanged + } + return ErrAttemptHeadChanged +} + +// Event is an immutable audit record. Payload is deliberately unstructured so +// newer clients can add detail without requiring a database migration. +type Event struct { + ID string `json:"id"` + Kind string `json:"kind"` + TaskID string `json:"task_id,omitempty"` + AttemptID string `json:"attempt_id,omitempty"` + StateID string `json:"state_id,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// Store owns Hop's durable state graph. One database connection is used per +// Store so connection-scoped SQLite settings (notably foreign_keys) cannot be +// lost when database/sql selects another connection. +type Store struct { + db *sql.DB + path string +} + +// OpenStore opens (and, when needed, creates) a Hop SQLite database, applies +// migrations, and enables foreign keys and WAL mode. +func OpenStore(path string) (*Store, error) { + if strings.TrimSpace(path) == "" { + return nil, errors.New("hop: database path is required") + } + if path != ":memory:" && !strings.HasPrefix(path, "file:") { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, fmt.Errorf("create database directory: %w", err) + } + } + + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + store := &Store{db: db, path: path} + if err := store.configure(context.Background()); err != nil { + _ = db.Close() + return nil, err + } + if err := store.migrate(context.Background()); err != nil { + _ = db.Close() + return nil, err + } + return store, nil +} + +// Open is a convenience alias for OpenStore. +func Open(path string) (*Store, error) { return OpenStore(path) } + +func (s *Store) Path() string { return s.path } + +func (s *Store) Close() error { + if s == nil || s.db == nil { + return nil + } + return s.db.Close() +} + +func (s *Store) configure(ctx context.Context) error { + for _, statement := range []string{ + "PRAGMA busy_timeout = 5000", + "PRAGMA foreign_keys = ON", + "PRAGMA synchronous = NORMAL", + } { + if _, err := s.db.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("configure sqlite (%s): %w", statement, err) + } + } + var journalMode string + if err := s.db.QueryRowContext(ctx, "PRAGMA journal_mode = WAL").Scan(&journalMode); err != nil { + return fmt.Errorf("enable sqlite WAL: %w", err) + } + var foreignKeys int + if err := s.db.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&foreignKeys); err != nil { + return fmt.Errorf("verify sqlite foreign keys: %w", err) + } + if foreignKeys != 1 { + return errors.New("hop: sqlite foreign keys could not be enabled") + } + return nil +} + +type migration struct { + version int + statements []string +} + +var migrations = []migration{{ + version: 1, + statements: []string{ + `CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) STRICT`, + `CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL + ) STRICT`, + `CREATE TABLE IF NOT EXISTS states ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED, + attempt_id TEXT REFERENCES attempts(id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED, + canonical_anchor_id TEXT REFERENCES states(id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED, + source_tree TEXT NOT NULL, + git_commit TEXT NOT NULL, + prompt TEXT NOT NULL, + summary TEXT NOT NULL, + agent TEXT NOT NULL, + digest TEXT NOT NULL, + created_at TEXT NOT NULL + ) STRICT`, + `CREATE TABLE IF NOT EXISTS attempts ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, + agent TEXT NOT NULL, + workspace TEXT NOT NULL, + base_state_id TEXT NOT NULL REFERENCES states(id) ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED, + head_state_id TEXT REFERENCES states(id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED, + status TEXT NOT NULL, + created_at TEXT NOT NULL + ) STRICT`, + `CREATE TABLE IF NOT EXISTS state_parents ( + state_id TEXT NOT NULL REFERENCES states(id) ON DELETE CASCADE, + parent_state_id TEXT NOT NULL REFERENCES states(id) ON DELETE RESTRICT, + role TEXT NOT NULL, + parent_order INTEGER NOT NULL, + PRIMARY KEY (state_id, parent_order), + UNIQUE (state_id, parent_state_id, role) + ) STRICT`, + `CREATE TABLE IF NOT EXISTS checks ( + id TEXT PRIMARY KEY, + attempt_id TEXT NOT NULL REFERENCES attempts(id) ON DELETE CASCADE, + state_id TEXT REFERENCES states(id) ON DELETE SET NULL, + tree_hash TEXT NOT NULL, + command_json TEXT NOT NULL, + exit_code INTEGER NOT NULL, + output TEXT NOT NULL, + created_at TEXT NOT NULL + ) STRICT`, + `CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL, + attempt_id TEXT REFERENCES attempts(id) ON DELETE SET NULL, + state_id TEXT REFERENCES states(id) ON DELETE SET NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL + ) STRICT`, + `CREATE INDEX IF NOT EXISTS states_task_created_idx ON states(task_id, created_at, id)`, + `CREATE INDEX IF NOT EXISTS states_attempt_created_idx ON states(attempt_id, created_at, id)`, + `CREATE INDEX IF NOT EXISTS states_kind_created_idx ON states(kind, created_at, id)`, + `CREATE INDEX IF NOT EXISTS state_parents_parent_idx ON state_parents(parent_state_id, state_id)`, + `CREATE INDEX IF NOT EXISTS state_parents_role_idx ON state_parents(state_id, role, parent_order)`, + `CREATE INDEX IF NOT EXISTS attempts_task_created_idx ON attempts(task_id, created_at, id)`, + `CREATE INDEX IF NOT EXISTS attempts_status_created_idx ON attempts(status, created_at, id)`, + `CREATE INDEX IF NOT EXISTS checks_attempt_tree_idx ON checks(attempt_id, tree_hash, created_at, id)`, + `CREATE INDEX IF NOT EXISTS checks_tree_idx ON checks(tree_hash, created_at, id)`, + `CREATE INDEX IF NOT EXISTS events_created_idx ON events(created_at, id)`, + `CREATE INDEX IF NOT EXISTS events_task_idx ON events(task_id, created_at, id)`, + `CREATE INDEX IF NOT EXISTS events_attempt_idx ON events(attempt_id, created_at, id)`, + }, +}} + +func (s *Store) migrate(ctx context.Context) error { + var current int + if err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(¤t); err != nil { + return fmt.Errorf("read schema version: %w", err) + } + if current > schemaVersion { + return fmt.Errorf("hop: database schema version %d is newer than supported version %d", current, schemaVersion) + } + for _, m := range migrations { + if m.version <= current { + continue + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin schema migration %d: %w", m.version, err) + } + ok := false + defer func() { + if !ok { + _ = tx.Rollback() + } + }() + for _, statement := range m.statements { + if _, err := tx.ExecContext(ctx, statement); err != nil { + _ = tx.Rollback() + return fmt.Errorf("apply schema migration %d: %w", m.version, err) + } + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO meta(key, value) VALUES ('schema_version', ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, strconv.Itoa(m.version)); err != nil { + _ = tx.Rollback() + return fmt.Errorf("record schema migration %d: %w", m.version, err) + } + if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", m.version)); err != nil { + _ = tx.Rollback() + return fmt.Errorf("record sqlite schema version %d: %w", m.version, err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit schema migration %d: %w", m.version, err) + } + ok = true + current = m.version + } + return nil +} + +// CreateInitialState atomically records the first accepted state and repository +// root, and makes that state the accepted head. +func (s *Store) CreateInitialState(ctx context.Context, root string, initial State) (State, error) { + if strings.TrimSpace(root) == "" { + return State{}, errors.New("hop: repository root is required") + } + if initial.Kind == "" { + initial.Kind = StateAccepted + } + if initial.Kind != StateAccepted { + return State{}, fmt.Errorf("hop: initial state must have kind %q", StateAccepted) + } + if initial.ID == "" { + initial.ID = newID(stateIDPrefix(initial.Kind)) + } + if initial.CreatedAt.IsZero() { + initial.CreatedAt = time.Now().UTC() + } + initial.TaskID = "" + initial.AttemptID = "" + initial.CanonicalAnchorID = "" + initial.Parents = nil + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return State{}, fmt.Errorf("begin repository initialization: %w", err) + } + defer tx.Rollback() + + var existing string + err = tx.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = 'accepted_head'`).Scan(&existing) + if err == nil { + return State{}, ErrAlreadyInitialized + } + if !errors.Is(err, sql.ErrNoRows) { + return State{}, fmt.Errorf("check repository initialization: %w", err) + } + + if err := insertStateTx(ctx, tx, initial, nil); err != nil { + return State{}, err + } + for key, value := range map[string]string{"root": filepath.Clean(root), "accepted_head": initial.ID} { + if _, err := tx.ExecContext(ctx, + `INSERT INTO meta(key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value); err != nil { + return State{}, fmt.Errorf("write repository metadata %q: %w", key, err) + } + } + if err := insertEventTx(ctx, tx, Event{ + Kind: "repository.initialized", + StateID: initial.ID, + }, map[string]string{"root": filepath.Clean(root)}); err != nil { + return State{}, err + } + if err := tx.Commit(); err != nil { + return State{}, fmt.Errorf("commit repository initialization: %w", err) + } + return initial, nil +} + +// Initialize is a convenience alias for CreateInitialState. +func (s *Store) Initialize(ctx context.Context, root string, initial State) (State, error) { + return s.CreateInitialState(ctx, root, initial) +} + +// IsInitialized reports whether an accepted head has been installed. +func (s *Store) IsInitialized(ctx context.Context) (bool, error) { + _, err := s.AcceptedHeadID(ctx) + if errors.Is(err, ErrNotInitialized) { + return false, nil + } + return err == nil, err +} + +// CreateTaskAttemptPrompt atomically creates all three records. The prompt is +// durable and the attempt head points to it before the transaction becomes +// visible, so an orchestrator may safely deliver the prompt after this returns. +func (s *Store) CreateTaskAttemptPrompt( + ctx context.Context, + task Task, + attempt Attempt, + prompt State, + parents []Parent, +) (Task, Attempt, State, error) { + now := time.Now().UTC() + if task.ID == "" { + task.ID = newID("t") + } + if task.CreatedAt.IsZero() { + task.CreatedAt = now + } + if task.Status == "" { + task.Status = "active" + } + if task.Title == "" { + task.Title = prompt.Prompt + } + if attempt.ID == "" { + attempt.ID = newID("at") + } + if attempt.TaskID == "" { + attempt.TaskID = task.ID + } + if attempt.TaskID != task.ID { + return Task{}, Attempt{}, State{}, errors.New("hop: attempt task does not match task") + } + if attempt.CreatedAt.IsZero() { + attempt.CreatedAt = now + } + if attempt.Status == "" { + attempt.Status = "active" + } + if prompt.ID == "" { + prompt.ID = newID("p") + } + if prompt.Kind == "" { + prompt.Kind = StatePrompt + } + if prompt.Kind != StatePrompt { + return Task{}, Attempt{}, State{}, fmt.Errorf("hop: task instruction must have kind %q", StatePrompt) + } + if prompt.TaskID == "" { + prompt.TaskID = task.ID + } + if prompt.TaskID != task.ID { + return Task{}, Attempt{}, State{}, errors.New("hop: prompt task does not match task") + } + if prompt.AttemptID == "" { + prompt.AttemptID = attempt.ID + } + if prompt.AttemptID != attempt.ID { + return Task{}, Attempt{}, State{}, errors.New("hop: prompt attempt does not match attempt") + } + if prompt.Agent == "" { + prompt.Agent = attempt.Agent + } + if prompt.CreatedAt.IsZero() { + prompt.CreatedAt = now + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return Task{}, Attempt{}, State{}, fmt.Errorf("begin task creation: %w", err) + } + defer tx.Rollback() + + acceptedHead, err := metaTx(ctx, tx, "accepted_head") + if errors.Is(err, sql.ErrNoRows) { + return Task{}, Attempt{}, State{}, ErrNotInitialized + } + if err != nil { + return Task{}, Attempt{}, State{}, fmt.Errorf("read accepted head: %w", err) + } + if attempt.BaseStateID == "" { + attempt.BaseStateID = acceptedHead + } + base, err := stateTx(ctx, tx, attempt.BaseStateID) + if err != nil { + return Task{}, Attempt{}, State{}, fmt.Errorf("read attempt base state: %w", err) + } + if attempt.HeadStateID != "" && attempt.HeadStateID != prompt.ID { + return Task{}, Attempt{}, State{}, errors.New("hop: new attempt head must be the prompt state") + } + attempt.HeadStateID = prompt.ID + if prompt.CanonicalAnchorID == "" { + prompt.CanonicalAnchorID = acceptedHead + } + if prompt.SourceTree == "" { + prompt.SourceTree = base.SourceTree + } + if prompt.GitCommit == "" { + prompt.GitCommit = base.GitCommit + } + + parents = chooseParents(prompt.Parents, parents) + parents = ensureParentRole(parents, "run_parent", attempt.BaseStateID) + parents = ensureParentRole(parents, "canonical_anchor", prompt.CanonicalAnchorID) + parents = canonicalizeParents(parents) + prompt.Parents = parents + + if _, err := tx.ExecContext(ctx, + `INSERT INTO tasks(id, title, status, created_at) VALUES (?, ?, ?, ?)`, + task.ID, task.Title, task.Status, formatTime(task.CreatedAt)); err != nil { + return Task{}, Attempt{}, State{}, fmt.Errorf("insert task: %w", err) + } + // head_state_id is temporarily NULL to break the intentional attempt/state + // reference cycle; it is populated before this transaction commits. + if _, err := tx.ExecContext(ctx, + `INSERT INTO attempts(id, task_id, agent, workspace, base_state_id, head_state_id, status, created_at) + VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`, + attempt.ID, attempt.TaskID, attempt.Agent, attempt.Workspace, attempt.BaseStateID, + attempt.Status, formatTime(attempt.CreatedAt)); err != nil { + return Task{}, Attempt{}, State{}, fmt.Errorf("insert attempt: %w", err) + } + if err := insertStateTx(ctx, tx, prompt, parents); err != nil { + return Task{}, Attempt{}, State{}, err + } + if _, err := tx.ExecContext(ctx, + `UPDATE attempts SET head_state_id = ? WHERE id = ?`, prompt.ID, attempt.ID); err != nil { + return Task{}, Attempt{}, State{}, fmt.Errorf("install initial attempt head: %w", err) + } + if err := insertEventTx(ctx, tx, Event{ + Kind: "task.created", TaskID: task.ID, + }, map[string]string{"title": task.Title}); err != nil { + return Task{}, Attempt{}, State{}, err + } + if err := insertEventTx(ctx, tx, Event{ + Kind: "prompt.created", TaskID: task.ID, AttemptID: attempt.ID, StateID: prompt.ID, + }, nil); err != nil { + return Task{}, Attempt{}, State{}, err + } + if err := tx.Commit(); err != nil { + return Task{}, Attempt{}, State{}, fmt.Errorf("commit task creation: %w", err) + } + return task, attempt, prompt, nil +} + +// AppendState atomically appends an immutable state and compare-and-swaps the +// owning attempt's head. An empty expectedAttemptHeadID means "the head observed +// by this transaction"; callers that already observed a head should pass it. +func (s *Store) AppendState( + ctx context.Context, + state State, + parents []Parent, + expectedAttemptHeadID string, +) (State, error) { + if state.AttemptID == "" { + return State{}, errors.New("hop: appended state requires an attempt ID") + } + if state.ID == "" { + state.ID = newID(stateIDPrefix(state.Kind)) + } + if state.Kind == "" { + state.Kind = StateCheckpoint + } + if state.CreatedAt.IsZero() { + state.CreatedAt = time.Now().UTC() + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return State{}, fmt.Errorf("begin state append: %w", err) + } + defer tx.Rollback() + + attempt, err := attemptTx(ctx, tx, state.AttemptID) + if err != nil { + return State{}, err + } + if expectedAttemptHeadID == "" { + expectedAttemptHeadID = attempt.HeadStateID + } + if attempt.HeadStateID != expectedAttemptHeadID { + return State{}, &HeadChangedError{Scope: "attempt", Expected: expectedAttemptHeadID, Actual: attempt.HeadStateID} + } + if state.TaskID == "" { + state.TaskID = attempt.TaskID + } + if state.TaskID != attempt.TaskID { + return State{}, errors.New("hop: appended state task does not match attempt") + } + if state.Agent == "" { + state.Agent = attempt.Agent + } + head, err := stateTx(ctx, tx, attempt.HeadStateID) + if err != nil { + return State{}, fmt.Errorf("read attempt head state: %w", err) + } + if state.SourceTree == "" { + state.SourceTree = head.SourceTree + } + if state.GitCommit == "" { + state.GitCommit = head.GitCommit + } + if state.CanonicalAnchorID == "" { + state.CanonicalAnchorID = head.CanonicalAnchorID + if state.CanonicalAnchorID == "" { + state.CanonicalAnchorID = attempt.BaseStateID + } + } + parents = chooseParents(state.Parents, parents) + parents = ensureParentRole(parents, "run_parent", expectedAttemptHeadID) + if state.Kind == StatePrompt { + parents = ensureParentRole(parents, "canonical_anchor", state.CanonicalAnchorID) + } + parents = canonicalizeParents(parents) + state.Parents = parents + + if err := insertStateTx(ctx, tx, state, parents); err != nil { + return State{}, err + } + result, err := tx.ExecContext(ctx, + `UPDATE attempts SET head_state_id = ? WHERE id = ? AND head_state_id = ?`, + state.ID, state.AttemptID, expectedAttemptHeadID) + if err != nil { + return State{}, fmt.Errorf("advance attempt head: %w", err) + } + changed, err := result.RowsAffected() + if err != nil { + return State{}, fmt.Errorf("inspect attempt head update: %w", err) + } + if changed != 1 { + actual, _ := attemptHeadTx(ctx, tx, state.AttemptID) + return State{}, &HeadChangedError{Scope: "attempt", Expected: expectedAttemptHeadID, Actual: actual} + } + if err := insertEventTx(ctx, tx, Event{ + Kind: "state.appended", TaskID: state.TaskID, AttemptID: state.AttemptID, StateID: state.ID, + }, map[string]string{"kind": string(state.Kind)}); err != nil { + return State{}, err + } + if err := tx.Commit(); err != nil { + return State{}, fmt.Errorf("commit state append: %w", err) + } + return state, nil +} + +// CASAccept atomically inserts an accepted state and advances accepted_head only +// if it still equals expectedHeadID. Any inserted state is rolled back when the +// compare-and-swap fails. +func (s *Store) CASAccept( + ctx context.Context, + expectedHeadID string, + accepted State, + parents []Parent, +) (State, error) { + if expectedHeadID == "" { + return State{}, errors.New("hop: expected accepted head is required") + } + if accepted.ID == "" { + accepted.ID = newID("a") + } + if accepted.Kind == "" { + accepted.Kind = StateAccepted + } + if accepted.Kind != StateAccepted { + return State{}, fmt.Errorf("hop: accepted state must have kind %q", StateAccepted) + } + if accepted.CreatedAt.IsZero() { + accepted.CreatedAt = time.Now().UTC() + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return State{}, fmt.Errorf("begin acceptance: %w", err) + } + defer tx.Rollback() + + actual, err := metaTx(ctx, tx, "accepted_head") + if errors.Is(err, sql.ErrNoRows) { + return State{}, ErrNotInitialized + } + if err != nil { + return State{}, fmt.Errorf("read accepted head: %w", err) + } + if actual != expectedHeadID { + return State{}, &HeadChangedError{Scope: "accepted", Expected: expectedHeadID, Actual: actual} + } + current, err := stateTx(ctx, tx, expectedHeadID) + if err != nil { + return State{}, fmt.Errorf("read current accepted state: %w", err) + } + + parents = chooseParents(accepted.Parents, parents) + parents = ensureParentRole(parents, "canonical_parent", expectedHeadID) + parents = canonicalizeParents(parents) + accepted.Parents = parents + if accepted.CanonicalAnchorID == "" { + accepted.CanonicalAnchorID = expectedHeadID + } + + // When the caller supplied a proposal parent but omitted repeated fields, + // inherit them from that immutable proposal. + if proposalParent, ok := parentWithRole(parents, "proposal_parent"); ok { + proposal, err := stateTx(ctx, tx, proposalParent.StateID) + if err != nil { + return State{}, fmt.Errorf("read proposal parent: %w", err) + } + if accepted.TaskID == "" { + accepted.TaskID = proposal.TaskID + } + if accepted.AttemptID == "" { + accepted.AttemptID = proposal.AttemptID + } + if accepted.SourceTree == "" { + accepted.SourceTree = proposal.SourceTree + } + if accepted.GitCommit == "" { + accepted.GitCommit = proposal.GitCommit + } + if accepted.Agent == "" { + accepted.Agent = proposal.Agent + } + } + if accepted.SourceTree == "" { + accepted.SourceTree = current.SourceTree + } + if accepted.GitCommit == "" { + accepted.GitCommit = current.GitCommit + } + + if err := insertStateTx(ctx, tx, accepted, parents); err != nil { + return State{}, err + } + result, err := tx.ExecContext(ctx, + `UPDATE meta SET value = ? WHERE key = 'accepted_head' AND value = ?`, + accepted.ID, expectedHeadID) + if err != nil { + return State{}, fmt.Errorf("advance accepted head: %w", err) + } + changed, err := result.RowsAffected() + if err != nil { + return State{}, fmt.Errorf("inspect accepted head update: %w", err) + } + if changed != 1 { + actual, _ := metaTx(ctx, tx, "accepted_head") + return State{}, &HeadChangedError{Scope: "accepted", Expected: expectedHeadID, Actual: actual} + } + if accepted.AttemptID != "" { + if _, err := tx.ExecContext(ctx, + `UPDATE attempts SET status = 'accepted' WHERE id = ?`, accepted.AttemptID); err != nil { + return State{}, fmt.Errorf("mark attempt accepted: %w", err) + } + } + if accepted.TaskID != "" { + if _, err := tx.ExecContext(ctx, + `UPDATE tasks SET status = 'accepted' WHERE id = ?`, accepted.TaskID); err != nil { + return State{}, fmt.Errorf("mark task accepted: %w", err) + } + } + if err := insertEventTx(ctx, tx, Event{ + Kind: "state.accepted", TaskID: accepted.TaskID, AttemptID: accepted.AttemptID, StateID: accepted.ID, + }, map[string]string{"previous_head": expectedHeadID}); err != nil { + return State{}, err + } + if err := tx.Commit(); err != nil { + return State{}, fmt.Errorf("commit acceptance: %w", err) + } + return accepted, nil +} + +// AcceptedHeadID returns the current canonical pointer without loading the +// state body. +func (s *Store) AcceptedHeadID(ctx context.Context) (string, error) { + var id string + err := s.db.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = 'accepted_head'`).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotInitialized + } + if err != nil { + return "", fmt.Errorf("read accepted head: %w", err) + } + return id, nil +} + +func (s *Store) AcceptedHead(ctx context.Context) (State, error) { + id, err := s.AcceptedHeadID(ctx) + if err != nil { + return State{}, err + } + return s.GetState(ctx, id) +} + +func (s *Store) RepositoryRoot(ctx context.Context) (string, error) { + var root string + err := s.db.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = 'root'`).Scan(&root) + if errors.Is(err, sql.ErrNoRows) { + return "", ErrNotInitialized + } + if err != nil { + return "", fmt.Errorf("read repository root: %w", err) + } + return root, nil +} + +func (s *Store) GetState(ctx context.Context, id string) (State, error) { + state, err := scanState(s.db.QueryRowContext(ctx, `SELECT `+stateColumns+` FROM states WHERE id = ?`, id)) + if err != nil { + return State{}, dbNotFound("state", id, err) + } + parents, err := s.GetParents(ctx, id) + if err != nil { + return State{}, err + } + state.Parents = parents + return state, nil +} + +func (s *Store) GetParents(ctx context.Context, stateID string) ([]Parent, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT parent_state_id, role, parent_order + FROM state_parents WHERE state_id = ? ORDER BY parent_order`, stateID) + if err != nil { + return nil, fmt.Errorf("query state parents: %w", err) + } + defer rows.Close() + var parents []Parent + for rows.Next() { + var parent Parent + if err := rows.Scan(&parent.StateID, &parent.Role, &parent.Order); err != nil { + return nil, fmt.Errorf("scan state parent: %w", err) + } + parents = append(parents, parent) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate state parents: %w", err) + } + return parents, nil +} + +func (s *Store) ParentByRole(ctx context.Context, stateID, role string) (Parent, error) { + var parent Parent + err := s.db.QueryRowContext(ctx, + `SELECT parent_state_id, role, parent_order + FROM state_parents WHERE state_id = ? AND role = ? ORDER BY parent_order LIMIT 1`, + stateID, role).Scan(&parent.StateID, &parent.Role, &parent.Order) + if err != nil { + return Parent{}, dbNotFound("parent", role, err) + } + return parent, nil +} + +func (s *Store) ParentsByRole(ctx context.Context, stateID, role string) ([]Parent, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT parent_state_id, role, parent_order + FROM state_parents WHERE state_id = ? AND role = ? ORDER BY parent_order`, + stateID, role) + if err != nil { + return nil, fmt.Errorf("query state parents by role: %w", err) + } + defer rows.Close() + var parents []Parent + for rows.Next() { + var parent Parent + if err := rows.Scan(&parent.StateID, &parent.Role, &parent.Order); err != nil { + return nil, fmt.Errorf("scan state parent: %w", err) + } + parents = append(parents, parent) + } + return parents, rows.Err() +} + +func (s *Store) GetTask(ctx context.Context, id string) (Task, error) { + return scanTask(s.db.QueryRowContext(ctx, + `SELECT id, title, status, created_at FROM tasks WHERE id = ?`, id), id) +} + +func (s *Store) ListTasks(ctx context.Context, status string) ([]Task, error) { + query := `SELECT id, title, status, created_at FROM tasks` + var args []any + if status != "" { + query += ` WHERE status = ?` + args = append(args, status) + } + query += ` ORDER BY created_at, id` + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("query tasks: %w", err) + } + defer rows.Close() + var tasks []Task + for rows.Next() { + task, err := scanTask(rows, "") + if err != nil { + return nil, err + } + tasks = append(tasks, task) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate tasks: %w", err) + } + return tasks, nil +} + +func (s *Store) GetAttempt(ctx context.Context, id string) (Attempt, error) { + attempt, err := scanAttempt(s.db.QueryRowContext(ctx, + `SELECT `+attemptColumns+` FROM attempts WHERE id = ?`, id)) + if err != nil { + return Attempt{}, dbNotFound("attempt", id, err) + } + return attempt, nil +} + +// ListAttempts filters by task and status when either value is non-empty. +func (s *Store) ListAttempts(ctx context.Context, taskID, status string) ([]Attempt, error) { + query := `SELECT ` + attemptColumns + ` FROM attempts WHERE 1 = 1` + var args []any + if taskID != "" { + query += ` AND task_id = ?` + args = append(args, taskID) + } + if status != "" { + query += ` AND status = ?` + args = append(args, status) + } + query += ` ORDER BY created_at, id` + return s.queryAttempts(ctx, query, args...) +} + +func (s *Store) ActiveAttempts(ctx context.Context) ([]Attempt, error) { + return s.queryAttempts(ctx, + `SELECT `+attemptColumns+` FROM attempts + WHERE status NOT IN ('accepted', 'completed', 'failed', 'cancelled', 'rejected') + ORDER BY created_at, id`) +} + +func (s *Store) queryAttempts(ctx context.Context, query string, args ...any) ([]Attempt, error) { + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("query attempts: %w", err) + } + defer rows.Close() + var attempts []Attempt + for rows.Next() { + attempt, err := scanAttempt(rows) + if err != nil { + return nil, err + } + attempts = append(attempts, attempt) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate attempts: %w", err) + } + return attempts, nil +} + +func (s *Store) UpdateAttemptStatus(ctx context.Context, id, status string) error { + if status == "" { + return errors.New("hop: attempt status is required") + } + result, err := s.db.ExecContext(ctx, `UPDATE attempts SET status = ? WHERE id = ?`, status, id) + if err != nil { + return fmt.Errorf("update attempt status: %w", err) + } + return requireOneRow(result, "attempt", id) +} + +func (s *Store) UpdateTaskStatus(ctx context.Context, id, status string) error { + if status == "" { + return errors.New("hop: task status is required") + } + result, err := s.db.ExecContext(ctx, `UPDATE tasks SET status = ? WHERE id = ?`, status, id) + if err != nil { + return fmt.Errorf("update task status: %w", err) + } + return requireOneRow(result, "task", id) +} + +// UpdateAttemptHead performs a standalone compare-and-swap. AppendState should +// normally be preferred because it cannot point at a state that was not created +// as part of the same logical operation. +func (s *Store) UpdateAttemptHead(ctx context.Context, attemptID, expectedHeadID, nextHeadID string) error { + result, err := s.db.ExecContext(ctx, + `UPDATE attempts SET head_state_id = ? WHERE id = ? AND head_state_id = ?`, + nextHeadID, attemptID, expectedHeadID) + if err != nil { + return fmt.Errorf("advance attempt head: %w", err) + } + changed, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("inspect attempt head update: %w", err) + } + if changed == 1 { + return nil + } + attempt, getErr := s.GetAttempt(ctx, attemptID) + if getErr != nil { + return getErr + } + return &HeadChangedError{Scope: "attempt", Expected: expectedHeadID, Actual: attempt.HeadStateID} +} + +// AddCheck records evidence bound to a tree. If StateID is present, TreeHash is +// filled from that state when empty and otherwise must match its source tree. +func (s *Store) AddCheck(ctx context.Context, check Check) (Check, error) { + if check.ID == "" { + check.ID = newID("check") + } + if check.AttemptID == "" { + return Check{}, errors.New("hop: check attempt ID is required") + } + if check.CreatedAt.IsZero() { + check.CreatedAt = time.Now().UTC() + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return Check{}, fmt.Errorf("begin check creation: %w", err) + } + defer tx.Rollback() + + if _, err := attemptTx(ctx, tx, check.AttemptID); err != nil { + return Check{}, err + } + if check.StateID != "" { + state, err := stateTx(ctx, tx, check.StateID) + if err != nil { + return Check{}, err + } + if state.AttemptID != "" && state.AttemptID != check.AttemptID { + return Check{}, errors.New("hop: check state belongs to another attempt") + } + if check.TreeHash == "" { + check.TreeHash = state.SourceTree + } else if check.TreeHash != state.SourceTree { + return Check{}, errors.New("hop: check tree hash does not match state source tree") + } + } + if check.TreeHash == "" { + return Check{}, errors.New("hop: check tree hash is required") + } + command, err := json.Marshal(check.Command) + if err != nil { + return Check{}, fmt.Errorf("encode check command: %w", err) + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO checks(id, attempt_id, state_id, tree_hash, command_json, exit_code, output, created_at) + VALUES (?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?)`, + check.ID, check.AttemptID, check.StateID, check.TreeHash, string(command), check.ExitCode, + check.Output, formatTime(check.CreatedAt)); err != nil { + return Check{}, fmt.Errorf("insert check: %w", err) + } + attempt, err := attemptTx(ctx, tx, check.AttemptID) + if err != nil { + return Check{}, err + } + if err := insertEventTx(ctx, tx, Event{ + Kind: "check.recorded", TaskID: attempt.TaskID, AttemptID: check.AttemptID, StateID: check.StateID, + }, map[string]any{"check_id": check.ID, "tree_hash": check.TreeHash, "exit_code": check.ExitCode}); err != nil { + return Check{}, err + } + if err := tx.Commit(); err != nil { + return Check{}, fmt.Errorf("commit check creation: %w", err) + } + return check, nil +} + +// CreateCheck is a convenience alias for AddCheck. +func (s *Store) CreateCheck(ctx context.Context, check Check) (Check, error) { + return s.AddCheck(ctx, check) +} + +func (s *Store) GetCheck(ctx context.Context, id string) (Check, error) { + check, err := scanCheck(s.db.QueryRowContext(ctx, + `SELECT `+checkColumns+` FROM checks WHERE id = ?`, id)) + if err != nil { + return Check{}, dbNotFound("check", id, err) + } + return check, nil +} + +// ListChecks filters by attempt and/or exact source tree. Passing both empty +// lists all checks. +func (s *Store) ListChecks(ctx context.Context, attemptID, treeHash string) ([]Check, error) { + query := `SELECT ` + checkColumns + ` FROM checks WHERE 1 = 1` + var args []any + if attemptID != "" { + query += ` AND attempt_id = ?` + args = append(args, attemptID) + } + if treeHash != "" { + query += ` AND tree_hash = ?` + args = append(args, treeHash) + } + query += ` ORDER BY created_at, id` + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("query checks: %w", err) + } + defer rows.Close() + var checks []Check + for rows.Next() { + check, err := scanCheck(rows) + if err != nil { + return nil, err + } + checks = append(checks, check) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate checks: %w", err) + } + return checks, nil +} + +func (s *Store) ChecksForTree(ctx context.Context, treeHash string) ([]Check, error) { + return s.ListChecks(ctx, "", treeHash) +} + +// Graph returns states in creation order with their typed parent edges. An +// empty taskID returns the repository-wide graph. +func (s *Store) Graph(ctx context.Context, taskID string) ([]GraphRow, error) { + query := `SELECT ` + stateColumns + ` FROM states` + var args []any + if taskID != "" { + query += ` WHERE task_id = ?` + args = append(args, taskID) + } + query += ` ORDER BY created_at, id` + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("query state graph: %w", err) + } + var states []State + for rows.Next() { + state, err := scanState(rows) + if err != nil { + _ = rows.Close() + return nil, err + } + states = append(states, state) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, fmt.Errorf("iterate state graph: %w", err) + } + if err := rows.Close(); err != nil { + return nil, fmt.Errorf("close state graph rows: %w", err) + } + + graph := make([]GraphRow, 0, len(states)) + for _, state := range states { + parents, err := s.GetParents(ctx, state.ID) + if err != nil { + return nil, err + } + state.Parents = parents + graph = append(graph, GraphRow{State: state, Parents: parents}) + } + return graph, nil +} + +// AcceptedHistory follows canonical_parent edges from newest to oldest. +func (s *Store) AcceptedHistory(ctx context.Context, limit int) ([]State, error) { + if limit <= 0 { + limit = 1000 + } + rows, err := s.db.QueryContext(ctx, + `WITH RECURSIVE lineage(id, depth) AS ( + SELECT value, 0 FROM meta WHERE key = 'accepted_head' + UNION ALL + SELECT p.parent_state_id, lineage.depth + 1 + FROM lineage + JOIN state_parents p ON p.state_id = lineage.id AND p.role = 'canonical_parent' + WHERE lineage.depth < 9999 + ) + SELECT id FROM lineage ORDER BY depth LIMIT ?`, limit) + if err != nil { + return nil, fmt.Errorf("query accepted history: %w", err) + } + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + _ = rows.Close() + return nil, fmt.Errorf("scan accepted history: %w", err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, fmt.Errorf("iterate accepted history: %w", err) + } + if err := rows.Close(); err != nil { + return nil, fmt.Errorf("close accepted history rows: %w", err) + } + if len(ids) == 0 { + return nil, ErrNotInitialized + } + history := make([]State, 0, len(ids)) + for _, id := range ids { + state, err := s.GetState(ctx, id) + if err != nil { + return nil, err + } + history = append(history, state) + } + return history, nil +} + +// History is a convenience alias for AcceptedHistory. +func (s *Store) History(ctx context.Context, limit int) ([]State, error) { + return s.AcceptedHistory(ctx, limit) +} + +func (s *Store) Status(ctx context.Context) (Status, error) { + root, err := s.RepositoryRoot(ctx) + if err != nil { + return Status{}, err + } + head, err := s.AcceptedHead(ctx) + if err != nil { + return Status{}, err + } + attempts, err := s.ActiveAttempts(ctx) + if err != nil { + return Status{}, err + } + return Status{Root: root, AcceptedHead: head, Attempts: attempts}, nil +} + +// AppendEvent adds an explicit audit event. Store transitions also add their +// own events in the same transactions as the state changes they describe. +func (s *Store) AppendEvent(ctx context.Context, event Event) (Event, error) { + if event.Kind == "" { + return Event{}, errors.New("hop: event kind is required") + } + if event.ID == "" { + event.ID = newID("e") + } + if event.CreatedAt.IsZero() { + event.CreatedAt = time.Now().UTC() + } + payload := event.Payload + if len(payload) == 0 { + payload = json.RawMessage(`{}`) + } + if !json.Valid(payload) { + return Event{}, errors.New("hop: event payload is not valid JSON") + } + _, err := s.db.ExecContext(ctx, + `INSERT INTO events(id, kind, task_id, attempt_id, state_id, payload_json, created_at) + VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?)`, + event.ID, event.Kind, event.TaskID, event.AttemptID, event.StateID, string(payload), formatTime(event.CreatedAt)) + if err != nil { + return Event{}, fmt.Errorf("insert event: %w", err) + } + event.Payload = payload + return event, nil +} + +func (s *Store) ListEvents(ctx context.Context, limit int) ([]Event, error) { + if limit <= 0 { + limit = 1000 + } + rows, err := s.db.QueryContext(ctx, + `SELECT id, kind, COALESCE(task_id, ''), COALESCE(attempt_id, ''), COALESCE(state_id, ''), payload_json, created_at + FROM events ORDER BY created_at, id LIMIT ?`, limit) + if err != nil { + return nil, fmt.Errorf("query events: %w", err) + } + defer rows.Close() + var events []Event + for rows.Next() { + var event Event + var payload, created string + if err := rows.Scan(&event.ID, &event.Kind, &event.TaskID, &event.AttemptID, &event.StateID, &payload, &created); err != nil { + return nil, fmt.Errorf("scan event: %w", err) + } + createdAt, err := parseTime(created) + if err != nil { + return nil, err + } + event.CreatedAt = createdAt + event.Payload = json.RawMessage(payload) + events = append(events, event) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate events: %w", err) + } + return events, nil +} + +const stateColumns = `id, kind, COALESCE(task_id, ''), COALESCE(attempt_id, ''), + COALESCE(canonical_anchor_id, ''), source_tree, git_commit, prompt, summary, agent, digest, created_at` + +const attemptColumns = `id, task_id, agent, workspace, base_state_id, COALESCE(head_state_id, ''), status, created_at` + +const checkColumns = `id, attempt_id, COALESCE(state_id, ''), tree_hash, command_json, exit_code, output, created_at` + +type scanner interface { + Scan(dest ...any) error +} + +func scanState(row scanner) (State, error) { + var state State + var kind, created string + if err := row.Scan( + &state.ID, &kind, &state.TaskID, &state.AttemptID, &state.CanonicalAnchorID, + &state.SourceTree, &state.GitCommit, &state.Prompt, &state.Summary, &state.Agent, + &state.Digest, &created, + ); err != nil { + return State{}, err + } + createdAt, err := parseTime(created) + if err != nil { + return State{}, err + } + state.Kind = StateKind(kind) + state.CreatedAt = createdAt + return state, nil +} + +func scanTask(row scanner, requestedID string) (Task, error) { + var task Task + var created string + if err := row.Scan(&task.ID, &task.Title, &task.Status, &created); err != nil { + return Task{}, dbNotFound("task", requestedID, err) + } + createdAt, err := parseTime(created) + if err != nil { + return Task{}, err + } + task.CreatedAt = createdAt + return task, nil +} + +func scanAttempt(row scanner) (Attempt, error) { + var attempt Attempt + var created string + if err := row.Scan( + &attempt.ID, &attempt.TaskID, &attempt.Agent, &attempt.Workspace, + &attempt.BaseStateID, &attempt.HeadStateID, &attempt.Status, &created, + ); err != nil { + return Attempt{}, err + } + createdAt, err := parseTime(created) + if err != nil { + return Attempt{}, err + } + attempt.CreatedAt = createdAt + return attempt, nil +} + +func scanCheck(row scanner) (Check, error) { + var check Check + var command, created string + if err := row.Scan( + &check.ID, &check.AttemptID, &check.StateID, &check.TreeHash, &command, + &check.ExitCode, &check.Output, &created, + ); err != nil { + return Check{}, err + } + if err := json.Unmarshal([]byte(command), &check.Command); err != nil { + return Check{}, fmt.Errorf("decode check command: %w", err) + } + createdAt, err := parseTime(created) + if err != nil { + return Check{}, err + } + check.CreatedAt = createdAt + return check, nil +} + +func stateTx(ctx context.Context, tx *sql.Tx, id string) (State, error) { + state, err := scanState(tx.QueryRowContext(ctx, + `SELECT `+stateColumns+` FROM states WHERE id = ?`, id)) + if err != nil { + return State{}, dbNotFound("state", id, err) + } + return state, nil +} + +func attemptTx(ctx context.Context, tx *sql.Tx, id string) (Attempt, error) { + attempt, err := scanAttempt(tx.QueryRowContext(ctx, + `SELECT `+attemptColumns+` FROM attempts WHERE id = ?`, id)) + if err != nil { + return Attempt{}, dbNotFound("attempt", id, err) + } + return attempt, nil +} + +func attemptHeadTx(ctx context.Context, tx *sql.Tx, id string) (string, error) { + var head string + err := tx.QueryRowContext(ctx, + `SELECT COALESCE(head_state_id, '') FROM attempts WHERE id = ?`, id).Scan(&head) + return head, err +} + +func metaTx(ctx context.Context, tx *sql.Tx, key string) (string, error) { + var value string + err := tx.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = ?`, key).Scan(&value) + return value, err +} + +func insertStateTx(ctx context.Context, tx *sql.Tx, state State, parents []Parent) error { + if state.ID == "" { + return errors.New("hop: state ID is required") + } + if state.Kind == "" { + return errors.New("hop: state kind is required") + } + if state.CreatedAt.IsZero() { + return errors.New("hop: state creation time is required") + } + _, err := tx.ExecContext(ctx, + `INSERT INTO states( + id, kind, task_id, attempt_id, canonical_anchor_id, source_tree, git_commit, + prompt, summary, agent, digest, created_at + ) VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?)`, + state.ID, string(state.Kind), state.TaskID, state.AttemptID, state.CanonicalAnchorID, + state.SourceTree, state.GitCommit, state.Prompt, state.Summary, state.Agent, + state.Digest, formatTime(state.CreatedAt)) + if err != nil { + return fmt.Errorf("insert state %q: %w", state.ID, err) + } + for _, parent := range parents { + if parent.StateID == "" { + return errors.New("hop: parent state ID is required") + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO state_parents(state_id, parent_state_id, role, parent_order) + VALUES (?, ?, ?, ?)`, state.ID, parent.StateID, parent.Role, parent.Order); err != nil { + return fmt.Errorf("insert parent for state %q: %w", state.ID, err) + } + } + return nil +} + +func insertEventTx(ctx context.Context, tx *sql.Tx, event Event, payload any) error { + if event.ID == "" { + event.ID = newID("e") + } + if event.CreatedAt.IsZero() { + event.CreatedAt = time.Now().UTC() + } + var encoded []byte + var err error + if payload == nil { + encoded = []byte(`{}`) + } else { + encoded, err = json.Marshal(payload) + if err != nil { + return fmt.Errorf("encode event payload: %w", err) + } + } + _, err = tx.ExecContext(ctx, + `INSERT INTO events(id, kind, task_id, attempt_id, state_id, payload_json, created_at) + VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?)`, + event.ID, event.Kind, event.TaskID, event.AttemptID, event.StateID, + string(encoded), formatTime(event.CreatedAt)) + if err != nil { + return fmt.Errorf("insert event %q: %w", event.Kind, err) + } + return nil +} + +func chooseParents(fromState, explicit []Parent) []Parent { + if len(explicit) > 0 { + return append([]Parent(nil), explicit...) + } + return append([]Parent(nil), fromState...) +} + +func ensureParentRole(parents []Parent, role, stateID string) []Parent { + if stateID == "" { + return parents + } + for _, parent := range parents { + if parent.Role == role { + return parents + } + } + return append(parents, Parent{StateID: stateID, Role: role, Order: len(parents)}) +} + +func parentWithRole(parents []Parent, role string) (Parent, bool) { + for _, parent := range parents { + if parent.Role == role { + return parent, true + } + } + return Parent{}, false +} + +func canonicalizeParents(parents []Parent) []Parent { + parents = append([]Parent(nil), parents...) + used := make(map[int]bool, len(parents)) + for i := range parents { + if parents[i].Role == "" { + parents[i].Role = "parent" + } + order := parents[i].Order + if order < 0 || used[order] { + order = 0 + for used[order] { + order++ + } + parents[i].Order = order + } + used[order] = true + } + return parents +} + +func stateIDPrefix(kind StateKind) string { + switch kind { + case StatePrompt: + return "p" + case StateCheckpoint: + return "c" + case StateProposal: + return "r" + case StateAccepted: + return "a" + case StateFailed: + return "f" + case StateCancelled: + return "x" + default: + return "s" + } +} + +func formatTime(value time.Time) string { + return value.UTC().Format(time.RFC3339Nano) +} + +func parseTime(value string) (time.Time, error) { + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return time.Time{}, fmt.Errorf("decode database timestamp %q: %w", value, err) + } + return parsed, nil +} + +func dbNotFound(kind, id string, err error) error { + if errors.Is(err, sql.ErrNoRows) { + if id == "" { + return fmt.Errorf("%w: %s", ErrNotFound, kind) + } + return fmt.Errorf("%w: %s %q", ErrNotFound, kind, id) + } + return fmt.Errorf("read %s %q: %w", kind, id, err) +} + +func requireOneRow(result sql.Result, kind, id string) error { + changed, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("inspect %s update: %w", kind, err) + } + if changed != 1 { + return fmt.Errorf("%w: %s %q", ErrNotFound, kind, id) + } + return nil +} diff --git a/internal/hop/git.go b/internal/hop/git.go new file mode 100644 index 0000000..68a56bd --- /dev/null +++ b/internal/hop/git.go @@ -0,0 +1,1033 @@ +package hop + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" +) + +const ( + defaultGitBinary = "git" + defaultCommitName = "Hop" + defaultCommitMail = "hop@localhost" + hiddenRefPrefix = "refs/hop/" +) + +// GitIdentity is the identity written to synthetic commits made by Hop. +type GitIdentity struct { + Name string + Email string +} + +// GitStore locates repositories and supplies the Git executable and synthetic +// commit identity used by them. Its zero value is ready to use. +type GitStore struct { + Binary string + Identity GitIdentity + Now func() time.Time +} + +// Repository is a non-bare Git worktree. Hop uses the repository's object +// database as storage, but never needs to stage data in the user's index. +type Repository struct { + root string + gitDir string + commonGitDir string + store *GitStore +} + +// GitError reports a failed Git invocation without exposing its environment. +type GitError struct { + Args []string + ExitCode int + Stdout string + Stderr string + Err error +} + +func (e *GitError) Error() string { + message := strings.TrimSpace(e.Stderr) + if message == "" { + message = strings.TrimSpace(e.Stdout) + } + if message == "" { + message = e.Err.Error() + } + return fmt.Sprintf("git %s: %s", strings.Join(e.Args, " "), message) +} + +func (e *GitError) Unwrap() error { return e.Err } + +// CommitOptions controls a synthetic commit. Empty identities and timestamps +// use the GitStore defaults; user Git identity configuration is never used. +type CommitOptions struct { + Message string + Parents []string + Author GitIdentity + Committer GitIdentity + AuthorTime time.Time + CommitterTime time.Time +} + +// SnapshotOptions controls a workspace snapshot commit. +type SnapshotOptions struct { + Message string + Commit CommitOptions +} + +// PathChange is a rename-aware path-level Git change. For renames and copies, +// OldPath and NewPath are both populated. Other changes use NewPath. +type PathChange struct { + Status string `json:"status"` + OldPath string `json:"old_path,omitempty"` + NewPath string `json:"new_path,omitempty"` +} + +// NewGitStore returns a Git store with Hop's controlled commit identity. +func NewGitStore() *GitStore { + return &GitStore{ + Binary: defaultGitBinary, + Identity: GitIdentity{ + Name: defaultCommitName, + Email: defaultCommitMail, + }, + Now: time.Now, + } +} + +// EnsureRepository opens the repository containing path, or initializes one at +// path when none exists. It also keeps Hop's private directory out of snapshots. +func EnsureRepository(path string) (*Repository, error) { + return NewGitStore().Ensure(context.Background(), path) +} + +// OpenRepository opens the repository containing path and adds .hop/ to its +// per-repository exclude file. +func OpenRepository(path string) (*Repository, error) { + return NewGitStore().Open(context.Background(), path) +} + +// FindRepositoryRoot returns the top-level worktree containing path. +func FindRepositoryRoot(path string) (string, error) { + return NewGitStore().FindRoot(context.Background(), path) +} + +// Ensure opens the repository containing path, or initializes a new repository +// at path if it is not already inside one. +func (s *GitStore) Ensure(ctx context.Context, path string) (*Repository, error) { + path, err := absolutePath(path) + if err != nil { + return nil, err + } + + if info, statErr := os.Stat(path); statErr == nil && !info.IsDir() { + if root, findErr := s.FindRoot(ctx, filepath.Dir(path)); findErr == nil { + return s.openRoot(ctx, root, true) + } + return nil, fmt.Errorf("cannot initialize a repository at non-directory %q", path) + } else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { + return nil, fmt.Errorf("inspect repository path: %w", statErr) + } + + if root, findErr := s.FindRoot(ctx, path); findErr == nil { + return s.openRoot(ctx, root, true) + } + + if err := os.MkdirAll(path, 0o755); err != nil { + return nil, fmt.Errorf("create repository directory: %w", err) + } + if _, err := s.run(ctx, path, nil, nil, "init", "--quiet", path); err != nil { + return nil, err + } + return s.openRoot(ctx, path, true) +} + +// Open opens the non-bare repository containing path. +func (s *GitStore) Open(ctx context.Context, path string) (*Repository, error) { + root, err := s.FindRoot(ctx, path) + if err != nil { + return nil, err + } + return s.openRoot(ctx, root, true) +} + +// FindRoot locates the top-level non-bare worktree containing path. +func (s *GitStore) FindRoot(ctx context.Context, path string) (string, error) { + path, err := existingDirectory(path) + if err != nil { + return "", err + } + output, err := s.run(ctx, path, nil, nil, "rev-parse", "--show-toplevel") + if err != nil { + return "", fmt.Errorf("find Git repository from %q: %w", path, err) + } + root := trimLine(output) + if root == "" { + return "", fmt.Errorf("git returned an empty repository root for %q", path) + } + if !filepath.IsAbs(root) { + root = filepath.Join(path, root) + } + return filepath.Clean(root), nil +} + +func (s *GitStore) openRoot(ctx context.Context, root string, addExclude bool) (*Repository, error) { + root, err := filepath.Abs(root) + if err != nil { + return nil, fmt.Errorf("resolve repository root: %w", err) + } + inside, err := s.run(ctx, root, nil, nil, "rev-parse", "--is-inside-work-tree") + if err != nil { + return nil, err + } + if trimLine(inside) != "true" { + return nil, fmt.Errorf("%q is not a non-bare Git worktree", root) + } + + gitDirOutput, err := s.run(ctx, root, nil, nil, "rev-parse", "--absolute-git-dir") + if err != nil { + return nil, err + } + commonOutput, err := s.run(ctx, root, nil, nil, "rev-parse", "--git-common-dir") + if err != nil { + return nil, err + } + gitDir := resolveGitPath(root, trimLine(gitDirOutput)) + commonGitDir := resolveGitPath(root, trimLine(commonOutput)) + repository := &Repository{ + root: filepath.Clean(root), + gitDir: gitDir, + commonGitDir: commonGitDir, + store: s, + } + if addExclude { + if err := repository.EnsureHopExcluded(); err != nil { + return nil, err + } + } + return repository, nil +} + +// Root returns the absolute top-level directory of this worktree. +func (r *Repository) Root() string { return r.root } + +// GitDir returns the absolute per-worktree Git directory. +func (r *Repository) GitDir() string { return r.gitDir } + +// CommonGitDir returns the absolute common Git directory shared by linked +// worktrees. +func (r *Repository) CommonGitDir() string { return r.commonGitDir } + +// EnsureHopExcluded adds .hop/ to .git/info/exclude without changing tracked +// files or the repository's public .gitignore. +func (r *Repository) EnsureHopExcluded() error { + infoDir := filepath.Join(r.commonGitDir, "info") + if err := os.MkdirAll(infoDir, 0o755); err != nil { + return fmt.Errorf("create Git info directory: %w", err) + } + excludePath := filepath.Join(infoDir, "exclude") + contents, err := os.ReadFile(excludePath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("read Git exclude file: %w", err) + } + for _, line := range strings.Split(string(contents), "\n") { + if strings.TrimSuffix(line, "\r") == ".hop/" { + return nil + } + } + + file, err := os.OpenFile(excludePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("open Git exclude file: %w", err) + } + defer file.Close() + if len(contents) > 0 && contents[len(contents)-1] != '\n' { + if _, err := io.WriteString(file, "\n"); err != nil { + return fmt.Errorf("complete Git exclude line: %w", err) + } + } + if _, err := io.WriteString(file, ".hop/\n"); err != nil { + return fmt.Errorf("exclude .hop directory: %w", err) + } + return file.Sync() +} + +// Head returns the current HEAD commit. exists is false for an unborn branch. +func (r *Repository) Head(ctx context.Context) (oid string, exists bool, err error) { + output, err := r.run(ctx, nil, nil, "rev-parse", "--verify", "--quiet", "HEAD^{commit}") + if err != nil { + if gitExitCode(err) == 1 { + return "", false, nil + } + return "", false, err + } + return trimLine(output), true, nil +} + +// Snapshot records all tracked files and all non-ignored untracked files in the +// worktree. It uses a disposable index, preserving both the contents and staging +// state of the user's real index. The synthetic commit is parented to HEAD when +// HEAD exists; an unborn repository produces a parentless commit. +func (r *Repository) Snapshot(ctx context.Context, message string) (commitOID, treeOID string, err error) { + return r.SnapshotWithOptions(ctx, SnapshotOptions{Message: message}) +} + +// SnapshotWithOptions is Snapshot with explicit synthetic commit controls. +func (r *Repository) SnapshotWithOptions(ctx context.Context, options SnapshotOptions) (commitOID, treeOID string, err error) { + indexPath, cleanup, err := r.temporaryIndex(true) + if err != nil { + return "", "", err + } + defer cleanup() + + env := []string{"GIT_INDEX_FILE=" + indexPath, "GIT_OPTIONAL_LOCKS=0"} + if _, err := r.run(ctx, env, nil, "add", "-A", "--", "."); err != nil { + return "", "", fmt.Errorf("snapshot workspace: %w", err) + } + treeOutput, err := r.run(ctx, env, nil, "write-tree") + if err != nil { + return "", "", fmt.Errorf("write snapshot tree: %w", err) + } + treeOID = trimLine(treeOutput) + + parent, hasParent, err := r.Head(ctx) + if err != nil { + return "", "", err + } + commitOptions := options.Commit + commitOptions.Message = options.Message + if commitOptions.Message == "" { + commitOptions.Message = "hop workspace snapshot" + } + if hasParent { + commitOptions.Parents = []string{parent} + } else { + commitOptions.Parents = nil + } + commitOID, err = r.CommitTreeWithOptions(ctx, treeOID, commitOptions) + if err != nil { + return "", "", err + } + return commitOID, treeOID, nil +} + +// CommitTree creates a synthetic commit from tree. Parent order is preserved. +func (r *Repository) CommitTree(ctx context.Context, tree string, parents []string, message string) (string, error) { + return r.CommitTreeWithOptions(ctx, tree, CommitOptions{ + Message: message, + Parents: append([]string(nil), parents...), + }) +} + +// CommitTreeWithOptions creates a synthetic commit without invoking hooks or +// consulting the user's Git identity. It does not update any ref. +func (r *Repository) CommitTreeWithOptions(ctx context.Context, tree string, options CommitOptions) (string, error) { + if err := validObjectName(tree); err != nil { + return "", fmt.Errorf("invalid tree: %w", err) + } + for _, parent := range options.Parents { + if err := validObjectName(parent); err != nil { + return "", fmt.Errorf("invalid parent: %w", err) + } + } + + author := r.store.identity(options.Author) + committer := r.store.identity(options.Committer) + if options.Committer.Name == "" && options.Committer.Email == "" { + committer = author + } + if err := validateIdentity(author); err != nil { + return "", fmt.Errorf("invalid author identity: %w", err) + } + if err := validateIdentity(committer); err != nil { + return "", fmt.Errorf("invalid committer identity: %w", err) + } + + authorTime := options.AuthorTime + if authorTime.IsZero() { + authorTime = r.store.now() + } + committerTime := options.CommitterTime + if committerTime.IsZero() { + committerTime = authorTime + } + env := []string{ + "GIT_AUTHOR_NAME=" + author.Name, + "GIT_AUTHOR_EMAIL=" + author.Email, + "GIT_AUTHOR_DATE=" + authorTime.Format(time.RFC3339), + "GIT_COMMITTER_NAME=" + committer.Name, + "GIT_COMMITTER_EMAIL=" + committer.Email, + "GIT_COMMITTER_DATE=" + committerTime.Format(time.RFC3339), + } + + args := []string{"-c", "commit.gpgSign=false", "-c", "i18n.commitEncoding=UTF-8", "commit-tree", tree} + for _, parent := range options.Parents { + args = append(args, "-p", parent) + } + message := options.Message + if message == "" { + message = "hop synthetic commit" + } + output, err := r.run(ctx, env, []byte(message), args...) + if err != nil { + return "", fmt.Errorf("create synthetic commit: %w", err) + } + return trimLine(output), nil +} + +// HiddenRef returns the full private ref name for name. +func HiddenRef(name string) (string, error) { + if strings.HasPrefix(name, hiddenRefPrefix) { + if err := checkRefName(name); err != nil { + return "", err + } + return name, nil + } + if strings.HasPrefix(name, "refs/") { + return "", fmt.Errorf("hidden ref must be below %s", hiddenRefPrefix) + } + name = strings.TrimPrefix(name, "/") + ref := hiddenRefPrefix + name + if err := checkRefName(ref); err != nil { + return "", err + } + return ref, nil +} + +// ReadHiddenRef reads refs/hop/name. exists is false when it has not been +// created yet. +func (r *Repository) ReadHiddenRef(ctx context.Context, name string) (oid string, exists bool, err error) { + ref, err := HiddenRef(name) + if err != nil { + return "", false, err + } + return r.ReadRef(ctx, ref) +} + +// UpdateHiddenRef atomically updates refs/hop/name. With no expectedOld value, +// it is unconditional. Passing expectedOld performs compare-and-swap; an empty +// expected value means the ref must not exist. +func (r *Repository) UpdateHiddenRef(ctx context.Context, name, newOID string, expectedOld ...string) error { + ref, err := HiddenRef(name) + if err != nil { + return err + } + return r.UpdateRef(ctx, ref, newOID, expectedOld...) +} + +// ReadRef reads an exact, fully qualified ref without resolving ambiguous short +// names. exists is false when the ref is absent. +func (r *Repository) ReadRef(ctx context.Context, ref string) (oid string, exists bool, err error) { + if err := r.validateRef(ctx, ref); err != nil { + return "", false, err + } + output, err := r.run(ctx, nil, nil, "show-ref", "--verify", "--hash", ref) + if err != nil { + if gitExitCode(err) == 1 { + return "", false, nil + } + return "", false, err + } + return trimLine(output), true, nil +} + +// UpdateRef updates a fully qualified ref. Supplying expectedOld makes the +// operation compare-and-swap; expectedOld == "" requires an absent ref. +func (r *Repository) UpdateRef(ctx context.Context, ref, newOID string, expectedOld ...string) error { + if err := r.validateRef(ctx, ref); err != nil { + return err + } + if err := validObjectName(newOID); err != nil { + return fmt.Errorf("invalid new object ID: %w", err) + } + if len(expectedOld) > 1 { + return fmt.Errorf("update ref accepts at most one expected old object ID") + } + args := []string{"update-ref", "--create-reflog", "-m", "hop update", ref, newOID} + if len(expectedOld) == 1 { + old := expectedOld[0] + if old == "" { + var err error + old, err = r.ZeroOID(ctx) + if err != nil { + return err + } + } else if err := validObjectName(old); err != nil { + return fmt.Errorf("invalid expected object ID: %w", err) + } + args = append(args, old) + } + if _, err := r.run(ctx, nil, nil, args...); err != nil { + return fmt.Errorf("update ref %s: %w", ref, err) + } + return nil +} + +// DeleteRef deletes a fully qualified ref, optionally only if it still has the +// expected object ID. +func (r *Repository) DeleteRef(ctx context.Context, ref string, expectedOld ...string) error { + if err := r.validateRef(ctx, ref); err != nil { + return err + } + if len(expectedOld) > 1 { + return fmt.Errorf("delete ref accepts at most one expected old object ID") + } + args := []string{"update-ref", "-d", ref} + if len(expectedOld) == 1 && expectedOld[0] != "" { + if err := validObjectName(expectedOld[0]); err != nil { + return fmt.Errorf("invalid expected object ID: %w", err) + } + args = append(args, expectedOld[0]) + } + if _, err := r.run(ctx, nil, nil, args...); err != nil { + return fmt.Errorf("delete ref %s: %w", ref, err) + } + return nil +} + +// AddDetachedWorktree materializes commit at path without creating or moving a +// branch. The returned repository is rooted at the new linked worktree. +func (r *Repository) AddDetachedWorktree(ctx context.Context, path, commit string) (*Repository, error) { + if err := validObjectName(commit); err != nil { + return nil, fmt.Errorf("invalid worktree commit: %w", err) + } + path, err := filepath.Abs(path) + if err != nil { + return nil, fmt.Errorf("resolve worktree path: %w", err) + } + if _, err := r.run(ctx, nil, nil, "worktree", "add", "--detach", path, commit); err != nil { + return nil, fmt.Errorf("create detached worktree: %w", err) + } + worktree, err := r.store.openRoot(ctx, path, true) + if err != nil { + return nil, err + } + return worktree, nil +} + +// RemoveWorktree removes a linked worktree. force allows removal when it has +// local modifications or untracked files. +func (r *Repository) RemoveWorktree(ctx context.Context, path string, force bool) error { + path, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("resolve worktree path: %w", err) + } + args := []string{"worktree", "remove"} + if force { + args = append(args, "--force") + } + args = append(args, path) + if _, err := r.run(ctx, nil, nil, args...); err != nil { + return fmt.Errorf("remove worktree: %w", err) + } + return nil +} + +// Diff returns a binary-capable, full-index patch between two commits or trees. +// An empty endpoint denotes Git's empty tree, which supports unborn histories. +func (r *Repository) Diff(ctx context.Context, from, to string) (string, error) { + fromTree, err := r.resolveTree(ctx, from) + if err != nil { + return "", err + } + toTree, err := r.resolveTree(ctx, to) + if err != nil { + return "", err + } + output, err := r.run(ctx, nil, nil, + "diff", "--no-ext-diff", "--no-textconv", "--binary", "--full-index", "--find-renames", + fromTree, toTree, "--", + ) + if err != nil { + return "", fmt.Errorf("diff trees: %w", err) + } + return output, nil +} + +// ChangedPathDetails returns Git's rename-aware path changes. +func (r *Repository) ChangedPathDetails(ctx context.Context, from, to string) ([]PathChange, error) { + fromTree, err := r.resolveTree(ctx, from) + if err != nil { + return nil, err + } + toTree, err := r.resolveTree(ctx, to) + if err != nil { + return nil, err + } + output, err := r.run(ctx, nil, nil, + "diff", "--no-ext-diff", "--no-textconv", "--name-status", "-z", "--find-renames", + fromTree, toTree, "--", + ) + if err != nil { + return nil, fmt.Errorf("list changed paths: %w", err) + } + return parseNameStatus([]byte(output)) +} + +// ChangedPaths returns the set of affected paths in bytewise order. A rename +// or copy contributes both its old and new path, making overlap checks safe. +func (r *Repository) ChangedPaths(ctx context.Context, from, to string) ([]string, error) { + changes, err := r.ChangedPathDetails(ctx, from, to) + if err != nil { + return nil, err + } + paths := make(map[string]struct{}, len(changes)) + for _, change := range changes { + if change.OldPath != "" { + paths[change.OldPath] = struct{}{} + } + if change.NewPath != "" { + paths[change.NewPath] = struct{}{} + } + } + result := make([]string, 0, len(paths)) + for path := range paths { + result = append(result, path) + } + sort.Strings(result) + return result, nil +} + +// TrackedPaths lists index entries at or below path. Hop uses this before +// initialization to avoid hiding or overwriting a repository that already +// treats .hop as user-owned source content. +func (r *Repository) TrackedPaths(ctx context.Context, path string) ([]string, error) { + if path == "" { + return nil, fmt.Errorf("tracked path query requires a path") + } + output, err := r.run(ctx, nil, nil, "ls-files", "-z", "--", path) + if err != nil { + return nil, fmt.Errorf("list tracked paths below %s: %w", path, err) + } + parts := splitNull([]byte(output)) + paths := make([]string, 0, len(parts)) + for _, part := range parts { + if part != "" { + paths = append(paths, part) + } + } + sort.Strings(paths) + return paths, nil +} + +// ComposeTrees conservatively composes the changes base->theirs onto ours. +// It uses Git's three-tree index merge, so a path independently changed to +// different content is reported as a conflict instead of being rewritten. No +// worktree or user index is read or changed. On conflict, tree is empty and the +// sorted conflicting path set is returned with a nil error. +func (r *Repository) ComposeTrees(ctx context.Context, base, ours, theirs string) (tree string, conflicts []string, err error) { + baseTree, err := r.resolveTree(ctx, base) + if err != nil { + return "", nil, fmt.Errorf("resolve base tree: %w", err) + } + oursTree, err := r.resolveTree(ctx, ours) + if err != nil { + return "", nil, fmt.Errorf("resolve current tree: %w", err) + } + theirsTree, err := r.resolveTree(ctx, theirs) + if err != nil { + return "", nil, fmt.Errorf("resolve proposal tree: %w", err) + } + + indexPath, cleanup, err := r.temporaryIndex(false) + if err != nil { + return "", nil, err + } + defer cleanup() + env := []string{"GIT_INDEX_FILE=" + indexPath, "GIT_OPTIONAL_LOCKS=0"} + // --aggressive resolves only additional provably-trivial cases (for example, + // a deletion on one side while the other side is unchanged). It does not + // attempt a content merge of independently modified files. + _, mergeErr := r.run(ctx, env, nil, "read-tree", "-m", "--aggressive", baseTree, oursTree, theirsTree) + + unmergedOutput, listErr := r.run(ctx, env, nil, "ls-files", "-u", "-z") + if listErr == nil { + conflicts = parseUnmergedPaths([]byte(unmergedOutput)) + } + if len(conflicts) > 0 { + return "", conflicts, nil + } + if mergeErr != nil { + return "", nil, fmt.Errorf("compose trees: %w", mergeErr) + } + if listErr != nil { + return "", nil, fmt.Errorf("inspect composed index: %w", listErr) + } + + treeOutput, err := r.run(ctx, env, nil, "write-tree") + if err != nil { + return "", nil, fmt.Errorf("write composed tree: %w", err) + } + return trimLine(treeOutput), nil, nil +} + +// VerifyObject verifies that name resolves to an object in this repository. +func (r *Repository) VerifyObject(ctx context.Context, name string) error { + if err := validObjectName(name); err != nil { + return err + } + if _, err := r.run(ctx, nil, nil, "cat-file", "-e", name+"^{object}"); err != nil { + return fmt.Errorf("verify Git object %s: %w", name, err) + } + return nil +} + +// VerifyObjects verifies each named object. +func (r *Repository) VerifyObjects(ctx context.Context, names ...string) error { + for _, name := range names { + if err := r.VerifyObject(ctx, name); err != nil { + return err + } + } + return nil +} + +// Verify checks the connectivity and validity of the repository's reachable +// object graph. Dangling Hop snapshots are allowed and do not produce noise. +func (r *Repository) Verify(ctx context.Context) error { + if _, err := r.run(ctx, nil, nil, "fsck", "--connectivity-only", "--no-dangling"); err != nil { + return fmt.Errorf("verify Git object database: %w", err) + } + return nil +} + +// EmptyTree returns the object ID of Git's empty tree for this repository's +// object format. +func (r *Repository) EmptyTree(ctx context.Context) (string, error) { + output, err := r.run(ctx, nil, nil, "mktree") + if err != nil { + return "", fmt.Errorf("create empty tree: %w", err) + } + return trimLine(output), nil +} + +// ZeroOID returns the all-zero object ID of the correct length for this +// repository (SHA-1 or SHA-256). +func (r *Repository) ZeroOID(ctx context.Context) (string, error) { + output, err := r.run(ctx, nil, nil, "rev-parse", "--show-object-format") + if err != nil { + return "", fmt.Errorf("read Git object format: %w", err) + } + switch trimLine(output) { + case "sha1": + return strings.Repeat("0", 40), nil + case "sha256": + return strings.Repeat("0", 64), nil + default: + return "", fmt.Errorf("unsupported Git object format %q", trimLine(output)) + } +} + +func (r *Repository) resolveTree(ctx context.Context, object string) (string, error) { + if object == "" { + return r.EmptyTree(ctx) + } + if err := validObjectName(object); err != nil { + return "", err + } + output, err := r.run(ctx, nil, nil, "rev-parse", "--verify", "--quiet", object+"^{tree}") + if err != nil { + return "", fmt.Errorf("resolve tree %s: %w", object, err) + } + return trimLine(output), nil +} + +func (r *Repository) validateRef(ctx context.Context, ref string) error { + if !strings.HasPrefix(ref, "refs/") { + return fmt.Errorf("ref must be fully qualified: %q", ref) + } + if _, err := r.run(ctx, nil, nil, "check-ref-format", ref); err != nil { + return fmt.Errorf("invalid ref %q: %w", ref, err) + } + return nil +} + +func (r *Repository) temporaryIndex(seedFromUser bool) (string, func(), error) { + if err := os.MkdirAll(r.gitDir, 0o755); err != nil { + return "", nil, fmt.Errorf("prepare Git directory: %w", err) + } + temporary, err := os.CreateTemp(r.gitDir, "hop-index-*") + if err != nil { + return "", nil, fmt.Errorf("create temporary Git index: %w", err) + } + path := temporary.Name() + if err := temporary.Close(); err != nil { + os.Remove(path) + return "", nil, fmt.Errorf("close temporary Git index: %w", err) + } + cleanup := func() { + _ = os.Remove(path) + _ = os.Remove(path + ".lock") + } + + if seedFromUser { + userIndex := filepath.Join(r.gitDir, "index") + contents, readErr := os.ReadFile(userIndex) + switch { + case readErr == nil: + if writeErr := os.WriteFile(path, contents, 0o600); writeErr != nil { + cleanup() + return "", nil, fmt.Errorf("seed temporary Git index: %w", writeErr) + } + case errors.Is(readErr, os.ErrNotExist): + if removeErr := os.Remove(path); removeErr != nil { + cleanup() + return "", nil, fmt.Errorf("prepare empty temporary Git index: %w", removeErr) + } + default: + cleanup() + return "", nil, fmt.Errorf("read user Git index: %w", readErr) + } + } else if err := os.Remove(path); err != nil { + cleanup() + return "", nil, fmt.Errorf("prepare temporary Git index: %w", err) + } + return path, cleanup, nil +} + +func (r *Repository) run(ctx context.Context, env []string, stdin []byte, args ...string) (string, error) { + return r.store.run(ctx, r.root, env, stdin, args...) +} + +func (s *GitStore) run(ctx context.Context, directory string, env []string, stdin []byte, args ...string) (string, error) { + if ctx == nil { + ctx = context.Background() + } + binary := s.Binary + if binary == "" { + binary = defaultGitBinary + } + command := exec.CommandContext(ctx, binary, args...) + command.Dir = directory + command.Env = mergeEnvironment(os.Environ(), append([]string{"LC_ALL=C"}, env...)) + if stdin != nil { + command.Stdin = bytes.NewReader(stdin) + } + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + err := command.Run() + if err != nil { + exitCode := -1 + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exitCode = exitErr.ExitCode() + } + return stdout.String(), &GitError{ + Args: append([]string(nil), args...), + ExitCode: exitCode, + Stdout: stdout.String(), + Stderr: stderr.String(), + Err: err, + } + } + return stdout.String(), nil +} + +func (s *GitStore) identity(override GitIdentity) GitIdentity { + identity := s.Identity + if identity.Name == "" { + identity.Name = defaultCommitName + } + if identity.Email == "" { + identity.Email = defaultCommitMail + } + if override.Name != "" { + identity.Name = override.Name + } + if override.Email != "" { + identity.Email = override.Email + } + return identity +} + +func (s *GitStore) now() time.Time { + if s.Now != nil { + return s.Now() + } + return time.Now() +} + +func parseNameStatus(output []byte) ([]PathChange, error) { + fields := splitNull(output) + changes := make([]PathChange, 0, len(fields)/2) + for index := 0; index < len(fields); { + status := fields[index] + index++ + if status == "" { + continue + } + kind := status[0] + if kind == 'R' || kind == 'C' { + if index+1 >= len(fields) { + return nil, fmt.Errorf("malformed rename entry in git diff --name-status") + } + changes = append(changes, PathChange{ + Status: status, + OldPath: fields[index], + NewPath: fields[index+1], + }) + index += 2 + continue + } + if index >= len(fields) { + return nil, fmt.Errorf("malformed entry in git diff --name-status") + } + changes = append(changes, PathChange{Status: status, NewPath: fields[index]}) + index++ + } + return changes, nil +} + +func parseUnmergedPaths(output []byte) []string { + set := make(map[string]struct{}) + for _, record := range splitNull(output) { + if record == "" { + continue + } + if tab := strings.IndexByte(record, '\t'); tab >= 0 && tab+1 < len(record) { + set[record[tab+1:]] = struct{}{} + } + } + paths := make([]string, 0, len(set)) + for path := range set { + paths = append(paths, path) + } + sort.Strings(paths) + return paths +} + +func splitNull(value []byte) []string { + if len(value) == 0 { + return nil + } + parts := bytes.Split(value, []byte{0}) + if len(parts) > 0 && len(parts[len(parts)-1]) == 0 { + parts = parts[:len(parts)-1] + } + result := make([]string, len(parts)) + for index, part := range parts { + result[index] = string(part) + } + return result +} + +func validObjectName(name string) error { + if name == "" { + return fmt.Errorf("object name is empty") + } + if strings.HasPrefix(name, "-") || strings.ContainsAny(name, "\x00\r\n") { + return fmt.Errorf("unsafe object name %q", name) + } + return nil +} + +func validateIdentity(identity GitIdentity) error { + if identity.Name == "" || identity.Email == "" { + return fmt.Errorf("name and email are required") + } + if strings.ContainsAny(identity.Name, "\x00\r\n<>") { + return fmt.Errorf("unsafe name") + } + if strings.ContainsAny(identity.Email, "\x00\r\n<>") { + return fmt.Errorf("unsafe email") + } + return nil +} + +func checkRefName(ref string) error { + if ref == "" || strings.ContainsAny(ref, "\x00\r\n") { + return fmt.Errorf("invalid ref %q", ref) + } + if !strings.HasPrefix(ref, "refs/") || strings.HasSuffix(ref, "/") || + strings.Contains(ref, "..") || strings.Contains(ref, "@{") || + strings.ContainsAny(ref, " ~^:?*[\\") { + return fmt.Errorf("invalid ref %q", ref) + } + for _, component := range strings.Split(ref, "/") { + if component == "" || strings.HasPrefix(component, ".") || strings.HasSuffix(component, ".") || strings.HasSuffix(component, ".lock") { + return fmt.Errorf("invalid ref %q", ref) + } + } + return nil +} + +func gitExitCode(err error) int { + var gitErr *GitError + if errors.As(err, &gitErr) { + return gitErr.ExitCode + } + return -1 +} + +func mergeEnvironment(base, overrides []string) []string { + values := make(map[string]string, len(base)+len(overrides)) + order := make([]string, 0, len(base)+len(overrides)) + add := func(entry string) { + key := entry + if equals := strings.IndexByte(entry, '='); equals >= 0 { + key = entry[:equals] + } + if _, exists := values[key]; !exists { + order = append(order, key) + } + values[key] = entry + } + for _, entry := range base { + add(entry) + } + for _, entry := range overrides { + add(entry) + } + result := make([]string, 0, len(values)) + for _, key := range order { + result = append(result, values[key]) + } + return result +} + +func absolutePath(path string) (string, error) { + if path == "" { + path = "." + } + absolute, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("resolve path %q: %w", path, err) + } + return filepath.Clean(absolute), nil +} + +func existingDirectory(path string) (string, error) { + path, err := absolutePath(path) + if err != nil { + return "", err + } + info, err := os.Stat(path) + if err != nil { + return "", fmt.Errorf("inspect path %q: %w", path, err) + } + if !info.IsDir() { + path = filepath.Dir(path) + } + return path, nil +} + +func resolveGitPath(root, path string) string { + if filepath.IsAbs(path) { + return filepath.Clean(path) + } + return filepath.Clean(filepath.Join(root, path)) +} + +func trimLine(value string) string { + return strings.TrimSuffix(strings.TrimSuffix(value, "\n"), "\r") +} diff --git a/internal/hop/id.go b/internal/hop/id.go new file mode 100644 index 0000000..21e4fcf --- /dev/null +++ b/internal/hop/id.go @@ -0,0 +1,28 @@ +package hop + +import ( + "crypto/rand" + "encoding/base32" + "fmt" + "strings" + "time" +) + +var idEncoding = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding) + +func newID(prefix string) string { + var entropy [8]byte + if _, err := rand.Read(entropy[:]); err != nil { + panic(fmt.Sprintf("generate id: %v", err)) + } + stamp := uint64(time.Now().UTC().UnixMilli()) + var raw [14]byte + raw[0] = byte(stamp >> 40) + raw[1] = byte(stamp >> 32) + raw[2] = byte(stamp >> 24) + raw[3] = byte(stamp >> 16) + raw[4] = byte(stamp >> 8) + raw[5] = byte(stamp) + copy(raw[6:], entropy[:]) + return strings.ToUpper(prefix) + "_" + idEncoding.EncodeToString(raw[:]) +} diff --git a/internal/hop/model.go b/internal/hop/model.go new file mode 100644 index 0000000..1d101ae --- /dev/null +++ b/internal/hop/model.go @@ -0,0 +1,155 @@ +package hop + +import ( + "fmt" + "time" +) + +type StateKind string + +const ( + StatePrompt StateKind = "prompt" + StateCheckpoint StateKind = "checkpoint" + StateProposal StateKind = "proposal" + StateAccepted StateKind = "accepted" + StateFailed StateKind = "failed" + StateCancelled StateKind = "cancelled" +) + +type Parent struct { + StateID string `json:"state_id"` + Role string `json:"role"` + Order int `json:"order"` +} + +type State struct { + ID string `json:"id"` + Kind StateKind `json:"kind"` + TaskID string `json:"task_id,omitempty"` + AttemptID string `json:"attempt_id,omitempty"` + CanonicalAnchorID string `json:"canonical_anchor_id,omitempty"` + SourceTree string `json:"source_tree"` + GitCommit string `json:"git_commit"` + Prompt string `json:"prompt,omitempty"` + Summary string `json:"summary,omitempty"` + Agent string `json:"agent,omitempty"` + Digest string `json:"digest"` + CreatedAt time.Time `json:"created_at"` + Parents []Parent `json:"parents,omitempty"` +} + +type Task struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +type Attempt struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + Agent string `json:"agent,omitempty"` + Workspace string `json:"workspace"` + BaseStateID string `json:"base_state_id"` + HeadStateID string `json:"head_state_id"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +type Check struct { + ID string `json:"id"` + AttemptID string `json:"attempt_id"` + StateID string `json:"state_id,omitempty"` + TreeHash string `json:"tree_hash"` + Command []string `json:"command"` + ExitCode int `json:"exit_code"` + Output string `json:"output"` + CreatedAt time.Time `json:"created_at"` +} + +type GraphRow struct { + State State `json:"state"` + Parents []Parent `json:"parents"` +} + +type Status struct { + Root string `json:"root"` + AcceptedHead State `json:"accepted_head"` + Attempts []Attempt `json:"attempts"` +} + +type AcceptResult struct { + State State `json:"state"` + ProposalPaths []string `json:"proposal_paths"` + CurrentPaths []string `json:"current_paths"` + Check *Check `json:"check,omitempty"` + Warnings []string `json:"warnings,omitempty"` +} + +type PromptResult struct { + Prompt State `json:"prompt"` + Checkpoint *State `json:"checkpoint,omitempty"` + Task Task `json:"task"` + Attempt Attempt `json:"attempt"` + Workspace string `json:"workspace"` + Deliver []string `json:"deliver"` +} + +type EnvironmentResult struct { + State State `json:"state"` + Attempt Attempt `json:"attempt"` + Workspace string `json:"workspace"` + Variables map[string]string `json:"variables"` +} + +type ProposalResult struct { + Proposal State `json:"proposal"` + Checks []Check `json:"checks"` +} + +type DoctorReport struct { + OK bool `json:"ok"` + AcceptedState string `json:"accepted_state"` + AcceptedCommit string `json:"accepted_commit"` + RefCommit string `json:"ref_commit,omitempty"` + Repaired bool `json:"repaired"` + Problems []string `json:"problems,omitempty"` +} + +type StaleHeadError struct { + Expected string + Actual string +} + +func (e *StaleHeadError) Error() string { + return "accepted head changed while the operation was running" +} + +type CheckFailedError struct { + Check Check +} + +func (e *CheckFailedError) Error() string { + return "validation command failed" +} + +// CommittedStateError means the authoritative SQLite transition succeeded but +// a derived Git ref needs repair. Callers must not retry the state transition. +type CommittedStateError struct { + State State + Err error +} + +func (e *CommittedStateError) Error() string { + return fmt.Sprintf("state %s is committed, but derived Git refs need repair: %v", e.State.ID, e.Err) +} + +func (e *CommittedStateError) Unwrap() error { return e.Err } + +type ConflictError struct { + Paths []string +} + +func (e *ConflictError) Error() string { + return "proposal overlaps changes accepted since its canonical anchor" +} diff --git a/internal/hop/service.go b/internal/hop/service.go new file mode 100644 index 0000000..b101c1b --- /dev/null +++ b/internal/hop/service.go @@ -0,0 +1,1008 @@ +package hop + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +const acceptedRef = "accepted" + +type Service struct { + Root string + Store *Store + Repo *Repository +} + +func InitProject(ctx context.Context, path string) (*Service, State, error) { + repo, err := EnsureRepository(path) + if err != nil { + return nil, State{}, err + } + root := repo.Root() + trackedHopPaths, err := repo.TrackedPaths(ctx, ".hop") + if err != nil { + return nil, State{}, err + } + if len(trackedHopPaths) > 0 { + return nil, State{}, fmt.Errorf("cannot initialize Hop: .hop is already tracked as project source (for example %s)", trackedHopPaths[0]) + } + hopDir := filepath.Join(root, ".hop") + if err := os.MkdirAll(filepath.Join(hopDir, "workspaces"), 0o755); err != nil { + return nil, State{}, fmt.Errorf("create Hop project directory: %w", err) + } + store, err := OpenStore(filepath.Join(hopDir, "hop.db")) + if err != nil { + return nil, State{}, err + } + service := &Service{Root: root, Store: store, Repo: repo} + initialized, err := store.IsInitialized(ctx) + if err != nil { + service.Close() + return nil, State{}, err + } + if initialized { + head, err := store.AcceptedHead(ctx) + if err != nil { + service.Close() + return nil, State{}, err + } + if err := service.reconcileDerivedRefs(ctx, true); err != nil { + service.Close() + return nil, State{}, err + } + return service, head, nil + } + + commit, tree, err := repo.Snapshot(ctx, "hop: initial project state\n") + if err != nil { + service.Close() + return nil, State{}, err + } + initial := State{ + ID: newID("a"), + Kind: StateAccepted, + SourceTree: tree, + GitCommit: commit, + Summary: "Initial project state", + Agent: "hop", + CreatedAt: time.Now().UTC(), + } + initial.Digest, err = digestState(initial, nil) + if err != nil { + service.Close() + return nil, State{}, err + } + if err := service.pinState(ctx, initial); err != nil { + service.Close() + return nil, State{}, err + } + initial, err = store.CreateInitialState(ctx, root, initial) + if err != nil { + service.Close() + return nil, State{}, err + } + if err := repo.UpdateHiddenRef(ctx, acceptedRef, commit); err != nil { + service.Close() + return nil, State{}, err + } + return service, initial, nil +} + +func OpenProject(start string) (*Service, error) { + root, err := FindHopRoot(start) + if err != nil { + return nil, err + } + store, err := OpenStore(filepath.Join(root, ".hop", "hop.db")) + if err != nil { + return nil, err + } + repo, err := OpenRepository(root) + if err != nil { + store.Close() + return nil, err + } + recordedRoot, err := store.RepositoryRoot(context.Background()) + if err != nil { + store.Close() + return nil, err + } + if filepath.Clean(recordedRoot) != filepath.Clean(root) { + store.Close() + return nil, fmt.Errorf("Hop database belongs to %s, not %s", recordedRoot, root) + } + service := &Service{Root: root, Store: store, Repo: repo} + reconcileAccepted := os.Getenv("HOP_ACCEPTANCE_LOCK_HELD") != "1" + if err := service.reconcileDerivedRefs(context.Background(), reconcileAccepted); err != nil { + service.Close() + return nil, err + } + return service, nil +} + +func (s *Service) Close() error { + if s == nil || s.Store == nil { + return nil + } + return s.Store.Close() +} + +func (s *Service) CreatePrompt(ctx context.Context, message, fromStateID, agent string) (PromptResult, error) { + if strings.TrimSpace(message) == "" { + return PromptResult{}, fmt.Errorf("prompt text is required") + } + if fromStateID == "" { + return s.createInitialPrompt(ctx, message, agent) + } + return s.createFollowupPrompt(ctx, message, fromStateID, agent) +} + +func (s *Service) createInitialPrompt(ctx context.Context, message, agent string) (PromptResult, error) { + accepted, err := s.Store.AcceptedHead(ctx) + if err != nil { + return PromptResult{}, err + } + now := time.Now().UTC() + task := Task{ID: newID("t"), Title: promptTitle(message), Status: "active", CreatedAt: now} + attemptID := newID("at") + workspace := filepath.Join(s.Root, ".hop", "workspaces", attemptID) + attempt := Attempt{ + ID: attemptID, + TaskID: task.ID, + Agent: agent, + Workspace: workspace, + BaseStateID: accepted.ID, + Status: "active", + CreatedAt: now, + } + parents := canonicalizeParents([]Parent{ + {StateID: accepted.ID, Role: "run_parent", Order: 0}, + {StateID: accepted.ID, Role: "canonical_anchor", Order: 1}, + }) + prompt := State{ + ID: newID("p"), + Kind: StatePrompt, + TaskID: task.ID, + AttemptID: attempt.ID, + CanonicalAnchorID: accepted.ID, + SourceTree: accepted.SourceTree, + GitCommit: accepted.GitCommit, + Prompt: message, + Agent: agent, + CreatedAt: now, + Parents: parents, + } + prompt.Digest, err = digestState(prompt, parents) + if err != nil { + return PromptResult{}, err + } + if err := s.pinState(ctx, prompt); err != nil { + return PromptResult{}, err + } + task, attempt, prompt, err = s.Store.CreateTaskAttemptPrompt(ctx, task, attempt, prompt, parents) + if err != nil { + return PromptResult{}, err + } + if err := os.MkdirAll(filepath.Dir(workspace), 0o755); err != nil { + _ = s.Store.UpdateAttemptStatus(ctx, attempt.ID, "failed") + return PromptResult{}, err + } + if _, err := s.Repo.AddDetachedWorktree(ctx, workspace, accepted.GitCommit); err != nil { + _ = s.Store.UpdateAttemptStatus(ctx, attempt.ID, "failed") + return PromptResult{}, err + } + return PromptResult{ + Prompt: prompt, + Task: task, + Attempt: attempt, + Workspace: workspace, + Deliver: s.deliveryEnvironment(prompt, attempt), + }, nil +} + +func (s *Service) createFollowupPrompt(ctx context.Context, message, fromStateID, agent string) (PromptResult, error) { + from, err := s.Store.GetState(ctx, fromStateID) + if err != nil { + return PromptResult{}, err + } + if from.AttemptID == "" { + return PromptResult{}, fmt.Errorf("state %s does not belong to an attempt", from.ID) + } + attempt, err := s.Store.GetAttempt(ctx, from.AttemptID) + if err != nil { + return PromptResult{}, err + } + task, err := s.Store.GetTask(ctx, attempt.TaskID) + if err != nil { + return PromptResult{}, err + } + checkpoint, err := s.checkpointAttempt(ctx, attempt) + if err != nil { + return PromptResult{}, err + } + accepted, err := s.Store.AcceptedHead(ctx) + if err != nil { + return PromptResult{}, err + } + if agent == "" { + agent = attempt.Agent + } + parents := canonicalizeParents([]Parent{ + {StateID: checkpoint.ID, Role: "run_parent", Order: 0}, + {StateID: accepted.ID, Role: "canonical_anchor", Order: 1}, + }) + prompt := State{ + ID: newID("p"), + Kind: StatePrompt, + TaskID: attempt.TaskID, + AttemptID: attempt.ID, + CanonicalAnchorID: accepted.ID, + SourceTree: checkpoint.SourceTree, + GitCommit: checkpoint.GitCommit, + Prompt: message, + Agent: agent, + CreatedAt: time.Now().UTC(), + Parents: parents, + } + prompt.Digest, err = digestState(prompt, parents) + if err != nil { + return PromptResult{}, err + } + if err := s.pinState(ctx, prompt); err != nil { + return PromptResult{}, err + } + prompt, err = s.Store.AppendState(ctx, prompt, parents, checkpoint.ID) + if err != nil { + return PromptResult{}, mapHeadError(err) + } + _ = s.Store.UpdateAttemptStatus(ctx, attempt.ID, "active") + _ = s.Store.UpdateTaskStatus(ctx, task.ID, "active") + attempt, err = s.Store.GetAttempt(ctx, attempt.ID) + if err != nil { + return PromptResult{}, err + } + return PromptResult{ + Prompt: prompt, + Checkpoint: &checkpoint, + Task: task, + Attempt: attempt, + Workspace: attempt.Workspace, + Deliver: s.deliveryEnvironment(prompt, attempt), + }, nil +} + +func (s *Service) Checkpoint(ctx context.Context, stateID string) (State, error) { + state, err := s.Store.GetState(ctx, stateID) + if err != nil { + return State{}, err + } + if state.AttemptID == "" { + return State{}, fmt.Errorf("state %s does not belong to an attempt", state.ID) + } + attempt, err := s.Store.GetAttempt(ctx, state.AttemptID) + if err != nil { + return State{}, err + } + return s.checkpointAttempt(ctx, attempt) +} + +func (s *Service) checkpointAttempt(ctx context.Context, attempt Attempt) (State, error) { + workspaceRepo, err := OpenRepository(attempt.Workspace) + if err != nil { + return State{}, err + } + commit, tree, err := workspaceRepo.Snapshot(ctx, "hop: workspace checkpoint\n") + if err != nil { + return State{}, err + } + head, err := s.Store.GetState(ctx, attempt.HeadStateID) + if err != nil { + return State{}, err + } + parents := canonicalizeParents([]Parent{{StateID: head.ID, Role: "run_parent", Order: 0}}) + checkpoint := State{ + ID: newID("c"), + Kind: StateCheckpoint, + TaskID: attempt.TaskID, + AttemptID: attempt.ID, + CanonicalAnchorID: head.CanonicalAnchorID, + SourceTree: tree, + GitCommit: commit, + Agent: attempt.Agent, + CreatedAt: time.Now().UTC(), + Parents: parents, + } + checkpoint.Digest, err = digestState(checkpoint, parents) + if err != nil { + return State{}, err + } + if err := s.pinState(ctx, checkpoint); err != nil { + return State{}, err + } + checkpoint, err = s.Store.AppendState(ctx, checkpoint, parents, head.ID) + if err != nil { + return State{}, mapHeadError(err) + } + return checkpoint, nil +} + +func (s *Service) RunCheck(ctx context.Context, stateID string, argv []string) (Check, error) { + checkpoint, err := s.Checkpoint(ctx, stateID) + if err != nil { + return Check{}, err + } + attempt, err := s.Store.GetAttempt(ctx, checkpoint.AttemptID) + if err != nil { + return Check{}, err + } + checkID := newID("evidence") + checkPath := filepath.Join(s.Root, ".hop", "checks", checkID) + if err := os.MkdirAll(filepath.Dir(checkPath), 0o755); err != nil { + return Check{}, err + } + if _, err := s.Repo.AddDetachedWorktree(ctx, checkPath, checkpoint.GitCommit); err != nil { + return Check{}, err + } + env := []string{ + "HOP_ROOT=" + s.Root, + "HOP_STATE_ID=" + checkpoint.ID, + "HOP_TASK_ID=" + checkpoint.TaskID, + "HOP_ATTEMPT_ID=" + attempt.ID, + "HOP_WORKSPACE=" + checkPath, + } + result, runErr := runWorkspaceCommand(ctx, checkPath, env, argv) + removeErr := s.Repo.RemoveWorktree(ctx, checkPath, true) + if runErr != nil { + return Check{}, runErr + } + if removeErr != nil { + return Check{}, removeErr + } + check := Check{ + ID: checkID, + AttemptID: attempt.ID, + StateID: checkpoint.ID, + TreeHash: checkpoint.SourceTree, + Command: append([]string(nil), argv...), + ExitCode: result.ExitCode, + Output: result.Output, + CreatedAt: time.Now().UTC(), + } + check, err = s.Store.AddCheck(ctx, check) + if err != nil { + return Check{}, err + } + if check.ExitCode != 0 { + return check, &CheckFailedError{Check: check} + } + return check, nil +} + +func (s *Service) Propose(ctx context.Context, stateID, summary string) (ProposalResult, error) { + state, err := s.Store.GetState(ctx, stateID) + if err != nil { + return ProposalResult{}, err + } + if state.AttemptID == "" { + return ProposalResult{}, fmt.Errorf("state %s does not belong to an attempt", state.ID) + } + attempt, err := s.Store.GetAttempt(ctx, state.AttemptID) + if err != nil { + return ProposalResult{}, err + } + task, err := s.Store.GetTask(ctx, attempt.TaskID) + if err != nil { + return ProposalResult{}, err + } + workspaceRepo, err := OpenRepository(attempt.Workspace) + if err != nil { + return ProposalResult{}, err + } + commit, tree, err := workspaceRepo.Snapshot(ctx, "hop: proposal\n") + if err != nil { + return ProposalResult{}, err + } + if strings.TrimSpace(summary) == "" { + summary = task.Title + } + parents := canonicalizeParents([]Parent{{StateID: attempt.HeadStateID, Role: "run_parent", Order: 0}}) + proposal := State{ + ID: newID("r"), + Kind: StateProposal, + TaskID: attempt.TaskID, + AttemptID: attempt.ID, + CanonicalAnchorID: attempt.BaseStateID, + SourceTree: tree, + GitCommit: commit, + Summary: summary, + Agent: attempt.Agent, + CreatedAt: time.Now().UTC(), + Parents: parents, + } + proposal.Digest, err = digestState(proposal, parents) + if err != nil { + return ProposalResult{}, err + } + if err := s.pinState(ctx, proposal); err != nil { + return ProposalResult{}, err + } + proposal, err = s.Store.AppendState(ctx, proposal, parents, attempt.HeadStateID) + if err != nil { + return ProposalResult{}, mapHeadError(err) + } + _ = s.Store.UpdateAttemptStatus(ctx, attempt.ID, "proposed") + _ = s.Store.UpdateTaskStatus(ctx, task.ID, "proposed") + checks, err := s.Store.ListChecks(ctx, attempt.ID, tree) + if err != nil { + return ProposalResult{}, err + } + return ProposalResult{Proposal: proposal, Checks: checks}, nil +} + +func (s *Service) Accept(ctx context.Context, proposalID string, checkCommand []string) (AcceptResult, error) { + release, err := acquireProjectLock(ctx, s.Root, "accept") + if err != nil { + return AcceptResult{}, err + } + defer release() + + proposal, err := s.Store.GetState(ctx, proposalID) + if err != nil { + return AcceptResult{}, err + } + if proposal.Kind != StateProposal { + return AcceptResult{}, fmt.Errorf("state %s is %s, not a proposal", proposal.ID, proposal.Kind) + } + attempt, err := s.Store.GetAttempt(ctx, proposal.AttemptID) + if err != nil { + return AcceptResult{}, err + } + base, err := s.Store.GetState(ctx, attempt.BaseStateID) + if err != nil { + return AcceptResult{}, err + } + current, err := s.Store.AcceptedHead(ctx) + if err != nil { + return AcceptResult{}, err + } + proposalPaths, err := s.Repo.ChangedPaths(ctx, base.GitCommit, proposal.GitCommit) + if err != nil { + return AcceptResult{}, err + } + currentPaths, err := s.Repo.ChangedPaths(ctx, base.GitCommit, current.GitCommit) + if err != nil { + return AcceptResult{}, err + } + overlap := intersectPaths(proposalPaths, currentPaths) + if len(overlap) > 0 { + return AcceptResult{}, &ConflictError{Paths: overlap} + } + finalTree, mergeConflicts, err := s.Repo.ComposeTrees(ctx, base.GitCommit, current.GitCommit, proposal.GitCommit) + if err != nil { + return AcceptResult{}, err + } + if len(mergeConflicts) > 0 { + return AcceptResult{}, &ConflictError{Paths: mergeConflicts} + } + + acceptedID := newID("a") + message := proposal.Summary + if message == "" { + message = "Accept " + proposal.ID + } + message += fmt.Sprintf("\n\nHop-State: %s\nHop-Proposal: %s\nHop-Task: %s\nHop-Attempt: %s\n", acceptedID, proposal.ID, proposal.TaskID, proposal.AttemptID) + commit, err := s.Repo.CommitTree(ctx, finalTree, []string{current.GitCommit}, message) + if err != nil { + return AcceptResult{}, err + } + + var recordedCheck *Check + validationRef := "" + if len(checkCommand) > 0 { + checkID := newID("evidence") + validationRef = "validation/" + checkID + if err := s.Repo.UpdateHiddenRef(ctx, validationRef, commit); err != nil { + return AcceptResult{}, fmt.Errorf("pin final validation tree: %w", err) + } + integrationPath := filepath.Join(s.Root, ".hop", "integration", acceptedID) + if err := os.MkdirAll(filepath.Dir(integrationPath), 0o755); err != nil { + return AcceptResult{}, err + } + if _, err := s.Repo.AddDetachedWorktree(ctx, integrationPath, commit); err != nil { + return AcceptResult{}, err + } + result, runErr := runWorkspaceCommand(ctx, integrationPath, []string{ + "HOP_ROOT=" + s.Root, + "HOP_STATE_ID=" + acceptedID, + "HOP_TASK_ID=" + proposal.TaskID, + "HOP_ATTEMPT_ID=" + proposal.AttemptID, + "HOP_ACCEPTANCE_LOCK_HELD=1", + }, checkCommand) + removeErr := s.Repo.RemoveWorktree(ctx, integrationPath, true) + if runErr != nil { + return AcceptResult{}, runErr + } + if removeErr != nil { + return AcceptResult{}, removeErr + } + check := Check{ + ID: checkID, + AttemptID: proposal.AttemptID, + TreeHash: finalTree, + Command: append([]string(nil), checkCommand...), + ExitCode: result.ExitCode, + Output: result.Output, + CreatedAt: time.Now().UTC(), + } + if check.ExitCode != 0 { + if failed, recorded := s.recordValidationFailure(ctx, proposal, current, commit, finalTree, checkCommand); recorded { + check.StateID = failed.ID + _ = s.Repo.DeleteRef(ctx, "refs/hop/"+validationRef, commit) + validationRef = "" + } + } + check, err = s.Store.AddCheck(ctx, check) + if err != nil { + return AcceptResult{}, err + } + recordedCheck = &check + if check.ExitCode != 0 { + return AcceptResult{}, &CheckFailedError{Check: check} + } + } + + parents := canonicalizeParents([]Parent{ + {StateID: current.ID, Role: "canonical_parent", Order: 0}, + {StateID: proposal.ID, Role: "proposal_parent", Order: 1}, + }) + accepted := State{ + ID: acceptedID, + Kind: StateAccepted, + TaskID: proposal.TaskID, + AttemptID: proposal.AttemptID, + CanonicalAnchorID: current.ID, + SourceTree: finalTree, + GitCommit: commit, + Summary: proposal.Summary, + Agent: proposal.Agent, + CreatedAt: time.Now().UTC(), + Parents: parents, + } + accepted.Digest, err = digestState(accepted, parents) + if err != nil { + return AcceptResult{}, err + } + if err := s.pinState(ctx, accepted); err != nil { + return AcceptResult{}, err + } + accepted, err = s.Store.CASAccept(ctx, current.ID, accepted, parents) + if err != nil { + return AcceptResult{}, mapHeadError(err) + } + if validationRef != "" { + _ = s.Repo.DeleteRef(ctx, "refs/hop/"+validationRef, commit) + } + var warnings []string + if err := s.Repo.UpdateHiddenRef(ctx, acceptedRef, commit); err != nil { + warnings = append(warnings, "accepted state is durable in SQLite but refs/hop/accepted needs repair: "+err.Error()) + } + return AcceptResult{ + State: accepted, + ProposalPaths: proposalPaths, + CurrentPaths: currentPaths, + Check: recordedCheck, + Warnings: warnings, + }, nil +} + +func (s *Service) Undo(ctx context.Context) (State, error) { + release, err := acquireProjectLock(ctx, s.Root, "accept") + if err != nil { + return State{}, err + } + defer release() + current, err := s.Store.AcceptedHead(ctx) + if err != nil { + return State{}, err + } + canonicalParent, err := s.Store.ParentByRole(ctx, current.ID, "canonical_parent") + if err != nil { + if errors.Is(err, ErrNotFound) { + return State{}, fmt.Errorf("initial accepted state cannot be undone") + } + return State{}, err + } + previous, err := s.Store.GetState(ctx, canonicalParent.StateID) + if err != nil { + return State{}, err + } + undoID := newID("a") + commit, err := s.Repo.CommitTree(ctx, previous.SourceTree, []string{current.GitCommit}, fmt.Sprintf( + "Undo %s\n\nHop-State: %s\nHop-Reverts: %s\n", current.ID, undoID, current.ID)) + if err != nil { + return State{}, err + } + parents := canonicalizeParents([]Parent{ + {StateID: current.ID, Role: "canonical_parent", Order: 0}, + {StateID: current.ID, Role: "reverts", Order: 1}, + {StateID: previous.ID, Role: "restores", Order: 2}, + }) + undo := State{ + ID: undoID, + Kind: StateAccepted, + CanonicalAnchorID: current.ID, + SourceTree: previous.SourceTree, + GitCommit: commit, + Summary: "Undo " + current.ID, + Agent: "hop", + CreatedAt: time.Now().UTC(), + Parents: parents, + } + undo.Digest, err = digestState(undo, parents) + if err != nil { + return State{}, err + } + if err := s.pinState(ctx, undo); err != nil { + return State{}, err + } + undo, err = s.Store.CASAccept(ctx, current.ID, undo, parents) + if err != nil { + return State{}, mapHeadError(err) + } + if err := s.Repo.UpdateHiddenRef(ctx, acceptedRef, undo.GitCommit); err != nil { + return undo, &CommittedStateError{State: undo, Err: err} + } + return undo, nil +} + +// recordValidationFailure turns a failed final-tree check into an immutable +// descendant state when the proposal is still the attempt head. If a follow-up +// raced with validation, the caller retains the validation ref and tree-bound +// evidence without rewriting the newer attempt lineage. +func (s *Service) recordValidationFailure( + ctx context.Context, + proposal State, + current State, + commit string, + tree string, + command []string, +) (State, bool) { + attempt, err := s.Store.GetAttempt(ctx, proposal.AttemptID) + if err != nil || attempt.HeadStateID != proposal.ID { + return State{}, false + } + parents := canonicalizeParents([]Parent{ + {StateID: proposal.ID, Role: "run_parent", Order: 0}, + {StateID: current.ID, Role: "integration_parent", Order: 1}, + }) + failed := State{ + ID: newID("f"), + Kind: StateFailed, + TaskID: proposal.TaskID, + AttemptID: proposal.AttemptID, + CanonicalAnchorID: current.ID, + SourceTree: tree, + GitCommit: commit, + Summary: "Final validation failed: " + shellQuote(command), + Agent: "hop", + CreatedAt: time.Now().UTC(), + Parents: parents, + } + failed.Digest, err = digestState(failed, parents) + if err != nil { + return State{}, false + } + if err := s.pinState(ctx, failed); err != nil { + return State{}, false + } + failed, err = s.Store.AppendState(ctx, failed, parents, proposal.ID) + if err != nil { + return State{}, false + } + _ = s.Store.UpdateAttemptStatus(ctx, proposal.AttemptID, "changes_requested") + _ = s.Store.UpdateTaskStatus(ctx, proposal.TaskID, "changes_requested") + return failed, true +} + +func (s *Service) State(ctx context.Context, id string) (State, error) { + return s.Store.GetState(ctx, id) +} + +func (s *Service) EnvironmentForState(ctx context.Context, id string) (EnvironmentResult, error) { + state, err := s.Store.GetState(ctx, id) + if err != nil { + return EnvironmentResult{}, err + } + if state.AttemptID == "" { + return EnvironmentResult{}, fmt.Errorf("state %s does not belong to an agent attempt", id) + } + attempt, err := s.Store.GetAttempt(ctx, state.AttemptID) + if err != nil { + return EnvironmentResult{}, err + } + return EnvironmentResult{ + State: state, + Attempt: attempt, + Workspace: attempt.Workspace, + Variables: map[string]string{ + "HOP_ROOT": s.Root, + "HOP_STATE_ID": state.ID, + "HOP_TASK_ID": state.TaskID, + "HOP_ATTEMPT_ID": attempt.ID, + "HOP_WORKSPACE": attempt.Workspace, + }, + }, nil +} + +func (s *Service) Graph(ctx context.Context) ([]GraphRow, error) { + return s.Store.Graph(ctx, "") +} + +func (s *Service) History(ctx context.Context) ([]State, error) { + return s.Store.AcceptedHistory(ctx, 1000) +} + +func (s *Service) Status(ctx context.Context) (Status, error) { + return s.Store.Status(ctx) +} + +func (s *Service) Diff(ctx context.Context, stateID string) (string, error) { + state, err := s.Store.GetState(ctx, stateID) + if err != nil { + return "", err + } + var base State + if state.Kind == StateAccepted { + parent, parentErr := s.Store.ParentByRole(ctx, state.ID, "canonical_parent") + if errors.Is(parentErr, ErrNotFound) { + return s.Repo.Diff(ctx, "", state.GitCommit) + } + if parentErr != nil { + return "", parentErr + } + base, err = s.Store.GetState(ctx, parent.StateID) + } else if state.AttemptID != "" { + attempt, attemptErr := s.Store.GetAttempt(ctx, state.AttemptID) + if attemptErr != nil { + return "", attemptErr + } + base, err = s.Store.GetState(ctx, attempt.BaseStateID) + } else { + return s.Repo.Diff(ctx, "", state.GitCommit) + } + if err != nil { + return "", err + } + return s.Repo.Diff(ctx, base.GitCommit, state.GitCommit) +} + +func (s *Service) Doctor(ctx context.Context, repair bool) (DoctorReport, error) { + if repair { + if os.Getenv("HOP_ACCEPTANCE_LOCK_HELD") == "1" { + return DoctorReport{}, fmt.Errorf("doctor --repair cannot run inside a final-state validation command") + } + release, err := acquireProjectLock(ctx, s.Root, "accept") + if err != nil { + return DoctorReport{}, err + } + defer release() + } + head, err := s.Store.AcceptedHead(ctx) + if err != nil { + return DoctorReport{}, err + } + report := DoctorReport{OK: true, AcceptedState: head.ID, AcceptedCommit: head.GitCommit} + if head.Kind != StateAccepted { + report.OK = false + report.Problems = append(report.Problems, fmt.Sprintf("accepted head %s has kind %s", head.ID, head.Kind)) + } + if err := s.Repo.Verify(ctx); err != nil { + report.OK = false + report.Problems = append(report.Problems, "Git connectivity check failed: "+err.Error()) + } + rows, err := s.Store.Graph(ctx, "") + if err != nil { + return DoctorReport{}, err + } + for _, row := range rows { + state := row.State + if err := s.Repo.VerifyObjects(ctx, state.GitCommit, state.SourceTree); err != nil { + report.OK = false + report.Problems = append(report.Problems, fmt.Sprintf("state %s references missing Git data: %v", state.ID, err)) + continue + } + commitTree, treeErr := s.Repo.resolveTree(ctx, state.GitCommit) + if treeErr != nil || commitTree != state.SourceTree { + report.OK = false + if treeErr != nil { + report.Problems = append(report.Problems, fmt.Sprintf("state %s commit tree cannot be resolved: %v", state.ID, treeErr)) + } else { + report.Problems = append(report.Problems, fmt.Sprintf("state %s records tree %s but commit resolves to %s", state.ID, state.SourceTree, commitTree)) + } + } + expectedDigest, digestErr := digestState(state, row.Parents) + if digestErr != nil || expectedDigest != state.Digest { + report.OK = false + if digestErr != nil { + report.Problems = append(report.Problems, fmt.Sprintf("state %s digest cannot be recomputed: %v", state.ID, digestErr)) + } else { + report.Problems = append(report.Problems, fmt.Sprintf("state %s digest mismatch", state.ID)) + } + } + if problem := stateParentProblem(state, row.Parents); problem != "" { + report.OK = false + report.Problems = append(report.Problems, problem) + } + refName := "states/" + state.ID + refCommit, exists, readErr := s.Repo.ReadHiddenRef(ctx, refName) + if readErr != nil { + return DoctorReport{}, readErr + } + if !exists || refCommit != state.GitCommit { + report.OK = false + report.Problems = append(report.Problems, fmt.Sprintf("%s does not pin state %s", refName, state.ID)) + if repair { + if err := s.Repo.UpdateHiddenRef(ctx, refName, state.GitCommit); err != nil { + return DoctorReport{}, err + } + report.Repaired = true + } + } + } + refCommit, exists, err := s.Repo.ReadHiddenRef(ctx, acceptedRef) + if err != nil { + return DoctorReport{}, err + } + if exists { + report.RefCommit = refCommit + } + if !exists || refCommit != head.GitCommit { + report.OK = false + report.Problems = append(report.Problems, "refs/hop/accepted does not match the SQLite accepted head") + if repair { + if err := s.Repo.UpdateHiddenRef(ctx, acceptedRef, head.GitCommit); err != nil { + return DoctorReport{}, err + } + report.RefCommit = head.GitCommit + report.Repaired = true + } + } + if repair && report.Repaired { + verified, verifyErr := s.Doctor(ctx, false) + if verifyErr != nil { + return DoctorReport{}, verifyErr + } + verified.Repaired = true + return verified, nil + } + return report, nil +} + +func stateParentProblem(state State, parents []Parent) string { + hasRole := func(role string) bool { + for _, parent := range parents { + if parent.Role == role { + return true + } + } + return false + } + switch state.Kind { + case StatePrompt: + if state.TaskID == "" || state.AttemptID == "" || !hasRole("run_parent") || !hasRole("canonical_anchor") { + return fmt.Sprintf("prompt state %s is missing task, attempt, run-parent, or canonical-anchor provenance", state.ID) + } + case StateCheckpoint, StateProposal, StateFailed, StateCancelled: + if state.TaskID == "" || state.AttemptID == "" || !hasRole("run_parent") { + return fmt.Sprintf("%s state %s is missing task, attempt, or run-parent provenance", state.Kind, state.ID) + } + case StateAccepted: + isInitial := state.CanonicalAnchorID == "" && len(parents) == 0 + if !isInitial && !hasRole("canonical_parent") { + return fmt.Sprintf("accepted state %s is missing its canonical parent", state.ID) + } + } + return "" +} + +func (s *Service) pinState(ctx context.Context, state State) error { + return s.Repo.UpdateHiddenRef(ctx, "states/"+state.ID, state.GitCommit) +} + +// reconcileDerivedRefs repairs the small crash window between SQLite commits +// and derived Git-ref updates. SQLite is authoritative, but every referenced +// commit is pinned before normal operation resumes so Git GC cannot discard it. +func (s *Service) reconcileDerivedRefs(ctx context.Context, reconcileAccepted bool) error { + rows, err := s.Store.Graph(ctx, "") + if err != nil { + return err + } + for _, row := range rows { + state := row.State + if err := s.Repo.VerifyObjects(ctx, state.GitCommit, state.SourceTree); err != nil { + return fmt.Errorf("state %s references unavailable Git data: %w", state.ID, err) + } + name := "states/" + state.ID + commit, exists, err := s.Repo.ReadHiddenRef(ctx, name) + if err != nil { + return err + } + if !exists || commit != state.GitCommit { + if err := s.Repo.UpdateHiddenRef(ctx, name, state.GitCommit); err != nil { + return fmt.Errorf("repair Git pin for state %s: %w", state.ID, err) + } + } + } + if !reconcileAccepted { + return nil + } + release, err := acquireProjectLock(ctx, s.Root, "accept") + if err != nil { + return err + } + defer release() + head, err := s.Store.AcceptedHead(ctx) + if err != nil { + return err + } + commit, exists, err := s.Repo.ReadHiddenRef(ctx, acceptedRef) + if err != nil { + return err + } + if !exists || commit != head.GitCommit { + if err := s.Repo.UpdateHiddenRef(ctx, acceptedRef, head.GitCommit); err != nil { + return fmt.Errorf("repair accepted Git ref: %w", err) + } + } + return nil +} + +func (s *Service) deliveryEnvironment(state State, attempt Attempt) []string { + return []string{ + "HOP_ROOT=" + s.Root, + "HOP_STATE_ID=" + state.ID, + "HOP_TASK_ID=" + state.TaskID, + "HOP_ATTEMPT_ID=" + attempt.ID, + "HOP_WORKSPACE=" + attempt.Workspace, + } +} + +func promptTitle(prompt string) string { + prompt = strings.TrimSpace(prompt) + if newline := strings.IndexByte(prompt, '\n'); newline >= 0 { + prompt = prompt[:newline] + } + if len(prompt) > 100 { + prompt = prompt[:97] + "..." + } + return prompt +} + +func intersectPaths(left, right []string) []string { + rightSet := make(map[string]struct{}, len(right)) + for _, path := range right { + rightSet[path] = struct{}{} + } + var intersection []string + for _, path := range left { + if _, ok := rightSet[path]; ok { + intersection = append(intersection, path) + } + } + sort.Strings(intersection) + return intersection +} + +func mapHeadError(err error) error { + var changed *HeadChangedError + if errors.As(err, &changed) { + return &StaleHeadError{Expected: changed.Expected, Actual: changed.Actual} + } + return err +} diff --git a/internal/hop/service_smoke_test.go b/internal/hop/service_smoke_test.go new file mode 100644 index 0000000..433ebeb --- /dev/null +++ b/internal/hop/service_smoke_test.go @@ -0,0 +1,578 @@ +package hop + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func TestInitPreservesGitBranchIndexAndWorkingTree(t *testing.T) { + ctx := context.Background() + root := t.TempDir() + runGitTest(t, root, "init", "--quiet") + writeTestFile(t, filepath.Join(root, "tracked.txt"), "committed\n") + runGitTest(t, root, "add", "tracked.txt") + runGitTest(t, root, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--quiet", "-m", "initial") + + writeTestFile(t, filepath.Join(root, "tracked.txt"), "staged\n") + runGitTest(t, root, "add", "tracked.txt") + writeTestFile(t, filepath.Join(root, "tracked.txt"), "working\n") + writeTestFile(t, filepath.Join(root, "untracked.txt"), "untracked\n") + + beforeHead := runGitTest(t, root, "rev-parse", "HEAD") + beforeBranch := runGitTest(t, root, "symbolic-ref", "--short", "HEAD") + beforeIndex := runGitTest(t, root, "diff", "--cached", "--binary") + beforeStatus := runGitTest(t, root, "status", "--porcelain=v1") + + service, initial, err := InitProject(ctx, root) + if err != nil { + t.Fatal(err) + } + if got := runGitTest(t, root, "rev-parse", "HEAD"); got != beforeHead { + t.Fatalf("HEAD moved from %s to %s", beforeHead, got) + } + if got := runGitTest(t, root, "symbolic-ref", "--short", "HEAD"); got != beforeBranch { + t.Fatalf("branch changed from %s to %s", beforeBranch, got) + } + if got := runGitTest(t, root, "diff", "--cached", "--binary"); got != beforeIndex { + t.Fatal("Hop init changed the user's index") + } + if got := runGitTest(t, root, "status", "--porcelain=v1"); got != beforeStatus { + t.Fatalf("working status changed:\nwant %q\n got %q", beforeStatus, got) + } + assertTreeFiles(t, service, initial.GitCommit, map[string]string{ + "tracked.txt": "working\n", + "untracked.txt": "untracked\n", + }) + if err := service.Close(); err != nil { + t.Fatal(err) + } + + service, again, err := InitProject(ctx, root) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = service.Close() }) + if again.ID != initial.ID { + t.Fatalf("idempotent init created %s, want existing %s", again.ID, initial.ID) + } +} + +func TestInitRefusesAUserTrackedHopDirectory(t *testing.T) { + root := t.TempDir() + runGitTest(t, root, "init", "--quiet") + writeTestFile(t, filepath.Join(root, ".hop", "user-owned.txt"), "do not overwrite\n") + runGitTest(t, root, "add", "-f", ".hop/user-owned.txt") + _, _, err := InitProject(context.Background(), root) + if err == nil || !strings.Contains(err.Error(), ".hop is already tracked") { + t.Fatalf("InitProject error = %v, want tracked .hop refusal", err) + } + contents, readErr := os.ReadFile(filepath.Join(root, ".hop", "user-owned.txt")) + if readErr != nil || string(contents) != "do not overwrite\n" { + t.Fatalf("tracked .hop content changed: %q, %v", string(contents), readErr) + } +} + +func TestCLIJSONWorkflow(t *testing.T) { + root := t.TempDir() + writeTestFile(t, filepath.Join(root, "base.txt"), "base\n") + previousDirectory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(root); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(previousDirectory) }) + + runCLIJSONTest(t, []string{"init", "--json"}) + started := runCLIJSONTest(t, []string{"start", "--agent", "fake", "--json", "Add CLI file"}) + data := objectField(t, started, "data") + prompt := objectField(t, data, "prompt") + promptID := stringField(t, prompt, "id") + workspace := stringField(t, data, "workspace") + if promptID == "" || workspace == "" { + t.Fatal("start JSON omitted prompt ID or workspace") + } + writeTestFile(t, filepath.Join(workspace, "cli.txt"), "cli\n") + + proposed := runCLIJSONTest(t, []string{"propose", "--summary", "CLI change", "--json", promptID}) + proposal := objectField(t, objectField(t, proposed, "data"), "proposal") + proposalID := stringField(t, proposal, "id") + accepted := runCLIJSONTest(t, []string{"land", proposalID, "--json", "--", "sh", "-c", "test -f cli.txt"}) + acceptedState := objectField(t, objectField(t, accepted, "data"), "state") + if kind := stringField(t, acceptedState, "kind"); kind != string(StateAccepted) { + t.Fatalf("landed kind = %q, want %q", kind, StateAccepted) + } + status := runCLIJSONTest(t, []string{"status", "--json"}) + statusData := objectField(t, status, "data") + head := objectField(t, statusData, "accepted_head") + if stringField(t, head, "id") != stringField(t, acceptedState, "id") { + t.Fatal("status accepted head does not match landed state") + } +} + +func TestDoctorRejectsCommitTreeAndDigestMismatch(t *testing.T) { + ctx := context.Background() + service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"}) + emptyTree, err := service.Repo.EmptyTree(ctx) + if err != nil { + t.Fatal(err) + } + if _, err := service.Store.db.ExecContext(ctx, + `UPDATE states SET source_tree = ?, digest = 'tampered' WHERE id = ?`, emptyTree, initial.ID); err != nil { + t.Fatal(err) + } + report, err := service.Doctor(ctx, false) + if err != nil { + t.Fatal(err) + } + if report.OK { + t.Fatal("doctor approved a state whose commit tree and digest were tampered") + } + joined := strings.Join(report.Problems, "\n") + if !strings.Contains(joined, "records tree") || !strings.Contains(joined, "digest mismatch") { + t.Fatalf("doctor problems did not explain both mismatches: %s", joined) + } +} + +func TestOpenProjectInsideFinalValidationDoesNotReacquireAcceptanceLock(t *testing.T) { + ctx := context.Background() + service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"}) + release, err := acquireProjectLock(ctx, service.Root, "accept") + if err != nil { + t.Fatal(err) + } + defer release() + previous, hadPrevious := os.LookupEnv("HOP_ACCEPTANCE_LOCK_HELD") + if err := os.Setenv("HOP_ACCEPTANCE_LOCK_HELD", "1"); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if hadPrevious { + _ = os.Setenv("HOP_ACCEPTANCE_LOCK_HELD", previous) + } else { + _ = os.Unsetenv("HOP_ACCEPTANCE_LOCK_HELD") + } + }) + opened := make(chan error, 1) + go func() { + nested, openErr := OpenProject(service.Root) + if openErr == nil { + openErr = nested.Close() + } + opened <- openErr + }() + select { + case openErr := <-opened: + if openErr != nil { + t.Fatal(openErr) + } + case <-time.After(2 * time.Second): + t.Fatal("OpenProject deadlocked by reacquiring the acceptance lock") + } +} + +func TestPromptFollowupAndProposalAreImmutable(t *testing.T) { + ctx := context.Background() + service, initial := newTestProject(t, map[string]string{"app.txt": "initial\n"}) + + exactPrompt := " Change the app\nwithout normalizing this text. " + started, err := service.CreatePrompt(ctx, exactPrompt, "", "test-agent") + if err != nil { + t.Fatal(err) + } + if started.Prompt.SourceTree != initial.SourceTree { + t.Fatalf("prompt tree = %s, want accepted tree %s", started.Prompt.SourceTree, initial.SourceTree) + } + if started.Prompt.Prompt != exactPrompt { + t.Fatalf("stored prompt = %q, want exact %q", started.Prompt.Prompt, exactPrompt) + } + writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "first change\n") + + followup, err := service.CreatePrompt(ctx, "Use the other approach", started.Prompt.ID, "") + if err != nil { + t.Fatal(err) + } + if followup.Checkpoint == nil { + t.Fatal("follow-up did not create a checkpoint") + } + if followup.Prompt.SourceTree != followup.Checkpoint.SourceTree { + t.Fatal("follow-up prompt did not preserve the checkpoint tree") + } + if followup.Prompt.SourceTree == initial.SourceTree { + t.Fatal("checkpoint failed to capture workspace edits") + } + parent, err := service.Store.ParentByRole(ctx, followup.Prompt.ID, "run_parent") + if err != nil { + t.Fatal(err) + } + if parent.StateID != followup.Checkpoint.ID { + t.Fatalf("follow-up parent = %s, want checkpoint %s", parent.StateID, followup.Checkpoint.ID) + } + + writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "proposed\n") + proposed, err := service.Propose(ctx, followup.Prompt.ID, "Changed the app") + if err != nil { + t.Fatal(err) + } + frozenTree := proposed.Proposal.SourceTree + writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "changed after proposal\n") + stored, err := service.State(ctx, proposed.Proposal.ID) + if err != nil { + t.Fatal(err) + } + if stored.SourceTree != frozenTree { + t.Fatal("proposal tree changed after later workspace edits") + } +} + +func TestCheckRunsAgainstTheRecordedCheckpointTree(t *testing.T) { + ctx := context.Background() + service, _ := newTestProject(t, map[string]string{"app.txt": "initial\n"}) + started, err := service.CreatePrompt(ctx, "Check exact state", "", "test") + if err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "checkpointed\n") + signal := filepath.Join(t.TempDir(), "check-started") + type outcome struct { + check Check + err error + } + result := make(chan outcome, 1) + go func() { + check, checkErr := service.RunCheck(ctx, started.Prompt.ID, []string{ + "sh", "-c", `touch "$1"; sleep 0.2; cat app.txt`, "hop-check", signal, + }) + result <- outcome{check: check, err: checkErr} + }() + deadline := time.Now().Add(5 * time.Second) + for { + if _, err := os.Stat(signal); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("validation command did not start") + } + time.Sleep(10 * time.Millisecond) + } + writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "raced edit\n") + finished := <-result + if finished.err != nil { + t.Fatal(finished.err) + } + if finished.check.Output != "checkpointed\n" { + t.Fatalf("check observed %q, want checkpointed tree", finished.check.Output) + } +} + +func TestDisjointProposalsLandAndUndoMovesForward(t *testing.T) { + ctx := context.Background() + service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"}) + + first, err := service.CreatePrompt(ctx, "Add one", "", "agent-one") + if err != nil { + t.Fatal(err) + } + second, err := service.CreatePrompt(ctx, "Add two", "", "agent-two") + if err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(first.Workspace, "one.txt"), "one\n") + writeTestFile(t, filepath.Join(second.Workspace, "two.txt"), "two\n") + + proposalOne, err := service.Propose(ctx, first.Prompt.ID, "Add one") + if err != nil { + t.Fatal(err) + } + proposalTwo, err := service.Propose(ctx, second.Prompt.ID, "Add two") + if err != nil { + t.Fatal(err) + } + acceptedOne, err := service.Accept(ctx, proposalOne.Proposal.ID, nil) + if err != nil { + t.Fatal(err) + } + acceptedTwo, err := service.Accept(ctx, proposalTwo.Proposal.ID, nil) + if err != nil { + t.Fatal(err) + } + if acceptedTwo.State.SourceTree == acceptedOne.State.SourceTree { + t.Fatal("second disjoint acceptance did not change the accepted tree") + } + assertTreeFiles(t, service, acceptedTwo.State.GitCommit, map[string]string{ + "base.txt": "base\n", + "one.txt": "one\n", + "two.txt": "two\n", + }) + + undo, err := service.Undo(ctx) + if err != nil { + t.Fatal(err) + } + if undo.ID == acceptedOne.State.ID || undo.ID == acceptedTwo.State.ID { + t.Fatal("undo rewrote an old state instead of creating a new one") + } + if undo.SourceTree != acceptedOne.State.SourceTree { + t.Fatalf("undo tree = %s, want previous accepted tree %s", undo.SourceTree, acceptedOne.State.SourceTree) + } + if undo.SourceTree == initial.SourceTree { + t.Fatal("undo erased more than the latest accepted transition") + } + assertTreeFiles(t, service, undo.GitCommit, map[string]string{ + "base.txt": "base\n", + "one.txt": "one\n", + }) + assertTreeMissing(t, service, undo.GitCommit, "two.txt") + report, err := service.Doctor(ctx, false) + if err != nil { + t.Fatal(err) + } + if !report.OK { + t.Fatalf("doctor reported problems: %#v", report.Problems) + } +} + +func TestConcurrentDisjointAcceptancesSerialize(t *testing.T) { + ctx := context.Background() + service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"}) + + first, err := service.CreatePrompt(ctx, "Add alpha", "", "alpha") + if err != nil { + t.Fatal(err) + } + second, err := service.CreatePrompt(ctx, "Add beta", "", "beta") + if err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(first.Workspace, "alpha.txt"), "alpha\n") + writeTestFile(t, filepath.Join(second.Workspace, "beta.txt"), "beta\n") + alpha, err := service.Propose(ctx, first.Prompt.ID, "Alpha") + if err != nil { + t.Fatal(err) + } + beta, err := service.Propose(ctx, second.Prompt.ID, "Beta") + if err != nil { + t.Fatal(err) + } + + proposalIDs := []string{alpha.Proposal.ID, beta.Proposal.ID} + errs := make(chan error, len(proposalIDs)) + var wg sync.WaitGroup + for _, id := range proposalIDs { + wg.Add(1) + go func() { + defer wg.Done() + _, acceptErr := service.Accept(ctx, id, nil) + errs <- acceptErr + }() + } + wg.Wait() + close(errs) + for acceptErr := range errs { + if acceptErr != nil { + t.Fatalf("concurrent acceptance: %v", acceptErr) + } + } + head, err := service.Store.AcceptedHead(ctx) + if err != nil { + t.Fatal(err) + } + assertTreeFiles(t, service, head.GitCommit, map[string]string{ + "base.txt": "base\n", + "alpha.txt": "alpha\n", + "beta.txt": "beta\n", + }) + history, err := service.History(ctx) + if err != nil { + t.Fatal(err) + } + if len(history) != 3 { + t.Fatalf("canonical history has %d states, want initial plus two acceptances", len(history)) + } +} + +func TestOverlappingProposalAndFailedFinalCheckDoNotMoveHead(t *testing.T) { + ctx := context.Background() + service, _ := newTestProject(t, map[string]string{"shared.txt": "base\n"}) + + first, err := service.CreatePrompt(ctx, "First edit", "", "one") + if err != nil { + t.Fatal(err) + } + second, err := service.CreatePrompt(ctx, "Second edit", "", "two") + if err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(first.Workspace, "shared.txt"), "first\n") + writeTestFile(t, filepath.Join(second.Workspace, "shared.txt"), "second\n") + firstProposal, err := service.Propose(ctx, first.Prompt.ID, "First") + if err != nil { + t.Fatal(err) + } + secondProposal, err := service.Propose(ctx, second.Prompt.ID, "Second") + if err != nil { + t.Fatal(err) + } + firstAccepted, err := service.Accept(ctx, firstProposal.Proposal.ID, nil) + if err != nil { + t.Fatal(err) + } + _, err = service.Accept(ctx, secondProposal.Proposal.ID, nil) + var conflict *ConflictError + if !errors.As(err, &conflict) { + t.Fatalf("second acceptance error = %v, want ConflictError", err) + } + if len(conflict.Paths) != 1 || conflict.Paths[0] != "shared.txt" { + t.Fatalf("conflict paths = %#v", conflict.Paths) + } + head, err := service.Store.AcceptedHead(ctx) + if err != nil { + t.Fatal(err) + } + if head.ID != firstAccepted.State.ID { + t.Fatalf("blocked proposal moved accepted head to %s", head.ID) + } + + third, err := service.CreatePrompt(ctx, "Add safe file", "", "three") + if err != nil { + t.Fatal(err) + } + writeTestFile(t, filepath.Join(third.Workspace, "safe.txt"), "safe\n") + thirdProposal, err := service.Propose(ctx, third.Prompt.ID, "Safe") + if err != nil { + t.Fatal(err) + } + _, err = service.Accept(ctx, thirdProposal.Proposal.ID, []string{"sh", "-c", "exit 9"}) + var checkFailed *CheckFailedError + if !errors.As(err, &checkFailed) { + t.Fatalf("failed validation error = %v, want CheckFailedError", err) + } + if checkFailed.Check.StateID == "" { + t.Fatal("failed final-tree validation was not attached to a durable state") + } + failedState, err := service.Store.GetState(ctx, checkFailed.Check.StateID) + if err != nil { + t.Fatal(err) + } + if failedState.Kind != StateFailed || failedState.SourceTree != checkFailed.Check.TreeHash { + t.Fatalf("failed state = %#v, check tree = %s", failedState, checkFailed.Check.TreeHash) + } + head, err = service.Store.AcceptedHead(ctx) + if err != nil { + t.Fatal(err) + } + if head.ID != firstAccepted.State.ID { + t.Fatal("failed final-tree validation moved accepted head") + } +} + +func newTestProject(t *testing.T, files map[string]string) (*Service, State) { + t.Helper() + root := t.TempDir() + for path, contents := range files { + writeTestFile(t, filepath.Join(root, path), contents) + } + service, initial, err := InitProject(context.Background(), root) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = service.Close() }) + return service, initial +} + +func writeTestFile(t *testing.T, path, contents string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { + t.Fatal(err) + } +} + +func assertTreeFiles(t *testing.T, service *Service, commit string, expected map[string]string) { + t.Helper() + path := filepath.Join(t.TempDir(), "materialized") + if _, err := service.Repo.AddDetachedWorktree(context.Background(), path, commit); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = service.Repo.RemoveWorktree(context.Background(), path, true) }) + for name, want := range expected { + contents, err := os.ReadFile(filepath.Join(path, name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + if string(contents) != want { + t.Fatalf("%s = %q, want %q", name, string(contents), want) + } + } +} + +func assertTreeMissing(t *testing.T, service *Service, commit string, name string) { + t.Helper() + path := filepath.Join(t.TempDir(), "materialized") + if _, err := service.Repo.AddDetachedWorktree(context.Background(), path, commit); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = service.Repo.RemoveWorktree(context.Background(), path, true) }) + if _, err := os.Stat(filepath.Join(path, name)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected %s to be absent, stat error = %v", name, err) + } +} + +func runGitTest(t *testing.T, root string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = root + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, stderr.String()) + } + return strings.TrimSpace(stdout.String()) +} + +func runCLIJSONTest(t *testing.T, args []string) map[string]any { + t.Helper() + var stdout, stderr bytes.Buffer + if code := RunCLI(args, &stdout, &stderr); code != 0 { + t.Fatalf("hop %s exited %d\nstdout: %s\nstderr: %s", strings.Join(args, " "), code, stdout.String(), stderr.String()) + } + var result map[string]any + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("decode JSON from hop %s: %v\n%s", strings.Join(args, " "), err, stdout.String()) + } + if ok, _ := result["ok"].(bool); !ok { + t.Fatalf("hop %s returned non-ok JSON: %#v", strings.Join(args, " "), result) + } + return result +} + +func objectField(t *testing.T, object map[string]any, key string) map[string]any { + t.Helper() + value, ok := object[key].(map[string]any) + if !ok { + t.Fatalf("field %q is not an object: %#v", key, object[key]) + } + return value +} + +func stringField(t *testing.T, object map[string]any, key string) string { + t.Helper() + value, ok := object[key].(string) + if !ok { + t.Fatalf("field %q is not a string: %#v", key, object[key]) + } + return value +} diff --git a/internal/hop/skill.go b/internal/hop/skill.go new file mode 100644 index 0000000..cce0489 --- /dev/null +++ b/internal/hop/skill.go @@ -0,0 +1,102 @@ +package hop + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + hopskill "github.com/hop-vcs/hop/skills/hop" +) + +type SkillInstallResult struct { + Path string `json:"path"` + Files []string `json:"files"` +} + +func DefaultSkillBase() (string, error) { + home := os.Getenv("CODEX_HOME") + if home == "" { + userHome, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("find home directory: %w", err) + } + home = filepath.Join(userHome, ".codex") + } + return filepath.Join(home, "skills"), nil +} + +func EmbeddedSkillText() (string, error) { + contents, err := hopskill.Files.ReadFile("SKILL.md") + if err != nil { + return "", fmt.Errorf("read embedded Hop skill: %w", err) + } + return string(contents), nil +} + +// InstallSkill writes the embedded skill below a skills directory. Existing +// bundles require force; known files are overwritten without deleting unknown +// user files from the destination. +func InstallSkill(base string, force bool) (SkillInstallResult, error) { + if strings.TrimSpace(base) == "" { + var err error + base, err = DefaultSkillBase() + if err != nil { + return SkillInstallResult{}, err + } + } + absBase, err := filepath.Abs(base) + if err != nil { + return SkillInstallResult{}, fmt.Errorf("resolve skills directory: %w", err) + } + target := filepath.Join(absBase, "hop") + if info, err := os.Lstat(target); err == nil { + if info.Mode()&os.ModeSymlink != 0 { + return SkillInstallResult{}, fmt.Errorf("refusing to install through symlink %s", target) + } + if !info.IsDir() { + return SkillInstallResult{}, fmt.Errorf("skill target exists and is not a directory: %s", target) + } + if !force { + return SkillInstallResult{}, fmt.Errorf("Hop skill already exists at %s; pass --force to update it", target) + } + } else if !os.IsNotExist(err) { + return SkillInstallResult{}, fmt.Errorf("inspect skill target: %w", err) + } + if err := os.MkdirAll(target, 0o755); err != nil { + return SkillInstallResult{}, fmt.Errorf("create skill target: %w", err) + } + + result := SkillInstallResult{Path: target} + err = fs.WalkDir(hopskill.Files, ".", func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if path == "." { + return nil + } + destination := filepath.Join(target, filepath.FromSlash(path)) + if entry.IsDir() { + return os.MkdirAll(destination, 0o755) + } + if info, statErr := os.Lstat(destination); statErr == nil && info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("refusing to overwrite symlink %s", destination) + } else if statErr != nil && !os.IsNotExist(statErr) { + return statErr + } + contents, readErr := hopskill.Files.ReadFile(path) + if readErr != nil { + return readErr + } + if writeErr := os.WriteFile(destination, contents, 0o644); writeErr != nil { + return writeErr + } + result.Files = append(result.Files, path) + return nil + }) + if err != nil { + return SkillInstallResult{}, fmt.Errorf("install Hop skill: %w", err) + } + return result, nil +} diff --git a/internal/hop/skill_test.go b/internal/hop/skill_test.go new file mode 100644 index 0000000..bc38edd --- /dev/null +++ b/internal/hop/skill_test.go @@ -0,0 +1,63 @@ +package hop + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestInstallSkillBundle(t *testing.T) { + base := t.TempDir() + result, err := InstallSkill(base, false) + if err != nil { + t.Fatal(err) + } + if result.Path != filepath.Join(base, "hop") { + t.Fatalf("skill path = %s", result.Path) + } + wantFiles := []string{"SKILL.md", "agents/openai.yaml", "references/protocol.md"} + for _, relative := range wantFiles { + contents, err := os.ReadFile(filepath.Join(result.Path, relative)) + if err != nil { + t.Fatalf("read installed %s: %v", relative, err) + } + if len(contents) == 0 { + t.Fatalf("installed %s is empty", relative) + } + } + if _, err := InstallSkill(base, false); err == nil || !strings.Contains(err.Error(), "already exists") { + t.Fatalf("second install error = %v, want existing-skill error", err) + } + if err := os.WriteFile(filepath.Join(result.Path, "SKILL.md"), []byte("tampered"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := InstallSkill(base, true); err != nil { + t.Fatal(err) + } + contents, err := os.ReadFile(filepath.Join(result.Path, "SKILL.md")) + if err != nil { + t.Fatal(err) + } + if string(contents) == "tampered" || !strings.Contains(string(contents), "name: hop") { + t.Fatal("forced skill update did not restore the embedded bundle") + } +} + +func TestSkillCLIWorksOutsideHopProject(t *testing.T) { + var stdout, stderr strings.Builder + base := t.TempDir() + code := RunCLI([]string{"skill", "install", "--path", base, "--json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("skill install exited %d: %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), filepath.Join(base, "hop")) { + t.Fatalf("skill install JSON omitted target: %s", stdout.String()) + } + stdout.Reset() + stderr.Reset() + code = RunCLI([]string{"skill", "print"}, &stdout, &stderr) + if code != 0 || !strings.Contains(stdout.String(), "Require a durable Hop prompt state") { + t.Fatalf("skill print exited %d\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String()) + } +} diff --git a/internal/hop/util.go b/internal/hop/util.go new file mode 100644 index 0000000..5fc1eac --- /dev/null +++ b/internal/hop/util.go @@ -0,0 +1,177 @@ +package hop + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +const maxRecordedOutput = 128 * 1024 + +func FindHopRoot(start string) (string, error) { + if configured := os.Getenv("HOP_ROOT"); configured != "" { + return requireHopRoot(configured) + } + + abs, err := filepath.Abs(start) + if err != nil { + return "", err + } + info, err := os.Stat(abs) + if err != nil { + return "", err + } + if !info.IsDir() { + abs = filepath.Dir(abs) + } + + for dir := abs; ; dir = filepath.Dir(dir) { + if _, err := os.Stat(filepath.Join(dir, ".hop", "hop.db")); err == nil { + return dir, nil + } else if !errors.Is(err, fs.ErrNotExist) { + return "", err + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + } + return "", fmt.Errorf("not inside a Hop project; run 'hop init' first") +} + +func requireHopRoot(root string) (string, error) { + abs, err := filepath.Abs(root) + if err != nil { + return "", err + } + if _, err := os.Stat(filepath.Join(abs, ".hop", "hop.db")); err != nil { + return "", fmt.Errorf("HOP_ROOT does not contain .hop/hop.db: %s", abs) + } + return abs, nil +} + +func digestState(state State, parents []Parent) (string, error) { + copyState := state + copyState.Digest = "" + copyState.Parents = append([]Parent(nil), parents...) + sort.Slice(copyState.Parents, func(i, j int) bool { + if copyState.Parents[i].Order != copyState.Parents[j].Order { + return copyState.Parents[i].Order < copyState.Parents[j].Order + } + if copyState.Parents[i].Role != copyState.Parents[j].Role { + return copyState.Parents[i].Role < copyState.Parents[j].Role + } + return copyState.Parents[i].StateID < copyState.Parents[j].StateID + }) + payload, err := json.Marshal(copyState) + if err != nil { + return "", err + } + sum := sha256.Sum256(payload) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +type commandResult struct { + ExitCode int + Output string +} + +func runWorkspaceCommand(ctx context.Context, workspace string, env []string, argv []string) (commandResult, error) { + if len(argv) == 0 { + return commandResult{}, fmt.Errorf("no command provided") + } + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + cmd.Dir = workspace + cmd.Env = append(os.Environ(), env...) + var output bytes.Buffer + cmd.Stdout = &output + cmd.Stderr = &output + err := cmd.Run() + result := commandResult{Output: truncateRecordedOutput(output.String())} + if err == nil { + return result, nil + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + result.ExitCode = exitErr.ExitCode() + return result, nil + } + return result, err +} + +func truncateRecordedOutput(output string) string { + if len(output) <= maxRecordedOutput { + return output + } + const marker = "\n… output truncated by Hop …\n" + keep := maxRecordedOutput - len(marker) + return output[:keep/2] + marker + output[len(output)-(keep-keep/2):] +} + +func acquireProjectLock(ctx context.Context, root, name string) (func(), error) { + path := filepath.Join(root, ".hop", name+".lock") + for { + file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err == nil { + _, _ = file.WriteString(fmt.Sprintf("pid=%d\n", os.Getpid())) + _ = file.Close() + stopHeartbeat := make(chan struct{}) + go func() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + now := time.Now() + _ = os.Chtimes(path, now, now) + case <-stopHeartbeat: + return + } + } + }() + var once sync.Once + return func() { + once.Do(func() { + close(stopHeartbeat) + _ = os.Remove(path) + }) + }, nil + } + if !errors.Is(err, fs.ErrExist) { + return nil, err + } + if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > 5*time.Minute { + _ = os.Remove(path) + continue + } + select { + case <-ctx.Done(): + return nil, fmt.Errorf("wait for Hop %s lock: %w", name, ctx.Err()) + case <-time.After(50 * time.Millisecond): + } + } +} + +func shellQuote(argv []string) string { + quoted := make([]string, len(argv)) + for i, arg := range argv { + if arg != "" && !strings.ContainsAny(arg, " \t\n\"'\\$`;&|<>()[]{}*?!") { + quoted[i] = arg + continue + } + quoted[i] = "'" + strings.ReplaceAll(arg, "'", "'\"'\"'") + "'" + } + return strings.Join(quoted, " ") +} diff --git a/skills/hop/SKILL.md b/skills/hop/SKILL.md new file mode 100644 index 0000000..d17906b --- /dev/null +++ b/skills/hop/SKILL.md @@ -0,0 +1,106 @@ +--- +name: hop +description: Work safely in Hop prompt-native version-control projects. Use whenever HOP_STATE_ID, HOP_TASK_ID, or HOP_ATTEMPT_ID is set; when a repository contains .hop/hop.db; or when the user asks to use Hop to isolate, checkpoint, validate, propose, land, inspect, continue, or undo coding-agent work instead of directly managing Git branches or commits. +--- + +# Hop + +Use Hop as the change-control boundary between agent work and accepted project state. + +## Enforce the boundary + +- Require a durable Hop prompt state before making repository changes. +- Work only in the assigned `HOP_WORKSPACE`; never edit the canonical project root. +- Do not run `git commit`, `git checkout`, `git switch`, `git branch`, `git rebase`, `git reset`, `git stash`, or `git worktree`. Hop owns snapshots and worktrees. +- Do not stage files. Hop captures every nonignored workspace change. +- Do not land your own proposal unless the user explicitly requests it. Default to stopping after proposal creation. +- Never resolve an overlap by silently merging. Preserve the proposal and request or create a reconciliation prompt. + +## Verify the launch context + +Before planning or editing: + +```bash +command -v hop +test -n "$HOP_ROOT" +test -n "$HOP_STATE_ID" +test -n "$HOP_TASK_ID" +test -n "$HOP_ATTEMPT_ID" +test -n "$HOP_WORKSPACE" +hop state "$HOP_STATE_ID" --json +hop status --json +``` + +Confirm the current working directory is `HOP_WORKSPACE` or direct every filesystem operation there. + +If the Hop variables are missing, stop before editing. Explain that the controller must first run: + +```bash +hop start --agent "" +``` + +Then relaunch or redirect the agent into the printed workspace with the printed environment. A skill loaded after prompt delivery cannot retroactively guarantee pre-delivery recording. + +## Execute the task + +1. Read the prompt state and current Hop status. +2. Inspect and modify only the assigned workspace. +3. Keep the change scoped to the recorded instruction. +4. Run relevant validation through Hop so evidence is bound to an immutable checkpoint: + +```bash +hop check "$HOP_STATE_ID" -- [args...] +``` + +5. Fix failures in the workspace and rerun the check as needed. +6. Freeze the result as a proposal: + +```bash +hop propose --summary "" "$HOP_STATE_ID" +``` + +7. Report the proposal ID, checks run, remaining risks, and any follow-up needed. Do not continue editing the frozen proposal; later changes require another prompt and proposal. + +## Handle follow-up instructions + +Every follow-up instruction needs a new prompt state before effects. + +- If the controller supplies a new `HOP_STATE_ID`, inspect it and continue. +- If no new state was supplied, stop before acting and ask the controller to record the exact follow-up: + +```bash +hop prompt --from "" +``` + +The command first checkpoints prior effects and then creates the follow-up prompt state. Continue only from the returned prompt state. + +## Land only with explicit authority + +When the user explicitly asks to land a proposal, validate the exact final composed tree: + +```bash +hop land -- [args...] +``` + +- On success, report the accepted-state ID. +- On overlap, do not mutate or discard the proposal. Report the conflicting paths and request a reconciliation prompt based on the latest accepted state. +- On final validation failure, preserve the failed state and evidence, then request a corrective follow-up. +- If no final test command is available, state clearly that landing will be manual and unvalidated. + +## Inspect and recover + +Use these commands as needed: + +```bash +hop status +hop graph +hop state +hop diff +hop history +hop doctor +``` + +Use `hop undo` only when the user explicitly asks to undo the latest accepted transition. It creates a new forward state; it does not erase history. + +Read [references/protocol.md](references/protocol.md) when command semantics, state kinds, exit codes, or troubleshooting details are needed. + diff --git a/skills/hop/agents/openai.yaml b/skills/hop/agents/openai.yaml new file mode 100644 index 0000000..30028bc --- /dev/null +++ b/skills/hop/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Hop Version Control" + short_description: "Safely coordinate and land agent work with Hop" + default_prompt: "Use $hop to complete this coding task in an isolated Hop state and submit a validated proposal." diff --git a/skills/hop/embed.go b/skills/hop/embed.go new file mode 100644 index 0000000..0100d47 --- /dev/null +++ b/skills/hop/embed.go @@ -0,0 +1,9 @@ +// Package hopskill embeds the distributable Hop agent skill in the CLI binary. +package hopskill + +import "embed" + +// Files contains the complete vendor-neutral Hop skill bundle. +// +//go:embed SKILL.md agents/openai.yaml references/protocol.md +var Files embed.FS diff --git a/skills/hop/references/protocol.md b/skills/hop/references/protocol.md new file mode 100644 index 0000000..d09832d --- /dev/null +++ b/skills/hop/references/protocol.md @@ -0,0 +1,108 @@ +# Hop agent protocol + +## State graph + +```text +A accepted +├─ P prompt, persisted before effects +│ └─ C checkpoint +│ └─ R proposal +└─ P independent prompt + +A + R ──land──> A next accepted state +``` + +State prefixes: + +| Prefix | Kind | Meaning | +|---|---|---| +| `A_` | accepted | Canonical project revision | +| `P_` | prompt | Exact instruction and pre-effect context | +| `C_` | checkpoint | Immutable workspace progress | +| `R_` | proposal | Frozen candidate result | +| `F_` | failed | Durable failed execution or validation state | +| `X_` | cancelled | Durable cancelled state | + +Prompt, checkpoint, and proposal states may reference identical Git trees while remaining distinct causal occurrences. + +## Environment contract + +| Variable | Purpose | +|---|---| +| `HOP_ROOT` | Canonical project root containing `.hop/hop.db` | +| `HOP_STATE_ID` | Prompt state authorizing the current instruction | +| `HOP_TASK_ID` | Logical task grouping related prompts and attempts | +| `HOP_ATTEMPT_ID` | Current agent approach/run | +| `HOP_WORKSPACE` | Only directory the agent may modify | + +Treat missing variables as an invalid agent launch. Do not infer an attempt from a nearby worktree when causality matters. + +## Command contract + +### Human or controller + +```bash +hop init +hop start --agent "" +hop env +hop prompt --from "" +hop land -- +hop undo +``` + +`hop start` creates the task, attempt, prompt state, and detached workspace before returning. The controller may deliver the prompt only after exit `0`. + +`hop prompt` captures a checkpoint of current workspace effects before creating the follow-up prompt state. + +### Agent + +```bash +hop state "$HOP_STATE_ID" --json +hop status --json +hop check "$HOP_STATE_ID" -- +hop propose --summary "" "$HOP_STATE_ID" +``` + +`hop check` snapshots the attempt and runs the command in a detached worktree materialized from that exact checkpoint. Edits made concurrently in the live workspace do not change the tested tree. + +`hop propose` freezes the current nonignored workspace tree. Later workspace edits cannot change the proposal. + +`hop land` compares paths changed by the proposal with paths accepted since its base. Any shared changed path blocks landing. Disjoint proposals are composed with Git three-tree plumbing and may then be validated on the final tree. + +## Exit codes + +| Code | Meaning | +|---:|---| +| `0` | Success | +| `1` | Git, SQLite, filesystem, or internal error | +| `2` | Invalid CLI usage | +| `20` | Overlap or conservative conflict block | +| `21` | Accepted or attempt head changed during compare-and-swap | +| `22` | Validation command failed | + +A failed `hop check` or final landing check persists its evidence. A blocked or failed landing does not advance accepted state. + +## Human launch sequence + +```bash +hop init +hop start --agent codex "Add password reset emails" +``` + +Use the returned workspace and environment to launch the agent. For example, conceptually: + +```bash +eval "$(hop env P_...)" + "" +``` + +The exact agent command is harness-specific. Until a Hop process adapter intercepts prompts automatically, follow-up prompts must also pass through `hop prompt` before the agent acts. + +## Failure handling + +- **Missing Hop environment:** stop before editing and request a Hop-controlled launch. +- **Check failure:** fix the live workspace, checkpoint/check again, then create a new proposal. +- **Frozen proposal needs changes:** record a follow-up prompt; never mutate the stored proposal. +- **Overlap on landing:** retain both lineages and reconcile through a new prompt against current accepted state. +- **Ref inconsistency:** run `hop doctor`; use `hop doctor --repair` only outside final validation. +- **Secrets:** prompt text and check output are stored locally without encryption in the alpha. Never place credentials in them.