ajhahn.de
← eeco
Go 393 lines
package cockpit

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/ajhahnde/eeco/internal/config"
)

// GenerateResult reports what Generate did. Action is one of "already
// current", "generated", "updated", or "regenerated". Fidelity is the
// target's harness-runtime enforcement (recomputed from the target, never
// persisted) so the message can flag an advisory emit.
type GenerateResult struct {
	Path     string
	Action   string
	Backup   string
	Fidelity Enforcement
}

// Message renders the human one-liner for a generate outcome. An advisory
// target appends a "not harness-enforced" note so the operator never mistakes
// the emit for an enforced policy.
func (r GenerateResult) Message() string {
	msg := r.Action + " " + r.Path
	if r.Backup != "" {
		msg += " (backup " + r.Backup + ")"
	}
	if r.Fidelity == EnforcementAdvisory {
		msg += " [advisory — not harness-enforced]"
	}
	return msg
}

// VerifyResult reports a verify outcome. Clean is true only when the
// on-disk artifact matches the freshly-rendered bytes, holds the safety
// invariant, and (when requested) passes parity. Detail is the line to
// print either way.
type VerifyResult struct {
	Clean  bool
	Detail string
}

// OffResult reports a removal outcome. Changed is true when something was
// removed or the ledger was updated (the caller commits workspace history
// only when Changed).
type OffResult struct {
	Changed bool
	Message string
}

// Generate renders pb for target and writes it under cfg.UserDir,
// reversibly. It refuses (writing nothing, no ledger) when the composed
// allowlist would grant a forbidden write-git verb — the safety invariant.
// A pre-existing foreign file is backed up first; re-emitting an unchanged
// artifact is a byte-idempotent no-op (no write, no backup, no ledger
// churn).
func Generate(cfg *config.Config, pb Playbook, target string) (GenerateResult, error) {
	r, ok := rendererFor(target)
	if !ok {
		return GenerateResult{}, unknownTargetErr(target)
	}
	if _, agg := isAggregate(r); agg {
		return GenerateResult{}, fmt.Errorf("target %q is aggregate (one shared file for the set); emit it via `eeco cockpit generate --target %s` without --playbook", target, target)
	}
	content, err := r.Render(pb)
	if err != nil {
		return GenerateResult{}, err
	}
	if hits := ScanAllowlistForWriteGitVerbs(composeAllowedTools(pb), pb.Intent.forbiddenVerbs()); len(hits) > 0 {
		return GenerateResult{}, fmt.Errorf(
			"refusing to emit %s/%s: forbidden write-git verb(s) in allowlist: %s",
			target, pb.Name, strings.Join(hits, ", "))
	}

	dst, err := userArtifactPath(cfg, r.RelPath(pb))
	if err != nil {
		return GenerateResult{}, err
	}
	newSHA := sha256hex(content)

	l, err := loadLedger(cfg)
	if err != nil {
		return GenerateResult{}, err
	}
	priorIdx := l.find(target, pb.Name)
	hasPrior := priorIdx >= 0 && l.Records[priorIdx].Installed
	var prior record
	if priorIdx >= 0 {
		prior = l.Records[priorIdx]
	}

	// Idempotency: an installed record whose recorded sha and on-disk sha
	// both equal the freshly-rendered sha means nothing to do.
	if hasPrior && prior.SHA256 == newSHA {
		if onDisk, rerr := os.ReadFile(dst); rerr == nil && sha256hex(onDisk) == newSHA {
			return GenerateResult{Path: dst, Action: "already current", Fidelity: fidelityOf(r)}, nil
		}
	}

	leafDir := filepath.Dir(dst)
	createdDir := prior.Created
	backup := prior.Backup
	if !hasPrior {
		// No prior eeco artifact here. A file present now is foreign — back
		// it up — and the leaf dir is "created by us" only if it is absent.
		_, statErr := os.Stat(leafDir)
		createdDir = errors.Is(statErr, os.ErrNotExist)
		if existing, rerr := os.ReadFile(dst); rerr == nil {
			bp, berr := backupExisting(cfg, target, pb.Name, existing)
			if berr != nil {
				return GenerateResult{}, berr
			}
			backup = bp
		} else if !errors.Is(rerr, os.ErrNotExist) {
			return GenerateResult{}, fmt.Errorf("inspect %s: %w", dst, rerr)
		}
	}

	if err := writeFileAtomic(dst, content, 0o644); err != nil {
		return GenerateResult{}, err
	}

	l.upsert(record{
		Installed: true,
		Target:    target,
		Playbook:  pb.Name,
		Path:      dst,
		SHA256:    newSHA,
		Backup:    backup,
		Created:   createdDir,
		At:        time.Now().UTC().Format(time.RFC3339),
	})
	if err := saveLedger(cfg, l); err != nil {
		return GenerateResult{}, err
	}

	action := "generated"
	switch {
	case hasPrior:
		action = "regenerated"
	case backup != "":
		action = "updated"
	}
	return GenerateResult{Path: dst, Action: action, Backup: backup, Fidelity: fidelityOf(r)}, nil
}

