diff --git a/.github/workflows/block-internal-prs.yml b/.github/workflows/block-internal-prs.yml deleted file mode 100644 index 689a130c..00000000 --- a/.github/workflows/block-internal-prs.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Block Internal PRs - -on: - pull_request: - types: [opened, reopened] - -jobs: - block-internal-prs: - name: Block Internal PRs - # Only run if PR is from the same repo (not a fork) - if: github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - steps: - - name: Close PR and comment - uses: actions/github-script@v7 - with: - script: | - const prNumber = context.issue.number; - const branch = context.payload.pull_request.head.ref; - - const body = [ - '**Internal PRs are not allowed.**', - '', - 'Gas Town agents push directly to main. PRs are for external contributors only.', - '', - 'To land your changes:', - '```bash', - 'git checkout main', - 'git merge ' + branch, - 'git push origin main', - 'git push origin --delete ' + branch, - '```', - '', - 'See CLAUDE.md: "Crew workers push directly to main. No feature branches. NEVER create PRs."' - ].join('\n'); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: body - }); - - await github.rest.pulls.update({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - state: 'closed' - }); - - core.setFailed('Internal PR blocked. Push directly to main instead.'); diff --git a/internal/cmd/plugin.go b/internal/cmd/plugin.go index 40bde4c0..5d3194a1 100644 --- a/internal/cmd/plugin.go +++ b/internal/cmd/plugin.go @@ -392,7 +392,7 @@ func runPluginRun(cmd *cobra.Command, args []string) error { if duration == "" { duration = "1h" // default } - count, err := recorder.CountRunsSince(p.Name, duration) + count, err := recorder.CountSuccessfulRunsSince(p.Name, duration) if err != nil { // Log warning but continue fmt.Fprintf(os.Stderr, "Warning: checking gate status: %v\n", err) @@ -433,13 +433,14 @@ func runPluginRun(cmd *cobra.Command, args []string) error { fmt.Printf("%s\n", style.Bold.Render("Instructions:")) fmt.Println(p.Instructions) - // Record the run + // Record the run as "skipped" - we only printed instructions, didn't execute + // Actual execution happens via Deacon patrol or an agent following the instructions recorder := plugin.NewRecorder(townRoot) beadID, err := recorder.RecordRun(plugin.PluginRunRecord{ PluginName: p.Name, RigName: p.RigName, - Result: plugin.ResultSuccess, // Manual runs are marked success - Body: "Manual run via gt plugin run", + Result: plugin.ResultSkipped, // Instructions printed, not executed + Body: "Manual run via gt plugin run (instructions printed, not executed)", }) if err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to record run: %v\n", err) diff --git a/internal/config/env.go b/internal/config/env.go index 5b4ddd65..a7dcad8c 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -49,36 +49,43 @@ func AgentEnv(cfg AgentEnvConfig) map[string]string { case "mayor": env["BD_ACTOR"] = "mayor" env["GIT_AUTHOR_NAME"] = "mayor" + env["GIT_AUTHOR_EMAIL"] = "mayor@gastown.local" case "deacon": env["BD_ACTOR"] = "deacon" env["GIT_AUTHOR_NAME"] = "deacon" + env["GIT_AUTHOR_EMAIL"] = "deacon@gastown.local" case "boot": env["BD_ACTOR"] = "deacon-boot" env["GIT_AUTHOR_NAME"] = "boot" + env["GIT_AUTHOR_EMAIL"] = "boot@gastown.local" case "witness": env["GT_RIG"] = cfg.Rig env["BD_ACTOR"] = fmt.Sprintf("%s/witness", cfg.Rig) env["GIT_AUTHOR_NAME"] = fmt.Sprintf("%s/witness", cfg.Rig) + env["GIT_AUTHOR_EMAIL"] = fmt.Sprintf("%s-witness@gastown.local", cfg.Rig) case "refinery": env["GT_RIG"] = cfg.Rig env["BD_ACTOR"] = fmt.Sprintf("%s/refinery", cfg.Rig) env["GIT_AUTHOR_NAME"] = fmt.Sprintf("%s/refinery", cfg.Rig) + env["GIT_AUTHOR_EMAIL"] = fmt.Sprintf("%s-refinery@gastown.local", cfg.Rig) case "polecat": env["GT_RIG"] = cfg.Rig env["GT_POLECAT"] = cfg.AgentName env["BD_ACTOR"] = fmt.Sprintf("%s/polecats/%s", cfg.Rig, cfg.AgentName) env["GIT_AUTHOR_NAME"] = cfg.AgentName + env["GIT_AUTHOR_EMAIL"] = fmt.Sprintf("%s-polecat-%s@gastown.local", cfg.Rig, cfg.AgentName) case "crew": env["GT_RIG"] = cfg.Rig env["GT_CREW"] = cfg.AgentName env["BD_ACTOR"] = fmt.Sprintf("%s/crew/%s", cfg.Rig, cfg.AgentName) env["GIT_AUTHOR_NAME"] = cfg.AgentName + env["GIT_AUTHOR_EMAIL"] = fmt.Sprintf("%s-crew-%s@gastown.local", cfg.Rig, cfg.AgentName) } // Only set GT_ROOT if provided diff --git a/internal/config/env_test.go b/internal/config/env_test.go index 0dda66f9..55b54db7 100644 --- a/internal/config/env_test.go +++ b/internal/config/env_test.go @@ -14,6 +14,7 @@ func TestAgentEnv_Mayor(t *testing.T) { assertEnv(t, env, "GT_ROLE", "mayor") assertEnv(t, env, "BD_ACTOR", "mayor") assertEnv(t, env, "GIT_AUTHOR_NAME", "mayor") + assertEnv(t, env, "GIT_AUTHOR_EMAIL", "mayor@gastown.local") assertEnv(t, env, "GT_ROOT", "/town") assertNotSet(t, env, "GT_RIG") assertNotSet(t, env, "BEADS_NO_DAEMON") @@ -31,6 +32,7 @@ func TestAgentEnv_Witness(t *testing.T) { assertEnv(t, env, "GT_RIG", "myrig") assertEnv(t, env, "BD_ACTOR", "myrig/witness") assertEnv(t, env, "GIT_AUTHOR_NAME", "myrig/witness") + assertEnv(t, env, "GIT_AUTHOR_EMAIL", "myrig-witness@gastown.local") assertEnv(t, env, "GT_ROOT", "/town") } @@ -49,6 +51,7 @@ func TestAgentEnv_Polecat(t *testing.T) { assertEnv(t, env, "GT_POLECAT", "Toast") assertEnv(t, env, "BD_ACTOR", "myrig/polecats/Toast") assertEnv(t, env, "GIT_AUTHOR_NAME", "Toast") + assertEnv(t, env, "GIT_AUTHOR_EMAIL", "myrig-polecat-Toast@gastown.local") assertEnv(t, env, "BEADS_AGENT_NAME", "myrig/Toast") assertEnv(t, env, "BEADS_NO_DAEMON", "1") } @@ -68,6 +71,7 @@ func TestAgentEnv_Crew(t *testing.T) { assertEnv(t, env, "GT_CREW", "emma") assertEnv(t, env, "BD_ACTOR", "myrig/crew/emma") assertEnv(t, env, "GIT_AUTHOR_NAME", "emma") + assertEnv(t, env, "GIT_AUTHOR_EMAIL", "myrig-crew-emma@gastown.local") assertEnv(t, env, "BEADS_AGENT_NAME", "myrig/emma") assertEnv(t, env, "BEADS_NO_DAEMON", "1") } @@ -85,6 +89,7 @@ func TestAgentEnv_Refinery(t *testing.T) { assertEnv(t, env, "GT_RIG", "myrig") assertEnv(t, env, "BD_ACTOR", "myrig/refinery") assertEnv(t, env, "GIT_AUTHOR_NAME", "myrig/refinery") + assertEnv(t, env, "GIT_AUTHOR_EMAIL", "myrig-refinery@gastown.local") assertEnv(t, env, "BEADS_NO_DAEMON", "1") } @@ -98,6 +103,7 @@ func TestAgentEnv_Deacon(t *testing.T) { assertEnv(t, env, "GT_ROLE", "deacon") assertEnv(t, env, "BD_ACTOR", "deacon") assertEnv(t, env, "GIT_AUTHOR_NAME", "deacon") + assertEnv(t, env, "GIT_AUTHOR_EMAIL", "deacon@gastown.local") assertEnv(t, env, "GT_ROOT", "/town") assertNotSet(t, env, "GT_RIG") assertNotSet(t, env, "BEADS_NO_DAEMON") @@ -113,6 +119,7 @@ func TestAgentEnv_Boot(t *testing.T) { assertEnv(t, env, "GT_ROLE", "boot") assertEnv(t, env, "BD_ACTOR", "deacon-boot") assertEnv(t, env, "GIT_AUTHOR_NAME", "boot") + assertEnv(t, env, "GIT_AUTHOR_EMAIL", "boot@gastown.local") assertEnv(t, env, "GT_ROOT", "/town") assertNotSet(t, env, "GT_RIG") assertNotSet(t, env, "BEADS_NO_DAEMON") diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 597bf7d3..262ea243 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -10,8 +10,8 @@ const ( ShutdownNotifyDelay = 500 * time.Millisecond // ClaudeStartTimeout is how long to wait for Claude to start in a session. - // Increased to 60s because Claude can take 30s+ on slower machines. - ClaudeStartTimeout = 60 * time.Second + // Increased to 120s because Claude can take 60s+ on slower machines or under load. + ClaudeStartTimeout = 120 * time.Second // ShellReadyTimeout is how long to wait for shell prompt after command. ShellReadyTimeout = 5 * time.Second diff --git a/internal/plugin/recording.go b/internal/plugin/recording.go index 77af0dc5..cbe174d0 100644 --- a/internal/plugin/recording.go +++ b/internal/plugin/recording.go @@ -213,3 +213,19 @@ func (r *Recorder) CountRunsSince(pluginName string, since string) (int, error) } return len(runs), nil } + +// CountSuccessfulRunsSince returns the count of successful runs for a plugin since the given duration. +// Only successful runs count for cooldown gate evaluation - skipped/failed runs don't reset the cooldown. +func (r *Recorder) CountSuccessfulRunsSince(pluginName string, since string) (int, error) { + runs, err := r.GetRunsSince(pluginName, since) + if err != nil { + return 0, err + } + count := 0 + for _, run := range runs { + if run.Result == ResultSuccess { + count++ + } + } + return count, nil +} diff --git a/internal/templates/roles/mayor.md.tmpl b/internal/templates/roles/mayor.md.tmpl index a6e80e3e..d6dcdf03 100644 --- a/internal/templates/roles/mayor.md.tmpl +++ b/internal/templates/roles/mayor.md.tmpl @@ -35,7 +35,9 @@ drive shaft - if you stall, the whole town stalls. **Your startup behavior:** 1. Check hook (`gt hook`) 2. If work is hooked → EXECUTE (no announcement beyond one line, no waiting) -3. If hook empty → Check mail, then wait for user instructions +3. If hook empty → Check escalations (`gt escalate list`) +4. Handle any pending escalations (these are urgent items from other agents) +5. Check mail, then wait for user instructions **Note:** "Hooked" means work assigned to you. This triggers autonomous mode even if no molecule (workflow) is attached. Don't confuse with "pinned" which is for @@ -262,16 +264,21 @@ Like crew, you're human-managed. But the hook protocol still applies: gt hook # Shows hooked work (if any) # Step 2: Work hooked? → RUN IT -# Hook empty? → Check mail for attached work + +# Step 3: Hook empty? → Check escalations (mayor-specific) +gt escalate list # Shows pending escalations from other agents +# Handle any pending escalations - these are urgent items requiring your attention + +# Step 4: Check mail for attached work gt mail inbox # If mail contains attached work, hook it: gt mol attach-from-mail -# Step 3: Still nothing? Wait for user instructions +# Step 5: Still nothing? Wait for user instructions # You're the Mayor - the human directs your work ``` -**Work hooked → Run it. Hook empty → Check mail. Nothing anywhere → Wait for user.** +**Work hooked → Run it. Hook empty → Check escalations → Check mail. Nothing anywhere → Wait for user.** Your hooked work persists across sessions. Handoff mail (🤝 HANDOFF subject) provides context notes.