Publish only user prompts with their own proposal outcomes

Hop-State: A_06FN72NMB1DRV5SV2T6YWPR
Hop-Proposal: R_06FN72K60164AV77NA2QP5G
Hop-Task: T_06FN6ZVNS25H7V4BRG743PG
Hop-Attempt: AT_06FN6ZVNS0W32KK9YQCRG88
This commit is contained in:
Hop
2026-07-11 16:37:06 -07:00
parent 074e849d84
commit 74ba06de91
74 changed files with 179 additions and 953 deletions
+95 -24
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
@@ -39,10 +40,26 @@ type PortablePromptMetadata struct {
AttemptHeadKind string `json:"attempt_head_kind,omitempty"`
}
type promptExportOptions struct {
AttemptIDs []string
PublishableOnly bool
Status string
ResponseSummary string
Overwrite bool
}
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.
func (s *Service) ExportPromptLedger(ctx context.Context, destinationRoot string, attemptIDs ...string) (PortablePromptLedger, error) {
return s.exportPromptLedger(ctx, destinationRoot, promptExportOptions{AttemptIDs: attemptIDs, PublishableOnly: true})
}
func (s *Service) exportPromptLedger(ctx context.Context, destinationRoot string, options promptExportOptions) (PortablePromptLedger, error) {
if destinationRoot == "" {
destinationRoot = s.Root
}
@@ -50,25 +67,59 @@ func (s *Service) ExportPromptLedger(ctx context.Context, destinationRoot string
if err != nil {
return PortablePromptLedger{}, err
}
suppressed, err := loadSuppressedPromptIDs(destinationRoot)
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 := make(map[string]struct{}, len(options.AttemptIDs))
for _, attemptID := range options.AttemptIDs {
wantedAttempts[attemptID] = struct{}{}
}
lastPromptByAttempt := make(map[string]string)
for _, row := range graph {
if row.State.Kind == StatePrompt && row.State.Prompt != "" && row.State.AttemptID != "" {
lastPromptByAttempt[row.State.AttemptID] = row.State.ID
}
}
for _, row := range graph {
state := row.State
if state.Kind != StatePrompt || state.Prompt == "" {
continue
}
if _, hidden := suppressed[state.ID]; hidden {
continue
}
if _, reconciliation := decodeReconciliationConflicts(state.Summary); reconciliation || strings.HasPrefix(state.Prompt, "Resolve proposal ") {
continue
}
if len(wantedAttempts) > 0 {
if _, wanted := wantedAttempts[state.AttemptID]; !wanted {
continue
}
}
if state.AttemptID == "" {
continue
}
attempt, err := s.Store.GetAttempt(ctx, state.AttemptID)
if err != nil {
return PortablePromptLedger{}, fmt.Errorf("read attempt for prompt %s: %w", state.ID, err)
}
var head State
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)
}
}
if options.PublishableOnly && head.Kind != StateProposal && head.Kind != StateAccepted {
continue
}
record := PortablePromptRecord{
ID: state.ID,
TaskID: state.TaskID,
@@ -76,33 +127,28 @@ func (s *Service) ExportPromptLedger(ctx context.Context, destinationRoot string
StateID: state.ID,
Prompt: state.Prompt,
AgentName: state.Agent,
Status: "unknown",
Status: attempt.Status,
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
}
record.Metadata.AttemptHead = attempt.HeadStateID
record.Metadata.AttemptHeadKind = string(head.Kind)
if lastPromptByAttempt[state.AttemptID] == state.ID {
record.ResponseSummary = head.Summary
if options.ResponseSummary != "" {
record.ResponseSummary = options.ResponseSummary
}
}
if options.Status != "" {
record.Status = options.Status
}
if head.Kind == StateAccepted || head.Kind == StateFailed || head.Kind == StateCancelled {
completedAt := head.CreatedAt
record.CompletedAt = &completedAt
}
ledger.Prompts = append(ledger.Prompts, record)
}
@@ -112,10 +158,12 @@ func (s *Service) ExportPromptLedger(ctx context.Context, destinationRoot string
}
for _, record := range ledger.Prompts {
outputPath := filepath.Join(outputDir, record.ID+".json")
if _, err := os.Stat(outputPath); err == nil {
_, statErr := os.Stat(outputPath)
if statErr == nil && !options.Overwrite {
continue // Prompt records are immutable once published.
} else if !os.IsNotExist(err) {
return PortablePromptLedger{}, fmt.Errorf("inspect prompt record %s: %w", record.ID, err)
}
if statErr != nil && !os.IsNotExist(statErr) {
return PortablePromptLedger{}, fmt.Errorf("inspect prompt record %s: %w", record.ID, statErr)
}
contents, err := json.MarshalIndent(record, "", " ")
if err != nil {
@@ -147,3 +195,26 @@ func (s *Service) ExportPromptLedger(ctx context.Context, destinationRoot string
}
return ledger, nil
}
func loadSuppressedPromptIDs(destinationRoot string) (map[string]struct{}, error) {
path := filepath.Join(destinationRoot, ".hop", "records", "suppressed.json")
contents, err := os.ReadFile(path)
if os.IsNotExist(err) {
return map[string]struct{}{}, nil
}
if err != nil {
return nil, fmt.Errorf("read suppressed prompt manifest: %w", err)
}
var manifest suppressedPromptManifest
if err := json.Unmarshal(contents, &manifest); err != nil {
return nil, fmt.Errorf("decode suppressed prompt manifest: %w", err)
}
result := make(map[string]struct{}, len(manifest.PromptIDs))
for _, id := range manifest.PromptIDs {
if !strings.HasPrefix(id, "P_") {
return nil, fmt.Errorf("invalid suppressed prompt ID %q", id)
}
result[id] = struct{}{}
}
return result, nil
}
+15 -22
View File
@@ -482,19 +482,6 @@ 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
@@ -564,6 +551,10 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
if err != nil {
return ProposalResult{}, err
}
if strings.TrimSpace(summary) == "" {
summary = task.Title
}
summary, _ = RedactPromptSecrets(summary)
reconciliationPrompt, reconciliation, err := s.Store.ReconciliationPromptForAttempt(ctx, attempt.ID)
if err != nil {
return ProposalResult{}, err
@@ -580,15 +571,12 @@ 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")
_, validationTree, err := workspaceRepo.Snapshot(ctx, "hop: proposal validation tree\n")
if err != nil {
return ProposalResult{}, err
}
if reconciliation {
unresolved, err := unresolvedReconciliationMarkers(ctx, workspaceRepo, tree, reconciliationConflicts)
unresolved, err := unresolvedReconciliationMarkers(ctx, workspaceRepo, validationTree, reconciliationConflicts)
if err != nil {
return ProposalResult{}, err
}
@@ -596,7 +584,7 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
return ProposalResult{}, fmt.Errorf("reconciliation still contains merge markers in: %s", strings.Join(unresolved, ", "))
}
}
checks, err := s.Store.ListChecks(ctx, attempt.ID, tree)
checks, err := s.Store.ListChecks(ctx, attempt.ID, validationTree)
if err != nil {
return ProposalResult{}, err
}
@@ -612,10 +600,15 @@ 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 strings.TrimSpace(summary) == "" {
summary = task.Title
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
}
summary, _ = RedactPromptSecrets(summary)
canonicalAnchorID := attempt.BaseStateID
if reconciliation && reconciliationPrompt.CanonicalAnchorID != "" {
canonicalAnchorID = reconciliationPrompt.CanonicalAnchorID
+42 -3
View File
@@ -259,11 +259,50 @@ func TestProposeExportsPortablePromptLedger(t *testing.T) {
if err := json.Unmarshal(contents, &record); err != nil {
t.Fatal(err)
}
if record.ID != result.Prompt.ID || record.Prompt != "Publish a portable prompt ledger" {
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)
}
}
func TestExportOmitsActiveInternalAttempt(t *testing.T) {
ctx := context.Background()
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
if _, err := service.CreatePrompt(ctx, "Read-only internal audit", "", "codex"); err != nil {
t.Fatal(err)
}
ledger, err := service.ExportPromptLedger(ctx, service.Root)
if err != nil {
t.Fatal(err)
}
if len(ledger.Prompts) != 0 {
t.Fatalf("exported active internal prompts: %#v", ledger.Prompts)
}
}
func TestProposeRespectsSuppressedPromptManifest(t *testing.T) {
ctx := context.Background()
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
result, err := service.CreatePrompt(ctx, "Internal controller instruction", "", "codex")
if err != nil {
t.Fatal(err)
}
manifest := fmt.Sprintf("{\"prompt_ids\":[%q]}\n", result.Prompt.ID)
writeTestFile(t, filepath.Join(result.Workspace, ".hop", "records", "suppressed.json"), manifest)
writeTestFile(t, filepath.Join(result.Workspace, "feature.txt"), "done\n")
proposal, err := service.Propose(ctx, result.Prompt.ID, "Apply the user-facing change")
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) })
if _, err := os.Stat(filepath.Join(materialized, ".hop", "records", "prompts", result.Prompt.ID+".json")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("suppressed prompt was published: %v", err)
}
}
func TestOpenProjectWaitsForInitializationLock(t *testing.T) {
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
root := service.Root
@@ -345,7 +384,7 @@ func TestCLIJSONWorkflow(t *testing.T) {
}
}
func TestCLIExportWritesPortablePromptRecords(t *testing.T) {
func TestCLIExportOmitsActivePromptRecords(t *testing.T) {
t.Setenv("HOP_ROOT", "")
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "base.txt"), "base\n")
@@ -362,7 +401,7 @@ func TestCLIExportWritesPortablePromptRecords(t *testing.T) {
runCLIJSONTest(t, []string{"begin", "--agent", "codex", "--json", "Publish prompt records"})
runCLIJSONTest(t, []string{"export", "--output", ".", "--json"})
entries, err := filepath.Glob(filepath.Join(root, ".hop", "records", "prompts", "P_*.json"))
if err != nil || len(entries) != 1 {
if err != nil || len(entries) != 0 {
t.Fatalf("portable prompt records = %v, err=%v", entries, err)
}
}