Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ git push # Push to remote

<!-- end-beads-agent-instructions -->

<!-- gastown-agent-instructions-v1 -->
<!-- gastown-agent-instructions-v2 -->

---

Expand Down Expand Up @@ -124,6 +124,31 @@ gt mail send --human -s "Subject" -m "Message to overseer"
| Send detailed task/info | `gt mail send <target> -s "..." --stdin` | Persists across restarts |
| Both: send + wake | `gt mail send` then `gt nudge` | Mail carries payload, nudge wakes |

### When Human Approval is Required

Some actions block on a permission prompt from the agent runtime:
- Editing `.claude/` configuration files (`.claude/commands/`, `.claude/settings.json`, etc.)
- Destructive shell operations (force-push, `rm -rf`, database drops)
- Operations guarded by `--dangerously-skip-permissions`
- Any tool call that triggers a "do you want to allow?" permission dialog

**Before attempting any such action**, mail mayor so the human can be notified:

```bash
gt mail send mayor/ -s "HUMAN_INPUT_REQUIRED: <brief description>" --stdin <<'BODY'
About to: <describe the action>
Why: <why you need to do this>
What will happen: The agent will block on a permission prompt until the user approves.
BODY
gt nudge mayor "Check mail — human approval needed"
```

Then **proceed immediately** — attempt the action. Mayor will notify the user to
come unblock the permission prompt.

**Do NOT** wait for a reply before attempting.
**Do NOT** abandon the action — mail is the notification, not a gate.

### Context Recovery

After compaction or new session, run `gt prime` to reload your full role context,
Expand Down
4 changes: 4 additions & 0 deletions internal/beads/beads.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ type CreateOptions struct {
Parent string
Actor string // Who is creating this issue (populates created_by)
Ephemeral bool // Create as ephemeral (wisp) - not synced to git
Rig string // Target rig database (e.g., "gastown"). When set, passes --rig to bd create.
}

// UpdateOptions specifies options for updating an issue.
Expand Down Expand Up @@ -1110,6 +1111,9 @@ func (b *Beads) Create(opts CreateOptions) (*Issue, error) {
if opts.Ephemeral {
args = append(args, "--ephemeral")
}
if opts.Rig != "" {
args = append(args, "--rig="+opts.Rig)
}
// Default Actor from BD_ACTOR env var if not specified
// Uses getActor() to respect isolated mode (tests)
actor := opts.Actor
Expand Down
22 changes: 22 additions & 0 deletions internal/beads/beads_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,28 @@ func TestCreateOptions(t *testing.T) {
}
}

// TestCreateOptionsRig verifies the Rig field targets the correct rig database (gt-7y7).
// When a polecat works on a cross-rig bead (e.g., hq-xxx), gt done must explicitly
// set Rig on CreateOptions so the MR bead lands in the polecat's rig database,
// not the town-level database where the source bead lives.
func TestCreateOptionsRig(t *testing.T) {
opts := CreateOptions{
Title: "Merge: hq-abc",
Labels: []string{"gt:merge-request"},
Ephemeral: true,
Rig: "gastown",
}
if opts.Rig != "gastown" {
t.Errorf("Rig = %q, want %q", opts.Rig, "gastown")
}

// Zero value: Rig is empty string (no --rig flag passed).
var empty CreateOptions
if empty.Rig != "" {
t.Errorf("zero-value Rig = %q, want empty string", empty.Rig)
}
}

// TestIsFlagLikeTitle verifies flag-like title detection (gt-e0kx5).
func TestIsFlagLikeTitle(t *testing.T) {
tests := []struct {
Expand Down
20 changes: 20 additions & 0 deletions internal/beads/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,26 @@ func ResolveBeadsDirForID(currentBeadsDir, beadID string) string {
return currentBeadsDir
}

// ValidateRigPrefix checks that a newly created bead landed in the expected rig's
// database (gt-gpy). This is a POST-creation guard: the bead already exists, so
// callers MUST treat a non-nil return as a warning, not a hard failure.
//
// A mismatch means the bead's prefix doesn't match the expected rig prefix, which
// typically indicates the bd create routing resolved to the town-level database
// instead of the rig's database. Callers should log the warning and continue.
func ValidateRigPrefix(townRoot, rigName, beadID string) error {
expectedPrefix := GetPrefixForRig(townRoot, rigName) // e.g., "gt"
actualPrefix := strings.TrimSuffix(ExtractPrefix(beadID), "-") // e.g., "gt"
if actualPrefix == "" {
return nil // Can't determine prefix — not an error
}
if actualPrefix != expectedPrefix {
return fmt.Errorf("bead %s has prefix %q but rig %q expects prefix %q — bead may have landed in wrong database",
beadID, actualPrefix, rigName, expectedPrefix)
}
return nil
}

// ResolveHookDir determines the directory for running bd update on a bead.
// Since bd update doesn't support routing or redirects, we must resolve the
// actual rig directory from the bead's prefix. hookWorkDir is only used as
Expand Down
58 changes: 58 additions & 0 deletions internal/beads/routes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,3 +367,61 @@ func TestAgentBeadIDsWithPrefix(t *testing.T) {
})
}
}

