ajhahn.de
← eeco
Go 696 lines
// Package brief renders a deterministic, no-AI-spend project brief for
// an AI assistant: what eeco is, the shape of the project, where to look
// for detail, what eeco already knows, and the open decisions.
//
// It is the engine behind `eeco go`. The brief lets any assistant —
// not only the strongest — pick up a project quickly and cheaply: one
// command returns a compact map instead of a scan across many files.
//
// The package only reads — the resolved config, the memory store, and
// the queue file — and writes nothing. The output carries no timestamp
// and lists facts in the store's stable sort order, so it is
// reproducible and safe to snapshot in a golden test.
//
// Collect gathers the brief once into a Data value; Render turns that
// value into the Markdown brief and RenderJSON into a JSON object, so
// the two representations always describe the same project state.
package brief

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"github.com/ajhahnde/eeco/internal/config"
	"github.com/ajhahnde/eeco/internal/gitx"
	"github.com/ajhahnde/eeco/internal/memory"
	"github.com/ajhahnde/eeco/internal/notes"
	"github.com/ajhahnde/eeco/internal/queue"
	"github.com/ajhahnde/eeco/internal/workflow"
)

// nowFunc is the clock the assembly timer reads. It is a package var so a
// test can pin elapsed time and assert exact output, mirroring the
// injectable memory.Store.Now seam and the os.Stdin seams in cmd/eeco.
var nowFunc = time.Now

// EstimateTokens approximates a token count from a byte length via the
// ~4-bytes-per-token heuristic. It is a deliberate estimate, never a real
// tokenizer count — eeco ships zero runtime dependencies, so no tokenizer
// can be embedded. Callers MUST present the result with a "≈" prefix so
// it never reads as a precise figure. EstimateTokens(0) == 0.
func EstimateTokens(n int) int { return n / 4 }

// AssemblyMetrics reports one `eeco go` brief assembly. The byte fields
// are real measurements; token figures derived from them (via
// EstimateTokens) are estimates. The value carries no project state and
// is never part of the brief or the --json surface.
type AssemblyMetrics struct {
	Elapsed        time.Duration // wall-clock for Collect + the Markdown render
	BriefBytes     int           // real bytes of the rendered Markdown brief
	KnowledgeBytes int           // real on-disk size of the distilled knowledge layer
}

// Measure renders the Markdown brief for cfg — the full brief, or the
// smaller --brief variant when brief is true — exactly as Render and
// RenderBrief do, and reports how the assembly went. The returned text is
// byte-identical to Render(cfg) / RenderBrief(cfg), so a --metrics readout
// never perturbs stdout or the brief goldens.
//
// Elapsed times only Collect plus the Markdown render — the work
// "assembling the brief" names. The knowledge-byte baseline is measured
// outside that window: reading the layer off disk is not part of how long
// the brief took to build. A nil config is an error, mirroring Render.
func Measure(cfg *config.Config, brief bool) (string, AssemblyMetrics, error) {
	if cfg == nil {
		return "", AssemblyMetrics{}, errors.New("brief.Measure: nil config")
	}
	start := nowFunc()
	d, err := Collect(cfg)
	if err != nil {
		return "", AssemblyMetrics{}, err
	}
	if brief {
		d.TrimToBrief()
	}
	text := renderMarkdown(d)
	elapsed := nowFunc().Sub(start)

	kb, err := knowledgeBytes(cfg)
	if err != nil {
		return "", AssemblyMetrics{}, err
	}
	return text, AssemblyMetrics{
		Elapsed:        elapsed,
		BriefBytes:     len(text),
		KnowledgeBytes: kb,
	}, nil
}

