Compare commits
13 Commits
7139b9e1a7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f7cd6df597 | |||
| 4364e1d8a4 | |||
| ff62e89596 | |||
| 59b8ca7c05 | |||
| 53823bad81 | |||
| 787acb2330 | |||
| 2881f02d18 | |||
| 58fcbd5fe2 | |||
| 74ba06de91 | |||
| 074e849d84 | |||
| e5bb6a5600 | |||
| a74f420307 | |||
| 853acec229 |
+2
-1
@@ -1,6 +1,7 @@
|
||||
.DS_Store
|
||||
|
||||
# Hop's local state and isolated workspaces.
|
||||
# Hop's local state, prompt records, and isolated workspaces can contain private
|
||||
# user requests and machine paths. Never publish them through Git.
|
||||
/.hop/
|
||||
|
||||
# Local build and verification artifacts.
|
||||
|
||||
@@ -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
|
||||
@@ -213,6 +215,28 @@ func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) i
|
||||
fmt.Fprintf(stdout, "Created checkpoint %s · tree %s\n", state.ID, shortHash(state.SourceTree))
|
||||
}
|
||||
|
||||
case "export":
|
||||
fs := flag.NewFlagSet("export", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
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]")
|
||||
}
|
||||
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 private local prompt records to %s\n", len(ledger.Prompts), filepath.Join(root, ".hop", "records", "prompts"))
|
||||
}
|
||||
case "check":
|
||||
stateID, argv, ok := splitCommand(commandArgs)
|
||||
if !ok {
|
||||
|
||||
@@ -83,3 +83,25 @@ func TestDistributionDoesNotRequireGiteaActions(t *testing.T) {
|
||||
t.Fatal("release checklist still depends on a Gitea Actions workflow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowRequiresPreProvisionedCredential(t *testing.T) {
|
||||
root := filepath.Clean(filepath.Join("..", ".."))
|
||||
script, err := os.ReadFile(filepath.Join(root, "scripts", "release-local.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(script), "pre-existing scoped GITEA_TOKEN") {
|
||||
t.Fatal("release script does not require a pre-provisioned credential")
|
||||
}
|
||||
if strings.Contains(string(script), "/tokens") {
|
||||
t.Fatal("release script must not manage provider tokens")
|
||||
}
|
||||
checklist, err := os.ReadFile(filepath.Join(root, "wiki", "Release-Checklist.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(checklist), "must never create, rotate, list,") ||
|
||||
!strings.Contains(string(checklist), "or revoke account tokens") {
|
||||
t.Fatal("release checklist does not forbid agent token management")
|
||||
}
|
||||
}
|
||||
|
||||
+154
-26
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -68,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
|
||||
@@ -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 all Hop state,
|
||||
// including prompt exports, private to the local machine.
|
||||
func OpenRepository(path string) (*Repository, error) {
|
||||
return NewGitStore().Open(context.Background(), path)
|
||||
}
|
||||
@@ -255,8 +254,8 @@ 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 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 {
|
||||
@@ -267,26 +266,29 @@ 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
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
updated := strings.Join(lines, "\n")
|
||||
if updated != "" && !strings.HasSuffix(updated, "\n") {
|
||||
updated += "\n"
|
||||
}
|
||||
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 += "# Hop private local runtime and prompt records.\n.hop/\n"
|
||||
if updated == string(contents) {
|
||||
return nil
|
||||
}
|
||||
if _, err := io.WriteString(file, ".hop/\n"); err != nil {
|
||||
return fmt.Errorf("exclude .hop directory: %w", err)
|
||||
if err := os.WriteFile(excludePath, []byte(updated), 0o644); err != nil {
|
||||
return fmt.Errorf("write Git exclude file: %w", err)
|
||||
}
|
||||
return file.Sync()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Head returns the current HEAD commit. exists is false for an unborn branch.
|
||||
@@ -308,6 +310,25 @@ func (r *Repository) PushAccepted(ctx context.Context, commit string) (result Re
|
||||
if err := validObjectName(commit); err != nil {
|
||||
return result, false, fmt.Errorf("invalid accepted commit: %w", err)
|
||||
}
|
||||
destination, configured, err := r.pushDestination(ctx)
|
||||
if err != nil || !configured {
|
||||
return result, configured, err
|
||||
}
|
||||
if _, err := r.run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, nil,
|
||||
"push", "--porcelain", destination.remote, commit+":"+destination.ref); err != nil {
|
||||
return result, true, fmt.Errorf("push accepted commit to %s/%s: %w", destination.remote, strings.TrimPrefix(destination.ref, "refs/heads/"), err)
|
||||
}
|
||||
return RemotePushResult{Remote: destination.safeRemote, Ref: destination.ref, Commit: commit}, true, nil
|
||||
}
|
||||
|
||||
type pushDestination struct {
|
||||
remote string
|
||||
safeRemote string
|
||||
ref string
|
||||
}
|
||||
|
||||
func (r *Repository) pushDestination(ctx context.Context) (pushDestination, bool, error) {
|
||||
var result pushDestination
|
||||
branch, exists, err := r.optionalGitOutput(ctx, "symbolic-ref", "--quiet", "--short", "HEAD")
|
||||
if err != nil {
|
||||
return result, false, fmt.Errorf("discover automatic push branch: %w", err)
|
||||
@@ -374,13 +395,60 @@ func (r *Repository) PushAccepted(ctx context.Context, commit string) (result Re
|
||||
if err := r.validateRef(ctx, ref); err != nil {
|
||||
return result, false, fmt.Errorf("validate automatic push destination: %w", err)
|
||||
}
|
||||
|
||||
if _, err := r.run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, nil,
|
||||
"push", "--porcelain", remote, commit+":"+ref); err != nil {
|
||||
return result, true, fmt.Errorf("push accepted commit to %s/%s: %w", remote, strings.TrimPrefix(ref, "refs/heads/"), err)
|
||||
}
|
||||
safeRemote, _ := RedactPromptSecrets(remote)
|
||||
return RemotePushResult{Remote: safeRemote, Ref: ref, Commit: commit}, true, nil
|
||||
return pushDestination{remote: remote, safeRemote: safeRemote, ref: ref}, true, nil
|
||||
}
|
||||
|
||||
// FetchPushTip fetches the configured destination branch without changing the
|
||||
// user's branch, index, or working tree. exists is false for a new remote branch.
|
||||
func (r *Repository) FetchPushTip(ctx context.Context) (tip string, configured, exists bool, err error) {
|
||||
destination, configured, err := r.pushDestination(ctx)
|
||||
if err != nil || !configured {
|
||||
return "", configured, false, err
|
||||
}
|
||||
output, err := r.run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, nil,
|
||||
"ls-remote", "--refs", destination.remote, destination.ref)
|
||||
if err != nil {
|
||||
return "", true, false, fmt.Errorf("inspect remote branch %s/%s: %w", destination.remote, strings.TrimPrefix(destination.ref, "refs/heads/"), err)
|
||||
}
|
||||
fields := strings.Fields(output)
|
||||
if len(fields) == 0 {
|
||||
return "", true, false, nil
|
||||
}
|
||||
if len(fields) != 2 || fields[1] != destination.ref {
|
||||
return "", true, false, fmt.Errorf("inspect remote branch %s/%s: unexpected ls-remote response", destination.remote, strings.TrimPrefix(destination.ref, "refs/heads/"))
|
||||
}
|
||||
if err := validObjectName(fields[0]); err != nil {
|
||||
return "", true, false, fmt.Errorf("invalid remote branch tip: %w", err)
|
||||
}
|
||||
if _, err := r.run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, nil,
|
||||
"fetch", "--no-tags", destination.remote, destination.ref); err != nil {
|
||||
return "", true, false, fmt.Errorf("fetch remote branch %s/%s: %w", destination.remote, strings.TrimPrefix(destination.ref, "refs/heads/"), err)
|
||||
}
|
||||
fetched, err := r.run(ctx, nil, nil, "rev-parse", "--verify", "FETCH_HEAD^{commit}")
|
||||
if err != nil {
|
||||
return "", true, false, fmt.Errorf("resolve fetched remote branch: %w", err)
|
||||
}
|
||||
fetched = trimLine(fetched)
|
||||
if fetched != fields[0] {
|
||||
return "", true, false, errors.New("fetched remote branch changed while reconciling; retry")
|
||||
}
|
||||
return fetched, true, true, nil
|
||||
}
|
||||
|
||||
// MergeBase returns the best common ancestor of two commits.
|
||||
func (r *Repository) MergeBase(ctx context.Context, left, right string) (string, error) {
|
||||
if err := validObjectName(left); err != nil {
|
||||
return "", fmt.Errorf("invalid left commit: %w", err)
|
||||
}
|
||||
if err := validObjectName(right); err != nil {
|
||||
return "", fmt.Errorf("invalid right commit: %w", err)
|
||||
}
|
||||
output, err := r.run(ctx, nil, nil, "merge-base", left, right)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("find merge base: %w", err)
|
||||
}
|
||||
return trimLine(output), nil
|
||||
}
|
||||
|
||||
func (r *Repository) optionalGitOutput(ctx context.Context, args ...string) (string, bool, error) {
|
||||
@@ -477,6 +545,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) {
|
||||
@@ -562,6 +656,27 @@ func (r *Repository) ReadHiddenRef(ctx context.Context, name string) (oid string
|
||||
return r.ReadRef(ctx, ref)
|
||||
}
|
||||
|
||||
// ListHiddenRefs reads all refs below refs/hop in one Git process. Keys are
|
||||
// relative to refs/hop/ (for example, "states/P_..." or "accepted").
|
||||
func (r *Repository) ListHiddenRefs(ctx context.Context) (map[string]string, error) {
|
||||
output, err := r.run(ctx, nil, nil, "for-each-ref", "--format=%(refname) %(objectname)", hiddenRefPrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refs := make(map[string]string)
|
||||
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
name, oid, found := strings.Cut(line, " ")
|
||||
if !found || !strings.HasPrefix(name, hiddenRefPrefix) {
|
||||
return nil, fmt.Errorf("unexpected hidden ref listing %q", line)
|
||||
}
|
||||
refs[strings.TrimPrefix(name, hiddenRefPrefix)] = strings.TrimSpace(oid)
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
// UpdateHiddenRef atomically updates refs/hop/name. With no expectedOld value,
|
||||
// it is unconditional. Passing expectedOld performs compare-and-swap; an empty
|
||||
// expected value means the ref must not exist.
|
||||
@@ -1302,6 +1417,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()
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package hop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type promptExportOptions struct {
|
||||
AttemptIDs []string
|
||||
PublishableOnly bool
|
||||
Status string
|
||||
ResponseSummary string
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
type suppressedPromptManifest struct {
|
||||
PromptIDs []string `json:"prompt_ids"`
|
||||
}
|
||||
|
||||
// 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})
|
||||
}
|
||||
|
||||
func (s *Service) exportPromptLedger(ctx context.Context, destinationRoot string, options promptExportOptions) (PortablePromptLedger, error) {
|
||||
if destinationRoot == "" {
|
||||
destinationRoot = s.Root
|
||||
}
|
||||
graph, err := s.Store.Graph(ctx, "")
|
||||
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(options.AttemptIDs))
|
||||
for _, attemptID := range options.AttemptIDs {
|
||||
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
|
||||
}
|
||||
}
|
||||
for _, row := range graph {
|
||||
state := row.State
|
||||
if state.Kind != StatePrompt || state.Prompt == "" {
|
||||
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 {
|
||||
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,
|
||||
AttemptID: state.AttemptID,
|
||||
StateID: state.ID,
|
||||
Prompt: state.Prompt,
|
||||
AgentName: state.Agent,
|
||||
Status: attempt.Status,
|
||||
CreatedAt: state.CreatedAt,
|
||||
Metadata: PortablePromptMetadata{
|
||||
SourceTree: state.SourceTree,
|
||||
GitCommit: state.GitCommit,
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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 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)
|
||||
if statErr == nil && !options.Overwrite {
|
||||
continue // Prompt records are immutable once published.
|
||||
}
|
||||
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 {
|
||||
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("write prompt record %s: %w", record.ID, err)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
+73
-22
@@ -33,8 +33,8 @@ 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 {
|
||||
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 {
|
||||
@@ -549,6 +549,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
|
||||
@@ -565,12 +569,12 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
|
||||
if 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
|
||||
}
|
||||
@@ -578,7 +582,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
|
||||
}
|
||||
@@ -594,10 +598,10 @@ 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
|
||||
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
|
||||
@@ -726,6 +730,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
|
||||
@@ -737,6 +743,32 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
|
||||
return AcceptResult{}, &ConflictError{Paths: mergeConflicts}
|
||||
}
|
||||
}
|
||||
commitParents := []string{current.GitCommit}
|
||||
remoteCtx, cancelRemote := context.WithTimeout(ctx, 30*time.Second)
|
||||
remoteTip, _, remoteExists, err := s.Repo.FetchPushTip(remoteCtx)
|
||||
cancelRemote()
|
||||
if err != nil {
|
||||
return AcceptResult{}, err
|
||||
}
|
||||
if remoteExists && remoteTip != current.GitCommit {
|
||||
candidate, candidateErr := s.Repo.CommitTree(ctx, finalTree, []string{current.GitCommit}, "Hop remote reconciliation candidate\n")
|
||||
if candidateErr != nil {
|
||||
return AcceptResult{}, candidateErr
|
||||
}
|
||||
mergeBase, mergeErr := s.Repo.MergeBase(ctx, candidate, remoteTip)
|
||||
if mergeErr != nil {
|
||||
return AcceptResult{}, mergeErr
|
||||
}
|
||||
var remoteConflicts []string
|
||||
finalTree, remoteConflicts, err = s.Repo.ComposeTrees(ctx, mergeBase, remoteTip, candidate)
|
||||
if err != nil {
|
||||
return AcceptResult{}, err
|
||||
}
|
||||
if len(remoteConflicts) > 0 {
|
||||
return AcceptResult{}, fmt.Errorf("remote accepted-state reconciliation requires agent resolution: %s", strings.Join(remoteConflicts, ", "))
|
||||
}
|
||||
commitParents = []string{remoteTip, current.GitCommit}
|
||||
}
|
||||
|
||||
acceptedID := newID("a")
|
||||
message := proposal.Summary
|
||||
@@ -744,7 +776,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: commitParents,
|
||||
Author: author,
|
||||
Committer: s.Repo.SyntheticIdentity(),
|
||||
})
|
||||
if err != nil {
|
||||
return AcceptResult{}, err
|
||||
}
|
||||
@@ -875,6 +919,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()
|
||||
@@ -1578,17 +1633,17 @@ func (s *Service) reconcileDerivedRefs(ctx context.Context, reconcileAccepted bo
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refs, err := s.Repo.ListHiddenRefs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
state := row.State
|
||||
if err := s.Repo.VerifyObjects(ctx, state.GitCommit, state.SourceTree); err != nil {
|
||||
return fmt.Errorf("state %s references unavailable Git data: %w", state.ID, err)
|
||||
}
|
||||
name := "states/" + state.ID
|
||||
commit, exists, err := s.Repo.ReadHiddenRef(ctx, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists || commit != state.GitCommit {
|
||||
if refs[name] != state.GitCommit {
|
||||
if err := s.Repo.VerifyObjects(ctx, state.GitCommit, state.SourceTree); err != nil {
|
||||
return fmt.Errorf("state %s references unavailable Git data: %w", state.ID, err)
|
||||
}
|
||||
if err := s.Repo.UpdateHiddenRef(ctx, name, state.GitCommit); err != nil {
|
||||
return fmt.Errorf("repair Git pin for state %s: %w", state.ID, err)
|
||||
}
|
||||
@@ -1606,11 +1661,7 @@ func (s *Service) reconcileDerivedRefs(ctx context.Context, reconcileAccepted bo
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commit, exists, err := s.Repo.ReadHiddenRef(ctx, acceptedRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists || commit != head.GitCommit {
|
||||
if refs[acceptedRef] != head.GitCommit {
|
||||
if err := s.Repo.UpdateHiddenRef(ctx, acceptedRef, head.GitCommit); err != nil {
|
||||
return fmt.Errorf("repair accepted Git ref: %w", err)
|
||||
}
|
||||
|
||||
@@ -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(), "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"))
|
||||
if readErr != nil || string(contents) != "do not overwrite\n" {
|
||||
@@ -82,6 +82,18 @@ func TestInitRefusesAUserTrackedHopDirectory(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")
|
||||
|
||||
_, _, 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentFirstBeginsInitializeExactlyOnce(t *testing.T) {
|
||||
t.Setenv("HOP_ROOT", "")
|
||||
root := t.TempDir()
|
||||
@@ -210,6 +222,94 @@ func TestConcurrentInitInExistingGitRepository(t *testing.T) {
|
||||
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 TestProposeDoesNotPublishPromptLedger(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
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, "Land without publishing the prompt")
|
||||
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")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("proposal published private .hop content: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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 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"})
|
||||
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) {
|
||||
@@ -293,6 +393,28 @@ func TestCLIJSONWorkflow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIExportOmitsActivePromptRecords(t *testing.T) {
|
||||
t.Setenv("HOP_ROOT", "")
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "base.txt"), "base\n")
|
||||
previousDirectory, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(previousDirectory) })
|
||||
|
||||
runCLIJSONTest(t, []string{"init", "--json"})
|
||||
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) != 0 {
|
||||
t.Fatalf("portable prompt records = %v, err=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIBeginAutoInitializesAndContinuesCodexSession(t *testing.T) {
|
||||
t.Setenv("HOP_ROOT", "")
|
||||
root := t.TempDir()
|
||||
@@ -923,6 +1045,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 <prompter@example.com>\nHop <hop@localhost>"
|
||||
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"})
|
||||
@@ -1488,7 +1637,7 @@ func TestLandAutomaticallyPushesAcceptedCommit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticPushNeverForcesDivergedRemote(t *testing.T) {
|
||||
func TestAutomaticPushReconcilesCompatibleDivergedRemote(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
remote := filepath.Join(t.TempDir(), "remote.git")
|
||||
@@ -1534,14 +1683,21 @@ func TestAutomaticPushNeverForcesDivergedRemote(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if secondLanded.RemotePush != nil {
|
||||
t.Fatalf("diverged remote unexpectedly reported a successful push: %#v", secondLanded.RemotePush)
|
||||
if secondLanded.RemotePush == nil {
|
||||
t.Fatal("compatible remote advancement was not reconciled and pushed")
|
||||
}
|
||||
if len(secondLanded.Warnings) == 0 || !strings.Contains(strings.Join(secondLanded.Warnings, "\n"), "automatic push failed") {
|
||||
t.Fatalf("diverged remote warnings = %#v", secondLanded.Warnings)
|
||||
if got := runGitTest(t, service.Root, "--git-dir", remote, "rev-parse", "refs/heads/"+branch); got != secondLanded.State.GitCommit {
|
||||
t.Fatalf("remote branch = %s, want reconciled accepted commit %s", got, secondLanded.State.GitCommit)
|
||||
}
|
||||
if got := runGitTest(t, service.Root, "--git-dir", remote, "rev-parse", "refs/heads/"+branch); got != remoteTip {
|
||||
t.Fatalf("automatic push rewrote diverged remote from %s to %s", remoteTip, got)
|
||||
assertTreeFiles(t, service, secondLanded.State.GitCommit, map[string]string{
|
||||
"base.txt": "base\n",
|
||||
"first.txt": "first\n",
|
||||
"local.txt": "local\n",
|
||||
"remote.txt": "remote\n",
|
||||
})
|
||||
parents := strings.Fields(runGitTest(t, service.Root, "show", "-s", "--format=%P", secondLanded.State.GitCommit))
|
||||
if len(parents) != 2 || parents[0] != remoteTip || parents[1] != firstLanded.State.GitCommit {
|
||||
t.Fatalf("reconciled parents = %v, want [%s %s]", parents, remoteTip, firstLanded.State.GitCommit)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-8
@@ -11,20 +11,30 @@ param(
|
||||
$ErrorActionPreference = "Stop"
|
||||
$GiteaUrl = $GiteaUrl.TrimEnd("/")
|
||||
|
||||
switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()) {
|
||||
"X64" { $arch = "amd64" }
|
||||
"Arm64" { $arch = "arm64" }
|
||||
default { throw "Unsupported Windows architecture: $($_)" }
|
||||
$architecture = if ($env:PROCESSOR_ARCHITEW6432) {
|
||||
[string]$env:PROCESSOR_ARCHITEW6432
|
||||
} else {
|
||||
[string]$env:PROCESSOR_ARCHITECTURE
|
||||
}
|
||||
switch ($architecture.ToUpperInvariant()) {
|
||||
"AMD64" { $arch = "amd64" }
|
||||
"ARM64" { $arch = "arm64" }
|
||||
default { throw "Unsupported Windows architecture: $architecture" }
|
||||
}
|
||||
|
||||
$asset = "hop_windows_${arch}.zip"
|
||||
if ($Version -eq "latest") {
|
||||
# Gitea's /releases/latest endpoint omits prereleases. Select the newest
|
||||
# published release so the normal installer also works during Hop's alpha.
|
||||
$releases = @(Invoke-RestMethod -Uri "$GiteaUrl/api/v1/repos/$Repository/releases?draft=false&page=1&limit=1")
|
||||
$latestRelease = $releases | Select-Object -First 1
|
||||
$tag = $latestRelease.tag_name
|
||||
if (-not $tag) { throw "Could not determine the latest published release" }
|
||||
$releaseResponse = Invoke-WebRequest -UseBasicParsing -Uri "$GiteaUrl/api/v1/repos/$Repository/releases?draft=false&page=1&limit=1"
|
||||
if (-not $releaseResponse -or [string]::IsNullOrWhiteSpace($releaseResponse.Content)) {
|
||||
throw "Could not determine the latest published release"
|
||||
}
|
||||
$releases = @($releaseResponse.Content | ConvertFrom-Json)
|
||||
$latestRelease = @($releases | Where-Object { $_ -and $_.tag_name } | Select-Object -First 1)[0]
|
||||
if ($null -eq $latestRelease) { throw "Could not determine the latest published release" }
|
||||
$tag = [string]$latestRelease.tag_name
|
||||
if ([string]::IsNullOrWhiteSpace($tag)) { throw "Could not determine the latest published release" }
|
||||
} else {
|
||||
$tag = if ($Version.StartsWith("v")) { $Version } else { "v$Version" }
|
||||
}
|
||||
@@ -85,3 +95,6 @@ try {
|
||||
} finally {
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $tempDir
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ done
|
||||
|
||||
if [ "$mode" = --publish ]; then
|
||||
[ -f LICENSE ] || fail "LICENSE is required before publishing"
|
||||
[ -n "${GITEA_TOKEN:-}" ] || fail "set a scoped GITEA_TOKEN in the local environment"
|
||||
[ -n "${GITEA_TOKEN:-}" ] || fail "provide a pre-existing scoped GITEA_TOKEN through the local secret store"
|
||||
[ -z "$(git status --porcelain)" ] || fail "the Git worktree must be clean"
|
||||
tag=$(git describe --tags --exact-match HEAD 2>/dev/null) ||
|
||||
fail "HEAD must have an exact release tag"
|
||||
|
||||
+12
-2
@@ -58,8 +58,13 @@ uses it as the default session and identifies the runtime as `codex` unless
|
||||
headers, and credential-bearing connection strings before persistence.
|
||||
|
||||
Read the returned `HOP_STATE_ID`, `HOP_TASK_ID`, `HOP_ATTEMPT_ID`, and workspace.
|
||||
If capture fails or `hop` is unavailable, stop without project effects and
|
||||
report the error.
|
||||
Allow at least 120 seconds for capture. If it times out or fails transiently,
|
||||
retry once with the same message, agent, and session; Hop session binding makes
|
||||
the retry idempotent for the task. If the retry fails, inspect Hop's error and
|
||||
locks, run `hop doctor`, and repair a safe local operational problem when
|
||||
possible. Stop without project effects only when Hop remains unavailable or
|
||||
unsafe after recovery; do not abandon the user's request after the first
|
||||
timeout.
|
||||
|
||||
If Hop reports redactions, never repeat the credential in output, summaries,
|
||||
commands recorded as evidence, or proposal text. Refer to its environment
|
||||
@@ -73,6 +78,11 @@ variable or secret-manager name instead.
|
||||
- Do not run `git commit`, `git checkout`, `git switch`, `git branch`,
|
||||
`git rebase`, `git reset`, `git stash`, `git worktree`, or `git push`.
|
||||
- Do not stage files. Hop captures every nonignored workspace change.
|
||||
- Never create, rotate, enumerate, revoke, or paste account access tokens
|
||||
through a provider website or API. For release or publishing work, use only a
|
||||
credential the user has already provisioned in an OS secret store or supplied
|
||||
through the runtime's secret mechanism. If it is missing, stop and ask the
|
||||
user to provision it; do not call a token-management endpoint.
|
||||
- Give a subagent project-changing work only after creating a distinct Hop
|
||||
prompt/attempt for that delegation.
|
||||
- Never discard either side of concurrent work. Let Hop perform its three-way
|
||||
|
||||
@@ -158,6 +158,15 @@ sanitizer replaces detected credential values before any durable write and
|
||||
returns only typed redaction counts. Do not place the value in any later
|
||||
command, summary, output, or source file.
|
||||
|
||||
## Account credential boundary
|
||||
|
||||
Hop never creates, rotates, lists, or revokes provider access tokens. Agents
|
||||
must not use account token-management APIs or settings pages as a shortcut for
|
||||
publishing work. A release or publishing task may use only a pre-existing
|
||||
credential the user deliberately provisioned through an OS secret store or the
|
||||
runtime's secret mechanism. When that credential is absent or invalid, stop and
|
||||
ask the user to replace it; never mint a task-named token.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|
||||
@@ -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 a private local prompt export to ignored `.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 |
|
||||
|
||||
@@ -12,8 +12,11 @@ The canonical repository is `githop.xyz/GnosysLabs/Hop`.
|
||||
- Configure `origin` as `https://githop.xyz/GnosysLabs/Hop.git`.
|
||||
- Keep Gitea Actions disabled when the instance does not have dedicated runner
|
||||
capacity; Hop's release process does not depend on it.
|
||||
- Create a narrowly scoped maintainer access token that can write releases.
|
||||
Export it as `GITEA_TOKEN` only for the local publish command, then unset it.
|
||||
- Provision a narrowly scoped maintainer access token outside the agent session
|
||||
and store it in the operating system's secret store. Agents and release
|
||||
scripts may use that existing credential, but must never create, rotate, list,
|
||||
or revoke account tokens through Gitea's website or API. Export it as
|
||||
`GITEA_TOKEN` only for the local publish command, then unset it.
|
||||
- When upgrading GoReleaser, update its pinned version and four archive
|
||||
checksums in `scripts/release-local.sh` from the official checksum file.
|
||||
- Permit release attachment MIME types for `.tar.gz`, `.zip`, and `.txt` in
|
||||
@@ -49,7 +52,8 @@ default skill destinations while preserving unrelated user files.
|
||||
injected into the binaries automatically.
|
||||
2. Run `scripts/release-local.sh --snapshot` and inspect the artifacts.
|
||||
3. Create a signed semantic-version tag such as `v0.1.0-alpha.1` and push it.
|
||||
4. Export a locally stored, scoped token: `export GITEA_TOKEN=...`.
|
||||
4. Read a pre-provisioned, locally stored scoped token into `GITEA_TOKEN`.
|
||||
Do not generate a task-specific token from an agent session.
|
||||
5. Run `scripts/release-local.sh --publish`. It reruns race tests, vet, installer
|
||||
checks, builds six platform archives, generates `checksums.txt`, and uploads
|
||||
a draft without executing build work on the Gitea server.
|
||||
|
||||
@@ -38,7 +38,9 @@ public key independently. Checksum signing is listed as a launch gate in the
|
||||
Release builds execute on a maintainer machine, not on the Gitea server. Use a
|
||||
trusted, patched machine; keep the release token out of shell history and source
|
||||
files; scope it to the Hop repository; export it only for the publish command;
|
||||
and unset it immediately afterward. Releases upload as drafts for review.
|
||||
and unset it immediately afterward. Releases upload as drafts for review. The
|
||||
token must be provisioned by the user outside the agent session: Hop and its
|
||||
agents never create, rotate, list, or revoke provider account tokens.
|
||||
|
||||
## Filesystem safety
|
||||
|
||||
|
||||
Reference in New Issue
Block a user