diff --git a/.claude/commands/audit-docs.md b/.claude/commands/audit-docs.md index 56c2f920e..24d085692 100644 --- a/.claude/commands/audit-docs.md +++ b/.claude/commands/audit-docs.md @@ -2,7 +2,8 @@ description: "Audit documentation for consistency and accuracy" --- -Perform a documentation audit across the codebase. This is an AI-assisted review that produces a report for human judgment. +Perform a documentation audit across the codebase. This is an AI-assisted +review that produces a report for human judgment. ## Audit Scope diff --git a/.claude/commands/ctx-prompt-audit.md b/.claude/commands/ctx-prompt-audit.md new file mode 100644 index 000000000..488cef950 --- /dev/null +++ b/.claude/commands/ctx-prompt-audit.md @@ -0,0 +1,89 @@ +--- +description: "Analyze session logs to identify vague prompts and suggest improvements" +--- + +Analyze recent session transcripts to identify prompts that led to unnecessary clarification back-and-forth. This helps the user improve their prompting patterns. + +## Your Task + +1. **Read recent sessions** from `.context/sessions/` (focus on the 3-5 most recent) +2. **Identify vague prompts** - user messages that caused you to ask clarifying questions +3. **Generate a coaching report** with concrete examples and suggestions + +## What Makes a Prompt "Vague" + +Look for user prompts where Claude's immediate response was to ask clarifying questions rather than take action. Signs include: + +- **Missing file context**: "fix the bug" without specifying which file or error +- **Ambiguous scope**: "optimize it" without what to optimize or success criteria +- **Undefined targets**: "update the component" when multiple components exist +- **Missing error details**: "it's not working" without symptoms or expected behavior +- **Vague action words**: "make it better", "clean this up", "improve the code" + +## Important Nuance + +Not every short prompt is vague! Consider context: +- "fix the bug" after discussing a specific error in detail → **NOT vague** +- "fix the bug" as the first message → **VAGUE** +- "optimize it" when working on a single function → probably fine +- "optimize it" in a large codebase with no context → **VAGUE** + +## Output Format + +Generate a report like this: + +``` +## Prompt Audit Report + +**Sessions analyzed**: 5 +**User prompts reviewed**: 47 +**Vague prompts found**: 4 (8.5%) + +--- + +### Example 1: Missing File Context + +**Your prompt**: "fix the bug" + +**What happened**: I had to ask which file and what error you were seeing, adding 2 messages of back-and-forth. + +**Better prompt**: "fix the authentication error in src/auth/login.ts where JWT validation fails with 401" + +**Cost**: ~2 extra messages, ~30 seconds + +--- + +### Example 2: Undefined Target + +**Your prompt**: "optimize the component" + +**What happened**: Multiple components exist. I asked which one and what performance issue to address. + +**Better prompt**: "optimize UserList in src/components/UserList.tsx to reduce re-renders when parent state updates" + +**Cost**: ~3 extra messages, ~1 minute + +--- + +## Patterns to Watch + +Based on your sessions, you tend to: +1. Skip mentioning file paths (3 occurrences) +2. Use "it" without establishing what "it" refers to (2 occurrences) + +## Tips + +- Start prompts with the **file path** when discussing specific code +- Include **error messages** when debugging +- Specify **success criteria** for optimization tasks +``` + +## Guidelines + +- Be constructive, not critical - the goal is to help, not shame +- Show the actual prompt from their session (quoted) +- Explain what happened (what you had to ask) +- Provide a concrete better alternative +- Estimate the "cost" in extra messages/time +- Look for patterns across multiple examples +- End with actionable tips based on their specific tendencies diff --git a/.claude/hooks/prompt-coach.sh b/.claude/hooks/prompt-coach.sh new file mode 100755 index 000000000..e89490a30 --- /dev/null +++ b/.claude/hooks/prompt-coach.sh @@ -0,0 +1,145 @@ +#!/bin/bash + +# / Context: https://ctx.ist +# ,'`./ do you remember? +# `.,'\ +# \ Copyright 2026-present Context contributors. +# SPDX-License-Identifier: Apache-2.0 + +# Prompt coaching hook for Claude Code +# Detects anti-patterns and suggests better alternatives. +# Limits suggestions to MAX_SUGGESTIONS per pattern per session. +# +# Generated by: ctx init +# +# ANTI-PATTERNS DETECTED: +# - "idiomatic X" → "follow project conventions" +# - "best practices" → "follow CONVENTIONS.md" +# - "fix the bug" (vague) → include error message, file, and context +# - "optimize it" (vague) → specify what to optimize and why +# - "make it better" (vague) → define "better" with criteria +# - "update the component" (vague) → specify which component and what changes +# +# Output: Warnings to stderr (non-blocking) +# Exit: Always 0 (never blocks execution) + +MAX_SUGGESTIONS=3 +SESSION_FILE="/tmp/ctx-prompt-coach-$$-$(date +%Y%m%d).state" + +# Initialize session file if it doesn't exist +if [ ! -f "$SESSION_FILE" ]; then + cat > "$SESSION_FILE" << 'EOF' +idiomatic=0 +bestpractices=0 +fixbug=0 +optimize=0 +makebetter=0 +update=0 +EOF +fi + +# Read hook input from stdin (JSON) +HOOK_INPUT=$(cat) + +# Extract the prompt text +PROMPT=$(echo "$HOOK_INPUT" | jq -r '.prompt // empty') + +# If no prompt, allow +if [ -z "$PROMPT" ]; then + exit 0 +fi + +# Helper: get count for a pattern +get_count() { + grep "^$1=" "$SESSION_FILE" 2>/dev/null | cut -d= -f2 || echo "0" +} + +# Helper: increment count for a pattern +increment_count() { + local pattern="$1" + local count=$(get_count "$pattern") + local new_count=$((count + 1)) + if grep -q "^$pattern=" "$SESSION_FILE" 2>/dev/null; then + sed -i "s/^$pattern=.*/$pattern=$new_count/" "$SESSION_FILE" + else + echo "$pattern=$new_count" >> "$SESSION_FILE" + fi +} + +# Helper: output a coaching tip (only if under limit) +suggest() { + local pattern="$1" + local tip="$2" + local example="$3" + local count=$(get_count "$pattern") + + if [ "$count" -lt "$MAX_SUGGESTIONS" ]; then + echo "" >&2 + echo "┌─ Prompt Tip ─────────────────────────────────────" >&2 + echo "│ $tip" >&2 + if [ -n "$example" ]; then + echo "│" >&2 + echo "│ Example: $example" >&2 + fi + echo "└──────────────────────────────────────────────────" >&2 + echo "" >&2 + increment_count "$pattern" + fi +} + +# Calculate prompt length (for detecting short vague prompts) +PROMPT_LEN=${#PROMPT} + +# Check for "idiomatic X" pattern (case-insensitive) +if echo "$PROMPT" | grep -qiE 'idiomatic (go|python|rust|javascript|typescript|java|c\+\+|ruby)'; then + suggest "idiomatic" \ + "Instead of 'idiomatic X', try 'follow project conventions'" \ + "'follow CONVENTIONS.md patterns for error handling'" +fi + +# Check for "best practices" pattern (case-insensitive) +if echo "$PROMPT" | grep -qiE '\bbest practices?\b'; then + suggest "bestpractices" \ + "Instead of 'best practices', try 'follow CONVENTIONS.md'" \ + "'follow the error handling pattern from CONVENTIONS.md'" +fi + +# Check for vague "fix the bug" / "fix this bug" patterns +# Only trigger if the prompt is short and lacks specifics +if [ "$PROMPT_LEN" -lt 50 ] && echo "$PROMPT" | grep -qiE '\bfix (the|this|that|a) (bug|issue|error|problem)\b'; then + # Check if prompt lacks context (no file path, no error message, no line number) + if ! echo "$PROMPT" | grep -qE '(\.[a-z]{2,4}|line [0-9]|:[0-9]+|error:|Error:|failed|Failed)'; then + suggest "fixbug" \ + "Add context: which file? what error? what's broken?" \ + "'fix the JWT validation error in src/auth/login.ts returning 401'" + fi +fi + +# Check for vague "optimize" patterns +if [ "$PROMPT_LEN" -lt 40 ] && echo "$PROMPT" | grep -qiE '\b(optimize|optimise) (it|this|that)\b'; then + suggest "optimize" \ + "Specify what to optimize and what metric matters" \ + "'optimize UserList to reduce re-renders when parent state updates'" +fi + +# Check for vague "make it better" patterns +if echo "$PROMPT" | grep -qiE '\bmake (it|this|that) (better|nicer|cleaner|good)\b'; then + if [ "$PROMPT_LEN" -lt 50 ]; then + suggest "makebetter" \ + "Define 'better' - readability? performance? maintainability?" \ + "'refactor to be more readable by extracting validation logic'" + fi +fi + +# Check for vague "update the component/function/file" patterns +if [ "$PROMPT_LEN" -lt 50 ] && echo "$PROMPT" | grep -qiE '\bupdate (the|this|that|a) (component|function|file|module|class)\b'; then + # Check if prompt lacks specifics + if ! echo "$PROMPT" | grep -qE '(\.[a-z]{2,4}|src/|lib/|internal/)'; then + suggest "update" \ + "Specify which component and what changes" \ + "'update Button in src/components/Button.tsx to use new color tokens'" + fi +fi + +# Always allow the prompt to proceed +exit 0 diff --git a/.context/AGENT_PLAYBOOK.md b/.context/AGENT_PLAYBOOK.md index 71ed30b7d..e33349a47 100644 --- a/.context/AGENT_PLAYBOOK.md +++ b/.context/AGENT_PLAYBOOK.md @@ -366,9 +366,23 @@ Before writing or modifying CLI code (`internal/cli/**/*.go`): 1. **Read CONVENTIONS.md** — Load established patterns into context 2. **Check similar commands** — How do existing commands in the same package handle output? -3. **Use cmd methods for output** — `cmd.Printf`, `cmd.Println`, not `fmt.Printf`, `fmt.Println` +3. **Use cmd methods for I/O** — Never use `fmt` for output in CLI code 4. **Follow docstring format** — See Go Documentation Standard below +**cmd methods to use:** + +| Instead of | Use | Purpose | +|-------------------|--------------------|--------------------------------------| +| `fmt.Printf` | `cmd.Printf` | Formatted stdout | +| `fmt.Println` | `cmd.Println` | Line to stdout | +| `fmt.Print` | `cmd.Print` | Raw stdout | +| `fmt.Fprintf(os.Stderr, ...)` | `cmd.PrintErrf` | Formatted stderr | +| `fmt.Fprintln(os.Stderr, ...)` | `cmd.PrintErrln` | Line to stderr | +| `fmt.Sprintf` | `fmt.Sprintf` | String formatting (OK to keep) | +| `fmt.Errorf` | `fmt.Errorf` | Error creation (OK to keep) | + +**Why**: cmd methods write to testable buffers; fmt writes to real stdout/stderr. + **Quick pattern check:** ```bash # See how other commands do output diff --git a/.context/LEARNINGS.md b/.context/LEARNINGS.md index fdc5b1a5a..f8f3dff50 100644 --- a/.context/LEARNINGS.md +++ b/.context/LEARNINGS.md @@ -3,6 +3,7 @@ | Date | Learning | |------|--------| +| 2026-01-30 | Say 'project conventions' not 'idiomatic X' | | 2026-01-29 | Documentation audits require verification against actual standards | | 2026-01-28 | Required flags now enforced for learnings | | 2026-01-28 | Claude Code Hooks Receive JSON via Stdin | @@ -40,6 +41,16 @@ --- +## [2026-01-30-120009] Say 'project conventions' not 'idiomatic X' + +**Context**: When asking Claude to follow documentation style, saying 'idiomatic Go' triggered training priors (stdlib conventions) instead of project-specific standards. + +**Lesson**: Use 'follow project conventions' or 'check AGENT_PLAYBOOK' rather than 'idiomatic [language]' to ensure Claude looks at project files first. + +**Application**: In prompts requesting style alignment, reference project files explicitly rather than language-wide conventions. + +--- + ## [2026-01-29-164322] Documentation audits require verification against actual standards **Context**: Agent claimed 'no Go docstring issues found' but manual inspection revealed many functions missing Parameters/Returns sections. The agent only checked if comments existed, not if they followed the standard format. diff --git a/.context/TASKS.md b/.context/TASKS.md index 8900b68e3..d610d5c3f 100644 --- a/.context/TASKS.md +++ b/.context/TASKS.md @@ -8,7 +8,6 @@ ### Phase 1: Parser (DONE) - ## Phase 1.a: Cleanup and Release - [x] T1.2.0.1b Add index markers to DECISIONS.md and LEARNINGS.md templates @@ -22,36 +21,29 @@ - [x] T1.2.7 feat: implement `--no-color` global flag to disable colored output Documented in cli-reference.md as planned. Currently `NO_COLOR=1` env var works. #priority:low #added:2026-01-28 -- [ ] T1.2.9: upstream CI is broken (again) -- [ ] T1.2.10: Human code review -- [ ] T1.2.11: Human to read all user-facing documentation and update as needed. -- [ ] T1.2.12: cut a release (version number is already bumped) - -### Phase 2: Export & Search - -- [ ] feat: `ctx recall export` - export sessions to editable journal files +- [x] Write AST-based test that warns if CLI functions use fmt.Print* instead of + cmd.Print* #added:2026-01-29-171351 #done:2026-01-31 +- [x] feat: `ctx recall export` - export sessions to editable journal files - `ctx recall export ` - export one session - `ctx recall export --all` - export all sessions - Skip existing files (user may have edited), `--force` to overwrite - Output to `.context/journal/YYYY-MM-DD-slug-shortid.md` - #added:2026-01-28 - -- [ ] feat: `ctx recall search ` - CLI-based search across sessions - - Simple text search, no server needed - - IDE grep is alternative, this is convenience - #priority:low - -- [ ] explore: `ctx recall stats` - analytics/statistics - - Token usage over time, tool patterns, session durations - - Explore when we have a clear use case - #priority:deferred + #added:2026-01-28 #done:2026-01-31 +- [x] feat: ctx journal - LLM-powered session analysis and synthesis + - [x] ctx journal site - generate zensical static site #done:2026-01-31 + - [x] ctx serve - convenience wrapper for zensical serve #done:2026-01-31 + - [x] /ctx-journal-enrich - slash command for frontmatter/tags #done:2026-01-31 + - [x] /ctx-journal-summarize - slash command for timeline summaries #done:2026-01-31 +- [ ] T1.2.9: upstream CI is broken (again) +- [ ] T1.2.10: Human code review +- [ ] T1.2.11: Human to read all user-facing documentation and update as needed. +- [ ] T1.2.12: cut a release (version number is already bumped) +- [ ] T1.2.13: Compose two blog posts: 1) what has changed after the human-guided + refactoring, and what we can learn about this. + 2) what has happened since the last release cut. ## Backlog -- [ ] Write AST-based test that warns if CLI functions use fmt.Print* instead of cmd.Print* #added:2026-01-29-171351 - -- [ ] feat: ctx journal - LLM-powered session analysis and synthesis - Parent command for working with exported sessions (.context/journal/): Subcommands to explore: @@ -104,19 +96,10 @@ Additional supporting context: Depends on: ctx recall export (Phase 2) #priority:low #phase:future #added:2026-01-28-071638 -- [ ] feat: /ctx-blog slash command - generate blog post draft from recent activity - -Analyzes what happened since last blog post: -- Sessions and their summaries -- Commits and features added -- Decisions made and rationale -- Learnings discovered - -Outputs narrative markdown draft for human editing. -Could integrate with ctx journal or work directly from sessions/git history. - -Related: ctx journal summarize (internal) vs ctx-blog (external/public) -#priority:low #phase:future #added:2026-01-28-072625 +- [x] feat: /ctx-blog slash command - generate blog post draft from recent activity + - [x] /ctx-blog - from recent activity (sessions, commits, decisions) + - [x] /ctx-blog-changelog - from commit range with theme + #added:2026-01-28-072625 #done:2026-01-31 - [ ] feat: ctx enrich - retroactively expand sparse context entries @@ -134,3 +117,23 @@ Could run as: #priority:low #phase:future #added:2026-01-28-073058 +- [ ] feat: make config constants configurable via .contextrc + +Some hardcoded constants in internal/config/config.go could be user-configurable: +- MaxDecisionsToSummarize (default 3) +- MaxLearningsToSummarize (default 5) +- MaxPreviewLen (default 60) +- WatchAutoSaveInterval (default 5) + +Follow the pattern established for token_budget and archive_after_days in internal/rc. +#priority:low #phase:future #added:2026-01-31 + +- [ ] explore: `ctx recall stats` - analytics/statistics + - Token usage over time, tool patterns, session durations + - Explore when we have a clear use case + #priority:deferred + +- [ ] feat: `ctx recall search ` - CLI-based search across sessions + - Simple text search, no server needed + - IDE grep is alternative, this is convenience + #priority:low \ No newline at end of file diff --git a/.gitignore b/.gitignore index 669b1c496..d2b26e092 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,5 @@ dist/ # Session transcripts (personal, potentially large) .context/sessions/ +.context/journal/ +.context/journal-site/ diff --git a/docs/ralph-loop.md b/docs/autonomous-loop.md similarity index 88% rename from docs/ralph-loop.md rename to docs/autonomous-loop.md index f3b53bfba..6247224bc 100644 --- a/docs/ralph-loop.md +++ b/docs/autonomous-loop.md @@ -5,26 +5,30 @@ # \ Copyright 2026-present Context contributors. # SPDX-License-Identifier: Apache-2.0 -title: Ralph and Context +title: Autonomous Loops icon: lucide/repeat --- ![ctx](images/ctx-banner.png) -## `ralph` + `ctx` +## Autonomous AI Development -*Like peanut butter and jelly: better together.* +*Iterate until done.* -The [Ralph Wiggum technique](https://ghuntley.com/ralph/) is an iterative AI development workflow where -an agent works autonomously on tasks until completion. Context (`ctx`) and -Ralph complement each other perfectly: +An autonomous loop is an iterative AI development workflow where an agent works +on tasks until completion—without constant human intervention. Context (`ctx`) +provides the memory that makes this possible: -- **`ctx`** provides the *memory*: persistent context that survives across sessions -- **`ralph`** provides the *loop*: autonomous iteration that runs until done +- **`ctx`** provides the *memory*: persistent context that survives across iterations +- **The loop** provides the *automation*: continuous execution until done -Together, they enable fully autonomous AI development where the agent remembers +Together, they enable fully autonomous AI development where the agent remembers everything across iterations. +!!! note "Origin" + This pattern is inspired by [Geoffrey Huntley's Ralph Wiggum technique](https://ghuntley.com/ralph/). + We use generic terminology here so the concepts remain clear regardless of trends. + ## How It Works ```mermaid @@ -51,14 +55,14 @@ graph TD ## Quick Start with Claude Code -Claude Code has a built-in Ralph Loop plugin: +Claude Code has built-in loop support: ```bash # Start autonomous loop -/ralph-loop +/loop # Cancel running loop -/cancel-ralph +/cancel-loop ``` That's it. The loop will: @@ -73,11 +77,11 @@ For other AI tools, create a `loop.sh`: ```bash #!/bin/bash -# loop.sh — a minimal Ralph loop +# loop.sh — an autonomous iteration loop PROMPT_FILE="${1:-PROMPT.md}" MAX_ITERATIONS="${2:-10}" -OUTPUT_FILE="/tmp/ralph_output.txt" +OUTPUT_FILE="/tmp/loop_output.txt" for i in $(seq 1 $MAX_ITERATIONS); do echo "=== Iteration $i ===" @@ -110,6 +114,8 @@ chmod +x loop.sh ./loop.sh ``` +You can also generate this script with `ctx loop` (see [CLI Reference](cli-reference.md#ctx-loop)). + ## The PROMPT.md File The prompt file instructs the AI on how to work autonomously. Here's a template: @@ -201,9 +207,7 @@ Please provide credentials and confirm deployment region. SYSTEM_BLOCKED ``` -## Context Integration - -### Why ctx + Ralph Work Well Together +## Why Context + Loops Work Well Together | Without ctx | With ctx | |-----------------------------|--------------------------------------| @@ -253,7 +257,7 @@ Run `ctx watch` alongside the loop to automatically process context updates: ctx watch --log /tmp/loop.log --auto-save ``` -The `--auto-save` flag periodically saves session snapshots, creating a +The `--auto-save` flag periodically saves session snapshots, creating a history of the loop's progress. ## Example Project Setup @@ -272,7 +276,7 @@ my-project/ └── src/ # Your code ``` -### Sample TASKS.md for Ralph +### Sample TASKS.md for Autonomous Loops ```markdown # Tasks @@ -347,6 +351,6 @@ BEFORE any work: ## Resources -- [Ralph Wiggum Technique](https://ghuntley.com/ralph/) — Original blog post +- [Geoffrey Huntley's Ralph Wiggum Technique](https://ghuntley.com/ralph/) — Original inspiration - [Context CLI](cli-reference.md) — Command reference - [Integrations](integrations.md) — Tool-specific setup diff --git a/docs/blog/2026-01-27-building-ctx-using-ctx.md b/docs/blog/2026-01-27-building-ctx-using-ctx.md index 1888b184b..61d9d116c 100644 --- a/docs/blog/2026-01-27-building-ctx-using-ctx.md +++ b/docs/blog/2026-01-27-building-ctx-using-ctx.md @@ -36,10 +36,11 @@ That is "*reset amnesia*", and it's not just annoying: it's expensive. Every session starts with re-explaining context, re-reading files, re-discovering decisions that were already made. -> "I don't want to lose this discussion... -> I am a brain-dead developer YOLO'ing my way out" +!!! quote "I Needed Context" + I don't want to lose this discussion... + I am a brain-dead developer YOLO'ing my way out -That's exactly what I said to Claude when I first started working on `ctx`. +☝️ that's exactly what I said to Claude when I first started working on `ctx`. ## The Genesis diff --git a/docs/blog/index.md b/docs/blog/index.md index ff201b825..70c0e2cd6 100644 --- a/docs/blog/index.md +++ b/docs/blog/index.md @@ -13,11 +13,15 @@ Stories, insights, and lessons learned from building and using ctx. ### [Building ctx Using ctx: A Meta-Experiment in AI-Assisted Development](2026-01-27-building-ctx-using-ctx.md) -*January 27, 2026* +*Jose Alekhinne / January 27, 2026* -What happens when you build a tool designed to give AI memory, using that very same tool to remember what you're building? This is the story of ctx—how it evolved from a hasty "YOLO mode" experiment to a disciplined system for persistent AI context, and what we learned along the way. +What happens when you build a tool designed to give AI memory, using that very +same tool to remember what you're building? This is the story of ctx—how it +evolved from a hasty "YOLO mode" experiment to a disciplined system for +persistent AI context, and what we learned along the way. -**Topics**: dogfooding, AI-assisted development, Ralph Loop, session persistence, architectural decisions +**Topics**: dogfooding, AI-assisted development, Ralph Loop, session +persistence, architectural decisions --- diff --git a/docs/cli-reference.md b/docs/cli-reference.md index ee429901e..7c86f18e0 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -24,7 +24,6 @@ All commands support these flags: | `--help` | Show command help | | `--version` | Show version | | `--context-dir ` | Override context directory (default: `.context/`) | -| `--quiet` | Suppress non-essential output | | `--no-color` | Disable colored output | > The `NO_COLOR=1` environment variable also disables colored output. @@ -530,6 +529,103 @@ ctx recall show --latest ctx recall show --latest --full ``` +#### `ctx recall export` + +Export sessions to editable journal files in `.context/journal/`. + +```bash +ctx recall export [session-id] [flags] +``` + +**Flags**: + +| Flag | Description | +|-----------|------------------------------------------| +| `--all` | Export all sessions | +| `--force` | Overwrite existing files | + +Exported files include session metadata, tool usage summary, and the full +conversation. Existing files are skipped by default to preserve your edits. + +The `journal/` directory should be gitignored (like `sessions/`) since it +contains raw conversation data. + +**Example**: + +```bash +ctx recall export abc123 # Export one session +ctx recall export --all # Export all sessions +ctx recall export --all --force # Overwrite existing exports +``` + +--- + +### `ctx journal` + +Analyze and synthesize exported session files. + +```bash +ctx journal +``` + +#### `ctx journal site` + +Generate a static site from journal entries in `.context/journal/`. + +```bash +ctx journal site [flags] +``` + +**Flags**: + +| Flag | Short | Description | +|------------|-------|------------------------------------------| +| `--output` | `-o` | Output directory (default: .context/journal-site) | +| `--build` | | Run zensical build after generating | +| `--serve` | | Run zensical serve after generating | + +Creates a zensical-compatible site structure with an index page listing +all sessions by date, and individual pages for each journal entry. + +Requires zensical to be installed for `--build` or `--serve`: +```bash +pip install zensical +``` + +**Example**: + +```bash +ctx journal site # Generate in .context/journal-site/ +ctx journal site --output ~/public # Custom output directory +ctx journal site --build # Generate and build HTML +ctx journal site --serve # Generate and serve locally +``` + +--- + +### `ctx serve` + +Serve a static site locally via zensical. + +```bash +ctx serve [directory] +``` + +If no directory is specified, serves the journal site (`.context/journal-site`). + +Requires zensical to be installed: +```bash +pip install zensical +``` + +**Example**: + +```bash +ctx serve # Serve journal site +ctx serve .context/journal-site # Serve specific directory +ctx serve ./docs # Serve docs folder +``` + --- ### `ctx watch` @@ -692,9 +788,9 @@ ctx session parse transcript.jsonl -o conversation.md ### `ctx loop` -Generate a shell script for running a Ralph loop. +Generate a shell script for running an autonomous loop. -A Ralph loop continuously runs an AI assistant with the same prompt until +An autonomous loop continuously runs an AI assistant with the same prompt until a completion signal is detected, enabling iterative development where the AI builds on its previous work. @@ -737,7 +833,7 @@ chmod +x loop.sh ./loop.sh ``` -See [Ralph Loop Integration](ralph-loop.md) for detailed workflow documentation. +See [Autonomous Loops](autonomous-loop.md) for detailed workflow documentation. --- diff --git a/docs/comparison.md b/docs/comparison.md index 85f39de9c..3bae52fa3 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -153,10 +153,9 @@ Without persistent context, agents tend to: * repeat mistakes * lose architectural intent -This is why `ctx` pairs well with loop-based workflows such as the -[Ralph Wiggum technique](https://ghuntley.com/ralph/): +This is why `ctx` pairs well with [autonomous loop workflows](autonomous-loop.md): -* `ralph` provides iteration +* The loop provides iteration * `ctx` provides continuity Together, loops become cumulative instead of forgetful. diff --git a/docs/index.md b/docs/index.md index 9a75f7ce1..86b9e1427 100644 --- a/docs/index.md +++ b/docs/index.md @@ -237,5 +237,5 @@ ctx drift - [Prompting Guide](prompting-guide.md) — Effective prompts for AI sessions - [CLI Reference](cli-reference.md) — All commands and options - [Context Files](context-files.md) — File formats and structure -- [`ralph` and `ctx`](ralph-loop.md) — Autonomous AI development workflows +- [Autonomous Loops](autonomous-loop.md) — Iterative AI development workflows - [Integrations](integrations.md) — Setup for Claude Code, Cursor, Aider diff --git a/docs/integrations.md b/docs/integrations.md index c1e950a7d..03ded8561 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -126,6 +126,62 @@ ctx agent --budget 4000 cat .context/TASKS.md ``` +### Slash Commands + +`ctx init` installs slash commands to `.claude/commands/`. These are shortcuts +you can invoke in Claude Code with `/command-name`. + +#### Context Commands + +| Command | Description | +|---------|-------------| +| `/ctx-status` | Show context summary (tasks, decisions, learnings) | +| `/ctx-agent` | Get AI-optimized context packet | +| `/ctx-save` | Save current session to `.context/sessions/` | +| `/ctx-reflect` | Review session and suggest what to persist | + +#### Adding Context + +| Command | Description | +|---------|-------------| +| `/ctx-add-task` | Add a task to TASKS.md | +| `/ctx-add-learning` | Add a learning to LEARNINGS.md | +| `/ctx-add-decision` | Add a decision with context/rationale/consequences | +| `/ctx-archive` | Archive completed tasks | + +#### Session History + +| Command | Description | +|---------|-------------| +| `/ctx-recall` | Browse AI session history | +| `/ctx-journal-enrich` | Enrich a journal entry with frontmatter/tags | +| `/ctx-journal-summarize` | Generate summary of sessions over a time period | + +#### Blogging + +| Command | Description | +|---------|-------------| +| `/ctx-blog` | Generate blog post from recent activity | +| `/ctx-blog-changelog` | Generate blog post from commit range with theme | + +#### Development + +| Command | Description | +|---------|-------------| +| `/ctx-loop` | Generate a Ralph Loop iteration script | +| `/ctx-prompt-audit` | Analyze session logs for vague prompts | + +#### Usage Examples + +```text +/ctx-status +/ctx-add-learning "Token refresh requires explicit cache invalidation" +/ctx-journal-enrich twinkly-stirring-kettle +/ctx-journal-summarize last week +``` + +Slash commands support partial matching where applicable (e.g., session slugs). + --- ## Cursor IDE diff --git a/docs/prompting-guide.md b/docs/prompting-guide.md index c83138f04..d2692ccd8 100644 --- a/docs/prompting-guide.md +++ b/docs/prompting-guide.md @@ -209,6 +209,7 @@ so far, here are some prompts that tend to produce poor results: | "Just do it" | Skips planning | "Plan this, then implement" | | "You should remember" | Confrontational | "Do you remember?" | | "Obviously..." | Discourages questions | State the requirement directly | +| "Idiomatic X" | Triggers language priors | "Follow project conventions" | --- diff --git a/docs/security.md b/docs/security.md index b8a8d36e3..cd14f4d35 100644 --- a/docs/security.md +++ b/docs/security.md @@ -51,8 +51,9 @@ Send details to **security@ctx.ist** - **Local only**: ctx runs entirely locally with no external network calls - **No code execution**: ctx reads and writes Markdown files only; it does not execute arbitrary code -- **Git-tracked**: All context files are meant to be committed, so they should - never contain sensitive data +- **Git-tracked**: Core context files are meant to be committed, so they should + never contain sensitive data. Exception: `sessions/` and `journal/` contain + raw conversation data and should be gitignored ## Best Practices diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index f15156eaa..4d51fab9a 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -23,20 +23,22 @@ import ( "github.com/ActiveMemory/ctx/internal/cli/agent" "github.com/ActiveMemory/ctx/internal/cli/compact" "github.com/ActiveMemory/ctx/internal/cli/complete" - "github.com/ActiveMemory/ctx/internal/cli/decisions" + "github.com/ActiveMemory/ctx/internal/cli/decision" "github.com/ActiveMemory/ctx/internal/cli/drift" "github.com/ActiveMemory/ctx/internal/cli/hook" "github.com/ActiveMemory/ctx/internal/cli/initialize" + "github.com/ActiveMemory/ctx/internal/cli/journal" "github.com/ActiveMemory/ctx/internal/cli/learnings" "github.com/ActiveMemory/ctx/internal/cli/load" "github.com/ActiveMemory/ctx/internal/cli/loop" "github.com/ActiveMemory/ctx/internal/cli/recall" + "github.com/ActiveMemory/ctx/internal/cli/serve" "github.com/ActiveMemory/ctx/internal/cli/session" "github.com/ActiveMemory/ctx/internal/cli/status" "github.com/ActiveMemory/ctx/internal/cli/sync" "github.com/ActiveMemory/ctx/internal/cli/task" "github.com/ActiveMemory/ctx/internal/cli/watch" - "github.com/ActiveMemory/ctx/internal/config" + "github.com/ActiveMemory/ctx/internal/rc" ) // version is set at build time via ldflags @@ -49,7 +51,6 @@ const version = "dev" // // Global flags: // - --context-dir: Override the context directory path (default: .context) -// - --quiet: Suppress non-essential output // - --no-color: Disable colored output // // Returns: @@ -71,7 +72,7 @@ func RootCmd() *cobra.Command { PersistentPreRun: func(cmd *cobra.Command, args []string) { // Apply global flag values if contextDir != "" { - config.SetContextDir(contextDir) + rc.OverrideContextDir(contextDir) } if noColor { color.NoColor = true @@ -86,12 +87,6 @@ func RootCmd() *cobra.Command { "", "Override context directory path (default: .context)", ) - cmd.PersistentFlags().BoolVar( - &config.Quiet, - "quiet", - false, - "Suppress non-essential output", - ) cmd.PersistentFlags().BoolVar( &noColor, "no-color", @@ -114,23 +109,29 @@ func RootCmd() *cobra.Command { // Returns: // - *cobra.Command: The same command with all subcommands registered func Initialize(cmd *cobra.Command) *cobra.Command { - cmd.AddCommand(initialize.Cmd()) - cmd.AddCommand(status.Cmd()) - cmd.AddCommand(load.Cmd()) - cmd.AddCommand(add.Cmd()) - cmd.AddCommand(complete.Cmd()) - cmd.AddCommand(agent.Cmd()) - cmd.AddCommand(drift.Cmd()) - cmd.AddCommand(sync.Cmd()) - cmd.AddCommand(compact.Cmd()) - cmd.AddCommand(decisions.Cmd()) - cmd.AddCommand(watch.Cmd()) - cmd.AddCommand(hook.Cmd()) - cmd.AddCommand(learnings.Cmd()) - cmd.AddCommand(session.Cmd()) - cmd.AddCommand(task.Cmd()) - cmd.AddCommand(loop.Cmd()) - cmd.AddCommand(recall.Cmd()) + for _, c := range []func() *cobra.Command{ + initialize.Cmd, + status.Cmd, + load.Cmd, + add.Cmd, + complete.Cmd, + agent.Cmd, + drift.Cmd, + sync.Cmd, + compact.Cmd, + decision.Cmd, + watch.Cmd, + hook.Cmd, + learnings.Cmd, + session.Cmd, + task.Cmd, + loop.Cmd, + recall.Cmd, + journal.Cmd, + serve.Cmd, + } { + cmd.AddCommand(c()) + } return cmd } diff --git a/internal/claude/embed_test.go b/internal/claude/claude_test.go similarity index 61% rename from internal/claude/embed_test.go rename to internal/claude/claude_test.go index b8da5d90a..3647b2691 100644 --- a/internal/claude/embed_test.go +++ b/internal/claude/claude_test.go @@ -11,63 +11,63 @@ import ( "testing" ) -func TestGetAutoSaveScript(t *testing.T) { - content, err := GetAutoSaveScript() +func TestAutoSaveScript(t *testing.T) { + content, err := AutoSaveScript() if err != nil { - t.Fatalf("GetAutoSaveScript() unexpected error: %v", err) + t.Fatalf("AutoSaveScript() unexpected error: %v", err) } if len(content) == 0 { - t.Error("GetAutoSaveScript() returned empty content") + t.Error("AutoSaveScript() returned empty content") } // Check for expected script content script := string(content) if !strings.Contains(script, "#!/") { - t.Error("GetAutoSaveScript() script missing shebang") + t.Error("AutoSaveScript() script missing shebang") } } -func TestGetBlockNonPathCtxScript(t *testing.T) { - content, err := GetBlockNonPathCtxScript() +func TestBlockNonPathCtxScript(t *testing.T) { + content, err := BlockNonPathCtxScript() if err != nil { - t.Fatalf("GetBlockNonPathCtxScript() unexpected error: %v", err) + t.Fatalf("BlockNonPathCtxScript() unexpected error: %v", err) } if len(content) == 0 { - t.Error("GetBlockNonPathCtxScript() returned empty content") + t.Error("BlockNonPathCtxScript() returned empty content") } // Check for expected script content script := string(content) if !strings.Contains(script, "#!/") { - t.Error("GetBlockNonPathCtxScript() script missing shebang") + t.Error("BlockNonPathCtxScript() script missing shebang") } } -func TestListCommands(t *testing.T) { - commands, err := ListCommands() +func TestCommands(t *testing.T) { + commands, err := Commands() if err != nil { - t.Fatalf("ListCommands() unexpected error: %v", err) + t.Fatalf("Commands() unexpected error: %v", err) } if len(commands) == 0 { - t.Error("ListCommands() returned empty list") + t.Error("Commands() returned empty list") } // Check that all entries are .md files for _, cmd := range commands { if !strings.HasSuffix(cmd, ".md") { - t.Errorf("ListCommands() returned non-.md file: %s", cmd) + t.Errorf("Commands() returned non-.md file: %s", cmd) } } } -func TestGetCommand(t *testing.T) { +func TestCommandByName(t *testing.T) { // First get the list of commands to test with - commands, err := ListCommands() + commands, err := Commands() if err != nil { - t.Fatalf("ListCommands() failed: %v", err) + t.Fatalf("Commands() failed: %v", err) } if len(commands) == 0 { @@ -75,22 +75,22 @@ func TestGetCommand(t *testing.T) { } // Test getting the first command - content, err := GetCommand(commands[0]) + content, err := CommandByName(commands[0]) if err != nil { - t.Errorf("GetCommand(%q) unexpected error: %v", commands[0], err) + t.Errorf("CommandByName(%q) unexpected error: %v", commands[0], err) } if len(content) == 0 { - t.Errorf("GetCommand(%q) returned empty content", commands[0]) + t.Errorf("CommandByName(%q) returned empty content", commands[0]) } // Test getting nonexistent command - _, err = GetCommand("nonexistent-command.md") + _, err = CommandByName("nonexistent-command.md") if err == nil { - t.Error("GetCommand(nonexistent) expected error, got nil") + t.Error("CommandByName(nonexistent) expected error, got nil") } } -func TestCreateDefaultHooks(t *testing.T) { +func TestDefaultHooks(t *testing.T) { tests := []struct { name string projectDir string @@ -107,16 +107,16 @@ func TestCreateDefaultHooks(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - hooks := CreateDefaultHooks(tt.projectDir) + hooks := DefaultHooks(tt.projectDir) // Check PreToolUse hooks if len(hooks.PreToolUse) == 0 { - t.Error("CreateDefaultHooks() PreToolUse is empty") + t.Error("DefaultHooks() PreToolUse is empty") } // Check SessionEnd hooks if len(hooks.SessionEnd) == 0 { - t.Error("CreateDefaultHooks() SessionEnd is empty") + t.Error("DefaultHooks() SessionEnd is empty") } // Check that project dir is used in paths when provided @@ -131,7 +131,7 @@ func TestCreateDefaultHooks(t *testing.T) { } } if !found { - t.Error("CreateDefaultHooks() project dir not found in hook commands") + t.Error("DefaultHooks() project dir not found in hook commands") } } }) @@ -141,7 +141,7 @@ func TestCreateDefaultHooks(t *testing.T) { func TestSettingsStructure(t *testing.T) { // Test that Settings struct can be instantiated correctly settings := Settings{ - Hooks: CreateDefaultHooks(""), + Hooks: DefaultHooks(""), Permissions: PermissionsConfig{ Allow: []string{"Bash(ctx status:*)", "Bash(ctx agent:*)"}, }, @@ -156,11 +156,11 @@ func TestSettingsStructure(t *testing.T) { } } -func TestCreateDefaultPermissions(t *testing.T) { - perms := CreateDefaultPermissions() +func TestDefaultPermissions(t *testing.T) { + perms := DefaultPermissions() if len(perms) == 0 { - t.Error("CreateDefaultPermissions should return permissions") + t.Error("DefaultPermissions should return permissions") } // Check that essential ctx commands are included diff --git a/internal/claude/cmd.go b/internal/claude/cmd.go new file mode 100644 index 000000000..01ae7ec5d --- /dev/null +++ b/internal/claude/cmd.go @@ -0,0 +1,46 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package claude + +import ( + "fmt" + + "github.com/ActiveMemory/ctx/internal/templates" +) + +// Commands returns the list of embedded command file names. +// +// These are Claude Code slash command definitions (e.g., "ctx-status.md", +// "ctx-reflect.md") from internal/templates/claude/commands/. They can be +// installed to .claude/commands/ via "ctx init". +// +// Returns: +// - []string: Filenames of available command definitions +// - error: Non-nil if the commands directory cannot be read +func Commands() ([]string, error) { + names, err := templates.ListClaudeCommands() + if err != nil { + return nil, fmt.Errorf("failed to list commands: %w", err) + } + return names, nil +} + +// CommandByName returns the content of a command file by name. +// +// Parameters: +// - name: Filename as returned by [Commands] (e.g., "ctx-status.md") +// +// Returns: +// - []byte: Raw bytes of the command definition file +// - error: Non-nil if the command file does not exist or cannot be read +func CommandByName(name string) ([]byte, error) { + content, err := templates.GetClaudeCommand(name) + if err != nil { + return nil, fmt.Errorf("failed to read command %s: %w", name, err) + } + return content, nil +} diff --git a/internal/claude/doc.go b/internal/claude/doc.go index 8882f8d42..09244de74 100644 --- a/internal/claude/doc.go +++ b/internal/claude/doc.go @@ -17,7 +17,7 @@ // // Example usage: // -// script, err := claude.GetAutoSaveScript() +// script, err := claude.AutoSaveScript() // if err != nil { // return err // } diff --git a/internal/claude/embed.go b/internal/claude/embed.go deleted file mode 100644 index 0418fb57f..000000000 --- a/internal/claude/embed.go +++ /dev/null @@ -1,153 +0,0 @@ -// / Context: https://ctx.ist -// ,'`./ do you remember? -// `.,'\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -package claude - -import ( - "fmt" - - "github.com/ActiveMemory/ctx/internal/templates" -) - -// GetAutoSaveScript returns the auto-save session script content. -// -// The script automatically saves Claude Code session transcripts when a -// session ends. It is installed to .claude/hooks/ during ctx init --claude. -// -// Returns: -// - []byte: Raw bytes of the auto-save-session.sh script -// - error: Non-nil if the embedded file cannot be read -func GetAutoSaveScript() ([]byte, error) { - content, err := templates.GetClaudeHook("auto-save-session.sh") - if err != nil { - return nil, fmt.Errorf("failed to read auto-save-session.sh: %w", err) - } - return content, nil -} - -// GetBlockNonPathCtxScript returns the script that blocks non-PATH ctx -// invocations. -// -// The script prevents Claude from running ctx via relative paths (./ctx, -// ./dist/ctx) or "go run", ensuring only the installed PATH version is used. -// It is installed to .claude/hooks/ during ctx init --claude. -// -// Returns: -// - []byte: Raw bytes of the block-non-path-ctx.sh script -// - error: Non-nil if the embedded file cannot be read -func GetBlockNonPathCtxScript() ([]byte, error) { - content, err := templates.GetClaudeHook("block-non-path-ctx.sh") - if err != nil { - return nil, fmt.Errorf("failed to read block-non-path-ctx.sh: %w", err) - } - return content, nil -} - -// ListCommands returns the list of embedded command file names. -// -// These are Claude Code slash command definitions (e.g., "ctx-status.md", -// "ctx-reflect.md") from internal/templates/claude/commands/. They can be -// installed to .claude/commands/ via "ctx init". -// -// Returns: -// - []string: Filenames of available command definitions -// - error: Non-nil if the commands directory cannot be read -func ListCommands() ([]string, error) { - names, err := templates.ListClaudeCommands() - if err != nil { - return nil, fmt.Errorf("failed to list commands: %w", err) - } - return names, nil -} - -// GetCommand returns the content of a command file by name. -// -// Parameters: -// - name: Filename as returned by [ListCommands] (e.g., "ctx-status.md") -// -// Returns: -// - []byte: Raw bytes of the command definition file -// - error: Non-nil if the command file does not exist or cannot be read -func GetCommand(name string) ([]byte, error) { - content, err := templates.GetClaudeCommand(name) - if err != nil { - return nil, fmt.Errorf("failed to read command %s: %w", name, err) - } - return content, nil -} - -// CreateDefaultPermissions returns the default permissions for ctx commands. -// -// These permissions allow Claude Code to run ctx CLI commands without -// prompting for approval. All ctx subcommands are pre-approved. -// -// Returns: -// - []string: List of permission patterns for ctx commands -func CreateDefaultPermissions() []string { - return []string{ - "Bash(ctx status:*)", - "Bash(ctx agent:*)", - "Bash(ctx add:*)", - "Bash(ctx session:*)", - "Bash(ctx tasks:*)", - "Bash(ctx loop:*)", - } -} - -// CreateDefaultHooks returns the default ctx hooks configuration for -// Claude Code. -// -// The returned hooks configure PreToolUse to block non-PATH ctx -// invocations and auto-load context on every tool use, and SessionEnd -// to run auto-save-session.sh for persisting session transcripts. -// -// Parameters: -// - projectDir: Project root directory for absolute hook paths; if empty, -// paths are relative (e.g., ".claude/hooks/") -// -// Returns: -// - HookConfig: Configured hooks for PreToolUse and SessionEnd events -func CreateDefaultHooks(projectDir string) HookConfig { - hooksDir := ".claude/hooks" - if projectDir != "" { - hooksDir = fmt.Sprintf("%s/.claude/hooks", projectDir) - } - - return HookConfig{ - PreToolUse: []HookMatcher{ - { - // Block non-PATH ctx invocations (./ctx, ./dist/ctx, go run ./cmd/ctx) - Matcher: "Bash", - Hooks: []Hook{ - { - Type: "command", - Command: fmt.Sprintf("%s/block-non-path-ctx.sh", hooksDir), - }, - }, - }, - { - // Autoload context on every tool use - Matcher: ".*", - Hooks: []Hook{ - { - Type: "command", - Command: "ctx agent --budget 4000 2>/dev/null || true", - }, - }, - }, - }, - SessionEnd: []HookMatcher{ - { - Hooks: []Hook{ - { - Type: "command", - Command: fmt.Sprintf("%s/auto-save-session.sh", hooksDir), - }, - }, - }, - }, - } -} diff --git a/internal/claude/hook.go b/internal/claude/hook.go new file mode 100644 index 000000000..237e66e42 --- /dev/null +++ b/internal/claude/hook.go @@ -0,0 +1,77 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package claude + +import "fmt" + +// DefaultHooks returns the default ctx hooks configuration for +// Claude Code. +// +// The returned hooks configure PreToolUse to block non-PATH ctx +// invocations and autoload context on every tool use, UserPromptSubmit +// for prompt coaching, and SessionEnd to run auto-save-session.sh for +// persisting session transcripts. +// +// Parameters: +// - projectDir: Project root directory for absolute hook paths; if empty, +// paths are relative (e.g., ".claude/hooks/") +// +// Returns: +// - HookConfig: Configured hooks for PreToolUse, UserPromptSubmit, and +// SessionEnd events +func DefaultHooks(projectDir string) HookConfig { + hooksDir := ".claude/hooks" + if projectDir != "" { + hooksDir = fmt.Sprintf("%s/.claude/hooks", projectDir) + } + + return HookConfig{ + PreToolUse: []HookMatcher{ + { + // Block non-PATH ctx invocations (./ctx, ./dist/ctx, go run ./cmd/ctx) + Matcher: "Bash", + Hooks: []Hook{ + { + Type: "command", + Command: fmt.Sprintf("%s/block-non-path-ctx.sh", hooksDir), + }, + }, + }, + { + // Autoload context on every tool use + Matcher: ".*", + Hooks: []Hook{ + { + Type: "command", + Command: "ctx agent --budget 4000 2>/dev/null || true", + }, + }, + }, + }, + UserPromptSubmit: []HookMatcher{ + { + // Prompt coaching: detect anti-patterns and suggest improvements + Hooks: []Hook{ + { + Type: "command", + Command: fmt.Sprintf("%s/prompt-coach.sh", hooksDir), + }, + }, + }, + }, + SessionEnd: []HookMatcher{ + { + Hooks: []Hook{ + { + Type: "command", + Command: fmt.Sprintf("%s/auto-save-session.sh", hooksDir), + }, + }, + }, + }, + } +} diff --git a/internal/claude/perm.go b/internal/claude/perm.go new file mode 100644 index 000000000..f034f8010 --- /dev/null +++ b/internal/claude/perm.go @@ -0,0 +1,25 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package claude + +// DefaultPermissions returns the default permissions for ctx commands. +// +// These permissions allow Claude Code to run ctx CLI commands without +// prompting for approval. All ctx subcommands are pre-approved. +// +// Returns: +// - []string: List of permission patterns for ctx commands +func DefaultPermissions() []string { + return []string{ + "Bash(ctx status:*)", + "Bash(ctx agent:*)", + "Bash(ctx add:*)", + "Bash(ctx session:*)", + "Bash(ctx tasks:*)", + "Bash(ctx loop:*)", + } +} diff --git a/internal/claude/script.go b/internal/claude/script.go new file mode 100644 index 000000000..e1a51d760 --- /dev/null +++ b/internal/claude/script.go @@ -0,0 +1,65 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package claude + +import ( + "fmt" + + "github.com/ActiveMemory/ctx/internal/templates" +) + +// AutoSaveScript returns the auto-save session script content. +// +// The script automatically saves Claude Code session transcripts when a +// session ends. It is installed to .claude/hooks/ during ctx init --claude. +// +// Returns: +// - []byte: Raw bytes of the auto-save-session.sh script +// - error: Non-nil if the embedded file cannot be read +func AutoSaveScript() ([]byte, error) { + content, err := templates.ClaudeHookByFileName("auto-save-session.sh") + if err != nil { + return nil, fmt.Errorf("failed to read auto-save-session.sh: %w", err) + } + return content, nil +} + +// BlockNonPathCtxScript returns the script that blocks non-PATH ctx +// invocations. +// +// The script prevents Claude from running ctx via relative paths (./ctx, +// ./dist/ctx) or "go run", ensuring only the installed PATH version is used. +// It is installed to .claude/hooks/ during ctx init --claude. +// +// Returns: +// - []byte: Raw bytes of the block-non-path-ctx.sh script +// - error: Non-nil if the embedded file cannot be read +func BlockNonPathCtxScript() ([]byte, error) { + content, err := templates.ClaudeHookByFileName("block-non-path-ctx.sh") + if err != nil { + return nil, fmt.Errorf("failed to read block-non-path-ctx.sh: %w", err) + } + return content, nil +} + +// PromptCoachScript returns the prompt coaching hook script. +// +// The script detects prompt anti-patterns (e.g., "idiomatic Go") and suggests +// better alternatives (e.g., "follow project conventions"). It limits +// suggestions to 3 per pattern per session to avoid annoying the user. +// It is installed to .claude/hooks/ during ctx init --claude. +// +// Returns: +// - []byte: Raw bytes of the prompt-coach.sh script +// - error: Non-nil if the embedded file cannot be read +func PromptCoachScript() ([]byte, error) { + content, err := templates.ClaudeHookByFileName("prompt-coach.sh") + if err != nil { + return nil, fmt.Errorf("failed to read prompt-coach.sh: %w", err) + } + return content, nil +} diff --git a/internal/claude/types.go b/internal/claude/types.go index 3b6eda0d8..083e6a4b4 100644 --- a/internal/claude/types.go +++ b/internal/claude/types.go @@ -14,10 +14,12 @@ package claude // // Fields: // - PreToolUse: Matchers that run before each tool invocation +// - UserPromptSubmit: Matchers that run when the user submits a prompt // - SessionEnd: Matchers that run when a session ends type HookConfig struct { - PreToolUse []HookMatcher `json:"PreToolUse,omitempty"` - SessionEnd []HookMatcher `json:"SessionEnd,omitempty"` + PreToolUse []HookMatcher `json:"PreToolUse,omitempty"` + UserPromptSubmit []HookMatcher `json:"UserPromptSubmit,omitempty"` + SessionEnd []HookMatcher `json:"SessionEnd,omitempty"` } // HookMatcher associates a regex pattern with hooks to execute. @@ -47,7 +49,8 @@ type Hook struct { // settings.local.json. // // Fields: -// - Allow: List of tool patterns that are pre-approved (e.g., "Bash(ctx status:*)") +// - Allow: List of tool patterns that are pre-approved +// (e.g., "Bash(ctx status:*)") type PermissionsConfig struct { Allow []string `json:"allow,omitempty"` } diff --git a/internal/cli/add/add.go b/internal/cli/add/add.go index b8e39ca9a..372722132 100644 --- a/internal/cli/add/add.go +++ b/internal/cli/add/add.go @@ -69,7 +69,7 @@ Examples: ctx add task "Implement user authentication" --priority high`, Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runAdd(cmd, args, addFlags{ + return runAdd(cmd, args, addConfig{ priority: priority, section: section, fromFile: fromFile, @@ -125,25 +125,3 @@ Examples: return cmd } - -// addFlags holds all flags for the add command. -// -// Fields: -// - priority: Priority level for tasks (high, medium, low) -// - section: Target section in TASKS.md -// - fromFile: Read content from file instead of argument -// - context: Context field for decisions/learnings -// - rationale: Rationale field for decisions -// - consequences: Consequences field for decisions -// - lesson: Lesson field for learnings -// - application: Application field for learnings -type addFlags struct { - priority string - section string - fromFile string - context string - rationale string - consequences string - lesson string - application string -} diff --git a/internal/cli/add/add_test.go b/internal/cli/add/add_test.go index ea01400fb..ab6be8ce4 100644 --- a/internal/cli/add/add_test.go +++ b/internal/cli/add/add_test.go @@ -312,9 +312,9 @@ func TestAppendEntry(t *testing.T) { }) t.Run("learning prepends after separator", func(t *testing.T) { - // Use timestamp format "- **[" to match what FormatLearning produces - existing := []byte("# Learnings\n\n\n\n- **[2026-01-01]** Old Learning\n") - entry := "- **[2026-01-02]** New Learning\n" + // Use section format "## [" to match what FormatLearning produces + existing := []byte("# Learnings\n\n\n\n## [2026-01-01] Old Learning\n\nContent\n") + entry := "## [2026-01-02] New Learning\n\nContent\n" result := AppendEntry(existing, entry, "learning", "") @@ -361,7 +361,7 @@ func TestAppendEntry(t *testing.T) { t.Run("learning on empty file", func(t *testing.T) { existing := []byte("# Learnings\n\n\n") - entry := "- **[2026-01-01]** First Learning\n" + entry := "## [2026-01-01] First Learning\n\nContent\n" result := AppendEntry(existing, entry, "learning", "") diff --git a/internal/cli/add/append.go b/internal/cli/add/append.go index 134c34225..094c82833 100644 --- a/internal/cli/add/append.go +++ b/internal/cli/add/append.go @@ -6,14 +6,13 @@ package add -import "strings" +import "github.com/ActiveMemory/ctx/internal/config" // AppendEntry inserts a formatted entry into existing file content. // -// For task entries, the function locates the target section header and inserts -// the entry immediately after it. For decisions and learnings, entries are -// prepended (inserted after the header section) for reverse-chronological order. -// For conventions, entries are appended to the end of the file. +// For tasks, inserts after the target section header. For decisions and +// learnings, inserts before existing entries (reverse-chronological order). +// For conventions, appends to the end of the file. // // Parameters: // - existing: Current file content as bytes @@ -29,158 +28,18 @@ func AppendEntry( ) []byte { existingStr := string(existing) + switch { // For tasks, find the appropriate section - if fileType == "task" || fileType == "tasks" { - targetSection := section - if targetSection == "" { - targetSection = "## Next Up" - } else if !strings.HasPrefix(targetSection, "##") { - targetSection = "## " + targetSection - } - - // Find the section and insert after it - idx := strings.Index(existingStr, targetSection) - if idx != -1 { - // Find the end of the section header line - lineEnd := strings.Index(existingStr[idx:], "\n") - if lineEnd != -1 { - insertPoint := idx + lineEnd + 1 - return []byte(existingStr[:insertPoint] + "\n" + - entry + existingStr[insertPoint:]) - } - } - } - - // For decisions, prepend after the "# Decisions" header for reverse-chronological order - if fileType == "decision" || fileType == "decisions" { - return prependAfterHeader(existingStr, entry, "# Decisions") - } - - // For learnings, prepend after the header section (after the first "---") - if fileType == "learning" || fileType == "learnings" { - return prependAfterSeparator(existingStr, entry) - } - - // Default (conventions): append at the end - if !strings.HasSuffix(existingStr, "\n") { - existingStr += "\n" - } - return []byte(existingStr + "\n" + entry) -} - -// prependAfterHeader inserts an entry after a header line. -// -// Used for DECISIONS.md to maintain reverse-chronological order. -// Entries are inserted before any existing entries (identified by "## ["). -// -// Parameters: -// - content: Existing file content -// - entry: Formatted entry to insert -// - header: Header line to insert after (e.g., "# Decisions") -// -// Returns: -// - []byte: Modified content with entry inserted -func prependAfterHeader(content, entry, header string) []byte { - // Find the first entry marker "## [" (timestamp-prefixed sections) - entryIdx := strings.Index(content, "## [") - if entryIdx != -1 { - // Insert before the first entry, with separator after - return []byte(content[:entryIdx] + entry + "\n---\n\n" + content[entryIdx:]) - } - - // No existing entries - find header and insert after it - idx := strings.Index(content, header) - if idx != -1 { - lineEnd := strings.Index(content[idx:], "\n") - if lineEnd != -1 { - insertPoint := idx + lineEnd + 1 - // Skip blank lines and comments - for insertPoint < len(content) { - if content[insertPoint] == '\n' { - insertPoint++ - } else if insertPoint+4 < len(content) && content[insertPoint:insertPoint+4] == "") - if endComment != -1 { - insertPoint += endComment + 3 - // Skip trailing whitespace after comment - for insertPoint < len(content) && (content[insertPoint] == '\n' || content[insertPoint] == ' ') { - insertPoint++ - } - } else { - break - } - } else { - break - } - } - return []byte(content[:insertPoint] + entry) - } - } - - // Fallback: append at the end - if !strings.HasSuffix(content, "\n") { - content += "\n" - } - return []byte(content + "\n" + entry) -} - -// prependAfterSeparator inserts an entry for learnings. -// -// Entries are inserted before any existing entries (identified by "- **["). -// -// Parameters: -// - content: Existing file content -// - entry: Formatted entry to insert -// -// Returns: -// - []byte: Modified content with entry inserted -func prependAfterSeparator(content, entry string) []byte { - // Find the first entry marker "- **[" (timestamp-prefixed list items) - entryIdx := strings.Index(content, "- **[") - if entryIdx != -1 { - // Insert before the first entry - return []byte(content[:entryIdx] + entry + "\n" + content[entryIdx:]) - } - - // Also check for section-style learnings "## [" - if entryIdx = strings.Index(content, "## ["); entryIdx != -1 { - return []byte(content[:entryIdx] + entry + "\n---\n\n" + content[entryIdx:]) - } - - // No existing entries - find header and insert after it - idx := strings.Index(content, "# Learnings") - if idx != -1 { - lineEnd := strings.Index(content[idx:], "\n") - if lineEnd != -1 { - insertPoint := idx + lineEnd + 1 - // Skip blank lines and comments - for insertPoint < len(content) { - if content[insertPoint] == '\n' { - insertPoint++ - } else if insertPoint+4 < len(content) && content[insertPoint:insertPoint+4] == "") - if endComment != -1 { - insertPoint += endComment + 3 - // Skip trailing whitespace after comment - for insertPoint < len(content) && (content[insertPoint] == '\n' || content[insertPoint] == ' ') { - insertPoint++ - } - } else { - break - } - } else { - break - } - } - return []byte(content[:insertPoint] + entry) - } - } - - // Final fallback: append at the end - if !strings.HasSuffix(content, "\n") { - content += "\n" + case fileTypeIsTask(fileType): + return insertTask(entry, existingStr, section) + // Decisions: insert before existing entries for reverse-chronological order + case fileTypeIsDecision(fileType): + return insertDecision(existingStr, entry, config.HeadingDecisions) + // Learnings: insert before existing entries for reverse-chronological order + case fileTypeIsLearning(fileType): + return insertLearning(existingStr, entry) + default: + // Default (conventions): append at the end + return appendAtEnd(existingStr, entry) } - return []byte(content + "\n" + entry) } diff --git a/internal/cli/add/config.go b/internal/cli/add/config.go new file mode 100644 index 000000000..da6ed043b --- /dev/null +++ b/internal/cli/add/config.go @@ -0,0 +1,29 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package add + +// addConfig holds all flags for the add command. +// +// Fields: +// - priority: Priority level for tasks (high, medium, low) +// - section: Target section in TASKS.md +// - fromFile: Read content from file instead of argument +// - context: Context field for decisions/learnings +// - rationale: Rationale field for decisions +// - consequences: Consequences field for decisions +// - lesson: Lesson field for learnings +// - application: Application field for learnings +type addConfig struct { + priority string + section string + fromFile string + context string + rationale string + consequences string + lesson string + application string +} diff --git a/internal/cli/add/content.go b/internal/cli/add/content.go new file mode 100644 index 000000000..1adbc0965 --- /dev/null +++ b/internal/cli/add/content.go @@ -0,0 +1,48 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package add + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/ActiveMemory/ctx/internal/config" +) + +func extractContent(args []string, flags addConfig) (string, error) { + if flags.fromFile != "" { + // Read from the file + fileContent, err := os.ReadFile(flags.fromFile) + if err != nil { + return "", fmt.Errorf("failed to read file %s: %w", flags.fromFile, err) + } + return strings.TrimSpace(string(fileContent)), nil + } + + if len(args) > 1 { + // Content from arguments + return strings.Join(args[1:], " "), nil + } + + // Try reading from stdin (check if it's a pipe) + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + // stdin is a pipe, read from it + scanner := bufio.NewScanner(os.Stdin) + var lines []string + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("failed to read from stdin: %w", err) + } + return strings.TrimSpace(strings.Join(lines, config.NewlineLF)), nil + } + return "", fmt.Errorf("no content provided") +} diff --git a/internal/cli/add/err.go b/internal/cli/add/err.go new file mode 100644 index 000000000..f14ada664 --- /dev/null +++ b/internal/cli/add/err.go @@ -0,0 +1,84 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package add + +import ( + "fmt" + "strings" +) + +// errMissingDecision returns an error with usage help for incomplete decisions. +// +// Parameters: +// - missing: List of missing required flag names (e.g., "--context") +// +// Returns: +// - error: Formatted error with ADR format requirements and example +func errMissingDecision(missing []string) error { + return fmt.Errorf(`decisions require complete ADR format + +Missing required flags: %s + +Usage: + ctx add decision "Decision title" \ + --context "What prompted this decision" \ + --rationale "Why this choice over alternatives" \ + --consequences "What changes as a result" + +Example: + ctx add decision "Use PostgreSQL for primary database" \ + --context "Need a reliable database for production workloads" \ + --rationale "PostgreSQL offers ACID compliance, JSON support, and team familiarity" \ + --consequences "Team needs PostgreSQL training; must set up replication"`, + strings.Join(missing, ", ")) +} + +// errMissingLearning returns an error with usage help for incomplete learnings. +// +// Parameters: +// - missing: List of missing required flag names (e.g., "--lesson") +// +// Returns: +// - error: Formatted error with learning format requirements and example +func errMissingLearning(missing []string) error { + return fmt.Errorf(`learnings require complete format + +Missing required flags: %s + +Usage: + ctx add learning "Learning title" \ + --context "What prompted this learning" \ + --lesson "The key insight" \ + --application "How to apply this going forward" + +Example: + ctx add learning "Go embed requires files in same package" \ + --context "Tried to embed files from parent directory, got compile error" \ + --lesson "go:embed only works with files in same or child directories" \ + --application "Keep embedded files in internal/templates/, not project root"`, + strings.Join(missing, ", ")) +} + +// errNoContentProvided returns an error with usage help when content is missing. +// +// Parameters: +// - fType: Entry type (e.g., "decision", "task") for contextual examples +// +// Returns: +// - error: Formatted error showing input methods and type-specific examples +func errNoContentProvided(fType string) error { + examples := examplesForType(fType) + return fmt.Errorf(`no content provided + +Usage: + ctx add %s "your content here" + ctx add %s --file /path/to/content.md + echo "content" | ctx add %s + +Examples: +%s`, fType, fType, fType, examples) +} diff --git a/internal/cli/add/example.go b/internal/cli/add/example.go index 2ddf6ddc0..ffb77a65f 100644 --- a/internal/cli/add/example.go +++ b/internal/cli/add/example.go @@ -8,7 +8,7 @@ package add import "github.com/ActiveMemory/ctx/internal/config" -// getExamplesForType returns example usage strings for a given entry type. +// examplesForType returns example usage strings for a given entry type. // // The examples are displayed in error messages when content is missing, // helping users understand the correct command syntax. @@ -19,20 +19,20 @@ import "github.com/ActiveMemory/ctx/internal/config" // Returns: // - string: Formatted example commands; returns a generic example for // unrecognized types -func getExamplesForType(fileType string) string { - switch fileType { - case config.UpdateTypeDecision, config.UpdateTypeDecisions: +func examplesForType(fileType string) string { + switch config.UserInputToEntry(fileType) { + case config.EntryDecision: return ` ctx add decision "Use PostgreSQL for primary database" ctx add decision "Adopt Go 1.22 for range-over-func support"` - case config.UpdateTypeTask, config.UpdateTypeTasks: + case config.EntryTask: return ` ctx add task "Implement user authentication" ctx add task "Fix login bug" --priority high` - case config.UpdateTypeLearning, config.UpdateTypeLearnings: + case config.EntryLearning: return ` ctx add learning "Go embed requires files in same package" \ --context "Tried to embed files from parent directory" \ --lesson "go:embed only works with files in same or child directories" \ --application "Keep embedded files in internal/templates/"` - case config.UpdateTypeConvention, config.UpdateTypeConventions: + case config.EntryConvention: return ` ctx add convention "Use camelCase for function names" ctx add convention "All API responses use JSON"` default: diff --git a/internal/cli/add/format.go b/internal/cli/add/fmt.go similarity index 100% rename from internal/cli/add/format.go rename to internal/cli/add/fmt.go diff --git a/internal/cli/add/index.go b/internal/cli/add/index.go index 6976771f0..e67222835 100644 --- a/internal/cli/add/index.go +++ b/internal/cli/add/index.go @@ -6,39 +6,24 @@ package add -import ( - "regexp" - "strings" -) +// This file contains backward-compatible aliases for index operations +// that delegate to the internal/index package. -// Index markers used in context files -const ( - IndexStart = "" - IndexEnd = "" +import ( + "github.com/ActiveMemory/ctx/internal/index" ) // IndexEntry represents a parsed entry header from a context file. // -// Fields: -// - Timestamp: Full timestamp (YYYY-MM-DD-HHMMSS) -// - Date: Date only (YYYY-MM-DD) -// - Title: Entry title -type IndexEntry struct { - Timestamp string - Date string - Title string -} +// This is an alias for index.Entry for backward compatibility. +type IndexEntry = index.Entry -// DecisionEntry is an alias for backward compatibility. -type DecisionEntry = IndexEntry - -// entryHeaderRegex matches headers like "## [2026-01-28-051426] Title here" -var entryHeaderRegex = regexp.MustCompile(`## \[(\d{4}-\d{2}-\d{2})-(\d{6})\] (.+)`) +// DecisionEntry is an alias for IndexEntry for backward compatibility. +type DecisionEntry = index.Entry // ParseEntryHeaders extracts all entries from file content. // -// It scans for headers matching the pattern "## [YYYY-MM-DD-HHMMSS] Title" -// and returns them in the order they appear in the file. +// Delegates to index.ParseHeaders. // // Parameters: // - content: The full content of a context file @@ -46,40 +31,25 @@ var entryHeaderRegex = regexp.MustCompile(`## \[(\d{4}-\d{2}-\d{2})-(\d{6})\] (. // Returns: // - []IndexEntry: Slice of parsed entries (may be empty) func ParseEntryHeaders(content string) []IndexEntry { - var entries []IndexEntry - - matches := entryHeaderRegex.FindAllStringSubmatch(content, -1) - for _, match := range matches { - if len(match) == 4 { - date := match[1] - time := match[2] - title := match[3] - entries = append(entries, IndexEntry{ - Timestamp: date + "-" + time, - Date: date, - Title: title, - }) - } - } - - return entries + return index.ParseHeaders(content) } -// ParseDecisionHeaders is an alias for ParseEntryHeaders for backward compatibility. +// ParseDecisionHeaders extracts all entries from file content. +// +// This is an alias for ParseEntryHeaders for backward compatibility. // // Parameters: // - content: The full content of a context file // // Returns: -// - []DecisionEntry: Slice of parsed entries (may be empty) +// - []DecisionEntry: Slice of parsed entries (it may be empty) func ParseDecisionHeaders(content string) []DecisionEntry { - return ParseEntryHeaders(content) + return index.ParseHeaders(content) } -// GenerateIndexTable creates a markdown table index from entries. +// GenerateIndexTable creates a Markdown table index from entries. // -// The table has two columns: Date and the specified column header. -// If there are no entries, returns an empty string. +// Delegates to index.GenerateTable. // // Parameters: // - entries: Slice of entries to include @@ -88,32 +58,12 @@ func ParseDecisionHeaders(content string) []DecisionEntry { // Returns: // - string: Markdown table (without markers) or empty string func GenerateIndexTable(entries []IndexEntry, columnHeader string) string { - if len(entries) == 0 { - return "" - } - - var sb strings.Builder - sb.WriteString("| Date | ") - sb.WriteString(columnHeader) - sb.WriteString(" |\n") - sb.WriteString("|------|") - sb.WriteString(strings.Repeat("-", len(columnHeader))) - sb.WriteString("|\n") - - for _, e := range entries { - // Escape pipe characters in title - title := strings.ReplaceAll(e.Title, "|", "\\|") - sb.WriteString("| ") - sb.WriteString(e.Date) - sb.WriteString(" | ") - sb.WriteString(title) - sb.WriteString(" |\n") - } - - return sb.String() + return index.GenerateTable(entries, columnHeader) } -// GenerateIndex creates a markdown table for decisions (backward compatibility). +// GenerateIndex creates a Markdown table for decisions. +// +// This is a convenience wrapper for backward compatibility. // // Parameters: // - entries: Slice of decision entries to include @@ -121,101 +71,31 @@ func GenerateIndexTable(entries []IndexEntry, columnHeader string) string { // Returns: // - string: Markdown table or empty string if no entries func GenerateIndex(entries []DecisionEntry) string { - return GenerateIndexTable(entries, "Decision") -} - -// updateFileIndex regenerates the index in file content. -// -// If INDEX:START and INDEX:END markers exist, the content between them -// is replaced. Otherwise, the index is inserted after the specified header. -// If there are no entries, any existing index is removed. -// -// Parameters: -// - content: The full content of the file -// - fileHeader: The main header to insert after (e.g., "# Decisions") -// - columnHeader: Header for the table column (e.g., "Decision") -// -// Returns: -// - string: Updated content with regenerated index -func updateFileIndex(content, fileHeader, columnHeader string) string { - entries := ParseEntryHeaders(content) - indexContent := GenerateIndexTable(entries, columnHeader) - - // Check if markers already exist - startIdx := strings.Index(content, IndexStart) - endIdx := strings.Index(content, IndexEnd) - - if startIdx != -1 && endIdx != -1 && endIdx > startIdx { - // Replace existing index - if indexContent == "" { - // No entries - remove index entirely (including markers and surrounding whitespace) - before := strings.TrimRight(content[:startIdx], "\n") - after := content[endIdx+len(IndexEnd):] - after = strings.TrimLeft(after, "\n") - if after != "" { - return before + "\n\n" + after - } - return before + "\n" - } - // Replace content between markers - before := content[:startIdx+len(IndexStart)] - after := content[endIdx:] - return before + "\n" + indexContent + after - } - - // No existing markers - insert after file header - if indexContent == "" { - // No entries, nothing to insert - return content - } - - headerIdx := strings.Index(content, fileHeader) - if headerIdx == -1 { - // No header found, return unchanged - return content - } - - // Find end of header line - lineEnd := strings.Index(content[headerIdx:], "\n") - if lineEnd == -1 { - // Header is at end of file - return content + "\n\n" + IndexStart + "\n" + indexContent + IndexEnd + "\n" - } - - insertPoint := headerIdx + lineEnd + 1 - - // Build new content with index - var sb strings.Builder - sb.WriteString(content[:insertPoint]) - sb.WriteString("\n") - sb.WriteString(IndexStart) - sb.WriteString("\n") - sb.WriteString(indexContent) - sb.WriteString(IndexEnd) - sb.WriteString("\n") - sb.WriteString(content[insertPoint:]) - - return sb.String() + return index.GenerateTable(entries, "Decision") } // UpdateIndex regenerates the decision index in DECISIONS.md content. // +// Delegates to index.UpdateDecisions. +// // Parameters: // - content: The full content of DECISIONS.md // // Returns: // - string: Updated content with regenerated index func UpdateIndex(content string) string { - return updateFileIndex(content, "# Decisions", "Decision") + return index.UpdateDecisions(content) } // UpdateLearningsIndex regenerates the learning index in LEARNINGS.md content. // +// Delegates to index.UpdateLearnings. +// // Parameters: // - content: The full content of LEARNINGS.md // // Returns: // - string: Updated content with regenerated index func UpdateLearningsIndex(content string) string { - return updateFileIndex(content, "# Learnings", "Learning") + return index.UpdateLearnings(content) } diff --git a/internal/cli/add/index_test.go b/internal/cli/add/index_test.go index 5796c8c78..7d423e680 100644 --- a/internal/cli/add/index_test.go +++ b/internal/cli/add/index_test.go @@ -9,399 +9,55 @@ package add import ( "strings" "testing" -) - -func TestParseDecisionHeaders(t *testing.T) { - tests := []struct { - name string - content string - expected []DecisionEntry - }{ - { - name: "empty content", - content: "", - expected: nil, - }, - { - name: "no decisions", - content: "# Decisions\n\nSome text here.", - expected: nil, - }, - { - name: "single decision", - content: `# Decisions - -## [2026-01-28-051426] No custom UI - IDE is the interface - -**Status**: Accepted -`, - expected: []DecisionEntry{ - {Timestamp: "2026-01-28-051426", Date: "2026-01-28", Title: "No custom UI - IDE is the interface"}, - }, - }, - { - name: "multiple decisions", - content: `# Decisions - -## [2026-01-28-051426] First decision - -**Status**: Accepted - ---- - -## [2026-01-27-123456] Second decision - -**Status**: Accepted -`, - expected: []DecisionEntry{ - {Timestamp: "2026-01-28-051426", Date: "2026-01-28", Title: "First decision"}, - {Timestamp: "2026-01-27-123456", Date: "2026-01-27", Title: "Second decision"}, - }, - }, - { - name: "decision with special characters", - content: `# Decisions - -## [2026-01-28-051426] Use tool-agnostic Session type | with pipe - -**Status**: Accepted -`, - expected: []DecisionEntry{ - {Timestamp: "2026-01-28-051426", Date: "2026-01-28", Title: "Use tool-agnostic Session type | with pipe"}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ParseDecisionHeaders(tt.content) - if len(got) != len(tt.expected) { - t.Errorf("ParseDecisionHeaders() got %d entries, want %d", len(got), len(tt.expected)) - return - } - for i, entry := range got { - if entry.Timestamp != tt.expected[i].Timestamp { - t.Errorf("entry[%d].Timestamp = %q, want %q", i, entry.Timestamp, tt.expected[i].Timestamp) - } - if entry.Date != tt.expected[i].Date { - t.Errorf("entry[%d].Date = %q, want %q", i, entry.Date, tt.expected[i].Date) - } - if entry.Title != tt.expected[i].Title { - t.Errorf("entry[%d].Title = %q, want %q", i, entry.Title, tt.expected[i].Title) - } - } - }) - } -} - -func TestGenerateIndex(t *testing.T) { - tests := []struct { - name string - entries []DecisionEntry - expected string - }{ - { - name: "empty entries", - entries: nil, - expected: "", - }, - { - name: "empty slice", - entries: []DecisionEntry{}, - expected: "", - }, - { - name: "single entry", - entries: []DecisionEntry{ - {Timestamp: "2026-01-28-051426", Date: "2026-01-28", Title: "First decision"}, - }, - expected: `| Date | Decision | -|------|--------| -| 2026-01-28 | First decision | -`, - }, - { - name: "multiple entries", - entries: []DecisionEntry{ - {Timestamp: "2026-01-28-051426", Date: "2026-01-28", Title: "First"}, - {Timestamp: "2026-01-27-123456", Date: "2026-01-27", Title: "Second"}, - }, - expected: `| Date | Decision | -|------|--------| -| 2026-01-28 | First | -| 2026-01-27 | Second | -`, - }, - { - name: "entry with pipe character", - entries: []DecisionEntry{ - {Timestamp: "2026-01-28-051426", Date: "2026-01-28", Title: "Use A | B format"}, - }, - expected: `| Date | Decision | -|------|--------| -| 2026-01-28 | Use A \| B format | -`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := GenerateIndex(tt.entries) - if got != tt.expected { - t.Errorf("GenerateIndex() =\n%q\nwant\n%q", got, tt.expected) - } - }) - } -} -func TestUpdateIndex(t *testing.T) { - tests := []struct { - name string - content string - wantHas []string // strings that should be present - wantNot []string // strings that should NOT be present - }{ - { - name: "empty file with header", - content: "# Decisions\n", - wantNot: []string{IndexStart, IndexEnd}, - }, - { - name: "file with one decision", - content: `# Decisions - -## [2026-01-28-051426] Test decision - -**Status**: Accepted -`, - wantHas: []string{ - IndexStart, - IndexEnd, - "| Date | Decision |", - "| 2026-01-28 | Test decision |", - "## [2026-01-28-051426] Test decision", - }, - }, - { - name: "update existing index", - content: `# Decisions - - -| Date | Decision | -|------|----------| -| 2026-01-28 | Old entry | - - -## [2026-01-28-051426] New decision - -**Status**: Accepted -`, - wantHas: []string{ - IndexStart, - IndexEnd, - "| 2026-01-28 | New decision |", - }, - wantNot: []string{ - "| 2026-01-28 | Old entry |", - }, - }, - { - name: "remove index when no decisions", - content: `# Decisions - - -| Date | Decision | -|------|----------| -| 2026-01-28 | Old entry | - - -Some other content. -`, - wantNot: []string{ - IndexStart, - IndexEnd, - "| Date | Decision |", - }, - wantHas: []string{ - "# Decisions", - "Some other content.", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := UpdateIndex(tt.content) - for _, want := range tt.wantHas { - if !strings.Contains(got, want) { - t.Errorf("UpdateIndex() result missing %q\nGot:\n%s", want, got) - } - } - for _, notWant := range tt.wantNot { - if strings.Contains(got, notWant) { - t.Errorf("UpdateIndex() result should not contain %q\nGot:\n%s", notWant, got) - } - } - }) - } -} + "github.com/ActiveMemory/ctx/internal/config" +) -func TestUpdateIndex_PreservesContent(t *testing.T) { +// TestDelegation verifies that the wrapper functions correctly delegate +// to the internal/index package. The full logic is tested in internal/index. +func TestDelegation(t *testing.T) { content := `# Decisions -## [2026-01-28-051426] First decision - -**Status**: Accepted - -**Context**: Some context here. - -**Decision**: The decision text. - -**Rationale**: Why we did it. - -**Consequences**: What happens next. - ---- - -## [2026-01-27-123456] Second decision +## [2026-01-28-051426] Test decision **Status**: Accepted - -**Context**: Another context. - -**Decision**: Another decision. - -**Rationale**: Another rationale. - -**Consequences**: More consequences. ` - got := UpdateIndex(content) - - // Index should be present - if !strings.Contains(got, IndexStart) { - t.Error("Missing INDEX:START marker") - } - if !strings.Contains(got, IndexEnd) { - t.Error("Missing INDEX:END marker") - } - - // Both entries should be in index - if !strings.Contains(got, "| 2026-01-28 | First decision |") { - t.Error("Missing first decision in index") + // Test ParseEntryHeaders delegation + entries := ParseEntryHeaders(content) + if len(entries) != 1 { + t.Errorf("ParseEntryHeaders() got %d entries, want 1", len(entries)) } - if !strings.Contains(got, "| 2026-01-27 | Second decision |") { - t.Error("Missing second decision in index") + if entries[0].Date != "2026-01-28" { + t.Errorf("ParseEntryHeaders() entry.Date = %q, want %q", entries[0].Date, "2026-01-28") } - // Full content should be preserved - if !strings.Contains(got, "**Context**: Some context here.") { - t.Error("Lost content from first decision") - } - if !strings.Contains(got, "**Rationale**: Another rationale.") { - t.Error("Lost content from second decision") + // Test ParseDecisionHeaders alias + decisionEntries := ParseDecisionHeaders(content) + if len(decisionEntries) != 1 { + t.Errorf("ParseDecisionHeaders() got %d entries, want 1", len(decisionEntries)) } -} - -func TestUpdateIndex_Idempotent(t *testing.T) { - content := `# Decisions -## [2026-01-28-051426] Test decision - -**Status**: Accepted -` - - // Apply once - first := UpdateIndex(content) - - // Apply again - second := UpdateIndex(first) - - // Should be identical - if first != second { - t.Errorf("UpdateIndex is not idempotent\nFirst:\n%s\nSecond:\n%s", first, second) + // Test GenerateIndexTable delegation + table := GenerateIndexTable(entries, "Decision") + if !strings.Contains(table, "| Date | Decision |") { + t.Error("GenerateIndexTable() missing header") } -} - -func TestUpdateLearningsIndex(t *testing.T) { - tests := []struct { - name string - content string - wantHas []string - wantNot []string - }{ - { - name: "empty file with header", - content: "# Learnings\n", - wantNot: []string{IndexStart, IndexEnd}, - }, - { - name: "file with one learning", - content: `# Learnings - -## [2026-01-28-191951] Required flags now enforced - -**Context**: Implemented ctx add learning flags - -**Lesson**: Structured entries are more useful -**Application**: Always use all three flags -`, - wantHas: []string{ - IndexStart, - IndexEnd, - "| Date | Learning |", - "| 2026-01-28 | Required flags now enforced |", - }, - }, - { - name: "multiple learnings", - content: `# Learnings - -## [2026-01-28-191951] First learning - -**Context**: Test - -**Lesson**: Test - -**Application**: Test - ---- - -## [2026-01-27-120000] Second learning - -**Context**: Test - -**Lesson**: Test - -**Application**: Test -`, - wantHas: []string{ - "| 2026-01-28 | First learning |", - "| 2026-01-27 | Second learning |", - }, - }, + // Test GenerateIndex convenience function + indexTable := GenerateIndex(entries) + if !strings.Contains(indexTable, "| Date | Decision |") { + t.Error("GenerateIndex() missing header") } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := UpdateLearningsIndex(tt.content) - for _, want := range tt.wantHas { - if !strings.Contains(got, want) { - t.Errorf("UpdateLearningsIndex() result missing %q\nGot:\n%s", want, got) - } - } - for _, notWant := range tt.wantNot { - if strings.Contains(got, notWant) { - t.Errorf("UpdateLearningsIndex() result should not contain %q\nGot:\n%s", notWant, got) - } - } - }) + // Test UpdateIndex delegation + updated := UpdateIndex(content) + if !strings.Contains(updated, config.IndexStart) { + t.Error("UpdateIndex() missing INDEX:START marker") } -} -func TestUpdateLearningsIndex_Idempotent(t *testing.T) { - content := `# Learnings + // Test UpdateLearningsIndex delegation + learningContent := `# Learnings ## [2026-01-28-191951] Test learning @@ -411,28 +67,11 @@ func TestUpdateLearningsIndex_Idempotent(t *testing.T) { **Application**: Test ` - - first := UpdateLearningsIndex(content) - second := UpdateLearningsIndex(first) - - if first != second { - t.Errorf("UpdateLearningsIndex is not idempotent\nFirst:\n%s\nSecond:\n%s", first, second) + updatedLearning := UpdateLearningsIndex(learningContent) + if !strings.Contains(updatedLearning, config.IndexStart) { + t.Error("UpdateLearningsIndex() missing INDEX:START marker") } -} - -func TestGenerateIndexTable(t *testing.T) { - entries := []IndexEntry{ - {Timestamp: "2026-01-28-051426", Date: "2026-01-28", Title: "Test entry"}, - } - - // Test with different column headers - decisionTable := GenerateIndexTable(entries, "Decision") - if !strings.Contains(decisionTable, "| Date | Decision |") { - t.Error("Decision table should have 'Decision' column header") - } - - learningTable := GenerateIndexTable(entries, "Learning") - if !strings.Contains(learningTable, "| Date | Learning |") { - t.Error("Learning table should have 'Learning' column header") + if !strings.Contains(updatedLearning, "| Date | Learning |") { + t.Error("UpdateLearningsIndex() missing Learning column header") } } diff --git a/internal/cli/add/insert.go b/internal/cli/add/insert.go new file mode 100644 index 000000000..3f46c84bb --- /dev/null +++ b/internal/cli/add/insert.go @@ -0,0 +1,178 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package add + +import ( + "strings" + + "github.com/ActiveMemory/ctx/internal/config" +) + +// insertAfterHeader finds a header line and inserts content after it. +// +// Skips blank lines and ctx markers between the header and insertion point. +// Falls back to appending at the end if the header is not found. +// +// Parameters: +// - content: Existing file content +// - entry: Formatted entry to insert +// - header: Header line to find (e.g., "# Learnings") +// +// Returns: +// - []byte: Modified content with entry inserted +func insertAfterHeader(content, entry, header string) []byte { + hasHeader, idx := contains(content, header) + if !hasHeader { + return appendAtEnd(content, entry) + } + + hasNewLine, lineEnd := containsNewLine(content[idx:]) + if !hasNewLine { + // Header exists but no newline after (the file ends with a header line) + return appendAtEnd(content, entry) + } + + insertPoint := idx + lineEnd + insertPoint = skipNewline(content, insertPoint) + + // Skip blank lines and ctx markers + for insertPoint < len(content) { + if n := skipNewline(content, insertPoint); n > insertPoint { + insertPoint = n + continue + } + + // No context marker: we found the insertion point. + if !startsWithCtxMarker(content[insertPoint:]) { + break + } + + // Skip past the closing marker + hasCommentEnd, endIdx := containsEndComment(content[insertPoint:]) + if !hasCommentEnd { + break + } + + insertPoint += endIdx + len(config.CommentClose) + insertPoint = skipWhitespace(content, insertPoint) + } + + return []byte(content[:insertPoint] + entry) +} + +// appendAtEnd appends an entry at the end of content. +// +// Ensures proper newline separation between existing content and the new entry. +// +// Parameters: +// - content: Existing file content +// - entry: Formatted entry to append +// +// Returns: +// - []byte: Content with entry appended +func appendAtEnd(content, entry string) []byte { + if !endsWithNewline(content) { + content += config.NewlineLF + } + return []byte(content + config.NewlineLF + entry) +} + +// insertTask inserts a task entry after a section header in TASKS.md. +// +// Finds the target section (e.g., "## Next Up") and inserts the task +// immediately after the header line. Falls back to appending at the end +// if the section is not found. +// +// Parameters: +// - existingStr: Existing file content +// - entry: Formatted task entry to insert +// - headerSection: Target section name (e.g., "next", "backlog") +// +// Returns: +// - []byte: Modified content with task inserted +func insertTask(existingStr, entry, headerSection string) []byte { + targetSectionHeader := normalizeTargetSection(headerSection) + + // Find the section and insert after it + containsSectionHeader, idx := contains(existingStr, targetSectionHeader) + if !containsSectionHeader { + // Section not found: Append at the end. + if !endsWithNewline(existingStr) { + existingStr += config.NewlineLF + } + return []byte(existingStr + config.NewlineLF + entry) + } + + // Found section header. Find the end of the section header line + hasNewLine, lineEnd := containsNewLine(existingStr[idx:]) + if hasNewLine { + insertPoint := idx + lineEnd + insertPoint = skipNewline(existingStr, insertPoint) + return []byte(existingStr[:insertPoint] + config.NewlineLF + + entry + existingStr[insertPoint:]) + } + + // If no newline; append to the end followed by a newline. + return []byte(existingStr + config.NewlineLF + entry) +} + +// insertDecision inserts a decision entry before existing entries. +// +// Finds the first "## [" marker and inserts before it, maintaining +// reverse-chronological order. Falls back to insertAfterHeader if no entries +// exist. +// +// Parameters: +// - content: Existing file content +// - entry: Formatted entry to insert +// - header: Header line to insert after (e.g., "# Decisions") +// +// Returns: +// - []byte: Modified content with entry inserted +func insertDecision(content, entry, header string) []byte { + // Find the first entry marker "## [" (timestamp-prefixed sections) + entryIdx := strings.Index(content, "## [") + if entryIdx != -1 { + // Insert before the first entry, with a separator after + return []byte( + content[:entryIdx] + entry + + config.NewlineLF + config.Separator + + config.NewlineLF + config.NewlineLF + + content[entryIdx:], + ) + } + + // No existing entries - find the header and insert after it + return insertAfterHeader(content, entry, header) +} + +// insertLearning inserts a learning entry before existing entries. +// +// Finds the first "## [" marker and inserts before it, maintaining +// reverse-chronological order. Falls back to insertAfterHeader if no entries +// exist. +// +// Parameters: +// - content: Existing file content +// - entry: Formatted entry to insert +// +// Returns: +// - []byte: Modified content with entry inserted +func insertLearning(content, entry string) []byte { + // Find the first entry marker "## [" (timestamp-prefixed sections) + entryIdx := strings.Index(content, config.HeadingLearningStart) + if entryIdx != -1 { + return []byte( + content[:entryIdx] + entry + config.NewlineLF + + config.Separator + config.NewlineLF + config.NewlineLF + + content[entryIdx:], + ) + } + + // No existing entries - find the header and insert after it + return insertAfterHeader(content, entry, config.HeadingLearnings) +} diff --git a/internal/cli/add/normalize.go b/internal/cli/add/normalize.go new file mode 100644 index 000000000..5d81f344d --- /dev/null +++ b/internal/cli/add/normalize.go @@ -0,0 +1,14 @@ +package add + +import "strings" + +func normalizeTargetSection(section string) string { + targetSection := section + if targetSection == "" { + return "## Next Up" + } + if !strings.HasPrefix(targetSection, "##") { + return "## " + targetSection + } + return targetSection +} diff --git a/internal/cli/add/pos.go b/internal/cli/add/pos.go new file mode 100644 index 000000000..c7365edc5 --- /dev/null +++ b/internal/cli/add/pos.go @@ -0,0 +1,62 @@ +package add + +// skipNewline advances pos past a newline (CRLF or LF) if present. +// +// Parameters: +// - s: String to scan +// - pos: Current position in s +// +// Returns: +// - int: New position (unchanged if no newline at pos) +func skipNewline(s string, pos int) int { + if pos >= len(s) { + return pos + } + if pos+1 < len(s) && s[pos] == '\r' && s[pos+1] == '\n' { + return pos + 2 + } + if s[pos] == '\n' { + return pos + 1 + } + return pos +} + +// skipWhitespace advances pos past any whitespace (space, tab, newline). +// +// Parameters: +// - s: String to scan +// - pos: Current position in s +// +// Returns: +// - int: New position after skipping whitespace +func skipWhitespace(s string, pos int) int { + for pos < len(s) { + if n := skipNewline(s, pos); n > pos { + pos = n + } else if s[pos] == ' ' || s[pos] == '\t' { + pos++ + } else { + break + } + } + return pos +} + +// findNewline returns the index of the first newline (CRLF or LF) in s. +// +// Parameters: +// - s: String to search +// +// Returns: +// - int: Index of first newline (-1 if not found) +func findNewline(s string) int { + for i := 0; i < len(s); i++ { + if i+1 < len(s) && s[i] == '\r' && s[i+1] == '\n' { + return i + } + if s[i] == '\n' { + return i + } + } + return -1 +} diff --git a/internal/cli/add/predicate.go b/internal/cli/add/predicate.go new file mode 100644 index 000000000..9ea33bde1 --- /dev/null +++ b/internal/cli/add/predicate.go @@ -0,0 +1,42 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package add + +import "github.com/ActiveMemory/ctx/internal/config" + +// fileTypeIsTask reports whether fileType represents a task entry. +// +// Parameters: +// - fileType: The type string to check (e.g., "task", "tasks") +// +// Returns: +// - bool: True if fileType is a task type +func fileTypeIsTask(fileType string) bool { + return config.UserInputToEntry(fileType) == config.EntryTask +} + +// fileTypeIsDecision reports whether fileType represents a decision entry. +// +// Parameters: +// - fileType: The type string to check (e.g., "decision", "decisions") +// +// Returns: +// - bool: True if fileType is a decision type +func fileTypeIsDecision(fileType string) bool { + return config.UserInputToEntry(fileType) == config.EntryDecision +} + +// fileTypeIsLearning reports whether fileType represents a learning entry. +// +// Parameters: +// - fileType: The type string to check (e.g., "learning", "learnings") +// +// Returns: +// - bool: True if fileType is a learning type +func fileTypeIsLearning(fileType string) bool { + return config.UserInputToEntry(fileType) == config.EntryLearning +} diff --git a/internal/cli/add/run.go b/internal/cli/add/run.go index e159e6e80..cc8fcbdd3 100644 --- a/internal/cli/add/run.go +++ b/internal/cli/add/run.go @@ -7,7 +7,6 @@ package add import ( - "bufio" "fmt" "os" "path/filepath" @@ -17,6 +16,8 @@ import ( "github.com/spf13/cobra" "github.com/ActiveMemory/ctx/internal/config" + "github.com/ActiveMemory/ctx/internal/index" + "github.com/ActiveMemory/ctx/internal/rc" ) // EntryParams contains all parameters needed to add an entry to a context file. @@ -43,7 +44,8 @@ type EntryParams struct { Application string } -// ValidateEntry checks that required fields are present for the given entry type. +// ValidateEntry checks that required fields are present for the given +// entry type. // // Parameters: // - params: Entry parameters to validate @@ -51,41 +53,43 @@ type EntryParams struct { // Returns: // - error: Non-nil with details about missing fields, nil if valid func ValidateEntry(params EntryParams) error { - fType := strings.ToLower(params.Type) - if params.Content == "" { return fmt.Errorf("no content provided") } - switch fType { - case config.UpdateTypeDecision, config.UpdateTypeDecisions: + switch config.UserInputToEntry(params.Type) { + case config.EntryDecision: var missing []string if params.Context == "" { - missing = append(missing, "context") + missing = append(missing, config.FieldContext) } if params.Rationale == "" { - missing = append(missing, "rationale") + missing = append(missing, config.FieldRationale) } if params.Consequences == "" { - missing = append(missing, "consequences") + missing = append(missing, config.FieldConsequence) } if len(missing) > 0 { - return fmt.Errorf("decision missing required fields: %s", strings.Join(missing, ", ")) + return fmt.Errorf( + "decision missing required fields: %s", strings.Join(missing, ", "), + ) } - case config.UpdateTypeLearning, config.UpdateTypeLearnings: + case config.EntryLearning: var missing []string if params.Context == "" { - missing = append(missing, "context") + missing = append(missing, config.FieldContext) } if params.Lesson == "" { - missing = append(missing, "lesson") + missing = append(missing, config.FieldLesson) } if params.Application == "" { - missing = append(missing, "application") + missing = append(missing, config.FieldApplication) } if len(missing) > 0 { - return fmt.Errorf("learning missing required fields: %s", strings.Join(missing, ", ")) + return fmt.Errorf( + "learning missing required fields: %s", strings.Join(missing, ", "), + ) } } @@ -101,7 +105,7 @@ func ValidateEntry(params EntryParams) error { // - params: EntryParams containing type, content, and optional fields // // Returns: -// - error: Non-nil if type is unknown, file doesn't exist, or write fails +// - error: Non-nil if type is unknown, the file doesn't exist, or write fails func WriteEntry(params EntryParams) error { fType := strings.ToLower(params.Type) @@ -110,11 +114,13 @@ func WriteEntry(params EntryParams) error { return fmt.Errorf("unknown type %q", fType) } - filePath := filepath.Join(config.ContextDir(), fileName) + filePath := filepath.Join(rc.GetContextDir(), fileName) - // Check if file exists + // Check if the file exists if _, err := os.Stat(filePath); os.IsNotExist(err) { - return fmt.Errorf("context file %s not found. Run 'ctx init' first", filePath) + return fmt.Errorf( + "context file %s not found. Run 'ctx init' first", filePath, + ) } // Read existing content @@ -125,14 +131,18 @@ func WriteEntry(params EntryParams) error { // Format the entry var entry string - switch fType { - case config.UpdateTypeDecision, config.UpdateTypeDecisions: - entry = FormatDecision(params.Content, params.Context, params.Rationale, params.Consequences) - case config.UpdateTypeTask, config.UpdateTypeTasks: + switch config.UserInputToEntry(fType) { + case config.EntryDecision: + entry = FormatDecision( + params.Content, params.Context, params.Rationale, params.Consequences, + ) + case config.EntryTask: entry = FormatTask(params.Content, params.Priority) - case config.UpdateTypeLearning, config.UpdateTypeLearnings: - entry = FormatLearning(params.Content, params.Context, params.Lesson, params.Application) - case config.UpdateTypeConvention, config.UpdateTypeConventions: + case config.EntryLearning: + entry = FormatLearning( + params.Content, params.Context, params.Lesson, params.Application, + ) + case config.EntryConvention: entry = FormatConvention(params.Content) default: return fmt.Errorf("unknown type %q", fType) @@ -145,18 +155,20 @@ func WriteEntry(params EntryParams) error { return fmt.Errorf("failed to write %s: %w", filePath, err) } - // Update index for decisions and learnings - switch fType { - case config.UpdateTypeDecision, config.UpdateTypeDecisions: - indexed := UpdateIndex(string(newContent)) + // Update index for decisions and learnings (tasks/conventions don't have indexes) + switch config.UserInputToEntry(fType) { + case config.EntryDecision: + indexed := index.UpdateDecisions(string(newContent)) if err := os.WriteFile(filePath, []byte(indexed), 0644); err != nil { return fmt.Errorf("failed to update index in %s: %w", filePath, err) } - case config.UpdateTypeLearning, config.UpdateTypeLearnings: - indexed := UpdateLearningsIndex(string(newContent)) + case config.EntryLearning: + indexed := index.UpdateLearnings(string(newContent)) if err := os.WriteFile(filePath, []byte(indexed), 0644); err != nil { return fmt.Errorf("failed to update index in %s: %w", filePath, err) } + case config.EntryTask, config.EntryConvention: + // No index to update for these types } return nil @@ -176,50 +188,14 @@ func WriteEntry(params EntryParams) error { // Returns: // - error: Non-nil if content is missing, type is invalid, required flags // are missing, or file operations fail -func runAdd(cmd *cobra.Command, args []string, flags addFlags) error { +func runAdd(cmd *cobra.Command, args []string, flags addConfig) error { fType := strings.ToLower(args[0]) // Determine the content source: args, --file, or stdin - var content string - - if flags.fromFile != "" { - // Read from the file - fileContent, err := os.ReadFile(flags.fromFile) - if err != nil { - return fmt.Errorf("failed to read file %s: %w", flags.fromFile, err) - } - content = strings.TrimSpace(string(fileContent)) - } else if len(args) > 1 { - // Content from arguments - content = strings.Join(args[1:], " ") - } else { - // Try reading from stdin (check if it's a pipe) - stat, _ := os.Stdin.Stat() - if (stat.Mode() & os.ModeCharDevice) == 0 { - // stdin is a pipe, read from it - scanner := bufio.NewScanner(os.Stdin) - var lines []string - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - if err := scanner.Err(); err != nil { - return fmt.Errorf("failed to read from stdin: %w", err) - } - content = strings.TrimSpace(strings.Join(lines, "\n")) - } - } + content, err := extractContent(args, flags) - if content == "" { - examples := getExamplesForType(fType) - return fmt.Errorf(`no content provided - -Usage: - ctx add %s "your content here" - ctx add %s --file /path/to/content.md - echo "content" | ctx add %s - -Examples: -%s`, fType, fType, fType, examples) + if err != nil || content == "" { + return errNoContentProvided(fType) } // Build entry params @@ -236,7 +212,8 @@ Examples: } // Validate required fields with CLI-friendly error messages - if fType == config.UpdateTypeDecision || fType == config.UpdateTypeDecisions { + switch config.UserInputToEntry(fType) { + case config.EntryDecision: var missing []string if flags.context == "" { missing = append(missing, "--context") @@ -248,26 +225,9 @@ Examples: missing = append(missing, "--consequences") } if len(missing) > 0 { - return fmt.Errorf(`decisions require complete ADR format - -Missing required flags: %s - -Usage: - ctx add decision "Decision title" \ - --context "What prompted this decision" \ - --rationale "Why this choice over alternatives" \ - --consequences "What changes as a result" - -Example: - ctx add decision "Use PostgreSQL for primary database" \ - --context "Need a reliable database for production workloads" \ - --rationale "PostgreSQL offers ACID compliance, JSON support, and team familiarity" \ - --consequences "Team needs PostgreSQL training; must set up replication"`, - strings.Join(missing, ", ")) + return errMissingDecision(missing) } - } - - if fType == config.UpdateTypeLearning || fType == config.UpdateTypeLearnings { + case config.EntryLearning: var missing []string if flags.context == "" { missing = append(missing, "--context") @@ -279,22 +239,7 @@ Example: missing = append(missing, "--application") } if len(missing) > 0 { - return fmt.Errorf(`learnings require complete format - -Missing required flags: %s - -Usage: - ctx add learning "Learning title" \ - --context "What prompted this learning" \ - --lesson "The key insight" \ - --application "How to apply this going forward" - -Example: - ctx add learning "Go embed requires files in same package" \ - --context "Tried to embed files from parent directory, got compile error" \ - --lesson "go:embed only works with files in same or child directories" \ - --application "Keep embedded files in internal/templates/, not project root"`, - strings.Join(missing, ", ")) + return errMissingLearning(missing) } } @@ -307,7 +252,7 @@ Example: ) } - // Write the entry using shared function + // Write the entry using the shared function if err := WriteEntry(params); err != nil { return err } diff --git a/internal/cli/add/strings.go b/internal/cli/add/strings.go new file mode 100644 index 000000000..21b6931c7 --- /dev/null +++ b/internal/cli/add/strings.go @@ -0,0 +1,78 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package add + +import ( + "strings" + + "github.com/ActiveMemory/ctx/internal/config" +) + +// endsWithNewline reports whether s ends with a newline (CRLF or LF). +// +// Parameters: +// - s: String to check +// +// Returns: +// - bool: True if s ends with a newline +func endsWithNewline(s string) bool { + return strings.HasSuffix(s, config.NewlineCRLF) || + strings.HasSuffix(s, config.NewlineLF) +} + +// contains reports whether content contains the header and returns its index. +// +// Parameters: +// - content: String to search in +// - header: Substring to find +// +// Returns: +// - bool: True if header is found +// - int: Index of header (-1 if not found) +func contains(content, header string) (bool, int) { + idx := strings.Index(content, header) + return idx != -1, idx +} + +// containsNewLine reports whether content contains a newline and +// returns its index. +// +// Parameters: +// - content: String to search in +// +// Returns: +// - bool: True if a newline is found +// - int: Index of newline (-1 if not found) +func containsNewLine(content string) (bool, int) { + lineEnd := findNewline(content) + return lineEnd != -1, lineEnd +} + +// containsEndComment reports whether content contains a comment close marker. +// +// Parameters: +// - content: String to search in +// +// Returns: +// - bool: True if comment close marker is found +// - int: Index of marker (-1 if not found) +func containsEndComment(content string) (bool, int) { + commentEnd := strings.Index(content, config.CommentClose) + return commentEnd != -1, commentEnd +} + +// startsWithCtxMarker reports whether s starts with a ctx marker comment. +// +// Parameters: +// - s: String to check +// +// Returns: +// - bool: True if s starts with CtxMarkerStart or CtxMarkerEnd +func startsWithCtxMarker(s string) bool { + return strings.HasPrefix(s, config.CtxMarkerStart) || + strings.HasPrefix(s, config.CtxMarkerEnd) +} diff --git a/internal/cli/agent/agent.go b/internal/cli/agent/agent.go index 508914e67..cb919eaad 100644 --- a/internal/cli/agent/agent.go +++ b/internal/cli/agent/agent.go @@ -9,7 +9,7 @@ package agent import ( "github.com/spf13/cobra" - "github.com/ActiveMemory/ctx/internal/config" + "github.com/ActiveMemory/ctx/internal/rc" ) // Cmd returns the "ctx agent" command for generating AI-ready context packets. @@ -53,13 +53,13 @@ Examples: RunE: func(cmd *cobra.Command, args []string) error { // Use configured budget if flag not explicitly set if !cmd.Flags().Changed("budget") { - budget = config.GetTokenBudget() + budget = rc.GetTokenBudget() } return runAgent(cmd, budget, format) }, } - cmd.Flags().IntVar(&budget, "budget", config.DefaultTokenBudget, "Token budget for context packet") + cmd.Flags().IntVar(&budget, "budget", rc.DefaultTokenBudget, "Token budget for context packet") cmd.Flags().StringVar(&format, "format", "md", "Output format: md or json") return cmd diff --git a/internal/cli/agent/extract.go b/internal/cli/agent/extract.go index 00a6d7c77..24c33f2bd 100644 --- a/internal/cli/agent/extract.go +++ b/internal/cli/agent/extract.go @@ -7,147 +7,125 @@ package agent import ( - "regexp" "strings" "github.com/ActiveMemory/ctx/internal/config" "github.com/ActiveMemory/ctx/internal/context" + "github.com/ActiveMemory/ctx/internal/task" ) -// extractConstitutionRules extracts checkbox items from CONSTITUTION.md. -// -// Parameters: -// - ctx: Loaded context containing the files +// extractBulletItems extracts Markdown bullet items up to a limit. // -// Returns: -// - []string: List of constitution rules; nil if the file is not found -func extractConstitutionRules(ctx *context.Context) []string { - for _, f := range ctx.Files { - if f.Name == config.FilenameConstitution { - return extractCheckboxItems(string(f.Content)) - } - } - return nil -} - -// extractActiveTasks extracts unchecked task items from TASKS.md. +// Skips empty items and lines starting with "#" (headers). // // Parameters: -// - ctx: Loaded context containing the files +// - content: Markdown content to parse +// - limit: Maximum number of items to return // // Returns: -// - []string: List of active tasks with "- [ ]" prefix; nil if -// the file is not found -func extractActiveTasks(ctx *context.Context) []string { - for _, f := range ctx.Files { - if f.Name == config.FilenameTask { - return extractUncheckedTasks(string(f.Content)) +// - []string: Bullet item text without the "- " prefix +func extractBulletItems(content string, limit int) []string { + matches := config.RegExBulletItem.FindAllStringSubmatch(content, -1) + items := make([]string, 0, limit) + for i, m := range matches { + if i >= limit { + break + } + text := strings.TrimSpace(m[1]) + // Skip empty or header-only items + if text != "" && !strings.HasPrefix(text, "#") { + items = append(items, text) } } - return nil + return items } -// extractConventions extracts bullet items from CONVENTIONS.md. +// extractCheckboxItems extracts text from Markdown checkbox items. +// +// Matches both checked "- [x]" and unchecked "- [ ]" items. // // Parameters: -// - ctx: Loaded context containing the files +// - content: Markdown content to parse // // Returns: -// - []string: Up to 5 convention items; nil if the file is not found -func extractConventions(ctx *context.Context) []string { - for _, f := range ctx.Files { - if f.Name == config.FilenameConvention { - return extractBulletItems(string(f.Content), 5) - } +// - []string: Text content of each checkbox item +func extractCheckboxItems(content string) []string { + matches := config.RegExTask.FindAllStringSubmatch(content, -1) + items := make([]string, 0, len(matches)) + for _, m := range matches { + items = append(items, strings.TrimSpace(task.Content(m))) } - return nil + return items } -// extractRecentDecisions extracts the most recent decision titles from -// DECISIONS.md. +// extractConstitutionRules extracts checkbox items from CONSTITUTION.md. // // Parameters: // - ctx: Loaded context containing the files -// - limit: Maximum number of decisions to return // // Returns: -// - []string: Decision titles (most recent last); nil if the file -// is not found -func extractRecentDecisions( - ctx *context.Context, limit int, -) []string { +// - []string: List of constitution rules; nil if the file is not found +func extractConstitutionRules(ctx *context.Context) []string { for _, f := range ctx.Files { - if f.Name == config.FilenameDecision { - return extractDecisionTitles(string(f.Content), limit) + if f.Name == config.FileConstitution { + return extractCheckboxItems(string(f.Content)) } } return nil } -// extractCheckboxItems extracts text from Markdown checkbox items. +// extractUncheckedTasks extracts unchecked Markdown checkbox items. // -// Matches both checked "- [x]" and unchecked "- [ ]" items. +// Only matches "- [ ]" items (not checked). Returns items with the +// "- [ ]" prefix preserved for display. // // Parameters: // - content: Markdown content to parse // // Returns: -// - []string: Text content of each checkbox item -func extractCheckboxItems(content string) []string { - re := regexp.MustCompile(`(?m)^-\s*\[[ x]]\s*(.+)$`) - matches := re.FindAllStringSubmatch(content, -1) +// - []string: Unchecked task items with "- [ ]" prefix +func extractUncheckedTasks(content string) []string { + matches := config.RegExTaskMultiline.FindAllStringSubmatch(content, -1) items := make([]string, 0, len(matches)) for _, m := range matches { - items = append(items, strings.TrimSpace(m[1])) + if task.IsPending(m) { + items = append(items, "- [ ] "+strings.TrimSpace(task.Content(m))) + } } return items } -// extractUncheckedTasks extracts unchecked Markdown checkbox items. -// -// Only matches "- [ ]" items (not checked). Returns items with the -// "- [ ]" prefix preserved for display. +// extractActiveTasks extracts unchecked task items from TASKS.md. // // Parameters: -// - content: Markdown content to parse +// - ctx: Loaded context containing the files // // Returns: -// - []string: Unchecked task items with "- [ ]" prefix -func extractUncheckedTasks(content string) []string { - re := regexp.MustCompile(`(?m)^-\s*\[\s*]\s*(.+)$`) - matches := re.FindAllStringSubmatch(content, -1) - items := make([]string, 0, len(matches)) - for _, m := range matches { - items = append(items, "- [ ] "+strings.TrimSpace(m[1])) +// - []string: List of active tasks with "- [ ]" prefix; nil if +// the file is not found +func extractActiveTasks(ctx *context.Context) []string { + for _, f := range ctx.Files { + if f.Name == config.FileTask { + return extractUncheckedTasks(string(f.Content)) + } } - return items + return nil } -// extractBulletItems extracts Markdown bullet items up to a limit. -// -// Skips empty items and lines starting with "#" (headers). +// extractConventions extracts bullet items from CONVENTIONS.md. // // Parameters: -// - content: Markdown content to parse -// - limit: Maximum number of items to return +// - ctx: Loaded context containing the files // // Returns: -// - []string: Bullet item text without the "- " prefix -func extractBulletItems(content string, limit int) []string { - re := regexp.MustCompile(`(?m)^-\s+(.+)$`) - matches := re.FindAllStringSubmatch(content, -1) - items := make([]string, 0, limit) - for i, m := range matches { - if i >= limit { - break - } - text := strings.TrimSpace(m[1]) - // Skip empty or header-only items - if text != "" && !strings.HasPrefix(text, "#") { - items = append(items, text) +// - []string: Up to 5 convention items; nil if the file is not found +func extractConventions(ctx *context.Context) []string { + for _, f := range ctx.Files { + if f.Name == config.FileConvention { + return extractBulletItems(string(f.Content), 5) } } - return items + return nil } // extractDecisionTitles extracts decision titles from Markdown headings. @@ -162,8 +140,7 @@ func extractBulletItems(content string, limit int) []string { // Returns: // - []string: Decision titles without a timestamp prefix func extractDecisionTitles(content string, limit int) []string { - re := regexp.MustCompile(`(?m)^##\s+\[[\d-]+]\s*(.+)$`) - matches := re.FindAllStringSubmatch(content, -1) + matches := config.RegExEntryHeader.FindAllStringSubmatch(content, -1) items := make([]string, 0, limit) // Get the most recent (last) decisions start := len(matches) - limit @@ -171,7 +148,29 @@ func extractDecisionTitles(content string, limit int) []string { start = 0 } for i := start; i < len(matches); i++ { - items = append(items, strings.TrimSpace(matches[i][1])) + // Group 3 is the title (groups: 1=date, 2=time, 3=title) + items = append(items, strings.TrimSpace(matches[i][3])) } return items } + +// extractRecentDecisions extracts the most recent decision titles from +// DECISIONS.md. +// +// Parameters: +// - ctx: Loaded context containing the files +// - limit: Maximum number of decisions to return +// +// Returns: +// - []string: Decision titles (most recent last); nil if the file +// is not found +func extractRecentDecisions( + ctx *context.Context, limit int, +) []string { + for _, f := range ctx.Files { + if f.Name == config.FileDecision { + return extractDecisionTitles(string(f.Content), limit) + } + } + return nil +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 6a4560a26..29123f903 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -7,6 +7,9 @@ package cli import ( + "go/ast" + "go/parser" + "go/token" "os" "os/exec" "path/filepath" @@ -210,3 +213,128 @@ func TestBinaryIntegration(t *testing.T) { } }) } + +// TestNoDirectFmtPrint ensures CLI code uses cmd.Print* instead of fmt.Print*. +// +// In Cobra commands, output should go through cmd.OutOrStdout() so that: +// - Tests can capture output +// - --quiet flags work correctly +// - Output can be redirected properly +// +// This test parses all non-test Go files in internal/cli and fails if any +// function that receives a *cobra.Command uses fmt.Print* directly. +func TestNoDirectFmtPrint(t *testing.T) { + cliDir := "." + + // Forbidden fmt functions that should use cmd.Print* instead + forbidden := map[string]bool{ + "Print": true, + "Println": true, + "Printf": true, + } + + var violations []string + + err := filepath.Walk(cliDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip non-Go files and test files + if info.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return nil + } + + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, path, nil, parser.ParseComments) + if err != nil { + t.Errorf("failed to parse %s: %v", path, err) + return nil + } + + // Track if this file imports "fmt" + var fmtAlias string + for _, imp := range node.Imports { + impPath := strings.Trim(imp.Path.Value, `"`) + if impPath == "fmt" { + if imp.Name != nil { + fmtAlias = imp.Name.Name + } else { + fmtAlias = "fmt" + } + break + } + } + + // No fmt import, nothing to check + if fmtAlias == "" { + return nil + } + + // Find functions that have a *cobra.Command parameter + for _, decl := range node.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Type.Params == nil { + continue + } + + // Check if this function has a *cobra.Command parameter + hasCobraCmd := false + for _, param := range fn.Type.Params.List { + if starExpr, ok := param.Type.(*ast.StarExpr); ok { + if sel, ok := starExpr.X.(*ast.SelectorExpr); ok { + if ident, ok := sel.X.(*ast.Ident); ok { + if ident.Name == "cobra" && sel.Sel.Name == "Command" { + hasCobraCmd = true + break + } + } + } + } + } + + if !hasCobraCmd { + continue + } + + // This function has *cobra.Command - check for fmt.Print* calls + ast.Inspect(fn.Body, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return true + } + + ident, ok := sel.X.(*ast.Ident) + if !ok { + return true + } + + if ident.Name == fmtAlias && forbidden[sel.Sel.Name] { + pos := fset.Position(call.Pos()) + violations = append(violations, + filepath.Join(path)+":"+ + strings.TrimPrefix(pos.String(), pos.Filename+":")+ + " in "+fn.Name.Name+"()") + } + + return true + }) + } + + return nil + }) + + if err != nil { + t.Fatalf("failed to walk directory: %v", err) + } + + if len(violations) > 0 { + t.Errorf("found %d uses of fmt.Print* in functions with *cobra.Command (use cmd.Print* instead):\n %s", + len(violations), strings.Join(violations, "\n ")) + } +} diff --git a/internal/cli/compact/block.go b/internal/cli/compact/block.go index a047a32e4..c4f4e74ba 100644 --- a/internal/cli/compact/block.go +++ b/internal/cli/compact/block.go @@ -7,72 +7,20 @@ package compact import ( - "regexp" "strings" -) - -// TaskBlock represents a task and its nested content. -// -// Fields: -// - Lines: All lines in the block (parent + children) -// - StartIndex: Index of first line in original content -// - EndIndex: Index of last line (exclusive) -// - IsCompleted: Parent task is checked -// - IsArchivable: Completed and no unchecked children -type TaskBlock struct { - Lines []string - StartIndex int - EndIndex int - IsCompleted bool - IsArchivable bool -} + "time" -// Patterns for task detection -var ( - // Matches checked task: "- [x] content" or " - [x] content" - checkedTaskPattern = regexp.MustCompile(`^(\s*)-\s*\[x]\s*(.+)$`) - // Matches unchecked task: "- [ ] content" or " - [ ] content" - uncheckedTaskPattern = regexp.MustCompile(`^(\s*)-\s*\[\s*]\s*(.+)$`) - // Matches any task (checked or unchecked) - anyTaskPattern = regexp.MustCompile(`^(\s*)-\s*\[[x ]*]\s*(.+)$`) + "github.com/ActiveMemory/ctx/internal/config" + "github.com/ActiveMemory/ctx/internal/task" ) -// UncheckedTaskPattern returns the regex for matching unchecked tasks. -// -// Returns: -// - *regexp.Regexp: Pattern matching "- [ ] content" with optional indentation -func UncheckedTaskPattern() *regexp.Regexp { - return uncheckedTaskPattern -} - -// GetIndentLevel returns the number of leading whitespace characters. -// -// Parameters: -// - line: The line to measure -// -// Returns: -// - int: Number of leading whitespace characters (spaces and tabs) -func GetIndentLevel(line string) int { - return getIndentLevel(line) -} - -// getIndentLevel returns the number of leading whitespace characters. -// -// Parameters: -// - line: The line to measure -// -// Returns: -// - int: Number of leading whitespace characters (spaces and tabs) -func getIndentLevel(line string) int { - return len(line) - len(strings.TrimLeft(line, " \t")) -} - // ParseTaskBlocks parses content into task blocks, identifying completed tasks // with their nested content. // // A task block consists of: -// - A parent task line at the top level (no indentation, e.g., "- [x] Task title") -// - All following lines that are more indented than the parent +// - A parent task line at the top level (no indentation, e.g., +// "- [x] Task title") +// - All following lines that are more indented than the parent // // Only top-level tasks (indent=0) are considered for archiving. Nested subtasks // are part of their parent block and are not collected as independent blocks. @@ -85,7 +33,8 @@ func getIndentLevel(line string) int { // - lines: Slice of lines from the tasks file // // Returns: -// - []TaskBlock: All completed top-level task blocks found (outside Completed section) +// - []TaskBlock: All completed top-level task blocks found +// (outside the Completed section) func ParseTaskBlocks(lines []string) []TaskBlock { var blocks []TaskBlock inCompletedSection := false @@ -104,15 +53,16 @@ func ParseTaskBlocks(lines []string) []TaskBlock { inCompletedSection = false } - // Skip if in Completed section or not a checked task - if inCompletedSection || !checkedTaskPattern.MatchString(line) { + // Skip if in the Completed section or not a checked task + match := config.RegExTask.FindStringSubmatch(line) + if inCompletedSection || match == nil || !task.Completed(match) { i++ continue } // Only consider top-level tasks (no indentation) for archiving // Nested subtasks are part of their parent block, not independent - if getIndentLevel(line) > 0 { + if indentLevel(line) > 0 { i++ continue } @@ -128,92 +78,25 @@ func ParseTaskBlocks(lines []string) []TaskBlock { return blocks } -// parseBlockAt parses a task block starting at the given index. -// -// Parameters: -// - lines: All lines from the file -// - startIdx: Index of the parent task line -// -// Returns: -// - TaskBlock: Parsed block with all nested content -func parseBlockAt(lines []string, startIdx int) TaskBlock { - parentLine := lines[startIdx] - parentIndent := getIndentLevel(parentLine) - - block := TaskBlock{ - Lines: []string{parentLine}, - StartIndex: startIdx, - EndIndex: startIdx + 1, - IsCompleted: true, // We only call this for checked tasks - IsArchivable: true, - } - - // Collect all lines that are more indented than the parent - for i := startIdx + 1; i < len(lines); i++ { - line := lines[i] - - // Empty lines: include if followed by more indented content - if strings.TrimSpace(line) == "" { - // Look ahead to see if there's more indented content - hasMoreContent := false - for j := i + 1; j < len(lines); j++ { - nextLine := lines[j] - if strings.TrimSpace(nextLine) == "" { - continue - } - if getIndentLevel(nextLine) > parentIndent { - hasMoreContent = true - } - break - } - if hasMoreContent { - block.Lines = append(block.Lines, line) - block.EndIndex = i + 1 - continue - } - // No more indented content, stop here - break - } - - // Check indentation - lineIndent := getIndentLevel(line) - if lineIndent <= parentIndent { - // Same or lower indentation - end of block - break - } - - // This line belongs to the block - block.Lines = append(block.Lines, line) - block.EndIndex = i + 1 - - // Check if this is an unchecked task - if uncheckedTaskPattern.MatchString(line) { - block.IsArchivable = false - } - } - - return block -} - // BlockContent returns the full content of a block as a single string. // // Returns: // - string: All lines joined with newlines func (b *TaskBlock) BlockContent() string { - return strings.Join(b.Lines, "\n") + return strings.Join(b.Lines, config.NewlineLF) } // ParentTaskText extracts just the task text from the parent line. // // Returns: -// - string: Task text without checkbox prefix, empty if no lines +// - string: Task text without the checkbox prefix, empty if no lines func (b *TaskBlock) ParentTaskText() string { if len(b.Lines) == 0 { return "" } - matches := checkedTaskPattern.FindStringSubmatch(b.Lines[0]) - if len(matches) > 2 { - return matches[2] + match := config.RegExTask.FindStringSubmatch(b.Lines[0]) + if match != nil { + return task.Content(match) } return "" } @@ -238,7 +121,8 @@ func RemoveBlocksFromLines(lines []string, blocks []TaskBlock) []string { for i := 0; i < len(lines); i++ { // Check if this line is part of a block to remove - if blockIdx < len(blocks) && i >= blocks[blockIdx].StartIndex && i < blocks[blockIdx].EndIndex { + if blockIdx < len(blocks) && + i >= blocks[blockIdx].StartIndex && i < blocks[blockIdx].EndIndex { // Skip this line if i == blocks[blockIdx].EndIndex-1 { blockIdx++ @@ -250,3 +134,18 @@ func RemoveBlocksFromLines(lines []string, blocks []TaskBlock) []string { return result } + +// OlderThan checks if the task was completed more than the specified days ago. +// +// Parameters: +// - days: Number of days threshold +// +// Returns: +// - bool: True if DoneTime is set and older than days ago, false otherwise +func (b *TaskBlock) OlderThan(days int) bool { + if b.DoneTime == nil { + return false + } + threshold := time.Now().AddDate(0, 0, -days) + return b.DoneTime.Before(threshold) +} diff --git a/internal/cli/compact/block_test.go b/internal/cli/compact/block_test.go index d2851a4dc..9f0a476e9 100644 --- a/internal/cli/compact/block_test.go +++ b/internal/cli/compact/block_test.go @@ -9,6 +9,7 @@ package compact import ( "strings" "testing" + "time" ) func TestGetIndentLevel(t *testing.T) { @@ -26,9 +27,9 @@ func TestGetIndentLevel(t *testing.T) { for _, tt := range tests { t.Run(tt.line, func(t *testing.T) { - got := getIndentLevel(tt.line) + got := indentLevel(tt.line) if got != tt.expected { - t.Errorf("getIndentLevel(%q) = %d, want %d", tt.line, got, tt.expected) + t.Errorf("indentLevel(%q) = %d, want %d", tt.line, got, tt.expected) } }) } @@ -427,3 +428,104 @@ func TestParseTaskBlocks_IncompleteDeepChild(t *testing.T) { t.Error("task with incomplete deep child should NOT be archivable") } } + +func TestParseDoneTimestamp(t *testing.T) { + tests := []struct { + name string + line string + wantNil bool + wantYear int + }{ + { + name: "no timestamp", + line: "- [x] Simple completed task", + wantNil: true, + }, + { + name: "with done timestamp", + line: "- [x] Task with timestamp #added:2026-01-15-100000 #done:2026-01-20-143000", + wantNil: false, + wantYear: 2026, + }, + { + name: "only done timestamp", + line: "- [x] Task #done:2025-12-25-120000", + wantNil: false, + wantYear: 2025, + }, + { + name: "invalid timestamp format", + line: "- [x] Task #done:2026-01-20", + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseDoneTimestamp(tt.line) + if tt.wantNil { + if result != nil { + t.Errorf("expected nil, got %v", result) + } + } else { + if result == nil { + t.Error("expected non-nil time") + } else if result.Year() != tt.wantYear { + t.Errorf("year = %d, want %d", result.Year(), tt.wantYear) + } + } + }) + } +} + +func TestTaskBlockIsOlderThan(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + doneTime *time.Time + days int + want bool + }{ + { + name: "nil done time", + doneTime: nil, + days: 7, + want: false, + }, + { + name: "completed today", + doneTime: func() *time.Time { t := now; return &t }(), + days: 7, + want: false, + }, + { + name: "completed 10 days ago", + doneTime: func() *time.Time { t := now.AddDate(0, 0, -10); return &t }(), + days: 7, + want: true, + }, + { + name: "completed exactly 7 days ago", + doneTime: func() *time.Time { t := now.AddDate(0, 0, -7).Add(-time.Hour); return &t }(), + days: 7, + want: true, + }, + { + name: "completed 5 days ago with 7 day threshold", + doneTime: func() *time.Time { t := now.AddDate(0, 0, -5); return &t }(), + days: 7, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + block := TaskBlock{DoneTime: tt.doneTime} + got := block.OlderThan(tt.days) + if got != tt.want { + t.Errorf("OlderThan(%d) = %v, want %v", tt.days, got, tt.want) + } + }) + } +} diff --git a/internal/cli/compact/parse.go b/internal/cli/compact/parse.go new file mode 100644 index 000000000..539ce694e --- /dev/null +++ b/internal/cli/compact/parse.go @@ -0,0 +1,116 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package compact + +import ( + "strings" + "time" + + "github.com/ActiveMemory/ctx/internal/config" + "github.com/ActiveMemory/ctx/internal/task" +) + +// indentLevel returns the number of leading whitespace characters. +// +// Parameters: +// - line: The line to measure +// +// Returns: +// - int: Number of leading whitespace characters (spaces and tabs) +func indentLevel(line string) int { + return len(line) - len(strings.TrimLeft(line, " \t")) +} + +// parseBlockAt parses a task block starting at the given index. +// +// Parameters: +// - lines: All lines from the file +// - startIdx: Index of the parent task line +// +// Returns: +// - TaskBlock: Parsed block with all nested content +func parseBlockAt(lines []string, startIdx int) TaskBlock { + parentLine := lines[startIdx] + parentIndent := indentLevel(parentLine) + + block := TaskBlock{ + Lines: []string{parentLine}, + StartIndex: startIdx, + EndIndex: startIdx + 1, + IsCompleted: true, // We only call this for checked tasks + IsArchivable: true, + DoneTime: parseDoneTimestamp(parentLine), + } + + // Collect all lines that are more indented than the parent + for i := startIdx + 1; i < len(lines); i++ { + line := lines[i] + + // Empty lines: include if followed by more indented content + if strings.TrimSpace(line) == "" { + // Look ahead to see if there's more indented content + hasMoreContent := false + for j := i + 1; j < len(lines); j++ { + nextLine := lines[j] + if strings.TrimSpace(nextLine) == "" { + continue + } + if indentLevel(nextLine) > parentIndent { + hasMoreContent = true + } + break + } + if hasMoreContent { + block.Lines = append(block.Lines, line) + block.EndIndex = i + 1 + continue + } + // No more indented content, stop here + break + } + + // Check indentation + lineIndent := indentLevel(line) + if lineIndent <= parentIndent { + // Same or lower indentation - end of block + break + } + + // This line belongs to the block + block.Lines = append(block.Lines, line) + block.EndIndex = i + 1 + + // Check if this is an unchecked task + nestedMatch := config.RegExTask.FindStringSubmatch(line) + if nestedMatch != nil && task.IsPending(nestedMatch) { + block.IsArchivable = false + } + } + + return block +} + +// parseDoneTimestamp extracts the #done: timestamp from a task line. +// +// Parameters: +// - line: Task line that may contain #done:YYYY-MM-DD-HHMMSS +// +// Returns: +// - *time.Time: Parsed time, or nil if no valid timestamp found +func parseDoneTimestamp(line string) *time.Time { + match := config.RegExTaskDoneTimestamp.FindStringSubmatch(line) + if match == nil || len(match) < 2 { + return nil + } + + // Parse YYYY-MM-DD-HHMMSS format + t, err := time.Parse("2006-01-02-150405", match[1]) + if err != nil { + return nil + } + return &t +} diff --git a/internal/cli/compact/process.go b/internal/cli/compact/process.go index 0f704e4c6..b08f764e3 100644 --- a/internal/cli/compact/process.go +++ b/internal/cli/compact/process.go @@ -17,6 +17,7 @@ import ( "github.com/spf13/cobra" "github.com/ActiveMemory/ctx/internal/config" + "github.com/ActiveMemory/ctx/internal/rc" ) // preCompactAutoSave saves a session snapshot before compacting. @@ -33,7 +34,7 @@ func preCompactAutoSave(cmd *cobra.Command) error { green := color.New(color.FgGreen).SprintFunc() // Ensure sessions directory exists - sessionsDir := filepath.Join(config.ContextDir(), "sessions") + sessionsDir := filepath.Join(rc.GetContextDir(), "sessions") if err := os.MkdirAll(sessionsDir, 0755); err != nil { return fmt.Errorf("failed to create sessions directory: %w", err) } @@ -69,29 +70,31 @@ func preCompactAutoSave(cmd *cobra.Command) error { // - string: Formatted Markdown content for the session file func buildPreCompactSession(timestamp time.Time) string { var sb strings.Builder + nl := config.NewlineLF + sep := config.Separator - sb.WriteString("# Pre-Compact Snapshot\n\n") - sb.WriteString(fmt.Sprintf("**Date**: %s\n", timestamp.Format("2006-01-02"))) - sb.WriteString(fmt.Sprintf("**Time**: %s\n", timestamp.Format("15:04:05"))) - sb.WriteString("**Type**: pre-compact\n\n") - sb.WriteString("---\n\n") + sb.WriteString("# Pre-Compact Snapshot" + nl + nl) + sb.WriteString(fmt.Sprintf("**Date**: %s"+nl, timestamp.Format("2006-01-02"))) + sb.WriteString(fmt.Sprintf("**Time**: %s"+nl, timestamp.Format("15:04:05"))) + sb.WriteString("**Type**: pre-compact" + nl + nl) + sb.WriteString(sep + nl + nl) - sb.WriteString("## Purpose\n\n") + sb.WriteString("## Purpose" + nl + nl) sb.WriteString( - "This snapshot was automatically created before running `ctx compact`.\n", + "This snapshot was automatically created before running `ctx compact`." + nl, ) sb.WriteString( - "It preserves the state of context files before any cleanup operations.\n\n", + "It preserves the state of context files before any cleanup operations." + nl + nl, ) - sb.WriteString("---\n\n") + sb.WriteString(sep + nl + nl) // Read and include current TASKS.md content - tasksPath := filepath.Join(config.ContextDir(), config.FilenameTask) + tasksPath := filepath.Join(rc.GetContextDir(), config.FileTask) if tasksContent, err := os.ReadFile(tasksPath); err == nil { - sb.WriteString("## Tasks (Before Compact)\n\n") - sb.WriteString("```markdown\n") + sb.WriteString("## Tasks (Before Compact)" + nl + nl) + sb.WriteString("```markdown" + nl) sb.WriteString(string(tasksContent)) - sb.WriteString("\n```\n\n") + sb.WriteString(nl + "```" + nl + nl) } return sb.String() diff --git a/internal/cli/compact/run.go b/internal/cli/compact/run.go index abfd5f816..fd6b6ba41 100644 --- a/internal/cli/compact/run.go +++ b/internal/cli/compact/run.go @@ -16,6 +16,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config" "github.com/ActiveMemory/ctx/internal/context" + "github.com/ActiveMemory/ctx/internal/rc" ) // runCompact executes the compact command logic. @@ -41,6 +42,11 @@ func runCompact(cmd *cobra.Command, archive, noAutoSave bool) error { return err } + // Enable archiving if configured in .contextrc + if rc.GetAutoArchive() { + archive = true + } + green := color.New(color.FgGreen).SprintFunc() yellow := color.New(color.FgYellow).SprintFunc() cyan := color.New(color.FgCyan).SprintFunc() @@ -70,7 +76,7 @@ func runCompact(cmd *cobra.Command, archive, noAutoSave bool) error { // Process other files for empty sections for _, f := range ctx.Files { - if f.Name == config.FilenameTask { + if f.Name == config.FileTask { continue } cleaned, count := removeEmptySections(string(f.Content)) diff --git a/internal/cli/compact/task.go b/internal/cli/compact/task.go index ad53613dd..af4837673 100644 --- a/internal/cli/compact/task.go +++ b/internal/cli/compact/task.go @@ -18,6 +18,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config" "github.com/ActiveMemory/ctx/internal/context" + "github.com/ActiveMemory/ctx/internal/rc" ) // compactTasks moves completed tasks to the "Completed" section in TASKS.md. @@ -40,7 +41,7 @@ func compactTasks( ) (int, error) { var tasksFile *context.FileInfo for i := range ctx.Files { - if ctx.Files[i].Name == config.FilenameTask { + if ctx.Files[i].Name == config.FileTask { tasksFile = &ctx.Files[i] break } @@ -110,25 +111,36 @@ func compactTasks( // Archive if requested if archive && len(archivableBlocks) > 0 { - archiveDir := filepath.Join(config.ContextDir(), "archive") - if err := os.MkdirAll(archiveDir, 0755); err == nil { - archiveFile := filepath.Join( - archiveDir, - fmt.Sprintf("tasks-%s.md", time.Now().Format("2006-01-02")), - ) - archiveContent := fmt.Sprintf( - "# Archived Tasks - %s\n\n", time.Now().Format("2006-01-02"), - ) - for _, block := range archivableBlocks { - archiveContent += block.BlockContent() + "\n\n" + // Filter to only tasks old enough to archive + archiveDays := rc.GetArchiveAfterDays() + var blocksToArchive []TaskBlock + for _, block := range archivableBlocks { + if block.OlderThan(archiveDays) { + blocksToArchive = append(blocksToArchive, block) } - if err := os.WriteFile( - archiveFile, []byte(archiveContent), 0644, - ); err == nil { - cmd.Printf( - "%s Archived %d tasks to %s\n", green("✓"), - len(archivableBlocks), archiveFile, + } + + if len(blocksToArchive) > 0 { + archiveDir := filepath.Join(rc.GetContextDir(), "archive") + if err := os.MkdirAll(archiveDir, 0755); err == nil { + archiveFile := filepath.Join( + archiveDir, + fmt.Sprintf("tasks-%s.md", time.Now().Format("2006-01-02")), + ) + archiveContent := fmt.Sprintf( + "# Archived Tasks - %s\n\n", time.Now().Format("2006-01-02"), ) + for _, block := range blocksToArchive { + archiveContent += block.BlockContent() + "\n\n" + } + if err := os.WriteFile( + archiveFile, []byte(archiveContent), 0644, + ); err == nil { + cmd.Printf( + "%s Archived %d tasks to %s (older than %d days)\n", green("✓"), + len(blocksToArchive), archiveFile, archiveDays, + ) + } } } } diff --git a/internal/cli/compact/types.go b/internal/cli/compact/types.go new file mode 100644 index 000000000..478796cde --- /dev/null +++ b/internal/cli/compact/types.go @@ -0,0 +1,27 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package compact + +import "time" + +// TaskBlock represents a task and its nested content. +// +// Fields: +// - Lines: All lines in the block (parent and children) +// - StartIndex: Index of first line in original content +// - EndIndex: Index of last line (exclusive) +// - IsCompleted: The parent task is checked +// - IsArchivable: Completed and no unchecked children +// - DoneTime: When the task was marked done (from #done: timestamp), nil if not present +type TaskBlock struct { + Lines []string + StartIndex int + EndIndex int + IsCompleted bool + IsArchivable bool + DoneTime *time.Time +} diff --git a/internal/cli/complete/run.go b/internal/cli/complete/run.go index 1f82555f2..b26d75b4b 100644 --- a/internal/cli/complete/run.go +++ b/internal/cli/complete/run.go @@ -10,7 +10,6 @@ import ( "fmt" "os" "path/filepath" - "regexp" "strconv" "strings" @@ -18,6 +17,8 @@ import ( "github.com/spf13/cobra" "github.com/ActiveMemory/ctx/internal/config" + "github.com/ActiveMemory/ctx/internal/rc" + "github.com/ActiveMemory/ctx/internal/task" ) // runComplete executes the complete command logic. @@ -35,7 +36,7 @@ import ( func runComplete(cmd *cobra.Command, args []string) error { query := args[0] - filePath := filepath.Join(config.ContextDir(), config.FilenameTask) + filePath := filepath.Join(rc.GetContextDir(), config.FileTask) // Check if the file exists if _, err := os.Stat(filePath); os.IsNotExist(err) { @@ -49,8 +50,7 @@ func runComplete(cmd *cobra.Command, args []string) error { } // Parse tasks and find matching one - lines := strings.Split(string(content), "\n") - taskPattern := regexp.MustCompile(`^(\s*)-\s*\[\s*]\s*(.+)$`) + lines := strings.Split(string(content), config.NewlineLF) var taskNumber int isNumber := false @@ -64,10 +64,10 @@ func runComplete(cmd *cobra.Command, args []string) error { matchedTask := "" for i, line := range lines { - matches := taskPattern.FindStringSubmatch(line) - if matches != nil { + match := config.RegExTask.FindStringSubmatch(line) + if match != nil && task.IsPending(match) { currentTaskNum++ - taskText := matches[2] + taskText := task.Content(match) // Match by number if isNumber && currentTaskNum == taskNumber { @@ -105,8 +105,8 @@ func runComplete(cmd *cobra.Command, args []string) error { } // Mark the task as complete - lines[matchedLine] = taskPattern.ReplaceAllString( - lines[matchedLine], "$1- [x] $2", + lines[matchedLine] = config.RegExTask.ReplaceAllString( + lines[matchedLine], "$1- [x] $3", ) // Write back diff --git a/internal/cli/decisions/decisions.go b/internal/cli/decision/decision.go similarity index 55% rename from internal/cli/decisions/decisions.go rename to internal/cli/decision/decision.go index 074c9e739..c887047b5 100644 --- a/internal/cli/decisions/decisions.go +++ b/internal/cli/decision/decision.go @@ -5,18 +5,10 @@ // SPDX-License-Identifier: Apache-2.0 // Package decisions provides commands for managing DECISIONS.md. -package decisions +package decision import ( - "fmt" - "os" - "path/filepath" - - "github.com/fatih/color" "github.com/spf13/cobra" - - "github.com/ActiveMemory/ctx/internal/cli/add" - "github.com/ActiveMemory/ctx/internal/config" ) // Cmd returns the decisions command with subcommands. @@ -68,46 +60,3 @@ Examples: RunE: runReindex, } } - -// runReindex regenerates the DECISIONS.md index. -// -// Parameters: -// - cmd: Cobra command for output messages -// - args: Command arguments (unused) -// -// Returns: -// - error: Non-nil if file read/write fails -func runReindex(cmd *cobra.Command, args []string) error { - filePath := filepath.Join(config.ContextDir(), config.FilenameDecision) - - // Check if file exists - if _, err := os.Stat(filePath); os.IsNotExist(err) { - return fmt.Errorf("DECISIONS.md not found. Run 'ctx init' first") - } - - // Read current content - content, err := os.ReadFile(filePath) - if err != nil { - return fmt.Errorf("failed to read %s: %w", filePath, err) - } - - // Update the index - updated := add.UpdateIndex(string(content)) - - // Write back - if err := os.WriteFile(filePath, []byte(updated), 0644); err != nil { - return fmt.Errorf("failed to write %s: %w", filePath, err) - } - - // Count entries for feedback - entries := add.ParseDecisionHeaders(string(content)) - - green := color.New(color.FgGreen).SprintFunc() - if len(entries) == 0 { - cmd.Printf("%s Index cleared (no decisions found)\n", green("✓")) - } else { - cmd.Printf("%s Index regenerated with %d entries\n", green("✓"), len(entries)) - } - - return nil -} diff --git a/internal/cli/decision/run.go b/internal/cli/decision/run.go new file mode 100644 index 000000000..e3ee9c45a --- /dev/null +++ b/internal/cli/decision/run.go @@ -0,0 +1,60 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package decision + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/config" + "github.com/ActiveMemory/ctx/internal/index" + "github.com/ActiveMemory/ctx/internal/rc" +) + +// runReindex regenerates the DECISIONS.md index. +// +// Parameters: +// - cmd: Cobra command for output messages +// - args: Command arguments (unused) +// +// Returns: +// - error: Non-nil if file read/write fails +func runReindex(cmd *cobra.Command, _ []string) error { + filePath := filepath.Join(rc.GetContextDir(), config.FileDecision) + + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return fmt.Errorf("%s not found. Run 'ctx init' first", config.FileDecision) + } + + content, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read %s: %w", filePath, err) + } + + updated := index.UpdateDecisions(string(content)) + + if err := os.WriteFile(filePath, []byte(updated), 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", filePath, err) + } + + entries := index.ParseHeaders(string(content)) + green := color.New(color.FgGreen).SprintFunc() + if len(entries) == 0 { + cmd.Printf("%s Index cleared (no decisions found)\n", green("✓")) + } else { + cmd.Printf( + "%s Index regenerated with %d entries\n", green("✓"), + len(entries), + ) + } + + return nil +} diff --git a/internal/cli/drift/fix.go b/internal/cli/drift/fix.go index ca3355ec8..946793c13 100644 --- a/internal/cli/drift/fix.go +++ b/internal/cli/drift/fix.go @@ -10,7 +10,6 @@ import ( "fmt" "os" "path/filepath" - "regexp" "strings" "time" @@ -20,6 +19,8 @@ import ( "github.com/ActiveMemory/ctx/internal/config" "github.com/ActiveMemory/ctx/internal/context" "github.com/ActiveMemory/ctx/internal/drift" + "github.com/ActiveMemory/ctx/internal/rc" + "github.com/ActiveMemory/ctx/internal/task" "github.com/ActiveMemory/ctx/internal/templates" ) @@ -69,7 +70,7 @@ func applyFixes( } case "missing_file": - if err := fixMissingFile(cmd, issue.File); err != nil { + if err := fixMissingFile(issue.File); err != nil { result.errors = append(result.errors, fmt.Sprintf("missing %s: %v", issue.File, err)) } else { @@ -110,7 +111,7 @@ func applyFixes( func fixStaleness(cmd *cobra.Command, ctx *context.Context) error { var tasksFile *context.FileInfo for i := range ctx.Files { - if ctx.Files[i].Name == config.FilenameTask { + if ctx.Files[i].Name == config.FileTask { tasksFile = &ctx.Files[i] break } @@ -120,11 +121,11 @@ func fixStaleness(cmd *cobra.Command, ctx *context.Context) error { return fmt.Errorf("TASKS.md not found") } + nl := config.NewlineLF content := string(tasksFile.Content) - lines := strings.Split(content, "\n") + lines := strings.Split(content, nl) // Find completed tasks in the Completed section - completedPattern := regexp.MustCompile(`^-\s*\[x]\s*(.+)$`) var completedTasks []string var newLines []string inCompletedSection := false @@ -141,12 +142,10 @@ func fixStaleness(cmd *cobra.Command, ctx *context.Context) error { } // Collect completed tasks from the Completed section for archiving - if inCompletedSection && completedPattern.MatchString(line) { - matches := completedPattern.FindStringSubmatch(line) - if len(matches) > 1 { - completedTasks = append(completedTasks, matches[1]) - continue // Remove from file - } + match := config.RegExTask.FindStringSubmatch(line) + if inCompletedSection && match != nil && task.Completed(match) { + completedTasks = append(completedTasks, task.Content(match)) + continue // Remove from file } newLines = append(newLines, line) @@ -157,7 +156,7 @@ func fixStaleness(cmd *cobra.Command, ctx *context.Context) error { } // Create an archive directory - archiveDir := filepath.Join(config.ContextDir(), "archive") + archiveDir := filepath.Join(rc.GetContextDir(), "archive") if err := os.MkdirAll(archiveDir, 0755); err != nil { return fmt.Errorf("failed to create archive directory: %w", err) } @@ -168,16 +167,14 @@ func fixStaleness(cmd *cobra.Command, ctx *context.Context) error { fmt.Sprintf("tasks-%s.md", time.Now().Format("2006-01-02")), ) - archiveContent := fmt.Sprintf( - "# Archived Tasks - %s\n\n", time.Now().Format("2006-01-02"), - ) - for _, task := range completedTasks { - archiveContent += fmt.Sprintf("- [x] %s\n", task) + archiveContent := "# Archived Tasks - " + time.Now().Format("2006-01-02") + nl + nl + for _, t := range completedTasks { + archiveContent += "- [x] " + t + nl } // Append to existing archive file if it exists if existing, err := os.ReadFile(archiveFile); err == nil { - archiveContent = string(existing) + "\n" + archiveContent + archiveContent = string(existing) + nl + archiveContent } if err := os.WriteFile( @@ -187,7 +184,7 @@ func fixStaleness(cmd *cobra.Command, ctx *context.Context) error { } // Write updated TASKS.md - newContent := strings.Join(newLines, "\n") + newContent := strings.Join(newLines, nl) if err := os.WriteFile( tasksFile.Path, []byte(newContent), 0644, ); err != nil { @@ -203,21 +200,20 @@ func fixStaleness(cmd *cobra.Command, ctx *context.Context) error { // fixMissingFile creates a missing required context file from template. // // Parameters: -// - cmd: Cobra command for output messages // - filename: Name of the file to create (e.g., "CONSTITUTION.md") // // Returns: // - error: Non-nil if the template is not found or file write fails -func fixMissingFile(cmd *cobra.Command, filename string) error { +func fixMissingFile(filename string) error { content, err := templates.GetTemplate(filename) if err != nil { return fmt.Errorf("no template available for %s: %w", filename, err) } - targetPath := filepath.Join(config.ContextDir(), filename) + targetPath := filepath.Join(rc.GetContextDir(), filename) // Ensure .context/ directory exists - if err := os.MkdirAll(config.ContextDir(), 0755); err != nil { + if err := os.MkdirAll(rc.GetContextDir(), 0755); err != nil { return fmt.Errorf("failed to create .context/: %w", err) } diff --git a/internal/cli/initialize/cmd.go b/internal/cli/initialize/cmd.go index c99b922d8..ddc9c0104 100644 --- a/internal/cli/initialize/cmd.go +++ b/internal/cli/initialize/cmd.go @@ -38,7 +38,7 @@ func createClaudeCommands(cmd *cobra.Command, force bool) error { } // Get the list of embedded command files - commands, err := claude.ListCommands() + commands, err := claude.Commands() if err != nil { return fmt.Errorf("failed to list commands: %w", err) } @@ -50,7 +50,7 @@ func createClaudeCommands(cmd *cobra.Command, force bool) error { continue } - content, err := claude.GetCommand(cmdName) + content, err := claude.CommandByName(cmdName) if err != nil { return fmt.Errorf("failed to get command %s: %w", cmdName, err) } diff --git a/internal/cli/initialize/hook.go b/internal/cli/initialize/hook.go index 163db9724..762c8b036 100644 --- a/internal/cli/initialize/hook.go +++ b/internal/cli/initialize/hook.go @@ -53,7 +53,7 @@ func createClaudeHooks(cmd *cobra.Command, force bool) error { if _, err := os.Stat(scriptPath); err == nil && !force { cmd.Printf(" %s %s (exists, skipped)\n", yellow("○"), scriptPath) } else { - scriptContent, err := claude.GetAutoSaveScript() + scriptContent, err := claude.AutoSaveScript() if err != nil { return fmt.Errorf("failed to get auto-save script: %w", err) } @@ -71,7 +71,7 @@ func createClaudeHooks(cmd *cobra.Command, force bool) error { if _, err := os.Stat(blockScriptPath); err == nil && !force { cmd.Printf(" %s %s (exists, skipped)\n", yellow("○"), blockScriptPath) } else { - blockScriptContent, err := claude.GetBlockNonPathCtxScript() + blockScriptContent, err := claude.BlockNonPathCtxScript() if err != nil { return fmt.Errorf("failed to get block-non-path-ctx script: %w", err) } @@ -83,6 +83,26 @@ func createClaudeHooks(cmd *cobra.Command, force bool) error { cmd.Printf(" %s %s\n", green("✓"), blockScriptPath) } + // Create prompt-coach.sh script + // (detects prompt anti-patterns and suggests improvements) + coachScriptPath := filepath.Join( + config.DirClaudeHooks, config.FilePromptCoach, + ) + if _, err := os.Stat(coachScriptPath); err == nil && !force { + cmd.Printf(" %s %s (exists, skipped)\n", yellow("○"), coachScriptPath) + } else { + coachScriptContent, err := claude.PromptCoachScript() + if err != nil { + return fmt.Errorf("failed to get prompt-coach script: %w", err) + } + if err := os.WriteFile( + coachScriptPath, coachScriptContent, 0755, + ); err != nil { + return fmt.Errorf("failed to write %s: %w", coachScriptPath, err) + } + cmd.Printf(" %s %s\n", green("✓"), coachScriptPath) + } + // Handle settings.local.json - merge rather than overwrite if err := mergeSettingsHooks(cmd, cwd, force); err != nil { return err @@ -128,11 +148,12 @@ func mergeSettingsHooks( } // Get our defaults - defaultHooks := claude.CreateDefaultHooks(projectDir) - defaultPerms := claude.CreateDefaultPermissions() + defaultHooks := claude.DefaultHooks(projectDir) + defaultPerms := claude.DefaultPermissions() // Check if hooks already exist hasPreToolUse := len(settings.Hooks.PreToolUse) > 0 + hasUserPromptSubmit := len(settings.Hooks.UserPromptSubmit) > 0 hasSessionEnd := len(settings.Hooks.SessionEnd) > 0 // Merge hooks - only add what's missing (or force overwrite) @@ -141,6 +162,10 @@ func mergeSettingsHooks( settings.Hooks.PreToolUse = defaultHooks.PreToolUse hooksModified = true } + if !hasUserPromptSubmit || force { + settings.Hooks.UserPromptSubmit = defaultHooks.UserPromptSubmit + hooksModified = true + } if !hasSessionEnd || force { settings.Hooks.SessionEnd = defaultHooks.SessionEnd hooksModified = true diff --git a/internal/cli/initialize/run.go b/internal/cli/initialize/run.go index fa68af7d2..facf55d84 100644 --- a/internal/cli/initialize/run.go +++ b/internal/cli/initialize/run.go @@ -17,6 +17,7 @@ import ( "github.com/spf13/cobra" "github.com/ActiveMemory/ctx/internal/config" + "github.com/ActiveMemory/ctx/internal/rc" "github.com/ActiveMemory/ctx/internal/templates" ) @@ -39,7 +40,7 @@ func runInit(cmd *cobra.Command, force, minimal, merge bool) error { return err } - contextDir := config.ContextDir() + contextDir := rc.GetContextDir() // Check if .context/ already exists if _, err := os.Stat(contextDir); err == nil { diff --git a/internal/cli/journal/journal.go b/internal/cli/journal/journal.go new file mode 100644 index 000000000..6e32d546e --- /dev/null +++ b/internal/cli/journal/journal.go @@ -0,0 +1,41 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package journal + +import ( + "github.com/spf13/cobra" +) + +// Cmd returns the journal command with subcommands. +// +// The journal system provides LLM-powered analysis and synthesis of +// exported session files in .context/journal/. +// +// Returns: +// - *cobra.Command: The journal command with subcommands +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "journal", + Short: "Analyze and synthesize exported sessions", + Long: `Work with exported session files in .context/journal/. + +The journal system provides tools for analyzing, enriching, and +publishing your AI session history. + +Subcommands: + site Generate a static site from journal entries + +Examples: + ctx journal site # Generate site in .context/journal-site/ + ctx journal site --output ~/public # Custom output directory + ctx journal site --serve # Generate and serve locally`, + } + + cmd.AddCommand(journalSiteCmd()) + + return cmd +} diff --git a/internal/cli/journal/site.go b/internal/cli/journal/site.go new file mode 100644 index 000000000..2ec674e92 --- /dev/null +++ b/internal/cli/journal/site.go @@ -0,0 +1,450 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package journal + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/config" + "github.com/ActiveMemory/ctx/internal/rc" +) + +// journalSiteCmd returns the journal site subcommand. +// +// Returns: +// - *cobra.Command: Command for generating a static site from journal entries +func journalSiteCmd() *cobra.Command { + var ( + output string + serve bool + build bool + ) + + cmd := &cobra.Command{ + Use: "site", + Short: "Generate a static site from journal entries", + Long: `Generate a zensical-compatible static site from .context/journal/ entries. + +Creates a site structure with: + - Index page with all sessions listed by date + - Individual pages for each journal entry + - Navigation and search support + +Requires zensical to be installed for building/serving: + pip install zensical + +Examples: + ctx journal site # Generate in .context/journal-site/ + ctx journal site --output ~/public # Custom output directory + ctx journal site --build # Generate and build HTML + ctx journal site --serve # Generate and serve locally`, + RunE: func(cmd *cobra.Command, args []string) error { + return runJournalSite(cmd, output, build, serve) + }, + } + + defaultOutput := filepath.Join(rc.GetContextDir(), "journal-site") + cmd.Flags().StringVarP(&output, "output", "o", defaultOutput, "Output directory for site") + cmd.Flags().BoolVar(&build, "build", false, "Run zensical build after generating") + cmd.Flags().BoolVar(&serve, "serve", false, "Run zensical serve after generating") + + return cmd +} + +// journalEntry represents a parsed journal file. +type journalEntry struct { + Filename string + Title string + Date string + Time string + Project string + Path string + Size int64 + IsSuggestion bool +} + +// runJournalSite handles the journal site command. +// +// Scans .context/journal/ for markdown files, generates a zensical project +// structure, and optionally builds or serves the site. +// +// Parameters: +// - cmd: Cobra command for output stream +// - output: Output directory for the generated site +// - build: If true, run zensical build after generating +// - serve: If true, run zensical serve after generating +// +// Returns: +// - error: Non-nil if generation fails +func runJournalSite(cmd *cobra.Command, output string, build, serve bool) error { + journalDir := filepath.Join(rc.GetContextDir(), "journal") + + // Check if journal directory exists + if _, err := os.Stat(journalDir); os.IsNotExist(err) { + return fmt.Errorf("no journal directory found at %s\nRun 'ctx recall export --all' first", journalDir) + } + + // Scan journal files + entries, err := scanJournalEntries(journalDir) + if err != nil { + return fmt.Errorf("failed to scan journal: %w", err) + } + + if len(entries) == 0 { + return fmt.Errorf("no journal entries found in %s\nRun 'ctx recall export --all' first", journalDir) + } + + green := color.New(color.FgGreen).SprintFunc() + + // Create output directory structure + docsDir := filepath.Join(output, "docs") + if err := os.MkdirAll(docsDir, 0755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + // Copy journal files to docs/ + for _, entry := range entries { + src := entry.Path + dst := filepath.Join(docsDir, entry.Filename) + + content, err := os.ReadFile(src) + if err != nil { + cmd.PrintErrf(" ! failed to read %s: %v\n", entry.Filename, err) + continue + } + + if err := os.WriteFile(dst, content, 0644); err != nil { + cmd.PrintErrf(" ! failed to write %s: %v\n", entry.Filename, err) + continue + } + } + + // Generate index.md + indexContent := generateIndex(entries) + indexPath := filepath.Join(docsDir, "index.md") + if err := os.WriteFile(indexPath, []byte(indexContent), 0644); err != nil { + return fmt.Errorf("failed to write index.md: %w", err) + } + + // Generate zensical.toml + tomlContent := generateZensicalToml(entries) + tomlPath := filepath.Join(output, "zensical.toml") + if err := os.WriteFile(tomlPath, []byte(tomlContent), 0644); err != nil { + return fmt.Errorf("failed to write zensical.toml: %w", err) + } + + cmd.Printf("%s Generated site with %d entries in %s\n", green("✓"), len(entries), output) + + // Build or serve if requested + if serve { + cmd.Println("\nStarting local server...") + return runZensical(output, "serve") + } else if build { + cmd.Println("\nBuilding site...") + return runZensical(output, "build") + } + + cmd.Println("\nNext steps:") + cmd.Printf(" cd %s && zensical serve\n", output) + + return nil +} + +// scanJournalEntries reads all journal markdown files and extracts metadata. +// +// Parameters: +// - journalDir: Path to .context/journal/ +// +// Returns: +// - []journalEntry: Parsed entries sorted by date (newest first) +// - error: Non-nil if directory scanning fails +func scanJournalEntries(journalDir string) ([]journalEntry, error) { + files, err := os.ReadDir(journalDir) + if err != nil { + return nil, err + } + + var entries []journalEntry + for _, f := range files { + if f.IsDir() || !strings.HasSuffix(f.Name(), ".md") { + continue + } + + path := filepath.Join(journalDir, f.Name()) + entry := parseJournalEntry(path, f.Name()) + entries = append(entries, entry) + } + + // Sort by datetime (newest first) - combine Date and Time + sort.Slice(entries, func(i, j int) bool { + // Compare Date+Time strings (YYYY-MM-DD + HH:MM:SS) + di := entries[i].Date + " " + entries[i].Time + dj := entries[j].Date + " " + entries[j].Time + return di > dj + }) + + return entries, nil +} + +// parseJournalEntry extracts metadata from a journal file. +// +// Parameters: +// - path: Full path to the journal file +// - filename: Filename (e.g., "2026-01-21-async-roaming-allen-af7cba21.md") +// +// Returns: +// - journalEntry: Parsed entry with title, date, project extracted +func parseJournalEntry(path, filename string) journalEntry { + entry := journalEntry{ + Filename: filename, + Path: path, + } + + // Extract date from filename (YYYY-MM-DD-slug-id.md) + if len(filename) >= 10 { + entry.Date = filename[:10] + } + + // Read file to extract metadata + content, err := os.ReadFile(path) + if err != nil { + entry.Title = strings.TrimSuffix(filename, ".md") + return entry + } + + // File size + entry.Size = int64(len(content)) + + // Check for suggestion mode sessions + contentStr := string(content) + if strings.Contains(contentStr, "[SUGGESTION MODE:") || + strings.Contains(contentStr, "SUGGESTION MODE:") { + entry.IsSuggestion = true + } + + lines := strings.Split(contentStr, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + + // Title from first H1 + if strings.HasPrefix(line, "# ") && entry.Title == "" { + entry.Title = strings.TrimPrefix(line, "# ") + } + + // Time from metadata + if strings.HasPrefix(line, "**Time**:") { + entry.Time = strings.TrimSpace(strings.TrimPrefix(line, "**Time**:")) + } + + // Project from metadata + if strings.HasPrefix(line, "**Project**:") { + entry.Project = strings.TrimSpace(strings.TrimPrefix(line, "**Project**:")) + } + + // Stop after we have all three + if entry.Title != "" && entry.Time != "" && entry.Project != "" { + break + } + } + + if entry.Title == "" { + entry.Title = strings.TrimSuffix(filename, ".md") + } + + return entry +} + +// generateIndex creates the index.md content for the journal site. +// +// Parameters: +// - entries: All journal entries to include +// +// Returns: +// - string: Markdown content for index.md +func generateIndex(entries []journalEntry) string { + var sb strings.Builder + nl := config.NewlineLF + + // Separate regular sessions from suggestions + var regular, suggestions []journalEntry + for _, e := range entries { + if e.IsSuggestion { + suggestions = append(suggestions, e) + } else { + regular = append(regular, e) + } + } + + sb.WriteString("# Session Journal" + nl + nl) + sb.WriteString("Browse your AI session history." + nl + nl) + sb.WriteString(fmt.Sprintf("**Sessions**: %d | **Suggestions**: %d"+nl+nl, len(regular), len(suggestions))) + + // Group regular sessions by month + months := make(map[string][]journalEntry) + var monthOrder []string + + for _, e := range regular { + if len(e.Date) >= 7 { + month := e.Date[:7] // YYYY-MM + if _, exists := months[month]; !exists { + monthOrder = append(monthOrder, month) + } + months[month] = append(months[month], e) + } + } + + for _, month := range monthOrder { + sb.WriteString(fmt.Sprintf("## %s"+nl+nl, month)) + + for _, e := range months[month] { + sb.WriteString(formatIndexEntry(e, nl)) + } + sb.WriteString(nl) + } + + // Suggestions section (collapsed by default via details tag) + if len(suggestions) > 0 { + sb.WriteString("---" + nl + nl) + sb.WriteString("## Suggestions" + nl + nl) + sb.WriteString("*Auto-generated suggestion prompts from Claude Code.*" + nl + nl) + + for _, e := range suggestions { + sb.WriteString(formatIndexEntry(e, nl)) + } + sb.WriteString(nl) + } + + return sb.String() +} + +// formatIndexEntry formats a single entry for the index. +// +// Format: - HH:MM [title](link.md) (project) [size] +func formatIndexEntry(e journalEntry, nl string) string { + link := strings.TrimSuffix(e.Filename, ".md") + + timeStr := "" + if e.Time != "" && len(e.Time) >= 5 { + timeStr = e.Time[:5] + " " + } + + project := "" + if e.Project != "" { + project = fmt.Sprintf(" (%s)", e.Project) + } + + size := formatSize(e.Size) + + return fmt.Sprintf("- %s[%s](%s.md)%s `%s`"+nl, timeStr, e.Title, link, project, size) +} + +// formatSize formats a file size in human-readable form. +func formatSize(bytes int64) string { + if bytes < 1024 { + return fmt.Sprintf("%dB", bytes) + } + kb := float64(bytes) / 1024 + if kb < 1024 { + return fmt.Sprintf("%.1fKB", kb) + } + mb := kb / 1024 + return fmt.Sprintf("%.1fMB", mb) +} + +// generateZensicalToml creates the zensical.toml configuration. +// +// Parameters: +// - entries: All journal entries for navigation +// +// Returns: +// - string: TOML content for zensical.toml +func generateZensicalToml(entries []journalEntry) string { + var sb strings.Builder + nl := config.NewlineLF + + sb.WriteString(`[project] +site_name = "Session Journal" +site_description = "AI session history and notes" +` + nl) + + // Build navigation + sb.WriteString("nav = [" + nl) + sb.WriteString(` { "Home" = "index.md" },` + nl) + + // Group recent entries (last 20) + recent := entries + if len(recent) > 20 { + recent = recent[:20] + } + + sb.WriteString(` { "Recent Sessions" = [` + nl) + for _, e := range recent { + title := e.Title + if len(title) > 40 { + title = title[:40] + "..." + } + // Escape quotes in title + title = strings.ReplaceAll(title, `"`, `\"`) + sb.WriteString(fmt.Sprintf(` { "%s" = "%s" },`+nl, title, e.Filename)) + } + sb.WriteString(" ]}" + nl) + sb.WriteString("]" + nl + nl) + + sb.WriteString(`[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" +`) + + return sb.String() +} + +// runZensical executes zensical build or serve in the output directory. +// +// Parameters: +// - dir: Directory containing the generated site +// - command: "build" or "serve" +// +// Returns: +// - error: Non-nil if zensical is not found or fails +func runZensical(dir, command string) error { + // Check if zensical is available + _, err := exec.LookPath("zensical") + if err != nil { + return fmt.Errorf("zensical not found. Install with: pip install zensical") + } + + cmd := exec.Command("zensical", command) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + return cmd.Run() +} diff --git a/internal/cli/learnings/learnings.go b/internal/cli/learnings/learnings.go index 06c63e909..b0faf9b12 100644 --- a/internal/cli/learnings/learnings.go +++ b/internal/cli/learnings/learnings.go @@ -15,8 +15,9 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" - "github.com/ActiveMemory/ctx/internal/cli/add" "github.com/ActiveMemory/ctx/internal/config" + "github.com/ActiveMemory/ctx/internal/index" + "github.com/ActiveMemory/ctx/internal/rc" ) // Cmd returns the learnings command with subcommands. @@ -77,36 +78,35 @@ Examples: // // Returns: // - error: Non-nil if file read/write fails -func runReindex(cmd *cobra.Command, args []string) error { - filePath := filepath.Join(config.ContextDir(), config.FilenameLearning) +func runReindex(cmd *cobra.Command, _ []string) error { + filePath := filepath.Join(rc.GetContextDir(), config.FileLearning) - // Check if file exists if _, err := os.Stat(filePath); os.IsNotExist(err) { - return fmt.Errorf("LEARNINGS.md not found. Run 'ctx init' first") + return fmt.Errorf( + "%s not found. Run 'ctx init' first", config.FileLearning, + ) } - // Read current content content, err := os.ReadFile(filePath) if err != nil { return fmt.Errorf("failed to read %s: %w", filePath, err) } - // Update the index - updated := add.UpdateLearningsIndex(string(content)) + updated := index.UpdateLearnings(string(content)) - // Write back if err := os.WriteFile(filePath, []byte(updated), 0644); err != nil { return fmt.Errorf("failed to write %s: %w", filePath, err) } - // Count entries for feedback - entries := add.ParseEntryHeaders(string(content)) - + entries := index.ParseHeaders(string(content)) green := color.New(color.FgGreen).SprintFunc() if len(entries) == 0 { cmd.Printf("%s Index cleared (no learnings found)\n", green("✓")) } else { - cmd.Printf("%s Index regenerated with %d entries\n", green("✓"), len(entries)) + cmd.Printf( + "%s Index regenerated with %d entries\n", green("✓"), + len(entries), + ) } return nil diff --git a/internal/cli/load/load.go b/internal/cli/load/load.go index ea3ec5949..06e8c7fe4 100644 --- a/internal/cli/load/load.go +++ b/internal/cli/load/load.go @@ -9,7 +9,7 @@ package load import ( "github.com/spf13/cobra" - "github.com/ActiveMemory/ctx/internal/config" + "github.com/ActiveMemory/ctx/internal/rc" ) // Cmd returns the "ctx load" command for outputting assembled context. @@ -51,14 +51,14 @@ Use --budget to limit output to a specific token count (default from .contextrc RunE: func(cmd *cobra.Command, args []string) error { // Use configured budget if flag not explicitly set if !cmd.Flags().Changed("budget") { - budget = config.GetTokenBudget() + budget = rc.GetTokenBudget() } return runLoad(cmd, budget, raw) }, } cmd.Flags().IntVar( - &budget, "budget", config.DefaultTokenBudget, "Token budget for assembly", + &budget, "budget", rc.DefaultTokenBudget, "Token budget for assembly", ) cmd.Flags().BoolVar( &raw, "raw", false, "Output raw file contents without assembly", diff --git a/internal/cli/load/out.go b/internal/cli/load/out.go index ba2c1b085..8fa735f2c 100644 --- a/internal/cli/load/out.go +++ b/internal/cli/load/out.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" + "github.com/ActiveMemory/ctx/internal/config" "github.com/ActiveMemory/ctx/internal/context" ) @@ -56,16 +57,18 @@ func outputAssembled( cmd *cobra.Command, ctx *context.Context, budget int, ) error { var sb strings.Builder + nl := config.NewlineLF + sep := config.Separator // Header - sb.WriteString("# Context\n\n") + sb.WriteString("# Context" + nl + nl) sb.WriteString( fmt.Sprintf( - "Token Budget: %d | Available: %d\n\n", + "Token Budget: %d | Available: %d"+nl+nl, budget, ctx.TotalTokens, ), ) - sb.WriteString("---\n\n") + sb.WriteString(sep + nl + nl) // Sort files by read order files := sortByReadOrder(ctx.Files) @@ -83,19 +86,19 @@ func outputAssembled( if tokensUsed+fileTokens > budget { // Add a truncation notice sb.WriteString( - fmt.Sprintf("\n---\n\n*[Truncated: %s and remaining files "+ - "excluded due to token budget]*\n", f.Name), + fmt.Sprintf(nl+sep+nl+nl+"*[Truncated: %s and remaining files "+ + "excluded due to token budget]*"+nl, f.Name), ) break } // Add the file section - sb.WriteString(fmt.Sprintf("## %s\n\n", fileNameToTitle(f.Name))) + sb.WriteString(fmt.Sprintf("## %s"+nl+nl, fileNameToTitle(f.Name))) sb.Write(f.Content) - if !strings.HasSuffix(string(f.Content), "\n") { - sb.WriteString("\n") + if !strings.HasSuffix(string(f.Content), nl) { + sb.WriteString(nl) } - sb.WriteString("\n---\n\n") + sb.WriteString(nl + sep + nl + nl) tokensUsed += fileTokens } diff --git a/internal/cli/recall/export.go b/internal/cli/recall/export.go new file mode 100644 index 000000000..d6d8b866d --- /dev/null +++ b/internal/cli/recall/export.go @@ -0,0 +1,316 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package recall + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/config" + "github.com/ActiveMemory/ctx/internal/rc" + "github.com/ActiveMemory/ctx/internal/recall/parser" +) + +// recallExportCmd returns the recall export subcommand. +// +// Returns: +// - *cobra.Command: Command for exporting sessions to journal files +func recallExportCmd() *cobra.Command { + var ( + all bool + allProjects bool + force bool + ) + + cmd := &cobra.Command{ + Use: "export [session-id]", + Short: "Export sessions to editable journal files", + Long: `Export AI sessions to .context/journal/ as editable Markdown files. + +Exported files include session metadata, tool usage summary, and the full +conversation. You can edit these files to add notes, highlight key moments, +or clean up the transcript. + +By default, only sessions from the current project are exported. Use +--all-projects to include sessions from all projects. + +Existing files are skipped to preserve your edits. Use --force to overwrite. + +Examples: + ctx recall export abc123 # Export one session + ctx recall export --all # Export all sessions from this project + ctx recall export --all --all-projects # Export from all projects + ctx recall export --all --force # Overwrite existing exports`, + RunE: func(cmd *cobra.Command, args []string) error { + return runRecallExport(cmd, args, all, allProjects, force) + }, + } + + cmd.Flags().BoolVar(&all, "all", false, "Export all sessions from current project") + cmd.Flags().BoolVar(&allProjects, "all-projects", false, "Include sessions from all projects") + cmd.Flags().BoolVar(&force, "force", false, "Overwrite existing files") + + return cmd +} + +// runRecallExport handles the recall export command. +// +// Exports one or more sessions to .context/journal/ as Markdown files. +// Skips existing files unless force is true. +// +// Parameters: +// - cmd: Cobra command for output stream +// - args: Session ID to export (ignored if all is true) +// - all: If true, export all sessions +// - allProjects: If true, include sessions from all projects +// - force: If true, overwrite existing files +// +// Returns: +// - error: Non-nil if export fails +func runRecallExport(cmd *cobra.Command, args []string, all, allProjects, force bool) error { + if len(args) > 0 && all { + return fmt.Errorf("cannot use --all with a session ID; use one or the other") + } + if len(args) == 0 && !all { + return fmt.Errorf("please provide a session ID or use --all") + } + + // Find sessions - filter by current project unless --all-projects is set + var sessions []*parser.Session + var err error + if allProjects { + sessions, err = parser.FindSessions() + } else { + cwd, cwdErr := os.Getwd() + if cwdErr != nil { + return fmt.Errorf("failed to get working directory: %w", cwdErr) + } + sessions, err = parser.FindSessionsForCWD(cwd) + } + if err != nil { + return fmt.Errorf("failed to find sessions: %w", err) + } + + if len(sessions) == 0 { + if allProjects { + cmd.Println("No sessions found.") + } else { + cmd.Println("No sessions found for this project. Use --all-projects to see all.") + } + return nil + } + + // Determine which sessions to export + var toExport []*parser.Session + if all { + toExport = sessions + } else { + query := strings.ToLower(args[0]) + for _, s := range sessions { + if strings.HasPrefix(strings.ToLower(s.ID), query) || + strings.Contains(strings.ToLower(s.Slug), query) { + toExport = append(toExport, s) + } + } + if len(toExport) == 0 { + return fmt.Errorf("session not found: %s", args[0]) + } + if len(toExport) > 1 && !all { + cmd.PrintErrf("Multiple sessions match '%s':\n", args[0]) + for _, m := range toExport { + cmd.PrintErrf(" %s (%s) - %s\n", + m.Slug, m.ID[:8], m.StartTime.Format("2006-01-02 15:04")) + } + return fmt.Errorf("ambiguous query, use a more specific ID") + } + } + + // Ensure journal directory exists + journalDir := filepath.Join(rc.GetContextDir(), "journal") + if err := os.MkdirAll(journalDir, 0755); err != nil { + return fmt.Errorf("failed to create journal directory: %w", err) + } + + // Export each session + green := color.New(color.FgGreen).SprintFunc() + yellow := color.New(color.FgYellow).SprintFunc() + dim := color.New(color.FgHiBlack) + + var exported, skipped int + for _, s := range toExport { + filename := formatJournalFilename(s) + path := filepath.Join(journalDir, filename) + + // Check if file exists + if _, err := os.Stat(path); err == nil && !force { + skipped++ + dim.Fprintf(cmd.OutOrStdout(), " skip %s (exists)\n", filename) + continue + } + + // Generate content + content := formatJournalEntry(s) + + // Write file + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + cmd.PrintErrf(" %s failed to write %s: %v\n", yellow("!"), filename, err) + continue + } + + exported++ + cmd.Printf(" %s %s\n", green("✓"), filename) + } + + cmd.Println() + if exported > 0 { + cmd.Printf("Exported %d session(s) to %s\n", exported, journalDir) + } + if skipped > 0 { + dim.Fprintf(cmd.OutOrStdout(), "Skipped %d existing file(s). Use --force to overwrite.\n", skipped) + } + + return nil +} + +// formatJournalFilename generates the filename for a journal entry. +// +// Format: YYYY-MM-DD-slug-shortid.md +// Uses local time for the date. +// +// Parameters: +// - s: Session to generate filename for +// +// Returns: +// - string: Filename like "2026-01-15-gleaming-wobbling-sutherland-abc12345.md" +func formatJournalFilename(s *parser.Session) string { + date := s.StartTime.Local().Format("2006-01-02") + shortID := s.ID + if len(shortID) > 8 { + shortID = shortID[:8] + } + return fmt.Sprintf("%s-%s-%s.md", date, s.Slug, shortID) +} + +// isEmptyMessage returns true if a message has no meaningful content. +func isEmptyMessage(msg parser.Message) bool { + return msg.Text == "" && len(msg.ToolUses) == 0 && len(msg.ToolResults) == 0 +} + +// formatJournalEntry generates the Markdown content for a journal entry. +// +// Includes metadata, tool usage summary, and full conversation. +// +// Parameters: +// - s: Session to format +// +// Returns: +// - string: Complete Markdown content +func formatJournalEntry(s *parser.Session) string { + var sb strings.Builder + nl := config.NewlineLF + sep := config.Separator + + // Header + sb.WriteString(fmt.Sprintf("# %s"+nl+nl, s.Slug)) + + // Metadata (use local time) + localStart := s.StartTime.Local() + sb.WriteString(fmt.Sprintf("**ID**: %s"+nl, s.ID)) + sb.WriteString(fmt.Sprintf("**Date**: %s"+nl, localStart.Format("2006-01-02"))) + sb.WriteString(fmt.Sprintf("**Time**: %s"+nl, localStart.Format("15:04:05"))) + sb.WriteString(fmt.Sprintf("**Duration**: %s"+nl, formatDuration(s.Duration))) + sb.WriteString(fmt.Sprintf("**Tool**: %s"+nl, s.Tool)) + sb.WriteString(fmt.Sprintf("**Project**: %s"+nl, s.Project)) + if s.GitBranch != "" { + sb.WriteString(fmt.Sprintf("**Branch**: %s"+nl, s.GitBranch)) + } + if s.Model != "" { + sb.WriteString(fmt.Sprintf("**Model**: %s"+nl, s.Model)) + } + sb.WriteString(nl) + + // Token stats + sb.WriteString(fmt.Sprintf("**Turns**: %d"+nl, s.TurnCount)) + sb.WriteString(fmt.Sprintf("**Tokens**: %s (in: %s, out: %s)"+nl, + formatTokens(s.TotalTokens), + formatTokens(s.TotalTokensIn), + formatTokens(s.TotalTokensOut))) + sb.WriteString(nl + sep + nl + nl) + + // Summary section (placeholder for user to fill in) + sb.WriteString("## Summary" + nl + nl) + sb.WriteString("[Add your summary of this session]" + nl + nl) + sb.WriteString(sep + nl + nl) + + // Tool usage summary + tools := s.AllToolUses() + if len(tools) > 0 { + sb.WriteString("## Tool Usage" + nl + nl) + toolCounts := make(map[string]int) + for _, t := range tools { + toolCounts[t.Name]++ + } + for name, count := range toolCounts { + sb.WriteString(fmt.Sprintf("- %s: %d"+nl, name, count)) + } + sb.WriteString(nl + sep + nl + nl) + } + + // Conversation (skip empty messages, use local time) + sb.WriteString("## Conversation" + nl + nl) + msgNum := 0 + for _, msg := range s.Messages { + // Skip empty messages + if isEmptyMessage(msg) { + continue + } + + msgNum++ + role := "User" + if msg.IsAssistant() { + role = "Assistant" + } else if len(msg.ToolResults) > 0 && msg.Text == "" { + // User messages with only tool results are system responses, not user input + role = "Tool Output" + } + + localTime := msg.Timestamp.Local() + sb.WriteString(fmt.Sprintf("### %d. %s (%s)"+nl+nl, + msgNum, role, localTime.Format("15:04:05"))) + + if msg.Text != "" { + sb.WriteString(msg.Text + nl + nl) + } + + // Tool uses + for _, t := range msg.ToolUses { + sb.WriteString(fmt.Sprintf("🔧 **%s**"+nl, formatToolUse(t))) + } + + // Tool results (these contain command output, file contents, etc.) + for _, tr := range msg.ToolResults { + if tr.IsError { + sb.WriteString("❌ Error" + nl) + } + if tr.Content != "" { + content := stripLineNumbers(tr.Content) + sb.WriteString(fmt.Sprintf("```"+nl+"%s"+nl+"```"+nl, content)) + } + } + + if len(msg.ToolUses) > 0 || len(msg.ToolResults) > 0 { + sb.WriteString(nl) + } + } + + return sb.String() +} diff --git a/internal/cli/recall/recall.go b/internal/cli/recall/recall.go index bd853357f..d95819ea4 100644 --- a/internal/cli/recall/recall.go +++ b/internal/cli/recall/recall.go @@ -29,17 +29,19 @@ list sessions, view details, and search across your conversation history. Subcommands: list List all parsed sessions show Show details of a specific session - serve Start web server for browsing (coming soon) + export Export sessions to editable journal files Examples: ctx recall list ctx recall list --limit 5 ctx recall show abc123 - ctx recall show --latest`, + ctx recall show --latest + ctx recall export --all`, } cmd.AddCommand(recallListCmd()) cmd.AddCommand(recallShowCmd()) + cmd.AddCommand(recallExportCmd()) return cmd } @@ -50,15 +52,16 @@ Examples: // - *cobra.Command: Command for listing parsed sessions func recallListCmd() *cobra.Command { var ( - limit int - project string - tool string + limit int + project string + tool string + allProjects bool ) cmd := &cobra.Command{ Use: "list", Short: "List all parsed sessions", - Long: `List all AI sessions found in ~/.claude/projects/ and other locations. + Long: `List AI sessions from the current project. Sessions are sorted by date (newest first) and display: - Session slug (human-friendly name) @@ -67,19 +70,24 @@ Sessions are sorted by date (newest first) and display: - Turn count (user messages) - Token usage +By default, only sessions from the current project are shown. +Use --all-projects to see sessions from all projects. + Examples: ctx recall list ctx recall list --limit 5 + ctx recall list --all-projects ctx recall list --project ctx ctx recall list --tool claude-code`, RunE: func(cmd *cobra.Command, args []string) error { - return runRecallList(cmd, limit, project, tool) + return runRecallList(cmd, limit, project, tool, allProjects) }, } cmd.Flags().IntVarP(&limit, "limit", "n", 20, "Maximum sessions to display") cmd.Flags().StringVarP(&project, "project", "p", "", "Filter by project name") cmd.Flags().StringVarP(&tool, "tool", "t", "", "Filter by tool (e.g., claude-code)") + cmd.Flags().BoolVar(&allProjects, "all-projects", false, "Include sessions from all projects") return cmd } @@ -90,8 +98,9 @@ Examples: // - *cobra.Command: Command for showing session details func recallShowCmd() *cobra.Command { var ( - latest bool - full bool + latest bool + full bool + allProjects bool ) cmd := &cobra.Command{ @@ -105,19 +114,22 @@ The session ID can be: - Session slug name Use --latest to show the most recent session. +By default, only searches sessions from the current project. Examples: ctx recall show abc123 ctx recall show gleaming-wobbling-sutherland ctx recall show --latest - ctx recall show --latest --full`, + ctx recall show --latest --full + ctx recall show abc123 --all-projects`, RunE: func(cmd *cobra.Command, args []string) error { - return runRecallShow(cmd, args, latest, full) + return runRecallShow(cmd, args, latest, full, allProjects) }, } cmd.Flags().BoolVar(&latest, "latest", false, "Show the most recent session") cmd.Flags().BoolVar(&full, "full", false, "Show full message content") + cmd.Flags().BoolVar(&allProjects, "all-projects", false, "Search sessions from all projects") return cmd } diff --git a/internal/cli/recall/run.go b/internal/cli/recall/run.go index 91dcd4bd2..b022ca66d 100644 --- a/internal/cli/recall/run.go +++ b/internal/cli/recall/run.go @@ -9,18 +9,16 @@ package recall import ( "encoding/json" "fmt" - "regexp" + "os" "strings" "github.com/fatih/color" "github.com/spf13/cobra" + "github.com/ActiveMemory/ctx/internal/config" "github.com/ActiveMemory/ctx/internal/recall/parser" ) -// lineNumberPattern matches Claude Code's line number prefixes like " 1→" -var lineNumberPattern = regexp.MustCompile(`(?m)^\s*\d+→`) - // runRecallList handles the recall list command. // // Finds all sessions, applies optional filters, and displays them in a @@ -31,19 +29,36 @@ var lineNumberPattern = regexp.MustCompile(`(?m)^\s*\d+→`) // - limit: Maximum sessions to display (0 for unlimited) // - project: Filter by project name (case-insensitive substring match) // - tool: Filter by tool identifier (exact match) +// - allProjects: If true, include sessions from all projects // // Returns: // - error: Non-nil if session scanning fails -func runRecallList(cmd *cobra.Command, limit int, project, tool string) error { - sessions, err := parser.FindSessions() +func runRecallList(cmd *cobra.Command, limit int, project, tool string, allProjects bool) error { + var sessions []*parser.Session + var err error + + if allProjects { + sessions, err = parser.FindSessions() + } else { + cwd, cwdErr := os.Getwd() + if cwdErr != nil { + return fmt.Errorf("failed to get working directory: %w", cwdErr) + } + sessions, err = parser.FindSessionsForCWD(cwd) + } if err != nil { return fmt.Errorf("failed to find sessions: %w", err) } if len(sessions) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "No sessions found.") - fmt.Fprintln(cmd.OutOrStdout(), "") - fmt.Fprintln(cmd.OutOrStdout(), "Sessions are stored in ~/.claude/projects/") + if allProjects { + cmd.Println("No sessions found.") + cmd.Println("") + cmd.Println("Sessions are stored in ~/.claude/projects/") + } else { + cmd.Println("No sessions found for this project.") + cmd.Println("Use --all-projects to see sessions from all projects.") + } return nil } @@ -134,17 +149,32 @@ func runRecallList(cmd *cobra.Command, limit int, project, tool string) error { // - args: Session ID or slug to show (ignored if latest is true) // - latest: If true, show the most recent session // - full: If true, show complete conversation instead of preview +// - allProjects: If true, search sessions from all projects // // Returns: // - error: Non-nil if session not found or scanning fails -func runRecallShow(cmd *cobra.Command, args []string, latest, full bool) error { - sessions, err := parser.FindSessions() +func runRecallShow(cmd *cobra.Command, args []string, latest, full, allProjects bool) error { + var sessions []*parser.Session + var err error + + if allProjects { + sessions, err = parser.FindSessions() + } else { + cwd, cwdErr := os.Getwd() + if cwdErr != nil { + return fmt.Errorf("failed to get working directory: %w", cwdErr) + } + sessions, err = parser.FindSessionsForCWD(cwd) + } if err != nil { return fmt.Errorf("failed to find sessions: %w", err) } if len(sessions) == 0 { - return fmt.Errorf("no sessions found") + if allProjects { + return fmt.Errorf("no sessions found") + } + return fmt.Errorf("no sessions found for this project; use --all-projects to search all") } var session *parser.Session @@ -233,6 +263,10 @@ func runRecallShow(cmd *cobra.Command, args []string, latest, full bool) error { if msg.IsAssistant() { role = "Assistant" roleColor = color.New(color.FgGreen, color.Bold) + } else if len(msg.ToolResults) > 0 && msg.Text == "" { + // User messages with only tool results are system responses + role = "Tool Output" + roleColor = color.New(color.FgYellow) } roleColor.Fprintf(cmd.OutOrStdout(), "### %d. %s ", i+1, role) @@ -342,7 +376,7 @@ func formatTokens(tokens int) string { // Returns: // - string: Content with line number prefixes removed func stripLineNumbers(content string) string { - return lineNumberPattern.ReplaceAllString(content, "") + return config.RegExLineNumber.ReplaceAllString(content, "") } // formatToolUse formats a tool invocation with its key parameters. diff --git a/internal/cli/serve/serve.go b/internal/cli/serve/serve.go new file mode 100644 index 000000000..778e77d24 --- /dev/null +++ b/internal/cli/serve/serve.go @@ -0,0 +1,97 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package serve + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/rc" +) + +// Cmd returns the serve command. +// +// Serves a static site by invoking zensical serve on the specified directory. +// +// Returns: +// - *cobra.Command: The serve command +func Cmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "serve [directory]", + Short: "Serve a static site locally via zensical", + Long: `Serve a static site using zensical. + +If no directory is specified, serves the journal site (.context/journal-site). + +Requires zensical to be installed: + pip install zensical + +Examples: + ctx serve # Serve journal site + ctx serve .context/journal-site # Serve specific directory + ctx serve ./docs # Serve docs folder`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runServe(cmd, args) + }, + } + + return cmd +} + +// runServe handles the serve command. +// +// Parameters: +// - cmd: Cobra command for output stream +// - args: Optional directory to serve +// +// Returns: +// - error: Non-nil if zensical is not found or fails +func runServe(cmd *cobra.Command, args []string) error { + var dir string + + if len(args) > 0 { + dir = args[0] + } else { + // Default: journal site + dir = filepath.Join(rc.GetContextDir(), "journal-site") + } + + // Verify directory exists + info, err := os.Stat(dir) + if err != nil { + return fmt.Errorf("directory not found: %s", dir) + } + if !info.IsDir() { + return fmt.Errorf("not a directory: %s", dir) + } + + // Check zensical.toml exists + tomlPath := filepath.Join(dir, "zensical.toml") + if _, err := os.Stat(tomlPath); os.IsNotExist(err) { + return fmt.Errorf("no zensical.toml found in %s", dir) + } + + // Check if zensical is available + _, err = exec.LookPath("zensical") + if err != nil { + return fmt.Errorf("zensical not found. Install with: pip install zensical") + } + + // Run zensical serve + zensical := exec.Command("zensical", "serve") + zensical.Dir = dir + zensical.Stdout = os.Stdout + zensical.Stderr = os.Stderr + zensical.Stdin = os.Stdin + + return zensical.Run() +} diff --git a/internal/cli/session/build.go b/internal/cli/session/build.go index de36bed98..9d505f7c7 100644 --- a/internal/cli/session/build.go +++ b/internal/cli/session/build.go @@ -11,6 +11,8 @@ import ( "os" "strings" "time" + + "github.com/ActiveMemory/ctx/internal/config" ) // buildSessionContent creates the Markdown content for a session file. @@ -31,12 +33,14 @@ func buildSessionContent( topic, sessionType string, timestamp time.Time, ) (string, error) { var sb strings.Builder + nl := config.NewlineLF + sep := config.Separator // Header with timestamp fields for session correlation - sb.WriteString(fmt.Sprintf("# Session: %s\n\n", topic)) - sb.WriteString(fmt.Sprintf("**Date**: %s\n", timestamp.Format("2006-01-02"))) - sb.WriteString(fmt.Sprintf("**Time**: %s\n", timestamp.Format("15:04:05"))) - sb.WriteString(fmt.Sprintf("**Type**: %s\n", sessionType)) + sb.WriteString(fmt.Sprintf("# Session: %s"+nl+nl, topic)) + sb.WriteString(fmt.Sprintf("**Date**: %s"+nl, timestamp.Format("2006-01-02"))) + sb.WriteString(fmt.Sprintf("**Time**: %s"+nl, timestamp.Format("15:04:05"))) + sb.WriteString(fmt.Sprintf("**Type**: %s"+nl, sessionType)) // Session correlation timestamps // (YYYY-MM-DD-HHMM format matches ctx add timestamps) @@ -49,61 +53,61 @@ func buildSessionContent( } } sb.WriteString( - fmt.Sprintf("**start_time**: %s\n", startTime.Format("2006-01-02-1504")), + fmt.Sprintf("**start_time**: %s"+nl, startTime.Format("2006-01-02-1504")), ) sb.WriteString( - fmt.Sprintf("**end_time**: %s\n", timestamp.Format("2006-01-02-1504")), + fmt.Sprintf("**end_time**: %s"+nl, timestamp.Format("2006-01-02-1504")), ) - sb.WriteString("\n---\n\n") + sb.WriteString(nl + sep + nl + nl) // Summary section (placeholder for the user to fill in) - sb.WriteString("## Summary\n\n") - sb.WriteString("[Describe what was accomplished in this session]\n\n") - sb.WriteString("---\n\n") + sb.WriteString("## Summary" + nl + nl) + sb.WriteString("[Describe what was accomplished in this session]" + nl + nl) + sb.WriteString(sep + nl + nl) // Current Tasks - sb.WriteString("## Current Tasks\n\n") + sb.WriteString("## Current Tasks" + nl + nl) tasks, err := readContextSection( "TASKS.md", "## In Progress", "## Next Up", ) if err == nil && tasks != "" { - sb.WriteString("### In Progress\n\n") + sb.WriteString("### In Progress" + nl + nl) sb.WriteString(tasks) - sb.WriteString("\n") + sb.WriteString(nl) } nextTasks, err := readContextSection( "TASKS.md", "## Next Up", "## Completed", ) if err == nil && nextTasks != "" { - sb.WriteString("### Next Up\n\n") + sb.WriteString("### Next Up" + nl + nl) sb.WriteString(nextTasks) - sb.WriteString("\n") + sb.WriteString(nl) } - sb.WriteString("---\n\n") + sb.WriteString(sep + nl + nl) // Recent Decisions - sb.WriteString("## Recent Decisions\n\n") + sb.WriteString("## Recent Decisions" + nl + nl) decisions, err := readRecentDecisions() if err == nil && decisions != "" { sb.WriteString(decisions) } else { - sb.WriteString("[No recent decisions found]\n") + sb.WriteString("[No recent decisions found]" + nl) } - sb.WriteString("\n---\n\n") + sb.WriteString(nl + sep + nl + nl) // Recent Learnings - sb.WriteString("## Recent Learnings\n\n") + sb.WriteString("## Recent Learnings" + nl + nl) learnings, err := readRecentLearnings() if err == nil && learnings != "" { sb.WriteString(learnings) } else { - sb.WriteString("[No recent learnings found]\n") + sb.WriteString("[No recent learnings found]" + nl) } - sb.WriteString("\n---\n\n") + sb.WriteString(nl + sep + nl + nl) // Tasks for Next Session - sb.WriteString("## Tasks for Next Session\n\n") - sb.WriteString("[List tasks to continue in the next session]\n\n") + sb.WriteString("## Tasks for Next Session" + nl + nl) + sb.WriteString("[List tasks to continue in the next session]" + nl + nl) return sb.String(), nil } diff --git a/internal/cli/session/extract.go b/internal/cli/session/extract.go index b556e7f4a..67bc381e4 100644 --- a/internal/cli/session/extract.go +++ b/internal/cli/session/extract.go @@ -11,7 +11,8 @@ import ( "encoding/json" "fmt" "os" - "regexp" + + "github.com/ActiveMemory/ctx/internal/config" ) // extractInsights parses a JSONL transcript and extracts potential decisions @@ -43,25 +44,6 @@ func extractInsights(path string) ([]string, []string, error) { var decisions []string var learnings []string - // Patterns for detecting decisions - decisionPatterns := []*regexp.Regexp{ - regexp.MustCompile(`(?i)decided to\s+(.{20,100})`), - regexp.MustCompile(`(?i)decision:\s*(.{20,100})`), - regexp.MustCompile(`(?i)we('ll| will) use\s+(.{10,80})`), - regexp.MustCompile(`(?i)going with\s+(.{10,80})`), - regexp.MustCompile(`(?i)chose\s+(.{10,80})\s+(over|instead)`), - } - - // Patterns for detecting learnings - learningPatterns := []*regexp.Regexp{ - regexp.MustCompile(`(?i)learned that\s+(.{20,100})`), - regexp.MustCompile(`(?i)gotcha:\s*(.{20,100})`), - regexp.MustCompile(`(?i)lesson:\s*(.{20,100})`), - regexp.MustCompile(`(?i)TIL:?\s*(.{20,100})`), - regexp.MustCompile(`(?i)turns out\s+(.{20,100})`), - regexp.MustCompile(`(?i)important to (note|remember):\s*(.{20,100})`), - } - scanner := bufio.NewScanner(file) buf := make([]byte, 0, 64*1024) scanner.Buffer(buf, 10*1024*1024) @@ -89,7 +71,7 @@ func extractInsights(path string) ([]string, []string, error) { for _, text := range texts { // Check for decisions - for _, pattern := range decisionPatterns { + for _, pattern := range config.RegExDecisionPatterns { matches := pattern.FindAllStringSubmatch(text, -1) for _, match := range matches { if len(match) > 1 { @@ -103,7 +85,7 @@ func extractInsights(path string) ([]string, []string, error) { } // Check for learnings - for _, pattern := range learningPatterns { + for _, pattern := range config.RegExLearningPatterns { matches := pattern.FindAllStringSubmatch(text, -1) for _, match := range matches { if len(match) > 1 { diff --git a/internal/cli/session/fs.go b/internal/cli/session/fs.go new file mode 100644 index 000000000..c765c23ae --- /dev/null +++ b/internal/cli/session/fs.go @@ -0,0 +1,22 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package session + +import ( + "path/filepath" + + "github.com/ActiveMemory/ctx/internal/config" + "github.com/ActiveMemory/ctx/internal/rc" +) + +// sessionsDirPath returns the path to the `sessions` directory. +// +// Returns: +// - string: Full path to .context/sessions/ +func sessionsDirPath() string { + return filepath.Join(rc.GetContextDir(), config.DirSessions) +} diff --git a/internal/cli/session/parse.go b/internal/cli/session/parse.go index 99b6d47ea..ba2ba6e06 100644 --- a/internal/cli/session/parse.go +++ b/internal/cli/session/parse.go @@ -13,6 +13,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/ActiveMemory/ctx/internal/config" ) // parseIndex attempts to parse a string as a positive integer index. @@ -59,9 +61,10 @@ func parseJsonlTranscript(path string) (string, error) { }(file) var sb strings.Builder - sb.WriteString("# Conversation Transcript\n\n") - sb.WriteString(fmt.Sprintf("**Source**: %s\n\n", filepath.Base(path))) - sb.WriteString("---\n\n") + nl := config.NewlineLF + sb.WriteString("# Conversation Transcript" + nl + nl) + sb.WriteString(fmt.Sprintf("**Source**: %s"+nl+nl, filepath.Base(path))) + sb.WriteString(config.Separator + nl + nl) scanner := bufio.NewScanner(file) // Increase buffer size for large lines @@ -90,7 +93,7 @@ func parseJsonlTranscript(path string) (string, error) { formatted := formatTranscriptEntry(entry) if formatted != "" { sb.WriteString(formatted) - sb.WriteString("\n---\n\n") + sb.WriteString(nl + config.Separator + nl + nl) } } @@ -98,7 +101,7 @@ func parseJsonlTranscript(path string) (string, error) { return "", fmt.Errorf("error reading file: %w", err) } - sb.WriteString(fmt.Sprintf("*Total messages: %d*\n", messageCount)) + sb.WriteString(fmt.Sprintf("*Total messages: %d*"+nl, messageCount)) return sb.String(), nil } @@ -160,7 +163,7 @@ func parseSessionFile(path string) (sessionInfo, error) { for _, line := range lines { line = strings.TrimSpace(line) if line != "" && !strings.HasPrefix(line, "#") && - !strings.HasPrefix(line, "---") && + !strings.HasPrefix(line, config.Separator) && !strings.HasPrefix(line, "[") { info.Summary = line break diff --git a/internal/cli/session/read.go b/internal/cli/session/read.go index 26c00758a..9fb10fd86 100644 --- a/internal/cli/session/read.go +++ b/internal/cli/session/read.go @@ -10,12 +10,29 @@ import ( "fmt" "os" "path/filepath" - "regexp" "strings" "github.com/ActiveMemory/ctx/internal/config" + "github.com/ActiveMemory/ctx/internal/rc" ) +// readContextFile reads a context file and returns its content as a string. +// +// Parameters: +// - fileName: Name of the file in .context/ (e.g., "DECISIONS.md") +// +// Returns: +// - string: File content +// - error: Non-nil if the file cannot be read +func readContextFile(fileName string) (string, error) { + filePath := filepath.Join(rc.GetContextDir(), fileName) + content, err := os.ReadFile(filePath) + if err != nil { + return "", err + } + return string(content), nil +} + // readContextSection reads a section from a context file between two headers. // // Extracts text between a start header and an optional end header from a @@ -25,7 +42,8 @@ import ( // Parameters: // - filename: Name of the file in .context/ (e.g., "TASKS.md") // - startHeader: Header marking the section start (e.g., "## In Progress") -// - endHeader: Header marking the section end, or empty string for end of file +// - endHeader: Header marking the section end, or empty string for +// the end of the file // // Returns: // - string: Trimmed content between the headers @@ -34,7 +52,7 @@ import ( func readContextSection( filename, startHeader, endHeader string, ) (string, error) { - filePath := filepath.Join(config.ContextDir(), filename) + filePath := filepath.Join(rc.GetContextDir(), filename) content, err := os.ReadFile(filePath) if err != nil { return "", err @@ -71,24 +89,19 @@ func readContextSection( // - string: Formatted list of recent decision titles, or empty if none found // - error: Non-nil if DECISIONS.md cannot be read func readRecentDecisions() (string, error) { - filePath := filepath.Join(config.ContextDir(), config.FilenameDecision) - content, err := os.ReadFile(filePath) + contentStr, err := readContextFile(config.FileDecision) if err != nil { return "", err } - contentStr := string(content) - // Find decision headers (## [YYYY-MM-DD] Title) - re := regexp.MustCompile(`(?m)^## \[\d{4}-\d{2}-\d{2}].*$`) - matches := re.FindAllStringIndex(contentStr, -1) - + matches := config.RegExDecision.FindAllStringIndex(contentStr, -1) if len(matches) == 0 { return "", nil } // Get the last 3 decisions (most recent) - limit := 3 + limit := config.MaxDecisionsToSummarize if len(matches) < limit { limit = len(matches) } @@ -102,13 +115,13 @@ func readRecentDecisions() (string, error) { } decision := strings.TrimSpace(contentStr[start:end]) // Only include the header for brevity - headerEnd := strings.Index(decision, "\n") + headerEnd := strings.Index(decision, config.NewlineLF) if headerEnd != -1 { decisions = append(decisions, "- "+decision[:headerEnd]) } } - return strings.Join(decisions, "\n"), nil + return strings.Join(decisions, config.NewlineLF), nil } // readRecentLearnings extracts the most recent learnings from LEARNINGS.md. @@ -120,27 +133,22 @@ func readRecentDecisions() (string, error) { // - string: Formatted list of recent learnings, or empty if none found // - error: Non-nil if LEARNINGS.md cannot be read func readRecentLearnings() (string, error) { - filePath := filepath.Join(config.ContextDir(), config.FilenameLearning) - content, err := os.ReadFile(filePath) + contentStr, err := readContextFile(config.FileLearning) if err != nil { return "", err } - contentStr := string(content) - // Find learning entries (- **[YYYY-MM-DD]** text) - re := regexp.MustCompile(`(?m)^- \*\*\[\d{4}-\d{2}-\d{2}]\*\*.*$`) - matches := re.FindAllString(contentStr, -1) - + matches := config.RegExLearning.FindAllString(contentStr, -1) if len(matches) == 0 { return "", nil } // Get the last 5 learnings (most recent) - limit := 5 + limit := config.MaxLearningsToSummarize if len(matches) < limit { limit = len(matches) } - return strings.Join(matches[len(matches)-limit:], "\n"), nil + return strings.Join(matches[len(matches)-limit:], config.NewlineLF), nil } diff --git a/internal/cli/session/run.go b/internal/cli/session/run.go index 792534492..5407d3663 100644 --- a/internal/cli/session/run.go +++ b/internal/cli/session/run.go @@ -13,9 +13,10 @@ import ( "strings" "time" - "github.com/ActiveMemory/ctx/internal/validation" "github.com/fatih/color" "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/validation" ) // runSessionLoad loads and displays a saved session file. @@ -73,7 +74,7 @@ func runSessionLoad(cmd *cobra.Command, args []string) error { // - extract: If true, extract decisions/learnings instead of full transcript // // Returns: -// - error: Non-nil if the file not found, parse fails, or write fails +// - error: Non-nil if the file is not found, parse fails, or write fails func runSessionParse( cmd *cobra.Command, args []string, output string, extract bool, ) error { @@ -203,8 +204,8 @@ func runSessionSave( // runSessionList lists saved sessions with summaries. // // Reads all session files from .context/sessions/, parses their metadata, -// and displays them sorted by date (newest first). Output includes topic, -// date, type, summary, and filename for each session. +// and displays them sorted by date (the newest first). Output includes +// the topic, date, type, summary, and filename for each session. // // Parameters: // - cmd: Cobra command for output diff --git a/internal/cli/session/session.go b/internal/cli/session/session.go index 6391bfdfc..63d6f9a9f 100644 --- a/internal/cli/session/session.go +++ b/internal/cli/session/session.go @@ -7,21 +7,9 @@ package session import ( - "path/filepath" - "github.com/spf13/cobra" - - "github.com/ActiveMemory/ctx/internal/config" ) -// sessionsDirPath returns the path to the sessions directory. -// -// Returns: -// - string: Full path to .context/sessions/ -func sessionsDirPath() string { - return filepath.Join(config.ContextDir(), config.DirSessions) -} - // Cmd returns the session command with subcommands. // // Provides commands for managing session snapshots that capture diff --git a/internal/cli/status/out.go b/internal/cli/status/out.go index 162a4f1e3..1c4d6d912 100644 --- a/internal/cli/status/out.go +++ b/internal/cli/status/out.go @@ -47,7 +47,7 @@ func outputStatusJSON( ModTime: f.ModTime.Format(time.RFC3339), } if verbose && !f.IsEmpty { - fs.Preview = getContentPreview(string(f.Content), 5) + fs.Preview = contentPreview(string(f.Content), 5) } output.Files = append(output.Files, fs) } @@ -113,7 +113,7 @@ func outputStatusText( // Show content preview for non-empty files if !f.IsEmpty { - preview := getContentPreview(string(f.Content), 3) + preview := contentPreview(string(f.Content), 3) for _, line := range preview { cmd.Printf(" %s\n", dim(line)) } diff --git a/internal/cli/status/preview.go b/internal/cli/status/preview.go index bfb6eeee4..687783258 100644 --- a/internal/cli/status/preview.go +++ b/internal/cli/status/preview.go @@ -6,9 +6,13 @@ package status -import "strings" +import ( + "strings" -// getContentPreview returns the first n non-empty, meaningful lines + "github.com/ActiveMemory/ctx/internal/config" +) + +// contentPreview returns the first n non-empty, meaningful lines // from content. // // Skips empty lines, YAML frontmatter delimiters, and HTML comments. @@ -20,8 +24,8 @@ import "strings" // // Returns: // - []string: Up to n meaningful lines from the content -func getContentPreview(content string, n int) []string { - lines := strings.Split(content, "\n") +func contentPreview(content string, n int) []string { + lines := strings.Split(content, config.NewlineLF) var preview []string inFrontmatter := false @@ -34,7 +38,7 @@ func getContentPreview(content string, n int) []string { } // Skip YAML frontmatter - if trimmed == "---" { + if trimmed == config.Separator { inFrontmatter = !inFrontmatter continue } @@ -43,13 +47,14 @@ func getContentPreview(content string, n int) []string { } // Skip HTML comments - if strings.HasPrefix(trimmed, "" - CtxMarkerStart = "" - DirArchive = "archive" - DirClaude = ".claude" - DirClaudeHooks = ".claude/hooks" - DirContext = ".context" - DirSessions = "sessions" - FileAutoSave = "auto-save-session.sh" - FileBlockNonPathScript = "block-non-path-ctx.sh" - FileClaudeMd = "CLAUDE.md" - FileSettings = ".claude/settings.local.json" -) +package config -// Global flag values set by CLI -var ( - // contextDirOverride is set by --context-dir flag - contextDirOverride string - // Quiet suppresses non-essential output when true - Quiet bool -) +// MaxDecisionsToSummarize is the number of recent decisions to include +// in summaries. +const MaxDecisionsToSummarize = 3 -// SetContextDir sets the context directory override. -// -// Parameters: -// - dir: Directory path to use as override -func SetContextDir(dir string) { - contextDirOverride = dir -} +// MaxLearningsToSummarize is the number of recent learnings to include +// in summaries. +const MaxLearningsToSummarize = 5 -// ContextDir returns the context directory path. -// -// Returns the --context-dir override if set, otherwise the default ".context". -// -// Returns: -// - string: The context directory path -func ContextDir() string { - if contextDirOverride != "" { - return contextDirOverride - } - return DirContext -} +// MaxPreviewLen is the maximum length for preview lines before truncation. +const MaxPreviewLen = 60 // WatchAutoSaveInterval is the number of updates between auto-saves // in watch mode. const WatchAutoSaveInterval = 5 - -// Context file name constants. -const ( - FilenameConstitution = "CONSTITUTION.md" - FilenameTask = "TASKS.md" - FilenameConvention = "CONVENTIONS.md" - FilenameArchitecture = "ARCHITECTURE.md" - FilenameDecision = "DECISIONS.md" - FilenameLearning = "LEARNINGS.md" - FilenameGlossary = "GLOSSARY.md" - FilenameDrift = "DRIFT.md" - FilenameAgentPlaybook = "AGENT_PLAYBOOK.md" - FilenameDependency = "DEPENDENCIES.md" -) - -// Update type constants for context entries. -// -// These are used in switch statements for routing add/update commands -// to the appropriate handler. -const ( - UpdateTypeTask = "task" - UpdateTypeDecision = "decision" - UpdateTypeLearning = "learning" - UpdateTypeConvention = "convention" - UpdateTypeComplete = "complete" -) - -// Plural aliases for update types. -// -// Accepted as synonyms for the singular forms. -const ( - UpdateTypeTasks = "tasks" - UpdateTypeDecisions = "decisions" - UpdateTypeLearnings = "learnings" - UpdateTypeConventions = "conventions" -) - -// FileType maps short names to actual file names. -var FileType = map[string]string{ - UpdateTypeDecision: FilenameDecision, - UpdateTypeDecisions: FilenameDecision, - UpdateTypeTask: FilenameTask, - UpdateTypeTasks: FilenameTask, - UpdateTypeLearning: FilenameLearning, - UpdateTypeLearnings: FilenameLearning, - UpdateTypeConvention: FilenameConvention, - UpdateTypeConventions: FilenameConvention, -} - -// RequiredFiles lists the essential context files that must be present. -// -// These are the files created with `ctx init --minimal` and checked by -// drift detection for missing files. -var RequiredFiles = []string{ - FilenameConstitution, - FilenameTask, - FilenameDecision, -} - -// FileReadOrder defines the priority order for reading context files. -// -// The order follows a logical progression for AI agents: -// -// 1. CONSTITUTION — Inviolable rules. Must be loaded first so the agent -// knows what it cannot do before attempting anything. -// -// 2. TASKS — Current work items. What the agent should focus on. -// -// 3. CONVENTIONS — How to write code. Patterns and standards to follow. -// -// 4. ARCHITECTURE — System structure. Understanding of components and -// boundaries before making changes. -// -// 5. DECISIONS — Historical context. Why things are the way they are, -// to avoid re-debating settled decisions. -// -// 6. LEARNINGS — Gotchas and tips. Lessons from past work that inform -// current implementation. -// -// 7. GLOSSARY — Reference material. Domain terms and abbreviations for -// lookup as needed. -// -// 8. DRIFT — Staleness indicators. Lower priority since it's primarily -// for maintenance workflows. -// -// 9. AGENT_PLAYBOOK — Meta instructions. How to use this context system. -// Loaded last because it's about the system itself, not the work. -// The agent should understand the content before the operating manual. -var FileReadOrder = []string{ - FilenameConstitution, - FilenameTask, - FilenameConvention, - FilenameArchitecture, - FilenameDecision, - FilenameLearning, - FilenameGlossary, - FilenameDrift, - FilenameAgentPlaybook, -} - -// filePriority maps filenames to their priority (derived from FileReadOrder). -// This is initialized at startup; use FilePriority() which checks for .contextrc overrides. -var filePriority = func() map[string]int { - m := make(map[string]int, len(FileReadOrder)) - for i, name := range FileReadOrder { - m[name] = i + 1 - } - return m -}() - -// Packages maps dependency manifest files to their descriptions. -// -// Used by sync to detect projects and suggest dependency documentation. -var Packages = map[string]string{ - "package.json": "Node.js dependencies", - "go.mod": "Go module dependencies", - "Cargo.toml": "Rust dependencies", - "requirements.txt": "Python dependencies", - "Gemfile": "Ruby dependencies", -} - -// Pattern represents a config file pattern and its documentation topic. -// -// Fields: -// - Pattern: Glob pattern to match (e.g., ".eslintrc*") -// - Topic: Documentation topic (e.g., "linting conventions") -type Pattern struct { - Pattern string - Topic string -} - -// Patterns lists config files that should be documented in CONVENTIONS.md. -// -// Used by sync to suggest documenting project configuration. -var Patterns = []Pattern{ - {".eslintrc*", "linting conventions"}, - {".prettierrc*", "formatting conventions"}, - {"tsconfig.json", "TypeScript configuration"}, - {".editorconfig", "editor configuration"}, - {"Makefile", "build commands"}, - {"Dockerfile", "containerization"}, -} - -// FilePriority returns the priority of a context file. -// -// If a priority_order is configured in .contextrc, that order is used. -// Otherwise, the default FileReadOrder is used. -// -// Lower numbers indicate higher priority (1 = highest). -// Unknown files return 100. -// -// Parameters: -// - name: Filename to look up (e.g., "TASKS.md") -// -// Returns: -// - int: Priority value (1-9 for known files, 100 for unknown) -func FilePriority(name string) int { - // Check for .contextrc override - if order := GetPriorityOrder(); order != nil { - for i, fname := range order { - if fname == name { - return i + 1 - } - } - // File not in custom order gets lowest priority - return 100 - } - - // Use default priority - if p, ok := filePriority[name]; ok { - return p - } - return 100 -} diff --git a/internal/config/dir.go b/internal/config/dir.go new file mode 100644 index 000000000..c4cf8cec5 --- /dev/null +++ b/internal/config/dir.go @@ -0,0 +1,21 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package config + +// Directory path constants used throughout the application. +const ( + // DirArchive is the subdirectory for archived tasks within .context/. + DirArchive = "archive" + // DirClaude is the Claude Code configuration directory in the project root. + DirClaude = ".claude" + // DirClaudeHooks is the hooks subdirectory within .claude/. + DirClaudeHooks = ".claude/hooks" + // DirContext is the default context directory name. + DirContext = ".context" + // DirSessions is the subdirectory for session snapshots within .context/. + DirSessions = "sessions" +) diff --git a/internal/config/entry.go b/internal/config/entry.go new file mode 100644 index 000000000..bac28ebda --- /dev/null +++ b/internal/config/entry.go @@ -0,0 +1,54 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2. + +package config + +import "strings" + +// Entry type constants for context updates. +// +// These are the canonical internal representations used in switch statements +// for routing add/update commands to the appropriate handler. +const ( + // EntryTask represents a task entry in TASKS.md. + EntryTask = "task" + // EntryDecision represents an architectural decision in DECISIONS.md. + EntryDecision = "decision" + // EntryLearning represents a lesson learned in LEARNINGS.md. + EntryLearning = "learning" + // EntryConvention represents a code pattern in CONVENTIONS.md. + EntryConvention = "convention" + // EntryComplete represents a task completion action (marks the task as done). + EntryComplete = "complete" + // EntryUnknown is returned when user input doesn't match any known type. + EntryUnknown = "unknown" +) + +// UserInputToEntry normalizes user input to a canonical entry type. +// +// Accepts both singular and plural forms (e.g., "task" or "tasks") and +// returns the canonical singular form. Matching is case-insensitive. +// Unknown inputs return EntryUnknown. +// +// Parameters: +// - s: User-provided entry type string +// +// Returns: +// - string: Canonical entry type constant (EntryTask, EntryDecision, etc.) +func UserInputToEntry(s string) string { + switch strings.ToLower(s) { + case "task", "tasks": + return EntryTask + case "decision", "decisions": + return EntryDecision + case "learning", "learnings": + return EntryLearning + case "convention", "conventions": + return EntryConvention + default: + return EntryUnknown + } +} diff --git a/internal/config/field.go b/internal/config/field.go new file mode 100644 index 000000000..e5ac7c6ba --- /dev/null +++ b/internal/config/field.go @@ -0,0 +1,26 @@ +// / Context: https://ctx.ist +// +// ,'`./ do you remember? +// +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2. + +package config + +// Field name constants for structured entry attributes. +// +// These are used in validation error messages and as attribute names +// in context-update XML tags for decisions and learnings. +const ( + // FieldContext is the background/situation field for decisions and learnings. + FieldContext = "context" + // FieldRationale is the reasoning field for decisions (why this choice). + FieldRationale = "rationale" + // FieldConsequence is the outcomes field for decisions (what changes). + FieldConsequence = "consequences" + // FieldApplication is the usage field for learnings (how to apply going forward). + FieldApplication = "application" + // FieldLesson is the insight field for learnings (the key takeaway). + FieldLesson = "lesson" +) diff --git a/internal/config/file.go b/internal/config/file.go new file mode 100644 index 000000000..e460ff7b3 --- /dev/null +++ b/internal/config/file.go @@ -0,0 +1,117 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package config + +// Claude Code integration file names. +const ( + // FileAutoSave is the hook script for auto-saving sessions. + FileAutoSave = "auto-save-session.sh" + // FileBlockNonPathScript is the hook script that blocks non-PATH ctx + // invocations. + FileBlockNonPathScript = "block-non-path-ctx.sh" + // FilePromptCoach is the hook script for prompt quality feedback. + FilePromptCoach = "prompt-coach.sh" + // FileClaudeMd is the Claude Code configuration file in the project root. + FileClaudeMd = "CLAUDE.md" + // FileSettings is the Claude Code local settings file. + FileSettings = ".claude/settings.local.json" +) + +// Context file name constants for .context/ directory. +const ( + // FileConstitution contains inviolable rules for agents. + FileConstitution = "CONSTITUTION.md" + // FileTask contains current work items and their status. + FileTask = "TASKS.md" + // FileConvention contains code patterns and standards. + FileConvention = "CONVENTIONS.md" + // FileArchitecture contains system structure documentation. + FileArchitecture = "ARCHITECTURE.md" + // FileDecision contains architectural decisions with rationale. + FileDecision = "DECISIONS.md" + // FileLearning contains gotchas, tips, and lessons learned. + FileLearning = "LEARNINGS.md" + // FileGlossary contains domain terms and definitions. + FileGlossary = "GLOSSARY.md" + // FileDrift contains staleness indicators and drift detection results. + FileDrift = "DRIFT.md" + // FileAgentPlaybook contains the meta-instructions for using the + // context system. + FileAgentPlaybook = "AGENT_PLAYBOOK.md" + // FileDependency contains project dependency documentation. + FileDependency = "DEPENDENCIES.md" +) + +// FileType maps short names to actual file names. +var FileType = map[string]string{ + EntryDecision: FileDecision, + EntryTask: FileTask, + EntryLearning: FileLearning, + EntryConvention: FileConvention, +} + +// RequiredFiles lists the essential context files that must be present. +// +// These are the files created with `ctx init --minimal` and checked by +// drift detection for missing files. +var RequiredFiles = []string{ + FileConstitution, + FileTask, + FileDecision, +} + +// FileReadOrder defines the priority order for reading context files. +// +// The order follows a logical progression for AI agents: +// +// 1. CONSTITUTION — Inviolable rules. Must be loaded first so the agent +// knows what it cannot do before attempting anything. +// +// 2. TASKS — Current work items. What the agent should focus on. +// +// 3. CONVENTIONS — How to write code. Patterns and standards to follow. +// +// 4. ARCHITECTURE — System structure. Understanding of components and +// boundaries before making changes. +// +// 5. DECISIONS — Historical context. Why things are the way they are, +// to avoid re-debating settled decisions. +// +// 6. LEARNINGS — Gotchas and tips. Lessons from past work that inform +// current implementation. +// +// 7. GLOSSARY — Reference material. Domain terms and abbreviations for +// lookup as needed. +// +// 8. DRIFT — Staleness indicators. Lower priority since it's primarily +// for maintenance workflows. +// +// 9. AGENT_PLAYBOOK — Meta instructions. How to use this context system. +// Loaded last because it's about the system itself, not the work. +// The agent should understand the content before the operating manual. +var FileReadOrder = []string{ + FileConstitution, + FileTask, + FileConvention, + FileArchitecture, + FileDecision, + FileLearning, + FileGlossary, + FileDrift, + FileAgentPlaybook, +} + +// Packages maps dependency manifest files to their descriptions. +// +// Used by sync to detect projects and suggest dependency documentation. +var Packages = map[string]string{ + "package.json": "Node.js dependencies", + "go.mod": "Go module dependencies", + "Cargo.toml": "Rust dependencies", + "requirements.txt": "Python dependencies", + "Gemfile": "Ruby dependencies", +} diff --git a/internal/config/heading.go b/internal/config/heading.go new file mode 100644 index 000000000..404d6230c --- /dev/null +++ b/internal/config/heading.go @@ -0,0 +1,27 @@ +// / Context: https://ctx.ist +// +// ,'`./ do you remember? +// +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2. + +package config + +// Learnings +const ( + // HeadingLearningStart is the Markdown heading for entries in LEARNINGS.md + HeadingLearningStart = "## [" + // HeadingLearnings is the Markdown heading for LEARNINGs.md + HeadingLearnings = "# Learnings" + // ColumnLearning is the singular column header for learning index tables. + ColumnLearning = "Learning" +) + +// Decisions +const ( + // HeadingDecisions is the Markdown heading for DECISIONS.md + HeadingDecisions = "# Decisions" + // ColumnDecision is the singular column header for decision index tables. + ColumnDecision = "Decision" +) diff --git a/internal/config/marker.go b/internal/config/marker.go new file mode 100644 index 000000000..0d5acb292 --- /dev/null +++ b/internal/config/marker.go @@ -0,0 +1,39 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package config + +// HTML comment markers for parsing and generation. +const ( + // CommentOpen is the HTML comment opening tag. + CommentOpen = "" +) + +// Context block markers for embedding context in files. +const ( + // CtxMarkerStart marks the beginning of an embedded context block. + CtxMarkerStart = "" + // CtxMarkerEnd marks the end of an embedded context block. + CtxMarkerEnd = "" +) + +// Index markers for auto-generated table of contents sections. +const ( + // IndexStart marks the beginning of an auto-generated index. + IndexStart = "" + // IndexEnd marks the end of an auto-generated index. + IndexEnd = "" +) + +// Task checkbox prefixes for Markdown task lists. +const ( + // PrefixTaskUndone is the prefix for an unchecked task item. + PrefixTaskUndone = "- [ ]" + // PrefixTaskDone is the prefix for a checked (completed) task item. + PrefixTaskDone = "- [x]" +) diff --git a/internal/config/pattern.go b/internal/config/pattern.go new file mode 100644 index 000000000..9861cc93b --- /dev/null +++ b/internal/config/pattern.go @@ -0,0 +1,29 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2. + +package config + +// Pattern represents a config file pattern and its documentation topic. +// +// Fields: +// - Pattern: Glob pattern to match (e.g., ".eslintrc*") +// - Topic: Documentation topic (e.g., "linting conventions") +type Pattern struct { + Pattern string + Topic string +} + +// Patterns lists config files that should be documented in CONVENTIONS.md. +// +// Used by sync to suggest documenting project configuration. +var Patterns = []Pattern{ + {".eslintrc*", "linting conventions"}, + {".prettierrc*", "formatting conventions"}, + {"tsconfig.json", "TypeScript configuration"}, + {".editorconfig", "editor configuration"}, + {"Makefile", "build commands"}, + {"Dockerfile", "containerization"}, +} diff --git a/internal/config/regex.go b/internal/config/regex.go new file mode 100644 index 000000000..597afba83 --- /dev/null +++ b/internal/config/regex.go @@ -0,0 +1,119 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package config + +import "regexp" + +// RegExEntryHeader matches entry headers like "## [2026-01-28-051426] Title here". +// +// Groups: +// - 1: date (YYYY-MM-DD) +// - 2: time (HHMMSS) +// - 3: title +var RegExEntryHeader = regexp.MustCompile( + `## \[(\d{4}-\d{2}-\d{2})-(\d{6})] (.+)`, +) + +// RegExLineNumber matches Claude Code's line number prefixes like " 1→". +var RegExLineNumber = regexp.MustCompile(`(?m)^\s*\d+→`) + +// RegExPhase matches phase headers at any heading level (e.g., "## Phase 1", "### Phase"). +var RegExPhase = regexp.MustCompile(`^#{1,6}\s+Phase`) + +// RegExBulletItem matches any Markdown bullet item (not just tasks). +// +// Groups: +// - 1: item content +var RegExBulletItem = regexp.MustCompile(`(?m)^-\s*(.+)$`) + +// RegExDecision matches decision entry headers in multiline content. +// Use for finding decision positions without capturing groups. +var RegExDecision = regexp.MustCompile(`(?m)^## \[\d{4}-\d{2}-\d{2}-\d{6}].*$`) + +// RegExLearning matches learning entry headers in multiline content. +// Use for finding learning positions without capturing groups. +var RegExLearning = regexp.MustCompile(`(?m)^- \*\*\[\d{4}-\d{2}-\d{2}]\*\*.*$`) + +// RegExNonFileNameChar matches characters not allowed in file names. +var RegExNonFileNameChar = regexp.MustCompile(`[^a-zA-Z0-9-]+`) + +// RegExEntryHeading matches any entry heading (## [timestamp]). +// Use for counting entries without capturing groups. +var RegExEntryHeading = regexp.MustCompile(`(?m)^## \[`) + +// RegExPath matches file paths in Markdown backticks. +// +// Groups: +// - 1: file path +var RegExPath = regexp.MustCompile("`([^`]+\\.[a-zA-Z]{1,5})`") + +// RegExContextUpdate matches context-update XML tags. +// +// Groups: +// - 1: opening tag attributes (e.g., ` type="task" context="..."`) +// - 2: content between tags +var RegExContextUpdate = regexp.MustCompile(`]+)>([^<]+)`) + +// RegExGlossary matches glossary definition entries (lines with **term**). +var RegExGlossary = regexp.MustCompile(`(?m)(?:^|\n)\s*(?:-\s*)?\*\*[^*]+\*\*`) + +// RegExDecisionPatterns detects decision-like phrases in text. +var RegExDecisionPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)decided to\s+(.{20,100})`), + regexp.MustCompile(`(?i)decision:\s*(.{20,100})`), + regexp.MustCompile(`(?i)we('ll| will) use\s+(.{10,80})`), + regexp.MustCompile(`(?i)going with\s+(.{10,80})`), + regexp.MustCompile(`(?i)chose\s+(.{10,80})\s+(over|instead)`), +} + +// RegExLearningPatterns detects learning-like phrases in text. +var RegExLearningPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)learned that\s+(.{20,100})`), + regexp.MustCompile(`(?i)gotcha:\s*(.{20,100})`), + regexp.MustCompile(`(?i)lesson:\s*(.{20,100})`), + regexp.MustCompile(`(?i)TIL:?\s*(.{20,100})`), + regexp.MustCompile(`(?i)turns out\s+(.{20,100})`), + regexp.MustCompile(`(?i)important to (note|remember):\s*(.{20,100})`), +} + +// regExTaskPattern captures indent, checkbox state, and content. +// +// Pattern: ^(\s*)-\s*\[([x ]?)]\s*(.+)$ +// +// Groups: +// - 1: indent (leading whitespace, may be empty) +// - 2: state ("x" for completed, " " or "" for pending) +// - 3: content (task text) +const regExTaskPattern = `^(\s*)-\s*\[([x ]?)]\s*(.+)$` + +// RegExTask matches a task item on a single line. +// +// Use with MatchString or FindStringSubmatch on individual lines. +// For multiline content, use RegExTaskMultiline. +var RegExTask = regexp.MustCompile(regExTaskPattern) + +// RegExTaskMultiline matches task items across multiple lines. +// +// Use with FindAllStringSubmatch on multiline content. +var RegExTaskMultiline = regexp.MustCompile(`(?m)` + regExTaskPattern) + +// RegExTaskDoneTimestamp extracts the #done: timestamp from a task line. +// +// Groups: +// - 1: timestamp (YYYY-MM-DD-HHMMSS) +var RegExTaskDoneTimestamp = regexp.MustCompile(`#done:(\d{4}-\d{2}-\d{2}-\d{6})`) + +// RegExFromAttrName creates a regex to extract an XML attribute value by name. +// +// Parameters: +// - name: The attribute name to match +// +// Returns: +// - *regexp.Regexp: Pattern matching name="value" with value in group 1 +func RegExFromAttrName(name string) *regexp.Regexp { + return regexp.MustCompile(name + `="([^"]*)"`) +} diff --git a/internal/config/token.go b/internal/config/token.go new file mode 100644 index 000000000..13eeea84c --- /dev/null +++ b/internal/config/token.go @@ -0,0 +1,20 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2. + +package config + +const ( + // NewlineCRLF is the Windows new line. + // + // We check NewlineCRLF first, then NewlineLF to handle both formats. + NewlineCRLF = "\r\n" + // NewlineLF is Unix new line. + NewlineLF = "\n" + // Separator is a Markdown horizontal rule used between sections. + Separator = "---" + // Ellipsis is a Markdown ellipsis. + Ellipsis = "..." +) diff --git a/internal/context/loader.go b/internal/context/loader.go index 659151371..77e196133 100644 --- a/internal/context/loader.go +++ b/internal/context/loader.go @@ -10,47 +10,10 @@ package context import ( "os" "path/filepath" - "time" - "github.com/ActiveMemory/ctx/internal/config" + "github.com/ActiveMemory/ctx/internal/rc" ) -// FileInfo represents metadata about a context file. -// -// Fields: -// - Name: Filename (e.g., "TASKS.md") -// - Path: Full path to the file -// - Size: File size in bytes -// - ModTime: Last modification time -// - Content: Raw file content -// - IsEmpty: True if file has no meaningful content (only headers/whitespace) -// - Tokens: Estimated token count for the content -// - Summary: Brief description generated from the content -type FileInfo struct { - Name string - Path string - Size int64 - ModTime time.Time - Content []byte - IsEmpty bool - Tokens int - Summary string -} - -// Context represents the loaded context from a .context/ directory. -// -// Fields: -// - Dir: Path to the context directory -// - Files: All loaded context files with their metadata -// - TotalTokens: Sum of estimated tokens across all files -// - TotalSize: Sum of file sizes in bytes -type Context struct { - Dir string - Files []FileInfo - TotalTokens int - TotalSize int64 -} - // Load reads all context files from the specified directory. // // If dir is empty, it uses the configured context directory from .contextrc, @@ -64,7 +27,7 @@ type Context struct { // - error: NotFoundError if directory doesn't exist, or other IO errors func Load(dir string) (*Context, error) { if dir == "" { - dir = config.GetContextDir() + dir = rc.GetContextDir() } // Check if the directory exists @@ -118,7 +81,7 @@ func Load(dir string) (*Context, error) { Size: fileInfo.Size(), ModTime: fileInfo.ModTime(), Content: content, - IsEmpty: len(content) == 0 || isEffectivelyEmpty(content), + IsEmpty: len(content) == 0 || effectivelyEmpty(content), Tokens: tokens, Summary: generateSummary(name, content), } @@ -130,116 +93,3 @@ func Load(dir string) (*Context, error) { return ctx, nil } - -// Exists checks if a context directory exists. -// -// If dir is empty, it uses the configured context directory. -// -// Parameters: -// - dir: Directory path to check, or empty string for default -// -// Returns: -// - bool: True if the directory exists and is a directory -func Exists(dir string) bool { - if dir == "" { - dir = config.GetContextDir() - } - info, err := os.Stat(dir) - return err == nil && info.IsDir() -} - -// NotFoundError is returned when the context directory doesn't exist. -type NotFoundError struct { - Dir string -} - -// Error implements the error interface for NotFoundError. -// -// Returns: -// - string: Error message including the missing directory path -func (e *NotFoundError) Error() string { - return "context directory not found: " + e.Dir -} - -// isEffectivelyEmpty checks if a file only contains headers and whitespace. -// -// Parameters: -// - content: File content to check -// -// Returns: -// - bool: True if file has no meaningful content (only headers, comments, whitespace) -func isEffectivelyEmpty(content []byte) bool { - // Simple heuristic: if `content` is less than 100 bytes - // and mostly headers/whitespace - if len(content) < 20 { - return true - } - - // Count non-header, non-whitespace content - lines := 0 - contentLines := 0 - for _, line := range splitLines(content) { - lines++ - trimmed := trimSpace(line) - if len(trimmed) == 0 { - continue - } - if trimmed[0] == '#' { - continue - } - if len(trimmed) > 0 && trimmed[0] == '-' && len(trimmed) < 5 { - continue - } - // Check for HTML comment markers - if len(trimmed) >= 4 && string(trimmed[:4]) == "" { - continue - } - contentLines++ - } - - return contentLines == 0 -} - -// splitLines splits content into lines without using strings package. -// -// Parameters: -// - content: Byte slice to split -// -// Returns: -// - [][]byte: Slice of lines (without newline characters) -func splitLines(content []byte) [][]byte { - var lines [][]byte - start := 0 - for i, b := range content { - if b == '\n' { - lines = append(lines, content[start:i]) - start = i + 1 - } - } - if start < len(content) { - lines = append(lines, content[start:]) - } - return lines -} - -// trimSpace trims leading and trailing whitespace from a byte slice. -// -// Parameters: -// - b: Byte slice to trim -// -// Returns: -// - []byte: Slice with spaces, tabs, and carriage returns removed from ends -func trimSpace(b []byte) []byte { - start := 0 - end := len(b) - for start < end && (b[start] == ' ' || b[start] == '\t' || b[start] == '\r') { - start++ - } - for end > start && (b[end-1] == ' ' || b[end-1] == '\t' || b[end-1] == '\r') { - end-- - } - return b[start:end] -} diff --git a/internal/context/loader_test.go b/internal/context/loader_test.go index 25df202ec..c2f78498b 100644 --- a/internal/context/loader_test.go +++ b/internal/context/loader_test.go @@ -175,9 +175,9 @@ func TestIsEffectivelyEmpty(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := isEffectivelyEmpty(tt.content) + result := effectivelyEmpty(tt.content) if result != tt.expected { - t.Errorf("isEffectivelyEmpty() = %v, want %v", result, tt.expected) + t.Errorf("effectivelyEmpty() = %v, want %v", result, tt.expected) } }) } diff --git a/internal/context/process.go b/internal/context/process.go new file mode 100644 index 000000000..2631d4bbc --- /dev/null +++ b/internal/context/process.go @@ -0,0 +1,48 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package context + +// splitLines splits content into lines without using strings package. +// +// Parameters: +// - content: Byte slice to split +// +// Returns: +// - [][]byte: Slice of lines (without newline characters) +func splitLines(content []byte) [][]byte { + var lines [][]byte + start := 0 + for i, b := range content { + if b == '\n' { + lines = append(lines, content[start:i]) + start = i + 1 + } + } + if start < len(content) { + lines = append(lines, content[start:]) + } + return lines +} + +// trimSpace trims leading and trailing whitespace from a byte slice. +// +// Parameters: +// - b: Byte slice to trim +// +// Returns: +// - []byte: Slice with spaces, tabs, and carriage returns removed from ends +func trimSpace(b []byte) []byte { + start := 0 + end := len(b) + for start < end && (b[start] == ' ' || b[start] == '\t' || b[start] == '\r') { + start++ + } + for end > start && (b[end-1] == ' ' || b[end-1] == '\t' || b[end-1] == '\r') { + end-- + } + return b[start:end] +} diff --git a/internal/context/sanitize.go b/internal/context/sanitize.go new file mode 100644 index 000000000..6f17bb668 --- /dev/null +++ b/internal/context/sanitize.go @@ -0,0 +1,56 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package context + +import "github.com/ActiveMemory/ctx/internal/config" + +// effectivelyEmpty checks if a file only contains headers and whitespace. +// +// Parameters: +// - content: File content to check +// +// Returns: +// - bool: True if the file has no meaningful content (only headers, +// comments, whitespace) +func effectivelyEmpty(content []byte) bool { + // Simple heuristic: if `content` is less than 100 bytes + // and mostly headers/whitespace + if len(content) < 20 { + return true + } + + // Count non-header, non-whitespace content + lines := 0 + contentLines := 0 + openLen := len(config.CommentOpen) + closeLen := len(config.CommentClose) + for _, line := range splitLines(content) { + lines++ + trimmed := trimSpace(line) + if len(trimmed) == 0 { + continue + } + if trimmed[0] == '#' { + continue + } + if len(trimmed) > 0 && trimmed[0] == '-' && len(trimmed) < 5 { + continue + } + // Check for HTML comment markers + if len(trimmed) >= openLen && + string(trimmed[:openLen]) == config.CommentOpen { + continue + } + if len(trimmed) >= closeLen && + string(trimmed[len(trimmed)-closeLen:]) == config.CommentClose { + continue + } + contentLines++ + } + + return contentLines == 0 +} diff --git a/internal/context/summary.go b/internal/context/summary.go index b2e19cb1e..f4c38e720 100644 --- a/internal/context/summary.go +++ b/internal/context/summary.go @@ -9,38 +9,11 @@ package context import ( "bytes" "fmt" - "regexp" "strings" "github.com/ActiveMemory/ctx/internal/config" ) -// generateSummary creates a brief summary for a context file based on its name and content. -// -// Parameters: -// - name: Filename to determine summary strategy -// - content: Raw file content to analyze -// -// Returns: -// - string: Summary string (e.g., "3 active, 2 completed" or "empty") -func generateSummary(name string, content []byte) string { - switch name { - case config.FilenameConstitution: - return summarizeConstitution(content) - case config.FilenameTask: - return summarizeTasks(content) - case config.FilenameDecision: - return summarizeDecisions(content) - case config.FilenameGlossary: - return summarizeGlossary(content) - default: - if len(content) == 0 || isEffectivelyEmpty(content) { - return "empty" - } - return "loaded" - } -} - // summarizeConstitution counts checkbox items (invariants) in CONSTITUTION.md. // // Parameters: @@ -50,7 +23,12 @@ func generateSummary(name string, content []byte) string { // - string: Summary like "5 invariants" or "loaded" if none found func summarizeConstitution(content []byte) string { // Count checkbox items (invariants) - count := bytes.Count(content, []byte("- [ ]")) + bytes.Count(content, []byte("- [x]")) + count := bytes.Count( + content, []byte(config.PrefixTaskUndone), + ) + + bytes.Count( + content, []byte(config.PrefixTaskDone), + ) if count == 0 { return "loaded" } @@ -66,8 +44,8 @@ func summarizeConstitution(content []byte) string { // - string: Summary like "3 active, 2 completed" or "empty" if none func summarizeTasks(content []byte) string { // Count active (unchecked) and completed (checked) tasks - active := bytes.Count(content, []byte("- [ ]")) - completed := bytes.Count(content, []byte("- [x]")) + active := bytes.Count(content, []byte(config.PrefixTaskUndone)) + completed := bytes.Count(content, []byte(config.PrefixTaskDone)) if active == 0 && completed == 0 { return "empty" @@ -92,8 +70,7 @@ func summarizeTasks(content []byte) string { // - string: Summary like "3 decisions" or "empty" if none func summarizeDecisions(content []byte) string { // Count decision headers (## [date] or ## Decision) - re := regexp.MustCompile(`(?m)^## `) - matches := re.FindAll(content, -1) + matches := config.RegExEntryHeading.FindAll(content, -1) count := len(matches) if count == 0 { @@ -113,9 +90,7 @@ func summarizeDecisions(content []byte) string { // Returns: // - string: Summary like "5 terms" or "empty" if none func summarizeGlossary(content []byte) string { - // Count definition entries (lines starting with **term** or - **term**) - re := regexp.MustCompile(`(?m)(?:^|\n)\s*(?:-\s*)?\*\*[^*]+\*\*`) - matches := re.FindAll(content, -1) + matches := config.RegExGlossary.FindAll(content, -1) count := len(matches) if count == 0 { @@ -126,3 +101,30 @@ func summarizeGlossary(content []byte) string { } return fmt.Sprintf("%d terms", count) } + +// generateSummary creates a brief summary for a context file based on its +// name and content. +// +// Parameters: +// - name: Filename to determine summary strategy +// - content: Raw file content to analyze +// +// Returns: +// - string: Summary string (e.g., "3 active, 2 completed" or "empty") +func generateSummary(name string, content []byte) string { + switch name { + case config.FileConstitution: + return summarizeConstitution(content) + case config.FileTask: + return summarizeTasks(content) + case config.FileDecision: + return summarizeDecisions(content) + case config.FileGlossary: + return summarizeGlossary(content) + default: + if len(content) == 0 || effectivelyEmpty(content) { + return "empty" + } + return "loaded" + } +} diff --git a/internal/context/summary_test.go b/internal/context/summary_test.go index b14117e8e..e057c4fa1 100644 --- a/internal/context/summary_test.go +++ b/internal/context/summary_test.go @@ -54,13 +54,13 @@ func TestGenerateSummary(t *testing.T) { { name: "decisions multiple", filename: "DECISIONS.md", - content: []byte("# Decisions\n\n## 2024-01-15 First\n\nContent\n\n## 2024-01-16 Second\n\nContent\n"), + content: []byte("# Decisions\n\n## [2024-01-15-120000] First\n\nContent\n\n## [2024-01-16-120000] Second\n\nContent\n"), expected: "2 decisions", }, { name: "decisions single", filename: "DECISIONS.md", - content: []byte("# Decisions\n\n## One decision\n\nContent\n"), + content: []byte("# Decisions\n\n## [2024-01-15-120000] One decision\n\nContent\n"), expected: "1 decision", }, { @@ -190,12 +190,12 @@ func TestSummarizeDecisions(t *testing.T) { }, { name: "one decision", - content: []byte("## First\n\nContent\n"), + content: []byte("## [2024-01-15-120000] First\n\nContent\n"), expected: "1 decision", }, { name: "multiple decisions", - content: []byte("## First\n\n## Second\n\n## Third\n"), + content: []byte("## [2024-01-15-120000] First\n\n## [2024-01-16-120000] Second\n\n## [2024-01-17-120000] Third\n"), expected: "3 decisions", }, } diff --git a/internal/context/types.go b/internal/context/types.go new file mode 100644 index 000000000..930a345a7 --- /dev/null +++ b/internal/context/types.go @@ -0,0 +1,59 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2. + +package context + +import "time" + +// FileInfo represents metadata about a context file. +// +// Fields: +// - Name: Filename (e.g., "TASKS.md") +// - Path: Full path to the file +// - Size: File size in bytes +// - ModTime: Last modification time +// - Content: Raw file content +// - IsEmpty: True if the file has no meaningful content +// (only headers/whitespace) +// - Tokens: Estimated token count for the content +// - Summary: Brief description generated from the content +type FileInfo struct { + Name string + Path string + Size int64 + ModTime time.Time + Content []byte + IsEmpty bool + Tokens int + Summary string +} + +// Context represents the loaded context from a .context/ directory. +// +// Fields: +// - Dir: Path to the context directory +// - Files: All loaded context files with their metadata +// - TotalTokens: Sum of estimated tokens across all files +// - TotalSize: Sum of file sizes in bytes +type Context struct { + Dir string + Files []FileInfo + TotalTokens int + TotalSize int64 +} + +// NotFoundError is returned when the context directory doesn't exist. +type NotFoundError struct { + Dir string +} + +// Error implements the error interface for NotFoundError. +// +// Returns: +// - string: Error message including the missing directory path +func (e *NotFoundError) Error() string { + return "context directory not found: " + e.Dir +} diff --git a/internal/context/verify.go b/internal/context/verify.go new file mode 100644 index 000000000..6c4cb23b2 --- /dev/null +++ b/internal/context/verify.go @@ -0,0 +1,30 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package context + +import ( + "os" + + "github.com/ActiveMemory/ctx/internal/rc" +) + +// Exists checks if a context directory exists. +// +// If dir is empty, it uses the configured context directory. +// +// Parameters: +// - dir: Directory path to check, or empty string for default +// +// Returns: +// - bool: True if the directory exists and is a directory +func Exists(dir string) bool { + if dir == "" { + dir = rc.GetContextDir() + } + info, err := os.Stat(dir) + return err == nil && info.IsDir() +} diff --git a/internal/drift/detector.go b/internal/drift/detector.go index d791cd21f..a6f7bb90b 100644 --- a/internal/drift/detector.go +++ b/internal/drift/detector.go @@ -9,7 +9,6 @@ package drift import ( "os" - "regexp" "strings" "github.com/ActiveMemory/ctx/internal/config" @@ -44,7 +43,7 @@ type Issue struct { // Fields: // - Warnings: Non-critical issues that should be addressed // - Violations: Critical issues that indicate constitution violations -// - Passed: Names of checks that completed without issues +// - Passed: Names of checks that are completed without issues type Report struct { Warnings []Issue `json:"warnings"` Violations []Issue `json:"violations"` @@ -54,7 +53,8 @@ type Report struct { // Status returns the overall status of the report. // // Returns: -// - string: "violation" if any violations, "warning" if only warnings, "ok" otherwise +// - string: "violation" if any violations, "warning" if only warnings, +// "ok" otherwise func (r *Report) Status() string { if len(r.Violations) > 0 { return "violation" @@ -106,19 +106,16 @@ func Detect(ctx *context.Context) *Report { // - ctx: Loaded context containing files to scan // - report: Report to append warnings to (modified in place) func checkPathReferences(ctx *context.Context, report *Report) { - // Pattern to match file paths in Markdown (backticks or code blocks) - pathPattern := regexp.MustCompile("`([^`]+\\.[a-zA-Z]{1,5})`") - foundDeadPaths := false for _, f := range ctx.Files { - if f.Name != config.FilenameArchitecture && f.Name != config.FilenameConvention { + if f.Name != config.FileArchitecture && f.Name != config.FileConvention { continue } lines := strings.Split(string(f.Content), "\n") for lineNum, line := range lines { - matches := pathPattern.FindAllStringSubmatch(line, -1) + matches := config.RegExPath.FindAllStringSubmatch(line, -1) for _, m := range matches { path := m[1] // Skip URLs and common non-file patterns @@ -161,7 +158,7 @@ func checkStaleness(ctx *context.Context, report *Report) { staleness := false for _, f := range ctx.Files { - if f.Name == config.FilenameTask { + if f.Name == config.FileTask { // Count completed tasks completedCount := strings.Count(string(f.Content), "- [x]") if completedCount > 10 { @@ -183,7 +180,7 @@ func checkStaleness(ctx *context.Context, report *Report) { // checkConstitution performs heuristic checks for constitution violations. // -// Currently scans the working directory for files that may contain secrets +// Currently, it scans the working directory for files that may contain secrets // (e.g., .env, credentials, api_key) and flags them as violations. // // Parameters: @@ -215,7 +212,9 @@ func checkConstitution(_ *context.Context, report *Report) { } name := strings.ToLower(entry.Name()) for _, pattern := range secretPatterns { - if strings.Contains(name, pattern) && !strings.HasSuffix(name, ".example") && !strings.HasSuffix(name, ".sample") { + if strings.Contains(name, pattern) && + !strings.HasSuffix(name, ".example") && + !strings.HasSuffix(name, ".sample") { // Check if it contains actual content (not just template) content, err := os.ReadFile(entry.Name()) if err != nil { @@ -272,7 +271,7 @@ func checkRequiredFiles(ctx *context.Context, report *Report) { // isTemplateFile checks if file content appears to be a template. // -// Looks for common template markers like YOUR_, {{, REPLACE_, TODO:, CHANGEME. +// Looks for common template markers like YOUR_, {{, REPLACE_, TODO, CHANGEME. // Used to avoid flagging template files as containing secrets. // // Parameters: @@ -288,8 +287,9 @@ func isTemplateFile(content []byte) bool { " startIdx { + // Replace the existing index + if indexContent == "" { + // No entries - remove index entirely (including markers + // and surrounding whitespace) + before := strings.TrimRight(content[:startIdx], nl) + after := content[endIdx+len(config.IndexEnd):] + after = strings.TrimLeft(after, nl) + if after != "" { + return before + nl + nl + after + } + return before + nl + } + // Replace content between markers + before := content[:startIdx+len(config.IndexStart)] + after := content[endIdx:] + return before + nl + indexContent + after + } + + // No existing markers - insert after file header + if indexContent == "" { + // No entries, nothing to insert + return content + } + + headerIdx := strings.Index(content, fileHeader) + if headerIdx == -1 { + // No header found, return unchanged + return content + } + + // Find end of header line + lineEnd := strings.Index(content[headerIdx:], nl) + if lineEnd == -1 { + // Header is at the end of the file + return content + nl + nl + + config.IndexStart + nl + indexContent + + config.IndexEnd + nl + } + + insertPoint := headerIdx + lineEnd + 1 + + // Build new content with the index + var sb strings.Builder + sb.WriteString(content[:insertPoint]) + sb.WriteString(nl) + sb.WriteString(config.IndexStart) + sb.WriteString(nl) + sb.WriteString(indexContent) + sb.WriteString(config.IndexEnd) + sb.WriteString(nl) + sb.WriteString(content[insertPoint:]) + + return sb.String() +} + +// UpdateDecisions regenerates the decision index in DECISIONS.md content. +// +// Parameters: +// - content: The full content of DECISIONS.md +// +// Returns: +// - string: Updated content with regenerated index +func UpdateDecisions(content string) string { + return Update(content, config.HeadingDecisions, config.ColumnDecision) +} + +// UpdateLearnings regenerates the learning index in LEARNINGS.md content. +// +// Parameters: +// - content: The full content of LEARNINGS.md +// +// Returns: +// - string: Updated content with regenerated index +func UpdateLearnings(content string) string { + return Update(content, config.HeadingLearnings, config.ColumnLearning) +} diff --git a/internal/index/index_test.go b/internal/index/index_test.go new file mode 100644 index 000000000..3b6c5dc5f --- /dev/null +++ b/internal/index/index_test.go @@ -0,0 +1,433 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package index + +import ( + "strings" + "testing" + + "github.com/ActiveMemory/ctx/internal/config" +) + +func TestParseHeaders(t *testing.T) { + tests := []struct { + name string + content string + expected []Entry + }{ + { + name: "empty content", + content: "", + expected: nil, + }, + { + name: "no entries", + content: "# Decisions\n\nSome text here.", + expected: nil, + }, + { + name: "single entry", + content: `# Decisions + +## [2026-01-28-051426] No custom UI - IDE is the interface + +**Status**: Accepted +`, + expected: []Entry{ + {Timestamp: "2026-01-28-051426", Date: "2026-01-28", Title: "No custom UI - IDE is the interface"}, + }, + }, + { + name: "multiple entries", + content: `# Decisions + +## [2026-01-28-051426] First decision + +**Status**: Accepted + +--- + +## [2026-01-27-123456] Second decision + +**Status**: Accepted +`, + expected: []Entry{ + {Timestamp: "2026-01-28-051426", Date: "2026-01-28", Title: "First decision"}, + {Timestamp: "2026-01-27-123456", Date: "2026-01-27", Title: "Second decision"}, + }, + }, + { + name: "entry with special characters", + content: `# Decisions + +## [2026-01-28-051426] Use tool-agnostic Session type | with pipe + +**Status**: Accepted +`, + expected: []Entry{ + {Timestamp: "2026-01-28-051426", Date: "2026-01-28", Title: "Use tool-agnostic Session type | with pipe"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseHeaders(tt.content) + if len(got) != len(tt.expected) { + t.Errorf("ParseHeaders() got %d entries, want %d", len(got), len(tt.expected)) + return + } + for i, entry := range got { + if entry.Timestamp != tt.expected[i].Timestamp { + t.Errorf("entry[%d].Timestamp = %q, want %q", i, entry.Timestamp, tt.expected[i].Timestamp) + } + if entry.Date != tt.expected[i].Date { + t.Errorf("entry[%d].Date = %q, want %q", i, entry.Date, tt.expected[i].Date) + } + if entry.Title != tt.expected[i].Title { + t.Errorf("entry[%d].Title = %q, want %q", i, entry.Title, tt.expected[i].Title) + } + } + }) + } +} + +func TestGenerateTable(t *testing.T) { + tests := []struct { + name string + entries []Entry + columnHeader string + expected string + }{ + { + name: "empty entries", + entries: nil, + columnHeader: "Decision", + expected: "", + }, + { + name: "empty slice", + entries: []Entry{}, + columnHeader: "Decision", + expected: "", + }, + { + name: "single entry", + entries: []Entry{ + {Timestamp: "2026-01-28-051426", Date: "2026-01-28", Title: "First decision"}, + }, + columnHeader: "Decision", + expected: `| Date | Decision | +|------|--------| +| 2026-01-28 | First decision | +`, + }, + { + name: "multiple entries", + entries: []Entry{ + {Timestamp: "2026-01-28-051426", Date: "2026-01-28", Title: "First"}, + {Timestamp: "2026-01-27-123456", Date: "2026-01-27", Title: "Second"}, + }, + columnHeader: "Decision", + expected: `| Date | Decision | +|------|--------| +| 2026-01-28 | First | +| 2026-01-27 | Second | +`, + }, + { + name: "entry with pipe character", + entries: []Entry{ + {Timestamp: "2026-01-28-051426", Date: "2026-01-28", Title: "Use A | B format"}, + }, + columnHeader: "Decision", + expected: `| Date | Decision | +|------|--------| +| 2026-01-28 | Use A \| B format | +`, + }, + { + name: "learning column header", + entries: []Entry{ + {Timestamp: "2026-01-28-051426", Date: "2026-01-28", Title: "Test entry"}, + }, + columnHeader: "Learning", + expected: `| Date | Learning | +|------|--------| +| 2026-01-28 | Test entry | +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GenerateTable(tt.entries, tt.columnHeader) + if got != tt.expected { + t.Errorf("GenerateTable() =\n%q\nwant\n%q", got, tt.expected) + } + }) + } +} + +func TestUpdateDecisions(t *testing.T) { + tests := []struct { + name string + content string + wantHas []string + wantNot []string + }{ + { + name: "empty file with header", + content: "# Decisions\n", + wantNot: []string{config.IndexStart, config.IndexEnd}, + }, + { + name: "file with one decision", + content: `# Decisions + +## [2026-01-28-051426] Test decision + +**Status**: Accepted +`, + wantHas: []string{ + config.IndexStart, + config.IndexEnd, + "| Date | Decision |", + "| 2026-01-28 | Test decision |", + "## [2026-01-28-051426] Test decision", + }, + }, + { + name: "update existing index", + content: `# Decisions + + +| Date | Decision | +|------|----------| +| 2026-01-28 | Old entry | + + +## [2026-01-28-051426] New decision + +**Status**: Accepted +`, + wantHas: []string{ + config.IndexStart, + config.IndexEnd, + "| 2026-01-28 | New decision |", + }, + wantNot: []string{ + "| 2026-01-28 | Old entry |", + }, + }, + { + name: "remove index when no decisions", + content: `# Decisions + + +| Date | Decision | +|------|----------| +| 2026-01-28 | Old entry | + + +Some other content. +`, + wantNot: []string{ + config.IndexStart, + config.IndexEnd, + "| Date | Decision |", + }, + wantHas: []string{ + "# Decisions", + "Some other content.", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := UpdateDecisions(tt.content) + for _, want := range tt.wantHas { + if !strings.Contains(got, want) { + t.Errorf("UpdateDecisions() result missing %q\nGot:\n%s", want, got) + } + } + for _, notWant := range tt.wantNot { + if strings.Contains(got, notWant) { + t.Errorf("UpdateDecisions() result should not contain %q\nGot:\n%s", notWant, got) + } + } + }) + } +} + +func TestUpdateDecisions_PreservesContent(t *testing.T) { + content := `# Decisions + +## [2026-01-28-051426] First decision + +**Status**: Accepted + +**Context**: Some context here. + +**Decision**: The decision text. + +**Rationale**: Why we did it. + +**Consequences**: What happens next. + +--- + +## [2026-01-27-123456] Second decision + +**Status**: Accepted + +**Context**: Another context. + +**Decision**: Another decision. + +**Rationale**: Another rationale. + +**Consequences**: More consequences. +` + + got := UpdateDecisions(content) + + if !strings.Contains(got, config.IndexStart) { + t.Error("Missing INDEX:START marker") + } + if !strings.Contains(got, config.IndexEnd) { + t.Error("Missing INDEX:END marker") + } + + if !strings.Contains(got, "| 2026-01-28 | First decision |") { + t.Error("Missing first decision in index") + } + if !strings.Contains(got, "| 2026-01-27 | Second decision |") { + t.Error("Missing second decision in index") + } + + if !strings.Contains(got, "**Context**: Some context here.") { + t.Error("Lost content from first decision") + } + if !strings.Contains(got, "**Rationale**: Another rationale.") { + t.Error("Lost content from second decision") + } +} + +func TestUpdateDecisions_Idempotent(t *testing.T) { + content := `# Decisions + +## [2026-01-28-051426] Test decision + +**Status**: Accepted +` + + first := UpdateDecisions(content) + second := UpdateDecisions(first) + + if first != second { + t.Errorf("UpdateDecisions is not idempotent\nFirst:\n%s\nSecond:\n%s", first, second) + } +} + +func TestUpdateLearnings(t *testing.T) { + tests := []struct { + name string + content string + wantHas []string + wantNot []string + }{ + { + name: "empty file with header", + content: "# Learnings\n", + wantNot: []string{config.IndexStart, config.IndexEnd}, + }, + { + name: "file with one learning", + content: `# Learnings + +## [2026-01-28-191951] Required flags now enforced + +**Context**: Implemented ctx add learning flags + +**Lesson**: Structured entries are more useful + +**Application**: Always use all three flags +`, + wantHas: []string{ + config.IndexStart, + config.IndexEnd, + "| Date | Learning |", + "| 2026-01-28 | Required flags now enforced |", + }, + }, + { + name: "multiple learnings", + content: `# Learnings + +## [2026-01-28-191951] First learning + +**Context**: Test + +**Lesson**: Test + +**Application**: Test + +--- + +## [2026-01-27-120000] Second learning + +**Context**: Test + +**Lesson**: Test + +**Application**: Test +`, + wantHas: []string{ + "| 2026-01-28 | First learning |", + "| 2026-01-27 | Second learning |", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := UpdateLearnings(tt.content) + for _, want := range tt.wantHas { + if !strings.Contains(got, want) { + t.Errorf("UpdateLearnings() result missing %q\nGot:\n%s", want, got) + } + } + for _, notWant := range tt.wantNot { + if strings.Contains(got, notWant) { + t.Errorf("UpdateLearnings() result should not contain %q\nGot:\n%s", notWant, got) + } + } + }) + } +} + +func TestUpdateLearnings_Idempotent(t *testing.T) { + content := `# Learnings + +## [2026-01-28-191951] Test learning + +**Context**: Test + +**Lesson**: Test + +**Application**: Test +` + + first := UpdateLearnings(content) + second := UpdateLearnings(first) + + if first != second { + t.Errorf("UpdateLearnings is not idempotent\nFirst:\n%s\nSecond:\n%s", first, second) + } +} diff --git a/internal/config/rc.go b/internal/rc/rc.go similarity index 77% rename from internal/config/rc.go rename to internal/rc/rc.go index 1ca2b9552..ab355a8ce 100644 --- a/internal/config/rc.go +++ b/internal/rc/rc.go @@ -4,7 +4,8 @@ // \ Copyright 2026-present Context contributors. // SPDX-License-Identifier: Apache-2.0 -package config +// Package rc provides runtime configuration loading from .contextrc files. +package rc import ( "os" @@ -12,6 +13,8 @@ import ( "sync" "gopkg.in/yaml.v3" + + "github.com/ActiveMemory/ctx/internal/config" ) // RC represents the configuration from .contextrc file. @@ -37,8 +40,8 @@ const DefaultTokenBudget = 8000 const DefaultArchiveAfterDays = 7 var ( - rc *RC - rcOnce sync.Once + rc *RC + rcOnce sync.Once rcOverrideDir string ) @@ -48,9 +51,9 @@ var ( // - *RC: Configuration with defaults (8000 token budget, 7-day archive, etc.) func DefaultRC() *RC { return &RC{ - ContextDir: DirContext, + ContextDir: config.DirContext, TokenBudget: DefaultTokenBudget, - PriorityOrder: nil, // nil means use FileReadOrder + PriorityOrder: nil, // nil means use config.FileReadOrder AutoArchive: true, ArchiveAfterDays: DefaultArchiveAfterDays, } @@ -124,7 +127,7 @@ func GetTokenBudget() int { // // Returns: // - []string: File names in priority order, or nil if not configured -// (callers should fall back to FileReadOrder) +// (callers should fall back to config.FileReadOrder) func GetPriorityOrder() []string { return GetRC().PriorityOrder } @@ -162,3 +165,37 @@ func ResetRC() { rc = nil rcOverrideDir = "" } + +// FilePriority returns the priority of a context file. +// +// If a priority_order is configured in .contextrc, that order is used. +// Otherwise, the default config.FileReadOrder is used. +// +// Lower numbers indicate higher priority (1 = highest). +// Unknown files return 100. +// +// Parameters: +// - name: Filename to look up (e.g., "TASKS.md") +// +// Returns: +// - int: Priority value (1-9 for known files, 100 for unknown) +func FilePriority(name string) int { + // Check for .contextrc override first + if order := GetPriorityOrder(); order != nil { + for i, fName := range order { + if fName == name { + return i + 1 + } + } + // File not in custom order gets the lowest priority + return 100 + } + + // Use default priority from config.FileReadOrder + for i, fName := range config.FileReadOrder { + if fName == name { + return i + 1 + } + } + return 100 +} diff --git a/internal/config/rc_test.go b/internal/rc/rc_test.go similarity index 94% rename from internal/config/rc_test.go rename to internal/rc/rc_test.go index 140760ab7..1a1d30ae1 100644 --- a/internal/config/rc_test.go +++ b/internal/rc/rc_test.go @@ -4,19 +4,21 @@ // \ Copyright 2026-present Context contributors. // SPDX-License-Identifier: Apache-2.0 -package config +package rc import ( "os" "path/filepath" "testing" + + "github.com/ActiveMemory/ctx/internal/config" ) func TestDefaultRC(t *testing.T) { rc := DefaultRC() - if rc.ContextDir != DirContext { - t.Errorf("ContextDir = %q, want %q", rc.ContextDir, DirContext) + if rc.ContextDir != config.DirContext { + t.Errorf("ContextDir = %q, want %q", rc.ContextDir, config.DirContext) } if rc.TokenBudget != DefaultTokenBudget { t.Errorf("TokenBudget = %d, want %d", rc.TokenBudget, DefaultTokenBudget) @@ -43,8 +45,8 @@ func TestGetRC_NoFile(t *testing.T) { rc := GetRC() - if rc.ContextDir != DirContext { - t.Errorf("ContextDir = %q, want %q", rc.ContextDir, DirContext) + if rc.ContextDir != config.DirContext { + t.Errorf("ContextDir = %q, want %q", rc.ContextDir, config.DirContext) } if rc.TokenBudget != DefaultTokenBudget { t.Errorf("TokenBudget = %d, want %d", rc.TokenBudget, DefaultTokenBudget) @@ -200,8 +202,8 @@ func TestGetRC_PartialConfig(t *testing.T) { t.Errorf("TokenBudget = %d, want %d", rc.TokenBudget, 5000) } // Unspecified values should use defaults - if rc.ContextDir != DirContext { - t.Errorf("ContextDir = %q, want %q (default)", rc.ContextDir, DirContext) + if rc.ContextDir != config.DirContext { + t.Errorf("ContextDir = %q, want %q (default)", rc.ContextDir, config.DirContext) } } diff --git a/internal/recall/parser/claude.go b/internal/recall/parser/claude.go index 5abcd9af4..f87867e7e 100644 --- a/internal/recall/parser/claude.go +++ b/internal/recall/parser/claude.go @@ -64,7 +64,9 @@ func (p *ClaudeCodeParser) CanParse(path string) bool { defer file.Close() scanner := bufio.NewScanner(file) - for i := 0; i < 5 && scanner.Scan(); i++ { + // Check first 50 lines - slug may not appear until later in the file + // (early lines can be file-history-snapshot or messages without slug) + for i := 0; i < 50 && scanner.Scan(); i++ { line := scanner.Bytes() if len(line) == 0 { continue diff --git a/internal/recall/parser/parser.go b/internal/recall/parser/parser.go index 0ebd663fd..ad169a8f7 100644 --- a/internal/recall/parser/parser.go +++ b/internal/recall/parser/parser.go @@ -9,8 +9,10 @@ package parser import ( "fmt" "os" + "os/exec" "path/filepath" "sort" + "strings" ) // registeredParsers holds all available session parsers. @@ -156,6 +158,101 @@ func ScanDirectoryWithErrors(dir string) ([]*Session, []error, error) { // - []*Session: Deduplicated sessions sorted by start time (newest first) // - error: Non-nil if scanning fails (partial results may still be returned) func FindSessions(additionalDirs ...string) ([]*Session, error) { + return findSessionsWithFilter(nil, additionalDirs...) +} + +// FindSessionsForCWD searches for sessions matching the given working directory. +// +// Matching is done in order of preference: +// 1. Git remote URL match - if both directories are git repos with same remote +// 2. Path relative to home - e.g., "WORKSPACE/ctx" matches across users +// 3. Exact CWD match - fallback for non-git, non-home paths +// +// Parameters: +// - cwd: Working directory to filter by +// - additionalDirs: Optional additional directories to scan +// +// Returns: +// - []*Session: Filtered sessions sorted by start time (newest first) +// - error: Non-nil if scanning fails +func FindSessionsForCWD(cwd string, additionalDirs ...string) ([]*Session, error) { + // Get current project's git remote (if available) + currentRemote := getGitRemote(cwd) + + // Get path relative to home directory + currentRelPath := getPathRelativeToHome(cwd) + + return findSessionsWithFilter(func(s *Session) bool { + // 1. Try git remote match (most robust) + if currentRemote != "" { + sessionRemote := getGitRemote(s.CWD) + if sessionRemote != "" && sessionRemote == currentRemote { + return true + } + } + + // 2. Try path relative to home match + if currentRelPath != "" { + sessionRelPath := getPathRelativeToHome(s.CWD) + if sessionRelPath != "" && sessionRelPath == currentRelPath { + return true + } + } + + // 3. Fallback to exact match + return s.CWD == cwd + }, additionalDirs...) +} + +// getGitRemote returns the git remote origin URL for a directory. +// Returns empty string if not a git repo or no remote configured. +func getGitRemote(dir string) string { + if dir == "" { + return "" + } + + // Check if directory exists + if _, err := os.Stat(dir); err != nil { + return "" + } + + // Try to get git remote + cmd := exec.Command("git", "-C", dir, "remote", "get-url", "origin") + output, err := cmd.Output() + if err != nil { + return "" + } + + return strings.TrimSpace(string(output)) +} + +// getPathRelativeToHome returns the path relative to the user's home directory. +// Returns empty string if path is not under a home directory. +func getPathRelativeToHome(path string) string { + if path == "" { + return "" + } + + // Handle common home directory patterns + // /home/username/... -> strip /home/username + // /Users/username/... -> strip /Users/username (macOS) + parts := strings.Split(path, string(filepath.Separator)) + + for i, part := range parts { + if part == "home" || part == "Users" { + // Next part is username, rest is relative path + if i+2 < len(parts) { + return filepath.Join(parts[i+2:]...) + } + return "" + } + } + + return "" +} + +// findSessionsWithFilter is the internal implementation with optional filtering. +func findSessionsWithFilter(filter func(*Session) bool, additionalDirs ...string) ([]*Session, error) { var allSessions []*Session // Check Claude Code default location @@ -176,10 +273,18 @@ func FindSessions(additionalDirs ...string) ([]*Session, error) { } } + // Apply filter if provided + var filtered []*Session + for _, s := range allSessions { + if filter == nil || filter(s) { + filtered = append(filtered, s) + } + } + // Deduplicate by session ID seen := make(map[string]bool) var unique []*Session - for _, s := range allSessions { + for _, s := range filtered { if !seen[s.ID] { seen[s.ID] = true unique = append(unique, s) diff --git a/internal/task/task.go b/internal/task/task.go new file mode 100644 index 000000000..9231a81e6 --- /dev/null +++ b/internal/task/task.go @@ -0,0 +1,95 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package task provides task item parsing and matching. +// +// This package handles the domain logic for task items, independent of +// their markdown representation. +package task + +// Match indices for accessing capture groups. +// +// Usage: +// +// match := task.ItemPattern.FindStringSubmatch(line) +// if match != nil { +// indent := match[task.MatchIndent] +// state := match[task.MatchState] +// content := match[task.MatchContent] +// } +const ( + MatchFull = 0 // Full match + MatchIndent = 1 // Leading whitespace + MatchState = 2 // "x" or " " or "" + MatchContent = 3 // Task text +) + +// Completed reports whether a match represents a completed task. +// +// Parameters: +// - match: Result from ItemPattern.FindStringSubmatch +// +// Returns: +// - bool: True if the checkbox is checked ([x]) +func Completed(match []string) bool { + if len(match) <= MatchState { + return false + } + return match[MatchState] == "x" +} + +// IsPending reports whether a match represents a pending task. +// +// Parameters: +// - match: Result from ItemPattern.FindStringSubmatch +// +// Returns: +// - bool: True if the checkbox is unchecked ([ ]) +func IsPending(match []string) bool { + if len(match) <= MatchState { + return false + } + return match[MatchState] != "x" +} + +// Indent returns the leading whitespace from a match. +// +// Parameters: +// - match: Result from ItemPattern.FindStringSubmatch +// +// Returns: +// - string: Indent string (may be empty for top-level tasks) +func Indent(match []string) string { + if len(match) <= MatchIndent { + return "" + } + return match[MatchIndent] +} + +// Content returns the task text from a match. +// +// Parameters: +// - match: Result from ItemPattern.FindStringSubmatch +// +// Returns: +// - string: Task content (empty if match is invalid) +func Content(match []string) string { + if len(match) <= MatchContent { + return "" + } + return match[MatchContent] +} + +// IsSubTask reports whether a match represents a subtask (indented). +// +// Parameters: +// - match: Result from ItemPattern.FindStringSubmatch +// +// Returns: +// - bool: True if indent is 2+ spaces +func IsSubTask(match []string) bool { + return len(Indent(match)) >= 2 +} diff --git a/internal/templates/claude/commands/ctx-blog-changelog.md b/internal/templates/claude/commands/ctx-blog-changelog.md new file mode 100644 index 000000000..72ba46e17 --- /dev/null +++ b/internal/templates/claude/commands/ctx-blog-changelog.md @@ -0,0 +1,112 @@ +--- +description: "Generate a blog post from a commit range with a theme" +--- + +Generate a blog post about changes since a specific commit, with a given theme. + +## Input + +Required: +- **Commit hash**: Starting point (e.g., `040ce99`, `HEAD~50`) +- **Theme**: The narrative angle (e.g., "human-assisted refactoring", "the recall system") + +Optional: +- **Reference post**: An existing post to match the style + +## Usage Examples + +```text +/ctx-blog-changelog 040ce99 "human-assisted refactoring" +/ctx-blog-changelog HEAD~30 "building the journal system" --like 2026-01-27-building-ctx-using-ctx.md +/ctx-blog-changelog v0.1.0 "what's new in v0.2.0" +``` + +## Process + +1. **Analyze the commit range**: +```! +git log --oneline ..HEAD +git diff --stat ..HEAD +git log --format="%s" ..HEAD | head -50 +``` + +2. **Gather supporting context**: +```! +# Decisions made in this period +git log --format="%H %s" ..HEAD | grep -i decision + +# Files most changed +git diff --stat ..HEAD | sort -t'|' -k2 -rn | head -20 + +# Journal entries from this period (if available) +ls .context/journal/*.md +``` + +3. **If reference post provided**, read it: +```! +cat docs/blog/.md +``` + +4. **Draft the narrative** following the theme: + +```markdown +--- +title: "[Theme]: [Specific Angle]" +date: YYYY-MM-DD +author: [Ask user] +--- + +# [Title] + +> [Hook related to theme] + +## The Starting Point +[State of codebase at , what prompted the change] + +## The Journey +[Narrative of changes, organized by theme not chronology] + +### [Sub-theme 1] +[Changes, examples, code snippets] + +### [Sub-theme 2] +[Changes, examples, code snippets] + +## Before and After +[Comparison table or code diff showing improvement] + +## Key Commits +| Commit | Change | +|--------|--------| +| abc123 | Description | + +## Lessons Learned +[Insights from this work] + +## What's Next +[Future work enabled by these changes] +``` + +## Example + +User: `/ctx-blog-changelog 040ce99 "human-assisted refactoring"` + +You: +> Analyzing 040ce99..HEAD (47 commits) +> +> I see the theme of "human-assisted refactoring" in: +> - Consolidation of magic strings → config constants +> - Package reorganization (init → initialize) +> - Test colocation +> - Convention documentation +> +> Reference: The existing post discusses YOLO vs human-guided modes. +> I'll frame this as a continuation: "The Refactoring Sessions: What Happened After YOLO Mode" +> +> Outline: +> 1. Where YOLO left us (the debt) +> 2. The consolidation pattern (constants, naming) +> 3. Before/after comparisons +> 4. What the AI couldn't see (why human guidance mattered) +> +> Does this angle work? Any commits or changes to highlight? diff --git a/internal/templates/claude/commands/ctx-blog.md b/internal/templates/claude/commands/ctx-blog.md new file mode 100644 index 000000000..2d5fe8df4 --- /dev/null +++ b/internal/templates/claude/commands/ctx-blog.md @@ -0,0 +1,109 @@ +--- +description: "Generate a blog post draft from recent activity" +--- + +Generate a blog post draft from recent project activity. + +## Input + +The user may specify: +- A time range: `last week`, `since Monday`, `January` +- A topic focus: `the refactoring`, `new features`, `lessons learned` +- Or just run it to analyze recent activity + +## Sources to Analyze + +Gather context from multiple sources: + +```! +# Recent commits +git log --oneline -30 + +# Recent decisions +head -100 .context/DECISIONS.md + +# Recent learnings +head -100 .context/LEARNINGS.md + +# Recent tasks completed +grep -A2 "\[x\]" .context/TASKS.md | head -50 + +# Journal entries (if available) +ls -la .context/journal/*.md 2>/dev/null | tail -10 +``` + +## Blog Post Structure + +Follow the style of existing posts in `docs/blog/`: + +```markdown +--- +title: "Descriptive Title: What This Post Is About" +date: YYYY-MM-DD +author: [Ask user] +--- + +*Author / Date* + +# Title + +> Opening hook or question + +[Introduction: Set the scene, why this matters] + +## Section 1: The Context/Problem +[What situation led to this work] + +## Section 2: What We Did +[Narrative of the work, with code examples] + +## Section 3: What We Learned +[Key insights, gotchas, patterns discovered] + +## Section 4: What's Next +[Future work, open questions] + +--- + +*Footer note about how the post was created* +``` + +## Style Guidelines + +Based on `docs/blog/2026-01-27-building-ctx-using-ctx.md`: + +- **Personal voice**: Use "I", "we", share the journey +- **Show don't tell**: Include actual code, commits, quotes from sessions +- **Tables for comparisons**: Before/after, patterns found +- **Callout boxes**: For key insights (`!!! info`, `!!! quote`) +- **Honest about failures**: Include what went wrong and why +- **Concrete examples**: Reference specific files, commits, decisions + +## Process + +1. Gather sources (git, decisions, learnings, journals) +2. Identify the narrative arc (what's the story?) +3. Draft outline for user approval +4. Write full draft +5. Ask for revisions +6. Save to `docs/blog/YYYY-MM-DD-slug.md` + +## Example + +User: `/ctx-blog last week` + +You: +> Looking at the last week, I see: +> - 15 commits focused on journal/recall features +> - 3 new decisions (project matching, tool output labeling) +> - 5 learnings (session parsing, git remote matching) +> +> I'd frame this as: "Building a Memory Palace: How ctx Learned to Remember Sessions" +> +> Narrative arc: +> 1. The problem: sessions scattered, hard to find +> 2. The solution: recall export + journal site +> 3. The gotchas: timezone bugs, missing sessions +> 4. The insight: git remote as stable project ID +> +> Sound good? Any angle you want to emphasize? diff --git a/internal/templates/claude/commands/ctx-journal-enrich.md b/internal/templates/claude/commands/ctx-journal-enrich.md new file mode 100644 index 000000000..0e55682c7 --- /dev/null +++ b/internal/templates/claude/commands/ctx-journal-enrich.md @@ -0,0 +1,107 @@ +--- +description: "Enrich a journal entry with frontmatter and tags" +--- + +Enrich a session journal entry with structured metadata. + +## Input + +The user specifies a journal entry by partial match: +- `twinkly-stirring-kettle` (slug) +- `twinkly` (partial slug) +- `2026-01-24` (date) +- `76fe2ab9` (short ID) + +Find matching files: +```! +ls .context/journal/*.md | grep -i "" +``` + +If multiple matches, show them and ask which one. +If no argument given, show recent entries and ask. + +## Enrichment Tasks + +Read the journal entry and extract: + +### 1. Frontmatter (YAML at top of file) +```yaml +--- +title: "Session title" +date: 2026-01-27 +type: feature|bugfix|refactor|exploration|debugging|documentation +outcome: completed|partial|abandoned|blocked +topics: + - authentication + - caching +technologies: + - go + - postgresql +libraries: + - cobra + - fatih/color +error_types: + - nil-pointer + - timeout +key_files: + - internal/auth/token.go + - internal/db/cache.go +--- +``` + +### 2. Summary +If `## Summary` says "[Add your summary...]", replace with 2-3 sentences. + +### 3. Extracted Items +Scan the conversation and extract: + +**Decisions made** - Link to DECISIONS.md if persisted: +```markdown +## Decisions +- Used Redis for caching ([D12](../DECISIONS.md#d12)) +- Chose JWT over sessions (not yet persisted) +``` + +**Learnings discovered** - Link to LEARNINGS.md if persisted: +```markdown +## Learnings +- Token refresh requires cache invalidation ([L8](../LEARNINGS.md#l8)) +- Go's defer runs LIFO (new insight) +``` + +**Tasks completed/created**: +```markdown +## Tasks +- [x] Implement caching layer +- [ ] Add cache metrics (created this session) +``` + +## Process + +1. Find and read the journal file +2. Analyze the conversation +3. Propose enrichment (type, topics, outcome) +4. Ask user for confirmation/adjustments +5. Show diff and write if approved + +## Example + +User: `/ctx-journal-enrich twinkly` + +You: +> Found: `2026-01-24-twinkly-stirring-kettle-76fe2ab9.md` +> +> After reading, I'd categorize this as: +> - **Type**: refactor +> - **Outcome**: completed +> - **Topics**: cli, code-cleanup, conventions +> - **Technologies**: go +> - **Libraries**: cobra, spf13/pflag +> - **Key files**: internal/cli/task/tasks.go, internal/cli/session/run.go +> +> I also found: +> - 1 decision: "Use cmd.Print* instead of fmt.Print*" +> - 2 learnings: AST parsing gotcha, flag naming convention +> - 3 tasks completed +> +> Want me to add these? Any adjustments to the categories? diff --git a/internal/templates/claude/commands/ctx-journal-summarize.md b/internal/templates/claude/commands/ctx-journal-summarize.md new file mode 100644 index 000000000..d3b6aa8cf --- /dev/null +++ b/internal/templates/claude/commands/ctx-journal-summarize.md @@ -0,0 +1,121 @@ +--- +description: "Generate a summary of sessions over a time period" +--- + +Generate a narrative summary of sessions from a time period. + +## Input + +Time range (flexible): +- `last week` / `this week` +- `last month` / `January` / `2026-01` +- `2026-01-20 to 2026-01-27` +- `recent` (last 7 days) + +Or topic/feature-based: +- `auth` (sessions mentioning authentication) +- `refactor` (sessions tagged as refactor) +- `how we built caching` (feature narrative across sessions) + +## Summary Types + +**Timeline summary** (default): What happened in a period +**Feature narrative**: "How we built X" from multiple sessions +**Decision trail**: Link decisions to the sessions that made them +**FAQ**: Common questions asked and their answers + +## Process + +1. **Find matching journal entries**: +```! +ls .context/journal/*.md | grep "2026-01" +``` + +2. **Read each entry** - understand what was worked on, accomplished, decided + +3. **Generate summary** with structure: + +```markdown +# Summary: [Period] + +## Overview +[2-3 sentences: focus and accomplishments] + +## Work Completed +- **[Feature]**: Description +- **[Feature]**: Description + +## Decisions Made +- [Decision]: [Rationale] + +## Learnings +- [Learning]: [Why it matters] + +## Unfinished Work +- [Task]: [Status] + +## Sessions +| Date | Session | Summary | +|------|---------|---------| +| 01-24 | twinkly-stirring-kettle | Code cleanup | +``` + +4. **Review with user** - ask about focus, omissions, filename + +5. **Write to** `.context/journal/summary-[period].md` + +## Alternate Formats + +**Feature narrative** (`/ctx-journal-summarize how we built caching`): +```markdown +# How We Built: Caching Layer + +## The Journey +[Narrative of how the feature evolved across sessions] + +## Key Sessions +1. [2026-01-20] Initial exploration - evaluated Redis vs Memcached +2. [2026-01-22] Implementation - basic cache layer +3. [2026-01-25] Refinement - added TTL and invalidation + +## Decisions Along the Way +- Chose Redis (session: 2026-01-20-...) +- Used write-through pattern (session: 2026-01-22-...) + +## Lessons Learned +- Cache invalidation is hard (session: 2026-01-25-...) +``` + +**Decision trail** (`/ctx-journal-summarize decisions`): +```markdown +# Decision Trail: January 2026 + +| Decision | Session | Rationale | +|----------|---------|-----------| +| Use Redis | 2026-01-20-... | Speed + team experience | +| JWT auth | 2026-01-22-... | Stateless, scales better | +``` + +## Output + +Summaries appear in the journal site alongside session entries: +- `summary-2026-01.md` (monthly) +- `summary-week-04.md` (weekly) +- `summary-auth-feature.md` (topic) +- `narrative-caching.md` (feature narrative) +- `decisions-2026-01.md` (decision trail) + +## Example + +User: `/ctx-journal-summarize last week` + +You: +> Found 5 sessions from Jan 24-31: +> - 2x recall/export feature +> - 2x journal site +> - 1x bug fixes +> +> Focus areas to emphasize? Things to skip? + +After input, generate summary and ask: +> Save to `.context/journal/summary-2026-week-05.md`? diff --git a/internal/templates/claude/commands/ctx-prompt-audit.md b/internal/templates/claude/commands/ctx-prompt-audit.md new file mode 100644 index 000000000..488cef950 --- /dev/null +++ b/internal/templates/claude/commands/ctx-prompt-audit.md @@ -0,0 +1,89 @@ +--- +description: "Analyze session logs to identify vague prompts and suggest improvements" +--- + +Analyze recent session transcripts to identify prompts that led to unnecessary clarification back-and-forth. This helps the user improve their prompting patterns. + +## Your Task + +1. **Read recent sessions** from `.context/sessions/` (focus on the 3-5 most recent) +2. **Identify vague prompts** - user messages that caused you to ask clarifying questions +3. **Generate a coaching report** with concrete examples and suggestions + +## What Makes a Prompt "Vague" + +Look for user prompts where Claude's immediate response was to ask clarifying questions rather than take action. Signs include: + +- **Missing file context**: "fix the bug" without specifying which file or error +- **Ambiguous scope**: "optimize it" without what to optimize or success criteria +- **Undefined targets**: "update the component" when multiple components exist +- **Missing error details**: "it's not working" without symptoms or expected behavior +- **Vague action words**: "make it better", "clean this up", "improve the code" + +## Important Nuance + +Not every short prompt is vague! Consider context: +- "fix the bug" after discussing a specific error in detail → **NOT vague** +- "fix the bug" as the first message → **VAGUE** +- "optimize it" when working on a single function → probably fine +- "optimize it" in a large codebase with no context → **VAGUE** + +## Output Format + +Generate a report like this: + +``` +## Prompt Audit Report + +**Sessions analyzed**: 5 +**User prompts reviewed**: 47 +**Vague prompts found**: 4 (8.5%) + +--- + +### Example 1: Missing File Context + +**Your prompt**: "fix the bug" + +**What happened**: I had to ask which file and what error you were seeing, adding 2 messages of back-and-forth. + +**Better prompt**: "fix the authentication error in src/auth/login.ts where JWT validation fails with 401" + +**Cost**: ~2 extra messages, ~30 seconds + +--- + +### Example 2: Undefined Target + +**Your prompt**: "optimize the component" + +**What happened**: Multiple components exist. I asked which one and what performance issue to address. + +**Better prompt**: "optimize UserList in src/components/UserList.tsx to reduce re-renders when parent state updates" + +**Cost**: ~3 extra messages, ~1 minute + +--- + +## Patterns to Watch + +Based on your sessions, you tend to: +1. Skip mentioning file paths (3 occurrences) +2. Use "it" without establishing what "it" refers to (2 occurrences) + +## Tips + +- Start prompts with the **file path** when discussing specific code +- Include **error messages** when debugging +- Specify **success criteria** for optimization tasks +``` + +## Guidelines + +- Be constructive, not critical - the goal is to help, not shame +- Show the actual prompt from their session (quoted) +- Explain what happened (what you had to ask) +- Provide a concrete better alternative +- Estimate the "cost" in extra messages/time +- Look for patterns across multiple examples +- End with actionable tips based on their specific tendencies diff --git a/internal/templates/claude/hooks/prompt-coach.sh b/internal/templates/claude/hooks/prompt-coach.sh new file mode 100644 index 000000000..e89490a30 --- /dev/null +++ b/internal/templates/claude/hooks/prompt-coach.sh @@ -0,0 +1,145 @@ +#!/bin/bash + +# / Context: https://ctx.ist +# ,'`./ do you remember? +# `.,'\ +# \ Copyright 2026-present Context contributors. +# SPDX-License-Identifier: Apache-2.0 + +# Prompt coaching hook for Claude Code +# Detects anti-patterns and suggests better alternatives. +# Limits suggestions to MAX_SUGGESTIONS per pattern per session. +# +# Generated by: ctx init +# +# ANTI-PATTERNS DETECTED: +# - "idiomatic X" → "follow project conventions" +# - "best practices" → "follow CONVENTIONS.md" +# - "fix the bug" (vague) → include error message, file, and context +# - "optimize it" (vague) → specify what to optimize and why +# - "make it better" (vague) → define "better" with criteria +# - "update the component" (vague) → specify which component and what changes +# +# Output: Warnings to stderr (non-blocking) +# Exit: Always 0 (never blocks execution) + +MAX_SUGGESTIONS=3 +SESSION_FILE="/tmp/ctx-prompt-coach-$$-$(date +%Y%m%d).state" + +# Initialize session file if it doesn't exist +if [ ! -f "$SESSION_FILE" ]; then + cat > "$SESSION_FILE" << 'EOF' +idiomatic=0 +bestpractices=0 +fixbug=0 +optimize=0 +makebetter=0 +update=0 +EOF +fi + +# Read hook input from stdin (JSON) +HOOK_INPUT=$(cat) + +# Extract the prompt text +PROMPT=$(echo "$HOOK_INPUT" | jq -r '.prompt // empty') + +# If no prompt, allow +if [ -z "$PROMPT" ]; then + exit 0 +fi + +# Helper: get count for a pattern +get_count() { + grep "^$1=" "$SESSION_FILE" 2>/dev/null | cut -d= -f2 || echo "0" +} + +# Helper: increment count for a pattern +increment_count() { + local pattern="$1" + local count=$(get_count "$pattern") + local new_count=$((count + 1)) + if grep -q "^$pattern=" "$SESSION_FILE" 2>/dev/null; then + sed -i "s/^$pattern=.*/$pattern=$new_count/" "$SESSION_FILE" + else + echo "$pattern=$new_count" >> "$SESSION_FILE" + fi +} + +# Helper: output a coaching tip (only if under limit) +suggest() { + local pattern="$1" + local tip="$2" + local example="$3" + local count=$(get_count "$pattern") + + if [ "$count" -lt "$MAX_SUGGESTIONS" ]; then + echo "" >&2 + echo "┌─ Prompt Tip ─────────────────────────────────────" >&2 + echo "│ $tip" >&2 + if [ -n "$example" ]; then + echo "│" >&2 + echo "│ Example: $example" >&2 + fi + echo "└──────────────────────────────────────────────────" >&2 + echo "" >&2 + increment_count "$pattern" + fi +} + +# Calculate prompt length (for detecting short vague prompts) +PROMPT_LEN=${#PROMPT} + +# Check for "idiomatic X" pattern (case-insensitive) +if echo "$PROMPT" | grep -qiE 'idiomatic (go|python|rust|javascript|typescript|java|c\+\+|ruby)'; then + suggest "idiomatic" \ + "Instead of 'idiomatic X', try 'follow project conventions'" \ + "'follow CONVENTIONS.md patterns for error handling'" +fi + +# Check for "best practices" pattern (case-insensitive) +if echo "$PROMPT" | grep -qiE '\bbest practices?\b'; then + suggest "bestpractices" \ + "Instead of 'best practices', try 'follow CONVENTIONS.md'" \ + "'follow the error handling pattern from CONVENTIONS.md'" +fi + +# Check for vague "fix the bug" / "fix this bug" patterns +# Only trigger if the prompt is short and lacks specifics +if [ "$PROMPT_LEN" -lt 50 ] && echo "$PROMPT" | grep -qiE '\bfix (the|this|that|a) (bug|issue|error|problem)\b'; then + # Check if prompt lacks context (no file path, no error message, no line number) + if ! echo "$PROMPT" | grep -qE '(\.[a-z]{2,4}|line [0-9]|:[0-9]+|error:|Error:|failed|Failed)'; then + suggest "fixbug" \ + "Add context: which file? what error? what's broken?" \ + "'fix the JWT validation error in src/auth/login.ts returning 401'" + fi +fi + +# Check for vague "optimize" patterns +if [ "$PROMPT_LEN" -lt 40 ] && echo "$PROMPT" | grep -qiE '\b(optimize|optimise) (it|this|that)\b'; then + suggest "optimize" \ + "Specify what to optimize and what metric matters" \ + "'optimize UserList to reduce re-renders when parent state updates'" +fi + +# Check for vague "make it better" patterns +if echo "$PROMPT" | grep -qiE '\bmake (it|this|that) (better|nicer|cleaner|good)\b'; then + if [ "$PROMPT_LEN" -lt 50 ]; then + suggest "makebetter" \ + "Define 'better' - readability? performance? maintainability?" \ + "'refactor to be more readable by extracting validation logic'" + fi +fi + +# Check for vague "update the component/function/file" patterns +if [ "$PROMPT_LEN" -lt 50 ] && echo "$PROMPT" | grep -qiE '\bupdate (the|this|that|a) (component|function|file|module|class)\b'; then + # Check if prompt lacks specifics + if ! echo "$PROMPT" | grep -qE '(\.[a-z]{2,4}|src/|lib/|internal/)'; then + suggest "update" \ + "Specify which component and what changes" \ + "'update Button in src/components/Button.tsx to use new color tokens'" + fi +fi + +# Always allow the prompt to proceed +exit 0 diff --git a/internal/templates/embed.go b/internal/templates/embed.go index 12168ba0a..87727a844 100644 --- a/internal/templates/embed.go +++ b/internal/templates/embed.go @@ -108,7 +108,7 @@ func GetClaudeCommand(name string) ([]byte, error) { return FS.ReadFile("claude/commands/" + name) } -// GetClaudeHook reads a Claude Code hook script by name. +// ClaudeHookByFileName reads a Claude Code hook script by name. // // Parameters: // - name: Hook script filename (e.g., "session-end-auto-save.sh") @@ -116,6 +116,6 @@ func GetClaudeCommand(name string) ([]byte, error) { // Returns: // - []byte: Hook script content from claude/hooks/ // - error: Non-nil if file not found or read fails -func GetClaudeHook(name string) ([]byte, error) { +func ClaudeHookByFileName(name string) ([]byte, error) { return FS.ReadFile("claude/hooks/" + name) } diff --git a/internal/validation/validation.go b/internal/validation/validate.go similarity index 87% rename from internal/validation/validation.go rename to internal/validation/validate.go index a00d8a7d7..5cb870d2b 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validate.go @@ -1,8 +1,9 @@ package validation import ( - "regexp" "strings" + + "github.com/ActiveMemory/ctx/internal/config" ) // SanitizeFilename converts a topic string to a safe filename component. @@ -17,8 +18,7 @@ import ( // - string: Safe filename component (lowercase, hyphenated, max 50 chars) func SanitizeFilename(s string) string { // Replace spaces and special chars with hyphens - re := regexp.MustCompile(`[^a-zA-Z0-9-]+`) - s = re.ReplaceAllString(s, "-") + s = config.RegExNonFileNameChar.ReplaceAllString(s, "-") // Remove leading/trailing hyphens s = strings.Trim(s, "-") // Convert to lowercase diff --git a/internal/validation/validation_test.go b/internal/validation/validate_test.go similarity index 100% rename from internal/validation/validation_test.go rename to internal/validation/validate_test.go diff --git a/site/404.html b/site/404.html index 37c64040b..1e818b630 100644 --- a/site/404.html +++ b/site/404.html @@ -19,7 +19,7 @@ - + @@ -372,7 +372,7 @@
  • - + @@ -381,7 +381,7 @@ - ralph + ctx + Autonomous Loops diff --git a/site/ralph-loop/index.html b/site/autonomous-loop/index.html similarity index 95% rename from site/ralph-loop/index.html rename to site/autonomous-loop/index.html index ad5eb5dae..09c6cf73e 100644 --- a/site/ralph-loop/index.html +++ b/site/autonomous-loop/index.html @@ -12,7 +12,7 @@ - + @@ -25,11 +25,11 @@ - + - Ralph and Context - ctx: do you remember? + Autonomous Loops - ctx: do you remember? @@ -85,7 +85,7 @@
    - + Skip to content @@ -120,7 +120,7 @@
    - Ralph and Context + Autonomous Loops
    @@ -399,7 +399,7 @@ - ralph + ctx + Autonomous Loops @@ -419,7 +419,7 @@ - ralph + ctx + Autonomous Loops @@ -444,10 +444,10 @@