// TestValidateRigPrefix verifies the post-creation prefix guard (gt-gpy).
func TestValidateRigPrefix(t *testing.T) {
// Set up a town root with routes.jsonl.
tmpDir := t.TempDir()
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0755); err != nil {
t.Fatal(err)
}
routesContent := `{"prefix": "gt-", "path": "gastown/mayor/rig"}
{"prefix": "bd-", "path": "beads/mayor/rig"}
{"prefix": "hq-", "path": "."}
`
if err := os.WriteFile(filepath.Join(beadsDir, "routes.jsonl"), []byte(routesContent), 0644); err != nil {
t.Fatal(err)
}

tests := []struct {
name string
rigName string
beadID string
wantErr bool
}{
{
name: "same-rig bead: no error",
rigName: "gastown",
beadID: "gt-wisp-abc",
wantErr: false,
},
{
name: "cross-rig: hq- bead on gastown rig returns error",
rigName: "gastown",
beadID: "hq-wisp-xyz",
wantErr: true,
},
{
name: "bd- bead on beads rig: no error",
rigName: "beads",
beadID: "bd-wisp-123",
wantErr: false,
},
{
name: "empty bead ID: no error (can't determine prefix)",
rigName: "gastown",
beadID: "",
wantErr: false,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := ValidateRigPrefix(tmpDir, tc.rigName, tc.beadID)
if (err != nil) != tc.wantErr {
t.Errorf("ValidateRigPrefix(%q, %q) error = %v, wantErr %v", tc.rigName, tc.beadID, err, tc.wantErr)
}
})
}
}
9 changes: 9 additions & 0 deletions internal/cmd/done.go
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,7 @@ func runDone(cmd *cobra.Command, args []string) (retErr error) {
Priority: priority,
Description: description,
Ephemeral: true,
Rig: rigName, // Ensure MR bead is created in the rig's database (gt-7y7)
})
if err != nil {
// Non-fatal: record the error and skip to notifyWitness.
Expand Down Expand Up @@ -970,6 +971,14 @@ func runDone(cmd *cobra.Command, args []string) (retErr error) {
goto notifyWitness
}

// gt-gpy: Validate that the MR bead landed in the rig's database.
// If the source bead has a cross-rig prefix (e.g., hq-), the routing
// could still resolve to the wrong database despite Rig: rigName.
// This is a warning-only guard — mrFailed is NOT set on mismatch.
if prefixErr := beads.ValidateRigPrefix(townRoot, rigName, mrID); prefixErr != nil {
style.PrintWarning("MR bead prefix mismatch: %v\nThe refinery may not find this MR — check 'gt mq list %s'", prefixErr, rigName)
}

// GH#3032: Supersede older open MRs for the same source issue.
// When a polecat re-submits after fixing a gate failure, the old MR
// (same branch, different SHA) is stale. Close it so the refinery
Expand Down
48 changes: 48 additions & 0 deletions internal/cmd/done_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,54 @@ func TestMRVerificationSetsMRFailed(t *testing.T) {
}
}

