ajhahn.de
← eeco
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
}