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:
+2
-2
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user