Compare commits
2 Commits
68d8bf2f7d
...
7139b9e1a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 7139b9e1a7 | |||
| 9a3a6d152b |
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Gnosys Labs LLC
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -21,6 +21,8 @@ and safe multi-agent integration.
|
||||
exact final tree before it becomes accepted.
|
||||
- **Accepted work is visible.** Successful results appear in the selected
|
||||
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
|
||||
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
|
||||
a terminal, or work inside `.hop` yourself. After a task, `hop status` shows the
|
||||
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
|
||||
project, and prompt normally. Other compatible runtimes can read the shared
|
||||
@@ -87,3 +91,7 @@ Hop is currently an early alpha. Expect its state model and CLI to evolve before
|
||||
- [Security and privacy](https://githop.xyz/GnosysLabs/Hop/wiki/Security-and-Privacy)
|
||||
- [Architecture](https://githop.xyz/GnosysLabs/Hop/wiki/Architecture)
|
||||
- [Product blueprint](docs/product-blueprint.md)
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE) © 2026 Gnosys Labs LLC.
|
||||
|
||||
@@ -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.
|
||||
11. Auto-accept successful ordinary local work without another user prompt,
|
||||
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.
|
||||
13. Generate a small `PROJECT.md` from accepted facts.
|
||||
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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Assign each attempt isolated temp/cache paths and, where possible, a port range; filesystem isolation alone does not isolate development servers and local services.
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ Usage:
|
||||
hop land STATE [-- COMMAND [ARG...]]
|
||||
hop refresh PROPOSAL
|
||||
hop sync
|
||||
hop push
|
||||
hop status
|
||||
hop graph
|
||||
hop state STATE
|
||||
@@ -264,6 +265,7 @@ func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) i
|
||||
}
|
||||
if !jsonOutput {
|
||||
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 {
|
||||
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 {
|
||||
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)
|
||||
printRemotePush(stdout, result.RemotePush)
|
||||
if result.Check == nil {
|
||||
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":
|
||||
status, err := service.Status(ctx)
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
found := false
|
||||
filtered := make([]string, 0, len(args))
|
||||
|
||||
@@ -301,6 +301,118 @@ func (r *Repository) Head(ctx context.Context) (oid string, exists bool, err err
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -86,9 +86,16 @@ type AcceptResult struct {
|
||||
CurrentPaths []string `json:"current_paths"`
|
||||
Check *Check `json:"check,omitempty"`
|
||||
MaterializedRoot string `json:"materialized_root,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 {
|
||||
State State `json:"state"`
|
||||
Root string `json:"root"`
|
||||
|
||||
@@ -644,6 +644,32 @@ func (s *Service) Land(ctx context.Context, proposalID string, checkCommand []st
|
||||
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) {
|
||||
release, err := acquireProjectLock(ctx, s.Root, "accept")
|
||||
if err != nil {
|
||||
@@ -668,6 +694,7 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
|
||||
}
|
||||
result.MaterializedRoot = s.Root
|
||||
}
|
||||
s.attachAutomaticPush(ctx, &result)
|
||||
return result, nil
|
||||
}
|
||||
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
|
||||
}
|
||||
s.attachAutomaticPush(ctx, &result)
|
||||
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
|
||||
// matches its durable materialized head. It is useful after controller-only accepts or
|
||||
// when upgrading a project created before automatic materialization existed.
|
||||
|
||||
@@ -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) {
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
|
||||
+9
-2
@@ -71,7 +71,7 @@ variable or secret-manager name instead.
|
||||
- Use absolute paths beneath that workspace for file reads and edits.
|
||||
- Never edit the selected canonical project root.
|
||||
- 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.
|
||||
- Give a subagent project-changing work only after creating a distinct Hop
|
||||
prompt/attempt for that delegation.
|
||||
@@ -130,7 +130,9 @@ hop status --json
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
runnable validation, `hop land <proposal-state>` is allowed and the final
|
||||
response must say that acceptance was not validated by a command.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
interface:
|
||||
display_name: "Hop Version Control"
|
||||
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:
|
||||
allow_implicit_invocation: true
|
||||
|
||||
@@ -91,6 +91,7 @@ hop check "$HOP_STATE_ID" -- <command>
|
||||
hop propose --summary "<summary>" "$HOP_STATE_ID"
|
||||
hop land <proposal-state> -- <final validation command>
|
||||
hop refresh <proposal-state>
|
||||
hop push
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
necessary and captures the current message before the agent performs project
|
||||
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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Secrets:** Hop redacts high-confidence provider keys plus contextual tokens,
|
||||
passwords, private keys, authorization headers, and credential-bearing URLs
|
||||
|
||||
@@ -34,7 +34,9 @@ hop land R_... -- go test ./...
|
||||
```
|
||||
|
||||
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
|
||||
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 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.
|
||||
- Run validation against immutable checkpoints and the final integrated tree.
|
||||
- Let Hop merge compatible concurrent work.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
[product blueprint](https://githop.xyz/GnosysLabs/Hop/src/branch/main/docs/product-blueprint.md).
|
||||
|
||||
@@ -34,6 +34,7 @@ release.
|
||||
|---|---|
|
||||
| `hop accept PROPOSAL [-- COMMAND...]` | Accept internally without changing visible files |
|
||||
| `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 doctor [--repair]` | Validate database/object/ref consistency |
|
||||
|
||||
|
||||
@@ -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
|
||||
still matches an accepted Hop ancestor. Untracked, ignored, staged, or ordinary
|
||||
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.
|
||||
|
||||
@@ -48,6 +48,11 @@ hop doctor
|
||||
|
||||
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
|
||||
|
||||
Automatic landing is the default because the original task authorizes the
|
||||
|
||||
+2
-1
@@ -3,7 +3,8 @@
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
Before the public security contact is configured, disclose vulnerabilities
|
||||
|
||||
@@ -73,6 +73,18 @@ hop doctor --repair
|
||||
|
||||
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
|
||||
|
||||
Rotate it. Hop redaction reduces durable exposure but cannot prove that every
|
||||
|
||||
Reference in New Issue
Block a user