Compare commits

..

15 Commits

Author SHA1 Message Date
cyph3rasi f7cd6df597 Keep all prompt records private and remove repository-published prompt ledgers
Hop-State: A_06FN8RB08RV0PTBDNTH4738
Hop-Proposal: R_06FN8R7MN60MJK1X7ZRDG5G
Hop-Task: T_06FN8PDXR44H4NMQ41P9P10
Hop-Attempt: AT_06FN8PDXR7HFSVKEKH6BQV8
2026-07-11 20:31:35 -07:00
cyph3rasi 4364e1d8a4 Make prompt capture fast and resilient
Hop-State: A_06FN8D0CK06GY5XBDYZ632R
Hop-Proposal: R_06FN8CY2FJBV5QA0WFEDWHR
Hop-Task: T_06FN8C4PMFJRT8APV4G03Y8
Hop-Attempt: AT_06FN8C4PMFRJEE4B7KKQ740
2026-07-11 19:42:05 -07:00
cyph3rasi ff62e89596 Reconcile compatible remote advancement before publishing accepted states
Hop-State: A_06FN8BW3A4C9EV7XMN6Q0FR
Hop-Proposal: R_06FN8BSNQNQNKB9D7951MQR
Hop-Task: T_06FN8AWMR0HY1RJY8AN2A3R
Hop-Attempt: AT_06FN8AWMR0WHBQ7ZMDZ0NWG
2026-07-11 19:37:07 -07:00
Hop 59b8ca7c05 Attribute accepted commits to the prompting user's configured Git identity
Hop-State: A_06FN8A13TXD2FW71C2B7MDR
Hop-Proposal: R_06FN89ZN929C6HYFDPJ2B48
Hop-Task: T_06FN892CVE210TZR2ZQQ9JG
Hop-Attempt: AT_06FN892CVDGHFG3F889Q3YR
2026-07-11 19:29:04 -07:00
cyph3rasi 53823bad81 Support Windows PowerShell 5 architecture detection 2026-07-12 02:05:47 +00:00
Hop 787acb2330 Purge internal reconciliation prompts from portable records
Hop-State: A_06FN83ZZSBQMQG8WN406CY8
Hop-Proposal: R_06FN83YD6CMFSR5CTE0ZWHR
Hop-Task: T_06FN83DN7SMZB9WJRZNZMTR
Hop-Attempt: AT_06FN83DN7VHVF5BVW6DEJ8R
2026-07-11 19:02:42 -07:00
cyph3rasi 2881f02d18 Harden Windows installer release discovery 2026-07-12 02:01:15 +00:00
cyph3rasi 58fcbd5fe2 Harden Windows installer release discovery 2026-07-12 02:00:25 +00:00
Hop 74ba06de91 Publish only user prompts with their own proposal outcomes
Hop-State: A_06FN72NMB1DRV5SV2T6YWPR
Hop-Proposal: R_06FN72K60164AV77NA2QP5G
Hop-Task: T_06FN6ZVNS25H7V4BRG743PG
Hop-Attempt: AT_06FN6ZVNS0W32KK9YQCRG88
2026-07-11 16:37:06 -07:00
Hop 074e849d84 Publish Hop's portable prompt record history with the repository
Hop-State: A_06FN6Y387QJN3F2MET3MACG
Hop-Proposal: R_06FN6Y1C6P8WK8AEVGGZHQ0
Hop-Task: T_06FN6XEKNY7YGPFTHZE3DM0
Hop-Attempt: AT_06FN6XEKNZMZE18CGY1Z2Y0
2026-07-11 16:17:07 -07:00
Hop e5bb6a5600 Fix portable prompt record export command routing
Hop-State: A_06FN6WBVEZC485TNKJEW7T0
Hop-Proposal: R_06FN6WAJ8346WS9XJEJY32R
Hop-Task: T_06FN6VQSN9Z7GEWHBVEFAJG
Hop-Attempt: AT_06FN6VQSN9TK8EEXTFR77ZG
2026-07-11 16:09:33 -07:00
Hop a74f420307 Publish immutable portable prompt records without exposing Hop runtime files
Hop-State: A_06FN6TVSHKAXPT37Z25BN38
Hop-Proposal: R_06FN6TSP5HXV3S320BV6TN8
Hop-Task: T_06FN6RMF4VPW419BR2ZD9Q0
Hop-Attempt: AT_06FN6RMF4V18KAM05RFZ8ER
2026-07-11 16:03:00 -07:00
Hop 853acec229 Require user-provisioned release credentials
Hop-State: A_06FN6KM758JV6S44F6CA6W0
Hop-Proposal: R_06FN6KJWAY5XWVMCVQHCXKR
Hop-Task: T_06FN6GZKFMRV2HP5N7XMNSR
Hop-Attempt: AT_06FN6GZKFP22ZCFRXGB7V00
2026-07-11 15:31:23 -07:00
Hop 7139b9e1a7 Automatically push every accepted Hop transition
Hop-State: A_06FN6FF8HFSFD9ANGK6BY4G
Hop-Proposal: R_06FN6FDTK9G17RXFFT82ZYR
Hop-Task: T_06FN6CFVXVVAP1KZAJ2X62R
Hop-Attempt: AT_06FN6CFVXTRZ8ZDG28QR5Z0
2026-07-11 15:13:13 -07:00
Hop 9a3a6d152b License Hop under MIT
Hop-State: A_06FN6C67C6D0KSG37ASKQDG
Hop-Proposal: R_06FN6C531M6TX6KCGEMG4E0
Hop-Task: T_06FN6BNGRHYXR3WCQBDNVNR
Hop-Attempt: AT_06FN6BNGRKX5FS7MJ6VN8Z8
2026-07-11 14:58:53 -07:00
25 changed files with 1093 additions and 73 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
.DS_Store
# Hop's local state and isolated workspaces.
# Hop's local state, prompt records, and isolated workspaces can contain private
# user requests and machine paths. Never publish them through Git.
/.hop/
# Local build and verification artifacts.
+21
View File
@@ -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.
+8
View File
@@ -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.
+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.
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.
+48
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"runtime/debug"
"strings"
)
@@ -24,7 +25,9 @@ Usage:
hop accept STATE [-- COMMAND [ARG...]]
hop land STATE [-- COMMAND [ARG...]]
hop refresh PROPOSAL
hop export [--output PATH]
hop sync
hop push
hop status
hop graph
hop state STATE
@@ -212,6 +215,28 @@ func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) i
fmt.Fprintf(stdout, "Created checkpoint %s · tree %s\n", state.ID, shortHash(state.SourceTree))
}
case "export":
fs := flag.NewFlagSet("export", flag.ContinueOnError)
fs.SetOutput(stderr)
output := fs.String("output", "", "write a private local prompt export beneath this directory")
if err := fs.Parse(commandArgs); err != nil || len(fs.Args()) != 0 {
if err == nil {
fmt.Fprintln(stderr, "usage: hop export [--output PATH]")
}
return 2
}
ledger, err := service.ExportPromptLedger(ctx, *output)
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
}
value = ledger
if !jsonOutput {
root := *output
if root == "" {
root = service.Root
}
fmt.Fprintf(stdout, "Exported %d private local prompt records to %s\n", len(ledger.Prompts), filepath.Join(root, ".hop", "records", "prompts"))
}
case "check":
stateID, argv, ok := splitCommand(commandArgs)
if !ok {
@@ -264,6 +289,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 +334,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 +375,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 +661,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))
+22
View File
@@ -83,3 +83,25 @@ func TestDistributionDoesNotRequireGiteaActions(t *testing.T) {
t.Fatal("release checklist still depends on a Gitea Actions workflow")
}
}
func TestReleaseWorkflowRequiresPreProvisionedCredential(t *testing.T) {
root := filepath.Clean(filepath.Join("..", ".."))
script, err := os.ReadFile(filepath.Join(root, "scripts", "release-local.sh"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(script), "pre-existing scoped GITEA_TOKEN") {
t.Fatal("release script does not require a pre-provisioned credential")
}
if strings.Contains(string(script), "/tokens") {
t.Fatal("release script must not manage provider tokens")
}
checklist, err := os.ReadFile(filepath.Join(root, "wiki", "Release-Checklist.md"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(checklist), "must never create, rotate, list,") ||
!strings.Contains(string(checklist), "or revoke account tokens") {
t.Fatal("release checklist does not forbid agent token management")
}
}
+260 -20
View File
@@ -5,7 +5,6 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
@@ -68,7 +67,7 @@ func (e *GitError) Error() string {
func (e *GitError) Unwrap() error { return e.Err }
// CommitOptions controls a synthetic commit. Empty identities and timestamps
// use the GitStore defaults; user Git identity configuration is never used.
// use the GitStore defaults.
type CommitOptions struct {
Message string
Parents []string
@@ -110,8 +109,8 @@ func EnsureRepository(path string) (*Repository, error) {
return NewGitStore().Ensure(context.Background(), path)
}
// OpenRepository opens the repository containing path and adds .hop/ to its
// per-repository exclude file.
// OpenRepository opens the repository containing path and keeps all Hop state,
// including prompt exports, private to the local machine.
func OpenRepository(path string) (*Repository, error) {
return NewGitStore().Open(context.Background(), path)
}
@@ -255,8 +254,8 @@ func (r *Repository) GitDir() string { return r.gitDir }
// worktrees.
func (r *Repository) CommonGitDir() string { return r.commonGitDir }
// EnsureHopExcluded adds .hop/ to .git/info/exclude without changing tracked
// files or the repository's public .gitignore.
// EnsureHopExcluded keeps the entire .hop directory private. Prompt exports can
// contain user requests and machine paths, so they must never enter snapshots.
func (r *Repository) EnsureHopExcluded() error {
infoDir := filepath.Join(r.commonGitDir, "info")
if err := os.MkdirAll(infoDir, 0o755); err != nil {
@@ -267,26 +266,29 @@ func (r *Repository) EnsureHopExcluded() error {
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("read Git exclude file: %w", err)
}
lines := make([]string, 0)
for _, line := range strings.Split(string(contents), "\n") {
if strings.TrimSuffix(line, "\r") == ".hop/" {
return nil
normalized := strings.TrimSuffix(line, "\r")
switch normalized {
case ".hop/", ".hop/*", "!.hop/records/", "!.hop/records/**",
"# Hop local runtime; .hop/records is intentionally versioned.",
"# Hop private local runtime and prompt records.":
continue
}
lines = append(lines, line)
}
file, err := os.OpenFile(excludePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return fmt.Errorf("open Git exclude file: %w", err)
updated := strings.Join(lines, "\n")
if updated != "" && !strings.HasSuffix(updated, "\n") {
updated += "\n"
}
defer file.Close()
if len(contents) > 0 && contents[len(contents)-1] != '\n' {
if _, err := io.WriteString(file, "\n"); err != nil {
return fmt.Errorf("complete Git exclude line: %w", err)
}
updated += "# Hop private local runtime and prompt records.\n.hop/\n"
if updated == string(contents) {
return nil
}
if _, err := io.WriteString(file, ".hop/\n"); err != nil {
return fmt.Errorf("exclude .hop directory: %w", err)
if err := os.WriteFile(excludePath, []byte(updated), 0o644); err != nil {
return fmt.Errorf("write Git exclude file: %w", err)
}
return file.Sync()
return nil
}
// Head returns the current HEAD commit. exists is false for an unborn branch.
@@ -301,6 +303,184 @@ 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)
}
destination, configured, err := r.pushDestination(ctx)
if err != nil || !configured {
return result, configured, err
}
if _, err := r.run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, nil,
"push", "--porcelain", destination.remote, commit+":"+destination.ref); err != nil {
return result, true, fmt.Errorf("push accepted commit to %s/%s: %w", destination.remote, strings.TrimPrefix(destination.ref, "refs/heads/"), err)
}
return RemotePushResult{Remote: destination.safeRemote, Ref: destination.ref, Commit: commit}, true, nil
}
type pushDestination struct {
remote string
safeRemote string
ref string
}
func (r *Repository) pushDestination(ctx context.Context) (pushDestination, bool, error) {
var result pushDestination
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)
}
safeRemote, _ := RedactPromptSecrets(remote)
return pushDestination{remote: remote, safeRemote: safeRemote, ref: ref}, true, nil
}
// FetchPushTip fetches the configured destination branch without changing the
// user's branch, index, or working tree. exists is false for a new remote branch.
func (r *Repository) FetchPushTip(ctx context.Context) (tip string, configured, exists bool, err error) {
destination, configured, err := r.pushDestination(ctx)
if err != nil || !configured {
return "", configured, false, err
}
output, err := r.run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, nil,
"ls-remote", "--refs", destination.remote, destination.ref)
if err != nil {
return "", true, false, fmt.Errorf("inspect remote branch %s/%s: %w", destination.remote, strings.TrimPrefix(destination.ref, "refs/heads/"), err)
}
fields := strings.Fields(output)
if len(fields) == 0 {
return "", true, false, nil
}
if len(fields) != 2 || fields[1] != destination.ref {
return "", true, false, fmt.Errorf("inspect remote branch %s/%s: unexpected ls-remote response", destination.remote, strings.TrimPrefix(destination.ref, "refs/heads/"))
}
if err := validObjectName(fields[0]); err != nil {
return "", true, false, fmt.Errorf("invalid remote branch tip: %w", err)
}
if _, err := r.run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, nil,
"fetch", "--no-tags", destination.remote, destination.ref); err != nil {
return "", true, false, fmt.Errorf("fetch remote branch %s/%s: %w", destination.remote, strings.TrimPrefix(destination.ref, "refs/heads/"), err)
}
fetched, err := r.run(ctx, nil, nil, "rev-parse", "--verify", "FETCH_HEAD^{commit}")
if err != nil {
return "", true, false, fmt.Errorf("resolve fetched remote branch: %w", err)
}
fetched = trimLine(fetched)
if fetched != fields[0] {
return "", true, false, errors.New("fetched remote branch changed while reconciling; retry")
}
return fetched, true, true, nil
}
// MergeBase returns the best common ancestor of two commits.
func (r *Repository) MergeBase(ctx context.Context, left, right string) (string, error) {
if err := validObjectName(left); err != nil {
return "", fmt.Errorf("invalid left commit: %w", err)
}
if err := validObjectName(right); err != nil {
return "", fmt.Errorf("invalid right commit: %w", err)
}
output, err := r.run(ctx, nil, nil, "merge-base", left, right)
if err != nil {
return "", fmt.Errorf("find merge base: %w", err)
}
return trimLine(output), 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
@@ -365,6 +545,32 @@ func (r *Repository) CommitTree(ctx context.Context, tree string, parents []stri
})
}
// ConfiguredUserIdentity returns the Git identity configured for the user in
// this repository. Both user.name and user.email must be present.
func (r *Repository) ConfiguredUserIdentity(ctx context.Context) (GitIdentity, bool, error) {
name, nameSet, err := r.configValue(ctx, "user.name")
if err != nil {
return GitIdentity{}, false, err
}
email, emailSet, err := r.configValue(ctx, "user.email")
if err != nil {
return GitIdentity{}, false, err
}
if !nameSet || !emailSet {
return GitIdentity{}, false, nil
}
identity := GitIdentity{Name: name, Email: email}
if err := validateIdentity(identity); err != nil {
return GitIdentity{}, false, fmt.Errorf("invalid configured user identity: %w", err)
}
return identity, true, nil
}
// SyntheticIdentity is Hop's controlled committer identity.
func (r *Repository) SyntheticIdentity() GitIdentity {
return r.store.identity(GitIdentity{})
}
// CommitTreeWithOptions creates a synthetic commit without invoking hooks or
// consulting the user's Git identity. It does not update any ref.
func (r *Repository) CommitTreeWithOptions(ctx context.Context, tree string, options CommitOptions) (string, error) {
@@ -450,6 +656,27 @@ func (r *Repository) ReadHiddenRef(ctx context.Context, name string) (oid string
return r.ReadRef(ctx, ref)
}
// ListHiddenRefs reads all refs below refs/hop in one Git process. Keys are
// relative to refs/hop/ (for example, "states/P_..." or "accepted").
func (r *Repository) ListHiddenRefs(ctx context.Context) (map[string]string, error) {
output, err := r.run(ctx, nil, nil, "for-each-ref", "--format=%(refname) %(objectname)", hiddenRefPrefix)
if err != nil {
return nil, err
}
refs := make(map[string]string)
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
if line == "" {
continue
}
name, oid, found := strings.Cut(line, " ")
if !found || !strings.HasPrefix(name, hiddenRefPrefix) {
return nil, fmt.Errorf("unexpected hidden ref listing %q", line)
}
refs[strings.TrimPrefix(name, hiddenRefPrefix)] = strings.TrimSpace(oid)
}
return refs, nil
}
// UpdateHiddenRef atomically updates refs/hop/name. With no expectedOld value,
// it is unconditional. Passing expectedOld performs compare-and-swap; an empty
// expected value means the ref must not exist.
@@ -1190,6 +1417,19 @@ func (s *GitStore) identity(override GitIdentity) GitIdentity {
return identity
}
func (r *Repository) configValue(ctx context.Context, key string) (string, bool, error) {
output, err := r.run(ctx, nil, nil, "config", "--get", key)
if err != nil {
var gitErr *GitError
if errors.As(err, &gitErr) && gitErr.ExitCode == 1 {
return "", false, nil
}
return "", false, fmt.Errorf("read git config %s: %w", key, err)
}
value := trimLine(output)
return value, value != "", nil
}
func (s *GitStore) now() time.Time {
if s.Now != nil {
return s.Now()
+227
View File
@@ -0,0 +1,227 @@
package hop
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// PortablePromptLedger is an explicit local export of prompt state. It is kept
// beneath the ignored .hop directory because prompts and metadata can be private.
type PortablePromptLedger struct {
SchemaVersion int `json:"schema_version"`
GeneratedAt time.Time `json:"generated_at"`
Prompts []PortablePromptRecord `json:"prompts"`
}
type PortablePromptRecord struct {
ID string `json:"id"`
TaskID string `json:"task_id,omitempty"`
AttemptID string `json:"attempt_id,omitempty"`
StateID string `json:"state_id"`
Prompt string `json:"prompt"`
AgentName string `json:"agent_name,omitempty"`
Status string `json:"status"`
ResponseSummary string `json:"response_summary,omitempty"`
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Metadata PortablePromptMetadata `json:"metadata"`
}
type PortablePromptMetadata struct {
SourceTree string `json:"source_tree"`
GitCommit string `json:"git_commit"`
AttemptHead string `json:"attempt_head,omitempty"`
AttemptHeadKind string `json:"attempt_head_kind,omitempty"`
}
type promptExportOptions struct {
AttemptIDs []string
PublishableOnly bool
Status string
ResponseSummary string
Overwrite bool
}
type suppressedPromptManifest struct {
PromptIDs []string `json:"prompt_ids"`
}
// ExportPromptLedger writes local prompt files beneath .hop/records/prompts.
// Passing attemptIDs restricts the export to those attempts.
func (s *Service) ExportPromptLedger(ctx context.Context, destinationRoot string, attemptIDs ...string) (PortablePromptLedger, error) {
return s.exportPromptLedger(ctx, destinationRoot, promptExportOptions{AttemptIDs: attemptIDs, PublishableOnly: true})
}
func (s *Service) exportPromptLedger(ctx context.Context, destinationRoot string, options promptExportOptions) (PortablePromptLedger, error) {
if destinationRoot == "" {
destinationRoot = s.Root
}
graph, err := s.Store.Graph(ctx, "")
if err != nil {
return PortablePromptLedger{}, err
}
suppressed, err := loadSuppressedPromptIDs(destinationRoot)
if err != nil {
return PortablePromptLedger{}, err
}
ledger := PortablePromptLedger{
SchemaVersion: 1,
GeneratedAt: time.Now().UTC(),
Prompts: make([]PortablePromptRecord, 0),
}
wantedAttempts := make(map[string]struct{}, len(options.AttemptIDs))
for _, attemptID := range options.AttemptIDs {
wantedAttempts[attemptID] = struct{}{}
}
lastPromptByAttempt := make(map[string]string)
excludedPromptIDs := make(map[string]struct{})
for _, row := range graph {
if row.State.Kind == StatePrompt && row.State.Prompt != "" && row.State.AttemptID != "" {
lastPromptByAttempt[row.State.AttemptID] = row.State.ID
}
}
for _, row := range graph {
state := row.State
if state.Kind != StatePrompt || state.Prompt == "" {
continue
}
if _, hidden := suppressed[state.ID]; hidden {
excludedPromptIDs[state.ID] = struct{}{}
continue
}
if _, reconciliation := decodeReconciliationConflicts(state.Summary); reconciliation || strings.HasPrefix(state.Prompt, "Resolve proposal ") {
excludedPromptIDs[state.ID] = struct{}{}
continue
}
if len(wantedAttempts) > 0 {
if _, wanted := wantedAttempts[state.AttemptID]; !wanted {
continue
}
}
if state.AttemptID == "" {
continue
}
attempt, err := s.Store.GetAttempt(ctx, state.AttemptID)
if err != nil {
return PortablePromptLedger{}, fmt.Errorf("read attempt for prompt %s: %w", state.ID, err)
}
var head State
if attempt.HeadStateID != "" {
head, err = s.Store.GetState(ctx, attempt.HeadStateID)
if err != nil {
return PortablePromptLedger{}, fmt.Errorf("read attempt head for prompt %s: %w", state.ID, err)
}
}
if options.PublishableOnly && head.Kind != StateProposal && head.Kind != StateAccepted {
continue
}
record := PortablePromptRecord{
ID: state.ID,
TaskID: state.TaskID,
AttemptID: state.AttemptID,
StateID: state.ID,
Prompt: state.Prompt,
AgentName: state.Agent,
Status: attempt.Status,
CreatedAt: state.CreatedAt,
Metadata: PortablePromptMetadata{
SourceTree: state.SourceTree,
GitCommit: state.GitCommit,
},
}
record.Metadata.AttemptHead = attempt.HeadStateID
record.Metadata.AttemptHeadKind = string(head.Kind)
if lastPromptByAttempt[state.AttemptID] == state.ID {
record.ResponseSummary = head.Summary
if options.ResponseSummary != "" {
record.ResponseSummary = options.ResponseSummary
}
}
if options.Status != "" {
record.Status = options.Status
}
if head.Kind == StateAccepted || head.Kind == StateFailed || head.Kind == StateCancelled {
completedAt := head.CreatedAt
record.CompletedAt = &completedAt
}
ledger.Prompts = append(ledger.Prompts, record)
}
outputDir := filepath.Join(destinationRoot, ".hop", "records", "prompts")
if err := os.MkdirAll(outputDir, 0o755); err != nil {
return PortablePromptLedger{}, fmt.Errorf("create prompt ledger directory: %w", err)
}
for promptID := range excludedPromptIDs {
outputPath := filepath.Join(outputDir, promptID+".json")
if err := os.Remove(outputPath); err != nil && !os.IsNotExist(err) {
return PortablePromptLedger{}, fmt.Errorf("remove excluded prompt record %s: %w", promptID, err)
}
}
for _, record := range ledger.Prompts {
outputPath := filepath.Join(outputDir, record.ID+".json")
_, statErr := os.Stat(outputPath)
if statErr == nil && !options.Overwrite {
continue // Prompt records are immutable once published.
}
if statErr != nil && !os.IsNotExist(statErr) {
return PortablePromptLedger{}, fmt.Errorf("inspect prompt record %s: %w", record.ID, statErr)
}
contents, err := json.MarshalIndent(record, "", " ")
if err != nil {
return PortablePromptLedger{}, fmt.Errorf("encode prompt record %s: %w", record.ID, err)
}
temporary, err := os.CreateTemp(outputDir, ".prompt-*.tmp")
if err != nil {
return PortablePromptLedger{}, fmt.Errorf("create prompt record %s: %w", record.ID, err)
}
temporaryPath := temporary.Name()
if _, err := temporary.Write(append(contents, '\n')); err != nil {
_ = temporary.Close()
_ = os.Remove(temporaryPath)
return PortablePromptLedger{}, fmt.Errorf("write prompt record %s: %w", record.ID, err)
}
if err := temporary.Chmod(0o644); err != nil {
_ = temporary.Close()
_ = os.Remove(temporaryPath)
return PortablePromptLedger{}, fmt.Errorf("set prompt record permissions for %s: %w", record.ID, err)
}
if err := temporary.Close(); err != nil {
_ = os.Remove(temporaryPath)
return PortablePromptLedger{}, fmt.Errorf("close prompt record %s: %w", record.ID, err)
}
if err := os.Rename(temporaryPath, outputPath); err != nil {
_ = os.Remove(temporaryPath)
return PortablePromptLedger{}, fmt.Errorf("write prompt record %s: %w", record.ID, err)
}
}
return ledger, nil
}
func loadSuppressedPromptIDs(destinationRoot string) (map[string]struct{}, error) {
path := filepath.Join(destinationRoot, ".hop", "records", "suppressed.json")
contents, err := os.ReadFile(path)
if os.IsNotExist(err) {
return map[string]struct{}{}, nil
}
if err != nil {
return nil, fmt.Errorf("read suppressed prompt manifest: %w", err)
}
var manifest suppressedPromptManifest
if err := json.Unmarshal(contents, &manifest); err != nil {
return nil, fmt.Errorf("decode suppressed prompt manifest: %w", err)
}
result := make(map[string]struct{}, len(manifest.PromptIDs))
for _, id := range manifest.PromptIDs {
if !strings.HasPrefix(id, "P_") {
return nil, fmt.Errorf("invalid suppressed prompt ID %q", id)
}
result[id] = struct{}{}
}
return result, nil
}
+13 -6
View File
@@ -81,12 +81,19 @@ type Status struct {
}
type AcceptResult struct {
State State `json:"state"`
ProposalPaths []string `json:"proposal_paths"`
CurrentPaths []string `json:"current_paths"`
Check *Check `json:"check,omitempty"`
MaterializedRoot string `json:"materialized_root,omitempty"`
Warnings []string `json:"warnings,omitempty"`
State State `json:"state"`
ProposalPaths []string `json:"proposal_paths"`
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 {
+115 -22
View File
@@ -33,8 +33,8 @@ func InitProject(ctx context.Context, path string) (*Service, State, error) {
if err != nil {
return nil, State{}, err
}
if len(trackedHopPaths) > 0 {
return nil, State{}, fmt.Errorf("cannot initialize Hop: .hop is already tracked as project source (for example %s)", trackedHopPaths[0])
for _, trackedPath := range trackedHopPaths {
return nil, State{}, fmt.Errorf("cannot initialize Hop: private .hop path is already tracked (for example %s)", trackedPath)
}
hopDir := filepath.Join(root, ".hop")
if err := os.MkdirAll(filepath.Join(hopDir, "workspaces"), 0o755); err != nil {
@@ -549,6 +549,10 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
if err != nil {
return ProposalResult{}, err
}
if strings.TrimSpace(summary) == "" {
summary = task.Title
}
summary, _ = RedactPromptSecrets(summary)
reconciliationPrompt, reconciliation, err := s.Store.ReconciliationPromptForAttempt(ctx, attempt.ID)
if err != nil {
return ProposalResult{}, err
@@ -565,12 +569,12 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
if err != nil {
return ProposalResult{}, err
}
commit, tree, err := workspaceRepo.Snapshot(ctx, "hop: proposal\n")
_, validationTree, err := workspaceRepo.Snapshot(ctx, "hop: proposal validation tree\n")
if err != nil {
return ProposalResult{}, err
}
if reconciliation {
unresolved, err := unresolvedReconciliationMarkers(ctx, workspaceRepo, tree, reconciliationConflicts)
unresolved, err := unresolvedReconciliationMarkers(ctx, workspaceRepo, validationTree, reconciliationConflicts)
if err != nil {
return ProposalResult{}, err
}
@@ -578,7 +582,7 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
return ProposalResult{}, fmt.Errorf("reconciliation still contains merge markers in: %s", strings.Join(unresolved, ", "))
}
}
checks, err := s.Store.ListChecks(ctx, attempt.ID, tree)
checks, err := s.Store.ListChecks(ctx, attempt.ID, validationTree)
if err != nil {
return ProposalResult{}, err
}
@@ -594,10 +598,10 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
return ProposalResult{}, fmt.Errorf("reconciliation must pass hop check on the resolved tree before proposing")
}
}
if strings.TrimSpace(summary) == "" {
summary = task.Title
commit, tree, err := workspaceRepo.Snapshot(ctx, "hop: proposal\n")
if err != nil {
return ProposalResult{}, err
}
summary, _ = RedactPromptSecrets(summary)
canonicalAnchorID := attempt.BaseStateID
if reconciliation && reconciliationPrompt.CanonicalAnchorID != "" {
canonicalAnchorID = reconciliationPrompt.CanonicalAnchorID
@@ -644,6 +648,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 +698,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)
@@ -699,6 +730,8 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
if err != nil {
return AcceptResult{}, err
}
proposalPaths = withoutPortableHopRecords(proposalPaths)
currentPaths = withoutPortableHopRecords(currentPaths)
finalTree := proposal.SourceTree
if current.SourceTree != base.SourceTree {
var mergeConflicts []string
@@ -710,6 +743,32 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
return AcceptResult{}, &ConflictError{Paths: mergeConflicts}
}
}
commitParents := []string{current.GitCommit}
remoteCtx, cancelRemote := context.WithTimeout(ctx, 30*time.Second)
remoteTip, _, remoteExists, err := s.Repo.FetchPushTip(remoteCtx)
cancelRemote()
if err != nil {
return AcceptResult{}, err
}
if remoteExists && remoteTip != current.GitCommit {
candidate, candidateErr := s.Repo.CommitTree(ctx, finalTree, []string{current.GitCommit}, "Hop remote reconciliation candidate\n")
if candidateErr != nil {
return AcceptResult{}, candidateErr
}
mergeBase, mergeErr := s.Repo.MergeBase(ctx, candidate, remoteTip)
if mergeErr != nil {
return AcceptResult{}, mergeErr
}
var remoteConflicts []string
finalTree, remoteConflicts, err = s.Repo.ComposeTrees(ctx, mergeBase, remoteTip, candidate)
if err != nil {
return AcceptResult{}, err
}
if len(remoteConflicts) > 0 {
return AcceptResult{}, fmt.Errorf("remote accepted-state reconciliation requires agent resolution: %s", strings.Join(remoteConflicts, ", "))
}
commitParents = []string{remoteTip, current.GitCommit}
}
acceptedID := newID("a")
message := proposal.Summary
@@ -717,7 +776,19 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
message = "Accept " + proposal.ID
}
message += fmt.Sprintf("\n\nHop-State: %s\nHop-Proposal: %s\nHop-Task: %s\nHop-Attempt: %s\n", acceptedID, proposal.ID, proposal.TaskID, proposal.AttemptID)
commit, err := s.Repo.CommitTree(ctx, finalTree, []string{current.GitCommit}, message)
author, configured, err := s.Repo.ConfiguredUserIdentity(ctx)
if err != nil {
return AcceptResult{}, err
}
if !configured {
author = s.Repo.SyntheticIdentity()
}
commit, err := s.Repo.CommitTreeWithOptions(ctx, finalTree, CommitOptions{
Message: message,
Parents: commitParents,
Author: author,
Committer: s.Repo.SyntheticIdentity(),
})
if err != nil {
return AcceptResult{}, err
}
@@ -844,9 +915,35 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
}
result.MaterializedRoot = s.Root
}
s.attachAutomaticPush(ctx, &result)
return result, nil
}
func withoutPortableHopRecords(paths []string) []string {
filtered := make([]string, 0, len(paths))
for _, path := range paths {
if strings.HasPrefix(path, ".hop/records/") {
continue
}
filtered = append(filtered, path)
}
return filtered
}
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.
@@ -1536,17 +1633,17 @@ func (s *Service) reconcileDerivedRefs(ctx context.Context, reconcileAccepted bo
if err != nil {
return err
}
refs, err := s.Repo.ListHiddenRefs(ctx)
if err != nil {
return err
}
for _, row := range rows {
state := row.State
if err := s.Repo.VerifyObjects(ctx, state.GitCommit, state.SourceTree); err != nil {
return fmt.Errorf("state %s references unavailable Git data: %w", state.ID, err)
}
name := "states/" + state.ID
commit, exists, err := s.Repo.ReadHiddenRef(ctx, name)
if err != nil {
return err
}
if !exists || commit != state.GitCommit {
if refs[name] != state.GitCommit {
if err := s.Repo.VerifyObjects(ctx, state.GitCommit, state.SourceTree); err != nil {
return fmt.Errorf("state %s references unavailable Git data: %w", state.ID, err)
}
if err := s.Repo.UpdateHiddenRef(ctx, name, state.GitCommit); err != nil {
return fmt.Errorf("repair Git pin for state %s: %w", state.ID, err)
}
@@ -1564,11 +1661,7 @@ func (s *Service) reconcileDerivedRefs(ctx context.Context, reconcileAccepted bo
if err != nil {
return err
}
commit, exists, err := s.Repo.ReadHiddenRef(ctx, acceptedRef)
if err != nil {
return err
}
if !exists || commit != head.GitCommit {
if refs[acceptedRef] != head.GitCommit {
if err := s.Repo.UpdateHiddenRef(ctx, acceptedRef, head.GitCommit); err != nil {
return fmt.Errorf("repair accepted Git ref: %w", err)
}
+255 -2
View File
@@ -73,8 +73,8 @@ func TestInitRefusesAUserTrackedHopDirectory(t *testing.T) {
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)
if err == nil || !strings.Contains(err.Error(), "private .hop path is already tracked") {
t.Fatalf("InitProject error = %v, want tracked local-runtime refusal", err)
}
contents, readErr := os.ReadFile(filepath.Join(root, ".hop", "user-owned.txt"))
if readErr != nil || string(contents) != "do not overwrite\n" {
@@ -82,6 +82,18 @@ func TestInitRefusesAUserTrackedHopDirectory(t *testing.T) {
}
}
func TestInitRefusesTrackedPromptRecords(t *testing.T) {
root := t.TempDir()
runGitTest(t, root, "init", "--quiet")
writeTestFile(t, filepath.Join(root, ".hop", "records", "prompts.json"), `{"schema_version":1,"prompts":[]}`+"\n")
runGitTest(t, root, "add", "-f", ".hop/records/prompts.json")
_, _, err := InitProject(context.Background(), root)
if err == nil || !strings.Contains(err.Error(), "private .hop path is already tracked") {
t.Fatalf("InitProject error = %v, want tracked private-path refusal", err)
}
}
func TestConcurrentFirstBeginsInitializeExactlyOnce(t *testing.T) {
t.Setenv("HOP_ROOT", "")
root := t.TempDir()
@@ -210,6 +222,94 @@ func TestConcurrentInitInExistingGitRepository(t *testing.T) {
if count := strings.Count(string(exclude), ".hop/\n"); count != 1 {
t.Fatalf("concurrent initialization wrote .hop/ exclusion %d times", count)
}
if strings.Contains(string(exclude), "!.hop/records") {
t.Fatalf("concurrent initialization exposed prompt records:\n%s", exclude)
}
}
func TestProposeDoesNotPublishPromptLedger(t *testing.T) {
ctx := context.Background()
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
result, err := service.CreatePrompt(ctx, "Keep this prompt private", "", "codex")
if err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(result.Workspace, "feature.txt"), "done\n")
proposal, err := service.Propose(ctx, result.Prompt.ID, "Land without publishing the prompt")
if err != nil {
t.Fatal(err)
}
materialized := filepath.Join(t.TempDir(), "proposal")
if _, err := service.Repo.AddDetachedWorktree(ctx, materialized, proposal.Proposal.GitCommit); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = service.Repo.RemoveWorktree(ctx, materialized, true) })
if _, err := os.Stat(filepath.Join(materialized, ".hop")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("proposal published private .hop content: %v", err)
}
}
func TestExportOmitsActiveInternalAttempt(t *testing.T) {
ctx := context.Background()
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
if _, err := service.CreatePrompt(ctx, "Read-only internal audit", "", "codex"); err != nil {
t.Fatal(err)
}
ledger, err := service.ExportPromptLedger(ctx, service.Root)
if err != nil {
t.Fatal(err)
}
if len(ledger.Prompts) != 0 {
t.Fatalf("exported active internal prompts: %#v", ledger.Prompts)
}
}
func TestExportRemovesPreviouslyPublishedReconciliationPrompt(t *testing.T) {
ctx := context.Background()
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
result, err := service.CreatePrompt(ctx, "Resolve proposal R_TEST against accepted state A_TEST", "", "codex")
if err != nil {
t.Fatal(err)
}
recordDir := filepath.Join(service.Root, ".hop", "records", "prompts")
if err := os.MkdirAll(recordDir, 0o755); err != nil {
t.Fatal(err)
}
recordPath := filepath.Join(recordDir, result.Prompt.ID+".json")
if err := os.WriteFile(recordPath, []byte("{}\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := service.ExportPromptLedger(ctx, service.Root); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(recordPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("internal reconciliation record still exists: %v", err)
}
}
func TestProposeRespectsSuppressedPromptManifest(t *testing.T) {
ctx := context.Background()
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
result, err := service.CreatePrompt(ctx, "Internal controller instruction", "", "codex")
if err != nil {
t.Fatal(err)
}
manifest := fmt.Sprintf("{\"prompt_ids\":[%q]}\n", result.Prompt.ID)
writeTestFile(t, filepath.Join(result.Workspace, ".hop", "records", "suppressed.json"), manifest)
writeTestFile(t, filepath.Join(result.Workspace, "feature.txt"), "done\n")
proposal, err := service.Propose(ctx, result.Prompt.ID, "Apply the user-facing change")
if err != nil {
t.Fatal(err)
}
materialized := filepath.Join(t.TempDir(), "proposal")
if _, err := service.Repo.AddDetachedWorktree(ctx, materialized, proposal.Proposal.GitCommit); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = service.Repo.RemoveWorktree(ctx, materialized, true) })
if _, err := os.Stat(filepath.Join(materialized, ".hop", "records", "prompts", result.Prompt.ID+".json")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("suppressed prompt was published: %v", err)
}
}
func TestOpenProjectWaitsForInitializationLock(t *testing.T) {
@@ -293,6 +393,28 @@ func TestCLIJSONWorkflow(t *testing.T) {
}
}
func TestCLIExportOmitsActivePromptRecords(t *testing.T) {
t.Setenv("HOP_ROOT", "")
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"})
runCLIJSONTest(t, []string{"begin", "--agent", "codex", "--json", "Publish prompt records"})
runCLIJSONTest(t, []string{"export", "--output", ".", "--json"})
entries, err := filepath.Glob(filepath.Join(root, ".hop", "records", "prompts", "P_*.json"))
if err != nil || len(entries) != 0 {
t.Fatalf("portable prompt records = %v, err=%v", entries, err)
}
}
func TestCLIBeginAutoInitializesAndContinuesCodexSession(t *testing.T) {
t.Setenv("HOP_ROOT", "")
root := t.TempDir()
@@ -923,6 +1045,33 @@ func TestDisjointProposalsLandAndUndoMovesForward(t *testing.T) {
}
}
func TestAcceptedCommitAttributesPromptingUserAndRetainsHopCommitter(t *testing.T) {
ctx := context.Background()
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
runGitTest(t, service.Root, "config", "user.name", "Prompting User")
runGitTest(t, service.Root, "config", "user.email", "prompter@example.com")
attempt, err := service.CreatePrompt(ctx, "Add authored change", "", "codex")
if err != nil {
t.Fatal(err)
}
writeTestFile(t, filepath.Join(attempt.Workspace, "authored.txt"), "authored\n")
proposal, err := service.Propose(ctx, attempt.Prompt.ID, "Add authored change")
if err != nil {
t.Fatal(err)
}
accepted, err := service.Accept(ctx, proposal.Proposal.ID, nil)
if err != nil {
t.Fatal(err)
}
metadata := runGitTest(t, service.Root, "show", "-s", "--format=%an <%ae>%n%cn <%ce>", accepted.State.GitCommit)
want := "Prompting User <prompter@example.com>\nHop <hop@localhost>"
if metadata != want {
t.Fatalf("accepted commit identity = %q, want %q", metadata, want)
}
}
func TestConcurrentDisjointAcceptancesSerialize(t *testing.T) {
ctx := context.Background()
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
@@ -1448,6 +1597,110 @@ 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 TestAutomaticPushReconcilesCompatibleDivergedRemote(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.Fatal("compatible remote advancement was not reconciled and pushed")
}
if got := runGitTest(t, service.Root, "--git-dir", remote, "rev-parse", "refs/heads/"+branch); got != secondLanded.State.GitCommit {
t.Fatalf("remote branch = %s, want reconciled accepted commit %s", got, secondLanded.State.GitCommit)
}
assertTreeFiles(t, service, secondLanded.State.GitCommit, map[string]string{
"base.txt": "base\n",
"first.txt": "first\n",
"local.txt": "local\n",
"remote.txt": "remote\n",
})
parents := strings.Fields(runGitTest(t, service.Root, "show", "-s", "--format=%P", secondLanded.State.GitCommit))
if len(parents) != 2 || parents[0] != remoteTip || parents[1] != firstLanded.State.GitCommit {
t.Fatalf("reconciled parents = %v, want [%s %s]", parents, remoteTip, firstLanded.State.GitCommit)
}
}
func TestLandMaterializesEmptyUnbornRootAcrossRepeatedLands(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
+21 -8
View File
@@ -11,20 +11,30 @@ param(
$ErrorActionPreference = "Stop"
$GiteaUrl = $GiteaUrl.TrimEnd("/")
switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()) {
"X64" { $arch = "amd64" }
"Arm64" { $arch = "arm64" }
default { throw "Unsupported Windows architecture: $($_)" }
$architecture = if ($env:PROCESSOR_ARCHITEW6432) {
[string]$env:PROCESSOR_ARCHITEW6432
} else {
[string]$env:PROCESSOR_ARCHITECTURE
}
switch ($architecture.ToUpperInvariant()) {
"AMD64" { $arch = "amd64" }
"ARM64" { $arch = "arm64" }
default { throw "Unsupported Windows architecture: $architecture" }
}
$asset = "hop_windows_${arch}.zip"
if ($Version -eq "latest") {
# Gitea's /releases/latest endpoint omits prereleases. Select the newest
# published release so the normal installer also works during Hop's alpha.
$releases = @(Invoke-RestMethod -Uri "$GiteaUrl/api/v1/repos/$Repository/releases?draft=false&page=1&limit=1")
$latestRelease = $releases | Select-Object -First 1
$tag = $latestRelease.tag_name
if (-not $tag) { throw "Could not determine the latest published release" }
$releaseResponse = Invoke-WebRequest -UseBasicParsing -Uri "$GiteaUrl/api/v1/repos/$Repository/releases?draft=false&page=1&limit=1"
if (-not $releaseResponse -or [string]::IsNullOrWhiteSpace($releaseResponse.Content)) {
throw "Could not determine the latest published release"
}
$releases = @($releaseResponse.Content | ConvertFrom-Json)
$latestRelease = @($releases | Where-Object { $_ -and $_.tag_name } | Select-Object -First 1)[0]
if ($null -eq $latestRelease) { throw "Could not determine the latest published release" }
$tag = [string]$latestRelease.tag_name
if ([string]::IsNullOrWhiteSpace($tag)) { throw "Could not determine the latest published release" }
} else {
$tag = if ($Version.StartsWith("v")) { $Version } else { "v$Version" }
}
@@ -85,3 +95,6 @@ try {
} finally {
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $tempDir
}
+1 -1
View File
@@ -33,7 +33,7 @@ done
if [ "$mode" = --publish ]; then
[ -f LICENSE ] || fail "LICENSE is required before publishing"
[ -n "${GITEA_TOKEN:-}" ] || fail "set a scoped GITEA_TOKEN in the local environment"
[ -n "${GITEA_TOKEN:-}" ] || fail "provide a pre-existing scoped GITEA_TOKEN through the local secret store"
[ -z "$(git status --porcelain)" ] || fail "the Git worktree must be clean"
tag=$(git describe --tags --exact-match HEAD 2>/dev/null) ||
fail "HEAD must have an exact release tag"
+21 -4
View File
@@ -58,8 +58,13 @@ uses it as the default session and identifies the runtime as `codex` unless
headers, and credential-bearing connection strings before persistence.
Read the returned `HOP_STATE_ID`, `HOP_TASK_ID`, `HOP_ATTEMPT_ID`, and workspace.
If capture fails or `hop` is unavailable, stop without project effects and
report the error.
Allow at least 120 seconds for capture. If it times out or fails transiently,
retry once with the same message, agent, and session; Hop session binding makes
the retry idempotent for the task. If the retry fails, inspect Hop's error and
locks, run `hop doctor`, and repair a safe local operational problem when
possible. Stop without project effects only when Hop remains unavailable or
unsafe after recovery; do not abandon the user's request after the first
timeout.
If Hop reports redactions, never repeat the credential in output, summaries,
commands recorded as evidence, or proposal text. Refer to its environment
@@ -71,8 +76,13 @@ 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.
- Never create, rotate, enumerate, revoke, or paste account access tokens
through a provider website or API. For release or publishing work, use only a
credential the user has already provisioned in an OS secret store or supplied
through the runtime's secret mechanism. If it is missing, stop and ask the
user to provision it; do not call a token-management endpoint.
- Give a subagent project-changing work only after creating a distinct Hop
prompt/attempt for that delegation.
- Never discard either side of concurrent work. Let Hop perform its three-way
@@ -130,7 +140,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 +159,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 -1
View File
@@ -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
+17
View File
@@ -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`
@@ -151,6 +158,15 @@ sanitizer replaces detected credential values before any durable write and
returns only typed redaction counts. Do not place the value in any later
command, summary, output, or source file.
## Account credential boundary
Hop never creates, rotates, lists, or revokes provider access tokens. Agents
must not use account token-management APIs or settings pages as a shortcut for
publishing work. A release or publishing task may use only a pre-existing
credential the user deliberately provisioned through an OS secret store or the
runtime's secret mechanism. When that credential is absent or invalid, stop and
ask the user to replace it; never mint a task-named token.
## Exit codes
| Code | Meaning |
@@ -206,6 +222,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
+5 -1
View File
@@ -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.
+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
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).
+2
View File
@@ -34,6 +34,8 @@ release.
|---|---|
| `hop accept PROPOSAL [-- COMMAND...]` | Accept internally without changing visible files |
| `hop sync` | Materialize the current accepted tree from a safe accepted ancestor |
| `hop export [--output PATH]` | Write a private local prompt export to ignored `.hop/records/prompts/` |
| `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 |
+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
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.
+5
View File
@@ -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
View File
@@ -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
+7 -3
View File
@@ -12,8 +12,11 @@ The canonical repository is `githop.xyz/GnosysLabs/Hop`.
- Configure `origin` as `https://githop.xyz/GnosysLabs/Hop.git`.
- Keep Gitea Actions disabled when the instance does not have dedicated runner
capacity; Hop's release process does not depend on it.
- Create a narrowly scoped maintainer access token that can write releases.
Export it as `GITEA_TOKEN` only for the local publish command, then unset it.
- Provision a narrowly scoped maintainer access token outside the agent session
and store it in the operating system's secret store. Agents and release
scripts may use that existing credential, but must never create, rotate, list,
or revoke account tokens through Gitea's website or API. Export it as
`GITEA_TOKEN` only for the local publish command, then unset it.
- When upgrading GoReleaser, update its pinned version and four archive
checksums in `scripts/release-local.sh` from the official checksum file.
- Permit release attachment MIME types for `.tar.gz`, `.zip`, and `.txt` in
@@ -49,7 +52,8 @@ default skill destinations while preserving unrelated user files.
injected into the binaries automatically.
2. Run `scripts/release-local.sh --snapshot` and inspect the artifacts.
3. Create a signed semantic-version tag such as `v0.1.0-alpha.1` and push it.
4. Export a locally stored, scoped token: `export GITEA_TOKEN=...`.
4. Read a pre-provisioned, locally stored scoped token into `GITEA_TOKEN`.
Do not generate a task-specific token from an agent session.
5. Run `scripts/release-local.sh --publish`. It reruns race tests, vet, installer
checks, builds six platform archives, generates `checksums.txt`, and uploads
a draft without executing build work on the Gitea server.
+8 -1
View File
@@ -38,7 +38,9 @@ public key independently. Checksum signing is listed as a launch gate in the
Release builds execute on a maintainer machine, not on the Gitea server. Use a
trusted, patched machine; keep the release token out of shell history and source
files; scope it to the Hop repository; export it only for the publish command;
and unset it immediately afterward. Releases upload as drafts for review.
and unset it immediately afterward. Releases upload as drafts for review. The
token must be provisioned by the user outside the agent session: Hop and its
agents never create, rotate, list, or revoke provider account tokens.
## Filesystem safety
@@ -46,6 +48,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
+12
View File
@@ -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