From 4dc532b95d6ba18813412ea824dd3340ac4be033 Mon Sep 17 00:00:00 2001 From: Levi Figueira Date: Thu, 22 Oct 2026 01:05:13 +0100 Subject: [PATCH 1/3] fix: keep the continuity digest within harness injection limits Claude Code saves hook context over 10,000 characters to a file and injects only a 2,000-character preview; Codex does the same past about 2,500 tokens. The digest had grown past both, so agents started with a preview of one old wrap. The default `loaf journal context`, every SessionStart hook and post-compaction resumption now render one compact digest: one line per entry with its date, id and shortened message, no cursor tokens, no empty layers, and a cursor-free command for any layer showing fewer items than it has. It stays under an 8,000-byte budget; when that is tight, layers give up their oldest items first, one round at a time, and the digest says how many entries it left out. Each target's injection limit, unit, source and checked date is now recorded in config/target-capabilities.json, and a test holds every adapter's payload under it. --json and single --layer output are unchanged. Closes #345 --- CHANGELOG.md | 1 + config/target-capabilities.json | 40 +++ docs/knowledge/cli-reference.md | 4 +- docs/knowledge/hook-system.md | 2 +- internal/cli/cli_reference.go | 4 +- internal/cli/journal.go | 6 +- internal/cli/journal_context_digest.go | 285 ++++++++++++++++ internal/cli/journal_context_digest_test.go | 320 ++++++++++++++++++ internal/cli/journal_hook_context.go | 4 +- internal/cli/journal_hook_context_test.go | 5 +- internal/cli/journal_hooks_test.go | 5 +- internal/cli/target_capability_contract.go | 63 +++- .../cli/target_capability_contract_test.go | 18 + 13 files changed, 742 insertions(+), 15 deletions(-) create mode 100644 internal/cli/journal_context_digest.go create mode 100644 internal/cli/journal_context_digest_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b4d9f50b9..9eb9f1a6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ This project follows [Common Changelog](https://common-changelog.org/) and [Sema - Write tracker issues and comments for people. Issues open with one to three plain sentences on where they came from, the problem and why it matters, followed by short `###` sections for Definition of Done, Out of Scope, Verification and Risks, leaving out empty ones. Detail only agents need moves into collapsed agent notes below a divider, with fixed labels and no status, dates, assignees or Loaf naming. Comments and Ship's review lead with one to three plain lines. Records link issues natively, write exact moments in UTC, point only to places everyone with tracker access can reach, and keep magic words in PR descriptions and commit messages. The GitHub and Linear skills describe each tracker's syntax, and migrated records keep their source marker in the agent notes instead of a visible line ([#391](https://github.com/levifig/loaf/issues/391)). - Ship confirms every Verification box on an issue is ticked before marking it Done or merging a PR that closes it, and does so before the merge because merging can close linked issues automatically; the merge hooks check the same boxes. An open box blocks closing and merging unless a reason from a person with authority over the work is written on it; a reason can defer only a check that cannot run before the merge or that only a person can do, and Ship reports that criterion as deferred rather than proven. Whoever ran a check ticks its box, an agent only after the check ran and its result is recorded; checks only a person can do stay for that person, and Implement hands off with the boxes it ran already ticked. Implement and Ship now declare that bounded body write in the Flow contract ([#392](https://github.com/levifig/loaf/issues/392)). +- Keep the continuity digest small enough for harnesses to inject it whole. Claude Code saves hook context over 10,000 characters to a file and injects only a 2,000-character preview, and Codex does the same past about 2,500 tokens, so a busy journal reached agents as a preview of one old wrap. The default `loaf journal context`, every SessionStart hook and post-compaction resumption now print a compact digest. Each entry is one line with its date, id and a shortened message, and `loaf journal show ` prints it in full. Cursor tokens and empty layers are gone, and a layer showing fewer items than it has names a command that lists them. The digest stays under 8,000 bytes; when that is tight, layers give up their oldest items first and the digest says how many it left out. Each target's injection limit, its source and the date it was checked are recorded in `config/target-capabilities.json`. `--json` and single-`--layer` output are unchanged ([#345](https://github.com/levifig/loaf/issues/345)). ### Fixed diff --git a/config/target-capabilities.json b/config/target-capabilities.json index 867388432..f2de29773 100644 --- a/config/target-capabilities.json +++ b/config/target-capabilities.json @@ -13,6 +13,14 @@ "root_policy": "deliver the bounded digest to the root session", "subagent_policy": "suppress when agent_id or agent_type identifies a child session", "background_policy": "suppress background and delegated agents", + "injection_limit": { + "status": "documented", + "limit": 10000, + "unit": "characters", + "source": "https://code.claude.com/docs/en/hooks", + "checked": "2026-10-21", + "summary": "Each additionalContext string and plain stdout is capped at 10,000 characters; larger output is saved to a file and only its path and a 2,000-character preview are injected. No setting raises the cap." + }, "modes": [ { "name": "startup", @@ -95,6 +103,12 @@ "root_policy": "deliver the bounded digest to the root session", "subagent_policy": "no subagentStart context wiring; subagent delivery is outside sessionStart", "background_policy": "silently suppress native is_background_agent=true payloads", + "injection_limit": { + "status": "undocumented", + "source": "https://cursor.com/docs/agent/hooks", + "checked": "2026-10-21", + "summary": "sessionStart additional_context joins the initial system context; no size limit is documented, so the tightest documented limit applies." + }, "modes": [ { "name": "new-composer", @@ -174,6 +188,12 @@ "root_policy": "deliver the bounded digest to the root session", "subagent_policy": "no subagentStart context wiring; subagent delivery is outside sessionStart", "background_policy": "silently suppress native is_background_agent=true payloads", + "injection_limit": { + "status": "undocumented", + "source": "https://cursor.com/docs/agent/hooks", + "checked": "2026-10-21", + "summary": "sessionStart additional_context joins the initial system context; no size limit is documented, so the tightest documented limit applies." + }, "modes": [ { "name": "new-composer", @@ -253,6 +273,14 @@ "root_policy": "deliver the bounded digest to the root session only", "subagent_policy": "suppress subagent context and capture", "background_policy": "suppress background and delegated agents", + "injection_limit": { + "status": "documented", + "limit": 2500, + "unit": "approximate-tokens", + "source": "https://learn.chatgpt.com/docs/hooks", + "checked": "2026-10-21", + "summary": "Each model-visible hook-output message is capped at about 2,500 tokens, estimated as UTF-8 bytes divided by 4; larger output is saved to a file and a head-and-tail preview is injected. additionalContextLimit on a hook changes the cap." + }, "modes": [ { "name": "startup", @@ -335,6 +363,12 @@ "root_policy": "look up the root session without parentID and fail closed when identity is unavailable", "subagent_policy": "use parentID to classify child sessions and suppress child context and capture", "background_policy": "use parentID to classify background sessions and suppress background context and capture", + "injection_limit": { + "status": "undocumented", + "source": "https://opencode.ai/docs/plugins/", + "checked": "2026-10-21", + "summary": "No size limit is documented for plugin-injected context, so the tightest documented limit applies." + }, "modes": [ { "name": "request", @@ -417,6 +451,12 @@ "root_policy": "disabled: root-only identity cannot be proven", "subagent_policy": "disabled: foreground child is indistinguishable", "background_policy": "disabled: foreground child is indistinguishable", + "injection_limit": { + "status": "undocumented", + "source": "https://ampcode.com/docs/plugin-api", + "checked": "2026-10-21", + "summary": "session.start cannot inject context and agent.start documents no size limit, so the tightest documented limit applies." + }, "modes": [ { "name": "foreground-turn", diff --git a/docs/knowledge/cli-reference.md b/docs/knowledge/cli-reference.md index bcef6b72d..55c3907aa 100644 --- a/docs/knowledge/cli-reference.md +++ b/docs/knowledge/cli-reference.md @@ -47,7 +47,9 @@ Before the first journal write, select the command in [Journal Commands](#journa Each layer reports `source_available`, `available_count`, `shown_count`, `truncated`, and an exact `expand_command`; paginated layers also return a cursor. `source_available: false` means the source could not be derived and is not an empty result. In particular, an unavailable Change source marks both `active-changes` and `active-lineage` unavailable and emits a diagnostic. -Use `--branch` to select `branch-recency` scope and bind state cursors. It does not override active Change provenance or reasons, which always use the actual Git branch. Use `--layer` to request one canonical layer. `--limit` accepts 1 through 100 only with `--layer`; `--cursor` also requires `--layer` and cannot expand the intrinsic one-item `project-synthesis` or `scoped-checkpoint` layers. Reuse the returned `expand_command` verbatim: cursors are bound to their layer, project, branch, snapshot, and limit. `--json` is the stable machine surface; human output retains the same counts, unavailable markers, and expansion command. +Use `--branch` to select `branch-recency` scope and bind state cursors. It does not override active Change provenance or reasons, which always use the actual Git branch. Use `--layer` to request one canonical layer. `--limit` accepts 1 through 100 only with `--layer`; `--cursor` also requires `--layer` and cannot expand the intrinsic one-item `project-synthesis` or `scoped-checkpoint` layers. Reuse the returned `expand_command` verbatim: cursors are bound to their layer, project, branch, snapshot, and limit. `--json` is the stable machine surface. Human output of one `--layer` retains the same counts, unavailable markers, and expansion command. + +Without `--layer`, human output is the compact digest that every harness hook and post-compaction resumption also receives. Each entry is one line with its date, id, label, and a shortened message; `loaf journal show ` prints it in full. Empty layers are left out, and cursors never appear. A layer showing fewer items than it has names a cursor-free `--layer` command that lists them. The digest stays under an 8,000-byte budget, below every harness injection limit recorded in `config/target-capabilities.json`. When the budget is tight, layers give up their oldest items first, one round at a time in layer order, and the digest says how many entries it left out. ## Command Index diff --git a/docs/knowledge/hook-system.md b/docs/knowledge/hook-system.md index 1b180e00e..eb0784f9f 100644 --- a/docs/knowledge/hook-system.md +++ b/docs/knowledge/hook-system.md @@ -169,7 +169,7 @@ Registered under `session:` in hooks.yaml with an `event:` field. These hooks em | Hook | Event | Dispatch | Purpose | |------|-------|----------|---------| -| `session-start-loaf` | SessionStart | command (`loaf journal context --from-hook`) | Emit the layered continuity digest (latest wrap + branch entries + open tasks); silent for subagents | +| `session-start-loaf` | SessionStart | command (`loaf journal context --from-hook`) | Emit the compact layered continuity digest, kept under every harness injection limit; silent for subagents | | `session-context-inject` | UserPromptSubmit | command (`loaf journal context for-prompt`) | Inject implementation conventions on prompt submit | | `pre-compact` | PreCompact | command (`loaf journal context for-compact`) | Reinforce ordinary capture and selective checkpoints; writes nothing itself | | `post-compact` | PostCompact | command (`loaf journal context for-resumption`) | Re-emit the continuity digest for post-compaction resumption | diff --git a/internal/cli/cli_reference.go b/internal/cli/cli_reference.go index f4bfcb8e1..6baa4f470 100644 --- a/internal/cli/cli_reference.go +++ b/internal/cli/cli_reference.go @@ -997,7 +997,9 @@ The Loaf operating manual for agents: how to discover commands, diagnose project "", "Each layer reports `source_available`, `available_count`, `shown_count`, `truncated`, and an exact `expand_command`; paginated layers also return a cursor. `source_available: false` means the source could not be derived and is not an empty result. In particular, an unavailable Change source marks both `active-changes` and `active-lineage` unavailable and emits a diagnostic.", "", - "Use `--branch` to select `branch-recency` scope and bind state cursors. It does not override active Change provenance or reasons, which always use the actual Git branch. Use `--layer` to request one canonical layer. `--limit` accepts 1 through 100 only with `--layer`; `--cursor` also requires `--layer` and cannot expand the intrinsic one-item `project-synthesis` or `scoped-checkpoint` layers. Reuse the returned `expand_command` verbatim: cursors are bound to their layer, project, branch, snapshot, and limit. `--json` is the stable machine surface; human output retains the same counts, unavailable markers, and expansion command.", + "Use `--branch` to select `branch-recency` scope and bind state cursors. It does not override active Change provenance or reasons, which always use the actual Git branch. Use `--layer` to request one canonical layer. `--limit` accepts 1 through 100 only with `--layer`; `--cursor` also requires `--layer` and cannot expand the intrinsic one-item `project-synthesis` or `scoped-checkpoint` layers. Reuse the returned `expand_command` verbatim: cursors are bound to their layer, project, branch, snapshot, and limit. `--json` is the stable machine surface. Human output of one `--layer` retains the same counts, unavailable markers, and expansion command.", + "", + "Without `--layer`, human output is the compact digest that every harness hook and post-compaction resumption also receives. Each entry is one line with its date, id, label, and a shortened message; `loaf journal show ` prints it in full. Empty layers are left out, and cursors never appear. A layer showing fewer items than it has names a cursor-free `--layer` command that lists them. The digest stays under an 8,000-byte budget, below every harness injection limit recorded in `config/target-capabilities.json`. When the budget is tight, layers give up their oldest items first, one round at a time in layer order, and the digest says how many entries it left out.", "", "## Command Index", "", diff --git a/internal/cli/journal.go b/internal/cli/journal.go index 8447920ab..5771ee68c 100644 --- a/internal/cli/journal.go +++ b/internal/cli/journal.go @@ -106,7 +106,7 @@ func writeJournalDeferHelp(out io.Writer) { } func writeJournalContextHelp(out io.Writer) { - writeUsageHelp(out, "loaf journal context [options]", "Emit the contract-v2 active-truth continuity digest.", + writeUsageHelp(out, "loaf journal context [options]", "Emit the contract-v2 active-truth continuity digest. Without --layer it prints the compact digest hooks inject: one line per entry, no empty layers, under 8,000 bytes.", "--branch Branch scope (defaults to the current git branch)", "--layer Select one layer: project-synthesis, scoped-checkpoint, active-lineage, unresolved-blockers, deferred-intent, exploration-checkpoints, active-changes, branch-recency, transitional-tasks", "--limit Maximum 1..100 items for the selected layer (requires --layer)", @@ -1061,6 +1061,10 @@ func (r Runner) runJournalContextDigestWithHookInput(args []string, out io.Write if options.jsonOutput { return writeJSON(out, output) } + if options.layer == "" { + fmt.Fprintln(out, renderJournalContextDigest(output, journalContextDigestBudget)) + return nil + } writeJournalContextHuman(out, output) return nil } diff --git a/internal/cli/journal_context_digest.go b/internal/cli/journal_context_digest.go new file mode 100644 index 000000000..3f99258ca --- /dev/null +++ b/internal/cli/journal_context_digest.go @@ -0,0 +1,285 @@ +package cli + +import ( + "fmt" + "strconv" + "strings" + "unicode/utf8" + + "github.com/levifig/loaf/internal/state" +) + +// journalContextDigestBudget bounds the default continuity digest in UTF-8 +// bytes. Harnesses inject hook context only up to a limit and replace anything +// larger with a short preview (Claude Code: 10,000 characters; Codex: about +// 2,500 tokens, counted as bytes ÷ 4). A byte count bounds both, and the +// headroom covers the drift nudge some adapters append. Each target's recorded +// limit lives in config/target-capabilities.json, and a test holds every +// adapter's payload under it. +const journalContextDigestBudget = 8000 + +const ( + journalContextDigestEntryRunes = 160 + journalContextDigestSynthesisRunes = 500 + journalContextDigestDiagnosticRunes = 300 + journalContextDigestListLimitMax = 100 + journalContextDigestCutMarker = "\n[digest cut to fit its budget]" +) + +// journalContextDigestLayer is one layer prepared for packing: every item is +// already a single line, newest first, and shown counts how many survive. +type journalContextDigestLayer struct { + name string + unavailable bool + total int + hint string + listCommand string + items []string + shown int + full bool +} + +// renderJournalContextDigest renders the default continuity digest every +// harness and the plain CLI receive. Layers keep their canonical order, empty +// layers are left out, and every item is one shortened line. When the budget is +// tight, items are admitted one round at a time across layers in that order, so +// each layer gives up its oldest items before any layer loses its newest. The +// output depends only on its input, so the same facts and budget produce the +// same bytes. +func renderJournalContextDigest(result journalContextCLIResult, budget int) string { + header := []string{ + journalContextDigestTitle(result), + "Entries are shortened to one line; read one in full with: loaf journal show ", + } + layers := journalContextDigestLayers(result) + diagnostics := []string{} + for _, diagnostic := range result.Diagnostics { + diagnostics = append(diagnostics, "diagnostic "+diagnostic.Code+": "+shortenJournalContextText(diagnostic.Message, journalContextDigestDiagnosticRunes)) + } + render := func() string { + return assembleJournalContextDigest(header, layers, diagnostics, budget) + } + for admitted := true; admitted; { + admitted = false + for _, layer := range layers { + if layer.full || layer.shown == len(layer.items) { + continue + } + layer.shown++ + if len(render()) <= budget { + admitted = true + continue + } + layer.shown-- + layer.full = true + } + } + return capJournalContextDigest(render(), budget) +} + +func journalContextDigestTitle(result journalContextCLIResult) string { + project := result.ProjectName + if project == "" { + project = result.ProjectID + } + branch := "branch " + result.Branch + if result.Branch == "" { + branch = "branch detached or unavailable" + } + return "loaf journal context: project " + project + ", " + branch +} + +func assembleJournalContextDigest(header []string, layers []*journalContextDigestLayer, diagnostics []string, budget int) string { + lines := append([]string(nil), header...) + omitted := 0 + for _, layer := range layers { + if layer.unavailable { + lines = append(lines, layer.name+": unavailable") + continue + } + line := fmt.Sprintf("%s: showing %d of %d", layer.name, layer.shown, layer.total) + notes := []string{} + if layer.shown < layer.total { + notes = append(notes, "more: "+layer.listCommand) + } + if layer.hint != "" { + notes = append(notes, layer.hint) + } + if len(notes) > 0 { + line += " (" + strings.Join(notes, "; ") + ")" + } + lines = append(lines, line) + for _, item := range layer.items[:layer.shown] { + lines = append(lines, " "+item) + } + omitted += len(layer.items) - layer.shown + } + lines = append(lines, diagnostics...) + if omitted > 0 { + noun := "entries" + if omitted == 1 { + noun = "entry" + } + lines = append(lines, fmt.Sprintf("%d %s left out to fit the %d-byte budget; each layer's more: command lists them", omitted, noun, budget)) + } + return strings.Join(lines, "\n") +} + +// capJournalContextDigest is the last guarantee: if the fixed lines alone +// overflow the budget, cut at a rune boundary and say so. +func capJournalContextDigest(digest string, budget int) string { + if len(digest) <= budget { + return digest + } + marker := journalContextDigestCutMarker + if len(marker) > budget { + marker = "" + } + cut := budget - len(marker) + for cut > 0 && !utf8.RuneStart(digest[cut]) { + cut-- + } + return digest[:cut] + marker +} + +func journalContextDigestLayers(result journalContextCLIResult) []*journalContextDigestLayer { + branch := result.Branch + layers := []*journalContextDigestLayer{} + add := func(name string, available bool, total int, items []string, hint string) { + if available && total == 0 { + return + } + layers = append(layers, &journalContextDigestLayer{ + name: name, + unavailable: !available, + total: total, + hint: hint, + listCommand: journalContextDigestListCommand(name, total, branch), + items: items, + }) + } + if layer := result.Layers.ProjectSynthesis; layer != nil { + items := []string{} + for _, entry := range layer.Items { + items = append(items, formatJournalContextDigestEntry(entry, branch, journalContextDigestSynthesisRunes)) + } + add(journalContextLayerProjectSynthesis, layer.Available, layer.AvailableCount, items, "") + } + if layer := result.Layers.ScopedCheckpoint; layer != nil { + items := []string{} + for _, item := range layer.Items { + items = append(items, item.Label+": "+formatJournalContextDigestEntry(item.Entry, branch, journalContextDigestSynthesisRunes)) + } + add(journalContextLayerScopedCheckpoint, layer.Available, layer.AvailableCount, items, "") + } + if layer := result.Layers.ActiveLineage; layer != nil { + add(journalContextLayerActiveLineage, layer.Available, layer.AvailableCount, formatJournalContextDigestEntries(layer.Items, branch), "") + } + if layer := result.Layers.UnresolvedBlockers; layer != nil { + items := []string{} + for _, item := range layer.Items { + items = append(items, formatJournalContextDigestEntry(item.Block, branch, journalContextDigestEntryRunes)) + } + add(journalContextLayerBlockers, layer.Available, layer.AvailableCount, items, "") + } + if layer := result.Layers.DeferredIntent; layer != nil { + items := []string{} + hint := "" + for _, item := range layer.Items { + if item.Intent == nil { + items = append(items, item.OperationKey+": "+shortenJournalContextText(item.Spark.Text, journalContextDigestEntryRunes)) + continue + } + hint = "read one: loaf intent show " + items = append(items, fmt.Sprintf("%s (%s): %s", firstNonEmpty(item.Intent.Alias, item.Intent.ID), item.Intent.Disposition, shortenJournalContextText(item.Intent.Body, journalContextDigestEntryRunes))) + } + add(journalContextLayerDeferredIntent, layer.Available, layer.AvailableCount, items, hint) + } + if layer := result.Layers.ExplorationCheckpoints; layer != nil { + items := []string{} + for _, item := range layer.Items { + items = append(items, fmt.Sprintf("%s: %s / next: %s", firstNonEmpty(item.Alias, item.ExplorationID), item.Title, shortenJournalContextText(item.NextAction, journalContextDigestEntryRunes))) + } + add(journalContextLayerExplorationCheckpoints, layer.Available, layer.AvailableCount, items, "resume one: loaf exploration context ") + } + if layer := result.Layers.ActiveChanges; layer != nil { + items := []string{} + for _, item := range layer.Items { + items = append(items, formatActiveChangeDigestItem(item)) + } + add(journalContextLayerActiveChanges, layer.Available, layer.AvailableCount, items, "") + } + if layer := result.Layers.BranchRecency; layer != nil { + add(journalContextLayerBranchRecency, layer.Available, layer.AvailableCount, formatJournalContextDigestEntries(layer.Items, branch), "") + } + if layer := result.Layers.TransitionalTasks; layer != nil { + items := []string{} + for _, task := range layer.Items { + items = append(items, fmt.Sprintf("%s (%s): %s", task.Ref, task.Status, shortenJournalContextText(task.Title, journalContextDigestEntryRunes))) + } + add(journalContextLayerTasks, layer.Available, layer.AvailableCount, items, "") + } + return layers +} + +// journalContextDigestListCommand names the cursor-free read that lists the +// whole layer. Only branch-recency is branch-scoped, so only it names a branch. +func journalContextDigestListCommand(layer string, total int, branch string) string { + command := "loaf journal context --layer " + layer + if total > defaultCLIJournalContextLimit { + limit := total + if limit > journalContextDigestListLimitMax { + limit = journalContextDigestListLimitMax + } + command += " --limit " + strconv.Itoa(limit) + } + if layer == journalContextLayerBranchRecency && branch != "" { + command += " --branch " + journalContextShellQuote(branch) + } + return command +} + +func formatJournalContextDigestEntries(entries []state.JournalEntryRecord, branch string) []string { + items := make([]string, 0, len(entries)) + for _, entry := range entries { + items = append(items, formatJournalContextDigestEntry(entry, branch, journalContextDigestEntryRunes)) + } + return items +} + +// formatJournalContextDigestEntry renders one entry as a single line: date, id, +// label, and the shortened message. An entry recorded on another branch names +// it, since the digest header already names the current one. +func formatJournalContextDigestEntry(entry state.JournalEntryRecord, branch string, maxRunes int) string { + label := entry.EntryType + if entry.Scope != "" { + label = entry.EntryType + "(" + entry.Scope + ")" + } + line := fmt.Sprintf("[%s] %s %s: %s", formatJournalTimestamp(entry.CreatedAt), entry.ID, label, shortenJournalContextText(entry.Message, maxRunes)) + if entry.ObservedBranch != "" && entry.ObservedBranch != branch { + line += " (branch: " + entry.ObservedBranch + ")" + } + return line +} + +func formatActiveChangeDigestItem(item activeChangeItem) string { + line := fmt.Sprintf("%s (%s): worktree %s; active because %s", firstNonEmpty(item.Slug, "(unparseable slug)"), item.ChangePath, item.WorktreeState, strings.Join(item.ActiveReasons, ", ")) + if len(item.Findings) > 0 { + line += fmt.Sprintf("; %d findings", len(item.Findings)) + } + if len(item.Gaps) > 0 { + line += fmt.Sprintf("; %d gaps", len(item.Gaps)) + } + return line +} + +// shortenJournalContextText folds text onto one line and cuts it to at most +// maxRunes runes, ending a cut with an ellipsis. +func shortenJournalContextText(text string, maxRunes int) string { + folded := strings.Join(strings.Fields(text), " ") + if utf8.RuneCountInString(folded) <= maxRunes { + return folded + } + runes := []rune(folded) + return strings.TrimRight(string(runes[:maxRunes-1]), " ") + "…" +} diff --git a/internal/cli/journal_context_digest_test.go b/internal/cli/journal_context_digest_test.go new file mode 100644 index 000000000..f356cd637 --- /dev/null +++ b/internal/cli/journal_context_digest_test.go @@ -0,0 +1,320 @@ +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "testing" + "unicode/utf8" + + "github.com/levifig/loaf/internal/state" +) + +func digestEntry(id, entryType, scope, message, created, branch string) state.JournalEntryRecord { + return state.JournalEntryRecord{ID: id, EntryType: entryType, Scope: scope, Message: message, CreatedAt: created, ObservedBranch: branch} +} + +// digestFixture is a small, fully populated default context: every layer kind, +// an empty layer, an unavailable layer, a truncated layer, and a diagnostic. +func digestFixture() journalContextCLIResult { + synthesis := state.JournalContextJournalLayer{Available: true, AvailableCount: 1, ShownCount: 1, Items: []state.JournalEntryRecord{ + digestEntry("e-synth", "wrap", "project", "shipped the digest budget;\nnext: mark stale items", "2026-10-20T10:00:00Z", "main"), + }} + checkpoint := state.JournalContextCheckpointLayer{Available: true, Items: []state.JournalContextCheckpointItem{}} + lineage := state.JournalContextJournalLayer{Available: false, Items: []state.JournalEntryRecord{}} + blockers := state.JournalContextBlockerLayer{Available: true, AvailableCount: 5, ShownCount: 2, Truncated: true, Cursor: "opaque-cursor", Items: []state.JournalContextBlockerItem{ + {Key: "ship", Block: digestEntry("e-block-2", "block", "ship", "waiting on review", "2026-10-19T09:30:00Z", "feature/x")}, + {Key: "pr-12", Block: digestEntry("e-block-1", "block", "pr-12", "rebase approval required", "2026-10-18T08:15:00Z", "main")}, + }} + deferrals := state.JournalContextDeferralLayer{Available: true, AvailableCount: 1, ShownCount: 1, Items: []state.JournalContextDeferralItem{ + {Intent: &state.JournalContextDeferredIntent{ID: "intent-1", Alias: "INTENT-1", Disposition: "deferred", Body: "revisit prompt recall"}}, + }} + explorations := state.JournalContextCheckpointsLayer{Available: true, AvailableCount: 1, ShownCount: 1, Items: []state.JournalContextExplorationCheckpointItem{ + {ExplorationID: "expl-1", Alias: "EXPL-1", Title: "Digest shape", NextAction: "compare budgets"}, + }} + active := activeChangeLayer{Available: true, Items: []activeChangeItem{}} + branch := state.JournalContextJournalLayer{Available: true, AvailableCount: 2, ShownCount: 2, Items: []state.JournalEntryRecord{ + digestEntry("e-branch-2", "decision", "digest", "one line per entry", "2026-10-21T12:00:00Z", "main"), + digestEntry("e-branch-1", "discover", "hooks", "Claude caps hook context at 10,000 characters", "2026-10-21T11:00:00Z", "main"), + }} + tasks := state.JournalContextTaskLayer{Available: true, Items: []state.JournalContextTask{}} + return journalContextCLIResult{ + ProjectName: "loaf", + Branch: "main", + Layers: journalContextLayersJSON{ + ProjectSynthesis: &synthesis, + ScopedCheckpoint: &checkpoint, + ActiveLineage: &lineage, + UnresolvedBlockers: &blockers, + DeferredIntent: &deferrals, + ExplorationCheckpoints: &explorations, + ActiveChanges: &active, + BranchRecency: &branch, + TransitionalTasks: &tasks, + }, + Diagnostics: []state.JournalContextDiagnostic{{Code: "change-source-unavailable", Message: "Change source unavailable"}}, + } +} + +func TestJournalContextDigestRendersCompactLayers(t *testing.T) { + got := renderJournalContextDigest(digestFixture(), journalContextDigestBudget) + want := strings.Join([]string{ + "loaf journal context: project loaf, branch main", + "Entries are shortened to one line; read one in full with: loaf journal show ", + "project-synthesis: showing 1 of 1", + " [2026-10-20 10:00] e-synth wrap(project): shipped the digest budget; next: mark stale items", + "active-lineage: unavailable", + "unresolved-blockers: showing 2 of 5 (more: loaf journal context --layer unresolved-blockers)", + " [2026-10-19 09:30] e-block-2 block(ship): waiting on review (branch: feature/x)", + " [2026-10-18 08:15] e-block-1 block(pr-12): rebase approval required", + "deferred-intent: showing 1 of 1 (read one: loaf intent show )", + " INTENT-1 (deferred): revisit prompt recall", + "exploration-checkpoints: showing 1 of 1 (resume one: loaf exploration context )", + " EXPL-1: Digest shape / next: compare budgets", + "branch-recency: showing 2 of 2", + " [2026-10-21 12:00] e-branch-2 decision(digest): one line per entry", + " [2026-10-21 11:00] e-branch-1 discover(hooks): Claude caps hook context at 10,000 characters", + "diagnostic change-source-unavailable: Change source unavailable", + }, "\n") + if got != want { + t.Fatalf("digest mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want) + } + if strings.Contains(got, "opaque-cursor") || strings.Contains(got, "--cursor") { + t.Fatalf("default digest must carry no cursor tokens:\n%s", got) + } +} + +// bulkyDigestFixture fills four layers with ten long entries each, so any +// realistic budget forces the packer to leave items out. +func bulkyDigestFixture() journalContextCLIResult { + result := digestFixture() + long := strings.Repeat("detail ", 80) + journalItems := func(prefix, entryType string) []state.JournalEntryRecord { + items := []state.JournalEntryRecord{} + for i := 0; i < 10; i++ { + items = append(items, digestEntry(fmt.Sprintf("%s-%02d", prefix, i), entryType, prefix, fmt.Sprintf("item %02d %s", i, long), "2026-10-21T12:00:00Z", "main")) + } + return items + } + blockers := []state.JournalContextBlockerItem{} + for _, entry := range journalItems("block", "block") { + blockers = append(blockers, state.JournalContextBlockerItem{Key: entry.Scope, Block: entry}) + } + result.Layers.UnresolvedBlockers = &state.JournalContextBlockerLayer{Available: true, AvailableCount: 19, ShownCount: 10, Truncated: true, Items: blockers} + result.Layers.BranchRecency = &state.JournalContextJournalLayer{Available: true, AvailableCount: 1171, ShownCount: 10, Truncated: true, Items: journalItems("branch", "decision")} + result.Layers.ActiveLineage = &state.JournalContextJournalLayer{Available: true, AvailableCount: 10, ShownCount: 10, Items: journalItems("lineage", "decision")} + tasks := []state.JournalContextTask{} + for i := 0; i < 10; i++ { + tasks = append(tasks, state.JournalContextTask{Ref: fmt.Sprintf("TASK-%02d", i), Status: "todo", Title: long}) + } + result.Layers.TransitionalTasks = &state.JournalContextTaskLayer{Available: true, AvailableCount: 10, ShownCount: 10, Items: tasks} + return result +} + +func digestLayerItemCounts(digest string) map[string]int { + counts := map[string]int{} + layer := "" + for _, line := range strings.Split(digest, "\n") { + if !strings.HasPrefix(line, " ") { + if name, _, ok := strings.Cut(line, ": "); ok { + layer = name + } + continue + } + counts[layer]++ + } + return counts +} + +func TestJournalContextDigestFitsBudgetAndTrimsOldestItemsFirst(t *testing.T) { + for _, budget := range []int{journalContextDigestBudget, 4000, 2000} { + t.Run(fmt.Sprint(budget), func(t *testing.T) { + got := renderJournalContextDigest(bulkyDigestFixture(), budget) + if len(got) > budget { + t.Fatalf("digest is %d bytes, budget %d", len(got), budget) + } + if !strings.Contains(got, "left out to fit the "+fmt.Sprint(budget)+"-byte budget") { + t.Fatalf("digest that dropped items must say so:\n%s", got) + } + counts := digestLayerItemCounts(got) + for _, layer := range []string{journalContextLayerActiveLineage, journalContextLayerBlockers, journalContextLayerBranchRecency, journalContextLayerTasks} { + if counts[layer] == 0 { + t.Fatalf("layer %s lost its newest item while others kept theirs: %v\n%s", layer, counts, got) + } + } + // Equal-sized items trim evenly: no layer keeps two more than another. + min, max := 10, 0 + for _, layer := range []string{journalContextLayerActiveLineage, journalContextLayerBlockers, journalContextLayerBranchRecency, journalContextLayerTasks} { + if counts[layer] < min { + min = counts[layer] + } + if counts[layer] > max { + max = counts[layer] + } + } + if max-min > 1 { + t.Fatalf("uneven trimming %v\n%s", counts, got) + } + // Newest first: the kept blockers are exactly the leading ones. + for i := 0; i < counts[journalContextLayerBlockers]; i++ { + if !strings.Contains(got, fmt.Sprintf("block-%02d block(block): item %02d", i, i)) { + t.Fatalf("blocker %d missing; kept items must be the newest:\n%s", i, got) + } + } + if strings.Contains(got, "block-09 ") { + t.Fatalf("oldest blocker survived a budget that trimmed the layer:\n%s", got) + } + if !strings.Contains(got, "branch-recency: showing ") || !strings.Contains(got, "(more: loaf journal context --layer branch-recency --limit 100 --branch 'main')") { + t.Fatalf("trimmed layer must name the command that lists it:\n%s", got) + } + }) + } +} + +func TestJournalContextDigestIsByteIdenticalForTheSameInput(t *testing.T) { + first := renderJournalContextDigest(bulkyDigestFixture(), 3000) + for i := 0; i < 5; i++ { + if again := renderJournalContextDigest(bulkyDigestFixture(), 3000); again != first { + t.Fatalf("render %d differs from the first render", i) + } + } +} + +func TestJournalContextDigestShortensEntriesToOneValidUTF8Line(t *testing.T) { + result := digestFixture() + message := strings.Repeat("ação 🍞 ", 100) + "\n\nsecond paragraph" + result.Layers.BranchRecency.Items = []state.JournalEntryRecord{digestEntry("e-long", "note", "", message, "2026-10-21T12:00:00Z", "main")} + result.Layers.BranchRecency.AvailableCount, result.Layers.BranchRecency.ShownCount = 1, 1 + got := renderJournalContextDigest(result, journalContextDigestBudget) + var line string + for _, candidate := range strings.Split(got, "\n") { + if strings.Contains(candidate, "e-long") { + line = candidate + } + } + if line == "" { + t.Fatalf("long entry missing:\n%s", got) + } + if !utf8.ValidString(line) || !strings.HasSuffix(line, "…") || strings.Contains(line, "second paragraph") { + t.Fatalf("entry line = %q, want one shortened valid UTF-8 line", line) + } + if runes := utf8.RuneCountInString(strings.SplitN(line, "note: ", 2)[1]); runes > journalContextDigestEntryRunes { + t.Fatalf("message has %d runes, cap %d", runes, journalContextDigestEntryRunes) + } +} + +func TestJournalContextDigestHardCapsEvenItsFixedLines(t *testing.T) { + result := digestFixture() + result.Diagnostics = []state.JournalContextDiagnostic{{Code: "big", Message: strings.Repeat("é", 400)}} + for _, budget := range []int{40, 120, 300} { + got := renderJournalContextDigest(result, budget) + if len(got) > budget || !utf8.ValidString(got) { + t.Fatalf("budget %d: got %d bytes, valid UTF-8 %t", budget, len(got), utf8.ValidString(got)) + } + } +} + +// injectionLimitPayloadBytes is the largest UTF-8 payload a recorded limit +// admits. A character never takes fewer bytes than it counts, and Codex counts +// one token per four bytes. A target that documents no limit is held to the +// tightest documented one. +func injectionLimitPayloadBytes(t *testing.T, contract TargetCapabilityEvidenceContract, limit TargetInjectionLimitRecord) int { + t.Helper() + toBytes := func(limit TargetInjectionLimitRecord) int { + switch limit.Unit { + case "characters", "bytes": + return limit.Limit + case "approximate-tokens": + return limit.Limit * 4 + } + t.Fatalf("unknown injection limit unit %q", limit.Unit) + return 0 + } + if limit.Status == "documented" { + return toBytes(limit) + } + tightest := 0 + for _, record := range contract.Records { + if documented := record.Context.InjectionLimit; documented.Status == "documented" { + if size := toBytes(documented); tightest == 0 || size < tightest { + tightest = size + } + } + } + if tightest == 0 { + t.Fatal("no documented injection limit to fall back on") + } + return tightest +} + +func TestJournalContextHookPayloadsFitRecordedInjectionLimits(t *testing.T) { + workingDir, stateHome := setupJournalHookRunner(t) + // A real branch makes branch-recency available, the layer that grows fastest. + runChangeContextGit(t, workingDir, "init", "-q", "-b", "main") + runChangeContextGit(t, workingDir, "-c", "user.name=Loaf Test", "-c", "user.email=test@example.com", "commit", "-q", "--allow-empty", "-m", "init") + // Messages are shortened but scopes are not, so long scopes push even + // one-line entries past the budget. + long := strings.Repeat("evidence that would overflow the harness ", 40) + scope := strings.Repeat("long-scope-", 30) + entries := []string{"wrap(project): " + long} + for i := 0; i < 25; i++ { + entries = append(entries, fmt.Sprintf("block(%s%02d): %s", scope, i, long), fmt.Sprintf("decision(%s%02d): %s", scope, i, long)) + } + for _, entry := range entries { + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"journal", "log", entry}); err != nil { + t.Fatal(err) + } + } + run := func(input string, args ...string) string { + t.Helper() + var out bytes.Buffer + if err := (Runner{Stdout: &out, WorkingDir: workingDir, StateHome: stateHome, Stdin: strings.NewReader(input)}).Run(append([]string{"journal", "context"}, args...)); err != nil { + t.Fatalf("journal context %v error = %v", args, err) + } + return out.String() + } + hookContext := func(output string) string { + t.Helper() + var payload struct { + HookSpecificOutput *struct { + AdditionalContext string `json:"additionalContext"` + } `json:"hookSpecificOutput"` + AdditionalContext string `json:"additional_context"` + } + if err := json.Unmarshal([]byte(output), &payload); err != nil { + t.Fatalf("hook output = %q: %v", output, err) + } + if payload.HookSpecificOutput != nil { + return payload.HookSpecificOutput.AdditionalContext + } + return payload.AdditionalContext + } + // Each target's injected strings: session start, plus post-compaction where + // the target delivers it through a separate command. + payloads := map[string][]string{ + "claude-code": {hookContext(run(`{"hook_event_name":"SessionStart","source":"compact","session_id":"s1"}`, "--from-hook", "--claude-code"))}, + "codex": {hookContext(run(codexSessionStartInput("compact"), "--from-hook", "--codex-hook"))}, + "cursor": {hookContext(run(`{"hook_event_name":"sessionStart","composer_mode":"agent","is_background_agent":false,"cursor_version":"2026.05.09-0afadcc"}`, "--from-hook", "--cursor-hook"))}, + "opencode": { + run(openCodeSessionStartPayload, "--from-hook", "--opencode-hook"), + run(`{"target":"opencode","session_id":"ses_1","lifecycle_event":"session.compacting"}`, "for-resumption"), + }, + } + contract := loadTestTargetCapabilityEvidence(t) + for _, record := range contract.Records { + injected, ok := payloads[record.Target] + if !ok { + continue // Amp delivers no automatic context. + } + limit := injectionLimitPayloadBytes(t, contract, record.Context.InjectionLimit) + for _, payload := range injected { + if len(payload) > limit { + t.Fatalf("%s/%s payload is %d bytes, recorded limit admits %d", record.Target, record.Surface, len(payload), limit) + } + if !strings.Contains(payload, "left out to fit the") { + t.Fatalf("%s/%s payload did not exercise the budget:\n%s", record.Target, record.Surface, payload) + } + } + } +} diff --git a/internal/cli/journal_hook_context.go b/internal/cli/journal_hook_context.go index 907857a3a..fdda0a2cd 100644 --- a/internal/cli/journal_hook_context.go +++ b/internal/cli/journal_hook_context.go @@ -102,9 +102,7 @@ func (r Runner) renderSessionStartDigest(result journalContextCLIResult, harness // content Loaf does not deliver, and therefore has no marker to compare and no // command to recommend. Claude Code is the one such harness today. func (r Runner) renderSessionStartDigestWithoutDrift(result journalContextCLIResult) string { - var rendered strings.Builder - writeJournalContextHuman(&rendered, result) - return strings.TrimSpace(rendered.String()) + return strings.TrimSpace(renderJournalContextDigest(result, journalContextDigestBudget)) } // evaluateJournalHookContext owns normalization, root-only suppression, the diff --git a/internal/cli/journal_hook_context_test.go b/internal/cli/journal_hook_context_test.go index 9f8dc5e15..5e46496d8 100644 --- a/internal/cli/journal_hook_context_test.go +++ b/internal/cli/journal_hook_context_test.go @@ -146,11 +146,14 @@ func TestJournalContextHookOutputRegressionRemainsHumanAndJSONCompatible(t *test if err := (Runner{Stdout: &human, WorkingDir: workingDir, StateHome: stateHome, Stdin: strings.NewReader(`{"session_id":"primary"}`)}).Run([]string{"journal", "context", "--from-hook"}); err != nil { t.Fatalf("human hook context error = %v", err) } - for _, want := range []string{"loaf journal context", "project-synthesis: none", "scoped-checkpoint: showing 1 of 1", "latest checkpoint (not project synthesis)", "wrap(hooks): checkpoint one"} { + for _, want := range []string{"loaf journal context", "scoped-checkpoint: showing 1 of 1", "latest checkpoint (not project synthesis)", "wrap(hooks): checkpoint one"} { if !strings.Contains(human.String(), want) { t.Fatalf("human hook context = %q, want %q", human.String(), want) } } + if strings.Contains(human.String(), "project-synthesis") { + t.Fatalf("digest = %q, empty layers must be left out", human.String()) + } var machine bytes.Buffer if err := (Runner{Stdout: &machine, WorkingDir: workingDir, StateHome: stateHome, Stdin: strings.NewReader(`{"session_id":"primary"}`)}).Run([]string{"journal", "context", "--from-hook", "--json"}); err != nil { t.Fatalf("JSON hook context error = %v", err) diff --git a/internal/cli/journal_hooks_test.go b/internal/cli/journal_hooks_test.go index dbf2f68a4..f70087f5f 100644 --- a/internal/cli/journal_hooks_test.go +++ b/internal/cli/journal_hooks_test.go @@ -70,11 +70,14 @@ func TestJournalContextFromHookEmitsDigestForPrimarySession(t *testing.T) { t.Fatalf("journal context --from-hook error = %v", err) } out := stdout.String() - for _, want := range []string{"loaf journal context", "project-synthesis: none", "scoped-checkpoint: showing 1 of 1", "latest checkpoint (not project synthesis)", "wrap(hooks): checkpoint one"} { + for _, want := range []string{"loaf journal context", "scoped-checkpoint: showing 1 of 1", "latest checkpoint (not project synthesis)", "wrap(hooks): checkpoint one"} { if !strings.Contains(out, want) { t.Fatalf("journal context --from-hook stdout = %q, want %q", out, want) } } + if strings.Contains(out, "project-synthesis") { + t.Fatalf("digest = %q, empty layers must be left out", out) + } } func TestJournalLogFromHookDisablesUnprovenTaskCompletedEntry(t *testing.T) { diff --git a/internal/cli/target_capability_contract.go b/internal/cli/target_capability_contract.go index 1f93398ad..0e1ca5f19 100644 --- a/internal/cli/target_capability_contract.go +++ b/internal/cli/target_capability_contract.go @@ -143,12 +143,26 @@ type ModeEvidence struct { // independently classified modes. Aggregate status/evidence claims are // intentionally absent; support belongs to each mode. type TargetContextCapabilityRecord struct { - Adapter string `json:"adapter"` - Mechanism string `json:"mechanism"` - RootPolicy string `json:"root_policy"` - SubagentPolicy string `json:"subagent_policy"` - BackgroundPolicy string `json:"background_policy"` - Modes []ModeEvidence `json:"modes"` + Adapter string `json:"adapter"` + Mechanism string `json:"mechanism"` + RootPolicy string `json:"root_policy"` + SubagentPolicy string `json:"subagent_policy"` + BackgroundPolicy string `json:"background_policy"` + InjectionLimit TargetInjectionLimitRecord `json:"injection_limit"` + Modes []ModeEvidence `json:"modes"` +} + +// TargetInjectionLimitRecord records how much hook context the target injects +// before it previews, cuts, or drops the rest, as its own documentation states +// it on the checked date. A target that documents no limit is held to the +// tightest documented limit instead. +type TargetInjectionLimitRecord struct { + Status string `json:"status"` + Limit int `json:"limit,omitempty"` + Unit string `json:"unit,omitempty"` + Source string `json:"source"` + Checked string `json:"checked"` + Summary string `json:"summary"` } // TargetCompletionCapabilityRecord records automatic-completion evidence. @@ -415,6 +429,9 @@ func validateContextCapability(contextRecord TargetContextCapabilityRecord, inst if strings.TrimSpace(contextRecord.BackgroundPolicy) == "" { return errors.New("background_policy is required") } + if err := validateInjectionLimit(contextRecord.InjectionLimit); err != nil { + return fmt.Errorf("injection_limit: %w", err) + } if len(contextRecord.Modes) == 0 { return errors.New("modes must contain at least one mode") } @@ -491,6 +508,40 @@ func validateModeEvidence(mode ModeEvidence, installedMode string) error { return nil } +var supportedInjectionLimitUnits = map[string]struct{}{ + "characters": {}, + "bytes": {}, + "approximate-tokens": {}, +} + +func validateInjectionLimit(limit TargetInjectionLimitRecord) error { + switch limit.Status { + case "documented": + if limit.Limit <= 0 { + return errors.New("a documented limit must be positive") + } + if _, ok := supportedInjectionLimitUnits[limit.Unit]; !ok { + return fmt.Errorf("unit %q must be characters, bytes, or approximate-tokens", limit.Unit) + } + case "undocumented": + if limit.Limit != 0 || limit.Unit != "" { + return errors.New("an undocumented limit carries no limit or unit") + } + default: + return fmt.Errorf("status %q must be documented or undocumented", limit.Status) + } + if !strings.HasPrefix(limit.Source, "https://") { + return fmt.Errorf("source %q must be the https URL that was checked", limit.Source) + } + if _, err := time.Parse("2006-01-02", limit.Checked); err != nil { + return fmt.Errorf("checked %q must be a YYYY-MM-DD date", limit.Checked) + } + if strings.TrimSpace(limit.Summary) == "" { + return errors.New("summary is required") + } + return nil +} + func validateCompletionCapability(completion TargetCompletionCapabilityRecord) error { if strings.TrimSpace(completion.Status) != "unsupported" { return errors.New("completion status must be unsupported") diff --git a/internal/cli/target_capability_contract_test.go b/internal/cli/target_capability_contract_test.go index 911a9765a..157ff626c 100644 --- a/internal/cli/target_capability_contract_test.go +++ b/internal/cli/target_capability_contract_test.go @@ -281,6 +281,24 @@ func TestTargetCapabilityEvidenceRejectsInvalidClaims(t *testing.T) { {name: "runtime bypass", mutate: func(contract *TargetCapabilityEvidenceContract) { contract.Records[1].BypassModes[0] = "resume" }, want: "duplicates runtime mode"}, {name: "supported completion", mutate: func(contract *TargetCapabilityEvidenceContract) { contract.Records[0].Completion.Status = "supported" }, want: "completion status must be unsupported"}, {name: "pi record", mutate: func(contract *TargetCapabilityEvidenceContract) { contract.Records[0].Target = "pi" }, want: "pi is deferred"}, + {name: "missing injection limit", mutate: func(contract *TargetCapabilityEvidenceContract) { + contract.Records[0].Context.InjectionLimit = TargetInjectionLimitRecord{} + }, want: "injection_limit: status"}, + {name: "documented limit without unit", mutate: func(contract *TargetCapabilityEvidenceContract) { + contract.Records[0].Context.InjectionLimit.Unit = "" + }, want: "must be characters, bytes, or approximate-tokens"}, + {name: "documented limit without size", mutate: func(contract *TargetCapabilityEvidenceContract) { + contract.Records[0].Context.InjectionLimit.Limit = 0 + }, want: "documented limit must be positive"}, + {name: "undocumented limit with size", mutate: func(contract *TargetCapabilityEvidenceContract) { + contract.Records[1].Context.InjectionLimit.Limit = 5000 + }, want: "undocumented limit carries no limit or unit"}, + {name: "injection limit without source", mutate: func(contract *TargetCapabilityEvidenceContract) { + contract.Records[0].Context.InjectionLimit.Source = "docs" + }, want: "must be the https URL that was checked"}, + {name: "injection limit without checked date", mutate: func(contract *TargetCapabilityEvidenceContract) { + contract.Records[0].Context.InjectionLimit.Checked = "October" + }, want: "must be a YYYY-MM-DD date"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 679686703e85e769f131f10b3ce34f1431204b08 Mon Sep 17 00:00:00 2001 From: Levi Figueira Date: Thu, 22 Oct 2026 01:16:09 +0100 Subject: [PATCH 2/3] test: match the compact digest header in worktree scoping tests #418's tests looked for the old " branch: " header line; the compact digest names the branch in its first line instead. --- internal/cli/journal_context_worktree_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cli/journal_context_worktree_test.go b/internal/cli/journal_context_worktree_test.go index b02f0329a..15b2cfc45 100644 --- a/internal/cli/journal_context_worktree_test.go +++ b/internal/cli/journal_context_worktree_test.go @@ -37,7 +37,7 @@ func setupJournalContextWorktree(t *testing.T) journalContextWorktreeFixture { func assertJournalContextScopedToFeature(t *testing.T, digest string) { t.Helper() - if !strings.Contains(digest, " branch: feature\n") || !strings.Contains(digest, "feature-only marker") { + if !strings.Contains(digest, ", branch feature\n") || !strings.Contains(digest, "feature-only marker") { t.Fatalf("digest is not scoped to the feature worktree:\n%s", digest) } if strings.Contains(digest, "branch: main") || strings.Contains(digest, "main-only marker") { @@ -84,13 +84,13 @@ func TestJournalContextScopesToInvokingLinkedWorktree(t *testing.T) { }) t.Run("main checkout keeps main", func(t *testing.T) { digest := runJournalContextFixture(t, fixture.primary, fixture.stateHome, "") - if !strings.Contains(digest, " branch: main\n") || strings.Contains(digest, "feature-only marker") { + if !strings.Contains(digest, ", branch main\n") || strings.Contains(digest, "feature-only marker") { t.Fatalf("main checkout digest:\n%s", digest) } }) t.Run("explicit branch wins", func(t *testing.T) { digest := runJournalContextFixture(t, fixture.worktree, fixture.stateHome, "", "--branch", "main") - if !strings.Contains(digest, " branch: main\n") || !strings.Contains(digest, "main-only marker") { + if !strings.Contains(digest, ", branch main\n") || !strings.Contains(digest, "main-only marker") { t.Fatalf("explicit --branch main digest:\n%s", digest) } }) From d551bb64c3ddfdae8809fde8a3866eefb192914b Mon Sep 17 00:00:00 2001 From: Levi Figueira Date: Thu, 22 Oct 2026 01:28:34 +0100 Subject: [PATCH 3/3] fix: bound digest diagnostics and name every layer's list limit Ship review of #422 found three gaps: - Exploration checkpoints page at five with no cursor, so a "more" command without --limit showed the same five again. Every list command now names --limit min(total, 100). - Diagnostics had no size bound, and a hard cut dropped the omission notice. Diagnostics now collapse to one line per code, and a cut keeps the cut marker and the notice when they fit. - No test pinned the priority order. A golden test now shows which layers win at a tight budget, and the docs say the canonical layer order is the priority order, and why. Also: the drift nudge now shares the budget; titles, scopes and labels fold onto one line; legacy deferrals name their spark; the payload test checks that only Amp goes unchecked; and the contract notes why injection_limit stays under version 3. --- CHANGELOG.md | 2 +- docs/knowledge/cli-reference.md | 2 +- internal/cli/cli_reference.go | 2 +- internal/cli/journal_context_digest.go | 192 +++++++++++++------- internal/cli/journal_context_digest_test.go | 120 +++++++++++- internal/cli/journal_hook_context.go | 17 +- internal/cli/target_capability_contract.go | 5 + 7 files changed, 256 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eb9f1a6d..c1a80f85a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ This project follows [Common Changelog](https://common-changelog.org/) and [Sema - Write tracker issues and comments for people. Issues open with one to three plain sentences on where they came from, the problem and why it matters, followed by short `###` sections for Definition of Done, Out of Scope, Verification and Risks, leaving out empty ones. Detail only agents need moves into collapsed agent notes below a divider, with fixed labels and no status, dates, assignees or Loaf naming. Comments and Ship's review lead with one to three plain lines. Records link issues natively, write exact moments in UTC, point only to places everyone with tracker access can reach, and keep magic words in PR descriptions and commit messages. The GitHub and Linear skills describe each tracker's syntax, and migrated records keep their source marker in the agent notes instead of a visible line ([#391](https://github.com/levifig/loaf/issues/391)). - Ship confirms every Verification box on an issue is ticked before marking it Done or merging a PR that closes it, and does so before the merge because merging can close linked issues automatically; the merge hooks check the same boxes. An open box blocks closing and merging unless a reason from a person with authority over the work is written on it; a reason can defer only a check that cannot run before the merge or that only a person can do, and Ship reports that criterion as deferred rather than proven. Whoever ran a check ticks its box, an agent only after the check ran and its result is recorded; checks only a person can do stay for that person, and Implement hands off with the boxes it ran already ticked. Implement and Ship now declare that bounded body write in the Flow contract ([#392](https://github.com/levifig/loaf/issues/392)). -- Keep the continuity digest small enough for harnesses to inject it whole. Claude Code saves hook context over 10,000 characters to a file and injects only a 2,000-character preview, and Codex does the same past about 2,500 tokens, so a busy journal reached agents as a preview of one old wrap. The default `loaf journal context`, every SessionStart hook and post-compaction resumption now print a compact digest. Each entry is one line with its date, id and a shortened message, and `loaf journal show ` prints it in full. Cursor tokens and empty layers are gone, and a layer showing fewer items than it has names a command that lists them. The digest stays under 8,000 bytes; when that is tight, layers give up their oldest items first and the digest says how many it left out. Each target's injection limit, its source and the date it was checked are recorded in `config/target-capabilities.json`. `--json` and single-`--layer` output are unchanged ([#345](https://github.com/levifig/loaf/issues/345)). +- Keep the continuity digest small enough for harnesses to inject it whole. Claude Code saves hook context over 10,000 characters to a file and injects only a 2,000-character preview, and Codex does the same past about 2,500 tokens, so a busy journal reached agents as a preview of one old wrap. The default `loaf journal context`, every SessionStart hook and post-compaction resumption now print a compact digest. Each entry is one line with its date, id and a shortened message, and `loaf journal show ` prints it in full. Pagination cursors and empty layers are gone, repeated diagnostics collapse to one line per code, and a layer showing fewer items than it has names a command that lists them. The digest stays under 8,000 bytes; when that is tight, every layer keeps its first items in priority order before any layer gets more, and the digest says how many entries it left out. Each target's injection limit, its source and the date it was checked are recorded in `config/target-capabilities.json`. `--json` and single-`--layer` output are unchanged ([#345](https://github.com/levifig/loaf/issues/345)). ### Fixed diff --git a/docs/knowledge/cli-reference.md b/docs/knowledge/cli-reference.md index 55c3907aa..ec797937b 100644 --- a/docs/knowledge/cli-reference.md +++ b/docs/knowledge/cli-reference.md @@ -49,7 +49,7 @@ Each layer reports `source_available`, `available_count`, `shown_count`, `trunca Use `--branch` to select `branch-recency` scope and bind state cursors. It does not override active Change provenance or reasons, which always use the actual Git branch. Use `--layer` to request one canonical layer. `--limit` accepts 1 through 100 only with `--layer`; `--cursor` also requires `--layer` and cannot expand the intrinsic one-item `project-synthesis` or `scoped-checkpoint` layers. Reuse the returned `expand_command` verbatim: cursors are bound to their layer, project, branch, snapshot, and limit. `--json` is the stable machine surface. Human output of one `--layer` retains the same counts, unavailable markers, and expansion command. -Without `--layer`, human output is the compact digest that every harness hook and post-compaction resumption also receives. Each entry is one line with its date, id, label, and a shortened message; `loaf journal show ` prints it in full. Empty layers are left out, and cursors never appear. A layer showing fewer items than it has names a cursor-free `--layer` command that lists them. The digest stays under an 8,000-byte budget, below every harness injection limit recorded in `config/target-capabilities.json`. When the budget is tight, layers give up their oldest items first, one round at a time in layer order, and the digest says how many entries it left out. +Without `--layer`, human output is the compact digest that every harness hook and post-compaction resumption also receives. Each entry is one line with its date, id, label, and a shortened message; `loaf journal show ` prints it in full. Empty layers are left out, and pagination cursors never appear. A layer showing fewer items than it has names a cursor-free `--layer` command with an explicit `--limit` that lists them. The digest, including any drift nudge, stays under an 8,000-byte budget, below every harness injection limit recorded in `config/target-capabilities.json`. Repeated diagnostics collapse to one line per code. The canonical layer order above is also the priority order: the latest deliberate synthesis first, then live obligations, then branch recency and transitional tasks, which the layer commands and `loaf journal recent` recover cheaply. When the budget is tight, items are admitted one round at a time in that order, so every layer keeps its first items before any layer gets more, and the digest says how many entries it left out. ## Command Index diff --git a/internal/cli/cli_reference.go b/internal/cli/cli_reference.go index 6baa4f470..1a3cb041b 100644 --- a/internal/cli/cli_reference.go +++ b/internal/cli/cli_reference.go @@ -999,7 +999,7 @@ The Loaf operating manual for agents: how to discover commands, diagnose project "", "Use `--branch` to select `branch-recency` scope and bind state cursors. It does not override active Change provenance or reasons, which always use the actual Git branch. Use `--layer` to request one canonical layer. `--limit` accepts 1 through 100 only with `--layer`; `--cursor` also requires `--layer` and cannot expand the intrinsic one-item `project-synthesis` or `scoped-checkpoint` layers. Reuse the returned `expand_command` verbatim: cursors are bound to their layer, project, branch, snapshot, and limit. `--json` is the stable machine surface. Human output of one `--layer` retains the same counts, unavailable markers, and expansion command.", "", - "Without `--layer`, human output is the compact digest that every harness hook and post-compaction resumption also receives. Each entry is one line with its date, id, label, and a shortened message; `loaf journal show ` prints it in full. Empty layers are left out, and cursors never appear. A layer showing fewer items than it has names a cursor-free `--layer` command that lists them. The digest stays under an 8,000-byte budget, below every harness injection limit recorded in `config/target-capabilities.json`. When the budget is tight, layers give up their oldest items first, one round at a time in layer order, and the digest says how many entries it left out.", + "Without `--layer`, human output is the compact digest that every harness hook and post-compaction resumption also receives. Each entry is one line with its date, id, label, and a shortened message; `loaf journal show ` prints it in full. Empty layers are left out, and pagination cursors never appear. A layer showing fewer items than it has names a cursor-free `--layer` command with an explicit `--limit` that lists them. The digest, including any drift nudge, stays under an 8,000-byte budget, below every harness injection limit recorded in `config/target-capabilities.json`. Repeated diagnostics collapse to one line per code. The canonical layer order above is also the priority order: the latest deliberate synthesis first, then live obligations, then branch recency and transitional tasks, which the layer commands and `loaf journal recent` recover cheaply. When the budget is tight, items are admitted one round at a time in that order, so every layer keeps its first items before any layer gets more, and the digest says how many entries it left out.", "", "## Command Index", "", diff --git a/internal/cli/journal_context_digest.go b/internal/cli/journal_context_digest.go index 3f99258ca..0327715b1 100644 --- a/internal/cli/journal_context_digest.go +++ b/internal/cli/journal_context_digest.go @@ -10,12 +10,12 @@ import ( ) // journalContextDigestBudget bounds the default continuity digest in UTF-8 -// bytes. Harnesses inject hook context only up to a limit and replace anything -// larger with a short preview (Claude Code: 10,000 characters; Codex: about -// 2,500 tokens, counted as bytes ÷ 4). A byte count bounds both, and the -// headroom covers the drift nudge some adapters append. Each target's recorded -// limit lives in config/target-capabilities.json, and a test holds every -// adapter's payload under it. +// bytes, including any drift nudge an adapter appends. Harnesses inject hook +// context only up to a limit and replace anything larger with a short preview +// (Claude Code: 10,000 characters; Codex: about 2,500 tokens, counted as +// bytes ÷ 4). A byte count bounds both. Each target's recorded limit lives in +// config/target-capabilities.json, and a test holds every adapter's payload +// under it. const journalContextDigestBudget = 8000 const ( @@ -27,12 +27,13 @@ const ( ) // journalContextDigestLayer is one layer prepared for packing: every item is -// already a single line, newest first, and shown counts how many survive. +// already a single line in the layer's own order, and shown counts how many +// of them survive. type journalContextDigestLayer struct { name string unavailable bool total int - hint string + hints []string listCommand string items []string shown int @@ -40,25 +41,24 @@ type journalContextDigestLayer struct { } // renderJournalContextDigest renders the default continuity digest every -// harness and the plain CLI receive. Layers keep their canonical order, empty -// layers are left out, and every item is one shortened line. When the budget is -// tight, items are admitted one round at a time across layers in that order, so -// each layer gives up its oldest items before any layer loses its newest. The -// output depends only on its input, so the same facts and budget produce the -// same bytes. +// harness and the plain CLI receive. Empty layers are left out and every item +// is one shortened line. +// +// The canonical layer order is also the priority order: the latest deliberate +// synthesis first, then live obligations (lineage, blockers, deferred intent, +// explorations, active changes), then branch recency and transitional tasks, +// which the layer commands and `loaf journal recent` recover cheaply. When the +// budget is tight, items are admitted one round at a time across layers in +// that order, so every layer keeps its first items before any layer gets more. +// The output depends only on its input, so the same facts and budget produce +// the same bytes. func renderJournalContextDigest(result journalContextCLIResult, budget int) string { header := []string{ journalContextDigestTitle(result), "Entries are shortened to one line; read one in full with: loaf journal show ", } layers := journalContextDigestLayers(result) - diagnostics := []string{} - for _, diagnostic := range result.Diagnostics { - diagnostics = append(diagnostics, "diagnostic "+diagnostic.Code+": "+shortenJournalContextText(diagnostic.Message, journalContextDigestDiagnosticRunes)) - } - render := func() string { - return assembleJournalContextDigest(header, layers, diagnostics, budget) - } + diagnostics := groupJournalContextDiagnostics(result.Diagnostics) for admitted := true; admitted; { admitted = false for _, layer := range layers { @@ -66,7 +66,8 @@ func renderJournalContextDigest(result journalContextCLIResult, budget int) stri continue } layer.shown++ - if len(render()) <= budget { + body, notice := assembleJournalContextDigest(header, layers, diagnostics) + if len(joinJournalContextDigest(body, notice)) <= budget { admitted = true continue } @@ -74,7 +75,8 @@ func renderJournalContextDigest(result journalContextCLIResult, budget int) stri layer.full = true } } - return capJournalContextDigest(render(), budget) + body, notice := assembleJournalContextDigest(header, layers, diagnostics) + return capJournalContextDigest(body, notice, budget) } func journalContextDigestTitle(result journalContextCLIResult) string { @@ -82,14 +84,16 @@ func journalContextDigestTitle(result journalContextCLIResult) string { if project == "" { project = result.ProjectID } - branch := "branch " + result.Branch + branch := "branch " + foldJournalContextText(result.Branch) if result.Branch == "" { branch = "branch detached or unavailable" } return "loaf journal context: project " + project + ", " + branch } -func assembleJournalContextDigest(header []string, layers []*journalContextDigestLayer, diagnostics []string, budget int) string { +// assembleJournalContextDigest returns the digest body and, separately, the +// notice saying how many items the budget left out, so a hard cut can keep it. +func assembleJournalContextDigest(header []string, layers []*journalContextDigestLayer, diagnostics []string) (string, string) { lines := append([]string(nil), header...) omitted := 0 for _, layer := range layers { @@ -102,9 +106,7 @@ func assembleJournalContextDigest(header []string, layers []*journalContextDiges if layer.shown < layer.total { notes = append(notes, "more: "+layer.listCommand) } - if layer.hint != "" { - notes = append(notes, layer.hint) - } + notes = append(notes, layer.hints...) if len(notes) > 0 { line += " (" + strings.Join(notes, "; ") + ")" } @@ -115,37 +117,77 @@ func assembleJournalContextDigest(header []string, layers []*journalContextDiges omitted += len(layer.items) - layer.shown } lines = append(lines, diagnostics...) + notice := "" if omitted > 0 { noun := "entries" if omitted == 1 { noun = "entry" } - lines = append(lines, fmt.Sprintf("%d %s left out to fit the %d-byte budget; each layer's more: command lists them", omitted, noun, budget)) + notice = fmt.Sprintf("%d %s left out to stay within the injection budget; each layer's more: command lists them", omitted, noun) + } + return strings.Join(lines, "\n"), notice +} + +func joinJournalContextDigest(body, notice string) string { + if notice == "" { + return body } - return strings.Join(lines, "\n") + return body + "\n" + notice } // capJournalContextDigest is the last guarantee: if the fixed lines alone -// overflow the budget, cut at a rune boundary and say so. -func capJournalContextDigest(digest string, budget int) string { - if len(digest) <= budget { - return digest +// overflow the budget, cut the body at a rune boundary, say so, and keep the +// omission notice when it fits. +func capJournalContextDigest(body, notice string, budget int) string { + full := joinJournalContextDigest(body, notice) + if len(full) <= budget { + return full + } + tail := "" + for _, candidate := range []string{joinJournalContextDigest(journalContextDigestCutMarker, notice), journalContextDigestCutMarker} { + if len(candidate) <= budget { + tail = candidate + break + } } - marker := journalContextDigestCutMarker - if len(marker) > budget { - marker = "" + cut := budget - len(tail) + if cut > len(body) { + cut = len(body) } - cut := budget - len(marker) - for cut > 0 && !utf8.RuneStart(digest[cut]) { + for cut > 0 && cut < len(body) && !utf8.RuneStart(body[cut]) { cut-- } - return digest[:cut] + marker + return body[:cut] + tail +} + +// groupJournalContextDiagnostics renders one line per diagnostic code, in the +// order codes first appear, so repeated diagnostics cannot crowd out entries. +func groupJournalContextDiagnostics(diagnostics []state.JournalContextDiagnostic) []string { + codes := []string{} + counts := map[string]int{} + first := map[string]string{} + for _, diagnostic := range diagnostics { + if _, seen := counts[diagnostic.Code]; !seen { + codes = append(codes, diagnostic.Code) + first[diagnostic.Code] = diagnostic.Message + } + counts[diagnostic.Code]++ + } + lines := make([]string, 0, len(codes)) + for _, code := range codes { + line := "diagnostic " + foldJournalContextText(code) + ": " + shortenJournalContextText(first[code], journalContextDigestDiagnosticRunes) + if more := counts[code] - 1; more > 0 { + line += fmt.Sprintf(" (%d more with this code)", more) + } + lines = append(lines, line) + } + return lines } func journalContextDigestLayers(result journalContextCLIResult) []*journalContextDigestLayer { branch := result.Branch layers := []*journalContextDigestLayer{} - add := func(name string, available bool, total int, items []string, hint string) { + add := func(name string, available bool, total int, items []string, hints ...string) { if available && total == 0 { return } @@ -153,7 +195,7 @@ func journalContextDigestLayers(result journalContextCLIResult) []*journalContex name: name, unavailable: !available, total: total, - hint: hint, + hints: hints, listCommand: journalContextDigestListCommand(name, total, branch), items: items, }) @@ -163,42 +205,50 @@ func journalContextDigestLayers(result journalContextCLIResult) []*journalContex for _, entry := range layer.Items { items = append(items, formatJournalContextDigestEntry(entry, branch, journalContextDigestSynthesisRunes)) } - add(journalContextLayerProjectSynthesis, layer.Available, layer.AvailableCount, items, "") + add(journalContextLayerProjectSynthesis, layer.Available, layer.AvailableCount, items) } if layer := result.Layers.ScopedCheckpoint; layer != nil { items := []string{} for _, item := range layer.Items { - items = append(items, item.Label+": "+formatJournalContextDigestEntry(item.Entry, branch, journalContextDigestSynthesisRunes)) + items = append(items, foldJournalContextText(item.Label)+": "+formatJournalContextDigestEntry(item.Entry, branch, journalContextDigestSynthesisRunes)) } - add(journalContextLayerScopedCheckpoint, layer.Available, layer.AvailableCount, items, "") + add(journalContextLayerScopedCheckpoint, layer.Available, layer.AvailableCount, items) } if layer := result.Layers.ActiveLineage; layer != nil { - add(journalContextLayerActiveLineage, layer.Available, layer.AvailableCount, formatJournalContextDigestEntries(layer.Items, branch), "") + add(journalContextLayerActiveLineage, layer.Available, layer.AvailableCount, formatJournalContextDigestEntries(layer.Items, branch)) } if layer := result.Layers.UnresolvedBlockers; layer != nil { items := []string{} for _, item := range layer.Items { items = append(items, formatJournalContextDigestEntry(item.Block, branch, journalContextDigestEntryRunes)) } - add(journalContextLayerBlockers, layer.Available, layer.AvailableCount, items, "") + add(journalContextLayerBlockers, layer.Available, layer.AvailableCount, items) } if layer := result.Layers.DeferredIntent; layer != nil { items := []string{} - hint := "" + canonical, legacy := false, false for _, item := range layer.Items { if item.Intent == nil { - items = append(items, item.OperationKey+": "+shortenJournalContextText(item.Spark.Text, journalContextDigestEntryRunes)) + legacy = true + items = append(items, fmt.Sprintf("%s (spark %s): %s", foldJournalContextText(item.OperationKey), firstNonEmpty(item.Spark.Alias, item.Spark.ID), shortenJournalContextText(item.Spark.Text, journalContextDigestEntryRunes))) continue } - hint = "read one: loaf intent show " + canonical = true items = append(items, fmt.Sprintf("%s (%s): %s", firstNonEmpty(item.Intent.Alias, item.Intent.ID), item.Intent.Disposition, shortenJournalContextText(item.Intent.Body, journalContextDigestEntryRunes))) } - add(journalContextLayerDeferredIntent, layer.Available, layer.AvailableCount, items, hint) + hints := []string{} + if canonical { + hints = append(hints, "read one: loaf intent show ") + } + if legacy { + hints = append(hints, "read a spark: loaf spark show ") + } + add(journalContextLayerDeferredIntent, layer.Available, layer.AvailableCount, items, hints...) } if layer := result.Layers.ExplorationCheckpoints; layer != nil { items := []string{} for _, item := range layer.Items { - items = append(items, fmt.Sprintf("%s: %s / next: %s", firstNonEmpty(item.Alias, item.ExplorationID), item.Title, shortenJournalContextText(item.NextAction, journalContextDigestEntryRunes))) + items = append(items, fmt.Sprintf("%s: %s / next: %s", firstNonEmpty(item.Alias, item.ExplorationID), foldJournalContextText(item.Title), shortenJournalContextText(item.NextAction, journalContextDigestEntryRunes))) } add(journalContextLayerExplorationCheckpoints, layer.Available, layer.AvailableCount, items, "resume one: loaf exploration context ") } @@ -207,32 +257,30 @@ func journalContextDigestLayers(result journalContextCLIResult) []*journalContex for _, item := range layer.Items { items = append(items, formatActiveChangeDigestItem(item)) } - add(journalContextLayerActiveChanges, layer.Available, layer.AvailableCount, items, "") + add(journalContextLayerActiveChanges, layer.Available, layer.AvailableCount, items) } if layer := result.Layers.BranchRecency; layer != nil { - add(journalContextLayerBranchRecency, layer.Available, layer.AvailableCount, formatJournalContextDigestEntries(layer.Items, branch), "") + add(journalContextLayerBranchRecency, layer.Available, layer.AvailableCount, formatJournalContextDigestEntries(layer.Items, branch)) } if layer := result.Layers.TransitionalTasks; layer != nil { items := []string{} for _, task := range layer.Items { items = append(items, fmt.Sprintf("%s (%s): %s", task.Ref, task.Status, shortenJournalContextText(task.Title, journalContextDigestEntryRunes))) } - add(journalContextLayerTasks, layer.Available, layer.AvailableCount, items, "") + add(journalContextLayerTasks, layer.Available, layer.AvailableCount, items) } return layers } // journalContextDigestListCommand names the cursor-free read that lists the -// whole layer. Only branch-recency is branch-scoped, so only it names a branch. +// whole layer. Layers page at different sizes, so the limit is always explicit. +// Only branch-recency is branch-scoped, so only it names a branch. func journalContextDigestListCommand(layer string, total int, branch string) string { - command := "loaf journal context --layer " + layer - if total > defaultCLIJournalContextLimit { - limit := total - if limit > journalContextDigestListLimitMax { - limit = journalContextDigestListLimitMax - } - command += " --limit " + strconv.Itoa(limit) + limit := total + if limit > journalContextDigestListLimitMax { + limit = journalContextDigestListLimitMax } + command := "loaf journal context --layer " + layer + " --limit " + strconv.Itoa(limit) if layer == journalContextLayerBranchRecency && branch != "" { command += " --branch " + journalContextShellQuote(branch) } @@ -251,19 +299,19 @@ func formatJournalContextDigestEntries(entries []state.JournalEntryRecord, branc // label, and the shortened message. An entry recorded on another branch names // it, since the digest header already names the current one. func formatJournalContextDigestEntry(entry state.JournalEntryRecord, branch string, maxRunes int) string { - label := entry.EntryType - if entry.Scope != "" { - label = entry.EntryType + "(" + entry.Scope + ")" + label := foldJournalContextText(entry.EntryType) + if scope := foldJournalContextText(entry.Scope); scope != "" { + label += "(" + scope + ")" } line := fmt.Sprintf("[%s] %s %s: %s", formatJournalTimestamp(entry.CreatedAt), entry.ID, label, shortenJournalContextText(entry.Message, maxRunes)) if entry.ObservedBranch != "" && entry.ObservedBranch != branch { - line += " (branch: " + entry.ObservedBranch + ")" + line += " (branch: " + foldJournalContextText(entry.ObservedBranch) + ")" } return line } func formatActiveChangeDigestItem(item activeChangeItem) string { - line := fmt.Sprintf("%s (%s): worktree %s; active because %s", firstNonEmpty(item.Slug, "(unparseable slug)"), item.ChangePath, item.WorktreeState, strings.Join(item.ActiveReasons, ", ")) + line := fmt.Sprintf("%s (%s): worktree %s; active because %s", foldJournalContextText(firstNonEmpty(item.Slug, "(unparseable slug)")), foldJournalContextText(item.ChangePath), item.WorktreeState, strings.Join(item.ActiveReasons, ", ")) if len(item.Findings) > 0 { line += fmt.Sprintf("; %d findings", len(item.Findings)) } @@ -273,10 +321,16 @@ func formatActiveChangeDigestItem(item activeChangeItem) string { return line } +// foldJournalContextText puts text on one line by collapsing every run of +// whitespace, newlines included, to a single space. +func foldJournalContextText(text string) string { + return strings.Join(strings.Fields(text), " ") +} + // shortenJournalContextText folds text onto one line and cuts it to at most // maxRunes runes, ending a cut with an ellipsis. func shortenJournalContextText(text string, maxRunes int) string { - folded := strings.Join(strings.Fields(text), " ") + folded := foldJournalContextText(text) if utf8.RuneCountInString(folded) <= maxRunes { return folded } diff --git a/internal/cli/journal_context_digest_test.go b/internal/cli/journal_context_digest_test.go index f356cd637..576fc56ff 100644 --- a/internal/cli/journal_context_digest_test.go +++ b/internal/cli/journal_context_digest_test.go @@ -65,7 +65,7 @@ func TestJournalContextDigestRendersCompactLayers(t *testing.T) { "project-synthesis: showing 1 of 1", " [2026-10-20 10:00] e-synth wrap(project): shipped the digest budget; next: mark stale items", "active-lineage: unavailable", - "unresolved-blockers: showing 2 of 5 (more: loaf journal context --layer unresolved-blockers)", + "unresolved-blockers: showing 2 of 5 (more: loaf journal context --layer unresolved-blockers --limit 5)", " [2026-10-19 09:30] e-block-2 block(ship): waiting on review (branch: feature/x)", " [2026-10-18 08:15] e-block-1 block(pr-12): rebase approval required", "deferred-intent: showing 1 of 1 (read one: loaf intent show )", @@ -127,14 +127,14 @@ func digestLayerItemCounts(digest string) map[string]int { return counts } -func TestJournalContextDigestFitsBudgetAndTrimsOldestItemsFirst(t *testing.T) { +func TestJournalContextDigestFitsBudgetAndKeepsEachLayersFirstItems(t *testing.T) { for _, budget := range []int{journalContextDigestBudget, 4000, 2000} { t.Run(fmt.Sprint(budget), func(t *testing.T) { got := renderJournalContextDigest(bulkyDigestFixture(), budget) if len(got) > budget { t.Fatalf("digest is %d bytes, budget %d", len(got), budget) } - if !strings.Contains(got, "left out to fit the "+fmt.Sprint(budget)+"-byte budget") { + if !strings.Contains(got, "left out to stay within the injection budget") { t.Fatalf("digest that dropped items must say so:\n%s", got) } counts := digestLayerItemCounts(got) @@ -212,6 +212,14 @@ func TestJournalContextDigestHardCapsEvenItsFixedLines(t *testing.T) { if len(got) > budget || !utf8.ValidString(got) { t.Fatalf("budget %d: got %d bytes, valid UTF-8 %t", budget, len(got), utf8.ValidString(got)) } + if !strings.Contains(got, "[digest cut to fit its budget]") { + t.Fatalf("budget %d: a cut digest must say so:\n%s", budget, got) + } + } + // With room for the tail, the omission notice survives the cut too. + got := renderJournalContextDigest(result, 300) + if !strings.Contains(got, "left out to stay within the injection budget") { + t.Fatalf("cut digest lost its omission notice:\n%s", got) } } @@ -302,19 +310,121 @@ func TestJournalContextHookPayloadsFitRecordedInjectionLimits(t *testing.T) { }, } contract := loadTestTargetCapabilityEvidence(t) + skipped := map[string]bool{} for _, record := range contract.Records { injected, ok := payloads[record.Target] if !ok { - continue // Amp delivers no automatic context. + skipped[record.Target] = true + continue } limit := injectionLimitPayloadBytes(t, contract, record.Context.InjectionLimit) for _, payload := range injected { if len(payload) > limit { t.Fatalf("%s/%s payload is %d bytes, recorded limit admits %d", record.Target, record.Surface, len(payload), limit) } - if !strings.Contains(payload, "left out to fit the") { + if !strings.Contains(payload, "left out to stay within the injection budget") { t.Fatalf("%s/%s payload did not exercise the budget:\n%s", record.Target, record.Surface, payload) } } } + // Only Amp, which delivers no automatic context, may go unchecked. + if len(skipped) != 1 || !skipped["amp"] { + t.Fatalf("unchecked targets = %v, want only amp", skipped) + } +} + +// The canonical layer order is the priority order: at a budget that fits only +// the first items of the leading layers, later layers keep just their counts +// and list commands. +func TestJournalContextDigestPriorityFollowsCanonicalLayerOrder(t *testing.T) { + result := journalContextCLIResult{ProjectName: "loaf", Branch: "main"} + result.Layers.ProjectSynthesis = &state.JournalContextJournalLayer{Available: true, AvailableCount: 1, ShownCount: 1, Items: []state.JournalEntryRecord{ + digestEntry("s1", "wrap", "project", "latest synthesis", "2026-10-21T12:00:00Z", "main"), + }} + blockers := []state.JournalContextBlockerItem{} + recent := []state.JournalEntryRecord{} + tasks := []state.JournalContextTask{} + for i := 1; i <= 3; i++ { + blockers = append(blockers, state.JournalContextBlockerItem{Key: "ship", Block: digestEntry(fmt.Sprintf("b%d", i), "block", "ship", fmt.Sprintf("blocker %d", i), "2026-10-21T12:00:00Z", "main")}) + recent = append(recent, digestEntry(fmt.Sprintf("r%d", i), "decision", "work", fmt.Sprintf("recent %d", i), "2026-10-21T12:00:00Z", "main")) + tasks = append(tasks, state.JournalContextTask{Ref: fmt.Sprintf("T-%d", i), Status: "todo", Title: fmt.Sprintf("task %d", i)}) + } + result.Layers.UnresolvedBlockers = &state.JournalContextBlockerLayer{Available: true, AvailableCount: 3, ShownCount: 3, Items: blockers} + result.Layers.BranchRecency = &state.JournalContextJournalLayer{Available: true, AvailableCount: 3, ShownCount: 3, Items: recent} + result.Layers.TransitionalTasks = &state.JournalContextTaskLayer{Available: true, AvailableCount: 3, ShownCount: 3, Items: tasks} + want := strings.Join([]string{ + "loaf journal context: project loaf, branch main", + "Entries are shortened to one line; read one in full with: loaf journal show ", + "project-synthesis: showing 1 of 1", + " [2026-10-21 12:00] s1 wrap(project): latest synthesis", + "unresolved-blockers: showing 1 of 3 (more: loaf journal context --layer unresolved-blockers --limit 3)", + " [2026-10-21 12:00] b1 block(ship): blocker 1", + "branch-recency: showing 0 of 3 (more: loaf journal context --layer branch-recency --limit 3 --branch 'main')", + "transitional-tasks: showing 0 of 3 (more: loaf journal context --layer transitional-tasks --limit 3)", + "8 entries left out to stay within the injection budget; each layer's more: command lists them", + }, "\n") + if got := renderJournalContextDigest(result, len(want)); got != want { + t.Fatalf("digest mismatch at a tight budget\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +// Exploration checkpoints page at five with no cursor, so the list command +// must name the layer's total rather than rely on the default page size. +func TestJournalContextDigestListCommandNamesTheLayerTotal(t *testing.T) { + result := digestFixture() + items := []state.JournalContextExplorationCheckpointItem{} + for i := 1; i <= 5; i++ { + items = append(items, state.JournalContextExplorationCheckpointItem{ExplorationID: fmt.Sprintf("expl-%d", i), Title: "Digest shape", NextAction: "compare budgets"}) + } + result.Layers.ExplorationCheckpoints = &state.JournalContextCheckpointsLayer{Available: true, AvailableCount: 8, ShownCount: 5, Truncated: true, Items: items} + got := renderJournalContextDigest(result, journalContextDigestBudget) + want := "exploration-checkpoints: showing 5 of 8 (more: loaf journal context --layer exploration-checkpoints --limit 8; resume one: loaf exploration context )" + if !strings.Contains(got, want+"\n") { + t.Fatalf("digest missing %q:\n%s", want, got) + } +} + +func TestJournalContextDigestGroupsRepeatedDiagnostics(t *testing.T) { + result := digestFixture() + result.Diagnostics = []state.JournalContextDiagnostic{} + for i := 0; i < 200; i++ { + result.Diagnostics = append(result.Diagnostics, state.JournalContextDiagnostic{Code: "journal-context-unmatched-unblock", Key: fmt.Sprintf("scope-%03d", i), Message: "unblock has no unresolved block for the exact scope key"}) + } + result.Diagnostics = append(result.Diagnostics, state.JournalContextDiagnostic{Code: "change-source-unavailable", Message: "Change source unavailable"}) + got := renderJournalContextDigest(result, journalContextDigestBudget) + lines := []string{} + for _, line := range strings.Split(got, "\n") { + if strings.HasPrefix(line, "diagnostic ") { + lines = append(lines, line) + } + } + want := []string{ + "diagnostic journal-context-unmatched-unblock: unblock has no unresolved block for the exact scope key (199 more with this code)", + "diagnostic change-source-unavailable: Change source unavailable", + } + if strings.Join(lines, "\n") != strings.Join(want, "\n") { + t.Fatalf("diagnostic lines = %q, want %q", lines, want) + } + if !strings.Contains(got, "e-branch-2 decision(digest)") { + t.Fatalf("repeated diagnostics crowded out entries:\n%s", got) + } +} + +func TestJournalContextDigestFoldsLabelsAndNamesLegacySparks(t *testing.T) { + result := digestFixture() + result.Layers.DeferredIntent = &state.JournalContextDeferralLayer{Available: true, AvailableCount: 1, ShownCount: 1, Items: []state.JournalContextDeferralItem{ + {OperationKey: "op-1", Spark: state.JournalContextDeferredSpark{ID: "spark-1", Text: "legacy\ndeferral"}}, + }} + result.Layers.ExplorationCheckpoints.Items[0].Title = "Digest\n shape" + result.Layers.BranchRecency.Items[0].Scope = "multi\nline scope" + got := renderJournalContextDigest(result, journalContextDigestBudget) + for _, want := range []string{ + "deferred-intent: showing 1 of 1 (read a spark: loaf spark show )\n op-1 (spark spark-1): legacy deferral\n", + " EXPL-1: Digest shape / next: compare budgets\n", + "e-branch-2 decision(multi line scope): one line per entry\n", + } { + if !strings.Contains(got, want) { + t.Fatalf("digest missing %q:\n%s", want, got) + } + } } diff --git a/internal/cli/journal_hook_context.go b/internal/cli/journal_hook_context.go index fdda0a2cd..f101c4712 100644 --- a/internal/cli/journal_hook_context.go +++ b/internal/cli/journal_hook_context.go @@ -81,21 +81,24 @@ func newJournalHookContextAvailable(contextResult journalContextCLIResult) (jour } // renderSessionStartDigest is the one emitter every harness SessionStart -// adapter renders through. The neutral digest is identical across harnesses; -// what the dispatch variant contributes is identity — which config dir's +// adapter renders through. The neutral digest is identical across harnesses, +// except that a drift nudge takes its share of the budget; what the dispatch +// variant contributes is identity — which config dir's // `.loaf-version` marker describes the content this conversation is actually // running on — so the drift nudge is resolved here rather than in each // adapter. An empty digest stays empty: the adapters treat that as a renderer // failure, and a nudge must never mask it. func (r Runner) renderSessionStartDigest(result journalContextCLIResult, harness string) string { - digest := r.renderSessionStartDigestWithoutDrift(result) + nudge := r.harnessDriftNudge(harness) + if nudge == "" { + return r.renderSessionStartDigestWithoutDrift(result) + } + // The nudge shares the digest budget so the whole payload stays bounded. + digest := strings.TrimSpace(renderJournalContextDigest(result, journalContextDigestBudget-len(nudge)-2)) if digest == "" { return "" } - if nudge := r.harnessDriftNudge(harness); nudge != "" { - digest += "\n\n" + nudge - } - return digest + return digest + "\n\n" + nudge } // renderSessionStartDigestWithoutDrift is the same digest for a harness whose diff --git a/internal/cli/target_capability_contract.go b/internal/cli/target_capability_contract.go index 0e1ca5f19..e74b2231c 100644 --- a/internal/cli/target_capability_contract.go +++ b/internal/cli/target_capability_contract.go @@ -156,6 +156,11 @@ type TargetContextCapabilityRecord struct { // before it previews, cuts, or drops the rest, as its own documentation states // it on the checked date. A target that documents no limit is held to the // tightest documented limit instead. +// +// It joined contract version 3 without a version bump: the registry is +// repo-local and read only by this binary and its tests, while the version is +// stamped into every built target manifest, so a bump would invalidate +// installed manifests without serving any reader. type TargetInjectionLimitRecord struct { Status string `json:"status"` Limit int `json:"limit,omitempty"`