Compare commits
32 Commits
v0.3.0
...
87e2a6a634
| Author | SHA1 | Date | |
|---|---|---|---|
| 87e2a6a634 | |||
| e9d987d19a | |||
| ee1bc35f3b | |||
| e9a262bca8 | |||
| b8eb936219 | |||
| dcf7b81011 | |||
| 37f465bde5 | |||
| 9cd2696abe | |||
| 2b3f287f02 | |||
| 021b087a12 | |||
| 3cb3a0bbf7 | |||
| 7714295a43 | |||
| 616ff01e2c | |||
| 8d41f817b9 | |||
| 3f724336f4 | |||
| 576e73a924 | |||
| 5ecf8ccaf5 | |||
| 238ad8cd95 | |||
| 50bcf96afb | |||
| 2feefd1731 | |||
| 4a856f6e0d | |||
| e853ac3539 | |||
| f14dadc956 | |||
| f19a0ab5d6 | |||
| 38d3c0c4f1 | |||
| d4ad4c0726 | |||
| 88a74c50f7 | |||
| 7ff87ff012 | |||
| bd655f58f9 | |||
| 72b03469d1 | |||
| d6a4bc22fd | |||
| 3283ee42aa |
@@ -32,6 +32,11 @@ beads.left.meta.json
|
||||
beads.right.jsonl
|
||||
beads.right.meta.json
|
||||
|
||||
# Sync state (local-only, per-machine)
|
||||
# These files are machine-specific and should not be shared across clones
|
||||
.sync.lock
|
||||
sync_base.jsonl
|
||||
|
||||
# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here.
|
||||
# They would override fork protection in .git/info/exclude, allowing
|
||||
# contributors to accidentally commit upstream issue databases.
|
||||
|
||||
@@ -341,20 +341,18 @@ id = "restart-daemons"
|
||||
title = "Restart daemons"
|
||||
needs = ["local-install"]
|
||||
description = """
|
||||
Restart gt daemons to pick up the new version.
|
||||
Restart gt daemon to pick up the new version.
|
||||
|
||||
```bash
|
||||
gt daemons killall
|
||||
gt daemon stop && gt daemon start
|
||||
```
|
||||
|
||||
Daemons will auto-restart with the new version on next gt command.
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
gt daemons list
|
||||
gt daemon status
|
||||
```
|
||||
|
||||
Check that daemon versions match {{version}}.
|
||||
The daemon should show the new binary timestamp and no stale warning.
|
||||
|
||||
Note: This step is safe to retry if it fails.
|
||||
"""
|
||||
|
||||
@@ -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.');
|
||||
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.0] - 2026-01-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Orphan cleanup skips valid tmux sessions** - `gt orphans kill` and automatic orphan cleanup now check for Claude processes belonging to valid Gas Town tmux sessions (gt-*/hq-*) before killing. This prevents false kills of witnesses, refineries, and deacon during startup when they may temporarily show TTY "?"
|
||||
|
||||
## [0.3.1] - 2026-01-17
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Orphan cleanup on macOS** - Fixed TTY comparison (`??` vs `?`) so orphan detection works on macOS
|
||||
- **Session kill leaves orphans** - `gt done` and `gt crew stop` now use `KillSessionWithProcesses` to properly terminate all child processes before killing the tmux session
|
||||
|
||||
## [0.3.0] - 2026-01-17
|
||||
|
||||
### Added
|
||||
|
||||
@@ -200,7 +200,8 @@ gt done # Signal completion (syncs, submits to MQ, notifi
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use `--continue` for propulsion** - Keep momentum by auto-advancing
|
||||
2. **Check progress with `bd mol current`** - Know where you are before resuming
|
||||
3. **Squash completed molecules** - Create digests for audit trail
|
||||
4. **Burn routine wisps** - Don't accumulate ephemeral patrol data
|
||||
1. **CRITICAL: Close steps in real-time** - Mark `in_progress` BEFORE starting, `closed` IMMEDIATELY after completing. Never batch-close steps at the end. Molecules ARE the ledger - each step closure is a timestamped CV entry. Batch-closing corrupts the timeline and violates HOP's core promise.
|
||||
2. **Use `--continue` for propulsion** - Keep momentum by auto-advancing
|
||||
3. **Check progress with `bd mol current`** - Know where you are before resuming
|
||||
4. **Squash completed molecules** - Create digests for audit trail
|
||||
5. **Burn routine wisps** - Don't accumulate ephemeral patrol data
|
||||
|
||||
+72
-5
@@ -113,6 +113,7 @@ type SyncStatus struct {
|
||||
type Beads struct {
|
||||
workDir string
|
||||
beadsDir string // Optional BEADS_DIR override for cross-database access
|
||||
isolated bool // If true, suppress inherited beads env vars (for test isolation)
|
||||
}
|
||||
|
||||
// New creates a new Beads wrapper for the given directory.
|
||||
@@ -120,12 +121,36 @@ func New(workDir string) *Beads {
|
||||
return &Beads{workDir: workDir}
|
||||
}
|
||||
|
||||
// NewIsolated creates a Beads wrapper for test isolation.
|
||||
// This suppresses inherited beads env vars (BD_ACTOR, BEADS_DB) to prevent
|
||||
// tests from accidentally routing to production databases.
|
||||
func NewIsolated(workDir string) *Beads {
|
||||
return &Beads{workDir: workDir, isolated: true}
|
||||
}
|
||||
|
||||
// NewWithBeadsDir creates a Beads wrapper with an explicit BEADS_DIR.
|
||||
// This is needed when running from a polecat worktree but accessing town-level beads.
|
||||
func NewWithBeadsDir(workDir, beadsDir string) *Beads {
|
||||
return &Beads{workDir: workDir, beadsDir: beadsDir}
|
||||
}
|
||||
|
||||
// getActor returns the BD_ACTOR value for this context.
|
||||
// Returns empty string when in isolated mode (tests) to prevent
|
||||
// inherited actors from routing to production databases.
|
||||
func (b *Beads) getActor() string {
|
||||
if b.isolated {
|
||||
return ""
|
||||
}
|
||||
return os.Getenv("BD_ACTOR")
|
||||
}
|
||||
|
||||
// Init initializes a new beads database in the working directory.
|
||||
// This uses the same environment isolation as other commands.
|
||||
func (b *Beads) Init(prefix string) error {
|
||||
_, err := b.run("init", "--prefix", prefix, "--quiet")
|
||||
return err
|
||||
}
|
||||
|
||||
// run executes a bd command and returns stdout.
|
||||
func (b *Beads) run(args ...string) ([]byte, error) {
|
||||
// Use --no-daemon for faster read operations (avoids daemon IPC overhead)
|
||||
@@ -133,8 +158,6 @@ func (b *Beads) run(args ...string) ([]byte, error) {
|
||||
// Use --allow-stale to prevent failures when db is out of sync with JSONL
|
||||
// (e.g., after daemon is killed during shutdown before syncing).
|
||||
fullArgs := append([]string{"--no-daemon", "--allow-stale"}, args...)
|
||||
cmd := exec.Command("bd", fullArgs...) //nolint:gosec // G204: bd is a trusted internal tool
|
||||
cmd.Dir = b.workDir
|
||||
|
||||
// Always explicitly set BEADS_DIR to prevent inherited env vars from
|
||||
// causing prefix mismatches. Use explicit beadsDir if set, otherwise
|
||||
@@ -143,7 +166,28 @@ func (b *Beads) run(args ...string) ([]byte, error) {
|
||||
if beadsDir == "" {
|
||||
beadsDir = ResolveBeadsDir(b.workDir)
|
||||
}
|
||||
cmd.Env = append(os.Environ(), "BEADS_DIR="+beadsDir)
|
||||
|
||||
// In isolated mode, use --db flag to force specific database path
|
||||
// This bypasses bd's routing logic that can redirect to .beads-planning
|
||||
// Skip --db for init command since it creates the database
|
||||
isInit := len(args) > 0 && args[0] == "init"
|
||||
if b.isolated && !isInit {
|
||||
beadsDB := filepath.Join(beadsDir, "beads.db")
|
||||
fullArgs = append([]string{"--db", beadsDB}, fullArgs...)
|
||||
}
|
||||
|
||||
cmd := exec.Command("bd", fullArgs...) //nolint:gosec // G204: bd is a trusted internal tool
|
||||
cmd.Dir = b.workDir
|
||||
|
||||
// Build environment: filter beads env vars when in isolated mode (tests)
|
||||
// to prevent routing to production databases.
|
||||
var env []string
|
||||
if b.isolated {
|
||||
env = filterBeadsEnv(os.Environ())
|
||||
} else {
|
||||
env = os.Environ()
|
||||
}
|
||||
cmd.Env = append(env, "BEADS_DIR="+beadsDir)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
@@ -196,6 +240,27 @@ func (b *Beads) wrapError(err error, stderr string, args []string) error {
|
||||
return fmt.Errorf("bd %s: %w", strings.Join(args, " "), err)
|
||||
}
|
||||
|
||||
// filterBeadsEnv removes beads-related environment variables from the given
|
||||
// environment slice. This ensures test isolation by preventing inherited
|
||||
// BD_ACTOR, BEADS_DB, GT_ROOT, HOME etc. from routing commands to production databases.
|
||||
func filterBeadsEnv(environ []string) []string {
|
||||
filtered := make([]string, 0, len(environ))
|
||||
for _, env := range environ {
|
||||
// Skip beads-related env vars that could interfere with test isolation
|
||||
// BD_ACTOR, BEADS_* - direct beads config
|
||||
// GT_ROOT - causes bd to find global routes file
|
||||
// HOME - causes bd to find ~/.beads-planning routing
|
||||
if strings.HasPrefix(env, "BD_ACTOR=") ||
|
||||
strings.HasPrefix(env, "BEADS_") ||
|
||||
strings.HasPrefix(env, "GT_ROOT=") ||
|
||||
strings.HasPrefix(env, "HOME=") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, env)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// List returns issues matching the given options.
|
||||
func (b *Beads) List(opts ListOptions) ([]*Issue, error) {
|
||||
args := []string{"list", "--json"}
|
||||
@@ -398,9 +463,10 @@ func (b *Beads) Create(opts CreateOptions) (*Issue, error) {
|
||||
args = append(args, "--ephemeral")
|
||||
}
|
||||
// Default Actor from BD_ACTOR env var if not specified
|
||||
// Uses getActor() to respect isolated mode (tests)
|
||||
actor := opts.Actor
|
||||
if actor == "" {
|
||||
actor = os.Getenv("BD_ACTOR")
|
||||
actor = b.getActor()
|
||||
}
|
||||
if actor != "" {
|
||||
args = append(args, "--actor="+actor)
|
||||
@@ -445,9 +511,10 @@ func (b *Beads) CreateWithID(id string, opts CreateOptions) (*Issue, error) {
|
||||
args = append(args, "--parent="+opts.Parent)
|
||||
}
|
||||
// Default Actor from BD_ACTOR env var if not specified
|
||||
// Uses getActor() to respect isolated mode (tests)
|
||||
actor := opts.Actor
|
||||
if actor == "" {
|
||||
actor = os.Getenv("BD_ACTOR")
|
||||
actor = b.getActor()
|
||||
}
|
||||
if actor != "" {
|
||||
args = append(args, "--actor="+actor)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -144,7 +143,8 @@ func (b *Beads) CreateAgentBead(id, title string, fields *AgentFields) (*Issue,
|
||||
}
|
||||
|
||||
// Default actor from BD_ACTOR env var for provenance tracking
|
||||
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||
// Uses getActor() to respect isolated mode (tests)
|
||||
if actor := b.getActor(); actor != "" {
|
||||
args = append(args, "--actor="+actor)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -162,7 +161,8 @@ func (b *Beads) CreateChannelBead(name string, subscribers []string, createdBy s
|
||||
}
|
||||
|
||||
// Default actor from BD_ACTOR env var for provenance tracking
|
||||
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||
// Uses getActor() to respect isolated mode (tests)
|
||||
if actor := b.getActor(); actor != "" {
|
||||
args = append(args, "--actor="+actor)
|
||||
}
|
||||
|
||||
@@ -382,7 +382,7 @@ func (b *Beads) LookupChannelByName(name string) (*Issue, *ChannelFields, error)
|
||||
|
||||
// EnforceChannelRetention prunes old messages from a channel to enforce retention.
|
||||
// Called after posting a new message to the channel (on-write cleanup).
|
||||
// If channel has >= retainCount messages, deletes oldest until count < retainCount.
|
||||
// Enforces both count-based (RetentionCount) and time-based (RetentionHours) limits.
|
||||
func (b *Beads) EnforceChannelRetention(name string) error {
|
||||
// Get channel config
|
||||
_, fields, err := b.GetChannelBead(name)
|
||||
@@ -393,8 +393,8 @@ func (b *Beads) EnforceChannelRetention(name string) error {
|
||||
return fmt.Errorf("channel not found: %s", name)
|
||||
}
|
||||
|
||||
// Skip if no retention limit
|
||||
if fields.RetentionCount <= 0 {
|
||||
// Skip if no retention limits configured
|
||||
if fields.RetentionCount <= 0 && fields.RetentionHours <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -411,23 +411,42 @@ func (b *Beads) EnforceChannelRetention(name string) error {
|
||||
}
|
||||
|
||||
var messages []struct {
|
||||
ID string `json:"id"`
|
||||
ID string `json:"id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &messages); err != nil {
|
||||
return fmt.Errorf("parsing channel messages: %w", err)
|
||||
}
|
||||
|
||||
// Calculate how many to delete
|
||||
// We're being called after a new message is posted, so we want to end up with retainCount
|
||||
toDelete := len(messages) - fields.RetentionCount
|
||||
if toDelete <= 0 {
|
||||
return nil // No pruning needed
|
||||
// Track which messages to delete (use map to avoid duplicates)
|
||||
toDeleteIDs := make(map[string]bool)
|
||||
|
||||
// Time-based retention: delete messages older than RetentionHours
|
||||
if fields.RetentionHours > 0 {
|
||||
cutoff := time.Now().Add(-time.Duration(fields.RetentionHours) * time.Hour)
|
||||
for _, msg := range messages {
|
||||
createdAt, err := time.Parse(time.RFC3339, msg.CreatedAt)
|
||||
if err != nil {
|
||||
continue // Skip messages with unparseable timestamps
|
||||
}
|
||||
if createdAt.Before(cutoff) {
|
||||
toDeleteIDs[msg.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete oldest messages (best-effort)
|
||||
for i := 0; i < toDelete && i < len(messages); i++ {
|
||||
// Count-based retention: delete oldest messages beyond RetentionCount
|
||||
if fields.RetentionCount > 0 {
|
||||
toDeleteByCount := len(messages) - fields.RetentionCount
|
||||
for i := 0; i < toDeleteByCount && i < len(messages); i++ {
|
||||
toDeleteIDs[messages[i].ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Delete marked messages (best-effort)
|
||||
for id := range toDeleteIDs {
|
||||
// Use close instead of delete for audit trail
|
||||
_, _ = b.run("close", messages[i].ID, "--reason=channel retention pruning")
|
||||
_, _ = b.run("close", id, "--reason=channel retention pruning")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -435,7 +454,8 @@ func (b *Beads) EnforceChannelRetention(name string) error {
|
||||
|
||||
// PruneAllChannels enforces retention on all channels.
|
||||
// Called by Deacon patrol as a backup cleanup mechanism.
|
||||
// Uses a 10% buffer to avoid thrashing (only prunes if count > retainCount * 1.1).
|
||||
// Enforces both count-based (RetentionCount) and time-based (RetentionHours) limits.
|
||||
// Uses a 10% buffer for count-based pruning to avoid thrashing.
|
||||
func (b *Beads) PruneAllChannels() (int, error) {
|
||||
channels, err := b.ListChannelBeads()
|
||||
if err != nil {
|
||||
@@ -444,38 +464,62 @@ func (b *Beads) PruneAllChannels() (int, error) {
|
||||
|
||||
pruned := 0
|
||||
for name, fields := range channels {
|
||||
if fields.RetentionCount <= 0 {
|
||||
// Skip if no retention limits configured
|
||||
if fields.RetentionCount <= 0 && fields.RetentionHours <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Count messages
|
||||
// Get messages with timestamps
|
||||
out, err := b.run("list",
|
||||
"--type=message",
|
||||
"--label=channel:"+name,
|
||||
"--json",
|
||||
"--limit=0",
|
||||
"--sort=created",
|
||||
)
|
||||
if err != nil {
|
||||
continue // Skip on error
|
||||
}
|
||||
|
||||
var messages []struct {
|
||||
ID string `json:"id"`
|
||||
ID string `json:"id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &messages); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// 10% buffer - only prune if significantly over limit
|
||||
threshold := int(float64(fields.RetentionCount) * 1.1)
|
||||
if len(messages) <= threshold {
|
||||
continue
|
||||
// Track which messages to delete (use map to avoid duplicates)
|
||||
toDeleteIDs := make(map[string]bool)
|
||||
|
||||
// Time-based retention: delete messages older than RetentionHours
|
||||
if fields.RetentionHours > 0 {
|
||||
cutoff := time.Now().Add(-time.Duration(fields.RetentionHours) * time.Hour)
|
||||
for _, msg := range messages {
|
||||
createdAt, err := time.Parse(time.RFC3339, msg.CreatedAt)
|
||||
if err != nil {
|
||||
continue // Skip messages with unparseable timestamps
|
||||
}
|
||||
if createdAt.Before(cutoff) {
|
||||
toDeleteIDs[msg.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prune down to exactly retainCount
|
||||
toDelete := len(messages) - fields.RetentionCount
|
||||
for i := 0; i < toDelete && i < len(messages); i++ {
|
||||
if _, err := b.run("close", messages[i].ID, "--reason=patrol retention pruning"); err == nil {
|
||||
// Count-based retention with 10% buffer to avoid thrashing
|
||||
if fields.RetentionCount > 0 {
|
||||
threshold := int(float64(fields.RetentionCount) * 1.1)
|
||||
if len(messages) > threshold {
|
||||
toDeleteByCount := len(messages) - fields.RetentionCount
|
||||
for i := 0; i < toDeleteByCount && i < len(messages); i++ {
|
||||
toDeleteIDs[messages[i].ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete marked messages
|
||||
for id := range toDeleteIDs {
|
||||
if _, err := b.run("close", id, "--reason=patrol retention pruning"); err == nil {
|
||||
pruned++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ package beads
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -28,7 +27,8 @@ func (b *Beads) CreateDogAgentBead(name, location string) (*Issue, error) {
|
||||
}
|
||||
|
||||
// Default actor from BD_ACTOR env var for provenance tracking
|
||||
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||
// Uses getActor() to respect isolated mode (tests)
|
||||
if actor := b.getActor(); actor != "" {
|
||||
args = append(args, "--actor="+actor)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -183,7 +182,8 @@ func (b *Beads) CreateEscalationBead(title string, fields *EscalationFields) (*I
|
||||
}
|
||||
|
||||
// Default actor from BD_ACTOR env var for provenance tracking
|
||||
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||
// Uses getActor() to respect isolated mode (tests)
|
||||
if actor := b.getActor(); actor != "" {
|
||||
args = append(args, "--actor="+actor)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -130,7 +129,8 @@ func (b *Beads) CreateGroupBead(name string, members []string, createdBy string)
|
||||
}
|
||||
|
||||
// Default actor from BD_ACTOR env var for provenance tracking
|
||||
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||
// Uses getActor() to respect isolated mode (tests)
|
||||
if actor := b.getActor(); actor != "" {
|
||||
args = append(args, "--actor="+actor)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -180,7 +179,8 @@ func (b *Beads) CreateQueueBead(id, title string, fields *QueueFields) (*Issue,
|
||||
}
|
||||
|
||||
// Default actor from BD_ACTOR env var for provenance tracking
|
||||
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||
// Uses getActor() to respect isolated mode (tests)
|
||||
if actor := b.getActor(); actor != "" {
|
||||
args = append(args, "--actor="+actor)
|
||||
}
|
||||
|
||||
@@ -292,6 +292,37 @@ func (b *Beads) DeleteQueueBead(id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// LookupQueueByName finds a queue by its name field (not by ID).
|
||||
// This is used for address resolution where we may not know the full bead ID.
|
||||
func (b *Beads) LookupQueueByName(name string) (*Issue, *QueueFields, error) {
|
||||
// First try direct lookup by standard ID formats (town and rig level)
|
||||
for _, isTownLevel := range []bool{true, false} {
|
||||
id := QueueBeadID(name, isTownLevel)
|
||||
issue, fields, err := b.GetQueueBead(id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if issue != nil {
|
||||
return issue, fields, nil
|
||||
}
|
||||
}
|
||||
|
||||
// If not found by ID, search all queues by name field
|
||||
queues, err := b.ListQueueBeads()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
for _, issue := range queues {
|
||||
fields := ParseQueueFields(issue.Description)
|
||||
if fields.Name == name {
|
||||
return issue, fields, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil, nil // Not found
|
||||
}
|
||||
|
||||
// MatchClaimPattern checks if an identity matches a claim pattern.
|
||||
// Patterns support:
|
||||
// - "*" matches anyone
|
||||
|
||||
@@ -4,7 +4,6 @@ package beads
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -90,7 +89,8 @@ func (b *Beads) CreateRigBead(id, title string, fields *RigFields) (*Issue, erro
|
||||
}
|
||||
|
||||
// Default actor from BD_ACTOR env var for provenance tracking
|
||||
if actor := os.Getenv("BD_ACTOR"); actor != "" {
|
||||
// Uses getActor() to respect isolated mode (tests)
|
||||
if actor := b.getActor(); actor != "" {
|
||||
args = append(args, "--actor="+actor)
|
||||
}
|
||||
|
||||
|
||||
@@ -1812,18 +1812,19 @@ func TestSetupRedirect(t *testing.T) {
|
||||
// 4. BUG: bd create fails with UNIQUE constraint
|
||||
// 5. BUG: bd reopen fails with "issue not found" (tombstones are invisible)
|
||||
func TestAgentBeadTombstoneBug(t *testing.T) {
|
||||
// Skip: bd CLI 0.47.2 has a bug where database writes don't commit
|
||||
// ("sql: database is closed" during auto-flush). This blocks all tests
|
||||
// that need to create issues. See internal issue for tracking.
|
||||
t.Skip("bd CLI 0.47.2 bug: database writes don't commit")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Initialize beads database
|
||||
cmd := exec.Command("bd", "--no-daemon", "init", "--prefix", "test", "--quiet")
|
||||
cmd.Dir = tmpDir
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("bd init: %v\n%s", err, output)
|
||||
// Create isolated beads instance and initialize database
|
||||
bd := NewIsolated(tmpDir)
|
||||
if err := bd.Init("test"); err != nil {
|
||||
t.Fatalf("bd init: %v", err)
|
||||
}
|
||||
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
bd := New(beadsDir)
|
||||
|
||||
agentID := "test-testrig-polecat-tombstone"
|
||||
|
||||
// Step 1: Create agent bead
|
||||
@@ -1896,18 +1897,14 @@ func TestAgentBeadTombstoneBug(t *testing.T) {
|
||||
// TestAgentBeadCloseReopenWorkaround demonstrates the workaround for the tombstone bug:
|
||||
// use Close instead of Delete, then Reopen works.
|
||||
func TestAgentBeadCloseReopenWorkaround(t *testing.T) {
|
||||
t.Skip("bd CLI 0.47.2 bug: database writes don't commit")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Initialize beads database
|
||||
cmd := exec.Command("bd", "--no-daemon", "init", "--prefix", "test", "--quiet")
|
||||
cmd.Dir = tmpDir
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("bd init: %v\n%s", err, output)
|
||||
bd := NewIsolated(tmpDir)
|
||||
if err := bd.Init("test"); err != nil {
|
||||
t.Fatalf("bd init: %v", err)
|
||||
}
|
||||
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
bd := New(beadsDir)
|
||||
|
||||
agentID := "test-testrig-polecat-closereopen"
|
||||
|
||||
// Step 1: Create agent bead
|
||||
@@ -1957,18 +1954,14 @@ func TestAgentBeadCloseReopenWorkaround(t *testing.T) {
|
||||
// TestCreateOrReopenAgentBead_ClosedBead tests that CreateOrReopenAgentBead
|
||||
// successfully reopens a closed agent bead and updates its fields.
|
||||
func TestCreateOrReopenAgentBead_ClosedBead(t *testing.T) {
|
||||
t.Skip("bd CLI 0.47.2 bug: database writes don't commit")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Initialize beads database
|
||||
cmd := exec.Command("bd", "--no-daemon", "init", "--prefix", "test", "--quiet")
|
||||
cmd.Dir = tmpDir
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("bd init: %v\n%s", err, output)
|
||||
bd := NewIsolated(tmpDir)
|
||||
if err := bd.Init("test"); err != nil {
|
||||
t.Fatalf("bd init: %v", err)
|
||||
}
|
||||
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
bd := New(beadsDir)
|
||||
|
||||
agentID := "test-testrig-polecat-lifecycle"
|
||||
|
||||
// Simulate polecat lifecycle: spawn → nuke → respawn
|
||||
@@ -2045,18 +2038,14 @@ func TestCreateOrReopenAgentBead_ClosedBead(t *testing.T) {
|
||||
// fields to emulate delete --force --hard behavior. This ensures reopened agent
|
||||
// beads don't have stale state from previous lifecycle.
|
||||
func TestCloseAndClearAgentBead_FieldClearing(t *testing.T) {
|
||||
t.Skip("bd CLI 0.47.2 bug: database writes don't commit")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Initialize beads database
|
||||
cmd := exec.Command("bd", "--no-daemon", "init", "--prefix", "test", "--quiet")
|
||||
cmd.Dir = tmpDir
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("bd init: %v\n%s", err, output)
|
||||
bd := NewIsolated(tmpDir)
|
||||
if err := bd.Init("test"); err != nil {
|
||||
t.Fatalf("bd init: %v", err)
|
||||
}
|
||||
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
bd := New(beadsDir)
|
||||
|
||||
// Test cases for field clearing permutations
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -2204,17 +2193,14 @@ func TestCloseAndClearAgentBead_FieldClearing(t *testing.T) {
|
||||
|
||||
// TestCloseAndClearAgentBead_NonExistent tests behavior when closing a non-existent agent bead.
|
||||
func TestCloseAndClearAgentBead_NonExistent(t *testing.T) {
|
||||
t.Skip("bd CLI 0.47.2 bug: database writes don't commit")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cmd := exec.Command("bd", "--no-daemon", "init", "--prefix", "test", "--quiet")
|
||||
cmd.Dir = tmpDir
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("bd init: %v\n%s", err, output)
|
||||
bd := NewIsolated(tmpDir)
|
||||
if err := bd.Init("test"); err != nil {
|
||||
t.Fatalf("bd init: %v", err)
|
||||
}
|
||||
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
bd := New(beadsDir)
|
||||
|
||||
// Attempt to close non-existent bead
|
||||
err := bd.CloseAndClearAgentBead("test-nonexistent-polecat-xyz", "should fail")
|
||||
|
||||
@@ -2226,17 +2212,14 @@ func TestCloseAndClearAgentBead_NonExistent(t *testing.T) {
|
||||
|
||||
// TestCloseAndClearAgentBead_AlreadyClosed tests behavior when closing an already-closed agent bead.
|
||||
func TestCloseAndClearAgentBead_AlreadyClosed(t *testing.T) {
|
||||
t.Skip("bd CLI 0.47.2 bug: database writes don't commit")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cmd := exec.Command("bd", "--no-daemon", "init", "--prefix", "test", "--quiet")
|
||||
cmd.Dir = tmpDir
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("bd init: %v\n%s", err, output)
|
||||
bd := NewIsolated(tmpDir)
|
||||
if err := bd.Init("test"); err != nil {
|
||||
t.Fatalf("bd init: %v", err)
|
||||
}
|
||||
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
bd := New(beadsDir)
|
||||
|
||||
agentID := "test-testrig-polecat-doubleclosed"
|
||||
|
||||
// Create agent bead
|
||||
@@ -2280,17 +2263,14 @@ func TestCloseAndClearAgentBead_AlreadyClosed(t *testing.T) {
|
||||
// TestCloseAndClearAgentBead_ReopenHasCleanState tests that reopening a closed agent bead
|
||||
// starts with clean state (no stale hook_bead, active_mr, etc.).
|
||||
func TestCloseAndClearAgentBead_ReopenHasCleanState(t *testing.T) {
|
||||
t.Skip("bd CLI 0.47.2 bug: database writes don't commit")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cmd := exec.Command("bd", "--no-daemon", "init", "--prefix", "test", "--quiet")
|
||||
cmd.Dir = tmpDir
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("bd init: %v\n%s", err, output)
|
||||
bd := NewIsolated(tmpDir)
|
||||
if err := bd.Init("test"); err != nil {
|
||||
t.Fatalf("bd init: %v", err)
|
||||
}
|
||||
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
bd := New(beadsDir)
|
||||
|
||||
agentID := "test-testrig-polecat-cleanreopen"
|
||||
|
||||
// Step 1: Create agent with all fields populated
|
||||
@@ -2348,17 +2328,14 @@ func TestCloseAndClearAgentBead_ReopenHasCleanState(t *testing.T) {
|
||||
|
||||
// TestCloseAndClearAgentBead_ReasonVariations tests close with different reason values.
|
||||
func TestCloseAndClearAgentBead_ReasonVariations(t *testing.T) {
|
||||
t.Skip("bd CLI 0.47.2 bug: database writes don't commit")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
cmd := exec.Command("bd", "--no-daemon", "init", "--prefix", "test", "--quiet")
|
||||
cmd.Dir = tmpDir
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("bd init: %v\n%s", err, output)
|
||||
bd := NewIsolated(tmpDir)
|
||||
if err := bd.Init("test"); err != nil {
|
||||
t.Fatalf("bd init: %v", err)
|
||||
}
|
||||
|
||||
beadsDir := filepath.Join(tmpDir, ".beads")
|
||||
bd := New(beadsDir)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
reason string
|
||||
|
||||
@@ -2,6 +2,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -55,6 +56,9 @@ func runBroadcast(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("listing sessions: %w", err)
|
||||
}
|
||||
|
||||
// Get sender identity to exclude self
|
||||
sender := os.Getenv("BD_ACTOR")
|
||||
|
||||
// Filter to target agents
|
||||
var targets []*AgentSession
|
||||
for _, agent := range agents {
|
||||
@@ -70,6 +74,11 @@ func runBroadcast(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Skip self to avoid interrupting own session
|
||||
if sender != "" && formatAgentName(agent) == sender {
|
||||
continue
|
||||
}
|
||||
|
||||
targets = append(targets, agent)
|
||||
}
|
||||
|
||||
|
||||
+23
-4
@@ -44,13 +44,22 @@ var (
|
||||
var costsCmd = &cobra.Command{
|
||||
Use: "costs",
|
||||
GroupID: GroupDiag,
|
||||
Short: "Show costs for running Claude sessions",
|
||||
Short: "Show costs for running Claude sessions [DISABLED]",
|
||||
Long: `Display costs for Claude Code sessions in Gas Town.
|
||||
|
||||
By default, shows live costs scraped from running tmux sessions.
|
||||
⚠️ COST TRACKING IS CURRENTLY DISABLED
|
||||
|
||||
Cost tracking uses ephemeral wisps for individual sessions that are
|
||||
aggregated into daily "Cost Report" digest beads for audit purposes.
|
||||
Claude Code displays costs in the TUI status bar, which cannot be captured
|
||||
via tmux. All sessions will show $0.00 until Claude Code exposes cost data
|
||||
through an API or environment variable.
|
||||
|
||||
What we need from Claude Code:
|
||||
- Stop hook env var (e.g., $CLAUDE_SESSION_COST)
|
||||
- Or queryable file/API endpoint
|
||||
|
||||
See: GH#24, gt-7awfj
|
||||
|
||||
The infrastructure remains in place and will work once cost data is available.
|
||||
|
||||
Examples:
|
||||
gt costs # Live costs from running sessions
|
||||
@@ -194,6 +203,11 @@ func runCosts(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
func runLiveCosts() error {
|
||||
// Warn that cost tracking is disabled
|
||||
fmt.Fprintf(os.Stderr, "%s Cost tracking is disabled - Claude Code does not expose session costs.\n",
|
||||
style.Warning.Render("⚠"))
|
||||
fmt.Fprintf(os.Stderr, " All sessions will show $0.00. See: GH#24, gt-7awfj\n\n")
|
||||
|
||||
t := tmux.NewTmux()
|
||||
|
||||
// Get all tmux sessions
|
||||
@@ -253,6 +267,11 @@ func runLiveCosts() error {
|
||||
}
|
||||
|
||||
func runCostsFromLedger() error {
|
||||
// Warn that cost tracking is disabled
|
||||
fmt.Fprintf(os.Stderr, "%s Cost tracking is disabled - Claude Code does not expose session costs.\n",
|
||||
style.Warning.Render("⚠"))
|
||||
fmt.Fprintf(os.Stderr, " Historical data may show $0.00 for all sessions. See: GH#24, gt-7awfj\n\n")
|
||||
|
||||
now := time.Now()
|
||||
var entries []CostEntry
|
||||
var err error
|
||||
|
||||
+23
-17
@@ -27,27 +27,33 @@ var (
|
||||
var crewCmd = &cobra.Command{
|
||||
Use: "crew",
|
||||
GroupID: GroupWorkspace,
|
||||
Short: "Manage crew workspaces (user-managed persistent workspaces)",
|
||||
Short: "Manage crew workers (persistent workspaces for humans)",
|
||||
RunE: requireSubcommand,
|
||||
Long: `Crew workers are user-managed persistent workspaces within a rig.
|
||||
Long: `Manage crew workers - persistent workspaces for human developers.
|
||||
|
||||
Unlike polecats which are witness-managed and transient, crew workers are:
|
||||
- Persistent: Not auto-garbage-collected
|
||||
- User-managed: Overseer controls lifecycle
|
||||
- Long-lived identities: recognizable names like dave, emma, fred
|
||||
- Gas Town integrated: Mail, handoff mechanics work
|
||||
- Tmux optional: Can work in terminal directly
|
||||
CREW VS POLECATS:
|
||||
Polecats: Ephemeral. Witness-managed. Auto-nuked after work.
|
||||
Crew: Persistent. User-managed. Stays until you remove it.
|
||||
|
||||
Crew workers are full git clones (not worktrees) for human developers
|
||||
who want persistent context and control over their workspace lifecycle.
|
||||
Use crew workers for exploratory work, long-running tasks, or when you
|
||||
want to keep uncommitted changes around.
|
||||
|
||||
Features:
|
||||
- Gas Town integrated: Mail, nudge, handoff all work
|
||||
- Recognizable names: dave, emma, fred (not ephemeral pool names)
|
||||
- Tmux optional: Can work in terminal directly without tmux session
|
||||
|
||||
Commands:
|
||||
gt crew start <name> Start a crew workspace (creates if needed)
|
||||
gt crew stop <name> Stop crew workspace session(s)
|
||||
gt crew add <name> Create a new crew workspace
|
||||
gt crew list List crew workspaces with status
|
||||
gt crew at <name> Attach to crew workspace session
|
||||
gt crew remove <name> Remove a crew workspace
|
||||
gt crew refresh <name> Context cycling with mail-to-self handoff
|
||||
gt crew restart <name> Kill and restart session fresh (alias: rs)
|
||||
gt crew status [<name>] Show detailed workspace status`,
|
||||
gt crew start <name> Start session (creates workspace if needed)
|
||||
gt crew stop <name> Stop session(s)
|
||||
gt crew add <name> Create workspace without starting
|
||||
gt crew list List workspaces with status
|
||||
gt crew at <name> Attach to session
|
||||
gt crew remove <name> Remove workspace
|
||||
gt crew refresh <name> Context cycle with handoff mail
|
||||
gt crew restart <name> Kill and restart session fresh`,
|
||||
}
|
||||
|
||||
var crewAddCmd = &cobra.Command{
|
||||
|
||||
@@ -60,11 +60,11 @@ func runCrewRemove(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Kill session if it exists
|
||||
// Kill session if it exists (with proper process cleanup to avoid orphans)
|
||||
t := tmux.NewTmux()
|
||||
sessionID := crewSessionName(r.Name, name)
|
||||
if hasSession, _ := t.HasSession(sessionID); hasSession {
|
||||
if err := t.KillSession(sessionID); err != nil {
|
||||
if err := t.KillSessionWithProcesses(sessionID); err != nil {
|
||||
fmt.Printf("Error killing session for %s: %v\n", arg, err)
|
||||
lastErr = err
|
||||
continue
|
||||
@@ -591,8 +591,8 @@ func runCrewStop(cmd *cobra.Command, args []string) error {
|
||||
output, _ = t.CapturePane(sessionID, 50)
|
||||
}
|
||||
|
||||
// Kill the session
|
||||
if err := t.KillSession(sessionID); err != nil {
|
||||
// Kill the session (with proper process cleanup to avoid orphans)
|
||||
if err := t.KillSessionWithProcesses(sessionID); err != nil {
|
||||
fmt.Printf(" %s [%s] %s: %s\n",
|
||||
style.ErrorPrefix,
|
||||
r.Name, name,
|
||||
@@ -681,8 +681,8 @@ func runCrewStopAll() error {
|
||||
output, _ = t.CapturePane(sessionID, 50)
|
||||
}
|
||||
|
||||
// Kill the session
|
||||
if err := t.KillSession(sessionID); err != nil {
|
||||
// Kill the session (with proper process cleanup to avoid orphans)
|
||||
if err := t.KillSessionWithProcesses(sessionID); err != nil {
|
||||
failed++
|
||||
failures = append(failures, fmt.Sprintf("%s: %v", agentName, err))
|
||||
fmt.Printf(" %s %s\n", style.ErrorPrefix, agentName)
|
||||
|
||||
+12
-6
@@ -35,14 +35,20 @@ var deaconCmd = &cobra.Command{
|
||||
Use: "deacon",
|
||||
Aliases: []string{"dea"},
|
||||
GroupID: GroupAgents,
|
||||
Short: "Manage the Deacon session",
|
||||
Short: "Manage the Deacon (town-level watchdog)",
|
||||
RunE: requireSubcommand,
|
||||
Long: `Manage the Deacon tmux session.
|
||||
Long: `Manage the Deacon - the town-level watchdog for Gas Town.
|
||||
|
||||
The Deacon is the hierarchical health-check orchestrator for Gas Town.
|
||||
It monitors the Mayor and Witnesses, handles lifecycle requests, and
|
||||
keeps the town running. Use the subcommands to start, stop, attach,
|
||||
and check status.`,
|
||||
The Deacon ("daemon beacon") is the only agent that receives mechanical
|
||||
heartbeats from the daemon. It monitors system health across all rigs:
|
||||
- Watches all Witnesses (are they alive? stuck? responsive?)
|
||||
- Manages Dogs for cross-rig infrastructure work
|
||||
- Handles lifecycle requests (respawns, restarts)
|
||||
- Receives heartbeat pokes and decides what needs attention
|
||||
|
||||
The Deacon patrols the town; Witnesses patrol their rigs; Polecats work.
|
||||
|
||||
Role shortcuts: "deacon" in mail/nudge addresses resolves to this agent.`,
|
||||
}
|
||||
|
||||
var deaconStartCmd = &cobra.Command{
|
||||
|
||||
+14
-6
@@ -40,14 +40,22 @@ var dogCmd = &cobra.Command{
|
||||
Use: "dog",
|
||||
Aliases: []string{"dogs"},
|
||||
GroupID: GroupAgents,
|
||||
Short: "Manage dogs (Deacon's helper workers)",
|
||||
Long: `Manage dogs in the kennel.
|
||||
Short: "Manage dogs (cross-rig infrastructure workers)",
|
||||
Long: `Manage dogs - reusable workers for infrastructure and cleanup.
|
||||
|
||||
Dogs are reusable helper workers managed by the Deacon for infrastructure
|
||||
and cleanup tasks. Unlike polecats (single-rig, ephemeral), dogs handle
|
||||
cross-rig infrastructure work with worktrees into each rig.
|
||||
CATS VS DOGS:
|
||||
Polecats (cats) build features. One rig. Ephemeral (one task, then nuked).
|
||||
Dogs clean up messes. Cross-rig. Reusable (multiple tasks, eventually recycled).
|
||||
|
||||
The kennel is located at ~/gt/deacon/dogs/.`,
|
||||
Dogs are managed by the Deacon for town-level work:
|
||||
- Infrastructure tasks (rebuilding, syncing, migrations)
|
||||
- Cleanup operations (orphan branches, stale files)
|
||||
- Cross-rig work that spans multiple projects
|
||||
|
||||
Each dog has worktrees into every configured rig, enabling cross-project
|
||||
operations. Dogs return to idle state after completing work (unlike cats).
|
||||
|
||||
The kennel is at ~/gt/deacon/dogs/. The Deacon dispatches work to dogs.`,
|
||||
}
|
||||
|
||||
var dogAddCmd = &cobra.Command{
|
||||
|
||||
@@ -3,7 +3,6 @@ package cmd
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
@@ -15,6 +14,7 @@ import (
|
||||
"github.com/steveyegge/gastown/internal/polecat"
|
||||
"github.com/steveyegge/gastown/internal/rig"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
"github.com/steveyegge/gastown/internal/townlog"
|
||||
"github.com/steveyegge/gastown/internal/workspace"
|
||||
)
|
||||
@@ -743,11 +743,11 @@ func selfKillSession(townRoot string, roleInfo RoleInfo) error {
|
||||
_ = events.LogFeed(events.TypeSessionDeath, agentID,
|
||||
events.SessionDeathPayload(sessionName, agentID, "self-clean: done means gone", "gt done"))
|
||||
|
||||
// Kill our own tmux session
|
||||
// This will terminate Claude and the shell, completing the self-cleaning cycle.
|
||||
// We use exec.Command instead of the tmux package to avoid import cycles.
|
||||
cmd := exec.Command("tmux", "kill-session", "-t", sessionName) //nolint:gosec // G204: sessionName is derived from env vars, not user input
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Kill our own tmux session with proper process cleanup
|
||||
// This will terminate Claude and all child processes, completing the self-cleaning cycle.
|
||||
// We use KillSessionWithProcesses to ensure no orphaned processes are left behind.
|
||||
t := tmux.NewTmux()
|
||||
if err := t.KillSessionWithProcesses(sessionName); err != nil {
|
||||
return fmt.Errorf("killing session %s: %w", sessionName, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -253,6 +253,11 @@ func TestDoneCircularRedirectProtection(t *testing.T) {
|
||||
// This is critical because branch names like "polecat/furiosa-mkb0vq9f" don't
|
||||
// contain the actual issue ID (test-845.1), but the agent's hook does.
|
||||
func TestGetIssueFromAgentHook(t *testing.T) {
|
||||
// Skip: bd CLI 0.47.2 has a bug where database writes don't commit
|
||||
// ("sql: database is closed" during auto-flush). This blocks tests
|
||||
// that need to create issues. See internal issue for tracking.
|
||||
t.Skip("bd CLI 0.47.2 bug: database writes don't commit")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
agentBeadID string
|
||||
|
||||
@@ -74,6 +74,21 @@ type VersionChange struct {
|
||||
|
||||
// versionChanges contains agent-actionable changes for recent versions
|
||||
var versionChanges = []VersionChange{
|
||||
{
|
||||
Version: "0.4.0",
|
||||
Date: "2026-01-17",
|
||||
Changes: []string{
|
||||
"FIX: Orphan cleanup skips valid tmux sessions - Prevents false kills of witnesses/refineries/deacon during startup by checking gt-*/hq-* session membership",
|
||||
},
|
||||
},
|
||||
{
|
||||
Version: "0.3.1",
|
||||
Date: "2026-01-17",
|
||||
Changes: []string{
|
||||
"FIX: Orphan cleanup on macOS - TTY comparison now handles macOS '??' format",
|
||||
"FIX: Session kill orphan prevention - gt done and gt crew stop use KillSessionWithProcesses",
|
||||
},
|
||||
},
|
||||
{
|
||||
Version: "0.3.0",
|
||||
Date: "2026-01-17",
|
||||
|
||||
@@ -80,6 +80,32 @@ var channelDeleteCmd = &cobra.Command{
|
||||
RunE: runChannelDelete,
|
||||
}
|
||||
|
||||
var channelSubscribeCmd = &cobra.Command{
|
||||
Use: "subscribe <name>",
|
||||
Short: "Subscribe to a channel",
|
||||
Long: `Subscribe the current identity (BD_ACTOR) to a channel.
|
||||
|
||||
Subscribers receive messages broadcast to the channel.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runChannelSubscribe,
|
||||
}
|
||||
|
||||
var channelUnsubscribeCmd = &cobra.Command{
|
||||
Use: "unsubscribe <name>",
|
||||
Short: "Unsubscribe from a channel",
|
||||
Long: `Unsubscribe the current identity (BD_ACTOR) from a channel.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runChannelUnsubscribe,
|
||||
}
|
||||
|
||||
var channelSubscribersCmd = &cobra.Command{
|
||||
Use: "subscribers <name>",
|
||||
Short: "List channel subscribers",
|
||||
Long: `List all subscribers to a channel.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runChannelSubscribers,
|
||||
}
|
||||
|
||||
func init() {
|
||||
// List flags
|
||||
channelListCmd.Flags().BoolVar(&channelJSON, "json", false, "Output as JSON")
|
||||
@@ -91,6 +117,9 @@ func init() {
|
||||
channelCreateCmd.Flags().IntVar(&channelRetainCount, "retain-count", 0, "Number of messages to retain (0 = unlimited)")
|
||||
channelCreateCmd.Flags().IntVar(&channelRetainHours, "retain-hours", 0, "Hours to retain messages (0 = forever)")
|
||||
|
||||
// Subscribers flags
|
||||
channelSubscribersCmd.Flags().BoolVar(&channelJSON, "json", false, "Output as JSON")
|
||||
|
||||
// Main channel command flags
|
||||
mailChannelCmd.Flags().BoolVar(&channelJSON, "json", false, "Output as JSON")
|
||||
|
||||
@@ -99,6 +128,9 @@ func init() {
|
||||
mailChannelCmd.AddCommand(channelShowCmd)
|
||||
mailChannelCmd.AddCommand(channelCreateCmd)
|
||||
mailChannelCmd.AddCommand(channelDeleteCmd)
|
||||
mailChannelCmd.AddCommand(channelSubscribeCmd)
|
||||
mailChannelCmd.AddCommand(channelUnsubscribeCmd)
|
||||
mailChannelCmd.AddCommand(channelSubscribersCmd)
|
||||
|
||||
mailCmd.AddCommand(mailChannelCmd)
|
||||
}
|
||||
@@ -305,6 +337,131 @@ func runChannelDelete(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func runChannelSubscribe(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
|
||||
subscriber := os.Getenv("BD_ACTOR")
|
||||
if subscriber == "" {
|
||||
return fmt.Errorf("BD_ACTOR not set - cannot determine subscriber identity")
|
||||
}
|
||||
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
b := beads.New(townRoot)
|
||||
|
||||
// Check channel exists and current subscription status
|
||||
_, fields, err := b.GetChannelBead(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting channel: %w", err)
|
||||
}
|
||||
if fields == nil {
|
||||
return fmt.Errorf("channel not found: %s", name)
|
||||
}
|
||||
|
||||
// Check if already subscribed
|
||||
for _, s := range fields.Subscribers {
|
||||
if s == subscriber {
|
||||
fmt.Printf("%s is already subscribed to channel %q\n", subscriber, name)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := b.SubscribeToChannel(name, subscriber); err != nil {
|
||||
return fmt.Errorf("subscribing to channel: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Subscribed %s to channel %q\n", subscriber, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runChannelUnsubscribe(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
|
||||
subscriber := os.Getenv("BD_ACTOR")
|
||||
if subscriber == "" {
|
||||
return fmt.Errorf("BD_ACTOR not set - cannot determine subscriber identity")
|
||||
}
|
||||
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
b := beads.New(townRoot)
|
||||
|
||||
// Check channel exists and current subscription status
|
||||
_, fields, err := b.GetChannelBead(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting channel: %w", err)
|
||||
}
|
||||
if fields == nil {
|
||||
return fmt.Errorf("channel not found: %s", name)
|
||||
}
|
||||
|
||||
// Check if actually subscribed
|
||||
found := false
|
||||
for _, s := range fields.Subscribers {
|
||||
if s == subscriber {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
fmt.Printf("%s is not subscribed to channel %q\n", subscriber, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := b.UnsubscribeFromChannel(name, subscriber); err != nil {
|
||||
return fmt.Errorf("unsubscribing from channel: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Unsubscribed %s from channel %q\n", subscriber, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runChannelSubscribers(cmd *cobra.Command, args []string) error {
|
||||
name := args[0]
|
||||
|
||||
townRoot, err := workspace.FindFromCwdOrError()
|
||||
if err != nil {
|
||||
return fmt.Errorf("not in a Gas Town workspace: %w", err)
|
||||
}
|
||||
|
||||
b := beads.New(townRoot)
|
||||
|
||||
_, fields, err := b.GetChannelBead(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting channel: %w", err)
|
||||
}
|
||||
if fields == nil {
|
||||
return fmt.Errorf("channel not found: %s", name)
|
||||
}
|
||||
|
||||
if channelJSON {
|
||||
subs := fields.Subscribers
|
||||
if subs == nil {
|
||||
subs = []string{}
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(subs)
|
||||
}
|
||||
|
||||
if len(fields.Subscribers) == 0 {
|
||||
fmt.Printf("Channel %q has no subscribers\n", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Subscribers to channel %q:\n", name)
|
||||
for _, sub := range fields.Subscribers {
|
||||
fmt.Printf(" %s\n", sub)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// channelMessage represents a message in a channel.
|
||||
type channelMessage struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
+12
-4
@@ -16,12 +16,20 @@ var mayorCmd = &cobra.Command{
|
||||
Use: "mayor",
|
||||
Aliases: []string{"may"},
|
||||
GroupID: GroupAgents,
|
||||
Short: "Manage the Mayor session",
|
||||
Short: "Manage the Mayor (Chief of Staff for cross-rig coordination)",
|
||||
RunE: requireSubcommand,
|
||||
Long: `Manage the Mayor tmux session.
|
||||
Long: `Manage the Mayor - the Overseer's Chief of Staff.
|
||||
|
||||
The Mayor is the global coordinator for Gas Town, running as a persistent
|
||||
tmux session. Use the subcommands to start, stop, attach, and check status.`,
|
||||
The Mayor is the global coordinator for Gas Town:
|
||||
- Receives escalations from Witnesses and Deacon
|
||||
- Coordinates work across multiple rigs
|
||||
- Handles human communication when needed
|
||||
- Routes strategic decisions and cross-project issues
|
||||
|
||||
The Mayor is the primary interface between the human Overseer and the
|
||||
automated agents. When in doubt, escalate to the Mayor.
|
||||
|
||||
Role shortcuts: "mayor" in mail/nudge addresses resolves to this agent.`,
|
||||
}
|
||||
|
||||
var mayorAgentOverride string
|
||||
|
||||
@@ -215,13 +215,15 @@ squashed_at: %s
|
||||
}())
|
||||
}
|
||||
|
||||
// Create the digest bead
|
||||
// Create the digest bead (ephemeral to avoid JSONL pollution)
|
||||
// Per-cycle digests are aggregated daily by 'gt patrol digest'
|
||||
digestIssue, err := b.Create(beads.CreateOptions{
|
||||
Title: digestTitle,
|
||||
Description: digestDesc,
|
||||
Type: "task",
|
||||
Priority: 4, // P4 - backlog priority for digests
|
||||
Priority: 4, // P4 - backlog priority for digests
|
||||
Actor: target,
|
||||
Ephemeral: true, // Don't export to JSONL - daily aggregation handles permanent record
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating digest: %w", err)
|
||||
|
||||
@@ -27,8 +27,12 @@ func init() {
|
||||
var nudgeCmd = &cobra.Command{
|
||||
Use: "nudge <target> [message]",
|
||||
GroupID: GroupComm,
|
||||
Short: "Send a message to a polecat or deacon session reliably",
|
||||
Long: `Sends a message to a polecat's or deacon's Claude Code session.
|
||||
Short: "Send a synchronous message to any Gas Town worker",
|
||||
Long: `Universal synchronous messaging API for Gas Town worker-to-worker communication.
|
||||
|
||||
Delivers a message directly to any worker's Claude Code session: polecats, crew,
|
||||
witness, refinery, mayor, or deacon. Use this for real-time coordination when
|
||||
you need immediate attention from another worker.
|
||||
|
||||
Uses a reliable delivery pattern:
|
||||
1. Sends text in literal mode (-l flag)
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
// Package cmd provides CLI commands for the gt tool.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/style"
|
||||
)
|
||||
|
||||
var (
|
||||
// Patrol digest flags
|
||||
patrolDigestYesterday bool
|
||||
patrolDigestDate string
|
||||
patrolDigestDryRun bool
|
||||
patrolDigestVerbose bool
|
||||
)
|
||||
|
||||
var patrolCmd = &cobra.Command{
|
||||
Use: "patrol",
|
||||
GroupID: GroupDiag,
|
||||
Short: "Patrol digest management",
|
||||
Long: `Manage patrol cycle digests.
|
||||
|
||||
Patrol cycles (Deacon, Witness, Refinery) create ephemeral per-cycle digests
|
||||
to avoid JSONL pollution. This command aggregates them into daily summaries.
|
||||
|
||||
Examples:
|
||||
gt patrol digest --yesterday # Aggregate yesterday's patrol digests
|
||||
gt patrol digest --dry-run # Preview what would be aggregated`,
|
||||
}
|
||||
|
||||
var patrolDigestCmd = &cobra.Command{
|
||||
Use: "digest",
|
||||
Short: "Aggregate patrol cycle digests into a daily summary bead",
|
||||
Long: `Aggregate ephemeral patrol cycle digests into a permanent daily summary.
|
||||
|
||||
This command is intended to be run by Deacon patrol (daily) or manually.
|
||||
It queries patrol digests for a target date, creates a single aggregate
|
||||
"Patrol Report YYYY-MM-DD" bead, then deletes the source digests.
|
||||
|
||||
The resulting digest bead is permanent (exported to JSONL, synced via git)
|
||||
and provides an audit trail without per-cycle pollution.
|
||||
|
||||
Examples:
|
||||
gt patrol digest --yesterday # Digest yesterday's patrols (for daily patrol)
|
||||
gt patrol digest --date 2026-01-15
|
||||
gt patrol digest --yesterday --dry-run`,
|
||||
RunE: runPatrolDigest,
|
||||
}
|
||||
|
||||
func init() {
|
||||
patrolCmd.AddCommand(patrolDigestCmd)
|
||||
rootCmd.AddCommand(patrolCmd)
|
||||
|
||||
// Patrol digest flags
|
||||
patrolDigestCmd.Flags().BoolVar(&patrolDigestYesterday, "yesterday", false, "Digest yesterday's patrol cycles")
|
||||
patrolDigestCmd.Flags().StringVar(&patrolDigestDate, "date", "", "Digest patrol cycles for specific date (YYYY-MM-DD)")
|
||||
patrolDigestCmd.Flags().BoolVar(&patrolDigestDryRun, "dry-run", false, "Preview what would be created without creating")
|
||||
patrolDigestCmd.Flags().BoolVarP(&patrolDigestVerbose, "verbose", "v", false, "Verbose output")
|
||||
}
|
||||
|
||||
// PatrolDigest represents the aggregated daily patrol report.
|
||||
type PatrolDigest struct {
|
||||
Date string `json:"date"`
|
||||
TotalCycles int `json:"total_cycles"`
|
||||
ByRole map[string]int `json:"by_role"` // deacon, witness, refinery
|
||||
Cycles []PatrolCycleEntry `json:"cycles"`
|
||||
}
|
||||
|
||||
// PatrolCycleEntry represents a single patrol cycle in the digest.
|
||||
type PatrolCycleEntry struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"` // deacon, witness, refinery
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ClosedAt time.Time `json:"closed_at,omitempty"`
|
||||
}
|
||||
|
||||
// runPatrolDigest aggregates patrol cycle digests into a daily digest bead.
|
||||
func runPatrolDigest(cmd *cobra.Command, args []string) error {
|
||||
// Determine target date
|
||||
var targetDate time.Time
|
||||
|
||||
if patrolDigestDate != "" {
|
||||
parsed, err := time.Parse("2006-01-02", patrolDigestDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid date format (use YYYY-MM-DD): %w", err)
|
||||
}
|
||||
targetDate = parsed
|
||||
} else if patrolDigestYesterday {
|
||||
targetDate = time.Now().AddDate(0, 0, -1)
|
||||
} else {
|
||||
return fmt.Errorf("specify --yesterday or --date YYYY-MM-DD")
|
||||
}
|
||||
|
||||
dateStr := targetDate.Format("2006-01-02")
|
||||
|
||||
// Idempotency check: see if digest already exists for this date
|
||||
existingID, err := findExistingPatrolDigest(dateStr)
|
||||
if err != nil {
|
||||
// Non-fatal: continue with creation attempt
|
||||
if patrolDigestVerbose {
|
||||
fmt.Fprintf(os.Stderr, "[patrol] warning: failed to check existing digest: %v\n", err)
|
||||
}
|
||||
} else if existingID != "" {
|
||||
fmt.Printf("%s Patrol digest already exists for %s (bead: %s)\n",
|
||||
style.Dim.Render("○"), dateStr, existingID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Query ephemeral patrol digest beads for target date
|
||||
cycles, err := queryPatrolDigests(targetDate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("querying patrol digests: %w", err)
|
||||
}
|
||||
|
||||
if len(cycles) == 0 {
|
||||
fmt.Printf("%s No patrol digests found for %s\n", style.Dim.Render("○"), dateStr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build digest
|
||||
digest := PatrolDigest{
|
||||
Date: dateStr,
|
||||
Cycles: cycles,
|
||||
ByRole: make(map[string]int),
|
||||
}
|
||||
|
||||
for _, c := range cycles {
|
||||
digest.TotalCycles++
|
||||
digest.ByRole[c.Role]++
|
||||
}
|
||||
|
||||
if patrolDigestDryRun {
|
||||
fmt.Printf("%s [DRY RUN] Would create Patrol Report %s:\n", style.Bold.Render("📊"), dateStr)
|
||||
fmt.Printf(" Total cycles: %d\n", digest.TotalCycles)
|
||||
fmt.Printf(" By Role:\n")
|
||||
roles := make([]string, 0, len(digest.ByRole))
|
||||
for role := range digest.ByRole {
|
||||
roles = append(roles, role)
|
||||
}
|
||||
sort.Strings(roles)
|
||||
for _, role := range roles {
|
||||
fmt.Printf(" %s: %d cycles\n", role, digest.ByRole[role])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create permanent digest bead
|
||||
digestID, err := createPatrolDigestBead(digest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating digest bead: %w", err)
|
||||
}
|
||||
|
||||
// Delete source digests (they're ephemeral)
|
||||
deletedCount, deleteErr := deletePatrolDigests(targetDate)
|
||||
if deleteErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: failed to delete some source digests: %v\n", deleteErr)
|
||||
}
|
||||
|
||||
fmt.Printf("%s Created Patrol Report %s (bead: %s)\n", style.Success.Render("✓"), dateStr, digestID)
|
||||
fmt.Printf(" Total: %d cycles\n", digest.TotalCycles)
|
||||
for role, count := range digest.ByRole {
|
||||
fmt.Printf(" %s: %d\n", role, count)
|
||||
}
|
||||
if deletedCount > 0 {
|
||||
fmt.Printf(" Deleted %d source digests\n", deletedCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryPatrolDigests queries ephemeral patrol digest beads for a target date.
|
||||
func queryPatrolDigests(targetDate time.Time) ([]PatrolCycleEntry, error) {
|
||||
// List closed issues with "digest" label that are ephemeral
|
||||
// Patrol digests have titles like "Digest: mol-deacon-patrol", "Digest: mol-witness-patrol"
|
||||
listCmd := exec.Command("bd", "list",
|
||||
"--status=closed",
|
||||
"--label=digest",
|
||||
"--json",
|
||||
"--limit=0", // Get all
|
||||
)
|
||||
listOutput, err := listCmd.Output()
|
||||
if err != nil {
|
||||
if patrolDigestVerbose {
|
||||
fmt.Fprintf(os.Stderr, "[patrol] bd list failed: %v\n", err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var issues []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ClosedAt time.Time `json:"closed_at"`
|
||||
Ephemeral bool `json:"ephemeral"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(listOutput, &issues); err != nil {
|
||||
return nil, fmt.Errorf("parsing issue list: %w", err)
|
||||
}
|
||||
|
||||
targetDay := targetDate.Format("2006-01-02")
|
||||
var patrolDigests []PatrolCycleEntry
|
||||
|
||||
for _, issue := range issues {
|
||||
// Only process ephemeral patrol digests
|
||||
if !issue.Ephemeral {
|
||||
continue
|
||||
}
|
||||
|
||||
// Must be a patrol digest (title starts with "Digest: mol-")
|
||||
if !strings.HasPrefix(issue.Title, "Digest: mol-") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if created on target date
|
||||
if issue.CreatedAt.Format("2006-01-02") != targetDay {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract role from title (e.g., "Digest: mol-deacon-patrol" -> "deacon")
|
||||
role := extractPatrolRole(issue.Title)
|
||||
|
||||
patrolDigests = append(patrolDigests, PatrolCycleEntry{
|
||||
ID: issue.ID,
|
||||
Role: role,
|
||||
Title: issue.Title,
|
||||
Description: issue.Description,
|
||||
CreatedAt: issue.CreatedAt,
|
||||
ClosedAt: issue.ClosedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return patrolDigests, nil
|
||||
}
|
||||
|
||||
// extractPatrolRole extracts the role from a patrol digest title.
|
||||
// "Digest: mol-deacon-patrol" -> "deacon"
|
||||
// "Digest: mol-witness-patrol" -> "witness"
|
||||
// "Digest: gt-wisp-abc123" -> "unknown"
|
||||
func extractPatrolRole(title string) string {
|
||||
// Remove "Digest: " prefix
|
||||
title = strings.TrimPrefix(title, "Digest: ")
|
||||
|
||||
// Extract role from "mol-<role>-patrol" or "gt-wisp-<id>"
|
||||
if strings.HasPrefix(title, "mol-") && strings.HasSuffix(title, "-patrol") {
|
||||
// "mol-deacon-patrol" -> "deacon"
|
||||
role := strings.TrimPrefix(title, "mol-")
|
||||
role = strings.TrimSuffix(role, "-patrol")
|
||||
return role
|
||||
}
|
||||
|
||||
// For wisp digests, try to extract from description or return generic
|
||||
return "patrol"
|
||||
}
|
||||
|
||||
// createPatrolDigestBead creates a permanent bead for the daily patrol digest.
|
||||
func createPatrolDigestBead(digest PatrolDigest) (string, error) {
|
||||
// Build description with aggregate data
|
||||
var desc strings.Builder
|
||||
desc.WriteString(fmt.Sprintf("Daily patrol aggregate for %s.\n\n", digest.Date))
|
||||
desc.WriteString(fmt.Sprintf("**Total Cycles:** %d\n\n", digest.TotalCycles))
|
||||
|
||||
if len(digest.ByRole) > 0 {
|
||||
desc.WriteString("## By Role\n")
|
||||
roles := make([]string, 0, len(digest.ByRole))
|
||||
for role := range digest.ByRole {
|
||||
roles = append(roles, role)
|
||||
}
|
||||
sort.Strings(roles)
|
||||
for _, role := range roles {
|
||||
desc.WriteString(fmt.Sprintf("- %s: %d cycles\n", role, digest.ByRole[role]))
|
||||
}
|
||||
desc.WriteString("\n")
|
||||
}
|
||||
|
||||
// Build payload JSON with cycle details
|
||||
payloadJSON, err := json.Marshal(digest)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshaling digest payload: %w", err)
|
||||
}
|
||||
|
||||
// Create the digest bead (NOT ephemeral - this is permanent)
|
||||
title := fmt.Sprintf("Patrol Report %s", digest.Date)
|
||||
bdArgs := []string{
|
||||
"create",
|
||||
"--type=event",
|
||||
"--title=" + title,
|
||||
"--event-category=patrol.digest",
|
||||
"--event-payload=" + string(payloadJSON),
|
||||
"--description=" + desc.String(),
|
||||
"--silent",
|
||||
}
|
||||
|
||||
bdCmd := exec.Command("bd", bdArgs...)
|
||||
output, err := bdCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating digest bead: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
digestID := strings.TrimSpace(string(output))
|
||||
|
||||
// Auto-close the digest (it's an audit record, not work)
|
||||
closeCmd := exec.Command("bd", "close", digestID, "--reason=daily patrol digest")
|
||||
_ = closeCmd.Run() // Best effort
|
||||
|
||||
return digestID, nil
|
||||
}
|
||||
|
||||
// findExistingPatrolDigest checks if a patrol digest already exists for the given date.
|
||||
// Returns the bead ID if found, empty string if not found.
|
||||
func findExistingPatrolDigest(dateStr string) (string, error) {
|
||||
expectedTitle := fmt.Sprintf("Patrol Report %s", dateStr)
|
||||
|
||||
// Query event beads with patrol.digest category
|
||||
listCmd := exec.Command("bd", "list",
|
||||
"--type=event",
|
||||
"--json",
|
||||
"--limit=50", // Recent events only
|
||||
)
|
||||
listOutput, err := listCmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var events []struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(listOutput, &events); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, evt := range events {
|
||||
if evt.Title == expectedTitle {
|
||||
return evt.ID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// deletePatrolDigests deletes ephemeral patrol digest beads for a target date.
|
||||
func deletePatrolDigests(targetDate time.Time) (int, error) {
|
||||
// Query patrol digests for the target date
|
||||
cycles, err := queryPatrolDigests(targetDate)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if len(cycles) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Collect IDs to delete
|
||||
var idsToDelete []string
|
||||
for _, cycle := range cycles {
|
||||
idsToDelete = append(idsToDelete, cycle.ID)
|
||||
}
|
||||
|
||||
// Delete in batch
|
||||
deleteArgs := append([]string{"delete", "--force"}, idsToDelete...)
|
||||
deleteCmd := exec.Command("bd", deleteArgs...)
|
||||
if err := deleteCmd.Run(); err != nil {
|
||||
return 0, fmt.Errorf("deleting patrol digests: %w", err)
|
||||
}
|
||||
|
||||
return len(idsToDelete), nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractPatrolRole(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
title string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "deacon patrol",
|
||||
title: "Digest: mol-deacon-patrol",
|
||||
expected: "deacon",
|
||||
},
|
||||
{
|
||||
name: "witness patrol",
|
||||
title: "Digest: mol-witness-patrol",
|
||||
expected: "witness",
|
||||
},
|
||||
{
|
||||
name: "refinery patrol",
|
||||
title: "Digest: mol-refinery-patrol",
|
||||
expected: "refinery",
|
||||
},
|
||||
{
|
||||
name: "wisp digest without patrol suffix",
|
||||
title: "Digest: gt-wisp-abc123",
|
||||
expected: "patrol",
|
||||
},
|
||||
{
|
||||
name: "random title",
|
||||
title: "Some other digest",
|
||||
expected: "patrol",
|
||||
},
|
||||
{
|
||||
name: "empty title",
|
||||
title: "",
|
||||
expected: "patrol",
|
||||
},
|
||||
{
|
||||
name: "just digest prefix",
|
||||
title: "Digest: ",
|
||||
expected: "patrol",
|
||||
},
|
||||
{
|
||||
name: "mol prefix but no patrol suffix",
|
||||
title: "Digest: mol-deacon-other",
|
||||
expected: "patrol",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := extractPatrolRole(tt.title)
|
||||
if got != tt.expected {
|
||||
t.Errorf("extractPatrolRole(%q) = %q, want %q", tt.title, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatrolDigestDateFormat(t *testing.T) {
|
||||
// Test that PatrolDigest.Date format is YYYY-MM-DD
|
||||
digest := PatrolDigest{
|
||||
Date: "2026-01-17",
|
||||
TotalCycles: 5,
|
||||
ByRole: map[string]int{"deacon": 2, "witness": 3},
|
||||
}
|
||||
|
||||
if digest.Date != "2026-01-17" {
|
||||
t.Errorf("Date format incorrect: got %q", digest.Date)
|
||||
}
|
||||
|
||||
if digest.TotalCycles != 5 {
|
||||
t.Errorf("TotalCycles: got %d, want 5", digest.TotalCycles)
|
||||
}
|
||||
|
||||
if digest.ByRole["deacon"] != 2 {
|
||||
t.Errorf("ByRole[deacon]: got %d, want 2", digest.ByRole["deacon"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatrolCycleEntry(t *testing.T) {
|
||||
entry := PatrolCycleEntry{
|
||||
ID: "gt-abc123",
|
||||
Role: "deacon",
|
||||
Title: "Digest: mol-deacon-patrol",
|
||||
Description: "Test description",
|
||||
}
|
||||
|
||||
if entry.ID != "gt-abc123" {
|
||||
t.Errorf("ID: got %q, want %q", entry.ID, "gt-abc123")
|
||||
}
|
||||
|
||||
if entry.Role != "deacon" {
|
||||
t.Errorf("Role: got %q, want %q", entry.Role, "deacon")
|
||||
}
|
||||
}
|
||||
+16
-3
@@ -32,12 +32,25 @@ var polecatCmd = &cobra.Command{
|
||||
Use: "polecat",
|
||||
Aliases: []string{"polecats"},
|
||||
GroupID: GroupAgents,
|
||||
Short: "Manage polecats in rigs",
|
||||
Short: "Manage polecats (ephemeral workers, one task then nuked)",
|
||||
RunE: requireSubcommand,
|
||||
Long: `Manage polecat lifecycle in rigs.
|
||||
|
||||
Polecats are worker agents that operate in their own git worktrees.
|
||||
Use the subcommands to add, remove, list, wake, and sleep polecats.`,
|
||||
Polecats are EPHEMERAL workers: spawned for one task, nuked when done.
|
||||
There is NO idle state. A polecat is either:
|
||||
- Working: Actively doing assigned work
|
||||
- Stalled: Session crashed mid-work (needs Witness intervention)
|
||||
- Zombie: Finished but gt done failed (needs cleanup)
|
||||
|
||||
Self-cleaning model: When work completes, the polecat runs 'gt done',
|
||||
which pushes the branch, submits to the merge queue, and exits. The
|
||||
Witness then nukes the sandbox. Polecats don't wait for more work.
|
||||
|
||||
Session vs sandbox: The Claude session cycles frequently (handoffs,
|
||||
compaction). The git worktree (sandbox) persists until nuke. Work
|
||||
survives session restarts.
|
||||
|
||||
Cats build features. Dogs clean up messes.`,
|
||||
}
|
||||
|
||||
var polecatListCmd = &cobra.Command{
|
||||
|
||||
@@ -374,6 +374,11 @@ func TestDetectSessionState(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("autonomous_state_hooked_bead", func(t *testing.T) {
|
||||
// Skip: bd CLI 0.47.2 has a bug where database writes don't commit
|
||||
// ("sql: database is closed" during auto-flush). This blocks tests
|
||||
// that need to create issues. See internal issue for tracking.
|
||||
t.Skip("bd CLI 0.47.2 bug: database writes don't commit")
|
||||
|
||||
// Skip if bd CLI is not available
|
||||
if _, err := exec.LookPath("bd"); err != nil {
|
||||
t.Skip("bd binary not found in PATH")
|
||||
|
||||
@@ -26,12 +26,23 @@ var refineryCmd = &cobra.Command{
|
||||
Use: "refinery",
|
||||
Aliases: []string{"ref"},
|
||||
GroupID: GroupAgents,
|
||||
Short: "Manage the merge queue processor",
|
||||
Short: "Manage the Refinery (merge queue processor)",
|
||||
RunE: requireSubcommand,
|
||||
Long: `Manage the Refinery merge queue processor for a rig.
|
||||
Long: `Manage the Refinery - the per-rig merge queue processor.
|
||||
|
||||
The Refinery processes merge requests from polecats, merging their work
|
||||
into integration branches and ultimately to main.`,
|
||||
The Refinery serializes all merges to main for a rig:
|
||||
- Receives MRs submitted by polecats (via gt done)
|
||||
- Rebases work branches onto latest main
|
||||
- Runs validation (tests, builds, checks)
|
||||
- Merges to main when clear
|
||||
- If conflict: spawns FRESH polecat to re-implement (original is gone)
|
||||
|
||||
Work flows: Polecat completes → gt done → MR in queue → Refinery merges.
|
||||
The polecat is already nuked by the time the Refinery processes.
|
||||
|
||||
One Refinery per rig. Persistent agent that processes work as it arrives.
|
||||
|
||||
Role shortcuts: "refinery" in mail/nudge addresses resolves to this rig's Refinery.`,
|
||||
}
|
||||
|
||||
var refineryStartCmd = &cobra.Command{
|
||||
|
||||
@@ -258,6 +258,11 @@ func runSlingFormula(args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip nudge during tests to prevent agent self-interruption
|
||||
if os.Getenv("GT_TEST_NO_NUDGE") != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var prompt string
|
||||
if slingArgs != "" {
|
||||
prompt = fmt.Sprintf("Formula %s slung. Args: %s. Run `gt hook` to see your hook, then execute using these args.", formulaName, slingArgs)
|
||||
|
||||
@@ -616,6 +616,7 @@ exit 0
|
||||
t.Setenv(EnvGTRole, "crew")
|
||||
t.Setenv("GT_CREW", "jv")
|
||||
t.Setenv("GT_POLECAT", "")
|
||||
t.Setenv("TMUX_PANE", "") // Prevent inheriting real tmux pane from test runner
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
@@ -637,6 +638,9 @@ exit 0
|
||||
slingDryRun = true
|
||||
slingNoConvoy = true
|
||||
|
||||
// Prevent real tmux nudge from firing during tests (causes agent self-interruption)
|
||||
t.Setenv("GT_TEST_NO_NUDGE", "1")
|
||||
|
||||
// EXPECTED: gt sling should use daemon mode and succeed
|
||||
// ACTUAL: verifyBeadExists uses --no-daemon and fails with sync error
|
||||
beadID := "jv-v599"
|
||||
@@ -792,6 +796,7 @@ exit 0
|
||||
t.Setenv(EnvGTRole, "mayor")
|
||||
t.Setenv("GT_POLECAT", "")
|
||||
t.Setenv("GT_CREW", "")
|
||||
t.Setenv("TMUX_PANE", "") // Prevent inheriting real tmux pane from test runner
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
@@ -819,6 +824,9 @@ exit 0
|
||||
slingVars = nil
|
||||
slingOnTarget = "gt-abc123" // The bug bead we're applying formula to
|
||||
|
||||
// Prevent real tmux nudge from firing during tests (causes agent self-interruption)
|
||||
t.Setenv("GT_TEST_NO_NUDGE", "1")
|
||||
|
||||
if err := runSling(nil, []string{"mol-polecat-work"}); err != nil {
|
||||
t.Fatalf("runSling: %v", err)
|
||||
}
|
||||
|
||||
+18
-40
@@ -184,10 +184,9 @@ func runMayorStatusLine(t *tmux.Tmux) error {
|
||||
|
||||
// Track per-rig status for LED indicators and sorting
|
||||
type rigStatus struct {
|
||||
hasWitness bool
|
||||
hasRefinery bool
|
||||
polecatCount int
|
||||
opState string // "OPERATIONAL", "PARKED", or "DOCKED"
|
||||
hasWitness bool
|
||||
hasRefinery bool
|
||||
opState string // "OPERATIONAL", "PARKED", or "DOCKED"
|
||||
}
|
||||
rigStatuses := make(map[string]*rigStatus)
|
||||
|
||||
@@ -202,10 +201,8 @@ func runMayorStatusLine(t *tmux.Tmux) error {
|
||||
working int
|
||||
}
|
||||
healthByType := map[AgentType]*agentHealth{
|
||||
AgentPolecat: {},
|
||||
AgentWitness: {},
|
||||
AgentRefinery: {},
|
||||
AgentDeacon: {},
|
||||
}
|
||||
|
||||
// Single pass: track rig status AND agent health
|
||||
@@ -215,7 +212,8 @@ func runMayorStatusLine(t *tmux.Tmux) error {
|
||||
continue
|
||||
}
|
||||
|
||||
// Track rig-level status (witness/refinery/polecat presence)
|
||||
// Track rig-level status (witness/refinery presence)
|
||||
// Polecats are not tracked in tmux - they're a GC concern, not a display concern
|
||||
if agent.Rig != "" && registeredRigs[agent.Rig] {
|
||||
if rigStatuses[agent.Rig] == nil {
|
||||
rigStatuses[agent.Rig] = &rigStatus{}
|
||||
@@ -225,8 +223,6 @@ func runMayorStatusLine(t *tmux.Tmux) error {
|
||||
rigStatuses[agent.Rig].hasWitness = true
|
||||
case AgentRefinery:
|
||||
rigStatuses[agent.Rig].hasRefinery = true
|
||||
case AgentPolecat:
|
||||
rigStatuses[agent.Rig].polecatCount++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,9 +250,10 @@ func runMayorStatusLine(t *tmux.Tmux) error {
|
||||
var parts []string
|
||||
|
||||
// Add per-agent-type health in consistent order
|
||||
// Format: "1/10 😺" = 1 working out of 10 total
|
||||
// Format: "1/3 👁️" = 1 working out of 3 total
|
||||
// Only show agent types that have sessions
|
||||
agentOrder := []AgentType{AgentPolecat, AgentWitness, AgentRefinery, AgentDeacon}
|
||||
// Note: Polecats and Deacon excluded - idle state display is misleading noise
|
||||
agentOrder := []AgentType{AgentWitness, AgentRefinery}
|
||||
var agentParts []string
|
||||
for _, agentType := range agentOrder {
|
||||
health := healthByType[agentType]
|
||||
@@ -287,7 +284,7 @@ func runMayorStatusLine(t *tmux.Tmux) error {
|
||||
rigs = append(rigs, rigInfo{name: rigName, status: status})
|
||||
}
|
||||
|
||||
// Sort by: 1) running state, 2) polecat count (desc), 3) operational state, 4) alphabetical
|
||||
// Sort by: 1) running state, 2) operational state, 3) alphabetical
|
||||
sort.Slice(rigs, func(i, j int) bool {
|
||||
isRunningI := rigs[i].status.hasWitness || rigs[i].status.hasRefinery
|
||||
isRunningJ := rigs[j].status.hasWitness || rigs[j].status.hasRefinery
|
||||
@@ -297,12 +294,7 @@ func runMayorStatusLine(t *tmux.Tmux) error {
|
||||
return isRunningI
|
||||
}
|
||||
|
||||
// Secondary sort: polecat count (descending)
|
||||
if rigs[i].status.polecatCount != rigs[j].status.polecatCount {
|
||||
return rigs[i].status.polecatCount > rigs[j].status.polecatCount
|
||||
}
|
||||
|
||||
// Tertiary sort: operational state (for non-running rigs: OPERATIONAL < PARKED < DOCKED)
|
||||
// Secondary sort: operational state (for non-running rigs: OPERATIONAL < PARKED < DOCKED)
|
||||
stateOrder := map[string]int{"OPERATIONAL": 0, "PARKED": 1, "DOCKED": 2}
|
||||
stateI := stateOrder[rigs[i].status.opState]
|
||||
stateJ := stateOrder[rigs[j].status.opState]
|
||||
@@ -310,7 +302,7 @@ func runMayorStatusLine(t *tmux.Tmux) error {
|
||||
return stateI < stateJ
|
||||
}
|
||||
|
||||
// Quaternary sort: alphabetical
|
||||
// Tertiary sort: alphabetical
|
||||
return rigs[i].name < rigs[j].name
|
||||
})
|
||||
|
||||
@@ -352,17 +344,12 @@ func runMayorStatusLine(t *tmux.Tmux) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Show polecat count if > 0
|
||||
// All icons get 1 space, Park gets 2
|
||||
space := " "
|
||||
if led == "🅿️" {
|
||||
space = " "
|
||||
}
|
||||
display := led + space + rig.name
|
||||
if status.polecatCount > 0 {
|
||||
display += fmt.Sprintf("(%d)", status.polecatCount)
|
||||
}
|
||||
rigParts = append(rigParts, display)
|
||||
rigParts = append(rigParts, led+space+rig.name)
|
||||
}
|
||||
|
||||
if len(rigParts) > 0 {
|
||||
@@ -421,7 +408,6 @@ func runDeaconStatusLine(t *tmux.Tmux) error {
|
||||
}
|
||||
|
||||
rigs := make(map[string]bool)
|
||||
polecatCount := 0
|
||||
for _, s := range sessions {
|
||||
agent := categorizeSession(s)
|
||||
if agent == nil {
|
||||
@@ -431,16 +417,13 @@ func runDeaconStatusLine(t *tmux.Tmux) error {
|
||||
if agent.Rig != "" && registeredRigs[agent.Rig] {
|
||||
rigs[agent.Rig] = true
|
||||
}
|
||||
if agent.Type == AgentPolecat && registeredRigs[agent.Rig] {
|
||||
polecatCount++
|
||||
}
|
||||
}
|
||||
rigCount := len(rigs)
|
||||
|
||||
// Build status
|
||||
// Note: Polecats excluded - they're ephemeral and idle detection is a GC concern
|
||||
var parts []string
|
||||
parts = append(parts, fmt.Sprintf("%d rigs", rigCount))
|
||||
parts = append(parts, fmt.Sprintf("%d 😺", polecatCount))
|
||||
|
||||
// Priority 1: Check for hooked work (town beads for deacon)
|
||||
hookedWork := ""
|
||||
@@ -466,7 +449,8 @@ func runDeaconStatusLine(t *tmux.Tmux) error {
|
||||
}
|
||||
|
||||
// runWitnessStatusLine outputs status for a witness session.
|
||||
// Shows: polecat count, crew count, hook or mail preview
|
||||
// Shows: crew count, hook or mail preview
|
||||
// Note: Polecats excluded - they're ephemeral and idle detection is a GC concern
|
||||
func runWitnessStatusLine(t *tmux.Tmux, rigName string) error {
|
||||
if rigName == "" {
|
||||
// Try to extract from session name: gt-<rig>-witness
|
||||
@@ -483,25 +467,20 @@ func runWitnessStatusLine(t *tmux.Tmux, rigName string) error {
|
||||
townRoot, _ = workspace.Find(paneDir)
|
||||
}
|
||||
|
||||
// Count polecats and crew in this rig
|
||||
// Count crew in this rig (crew are persistent, worth tracking)
|
||||
sessions, err := t.ListSessions()
|
||||
if err != nil {
|
||||
return nil // Silent fail
|
||||
}
|
||||
|
||||
polecatCount := 0
|
||||
crewCount := 0
|
||||
for _, s := range sessions {
|
||||
agent := categorizeSession(s)
|
||||
if agent == nil {
|
||||
continue
|
||||
}
|
||||
if agent.Rig == rigName {
|
||||
if agent.Type == AgentPolecat {
|
||||
polecatCount++
|
||||
} else if agent.Type == AgentCrew {
|
||||
crewCount++
|
||||
}
|
||||
if agent.Rig == rigName && agent.Type == AgentCrew {
|
||||
crewCount++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,7 +488,6 @@ func runWitnessStatusLine(t *tmux.Tmux, rigName string) error {
|
||||
|
||||
// Build status
|
||||
var parts []string
|
||||
parts = append(parts, fmt.Sprintf("%d 😺", polecatCount))
|
||||
if crewCount > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d crew", crewCount))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var tapCmd = &cobra.Command{
|
||||
Use: "tap",
|
||||
Short: "Claude Code hook handlers",
|
||||
Long: `Hook handlers for Claude Code PreToolUse and PostToolUse events.
|
||||
|
||||
These commands are called by Claude Code hooks to implement policies,
|
||||
auditing, and input transformation. They tap into the tool execution
|
||||
flow to guard, audit, inject, or check.
|
||||
|
||||
Subcommands:
|
||||
guard - Block forbidden operations (PreToolUse, exit 2)
|
||||
audit - Log/record tool executions (PostToolUse) [planned]
|
||||
inject - Modify tool inputs (PreToolUse, updatedInput) [planned]
|
||||
check - Validate after execution (PostToolUse) [planned]
|
||||
|
||||
Hook configuration in .claude/settings.json:
|
||||
{
|
||||
"PreToolUse": [{
|
||||
"matcher": "Bash(gh pr create*)",
|
||||
"hooks": [{"command": "gt tap guard pr-workflow"}]
|
||||
}]
|
||||
}
|
||||
|
||||
See ~/gt/docs/HOOKS.md for full documentation.`,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(tapCmd)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var tapGuardCmd = &cobra.Command{
|
||||
Use: "guard",
|
||||
Short: "Block forbidden operations (PreToolUse hook)",
|
||||
Long: `Block forbidden operations via Claude Code PreToolUse hooks.
|
||||
|
||||
Guard commands exit with code 2 to BLOCK tool execution when a policy
|
||||
is violated. They're called before the tool runs, preventing the
|
||||
forbidden operation entirely.
|
||||
|
||||
Available guards:
|
||||
pr-workflow - Block PR creation and feature branches
|
||||
|
||||
Example hook configuration:
|
||||
{
|
||||
"PreToolUse": [{
|
||||
"matcher": "Bash(gh pr create*)",
|
||||
"hooks": [{"command": "gt tap guard pr-workflow"}]
|
||||
}]
|
||||
}`,
|
||||
}
|
||||
|
||||
var tapGuardPRWorkflowCmd = &cobra.Command{
|
||||
Use: "pr-workflow",
|
||||
Short: "Block PR creation and feature branches",
|
||||
Long: `Block PR workflow operations in Gas Town.
|
||||
|
||||
Gas Town workers push directly to main. PRs add friction that breaks
|
||||
the autonomous execution model (GUPP principle).
|
||||
|
||||
This guard blocks:
|
||||
- gh pr create
|
||||
- git checkout -b (feature branches)
|
||||
- git switch -c (feature branches)
|
||||
|
||||
Exit codes:
|
||||
0 - Operation allowed (not in Gas Town agent context)
|
||||
2 - Operation BLOCKED (in agent context)
|
||||
|
||||
The guard only blocks when running as a Gas Town agent (crew, polecat,
|
||||
witness, etc.). Humans running outside Gas Town can still use PRs.`,
|
||||
RunE: runTapGuardPRWorkflow,
|
||||
}
|
||||
|
||||
func init() {
|
||||
tapCmd.AddCommand(tapGuardCmd)
|
||||
tapGuardCmd.AddCommand(tapGuardPRWorkflowCmd)
|
||||
}
|
||||
|
||||
func runTapGuardPRWorkflow(cmd *cobra.Command, args []string) error {
|
||||
// Check if we're in a Gas Town agent context
|
||||
if !isGasTownAgentContext() {
|
||||
// Not in a Gas Town managed context - allow the operation
|
||||
return nil
|
||||
}
|
||||
|
||||
// We're in a Gas Town context - block PR operations
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "╔══════════════════════════════════════════════════════════════════╗")
|
||||
fmt.Fprintln(os.Stderr, "║ ❌ PR WORKFLOW BLOCKED ║")
|
||||
fmt.Fprintln(os.Stderr, "╠══════════════════════════════════════════════════════════════════╣")
|
||||
fmt.Fprintln(os.Stderr, "║ Gas Town workers push directly to main. PRs are forbidden. ║")
|
||||
fmt.Fprintln(os.Stderr, "║ ║")
|
||||
fmt.Fprintln(os.Stderr, "║ Instead of: gh pr create / git checkout -b / git switch -c ║")
|
||||
fmt.Fprintln(os.Stderr, "║ Do this: git add . && git commit && git push origin main ║")
|
||||
fmt.Fprintln(os.Stderr, "║ ║")
|
||||
fmt.Fprintln(os.Stderr, "║ Why? PRs add friction that breaks autonomous execution. ║")
|
||||
fmt.Fprintln(os.Stderr, "║ See: ~/gt/docs/PRIMING.md (GUPP principle) ║")
|
||||
fmt.Fprintln(os.Stderr, "╚══════════════════════════════════════════════════════════════════╝")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
os.Exit(2) // Exit 2 = BLOCK in Claude Code hooks
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isGasTownAgentContext returns true if we're running as a Gas Town managed agent.
|
||||
func isGasTownAgentContext() bool {
|
||||
// Check environment variables set by Gas Town session management
|
||||
envVars := []string{
|
||||
"GT_POLECAT",
|
||||
"GT_CREW",
|
||||
"GT_WITNESS",
|
||||
"GT_REFINERY",
|
||||
"GT_MAYOR",
|
||||
"GT_DEACON",
|
||||
}
|
||||
for _, env := range envVars {
|
||||
if os.Getenv(env) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if we're in a crew or polecat worktree by path
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
agentPaths := []string{"/crew/", "/polecats/"}
|
||||
for _, path := range agentPaths {
|
||||
if strings.Contains(cwd, path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
// Version information - set at build time via ldflags
|
||||
var (
|
||||
Version = "0.3.0"
|
||||
Version = "0.4.0"
|
||||
// Build can be set via ldflags at compile time
|
||||
Build = "dev"
|
||||
// Commit and Branch - the git revision the binary was built from (optional ldflag)
|
||||
|
||||
+14
-7
@@ -24,16 +24,23 @@ var (
|
||||
var witnessCmd = &cobra.Command{
|
||||
Use: "witness",
|
||||
GroupID: GroupAgents,
|
||||
Short: "Manage the polecat monitoring agent",
|
||||
Short: "Manage the Witness (per-rig polecat health monitor)",
|
||||
RunE: requireSubcommand,
|
||||
Long: `Manage the Witness monitoring agent for a rig.
|
||||
Long: `Manage the Witness - the per-rig polecat health monitor.
|
||||
|
||||
The Witness monitors polecats for stuck states and orphaned sandboxes,
|
||||
nudges polecats that seem blocked, and reports status to the mayor.
|
||||
The Witness patrols a single rig, watching over its polecats:
|
||||
- Detects stalled polecats (crashed or stuck mid-work)
|
||||
- Nudges unresponsive sessions back to life
|
||||
- Cleans up zombie polecats (finished but failed to exit)
|
||||
- Nukes sandboxes when polecats complete via 'gt done'
|
||||
|
||||
In the self-cleaning model, polecats nuke themselves after work completion.
|
||||
The Witness handles edge cases: crashed sessions, orphaned worktrees, and
|
||||
stuck polecats that need intervention.`,
|
||||
The Witness does NOT force session cycles or interrupt working polecats.
|
||||
Polecats manage their own sessions (via gt handoff). The Witness handles
|
||||
failures and edge cases only.
|
||||
|
||||
One Witness per rig. The Deacon monitors all Witnesses.
|
||||
|
||||
Role shortcuts: "witness" in mail/nudge addresses resolves to this rig's Witness.`,
|
||||
}
|
||||
|
||||
var witnessStartCmd = &cobra.Command{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -242,6 +242,7 @@ func (m *Manager) List() ([]*Dog, error) {
|
||||
}
|
||||
|
||||
// Get returns a specific dog by name.
|
||||
// Returns ErrDogNotFound if the dog directory or .dog.json state file doesn't exist.
|
||||
func (m *Manager) Get(name string) (*Dog, error) {
|
||||
if !m.exists(name) {
|
||||
return nil, ErrDogNotFound
|
||||
@@ -249,12 +250,9 @@ func (m *Manager) Get(name string) (*Dog, error) {
|
||||
|
||||
state, err := m.loadState(name)
|
||||
if err != nil {
|
||||
// Return minimal dog if state file is missing
|
||||
return &Dog{
|
||||
Name: name,
|
||||
State: StateIdle,
|
||||
Path: m.dogDir(name),
|
||||
}, nil
|
||||
// No .dog.json means this isn't a valid dog worker
|
||||
// (e.g., "boot" is the boot watchdog using .boot-status.json, not a dog)
|
||||
return nil, ErrDogNotFound
|
||||
}
|
||||
|
||||
return &Dog{
|
||||
|
||||
@@ -341,20 +341,18 @@ id = "restart-daemons"
|
||||
title = "Restart daemons"
|
||||
needs = ["local-install"]
|
||||
description = """
|
||||
Restart gt daemons to pick up the new version.
|
||||
Restart gt daemon to pick up the new version.
|
||||
|
||||
```bash
|
||||
gt daemons killall
|
||||
gt daemon stop && gt daemon start
|
||||
```
|
||||
|
||||
Daemons will auto-restart with the new version on next gt command.
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
gt daemons list
|
||||
gt daemon status
|
||||
```
|
||||
|
||||
Check that daemon versions match {{version}}.
|
||||
The daemon should show the new binary timestamp and no stale warning.
|
||||
|
||||
Note: This step is safe to retry if it fails.
|
||||
"""
|
||||
|
||||
@@ -84,10 +84,46 @@ Callbacks may spawn new polecats, update issue state, or trigger other actions.
|
||||
**Hygiene principle**: Archive messages after they're fully processed.
|
||||
Keep inbox near-empty - only unprocessed items should remain."""
|
||||
|
||||
[[steps]]
|
||||
id = "orphan-process-cleanup"
|
||||
title = "Clean up orphaned claude subagent processes"
|
||||
needs = ["inbox-check"]
|
||||
description = """
|
||||
Clean up orphaned claude subagent processes.
|
||||
|
||||
Claude Code's Task tool spawns subagent processes that sometimes don't clean up
|
||||
properly after completion. These accumulate and consume significant memory.
|
||||
|
||||
**Detection method:**
|
||||
Orphaned processes have no controlling terminal (TTY = "?"). Legitimate claude
|
||||
instances in terminals have a TTY like "pts/0".
|
||||
|
||||
**Run cleanup:**
|
||||
```bash
|
||||
gt deacon cleanup-orphans
|
||||
```
|
||||
|
||||
This command:
|
||||
1. Lists all claude/codex processes with `ps -eo pid,tty,comm`
|
||||
2. Filters for TTY = "?" (no controlling terminal)
|
||||
3. Sends SIGTERM to each orphaned process
|
||||
4. Reports how many were killed
|
||||
|
||||
**Why this is safe:**
|
||||
- Processes in terminals (your personal sessions) have a TTY - they won't be touched
|
||||
- Only kills processes that have no controlling terminal
|
||||
- These orphans are children of the tmux server with no TTY, indicating they're
|
||||
detached subagents that failed to exit
|
||||
|
||||
**If cleanup fails:**
|
||||
Log the error but continue patrol - this is best-effort cleanup.
|
||||
|
||||
**Exit criteria:** Orphan cleanup attempted (success or logged failure)."""
|
||||
|
||||
[[steps]]
|
||||
id = "trigger-pending-spawns"
|
||||
title = "Nudge newly spawned polecats"
|
||||
needs = ["inbox-check"]
|
||||
needs = ["orphan-process-cleanup"]
|
||||
description = """
|
||||
Nudge newly spawned polecats that are ready for input.
|
||||
|
||||
@@ -629,46 +665,71 @@ Skip dispatch - system is healthy.
|
||||
|
||||
[[steps]]
|
||||
id = "costs-digest"
|
||||
title = "Aggregate daily costs"
|
||||
title = "Aggregate daily costs [DISABLED]"
|
||||
needs = ["session-gc"]
|
||||
description = """
|
||||
**DAILY DIGEST** - Aggregate yesterday's session cost wisps.
|
||||
**⚠️ DISABLED** - Skip this step entirely.
|
||||
|
||||
Session costs are recorded as ephemeral wisps (not exported to JSONL) to avoid
|
||||
log-in-database pollution. This step aggregates them into a permanent daily
|
||||
"Cost Report YYYY-MM-DD" bead for audit purposes.
|
||||
Cost tracking is temporarily disabled because Claude Code does not expose
|
||||
session costs in a way that can be captured programmatically.
|
||||
|
||||
**Why disabled:**
|
||||
- The `gt costs` command uses tmux capture-pane to find costs
|
||||
- Claude Code displays costs in the TUI status bar, not in scrollback
|
||||
- All sessions show $0.00 because capture-pane can't see TUI chrome
|
||||
- The infrastructure is sound but has no data source
|
||||
|
||||
**What we need from Claude Code:**
|
||||
- Stop hook env var (e.g., `$CLAUDE_SESSION_COST`)
|
||||
- Or queryable file/API endpoint
|
||||
|
||||
**Re-enable when:** Claude Code exposes cost data via API or environment.
|
||||
|
||||
See: GH#24, gt-7awfj
|
||||
|
||||
**Exit criteria:** Skip this step - proceed to next."""
|
||||
|
||||
[[steps]]
|
||||
id = "patrol-digest"
|
||||
title = "Aggregate daily patrol digests"
|
||||
needs = ["costs-digest"]
|
||||
description = """
|
||||
**DAILY DIGEST** - Aggregate yesterday's patrol cycle digests.
|
||||
|
||||
Patrol cycles (Deacon, Witness, Refinery) create ephemeral per-cycle digests
|
||||
to avoid JSONL pollution. This step aggregates them into a single permanent
|
||||
"Patrol Report YYYY-MM-DD" bead for audit purposes.
|
||||
|
||||
**Step 1: Check if digest is needed**
|
||||
```bash
|
||||
# Preview yesterday's costs (dry run)
|
||||
gt costs digest --yesterday --dry-run
|
||||
# Preview yesterday's patrol digests (dry run)
|
||||
gt patrol digest --yesterday --dry-run
|
||||
```
|
||||
|
||||
If output shows "No session cost wisps found", skip to Step 3.
|
||||
If output shows "No patrol digests found", skip to Step 3.
|
||||
|
||||
**Step 2: Create the digest**
|
||||
```bash
|
||||
gt costs digest --yesterday
|
||||
gt patrol digest --yesterday
|
||||
```
|
||||
|
||||
This:
|
||||
- Queries all session.ended wisps from yesterday
|
||||
- Creates a single "Cost Report YYYY-MM-DD" bead with aggregated data
|
||||
- Deletes the source wisps
|
||||
- Queries all ephemeral patrol digests from yesterday
|
||||
- Creates a single "Patrol Report YYYY-MM-DD" bead with aggregated data
|
||||
- Deletes the source digests
|
||||
|
||||
**Step 3: Verify**
|
||||
The digest appears in `gt costs --week` queries.
|
||||
Daily digests preserve audit trail without per-session pollution.
|
||||
Daily patrol digests preserve audit trail without per-cycle pollution.
|
||||
|
||||
**Timing**: Run once per morning patrol cycle. The --yesterday flag ensures
|
||||
we don't try to digest today's incomplete data.
|
||||
|
||||
**Exit criteria:** Yesterday's costs digested (or no wisps to digest)."""
|
||||
**Exit criteria:** Yesterday's patrol digests aggregated (or none to aggregate)."""
|
||||
|
||||
[[steps]]
|
||||
id = "log-maintenance"
|
||||
title = "Rotate logs and prune state"
|
||||
needs = ["costs-digest"]
|
||||
needs = ["patrol-digest"]
|
||||
description = """
|
||||
Maintain daemon logs and state files.
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ needs = ['check-timer-gates']
|
||||
title = 'Check if active swarm is complete'
|
||||
|
||||
[[steps]]
|
||||
description = "Send WITNESS_PING to Deacon for second-order monitoring.\n\nThe Witness fleet collectively monitors Deacon health - this prevents the\n\"who watches the watchers\" problem. If Deacon dies, Witnesses detect it.\n\n**Step 1: Send ping**\n```bash\ngt mail send deacon/ -s \"WITNESS_PING <rig>\" -m \"Rig: <rig>\nTimestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)\nPatrol: <cycle-number>\"\n```\n\n**Step 2: Check Deacon health**\n```bash\n# Check Deacon agent bead for last_activity\nbd list --type=agent --json | jq '.[] | select(.description | contains(\"role_type: deacon\"))'\n```\n\nLook at the `last_activity` timestamp. If stale (>5 minutes since last update):\n- Deacon may be dead or stuck\n\n**Step 3: Escalate if needed**\n```bash\n# If Deacon appears down\ngt mail send mayor/ -s \"ALERT: Town-level Deacon appears unresponsive\" -m \"Town Deacon (hq-deacon) has no activity for >5 minutes.\nLast seen: <timestamp>\nWitness: <rig>/witness\"\n```\n\nNote: Multiple Witnesses may send this alert. Mayor should handle deduplication."
|
||||
description = "Send WITNESS_PING to Deacon for second-order monitoring.\n\nThe Witness fleet collectively monitors Deacon health - this prevents the\n\"who watches the watchers\" problem. If Deacon dies, Witnesses detect it.\n\n**Step 1: Send ping**\n```bash\ngt mail send deacon/ -s \"WITNESS_PING <rig>\" -m \"Rig: <rig>\nTimestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)\nPatrol: <cycle-number>\"\n```\n\n**Step 2: Check Deacon health**\n```bash\n# Check Deacon agent bead for last_activity\nbd list --type=agent --json | jq '.[] | select(.description | contains(\"deacon\"))'\n```\n\nLook at the `last_activity` timestamp. If stale (>5 minutes since last update):\n- Deacon may be dead or stuck\n\n**Step 3: Escalate if needed**\n```bash\n# If Deacon appears down\ngt mail send mayor/ -s \"ALERT: Deacon appears unresponsive\" -m \"No Deacon activity for >5 minutes.\nLast seen: <timestamp>\nWitness: <rig>/witness\"\n```\n\nNote: Multiple Witnesses may send this alert. Mayor should handle deduplication."
|
||||
id = 'ping-deacon'
|
||||
needs = ['check-swarm-completion']
|
||||
title = 'Ping Deacon for health check'
|
||||
|
||||
@@ -171,7 +171,29 @@ func (r *Resolver) resolveByName(name string) ([]Recipient, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for queue in config
|
||||
// Check for beads-native queue
|
||||
if r.beads != nil {
|
||||
_, queueFields, err := r.beads.LookupQueueByName(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if queueFields != nil {
|
||||
foundQueue = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for beads-native channel
|
||||
if r.beads != nil {
|
||||
_, channelFields, err := r.beads.LookupChannelByName(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if channelFields != nil {
|
||||
foundChannel = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for queue/channel in config (legacy)
|
||||
if r.townRoot != "" {
|
||||
cfg, err := config.LoadMessagingConfig(config.MessagingConfigPath(r.townRoot))
|
||||
if err == nil && cfg != nil {
|
||||
|
||||
+18
-1
@@ -804,6 +804,7 @@ func (r *Router) sendToAnnounce(msg *Message) error {
|
||||
|
||||
// sendToChannel delivers a message to a beads-native channel.
|
||||
// Creates a message with channel:<name> label for channel queries.
|
||||
// Also fans out delivery to each subscriber's inbox.
|
||||
// Retention is enforced by the channel's EnforceChannelRetention after message creation.
|
||||
func (r *Router) sendToChannel(msg *Message) error {
|
||||
channelName := parseChannelName(msg.To)
|
||||
@@ -872,7 +873,23 @@ func (r *Router) sendToChannel(msg *Message) error {
|
||||
// Enforce channel retention policy (on-write cleanup)
|
||||
_ = b.EnforceChannelRetention(channelName)
|
||||
|
||||
// No notification for channel messages - readers poll or check on their own schedule
|
||||
// Fan-out delivery: send a copy to each subscriber's inbox
|
||||
if len(fields.Subscribers) > 0 {
|
||||
for _, subscriber := range fields.Subscribers {
|
||||
// Skip self-delivery (don't notify the sender)
|
||||
if isSelfMail(msg.From, subscriber) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create a copy for this subscriber with channel context in subject
|
||||
msgCopy := *msg
|
||||
msgCopy.To = subscriber
|
||||
msgCopy.Subject = fmt.Sprintf("[channel:%s] %s", channelName, msg.Subject)
|
||||
|
||||
// Best-effort delivery - don't fail the channel send if one subscriber fails
|
||||
_ = r.sendToSingle(&msgCopy)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -432,6 +432,18 @@ func (m *Manager) RemoveWithOptions(name string, force, nuclear bool) error {
|
||||
// Get repo base to remove the worktree properly
|
||||
repoGit, err := m.repoBase()
|
||||
if err != nil {
|
||||
// Best-effort: try to prune stale worktree entries from both possible repo locations.
|
||||
// This handles edge cases where the repo base is corrupted but worktree entries exist.
|
||||
bareRepoPath := filepath.Join(m.rig.Path, ".repo.git")
|
||||
if info, statErr := os.Stat(bareRepoPath); statErr == nil && info.IsDir() {
|
||||
bareGit := git.NewGitWithDir(bareRepoPath, "")
|
||||
_ = bareGit.WorktreePrune()
|
||||
}
|
||||
mayorRigPath := filepath.Join(m.rig.Path, "mayor", "rig")
|
||||
if info, statErr := os.Stat(mayorRigPath); statErr == nil && info.IsDir() {
|
||||
mayorGit := git.NewGit(mayorRigPath)
|
||||
_ = mayorGit.WorktreePrune()
|
||||
}
|
||||
// Fall back to direct removal if repo base not found
|
||||
return os.RemoveAll(polecatDir)
|
||||
}
|
||||
|
||||
@@ -308,9 +308,49 @@ ONE exception where branches are created. But the rule still applies:
|
||||
- `bd sync` - Sync beads changes
|
||||
|
||||
### Communication
|
||||
- `gt mail send <addr> -s "Subject" -m "Message"` - Send mail
|
||||
- `gt mail send <addr> -s "Subject" -m "Message"` - Send mail (async, queued)
|
||||
- `gt mail send mayor/ -s "Subject" -m "Message"` - To Mayor
|
||||
- `gt mail send --human -s "Subject" -m "Message"` - To overseer
|
||||
- `gt nudge <target> "message"` - Wake an agent and send immediate message
|
||||
|
||||
### gt nudge: Waking Agents
|
||||
|
||||
`gt nudge` is the **core mechanism for inter-agent communication**. It sends a message
|
||||
directly to another agent's Claude Code session via tmux.
|
||||
|
||||
**When to use nudge vs mail:**
|
||||
| Use Case | Tool | Why |
|
||||
|----------|------|-----|
|
||||
| Wake a sleeping agent | `gt nudge` | Immediate delivery to their session |
|
||||
| Send task for later | `gt mail send` | Queued, they'll see it on next check |
|
||||
| Both: assign + wake | `gt mail send` then `gt nudge` | Mail carries payload, nudge wakes them |
|
||||
|
||||
**Common patterns:**
|
||||
```bash
|
||||
# Wake another crew member (full path: rig/crew/name)
|
||||
gt nudge {{ .RigName }}/crew/peer "Check your mail - PR review waiting"
|
||||
|
||||
# Wake a polecat (full path: rig/polecats/name)
|
||||
gt nudge {{ .RigName }}/polecats/alpha "Work available on hook"
|
||||
|
||||
# Nudge with notification flag (also sends tmux bell)
|
||||
gt mail send {{ .RigName }}/peer -s "Urgent" -m "..." --notify
|
||||
|
||||
# Nudge patrol agents
|
||||
gt nudge witness "Check polecat health"
|
||||
gt nudge deacon "Session started"
|
||||
gt nudge mayor "Status update needed"
|
||||
```
|
||||
|
||||
**Target shortcuts:**
|
||||
- `mayor` → gt-mayor session
|
||||
- `deacon` → gt-deacon session
|
||||
- `witness` → gt-{{ .RigName }}-witness session
|
||||
- `refinery` → gt-{{ .RigName }}-refinery session
|
||||
- `channel:<name>` → All members of a named channel
|
||||
|
||||
**Important:** `gt nudge` is the ONLY reliable way to send text to Claude sessions.
|
||||
Raw `tmux send-keys` is unreliable. Always use `gt nudge` for agent-to-agent communication.
|
||||
|
||||
## No Witness Monitoring
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -241,16 +243,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 <mail-id>
|
||||
|
||||
# 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.
|
||||
|
||||
|
||||
+67
-1
@@ -19,6 +19,60 @@ import (
|
||||
// processes and avoids killing legitimate short-lived subagents.
|
||||
const minOrphanAge = 60
|
||||
|
||||
// getGasTownSessionPIDs returns a set of PIDs belonging to valid Gas Town tmux sessions.
|
||||
// This prevents killing Claude processes that are part of witness/refinery/deacon sessions
|
||||
// even if they temporarily show TTY "?" during startup or session transitions.
|
||||
func getGasTownSessionPIDs() map[int]bool {
|
||||
pids := make(map[int]bool)
|
||||
|
||||
// Get list of Gas Town tmux sessions (gt-* and hq-*)
|
||||
out, err := exec.Command("tmux", "list-sessions", "-F", "#{session_name}").Output()
|
||||
if err != nil {
|
||||
return pids // tmux not available or no sessions
|
||||
}
|
||||
|
||||
var gasTownSessions []string
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if strings.HasPrefix(line, "gt-") || strings.HasPrefix(line, "hq-") {
|
||||
gasTownSessions = append(gasTownSessions, line)
|
||||
}
|
||||
}
|
||||
|
||||
// For each Gas Town session, get the PIDs of processes in its panes
|
||||
for _, session := range gasTownSessions {
|
||||
out, err := exec.Command("tmux", "list-panes", "-t", session, "-F", "#{pane_pid}").Output()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, pidStr := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if pid, err := strconv.Atoi(pidStr); err == nil && pid > 0 {
|
||||
pids[pid] = true
|
||||
// Also add child processes of the pane shell
|
||||
addChildPIDs(pid, pids)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pids
|
||||
}
|
||||
|
||||
// addChildPIDs adds all descendant PIDs of a process to the set.
|
||||
// This catches Claude processes spawned by the shell in a tmux pane.
|
||||
func addChildPIDs(parentPID int, pids map[int]bool) {
|
||||
// Use pgrep to find children (more reliable than parsing ps output)
|
||||
out, err := exec.Command("pgrep", "-P", strconv.Itoa(parentPID)).Output()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, pidStr := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if pid, err := strconv.Atoi(pidStr); err == nil && pid > 0 {
|
||||
pids[pid] = true
|
||||
// Recurse to get grandchildren
|
||||
addChildPIDs(pid, pids)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sigkillGracePeriod is how long (in seconds) we wait after sending SIGTERM
|
||||
// before escalating to SIGKILL. If a process was sent SIGTERM and is still
|
||||
// around after this period, we use SIGKILL on the next cleanup cycle.
|
||||
@@ -166,6 +220,10 @@ type OrphanedProcess struct {
|
||||
// Additionally, processes must be older than minOrphanAge seconds to be considered
|
||||
// orphaned. This prevents race conditions with newly spawned processes.
|
||||
func FindOrphanedClaudeProcesses() ([]OrphanedProcess, error) {
|
||||
// Get PIDs belonging to valid Gas Town tmux sessions.
|
||||
// These should not be killed even if they show TTY "?" during startup.
|
||||
gasTownPIDs := getGasTownSessionPIDs()
|
||||
|
||||
// Use ps to get PID, TTY, command, and elapsed time for all processes
|
||||
// TTY "?" indicates no controlling terminal
|
||||
// etime is elapsed time in [[DD-]HH:]MM:SS format (portable across Linux/macOS)
|
||||
@@ -191,7 +249,8 @@ func FindOrphanedClaudeProcesses() ([]OrphanedProcess, error) {
|
||||
etimeStr := fields[3]
|
||||
|
||||
// Only look for claude/codex processes without a TTY
|
||||
if tty != "?" {
|
||||
// Linux shows "?" for no TTY, macOS shows "??"
|
||||
if tty != "?" && tty != "??" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -201,6 +260,13 @@ func FindOrphanedClaudeProcesses() ([]OrphanedProcess, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip processes that belong to valid Gas Town tmux sessions.
|
||||
// This prevents killing witnesses/refineries/deacon during startup
|
||||
// when they may temporarily show TTY "?".
|
||||
if gasTownPIDs[pid] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip processes younger than minOrphanAge seconds
|
||||
// This prevents killing newly spawned subagents and reduces false positives
|
||||
age, err := parseEtime(etimeStr)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@gastown/gt",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "Gas Town CLI - multi-agent workspace manager with native binary support",
|
||||
"main": "bin/gt.js",
|
||||
"bin": {
|
||||
|
||||
@@ -235,9 +235,23 @@ merge queue. Without this step:
|
||||
|
||||
**You own your session cadence.** The Witness monitors but doesn't force recycles.
|
||||
|
||||
### Closing Steps (for Activity Feed)
|
||||
### 🚨 THE BATCH-CLOSURE HERESY 🚨
|
||||
|
||||
Molecules are the **LEDGER** - not a task checklist. Each step closure is a timestamped entry in your permanent work record (your CV).
|
||||
|
||||
**The discipline:**
|
||||
1. Mark step `in_progress` BEFORE starting it: `bd update <step-id> --status=in_progress`
|
||||
2. Mark step `closed` IMMEDIATELY after completing it: `bd close <step-id>`
|
||||
3. **NEVER** batch-close steps at the end
|
||||
|
||||
**Why this matters:** Batch-closing corrupts the timeline. It creates a lie - showing all steps completed at the same moment instead of the actual work progression. The activity feed should show your REAL work timeline.
|
||||
|
||||
**Wrong:** Do all work, then close steps 1, 2, 3, 4, 5 in sequence at the end
|
||||
**Right:**
|
||||
- Mark step 1 in_progress → do work → close step 1
|
||||
- Mark step 2 in_progress → do work → close step 2
|
||||
- (repeat for each step)
|
||||
|
||||
As you complete each molecule step, close it:
|
||||
```bash
|
||||
bd close <step-id> --reason "Implemented: <what you did>"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user