feat: add prepare-commit-msg hook for agent identity trailers (bd-luso)

Automatically adds trailers to commits when running in Gas Town agent context:
- Executed-By: agent identity (e.g., beads/crew/dave)
- Rig: the rig name
- Role: crew, polecat, witness, etc.
- Molecule: pinned molecule ID (if any)

Detection sources:
1. GT_ROLE environment variable (set by Gas Town sessions)
2. cwd path patterns (crew/, polecats/, witness/, refinery/)

Trailers are skipped for:
- Human commits (no agent context detected)
- Merge commits (have their own format)
- Commits that already have Executed-By trailer (avoid duplicates)

Executed-By: beads/crew/dave
Rig: beads
Role: crew
This commit is contained in:
Steve Yegge
2025-12-29 21:25:45 -08:00
parent a8748936e4
commit 1b9c0e145e
2 changed files with 293 additions and 5 deletions

View File

@@ -19,7 +19,7 @@ var hooksFS embed.FS
func getEmbeddedHooks() (map[string]string, error) {
hooks := make(map[string]string)
hookNames := []string{"pre-commit", "post-merge", "pre-push", "post-checkout"}
hookNames := []string{"pre-commit", "post-merge", "pre-push", "post-checkout", "prepare-commit-msg"}
for _, name := range hookNames {
content, err := hooksFS.ReadFile("templates/hooks/" + name)
@@ -46,7 +46,7 @@ type HookStatus struct {
// CheckGitHooks checks the status of bd git hooks in .git/hooks/
func CheckGitHooks() []HookStatus {
hooks := []string{"pre-commit", "post-merge", "pre-push", "post-checkout"}
hooks := []string{"pre-commit", "post-merge", "pre-push", "post-checkout", "prepare-commit-msg"}
statuses := make([]HookStatus, 0, len(hooks))
// Get actual git directory (handles worktrees)
@@ -169,7 +169,8 @@ The hooks ensure that:
- pre-commit: Flushes pending changes to JSONL before commit
- post-merge: Imports updated JSONL after pull/merge
- pre-push: Prevents pushing stale JSONL
- post-checkout: Imports JSONL after branch checkout`,
- post-checkout: Imports JSONL after branch checkout
- prepare-commit-msg: Adds agent identity trailers for forensics`,
}
var hooksInstallCmd = &cobra.Command{
@@ -185,7 +186,8 @@ Installed hooks:
- pre-commit: Flush changes to JSONL before commit
- post-merge: Import JSONL after pull/merge
- pre-push: Prevent pushing stale JSONL
- post-checkout: Import JSONL after branch checkout`,
- post-checkout: Import JSONL after branch checkout
- prepare-commit-msg: Add agent identity trailers (for Gas Town agents)`,
Run: func(cmd *cobra.Command, args []string) {
force, _ := cmd.Flags().GetBool("force")
shared, _ := cmd.Flags().GetBool("shared")
@@ -374,7 +376,7 @@ func uninstallHooks() error {
return err
}
hooksDir := filepath.Join(gitDir, "hooks")
hookNames := []string{"pre-commit", "post-merge", "pre-push", "post-checkout"}
hookNames := []string{"pre-commit", "post-merge", "pre-push", "post-checkout", "prepare-commit-msg"}
for _, hookName := range hookNames {
hookPath := filepath.Join(hooksDir, hookName)
@@ -608,6 +610,265 @@ func runPostCheckoutHook(args []string) int {
return 0
}
// runPrepareCommitMsgHook adds agent identity trailers to commit messages.
// args: [commit-msg-file, source, sha1]
// Returns 0 on success (or if not applicable), non-zero on error.
//
//nolint:unparam // Always returns 0 by design - we don't block commits
func runPrepareCommitMsgHook(args []string) int {
if len(args) < 1 {
return 0 // No message file provided
}
msgFile := args[0]
source := ""
if len(args) >= 2 {
source = args[1]
}
// Skip for merge commits (they already have their own format)
if source == "merge" {
return 0
}
// Detect agent context
identity := detectAgentIdentity()
if identity == nil {
return 0 // Not in agent context, nothing to add
}
// Read current message
content, err := os.ReadFile(msgFile) // #nosec G304 -- path from git
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not read commit message: %v\n", err)
return 0
}
// Check if trailers already present (avoid duplicates on amend)
// Look for "Executed-By:" at the start of a line (actual trailer format)
for _, line := range strings.Split(string(content), "\n") {
if strings.HasPrefix(line, "Executed-By:") {
return 0
}
}
// Build trailers
var trailers []string
trailers = append(trailers, fmt.Sprintf("Executed-By: %s", identity.FullIdentity))
if identity.Rig != "" {
trailers = append(trailers, fmt.Sprintf("Rig: %s", identity.Rig))
}
if identity.Role != "" {
trailers = append(trailers, fmt.Sprintf("Role: %s", identity.Role))
}
if identity.Molecule != "" {
trailers = append(trailers, fmt.Sprintf("Molecule: %s", identity.Molecule))
}
// Append trailers to message
msg := strings.TrimRight(string(content), "\n\r\t ")
var sb strings.Builder
sb.WriteString(msg)
sb.WriteString("\n\n")
for _, trailer := range trailers {
sb.WriteString(trailer)
sb.WriteString("\n")
}
// Write back
if err := os.WriteFile(msgFile, []byte(sb.String()), 0644); err != nil { // #nosec G306
fmt.Fprintf(os.Stderr, "Warning: could not write commit message: %v\n", err)
}
return 0
}
// agentIdentity holds detected agent context information.
type agentIdentity struct {
FullIdentity string // e.g., "beads/crew/dave"
Rig string // e.g., "beads"
Role string // e.g., "crew"
Molecule string // e.g., "bd-xyz" (if attached)
}
// detectAgentIdentity returns agent identity if running in agent context.
// Returns nil if not in an agent context (human commit).
func detectAgentIdentity() *agentIdentity {
// Check GT_ROLE environment variable first (set by Gas Town sessions)
gtRole := os.Getenv("GT_ROLE")
if gtRole != "" {
return parseAgentIdentity(gtRole)
}
// Fall back to cwd-based detection
cwd, err := os.Getwd()
if err != nil {
return nil
}
// Detect from path patterns
return detectAgentFromPath(cwd)
}
// parseAgentIdentity parses a GT_ROLE value into agent identity.
func parseAgentIdentity(role string) *agentIdentity {
// GT_ROLE can be:
// - Simple: "crew", "polecat", "witness", "refinery", "mayor"
// - Compound: "beads/crew/dave", "gastown/polecat/Nux-123"
if strings.Contains(role, "/") {
// Compound format
parts := strings.Split(role, "/")
identity := &agentIdentity{FullIdentity: role}
if len(parts) >= 1 {
identity.Rig = parts[0]
}
if len(parts) >= 2 {
identity.Role = parts[1]
}
// Check for molecule
identity.Molecule = getPinnedMolecule()
return identity
}
// Simple format - need to combine with env vars
rig := os.Getenv("GT_RIG")
identity := &agentIdentity{Role: role, Rig: rig}
switch role {
case "crew":
crew := os.Getenv("GT_CREW")
if rig != "" && crew != "" {
identity.FullIdentity = fmt.Sprintf("%s/crew/%s", rig, crew)
}
case "polecat":
polecat := os.Getenv("GT_POLECAT")
if rig != "" && polecat != "" {
identity.FullIdentity = fmt.Sprintf("%s/%s", rig, polecat)
}
case "witness":
if rig != "" {
identity.FullIdentity = fmt.Sprintf("%s/witness", rig)
}
case "refinery":
if rig != "" {
identity.FullIdentity = fmt.Sprintf("%s/refinery", rig)
}
case "mayor":
identity.FullIdentity = "mayor"
identity.Rig = "" // Mayor is rig-agnostic
case "deacon":
identity.FullIdentity = "deacon"
identity.Rig = "" // Deacon is rig-agnostic
}
if identity.FullIdentity == "" {
return nil
}
identity.Molecule = getPinnedMolecule()
return identity
}
// detectAgentFromPath detects agent identity from cwd path patterns.
func detectAgentFromPath(cwd string) *agentIdentity {
// Match patterns like:
// - /Users/.../gt/<rig>/crew/<name>/...
// - /Users/.../gt/<rig>/polecats/<name>/...
// - /Users/.../gt/<rig>/witness/...
// - /Users/.../gt/<rig>/refinery/...
// Crew pattern
if strings.Contains(cwd, "/crew/") {
parts := strings.Split(cwd, "/crew/")
if len(parts) >= 2 {
rigPath := parts[0]
crewPath := parts[1]
rig := filepath.Base(rigPath)
crew := strings.Split(crewPath, "/")[0]
return &agentIdentity{
FullIdentity: fmt.Sprintf("%s/crew/%s", rig, crew),
Rig: rig,
Role: "crew",
Molecule: getPinnedMolecule(),
}
}
}
// Polecat pattern
if strings.Contains(cwd, "/polecats/") {
parts := strings.Split(cwd, "/polecats/")
if len(parts) >= 2 {
rigPath := parts[0]
polecatPath := parts[1]
rig := filepath.Base(rigPath)
polecat := strings.Split(polecatPath, "/")[0]
return &agentIdentity{
FullIdentity: fmt.Sprintf("%s/%s", rig, polecat),
Rig: rig,
Role: "polecat",
Molecule: getPinnedMolecule(),
}
}
}
// Witness pattern
if strings.Contains(cwd, "/witness/") || strings.HasSuffix(cwd, "/witness") {
parts := strings.Split(cwd, "/witness")
if len(parts) >= 1 {
rig := filepath.Base(parts[0])
return &agentIdentity{
FullIdentity: fmt.Sprintf("%s/witness", rig),
Rig: rig,
Role: "witness",
}
}
}
// Refinery pattern
if strings.Contains(cwd, "/refinery/") || strings.HasSuffix(cwd, "/refinery") {
parts := strings.Split(cwd, "/refinery")
if len(parts) >= 1 {
rig := filepath.Base(parts[0])
return &agentIdentity{
FullIdentity: fmt.Sprintf("%s/refinery", rig),
Rig: rig,
Role: "refinery",
}
}
}
return nil
}
// getPinnedMolecule checks if there's a molecule attached via gt mol status.
func getPinnedMolecule() string {
// Try gt mol status --json
cmd := exec.Command("gt", "mol", "status", "--json")
out, err := cmd.Output()
if err != nil {
return ""
}
// Parse JSON response
var status struct {
HasMolecule bool `json:"has_molecule"`
MoleculeID string `json:"molecule_id"`
}
if err := json.Unmarshal(out, &status); err != nil {
return ""
}
if status.HasMolecule && status.MoleculeID != "" {
return status.MoleculeID
}
return ""
}
// =============================================================================
// Hook Helper Functions
// =============================================================================
@@ -673,6 +934,7 @@ Supported hooks:
- post-merge: Import JSONL after pull/merge
- pre-push: Prevent pushing stale JSONL
- post-checkout: Import JSONL after branch checkout
- prepare-commit-msg: Add agent identity trailers for forensics
The thin shim pattern ensures hook logic is always in sync with the
installed bd version - upgrading bd automatically updates hook behavior.`,
@@ -691,6 +953,8 @@ installed bd version - upgrading bd automatically updates hook behavior.`,
exitCode = runPrePushHook()
case "post-checkout":
exitCode = runPostCheckoutHook(hookArgs)
case "prepare-commit-msg":
exitCode = runPrepareCommitMsgHook(hookArgs)
default:
fmt.Fprintf(os.Stderr, "Unknown hook: %s\n", hookName)
os.Exit(1)

View File

@@ -0,0 +1,24 @@
#!/bin/sh
# bd-shim v1
# bd-hooks-version: 0.41.0
#
# bd (beads) prepare-commit-msg hook - thin shim
#
# This shim delegates to 'bd hooks run prepare-commit-msg' which contains
# the actual hook logic. This pattern ensures hook behavior is always
# in sync with the installed bd version - no manual updates needed.
#
# Arguments:
# $1 = path to the commit message file
# $2 = source of commit message (message, template, merge, squash, commit)
# $3 = commit SHA-1 (if -c, -C, or --amend)
# Check if bd is available
if ! command -v bd >/dev/null 2>&1; then
echo "Warning: bd command not found in PATH, skipping prepare-commit-msg hook" >&2
echo " Install bd: brew install steveyegge/tap/bd" >&2
echo " Or add bd to your PATH" >&2
exit 0
fi
exec bd hooks run prepare-commit-msg "$@"