Run one coding agent or many. Hop captures the request, isolates every attempt, combines concurrent edits, and routes real conflicts through an agent reconciliation loop. Once checks pass, the agent lands the result automatically by default. You do not have to manage a branch or write a commit.
+ curl -fsSL https://githop.xyz/GnosysLabs/Hop/raw/branch/main/scripts/install.sh | sh
+
+
+
+
+
+
Git stays underneath for compatibility. The mechanics disappear from your workflow.
+
+
+
+
+
+
+
+
+
One instruction in. Working code out.
+
From prompt to landed code, without Git ceremony.
+
Hop runs the version-control workflow around your agents. You stay focused on the outcome.
+
+
+
+ 01
+
Prompt captured
+
Your instruction becomes durable state before any agent touches the project.
+
+
+ 02
+
Agents work in parallel
+
Each agent runs in its own Hop workspace. Several can edit the same files at the same time.
+
+
+ 03
+
Hop reconciles
+
Compatible hunks merge automatically. A real conflict starts an agent reconciliation loop, then runs the checks again. Ordinary code conflicts never get handed back to you.
+
+
+ 04
+
Code lands
+
After required checks pass, the agent lands by default and Hop synchronizes the project. No manual commit, rebase, or merge.
+
+
+
+
+
+
+
Why not just Git?
+
Git stores code. Hop runs the work.
+
Git was designed to track and exchange source history, not to coordinate autonomous coding agents. Hop keeps Git underneath, then adds an agent workflow with state, isolation, evidence, reconciliation, and automatic landing after checks.
+
+
+
+
Comparison of standard Git workflows and Hop
+
+
Work
Standard Git
Hop
+
+
+
Start
You create a branch or worktree.
Starting a task creates an isolated agent attempt.
+
Save progress
You decide when and how to commit.
Checks and follow-ups capture immutable checkpoints.
+
Parallel edits
Incompatible overlapping edits require manual conflict handling.
Hop three-way-merges compatible edits and sends unresolved conflicts through agent reconciliation.
+
Verify
Validation is conventionally attached to commits.
Hop records evidence for the exact checkpoint and requires checked reconciliation.
+
Finish
You merge, rebase, and clean up.
The agent lands checked work automatically by default.
+
+
+
+
+
+
+
+
Git compatibility without Git busywork.
+
Keep Git-compatible hosting and tooling underneath. The day-to-day branch, commit, rebase, and conflict choreography disappears.
+
+ {{if .IsSigned}}
+ Create a repository
+ {{else}}
+ Create a Hop account
+ {{end}}
+
+
+{{template "base/footer" .}}
diff --git a/deploy/postgres/init/001-create-hop-database.sql b/deploy/postgres/init/001-create-hop-database.sql
new file mode 100644
index 0000000..a7c85d5
--- /dev/null
+++ b/deploy/postgres/init/001-create-hop-database.sql
@@ -0,0 +1,2 @@
+CREATE DATABASE hop OWNER hopweb;
+
diff --git a/docs/adr/0001-gitea-as-forge-foundation.md b/docs/adr/0001-gitea-as-forge-foundation.md
new file mode 100644
index 0000000..ea88d79
--- /dev/null
+++ b/docs/adr/0001-gitea-as-forge-foundation.md
@@ -0,0 +1,69 @@
+# ADR 0001: Use Gitea as the forge foundation
+
+- Status: Accepted
+- Date: 2026-07-11
+
+## Context
+
+HopWeb needs reliable Git hosting, repository permissions, users and
+organizations, review primitives, webhooks, CI integration, packages, releases,
+and administration. Rebuilding this foundation would delay the Hop-native
+workflow and create a large security and maintenance burden.
+
+Gitea is a mature, MIT-licensed, Go-based forge with a REST API, OAuth2 provider,
+webhooks, Git LFS, package registries, and a production-capable Actions system.
+It supports custom assets, themes, templates, and repository tabs, which are
+enough to prototype a unified Hop experience without immediately carrying a
+large source fork.
+
+## Decision
+
+Use a pinned upstream Gitea release as the forge substrate. Build Hop as a
+separate control-plane service and Hop-native web experience that integrates
+with Gitea through stable APIs, webhooks, OAuth, and Git protocols.
+
+Adopt a staged customization policy:
+
+1. configuration, branding, templates, assets, and API integration;
+2. small upstreamable extension points where integration seams are missing;
+3. a shallow maintained fork only for essential Hop-native behavior.
+
+Hop domain data will live outside the Gitea schema. Cross-service operations
+will use stable IDs, idempotency keys, and explicit reconciliation rather than
+distributed database writes.
+
+## Consequences
+
+### Positive
+
+- The team can focus on Hop's state model and collaboration experience.
+- Standard Git clients and familiar forge features work from the beginning.
+- Gitea security fixes and features can be consumed from upstream.
+- A separable control plane makes the Hop model portable to another Git host.
+
+### Costs and risks
+
+- A visually unified product must compose two service boundaries.
+- Some acceptance operations need careful compensation and reconciliation.
+- Template overrides are version-sensitive.
+- A deep fork would make upstream upgrades expensive.
+- Public multi-tenant runners require a stronger isolation model than Gitea's
+ default trusted-runner assumptions.
+
+## Guardrails
+
+- Pin exact Gitea versions and test upgrades in automation.
+- Keep a patch ledger for every source-level deviation from upstream.
+- Never edit vendored Gitea code for branding alone.
+- Prefer contributing generic extension points upstream.
+- Keep the Hop control plane independently testable and deployable.
+- Threat-model Git hooks, webhooks, runner registration, job tokens, and
+ untrusted repository content before supporting public execution.
+
+## Revisit when
+
+- Gitea's architecture prevents a core Hop invariant;
+- the source patch set grows beyond a routinely rebaseable size;
+- transactional acceptance cannot be made reliable across the boundary; or
+- operating two services costs more than owning a cohesive fork.
+
diff --git a/docs/adr/0002-gitea-native-semantic-layer.md b/docs/adr/0002-gitea-native-semantic-layer.md
new file mode 100644
index 0000000..d6e5c6a
--- /dev/null
+++ b/docs/adr/0002-gitea-native-semantic-layer.md
@@ -0,0 +1,64 @@
+# ADR 0002: Preserve Gitea's design and replace its collaboration semantics
+
+- Status: Accepted
+- Date: 2026-07-11
+
+## Context
+
+HopWeb needs to become Hop-native without spending early product effort on a
+new visual shell or maintaining broad Gitea template forks. Gitea already has a
+coherent, accessible component system and familiar repository navigation.
+
+The first product distinction is conceptual rather than visual. Hop users work
+with tasks, attempts, checkpoints, proposals, evidence, and accepted outcomes.
+Gitea exposes closely related infrastructure as issues, branches, commits, pull
+requests, Actions, and merges.
+
+## Decision
+
+Keep Gitea's existing layout, typography, color, spacing, icons, responsive
+behavior, and component states unchanged.
+
+Load a small same-origin semantic adapter through Gitea's supported custom
+footer template. The adapter changes visible labels and matching accessibility
+metadata while preserving the underlying routes and behavior:
+
+| Gitea element | Hop meaning |
+| --- | --- |
+| Code | Files |
+| Issues | Tasks |
+| Branches | Attempts |
+| Commits | Checkpoints |
+| Pull Requests | Proposals |
+| Actions | Evidence |
+| Merge Pull Request | Accept Proposal |
+
+On repository home pages, add a compact `Hop workflow` summary using Gitea's
+existing sidebar classes and the live navigation links and counts already
+rendered by Gitea. Do not add custom CSS.
+
+Set the application name to `Hop`, while retaining Gitea attribution and its
+standard administrative and Git compatibility surfaces.
+
+## Consequences
+
+- Existing Gitea behavior, responsiveness, themes, and accessibility remain
+ available.
+- The first Hop experience ships without a deep template fork.
+- URLs and APIs remain Gitea-compatible even when their visible names change.
+- DOM selectors and English labels are version-sensitive and require a smoke
+ test when upgrading Gitea.
+- As Hop gains distinct task and state behavior, individual semantic elements
+ can move from adapted Gitea routes to native server-backed routes without a
+ simultaneous visual redesign.
+
+## Guardrails
+
+- Do not add CSS to the semantic adapter.
+- Never rename an element unless its behavior is meaningfully compatible with
+ the corresponding Hop concept.
+- Keep accessible names and tooltips synchronized with visible labels.
+- Scope text replacement to navigation, headings, menus, labels, breadcrumbs,
+ and buttons. Never rewrite repository content or user-authored prose.
+- Verify the adapter against the pinned Gitea version before every upgrade.
+
diff --git a/docs/gitea-hop-native.md b/docs/gitea-hop-native.md
new file mode 100644
index 0000000..54e8d16
--- /dev/null
+++ b/docs/gitea-hop-native.md
@@ -0,0 +1,64 @@
+# Hop-native Gitea semantics
+
+HopWeb keeps Gitea's existing forge interface and adds a Hop-specific public
+home page, brand mark, and semantic vocabulary for collaboration elements.
+
+## Install
+
+From the project root:
+
+```sh
+./deploy/gitea/install-hop-native.sh
+```
+
+Set `ENV_FILE` when the Compose environment is stored elsewhere:
+
+```sh
+ENV_FILE=/path/to/environment ./deploy/gitea/install-hop-native.sh
+```
+
+The installer copies the home page, brand mark, styles, and adapter into
+Gitea's persistent custom directory, sets the application name to `Hop`,
+restarts Gitea, and waits for a healthy response. Re-run it after editing an
+asset or replacing the Gitea volume.
+
+## Semantic mapping
+
+| Native route and behavior | Hop label |
+| --- | --- |
+| Repository code | Files |
+| — | Prompts (Hop-native causal record) |
+| Issues | Tasks |
+| Branches | Attempts |
+| Commits | Checkpoints |
+| Pull requests | Proposals |
+| Actions | Evidence |
+| Merge pull request | Accept proposal |
+
+The adapter updates visible text, document titles, form placeholders,
+tooltips, and ARIA labels. It scopes replacements to Gitea navigation,
+headings, menus, breadcrumbs, labels, and buttons, so repository content and
+user-authored prose remain unchanged.
+
+Repository home pages also receive a `Hop workflow` group in the existing
+Gitea sidebar. It uses Gitea's own classes, links, icons, and live counts.
+
+## Portable prompt ledger
+
+The Prompts tab reads immutable JSON records from `.hop/records/prompts/`
+directly from the repository branch being reviewed. Hop writes the relevant
+prompt records automatically when it creates a proposal; `make hop-records`
+exports the complete local history on demand. The records include prompts and
+safe review metadata, but deliberately exclude the local Hop database,
+workspaces, check output, and machine paths.
+
+## Upgrade check
+
+After changing the pinned Gitea version:
+
+1. run the installer;
+2. confirm Prompts, Tasks, Proposals, and Evidence in repository navigation;
+3. confirm Attempts and Checkpoints in the Files view;
+4. confirm proposal creation and acceptance labels;
+5. inspect browser console errors; and
+6. test a narrow viewport and keyboard navigation.
diff --git a/docs/local-development.md b/docs/local-development.md
new file mode 100644
index 0000000..0270d47
--- /dev/null
+++ b/docs/local-development.md
@@ -0,0 +1,64 @@
+# Local development
+
+## Start the foundation
+
+Requirements: Docker with Compose v2, Go 1.26 or newer for host-side tests, and
+ports 3000, 8080, and 2222 available.
+
+```sh
+cp .env.example .env
+make up
+```
+
+Open Gitea at . The Hop control plane exposes health at
+ and readiness at
+.
+
+Gitea self-registration is enabled for this local stack. After creating the
+first account, create an access token in Gitea and set `GITEA_API_TOKEN` in
+`.env`, then restart the control plane:
+
+```sh
+docker compose up -d --force-recreate control-plane
+```
+
+## Link a repository
+
+Create a repository in Gitea, then ask Hop to verify it through the Gitea API
+and record the link:
+
+```sh
+set -a
+. ./.env
+set +a
+
+curl --fail-with-body \
+ -H "Authorization: Bearer $HOP_ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"owner":"YOUR_USER","name":"YOUR_REPOSITORY"}' \
+ http://localhost:8080/api/v1/repositories/link
+```
+
+## Configure a webhook
+
+In the Gitea repository, create a Gitea webhook with:
+
+- target URL: `http://control-plane:8080/api/v1/gitea/webhooks`
+- content type: `application/json`
+- secret: the value of `HOP_GITEA_WEBHOOK_SECRET` in `.env`
+- events: all events
+
+The control plane verifies `X-Gitea-Signature` against the raw request body,
+records each delivery exactly once, and refreshes repository metadata from the
+signed payload.
+
+## Useful commands
+
+```sh
+make test
+make config
+make logs
+make down
+```
+
+To destroy local data as well as containers, run `docker compose down -v`.
diff --git a/docs/product-blueprint.md b/docs/product-blueprint.md
index 44b370c..6797d01 100644
--- a/docs/product-blueprint.md
+++ b/docs/product-blueprint.md
@@ -1,549 +1,170 @@
-# Hop: prompt-native version control for coding agents
+# HopWeb product blueprint
-Concept blueprint v0.1 — July 11, 2026
+## Product position
-## One-sentence thesis
+HopWeb is not "Gitea with AI buttons." It is a forge whose source of truth is a
+causal record of intent and verified outcomes, with Git underneath for universal
+tool compatibility.
-Hop makes every prompt a durable, versioned project state, then provides the isolation, evidence, reconciliation, and policy needed to move selected prompt-states into the project’s accepted lineage.
+The core screen should answer four questions without reconstructing them from a
+commit history:
-The clean category distinction is:
+1. What outcome was requested?
+2. Who or what tried to produce it?
+3. What exact result was checked, and what passed?
+4. Which outcome became shared truth, and why?
-- Agent orchestrators decide **who does the work**.
-- Git records **what tree was saved**.
-- Hop records **every intentful state**, then governs which of those states become accepted project history.
+## Concept mapping
-The initial product should be a Git-compatible change-control plane for coding agents, not a from-scratch replacement for Git storage or remotes.
+| Conventional forge | HopWeb primary concept | Notes |
+| --- | --- | --- |
+| Issue | Task | Outcome plus its evolving prompt history |
+| Branch/worktree | Attempt | Isolated work by a human, agent, or team |
+| Commit | Checkpoint/state | Immutable tree plus causal parents and metadata |
+| CI status | Evidence | Bound to the exact checkpoint that was tested |
+| Pull request | Proposal | Candidate outcome with intent, evidence, and diff |
+| Merge | Accept/land | Advances the accepted state after validation |
+| Commit graph | State graph | Includes prompts, checkpoints, proposals, and outcomes |
+| Contributor | Actor | Human or agent identity with attribution and policy |
-## The user and the job
+Git branches, commits, and pull requests remain available as compatibility
+views. They should not dictate the Hop-native experience.
-The first user is a solo technical founder, staff engineer, or tech lead supervising two to ten concurrent coding-agent tasks in one repository. They already use Git and GitHub, but they manually create worktrees, remember which agent owns what, inspect diffs, rebase stale results, run tests, clean up branches, and reconstruct why a change was made.
+## System shape
-Their job-to-be-done is:
-
-> When I delegate several changes to coding agents, help me run them safely in parallel, understand their live impact, and land or undo each result as one coherent unit—without manually coordinating branches, worktrees, context, and validation.
-
-The first market should favor mixed-agent users. A developer using Codex, Claude Code, Cursor, and future agents has no neutral system of record spanning all of them.
-
-## What Hop is—and is not
-
-Hop is:
-
-- A universal prompt-state graph, with tasks and proposals as useful views over it.
-- An isolation and coordination layer for parallel work.
-- An acceptance gateway that validates the final composed state.
-- A provenance system connecting intent, execution, evidence, and resulting code.
-- A structured project-knowledge system whose claims are sourced and versioned.
-- A Git-compatible bridge to existing commits, remotes, pull requests, and CI.
-
-Hop is not initially:
-
-- A new content-addressed object store.
-- A replacement for GitHub or pull requests.
-- A general-purpose agent orchestrator.
-- A promise to solve arbitrary semantic merge conflicts with an LLM.
-- A distributed multi-repository database.
-- A giant autonomous project wiki.
-
-## The foundational model: every prompt is a state
-
-Hop should not relegate prompts to metadata attached to code snapshots. Every prompt creates a durable state in the universal project graph, including prompts that change no files, fail, are cancelled, or only refine the project’s intent.
-
-Acceptance does not make something a state. It creates another typed state in the same graph and determines what enters the canonical project lineage.
-
-```text
-A184 accepted project state
-├── P185 prompt: “Add password reset”
-│ └── C186 workspace checkpoint
-│ └── P187 prompt: “Use Resend, not SendGrid”
-│ └── R188 proposed result
-└── P189 prompt: “Redesign account settings”
- └── R190 proposed result
-
-A184 + R188 ──accept──> A191
+```mermaid
+flowchart LR
+ UI["Hop-native web UI"] --> API["Hop control-plane API"]
+ API --> HDB["Hop state and evidence store"]
+ API --> ORCH["Attempt orchestrator"]
+ ORCH --> RUN["Isolated agent runners"]
+ API --> GAPI["Gitea API and webhooks"]
+ GAPI --> GIT["Git repositories"]
+ GAPI --> FORGE["Identity, permissions, issues, Actions, packages"]
+ RUN --> GIT
+ RUN --> HDB
```
-The prompt state is the exact project, workspace, and delivery context immediately after Hop durably accepts the instruction but before the agent causes any effects. Checkpoint and proposal states record its effects. This makes causality exact: no change can appear inside the state that supposedly caused it.
+### Ownership boundary
-Two Hop states may reference the exact same source tree and still be distinct because their kind, prompt, context, evidence, occurrence, or parentage differs. This is the crucial departure from Git: the complete thing being versioned is not just source content, but **project intent plus project content plus the causal evidence connecting them**.
+**Gitea owns** repositories, Git protocol, blob storage, users, organizations,
+teams, baseline authorization, webhooks, Actions, releases, and packages.
-Tasks, attempts, and proposals remain useful, but they become views over the state graph:
+**Hop owns** tasks, prompts, attempts, state graph edges, agent sessions,
+checkpoints, evidence, proposals, acceptance policy, and orchestration.
-- A **task** is a named subgraph describing one desired outcome.
-- An **attempt** is a path through prompt-states produced by one agent approach.
-- A **proposal** is a frozen descendant state containing the result of one or more prompts.
-- “Try another approach” creates a sibling path from a chosen state.
-- A follow-up prompt creates a child state, even if its source tree is unchanged.
+**The Hop UI composes both.** A user should experience one product even when the
+data comes from two services.
-This model preserves every turn while still supporting abandoned work, competing solutions, replay, and clean canonical history.
+## Integration strategy
-## The fundamental objects
+Start from an unmodified, pinned Gitea release and integrate through its API,
+webhooks, OAuth, custom templates, and custom assets. This lets us validate the
+workflow while preserving straightforward upstream upgrades.
-### Project
+Maintain a shallow source fork only when a required experience cannot be built
+cleanly through those seams—for example, a first-class state graph inside every
+repository route or authorization decisions that must be transactional with Git
+writes. Every fork patch must be small, isolated, tested, and documented with an
+upstream issue or a reason it is intentionally Hop-specific.
-The authority boundary containing accepted state, policies, component definitions, tasks, proposals, and knowledge.
+Do not put Hop's domain tables directly into Gitea's database. Services may
+share stable identifiers, but each service owns its schema and migrations.
-### State
+## Primary experience
-The primary Hop object. Hop uses one typed, immutable Merkle DAG. Every instruction becomes a `prompt` state before it is delivered. Work caused by that prompt produces descendant `checkpoint` and `proposal` states. Acceptance, replay, conflict resolution, and undo also produce states rather than rewriting old ones.
+### Repository home
-Initial state kinds:
+Lead with active tasks, recent accepted outcomes, running attempts, and failing
+evidence. Files and commits remain one click away, not the default narrative.
-- `prompt` — an instruction plus the exact context in which it was delivered
-- `checkpoint` — immutable workspace progress at a tool or message boundary
-- `proposal` — a frozen candidate outcome with evidence
-- `accepted` — a canonical project revision and its acceptance receipt
-- `replay`, `resolution`, `revert`, and `restore` — later specialized transitions
+### Task page
-A materialized state manifest can contain:
+Show the current requested outcome, prompt history, constraints, linked issues,
+all attempts, and the policy required for acceptance.
-```text
-State {
- id
- occurrence_id
- kind
- parents[]: { role, state_id }
- canonical_anchor
- roots {
- project_tree
- knowledge
- policy
- conversation
- execution_context
- evidence
- }
- cause { prompt, attachments, actor, recipient }
- task_id
- attempt_id
- created_at
-}
-```
+### Attempt page
-Parent edges have roles such as `run_parent`, `canonical_anchor`, `canonical_parent`, `proposal_parent`, and `caused_by`. This lets Hop derive a linear canonical history while tasks, subagents, alternatives, and conversations branch freely.
+Stream agent activity and expose the evolving state graph, working diff,
+checkpoints, check results, cost/runtime metadata, and interventions from humans.
-All roots point to immutable, content-addressed data. `occurrence_id` keeps two identical prompts sent twice as distinct historical occurrences. Structural sharing makes a no-code prompt cheap: it adds a prompt blob, a conversation node, and a small manifest while reusing the complete project, knowledge, and policy roots.
+### Proposal review
-A mutable `accepted_head` pointer identifies the current canonical `accepted` state. Task and attempt heads use separate pointers. Prompt creation never moves `accepted_head`.
-
-### Task
-
-A named subgraph of related prompt-states representing one stable desired outcome, its acceptance criteria, competing attempts, and status. A prompt is never hidden inside the task record; it always has its own state identity.
-
-### Attempt
-
-One path through a task’s state subgraph, produced by a human or agent following a particular approach. It records the base state, agent and harness identity, environment fingerprint, declared scope, and outcome.
-
-### Claim
-
-An expiring declaration that an attempt expects to read or change a resource. Selectors may refer to a component, file, symbol, API route, database schema, package, generated artifact, or other named contract.
-
-Claims should normally be advisory warnings. Hard locks should be reserved for genuinely non-mergeable resources.
-
-### Proposal
-
-A frozen descendant state containing the candidate result of an attempt: source and knowledge roots, computed delta, summary, observed impact, risk signals, evidence, and proposed knowledge changes. Further feedback creates a new prompt and a new proposal descendant; it never mutates the old proposal.
-
-### Evidence
-
-A test, lint, build, review, benchmark, or policy result bound to an exact proposal tree and environment. Evidence becomes stale if the proposal is refreshed or composed with other work.
+Review by outcome first: summary, behavioral changes, evidence, risks, then file
+diff. Let reviewers compare multiple proposals for the same task side by side.
### Acceptance
-The immutable transaction that evaluates a proposal against the current accepted head, materializes the final combined roots, runs required policy checks, and creates a new `accepted` state.
+Acceptance is a named product event. It advances the repository's accepted Hop
+state, records the actor and policy decision, and materializes the corresponding
+Git change atomically or fails without advancing either view.
+
+## Initial domain model
+
+- `repositories`: link a Hop repository to its Gitea repository identifier.
+- `tasks`: requested outcome, status, creator, and acceptance policy.
+- `states`: immutable typed nodes with tree identity, digest, actor, and time.
+- `state_edges`: ordered, role-labelled causal relationships between states.
+- `attempts`: isolated execution contexts associated with a task and base state.
+- `checks`: commands or policies evaluated against an immutable checkpoint.
+- `evidence`: normalized results, logs, artifacts, and provenance for checks.
+- `proposals`: frozen candidate outcomes and their review status.
+- `acceptances`: atomic record linking a proposal, accepted state, and Git ref.
+- `actors`: human, service, and agent identities plus attestable metadata.
+
+Use opaque, sortable IDs and repository-scoped uniqueness. Treat all mutable
+status fields as projections of an append-only event history where auditability
+matters.
+
+## Delivery sequence
+
+### Milestone 0 — foundation
+
+- Pin and run Gitea locally with PostgreSQL and object storage boundaries.
+- Add a Hop control-plane service and database.
+- Establish Gitea OAuth/API authentication and webhook verification.
+- Link Hop repositories to Gitea repositories.
+- Define upgrade policy and an automated upstream rebase test.
+
+### Milestone 1 — task to accepted outcome
+
+- Create tasks and capture prompt states.
+- Launch isolated attempts through a runner interface.
+- Ingest checkpoints and checkpoint-bound check evidence.
+- Freeze proposals and display their diffs.
+- Accept a validated proposal and advance the Git default branch.
+
+### Milestone 2 — native collaboration
+
+- Multiple concurrent attempts and proposal comparison.
+- Human intervention and prompt follow-ups within an attempt.
+- Review policies, approvals, protected accepted state, and audit history.
+- Notifications and task/attempt dashboards.
+
+### Milestone 3 — ecosystem
+
+- Public repositories and discoverability.
+- Agent marketplace and reusable execution profiles.
+- Organization policy, billing, usage controls, and hosted runners.
+- GitHub/GitLab import and bidirectional compatibility workflows.
+
+## Non-negotiable invariants
+
+- A prompt is captured before project effects begin.
+- Evidence always identifies the immutable checkpoint it evaluated.
+- A proposal never changes after it is frozen.
+- Acceptance never silently includes unrelated workspace effects.
+- Human and agent actions are distinguishable and attributable.
+- Git operations remain possible with standard Git clients.
+- Hop state can be exported without requiring the HopWeb service forever.
+
+## Decisions still to make
+
+- Hosted SaaS, self-hosted distribution, or both for the first release.
+- Runner isolation boundary: container, microVM, or pluggable backends.
+- Whether accepted Hop state is encoded into Git refs/notes in addition to the
+ Hop database for portability and disaster recovery.
+- The first authentication path: Gitea as OAuth provider or a shared external
+ identity provider.
+- Product name: retain HopWeb internally or choose a public forge name.
-The accepted state has a `canonical_parent` edge to the previous accepted state and a `proposal_parent` edge to the reconciled proposal. Even when no replay was necessary, it can reuse every proposal root and add only a tiny acceptance manifest. The canonical pointer advances with compare-and-swap; the prompt and proposal lineages are never rebased away or rewritten.
-
-### Knowledge fact
-
-A typed, sourced, versioned assertion about the project. Each fact has provenance, a state from which it is valid, a status, and optionally supersession or expiration information.
-
-### Event
-
-An append-only audit record such as task creation, claim change, instruction, proposal submission, evidence attachment, acceptance, rejection, refresh, or undo.
-
-## The state transition protocol
-
-Prompt creation and canonical acceptance are separate transitions:
-
-1. A user submits a prompt while viewing run state `S`.
-2. Hop quiesces the workspace at a tool/message boundary and seals any preceding edits as checkpoint `C`.
-3. Hop creates immutable prompt state `P`, with `run_parent=C` and a `canonical_anchor` to the accepted state visible at creation.
-4. Hop compare-and-swaps the run head to `P`; only then may it deliver the prompt to the agent.
-5. The agent receives `HOP_STATE_ID=P`, works in an isolated workspace, and declares expiring claims.
-6. Work becomes descendant checkpoints and eventually frozen proposal `R`; failure or cancellation also produces an addressable terminal state.
-7. By default the agent immediately nominates `R` for automatic acceptance; an explicit review-first request pauses here. Hop reconciles it against the current accepted head in a temporary integration workspace.
-8. Hop evaluates textual overlap, symbol and contract risk, policies, and required tests on the exact final roots.
-9. Hop creates accepted state `A`, linked to both the previous accepted state and `R`, then atomically advances `accepted_head` with compare-and-swap.
-10. In Desktop mode, Hop materializes `A` into the selected visible root only when that root still matches accepted history; it preserves HEAD and the real Git index and blocks rather than overwriting divergence.
-11. Every prompt, checkpoint, proposal, and acceptance remains addressable regardless of later outcomes.
-
-The important invariants are:
-
-- **Every instruction delivered to an agent or subagent has an immutable prompt-state ID first.**
-- **Effects caused by a prompt exist only in descendant states.**
-- **A prompt-state is never destroyed because its result was rejected, superseded, or failed.**
-- **Checks must pass on the exact state that will actually become accepted**, not merely on the attempt’s original source tree.
-
-## Conflict handling
-
-Hop should distinguish several kinds of interaction:
-
-1. **Claim overlap** — two active attempts announce overlapping intent. This is an early warning, not proof of conflict.
-2. **Textual conflict** — patches cannot be applied mechanically.
-3. **Structural conflict** — changes touch the same symbol, API, schema, dependency, migration sequence, generated file, or contract.
-4. **Behavioral risk** — different files compose cleanly but may be incompatible in behavior.
-5. **Validation failure** — the integrated tree builds or tests incorrectly.
-
-Non-overlapping files are not sufficient evidence of compatibility. Database migrations, API callers and implementations, dependency upgrades, generated outputs, and shared invariants often conflict across files.
-
-“Semantic merge” should not mean blindly rewriting conflicting code. Hop first
-uses deterministic Git three-way merging. When genuine hunks remain, the agent
-automatically enters a provenance-linked **refresh** workspace:
-
-> Give the original agent the newer accepted state, the intervening task
-> summaries, the conflict-marker tree, and its original intent; require it to
-> resolve, validate, repropose, and land without asking the human to coordinate
-> an ordinary code merge.
-
-For many agent tasks, replaying or regenerating against the latest state is cheaper and safer than preserving every old hunk.
-
-## Acceptance policy
-
-Automatic acceptance should require all of the following:
-
-- The proposal applies to the current accepted state or refreshes cleanly.
-- No protected path or contract requires manual review.
-- No active exclusive claim is violated.
-- Required deterministic checks pass on the final tree.
-- The proposal remains inside configured risk thresholds.
-- Any required human or policy approval is present.
-
-For ordinary local agent work, the task prompt supplies the acceptance authority
-and Hop should auto-accept after deterministic checks pass. A separate landing
-prompt is unnecessary ceremony. Human approval remains available when the user
-explicitly requests review first or project policy protects the affected scope.
-
-Model-generated compatibility analysis can explain risk and select extra checks, but it should not be the sole authority for acceptance.
-
-## Undo and history
-
-Hop can make undo feel simple without pretending collaborative history is linear.
-
-- If no later accepted state depends on a local change, a pointer rewind may be safe.
-- Once other work builds on it, `hop undo ` should create and validate a compensating proposal against the current state.
-- The original acceptance remains in the ledger; the undo is a new accepted transition.
-
-The primary history view should show prompt-states grouped into tasks, not commits or hashes:
-
-```text
-P185 Add password reset emails
- └─ P186 Use Resend, not SendGrid
- └─ R187 Codex · 14 files · 8 checks passed
- ✓ accepted as A188
-
-P189 Redesign account settings
- └─ R190 Claude · integrated with A188
- ✓ accepted as A191
-```
-
-Raw Git commits and hashes remain available for interoperability and debugging.
-
-## Project knowledge
-
-`PROJECT.md` should be a generated briefing, not a shared scratchpad that every agent rewrites.
-
-Useful fact types include:
-
-- purpose
-- invariant
-- architectural decision
-- component responsibility
-- public contract
-- capability
-- operational constraint
-- known issue
-- deprecated behavior
-
-Every accepted fact should include:
-
-- the task and proposal that introduced it
-- supporting code locations or evidence
-- the accepted state from which it is valid
-- `accepted`, `superseded`, or `invalidated` status
-- optional confidence and expiration rules
-
-Hop should expose two views:
-
-- `hop context --json` for complete machine-readable context
-- generated `PROJECT.md` for a concise human and agent briefing
-
-Dynamic active work belongs in `hop board` and `hop context`, not in a file copied into every workspace. This prevents unrelated tasks from dirtying their trees whenever another task changes status.
-
-## Human and agent experience
-
-The default human-facing loop should be four verbs, with Review as an opt-in
-pause before acceptance:
-
-```text
-Ask → Work → Accept → Undo
- ↑
- optional Review
-```
-
-A plausible CLI:
-
-```bash
-hop init
-hop begin --agent codex --heredoc # agent first action in Codex Desktop
-hop run --agent codex "Add password reset emails"
-hop run --agent claude "Redesign account settings"
-hop prompt P185 "Use Resend, not SendGrid"
-hop board
-hop graph
-hop state show P186
-hop show
-hop diff
-hop compare # compare multiple attempts
-hop land R187
-hop refresh R187
-hop undo
-hop why src/auth/reset.ts
-```
-
-The machine-facing protocol should be explicit and JSON-first:
-
-```bash
-hop context --json
-hop state inspect "$HOP_STATE_ID" --json
-hop task inspect "$HOP_TASK_ID" --json
-hop claim add component:auth --mode write --ttl 20m --json
-hop claim add api:'POST /password-reset' --mode write --ttl 20m --json
-hop progress --summary-file progress.json --json
-hop state checkpoint --manifest checkpoint.json --json
-hop propose --manifest result.json --json
-```
-
-Hop supports two capture strengths. In Codex Desktop, the skill makes `hop begin` the agent's first project action, providing a practical pre-effect boundary without changing the user's prompt-box workflow. A trusted prompt hook or orchestrator can durably create the prompt-state, task/attempt grouping, and workspace before model delivery, then inject `HOP_STATE_ID`, `HOP_TASK_ID`, `HOP_ATTEMPT_ID`, and the workspace path. The skill teaches and applies the workflow; a hook or process boundary can enforce the stronger pre-delivery invariant.
-
-## The MVP
-
-The smallest complete product is a **parallel-agent landing queue** backed by Git.
-
-### It must do
-
-1. Initialize Hop inside an existing Git repository.
-2. Turn every submitted prompt into a durable child state before project effects, with a pre-delivery mode where the harness supports it.
-3. Create a task/attempt grouping and isolated Git worktree from the prompt’s parent state.
-4. Attach Codex Desktop through a skill/session binding and support thin controller adapters for other agents.
-5. Capture immutable checkpoint, proposal, failure, and cancellation states beneath each prompt-state.
-6. Record claims, actual changed files, agent/environment identity, and status.
-7. Show the universal state graph and detect claim/file overlap.
-8. Nominate a sealed state with its structured summary, commands, and test evidence.
-9. Materialize it on the current accepted head in an integration workspace.
-10. Run configured checks on that exact final state.
-11. Auto-accept successful ordinary local work without another user prompt,
- then advance the accepted head atomically, export a normal Git-compatible
- commit, safely synchronize the visible root, and non-force push an existing
- unambiguous Git upstream.
-12. Undo an accepted state through a compensating prompt/integration state.
-13. Generate a small `PROJECT.md` from accepted facts.
-14. Install a vendor-neutral agent skill and expose stable JSON CLI output.
-15. Redact credentials before prompt text reaches state digests, titles, events,
- validation evidence, or any durable database/write-ahead-log page.
-
-### It should not do yet
-
-- distributed Hop remotes
-- multi-repository atomic tasks
-- autonomous task decomposition
-- general semantic merging
-- organization identity and permissions
-- a full hosted dashboard
-- vector-database project memory
-- symbol-level locking for every language
-- bespoke content storage
-
-### Pragmatic implementation
-
-- Use Git blobs, trees, hidden refs, and worktrees as the source-content and compatibility substrate.
-- Store the Hop state graph, prompt envelopes, lifecycle events, and coordination data in SQLite with WAL mode.
-- Keep an append-only event table and derive task/proposal views from it.
-- Give each historical occurrence a stable ULID while content-addressing immutable state manifests and every source, knowledge, thread, evidence, and artifact root.
-- Deduplicate roots so a prompt that changes one file—or no files—adds only a small manifest plus new chunks rather than copying the repository.
-- Use a temporary integration worktree for final checks.
-- Emit an ordinary Git commit for each accepted source transition, with `Hop-State`, `Hop-Task`, and `Hop-Attempt` trailers.
-- Preserve metadata in a Hop receipt referenced by an internal Git ref. Push
- accepted Git commits to existing upstream branches now; distributed Hop-state
- synchronization remains later work.
-- Shell out to the installed Git CLI before taking on the complexity of a custom Git implementation.
-- Assign each attempt isolated temp/cache paths and, where possible, a port range; filesystem isolation alone does not isolate development servers and local services.
-
-## Suggested six-week build sequence
-
-### Week 1: ledger and state
-
-- `hop init`, state IDs, prompt envelopes, accepted-state pointer, SQLite event store
-- Git worktree creation and cleanup
-- `hop board`, `hop graph`, `hop state show`, stable JSON output
-
-### Week 2: attempts and proposals
-
-- Codex and Claude adapters
-- skill-driven pre-effect capture, controller-grade pre-delivery capture, checkpoint states, and proposal states
-- claims with leases
-- proposal nomination and receipts that reference sealed states
-
-### Week 3: integration
-
-- temporary integration workspace
-- three-way application onto current head
-- configured checks and evidence binding
-- atomic land with Git export
-
-### Week 4: coordination
-
-- live file-footprint observation at command boundaries
-- overlap and stale-base warnings
-- refresh packets and agent-assisted re-proposal
-- safe workspace retention and cleanup
-
-### Week 5: history and knowledge
-
-- prompt-state log grouped by task, plus `hop why`
-- compensating undo
-- sourced knowledge facts
-- generated `PROJECT.md`
-
-### Week 6: polish and dogfooding
-
-- installable skill
-- crash recovery and `hop doctor`
-- demo UI or terminal board
-- real use on one active repository
-- instrumentation for validation metrics
-
-## The demo that proves the idea
-
-Do not demo only “two agents in two worktrees”; existing tools already do that.
-
-Demo this:
-
-1. Start an authentication task and a settings task from the same accepted state.
-2. Show both agents’ declared and observed impact live.
-3. Land the independent settings task with one command.
-4. Show that authentication is now based on a stale state.
-5. Refresh it automatically against the new accepted state.
-6. Run its checks on the final combined tree and land it.
-7. Ask `hop why` on a changed symbol and show intent, attempt, evidence, and decision.
-8. Undo the authentication task against the current head without erasing history.
-
-The demo promise is:
-
-> Run coding agents in parallel, see collisions before landing, and accept each task with confidence.
-
-## Competitive boundary
-
-The isolation layer is not the moat:
-
-- Git already supports multiple linked working trees through `git worktree`.
-- GitButler already offers parallel and stacked branches, agent sessions, an installable agent skill, and automatic commits associated with prompts.
-- Jujutsu already treats the working copy as a commit, records an operation log, supports undo, and represents conflicts as first-class state.
-- BitterGit already frames the agent run as a signed provenance and review unit around ordinary Git.
-
-So “save a code snapshot per prompt” is not enough by itself. Hop’s stronger claim is that every prompt—including no-op, failed, interrupted, follow-up, and subagent prompts—is an immutable causal state with exact delivery context; code results, evidence, knowledge, and canonical acceptance are typed descendants in the same graph.
-
-Hop’s defensible layer is the combined graph of:
-
-```text
-every prompt as a state
- → task and attempt paths
- → live resource claims and observed impact
- → sealed states nominated as proposals
- → state-bound validation evidence
- → policy decisions
- → accepted-state lineage
- → sourced project knowledge
-```
-
-That graph can eventually answer questions existing source control cannot answer naturally:
-
-- Which active tasks are likely to interfere before their diffs exist?
-- Which agent attempt best satisfied this task’s acceptance criteria?
-- Is this test evidence still valid for the tree we are about to accept?
-- What accepted prompt-state introduced this contract, and why?
-- Can this old task be replayed safely on today’s project?
-- Which project facts were invalidated by this change?
-
-Adjacent references: [Git worktrees](https://git-scm.com/docs/git-worktree.html), [GitButler agent sessions](https://blog.gitbutler.com/gitbutler-agent-assist), [GitButler’s agent skill](https://docs.gitbutler.com/ai-agents/getting-started), [Jujutsu’s model](https://github.com/jj-vcs/jj), and [BitterGit provenance](https://bittergit.com/).
-
-## Product risks
-
-### It becomes a worktree wrapper
-
-If users value only launching isolated agents, this becomes a feature inside an IDE or Git client. The acceptance ledger, evidence validity, refresh workflow, and impact map must create recurring value.
-
-### Scope declarations are inaccurate
-
-Treat claims as predictions, compare them with observed edits, and improve them continuously. Never make safety depend on an agent accurately naming every file in advance.
-
-### “Semantic merge” destroys trust
-
-Use models to explain, plan, and regenerate. Use deterministic application, policies, and tests to decide what is accepted.
-
-### Knowledge becomes hallucinated authority
-
-Only accepted prompt-states or integration states can introduce facts. Require provenance and allow supersession or invalidation. Keep the generated briefing small.
-
-### Prompt history leaks secrets
-
-Make secret removal a pre-persistence boundary, not a display filter. Retain a
-typed redaction marker and count, but never the credential, a reversible form,
-or a credential hash. Apply the same sanitizer to summaries, recorded commands,
-and check output. Separate private transcripts from shareable receipts, support
-configurable retention, and encrypt sensitive local records. Never require
-hidden model reasoning to be stored.
-
-### Claims create deadlocks
-
-Default to soft leases that expire. Use hard locks only for specifically configured critical resources.
-
-### “Replace Git” blocks adoption
-
-Keep normal commits, remotes, and pull requests as an escape hatch. Become the preferred interface first; earn the right to replace deeper layers later.
-
-## Validation plan
-
-Recruit ten to fifteen developers already running at least three concurrent agent sessions. Observe their existing workflow before pitching the solution.
-
-Measure:
-
-- human coordination and landing time per accepted task
-- collisions found before versus after submission
-- percentage of proposals landed without manual worktree or rebase work
-- refresh success rate for stale proposals
-- regressions and rollbacks per accepted task
-- repeat usage after one week
-- number of times users query history, evidence, or project knowledge after landing
-
-The decisive signal is not “people like the branchless UI.” It is that they repeatedly rely on Hop’s acceptance ledger and cross-agent impact model. A strong early target would be cutting supervision time per accepted task roughly in half while catching meaningful collisions earlier.
-
-## Naming and positioning
-
-“Hop” is memorable, friendly, and verbable: hop into an attempt, hop back, hop between solutions. It is also crowded. Apache Hop already ships a `hop` CLI for its orchestration platform, and `hop.dev` is in use. Keep Hop as the prototype codename until package, domain, trademark, and search clearance are complete.
-
-Recommended category:
-
-> Prompt-native version control for coding agents
-
-Recommended headline:
-
-> Run coding agents in parallel. Land changes with confidence.
-
-Recommended explanatory line:
-
-> Hop saves every prompt as a project state, gives its work an isolated path, and lets you safely accept, compare, replay, or undo the result—while remaining compatible with Git and GitHub.
-
-## The next three decisions
-
-1. **Choose the initial product boundary.** Recommendation: local CLI and ledger that can launch or attach agents; no hosted service yet.
-2. **Choose the acceptance contract.** Define the exact deterministic conditions under which Hop may advance canonical state.
-3. **Build the collision demo before the UI.** If stale proposals, final-tree validation, task history, and undo do not feel dramatically better from the CLI, a visual client will not create the moat.
diff --git a/docs/production-deployment.md b/docs/production-deployment.md
new file mode 100644
index 0000000..a304238
--- /dev/null
+++ b/docs/production-deployment.md
@@ -0,0 +1,46 @@
+# Production deployment
+
+Run the Compose stack behind an HTTPS reverse proxy. Keep the Gitea HTTP port
+and Hop control-plane port bound to loopback; only publish Gitea's SSH port if
+the deployment has a DNS-only hostname that can reach it without an HTTP
+proxy.
+
+Start from `.env.example`, replace every secret, and set at least:
+
+```dotenv
+GITEA_DOMAIN=git.example.com
+GITEA_ROOT_URL=https://git.example.com/
+GITEA_HTTP_BIND=127.0.0.1
+HOP_HTTP_BIND=127.0.0.1
+GITEA_SSH_BIND=127.0.0.1
+GITEA_DISABLE_SSH=true
+GITEA_DISABLE_REGISTRATION=true
+GITEA_ACTIONS_ENABLED=false
+```
+
+Start the services and install the Hop-native Gitea assets:
+
+```sh
+docker compose --env-file .env up --build -d
+./deploy/gitea/install-hop-native.sh
+```
+
+The reverse proxy should forward the public hostname to the configured
+`GITEA_HTTP_BIND:GITEA_HTTP_PORT`, preserve the `Host` header, and set
+`X-Forwarded-Proto` to `https`. It must also proxy `/hop/` to the Hop control
+plane, preserving the browser's Gitea session cookie. This lets the Prompts
+view check the viewer's repository access before returning potentially
+sensitive prompt text.
+
+For nginx, the control-plane location should precede the Gitea catch-all:
+
+```nginx
+location /hop/ {
+ proxy_pass http://127.0.0.1:8080/;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Cookie $http_cookie;
+}
+```
+
+Persistent repository and database data live in the named Compose volumes.
diff --git a/go.mod b/go.mod
deleted file mode 100644
index 6a4aefd..0000000
--- a/go.mod
+++ /dev/null
@@ -1,18 +0,0 @@
-module githop.xyz/GnosysLabs/Hop
-
-go 1.26
-
-require modernc.org/sqlite v1.39.1
-
-require (
- github.com/dustin/go-humanize v1.0.1 // indirect
- github.com/google/uuid v1.6.0 // indirect
- github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/ncruces/go-strftime v0.1.9 // indirect
- github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
- golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
- golang.org/x/sys v0.36.0 // indirect
- modernc.org/libc v1.66.10 // indirect
- modernc.org/mathutil v1.7.1 // indirect
- modernc.org/memory v1.11.0 // indirect
-)
diff --git a/go.sum b/go.sum
deleted file mode 100644
index 4ffbac8..0000000
--- a/go.sum
+++ /dev/null
@@ -1,49 +0,0 @@
-github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
-github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
-github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
-github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
-github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
-github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
-github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
-github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
-github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
-golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
-golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
-golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
-golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
-golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
-golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
-golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
-golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
-modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
-modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
-modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
-modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
-modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
-modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
-modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
-modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
-modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
-modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
-modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
-modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
-modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
-modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
-modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
-modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
-modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
-modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
-modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
-modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
-modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
-modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
-modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
-modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
-modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
-modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/internal/hop/cli.go b/internal/hop/cli.go
deleted file mode 100644
index 7f6475f..0000000
--- a/internal/hop/cli.go
+++ /dev/null
@@ -1,767 +0,0 @@
-package hop
-
-import (
- "context"
- "encoding/json"
- "errors"
- "flag"
- "fmt"
- "io"
- "os"
- "path/filepath"
- "runtime/debug"
- "strings"
-)
-
-const usageText = `Hop — prompt-native version control
-
-Usage:
- hop init [path]
- hop begin [--agent NAME] [--session ID] [--from STATE] (--stdin | --heredoc | "instruction")
- hop prompt [--from STATE] [--agent NAME] (--stdin | --heredoc | "instruction")
- hop checkpoint STATE
- hop check STATE -- COMMAND [ARG...]
- hop propose [--summary TEXT] STATE
- hop accept STATE [-- COMMAND [ARG...]]
- hop land STATE [-- COMMAND [ARG...]]
- hop refresh PROPOSAL
- hop export [--output PATH]
- hop sync
- hop push
- hop status
- hop graph
- hop state STATE
- hop env STATE
- hop diff STATE
- hop history
- hop undo
- hop doctor [--repair]
- hop skill install [--path SKILLS_DIR] [--force]
- hop skill print
- hop version
-
-Add --json anywhere for machine-readable output.
-`
-
-// Version is replaced by GoReleaser through -ldflags. Source installs made by
-// `go install module@version` fall back to the module version in Go build info.
-var Version = "dev"
-
-func effectiveVersion() string {
- if Version != "" && Version != "dev" {
- return strings.TrimPrefix(Version, "v")
- }
- if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
- return strings.TrimPrefix(info.Main.Version, "v")
- }
- return "dev"
-}
-
-func RunCLI(args []string, stdout, stderr io.Writer) int {
- return RunCLIWithInput(args, os.Stdin, stdout, stderr)
-}
-
-func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
- jsonOutput, args := removeFlag(args, "--json")
- if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
- fmt.Fprint(stdout, usageText)
- return 0
- }
-
- ctx := context.Background()
- command := args[0]
- commandArgs := args[1:]
- if command == "version" || command == "--version" {
- version := effectiveVersion()
- if jsonOutput {
- writeJSON(stdout, map[string]any{"ok": true, "version": version})
- } else {
- fmt.Fprintf(stdout, "hop %s\n", version)
- }
- return 0
- }
- if command == "skill" {
- return runSkillCLI(commandArgs, jsonOutput, stdout, stderr)
- }
-
- if command == "init" {
- path := "."
- if len(commandArgs) > 1 {
- fmt.Fprintln(stderr, "hop init accepts at most one path")
- return 2
- }
- if len(commandArgs) == 1 {
- path = commandArgs[0]
- }
- service, state, err := InitProject(ctx, path)
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- defer service.Close()
- if jsonOutput {
- writeJSON(stdout, map[string]any{"ok": true, "root": service.Root, "state": state})
- } else {
- fmt.Fprintf(stdout, "Initialized Hop at %s\nAccepted %s · tree %s\n", service.Root, state.ID, shortHash(state.SourceTree))
- }
- return 0
- }
-
- if command == "begin" {
- fs := flag.NewFlagSet("begin", flag.ContinueOnError)
- fs.SetOutput(stderr)
- from := fs.String("from", "", "continue from an explicit Hop state")
- sessionDefault := os.Getenv("CODEX_THREAD_ID")
- session := fs.String("session", sessionDefault, "stable interactive-agent session ID")
- agentDefault := os.Getenv("HOP_AGENT")
- if agentDefault == "" {
- if sessionDefault != "" {
- agentDefault = "codex"
- } else {
- agentDefault = "agent"
- }
- }
- agent := fs.String("agent", agentDefault, "agent or harness name")
- stdinPrompt := fs.Bool("stdin", false, "read exact prompt bytes from stdin")
- heredocPrompt := fs.Bool("heredoc", false, "read prompt from stdin and remove one shell-added final newline")
- if err := fs.Parse(commandArgs); err != nil {
- return 2
- }
- message, err := promptMessage(stdin, fs.Args(), *stdinPrompt, *heredocPrompt)
- if err != nil {
- fmt.Fprintf(stderr, "hop begin: %v\n", err)
- return 2
- }
-
- service, err := OpenProject(".")
- initialized := false
- if errors.Is(err, ErrNotHopProject) {
- service, _, err = InitProject(ctx, ".")
- initialized = err == nil
- }
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- defer service.Close()
-
- result, err := service.BeginPrompt(ctx, message, *from, *agent, *session)
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- begin := BeginResult{PromptResult: result, Initialized: initialized, SessionID: *session}
- if jsonOutput {
- writeJSON(stdout, map[string]any{"ok": true, "data": begin})
- } else {
- writeRedactionNotice(stderr, result.Redactions)
- if initialized {
- fmt.Fprintf(stdout, "Initialized Hop at %s\n", service.Root)
- }
- if result.Checkpoint != nil {
- fmt.Fprintf(stdout, "Checkpointed %s before the follow-up\n", result.Checkpoint.ID)
- }
- fmt.Fprintf(stdout, "Captured prompt state %s before project effects\nWorkspace: %s\n", result.Prompt.ID, result.Workspace)
- fmt.Fprintf(stdout, "Use HOP_STATE_ID=%s HOP_TASK_ID=%s HOP_ATTEMPT_ID=%s for this turn.\n", result.Prompt.ID, result.Task.ID, result.Attempt.ID)
- }
- return 0
- }
-
- service, err := OpenProject(".")
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- defer service.Close()
-
- var value any
- switch command {
- case "prompt", "start":
- fs := flag.NewFlagSet("prompt", flag.ContinueOnError)
- fs.SetOutput(stderr)
- from := fs.String("from", "", "continue an existing attempt")
- agent := fs.String("agent", "", "agent or harness name")
- stdinPrompt := fs.Bool("stdin", false, "read exact prompt bytes from stdin")
- heredocPrompt := fs.Bool("heredoc", false, "read prompt from stdin and remove one shell-added final newline")
- if err := fs.Parse(commandArgs); err != nil {
- return 2
- }
- message, err := promptMessage(stdin, fs.Args(), *stdinPrompt, *heredocPrompt)
- if err != nil {
- fmt.Fprintf(stderr, "hop prompt: %v\n", err)
- return 2
- }
- result, err := service.CreatePrompt(ctx, message, *from, *agent)
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- value = result
- if !jsonOutput {
- writeRedactionNotice(stderr, result.Redactions)
- if result.Checkpoint != nil {
- fmt.Fprintf(stdout, "Checkpointed %s before the follow-up\n", result.Checkpoint.ID)
- }
- fmt.Fprintf(stdout, "Created prompt state %s before delivery\nWorkspace: %s\n", result.Prompt.ID, result.Workspace)
- fmt.Fprintf(stdout, "Environment: HOP_ROOT=%s HOP_STATE_ID=%s HOP_TASK_ID=%s HOP_ATTEMPT_ID=%s\n", service.Root, result.Prompt.ID, result.Task.ID, result.Attempt.ID)
- }
-
- case "checkpoint":
- if len(commandArgs) != 1 {
- fmt.Fprintln(stderr, "usage: hop checkpoint STATE")
- return 2
- }
- state, err := service.Checkpoint(ctx, commandArgs[0])
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- value = state
- if !jsonOutput {
- 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 the portable prompt ledger 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 prompt records to %s\n", len(ledger.Prompts), filepath.Join(root, ".hop", "records", "prompts"))
- }
- case "check":
- stateID, argv, ok := splitCommand(commandArgs)
- if !ok {
- fmt.Fprintln(stderr, "usage: hop check STATE -- COMMAND [ARG...]")
- return 2
- }
- check, err := service.RunCheck(ctx, stateID, argv)
- value = check
- if !jsonOutput {
- fmt.Fprintf(stdout, "$ %s\n%s", shellQuote(check.Command), check.Output)
- if check.Output != "" && !strings.HasSuffix(check.Output, "\n") {
- fmt.Fprintln(stdout)
- }
- fmt.Fprintf(stdout, "Check %s · exit %d · tree %s\n", check.ID, check.ExitCode, shortHash(check.TreeHash))
- }
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
-
- case "propose", "submit":
- fs := flag.NewFlagSet("propose", flag.ContinueOnError)
- fs.SetOutput(stderr)
- summary := fs.String("summary", "", "result summary")
- if err := fs.Parse(commandArgs); err != nil {
- return 2
- }
- if len(fs.Args()) != 1 {
- fmt.Fprintln(stderr, "usage: hop propose [--summary TEXT] STATE")
- return 2
- }
- result, err := service.Propose(ctx, fs.Args()[0], *summary)
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- value = result
- if !jsonOutput {
- fmt.Fprintf(stdout, "Created proposal %s · tree %s · %d matching checks\n", result.Proposal.ID, shortHash(result.Proposal.SourceTree), len(result.Checks))
- }
-
- case "accept":
- stateID, argv, ok := splitOptionalCommand(commandArgs)
- if !ok {
- fmt.Fprintln(stderr, "usage: hop accept STATE [-- COMMAND [ARG...]]")
- return 2
- }
- result, err := service.Accept(ctx, stateID, argv)
- value = result
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- if !jsonOutput {
- fmt.Fprintf(stdout, "Accepted internally as %s · tree %s · visible root unchanged\n", result.State.ID, shortHash(result.State.SourceTree))
- printRemotePush(stdout, result.RemotePush)
- if result.Check == nil {
- fmt.Fprintln(stdout, "No final-state validation command was supplied.")
- }
- for _, warning := range result.Warnings {
- fmt.Fprintf(stderr, "Warning: %s\n", warning)
- }
- }
-
- case "land":
- stateID, argv, ok := splitOptionalCommand(commandArgs)
- if !ok {
- fmt.Fprintln(stderr, "usage: hop land STATE [-- COMMAND [ARG...]]")
- return 2
- }
- result, err := service.Land(ctx, stateID, argv)
- value = result
- if err != nil {
- var conflict *ConflictError
- if errors.As(err, &conflict) {
- refresh, refreshErr := service.Refresh(ctx, stateID)
- if refreshErr != nil {
- preparationErr := fmt.Errorf("automatic merge conflict detected (%v), but reconciliation preparation failed: %v", err, refreshErr)
- return printCLIError(preparationErr, jsonOutput, stdout, stderr)
- }
- if jsonOutput {
- writeJSON(stdout, map[string]any{
- "ok": false,
- "error": err.Error(),
- "exit_code": 20,
- "conflict": conflict,
- "reconciliation": refresh,
- "next_command": "resolve the returned workspace, then check, propose, and land using the returned prompt state",
- })
- return 20
- }
- fmt.Fprintf(stderr, "hop: %v\n", err)
- printRefreshSummary(stdout, refresh)
- return 20
- }
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- if !jsonOutput {
- fmt.Fprintf(stdout, "Landed as %s · tree %s\n", result.State.ID, shortHash(result.State.SourceTree))
- fmt.Fprintf(stdout, "Synchronized visible root: %s\n", result.MaterializedRoot)
- printRemotePush(stdout, result.RemotePush)
- if result.Check == nil {
- fmt.Fprintln(stdout, "No final-state validation command was supplied.")
- }
- for _, warning := range result.Warnings {
- fmt.Fprintf(stderr, "Warning: %s\n", warning)
- }
- }
-
- case "refresh", "reconcile":
- if len(commandArgs) != 1 {
- fmt.Fprintln(stderr, "usage: hop refresh PROPOSAL")
- return 2
- }
- result, err := service.Refresh(ctx, commandArgs[0])
- value = result
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- if !jsonOutput {
- printRefreshSummary(stdout, result)
- }
-
- case "sync":
- if len(commandArgs) != 0 {
- fmt.Fprintln(stderr, "usage: hop sync")
- return 2
- }
- result, err := service.Sync(ctx)
- value = result
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- if !jsonOutput {
- if result.Changed {
- fmt.Fprintf(stdout, "Synchronized %s to accepted state %s\n", result.Root, result.State.ID)
- } else {
- fmt.Fprintf(stdout, "Visible root already matches accepted state %s\n", result.State.ID)
- }
- }
-
- case "push":
- if len(commandArgs) != 0 {
- fmt.Fprintln(stderr, "usage: hop push")
- return 2
- }
- result, err := service.Push(ctx)
- value = result
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- if !jsonOutput {
- printRemotePush(stdout, &result)
- }
-
- case "status":
- status, err := service.Status(ctx)
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- value = status
- if !jsonOutput {
- fmt.Fprintf(stdout, "Accepted: %s · tree %s\n", status.AcceptedHead.ID, shortHash(status.AcceptedHead.SourceTree))
- switch status.RootStatus {
- case "synchronized":
- fmt.Fprintln(stdout, "Root: synchronized")
- case "stale":
- fmt.Fprintf(stdout, "Root: stale at %s\n", status.RootStateID)
- default:
- fmt.Fprintln(stdout, "Root: diverged; Hop will not overwrite it")
- }
- if len(status.Attempts) == 0 {
- fmt.Fprintln(stdout, "No attempts yet.")
- }
- for _, attempt := range status.Attempts {
- fmt.Fprintf(stdout, "%s %-10s head=%s %s\n", attempt.ID, attempt.Status, attempt.HeadStateID, attempt.Workspace)
- }
- }
-
- case "graph":
- rows, err := service.Graph(ctx)
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- value = rows
- if !jsonOutput {
- for _, row := range rows {
- parents := make([]string, 0, len(row.Parents))
- for _, parent := range row.Parents {
- parents = append(parents, parent.Role+"="+parent.StateID)
- }
- label := row.State.Summary
- if label == "" {
- label = row.State.Prompt
- }
- fmt.Fprintf(stdout, "%-10s %-28s %-50s %s\n", row.State.Kind, row.State.ID, strings.Join(parents, " "), label)
- }
- }
-
- case "state", "inspect":
- if len(commandArgs) != 1 {
- fmt.Fprintln(stderr, "usage: hop state STATE")
- return 2
- }
- state, err := service.State(ctx, commandArgs[0])
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- value = state
- if !jsonOutput {
- fmt.Fprintf(stdout, "%s %s\nTree: %s\nCommit: %s\nDigest: %s\n", state.Kind, state.ID, state.SourceTree, state.GitCommit, state.Digest)
- if state.Prompt != "" {
- fmt.Fprintf(stdout, "Prompt: %s\n", state.Prompt)
- }
- if state.Summary != "" {
- fmt.Fprintf(stdout, "Summary: %s\n", state.Summary)
- }
- for _, parent := range state.Parents {
- fmt.Fprintf(stdout, "Parent: %-18s %s\n", parent.Role, parent.StateID)
- }
- }
-
- case "env":
- if len(commandArgs) != 1 {
- fmt.Fprintln(stderr, "usage: hop env STATE")
- return 2
- }
- result, err := service.EnvironmentForState(ctx, commandArgs[0])
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- value = result
- if !jsonOutput {
- for _, name := range []string{"HOP_ROOT", "HOP_STATE_ID", "HOP_TASK_ID", "HOP_ATTEMPT_ID", "HOP_WORKSPACE"} {
- fmt.Fprintf(stdout, "export %s=%s\n", name, shellQuote([]string{result.Variables[name]}))
- }
- fmt.Fprintf(stdout, "cd %s\n", shellQuote([]string{result.Workspace}))
- }
-
- case "diff":
- if len(commandArgs) != 1 {
- fmt.Fprintln(stderr, "usage: hop diff STATE")
- return 2
- }
- diff, err := service.Diff(ctx, commandArgs[0])
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- value = map[string]string{"state": commandArgs[0], "diff": diff}
- if !jsonOutput {
- fmt.Fprint(stdout, diff)
- }
-
- case "history":
- states, err := service.History(ctx)
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- value = states
- if !jsonOutput {
- for _, state := range states {
- fmt.Fprintf(stdout, "%s %s %s\n", state.ID, shortHash(state.SourceTree), state.Summary)
- }
- }
-
- case "undo":
- if len(commandArgs) != 0 {
- fmt.Fprintln(stderr, "usage: hop undo")
- return 2
- }
- state, err := service.Undo(ctx)
- var committed *CommittedStateError
- if err != nil && !errors.As(err, &committed) {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- if committed != nil {
- state = committed.State
- value = map[string]any{"state": state, "warnings": []string{committed.Error()}}
- } else {
- value = state
- }
- if !jsonOutput {
- fmt.Fprintf(stdout, "Created forward undo state %s · tree %s\n", state.ID, shortHash(state.SourceTree))
- if committed != nil {
- fmt.Fprintf(stderr, "Warning: %s\n", committed.Error())
- }
- }
-
- case "doctor":
- fs := flag.NewFlagSet("doctor", flag.ContinueOnError)
- fs.SetOutput(stderr)
- repair := fs.Bool("repair", false, "repair the internal accepted ref")
- if err := fs.Parse(commandArgs); err != nil || len(fs.Args()) != 0 {
- return 2
- }
- report, err := service.Doctor(ctx, *repair)
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- value = report
- if !jsonOutput {
- if report.OK {
- fmt.Fprintf(stdout, "Hop project is healthy · %s\n", report.AcceptedState)
- } else {
- for _, problem := range report.Problems {
- fmt.Fprintf(stdout, "Problem: %s\n", problem)
- }
- }
- }
-
- default:
- fmt.Fprintf(stderr, "unknown command %q\n\n%s", command, usageText)
- return 2
- }
-
- if jsonOutput {
- writeJSON(stdout, map[string]any{"ok": true, "data": value})
- }
- return 0
-}
-
-func runSkillCLI(args []string, jsonOutput bool, stdout, stderr io.Writer) int {
- if len(args) == 0 {
- fmt.Fprintln(stderr, "usage: hop skill install [--path SKILLS_DIR] [--force] | hop skill print")
- return 2
- }
- switch args[0] {
- case "install":
- fs := flag.NewFlagSet("skill install", flag.ContinueOnError)
- fs.SetOutput(stderr)
- path := fs.String("path", "", "parent skills directory")
- force := fs.Bool("force", false, "update an existing Hop skill")
- if err := fs.Parse(args[1:]); err != nil || len(fs.Args()) != 0 {
- return 2
- }
- var result SkillInstallResult
- var err error
- if strings.TrimSpace(*path) == "" {
- result, err = InstallDefaultSkills(*force)
- } else {
- result, err = InstallSkill(*path, *force)
- }
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- if jsonOutput {
- writeJSON(stdout, map[string]any{"ok": true, "data": result})
- } else {
- for _, installedPath := range result.Paths {
- fmt.Fprintf(stdout, "Installed Hop skill at %s\n", installedPath)
- }
- }
- return 0
- case "print":
- if len(args) != 1 {
- fmt.Fprintln(stderr, "usage: hop skill print")
- return 2
- }
- contents, err := EmbeddedSkillText()
- if err != nil {
- return printCLIError(err, jsonOutput, stdout, stderr)
- }
- if jsonOutput {
- writeJSON(stdout, map[string]any{"ok": true, "data": map[string]string{"skill": contents}})
- } else {
- fmt.Fprint(stdout, contents)
- }
- return 0
- default:
- fmt.Fprintf(stderr, "unknown skill command %q\n", args[0])
- return 2
- }
-}
-
-func printCLIError(err error, jsonOutput bool, stdout, stderr io.Writer) int {
- code := 1
- var conflict *ConflictError
- var rootConflict *RootConflictError
- var stale *StaleHeadError
- var failed *CheckFailedError
- switch {
- case errors.As(err, &conflict):
- code = 20
- case errors.As(err, &rootConflict):
- code = 23
- case errors.As(err, &stale):
- code = 21
- case errors.As(err, &failed):
- code = 22
- }
- if jsonOutput {
- writeJSON(stdout, map[string]any{"ok": false, "error": err.Error(), "exit_code": code})
- } else {
- fmt.Fprintf(stderr, "hop: %v\n", err)
- if conflict != nil && len(conflict.Paths) > 0 {
- fmt.Fprintln(stderr, "Merge conflicts:")
- for _, path := range conflict.Paths {
- fmt.Fprintf(stderr, " %s\n", path)
- }
- }
- if rootConflict != nil && len(rootConflict.Paths) > 0 {
- fmt.Fprintln(stderr, "Visible-root conflicts:")
- for _, path := range rootConflict.Paths {
- fmt.Fprintf(stderr, " %s\n", path)
- }
- }
- }
- return code
-}
-
-func printRefreshSummary(w io.Writer, result RefreshResult) {
- action := "Prepared"
- if result.Reused {
- action = "Reusing"
- }
- fmt.Fprintf(w, "%s reconciliation prompt %s\n", action, result.Prompt.ID)
- fmt.Fprintf(w, "Reconciliation attempt: %s\n", result.Attempt.ID)
- fmt.Fprintf(w, "Workspace: %s\n", result.Workspace)
- fmt.Fprintf(w, "Accepted input: %s · commit %s\n", result.AcceptedHead.ID, shortHash(result.AcceptedHead.GitCommit))
- fmt.Fprintf(w, "Proposal input: %s · commit %s\n", result.Proposal.ID, shortHash(result.Proposal.GitCommit))
- fmt.Fprintln(w, "Resolve these genuine merge conflicts while preserving both intents (structural conflicts may have no text markers):")
- for _, path := range result.Conflicts {
- fmt.Fprintf(w, " %s\n", path)
- }
- fmt.Fprintf(w, "Continue automatically with: hop check %s -- , then propose and land again.\n", result.Prompt.ID)
-}
-
-func printRemotePush(w io.Writer, result *RemotePushResult) {
- if result == nil {
- return
- }
- fmt.Fprintf(w, "Pushed accepted commit to %s/%s\n", result.Remote, strings.TrimPrefix(result.Ref, "refs/heads/"))
-}
-
-func removeFlag(args []string, wanted string) (bool, []string) {
- found := false
- filtered := make([]string, 0, len(args))
- for i, arg := range args {
- if arg == "--" {
- filtered = append(filtered, args[i:]...)
- break
- }
- if arg == wanted {
- found = true
- continue
- }
- filtered = append(filtered, arg)
- }
- return found, filtered
-}
-
-func splitCommand(args []string) (string, []string, bool) {
- if len(args) < 3 || args[1] != "--" {
- return "", nil, false
- }
- return args[0], args[2:], len(args[2:]) > 0
-}
-
-func splitOptionalCommand(args []string) (string, []string, bool) {
- if len(args) == 1 {
- return args[0], nil, true
- }
- if len(args) >= 3 && args[1] == "--" {
- return args[0], args[2:], true
- }
- return "", nil, false
-}
-
-const maxPromptBytes = 16 << 20
-
-func promptMessage(stdin io.Reader, args []string, rawStdin, heredoc bool) (string, error) {
- if rawStdin && heredoc {
- return "", errors.New("use only one of --stdin or --heredoc")
- }
- if !rawStdin && !heredoc {
- if len(args) != 1 || strings.TrimSpace(args[0]) == "" {
- return "", errors.New("provide exactly one non-empty prompt argument, or use --stdin/--heredoc")
- }
- return args[0], nil
- }
- if len(args) != 0 {
- return "", errors.New("do not combine a prompt argument with --stdin or --heredoc")
- }
- data, err := io.ReadAll(io.LimitReader(stdin, maxPromptBytes+1))
- if err != nil {
- return "", fmt.Errorf("read prompt from stdin: %w", err)
- }
- if len(data) > maxPromptBytes {
- return "", fmt.Errorf("prompt exceeds %d bytes", maxPromptBytes)
- }
- message := string(data)
- if heredoc {
- if strings.HasSuffix(message, "\r\n") {
- message = strings.TrimSuffix(message, "\r\n")
- } else {
- message = strings.TrimSuffix(message, "\n")
- }
- }
- if strings.TrimSpace(message) == "" {
- return "", errors.New("prompt text is required")
- }
- return message, nil
-}
-
-func writeRedactionNotice(w io.Writer, redactions []PromptRedaction) {
- total := 0
- for _, redaction := range redactions {
- total += redaction.Count
- }
- if total == 0 {
- return
- }
- noun := "credentials"
- if total == 1 {
- noun = "credential"
- }
- fmt.Fprintf(w, "Warning: redacted %d potential %s before storing the prompt.\n", total, noun)
-}
-
-func writeJSON(w io.Writer, value any) {
- encoder := json.NewEncoder(w)
- encoder.SetIndent("", " ")
- _ = encoder.Encode(value)
-}
-
-func shortHash(hash string) string {
- if len(hash) <= 12 {
- return hash
- }
- return hash[:12]
-}
diff --git a/internal/hop/db.go b/internal/hop/db.go
deleted file mode 100644
index c19b46d..0000000
--- a/internal/hop/db.go
+++ /dev/null
@@ -1,1983 +0,0 @@
-package hop
-
-import (
- "context"
- "database/sql"
- "encoding/json"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "strconv"
- "strings"
- "time"
-
- _ "modernc.org/sqlite"
-)
-
-const schemaVersion = 3
-
-var (
- ErrNotFound = errors.New("hop: not found")
- ErrNotInitialized = errors.New("hop: repository is not initialized")
- ErrAlreadyInitialized = errors.New("hop: repository is already initialized")
- ErrAttemptHeadChanged = errors.New("hop: attempt head changed")
- ErrAcceptedHeadChanged = errors.New("hop: accepted head changed")
- ErrMaterializedChanged = errors.New("hop: materialized head changed")
-)
-
-// HeadChangedError reports a failed compare-and-swap of an attempt or the
-// repository's accepted head. Callers may use errors.Is with
-// ErrAttemptHeadChanged or ErrAcceptedHeadChanged.
-type HeadChangedError struct {
- Scope string `json:"scope"`
- Expected string `json:"expected"`
- Actual string `json:"actual"`
-}
-
-func (e *HeadChangedError) Error() string {
- return fmt.Sprintf("hop: %s head changed: expected %q, found %q", e.Scope, e.Expected, e.Actual)
-}
-
-func (e *HeadChangedError) Unwrap() error {
- switch e.Scope {
- case "accepted":
- return ErrAcceptedHeadChanged
- case "materialized":
- return ErrMaterializedChanged
- default:
- return ErrAttemptHeadChanged
- }
-}
-
-// Event is an immutable audit record. Payload is deliberately unstructured so
-// newer clients can add detail without requiring a database migration.
-type Event struct {
- ID string `json:"id"`
- Kind string `json:"kind"`
- TaskID string `json:"task_id,omitempty"`
- AttemptID string `json:"attempt_id,omitempty"`
- StateID string `json:"state_id,omitempty"`
- Payload json.RawMessage `json:"payload,omitempty"`
- CreatedAt time.Time `json:"created_at"`
-}
-
-// Store owns Hop's durable state graph. One database connection is used per
-// Store so connection-scoped SQLite settings (notably foreign_keys) cannot be
-// lost when database/sql selects another connection.
-type Store struct {
- db *sql.DB
- path string
-}
-
-// OpenStore opens (and, when needed, creates) a Hop SQLite database, applies
-// migrations, and enables foreign keys and WAL mode.
-func OpenStore(path string) (*Store, error) {
- if strings.TrimSpace(path) == "" {
- return nil, errors.New("hop: database path is required")
- }
- if path != ":memory:" && !strings.HasPrefix(path, "file:") {
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- return nil, fmt.Errorf("create database directory: %w", err)
- }
- }
-
- db, err := sql.Open("sqlite", path)
- if err != nil {
- return nil, fmt.Errorf("open database: %w", err)
- }
- db.SetMaxOpenConns(1)
- db.SetMaxIdleConns(1)
-
- store := &Store{db: db, path: path}
- if err := store.configure(context.Background()); err != nil {
- _ = db.Close()
- return nil, err
- }
- if err := store.migrate(context.Background()); err != nil {
- _ = db.Close()
- return nil, err
- }
- return store, nil
-}
-
-// Open is a convenience alias for OpenStore.
-func Open(path string) (*Store, error) { return OpenStore(path) }
-
-func (s *Store) Path() string { return s.path }
-
-func (s *Store) Close() error {
- if s == nil || s.db == nil {
- return nil
- }
- return s.db.Close()
-}
-
-func (s *Store) configure(ctx context.Context) error {
- for _, statement := range []string{
- "PRAGMA busy_timeout = 5000",
- "PRAGMA foreign_keys = ON",
- "PRAGMA synchronous = NORMAL",
- } {
- if _, err := s.db.ExecContext(ctx, statement); err != nil {
- return fmt.Errorf("configure sqlite (%s): %w", statement, err)
- }
- }
- var journalMode string
- if err := s.db.QueryRowContext(ctx, "PRAGMA journal_mode = WAL").Scan(&journalMode); err != nil {
- return fmt.Errorf("enable sqlite WAL: %w", err)
- }
- var foreignKeys int
- if err := s.db.QueryRowContext(ctx, "PRAGMA foreign_keys").Scan(&foreignKeys); err != nil {
- return fmt.Errorf("verify sqlite foreign keys: %w", err)
- }
- if foreignKeys != 1 {
- return errors.New("hop: sqlite foreign keys could not be enabled")
- }
- return nil
-}
-
-type migration struct {
- version int
- statements []string
-}
-
-var migrations = []migration{
- {
- version: 1,
- statements: []string{
- `CREATE TABLE IF NOT EXISTS meta (
- key TEXT PRIMARY KEY,
- value TEXT NOT NULL
- ) STRICT`,
- `CREATE TABLE IF NOT EXISTS tasks (
- id TEXT PRIMARY KEY,
- title TEXT NOT NULL,
- status TEXT NOT NULL,
- created_at TEXT NOT NULL
- ) STRICT`,
- `CREATE TABLE IF NOT EXISTS states (
- id TEXT PRIMARY KEY,
- kind TEXT NOT NULL,
- task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED,
- attempt_id TEXT REFERENCES attempts(id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED,
- canonical_anchor_id TEXT REFERENCES states(id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED,
- source_tree TEXT NOT NULL,
- git_commit TEXT NOT NULL,
- prompt TEXT NOT NULL,
- summary TEXT NOT NULL,
- agent TEXT NOT NULL,
- digest TEXT NOT NULL,
- created_at TEXT NOT NULL
- ) STRICT`,
- `CREATE TABLE IF NOT EXISTS attempts (
- id TEXT PRIMARY KEY,
- task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
- agent TEXT NOT NULL,
- workspace TEXT NOT NULL,
- base_state_id TEXT NOT NULL REFERENCES states(id) ON DELETE RESTRICT DEFERRABLE INITIALLY DEFERRED,
- head_state_id TEXT REFERENCES states(id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED,
- status TEXT NOT NULL,
- created_at TEXT NOT NULL
- ) STRICT`,
- `CREATE TABLE IF NOT EXISTS state_parents (
- state_id TEXT NOT NULL REFERENCES states(id) ON DELETE CASCADE,
- parent_state_id TEXT NOT NULL REFERENCES states(id) ON DELETE RESTRICT,
- role TEXT NOT NULL,
- parent_order INTEGER NOT NULL,
- PRIMARY KEY (state_id, parent_order),
- UNIQUE (state_id, parent_state_id, role)
- ) STRICT`,
- `CREATE TABLE IF NOT EXISTS checks (
- id TEXT PRIMARY KEY,
- attempt_id TEXT NOT NULL REFERENCES attempts(id) ON DELETE CASCADE,
- state_id TEXT REFERENCES states(id) ON DELETE SET NULL,
- tree_hash TEXT NOT NULL,
- command_json TEXT NOT NULL,
- exit_code INTEGER NOT NULL,
- output TEXT NOT NULL,
- created_at TEXT NOT NULL
- ) STRICT`,
- `CREATE TABLE IF NOT EXISTS events (
- id TEXT PRIMARY KEY,
- kind TEXT NOT NULL,
- task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL,
- attempt_id TEXT REFERENCES attempts(id) ON DELETE SET NULL,
- state_id TEXT REFERENCES states(id) ON DELETE SET NULL,
- payload_json TEXT NOT NULL,
- created_at TEXT NOT NULL
- ) STRICT`,
- `CREATE INDEX IF NOT EXISTS states_task_created_idx ON states(task_id, created_at, id)`,
- `CREATE INDEX IF NOT EXISTS states_attempt_created_idx ON states(attempt_id, created_at, id)`,
- `CREATE INDEX IF NOT EXISTS states_kind_created_idx ON states(kind, created_at, id)`,
- `CREATE INDEX IF NOT EXISTS state_parents_parent_idx ON state_parents(parent_state_id, state_id)`,
- `CREATE INDEX IF NOT EXISTS state_parents_role_idx ON state_parents(state_id, role, parent_order)`,
- `CREATE INDEX IF NOT EXISTS attempts_task_created_idx ON attempts(task_id, created_at, id)`,
- `CREATE INDEX IF NOT EXISTS attempts_status_created_idx ON attempts(status, created_at, id)`,
- `CREATE INDEX IF NOT EXISTS checks_attempt_tree_idx ON checks(attempt_id, tree_hash, created_at, id)`,
- `CREATE INDEX IF NOT EXISTS checks_tree_idx ON checks(tree_hash, created_at, id)`,
- `CREATE INDEX IF NOT EXISTS events_created_idx ON events(created_at, id)`,
- `CREATE INDEX IF NOT EXISTS events_task_idx ON events(task_id, created_at, id)`,
- `CREATE INDEX IF NOT EXISTS events_attempt_idx ON events(attempt_id, created_at, id)`,
- },
- },
- {
- version: 2,
- statements: []string{
- `CREATE TABLE IF NOT EXISTS agent_sessions (
- agent TEXT NOT NULL,
- session_id TEXT NOT NULL,
- head_state_id TEXT NOT NULL REFERENCES states(id) ON DELETE RESTRICT,
- created_at TEXT NOT NULL,
- updated_at TEXT NOT NULL,
- PRIMARY KEY (agent, session_id)
- ) STRICT`,
- `CREATE INDEX IF NOT EXISTS agent_sessions_head_idx ON agent_sessions(head_state_id)`,
- },
- },
- {
- version: 3,
- statements: []string{
- `INSERT OR IGNORE INTO meta(key, value)
- SELECT 'materialized_head', id
- FROM states
- WHERE kind = 'accepted'
- ORDER BY created_at, id
- LIMIT 1`,
- },
- },
-}
-
-func (s *Store) migrate(ctx context.Context) error {
- var current int
- if err := s.db.QueryRowContext(ctx, "PRAGMA user_version").Scan(¤t); err != nil {
- return fmt.Errorf("read schema version: %w", err)
- }
- if current > schemaVersion {
- return fmt.Errorf("hop: database schema version %d is newer than supported version %d", current, schemaVersion)
- }
- for _, m := range migrations {
- if m.version <= current {
- continue
- }
- tx, err := s.db.BeginTx(ctx, nil)
- if err != nil {
- return fmt.Errorf("begin schema migration %d: %w", m.version, err)
- }
- ok := false
- defer func() {
- if !ok {
- _ = tx.Rollback()
- }
- }()
- for _, statement := range m.statements {
- if _, err := tx.ExecContext(ctx, statement); err != nil {
- _ = tx.Rollback()
- return fmt.Errorf("apply schema migration %d: %w", m.version, err)
- }
- }
- if _, err := tx.ExecContext(ctx,
- `INSERT INTO meta(key, value) VALUES ('schema_version', ?)
- ON CONFLICT(key) DO UPDATE SET value = excluded.value`, strconv.Itoa(m.version)); err != nil {
- _ = tx.Rollback()
- return fmt.Errorf("record schema migration %d: %w", m.version, err)
- }
- if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA user_version = %d", m.version)); err != nil {
- _ = tx.Rollback()
- return fmt.Errorf("record sqlite schema version %d: %w", m.version, err)
- }
- if err := tx.Commit(); err != nil {
- return fmt.Errorf("commit schema migration %d: %w", m.version, err)
- }
- ok = true
- current = m.version
- }
- return nil
-}
-
-// CreateInitialState atomically records the first accepted state and repository
-// root, and makes that state the accepted head.
-func (s *Store) CreateInitialState(ctx context.Context, root string, initial State) (State, error) {
- if strings.TrimSpace(root) == "" {
- return State{}, errors.New("hop: repository root is required")
- }
- if initial.Kind == "" {
- initial.Kind = StateAccepted
- }
- if initial.Kind != StateAccepted {
- return State{}, fmt.Errorf("hop: initial state must have kind %q", StateAccepted)
- }
- if initial.ID == "" {
- initial.ID = newID(stateIDPrefix(initial.Kind))
- }
- if initial.CreatedAt.IsZero() {
- initial.CreatedAt = time.Now().UTC()
- }
- initial.TaskID = ""
- initial.AttemptID = ""
- initial.CanonicalAnchorID = ""
- initial.Parents = nil
-
- tx, err := s.db.BeginTx(ctx, nil)
- if err != nil {
- return State{}, fmt.Errorf("begin repository initialization: %w", err)
- }
- defer tx.Rollback()
-
- var existing string
- err = tx.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = 'accepted_head'`).Scan(&existing)
- if err == nil {
- return State{}, ErrAlreadyInitialized
- }
- if !errors.Is(err, sql.ErrNoRows) {
- return State{}, fmt.Errorf("check repository initialization: %w", err)
- }
-
- if err := insertStateTx(ctx, tx, initial, nil); err != nil {
- return State{}, err
- }
- for key, value := range map[string]string{
- "root": filepath.Clean(root),
- "accepted_head": initial.ID,
- "materialized_head": initial.ID,
- } {
- if _, err := tx.ExecContext(ctx,
- `INSERT INTO meta(key, value) VALUES (?, ?)
- ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value); err != nil {
- return State{}, fmt.Errorf("write repository metadata %q: %w", key, err)
- }
- }
- if err := insertEventTx(ctx, tx, Event{
- Kind: "repository.initialized",
- StateID: initial.ID,
- }, map[string]string{"root": filepath.Clean(root)}); err != nil {
- return State{}, err
- }
- if err := tx.Commit(); err != nil {
- return State{}, fmt.Errorf("commit repository initialization: %w", err)
- }
- return initial, nil
-}
-
-// Initialize is a convenience alias for CreateInitialState.
-func (s *Store) Initialize(ctx context.Context, root string, initial State) (State, error) {
- return s.CreateInitialState(ctx, root, initial)
-}
-
-// IsInitialized reports whether an accepted head has been installed.
-func (s *Store) IsInitialized(ctx context.Context) (bool, error) {
- _, err := s.AcceptedHeadID(ctx)
- if errors.Is(err, ErrNotInitialized) {
- return false, nil
- }
- return err == nil, err
-}
-
-// AgentSessionHead returns the latest prompt state associated with an agent
-// session. Session heads let interactive agents turn follow-up messages into
-// descendants without asking the human to carry Hop state IDs between turns.
-func (s *Store) AgentSessionHead(ctx context.Context, agent, sessionID string) (string, bool, error) {
- if strings.TrimSpace(agent) == "" || strings.TrimSpace(sessionID) == "" {
- return "", false, errors.New("hop: agent and session ID are required")
- }
- var stateID string
- err := s.db.QueryRowContext(ctx,
- `SELECT head_state_id FROM agent_sessions WHERE agent = ? AND session_id = ?`,
- agent, sessionID).Scan(&stateID)
- if errors.Is(err, sql.ErrNoRows) {
- return "", false, nil
- }
- if err != nil {
- return "", false, fmt.Errorf("read agent session head: %w", err)
- }
- return stateID, true, nil
-}
-
-// SetAgentSessionHead records the prompt state that should parent the next
-// message in an interactive agent session.
-func (s *Store) SetAgentSessionHead(ctx context.Context, agent, sessionID, stateID string) error {
- if strings.TrimSpace(agent) == "" || strings.TrimSpace(sessionID) == "" || strings.TrimSpace(stateID) == "" {
- return errors.New("hop: agent, session ID, and state ID are required")
- }
- if redacted, findings := RedactPromptSecrets(agent + "\n" + sessionID); len(findings) > 0 || redacted != agent+"\n"+sessionID {
- return errors.New("hop: refusing to persist a potential credential in agent session metadata")
- }
- now := formatTime(time.Now().UTC())
- if _, err := s.db.ExecContext(ctx,
- `INSERT INTO agent_sessions(agent, session_id, head_state_id, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?)
- ON CONFLICT(agent, session_id) DO UPDATE SET
- head_state_id = excluded.head_state_id,
- updated_at = excluded.updated_at`,
- agent, sessionID, stateID, now, now); err != nil {
- return fmt.Errorf("record agent session head: %w", err)
- }
- return nil
-}
-
-// RetargetAgentSessions moves interactive sessions that currently point into
-// one attempt to a successor prompt. Reconciliation uses this so a follow-up
-// received while conflicts are being resolved reaches the reconciliation
-// workspace instead of reopening the stale source workspace.
-func (s *Store) RetargetAgentSessions(ctx context.Context, agent, fromAttemptID, expectedHeadID, toStateID string) error {
- if strings.TrimSpace(agent) == "" || strings.TrimSpace(fromAttemptID) == "" ||
- strings.TrimSpace(expectedHeadID) == "" || strings.TrimSpace(toStateID) == "" {
- return errors.New("hop: agent, source attempt, expected head, and target state are required")
- }
- tx, err := s.db.BeginTx(ctx, nil)
- if err != nil {
- return fmt.Errorf("begin agent session retarget: %w", err)
- }
- defer tx.Rollback()
- attempt, err := attemptTx(ctx, tx, fromAttemptID)
- if err != nil {
- return err
- }
- if attempt.HeadStateID != expectedHeadID {
- return &HeadChangedError{Scope: "attempt", Expected: expectedHeadID, Actual: attempt.HeadStateID}
- }
- if _, err := tx.ExecContext(ctx,
- `UPDATE agent_sessions
- SET head_state_id = ?, updated_at = ?
- WHERE agent = ?
- AND head_state_id IN (SELECT id FROM states WHERE attempt_id = ?)`,
- toStateID, formatTime(time.Now().UTC()), agent, fromAttemptID); err != nil {
- return fmt.Errorf("retarget agent sessions: %w", err)
- }
- if err := tx.Commit(); err != nil {
- return fmt.Errorf("commit agent session retarget: %w", err)
- }
- return nil
-}
-
-// CreateTaskAttemptPrompt atomically creates all three records. The prompt is
-// durable and the attempt head points to it before the transaction becomes
-// visible, so an orchestrator may safely deliver the prompt after this returns.
-func (s *Store) CreateTaskAttemptPrompt(
- ctx context.Context,
- task Task,
- attempt Attempt,
- prompt State,
- parents []Parent,
-) (Task, Attempt, State, error) {
- now := time.Now().UTC()
- if task.ID == "" {
- task.ID = newID("t")
- }
- if task.CreatedAt.IsZero() {
- task.CreatedAt = now
- }
- if task.Status == "" {
- task.Status = "active"
- }
- if task.Title == "" {
- task.Title = prompt.Prompt
- }
- task.Title, _ = RedactPromptSecrets(task.Title)
- if attempt.ID == "" {
- attempt.ID = newID("at")
- }
- if attempt.TaskID == "" {
- attempt.TaskID = task.ID
- }
- if attempt.TaskID != task.ID {
- return Task{}, Attempt{}, State{}, errors.New("hop: attempt task does not match task")
- }
- if attempt.CreatedAt.IsZero() {
- attempt.CreatedAt = now
- }
- if attempt.Status == "" {
- attempt.Status = "active"
- }
- if prompt.ID == "" {
- prompt.ID = newID("p")
- }
- if prompt.Kind == "" {
- prompt.Kind = StatePrompt
- }
- if prompt.Kind != StatePrompt {
- return Task{}, Attempt{}, State{}, fmt.Errorf("hop: task instruction must have kind %q", StatePrompt)
- }
- if prompt.TaskID == "" {
- prompt.TaskID = task.ID
- }
- if prompt.TaskID != task.ID {
- return Task{}, Attempt{}, State{}, errors.New("hop: prompt task does not match task")
- }
- if prompt.AttemptID == "" {
- prompt.AttemptID = attempt.ID
- }
- if prompt.AttemptID != attempt.ID {
- return Task{}, Attempt{}, State{}, errors.New("hop: prompt attempt does not match attempt")
- }
- if prompt.Agent == "" {
- prompt.Agent = attempt.Agent
- }
- if prompt.CreatedAt.IsZero() {
- prompt.CreatedAt = now
- }
-
- tx, err := s.db.BeginTx(ctx, nil)
- if err != nil {
- return Task{}, Attempt{}, State{}, fmt.Errorf("begin task creation: %w", err)
- }
- defer tx.Rollback()
-
- acceptedHead, err := metaTx(ctx, tx, "accepted_head")
- if errors.Is(err, sql.ErrNoRows) {
- return Task{}, Attempt{}, State{}, ErrNotInitialized
- }
- if err != nil {
- return Task{}, Attempt{}, State{}, fmt.Errorf("read accepted head: %w", err)
- }
- if attempt.BaseStateID == "" {
- attempt.BaseStateID = acceptedHead
- }
- base, err := stateTx(ctx, tx, attempt.BaseStateID)
- if err != nil {
- return Task{}, Attempt{}, State{}, fmt.Errorf("read attempt base state: %w", err)
- }
- if attempt.HeadStateID != "" && attempt.HeadStateID != prompt.ID {
- return Task{}, Attempt{}, State{}, errors.New("hop: new attempt head must be the prompt state")
- }
- attempt.HeadStateID = prompt.ID
- if prompt.CanonicalAnchorID == "" {
- prompt.CanonicalAnchorID = acceptedHead
- }
- if prompt.SourceTree == "" {
- prompt.SourceTree = base.SourceTree
- }
- if prompt.GitCommit == "" {
- prompt.GitCommit = base.GitCommit
- }
-
- parents = chooseParents(prompt.Parents, parents)
- parents = ensureParentRole(parents, "run_parent", attempt.BaseStateID)
- parents = ensureParentRole(parents, "canonical_anchor", prompt.CanonicalAnchorID)
- parents = canonicalizeParents(parents)
- prompt.Parents = parents
-
- if _, err := tx.ExecContext(ctx,
- `INSERT INTO tasks(id, title, status, created_at) VALUES (?, ?, ?, ?)`,
- task.ID, task.Title, task.Status, formatTime(task.CreatedAt)); err != nil {
- return Task{}, Attempt{}, State{}, fmt.Errorf("insert task: %w", err)
- }
- // head_state_id is temporarily NULL to break the intentional attempt/state
- // reference cycle; it is populated before this transaction commits.
- if _, err := tx.ExecContext(ctx,
- `INSERT INTO attempts(id, task_id, agent, workspace, base_state_id, head_state_id, status, created_at)
- VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
- attempt.ID, attempt.TaskID, attempt.Agent, attempt.Workspace, attempt.BaseStateID,
- attempt.Status, formatTime(attempt.CreatedAt)); err != nil {
- return Task{}, Attempt{}, State{}, fmt.Errorf("insert attempt: %w", err)
- }
- if err := insertStateTx(ctx, tx, prompt, parents); err != nil {
- return Task{}, Attempt{}, State{}, err
- }
- if _, err := tx.ExecContext(ctx,
- `UPDATE attempts SET head_state_id = ? WHERE id = ?`, prompt.ID, attempt.ID); err != nil {
- return Task{}, Attempt{}, State{}, fmt.Errorf("install initial attempt head: %w", err)
- }
- if err := insertEventTx(ctx, tx, Event{
- Kind: "task.created", TaskID: task.ID,
- }, map[string]string{"title": task.Title}); err != nil {
- return Task{}, Attempt{}, State{}, err
- }
- if err := insertEventTx(ctx, tx, Event{
- Kind: "prompt.created", TaskID: task.ID, AttemptID: attempt.ID, StateID: prompt.ID,
- }, nil); err != nil {
- return Task{}, Attempt{}, State{}, err
- }
- if err := tx.Commit(); err != nil {
- return Task{}, Attempt{}, State{}, fmt.Errorf("commit task creation: %w", err)
- }
- return task, attempt, prompt, nil
-}
-
-// CreateAttemptPrompt atomically adds a new attempt and its initial prompt to
-// an existing task. Reconciliation uses a fresh workspace so a frozen proposal
-// and any concurrent work in its original attempt are never overwritten.
-func (s *Store) CreateAttemptPrompt(
- ctx context.Context,
- attempt Attempt,
- prompt State,
- parents []Parent,
- sessionSourceAttemptID string,
- expectedSourceHeadID string,
-) (Attempt, State, error) {
- now := time.Now().UTC()
- if attempt.ID == "" {
- attempt.ID = newID("at")
- }
- if attempt.TaskID == "" {
- return Attempt{}, State{}, errors.New("hop: reconciliation attempt requires a task ID")
- }
- if attempt.BaseStateID == "" {
- return Attempt{}, State{}, errors.New("hop: reconciliation attempt requires a base state")
- }
- if strings.TrimSpace(attempt.Workspace) == "" {
- return Attempt{}, State{}, errors.New("hop: reconciliation attempt requires a workspace")
- }
- if attempt.CreatedAt.IsZero() {
- attempt.CreatedAt = now
- }
- if attempt.Status == "" {
- attempt.Status = "reconciling"
- }
- if prompt.ID == "" {
- prompt.ID = newID("p")
- }
- if prompt.Kind == "" {
- prompt.Kind = StatePrompt
- }
- if prompt.Kind != StatePrompt {
- return Attempt{}, State{}, fmt.Errorf("hop: attempt instruction must have kind %q", StatePrompt)
- }
- if prompt.TaskID == "" {
- prompt.TaskID = attempt.TaskID
- }
- if prompt.TaskID != attempt.TaskID {
- return Attempt{}, State{}, errors.New("hop: prompt task does not match reconciliation attempt")
- }
- if prompt.AttemptID == "" {
- prompt.AttemptID = attempt.ID
- }
- if prompt.AttemptID != attempt.ID {
- return Attempt{}, State{}, errors.New("hop: prompt attempt does not match reconciliation attempt")
- }
- if prompt.Agent == "" {
- prompt.Agent = attempt.Agent
- }
- if prompt.CreatedAt.IsZero() {
- prompt.CreatedAt = now
- }
-
- tx, err := s.db.BeginTx(ctx, nil)
- if err != nil {
- return Attempt{}, State{}, fmt.Errorf("begin reconciliation attempt creation: %w", err)
- }
- defer tx.Rollback()
-
- var taskExists int
- if err := tx.QueryRowContext(ctx, `SELECT 1 FROM tasks WHERE id = ?`, attempt.TaskID).Scan(&taskExists); err != nil {
- return Attempt{}, State{}, dbNotFound("task", attempt.TaskID, err)
- }
- if sessionSourceAttemptID != "" {
- if expectedSourceHeadID == "" {
- return Attempt{}, State{}, errors.New("hop: reconciliation source head is required")
- }
- sourceAttempt, err := attemptTx(ctx, tx, sessionSourceAttemptID)
- if err != nil {
- return Attempt{}, State{}, err
- }
- if sourceAttempt.HeadStateID != expectedSourceHeadID {
- return Attempt{}, State{}, &HeadChangedError{
- Scope: "attempt", Expected: expectedSourceHeadID, Actual: sourceAttempt.HeadStateID,
- }
- }
- }
- base, err := stateTx(ctx, tx, attempt.BaseStateID)
- if err != nil {
- return Attempt{}, State{}, fmt.Errorf("read reconciliation base state: %w", err)
- }
- if prompt.CanonicalAnchorID == "" {
- prompt.CanonicalAnchorID = attempt.BaseStateID
- }
- if prompt.SourceTree == "" {
- prompt.SourceTree = base.SourceTree
- }
- if prompt.GitCommit == "" {
- prompt.GitCommit = base.GitCommit
- }
- parents = chooseParents(prompt.Parents, parents)
- parents = ensureParentRole(parents, "run_parent", attempt.BaseStateID)
- parents = ensureParentRole(parents, "canonical_anchor", prompt.CanonicalAnchorID)
- parents = canonicalizeParents(parents)
- prompt.Parents = parents
- attempt.HeadStateID = prompt.ID
-
- if _, err := tx.ExecContext(ctx,
- `INSERT INTO attempts(id, task_id, agent, workspace, base_state_id, head_state_id, status, created_at)
- VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
- attempt.ID, attempt.TaskID, attempt.Agent, attempt.Workspace, attempt.BaseStateID,
- attempt.Status, formatTime(attempt.CreatedAt)); err != nil {
- return Attempt{}, State{}, fmt.Errorf("insert reconciliation attempt: %w", err)
- }
- if err := insertStateTx(ctx, tx, prompt, parents); err != nil {
- return Attempt{}, State{}, err
- }
- if _, err := tx.ExecContext(ctx,
- `UPDATE attempts SET head_state_id = ? WHERE id = ?`, prompt.ID, attempt.ID); err != nil {
- return Attempt{}, State{}, fmt.Errorf("install reconciliation attempt head: %w", err)
- }
- if sessionSourceAttemptID != "" {
- if _, err := tx.ExecContext(ctx,
- `UPDATE agent_sessions
- SET head_state_id = ?, updated_at = ?
- WHERE agent = ?
- AND head_state_id IN (SELECT id FROM states WHERE attempt_id = ?)`,
- prompt.ID, formatTime(now), attempt.Agent, sessionSourceAttemptID); err != nil {
- return Attempt{}, State{}, fmt.Errorf("retarget reconciliation sessions: %w", err)
- }
- }
- if _, err := tx.ExecContext(ctx,
- `UPDATE tasks SET status = 'reconciling' WHERE id = ?`, attempt.TaskID); err != nil {
- return Attempt{}, State{}, fmt.Errorf("mark task reconciling: %w", err)
- }
- if err := insertEventTx(ctx, tx, Event{
- Kind: "attempt.created", TaskID: attempt.TaskID, AttemptID: attempt.ID,
- }, map[string]string{"purpose": "reconciliation"}); err != nil {
- return Attempt{}, State{}, err
- }
- if err := insertEventTx(ctx, tx, Event{
- Kind: "prompt.created", TaskID: attempt.TaskID, AttemptID: attempt.ID, StateID: prompt.ID,
- }, map[string]string{"purpose": "reconciliation"}); err != nil {
- return Attempt{}, State{}, err
- }
- if err := tx.Commit(); err != nil {
- return Attempt{}, State{}, fmt.Errorf("commit reconciliation attempt creation: %w", err)
- }
- return attempt, prompt, nil
-}
-
-// AppendState atomically appends an immutable state and compare-and-swaps the
-// owning attempt's head. An empty expectedAttemptHeadID means "the head observed
-// by this transaction"; callers that already observed a head should pass it.
-func (s *Store) AppendState(
- ctx context.Context,
- state State,
- parents []Parent,
- expectedAttemptHeadID string,
-) (State, error) {
- if state.AttemptID == "" {
- return State{}, errors.New("hop: appended state requires an attempt ID")
- }
- if state.ID == "" {
- state.ID = newID(stateIDPrefix(state.Kind))
- }
- if state.Kind == "" {
- state.Kind = StateCheckpoint
- }
- if state.CreatedAt.IsZero() {
- state.CreatedAt = time.Now().UTC()
- }
-
- tx, err := s.db.BeginTx(ctx, nil)
- if err != nil {
- return State{}, fmt.Errorf("begin state append: %w", err)
- }
- defer tx.Rollback()
-
- attempt, err := attemptTx(ctx, tx, state.AttemptID)
- if err != nil {
- return State{}, err
- }
- if expectedAttemptHeadID == "" {
- expectedAttemptHeadID = attempt.HeadStateID
- }
- if attempt.HeadStateID != expectedAttemptHeadID {
- return State{}, &HeadChangedError{Scope: "attempt", Expected: expectedAttemptHeadID, Actual: attempt.HeadStateID}
- }
- if state.TaskID == "" {
- state.TaskID = attempt.TaskID
- }
- if state.TaskID != attempt.TaskID {
- return State{}, errors.New("hop: appended state task does not match attempt")
- }
- if state.Agent == "" {
- state.Agent = attempt.Agent
- }
- head, err := stateTx(ctx, tx, attempt.HeadStateID)
- if err != nil {
- return State{}, fmt.Errorf("read attempt head state: %w", err)
- }
- if state.SourceTree == "" {
- state.SourceTree = head.SourceTree
- }
- if state.GitCommit == "" {
- state.GitCommit = head.GitCommit
- }
- if state.CanonicalAnchorID == "" {
- state.CanonicalAnchorID = head.CanonicalAnchorID
- if state.CanonicalAnchorID == "" {
- state.CanonicalAnchorID = attempt.BaseStateID
- }
- }
- parents = chooseParents(state.Parents, parents)
- parents = ensureParentRole(parents, "run_parent", expectedAttemptHeadID)
- if state.Kind == StatePrompt {
- parents = ensureParentRole(parents, "canonical_anchor", state.CanonicalAnchorID)
- }
- parents = canonicalizeParents(parents)
- state.Parents = parents
-
- if err := insertStateTx(ctx, tx, state, parents); err != nil {
- return State{}, err
- }
- result, err := tx.ExecContext(ctx,
- `UPDATE attempts SET head_state_id = ? WHERE id = ? AND head_state_id = ?`,
- state.ID, state.AttemptID, expectedAttemptHeadID)
- if err != nil {
- return State{}, fmt.Errorf("advance attempt head: %w", err)
- }
- changed, err := result.RowsAffected()
- if err != nil {
- return State{}, fmt.Errorf("inspect attempt head update: %w", err)
- }
- if changed != 1 {
- actual, _ := attemptHeadTx(ctx, tx, state.AttemptID)
- return State{}, &HeadChangedError{Scope: "attempt", Expected: expectedAttemptHeadID, Actual: actual}
- }
- if err := insertEventTx(ctx, tx, Event{
- Kind: "state.appended", TaskID: state.TaskID, AttemptID: state.AttemptID, StateID: state.ID,
- }, map[string]string{"kind": string(state.Kind)}); err != nil {
- return State{}, err
- }
- if err := tx.Commit(); err != nil {
- return State{}, fmt.Errorf("commit state append: %w", err)
- }
- return state, nil
-}
-
-// CASAccept atomically inserts an accepted state and advances accepted_head only
-// if it still equals expectedHeadID. Any inserted state is rolled back when the
-// compare-and-swap fails.
-func (s *Store) CASAccept(
- ctx context.Context,
- expectedHeadID string,
- accepted State,
- parents []Parent,
-) (State, error) {
- if expectedHeadID == "" {
- return State{}, errors.New("hop: expected accepted head is required")
- }
- if accepted.ID == "" {
- accepted.ID = newID("a")
- }
- if accepted.Kind == "" {
- accepted.Kind = StateAccepted
- }
- if accepted.Kind != StateAccepted {
- return State{}, fmt.Errorf("hop: accepted state must have kind %q", StateAccepted)
- }
- if accepted.CreatedAt.IsZero() {
- accepted.CreatedAt = time.Now().UTC()
- }
-
- tx, err := s.db.BeginTx(ctx, nil)
- if err != nil {
- return State{}, fmt.Errorf("begin acceptance: %w", err)
- }
- defer tx.Rollback()
-
- actual, err := metaTx(ctx, tx, "accepted_head")
- if errors.Is(err, sql.ErrNoRows) {
- return State{}, ErrNotInitialized
- }
- if err != nil {
- return State{}, fmt.Errorf("read accepted head: %w", err)
- }
- if actual != expectedHeadID {
- return State{}, &HeadChangedError{Scope: "accepted", Expected: expectedHeadID, Actual: actual}
- }
- current, err := stateTx(ctx, tx, expectedHeadID)
- if err != nil {
- return State{}, fmt.Errorf("read current accepted state: %w", err)
- }
-
- parents = chooseParents(accepted.Parents, parents)
- parents = ensureParentRole(parents, "canonical_parent", expectedHeadID)
- parents = canonicalizeParents(parents)
- accepted.Parents = parents
- if accepted.CanonicalAnchorID == "" {
- accepted.CanonicalAnchorID = expectedHeadID
- }
-
- // When the caller supplied a proposal parent but omitted repeated fields,
- // inherit them from that immutable proposal.
- if proposalParent, ok := parentWithRole(parents, "proposal_parent"); ok {
- proposal, err := stateTx(ctx, tx, proposalParent.StateID)
- if err != nil {
- return State{}, fmt.Errorf("read proposal parent: %w", err)
- }
- if proposal.AttemptID != "" {
- attempt, err := attemptTx(ctx, tx, proposal.AttemptID)
- if err != nil {
- return State{}, err
- }
- if attempt.HeadStateID != proposal.ID {
- return State{}, &HeadChangedError{
- Scope: "attempt", Expected: proposal.ID, Actual: attempt.HeadStateID,
- }
- }
- }
- if accepted.TaskID == "" {
- accepted.TaskID = proposal.TaskID
- }
- if accepted.AttemptID == "" {
- accepted.AttemptID = proposal.AttemptID
- }
- if accepted.SourceTree == "" {
- accepted.SourceTree = proposal.SourceTree
- }
- if accepted.GitCommit == "" {
- accepted.GitCommit = proposal.GitCommit
- }
- if accepted.Agent == "" {
- accepted.Agent = proposal.Agent
- }
- }
- if accepted.SourceTree == "" {
- accepted.SourceTree = current.SourceTree
- }
- if accepted.GitCommit == "" {
- accepted.GitCommit = current.GitCommit
- }
-
- if err := insertStateTx(ctx, tx, accepted, parents); err != nil {
- return State{}, err
- }
- result, err := tx.ExecContext(ctx,
- `UPDATE meta SET value = ? WHERE key = 'accepted_head' AND value = ?`,
- accepted.ID, expectedHeadID)
- if err != nil {
- return State{}, fmt.Errorf("advance accepted head: %w", err)
- }
- changed, err := result.RowsAffected()
- if err != nil {
- return State{}, fmt.Errorf("inspect accepted head update: %w", err)
- }
- if changed != 1 {
- actual, _ := metaTx(ctx, tx, "accepted_head")
- return State{}, &HeadChangedError{Scope: "accepted", Expected: expectedHeadID, Actual: actual}
- }
- if accepted.AttemptID != "" {
- if _, err := tx.ExecContext(ctx,
- `UPDATE attempts SET status = 'accepted' WHERE id = ?`, accepted.AttemptID); err != nil {
- return State{}, fmt.Errorf("mark attempt accepted: %w", err)
- }
- }
- if accepted.TaskID != "" {
- if accepted.AttemptID != "" {
- if _, err := tx.ExecContext(ctx,
- `UPDATE attempts
- SET status = 'completed'
- WHERE task_id = ?
- AND id != ?
- AND status NOT IN ('accepted', 'completed', 'failed', 'cancelled', 'rejected')`,
- accepted.TaskID, accepted.AttemptID); err != nil {
- return State{}, fmt.Errorf("complete superseded task attempts: %w", err)
- }
- }
- if _, err := tx.ExecContext(ctx,
- `UPDATE tasks SET status = 'accepted' WHERE id = ?`, accepted.TaskID); err != nil {
- return State{}, fmt.Errorf("mark task accepted: %w", err)
- }
- }
- if err := insertEventTx(ctx, tx, Event{
- Kind: "state.accepted", TaskID: accepted.TaskID, AttemptID: accepted.AttemptID, StateID: accepted.ID,
- }, map[string]string{"previous_head": expectedHeadID}); err != nil {
- return State{}, err
- }
- if err := tx.Commit(); err != nil {
- return State{}, fmt.Errorf("commit acceptance: %w", err)
- }
- return accepted, nil
-}
-
-// AcceptedHeadID returns the current canonical pointer without loading the
-// state body.
-func (s *Store) AcceptedHeadID(ctx context.Context) (string, error) {
- var id string
- err := s.db.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = 'accepted_head'`).Scan(&id)
- if errors.Is(err, sql.ErrNoRows) {
- return "", ErrNotInitialized
- }
- if err != nil {
- return "", fmt.Errorf("read accepted head: %w", err)
- }
- return id, nil
-}
-
-func (s *Store) AcceptedHead(ctx context.Context) (State, error) {
- id, err := s.AcceptedHeadID(ctx)
- if err != nil {
- return State{}, err
- }
- return s.GetState(ctx, id)
-}
-
-// AcceptedForProposal returns the immutable accepted child already created for
-// proposalID. It makes retry after a post-commit projection failure
-// idempotent instead of creating a second acceptance transition.
-func (s *Store) AcceptedForProposal(ctx context.Context, proposalID string) (State, bool, error) {
- var id string
- err := s.db.QueryRowContext(ctx,
- `SELECT p.state_id
- FROM state_parents p
- JOIN states s ON s.id = p.state_id
- WHERE p.parent_state_id = ? AND p.role = 'proposal_parent' AND s.kind = 'accepted'
- ORDER BY s.created_at, s.id
- LIMIT 1`, proposalID).Scan(&id)
- if errors.Is(err, sql.ErrNoRows) {
- return State{}, false, nil
- }
- if err != nil {
- return State{}, false, fmt.Errorf("find accepted state for proposal %s: %w", proposalID, err)
- }
- state, err := s.GetState(ctx, id)
- if err != nil {
- return State{}, false, err
- }
- return state, true, nil
-}
-
-// AcceptedForTask returns the latest immutable accepted outcome produced by a
-// task. Unlike the mutable task status, this remains authoritative when an
-// older client has accidentally reactivated an already accepted task.
-func (s *Store) AcceptedForTask(ctx context.Context, taskID string) (State, bool, error) {
- if strings.TrimSpace(taskID) == "" {
- return State{}, false, errors.New("hop: task ID is required")
- }
- var id string
- err := s.db.QueryRowContext(ctx,
- `SELECT id
- FROM states
- WHERE task_id = ? AND kind = 'accepted'
- ORDER BY created_at DESC, id DESC
- LIMIT 1`, taskID).Scan(&id)
- if errors.Is(err, sql.ErrNoRows) {
- return State{}, false, nil
- }
- if err != nil {
- return State{}, false, fmt.Errorf("find accepted state for task %s: %w", taskID, err)
- }
- state, err := s.GetState(ctx, id)
- if err != nil {
- return State{}, false, err
- }
- return state, true, nil
-}
-
-// ReconciliationPrompt returns the prompt already created to reconcile a
-// proposal against a particular accepted head. The pair is unique by
-// convention and makes `hop refresh` safe to retry.
-func (s *Store) ReconciliationPrompt(ctx context.Context, proposalID, acceptedID string) (State, bool, error) {
- var id string
- err := s.db.QueryRowContext(ctx,
- `SELECT s.id
- FROM states s
- JOIN state_parents source
- ON source.state_id = s.id
- AND source.parent_state_id = ?
- AND source.role = 'reconciliation_source'
- JOIN state_parents anchor
- ON anchor.state_id = s.id
- AND anchor.parent_state_id = ?
- AND anchor.role = 'canonical_anchor'
- WHERE s.kind = 'prompt'
- ORDER BY s.created_at DESC, s.id DESC
- LIMIT 1`, proposalID, acceptedID).Scan(&id)
- if errors.Is(err, sql.ErrNoRows) {
- return State{}, false, nil
- }
- if err != nil {
- return State{}, false, fmt.Errorf("find reconciliation prompt: %w", err)
- }
- state, err := s.GetState(ctx, id)
- if err != nil {
- return State{}, false, err
- }
- return state, true, nil
-}
-
-// ReconciliationPromptForAttempt identifies attempts created specifically to
-// resolve a prior proposal. It lets proposal creation enforce reconciliation
-// evidence even when the caller names a later checkpoint instead of the
-// attempt's initial prompt.
-func (s *Store) ReconciliationPromptForAttempt(ctx context.Context, attemptID string) (State, bool, error) {
- var id string
- err := s.db.QueryRowContext(ctx,
- `SELECT s.id
- FROM states s
- JOIN state_parents source
- ON source.state_id = s.id
- AND source.role = 'reconciliation_source'
- WHERE s.attempt_id = ? AND s.kind = 'prompt'
- ORDER BY s.created_at, s.id
- LIMIT 1`, attemptID).Scan(&id)
- if errors.Is(err, sql.ErrNoRows) {
- return State{}, false, nil
- }
- if err != nil {
- return State{}, false, fmt.Errorf("find reconciliation prompt for attempt %s: %w", attemptID, err)
- }
- state, err := s.GetState(ctx, id)
- if err != nil {
- return State{}, false, err
- }
- return state, true, nil
-}
-
-// MaterializedHead is the accepted state currently projected into the visible
-// project root. It may trail AcceptedHead after controller-only acceptance or
-// an interrupted post-acceptance synchronization.
-func (s *Store) MaterializedHead(ctx context.Context) (State, error) {
- var id string
- err := s.db.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = 'materialized_head'`).Scan(&id)
- if errors.Is(err, sql.ErrNoRows) {
- return State{}, ErrNotInitialized
- }
- if err != nil {
- return State{}, fmt.Errorf("read materialized head: %w", err)
- }
- return s.GetState(ctx, id)
-}
-
-// CASMaterializedHead advances the recoverable visible-root projection marker.
-// Filesystem materialization must be verified before calling this method.
-func (s *Store) CASMaterializedHead(ctx context.Context, expectedID, nextID string) error {
- if expectedID == "" || nextID == "" {
- return errors.New("hop: expected and next materialized heads are required")
- }
- if expectedID == nextID {
- return nil
- }
- next, err := s.GetState(ctx, nextID)
- if err != nil {
- return err
- }
- if next.Kind != StateAccepted {
- return fmt.Errorf("hop: materialized head must be an accepted state, found %s", next.Kind)
- }
- result, err := s.db.ExecContext(ctx,
- `UPDATE meta SET value = ? WHERE key = 'materialized_head' AND value = ?`,
- nextID, expectedID)
- if err != nil {
- return fmt.Errorf("advance materialized head: %w", err)
- }
- rows, err := result.RowsAffected()
- if err != nil {
- return fmt.Errorf("inspect materialized head update: %w", err)
- }
- if rows != 1 {
- var actual string
- _ = s.db.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = 'materialized_head'`).Scan(&actual)
- return &HeadChangedError{Scope: "materialized", Expected: expectedID, Actual: actual}
- }
- return nil
-}
-
-func (s *Store) RepositoryRoot(ctx context.Context) (string, error) {
- var root string
- err := s.db.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = 'root'`).Scan(&root)
- if errors.Is(err, sql.ErrNoRows) {
- return "", ErrNotInitialized
- }
- if err != nil {
- return "", fmt.Errorf("read repository root: %w", err)
- }
- return root, nil
-}
-
-func (s *Store) GetState(ctx context.Context, id string) (State, error) {
- state, err := scanState(s.db.QueryRowContext(ctx, `SELECT `+stateColumns+` FROM states WHERE id = ?`, id))
- if err != nil {
- return State{}, dbNotFound("state", id, err)
- }
- parents, err := s.GetParents(ctx, id)
- if err != nil {
- return State{}, err
- }
- state.Parents = parents
- return state, nil
-}
-
-func (s *Store) GetParents(ctx context.Context, stateID string) ([]Parent, error) {
- rows, err := s.db.QueryContext(ctx,
- `SELECT parent_state_id, role, parent_order
- FROM state_parents WHERE state_id = ? ORDER BY parent_order`, stateID)
- if err != nil {
- return nil, fmt.Errorf("query state parents: %w", err)
- }
- defer rows.Close()
- var parents []Parent
- for rows.Next() {
- var parent Parent
- if err := rows.Scan(&parent.StateID, &parent.Role, &parent.Order); err != nil {
- return nil, fmt.Errorf("scan state parent: %w", err)
- }
- parents = append(parents, parent)
- }
- if err := rows.Err(); err != nil {
- return nil, fmt.Errorf("iterate state parents: %w", err)
- }
- return parents, nil
-}
-
-// StateDescendsFrom reports whether descendantID is the same state as, or has
-// any typed-parent path to, ancestorID. Session rollover uses state ancestry so
-// a mutable task status cannot hide unfinished prompts created after an older
-// accepted outcome.
-func (s *Store) StateDescendsFrom(ctx context.Context, descendantID, ancestorID string) (bool, error) {
- if strings.TrimSpace(descendantID) == "" || strings.TrimSpace(ancestorID) == "" {
- return false, errors.New("hop: descendant and ancestor state IDs are required")
- }
- var found int
- err := s.db.QueryRowContext(ctx,
- `WITH RECURSIVE ancestry(id) AS (
- SELECT ?
- UNION
- SELECT parents.parent_state_id
- FROM state_parents parents
- JOIN ancestry ON parents.state_id = ancestry.id
- )
- SELECT 1 FROM ancestry WHERE id = ? LIMIT 1`,
- descendantID, ancestorID).Scan(&found)
- if errors.Is(err, sql.ErrNoRows) {
- return false, nil
- }
- if err != nil {
- return false, fmt.Errorf("inspect state ancestry: %w", err)
- }
- return found == 1, nil
-}
-
-func (s *Store) ParentByRole(ctx context.Context, stateID, role string) (Parent, error) {
- var parent Parent
- err := s.db.QueryRowContext(ctx,
- `SELECT parent_state_id, role, parent_order
- FROM state_parents WHERE state_id = ? AND role = ? ORDER BY parent_order LIMIT 1`,
- stateID, role).Scan(&parent.StateID, &parent.Role, &parent.Order)
- if err != nil {
- return Parent{}, dbNotFound("parent", role, err)
- }
- return parent, nil
-}
-
-func (s *Store) ParentsByRole(ctx context.Context, stateID, role string) ([]Parent, error) {
- rows, err := s.db.QueryContext(ctx,
- `SELECT parent_state_id, role, parent_order
- FROM state_parents WHERE state_id = ? AND role = ? ORDER BY parent_order`,
- stateID, role)
- if err != nil {
- return nil, fmt.Errorf("query state parents by role: %w", err)
- }
- defer rows.Close()
- var parents []Parent
- for rows.Next() {
- var parent Parent
- if err := rows.Scan(&parent.StateID, &parent.Role, &parent.Order); err != nil {
- return nil, fmt.Errorf("scan state parent: %w", err)
- }
- parents = append(parents, parent)
- }
- return parents, rows.Err()
-}
-
-func (s *Store) GetTask(ctx context.Context, id string) (Task, error) {
- return scanTask(s.db.QueryRowContext(ctx,
- `SELECT id, title, status, created_at FROM tasks WHERE id = ?`, id), id)
-}
-
-func (s *Store) ListTasks(ctx context.Context, status string) ([]Task, error) {
- query := `SELECT id, title, status, created_at FROM tasks`
- var args []any
- if status != "" {
- query += ` WHERE status = ?`
- args = append(args, status)
- }
- query += ` ORDER BY created_at, id`
- rows, err := s.db.QueryContext(ctx, query, args...)
- if err != nil {
- return nil, fmt.Errorf("query tasks: %w", err)
- }
- defer rows.Close()
- var tasks []Task
- for rows.Next() {
- task, err := scanTask(rows, "")
- if err != nil {
- return nil, err
- }
- tasks = append(tasks, task)
- }
- if err := rows.Err(); err != nil {
- return nil, fmt.Errorf("iterate tasks: %w", err)
- }
- return tasks, nil
-}
-
-func (s *Store) GetAttempt(ctx context.Context, id string) (Attempt, error) {
- attempt, err := scanAttempt(s.db.QueryRowContext(ctx,
- `SELECT `+attemptColumns+` FROM attempts WHERE id = ?`, id))
- if err != nil {
- return Attempt{}, dbNotFound("attempt", id, err)
- }
- return attempt, nil
-}
-
-// ListAttempts filters by task and status when either value is non-empty.
-func (s *Store) ListAttempts(ctx context.Context, taskID, status string) ([]Attempt, error) {
- query := `SELECT ` + attemptColumns + ` FROM attempts WHERE 1 = 1`
- var args []any
- if taskID != "" {
- query += ` AND task_id = ?`
- args = append(args, taskID)
- }
- if status != "" {
- query += ` AND status = ?`
- args = append(args, status)
- }
- query += ` ORDER BY created_at, id`
- return s.queryAttempts(ctx, query, args...)
-}
-
-func (s *Store) ActiveAttempts(ctx context.Context) ([]Attempt, error) {
- return s.queryAttempts(ctx,
- `SELECT `+attemptColumns+` FROM attempts
- WHERE status NOT IN ('accepted', 'completed', 'failed', 'cancelled', 'rejected')
- ORDER BY created_at, id`)
-}
-
-func (s *Store) queryAttempts(ctx context.Context, query string, args ...any) ([]Attempt, error) {
- rows, err := s.db.QueryContext(ctx, query, args...)
- if err != nil {
- return nil, fmt.Errorf("query attempts: %w", err)
- }
- defer rows.Close()
- var attempts []Attempt
- for rows.Next() {
- attempt, err := scanAttempt(rows)
- if err != nil {
- return nil, err
- }
- attempts = append(attempts, attempt)
- }
- if err := rows.Err(); err != nil {
- return nil, fmt.Errorf("iterate attempts: %w", err)
- }
- return attempts, nil
-}
-
-func (s *Store) UpdateAttemptStatus(ctx context.Context, id, status string) error {
- if status == "" {
- return errors.New("hop: attempt status is required")
- }
- result, err := s.db.ExecContext(ctx, `UPDATE attempts SET status = ? WHERE id = ?`, status, id)
- if err != nil {
- return fmt.Errorf("update attempt status: %w", err)
- }
- return requireOneRow(result, "attempt", id)
-}
-
-func (s *Store) UpdateTaskStatus(ctx context.Context, id, status string) error {
- if status == "" {
- return errors.New("hop: task status is required")
- }
- result, err := s.db.ExecContext(ctx, `UPDATE tasks SET status = ? WHERE id = ?`, status, id)
- if err != nil {
- return fmt.Errorf("update task status: %w", err)
- }
- return requireOneRow(result, "task", id)
-}
-
-// UpdateAttemptHead performs a standalone compare-and-swap. AppendState should
-// normally be preferred because it cannot point at a state that was not created
-// as part of the same logical operation.
-func (s *Store) UpdateAttemptHead(ctx context.Context, attemptID, expectedHeadID, nextHeadID string) error {
- result, err := s.db.ExecContext(ctx,
- `UPDATE attempts SET head_state_id = ? WHERE id = ? AND head_state_id = ?`,
- nextHeadID, attemptID, expectedHeadID)
- if err != nil {
- return fmt.Errorf("advance attempt head: %w", err)
- }
- changed, err := result.RowsAffected()
- if err != nil {
- return fmt.Errorf("inspect attempt head update: %w", err)
- }
- if changed == 1 {
- return nil
- }
- attempt, getErr := s.GetAttempt(ctx, attemptID)
- if getErr != nil {
- return getErr
- }
- return &HeadChangedError{Scope: "attempt", Expected: expectedHeadID, Actual: attempt.HeadStateID}
-}
-
-// AddCheck records evidence bound to a tree. If StateID is present, TreeHash is
-// filled from that state when empty and otherwise must match its source tree.
-func (s *Store) AddCheck(ctx context.Context, check Check) (Check, error) {
- if check.ID == "" {
- check.ID = newID("check")
- }
- if check.AttemptID == "" {
- return Check{}, errors.New("hop: check attempt ID is required")
- }
- if check.CreatedAt.IsZero() {
- check.CreatedAt = time.Now().UTC()
- }
- check.Command, _ = redactSecretStrings(check.Command)
- check.Output, _ = RedactPromptSecrets(check.Output)
- tx, err := s.db.BeginTx(ctx, nil)
- if err != nil {
- return Check{}, fmt.Errorf("begin check creation: %w", err)
- }
- defer tx.Rollback()
-
- if _, err := attemptTx(ctx, tx, check.AttemptID); err != nil {
- return Check{}, err
- }
- if check.StateID != "" {
- state, err := stateTx(ctx, tx, check.StateID)
- if err != nil {
- return Check{}, err
- }
- if state.AttemptID != "" && state.AttemptID != check.AttemptID {
- return Check{}, errors.New("hop: check state belongs to another attempt")
- }
- if check.TreeHash == "" {
- check.TreeHash = state.SourceTree
- } else if check.TreeHash != state.SourceTree {
- return Check{}, errors.New("hop: check tree hash does not match state source tree")
- }
- }
- if check.TreeHash == "" {
- return Check{}, errors.New("hop: check tree hash is required")
- }
- command, err := json.Marshal(check.Command)
- if err != nil {
- return Check{}, fmt.Errorf("encode check command: %w", err)
- }
- if _, err := tx.ExecContext(ctx,
- `INSERT INTO checks(id, attempt_id, state_id, tree_hash, command_json, exit_code, output, created_at)
- VALUES (?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?)`,
- check.ID, check.AttemptID, check.StateID, check.TreeHash, string(command), check.ExitCode,
- check.Output, formatTime(check.CreatedAt)); err != nil {
- return Check{}, fmt.Errorf("insert check: %w", err)
- }
- attempt, err := attemptTx(ctx, tx, check.AttemptID)
- if err != nil {
- return Check{}, err
- }
- if err := insertEventTx(ctx, tx, Event{
- Kind: "check.recorded", TaskID: attempt.TaskID, AttemptID: check.AttemptID, StateID: check.StateID,
- }, map[string]any{"check_id": check.ID, "tree_hash": check.TreeHash, "exit_code": check.ExitCode}); err != nil {
- return Check{}, err
- }
- if err := tx.Commit(); err != nil {
- return Check{}, fmt.Errorf("commit check creation: %w", err)
- }
- return check, nil
-}
-
-// CreateCheck is a convenience alias for AddCheck.
-func (s *Store) CreateCheck(ctx context.Context, check Check) (Check, error) {
- return s.AddCheck(ctx, check)
-}
-
-func (s *Store) GetCheck(ctx context.Context, id string) (Check, error) {
- check, err := scanCheck(s.db.QueryRowContext(ctx,
- `SELECT `+checkColumns+` FROM checks WHERE id = ?`, id))
- if err != nil {
- return Check{}, dbNotFound("check", id, err)
- }
- return check, nil
-}
-
-// ListChecks filters by attempt and/or exact source tree. Passing both empty
-// lists all checks.
-func (s *Store) ListChecks(ctx context.Context, attemptID, treeHash string) ([]Check, error) {
- query := `SELECT ` + checkColumns + ` FROM checks WHERE 1 = 1`
- var args []any
- if attemptID != "" {
- query += ` AND attempt_id = ?`
- args = append(args, attemptID)
- }
- if treeHash != "" {
- query += ` AND tree_hash = ?`
- args = append(args, treeHash)
- }
- query += ` ORDER BY created_at, id`
- rows, err := s.db.QueryContext(ctx, query, args...)
- if err != nil {
- return nil, fmt.Errorf("query checks: %w", err)
- }
- defer rows.Close()
- var checks []Check
- for rows.Next() {
- check, err := scanCheck(rows)
- if err != nil {
- return nil, err
- }
- checks = append(checks, check)
- }
- if err := rows.Err(); err != nil {
- return nil, fmt.Errorf("iterate checks: %w", err)
- }
- return checks, nil
-}
-
-func (s *Store) ChecksForTree(ctx context.Context, treeHash string) ([]Check, error) {
- return s.ListChecks(ctx, "", treeHash)
-}
-
-// Graph returns states in creation order with their typed parent edges. An
-// empty taskID returns the repository-wide graph.
-func (s *Store) Graph(ctx context.Context, taskID string) ([]GraphRow, error) {
- query := `SELECT ` + stateColumns + ` FROM states`
- var args []any
- if taskID != "" {
- query += ` WHERE task_id = ?`
- args = append(args, taskID)
- }
- query += ` ORDER BY created_at, id`
- rows, err := s.db.QueryContext(ctx, query, args...)
- if err != nil {
- return nil, fmt.Errorf("query state graph: %w", err)
- }
- var states []State
- for rows.Next() {
- state, err := scanState(rows)
- if err != nil {
- _ = rows.Close()
- return nil, err
- }
- states = append(states, state)
- }
- if err := rows.Err(); err != nil {
- _ = rows.Close()
- return nil, fmt.Errorf("iterate state graph: %w", err)
- }
- if err := rows.Close(); err != nil {
- return nil, fmt.Errorf("close state graph rows: %w", err)
- }
-
- graph := make([]GraphRow, 0, len(states))
- for _, state := range states {
- parents, err := s.GetParents(ctx, state.ID)
- if err != nil {
- return nil, err
- }
- state.Parents = parents
- graph = append(graph, GraphRow{State: state, Parents: parents})
- }
- return graph, nil
-}
-
-// AcceptedHistory follows canonical_parent edges from newest to oldest.
-func (s *Store) AcceptedHistory(ctx context.Context, limit int) ([]State, error) {
- if limit <= 0 {
- limit = 1000
- }
- rows, err := s.db.QueryContext(ctx,
- `WITH RECURSIVE lineage(id, depth) AS (
- SELECT value, 0 FROM meta WHERE key = 'accepted_head'
- UNION ALL
- SELECT p.parent_state_id, lineage.depth + 1
- FROM lineage
- JOIN state_parents p ON p.state_id = lineage.id AND p.role = 'canonical_parent'
- WHERE lineage.depth < 9999
- )
- SELECT id FROM lineage ORDER BY depth LIMIT ?`, limit)
- if err != nil {
- return nil, fmt.Errorf("query accepted history: %w", err)
- }
- var ids []string
- for rows.Next() {
- var id string
- if err := rows.Scan(&id); err != nil {
- _ = rows.Close()
- return nil, fmt.Errorf("scan accepted history: %w", err)
- }
- ids = append(ids, id)
- }
- if err := rows.Err(); err != nil {
- _ = rows.Close()
- return nil, fmt.Errorf("iterate accepted history: %w", err)
- }
- if err := rows.Close(); err != nil {
- return nil, fmt.Errorf("close accepted history rows: %w", err)
- }
- if len(ids) == 0 {
- return nil, ErrNotInitialized
- }
- history := make([]State, 0, len(ids))
- for _, id := range ids {
- state, err := s.GetState(ctx, id)
- if err != nil {
- return nil, err
- }
- history = append(history, state)
- }
- return history, nil
-}
-
-// History is a convenience alias for AcceptedHistory.
-func (s *Store) History(ctx context.Context, limit int) ([]State, error) {
- return s.AcceptedHistory(ctx, limit)
-}
-
-func (s *Store) Status(ctx context.Context) (Status, error) {
- root, err := s.RepositoryRoot(ctx)
- if err != nil {
- return Status{}, err
- }
- head, err := s.AcceptedHead(ctx)
- if err != nil {
- return Status{}, err
- }
- attempts, err := s.ActiveAttempts(ctx)
- if err != nil {
- return Status{}, err
- }
- return Status{Root: root, AcceptedHead: head, Attempts: attempts}, nil
-}
-
-// AppendEvent adds an explicit audit event. Store transitions also add their
-// own events in the same transactions as the state changes they describe.
-func (s *Store) AppendEvent(ctx context.Context, event Event) (Event, error) {
- if event.Kind == "" {
- return Event{}, errors.New("hop: event kind is required")
- }
- if event.ID == "" {
- event.ID = newID("e")
- }
- if event.CreatedAt.IsZero() {
- event.CreatedAt = time.Now().UTC()
- }
- payload := event.Payload
- if len(payload) == 0 {
- payload = json.RawMessage(`{}`)
- }
- if !json.Valid(payload) {
- return Event{}, errors.New("hop: event payload is not valid JSON")
- }
- _, err := s.db.ExecContext(ctx,
- `INSERT INTO events(id, kind, task_id, attempt_id, state_id, payload_json, created_at)
- VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?)`,
- event.ID, event.Kind, event.TaskID, event.AttemptID, event.StateID, string(payload), formatTime(event.CreatedAt))
- if err != nil {
- return Event{}, fmt.Errorf("insert event: %w", err)
- }
- event.Payload = payload
- return event, nil
-}
-
-func (s *Store) ListEvents(ctx context.Context, limit int) ([]Event, error) {
- if limit <= 0 {
- limit = 1000
- }
- rows, err := s.db.QueryContext(ctx,
- `SELECT id, kind, COALESCE(task_id, ''), COALESCE(attempt_id, ''), COALESCE(state_id, ''), payload_json, created_at
- FROM events ORDER BY created_at, id LIMIT ?`, limit)
- if err != nil {
- return nil, fmt.Errorf("query events: %w", err)
- }
- defer rows.Close()
- var events []Event
- for rows.Next() {
- var event Event
- var payload, created string
- if err := rows.Scan(&event.ID, &event.Kind, &event.TaskID, &event.AttemptID, &event.StateID, &payload, &created); err != nil {
- return nil, fmt.Errorf("scan event: %w", err)
- }
- createdAt, err := parseTime(created)
- if err != nil {
- return nil, err
- }
- event.CreatedAt = createdAt
- event.Payload = json.RawMessage(payload)
- events = append(events, event)
- }
- if err := rows.Err(); err != nil {
- return nil, fmt.Errorf("iterate events: %w", err)
- }
- return events, nil
-}
-
-const stateColumns = `id, kind, COALESCE(task_id, ''), COALESCE(attempt_id, ''),
- COALESCE(canonical_anchor_id, ''), source_tree, git_commit, prompt, summary, agent, digest, created_at`
-
-const attemptColumns = `id, task_id, agent, workspace, base_state_id, COALESCE(head_state_id, ''), status, created_at`
-
-const checkColumns = `id, attempt_id, COALESCE(state_id, ''), tree_hash, command_json, exit_code, output, created_at`
-
-type scanner interface {
- Scan(dest ...any) error
-}
-
-func scanState(row scanner) (State, error) {
- var state State
- var kind, created string
- if err := row.Scan(
- &state.ID, &kind, &state.TaskID, &state.AttemptID, &state.CanonicalAnchorID,
- &state.SourceTree, &state.GitCommit, &state.Prompt, &state.Summary, &state.Agent,
- &state.Digest, &created,
- ); err != nil {
- return State{}, err
- }
- createdAt, err := parseTime(created)
- if err != nil {
- return State{}, err
- }
- state.Kind = StateKind(kind)
- state.CreatedAt = createdAt
- return state, nil
-}
-
-func scanTask(row scanner, requestedID string) (Task, error) {
- var task Task
- var created string
- if err := row.Scan(&task.ID, &task.Title, &task.Status, &created); err != nil {
- return Task{}, dbNotFound("task", requestedID, err)
- }
- createdAt, err := parseTime(created)
- if err != nil {
- return Task{}, err
- }
- task.CreatedAt = createdAt
- return task, nil
-}
-
-func scanAttempt(row scanner) (Attempt, error) {
- var attempt Attempt
- var created string
- if err := row.Scan(
- &attempt.ID, &attempt.TaskID, &attempt.Agent, &attempt.Workspace,
- &attempt.BaseStateID, &attempt.HeadStateID, &attempt.Status, &created,
- ); err != nil {
- return Attempt{}, err
- }
- createdAt, err := parseTime(created)
- if err != nil {
- return Attempt{}, err
- }
- attempt.CreatedAt = createdAt
- return attempt, nil
-}
-
-func scanCheck(row scanner) (Check, error) {
- var check Check
- var command, created string
- if err := row.Scan(
- &check.ID, &check.AttemptID, &check.StateID, &check.TreeHash, &command,
- &check.ExitCode, &check.Output, &created,
- ); err != nil {
- return Check{}, err
- }
- if err := json.Unmarshal([]byte(command), &check.Command); err != nil {
- return Check{}, fmt.Errorf("decode check command: %w", err)
- }
- createdAt, err := parseTime(created)
- if err != nil {
- return Check{}, err
- }
- check.CreatedAt = createdAt
- return check, nil
-}
-
-func stateTx(ctx context.Context, tx *sql.Tx, id string) (State, error) {
- state, err := scanState(tx.QueryRowContext(ctx,
- `SELECT `+stateColumns+` FROM states WHERE id = ?`, id))
- if err != nil {
- return State{}, dbNotFound("state", id, err)
- }
- return state, nil
-}
-
-func attemptTx(ctx context.Context, tx *sql.Tx, id string) (Attempt, error) {
- attempt, err := scanAttempt(tx.QueryRowContext(ctx,
- `SELECT `+attemptColumns+` FROM attempts WHERE id = ?`, id))
- if err != nil {
- return Attempt{}, dbNotFound("attempt", id, err)
- }
- return attempt, nil
-}
-
-func attemptHeadTx(ctx context.Context, tx *sql.Tx, id string) (string, error) {
- var head string
- err := tx.QueryRowContext(ctx,
- `SELECT COALESCE(head_state_id, '') FROM attempts WHERE id = ?`, id).Scan(&head)
- return head, err
-}
-
-func metaTx(ctx context.Context, tx *sql.Tx, key string) (string, error) {
- var value string
- err := tx.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = ?`, key).Scan(&value)
- return value, err
-}
-
-func insertStateTx(ctx context.Context, tx *sql.Tx, state State, parents []Parent) error {
- if state.ID == "" {
- return errors.New("hop: state ID is required")
- }
- if state.Kind == "" {
- return errors.New("hop: state kind is required")
- }
- if state.CreatedAt.IsZero() {
- return errors.New("hop: state creation time is required")
- }
- for name, value := range map[string]string{
- "prompt": state.Prompt,
- "summary": state.Summary,
- "agent": state.Agent,
- } {
- if redacted, findings := RedactPromptSecrets(value); len(findings) > 0 || redacted != value {
- return fmt.Errorf("hop: refusing to persist an unredacted credential in state %s", name)
- }
- }
- _, err := tx.ExecContext(ctx,
- `INSERT INTO states(
- id, kind, task_id, attempt_id, canonical_anchor_id, source_tree, git_commit,
- prompt, summary, agent, digest, created_at
- ) VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?)`,
- state.ID, string(state.Kind), state.TaskID, state.AttemptID, state.CanonicalAnchorID,
- state.SourceTree, state.GitCommit, state.Prompt, state.Summary, state.Agent,
- state.Digest, formatTime(state.CreatedAt))
- if err != nil {
- return fmt.Errorf("insert state %q: %w", state.ID, err)
- }
- for _, parent := range parents {
- if parent.StateID == "" {
- return errors.New("hop: parent state ID is required")
- }
- if _, err := tx.ExecContext(ctx,
- `INSERT INTO state_parents(state_id, parent_state_id, role, parent_order)
- VALUES (?, ?, ?, ?)`, state.ID, parent.StateID, parent.Role, parent.Order); err != nil {
- return fmt.Errorf("insert parent for state %q: %w", state.ID, err)
- }
- }
- return nil
-}
-
-func insertEventTx(ctx context.Context, tx *sql.Tx, event Event, payload any) error {
- if event.ID == "" {
- event.ID = newID("e")
- }
- if event.CreatedAt.IsZero() {
- event.CreatedAt = time.Now().UTC()
- }
- var encoded []byte
- var err error
- if payload == nil {
- encoded = []byte(`{}`)
- } else {
- encoded, err = json.Marshal(payload)
- if err != nil {
- return fmt.Errorf("encode event payload: %w", err)
- }
- }
- if redacted, findings := RedactPromptSecrets(string(encoded)); len(findings) > 0 || redacted != string(encoded) {
- return fmt.Errorf("hop: refusing to persist an unredacted credential in event %q", event.Kind)
- }
- _, err = tx.ExecContext(ctx,
- `INSERT INTO events(id, kind, task_id, attempt_id, state_id, payload_json, created_at)
- VALUES (?, ?, NULLIF(?, ''), NULLIF(?, ''), NULLIF(?, ''), ?, ?)`,
- event.ID, event.Kind, event.TaskID, event.AttemptID, event.StateID,
- string(encoded), formatTime(event.CreatedAt))
- if err != nil {
- return fmt.Errorf("insert event %q: %w", event.Kind, err)
- }
- return nil
-}
-
-func chooseParents(fromState, explicit []Parent) []Parent {
- if len(explicit) > 0 {
- return append([]Parent(nil), explicit...)
- }
- return append([]Parent(nil), fromState...)
-}
-
-func ensureParentRole(parents []Parent, role, stateID string) []Parent {
- if stateID == "" {
- return parents
- }
- for _, parent := range parents {
- if parent.Role == role {
- return parents
- }
- }
- return append(parents, Parent{StateID: stateID, Role: role, Order: len(parents)})
-}
-
-func parentWithRole(parents []Parent, role string) (Parent, bool) {
- for _, parent := range parents {
- if parent.Role == role {
- return parent, true
- }
- }
- return Parent{}, false
-}
-
-func canonicalizeParents(parents []Parent) []Parent {
- parents = append([]Parent(nil), parents...)
- used := make(map[int]bool, len(parents))
- for i := range parents {
- if parents[i].Role == "" {
- parents[i].Role = "parent"
- }
- order := parents[i].Order
- if order < 0 || used[order] {
- order = 0
- for used[order] {
- order++
- }
- parents[i].Order = order
- }
- used[order] = true
- }
- return parents
-}
-
-func stateIDPrefix(kind StateKind) string {
- switch kind {
- case StatePrompt:
- return "p"
- case StateCheckpoint:
- return "c"
- case StateProposal:
- return "r"
- case StateAccepted:
- return "a"
- case StateFailed:
- return "f"
- case StateCancelled:
- return "x"
- default:
- return "s"
- }
-}
-
-func formatTime(value time.Time) string {
- return value.UTC().Format(time.RFC3339Nano)
-}
-
-func parseTime(value string) (time.Time, error) {
- parsed, err := time.Parse(time.RFC3339Nano, value)
- if err != nil {
- return time.Time{}, fmt.Errorf("decode database timestamp %q: %w", value, err)
- }
- return parsed, nil
-}
-
-func dbNotFound(kind, id string, err error) error {
- if errors.Is(err, sql.ErrNoRows) {
- if id == "" {
- return fmt.Errorf("%w: %s", ErrNotFound, kind)
- }
- return fmt.Errorf("%w: %s %q", ErrNotFound, kind, id)
- }
- return fmt.Errorf("read %s %q: %w", kind, id, err)
-}
-
-func requireOneRow(result sql.Result, kind, id string) error {
- changed, err := result.RowsAffected()
- if err != nil {
- return fmt.Errorf("inspect %s update: %w", kind, err)
- }
- if changed != 1 {
- return fmt.Errorf("%w: %s %q", ErrNotFound, kind, id)
- }
- return nil
-}
diff --git a/internal/hop/docs_test.go b/internal/hop/docs_test.go
deleted file mode 100644
index 217e99b..0000000
--- a/internal/hop/docs_test.go
+++ /dev/null
@@ -1,107 +0,0 @@
-package hop
-
-import (
- "os"
- "path/filepath"
- "regexp"
- "strings"
- "testing"
-)
-
-var markdownLink = regexp.MustCompile(`\[[^]]+\]\(([^)]+)\)`)
-
-func TestDocumentationLinksResolve(t *testing.T) {
- root := filepath.Clean(filepath.Join("..", ".."))
- files := []string{filepath.Join(root, "README.md")}
- wiki, err := filepath.Glob(filepath.Join(root, "wiki", "*.md"))
- if err != nil {
- t.Fatal(err)
- }
- files = append(files, wiki...)
- if len(wiki) < 10 {
- t.Fatalf("wiki contains %d pages, want at least 10", len(wiki))
- }
- for _, file := range files {
- contents, err := os.ReadFile(file)
- if err != nil {
- t.Fatal(err)
- }
- for _, match := range markdownLink.FindAllStringSubmatch(string(contents), -1) {
- target := match[1]
- if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") ||
- strings.HasPrefix(target, "#") || strings.HasPrefix(target, "mailto:") {
- continue
- }
- if anchor := strings.IndexByte(target, '#'); anchor >= 0 {
- target = target[:anchor]
- }
- if target == "" {
- continue
- }
- resolved := filepath.Clean(filepath.Join(filepath.Dir(file), filepath.FromSlash(target)))
- if filepath.Dir(file) == filepath.Join(root, "wiki") && filepath.Ext(resolved) == "" {
- resolved += ".md"
- }
- if _, err := os.Stat(resolved); err != nil {
- t.Errorf("%s links to missing %s: %v", file, target, err)
- }
- }
- }
-}
-
-func TestProductDocumentationUsesCanonicalGiteaHost(t *testing.T) {
- for _, relative := range []string{"README.md", "wiki/Home.md", "wiki/Installation.md"} {
- contents, err := os.ReadFile(filepath.Join("..", "..", filepath.FromSlash(relative)))
- if err != nil {
- t.Fatal(err)
- }
- text := string(contents)
- if !strings.Contains(text, "githop.xyz/GnosysLabs/Hop") {
- t.Errorf("%s does not name the canonical distribution host", relative)
- }
- if strings.Contains(text, "raw.githubusercontent.com/hop-vcs/hop") ||
- strings.Contains(text, "github.com/hop-vcs/hop") {
- t.Errorf("%s still points installation at GitHub", relative)
- }
- }
-}
-
-func TestDistributionDoesNotRequireGiteaActions(t *testing.T) {
- root := filepath.Clean(filepath.Join("..", ".."))
- workflows, err := filepath.Glob(filepath.Join(root, ".gitea", "workflows", "*"))
- if err != nil {
- t.Fatal(err)
- }
- if len(workflows) != 0 {
- t.Fatalf("Gitea Actions workflows remain enabled in source: %v", workflows)
- }
- contents, err := os.ReadFile(filepath.Join(root, "wiki", "Release-Checklist.md"))
- if err != nil {
- t.Fatal(err)
- }
- if strings.Contains(string(contents), ".gitea/workflows/") {
- 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")
- }
-}
diff --git a/internal/hop/git.go b/internal/hop/git.go
deleted file mode 100644
index d8e059a..0000000
--- a/internal/hop/git.go
+++ /dev/null
@@ -1,1486 +0,0 @@
-package hop
-
-import (
- "bytes"
- "context"
- "errors"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "sort"
- "strings"
- "syscall"
- "time"
-)
-
-const (
- defaultGitBinary = "git"
- defaultCommitName = "Hop"
- defaultCommitMail = "hop@localhost"
- hiddenRefPrefix = "refs/hop/"
-)
-
-// GitIdentity is the identity written to synthetic commits made by Hop.
-type GitIdentity struct {
- Name string
- Email string
-}
-
-// GitStore locates repositories and supplies the Git executable and synthetic
-// commit identity used by them. Its zero value is ready to use.
-type GitStore struct {
- Binary string
- Identity GitIdentity
- Now func() time.Time
-}
-
-// Repository is a non-bare Git worktree. Hop uses the repository's object
-// database as storage, but never needs to stage data in the user's index.
-type Repository struct {
- root string
- gitDir string
- commonGitDir string
- store *GitStore
-}
-
-// GitError reports a failed Git invocation without exposing its environment.
-type GitError struct {
- Args []string
- ExitCode int
- Stdout string
- Stderr string
- Err error
-}
-
-func (e *GitError) Error() string {
- message := strings.TrimSpace(e.Stderr)
- if message == "" {
- message = strings.TrimSpace(e.Stdout)
- }
- if message == "" {
- message = e.Err.Error()
- }
- return fmt.Sprintf("git %s: %s", strings.Join(e.Args, " "), message)
-}
-
-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.
-type CommitOptions struct {
- Message string
- Parents []string
- Author GitIdentity
- Committer GitIdentity
- AuthorTime time.Time
- CommitterTime time.Time
-}
-
-// SnapshotOptions controls a workspace snapshot commit.
-type SnapshotOptions struct {
- Message string
- Commit CommitOptions
-}
-
-// PathChange is a rename-aware path-level Git change. For renames and copies,
-// OldPath and NewPath are both populated. Other changes use NewPath.
-type PathChange struct {
- Status string `json:"status"`
- OldPath string `json:"old_path,omitempty"`
- NewPath string `json:"new_path,omitempty"`
-}
-
-// NewGitStore returns a Git store with Hop's controlled commit identity.
-func NewGitStore() *GitStore {
- return &GitStore{
- Binary: defaultGitBinary,
- Identity: GitIdentity{
- Name: defaultCommitName,
- Email: defaultCommitMail,
- },
- Now: time.Now,
- }
-}
-
-// EnsureRepository opens the repository containing path, or initializes one at
-// path when none exists. It also keeps Hop's private directory out of snapshots.
-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.
-func OpenRepository(path string) (*Repository, error) {
- return NewGitStore().Open(context.Background(), path)
-}
-
-// FindRepositoryRoot returns the top-level worktree containing path.
-func FindRepositoryRoot(path string) (string, error) {
- return NewGitStore().FindRoot(context.Background(), path)
-}
-
-// Ensure opens the repository containing path, or initializes a new repository
-// at path if it is not already inside one.
-func (s *GitStore) Ensure(ctx context.Context, path string) (*Repository, error) {
- path, err := absolutePath(path)
- if err != nil {
- return nil, err
- }
-
- if info, statErr := os.Stat(path); statErr == nil && !info.IsDir() {
- if root, findErr := s.FindRoot(ctx, filepath.Dir(path)); findErr == nil {
- return s.openRootForEnsure(ctx, root)
- }
- return nil, fmt.Errorf("cannot initialize a repository at non-directory %q", path)
- } else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
- return nil, fmt.Errorf("inspect repository path: %w", statErr)
- }
-
- if root, findErr := s.FindRoot(ctx, path); findErr == nil {
- return s.openRootForEnsure(ctx, root)
- }
-
- if err := os.MkdirAll(path, 0o755); err != nil {
- return nil, fmt.Errorf("create repository directory: %w", err)
- }
- lockPath, err := repositoryInitLockPath(path)
- if err != nil {
- return nil, err
- }
- release, err := acquireFileLock(ctx, lockPath, "Hop repository initialization")
- if err != nil {
- return nil, err
- }
- defer release()
-
- // A concurrent Hop process may have initialized this directory while this
- // process waited for the per-user bootstrap lock.
- if root, findErr := s.FindRoot(ctx, path); findErr == nil {
- return s.openRoot(ctx, root, true)
- }
- if _, err := s.run(ctx, path, nil, nil, "init", "--quiet", path); err != nil {
- return nil, err
- }
- return s.openRoot(ctx, path, true)
-}
-
-func (s *GitStore) openRootForEnsure(ctx context.Context, root string) (*Repository, error) {
- lockPath, err := repositoryInitLockPath(root)
- if err != nil {
- return nil, err
- }
- release, err := acquireFileLock(ctx, lockPath, "Hop repository initialization")
- if err != nil {
- return nil, err
- }
- defer release()
- return s.openRoot(ctx, root, true)
-}
-
-// Open opens the non-bare repository containing path.
-func (s *GitStore) Open(ctx context.Context, path string) (*Repository, error) {
- root, err := s.FindRoot(ctx, path)
- if err != nil {
- return nil, err
- }
- return s.openRoot(ctx, root, true)
-}
-
-// FindRoot locates the top-level non-bare worktree containing path.
-func (s *GitStore) FindRoot(ctx context.Context, path string) (string, error) {
- path, err := existingDirectory(path)
- if err != nil {
- return "", err
- }
- output, err := s.run(ctx, path, nil, nil, "rev-parse", "--show-toplevel")
- if err != nil {
- return "", fmt.Errorf("find Git repository from %q: %w", path, err)
- }
- root := trimLine(output)
- if root == "" {
- return "", fmt.Errorf("git returned an empty repository root for %q", path)
- }
- if !filepath.IsAbs(root) {
- root = filepath.Join(path, root)
- }
- return filepath.Clean(root), nil
-}
-
-func (s *GitStore) openRoot(ctx context.Context, root string, addExclude bool) (*Repository, error) {
- root, err := filepath.Abs(root)
- if err != nil {
- return nil, fmt.Errorf("resolve repository root: %w", err)
- }
- inside, err := s.run(ctx, root, nil, nil, "rev-parse", "--is-inside-work-tree")
- if err != nil {
- return nil, err
- }
- if trimLine(inside) != "true" {
- return nil, fmt.Errorf("%q is not a non-bare Git worktree", root)
- }
-
- gitDirOutput, err := s.run(ctx, root, nil, nil, "rev-parse", "--absolute-git-dir")
- if err != nil {
- return nil, err
- }
- commonOutput, err := s.run(ctx, root, nil, nil, "rev-parse", "--git-common-dir")
- if err != nil {
- return nil, err
- }
- gitDir := resolveGitPath(root, trimLine(gitDirOutput))
- commonGitDir := resolveGitPath(root, trimLine(commonOutput))
- repository := &Repository{
- root: filepath.Clean(root),
- gitDir: gitDir,
- commonGitDir: commonGitDir,
- store: s,
- }
- if addExclude {
- if err := repository.EnsureHopExcluded(); err != nil {
- return nil, err
- }
- }
- return repository, nil
-}
-
-// Root returns the absolute top-level directory of this worktree.
-func (r *Repository) Root() string { return r.root }
-
-// GitDir returns the absolute per-worktree Git directory.
-func (r *Repository) GitDir() string { return r.gitDir }
-
-// CommonGitDir returns the absolute common Git directory shared by linked
-// 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.
-func (r *Repository) EnsureHopExcluded() error {
- infoDir := filepath.Join(r.commonGitDir, "info")
- if err := os.MkdirAll(infoDir, 0o755); err != nil {
- return fmt.Errorf("create Git info directory: %w", err)
- }
- excludePath := filepath.Join(infoDir, "exclude")
- contents, err := os.ReadFile(excludePath)
- 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/" {
- continue // Migrate the legacy blanket exclusion.
- }
- 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 == string(contents) {
- return nil
- }
- if err := os.WriteFile(excludePath, []byte(updated), 0o644); err != nil {
- return fmt.Errorf("write Git exclude file: %w", err)
- }
- return nil
-}
-
-// Head returns the current HEAD commit. exists is false for an unborn branch.
-func (r *Repository) Head(ctx context.Context) (oid string, exists bool, err error) {
- output, err := r.run(ctx, nil, nil, "rev-parse", "--verify", "--quiet", "HEAD^{commit}")
- if err != nil {
- if gitExitCode(err) == 1 {
- return "", false, nil
- }
- return "", false, err
- }
- return trimLine(output), true, nil
-}
-
-// PushAccepted publishes one accepted Hop commit to the repository's existing
-// branch destination. It never force-pushes and returns configured=false when
-// the repository has no unambiguous remote branch target.
-func (r *Repository) PushAccepted(ctx context.Context, commit string) (result RemotePushResult, configured bool, err error) {
- if err := validObjectName(commit); err != nil {
- return result, false, fmt.Errorf("invalid accepted commit: %w", err)
- }
- 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)
- }
- if !exists || branch == "" {
- return result, false, nil
- }
- if _, err := r.run(ctx, nil, nil, "check-ref-format", "--branch", branch); err != nil {
- return result, false, fmt.Errorf("validate automatic push branch: %w", err)
- }
-
- remoteNamesOutput, err := r.run(ctx, nil, nil, "remote")
- if err != nil {
- return result, false, fmt.Errorf("list Git remotes: %w", err)
- }
- remoteNames := nonemptyLines(remoteNamesOutput)
- if len(remoteNames) == 0 {
- return result, false, nil
- }
-
- upstreamRemote, hasUpstreamRemote, err := r.optionalGitOutput(ctx, "config", "--get", "branch."+branch+".remote")
- if err != nil {
- return result, false, fmt.Errorf("read Git upstream remote for %s: %w", branch, err)
- }
- remote := ""
- for _, key := range []string{
- "branch." + branch + ".pushRemote",
- "remote.pushDefault",
- } {
- value, found, configErr := r.optionalGitOutput(ctx, "config", "--get", key)
- if configErr != nil {
- return result, false, fmt.Errorf("read Git config %s: %w", key, configErr)
- }
- if found && value != "" {
- remote = value
- break
- }
- }
- if remote == "" && hasUpstreamRemote {
- remote = upstreamRemote
- }
- if remote == "." {
- return result, false, nil
- }
- if remote == "" {
- if containsString(remoteNames, "origin") {
- remote = "origin"
- } else if len(remoteNames) == 1 {
- remote = remoteNames[0]
- } else {
- return result, false, nil
- }
- }
- if !containsString(remoteNames, remote) {
- return result, false, fmt.Errorf("configured automatic push remote %q does not exist", remote)
- }
-
- ref := "refs/heads/" + branch
- if mergeRef, found, configErr := r.optionalGitOutput(ctx, "config", "--get", "branch."+branch+".merge"); configErr != nil {
- return result, false, fmt.Errorf("read upstream branch for %s: %w", branch, configErr)
- } else if found && remote == upstreamRemote && strings.HasPrefix(mergeRef, "refs/heads/") {
- ref = mergeRef
- }
- 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
-}
-
-func (r *Repository) optionalGitOutput(ctx context.Context, args ...string) (string, bool, error) {
- output, err := r.run(ctx, nil, nil, args...)
- if err != nil {
- if gitExitCode(err) == 1 {
- return "", false, nil
- }
- return "", false, err
- }
- return trimLine(output), true, nil
-}
-
-func nonemptyLines(value string) []string {
- var lines []string
- for _, line := range strings.Split(value, "\n") {
- if line = strings.TrimSpace(strings.TrimSuffix(line, "\r")); line != "" {
- lines = append(lines, line)
- }
- }
- return lines
-}
-
-func containsString(values []string, target string) bool {
- for _, value := range values {
- if value == target {
- return true
- }
- }
- return false
-}
-
-// Snapshot records all tracked files and all non-ignored untracked files in the
-// worktree. It uses a disposable index, preserving both the contents and staging
-// state of the user's real index. The synthetic commit is parented to HEAD when
-// HEAD exists; an unborn repository produces a parentless commit.
-func (r *Repository) Snapshot(ctx context.Context, message string) (commitOID, treeOID string, err error) {
- return r.SnapshotWithOptions(ctx, SnapshotOptions{Message: message})
-}
-
-// SnapshotWithOptions is Snapshot with explicit synthetic commit controls.
-func (r *Repository) SnapshotWithOptions(ctx context.Context, options SnapshotOptions) (commitOID, treeOID string, err error) {
- indexPath, cleanup, err := r.temporaryIndex(true)
- if err != nil {
- return "", "", err
- }
- defer cleanup()
-
- env := []string{"GIT_INDEX_FILE=" + indexPath, "GIT_OPTIONAL_LOCKS=0"}
- if _, err := r.run(ctx, env, nil, "add", "-A", "--", "."); err != nil {
- return "", "", fmt.Errorf("snapshot workspace: %w", err)
- }
- // The disposable index is copied from the worktree index and therefore
- // carries its stat cache. An agent can rewrite a file to the same size within
- // the filesystem timestamp granularity, making a plain `git add -A`
- // intermittently treat fresh content as unchanged. Renormalizing forces Git
- // to hash every tracked path again while retaining additions/deletions staged
- // by the first pass.
- if _, err := r.run(ctx, env, nil, "add", "--renormalize", "--", "."); err != nil {
- return "", "", fmt.Errorf("rehash workspace snapshot: %w", err)
- }
- treeOutput, err := r.run(ctx, env, nil, "write-tree")
- if err != nil {
- return "", "", fmt.Errorf("write snapshot tree: %w", err)
- }
- treeOID = trimLine(treeOutput)
-
- parent, hasParent, err := r.Head(ctx)
- if err != nil {
- return "", "", err
- }
- commitOptions := options.Commit
- commitOptions.Message = options.Message
- if commitOptions.Message == "" {
- commitOptions.Message = "hop workspace snapshot"
- }
- if hasParent {
- commitOptions.Parents = []string{parent}
- } else {
- commitOptions.Parents = nil
- }
- commitOID, err = r.CommitTreeWithOptions(ctx, treeOID, commitOptions)
- if err != nil {
- return "", "", err
- }
- return commitOID, treeOID, nil
-}
-
-// CommitTree creates a synthetic commit from tree. Parent order is preserved.
-func (r *Repository) CommitTree(ctx context.Context, tree string, parents []string, message string) (string, error) {
- return r.CommitTreeWithOptions(ctx, tree, CommitOptions{
- Message: message,
- Parents: append([]string(nil), parents...),
- })
-}
-
-// 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) {
- if err := validObjectName(tree); err != nil {
- return "", fmt.Errorf("invalid tree: %w", err)
- }
- for _, parent := range options.Parents {
- if err := validObjectName(parent); err != nil {
- return "", fmt.Errorf("invalid parent: %w", err)
- }
- }
-
- author := r.store.identity(options.Author)
- committer := r.store.identity(options.Committer)
- if options.Committer.Name == "" && options.Committer.Email == "" {
- committer = author
- }
- if err := validateIdentity(author); err != nil {
- return "", fmt.Errorf("invalid author identity: %w", err)
- }
- if err := validateIdentity(committer); err != nil {
- return "", fmt.Errorf("invalid committer identity: %w", err)
- }
-
- authorTime := options.AuthorTime
- if authorTime.IsZero() {
- authorTime = r.store.now()
- }
- committerTime := options.CommitterTime
- if committerTime.IsZero() {
- committerTime = authorTime
- }
- env := []string{
- "GIT_AUTHOR_NAME=" + author.Name,
- "GIT_AUTHOR_EMAIL=" + author.Email,
- "GIT_AUTHOR_DATE=" + authorTime.Format(time.RFC3339),
- "GIT_COMMITTER_NAME=" + committer.Name,
- "GIT_COMMITTER_EMAIL=" + committer.Email,
- "GIT_COMMITTER_DATE=" + committerTime.Format(time.RFC3339),
- }
-
- args := []string{"-c", "commit.gpgSign=false", "-c", "i18n.commitEncoding=UTF-8", "commit-tree", tree}
- for _, parent := range options.Parents {
- args = append(args, "-p", parent)
- }
- message := options.Message
- if message == "" {
- message = "hop synthetic commit"
- }
- output, err := r.run(ctx, env, []byte(message), args...)
- if err != nil {
- return "", fmt.Errorf("create synthetic commit: %w", err)
- }
- return trimLine(output), nil
-}
-
-// HiddenRef returns the full private ref name for name.
-func HiddenRef(name string) (string, error) {
- if strings.HasPrefix(name, hiddenRefPrefix) {
- if err := checkRefName(name); err != nil {
- return "", err
- }
- return name, nil
- }
- if strings.HasPrefix(name, "refs/") {
- return "", fmt.Errorf("hidden ref must be below %s", hiddenRefPrefix)
- }
- name = strings.TrimPrefix(name, "/")
- ref := hiddenRefPrefix + name
- if err := checkRefName(ref); err != nil {
- return "", err
- }
- return ref, nil
-}
-
-// ReadHiddenRef reads refs/hop/name. exists is false when it has not been
-// created yet.
-func (r *Repository) ReadHiddenRef(ctx context.Context, name string) (oid string, exists bool, err error) {
- ref, err := HiddenRef(name)
- if err != nil {
- return "", false, err
- }
- return r.ReadRef(ctx, ref)
-}
-
-// 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.
-func (r *Repository) UpdateHiddenRef(ctx context.Context, name, newOID string, expectedOld ...string) error {
- ref, err := HiddenRef(name)
- if err != nil {
- return err
- }
- return r.UpdateRef(ctx, ref, newOID, expectedOld...)
-}
-
-// ReadRef reads an exact, fully qualified ref without resolving ambiguous short
-// names. exists is false when the ref is absent.
-func (r *Repository) ReadRef(ctx context.Context, ref string) (oid string, exists bool, err error) {
- if err := r.validateRef(ctx, ref); err != nil {
- return "", false, err
- }
- output, err := r.run(ctx, nil, nil, "show-ref", "--verify", "--hash", ref)
- if err != nil {
- if gitExitCode(err) == 1 {
- return "", false, nil
- }
- return "", false, err
- }
- return trimLine(output), true, nil
-}
-
-// UpdateRef updates a fully qualified ref. Supplying expectedOld makes the
-// operation compare-and-swap; expectedOld == "" requires an absent ref.
-func (r *Repository) UpdateRef(ctx context.Context, ref, newOID string, expectedOld ...string) error {
- if err := r.validateRef(ctx, ref); err != nil {
- return err
- }
- if err := validObjectName(newOID); err != nil {
- return fmt.Errorf("invalid new object ID: %w", err)
- }
- if len(expectedOld) > 1 {
- return fmt.Errorf("update ref accepts at most one expected old object ID")
- }
- args := []string{"update-ref", "--create-reflog", "-m", "hop update", ref, newOID}
- if len(expectedOld) == 1 {
- old := expectedOld[0]
- if old == "" {
- var err error
- old, err = r.ZeroOID(ctx)
- if err != nil {
- return err
- }
- } else if err := validObjectName(old); err != nil {
- return fmt.Errorf("invalid expected object ID: %w", err)
- }
- args = append(args, old)
- }
- if _, err := r.run(ctx, nil, nil, args...); err != nil {
- return fmt.Errorf("update ref %s: %w", ref, err)
- }
- return nil
-}
-
-// DeleteRef deletes a fully qualified ref, optionally only if it still has the
-// expected object ID.
-func (r *Repository) DeleteRef(ctx context.Context, ref string, expectedOld ...string) error {
- if err := r.validateRef(ctx, ref); err != nil {
- return err
- }
- if len(expectedOld) > 1 {
- return fmt.Errorf("delete ref accepts at most one expected old object ID")
- }
- args := []string{"update-ref", "-d", ref}
- if len(expectedOld) == 1 && expectedOld[0] != "" {
- if err := validObjectName(expectedOld[0]); err != nil {
- return fmt.Errorf("invalid expected object ID: %w", err)
- }
- args = append(args, expectedOld[0])
- }
- if _, err := r.run(ctx, nil, nil, args...); err != nil {
- return fmt.Errorf("delete ref %s: %w", ref, err)
- }
- return nil
-}
-
-// AddDetachedWorktree materializes commit at path without creating or moving a
-// branch. The returned repository is rooted at the new linked worktree.
-func (r *Repository) AddDetachedWorktree(ctx context.Context, path, commit string) (*Repository, error) {
- if err := validObjectName(commit); err != nil {
- return nil, fmt.Errorf("invalid worktree commit: %w", err)
- }
- path, err := filepath.Abs(path)
- if err != nil {
- return nil, fmt.Errorf("resolve worktree path: %w", err)
- }
- if _, err := r.run(ctx, nil, nil, "worktree", "add", "--detach", path, commit); err != nil {
- return nil, fmt.Errorf("create detached worktree: %w", err)
- }
- worktree, err := r.store.openRoot(ctx, path, true)
- if err != nil {
- return nil, err
- }
- return worktree, nil
-}
-
-// RemoveWorktree removes a linked worktree. force allows removal when it has
-// local modifications or untracked files.
-func (r *Repository) RemoveWorktree(ctx context.Context, path string, force bool) error {
- path, err := filepath.Abs(path)
- if err != nil {
- return fmt.Errorf("resolve worktree path: %w", err)
- }
- args := []string{"worktree", "remove"}
- if force {
- args = append(args, "--force")
- }
- args = append(args, path)
- if _, err := r.run(ctx, nil, nil, args...); err != nil {
- return fmt.Errorf("remove worktree: %w", err)
- }
- return nil
-}
-
-// Diff returns a binary-capable, full-index patch between two commits or trees.
-// An empty endpoint denotes Git's empty tree, which supports unborn histories.
-func (r *Repository) Diff(ctx context.Context, from, to string) (string, error) {
- fromTree, err := r.resolveTree(ctx, from)
- if err != nil {
- return "", err
- }
- toTree, err := r.resolveTree(ctx, to)
- if err != nil {
- return "", err
- }
- output, err := r.run(ctx, nil, nil,
- "diff", "--no-ext-diff", "--no-textconv", "--binary", "--full-index", "--find-renames",
- fromTree, toTree, "--",
- )
- if err != nil {
- return "", fmt.Errorf("diff trees: %w", err)
- }
- return output, nil
-}
-
-// ChangedPathDetails returns Git's rename-aware path changes.
-func (r *Repository) ChangedPathDetails(ctx context.Context, from, to string) ([]PathChange, error) {
- fromTree, err := r.resolveTree(ctx, from)
- if err != nil {
- return nil, err
- }
- toTree, err := r.resolveTree(ctx, to)
- if err != nil {
- return nil, err
- }
- output, err := r.run(ctx, nil, nil,
- "diff", "--no-ext-diff", "--no-textconv", "--name-status", "-z", "--find-renames",
- fromTree, toTree, "--",
- )
- if err != nil {
- return nil, fmt.Errorf("list changed paths: %w", err)
- }
- return parseNameStatus([]byte(output))
-}
-
-// ChangedPaths returns the set of affected paths in bytewise order. A rename
-// or copy contributes both its old and new path, making overlap checks safe.
-func (r *Repository) ChangedPaths(ctx context.Context, from, to string) ([]string, error) {
- changes, err := r.ChangedPathDetails(ctx, from, to)
- if err != nil {
- return nil, err
- }
- paths := make(map[string]struct{}, len(changes))
- for _, change := range changes {
- if change.OldPath != "" {
- paths[change.OldPath] = struct{}{}
- }
- if change.NewPath != "" {
- paths[change.NewPath] = struct{}{}
- }
- }
- result := make([]string, 0, len(paths))
- for path := range paths {
- result = append(result, path)
- }
- sort.Strings(result)
- return result, nil
-}
-
-// WorktreeTree snapshots the visible worktree relative to an expected Hop
-// tree. The caller's real Git index is never read or changed. Seeding from the
-// expected tree makes Hop-projected files remain visible to the snapshot even
-// when they are untracked by the user's branch or matched by an ignore rule.
-func (r *Repository) WorktreeTree(ctx context.Context, expected string) (string, error) {
- expectedTree, err := r.resolveTree(ctx, expected)
- if err != nil {
- return "", err
- }
- indexPath, cleanup, err := r.temporaryIndex(false)
- if err != nil {
- return "", err
- }
- defer cleanup()
-
- env := []string{"GIT_INDEX_FILE=" + indexPath, "GIT_OPTIONAL_LOCKS=0"}
- if _, err := r.run(ctx, env, nil, "read-tree", expectedTree); err != nil {
- return "", fmt.Errorf("seed visible-root snapshot: %w", err)
- }
- if _, err := r.run(ctx, env, nil, "add", "-A", "--", "."); err != nil {
- return "", fmt.Errorf("snapshot visible project root: %w", err)
- }
- output, err := r.run(ctx, env, nil, "write-tree")
- if err != nil {
- return "", fmt.Errorf("write visible-root tree: %w", err)
- }
- return trimLine(output), nil
-}
-
-// CheckIndexSafe verifies that the user's real index contains no staged state
-// outside either their current HEAD or the Hop tree already visible in the
-// project root. Hop never writes this index, but refusing divergent staging
-// prevents a landing from obscuring the user's in-progress Git operation.
-func (r *Repository) CheckIndexSafe(ctx context.Context, visibleTree string) error {
- visibleTree, err := r.resolveTree(ctx, visibleTree)
- if err != nil {
- return err
- }
- head, exists, err := r.Head(ctx)
- if err != nil {
- return err
- }
- headTree := ""
- if exists {
- headTree, err = r.resolveTree(ctx, head)
- } else {
- headTree, err = r.EmptyTree(ctx)
- }
- if err != nil {
- return err
- }
- indexTree, err := r.userIndexTree(ctx)
- if err != nil {
- return &RootConflictError{Reason: "visible project root has unmerged or intent-to-add entries in the real Git index"}
- }
- if indexTree == headTree || indexTree == visibleTree {
- return nil
- }
- paths, err := r.ChangedPaths(ctx, headTree, indexTree)
- if err != nil {
- return err
- }
- return &RootConflictError{
- Paths: paths,
- Reason: "visible project root has staged Git changes that do not match HEAD or its materialized Hop state",
- }
-}
-
-func (r *Repository) userIndexTree(ctx context.Context) (string, error) {
- indexPath := filepath.Join(r.gitDir, "index")
- if _, err := os.Stat(indexPath); errors.Is(err, os.ErrNotExist) {
- return r.EmptyTree(ctx)
- } else if err != nil {
- return "", fmt.Errorf("inspect real Git index: %w", err)
- }
- output, err := r.run(ctx, []string{
- "GIT_INDEX_FILE=" + indexPath,
- "GIT_OPTIONAL_LOCKS=0",
- }, nil, "write-tree")
- if err != nil {
- return "", fmt.Errorf("read real Git index tree: %w", err)
- }
- return trimLine(output), nil
-}
-
-// MaterializationConflicts finds filesystem entries that a tree projection
-// would overwrite even though they are absent from the source tree. This
-// catches ignored files, which intentionally do not appear in WorktreeTree.
-func (r *Repository) MaterializationConflicts(ctx context.Context, from, to string) ([]string, error) {
- fromPaths, err := r.treeLeafPaths(ctx, from)
- if err != nil {
- return nil, err
- }
- toPaths, err := r.treeLeafPaths(ctx, to)
- if err != nil {
- return nil, err
- }
- conflicts := map[string]struct{}{}
- for path := range toPaths {
- if _, existed := fromPaths[path]; existed {
- continue
- }
- parts := strings.Split(path, "/")
- for index := range parts {
- prefix := strings.Join(parts[:index+1], "/")
- info, statErr := os.Lstat(filepath.Join(r.root, filepath.FromSlash(prefix)))
- if statErr != nil {
- if errors.Is(statErr, os.ErrNotExist) || errors.Is(statErr, syscall.ENOTDIR) {
- break
- }
- return nil, fmt.Errorf("inspect visible path %s: %w", prefix, statErr)
- }
- if index < len(parts)-1 {
- if info.IsDir() {
- continue
- }
- if _, expected := fromPaths[prefix]; expected {
- break
- }
- conflicts[prefix] = struct{}{}
- break
- }
- if info.IsDir() {
- unexpected, walkErr := r.unexpectedDirectoryLeaves(path, fromPaths)
- if walkErr != nil {
- return nil, walkErr
- }
- for _, unexpectedPath := range unexpected {
- conflicts[unexpectedPath] = struct{}{}
- }
- continue
- }
- conflicts[path] = struct{}{}
- }
- }
- paths := make([]string, 0, len(conflicts))
- for path := range conflicts {
- paths = append(paths, path)
- }
- sort.Strings(paths)
- return paths, nil
-}
-
-func (r *Repository) unexpectedDirectoryLeaves(path string, expected map[string]struct{}) ([]string, error) {
- root := filepath.Join(r.root, filepath.FromSlash(path))
- var conflicts []string
- err := filepath.WalkDir(root, func(candidate string, entry os.DirEntry, walkErr error) error {
- if walkErr != nil {
- return walkErr
- }
- if candidate == root || entry.IsDir() {
- return nil
- }
- relative, err := filepath.Rel(r.root, candidate)
- if err != nil {
- return err
- }
- relative = filepath.ToSlash(relative)
- if _, ok := expected[relative]; !ok {
- conflicts = append(conflicts, relative)
- }
- return nil
- })
- if err != nil {
- return nil, fmt.Errorf("inspect visible directory %s: %w", path, err)
- }
- sort.Strings(conflicts)
- return conflicts, nil
-}
-
-// MaterializeTree updates only the visible worktree from one accepted tree to
-// another. HEAD, the current branch, and the user's real index remain exactly
-// where they were. The operation fails closed when the worktree no longer
-// matches from or an ignored destination would be overwritten.
-func (r *Repository) MaterializeTree(ctx context.Context, from, to string) error {
- fromTree, err := r.resolveTree(ctx, from)
- if err != nil {
- return err
- }
- toTree, err := r.resolveTree(ctx, to)
- if err != nil {
- return err
- }
- if err := r.CheckIndexSafe(ctx, fromTree); err != nil {
- return err
- }
- actualTree, err := r.WorktreeTree(ctx, fromTree)
- if err != nil {
- return err
- }
- if actualTree != fromTree {
- paths, pathErr := r.ChangedPaths(ctx, fromTree, actualTree)
- if pathErr != nil {
- return pathErr
- }
- return &RootConflictError{Paths: paths}
- }
- conflicts, err := r.MaterializationConflicts(ctx, fromTree, toTree)
- if err != nil {
- return err
- }
- if len(conflicts) > 0 {
- return &RootConflictError{
- Paths: conflicts,
- Reason: "visible project root contains ignored or untracked paths that landing would overwrite",
- }
- }
- if fromTree == toTree {
- return nil
- }
-
- indexPath, cleanup, err := r.temporaryIndex(false)
- if err != nil {
- return err
- }
- defer cleanup()
- env := []string{"GIT_INDEX_FILE=" + indexPath, "GIT_OPTIONAL_LOCKS=0"}
- if _, err := r.run(ctx, env, nil, "read-tree", fromTree); err != nil {
- return fmt.Errorf("seed visible-root materialization: %w", err)
- }
- if _, err := r.run(ctx, env, nil, "update-index", "--refresh"); err != nil {
- return &RootConflictError{Reason: "visible project root changed while Hop was preparing to synchronize it"}
- }
- if _, err := r.run(ctx, env, nil, "read-tree", "-m", "-u", fromTree, toTree); err != nil {
- return fmt.Errorf("materialize accepted tree into visible project root: %w", err)
- }
- materializedTree, err := r.WorktreeTree(ctx, toTree)
- if err != nil {
- return err
- }
- if materializedTree != toTree {
- paths, pathErr := r.ChangedPaths(ctx, toTree, materializedTree)
- if pathErr != nil {
- return pathErr
- }
- return &RootConflictError{
- Paths: paths,
- Reason: "visible project root changed while Hop was synchronizing it",
- }
- }
- return nil
-}
-
-func (r *Repository) treeLeafPaths(ctx context.Context, object string) (map[string]struct{}, error) {
- tree, err := r.resolveTree(ctx, object)
- if err != nil {
- return nil, err
- }
- output, err := r.run(ctx, nil, nil, "ls-tree", "-r", "-z", "--name-only", tree)
- if err != nil {
- return nil, fmt.Errorf("list tree paths: %w", err)
- }
- paths := make(map[string]struct{})
- for _, path := range splitNull([]byte(output)) {
- if path != "" {
- paths[path] = struct{}{}
- }
- }
- return paths, nil
-}
-
-// TrackedPaths lists index entries at or below path. Hop uses this before
-// initialization to avoid hiding or overwriting a repository that already
-// treats .hop as user-owned source content.
-func (r *Repository) TrackedPaths(ctx context.Context, path string) ([]string, error) {
- if path == "" {
- return nil, fmt.Errorf("tracked path query requires a path")
- }
- output, err := r.run(ctx, nil, nil, "ls-files", "-z", "--", path)
- if err != nil {
- return nil, fmt.Errorf("list tracked paths below %s: %w", path, err)
- }
- parts := splitNull([]byte(output))
- paths := make([]string, 0, len(parts))
- for _, part := range parts {
- if part != "" {
- paths = append(paths, part)
- }
- }
- sort.Strings(paths)
- return paths, nil
-}
-
-// ComposeTrees performs Git's real three-way merge without touching a
-// worktree or index. Independent hunks in the same file, identical changes,
-// renames, and compatible mode/content changes merge automatically. On a
-// genuine conflict, tree is the best-effort conflict-marker tree and conflicts
-// lists the paths an agent must reconcile.
-func (r *Repository) ComposeTrees(ctx context.Context, base, ours, theirs string) (tree string, conflicts []string, err error) {
- baseCommit, err := r.resolveCommit(ctx, base)
- if err != nil {
- return "", nil, fmt.Errorf("resolve base commit: %w", err)
- }
- oursCommit, err := r.resolveCommit(ctx, ours)
- if err != nil {
- return "", nil, fmt.Errorf("resolve current commit: %w", err)
- }
- theirsCommit, err := r.resolveCommit(ctx, theirs)
- if err != nil {
- return "", nil, fmt.Errorf("resolve proposal commit: %w", err)
- }
-
- output, mergeErr := r.run(ctx, []string{"GIT_OPTIONAL_LOCKS=0"}, nil,
- "-c", "merge.conflictStyle=zdiff3",
- "-c", "merge.renames=true",
- "-c", "merge.directoryRenames=conflict",
- "merge-tree", "--write-tree", "--merge-base", baseCommit,
- "--name-only", "-z", "--no-messages", oursCommit, theirsCommit,
- )
- fields := splitNull([]byte(output))
- if len(fields) == 0 || fields[0] == "" {
- if mergeErr != nil {
- return "", nil, fmt.Errorf("compose trees: %w", mergeErr)
- }
- return "", nil, errors.New("git merge-tree returned no tree")
- }
- tree = fields[0]
- if err := validObjectName(tree); err != nil {
- return "", nil, fmt.Errorf("invalid composed tree: %w", err)
- }
- conflictSet := map[string]struct{}{}
- for _, path := range fields[1:] {
- if path != "" {
- conflictSet[path] = struct{}{}
- }
- }
- for path := range conflictSet {
- conflicts = append(conflicts, path)
- }
- sort.Strings(conflicts)
- if mergeErr == nil {
- if len(conflicts) > 0 {
- return "", nil, errors.New("git merge-tree reported conflict paths with a successful exit")
- }
- return tree, nil, nil
- }
- if gitExitCode(mergeErr) != 1 {
- return "", nil, fmt.Errorf("compose trees: %w", mergeErr)
- }
- if len(conflicts) == 0 {
- // Git documents conflict classes (notably some directory-renames) that
- // produce no conflicted-file list. Give the agent the changed-path union
- // as useful reconciliation candidates instead of converting a real merge
- // conflict into an internal error.
- for _, side := range []string{oursCommit, theirsCommit} {
- paths, pathsErr := r.ChangedPaths(ctx, baseCommit, side)
- if pathsErr != nil {
- return "", nil, fmt.Errorf("derive structural conflict paths: %w", pathsErr)
- }
- for _, path := range paths {
- conflictSet[path] = struct{}{}
- }
- }
- for path := range conflictSet {
- conflicts = append(conflicts, path)
- }
- sort.Strings(conflicts)
- if len(conflicts) == 0 {
- conflicts = []string{"(structural merge conflict; inspect both inputs)"}
- }
- }
- return tree, conflicts, nil
-}
-
-// VerifyObject verifies that name resolves to an object in this repository.
-func (r *Repository) VerifyObject(ctx context.Context, name string) error {
- if err := validObjectName(name); err != nil {
- return err
- }
- if _, err := r.run(ctx, nil, nil, "cat-file", "-e", name+"^{object}"); err != nil {
- return fmt.Errorf("verify Git object %s: %w", name, err)
- }
- return nil
-}
-
-// VerifyObjects verifies each named object.
-func (r *Repository) VerifyObjects(ctx context.Context, names ...string) error {
- for _, name := range names {
- if err := r.VerifyObject(ctx, name); err != nil {
- return err
- }
- }
- return nil
-}
-
-// Verify checks the connectivity and validity of the repository's reachable
-// object graph. Dangling Hop snapshots are allowed and do not produce noise.
-func (r *Repository) Verify(ctx context.Context) error {
- if _, err := r.run(ctx, nil, nil, "fsck", "--connectivity-only", "--no-dangling"); err != nil {
- return fmt.Errorf("verify Git object database: %w", err)
- }
- return nil
-}
-
-// EmptyTree returns the object ID of Git's empty tree for this repository's
-// object format.
-func (r *Repository) EmptyTree(ctx context.Context) (string, error) {
- output, err := r.run(ctx, nil, nil, "mktree")
- if err != nil {
- return "", fmt.Errorf("create empty tree: %w", err)
- }
- return trimLine(output), nil
-}
-
-// ZeroOID returns the all-zero object ID of the correct length for this
-// repository (SHA-1 or SHA-256).
-func (r *Repository) ZeroOID(ctx context.Context) (string, error) {
- output, err := r.run(ctx, nil, nil, "rev-parse", "--show-object-format")
- if err != nil {
- return "", fmt.Errorf("read Git object format: %w", err)
- }
- switch trimLine(output) {
- case "sha1":
- return strings.Repeat("0", 40), nil
- case "sha256":
- return strings.Repeat("0", 64), nil
- default:
- return "", fmt.Errorf("unsupported Git object format %q", trimLine(output))
- }
-}
-
-func (r *Repository) resolveTree(ctx context.Context, object string) (string, error) {
- if object == "" {
- return r.EmptyTree(ctx)
- }
- if err := validObjectName(object); err != nil {
- return "", err
- }
- output, err := r.run(ctx, nil, nil, "rev-parse", "--verify", "--quiet", object+"^{tree}")
- if err != nil {
- return "", fmt.Errorf("resolve tree %s: %w", object, err)
- }
- return trimLine(output), nil
-}
-
-func (r *Repository) resolveCommit(ctx context.Context, object string) (string, error) {
- if err := validObjectName(object); err != nil {
- return "", err
- }
- output, err := r.run(ctx, nil, nil, "rev-parse", "--verify", "--quiet", object+"^{commit}")
- if err != nil {
- return "", fmt.Errorf("resolve commit %s: %w", object, err)
- }
- return trimLine(output), nil
-}
-
-func (r *Repository) validateRef(ctx context.Context, ref string) error {
- if !strings.HasPrefix(ref, "refs/") {
- return fmt.Errorf("ref must be fully qualified: %q", ref)
- }
- if _, err := r.run(ctx, nil, nil, "check-ref-format", ref); err != nil {
- return fmt.Errorf("invalid ref %q: %w", ref, err)
- }
- return nil
-}
-
-func (r *Repository) temporaryIndex(seedFromUser bool) (string, func(), error) {
- if err := os.MkdirAll(r.gitDir, 0o755); err != nil {
- return "", nil, fmt.Errorf("prepare Git directory: %w", err)
- }
- temporary, err := os.CreateTemp(r.gitDir, "hop-index-*")
- if err != nil {
- return "", nil, fmt.Errorf("create temporary Git index: %w", err)
- }
- path := temporary.Name()
- if err := temporary.Close(); err != nil {
- os.Remove(path)
- return "", nil, fmt.Errorf("close temporary Git index: %w", err)
- }
- cleanup := func() {
- _ = os.Remove(path)
- _ = os.Remove(path + ".lock")
- }
-
- if seedFromUser {
- userIndex := filepath.Join(r.gitDir, "index")
- contents, readErr := os.ReadFile(userIndex)
- switch {
- case readErr == nil:
- if writeErr := os.WriteFile(path, contents, 0o600); writeErr != nil {
- cleanup()
- return "", nil, fmt.Errorf("seed temporary Git index: %w", writeErr)
- }
- case errors.Is(readErr, os.ErrNotExist):
- if removeErr := os.Remove(path); removeErr != nil {
- cleanup()
- return "", nil, fmt.Errorf("prepare empty temporary Git index: %w", removeErr)
- }
- default:
- cleanup()
- return "", nil, fmt.Errorf("read user Git index: %w", readErr)
- }
- } else if err := os.Remove(path); err != nil {
- cleanup()
- return "", nil, fmt.Errorf("prepare temporary Git index: %w", err)
- }
- return path, cleanup, nil
-}
-
-func (r *Repository) run(ctx context.Context, env []string, stdin []byte, args ...string) (string, error) {
- return r.store.run(ctx, r.root, env, stdin, args...)
-}
-
-func (s *GitStore) run(ctx context.Context, directory string, env []string, stdin []byte, args ...string) (string, error) {
- if ctx == nil {
- ctx = context.Background()
- }
- binary := s.Binary
- if binary == "" {
- binary = defaultGitBinary
- }
- command := exec.CommandContext(ctx, binary, args...)
- command.Dir = directory
- command.Env = mergeEnvironment(os.Environ(), append([]string{"LC_ALL=C"}, env...))
- if stdin != nil {
- command.Stdin = bytes.NewReader(stdin)
- }
- var stdout bytes.Buffer
- var stderr bytes.Buffer
- command.Stdout = &stdout
- command.Stderr = &stderr
- err := command.Run()
- if err != nil {
- exitCode := -1
- var exitErr *exec.ExitError
- if errors.As(err, &exitErr) {
- exitCode = exitErr.ExitCode()
- }
- return stdout.String(), &GitError{
- Args: append([]string(nil), args...),
- ExitCode: exitCode,
- Stdout: stdout.String(),
- Stderr: stderr.String(),
- Err: err,
- }
- }
- return stdout.String(), nil
-}
-
-func (s *GitStore) identity(override GitIdentity) GitIdentity {
- identity := s.Identity
- if identity.Name == "" {
- identity.Name = defaultCommitName
- }
- if identity.Email == "" {
- identity.Email = defaultCommitMail
- }
- if override.Name != "" {
- identity.Name = override.Name
- }
- if override.Email != "" {
- identity.Email = override.Email
- }
- return identity
-}
-
-func (s *GitStore) now() time.Time {
- if s.Now != nil {
- return s.Now()
- }
- return time.Now()
-}
-
-func parseNameStatus(output []byte) ([]PathChange, error) {
- fields := splitNull(output)
- changes := make([]PathChange, 0, len(fields)/2)
- for index := 0; index < len(fields); {
- status := fields[index]
- index++
- if status == "" {
- continue
- }
- kind := status[0]
- if kind == 'R' || kind == 'C' {
- if index+1 >= len(fields) {
- return nil, fmt.Errorf("malformed rename entry in git diff --name-status")
- }
- changes = append(changes, PathChange{
- Status: status,
- OldPath: fields[index],
- NewPath: fields[index+1],
- })
- index += 2
- continue
- }
- if index >= len(fields) {
- return nil, fmt.Errorf("malformed entry in git diff --name-status")
- }
- changes = append(changes, PathChange{Status: status, NewPath: fields[index]})
- index++
- }
- return changes, nil
-}
-
-func parseUnmergedPaths(output []byte) []string {
- set := make(map[string]struct{})
- for _, record := range splitNull(output) {
- if record == "" {
- continue
- }
- if tab := strings.IndexByte(record, '\t'); tab >= 0 && tab+1 < len(record) {
- set[record[tab+1:]] = struct{}{}
- }
- }
- paths := make([]string, 0, len(set))
- for path := range set {
- paths = append(paths, path)
- }
- sort.Strings(paths)
- return paths
-}
-
-func splitNull(value []byte) []string {
- if len(value) == 0 {
- return nil
- }
- parts := bytes.Split(value, []byte{0})
- if len(parts) > 0 && len(parts[len(parts)-1]) == 0 {
- parts = parts[:len(parts)-1]
- }
- result := make([]string, len(parts))
- for index, part := range parts {
- result[index] = string(part)
- }
- return result
-}
-
-func validObjectName(name string) error {
- if name == "" {
- return fmt.Errorf("object name is empty")
- }
- if strings.HasPrefix(name, "-") || strings.ContainsAny(name, "\x00\r\n") {
- return fmt.Errorf("unsafe object name %q", name)
- }
- return nil
-}
-
-func validateIdentity(identity GitIdentity) error {
- if identity.Name == "" || identity.Email == "" {
- return fmt.Errorf("name and email are required")
- }
- if strings.ContainsAny(identity.Name, "\x00\r\n<>") {
- return fmt.Errorf("unsafe name")
- }
- if strings.ContainsAny(identity.Email, "\x00\r\n<>") {
- return fmt.Errorf("unsafe email")
- }
- return nil
-}
-
-func checkRefName(ref string) error {
- if ref == "" || strings.ContainsAny(ref, "\x00\r\n") {
- return fmt.Errorf("invalid ref %q", ref)
- }
- if !strings.HasPrefix(ref, "refs/") || strings.HasSuffix(ref, "/") ||
- strings.Contains(ref, "..") || strings.Contains(ref, "@{") ||
- strings.ContainsAny(ref, " ~^:?*[\\") {
- return fmt.Errorf("invalid ref %q", ref)
- }
- for _, component := range strings.Split(ref, "/") {
- if component == "" || strings.HasPrefix(component, ".") || strings.HasSuffix(component, ".") || strings.HasSuffix(component, ".lock") {
- return fmt.Errorf("invalid ref %q", ref)
- }
- }
- return nil
-}
-
-func gitExitCode(err error) int {
- var gitErr *GitError
- if errors.As(err, &gitErr) {
- return gitErr.ExitCode
- }
- return -1
-}
-
-func mergeEnvironment(base, overrides []string) []string {
- values := make(map[string]string, len(base)+len(overrides))
- order := make([]string, 0, len(base)+len(overrides))
- add := func(entry string) {
- key := entry
- if equals := strings.IndexByte(entry, '='); equals >= 0 {
- key = entry[:equals]
- }
- if _, exists := values[key]; !exists {
- order = append(order, key)
- }
- values[key] = entry
- }
- for _, entry := range base {
- add(entry)
- }
- for _, entry := range overrides {
- add(entry)
- }
- result := make([]string, 0, len(values))
- for _, key := range order {
- result = append(result, values[key])
- }
- return result
-}
-
-func absolutePath(path string) (string, error) {
- if path == "" {
- path = "."
- }
- absolute, err := filepath.Abs(path)
- if err != nil {
- return "", fmt.Errorf("resolve path %q: %w", path, err)
- }
- return filepath.Clean(absolute), nil
-}
-
-func existingDirectory(path string) (string, error) {
- path, err := absolutePath(path)
- if err != nil {
- return "", err
- }
- info, err := os.Stat(path)
- if err != nil {
- return "", fmt.Errorf("inspect path %q: %w", path, err)
- }
- if !info.IsDir() {
- path = filepath.Dir(path)
- }
- return path, nil
-}
-
-func resolveGitPath(root, path string) string {
- if filepath.IsAbs(path) {
- return filepath.Clean(path)
- }
- return filepath.Clean(filepath.Join(root, path))
-}
-
-func trimLine(value string) string {
- return strings.TrimSuffix(strings.TrimSuffix(value, "\n"), "\r")
-}
diff --git a/internal/hop/id.go b/internal/hop/id.go
deleted file mode 100644
index 21e4fcf..0000000
--- a/internal/hop/id.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package hop
-
-import (
- "crypto/rand"
- "encoding/base32"
- "fmt"
- "strings"
- "time"
-)
-
-var idEncoding = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding)
-
-func newID(prefix string) string {
- var entropy [8]byte
- if _, err := rand.Read(entropy[:]); err != nil {
- panic(fmt.Sprintf("generate id: %v", err))
- }
- stamp := uint64(time.Now().UTC().UnixMilli())
- var raw [14]byte
- raw[0] = byte(stamp >> 40)
- raw[1] = byte(stamp >> 32)
- raw[2] = byte(stamp >> 24)
- raw[3] = byte(stamp >> 16)
- raw[4] = byte(stamp >> 8)
- raw[5] = byte(stamp)
- copy(raw[6:], entropy[:])
- return strings.ToUpper(prefix) + "_" + idEncoding.EncodeToString(raw[:])
-}
diff --git a/internal/hop/ledger.go b/internal/hop/ledger.go
deleted file mode 100644
index 51c2cd5..0000000
--- a/internal/hop/ledger.go
+++ /dev/null
@@ -1,149 +0,0 @@
-package hop
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "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.
-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"`
-}
-
-// 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) {
- if destinationRoot == "" {
- destinationRoot = s.Root
- }
- graph, err := s.Store.Graph(ctx, "")
- 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[attemptID] = struct{}{}
- }
- for _, row := range graph {
- state := row.State
- if state.Kind != StatePrompt || state.Prompt == "" {
- continue
- }
- if len(wantedAttempts) > 0 {
- if _, wanted := wantedAttempts[state.AttemptID]; !wanted {
- continue
- }
- }
- record := PortablePromptRecord{
- ID: state.ID,
- TaskID: state.TaskID,
- AttemptID: state.AttemptID,
- StateID: state.ID,
- Prompt: state.Prompt,
- AgentName: state.Agent,
- Status: "unknown",
- 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
- }
- }
- }
- 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 _, record := range ledger.Prompts {
- outputPath := filepath.Join(outputDir, record.ID+".json")
- if _, err := os.Stat(outputPath); err == nil {
- continue // Prompt records are immutable once published.
- } else if !os.IsNotExist(err) {
- return PortablePromptLedger{}, fmt.Errorf("inspect prompt record %s: %w", record.ID, err)
- }
- 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("publish prompt record %s: %w", record.ID, err)
- }
- }
- return ledger, nil
-}
diff --git a/internal/hop/model.go b/internal/hop/model.go
deleted file mode 100644
index 208c18c..0000000
--- a/internal/hop/model.go
+++ /dev/null
@@ -1,210 +0,0 @@
-package hop
-
-import (
- "fmt"
- "time"
-)
-
-type StateKind string
-
-const (
- StatePrompt StateKind = "prompt"
- StateCheckpoint StateKind = "checkpoint"
- StateProposal StateKind = "proposal"
- StateAccepted StateKind = "accepted"
- StateFailed StateKind = "failed"
- StateCancelled StateKind = "cancelled"
-)
-
-type Parent struct {
- StateID string `json:"state_id"`
- Role string `json:"role"`
- Order int `json:"order"`
-}
-
-type State struct {
- ID string `json:"id"`
- Kind StateKind `json:"kind"`
- TaskID string `json:"task_id,omitempty"`
- AttemptID string `json:"attempt_id,omitempty"`
- CanonicalAnchorID string `json:"canonical_anchor_id,omitempty"`
- SourceTree string `json:"source_tree"`
- GitCommit string `json:"git_commit"`
- Prompt string `json:"prompt,omitempty"`
- Summary string `json:"summary,omitempty"`
- Agent string `json:"agent,omitempty"`
- Digest string `json:"digest"`
- CreatedAt time.Time `json:"created_at"`
- Parents []Parent `json:"parents,omitempty"`
-}
-
-type Task struct {
- ID string `json:"id"`
- Title string `json:"title"`
- Status string `json:"status"`
- CreatedAt time.Time `json:"created_at"`
-}
-
-type Attempt struct {
- ID string `json:"id"`
- TaskID string `json:"task_id"`
- Agent string `json:"agent,omitempty"`
- Workspace string `json:"workspace"`
- BaseStateID string `json:"base_state_id"`
- HeadStateID string `json:"head_state_id"`
- Status string `json:"status"`
- CreatedAt time.Time `json:"created_at"`
-}
-
-type Check struct {
- ID string `json:"id"`
- AttemptID string `json:"attempt_id"`
- StateID string `json:"state_id,omitempty"`
- TreeHash string `json:"tree_hash"`
- Command []string `json:"command"`
- ExitCode int `json:"exit_code"`
- Output string `json:"output"`
- CreatedAt time.Time `json:"created_at"`
-}
-
-type GraphRow struct {
- State State `json:"state"`
- Parents []Parent `json:"parents"`
-}
-
-type Status struct {
- Root string `json:"root"`
- AcceptedHead State `json:"accepted_head"`
- Attempts []Attempt `json:"attempts"`
- RootStatus string `json:"root_status"`
- RootStateID string `json:"root_state_id,omitempty"`
-}
-
-type AcceptResult struct {
- State State `json:"state"`
- ProposalPaths []string `json:"proposal_paths"`
- CurrentPaths []string `json:"current_paths"`
- Check *Check `json:"check,omitempty"`
- MaterializedRoot string `json:"materialized_root,omitempty"`
- RemotePush *RemotePushResult `json:"remote_push,omitempty"`
- Warnings []string `json:"warnings,omitempty"`
-}
-
-type RemotePushResult struct {
- Remote string `json:"remote"`
- Ref string `json:"ref"`
- Commit string `json:"commit"`
-}
-
-type SyncResult struct {
- State State `json:"state"`
- Root string `json:"root"`
- FromState string `json:"from_state"`
- Changed bool `json:"changed"`
-}
-
-type RefreshResult struct {
- Prompt State `json:"prompt"`
- Task Task `json:"task"`
- Attempt Attempt `json:"attempt"`
- Proposal State `json:"proposal"`
- AcceptedHead State `json:"accepted_head"`
- Workspace string `json:"workspace"`
- Deliver []string `json:"deliver"`
- ConflictTree string `json:"conflict_tree"`
- Conflicts []string `json:"conflicts"`
- Reused bool `json:"reused"`
-}
-
-type PromptRedaction struct {
- Kind string `json:"kind"`
- Count int `json:"count"`
-}
-
-type PromptResult struct {
- Prompt State `json:"prompt"`
- Checkpoint *State `json:"checkpoint,omitempty"`
- Task Task `json:"task"`
- Attempt Attempt `json:"attempt"`
- Workspace string `json:"workspace"`
- Deliver []string `json:"deliver"`
- Redactions []PromptRedaction `json:"redactions,omitempty"`
-}
-
-type BeginResult struct {
- PromptResult
- Initialized bool `json:"initialized"`
- SessionID string `json:"session_id,omitempty"`
-}
-
-type EnvironmentResult struct {
- State State `json:"state"`
- Attempt Attempt `json:"attempt"`
- Workspace string `json:"workspace"`
- Variables map[string]string `json:"variables"`
-}
-
-type ProposalResult struct {
- Proposal State `json:"proposal"`
- Checks []Check `json:"checks"`
-}
-
-type DoctorReport struct {
- OK bool `json:"ok"`
- AcceptedState string `json:"accepted_state"`
- AcceptedCommit string `json:"accepted_commit"`
- RefCommit string `json:"ref_commit,omitempty"`
- Repaired bool `json:"repaired"`
- Problems []string `json:"problems,omitempty"`
-}
-
-type StaleHeadError struct {
- Expected string
- Actual string
-}
-
-func (e *StaleHeadError) Error() string {
- return "accepted head changed while the operation was running"
-}
-
-type CheckFailedError struct {
- Check Check
-}
-
-func (e *CheckFailedError) Error() string {
- return "validation command failed"
-}
-
-// CommittedStateError means the authoritative SQLite transition succeeded but
-// a derived ref or visible-root synchronization needs repair. Callers must not
-// retry the state transition.
-type CommittedStateError struct {
- State State
- Err error
-}
-
-func (e *CommittedStateError) Error() string {
- return fmt.Sprintf("state %s is committed, but post-acceptance synchronization needs repair: %v", e.State.ID, e.Err)
-}
-
-func (e *CommittedStateError) Unwrap() error { return e.Err }
-
-type ConflictError struct {
- Paths []string `json:"paths"`
-}
-
-func (e *ConflictError) Error() string {
- return "automatic three-way merge has genuine unresolved conflicts"
-}
-
-type RootConflictError struct {
- Paths []string
- Reason string
-}
-
-func (e *RootConflictError) Error() string {
- if e.Reason != "" {
- return e.Reason
- }
- return "visible project root has out-of-band changes; refusing to overwrite it"
-}
diff --git a/internal/hop/redact.go b/internal/hop/redact.go
deleted file mode 100644
index 88c6f98..0000000
--- a/internal/hop/redact.go
+++ /dev/null
@@ -1,245 +0,0 @@
-package hop
-
-import (
- "math"
- "regexp"
- "sort"
- "strings"
-)
-
-const redactedMarkerPrefix = "[REDACTED:"
-
-type secretPattern struct {
- pattern *regexp.Regexp
- group int
- kind string
- priority int
-}
-
-type secretCandidate struct {
- start int
- end int
- kind string
- priority int
-}
-
-var promptSecretPatterns = []secretPattern{
- {
- pattern: regexp.MustCompile(`(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----`),
- kind: "private_key",
- priority: 120,
- },
- {
- pattern: regexp.MustCompile(`(?s).*?`),
- kind: "explicit_secret",
- priority: 120,
- },
- {
- pattern: regexp.MustCompile(`\bsk-ant-[A-Za-z0-9_-]{20,}\b`),
- kind: "api_key",
- priority: 110,
- },
- {
- pattern: regexp.MustCompile(`\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}\b`),
- kind: "api_key",
- priority: 110,
- },
- {
- pattern: regexp.MustCompile(`\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b`),
- kind: "access_token",
- priority: 110,
- },
- {
- pattern: regexp.MustCompile(`\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b`),
- kind: "api_key",
- priority: 110,
- },
- {
- pattern: regexp.MustCompile(`\bAIza[0-9A-Za-z_-]{30,}\b`),
- kind: "api_key",
- priority: 110,
- },
- {
- pattern: regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{20,}\b`),
- kind: "access_token",
- priority: 110,
- },
- {
- pattern: regexp.MustCompile(`\b(?:AKIA|ASIA|AIDA|AROA|AIPA|ANPA|ANVA|AGPA)[A-Z0-9]{16}\b`),
- kind: "access_key",
- priority: 110,
- },
- {
- pattern: regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b`),
- kind: "auth_token",
- priority: 105,
- },
- {
- pattern: regexp.MustCompile(`(?i)(authorization[ \t]*:[ \t]*(?:bearer|token|basic)[ \t]+)([A-Za-z0-9._~+/=-]{12,})`),
- group: 2,
- kind: "auth_token",
- priority: 100,
- },
- {
- pattern: regexp.MustCompile(`(?i)\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|https?)://([^@\s/]+)@`),
- group: 1,
- kind: "connection_credential",
- priority: 100,
- },
- {
- pattern: regexp.MustCompile(
- `(?i)\b[a-z0-9_.-]*(?:api[ _-]?key|access[ _-]?key|secret[ _-]?key|access[ _-]?token|auth[ _-]?token|client[ _-]?secret|api[ _-]?secret|password|passwd|credential|token|secret|key)\b[ \t]*(?:=|:|is\b)[ \t]*["']?([^\s"'` + "`" + `]{12,})`,
- ),
- group: 1,
- kind: "credential",
- priority: 80,
- },
-}
-
-// RedactPromptSecrets removes high-confidence credentials before a prompt is
-// passed to any durable Hop component. It returns only category/count metadata;
-// secret values, hashes, and byte positions are deliberately discarded.
-func RedactPromptSecrets(prompt string) (string, []PromptRedaction) {
- if prompt == "" {
- return prompt, nil
- }
-
- var candidates []secretCandidate
- for _, detector := range promptSecretPatterns {
- for _, match := range detector.pattern.FindAllStringSubmatchIndex(prompt, -1) {
- index := detector.group * 2
- if index+1 >= len(match) || match[index] < 0 || match[index+1] <= match[index] {
- continue
- }
- start, end := match[index], match[index+1]
- if detector.kind == "credential" {
- for end > start && strings.ContainsRune(".,;:!?)]}", rune(prompt[end-1])) {
- end--
- }
- }
- if end <= start || strings.HasPrefix(prompt[start:end], redactedMarkerPrefix) {
- continue
- }
- if detector.kind == "credential" && !likelyCredentialValue(prompt[start:end]) {
- continue
- }
- candidates = append(candidates, secretCandidate{
- start: start,
- end: end,
- kind: detector.kind,
- priority: detector.priority,
- })
- }
- }
- if len(candidates) == 0 {
- return prompt, nil
- }
-
- sort.Slice(candidates, func(i, j int) bool {
- if candidates[i].priority != candidates[j].priority {
- return candidates[i].priority > candidates[j].priority
- }
- if candidates[i].start != candidates[j].start {
- return candidates[i].start < candidates[j].start
- }
- return candidates[i].end > candidates[j].end
- })
- selected := make([]secretCandidate, 0, len(candidates))
- for _, candidate := range candidates {
- overlaps := false
- for _, existing := range selected {
- if candidate.start < existing.end && existing.start < candidate.end {
- overlaps = true
- break
- }
- }
- if !overlaps {
- selected = append(selected, candidate)
- }
- }
- sort.Slice(selected, func(i, j int) bool { return selected[i].start < selected[j].start })
-
- counts := make(map[string]int)
- var redacted strings.Builder
- cursor := 0
- for _, candidate := range selected {
- redacted.WriteString(prompt[cursor:candidate.start])
- redacted.WriteString(redactedMarkerPrefix)
- redacted.WriteString(candidate.kind)
- redacted.WriteByte(']')
- counts[candidate.kind]++
- cursor = candidate.end
- }
- redacted.WriteString(prompt[cursor:])
-
- kinds := make([]string, 0, len(counts))
- for kind := range counts {
- kinds = append(kinds, kind)
- }
- sort.Strings(kinds)
- findings := make([]PromptRedaction, 0, len(kinds))
- for _, kind := range kinds {
- findings = append(findings, PromptRedaction{Kind: kind, Count: counts[kind]})
- }
- return redacted.String(), findings
-}
-
-func redactSecretStrings(values []string) ([]string, []PromptRedaction) {
- redacted := make([]string, len(values))
- counts := make(map[string]int)
- for index, value := range values {
- var findings []PromptRedaction
- redacted[index], findings = RedactPromptSecrets(value)
- for _, finding := range findings {
- counts[finding.Kind] += finding.Count
- }
- }
- kinds := make([]string, 0, len(counts))
- for kind := range counts {
- kinds = append(kinds, kind)
- }
- sort.Strings(kinds)
- findings := make([]PromptRedaction, 0, len(kinds))
- for _, kind := range kinds {
- findings = append(findings, PromptRedaction{Kind: kind, Count: counts[kind]})
- }
- return redacted, findings
-}
-
-func likelyCredentialValue(value string) bool {
- if len(value) < 12 {
- return false
- }
- classes := 0
- var lower, upper, digit, symbol bool
- counts := make(map[byte]int)
- for index := 0; index < len(value); index++ {
- character := value[index]
- counts[character]++
- switch {
- case character >= 'a' && character <= 'z':
- lower = true
- case character >= 'A' && character <= 'Z':
- upper = true
- case character >= '0' && character <= '9':
- digit = true
- default:
- symbol = true
- }
- }
- for _, present := range []bool{lower, upper, digit, symbol} {
- if present {
- classes++
- }
- }
- if classes < 2 {
- return false
- }
- entropy := 0.0
- length := float64(len(value))
- for _, count := range counts {
- probability := float64(count) / length
- entropy -= probability * math.Log2(probability)
- }
- return entropy >= 3.0
-}
diff --git a/internal/hop/redact_test.go b/internal/hop/redact_test.go
deleted file mode 100644
index 3d075af..0000000
--- a/internal/hop/redact_test.go
+++ /dev/null
@@ -1,141 +0,0 @@
-package hop
-
-import (
- "bytes"
- "context"
- "io/fs"
- "os"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func TestRedactPromptSecrets(t *testing.T) {
- apiKey := "sk-proj-" + strings.Repeat("A7", 24)
- githubToken := "ghp_" + strings.Repeat("b9", 20)
- bearer := strings.Repeat("abcDEF123._-", 3)
- generic := strings.Repeat("customKey9", 4)
- privateKey := "-----BEGIN PRIVATE KEY-----\n" + strings.Repeat("base64material", 4) + "\n-----END PRIVATE KEY-----"
-
- tests := []struct {
- name string
- prompt string
- secret string
- marker string
- }{
- {"provider API key", "Use " + apiKey + " for this request", apiKey, "[REDACTED:api_key]"},
- {"provider access token", "token=" + githubToken, githubToken, "[REDACTED:access_token]"},
- {"authorization header", "Authorization: Bearer " + bearer, bearer, "[REDACTED:auth_token]"},
- {"contextual generic key", "OPENAI_API_KEY=\"" + generic + "\"", generic, "[REDACTED:credential]"},
- {"natural language key", "my api key is " + generic, generic, "[REDACTED:credential]"},
- {"private key", "deploy with\n" + privateKey + "\nnow", privateKey, "[REDACTED:private_key]"},
- }
- for _, test := range tests {
- t.Run(test.name, func(t *testing.T) {
- redacted, findings := RedactPromptSecrets(test.prompt)
- if strings.Contains(redacted, test.secret) {
- t.Fatalf("redacted prompt still contains secret: %q", redacted)
- }
- if !strings.Contains(redacted, test.marker) {
- t.Fatalf("redacted prompt = %q, want marker %q", redacted, test.marker)
- }
- if len(findings) == 0 {
- t.Fatal("redaction metadata is empty")
- }
- })
- }
-
- plain := "Read OPENAI_API_KEY from the environment; the key is configuration; inspect commit 0123456789abcdef0123456789abcdef."
- if redacted, findings := RedactPromptSecrets(plain); redacted != plain || len(findings) != 0 {
- t.Fatalf("plain prompt changed to %q with findings %#v", redacted, findings)
- }
-}
-
-func TestPromptSecretNeverReachesDurableProjectBytes(t *testing.T) {
- root := t.TempDir()
- writeTestFile(t, filepath.Join(root, "base.txt"), "base\n")
- service, _, err := InitProject(context.Background(), root)
- if err != nil {
- t.Fatal(err)
- }
- secret := "sk-proj-" + strings.Repeat("NeverPersist7", 4)
- result, err := service.CreatePrompt(context.Background(), "Use this API key: "+secret, "", "test")
- if err != nil {
- t.Fatal(err)
- }
- if len(result.Redactions) != 1 || result.Redactions[0].Count != 1 {
- t.Fatalf("redactions = %#v, want one finding", result.Redactions)
- }
- if strings.Contains(result.Prompt.Prompt, secret) || !strings.Contains(result.Prompt.Prompt, redactedMarkerPrefix) {
- t.Fatalf("stored prompt was not redacted: %q", result.Prompt.Prompt)
- }
- check, err := service.RunCheck(context.Background(), result.Prompt.ID, []string{
- "sh", "-c", "printf '%s' '" + secret + "'",
- })
- if err != nil {
- t.Fatal(err)
- }
- if strings.Contains(strings.Join(check.Command, " "), secret) || strings.Contains(check.Output, secret) {
- t.Fatalf("stored check leaked secret: %#v / %q", check.Command, check.Output)
- }
- proposal, err := service.Propose(context.Background(), result.Prompt.ID, "Completed with "+secret)
- if err != nil {
- t.Fatal(err)
- }
- if strings.Contains(proposal.Proposal.Summary, secret) {
- t.Fatalf("stored proposal summary leaked secret: %q", proposal.Proposal.Summary)
- }
- if err := service.Close(); err != nil {
- t.Fatal(err)
- }
-
- needle := []byte(secret)
- for _, tree := range []string{filepath.Join(root, ".hop"), filepath.Join(root, ".git", "refs", "hop")} {
- err := filepath.WalkDir(tree, func(path string, entry fs.DirEntry, walkErr error) error {
- if walkErr != nil {
- return walkErr
- }
- if entry.IsDir() {
- return nil
- }
- contents, readErr := os.ReadFile(path)
- if readErr != nil {
- return readErr
- }
- if bytes.Contains(contents, needle) {
- t.Errorf("secret leaked into durable file %s", path)
- }
- return nil
- })
- if err != nil && !os.IsNotExist(err) {
- t.Fatal(err)
- }
- }
-}
-
-func TestBeginJSONNeverEchoesPromptSecret(t *testing.T) {
- root := t.TempDir()
- 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) })
-
- secret := "ghp_" + strings.Repeat("NoEcho7", 6)
- var stdout, stderr bytes.Buffer
- code := RunCLIWithInput(
- []string{"begin", "--agent", "codex", "--session", "secret-test", "--stdin", "--json"},
- strings.NewReader("Use token="+secret), &stdout, &stderr)
- if code != 0 {
- t.Fatalf("begin exited %d: %s", code, stderr.String())
- }
- if strings.Contains(stdout.String(), secret) || strings.Contains(stderr.String(), secret) {
- t.Fatal("begin output echoed the prompt secret")
- }
- if !strings.Contains(stdout.String(), redactedMarkerPrefix) || !strings.Contains(stdout.String(), `"redactions"`) {
- t.Fatalf("begin JSON omitted redaction evidence: %s", stdout.String())
- }
-}
diff --git a/internal/hop/service.go b/internal/hop/service.go
deleted file mode 100644
index f7aa246..0000000
--- a/internal/hop/service.go
+++ /dev/null
@@ -1,1694 +0,0 @@
-package hop
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "time"
-)
-
-const (
- acceptedRef = "accepted"
- reconciliationSummaryPrefix = "hop-reconciliation-conflicts:"
-)
-
-type Service struct {
- Root string
- Store *Store
- Repo *Repository
-}
-
-func InitProject(ctx context.Context, path string) (*Service, State, error) {
- repo, err := EnsureRepository(path)
- if err != nil {
- return nil, State{}, err
- }
- root := repo.Root()
- trackedHopPaths, err := repo.TrackedPaths(ctx, ".hop")
- if err != nil {
- 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)
- }
- }
- hopDir := filepath.Join(root, ".hop")
- if err := os.MkdirAll(filepath.Join(hopDir, "workspaces"), 0o755); err != nil {
- return nil, State{}, fmt.Errorf("create Hop project directory: %w", err)
- }
- releaseInit, err := acquireProjectLock(ctx, root, "init")
- if err != nil {
- return nil, State{}, err
- }
- defer releaseInit()
- store, err := OpenStore(filepath.Join(hopDir, "hop.db"))
- if err != nil {
- return nil, State{}, err
- }
- service := &Service{Root: root, Store: store, Repo: repo}
- initialized, err := store.IsInitialized(ctx)
- if err != nil {
- service.Close()
- return nil, State{}, err
- }
- if initialized {
- head, err := store.AcceptedHead(ctx)
- if err != nil {
- service.Close()
- return nil, State{}, err
- }
- // Derived-ref repair acquires accept.lock. Release init.lock first so a
- // validation child opening this project cannot invert those locks.
- releaseInit()
- if err := service.reconcileDerivedRefs(ctx, true); err != nil {
- service.Close()
- return nil, State{}, err
- }
- return service, head, nil
- }
-
- commit, tree, err := repo.Snapshot(ctx, "hop: initial project state\n")
- if err != nil {
- service.Close()
- return nil, State{}, err
- }
- initial := State{
- ID: newID("a"),
- Kind: StateAccepted,
- SourceTree: tree,
- GitCommit: commit,
- Summary: "Initial project state",
- Agent: "hop",
- CreatedAt: time.Now().UTC(),
- }
- initial.Digest, err = digestState(initial, nil)
- if err != nil {
- service.Close()
- return nil, State{}, err
- }
- if err := service.pinState(ctx, initial); err != nil {
- service.Close()
- return nil, State{}, err
- }
- initial, err = store.CreateInitialState(ctx, root, initial)
- if err != nil {
- service.Close()
- return nil, State{}, err
- }
- if err := repo.UpdateHiddenRef(ctx, acceptedRef, commit); err != nil {
- service.Close()
- return nil, State{}, err
- }
- return service, initial, nil
-}
-
-func OpenProject(start string) (*Service, error) {
- root, err := FindHopRoot(start)
- if err != nil {
- return nil, err
- }
- releaseInit, err := acquireProjectLock(context.Background(), root, "init")
- if err != nil {
- return nil, err
- }
- defer releaseInit()
- store, err := OpenStore(filepath.Join(root, ".hop", "hop.db"))
- if err != nil {
- return nil, err
- }
- repo, err := OpenRepository(root)
- if err != nil {
- store.Close()
- return nil, err
- }
- recordedRoot, err := store.RepositoryRoot(context.Background())
- if err != nil {
- store.Close()
- return nil, err
- }
- recordedInfo, recordedErr := os.Stat(recordedRoot)
- rootInfo, rootErr := os.Stat(root)
- if recordedErr != nil || rootErr != nil {
- store.Close()
- if recordedErr != nil {
- return nil, fmt.Errorf("inspect recorded Hop root: %w", recordedErr)
- }
- return nil, fmt.Errorf("inspect discovered Hop root: %w", rootErr)
- }
- if !os.SameFile(recordedInfo, rootInfo) {
- store.Close()
- return nil, fmt.Errorf("Hop database belongs to %s, not %s", recordedRoot, root)
- }
- service := &Service{Root: root, Store: store, Repo: repo}
- reconcileAccepted := os.Getenv("HOP_ACCEPTANCE_LOCK_HELD") != "1"
- // Never hold init.lock while derived-ref repair acquires accept.lock.
- releaseInit()
- if err := service.reconcileDerivedRefs(context.Background(), reconcileAccepted); err != nil {
- service.Close()
- return nil, err
- }
- return service, nil
-}
-
-func (s *Service) Close() error {
- if s == nil || s.Store == nil {
- return nil
- }
- return s.Store.Close()
-}
-
-func (s *Service) CreatePrompt(ctx context.Context, message, fromStateID, agent string) (PromptResult, error) {
- if strings.TrimSpace(message) == "" {
- return PromptResult{}, fmt.Errorf("prompt text is required")
- }
- message, redactions := RedactPromptSecrets(message)
- agent, _ = RedactPromptSecrets(agent)
- var result PromptResult
- var err error
- if fromStateID == "" {
- result, err = s.createInitialPrompt(ctx, message, agent)
- } else {
- result, err = s.createFollowupPrompt(ctx, message, fromStateID, agent)
- }
- if err != nil {
- return PromptResult{}, err
- }
- result.Redactions = redactions
- return result, nil
-}
-
-// BeginPrompt is the interactive-agent entry point. It resolves follow-up
-// ancestry from a stable harness session ID, creates the prompt state before
-// project effects, and advances the session pointer for the next turn.
-func (s *Service) BeginPrompt(ctx context.Context, message, fromStateID, agent, sessionID string) (PromptResult, error) {
- if strings.TrimSpace(agent) == "" {
- agent = "agent"
- }
- if redactedAgent, findings := RedactPromptSecrets(agent); len(findings) > 0 || redactedAgent != agent {
- return PromptResult{}, errors.New("hop: refusing to use a potential credential as an agent name")
- }
- if redactedSession, findings := RedactPromptSecrets(sessionID); len(findings) > 0 || redactedSession != sessionID {
- return PromptResult{}, errors.New("hop: refusing to use a potential credential as an agent session ID")
- }
- // Serialize interactive prompt capture with other begins. Acceptance checks
- // the proposal's attempt head again inside its SQLite transaction, so a begin
- // racing a land makes that proposal stale without sharing accept.lock.
- release, err := acquireProjectLock(ctx, s.Root, "prompt")
- if err != nil {
- return PromptResult{}, err
- }
- defer release()
-
- if fromStateID == "" && sessionID != "" {
- if stateID, exists, err := s.Store.AgentSessionHead(ctx, agent, sessionID); err != nil {
- return PromptResult{}, err
- } else if exists {
- continuable, err := s.sessionStateContinuable(ctx, stateID)
- if err != nil {
- return PromptResult{}, err
- }
- if continuable {
- fromStateID = stateID
- }
- }
- }
- result, err := s.CreatePrompt(ctx, message, fromStateID, agent)
- if err != nil {
- return PromptResult{}, err
- }
- if sessionID != "" {
- if err := s.Store.SetAgentSessionHead(ctx, agent, sessionID, result.Prompt.ID); err != nil {
- return PromptResult{}, err
- }
- }
- return result, nil
-}
-
-// sessionStateContinuable decides whether an implicit interactive-session
-// pointer still represents unfinished work. An immutable accepted state for
-// the task ends ordinary session continuation even if an older Hop build later
-// changed the task's mutable status back to active. A dirty post-proposal
-// workspace is preserved by continuing it instead of silently abandoning work.
-func (s *Service) sessionStateContinuable(ctx context.Context, stateID string) (bool, error) {
- state, err := s.Store.GetState(ctx, stateID)
- if err != nil {
- return false, err
- }
- if state.TaskID == "" || state.AttemptID == "" {
- return false, fmt.Errorf("session state %s does not belong to a task attempt", state.ID)
- }
- accepted, exists, err := s.Store.AcceptedForTask(ctx, state.TaskID)
- if err != nil {
- return false, err
- }
- if !exists {
- return true, nil
- }
- attempt, err := s.Store.GetAttempt(ctx, state.AttemptID)
- if err != nil {
- return false, err
- }
- head, err := s.Store.GetState(ctx, attempt.HeadStateID)
- if err != nil {
- return false, err
- }
- covered, err := s.Store.StateDescendsFrom(ctx, accepted.ID, head.ID)
- if err != nil {
- return false, err
- }
- if !covered {
- return true, nil
- }
- workspaceRepo, err := OpenRepository(attempt.Workspace)
- if err != nil {
- return false, err
- }
- workspaceTree, err := workspaceRepo.WorktreeTree(ctx, head.SourceTree)
- if err != nil {
- return false, err
- }
- if workspaceTree != head.SourceTree {
- return true, nil
- }
-
- status := "completed"
- if attempt.ID == accepted.AttemptID {
- status = "accepted"
- }
- if attempt.Status != status {
- if err := s.Store.UpdateAttemptStatus(ctx, attempt.ID, status); err != nil {
- return false, err
- }
- }
- if err := s.Store.UpdateTaskStatus(ctx, state.TaskID, "accepted"); err != nil {
- return false, err
- }
- return false, nil
-}
-
-func (s *Service) createInitialPrompt(ctx context.Context, message, agent string) (PromptResult, error) {
- accepted, err := s.Store.AcceptedHead(ctx)
- if err != nil {
- return PromptResult{}, err
- }
- now := time.Now().UTC()
- task := Task{ID: newID("t"), Title: promptTitle(message), Status: "active", CreatedAt: now}
- attemptID := newID("at")
- workspace := filepath.Join(s.Root, ".hop", "workspaces", attemptID)
- attempt := Attempt{
- ID: attemptID,
- TaskID: task.ID,
- Agent: agent,
- Workspace: workspace,
- BaseStateID: accepted.ID,
- Status: "active",
- CreatedAt: now,
- }
- parents := canonicalizeParents([]Parent{
- {StateID: accepted.ID, Role: "run_parent", Order: 0},
- {StateID: accepted.ID, Role: "canonical_anchor", Order: 1},
- })
- prompt := State{
- ID: newID("p"),
- Kind: StatePrompt,
- TaskID: task.ID,
- AttemptID: attempt.ID,
- CanonicalAnchorID: accepted.ID,
- SourceTree: accepted.SourceTree,
- GitCommit: accepted.GitCommit,
- Prompt: message,
- Agent: agent,
- CreatedAt: now,
- Parents: parents,
- }
- prompt.Digest, err = digestState(prompt, parents)
- if err != nil {
- return PromptResult{}, err
- }
- if err := s.pinState(ctx, prompt); err != nil {
- return PromptResult{}, err
- }
- task, attempt, prompt, err = s.Store.CreateTaskAttemptPrompt(ctx, task, attempt, prompt, parents)
- if err != nil {
- return PromptResult{}, err
- }
- if err := os.MkdirAll(filepath.Dir(workspace), 0o755); err != nil {
- _ = s.Store.UpdateAttemptStatus(ctx, attempt.ID, "failed")
- return PromptResult{}, err
- }
- if _, err := s.Repo.AddDetachedWorktree(ctx, workspace, accepted.GitCommit); err != nil {
- _ = s.Store.UpdateAttemptStatus(ctx, attempt.ID, "failed")
- return PromptResult{}, err
- }
- return PromptResult{
- Prompt: prompt,
- Task: task,
- Attempt: attempt,
- Workspace: workspace,
- Deliver: s.deliveryEnvironment(prompt, attempt),
- }, nil
-}
-
-func (s *Service) createFollowupPrompt(ctx context.Context, message, fromStateID, agent string) (PromptResult, error) {
- from, err := s.Store.GetState(ctx, fromStateID)
- if err != nil {
- return PromptResult{}, err
- }
- if from.AttemptID == "" {
- return PromptResult{}, fmt.Errorf("state %s does not belong to an attempt", from.ID)
- }
- attempt, err := s.Store.GetAttempt(ctx, from.AttemptID)
- if err != nil {
- return PromptResult{}, err
- }
- task, err := s.Store.GetTask(ctx, attempt.TaskID)
- if err != nil {
- return PromptResult{}, err
- }
- checkpoint, err := s.checkpointAttempt(ctx, attempt)
- if err != nil {
- return PromptResult{}, err
- }
- accepted, err := s.Store.AcceptedHead(ctx)
- if err != nil {
- return PromptResult{}, err
- }
- if agent == "" {
- agent = attempt.Agent
- }
- parents := canonicalizeParents([]Parent{
- {StateID: checkpoint.ID, Role: "run_parent", Order: 0},
- {StateID: accepted.ID, Role: "canonical_anchor", Order: 1},
- })
- prompt := State{
- ID: newID("p"),
- Kind: StatePrompt,
- TaskID: attempt.TaskID,
- AttemptID: attempt.ID,
- CanonicalAnchorID: accepted.ID,
- SourceTree: checkpoint.SourceTree,
- GitCommit: checkpoint.GitCommit,
- Prompt: message,
- Agent: agent,
- CreatedAt: time.Now().UTC(),
- Parents: parents,
- }
- prompt.Digest, err = digestState(prompt, parents)
- if err != nil {
- return PromptResult{}, err
- }
- if err := s.pinState(ctx, prompt); err != nil {
- return PromptResult{}, err
- }
- prompt, err = s.Store.AppendState(ctx, prompt, parents, checkpoint.ID)
- if err != nil {
- return PromptResult{}, mapHeadError(err)
- }
- _ = s.Store.UpdateAttemptStatus(ctx, attempt.ID, "active")
- _ = s.Store.UpdateTaskStatus(ctx, task.ID, "active")
- attempt, err = s.Store.GetAttempt(ctx, attempt.ID)
- if err != nil {
- return PromptResult{}, err
- }
- return PromptResult{
- Prompt: prompt,
- Checkpoint: &checkpoint,
- Task: task,
- Attempt: attempt,
- Workspace: attempt.Workspace,
- Deliver: s.deliveryEnvironment(prompt, attempt),
- }, nil
-}
-
-func (s *Service) Checkpoint(ctx context.Context, stateID string) (State, error) {
- state, err := s.Store.GetState(ctx, stateID)
- if err != nil {
- return State{}, err
- }
- if state.AttemptID == "" {
- return State{}, fmt.Errorf("state %s does not belong to an attempt", state.ID)
- }
- attempt, err := s.Store.GetAttempt(ctx, state.AttemptID)
- if err != nil {
- return State{}, err
- }
- return s.checkpointAttempt(ctx, attempt)
-}
-
-func (s *Service) checkpointAttempt(ctx context.Context, attempt Attempt) (State, error) {
- workspaceRepo, err := OpenRepository(attempt.Workspace)
- if err != nil {
- return State{}, err
- }
- commit, tree, err := workspaceRepo.Snapshot(ctx, "hop: workspace checkpoint\n")
- if err != nil {
- return State{}, err
- }
- head, err := s.Store.GetState(ctx, attempt.HeadStateID)
- if err != nil {
- return State{}, err
- }
- parents := canonicalizeParents([]Parent{{StateID: head.ID, Role: "run_parent", Order: 0}})
- checkpoint := State{
- ID: newID("c"),
- Kind: StateCheckpoint,
- TaskID: attempt.TaskID,
- AttemptID: attempt.ID,
- CanonicalAnchorID: head.CanonicalAnchorID,
- SourceTree: tree,
- GitCommit: commit,
- Agent: attempt.Agent,
- CreatedAt: time.Now().UTC(),
- Parents: parents,
- }
- checkpoint.Digest, err = digestState(checkpoint, parents)
- if err != nil {
- return State{}, err
- }
- if err := s.pinState(ctx, checkpoint); err != nil {
- return State{}, err
- }
- checkpoint, err = s.Store.AppendState(ctx, checkpoint, parents, head.ID)
- if err != nil {
- return State{}, mapHeadError(err)
- }
- return checkpoint, nil
-}
-
-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
- }
- attempt, err := s.Store.GetAttempt(ctx, checkpoint.AttemptID)
- if err != nil {
- return Check{}, err
- }
- checkID := newID("evidence")
- checkPath := filepath.Join(s.Root, ".hop", "checks", checkID)
- if err := os.MkdirAll(filepath.Dir(checkPath), 0o755); err != nil {
- return Check{}, err
- }
- if _, err := s.Repo.AddDetachedWorktree(ctx, checkPath, checkpoint.GitCommit); err != nil {
- return Check{}, err
- }
- env := []string{
- "HOP_ROOT=" + s.Root,
- "HOP_STATE_ID=" + checkpoint.ID,
- "HOP_TASK_ID=" + checkpoint.TaskID,
- "HOP_ATTEMPT_ID=" + attempt.ID,
- "HOP_WORKSPACE=" + checkPath,
- }
- result, runErr := runWorkspaceCommand(ctx, checkPath, env, argv)
- removeErr := s.Repo.RemoveWorktree(ctx, checkPath, true)
- if runErr != nil {
- return Check{}, runErr
- }
- if removeErr != nil {
- return Check{}, removeErr
- }
- storedCommand, _ := redactSecretStrings(argv)
- storedOutput, _ := RedactPromptSecrets(result.Output)
- check := Check{
- ID: checkID,
- AttemptID: attempt.ID,
- StateID: checkpoint.ID,
- TreeHash: checkpoint.SourceTree,
- Command: storedCommand,
- ExitCode: result.ExitCode,
- Output: storedOutput,
- CreatedAt: time.Now().UTC(),
- }
- check, err = s.Store.AddCheck(ctx, check)
- if err != nil {
- return Check{}, err
- }
- if check.ExitCode != 0 {
- return check, &CheckFailedError{Check: check}
- }
- return check, nil
-}
-
-func (s *Service) Propose(ctx context.Context, stateID, summary string) (ProposalResult, error) {
- state, err := s.Store.GetState(ctx, stateID)
- if err != nil {
- return ProposalResult{}, err
- }
- if state.AttemptID == "" {
- return ProposalResult{}, fmt.Errorf("state %s does not belong to an attempt", state.ID)
- }
- attempt, err := s.Store.GetAttempt(ctx, state.AttemptID)
- if err != nil {
- return ProposalResult{}, err
- }
- task, err := s.Store.GetTask(ctx, attempt.TaskID)
- if err != nil {
- return ProposalResult{}, err
- }
- reconciliationPrompt, reconciliation, err := s.Store.ReconciliationPromptForAttempt(ctx, attempt.ID)
- if err != nil {
- return ProposalResult{}, err
- }
- var reconciliationConflicts []string
- if reconciliation {
- conflicts, ok := decodeReconciliationConflicts(reconciliationPrompt.Summary)
- if !ok {
- return ProposalResult{}, fmt.Errorf("reconciliation prompt %s has invalid conflict metadata", reconciliationPrompt.ID)
- }
- reconciliationConflicts = conflicts
- }
- workspaceRepo, err := OpenRepository(attempt.Workspace)
- 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")
- if err != nil {
- return ProposalResult{}, err
- }
- if reconciliation {
- unresolved, err := unresolvedReconciliationMarkers(ctx, workspaceRepo, tree, reconciliationConflicts)
- if err != nil {
- return ProposalResult{}, err
- }
- if len(unresolved) > 0 {
- return ProposalResult{}, fmt.Errorf("reconciliation still contains merge markers in: %s", strings.Join(unresolved, ", "))
- }
- }
- checks, err := s.Store.ListChecks(ctx, attempt.ID, tree)
- if err != nil {
- return ProposalResult{}, err
- }
- if reconciliation {
- validated := false
- for _, check := range checks {
- if check.ExitCode == 0 {
- validated = true
- break
- }
- }
- if !validated {
- return ProposalResult{}, fmt.Errorf("reconciliation must pass hop check on the resolved tree before proposing")
- }
- }
- if strings.TrimSpace(summary) == "" {
- summary = task.Title
- }
- summary, _ = RedactPromptSecrets(summary)
- canonicalAnchorID := attempt.BaseStateID
- if reconciliation && reconciliationPrompt.CanonicalAnchorID != "" {
- canonicalAnchorID = reconciliationPrompt.CanonicalAnchorID
- }
- parents := canonicalizeParents([]Parent{{StateID: attempt.HeadStateID, Role: "run_parent", Order: 0}})
- proposal := State{
- ID: newID("r"),
- Kind: StateProposal,
- TaskID: attempt.TaskID,
- AttemptID: attempt.ID,
- CanonicalAnchorID: canonicalAnchorID,
- SourceTree: tree,
- GitCommit: commit,
- Summary: summary,
- Agent: attempt.Agent,
- CreatedAt: time.Now().UTC(),
- Parents: parents,
- }
- proposal.Digest, err = digestState(proposal, parents)
- if err != nil {
- return ProposalResult{}, err
- }
- if err := s.pinState(ctx, proposal); err != nil {
- return ProposalResult{}, err
- }
- proposal, err = s.Store.AppendState(ctx, proposal, parents, attempt.HeadStateID)
- if err != nil {
- return ProposalResult{}, mapHeadError(err)
- }
- _ = s.Store.UpdateAttemptStatus(ctx, attempt.ID, "proposed")
- _ = s.Store.UpdateTaskStatus(ctx, task.ID, "proposed")
- return ProposalResult{Proposal: proposal, Checks: checks}, nil
-}
-
-// Accept advances Hop's internal accepted lineage without changing the visible
-// project root. Controllers use this lower-level operation deliberately.
-func (s *Service) Accept(ctx context.Context, proposalID string, checkCommand []string) (AcceptResult, error) {
- return s.accept(ctx, proposalID, checkCommand, false)
-}
-
-// Land accepts a proposal and projects the resulting accepted tree into the
-// visible project root while preserving the user's HEAD, branch, and index.
-func (s *Service) Land(ctx context.Context, proposalID string, checkCommand []string) (AcceptResult, error) {
- return s.accept(ctx, proposalID, checkCommand, true)
-}
-
-// Push publishes the current accepted commit to the repository's inferred
-// upstream branch. Land and Accept call the same operation automatically; this
-// explicit form is the retry/recovery surface for an agent after a warning.
-func (s *Service) Push(ctx context.Context) (RemotePushResult, error) {
- release, err := acquireProjectLock(ctx, s.Root, "accept")
- if err != nil {
- return RemotePushResult{}, err
- }
- defer release()
- accepted, err := s.Store.AcceptedHead(ctx)
- if err != nil {
- return RemotePushResult{}, err
- }
- pushCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
- defer cancel()
- result, configured, err := s.Repo.PushAccepted(pushCtx, accepted.GitCommit)
- if err != nil {
- message, _ := RedactPromptSecrets(err.Error())
- return RemotePushResult{}, errors.New(message)
- }
- if !configured {
- return RemotePushResult{}, errors.New("hop: no unambiguous Git remote branch is configured for automatic push")
- }
- return result, nil
-}
-
-func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []string, materialize bool) (AcceptResult, error) {
- release, err := acquireProjectLock(ctx, s.Root, "accept")
- if err != nil {
- return AcceptResult{}, err
- }
- defer release()
-
- proposal, err := s.Store.GetState(ctx, proposalID)
- if err != nil {
- return AcceptResult{}, err
- }
- if proposal.Kind != StateProposal {
- return AcceptResult{}, fmt.Errorf("state %s is %s, not a proposal", proposal.ID, proposal.Kind)
- }
- if existing, exists, err := s.Store.AcceptedForProposal(ctx, proposal.ID); err != nil {
- return AcceptResult{}, err
- } else if exists {
- result := AcceptResult{State: existing}
- if materialize {
- if _, err := s.syncLocked(ctx); err != nil {
- return result, &CommittedStateError{State: existing, Err: fmt.Errorf("repair visible root after prior acceptance: %w", err)}
- }
- result.MaterializedRoot = s.Root
- }
- s.attachAutomaticPush(ctx, &result)
- return result, nil
- }
- attempt, err := s.Store.GetAttempt(ctx, proposal.AttemptID)
- if err != nil {
- return AcceptResult{}, err
- }
- if attempt.HeadStateID != proposal.ID {
- return AcceptResult{}, &HeadChangedError{
- Scope: "attempt", Expected: proposal.ID, Actual: attempt.HeadStateID,
- }
- }
- baseStateID := proposal.CanonicalAnchorID
- if baseStateID == "" {
- baseStateID = attempt.BaseStateID
- }
- base, err := s.Store.GetState(ctx, baseStateID)
- if err != nil {
- return AcceptResult{}, err
- }
- current, err := s.Store.AcceptedHead(ctx)
- if err != nil {
- return AcceptResult{}, err
- }
- proposalPaths, err := s.Repo.ChangedPaths(ctx, base.GitCommit, proposal.GitCommit)
- if err != nil {
- return AcceptResult{}, err
- }
- currentPaths, err := s.Repo.ChangedPaths(ctx, base.GitCommit, current.GitCommit)
- if err != nil {
- return AcceptResult{}, err
- }
- proposalPaths = withoutPortableHopRecords(proposalPaths)
- currentPaths = withoutPortableHopRecords(currentPaths)
- finalTree := proposal.SourceTree
- if current.SourceTree != base.SourceTree {
- var mergeConflicts []string
- finalTree, mergeConflicts, err = s.Repo.ComposeTrees(ctx, base.GitCommit, current.GitCommit, proposal.GitCommit)
- if err != nil {
- return AcceptResult{}, err
- }
- if len(mergeConflicts) > 0 {
- return AcceptResult{}, &ConflictError{Paths: mergeConflicts}
- }
- }
-
- acceptedID := newID("a")
- message := proposal.Summary
- if message == "" {
- 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)
- if err != nil {
- return AcceptResult{}, err
- }
-
- var recordedCheck *Check
- validationRef := ""
- defer func() {
- if validationRef != "" {
- _ = s.Repo.DeleteRef(ctx, "refs/hop/"+validationRef, commit)
- }
- }()
- if len(checkCommand) > 0 {
- checkID := newID("evidence")
- validationRef = "validation/" + checkID
- if err := s.Repo.UpdateHiddenRef(ctx, validationRef, commit); err != nil {
- return AcceptResult{}, fmt.Errorf("pin final validation tree: %w", err)
- }
- integrationPath := filepath.Join(s.Root, ".hop", "integration", acceptedID)
- if err := os.MkdirAll(filepath.Dir(integrationPath), 0o755); err != nil {
- return AcceptResult{}, err
- }
- if _, err := s.Repo.AddDetachedWorktree(ctx, integrationPath, commit); err != nil {
- return AcceptResult{}, err
- }
- result, runErr := runWorkspaceCommand(ctx, integrationPath, []string{
- "HOP_ROOT=" + s.Root,
- "HOP_STATE_ID=" + acceptedID,
- "HOP_TASK_ID=" + proposal.TaskID,
- "HOP_ATTEMPT_ID=" + proposal.AttemptID,
- "HOP_ACCEPTANCE_LOCK_HELD=1",
- }, checkCommand)
- removeErr := s.Repo.RemoveWorktree(ctx, integrationPath, true)
- if runErr != nil {
- return AcceptResult{}, runErr
- }
- if removeErr != nil {
- return AcceptResult{}, removeErr
- }
- storedCommand, _ := redactSecretStrings(checkCommand)
- storedOutput, _ := RedactPromptSecrets(result.Output)
- check := Check{
- ID: checkID,
- AttemptID: proposal.AttemptID,
- TreeHash: finalTree,
- Command: storedCommand,
- ExitCode: result.ExitCode,
- Output: storedOutput,
- CreatedAt: time.Now().UTC(),
- }
- if check.ExitCode != 0 {
- if failed, recorded := s.recordValidationFailure(ctx, proposal, current, commit, finalTree, checkCommand); recorded {
- check.StateID = failed.ID
- _ = s.Repo.DeleteRef(ctx, "refs/hop/"+validationRef, commit)
- validationRef = ""
- }
- }
- check, err = s.Store.AddCheck(ctx, check)
- if err != nil {
- return AcceptResult{}, err
- }
- recordedCheck = &check
- if check.ExitCode != 0 {
- return AcceptResult{}, &CheckFailedError{Check: check}
- }
- }
-
- var rootBase State
- if materialize {
- rootBase, err = s.prepareRootMaterialization(ctx, finalTree)
- if err != nil {
- return AcceptResult{}, err
- }
- }
-
- parents := canonicalizeParents([]Parent{
- {StateID: current.ID, Role: "canonical_parent", Order: 0},
- {StateID: proposal.ID, Role: "proposal_parent", Order: 1},
- })
- accepted := State{
- ID: acceptedID,
- Kind: StateAccepted,
- TaskID: proposal.TaskID,
- AttemptID: proposal.AttemptID,
- CanonicalAnchorID: current.ID,
- SourceTree: finalTree,
- GitCommit: commit,
- Summary: proposal.Summary,
- Agent: proposal.Agent,
- CreatedAt: time.Now().UTC(),
- Parents: parents,
- }
- accepted.Digest, err = digestState(accepted, parents)
- if err != nil {
- return AcceptResult{}, err
- }
- if err := s.pinState(ctx, accepted); err != nil {
- return AcceptResult{}, err
- }
- accepted, err = s.Store.CASAccept(ctx, current.ID, accepted, parents)
- if err != nil {
- return AcceptResult{}, mapHeadError(err)
- }
- if validationRef != "" {
- _ = s.Repo.DeleteRef(ctx, "refs/hop/"+validationRef, commit)
- validationRef = ""
- }
- var warnings []string
- if err := s.Repo.UpdateHiddenRef(ctx, acceptedRef, commit); err != nil {
- warnings = append(warnings, "accepted state is durable in SQLite but refs/hop/accepted needs repair: "+err.Error())
- }
- result := AcceptResult{
- State: accepted,
- ProposalPaths: proposalPaths,
- CurrentPaths: currentPaths,
- Check: recordedCheck,
- Warnings: warnings,
- }
- if materialize {
- if err := s.Repo.MaterializeTree(ctx, rootBase.SourceTree, accepted.SourceTree); err != nil {
- return result, &CommittedStateError{State: accepted, Err: fmt.Errorf("visible root %s was not synchronized: %w", s.Root, err)}
- }
- if err := s.Store.CASMaterializedHead(ctx, rootBase.ID, accepted.ID); err != nil {
- return result, &CommittedStateError{State: accepted, Err: fmt.Errorf("record visible root synchronization: %w", err)}
- }
- result.MaterializedRoot = s.Root
- }
- s.attachAutomaticPush(ctx, &result)
- 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()
- pushed, configured, err := s.Repo.PushAccepted(pushCtx, result.State.GitCommit)
- if err != nil {
- message, _ := RedactPromptSecrets(err.Error())
- result.Warnings = append(result.Warnings, "accepted state is local, but automatic push failed: "+message)
- return
- }
- if configured {
- result.RemotePush = &pushed
- }
-}
-
-// Sync projects the current accepted tree into a visible root that still
-// matches its durable materialized head. It is useful after controller-only accepts or
-// when upgrading a project created before automatic materialization existed.
-func (s *Service) Sync(ctx context.Context) (SyncResult, error) {
- release, err := acquireProjectLock(ctx, s.Root, "accept")
- if err != nil {
- return SyncResult{}, err
- }
- defer release()
- return s.syncLocked(ctx)
-}
-
-// Refresh prepares an agent-editable three-way conflict tree in the original
-// attempt workspace. It appends an internal reconciliation prompt so the agent
-// can resolve genuine conflicts without asking the user to coordinate them.
-func (s *Service) Refresh(ctx context.Context, proposalID string) (RefreshResult, error) {
- release, err := acquireProjectLock(ctx, s.Root, "accept")
- if err != nil {
- return RefreshResult{}, err
- }
- defer release()
-
- proposal, err := s.Store.GetState(ctx, proposalID)
- if err != nil {
- return RefreshResult{}, err
- }
- if proposal.Kind != StateProposal {
- return RefreshResult{}, fmt.Errorf("state %s is %s, not a proposal", proposal.ID, proposal.Kind)
- }
- sourceAttempt, err := s.Store.GetAttempt(ctx, proposal.AttemptID)
- if err != nil {
- return RefreshResult{}, err
- }
- if sourceAttempt.HeadStateID != proposal.ID {
- return RefreshResult{}, &HeadChangedError{
- Scope: "attempt", Expected: proposal.ID, Actual: sourceAttempt.HeadStateID,
- }
- }
- task, err := s.Store.GetTask(ctx, proposal.TaskID)
- if err != nil {
- return RefreshResult{}, err
- }
- current, err := s.Store.AcceptedHead(ctx)
- if err != nil {
- return RefreshResult{}, err
- }
- if existing, exists, err := s.Store.ReconciliationPrompt(ctx, proposal.ID, current.ID); err != nil {
- return RefreshResult{}, err
- } else if exists {
- if err := s.Store.RetargetAgentSessions(ctx, sourceAttempt.Agent, sourceAttempt.ID, proposal.ID, existing.ID); err != nil {
- return RefreshResult{}, err
- }
- return s.reconciliationResult(ctx, existing, task, proposal, current, true)
- }
- baseStateID := proposal.CanonicalAnchorID
- if baseStateID == "" {
- baseStateID = sourceAttempt.BaseStateID
- }
- base, err := s.Store.GetState(ctx, baseStateID)
- if err != nil {
- return RefreshResult{}, err
- }
- conflictTree, conflicts, err := s.Repo.ComposeTrees(ctx, base.GitCommit, current.GitCommit, proposal.GitCommit)
- if err != nil {
- return RefreshResult{}, err
- }
- if len(conflicts) == 0 {
- return RefreshResult{}, fmt.Errorf("proposal %s now merges cleanly; retry hop land", proposal.ID)
- }
- commit, err := s.Repo.CommitTree(ctx, conflictTree, []string{current.GitCommit, proposal.GitCommit},
- fmt.Sprintf("Reconcile %s against %s\n", proposal.ID, current.ID))
- if err != nil {
- return RefreshResult{}, err
- }
- summary, err := encodeReconciliationConflicts(conflicts)
- if err != nil {
- return RefreshResult{}, err
- }
- instruction := fmt.Sprintf(
- "Resolve proposal %s (%s) against accepted state %s (%s). Preserve both compatible intents. Inspect both input states for structural, delete/rename, mode, symlink, or binary conflicts that may have no text markers; resolve every conflict intentionally, remove all merge markers, run hop check, propose the result, and land it without asking the user to coordinate the merge. Conflict candidates: %s",
- proposal.ID, proposal.Summary, current.ID, current.Summary, strings.Join(conflicts, ", "))
- instruction, _ = RedactPromptSecrets(instruction)
- attemptID := newID("at")
- workspace := filepath.Join(s.Root, ".hop", "workspaces", attemptID)
- reconciliationAttempt := Attempt{
- ID: attemptID,
- TaskID: proposal.TaskID,
- Agent: sourceAttempt.Agent,
- Workspace: workspace,
- BaseStateID: current.ID,
- Status: "reconciling",
- CreatedAt: time.Now().UTC(),
- }
- parents := canonicalizeParents([]Parent{
- {StateID: proposal.ID, Role: "run_parent", Order: 0},
- {StateID: current.ID, Role: "canonical_anchor", Order: 1},
- {StateID: proposal.ID, Role: "reconciliation_source", Order: 2},
- })
- prompt := State{
- ID: newID("p"),
- Kind: StatePrompt,
- TaskID: proposal.TaskID,
- AttemptID: reconciliationAttempt.ID,
- CanonicalAnchorID: current.ID,
- SourceTree: conflictTree,
- GitCommit: commit,
- Prompt: instruction,
- Summary: summary,
- Agent: reconciliationAttempt.Agent,
- CreatedAt: time.Now().UTC(),
- Parents: parents,
- }
- prompt.Digest, err = digestState(prompt, parents)
- if err != nil {
- return RefreshResult{}, err
- }
- if err := s.pinState(ctx, prompt); err != nil {
- return RefreshResult{}, err
- }
- promptPinned := true
- defer func() {
- if promptPinned {
- _ = s.Repo.DeleteRef(context.Background(), "refs/hop/states/"+prompt.ID, prompt.GitCommit)
- }
- }()
- if err := os.MkdirAll(filepath.Dir(workspace), 0o755); err != nil {
- return RefreshResult{}, fmt.Errorf("create reconciliation workspace directory: %w", err)
- }
- if _, err := s.Repo.AddDetachedWorktree(ctx, workspace, prompt.GitCommit); err != nil {
- _ = s.Repo.RemoveWorktree(context.Background(), workspace, true)
- return RefreshResult{}, fmt.Errorf("create reconciliation workspace: %w", err)
- }
- workspaceInstalled := true
- defer func() {
- if workspaceInstalled {
- _ = s.Repo.RemoveWorktree(context.Background(), workspace, true)
- }
- }()
- reconciliationAttempt, prompt, err = s.Store.CreateAttemptPrompt(
- ctx, reconciliationAttempt, prompt, parents, sourceAttempt.ID, proposal.ID,
- )
- if err != nil {
- return RefreshResult{}, err
- }
- workspaceInstalled = false
- promptPinned = false
- task.Status = "reconciling"
- return s.reconciliationResult(ctx, prompt, task, proposal, current, false)
-}
-
-func (s *Service) reconciliationResult(
- ctx context.Context,
- prompt State,
- task Task,
- proposal State,
- current State,
- reused bool,
-) (RefreshResult, error) {
- conflicts, ok := decodeReconciliationConflicts(prompt.Summary)
- if !ok || len(conflicts) == 0 {
- return RefreshResult{}, fmt.Errorf("reconciliation prompt %s has no conflict metadata", prompt.ID)
- }
- attempt, err := s.Store.GetAttempt(ctx, prompt.AttemptID)
- if err != nil {
- return RefreshResult{}, err
- }
- if _, err := OpenRepository(attempt.Workspace); err != nil {
- return RefreshResult{}, fmt.Errorf("open prepared reconciliation workspace: %w", err)
- }
- task, err = s.Store.GetTask(ctx, task.ID)
- if err != nil {
- return RefreshResult{}, err
- }
- return RefreshResult{
- Prompt: prompt,
- Task: task,
- Attempt: attempt,
- Proposal: proposal,
- AcceptedHead: current,
- Workspace: attempt.Workspace,
- Deliver: s.deliveryEnvironment(prompt, attempt),
- ConflictTree: prompt.SourceTree,
- Conflicts: conflicts,
- Reused: reused,
- }, nil
-}
-
-func encodeReconciliationConflicts(paths []string) (string, error) {
- encoded, err := json.Marshal(paths)
- if err != nil {
- return "", fmt.Errorf("encode reconciliation conflicts: %w", err)
- }
- return reconciliationSummaryPrefix + string(encoded), nil
-}
-
-func decodeReconciliationConflicts(summary string) ([]string, bool) {
- if !strings.HasPrefix(summary, reconciliationSummaryPrefix) {
- return nil, false
- }
- var paths []string
- if err := json.Unmarshal([]byte(strings.TrimPrefix(summary, reconciliationSummaryPrefix)), &paths); err != nil {
- return nil, false
- }
- return paths, true
-}
-
-func unresolvedReconciliationMarkers(ctx context.Context, repo *Repository, tree string, conflicts []string) ([]string, error) {
- args := []string{
- "grep", "-z", "-l", "-I",
- "-e", "^<<<<<<< ",
- "-e", "^>>>>>>> ",
- tree, "--",
- }
- args = append(args, conflicts...)
- output, err := repo.run(ctx, []string{"GIT_LITERAL_PATHSPECS=1", "GIT_OPTIONAL_LOCKS=0"}, nil, args...)
- if err != nil {
- if gitExitCode(err) == 1 {
- return nil, nil
- }
- return nil, fmt.Errorf("inspect reconciliation markers: %w", err)
- }
- unresolved := splitNull([]byte(output))
- sort.Strings(unresolved)
- return unresolved, nil
-}
-
-func (s *Service) syncLocked(ctx context.Context) (SyncResult, error) {
- current, err := s.Store.AcceptedHead(ctx)
- if err != nil {
- return SyncResult{}, err
- }
- rootBase, err := s.prepareRootMaterialization(ctx, current.SourceTree)
- if err != nil {
- return SyncResult{}, err
- }
- changed := rootBase.SourceTree != current.SourceTree
- if err := s.Repo.MaterializeTree(ctx, rootBase.SourceTree, current.SourceTree); err != nil {
- return SyncResult{}, err
- }
- if err := s.Store.CASMaterializedHead(ctx, rootBase.ID, current.ID); err != nil {
- return SyncResult{}, fmt.Errorf("record visible root synchronization: %w", err)
- }
- return SyncResult{
- State: current,
- Root: s.Root,
- FromState: rootBase.ID,
- Changed: changed,
- }, nil
-}
-
-func (s *Service) prepareRootMaterialization(ctx context.Context, targetTree string) (State, error) {
- rootState, matches, err := s.visibleRootState(ctx)
- if err != nil {
- return State{}, err
- }
- if !matches {
- materialized, materializedErr := s.Store.MaterializedHead(ctx)
- if materializedErr != nil {
- return State{}, materializedErr
- }
- actual, snapshotErr := s.Repo.WorktreeTree(ctx, materialized.SourceTree)
- if snapshotErr != nil {
- return State{}, snapshotErr
- }
- paths, pathErr := s.Repo.ChangedPaths(ctx, materialized.SourceTree, actual)
- if pathErr != nil {
- return State{}, pathErr
- }
- if len(paths) == 0 {
- paths = []string{"."}
- }
- return State{}, &RootConflictError{Paths: paths}
- }
- if err := s.Repo.CheckIndexSafe(ctx, rootState.SourceTree); err != nil {
- return State{}, err
- }
- materialized, err := s.Store.MaterializedHead(ctx)
- if err != nil {
- return State{}, err
- }
- if rootState.ID != materialized.ID {
- if err := s.Store.CASMaterializedHead(ctx, materialized.ID, rootState.ID); err != nil {
- return State{}, fmt.Errorf("repair visible root projection marker: %w", err)
- }
- }
- conflicts, err := s.Repo.MaterializationConflicts(ctx, rootState.SourceTree, targetTree)
- if err != nil {
- return State{}, err
- }
- if len(conflicts) > 0 {
- return State{}, &RootConflictError{
- Paths: conflicts,
- Reason: "visible project root contains ignored or untracked paths that landing would overwrite",
- }
- }
- return rootState, nil
-}
-
-func (s *Service) visibleRootState(ctx context.Context) (State, bool, error) {
- materialized, err := s.Store.MaterializedHead(ctx)
- if err != nil {
- return State{}, false, err
- }
- actualTree, err := s.Repo.WorktreeTree(ctx, materialized.SourceTree)
- if err != nil {
- return State{}, false, err
- }
- if actualTree == materialized.SourceTree {
- return materialized, true, nil
- }
- accepted, err := s.Store.AcceptedHead(ctx)
- if err != nil {
- return State{}, false, err
- }
- if accepted.ID == materialized.ID {
- return State{}, false, nil
- }
- actualTree, err = s.Repo.WorktreeTree(ctx, accepted.SourceTree)
- if err != nil {
- return State{}, false, err
- }
- if actualTree == accepted.SourceTree {
- return accepted, true, nil
- }
- return State{}, false, nil
-}
-
-func (s *Service) Undo(ctx context.Context) (State, error) {
- release, err := acquireProjectLock(ctx, s.Root, "accept")
- if err != nil {
- return State{}, err
- }
- defer release()
- current, err := s.Store.AcceptedHead(ctx)
- if err != nil {
- return State{}, err
- }
- canonicalParent, err := s.Store.ParentByRole(ctx, current.ID, "canonical_parent")
- if err != nil {
- if errors.Is(err, ErrNotFound) {
- return State{}, fmt.Errorf("initial accepted state cannot be undone")
- }
- return State{}, err
- }
- previous, err := s.Store.GetState(ctx, canonicalParent.StateID)
- if err != nil {
- return State{}, err
- }
- rootBase, err := s.prepareRootMaterialization(ctx, previous.SourceTree)
- if err != nil {
- return State{}, err
- }
- undoID := newID("a")
- commit, err := s.Repo.CommitTree(ctx, previous.SourceTree, []string{current.GitCommit}, fmt.Sprintf(
- "Undo %s\n\nHop-State: %s\nHop-Reverts: %s\n", current.ID, undoID, current.ID))
- if err != nil {
- return State{}, err
- }
- parents := canonicalizeParents([]Parent{
- {StateID: current.ID, Role: "canonical_parent", Order: 0},
- {StateID: current.ID, Role: "reverts", Order: 1},
- {StateID: previous.ID, Role: "restores", Order: 2},
- })
- undo := State{
- ID: undoID,
- Kind: StateAccepted,
- CanonicalAnchorID: current.ID,
- SourceTree: previous.SourceTree,
- GitCommit: commit,
- Summary: "Undo " + current.ID,
- Agent: "hop",
- CreatedAt: time.Now().UTC(),
- Parents: parents,
- }
- undo.Digest, err = digestState(undo, parents)
- if err != nil {
- return State{}, err
- }
- if err := s.pinState(ctx, undo); err != nil {
- return State{}, err
- }
- undo, err = s.Store.CASAccept(ctx, current.ID, undo, parents)
- if err != nil {
- return State{}, mapHeadError(err)
- }
- var postCommitErrors []error
- if err := s.Repo.UpdateHiddenRef(ctx, acceptedRef, undo.GitCommit); err != nil {
- postCommitErrors = append(postCommitErrors, fmt.Errorf("repair refs/hop/accepted: %w", err))
- }
- if err := s.Repo.MaterializeTree(ctx, rootBase.SourceTree, undo.SourceTree); err != nil {
- postCommitErrors = append(postCommitErrors, fmt.Errorf("visible root %s was not synchronized: %w", s.Root, err))
- } else if err := s.Store.CASMaterializedHead(ctx, rootBase.ID, undo.ID); err != nil {
- postCommitErrors = append(postCommitErrors, fmt.Errorf("record visible root synchronization: %w", err))
- }
- if len(postCommitErrors) > 0 {
- return undo, &CommittedStateError{State: undo, Err: errors.Join(postCommitErrors...)}
- }
- return undo, nil
-}
-
-// recordValidationFailure turns a failed final-tree check into an immutable
-// descendant state when the proposal is still the attempt head. If a follow-up
-// raced with validation, the caller retains the validation ref and tree-bound
-// evidence without rewriting the newer attempt lineage.
-func (s *Service) recordValidationFailure(
- ctx context.Context,
- proposal State,
- current State,
- commit string,
- tree string,
- command []string,
-) (State, bool) {
- attempt, err := s.Store.GetAttempt(ctx, proposal.AttemptID)
- if err != nil || attempt.HeadStateID != proposal.ID {
- return State{}, false
- }
- parents := canonicalizeParents([]Parent{
- {StateID: proposal.ID, Role: "run_parent", Order: 0},
- {StateID: current.ID, Role: "integration_parent", Order: 1},
- })
- storedCommand, _ := redactSecretStrings(command)
- failed := State{
- ID: newID("f"),
- Kind: StateFailed,
- TaskID: proposal.TaskID,
- AttemptID: proposal.AttemptID,
- CanonicalAnchorID: current.ID,
- SourceTree: tree,
- GitCommit: commit,
- Summary: "Final validation failed: " + shellQuote(storedCommand),
- Agent: "hop",
- CreatedAt: time.Now().UTC(),
- Parents: parents,
- }
- failed.Digest, err = digestState(failed, parents)
- if err != nil {
- return State{}, false
- }
- if err := s.pinState(ctx, failed); err != nil {
- return State{}, false
- }
- failed, err = s.Store.AppendState(ctx, failed, parents, proposal.ID)
- if err != nil {
- return State{}, false
- }
- _ = s.Store.UpdateAttemptStatus(ctx, proposal.AttemptID, "changes_requested")
- _ = s.Store.UpdateTaskStatus(ctx, proposal.TaskID, "changes_requested")
- return failed, true
-}
-
-func (s *Service) State(ctx context.Context, id string) (State, error) {
- return s.Store.GetState(ctx, id)
-}
-
-func (s *Service) EnvironmentForState(ctx context.Context, id string) (EnvironmentResult, error) {
- state, err := s.Store.GetState(ctx, id)
- if err != nil {
- return EnvironmentResult{}, err
- }
- if state.AttemptID == "" {
- return EnvironmentResult{}, fmt.Errorf("state %s does not belong to an agent attempt", id)
- }
- attempt, err := s.Store.GetAttempt(ctx, state.AttemptID)
- if err != nil {
- return EnvironmentResult{}, err
- }
- return EnvironmentResult{
- State: state,
- Attempt: attempt,
- Workspace: attempt.Workspace,
- Variables: map[string]string{
- "HOP_ROOT": s.Root,
- "HOP_STATE_ID": state.ID,
- "HOP_TASK_ID": state.TaskID,
- "HOP_ATTEMPT_ID": attempt.ID,
- "HOP_WORKSPACE": attempt.Workspace,
- },
- }, nil
-}
-
-func (s *Service) Graph(ctx context.Context) ([]GraphRow, error) {
- return s.Store.Graph(ctx, "")
-}
-
-func (s *Service) History(ctx context.Context) ([]State, error) {
- return s.Store.AcceptedHistory(ctx, 1000)
-}
-
-func (s *Service) Status(ctx context.Context) (Status, error) {
- status, err := s.Store.Status(ctx)
- if err != nil {
- return Status{}, err
- }
- rootState, matches, err := s.visibleRootState(ctx)
- if err != nil {
- return Status{}, err
- }
- if !matches {
- status.RootStatus = "diverged"
- return status, nil
- }
- if err := s.Repo.CheckIndexSafe(ctx, rootState.SourceTree); err != nil {
- var conflict *RootConflictError
- if errors.As(err, &conflict) {
- status.RootStatus = "diverged"
- return status, nil
- }
- return Status{}, err
- }
- status.RootStateID = rootState.ID
- if rootState.ID == status.AcceptedHead.ID {
- status.RootStatus = "synchronized"
- } else {
- status.RootStatus = "stale"
- }
- return status, nil
-}
-
-func (s *Service) Diff(ctx context.Context, stateID string) (string, error) {
- state, err := s.Store.GetState(ctx, stateID)
- if err != nil {
- return "", err
- }
- var base State
- if state.Kind == StateAccepted {
- parent, parentErr := s.Store.ParentByRole(ctx, state.ID, "canonical_parent")
- if errors.Is(parentErr, ErrNotFound) {
- return s.Repo.Diff(ctx, "", state.GitCommit)
- }
- if parentErr != nil {
- return "", parentErr
- }
- base, err = s.Store.GetState(ctx, parent.StateID)
- } else if state.AttemptID != "" {
- attempt, attemptErr := s.Store.GetAttempt(ctx, state.AttemptID)
- if attemptErr != nil {
- return "", attemptErr
- }
- base, err = s.Store.GetState(ctx, attempt.BaseStateID)
- } else {
- return s.Repo.Diff(ctx, "", state.GitCommit)
- }
- if err != nil {
- return "", err
- }
- return s.Repo.Diff(ctx, base.GitCommit, state.GitCommit)
-}
-
-func (s *Service) Doctor(ctx context.Context, repair bool) (DoctorReport, error) {
- if repair {
- if os.Getenv("HOP_ACCEPTANCE_LOCK_HELD") == "1" {
- return DoctorReport{}, fmt.Errorf("doctor --repair cannot run inside a final-state validation command")
- }
- release, err := acquireProjectLock(ctx, s.Root, "accept")
- if err != nil {
- return DoctorReport{}, err
- }
- defer release()
- }
- head, err := s.Store.AcceptedHead(ctx)
- if err != nil {
- return DoctorReport{}, err
- }
- report := DoctorReport{OK: true, AcceptedState: head.ID, AcceptedCommit: head.GitCommit}
- if head.Kind != StateAccepted {
- report.OK = false
- report.Problems = append(report.Problems, fmt.Sprintf("accepted head %s has kind %s", head.ID, head.Kind))
- }
- if err := s.Repo.Verify(ctx); err != nil {
- report.OK = false
- report.Problems = append(report.Problems, "Git connectivity check failed: "+err.Error())
- }
- rows, err := s.Store.Graph(ctx, "")
- if err != nil {
- return DoctorReport{}, err
- }
- for _, row := range rows {
- state := row.State
- if err := s.Repo.VerifyObjects(ctx, state.GitCommit, state.SourceTree); err != nil {
- report.OK = false
- report.Problems = append(report.Problems, fmt.Sprintf("state %s references missing Git data: %v", state.ID, err))
- continue
- }
- commitTree, treeErr := s.Repo.resolveTree(ctx, state.GitCommit)
- if treeErr != nil || commitTree != state.SourceTree {
- report.OK = false
- if treeErr != nil {
- report.Problems = append(report.Problems, fmt.Sprintf("state %s commit tree cannot be resolved: %v", state.ID, treeErr))
- } else {
- report.Problems = append(report.Problems, fmt.Sprintf("state %s records tree %s but commit resolves to %s", state.ID, state.SourceTree, commitTree))
- }
- }
- expectedDigest, digestErr := digestState(state, row.Parents)
- if digestErr != nil || expectedDigest != state.Digest {
- report.OK = false
- if digestErr != nil {
- report.Problems = append(report.Problems, fmt.Sprintf("state %s digest cannot be recomputed: %v", state.ID, digestErr))
- } else {
- report.Problems = append(report.Problems, fmt.Sprintf("state %s digest mismatch", state.ID))
- }
- }
- if problem := stateParentProblem(state, row.Parents); problem != "" {
- report.OK = false
- report.Problems = append(report.Problems, problem)
- }
- refName := "states/" + state.ID
- refCommit, exists, readErr := s.Repo.ReadHiddenRef(ctx, refName)
- if readErr != nil {
- return DoctorReport{}, readErr
- }
- if !exists || refCommit != state.GitCommit {
- report.OK = false
- report.Problems = append(report.Problems, fmt.Sprintf("%s does not pin state %s", refName, state.ID))
- if repair {
- if err := s.Repo.UpdateHiddenRef(ctx, refName, state.GitCommit); err != nil {
- return DoctorReport{}, err
- }
- report.Repaired = true
- }
- }
- }
- refCommit, exists, err := s.Repo.ReadHiddenRef(ctx, acceptedRef)
- if err != nil {
- return DoctorReport{}, err
- }
- if exists {
- report.RefCommit = refCommit
- }
- if !exists || refCommit != head.GitCommit {
- report.OK = false
- report.Problems = append(report.Problems, "refs/hop/accepted does not match the SQLite accepted head")
- if repair {
- if err := s.Repo.UpdateHiddenRef(ctx, acceptedRef, head.GitCommit); err != nil {
- return DoctorReport{}, err
- }
- report.RefCommit = head.GitCommit
- report.Repaired = true
- }
- }
- if repair && report.Repaired {
- verified, verifyErr := s.Doctor(ctx, false)
- if verifyErr != nil {
- return DoctorReport{}, verifyErr
- }
- verified.Repaired = true
- return verified, nil
- }
- return report, nil
-}
-
-func stateParentProblem(state State, parents []Parent) string {
- hasRole := func(role string) bool {
- for _, parent := range parents {
- if parent.Role == role {
- return true
- }
- }
- return false
- }
- switch state.Kind {
- case StatePrompt:
- if state.TaskID == "" || state.AttemptID == "" || !hasRole("run_parent") || !hasRole("canonical_anchor") {
- return fmt.Sprintf("prompt state %s is missing task, attempt, run-parent, or canonical-anchor provenance", state.ID)
- }
- case StateCheckpoint, StateProposal, StateFailed, StateCancelled:
- if state.TaskID == "" || state.AttemptID == "" || !hasRole("run_parent") {
- return fmt.Sprintf("%s state %s is missing task, attempt, or run-parent provenance", state.Kind, state.ID)
- }
- case StateAccepted:
- isInitial := state.CanonicalAnchorID == "" && len(parents) == 0
- if !isInitial && !hasRole("canonical_parent") {
- return fmt.Sprintf("accepted state %s is missing its canonical parent", state.ID)
- }
- }
- return ""
-}
-
-func (s *Service) pinState(ctx context.Context, state State) error {
- return s.Repo.UpdateHiddenRef(ctx, "states/"+state.ID, state.GitCommit)
-}
-
-// reconcileDerivedRefs repairs the small crash window between SQLite commits
-// and derived Git-ref updates. SQLite is authoritative, but every referenced
-// commit is pinned before normal operation resumes so Git GC cannot discard it.
-func (s *Service) reconcileDerivedRefs(ctx context.Context, reconcileAccepted bool) error {
- rows, err := s.Store.Graph(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 err := s.Repo.UpdateHiddenRef(ctx, name, state.GitCommit); err != nil {
- return fmt.Errorf("repair Git pin for state %s: %w", state.ID, err)
- }
- }
- }
- if !reconcileAccepted {
- return nil
- }
- release, err := acquireProjectLock(ctx, s.Root, "accept")
- if err != nil {
- return err
- }
- defer release()
- head, err := s.Store.AcceptedHead(ctx)
- if err != nil {
- return err
- }
- commit, exists, err := s.Repo.ReadHiddenRef(ctx, acceptedRef)
- if err != nil {
- return err
- }
- if !exists || commit != head.GitCommit {
- if err := s.Repo.UpdateHiddenRef(ctx, acceptedRef, head.GitCommit); err != nil {
- return fmt.Errorf("repair accepted Git ref: %w", err)
- }
- }
- return nil
-}
-
-func (s *Service) deliveryEnvironment(state State, attempt Attempt) []string {
- return []string{
- "HOP_ROOT=" + s.Root,
- "HOP_STATE_ID=" + state.ID,
- "HOP_TASK_ID=" + state.TaskID,
- "HOP_ATTEMPT_ID=" + attempt.ID,
- "HOP_WORKSPACE=" + attempt.Workspace,
- }
-}
-
-func promptTitle(prompt string) string {
- prompt = strings.TrimSpace(prompt)
- if newline := strings.IndexByte(prompt, '\n'); newline >= 0 {
- prompt = prompt[:newline]
- }
- if len(prompt) > 100 {
- prompt = prompt[:97] + "..."
- }
- return prompt
-}
-
-func intersectPaths(left, right []string) []string {
- rightSet := make(map[string]struct{}, len(right))
- for _, path := range right {
- rightSet[path] = struct{}{}
- }
- var intersection []string
- for _, path := range left {
- if _, ok := rightSet[path]; ok {
- intersection = append(intersection, path)
- }
- }
- sort.Strings(intersection)
- return intersection
-}
-
-func mapHeadError(err error) error {
- var changed *HeadChangedError
- if errors.As(err, &changed) {
- return &StaleHeadError{Expected: changed.Expected, Actual: changed.Actual}
- }
- return err
-}
diff --git a/internal/hop/service_smoke_test.go b/internal/hop/service_smoke_test.go
deleted file mode 100644
index e3bffe5..0000000
--- a/internal/hop/service_smoke_test.go
+++ /dev/null
@@ -1,2052 +0,0 @@
-package hop
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "sync"
- "testing"
- "time"
-)
-
-func TestInitPreservesGitBranchIndexAndWorkingTree(t *testing.T) {
- ctx := context.Background()
- root := t.TempDir()
- runGitTest(t, root, "init", "--quiet")
- writeTestFile(t, filepath.Join(root, "tracked.txt"), "committed\n")
- runGitTest(t, root, "add", "tracked.txt")
- runGitTest(t, root, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--quiet", "-m", "initial")
-
- writeTestFile(t, filepath.Join(root, "tracked.txt"), "staged\n")
- runGitTest(t, root, "add", "tracked.txt")
- writeTestFile(t, filepath.Join(root, "tracked.txt"), "working\n")
- writeTestFile(t, filepath.Join(root, "untracked.txt"), "untracked\n")
-
- beforeHead := runGitTest(t, root, "rev-parse", "HEAD")
- beforeBranch := runGitTest(t, root, "symbolic-ref", "--short", "HEAD")
- beforeIndex := runGitTest(t, root, "diff", "--cached", "--binary")
- beforeStatus := runGitTest(t, root, "status", "--porcelain=v1")
-
- service, initial, err := InitProject(ctx, root)
- if err != nil {
- t.Fatal(err)
- }
- if got := runGitTest(t, root, "rev-parse", "HEAD"); got != beforeHead {
- t.Fatalf("HEAD moved from %s to %s", beforeHead, got)
- }
- if got := runGitTest(t, root, "symbolic-ref", "--short", "HEAD"); got != beforeBranch {
- t.Fatalf("branch changed from %s to %s", beforeBranch, got)
- }
- if got := runGitTest(t, root, "diff", "--cached", "--binary"); got != beforeIndex {
- t.Fatal("Hop init changed the user's index")
- }
- if got := runGitTest(t, root, "status", "--porcelain=v1"); got != beforeStatus {
- t.Fatalf("working status changed:\nwant %q\n got %q", beforeStatus, got)
- }
- assertTreeFiles(t, service, initial.GitCommit, map[string]string{
- "tracked.txt": "working\n",
- "untracked.txt": "untracked\n",
- })
- if err := service.Close(); err != nil {
- t.Fatal(err)
- }
-
- service, again, err := InitProject(ctx, root)
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = service.Close() })
- if again.ID != initial.ID {
- t.Fatalf("idempotent init created %s, want existing %s", again.ID, initial.ID)
- }
-}
-
-func TestInitRefusesAUserTrackedHopDirectory(t *testing.T) {
- root := t.TempDir()
- runGitTest(t, root, "init", "--quiet")
- 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") {
- 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" {
- t.Fatalf("tracked .hop content changed: %q, %v", string(contents), readErr)
- }
-}
-
-func TestInitAllowsTrackedPortableHopRecords(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)
- }
-}
-
-func TestConcurrentFirstBeginsInitializeExactlyOnce(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) })
-
- type result struct {
- code int
- stdout string
- stderr string
- }
- const workers = 8
- start := make(chan struct{})
- results := make(chan result, workers)
- var group sync.WaitGroup
- for index := 0; index < workers; index++ {
- group.Add(1)
- go func(index int) {
- defer group.Done()
- <-start
- var stdout, stderr bytes.Buffer
- code := RunCLI([]string{
- "begin", "--json", "--agent", fmt.Sprintf("agent-%d", index),
- "--session", fmt.Sprintf("session-%d", index), fmt.Sprintf("Prompt %d", index),
- }, &stdout, &stderr)
- results <- result{code: code, stdout: stdout.String(), stderr: stderr.String()}
- }(index)
- }
- close(start)
- group.Wait()
- close(results)
-
- var workspaces []string
- for result := range results {
- if result.code != 0 {
- t.Fatalf("concurrent first begin exited %d\nstdout: %s\nstderr: %s", result.code, result.stdout, result.stderr)
- }
- var response map[string]any
- if err := json.Unmarshal([]byte(result.stdout), &response); err != nil {
- t.Fatalf("decode concurrent begin output %q: %v", result.stdout, err)
- }
- data := objectField(t, response, "data")
- workspaces = append(workspaces, stringField(t, data, "workspace"))
- }
- for _, workspace := range workspaces {
- if info, err := os.Stat(workspace); err != nil || !info.IsDir() {
- t.Fatalf("concurrent begin workspace %q missing: %v", workspace, err)
- }
- }
-
- service, err := OpenProject(root)
- if err != nil {
- t.Fatal(err)
- }
- defer service.Close()
- graph, err := service.Store.Graph(context.Background(), "")
- if err != nil {
- t.Fatal(err)
- }
- initialAccepted := 0
- prompts := 0
- for _, row := range graph {
- if row.State.Kind == StateAccepted && row.State.TaskID == "" {
- initialAccepted++
- }
- if row.State.Kind == StatePrompt {
- prompts++
- }
- }
- if initialAccepted != 1 || prompts != workers {
- t.Fatalf("concurrent bootstrap created %d initial states and %d prompts, want 1 and %d", initialAccepted, prompts, workers)
- }
-}
-
-func TestConcurrentInitInExistingGitRepository(t *testing.T) {
- root := t.TempDir()
- runGitTest(t, root, "init", "--quiet")
- writeTestFile(t, filepath.Join(root, "base.txt"), "base\n")
-
- const workers = 8
- start := make(chan struct{})
- type result struct {
- stateID string
- err error
- }
- results := make(chan result, workers)
- var group sync.WaitGroup
- for range workers {
- group.Add(1)
- go func() {
- defer group.Done()
- <-start
- service, initial, err := InitProject(context.Background(), root)
- if service != nil {
- _ = service.Close()
- }
- results <- result{stateID: initial.ID, err: err}
- }()
- }
- close(start)
- group.Wait()
- close(results)
-
- initialID := ""
- for result := range results {
- if result.err != nil {
- t.Fatalf("concurrent existing-repository init: %v", result.err)
- }
- if initialID == "" {
- initialID = result.stateID
- } else if result.stateID != initialID {
- t.Fatalf("concurrent init returned initial states %s and %s", initialID, result.stateID)
- }
- }
- exclude, err := os.ReadFile(filepath.Join(root, ".git", "info", "exclude"))
- 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)
- }
-}
-
-func TestProposeExportsPortablePromptLedger(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")
- 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")
- 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) })
- 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" {
- t.Fatalf("unexpected portable prompt record: %#v", record)
- }
-}
-
-func TestOpenProjectWaitsForInitializationLock(t *testing.T) {
- service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
- root := service.Root
- if err := service.Close(); err != nil {
- t.Fatal(err)
- }
- release, err := acquireProjectLock(context.Background(), root, "init")
- if err != nil {
- t.Fatal(err)
- }
- opened := make(chan error, 1)
- go func() {
- project, openErr := OpenProject(root)
- if openErr == nil {
- openErr = project.Close()
- }
- opened <- openErr
- }()
- select {
- case err := <-opened:
- release()
- t.Fatalf("OpenProject returned before initialization lock release: %v", err)
- case <-time.After(100 * time.Millisecond):
- }
- release()
- select {
- case err := <-opened:
- if err != nil {
- t.Fatal(err)
- }
- case <-time.After(10 * time.Second):
- t.Fatal("OpenProject did not resume after initialization lock release")
- }
-}
-
-func TestCLIJSONWorkflow(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"})
- started := runCLIJSONTest(t, []string{"start", "--agent", "fake", "--json", "Add CLI file"})
- data := objectField(t, started, "data")
- prompt := objectField(t, data, "prompt")
- promptID := stringField(t, prompt, "id")
- workspace := stringField(t, data, "workspace")
- if promptID == "" || workspace == "" {
- t.Fatal("start JSON omitted prompt ID or workspace")
- }
- writeTestFile(t, filepath.Join(workspace, "cli.txt"), "cli\n")
-
- proposed := runCLIJSONTest(t, []string{"propose", "--summary", "CLI change", "--json", promptID})
- proposal := objectField(t, objectField(t, proposed, "data"), "proposal")
- proposalID := stringField(t, proposal, "id")
- accepted := runCLIJSONTest(t, []string{"land", proposalID, "--json", "--", "sh", "-c", "test -f cli.txt"})
- acceptedState := objectField(t, objectField(t, accepted, "data"), "state")
- if kind := stringField(t, acceptedState, "kind"); kind != string(StateAccepted) {
- t.Fatalf("landed kind = %q, want %q", kind, StateAccepted)
- }
- if contents, err := os.ReadFile(filepath.Join(root, "cli.txt")); err != nil || string(contents) != "cli\n" {
- t.Fatalf("visible root was not materialized: contents=%q err=%v", string(contents), err)
- }
- status := runCLIJSONTest(t, []string{"status", "--json"})
- statusData := objectField(t, status, "data")
- head := objectField(t, statusData, "accepted_head")
- if stringField(t, head, "id") != stringField(t, acceptedState, "id") {
- t.Fatal("status accepted head does not match landed state")
- }
- if stringField(t, statusData, "root_status") != "synchronized" {
- t.Fatalf("root status = %q", stringField(t, statusData, "root_status"))
- }
-}
-
-func TestCLIExportWritesPortablePromptRecords(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) != 1 {
- t.Fatalf("portable prompt records = %v, err=%v", entries, err)
- }
-}
-
-func TestCLIBeginAutoInitializesAndContinuesCodexSession(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) })
-
- firstPrompt := " Add a Desktop-safe file\nwithout changing this spacing. "
- started := runCLIJSONInputTest(t,
- []string{"begin", "--agent", "codex", "--session", "thread-test", "--heredoc", "--json"},
- firstPrompt+"\n")
- first := objectField(t, started, "data")
- if initialized, _ := first["initialized"].(bool); !initialized {
- t.Fatal("begin did not report automatic Hop initialization")
- }
- firstState := objectField(t, first, "prompt")
- if got := stringField(t, firstState, "prompt"); got != firstPrompt {
- t.Fatalf("heredoc prompt = %q, want %q", got, firstPrompt)
- }
- firstAttempt := objectField(t, first, "attempt")
- workspace := stringField(t, first, "workspace")
- writeTestFile(t, filepath.Join(workspace, "desktop.txt"), "first turn\n")
-
- followupPrompt := "Now preserve this final newline.\n"
- followed := runCLIJSONInputTest(t,
- []string{"begin", "--agent", "codex", "--session", "thread-test", "--stdin", "--json"},
- followupPrompt)
- second := objectField(t, followed, "data")
- if initialized, _ := second["initialized"].(bool); initialized {
- t.Fatal("follow-up begin unexpectedly reinitialized Hop")
- }
- secondState := objectField(t, second, "prompt")
- if got := stringField(t, secondState, "prompt"); got != followupPrompt {
- t.Fatalf("stdin prompt = %q, want %q", got, followupPrompt)
- }
- secondAttempt := objectField(t, second, "attempt")
- if stringField(t, secondAttempt, "id") != stringField(t, firstAttempt, "id") {
- t.Fatal("Codex session follow-up created a new attempt")
- }
- if stringField(t, second, "workspace") != workspace {
- t.Fatal("Codex session follow-up changed workspaces")
- }
- checkpoint := objectField(t, second, "checkpoint")
- if stringField(t, checkpoint, "kind") != string(StateCheckpoint) {
- t.Fatal("Codex session follow-up did not checkpoint prior effects")
- }
-
- service, err := OpenProject(root)
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = service.Close() })
- head, exists, err := service.Store.AgentSessionHead(context.Background(), "codex", "thread-test")
- if err != nil {
- t.Fatal(err)
- }
- if !exists || head != stringField(t, secondState, "id") {
- t.Fatalf("session head = %q, %v; want second prompt", head, exists)
- }
- assertTreeFiles(t, service, stringField(t, checkpoint, "git_commit"), map[string]string{
- "base.txt": "base\n",
- "desktop.txt": "first turn\n",
- })
-}
-
-func TestBeginRollsAcceptedReconciliationSessionToCurrentHead(t *testing.T) {
- ctx := context.Background()
- service, _ := newTestProject(t, map[string]string{"shared.txt": "color=base\n"})
-
- session, err := service.BeginPrompt(ctx, "Use blue", "", "codex", "thread-rollover")
- if err != nil {
- t.Fatal(err)
- }
- concurrent, err := service.CreatePrompt(ctx, "Use red", "", "other")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(session.Workspace, "shared.txt"), "color=blue\n")
- writeTestFile(t, filepath.Join(concurrent.Workspace, "shared.txt"), "color=red\n")
-
- sessionProposal, err := service.Propose(ctx, session.Prompt.ID, "Blue")
- if err != nil {
- t.Fatal(err)
- }
- concurrentProposal, err := service.Propose(ctx, concurrent.Prompt.ID, "Red")
- if err != nil {
- t.Fatal(err)
- }
- if _, err := service.Land(ctx, concurrentProposal.Proposal.ID, nil); err != nil {
- t.Fatal(err)
- }
- if _, err := service.Land(ctx, sessionProposal.Proposal.ID, nil); err == nil {
- t.Fatal("stale session proposal unexpectedly landed without reconciliation")
- } else {
- var conflict *ConflictError
- if !errors.As(err, &conflict) {
- t.Fatalf("land error = %v, want ConflictError", err)
- }
- }
-
- reconciliation, err := service.Refresh(ctx, sessionProposal.Proposal.ID)
- if err != nil {
- t.Fatal(err)
- }
- reconciliationHead, exists, err := service.Store.AgentSessionHead(ctx, "codex", "thread-rollover")
- if err != nil {
- t.Fatal(err)
- }
- if !exists || reconciliationHead != reconciliation.Prompt.ID {
- t.Fatalf("session head = %q, %v; want reconciliation prompt %s", reconciliationHead, exists, reconciliation.Prompt.ID)
- }
- writeTestFile(t, filepath.Join(reconciliation.Workspace, "shared.txt"), "color=red-blue\n")
- if _, err := service.RunCheck(ctx, reconciliation.Prompt.ID, []string{
- "sh", "-c", `test "$(cat shared.txt)" = "color=red-blue"`,
- }); err != nil {
- t.Fatal(err)
- }
- resolvedProposal, err := service.Propose(ctx, reconciliation.Prompt.ID, "Resolve red and blue")
- if err != nil {
- t.Fatal(err)
- }
- if _, err := service.Land(ctx, resolvedProposal.Proposal.ID, nil); err != nil {
- t.Fatal(err)
- }
- sourceAttempt, err := service.Store.GetAttempt(ctx, session.Attempt.ID)
- if err != nil {
- t.Fatal(err)
- }
- if sourceAttempt.Status != "completed" {
- t.Fatalf("source attempt status = %q, want completed", sourceAttempt.Status)
- }
-
- // Rollover must use the latest global accepted state, not merely this task's
- // accepted outcome.
- later, err := service.CreatePrompt(ctx, "Add a later accepted change", "", "other")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(later.Workspace, "later.txt"), "later\n")
- laterProposal, err := service.Propose(ctx, later.Prompt.ID, "Later accepted change")
- if err != nil {
- t.Fatal(err)
- }
- laterAccepted, err := service.Land(ctx, laterProposal.Proposal.ID, nil)
- if err != nil {
- t.Fatal(err)
- }
-
- next, err := service.BeginPrompt(ctx, "Next request", "", "codex", "thread-rollover")
- if err != nil {
- t.Fatal(err)
- }
- assertFreshRollover := func(label string, result PromptResult) {
- t.Helper()
- if result.Checkpoint != nil {
- t.Fatalf("%s continued accepted session with checkpoint %s", label, result.Checkpoint.ID)
- }
- if result.Attempt.ID == session.Attempt.ID || result.Attempt.ID == reconciliation.Attempt.ID {
- t.Fatalf("%s reused completed attempt %s", label, result.Attempt.ID)
- }
- if result.Task.ID == session.Task.ID {
- t.Fatalf("%s reopened accepted task %s", label, result.Task.ID)
- }
- if result.Attempt.BaseStateID != laterAccepted.State.ID {
- t.Fatalf("%s base = %s, want %s", label, result.Attempt.BaseStateID, laterAccepted.State.ID)
- }
- if result.Prompt.CanonicalAnchorID != laterAccepted.State.ID ||
- result.Prompt.SourceTree != laterAccepted.State.SourceTree ||
- result.Prompt.GitCommit != laterAccepted.State.GitCommit {
- t.Fatalf("%s prompt was not rooted at latest accepted head: %#v; accepted=%#v", label, result.Prompt, laterAccepted.State)
- }
- if contents, err := os.ReadFile(filepath.Join(result.Workspace, "shared.txt")); err != nil || string(contents) != "color=red-blue\n" {
- t.Fatalf("%s shared workspace = %q, err=%v", label, string(contents), err)
- }
- if contents, err := os.ReadFile(filepath.Join(result.Workspace, "later.txt")); err != nil || string(contents) != "later\n" {
- t.Fatalf("%s later workspace = %q, err=%v", label, string(contents), err)
- }
- }
- assertFreshRollover("normal reconciliation session", next)
- head, exists, err := service.Store.AgentSessionHead(ctx, "codex", "thread-rollover")
- if err != nil {
- t.Fatal(err)
- }
- if !exists || head != next.Prompt.ID {
- t.Fatalf("session head = %q, %v; want %s", head, exists, next.Prompt.ID)
- }
-
- // Older Hop builds could reactivate both records after acceptance. The
- // immutable accepted outcome, not these mutable statuses, must drive rollover.
- if err := service.Store.UpdateAttemptStatus(ctx, session.Attempt.ID, "active"); err != nil {
- t.Fatal(err)
- }
- if err := service.Store.UpdateTaskStatus(ctx, session.Task.ID, "active"); err != nil {
- t.Fatal(err)
- }
- if err := service.Store.SetAgentSessionHead(ctx, "codex", "legacy-thread-rollover", session.Prompt.ID); err != nil {
- t.Fatal(err)
- }
- legacyNext, err := service.BeginPrompt(ctx, "Legacy next request", "", "codex", "legacy-thread-rollover")
- if err != nil {
- t.Fatal(err)
- }
- assertFreshRollover("legacy session", legacyNext)
- sourceAttempt, err = service.Store.GetAttempt(ctx, session.Attempt.ID)
- if err != nil {
- t.Fatal(err)
- }
- if sourceAttempt.Status == "active" {
- t.Fatalf("source attempt %s was left active", sourceAttempt.ID)
- }
- head, exists, err = service.Store.AgentSessionHead(ctx, "codex", "legacy-thread-rollover")
- if err != nil {
- t.Fatal(err)
- }
- if !exists || head != legacyNext.Prompt.ID {
- t.Fatalf("legacy session head = %q, %v; want %s", head, exists, legacyNext.Prompt.ID)
- }
-}
-
-func TestLandRejectsProposalSupersededByFollowup(t *testing.T) {
- ctx := context.Background()
- service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"})
- started, err := service.BeginPrompt(ctx, "Create a change", "", "codex", "thread-stale-proposal")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(started.Workspace, "change.txt"), "first\n")
- proposal, err := service.Propose(ctx, started.Prompt.ID, "First change")
- if err != nil {
- t.Fatal(err)
- }
- followup, err := service.BeginPrompt(ctx, "Refine that change", "", "codex", "thread-stale-proposal")
- if err != nil {
- t.Fatal(err)
- }
- if followup.Attempt.ID != started.Attempt.ID || followup.Checkpoint == nil {
- t.Fatalf("follow-up did not advance the original attempt: %#v", followup)
- }
- if _, err := service.Land(ctx, proposal.Proposal.ID, nil); !errors.Is(err, ErrAttemptHeadChanged) {
- t.Fatalf("superseded proposal land error = %v, want ErrAttemptHeadChanged", err)
- }
- parents := canonicalizeParents([]Parent{
- {StateID: initial.ID, Role: "canonical_parent", Order: 0},
- {StateID: proposal.Proposal.ID, Role: "proposal_parent", Order: 1},
- })
- accepted := State{
- ID: newID("a"),
- Kind: StateAccepted,
- TaskID: proposal.Proposal.TaskID,
- AttemptID: proposal.Proposal.AttemptID,
- CanonicalAnchorID: initial.ID,
- SourceTree: proposal.Proposal.SourceTree,
- GitCommit: proposal.Proposal.GitCommit,
- Summary: proposal.Proposal.Summary,
- Agent: proposal.Proposal.Agent,
- CreatedAt: time.Now().UTC(),
- Parents: parents,
- }
- accepted.Digest, err = digestState(accepted, parents)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := service.Store.CASAccept(ctx, initial.ID, accepted, parents); !errors.Is(err, ErrAttemptHeadChanged) {
- t.Fatalf("transactional stale proposal error = %v, want ErrAttemptHeadChanged", err)
- }
- head, err := service.Store.AcceptedHead(ctx)
- if err != nil {
- t.Fatal(err)
- }
- if head.ID != initial.ID {
- t.Fatalf("superseded proposal advanced accepted head to %s", head.ID)
- }
-}
-
-func TestBeginKeepsUnfinishedStateCreatedAfterAcceptedOutcome(t *testing.T) {
- ctx := context.Background()
- service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
- started, err := service.BeginPrompt(ctx, "Land the first change", "", "codex", "thread-post-accept")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(started.Workspace, "landed.txt"), "landed\n")
- proposal, err := service.Propose(ctx, started.Prompt.ID, "Landed change")
- if err != nil {
- t.Fatal(err)
- }
- if _, err := service.Land(ctx, proposal.Proposal.ID, nil); err != nil {
- t.Fatal(err)
- }
-
- // Although agents must not mutate a frozen proposal, preserve any such
- // residual work rather than silently rolling it away.
- writeTestFile(t, filepath.Join(started.Workspace, "pending.txt"), "pending\n")
- continued, err := service.BeginPrompt(ctx, "Preserve the pending work", "", "codex", "thread-post-accept")
- if err != nil {
- t.Fatal(err)
- }
- if continued.Attempt.ID != started.Attempt.ID || continued.Checkpoint == nil {
- t.Fatalf("dirty accepted workspace was not preserved: %#v", continued)
- }
- assertTreeFiles(t, service, continued.Checkpoint.GitCommit, map[string]string{
- "base.txt": "base\n",
- "landed.txt": "landed\n",
- "pending.txt": "pending\n",
- })
- again, err := service.BeginPrompt(ctx, "Continue that pending work", "", "codex", "thread-post-accept")
- if err != nil {
- t.Fatal(err)
- }
- if again.Attempt.ID != started.Attempt.ID || again.Checkpoint == nil {
- t.Fatalf("post-acceptance prompt lineage was abandoned: %#v", again)
- }
-}
-
-func TestDoctorRejectsCommitTreeAndDigestMismatch(t *testing.T) {
- ctx := context.Background()
- service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"})
- emptyTree, err := service.Repo.EmptyTree(ctx)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := service.Store.db.ExecContext(ctx,
- `UPDATE states SET source_tree = ?, digest = 'tampered' WHERE id = ?`, emptyTree, initial.ID); err != nil {
- t.Fatal(err)
- }
- report, err := service.Doctor(ctx, false)
- if err != nil {
- t.Fatal(err)
- }
- if report.OK {
- t.Fatal("doctor approved a state whose commit tree and digest were tampered")
- }
- joined := strings.Join(report.Problems, "\n")
- if !strings.Contains(joined, "records tree") || !strings.Contains(joined, "digest mismatch") {
- t.Fatalf("doctor problems did not explain both mismatches: %s", joined)
- }
-}
-
-func TestCLILandConflictReturnsAutomaticReconciliation(t *testing.T) {
- t.Setenv("HOP_ROOT", "")
- ctx := context.Background()
- service, _ := newTestProject(t, map[string]string{"shared.txt": "base\n"})
- first, err := service.CreatePrompt(ctx, "First", "", "one")
- if err != nil {
- t.Fatal(err)
- }
- second, err := service.CreatePrompt(ctx, "Second", "", "two")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(first.Workspace, "shared.txt"), "first\n")
- writeTestFile(t, filepath.Join(second.Workspace, "shared.txt"), "second\n")
- firstProposal, err := service.Propose(ctx, first.Prompt.ID, "First")
- if err != nil {
- t.Fatal(err)
- }
- secondProposal, err := service.Propose(ctx, second.Prompt.ID, "Second")
- if err != nil {
- t.Fatal(err)
- }
- if _, err := service.Land(ctx, firstProposal.Proposal.ID, nil); err != nil {
- t.Fatal(err)
- }
- root := service.Root
- if err := service.Close(); err != nil {
- t.Fatal(err)
- }
- 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) })
-
- var stdout, stderr bytes.Buffer
- code := RunCLI([]string{"land", secondProposal.Proposal.ID, "--json"}, &stdout, &stderr)
- if code != 20 {
- t.Fatalf("land exit = %d, want 20\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String())
- }
- var response map[string]any
- if err := json.Unmarshal(stdout.Bytes(), &response); err != nil {
- t.Fatal(err)
- }
- if response["next_command"] == nil {
- t.Fatalf("conflict response omitted next command: %s", stdout.String())
- }
- reconciliation := objectField(t, response, "reconciliation")
- prompt := objectField(t, reconciliation, "prompt")
- if stringField(t, prompt, "id") == "" || stringField(t, reconciliation, "workspace") == "" {
- t.Fatalf("reconciliation response omitted prompt/workspace: %s", stdout.String())
- }
- if status := stringField(t, objectField(t, reconciliation, "task"), "status"); status != "reconciling" {
- t.Fatalf("reconciliation task status = %q, want reconciling", status)
- }
- if status := stringField(t, objectField(t, reconciliation, "attempt"), "status"); status != "reconciling" {
- t.Fatalf("reconciliation attempt status = %q, want reconciling", status)
- }
- conflicts, ok := reconciliation["conflicts"].([]any)
- if !ok || len(conflicts) != 1 || conflicts[0] != "shared.txt" {
- t.Fatalf("conflicts = %#v", reconciliation["conflicts"])
- }
- if contents, err := os.ReadFile(filepath.Join(root, "shared.txt")); err != nil || string(contents) != "first\n" {
- t.Fatalf("conflicted land changed visible root: %q, %v", string(contents), err)
- }
-}
-
-func TestCLIVersionUsesReleaseLinkerValue(t *testing.T) {
- previous := Version
- Version = "v1.2.3"
- t.Cleanup(func() { Version = previous })
-
- var stdout, stderr bytes.Buffer
- if code := RunCLI([]string{"version", "--json"}, &stdout, &stderr); code != 0 {
- t.Fatalf("version exited %d: %s", code, stderr.String())
- }
- var response map[string]any
- if err := json.Unmarshal(stdout.Bytes(), &response); err != nil {
- t.Fatal(err)
- }
- if got, _ := response["version"].(string); got != "1.2.3" {
- t.Fatalf("version = %q, want 1.2.3", got)
- }
-}
-
-func TestOpenProjectInsideFinalValidationDoesNotReacquireAcceptanceLock(t *testing.T) {
- ctx := context.Background()
- service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
- release, err := acquireProjectLock(ctx, service.Root, "accept")
- if err != nil {
- t.Fatal(err)
- }
- defer release()
- previous, hadPrevious := os.LookupEnv("HOP_ACCEPTANCE_LOCK_HELD")
- if err := os.Setenv("HOP_ACCEPTANCE_LOCK_HELD", "1"); err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() {
- if hadPrevious {
- _ = os.Setenv("HOP_ACCEPTANCE_LOCK_HELD", previous)
- } else {
- _ = os.Unsetenv("HOP_ACCEPTANCE_LOCK_HELD")
- }
- })
- opened := make(chan error, 1)
- go func() {
- nested, openErr := OpenProject(service.Root)
- if openErr == nil {
- openErr = nested.Close()
- }
- opened <- openErr
- }()
- select {
- case openErr := <-opened:
- if openErr != nil {
- t.Fatal(openErr)
- }
- case <-time.After(10 * time.Second):
- t.Fatal("OpenProject deadlocked by reacquiring the acceptance lock")
- }
-}
-
-func TestPromptFollowupAndProposalAreImmutable(t *testing.T) {
- ctx := context.Background()
- service, initial := newTestProject(t, map[string]string{"app.txt": "initial\n"})
-
- exactPrompt := " Change the app\nwithout normalizing this text. "
- started, err := service.CreatePrompt(ctx, exactPrompt, "", "test-agent")
- if err != nil {
- t.Fatal(err)
- }
- if started.Prompt.SourceTree != initial.SourceTree {
- t.Fatalf("prompt tree = %s, want accepted tree %s", started.Prompt.SourceTree, initial.SourceTree)
- }
- if started.Prompt.Prompt != exactPrompt {
- t.Fatalf("stored prompt = %q, want exact %q", started.Prompt.Prompt, exactPrompt)
- }
- writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "first change\n")
-
- followup, err := service.CreatePrompt(ctx, "Use the other approach", started.Prompt.ID, "")
- if err != nil {
- t.Fatal(err)
- }
- if followup.Checkpoint == nil {
- t.Fatal("follow-up did not create a checkpoint")
- }
- if followup.Prompt.SourceTree != followup.Checkpoint.SourceTree {
- t.Fatal("follow-up prompt did not preserve the checkpoint tree")
- }
- if followup.Prompt.SourceTree == initial.SourceTree {
- t.Fatal("checkpoint failed to capture workspace edits")
- }
- parent, err := service.Store.ParentByRole(ctx, followup.Prompt.ID, "run_parent")
- if err != nil {
- t.Fatal(err)
- }
- if parent.StateID != followup.Checkpoint.ID {
- t.Fatalf("follow-up parent = %s, want checkpoint %s", parent.StateID, followup.Checkpoint.ID)
- }
-
- writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "proposed\n")
- proposed, err := service.Propose(ctx, followup.Prompt.ID, "Changed the app")
- if err != nil {
- t.Fatal(err)
- }
- frozenTree := proposed.Proposal.SourceTree
- writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "changed after proposal\n")
- stored, err := service.State(ctx, proposed.Proposal.ID)
- if err != nil {
- t.Fatal(err)
- }
- if stored.SourceTree != frozenTree {
- t.Fatal("proposal tree changed after later workspace edits")
- }
-}
-
-func TestCheckRunsAgainstTheRecordedCheckpointTree(t *testing.T) {
- ctx := context.Background()
- service, _ := newTestProject(t, map[string]string{"app.txt": "initial\n"})
- started, err := service.CreatePrompt(ctx, "Check exact state", "", "test")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "checkpointed\n")
- signal := filepath.Join(t.TempDir(), "check-started")
- type outcome struct {
- check Check
- err error
- }
- result := make(chan outcome, 1)
- go func() {
- check, checkErr := service.RunCheck(ctx, started.Prompt.ID, []string{
- "sh", "-c", `touch "$1"; sleep 0.2; cat app.txt`, "hop-check", signal,
- })
- result <- outcome{check: check, err: checkErr}
- }()
- deadline := time.Now().Add(5 * time.Second)
- for {
- if _, err := os.Stat(signal); err == nil {
- break
- }
- if time.Now().After(deadline) {
- t.Fatal("validation command did not start")
- }
- time.Sleep(10 * time.Millisecond)
- }
- writeTestFile(t, filepath.Join(started.Workspace, "app.txt"), "raced edit\n")
- finished := <-result
- if finished.err != nil {
- t.Fatal(finished.err)
- }
- if finished.check.Output != "checkpointed\n" {
- t.Fatalf("check observed %q, want checkpointed tree", finished.check.Output)
- }
-}
-
-func TestDisjointProposalsLandAndUndoMovesForward(t *testing.T) {
- ctx := context.Background()
- service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"})
-
- first, err := service.CreatePrompt(ctx, "Add one", "", "agent-one")
- if err != nil {
- t.Fatal(err)
- }
- second, err := service.CreatePrompt(ctx, "Add two", "", "agent-two")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(first.Workspace, "one.txt"), "one\n")
- writeTestFile(t, filepath.Join(second.Workspace, "two.txt"), "two\n")
-
- proposalOne, err := service.Propose(ctx, first.Prompt.ID, "Add one")
- if err != nil {
- t.Fatal(err)
- }
- proposalTwo, err := service.Propose(ctx, second.Prompt.ID, "Add two")
- if err != nil {
- t.Fatal(err)
- }
- acceptedOne, err := service.Accept(ctx, proposalOne.Proposal.ID, nil)
- if err != nil {
- t.Fatal(err)
- }
- acceptedTwo, err := service.Accept(ctx, proposalTwo.Proposal.ID, nil)
- if err != nil {
- t.Fatal(err)
- }
- if acceptedTwo.State.SourceTree == acceptedOne.State.SourceTree {
- t.Fatal("second disjoint acceptance did not change the accepted tree")
- }
- assertTreeFiles(t, service, acceptedTwo.State.GitCommit, map[string]string{
- "base.txt": "base\n",
- "one.txt": "one\n",
- "two.txt": "two\n",
- })
-
- undo, err := service.Undo(ctx)
- if err != nil {
- t.Fatal(err)
- }
- if undo.ID == acceptedOne.State.ID || undo.ID == acceptedTwo.State.ID {
- t.Fatal("undo rewrote an old state instead of creating a new one")
- }
- if undo.SourceTree != acceptedOne.State.SourceTree {
- t.Fatalf("undo tree = %s, want previous accepted tree %s", undo.SourceTree, acceptedOne.State.SourceTree)
- }
- if undo.SourceTree == initial.SourceTree {
- t.Fatal("undo erased more than the latest accepted transition")
- }
- assertTreeFiles(t, service, undo.GitCommit, map[string]string{
- "base.txt": "base\n",
- "one.txt": "one\n",
- })
- assertTreeMissing(t, service, undo.GitCommit, "two.txt")
- report, err := service.Doctor(ctx, false)
- if err != nil {
- t.Fatal(err)
- }
- if !report.OK {
- t.Fatalf("doctor reported problems: %#v", report.Problems)
- }
-}
-
-func TestConcurrentDisjointAcceptancesSerialize(t *testing.T) {
- ctx := context.Background()
- service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
-
- first, err := service.CreatePrompt(ctx, "Add alpha", "", "alpha")
- if err != nil {
- t.Fatal(err)
- }
- second, err := service.CreatePrompt(ctx, "Add beta", "", "beta")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(first.Workspace, "alpha.txt"), "alpha\n")
- writeTestFile(t, filepath.Join(second.Workspace, "beta.txt"), "beta\n")
- alpha, err := service.Propose(ctx, first.Prompt.ID, "Alpha")
- if err != nil {
- t.Fatal(err)
- }
- beta, err := service.Propose(ctx, second.Prompt.ID, "Beta")
- if err != nil {
- t.Fatal(err)
- }
-
- proposalIDs := []string{alpha.Proposal.ID, beta.Proposal.ID}
- errs := make(chan error, len(proposalIDs))
- var wg sync.WaitGroup
- for _, id := range proposalIDs {
- wg.Add(1)
- go func() {
- defer wg.Done()
- _, acceptErr := service.Accept(ctx, id, nil)
- errs <- acceptErr
- }()
- }
- wg.Wait()
- close(errs)
- for acceptErr := range errs {
- if acceptErr != nil {
- t.Fatalf("concurrent acceptance: %v", acceptErr)
- }
- }
- head, err := service.Store.AcceptedHead(ctx)
- if err != nil {
- t.Fatal(err)
- }
- assertTreeFiles(t, service, head.GitCommit, map[string]string{
- "base.txt": "base\n",
- "alpha.txt": "alpha\n",
- "beta.txt": "beta\n",
- })
- history, err := service.History(ctx)
- if err != nil {
- t.Fatal(err)
- }
- if len(history) != 3 {
- t.Fatalf("canonical history has %d states, want initial plus two acceptances", len(history))
- }
-}
-
-func TestOverlappingSameFileIndependentHunksAutoMerge(t *testing.T) {
- ctx := context.Background()
- service, _ := newTestProject(t, map[string]string{
- "shared.txt": "header\nmiddle\nfooter\n",
- })
- first, err := service.CreatePrompt(ctx, "Change header", "", "one")
- if err != nil {
- t.Fatal(err)
- }
- second, err := service.CreatePrompt(ctx, "Change footer", "", "two")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(first.Workspace, "shared.txt"), "HEADER\nmiddle\nfooter\n")
- writeTestFile(t, filepath.Join(second.Workspace, "shared.txt"), "header\nmiddle\nFOOTER\n")
- firstProposal, err := service.Propose(ctx, first.Prompt.ID, "Header")
- if err != nil {
- t.Fatal(err)
- }
- secondProposal, err := service.Propose(ctx, second.Prompt.ID, "Footer")
- if err != nil {
- t.Fatal(err)
- }
- if _, err := service.Accept(ctx, firstProposal.Proposal.ID, nil); err != nil {
- t.Fatal(err)
- }
- merged, err := service.Accept(ctx, secondProposal.Proposal.ID, []string{
- "sh", "-c", `grep -qx HEADER shared.txt && grep -qx FOOTER shared.txt`,
- })
- if err != nil {
- var failed *CheckFailedError
- if errors.As(err, &failed) {
- contents, showErr := service.Repo.run(ctx, nil, nil, "show", failed.Check.TreeHash+":shared.txt")
- t.Fatalf("mergeable same-file proposal failed validation with shared.txt=%q (show error %v): %v", contents, showErr, err)
- }
- t.Fatalf("mergeable same-file proposal was blocked: %v", err)
- }
- assertTreeFiles(t, service, merged.State.GitCommit, map[string]string{
- "shared.txt": "HEADER\nmiddle\nFOOTER\n",
- })
- if len(merged.ProposalPaths) != 1 || len(merged.CurrentPaths) != 1 ||
- merged.ProposalPaths[0] != "shared.txt" || merged.CurrentPaths[0] != "shared.txt" {
- t.Fatalf("overlap audit paths were not retained: proposal=%v current=%v", merged.ProposalPaths, merged.CurrentPaths)
- }
-}
-
-func TestSnapshotCapturesRacySameSizeRewrite(t *testing.T) {
- ctx := context.Background()
- service, _ := newTestProject(t, map[string]string{"shared.txt": "lower\n"})
- runGitTest(t, service.Root, "config", "core.trustctime", "false")
- runGitTest(t, service.Root, "config", "core.checkStat", "minimal")
- prompt, err := service.CreatePrompt(ctx, "Uppercase the value", "", "agent")
- if err != nil {
- t.Fatal(err)
- }
- path := filepath.Join(prompt.Workspace, "shared.txt")
- before, err := os.Stat(path)
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, path, "UPPER\n")
- if err := os.Chtimes(path, before.ModTime(), before.ModTime()); err != nil {
- t.Fatal(err)
- }
- proposal, err := service.Propose(ctx, prompt.Prompt.ID, "Uppercase")
- if err != nil {
- t.Fatal(err)
- }
- assertTreeFiles(t, service, proposal.Proposal.GitCommit, map[string]string{"shared.txt": "UPPER\n"})
-}
-
-func TestSameAttemptFollowupCoalescesAlreadyAcceptedEdit(t *testing.T) {
- ctx := context.Background()
- service, initial := newTestProject(t, map[string]string{
- "hop-home.css": "body { color: black; }\n",
- "footer.html": `` + "\n",
- })
- first, err := service.CreatePrompt(ctx, "Add the primary color", "", "agent")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(first.Workspace, "hop-home.css"), "body { color: black; }\n:root { --color-primary: #724bdb; }\n")
- firstProposal, err := service.Propose(ctx, first.Prompt.ID, "Add primary color")
- if err != nil {
- t.Fatal(err)
- }
- if _, err := service.Land(ctx, firstProposal.Proposal.ID, nil); err != nil {
- t.Fatal(err)
- }
-
- followup, err := service.CreatePrompt(ctx, "Bust the stylesheet cache", firstProposal.Proposal.ID, "agent")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(followup.Workspace, "footer.html"), ``+"\n")
- secondProposal, err := service.Propose(ctx, followup.Prompt.ID, "Bump stylesheet cache key")
- if err != nil {
- t.Fatal(err)
- }
- if secondProposal.Proposal.CanonicalAnchorID != initial.ID {
- t.Fatalf("test no longer exercises stale attempt base: proposal anchor = %s, want %s", secondProposal.Proposal.CanonicalAnchorID, initial.ID)
- }
- landed, err := service.Land(ctx, secondProposal.Proposal.ID, nil)
- if err != nil {
- t.Fatalf("same-attempt follow-up was blocked: %v", err)
- }
- assertTreeFiles(t, service, landed.State.GitCommit, map[string]string{
- "hop-home.css": "body { color: black; }\n:root { --color-primary: #724bdb; }\n",
- "footer.html": `` + "\n",
- })
-}
-
-func TestIdenticalSameFileChangesCoalesceAutomatically(t *testing.T) {
- ctx := context.Background()
- service, _ := newTestProject(t, map[string]string{"shared.css": "body {}\n"})
- first, err := service.CreatePrompt(ctx, "Add primary color", "", "one")
- if err != nil {
- t.Fatal(err)
- }
- second, err := service.CreatePrompt(ctx, "Add the same primary color", "", "two")
- if err != nil {
- t.Fatal(err)
- }
- want := "body {}\n:root { --primary: purple; }\n"
- writeTestFile(t, filepath.Join(first.Workspace, "shared.css"), want)
- writeTestFile(t, filepath.Join(second.Workspace, "shared.css"), want)
- firstProposal, err := service.Propose(ctx, first.Prompt.ID, "Primary color")
- if err != nil {
- t.Fatal(err)
- }
- secondProposal, err := service.Propose(ctx, second.Prompt.ID, "Same primary color")
- if err != nil {
- t.Fatal(err)
- }
- if _, err := service.Accept(ctx, firstProposal.Proposal.ID, nil); err != nil {
- t.Fatal(err)
- }
- secondAccepted, err := service.Accept(ctx, secondProposal.Proposal.ID, nil)
- if err != nil {
- t.Fatalf("identical same-file change was blocked: %v", err)
- }
- assertTreeFiles(t, service, secondAccepted.State.GitCommit, map[string]string{"shared.css": want})
-}
-
-func TestConcurrentSameFileCompatibleAcceptancesSerializeAndMerge(t *testing.T) {
- ctx := context.Background()
- service, _ := newTestProject(t, map[string]string{"shared.txt": "one\ntwo\nthree\n"})
- first, err := service.CreatePrompt(ctx, "Uppercase one", "", "one")
- if err != nil {
- t.Fatal(err)
- }
- second, err := service.CreatePrompt(ctx, "Uppercase three", "", "two")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(first.Workspace, "shared.txt"), "ONE\ntwo\nthree\n")
- writeTestFile(t, filepath.Join(second.Workspace, "shared.txt"), "one\ntwo\nTHREE\n")
- firstProposal, err := service.Propose(ctx, first.Prompt.ID, "One")
- if err != nil {
- t.Fatal(err)
- }
- secondProposal, err := service.Propose(ctx, second.Prompt.ID, "Three")
- if err != nil {
- t.Fatal(err)
- }
- errs := make(chan error, 2)
- var wg sync.WaitGroup
- for _, proposalID := range []string{firstProposal.Proposal.ID, secondProposal.Proposal.ID} {
- wg.Add(1)
- go func() {
- defer wg.Done()
- _, acceptErr := service.Accept(ctx, proposalID, nil)
- errs <- acceptErr
- }()
- }
- wg.Wait()
- close(errs)
- for err := range errs {
- if err != nil {
- t.Fatalf("concurrent compatible acceptance: %v", err)
- }
- }
- head, err := service.Store.AcceptedHead(ctx)
- if err != nil {
- t.Fatal(err)
- }
- assertTreeFiles(t, service, head.GitCommit, map[string]string{
- "shared.txt": "ONE\ntwo\nTHREE\n",
- })
-}
-
-func TestTrueConflictCreatesAgentReconciliationWorkspace(t *testing.T) {
- ctx := context.Background()
- service, _ := newTestProject(t, map[string]string{"shared.txt": "color=base\n"})
- first, err := service.CreatePrompt(ctx, "Use red", "", "one")
- if err != nil {
- t.Fatal(err)
- }
- second, err := service.CreatePrompt(ctx, "Use blue with fallback", "", "two")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(first.Workspace, "shared.txt"), "color=red\n")
- writeTestFile(t, filepath.Join(second.Workspace, "shared.txt"), "color=blue\n")
- firstProposal, err := service.Propose(ctx, first.Prompt.ID, "Red")
- if err != nil {
- t.Fatal(err)
- }
- secondProposal, err := service.Propose(ctx, second.Prompt.ID, "Blue")
- if err != nil {
- t.Fatal(err)
- }
- if _, err := service.Land(ctx, firstProposal.Proposal.ID, nil); err != nil {
- t.Fatal(err)
- }
- _, err = service.Land(ctx, secondProposal.Proposal.ID, nil)
- var conflict *ConflictError
- if !errors.As(err, &conflict) {
- t.Fatalf("land error = %v, want ConflictError", err)
- }
- refresh, err := service.Refresh(ctx, secondProposal.Proposal.ID)
- if err != nil {
- t.Fatal(err)
- }
- if refresh.Task.ID != second.Task.ID {
- t.Fatalf("reconciliation escaped original task: %#v", refresh)
- }
- if refresh.Attempt.ID == second.Attempt.ID || refresh.Attempt.BaseStateID != refresh.AcceptedHead.ID {
- t.Fatalf("reconciliation did not receive a fresh attempt at the accepted head: %#v", refresh.Attempt)
- }
- if len(refresh.Conflicts) != 1 || refresh.Conflicts[0] != "shared.txt" {
- t.Fatalf("conflicts = %#v", refresh.Conflicts)
- }
- contents, err := os.ReadFile(filepath.Join(refresh.Workspace, "shared.txt"))
- if err != nil {
- t.Fatal(err)
- }
- if !bytes.Contains(contents, []byte("<<<<<<< ")) ||
- !bytes.Contains(contents, []byte("=======")) ||
- !bytes.Contains(contents, []byte(">>>>>>> ")) {
- t.Fatalf("reconciliation workspace lacks useful diff3 markers:\n%s", contents)
- }
- if _, err := service.Propose(ctx, refresh.Prompt.ID, "Unresolved"); err == nil || !strings.Contains(err.Error(), "merge markers") {
- t.Fatalf("unresolved proposal error = %v", err)
- }
- writeTestFile(t, filepath.Join(refresh.Workspace, "shared.txt"), "color=red-blue-fallback\n")
- if _, err := service.Propose(ctx, refresh.Prompt.ID, "Unchecked resolution"); err == nil || !strings.Contains(err.Error(), "must pass hop check") {
- t.Fatalf("unchecked reconciliation proposal error = %v", err)
- }
- if _, err := service.RunCheck(ctx, refresh.Prompt.ID, []string{
- "sh", "-c", `test "$(cat shared.txt)" = "color=red-blue-fallback"`,
- }); err != nil {
- t.Fatal(err)
- }
- resolvedProposal, err := service.Propose(ctx, refresh.Prompt.ID, "Resolve both color intents")
- if err != nil {
- t.Fatal(err)
- }
- resolved, err := service.Land(ctx, resolvedProposal.Proposal.ID, []string{
- "sh", "-c", `test "$(cat shared.txt)" = "color=red-blue-fallback"`,
- })
- if err != nil {
- t.Fatal(err)
- }
- if contents, err := os.ReadFile(filepath.Join(service.Root, "shared.txt")); err != nil || string(contents) != "color=red-blue-fallback\n" {
- t.Fatalf("visible resolution = %q, err=%v", string(contents), err)
- }
- if resolved.MaterializedRoot != service.Root {
- t.Fatalf("resolved root = %q", resolved.MaterializedRoot)
- }
-}
-
-func TestMarkerlessModifyDeleteConflictRequiresCheckedResolution(t *testing.T) {
- ctx := context.Background()
- service, _ := newTestProject(t, map[string]string{"shared.txt": "base\n"})
- modify, err := service.CreatePrompt(ctx, "Modify the shared file", "", "modifier")
- if err != nil {
- t.Fatal(err)
- }
- remove, err := service.CreatePrompt(ctx, "Remove the shared file", "", "remover")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(modify.Workspace, "shared.txt"), "accepted modification\n")
- if err := os.Remove(filepath.Join(remove.Workspace, "shared.txt")); err != nil {
- t.Fatal(err)
- }
- modifyProposal, err := service.Propose(ctx, modify.Prompt.ID, "Modify")
- if err != nil {
- t.Fatal(err)
- }
- removeProposal, err := service.Propose(ctx, remove.Prompt.ID, "Remove")
- if err != nil {
- t.Fatal(err)
- }
- if _, err := service.Land(ctx, modifyProposal.Proposal.ID, nil); err != nil {
- t.Fatal(err)
- }
- if _, err := service.Land(ctx, removeProposal.Proposal.ID, nil); err == nil {
- t.Fatal("modify/delete proposal unexpectedly landed without reconciliation")
- }
- refresh, err := service.Refresh(ctx, removeProposal.Proposal.ID)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := service.Propose(ctx, refresh.Prompt.ID, "Unchecked structural resolution"); err == nil || !strings.Contains(err.Error(), "must pass hop check") {
- t.Fatalf("unchecked markerless reconciliation error = %v", err)
- }
- if err := os.Remove(filepath.Join(refresh.Workspace, "shared.txt")); err != nil && !errors.Is(err, os.ErrNotExist) {
- t.Fatal(err)
- }
- if _, err := service.RunCheck(ctx, refresh.Prompt.ID, []string{"sh", "-c", "test ! -e shared.txt"}); err != nil {
- t.Fatal(err)
- }
- resolvedProposal, err := service.Propose(ctx, refresh.Prompt.ID, "Intentionally remove shared file")
- if err != nil {
- t.Fatal(err)
- }
- if _, err := service.Land(ctx, resolvedProposal.Proposal.ID, []string{"sh", "-c", "test ! -e shared.txt"}); err != nil {
- t.Fatal(err)
- }
- if _, err := os.Stat(filepath.Join(service.Root, "shared.txt")); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("resolved visible root retained deleted file: %v", err)
- }
-}
-
-func TestOverlappingProposalAndFailedFinalCheckDoNotMoveHead(t *testing.T) {
- ctx := context.Background()
- service, _ := newTestProject(t, map[string]string{"shared.txt": "base\n"})
-
- first, err := service.CreatePrompt(ctx, "First edit", "", "one")
- if err != nil {
- t.Fatal(err)
- }
- second, err := service.CreatePrompt(ctx, "Second edit", "", "two")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(first.Workspace, "shared.txt"), "first\n")
- writeTestFile(t, filepath.Join(second.Workspace, "shared.txt"), "second\n")
- firstProposal, err := service.Propose(ctx, first.Prompt.ID, "First")
- if err != nil {
- t.Fatal(err)
- }
- secondProposal, err := service.Propose(ctx, second.Prompt.ID, "Second")
- if err != nil {
- t.Fatal(err)
- }
- firstAccepted, err := service.Accept(ctx, firstProposal.Proposal.ID, nil)
- if err != nil {
- t.Fatal(err)
- }
- _, err = service.Accept(ctx, secondProposal.Proposal.ID, nil)
- var conflict *ConflictError
- if !errors.As(err, &conflict) {
- t.Fatalf("second acceptance error = %v, want ConflictError", err)
- }
- if len(conflict.Paths) != 1 || conflict.Paths[0] != "shared.txt" {
- t.Fatalf("conflict paths = %#v", conflict.Paths)
- }
- head, err := service.Store.AcceptedHead(ctx)
- if err != nil {
- t.Fatal(err)
- }
- if head.ID != firstAccepted.State.ID {
- t.Fatalf("blocked proposal moved accepted head to %s", head.ID)
- }
-
- third, err := service.CreatePrompt(ctx, "Add safe file", "", "three")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(third.Workspace, "safe.txt"), "safe\n")
- thirdProposal, err := service.Propose(ctx, third.Prompt.ID, "Safe")
- if err != nil {
- t.Fatal(err)
- }
- _, err = service.Accept(ctx, thirdProposal.Proposal.ID, []string{"sh", "-c", "exit 9"})
- var checkFailed *CheckFailedError
- if !errors.As(err, &checkFailed) {
- t.Fatalf("failed validation error = %v, want CheckFailedError", err)
- }
- if checkFailed.Check.StateID == "" {
- t.Fatal("failed final-tree validation was not attached to a durable state")
- }
- failedState, err := service.Store.GetState(ctx, checkFailed.Check.StateID)
- if err != nil {
- t.Fatal(err)
- }
- if failedState.Kind != StateFailed || failedState.SourceTree != checkFailed.Check.TreeHash {
- t.Fatalf("failed state = %#v, check tree = %s", failedState, checkFailed.Check.TreeHash)
- }
- head, err = service.Store.AcceptedHead(ctx)
- if err != nil {
- t.Fatal(err)
- }
- if head.ID != firstAccepted.State.ID {
- t.Fatal("failed final-tree validation moved accepted head")
- }
-}
-
-func TestLandMaterializesVisibleRootWithoutMovingGitState(t *testing.T) {
- ctx := context.Background()
- root := t.TempDir()
- runGitTest(t, root, "init", "--quiet")
- writeTestFile(t, filepath.Join(root, "base.txt"), "base\n")
- writeTestFile(t, filepath.Join(root, "remove.txt"), "remove\n")
- runGitTest(t, root, "add", "base.txt", "remove.txt")
- runGitTest(t, root, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--quiet", "-m", "initial")
-
- service, _, err := InitProject(ctx, root)
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = service.Close() })
- started, err := service.CreatePrompt(ctx, "Materialize the result", "", "agent")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(started.Workspace, "base.txt"), "landed\n")
- writeTestFile(t, filepath.Join(started.Workspace, "nested", "new.txt"), "new\n")
- if err := os.Remove(filepath.Join(started.Workspace, "remove.txt")); err != nil {
- t.Fatal(err)
- }
- proposal, err := service.Propose(ctx, started.Prompt.ID, "Materialized change")
- if err != nil {
- t.Fatal(err)
- }
-
- beforeHead := runGitTest(t, root, "rev-parse", "HEAD")
- beforeBranch := runGitTest(t, root, "symbolic-ref", "--short", "HEAD")
- beforeIndex := runGitTest(t, root, "ls-files", "--stage")
- result, err := service.Land(ctx, proposal.Proposal.ID, nil)
- if err != nil {
- t.Fatal(err)
- }
- if result.MaterializedRoot != service.Root {
- t.Fatalf("materialized root = %q, want %q", result.MaterializedRoot, service.Root)
- }
- if got := runGitTest(t, root, "rev-parse", "HEAD"); got != beforeHead {
- t.Fatalf("HEAD moved from %s to %s", beforeHead, got)
- }
- if got := runGitTest(t, root, "symbolic-ref", "--short", "HEAD"); got != beforeBranch {
- t.Fatalf("branch changed from %s to %s", beforeBranch, got)
- }
- if got := runGitTest(t, root, "ls-files", "--stage"); got != beforeIndex {
- t.Fatalf("real index changed:\nwant %s\n got %s", beforeIndex, got)
- }
- if contents, err := os.ReadFile(filepath.Join(root, "base.txt")); err != nil || string(contents) != "landed\n" {
- t.Fatalf("base.txt = %q, err=%v", string(contents), err)
- }
- if contents, err := os.ReadFile(filepath.Join(root, "nested", "new.txt")); err != nil || string(contents) != "new\n" {
- t.Fatalf("nested/new.txt = %q, err=%v", string(contents), err)
- }
- if _, err := os.Stat(filepath.Join(root, "remove.txt")); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("removed file still exists: %v", err)
- }
- status, err := service.Status(ctx)
- if err != nil {
- t.Fatal(err)
- }
- if status.RootStatus != "synchronized" || status.RootStateID != result.State.ID {
- t.Fatalf("root status = %#v", status)
- }
-}
-
-func TestLandAutomaticallyPushesAcceptedCommit(t *testing.T) {
- ctx := context.Background()
- service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
- remote := filepath.Join(t.TempDir(), "remote.git")
- runGitTest(t, service.Root, "init", "--quiet", "--bare", remote)
- runGitTest(t, service.Root, "remote", "add", "origin", remote)
- branch := runGitTest(t, service.Root, "symbolic-ref", "--short", "HEAD")
-
- started, err := service.CreatePrompt(ctx, "Publish accepted work", "", "agent")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(started.Workspace, "published.txt"), "published\n")
- proposal, err := service.Propose(ctx, started.Prompt.ID, "Published change")
- if err != nil {
- t.Fatal(err)
- }
- result, err := service.Land(ctx, proposal.Proposal.ID, nil)
- if err != nil {
- t.Fatal(err)
- }
- if result.RemotePush == nil {
- t.Fatal("land did not report an automatic remote push")
- }
- wantRef := "refs/heads/" + branch
- if result.RemotePush.Remote != "origin" || result.RemotePush.Ref != wantRef || result.RemotePush.Commit != result.State.GitCommit {
- t.Fatalf("automatic push = %#v", result.RemotePush)
- }
- if got := runGitTest(t, service.Root, "--git-dir", remote, "rev-parse", wantRef); got != result.State.GitCommit {
- t.Fatalf("remote branch = %s, want accepted commit %s", got, result.State.GitCommit)
- }
- retried, err := service.Push(ctx)
- if err != nil {
- t.Fatal(err)
- }
- if retried.Commit != result.State.GitCommit || retried.Remote != "origin" || retried.Ref != wantRef {
- t.Fatalf("explicit push retry = %#v", retried)
- }
-}
-
-func TestAutomaticPushNeverForcesDivergedRemote(t *testing.T) {
- ctx := context.Background()
- service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
- remote := filepath.Join(t.TempDir(), "remote.git")
- runGitTest(t, service.Root, "init", "--quiet", "--bare", remote)
- runGitTest(t, service.Root, "remote", "add", "origin", remote)
- branch := runGitTest(t, service.Root, "symbolic-ref", "--short", "HEAD")
-
- first, err := service.CreatePrompt(ctx, "Publish first work", "", "agent")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(first.Workspace, "first.txt"), "first\n")
- firstProposal, err := service.Propose(ctx, first.Prompt.ID, "First published change")
- if err != nil {
- t.Fatal(err)
- }
- firstLanded, err := service.Land(ctx, firstProposal.Proposal.ID, nil)
- if err != nil {
- t.Fatal(err)
- }
-
- other := filepath.Join(t.TempDir(), "other")
- runGitTest(t, service.Root, "clone", "--quiet", "--branch", branch, remote, other)
- writeTestFile(t, filepath.Join(other, "remote.txt"), "remote\n")
- runGitTest(t, other, "add", "remote.txt")
- runGitTest(t, other, "-c", "user.name=Remote", "-c", "user.email=remote@example.com", "commit", "--quiet", "-m", "remote change")
- runGitTest(t, other, "push", "--quiet", "origin", branch)
- remoteTip := runGitTest(t, service.Root, "--git-dir", remote, "rev-parse", "refs/heads/"+branch)
- if remoteTip == firstLanded.State.GitCommit {
- t.Fatal("test did not advance the remote independently")
- }
-
- second, err := service.CreatePrompt(ctx, "Publish local work", "", "agent")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(second.Workspace, "local.txt"), "local\n")
- secondProposal, err := service.Propose(ctx, second.Prompt.ID, "Local accepted change")
- if err != nil {
- t.Fatal(err)
- }
- secondLanded, err := service.Land(ctx, secondProposal.Proposal.ID, nil)
- if err != nil {
- t.Fatal(err)
- }
- if secondLanded.RemotePush != nil {
- t.Fatalf("diverged remote unexpectedly reported a successful push: %#v", secondLanded.RemotePush)
- }
- 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 != remoteTip {
- t.Fatalf("automatic push rewrote diverged remote from %s to %s", remoteTip, got)
- }
-}
-
-func TestLandMaterializesEmptyUnbornRootAcrossRepeatedLands(t *testing.T) {
- ctx := context.Background()
- root := t.TempDir()
- service, _, err := InitProject(ctx, root)
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = service.Close() })
- for index, name := range []string{"one.txt", "two.txt"} {
- started, err := service.CreatePrompt(ctx, "Add "+name, "", "agent")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(started.Workspace, name), name+"\n")
- proposal, err := service.Propose(ctx, started.Prompt.ID, "Add "+name)
- if err != nil {
- t.Fatal(err)
- }
- if _, err := service.Land(ctx, proposal.Proposal.ID, nil); err != nil {
- t.Fatalf("land %d: %v", index+1, err)
- }
- }
- for _, name := range []string{"one.txt", "two.txt"} {
- contents, err := os.ReadFile(filepath.Join(service.Root, name))
- if err != nil || string(contents) != name+"\n" {
- t.Fatalf("%s = %q, err=%v", name, string(contents), err)
- }
- }
- if _, exists, err := service.Repo.Head(ctx); err != nil || exists {
- t.Fatalf("unborn HEAD changed: exists=%v err=%v", exists, err)
- }
- if _, err := os.Stat(filepath.Join(service.Repo.GitDir(), "index")); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("real index was created or changed: %v", err)
- }
-}
-
-func TestAcceptLeavesVisibleRootStaleUntilSync(t *testing.T) {
- ctx := context.Background()
- service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"})
- started, err := service.CreatePrompt(ctx, "Add accepted file", "", "controller")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(started.Workspace, "accepted.txt"), "accepted\n")
- proposal, err := service.Propose(ctx, started.Prompt.ID, "Controller accept")
- if err != nil {
- t.Fatal(err)
- }
- accepted, err := service.Accept(ctx, proposal.Proposal.ID, nil)
- if err != nil {
- t.Fatal(err)
- }
- if accepted.MaterializedRoot != "" {
- t.Fatal("controller accept unexpectedly materialized the visible root")
- }
- materialized, err := service.Store.MaterializedHead(ctx)
- if err != nil || materialized.ID != initial.ID {
- t.Fatalf("controller accept moved materialized head to %s: %v", materialized.ID, err)
- }
- if _, err := os.Stat(filepath.Join(service.Root, "accepted.txt")); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("controller accept changed visible root: %v", err)
- }
- status, err := service.Status(ctx)
- if err != nil {
- t.Fatal(err)
- }
- if status.RootStatus != "stale" {
- t.Fatalf("root status = %q, want stale", status.RootStatus)
- }
- synced, err := service.Sync(ctx)
- if err != nil {
- t.Fatal(err)
- }
- if !synced.Changed || synced.State.ID != accepted.State.ID {
- t.Fatalf("sync result = %#v", synced)
- }
- materialized, err = service.Store.MaterializedHead(ctx)
- if err != nil || materialized.ID != accepted.State.ID {
- t.Fatalf("sync materialized head = %s, err=%v", materialized.ID, err)
- }
- if contents, err := os.ReadFile(filepath.Join(service.Root, "accepted.txt")); err != nil || string(contents) != "accepted\n" {
- t.Fatalf("accepted.txt = %q, err=%v", string(contents), err)
- }
- retried, err := service.Land(ctx, proposal.Proposal.ID, nil)
- if err != nil {
- t.Fatal(err)
- }
- if retried.State.ID != accepted.State.ID {
- t.Fatalf("retry created accepted state %s, want existing %s", retried.State.ID, accepted.State.ID)
- }
- history, err := service.History(ctx)
- if err != nil {
- t.Fatal(err)
- }
- if len(history) != 2 {
- t.Fatalf("retry created duplicate acceptance; history has %d states", len(history))
- }
-}
-
-func TestLandBlocksDivergedVisibleRootBeforeAcceptance(t *testing.T) {
- ctx := context.Background()
- service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"})
- started, err := service.CreatePrompt(ctx, "Add landed file", "", "agent")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(started.Workspace, "landed.txt"), "landed\n")
- proposal, err := service.Propose(ctx, started.Prompt.ID, "Landed file")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(service.Root, "local.txt"), "do not overwrite\n")
- beforeRef, exists, err := service.Repo.ReadHiddenRef(ctx, acceptedRef)
- if err != nil || !exists {
- t.Fatalf("read accepted ref: exists=%v err=%v", exists, err)
- }
- _, err = service.Land(ctx, proposal.Proposal.ID, nil)
- var conflict *RootConflictError
- if !errors.As(err, &conflict) {
- t.Fatalf("land error = %v, want RootConflictError", err)
- }
- head, err := service.Store.AcceptedHead(ctx)
- if err != nil {
- t.Fatal(err)
- }
- if head.ID != initial.ID {
- t.Fatalf("blocked land moved accepted head to %s", head.ID)
- }
- afterRef, _, err := service.Repo.ReadHiddenRef(ctx, acceptedRef)
- if err != nil || afterRef != beforeRef {
- t.Fatalf("accepted ref changed from %s to %s: %v", beforeRef, afterRef, err)
- }
- if contents, err := os.ReadFile(filepath.Join(service.Root, "local.txt")); err != nil || string(contents) != "do not overwrite\n" {
- t.Fatalf("local file changed: %q, %v", string(contents), err)
- }
- if _, err := os.Stat(filepath.Join(service.Root, "landed.txt")); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("blocked land materialized proposal: %v", err)
- }
-}
-
-func TestLandBlocksDivergentRealIndexWithoutChangingIt(t *testing.T) {
- ctx := context.Background()
- root := t.TempDir()
- runGitTest(t, root, "init", "--quiet")
- writeTestFile(t, filepath.Join(root, "base.txt"), "base\n")
- runGitTest(t, root, "add", "base.txt")
- runGitTest(t, root, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--quiet", "-m", "initial")
- service, initial, err := InitProject(ctx, root)
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = service.Close() })
- started, err := service.CreatePrompt(ctx, "Add landed file", "", "agent")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(started.Workspace, "landed.txt"), "landed\n")
- proposal, err := service.Propose(ctx, started.Prompt.ID, "Landed file")
- if err != nil {
- t.Fatal(err)
- }
-
- writeTestFile(t, filepath.Join(root, "base.txt"), "staged\n")
- runGitTest(t, root, "add", "base.txt")
- writeTestFile(t, filepath.Join(root, "base.txt"), "base\n")
- beforeIndex := runGitTest(t, root, "ls-files", "--stage")
- _, err = service.Land(ctx, proposal.Proposal.ID, nil)
- var conflict *RootConflictError
- if !errors.As(err, &conflict) {
- t.Fatalf("land error = %v, want RootConflictError", err)
- }
- if got := runGitTest(t, root, "ls-files", "--stage"); got != beforeIndex {
- t.Fatalf("real index changed:\nwant %s\n got %s", beforeIndex, got)
- }
- head, err := service.Store.AcceptedHead(ctx)
- if err != nil || head.ID != initial.ID {
- t.Fatalf("accepted head = %s, err=%v", head.ID, err)
- }
- if _, err := os.Stat(filepath.Join(root, "landed.txt")); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("blocked land materialized proposal: %v", err)
- }
-}
-
-func TestLandPreservesIgnoredFilesAndRejectsIgnoredDestination(t *testing.T) {
- t.Run("unrelated ignored content", func(t *testing.T) {
- ctx := context.Background()
- service, _ := newTestProject(t, map[string]string{".gitignore": "cache/\n", "base.txt": "base\n"})
- writeTestFile(t, filepath.Join(service.Root, "cache", "private.txt"), "private\n")
- started, err := service.CreatePrompt(ctx, "Add visible file", "", "agent")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(started.Workspace, "landed.txt"), "landed\n")
- proposal, err := service.Propose(ctx, started.Prompt.ID, "Visible file")
- if err != nil {
- t.Fatal(err)
- }
- if _, err := service.Land(ctx, proposal.Proposal.ID, nil); err != nil {
- t.Fatal(err)
- }
- if contents, err := os.ReadFile(filepath.Join(service.Root, "cache", "private.txt")); err != nil || string(contents) != "private\n" {
- t.Fatalf("ignored file changed: %q, %v", string(contents), err)
- }
- })
-
- t.Run("ignored destination collision", func(t *testing.T) {
- ctx := context.Background()
- service, initial := newTestProject(t, map[string]string{".gitignore": "generated\n"})
- writeTestFile(t, filepath.Join(service.Root, "generated"), "private\n")
- started, err := service.CreatePrompt(ctx, "Generate tracked output", "", "agent")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(started.Workspace, ".gitignore"), "")
- writeTestFile(t, filepath.Join(started.Workspace, "generated"), "accepted\n")
- proposal, err := service.Propose(ctx, started.Prompt.ID, "Tracked output")
- if err != nil {
- t.Fatal(err)
- }
- _, err = service.Land(ctx, proposal.Proposal.ID, nil)
- var conflict *RootConflictError
- if !errors.As(err, &conflict) {
- t.Fatalf("land error = %v, want RootConflictError", err)
- }
- head, err := service.Store.AcceptedHead(ctx)
- if err != nil || head.ID != initial.ID {
- t.Fatalf("accepted head = %s, err=%v", head.ID, err)
- }
- if contents, err := os.ReadFile(filepath.Join(service.Root, "generated")); err != nil || string(contents) != "private\n" {
- t.Fatalf("ignored collision was overwritten: %q, %v", string(contents), err)
- }
- })
-}
-
-func TestLandMaterializesFileDirectoryTypeChanges(t *testing.T) {
- ctx := context.Background()
- service, _ := newTestProject(t, map[string]string{
- "directory/old.txt": "old\n",
- "file": "old file\n",
- })
- started, err := service.CreatePrompt(ctx, "Change source entry types", "", "agent")
- if err != nil {
- t.Fatal(err)
- }
- if err := os.RemoveAll(filepath.Join(started.Workspace, "directory")); err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(started.Workspace, "directory"), "now a file\n")
- if err := os.Remove(filepath.Join(started.Workspace, "file")); err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(started.Workspace, "file", "new.txt"), "now a directory\n")
- proposal, err := service.Propose(ctx, started.Prompt.ID, "Change entry types")
- if err != nil {
- t.Fatal(err)
- }
- if _, err := service.Land(ctx, proposal.Proposal.ID, nil); err != nil {
- t.Fatal(err)
- }
- if contents, err := os.ReadFile(filepath.Join(service.Root, "directory")); err != nil || string(contents) != "now a file\n" {
- t.Fatalf("directory replacement = %q, %v", string(contents), err)
- }
- if contents, err := os.ReadFile(filepath.Join(service.Root, "file", "new.txt")); err != nil || string(contents) != "now a directory\n" {
- t.Fatalf("file replacement = %q, %v", string(contents), err)
- }
-}
-
-func TestFailedLandValidationDoesNotMaterializeVisibleRoot(t *testing.T) {
- ctx := context.Background()
- service, initial := newTestProject(t, map[string]string{"base.txt": "base\n"})
- started, err := service.CreatePrompt(ctx, "Add invalid file", "", "agent")
- if err != nil {
- t.Fatal(err)
- }
- writeTestFile(t, filepath.Join(started.Workspace, "invalid.txt"), "invalid\n")
- proposal, err := service.Propose(ctx, started.Prompt.ID, "Invalid")
- if err != nil {
- t.Fatal(err)
- }
- _, err = service.Land(ctx, proposal.Proposal.ID, []string{"sh", "-c", "exit 7"})
- var failed *CheckFailedError
- if !errors.As(err, &failed) {
- t.Fatalf("land error = %v, want CheckFailedError", err)
- }
- if _, err := os.Stat(filepath.Join(service.Root, "invalid.txt")); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("failed land materialized file: %v", err)
- }
- head, err := service.Store.AcceptedHead(ctx)
- if err != nil || head.ID != initial.ID {
- t.Fatalf("accepted head = %s, err=%v", head.ID, err)
- }
-}
-
-func newTestProject(t *testing.T, files map[string]string) (*Service, State) {
- t.Helper()
- root := t.TempDir()
- for path, contents := range files {
- writeTestFile(t, filepath.Join(root, path), contents)
- }
- service, initial, err := InitProject(context.Background(), root)
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = service.Close() })
- return service, initial
-}
-
-func writeTestFile(t *testing.T, path, contents string) {
- t.Helper()
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
- t.Fatal(err)
- }
-}
-
-func assertTreeFiles(t *testing.T, service *Service, commit string, expected map[string]string) {
- t.Helper()
- path := filepath.Join(t.TempDir(), "materialized")
- if _, err := service.Repo.AddDetachedWorktree(context.Background(), path, commit); err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = service.Repo.RemoveWorktree(context.Background(), path, true) })
- for name, want := range expected {
- contents, err := os.ReadFile(filepath.Join(path, name))
- if err != nil {
- t.Fatalf("read %s: %v", name, err)
- }
- if string(contents) != want {
- t.Fatalf("%s = %q, want %q", name, string(contents), want)
- }
- }
-}
-
-func assertTreeMissing(t *testing.T, service *Service, commit string, name string) {
- t.Helper()
- path := filepath.Join(t.TempDir(), "materialized")
- if _, err := service.Repo.AddDetachedWorktree(context.Background(), path, commit); err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = service.Repo.RemoveWorktree(context.Background(), path, true) })
- if _, err := os.Stat(filepath.Join(path, name)); !errors.Is(err, os.ErrNotExist) {
- t.Fatalf("expected %s to be absent, stat error = %v", name, err)
- }
-}
-
-func runGitTest(t *testing.T, root string, args ...string) string {
- t.Helper()
- cmd := exec.Command("git", args...)
- cmd.Dir = root
- var stdout, stderr bytes.Buffer
- cmd.Stdout = &stdout
- cmd.Stderr = &stderr
- if err := cmd.Run(); err != nil {
- t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, stderr.String())
- }
- return strings.TrimSpace(stdout.String())
-}
-
-func runCLIJSONTest(t *testing.T, args []string) map[string]any {
- t.Helper()
- configuredRoot, hadConfiguredRoot := os.LookupEnv("HOP_ROOT")
- if err := os.Unsetenv("HOP_ROOT"); err != nil {
- t.Fatal(err)
- }
- defer func() {
- if hadConfiguredRoot {
- _ = os.Setenv("HOP_ROOT", configuredRoot)
- } else {
- _ = os.Unsetenv("HOP_ROOT")
- }
- }()
- var stdout, stderr bytes.Buffer
- if code := RunCLI(args, &stdout, &stderr); code != 0 {
- t.Fatalf("hop %s exited %d\nstdout: %s\nstderr: %s", strings.Join(args, " "), code, stdout.String(), stderr.String())
- }
- var result map[string]any
- if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
- t.Fatalf("decode JSON from hop %s: %v\n%s", strings.Join(args, " "), err, stdout.String())
- }
- if ok, _ := result["ok"].(bool); !ok {
- t.Fatalf("hop %s returned non-ok JSON: %#v", strings.Join(args, " "), result)
- }
- return result
-}
-
-func runCLIJSONInputTest(t *testing.T, args []string, input string) map[string]any {
- t.Helper()
- configuredRoot, hadConfiguredRoot := os.LookupEnv("HOP_ROOT")
- if err := os.Unsetenv("HOP_ROOT"); err != nil {
- t.Fatal(err)
- }
- defer func() {
- if hadConfiguredRoot {
- _ = os.Setenv("HOP_ROOT", configuredRoot)
- } else {
- _ = os.Unsetenv("HOP_ROOT")
- }
- }()
- var stdout, stderr bytes.Buffer
- if code := RunCLIWithInput(args, strings.NewReader(input), &stdout, &stderr); code != 0 {
- t.Fatalf("hop %s exited %d\nstdout: %s\nstderr: %s", strings.Join(args, " "), code, stdout.String(), stderr.String())
- }
- var result map[string]any
- if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
- t.Fatalf("decode JSON from hop %s: %v\n%s", strings.Join(args, " "), err, stdout.String())
- }
- if ok, _ := result["ok"].(bool); !ok {
- t.Fatalf("hop %s returned non-ok JSON: %#v", strings.Join(args, " "), result)
- }
- return result
-}
-
-func objectField(t *testing.T, object map[string]any, key string) map[string]any {
- t.Helper()
- value, ok := object[key].(map[string]any)
- if !ok {
- t.Fatalf("field %q is not an object: %#v", key, object[key])
- }
- return value
-}
-
-func stringField(t *testing.T, object map[string]any, key string) string {
- t.Helper()
- value, ok := object[key].(string)
- if !ok {
- t.Fatalf("field %q is not a string: %#v", key, object[key])
- }
- return value
-}
diff --git a/internal/hop/skill.go b/internal/hop/skill.go
deleted file mode 100644
index edd94b5..0000000
--- a/internal/hop/skill.go
+++ /dev/null
@@ -1,276 +0,0 @@
-package hop
-
-import (
- "fmt"
- "io/fs"
- "os"
- "path/filepath"
- "strings"
-
- hopskill "githop.xyz/GnosysLabs/Hop/skills/hop"
-)
-
-type SkillInstallResult struct {
- Path string `json:"path"`
- Paths []string `json:"paths,omitempty"`
- Files []string `json:"files"`
-}
-
-func DefaultSkillBase() (string, error) {
- home := os.Getenv("CODEX_HOME")
- if home == "" {
- userHome, err := os.UserHomeDir()
- if err != nil {
- return "", fmt.Errorf("find home directory: %w", err)
- }
- home = filepath.Join(userHome, ".codex")
- }
- return filepath.Join(home, "skills"), nil
-}
-
-func DefaultSharedSkillBase() (string, error) {
- home, err := os.UserHomeDir()
- if err != nil {
- return "", fmt.Errorf("find home directory: %w", err)
- }
- return filepath.Join(home, ".agents", "skills"), nil
-}
-
-func DefaultSkillBases() ([]string, error) {
- codex, err := DefaultSkillBase()
- if err != nil {
- return nil, err
- }
- shared, err := DefaultSharedSkillBase()
- if err != nil {
- return nil, err
- }
- return []string{codex, shared}, nil
-}
-
-func EmbeddedSkillText() (string, error) {
- contents, err := hopskill.Files.ReadFile("SKILL.md")
- if err != nil {
- return "", fmt.Errorf("read embedded Hop skill: %w", err)
- }
- return string(contents), nil
-}
-
-// InstallSkill writes the embedded skill below one skills directory. Existing
-// bundles require force; known files are overwritten without deleting unknown
-// user files from the destination. An empty base retains the legacy Codex
-// destination; the CLI uses InstallDefaultSkills for its no-path behavior.
-func InstallSkill(base string, force bool) (SkillInstallResult, error) {
- if strings.TrimSpace(base) == "" {
- var err error
- base, err = DefaultSkillBase()
- if err != nil {
- return SkillInstallResult{}, err
- }
- }
- target, err := resolveSkillTarget(base)
- if err != nil {
- return SkillInstallResult{}, err
- }
- if err := preflightSkillTarget(target, force); err != nil {
- return SkillInstallResult{}, err
- }
- return writeSkillTarget(target)
-}
-
-// InstallDefaultSkills installs the same embedded files in the client-specific
-// Codex directory and the cross-client .agents directory. Destinations are
-// canonicalized and preflighted before any write to avoid partial upgrades when
-// one existing bundle requires --force.
-func InstallDefaultSkills(force bool) (SkillInstallResult, error) {
- bases, err := DefaultSkillBases()
- if err != nil {
- return SkillInstallResult{}, err
- }
- var targets []string
- seen := make(map[string]struct{}, len(bases))
- for _, base := range bases {
- target, err := resolveSkillTarget(base)
- if err != nil {
- return SkillInstallResult{}, err
- }
- key, err := canonicalPathKey(target)
- if err != nil {
- return SkillInstallResult{}, err
- }
- if _, exists := seen[key]; exists {
- continue
- }
- seen[key] = struct{}{}
- targets = append(targets, target)
- }
- for _, target := range targets {
- if err := preflightSkillTarget(target, force); err != nil {
- return SkillInstallResult{}, err
- }
- }
- var combined SkillInstallResult
- for _, target := range targets {
- installed, err := writeSkillTarget(target)
- if err != nil {
- return SkillInstallResult{}, err
- }
- if combined.Path == "" {
- combined.Path = installed.Path
- combined.Files = installed.Files
- }
- combined.Paths = append(combined.Paths, installed.Path)
- }
- return combined, nil
-}
-
-func resolveSkillTarget(base string) (string, error) {
- absBase, err := filepath.Abs(base)
- if err != nil {
- return "", fmt.Errorf("resolve skills directory: %w", err)
- }
- return filepath.Join(absBase, "hop"), nil
-}
-
-func canonicalPathKey(path string) (string, error) {
- if err := preflightSkillAncestors(path); err != nil {
- return "", err
- }
- current := filepath.Clean(path)
- var suffix []string
- for {
- resolved, err := filepath.EvalSymlinks(current)
- if err == nil {
- for index := len(suffix) - 1; index >= 0; index-- {
- resolved = filepath.Join(resolved, suffix[index])
- }
- return filepath.Clean(resolved), nil
- }
- if !os.IsNotExist(err) {
- return "", fmt.Errorf("canonicalize skill destination: %w", err)
- }
- parent := filepath.Dir(current)
- if parent == current {
- return filepath.Clean(path), nil
- }
- suffix = append(suffix, filepath.Base(current))
- current = parent
- }
-}
-
-func preflightSkillTarget(target string, force bool) error {
- if err := preflightSkillAncestors(target); err != nil {
- return err
- }
- if info, err := os.Lstat(target); err == nil {
- if info.Mode()&os.ModeSymlink != 0 {
- return fmt.Errorf("refusing to install through symlink %s", target)
- }
- if !info.IsDir() {
- return fmt.Errorf("skill target exists and is not a directory: %s", target)
- }
- if !force {
- return fmt.Errorf("Hop skill already exists at %s; pass --force to update it", target)
- }
- } else if !os.IsNotExist(err) {
- return fmt.Errorf("inspect skill target: %w", err)
- }
- return fs.WalkDir(hopskill.Files, ".", func(path string, entry fs.DirEntry, walkErr error) error {
- if walkErr != nil || path == "." {
- return walkErr
- }
- destination := filepath.Join(target, filepath.FromSlash(path))
- info, err := os.Lstat(destination)
- if os.IsNotExist(err) {
- return nil
- }
- if err != nil {
- return fmt.Errorf("inspect skill destination %s: %w", destination, err)
- }
- if info.Mode()&os.ModeSymlink != 0 {
- return fmt.Errorf("refusing to overwrite symlink %s", destination)
- }
- if entry.IsDir() && !info.IsDir() {
- return fmt.Errorf("skill directory destination is not a directory: %s", destination)
- }
- if !entry.IsDir() && info.IsDir() {
- return fmt.Errorf("skill file destination is a directory: %s", destination)
- }
- return nil
- })
-}
-
-// preflightSkillAncestors finds the nearest existing path component before any
-// writes. This distinguishes an ordinary missing destination from a dangling
-// parent symlink, which filepath.EvalSymlinks otherwise reports as os.ErrNotExist.
-func preflightSkillAncestors(path string) error {
- target := filepath.Clean(path)
- current := target
- for {
- info, err := os.Lstat(current)
- if err == nil {
- if info.Mode()&os.ModeSymlink != 0 {
- resolved, resolveErr := filepath.EvalSymlinks(current)
- if resolveErr != nil {
- return fmt.Errorf("resolve skill destination ancestor %s: %w", current, resolveErr)
- }
- resolvedInfo, statErr := os.Stat(resolved)
- if statErr != nil {
- return fmt.Errorf("inspect resolved skill destination ancestor %s: %w", current, statErr)
- }
- if current != target && !resolvedInfo.IsDir() {
- return fmt.Errorf("skill destination ancestor is not a directory: %s", current)
- }
- } else if current != target && !info.IsDir() {
- return fmt.Errorf("skill destination ancestor is not a directory: %s", current)
- }
- return nil
- }
- if !os.IsNotExist(err) {
- return fmt.Errorf("inspect skill destination ancestor %s: %w", current, err)
- }
- parent := filepath.Dir(current)
- if parent == current {
- return nil
- }
- current = parent
- }
-}
-
-func writeSkillTarget(target string) (SkillInstallResult, error) {
- if err := os.MkdirAll(target, 0o755); err != nil {
- return SkillInstallResult{}, fmt.Errorf("create skill target: %w", err)
- }
-
- result := SkillInstallResult{Path: target, Paths: []string{target}}
- err := fs.WalkDir(hopskill.Files, ".", func(path string, entry fs.DirEntry, walkErr error) error {
- if walkErr != nil {
- return walkErr
- }
- if path == "." {
- return nil
- }
- destination := filepath.Join(target, filepath.FromSlash(path))
- if entry.IsDir() {
- return os.MkdirAll(destination, 0o755)
- }
- if info, statErr := os.Lstat(destination); statErr == nil && info.Mode()&os.ModeSymlink != 0 {
- return fmt.Errorf("refusing to overwrite symlink %s", destination)
- } else if statErr != nil && !os.IsNotExist(statErr) {
- return statErr
- }
- contents, readErr := hopskill.Files.ReadFile(path)
- if readErr != nil {
- return readErr
- }
- if writeErr := os.WriteFile(destination, contents, 0o644); writeErr != nil {
- return writeErr
- }
- result.Files = append(result.Files, path)
- return nil
- })
- if err != nil {
- return SkillInstallResult{}, fmt.Errorf("install Hop skill: %w", err)
- }
- return result, nil
-}
diff --git a/internal/hop/skill_test.go b/internal/hop/skill_test.go
deleted file mode 100644
index 3c5348e..0000000
--- a/internal/hop/skill_test.go
+++ /dev/null
@@ -1,230 +0,0 @@
-package hop
-
-import (
- "bytes"
- "os"
- "path/filepath"
- "runtime"
- "strings"
- "testing"
-)
-
-func TestInstallSkillBundle(t *testing.T) {
- base := t.TempDir()
- result, err := InstallSkill(base, false)
- if err != nil {
- t.Fatal(err)
- }
- if result.Path != filepath.Join(base, "hop") {
- t.Fatalf("skill path = %s", result.Path)
- }
- if len(result.Paths) != 1 || result.Paths[0] != result.Path {
- t.Fatalf("skill paths = %#v, want only %s", result.Paths, result.Path)
- }
- wantFiles := []string{"SKILL.md", "agents/openai.yaml", "references/protocol.md"}
- for _, relative := range wantFiles {
- contents, err := os.ReadFile(filepath.Join(result.Path, relative))
- if err != nil {
- t.Fatalf("read installed %s: %v", relative, err)
- }
- if len(contents) == 0 {
- t.Fatalf("installed %s is empty", relative)
- }
- }
- metadata, err := os.ReadFile(filepath.Join(result.Path, "agents", "openai.yaml"))
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(string(metadata), "allow_implicit_invocation: true") {
- t.Fatal("installed OpenAI metadata does not permit implicit invocation")
- }
- skill, err := os.ReadFile(filepath.Join(result.Path, "SKILL.md"))
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(string(skill), "Auto-accept by default") {
- t.Fatal("installed skill does not enable automatic acceptance")
- }
- if _, err := InstallSkill(base, false); err == nil || !strings.Contains(err.Error(), "already exists") {
- t.Fatalf("second install error = %v, want existing-skill error", err)
- }
- if err := os.WriteFile(filepath.Join(result.Path, "SKILL.md"), []byte("tampered"), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := InstallSkill(base, true); err != nil {
- t.Fatal(err)
- }
- contents, err := os.ReadFile(filepath.Join(result.Path, "SKILL.md"))
- if err != nil {
- t.Fatal(err)
- }
- if string(contents) == "tampered" || !strings.Contains(string(contents), "name: hop") {
- t.Fatal("forced skill update did not restore the embedded bundle")
- }
-}
-
-func TestInstallDefaultSkillsWritesSharedAndCodexBundles(t *testing.T) {
- home := t.TempDir()
- codexHome := filepath.Join(home, "codex-home")
- t.Setenv("HOME", home)
- t.Setenv("USERPROFILE", home)
- t.Setenv("CODEX_HOME", codexHome)
-
- result, err := InstallDefaultSkills(false)
- if err != nil {
- t.Fatal(err)
- }
- codexTarget := filepath.Join(codexHome, "skills", "hop")
- sharedTarget := filepath.Join(home, ".agents", "skills", "hop")
- if result.Path != codexTarget {
- t.Fatalf("legacy primary path = %s, want %s", result.Path, codexTarget)
- }
- wantPaths := []string{codexTarget, sharedTarget}
- if len(result.Paths) != len(wantPaths) {
- t.Fatalf("default paths = %#v, want %#v", result.Paths, wantPaths)
- }
- for index, want := range wantPaths {
- if result.Paths[index] != want {
- t.Fatalf("default path %d = %s, want %s", index, result.Paths[index], want)
- }
- }
- codexSkill, err := os.ReadFile(filepath.Join(codexTarget, "SKILL.md"))
- if err != nil {
- t.Fatal(err)
- }
- sharedSkill, err := os.ReadFile(filepath.Join(sharedTarget, "SKILL.md"))
- if err != nil {
- t.Fatal(err)
- }
- if !bytes.Equal(codexSkill, sharedSkill) {
- t.Fatal("default skill bundles differ")
- }
-}
-
-func TestInstallDefaultSkillsPreflightsAndDeduplicates(t *testing.T) {
- t.Run("preflight", func(t *testing.T) {
- home := t.TempDir()
- codexHome := filepath.Join(home, "codex-home")
- t.Setenv("HOME", home)
- t.Setenv("USERPROFILE", home)
- t.Setenv("CODEX_HOME", codexHome)
- codexTarget := filepath.Join(codexHome, "skills", "hop")
- if err := os.MkdirAll(codexTarget, 0o755); err != nil {
- t.Fatal(err)
- }
- unknown := filepath.Join(codexTarget, "user-note.txt")
- if err := os.WriteFile(unknown, []byte("keep me"), 0o644); err != nil {
- t.Fatal(err)
- }
- if _, err := InstallDefaultSkills(false); err == nil || !strings.Contains(err.Error(), "--force") {
- t.Fatalf("default preflight error = %v", err)
- }
- sharedTarget := filepath.Join(home, ".agents", "skills", "hop")
- if _, err := os.Stat(sharedTarget); !os.IsNotExist(err) {
- t.Fatalf("partial shared install exists after preflight failure: %v", err)
- }
- if _, err := InstallDefaultSkills(true); err != nil {
- t.Fatal(err)
- }
- if contents, err := os.ReadFile(unknown); err != nil || string(contents) != "keep me" {
- t.Fatalf("force install removed unknown file: %q, %v", string(contents), err)
- }
- })
-
- t.Run("nested symlink", func(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("symlink creation requires additional privileges on Windows")
- }
- home := t.TempDir()
- codexHome := filepath.Join(home, "codex-home")
- t.Setenv("HOME", home)
- t.Setenv("USERPROFILE", home)
- t.Setenv("CODEX_HOME", codexHome)
- sharedTarget := filepath.Join(home, ".agents", "skills", "hop")
- if err := os.MkdirAll(sharedTarget, 0o755); err != nil {
- t.Fatal(err)
- }
- outside := filepath.Join(home, "outside")
- if err := os.WriteFile(outside, []byte("do not overwrite"), 0o644); err != nil {
- t.Fatal(err)
- }
- if err := os.Symlink(outside, filepath.Join(sharedTarget, "SKILL.md")); err != nil {
- t.Fatal(err)
- }
- if _, err := InstallDefaultSkills(true); err == nil || !strings.Contains(err.Error(), "symlink") {
- t.Fatalf("nested symlink preflight error = %v", err)
- }
- codexTarget := filepath.Join(codexHome, "skills", "hop")
- if _, err := os.Stat(codexTarget); !os.IsNotExist(err) {
- t.Fatalf("partial Codex install exists after nested preflight failure: %v", err)
- }
- if contents, err := os.ReadFile(outside); err != nil || string(contents) != "do not overwrite" {
- t.Fatalf("nested symlink target changed: %q, %v", contents, err)
- }
- })
-
- t.Run("dangling parent symlink", func(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("symlink creation requires additional privileges on Windows")
- }
- home := t.TempDir()
- codexHome := filepath.Join(home, "codex-home")
- t.Setenv("HOME", home)
- t.Setenv("USERPROFILE", home)
- t.Setenv("CODEX_HOME", codexHome)
- if err := os.Symlink(filepath.Join(home, "missing-agents-home"), filepath.Join(home, ".agents")); err != nil {
- t.Fatal(err)
- }
- if _, err := InstallDefaultSkills(true); err == nil || !strings.Contains(err.Error(), "ancestor") {
- t.Fatalf("dangling parent preflight error = %v", err)
- }
- codexTarget := filepath.Join(codexHome, "skills", "hop")
- if _, err := os.Stat(codexTarget); !os.IsNotExist(err) {
- t.Fatalf("partial Codex install exists after dangling-parent failure: %v", err)
- }
- })
-
- t.Run("deduplicate", func(t *testing.T) {
- home := t.TempDir()
- t.Setenv("HOME", home)
- t.Setenv("USERPROFILE", home)
- t.Setenv("CODEX_HOME", filepath.Join(home, ".agents"))
- result, err := InstallDefaultSkills(false)
- if err != nil {
- t.Fatal(err)
- }
- if len(result.Paths) != 1 {
- t.Fatalf("aliased default paths were not deduplicated: %#v", result.Paths)
- }
- })
-}
-
-func TestSkillCLIWorksOutsideHopProject(t *testing.T) {
- var stdout, stderr strings.Builder
- home := t.TempDir()
- t.Setenv("HOME", home)
- t.Setenv("USERPROFILE", home)
- t.Setenv("CODEX_HOME", filepath.Join(home, "codex-home"))
- base := filepath.Join(home, "custom-skills")
- code := RunCLI([]string{"skill", "install", "--path", base, "--json"}, &stdout, &stderr)
- if code != 0 {
- t.Fatalf("skill install exited %d: %s", code, stderr.String())
- }
- if !strings.Contains(stdout.String(), filepath.Join(base, "hop")) {
- t.Fatalf("skill install JSON omitted target: %s", stdout.String())
- }
- for _, unexpected := range []string{
- filepath.Join(home, ".agents", "skills", "hop"),
- filepath.Join(home, "codex-home", "skills", "hop"),
- } {
- if _, err := os.Stat(unexpected); !os.IsNotExist(err) {
- t.Fatalf("explicit --path also installed default target %s: %v", unexpected, err)
- }
- }
- stdout.Reset()
- stderr.Reset()
- code = RunCLI([]string{"skill", "print"}, &stdout, &stderr)
- if code != 0 || !strings.Contains(stdout.String(), "Capture the current prompt first") {
- t.Fatalf("skill print exited %d\nstdout: %s\nstderr: %s", code, stdout.String(), stderr.String())
- }
-}
diff --git a/internal/hop/util.go b/internal/hop/util.go
deleted file mode 100644
index cf0894a..0000000
--- a/internal/hop/util.go
+++ /dev/null
@@ -1,199 +0,0 @@
-package hop
-
-import (
- "bytes"
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "errors"
- "fmt"
- "io/fs"
- "os"
- "os/exec"
- "path/filepath"
- "sort"
- "strings"
- "sync"
- "time"
-)
-
-const maxRecordedOutput = 128 * 1024
-
-var ErrNotHopProject = errors.New("not inside a Hop project")
-
-func FindHopRoot(start string) (string, error) {
- if configured := os.Getenv("HOP_ROOT"); configured != "" {
- return requireHopRoot(configured)
- }
-
- abs, err := filepath.Abs(start)
- if err != nil {
- return "", err
- }
- info, err := os.Stat(abs)
- if err != nil {
- return "", err
- }
- if !info.IsDir() {
- abs = filepath.Dir(abs)
- }
-
- for dir := abs; ; dir = filepath.Dir(dir) {
- if _, err := os.Stat(filepath.Join(dir, ".hop", "hop.db")); err == nil {
- return dir, nil
- } else if !errors.Is(err, fs.ErrNotExist) {
- return "", err
- }
- parent := filepath.Dir(dir)
- if parent == dir {
- break
- }
- }
- return "", fmt.Errorf("%w; run 'hop init' first", ErrNotHopProject)
-}
-
-func requireHopRoot(root string) (string, error) {
- abs, err := filepath.Abs(root)
- if err != nil {
- return "", err
- }
- if _, err := os.Stat(filepath.Join(abs, ".hop", "hop.db")); err != nil {
- return "", fmt.Errorf("HOP_ROOT does not contain .hop/hop.db: %s", abs)
- }
- return abs, nil
-}
-
-func digestState(state State, parents []Parent) (string, error) {
- copyState := state
- copyState.Digest = ""
- copyState.Parents = append([]Parent(nil), parents...)
- sort.Slice(copyState.Parents, func(i, j int) bool {
- if copyState.Parents[i].Order != copyState.Parents[j].Order {
- return copyState.Parents[i].Order < copyState.Parents[j].Order
- }
- if copyState.Parents[i].Role != copyState.Parents[j].Role {
- return copyState.Parents[i].Role < copyState.Parents[j].Role
- }
- return copyState.Parents[i].StateID < copyState.Parents[j].StateID
- })
- payload, err := json.Marshal(copyState)
- if err != nil {
- return "", err
- }
- sum := sha256.Sum256(payload)
- return "sha256:" + hex.EncodeToString(sum[:]), nil
-}
-
-type commandResult struct {
- ExitCode int
- Output string
-}
-
-func runWorkspaceCommand(ctx context.Context, workspace string, env []string, argv []string) (commandResult, error) {
- if len(argv) == 0 {
- return commandResult{}, fmt.Errorf("no command provided")
- }
- cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
- cmd.Dir = workspace
- cmd.Env = append(os.Environ(), env...)
- var output bytes.Buffer
- cmd.Stdout = &output
- cmd.Stderr = &output
- err := cmd.Run()
- result := commandResult{Output: truncateRecordedOutput(output.String())}
- if err == nil {
- return result, nil
- }
- var exitErr *exec.ExitError
- if errors.As(err, &exitErr) {
- result.ExitCode = exitErr.ExitCode()
- return result, nil
- }
- return result, err
-}
-
-func truncateRecordedOutput(output string) string {
- if len(output) <= maxRecordedOutput {
- return output
- }
- const marker = "\n… output truncated by Hop …\n"
- keep := maxRecordedOutput - len(marker)
- return output[:keep/2] + marker + output[len(output)-(keep-keep/2):]
-}
-
-func acquireProjectLock(ctx context.Context, root, name string) (func(), error) {
- path := filepath.Join(root, ".hop", name+".lock")
- return acquireFileLock(ctx, path, "Hop "+name)
-}
-
-func acquireFileLock(ctx context.Context, path, description string) (func(), error) {
- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
- return nil, fmt.Errorf("create %s lock directory: %w", description, err)
- }
- for {
- file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
- if err == nil {
- _, _ = file.WriteString(fmt.Sprintf("pid=%d\n", os.Getpid()))
- _ = file.Close()
- stopHeartbeat := make(chan struct{})
- go func() {
- ticker := time.NewTicker(30 * time.Second)
- defer ticker.Stop()
- for {
- select {
- case <-ticker.C:
- now := time.Now()
- _ = os.Chtimes(path, now, now)
- case <-stopHeartbeat:
- return
- }
- }
- }()
- var once sync.Once
- return func() {
- once.Do(func() {
- close(stopHeartbeat)
- _ = os.Remove(path)
- })
- }, nil
- }
- if !errors.Is(err, fs.ErrExist) {
- return nil, err
- }
- if info, statErr := os.Stat(path); statErr == nil && time.Since(info.ModTime()) > 5*time.Minute {
- _ = os.Remove(path)
- continue
- }
- select {
- case <-ctx.Done():
- return nil, fmt.Errorf("wait for %s lock: %w", description, ctx.Err())
- case <-time.After(50 * time.Millisecond):
- }
- }
-}
-
-func repositoryInitLockPath(path string) (string, error) {
- cache, err := os.UserCacheDir()
- if err != nil {
- return "", fmt.Errorf("locate user cache for repository initialization lock: %w", err)
- }
- canonical, err := filepath.EvalSymlinks(path)
- if err != nil {
- return "", fmt.Errorf("resolve repository initialization path: %w", err)
- }
- digest := sha256.Sum256([]byte(filepath.Clean(canonical)))
- return filepath.Join(cache, "hop", "locks", "repository-"+hex.EncodeToString(digest[:])+".lock"), nil
-}
-
-func shellQuote(argv []string) string {
- quoted := make([]string, len(argv))
- for i, arg := range argv {
- if arg != "" && !strings.ContainsAny(arg, " \t\n\"'\\$`;&|<>()[]{}*?!") {
- quoted[i] = arg
- continue
- }
- quoted[i] = "'" + strings.ReplaceAll(arg, "'", "'\"'\"'") + "'"
- }
- return strings.Join(quoted, " ")
-}
diff --git a/scripts/install.ps1 b/scripts/install.ps1
deleted file mode 100644
index fc25767..0000000
--- a/scripts/install.ps1
+++ /dev/null
@@ -1,87 +0,0 @@
-[CmdletBinding()]
-param(
- [string]$GiteaUrl = $(if ($env:HOP_GITEA_URL) { $env:HOP_GITEA_URL } else { "https://githop.xyz" }),
- [string]$Repository = $(if ($env:HOP_REPOSITORY) { $env:HOP_REPOSITORY } else { "GnosysLabs/Hop" }),
- [string]$Version = $(if ($env:HOP_VERSION) { $env:HOP_VERSION } else { "latest" }),
- [string]$InstallDir = $(if ($env:HOP_INSTALL_DIR) { $env:HOP_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA "Programs\Hop" }),
- [switch]$SkipSkill,
- [switch]$SkipPath
-)
-
-$ErrorActionPreference = "Stop"
-$GiteaUrl = $GiteaUrl.TrimEnd("/")
-
-switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()) {
- "X64" { $arch = "amd64" }
- "Arm64" { $arch = "arm64" }
- default { throw "Unsupported Windows 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" }
-} else {
- $tag = if ($Version.StartsWith("v")) { $Version } else { "v$Version" }
-}
-if ($tag -notmatch '^[A-Za-z0-9._-]+$') {
- throw "Release API returned an unsafe tag: $tag"
-}
-$releaseUrl = "$GiteaUrl/$Repository/releases/download/$tag"
-
-$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("hop-install-" + [guid]::NewGuid())
-New-Item -ItemType Directory -Path $tempDir | Out-Null
-try {
- $archivePath = Join-Path $tempDir $asset
- $checksumsPath = Join-Path $tempDir "checksums.txt"
- Write-Host "Downloading $asset..."
- Invoke-WebRequest -UseBasicParsing -Uri "$releaseUrl/$asset" -OutFile $archivePath
- Invoke-WebRequest -UseBasicParsing -Uri "$releaseUrl/checksums.txt" -OutFile $checksumsPath
-
- $escapedAsset = [regex]::Escape($asset)
- $checksumLine = Get-Content $checksumsPath | Where-Object { $_ -match "^([a-fA-F0-9]{64})\s+\*?$escapedAsset$" } | Select-Object -First 1
- if (-not $checksumLine) { throw "checksums.txt does not contain $asset" }
- $expected = ([regex]::Match($checksumLine, "^[a-fA-F0-9]{64}")).Value.ToLowerInvariant()
- $actual = (Get-FileHash -Algorithm SHA256 $archivePath).Hash.ToLowerInvariant()
- if ($actual -ne $expected) { throw "Checksum verification failed for $asset" }
-
- $extractPath = Join-Path $tempDir "archive"
- Expand-Archive -Path $archivePath -DestinationPath $extractPath
- $binary = Join-Path $extractPath "hop.exe"
- if (-not (Test-Path $binary)) { throw "Release archive does not contain hop.exe" }
-
- New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
- $installedBinary = Join-Path $InstallDir "hop.exe"
- Copy-Item -Force $binary $installedBinary
-
- if (-not $SkipPath) {
- $userPath = [Environment]::GetEnvironmentVariable("Path", "User")
- $pathEntries = @($userPath -split ";" | Where-Object { $_ })
- if ($pathEntries -notcontains $InstallDir) {
- $newUserPath = (($pathEntries + $InstallDir) -join ";")
- [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User")
- Write-Host "Added $InstallDir to your user PATH."
- }
- if (($env:Path -split ";") -notcontains $InstallDir) {
- $env:Path = "$InstallDir;$env:Path"
- }
- }
-
- if (-not $SkipSkill) {
- & $installedBinary skill install --force
- if ($LASTEXITCODE -ne 0) {
- throw "Hop skill installation failed with exit code $LASTEXITCODE"
- }
- }
- Write-Host "Installed $(& $installedBinary version)"
- Write-Host "Binary: $installedBinary"
- if (-not $SkipSkill) {
- Write-Host "Restart any open agent application, then use it normally in any Git repository."
- }
-} finally {
- Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $tempDir
-}
diff --git a/scripts/install.sh b/scripts/install.sh
deleted file mode 100644
index a12d94b..0000000
--- a/scripts/install.sh
+++ /dev/null
@@ -1,107 +0,0 @@
-#!/bin/sh
-set -eu
-
-gitea_url=${HOP_GITEA_URL:-https://githop.xyz}
-gitea_url=${gitea_url%/}
-repository=${HOP_REPOSITORY:-GnosysLabs/Hop}
-requested_version=${HOP_VERSION:-latest}
-install_dir=${HOP_INSTALL_DIR:-"$HOME/.local/bin"}
-install_skill=${HOP_INSTALL_SKILL:-1}
-modify_path=${HOP_MODIFY_PATH:-1}
-
-fail() {
- printf 'hop installer: %s\n' "$*" >&2
- exit 1
-}
-
-command -v curl >/dev/null 2>&1 || fail "curl is required"
-command -v tar >/dev/null 2>&1 || fail "tar is required"
-
-case $(uname -s) in
- Darwin) os=darwin ;;
- Linux) os=linux ;;
- *) fail "unsupported operating system: $(uname -s)" ;;
-esac
-
-case $(uname -m) in
- x86_64 | amd64) arch=amd64 ;;
- arm64 | aarch64) arch=arm64 ;;
- *) fail "unsupported architecture: $(uname -m)" ;;
-esac
-
-asset="hop_${os}_${arch}.tar.gz"
-if [ "$requested_version" = latest ]; then
- # Gitea's /releases/latest endpoint omits prereleases. Hop is distributed as
- # an alpha today, so select the newest published release from the list API.
- latest_json=$(curl -fL --retry 3 --proto '=https' --tlsv1.2 \
- "$gitea_url/api/v1/repos/$repository/releases?draft=false&page=1&limit=1")
- tag=$(printf '%s' "$latest_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
- [ -n "$tag" ] || fail "could not determine the latest published release"
-else
- case "$requested_version" in
- v*) tag=$requested_version ;;
- *) tag="v${requested_version}" ;;
- esac
-fi
-case "$tag" in
- *[!A-Za-z0-9._-]*) fail "release API returned an unsafe tag: $tag" ;;
-esac
-release_url="$gitea_url/$repository/releases/download/${tag}"
-
-tmp_dir=$(mktemp -d 2>/dev/null || mktemp -d -t hop-install)
-cleanup() {
- rm -rf "$tmp_dir"
-}
-trap cleanup EXIT HUP INT TERM
-
-printf 'Downloading %s...\n' "$asset"
-curl -fL --retry 3 --proto '=https' --tlsv1.2 \
- -o "$tmp_dir/$asset" "$release_url/$asset"
-curl -fL --retry 3 --proto '=https' --tlsv1.2 \
- -o "$tmp_dir/checksums.txt" "$release_url/checksums.txt"
-
-expected=$(awk -v name="$asset" '$2 == name || $2 == "*" name { print $1; exit }' "$tmp_dir/checksums.txt")
-[ -n "$expected" ] || fail "checksums.txt does not contain $asset"
-if command -v sha256sum >/dev/null 2>&1; then
- actual=$(sha256sum "$tmp_dir/$asset" | awk '{print $1}')
-elif command -v shasum >/dev/null 2>&1; then
- actual=$(shasum -a 256 "$tmp_dir/$asset" | awk '{print $1}')
-else
- fail "sha256sum or shasum is required to verify the download"
-fi
-[ "$actual" = "$expected" ] || fail "checksum verification failed for $asset"
-
-tar -xzf "$tmp_dir/$asset" -C "$tmp_dir"
-[ -f "$tmp_dir/hop" ] || fail "release archive does not contain hop"
-mkdir -p "$install_dir"
-cp "$tmp_dir/hop" "$install_dir/hop"
-chmod 0755 "$install_dir/hop"
-
-case ":${PATH:-}:" in
- *":$install_dir:"*) ;;
- *)
- if [ "$modify_path" = 1 ] && [ "$install_dir" = "$HOME/.local/bin" ]; then
- case ${SHELL:-} in
- */zsh) profile="$HOME/.zprofile" ;;
- *) profile="$HOME/.profile" ;;
- esac
- path_line='export PATH="$HOME/.local/bin:$PATH"'
- if ! grep -F "$path_line" "$profile" >/dev/null 2>&1; then
- printf '\n%s\n' "$path_line" >>"$profile"
- printf 'Added ~/.local/bin to PATH in %s.\n' "$profile"
- fi
- else
- printf 'Add %s to PATH before opening a new terminal.\n' "$install_dir" >&2
- fi
- ;;
-esac
-
-if [ "$install_skill" = 1 ]; then
- "$install_dir/hop" skill install --force
-fi
-
-printf 'Installed %s\n' "$("$install_dir/hop" version)"
-printf 'Binary: %s\n' "$install_dir/hop"
-if [ "$install_skill" = 1 ]; then
- printf 'Restart any open agent application, then use it normally in any Git repository.\n'
-fi
diff --git a/scripts/release-local.sh b/scripts/release-local.sh
deleted file mode 100755
index 20bb7d6..0000000
--- a/scripts/release-local.sh
+++ /dev/null
@@ -1,105 +0,0 @@
-#!/bin/sh
-set -eu
-
-fail() {
- printf 'hop release: %s\n' "$*" >&2
- exit 1
-}
-
-usage() {
- cat <<'EOF'
-Usage: scripts/release-local.sh --snapshot
- scripts/release-local.sh --publish
-
---snapshot Test and build all release archives locally without uploading.
---publish Test, build, and upload a draft release to githop.xyz.
- Requires a clean signed tag, LICENSE, and GITEA_TOKEN.
-EOF
-}
-
-[ "$#" -eq 1 ] || { usage >&2; exit 2; }
-mode=$1
-case "$mode" in
- --snapshot | --publish) ;;
- *) usage >&2; exit 2 ;;
-esac
-
-root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
-cd "$root"
-
-for command in curl git go tar; do
- command -v "$command" >/dev/null 2>&1 || fail "$command is required"
-done
-
-if [ "$mode" = --publish ]; then
- [ -f LICENSE ] || fail "LICENSE is required before publishing"
- [ -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"
- case "$tag" in
- v[0-9]*) ;;
- *) fail "release tag must start with v followed by a number" ;;
- esac
- [ "$(git cat-file -t "$tag")" = tag ] || fail "$tag must be an annotated tag"
- git verify-tag "$tag" >/dev/null 2>&1 || fail "$tag must have a valid signature"
- git ls-remote --exit-code origin "refs/tags/$tag" >/dev/null 2>&1 ||
- fail "$tag must be pushed to origin before publishing"
-fi
-
-printf 'Running local release validation...\n'
-go test -race ./...
-go vet ./...
-sh -n scripts/install.sh
-sh scripts/test-install.sh
-git diff --check
-
-goreleaser_version=2.17.0
-case "$(uname -s)/$(uname -m)" in
- Darwin/arm64)
- archive=goreleaser_Darwin_arm64.tar.gz
- expected=58912a80159199c0fd5c8484e4c868bf87414129655d6d87cd1cd84ee645736c
- ;;
- Darwin/x86_64)
- archive=goreleaser_Darwin_x86_64.tar.gz
- expected=f37e89fb844ddfd23cffb97e30d91f972c42da68232a676bfba2beacea300543
- ;;
- Linux/arm64 | Linux/aarch64)
- archive=goreleaser_Linux_arm64.tar.gz
- expected=75f93fc0e25d10d8535ffd0e4abcf39d6784a2467ba453d479ae513729a9ebbf
- ;;
- Linux/x86_64 | Linux/amd64)
- archive=goreleaser_Linux_x86_64.tar.gz
- expected=dde10e2d5a13cef969c0eec00c74f359c0ac306d702b1bd291ad9337b4e54c1d
- ;;
- *) fail "unsupported release host: $(uname -s)/$(uname -m)" ;;
-esac
-
-tmp_dir=$(mktemp -d 2>/dev/null || mktemp -d -t hop-release)
-cleanup() {
- rm -rf "$tmp_dir"
-}
-trap cleanup EXIT HUP INT TERM
-
-printf 'Downloading verified GoReleaser %s...\n' "$goreleaser_version"
-curl -fsSL --retry 3 --proto '=https' --tlsv1.2 \
- -o "$tmp_dir/$archive" \
- "https://github.com/goreleaser/goreleaser/releases/download/v${goreleaser_version}/${archive}"
-if command -v sha256sum >/dev/null 2>&1; then
- actual=$(sha256sum "$tmp_dir/$archive" | awk '{print $1}')
-elif command -v shasum >/dev/null 2>&1; then
- actual=$(shasum -a 256 "$tmp_dir/$archive" | awk '{print $1}')
-else
- fail "sha256sum or shasum is required"
-fi
-[ "$actual" = "$expected" ] || fail "GoReleaser checksum verification failed"
-tar -xzf "$tmp_dir/$archive" -C "$tmp_dir" goreleaser
-
-"$tmp_dir/goreleaser" check
-if [ "$mode" = --snapshot ]; then
- "$tmp_dir/goreleaser" release --snapshot --clean
- printf 'Local snapshot complete: %s/dist\n' "$root"
-else
- "$tmp_dir/goreleaser" release --clean
- printf 'Draft release uploaded to https://githop.xyz/GnosysLabs/Hop/releases\n'
-fi
diff --git a/scripts/test-install.ps1 b/scripts/test-install.ps1
deleted file mode 100644
index c20125e..0000000
--- a/scripts/test-install.ps1
+++ /dev/null
@@ -1,140 +0,0 @@
-[CmdletBinding()]
-param()
-
-$ErrorActionPreference = "Stop"
-$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
-$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("hop-installer-test-" + [guid]::NewGuid())
-$fixtures = Join-Path $tempDir "fixtures"
-$payload = Join-Path $tempDir "payload"
-$installDir = Join-Path $tempDir "install"
-$testHome = Join-Path $tempDir "home"
-$testCodexHome = Join-Path $testHome ".codex"
-$testArch = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -eq "Arm64") { "arm64" } else { "amd64" }
-$asset = "hop_windows_${testArch}.zip"
-$originalHome = $env:HOME
-$originalUserProfile = $env:USERPROFILE
-$originalCodexHome = $env:CODEX_HOME
-
-New-Item -ItemType Directory -Path $tempDir | Out-Null
-New-Item -ItemType Directory -Path $fixtures, $payload, $testHome | Out-Null
-try {
- $binary = Join-Path $payload "hop.exe"
- & go build -trimpath `
- -ldflags "-X githop.xyz/GnosysLabs/Hop/internal/hop.Version=9.9.9-installer-test" `
- -o $binary (Join-Path $root "cmd/hop")
- if ($LASTEXITCODE -ne 0) { throw "Could not build installer test binary" }
-
- $archive = Join-Path $fixtures $asset
- Compress-Archive -Path $binary -DestinationPath $archive
- $hash = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLowerInvariant()
- "$hash $asset" | Set-Content -Encoding ascii (Join-Path $fixtures "checksums.txt")
-
- function Invoke-RestMethod {
- [CmdletBinding()]
- param([Parameter(Mandatory)][string]$Uri)
- if (-not $Uri.EndsWith("/releases?draft=false&page=1&limit=1")) {
- throw "Unexpected installer API URL: $Uri"
- }
- return ,([pscustomobject]@{
- tag_name = "v9.9.9-installer-test"
- prerelease = $true
- draft = $false
- })
- }
-
- function Invoke-WebRequest {
- [CmdletBinding()]
- param(
- [switch]$UseBasicParsing,
- [Parameter(Mandatory)][string]$Uri,
- [Parameter(Mandatory)][string]$OutFile
- )
- if ($Uri.EndsWith("/checksums.txt")) {
- Copy-Item (Join-Path $fixtures "checksums.txt") $OutFile
- } elseif ($Uri.EndsWith("/$asset")) {
- Copy-Item (Join-Path $fixtures $asset) $OutFile
- } else {
- throw "Unexpected installer asset URL: $Uri"
- }
- }
-
- $env:HOME = $testHome
- $env:USERPROFILE = $testHome
- $env:CODEX_HOME = $testCodexHome
-
- & (Join-Path $root "scripts/install.ps1") `
- -GiteaUrl "https://gitea.test" `
- -Repository "GnosysLabs/Hop" `
- -InstallDir $installDir `
- -SkipPath
-
- $installed = Join-Path $installDir "hop.exe"
- if (-not (Test-Path $installed)) { throw "Installer did not install hop.exe" }
- $version = & $installed version
- if ($LASTEXITCODE -ne 0 -or $version -ne "hop 9.9.9-installer-test") {
- throw "Unexpected installed version: $version"
- }
-
- $sharedBundle = Join-Path $testHome ".agents\skills\hop"
- $codexBundle = Join-Path $testCodexHome "skills\hop"
- $sharedSkill = Join-Path $sharedBundle "SKILL.md"
- $codexSkill = Join-Path $codexBundle "SKILL.md"
- if (-not (Test-Path -LiteralPath $sharedSkill -PathType Leaf) -or (Get-Item -LiteralPath $sharedSkill).Length -eq 0) {
- throw "Installer did not install the shared Hop skill"
- }
- if (-not (Test-Path -LiteralPath $codexSkill -PathType Leaf) -or (Get-Item -LiteralPath $codexSkill).Length -eq 0) {
- throw "Installer did not install the Codex Hop skill"
- }
-
- function Get-BundleHashes {
- param([Parameter(Mandatory)][string]$BundlePath)
- $hashes = @{}
- Get-ChildItem -LiteralPath $BundlePath -File -Recurse | ForEach-Object {
- $relative = $_.FullName.Substring($BundlePath.Length).TrimStart(
- [System.IO.Path]::DirectorySeparatorChar,
- [System.IO.Path]::AltDirectorySeparatorChar
- )
- $hashes[$relative] = (Get-FileHash -Algorithm SHA256 -LiteralPath $_.FullName).Hash
- }
- return $hashes
- }
-
- $sharedHashes = Get-BundleHashes $sharedBundle
- $codexHashes = Get-BundleHashes $codexBundle
- $sharedFiles = @($sharedHashes.Keys | Sort-Object)
- $codexFiles = @($codexHashes.Keys | Sort-Object)
- if ($sharedFiles.Count -eq 0 -or (Compare-Object -ReferenceObject $sharedFiles -DifferenceObject $codexFiles)) {
- throw "Shared and Codex Hop skill bundles contain different files"
- }
- foreach ($relative in $sharedFiles) {
- if ($sharedHashes[$relative] -ne $codexHashes[$relative]) {
- throw "Shared and Codex Hop skill bundles differ at $relative"
- }
- }
-
- $blockedCodexHome = Join-Path $testHome "blocked-codex"
- $blockedSkills = Join-Path $blockedCodexHome "skills"
- New-Item -ItemType Directory -Path $blockedSkills | Out-Null
- "blocked" | Set-Content -Encoding ascii (Join-Path $blockedSkills "hop")
- $env:CODEX_HOME = $blockedCodexHome
- $installFailed = $false
- try {
- & (Join-Path $root "scripts/install.ps1") `
- -GiteaUrl "https://gitea.test" `
- -Repository "GnosysLabs/Hop" `
- -InstallDir $installDir `
- -SkipPath
- } catch {
- $installFailed = $true
- }
- if (-not $installFailed) {
- throw "Installer did not fail when skill installation failed"
- }
- $env:CODEX_HOME = $testCodexHome
- Write-Host "PowerShell installer smoke test passed."
-} finally {
- $env:HOME = $originalHome
- $env:USERPROFILE = $originalUserProfile
- $env:CODEX_HOME = $originalCodexHome
- Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $tempDir
-}
diff --git a/scripts/test-install.sh b/scripts/test-install.sh
deleted file mode 100644
index 6d654eb..0000000
--- a/scripts/test-install.sh
+++ /dev/null
@@ -1,94 +0,0 @@
-#!/bin/sh
-set -eu
-
-root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
-cd "$root"
-tmp_dir=$(mktemp -d 2>/dev/null || mktemp -d -t hop-installer-test)
-cleanup() {
- rm -rf "$tmp_dir"
-}
-trap cleanup EXIT HUP INT TERM
-
-case $(uname -s) in
- Darwin) os=darwin ;;
- Linux) os=linux ;;
- *) printf 'installer smoke test does not support %s\n' "$(uname -s)" >&2; exit 1 ;;
-esac
-case $(uname -m) in
- x86_64 | amd64) arch=amd64 ;;
- arm64 | aarch64) arch=arm64 ;;
- *) printf 'installer smoke test does not support %s\n' "$(uname -m)" >&2; exit 1 ;;
-esac
-
-asset="hop_${os}_${arch}.tar.gz"
-mkdir -p "$tmp_dir/payload" "$tmp_dir/fixtures" "$tmp_dir/mock-bin" "$tmp_dir/home"
-go build -trimpath \
- -ldflags '-X githop.xyz/GnosysLabs/Hop/internal/hop.Version=9.9.9-installer-test' \
- -o "$tmp_dir/payload/hop" "$root/cmd/hop"
-tar -czf "$tmp_dir/fixtures/$asset" -C "$tmp_dir/payload" hop
-if command -v sha256sum >/dev/null 2>&1; then
- hash=$(sha256sum "$tmp_dir/fixtures/$asset" | awk '{print $1}')
-else
- hash=$(shasum -a 256 "$tmp_dir/fixtures/$asset" | awk '{print $1}')
-fi
-printf '%s %s\n' "$hash" "$asset" >"$tmp_dir/fixtures/checksums.txt"
-
-cat >"$tmp_dir/mock-bin/curl" <<'MOCK_CURL'
-#!/bin/sh
-set -eu
-output=
-url=
-while [ "$#" -gt 0 ]; do
- case "$1" in
- -o) shift; output=$1 ;;
- http://* | https://*) url=$1 ;;
- esac
- shift
-done
-case "$url" in
- */api/v1/repos/*/releases\?draft=false\&page=1\&limit=1)
- printf '[{"tag_name":"v9.9.9-installer-test","prerelease":true,"draft":false}]'
- ;;
- */checksums.txt)
- cp "$HOP_TEST_FIXTURES/checksums.txt" "$output"
- ;;
- */hop_*.tar.gz)
- cp "$HOP_TEST_FIXTURES/${url##*/}" "$output"
- ;;
- *)
- printf 'unexpected installer URL: %s\n' "$url" >&2
- exit 1
- ;;
-esac
-MOCK_CURL
-chmod 0755 "$tmp_dir/mock-bin/curl"
-
-HOME="$tmp_dir/home" \
-CODEX_HOME="$tmp_dir/home/.codex" \
-PATH="$tmp_dir/mock-bin:$PATH" \
-HOP_TEST_FIXTURES="$tmp_dir/fixtures" \
-HOP_GITEA_URL="https://gitea.test" \
-HOP_REPOSITORY="GnosysLabs/Hop" \
-HOP_INSTALL_DIR="$tmp_dir/home/bin" \
-HOP_MODIFY_PATH=0 \
-sh "$root/scripts/install.sh"
-
-version=$($tmp_dir/home/bin/hop version)
-[ "$version" = "hop 9.9.9-installer-test" ] || {
- printf 'unexpected installed version: %s\n' "$version" >&2
- exit 1
-}
-shared_bundle="$tmp_dir/home/.agents/skills/hop"
-codex_bundle="$tmp_dir/home/.codex/skills/hop"
-[ -s "$shared_bundle/SKILL.md" ] || {
- printf 'installer did not install the shared Hop skill\n' >&2
- exit 1
-}
-[ -s "$codex_bundle/SKILL.md" ] || {
- printf 'installer did not install the Codex Hop skill\n' >&2
- exit 1
-}
-if ! diff -r "$shared_bundle" "$codex_bundle" >/dev/null; then
- printf 'shared and Codex Hop skill bundles differ\n' >&2
- exit 1
-fi
diff --git a/services/control-plane/Dockerfile b/services/control-plane/Dockerfile
new file mode 100644
index 0000000..53311a6
--- /dev/null
+++ b/services/control-plane/Dockerfile
@@ -0,0 +1,14 @@
+FROM golang:1.26-alpine AS build
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/hop-control-plane ./cmd/hop-control-plane
+
+FROM alpine:3.23
+RUN apk add --no-cache ca-certificates && addgroup -S hop && adduser -S -G hop hop
+COPY --from=build /out/hop-control-plane /usr/local/bin/hop-control-plane
+USER hop
+EXPOSE 8080
+ENTRYPOINT ["hop-control-plane"]
+
diff --git a/services/control-plane/cmd/hop-control-plane/main.go b/services/control-plane/cmd/hop-control-plane/main.go
new file mode 100644
index 0000000..512b6fd
--- /dev/null
+++ b/services/control-plane/cmd/hop-control-plane/main.go
@@ -0,0 +1,77 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "hopweb/internal/config"
+ "hopweb/internal/gitea"
+ "hopweb/internal/httpapi"
+ "hopweb/internal/store"
+)
+
+func main() {
+ logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
+ cfg, err := config.Load()
+ if err != nil {
+ logger.Error("invalid configuration", "error", err)
+ os.Exit(1)
+ }
+
+ ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer cancel()
+
+ db, err := store.Open(ctx, cfg.DatabaseURL)
+ if err != nil {
+ logger.Error("open database", "error", err)
+ os.Exit(1)
+ }
+ defer db.Close()
+
+ if err := db.Migrate(ctx); err != nil {
+ logger.Error("migrate database", "error", err)
+ os.Exit(1)
+ }
+
+ giteaClient, err := gitea.NewClient(cfg.GiteaBaseURL, cfg.GiteaAPIToken)
+ if err != nil {
+ logger.Error("configure gitea client", "error", err)
+ os.Exit(1)
+ }
+
+ server := &http.Server{
+ Addr: cfg.HTTPAddr,
+ Handler: httpapi.New(db, giteaClient, cfg.AdminToken, cfg.GiteaWebhookSecret, logger),
+ ReadHeaderTimeout: 5 * time.Second,
+ ReadTimeout: 15 * time.Second,
+ WriteTimeout: 15 * time.Second,
+ IdleTimeout: 60 * time.Second,
+ }
+
+ errCh := make(chan error, 1)
+ go func() {
+ logger.Info("control plane listening", "addr", cfg.HTTPAddr)
+ errCh <- server.ListenAndServe()
+ }()
+
+ select {
+ case err := <-errCh:
+ if !errors.Is(err, http.ErrServerClosed) {
+ logger.Error("serve", "error", err)
+ os.Exit(1)
+ }
+ case <-ctx.Done():
+ shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer shutdownCancel()
+ if err := server.Shutdown(shutdownCtx); err != nil {
+ logger.Error("shutdown", "error", err)
+ os.Exit(1)
+ }
+ }
+}
diff --git a/services/control-plane/go.mod b/services/control-plane/go.mod
new file mode 100644
index 0000000..aeea129
--- /dev/null
+++ b/services/control-plane/go.mod
@@ -0,0 +1,13 @@
+module hopweb
+
+go 1.26.0
+
+require github.com/jackc/pgx/v5 v5.10.0
+
+require (
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ golang.org/x/sync v0.17.0 // indirect
+ golang.org/x/text v0.29.0 // indirect
+)
diff --git a/services/control-plane/go.sum b/services/control-plane/go.sum
new file mode 100644
index 0000000..c0e505b
--- /dev/null
+++ b/services/control-plane/go.sum
@@ -0,0 +1,26 @@
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
+github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
+golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
+golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/services/control-plane/internal/config/config.go b/services/control-plane/internal/config/config.go
new file mode 100644
index 0000000..5fc724c
--- /dev/null
+++ b/services/control-plane/internal/config/config.go
@@ -0,0 +1,50 @@
+package config
+
+import (
+ "errors"
+ "os"
+)
+
+type Config struct {
+ HTTPAddr string
+ DatabaseURL string
+ AdminToken string
+ GiteaWebhookSecret string
+ GiteaBaseURL string
+ GiteaAPIToken string
+}
+
+func Load() (Config, error) {
+ cfg := Config{
+ HTTPAddr: value("HOP_HTTP_ADDR", ":8080"),
+ DatabaseURL: os.Getenv("HOP_DATABASE_URL"),
+ AdminToken: os.Getenv("HOP_ADMIN_TOKEN"),
+ GiteaWebhookSecret: os.Getenv("HOP_GITEA_WEBHOOK_SECRET"),
+ GiteaBaseURL: value("GITEA_BASE_URL", "http://localhost:3000"),
+ GiteaAPIToken: os.Getenv("GITEA_API_TOKEN"),
+ }
+
+ if cfg.DatabaseURL == "" {
+ return Config{}, errors.New("HOP_DATABASE_URL is required")
+ }
+ if cfg.AdminToken == "" {
+ return Config{}, errors.New("HOP_ADMIN_TOKEN is required")
+ }
+ if len(cfg.AdminToken) < 16 {
+ return Config{}, errors.New("HOP_ADMIN_TOKEN must be at least 16 characters")
+ }
+ if cfg.GiteaWebhookSecret == "" {
+ return Config{}, errors.New("HOP_GITEA_WEBHOOK_SECRET is required")
+ }
+ if len(cfg.GiteaWebhookSecret) < 16 {
+ return Config{}, errors.New("HOP_GITEA_WEBHOOK_SECRET must be at least 16 characters")
+ }
+ return cfg, nil
+}
+
+func value(name, fallback string) string {
+ if v := os.Getenv(name); v != "" {
+ return v
+ }
+ return fallback
+}
diff --git a/services/control-plane/internal/config/config_test.go b/services/control-plane/internal/config/config_test.go
new file mode 100644
index 0000000..9d48581
--- /dev/null
+++ b/services/control-plane/internal/config/config_test.go
@@ -0,0 +1,27 @@
+package config
+
+import "testing"
+
+func TestLoadRequiresSecretsAndDatabase(t *testing.T) {
+ t.Setenv("HOP_DATABASE_URL", "")
+ t.Setenv("HOP_ADMIN_TOKEN", "")
+ t.Setenv("HOP_GITEA_WEBHOOK_SECRET", "")
+ if _, err := Load(); err == nil {
+ t.Fatal("expected missing configuration error")
+ }
+}
+
+func TestLoadDefaults(t *testing.T) {
+ t.Setenv("HOP_DATABASE_URL", "postgres://example")
+ t.Setenv("HOP_ADMIN_TOKEN", "admin-token-long-enough")
+ t.Setenv("HOP_GITEA_WEBHOOK_SECRET", "webhook-secret-long-enough")
+ t.Setenv("HOP_HTTP_ADDR", "")
+ t.Setenv("GITEA_BASE_URL", "")
+ cfg, err := Load()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.HTTPAddr != ":8080" || cfg.GiteaBaseURL != "http://localhost:3000" {
+ t.Fatalf("unexpected defaults: %#v", cfg)
+ }
+}
diff --git a/services/control-plane/internal/gitea/client.go b/services/control-plane/internal/gitea/client.go
new file mode 100644
index 0000000..c8f076a
--- /dev/null
+++ b/services/control-plane/internal/gitea/client.go
@@ -0,0 +1,110 @@
+package gitea
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+var ErrTokenNotConfigured = errors.New("gitea API token is not configured")
+
+type Repository struct {
+ ID int64 `json:"id"`
+ Owner Owner `json:"owner"`
+ Name string `json:"name"`
+ FullName string `json:"full_name"`
+ CloneURL string `json:"clone_url"`
+ DefaultBranch string `json:"default_branch"`
+}
+
+type Owner struct {
+ Login string `json:"login"`
+ Username string `json:"username"`
+}
+
+func (r Repository) OwnerName() string {
+ if r.Owner.Login != "" {
+ return r.Owner.Login
+ }
+ return r.Owner.Username
+}
+
+type Client struct {
+ baseURL *url.URL
+ token string
+ http *http.Client
+}
+
+func NewClient(baseURL, token string) (*Client, error) {
+ u, err := url.Parse(strings.TrimRight(baseURL, "/"))
+ if err != nil || u.Scheme == "" || u.Host == "" {
+ return nil, fmt.Errorf("invalid GITEA_BASE_URL %q", baseURL)
+ }
+ return &Client{baseURL: u, token: token, http: &http.Client{Timeout: 10 * time.Second}}, nil
+}
+
+func (c *Client) Repository(ctx context.Context, owner, name string) (Repository, error) {
+ if c.token == "" {
+ return Repository{}, ErrTokenNotConfigured
+ }
+ u := c.baseURL.JoinPath("api", "v1", "repos", owner, name)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
+ if err != nil {
+ return Repository{}, err
+ }
+ req.Header.Set("Authorization", "token "+c.token)
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return Repository{}, fmt.Errorf("request repository: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return Repository{}, fmt.Errorf("gitea repository lookup returned %s", resp.Status)
+ }
+
+ var repo Repository
+ if err := json.NewDecoder(resp.Body).Decode(&repo); err != nil {
+ return Repository{}, fmt.Errorf("decode repository: %w", err)
+ }
+ if repo.ID <= 0 || repo.OwnerName() == "" || repo.Name == "" {
+ return Repository{}, errors.New("gitea returned an incomplete repository")
+ }
+ return repo, nil
+}
+
+// ViewerCanReadRepository checks a browser's existing Gitea session before
+// returning prompt data. Prompts may contain sensitive intent, so the Hop API
+// must never turn a private repository into a public metadata feed.
+func (c *Client) ViewerCanReadRepository(ctx context.Context, cookie, owner, name string) (bool, error) {
+ if strings.TrimSpace(cookie) == "" {
+ return false, nil
+ }
+ u := c.baseURL.JoinPath("api", "v1", "repos", owner, name)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
+ if err != nil {
+ return false, err
+ }
+ req.Header.Set("Cookie", cookie)
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return false, fmt.Errorf("check viewer repository access: %w", err)
+ }
+ defer resp.Body.Close()
+ switch resp.StatusCode {
+ case http.StatusOK:
+ return true, nil
+ case http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound:
+ return false, nil
+ default:
+ return false, fmt.Errorf("gitea viewer repository check returned %s", resp.Status)
+ }
+}
diff --git a/services/control-plane/internal/gitea/client_test.go b/services/control-plane/internal/gitea/client_test.go
new file mode 100644
index 0000000..1657b09
--- /dev/null
+++ b/services/control-plane/internal/gitea/client_test.go
@@ -0,0 +1,82 @@
+package gitea
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestRepositoryUsesTokenAndDecodesResponse(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/gitea/api/v1/repos/hop/demo" {
+ t.Fatalf("path = %q", r.URL.Path)
+ }
+ if got := r.Header.Get("Authorization"); got != "token gitea-token" {
+ t.Fatalf("authorization = %q", got)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id":42,"owner":{"login":"hop"},"name":"demo","full_name":"hop/demo","clone_url":"http://gitea/hop/demo.git","default_branch":"main"}`))
+ }))
+ defer server.Close()
+
+ client, err := NewClient(server.URL+"/gitea", "gitea-token")
+ if err != nil {
+ t.Fatal(err)
+ }
+ repository, err := client.Repository(context.Background(), "hop", "demo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if repository.ID != 42 || repository.OwnerName() != "hop" || repository.DefaultBranch != "main" {
+ t.Fatalf("unexpected repository: %#v", repository)
+ }
+}
+
+func TestRepositoryRequiresToken(t *testing.T) {
+ client, err := NewClient("http://gitea.test", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := client.Repository(context.Background(), "hop", "demo"); err != ErrTokenNotConfigured {
+ t.Fatalf("error = %v, want %v", err, ErrTokenNotConfigured)
+ }
+}
+
+func TestViewerCanReadRepositoryForwardsSessionCookie(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/v1/repos/hop/private" {
+ t.Fatalf("path = %q", r.URL.Path)
+ }
+ if got := r.Header.Get("Cookie"); got != "i_like_gitea=session" {
+ t.Fatalf("cookie = %q", got)
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ client, err := NewClient(server.URL, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ allowed, err := client.ViewerCanReadRepository(context.Background(), "i_like_gitea=session", "hop", "private")
+ if err != nil || !allowed {
+ t.Fatalf("allowed = %t, err = %v", allowed, err)
+ }
+}
+
+func TestViewerCannotReadMissingRepository(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer server.Close()
+
+ client, err := NewClient(server.URL, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ allowed, err := client.ViewerCanReadRepository(context.Background(), "i_like_gitea=session", "hop", "missing")
+ if err != nil || allowed {
+ t.Fatalf("allowed = %t, err = %v", allowed, err)
+ }
+}
diff --git a/services/control-plane/internal/gitea/webhook.go b/services/control-plane/internal/gitea/webhook.go
new file mode 100644
index 0000000..ff11f7b
--- /dev/null
+++ b/services/control-plane/internal/gitea/webhook.go
@@ -0,0 +1,17 @@
+package gitea
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+)
+
+func VerifySignature(body []byte, signature, secret string) bool {
+ provided, err := hex.DecodeString(signature)
+ if err != nil {
+ return false
+ }
+ mac := hmac.New(sha256.New, []byte(secret))
+ _, _ = mac.Write(body)
+ return hmac.Equal(provided, mac.Sum(nil))
+}
diff --git a/services/control-plane/internal/gitea/webhook_test.go b/services/control-plane/internal/gitea/webhook_test.go
new file mode 100644
index 0000000..2c5f87b
--- /dev/null
+++ b/services/control-plane/internal/gitea/webhook_test.go
@@ -0,0 +1,25 @@
+package gitea
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "testing"
+)
+
+func TestVerifySignature(t *testing.T) {
+ body := []byte(`{"hello":"hop"}`)
+ mac := hmac.New(sha256.New, []byte("secret"))
+ _, _ = mac.Write(body)
+ signature := hex.EncodeToString(mac.Sum(nil))
+
+ if !VerifySignature(body, signature, "secret") {
+ t.Fatal("expected valid signature")
+ }
+ if VerifySignature(body, signature, "wrong") {
+ t.Fatal("accepted signature with wrong secret")
+ }
+ if VerifySignature(body, "not-hex", "secret") {
+ t.Fatal("accepted malformed signature")
+ }
+}
diff --git a/services/control-plane/internal/httpapi/server.go b/services/control-plane/internal/httpapi/server.go
new file mode 100644
index 0000000..9b33574
--- /dev/null
+++ b/services/control-plane/internal/httpapi/server.go
@@ -0,0 +1,271 @@
+package httpapi
+
+import (
+ "context"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "io"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+
+ "hopweb/internal/gitea"
+ "hopweb/internal/store"
+)
+
+const maxWebhookBody = 2 << 20
+
+type giteaClient interface {
+ Repository(context.Context, string, string) (gitea.Repository, error)
+ ViewerCanReadRepository(context.Context, string, string, string) (bool, error)
+}
+
+type server struct {
+ store store.Store
+ gitea giteaClient
+ adminToken string
+ webhookSecret string
+ logger *slog.Logger
+}
+
+func New(data store.Store, client giteaClient, adminToken, webhookSecret string, logger *slog.Logger) http.Handler {
+ s := &server{store: data, gitea: client, adminToken: adminToken, webhookSecret: webhookSecret, logger: logger}
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /healthz", s.health)
+ mux.HandleFunc("GET /readyz", s.ready)
+ mux.Handle("GET /api/v1/repositories", s.requireAdmin(http.HandlerFunc(s.listRepositories)))
+ mux.Handle("POST /api/v1/repositories/link", s.requireAdmin(http.HandlerFunc(s.linkRepository)))
+ mux.Handle("GET /api/v1/prompts", http.HandlerFunc(s.listPrompts))
+ mux.Handle("POST /api/v1/repositories/{owner}/{name}/prompts", s.requireAdmin(http.HandlerFunc(s.createPrompt)))
+ mux.HandleFunc("POST /api/v1/gitea/webhooks", s.giteaWebhook)
+ return requestLogger(logger, mux)
+}
+
+func (s *server) listPrompts(w http.ResponseWriter, r *http.Request) {
+ owner, name := strings.TrimSpace(r.URL.Query().Get("owner")), strings.TrimSpace(r.URL.Query().Get("repo"))
+ if !validSlug(owner) || !validSlug(name) {
+ writeError(w, http.StatusBadRequest, "owner and repo are required")
+ return
+ }
+ allowed, err := s.gitea.ViewerCanReadRepository(r.Context(), r.Header.Get("Cookie"), owner, name)
+ if err != nil {
+ s.logger.Error("check prompt viewer access", "owner", owner, "name", name, "error", err)
+ writeError(w, http.StatusBadGateway, "could not verify repository access")
+ return
+ }
+ if !allowed {
+ writeError(w, http.StatusUnauthorized, "sign in to view this repository's prompts")
+ return
+ }
+ prompts, err := s.store.ListPrompts(r.Context(), owner, name, 100)
+ if err != nil {
+ s.logger.Error("list prompts", "owner", owner, "name", name, "error", err)
+ writeError(w, http.StatusInternalServerError, "could not list prompts")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"prompts": prompts})
+}
+
+func (s *server) createPrompt(w http.ResponseWriter, r *http.Request) {
+ owner, name := r.PathValue("owner"), r.PathValue("name")
+ if !validSlug(owner) || !validSlug(name) {
+ writeError(w, http.StatusBadRequest, "owner and name are required")
+ return
+ }
+ var input struct {
+ TaskID string `json:"task_id"`
+ AttemptID string `json:"attempt_id"`
+ StateID string `json:"state_id"`
+ Prompt string `json:"prompt"`
+ AgentName string `json:"agent_name"`
+ AgentModel string `json:"agent_model"`
+ Status string `json:"status"`
+ ResponseSummary string `json:"response_summary"`
+ Metadata json.RawMessage `json:"metadata"`
+ CompletedAt *time.Time `json:"completed_at"`
+ }
+ if err := decodeJSON(w, r, &input); err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ input.Prompt = strings.TrimSpace(input.Prompt)
+ if input.Prompt == "" || len(input.Prompt) > 32<<10 {
+ writeError(w, http.StatusBadRequest, "prompt is required and must be at most 32 KiB")
+ return
+ }
+ if input.Status == "" {
+ input.Status = "running"
+ }
+ switch input.Status {
+ case "running", "completed", "failed", "cancelled":
+ default:
+ writeError(w, http.StatusBadRequest, "invalid prompt status")
+ return
+ }
+ if len(input.Metadata) > 16<<10 || (len(input.Metadata) > 0 && !json.Valid(input.Metadata)) {
+ writeError(w, http.StatusBadRequest, "metadata must be valid JSON and at most 16 KiB")
+ return
+ }
+ saved, err := s.store.CreatePrompt(r.Context(), store.CreatePromptInput{
+ Owner: owner, Repository: name, TaskID: input.TaskID, AttemptID: input.AttemptID, StateID: input.StateID,
+ Prompt: input.Prompt, AgentName: input.AgentName, AgentModel: input.AgentModel, Status: input.Status,
+ ResponseSummary: input.ResponseSummary, Metadata: input.Metadata, CompletedAt: input.CompletedAt,
+ })
+ if err != nil {
+ s.logger.Error("create prompt", "owner", owner, "name", name, "error", err)
+ writeError(w, http.StatusInternalServerError, "could not create prompt")
+ return
+ }
+ writeJSON(w, http.StatusCreated, saved)
+}
+
+func (s *server) health(w http.ResponseWriter, _ *http.Request) {
+ writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+}
+
+func (s *server) ready(w http.ResponseWriter, r *http.Request) {
+ ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
+ defer cancel()
+ if err := s.store.Ping(ctx); err != nil {
+ writeError(w, http.StatusServiceUnavailable, "database unavailable")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
+}
+
+func (s *server) listRepositories(w http.ResponseWriter, r *http.Request) {
+ repositories, err := s.store.ListRepositories(r.Context())
+ if err != nil {
+ s.logger.Error("list repositories", "error", err)
+ writeError(w, http.StatusInternalServerError, "could not list repositories")
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"repositories": repositories})
+}
+
+func (s *server) linkRepository(w http.ResponseWriter, r *http.Request) {
+ var input struct {
+ Owner string `json:"owner"`
+ Name string `json:"name"`
+ }
+ if err := decodeJSON(w, r, &input); err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ if !validSlug(input.Owner) || !validSlug(input.Name) {
+ writeError(w, http.StatusBadRequest, "owner and name are required and cannot contain slashes")
+ return
+ }
+ repository, err := s.gitea.Repository(r.Context(), input.Owner, input.Name)
+ if err != nil {
+ if errors.Is(err, gitea.ErrTokenNotConfigured) {
+ writeError(w, http.StatusServiceUnavailable, err.Error())
+ return
+ }
+ s.logger.Warn("gitea repository lookup failed", "owner", input.Owner, "name", input.Name, "error", err)
+ writeError(w, http.StatusBadGateway, "could not load repository from Gitea")
+ return
+ }
+ saved, err := s.store.UpsertRepository(r.Context(), repository)
+ if err != nil {
+ s.logger.Error("link repository", "error", err)
+ writeError(w, http.StatusInternalServerError, "could not link repository")
+ return
+ }
+ writeJSON(w, http.StatusCreated, saved)
+}
+
+func (s *server) giteaWebhook(w http.ResponseWriter, r *http.Request) {
+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxWebhookBody))
+ if err != nil {
+ writeError(w, http.StatusRequestEntityTooLarge, "webhook payload too large")
+ return
+ }
+ if !gitea.VerifySignature(body, r.Header.Get("X-Gitea-Signature"), s.webhookSecret) {
+ writeError(w, http.StatusUnauthorized, "invalid webhook signature")
+ return
+ }
+ deliveryID := strings.TrimSpace(r.Header.Get("X-Gitea-Delivery"))
+ event := strings.TrimSpace(r.Header.Get("X-Gitea-Event"))
+ if deliveryID == "" || event == "" {
+ writeError(w, http.StatusBadRequest, "missing Gitea delivery or event header")
+ return
+ }
+ var payload struct {
+ Repository *gitea.Repository `json:"repository"`
+ }
+ if err := json.Unmarshal(body, &payload); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid JSON payload")
+ return
+ }
+ digest := sha256.Sum256(body)
+ duplicate, err := s.store.RecordWebhook(r.Context(), store.WebhookDelivery{
+ DeliveryID: deliveryID, Event: event, EventType: r.Header.Get("X-Gitea-Event-Type"),
+ PayloadSHA256: hex.EncodeToString(digest[:]), Repository: payload.Repository,
+ })
+ if err != nil {
+ s.logger.Error("record webhook", "delivery_id", deliveryID, "error", err)
+ writeError(w, http.StatusInternalServerError, "could not record webhook")
+ return
+ }
+ if duplicate {
+ w.Header().Set("X-Hop-Duplicate", "true")
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
+
+func (s *server) requireAdmin(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ authorization := r.Header.Get("Authorization")
+ if !strings.HasPrefix(authorization, "Bearer ") {
+ writeError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+ provided := strings.TrimPrefix(authorization, "Bearer ")
+ if len(provided) != len(s.adminToken) || subtle.ConstantTimeCompare([]byte(provided), []byte(s.adminToken)) != 1 {
+ writeError(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func validSlug(value string) bool {
+ value = strings.TrimSpace(value)
+ return value != "" && !strings.ContainsAny(value, "/\\")
+}
+
+func decodeJSON(w http.ResponseWriter, r *http.Request, target any) error {
+ r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
+ decoder := json.NewDecoder(r.Body)
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(target); err != nil {
+ return errors.New("invalid JSON body")
+ }
+ if err := decoder.Decode(&struct{}{}); err != io.EOF {
+ return errors.New("request body must contain one JSON object")
+ }
+ return nil
+}
+
+func writeError(w http.ResponseWriter, status int, message string) {
+ writeJSON(w, status, map[string]any{"error": map[string]string{"message": message}})
+}
+
+func writeJSON(w http.ResponseWriter, status int, body any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(body)
+}
+
+func requestLogger(logger *slog.Logger, next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ started := time.Now()
+ next.ServeHTTP(w, r)
+ logger.Info("request", "method", r.Method, "path", r.URL.Path, "duration_ms", time.Since(started).Milliseconds())
+ })
+}
diff --git a/services/control-plane/internal/httpapi/server_test.go b/services/control-plane/internal/httpapi/server_test.go
new file mode 100644
index 0000000..bafb090
--- /dev/null
+++ b/services/control-plane/internal/httpapi/server_test.go
@@ -0,0 +1,169 @@
+package httpapi
+
+import (
+ "bytes"
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "hopweb/internal/gitea"
+ "hopweb/internal/store"
+)
+
+type fakeStore struct {
+ repositories []store.Repository
+ prompts []store.Prompt
+ delivery store.WebhookDelivery
+ duplicate bool
+}
+
+func (f *fakeStore) Ping(context.Context) error { return nil }
+func (f *fakeStore) ListRepositories(context.Context) ([]store.Repository, error) {
+ return f.repositories, nil
+}
+func (f *fakeStore) UpsertRepository(_ context.Context, repository gitea.Repository) (store.Repository, error) {
+ saved := store.Repository{ID: "RP_TEST", GiteaID: repository.ID, Owner: repository.OwnerName(), Name: repository.Name, FullName: repository.FullName}
+ f.repositories = append(f.repositories, saved)
+ return saved, nil
+}
+func (f *fakeStore) ListPrompts(context.Context, string, string, int) ([]store.Prompt, error) {
+ return f.prompts, nil
+}
+func (f *fakeStore) CreatePrompt(_ context.Context, input store.CreatePromptInput) (store.Prompt, error) {
+ prompt := store.Prompt{ID: "PM_TEST", Prompt: input.Prompt, Status: input.Status, AgentName: input.AgentName, AgentModel: input.AgentModel, Metadata: input.Metadata}
+ f.prompts = append(f.prompts, prompt)
+ return prompt, nil
+}
+func (f *fakeStore) RecordWebhook(_ context.Context, delivery store.WebhookDelivery) (bool, error) {
+ f.delivery = delivery
+ return f.duplicate, nil
+}
+
+type fakeGitea struct {
+ repository gitea.Repository
+ err error
+ viewerAllowed bool
+}
+
+func (f fakeGitea) Repository(context.Context, string, string) (gitea.Repository, error) {
+ return f.repository, f.err
+}
+func (f fakeGitea) ViewerCanReadRepository(context.Context, string, string, string) (bool, error) {
+ return f.viewerAllowed, f.err
+}
+
+func testServer(data store.Store, client giteaClient) http.Handler {
+ return New(data, client, "admin-secret", "webhook-secret", slog.New(slog.NewTextHandler(io.Discard, nil)))
+}
+
+func TestAdminAuthentication(t *testing.T) {
+ handler := testServer(&fakeStore{}, fakeGitea{})
+ for _, header := range []string{"", "admin-secret", "Bearer wrong"} {
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/repositories", nil)
+ req.Header.Set("Authorization", header)
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, req)
+ if response.Code != http.StatusUnauthorized {
+ t.Fatalf("header %q: status = %d, want %d", header, response.Code, http.StatusUnauthorized)
+ }
+ }
+}
+
+func TestLinkRepository(t *testing.T) {
+ data := &fakeStore{}
+ client := fakeGitea{repository: gitea.Repository{ID: 42, Owner: gitea.Owner{Login: "hop"}, Name: "demo", FullName: "hop/demo"}}
+ handler := testServer(data, client)
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/repositories/link", bytes.NewBufferString(`{"owner":"hop","name":"demo"}`))
+ req.Header.Set("Authorization", "Bearer admin-secret")
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, req)
+ if response.Code != http.StatusCreated {
+ t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusCreated, response.Body.String())
+ }
+ if len(data.repositories) != 1 || data.repositories[0].GiteaID != 42 {
+ t.Fatalf("repository not linked: %#v", data.repositories)
+ }
+}
+
+func TestWebhookVerificationAndDeduplication(t *testing.T) {
+ data := &fakeStore{duplicate: true}
+ handler := testServer(data, fakeGitea{err: errors.New("unused")})
+ body := []byte(`{"repository":{"id":42,"owner":{"login":"hop"},"name":"demo","full_name":"hop/demo"}}`)
+ mac := hmac.New(sha256.New, []byte("webhook-secret"))
+ _, _ = mac.Write(body)
+
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/gitea/webhooks", bytes.NewReader(body))
+ req.Header.Set("X-Gitea-Signature", hex.EncodeToString(mac.Sum(nil)))
+ req.Header.Set("X-Gitea-Delivery", "delivery-1")
+ req.Header.Set("X-Gitea-Event", "push")
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, req)
+
+ if response.Code != http.StatusNoContent {
+ t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusNoContent, response.Body.String())
+ }
+ if response.Header().Get("X-Hop-Duplicate") != "true" {
+ t.Fatal("expected duplicate response header")
+ }
+ if data.delivery.DeliveryID != "delivery-1" || data.delivery.Repository == nil || data.delivery.Repository.ID != 42 {
+ t.Fatalf("delivery not recorded: %#v", data.delivery)
+ }
+}
+
+func TestWebhookRejectsInvalidSignature(t *testing.T) {
+ handler := testServer(&fakeStore{}, fakeGitea{})
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/gitea/webhooks", bytes.NewBufferString(`{}`))
+ req.Header.Set("X-Gitea-Signature", "bad")
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, req)
+ if response.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want %d", response.Code, http.StatusUnauthorized)
+ }
+}
+
+func TestListPromptsRequiresGiteaSession(t *testing.T) {
+ data := &fakeStore{prompts: []store.Prompt{{ID: "PM_TEST", Prompt: "Ship the prompt view", Status: "completed"}}}
+ handler := testServer(data, fakeGitea{viewerAllowed: true})
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/prompts?owner=hop&repo=demo", nil)
+ req.Header.Set("Cookie", "i_like_gitea=session")
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, req)
+ if response.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusOK, response.Body.String())
+ }
+ if !bytes.Contains(response.Body.Bytes(), []byte("Ship the prompt view")) {
+ t.Fatalf("prompt missing from response: %s", response.Body.String())
+ }
+}
+
+func TestListPromptsRejectsUnauthenticatedViewer(t *testing.T) {
+ handler := testServer(&fakeStore{}, fakeGitea{})
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/prompts?owner=hop&repo=demo", nil)
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, req)
+ if response.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusUnauthorized, response.Body.String())
+ }
+}
+
+func TestCreatePrompt(t *testing.T) {
+ data := &fakeStore{}
+ handler := testServer(data, fakeGitea{})
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/repositories/hop/demo/prompts", bytes.NewBufferString(`{"prompt":"Ship the prompt view","agent_name":"codex","status":"completed","metadata":{"duration_ms":1200}}`))
+ req.Header.Set("Authorization", "Bearer admin-secret")
+ response := httptest.NewRecorder()
+ handler.ServeHTTP(response, req)
+ if response.Code != http.StatusCreated {
+ t.Fatalf("status = %d, want %d; body=%s", response.Code, http.StatusCreated, response.Body.String())
+ }
+ if len(data.prompts) != 1 || data.prompts[0].Prompt != "Ship the prompt view" {
+ t.Fatalf("prompt not created: %#v", data.prompts)
+ }
+}
diff --git a/services/control-plane/internal/ids/ids.go b/services/control-plane/internal/ids/ids.go
new file mode 100644
index 0000000..795cb77
--- /dev/null
+++ b/services/control-plane/internal/ids/ids.go
@@ -0,0 +1,32 @@
+package ids
+
+import (
+ "crypto/rand"
+ "encoding/base32"
+ "time"
+)
+
+var encoding = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding)
+
+func Repository() (string, error) {
+ return newID("RP_")
+}
+
+func Prompt() (string, error) {
+ return newID("PM_")
+}
+
+func newID(prefix string) (string, error) {
+ value := make([]byte, 16)
+ milliseconds := uint64(time.Now().UnixMilli())
+ value[0] = byte(milliseconds >> 40)
+ value[1] = byte(milliseconds >> 32)
+ value[2] = byte(milliseconds >> 24)
+ value[3] = byte(milliseconds >> 16)
+ value[4] = byte(milliseconds >> 8)
+ value[5] = byte(milliseconds)
+ if _, err := rand.Read(value[6:]); err != nil {
+ return "", err
+ }
+ return prefix + encoding.EncodeToString(value), nil
+}
diff --git a/services/control-plane/internal/ids/ids_test.go b/services/control-plane/internal/ids/ids_test.go
new file mode 100644
index 0000000..6fa8970
--- /dev/null
+++ b/services/control-plane/internal/ids/ids_test.go
@@ -0,0 +1,33 @@
+package ids
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRepository(t *testing.T) {
+ first, err := Repository()
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := Repository()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(first, "RP_") || len(first) != 29 {
+ t.Fatalf("unexpected repository ID %q", first)
+ }
+ if first == second {
+ t.Fatal("generated duplicate IDs")
+ }
+}
+
+func TestPrompt(t *testing.T) {
+ id, err := Prompt()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(id, "PM_") || len(id) != 29 {
+ t.Fatalf("unexpected prompt ID %q", id)
+ }
+}
diff --git a/services/control-plane/internal/store/migrations/001_foundation.sql b/services/control-plane/internal/store/migrations/001_foundation.sql
new file mode 100644
index 0000000..0f00bab
--- /dev/null
+++ b/services/control-plane/internal/store/migrations/001_foundation.sql
@@ -0,0 +1,21 @@
+CREATE TABLE repositories (
+ id text PRIMARY KEY,
+ gitea_id bigint NOT NULL UNIQUE CHECK (gitea_id > 0),
+ owner text NOT NULL,
+ name text NOT NULL,
+ full_name text NOT NULL,
+ clone_url text NOT NULL DEFAULT '',
+ default_branch text NOT NULL DEFAULT '',
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (owner, name)
+);
+
+CREATE TABLE webhook_deliveries (
+ delivery_id text PRIMARY KEY,
+ event text NOT NULL,
+ event_type text NOT NULL DEFAULT '',
+ payload_sha256 text NOT NULL,
+ received_at timestamptz NOT NULL DEFAULT now()
+);
+
diff --git a/services/control-plane/internal/store/migrations/002_prompts.sql b/services/control-plane/internal/store/migrations/002_prompts.sql
new file mode 100644
index 0000000..50d3685
--- /dev/null
+++ b/services/control-plane/internal/store/migrations/002_prompts.sql
@@ -0,0 +1,17 @@
+CREATE TABLE prompts (
+ id text PRIMARY KEY,
+ repository_id text NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
+ task_id text NOT NULL DEFAULT '',
+ attempt_id text NOT NULL DEFAULT '',
+ state_id text NOT NULL DEFAULT '',
+ prompt text NOT NULL,
+ agent_name text NOT NULL DEFAULT '',
+ agent_model text NOT NULL DEFAULT '',
+ status text NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'completed', 'failed', 'cancelled')),
+ response_summary text NOT NULL DEFAULT '',
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ completed_at timestamptz
+);
+
+CREATE INDEX prompts_repository_created_at_idx ON prompts (repository_id, created_at DESC);
diff --git a/services/control-plane/internal/store/store.go b/services/control-plane/internal/store/store.go
new file mode 100644
index 0000000..ca47f7e
--- /dev/null
+++ b/services/control-plane/internal/store/store.go
@@ -0,0 +1,271 @@
+package store
+
+import (
+ "context"
+ "embed"
+ "encoding/json"
+ "fmt"
+ "sort"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "hopweb/internal/gitea"
+ "hopweb/internal/ids"
+)
+
+//go:embed migrations/*.sql
+var migrations embed.FS
+
+type Repository struct {
+ ID string `json:"id"`
+ GiteaID int64 `json:"gitea_id"`
+ Owner string `json:"owner"`
+ Name string `json:"name"`
+ FullName string `json:"full_name"`
+ CloneURL string `json:"clone_url"`
+ DefaultBranch string `json:"default_branch"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+type WebhookDelivery struct {
+ DeliveryID string
+ Event string
+ EventType string
+ PayloadSHA256 string
+ Repository *gitea.Repository
+}
+
+type Prompt struct {
+ ID string `json:"id"`
+ TaskID string `json:"task_id,omitempty"`
+ AttemptID string `json:"attempt_id,omitempty"`
+ StateID string `json:"state_id,omitempty"`
+ Prompt string `json:"prompt"`
+ AgentName string `json:"agent_name,omitempty"`
+ AgentModel string `json:"agent_model,omitempty"`
+ Status string `json:"status"`
+ ResponseSummary string `json:"response_summary,omitempty"`
+ Metadata json.RawMessage `json:"metadata"`
+ CreatedAt time.Time `json:"created_at"`
+ CompletedAt *time.Time `json:"completed_at,omitempty"`
+}
+
+type CreatePromptInput struct {
+ Owner string
+ Repository string
+ TaskID string
+ AttemptID string
+ StateID string
+ Prompt string
+ AgentName string
+ AgentModel string
+ Status string
+ ResponseSummary string
+ Metadata json.RawMessage
+ CompletedAt *time.Time
+}
+
+type Store interface {
+ Ping(context.Context) error
+ ListRepositories(context.Context) ([]Repository, error)
+ UpsertRepository(context.Context, gitea.Repository) (Repository, error)
+ ListPrompts(context.Context, string, string, int) ([]Prompt, error)
+ CreatePrompt(context.Context, CreatePromptInput) (Prompt, error)
+ RecordWebhook(context.Context, WebhookDelivery) (bool, error)
+}
+
+type Postgres struct {
+ pool *pgxpool.Pool
+}
+
+func Open(ctx context.Context, databaseURL string) (*Postgres, error) {
+ pool, err := pgxpool.New(ctx, databaseURL)
+ if err != nil {
+ return nil, err
+ }
+ if err := pool.Ping(ctx); err != nil {
+ pool.Close()
+ return nil, err
+ }
+ return &Postgres{pool: pool}, nil
+}
+
+func (p *Postgres) Close() { p.pool.Close() }
+
+func (p *Postgres) Ping(ctx context.Context) error { return p.pool.Ping(ctx) }
+
+func (p *Postgres) Migrate(ctx context.Context) error {
+ if _, err := p.pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
+ return err
+ }
+ entries, err := migrations.ReadDir("migrations")
+ if err != nil {
+ return err
+ }
+ sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
+ for _, entry := range entries {
+ name := entry.Name()
+ var exists bool
+ if err := p.pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE name=$1)`, name).Scan(&exists); err != nil {
+ return err
+ }
+ if exists {
+ continue
+ }
+ sql, err := migrations.ReadFile("migrations/" + name)
+ if err != nil {
+ return err
+ }
+ tx, err := p.pool.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ if _, err = tx.Exec(ctx, string(sql)); err == nil {
+ _, err = tx.Exec(ctx, `INSERT INTO schema_migrations (name) VALUES ($1)`, name)
+ }
+ if err != nil {
+ _ = tx.Rollback(ctx)
+ return fmt.Errorf("apply migration %s: %w", name, err)
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (p *Postgres) ListRepositories(ctx context.Context) ([]Repository, error) {
+ rows, err := p.pool.Query(ctx, `SELECT id, gitea_id, owner, name, full_name, clone_url, default_branch, created_at, updated_at FROM repositories ORDER BY full_name`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ repositories := make([]Repository, 0)
+ for rows.Next() {
+ var repository Repository
+ if err := rows.Scan(&repository.ID, &repository.GiteaID, &repository.Owner, &repository.Name, &repository.FullName, &repository.CloneURL, &repository.DefaultBranch, &repository.CreatedAt, &repository.UpdatedAt); err != nil {
+ return nil, err
+ }
+ repositories = append(repositories, repository)
+ }
+ return repositories, rows.Err()
+}
+
+func (p *Postgres) UpsertRepository(ctx context.Context, repository gitea.Repository) (Repository, error) {
+ id, err := ids.Repository()
+ if err != nil {
+ return Repository{}, err
+ }
+ return upsertRepository(ctx, p.pool, id, repository)
+}
+
+func (p *Postgres) ListPrompts(ctx context.Context, owner, name string, limit int) ([]Prompt, error) {
+ rows, err := p.pool.Query(ctx, `
+ SELECT p.id, p.task_id, p.attempt_id, p.state_id, p.prompt, p.agent_name, p.agent_model,
+ p.status, p.response_summary, p.metadata, p.created_at, p.completed_at
+ FROM prompts p
+ JOIN repositories r ON r.id = p.repository_id
+ WHERE r.owner=$1 AND r.name=$2
+ ORDER BY p.created_at DESC
+ LIMIT $3`, owner, name, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ prompts := make([]Prompt, 0)
+ for rows.Next() {
+ var prompt Prompt
+ if err := rows.Scan(&prompt.ID, &prompt.TaskID, &prompt.AttemptID, &prompt.StateID, &prompt.Prompt, &prompt.AgentName, &prompt.AgentModel, &prompt.Status, &prompt.ResponseSummary, &prompt.Metadata, &prompt.CreatedAt, &prompt.CompletedAt); err != nil {
+ return nil, err
+ }
+ prompts = append(prompts, prompt)
+ }
+ return prompts, rows.Err()
+}
+
+func (p *Postgres) CreatePrompt(ctx context.Context, input CreatePromptInput) (Prompt, error) {
+ id, err := ids.Prompt()
+ if err != nil {
+ return Prompt{}, err
+ }
+ metadata := input.Metadata
+ if len(metadata) == 0 {
+ metadata = json.RawMessage(`{}`)
+ }
+ var prompt Prompt
+ err = p.pool.QueryRow(ctx, `
+ INSERT INTO prompts (id, repository_id, task_id, attempt_id, state_id, prompt, agent_name, agent_model, status, response_summary, metadata, completed_at)
+ SELECT $1, r.id, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13
+ FROM repositories r WHERE r.owner=$2 AND r.name=$3
+ RETURNING id, task_id, attempt_id, state_id, prompt, agent_name, agent_model, status, response_summary, metadata, created_at, completed_at`,
+ id, input.Owner, input.Repository, input.TaskID, input.AttemptID, input.StateID, input.Prompt, input.AgentName, input.AgentModel, input.Status, input.ResponseSummary, metadata, input.CompletedAt,
+ ).Scan(&prompt.ID, &prompt.TaskID, &prompt.AttemptID, &prompt.StateID, &prompt.Prompt, &prompt.AgentName, &prompt.AgentModel, &prompt.Status, &prompt.ResponseSummary, &prompt.Metadata, &prompt.CreatedAt, &prompt.CompletedAt)
+ if err != nil {
+ return Prompt{}, err
+ }
+ return prompt, nil
+}
+
+type querier interface {
+ QueryRow(context.Context, string, ...any) pgx.Row
+}
+
+func upsertRepository(ctx context.Context, q querier, id string, repository gitea.Repository) (Repository, error) {
+ owner := repository.OwnerName()
+ fullName := repository.FullName
+ if fullName == "" {
+ fullName = owner + "/" + repository.Name
+ }
+ var saved Repository
+ err := q.QueryRow(ctx, `
+ INSERT INTO repositories (id, gitea_id, owner, name, full_name, clone_url, default_branch)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
+ ON CONFLICT (gitea_id) DO UPDATE SET
+ owner=EXCLUDED.owner, name=EXCLUDED.name, full_name=EXCLUDED.full_name,
+ clone_url=EXCLUDED.clone_url, default_branch=EXCLUDED.default_branch, updated_at=now()
+ RETURNING id, gitea_id, owner, name, full_name, clone_url, default_branch, created_at, updated_at`,
+ id, repository.ID, owner, repository.Name, fullName, repository.CloneURL, repository.DefaultBranch,
+ ).Scan(&saved.ID, &saved.GiteaID, &saved.Owner, &saved.Name, &saved.FullName, &saved.CloneURL, &saved.DefaultBranch, &saved.CreatedAt, &saved.UpdatedAt)
+ return saved, err
+}
+
+func (p *Postgres) RecordWebhook(ctx context.Context, delivery WebhookDelivery) (bool, error) {
+ tx, err := p.pool.Begin(ctx)
+ if err != nil {
+ return false, err
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+
+ result, err := tx.Exec(ctx, `INSERT INTO webhook_deliveries (delivery_id, event, event_type, payload_sha256) VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING`, delivery.DeliveryID, delivery.Event, delivery.EventType, delivery.PayloadSHA256)
+ if err != nil {
+ return false, err
+ }
+ if result.RowsAffected() == 0 {
+ var existingEvent, existingEventType, existingDigest string
+ if err := tx.QueryRow(ctx, `SELECT event, event_type, payload_sha256 FROM webhook_deliveries WHERE delivery_id=$1`, delivery.DeliveryID).Scan(&existingEvent, &existingEventType, &existingDigest); err != nil {
+ return false, err
+ }
+ if existingEvent != delivery.Event || existingEventType != delivery.EventType || existingDigest != delivery.PayloadSHA256 {
+ return false, fmt.Errorf("webhook delivery ID %q was reused with different content", delivery.DeliveryID)
+ }
+ return true, nil
+ }
+ if delivery.Repository != nil && delivery.Repository.ID > 0 {
+ id, err := ids.Repository()
+ if err != nil {
+ return false, err
+ }
+ if _, err := upsertRepository(ctx, tx, id, *delivery.Repository); err != nil {
+ return false, err
+ }
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return false, err
+ }
+ return false, nil
+}
+
+var _ Store = (*Postgres)(nil)
diff --git a/skills/hop/SKILL.md b/skills/hop/SKILL.md
deleted file mode 100644
index 58899b7..0000000
--- a/skills/hop/SKILL.md
+++ /dev/null
@@ -1,191 +0,0 @@
----
-name: hop
-description: Capture local repository prompts as Hop states and perform agent work in isolated Hop workspaces. Use at the start of every interactive coding-agent repository turn and follow-up, before inspecting files, running project commands, editing, reviewing, delegating, landing, or undoing—even when the user does not mention Hop. Also use whenever HOP_STATE_ID, HOP_TASK_ID, HOP_ATTEMPT_ID, HOP_AGENT, CODEX_THREAD_ID, or .hop/hop.db is present.
----
-
-# Hop
-
-Make prompt capture the first project action, then keep all effects inside the
-returned Hop workspace.
-
-## Capture the current prompt first
-
-Do not inspect repository files, plan from repository contents, run project
-commands, edit, or delegate before capture. Run the form for the current shell
-from the selected project directory.
-
-POSIX shell:
-
-```bash
-hop begin --heredoc <<'HOP_PROMPT_EOF'
-
-HOP_PROMPT_EOF
-```
-
-PowerShell:
-
-```powershell
-$hopPrompt = @'
-
-'@
-$hopPrompt | hop begin --heredoc
-```
-
-Choose a different non-interpolating stdin construction if the applicable
-terminator appears in the message. Include visible attachment paths and
-references. Do not paraphrase, pre-redact, or omit a suspected credential in
-this one capture stream; Hop must see it to replace it deterministically before
-persistence. `--heredoc` removes only the shell-added final newline. Never copy
-the credential anywhere else.
-
-An integration may identify its runtime through `HOP_AGENT` or `--agent`, and
-should pass a stable `--session` value when it has one. A stable session lets
-Hop connect unfinished follow-ups without making the user carry state IDs.
-Codex is one adapter example: when `CODEX_THREAD_ID` is present, `hop begin`
-uses it as the default session and identifies the runtime as `codex` unless
-`HOP_AGENT` or `--agent` overrides that name.
-
-`hop begin` performs the interactive-agent bootstrap:
-
-- Initialize Hop automatically when the project has not used it before.
-- Use the integration's stable session identity to bind later messages to
- unfinished Hop work. Without one, each invocation begins independent work.
-- Create a prompt state and isolated workspace on the first turn.
-- Checkpoint prior workspace effects and append follow-ups until that work lands.
-- Follow a reconciliation into its fresh attempt, then start the first prompt
- after landing from the latest accepted state instead of reopening old work.
-- Redact detected API keys, tokens, passwords, private keys, authorization
- 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.
-
-If Hop reports redactions, never repeat the credential in output, summaries,
-commands recorded as evidence, or proposal text. Refer to its environment
-variable or secret-manager name instead.
-
-## Enforce the workspace boundary
-
-- Direct every shell command to the returned workspace.
-- Use absolute paths beneath that workspace for file reads and edits.
-- Never edit the selected canonical project root.
-- 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
- merge, then resolve only the genuine conflict hunks in the reconciliation
- workspace it returns.
-
-Verify the captured state before making changes:
-
-```bash
-hop state --json
-hop status --json
-```
-
-## Execute and auto-accept
-
-1. Inspect and modify only the Hop workspace.
-2. Keep the change scoped to the captured prompt.
-3. Bind validation evidence to an immutable checkpoint:
-
- ```bash
- hop check -- [args...]
- ```
-
-4. Fix failures in the live Hop workspace and rerun checks.
-5. Freeze project changes as a proposal:
-
- ```bash
- hop propose --summary ""
- ```
-
-6. Unless the user explicitly requested review-only mode, immediately land the
- proposal and validate the exact final tree:
-
- ```bash
- hop land -- [args...]
- ```
-
- Same-file edits with compatible hunks merge automatically.
-
-7. If `hop land` reports a prepared reconciliation prompt/workspace, continue
- immediately in that returned workspace. Do not stop or ask the user to
- coordinate an ordinary code conflict:
-
- - adopt every returned `HOP_*` value and the fresh reconciliation workspace;
- - inspect every conflict candidate plus both returned proposal/current
- accepted states; compare their commits when a delete/rename, binary, mode,
- symlink, or directory conflict has no text markers;
- - resolve every conflict intelligently, preserving both compatible intents;
- - remove all merge markers;
- - run `hop check` with the returned prompt state (Hop requires checked
- reconciliation evidence before it will accept a new proposal);
- - create a new proposal and run `hop land` again; and
- - repeat if accepted state raced forward again.
-
-8. Report the accepted result, validation, and remaining risks. Keep internal
- state and evidence IDs out of the normal response unless they help explain a
- failure or the user asks for them. Confirm that `hop land` reported the
- selected visible project root as synchronized. When it reports an automatic
- push warning, retry once with `hop push`; never force-push or ask the user to
- perform routine source-control mechanics.
-
-For a read-only or informational turn, the prompt state is sufficient; do not
-invent a proposal when the workspace tree is unchanged.
-
-Do not edit a frozen proposal. A user follow-up triggers this skill again;
-run `hop begin` again before acting. Session binding selects unfinished work
-automatically and rolls completed work onto the latest accepted state, so the
-user never needs to carry state IDs.
-
-## Auto-accept by default
-
-The captured task prompt authorizes accepting the local project changes needed
-to complete that task. Do not ask for separate landing permission and do not
-capture a second prompt merely to land. After checks pass and the proposal is
-frozen, run `hop land` as part of the same turn.
-
-An existing unambiguous Git upstream is standing project configuration for
-non-forced publication of accepted states. Hop pushes accepted commits
-automatically after landing; prompts, checkpoints, proposals, and `.hop/` state
-remain local. Do not run raw `git push`.
-
-Use the strongest relevant final validation command. If the task truly has no
-runnable validation, `hop land ` is allowed and the final
-response must say that acceptance was not validated by a command.
-
-Stop before acceptance only when:
-
-- the user explicitly says `review first`, `proposal only`, `do not land`, or
- otherwise asks to approve the result before it is accepted;
-- validation fails;
-- Hop reports visible-root divergence; a conflict has genuine product ambiguity
- that cannot be resolved from both recorded intents; or
-- acceptance would require a destructive, external, or out-of-scope action not
- authorized by the captured task.
-
-Ordinary textual overlap is not a reason to stop. Hop first performs a real
-three-way content merge; genuine unresolved hunks enter the automatic
-reconciliation loop above. Preserve and report a block only when the intents
-are product-level incompatible, required validation cannot be repaired, or
-safe continuation needs new user authority.
-If visible-root synchronization is blocked, do not bypass it with `hop accept`,
-force checkout, reset, or file copying. Preserve the proposal and identify the
-user-owned paths that must be resolved. `hop accept` is reserved for an
-explicitly controller-only workflow; interactive agent work uses `hop land`.
-Use `hop undo` only after a separately captured, explicit user request.
-
-Read [references/protocol.md](references/protocol.md) for state semantics, exit
-codes, recovery, and controller-grade pre-delivery capture. Skill-driven
-interactive capture is a pre-project-effect boundary; it does not claim the
-prompt was stored before the runtime received it. On Codex Desktop, for
-example, Codex has already received the prompt before this skill can run.
diff --git a/skills/hop/agents/openai.yaml b/skills/hop/agents/openai.yaml
deleted file mode 100644
index d57f0b8..0000000
--- a/skills/hop/agents/openai.yaml
+++ /dev/null
@@ -1,7 +0,0 @@
-interface:
- display_name: "Hop Version Control"
- short_description: "Capture every coding prompt before project effects"
- default_prompt: "Use $hop to capture this prompt first, complete and validate the task in its isolated workspace, auto-land and publish accepted work, and automatically reconcile ordinary merge conflicts unless I explicitly request review first."
-
-policy:
- allow_implicit_invocation: true
diff --git a/skills/hop/embed.go b/skills/hop/embed.go
deleted file mode 100644
index 0100d47..0000000
--- a/skills/hop/embed.go
+++ /dev/null
@@ -1,9 +0,0 @@
-// Package hopskill embeds the distributable Hop agent skill in the CLI binary.
-package hopskill
-
-import "embed"
-
-// Files contains the complete vendor-neutral Hop skill bundle.
-//
-//go:embed SKILL.md agents/openai.yaml references/protocol.md
-var Files embed.FS
diff --git a/skills/hop/references/protocol.md b/skills/hop/references/protocol.md
deleted file mode 100644
index c1c0f09..0000000
--- a/skills/hop/references/protocol.md
+++ /dev/null
@@ -1,231 +0,0 @@
-# Hop agent protocol
-
-## State graph
-
-```text
-A accepted
-├─ P prompt, persisted before project effects
-│ └─ C checkpoint
-│ └─ R proposal
-└─ P independent prompt
-
-A + R ──land──> A next accepted state
-```
-
-State prefixes:
-
-| Prefix | Kind | Meaning |
-|---|---|---|
-| `A_` | accepted | Canonical project revision |
-| `P_` | prompt | Exact instruction and pre-effect context |
-| `C_` | checkpoint | Immutable workspace progress |
-| `R_` | proposal | Frozen candidate result |
-| `F_` | failed | Durable failed execution or validation state |
-| `X_` | cancelled | Durable cancelled state |
-
-Prompt, checkpoint, and proposal states may reference identical Git trees while remaining distinct causal occurrences.
-
-## Environment contract
-
-| Variable | Purpose |
-|---|---|
-| `HOP_ROOT` | Canonical project root containing `.hop/hop.db` |
-| `HOP_STATE_ID` | Prompt state authorizing the current instruction |
-| `HOP_TASK_ID` | Logical task grouping related prompts and attempts |
-| `HOP_ATTEMPT_ID` | Current agent approach/run |
-| `HOP_WORKSPACE` | Only directory the agent may modify |
-| `HOP_AGENT` | Optional runtime name used by `hop begin` when `--agent` is omitted |
-
-Interactive agents may begin without these variables. `hop begin` returns the
-equivalent IDs and workspace. Integrations should identify themselves with
-`HOP_AGENT` or `--agent` and pass a stable `--session` value when available.
-That session binds later messages to unfinished work; without it, each
-invocation begins independent work. The Codex adapter uses `CODEX_THREAD_ID` as
-the default session and `codex` as the default runtime name. Follow-ups before
-acceptance continue the attempt; the first prompt after acceptance starts a
-fresh task and attempt at the latest accepted state.
-
-## Command contract
-
-### Human or controller
-
-```bash
-hop init
-hop start --agent ""
-hop env
-hop prompt --from ""
-hop accept --
-hop sync
-hop undo
-```
-
-`hop start` creates the task, attempt, prompt state, and detached workspace before returning. The controller may deliver the prompt only after exit `0`.
-
-`hop prompt` captures a checkpoint of current workspace effects before creating the follow-up prompt state.
-
-### Agent
-
-POSIX shell:
-
-```bash
-hop begin --heredoc <<'HOP_PROMPT_EOF'
-
-HOP_PROMPT_EOF
-```
-
-PowerShell:
-
-```powershell
-$hopPrompt = @'
-
-'@
-$hopPrompt | hop begin --heredoc
-```
-
-Then continue in the returned workspace:
-
-```bash
-hop state "$HOP_STATE_ID" --json
-hop status --json
-hop check "$HOP_STATE_ID" --
-hop propose --summary "" "$HOP_STATE_ID"
-hop land --
-hop refresh
-hop push
-```
-
-An adapter may set `HOP_AGENT=` or pass `--agent `. If it has
-a stable conversation/run identifier, it should also pass `--session ` on
-every `hop begin`. Codex normally needs neither explicit flag because
-`CODEX_THREAD_ID` supplies its session adapter automatically.
-
-`hop check` snapshots the attempt and runs the command in a detached worktree materialized from that exact checkpoint. Edits made concurrently in the live workspace do not change the tested tree.
-
-`hop propose` freezes the current nonignored workspace tree. Later workspace edits cannot change the proposal.
-
-The initial task prompt authorizes the agent to run `hop land` after successful
-validation; a second user approval is not required. Manual review is an opt-in
-mode: stop at the proposal only when the user explicitly asks to review or
-approve before acceptance. Validation failure, visible-root divergence,
-unresolved product ambiguity, or newly required destructive/external scope
-stops automatic acceptance. Path overlap and a stale accepted head do not:
-Hop merges, retries, or prepares agent reconciliation.
-
-`hop land` is the interactive working-root operation. It performs a real Git
-three-way content merge, so compatible edits in the same file and identical
-changes compose automatically. It validates and advances accepted state, then
-safely materializes that tree into the selected visible project root. The root
-must still match an accepted Hop ancestor, and ignored or untracked destination
-collisions block before acceptance. Materialization uses a disposable index
-and never moves HEAD, the active branch, or the user's real index.
-
-When the three-way merge has genuine unresolved conflicts, `hop land` returns
-exit `20` and automatically prepares a reconciliation prompt in the original
-task but a fresh isolated attempt/workspace. Its JSON includes
-`reconciliation.prompt`, `workspace`,
-`conflicts`, and the proposal/current accepted states. The agent adopts that
-prompt/workspace, resolves both intents, checks, proposes, and lands again.
-Structural, binary, delete/rename, mode, and symlink conflicts may have no text
-markers, so the agent must inspect both returned input states. Hop requires a
-successful `hop check` on the resolved tree before reproposal. The user is not
-asked to coordinate ordinary code conflicts. `hop refresh` is the idempotent
-explicit form of the same preparation step.
-
-`hop accept` is the controller/kernel operation. It advances SQLite and
-`refs/hop/accepted` but intentionally leaves the visible root untouched.
-`hop sync` safely catches a stale accepted-ancestor root up to the current
-accepted state, including projects created with older Hop builds.
-
-After every successful `hop land` or `hop accept`, Hop automatically performs a
-non-forced push of the accepted commit when the active branch has an
-unambiguous upstream. No remote is a normal local-only mode. Push failure does
-not undo acceptance; `hop push` retries the current accepted commit. Agents
-must never replace a non-fast-forward rejection with a force-push.
-
-`hop begin` is the interactive-agent entry point. It initializes Hop when
-necessary and captures the current message before the agent performs project
-work. Runtime adapters identify themselves through `HOP_AGENT` or `--agent`
-and use `--session` to supply a stable conversation/run key. A later
-`hop begin` with the same session checkpoints the prior workspace before
-appending a follow-up while that work remains unfinished. Reconciliation
-transfers the session to its fresh attempt. After a proposal is accepted, the
-next `hop begin` starts from the latest accepted state and never reopens the
-completed workspace. Codex Desktop supplies `CODEX_THREAD_ID` as its default
-session key, so its adapter does not need to add `--session` explicitly.
-
-Pass the original message to `hop begin` without model-side redaction. Hop's
-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 |
-|---:|---|
-| `0` | Success |
-| `1` | Git, SQLite, filesystem, or internal error |
-| `2` | Invalid CLI usage |
-| `20` | Genuine three-way merge conflict; reconciliation workspace prepared |
-| `21` | Accepted or attempt head changed during compare-and-swap |
-| `22` | Validation command failed |
-| `23` | Visible project root diverged or contains an overwrite collision |
-
-A failed `hop check` or final landing check persists its evidence. A blocked or failed landing does not advance accepted state.
-
-## Capture modes
-
-### Interactive agent skill
-
-The user types normally in their agent interface. The Hop skill makes
-`hop begin` its first project action and then directs every operation into the
-returned workspace. This is a pre-project-effect boundary: the runtime has
-already received the prompt, but no repository inspection, command, or
-modification may precede the durable prompt state. Codex Desktop is one such
-adapter; it provides session continuity through `CODEX_THREAD_ID`.
-
-### Controller-grade pre-delivery capture
-
-```bash
-hop init
-hop start --agent "Add password reset emails"
-```
-
-Use the returned workspace and environment to launch the agent. For example, conceptually:
-
-```bash
-eval "$(hop env P_...)"
- ""
-```
-
-The exact agent command is harness-specific. This stronger mode stores the
-prompt before the model receives it. A future trusted prompt-submission hook can
-provide the same boundary inside compatible agent clients.
-
-## Failure handling
-
-- **Missing Hop environment:** run `hop begin` before project work and use the returned state and workspace.
-- **Check failure:** fix the live workspace, checkpoint/check again, then create a new proposal.
-- **Review-only request:** preserve and report the proposal without landing it.
-- **Frozen proposal needs changes:** record a follow-up prompt; never mutate the stored proposal.
-- **Merge conflict on landing:** continue automatically in the returned
- reconciliation prompt/workspace; inspect both inputs, resolve textual and
- structural conflicts, validate, propose, and land again. Stop only for
- product ambiguity, not ordinary textual overlap.
-- **Visible-root conflict:** preserve the proposal and the user's files. Do not substitute controller-only `hop accept`; resolve or capture the visible changes, then land again.
-- **Controller-accepted root is stale:** run `hop sync`; it succeeds only from an accepted ancestor and never overwrites divergence.
-- **Automatic push warning:** retry once with `hop push`; preserve a diverged remote and never force-push it.
-- **Ref inconsistency:** run `hop doctor`; use `hop doctor --repair` only outside final validation.
-- **Secrets:** Hop redacts high-confidence provider keys plus contextual tokens,
- passwords, private keys, authorization headers, and credential-bearing URLs
- before durable storage. It also sanitizes recorded check commands/output and
- proposal summaries. Detection is defense in depth, not a substitute for
- environment variables or a secret manager. Never repeat a detected secret.
diff --git a/wiki/Agent-Workflow.md b/wiki/Agent-Workflow.md
deleted file mode 100644
index 90d55a0..0000000
--- a/wiki/Agent-Workflow.md
+++ /dev/null
@@ -1,95 +0,0 @@
-# Agent integrations and workflow
-
-## Skill-based integration
-
-A compatible agent skill makes prompt capture the agent's first repository
-action. The integration supplies a stable agent and session identity. In a
-POSIX shell:
-
-```bash
-hop begin --agent my-agent --session stable-session-id --heredoc <<'HOP_PROMPT_EOF'
-
-HOP_PROMPT_EOF
-```
-
-In PowerShell:
-
-```powershell
-$hopPrompt = @'
-
-'@
-$hopPrompt | hop begin --agent my-agent --session stable-session-id --heredoc
-```
-
-The agent adopts the returned `HOP_STATE_ID`, `HOP_TASK_ID`,
-`HOP_ATTEMPT_ID`, and `HOP_WORKSPACE`, then confines reads, commands, edits, and
-tests to that workspace.
-
-The normal lifecycle is:
-
-```bash
-hop check P_... -- go test ./...
-hop propose --summary "Implemented the requested behavior" P_...
-hop land R_... -- go test ./...
-```
-
-No second landing authorization is requested unless the user explicitly asks
-for review-first behavior. After acceptance, Hop automatically pushes the
-accepted commit when the repository has an unambiguous upstream. The agent does
-not ask the user to run `git push`.
-
-Skill-based capture stores the agent's verbatim transcription of the visible
-message and its attachment references. Because the skill runs after the client
-receives the message, it cannot prove byte-for-byte fidelity with the raw
-submission. A trusted prompt-submission hook or controller is the deterministic
-capture boundary.
-
-### Codex Desktop example
-
-The bundled Codex integration uses `CODEX_THREAD_ID` as its stable session key,
-defaults the agent name to `codex`, and lets the user type normally. Its bundle
-is installed at `${CODEX_HOME:-~/.codex}/skills/hop`; the same files are also
-installed at `~/.agents/skills/hop` for compatible clients.
-
-## Follow-up messages
-
-A later `hop begin` with the same integration session checkpoints existing
-workspace effects, appends a new prompt state, and continues the same attempt
-while that work remains unfinished. If Hop prepares reconciliation, the session
-follows its fresh workspace. After the result lands, the next prompt starts a
-new task and attempt rooted at the latest accepted state. Completed workspaces
-are never reopened, and the user does not carry state IDs between messages.
-
-## Controller-grade capture
-
-A harness that can persist before delivering a prompt to the model can use:
-
-```bash
-hop init
-hop start --agent my-agent --heredoc <<'HOP_PROMPT_EOF'
-Add password reset emails
-HOP_PROMPT_EOF
-eval "$(hop env P_...)"
-```
-
-Only deliver the prompt after `hop start` exits successfully. Controller-managed
-follow-ups use:
-
-```bash
-hop prompt --from P_... --heredoc
-```
-
-This provides a stronger pre-delivery boundary than an agent-side skill, which
-can only guarantee capture before project effects.
-
-## Agent rules
-
-- Never edit the canonical project root directly.
-- Never mutate a frozen proposal.
-- Inspect landing warnings. If automatic push failed transiently, retry once
- with `hop push`; never force-push a diverged remote.
-- Do not bypass `hop land` with Git reset, checkout, worktree, or manual copying.
-- Run validation against immutable checkpoints and the final integrated tree.
-- Let Hop merge compatible concurrent work.
-- Resolve genuine reconciliation workspaces without asking the user to perform
- source-control mechanics, unless the underlying product intents are ambiguous.
diff --git a/wiki/Architecture.md b/wiki/Architecture.md
deleted file mode 100644
index 0089eb2..0000000
--- a/wiki/Architecture.md
+++ /dev/null
@@ -1,59 +0,0 @@
-# Architecture
-
-Hop uses Git under the hood, but it is not merely a directory of metadata files
-inside `.git`.
-
-## Git responsibilities
-
-Git provides:
-
-- content-addressed blobs and trees;
-- synthetic commits for immutable snapshots;
-- detached worktrees for attempt isolation;
-- three-way merge behavior;
-- diffs and path inspection; and
-- interoperability with existing repositories.
-
-Hop never needs to move the user's active branch or rewrite the real index.
-Private objects are pinned beneath `refs/hop/states/*`; accepted history is
-mirrored at `refs/hop/accepted`.
-
-## Hop responsibilities
-
-`.hop/hop.db` is a SQLite WAL database containing:
-
-- prompt/task/attempt identity;
-- typed state edges and accepted lineage;
-- evidence tied to exact source trees;
-- session heads for interactive-agent follow-ups;
-- materialized-root state; and
-- immutable audit events.
-
-## Project layout
-
-```text
-.hop/
-├── hop.db
-├── workspaces/
-├── checks/
-├── integration/
-└── *.lock
-```
-
-`.hop/` is added to `.git/info/exclude`, not the public `.gitignore`. Hop refuses
-initialization when `.hop` is already tracked as user-owned project content.
-
-## Acceptance consistency
-
-Acceptance is serialized and compare-and-swapped. SQLite is authoritative;
-derived Git refs can be repaired by `hop doctor --repair`. Visible-root landing
-also tracks which accepted state is physically visible, allowing safe catch-up
-with `hop sync` without treating a divergent folder as disposable.
-
-After that local transaction succeeds, Hop attempts a non-forced push of the
-accepted commit to the inferred upstream branch. Remote publication is derived
-and retryable: its failure cannot roll back or corrupt the durable local
-acceptance.
-
-For the full product direction, read the
-[product blueprint](https://githop.xyz/GnosysLabs/Hop/src/branch/main/docs/product-blueprint.md).
diff --git a/wiki/CLI-Reference.md b/wiki/CLI-Reference.md
deleted file mode 100644
index de15b84..0000000
--- a/wiki/CLI-Reference.md
+++ /dev/null
@@ -1,78 +0,0 @@
-# CLI reference
-
-Add `--json` anywhere in a command for machine-readable output. Successful
-responses use this envelope:
-
-```json
-{
- "ok": true,
- "data": {}
-}
-```
-
-The JSON shape is an alpha contract and may evolve before the first tagged
-release.
-
-## Project and prompt lifecycle
-
-| Command | Purpose |
-|---|---|
-| `hop init [path]` | Initialize Hop without moving the Git branch or index |
-| `hop begin ...` | Interactive-agent entry point: initialize if needed, capture prompt, continue session |
-| `hop prompt ...` | Controller-managed prompt or follow-up capture |
-| `hop checkpoint STATE` | Freeze current attempt progress |
-| `hop check STATE -- COMMAND...` | Validate an immutable checkpoint |
-| `hop propose [--summary TEXT] STATE` | Freeze a candidate proposal |
-| `hop land PROPOSAL [-- COMMAND...]` | Accept and synchronize the visible root |
-| `hop refresh PROPOSAL` | Explicitly prepare/reuse conflict reconciliation |
-
-`hop start` aliases `hop prompt`; `hop reconcile` aliases `hop refresh`.
-
-## Controller and synchronization commands
-
-| Command | Purpose |
-|---|---|
-| `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 immutable, repository-safe prompt records to `.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 |
-
-## Inspection
-
-| Command | Purpose |
-|---|---|
-| `hop status` | Accepted head, attempts, and visible-root status |
-| `hop graph` | State graph |
-| `hop state STATE` | One state and its provenance |
-| `hop env STATE` | Shell exports for an attempt |
-| `hop diff STATE` | Diff represented by a state |
-| `hop history` | Accepted lineage |
-| `hop version` | Installed version |
-
-## Agent integration bundle
-
-```bash
-hop skill install [--path SKILLS_DIR] [--force]
-hop skill print
-```
-
-Without `--path`, Hop installs the same Hop-managed skill files at
-`~/.agents/skills/hop` and `${CODEX_HOME:-~/.codex}/skills/hop`. With `--path`,
-it installs only to `SKILLS_DIR/hop`.
-
-## Exit codes
-
-| Code | Meaning |
-|---:|---|
-| `0` | Success |
-| `1` | Git, SQLite, filesystem, or internal failure |
-| `2` | Invalid usage |
-| `20` | Merge conflict; reconciliation workspace was prepared |
-| `21` | Attempt or accepted head changed during compare-and-swap |
-| `22` | Validation command failed |
-| `23` | Visible-root divergence or overwrite collision |
-
-Exit `20` is a continuation signal for an agent, not a request for the user to
-manually merge files.
diff --git a/wiki/Core-Concepts.md b/wiki/Core-Concepts.md
deleted file mode 100644
index 6884511..0000000
--- a/wiki/Core-Concepts.md
+++ /dev/null
@@ -1,71 +0,0 @@
-# Core concepts
-
-Hop versions intent and source together. Git remains the content store; Hop adds
-the causal state graph that explains which instruction produced which result.
-
-## States
-
-| Prefix | State | Meaning |
-|---|---|---|
-| `A_` | Accepted | Canonical Hop project revision |
-| `P_` | Prompt | Durable instruction and pre-effect context |
-| `C_` | Checkpoint | Immutable snapshot of attempt progress |
-| `R_` | Proposal | Frozen candidate result |
-| `F_` | Failed | Durable failed execution or validation result |
-| `X_` | Cancelled | Terminal cancelled result |
-
-Two states can reference the same Git tree and still be distinct occurrences.
-For example, a prompt and checkpoint may contain identical files but represent
-different moments and causal roles.
-
-## Task
-
-A task groups the prompts and attempts pursuing one user outcome. Follow-up
-messages pursuing unfinished work stay connected automatically through
-a stable session ID supplied by the integration. Codex Desktop supplies
-`CODEX_THREAD_ID` automatically. Once that outcome is accepted, the next message
-starts a new Hop task at the latest accepted state even when the client
-conversation stays open.
-
-## Attempt and workspace
-
-An attempt is one agent approach. Each attempt has a detached Git worktree under
-`.hop/workspaces/`. Agents edit there instead of racing in the visible project
-root.
-
-## Evidence
-
-`hop check` snapshots the workspace and runs validation against that immutable
-tree. Evidence stores the command, redacted output, exit code, and exact tree
-hash.
-
-## Proposal
-
-`hop propose` freezes a candidate tree. Later workspace edits cannot mutate the
-proposal.
-
-## Landing
-
-`hop land` composes the proposal onto the current accepted state, runs optional
-final-tree validation, advances accepted history with compare-and-swap, and
-safely materializes the result into the visible project directory.
-
-`hop accept` is lower-level controller behavior: it advances internal accepted
-state but intentionally leaves the visible folder unchanged.
-
-## Visible root
-
-The visible root is the project directory selected in an agent client or passed
-as the controller's working directory. Hop only materializes into it when it
-still matches an accepted Hop ancestor. Untracked, ignored, staged, or ordinary
-file divergence that could be overwritten causes a fail-closed error.
-
-## Automatic upstream push
-
-Every successful accepted transition is automatically pushed to the active
-branch's configured Git upstream. If no upstream is set, Hop uses `origin`, or a
-single unambiguous remote, with the active branch name. It pushes only accepted
-commits—not prompts, checkpoints, proposals, SQLite history, or workspaces—and
-never force-pushes. A network, authentication, or non-fast-forward failure
-leaves the accepted local state intact and is returned as a warning for the
-agent to handle.
diff --git a/wiki/Getting-Started.md b/wiki/Getting-Started.md
deleted file mode 100644
index a6b7991..0000000
--- a/wiki/Getting-Started.md
+++ /dev/null
@@ -1,75 +0,0 @@
-# Getting started
-
-## Use Hop with an agent integration
-
-1. [Install Hop](Installation).
-2. Select an existing Git project in a compatible agent client, or make it the
- controller's working directory.
-3. Ask the agent to make a normal change.
-
-That is the full user workflow. Do not manually create `.hop`, route the prompt
-through a terminal, or tell the agent to work inside `.hop/workspaces`. A Hop
-integration does that coordination for the agent.
-
-Without `--path`, `hop skill install` writes the same Hop-managed skill files to
-`~/.agents/skills/hop` and `${CODEX_HOME:-~/.codex}/skills/hop`. Compatible
-runtimes can use the shared bundle. An explicit `--path` installs only to the
-requested skills directory.
-
-### Codex Desktop example
-
-Restart Codex Desktop after installing or upgrading the skill, select a Git
-project, and ask Codex to work normally. The skill is eligible for implicit
-activation on every repository task; mention `$hop` for deterministic explicit
-activation.
-
-## What happens on the first task
-
-Before reading or changing project files, the agent runs `hop begin`. Hop then:
-
-- initializes local state without moving the Git branch or index;
-- stores the prompt after redacting detected credentials;
-- creates an isolated attempt workspace;
-- returns the state and workspace to the agent; and
-- keeps all project-changing work inside that workspace.
-
-The agent validates, proposes, and lands the result. A successful `hop land`
-updates the visible project folder to the accepted tree.
-
-## Confirm the result
-
-From the selected project directory:
-
-```bash
-hop status
-hop history
-hop doctor
-```
-
-A normal interactive result reports `Root: synchronized`.
-
-If the active Git branch has an upstream—or the repository has one unambiguous
-`origin`/single-remote destination—landing also fast-forward pushes the accepted
-commit automatically. Hop never force-pushes. Repositories without a remote
-remain local without treating that as an error.
-
-## Ask for review before landing
-
-Automatic landing is the default because the original task authorizes the
-local code change. To stop at a proposal, say one of the following in the task:
-
-- `review first`
-- `proposal only`
-- `do not land`
-
-## Connect another agent runtime
-
-If a compatible runtime does not read `~/.agents/skills`, install the embedded
-bundle into that runtime's skills directory:
-
-```bash
-hop skill install --path /path/to/agent/skills --force
-```
-
-Controllers that can persist a prompt before model delivery should use
-`hop start`, `hop env`, and `hop prompt`; see [Agent workflow](Agent-Workflow).
diff --git a/wiki/Home.md b/wiki/Home.md
deleted file mode 100644
index 3a4ede4..0000000
--- a/wiki/Home.md
+++ /dev/null
@@ -1,47 +0,0 @@
-# Hop documentation
-
-Hop is prompt-native version control for coding agents. It stores each prompt
-as an immutable project state, gives agent work an isolated workspace, validates
-the exact tree being accepted, and safely materializes accepted results into the
-visible project folder. When a Git upstream exists, accepted commits are pushed
-automatically.
-
-## Start here
-
-- [Installation](Installation)
-- [Getting started](Getting-Started)
-- [Core concepts](Core-Concepts)
-- [Agent integrations and workflow](Agent-Workflow)
-- [Parallel agents and conflict resolution](Parallel-Agents-and-Conflicts)
-
-## Reference and operations
-
-- [CLI reference](CLI-Reference)
-- [Architecture](Architecture)
-- [Security and privacy](Security-and-Privacy)
-- [Troubleshooting](Troubleshooting)
-- [Upgrading and uninstalling](Upgrading-and-Uninstalling)
-- [Release checklist](Release-Checklist)
-
-## Thirty-second install
-
-macOS or Linux:
-
-```bash
-curl -fsSL https://githop.xyz/GnosysLabs/Hop/raw/branch/main/scripts/install.sh | sh
-```
-
-Windows PowerShell:
-
-```powershell
-irm https://githop.xyz/GnosysLabs/Hop/raw/branch/main/scripts/install.ps1 | iex
-```
-
-Open a Git project in a compatible agent client and work normally. The installed
-skill bundle activates Hop before the agent inspects or changes the project;
-controllers can invoke the same protocol directly. Codex Desktop is a bundled
-integration, and there is no required manual `hop init` step.
-
-Hop is currently an alpha. Keep Git history and normal backups, read release
-notes before upgrading, and report unexpected behavior with `hop doctor` output
-after removing private paths or data.
diff --git a/wiki/Installation.md b/wiki/Installation.md
deleted file mode 100644
index dc592f1..0000000
--- a/wiki/Installation.md
+++ /dev/null
@@ -1,110 +0,0 @@
-# Installation
-
-Packaged binaries are the recommended installation. They need Git 2.40 or
-newer; they do not need a local Go toolchain.
-
-## macOS and Linux
-
-```bash
-curl -fsSL https://githop.xyz/GnosysLabs/Hop/raw/branch/main/scripts/install.sh | sh
-```
-
-The installer:
-
-1. detects macOS/Linux and `amd64`/`arm64`;
-2. downloads the matching archive from the latest Gitea Release;
-3. downloads `checksums.txt` and verifies SHA-256 before extraction;
-4. installs the CLI to `~/.local/bin/hop`;
-5. adds `~/.local/bin` to `.zprofile` or `.profile` when necessary; and
-6. runs `hop skill install --force` to install the bundled agent integration.
-
-The no-path skill command writes the same Hop-managed skill files to
-`~/.agents/skills/hop` and `${CODEX_HOME:-~/.codex}/skills/hop`. The second path
-supports Codex Desktop; compatible clients can use the shared `.agents` path.
-
-Review before execution:
-
-```bash
-curl -fsSLO https://githop.xyz/GnosysLabs/Hop/raw/branch/main/scripts/install.sh
-less install.sh
-sh install.sh
-```
-
-Installer options are environment variables:
-
-| Variable | Default | Purpose |
-|---|---|---|
-| `HOP_VERSION` | `latest` | Release tag such as `v0.1.0` |
-| `HOP_INSTALL_DIR` | `~/.local/bin` | Binary destination |
-| `HOP_INSTALL_SKILL` | `1` | Set to `0` for a CLI-only or custom integration |
-| `HOP_MODIFY_PATH` | `1` | Set to `0` to leave shell profiles unchanged |
-| `HOP_GITEA_URL` | `https://githop.xyz` | Gitea instance base URL |
-| `HOP_REPOSITORY` | `GnosysLabs/Hop` | Alternate Gitea owner/repository |
-
-Example:
-
-```bash
-HOP_VERSION=v0.1.0 HOP_INSTALL_DIR="$HOME/bin" sh install.sh
-```
-
-## Windows
-
-In PowerShell as your normal user:
-
-```powershell
-irm https://githop.xyz/GnosysLabs/Hop/raw/branch/main/scripts/install.ps1 | iex
-```
-
-The script verifies the Windows archive, installs to
-`%LOCALAPPDATA%\Programs\Hop`, updates the user PATH, and installs the shared and
-Codex-compatible skill bundles. To pin a version after downloading the script:
-
-```powershell
-.\install.ps1 -Version v0.1.0
-```
-
-Use `-SkipSkill` for a CLI-only or separately managed integration. Use `-SkipPath`
-when another tool manages your PATH.
-
-## Go install
-
-With Go 1.26 or newer:
-
-```bash
-go install githop.xyz/GnosysLabs/Hop/cmd/hop@latest
-hop skill install --force
-```
-
-Put `$(go env GOPATH)/bin` on PATH if `hop` is not found. The second command
-installs both default skill bundles; omit it for a CLI-only installation.
-
-## Build from source
-
-```bash
-git clone https://githop.xyz/GnosysLabs/Hop.git
-cd hop
-go test ./...
-go build -trimpath -o hop ./cmd/hop
-mkdir -p "$HOME/.local/bin"
-install -m 755 hop "$HOME/.local/bin/hop"
-"$HOME/.local/bin/hop" skill install --force
-```
-
-Source builds are intended for contributors and as a pre-release fallback.
-
-To install only one compatible runtime target, pass its parent skills directory:
-
-```bash
-hop skill install --path /path/to/agent/skills --force
-```
-
-## Verify
-
-```bash
-hop version
-hop help
-git --version
-```
-
-Restart any agent client that was open while its skill bundle changed. See
-[Getting started](Getting-Started) next.
diff --git a/wiki/Parallel-Agents-and-Conflicts.md b/wiki/Parallel-Agents-and-Conflicts.md
deleted file mode 100644
index a51b263..0000000
--- a/wiki/Parallel-Agents-and-Conflicts.md
+++ /dev/null
@@ -1,49 +0,0 @@
-# Parallel agents and conflict resolution
-
-Each independent prompt starts from an accepted state and receives its own
-worktree. Multiple agents can therefore read, edit, and test the same codebase
-without sharing a mutable working directory.
-
-## Compatible work
-
-Hop uses Git's real three-way content merge:
-
-- disjoint files compose automatically;
-- independent hunks in the same file compose automatically;
-- identical same-file changes coalesce; and
-- compatible rename/content and mode/content changes can compose.
-
-Before acceptance, Hop validates the exact integrated tree rather than trusting
-tests run only against an agent's stale starting point.
-
-## Genuine conflicts
-
-When Git cannot compose both intents, `hop land` exits with code `20` and
-creates a fresh reconciliation attempt. The response includes:
-
-- a reconciliation prompt state;
-- a new isolated workspace;
-- current accepted and proposed inputs; and
-- conflict candidate paths.
-
-The agent switches to that workspace, preserves both compatible intents,
-resolves the conflict, runs `hop check`, creates a new proposal, and lands again.
-Hop requires successful checked evidence before a reconciliation proposal can
-be frozen.
-
-Text conflicts usually contain diff3 markers. Delete/rename, binary, symlink,
-mode, and directory conflicts may not, so the agent must inspect both input
-states rather than assuming the provisional tree is resolved.
-
-## What still needs a person
-
-The agent should stop only when the source conflict exposes a real product
-decision—for example, two prompts require mutually exclusive API behavior and
-neither recorded intent determines the right result. Ordinary code overlap is
-not user work.
-
-## Visible-root safety
-
-The selected project directory remains at the last accepted state while
-reconciliation is underway. If that folder has user-owned divergence, Hop does
-not overwrite it; `hop land` exits with code `23` and reports the paths.
diff --git a/wiki/Release-Checklist.md b/wiki/Release-Checklist.md
deleted file mode 100644
index c059873..0000000
--- a/wiki/Release-Checklist.md
+++ /dev/null
@@ -1,85 +0,0 @@
-# Release checklist
-
-Hop releases are built on a trusted maintainer machine and uploaded by
-GoReleaser as draft Gitea Releases. Gitea Actions and act runners are not
-required; the server only stores source, tags, release metadata, and assets.
-
-The canonical repository is `githop.xyz/GnosysLabs/Hop`.
-
-## One-time Gitea setup
-
-- Create the `GnosysLabs/Hop` repository and set its default branch to `main`.
-- 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.
-- 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
- Gitea's `[attachment] ALLOWED_TYPES` configuration.
-- Enable the repository wiki, then push the files in `wiki/` to its wiki Git
- repository.
-
-## Public-launch gates
-
-- Choose and add a `LICENSE`. The local publishing script intentionally fails
- without one; this is a product/legal decision, not a build default.
-- Add `SECURITY.md` with a monitored private disclosure address.
-- Create an offline-controlled release-signing key, publish its public key, and
- add detached signing for `checksums.txt` before general availability.
-- Confirm the `githop.xyz/GnosysLabs/Hop` Go import path serves valid `go-import`
- metadata.
-- Back up the Gitea database, repositories, and release attachments.
-
-## Validate before tagging
-
-```bash
-scripts/release-local.sh --snapshot
-```
-
-Inspect `dist/` and test at least one archive on each operating system family.
-Confirm `hop version` reports the snapshot/tag-injected version and
-`hop skill install --force` installs identical Hop-managed files at both
-default skill destinations while preserving unrelated user files.
-
-## Create a release
-
-1. Update release notes. The signed Git tag is the version source and is
- 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. 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.
-6. Immediately run `unset GITEA_TOKEN`.
-7. Download the draft assets and independently verify checksums, version output,
- skill installation, and a disposable Hop project.
-8. Publish the Gitea draft only after those checks pass.
-9. Test both one-command installers against the now-published release.
-
-## Expected assets
-
-```text
-hop_darwin_amd64.tar.gz
-hop_darwin_arm64.tar.gz
-hop_linux_amd64.tar.gz
-hop_linux_arm64.tar.gz
-hop_windows_amd64.zip
-hop_windows_arm64.zip
-checksums.txt
-```
-
-## After the first release
-
-- Create a Gitea-hosted Homebrew tap/cask fed by immutable release URLs and
- checksums; do not publish placeholder hashes.
-- Add Windows package-manager metadata only after the Windows artifact has been
- tested on a real signed build.
-- Establish release retention, package cleanup, rollback, and incident-response
- procedures for the custom Gitea instance.
diff --git a/wiki/Security-and-Privacy.md b/wiki/Security-and-Privacy.md
deleted file mode 100644
index 9c7baab..0000000
--- a/wiki/Security-and-Privacy.md
+++ /dev/null
@@ -1,60 +0,0 @@
-# Security and privacy
-
-## Local data
-
-Hop stores state in the project under `.hop/`. Prompt text, source trees,
-commands, and check output are local unless the project or its filesystem is
-copied elsewhere. SQLite data is not encrypted at rest.
-
-`.hop/` is excluded through `.git/info/exclude`, so ordinary Git operations do
-not publish it. Initialization refuses to hide a `.hop` directory that the
-project already tracks.
-
-## Credential redaction
-
-Before persistence, Hop redacts high-confidence provider keys, contextual
-tokens/passwords, private keys, authorization headers, and credential-bearing
-URLs. The same sanitizer is applied to proposal summaries and recorded check
-commands/output.
-
-Detection is defense in depth, not a guarantee. Use environment variables or a
-secret manager. Rotate any real credential pasted into any agent prompt even
-when Hop reports a redaction.
-
-## Installer and release integrity
-
-Packaged installers download `checksums.txt` from the same published Gitea
-Release and verify the selected archive before extraction. Gitea Releases are
-created as drafts, after race tests, vetting, and cross-platform builds, then
-must be reviewed before publication.
-
-For stronger provenance before general availability, the release owner should
-sign `checksums.txt` with an offline-controlled release key and publish the
-public key independently. Checksum signing is listed as a launch gate in the
-[release checklist](Release-Checklist).
-
-## Release-machine trust
-
-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. 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
-
-Hop does not use `reset --hard`, move the active branch, or write the user's
-real Git index. Visible-root synchronization fails closed when files, ignored
-destinations, or staged state could be overwritten.
-
-Automatic push delegates authentication to the user's existing Git transport
-and credential configuration. Hop does not store remote passwords, SSH private
-keys, or access tokens. It disables terminal credential prompting in the
-background push path and redacts detected credentials from returned errors.
-
-## Reporting a vulnerability
-
-Before the public security contact is configured, disclose vulnerabilities
-privately to the repository owner rather than opening a public issue. Add a
-`SECURITY.md` with the final contact before the first public release.
diff --git a/wiki/Troubleshooting.md b/wiki/Troubleshooting.md
deleted file mode 100644
index 988fa7b..0000000
--- a/wiki/Troubleshooting.md
+++ /dev/null
@@ -1,91 +0,0 @@
-# Troubleshooting
-
-## `hop: command not found`
-
-Open a new terminal after installation. Confirm the install directory is on
-PATH:
-
-```bash
-command -v hop
-printf '%s\n' "$PATH"
-```
-
-The default Unix location is `~/.local/bin`. On Windows it is
-`%LOCALAPPDATA%\Programs\Hop`.
-
-## An agent integration does not activate Hop
-
-```bash
-hop skill install --force
-```
-
-The command refreshes both default skill bundles. Restart the agent client. For
-Codex Desktop, mention `$hop` in a task as a deterministic activation fallback.
-For another compatible runtime, confirm that it reads `~/.agents/skills` or run
-`hop skill install --path /path/to/agent/skills --force`.
-
-## The installer cannot find a release
-
-Only published Gitea Releases appear through the public releases API. Drafts
-and an instance that is not live will return an error; published prereleases
-are supported. Pin an existing tag with `HOP_VERSION`, or use the source build
-until the first release is published.
-
-## Git is too old
-
-Hop requires Git 2.40 or newer for structured, explicit-base `merge-tree`
-behavior:
-
-```bash
-git --version
-```
-
-Upgrade Git through the operating system package manager before retrying.
-
-## Exit code 20: merge conflict
-
-The agent should adopt the returned reconciliation prompt and workspace,
-resolve both intents, run `hop check`, propose, and land again. Users should not
-need to perform ordinary source merges.
-
-## Exit code 22: validation failed
-
-The accepted head did not advance. Inspect the recorded output, fix the attempt
-workspace, and rerun the check.
-
-## Exit code 23 or `Root: diverged`
-
-Hop found visible files or index state it will not overwrite. Preserve those
-changes. Capture them as a new Hop task or resolve them intentionally, then
-retry `hop land`. Do not bypass this with `hop accept` in interactive workflows.
-
-## Internal ref or object warning
-
-```bash
-hop doctor
-```
-
-If SQLite is healthy and the report specifically identifies derived refs:
-
-```bash
-hop doctor --repair
-```
-
-Do not repair while a final validation command is running.
-
-## Automatic push failed
-
-The accepted state remains safe locally. Hop never force-pushes. Retry a
-transient network or authentication failure with:
-
-```bash
-hop push
-```
-
-If the remote branch moved independently, preserve both histories and resolve
-the divergence intentionally; do not replace this with a force-push.
-
-## A secret was pasted
-
-Rotate it. Hop redaction reduces durable exposure but cannot prove that every
-credential format, attachment, agent log, or external system omitted the value.
diff --git a/wiki/Upgrading-and-Uninstalling.md b/wiki/Upgrading-and-Uninstalling.md
deleted file mode 100644
index 83feeb7..0000000
--- a/wiki/Upgrading-and-Uninstalling.md
+++ /dev/null
@@ -1,75 +0,0 @@
-# Upgrading and uninstalling
-
-## Upgrade packaged installations
-
-Rerun the installer. It replaces the binary and refreshes both default skill
-bundles:
-
-```bash
-curl -fsSL https://githop.xyz/GnosysLabs/Hop/raw/branch/main/scripts/install.sh | sh
-```
-
-Pin a release when required:
-
-```bash
-curl -fsSL https://githop.xyz/GnosysLabs/Hop/raw/branch/main/scripts/install.sh | \
- HOP_VERSION=v0.1.0 sh
-```
-
-Windows:
-
-```powershell
-irm https://githop.xyz/GnosysLabs/Hop/raw/branch/main/scripts/install.ps1 -OutFile install.ps1
-.\install.ps1 -Version v0.1.0
-Remove-Item install.ps1
-```
-
-After upgrading:
-
-```bash
-hop version
-hop skill install --force
-hop doctor
-```
-
-Restart any agent client whose installed skill changed.
-
-## Upgrade Go installations
-
-```bash
-go install githop.xyz/GnosysLabs/Hop/cmd/hop@latest
-hop skill install --force
-```
-
-## Project migrations
-
-Hop opens and migrates older supported SQLite schemas automatically. Back up
-important repositories before alpha upgrades and read the release notes for any
-one-way schema change.
-
-## Uninstall the CLI and skill bundles
-
-Unix default:
-
-```bash
-rm -f "$HOME/.local/bin/hop"
-rm -rf "$HOME/.agents/skills/hop"
-rm -rf "${CODEX_HOME:-$HOME/.codex}/skills/hop"
-```
-
-Windows PowerShell:
-
-```powershell
-Remove-Item -Force "$env:LOCALAPPDATA\Programs\Hop\hop.exe"
-Remove-Item -Recurse -Force "$HOME\.agents\skills\hop"
-Remove-Item -Recurse -Force "$HOME\.codex\skills\hop"
-```
-
-If `CODEX_HOME` points somewhere else, remove its `skills\hop` directory instead
-of `$HOME\.codex\skills\hop`. Remove any explicit `--path` target manually.
-Remove the Hop install directory from PATH if it is no longer used.
-
-Uninstalling the program does not delete project-local `.hop/` histories. That
-is intentional, so reinstalling restores access. Deleting a project's `.hop/`
-directory permanently removes its prompt graph, evidence, workspaces, and
-accepted Hop history; make a backup and treat that as destructive data removal.
diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md
deleted file mode 100644
index d877ea9..0000000
--- a/wiki/_Sidebar.md
+++ /dev/null
@@ -1,14 +0,0 @@
-## Hop
-
-- [Home](Home)
-- [Installation](Installation)
-- [Getting started](Getting-Started)
-- [Core concepts](Core-Concepts)
-- [Agent workflow](Agent-Workflow)
-- [Parallel agents and conflicts](Parallel-Agents-and-Conflicts)
-- [CLI reference](CLI-Reference)
-- [Architecture](Architecture)
-- [Security and privacy](Security-and-Privacy)
-- [Troubleshooting](Troubleshooting)
-- [Upgrading and uninstalling](Upgrading-and-Uninstalling)
-- [Release checklist](Release-Checklist)