// Verify recomputes the desired bytes for pb/target and checks the on-disk
// artifact against them, plus the safety invariant on the on-disk
// allowlist. When parityKey is non-empty it also runs the structural parity
// check against that answer-key SKILL.md. It never mutates anything.
func Verify(cfg *config.Config, pb Playbook, target, parityKey string) (VerifyResult, error) {
	r, ok := rendererFor(target)
	if !ok {
		return VerifyResult{}, unknownTargetErr(target)
	}
	if _, agg := isAggregate(r); agg {
		return VerifyResult{}, fmt.Errorf("target %q is aggregate; verify it via `eeco cockpit verify --target %s` without --playbook", target, target)
	}
	desired, err := r.Render(pb)
	if err != nil {
		return VerifyResult{}, err
	}
	dst, err := userArtifactPath(cfg, r.RelPath(pb))
	if err != nil {
		return VerifyResult{}, err
	}
	onDisk, rerr := os.ReadFile(dst)
	if errors.Is(rerr, os.ErrNotExist) {
		return VerifyResult{Clean: false, Detail: fmt.Sprintf("%s/%s: not emitted (run `eeco cockpit generate`)", target, pb.Name)}, nil
	}
	if rerr != nil {
		return VerifyResult{}, fmt.Errorf("read %s: %w", dst, rerr)
	}
	// Advisory per-playbook targets (cursor) have no SKILL.md allowlist and no
	// answer key: the on-disk safety check is self-consistency, asserted on
	// the literal on-disk bytes (S4) so a renderer regression or hand-edit
	// that drops the Forbidden block fails. Drift (any hand-edit) is reported
	// first.
	if fidelityOf(r) == EnforcementAdvisory {
		if sha256hex(onDisk) != sha256hex(desired) {
			return VerifyResult{Clean: false, Detail: fmt.Sprintf(
				"%s/%s: drifted (hand-edited; run `eeco cockpit generate` to restore)", target, pb.Name)}, nil
		}
		sc := checkSelfConsistencyBytes(onDisk, []Playbook{pb})
		if !sc.OK {
			return VerifyResult{Clean: false, Detail: fmt.Sprintf(
				"%s/%s: self-consistency FAILED: %s", target, pb.Name, strings.Join(sc.Notes, "; "))}, nil
		}
		return VerifyResult{Clean: true, Detail: fmt.Sprintf("%s/%s: clean (advisory — not harness-enforced)", target, pb.Name)}, nil
	}
	// Enforced target (claude): on-disk allowlist safety scan first (C1 order),
	// then drift, then optional parity.
	if hits := ScanAllowlistForWriteGitVerbs(parseAllowedTools(onDisk), pb.Intent.forbiddenVerbs()); len(hits) > 0 {
		return VerifyResult{Clean: false, Detail: fmt.Sprintf(
			"%s/%s: SAFETY VIOLATION — forbidden write-git verb(s) on disk: %s",
			target, pb.Name, strings.Join(hits, ", "))}, nil
	}
	if sha256hex(onDisk) != sha256hex(desired) {
		return VerifyResult{Clean: false, Detail: fmt.Sprintf(
			"%s/%s: drifted (hand-edited; run `eeco cockpit generate` to restore)", target, pb.Name)}, nil
	}
	if parityKey != "" {
		pr, perr := Parity(pb, target, parityKey)
		if perr != nil {
			return VerifyResult{}, perr
		}
		if !pr.OK() {
			return VerifyResult{Clean: false, Detail: fmt.Sprintf(
				"%s/%s: clean, but parity FAILED vs %s: %s", target, pb.Name, parityKey, strings.Join(pr.Notes, "; "))}, nil
		}
		return VerifyResult{Clean: true, Detail: fmt.Sprintf("%s/%s: clean + parity OK vs %s", target, pb.Name, parityKey)}, nil
	}
	return VerifyResult{Clean: true, Detail: fmt.Sprintf("%s/%s: clean", target, pb.Name)}, nil
}

