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
}