Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions cmd/entire/cli/runner_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,234 @@ package cli

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"

"github.com/entireio/cli/cmd/entire/cli/entiredir"
"github.com/entireio/cli/cmd/entire/cli/paths"

"github.com/sergi/go-diff/diffmatchpatch"
)

// tunedRunner is one accepted rewrite: the runner, its new file bytes, and the
// new template on its own. The template is kept because --dry-run diffs the
// template rather than the JSON file.
type tunedRunner struct {
runner tuneRunner
newRaw []byte
template string
}

// runTuning runs the prompt through an already-resolved summary provider
// (prompt -> text) and turns the runner-id -> template map it returns into
// accepted rewrites. Rejections — out of scope, invalid template, unpatchable
// file — are reported on errW and counted rather than returned, so one bad
// runner does not sink the rest. Nothing is written here: the caller chooses
// between applying the changes and previewing them.
//
// The provider is resolved by the caller, before it gathers repository signal:
// resolution can fail outright or stop to ask which provider to use, and
// neither belongs after the seconds the gather costs.
func runTuning(ctx context.Context, errW io.Writer, provider *checkpointSummaryProvider, runners []tuneRunner, prompt, debugDir string) (changes []tunedRunner, skipped int, err error) {
// provider.TextGenerator is the plain prompt->text generator, and is
// guaranteed non-nil: its constructor fails when the agent has none.
// provider.Generator is deliberately not used — that is a
// summarize.Generator, which turns a transcript into a checkpoint Summary.
stop := startSpinner(errW, fmt.Sprintf("Tuning %d runner(s) with %s", len(runners), provider.DisplayName))
out, err := provider.TextGenerator.GenerateText(ctx, prompt, provider.Model)
stop(err == nil)
if err != nil {
return nil, 0, fmt.Errorf("agent run failed: %w", err)
}
if debugDir != "" {
writeTuneDebug(errW, debugDir, "response.txt", out)
}

templates, err := parseTuneOutput(out)
if err != nil {
return nil, 0, err
}
changes, skipped = classifyTuneProposals(errW, runners, templates)
return changes, skipped, nil
}

// classifyTuneProposals turns the model's runner-id -> template map into the
// rewrites that will actually be used, reporting each rejection on errW and
// counting it. It is separate from the provider call so the accept/reject rules
// can be tested against canned proposals.
//
// A rejection is never fatal: one unusable proposal must not cost the other
// runners their tailoring.
func classifyTuneProposals(errW io.Writer, runners []tuneRunner, templates map[string]string) (changes []tunedRunner, skipped int) {
byID := make(map[string]tuneRunner, len(runners))
for _, r := range runners {
byID[normalizeRunnerID(r.ID)] = r
}

// Sorted so the skip/note messages and the preview diff come out in a stable
// order rather than in Go's randomized map order.
for _, id := range slices.Sorted(maps.Keys(templates)) {
tmpl := templates[id]
r, ok := byID[normalizeRunnerID(id)]
if !ok {
fmt.Fprintf(errW, "skip %q: not a runner in scope\n", id)
skipped++
continue
}
if err := validateNewTemplate(r.Template, tmpl); err != nil {
fmt.Fprintf(errW, "skip %s: %v\n", r.ID, err)
skipped++
continue
}
if dropped := droppedPlaceholders(r.Template, tmpl); len(dropped) > 0 {
fmt.Fprintf(errW, "note: %s no longer references %v\n", r.ID, dropped)
}
newRaw, err := replaceRunnerTemplate(r.Raw, tmpl)
if err != nil {
fmt.Fprintf(errW, "skip %s: %v\n", r.ID, err)
skipped++
continue
}
if bytes.Equal(newRaw, r.Raw) {
continue // model returned the current template verbatim — benign no-op
}
changes = append(changes, tunedRunner{runner: r, newRaw: newRaw, template: tmpl})
}
return changes, skipped
}

// applyTunedRunners writes each accepted rewrite over its runner file.
// createdIDs are runners this invocation just scaffolded from defaults; any of
// those left un-tailored is flagged so it is not committed as if it were
// repo-specific.
func applyTunedRunners(w, errW io.Writer, repoRoot string, changes []tunedRunner, skipped int, createdIDs []string) error {
root, err := entiredir.OpenAt(repoRoot)
if err != nil {
return fmt.Errorf("open %s: %w", paths.EntireDir, err)
}

tailored := make(map[string]bool, len(changes))
for _, c := range changes {
if err := entiredir.WriteFile(root, c.runner.Name, c.newRaw, 0o644); err != nil {
return fmt.Errorf("writing %s: %w", c.runner.Path, err)
}
fmt.Fprintf(w, "updated %s\n", filepath.Base(c.runner.Path))
tailored[normalizeRunnerID(c.runner.ID)] = true
}

switch {
case len(changes) > 0:
fmt.Fprintf(w, "\nUpdated %d runner(s). Review with: git diff %s\n",
len(changes), filepath.Join(paths.EntireDir, runnersName))
case len(createdIDs) == 0 && skipped > 0:
// Existing runners, model proposed templates, all rejected — a failed run.
// (When onboarding just created the set, an un-tailored runner is reported
// below as a generic default instead, which is more actionable.)
return fmt.Errorf("model proposed %d template(s) but all were rejected or out of scope (see messages above)", skipped)
case len(createdIDs) == 0:
fmt.Fprintln(w, "No runner changes proposed.")
}

// Runners onboarding scaffolded but tailoring did not change remain the
// generic defaults. Those are working minimal prompts (valid output
// contract), so they are committable as-is — just note which are generic.
if untailored := untailoredRunners(createdIDs, tailored); len(untailored) > 0 {
fmt.Fprintf(errW, "\n%d runner(s) kept as working defaults (generic, not tailored to this repo): %s\n",
len(untailored), strings.Join(untailored, ", "))
fmt.Fprintln(errW, "They are functional as-is; re-run `entire runner setup -y` to tailor them.")
}
return nil
}

