Compare commits
12 Commits
v0.1.0-alpha.1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4364e1d8a4 | |||
| ff62e89596 | |||
| 59b8ca7c05 | |||
| 53823bad81 | |||
| 787acb2330 | |||
| 2881f02d18 | |||
| 58fcbd5fe2 | |||
| 74ba06de91 | |||
| 074e849d84 | |||
| e5bb6a5600 | |||
| a74f420307 | |||
| 853acec229 |
+5
-1
@@ -1,7 +1,11 @@
|
||||
.DS_Store
|
||||
|
||||
# Hop's local state and isolated workspaces.
|
||||
/.hop/
|
||||
# Hop's database and disposable workspaces stay local. Its immutable prompt
|
||||
# records are intentionally versioned with the repository.
|
||||
/.hop/*
|
||||
!/.hop/records/
|
||||
!/.hop/records/**
|
||||
|
||||
# Local build and verification artifacts.
|
||||
/hop
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "P_06FN3MBF9ABQ6NTST1B7GZ0",
|
||||
"task_id": "T_06FN3MBF98GWD4NA5PA1RWG",
|
||||
"attempt_id": "AT_06FN3MBF98FBDSE1BZDP5DG",
|
||||
"state_id": "P_06FN3MBF9ABQ6NTST1B7GZ0",
|
||||
"prompt": "what? wtf? i need it in the right folder how do i do that",
|
||||
"agent_name": "codex",
|
||||
"status": "completed",
|
||||
"created_at": "2026-07-11T15:35:07.850559Z",
|
||||
"metadata": {
|
||||
"source_tree": "012d4872472b5c8621c6ef3cedd50509e09e7aa6",
|
||||
"git_commit": "a70773826f67e30e57b7c0a0419e3b601674632f",
|
||||
"attempt_head": "R_06FN5TJRZC3APECRM9VM2A8",
|
||||
"attempt_head_kind": "proposal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "P_06FN4EVBXYRV3MQJE7FXJ9R",
|
||||
"task_id": "T_06FN3MBF98GWD4NA5PA1RWG",
|
||||
"attempt_id": "AT_06FN3MBF98FBDSE1BZDP5DG",
|
||||
"state_id": "P_06FN4EVBXYRV3MQJE7FXJ9R",
|
||||
"prompt": "so.. i can have multiple agents working on the same codebase at the same time and it should be fine?",
|
||||
"agent_name": "codex",
|
||||
"status": "completed",
|
||||
"created_at": "2026-07-11T17:30:53.807892Z",
|
||||
"metadata": {
|
||||
"source_tree": "22e1b6bfde927afbe0622a552cfe6703fed85f3f",
|
||||
"git_commit": "dcad0b49f3e5cde9cffd3ea23f50088e6ba4c617",
|
||||
"attempt_head": "R_06FN5TJRZC3APECRM9VM2A8",
|
||||
"attempt_head_kind": "proposal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "P_06FN4K2V1S84CQ0XFJDQRQG",
|
||||
"task_id": "T_06FN3MBF98GWD4NA5PA1RWG",
|
||||
"attempt_id": "AT_06FN3MBF98FBDSE1BZDP5DG",
|
||||
"state_id": "P_06FN4K2V1S84CQ0XFJDQRQG",
|
||||
"prompt": "hop is broken. \"I updated the reference to v=3, but Hop blocked landing because another recent task also changed hop-home.css, creating an overlap. That stylesheet conflict needs to be resolved before I can safely apply the cache-busting fix.\" this is fucking horse shit. the agent should intelligently resolve the conflict",
|
||||
"agent_name": "codex",
|
||||
"status": "completed",
|
||||
"created_at": "2026-07-11T17:49:23.598284Z",
|
||||
"metadata": {
|
||||
"source_tree": "22e1b6bfde927afbe0622a552cfe6703fed85f3f",
|
||||
"git_commit": "94d2ac053cd45d514d586764836ee46eb26c2af2",
|
||||
"attempt_head": "R_06FN5TJRZC3APECRM9VM2A8",
|
||||
"attempt_head_kind": "proposal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "P_06FN4ZBB60PW23320GTM4T8",
|
||||
"task_id": "T_06FN3MBF98GWD4NA5PA1RWG",
|
||||
"attempt_id": "AT_06FN3MBF98FBDSE1BZDP5DG",
|
||||
"state_id": "P_06FN4ZBB60PW23320GTM4T8",
|
||||
"prompt": "now i want to publish this as a real product people can use. how will people install it? by cloning a git repo and running some commands? can you update the readme with clear installation/getting started instructions, and create the wiki pages as well",
|
||||
"agent_name": "codex",
|
||||
"status": "completed",
|
||||
"created_at": "2026-07-11T18:42:58.992885Z",
|
||||
"metadata": {
|
||||
"source_tree": "8e3074490c46530db90e20242bd16000aee239d0",
|
||||
"git_commit": "56fe10e4deb1d42626d0a5dfc4f1446987338019",
|
||||
"attempt_head": "R_06FN5TJRZC3APECRM9VM2A8",
|
||||
"attempt_head_kind": "proposal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "P_06FN4ZY663SBZ8CDXP9YPF8",
|
||||
"task_id": "T_06FN3MBF98GWD4NA5PA1RWG",
|
||||
"attempt_id": "AT_06FN3MBF98FBDSE1BZDP5DG",
|
||||
"state_id": "P_06FN4ZY663SBZ8CDXP9YPF8",
|
||||
"prompt": "wait? should we create a packaged release? HELLO IM ASKING YOU WHATS THE BEST IMPLEMENTATION FOR A POLISHED PRODUCT NOT TO JUST WORK WITH HOWEVER IT IS NOW JSUT BECAUSE",
|
||||
"agent_name": "codex",
|
||||
"status": "completed",
|
||||
"created_at": "2026-07-11T18:45:33.360226Z",
|
||||
"metadata": {
|
||||
"source_tree": "8e3074490c46530db90e20242bd16000aee239d0",
|
||||
"git_commit": "422e59ec24e57ddb0d3df46199c4f16bf428371e",
|
||||
"attempt_head": "R_06FN5TJRZC3APECRM9VM2A8",
|
||||
"attempt_head_kind": "proposal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "P_06FN5172P0R8W5XZ1CHK8N0",
|
||||
"task_id": "T_06FN3MBF98GWD4NA5PA1RWG",
|
||||
"attempt_id": "AT_06FN3MBF98FBDSE1BZDP5DG",
|
||||
"state_id": "P_06FN5172P0R8W5XZ1CHK8N0",
|
||||
"prompt": "i wont be hosting this on github, ill be hosting it on githop.xyz which is a custom gitea. the site isnt live yet but its done",
|
||||
"agent_name": "codex",
|
||||
"status": "completed",
|
||||
"created_at": "2026-07-11T18:51:08.33613Z",
|
||||
"metadata": {
|
||||
"source_tree": "7879aa3466a9b33dcebbe8c1c9873334976577e5",
|
||||
"git_commit": "f434be463cc2940449970408924e653161dc9ac8",
|
||||
"attempt_head": "R_06FN5TJRZC3APECRM9VM2A8",
|
||||
"attempt_head_kind": "proposal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "P_06FN572J3TK7PBAZVM7HSVG",
|
||||
"task_id": "T_06FN3MBF98GWD4NA5PA1RWG",
|
||||
"attempt_id": "AT_06FN3MBF98FBDSE1BZDP5DG",
|
||||
"state_id": "P_06FN572J3TK7PBAZVM7HSVG",
|
||||
"prompt": "can you publish it under GnosysLabsLLC/Hop .. i have added my SSH key to githop so you should be able to",
|
||||
"agent_name": "codex",
|
||||
"status": "completed",
|
||||
"created_at": "2026-07-11T19:16:44.190798Z",
|
||||
"metadata": {
|
||||
"source_tree": "a91a4bd37315fa30673005970ce750755e9582be",
|
||||
"git_commit": "e13e291812530b2dee72d0caec1e5974a87fbf47",
|
||||
"attempt_head": "R_06FN5TJRZC3APECRM9VM2A8",
|
||||
"attempt_head_kind": "proposal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "P_06FN57YDR2V5WBGFMNBYN48",
|
||||
"task_id": "T_06FN3MBF98GWD4NA5PA1RWG",
|
||||
"attempt_id": "AT_06FN3MBF98FBDSE1BZDP5DG",
|
||||
"state_id": "P_06FN57YDR2V5WBGFMNBYN48",
|
||||
"prompt": "sorry actually its https://githop.xyz/org/GnosysLabs/Hop",
|
||||
"agent_name": "codex",
|
||||
"status": "completed",
|
||||
"created_at": "2026-07-11T19:20:32.448864Z",
|
||||
"metadata": {
|
||||
"source_tree": "a91a4bd37315fa30673005970ce750755e9582be",
|
||||
"git_commit": "020f15acdb2ae92f5fb80e431485a6ff8ea8025a",
|
||||
"attempt_head": "R_06FN5TJRZC3APECRM9VM2A8",
|
||||
"attempt_head_kind": "proposal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "P_06FN58K0XX5MSYB34619NX0",
|
||||
"task_id": "T_06FN3MBF98GWD4NA5PA1RWG",
|
||||
"attempt_id": "AT_06FN3MBF98FBDSE1BZDP5DG",
|
||||
"state_id": "P_06FN58K0XX5MSYB34619NX0",
|
||||
"prompt": "i turned off the proxy? does that make it work",
|
||||
"agent_name": "codex",
|
||||
"status": "completed",
|
||||
"created_at": "2026-07-11T19:23:21.19964Z",
|
||||
"metadata": {
|
||||
"source_tree": "a91a4bd37315fa30673005970ce750755e9582be",
|
||||
"git_commit": "ad1055c4f4e79f0a0efa9e39c2d6dd8ca5c1b219",
|
||||
"attempt_head": "R_06FN5TJRZC3APECRM9VM2A8",
|
||||
"attempt_head_kind": "proposal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "P_06FN58Y686QH957QKXDC820",
|
||||
"task_id": "T_06FN3MBF98GWD4NA5PA1RWG",
|
||||
"attempt_id": "AT_06FN3MBF98FBDSE1BZDP5DG",
|
||||
"state_id": "P_06FN58Y686QH957QKXDC820",
|
||||
"prompt": "why would you have to put port 22? thats not how it works with github. wtf?",
|
||||
"agent_name": "codex",
|
||||
"status": "completed",
|
||||
"created_at": "2026-07-11T19:24:52.673537Z",
|
||||
"metadata": {
|
||||
"source_tree": "a91a4bd37315fa30673005970ce750755e9582be",
|
||||
"git_commit": "092fec93f02614e919b8a75392044f86703d2293",
|
||||
"attempt_head": "R_06FN5TJRZC3APECRM9VM2A8",
|
||||
"attempt_head_kind": "proposal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "P_06FN596K5H32W6J8X4NNS4R",
|
||||
"task_id": "T_06FN3MBF98GWD4NA5PA1RWG",
|
||||
"attempt_id": "AT_06FN3MBF98FBDSE1BZDP5DG",
|
||||
"state_id": "P_06FN596K5H32W6J8X4NNS4R",
|
||||
"prompt": "no thats gucking dumb i want it to work with the base url? so wtf do i need to do? go into the vps and make it fucking work",
|
||||
"agent_name": "codex",
|
||||
"status": "completed",
|
||||
"created_at": "2026-07-11T19:26:01.516159Z",
|
||||
"metadata": {
|
||||
"source_tree": "a91a4bd37315fa30673005970ce750755e9582be",
|
||||
"git_commit": "c88206ebec78fd2abff1b971188a520ba39dedfa",
|
||||
"attempt_head": "R_06FN5TJRZC3APECRM9VM2A8",
|
||||
"attempt_head_kind": "proposal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "P_06FN5E0MF7G4PQ12VZTMB9G",
|
||||
"task_id": "T_06FN3MBF98GWD4NA5PA1RWG",
|
||||
"attempt_id": "AT_06FN3MBF98FBDSE1BZDP5DG",
|
||||
"state_id": "P_06FN5E0MF7G4PQ12VZTMB9G",
|
||||
"prompt": "idk what this is, im going to disable the actions thing on our instance we dont have the server resources for i\n\nAttachment: /var/folders/58/b6yn15tx3jb7_6lw9m67f89m0000gn/T/TemporaryItems/NSIRD_screencaptureui_4Rixx8/Screenshot 2026-07-11 at 12.45.33 PM.png",
|
||||
"agent_name": "codex",
|
||||
"status": "completed",
|
||||
"created_at": "2026-07-11T19:47:03.417913Z",
|
||||
"metadata": {
|
||||
"source_tree": "52869fbc4277ef5d2db2794c6d7867d6c23c423c",
|
||||
"git_commit": "c77bf19172e2fed26b577183282c27ce341c80d4",
|
||||
"attempt_head": "R_06FN5TJRZC3APECRM9VM2A8",
|
||||
"attempt_head_kind": "proposal"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "P_06FN5JNW8825596P3JQACJG",
|
||||
"task_id": "T_06FN3MBF98GWD4NA5PA1RWG",
|
||||
"attempt_id": "AT_06FN5J8NBKM5ES4ZMD5Y1CR",
|
||||
"state_id": "P_06FN5JNW8825596P3JQACJG",
|
||||
"prompt": "\"Hop correctly detected that this long-running task’s original workspace was based on an older accepted tree. It has created a reconciliation workspace; I’m preserving the current product code and applying only the README/wiki cleanup there, then I’ll revalidate and land it automatically.\" WHY DOES THIS HAPPEN WITH EVERY SINGLE FUCKING REQUEST?",
|
||||
"agent_name": "codex",
|
||||
"status": "accepted",
|
||||
"response_summary": "Reconcile concise README with current accepted product",
|
||||
"created_at": "2026-07-11T20:07:26.01873Z",
|
||||
"metadata": {
|
||||
"source_tree": "2c8e849cbf49c422470f5857fda59a1aaf9ac2eb",
|
||||
"git_commit": "240d7927e029adfc01c0a83622a8720963a3cbec",
|
||||
"attempt_head": "R_06FN5JXX64P7BMT8Q98HHE8",
|
||||
"attempt_head_kind": "proposal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "P_06FN5M2868H466RPTMYQ3B0",
|
||||
"task_id": "T_06FN3MBF98GWD4NA5PA1RWG",
|
||||
"attempt_id": "AT_06FN3MBF98FBDSE1BZDP5DG",
|
||||
"state_id": "P_06FN5M2868H466RPTMYQ3B0",
|
||||
"prompt": "ok please fix it",
|
||||
"agent_name": "codex",
|
||||
"status": "completed",
|
||||
"response_summary": "Roll completed agent sessions onto the latest accepted state",
|
||||
"created_at": "2026-07-11T20:13:29.522241Z",
|
||||
"metadata": {
|
||||
"source_tree": "4dae249394e559de9d1d86b76b5bcb1b551cc109",
|
||||
"git_commit": "4e5aa7120134cb7a017685e50c3ec8d1534ad816",
|
||||
"attempt_head": "R_06FN5TJRZC3APECRM9VM2A8",
|
||||
"attempt_head_kind": "proposal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "P_06FN63779989R5PFV1QF8TR",
|
||||
"task_id": "T_06FN637799RW5Q7WH9H6PJR",
|
||||
"attempt_id": "AT_06FN63779BJRBK1VFQ94GRR",
|
||||
"state_id": "P_06FN63779989R5PFV1QF8TR",
|
||||
"prompt": "does our installer install the skill in .agents so it works with any agent? and, why is the readme so specific to codex? it should work with any agent",
|
||||
"agent_name": "codex",
|
||||
"status": "accepted",
|
||||
"response_summary": "Install portable agent-neutral Hop skill and documentation",
|
||||
"created_at": "2026-07-11T21:19:42.41027Z",
|
||||
"metadata": {
|
||||
"source_tree": "fcc3f3c78f5bbf6fae43651a99019e833bd88530",
|
||||
"git_commit": "2f00b2dd3f8bc5e4c261563a9ac063d1716f0d47",
|
||||
"attempt_head": "R_06FN69VET1STZNKPWK913K8",
|
||||
"attempt_head_kind": "proposal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "P_06FN6BNGRJKMV6THWMBFVM8",
|
||||
"task_id": "T_06FN6BNGRHYXR3WCQBDNVNR",
|
||||
"attempt_id": "AT_06FN6BNGRKX5FS7MJ6VN8Z8",
|
||||
"state_id": "P_06FN6BNGRJKMV6THWMBFVM8",
|
||||
"prompt": "we will do MIT license, and, do I just post the token here or what?",
|
||||
"agent_name": "codex",
|
||||
"status": "accepted",
|
||||
"response_summary": "License Hop under MIT",
|
||||
"created_at": "2026-07-11T21:56:36.676853Z",
|
||||
"metadata": {
|
||||
"source_tree": "8423d5eeffbb7d7e3b416139417c06a552beca7d",
|
||||
"git_commit": "68d8bf2f7d4e064ba21fb6dcd4fb72b01fd8722f",
|
||||
"attempt_head": "R_06FN6C531M6TX6KCGEMG4E0",
|
||||
"attempt_head_kind": "proposal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "P_06FN6CFVXS0Q22MHZRR17PR",
|
||||
"task_id": "T_06FN6CFVXVVAP1KZAJ2X62R",
|
||||
"attempt_id": "AT_06FN6CFVXTRZ8ZDG28QR5Z0",
|
||||
"state_id": "P_06FN6CFVXS0Q22MHZRR17PR",
|
||||
"prompt": "before we do that, i think we should set up hop to push after every commit, my logic is taking the most steps out of the users need to do manually as possible, so having it auto push would alliviate manual work",
|
||||
"agent_name": "codex",
|
||||
"status": "accepted",
|
||||
"response_summary": "Automatically push every accepted Hop transition",
|
||||
"created_at": "2026-07-11T22:00:12.526502Z",
|
||||
"metadata": {
|
||||
"source_tree": "996e9d600f0b31af0db77ff6794370758544a417",
|
||||
"git_commit": "9a3a6d152b93df531cc21753e5564045db4b75b2",
|
||||
"attempt_head": "R_06FN6FDTK9G17RXFFT82ZYR",
|
||||
"attempt_head_kind": "proposal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "P_06FN6GZKFM9KAYSX7A6TXP8",
|
||||
"task_id": "T_06FN6GZKFMRV2HP5N7XMNSR",
|
||||
"attempt_id": "AT_06FN6GZKFP22ZCFRXGB7V00",
|
||||
"state_id": "P_06FN6GZKFM9KAYSX7A6TXP8",
|
||||
"prompt": "lets finish what we were doing then investigate that, i have added the key to the keychain",
|
||||
"agent_name": "codex",
|
||||
"status": "accepted",
|
||||
"created_at": "2026-07-11T22:19:50.013492Z",
|
||||
"metadata": {
|
||||
"source_tree": "3b5d48444b5e5290839774d383a7498d0190e708",
|
||||
"git_commit": "7139b9e1a7e52e8748c130dc366472c9f10cb04e",
|
||||
"attempt_head": "R_06FN6KJWAY5XWVMCVQHCXKR",
|
||||
"attempt_head_kind": "proposal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "P_06FN6JQ4DBZHXABYYCA61E8",
|
||||
"task_id": "T_06FN6GZKFMRV2HP5N7XMNSR",
|
||||
"attempt_id": "AT_06FN6GZKFP22ZCFRXGB7V00",
|
||||
"state_id": "P_06FN6JQ4DBZHXABYYCA61E8",
|
||||
"prompt": "why is it creating tokens? is there something in hop encouraging that behavior? i dont like that",
|
||||
"agent_name": "codex",
|
||||
"status": "accepted",
|
||||
"response_summary": "Require user-provisioned release credentials",
|
||||
"created_at": "2026-07-11T22:27:24.906795Z",
|
||||
"metadata": {
|
||||
"source_tree": "3b5d48444b5e5290839774d383a7498d0190e708",
|
||||
"git_commit": "531775df8be5e87e57832473fc6a7ad8342b9b91",
|
||||
"attempt_head": "R_06FN6KJWAY5XWVMCVQHCXKR",
|
||||
"attempt_head_kind": "proposal"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "P_06FN72497T1AVJAQC7X7860",
|
||||
"task_id": "T_06FN6ZVNS25H7V4BRG743PG",
|
||||
"attempt_id": "AT_06FN6ZVNS0W32KK9YQCRG88",
|
||||
"state_id": "P_06FN72497T1AVJAQC7X7860",
|
||||
"prompt": "we dont need to show internal agent instructions at all. also why do all the agent responses say \"Roll completed agent sessions onto the latest accepted state\"",
|
||||
"agent_name": "codex",
|
||||
"status": "proposed",
|
||||
"response_summary": "Publish only user prompts with their own proposal outcomes",
|
||||
"created_at": "2026-07-11T23:34:44.798223Z",
|
||||
"metadata": {
|
||||
"source_tree": "a7f12a311a193987f0691cd61e7d7efd313a61d0",
|
||||
"git_commit": "61fef5fbb2288dfeb5fc02675644f2780b9305d4",
|
||||
"attempt_head": "C_06FN72ADQRWFSQ8TP9Q9SY0",
|
||||
"attempt_head_kind": "checkpoint"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "P_06FN83DN7R0B9X5TEV3YDC0",
|
||||
"task_id": "T_06FN83DN7SMZB9WJRZNZMTR",
|
||||
"attempt_id": "AT_06FN83DN7VHVF5BVW6DEJ8R",
|
||||
"state_id": "P_06FN83DN7R0B9X5TEV3YDC0",
|
||||
"prompt": "# Files mentioned by the user:\n\n## Screenshot 2026-07-11 at 6.58.29 PM.png: /var/folders/58/b6yn15tx3jb7_6lw9m67f89m0000gn/T/TemporaryItems/NSIRD_screencaptureui_vyA0oe/Screenshot 2026-07-11 at 6.58.29 PM.png\n\n## My request for Codex:\nonce again, there are prompts here that arent mine!",
|
||||
"agent_name": "codex",
|
||||
"status": "proposed",
|
||||
"response_summary": "Purge internal reconciliation prompts from portable records",
|
||||
"created_at": "2026-07-12T02:00:12.350322Z",
|
||||
"metadata": {
|
||||
"source_tree": "27e94b6d19eb7e4107b127e3cec0ca41718053dc",
|
||||
"git_commit": "74ba06de917236754f6146aca0e2d6e10438e455",
|
||||
"attempt_head": "C_06FN83MVSDWQH772HR663SR",
|
||||
"attempt_head_kind": "checkpoint"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "P_06FN892CVDG36AN981MGF00",
|
||||
"task_id": "T_06FN892CVE210TZR2ZQQ9JG",
|
||||
"attempt_id": "AT_06FN892CVDGHFG3F889Q3YR",
|
||||
"state_id": "P_06FN892CVDG36AN981MGF00",
|
||||
"prompt": "implement that",
|
||||
"agent_name": "codex",
|
||||
"status": "proposed",
|
||||
"response_summary": "Attribute accepted commits to the prompting user's configured Git identity",
|
||||
"created_at": "2026-07-12T02:24:52.955741Z",
|
||||
"metadata": {
|
||||
"source_tree": "5895f21f33fd3d6d04b764c84dfeb6e0c4738c1f",
|
||||
"git_commit": "787acb2330d8662320477d022da37ee4029697ac",
|
||||
"attempt_head": "C_06FN89NDDXYFZK3XRRBQPVR",
|
||||
"attempt_head_kind": "checkpoint"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "P_06FN8AWMR1EMWPB5787YS60",
|
||||
"task_id": "T_06FN8AWMR0HY1RJY8AN2A3R",
|
||||
"attempt_id": "AT_06FN8AWMR0WHBQ7ZMDZ0NWG",
|
||||
"state_id": "P_06FN8AWMR1EMWPB5787YS60",
|
||||
"prompt": "HOP IS SUPPOSED TO INTELLIGENTLY RECONCILE THIS!",
|
||||
"agent_name": "codex",
|
||||
"status": "proposed",
|
||||
"response_summary": "Reconcile compatible remote advancement before publishing accepted states",
|
||||
"created_at": "2026-07-12T02:32:50.112254Z",
|
||||
"metadata": {
|
||||
"source_tree": "b24e0fc4185ee2df0b827cbcddf7712e8f14ebff",
|
||||
"git_commit": "59b8ca7c05edbdd50535a61ae9ca732516ce7f31",
|
||||
"attempt_head": "C_06FN8BEYCESTJ4ZDR0XRNK0",
|
||||
"attempt_head_kind": "checkpoint"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "P_06FN8C4PMD47GH10ZFQY2VR",
|
||||
"task_id": "T_06FN8C4PMFJRT8APV4G03Y8",
|
||||
"attempt_id": "AT_06FN8C4PMFRJEE4B7KKQ740",
|
||||
"state_id": "P_06FN8C4PMD47GH10ZFQY2VR",
|
||||
"prompt": "\"I couldn’t safely start the repository change because Hop prompt capture timed out. Per the repository workflow, I stopped before inspecting or modifying files. No changes were made.\" what the fuck is this bullshit",
|
||||
"agent_name": "codex",
|
||||
"status": "proposed",
|
||||
"response_summary": "Make prompt capture fast and resilient",
|
||||
"created_at": "2026-07-12T02:38:18.275042Z",
|
||||
"metadata": {
|
||||
"source_tree": "7f3f889e96a317c157eedf4f5285af835865f6fb",
|
||||
"git_commit": "ff62e89596a85ff3073ea348919b8d2b7125069f",
|
||||
"attempt_head": "C_06FN8CQ0ZNZ60MRZ818MB9G",
|
||||
"attempt_head_kind": "checkpoint"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"prompt_ids": [
|
||||
"P_06FN3SFFTFGNMTA6219GNX0",
|
||||
"P_06FN5WHD61PV5ZABVFXDXS8",
|
||||
"P_06FN6RMF4VH728C9Q6GTQNR",
|
||||
"P_06FN6VQSN9P8R8NGASJQG7R",
|
||||
"P_06FN6XEKNYR6BP9GQF78MVG",
|
||||
"P_06FN6ZVNS3EYMSG7D7J17E8"
|
||||
]
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
)
|
||||
@@ -24,6 +25,7 @@ Usage:
|
||||
hop accept STATE [-- COMMAND [ARG...]]
|
||||
hop land STATE [-- COMMAND [ARG...]]
|
||||
hop refresh PROPOSAL
|
||||
hop export [--output PATH]
|
||||
hop sync
|
||||
hop push
|
||||
hop status
|
||||
@@ -213,6 +215,28 @@ func RunCLIWithInput(args []string, stdin io.Reader, stdout, stderr io.Writer) i
|
||||
fmt.Fprintf(stdout, "Created checkpoint %s · tree %s\n", state.ID, shortHash(state.SourceTree))
|
||||
}
|
||||
|
||||
case "export":
|
||||
fs := flag.NewFlagSet("export", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
output := fs.String("output", "", "write 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 {
|
||||
|
||||
@@ -83,3 +83,25 @@ func TestDistributionDoesNotRequireGiteaActions(t *testing.T) {
|
||||
t.Fatal("release checklist still depends on a Gitea Actions workflow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseWorkflowRequiresPreProvisionedCredential(t *testing.T) {
|
||||
root := filepath.Clean(filepath.Join("..", ".."))
|
||||
script, err := os.ReadFile(filepath.Join(root, "scripts", "release-local.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(script), "pre-existing scoped GITEA_TOKEN") {
|
||||
t.Fatal("release script does not require a pre-provisioned credential")
|
||||
}
|
||||
if strings.Contains(string(script), "/tokens") {
|
||||
t.Fatal("release script must not manage provider tokens")
|
||||
}
|
||||
checklist, err := os.ReadFile(filepath.Join(root, "wiki", "Release-Checklist.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(checklist), "must never create, rotate, list,") ||
|
||||
!strings.Contains(string(checklist), "or revoke account tokens") {
|
||||
t.Fatal("release checklist does not forbid agent token management")
|
||||
}
|
||||
}
|
||||
|
||||
+154
-27
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -68,7 +67,7 @@ func (e *GitError) Error() string {
|
||||
func (e *GitError) Unwrap() error { return e.Err }
|
||||
|
||||
// CommitOptions controls a synthetic commit. Empty identities and timestamps
|
||||
// use the GitStore defaults; user Git identity configuration is never used.
|
||||
// use the GitStore defaults.
|
||||
type CommitOptions struct {
|
||||
Message string
|
||||
Parents []string
|
||||
@@ -110,8 +109,8 @@ func EnsureRepository(path string) (*Repository, error) {
|
||||
return NewGitStore().Ensure(context.Background(), path)
|
||||
}
|
||||
|
||||
// OpenRepository opens the repository containing path and adds .hop/ to its
|
||||
// per-repository exclude file.
|
||||
// OpenRepository opens the repository containing path and keeps Hop's local
|
||||
// runtime private while leaving its portable record ledger versionable.
|
||||
func OpenRepository(path string) (*Repository, error) {
|
||||
return NewGitStore().Open(context.Background(), path)
|
||||
}
|
||||
@@ -255,8 +254,9 @@ func (r *Repository) GitDir() string { return r.gitDir }
|
||||
// worktrees.
|
||||
func (r *Repository) CommonGitDir() string { return r.commonGitDir }
|
||||
|
||||
// EnsureHopExcluded adds .hop/ to .git/info/exclude without changing tracked
|
||||
// files or the repository's public .gitignore.
|
||||
// EnsureHopExcluded keeps the 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 {
|
||||
@@ -267,26 +267,27 @@ func (r *Repository) EnsureHopExcluded() error {
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("read Git exclude file: %w", err)
|
||||
}
|
||||
lines := make([]string, 0)
|
||||
for _, line := range strings.Split(string(contents), "\n") {
|
||||
if strings.TrimSuffix(line, "\r") == ".hop/" {
|
||||
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)
|
||||
}
|
||||
|
||||
file, err := os.OpenFile(excludePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open Git exclude file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
if len(contents) > 0 && contents[len(contents)-1] != '\n' {
|
||||
if _, err := io.WriteString(file, "\n"); err != nil {
|
||||
return fmt.Errorf("complete Git exclude line: %w", err)
|
||||
}
|
||||
}
|
||||
if _, err := io.WriteString(file, ".hop/\n"); err != nil {
|
||||
return fmt.Errorf("exclude .hop directory: %w", err)
|
||||
}
|
||||
return file.Sync()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Head returns the current HEAD commit. exists is false for an unborn branch.
|
||||
@@ -308,6 +309,25 @@ func (r *Repository) PushAccepted(ctx context.Context, commit string) (result Re
|
||||
if err := validObjectName(commit); err != nil {
|
||||
return result, false, fmt.Errorf("invalid accepted commit: %w", err)
|
||||
}
|
||||
destination, configured, err := r.pushDestination(ctx)
|
||||
if err != nil || !configured {
|
||||
return result, configured, err
|
||||
}
|
||||
if _, err := r.run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, nil,
|
||||
"push", "--porcelain", destination.remote, commit+":"+destination.ref); err != nil {
|
||||
return result, true, fmt.Errorf("push accepted commit to %s/%s: %w", destination.remote, strings.TrimPrefix(destination.ref, "refs/heads/"), err)
|
||||
}
|
||||
return RemotePushResult{Remote: destination.safeRemote, Ref: destination.ref, Commit: commit}, true, nil
|
||||
}
|
||||
|
||||
type pushDestination struct {
|
||||
remote string
|
||||
safeRemote string
|
||||
ref string
|
||||
}
|
||||
|
||||
func (r *Repository) pushDestination(ctx context.Context) (pushDestination, bool, error) {
|
||||
var result pushDestination
|
||||
branch, exists, err := r.optionalGitOutput(ctx, "symbolic-ref", "--quiet", "--short", "HEAD")
|
||||
if err != nil {
|
||||
return result, false, fmt.Errorf("discover automatic push branch: %w", err)
|
||||
@@ -374,13 +394,60 @@ func (r *Repository) PushAccepted(ctx context.Context, commit string) (result Re
|
||||
if err := r.validateRef(ctx, ref); err != nil {
|
||||
return result, false, fmt.Errorf("validate automatic push destination: %w", err)
|
||||
}
|
||||
|
||||
if _, err := r.run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, nil,
|
||||
"push", "--porcelain", remote, commit+":"+ref); err != nil {
|
||||
return result, true, fmt.Errorf("push accepted commit to %s/%s: %w", remote, strings.TrimPrefix(ref, "refs/heads/"), err)
|
||||
}
|
||||
safeRemote, _ := RedactPromptSecrets(remote)
|
||||
return RemotePushResult{Remote: safeRemote, Ref: ref, Commit: commit}, true, nil
|
||||
return pushDestination{remote: remote, safeRemote: safeRemote, ref: ref}, true, nil
|
||||
}
|
||||
|
||||
// FetchPushTip fetches the configured destination branch without changing the
|
||||
// user's branch, index, or working tree. exists is false for a new remote branch.
|
||||
func (r *Repository) FetchPushTip(ctx context.Context) (tip string, configured, exists bool, err error) {
|
||||
destination, configured, err := r.pushDestination(ctx)
|
||||
if err != nil || !configured {
|
||||
return "", configured, false, err
|
||||
}
|
||||
output, err := r.run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, nil,
|
||||
"ls-remote", "--refs", destination.remote, destination.ref)
|
||||
if err != nil {
|
||||
return "", true, false, fmt.Errorf("inspect remote branch %s/%s: %w", destination.remote, strings.TrimPrefix(destination.ref, "refs/heads/"), err)
|
||||
}
|
||||
fields := strings.Fields(output)
|
||||
if len(fields) == 0 {
|
||||
return "", true, false, nil
|
||||
}
|
||||
if len(fields) != 2 || fields[1] != destination.ref {
|
||||
return "", true, false, fmt.Errorf("inspect remote branch %s/%s: unexpected ls-remote response", destination.remote, strings.TrimPrefix(destination.ref, "refs/heads/"))
|
||||
}
|
||||
if err := validObjectName(fields[0]); err != nil {
|
||||
return "", true, false, fmt.Errorf("invalid remote branch tip: %w", err)
|
||||
}
|
||||
if _, err := r.run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, nil,
|
||||
"fetch", "--no-tags", destination.remote, destination.ref); err != nil {
|
||||
return "", true, false, fmt.Errorf("fetch remote branch %s/%s: %w", destination.remote, strings.TrimPrefix(destination.ref, "refs/heads/"), err)
|
||||
}
|
||||
fetched, err := r.run(ctx, nil, nil, "rev-parse", "--verify", "FETCH_HEAD^{commit}")
|
||||
if err != nil {
|
||||
return "", true, false, fmt.Errorf("resolve fetched remote branch: %w", err)
|
||||
}
|
||||
fetched = trimLine(fetched)
|
||||
if fetched != fields[0] {
|
||||
return "", true, false, errors.New("fetched remote branch changed while reconciling; retry")
|
||||
}
|
||||
return fetched, true, true, nil
|
||||
}
|
||||
|
||||
// MergeBase returns the best common ancestor of two commits.
|
||||
func (r *Repository) MergeBase(ctx context.Context, left, right string) (string, error) {
|
||||
if err := validObjectName(left); err != nil {
|
||||
return "", fmt.Errorf("invalid left commit: %w", err)
|
||||
}
|
||||
if err := validObjectName(right); err != nil {
|
||||
return "", fmt.Errorf("invalid right commit: %w", err)
|
||||
}
|
||||
output, err := r.run(ctx, nil, nil, "merge-base", left, right)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("find merge base: %w", err)
|
||||
}
|
||||
return trimLine(output), nil
|
||||
}
|
||||
|
||||
func (r *Repository) optionalGitOutput(ctx context.Context, args ...string) (string, bool, error) {
|
||||
@@ -477,6 +544,32 @@ func (r *Repository) CommitTree(ctx context.Context, tree string, parents []stri
|
||||
})
|
||||
}
|
||||
|
||||
// ConfiguredUserIdentity returns the Git identity configured for the user in
|
||||
// this repository. Both user.name and user.email must be present.
|
||||
func (r *Repository) ConfiguredUserIdentity(ctx context.Context) (GitIdentity, bool, error) {
|
||||
name, nameSet, err := r.configValue(ctx, "user.name")
|
||||
if err != nil {
|
||||
return GitIdentity{}, false, err
|
||||
}
|
||||
email, emailSet, err := r.configValue(ctx, "user.email")
|
||||
if err != nil {
|
||||
return GitIdentity{}, false, err
|
||||
}
|
||||
if !nameSet || !emailSet {
|
||||
return GitIdentity{}, false, nil
|
||||
}
|
||||
identity := GitIdentity{Name: name, Email: email}
|
||||
if err := validateIdentity(identity); err != nil {
|
||||
return GitIdentity{}, false, fmt.Errorf("invalid configured user identity: %w", err)
|
||||
}
|
||||
return identity, true, nil
|
||||
}
|
||||
|
||||
// SyntheticIdentity is Hop's controlled committer identity.
|
||||
func (r *Repository) SyntheticIdentity() GitIdentity {
|
||||
return r.store.identity(GitIdentity{})
|
||||
}
|
||||
|
||||
// CommitTreeWithOptions creates a synthetic commit without invoking hooks or
|
||||
// consulting the user's Git identity. It does not update any ref.
|
||||
func (r *Repository) CommitTreeWithOptions(ctx context.Context, tree string, options CommitOptions) (string, error) {
|
||||
@@ -562,6 +655,27 @@ func (r *Repository) ReadHiddenRef(ctx context.Context, name string) (oid string
|
||||
return r.ReadRef(ctx, ref)
|
||||
}
|
||||
|
||||
// ListHiddenRefs reads all refs below refs/hop in one Git process. Keys are
|
||||
// relative to refs/hop/ (for example, "states/P_..." or "accepted").
|
||||
func (r *Repository) ListHiddenRefs(ctx context.Context) (map[string]string, error) {
|
||||
output, err := r.run(ctx, nil, nil, "for-each-ref", "--format=%(refname) %(objectname)", hiddenRefPrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refs := make(map[string]string)
|
||||
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
name, oid, found := strings.Cut(line, " ")
|
||||
if !found || !strings.HasPrefix(name, hiddenRefPrefix) {
|
||||
return nil, fmt.Errorf("unexpected hidden ref listing %q", line)
|
||||
}
|
||||
refs[strings.TrimPrefix(name, hiddenRefPrefix)] = strings.TrimSpace(oid)
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
// UpdateHiddenRef atomically updates refs/hop/name. With no expectedOld value,
|
||||
// it is unconditional. Passing expectedOld performs compare-and-swap; an empty
|
||||
// expected value means the ref must not exist.
|
||||
@@ -1302,6 +1416,19 @@ func (s *GitStore) identity(override GitIdentity) GitIdentity {
|
||||
return identity
|
||||
}
|
||||
|
||||
func (r *Repository) configValue(ctx context.Context, key string) (string, bool, error) {
|
||||
output, err := r.run(ctx, nil, nil, "config", "--get", key)
|
||||
if err != nil {
|
||||
var gitErr *GitError
|
||||
if errors.As(err, &gitErr) && gitErr.ExitCode == 1 {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, fmt.Errorf("read git config %s: %w", key, err)
|
||||
}
|
||||
value := trimLine(output)
|
||||
return value, value != "", nil
|
||||
}
|
||||
|
||||
func (s *GitStore) now() time.Time {
|
||||
if s.Now != nil {
|
||||
return s.Now()
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
package hop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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"`
|
||||
}
|
||||
|
||||
type promptExportOptions struct {
|
||||
AttemptIDs []string
|
||||
PublishableOnly bool
|
||||
Status string
|
||||
ResponseSummary string
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
type suppressedPromptManifest struct {
|
||||
PromptIDs []string `json:"prompt_ids"`
|
||||
}
|
||||
|
||||
// ExportPromptLedger writes immutable prompt files beneath
|
||||
// .hop/records/prompts. Passing attemptIDs restricts the export to those
|
||||
// attempts, which keeps concurrent proposals from writing the same files.
|
||||
func (s *Service) ExportPromptLedger(ctx context.Context, destinationRoot string, attemptIDs ...string) (PortablePromptLedger, error) {
|
||||
return s.exportPromptLedger(ctx, destinationRoot, promptExportOptions{AttemptIDs: attemptIDs, PublishableOnly: true})
|
||||
}
|
||||
|
||||
func (s *Service) exportPromptLedger(ctx context.Context, destinationRoot string, options promptExportOptions) (PortablePromptLedger, error) {
|
||||
if destinationRoot == "" {
|
||||
destinationRoot = s.Root
|
||||
}
|
||||
graph, err := s.Store.Graph(ctx, "")
|
||||
if err != nil {
|
||||
return PortablePromptLedger{}, err
|
||||
}
|
||||
suppressed, err := loadSuppressedPromptIDs(destinationRoot)
|
||||
if err != nil {
|
||||
return PortablePromptLedger{}, err
|
||||
}
|
||||
ledger := PortablePromptLedger{
|
||||
SchemaVersion: 1,
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
Prompts: make([]PortablePromptRecord, 0),
|
||||
}
|
||||
wantedAttempts := make(map[string]struct{}, len(options.AttemptIDs))
|
||||
for _, attemptID := range options.AttemptIDs {
|
||||
wantedAttempts[attemptID] = struct{}{}
|
||||
}
|
||||
lastPromptByAttempt := make(map[string]string)
|
||||
excludedPromptIDs := make(map[string]struct{})
|
||||
for _, row := range graph {
|
||||
if row.State.Kind == StatePrompt && row.State.Prompt != "" && row.State.AttemptID != "" {
|
||||
lastPromptByAttempt[row.State.AttemptID] = row.State.ID
|
||||
}
|
||||
}
|
||||
for _, row := range graph {
|
||||
state := row.State
|
||||
if state.Kind != StatePrompt || state.Prompt == "" {
|
||||
continue
|
||||
}
|
||||
if _, hidden := suppressed[state.ID]; hidden {
|
||||
excludedPromptIDs[state.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if _, reconciliation := decodeReconciliationConflicts(state.Summary); reconciliation || strings.HasPrefix(state.Prompt, "Resolve proposal ") {
|
||||
excludedPromptIDs[state.ID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if len(wantedAttempts) > 0 {
|
||||
if _, wanted := wantedAttempts[state.AttemptID]; !wanted {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if state.AttemptID == "" {
|
||||
continue
|
||||
}
|
||||
attempt, err := s.Store.GetAttempt(ctx, state.AttemptID)
|
||||
if err != nil {
|
||||
return PortablePromptLedger{}, fmt.Errorf("read attempt for prompt %s: %w", state.ID, err)
|
||||
}
|
||||
var head State
|
||||
if attempt.HeadStateID != "" {
|
||||
head, err = s.Store.GetState(ctx, attempt.HeadStateID)
|
||||
if err != nil {
|
||||
return PortablePromptLedger{}, fmt.Errorf("read attempt head for prompt %s: %w", state.ID, err)
|
||||
}
|
||||
}
|
||||
if options.PublishableOnly && head.Kind != StateProposal && head.Kind != StateAccepted {
|
||||
continue
|
||||
}
|
||||
|
||||
record := PortablePromptRecord{
|
||||
ID: state.ID,
|
||||
TaskID: state.TaskID,
|
||||
AttemptID: state.AttemptID,
|
||||
StateID: state.ID,
|
||||
Prompt: state.Prompt,
|
||||
AgentName: state.Agent,
|
||||
Status: attempt.Status,
|
||||
CreatedAt: state.CreatedAt,
|
||||
Metadata: PortablePromptMetadata{
|
||||
SourceTree: state.SourceTree,
|
||||
GitCommit: state.GitCommit,
|
||||
},
|
||||
}
|
||||
record.Metadata.AttemptHead = attempt.HeadStateID
|
||||
record.Metadata.AttemptHeadKind = string(head.Kind)
|
||||
if lastPromptByAttempt[state.AttemptID] == state.ID {
|
||||
record.ResponseSummary = head.Summary
|
||||
if options.ResponseSummary != "" {
|
||||
record.ResponseSummary = options.ResponseSummary
|
||||
}
|
||||
}
|
||||
if options.Status != "" {
|
||||
record.Status = options.Status
|
||||
}
|
||||
if head.Kind == StateAccepted || head.Kind == StateFailed || head.Kind == StateCancelled {
|
||||
completedAt := head.CreatedAt
|
||||
record.CompletedAt = &completedAt
|
||||
}
|
||||
ledger.Prompts = append(ledger.Prompts, record)
|
||||
}
|
||||
|
||||
outputDir := filepath.Join(destinationRoot, ".hop", "records", "prompts")
|
||||
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||
return PortablePromptLedger{}, fmt.Errorf("create prompt ledger directory: %w", err)
|
||||
}
|
||||
for promptID := range excludedPromptIDs {
|
||||
outputPath := filepath.Join(outputDir, promptID+".json")
|
||||
if err := os.Remove(outputPath); err != nil && !os.IsNotExist(err) {
|
||||
return PortablePromptLedger{}, fmt.Errorf("remove excluded prompt record %s: %w", promptID, err)
|
||||
}
|
||||
}
|
||||
for _, record := range ledger.Prompts {
|
||||
outputPath := filepath.Join(outputDir, record.ID+".json")
|
||||
_, statErr := os.Stat(outputPath)
|
||||
if statErr == nil && !options.Overwrite {
|
||||
continue // Prompt records are immutable once published.
|
||||
}
|
||||
if statErr != nil && !os.IsNotExist(statErr) {
|
||||
return PortablePromptLedger{}, fmt.Errorf("inspect prompt record %s: %w", record.ID, statErr)
|
||||
}
|
||||
contents, err := json.MarshalIndent(record, "", " ")
|
||||
if err != nil {
|
||||
return PortablePromptLedger{}, fmt.Errorf("encode prompt record %s: %w", record.ID, err)
|
||||
}
|
||||
temporary, err := os.CreateTemp(outputDir, ".prompt-*.tmp")
|
||||
if err != nil {
|
||||
return PortablePromptLedger{}, fmt.Errorf("create prompt record %s: %w", record.ID, err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
if _, err := temporary.Write(append(contents, '\n')); err != nil {
|
||||
_ = temporary.Close()
|
||||
_ = os.Remove(temporaryPath)
|
||||
return PortablePromptLedger{}, fmt.Errorf("write prompt record %s: %w", record.ID, err)
|
||||
}
|
||||
if err := temporary.Chmod(0o644); err != nil {
|
||||
_ = temporary.Close()
|
||||
_ = os.Remove(temporaryPath)
|
||||
return PortablePromptLedger{}, fmt.Errorf("set prompt record permissions for %s: %w", record.ID, err)
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
_ = os.Remove(temporaryPath)
|
||||
return PortablePromptLedger{}, fmt.Errorf("close prompt record %s: %w", record.ID, err)
|
||||
}
|
||||
if err := os.Rename(temporaryPath, outputPath); err != nil {
|
||||
_ = os.Remove(temporaryPath)
|
||||
return PortablePromptLedger{}, fmt.Errorf("publish prompt record %s: %w", record.ID, err)
|
||||
}
|
||||
}
|
||||
return ledger, nil
|
||||
}
|
||||
|
||||
func loadSuppressedPromptIDs(destinationRoot string) (map[string]struct{}, error) {
|
||||
path := filepath.Join(destinationRoot, ".hop", "records", "suppressed.json")
|
||||
contents, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]struct{}{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read suppressed prompt manifest: %w", err)
|
||||
}
|
||||
var manifest suppressedPromptManifest
|
||||
if err := json.Unmarshal(contents, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("decode suppressed prompt manifest: %w", err)
|
||||
}
|
||||
result := make(map[string]struct{}, len(manifest.PromptIDs))
|
||||
for _, id := range manifest.PromptIDs {
|
||||
if !strings.HasPrefix(id, "P_") {
|
||||
return nil, fmt.Errorf("invalid suppressed prompt ID %q", id)
|
||||
}
|
||||
result[id] = struct{}{}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
+80
-22
@@ -33,8 +33,10 @@ func InitProject(ctx context.Context, path string) (*Service, State, error) {
|
||||
if err != nil {
|
||||
return nil, State{}, err
|
||||
}
|
||||
if len(trackedHopPaths) > 0 {
|
||||
return nil, State{}, fmt.Errorf("cannot initialize Hop: .hop is already tracked as project source (for example %s)", trackedHopPaths[0])
|
||||
for _, trackedPath := range trackedHopPaths {
|
||||
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 {
|
||||
@@ -549,6 +551,10 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
if strings.TrimSpace(summary) == "" {
|
||||
summary = task.Title
|
||||
}
|
||||
summary, _ = RedactPromptSecrets(summary)
|
||||
reconciliationPrompt, reconciliation, err := s.Store.ReconciliationPromptForAttempt(ctx, attempt.ID)
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
@@ -565,12 +571,12 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
commit, tree, err := workspaceRepo.Snapshot(ctx, "hop: proposal\n")
|
||||
_, validationTree, err := workspaceRepo.Snapshot(ctx, "hop: proposal validation tree\n")
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
if reconciliation {
|
||||
unresolved, err := unresolvedReconciliationMarkers(ctx, workspaceRepo, tree, reconciliationConflicts)
|
||||
unresolved, err := unresolvedReconciliationMarkers(ctx, workspaceRepo, validationTree, reconciliationConflicts)
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
@@ -578,7 +584,7 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
|
||||
return ProposalResult{}, fmt.Errorf("reconciliation still contains merge markers in: %s", strings.Join(unresolved, ", "))
|
||||
}
|
||||
}
|
||||
checks, err := s.Store.ListChecks(ctx, attempt.ID, tree)
|
||||
checks, err := s.Store.ListChecks(ctx, attempt.ID, validationTree)
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
@@ -594,10 +600,15 @@ func (s *Service) Propose(ctx context.Context, stateID, summary string) (Proposa
|
||||
return ProposalResult{}, fmt.Errorf("reconciliation must pass hop check on the resolved tree before proposing")
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(summary) == "" {
|
||||
summary = task.Title
|
||||
if _, err := s.exportPromptLedger(ctx, attempt.Workspace, promptExportOptions{
|
||||
AttemptIDs: []string{attempt.ID}, Status: "proposed", ResponseSummary: summary,
|
||||
}); err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
commit, tree, err := workspaceRepo.Snapshot(ctx, "hop: proposal\n")
|
||||
if err != nil {
|
||||
return ProposalResult{}, err
|
||||
}
|
||||
summary, _ = RedactPromptSecrets(summary)
|
||||
canonicalAnchorID := attempt.BaseStateID
|
||||
if reconciliation && reconciliationPrompt.CanonicalAnchorID != "" {
|
||||
canonicalAnchorID = reconciliationPrompt.CanonicalAnchorID
|
||||
@@ -726,6 +737,8 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
|
||||
if err != nil {
|
||||
return AcceptResult{}, err
|
||||
}
|
||||
proposalPaths = withoutPortableHopRecords(proposalPaths)
|
||||
currentPaths = withoutPortableHopRecords(currentPaths)
|
||||
finalTree := proposal.SourceTree
|
||||
if current.SourceTree != base.SourceTree {
|
||||
var mergeConflicts []string
|
||||
@@ -737,6 +750,32 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
|
||||
return AcceptResult{}, &ConflictError{Paths: mergeConflicts}
|
||||
}
|
||||
}
|
||||
commitParents := []string{current.GitCommit}
|
||||
remoteCtx, cancelRemote := context.WithTimeout(ctx, 30*time.Second)
|
||||
remoteTip, _, remoteExists, err := s.Repo.FetchPushTip(remoteCtx)
|
||||
cancelRemote()
|
||||
if err != nil {
|
||||
return AcceptResult{}, err
|
||||
}
|
||||
if remoteExists && remoteTip != current.GitCommit {
|
||||
candidate, candidateErr := s.Repo.CommitTree(ctx, finalTree, []string{current.GitCommit}, "Hop remote reconciliation candidate\n")
|
||||
if candidateErr != nil {
|
||||
return AcceptResult{}, candidateErr
|
||||
}
|
||||
mergeBase, mergeErr := s.Repo.MergeBase(ctx, candidate, remoteTip)
|
||||
if mergeErr != nil {
|
||||
return AcceptResult{}, mergeErr
|
||||
}
|
||||
var remoteConflicts []string
|
||||
finalTree, remoteConflicts, err = s.Repo.ComposeTrees(ctx, mergeBase, remoteTip, candidate)
|
||||
if err != nil {
|
||||
return AcceptResult{}, err
|
||||
}
|
||||
if len(remoteConflicts) > 0 {
|
||||
return AcceptResult{}, fmt.Errorf("remote accepted-state reconciliation requires agent resolution: %s", strings.Join(remoteConflicts, ", "))
|
||||
}
|
||||
commitParents = []string{remoteTip, current.GitCommit}
|
||||
}
|
||||
|
||||
acceptedID := newID("a")
|
||||
message := proposal.Summary
|
||||
@@ -744,7 +783,19 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
|
||||
message = "Accept " + proposal.ID
|
||||
}
|
||||
message += fmt.Sprintf("\n\nHop-State: %s\nHop-Proposal: %s\nHop-Task: %s\nHop-Attempt: %s\n", acceptedID, proposal.ID, proposal.TaskID, proposal.AttemptID)
|
||||
commit, err := s.Repo.CommitTree(ctx, finalTree, []string{current.GitCommit}, message)
|
||||
author, configured, err := s.Repo.ConfiguredUserIdentity(ctx)
|
||||
if err != nil {
|
||||
return AcceptResult{}, err
|
||||
}
|
||||
if !configured {
|
||||
author = s.Repo.SyntheticIdentity()
|
||||
}
|
||||
commit, err := s.Repo.CommitTreeWithOptions(ctx, finalTree, CommitOptions{
|
||||
Message: message,
|
||||
Parents: commitParents,
|
||||
Author: author,
|
||||
Committer: s.Repo.SyntheticIdentity(),
|
||||
})
|
||||
if err != nil {
|
||||
return AcceptResult{}, err
|
||||
}
|
||||
@@ -875,6 +926,17 @@ func (s *Service) accept(ctx context.Context, proposalID string, checkCommand []
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func withoutPortableHopRecords(paths []string) []string {
|
||||
filtered := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
if strings.HasPrefix(path, ".hop/records/") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, path)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (s *Service) attachAutomaticPush(ctx context.Context, result *AcceptResult) {
|
||||
pushCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -1578,17 +1640,17 @@ func (s *Service) reconcileDerivedRefs(ctx context.Context, reconcileAccepted bo
|
||||
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)
|
||||
refs, err := s.Repo.ListHiddenRefs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists || commit != state.GitCommit {
|
||||
for _, row := range rows {
|
||||
state := row.State
|
||||
name := "states/" + state.ID
|
||||
if refs[name] != state.GitCommit {
|
||||
if err := s.Repo.VerifyObjects(ctx, state.GitCommit, state.SourceTree); err != nil {
|
||||
return fmt.Errorf("state %s references unavailable Git data: %w", state.ID, err)
|
||||
}
|
||||
if err := s.Repo.UpdateHiddenRef(ctx, name, state.GitCommit); err != nil {
|
||||
return fmt.Errorf("repair Git pin for state %s: %w", state.ID, err)
|
||||
}
|
||||
@@ -1606,11 +1668,7 @@ func (s *Service) reconcileDerivedRefs(ctx context.Context, reconcileAccepted bo
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commit, exists, err := s.Repo.ReadHiddenRef(ctx, acceptedRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists || commit != head.GitCommit {
|
||||
if refs[acceptedRef] != head.GitCommit {
|
||||
if err := s.Repo.UpdateHiddenRef(ctx, acceptedRef, head.GitCommit); err != nil {
|
||||
return fmt.Errorf("repair accepted Git ref: %w", err)
|
||||
}
|
||||
|
||||
@@ -73,8 +73,8 @@ func TestInitRefusesAUserTrackedHopDirectory(t *testing.T) {
|
||||
writeTestFile(t, filepath.Join(root, ".hop", "user-owned.txt"), "do not overwrite\n")
|
||||
runGitTest(t, root, "add", "-f", ".hop/user-owned.txt")
|
||||
_, _, err := InitProject(context.Background(), root)
|
||||
if err == nil || !strings.Contains(err.Error(), ".hop is already tracked") {
|
||||
t.Fatalf("InitProject error = %v, want tracked .hop refusal", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "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" {
|
||||
@@ -82,6 +82,27 @@ func TestInitRefusesAUserTrackedHopDirectory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -207,8 +228,101 @@ func TestConcurrentInitInExistingGitRepository(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count := strings.Count(string(exclude), ".hop/\n"); count != 1 {
|
||||
t.Fatalf("concurrent initialization wrote .hop/ exclusion %d times", count)
|
||||
if count := strings.Count(string(exclude), ".hop/*\n"); count != 1 {
|
||||
t.Fatalf("concurrent initialization wrote .hop/* exclusion %d times", count)
|
||||
}
|
||||
}
|
||||
|
||||
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" || record.Status != "proposed" || record.ResponseSummary != "Publish the ledger" {
|
||||
t.Fatalf("unexpected portable prompt record: %#v", record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportOmitsActiveInternalAttempt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
if _, err := service.CreatePrompt(ctx, "Read-only internal audit", "", "codex"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ledger, err := service.ExportPromptLedger(ctx, service.Root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ledger.Prompts) != 0 {
|
||||
t.Fatalf("exported active internal prompts: %#v", ledger.Prompts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportRemovesPreviouslyPublishedReconciliationPrompt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
result, err := service.CreatePrompt(ctx, "Resolve proposal R_TEST against accepted state A_TEST", "", "codex")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recordDir := filepath.Join(service.Root, ".hop", "records", "prompts")
|
||||
if err := os.MkdirAll(recordDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recordPath := filepath.Join(recordDir, result.Prompt.ID+".json")
|
||||
if err := os.WriteFile(recordPath, []byte("{}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.ExportPromptLedger(ctx, service.Root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(recordPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("internal reconciliation record still exists: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProposeRespectsSuppressedPromptManifest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
result, err := service.CreatePrompt(ctx, "Internal controller instruction", "", "codex")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := fmt.Sprintf("{\"prompt_ids\":[%q]}\n", result.Prompt.ID)
|
||||
writeTestFile(t, filepath.Join(result.Workspace, ".hop", "records", "suppressed.json"), manifest)
|
||||
writeTestFile(t, filepath.Join(result.Workspace, "feature.txt"), "done\n")
|
||||
proposal, err := service.Propose(ctx, result.Prompt.ID, "Apply the user-facing change")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
materialized := filepath.Join(t.TempDir(), "proposal")
|
||||
if _, err := service.Repo.AddDetachedWorktree(ctx, materialized, proposal.Proposal.GitCommit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = service.Repo.RemoveWorktree(ctx, materialized, true) })
|
||||
if _, err := os.Stat(filepath.Join(materialized, ".hop", "records", "prompts", result.Prompt.ID+".json")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("suppressed prompt was published: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +407,28 @@ func TestCLIJSONWorkflow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIExportOmitsActivePromptRecords(t *testing.T) {
|
||||
t.Setenv("HOP_ROOT", "")
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, filepath.Join(root, "base.txt"), "base\n")
|
||||
previousDirectory, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(previousDirectory) })
|
||||
|
||||
runCLIJSONTest(t, []string{"init", "--json"})
|
||||
runCLIJSONTest(t, []string{"begin", "--agent", "codex", "--json", "Publish prompt records"})
|
||||
runCLIJSONTest(t, []string{"export", "--output", ".", "--json"})
|
||||
entries, err := filepath.Glob(filepath.Join(root, ".hop", "records", "prompts", "P_*.json"))
|
||||
if err != nil || len(entries) != 0 {
|
||||
t.Fatalf("portable prompt records = %v, err=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIBeginAutoInitializesAndContinuesCodexSession(t *testing.T) {
|
||||
t.Setenv("HOP_ROOT", "")
|
||||
root := t.TempDir()
|
||||
@@ -923,6 +1059,33 @@ func TestDisjointProposalsLandAndUndoMovesForward(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptedCommitAttributesPromptingUserAndRetainsHopCommitter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
runGitTest(t, service.Root, "config", "user.name", "Prompting User")
|
||||
runGitTest(t, service.Root, "config", "user.email", "prompter@example.com")
|
||||
|
||||
attempt, err := service.CreatePrompt(ctx, "Add authored change", "", "codex")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeTestFile(t, filepath.Join(attempt.Workspace, "authored.txt"), "authored\n")
|
||||
proposal, err := service.Propose(ctx, attempt.Prompt.ID, "Add authored change")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accepted, err := service.Accept(ctx, proposal.Proposal.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
metadata := runGitTest(t, service.Root, "show", "-s", "--format=%an <%ae>%n%cn <%ce>", accepted.State.GitCommit)
|
||||
want := "Prompting User <prompter@example.com>\nHop <hop@localhost>"
|
||||
if metadata != want {
|
||||
t.Fatalf("accepted commit identity = %q, want %q", metadata, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentDisjointAcceptancesSerialize(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
@@ -1488,7 +1651,7 @@ func TestLandAutomaticallyPushesAcceptedCommit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticPushNeverForcesDivergedRemote(t *testing.T) {
|
||||
func TestAutomaticPushReconcilesCompatibleDivergedRemote(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
service, _ := newTestProject(t, map[string]string{"base.txt": "base\n"})
|
||||
remote := filepath.Join(t.TempDir(), "remote.git")
|
||||
@@ -1534,14 +1697,21 @@ func TestAutomaticPushNeverForcesDivergedRemote(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if secondLanded.RemotePush != nil {
|
||||
t.Fatalf("diverged remote unexpectedly reported a successful push: %#v", secondLanded.RemotePush)
|
||||
if secondLanded.RemotePush == nil {
|
||||
t.Fatal("compatible remote advancement was not reconciled and pushed")
|
||||
}
|
||||
if len(secondLanded.Warnings) == 0 || !strings.Contains(strings.Join(secondLanded.Warnings, "\n"), "automatic push failed") {
|
||||
t.Fatalf("diverged remote warnings = %#v", secondLanded.Warnings)
|
||||
if got := runGitTest(t, service.Root, "--git-dir", remote, "rev-parse", "refs/heads/"+branch); got != secondLanded.State.GitCommit {
|
||||
t.Fatalf("remote branch = %s, want reconciled accepted commit %s", got, secondLanded.State.GitCommit)
|
||||
}
|
||||
if got := runGitTest(t, service.Root, "--git-dir", remote, "rev-parse", "refs/heads/"+branch); got != remoteTip {
|
||||
t.Fatalf("automatic push rewrote diverged remote from %s to %s", remoteTip, got)
|
||||
assertTreeFiles(t, service, secondLanded.State.GitCommit, map[string]string{
|
||||
"base.txt": "base\n",
|
||||
"first.txt": "first\n",
|
||||
"local.txt": "local\n",
|
||||
"remote.txt": "remote\n",
|
||||
})
|
||||
parents := strings.Fields(runGitTest(t, service.Root, "show", "-s", "--format=%P", secondLanded.State.GitCommit))
|
||||
if len(parents) != 2 || parents[0] != remoteTip || parents[1] != firstLanded.State.GitCommit {
|
||||
t.Fatalf("reconciled parents = %v, want [%s %s]", parents, remoteTip, firstLanded.State.GitCommit)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-8
@@ -11,20 +11,30 @@ param(
|
||||
$ErrorActionPreference = "Stop"
|
||||
$GiteaUrl = $GiteaUrl.TrimEnd("/")
|
||||
|
||||
switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()) {
|
||||
"X64" { $arch = "amd64" }
|
||||
"Arm64" { $arch = "arm64" }
|
||||
default { throw "Unsupported Windows architecture: $($_)" }
|
||||
$architecture = if ($env:PROCESSOR_ARCHITEW6432) {
|
||||
[string]$env:PROCESSOR_ARCHITEW6432
|
||||
} else {
|
||||
[string]$env:PROCESSOR_ARCHITECTURE
|
||||
}
|
||||
switch ($architecture.ToUpperInvariant()) {
|
||||
"AMD64" { $arch = "amd64" }
|
||||
"ARM64" { $arch = "arm64" }
|
||||
default { throw "Unsupported Windows architecture: $architecture" }
|
||||
}
|
||||
|
||||
$asset = "hop_windows_${arch}.zip"
|
||||
if ($Version -eq "latest") {
|
||||
# Gitea's /releases/latest endpoint omits prereleases. Select the newest
|
||||
# published release so the normal installer also works during Hop's alpha.
|
||||
$releases = @(Invoke-RestMethod -Uri "$GiteaUrl/api/v1/repos/$Repository/releases?draft=false&page=1&limit=1")
|
||||
$latestRelease = $releases | Select-Object -First 1
|
||||
$tag = $latestRelease.tag_name
|
||||
if (-not $tag) { throw "Could not determine the latest published release" }
|
||||
$releaseResponse = Invoke-WebRequest -UseBasicParsing -Uri "$GiteaUrl/api/v1/repos/$Repository/releases?draft=false&page=1&limit=1"
|
||||
if (-not $releaseResponse -or [string]::IsNullOrWhiteSpace($releaseResponse.Content)) {
|
||||
throw "Could not determine the latest published release"
|
||||
}
|
||||
$releases = @($releaseResponse.Content | ConvertFrom-Json)
|
||||
$latestRelease = @($releases | Where-Object { $_ -and $_.tag_name } | Select-Object -First 1)[0]
|
||||
if ($null -eq $latestRelease) { throw "Could not determine the latest published release" }
|
||||
$tag = [string]$latestRelease.tag_name
|
||||
if ([string]::IsNullOrWhiteSpace($tag)) { throw "Could not determine the latest published release" }
|
||||
} else {
|
||||
$tag = if ($Version.StartsWith("v")) { $Version } else { "v$Version" }
|
||||
}
|
||||
@@ -85,3 +95,6 @@ try {
|
||||
} finally {
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $tempDir
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ done
|
||||
|
||||
if [ "$mode" = --publish ]; then
|
||||
[ -f LICENSE ] || fail "LICENSE is required before publishing"
|
||||
[ -n "${GITEA_TOKEN:-}" ] || fail "set a scoped GITEA_TOKEN in the local environment"
|
||||
[ -n "${GITEA_TOKEN:-}" ] || fail "provide a pre-existing scoped GITEA_TOKEN through the local secret store"
|
||||
[ -z "$(git status --porcelain)" ] || fail "the Git worktree must be clean"
|
||||
tag=$(git describe --tags --exact-match HEAD 2>/dev/null) ||
|
||||
fail "HEAD must have an exact release tag"
|
||||
|
||||
+12
-2
@@ -58,8 +58,13 @@ uses it as the default session and identifies the runtime as `codex` unless
|
||||
headers, and credential-bearing connection strings before persistence.
|
||||
|
||||
Read the returned `HOP_STATE_ID`, `HOP_TASK_ID`, `HOP_ATTEMPT_ID`, and workspace.
|
||||
If capture fails or `hop` is unavailable, stop without project effects and
|
||||
report the error.
|
||||
Allow at least 120 seconds for capture. If it times out or fails transiently,
|
||||
retry once with the same message, agent, and session; Hop session binding makes
|
||||
the retry idempotent for the task. If the retry fails, inspect Hop's error and
|
||||
locks, run `hop doctor`, and repair a safe local operational problem when
|
||||
possible. Stop without project effects only when Hop remains unavailable or
|
||||
unsafe after recovery; do not abandon the user's request after the first
|
||||
timeout.
|
||||
|
||||
If Hop reports redactions, never repeat the credential in output, summaries,
|
||||
commands recorded as evidence, or proposal text. Refer to its environment
|
||||
@@ -73,6 +78,11 @@ variable or secret-manager name instead.
|
||||
- Do not run `git commit`, `git checkout`, `git switch`, `git branch`,
|
||||
`git rebase`, `git reset`, `git stash`, `git worktree`, or `git push`.
|
||||
- Do not stage files. Hop captures every nonignored workspace change.
|
||||
- Never create, rotate, enumerate, revoke, or paste account access tokens
|
||||
through a provider website or API. For release or publishing work, use only a
|
||||
credential the user has already provisioned in an OS secret store or supplied
|
||||
through the runtime's secret mechanism. If it is missing, stop and ask the
|
||||
user to provision it; do not call a token-management endpoint.
|
||||
- Give a subagent project-changing work only after creating a distinct Hop
|
||||
prompt/attempt for that delegation.
|
||||
- Never discard either side of concurrent work. Let Hop perform its three-way
|
||||
|
||||
@@ -158,6 +158,15 @@ sanitizer replaces detected credential values before any durable write and
|
||||
returns only typed redaction counts. Do not place the value in any later
|
||||
command, summary, output, or source file.
|
||||
|
||||
## Account credential boundary
|
||||
|
||||
Hop never creates, rotates, lists, or revokes provider access tokens. Agents
|
||||
must not use account token-management APIs or settings pages as a shortcut for
|
||||
publishing work. A release or publishing task may use only a pre-existing
|
||||
credential the user deliberately provisioned through an OS secret store or the
|
||||
runtime's secret mechanism. When that credential is absent or invalid, stop and
|
||||
ask the user to replace it; never mint a task-named token.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|
||||
@@ -34,6 +34,7 @@ release.
|
||||
|---|---|
|
||||
| `hop accept PROPOSAL [-- COMMAND...]` | Accept internally without changing visible files |
|
||||
| `hop sync` | Materialize the current accepted tree from a safe accepted ancestor |
|
||||
| `hop export [--output PATH]` | Write 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 |
|
||||
|
||||
@@ -12,8 +12,11 @@ The canonical repository is `githop.xyz/GnosysLabs/Hop`.
|
||||
- Configure `origin` as `https://githop.xyz/GnosysLabs/Hop.git`.
|
||||
- Keep Gitea Actions disabled when the instance does not have dedicated runner
|
||||
capacity; Hop's release process does not depend on it.
|
||||
- Create a narrowly scoped maintainer access token that can write releases.
|
||||
Export it as `GITEA_TOKEN` only for the local publish command, then unset it.
|
||||
- Provision a narrowly scoped maintainer access token outside the agent session
|
||||
and store it in the operating system's secret store. Agents and release
|
||||
scripts may use that existing credential, but must never create, rotate, list,
|
||||
or revoke account tokens through Gitea's website or API. Export it as
|
||||
`GITEA_TOKEN` only for the local publish command, then unset it.
|
||||
- When upgrading GoReleaser, update its pinned version and four archive
|
||||
checksums in `scripts/release-local.sh` from the official checksum file.
|
||||
- Permit release attachment MIME types for `.tar.gz`, `.zip`, and `.txt` in
|
||||
@@ -49,7 +52,8 @@ default skill destinations while preserving unrelated user files.
|
||||
injected into the binaries automatically.
|
||||
2. Run `scripts/release-local.sh --snapshot` and inspect the artifacts.
|
||||
3. Create a signed semantic-version tag such as `v0.1.0-alpha.1` and push it.
|
||||
4. Export a locally stored, scoped token: `export GITEA_TOKEN=...`.
|
||||
4. Read a pre-provisioned, locally stored scoped token into `GITEA_TOKEN`.
|
||||
Do not generate a task-specific token from an agent session.
|
||||
5. Run `scripts/release-local.sh --publish`. It reruns race tests, vet, installer
|
||||
checks, builds six platform archives, generates `checksums.txt`, and uploads
|
||||
a draft without executing build work on the Gitea server.
|
||||
|
||||
@@ -38,7 +38,9 @@ public key independently. Checksum signing is listed as a launch gate in the
|
||||
Release builds execute on a maintainer machine, not on the Gitea server. Use a
|
||||
trusted, patched machine; keep the release token out of shell history and source
|
||||
files; scope it to the Hop repository; export it only for the publish command;
|
||||
and unset it immediately afterward. Releases upload as drafts for review.
|
||||
and unset it immediately afterward. Releases upload as drafts for review. The
|
||||
token must be provisioned by the user outside the agent session: Hop and its
|
||||
agents never create, rotate, list, or revoke provider account tokens.
|
||||
|
||||
## Filesystem safety
|
||||
|
||||
|
||||
Reference in New Issue
Block a user