// knowledgeBytes sums the real on-disk size of the knowledge layer the
// brief distills: every memory fact file, the queue, and every note. It
// mirrors memory.Store.LoadAll's selection (skips MEMORY.md, dot-prefixed
// entries, the attic and other directories, and non-.md files) but counts
// disabled facts too — they are real bytes on disk that the brief omits,
// which is exactly the compression a "distilled M into N" readout should
// report. A missing file or directory contributes 0, not an error (an
// uninitialised or empty workspace honestly distills 0 bytes); any other
// I/O fault is wrapped and returned.
func knowledgeBytes(cfg *config.Config) (int, error) {
	total := 0

	// Memory facts: <workspace>/memory/*.md, same selection as LoadAll.
	n, err := dirMarkdownBytes(filepath.Join(cfg.Workspace, "memory"), memory.IndexFilename)
	if err != nil {
		return 0, err
	}
	total += n

	// Queue: <workspace>/state/<queue.Filename>.
	n, err = fileBytes(filepath.Join(cfg.Workspace, "state", queue.Filename))
	if err != nil {
		return 0, err
	}
	total += n

	// Notes: <workspace>/notes/*.md (counted unconditionally — the
	// baseline is what knowledge exists, not what the brief chose to show).
	n, err = dirMarkdownBytes(filepath.Join(cfg.Workspace, "notes"), "")
	if err != nil {
		return 0, err
	}
	total += n

	return total, nil
}

// dirMarkdownBytes sums the size of every regular ".md" file directly
// under dir, skipping subdirectories, dot-prefixed entries, and the file
// named skip (the MEMORY.md index for the memory dir; "" skips nothing).
// DirEntry.Info avoids a second stat per file. A missing directory is 0
// bytes, not an error.
func dirMarkdownBytes(dir, skip string) (int, error) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return 0, nil
		}
		return 0, err
	}
	total := 0
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		name := e.Name()
		if name == skip || strings.HasPrefix(name, ".") || !strings.HasSuffix(name, ".md") {
			continue
		}
		info, err := e.Info()
		if err != nil {
			return 0, err
		}
		total += int(info.Size())
	}
	return total, nil
}

// fileBytes returns the on-disk size of path. A missing file is 0 bytes,
// not an error; any other stat fault is returned.
func fileBytes(path string) (int, error) {
	info, err := os.Stat(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return 0, nil
		}
		return 0, err
	}
	return int(info.Size()), nil
}

// Data is the deterministic project brief in structured form: the data
// behind `eeco go`, independent of how it is rendered. Render turns it
// into Markdown, RenderJSON into a JSON object. The static onboarding
// prose ("Working with eeco", "Recording back") is not part of Data —
// it carries no project state and lives only in the Markdown brief.
type Data struct {
	Project       string          `json:"project"`
	Profile       string          `json:"profile"`
	Gate          []string        `json:"gate"`
	TopLevel      []string        `json:"top_level"`
	Initialized   bool            `json:"initialized"`
	Workflows     []string        `json:"workflows"`
	WhereToLook   []Pointer       `json:"where_to_look"`
	Knowledge     []KnowledgeFact `json:"knowledge"`
	OpenDecisions []string        `json:"open_decisions"`

	// BriefMode is set by TrimToBrief and controls Markdown rendering:
	// when true the "Working with eeco" preamble and "Recording back"
	// outro are omitted. It carries no project state and is excluded
	// from JSON output so the nine-frozen-top-level-key contract holds.
	BriefMode bool `json:"-"`

	// IncludeNotes mirrors cfg.BriefIncludeNotes and gates the "Recent
	// notes" section in the Markdown render. The Notes payload itself is
	// hidden from JSON for the same nine-frozen-top-level-key reason:
	// notes belong to the assistant-prose channel, not the
	// machine-parsed brief.
	IncludeNotes bool         `json:"-"`
	Notes        []notes.Note `json:"-"`
}

// briefCap is the per-section list cap TrimToBrief enforces — the same
// N=5 ceiling Render already applies to the open-decisions section, now
// extended to the where-to-look and knowledge lists so an assistant on
// a tight context budget always reads a bounded brief.
const briefCap = 5

// TrimToBrief reshapes d into the smaller brief form: BriefMode is set
// so Render skips the preamble and outro sections, and each per-section
// list is capped at briefCap. JSON output is unchanged in shape — the
// nine top-level keys remain, with arrays possibly shorter.
func (d *Data) TrimToBrief() {
	d.trimToCap(briefCap)
}

