From 787acb2330d8662320477d022da37ee4029697ac Mon Sep 17 00:00:00 2001 From: Hop Date: Sat, 11 Jul 2026 19:02:42 -0700 Subject: [PATCH 1/2] Purge internal reconciliation prompts from portable records Hop-State: A_06FN83ZZSBQMQG8WN406CY8 Hop-Proposal: R_06FN83YD6CMFSR5CTE0ZWHR Hop-Task: T_06FN83DN7SMZB9WJRZNZMTR Hop-Attempt: AT_06FN83DN7VHVF5BVW6DEJ8R --- .../prompts/P_06FN83DN7R0B9X5TEV3YDC0.json | 17 ++++++++++++++ internal/hop/ledger.go | 9 ++++++++ internal/hop/service_smoke_test.go | 23 +++++++++++++++++++ 3 files changed, 49 insertions(+) create mode 100644 .hop/records/prompts/P_06FN83DN7R0B9X5TEV3YDC0.json diff --git a/.hop/records/prompts/P_06FN83DN7R0B9X5TEV3YDC0.json b/.hop/records/prompts/P_06FN83DN7R0B9X5TEV3YDC0.json new file mode 100644 index 0000000..84dee3f --- /dev/null +++ b/.hop/records/prompts/P_06FN83DN7R0B9X5TEV3YDC0.json @@ -0,0 +1,17 @@ +{ + "id": "P_06FN83DN7R0B9X5TEV3YDC0", + "task_id": "T_06FN83DN7SMZB9WJRZNZMTR", + "attempt_id": "AT_06FN83DN7VHVF5BVW6DEJ8R", + "state_id": "P_06FN83DN7R0B9X5TEV3YDC0", + "prompt": "# Files mentioned by the user:\n\n## Screenshot 2026-07-11 at 6.58.29 PM.png: /var/folders/58/b6yn15tx3jb7_6lw9m67f89m0000gn/T/TemporaryItems/NSIRD_screencaptureui_vyA0oe/Screenshot 2026-07-11 at 6.58.29 PM.png\n\n## My request for Codex:\nonce again, there are prompts here that arent mine!", + "agent_name": "codex", + "status": "proposed", + "response_summary": "Purge internal reconciliation prompts from portable records", + "created_at": "2026-07-12T02:00:12.350322Z", + "metadata": { + "source_tree": "27e94b6d19eb7e4107b127e3cec0ca41718053dc", + "git_commit": "74ba06de917236754f6146aca0e2d6e10438e455", + "attempt_head": "C_06FN83MVSDWQH772HR663SR", + "attempt_head_kind": "checkpoint" + } +} diff --git a/internal/hop/ledger.go b/internal/hop/ledger.go index 7f08b3f..e28cd5d 100644 --- a/internal/hop/ledger.go +++ b/internal/hop/ledger.go @@ -81,6 +81,7 @@ func (s *Service) exportPromptLedger(ctx context.Context, destinationRoot string 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 @@ -92,9 +93,11 @@ func (s *Service) exportPromptLedger(ctx context.Context, destinationRoot string 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 { @@ -156,6 +159,12 @@ func (s *Service) exportPromptLedger(ctx context.Context, destinationRoot string 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) diff --git a/internal/hop/service_smoke_test.go b/internal/hop/service_smoke_test.go index 240b98e..eac7244 100644 --- a/internal/hop/service_smoke_test.go +++ b/internal/hop/service_smoke_test.go @@ -279,6 +279,29 @@ func TestExportOmitsActiveInternalAttempt(t *testing.T) { } } +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"}) From 59b8ca7c05edbdd50535a61ae9ca732516ce7f31 Mon Sep 17 00:00:00 2001 From: Hop Date: Sat, 11 Jul 2026 19:29:04 -0700 Subject: [PATCH 2/2] 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 --- .../prompts/P_06FN892CVDG36AN981MGF00.json | 17 ++++++++ internal/hop/git.go | 41 ++++++++++++++++++- internal/hop/service.go | 14 ++++++- internal/hop/service_smoke_test.go | 27 ++++++++++++ 4 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 .hop/records/prompts/P_06FN892CVDG36AN981MGF00.json diff --git a/.hop/records/prompts/P_06FN892CVDG36AN981MGF00.json b/.hop/records/prompts/P_06FN892CVDG36AN981MGF00.json new file mode 100644 index 0000000..a65fde0 --- /dev/null +++ b/.hop/records/prompts/P_06FN892CVDG36AN981MGF00.json @@ -0,0 +1,17 @@ +{ + "id": "P_06FN892CVDG36AN981MGF00", + "task_id": "T_06FN892CVE210TZR2ZQQ9JG", + "attempt_id": "AT_06FN892CVDGHFG3F889Q3YR", + "state_id": "P_06FN892CVDG36AN981MGF00", + "prompt": "implement that", + "agent_name": "codex", + "status": "proposed", + "response_summary": "Attribute accepted commits to the prompting user's configured Git identity", + "created_at": "2026-07-12T02:24:52.955741Z", + "metadata": { + "source_tree": "5895f21f33fd3d6d04b764c84dfeb6e0c4738c1f", + "git_commit": "787acb2330d8662320477d022da37ee4029697ac", + "attempt_head": "C_06FN89NDDXYFZK3XRRBQPVR", + "attempt_head_kind": "checkpoint" + } +} diff --git a/internal/hop/git.go b/internal/hop/git.go index d8e059a..a9cc5a9 100644 --- a/internal/hop/git.go +++ b/internal/hop/git.go @@ -67,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 @@ -478,6 +478,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) { @@ -1303,6 +1329,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() diff --git a/internal/hop/service.go b/internal/hop/service.go index 4ddffb6..dc34e56 100644 --- a/internal/hop/service.go +++ b/internal/hop/service.go @@ -757,7 +757,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: []string{current.GitCommit}, + Author: author, + Committer: s.Repo.SyntheticIdentity(), + }) if err != nil { return AcceptResult{}, err } diff --git a/internal/hop/service_smoke_test.go b/internal/hop/service_smoke_test.go index eac7244..9a15c0c 100644 --- a/internal/hop/service_smoke_test.go +++ b/internal/hop/service_smoke_test.go @@ -1059,6 +1059,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 \nHop " + 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"})