// Off removes eeco's emitted artifact, sha-gated and reversible. A
// hand-edited file (on-disk sha != recorded sha) is left untouched. When
// the artifact matches, it is removed; a backed-up pre-eeco file is
// restored, otherwise a leaf skill dir eeco created is pruned. A missing
// file or absent record is a clean no-op.
func Off(cfg *config.Config, pb Playbook, target string) (OffResult, error) {
	r, ok := rendererFor(target)
	if !ok {
		return OffResult{}, unknownTargetErr(target)
	}
	if _, agg := isAggregate(r); agg {
		return OffResult{}, fmt.Errorf("target %q is aggregate; remove it via `eeco cockpit off --target %s` without --playbook", target, target)
	}
	dst, err := userArtifactPath(cfg, r.RelPath(pb))
	if err != nil {
		return OffResult{}, err
	}
	l, err := loadLedger(cfg)
	if err != nil {
		return OffResult{}, err
	}
	i := l.find(target, pb.Name)
	if i < 0 || !l.Records[i].Installed {
		return OffResult{Changed: false, Message: fmt.Sprintf("%s/%s: not emitted", target, pb.Name)}, nil
	}
	rec := l.Records[i]

	onDisk, rerr := os.ReadFile(dst)
	if errors.Is(rerr, os.ErrNotExist) {
		l.clear(target, pb.Name)
		if err := saveLedger(cfg, l); err != nil {
			return OffResult{}, err
		}
		return OffResult{Changed: true, Message: fmt.Sprintf("%s/%s: already removed; ledger cleared", target, pb.Name)}, nil
	}
	if rerr != nil {
		return OffResult{}, fmt.Errorf("read %s: %w", dst, rerr)
	}
	if sha256hex(onDisk) != rec.SHA256 {
		return OffResult{Changed: false, Message: fmt.Sprintf("%s/%s: edited since generate — left untouched", target, pb.Name)}, nil
	}

	switch {
	case rec.Backup != "":
		// Restore the pre-eeco file by atomically replacing eeco's artifact
		// (writeFileAtomic does temp + rename), so there is never a window
		// where the path is absent: a failed restore leaves eeco's artifact
		// in place and the ledger untouched, so off stays retryable. If the
		// backup is unreadable, remove the artifact rather than leave it.
		if bb, berr := os.ReadFile(rec.Backup); berr == nil {
			if werr := writeFileAtomic(dst, bb, 0o644); werr != nil {
				return OffResult{}, werr
			}
		} else if rerr := os.Remove(dst); rerr != nil {
			return OffResult{}, fmt.Errorf("remove %s: %w", dst, rerr)
		}
	default:
		if err := os.Remove(dst); err != nil {
			return OffResult{}, fmt.Errorf("remove %s: %w", dst, err)
		}
		if rec.Created {
			// Prune the leaf skill dir only if eeco created it and it is now
			// empty; os.Remove fails (and is ignored) on a non-empty dir.
			_ = os.Remove(filepath.Dir(dst))
		}
	}
	l.clear(target, pb.Name)
	if err := saveLedger(cfg, l); err != nil {
		return OffResult{}, err
	}
	return OffResult{Changed: true, Message: fmt.Sprintf("%s/%s: removed (reversed)", target, pb.Name)}, nil
}