// trimToCap sets BriefMode and caps each per-section list at n entries.
// n is the per-section ceiling TrimToBrief and the RenderWithinBudget
// ladder share; n == 0 empties the lists, leaving only the fixed
// section scaffolding. Each list is only ever shortened (resliced),
// never written into, so a caller may trim a shallow copy of one
// Collect result repeatedly without disturbing the original.
func (d *Data) trimToCap(n int) {
	d.BriefMode = true
	if len(d.WhereToLook) > n {
		d.WhereToLook = d.WhereToLook[:n]
	}
	if len(d.Knowledge) > n {
		d.Knowledge = d.Knowledge[:n]
	}
	if len(d.OpenDecisions) > n {
		d.OpenDecisions = d.OpenDecisions[:n]
	}
	if len(d.Notes) > n {
		d.Notes = d.Notes[:n]
	}
}

// BudgetReport describes the outcome of RenderWithinBudget: which trim
// tier produced the returned brief, its byte size, and whether it fit
// the requested budget.
type BudgetReport struct {
	// Tier is "full", "brief", or "brief (cap N)" — the trim tier the
	// returned text was rendered from.
	Tier string
	// Bytes is the byte length of the returned brief.
	Bytes int
	// Met is true when Bytes is within the requested budget. It is
	// false only when even the smallest tier (cap 0) overruns — the
	// caller still receives that smallest brief.
	Met bool
}

// Pointer is one topic → file entry: a memory fact that carries a ref,
// the fastest path for an assistant to the right file.
type Pointer struct {
	Description string `json:"description"`
	Ref         string `json:"ref"`
}

// KnowledgeFact is one load-bearing memory fact — project, feedback, or
// user — in the terse name/description/type shape of the MEMORY.md index.
type KnowledgeFact struct {
	Name        string `json:"name"`
	Description string `json:"description"`
	Type        string `json:"type"`
}

// Collect assembles the structured project brief for cfg. It reads the
// memory store and the queue file when the workspace is initialised and
// degrades gracefully when it is not: Project, Profile, Gate, TopLevel,
// and Workflows are populated either way. Every slice field is non-nil
// so the JSON form renders an empty list rather than null. A non-nil
// error means a real I/O fault while reading the store or the queue.
func Collect(cfg *config.Config) (Data, error) {
	if cfg == nil {
		return Data{}, errors.New("brief.Collect: nil config")
	}

	d := Data{
		Project:       filepath.Base(cfg.RepoRoot),
		Profile:       string(cfg.Profile),
		Gate:          config.GateSteps(cfg.Gate),
		TopLevel:      []string{},
		Workflows:     workflow.DefaultRegistry().Names(),
		WhereToLook:   []Pointer{},
		Knowledge:     []KnowledgeFact{},
		OpenDecisions: []string{},
	}
	d.TopLevel = append(d.TopLevel, topLevel(cfg)...)

	d.Initialized = config.IsInitialized(cfg)
	if !d.Initialized {
		return d, nil
	}

	store, err := memory.Open(cfg)
	if err != nil {
		return Data{}, fmt.Errorf("brief: open memory: %w", err)
	}
	facts, err := store.LoadAll()
	if err != nil {
		return Data{}, fmt.Errorf("brief: load memory: %w", err)
	}
	for _, f := range facts {
		if f.Disabled {
			continue
		}
		if ref := strings.TrimSpace(f.Ref); ref != "" {
			d.WhereToLook = append(d.WhereToLook, Pointer{Description: f.Description, Ref: ref})
		}
		switch f.Type {
		case memory.TypeProject, memory.TypeFeedback, memory.TypeUser:
			d.Knowledge = append(d.Knowledge, KnowledgeFact{
				Name:        f.Name,
				Description: f.Description,
				Type:        string(f.Type),
			})
		}
	}

	items, err := queueLines(cfg)
	if err != nil {
		return Data{}, fmt.Errorf("brief: read queue: %w", err)
	}
	d.OpenDecisions = append(d.OpenDecisions, items...)

	if cfg.BriefIncludeNotes {
		d.IncludeNotes = true
		list, err := notes.List(filepath.Join(cfg.Workspace, "notes"))
		if err != nil {
			return Data{}, fmt.Errorf("brief: list notes: %w", err)
		}
		// The full brief still caps notes at briefCap so a workspace with
		// dozens of scribbles cannot balloon the brief; the trim ladder
		// shortens it further when budget is tight.
		if len(list) > briefCap {
			list = list[:briefCap]
		}
		d.Notes = list
	}

	return d, nil
}

