diff --git a/.context/LEARNINGS.md b/.context/LEARNINGS.md index 8028aead6..82ea8bc62 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'. diff --git a/.context/TASKS.md b/.context/TASKS.md index 510e38bb0..dc414d99f 100644 --- a/.context/TASKS.md +++ b/.context/TASKS.md @@ -249,9 +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 new file mode 100644 index 000000000..1a8a55188 --- /dev/null +++ b/internal/assets/tpl/load.go @@ -0,0 +1,82 @@ +// / 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 templates/*.toml +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") + 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") + 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") +} + +// 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 +} + +// 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.go b/internal/assets/tpl/render.go new file mode 100644 index 000000000..77b3dfadd --- /dev/null +++ b/internal/assets/tpl/render.go @@ -0,0 +1,92 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +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. +// 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 + +// 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 + +// 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 +// 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 +} + +// 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/render_test.go b/internal/assets/tpl/render_test.go new file mode 100644 index 000000000..ec0b87382 --- /dev/null +++ b/internal/assets/tpl/render_test.go @@ -0,0 +1,193 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package tpl + +import ( + "fmt" + "strings" + "testing" + "text/template" +) + +// 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. +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 +` + +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. +func TestTemplatesParse(t *testing.T) { + if len(parseErrs) > 0 { + t.Fatalf("embedded templates failed to parse at init: %v", parseErrs) + } +} + +func TestObsidianReadmeMatchesLegacy(t *testing.T) { + const journalDir = "src/journal" + 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)) +} + +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)) +} + +// 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 000000000..1a00e192b --- /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/decision.md.tmpl b/internal/assets/tpl/templates/decision.md.tmpl new file mode 100644 index 000000000..97479fdff --- /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/details.html.tmpl b/internal/assets/tpl/templates/details.html.tmpl new file mode 100644 index 000000000..96d784219 --- /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/journal-site-readme.md.tmpl b/internal/assets/tpl/templates/journal-site-readme.md.tmpl new file mode 100644 index 000000000..8f8e9de5b --- /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 000000000..e4bd8ffdf --- /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/loop-script.sh.tmpl b/internal/assets/tpl/templates/loop-script.sh.tmpl new file mode 100644 index 000000000..316c71b5d --- /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/templates/meta-table.html.tmpl b/internal/assets/tpl/templates/meta-table.html.tmpl new file mode 100644 index 000000000..10bc14568 --- /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/templates/obsidian-readme.md.tmpl b/internal/assets/tpl/templates/obsidian-readme.md.tmpl new file mode 100644 index 000000000..a410c6c7f --- /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/templates/trigger-script.sh.tmpl b/internal/assets/tpl/templates/trigger-script.sh.tmpl new file mode 100644 index 000000000..5155a1e29 --- /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/templates/zensical-project.toml b/internal/assets/tpl/templates/zensical-project.toml new file mode 100644 index 000000000..795e507c3 --- /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 000000000..25c0244d2 --- /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_entry.go b/internal/assets/tpl/tpl_entry.go
index 81d6fe327..0a14a4d46 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 8b422a261..960dfac3b 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."
 
@@ -125,97 +107,6 @@ ctx journal site --serve  # generate and preview
 	// 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"]`
diff --git a/internal/assets/tpl/tpl_loop.go b/internal/assets/tpl/tpl_loop.go
index 970409140..030b248e2 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/tpl_obsidian.go b/internal/assets/tpl/tpl_obsidian.go
deleted file mode 100644
index 7b42a5e1c..000000000
--- 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/tpl_recall.go b/internal/assets/tpl/tpl_recall.go
index 1a7abe011..41509c69a 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/tpl_trigger.go b/internal/assets/tpl/tpl_trigger.go deleted file mode 100644 index 47c3f9b1f..000000000 --- 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 new file mode 100644 index 000000000..84c5c88d2 --- /dev/null +++ b/internal/assets/tpl/types.go @@ -0,0 +1,96 @@ +// / 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 +} + +// 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 +} + +// 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. + 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 +} + +// 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/add/core/format/fmt.go b/internal/cli/add/core/format/fmt.go index 5a9bbba63..02bb19e2e 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 6c852768f..4d0cf908a 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/collapse/collapse.go b/internal/cli/journal/core/collapse/collapse.go index c251739bb..9afcd3835 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 000000000..b61724583 --- /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 000000000..ff200c04b --- /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/generate/generate.go b/internal/cli/journal/core/generate/generate.go index f4537e0d6..b4efcfeeb 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/journal/core/obsidian/vault.go b/internal/cli/journal/core/obsidian/vault.go index 19f794533..7b2ef033d 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/internal/cli/journal/core/source/format/format.go b/internal/cli/journal/core/source/format/format.go index 0450af19c..b407c4743 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 000000000..58b6bb6c6 --- /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 000000000..717268655 --- /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 000000000..a6327960c --- /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 000000000..b650a04d5 --- /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 000000000..9b31373a5 --- /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/cli/loop/cmd/root/run.go b/internal/cli/loop/cmd/root/run.go index 798235f7b..dfc2fe638 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 ec3845089..d8167e69c 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 000000000..bc533167b --- /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 000000000..31f339245 --- /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 000000000..9a7bc8dba --- /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 000000000..7680aacd0 --- /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 000000000..8a68dd908 --- /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 diff --git a/internal/cli/trigger/cmd/add/cmd.go b/internal/cli/trigger/cmd/add/cmd.go index ae48a5d07..443fef706 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/config/warn/warn.go b/internal/config/warn/warn.go index 7a68c91a6..faaa2ef6a 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. diff --git a/internal/entry/write.go b/internal/entry/write.go index 9cfcea4e7..6a9096583 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: diff --git a/specs/tpl-text-template-migration.md b/specs/tpl-text-template-migration.md new file mode 100644 index 000000000..28c2b1935 --- /dev/null +++ b/specs/tpl-text-template-migration.md @@ -0,0 +1,283 @@ +# 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). +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). +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 + +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`. 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 +`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 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) + +| 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, with two entry +points for the two caller shapes in the codebase: + +```go +// 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 `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 + +Two block templates replace six paired-tag constants +(`MetaDetailsOpen/Close`, `MetaRow`, `RecallDetailsOpen/Close`, +`RecallPlanOpen/Close`): + +- **`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}}` + 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 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 —
+   **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 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. | + +### 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 + +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 + +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 + 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`) | +| `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.