From cb6cf71b42a226cc2f0560d60febc75ef3ac343b Mon Sep 17 00:00:00 2001 From: Jose Alekhinne Date: Sat, 30 May 2026 18:46:22 -0700 Subject: [PATCH 1/9] docs(spec): tpl text/template migration design (task 252) Add the design spec for migrating the multi-line block templates in internal/assets/tpl/tpl_*.go off fmt.Sprintf format-string constants to Go text/template + embedded files, and wire its reference into the line-252 task. Three tiers: (1) multi-line documents/scripts/config -> one embedded file each, parsed at init into *template.Template handles (no magic name literals at call sites); (2) the recall
/ HTML assembly -> two data-driven block templates (metaTable, details) that delete the scattered paired-tag constants; (3) single-line format strings, pure positional joins, and the RecallListRow meta-format stay fmt.Sprintf. No-panic init parse, gated by TestTemplatesParse. Behavior-preserving, asserted by byte-for-byte golden tests. Spec: specs/tpl-text-template-migration.md Signed-off-by: Jose Alekhinne --- .context/TASKS.md | 1 + specs/tpl-text-template-migration.md | 246 +++++++++++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 specs/tpl-text-template-migration.md diff --git a/.context/TASKS.md b/.context/TASKS.md index 510e38bb..dd337894 100644 --- a/.context/TASKS.md +++ b/.context/TASKS.md @@ -252,6 +252,7 @@ Important things that agent (or human) yeeted to the future. - [ ] Migrate Sprintf-based templates (tpl_*.go) to Go text/template or embedded template files — ObsidianReadme, LoopScript, and other multi-line format strings that can't move to YAML #added:2026-03-18-163629 + Spec: specs/tpl-text-template-migration.md - [ ] P0.8.5: Enable webhook notifications in worktrees. Currently `ctx notify` silently fails because `.context.key` is gitignored and absent in worktrees. For autonomous runs with opaque worktree agents, notifications diff --git a/specs/tpl-text-template-migration.md b/specs/tpl-text-template-migration.md new file mode 100644 index 00000000..a8c4affb --- /dev/null +++ b/specs/tpl-text-template-migration.md @@ -0,0 +1,246 @@ +# tpl-text-template-migration + +Covers TASKS.md task 252 ("Migrate Sprintf-based templates +(`tpl_*.go`) to Go `text/template` or embedded template files"). + +## Problem + +The multi-line block templates in `internal/assets/tpl/tpl_*.go` are +stored as `fmt.Sprintf` format-string constants. For documents, +scripts, and config blocks this is the wrong tool: + +- **Positional `%s`/`%d` verbs are unreadable and unsafe at scale.** + `LoopScript` takes six positional args assembled in a precise order + (`script.go:61`); `Decision` passes `title` twice + (`fmt.go:100`). A reordered argument is a silent corruption, not a + compile error. +- **The copy lives inside `.go` source**, so editing a generated + README or a TOML block means editing Go string literals with + backtick-escaping gymnastics (`tpl_obsidian.go` interleaves + `` ` `` + `"..."` + `` ` `` just to embed a fenced block). +- **HTML is assembled by scattered paired-tag writes.** The recall + formatter (`source/format/format.go`) builds `
`/`
` + blocks by emitting an open constant, looping rows, then a close + constant across ~25 lines. The open/close pair is a structural + invariant smeared across the call site — its own code smell. +- **`tpl_obsidian.go`'s own docstring already prescribes the fix**: + "should migrate to a Go text/template or an embedded template file + when the template rendering pipeline is implemented (see + TASKS.md)." This spec is that pipeline. + +These templates can't move to the YAML `desc.Text` system (which is +for short/long single-string descriptions); they need real template +rendering. + +## Settled Decisions + +Resolved during spec review (2026-05-30): + +1. **Tier-3 stays `fmt.Sprintf`.** Pure positional joins + (`RecallFencedBlock = "%s\n%s\n%s"`, `Fm*`, `ToolDisplay`) and the + `RecallListRow` meta-format are not templates; converting them adds + indirection (and a name surface) for no readability gain. +2. **Tier-2 is refactored, not demoted.** The interleaved paired-tag + call sites are the smell; the fix is two data-driven block + templates that own the structure, per the no-broken-windows + invariant — not leaving them as scattered `Sprintf` because it is + easier. +3. **No `panic` on init parse.** Parse-at-init + a `TestTemplatesParse` + CI guard + an error-returning `Render`. No `template.Must` (it + panics, and has no precedent in this repo). + +## Approach + +Move multi-line template **text out of `.go` into embedded files** +under `internal/assets/tpl/templates/`, parsed once via Go +`text/template`, following the existing pattern in +`internal/cli/system/core/message/render.go`. The embedded `assets.FS` +(`internal/assets/embed.go`) is the delivery mechanism — it already +embeds an `entry-templates/*.md` set, so a sibling `tpl/templates/` +glob is idiomatic. + +**No magic strings (hard constraint).** The exported identifier is +preserved but retyped: `tpl.ObsidianReadme` changes from a +`string` format constant to a parsed `*template.Template` handle. +Call sites reference the **handle**, never a name literal: + +```go +// before +[]byte(fmt.Sprintf(tpl.ObsidianReadme, journalDir)) +// after +out, err := tpl.Render(tpl.ObsidianReadme, obsidianData{JournalDir: journalDir}) +``` + +The string filename (`"tpl/templates/obsidian-readme.md.tmpl"`) appears +**exactly once**, in the parse table inside the `tpl` package — never +at a call site. This satisfies `audit/magic_strings_test.go`, which +would have rejected an earlier `Render("obsidian-readme", …)` sketch. + +### Three tiers (full inventory below) + +| Tier | What | Treatment | +|------|------|-----------| +| **1 — Blocks** | Multi-line documents/scripts/config | One embedded file each; `*.tmpl` (interpolated) or static (`Zensical*`) | +| **2 — HTML assembly** | Recall `
`/`
` blocks built from paired-tag constants | Refactor into two data-driven block templates (`metaTable`, `details`); the paired constants are deleted | +| **3 — Joins** | Single-line format strings + pure positional joins + the meta-format | **Stay `fmt.Sprintf` consts** (not templates) | + +### Rendering helper + +Generalize `message/render.go` into the `tpl` package: + +```go +// Render executes a parsed template handle against data, returning +// the rendered string. A non-nil error means a programmer bug (bad +// field, malformed embedded template) — golden tests gate against it. +func Render(t *template.Template, data any) (string, error) +``` + +Templates are parsed at package init from the embedded FS into the +exported handles. Parse failures are collected (not panicked) and +asserted empty by `TestTemplatesParse`, so a malformed embedded +template fails CI rather than reaching production. + +### Tier-2 refactor detail + +Two block templates replace six paired-tag constants +(`MetaDetailsOpen/Close`, `MetaRow`, `RecallDetailsOpen/Close`, +`RecallPlanOpen/Close`): + +- **`metaTable`** — input `{Summary string; Rows []struct{Label, Value string}}`. + Replaces `format.go:255-276` and `280-293`: build the rows slice + (conditional rows like `GitBranch`/`Model`/`Parts` become + conditional appends), render once. `MetaRow` becomes a `{{range}}` + body, not a standalone const. +- **`details`** — input `{Summary, Body string}`. Replaces the three + open/close pairs (`format.go:357-359`, `396-400`, + `collapse.go:92-100`): the caller builds the inner body string + (e.g. `
`-escaped content) and the template wraps it.
+
+## Behavior
+
+### Happy Path
+
+1. At `tpl` init, each `*.tmpl` file is read from `assets.FS` and
+   parsed into its exported `*template.Template` handle.
+2. A call site builds a typed data struct and calls
+   `tpl.Render(tpl.X, data)`.
+3. `Render` executes into a `bytes.Buffer` and returns the string —
+   **byte-for-byte identical** to today's output, trailing newlines
+   included.
+4. Static blocks (`ZensicalProject`, `ZensicalTheme`) are exposed as
+   `string` values loaded from their embedded files at init; their
+   `sb.WriteString(...)` call sites (`generate.go:182,242`) are
+   unchanged.
+
+### Edge Cases
+
+| Case | Expected behavior |
+|------|-------------------|
+| Empty data field (e.g. empty `journalDir`) | Renders the empty string into the placeholder — same as `Sprintf("%s","")`. No special-casing. |
+| `LoopScript` with `maxIterations == 0` | `{{if .MaxIter}}…{{end}}` renders nothing — replaces the "inject empty `maxIterCheck`" composition (`script.go:53-59`). Output identical. |
+| `LoopScript` tool selection | `aiCommand` is chosen in Go (small `LoopCmd*` consts stay) and passed as `{{.AICommand}}`; the template does not branch on tool. |
+| `metaTable` conditional rows | Absent `GitBranch`/`Model`/`Parts` append no row — matches the current `if s.X != ""` guards exactly. |
+| **Whitespace fidelity (the chief hazard)** | `MetaDetailsOpen` ends `
` with *no* newline; the first `` follows on the same line. The `{{range}}`/`{{define}}` blocks need explicit `{{-`/`-}}` trimming to reproduce exact bytes. Golden tests assert this. | +| Malformed embedded template ships | `init` records the parse error; `TestTemplatesParse` fails in CI. Cannot reach a release. | +| Exec error (missing/renamed field) | `Render` returns non-nil error; the call site's golden test fails pre-merge. | + +### Validation Rules + +Template data is passed as typed structs (one per template), so field +presence is compile-checked. No runtime input validation is added — +inputs are already-validated values from existing call sites. + +### Error Handling + +| Error condition | User-facing message | Recovery | +|-----------------|---------------------|----------| +| Init parse failure (malformed `.tmpl`) | None in prod (CI-gated); dev sees `TestTemplatesParse` failure naming the file | Fix the template file | +| Exec error (bad field) | Propagated by `Render`; call sites that today return a bare `string` (`SiteReadme`, `script.Generate`) gain an `error` return or a test-guaranteed wrapper | Golden test catches pre-merge | + +## Interface + +Internal refactor — **no CLI, no skill, no user-visible surface +change**. The "interface" is the `tpl` package API: exported +`*template.Template` handles + static `string`s + `Render`. Output of +every affected command is byte-identical. + +## Implementation + +### Files to Create/Modify + +| File | Change | +|------|--------| +| `internal/assets/tpl/templates/*.tmpl`, `*.toml` | **New** — extracted Tier-1 bodies + `blocks.tmpl` holding the `metaTable` and `details` `{{define}}`s | +| `internal/assets/tpl/render.go` | **New** — `Render(t, data)`, init parse table (the only place filenames appear), parse-error collection, `TestTemplatesParse` target | +| `internal/assets/embed.go` | Add `//go:embed tpl/templates/*` glob | +| `internal/assets/tpl/tpl_*.go` | Retype migrated consts → handles / FS-loaded strings; delete migrated bodies + the six Tier-2 paired-tag consts; Tier-3 consts stay | +| `internal/cli/journal/core/source/format/format.go` | Tier-2 refactor: build `metaTable` rows + `details` bodies, render via handles (replaces `255-293`, `357-359`, `394-400`) | +| `internal/cli/journal/core/collapse/collapse.go` | Tier-2: `92-100` → `details` render | +| `internal/cli/journal/core/obsidian/vault.go:91` | `Sprintf(tpl.ObsidianReadme,…)` → `Render` | +| `internal/cli/journal/core/generate/generate.go:37` | `SiteReadme` → `Render`; `Zensical*` `WriteString` unchanged (FS-loaded strings) | +| `internal/cli/loop/core/script/script.go:61` | Replace 6-arg `Sprintf` + `maxIterCheck` pre-format with one `Render(tpl.LoopScript, loopData{…})` | +| `internal/cli/trigger/cmd/add/cmd.go:93` | `Sprintf(tpl.TriggerScript,…)` → `Render` | +| `internal/cli/add/core/format/fmt.go:63-101` | `Learning`/`Decision` → `Render` (removes the double-`title` positional surface) | + +### Helpers to Reuse + +- `internal/cli/system/core/message/render.go` — the parse+execute+buffer + pattern to generalize (don't reinvent). +- `internal/assets` `embed.FS` — existing embed delivery. +- `internal/io.SafeWriteFile` / `SafeFprintf` — unchanged where Tier-3 + consts remain. + +### Full Inventory (every `tpl_*.go` constant) + +**Tier 1 — embedded files:** `ObsidianReadme`, `JournalSiteReadme`, +`LoopScript` (absorbs `LoopMaxIter` as `{{if .MaxIter}}` and +`LoopNotify` as a `{{define}}`), `TriggerScript`, `Learning`, +`Decision`; static: `ZensicalProject`, `ZensicalTheme`. + +**Tier 2 — absorbed into block templates (consts deleted):** +`MetaDetailsOpen`, `MetaDetailsClose`, `MetaRow` → `metaTable`; +`RecallDetailsOpen`, `RecallDetailsClose`, `RecallPlanOpen`, +`RecallPlanClose` → `details`. + +**Tier 3 — stay `fmt.Sprintf`:** single-line format strings +(`LoadBudget`, `LoadSectionHeading`, `RecallTurnHeader`, +`RecallDetailsSummary`, `JournalMonthHeading`, `Task*`, `Convention`, +`HubEntryMarkdown`, `JournalNav*`, stats lines, `LoopCmd*`, …); pure +positional joins (`RecallFencedBlock`, `Fm{Quoted,String,Int}`, +`ToolDisplay`, `RecallFilename`, `RecallPartFilename`); the meta-format +`RecallListRow`. + +## Configuration + +None. No `.ctxrc` keys, environment variables, or settings. + +## Testing + +- **Golden equivalence (the core guarantee):** for every migrated + template, assert `Render(handle, data)` is byte-for-byte equal to the + legacy `fmt.Sprintf(oldConst, args)` output for representative + inputs. Capture legacy output as a golden fixture *before* deleting + the old const. +- **Tier-2 assembly goldens:** full-output tests for the two metadata + tables (with/without `GitBranch`/`Model`/`Parts`), the plan block, + the tool-result `
` (collapsed and fenced branches), and + `collapse.go` (wrapped and already-wrapped). These guard the + whitespace-fidelity hazard. +- **`TestTemplatesParse`:** asserts the init parse-error set is empty. +- **Per-call-site tests:** `loop/core/script` (with/without + max-iterations, each tool), `trigger/cmd/add`, `add/core/format`, + `journal/core/generate` (SiteReadme + full `ZensicalToml`). +- **Compliance:** `internal/audit/magic_strings_test.go` and the + `compliance` suite stay green (no name literals at call sites). + +## Non-Goals + +- **Not** migrating Tier-3 format strings, pure joins, or the + `RecallListRow` meta-format — they are not templates. +- **Not** changing any rendered output — behavior-preserving, asserted + by golden tests. +- **Not** touching the YAML `desc.Text` system or moving anything into + YAML. +- **Not** adding caching/perf work; init-time parse is sufficient. +- **Not** restructuring the recall formatter beyond the `
`/ + `
` assembly — only the paired-tag smell is in scope. From 96eee0294b9f99417553a278c75b1c8390f7c9b7 Mon Sep 17 00:00:00 2001 From: Jose Alekhinne Date: Sat, 30 May 2026 19:03:40 -0700 Subject: [PATCH 2/9] refactor(tpl): introduce text/template pipeline; migrate ObsidianReadme First slice of the tpl migration (task 252): stand up the rendering pipeline and convert ObsidianReadme as the proof-of-pattern. - internal/assets/tpl: tpl-local //go:embed templates/*, a Render helper generalizing message/render.go, and an init parse table. parseTemplate returns a non-nil template even on failure (recording the cause in parseErrs) so Render never panics on a nil handle; TestTemplatesParse turns a malformed template into a CI failure instead -- no template.Must, per the no-panic invariant. - ObsidianReadme is now a *template.Template handle; obsidian/vault.go renders via tpl.Render(tpl.ObsidianReadme, tpl.ObsidianData{...}), passing a typed struct (no map-key literal) so the non-exempt caller stays magic-string clean. - File split honors the audit: exported funcs in render.go, unexported loader in load.go, the data type in types.go. - TestObsidianReadmeMatchesLegacy keeps the pre-migration format string verbatim and asserts byte-for-byte identical render output. - Spec refined: embed is tpl-local (leaf package, cycle avoidance), not assets.FS. Spec: specs/tpl-text-template-migration.md Signed-off-by: Jose Alekhinne --- internal/assets/tpl/load.go | 56 ++++++++++++++++ internal/assets/tpl/render.go | 41 ++++++++++++ internal/assets/tpl/render_test.go | 66 +++++++++++++++++++ .../tpl/templates/obsidian-readme.md.tmpl | 21 ++++++ internal/assets/tpl/tpl_obsidian.go | 37 ----------- internal/assets/tpl/types.go | 13 ++++ internal/cli/journal/core/obsidian/vault.go | 11 ++-- specs/tpl-text-template-migration.md | 30 ++++++--- 8 files changed, 224 insertions(+), 51 deletions(-) create mode 100644 internal/assets/tpl/load.go create mode 100644 internal/assets/tpl/render.go create mode 100644 internal/assets/tpl/render_test.go create mode 100644 internal/assets/tpl/templates/obsidian-readme.md.tmpl delete mode 100644 internal/assets/tpl/tpl_obsidian.go create mode 100644 internal/assets/tpl/types.go diff --git a/internal/assets/tpl/load.go b/internal/assets/tpl/load.go new file mode 100644 index 00000000..6b4de8d8 --- /dev/null +++ b/internal/assets/tpl/load.go @@ -0,0 +1,56 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package tpl + +import ( + "embed" + "text/template" +) + +// templatesFS holds the multi-line template bodies migrated out of the +// fmt.Sprintf format-string constants. The embed is local to tpl: tpl +// is a leaf package, and reaching into the parent assets.FS would +// couple it there and invite the import cycle the embed_test split +// fought. +// +//go:embed templates/*.tmpl +var templatesFS embed.FS + +// parseErrs accumulates init-time template parse failures. It is empty +// in any correct build; TestTemplatesParse asserts so, turning a +// malformed embedded template into a CI failure rather than a runtime +// panic (the project forbids panic, and there is no template.Must +// precedent here). +var parseErrs []error + +// init parses every embedded template into its exported handle. +func init() { + ObsidianReadme = parseTemplate("templates/obsidian-readme.md.tmpl") +} + +// parseTemplate reads and parses one embedded template. On failure it +// records the cause in parseErrs and returns the non-nil (empty) +// template, so Render never receives a nil handle: the failure path +// stays panic-free while TestTemplatesParse flags it. +// +// Parameters: +// - path: embedded template path under templatesFS +// +// Returns: +// - *template.Template: the parsed template (never nil) +func parseTemplate(path string) *template.Template { + t := template.New(path) + body, readErr := templatesFS.ReadFile(path) + if readErr != nil { + parseErrs = append(parseErrs, readErr) + return t + } + if _, parseErr := t.Parse(string(body)); parseErr != nil { + parseErrs = append(parseErrs, parseErr) + } + return t +} diff --git a/internal/assets/tpl/render.go b/internal/assets/tpl/render.go new file mode 100644 index 00000000..cd4b50cb --- /dev/null +++ b/internal/assets/tpl/render.go @@ -0,0 +1,41 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package tpl + +import ( + "bytes" + "text/template" +) + +// ObsidianReadme renders the README for a generated Obsidian vault. +// Data: [ObsidianData]. Call sites render via [Render], passing this +// handle — never a name literal — so non-exempt caller packages stay +// clean under audit/magic_strings. +var ObsidianReadme *template.Template + +// Render executes a parsed template handle against data. +// +// The handle is always non-nil for a registered template (a parse +// failure still yields a usable empty template, recorded for +// TestTemplatesParse), so this never panics on a nil handle. An +// execution error (e.g. a renamed data field) is returned, not +// panicked; golden tests gate template correctness. +// +// Parameters: +// - t: a parsed template handle (e.g. [ObsidianReadme]) +// - data: the template's typed data struct +// +// Returns: +// - string: the rendered output +// - error: non-nil on an execution failure +func Render(t *template.Template, data any) (string, error) { + var buf bytes.Buffer + if execErr := t.Execute(&buf, data); execErr != nil { + return "", execErr + } + return buf.String(), nil +} diff --git a/internal/assets/tpl/render_test.go b/internal/assets/tpl/render_test.go new file mode 100644 index 00000000..4ad8c3d3 --- /dev/null +++ b/internal/assets/tpl/render_test.go @@ -0,0 +1,66 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package tpl + +import ( + "fmt" + "testing" +) + +// oldObsidianReadme is the pre-migration fmt.Sprintf format string, +// retained verbatim as the golden source. The migrated template +// (templates/obsidian-readme.md.tmpl) must reproduce its output +// byte-for-byte; this is the behavior-preserving contract. +const oldObsidianReadme = `# journal-obsidian (generated) + +Generated by ` + "`ctx journal obsidian`" + `, read-only. +Do not edit files here - changes will be overwritten on the next run. + +## To update + +1. Edit source entries in ` + "`%s/`" + ` +2. Regenerate: + +` + "```" + ` +ctx journal obsidian +` + "```" + ` + +## Usage + +Open this directory as an Obsidian vault: + +1. Open Obsidian +2. Choose "Open folder as vault" +3. Select this directory +` + +// TestTemplatesParse fails if any embedded template failed to parse at +// init — the CI guard that lets the migration avoid template.Must (and +// thus a panic) while still catching a malformed template. +func TestTemplatesParse(t *testing.T) { + if len(parseErrs) > 0 { + t.Fatalf("embedded templates failed to parse at init: %v", parseErrs) + } +} + +// TestObsidianReadmeMatchesLegacy proves the migrated template renders +// identically to the legacy fmt.Sprintf form. +func TestObsidianReadmeMatchesLegacy(t *testing.T) { + const journalDir = "src/journal" + want := fmt.Sprintf(oldObsidianReadme, journalDir) + + got, err := Render(ObsidianReadme, ObsidianData{JournalDir: journalDir}) + if err != nil { + t.Fatalf("Render returned error: %v", err) + } + if got != want { + t.Errorf( + "ObsidianReadme render drift:\n--- want ---\n%q\n--- got ---\n%q", + want, got, + ) + } +} diff --git a/internal/assets/tpl/templates/obsidian-readme.md.tmpl b/internal/assets/tpl/templates/obsidian-readme.md.tmpl new file mode 100644 index 00000000..a410c6c7 --- /dev/null +++ b/internal/assets/tpl/templates/obsidian-readme.md.tmpl @@ -0,0 +1,21 @@ +# journal-obsidian (generated) + +Generated by `ctx journal obsidian`, read-only. +Do not edit files here - changes will be overwritten on the next run. + +## To update + +1. Edit source entries in `{{.JournalDir}}/` +2. Regenerate: + +``` +ctx journal obsidian +``` + +## Usage + +Open this directory as an Obsidian vault: + +1. Open Obsidian +2. Choose "Open folder as vault" +3. Select this directory diff --git a/internal/assets/tpl/tpl_obsidian.go b/internal/assets/tpl/tpl_obsidian.go deleted file mode 100644 index 7b42a5e1..00000000 --- a/internal/assets/tpl/tpl_obsidian.go +++ /dev/null @@ -1,37 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -package tpl - -// ObsidianReadme is the README template for the generated Obsidian vault. -// Args: journal source directory path. -// -// This template contains multi-line Markdown with fmt.Sprintf placeholders, -// which cannot be expressed in the YAML short/long text format. It should -// migrate to a Go text/template or an embedded template file when the -// template rendering pipeline is implemented (see TASKS.md). -const ObsidianReadme = `# journal-obsidian (generated) - -Generated by ` + "`ctx journal obsidian`" + `, read-only. -Do not edit files here - changes will be overwritten on the next run. - -## To update - -1. Edit source entries in ` + "`%s/`" + ` -2. Regenerate: - -` + "```" + ` -ctx journal obsidian -` + "```" + ` - -## Usage - -Open this directory as an Obsidian vault: - -1. Open Obsidian -2. Choose "Open folder as vault" -3. Select this directory -` diff --git a/internal/assets/tpl/types.go b/internal/assets/tpl/types.go new file mode 100644 index 00000000..c04ffcca --- /dev/null +++ b/internal/assets/tpl/types.go @@ -0,0 +1,13 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package tpl + +// ObsidianData is the render data for [ObsidianReadme]. +type ObsidianData struct { + // JournalDir is the journal source directory path. + JournalDir string +} diff --git a/internal/cli/journal/core/obsidian/vault.go b/internal/cli/journal/core/obsidian/vault.go index 19f79453..7b2ef033 100644 --- a/internal/cli/journal/core/obsidian/vault.go +++ b/internal/cli/journal/core/obsidian/vault.go @@ -7,7 +7,6 @@ package obsidian import ( - "fmt" "os" "path/filepath" @@ -86,10 +85,14 @@ func BuildVault(cmd *cobra.Command, journalDir, output string) error { // Write README readmePath := filepath.Join(output, file.Readme) + readme, rErr := tpl.Render( + tpl.ObsidianReadme, tpl.ObsidianData{JournalDir: journalDir}, + ) + if rErr != nil { + return errFs.FileWrite(readmePath, rErr) + } if wErr := io.SafeWriteFile( - readmePath, - []byte(fmt.Sprintf(tpl.ObsidianReadme, journalDir)), - fs.PermFile, + readmePath, []byte(readme), fs.PermFile, ); wErr != nil { return errFs.FileWrite(readmePath, wErr) } diff --git a/specs/tpl-text-template-migration.md b/specs/tpl-text-template-migration.md index a8c4affb..1519201a 100644 --- a/specs/tpl-text-template-migration.md +++ b/specs/tpl-text-template-migration.md @@ -48,16 +48,23 @@ Resolved during spec review (2026-05-30): 3. **No `panic` on init parse.** Parse-at-init + a `TestTemplatesParse` CI guard + an error-returning `Render`. No `template.Must` (it panics, and has no precedent in this repo). +4. **`tpl`-local embed, not `assets.FS`** (discovered in impl). `tpl` + is a leaf package; a local `//go:embed` keeps it that way and + avoids an import cycle. `tpl` is already in the magic-string audit's + `exemptStringPackages`, so the parse-table path literals are + sanctioned; call sites use typed data structs (no map-key literals). ## Approach Move multi-line template **text out of `.go` into embedded files** under `internal/assets/tpl/templates/`, parsed once via Go `text/template`, following the existing pattern in -`internal/cli/system/core/message/render.go`. The embedded `assets.FS` -(`internal/assets/embed.go`) is the delivery mechanism — it already -embeds an `entry-templates/*.md` set, so a sibling `tpl/templates/` -glob is idiomatic. +`internal/cli/system/core/message/render.go`. Delivery is a +**`tpl`-local `//go:embed templates/*`**, not the parent `assets.FS`: +`tpl` is a leaf package (zero internal imports), and reaching into +`assets.FS` would couple it to that package and invite the import +cycle the recent `embed_test` split fought. A local embed keeps `tpl` +self-contained (stdlib `embed`/`text/template` only). **No magic strings (hard constraint).** The exported identifier is preserved but retyped: `tpl.ObsidianReadme` changes from a @@ -71,10 +78,13 @@ Call sites reference the **handle**, never a name literal: out, err := tpl.Render(tpl.ObsidianReadme, obsidianData{JournalDir: journalDir}) ``` -The string filename (`"tpl/templates/obsidian-readme.md.tmpl"`) appears -**exactly once**, in the parse table inside the `tpl` package — never -at a call site. This satisfies `audit/magic_strings_test.go`, which -would have rejected an earlier `Render("obsidian-readme", …)` sketch. +The template-path literal appears only in the parse table inside the +`tpl` package, which `audit/magic_strings_test.go` already lists in +`exemptStringPackages` — so it is sanctioned there and never reaches a +call site. Call-site data is a **typed struct** (`tpl.ObsidianData{…}`), +never `map[string]any{"Key":…}`: a map-key literal in a non-exempt +caller would itself trip the magic-string audit. This is why the +earlier `Render("obsidian-readme", …)` sketch was wrong. ### Three tiers (full inventory below) @@ -171,8 +181,8 @@ every affected command is byte-identical. | File | Change | |------|--------| | `internal/assets/tpl/templates/*.tmpl`, `*.toml` | **New** — extracted Tier-1 bodies + `blocks.tmpl` holding the `metaTable` and `details` `{{define}}`s | -| `internal/assets/tpl/render.go` | **New** — `Render(t, data)`, init parse table (the only place filenames appear), parse-error collection, `TestTemplatesParse` target | -| `internal/assets/embed.go` | Add `//go:embed tpl/templates/*` glob | +| `internal/assets/tpl/render.go` | **New** — `tpl`-local `//go:embed templates/*`, `Render(t, data)`, init parse table (the only place filenames appear), parse-error collection, `ParseErrors()` for `TestTemplatesParse`, typed data structs | +| `internal/assets/embed.go` | **Untouched** — the embed is local to `tpl`, not the parent `assets.FS` (cycle avoidance) | | `internal/assets/tpl/tpl_*.go` | Retype migrated consts → handles / FS-loaded strings; delete migrated bodies + the six Tier-2 paired-tag consts; Tier-3 consts stay | | `internal/cli/journal/core/source/format/format.go` | Tier-2 refactor: build `metaTable` rows + `details` bodies, render via handles (replaces `255-293`, `357-359`, `394-400`) | | `internal/cli/journal/core/collapse/collapse.go` | Tier-2: `92-100` → `details` render | From 3d94b288978d39df2a9ffc5e0147e9e82a84d7a9 Mon Sep 17 00:00:00 2001 From: Jose Alekhinne Date: Sat, 30 May 2026 19:16:57 -0700 Subject: [PATCH 3/9] refactor(tpl): migrate JournalSiteReadme, TriggerScript, Learning, Decision Chunk 1 of the tpl migration (task 252): the four straightforward interpolated block templates move to embedded files behind handles. - New templates/{journal-site-readme.md,trigger-script.sh,learning.md, decision.md}.tmpl; the consts are deleted (tpl_trigger.go, which held only TriggerScript, is removed entirely). - TriggerScript drops fmt's positional %[1]s/%[2]s for {{.Name}}/ {{.Type}}; Decision drops the repeated-%s title for {{.Title}} twice -- both eliminate positional-argument fragility. - Render returns (string, error), so the bare-string helpers it now feeds gain an error return: generate.SiteReadme, format.Learning, format.Decision, with callers (journal/cmd/site, entry/write) propagating. trigger/cmd/add renders inline; its fmt import is gone. - Golden tests keep each legacy format string verbatim and assert byte-for-byte identical output. Spec: specs/tpl-text-template-migration.md Signed-off-by: Jose Alekhinne --- internal/assets/tpl/load.go | 4 + internal/assets/tpl/render.go | 14 ++ internal/assets/tpl/render_test.go | 140 +++++++++++++++--- .../assets/tpl/templates/decision.md.tmpl | 11 ++ .../tpl/templates/journal-site-readme.md.tmpl | 14 ++ .../assets/tpl/templates/learning.md.tmpl | 7 + .../tpl/templates/trigger-script.sh.tmpl | 24 +++ internal/assets/tpl/tpl_entry.go | 26 ---- internal/assets/tpl/tpl_journal.go | 18 --- internal/assets/tpl/tpl_trigger.go | 47 ------ internal/assets/tpl/types.go | 42 ++++++ internal/cli/add/core/format/fmt.go | 27 ++-- internal/cli/journal/cmd/site/run.go | 7 +- .../cli/journal/core/generate/generate.go | 8 +- internal/cli/trigger/cmd/add/cmd.go | 8 +- internal/entry/write.go | 12 +- 16 files changed, 284 insertions(+), 125 deletions(-) create mode 100644 internal/assets/tpl/templates/decision.md.tmpl create mode 100644 internal/assets/tpl/templates/journal-site-readme.md.tmpl create mode 100644 internal/assets/tpl/templates/learning.md.tmpl create mode 100644 internal/assets/tpl/templates/trigger-script.sh.tmpl delete mode 100644 internal/assets/tpl/tpl_trigger.go diff --git a/internal/assets/tpl/load.go b/internal/assets/tpl/load.go index 6b4de8d8..1124546b 100644 --- a/internal/assets/tpl/load.go +++ b/internal/assets/tpl/load.go @@ -30,6 +30,10 @@ var parseErrs []error // init parses every embedded template into its exported handle. func init() { ObsidianReadme = parseTemplate("templates/obsidian-readme.md.tmpl") + JournalSiteReadme = parseTemplate("templates/journal-site-readme.md.tmpl") + TriggerScript = parseTemplate("templates/trigger-script.sh.tmpl") + Learning = parseTemplate("templates/learning.md.tmpl") + Decision = parseTemplate("templates/decision.md.tmpl") } // parseTemplate reads and parses one embedded template. On failure it diff --git a/internal/assets/tpl/render.go b/internal/assets/tpl/render.go index cd4b50cb..57ab5326 100644 --- a/internal/assets/tpl/render.go +++ b/internal/assets/tpl/render.go @@ -17,6 +17,20 @@ import ( // clean under audit/magic_strings. var ObsidianReadme *template.Template +// JournalSiteReadme renders the README for the journal-site directory. +// Data: [JournalSiteData]. +var JournalSiteReadme *template.Template + +// TriggerScript renders the scaffold bash script for `ctx trigger add`. +// Data: [TriggerData]. +var TriggerScript *template.Template + +// Learning renders a learning entry section. Data: [LearningData]. +var Learning *template.Template + +// Decision renders a decision (ADR) entry section. Data: [DecisionData]. +var Decision *template.Template + // Render executes a parsed template handle against data. // // The handle is always non-nil for a registered template (a parse diff --git a/internal/assets/tpl/render_test.go b/internal/assets/tpl/render_test.go index 4ad8c3d3..c564d09b 100644 --- a/internal/assets/tpl/render_test.go +++ b/internal/assets/tpl/render_test.go @@ -9,12 +9,14 @@ package tpl import ( "fmt" "testing" + "text/template" ) -// oldObsidianReadme is the pre-migration fmt.Sprintf format string, -// retained verbatim as the golden source. The migrated template -// (templates/obsidian-readme.md.tmpl) must reproduce its output -// byte-for-byte; this is the behavior-preserving contract. +// The old* constants below are the pre-migration fmt.Sprintf format +// strings, kept verbatim as golden sources. Each migrated template +// must reproduce its legacy output byte-for-byte; that is the +// behavior-preserving contract this file enforces. + const oldObsidianReadme = `# journal-obsidian (generated) Generated by ` + "`ctx journal obsidian`" + `, read-only. @@ -38,6 +40,87 @@ Open this directory as an Obsidian vault: 3. Select this directory ` +const oldJournalSiteReadme = `# journal-site (generated) + +This directory is generated by ` + "`ctx journal site`" + ` and is read-only. +Do not edit files here - changes will be overwritten on the next run. + +## To update + +1. Edit source entries in ` + "`%s/`" + ` +2. Regenerate: + +` + "```" + ` +ctx journal site # generate +ctx journal site --serve # generate and preview +` + "```" + ` +` + +const oldTriggerScript = `#!/usr/bin/env bash +# Trigger: %s +# Type: %s +# Created by: ctx trigger add +# +# Enable with: ctx trigger enable %[1]s +# Test with: ctx trigger test %[2]s + +set -euo pipefail + +# Read the JSON event payload from stdin. +INPUT=$(cat) + +# Parse the fields you need from the payload. +TRIGGER_TYPE=$(echo "$INPUT" | jq -r '.hookType // empty') +TOOL=$(echo "$INPUT" | jq -r '.tool // empty') +PATH_ARG=$(echo "$INPUT" | jq -r '.path // empty') + +# Your trigger logic here. + +# Return a JSON response on stdout. "cancel": true blocks +# the tool call (pre-tool-use only); "context" injects +# additional context; "message" is shown to the user. +echo '{"cancel": false, "context": "", "message": ""}' +` + +const oldLearning = `## [%s] %s + +**Context**: %s + +**Lesson**: %s + +**Application**: %s +` + +const oldDecision = `## [%s] %s + +**Status**: Accepted + +**Context**: %s + +**Decision**: %s + +**Rationale**: %s + +**Consequence**: %s +` + +// assertRenderMatches renders tmpl with data and fails if the output +// differs from want. +func assertRenderMatches( + t *testing.T, tmpl *template.Template, data any, want string, +) { + t.Helper() + got, err := Render(tmpl, data) + if err != nil { + t.Fatalf("Render returned error: %v", err) + } + if got != want { + t.Errorf( + "render drift:\n--- want ---\n%q\n--- got ---\n%q", want, got, + ) + } +} + // TestTemplatesParse fails if any embedded template failed to parse at // init — the CI guard that lets the migration avoid template.Must (and // thus a panic) while still catching a malformed template. @@ -47,20 +130,43 @@ func TestTemplatesParse(t *testing.T) { } } -// TestObsidianReadmeMatchesLegacy proves the migrated template renders -// identically to the legacy fmt.Sprintf form. func TestObsidianReadmeMatchesLegacy(t *testing.T) { const journalDir = "src/journal" - want := fmt.Sprintf(oldObsidianReadme, journalDir) + assertRenderMatches(t, ObsidianReadme, + ObsidianData{JournalDir: journalDir}, + fmt.Sprintf(oldObsidianReadme, journalDir)) +} + +func TestJournalSiteReadmeMatchesLegacy(t *testing.T) { + const journalDir = "src/journal" + assertRenderMatches(t, JournalSiteReadme, + JournalSiteData{JournalDir: journalDir}, + fmt.Sprintf(oldJournalSiteReadme, journalDir)) +} - got, err := Render(ObsidianReadme, ObsidianData{JournalDir: journalDir}) - if err != nil { - t.Fatalf("Render returned error: %v", err) - } - if got != want { - t.Errorf( - "ObsidianReadme render drift:\n--- want ---\n%q\n--- got ---\n%q", - want, got, - ) - } +func TestTriggerScriptMatchesLegacy(t *testing.T) { + const name, hookType = "my-trigger", "pre-tool-use" + assertRenderMatches(t, TriggerScript, + TriggerData{Name: name, Type: hookType}, + fmt.Sprintf(oldTriggerScript, name, hookType)) +} + +func TestLearningMatchesLegacy(t *testing.T) { + const ts, title, ctxt, lesson, app = "2026-05-30-120000", "T", "C", "L", "A" + assertRenderMatches(t, Learning, + LearningData{ + Timestamp: ts, Title: title, Context: ctxt, + Lesson: lesson, Application: app, + }, + fmt.Sprintf(oldLearning, ts, title, ctxt, lesson, app)) +} + +func TestDecisionMatchesLegacy(t *testing.T) { + const ts, title, ctxt, rat, cons = "2026-05-30-120000", "T", "C", "R", "Q" + assertRenderMatches(t, Decision, + DecisionData{ + Timestamp: ts, Title: title, Context: ctxt, + Rationale: rat, Consequence: cons, + }, + fmt.Sprintf(oldDecision, ts, title, ctxt, title, rat, cons)) } diff --git a/internal/assets/tpl/templates/decision.md.tmpl b/internal/assets/tpl/templates/decision.md.tmpl new file mode 100644 index 00000000..97479fdf --- /dev/null +++ b/internal/assets/tpl/templates/decision.md.tmpl @@ -0,0 +1,11 @@ +## [{{.Timestamp}}] {{.Title}} + +**Status**: Accepted + +**Context**: {{.Context}} + +**Decision**: {{.Title}} + +**Rationale**: {{.Rationale}} + +**Consequence**: {{.Consequence}} diff --git a/internal/assets/tpl/templates/journal-site-readme.md.tmpl b/internal/assets/tpl/templates/journal-site-readme.md.tmpl new file mode 100644 index 00000000..8f8e9de5 --- /dev/null +++ b/internal/assets/tpl/templates/journal-site-readme.md.tmpl @@ -0,0 +1,14 @@ +# journal-site (generated) + +This directory is generated by `ctx journal site` and is read-only. +Do not edit files here - changes will be overwritten on the next run. + +## To update + +1. Edit source entries in `{{.JournalDir}}/` +2. Regenerate: + +``` +ctx journal site # generate +ctx journal site --serve # generate and preview +``` diff --git a/internal/assets/tpl/templates/learning.md.tmpl b/internal/assets/tpl/templates/learning.md.tmpl new file mode 100644 index 00000000..e4bd8ffd --- /dev/null +++ b/internal/assets/tpl/templates/learning.md.tmpl @@ -0,0 +1,7 @@ +## [{{.Timestamp}}] {{.Title}} + +**Context**: {{.Context}} + +**Lesson**: {{.Lesson}} + +**Application**: {{.Application}} diff --git a/internal/assets/tpl/templates/trigger-script.sh.tmpl b/internal/assets/tpl/templates/trigger-script.sh.tmpl new file mode 100644 index 00000000..5155a1e2 --- /dev/null +++ b/internal/assets/tpl/templates/trigger-script.sh.tmpl @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Trigger: {{.Name}} +# Type: {{.Type}} +# Created by: ctx trigger add +# +# Enable with: ctx trigger enable {{.Name}} +# Test with: ctx trigger test {{.Type}} + +set -euo pipefail + +# Read the JSON event payload from stdin. +INPUT=$(cat) + +# Parse the fields you need from the payload. +TRIGGER_TYPE=$(echo "$INPUT" | jq -r '.hookType // empty') +TOOL=$(echo "$INPUT" | jq -r '.tool // empty') +PATH_ARG=$(echo "$INPUT" | jq -r '.path // empty') + +# Your trigger logic here. + +# Return a JSON response on stdout. "cancel": true blocks +# the tool call (pre-tool-use only); "context" injects +# additional context; "message" is shown to the user. +echo '{"cancel": false, "context": "", "message": ""}' diff --git a/internal/assets/tpl/tpl_entry.go b/internal/assets/tpl/tpl_entry.go index 81d6fe32..0a14a4d4 100644 --- a/internal/assets/tpl/tpl_entry.go +++ b/internal/assets/tpl/tpl_entry.go @@ -31,33 +31,7 @@ const ( // Args: short commit hash. TaskCommit = " #commit:%s" - // Learning formats a learning section with all ADR-style fields. - // Args: timestamp, title, context, lesson, application. - Learning = `## [%s] %s - -**Context**: %s - -**Lesson**: %s - -**Application**: %s -` - // Convention formats a convention list item. // Args: content. Convention = "- %s\n" - - // Decision formats a decision section with all ADR fields. - // Args: timestamp, title, context, title (repeated), rationale, consequence. - Decision = `## [%s] %s - -**Status**: Accepted - -**Context**: %s - -**Decision**: %s - -**Rationale**: %s - -**Consequence**: %s -` ) diff --git a/internal/assets/tpl/tpl_journal.go b/internal/assets/tpl/tpl_journal.go index 8b422a26..ed080cde 100644 --- a/internal/assets/tpl/tpl_journal.go +++ b/internal/assets/tpl/tpl_journal.go @@ -11,24 +11,6 @@ package tpl // These templates define the structure of generated journal site pages. // Each uses fmt.Sprintf verbs for interpolation. const ( - // JournalSiteReadme formats the README for the journal-site directory. - // Args: journalDir. - JournalSiteReadme = `# journal-site (generated) - -This directory is generated by ` + "`ctx journal site`" + ` and is read-only. -Do not edit files here - changes will be overwritten on the next run. - -## To update - -1. Edit source entries in ` + "`%s/`" + ` -2. Regenerate: - -` + "```" + ` -ctx journal site # generate -ctx journal site --serve # generate and preview -` + "```" + ` -` - // JournalIndexIntro is the introductory line on the journal index. JournalIndexIntro = "Browse your AI session history." diff --git a/internal/assets/tpl/tpl_trigger.go b/internal/assets/tpl/tpl_trigger.go deleted file mode 100644 index 47c3f9b1..00000000 --- a/internal/assets/tpl/tpl_trigger.go +++ /dev/null @@ -1,47 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -package tpl - -// Shell script template used by `ctx trigger add` to -// scaffold new lifecycle triggers. -const ( - // TriggerScript is the bash template written to - // .context/hooks//.sh by ctx trigger add. - // - // Args (in order): - // - name: trigger script base name (without .sh) - // - type: trigger type (e.g. pre-tool-use, session-start) - // - // The generated script has no executable bit; users - // must run `ctx trigger enable ` after review, so - // unreviewed code never fires on real events. - TriggerScript = `#!/usr/bin/env bash -# Trigger: %s -# Type: %s -# Created by: ctx trigger add -# -# Enable with: ctx trigger enable %[1]s -# Test with: ctx trigger test %[2]s - -set -euo pipefail - -# Read the JSON event payload from stdin. -INPUT=$(cat) - -# Parse the fields you need from the payload. -TRIGGER_TYPE=$(echo "$INPUT" | jq -r '.hookType // empty') -TOOL=$(echo "$INPUT" | jq -r '.tool // empty') -PATH_ARG=$(echo "$INPUT" | jq -r '.path // empty') - -# Your trigger logic here. - -# Return a JSON response on stdout. "cancel": true blocks -# the tool call (pre-tool-use only); "context" injects -# additional context; "message" is shown to the user. -echo '{"cancel": false, "context": "", "message": ""}' -` -) diff --git a/internal/assets/tpl/types.go b/internal/assets/tpl/types.go index c04ffcca..4ce9ce3e 100644 --- a/internal/assets/tpl/types.go +++ b/internal/assets/tpl/types.go @@ -11,3 +11,45 @@ type ObsidianData struct { // JournalDir is the journal source directory path. JournalDir string } + +// JournalSiteData is the render data for [JournalSiteReadme]. +type JournalSiteData struct { + // JournalDir is the journal source directory path. + JournalDir string +} + +// TriggerData is the render data for [TriggerScript]. +type TriggerData struct { + // Name is the trigger script base name (without .sh). + Name string + // Type is the trigger type (e.g. pre-tool-use, session-start). + Type string +} + +// LearningData is the render data for [Learning]. +type LearningData struct { + // Timestamp is the entry creation timestamp. + Timestamp string + // Title is the learning title/summary. + Title string + // Context is what prompted the learning. + Context string + // Lesson is the key insight. + Lesson string + // Application is how to apply it going forward. + Application string +} + +// DecisionData is the render data for [Decision]. +type DecisionData struct { + // Timestamp is the entry creation timestamp. + Timestamp string + // Title is the decision title/summary. + Title string + // Context is what prompted the decision. + Context string + // Rationale is why this choice over alternatives. + Rationale string + // Consequence is what changes as a result. + Consequence string +} diff --git a/internal/cli/add/core/format/fmt.go b/internal/cli/add/core/format/fmt.go index 5a9bbba6..02bb19e2 100644 --- a/internal/cli/add/core/format/fmt.go +++ b/internal/cli/add/core/format/fmt.go @@ -60,11 +60,16 @@ func Task(content, priority, sessionID, branch, commit string) string { // // Returns: // - string: Formatted learning section with all fields -func Learning(title, context, lesson, application string) string { +// - error: non-nil if template rendering fails +func Learning(title, context, lesson, application string) (string, error) { timestamp := time.Now().Format(cfgTime.CompactTimestamp) - return fmt.Sprintf( - tpl.Learning, timestamp, title, context, lesson, application, - ) + return tpl.Render(tpl.Learning, tpl.LearningData{ + Timestamp: timestamp, + Title: title, + Context: context, + Lesson: lesson, + Application: application, + }) } // Convention formats a convention entry as a simple Markdown list item. @@ -93,10 +98,14 @@ func Convention(content string) string { // // Returns: // - string: Formatted decision section with all ADR fields -func Decision(title, context, rationale, consequence string) string { +// - error: non-nil if template rendering fails +func Decision(title, context, rationale, consequence string) (string, error) { timestamp := time.Now().Format(cfgTime.CompactTimestamp) - return fmt.Sprintf( - tpl.Decision, - timestamp, title, context, title, rationale, consequence, - ) + return tpl.Render(tpl.Decision, tpl.DecisionData{ + Timestamp: timestamp, + Title: title, + Context: context, + Rationale: rationale, + Consequence: consequence, + }) } diff --git a/internal/cli/journal/cmd/site/run.go b/internal/cli/journal/cmd/site/run.go index 6c852768..4d0cf908 100644 --- a/internal/cli/journal/cmd/site/run.go +++ b/internal/cli/journal/cmd/site/run.go @@ -109,9 +109,12 @@ func Run( // Write README readmePath := filepath.Join(output, file.Readme) + readme, rErr := generate.SiteReadme(journalDir) + if rErr != nil { + return errFs.FileWrite(readmePath, rErr) + } if writeErr := ctxIo.SafeWriteFile( - readmePath, - []byte(generate.SiteReadme(journalDir)), fs.PermFile, + readmePath, []byte(readme), fs.PermFile, ); writeErr != nil { return errFs.FileWrite(readmePath, writeErr) } diff --git a/internal/cli/journal/core/generate/generate.go b/internal/cli/journal/core/generate/generate.go index f4537e0d..b4efcfee 100644 --- a/internal/cli/journal/core/generate/generate.go +++ b/internal/cli/journal/core/generate/generate.go @@ -33,8 +33,12 @@ import ( // // Returns: // - string: Markdown README content with regeneration instructions -func SiteReadme(journalDir string) string { - return fmt.Sprintf(tpl.JournalSiteReadme, journalDir) +// - error: non-nil if template rendering fails +func SiteReadme(journalDir string) (string, error) { + return tpl.Render( + tpl.JournalSiteReadme, + tpl.JournalSiteData{JournalDir: journalDir}, + ) } // Index creates the index.md content for the journal site. diff --git a/internal/cli/trigger/cmd/add/cmd.go b/internal/cli/trigger/cmd/add/cmd.go index ae48a5d0..443fef70 100644 --- a/internal/cli/trigger/cmd/add/cmd.go +++ b/internal/cli/trigger/cmd/add/cmd.go @@ -7,7 +7,6 @@ package add import ( - "fmt" "path/filepath" "strings" @@ -90,7 +89,12 @@ func Run(c *cobra.Command, hookType, name string) error { return errTrigger.ScriptExists(filePath) } - content := fmt.Sprintf(tpl.TriggerScript, name, hookType) + content, rErr := tpl.Render( + tpl.TriggerScript, tpl.TriggerData{Name: name, Type: hookType}, + ) + if rErr != nil { + return errTrigger.WriteScript(rErr) + } writeErr := ctxIo.SafeWriteFile( filePath, []byte(content), fs.PermExec, ) diff --git a/internal/entry/write.go b/internal/entry/write.go index 9cfcea4e..6a909658 100644 --- a/internal/entry/write.go +++ b/internal/entry/write.go @@ -65,18 +65,26 @@ func Write(params entity.EntryParams) error { var formatted string switch fType { case entry.Decision: - formatted = format.Decision( + out, fErr := format.Decision( params.Content, params.Context, params.Rationale, params.Consequence, ) + if fErr != nil { + return fErr + } + formatted = out case entry.Task: formatted = format.Task( params.Content, params.Priority, params.SessionID, params.Branch, params.Commit, ) case entry.Learning: - formatted = format.Learning( + out, fErr := format.Learning( params.Content, params.Context, params.Lesson, params.Application, ) + if fErr != nil { + return fErr + } + formatted = out case entry.Convention: formatted = format.Convention(params.Content) default: From 5c4f2d8463d491b67c6953b68afdfe73762818dd Mon Sep 17 00:00:00 2001 From: Jose Alekhinne Date: Sat, 30 May 2026 19:25:39 -0700 Subject: [PATCH 4/9] refactor(tpl): migrate static Zensical blocks to embedded files Chunk 2 (task 252): ZensicalProject and ZensicalTheme were static multi-line consts (zero interpolation), so they move to embedded .toml files loaded verbatim as string vars at init via loadStatic (no text/template parse). Call sites are unchanged -- a string var swaps in for a string const transparently, so generate.ZensicalToml output stays identical. - Bodies extracted from the consts byte-for-byte (written by Go, not retyped); consts removed from tpl_journal.go (ZensicalExtraCSS, a one-liner, stays Tier-3). - embed glob extended to templates/*.toml; loadStatic added (no parse). - TestZensicalStaticLoaded pins the loaded blocks structurally; the generate package's ZensicalToml tests cover full output end-to-end. Spec: specs/tpl-text-template-migration.md Signed-off-by: Jose Alekhinne --- internal/assets/tpl/load.go | 21 ++++- internal/assets/tpl/render_test.go | 21 +++++ internal/assets/tpl/static.go | 17 ++++ .../tpl/templates/zensical-project.toml | 13 +++ .../assets/tpl/templates/zensical-theme.toml | 72 +++++++++++++++ internal/assets/tpl/tpl_journal.go | 91 ------------------- 6 files changed, 143 insertions(+), 92 deletions(-) create mode 100644 internal/assets/tpl/static.go create mode 100644 internal/assets/tpl/templates/zensical-project.toml create mode 100644 internal/assets/tpl/templates/zensical-theme.toml diff --git a/internal/assets/tpl/load.go b/internal/assets/tpl/load.go index 1124546b..83a587d4 100644 --- a/internal/assets/tpl/load.go +++ b/internal/assets/tpl/load.go @@ -17,7 +17,7 @@ import ( // couple it there and invite the import cycle the embed_test split // fought. // -//go:embed templates/*.tmpl +//go:embed templates/*.tmpl templates/*.toml var templatesFS embed.FS // parseErrs accumulates init-time template parse failures. It is empty @@ -34,6 +34,8 @@ func init() { TriggerScript = parseTemplate("templates/trigger-script.sh.tmpl") Learning = parseTemplate("templates/learning.md.tmpl") Decision = parseTemplate("templates/decision.md.tmpl") + ZensicalProject = loadStatic("templates/zensical-project.toml") + ZensicalTheme = loadStatic("templates/zensical-theme.toml") } // parseTemplate reads and parses one embedded template. On failure it @@ -58,3 +60,20 @@ func parseTemplate(path string) *template.Template { } return t } + +// loadStatic reads an embedded static (non-interpolated) template body +// as a string, recording any read failure in parseErrs. +// +// Parameters: +// - path: embedded file path under templatesFS +// +// Returns: +// - string: the file contents, or "" on read error +func loadStatic(path string) string { + body, readErr := templatesFS.ReadFile(path) + if readErr != nil { + parseErrs = append(parseErrs, readErr) + return "" + } + return string(body) +} diff --git a/internal/assets/tpl/render_test.go b/internal/assets/tpl/render_test.go index c564d09b..ec0b8738 100644 --- a/internal/assets/tpl/render_test.go +++ b/internal/assets/tpl/render_test.go @@ -8,6 +8,7 @@ package tpl import ( "fmt" + "strings" "testing" "text/template" ) @@ -170,3 +171,23 @@ func TestDecisionMatchesLegacy(t *testing.T) { }, fmt.Sprintf(oldDecision, ts, title, ctxt, title, rat, cons)) } + +// TestZensicalStaticLoaded checks the static blocks loaded from their +// embedded files (their bytes were extracted verbatim from the legacy +// consts at migration; full output is covered end-to-end by the +// generate package's ZensicalToml tests). +func TestZensicalStaticLoaded(t *testing.T) { + if !strings.HasPrefix(ZensicalProject, "[project]") { + t.Errorf("ZensicalProject should start with [project]; got %.16q", + ZensicalProject) + } + if !strings.Contains(ZensicalProject, `site_name = "ctx: Session Journal"`) { + t.Error("ZensicalProject missing site_name") + } + if !strings.Contains(ZensicalTheme, "[project.theme]") { + t.Error("ZensicalTheme missing [project.theme]") + } + if !strings.Contains(ZensicalTheme, "combine_header_slug = true") { + t.Error("ZensicalTheme missing markdown_extensions tail") + } +} diff --git a/internal/assets/tpl/static.go b/internal/assets/tpl/static.go new file mode 100644 index 00000000..1a00e192 --- /dev/null +++ b/internal/assets/tpl/static.go @@ -0,0 +1,17 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package tpl + +// ZensicalProject is the static [project] section of the generated +// zensical.toml. It has no interpolation; it is loaded verbatim from +// an embedded file at init and written through by callers as-is. +var ZensicalProject string + +// ZensicalTheme is the static theme and extras section of the +// generated zensical.toml, loaded verbatim from an embedded file at +// init. +var ZensicalTheme string diff --git a/internal/assets/tpl/templates/zensical-project.toml b/internal/assets/tpl/templates/zensical-project.toml new file mode 100644 index 00000000..795e507c --- /dev/null +++ b/internal/assets/tpl/templates/zensical-project.toml @@ -0,0 +1,13 @@ +[project] +site_name = "ctx: Session Journal" +site_description = "AI session history and notes" +site_author = "Jose Alekhinne " +site_url = "https://ctx.ist/" +repo_url = "https://github.com/ActiveMemory/ctx" +repo_name = "ActiveMemory/ctx" +copyright = """ +Copyright © 2026–present Context contributors.
+ctx's code is distributed under +Apache (v2.0).
+""" + diff --git a/internal/assets/tpl/templates/zensical-theme.toml b/internal/assets/tpl/templates/zensical-theme.toml new file mode 100644 index 00000000..25c0244d --- /dev/null +++ b/internal/assets/tpl/templates/zensical-theme.toml @@ -0,0 +1,72 @@ + + +[project.theme] +language = "en" +features = [ + "content.code.copy", + "navigation.instant", + "navigation.top", + "search.highlight", +] + +[[project.theme.palette]] +scheme = "default" +toggle.icon = "lucide/sun" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +scheme = "slate" +toggle.icon = "lucide/moon" +toggle.name = "Switch to light mode" + +[[project.theme.palette]] +scheme = "slate" +toggle.icon = "lucide/moon" +toggle.name = "Switch to light mode" + +[[project.extra.social]] +icon = "fontawesome/brands/github" +link = "https://github.com/ActiveMemory/ctx" + +[[project.extra.social]] +icon = "fontawesome/brands/discord" +link = "https://ctx.ist/discord" + +[project.extra] +generator = false + +# Markdown extensions - mirrors zensical defaults but disables Pygments +# code highlighting. Journal entries use
 for user turns and
+# fenced blocks for tool output; pymdownx.highlight with Pygments on
+# hijacks 
 patterns, transforming block boundaries and
+# swallowing subsequent content.
+[project.markdown_extensions]
+abbr = {}
+admonition = {}
+attr_list = {}
+def_list = {}
+footnotes = {}
+md_in_html = {}
+toc = { permalink = true }
+
+[project.markdown_extensions.pymdownx]
+betterem = {}
+caret = {}
+details = {}
+inlinehilite = {}
+keys = {}
+magiclink = {}
+mark = {}
+smartsymbols = {}
+tasklist = { custom_checkbox = true }
+tilde = {}
+
+[project.markdown_extensions.pymdownx.highlight]
+use_pygments = false
+
+[project.markdown_extensions.pymdownx.superfences]
+custom_fences = [{ name = "mermaid", class = "mermaid" }]
+
+[project.markdown_extensions.pymdownx.tabbed]
+alternate_style = true
+combine_header_slug = true
diff --git a/internal/assets/tpl/tpl_journal.go b/internal/assets/tpl/tpl_journal.go
index ed080cde..960dfac3 100644
--- a/internal/assets/tpl/tpl_journal.go
+++ b/internal/assets/tpl/tpl_journal.go
@@ -107,97 +107,6 @@ const (
 	// Args: title, filename.
 	JournalNavSessionItem = `    { "%s" = "%s" },`
 
-	// ZensicalProject is the [project] section of zensical.toml.
-	ZensicalProject = `[project]
-site_name = "ctx: Session Journal"
-site_description = "AI session history and notes"
-site_author = "Jose Alekhinne "
-site_url = "https://ctx.ist/"
-repo_url = "https://github.com/ActiveMemory/ctx"
-repo_name = "ActiveMemory/ctx"
-copyright = """
-Copyright © 2026–present Context contributors.
-ctx's code is distributed under -Apache (v2.0).
-""" - -` - - // ZensicalTheme is the theme and extras section of zensical.toml. - ZensicalTheme = ` - -[project.theme] -language = "en" -features = [ - "content.code.copy", - "navigation.instant", - "navigation.top", - "search.highlight", -] - -[[project.theme.palette]] -scheme = "default" -toggle.icon = "lucide/sun" -toggle.name = "Switch to dark mode" - -[[project.theme.palette]] -scheme = "slate" -toggle.icon = "lucide/moon" -toggle.name = "Switch to light mode" - -[[project.theme.palette]] -scheme = "slate" -toggle.icon = "lucide/moon" -toggle.name = "Switch to light mode" - -[[project.extra.social]] -icon = "fontawesome/brands/github" -link = "https://github.com/ActiveMemory/ctx" - -[[project.extra.social]] -icon = "fontawesome/brands/discord" -link = "https://ctx.ist/discord" - -[project.extra] -generator = false - -# Markdown extensions - mirrors zensical defaults but disables Pygments -# code highlighting. Journal entries use
 for user turns and
-# fenced blocks for tool output; pymdownx.highlight with Pygments on
-# hijacks 
 patterns, transforming block boundaries and
-# swallowing subsequent content.
-[project.markdown_extensions]
-abbr = {}
-admonition = {}
-attr_list = {}
-def_list = {}
-footnotes = {}
-md_in_html = {}
-toc = { permalink = true }
-
-[project.markdown_extensions.pymdownx]
-betterem = {}
-caret = {}
-details = {}
-inlinehilite = {}
-keys = {}
-magiclink = {}
-mark = {}
-smartsymbols = {}
-tasklist = { custom_checkbox = true }
-tilde = {}
-
-[project.markdown_extensions.pymdownx.highlight]
-use_pygments = false
-
-[project.markdown_extensions.pymdownx.superfences]
-custom_fences = [{ name = "mermaid", class = "mermaid" }]
-
-[project.markdown_extensions.pymdownx.tabbed]
-alternate_style = true
-combine_header_slug = true
-`
-
 	// ZensicalExtraCSS is the extra_css line for zensical.toml.
 	// Must appear under [project] (after nav, before [project.theme]).
 	ZensicalExtraCSS = `extra_css = ["stylesheets/extra.css"]`

From e3475f5b06f09807c4f8425eb6d20e8c3cd4b8ee Mon Sep 17 00:00:00 2001
From: Jose Alekhinne 
Date: Sat, 30 May 2026 19:33:32 -0700
Subject: [PATCH 5/9] refactor(tpl): migrate LoopScript to text/template

Chunk 3 (task 252): the Ralph-loop bash script -- the composed
template -- moves to an embedded file.

- templates/loop-script.sh.tmpl absorbs LoopMaxIter as a {{if .MaxIter}}
  block (replacing the Go-side maxIterCheck pre-format) and inlines
  LoopNotify literally at both completion points; the LoopScript,
  LoopMaxIter, and LoopNotify consts are removed. LoopCmd*/Load* stay
  Tier-3 (script.go still selects the tool command and passes it as
  {{.AICommand}}).
