Automatically push every accepted Hop transition

Hop-State: A_06FN6FF8HFSFD9ANGK6BY4G
Hop-Proposal: R_06FN6FDTK9G17RXFFT82ZYR
Hop-Task: T_06FN6CFVXVVAP1KZAJ2X62R
Hop-Attempt: AT_06FN6CFVXTRZ8ZDG28QR5Z0
This commit is contained in:
Hop
2026-07-11 15:13:13 -07:00
parent 9a3a6d152b
commit 7139b9e1a7
18 changed files with 360 additions and 13 deletions
+4
View File
@@ -21,6 +21,8 @@ and safe multi-agent integration.
exact final tree before it becomes accepted. exact final tree before it becomes accepted.
- **Accepted work is visible.** Successful results appear in the selected - **Accepted work is visible.** Successful results appear in the selected
project folder without moving your active Git branch or index. project folder without moving your active Git branch or index.
- **Publishing is automatic.** When an upstream branch exists, each accepted
transition is pushed without moving the local branch or force-pushing.
- **History stays local by default.** Detected credentials are redacted before - **History stays local by default.** Detected credentials are redacted before
prompts and evidence are persisted. prompts and evidence are persisted.
@@ -68,6 +70,8 @@ verification, see the
That is the full user workflow. You do not run `hop init`, route prompts through That is the full user workflow. You do not run `hop init`, route prompts through
a terminal, or work inside `.hop` yourself. After a task, `hop status` shows the a terminal, or work inside `.hop` yourself. After a task, `hop status` shows the
accepted state and whether the visible project folder is synchronized. accepted state and whether the visible project folder is synchronized.
When the repository has an unambiguous Git upstream, Hop also pushes the
accepted commit automatically; users do not run `git push` after each task.
For example, Codex Desktop users restart Codex after installation, select a Git For example, Codex Desktop users restart Codex after installation, select a Git
project, and prompt normally. Other compatible runtimes can read the shared project, and prompt normally. Other compatible runtimes can read the shared
+5 -2
View File
@@ -342,7 +342,8 @@ The smallest complete product is a **parallel-agent landing queue** backed by Gi
10. Run configured checks on that exact final state. 10. Run configured checks on that exact final state.
11. Auto-accept successful ordinary local work without another user prompt, 11. Auto-accept successful ordinary local work without another user prompt,
then advance the accepted head atomically, export a normal Git-compatible then advance the accepted head atomically, export a normal Git-compatible
commit, and safely synchronize the visible Desktop root. commit, safely synchronize the visible root, and non-force push an existing
unambiguous Git upstream.
12. Undo an accepted state through a compensating prompt/integration state. 12. Undo an accepted state through a compensating prompt/integration state.
13. Generate a small `PROJECT.md` from accepted facts. 13. Generate a small `PROJECT.md` from accepted facts.
14. Install a vendor-neutral agent skill and expose stable JSON CLI output. 14. Install a vendor-neutral agent skill and expose stable JSON CLI output.
@@ -370,7 +371,9 @@ The smallest complete product is a **parallel-agent landing queue** backed by Gi
- Deduplicate roots so a prompt that changes one file—or no files—adds only a small manifest plus new chunks rather than copying the repository. - Deduplicate roots so a prompt that changes one file—or no files—adds only a small manifest plus new chunks rather than copying the repository.
- Use a temporary integration worktree for final checks. - Use a temporary integration worktree for final checks.
- Emit an ordinary Git commit for each accepted source transition, with `Hop-State`, `Hop-Task`, and `Hop-Attempt` trailers. - Emit an ordinary Git commit for each accepted source transition, with `Hop-State`, `Hop-Task`, and `Hop-Attempt` trailers.
- Preserve metadata in a Hop receipt referenced by an internal Git ref; add remote synchronization later. - Preserve metadata in a Hop receipt referenced by an internal Git ref. Push
accepted Git commits to existing upstream branches now; distributed Hop-state
synchronization remains later work.
- Shell out to the installed Git CLI before taking on the complexity of a custom Git implementation. - Shell out to the installed Git CLI before taking on the complexity of a custom Git implementation.
- Assign each attempt isolated temp/cache paths and, where possible, a port range; filesystem isolation alone does not isolate development servers and local services. - Assign each attempt isolated temp/cache paths and, where possible, a port range; filesystem isolation alone does not isolate development servers and local services.
+24
View File
@@ -25,6 +25,7 @@ Usage:
hop land STATE [-- COMMAND [ARG...]] hop land STATE [-- COMMAND [ARG...]]
hop refresh PROPOSAL hop refresh PROPOSAL
hop sync hop sync
hop push
hop status hop status
hop graph hop graph
hop state STATE hop state STATE
@@ -264,6 +265,7 @@ func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) i
} }
if !jsonOutput { if !jsonOutput {
fmt.Fprintf(stdout, "Accepted internally as %s · tree %s · visible root unchanged\n", result.State.ID, shortHash(result.State.SourceTree)) fmt.Fprintf(stdout, "Accepted internally as %s · tree %s · visible root unchanged\n", result.State.ID, shortHash(result.State.SourceTree))
printRemotePush(stdout, result.RemotePush)
if result.Check == nil { if result.Check == nil {
fmt.Fprintln(stdout, "No final-state validation command was supplied.") fmt.Fprintln(stdout, "No final-state validation command was supplied.")
} }
@@ -308,6 +310,7 @@ func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) i
if !jsonOutput { if !jsonOutput {
fmt.Fprintf(stdout, "Landed as %s · tree %s\n", result.State.ID, shortHash(result.State.SourceTree)) fmt.Fprintf(stdout, "Landed as %s · tree %s\n", result.State.ID, shortHash(result.State.SourceTree))
fmt.Fprintf(stdout, "Synchronized visible root: %s\n", result.MaterializedRoot) fmt.Fprintf(stdout, "Synchronized visible root: %s\n", result.MaterializedRoot)
printRemotePush(stdout, result.RemotePush)
if result.Check == nil { if result.Check == nil {
fmt.Fprintln(stdout, "No final-state validation command was supplied.") fmt.Fprintln(stdout, "No final-state validation command was supplied.")
} }
@@ -348,6 +351,20 @@ func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) i
} }
} }
case "push":
if len(commandArgs) != 0 {
fmt.Fprintln(stderr, "usage: hop push")
return 2
}
result, err := service.Push(ctx)
value = result
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
if !jsonOutput {
printRemotePush(stdout, &result)
}
case "status": case "status":
status, err := service.Status(ctx) status, err := service.Status(ctx)
if err != nil { if err != nil {
@@ -620,6 +637,13 @@ func printRefreshSummary(w io.Writer, result RefreshResult) {
fmt.Fprintf(w, "Continue automatically with: hop check %s -- <test-command>, then propose and land again.\n", result.Prompt.ID) fmt.Fprintf(w, "Continue automatically with: hop check %s -- <test-command>, then propose and land again.\n", result.Prompt.ID)
} }
func printRemotePush(w io.Writer, result *RemotePushResult) {
if result == nil {
return
}
fmt.Fprintf(w, "Pushed accepted commit to %s/%s\n", result.Remote, strings.TrimPrefix(result.Ref, "refs/heads/"))
}
func removeFlag(args []string, wanted string) (bool, []string) { func removeFlag(args []string, wanted string) (bool, []string) {
found := false found := false
filtered := make([]string, 0, len(args)) filtered := make([]string, 0, len(args))
+112
View File
@@ -301,6 +301,118 @@ func (r *Repository) Head(ctx context.Context) (oid string, exists bool, err err
return trimLine(output), true, nil return trimLine(output), true, nil
} }
// PushAccepted publishes one accepted Hop commit to the repository's existing
// branch destination. It never force-pushes and returns configured=false when
// the repository has no unambiguous remote branch target.
func (r *Repository) PushAccepted(ctx context.Context, commit string) (result RemotePushResult, configured bool, err error) {
if err := validObjectName(commit); err != nil {
return result, false, fmt.Errorf("invalid accepted commit: %w", err)
}
branch, exists, err := r.optionalGitOutput(ctx, "symbolic-ref", "--quiet", "--short", "HEAD")
if err != nil {
return result, false, fmt.Errorf("discover automatic push branch: %w", err)
}
if !exists || branch == "" {
return result, false, nil
}
if _, err := r.run(ctx, nil, nil, "check-ref-format", "--branch", branch); err != nil {
return result, false, fmt.Errorf("validate automatic push branch: %w", err)
}
remoteNamesOutput, err := r.run(ctx, nil, nil, "remote")
if err != nil {
return result, false, fmt.Errorf("list Git remotes: %w", err)
}
remoteNames := nonemptyLines(remoteNamesOutput)
if len(remoteNames) == 0 {
return result, false, nil
}
upstreamRemote, hasUpstreamRemote, err := r.optionalGitOutput(ctx, "config", "--get", "branch."+branch+".remote")
if err != nil {
return result, false, fmt.Errorf("read Git upstream remote for %s: %w", branch, err)
}
remote := ""
for _, key := range []string{
"branch." + branch + ".pushRemote",
"remote.pushDefault",
} {
value, found, configErr := r.optionalGitOutput(ctx, "config", "--get", key)
if configErr != nil {
return result, false, fmt.Errorf("read Git config %s: %w", key, configErr)
}
if found && value != "" {
remote = value
break
}
}
if remote == "" && hasUpstreamRemote {
remote = upstreamRemote
}
if remote == "." {
return result, false, nil
}
if remote == "" {
if containsString(remoteNames, "origin") {
remote = "origin"
} else if len(remoteNames) == 1 {
remote = remoteNames[0]
} else {
return result, false, nil
}
}
if !containsString(remoteNames, remote) {
return result, false, fmt.Errorf("configured automatic push remote %q does not exist", remote)
}
ref := "refs/heads/" + branch
if mergeRef, found, configErr := r.optionalGitOutput(ctx, "config", "--get", "branch."+branch+".merge"); configErr != nil {
return result, false, fmt.Errorf("read upstream branch for %s: %w", branch, configErr)
} else if found && remote == upstreamRemote && strings.HasPrefix(mergeRef, "refs/heads/") {
ref = mergeRef
}
if err := r.validateRef(ctx, ref); err != nil {
return result, false, fmt.Errorf("validate automatic push destination: %w", err)
}
if _, err := r.run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, nil,
"push", "--porcelain", remote, commit+":"+ref); err != nil {
return result, true, fmt.Errorf("push accepted commit to %s/%s: %w", remote, strings.TrimPrefix(ref, "refs/heads/"), err)
}
safeRemote, _ := RedactPromptSecrets(remote)
return RemotePushResult{Remote: safeRemote, Ref: ref, Commit: commit}, true, nil
}
func (r *Repository) optionalGitOutput(ctx context.Context, args ...string) (string, bool, error) {
output, err := r.run(ctx, nil, nil, args...)
if err != nil {
if gitExitCode(err) == 1 {
return "", false, nil
}
return "", false, err
}
return trimLine(output), true, nil
}
func nonemptyLines(value string) []string {
var lines []string
for _, line := range strings.Split(value, "\n") {
if line = strings.TrimSpace(strings.TrimSuffix(line, "\r")); line != "" {
lines = append(lines, line)
}
}
return lines
}
func containsString(values []string, target string) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
// Snapshot records all tracked files and all non-ignored untracked files in the // 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 // 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 // state of the user's real index. The synthetic commit is parented to HEAD when
+13 -6
View File
@@ -81,12 +81,19 @@ type Status struct {
} }
type AcceptResult struct { type AcceptResult struct {
State State `json:"state"` State State `json:"state"`
ProposalPaths []string `json:"proposal_paths"` ProposalPaths []string `json:"proposal_paths"`
CurrentPaths []string `json:"current_paths"` CurrentPaths []string `json:"current_paths"`
Check *Check `json:"check,omitempty"` Check *Check `json:"check,omitempty"`
MaterializedRoot string `json:"materialized_root,omitempty"` MaterializedRoot string `json:"materialized_root,omitempty"`
Warnings []string `json:"warnings,omitempty"` RemotePush *RemotePushResult `json:"remote_push,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
type RemotePushResult struct {
Remote string `json:"remote"`
Ref string `json:"ref"`
Commit string `json:"commit"`
} }
type SyncResult struct { type SyncResult struct {
+42
View File
@@ -644,6 +644,32 @@ func (s *Service) Land(ctx context.Context, proposalID string, checkCommand []st
return s.accept(ctx, proposalID, checkCommand, true) return s.accept(ctx, proposalID, checkCommand, true)
} }
// Push publishes the current accepted commit to the repository's inferred
// upstream branch. Land and Accept call the same operation automatically; this
// explicit form is the retry/recovery surface for an agent after a warning.
func (s *Service) Push(ctx context.Context) (RemotePushResult, error) {
release, err := acquireProjectLock(ctx, s.Root, "accept")
if err != nil {
return RemotePushResult{}, err
}
defer release()
accepted, err := s.Store.AcceptedHead(ctx)
if err != nil {
return RemotePushResult{}, err
}
pushCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
result, configured, err := s.Repo.PushAccepted(pushCtx, accepted.GitCommit)
if err != nil {
message, _ := RedactPromptSecrets(err.Error())
return RemotePushResult{}, errors.New(message)
}
if !configured {
return RemotePushResult{}, errors.New("hop: no unambiguous Git remote branch is configured for automatic push")
}
return result, nil
}
func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []string, materialize bool) (AcceptResult, error) { func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []string, materialize bool) (AcceptResult, error) {
release, err := acquireProjectLock(ctx, s.Root, "accept") release, err := acquireProjectLock(ctx, s.Root, "accept")
if err != nil { if err != nil {
@@ -668,6 +694,7 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
} }
result.MaterializedRoot = s.Root result.MaterializedRoot = s.Root
} }
s.attachAutomaticPush(ctx, &result)
return result, nil return result, nil
} }
attempt, err := s.Store.GetAttempt(ctx, proposal.AttemptID) attempt, err := s.Store.GetAttempt(ctx, proposal.AttemptID)
@@ -844,9 +871,24 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
} }
result.MaterializedRoot = s.Root result.MaterializedRoot = s.Root
} }
s.attachAutomaticPush(ctx, &result)
return result, nil return result, nil
} }
func (s *Service) attachAutomaticPush(ctx context.Context, result *AcceptResult) {
pushCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
pushed, configured, err := s.Repo.PushAccepted(pushCtx, result.State.GitCommit)
if err != nil {
message, _ := RedactPromptSecrets(err.Error())
result.Warnings = append(result.Warnings, "accepted state is local, but automatic push failed: "+message)
return
}
if configured {
result.RemotePush = &pushed
}
}
// Sync projects the current accepted tree into a visible root that still // Sync projects the current accepted tree into a visible root that still
// matches its durable materialized head. It is useful after controller-only accepts or // matches its durable materialized head. It is useful after controller-only accepts or
// when upgrading a project created before automatic materialization existed. // when upgrading a project created before automatic materialization existed.
+97
View File
@@ -1448,6 +1448,103 @@ func TestLandMaterializesVisibleRootWithoutMovingGitState(t *testing.T) {
} }
} }
func TestLandAutomaticallyPushesAcceptedCommit(t *testing.T) {
ctx := context.Background()
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
remote := filepath.Join(t.TempDir(), "remote.git")
runGitTest(t, service.Root, "init", "--quiet", "--bare", remote)
runGitTest(t, service.Root, "remote", "add", "origin", remote)
branch := runGitTest(t, service.Root, "symbolic-ref", "--short", "HEAD")
started, err := service.CreatePrompt(ctx, "Publish accepted work", "", "agent")
if err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(started.Workspace, "published.txt"), "published\n")
proposal, err := service.Propose(ctx, started.Prompt.ID, "Published change")
if err != nil {
t.Fatal(err)
}
result, err := service.Land(ctx, proposal.Proposal.ID, nil)
if err != nil {
t.Fatal(err)
}
if result.RemotePush == nil {
t.Fatal("land did not report an automatic remote push")
}
wantRef := "refs/heads/" + branch
if result.RemotePush.Remote != "origin" || result.RemotePush.Ref != wantRef || result.RemotePush.Commit != result.State.GitCommit {
t.Fatalf("automatic push = %#v", result.RemotePush)
}
if got := runGitTest(t, service.Root, "--git-dir", remote, "rev-parse", wantRef); got != result.State.GitCommit {
t.Fatalf("remote branch = %s, want accepted commit %s", got, result.State.GitCommit)
}
retried, err := service.Push(ctx)
if err != nil {
t.Fatal(err)
}
if retried.Commit != result.State.GitCommit || retried.Remote != "origin" || retried.Ref != wantRef {
t.Fatalf("explicit push retry = %#v", retried)
}
}
func TestAutomaticPushNeverForcesDivergedRemote(t *testing.T) {
ctx := context.Background()
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
remote := filepath.Join(t.TempDir(), "remote.git")
runGitTest(t, service.Root, "init", "--quiet", "--bare", remote)
runGitTest(t, service.Root, "remote", "add", "origin", remote)
branch := runGitTest(t, service.Root, "symbolic-ref", "--short", "HEAD")
first, err := service.CreatePrompt(ctx, "Publish first work", "", "agent")
if err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(first.Workspace, "first.txt"), "first\n")
firstProposal, err := service.Propose(ctx, first.Prompt.ID, "First published change")
if err != nil {
t.Fatal(err)
}
firstLanded, err := service.Land(ctx, firstProposal.Proposal.ID, nil)
if err != nil {
t.Fatal(err)
}
other := filepath.Join(t.TempDir(), "other")
runGitTest(t, service.Root, "clone", "--quiet", "--branch", branch, remote, other)
writeTestFile(t, filepath.Join(other, "remote.txt"), "remote\n")
runGitTest(t, other, "add", "remote.txt")
runGitTest(t, other, "-c", "user.name=Remote", "-c", "user.email=remote@example.com", "commit", "--quiet", "-m", "remote change")
runGitTest(t, other, "push", "--quiet", "origin", branch)
remoteTip := runGitTest(t, service.Root, "--git-dir", remote, "rev-parse", "refs/heads/"+branch)
if remoteTip == firstLanded.State.GitCommit {
t.Fatal("test did not advance the remote independently")
}
second, err := service.CreatePrompt(ctx, "Publish local work", "", "agent")
if err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(second.Workspace, "local.txt"), "local\n")
secondProposal, err := service.Propose(ctx, second.Prompt.ID, "Local accepted change")
if err != nil {
t.Fatal(err)
}
secondLanded, err := service.Land(ctx, secondProposal.Proposal.ID, nil)
if err != nil {
t.Fatal(err)
}
if secondLanded.RemotePush != nil {
t.Fatalf("diverged remote unexpectedly reported a successful push: %#v", secondLanded.RemotePush)
}
if len(secondLanded.Warnings) == 0 || !strings.Contains(strings.Join(secondLanded.Warnings, "\n"), "automatic push failed") {
t.Fatalf("diverged remote warnings = %#v", secondLanded.Warnings)
}
if got := runGitTest(t, service.Root, "--git-dir", remote, "rev-parse", "refs/heads/"+branch); got != remoteTip {
t.Fatalf("automatic push rewrote diverged remote from %s to %s", remoteTip, got)
}
}
func TestLandMaterializesEmptyUnbornRootAcrossRepeatedLands(t *testing.T) { func TestLandMaterializesEmptyUnbornRootAcrossRepeatedLands(t *testing.T) {
ctx := context.Background() ctx := context.Background()
root := t.TempDir() root := t.TempDir()
+9 -2
View File
@@ -71,7 +71,7 @@ variable or secret-manager name instead.
- Use absolute paths beneath that workspace for file reads and edits. - Use absolute paths beneath that workspace for file reads and edits.
- Never edit the selected canonical project root. - Never edit the selected canonical project root.
- Do not run `git commit`, `git checkout`, `git switch`, `git branch`, - Do not run `git commit`, `git checkout`, `git switch`, `git branch`,
`git rebase`, `git reset`, `git stash`, or `git worktree`. `git rebase`, `git reset`, `git stash`, `git worktree`, or `git push`.
- Do not stage files. Hop captures every nonignored workspace change. - Do not stage files. Hop captures every nonignored workspace change.
- Give a subagent project-changing work only after creating a distinct Hop - Give a subagent project-changing work only after creating a distinct Hop
prompt/attempt for that delegation. prompt/attempt for that delegation.
@@ -130,7 +130,9 @@ hop status --json
8. Report the accepted result, validation, and remaining risks. Keep internal 8. Report the accepted result, validation, and remaining risks. Keep internal
state and evidence IDs out of the normal response unless they help explain a state and evidence IDs out of the normal response unless they help explain a
failure or the user asks for them. Confirm that `hop land` reported the failure or the user asks for them. Confirm that `hop land` reported the
selected visible project root as synchronized. selected visible project root as synchronized. When it reports an automatic
push warning, retry once with `hop push`; never force-push or ask the user to
perform routine source-control mechanics.
For a read-only or informational turn, the prompt state is sufficient; do not For a read-only or informational turn, the prompt state is sufficient; do not
invent a proposal when the workspace tree is unchanged. invent a proposal when the workspace tree is unchanged.
@@ -147,6 +149,11 @@ to complete that task. Do not ask for separate landing permission and do not
capture a second prompt merely to land. After checks pass and the proposal is capture a second prompt merely to land. After checks pass and the proposal is
frozen, run `hop land` as part of the same turn. frozen, run `hop land` as part of the same turn.
An existing unambiguous Git upstream is standing project configuration for
non-forced publication of accepted states. Hop pushes accepted commits
automatically after landing; prompts, checkpoints, proposals, and `.hop/` state
remain local. Do not run raw `git push`.
Use the strongest relevant final validation command. If the task truly has no Use the strongest relevant final validation command. If the task truly has no
runnable validation, `hop land <proposal-state>` is allowed and the final runnable validation, `hop land <proposal-state>` is allowed and the final
response must say that acceptance was not validated by a command. response must say that acceptance was not validated by a command.
+1 -1
View File
@@ -1,7 +1,7 @@
interface: interface:
display_name: "Hop Version Control" display_name: "Hop Version Control"
short_description: "Capture every coding prompt before project effects" short_description: "Capture every coding prompt before project effects"
default_prompt: "Use $hop to capture this prompt first, complete and validate the task in its isolated workspace, auto-land it, and automatically reconcile ordinary merge conflicts unless I explicitly request review first." default_prompt: "Use $hop to capture this prompt first, complete and validate the task in its isolated workspace, auto-land and publish accepted work, and automatically reconcile ordinary merge conflicts unless I explicitly request review first."
policy: policy:
allow_implicit_invocation: true allow_implicit_invocation: true
+8
View File
@@ -91,6 +91,7 @@ hop check "$HOP_STATE_ID" -- <command>
hop propose --summary "<summary>" "$HOP_STATE_ID" hop propose --summary "<summary>" "$HOP_STATE_ID"
hop land <proposal-state> -- <final validation command> hop land <proposal-state> -- <final validation command>
hop refresh <proposal-state> hop refresh <proposal-state>
hop push
``` ```
An adapter may set `HOP_AGENT=<runtime>` or pass `--agent <runtime>`. If it has An adapter may set `HOP_AGENT=<runtime>` or pass `--agent <runtime>`. If it has
@@ -135,6 +136,12 @@ explicit form of the same preparation step.
`hop sync` safely catches a stale accepted-ancestor root up to the current `hop sync` safely catches a stale accepted-ancestor root up to the current
accepted state, including projects created with older Hop builds. accepted state, including projects created with older Hop builds.
After every successful `hop land` or `hop accept`, Hop automatically performs a
non-forced push of the accepted commit when the active branch has an
unambiguous upstream. No remote is a normal local-only mode. Push failure does
not undo acceptance; `hop push` retries the current accepted commit. Agents
must never replace a non-fast-forward rejection with a force-push.
`hop begin` is the interactive-agent entry point. It initializes Hop when `hop begin` is the interactive-agent entry point. It initializes Hop when
necessary and captures the current message before the agent performs project necessary and captures the current message before the agent performs project
work. Runtime adapters identify themselves through `HOP_AGENT` or `--agent` work. Runtime adapters identify themselves through `HOP_AGENT` or `--agent`
@@ -206,6 +213,7 @@ provide the same boundary inside compatible agent clients.
product ambiguity, not ordinary textual overlap. product ambiguity, not ordinary textual overlap.
- **Visible-root conflict:** preserve the proposal and the user's files. Do not substitute controller-only `hop accept`; resolve or capture the visible changes, then land again. - **Visible-root conflict:** preserve the proposal and the user's files. Do not substitute controller-only `hop accept`; resolve or capture the visible changes, then land again.
- **Controller-accepted root is stale:** run `hop sync`; it succeeds only from an accepted ancestor and never overwrites divergence. - **Controller-accepted root is stale:** run `hop sync`; it succeeds only from an accepted ancestor and never overwrites divergence.
- **Automatic push warning:** retry once with `hop push`; preserve a diverged remote and never force-push it.
- **Ref inconsistency:** run `hop doctor`; use `hop doctor --repair` only outside final validation. - **Ref inconsistency:** run `hop doctor`; use `hop doctor --repair` only outside final validation.
- **Secrets:** Hop redacts high-confidence provider keys plus contextual tokens, - **Secrets:** Hop redacts high-confidence provider keys plus contextual tokens,
passwords, private keys, authorization headers, and credential-bearing URLs passwords, private keys, authorization headers, and credential-bearing URLs
+5 -1
View File
@@ -34,7 +34,9 @@ hop land R_... -- go test ./...
``` ```
No second landing authorization is requested unless the user explicitly asks No second landing authorization is requested unless the user explicitly asks
for review-first behavior. for review-first behavior. After acceptance, Hop automatically pushes the
accepted commit when the repository has an unambiguous upstream. The agent does
not ask the user to run `git push`.
Skill-based capture stores the agent's verbatim transcription of the visible Skill-based capture stores the agent's verbatim transcription of the visible
message and its attachment references. Because the skill runs after the client message and its attachment references. Because the skill runs after the client
@@ -84,6 +86,8 @@ can only guarantee capture before project effects.
- Never edit the canonical project root directly. - Never edit the canonical project root directly.
- Never mutate a frozen proposal. - Never mutate a frozen proposal.
- Inspect landing warnings. If automatic push failed transiently, retry once
with `hop push`; never force-push a diverged remote.
- Do not bypass `hop land` with Git reset, checkout, worktree, or manual copying. - Do not bypass `hop land` with Git reset, checkout, worktree, or manual copying.
- Run validation against immutable checkpoints and the final integrated tree. - Run validation against immutable checkpoints and the final integrated tree.
- Let Hop merge compatible concurrent work. - Let Hop merge compatible concurrent work.
+5
View File
@@ -50,5 +50,10 @@ derived Git refs can be repaired by `hop doctor --repair`. Visible-root landing
also tracks which accepted state is physically visible, allowing safe catch-up also tracks which accepted state is physically visible, allowing safe catch-up
with `hop sync` without treating a divergent folder as disposable. with `hop sync` without treating a divergent folder as disposable.
After that local transaction succeeds, Hop attempts a non-forced push of the
accepted commit to the inferred upstream branch. Remote publication is derived
and retryable: its failure cannot roll back or corrupt the durable local
acceptance.
For the full product direction, read the For the full product direction, read the
[product blueprint](https://githop.xyz/GnosysLabs/Hop/src/branch/main/docs/product-blueprint.md). [product blueprint](https://githop.xyz/GnosysLabs/Hop/src/branch/main/docs/product-blueprint.md).
+1
View File
@@ -34,6 +34,7 @@ release.
|---|---| |---|---|
| `hop accept PROPOSAL [-- COMMAND...]` | Accept internally without changing visible files | | `hop accept PROPOSAL [-- COMMAND...]` | Accept internally without changing visible files |
| `hop sync` | Materialize the current accepted tree from a safe accepted ancestor | | `hop sync` | Materialize the current accepted tree from a safe accepted ancestor |
| `hop push` | Retry publishing the current accepted commit to its inferred upstream |
| `hop undo` | Create a forward-only acceptance that restores the previous accepted tree | | `hop undo` | Create a forward-only acceptance that restores the previous accepted tree |
| `hop doctor [--repair]` | Validate database/object/ref consistency | | `hop doctor [--repair]` | Validate database/object/ref consistency |
+10
View File
@@ -59,3 +59,13 @@ The visible root is the project directory selected in an agent client or passed
as the controller's working directory. Hop only materializes into it when it as the controller's working directory. Hop only materializes into it when it
still matches an accepted Hop ancestor. Untracked, ignored, staged, or ordinary still matches an accepted Hop ancestor. Untracked, ignored, staged, or ordinary
file divergence that could be overwritten causes a fail-closed error. file divergence that could be overwritten causes a fail-closed error.
## Automatic upstream push
Every successful accepted transition is automatically pushed to the active
branch's configured Git upstream. If no upstream is set, Hop uses `origin`, or a
single unambiguous remote, with the active branch name. It pushes only accepted
commits—not prompts, checkpoints, proposals, SQLite history, or workspaces—and
never force-pushes. A network, authentication, or non-fast-forward failure
leaves the accepted local state intact and is returned as a warning for the
agent to handle.
+5
View File
@@ -48,6 +48,11 @@ hop doctor
A normal interactive result reports `Root: synchronized`. A normal interactive result reports `Root: synchronized`.
If the active Git branch has an upstream—or the repository has one unambiguous
`origin`/single-remote destination—landing also fast-forward pushes the accepted
commit automatically. Hop never force-pushes. Repositories without a remote
remain local without treating that as an error.
## Ask for review before landing ## Ask for review before landing
Automatic landing is the default because the original task authorizes the Automatic landing is the default because the original task authorizes the
+2 -1
View File
@@ -3,7 +3,8 @@
Hop is prompt-native version control for coding agents. It stores each prompt Hop is prompt-native version control for coding agents. It stores each prompt
as an immutable project state, gives agent work an isolated workspace, validates as an immutable project state, gives agent work an isolated workspace, validates
the exact tree being accepted, and safely materializes accepted results into the the exact tree being accepted, and safely materializes accepted results into the
visible project folder. visible project folder. When a Git upstream exists, accepted commits are pushed
automatically.
## Start here ## Start here
+5
View File
@@ -46,6 +46,11 @@ Hop does not use `reset --hard`, move the active branch, or write the user's
real Git index. Visible-root synchronization fails closed when files, ignored real Git index. Visible-root synchronization fails closed when files, ignored
destinations, or staged state could be overwritten. destinations, or staged state could be overwritten.
Automatic push delegates authentication to the user's existing Git transport
and credential configuration. Hop does not store remote passwords, SSH private
keys, or access tokens. It disables terminal credential prompting in the
background push path and redacts detected credentials from returned errors.
## Reporting a vulnerability ## Reporting a vulnerability
Before the public security contact is configured, disclose vulnerabilities Before the public security contact is configured, disclose vulnerabilities
+12
View File
@@ -73,6 +73,18 @@ hop doctor --repair
Do not repair while a final validation command is running. Do not repair while a final validation command is running.
## Automatic push failed
The accepted state remains safe locally. Hop never force-pushes. Retry a
transient network or authentication failure with:
```bash
hop push
```
If the remote branch moved independently, preserve both histories and resolve
the divergence intentionally; do not replace this with a force-push.
## A secret was pasted ## A secret was pasted
Rotate it. Hop redaction reduces durable exposure but cannot prove that every Rotate it. Hop redaction reduces durable exposure but cannot prove that every