From 7fd073810d50471dcef6a12a2f3849704ab07789 Mon Sep 17 00:00:00 2001 From: mayor Date: Sun, 18 Jan 2026 23:07:02 -0800 Subject: [PATCH 1/5] feat(mayor): add escalation check to startup protocol Mayor now checks `gt escalate list` between hook and mail checks at startup. This ensures pending escalations from other agents are handled promptly. Other roles (witness, refinery, polecat, crew, deacon) are unaffected - they create escalations but don't handle them at startup. --- internal/templates/roles/mayor.md.tmpl | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) 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. -- 2.49.1 From 9f9b2376ead4467b5d842dbb484e35639fac4971 Mon Sep 17 00:00:00 2001 From: mayor Date: Mon, 19 Jan 2026 20:19:58 -0800 Subject: [PATCH 2/5] ci: disable block-internal-prs for fork workflow We use PRs for human review before merging in our fork. --- .github/workflows/block-internal-prs.yml | 51 ------------------------ 1 file changed, 51 deletions(-) delete mode 100644 .github/workflows/block-internal-prs.yml 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.'); -- 2.49.1 From eed894112658c38965ab04e8927c5ad01d4b8ffb Mon Sep 17 00:00:00 2001 From: mayor Date: Mon, 19 Jan 2026 14:52:49 -0800 Subject: [PATCH 3/5] feat(security): add GIT_AUTHOR_EMAIL per agent type Phase 1 of agent security model: Set distinct email addresses for each agent type to improve audit trail clarity. Email format: - Town-level: {role}@gastown.local (mayor, deacon, boot) - Rig-level: {rig}-{role}@gastown.local (witness, refinery) - Named agents: {rig}-{role}-{name}@gastown.local (polecat, crew) This makes git log filtering by agent type trivial and provides a foundation for per-agent key separation in future phases. Refs: hq-biot --- internal/config/env.go | 7 +++++++ internal/config/env_test.go | 7 +++++++ 2 files changed, 14 insertions(+) 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") -- 2.49.1 From 2a639ff9995e544ad77d4ff8a03ebee7a743c3da Mon Sep 17 00:00:00 2001 From: furiosa Date: Tue, 20 Jan 2026 07:06:02 -0800 Subject: [PATCH 4/5] fix(session): increase ClaudeStartTimeout from 60s to 120s Fixes intermittent 'timeout waiting for runtime prompt' errors that occur when Claude takes longer than 60s to start under load or on slower machines. Resolves: hq-j2wl Co-Authored-By: Claude Opus 4.5 --- internal/constants/constants.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 -- 2.49.1 From 612c59629f8eeb47be9386d6045601525d53a952 Mon Sep 17 00:00:00 2001 From: chrome Date: Sat, 24 Jan 2026 01:20:12 -0800 Subject: [PATCH 5/5] fix(plugin): don't record false success for manual plugin runs `gt plugin run` was recording ResultSuccess even though it only prints instructions without executing them. This poisoned the cooldown gate, blocking actual executions for 24h. Changes: - Record manual runs as ResultSkipped instead of ResultSuccess - Add CountSuccessfulRunsSince() to only count successful runs for gate - Gate check now uses CountSuccessfulRunsSince() so skipped/failed runs don't block future executions Fixes: hq-2dis4c Co-Authored-By: Claude Opus 4.5 --- internal/cmd/plugin.go | 9 +++++---- internal/plugin/recording.go | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) 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/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 +} -- 2.49.1