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
This commit is contained in:
2026-07-11 20:31:35 -07:00
committed by Hop
parent 4364e1d8a4
commit f7cd6df597
33 changed files with 40 additions and 486 deletions
+2 -2
View File
@@ -218,7 +218,7 @@ func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) i
case "export":
fs := flag.NewFlagSet("export", flag.ContinueOnError)
fs.SetOutput(stderr)
output := fs.String("output", "", "write the portable prompt ledger beneath this directory")
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]")
@@ -235,7 +235,7 @@ func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) i
if root == "" {
root = service.Root
}
fmt.Fprintf(stdout, "Exported %d prompt records to %s\n", len(ledger.Prompts), filepath.Join(root, ".hop", "records", "prompts"))
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)
+13 -12
View File
@@ -109,8 +109,8 @@ func EnsureRepository(path string) (*Repository, error) {
return NewGitStore().Ensure(context.Background(), path)
}
// OpenRepository opens the repository containing path and keeps Hop's local
// runtime private while leaving its portable record ledger versionable.
// 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)
}
@@ -254,9 +254,8 @@ func (r *Repository) GitDir() string { return r.gitDir }
// worktrees.
func (r *Repository) CommonGitDir() string { return r.commonGitDir }
// 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.
// 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 {
@@ -269,18 +268,20 @@ func (r *Repository) EnsureHopExcluded() error {
}
lines := make([]string, 0)
for _, line := range strings.Split(string(contents), "\n") {
if strings.TrimSuffix(line, "\r") == ".hop/" {
continue // Migrate the legacy blanket exclusion.
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)
}
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 updated != "" && !strings.HasSuffix(updated, "\n") {
updated += "\n"
}
updated += "# Hop private local runtime and prompt records.\n.hop/\n"
if updated == string(contents) {
return nil
}
+5 -7
View File
@@ -10,9 +10,8 @@ import (
"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.
// 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"`
@@ -52,9 +51,8 @@ type suppressedPromptManifest struct {
PromptIDs []string `json:"prompt_ids"`
}
// 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.
// 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})
}
@@ -199,7 +197,7 @@ func (s *Service) exportPromptLedger(ctx context.Context, destinationRoot string
}
if err := os.Rename(temporaryPath, outputPath); err != nil {
_ = os.Remove(temporaryPath)
return PortablePromptLedger{}, fmt.Errorf("publish prompt record %s: %w", record.ID, err)
return PortablePromptLedger{}, fmt.Errorf("write prompt record %s: %w", record.ID, err)
}
}
return ledger, nil
+1 -8
View File
@@ -34,9 +34,7 @@ func InitProject(ctx context.Context, path string) (*Service, State, error) {
return nil, State{}, err
}
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)
}
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 {
@@ -600,11 +598,6 @@ 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 _, err := s.exportPromptLedger(ctx, attempt.Workspace, promptExportOptions{
AttemptIDs: []string{attempt.ID}, Status: "proposed", ResponseSummary: summary,
}); err != nil {
return ProposalResult{}, err
}
commit, tree, err := workspaceRepo.Snapshot(ctx, "hop: proposal\n")
if err != nil {
return ProposalResult{}, err
+15 -29
View File
@@ -73,7 +73,7 @@ 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(), "local .hop runtime path is already tracked") {
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"))
@@ -82,24 +82,15 @@ func TestInitRefusesAUserTrackedHopDirectory(t *testing.T) {
}
}
func TestInitAllowsTrackedPortableHopRecords(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")
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)
_, _, 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)
}
}
@@ -228,20 +219,23 @@ 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)
}
if strings.Contains(string(exclude), "!.hop/records") {
t.Fatalf("concurrent initialization exposed prompt records:\n%s", exclude)
}
}
func TestProposeExportsPortablePromptLedger(t *testing.T) {
func TestProposeDoesNotPublishPromptLedger(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")
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, "Publish the ledger")
proposal, err := service.Propose(ctx, result.Prompt.ID, "Land without publishing the prompt")
if err != nil {
t.Fatal(err)
}
@@ -251,16 +245,8 @@ func TestProposeExportsPortablePromptLedger(t *testing.T) {
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" || record.Status != "proposed" || record.ResponseSummary != "Publish the ledger" {
t.Fatalf("unexpected portable prompt record: %#v", record)
if _, err := os.Stat(filepath.Join(materialized, ".hop")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("proposal published private .hop content: %v", err)
}
}