feat(prime): add --state, --dry-run, --explain flags with mutual exclusivity validation
Add three new flags to gt prime command: - --state: Output role state as JSON and exit early (for scripting) - --dry-run: Skip side effects (persistence, locks, events) - --explain: Show verbose role detection reasoning The --state flag is mutually exclusive with all other flags and errors if combined. The other flags (--dry-run, --explain, --hook) can be combined freely. Also fixes missing filepath import in beads.go. Closes: bd-t8ven Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,21 @@ Role detection:
|
|||||||
|
|
||||||
This command is typically used in shell prompts or agent initialization.
|
This command is typically used in shell prompts or agent initialization.
|
||||||
|
|
||||||
|
FLAGS:
|
||||||
|
|
||||||
|
STATE MODE (--state):
|
||||||
|
Output detected role information as JSON and exit early.
|
||||||
|
Useful for scripting and programmatic role detection.
|
||||||
|
This flag is standalone - cannot be combined with other flags.
|
||||||
|
|
||||||
|
DRY-RUN MODE (--dry-run):
|
||||||
|
Show what context would be output without side effects.
|
||||||
|
Skips session ID persistence, lock acquisition, and event emission.
|
||||||
|
|
||||||
|
EXPLAIN MODE (--explain):
|
||||||
|
Provide verbose explanations for role detection decisions.
|
||||||
|
Shows why certain context is being included.
|
||||||
|
|
||||||
HOOK MODE (--hook):
|
HOOK MODE (--hook):
|
||||||
When called as an LLM runtime hook, use --hook to enable session ID handling.
|
When called as an LLM runtime hook, use --hook to enable session ID handling.
|
||||||
This reads session metadata from stdin and persists it for the session.
|
This reads session metadata from stdin and persists it for the session.
|
||||||
@@ -71,7 +86,11 @@ HOOK MODE (--hook):
|
|||||||
Claude Code sends JSON on stdin:
|
Claude Code sends JSON on stdin:
|
||||||
{"session_id": "uuid", "transcript_path": "/path", "source": "startup|resume"}
|
{"session_id": "uuid", "transcript_path": "/path", "source": "startup|resume"}
|
||||||
|
|
||||||
Other agents can set GT_SESSION_ID environment variable instead.`,
|
Other agents can set GT_SESSION_ID environment variable instead.
|
||||||
|
|
||||||
|
FLAG COMBINATIONS:
|
||||||
|
--state is mutually exclusive with all other flags.
|
||||||
|
--dry-run, --explain, and --hook can be combined.`,
|
||||||
RunE: runPrime,
|
RunE: runPrime,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +121,11 @@ func runPrime(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("finding workspace: %w", err)
|
return fmt.Errorf("finding workspace: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate mutually exclusive flags
|
||||||
|
if primeState && (primeHookMode || primeDryRun || primeExplain) {
|
||||||
|
return fmt.Errorf("--state cannot be combined with other flags (--hook, --dry-run, --explain)")
|
||||||
|
}
|
||||||
|
|
||||||
// "Discover, Don't Track" principle:
|
// "Discover, Don't Track" principle:
|
||||||
// - If we're in a workspace, proceed - the workspace's existence IS the enable signal
|
// - If we're in a workspace, proceed - the workspace's existence IS the enable signal
|
||||||
// - If we're NOT in a workspace, check the global enabled state
|
// - If we're NOT in a workspace, check the global enabled state
|
||||||
@@ -123,10 +147,12 @@ func runPrime(cmd *cobra.Command, args []string) error {
|
|||||||
if cwd != townRoot {
|
if cwd != townRoot {
|
||||||
persistSessionID(cwd, sessionID)
|
persistSessionID(cwd, sessionID)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Set environment for this process (affects event emission below)
|
// Set environment for this process (affects event emission below)
|
||||||
_ = os.Setenv("GT_SESSION_ID", sessionID)
|
_ = os.Setenv("GT_SESSION_ID", sessionID)
|
||||||
_ = os.Setenv("CLAUDE_SESSION_ID", sessionID) // Legacy compatibility
|
_ = os.Setenv("CLAUDE_SESSION_ID", sessionID) // Legacy compatibility
|
||||||
|
} else if primeExplain {
|
||||||
|
fmt.Println("[dry-run] Would persist session ID:", sessionID)
|
||||||
|
}
|
||||||
// Output session beacon
|
// Output session beacon
|
||||||
explain(true, "Session beacon: hook mode enabled, session ID from stdin")
|
explain(true, "Session beacon: hook mode enabled, session ID from stdin")
|
||||||
fmt.Printf("[session:%s]\n", sessionID)
|
fmt.Printf("[session:%s]\n", sessionID)
|
||||||
@@ -149,6 +175,24 @@ func runPrime(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("detecting role: %w", err)
|
return fmt.Errorf("detecting role: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --state mode: output JSON and exit early
|
||||||
|
if primeState {
|
||||||
|
return outputStateJSON(roleInfo, cwd, townRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --explain mode: show role detection reasoning
|
||||||
|
if primeExplain {
|
||||||
|
fmt.Printf("[explain] Role detection source: %s\n", roleInfo.Source)
|
||||||
|
fmt.Printf("[explain] Detected role: %s\n", roleInfo.Role)
|
||||||
|
if roleInfo.Rig != "" {
|
||||||
|
fmt.Printf("[explain] Rig: %s\n", roleInfo.Rig)
|
||||||
|
}
|
||||||
|
if roleInfo.Polecat != "" {
|
||||||
|
fmt.Printf("[explain] Polecat/Crew: %s\n", roleInfo.Polecat)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
// Warn prominently if there's a role/cwd mismatch
|
// Warn prominently if there's a role/cwd mismatch
|
||||||
if roleInfo.Mismatch {
|
if roleInfo.Mismatch {
|
||||||
fmt.Printf("\n%s\n", style.Bold.Render("⚠️ ROLE/LOCATION MISMATCH"))
|
fmt.Printf("\n%s\n", style.Bold.Render("⚠️ ROLE/LOCATION MISMATCH"))
|
||||||
@@ -184,12 +228,18 @@ func runPrime(cmd *cobra.Command, args []string) error {
|
|||||||
if err := acquireIdentityLock(ctx); err != nil {
|
if err := acquireIdentityLock(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
} else if primeExplain {
|
||||||
|
fmt.Println("[dry-run] Would acquire identity lock for:", getAgentIdentity(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure beads redirect exists for worktree-based roles
|
// Ensure beads redirect exists for worktree-based roles
|
||||||
// Skip if there's a role/location mismatch to avoid creating bad redirects
|
// Skip if there's a role/location mismatch to avoid creating bad redirects
|
||||||
if !roleInfo.Mismatch && !primeDryRun {
|
if !roleInfo.Mismatch {
|
||||||
|
if !primeDryRun {
|
||||||
ensureBeadsRedirect(ctx)
|
ensureBeadsRedirect(ctx)
|
||||||
|
} else if primeExplain {
|
||||||
|
fmt.Println("[dry-run] Would ensure beads redirect")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: reportAgentState("running") removed (gt-zecmc)
|
// NOTE: reportAgentState("running") removed (gt-zecmc)
|
||||||
@@ -199,6 +249,8 @@ func runPrime(cmd *cobra.Command, args []string) error {
|
|||||||
// Emit session_start event for seance discovery
|
// Emit session_start event for seance discovery
|
||||||
if !primeDryRun {
|
if !primeDryRun {
|
||||||
emitSessionEvent(ctx)
|
emitSessionEvent(ctx)
|
||||||
|
} else if primeExplain {
|
||||||
|
fmt.Println("[dry-run] Would emit session_start event")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output session metadata for seance discovery
|
// Output session metadata for seance discovery
|
||||||
@@ -335,6 +387,47 @@ func detectRole(cwd, townRoot string) RoleInfo {
|
|||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PrimeState represents the JSON output for --state mode.
|
||||||
|
type PrimeState struct {
|
||||||
|
Role Role `json:"role"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Rig string `json:"rig,omitempty"`
|
||||||
|
Polecat string `json:"polecat,omitempty"`
|
||||||
|
TownRoot string `json:"town_root"`
|
||||||
|
WorkDir string `json:"work_dir"`
|
||||||
|
Mismatch bool `json:"mismatch,omitempty"`
|
||||||
|
CwdRole Role `json:"cwd_role,omitempty"`
|
||||||
|
Identity string `json:"identity,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// outputStateJSON outputs role state as JSON and returns (for --state flag).
|
||||||
|
func outputStateJSON(roleInfo RoleInfo, cwd, townRoot string) error {
|
||||||
|
state := PrimeState{
|
||||||
|
Role: roleInfo.Role,
|
||||||
|
Source: roleInfo.Source,
|
||||||
|
Rig: roleInfo.Rig,
|
||||||
|
Polecat: roleInfo.Polecat,
|
||||||
|
TownRoot: townRoot,
|
||||||
|
WorkDir: cwd,
|
||||||
|
Mismatch: roleInfo.Mismatch,
|
||||||
|
CwdRole: roleInfo.CwdRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute identity string
|
||||||
|
ctx := RoleContext{
|
||||||
|
Role: roleInfo.Role,
|
||||||
|
Rig: roleInfo.Rig,
|
||||||
|
Polecat: roleInfo.Polecat,
|
||||||
|
TownRoot: townRoot,
|
||||||
|
WorkDir: cwd,
|
||||||
|
}
|
||||||
|
state.Identity = getAgentIdentity(ctx)
|
||||||
|
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(state)
|
||||||
|
}
|
||||||
|
|
||||||
func outputPrimeContext(ctx RoleContext) error {
|
func outputPrimeContext(ctx RoleContext) error {
|
||||||
// Try to use templates first
|
// Try to use templates first
|
||||||
tmpl, err := templates.New()
|
tmpl, err := templates.New()
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/steveyegge/gastown/internal/beads"
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
@@ -95,3 +97,73 @@ func TestGetAgentBeadID_UsesRigPrefix(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPrimeFlagCombinations(t *testing.T) {
|
||||||
|
// Find the gt binary - we need to test CLI flag validation
|
||||||
|
gtBin, err := exec.LookPath("gt")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("gt binary not found in PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
wantError bool
|
||||||
|
errorMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "state_alone_is_valid",
|
||||||
|
args: []string{"prime", "--state"},
|
||||||
|
wantError: false, // May fail for other reasons (not in workspace), but not flag validation
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "state_with_hook_errors",
|
||||||
|
args: []string{"prime", "--state", "--hook"},
|
||||||
|
wantError: true,
|
||||||
|
errorMsg: "--state cannot be combined with other flags",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "state_with_dry_run_errors",
|
||||||
|
args: []string{"prime", "--state", "--dry-run"},
|
||||||
|
wantError: true,
|
||||||
|
errorMsg: "--state cannot be combined with other flags",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "state_with_explain_errors",
|
||||||
|
args: []string{"prime", "--state", "--explain"},
|
||||||
|
wantError: true,
|
||||||
|
errorMsg: "--state cannot be combined with other flags",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dry_run_and_explain_valid",
|
||||||
|
args: []string{"prime", "--dry-run", "--explain"},
|
||||||
|
wantError: false, // May fail for other reasons, but not flag validation
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hook_and_dry_run_valid",
|
||||||
|
args: []string{"prime", "--hook", "--dry-run"},
|
||||||
|
wantError: false, // May fail for other reasons, but not flag validation
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cmd := exec.Command(gtBin, tc.args...)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
|
if tc.wantError {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got success with output: %s", output)
|
||||||
|
}
|
||||||
|
if tc.errorMsg != "" && !strings.Contains(string(output), tc.errorMsg) {
|
||||||
|
t.Fatalf("expected error containing %q, got: %s", tc.errorMsg, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For non-error cases, we don't fail on other errors (like "not in workspace")
|
||||||
|
// because we're only testing flag validation
|
||||||
|
if !tc.wantError && tc.errorMsg != "" && strings.Contains(string(output), tc.errorMsg) {
|
||||||
|
t.Fatalf("unexpected error message %q in output: %s", tc.errorMsg, output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user