// Status returns one line per ledger record reflecting on-disk reality, so
// a hand-removed or hand-edited artifact reads honestly. With no records it
// reports the C1 surface as not emitted.
func Status(cfg *config.Config) []string {
	l, _ := loadLedger(cfg)
	if len(l.Records) == 0 {
		return []string{"claude/handover: not emitted"}
	}
	lines := make([]string, 0, len(l.Records))
	for _, rec := range l.Records {
		state := recordStatus(rec)
		if rec.Playbook == "" {
			// Aggregate record (AGENTS.md / GEMINI.md): keyed on target alone.
			lines = append(lines, fmt.Sprintf("%s: %s (aggregate, ADVISORY)", rec.Target, state))
			continue
		}
		line := rec.Target + "/" + rec.Playbook + ": " + state
		if enf, ok := TargetFidelity(rec.Target); ok && enf == EnforcementAdvisory {
			line += " (advisory)"
		}
		lines = append(lines, line)
	}
	return lines
}

func recordStatus(rec record) string {
	b, err := os.ReadFile(rec.Path)
	if errors.Is(err, os.ErrNotExist) {
		return "off"
	}
	if err != nil {
		return "unknown (" + err.Error() + ")"
	}
	if sha256hex(b) == rec.SHA256 {
		return "on"
	}
	return "off (edited)"
}

// backupExisting copies a pre-existing artifact into
// <workspace>/state/backups before it is overwritten, mirroring the hooks
// package's backup discipline (inside the workspace, never beside the
// target file).
func backupExisting(cfg *config.Config, target, playbook string, orig []byte) (string, error) {
	dir := filepath.Join(cfg.Workspace, "state", "backups")
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return "", fmt.Errorf("backup dir: %w", err)
	}
	name := fmt.Sprintf("cockpit-%s-%s-%s.md", target, playbook, time.Now().UTC().Format("20060102T150405.000000000Z"))
	bp := filepath.Join(dir, name)
	if err := os.WriteFile(bp, orig, 0o644); err != nil {
		return "", fmt.Errorf("write backup: %w", err)
	}
	return bp, nil
}

// userArtifactPath joins a renderer's relative artifact path to cfg.UserDir
// after the write-scope-floor guard (relUnder), so a renderer can never write
// outside the gitignored private tree (an absolute or "../"-escaping RelPath
// is rejected, not silently joined).
func userArtifactPath(cfg *config.Config, rel string) (string, error) {
	clean, err := relUnder(rel)
	if err != nil {
		return "", err
	}
	return filepath.Join(cfg.UserDir, clean), nil
}

// writeFileAtomic mirrors internal/hooks' same-directory temp + rename
// discipline so a crash mid-write cannot leave a truncated artifact.
func writeFileAtomic(path string, content []byte, perm os.FileMode) error {
	dir := filepath.Dir(path)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return fmt.Errorf("ensure dir %s: %w", dir, err)
	}
	tmp, err := os.CreateTemp(dir, ".eeco-cockpit-*")
	if err != nil {
		return fmt.Errorf("temp file: %w", err)
	}
	tmpName := tmp.Name()
	defer os.Remove(tmpName)
	if _, werr := tmp.Write(content); werr != nil {
		tmp.Close()
		return fmt.Errorf("write temp file: %w", werr)
	}
	if cerr := tmp.Close(); cerr != nil {
		return fmt.Errorf("close temp file: %w", cerr)
	}
	if perm == 0 {
		perm = 0o644
	}
	if cherr := os.Chmod(tmpName, perm); cherr != nil {
		return fmt.Errorf("chmod temp file: %w", cherr)
	}
	if rerr := os.Rename(tmpName, path); rerr != nil {
		return fmt.Errorf("replace file: %w", rerr)
	}
	return nil
}