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:
274
cmd/bd/hooks.go
274
cmd/bd/hooks.go
@@ -19,7 +19,7 @@ var hooksFS embed.FS
|
|||||||
|
|
||||||
func getEmbeddedHooks() (map[string]string, error) {
|
func getEmbeddedHooks() (map[string]string, error) {
|
||||||
hooks := make(map[string]string)
|
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 {
|
for _, name := range hookNames {
|
||||||
content, err := hooksFS.ReadFile("templates/hooks/" + name)
|
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/
|
// CheckGitHooks checks the status of bd git hooks in .git/hooks/
|
||||||
func CheckGitHooks() []HookStatus {
|
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))
|
statuses := make([]HookStatus, 0, len(hooks))
|
||||||
|
|
||||||
// Get actual git directory (handles worktrees)
|
// Get actual git directory (handles worktrees)
|
||||||
@@ -169,7 +169,8 @@ The hooks ensure that:
|
|||||||
- pre-commit: Flushes pending changes to JSONL before commit
|
- pre-commit: Flushes pending changes to JSONL before commit
|
||||||
- post-merge: Imports updated JSONL after pull/merge
|
- post-merge: Imports updated JSONL after pull/merge
|
||||||
- pre-push: Prevents pushing stale JSONL
|
- 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{
|
var hooksInstallCmd = &cobra.Command{
|
||||||
@@ -185,7 +186,8 @@ Installed hooks:
|
|||||||
- pre-commit: Flush changes to JSONL before commit
|
- pre-commit: Flush changes to JSONL before commit
|
||||||
- post-merge: Import JSONL after pull/merge
|
- post-merge: Import JSONL after pull/merge
|
||||||
- pre-push: Prevent pushing stale JSONL
|
- 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) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
force, _ := cmd.Flags().GetBool("force")
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
shared, _ := cmd.Flags().GetBool("shared")
|
shared, _ := cmd.Flags().GetBool("shared")
|
||||||
@@ -374,7 +376,7 @@ func uninstallHooks() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
hooksDir := filepath.Join(gitDir, "hooks")
|
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 {
|
for _, hookName := range hookNames {
|
||||||
hookPath := filepath.Join(hooksDir, hookName)
|
hookPath := filepath.Join(hooksDir, hookName)
|
||||||
@@ -608,6 +610,265 @@ func runPostCheckoutHook(args []string) int {
|
|||||||
return 0
|
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
|
// Hook Helper Functions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -673,6 +934,7 @@ Supported hooks:
|
|||||||
- post-merge: Import JSONL after pull/merge
|
- post-merge: Import JSONL after pull/merge
|
||||||
- pre-push: Prevent pushing stale JSONL
|
- 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 forensics
|
||||||
|
|
||||||
The thin shim pattern ensures hook logic is always in sync with the
|
The thin shim pattern ensures hook logic is always in sync with the
|
||||||
installed bd version - upgrading bd automatically updates hook behavior.`,
|
installed bd version - upgrading bd automatically updates hook behavior.`,
|
||||||
@@ -691,6 +953,8 @@ installed bd version - upgrading bd automatically updates hook behavior.`,
|
|||||||
exitCode = runPrePushHook()
|
exitCode = runPrePushHook()
|
||||||
case "post-checkout":
|
case "post-checkout":
|
||||||
exitCode = runPostCheckoutHook(hookArgs)
|
exitCode = runPostCheckoutHook(hookArgs)
|
||||||
|
case "prepare-commit-msg":
|
||||||
|
exitCode = runPrepareCommitMsgHook(hookArgs)
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "Unknown hook: %s\n", hookName)
|
fmt.Fprintf(os.Stderr, "Unknown hook: %s\n", hookName)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
24
cmd/bd/templates/hooks/prepare-commit-msg
Executable file
24
cmd/bd/templates/hooks/prepare-commit-msg
Executable 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 "$@"
|
||||||
Reference in New Issue
Block a user