// Render assembles the Markdown project brief for cfg. It reads the
// memory store and the queue file when the workspace is initialised and
// degrades gracefully when it is not: the "Working with eeco" and
// "Project" sections render either way. A non-nil error means a real
// I/O fault while reading the store or the queue.
func Render(cfg *config.Config) (string, error) {
	d, err := Collect(cfg)
	if err != nil {
		return "", err
	}
	return renderMarkdown(d), nil
}

// RenderBrief is Render's smaller sibling: it collects the same data
// then trims via TrimToBrief, so the assistant-facing preamble and
// outro drop out and each per-section list is capped at briefCap.
func RenderBrief(cfg *config.Config) (string, error) {
	d, err := Collect(cfg)
	if err != nil {
		return "", err
	}
	d.TrimToBrief()
	return renderMarkdown(d), nil
}

// RenderJSON assembles the project brief for cfg as an indented JSON
// object — the machine-readable counterpart to Render, for a downstream
// agent or script rather than an assistant reading prose. It carries the
// same project state as the Markdown brief and is equally deterministic.
func RenderJSON(cfg *config.Config) (string, error) {
	d, err := Collect(cfg)
	if err != nil {
		return "", err
	}
	return marshalData(d)
}

// RenderJSONBrief is RenderJSON's smaller sibling: TrimToBrief caps the
// per-section arrays before marshalling, keeping the nine top-level
// keys (arrays may be shorter, never absent or null).
func RenderJSONBrief(cfg *config.Config) (string, error) {
	d, err := Collect(cfg)
	if err != nil {
		return "", err
	}
	d.TrimToBrief()
	return marshalData(d)
}

// RenderWithinBudget renders the Markdown brief for cfg trimmed to fit
// maxBytes. It is the engine behind `eeco go --write` when the
// `context_budget` config key is set: the persisted brief an assistant
// re-reads each session stays under a known size.
//
// It walks a deterministic ladder — the full brief, then the brief form
// (preamble/outro dropped) with per-section lists capped at 5, 4, 3, 2,
// 1, and finally 0 — and returns the largest tier whose rendered byte
// length is within maxBytes. When skipFull is set (the caller passed
// --brief) the full tier is left out and the ladder starts at the brief
// form. maxBytes <= 0 means no cap: the full brief (or, with skipFull,
// the brief form) is returned unchanged.
//
// When even the cap-0 tier overruns maxBytes the smallest brief is
// returned anyway with BudgetReport.Met false — a brief slightly over
// budget beats no brief at all. A non-nil error means a real I/O fault
// while reading the store or the queue.
func RenderWithinBudget(cfg *config.Config, maxBytes int, skipFull bool) (string, BudgetReport, error) {
	base, err := Collect(cfg)
	if err != nil {
		return "", BudgetReport{}, err
	}

	// renderTier renders a shallow copy of base trimmed to cap n; a
	// negative n leaves the full brief untrimmed. trimToCap only
	// reshortens slices, so each tier is independent of the others.
	renderTier := func(n int) string {
		d := base
		if n >= 0 {
			d.trimToCap(n)
		}
		return renderMarkdown(d)
	}
	tierName := func(n int) string {
		switch {
		case n < 0:
			return "full"
		case n == briefCap:
			return "brief"
		default:
			return fmt.Sprintf("brief (cap %d)", n)
		}
	}

	// The ladder, widest tier first: full (cap -1), then briefCap down
	// to 0. skipFull drops the full tier.
	ladder := []int{-1}
	for n := briefCap; n >= 0; n-- {
		ladder = append(ladder, n)
	}
	if skipFull {
		ladder = ladder[1:]
	}

	if maxBytes <= 0 {
		n := ladder[0]
		text := renderTier(n)
		return text, BudgetReport{Tier: tierName(n), Bytes: len(text), Met: true}, nil
	}

	var text string
	var n int
	for _, n = range ladder {
		text = renderTier(n)
		if len(text) <= maxBytes {
			return text, BudgetReport{Tier: tierName(n), Bytes: len(text), Met: true}, nil
		}
	}
	// Nothing fit: text/n hold the last (smallest) tier rendered.
	return text, BudgetReport{Tier: tierName(n), Bytes: len(text), Met: false}, nil
}