// previewTunedRunners prints what tailoring would change and writes nothing.
// It diffs each runner's prompt template rather than its JSON file: the
// template is the only field that changes and it is stored as one long JSON
// string, so a file-level diff would be a single unreadable line.
func previewTunedRunners(w, errW io.Writer, inScope int, changes []tunedRunner, skipped int) {
for _, c := range changes {
fmt.Fprintf(w, "=== %s — prompt.template would change ===\n", c.runner.ID)
fmt.Fprint(w, renderTemplateDiff(c.runner.Template, c.template))
fmt.Fprintln(w)
}

switch {
case len(changes) > 0:
fmt.Fprintf(errW, "%d of %d runner(s) would change", len(changes), inScope)
if skipped > 0 {
fmt.Fprintf(errW, ", %d proposal(s) rejected", skipped)
}
fmt.Fprintln(errW, ". Nothing was written — re-run with --yes to apply.")
case skipped > 0:
fmt.Fprintf(errW, "No runner would change: all %d proposal(s) were rejected or out of scope (see messages above).\n", skipped)
default:
fmt.Fprintln(errW, "No runner changes proposed.")
}
}

// diffContextLines is how many unchanged template lines to keep either side of
// a change in the --dry-run preview. A tailored template is mostly rewritten,
// so the point of the collapse is the long unchanged tail (the output-JSON
// contract), not economy on the changed part.
const diffContextLines = 3

// renderTemplateDiff renders a line-level diff of two prompt templates, with
// unchanged runs longer than twice the context collapsed to a count.
// diffmatchpatch is character-oriented, so the templates are folded to one
// char per line first (the DiffLinesToChars/DiffCharsToLines pattern, as in
// strategy/manual_commit_attribution.go).
func renderTemplateDiff(oldText, newText string) string {
dmp := diffmatchpatch.New()
a, b, lines := dmp.DiffLinesToChars(oldText, newText)
diffs := dmp.DiffCharsToLines(dmp.DiffMain(a, b, false), lines)

var out strings.Builder
for _, d := range diffs {
switch d.Type {
case diffmatchpatch.DiffInsert:
writeDiffLines(&out, "+", splitLines(d.Text))
case diffmatchpatch.DiffDelete:
writeDiffLines(&out, "-", splitLines(d.Text))
case diffmatchpatch.DiffEqual:
ls := splitLines(d.Text)
if len(ls) <= 2*diffContextLines {
writeDiffLines(&out, " ", ls)
continue
}
writeDiffLines(&out, " ", ls[:diffContextLines])
fmt.Fprintf(&out, "@@ %d unchanged line(s) @@\n", len(ls)-2*diffContextLines)
writeDiffLines(&out, " ", ls[len(ls)-diffContextLines:])
}
}
return out.String()
}

func writeDiffLines(out *strings.Builder, prefix string, lines []string) {
for _, line := range lines {
out.WriteString(prefix)
out.WriteString(line)
out.WriteByte('\n')
}
}

// splitLines splits a diff chunk into lines, dropping the empty element a
// trailing newline produces so it is not rendered as a blank diff line. The
// empty-string guard matters: Split("") is [""], which would render one blank.
func splitLines(s string) []string {
if s == "" {
return nil
}
return strings.Split(strings.TrimSuffix(s, "\n"), "\n")
}

// parseTuneOutput extracts the runner-id -> new-template map the tuning model
// is instructed to emit as a single JSON object. The model may wrap the object
// in prose or code fences, so we slice from the first "{" to the last "}". An
Expand Down
89 changes: 39 additions & 50 deletions cmd/entire/cli/runner_init.go
Original file line number Diff line number Diff line change
@@ -1,51 +1,37 @@
package cli

import (
"errors"
"fmt"
"io"
"path/filepath"
"strings"

"github.com/entireio/cli/cmd/entire/cli/entiredir"
"github.com/entireio/cli/cmd/entire/cli/interactive"
"github.com/entireio/cli/cmd/entire/cli/osroot"
"github.com/entireio/cli/cmd/entire/cli/paths"
"github.com/entireio/cli/cmd/entire/cli/runnerdefaults"

"charm.land/huh/v2"
)

