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 // /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 }