Go 129 lines
package hooks
import (
"os"
"path/filepath"
"time"
"github.com/ajhahnde/eeco/internal/cockpit"
"github.com/ajhahnde/eeco/internal/config"
)
// The contract-watch / doc-drift-nudge pair is the cockpit machinery's
// event-driven drift signal: a PostToolUse edit to a cockpit input drops a
// flag (ContractWatch), and the next SessionStart consumes it into a one-time
// doc-drift nudge (DocDriftNudge) — so the drift check fires on real change,
// not a blind timer (a weekly backstop still catches silent drift). Both ends
// live here so the flag names stay in one place.
const (
// contractChangedFlag marks that a watched cockpit input changed since the
// last session; DocDriftNudge consumes it.
contractChangedFlag = "contract-changed"
// cockpitDirtyFlag is the companion marker for the cockpit specifically,
// dropped alongside contractChangedFlag.
cockpitDirtyFlag = "cockpit-dirty"
// docDriftStampName throttles the doc-drift nudge's time backstop.
docDriftStampName = "doc-drift.last"
)
// docDriftBackstop is the time backstop for the doc-drift nudge when no
// contract-changed flag is present: docs drift on code change, but a weekly
// backstop still catches drift no edit announced.
const docDriftBackstop = 7 * 24 * time.Hour
// ClearGitWriteSentinels removes both one-shot git-write authorization
// sentinels under <workspace>/state, so no new session inherits a standing
// authorization left over from a prior one (security-critical — it pairs with
// the C4a git-write guard). Called from runSessionEmit before the pure Emit.
// Missing sentinels are not an error.
func ClearGitWriteSentinels(cfg *config.Config) {
stateDir := filepath.Join(cfg.Workspace, "state")
for _, kind := range []string{"commit", "tag"} {
_ = os.Remove(filepath.Join(stateDir, "git-"+kind+"-authorized"))
}
}
// DocDriftNudge decides whether session start should nudge a doc/cockpit-drift
// check. It fires when a cockpit input changed since the last session (the
// contract-watch PostToolUse hook dropped the contract-changed flag) OR the
// weekly backstop elapsed. On a fire it writes the throttle stamp and clears
// the flags (so the nudge is one-shot per change), returning the nudge line.
// It performs WRITES, so runSessionEmit calls it around the pure Emit, never
// from inside Emit. It never errors.
func DocDriftNudge(cfg *config.Config, now time.Time) (line string, fire bool) {
stateDir := filepath.Join(cfg.Workspace, "state")
flag := filepath.Join(stateDir, contractChangedFlag)
stamp := filepath.Join(stateDir, docDriftStampName)
flagged := fileExists(flag)
if !flagged && !throttleElapsed(stamp, now, docDriftBackstop) {
return "", false
}
// A backstop-only trigger (no explicit contract change) stays silent unless
// the cockpit is actually generated here — otherwise there is nothing to
// drift-check, and session start must not nag an unrelated repo. (The flag
// path needs no such gate: the contract-changed flag can only be dropped by
// the machinery's PostToolUse hook, which implies the cockpit is in use.)
if !flagged && !cockpit.IsGenerated(cfg) {
return "", false
}
trigger := "the weekly backstop is due"
if flagged {
trigger = "a cockpit input (cockpit.json / config.local) changed since the last session"
}
writeStamp(stamp, now)
_ = os.Remove(flag)
_ = os.Remove(filepath.Join(stateDir, cockpitDirtyFlag))
return "[eeco maintenance] run a doc/cockpit drift check (`eeco cockpit verify`): " + trigger +
". Report the verdict (one line if clean), then carry on.", true
}
// ContractWatch is the PostToolUse side-effect: when the edited file is a
// cockpit input (the selection store <workspace>/cockpit.json or
// <workspace>/config.local), it drops the contract-changed + cockpit-dirty
// flags under <workspace>/state so the next SessionStart orient nudges a drift
// check. filePath is the absolute path the tool wrote (from the PostToolUse
// event); a blank or non-matching path is a no-op. It never blocks and never
// errors — a flag write that fails is simply skipped. Returns whether a flag
// was dropped (for tests / the runner's accounting).
func ContractWatch(cfg *config.Config, filePath string) bool {
if filePath == "" || !isWatchedInput(cfg, filePath) {
return false
}
stateDir := filepath.Join(cfg.Workspace, "state")
if err := os.MkdirAll(stateDir, 0o755); err != nil {
return false
}
wrote := false
for _, name := range []string{contractChangedFlag, cockpitDirtyFlag} {
if err := os.WriteFile(filepath.Join(stateDir, name), nil, 0o644); err == nil {
wrote = true
}
}
return wrote
}
// isWatchedInput reports whether path (the tool's edited file) is a cockpit
// input whose change should trigger a drift re-check: the selection store or
// config.local. Matching is on the cleaned absolute path so a relative or loose
// form still resolves.
func isWatchedInput(cfg *config.Config, path string) bool {
abs := path
if !filepath.IsAbs(abs) {
if a, err := filepath.Abs(abs); err == nil {
abs = a
}
}
abs = filepath.Clean(abs)
watched := []string{
cockpit.SelectionPath(cfg),
filepath.Join(cfg.Workspace, "config.local"),
}
for _, w := range watched {
if filepath.Clean(w) == abs {
return true
}
}
return false
}