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
This commit is contained in:
Hop
2026-07-11 16:03:00 -07:00
parent 853acec229
commit a74f420307
6 changed files with 283 additions and 24 deletions
+25
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"runtime/debug"
"strings"
)
@@ -24,6 +25,7 @@ Usage:
hop accept STATE [-- COMMAND [ARG...]]
hop land STATE [-- COMMAND [ARG...]]
hop refresh PROPOSAL
hop export [--output PATH]
hop sync
hop push
hop status
@@ -204,6 +206,29 @@ func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) i
fmt.Fprintln(stderr, "usage: hop checkpoint STATE")
return 2
}
case "export":
fs := flag.NewFlagSet("export", flag.ContinueOnError)
fs.SetOutput(stderr)
output := fs.String("output", "", "write the portable prompt ledger 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 prompt records to %s\n", len(ledger.Prompts), filepath.Join(root, ".hop", "records", "prompts"))
}
state, err := service.Checkpoint(ctx, commandArgs[0])
if err != nil {
return printCLIError(err, jsonOutput, stdout, stderr)
+19 -18
View File
@@ -5,7 +5,6 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
@@ -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 Hop's local
// runtime private while leaving its portable record ledger versionable.
func OpenRepository(path string) (*Repository, error) {
return NewGitStore().Open(context.Background(), path)
}
@@ -255,8 +254,9 @@ 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 SQLite cache and disposable workspaces private
// without excluding .hop/records. The records directory is intentionally
// versionable so a repository can carry Hop's portable causal history.
func (r *Repository) EnsureHopExcluded() error {
infoDir := filepath.Join(r.commonGitDir, "info")
if err := os.MkdirAll(infoDir, 0o755); err != nil {
@@ -267,26 +267,27 @@ 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
continue // Migrate the legacy blanket exclusion.
}
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)
}
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 := strings.Join(lines, "\n")
if !strings.Contains(updated, ".hop/*\n") {
if updated != "" && !strings.HasSuffix(updated, "\n") {
updated += "\n"
}
updated += "# Hop local runtime; .hop/records is intentionally versioned.\n.hop/*\n!.hop/records/\n!.hop/records/**\n"
}
if _, err := io.WriteString(file, ".hop/\n"); err != nil {
return fmt.Errorf("exclude .hop directory: %w", err)
if updated == string(contents) {
return nil
}
return file.Sync()
if err := os.WriteFile(excludePath, []byte(updated), 0o644); err != nil {
return fmt.Errorf("write Git exclude file: %w", err)
}
return nil
}
// Head returns the current HEAD commit. exists is false for an unborn branch.
+149
View File
@@ -0,0 +1,149 @@
package hop
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
// PortablePromptLedger is the versioned, repository-safe subset of Hop's
// local state. It deliberately excludes SQLite, workspace paths, check output,
// and every other machine-local runtime detail.
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"`
}
// ExportPromptLedger writes immutable prompt files beneath
// .hop/records/prompts. Passing attemptIDs restricts the export to those
// attempts, which keeps concurrent proposals from writing the same files.
func (s *Service) ExportPromptLedger(ctx context.Context, destinationRoot string, attemptIDs ...string) (PortablePromptLedger, error) {
if destinationRoot == "" {
destinationRoot = s.Root
}
graph, err := s.Store.Graph(ctx, "")
if err != nil {
return PortablePromptLedger{}, err
}
ledger := PortablePromptLedger{
SchemaVersion: 1,
GeneratedAt: time.Now().UTC(),
Prompts: make([]PortablePromptRecord, 0),
}
wantedAttempts := make(map[string]struct{}, len(attemptIDs))
for _, attemptID := range attemptIDs {
wantedAttempts[attemptID] = struct{}{}
}
for _, row := range graph {
state := row.State
if state.Kind != StatePrompt || state.Prompt == "" {
continue
}
if len(wantedAttempts) > 0 {
if _, wanted := wantedAttempts[state.AttemptID]; !wanted {
continue
}
}
record := PortablePromptRecord{
ID: state.ID,
TaskID: state.TaskID,
AttemptID: state.AttemptID,
StateID: state.ID,
Prompt: state.Prompt,
AgentName: state.Agent,
Status: "unknown",
CreatedAt: state.CreatedAt,
Metadata: PortablePromptMetadata{
SourceTree: state.SourceTree,
GitCommit: state.GitCommit,
},
}
if state.AttemptID != "" {
attempt, err := s.Store.GetAttempt(ctx, state.AttemptID)
if err != nil {
return PortablePromptLedger{}, fmt.Errorf("read attempt for prompt %s: %w", state.ID, err)
}
record.Status = attempt.Status
record.Metadata.AttemptHead = attempt.HeadStateID
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)
}
record.Metadata.AttemptHeadKind = string(head.Kind)
record.ResponseSummary = head.Summary
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 _, record := range ledger.Prompts {
outputPath := filepath.Join(outputDir, record.ID+".json")
if _, err := os.Stat(outputPath); err == nil {
continue // Prompt records are immutable once published.
} else if !os.IsNotExist(err) {
return PortablePromptLedger{}, fmt.Errorf("inspect prompt record %s: %w", record.ID, err)
}
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("publish prompt record %s: %w", record.ID, err)
}
}
return ledger, nil
}
+33 -2
View File
@@ -33,8 +33,10 @@ 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 {
if !strings.HasPrefix(filepath.ToSlash(trackedPath), ".hop/records/") {
return nil, State{}, fmt.Errorf("cannot initialize Hop: local .hop runtime path is already tracked (for example %s)", trackedPath)
}
}
hopDir := filepath.Join(root, ".hop")
if err := os.MkdirAll(filepath.Join(hopDir, "workspaces"), 0o755); err != nil {
@@ -480,6 +482,19 @@ func (s *Service) checkpointAttempt(ctx context.Context, attempt Attempt) (State
}
func (s *Service) RunCheck(ctx context.Context, stateID string, argv []string) (Check, error) {
state, err := s.Store.GetState(ctx, stateID)
if err != nil {
return Check{}, err
}
if state.AttemptID != "" {
attempt, err := s.Store.GetAttempt(ctx, state.AttemptID)
if err != nil {
return Check{}, err
}
if _, err := s.ExportPromptLedger(ctx, attempt.Workspace, attempt.ID); err != nil {
return Check{}, err
}
}
checkpoint, err := s.Checkpoint(ctx, stateID)
if err != nil {
return Check{}, err
@@ -565,6 +580,9 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
if err != nil {
return ProposalResult{}, err
}
if _, err := s.ExportPromptLedger(ctx, attempt.Workspace, attempt.ID); err != nil {
return ProposalResult{}, err
}
commit, tree, err := workspaceRepo.Snapshot(ctx, "hop: proposal\n")
if err != nil {
return ProposalResult{}, err
@@ -726,6 +744,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
@@ -875,6 +895,17 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
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()
+56 -4
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(), "local .hop runtime 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,27 @@ func TestInitRefusesAUserTrackedHopDirectory(t *testing.T) {
}
}
func TestInitAllowsTrackedPortableHopRecords(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")
service, _, err := InitProject(context.Background(), root)
if err != nil {
t.Fatalf("InitProject error = %v", err)
}
t.Cleanup(func() { _ = service.Close() })
exclude, err := os.ReadFile(filepath.Join(root, ".git", "info", "exclude"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(exclude), "\n.hop/\n") || !strings.Contains(string(exclude), "!.hop/records/**") {
t.Fatalf("portable ledger is not allowed by exclude file:\n%s", exclude)
}
}
func TestConcurrentFirstBeginsInitializeExactlyOnce(t *testing.T) {
t.Setenv("HOP_ROOT", "")
root := t.TempDir()
@@ -207,8 +228,39 @@ func TestConcurrentInitInExistingGitRepository(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if count := strings.Count(string(exclude), ".hop/\n"); count != 1 {
t.Fatalf("concurrent initialization wrote .hop/ exclusion %d times", count)
if count := strings.Count(string(exclude), ".hop/*\n"); count != 1 {
t.Fatalf("concurrent initialization wrote .hop/* exclusion %d times", count)
}
}
func TestProposeExportsPortablePromptLedger(t *testing.T) {
ctx := context.Background()
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
result, err := service.CreatePrompt(ctx, "Publish a portable prompt ledger", "", "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, "Publish the ledger")
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) })
contents, err := os.ReadFile(filepath.Join(materialized, ".hop", "records", "prompts", result.Prompt.ID+".json"))
if err != nil {
t.Fatal(err)
}
var record PortablePromptRecord
if err := json.Unmarshal(contents, &record); err != nil {
t.Fatal(err)
}
if record.ID != result.Prompt.ID || record.Prompt != "Publish a portable prompt ledger" {
t.Fatalf("unexpected portable prompt record: %#v", record)
}
}
+1
View File
@@ -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 export [--output PATH]` | Write immutable, repository-safe prompt records to `.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 |