// TestMRBeadCreationUsesRig verifies that MR bead creation specifies the rig (gt-7y7).
// When a polecat works on a cross-rig bead (e.g., hq-xxx on rig "gastown"), the
// MR bead must be created with Rig set to the polecat's rig so it lands in the
// rig's database — not the town-level database where the source bead lives.
// Without this, the refinery never finds the MR and the branch sits unmerged.
func TestMRBeadCreationUsesRig(t *testing.T) {
tests := []struct {
name string
issueID string
rigName string
wantRig string
}{
{
name: "same-rig bead: rig is still set",
issueID: "gt-abc",
rigName: "gastown",
wantRig: "gastown",
},
{
name: "cross-rig hq- bead: MR must land in polecat rig",
issueID: "hq-abc",
rigName: "gastown",
wantRig: "gastown",
},
{
name: "cross-rig en- bead: MR must land in polecat rig",
issueID: "en-xyz",
rigName: "gastown",
wantRig: "gastown",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate the CreateOptions construction in done.go.
opts := beads.CreateOptions{
Title: "Merge: " + tt.issueID,
Labels: []string{"gt:merge-request"},
Ephemeral: true,
Rig: tt.rigName,
}
if opts.Rig != tt.wantRig {
t.Errorf("CreateOptions.Rig = %q, want %q (issue %s)", opts.Rig, tt.wantRig, tt.issueID)
}
})
}
}

// TestDeferredKillNotOnValidationError verifies that the deferred session kill
// does NOT trigger when runDone returns early due to validation errors (bad flags,
// wrong role). The sessionCleanupNeeded flag must only be set after role detection
Expand Down
6 changes: 6 additions & 0 deletions internal/cmd/mq_submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,11 +260,17 @@ func runMqSubmit(cmd *cobra.Command, args []string) error {
Priority: priority,
Description: description,
Ephemeral: true,
Rig: rigName, // Ensure MR bead is created in the rig's database (gt-7y7)
})
if err != nil {
return fmt.Errorf("creating merge request bead: %w", err)
}

// gt-gpy: Validate MR bead landed in the rig's database (warning only).
if prefixErr := beads.ValidateRigPrefix(townRoot, rigName, mrIssue.ID); prefixErr != nil {
style.PrintWarning("MR bead prefix mismatch: %v\nThe refinery may not find this MR — check 'gt mq list %s'", prefixErr, rigName)
}

// Nudge refinery to pick up the new MR
nudgeRefinery(rigName, "MERGE_READY received - check inbox for pending work")

Expand Down
7 changes: 7 additions & 0 deletions internal/refinery/engineer.go
Original file line number Diff line number Diff line change
Expand Up @@ -1291,12 +1291,19 @@ The Refinery will automatically retry the merge after you force-push.`,
Priority: mr.Priority,
Description: description,
Actor: e.rig.Name + "/refinery",
Rig: e.rig.Name, // Ensure task lands in the rig's database (gt-7y7)
})
if err != nil {
releaseSlotOnError()
return "", fmt.Errorf("creating conflict resolution task: %w", err)
}

// gt-gpy: Validate task bead landed in the rig's database (warning only).
townRoot := filepath.Dir(e.rig.Path)
if prefixErr := beads.ValidateRigPrefix(townRoot, e.rig.Name, task.ID); prefixErr != nil {
_, _ = fmt.Fprintf(e.output, "[Engineer] WARNING: conflict task prefix mismatch: %v\n", prefixErr)
}

// The conflict task's ID is returned so the MR can be blocked on it.
// When the task closes, the MR unblocks and re-enters the ready queue.

Expand Down
25 changes: 25 additions & 0 deletions internal/templates/roles/crew.md.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,31 @@ note and continue later.
For long-running operations, prefer `run_in_background: true` on Task and Bash
tools — background tasks survive turn interruption.

### When Human Approval is Required (HUMAN_INPUT_REQUIRED)

Some actions block on a permission prompt from the agent runtime:
- Editing `.claude/` configuration files (`.claude/commands/`, `.claude/settings.json`, etc.)
- Destructive shell operations (force-push, `rm -rf`, database drops)
- Operations guarded by `--dangerously-skip-permissions`
- Any tool call that triggers a "do you want to allow?" permission dialog

**Before attempting any such action**, mail mayor so the human can be notified to
come unblock the permission prompt:

```bash
{{ cmd }} mail send mayor/ -s "HUMAN_INPUT_REQUIRED: <brief description>" --stdin <<'BODY'
About to: <describe the action>
Why: <why you need to do this>
What will happen: The agent will block on a permission prompt until the user approves.
BODY
{{ cmd }} nudge mayor "Check mail — human approval needed"
```

Then **proceed immediately** — attempt the action. Mayor will notify the user.

**Do NOT** wait for a reply before attempting.
**Do NOT** abandon the action — mail is the notification, not a gate.

## Context Cycling (Handoff)

When your context fills up, cycle using `{{ cmd }} handoff`.
Expand Down
Loading