a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
317 lines
8.8 KiB
Go
317 lines
8.8 KiB
Go
package hooks
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// PostTaskInput is the JSON structure Claude Code sends to Stop hooks.
|
|
// stop_hook_active is true when the hook is already being rerun (another
|
|
// Stop hook asked the agent to continue) — we must skip in that case to
|
|
// avoid recursion.
|
|
type PostTaskInput struct {
|
|
HookEventName string `json:"hook_event_name"`
|
|
SessionID string `json:"session_id"`
|
|
TranscriptPath string `json:"transcript_path"`
|
|
CWD string `json:"cwd"`
|
|
StopHookActive bool `json:"stop_hook_active"`
|
|
}
|
|
|
|
// runPostTask handles a Stop hook invocation with the raw stdin bytes.
|
|
// It runs diagnostics on any changed symbols (dead-code, test targets,
|
|
// guards, contract violations) and injects the findings as
|
|
// additionalContext so the agent self-corrects before handing off.
|
|
//
|
|
// Degrades silently when: the bridge is unreachable, there are no
|
|
// changes, or stop_hook_active is true.
|
|
func runPostTask(data []byte, port int) {
|
|
var input PostTaskInput
|
|
if err := json.Unmarshal(data, &input); err != nil {
|
|
return
|
|
}
|
|
if input.HookEventName != "Stop" {
|
|
return
|
|
}
|
|
// Prevent recursion — if we're already rerunning a Stop hook, don't fire again.
|
|
if input.StopHookActive {
|
|
return
|
|
}
|
|
|
|
briefing := buildPostTaskBriefing(port)
|
|
if briefing == "" {
|
|
return
|
|
}
|
|
|
|
output := HookOutput{
|
|
HookSpecificOutput: &HookSpecificOutput{
|
|
HookEventName: "Stop",
|
|
AdditionalContext: briefing,
|
|
},
|
|
}
|
|
out, err := json.Marshal(output)
|
|
if err != nil {
|
|
return
|
|
}
|
|
fmt.Print(string(out))
|
|
}
|
|
|
|
// buildPostTaskBriefing runs diagnostics on the current working tree and
|
|
// returns a compact markdown summary. Returns empty string when there's
|
|
// nothing to report or the bridge is unreachable.
|
|
func buildPostTaskBriefing(port int) string {
|
|
raw := callServerTool(port, "detect_changes", map[string]any{
|
|
"scope": "unstaged",
|
|
})
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
|
|
var changes struct {
|
|
ChangedFiles []string `json:"changed_files"`
|
|
ChangedSymbols []struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
} `json:"changed_symbols"`
|
|
Risk string `json:"risk"`
|
|
Summary string `json:"summary"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &changes); err != nil {
|
|
return ""
|
|
}
|
|
|
|
if len(changes.ChangedSymbols) == 0 {
|
|
// No indexed symbols touched — skip silently. The agent doesn't need
|
|
// a post-task briefing when nothing meaningful changed in the graph.
|
|
return ""
|
|
}
|
|
|
|
ids := make([]string, len(changes.ChangedSymbols))
|
|
for i, cs := range changes.ChangedSymbols {
|
|
ids[i] = cs.ID
|
|
}
|
|
idsCSV := strings.Join(ids, ",")
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("## Gortex Post-Task Diagnostics\n\n")
|
|
fmt.Fprintf(&sb, "**Changed:** %d symbols across %d files — risk `%s`.\n\n",
|
|
len(changes.ChangedSymbols), len(changes.ChangedFiles), changes.Risk)
|
|
|
|
// Test targets — what to run.
|
|
if tests := renderTestTargets(port, idsCSV); tests != "" {
|
|
sb.WriteString("### Tests to Run\n\n")
|
|
sb.WriteString(tests)
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Guard rule violations.
|
|
if guards := renderGuardViolations(port, idsCSV); guards != "" {
|
|
sb.WriteString("### Guard Violations\n\n")
|
|
sb.WriteString(guards)
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Dead code — specifically whether any of the changed symbols are now orphaned.
|
|
if dead := renderDeadCodeHits(port, ids); dead != "" {
|
|
sb.WriteString("### Potential Dead Code (among changed symbols)\n\n")
|
|
sb.WriteString(dead)
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Coverage gaps among changed symbols. Silent when the graph carries
|
|
// no coverage_pct meta (the analyzer returns "no coverage gaps
|
|
// matched" or just an empty body) — we don't want to nag when the
|
|
// repo simply doesn't run `gortex enrich coverage`.
|
|
if gaps := renderCoverageGapsHits(port, ids); gaps != "" {
|
|
sb.WriteString("### Coverage Gaps (among changed symbols)\n\n")
|
|
sb.WriteString(gaps)
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Stale-flag regression check. Silent unless one of the changed
|
|
// symbols is itself a flag site or toggles one — pointing the agent
|
|
// at flag rollouts they may have just touched.
|
|
if flags := renderStaleFlagHits(port, ids); flags != "" {
|
|
sb.WriteString("### Stale Flag Sites Touched\n\n")
|
|
sb.WriteString(flags)
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
// Contract mismatches.
|
|
if contracts := renderContractMismatches(port); contracts != "" {
|
|
sb.WriteString("### API Contract Issues\n\n")
|
|
sb.WriteString(contracts)
|
|
sb.WriteString("\n")
|
|
}
|
|
|
|
sb.WriteString("_Run the tests above and review any flagged items before handoff._\n")
|
|
return sb.String()
|
|
}
|
|
|
|
// renderTestTargets asks the bridge for test files that exercise the changed symbols.
|
|
func renderTestTargets(port int, idsCSV string) string {
|
|
raw := callServerTool(port, "get_test_targets", map[string]any{
|
|
"ids": idsCSV,
|
|
"compact": true,
|
|
})
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" || raw == "no test targets found" {
|
|
return ""
|
|
}
|
|
return cappedLines(raw, 15)
|
|
}
|
|
|
|
// renderGuardViolations asks the bridge for .gortex.yaml guard rule violations.
|
|
func renderGuardViolations(port int, idsCSV string) string {
|
|
raw := callServerTool(port, "check_guards", map[string]any{
|
|
"ids": idsCSV,
|
|
"compact": true,
|
|
})
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" || strings.HasPrefix(raw, "no guard rule violations") {
|
|
return ""
|
|
}
|
|
return cappedLines(raw, 10)
|
|
}
|
|
|
|
// renderDeadCodeHits filters analyze:dead_code results to the intersection
|
|
// with the currently-changed symbols (i.e. "did this task leave anything
|
|
// orphaned"). Emits nothing when the intersection is empty.
|
|
func renderDeadCodeHits(port int, ids []string) string {
|
|
raw := callServerTool(port, "analyze", map[string]any{
|
|
"kind": "dead_code",
|
|
"compact": true,
|
|
})
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
|
|
idSet := make(map[string]bool, len(ids))
|
|
for _, id := range ids {
|
|
idSet[id] = true
|
|
}
|
|
|
|
var hits []string
|
|
for _, line := range strings.Split(raw, "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
// Each compact line starts with the symbol descriptor including the
|
|
// file path — we substring-match any of the changed IDs.
|
|
for id := range idSet {
|
|
if strings.Contains(line, id) {
|
|
hits = append(hits, line)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if len(hits) == 0 {
|
|
return ""
|
|
}
|
|
if len(hits) > 8 {
|
|
hits = hits[:8]
|
|
}
|
|
return strings.Join(hits, "\n") + "\n"
|
|
}
|
|
|
|
// renderCoverageGapsHits filters analyze:coverage_gaps results to
|
|
// the intersection with currently-changed symbols. Silent when no
|
|
// changed symbol carries coverage_pct (i.e. coverage hasn't been
|
|
// hydrated for this graph) or none of the changed symbols are
|
|
// undertested. The agent gets a nudge precisely when a coverage
|
|
// gap was just touched — not a noisy package-wide rollup.
|
|
func renderCoverageGapsHits(port int, ids []string) string {
|
|
raw := callServerTool(port, "analyze", map[string]any{
|
|
"kind": "coverage_gaps",
|
|
"compact": true,
|
|
})
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" || strings.HasPrefix(raw, "no coverage gaps") {
|
|
return ""
|
|
}
|
|
|
|
idSet := make(map[string]bool, len(ids))
|
|
for _, id := range ids {
|
|
idSet[id] = true
|
|
}
|
|
var hits []string
|
|
for _, line := range strings.Split(raw, "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
for id := range idSet {
|
|
if strings.Contains(line, id) {
|
|
hits = append(hits, line)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if len(hits) == 0 {
|
|
return ""
|
|
}
|
|
if len(hits) > 8 {
|
|
hits = hits[:8]
|
|
}
|
|
return strings.Join(hits, "\n") + "\n"
|
|
}
|
|
|
|
// renderStaleFlagHits filters analyze:stale_flags results to the
|
|
// intersection with currently-changed symbols. Silent unless one
|
|
// of the changed symbols is a flag site (its id appears in the
|
|
// stale_flags output). Useful for catching a refactor that
|
|
// inadvertently touched a flag rollout that's been quiet for ages.
|
|
func renderStaleFlagHits(port int, ids []string) string {
|
|
raw := callServerTool(port, "analyze", map[string]any{
|
|
"kind": "stale_flags",
|
|
"compact": true,
|
|
})
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" || strings.HasPrefix(raw, "no stale flags") {
|
|
return ""
|
|
}
|
|
|
|
idSet := make(map[string]bool, len(ids))
|
|
for _, id := range ids {
|
|
idSet[id] = true
|
|
}
|
|
var hits []string
|
|
for _, line := range strings.Split(raw, "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
for id := range idSet {
|
|
if strings.Contains(line, id) {
|
|
hits = append(hits, line)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if len(hits) == 0 {
|
|
return ""
|
|
}
|
|
if len(hits) > 8 {
|
|
hits = hits[:8]
|
|
}
|
|
return strings.Join(hits, "\n") + "\n"
|
|
}
|
|
|
|
// renderContractMismatches runs the contracts check and returns a short list
|
|
// of orphan providers/consumers. Empty when all contracts are matched.
|
|
func renderContractMismatches(port int) string {
|
|
raw := callServerTool(port, "contracts", map[string]any{
|
|
"action": "check",
|
|
"compact": true,
|
|
})
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
// The compact summary prefixes "no contract issues" when clean.
|
|
if strings.HasPrefix(strings.ToLower(raw), "no contract") {
|
|
return ""
|
|
}
|
|
return cappedLines(raw, 10)
|
|
}
|