// renderMarkdown serialises a Data value to the Markdown brief. When
// d.BriefMode is set the preamble and outro sections are omitted; every
// other section renders as in the full brief so the smaller form stays
// a strict subset.
func renderMarkdown(d Data) string {
	var b strings.Builder
	fmt.Fprintf(&b, "# %s — eeco project brief\n\n", d.Project)
	b.WriteString("Written by `eeco go`: a deterministic, no-AI-spend project brief.\n")
	b.WriteString("Read this once instead of scanning the tree, then open the files\n")
	b.WriteString("named under \"Where to look\" for detail.\n\n")

	if !d.BriefMode {
		writeWorkingWithEeco(&b, d.Workflows)
	}
	writeProject(&b, d)
	writeWhereToLook(&b, d)
	writeKnowledge(&b, d)
	if d.IncludeNotes {
		writeNotes(&b, d)
	}
	writeDecisions(&b, d)
	if !d.BriefMode {
		writeRecordingBack(&b)
	}

	return b.String()
}

// marshalData turns a Data value into the indented JSON brief.
func marshalData(d Data) (string, error) {
	out, err := json.MarshalIndent(d, "", "  ")
	if err != nil {
		return "", fmt.Errorf("brief: marshal json: %w", err)
	}
	return string(out) + "\n", nil
}

// writeWorkingWithEeco explains eeco to the assistant: the durable
// context it keeps and the safe, read-only commands worth running. The
// builtin list is taken from the registry so it never drifts.
func writeWorkingWithEeco(b *strings.Builder, workflows []string) {
	b.WriteString("## Working with eeco\n\n")
	b.WriteString("This repo uses eeco — a local tool that keeps project memory and a\n")
	b.WriteString("decision queue so an assistant carries durable context across\n")
	b.WriteString("sessions. Commands you can run (read-only, safe by default):\n\n")
	b.WriteString("- `eeco go` — print this brief\n")
	b.WriteString("- `eeco doctor` — workspace and configuration diagnostics\n")
	fmt.Fprintf(b, "- `eeco run <workflow>` — run a workflow (builtins: %s)\n",
		strings.Join(workflows, ", "))
	b.WriteString("- `eeco gc` — memory garbage collection\n\n")
	b.WriteString("Findings and decisions go to eeco's queue, not silent edits to the\n")
	b.WriteString("tracked tree.\n\n")
}

// writeProject states the detected profile, the parse/build gate, and
// the repository's top-level layout.
func writeProject(b *strings.Builder, d Data) {
	b.WriteString("## Project\n\n")
	fmt.Fprintf(b, "- profile: %s\n", d.Profile)
	gate := "(none)"
	if len(d.Gate) > 0 {
		gate = strings.Join(d.Gate, " && ")
	}
	fmt.Fprintf(b, "- gate: %s\n", gate)
	if len(d.TopLevel) == 0 {
		b.WriteString("- top-level: (empty)\n\n")
		return
	}
	fmt.Fprintf(b, "- top-level: %s\n\n", strings.Join(d.TopLevel, ", "))
}

// topLevel lists the repository's top-level entry names. It derives
// them from git's tracked set when git is available, so build
// artifacts, the eeco workspace, and other untracked clutter stay out
// of the brief; it falls back to a directory listing otherwise. Either
// path skips the .git directory and eeco's own per-user workspace dir
// (cfg.Username, which holds the .eeco engine workspace), and the
// result is sorted, so the brief is deterministic.
func topLevel(cfg *config.Config) []string {
	skip := func(seg string) bool {
		return seg == ".git" || seg == cfg.WorkspaceName ||
			(cfg.Username != "" && seg == cfg.Username)
	}
	if tracked, err := gitx.TrackedFiles(cfg.RepoRoot); err == nil && len(tracked) > 0 {
		seen := map[string]struct{}{}
		var out []string
		for _, p := range tracked {
			seg, _, _ := strings.Cut(p, "/")
			if skip(seg) {
				continue
			}
			if _, ok := seen[seg]; ok {
				continue
			}
			seen[seg] = struct{}{}
			out = append(out, seg)
		}
		sort.Strings(out)
		return out
	}
	// No git, or an unborn repo: fall back to a directory listing.
	ents, err := os.ReadDir(cfg.RepoRoot)
	if err != nil {
		return nil
	}
	var out []string
	for _, e := range ents {
		if skip(e.Name()) {
			continue
		}
		out = append(out, e.Name())
	}
	return out
}