// ensureRunnersPresent scaffolds the default runner set when a repo has none
// yet, so `tune` doubles as onboarding. It returns the IDs it created (nil when
// runners already existed) so the caller can flag any that tuning then leaves
// un-tailored. It is a no-op when runners already exist, and errors when the
// user declined or creation failed. Writing is gated on confirmation
// (interactive prompt, or the --yes flag for non-interactive runs).
func ensureRunnersPresent(w, errW io.Writer, repoRoot string, assumeYes bool) (created []string, err error) {
// createDefaultRunners writes the default runner set, so setup doubles as
// onboarding. It returns the IDs it created, so the caller can flag any that
// tailoring then leaves un-tailored.
//
// Two things it deliberately does not do. It does not ask — consent belongs to
// the mode decision in resolveRunnerSetupMode, so one answer covers every
// prompt the command can raise, and runnerSetupMode.writesRunnerFiles is what
// gates the call. And it does not re-check whether runners already exist: the
// caller has that answer already, and deriving it twice gave the invariant two
// homes that could drift.
func createDefaultRunners(w io.Writer, repoRoot string) (created []string, err error) {
dir := runnersDir(repoRoot)
if runnerConfigsExist(repoRoot) {
return nil, nil
}

defaults, err := runnerdefaults.Files()
if err != nil {
return nil, fmt.Errorf("loading default runners: %w", err)
}

if !assumeYes {
if !interactive.CanPromptInteractively() {
return nil, fmt.Errorf("no runner configs found under %s; re-run with --yes to create the default set (%d runners)", dir, len(defaults))
}
confirmed, err := confirmCreateRunners(len(defaults))
if err != nil {
return nil, err
}
if !confirmed {
return nil, errors.New("no runner configs created (declined)")
}
}

root, err := entiredir.OpenAt(repoRoot)
if err != nil {
return nil, fmt.Errorf("creating %s: %w", dir, err)
Expand All @@ -61,7 +47,6 @@ func ensureRunnersPresent(w, errW io.Writer, repoRoot string, assumeYes bool) (c
fmt.Fprintf(w, "created %s\n", filepath.Join(paths.EntireDir, runnersName, f.Name))
created = append(created, strings.TrimSuffix(f.Name, ".json"))
}
fmt.Fprintf(errW, "Created %d default runner(s); tailoring them to this repo…\n", len(defaults))
return created, nil
}

Expand All @@ -85,36 +70,40 @@ func runnerConfigsExist(repoRoot string) bool {
return false
}

func confirmCreateRunners(n int) (bool, error) {
var ok bool
form := NewAccessibleForm(
huh.NewGroup(
huh.NewConfirm().
Title(fmt.Sprintf("No trail runners found. Create the default set (%d runners) in .entire/runners/?", n)).
Description("Written from the built-in defaults, then tailored to this repo.").
Value(&ok),
),
)
if err := form.Run(); err != nil {
return false, fmt.Errorf("runner-creation prompt cancelled: %w", err)
// chooseRunnerSetupAction asks a terminal what setup should do. The choice is
// the user's own framing of the command — generic defaults, or defaults
// tailored to this repo — and it is asked once, whether or not the repo already
// has runners, so that the answer settles both creating and tailoring.
// Cancelling the form yields setupModeNone, which authorizes nothing.
func chooseRunnerSetupAction(errW io.Writer, haveRunners bool) (runnerSetupMode, error) {
title := "Runners are already configured for this repo. Tailor them to this repo now?"
adaptLabel := "Tailor them to this repo"
keepLabel := "Leave them as they are"
if !haveRunners {
defaults, err := runnerdefaults.Files()
if err != nil {
return setupModeNone, fmt.Errorf("loading default runners: %w", err)
}
title = fmt.Sprintf("No trail runners found. What should setup do? (%d runners)", len(defaults))
adaptLabel = "Create the defaults and tailor them to this repo"
keepLabel = "Create the generic defaults only"
}
return ok, nil
}

// confirmTuneExisting asks whether to re-tailor runners that already exist —
// the re-run case where there is nothing to scaffold.
func confirmTuneExisting() (bool, error) {
var ok bool
mode := setupModeAdapt
form := NewAccessibleForm(
huh.NewGroup(
huh.NewConfirm().
Title("Runners are already configured for this repo. Tune them to this repo now?").
Description("Re-tailors the runner prompts using fresh repository signal.").
Value(&ok),
huh.NewSelect[runnerSetupMode]().
Title(title).
Description("Tailoring rewrites each runner's prompt from this repo's docs, history and past findings — one call to your configured summary provider.").
Options(
huh.NewOption(adaptLabel, setupModeAdapt),
huh.NewOption(keepLabel, setupModeDefaults),
).
Value(&mode),
),
)
if err := form.Run(); err != nil {
return false, fmt.Errorf("runner-tune prompt cancelled: %w", err)
return setupModeNone, handleFormCancellation(errW, "Runner setup", err)
Comment thread
Soph marked this conversation as resolved.
}
return ok, nil
return mode, nil
}
Loading
Loading