diff --git a/internal/hop/cli.go b/internal/hop/cli.go index bd1afde..070e730 100644 --- a/internal/hop/cli.go +++ b/internal/hop/cli.go @@ -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) diff --git a/internal/hop/git.go b/internal/hop/git.go index 20bf6f6..d8e059a 100644 --- a/internal/hop/git.go +++ b/internal/hop/git.go @@ -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. diff --git a/internal/hop/ledger.go b/internal/hop/ledger.go new file mode 100644 index 0000000..51c2cd5 --- /dev/null +++ b/internal/hop/ledger.go @@ -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 +} diff --git a/internal/hop/service.go b/internal/hop/service.go index 0aed030..f7aa246 100644 --- a/internal/hop/service.go +++ b/internal/hop/service.go @@ -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() diff --git a/internal/hop/service_smoke_test.go b/internal/hop/service_smoke_test.go index 8c83725..442aba6 100644 --- a/internal/hop/service_smoke_test.go +++ b/internal/hop/service_smoke_test.go @@ -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) } } diff --git a/wiki/CLI-Reference.md b/wiki/CLI-Reference.md index c5b7e41..de15b84 100644 --- a/wiki/CLI-Reference.md +++ b/wiki/CLI-Reference.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 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 |