// writeWhereToLook turns memory facts that carry a ref into a topic →
// file map: the fastest path for an assistant to the right file.
func writeWhereToLook(b *strings.Builder, d Data) {
	b.WriteString("## Where to look\n\n")
	if !d.Initialized {
		b.WriteString("Workspace not initialised — run `eeco init` to start project memory.\n\n")
		return
	}
	if len(d.WhereToLook) == 0 {
		b.WriteString("No file pointers recorded yet.\n")
	} else {
		for _, p := range d.WhereToLook {
			fmt.Fprintf(b, "- %s → `%s`\n", p.Description, p.Ref)
		}
	}
	b.WriteString("\n")
}

// writeKnowledge lists the load-bearing facts — project, feedback, and
// user — as terse name/description lines, the same shape as the
// MEMORY.md index.
func writeKnowledge(b *strings.Builder, d Data) {
	b.WriteString("## What eeco knows\n\n")
	if !d.Initialized {
		b.WriteString("Workspace not initialised — no project memory yet.\n\n")
		return
	}
	if len(d.Knowledge) == 0 {
		b.WriteString("No durable facts recorded yet.\n")
	} else {
		for _, f := range d.Knowledge {
			fmt.Fprintf(b, "- %s%s (%s)\n", f.Name, f.Description, f.Type)
		}
	}
	b.WriteString("\n")
}

// writeNotes lists the most recent free-form workspace notes. The
// section appears only when cfg.BriefIncludeNotes is set (mirrored on
// d.IncludeNotes); the timestamp is formatted in UTC so the brief stays
// reproducible across machines, and the list is already capped at
// briefCap by Collect (with the trim ladder shortening further when
// budget is tight).
func writeNotes(b *strings.Builder, d Data) {
	b.WriteString("## Recent notes\n\n")
	if !d.Initialized {
		b.WriteString("Workspace not initialised — no notes yet.\n\n")
		return
	}
	if len(d.Notes) == 0 {
		b.WriteString("No notes recorded yet — add one with `eeco add note \"...\"`.\n\n")
		return
	}
	for _, n := range d.Notes {
		fmt.Fprintf(b, "- %s%s\n", n.When.UTC().Format("2006-01-02 15:04"), n.Summary)
	}
	b.WriteString("\n")
}

// writeDecisions reports the open queue items — the only things eeco
// flags as needing a human decision.
func writeDecisions(b *strings.Builder, d Data) {
	b.WriteString("## Open decisions\n\n")
	if !d.Initialized {
		b.WriteString("Workspace not initialised — no queue yet.\n\n")
		return
	}
	if len(d.OpenDecisions) == 0 {
		b.WriteString("None — nothing is waiting on a decision.\n\n")
		return
	}
	fmt.Fprintf(b, "%d open:\n", len(d.OpenDecisions))
	for _, it := range d.OpenDecisions {
		fmt.Fprintf(b, "- %s\n", it)
	}
	b.WriteString("\n")
}

// queueLines returns the text of each unchecked queue item, the same
// read-only extraction `eeco uninstall` uses. A missing queue file is
// not an error: the workspace simply has no open items yet.
func queueLines(cfg *config.Config) ([]string, error) {
	data, err := os.ReadFile(filepath.Join(cfg.Workspace, "state", queue.Filename))
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil, nil
		}
		return nil, err
	}
	var out []string
	const prefix = "- [ ]"
	for raw := range strings.SplitSeq(string(data), "\n") {
		line := strings.TrimSpace(raw)
		if !strings.HasPrefix(line, prefix) {
			continue
		}
		out = append(out, strings.TrimSpace(line[len(prefix):]))
	}
	return out, nil
}

// writeRecordingBack tells the assistant how to keep the brief useful:
// record durable facts and route decisions through the queue.
func writeRecordingBack(b *strings.Builder) {
	b.WriteString("## Recording back\n\n")
	b.WriteString("Keep this brief useful for the next session: record durable facts\n")
	b.WriteString("in eeco's memory store and route findings and decisions through its\n")
	b.WriteString("queue rather than acting silently. Run `eeco doctor` if anything\n")
	b.WriteString("here looks stale.\n")
}