- script.Generate returns (string, error); its sole caller
  (loop/cmd/root) propagates. filepath.Abs's error -- previously
  discarded -- is now handled, since the signature already carries one.
- Verified by golden fixtures captured from the legacy code path
  (testdata/*.golden): byte-for-byte across the iteration-cap on/off
  branch and each tool.

Spec: specs/tpl-text-template-migration.md
Signed-off-by: Jose Alekhinne 
---
 internal/assets/tpl/load.go                   |  1 +
 internal/assets/tpl/render.go                 |  3 +
 .../assets/tpl/templates/loop-script.sh.tmpl  | 58 +++++++++++++++
 internal/assets/tpl/tpl_loop.go               | 72 -------------------
 internal/assets/tpl/types.go                  | 16 +++++
 internal/cli/loop/cmd/root/run.go             |  5 +-
 internal/cli/loop/core/script/script.go       | 30 ++++----
 internal/cli/loop/core/script/script_test.go  | 59 +++++++++++++++
 .../core/script/testdata/aider-nomax.golden   | 52 ++++++++++++++
 .../core/script/testdata/claude-max.golden    | 58 +++++++++++++++
 .../core/script/testdata/claude-nomax.golden  | 52 ++++++++++++++
 .../core/script/testdata/generic-max.golden   | 59 +++++++++++++++
 12 files changed, 375 insertions(+), 90 deletions(-)
 create mode 100644 internal/assets/tpl/templates/loop-script.sh.tmpl
 create mode 100644 internal/cli/loop/core/script/script_test.go
 create mode 100644 internal/cli/loop/core/script/testdata/aider-nomax.golden
 create mode 100644 internal/cli/loop/core/script/testdata/claude-max.golden
 create mode 100644 internal/cli/loop/core/script/testdata/claude-nomax.golden
 create mode 100644 internal/cli/loop/core/script/testdata/generic-max.golden

diff --git a/internal/assets/tpl/load.go b/internal/assets/tpl/load.go
index 83a587d4..a6b51738 100644
--- a/internal/assets/tpl/load.go
+++ b/internal/assets/tpl/load.go
@@ -34,6 +34,7 @@ func init() {
 	TriggerScript = parseTemplate("templates/trigger-script.sh.tmpl")
 	Learning = parseTemplate("templates/learning.md.tmpl")
 	Decision = parseTemplate("templates/decision.md.tmpl")
+	LoopScript = parseTemplate("templates/loop-script.sh.tmpl")
 	ZensicalProject = loadStatic("templates/zensical-project.toml")
 	ZensicalTheme = loadStatic("templates/zensical-theme.toml")
 }
diff --git a/internal/assets/tpl/render.go b/internal/assets/tpl/render.go
index 57ab5326..e7fe1f4b 100644
--- a/internal/assets/tpl/render.go
+++ b/internal/assets/tpl/render.go
@@ -31,6 +31,9 @@ var Learning *template.Template
 // Decision renders a decision (ADR) entry section. Data: [DecisionData].
 var Decision *template.Template
 
+// LoopScript renders the Ralph-loop bash script. Data: [LoopData].
+var LoopScript *template.Template
+
 // Render executes a parsed template handle against data.
 //
 // The handle is always non-nil for a registered template (a parse
diff --git a/internal/assets/tpl/templates/loop-script.sh.tmpl b/internal/assets/tpl/templates/loop-script.sh.tmpl
new file mode 100644
index 00000000..316c71b5
--- /dev/null
+++ b/internal/assets/tpl/templates/loop-script.sh.tmpl
@@ -0,0 +1,58 @@
+#!/bin/bash
+#
+# Context: Ralph Loop Script
+# Generated by: ctx loop
+#
+# This script runs an AI assistant in a loop until completion.
+# The AI works on the same prompt file repeatedly, building on
+# previous work visible in files and git history.
+#
+
+set -e
+
+PROMPT_FILE="{{.PromptFile}}"
+COMPLETION_SIGNAL="{{.CompletionSignal}}"
+ITERATION=0
+
+echo "Starting Ralph Loop"
+echo "==================="
+echo "Prompt: $PROMPT_FILE"
+echo "Completion signal: $COMPLETION_SIGNAL"
+echo ""
+
+# Ensure prompt file exists
+if [ ! -f "$PROMPT_FILE" ]; then
+    echo "Error: Prompt file not found: $PROMPT_FILE"
+    exit 1
+fi
+
+while true; do
+    ITERATION=$((ITERATION + 1))
+    echo ""
+    echo "=== Iteration $ITERATION ==="
+    echo ""
+
+{{if .MaxIter}}    # Check iteration limit
+    if [ $ITERATION -ge {{.MaxIter}} ]; then
+        echo "Reached maximum iterations ({{.MaxIter}})"
+        ctx hook notify --event loop "Loop completed after $ITERATION iterations" 2>/dev/null || true
+        break
+    fi
+{{end}}    # Run the AI tool
+    OUTPUT=$({{.AICommand}} 2>&1) || true
+
+    echo "$OUTPUT"
+
+    # Check for completion signal
+    if echo "$OUTPUT" | grep -q "$COMPLETION_SIGNAL"; then
+        echo ""
+        echo "{{.LoopComplete}}"
+        echo "Detected completion signal: $COMPLETION_SIGNAL"
+        echo "Total iterations: $ITERATION"
+        ctx hook notify --event loop "Loop completed after $ITERATION iterations" 2>/dev/null || true
+        break
+    fi
+
+    # Small delay to prevent runaway loops
+    sleep 1
+done
diff --git a/internal/assets/tpl/tpl_loop.go b/internal/assets/tpl/tpl_loop.go
index 97040914..030b248e 100644
--- a/internal/assets/tpl/tpl_loop.go
+++ b/internal/assets/tpl/tpl_loop.go
@@ -21,78 +21,6 @@ const (
 	// Args: title.
 	LoadSectionHeading = "## %s"
 
-	// LoopScript is the bash script template for the Ralph Loop.
-	// Args: promptFile, completionMsg, maxIterCheck,
-	// aiCommand, loopComplete, notifyCmd.
-	LoopScript = `#!/bin/bash
-#
-# Context: Ralph Loop Script
-# Generated by: ctx loop
-#
-# This script runs an AI assistant in a loop until completion.
-# The AI works on the same prompt file repeatedly, building on
-# previous work visible in files and git history.
-#
-
-set -e
-
-PROMPT_FILE="%s"
-COMPLETION_SIGNAL="%s"
-ITERATION=0
-
-echo "Starting Ralph Loop"
-echo "==================="
-echo "Prompt: $PROMPT_FILE"
-echo "Completion signal: $COMPLETION_SIGNAL"
-echo ""
-
-# Ensure prompt file exists
-if [ ! -f "$PROMPT_FILE" ]; then
-    echo "Error: Prompt file not found: $PROMPT_FILE"
-    exit 1
-fi
-
-while true; do
-    ITERATION=$((ITERATION + 1))
-    echo ""
-    echo "=== Iteration $ITERATION ==="
-    echo ""
-%s
-    # Run the AI tool
-    OUTPUT=$(%s 2>&1) || true
-
-    echo "$OUTPUT"
-
-    # Check for completion signal
-    if echo "$OUTPUT" | grep -q "$COMPLETION_SIGNAL"; then
-        echo ""
-        echo "%s"
-        echo "Detected completion signal: $COMPLETION_SIGNAL"
-        echo "Total iterations: $ITERATION"
-        %s
-        break
-    fi
-
-    # Small delay to prevent runaway loops
-    sleep 1
-done
-`
-
-	// LoopMaxIter is the iteration-limit check block for the loop script.
-	// Args: maxIterations, maxIterations, notifyCmd.
-	LoopMaxIter = `
-    # Check iteration limit
-    if [ $ITERATION -ge %d ]; then
-        echo "Reached maximum iterations (%d)"
-        %s
-        break
-    fi`
-
-	// LoopNotify is the ctx hook notify call appended to loop completion points.
-	LoopNotify = `ctx hook notify --event loop` +
-		` "Loop completed after $ITERATION iterations"` +
-		` 2>/dev/null || true`
-
 	// LoopCmdClaude is the shell command template for Claude Code.
 	// Args: promptFile.
 	LoopCmdClaude = `claude --print "$(cat %s)"`
diff --git a/internal/assets/tpl/types.go b/internal/assets/tpl/types.go
index 4ce9ce3e..e7b3f6e3 100644
--- a/internal/assets/tpl/types.go
+++ b/internal/assets/tpl/types.go
@@ -40,6 +40,22 @@ type LearningData struct {
 	Application string
 }
 
+// LoopData is the render data for [LoopScript].
+type LoopData struct {
+	// PromptFile is the absolute path to the loop's prompt file.
+	PromptFile string
+	// CompletionSignal is the string that, when seen in tool output,
+	// ends the loop.
+	CompletionSignal string
+	// MaxIter is the iteration cap; 0 means unlimited (the
+	// iteration-limit block is omitted).
+	MaxIter int
+	// AICommand is the shell command that runs the AI tool.
+	AICommand string
+	// LoopComplete is the completion banner line.
+	LoopComplete string
+}
+
 // DecisionData is the render data for [Decision].
 type DecisionData struct {
 	// Timestamp is the entry creation timestamp.
diff --git a/internal/cli/loop/cmd/root/run.go b/internal/cli/loop/cmd/root/run.go
index 798235f7..dfc2fe63 100644
--- a/internal/cli/loop/cmd/root/run.go
+++ b/internal/cli/loop/cmd/root/run.go
@@ -46,9 +46,12 @@ func Run(
 		return config.InvalidTool(tool)
 	}
 
-	s := script.Generate(
+	s, genErr := script.Generate(
 		promptFile, tool, maxIterations, completionMsg,
 	)
+	if genErr != nil {
+		return genErr
+	}
 
 	if writeErr := ctxIo.SafeWriteFile(
 		outputFile, []byte(s), fs.PermExec,
diff --git a/internal/cli/loop/core/script/script.go b/internal/cli/loop/core/script/script.go
index ec384508..d8167e69 100644
--- a/internal/cli/loop/core/script/script.go
+++ b/internal/cli/loop/core/script/script.go
@@ -30,13 +30,17 @@ import (
 //
 // Returns:
 //   - string: Complete bash script content
+//   - error: non-nil if the prompt path or template rendering fails
 func Generate(
 	promptFile, tool string,
 	maxIterations int,
 	completionMsg string,
-) string {
+) (string, error) {
 	// Get the absolute path for the prompt file
-	absPrompt, _ := filepath.Abs(promptFile)
+	absPrompt, absErr := filepath.Abs(promptFile)
+	if absErr != nil {
+		return "", absErr
+	}
 
 	var aiCommand string
 	switch tool {
@@ -50,19 +54,11 @@ func Generate(
 		)
 	}
 
-	maxIterCheck := ""
-	if maxIterations > 0 {
-		maxIterCheck = fmt.Sprintf(
-			tpl.LoopMaxIter,
-			maxIterations, maxIterations, tpl.LoopNotify,
-		)
-	}
-
-	script := fmt.Sprintf(tpl.LoopScript,
-		absPrompt, completionMsg, maxIterCheck, aiCommand,
-		desc.Text(text.DescKeyLabelLoopComplete),
-		tpl.LoopNotify,
-	)
-
-	return script
+	return tpl.Render(tpl.LoopScript, tpl.LoopData{
+		PromptFile:       absPrompt,
+		CompletionSignal: completionMsg,
+		MaxIter:          maxIterations,
+		AICommand:        aiCommand,
+		LoopComplete:     desc.Text(text.DescKeyLabelLoopComplete),
+	})
 }
diff --git a/internal/cli/loop/core/script/script_test.go b/internal/cli/loop/core/script/script_test.go
new file mode 100644
index 00000000..bc533167
--- /dev/null
+++ b/internal/cli/loop/core/script/script_test.go
@@ -0,0 +1,59 @@
+//   /    ctx:                         https://ctx.ist
+// ,'`./    do you remember?
+// `.,'\
+//   \    Copyright 2026-present Context contributors.
+//                 SPDX-License-Identifier: Apache-2.0
+
+package script
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/ActiveMemory/ctx/internal/assets/read/lookup"
+	cfgLoop "github.com/ActiveMemory/ctx/internal/config/loop"
+)
+
+func TestMain(m *testing.M) {
+	lookup.Init()
+	os.Exit(m.Run())
+}
+
+// TestGenerateMatchesLegacy asserts the text/template-based Generate
+// reproduces, byte-for-byte, the output of the pre-migration
+// fmt.Sprintf composition. The golden fixtures were captured from the
+// legacy code path (see git history) and cover both the iteration-cap
+// on/off branch and each tool's command.
+func TestGenerateMatchesLegacy(t *testing.T) {
+	cases := []struct {
+		name    string
+		tool    string
+		maxIter int
+	}{
+		{"claude-nomax", cfgLoop.DefaultTool, 0},
+		{"claude-max", cfgLoop.DefaultTool, 5},
+		{"aider-nomax", cfgLoop.ToolAider, 0},
+		{"generic-max", cfgLoop.ToolGeneric, 5},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			want, readErr := os.ReadFile(
+				filepath.Join("testdata", c.name+".golden"),
+			)
+			if readErr != nil {
+				t.Fatal(readErr)
+			}
+			got, genErr := Generate("/tmp/prompt.md", c.tool, c.maxIter, "DONE")
+			if genErr != nil {
+				t.Fatalf("Generate: %v", genErr)
+			}
+			if got != string(want) {
+				t.Errorf(
+					"drift:\n--- want ---\n%q\n--- got ---\n%q",
+					string(want), got,
+				)
+			}
+		})
+	}
+}
diff --git a/internal/cli/loop/core/script/testdata/aider-nomax.golden b/internal/cli/loop/core/script/testdata/aider-nomax.golden
new file mode 100644
index 00000000..31f33924
--- /dev/null
+++ b/internal/cli/loop/core/script/testdata/aider-nomax.golden
@@ -0,0 +1,52 @@
+#!/bin/bash
+#
+# Context: Ralph Loop Script
+# Generated by: ctx loop
+#
+# This script runs an AI assistant in a loop until completion.
+# The AI works on the same prompt file repeatedly, building on
+# previous work visible in files and git history.
+#
+
+set -e
+
+PROMPT_FILE="/tmp/prompt.md"
+COMPLETION_SIGNAL="DONE"
+ITERATION=0
+
+echo "Starting Ralph Loop"
+echo "==================="
+echo "Prompt: $PROMPT_FILE"
+echo "Completion signal: $COMPLETION_SIGNAL"
+echo ""
+
+# Ensure prompt file exists
+if [ ! -f "$PROMPT_FILE" ]; then
+    echo "Error: Prompt file not found: $PROMPT_FILE"
+    exit 1
+fi
+
+while true; do
+    ITERATION=$((ITERATION + 1))
+    echo ""
+    echo "=== Iteration $ITERATION ==="
+    echo ""
+
+    # Run the AI tool
+    OUTPUT=$(aider --message-file /tmp/prompt.md 2>&1) || true
+
+    echo "$OUTPUT"
+
+    # Check for completion signal
+    if echo "$OUTPUT" | grep -q "$COMPLETION_SIGNAL"; then
+        echo ""
+        echo "=== Loop Complete ==="
+        echo "Detected completion signal: $COMPLETION_SIGNAL"
+        echo "Total iterations: $ITERATION"
+        ctx hook notify --event loop "Loop completed after $ITERATION iterations" 2>/dev/null || true
+        break
+    fi
+
+    # Small delay to prevent runaway loops
+    sleep 1
+done
diff --git a/internal/cli/loop/core/script/testdata/claude-max.golden b/internal/cli/loop/core/script/testdata/claude-max.golden
new file mode 100644
index 00000000..9a7bc8db
--- /dev/null
+++ b/internal/cli/loop/core/script/testdata/claude-max.golden
@@ -0,0 +1,58 @@
+#!/bin/bash
+#
+# Context: Ralph Loop Script
+# Generated by: ctx loop
+#
+# This script runs an AI assistant in a loop until completion.
+# The AI works on the same prompt file repeatedly, building on
+# previous work visible in files and git history.
+#
+
+set -e
+
+PROMPT_FILE="/tmp/prompt.md"
+COMPLETION_SIGNAL="DONE"
+ITERATION=0
+
+echo "Starting Ralph Loop"
+echo "==================="
+echo "Prompt: $PROMPT_FILE"
+echo "Completion signal: $COMPLETION_SIGNAL"
+echo ""
+
+# Ensure prompt file exists
+if [ ! -f "$PROMPT_FILE" ]; then
+    echo "Error: Prompt file not found: $PROMPT_FILE"
+    exit 1
+fi
+
+while true; do
+    ITERATION=$((ITERATION + 1))
+    echo ""
+    echo "=== Iteration $ITERATION ==="
+    echo ""
+
+    # Check iteration limit
+    if [ $ITERATION -ge 5 ]; then
+        echo "Reached maximum iterations (5)"
+        ctx hook notify --event loop "Loop completed after $ITERATION iterations" 2>/dev/null || true
+        break
+    fi
+    # Run the AI tool
+    OUTPUT=$(claude --print "$(cat /tmp/prompt.md)" 2>&1) || true
+
+    echo "$OUTPUT"
+
+    # Check for completion signal
+    if echo "$OUTPUT" | grep -q "$COMPLETION_SIGNAL"; then
+        echo ""
+        echo "=== Loop Complete ==="
+        echo "Detected completion signal: $COMPLETION_SIGNAL"
+        echo "Total iterations: $ITERATION"
+        ctx hook notify --event loop "Loop completed after $ITERATION iterations" 2>/dev/null || true
+        break
+    fi
+
+    # Small delay to prevent runaway loops
+    sleep 1
+done
diff --git a/internal/cli/loop/core/script/testdata/claude-nomax.golden b/internal/cli/loop/core/script/testdata/claude-nomax.golden
new file mode 100644
index 00000000..7680aacd
--- /dev/null
+++ b/internal/cli/loop/core/script/testdata/claude-nomax.golden
@@ -0,0 +1,52 @@
+#!/bin/bash
+#
+# Context: Ralph Loop Script
+# Generated by: ctx loop
+#
+# This script runs an AI assistant in a loop until completion.
+# The AI works on the same prompt file repeatedly, building on
+# previous work visible in files and git history.
+#
+
+set -e
+
+PROMPT_FILE="/tmp/prompt.md"
+COMPLETION_SIGNAL="DONE"
+ITERATION=0
+
+echo "Starting Ralph Loop"
+echo "==================="
+echo "Prompt: $PROMPT_FILE"
+echo "Completion signal: $COMPLETION_SIGNAL"
+echo ""
+
+# Ensure prompt file exists
+if [ ! -f "$PROMPT_FILE" ]; then
+    echo "Error: Prompt file not found: $PROMPT_FILE"
+    exit 1
+fi
+
+while true; do
+    ITERATION=$((ITERATION + 1))
+    echo ""
+    echo "=== Iteration $ITERATION ==="
+    echo ""
+
+    # Run the AI tool
+    OUTPUT=$(claude --print "$(cat /tmp/prompt.md)" 2>&1) || true
+
+    echo "$OUTPUT"
+
+    # Check for completion signal
+    if echo "$OUTPUT" | grep -q "$COMPLETION_SIGNAL"; then
+        echo ""
+        echo "=== Loop Complete ==="
+        echo "Detected completion signal: $COMPLETION_SIGNAL"
+        echo "Total iterations: $ITERATION"
+        ctx hook notify --event loop "Loop completed after $ITERATION iterations" 2>/dev/null || true
+        break
+    fi
+
+    # Small delay to prevent runaway loops
+    sleep 1
+done
diff --git a/internal/cli/loop/core/script/testdata/generic-max.golden b/internal/cli/loop/core/script/testdata/generic-max.golden
new file mode 100644
index 00000000..8a68dd90
--- /dev/null
+++ b/internal/cli/loop/core/script/testdata/generic-max.golden
@@ -0,0 +1,59 @@
+#!/bin/bash
+#
+# Context: Ralph Loop Script
+# Generated by: ctx loop
+#
+# This script runs an AI assistant in a loop until completion.
+# The AI works on the same prompt file repeatedly, building on
+# previous work visible in files and git history.
+#
+
+set -e
+
+PROMPT_FILE="/tmp/prompt.md"
+COMPLETION_SIGNAL="DONE"
+ITERATION=0
+
+echo "Starting Ralph Loop"
+echo "==================="
+echo "Prompt: $PROMPT_FILE"
+echo "Completion signal: $COMPLETION_SIGNAL"
+echo ""
+
+# Ensure prompt file exists
+if [ ! -f "$PROMPT_FILE" ]; then
+    echo "Error: Prompt file not found: $PROMPT_FILE"
+    exit 1
+fi
+
+while true; do
+    ITERATION=$((ITERATION + 1))
+    echo ""
+    echo "=== Iteration $ITERATION ==="
+    echo ""
+
+    # Check iteration limit
+    if [ $ITERATION -ge 5 ]; then
+        echo "Reached maximum iterations (5)"
+        ctx hook notify --event loop "Loop completed after $ITERATION iterations" 2>/dev/null || true
+        break
+    fi
+    # Run the AI tool
+    OUTPUT=$(# Replace with your AI CLI command
+    cat /tmp/prompt.md | your-ai-cli 2>&1) || true
+
+    echo "$OUTPUT"
+
+    # Check for completion signal
+    if echo "$OUTPUT" | grep -q "$COMPLETION_SIGNAL"; then
+        echo ""
+        echo "=== Loop Complete ==="
+        echo "Detected completion signal: $COMPLETION_SIGNAL"
+        echo "Total iterations: $ITERATION"
+        ctx hook notify --event loop "Loop completed after $ITERATION iterations" 2>/dev/null || true
+        break
+    fi
+
+    # Small delay to prevent runaway loops
+    sleep 1
+done

From 997a70e7f80df38e2ed189bfe20003bfd705cd72 Mon Sep 17 00:00:00 2001
From: Jose Alekhinne 
Date: Sat, 30 May 2026 19:56:30 -0700
Subject: [PATCH 6/9] refactor(tpl): migrate recall HTML to block templates
 (Tier-2, final)

Chunk 4 (task 252, final): the recall formatter's 
/
blocks move from scattered paired-tag constants to two embedded block templates, completing the migration. - metaTable template ({Summary, Rows}) replaces MetaDetailsOpen + per-row MetaRow + MetaDetailsClose in source/format (metadata and token-stats tables): format.go builds a typed row slice (conditional Branch/Model/Parts rows) and renders once instead of scattering conditional Fprintf calls. - details template ({Summary, Body}) replaces RecallDetailsOpen/Close and RecallPlanOpen/Close across three sites: the plan block, the collapsed tool-result block (format.go), and collapse.ToolOutputs. Seven paired-tag consts are deleted; PlanSummary stays (Tier-3). - tpl.RenderOr added for best-effort string builders whose callers don't return errors (the recall formatter, the Import counter): it logs warn.TemplateRender and returns a fallback on the parse-gated- impossible error rather than contorting those signatures. The error-returning tpl.Render still serves chunks 1-3. - Byte-for-byte goldens captured from the legacy code path cover the metadata table (all conditional rows), the plan block, fenced + collapsed tool results, and the collapse wrap path. Closes task 252. Spec: specs/tpl-text-template-migration.md Signed-off-by: Jose Alekhinne --- .context/TASKS.md | 6 +- internal/assets/tpl/load.go | 2 + internal/assets/tpl/render.go | 34 ++++++ .../assets/tpl/templates/details.html.tmpl | 5 + .../assets/tpl/templates/meta-table.html.tmpl | 5 + internal/assets/tpl/tpl_recall.go | 31 +----- internal/assets/tpl/types.go | 25 +++++ .../cli/journal/core/collapse/collapse.go | 14 ++- .../cli/journal/core/collapse/golden_test.go | 37 +++++++ .../core/collapse/testdata/wrapped.golden | 19 ++++ .../cli/journal/core/source/format/format.go | 78 ++++++++------ .../source/format/testdata/metafull.golden | 54 ++++++++++ .../core/source/format/testdata/plan.golden | 43 ++++++++ .../core/source/format/testdata/single.golden | 41 +++++++ .../source/format/testdata/tooluse.golden | 76 +++++++++++++ .../core/source/format/tier2_golden_test.go | 101 ++++++++++++++++++ internal/config/warn/warn.go | 7 ++ 17 files changed, 506 insertions(+), 72 deletions(-) create mode 100644 internal/assets/tpl/templates/details.html.tmpl create mode 100644 internal/assets/tpl/templates/meta-table.html.tmpl create mode 100644 internal/cli/journal/core/collapse/golden_test.go create mode 100644 internal/cli/journal/core/collapse/testdata/wrapped.golden create mode 100644 internal/cli/journal/core/source/format/testdata/metafull.golden create mode 100644 internal/cli/journal/core/source/format/testdata/plan.golden create mode 100644 internal/cli/journal/core/source/format/testdata/single.golden create mode 100644 internal/cli/journal/core/source/format/testdata/tooluse.golden create mode 100644 internal/cli/journal/core/source/format/tier2_golden_test.go diff --git a/.context/TASKS.md b/.context/TASKS.md index dd337894..dc414d99 100644 --- a/.context/TASKS.md +++ b/.context/TASKS.md @@ -249,10 +249,14 @@ These have priority because other knowledge ingestion projects depend on them. Important things that agent (or human) yeeted to the future. -- [ ] Migrate Sprintf-based templates (tpl_*.go) to Go text/template or embedded +- [x] Migrate Sprintf-based templates (tpl_*.go) to Go text/template or embedded template files — ObsidianReadme, LoopScript, and other multi-line format strings that can't move to YAML #added:2026-03-18-163629 Spec: specs/tpl-text-template-migration.md + DONE 2026-05-30 (branch refactor/tpl-text-template-migration). Tier-1 blocks + + static Zensical + LoopScript + Tier-2 recall HTML (metaTable/details) + migrated to embedded templates behind handles; Tier-3 single-line format + strings, pure joins, and the RecallListRow meta-format kept as fmt.Sprintf. - [ ] P0.8.5: Enable webhook notifications in worktrees. Currently `ctx notify` silently fails because `.context.key` is gitignored and absent in worktrees. For autonomous runs with opaque worktree agents, notifications diff --git a/internal/assets/tpl/load.go b/internal/assets/tpl/load.go index a6b51738..1a8a5518 100644 --- a/internal/assets/tpl/load.go +++ b/internal/assets/tpl/load.go @@ -35,6 +35,8 @@ func init() { Learning = parseTemplate("templates/learning.md.tmpl") Decision = parseTemplate("templates/decision.md.tmpl") LoopScript = parseTemplate("templates/loop-script.sh.tmpl") + MetaTable = parseTemplate("templates/meta-table.html.tmpl") + Details = parseTemplate("templates/details.html.tmpl") ZensicalProject = loadStatic("templates/zensical-project.toml") ZensicalTheme = loadStatic("templates/zensical-theme.toml") } diff --git a/internal/assets/tpl/render.go b/internal/assets/tpl/render.go index e7fe1f4b..77b3dfad 100644 --- a/internal/assets/tpl/render.go +++ b/internal/assets/tpl/render.go @@ -9,6 +9,9 @@ package tpl import ( "bytes" "text/template" + + "github.com/ActiveMemory/ctx/internal/config/warn" + logWarn "github.com/ActiveMemory/ctx/internal/log/warn" ) // ObsidianReadme renders the README for a generated Obsidian vault. @@ -34,6 +37,14 @@ var Decision *template.Template // LoopScript renders the Ralph-loop bash script. Data: [LoopData]. var LoopScript *template.Template +// MetaTable renders a collapsible session-metadata HTML table. +// Data: [MetaTableData]. +var MetaTable *template.Template + +// Details renders a collapsible
block wrapping a body. +// Data: [DetailsData]. +var Details *template.Template + // Render executes a parsed template handle against data. // // The handle is always non-nil for a registered template (a parse @@ -56,3 +67,26 @@ func Render(t *template.Template, data any) (string, error) { } return buf.String(), nil } + +// RenderOr renders like [Render] but suits best-effort string builders +// whose callers do not return errors (the recall formatter, the Import +// counter). On the render error it logs a warning and returns fallback +// rather than propagating: the template is parse-gated by +// TestTemplatesParse and fed typed data, so Execute cannot fail in a +// correct build — the fallback is unreachable defense. +// +// Parameters: +// - t: a parsed template handle +// - data: the template's typed data struct +// - fallback: returned on the unreachable error path +// +// Returns: +// - string: the rendered output, or fallback on error +func RenderOr(t *template.Template, data any, fallback string) string { + out, err := Render(t, data) + if err != nil { + logWarn.Warn(warn.TemplateRender, err) + return fallback + } + return out +} diff --git a/internal/assets/tpl/templates/details.html.tmpl b/internal/assets/tpl/templates/details.html.tmpl new file mode 100644 index 00000000..96d78421 --- /dev/null +++ b/internal/assets/tpl/templates/details.html.tmpl @@ -0,0 +1,5 @@ +
+{{.Summary}} + +{{.Body}} +
\ No newline at end of file diff --git a/internal/assets/tpl/templates/meta-table.html.tmpl b/internal/assets/tpl/templates/meta-table.html.tmpl new file mode 100644 index 00000000..10bc1456 --- /dev/null +++ b/internal/assets/tpl/templates/meta-table.html.tmpl @@ -0,0 +1,5 @@ +
+{{.Summary}} +
{{range .Rows}} +{{end}}
{{.Label}}{{.Value}}
+
\ No newline at end of file diff --git a/internal/assets/tpl/tpl_recall.go b/internal/assets/tpl/tpl_recall.go index 1a7abe01..41509c69 100644 --- a/internal/assets/tpl/tpl_recall.go +++ b/internal/assets/tpl/tpl_recall.go @@ -42,17 +42,6 @@ const ( // Args: line count. RecallDetailsSummary = "%d lines" - // RecallDetailsOpen formats the opening HTML for collapsible content. - // Args: summary text. INVARIANT: the tag is always single-line - // (N lines). Multi-line blocks (standalone - // on its own line) are Claude Code context compaction artifacts - // and are stripped by stripSystemReminders. This distinction is the basis - // for safe disambiguation. - RecallDetailsOpen = "
\n%s" - - // RecallDetailsClose is the closing HTML for collapsible content. - RecallDetailsClose = "
" - // RecallFencedBlock formats content inside code fences. // Args: fence, content, fence. RecallFencedBlock = "%s\n%s\n%s" @@ -77,18 +66,6 @@ const ( // Args: slug, shortID, dateTime. SessionMatch = "%s (%s) - %s" - // MetaDetailsOpen opens a collapsible details block with an HTML table. - // Markdown tables don't render inside
in Zensical, so we use HTML. - // Args: summary text. - MetaDetailsOpen = "
\n%s\n" - - // MetaDetailsClose closes a collapsible details block with HTML table. - MetaDetailsClose = "
\n
" - - // MetaRow formats a single row in an HTML metadata table. - // Args: label, value. - MetaRow = "%s%s" - // FmQuoted formats a YAML frontmatter quoted string field. // Args: key, value. FmQuoted = "%s: %q" @@ -105,11 +82,9 @@ const ( // Args: tool name, parameter value. ToolDisplay = "%s: %s" - // RecallPlanOpen opens a collapsible plan section. - RecallPlanOpen = "
\n📋 Plan\n" - - // RecallPlanClose closes a collapsible plan section. - RecallPlanClose = "\n
" + // PlanSummary is the label for a collapsible plan + // section, rendered via the [Details] template. + PlanSummary = "📋 Plan" // RecallApiError is a collapsed API error message. RecallApiError = "> ⚠ API error response (message omitted)" diff --git a/internal/assets/tpl/types.go b/internal/assets/tpl/types.go index e7b3f6e3..84c5c88d 100644 --- a/internal/assets/tpl/types.go +++ b/internal/assets/tpl/types.go @@ -69,3 +69,28 @@ type DecisionData struct { // Consequence is what changes as a result. Consequence string } + +// MetaTableData is the render data for [MetaTable]. +type MetaTableData struct { + // Summary is the text for the collapsible block. + Summary string + // Rows are the table's label/value rows, in order. + Rows []MetaRow +} + +// MetaRow is one label/value row in a [MetaTable]. +type MetaRow struct { + // Label is the row's bold left-column text. + Label string + // Value is the row's right-column text. + Value string +} + +// DetailsData is the render data for [Details]. +type DetailsData struct { + // Summary is the text for the collapsible block. + Summary string + // Body is the pre-rendered block body (already escaped/wrapped by + // the caller). + Body string +} diff --git a/internal/cli/journal/core/collapse/collapse.go b/internal/cli/journal/core/collapse/collapse.go index c251739b..9afcd383 100644 --- a/internal/cli/journal/core/collapse/collapse.go +++ b/internal/cli/journal/core/collapse/collapse.go @@ -89,15 +89,13 @@ func ToolOutputs(content string) string { summary := fmt.Sprintf( tpl.RecallDetailsSummary, nonBlank, ) - out = append(out, header, "") - out = append(out, - fmt.Sprintf(tpl.RecallDetailsOpen, summary), + body := strings.Join( + lines[bodyStart:bodyEnd], token.NewlineLF, ) - out = append(out, "") - for k := bodyStart; k < bodyEnd; k++ { - out = append(out, lines[k]) - } - out = append(out, tpl.RecallDetailsClose, "") + rendered := tpl.RenderOr(tpl.Details, tpl.DetailsData{ + Summary: summary, Body: body, + }, "") + out = append(out, header, "", rendered, "") } else { for k := i; k < bodyEnd; k++ { out = append(out, lines[k]) diff --git a/internal/cli/journal/core/collapse/golden_test.go b/internal/cli/journal/core/collapse/golden_test.go new file mode 100644 index 00000000..b6172458 --- /dev/null +++ b/internal/cli/journal/core/collapse/golden_test.go @@ -0,0 +1,37 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package collapse + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + "github.com/ActiveMemory/ctx/internal/config/embed/text" +) + +// TestToolOutputsMatchesLegacy asserts the details-template rewrite of +// the long-output wrap path reproduces the legacy paired-tag output +// byte-for-byte. The fixture was captured from the legacy code path. +func TestToolOutputsMatchesLegacy(t *testing.T) { + header := turnHeader( + 1, desc.Text(text.DescKeyLabelToolOutput), "10:00:00", + ) + input := header + "\n\n" + bodyLines(12) + "\n" + + want, readErr := os.ReadFile(filepath.Join("testdata", "wrapped.golden")) + if readErr != nil { + t.Fatal(readErr) + } + got := ToolOutputs(input) + if got != string(want) { + t.Errorf( + "drift:\n--- want ---\n%q\n--- got ---\n%q", string(want), got, + ) + } +} diff --git a/internal/cli/journal/core/collapse/testdata/wrapped.golden b/internal/cli/journal/core/collapse/testdata/wrapped.golden new file mode 100644 index 00000000..ff200c04 --- /dev/null +++ b/internal/cli/journal/core/collapse/testdata/wrapped.golden @@ -0,0 +1,19 @@ +### 1. Tool Output (10:00:00) + +
+12 lines + +line 1 +line 2 +line 3 +line 4 +line 5 +line 6 +line 7 +line 8 +line 9 +line 10 +line 11 +line 12 + +
diff --git a/internal/cli/journal/core/source/format/format.go b/internal/cli/journal/core/source/format/format.go index 0450af19..b407c474 100644 --- a/internal/cli/journal/core/source/format/format.go +++ b/internal/cli/journal/core/source/format/format.go @@ -252,45 +252,50 @@ func JournalEntryPart( desc.Text(text.DescKeyJournalSourceMetaSummary), dateStr, durationStr, s.Model, ) - io.SafeFprintf(&sb, tpl.MetaDetailsOpen, summaryText) - io.SafeFprintf(&sb, - tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaID), s.ID) - io.SafeFprintf(&sb, - tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaDate), dateStr) - io.SafeFprintf(&sb, - tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaTime), timeStr) - io.SafeFprintf(&sb, - tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaDuration), durationStr) - io.SafeFprintf(&sb, - tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaTool), s.Tool) - io.SafeFprintf(&sb, - tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaProject), s.Project) + metaRows := []tpl.MetaRow{ + {Label: desc.Text(text.DescKeyLabelMetaID), Value: s.ID}, + {Label: desc.Text(text.DescKeyLabelMetaDate), Value: dateStr}, + {Label: desc.Text(text.DescKeyLabelMetaTime), Value: timeStr}, + {Label: desc.Text(text.DescKeyLabelMetaDuration), Value: durationStr}, + {Label: desc.Text(text.DescKeyLabelMetaTool), Value: s.Tool}, + {Label: desc.Text(text.DescKeyLabelMetaProject), Value: s.Project}, + } if s.GitBranch != "" { - io.SafeFprintf(&sb, - tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaBranch), s.GitBranch) + metaRows = append(metaRows, tpl.MetaRow{ + Label: desc.Text(text.DescKeyLabelMetaBranch), Value: s.GitBranch, + }) } if s.Model != "" { - io.SafeFprintf(&sb, - tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaModel), s.Model) + metaRows = append(metaRows, tpl.MetaRow{ + Label: desc.Text(text.DescKeyLabelMetaModel), Value: s.Model, + }) } - sb.WriteString(tpl.MetaDetailsClose + nl + nl) + metaOut := tpl.RenderOr(tpl.MetaTable, tpl.MetaTableData{ + Summary: summaryText, Rows: metaRows, + }, "") + sb.WriteString(metaOut + nl + nl) // Token stats as collapsible HTML table turnStr := strconv.Itoa(s.TurnCount) - io.SafeFprintf(&sb, tpl.MetaDetailsOpen, turnStr) - io.SafeFprintf(&sb, - tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaTurns), turnStr) - tokenSummary := fmt.Sprintf(desc.Text(text.DescKeyJournalSourceTokenSummary), + tokenSummary := fmt.Sprintf( + desc.Text(text.DescKeyJournalSourceTokenSummary), sharedFmt.Tokens(s.TotalTokens), sharedFmt.Tokens(s.TotalTokensIn), sharedFmt.Tokens(s.TotalTokensOut)) - io.SafeFprintf(&sb, - tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaTokens), tokenSummary) + statRows := []tpl.MetaRow{ + {Label: desc.Text(text.DescKeyLabelMetaTurns), Value: turnStr}, + {Label: desc.Text(text.DescKeyLabelMetaTokens), Value: tokenSummary}, + } if totalParts > 1 { - io.SafeFprintf(&sb, tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaParts), - strconv.Itoa(totalParts)) + statRows = append(statRows, tpl.MetaRow{ + Label: desc.Text(text.DescKeyLabelMetaParts), + Value: strconv.Itoa(totalParts), + }) } - sb.WriteString(tpl.MetaDetailsClose + nl + nl) + statOut := tpl.RenderOr(tpl.MetaTable, tpl.MetaTableData{ + Summary: turnStr, Rows: statRows, + }, "") + sb.WriteString(statOut + nl + nl) sb.WriteString(sep + nl + nl) @@ -354,9 +359,10 @@ func JournalEntryPart( // Render plan content as collapsible section. if msg.PlanContent != "" { - sb.WriteString(tpl.RecallPlanOpen + nl) - sb.WriteString(msg.PlanContent + nl) - sb.WriteString(tpl.RecallPlanClose + nl + nl) + planOut := tpl.RenderOr(tpl.Details, tpl.DetailsData{ + Summary: tpl.PlanSummary, Body: msg.PlanContent + nl, + }, "") + sb.WriteString(planOut + nl + nl) } // Render CC-level tool errors. @@ -393,11 +399,13 @@ func JournalEntryPart( if lines > journal.DetailsThreshold { summary := fmt.Sprintf(tpl.RecallDetailsSummary, lines) - io.SafeFprintf(&sb, tpl.RecallDetailsOpen+nl+nl, summary) - sb.WriteString(marker.TagPre + nl) - sb.WriteString(html.EscapeString(content) + nl) - sb.WriteString(marker.TagPreClose + nl) - sb.WriteString(tpl.RecallDetailsClose + nl) + body := marker.TagPre + nl + + html.EscapeString(content) + nl + + marker.TagPreClose + detOut := tpl.RenderOr(tpl.Details, tpl.DetailsData{ + Summary: summary, Body: body, + }, "") + sb.WriteString(detOut + nl) } else { io.SafeFprintf(&sb, tpl.RecallFencedBlock+nl, fence, content, fence) diff --git a/internal/cli/journal/core/source/format/testdata/metafull.golden b/internal/cli/journal/core/source/format/testdata/metafull.golden new file mode 100644 index 00000000..58b6bb6c --- /dev/null +++ b/internal/cli/journal/core/source/format/testdata/metafull.golden @@ -0,0 +1,54 @@ +--- +date: "2026-01-15" +time: "10:30:00" +project: myproject +branch: main +model: claude-opus +tokens_in: 10000 +tokens_out: 5000 +session_id: "abc12345-session-id" +--- + +# test-slug + +**Part 1 of 2** | [Next →](b-p2.md) + +--- + +
+2026-01-15 · 30m · claude-opus + + + + + + + + +
IDabc12345-session-id
Date2026-01-15
Time10:30:00
Duration30m
Toolclaude-code
Projectmyproject
Branchmain
Modelclaude-opus
+
+ +
+2 + + + +
Turns2
Tokens15.0K (in: 10.0K, out: 5.0K)
Parts2
+
+ +--- + +## Conversation + +### 1. User (10:30:00) + +Hello + +### 2. Assistant (10:30:05) + +Hi there! + + +--- + +**Part 1 of 2** | [Next →](b-p2.md) diff --git a/internal/cli/journal/core/source/format/testdata/plan.golden b/internal/cli/journal/core/source/format/testdata/plan.golden new file mode 100644 index 00000000..71726865 --- /dev/null +++ b/internal/cli/journal/core/source/format/testdata/plan.golden @@ -0,0 +1,43 @@ +--- +date: "2026-01-15" +time: "10:30:00" +project: myproject +tokens_in: 10000 +tokens_out: 5000 +session_id: "abc12345-session-id" +--- + +# test-slug + +
+2026-01-15 · 30m · + + + + + + +
IDabc12345-session-id
Date2026-01-15
Time10:30:00
Duration30m
Toolclaude-code
Projectmyproject
+
+ +
+2 + + +
Turns2
Tokens15.0K (in: 10.0K, out: 5.0K)
+
+ +--- + +## Conversation + +### 1. Assistant (10:31:00) + +
+📋 Plan + +step one +step two + +
+ diff --git a/internal/cli/journal/core/source/format/testdata/single.golden b/internal/cli/journal/core/source/format/testdata/single.golden new file mode 100644 index 00000000..a6327960 --- /dev/null +++ b/internal/cli/journal/core/source/format/testdata/single.golden @@ -0,0 +1,41 @@ +--- +date: "2026-01-15" +time: "10:30:00" +project: myproject +tokens_in: 10000 +tokens_out: 5000 +session_id: "abc12345-session-id" +--- + +# test-slug + +
+2026-01-15 · 30m · + + + + + + +
IDabc12345-session-id
Date2026-01-15
Time10:30:00
Duration30m
Toolclaude-code
Projectmyproject
+
+ +
+2 + + +
Turns2
Tokens15.0K (in: 10.0K, out: 5.0K)
+
+ +--- + +## Conversation + +### 1. User (10:30:00) + +Hello + +### 2. Assistant (10:30:05) + +Hi there! + diff --git a/internal/cli/journal/core/source/format/testdata/tooluse.golden b/internal/cli/journal/core/source/format/testdata/tooluse.golden new file mode 100644 index 00000000..b650a04d --- /dev/null +++ b/internal/cli/journal/core/source/format/testdata/tooluse.golden @@ -0,0 +1,76 @@ +--- +date: "2026-01-15" +time: "10:30:00" +project: myproject +tokens_in: 10000 +tokens_out: 5000 +session_id: "abc12345-session-id" +--- + +# test-slug + +
+2026-01-15 · 30m · + + + + + + +
IDabc12345-session-id
Date2026-01-15
Time10:30:00
Duration30m
Toolclaude-code
Projectmyproject
+
+ +
+2 + + +
Turns2
Tokens15.0K (in: 10.0K, out: 5.0K)
+
+ +--- + +## Tool Usage + +- Read: 1 + +--- + +## Conversation + +### 1. Assistant (10:32:00) + +🔧 **Read: /tmp/x.go** + +### 2. Tool Output (10:32:01) + +``` +package main +func main() {} +``` +❌ Error +``` +boom +``` +
+15 lines + +
+line
+line
+line
+line
+line
+line
+line
+line
+line
+line
+line
+line
+line
+line
+line
+
+
+
+ diff --git a/internal/cli/journal/core/source/format/tier2_golden_test.go b/internal/cli/journal/core/source/format/tier2_golden_test.go new file mode 100644 index 00000000..9b31373a --- /dev/null +++ b/internal/cli/journal/core/source/format/tier2_golden_test.go @@ -0,0 +1,101 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package format + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ActiveMemory/ctx/internal/entity" +) + +// TestJournalEntryPartMatchesLegacy asserts JournalEntryPart still +// produces, byte-for-byte, the pre-migration output for the metadata +// table, plan, and tool-result
paths now rendered via the +// metaTable and details templates. Fixtures were captured from the +// legacy fmt.Sprintf/paired-tag code path (see git history). +func TestJournalEntryPartMatchesLegacy(t *testing.T) { + t.Setenv("TZ", "UTC") + + base := func() *entity.Session { + return &entity.Session{ + ID: "abc12345-session-id", Slug: "test-slug", + Tool: "claude-code", Project: "myproject", + StartTime: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC), + EndTime: time.Date(2026, 1, 15, 11, 0, 0, 0, time.UTC), + Duration: 30 * time.Minute, TurnCount: 2, + TotalTokens: 15000, TotalTokensIn: 10000, TotalTokensOut: 5000, + } + } + + single := base() + single.Messages = []entity.Message{ + {Role: "user", Text: "Hello", + Timestamp: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC)}, + {Role: "assistant", Text: "Hi there!", + Timestamp: time.Date(2026, 1, 15, 10, 30, 5, 0, time.UTC)}, + } + + metafull := base() + metafull.GitBranch = "main" + metafull.Model = "claude-opus" + metafull.Messages = single.Messages + + plan := base() + plan.Messages = []entity.Message{ + {Role: "assistant", PlanContent: "step one\nstep two", + Timestamp: time.Date(2026, 1, 15, 10, 31, 0, 0, time.UTC)}, + } + + tooluse := base() + tooluse.Messages = []entity.Message{ + {Role: "assistant", + Timestamp: time.Date(2026, 1, 15, 10, 32, 0, 0, time.UTC), + ToolUses: []entity.ToolUse{ + {ID: "t1", Name: "Read", Input: `{"file_path":"/tmp/x.go"}`}, + }}, + {Role: "user", + Timestamp: time.Date(2026, 1, 15, 10, 32, 1, 0, time.UTC), + ToolResults: []entity.ToolResult{ + {ToolUseID: "t1", Content: "package main\nfunc main() {}"}, + {ToolUseID: "t2", Content: "boom", IsError: true}, + {ToolUseID: "t3", Content: strings.Repeat("line\n", 15)}, + }}, + } + + cases := map[string]struct { + s *entity.Session + part, total int + }{ + "single": {single, 1, 1}, + "metafull": {metafull, 1, 2}, + "plan": {plan, 1, 1}, + "tooluse": {tooluse, 1, 1}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + want, readErr := os.ReadFile( + filepath.Join("testdata", name+".golden"), + ) + if readErr != nil { + t.Fatal(readErr) + } + got := JournalEntryPart( + c.s, c.s.Messages, 0, c.part, c.total, "b", "", + ) + if got != string(want) { + t.Errorf( + "drift:\n--- want ---\n%q\n--- got ---\n%q", + string(want), got, + ) + } + }) + } +} diff --git a/internal/config/warn/warn.go b/internal/config/warn/warn.go index 7a68c91a..faaa2ef6 100644 --- a/internal/config/warn/warn.go +++ b/internal/config/warn/warn.go @@ -120,6 +120,13 @@ const ( // until the tombstone line is removed. SteeringUnfilled = "skipping unfilled steering file %s " + "(remove the tombstone line to activate)" + + // TemplateRender is the stderr format for an embedded-template + // render failure. Parse is gated by TestTemplatesParse and the + // data is typed, so Execute cannot fail in a correct build; the + // warning catches a future regression loudly instead of letting + // [tpl.RenderOr]'s fallback silently blank a section. + TemplateRender = "render template: %v" ) // Pad history warning formats. From bc9e477c7a19b9cc3e2d5e91780b09ec277c2bcc Mon Sep 17 00:00:00 2001 From: Jose Alekhinne Date: Sat, 30 May 2026 20:03:29 -0700 Subject: [PATCH 7/9] docs(spec): sync error handling to the Render/RenderOr split The spec described only the error-returning Render. The implementation added tpl.RenderOr for best-effort string builders (the recall formatter, the Import counter) whose signatures should not grow an error return for a parse-gated, unreachable branch: it logs warn.TemplateRender and falls back, mirroring message/render.go. Update the Rendering-helper subsection (both entry points), the Error Handling table (by caller shape), the exec-error edge case, and add Settled Decision 5 recording the split. Spec: specs/tpl-text-template-migration.md Signed-off-by: Jose Alekhinne --- specs/tpl-text-template-migration.md | 51 +++++++++++++++++++++------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/specs/tpl-text-template-migration.md b/specs/tpl-text-template-migration.md index 1519201a..c6b43911 100644 --- a/specs/tpl-text-template-migration.md +++ b/specs/tpl-text-template-migration.md @@ -53,6 +53,12 @@ Resolved during spec review (2026-05-30): avoids an import cycle. `tpl` is already in the magic-string audit's `exemptStringPackages`, so the parse-table path literals are sanctioned; call sites use typed data structs (no map-key literals). +5. **`Render` + `RenderOr`, split by caller shape** (decided in impl). + Error-returning callers use `Render`. The recall formatters and the + `Import` counter are best-effort string builders by design, so they + use `RenderOr`, which logs `warn.TemplateRender` and falls back + instead of growing an `error` return for a parse-gated, unreachable + branch. Detailed under Error Handling. ## Approach @@ -96,19 +102,27 @@ earlier `Render("obsidian-readme", …)` sketch was wrong. ### Rendering helper -Generalize `message/render.go` into the `tpl` package: +Generalize `message/render.go` into the `tpl` package, with two entry +points for the two caller shapes in the codebase: ```go -// Render executes a parsed template handle against data, returning -// the rendered string. A non-nil error means a programmer bug (bad -// field, malformed embedded template) — golden tests gate against it. +// Render executes a parsed handle against data. A non-nil error means +// a programmer bug (renamed field, malformed template). Error- +// returning callers propagate it. func Render(t *template.Template, data any) (string, error) + +// RenderOr renders for best-effort string builders whose callers do +// not return errors (the recall formatter; the Import counter that +// drives it). On the error it logs warn.TemplateRender and returns +// fallback instead of forcing those signatures to grow an error. +func RenderOr(t *template.Template, data any, fallback string) string ``` -Templates are parsed at package init from the embedded FS into the -exported handles. Parse failures are collected (not panicked) and -asserted empty by `TestTemplatesParse`, so a malformed embedded -template fails CI rather than reaching production. +Templates are parsed at package init from the `tpl`-local embedded FS +into the exported handles. Parse failures are collected (not panicked) +and asserted empty by `TestTemplatesParse` (an in-package test reading +the unexported `parseErrs`), so a malformed embedded template fails CI +rather than reaching production. ### Tier-2 refactor detail @@ -152,7 +166,7 @@ Two block templates replace six paired-tag constants | `metaTable` conditional rows | Absent `GitBranch`/`Model`/`Parts` append no row — matches the current `if s.X != ""` guards exactly. | | **Whitespace fidelity (the chief hazard)** | `MetaDetailsOpen` ends `` with *no* newline; the first `` follows on the same line. The `{{range}}`/`{{define}}` blocks need explicit `{{-`/`-}}` trimming to reproduce exact bytes. Golden tests assert this. | | Malformed embedded template ships | `init` records the parse error; `TestTemplatesParse` fails in CI. Cannot reach a release. | -| Exec error (missing/renamed field) | `Render` returns non-nil error; the call site's golden test fails pre-merge. | +| Exec error (missing/renamed field) | Error-returning callers get it from `Render`; best-effort builders log it via `RenderOr` and fall back. Either way the golden test fails pre-merge. See Error Handling. | ### Validation Rules @@ -162,10 +176,21 @@ inputs are already-validated values from existing call sites. ### Error Handling -| Error condition | User-facing message | Recovery | -|-----------------|---------------------|----------| -| Init parse failure (malformed `.tmpl`) | None in prod (CI-gated); dev sees `TestTemplatesParse` failure naming the file | Fix the template file | -| Exec error (bad field) | Propagated by `Render`; call sites that today return a bare `string` (`SiteReadme`, `script.Generate`) gain an `error` return or a test-guaranteed wrapper | Golden test catches pre-merge | +Two render entry points, chosen by caller shape: + +| Error condition | Handling | Recovery | +|-----------------|----------|----------| +| Init parse failure (malformed `.tmpl`) | None in prod (CI-gated); `TestTemplatesParse` fails naming the file | Fix the template file | +| Exec error, error-returning caller (`vault`, `generate.SiteReadme`, `format.Learning`/`Decision`, `script.Generate`) | `tpl.Render` returns `(string, error)`; the caller propagates | Golden test catches pre-merge | +| Exec error, best-effort builder (`JournalEntryPart`, `collapse.ToolOutputs`, fed by the `Import` counter) | `tpl.RenderOr` logs `warn.TemplateRender` and returns the fallback — no signature change to these `string`-returning functions | Logged warning + golden test catches pre-merge | + +The split exists because the recall formatters and `Import` are +best-effort string builders/counters by design; threading an error +through them (plus their callers and ~15 existing tests) to satisfy a +parse-gated, provably-unreachable branch would contort signatures with +no real recovery path. `RenderOr` mirrors the pre-existing +`message/render.go` fallback pattern, adding the warn log so the +(impossible) failure is never silent. ## Interface From 668cd1940caee3633ec9fbad90b87f7349e59032 Mon Sep 17 00:00:00 2001 From: Jose Alekhinne Date: Sat, 30 May 2026 20:13:21 -0700 Subject: [PATCH 8/9] docs(spec): correct remaining implementation drifts Bring the non-error-handling sections in line with what shipped: - Happy Path: templates load from the tpl-local embedded FS, not assets.FS (already stated in Approach + Decision 4). - Whitespace-fidelity edge case: the templates reproduce exact bytes with plain {{range}}/{{if}} + literal newlines + no-trailing-newline files; no {{-/-}} trimming was needed. - metaTable input is MetaTableData{Summary; Rows []MetaRow}. - Files table: separate meta-table.html.tmpl / details.html.tmpl (not one blocks.tmpl); the tpl package is split render.go/load.go/ static.go/types.go, and TestTemplatesParse is an in-package test reading parseErrs (no exported ParseErrors()). Spec: specs/tpl-text-template-migration.md Signed-off-by: Jose Alekhinne --- specs/tpl-text-template-migration.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/specs/tpl-text-template-migration.md b/specs/tpl-text-template-migration.md index c6b43911..28c2b193 100644 --- a/specs/tpl-text-template-migration.md +++ b/specs/tpl-text-template-migration.md @@ -130,7 +130,8 @@ Two block templates replace six paired-tag constants (`MetaDetailsOpen/Close`, `MetaRow`, `RecallDetailsOpen/Close`, `RecallPlanOpen/Close`): -- **`metaTable`** — input `{Summary string; Rows []struct{Label, Value string}}`. +- **`metaTable`** — input `MetaTableData{Summary string; Rows []MetaRow}` + (`MetaRow{Label, Value string}`). Replaces `format.go:255-276` and `280-293`: build the rows slice (conditional rows like `GitBranch`/`Model`/`Parts` become conditional appends), render once. `MetaRow` becomes a `{{range}}` @@ -144,8 +145,9 @@ Two block templates replace six paired-tag constants ### Happy Path -1. At `tpl` init, each `*.tmpl` file is read from `assets.FS` and - parsed into its exported `*template.Template` handle. +1. At `tpl` init, each `*.tmpl` file is read from the `tpl`-local + embedded FS and parsed into its exported `*template.Template` + handle. 2. A call site builds a typed data struct and calls `tpl.Render(tpl.X, data)`. 3. `Render` executes into a `bytes.Buffer` and returns the string — @@ -164,7 +166,7 @@ Two block templates replace six paired-tag constants | `LoopScript` with `maxIterations == 0` | `{{if .MaxIter}}…{{end}}` renders nothing — replaces the "inject empty `maxIterCheck`" composition (`script.go:53-59`). Output identical. | | `LoopScript` tool selection | `aiCommand` is chosen in Go (small `LoopCmd*` consts stay) and passed as `{{.AICommand}}`; the template does not branch on tool. | | `metaTable` conditional rows | Absent `GitBranch`/`Model`/`Parts` append no row — matches the current `if s.X != ""` guards exactly. | -| **Whitespace fidelity (the chief hazard)** | `MetaDetailsOpen` ends `
` with *no* newline; the first `` follows on the same line. The `{{range}}`/`{{define}}` blocks need explicit `{{-`/`-}}` trimming to reproduce exact bytes. Golden tests assert this. | +| **Whitespace fidelity (the chief hazard)** | `MetaDetailsOpen` ends `
` with *no* newline; the first `` follows on the same line. The templates reproduce this with plain `{{range}}`/`{{if}}` plus deliberate literal newlines and no-trailing-newline files (callers add the surrounding newlines) — no `{{-`/`-}}` trimming was needed. Golden tests assert the exact bytes. | | Malformed embedded template ships | `init` records the parse error; `TestTemplatesParse` fails in CI. Cannot reach a release. | | Exec error (missing/renamed field) | Error-returning callers get it from `Render`; best-effort builders log it via `RenderOr` and fall back. Either way the golden test fails pre-merge. See Error Handling. | @@ -205,8 +207,8 @@ every affected command is byte-identical. | File | Change | |------|--------| -| `internal/assets/tpl/templates/*.tmpl`, `*.toml` | **New** — extracted Tier-1 bodies + `blocks.tmpl` holding the `metaTable` and `details` `{{define}}`s | -| `internal/assets/tpl/render.go` | **New** — `tpl`-local `//go:embed templates/*`, `Render(t, data)`, init parse table (the only place filenames appear), parse-error collection, `ParseErrors()` for `TestTemplatesParse`, typed data structs | +| `internal/assets/tpl/templates/*.tmpl`, `*.toml` | **New** — extracted Tier-1 bodies + separate `meta-table.html.tmpl` and `details.html.tmpl` block templates | +| `internal/assets/tpl/render.go`, `load.go`, `static.go`, `types.go` | **New** — `Render`/`RenderOr` (render.go); `tpl`-local `//go:embed`, the init parse table (the only place filenames appear), and `parseErrs` (load.go); FS-loaded static strings (static.go); typed data structs (types.go). `TestTemplatesParse` is an in-package test reading `parseErrs` | | `internal/assets/embed.go` | **Untouched** — the embed is local to `tpl`, not the parent `assets.FS` (cycle avoidance) | | `internal/assets/tpl/tpl_*.go` | Retype migrated consts → handles / FS-loaded strings; delete migrated bodies + the six Tier-2 paired-tag consts; Tier-3 consts stay | | `internal/cli/journal/core/source/format/format.go` | Tier-2 refactor: build `metaTable` rows + `details` bodies, render via handles (replaces `255-293`, `357-359`, `394-400`) | From 91bbc4d4e64100328f5f8fc52ce5c509389b46f9 Mon Sep 17 00:00:00 2001 From: Jose Alekhinne Date: Sat, 30 May 2026 21:31:14 -0700 Subject: [PATCH 9/9] learning. Signed-off-by: Jose Alekhinne --- .context/LEARNINGS.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.context/LEARNINGS.md b/.context/LEARNINGS.md index 8028aead..82ea8bc6 100644 --- a/.context/LEARNINGS.md +++ b/.context/LEARNINGS.md @@ -17,6 +17,8 @@ DO NOT UPDATE FOR: | Date | Learning | |----|--------| +| 2026-05-30 | Capture golden fixtures from the live legacy code path before deleting it | +| 2026-05-30 | tpl package is magic-string-audit-exempt but its call sites are not | | 2026-05-30 | New exported types must live in types.go or TestTypeFileConvention fails | | 2026-05-28 | ctx kb: single topic-enumeration site; life-stage count is consumer-side | | 2026-05-28 | Swap occupancy is not memory pressure — use the kernel's derivative | @@ -167,6 +169,26 @@ DO NOT UPDATE FOR: --- +## [2026-05-30-212109] Capture golden fixtures from the live legacy code path before deleting it + +**Context**: Behavior-preserving refactors of LoopScript composition and the recall
/
assembly had fragile whitespace where hand-transcribing the expected output risked silent drift from the original bytes. + +**Lesson**: A throwaway test that runs the current (pre-refactor) code and writes its output to testdata/*.golden gives a regression baseline derived from real behavior, not a re-transcription; delete the throwaway, then have the committed test assert the new code is byte-identical to the fixtures. + +**Application**: Use for any behavior-preserving refactor of formatting/rendering code: capture goldens from the legacy path before removing it, then assert byte-equality after. + +--- + +## [2026-05-30-212102] tpl package is magic-string-audit-exempt but its call sites are not + +**Context**: Migrating tpl_*.go format-string consts to text/template handles; a Render("name",...) sketch and map[string]any{"Key":...} render data would both trip audit/magic_strings_test.go (TestNoMagicStrings). + +**Lesson**: internal/assets/tpl is in the magic-strings audit exemptStringPackages, so template-path literals are sanctioned there; but render data passed from non-exempt caller packages must be a typed struct (e.g. tpl.ObsidianData{...}), never a map[string]any with literal keys, which trips the audit at the call site. + +**Application**: When adding a template, define a typed data struct in tpl/types.go and pass it at the call site; never pass map literals from caller packages. + +--- + ## [2026-05-30-114436] New exported types must live in types.go or TestTypeFileConvention fails **Context**: Defined Payload and Provenance structs alongside the Load/OverlayFlags funcs in a new payload.go; make test failed in internal/audit on TestTypeFileConvention with '2 NEW type definitions outside types.go'.