Initial Hop alpha

This commit is contained in:
cyph3rasi
2026-07-11 07:33:41 -07:00
commit 675135b77e
19 changed files with 5612 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.DS_Store
# Hop's local state and isolated workspaces.
/.hop/
# Local build and verification artifacts.
/hop
/SHA256SUMS
/coverage.out
*.test
+215
View File
@@ -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 users 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. Hops 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 agents 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 repositorys `.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.
+11
View File
@@ -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))
}
+18
View File
@@ -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
)
+23
View File
@@ -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=
+466
View File
@@ -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]
}
+1498
View File
File diff suppressed because it is too large Load Diff
+1033
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -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[:])
}
+155
View File
@@ -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"
}
File diff suppressed because it is too large Load Diff
+578
View File
@@ -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
}
+102
View File
@@ -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
}
+63
View File
@@ -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())
}
}
+177
View File
@@ -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, " ")
}
+106
View File
@@ -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 <agent-name> "<exact prompt>"
```
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" -- <test-command> [args...]
```
5. Fix failures in the workspace and rerun the check as needed.
6. Freeze the result as a proposal:
```bash
hop propose --summary "<behavioral 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 <current-state> "<exact follow-up>"
```
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 <proposal-state> -- <final-test-command> [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 <state-id>
hop diff <state-id>
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.
+4
View File
@@ -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."
+9
View File
@@ -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
+108
View File
@@ -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 <name> "<exact initial prompt>"
hop env <prompt-state>
hop prompt --from <state> "<exact follow-up prompt>"
hop land <proposal> -- <final validation command>
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" -- <command>
hop propose --summary "<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_...)"
<agent-command> "<the same exact prompt>"
```
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.