diff --git a/.context/TASKS.md b/.context/TASKS.md index d610d5c3f..34a7d47ab 100644 --- a/.context/TASKS.md +++ b/.context/TASKS.md @@ -1,3 +1,5 @@ +- [ ] Add integration tests for CLI commands (drift, sync, decision, learnings, serve, recall) - test actual file operations and command execution #added:2026-02-01-062541 + # Tasks — Context CLI # Tasks diff --git a/VERSION b/VERSION index b1e80bb24..0ea3a944b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.3 +0.2.0 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 61d9d116c..6a26c1dc0 100644 --- a/docs/blog/2026-01-27-building-ctx-using-ctx.md +++ b/docs/blog/2026-01-27-building-ctx-using-ctx.md @@ -4,12 +4,17 @@ date: 2026-01-27 author: Jose Alekhinne --- -*Jose Alekhinne / 2026-01-27* +# Building ctx Using ctx + +![ctx](../images/ctx-banner.png) -# Building ctx Using ctx: A Meta-Experiment in AI-Assisted Development +## A Meta-Experiment in AI-Assisted Development -> What happens when you build a tool designed to give AI memory, using that very -same tool to remember what you are building? +*Jose Alekhinne / 2026-01-27* + +!!! question "Can a tool design itself?" + What happens when you build a tool designed to give AI memory, + using that very same tool to remember what you are 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 I have @@ -29,7 +34,7 @@ Every developer who works with AI code generators knows the frustration: you have a deep, productive session where the AI understands your codebase, your conventions, your decisions. And then you close the terminal. -Tomorrow it's a blank slate. The AI has forgotten everything. +Tomorrow; it's a blank slate. The AI has forgotten everything. That is "*reset amnesia*", and it's not just annoying: it's expensive. @@ -52,7 +57,10 @@ Markdown files for decisions, learnings, tasks, and conventions. The AI reads these at session start and writes to them before the session ends. **The first commit** was just scaffolding. But within hours, the -**Ralph Loop**—an iterative AI development workflow—had produced a working CLI: +[**Ralph Loop**][ralph]—an iterative AI development workflow—had produced +a working CLI: + +[ralph]: https://ctx.ist/autonomous-loop/ "Autonomous Loop" ``` feat(cli): implement amem init command @@ -62,10 +70,14 @@ feat(cli): implement amem agent command ... ``` -Fourteen core commands shipped in rapid succession. +Not one, not two, but a whopping **fourteen** core commands shipped in rapid +succession! I was YOLO'ing like there was no tomorrow: -auto-accept every change, let the AI run free, ship features fast. + +* auto-accept every change, +* let the AI run free, +* ship features fast. ## The Meta-Experiment: Using `amem` to Build `amem` @@ -75,8 +87,8 @@ Here's where it gets interesting: On January 20th, I asked: The answer was yes—but with a gap: -Auto-load worked (*via Claude Code's `PreToolUse` hook*), but auto-save was -missing. If the user quit with Ctrl+C, everything since the last manual save +Autoload worked (*via Claude Code's `PreToolUse` hook*), but auto-save was +missing. If the user quit, with Ctrl+C, everything since the last manual save was lost. That session became the first real test of the system. @@ -95,10 +107,15 @@ development workflow. They're complementary but separate. ### 2. Two Tiers of Context Persistence -| Tier | What | Why | Where | -|-----------|-----------------------------|-------------------------------|------------------------| -| Curated | Learnings, decisions, tasks | Quick reload, token-efficient | .context/*.md | -| Full dump | Entire conversation | Safety net, nothing lost | .context/sessions/*.md | +| Tier | What | Why | +|-----------|-----------------------------|-------------------------------| +| Curated | Learnings, decisions, tasks | Quick reload, token-efficient | +| Full dump | Entire conversation | Safety net, nothing lost | + +| Where | +|------------------------| +| .context/*.md | +| .context/sessions/*.md | ``` This session file—written by the AI to preserve its own context—became the @@ -161,7 +178,8 @@ Human-Guided Mode (Post-040ce99) - Canonical naming: Package name = folder name ``` -The fix required a human-guided refactoring session. +The fix required a **human-guided refactoring session**. I continued to do +that before every major release, from that point on. We introduced `internal/config/config.go` with semantic prefixes: @@ -178,8 +196,7 @@ const ( What I begrudgingly learned was: **YOLO mode is effective for velocity but accumulates debt**. -So I took a mental note to schedule **periodic consolidation sessions** -from that point onward. +So I took a mental note to schedule **periodic consolidation sessions**. ## The Dogfooding Test That Failed @@ -220,7 +237,7 @@ So I added: ## The Constitution versus Conventions As lessons accumulated, there was the temptation to add everything to -`CONSTITUTION.md` as "inviolable rules". +`CONSTITUTION.md` as "*inviolable rules*". But I resisted. @@ -307,7 +324,7 @@ Each **session file** is a timestamped Markdown with: * Tasks for the next session * Technical context (*platform, versions*) -These files are **not auto-loaded** (*that would bust the token budget*). +These files are **not autoloaded** (*that would bust the token budget*). They are what I see as the "*archaeological record*" of `ctx`: When the AI needs deeper information about why something was done, it @@ -347,7 +364,8 @@ and LEARNINGS.md. **Context**: Original implementation hardcoded absolute paths in hooks. This breaks when sharing configs with other developers. -**Decision**: Hooks use `ctx` from PATH. `ctx init` checks PATH before proceeding. +**Decision**: Hooks use `ctx` from PATH. `ctx init` checks PATH before +proceeding. ``` **Generic core with Claude enhancements (2026-01-20)** @@ -368,8 +386,10 @@ forgotten. Each has Context, Lesson, and Application sections: **CGO on ARM64** ```markdown -**Context**: `go test` failed with `gcc: error: unrecognized command-line option '-m64'` -**Lesson**: On ARM64 Linux, CGO causes cross-compilation issues. Always use `CGO_ENABLED=0`. +**Context**: `go test` failed with +`gcc: error: unrecognized command-line option '-m64'` +**Lesson**: On ARM64 Linux, CGO causes cross-compilation issues. +Always use `CGO_ENABLED=0`. ``` **Claude Code skills format** @@ -382,7 +402,8 @@ frontmatter (*description, argument-hint, allowed-tools*). Body is the prompt. **"Do you remember?" handling** ```markdown -**Lesson**: In a `ctx`-enabled project, "*do you remember?*" has an obvious meaning: +**Lesson**: In a `ctx`-enabled project, "*do you remember?*" +has an obvious meaning: check the `.context/` files. Don't ask for clarification—just do it. ``` @@ -487,7 +508,16 @@ If you are reading this, chances are that you already have heard about `ctx`. [github.com/ActiveMemory/ctx](https://github.com/ActiveMemory/ctx), * and the documentation lives at [ctx.ist](https://ctx.ist). -If you're a mere mortal tired of reset amnesia, give `ctx` a try. +!!! note "Session Records are a Gold Mine" +By the time of this writing, I have **more than 70 megabytes** of +**text-only** session capture, spread across >100 markdown and JSONL +files. + + I am analyzing, synthesizing, encriching them with AI, running RAG + (*Retrieval-Augmented Generation*) models on them, and the outcome + surprises me every day. + +If you are a mere mortal tired of reset amnesia, give `ctx` a try. And when you do, check `.context/sessions/` sometime. diff --git a/docs/blog/index.md b/docs/blog/index.md index 70c0e2cd6..0532cc6e4 100644 --- a/docs/blog/index.md +++ b/docs/blog/index.md @@ -3,7 +3,7 @@ title: Blog icon: lucide/newspaper --- -# Blog +![ctx](../images/ctx-banner.png) Stories, insights, and lessons learned from building and using ctx. @@ -25,4 +25,4 @@ persistence, architectural decisions --- -*More posts coming soon.* +*more posts are coming soon* diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 7c86f18e0..6fdcbd85f 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -348,6 +348,49 @@ ctx compact --no-auto-save --- +### `ctx completion` + +Generate shell autocompletion scripts. + +```bash +ctx completion +``` + +#### Subcommands + +| Shell | Command | +|--------------|--------------------------| +| `bash` | `ctx completion bash` | +| `zsh` | `ctx completion zsh` | +| `fish` | `ctx completion fish` | +| `powershell` | `ctx completion powershell` | + +#### Installation + +=== "Bash" + + ```bash + # Add to ~/.bashrc + source <(ctx completion bash) + ``` + +=== "Zsh" + + ```bash + # Add to ~/.zshrc + source <(ctx completion zsh) + ``` + +=== "Fish" + + ```bash + ctx completion fish | source + # Or save to completions directory + ctx completion fish > ~/.config/fish/completions/ctx.fish + ``` + +--- + ### `ctx tasks` Manage task archival and snapshots. @@ -485,11 +528,12 @@ ctx recall list [flags] **Flags**: -| Flag | Short | Description | -|-------------------|-------|------------------------------------------| -| `--limit` | `-n` | Maximum sessions to display (default: 20)| -| `--project` | `-p` | Filter by project name | -| `--tool` | `-t` | Filter by tool (e.g., `claude-code`) | +| Flag | Short | Description | +|------------------|-------|-------------------------------------------| +| `--limit` | `-n` | Maximum sessions to display (default: 20) | +| `--project` | `-p` | Filter by project name | +| `--tool` | `-t` | Filter by tool (e.g., `claude-code`) | +| `--all-projects` | | Include sessions from all projects | Sessions are sorted by date (newest first) and display slug, project, start time, duration, turn count, and token usage. @@ -513,10 +557,11 @@ ctx recall show [session-id] [flags] **Flags**: -| Flag | Description | -|------------|------------------------------------| -| `--latest` | Show the most recent session | -| `--full` | Show full message content | +| Flag | Description | +|------------------|------------------------------------| +| `--latest` | Show the most recent session | +| `--full` | Show full message content | +| `--all-projects` | Search across all projects | The session ID can be a full UUID, partial match, or session slug name. @@ -539,10 +584,11 @@ ctx recall export [session-id] [flags] **Flags**: -| Flag | Description | -|-----------|------------------------------------------| -| `--all` | Export all sessions | -| `--force` | Overwrite existing files | +| Flag | Description | +|------------------|------------------------------------------| +| `--all` | Export all sessions | +| `--force` | Overwrite existing files | +| `--all-projects` | Export from all projects | Exported files include session metadata, tool usage summary, and the full conversation. Existing files are skipped by default to preserve your edits. @@ -578,16 +624,17 @@ ctx journal site [flags] **Flags**: -| Flag | Short | Description | -|------------|-------|------------------------------------------| +| Flag | Short | Description | +|------------|-------|---------------------------------------------------| | `--output` | `-o` | Output directory (default: .context/journal-site) | -| `--build` | | Run zensical build after generating | -| `--serve` | | Run zensical serve after generating | +| `--build` | | Run zensical build after generating | +| `--serve` | | Run zensical serve after generating | -Creates a zensical-compatible site structure with an index page listing +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`: +Requires `zensical` to be installed for `--build` or `--serve`: + ```bash pip install zensical ``` @@ -605,7 +652,7 @@ ctx journal site --serve # Generate and serve locally ### `ctx serve` -Serve a static site locally via zensical. +Serve a static site locally via `zensical`. ```bash ctx serve [directory] @@ -613,7 +660,8 @@ ctx serve [directory] If no directory is specified, serves the journal site (`.context/journal-site`). -Requires zensical to be installed: +Requires `zensical` to be installed: + ```bash pip install zensical ``` @@ -800,13 +848,13 @@ ctx loop [flags] **Flags**: -| Flag | Short | Description | Default | -|--------------------------|-------|-------------------------------------------------|--------------------| -| `--tool ` | `-t` | AI tool: `claude`, `aider`, or `generic` | `claude` | -| `--prompt ` | `-p` | Prompt file to use | `PROMPT.md` | -| `--max-iterations ` | `-n` | Maximum iterations (0 = unlimited) | `0` | -| `--completion ` | `-c` | Completion signal to detect | `SYSTEM_CONVERGED` | -| `--output ` | `-o` | Output script filename | `loop.sh` | +| Flag | Short | Description | Default | +|--------------------------|-------|------------------------------------------|--------------------| +| `--tool ` | `-t` | AI tool: `claude`, `aider`, or `generic` | `claude` | +| `--prompt ` | `-p` | Prompt file to use | `PROMPT.md` | +| `--max-iterations ` | `-n` | Maximum iterations (0 = unlimited) | `0` | +| `--completion ` | `-c` | Completion signal to detect | `SYSTEM_CONVERGED` | +| `--output ` | `-o` | Output script filename | `loop.sh` | **Example**: diff --git a/docs/context-files.md b/docs/context-files.md index 8bbd1bf23..1495e0727 100644 --- a/docs/context-files.md +++ b/docs/context-files.md @@ -64,7 +64,7 @@ The priority order follows a logical progression for AI tools: --- -## CONSTITUTION.md +## `CONSTITUTION.md` **Purpose:** Define hard invariants—rules that must **NEVER** be violated, regardless of the task. @@ -107,7 +107,7 @@ is wrong. --- -## TASKS.md +## `TASKS.md` **Purpose:** Track current work, planned work, and blockers. @@ -183,7 +183,7 @@ session started vs completed work. --- -## DECISIONS.md +## `DECISIONS.md` **Purpose:** Record architectural decisions with rationale so they don't get re-debated. @@ -242,7 +242,7 @@ third-party libraries need type assertions. --- -## LEARNINGS.md +## `LEARNINGS.md` **Purpose:** Capture lessons learned, gotchas, and tips that shouldn't be forgotten. @@ -296,7 +296,7 @@ Organize learnings by topic: --- -## CONVENTIONS.md +## `CONVENTIONS.md` **Purpose**: Document project patterns, naming conventions, and standards. @@ -333,7 +333,7 @@ Organize learnings by topic: --- -## ARCHITECTURE.md +## `ARCHITECTURE.md` **Purpose**: Provide system overview and component relationships. @@ -376,7 +376,7 @@ What's in scope vs out of scope for this codebase. --- -## GLOSSARY.md +## `GLOSSARY.md` **Purpose**: Define domain terms, abbreviations, and project vocabulary. @@ -411,7 +411,7 @@ What's in scope vs out of scope for this codebase. --- -## DRIFT.md +## `DRIFT.md` **Purpose**: Define signals that the context is stale and needs updating. @@ -460,7 +460,7 @@ Update context when: --- -## AGENT_PLAYBOOK.md +## `AGENT_PLAYBOOK.md` **Purpose**: Explicit instructions for how AI tools should read, apply, and update context. diff --git a/docs/index.md b/docs/index.md index 86b9e1427..112c82dc7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,9 +27,15 @@ conventions, and learnings: **Open source is better together**. -> ⭐️ **If the idea behind `ctx` resonates, a star helps it reach engineers who run into context drift every day.** -> -> → https://github.com/ActiveMemory/ctx + +!!! tip "Help `ctx` Change How AI Remembers" + **If the idea behind `ctx` resonates, a star helps it reach engineers + who run into context drift every day.** + + → https://github.com/ActiveMemory/ctx + + `ctx` is free and open source software, and **contributions are always + welcome** and appreciated. Join the community to ask questions, share feedback, and connect with other users: @@ -61,38 +67,38 @@ Download pre-built binaries from the === "Linux (x86_64)" ```bash - curl -LO https://github.com/ActiveMemory/ctx/releases/download/v0.1.2/ctx-0.1.2-linux-amd64 - chmod +x ctx-0.1.2-linux-amd64 - sudo mv ctx-0.1.2-linux-amd64 /usr/local/bin/ctx + curl -LO https://github.com/ActiveMemory/ctx/releases/download/v0.2.0/ctx-0.2.0-linux-amd64 + chmod +x ctx-0.2.0-linux-amd64 + sudo mv ctx-0.2.0-linux-amd64 /usr/local/bin/ctx ``` === "Linux (ARM64)" ```bash - curl -LO https://github.com/ActiveMemory/ctx/releases/download/v0.1.2/ctx-0.1.2-linux-arm64 - chmod +x ctx-0.1.2-linux-arm64 - sudo mv ctx-0.1.2-linux-arm64 /usr/local/bin/ctx + curl -LO https://github.com/ActiveMemory/ctx/releases/download/v0.2.0/ctx-0.2.0-linux-arm64 + chmod +x ctx-0.2.0-linux-arm64 + sudo mv ctx-0.2.0-linux-arm64 /usr/local/bin/ctx ``` === "macOS (Apple Silicon)" ```bash - curl -LO https://github.com/ActiveMemory/ctx/releases/download/v0.1.2/ctx-0.1.2-darwin-arm64 - chmod +x ctx-0.1.2-darwin-arm64 - sudo mv ctx-0.1.2-darwin-arm64 /usr/local/bin/ctx + curl -LO https://github.com/ActiveMemory/ctx/releases/download/v0.2.0/ctx-0.2.0-darwin-arm64 + chmod +x ctx-0.2.0-darwin-arm64 + sudo mv ctx-0.2.0-darwin-arm64 /usr/local/bin/ctx ``` === "macOS (Intel)" ```bash - curl -LO https://github.com/ActiveMemory/ctx/releases/download/v0.1.2/ctx-0.1.2-darwin-amd64 - chmod +x ctx-0.1.2-darwin-amd64 - sudo mv ctx-0.1.2-darwin-amd64 /usr/local/bin/ctx + curl -LO https://github.com/ActiveMemory/ctx/releases/download/v0.2.0/ctx-0.2.0-darwin-amd64 + chmod +x ctx-0.2.0-darwin-amd64 + sudo mv ctx-0.2.0-darwin-amd64 /usr/local/bin/ctx ``` === "Windows" - Download `ctx-0.1.2-windows-amd64.exe` from the releases page and add it to your `PATH`. + Download `ctx-0.2.0-windows-amd64.exe` from the releases page and add it to your `PATH`. ### Verifying Checksums @@ -100,10 +106,10 @@ Each binary has a corresponding `.sha256` checksum file. To verify your download ```bash # Download the checksum file -curl -LO https://github.com/ActiveMemory/ctx/releases/download/v0.1.2/ctx-0.1.2-linux-amd64.sha256 +curl -LO https://github.com/ActiveMemory/ctx/releases/download/v0.2.0/ctx-0.2.0-linux-amd64.sha256 # Verify the binary -sha256sum -c ctx-0.1.2-linux-amd64.sha256 +sha256sum -c ctx-0.2.0-linux-amd64.sha256 ``` On macOS, use `shasum -a 256 -c` instead of `sha256sum -c`. diff --git a/docs/integrations.md b/docs/integrations.md index 03ded8561..a09143b14 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -133,42 +133,48 @@ 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 | +| 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 | +| 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 | +| `/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 | +| 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 | +!!! tip "Blogging is a Better Way of Creating Release Notes" + The blogging workflow can also double as generating release notes: + + AI reads your git commit history and creates a "*narrative*", + which is essentially what a *release note* is for. + +| 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 | +| Command | Description | +|---------------------|----------------------------------------| +| `/ctx-loop` | Generate a Ralph Loop iteration script | | `/ctx-prompt-audit` | Analyze session logs for vague prompts | #### Usage Examples diff --git a/docs/security.md b/docs/security.md index cd14f4d35..bf1f0ffd5 100644 --- a/docs/security.md +++ b/docs/security.md @@ -9,6 +9,8 @@ title: Security icon: lucide/shield --- +![ctx](images/ctx-banner.png) + ## Reporting Vulnerabilities At `ctx` we take security very seriously. diff --git a/docs/versions.md b/docs/versions.md index aa0398e4a..6f9bd6226 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -9,7 +9,9 @@ title: Version History icon: lucide/history --- -# Version History +![ctx](images/ctx-banner.png) + +## Version History Documentation snapshots for each release. Click a version to view the docs as they were at that release. @@ -18,6 +20,7 @@ docs as they were at that release. | Version | Release Date | Documentation | |---------|--------------|-------------------------------------------------------------------| +| v0.2.0 | 2026-02-01 | [View docs](https://github.com/ActiveMemory/ctx/tree/v0.2.0/docs) | | v0.1.2 | 2026-01-27 | [View docs](https://github.com/ActiveMemory/ctx/tree/v0.1.2/docs) | | v0.1.1 | 2026-01-26 | [View docs](https://github.com/ActiveMemory/ctx/tree/v0.1.1/docs) | | v0.1.0 | 2026-01-25 | [View docs](https://github.com/ActiveMemory/ctx/tree/v0.1.0/docs) | @@ -26,8 +29,8 @@ docs as they were at that release. The [main documentation](index.md) always reflects the latest development version. -For the most recent stable release, see -[v0.1.2](https://github.com/ActiveMemory/ctx/tree/v0.1.2/docs). +For the most recent stable release, see +[v0.2.0](https://github.com/ActiveMemory/ctx/tree/v0.2.0/docs). ## Changelog diff --git a/examples/demo/.context/AGENT_PLAYBOOK.md b/examples/demo/.context/AGENT_PLAYBOOK.md new file mode 100644 index 000000000..4aefe6827 --- /dev/null +++ b/examples/demo/.context/AGENT_PLAYBOOK.md @@ -0,0 +1,113 @@ +# Agent Playbook + +## Mental Model + +This system does not persist experience. + +- Each session is a fresh execution in a shared workshop. +- Work continuity comes from artifacts left on the bench. + +### Work → Reflect → Persist + +After completing meaningful work, follow this cycle: + +``` +┌─────────┐ ┌─────────┐ ┌─────────┐ +│ Work │ ──▶ │ Reflect │ ──▶ │ Persist │ ──▶ (continue) +└─────────┘ └─────────┘ └─────────┘ + │ + ▼ + Did I just... + • Complete a task? + • Make a decision? + • Learn something? + • Hit a milestone? +``` + +**Don't wait for session end** — it may never come cleanly. Persist as you go. + +## Read Order + +1. CONSTITUTION.md — Hard rules, NEVER violate +2. TASKS.md — What to work on next +3. CONVENTIONS.md — How to write code +4. ARCHITECTURE.md — Where things go +5. DECISIONS.md — Why things are the way they are +6. LEARNINGS.md — Gotchas to avoid + +## How Your Memory Works + +When asked "Do you remember?" or similar: + +1. **Check the context files** (sessions/, TASKS.md, LEARNINGS.md, etc.) +2. **Respond with what you found** — "From the sessions, I see we discussed X..." +3. **Be honest about mechanism only if asked** — the user wants results, not disclaimers + +**Wrong**: "I don't have memory, but I can check files. Want me to?" +**Right**: *checks files* → "Yes, I see from the last session we were working on..." + +The context IS your memory. Load it and use it — don't lead with caveats. + +## Session History + +Check `.context/sessions/` for session dumps from previous sessions. + +Session files are named `YYYY-MM-DD-HHMMSS-.md`. + +## When to Update Memory + +| Event | Action | +|-----------------------------|-----------------------| +| Made architectural decision | Add to DECISIONS.md | +| Discovered gotcha/bug | Add to LEARNINGS.md | +| Established new pattern | Add to CONVENTIONS.md | +| Completed task | Mark [x] in TASKS.md | +| Had important discussion | Save to sessions/ | + +### Prefer `ctx add` Over Direct File Edits + +When adding learnings, decisions, or tasks, **use `ctx add` commands**: + +```bash +# ✓ Preferred - ensures consistent format, timestamps, structure +ctx add learning "Title" --context "..." --lesson "..." --application "..." +ctx add decision "Title" --context "..." --rationale "..." --consequences "..." +ctx add task "Description" + +# ✗ Avoid - bypasses structure, easy to write incomplete entries +Edit LEARNINGS.md directly with a one-liner +``` + +**Exception:** Direct edits are fine for: +- Marking tasks complete (`[ ]` → `[x]`) +- Minor corrections to existing entries + +## Proactive Context Persistence + +**Don't wait for session end** — persist context at natural milestones. + +### Milestone Triggers + +| Milestone | Action | +|------------------------------------|-------------------------------------------------| +| Complete a task | Mark done in TASKS.md, offer to add learnings | +| Make an architectural decision | `ctx add decision "..."` | +| Discover a gotcha or bug | `ctx add learning "..."` | +| Finish a significant code change | Offer to summarize what was done | + +### Self-Check Prompt + +Periodically ask yourself: + +> "If this session ended right now, would the next session know what happened?" + +If no — persist something before continuing. + +## How to Avoid Hallucinating Memory + +Never assume: If you don't see it in files, you don't know it. + +- Don't claim "we discussed X" without file evidence +- Don't invent history - check sessions/ for actual discussions +- If uncertain, say "I don't see this documented" +- Trust files over intuition diff --git a/examples/demo/.context/ARCHITECTURE.md b/examples/demo/.context/ARCHITECTURE.md index 766ba5269..7ff400f2e 100644 --- a/examples/demo/.context/ARCHITECTURE.md +++ b/examples/demo/.context/ARCHITECTURE.md @@ -1,28 +1,72 @@ # Architecture -System overview and key design decisions. +System overview and component organization. -## System Overview +## High-Level Architecture ``` -┌──────────────┐ ┌──────────────┐ ┌──────────────┐ -│ Frontend │───▶│ API Server │───▶│ Database │ -│ (React) │ │ (Go) │ │ (Postgres) │ -└──────────────┘ └──────────────┘ └──────────────┘ - │ - ▼ - ┌──────────────┐ - │ Redis Cache │ - └──────────────┘ +┌─────────────────────────────────────────────────────────────┐ +│ Clients │ +│ (Web App, Mobile App, CLI) │ +└─────────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Load Balancer │ +│ (nginx / AWS ALB) │ +└─────────────────────────┬───────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ API Server │ +│ (Go / net/http) │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ +│ │ Handlers │ │ Services │ │ Repositories │ │ +│ └─────────────┘ └─────────────┘ └─────────────────────┘ │ +└─────────────────────────┬───────────────────────────────────┘ + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + ┌───────────┐ ┌───────────┐ ┌───────────────┐ + │ PostgreSQL│ │ Redis │ │ Object Store │ + │ (primary) │ │ (cache) │ │ (S3) │ + └───────────┘ └───────────┘ └───────────────┘ ``` ## Directory Structure -- `/cmd/` - Application entry points -- `/internal/` - Private application code -- `/pkg/` - Public library code -- `/api/` - API definitions and OpenAPI specs -- `/web/` - Frontend React application +``` +. +├── cmd/ +│ ├── api/ # API server entrypoint +│ └── worker/ # Background worker entrypoint +├── internal/ +│ ├── handler/ # HTTP handlers +│ ├── service/ # Business logic +│ ├── repository/ # Data access +│ └── model/ # Domain types +├── pkg/ # Shared libraries (importable) +├── migrations/ # Database migrations +├── docs/ # Documentation +└── .context/ # AI context files +``` + +## Key Components + +### API Server (`cmd/api`) +- Handles HTTP requests +- Validates input, calls services, returns responses +- Stateless — all state in database or cache + +### Services (`internal/service`) +- Contains business logic +- Orchestrates multiple repositories +- Enforces business rules + +### Repositories (`internal/repository`) +- Data access layer +- One repository per aggregate root +- Handles database queries and caching ## Key Patterns @@ -38,3 +82,19 @@ easier and components more modular. The system uses an event bus for decoupled component communication. Events are published when state changes, and interested components subscribe to relevant events. + +## Data Flow + +1. Request arrives at handler +2. Handler validates input, extracts user context +3. Handler calls service with validated data +4. Service applies business logic, calls repositories +5. Repository reads/writes to database +6. Response flows back up the stack + +## Scaling Strategy + +- **Horizontal**: Add more API server instances behind load balancer +- **Database**: Read replicas for read-heavy workloads +- **Cache**: Redis for session data and frequently accessed records +- **Background work**: Separate worker processes for async jobs diff --git a/examples/demo/.context/CONSTITUTION.md b/examples/demo/.context/CONSTITUTION.md index 3c11befd4..2732066d3 100644 --- a/examples/demo/.context/CONSTITUTION.md +++ b/examples/demo/.context/CONSTITUTION.md @@ -1,11 +1,36 @@ # Constitution -Core invariants that must NEVER be violated. Read these first. +These rules are INVIOLABLE. If a task requires violating these, the task is wrong. -## Inviolable Rules +## Security Invariants -- [ ] All code changes must include tests -- [ ] Never commit secrets or credentials to the repository -- [ ] Breaking changes require a deprecation period -- [ ] Security vulnerabilities must be fixed within 24 hours -- [ ] All public APIs must be documented +- [ ] Never commit secrets, tokens, API keys, or credentials +- [ ] Never store customer/user data in context files +- [ ] All user input must be validated and sanitized + +## Quality Invariants + +- [ ] All code must pass tests before commit +- [ ] No TODO comments in main branch (move to TASKS.md) +- [ ] Breaking API changes require deprecation period + +## Process Invariants + +- [ ] All architectural changes require a decision record in DECISIONS.md + +## TASKS.md Structure Invariants + +TASKS.md must remain a replayable checklist. Uncheck all items and re-run +the loop = verify/redo all tasks in order. + +- [ ] **Never move tasks** — tasks stay in their Phase section permanently +- [ ] **Never remove Phase headers** — Phase labels provide structure and order +- [ ] **Never delete tasks** — mark as `[x]` completed, or `[-]` skipped with reason +- [ ] **Use inline labels for status** — add `#in-progress` to task text, don't move it +- [ ] **No "In Progress" sections** — these encourage moving tasks +- [ ] **Ask before restructuring** — if structure changes seem needed, ask the user first + +## Context Preservation Invariants + +- [ ] **Archival is allowed, deletion is not** — use `ctx tasks archive` to move completed tasks, never delete context history +- [ ] **Archive preserves structure** — archived tasks keep their Phase headers for traceability diff --git a/examples/demo/.context/CONVENTIONS.md b/examples/demo/.context/CONVENTIONS.md index 5978b6575..7f742f390 100644 --- a/examples/demo/.context/CONVENTIONS.md +++ b/examples/demo/.context/CONVENTIONS.md @@ -2,28 +2,82 @@ Coding standards and patterns used in this project. -## Code Style +## Naming - Use camelCase for variables and functions - Use PascalCase for types and interfaces +- Use SCREAMING_SNAKE_CASE for constants + +## Code Style + - Prefer early returns over nested conditionals - Maximum line length: 100 characters +- One component per file -## File Organization +## Patterns -- One component per file -- Group related files in directories -- Test files should be adjacent to source files +### Error Handling + +Always return errors, never panic in library code: + +```go +// ✓ Correct +func ProcessData(data []byte) (Result, error) { + if len(data) == 0 { + return Result{}, fmt.Errorf("empty data") + } + // ... +} + +// ✗ Wrong +func ProcessData(data []byte) Result { + if len(data) == 0 { + panic("empty data") // Never panic in libraries + } + // ... +} +``` + +Wrap errors with context: + +```go +if err != nil { + return fmt.Errorf("processing user %s: %w", userID, err) +} +``` + +### Configuration + +Load order (highest priority first): +1. Environment variables +2. Config file (config.yaml) +3. Default values + +Log config source at startup for debuggability. + +## Testing + +- Test files adjacent to source files (`foo.go` → `foo_test.go`) +- Use table-driven tests for multiple cases +- Mock external dependencies, never call real services in tests ## Git Practices -- Commit messages follow Conventional Commits +- Commit messages follow Conventional Commits format - Feature branches: `feature/` - Bug fixes: `fix/` - All PRs require at least one approval -## Error Handling +## Documentation + +### Doc-Impact Rule + +When modifying code that affects user-facing behavior, update the corresponding +documentation: -- Always return errors, never panic in libraries -- Wrap errors with context using `fmt.Errorf` -- Log errors at the boundary, not in helpers +| Code Change | Doc Update Required | +|--------------------------|------------------------| +| API endpoint changes | `docs/api.md` | +| CLI command changes | `docs/cli.md` | +| Configuration changes | `docs/configuration.md`| +| New features | `README.md` | diff --git a/examples/demo/.context/DECISIONS.md b/examples/demo/.context/DECISIONS.md index 63a49b75b..229d83308 100644 --- a/examples/demo/.context/DECISIONS.md +++ b/examples/demo/.context/DECISIONS.md @@ -1,37 +1,87 @@ # Decisions -Record of significant technical decisions with rationale. +Architectural decisions with rationale and consequences. -## 2024-01-15 Use PostgreSQL for primary database +--- + +## [2026-01-05-110000] Use PostgreSQL for Primary Database -**Context**: We need a reliable, scalable database for our application. +**Context**: Needed to choose a database for the application. Options were +PostgreSQL, MySQL, and MongoDB. -**Decision**: Use PostgreSQL instead of MySQL or MongoDB. +**Decision**: PostgreSQL **Rationale**: - Strong ACID compliance for financial transactions - Excellent JSON support for flexible schema needs +- Team has existing PostgreSQL expertise - Rich ecosystem of tools and extensions -- Team has existing expertise **Consequences**: -- Need to learn PostgreSQL-specific features -- Deployment requires PostgreSQL setup +- Need to manage schema migrations explicitly +- Requires more upfront schema design than document stores +- Horizontal scaling requires additional tooling (Citus, read replicas) + +--- + +## [2026-01-08-140000] JWT for API Authentication + +**Context**: Needed to choose authentication mechanism for the REST API. +Options were session cookies, JWT tokens, and API keys. + +**Decision**: JWT tokens with short expiry + refresh tokens + +**Rationale**: +- Stateless authentication scales horizontally without session storage +- Works well for both web and mobile clients +- Can embed user claims to reduce database lookups +- Industry standard with good library support + +**Consequences**: +- Cannot immediately revoke tokens (must wait for expiry) +- Need secure storage for refresh tokens +- Must implement token refresh flow in all clients +- Larger request payload than session cookies --- -## 2024-01-10 Use Go for API server +## [2026-01-10-090000] Use Go for API Server -**Context**: Choosing a backend language for our API. +**Context**: Choosing a backend language for the API. Options were Go, +Node.js, and Python. -**Decision**: Use Go instead of Node.js or Python. +**Decision**: Go **Rationale**: - Excellent performance characteristics - Strong typing catches bugs at compile time - Simple deployment with single binary -- Great concurrency primitives +- Great concurrency primitives for handling many connections **Consequences**: -- Smaller talent pool than JavaScript +- Smaller talent pool than JavaScript/Python - Some team members need Go training +- Compile step required (vs interpreted languages) + +--- + +## [2026-01-12-160000] Monorepo Structure + +**Context**: Starting with multiple services (API, worker, CLI). Needed to +decide between monorepo and multi-repo structure. + +**Decision**: Monorepo with shared packages + +**Rationale**: +- Atomic commits across services +- Easier code sharing and refactoring +- Single CI/CD pipeline to maintain +- Better visibility into cross-service changes + +**Consequences**: +- Need tooling to handle partial builds (only changed services) +- Repository will grow large over time +- All developers need access to entire codebase +- Must establish clear package boundaries + +--- diff --git a/examples/demo/.context/LEARNINGS.md b/examples/demo/.context/LEARNINGS.md new file mode 100644 index 000000000..3813eae83 --- /dev/null +++ b/examples/demo/.context/LEARNINGS.md @@ -0,0 +1,57 @@ +# Learnings + + +| Date | Learning | +|------|----------| +| 2026-01-15 | Database connections need explicit timeouts | +| 2026-01-10 | Environment variables override config files | +| 2026-01-05 | Rate limiter must be per-user, not global | + + +--- + +## [2026-01-15-143022] Database connections need explicit timeouts + +**Context**: Production outage caused by database connection pool exhaustion. +Connections were hanging indefinitely waiting for slow queries. + +**Lesson**: Always set explicit timeouts on database connections: connect timeout, +read timeout, and write timeout. Default "no timeout" is never acceptable in production. + +**Application**: Add to connection config: +```go +db.SetConnMaxLifetime(5 * time.Minute) +db.SetConnMaxIdleTime(1 * time.Minute) +ctx, cancel := context.WithTimeout(ctx, 30*time.Second) +``` + +--- + +## [2026-01-10-091500] Environment variables override config files + +**Context**: Debugging why staging had different behavior than local. Config file +was correct, but an old environment variable was overriding it. + +**Lesson**: Document the precedence order clearly: ENV > config file > defaults. +When debugging config issues, always check environment variables first. + +**Application**: Add config source logging at startup: +``` +Config loaded: database.host=localhost (source: ENV) +Config loaded: database.port=5432 (source: config.yaml) +``` + +--- + +## [2026-01-05-160030] Rate limiter must be per-user, not global + +**Context**: Implemented global rate limiter (100 req/sec total). One heavy user +could starve all other users. + +**Lesson**: Rate limiting should be per-user (or per-API-key) to ensure fair +resource allocation. Global limits are only useful as a last-resort circuit breaker. + +**Application**: Use user ID or API key as the rate limiter bucket key, not a +single global bucket. + +--- diff --git a/examples/demo/.context/TASKS.md b/examples/demo/.context/TASKS.md index 7986db523..77657f181 100644 --- a/examples/demo/.context/TASKS.md +++ b/examples/demo/.context/TASKS.md @@ -1,21 +1,26 @@ # Tasks -Current work items, prioritized from top to bottom. +Current work items, organized by phase. Tasks stay in their phase permanently. -## Active Tasks +## Phase 1: Foundation -- [ ] Implement user authentication with OAuth2 -- [ ] Add rate limiting to API endpoints -- [ ] Write integration tests for payment flow +- [x] Initial project setup #added:2026-01-01-090000 #done:2026-01-01-120000 +- [x] Database schema design #added:2026-01-01-090000 #done:2026-01-02-150000 +- [x] Core API scaffolding #added:2026-01-01-090000 #done:2026-01-03-110000 -## Backlog +## Phase 2: Authentication -- [ ] Add support for WebSocket connections -- [ ] Implement caching layer for frequently accessed data -- [ ] Set up monitoring and alerting +- [x] Implement user registration #added:2026-01-04-100000 #done:2026-01-05-140000 +- [ ] Implement OAuth2 login #added:2026-01-04-100000 #in-progress +- [ ] Add session management #added:2026-01-04-100000 -## Completed +## Phase 3: API Features -- [x] Initial project setup -- [x] Database schema design -- [x] Core API scaffolding +- [ ] Add rate limiting to API endpoints #added:2026-01-10-090000 +- [ ] Write integration tests for payment flow #added:2026-01-10-090000 + +## Phase 4: Infrastructure + +- [ ] Add support for WebSocket connections #added:2026-01-15-140000 +- [ ] Implement caching layer #added:2026-01-15-140000 +- [ ] Set up monitoring and alerting #added:2026-01-15-140000 diff --git a/examples/demo/.context/sessions/2026-01-15-143000-database-timeout-investigation.md b/examples/demo/.context/sessions/2026-01-15-143000-database-timeout-investigation.md new file mode 100644 index 000000000..4ffacfbb9 --- /dev/null +++ b/examples/demo/.context/sessions/2026-01-15-143000-database-timeout-investigation.md @@ -0,0 +1,64 @@ +# Session: Database Timeout Investigation + +**Date**: 2026-01-15 +**start_time**: 2026-01-15-140000 +**end_time**: 2026-01-15-160000 +**Topic**: Investigating production database connection issues +**Type**: bugfix + +--- + +## Summary + +Investigated production outage caused by database connection pool exhaustion. +Found that connections were hanging indefinitely on slow queries. Implemented +explicit timeouts and connection lifecycle management. + +## Problem + +- Production API started returning 503 errors +- Database connection pool was exhausted (all 100 connections in use) +- Connections were stuck waiting for queries that never returned +- No timeout configured on database connections + +## Root Cause + +Default Go database driver has no timeout. When the database is slow or +unresponsive, connections wait forever, eventually exhausting the pool. + +## Fix Applied + +```go +// Before: no timeouts +db, err := sql.Open("postgres", connStr) + +// After: explicit lifecycle management +db, err := sql.Open("postgres", connStr) +db.SetConnMaxLifetime(5 * time.Minute) +db.SetConnMaxIdleTime(1 * time.Minute) +db.SetMaxOpenConns(100) +db.SetMaxIdleConns(10) + +// Query-level timeouts +ctx, cancel := context.WithTimeout(ctx, 30*time.Second) +defer cancel() +rows, err := db.QueryContext(ctx, query) +``` + +## Key Decisions + +- Set connection max lifetime to 5 minutes (prevents stale connections) +- Set query timeout to 30 seconds (fail fast on slow queries) +- Added circuit breaker for database calls + +## Tasks for Next Session + +- Add monitoring for connection pool metrics +- Set up alerting for connection pool utilization > 80% +- Review other services for similar timeout issues + +## Files Changed + +- `internal/repository/db.go` +- `internal/config/database.go` +- `docs/operations.md` diff --git a/examples/demo/PROMPT.md b/examples/demo/PROMPT.md new file mode 100644 index 000000000..d5df48fe3 --- /dev/null +++ b/examples/demo/PROMPT.md @@ -0,0 +1,95 @@ +# PROMPT.md — Demo Project + +## CORE PRINCIPLE + +You have NO conversational memory. Your memory IS the file system. +Your goal: advance the project by exactly ONE task, update context, and exit. + +--- + +## PROJECT CONTEXT + +**Project**: Demo API Server +**Language**: Go 1.22+ +**Current Focus**: Phase 2 — Authentication + +--- + +## PHASE 0: ORIENT + +1. Read `.context/TASKS.md` — Current work items +2. Read `.context/CONSTITUTION.md` — Rules to never violate +3. Read `.context/CONVENTIONS.md` — How to write code +4. Read relevant spec in `specs/` for the current task + +--- + +## PHASE 1: SELECT TASK + +1. Read `.context/TASKS.md` +2. Find the **first unchecked item** (line starting with `- [ ]`) +3. That is your ONE task for this iteration + +**IF NO UNCHECKED ITEMS:** +1. Run validation: `go build ./...`, `go test ./...` +2. If all pass, output `PHASE_COMPLETE` +3. If any fail, add fix task and continue + +--- + +## PHASE 2: EXECUTE + +1. **Read the spec** — Check `specs/` for detailed requirements +2. **Search first** — Don't assume code doesn't exist +3. **Implement ONE task** — Complete it fully. No placeholders. +4. **Follow conventions** — Check `.context/CONVENTIONS.md` + +--- + +## PHASE 3: VALIDATE + +After implementing, run: + +```bash +go build ./... # Must compile +go test ./... # Tests must pass +go vet ./... # No vet errors +``` + +--- + +## PHASE 4: UPDATE CONTEXT + +1. Mark completed task `[x]` in `.context/TASKS.md` +2. Add `#done:YYYY-MM-DD-HHMMSS` timestamp +3. If you made an architectural decision → add to `.context/DECISIONS.md` +4. If you learned a gotcha → add to `.context/LEARNINGS.md` + +**EXIT.** Do not continue to next task. The loop will restart you. + +--- + +## CRITICAL CONSTRAINTS + +### ONE TASK ONLY +Complete ONE task, then stop. The loop handles continuation. + +### NO CHAT +Never ask questions. If blocked: +1. Add reason to task in `.context/TASKS.md` +2. Move to next task + +### MEMORY IS THE FILESYSTEM +You will not remember this conversation. Write everything important to files. + +--- + +## REFERENCE: SPECS + +| Spec | Description | +|------|-------------| +| `specs/oauth2.md` | OAuth2 authentication implementation | + +--- + +Now read `.context/TASKS.md` and begin. diff --git a/examples/demo/README.md b/examples/demo/README.md index 5dbddfc2b..5fce5b5f4 100644 --- a/examples/demo/README.md +++ b/examples/demo/README.md @@ -2,42 +2,124 @@ # Demo Project -This is a sample project demonstrating Context context structure. +This is a sample project demonstrating Context (ctx) structure and best practices. -## Using Context +## Quick Start -1. View the context files in `.context/`: - - `CONSTITUTION.md` - Inviolable rules - - `TASKS.md` - Current work items - - `CONVENTIONS.md` - Coding standards - - `ARCHITECTURE.md` - System overview - - `DECISIONS.md` - Technical decisions +```bash +# View context status +ctx status -2. Run Context commands: - ```bash - # View context status - ctx status +# Get AI-ready context packet +ctx agent - # Get AI-ready context packet - ctx agent +# Add a new task +ctx add task "Implement feature X" - # Add a new task - ctx add task "Implement feature X" +# Mark a task complete +ctx complete "feature X" - # Mark a task complete - ctx complete "feature X" +# Check for stale context +ctx drift +``` - # Check for stale context - ctx drift - ``` - -## Context Structure +## Context Files The `.context/` directory contains markdown files that provide persistent -context for AI coding assistants. This helps AI tools understand: +context for AI coding assistants: + +| File | Purpose | +|----------------------|---------------------------------------------------| +| `AGENT_PLAYBOOK.md` | **Read first** — How agents should use this system | +| `CONSTITUTION.md` | Inviolable rules — NEVER violate these | +| `TASKS.md` | Current work items with phases and timestamps | +| `CONVENTIONS.md` | Coding standards and patterns | +| `ARCHITECTURE.md` | System overview and component layout | +| `DECISIONS.md` | Technical decisions with rationale | +| `LEARNINGS.md` | Gotchas, tips, lessons learned | + +## Key Concepts + +### Agent Playbook + +`AGENT_PLAYBOOK.md` is the bootstrap file for AI agents. It explains: +- The mental model (memory = files, not conversation) +- Read order for context files +- When and how to persist learnings/decisions +- How to avoid hallucinating memory + +### Phase-Based Tasks + +Tasks in `TASKS.md` stay in their phase permanently. Use inline labels +(`#in-progress`) instead of moving tasks between sections: + +```markdown +## Phase 2: Authentication + +- [x] Implement user registration #added:2026-01-04-100000 #done:2026-01-05-140000 +- [ ] Implement OAuth2 login #added:2026-01-04-100000 #in-progress +- [ ] Add session management #added:2026-01-04-100000 +``` + +### Structured Entries + +Learnings and decisions follow structured formats with timestamps: + +```markdown +## [2026-01-15-143022] Database connections need explicit timeouts + +**Context**: What situation led to this learning + +**Lesson**: What we learned + +**Application**: How to apply it going forward +``` + +## Adding Context + +```bash +# Add a learning with full structure +ctx add learning "Title" \ + --context "What happened" \ + --lesson "What we learned" \ + --application "How to apply it" + +# Add a decision with rationale +ctx add decision "Title" \ + --context "What prompted this" \ + --rationale "Why this choice" \ + --consequences "What changes" + +# Add a task +ctx add task "Implement feature X" +``` + +## Ralph Loop Integration + +This demo includes Ralph Loop infrastructure for iterative AI development: + +| File | Purpose | +|------|---------| +| `PROMPT.md` | Directive for AI agents — defines the work loop | +| `specs/` | Detailed specifications for features | + +The Ralph Loop pattern: +1. AI reads `PROMPT.md` to understand the workflow +2. Picks ONE task from `.context/TASKS.md` +3. Reads relevant spec from `specs/` for requirements +4. Implements the task +5. Updates context files +6. Exits — the loop restarts with fresh context + +This is separate from but complementary to ctx: +- **ctx** = context persistence (`.context/`) +- **Ralph Loop** = iterative AI workflow (`PROMPT.md` + `specs/`) + +## Session Persistence + +Session dumps are saved to `.context/sessions/` with timestamps: +- `2026-01-20-164600-feature-discussion.md` — Manual session notes +- Auto-saved transcripts (if Claude Code hooks are configured) -- What rules must never be broken (CONSTITUTION) -- What work is currently in progress (TASKS) -- How code should be written (CONVENTIONS) -- How the system is organized (ARCHITECTURE) -- Why things are the way they are (DECISIONS) +This allows future sessions to understand past context without relying on +conversation memory. diff --git a/examples/demo/specs/oauth2.md b/examples/demo/specs/oauth2.md new file mode 100644 index 000000000..2349a7010 --- /dev/null +++ b/examples/demo/specs/oauth2.md @@ -0,0 +1,94 @@ +# OAuth2 Authentication Spec + +## Overview + +Implement OAuth2 authentication supporting Google and GitHub providers. + +## Requirements + +### Functional + +1. **Provider Support** + - Google OAuth2 + - GitHub OAuth2 + - Extensible provider interface for future additions + +2. **Flow** + - User clicks "Sign in with Google/GitHub" + - Redirect to provider's authorization page + - Provider redirects back with authorization code + - Exchange code for access token + - Fetch user profile from provider + - Create or update local user record + - Issue JWT session token + +3. **User Linking** + - If email already exists, link OAuth identity to existing account + - If new email, create new user account + - Store provider ID for future logins + +### Non-Functional + +- Token exchange must complete in < 2 seconds +- Handle provider downtime gracefully (show user-friendly error) +- Log all OAuth events for security auditing + +## API Endpoints + +``` +GET /auth/oauth/{provider} # Initiate OAuth flow +GET /auth/oauth/{provider}/callback # Handle OAuth callback +POST /auth/logout # Revoke session +``` + +## Data Model + +```go +type OAuthIdentity struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Provider string `json:"provider"` // "google", "github" + ProviderID string `json:"provider_id"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` +} +``` + +## Configuration + +```yaml +oauth: + google: + client_id: ${GOOGLE_CLIENT_ID} + client_secret: ${GOOGLE_CLIENT_SECRET} + redirect_url: https://example.com/auth/oauth/google/callback + github: + client_id: ${GITHUB_CLIENT_ID} + client_secret: ${GITHUB_CLIENT_SECRET} + redirect_url: https://example.com/auth/oauth/github/callback +``` + +## Security Considerations + +- Use `state` parameter to prevent CSRF attacks +- Validate redirect URLs against allowlist +- Never log access tokens or client secrets +- Store only necessary user data from provider + +## Testing + +- Unit tests for token exchange logic +- Integration tests with mock OAuth provider +- E2E test with real providers in staging environment + +## Tasks + +These map to `.context/TASKS.md` Phase 2: + +1. [ ] Create OAuth provider interface +2. [ ] Implement Google OAuth provider +3. [ ] Implement GitHub OAuth provider +4. [ ] Add OAuth callback handler +5. [ ] Implement user linking logic +6. [ ] Add OAuth configuration loading +7. [ ] Write integration tests diff --git a/hack/release.sh b/hack/release.sh index 8752ea9b5..9745da6ea 100755 --- a/hack/release.sh +++ b/hack/release.sh @@ -57,7 +57,7 @@ if [ ! -f "$ROOT_DIR/VERSION" ]; then exit 1 fi -VERSION="v$(cat "$ROOT_DIR/VERSION" | tr -d '[:space:]')" +VERSION="v$(tr -d '[:space:]' < "$ROOT_DIR/VERSION")" # ----------------------------------------------------------------------------- # Derived values @@ -179,7 +179,7 @@ echo " - Tag: ${TAG_NAME}" echo " - Tag: latest -> ${TAG_NAME}" echo "" echo "Built artifacts in dist/:" -ls -1 dist/ctx-* 2>/dev/null | sed 's/^/ /' +find dist -maxdepth 1 -name 'ctx-*' 2>/dev/null | sed 's/^/ /' echo "" echo "Next step:" echo "" diff --git a/hack/tag.sh b/hack/tag.sh index 550520ade..64457f292 100755 --- a/hack/tag.sh +++ b/hack/tag.sh @@ -26,7 +26,7 @@ if [ ! -f "$ROOT_DIR/VERSION" ]; then exit 1 fi -VERSION="v$(cat "$ROOT_DIR/VERSION" | tr -d '[:space:]')" +VERSION="v$(tr -d '[:space:]' < "$ROOT_DIR/VERSION")" echo "Creating tag: $VERSION" diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 4d51fab9a..b9e6a69d7 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -62,7 +62,7 @@ func RootCmd() *cobra.Command { cmd := &cobra.Command{ Use: "ctx", Short: "Context - persistent context for AI coding assistants", - Long: `Context (ctx) maintains persistent context files that help + Long: `ctx (Context) maintains persistent context files that help AI coding assistants understand your project's architecture, conventions, decisions, and current tasks. diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go new file mode 100644 index 000000000..f71fe9636 --- /dev/null +++ b/internal/bootstrap/bootstrap_test.go @@ -0,0 +1,99 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package bootstrap + +import ( + "testing" +) + +func TestRootCmd(t *testing.T) { + cmd := RootCmd() + + if cmd == nil { + t.Fatal("RootCmd() returned nil") + } + + if cmd.Use != "ctx" { + t.Errorf("RootCmd().Use = %q, want %q", cmd.Use, "ctx") + } + + if cmd.Short == "" { + t.Error("RootCmd().Short is empty") + } + + if cmd.Long == "" { + t.Error("RootCmd().Long is empty") + } + + // Check global flags exist + contextDirFlag := cmd.PersistentFlags().Lookup("context-dir") + if contextDirFlag == nil { + t.Error("--context-dir flag not found") + } + + noColorFlag := cmd.PersistentFlags().Lookup("no-color") + if noColorFlag == nil { + t.Error("--no-color flag not found") + } +} + +func TestInitialize(t *testing.T) { + root := RootCmd() + cmd := Initialize(root) + + if cmd == nil { + t.Fatal("Initialize() returned nil") + } + + // Verify all expected subcommands are registered + expectedCommands := []string{ + "init", + "status", + "load", + "add", + "complete", + "agent", + "drift", + "sync", + "compact", + "decisions", + "watch", + "hook", + "learnings", + "session", + "tasks", + "loop", + "recall", + "journal", + "serve", + } + + commands := make(map[string]bool) + for _, c := range cmd.Commands() { + commands[c.Use] = true + // Handle commands with args in Use (e.g., "serve [directory]") + for _, exp := range expectedCommands { + if c.Name() == exp { + commands[exp] = true + } + } + } + + for _, exp := range expectedCommands { + if !commands[exp] { + t.Errorf("missing subcommand: %s", exp) + } + } +} + +func TestRootCmdVersion(t *testing.T) { + cmd := RootCmd() + + if cmd.Version == "" { + t.Error("RootCmd().Version is empty") + } +} diff --git a/internal/claude/claude_test.go b/internal/claude/claude_test.go index 3647b2691..d27044ed2 100644 --- a/internal/claude/claude_test.go +++ b/internal/claude/claude_test.go @@ -45,6 +45,28 @@ func TestBlockNonPathCtxScript(t *testing.T) { } } +func TestPromptCoachScript(t *testing.T) { + content, err := PromptCoachScript() + if err != nil { + t.Fatalf("PromptCoachScript() unexpected error: %v", err) + } + + if len(content) == 0 { + t.Error("PromptCoachScript() returned empty content") + } + + // Check for expected script content + script := string(content) + if !strings.Contains(script, "#!/") { + t.Error("PromptCoachScript() script missing shebang") + } + + // Check that it contains pattern detection logic + if !strings.Contains(script, "idiomatic") { + t.Error("PromptCoachScript() should contain anti-pattern detection") + } +} + func TestCommands(t *testing.T) { commands, err := Commands() if err != nil { @@ -156,29 +178,3 @@ func TestSettingsStructure(t *testing.T) { } } -func TestDefaultPermissions(t *testing.T) { - perms := DefaultPermissions() - - if len(perms) == 0 { - t.Error("DefaultPermissions should return permissions") - } - - // Check that essential ctx commands are included - expected := []string{ - "Bash(ctx status:*)", - "Bash(ctx agent:*)", - "Bash(ctx add:*)", - "Bash(ctx session:*)", - } - - permSet := make(map[string]bool) - for _, p := range perms { - permSet[p] = true - } - - for _, e := range expected { - if !permSet[e] { - t.Errorf("Missing expected permission: %s", e) - } - } -} diff --git a/internal/claude/cmd.go b/internal/claude/cmd.go index 01ae7ec5d..6f4daedaf 100644 --- a/internal/claude/cmd.go +++ b/internal/claude/cmd.go @@ -9,7 +9,7 @@ package claude import ( "fmt" - "github.com/ActiveMemory/ctx/internal/templates" + "github.com/ActiveMemory/ctx/internal/tpl" ) // Commands returns the list of embedded command file names. @@ -22,7 +22,7 @@ import ( // - []string: Filenames of available command definitions // - error: Non-nil if the commands directory cannot be read func Commands() ([]string, error) { - names, err := templates.ListClaudeCommands() + names, err := tpl.ListClaudeCommands() if err != nil { return nil, fmt.Errorf("failed to list commands: %w", err) } @@ -38,7 +38,7 @@ func Commands() ([]string, error) { // - []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) + content, err := tpl.ClaudeCommandByName(name) if err != nil { return nil, fmt.Errorf("failed to read command %s: %w", name, err) } diff --git a/internal/claude/hook.go b/internal/claude/hook.go index 237e66e42..4307e83c2 100644 --- a/internal/claude/hook.go +++ b/internal/claude/hook.go @@ -6,7 +6,11 @@ package claude -import "fmt" +import ( + "fmt" + + "github.com/ActiveMemory/ctx/internal/config" +) // DefaultHooks returns the default ctx hooks configuration for // Claude Code. @@ -24,9 +28,11 @@ import "fmt" // - HookConfig: Configured hooks for PreToolUse, UserPromptSubmit, and // SessionEnd events func DefaultHooks(projectDir string) HookConfig { - hooksDir := ".claude/hooks" + hooksDir := config.DirClaudeHooks if projectDir != "" { - hooksDir = fmt.Sprintf("%s/.claude/hooks", projectDir) + hooksDir = fmt.Sprintf( + "%s/%s", projectDir, config.DirClaudeHooks, + ) } return HookConfig{ @@ -36,8 +42,10 @@ func DefaultHooks(projectDir string) HookConfig { Matcher: "Bash", Hooks: []Hook{ { - Type: "command", - Command: fmt.Sprintf("%s/block-non-path-ctx.sh", hooksDir), + Type: "command", + Command: fmt.Sprintf( + "%s/%s", hooksDir, config.FileBlockNonPathScript, + ), }, }, }, @@ -58,7 +66,7 @@ func DefaultHooks(projectDir string) HookConfig { Hooks: []Hook{ { Type: "command", - Command: fmt.Sprintf("%s/prompt-coach.sh", hooksDir), + Command: fmt.Sprintf("%s/%s", hooksDir, config.FilePromptCoach), }, }, }, @@ -68,7 +76,7 @@ func DefaultHooks(projectDir string) HookConfig { Hooks: []Hook{ { Type: "command", - Command: fmt.Sprintf("%s/auto-save-session.sh", hooksDir), + Command: fmt.Sprintf("%s/%s", hooksDir, config.FileAutoSave), }, }, }, diff --git a/internal/claude/perm.go b/internal/claude/perm.go deleted file mode 100644 index f034f8010..000000000 --- a/internal/claude/perm.go +++ /dev/null @@ -1,25 +0,0 @@ -// / 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 index e1a51d760..ab7f28af5 100644 --- a/internal/claude/script.go +++ b/internal/claude/script.go @@ -9,7 +9,8 @@ package claude import ( "fmt" - "github.com/ActiveMemory/ctx/internal/templates" + "github.com/ActiveMemory/ctx/internal/config" + "github.com/ActiveMemory/ctx/internal/tpl" ) // AutoSaveScript returns the auto-save session script content. @@ -21,9 +22,9 @@ import ( // - []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") + content, err := tpl.ClaudeHookByFileName(config.FileAutoSave) if err != nil { - return nil, fmt.Errorf("failed to read auto-save-session.sh: %w", err) + return nil, fmt.Errorf("failed to read %s: %w", config.FileAutoSave, err) } return content, nil } @@ -39,16 +40,18 @@ func AutoSaveScript() ([]byte, error) { // - []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") + content, err := tpl.ClaudeHookByFileName(config.FileBlockNonPathScript) if err != nil { - return nil, fmt.Errorf("failed to read block-non-path-ctx.sh: %w", err) + return nil, fmt.Errorf( + "failed to read %s: %w", config.FileBlockNonPathScript, err, + ) } return content, nil } // PromptCoachScript returns the prompt coaching hook script. // -// The script detects prompt anti-patterns (e.g., "idiomatic Go") and suggests +// The script detects prompt antipatterns (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. @@ -57,9 +60,11 @@ func BlockNonPathCtxScript() ([]byte, error) { // - []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") + content, err := tpl.ClaudeHookByFileName(config.FilePromptCoach) if err != nil { - return nil, fmt.Errorf("failed to read prompt-coach.sh: %w", err) + return nil, fmt.Errorf( + "failed to read %s: %w", config.FilePromptCoach, err, + ) } return content, nil } diff --git a/internal/cli/add/content.go b/internal/cli/add/content.go index 1adbc0965..d26fdde98 100644 --- a/internal/cli/add/content.go +++ b/internal/cli/add/content.go @@ -15,6 +15,20 @@ import ( "github.com/ActiveMemory/ctx/internal/config" ) +// extractContent retrieves content from various sources for adding entries. +// +// Content is extracted in priority order: +// 1. From the file specified by --file flag +// 2. From command line arguments (after the entry type) +// 3. From stdin (if piped) +// +// Parameters: +// - args: Command arguments where args[1:] may contain inline content +// - flags: Configuration flags including fromFile path +// +// Returns: +// - string: Extracted and trimmed content +// - error: Non-nil if no content source is available or reading fails func extractContent(args []string, flags addConfig) (string, error) { if flags.fromFile != "" { // Read from the file diff --git a/internal/cli/add/fmt.go b/internal/cli/add/fmt.go index 8633cf263..fb0e590c4 100644 --- a/internal/cli/add/fmt.go +++ b/internal/cli/add/fmt.go @@ -11,7 +11,7 @@ import ( "time" ) -// FormatTask formats a task entry as a markdown checkbox item. +// FormatTask formats a task entry as a Markdown checkbox item. // // The output includes a timestamp tag for session correlation and an optional // priority tag. Format: "- [ ] content #priority:level #added:YYYY-MM-DD-HHMMSS" @@ -32,7 +32,7 @@ func FormatTask(content string, priority string) string { return fmt.Sprintf("- [ ] %s%s #added:%s\n", content, priorityTag, timestamp) } -// FormatLearning formats a learning entry as a structured markdown section. +// FormatLearning formats a learning entry as a structured Markdown section. // // The output includes a timestamped heading and complete sections for context, // lesson, and application. @@ -57,7 +57,7 @@ func FormatLearning(title, context, lesson, application string) string { `, timestamp, title, context, lesson, application) } -// FormatConvention formats a convention entry as a simple markdown list item. +// FormatConvention formats a convention entry as a simple Markdown list item. // // Format: "- content" // diff --git a/internal/cli/add/normalize.go b/internal/cli/add/normalize.go index 5d81f344d..035e60674 100644 --- a/internal/cli/add/normalize.go +++ b/internal/cli/add/normalize.go @@ -1,7 +1,24 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + package add import "strings" +// normalizeTargetSection ensures a section heading has a proper Markdown +// format. +// +// If the section is empty, defaults to "## Next Up". If provided without +// a heading prefix, prepends "## " to make it a level-2 heading. +// +// Parameters: +// - section: Raw section name from user input +// +// Returns: +// - string: Normalized section heading (e.g., "## Phase 1") func normalizeTargetSection(section string) string { targetSection := section if targetSection == "" { diff --git a/internal/cli/add/pos.go b/internal/cli/add/pos.go index c7365edc5..0473b1166 100644 --- a/internal/cli/add/pos.go +++ b/internal/cli/add/pos.go @@ -1,3 +1,9 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + package add // skipNewline advances pos past a newline (CRLF or LF) if present. diff --git a/internal/cli/add/run.go b/internal/cli/add/run.go index cc8fcbdd3..b7d3162b9 100644 --- a/internal/cli/add/run.go +++ b/internal/cli/add/run.go @@ -114,7 +114,7 @@ func WriteEntry(params EntryParams) error { return fmt.Errorf("unknown type %q", fType) } - filePath := filepath.Join(rc.GetContextDir(), fileName) + filePath := filepath.Join(rc.ContextDir(), fileName) // Check if the file exists if _, err := os.Stat(filePath); os.IsNotExist(err) { @@ -155,7 +155,8 @@ func WriteEntry(params EntryParams) error { return fmt.Errorf("failed to write %s: %w", filePath, err) } - // Update index for decisions and learnings (tasks/conventions don't have indexes) + // Update index for decisions and learnings + // (tasks/conventions don't have indexes) switch config.UserInputToEntry(fType) { case config.EntryDecision: indexed := index.UpdateDecisions(string(newContent)) diff --git a/internal/cli/add/config.go b/internal/cli/add/types.go similarity index 100% rename from internal/cli/add/config.go rename to internal/cli/add/types.go diff --git a/internal/cli/agent/agent.go b/internal/cli/agent/agent.go index cb919eaad..1c50a32cf 100644 --- a/internal/cli/agent/agent.go +++ b/internal/cli/agent/agent.go @@ -51,15 +51,18 @@ Examples: ctx agent --format json # JSON output for programmatic use ctx agent --budget 2000 --format json`, RunE: func(cmd *cobra.Command, args []string) error { - // Use configured budget if flag not explicitly set + // Use the configured budget if the flag is not explicitly set if !cmd.Flags().Changed("budget") { - budget = rc.GetTokenBudget() + budget = rc.TokenBudget() } return runAgent(cmd, budget, format) }, } - cmd.Flags().IntVar(&budget, "budget", rc.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/compact/parse.go b/internal/cli/compact/parse.go index 539ce694e..c9a5e5c16 100644 --- a/internal/cli/compact/parse.go +++ b/internal/cli/compact/parse.go @@ -50,7 +50,7 @@ func parseBlockAt(lines []string, startIdx int) TaskBlock { for i := startIdx + 1; i < len(lines); i++ { line := lines[i] - // Empty lines: include if followed by more indented content + // 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 @@ -100,7 +100,7 @@ func parseBlockAt(lines []string, startIdx int) TaskBlock { // - line: Task line that may contain #done:YYYY-MM-DD-HHMMSS // // Returns: -// - *time.Time: Parsed time, or nil if no valid timestamp found +// - *time.Time: Parsed time, or nil if no valid timestamp is found func parseDoneTimestamp(line string) *time.Time { match := config.RegExTaskDoneTimestamp.FindStringSubmatch(line) if match == nil || len(match) < 2 { diff --git a/internal/cli/compact/process.go b/internal/cli/compact/process.go index b08f764e3..06b6484bd 100644 --- a/internal/cli/compact/process.go +++ b/internal/cli/compact/process.go @@ -34,7 +34,7 @@ func preCompactAutoSave(cmd *cobra.Command) error { green := color.New(color.FgGreen).SprintFunc() // Ensure sessions directory exists - sessionsDir := filepath.Join(rc.GetContextDir(), "sessions") + sessionsDir := filepath.Join(rc.ContextDir(), "sessions") if err := os.MkdirAll(sessionsDir, 0755); err != nil { return fmt.Errorf("failed to create sessions directory: %w", err) } @@ -84,12 +84,13 @@ func buildPreCompactSession(timestamp time.Time) string { "This snapshot was automatically created before running `ctx compact`." + nl, ) sb.WriteString( - "It preserves the state of context files before any cleanup operations." + nl + nl, + "It preserves the state of context files before any cleanup operations." + + nl + nl, ) sb.WriteString(sep + nl + nl) // Read and include current TASKS.md content - tasksPath := filepath.Join(rc.GetContextDir(), config.FileTask) + tasksPath := filepath.Join(rc.ContextDir(), config.FileTask) if tasksContent, err := os.ReadFile(tasksPath); err == nil { sb.WriteString("## Tasks (Before Compact)" + nl + nl) sb.WriteString("```markdown" + nl) diff --git a/internal/cli/compact/run.go b/internal/cli/compact/run.go index fd6b6ba41..69843d1cd 100644 --- a/internal/cli/compact/run.go +++ b/internal/cli/compact/run.go @@ -43,7 +43,7 @@ func runCompact(cmd *cobra.Command, archive, noAutoSave bool) error { } // Enable archiving if configured in .contextrc - if rc.GetAutoArchive() { + if rc.AutoArchive() { archive = true } @@ -82,7 +82,9 @@ func runCompact(cmd *cobra.Command, archive, noAutoSave bool) error { cleaned, count := removeEmptySections(string(f.Content)) if count > 0 { if err := os.WriteFile(f.Path, []byte(cleaned), 0644); err == nil { - cmd.Printf("%s Removed %d empty sections from %s\n", green("✓"), count, f.Name) + cmd.Printf( + "%s Removed %d empty sections from %s\n", green("✓"), count, f.Name, + ) changes += count } } diff --git a/internal/cli/compact/sanitize.go b/internal/cli/compact/sanitize.go index 4287b2235..fff361223 100644 --- a/internal/cli/compact/sanitize.go +++ b/internal/cli/compact/sanitize.go @@ -6,7 +6,11 @@ package compact -import "strings" +import ( + "strings" + + "github.com/ActiveMemory/ctx/internal/config" +) // removeEmptySections removes Markdown sections that contain no content. // @@ -20,7 +24,7 @@ import "strings" // - string: Content with empty sections removed // - int: Number of sections removed func removeEmptySections(content string) (string, int) { - lines := strings.Split(content, "\n") + lines := strings.Split(content, config.NewlineLF) var result []string removed := 0 @@ -41,8 +45,8 @@ func removeEmptySections(content string) (string, int) { // Check if we hit another section or end of the file if i >= len(lines) || - strings.HasPrefix(lines[i], "## ") || - strings.HasPrefix(lines[i], "# ") { + strings.HasPrefix(lines[i], config.HeadingLevelTwoStart) || + strings.HasPrefix(lines[i], config.HeadingLevelOneStart) { // Section is empty, skip it removed++ continue @@ -72,5 +76,5 @@ func truncateString(s string, maxLen int) string { if len(s) <= maxLen { return s } - return s[:maxLen-3] + "..." + return s[:maxLen-3] + config.Ellipsis } diff --git a/internal/cli/compact/task.go b/internal/cli/compact/task.go index af4837673..e90d7fe42 100644 --- a/internal/cli/compact/task.go +++ b/internal/cli/compact/task.go @@ -25,7 +25,7 @@ import ( // // Scans TASKS.md for checked items ("- [x]") outside the Completed section, // including their nested content (indented lines below the task). -// Only moves tasks where all nested sub-tasks are also complete. +// This only moves tasks where all nested subtasks are also complete. // Optionally archives them to .context/archive/. // // Parameters: @@ -84,13 +84,13 @@ func compactTasks( // Remove archivable blocks from lines newLines := RemoveBlocksFromLines(lines, archivableBlocks) - // Add blocks to Completed section + // Add blocks to the Completed section for i, line := range newLines { if strings.HasPrefix(line, "## Completed") { // Find the next line that's either empty or another section insertIdx := i + 1 for insertIdx < len(newLines) && newLines[insertIdx] != "" && - !strings.HasPrefix(newLines[insertIdx], "## ") { + !strings.HasPrefix(newLines[insertIdx], config.HeadingLevelTwoStart) { insertIdx++ } @@ -112,7 +112,7 @@ func compactTasks( // Archive if requested if archive && len(archivableBlocks) > 0 { // Filter to only tasks old enough to archive - archiveDays := rc.GetArchiveAfterDays() + archiveDays := rc.ArchiveAfterDays() var blocksToArchive []TaskBlock for _, block := range archivableBlocks { if block.OlderThan(archiveDays) { @@ -121,7 +121,7 @@ func compactTasks( } if len(blocksToArchive) > 0 { - archiveDir := filepath.Join(rc.GetContextDir(), "archive") + archiveDir := filepath.Join(rc.ContextDir(), "archive") if err := os.MkdirAll(archiveDir, 0755); err == nil { archiveFile := filepath.Join( archiveDir, @@ -146,7 +146,7 @@ func compactTasks( } // Write back - newContent := strings.Join(newLines, "\n") + newContent := strings.Join(newLines, config.NewlineLF) if newContent != content { if err := os.WriteFile( tasksFile.Path, []byte(newContent), 0644, diff --git a/internal/cli/compact/types.go b/internal/cli/compact/types.go index 478796cde..ff1281dd6 100644 --- a/internal/cli/compact/types.go +++ b/internal/cli/compact/types.go @@ -16,7 +16,8 @@ import "time" // - 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 +// - DoneTime: When the task was marked done (from #done: timestamp), +// nil if not present type TaskBlock struct { Lines []string StartIndex int diff --git a/internal/cli/complete/run.go b/internal/cli/complete/run.go index b26d75b4b..e6d91a1d1 100644 --- a/internal/cli/complete/run.go +++ b/internal/cli/complete/run.go @@ -36,7 +36,7 @@ import ( func runComplete(cmd *cobra.Command, args []string) error { query := args[0] - filePath := filepath.Join(rc.GetContextDir(), config.FileTask) + filePath := filepath.Join(rc.ContextDir(), config.FileTask) // Check if the file exists if _, err := os.Stat(filePath); os.IsNotExist(err) { diff --git a/internal/cli/decision/decision_test.go b/internal/cli/decision/decision_test.go new file mode 100644 index 000000000..84df63e7b --- /dev/null +++ b/internal/cli/decision/decision_test.go @@ -0,0 +1,53 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package decision + +import ( + "testing" +) + +func TestCmd(t *testing.T) { + cmd := Cmd() + + if cmd == nil { + t.Fatal("Cmd() returned nil") + } + + if cmd.Use != "decisions" { + t.Errorf("Cmd().Use = %q, want %q", cmd.Use, "decisions") + } + + if cmd.Short == "" { + t.Error("Cmd().Short is empty") + } + + if cmd.Long == "" { + t.Error("Cmd().Long is empty") + } +} + +func TestCmd_HasReindexSubcommand(t *testing.T) { + cmd := Cmd() + + var found bool + for _, sub := range cmd.Commands() { + if sub.Use == "reindex" { + found = true + if sub.Short == "" { + t.Error("reindex subcommand has empty Short description") + } + if sub.RunE == nil { + t.Error("reindex subcommand has no RunE function") + } + break + } + } + + if !found { + t.Error("reindex subcommand not found") + } +} diff --git a/internal/cli/decision/run.go b/internal/cli/decision/run.go index e3ee9c45a..47545d898 100644 --- a/internal/cli/decision/run.go +++ b/internal/cli/decision/run.go @@ -7,11 +7,8 @@ package decision import ( - "fmt" - "os" "path/filepath" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/ActiveMemory/ctx/internal/config" @@ -28,33 +25,12 @@ import ( // 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 + filePath := filepath.Join(rc.ContextDir(), config.FileDecision) + return index.ReindexFile( + cmd.OutOrStdout(), + filePath, + config.FileDecision, + index.UpdateDecisions, + config.EntryPlural[config.EntryDecision], + ) } diff --git a/internal/cli/drift/fix.go b/internal/cli/drift/fix.go index 946793c13..06e183113 100644 --- a/internal/cli/drift/fix.go +++ b/internal/cli/drift/fix.go @@ -21,7 +21,7 @@ import ( "github.com/ActiveMemory/ctx/internal/drift" "github.com/ActiveMemory/ctx/internal/rc" "github.com/ActiveMemory/ctx/internal/task" - "github.com/ActiveMemory/ctx/internal/templates" + "github.com/ActiveMemory/ctx/internal/tpl" ) // fixResult tracks fixes applied during drift fix. @@ -156,7 +156,7 @@ func fixStaleness(cmd *cobra.Command, ctx *context.Context) error { } // Create an archive directory - archiveDir := filepath.Join(rc.GetContextDir(), "archive") + archiveDir := filepath.Join(rc.ContextDir(), "archive") if err := os.MkdirAll(archiveDir, 0755); err != nil { return fmt.Errorf("failed to create archive directory: %w", err) } @@ -205,15 +205,15 @@ func fixStaleness(cmd *cobra.Command, ctx *context.Context) error { // Returns: // - error: Non-nil if the template is not found or file write fails func fixMissingFile(filename string) error { - content, err := templates.GetTemplate(filename) + content, err := tpl.Template(filename) if err != nil { return fmt.Errorf("no template available for %s: %w", filename, err) } - targetPath := filepath.Join(rc.GetContextDir(), filename) + targetPath := filepath.Join(rc.ContextDir(), filename) // Ensure .context/ directory exists - if err := os.MkdirAll(rc.GetContextDir(), 0755); err != nil { + if err := os.MkdirAll(rc.ContextDir(), 0755); err != nil { return fmt.Errorf("failed to create .context/: %w", err) } diff --git a/internal/cli/initialize/claude.go b/internal/cli/initialize/claude.go index 47159eed4..58195b81e 100644 --- a/internal/cli/initialize/claude.go +++ b/internal/cli/initialize/claude.go @@ -17,7 +17,7 @@ import ( "github.com/spf13/cobra" "github.com/ActiveMemory/ctx/internal/config" - "github.com/ActiveMemory/ctx/internal/templates" + "github.com/ActiveMemory/ctx/internal/tpl" ) // handleClaudeMd creates or merges CLAUDE.md in the project root. @@ -41,7 +41,7 @@ func handleClaudeMd(cmd *cobra.Command, force, autoMerge bool) error { yellow := color.New(color.FgYellow).SprintFunc() // Get template content - templateContent, err := templates.GetTemplate("CLAUDE.md") + templateContent, err := tpl.Template("CLAUDE.md") if err != nil { return fmt.Errorf("failed to read CLAUDE.md template: %w", err) } diff --git a/internal/cli/initialize/fs.go b/internal/cli/initialize/fs.go index 63eee45ba..54b505d8e 100644 --- a/internal/cli/initialize/fs.go +++ b/internal/cli/initialize/fs.go @@ -13,7 +13,7 @@ import ( "time" "github.com/ActiveMemory/ctx/internal/config" - "github.com/ActiveMemory/ctx/internal/templates" + "github.com/ActiveMemory/ctx/internal/tpl" "github.com/fatih/color" "github.com/spf13/cobra" ) @@ -174,7 +174,7 @@ func createImplementationPlan(cmd *cobra.Command, force bool) error { } // Get template content - content, err := templates.GetTemplate(planFileName) + content, err := tpl.Template(planFileName) if err != nil { return fmt.Errorf("failed to read template: %w", err) } diff --git a/internal/cli/initialize/hook.go b/internal/cli/initialize/hook.go index 762c8b036..bc1952ad0 100644 --- a/internal/cli/initialize/hook.go +++ b/internal/cli/initialize/hook.go @@ -149,7 +149,7 @@ func mergeSettingsHooks( // Get our defaults defaultHooks := claude.DefaultHooks(projectDir) - defaultPerms := claude.DefaultPermissions() + defaultPerms := config.DefaultClaudePermissions // Check if hooks already exist hasPreToolUse := len(settings.Hooks.PreToolUse) > 0 diff --git a/internal/cli/initialize/run.go b/internal/cli/initialize/run.go index facf55d84..5df5c7b38 100644 --- a/internal/cli/initialize/run.go +++ b/internal/cli/initialize/run.go @@ -18,7 +18,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config" "github.com/ActiveMemory/ctx/internal/rc" - "github.com/ActiveMemory/ctx/internal/templates" + "github.com/ActiveMemory/ctx/internal/tpl" ) // runInit executes the init command logic. @@ -40,7 +40,7 @@ func runInit(cmd *cobra.Command, force, minimal, merge bool) error { return err } - contextDir := rc.GetContextDir() + contextDir := rc.ContextDir() // Check if .context/ already exists if _, err := os.Stat(contextDir); err == nil { @@ -70,7 +70,7 @@ func runInit(cmd *cobra.Command, force, minimal, merge bool) error { if minimal { templatesToCreate = config.RequiredFiles } else { - allTemplates, err := templates.ListTemplates() + allTemplates, err := tpl.List() if err != nil { return fmt.Errorf("failed to list templates: %w", err) } @@ -95,7 +95,7 @@ func runInit(cmd *cobra.Command, force, minimal, merge bool) error { continue } - content, err := templates.GetTemplate(name) + content, err := tpl.Template(name) if err != nil { return fmt.Errorf("failed to read template %s: %w", name, err) } diff --git a/internal/cli/initialize/tpl.go b/internal/cli/initialize/tpl.go index d70033be5..5f3d1aa59 100644 --- a/internal/cli/initialize/tpl.go +++ b/internal/cli/initialize/tpl.go @@ -14,7 +14,7 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" - "github.com/ActiveMemory/ctx/internal/templates" + "github.com/ActiveMemory/ctx/internal/tpl" ) // createEntryTemplates creates .context/templates/ with entry templates for @@ -42,7 +42,7 @@ func createEntryTemplates( } // Get list of entry templates - entryTemplates, err := templates.ListEntryTemplates() + entryTemplates, err := tpl.ListEntry() if err != nil { return fmt.Errorf("failed to list entry templates: %w", err) } @@ -56,7 +56,7 @@ func createEntryTemplates( continue } - content, err := templates.GetEntryTemplate(name) + content, err := tpl.Entry(name) if err != nil { return fmt.Errorf("failed to read entry template %s: %w", name, err) } diff --git a/internal/cli/journal/journal_test.go b/internal/cli/journal/journal_test.go new file mode 100644 index 000000000..be3dce1c9 --- /dev/null +++ b/internal/cli/journal/journal_test.go @@ -0,0 +1,301 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package journal + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCmd(t *testing.T) { + cmd := Cmd() + + if cmd == nil { + t.Fatal("Cmd() returned nil") + } + + if cmd.Use != "journal" { + t.Errorf("Cmd().Use = %q, want %q", cmd.Use, "journal") + } + + if cmd.Short == "" { + t.Error("Cmd().Short is empty") + } + + if cmd.Long == "" { + t.Error("Cmd().Long is empty") + } +} + +func TestCmd_HasSiteSubcommand(t *testing.T) { + cmd := Cmd() + + var found bool + for _, sub := range cmd.Commands() { + if sub.Use == "site" { + found = true + if sub.Short == "" { + t.Error("site subcommand has empty Short description") + } + if sub.RunE == nil { + t.Error("site subcommand has no RunE function") + } + + // Check flags + outputFlag := sub.Flags().Lookup("output") + if outputFlag == nil { + t.Error("site subcommand missing --output flag") + } + + buildFlag := sub.Flags().Lookup("build") + if buildFlag == nil { + t.Error("site subcommand missing --build flag") + } + + serveFlag := sub.Flags().Lookup("serve") + if serveFlag == nil { + t.Error("site subcommand missing --serve flag") + } + + break + } + } + + if !found { + t.Error("site subcommand not found") + } +} + +func TestFormatSize(t *testing.T) { + tests := []struct { + bytes int64 + want string + }{ + {0, "0B"}, + {100, "100B"}, + {1023, "1023B"}, + {1024, "1.0KB"}, + {1536, "1.5KB"}, + {10240, "10.0KB"}, + {1048576, "1.0MB"}, + {1572864, "1.5MB"}, + } + + for _, tt := range tests { + t.Run(tt.want, func(t *testing.T) { + got := formatSize(tt.bytes) + if got != tt.want { + t.Errorf("formatSize(%d) = %q, want %q", tt.bytes, got, tt.want) + } + }) + } +} + +func TestParseJournalEntry(t *testing.T) { + // Create a temp file with journal content + tmpDir := t.TempDir() + filename := "2026-01-21-test-slug-abc12345.md" + content := `# Test Session + +**Time**: 14:30:00 +**Project**: my-project + +Some content here. +` + path := filepath.Join(tmpDir, filename) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + + entry := parseJournalEntry(path, filename) + + if entry.Filename != filename { + t.Errorf("Filename = %q, want %q", entry.Filename, filename) + } + + if entry.Date != "2026-01-21" { + t.Errorf("Date = %q, want %q", entry.Date, "2026-01-21") + } + + if entry.Title != "Test Session" { + t.Errorf("Title = %q, want %q", entry.Title, "Test Session") + } + + if entry.Time != "14:30:00" { + t.Errorf("Time = %q, want %q", entry.Time, "14:30:00") + } + + if entry.Project != "my-project" { + t.Errorf("Project = %q, want %q", entry.Project, "my-project") + } + + if entry.Size != int64(len(content)) { + t.Errorf("Size = %d, want %d", entry.Size, len(content)) + } +} + +func TestParseJournalEntry_SuggestionMode(t *testing.T) { + tmpDir := t.TempDir() + filename := "2026-01-21-suggestion-abc12345.md" + content := `# Suggestion + +[SUGGESTION MODE: some suggestion] + +Content here. +` + path := filepath.Join(tmpDir, filename) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + + entry := parseJournalEntry(path, filename) + + if !entry.IsSuggestion { + t.Error("IsSuggestion should be true for suggestion mode sessions") + } +} + +func TestParseJournalEntry_MissingFile(t *testing.T) { + entry := parseJournalEntry("/nonexistent/path.md", "2026-01-21-test.md") + + // Should use filename as title fallback + if entry.Title != "2026-01-21-test" { + t.Errorf("Title = %q, want %q", entry.Title, "2026-01-21-test") + } +} + +func TestGenerateIndex(t *testing.T) { + entries := []journalEntry{ + { + Filename: "2026-01-21-session-one-abc12345.md", + Title: "Session One", + Date: "2026-01-21", + Time: "14:30:00", + Project: "project-a", + Size: 1024, + }, + { + Filename: "2026-01-20-session-two-def67890.md", + Title: "Session Two", + Date: "2026-01-20", + Time: "10:00:00", + Project: "project-b", + Size: 2048, + }, + { + Filename: "2026-01-19-suggestion-ghi11111.md", + Title: "Suggestion", + Date: "2026-01-19", + Time: "09:00:00", + IsSuggestion: true, + Size: 512, + }, + } + + index := generateIndex(entries) + + // Should have header + if !strings.Contains(index, "# Session Journal") { + t.Error("index missing header") + } + + // Should have session count + if !strings.Contains(index, "**Sessions**: 2") { + t.Error("index missing session count") + } + + // Should have suggestions count + if !strings.Contains(index, "**Suggestions**: 1") { + t.Error("index missing suggestions count") + } + + // Should have month headers + if !strings.Contains(index, "## 2026-01") { + t.Error("index missing month header") + } + + // Should have entry links + if !strings.Contains(index, "[Session One]") { + t.Error("index missing session one link") + } + + // Should have suggestions section + if !strings.Contains(index, "## Suggestions") { + t.Error("index missing suggestions section") + } +} + +func TestFormatIndexEntry(t *testing.T) { + entry := journalEntry{ + Filename: "2026-01-21-test-abc12345.md", + Title: "Test Session", + Date: "2026-01-21", + Time: "14:30:00", + Project: "my-project", + Size: 1536, + } + + result := formatIndexEntry(entry, "\n") + + // Should have time prefix + if !strings.Contains(result, "14:30") { + t.Error("entry missing time prefix") + } + + // Should have title link + if !strings.Contains(result, "[Test Session]") { + t.Error("entry missing title") + } + + // Should have link to md file + if !strings.Contains(result, "(2026-01-21-test-abc12345.md)") { + t.Error("entry missing link") + } + + // Should have project + if !strings.Contains(result, "(my-project)") { + t.Error("entry missing project") + } + + // Should have size + if !strings.Contains(result, "1.5KB") { + t.Error("entry missing size") + } +} + +func TestGenerateZensicalToml(t *testing.T) { + entries := []journalEntry{ + { + Filename: "2026-01-21-test.md", + Title: "Test Session", + }, + } + + toml := generateZensicalToml(entries) + + // Should have project section + if !strings.Contains(toml, "[project]") { + t.Error("toml missing [project] section") + } + + // Should have site name + if !strings.Contains(toml, `site_name = "Session Journal"`) { + t.Error("toml missing site_name") + } + + // Should have nav + if !strings.Contains(toml, "nav = [") { + t.Error("toml missing nav") + } + + // Should have theme section + if !strings.Contains(toml, "[project.theme]") { + t.Error("toml missing theme section") + } +} diff --git a/internal/cli/journal/site.go b/internal/cli/journal/site.go index 2ec674e92..6e7bdaaa9 100644 --- a/internal/cli/journal/site.go +++ b/internal/cli/journal/site.go @@ -55,7 +55,7 @@ Examples: }, } - defaultOutput := filepath.Join(rc.GetContextDir(), "journal-site") + defaultOutput := filepath.Join(rc.ContextDir(), "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") @@ -89,7 +89,7 @@ type journalEntry struct { // Returns: // - error: Non-nil if generation fails func runJournalSite(cmd *cobra.Command, output string, build, serve bool) error { - journalDir := filepath.Join(rc.GetContextDir(), "journal") + journalDir := filepath.Join(rc.ContextDir(), "journal") // Check if journal directory exists if _, err := os.Stat(journalDir); os.IsNotExist(err) { diff --git a/internal/cli/learnings/learnings.go b/internal/cli/learnings/learnings.go index b0faf9b12..34b88a886 100644 --- a/internal/cli/learnings/learnings.go +++ b/internal/cli/learnings/learnings.go @@ -8,11 +8,8 @@ package learnings import ( - "fmt" - "os" "path/filepath" - "github.com/fatih/color" "github.com/spf13/cobra" "github.com/ActiveMemory/ctx/internal/config" @@ -79,35 +76,12 @@ Examples: // Returns: // - error: Non-nil if file read/write fails func runReindex(cmd *cobra.Command, _ []string) error { - filePath := filepath.Join(rc.GetContextDir(), config.FileLearning) - - if _, err := os.Stat(filePath); os.IsNotExist(err) { - return fmt.Errorf( - "%s not found. Run 'ctx init' first", config.FileLearning, - ) - } - - content, err := os.ReadFile(filePath) - if err != nil { - return fmt.Errorf("failed to read %s: %w", filePath, err) - } - - updated := index.UpdateLearnings(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 learnings found)\n", green("✓")) - } else { - cmd.Printf( - "%s Index regenerated with %d entries\n", green("✓"), - len(entries), - ) - } - - return nil + filePath := filepath.Join(rc.ContextDir(), config.FileLearning) + return index.ReindexFile( + cmd.OutOrStdout(), + filePath, + config.FileLearning, + index.UpdateLearnings, + config.EntryPlural[config.EntryLearning], + ) } diff --git a/internal/cli/learnings/learnings_test.go b/internal/cli/learnings/learnings_test.go new file mode 100644 index 000000000..f0cc5d328 --- /dev/null +++ b/internal/cli/learnings/learnings_test.go @@ -0,0 +1,53 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package learnings + +import ( + "testing" +) + +func TestCmd(t *testing.T) { + cmd := Cmd() + + if cmd == nil { + t.Fatal("Cmd() returned nil") + } + + if cmd.Use != "learnings" { + t.Errorf("Cmd().Use = %q, want %q", cmd.Use, "learnings") + } + + if cmd.Short == "" { + t.Error("Cmd().Short is empty") + } + + if cmd.Long == "" { + t.Error("Cmd().Long is empty") + } +} + +func TestCmd_HasReindexSubcommand(t *testing.T) { + cmd := Cmd() + + var found bool + for _, sub := range cmd.Commands() { + if sub.Use == "reindex" { + found = true + if sub.Short == "" { + t.Error("reindex subcommand has empty Short description") + } + if sub.RunE == nil { + t.Error("reindex subcommand has no RunE function") + } + break + } + } + + if !found { + t.Error("reindex subcommand not found") + } +} diff --git a/internal/cli/load/load.go b/internal/cli/load/load.go index 06e8c7fe4..ce6b28d7a 100644 --- a/internal/cli/load/load.go +++ b/internal/cli/load/load.go @@ -51,7 +51,7 @@ 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 = rc.GetTokenBudget() + budget = rc.TokenBudget() } return runLoad(cmd, budget, raw) }, diff --git a/internal/cli/recall/export.go b/internal/cli/recall/export.go index d6d8b866d..7ecae4390 100644 --- a/internal/cli/recall/export.go +++ b/internal/cli/recall/export.go @@ -135,7 +135,7 @@ func runRecallExport(cmd *cobra.Command, args []string, all, allProjects, force } // Ensure journal directory exists - journalDir := filepath.Join(rc.GetContextDir(), "journal") + journalDir := filepath.Join(rc.ContextDir(), "journal") if err := os.MkdirAll(journalDir, 0755); err != nil { return fmt.Errorf("failed to create journal directory: %w", err) } diff --git a/internal/cli/recall/recall_test.go b/internal/cli/recall/recall_test.go new file mode 100644 index 000000000..65ac35e2d --- /dev/null +++ b/internal/cli/recall/recall_test.go @@ -0,0 +1,193 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package recall + +import ( + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/recall/parser" +) + +func TestCmd(t *testing.T) { + cmd := Cmd() + + if cmd == nil { + t.Fatal("Cmd() returned nil") + } + + if cmd.Use != "recall" { + t.Errorf("Cmd().Use = %q, want %q", cmd.Use, "recall") + } + + if cmd.Short == "" { + t.Error("Cmd().Short is empty") + } + + if cmd.Long == "" { + t.Error("Cmd().Long is empty") + } +} + +func TestCmd_HasSubcommands(t *testing.T) { + cmd := Cmd() + + expectedSubs := []string{"list", "show", "export"} + subs := make(map[string]bool) + + for _, sub := range cmd.Commands() { + subs[sub.Name()] = true + } + + for _, exp := range expectedSubs { + if !subs[exp] { + t.Errorf("missing subcommand: %s", exp) + } + } +} + +func TestRecallListCmd_Flags(t *testing.T) { + cmd := Cmd() + + var listCmd *cobra.Command + for _, sub := range cmd.Commands() { + if sub.Name() == "list" { + listCmd = sub + break + } + } + + if listCmd == nil { + t.Fatal("list subcommand not found") + } + + // Check flags + flags := []string{"limit", "project", "tool", "all-projects"} + for _, f := range flags { + if listCmd.Flags().Lookup(f) == nil { + t.Errorf("list subcommand missing --%s flag", f) + } + } +} + +func TestRecallShowCmd_Flags(t *testing.T) { + cmd := Cmd() + + var showCmd *cobra.Command + for _, sub := range cmd.Commands() { + if sub.Name() == "show" { + showCmd = sub + break + } + } + + if showCmd == nil { + t.Fatal("show subcommand not found") + } + + // Check flags + flags := []string{"latest", "full", "all-projects"} + for _, f := range flags { + if showCmd.Flags().Lookup(f) == nil { + t.Errorf("show subcommand missing --%s flag", f) + } + } +} + +func TestRecallExportCmd_Flags(t *testing.T) { + cmd := Cmd() + + var exportCmd *cobra.Command + for _, sub := range cmd.Commands() { + if sub.Name() == "export" { + exportCmd = sub + break + } + } + + if exportCmd == nil { + t.Fatal("export subcommand not found") + } + + // Check flags + flags := []string{"all", "all-projects", "force"} + for _, f := range flags { + if exportCmd.Flags().Lookup(f) == nil { + t.Errorf("export subcommand missing --%s flag", f) + } + } +} + +func TestFormatJournalFilename(t *testing.T) { + session := &parser.Session{ + ID: "abc12345-6789-0123-4567-890123456789", + Slug: "gleaming-wobbling-sutherland", + StartTime: time.Date(2026, 1, 21, 14, 30, 0, 0, time.UTC), + } + + filename := formatJournalFilename(session) + + // Should contain slug + if !strings.Contains(filename, "gleaming-wobbling-sutherland") { + t.Errorf("filename missing slug: %q", filename) + } + + // Should contain short ID (first 8 chars) + if !strings.Contains(filename, "abc12345") { + t.Errorf("filename missing short ID: %q", filename) + } + + // Should end with .md + if !strings.HasSuffix(filename, ".md") { + t.Errorf("filename missing .md extension: %q", filename) + } +} + +func TestIsEmptyMessage(t *testing.T) { + tests := []struct { + name string + msg parser.Message + want bool + }{ + { + name: "empty message", + msg: parser.Message{}, + want: true, + }, + { + name: "message with text", + msg: parser.Message{Text: "Hello"}, + want: false, + }, + { + name: "message with tool uses", + msg: parser.Message{ + ToolUses: []parser.ToolUse{{Name: "Bash"}}, + }, + want: false, + }, + { + name: "message with tool results", + msg: parser.Message{ + ToolResults: []parser.ToolResult{{Content: "output"}}, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isEmptyMessage(tt.msg) + if got != tt.want { + t.Errorf("isEmptyMessage() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/cli/serve/serve.go b/internal/cli/serve/serve.go index 778e77d24..5b866f3fd 100644 --- a/internal/cli/serve/serve.go +++ b/internal/cli/serve/serve.go @@ -62,7 +62,7 @@ func runServe(cmd *cobra.Command, args []string) error { dir = args[0] } else { // Default: journal site - dir = filepath.Join(rc.GetContextDir(), "journal-site") + dir = filepath.Join(rc.ContextDir(), "journal-site") } // Verify directory exists diff --git a/internal/cli/serve/serve_test.go b/internal/cli/serve/serve_test.go new file mode 100644 index 000000000..52d5a6519 --- /dev/null +++ b/internal/cli/serve/serve_test.go @@ -0,0 +1,52 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package serve + +import ( + "testing" +) + +func TestCmd(t *testing.T) { + cmd := Cmd() + + if cmd == nil { + t.Fatal("Cmd() returned nil") + } + + if cmd.Use != "serve [directory]" { + t.Errorf("Cmd().Use = %q, want %q", cmd.Use, "serve [directory]") + } + + if cmd.Short == "" { + t.Error("Cmd().Short is empty") + } + + if cmd.Long == "" { + t.Error("Cmd().Long is empty") + } + + if cmd.RunE == nil { + t.Error("Cmd().RunE is nil") + } +} + +func TestCmd_AcceptsArgs(t *testing.T) { + cmd := Cmd() + + // Should accept 0 or 1 args + if err := cmd.Args(cmd, []string{}); err != nil { + t.Errorf("should accept 0 args: %v", err) + } + + if err := cmd.Args(cmd, []string{"./docs"}); err != nil { + t.Errorf("should accept 1 arg: %v", err) + } + + if err := cmd.Args(cmd, []string{"a", "b"}); err == nil { + t.Error("should reject 2 args") + } +} diff --git a/internal/cli/session/fs.go b/internal/cli/session/fs.go index c765c23ae..1a5479268 100644 --- a/internal/cli/session/fs.go +++ b/internal/cli/session/fs.go @@ -18,5 +18,5 @@ import ( // Returns: // - string: Full path to .context/sessions/ func sessionsDirPath() string { - return filepath.Join(rc.GetContextDir(), config.DirSessions) + return filepath.Join(rc.ContextDir(), config.DirSessions) } diff --git a/internal/cli/session/read.go b/internal/cli/session/read.go index 9fb10fd86..558f7c0e1 100644 --- a/internal/cli/session/read.go +++ b/internal/cli/session/read.go @@ -25,7 +25,7 @@ import ( // - string: File content // - error: Non-nil if the file cannot be read func readContextFile(fileName string) (string, error) { - filePath := filepath.Join(rc.GetContextDir(), fileName) + filePath := filepath.Join(rc.ContextDir(), fileName) content, err := os.ReadFile(filePath) if err != nil { return "", err @@ -52,7 +52,7 @@ func readContextFile(fileName string) (string, error) { func readContextSection( filename, startHeader, endHeader string, ) (string, error) { - filePath := filepath.Join(rc.GetContextDir(), filename) + filePath := filepath.Join(rc.ContextDir(), filename) content, err := os.ReadFile(filePath) if err != nil { return "", err diff --git a/internal/cli/task/path.go b/internal/cli/task/path.go index 6861ae075..6f2c9b30d 100644 --- a/internal/cli/task/path.go +++ b/internal/cli/task/path.go @@ -12,7 +12,7 @@ import ( // Returns: // - string: Full path to .context/TASKS.md func tasksFilePath() string { - return filepath.Join(rc.GetContextDir(), config.FileTask) + return filepath.Join(rc.ContextDir(), config.FileTask) } // archiveDirPath returns the path to the archive directory. @@ -20,5 +20,5 @@ func tasksFilePath() string { // Returns: // - string: Full path to .context/archive/ func archiveDirPath() string { - return filepath.Join(rc.GetContextDir(), config.DirArchive) + return filepath.Join(rc.ContextDir(), config.DirArchive) } diff --git a/internal/cli/watch/run.go b/internal/cli/watch/run.go index 7f5b9a7aa..523af45bd 100644 --- a/internal/cli/watch/run.go +++ b/internal/cli/watch/run.go @@ -124,7 +124,7 @@ func runCompleteSilent(args []string) error { } query := args[0] - filePath := filepath.Join(rc.GetContextDir(), config.FileTask) + filePath := filepath.Join(rc.ContextDir(), config.FileTask) nl := config.NewlineLF content, err := os.ReadFile(filePath) diff --git a/internal/cli/watch/session.go b/internal/cli/watch/session.go index 5dd3443da..a802562c5 100644 --- a/internal/cli/watch/session.go +++ b/internal/cli/watch/session.go @@ -29,7 +29,7 @@ import ( // Returns: // - error: Non-nil if directory creation or file write fails func watchAutoSaveSession(updates []ContextUpdate) error { - sessionsDir := filepath.Join(rc.GetContextDir(), config.DirSessions) + sessionsDir := filepath.Join(rc.ContextDir(), config.DirSessions) if err := os.MkdirAll(sessionsDir, 0755); err != nil { return fmt.Errorf("failed to create sessions directory: %w", err) } @@ -100,7 +100,7 @@ func buildWatchSession(timestamp time.Time, updates []ContextUpdate) string { sb.WriteString(config.Separator + nl + nl) sb.WriteString("## Context Snapshot" + nl + nl) - tasksPath := filepath.Join(rc.GetContextDir(), config.FileTask) + tasksPath := filepath.Join(rc.ContextDir(), config.FileTask) if tasksContent, err := os.ReadFile(tasksPath); err == nil { sb.WriteString("### Current Tasks" + nl + nl) sb.WriteString("```markdown" + nl) diff --git a/internal/cli/watch/watch_test.go b/internal/cli/watch/watch_test.go index 02b4190a7..d8ef3f6f8 100644 --- a/internal/cli/watch/watch_test.go +++ b/internal/cli/watch/watch_test.go @@ -116,7 +116,7 @@ func TestApplyUpdate(t *testing.T) { } // Verify content was added - filePath := filepath.Join(rc.GetContextDir(), tt.checkFile) + filePath := filepath.Join(rc.ContextDir(), tt.checkFile) content, err := os.ReadFile(filePath) if err != nil { t.Fatalf("failed to read %s: %v", tt.checkFile, err) @@ -150,7 +150,7 @@ func TestApplyCompleteUpdate(t *testing.T) { } // Add a task to complete - tasksPath := filepath.Join(rc.GetContextDir(), config.FileTask) + tasksPath := filepath.Join(rc.ContextDir(), config.FileTask) tasksContent := `# Tasks ## Next Up @@ -263,7 +263,7 @@ More output } // Verify task was written - tasksPath := filepath.Join(rc.GetContextDir(), config.FileTask) + tasksPath := filepath.Join(rc.ContextDir(), config.FileTask) content, err := os.ReadFile(tasksPath) if err != nil { t.Fatalf("failed to read tasks: %v", err) @@ -314,7 +314,7 @@ More output } // Verify learning was written with structured fields - learningsPath := filepath.Join(rc.GetContextDir(), config.FileLearning) + learningsPath := filepath.Join(rc.ContextDir(), config.FileLearning) content, err := os.ReadFile(learningsPath) if err != nil { t.Fatalf("failed to read learnings: %v", err) diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 000000000..bc981a029 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,562 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package config + +import "testing" + +func TestUserInputToEntry(t *testing.T) { + tests := []struct { + input string + want string + }{ + // Task variations + {"task", EntryTask}, + {"tasks", EntryTask}, + {"Task", EntryTask}, + {"TASKS", EntryTask}, + + // Decision variations + {"decision", EntryDecision}, + {"decisions", EntryDecision}, + {"Decision", EntryDecision}, + {"DECISION", EntryDecision}, + + // Learning variations + {"learning", EntryLearning}, + {"learnings", EntryLearning}, + {"Learning", EntryLearning}, + {"LEARNINGS", EntryLearning}, + + // Convention variations + {"convention", EntryConvention}, + {"conventions", EntryConvention}, + {"Convention", EntryConvention}, + {"CONVENTIONS", EntryConvention}, + + // Unknown inputs + {"", EntryUnknown}, + {"unknown", EntryUnknown}, + {"foo", EntryUnknown}, + {"taskss", EntryUnknown}, + {"learn", EntryUnknown}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := UserInputToEntry(tt.input) + if got != tt.want { + t.Errorf("UserInputToEntry(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestRegExFromAttrName(t *testing.T) { + tests := []struct { + name string + attrName string + input string + wantMatch bool + wantValue string + }{ + { + name: "type attribute", + attrName: "type", + input: `type="task"`, + wantMatch: true, + wantValue: "task", + }, + { + name: "context attribute", + attrName: "context", + input: `context="some context here"`, + wantMatch: true, + wantValue: "some context here", + }, + { + name: "attribute in larger string", + attrName: "id", + input: ``, + wantMatch: true, + wantValue: "123", + }, + { + name: "no match", + attrName: "missing", + input: `type="task"`, + wantMatch: false, + wantValue: "", + }, + { + name: "empty value", + attrName: "empty", + input: `empty=""`, + wantMatch: true, + wantValue: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + re := RegExFromAttrName(tt.attrName) + match := re.FindStringSubmatch(tt.input) + + if tt.wantMatch { + if match == nil { + t.Errorf("expected match for %q in %q", tt.attrName, tt.input) + return + } + if len(match) < 2 { + t.Errorf("match has no capture group") + return + } + if match[1] != tt.wantValue { + t.Errorf("got value %q, want %q", match[1], tt.wantValue) + } + } else { + if match != nil { + t.Errorf("expected no match for %q in %q, got %v", tt.attrName, tt.input, match) + } + } + }) + } +} + +func TestRegExEntryHeader(t *testing.T) { + tests := []struct { + name string + input string + wantMatch bool + wantDate string + wantTime string + wantTitle string + }{ + { + name: "valid entry header", + input: "## [2026-01-28-051426] Title here", + wantMatch: true, + wantDate: "2026-01-28", + wantTime: "051426", + wantTitle: "Title here", + }, + { + name: "entry with long title", + input: "## [2026-12-31-235959] A much longer title with spaces and stuff", + wantMatch: true, + wantDate: "2026-12-31", + wantTime: "235959", + wantTitle: "A much longer title with spaces and stuff", + }, + { + name: "invalid - missing time", + input: "## [2026-01-28] Title", + wantMatch: false, + }, + { + name: "invalid - wrong format", + input: "## Title without timestamp", + wantMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match := RegExEntryHeader.FindStringSubmatch(tt.input) + + if tt.wantMatch { + if match == nil { + t.Errorf("expected match for %q", tt.input) + return + } + if match[1] != tt.wantDate { + t.Errorf("date = %q, want %q", match[1], tt.wantDate) + } + if match[2] != tt.wantTime { + t.Errorf("time = %q, want %q", match[2], tt.wantTime) + } + if match[3] != tt.wantTitle { + t.Errorf("title = %q, want %q", match[3], tt.wantTitle) + } + } else { + if match != nil { + t.Errorf("expected no match for %q", tt.input) + } + } + }) + } +} + +func TestRegExTask(t *testing.T) { + tests := []struct { + name string + input string + wantMatch bool + wantIndent string + wantState string + wantContent string + }{ + { + name: "pending task", + input: "- [ ] Do something", + wantMatch: true, + wantIndent: "", + wantState: " ", + wantContent: "Do something", + }, + { + name: "completed task", + input: "- [x] Done task", + wantMatch: true, + wantIndent: "", + wantState: "x", + wantContent: "Done task", + }, + { + name: "indented task", + input: " - [ ] Subtask", + wantMatch: true, + wantIndent: " ", + wantState: " ", + wantContent: "Subtask", + }, + { + name: "empty checkbox", + input: "- [] Task with empty checkbox", + wantMatch: true, + wantIndent: "", + wantState: "", + wantContent: "Task with empty checkbox", + }, + { + name: "task with tags", + input: "- [ ] Task #added:2026-01-15-120000 #in-progress", + wantMatch: true, + wantIndent: "", + wantState: " ", + wantContent: "Task #added:2026-01-15-120000 #in-progress", + }, + { + name: "not a task - regular bullet", + input: "- Regular bullet point", + wantMatch: false, + }, + { + name: "not a task - numbered list", + input: "1. Numbered item", + wantMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match := RegExTask.FindStringSubmatch(tt.input) + + if tt.wantMatch { + if match == nil { + t.Errorf("expected match for %q", tt.input) + return + } + if match[1] != tt.wantIndent { + t.Errorf("indent = %q, want %q", match[1], tt.wantIndent) + } + if match[2] != tt.wantState { + t.Errorf("state = %q, want %q", match[2], tt.wantState) + } + if match[3] != tt.wantContent { + t.Errorf("content = %q, want %q", match[3], tt.wantContent) + } + } else { + if match != nil { + t.Errorf("expected no match for %q", tt.input) + } + } + }) + } +} + +func TestRegExTaskMultiline(t *testing.T) { + input := `# Tasks + +## Phase 1 + +- [x] First task +- [ ] Second task + - [ ] Subtask A + - [x] Subtask B + +## Phase 2 + +- [ ] Third task +` + + matches := RegExTaskMultiline.FindAllStringSubmatch(input, -1) + + if len(matches) != 5 { + t.Errorf("expected 5 matches, got %d", len(matches)) + } + + // Verify first match + if matches[0][3] != "First task" { + t.Errorf("first match content = %q, want %q", matches[0][3], "First task") + } + if matches[0][2] != "x" { + t.Errorf("first match state = %q, want %q", matches[0][2], "x") + } +} + +func TestRegExPhase(t *testing.T) { + tests := []struct { + input string + wantMatch bool + }{ + {"## Phase 1", true}, + {"### Phase 2: Setup", true}, + {"# Phase", true}, + {"###### Phase 99", true}, + {"Phase 1", false}, + {"##Phase 1", false}, + {"## phase 1", false}, // case sensitive + {"## Not a phase", false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + matched := RegExPhase.MatchString(tt.input) + if matched != tt.wantMatch { + t.Errorf("RegExPhase.MatchString(%q) = %v, want %v", tt.input, matched, tt.wantMatch) + } + }) + } +} + +func TestRegExTaskDoneTimestamp(t *testing.T) { + tests := []struct { + name string + input string + wantMatch bool + wantTime string + }{ + { + name: "task with done timestamp", + input: "- [x] Task #done:2026-01-15-143022", + wantMatch: true, + wantTime: "2026-01-15-143022", + }, + { + name: "task with multiple tags", + input: "- [x] Task #added:2026-01-01-000000 #done:2026-01-15-143022", + wantMatch: true, + wantTime: "2026-01-15-143022", + }, + { + name: "task without done", + input: "- [ ] Task #added:2026-01-01-000000", + wantMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match := RegExTaskDoneTimestamp.FindStringSubmatch(tt.input) + + if tt.wantMatch { + if match == nil { + t.Errorf("expected match for %q", tt.input) + return + } + if match[1] != tt.wantTime { + t.Errorf("timestamp = %q, want %q", match[1], tt.wantTime) + } + } else { + if match != nil { + t.Errorf("expected no match for %q", tt.input) + } + } + }) + } +} + +func TestRegExPath(t *testing.T) { + tests := []struct { + name string + input string + wantMatch bool + wantPath string + }{ + { + name: "go file", + input: "Check `internal/config/config.go` for details", + wantMatch: true, + wantPath: "internal/config/config.go", + }, + { + name: "markdown file", + input: "See `docs/README.md`", + wantMatch: true, + wantPath: "docs/README.md", + }, + { + name: "no extension", + input: "`Makefile`", + wantMatch: false, + }, + { + name: "code snippet not path", + input: "`fmt.Println`", + wantMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match := RegExPath.FindStringSubmatch(tt.input) + + if tt.wantMatch { + if match == nil { + t.Errorf("expected match for %q", tt.input) + return + } + if match[1] != tt.wantPath { + t.Errorf("path = %q, want %q", match[1], tt.wantPath) + } + } else { + if match != nil { + t.Errorf("expected no match for %q, got %v", tt.input, match) + } + } + }) + } +} + +func TestFileTypeMap(t *testing.T) { + // Verify FileType map contains expected mappings + expected := map[string]string{ + EntryDecision: FileDecision, + EntryTask: FileTask, + EntryLearning: FileLearning, + EntryConvention: FileConvention, + } + + for entry, file := range expected { + if FileType[entry] != file { + t.Errorf("FileType[%q] = %q, want %q", entry, FileType[entry], file) + } + } +} + +func TestRequiredFiles(t *testing.T) { + // Verify RequiredFiles contains essential files + required := map[string]bool{ + FileConstitution: false, + FileTask: false, + FileDecision: false, + } + + for _, f := range RequiredFiles { + if _, ok := required[f]; ok { + required[f] = true + } + } + + for f, found := range required { + if !found { + t.Errorf("RequiredFiles missing %q", f) + } + } +} + +func TestFileReadOrder(t *testing.T) { + // Verify FileReadOrder has expected files in order + if len(FileReadOrder) == 0 { + t.Error("FileReadOrder is empty") + } + + // Constitution should be first (most important) + if FileReadOrder[0] != FileConstitution { + t.Errorf("FileReadOrder[0] = %q, want %q (constitution should be first)", + FileReadOrder[0], FileConstitution) + } + + // Tasks should be second (what to work on) + if FileReadOrder[1] != FileTask { + t.Errorf("FileReadOrder[1] = %q, want %q (tasks should be second)", + FileReadOrder[1], FileTask) + } +} + +func TestEntryPlural(t *testing.T) { + tests := []struct { + entry string + want string + }{ + {EntryTask, "tasks"}, + {EntryDecision, "decisions"}, + {EntryLearning, "learnings"}, + {EntryConvention, "conventions"}, + } + + for _, tt := range tests { + t.Run(tt.entry, func(t *testing.T) { + got := EntryPlural[tt.entry] + if got != tt.want { + t.Errorf("EntryPlural[%q] = %q, want %q", tt.entry, got, tt.want) + } + }) + } +} + +func TestDefaultClaudePermissions(t *testing.T) { + if len(DefaultClaudePermissions) == 0 { + t.Error("DefaultClaudePermissions should not be empty") + } + + // Check that essential ctx commands are included + expected := []string{ + "Bash(ctx status:*)", + "Bash(ctx agent:*)", + "Bash(ctx add:*)", + "Bash(ctx session:*)", + } + + permSet := make(map[string]bool) + for _, p := range DefaultClaudePermissions { + permSet[p] = true + } + + for _, e := range expected { + if !permSet[e] { + t.Errorf("DefaultClaudePermissions missing: %s", e) + } + } +} + +func TestConstants(t *testing.T) { + // Verify important constants are set correctly + tests := []struct { + name string + got string + want string + }{ + {"DirContext", DirContext, ".context"}, + {"DirClaude", DirClaude, ".claude"}, + {"FileTask", FileTask, "TASKS.md"}, + {"FileDecision", FileDecision, "DECISIONS.md"}, + {"FileLearning", FileLearning, "LEARNINGS.md"}, + {"PrefixTaskUndone", PrefixTaskUndone, "- [ ]"}, + {"PrefixTaskDone", PrefixTaskDone, "- [x]"}, + {"IndexStart", IndexStart, ""}, + {"IndexEnd", IndexEnd, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.got != tt.want { + t.Errorf("%s = %q, want %q", tt.name, tt.got, tt.want) + } + }) + } +} diff --git a/internal/config/entry.go b/internal/config/entry.go index bac28ebda..5aabc62fe 100644 --- a/internal/config/entry.go +++ b/internal/config/entry.go @@ -27,6 +27,16 @@ const ( EntryUnknown = "unknown" ) +// EntryPlural maps entry type constants to their plural forms. +// +// Used for user-facing messages (e.g., "no decisions found"). +var EntryPlural = map[string]string{ + EntryTask: "tasks", + EntryDecision: "decisions", + EntryLearning: "learnings", + EntryConvention: "conventions", +} + // UserInputToEntry normalizes user input to a canonical entry type. // // Accepts both singular and plural forms (e.g., "task" or "tasks") and diff --git a/internal/config/file.go b/internal/config/file.go index e460ff7b3..cfb7f36eb 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -6,6 +6,46 @@ package config +// Runtime configuration constants. +const ( + // FileContextRC is the optional runtime configuration file. + FileContextRC = ".contextrc" +) + +// Environment configuration. +const ( + // EnvCtxDir is the environment variable for overriding the context directory. + EnvCtxDir = "CTX_DIR" + // EnvCtxTokenBudget is the environment variable for overriding the token budget. + EnvCtxTokenBudget = "CTX_TOKEN_BUDGET" +) + +// Parser configuration. +const ( + // ParserPeekLines is the number of lines to scan when detecting file format. + ParserPeekLines = 50 +) + +// Claude API content block types. +const ( + // ClaudeBlockText is a text content block. + ClaudeBlockText = "text" + // ClaudeBlockThinking is an extended thinking content block. + ClaudeBlockThinking = "thinking" + // ClaudeBlockToolUse is a tool invocation block. + ClaudeBlockToolUse = "tool_use" + // ClaudeBlockToolResult is a tool execution result block. + ClaudeBlockToolResult = "tool_result" +) + +// Claude API message roles. +const ( + // RoleUser is a user message. + RoleUser = "user" + // RoleAssistant is an assistant message. + RoleAssistant = "assistant" +) + // Claude Code integration file names. const ( // FileAutoSave is the hook script for auto-saving sessions. @@ -115,3 +155,16 @@ var Packages = map[string]string{ "requirements.txt": "Python dependencies", "Gemfile": "Ruby dependencies", } + +// DefaultClaudePermissions lists 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. +var DefaultClaudePermissions = []string{ + "Bash(ctx status:*)", + "Bash(ctx agent:*)", + "Bash(ctx add:*)", + "Bash(ctx session:*)", + "Bash(ctx tasks:*)", + "Bash(ctx loop:*)", +} diff --git a/internal/config/marker.go b/internal/config/marker.go index 0d5acb292..ae48d07bb 100644 --- a/internal/config/marker.go +++ b/internal/config/marker.go @@ -37,3 +37,8 @@ const ( // PrefixTaskDone is the prefix for a checked (completed) task item. PrefixTaskDone = "- [x]" ) + +const ( + // MarkTaskComplete is the unchecked task marker. + MarkTaskComplete = "x" +) diff --git a/internal/config/token.go b/internal/config/token.go index 13eeea84c..6cad20217 100644 --- a/internal/config/token.go +++ b/internal/config/token.go @@ -17,4 +17,8 @@ const ( Separator = "---" // Ellipsis is a Markdown ellipsis. Ellipsis = "..." + // HeadingLevelOneStart is the Markdown heading for the first section. + HeadingLevelOneStart = "# " + // HeadingLevelTwoStart is the Markdown heading for subsequent sections. + HeadingLevelTwoStart = "## " ) diff --git a/internal/context/loader.go b/internal/context/loader.go index 77e196133..94145c662 100644 --- a/internal/context/loader.go +++ b/internal/context/loader.go @@ -27,7 +27,7 @@ import ( // - error: NotFoundError if directory doesn't exist, or other IO errors func Load(dir string) (*Context, error) { if dir == "" { - dir = rc.GetContextDir() + dir = rc.ContextDir() } // Check if the directory exists diff --git a/internal/context/verify.go b/internal/context/verify.go index 6c4cb23b2..caa7cc35b 100644 --- a/internal/context/verify.go +++ b/internal/context/verify.go @@ -23,7 +23,7 @@ import ( // - bool: True if the directory exists and is a directory func Exists(dir string) bool { if dir == "" { - dir = rc.GetContextDir() + dir = rc.ContextDir() } info, err := os.Stat(dir) return err == nil && info.IsDir() diff --git a/internal/index/index.go b/internal/index/index.go index b7af7fae0..45dbbfc11 100644 --- a/internal/index/index.go +++ b/internal/index/index.go @@ -8,8 +8,13 @@ package index import ( + "fmt" + "io" + "os" "strings" + "github.com/fatih/color" + "github.com/ActiveMemory/ctx/internal/config" ) @@ -192,3 +197,63 @@ func UpdateDecisions(content string) string { func UpdateLearnings(content string) string { return Update(content, config.HeadingLearnings, config.ColumnLearning) } + +// ReindexFile reads a context file, regenerates its index, and writes it back. +// +// This is a convenience function that handles the common reindex workflow: +// check the file exists, read content, apply update function, write back, +// report. +// +// Note: This function uses io.Writer instead of *cobra.Command to keep the +// index package decoupled from CLI concerns. Callers pass cmd.OutOrStdout() +// which writes to the same destination as cmd.Printf. +// +// Parameters: +// - w: Writer for status output (typically cmd.OutOrStdout()) +// - filePath: Full path to the context file +// - fileName: Display name for error messages (e.g., "DECISIONS.md") +// - updateFunc: Function to regenerate the index (e.g., UpdateDecisions) +// - entryType: Plural noun for the status message (e.g., "decisions") +// +// Returns: +// - error: Non-nil if file operations fail +func ReindexFile( + w io.Writer, filePath, fileName string, + updateFunc func(string) string, + entryType string, +) error { + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return fmt.Errorf("%s not found. Run 'ctx init' first", fileName) + } + + content, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read %s: %w", filePath, err) + } + + updated := updateFunc(string(content)) + + if err := os.WriteFile(filePath, []byte(updated), 0644); err != nil { + return fmt.Errorf("failed to write %s: %w", filePath, err) + } + + entries := ParseHeaders(string(content)) + green := color.New(color.FgGreen).SprintFunc() + if len(entries) == 0 { + _, err := fmt.Fprintf( + w, "%s Index cleared (no %s found)\n", green("✓"), entryType) + if err != nil { + return err + } + } else { + _, err := fmt.Fprintf( + w, + "%s Index regenerated with %d entries\n", green("✓"), len(entries), + ) + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/rc/default.go b/internal/rc/default.go new file mode 100644 index 000000000..0179518fe --- /dev/null +++ b/internal/rc/default.go @@ -0,0 +1,13 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package rc + +// DefaultTokenBudget is the default token budget when not configured. +const DefaultTokenBudget = 8000 + +// DefaultArchiveAfterDays is the default days before archiving. +const DefaultArchiveAfterDays = 7 diff --git a/internal/rc/load.go b/internal/rc/load.go new file mode 100644 index 000000000..49dc2dafd --- /dev/null +++ b/internal/rc/load.go @@ -0,0 +1,44 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package rc + +import ( + "os" + "strconv" + + "gopkg.in/yaml.v3" + + "github.com/ActiveMemory/ctx/internal/config" +) + +// loadRC loads configuration from the .contextrc file and applies env +// overrides. +// +// Returns: +// - *CtxRC: Configuration with file values and env overrides applied +func loadRC() *CtxRC { + cfg := Default() + + // Try to load .contextrc from the current directory + data, err := os.ReadFile(config.FileContextRC) + if err == nil { + // Parse YAML, ignoring errors (use defaults for invalid config) + _ = yaml.Unmarshal(data, cfg) + } + + // Apply environment variable overrides + if envDir := os.Getenv(config.EnvCtxDir); envDir != "" { + cfg.ContextDir = envDir + } + if envBudget := os.Getenv(config.EnvCtxTokenBudget); envBudget != "" { + if budget, err := strconv.Atoi(envBudget); err == nil && budget > 0 { + cfg.TokenBudget = budget + } + } + + return cfg +} diff --git a/internal/rc/lock.go b/internal/rc/lock.go new file mode 100644 index 000000000..64febba89 --- /dev/null +++ b/internal/rc/lock.go @@ -0,0 +1,16 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package rc + +import "sync" + +var ( + rc *CtxRC + rcOnce sync.Once + rcOverrideDir string + rcMu sync.RWMutex +) diff --git a/internal/rc/rc.go b/internal/rc/rc.go index ab355a8ce..94c6923ea 100644 --- a/internal/rc/rc.go +++ b/internal/rc/rc.go @@ -8,49 +8,18 @@ package rc import ( - "os" - "strconv" "sync" - "gopkg.in/yaml.v3" - "github.com/ActiveMemory/ctx/internal/config" ) -// RC represents the configuration from .contextrc file. -// -// Fields: -// - ContextDir: Name of the context directory (default ".context") -// - TokenBudget: Default token budget for context assembly (default 8000) -// - PriorityOrder: Custom file loading priority order -// - AutoArchive: Whether to auto-archive completed tasks (default true) -// - ArchiveAfterDays: Days before archiving completed tasks (default 7) -type RC struct { - ContextDir string `yaml:"context_dir"` - TokenBudget int `yaml:"token_budget"` - PriorityOrder []string `yaml:"priority_order"` - AutoArchive bool `yaml:"auto_archive"` - ArchiveAfterDays int `yaml:"archive_after_days"` -} - -// DefaultTokenBudget is the default token budget when not configured. -const DefaultTokenBudget = 8000 - -// DefaultArchiveAfterDays is the default days before archiving. -const DefaultArchiveAfterDays = 7 - -var ( - rc *RC - rcOnce sync.Once - rcOverrideDir string -) - -// DefaultRC returns a new RC with hardcoded default values. +// Default returns a new CtxRC with hardcoded default values. // // Returns: -// - *RC: Configuration with defaults (8000 token budget, 7-day archive, etc.) -func DefaultRC() *RC { - return &RC{ +// - *CtxRC: Configuration with defaults +// (8000 token budget, 7-day archive, etc.) +func Default() *CtxRC { + return &CtxRC{ ContextDir: config.DirContext, TokenBudget: DefaultTokenBudget, PriorityOrder: nil, // nil means use config.FileReadOrder @@ -59,93 +28,68 @@ func DefaultRC() *RC { } } -// GetRC returns the loaded configuration, initializing it on first call. +// RC returns the loaded configuration, initializing it on the first call. // // It loads from .contextrc if present, then applies environment overrides. // The result is cached for subsequent calls. // // Returns: -// - *RC: The loaded and cached configuration -func GetRC() *RC { +// - *CtxRC: The loaded and cached configuration +func RC() *CtxRC { rcOnce.Do(func() { rc = loadRC() }) return rc } -// loadRC loads configuration from .contextrc file and applies env overrides. -// -// Returns: -// - *RC: Configuration with file values and env overrides applied -func loadRC() *RC { - cfg := DefaultRC() - - // Try to load .contextrc from current directory - data, err := os.ReadFile(".contextrc") - if err == nil { - // Parse YAML, ignoring errors (use defaults for invalid config) - _ = yaml.Unmarshal(data, cfg) - } - - // Apply environment variable overrides - if envDir := os.Getenv("CTX_DIR"); envDir != "" { - cfg.ContextDir = envDir - } - if envBudget := os.Getenv("CTX_TOKEN_BUDGET"); envBudget != "" { - if budget, err := strconv.Atoi(envBudget); err == nil && budget > 0 { - cfg.TokenBudget = budget - } - } - - return cfg -} - -// GetContextDir returns the configured context directory. +// ContextDir returns the configured context directory. // // Priority: CLI override > env var > .contextrc > default. // // Returns: // - string: The context directory path (e.g., ".context") -func GetContextDir() string { +func ContextDir() string { + rcMu.RLock() + defer rcMu.RUnlock() if rcOverrideDir != "" { return rcOverrideDir } - return GetRC().ContextDir + return RC().ContextDir } -// GetTokenBudget returns the configured default token budget. +// TokenBudget returns the configured default token budget. // // Priority: env var > .contextrc > default (8000). // // Returns: // - int: The token budget for context assembly -func GetTokenBudget() int { - return GetRC().TokenBudget +func TokenBudget() int { + return RC().TokenBudget } -// GetPriorityOrder returns the configured file priority order. +// PriorityOrder returns the configured file priority order. // // Returns: // - []string: File names in priority order, or nil if not configured // (callers should fall back to config.FileReadOrder) -func GetPriorityOrder() []string { - return GetRC().PriorityOrder +func PriorityOrder() []string { + return RC().PriorityOrder } -// GetAutoArchive returns whether auto-archiving is enabled. +// AutoArchive returns whether auto-archiving is enabled. // // Returns: // - bool: True if completed tasks should be auto-archived -func GetAutoArchive() bool { - return GetRC().AutoArchive +func AutoArchive() bool { + return RC().AutoArchive } -// GetArchiveAfterDays returns the configured days before archiving. +// ArchiveAfterDays returns the configured days before archiving. // // Returns: // - int: Number of days after which completed tasks are archived (default 7) -func GetArchiveAfterDays() int { - return GetRC().ArchiveAfterDays +func ArchiveAfterDays() int { + return RC().ArchiveAfterDays } // OverrideContextDir sets a CLI-provided override for the context directory. @@ -153,14 +97,18 @@ func GetArchiveAfterDays() int { // This takes precedence over all other configuration sources. // // Parameters: -// - dir: Directory path to use as override +// - dir: Directory path to use as an override func OverrideContextDir(dir string) { + rcMu.Lock() + defer rcMu.Unlock() rcOverrideDir = dir } -// ResetRC clears the cached configuration, forcing reload on next access. +// Reset clears the cached configuration, forcing reload on the next access. // This is primarily useful for testing. -func ResetRC() { +func Reset() { + rcMu.Lock() + defer rcMu.Unlock() rcOnce = sync.Once{} rc = nil rcOverrideDir = "" @@ -181,7 +129,7 @@ func ResetRC() { // - 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 { + if order := PriorityOrder(); order != nil { for i, fName := range order { if fName == name { return i + 1 @@ -191,7 +139,7 @@ func FilePriority(name string) int { return 100 } - // Use default priority from config.FileReadOrder + // Use the default priority from config.FileReadOrder for i, fName := range config.FileReadOrder { if fName == name { return i + 1 diff --git a/internal/rc/rc_test.go b/internal/rc/rc_test.go index 1a1d30ae1..c85dd3ba3 100644 --- a/internal/rc/rc_test.go +++ b/internal/rc/rc_test.go @@ -15,7 +15,7 @@ import ( ) func TestDefaultRC(t *testing.T) { - rc := DefaultRC() + rc := Default() if rc.ContextDir != config.DirContext { t.Errorf("ContextDir = %q, want %q", rc.ContextDir, config.DirContext) @@ -41,9 +41,9 @@ func TestGetRC_NoFile(t *testing.T) { os.Chdir(tempDir) defer os.Chdir(origDir) - ResetRC() + Reset() - rc := GetRC() + rc := RC() if rc.ContextDir != config.DirContext { t.Errorf("ContextDir = %q, want %q", rc.ContextDir, config.DirContext) @@ -70,9 +70,9 @@ archive_after_days: 14 ` os.WriteFile(filepath.Join(tempDir, ".contextrc"), []byte(rcContent), 0644) - ResetRC() + Reset() - rc := GetRC() + rc := RC() if rc.ContextDir != "custom-context" { t.Errorf("ContextDir = %q, want %q", rc.ContextDir, "custom-context") @@ -103,17 +103,13 @@ token_budget: 4000 ` os.WriteFile(filepath.Join(tempDir, ".contextrc"), []byte(rcContent), 0644) - // Set environment variables - os.Setenv("CTX_DIR", "env-context") - os.Setenv("CTX_TOKEN_BUDGET", "2000") - defer func() { - os.Unsetenv("CTX_DIR") - os.Unsetenv("CTX_TOKEN_BUDGET") - }() + // Set environment variables (t.Setenv auto-restores after test) + t.Setenv(config.EnvCtxDir, "env-context") + t.Setenv(config.EnvCtxTokenBudget, "2000") - ResetRC() + Reset() - rc := GetRC() + rc := RC() // Env should override file if rc.ContextDir != "env-context" { @@ -134,19 +130,18 @@ func TestGetContextDir_CLIOverride(t *testing.T) { rcContent := `context_dir: file-context` os.WriteFile(filepath.Join(tempDir, ".contextrc"), []byte(rcContent), 0644) - // Set env override - os.Setenv("CTX_DIR", "env-context") - defer os.Unsetenv("CTX_DIR") + // Set env override (t.Setenv auto-restores after test) + t.Setenv(config.EnvCtxDir, "env-context") - ResetRC() + Reset() // CLI override takes precedence over all OverrideContextDir("cli-context") - defer ResetRC() + defer Reset() - dir := GetContextDir() + dir := ContextDir() if dir != "cli-context" { - t.Errorf("GetContextDir() = %q, want %q (CLI override)", dir, "cli-context") + t.Errorf("ContextDir() = %q, want %q (CLI override)", dir, "cli-context") } } @@ -156,12 +151,12 @@ func TestGetTokenBudget(t *testing.T) { os.Chdir(tempDir) defer os.Chdir(origDir) - ResetRC() + Reset() // Default value - budget := GetTokenBudget() + budget := TokenBudget() if budget != DefaultTokenBudget { - t.Errorf("GetTokenBudget() = %d, want %d", budget, DefaultTokenBudget) + t.Errorf("TokenBudget() = %d, want %d", budget, DefaultTokenBudget) } } @@ -174,10 +169,10 @@ func TestGetRC_InvalidYAML(t *testing.T) { // Create invalid .contextrc file os.WriteFile(filepath.Join(tempDir, ".contextrc"), []byte("invalid: [yaml: content"), 0644) - ResetRC() + Reset() // Should return defaults on invalid YAML - rc := GetRC() + rc := RC() if rc.TokenBudget != DefaultTokenBudget { t.Errorf("TokenBudget = %d, want %d (defaults on invalid YAML)", rc.TokenBudget, DefaultTokenBudget) } @@ -193,9 +188,9 @@ func TestGetRC_PartialConfig(t *testing.T) { rcContent := `token_budget: 5000` os.WriteFile(filepath.Join(tempDir, ".contextrc"), []byte(rcContent), 0644) - ResetRC() + Reset() - rc := GetRC() + rc := RC() // Specified value should be used if rc.TokenBudget != 5000 { @@ -213,13 +208,12 @@ func TestGetRC_InvalidEnvBudget(t *testing.T) { os.Chdir(tempDir) defer os.Chdir(origDir) - os.Setenv("CTX_TOKEN_BUDGET", "not-a-number") - defer os.Unsetenv("CTX_TOKEN_BUDGET") + t.Setenv(config.EnvCtxTokenBudget, "not-a-number") - ResetRC() + Reset() // Invalid env should be ignored, use default - rc := GetRC() + rc := RC() if rc.TokenBudget != DefaultTokenBudget { t.Errorf("TokenBudget = %d, want %d (default on invalid env)", rc.TokenBudget, DefaultTokenBudget) } @@ -231,12 +225,12 @@ func TestGetRC_Singleton(t *testing.T) { os.Chdir(tempDir) defer os.Chdir(origDir) - ResetRC() + Reset() - rc1 := GetRC() - rc2 := GetRC() + rc1 := RC() + rc2 := RC() if rc1 != rc2 { - t.Error("GetRC() should return same instance") + t.Error("RC() should return same instance") } } diff --git a/internal/rc/types.go b/internal/rc/types.go new file mode 100644 index 000000000..e05ffaa16 --- /dev/null +++ b/internal/rc/types.go @@ -0,0 +1,23 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package rc + +// CtxRC represents the configuration from the .contextrc file. +// +// Fields: +// - ContextDir: Name of the context directory (default ".context") +// - TokenBudget: Default token budget for context assembly (default 8000) +// - PriorityOrder: Custom file loading priority order +// - AutoArchive: Whether to auto-archive completed tasks (default true) +// - ArchiveAfterDays: Days before archiving completed tasks (default 7) +type CtxRC struct { + ContextDir string `yaml:"context_dir"` + TokenBudget int `yaml:"token_budget"` + PriorityOrder []string `yaml:"priority_order"` + AutoArchive bool `yaml:"auto_archive"` + ArchiveAfterDays int `yaml:"archive_after_days"` +} diff --git a/internal/recall/parser/claude.go b/internal/recall/parser/claude.go index f87867e7e..45f6eb8eb 100644 --- a/internal/recall/parser/claude.go +++ b/internal/recall/parser/claude.go @@ -11,10 +11,11 @@ import ( "encoding/json" "fmt" "os" - "path/filepath" "sort" "strings" "time" + + "github.com/ActiveMemory/ctx/internal/config" ) // ClaudeCodeParser parses Claude Code JSONL session files. @@ -56,17 +57,22 @@ func (p *ClaudeCodeParser) CanParse(path string) bool { return false } - // Peek at first few lines to detect Claude Code format + // Peek at the first few lines to detect the Claude Code format file, err := os.Open(path) if err != nil { return false } - defer file.Close() + defer func(file *os.File) { + err := file.Close() + if err != nil { + fmt.Println("Error closing file:", err.Error()) + } + }(file) scanner := bufio.NewScanner(file) - // 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++ { + // Check the first N lines - slug may not appear until later in the file + // (early lines can be file-history-snapshot or messages without the slug) + for i := 0; i < config.ParserPeekLines && scanner.Scan(); i++ { line := scanner.Bytes() if len(line) == 0 { continue @@ -103,7 +109,12 @@ func (p *ClaudeCodeParser) ParseFile(path string) ([]*Session, error) { if err != nil { return nil, fmt.Errorf("open file: %w", err) } - defer file.Close() + defer func(file *os.File) { + err := file.Close() + if err != nil { + fmt.Println("Error closing file:", err.Error()) + } + }(file) // Group messages by session ID sessionMsgs := make(map[string][]claudeRawMessage) @@ -128,7 +139,7 @@ func (p *ClaudeCodeParser) ParseFile(path string) ([]*Session, error) { } // Skip non-message lines (e.g., file-history-snapshot) - if raw.Type != "user" && raw.Type != "assistant" { + if raw.Type != config.RoleUser && raw.Type != config.RoleAssistant { continue } @@ -184,7 +195,7 @@ func (p *ClaudeCodeParser) ParseLine(line []byte) (*Message, string, error) { } // Skip non-message lines - if raw.Type != "user" && raw.Type != "assistant" { + if raw.Type != config.RoleUser && raw.Type != config.RoleAssistant { return nil, "", nil } @@ -192,193 +203,22 @@ func (p *ClaudeCodeParser) ParseLine(line []byte) (*Message, string, error) { return &msg, raw.SessionID, nil } -// buildSession constructs a Session from raw Claude Code messages. -// -// Parameters: -// - id: Session ID to use -// - rawMsgs: Raw messages belonging to this session -// - sourcePath: Path to the source JSONL file -// -// Returns: -// - *Session: Constructed session with messages, stats, and metadata -func (p *ClaudeCodeParser) buildSession(id string, rawMsgs []claudeRawMessage, sourcePath string) *Session { - if len(rawMsgs) == 0 { - return nil - } - - // Sort by timestamp - sort.Slice(rawMsgs, func(i, j int) bool { - return rawMsgs[i].Timestamp.Before(rawMsgs[j].Timestamp) - }) - - first := rawMsgs[0] - last := rawMsgs[len(rawMsgs)-1] - - session := &Session{ - ID: id, - Slug: first.Slug, - Tool: "claude-code", - SourceFile: sourcePath, - CWD: first.CWD, - Project: filepath.Base(first.CWD), - GitBranch: first.GitBranch, - StartTime: first.Timestamp, - EndTime: last.Timestamp, - Duration: last.Timestamp.Sub(first.Timestamp), - } - - // Convert messages and accumulate stats - for _, raw := range rawMsgs { - msg := p.convertMessage(raw) - session.Messages = append(session.Messages, msg) - - if msg.IsUser() { - session.TurnCount++ - if session.FirstUserMsg == "" && msg.Text != "" { - // Truncate preview - preview := msg.Text - if len(preview) > 100 { - preview = preview[:100] + "..." - } - session.FirstUserMsg = preview - } - } - - session.TotalTokensIn += msg.TokensIn - session.TotalTokensOut += msg.TokensOut - - // Check for errors in tool results - for _, tr := range msg.ToolResults { - if tr.IsError { - session.HasErrors = true - } - } - - // Track model - if raw.Message.Model != "" && session.Model == "" { - session.Model = raw.Message.Model - } - } - - session.TotalTokens = session.TotalTokensIn + session.TotalTokensOut - - return session -} - -// convertMessage converts a Claude Code raw message to the common Message type. -// -// Parameters: -// - raw: Raw message from JSONL parsing -// -// Returns: -// - Message: Normalized message with text, tool uses, and tool results extracted -func (p *ClaudeCodeParser) convertMessage(raw claudeRawMessage) Message { - msg := Message{ - ID: raw.UUID, - Timestamp: raw.Timestamp, - Role: raw.Type, - } - - if raw.Message.Usage != nil { - msg.TokensIn = raw.Message.Usage.InputTokens - msg.TokensOut = raw.Message.Usage.OutputTokens - } - - // Parse content - can be a string or array of blocks - blocks := p.parseContentBlocks(raw.Message.Content) - - // Extract content from blocks - for _, block := range blocks { - switch block.Type { - case "text": - if msg.Text != "" { - msg.Text += "\n" - } - msg.Text += block.Text - - case "thinking": - if msg.Thinking != "" { - msg.Thinking += "\n" - } - msg.Thinking += block.Thinking - - case "tool_use": - inputStr := "" - if block.Input != nil { - inputStr = string(block.Input) - } - msg.ToolUses = append(msg.ToolUses, ToolUse{ - ID: block.ID, - Name: block.Name, - Input: inputStr, - }) - - case "tool_result": - contentStr := "" - if block.Content != nil { - // Try to unmarshal as JSON string first (handles escaping) - var unescaped string - if err := json.Unmarshal(block.Content, &unescaped); err == nil { - contentStr = unescaped - } else { - // Fallback to raw bytes - contentStr = string(block.Content) - } - } - msg.ToolResults = append(msg.ToolResults, ToolResult{ - ToolUseID: block.ToolUseID, - Content: contentStr, - IsError: block.IsError, - }) - } - } - - return msg -} - -// parseContentBlocks parses the content field which can be a string or array. -// -// Parameters: -// - content: Raw JSON content that may be a string or array of blocks -// -// Returns: -// - []claudeRawBlock: Parsed content blocks (text, thinking, tool_use, tool_result) -func (p *ClaudeCodeParser) parseContentBlocks(content json.RawMessage) []claudeRawBlock { - if len(content) == 0 { - return nil - } - - // Try parsing as array of blocks first - var blocks []claudeRawBlock - if err := json.Unmarshal(content, &blocks); err == nil { - return blocks - } - - // Try parsing as a simple string - var text string - if err := json.Unmarshal(content, &text); err == nil && text != "" { - return []claudeRawBlock{{Type: "text", Text: text}} - } - - return nil -} - // Claude Code-specific raw types for parsing JSONL type claudeRawMessage struct { - UUID string `json:"uuid"` - ParentUUID *string `json:"parentUuid"` - SessionID string `json:"sessionId"` - RequestID string `json:"requestId,omitempty"` - Timestamp time.Time `json:"timestamp"` - Type string `json:"type"` // "user", "assistant", or other - UserType string `json:"userType,omitempty"` - IsSidechain bool `json:"isSidechain,omitempty"` - CWD string `json:"cwd"` - GitBranch string `json:"gitBranch,omitempty"` - Version string `json:"version"` - Slug string `json:"slug"` - Message claudeRawContent `json:"message"` + UUID string `json:"uuid"` + ParentUUID *string `json:"parentUuid"` + SessionID string `json:"sessionId"` + RequestID string `json:"requestId,omitempty"` + Timestamp time.Time `json:"timestamp"` + Type string `json:"type"` // "user", "assistant", or other + UserType string `json:"userType,omitempty"` + IsSidechain bool `json:"isSidechain,omitempty"` + CWD string `json:"cwd"` + GitBranch string `json:"gitBranch,omitempty"` + Version string `json:"version"` + Slug string `json:"slug"` + Message claudeRawContent `json:"message"` } type claudeRawContent struct { diff --git a/internal/recall/parser/git.go b/internal/recall/parser/git.go new file mode 100644 index 000000000..8dd2fed81 --- /dev/null +++ b/internal/recall/parser/git.go @@ -0,0 +1,35 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package parser + +import ( + "os" + "os/exec" + "strings" +) + +// gitRemote returns the git remote origin URL for a directory. +// Returns an empty string if not a git repo or no remote configured. +func gitRemote(dir string) string { + if dir == "" { + return "" + } + + // Check if the 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)) +} diff --git a/internal/recall/parser/parse.go b/internal/recall/parser/parse.go new file mode 100644 index 000000000..d760d7eb5 --- /dev/null +++ b/internal/recall/parser/parse.go @@ -0,0 +1,192 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package parser + +import ( + "encoding/json" + "path/filepath" + "sort" + + "github.com/ActiveMemory/ctx/internal/config" +) + +// buildSession constructs a Session from raw Claude Code messages. +// +// Parameters: +// - id: Session ID to use +// - rawMsgs: Raw messages belonging to this session +// - sourcePath: Path to the source JSONL file +// +// Returns: +// - *Session: Constructed session with messages, stats, and metadata +func (p *ClaudeCodeParser) buildSession( + id string, rawMsgs []claudeRawMessage, sourcePath string, +) *Session { + if len(rawMsgs) == 0 { + return nil + } + + // Sort by timestamp + sort.Slice(rawMsgs, func(i, j int) bool { + return rawMsgs[i].Timestamp.Before(rawMsgs[j].Timestamp) + }) + + first := rawMsgs[0] + last := rawMsgs[len(rawMsgs)-1] + + session := &Session{ + ID: id, + Slug: first.Slug, + Tool: "claude-code", + SourceFile: sourcePath, + CWD: first.CWD, + Project: filepath.Base(first.CWD), + GitBranch: first.GitBranch, + StartTime: first.Timestamp, + EndTime: last.Timestamp, + Duration: last.Timestamp.Sub(first.Timestamp), + } + + // Convert messages and accumulate stats + for _, raw := range rawMsgs { + msg := p.convertMessage(raw) + session.Messages = append(session.Messages, msg) + + if msg.IsUser() { + session.TurnCount++ + if session.FirstUserMsg == "" && msg.Text != "" { + // Truncate preview + preview := msg.Text + if len(preview) > 100 { + preview = preview[:100] + "..." + } + session.FirstUserMsg = preview + } + } + + session.TotalTokensIn += msg.TokensIn + session.TotalTokensOut += msg.TokensOut + + // Check for errors in tool results + for _, tr := range msg.ToolResults { + if tr.IsError { + session.HasErrors = true + } + } + + // Track model + if raw.Message.Model != "" && session.Model == "" { + session.Model = raw.Message.Model + } + } + + session.TotalTokens = session.TotalTokensIn + session.TotalTokensOut + + return session +} + +// convertMessage converts a Claude Code raw message to the common Message type. +// +// Parameters: +// - raw: Raw message from JSONL parsing +// +// Returns: +// - Message: Normalized message with text, tool uses, +// and tool results extracted +func (p *ClaudeCodeParser) convertMessage(raw claudeRawMessage) Message { + msg := Message{ + ID: raw.UUID, + Timestamp: raw.Timestamp, + Role: raw.Type, + } + + if raw.Message.Usage != nil { + msg.TokensIn = raw.Message.Usage.InputTokens + msg.TokensOut = raw.Message.Usage.OutputTokens + } + + // Parse content - can be a string or array of blocks + blocks := p.parseContentBlocks(raw.Message.Content) + + // Extract content from blocks + for _, block := range blocks { + switch block.Type { + case config.ClaudeBlockText: + if msg.Text != "" { + msg.Text += config.NewlineLF + } + msg.Text += block.Text + + case config.ClaudeBlockThinking: + if msg.Thinking != "" { + msg.Thinking += config.NewlineLF + } + msg.Thinking += block.Thinking + + case config.ClaudeBlockToolUse: + inputStr := "" + if block.Input != nil { + inputStr = string(block.Input) + } + msg.ToolUses = append(msg.ToolUses, ToolUse{ + ID: block.ID, + Name: block.Name, + Input: inputStr, + }) + + case config.ClaudeBlockToolResult: + contentStr := "" + if block.Content != nil { + // Try to unmarshal as JSON string first (handles escaping) + var unescaped string + if err := json.Unmarshal(block.Content, &unescaped); err == nil { + contentStr = unescaped + } else { + // Fallback to raw bytes + contentStr = string(block.Content) + } + } + msg.ToolResults = append(msg.ToolResults, ToolResult{ + ToolUseID: block.ToolUseID, + Content: contentStr, + IsError: block.IsError, + }) + } + } + + return msg +} + +// parseContentBlocks parses the content field, which can be a string or array. +// +// Parameters: +// - content: Raw JSON content that may be a string or array of blocks +// +// Returns: +// - []claudeRawBlock: Parsed content blocks +// (text, thinking, tool_use, tool_result) +func (p *ClaudeCodeParser) parseContentBlocks( + content json.RawMessage, +) []claudeRawBlock { + if len(content) == 0 { + return nil + } + + // Try parsing as the array of blocks first + var blocks []claudeRawBlock + if err := json.Unmarshal(content, &blocks); err == nil { + return blocks + } + + // Try parsing as a simple string + var text string + if err := json.Unmarshal(content, &text); err == nil && text != "" { + return []claudeRawBlock{{Type: config.ClaudeBlockText, Text: text}} + } + + return nil +} diff --git a/internal/recall/parser/parser.go b/internal/recall/parser/parser.go index ad169a8f7..733a2d2a3 100644 --- a/internal/recall/parser/parser.go +++ b/internal/recall/parser/parser.go @@ -9,10 +9,8 @@ package parser import ( "fmt" "os" - "os/exec" "path/filepath" "sort" - "strings" ) // registeredParsers holds all available session parsers. @@ -43,7 +41,9 @@ func ParseFile(path string) ([]*Session, error) { // ScanDirectory recursively scans a directory for session files. // // It finds all parseable files, parses them, and aggregates sessions. -// Sessions are sorted by start time (newest first). +// Sessions are sorted by start time (newest first). Parse errors for +// individual files are silently ignored; use ScanDirectoryWithErrors +// if you need to report them. // // Parameters: // - dir: Root directory to scan recursively @@ -52,44 +52,8 @@ func ParseFile(path string) ([]*Session, error) { // - []*Session: All sessions found, sorted by start time (newest first) // - error: Non-nil if directory traversal fails func ScanDirectory(dir string) ([]*Session, error) { - var allSessions []*Session - var parseErrors []error - - err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - if info.IsDir() { - return nil - } - - // Try to parse with any registered parser - for _, parser := range registeredParsers { - if parser.CanParse(path) { - sessions, err := parser.ParseFile(path) - if err != nil { - parseErrors = append(parseErrors, fmt.Errorf("%s: %w", path, err)) - break - } - allSessions = append(allSessions, sessions...) - break - } - } - - return nil - }) - - if err != nil { - return nil, fmt.Errorf("walk directory: %w", err) - } - - // Sort by start time (newest first) - sort.Slice(allSessions, func(i, j int) bool { - return allSessions[i].StartTime.After(allSessions[j].StartTime) - }) - - return allSessions, nil + sessions, _, err := ScanDirectoryWithErrors(dir) + return sessions, err } // ScanDirectoryWithErrors is like ScanDirectory but also returns parse errors. @@ -108,7 +72,9 @@ func ScanDirectoryWithErrors(dir string) ([]*Session, []error, error) { var allSessions []*Session var parseErrors []error - err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + err := filepath.Walk(dir, func( + path string, info os.FileInfo, err error, + ) error { if err != nil { return err } @@ -161,10 +127,12 @@ func FindSessions(additionalDirs ...string) ([]*Session, error) { return findSessionsWithFilter(nil, additionalDirs...) } -// FindSessionsForCWD searches for sessions matching the given working directory. +// 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 +// 1. Git remote URL match - if both directories are git repos with +// the 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 // @@ -175,9 +143,11 @@ func FindSessions(additionalDirs ...string) ([]*Session, error) { // Returns: // - []*Session: Filtered sessions sorted by start time (newest first) // - error: Non-nil if scanning fails -func FindSessionsForCWD(cwd string, additionalDirs ...string) ([]*Session, error) { +func FindSessionsForCWD( + cwd string, additionalDirs ...string, +) ([]*Session, error) { // Get current project's git remote (if available) - currentRemote := getGitRemote(cwd) + currentRemote := gitRemote(cwd) // Get path relative to home directory currentRelPath := getPathRelativeToHome(cwd) @@ -185,13 +155,13 @@ func FindSessionsForCWD(cwd string, additionalDirs ...string) ([]*Session, error return findSessionsWithFilter(func(s *Session) bool { // 1. Try git remote match (most robust) if currentRemote != "" { - sessionRemote := getGitRemote(s.CWD) + sessionRemote := gitRemote(s.CWD) if sessionRemote != "" && sessionRemote == currentRemote { return true } } - // 2. Try path relative to home match + // 2. Try the path relative to the home match if currentRelPath != "" { sessionRelPath := getPathRelativeToHome(s.CWD) if sessionRelPath != "" && sessionRelPath == currentRelPath { @@ -199,114 +169,19 @@ func FindSessionsForCWD(cwd string, additionalDirs ...string) ([]*Session, error } } - // 3. Fallback to exact match + // 3. Fallback to an 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 - home, err := os.UserHomeDir() - if err == nil { - claudeDir := filepath.Join(home, ".claude", "projects") - if info, err := os.Stat(claudeDir); err == nil && info.IsDir() { - sessions, _ := ScanDirectory(claudeDir) - allSessions = append(allSessions, sessions...) - } - } - - // Check additional directories - for _, dir := range additionalDirs { - if info, err := os.Stat(dir); err == nil && info.IsDir() { - sessions, _ := ScanDirectory(dir) - allSessions = append(allSessions, sessions...) - } - } - - // 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 filtered { - if !seen[s.ID] { - seen[s.ID] = true - unique = append(unique, s) - } - } - - // Sort by start time (newest first) - sort.Slice(unique, func(i, j int) bool { - return unique[i].StartTime.After(unique[j].StartTime) - }) - - return unique, nil -} - -// GetParser returns a parser for the specified tool. +// Parser returns a parser for the specified tool. // // Parameters: // - tool: Tool identifier (e.g., "claude-code") // // Returns: // - SessionParser: The parser for the tool, or nil if not found -func GetParser(tool string) SessionParser { +func Parser(tool string) SessionParser { for _, parser := range registeredParsers { if parser.Tool() == tool { return parser diff --git a/internal/recall/parser/parser_test.go b/internal/recall/parser/parser_test.go index db1686d7a..540532b19 100644 --- a/internal/recall/parser/parser_test.go +++ b/internal/recall/parser/parser_test.go @@ -334,7 +334,7 @@ func TestRegisteredTools(t *testing.T) { } func TestGetParser(t *testing.T) { - parser := GetParser("claude-code") + parser := Parser("claude-code") if parser == nil { t.Error("expected parser for 'claude-code'") } @@ -342,7 +342,7 @@ func TestGetParser(t *testing.T) { t.Errorf("expected tool 'claude-code', got '%s'", parser.Tool()) } - unknown := GetParser("unknown-tool") + unknown := Parser("unknown-tool") if unknown != nil { t.Error("expected nil for unknown tool") } @@ -352,14 +352,14 @@ func TestFindSessions_Integration(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in short mode") } - + sessions, err := FindSessions() if err != nil { t.Fatalf("FindSessions failed: %v", err) } - + t.Logf("Found %d sessions", len(sessions)) - + for i, s := range sessions { if i >= 3 { t.Logf("... and %d more", len(sessions)-3) @@ -383,7 +383,9 @@ func TestDebugSession(t *testing.T) { t.Logf("Session: %s", s.ID) t.Logf("Messages: %d", len(s.Messages)) for i, m := range s.Messages { - if i > 5 { break } + if i > 5 { + break + } t.Logf(" %d. %s: text=%d chars, tools=%d", i, m.Role, len(m.Text), len(m.ToolUses)) if len(m.ToolUses) > 0 { t.Logf(" tool: %s, input: %.100s", m.ToolUses[0].Name, m.ToolUses[0].Input) diff --git a/internal/recall/parser/path.go b/internal/recall/parser/path.go new file mode 100644 index 000000000..9a239325e --- /dev/null +++ b/internal/recall/parser/path.go @@ -0,0 +1,37 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package parser + +import ( + "path/filepath" + "strings" +) + +// getPathRelativeToHome returns the path relative to the user's home directory. +// Returns an empty string if the 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 "" +} diff --git a/internal/recall/parser/query.go b/internal/recall/parser/query.go new file mode 100644 index 000000000..3100d27c7 --- /dev/null +++ b/internal/recall/parser/query.go @@ -0,0 +1,76 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package parser + +import ( + "os" + "path/filepath" + "sort" +) + +// findSessionsWithFilter scans common locations and additional directories +// for session files, applying an optional filter. +// +// It checks ~/.claude/projects/ (Claude Code default) and any additional +// directories provided. Results are deduplicated by session ID and sorted +// by start time (newest first). +// +// Parameters: +// - filter: Optional function to filter sessions (nil includes all) +// - additionalDirs: Optional additional directories to scan +// +// Returns: +// - []*Session: Deduplicated, filtered sessions sorted by start time +// - error: Currently always nil (errors are silently ignored) +func findSessionsWithFilter( + filter func(*Session) bool, additionalDirs ...string, +) ([]*Session, error) { + var allSessions []*Session + + // Check Claude Code default location + home, err := os.UserHomeDir() + if err == nil { + claudeDir := filepath.Join(home, ".claude", "projects") + if info, err := os.Stat(claudeDir); err == nil && info.IsDir() { + sessions, _ := ScanDirectory(claudeDir) + allSessions = append(allSessions, sessions...) + } + } + + // Check additional directories + for _, dir := range additionalDirs { + if info, err := os.Stat(dir); err == nil && info.IsDir() { + sessions, _ := ScanDirectory(dir) + allSessions = append(allSessions, sessions...) + } + } + + // 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 filtered { + if !seen[s.ID] { + seen[s.ID] = true + unique = append(unique, s) + } + } + + // Sort by start time (newest first) + sort.Slice(unique, func(i, j int) bool { + return unique[i].StartTime.After(unique[j].StartTime) + }) + + return unique, nil +} diff --git a/internal/task/task.go b/internal/task/task.go index 9231a81e6..03571ab1c 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -7,9 +7,11 @@ // Package task provides task item parsing and matching. // // This package handles the domain logic for task items, independent of -// their markdown representation. +// their Markdown representation. package task +import "github.com/ActiveMemory/ctx/internal/config" + // Match indices for accessing capture groups. // // Usage: @@ -21,10 +23,10 @@ package task // content := match[task.MatchContent] // } const ( - MatchFull = 0 // Full match - MatchIndent = 1 // Leading whitespace - MatchState = 2 // "x" or " " or "" - MatchContent = 3 // Task text + MatchFull = iota // Full match + MatchIndent // Leading whitespace + MatchState // "x" or " " or "" + MatchContent // Task text ) // Completed reports whether a match represents a completed task. @@ -38,7 +40,7 @@ func Completed(match []string) bool { if len(match) <= MatchState { return false } - return match[MatchState] == "x" + return match[MatchState] == config.MarkTaskComplete } // IsPending reports whether a match represents a pending task. @@ -52,7 +54,7 @@ func IsPending(match []string) bool { if len(match) <= MatchState { return false } - return match[MatchState] != "x" + return match[MatchState] != config.MarkTaskComplete } // Indent returns the leading whitespace from a match. @@ -75,7 +77,7 @@ func Indent(match []string) string { // - match: Result from ItemPattern.FindStringSubmatch // // Returns: -// - string: Task content (empty if match is invalid) +// - string: Task content (empty if the match is invalid) func Content(match []string) string { if len(match) <= MatchContent { return "" diff --git a/internal/task/task_test.go b/internal/task/task_test.go new file mode 100644 index 000000000..185ffaf35 --- /dev/null +++ b/internal/task/task_test.go @@ -0,0 +1,295 @@ +// / Context: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package task + +import ( + "testing" + + "github.com/ActiveMemory/ctx/internal/config" +) + +func TestCompleted(t *testing.T) { + tests := []struct { + name string + line string + want bool + }{ + { + name: "completed task", + line: "- [x] Do something", + want: true, + }, + { + name: "pending task with space", + line: "- [ ] Do something", + want: false, + }, + { + name: "pending task empty checkbox", + line: "- [] Do something", + want: false, + }, + { + name: "indented completed task", + line: " - [x] Subtask done", + want: true, + }, + { + name: "indented pending task", + line: " - [ ] Subtask pending", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match := config.RegExTask.FindStringSubmatch(tt.line) + if match == nil { + t.Fatalf("line did not match task pattern: %q", tt.line) + } + got := Completed(match) + if got != tt.want { + t.Errorf("Completed() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCompleted_InvalidMatch(t *testing.T) { + // Test with nil/short match slices + if Completed(nil) { + t.Error("Completed(nil) should return false") + } + if Completed([]string{}) { + t.Error("Completed([]) should return false") + } + if Completed([]string{"full", "indent"}) { + t.Error("Completed() with short slice should return false") + } +} + +func TestIsPending(t *testing.T) { + tests := []struct { + name string + line string + want bool + }{ + { + name: "pending task with space", + line: "- [ ] Do something", + want: true, + }, + { + name: "pending task empty checkbox", + line: "- [] Do something", + want: true, + }, + { + name: "completed task", + line: "- [x] Done task", + want: false, + }, + { + name: "indented pending", + line: " - [ ] Deep subtask", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match := config.RegExTask.FindStringSubmatch(tt.line) + if match == nil { + t.Fatalf("line did not match task pattern: %q", tt.line) + } + got := IsPending(match) + if got != tt.want { + t.Errorf("IsPending() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestIsPending_InvalidMatch(t *testing.T) { + if IsPending(nil) { + t.Error("IsPending(nil) should return false") + } + if IsPending([]string{"full", "indent"}) { + t.Error("IsPending() with short slice should return false") + } +} + +func TestIndent(t *testing.T) { + tests := []struct { + name string + line string + want string + }{ + { + name: "no indent", + line: "- [ ] Top level task", + want: "", + }, + { + name: "two space indent", + line: " - [ ] Subtask", + want: " ", + }, + { + name: "four space indent", + line: " - [x] Deep subtask", + want: " ", + }, + { + name: "tab indent", + line: "\t- [ ] Tab indented", + want: "\t", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match := config.RegExTask.FindStringSubmatch(tt.line) + if match == nil { + t.Fatalf("line did not match task pattern: %q", tt.line) + } + got := Indent(match) + if got != tt.want { + t.Errorf("Indent() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestIndent_InvalidMatch(t *testing.T) { + if Indent(nil) != "" { + t.Error("Indent(nil) should return empty string") + } + if Indent([]string{}) != "" { + t.Error("Indent([]) should return empty string") + } +} + +func TestContent(t *testing.T) { + tests := []struct { + name string + line string + want string + }{ + { + name: "simple task", + line: "- [ ] Implement feature", + want: "Implement feature", + }, + { + name: "task with tags", + line: "- [ ] Fix bug #added:2026-01-15-120000", + want: "Fix bug #added:2026-01-15-120000", + }, + { + name: "completed task", + line: "- [x] Done task #done:2026-01-15-130000", + want: "Done task #done:2026-01-15-130000", + }, + { + name: "task with special characters", + line: "- [ ] Handle `error` in foo()", + want: "Handle `error` in foo()", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match := config.RegExTask.FindStringSubmatch(tt.line) + if match == nil { + t.Fatalf("line did not match task pattern: %q", tt.line) + } + got := Content(match) + if got != tt.want { + t.Errorf("Content() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestContent_InvalidMatch(t *testing.T) { + if Content(nil) != "" { + t.Error("Content(nil) should return empty string") + } + if Content([]string{"full", "indent", "state"}) != "" { + t.Error("Content() with short slice should return empty string") + } +} + +func TestIsSubTask(t *testing.T) { + tests := []struct { + name string + line string + want bool + }{ + { + name: "top level task", + line: "- [ ] Top level", + want: false, + }, + { + name: "single space - not subtask", + line: " - [ ] One space", + want: false, + }, + { + name: "two space subtask", + line: " - [ ] Subtask", + want: true, + }, + { + name: "four space subtask", + line: " - [x] Deep subtask", + want: true, + }, + { + name: "tab subtask", + line: "\t\t- [ ] Tab indented", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match := config.RegExTask.FindStringSubmatch(tt.line) + if match == nil { + t.Fatalf("line did not match task pattern: %q", tt.line) + } + got := IsSubTask(match) + if got != tt.want { + t.Errorf("IsSubTask() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMatchConstants(t *testing.T) { + // Verify match indices work correctly + line := " - [x] Task content here" + match := config.RegExTask.FindStringSubmatch(line) + if match == nil { + t.Fatal("line did not match task pattern") + } + + if match[MatchFull] != line { + t.Errorf("MatchFull = %q, want %q", match[MatchFull], line) + } + if match[MatchIndent] != " " { + t.Errorf("MatchIndent = %q, want %q", match[MatchIndent], " ") + } + if match[MatchState] != "x" { + t.Errorf("MatchState = %q, want %q", match[MatchState], "x") + } + if match[MatchContent] != "Task content here" { + t.Errorf("MatchContent = %q, want %q", match[MatchContent], "Task content here") + } +} diff --git a/internal/templates/AGENT_PLAYBOOK.md b/internal/tpl/AGENT_PLAYBOOK.md similarity index 100% rename from internal/templates/AGENT_PLAYBOOK.md rename to internal/tpl/AGENT_PLAYBOOK.md diff --git a/internal/templates/ARCHITECTURE.md b/internal/tpl/ARCHITECTURE.md similarity index 100% rename from internal/templates/ARCHITECTURE.md rename to internal/tpl/ARCHITECTURE.md diff --git a/internal/templates/CLAUDE.md b/internal/tpl/CLAUDE.md similarity index 100% rename from internal/templates/CLAUDE.md rename to internal/tpl/CLAUDE.md diff --git a/internal/templates/CONSTITUTION.md b/internal/tpl/CONSTITUTION.md similarity index 100% rename from internal/templates/CONSTITUTION.md rename to internal/tpl/CONSTITUTION.md diff --git a/internal/templates/CONVENTIONS.md b/internal/tpl/CONVENTIONS.md similarity index 100% rename from internal/templates/CONVENTIONS.md rename to internal/tpl/CONVENTIONS.md diff --git a/internal/templates/DECISIONS.md b/internal/tpl/DECISIONS.md similarity index 100% rename from internal/templates/DECISIONS.md rename to internal/tpl/DECISIONS.md diff --git a/internal/templates/DRIFT.md b/internal/tpl/DRIFT.md similarity index 100% rename from internal/templates/DRIFT.md rename to internal/tpl/DRIFT.md diff --git a/internal/templates/GLOSSARY.md b/internal/tpl/GLOSSARY.md similarity index 100% rename from internal/templates/GLOSSARY.md rename to internal/tpl/GLOSSARY.md diff --git a/internal/templates/IMPLEMENTATION_PLAN.md b/internal/tpl/IMPLEMENTATION_PLAN.md similarity index 100% rename from internal/templates/IMPLEMENTATION_PLAN.md rename to internal/tpl/IMPLEMENTATION_PLAN.md diff --git a/internal/templates/LEARNINGS.md b/internal/tpl/LEARNINGS.md similarity index 100% rename from internal/templates/LEARNINGS.md rename to internal/tpl/LEARNINGS.md diff --git a/internal/templates/TASKS.md b/internal/tpl/TASKS.md similarity index 100% rename from internal/templates/TASKS.md rename to internal/tpl/TASKS.md diff --git a/internal/templates/claude/commands/ctx-add-decision.md b/internal/tpl/claude/commands/ctx-add-decision.md similarity index 93% rename from internal/templates/claude/commands/ctx-add-decision.md rename to internal/tpl/claude/commands/ctx-add-decision.md index 99315c91d..f84b0fd28 100644 --- a/internal/templates/claude/commands/ctx-add-decision.md +++ b/internal/tpl/claude/commands/ctx-add-decision.md @@ -3,7 +3,8 @@ description: "Add a decision to DECISIONS.md (requires context, rationale, conse argument-hint: "\"decision title\"" --- -When the user runs /ctx-add-decision, you need to gather the complete ADR (Architecture Decision Record) format. +When the user runs /ctx-add-decision, you need to gather the complete ADR +(Architecture Decision Record) format. If $ARGUMENTS contains only a title (no flags), ask the user for: 1. **Context**: What prompted this decision? diff --git a/internal/templates/claude/commands/ctx-add-learning.md b/internal/tpl/claude/commands/ctx-add-learning.md similarity index 100% rename from internal/templates/claude/commands/ctx-add-learning.md rename to internal/tpl/claude/commands/ctx-add-learning.md diff --git a/internal/templates/claude/commands/ctx-add-task.md b/internal/tpl/claude/commands/ctx-add-task.md similarity index 100% rename from internal/templates/claude/commands/ctx-add-task.md rename to internal/tpl/claude/commands/ctx-add-task.md diff --git a/internal/templates/claude/commands/ctx-agent.md b/internal/tpl/claude/commands/ctx-agent.md similarity index 100% rename from internal/templates/claude/commands/ctx-agent.md rename to internal/tpl/claude/commands/ctx-agent.md diff --git a/internal/templates/claude/commands/ctx-archive.md b/internal/tpl/claude/commands/ctx-archive.md similarity index 100% rename from internal/templates/claude/commands/ctx-archive.md rename to internal/tpl/claude/commands/ctx-archive.md diff --git a/internal/templates/claude/commands/ctx-blog-changelog.md b/internal/tpl/claude/commands/ctx-blog-changelog.md similarity index 100% rename from internal/templates/claude/commands/ctx-blog-changelog.md rename to internal/tpl/claude/commands/ctx-blog-changelog.md diff --git a/internal/templates/claude/commands/ctx-blog.md b/internal/tpl/claude/commands/ctx-blog.md similarity index 100% rename from internal/templates/claude/commands/ctx-blog.md rename to internal/tpl/claude/commands/ctx-blog.md diff --git a/internal/templates/claude/commands/ctx-journal-enrich.md b/internal/tpl/claude/commands/ctx-journal-enrich.md similarity index 100% rename from internal/templates/claude/commands/ctx-journal-enrich.md rename to internal/tpl/claude/commands/ctx-journal-enrich.md diff --git a/internal/templates/claude/commands/ctx-journal-summarize.md b/internal/tpl/claude/commands/ctx-journal-summarize.md similarity index 100% rename from internal/templates/claude/commands/ctx-journal-summarize.md rename to internal/tpl/claude/commands/ctx-journal-summarize.md diff --git a/internal/templates/claude/commands/ctx-loop.md b/internal/tpl/claude/commands/ctx-loop.md similarity index 100% rename from internal/templates/claude/commands/ctx-loop.md rename to internal/tpl/claude/commands/ctx-loop.md diff --git a/internal/templates/claude/commands/ctx-prompt-audit.md b/internal/tpl/claude/commands/ctx-prompt-audit.md similarity index 88% rename from internal/templates/claude/commands/ctx-prompt-audit.md rename to internal/tpl/claude/commands/ctx-prompt-audit.md index 488cef950..6beb6ec7f 100644 --- a/internal/templates/claude/commands/ctx-prompt-audit.md +++ b/internal/tpl/claude/commands/ctx-prompt-audit.md @@ -2,7 +2,9 @@ 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. +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 @@ -12,7 +14,8 @@ Analyze recent session transcripts to identify prompts that led to unnecessary c ## 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: +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 @@ -45,9 +48,11 @@ Generate a report like this: **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. +**What happened**: I had to ask which file and what error you were seeing, a +dding 2 messages of back-and-forth. -**Better prompt**: "fix the authentication error in src/auth/login.ts where JWT validation fails with 401" +**Better prompt**: "fix the authentication error in src/auth/login.ts where +JWT validation fails with 401" **Cost**: ~2 extra messages, ~30 seconds @@ -57,9 +62,11 @@ Generate a report like this: **Your prompt**: "optimize the component" -**What happened**: Multiple components exist. I asked which one and what performance issue to address. +**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" +**Better prompt**: "optimize UserList in src/components/UserList.tsx to reduce +re-renders when parent state updates" **Cost**: ~3 extra messages, ~1 minute diff --git a/internal/templates/claude/commands/ctx-recall.md b/internal/tpl/claude/commands/ctx-recall.md similarity index 92% rename from internal/templates/claude/commands/ctx-recall.md rename to internal/tpl/claude/commands/ctx-recall.md index d2f48f93f..c770ac10c 100644 --- a/internal/templates/claude/commands/ctx-recall.md +++ b/internal/tpl/claude/commands/ctx-recall.md @@ -9,4 +9,5 @@ ctx recall list --limit 10 ``` Show the user's recent sessions with project, time, and turn count. -If the user asks about a specific session, use `ctx recall show ` to get details. +If the user asks about a specific session, use `ctx recall show ` to +get details. diff --git a/internal/templates/claude/commands/ctx-reflect.md b/internal/tpl/claude/commands/ctx-reflect.md similarity index 94% rename from internal/templates/claude/commands/ctx-reflect.md rename to internal/tpl/claude/commands/ctx-reflect.md index 20e68331d..593e09000 100644 --- a/internal/templates/claude/commands/ctx-reflect.md +++ b/internal/tpl/claude/commands/ctx-reflect.md @@ -2,7 +2,8 @@ description: "Reflect on session and suggest what to persist" --- -Pause and reflect on this session. Review what has been accomplished and identify context worth persisting. +Pause and reflect on this session. Review what has been accomplished and +identify context worth persisting. ## Reflection Checklist @@ -36,7 +37,8 @@ After reflecting, provide: 3. **Offer**: Ask if the user wants you to persist any of these Example: -> "This session fixed the auth bug and we discovered the token refresh gotcha. I'd suggest: +> "This session fixed the auth bug and we discovered the token refresh gotcha. +> I'd suggest: > - Learning: Token refresh requires explicit cache invalidation > - Task: Mark 'Fix auth bug' as done > Want me to persist these?" diff --git a/internal/templates/claude/commands/ctx-save.md b/internal/tpl/claude/commands/ctx-save.md similarity index 100% rename from internal/templates/claude/commands/ctx-save.md rename to internal/tpl/claude/commands/ctx-save.md diff --git a/internal/templates/claude/commands/ctx-status.md b/internal/tpl/claude/commands/ctx-status.md similarity index 100% rename from internal/templates/claude/commands/ctx-status.md rename to internal/tpl/claude/commands/ctx-status.md diff --git a/internal/templates/claude/hooks/auto-save-session.sh b/internal/tpl/claude/hooks/auto-save-session.sh similarity index 100% rename from internal/templates/claude/hooks/auto-save-session.sh rename to internal/tpl/claude/hooks/auto-save-session.sh diff --git a/internal/templates/claude/hooks/block-non-path-ctx.sh b/internal/tpl/claude/hooks/block-non-path-ctx.sh similarity index 100% rename from internal/templates/claude/hooks/block-non-path-ctx.sh rename to internal/tpl/claude/hooks/block-non-path-ctx.sh diff --git a/internal/templates/claude/hooks/prompt-coach.sh b/internal/tpl/claude/hooks/prompt-coach.sh similarity index 100% rename from internal/templates/claude/hooks/prompt-coach.sh rename to internal/tpl/claude/hooks/prompt-coach.sh diff --git a/internal/templates/embed.go b/internal/tpl/embed.go similarity index 72% rename from internal/templates/embed.go rename to internal/tpl/embed.go index 87727a844..e8035ff22 100644 --- a/internal/templates/embed.go +++ b/internal/tpl/embed.go @@ -4,32 +4,33 @@ // \ Copyright 2026-present Context contributors. // SPDX-License-Identifier: Apache-2.0 -// Package templates provides embedded template files for initializing .context/ directories. -package templates +// Package tpl provides embedded template files for initializing +// .context/ directories. +package tpl import "embed" //go:embed *.md entry-templates/*.md claude/commands/*.md claude/hooks/*.sh var FS embed.FS -// GetTemplate reads a template file by name from the embedded filesystem. +// Template reads a template file by name from the embedded filesystem. // // Parameters: // - name: Template filename (e.g., "TASKS.md") // // Returns: // - []byte: Template content -// - error: Non-nil if file not found or read fails -func GetTemplate(name string) ([]byte, error) { +// - error: Non-nil if the file is not found or read fails +func Template(name string) ([]byte, error) { return FS.ReadFile(name) } -// ListTemplates returns all available template file names. +// List returns all available template file names. // // Returns: // - []string: List of template filenames in the root templates directory // - error: Non-nil if directory read fails -func ListTemplates() ([]string, error) { +func List() ([]string, error) { entries, err := FS.ReadDir(".") if err != nil { return nil, err @@ -44,12 +45,12 @@ func ListTemplates() ([]string, error) { return names, nil } -// ListEntryTemplates returns available entry template file names. +// ListEntry returns available entry template file names. // // Returns: // - []string: List of template filenames in entry-templates/ // - error: Non-nil if directory read fails -func ListEntryTemplates() ([]string, error) { +func ListEntry() ([]string, error) { entries, err := FS.ReadDir("entry-templates") if err != nil { return nil, err @@ -64,15 +65,15 @@ func ListEntryTemplates() ([]string, error) { return names, nil } -// GetEntryTemplate reads an entry template by name. +// Entry reads an entry template by name. // // Parameters: // - name: Template filename (e.g., "decision.md") // // Returns: // - []byte: Template content from entry-templates/ -// - error: Non-nil if file not found or read fails -func GetEntryTemplate(name string) ([]byte, error) { +// - error: Non-nil if the file is not found or read fails +func Entry(name string) ([]byte, error) { return FS.ReadFile("entry-templates/" + name) } @@ -96,15 +97,15 @@ func ListClaudeCommands() ([]string, error) { return names, nil } -// GetClaudeCommand reads a Claude Code slash command template by name. +// ClaudeCommandByName reads a Claude Code slash command template by name. // // Parameters: // - name: Command filename (e.g., "ctx-status.md") // // Returns: // - []byte: Command template content from claude/commands/ -// - error: Non-nil if file not found or read fails -func GetClaudeCommand(name string) ([]byte, error) { +// - error: Non-nil if the file not found or read fails +func ClaudeCommandByName(name string) ([]byte, error) { return FS.ReadFile("claude/commands/" + name) } @@ -115,7 +116,7 @@ func GetClaudeCommand(name string) ([]byte, error) { // // Returns: // - []byte: Hook script content from claude/hooks/ -// - error: Non-nil if file not found or read fails +// - error: Non-nil if the file is not found or read fails func ClaudeHookByFileName(name string) ([]byte, error) { return FS.ReadFile("claude/hooks/" + name) } diff --git a/internal/templates/embed_test.go b/internal/tpl/embed_test.go similarity index 79% rename from internal/templates/embed_test.go rename to internal/tpl/embed_test.go index ebd09d13c..c6f903ed7 100644 --- a/internal/templates/embed_test.go +++ b/internal/tpl/embed_test.go @@ -4,7 +4,7 @@ // \ Copyright 2026-present Context contributors. // SPDX-License-Identifier: Apache-2.0 -package templates +package tpl import ( "strings" @@ -87,32 +87,32 @@ func TestGetTemplate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - content, err := GetTemplate(tt.template) + content, err := Template(tt.template) if tt.wantErr { if err == nil { - t.Errorf("GetTemplate(%q) expected error, got nil", tt.template) + t.Errorf("Template(%q) expected error, got nil", tt.template) } return } if err != nil { - t.Errorf("GetTemplate(%q) unexpected error: %v", tt.template, err) + t.Errorf("Template(%q) unexpected error: %v", tt.template, err) return } if !strings.Contains(string(content), tt.wantContain) { - t.Errorf("GetTemplate(%q) content does not contain %q", tt.template, tt.wantContain) + t.Errorf("Template(%q) content does not contain %q", tt.template, tt.wantContain) } }) } } func TestListTemplates(t *testing.T) { - templates, err := ListTemplates() + templates, err := List() if err != nil { - t.Fatalf("ListTemplates() unexpected error: %v", err) + t.Fatalf("List() unexpected error: %v", err) } if len(templates) == 0 { - t.Error("ListTemplates() returned empty list") + t.Error("List() returned empty list") } // Check for required templates @@ -130,19 +130,19 @@ func TestListTemplates(t *testing.T) { for _, req := range required { if !templateSet[req] { - t.Errorf("ListTemplates() missing required template: %s", req) + t.Errorf("List() missing required template: %s", req) } } } func TestListEntryTemplates(t *testing.T) { - templates, err := ListEntryTemplates() + templates, err := ListEntry() if err != nil { - t.Fatalf("ListEntryTemplates() unexpected error: %v", err) + t.Fatalf("ListEntry() unexpected error: %v", err) } if len(templates) == 0 { - t.Error("ListEntryTemplates() returned empty list") + t.Error("ListEntry() returned empty list") } // Check for expected entry templates @@ -158,7 +158,7 @@ func TestListEntryTemplates(t *testing.T) { for _, exp := range expected { if !templateSet[exp] { - t.Errorf("ListEntryTemplates() missing expected template: %s", exp) + t.Errorf("ListEntry() missing expected template: %s", exp) } } } @@ -191,19 +191,19 @@ func TestGetEntryTemplate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - content, err := GetEntryTemplate(tt.template) + content, err := Entry(tt.template) if tt.wantErr { if err == nil { - t.Errorf("GetEntryTemplate(%q) expected error, got nil", tt.template) + t.Errorf("Entry(%q) expected error, got nil", tt.template) } return } if err != nil { - t.Errorf("GetEntryTemplate(%q) unexpected error: %v", tt.template, err) + t.Errorf("Entry(%q) unexpected error: %v", tt.template, err) return } if !strings.Contains(string(content), tt.wantContain) { - t.Errorf("GetEntryTemplate(%q) content does not contain %q", tt.template, tt.wantContain) + t.Errorf("Entry(%q) content does not contain %q", tt.template, tt.wantContain) } }) } @@ -239,9 +239,9 @@ func TestListClaudeCommands(t *testing.T) { } func TestGetClaudeCommand(t *testing.T) { - content, err := GetClaudeCommand("ctx-recall.md") + content, err := ClaudeCommandByName("ctx-recall.md") if err != nil { - t.Fatalf("GetClaudeCommand(ctx-recall.md) error: %v", err) + t.Fatalf("ClaudeCommandByName(ctx-recall.md) error: %v", err) } if !strings.Contains(string(content), "recall") { t.Error("ctx-recall.md does not contain 'recall'") diff --git a/internal/templates/entry-templates/decision.md b/internal/tpl/entry-templates/decision.md similarity index 100% rename from internal/templates/entry-templates/decision.md rename to internal/tpl/entry-templates/decision.md diff --git a/internal/templates/entry-templates/learning.md b/internal/tpl/entry-templates/learning.md similarity index 100% rename from internal/templates/entry-templates/learning.md rename to internal/tpl/entry-templates/learning.md diff --git a/internal/validation/validate.go b/internal/validation/validate.go index 5cb870d2b..0d9192029 100644 --- a/internal/validation/validate.go +++ b/internal/validation/validate.go @@ -9,7 +9,7 @@ import ( // SanitizeFilename converts a topic string to a safe filename component. // // Replaces spaces and special characters with hyphens, converts to lowercase, -// and limits length to 50 characters. Returns "session" if input is empty. +// and limits length to 50 characters. Returns "session" if the input is empty. // // Parameters: // - s: Topic string to sanitize diff --git a/site/blog/2026-01-27-building-ctx-using-ctx/index.html b/site/blog/2026-01-27-building-ctx-using-ctx/index.html index 2ca0ee373..76d0d2bbc 100644 --- a/site/blog/2026-01-27-building-ctx-using-ctx/index.html +++ b/site/blog/2026-01-27-building-ctx-using-ctx/index.html @@ -83,7 +83,7 @@
- + Skip to content @@ -630,6 +630,17 @@
    +
  • + + + + A Meta-Experiment in AI-Assisted Development + + + + +
  • +
  • @@ -838,6 +849,17 @@
      +
    • + + + + A Meta-Experiment in AI-Assisted Development + + + + +
    • +
    • @@ -1069,12 +1091,15 @@ +

      Building ctx Using ctx

      +

      ctx

      +

      A Meta-Experiment in AI-Assisted Development

      Jose Alekhinne / 2026-01-27

      -

      Building ctx Using ctx: A Meta-Experiment in AI-Assisted Development

      -
      -

      What happens when you build a tool designed to give AI memory, using that very -same tool to remember what you are building?

      -
      +
      +

      Can a tool design itself?

      +

      What happens when you build a tool designed to give AI memory, +using that very same tool to remember what you are 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 I have learned along the way.

      @@ -1089,7 +1114,7 @@

      AI Amnesiareset 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.

      @@ -1106,24 +1131,30 @@

      The Genesis

      The first commit was just scaffolding. But within hours, the -Ralph Loop—an iterative AI development workflow—had produced a working CLI:

      +
      Ralph Loop—an iterative AI development workflow—had produced +a working CLI:

      feat(cli): implement amem init command
       feat(cli): implement amem status command
       feat(cli): implement amem add command
       feat(cli): implement amem agent command
       ...
       
      -

      Fourteen core commands shipped in rapid succession.

      -

      I was YOLO'ing like there was no tomorrow: -auto-accept every change, let the AI run free, ship features fast.

      +

      Not one, not two, but a whopping fourteen core commands shipped in rapid +succession!

      +

      I was YOLO'ing like there was no tomorrow:

      +
        +
      • auto-accept every change,
      • +
      • let the AI run free,
      • +
      • ship features fast.
      • +

      The Meta-Experiment: Using amem to Build amem

      Here's where it gets interesting: On January 20th, I asked:

      "Can I use amem to help you remember this context when I restart?"

      The answer was yes—but with a gap:

      -

      Auto-load worked (via Claude Code's PreToolUse hook), but auto-save was -missing. If the user quit with Ctrl+C, everything since the last manual save +

      Autoload worked (via Claude Code's PreToolUse hook), but auto-save was +missing. If the user quit, with Ctrl+C, everything since the last manual save was lost.

      That session became the first real test of the system.

      Here is the first session file we recorded:

      @@ -1138,10 +1169,15 @@

      The Meta-Experiment: Using ### 2. Two Tiers of Context Persistence -| Tier | What | Why | Where | -|-----------|-----------------------------|-------------------------------|------------------------| -| Curated | Learnings, decisions, tasks | Quick reload, token-efficient | .context/*.md | -| Full dump | Entire conversation | Safety net, nothing lost | .context/sessions/*.md | +| Tier | What | Why | +|-----------|-----------------------------|-------------------------------| +| Curated | Learnings, decisions, tasks | Quick reload, token-efficient | +| Full dump | Entire conversation | Safety net, nothing lost | + +| Where | +|------------------------| +| .context/*.md | +| .context/sessions/*.md |

This session file—written by the AI to preserve its own context—became the template for how ctx handles session persistence.

@@ -1206,7 +1242,8 @@

YOLO Mode: Fast, But Dangerous- Colocated tests: Tests next to implementations - Canonical naming: Package name = folder name -

The fix required a human-guided refactoring session.

+

The fix required a human-guided refactoring session. I continued to do +that before every major release, from that point on.

We introduced internal/config/config.go with semantic prefixes:

const (
     DirContext     = ".context"
@@ -1218,8 +1255,7 @@ 

YOLO Mode: Fast, But DangerousThe Dogfooding Test That Failed

On January 21st, I ran an experiment: have another Claude instance rebuild ctx from scratch using only the specs and PROMPT.md.

@@ -1247,7 +1283,7 @@

The Dogfooding Test That Failed

The Constitution versus Conventions

As lessons accumulated, there was the temptation to add everything to -CONSTITUTION.md as "inviolable rules".

+CONSTITUTION.md as "inviolable rules".

But I resisted.

The constitution should contain only truly inviolable invariants:

    @@ -1309,7 +1345,7 @@

    The Session Filesarchaeological record" of ctx: When the AI needs deeper information about why something was done, it digs into the sessions.

    @@ -1335,7 +1371,8 @@

    The Decision Log: 18 Archit
    **Context**: Original implementation hardcoded absolute paths in hooks.
     This breaks when sharing configs with other developers.
     
    -**Decision**: Hooks use `ctx` from PATH. `ctx init` checks PATH before proceeding.
    +**Decision**: Hooks use `ctx` from PATH. `ctx init` checks PATH before 
    +proceeding.
     

    Generic core with Claude enhancements (2026-01-20)

    **Context**: ctx should work with any AI tool, but Claude Code users could
    @@ -1348,16 +1385,19 @@ 

    The Learning Log: 24 Gotchas a

    The .context/LEARNINGS.md file captures gotchas that would otherwise be forgotten. Each has Context, Lesson, and Application sections:

    CGO on ARM64

    -
    **Context**: `go test` failed with `gcc: error: unrecognized command-line option '-m64'`
    -**Lesson**: On ARM64 Linux, CGO causes cross-compilation issues. Always use `CGO_ENABLED=0`.
    +
    **Context**: `go test` failed with 
    +`gcc: error: unrecognized command-line option '-m64'`
    +**Lesson**: On ARM64 Linux, CGO causes cross-compilation issues. 
    +Always use `CGO_ENABLED=0`.
     

    Claude Code skills format

    **Lesson**: Claude Code skills are Markdown files in .claude/commands/ with `YAML`
     frontmatter (*description, argument-hint, allowed-tools*). Body is the prompt.
     

    "Do you remember?" handling

    -
    **Lesson**: In a `ctx`-enabled project, "*do you remember?*" has an obvious meaning:
    -check the `.context/` files. Don't ask for clarification—just do it.
    +
    **Lesson**: In a `ctx`-enabled project, "*do you remember?*" 
    +has an obvious meaning:
    +check the `.context/` files. Don't ask for clarification—just do it.
     

    Task Archives: The Completed Work

    Completed tasks are archived to .context/archive/ with timestamps.

    @@ -1430,7 +1470,17 @@

    Conclusiongithub.com/ActiveMemory/ctx,
  • and the documentation lives at ctx.ist.
-

If you're a mere mortal tired of reset amnesia, give ctx a try.

+
+

Session Records are a Gold Mine

+
+

By the time of this writing, I have more than 70 megabytes of +text-only session capture, spread across >100 markdown and JSONL +files.

+
I am analyzing, synthesizing, encriching them with AI, running RAG
+(*Retrieval-Augmented Generation*) models on them, and the outcome
+surprises me every day.
+
+

If you are a mere mortal tired of reset amnesia, give ctx a try.

And when you do, check .context/sessions/ sometime.

The archaeological record might surprise you.


diff --git a/site/blog/index.html b/site/blog/index.html index 79fd88d42..34633ccb9 100644 --- a/site/blog/index.html +++ b/site/blog/index.html @@ -85,7 +85,7 @@
- + Skip to content @@ -618,8 +618,6 @@ - -

Topics: dogfooding, AI-assisted development, Ralph Loop, session persistence, architectural decisions


-

More posts coming soon.

+

more posts are coming soon

diff --git a/site/cli-reference/index.html b/site/cli-reference/index.html index 23ff09170..4b0bdb6d9 100644 --- a/site/cli-reference/index.html +++ b/site/cli-reference/index.html @@ -516,6 +516,45 @@
+ + +
  • + + + + ctx completion + + + + + +
  • @@ -1249,6 +1288,45 @@ +
  • + +
  • + + + + ctx completion + + + + + +
  • @@ -2039,13 +2117,66 @@

    ctx compactctx compact --no-auto-save


  • +

    ctx completion

    +

    Generate shell autocompletion scripts.

    +
    ctx completion <shell>
    +
    +

    Subcommands

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    ShellCommand
    bashctx completion bash
    zshctx completion zsh
    fishctx completion fish
    powershellctx completion powershell
    +

    Installation

    +
    +
    +
    +
    # Add to ~/.bashrc
    +source <(ctx completion bash)
    +
    +
    +
    +
    # Add to ~/.zshrc
    +source <(ctx completion zsh)
    +
    +
    +
    +
    ctx completion fish | source
    +# Or save to completions directory
    +ctx completion fish > ~/.config/fish/completions/ctx.fish
    +
    +
    +
    +
    +

    ctx tasks

    Manage task archival and snapshots.

    -
    ctx tasks <subcommand>
    +
    ctx tasks <subcommand>
     

    ctx tasks archive

    Move completed tasks from TASKS.md to a timestamped archive file.

    -
    ctx tasks archive [flags]
    +
    ctx tasks archive [flags]
     

    Flags:

    @@ -2066,12 +2197,12 @@

    ctx tasks archivetasks-YYYY-MM-DD.md). Completed tasks (marked with [x]) are moved; pending tasks ([ ]) remain in TASKS.md.

    Example:

    -
    ctx tasks archive
    -ctx tasks archive --dry-run
    +
    ctx tasks archive
    +ctx tasks archive --dry-run
     

    ctx tasks snapshot

    Create a point-in-time snapshot of TASKS.md without modifying the original.

    -
    ctx tasks snapshot [name]
    +
    ctx tasks snapshot [name]
     

    Arguments:

      @@ -2080,51 +2211,51 @@

      ctx tasks snapshotSnapshots are stored in .context/archive/ with timestamped names (tasks-<name>-YYYY-MM-DD-HHMM.md).

      Example:

      -
      ctx tasks snapshot
      -ctx tasks snapshot "before-refactor"
      +
      ctx tasks snapshot
      +ctx tasks snapshot "before-refactor"
       

      ctx decisions

      Manage the DECISIONS.md file.

      -
      ctx decisions <subcommand>
      +
      ctx decisions <subcommand>
       

      ctx decisions reindex

      Regenerate the quick-reference index at the top of DECISIONS.md.

      -
      ctx decisions reindex
      +
      ctx decisions reindex
       

      The index is a compact table showing date and title for each decision, allowing AI tools to quickly scan entries without reading the full file.

      Use this after manual edits to DECISIONS.md or when migrating existing files to use the index format.

      Example:

      -
      ctx decisions reindex
      -# ✓ Index regenerated with 12 entries
      +
      ctx decisions reindex
      +# ✓ Index regenerated with 12 entries
       

      ctx learnings

      Manage the LEARNINGS.md file.

      -
      ctx learnings <subcommand>
      +
      ctx learnings <subcommand>
       

      ctx learnings reindex

      Regenerate the quick-reference index at the top of LEARNINGS.md.

      -
      ctx learnings reindex
      +
      ctx learnings reindex
       

      The index is a compact table showing date and title for each learning, allowing AI tools to quickly scan entries without reading the full file.

      Use this after manual edits to LEARNINGS.md or when migrating existing files to use the index format.

      Example:

      -
      ctx learnings reindex
      -# ✓ Index regenerated with 8 entries
      +
      ctx learnings reindex
      +# ✓ Index regenerated with 8 entries
       

      ctx recall

      Browse and search AI session history from Claude Code and other tools.

      -
      ctx recall <subcommand>
      +
      ctx recall <subcommand>
       

      ctx recall list

      List all parsed sessions.

      -
      ctx recall list [flags]
      +
      ctx recall list [flags]
       

      Flags:

    @@ -2151,19 +2282,24 @@

    ctx recall list-t

    + + + + +
    Filter by tool (e.g., claude-code)
    --all-projectsInclude sessions from all projects

    Sessions are sorted by date (newest first) and display slug, project, start time, duration, turn count, and token usage.

    Example:

    -
    ctx recall list
    -ctx recall list --limit 5
    -ctx recall list --project ctx
    -ctx recall list --tool claude-code
    +
    ctx recall list
    +ctx recall list --limit 5
    +ctx recall list --project ctx
    +ctx recall list --tool claude-code
     

    ctx recall show

    Show details of a specific session.

    -
    ctx recall show [session-id] [flags]
    +
    ctx recall show [session-id] [flags]
     

    Flags:

    @@ -2182,18 +2318,22 @@

    ctx recall show--full

    + + + +
    Show full message content
    --all-projectsSearch across all projects

    The session ID can be a full UUID, partial match, or session slug name.

    Example:

    -
    ctx recall show abc123
    -ctx recall show gleaming-wobbling-sutherland
    -ctx recall show --latest
    -ctx recall show --latest --full
    +
    ctx recall show abc123
    +ctx recall show gleaming-wobbling-sutherland
    +ctx recall show --latest
    +ctx recall show --latest --full
     

    ctx recall export

    Export sessions to editable journal files in .context/journal/.

    -
    ctx recall export [session-id] [flags]
    +
    ctx recall export [session-id] [flags]
     

    Flags:

    @@ -2212,6 +2352,10 @@

    ctx recall export--force

    + + + +
    Overwrite existing files
    --all-projectsExport from all projects

    Exported files include session metadata, tool usage summary, and the full @@ -2219,18 +2363,18 @@

    ctx recall exportThe journal/ directory should be gitignored (like sessions/) since it contains raw conversation data.

    Example:

    -
    ctx recall export abc123          # Export one session
    -ctx recall export --all           # Export all sessions
    -ctx recall export --all --force   # Overwrite existing exports
    +
    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.

    -
    ctx journal <subcommand>
    +
    ctx journal <subcommand>
     

    ctx journal site

    Generate a static site from journal entries in .context/journal/.

    -
    ctx journal site [flags]
    +
    ctx journal site [flags]
     

    Flags:

    @@ -2259,37 +2403,37 @@

    ctx journal site

    -

    Creates a zensical-compatible site structure with an index page listing +

    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: -

    pip install zensical
    -

    +

    Requires zensical to be installed for --build or --serve:

    +
    pip install zensical
    +

    Example:

    -
    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 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.

    -
    ctx serve [directory]
    +

    Serve a static site locally via zensical.

    +
    ctx serve [directory]
     

    If no directory is specified, serves the journal site (.context/journal-site).

    -

    Requires zensical to be installed: -

    pip install zensical
    -

    +

    Requires zensical to be installed:

    +
    pip install zensical
    +

    Example:

    -
    ctx serve                           # Serve journal site
    -ctx serve .context/journal-site     # Serve specific directory
    -ctx serve ./docs                    # Serve docs folder
    +
    ctx serve                           # Serve journal site
    +ctx serve .context/journal-site     # Serve specific directory
    +ctx serve ./docs                    # Serve docs folder
     

    ctx watch

    Watch for AI output and auto-apply context updates.

    Parses <context-update> XML commands from AI output and applies them to context files.

    -
    ctx watch [flags]
    +
    ctx watch [flags]
     

    Flags:

    @@ -2315,19 +2459,19 @@

    ctx watch

    Example:

    -
    # Watch stdin
    -ai-tool | ctx watch
    -
    -# Watch a log file
    -ctx watch --log /path/to/ai-output.log
    -
    -# Preview without applying
    -ctx watch --dry-run
    +
    # Watch stdin
    +ai-tool | ctx watch
    +
    +# Watch a log file
    +ctx watch --log /path/to/ai-output.log
    +
    +# Preview without applying
    +ctx watch --dry-run
     

    ctx hook

    Generate AI tool integration configuration.

    -
    ctx hook <tool>
    +
    ctx hook <tool>
     

    Supported tools:

    @@ -2361,16 +2505,16 @@

    ctx hook

    Example:

    -
    ctx hook claude-code
    -ctx hook cursor
    -ctx hook aider
    +
    ctx hook claude-code
    +ctx hook cursor
    +ctx hook aider
     

    ctx session

    Manage session snapshots.

    ctx session save

    Save the current context snapshot.

    -
    ctx session save [topic] [flags]
    +
    ctx session save [topic] [flags]
     

    Flags:

    @@ -2390,13 +2534,13 @@

    ctx session save
    ctx session save
    -ctx session save "feature-auth"
    -ctx session save "bugfix" --type bugfix
    +
    ctx session save
    +ctx session save "feature-auth"
    +ctx session save "bugfix" --type bugfix
     

    ctx session list

    List saved sessions.

    -
    ctx session list [flags]
    +
    ctx session list [flags]
     

    Flags:

    @@ -2417,12 +2561,12 @@

    ctx session list

    Output: Table of sessions with index, date, topic, and type.

    Example:

    -
    ctx session list
    -ctx session list --limit 5
    +
    ctx session list
    +ctx session list --limit 5
     

    ctx session load

    Load and display a previous session.

    -
    ctx session load <index|date|topic>
    +
    ctx session load <index|date|topic>
     

    Arguments:

    Example:

    -
    ctx session load 1           # by index
    -ctx session load 2026-01-21  # by date
    -ctx session load auth        # by topic
    +
    ctx session load 1           # by index
    +ctx session load 2026-01-21  # by date
    +ctx session load auth        # by topic
     

    ctx session parse

    Parse JSONL transcript to readable markdown.

    -
    ctx session parse <file> [flags]
    +
    ctx session parse <file> [flags]
     

    Flags:

    @@ -2462,9 +2606,9 @@

    ctx session parse

    Example:

    -
    ctx session parse ~/.claude/projects/.../transcript.jsonl
    -ctx session parse transcript.jsonl --extract
    -ctx session parse transcript.jsonl -o conversation.md
    +
    ctx session parse ~/.claude/projects/.../transcript.jsonl
    +ctx session parse transcript.jsonl --extract
    +ctx session parse transcript.jsonl -o conversation.md
     

    ctx loop

    @@ -2472,7 +2616,7 @@

    ctx loopAn 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.

    -
    ctx loop [flags]
    +
    ctx loop [flags]
     

    Flags:

    @@ -2518,23 +2662,23 @@

    ctx loop

    Example:

    -
    # Generate loop.sh for Claude Code
    -ctx loop
    -
    -# Generate for Aider with custom prompt
    -ctx loop --tool aider --prompt TASKS.md
    -
    -# Limit to 10 iterations
    -ctx loop --max-iterations 10
    -
    -# Output to custom file
    -ctx loop -o my-loop.sh
    +
    # Generate loop.sh for Claude Code
    +ctx loop
    +
    +# Generate for Aider with custom prompt
    +ctx loop --tool aider --prompt TASKS.md
    +
    +# Limit to 10 iterations
    +ctx loop --max-iterations 10
    +
    +# Output to custom file
    +ctx loop -o my-loop.sh
     

    Usage:

    -
    # Generate and run the loop
    -ctx loop
    -chmod +x loop.sh
    -./loop.sh
    +
    # Generate and run the loop
    +ctx loop
    +chmod +x loop.sh
    +./loop.sh
     

    See Autonomous Loops for detailed workflow documentation.


    @@ -2594,15 +2738,15 @@

    Environment VariablesConfiguration File

    Optional .contextrc (YAML format) at project root:

    -
    # .contextrc
    -context_dir: .context # Context directory name
    -token_budget: 8000    # Default token budget
    -priority_order:       # File loading priority
    -  - TASKS.md
    -  - DECISIONS.md
    -  - CONVENTIONS.md
    -auto_archive: true    # Auto-archive old items
    -archive_after_days: 7 # Days before archiving
    +
    # .contextrc
    +context_dir: .context # Context directory name
    +token_budget: 8000    # Default token budget
    +priority_order:       # File loading priority
    +  - TASKS.md
    +  - DECISIONS.md
    +  - CONVENTIONS.md
    +auto_archive: true    # Auto-archive old items
    +archive_after_days: 7 # Days before archiving
     

    Priority order: CLI flags > Environment variables > .contextrc > Defaults

    All settings are optional. Missing values use defaults.

    diff --git a/site/context-files/index.html b/site/context-files/index.html index 524b0240e..9b316ec7d 100644 --- a/site/context-files/index.html +++ b/site/context-files/index.html @@ -1655,7 +1655,7 @@

    Read Order RationaleCONSTITUTION.md

    +

    CONSTITUTION.md

    Purpose: Define hard invariants—rules that must NEVER be violated, regardless of the task.

    AI tools read this first and should refuse tasks that violate these rules.

    @@ -1691,7 +1691,7 @@

    GuidelinesTASKS.md

    +

    TASKS.md

    Purpose: Track current work, planned work, and blockers.

    Structure

    Tasks are organized by Phase — logical groupings that preserve order and @@ -1814,7 +1814,7 @@

    GuidelinesMark current work with #in-progress inline tag
    -

    DECISIONS.md

    +

    DECISIONS.md

    Purpose: Record architectural decisions with rationale so they don't get re-debated.

    Structure

    @@ -1879,7 +1879,7 @@

    Status Values
    -

    LEARNINGS.md

    +

    LEARNINGS.md

    Purpose: Capture lessons learned, gotchas, and tips that shouldn't be forgotten.

    Structure

    @@ -1923,7 +1923,7 @@

    CategoriesCONVENTIONS.md

    +

    CONVENTIONS.md

    Purpose: Document project patterns, naming conventions, and standards.

    Structure

    # Conventions
    @@ -1954,7 +1954,7 @@ 

    GuidelinesKeep patterns minimal—only document what's non-obvious
    -

    ARCHITECTURE.md

    +

    ARCHITECTURE.md

    Purpose: Provide system overview and component relationships.

    Structure

    # Architecture
    @@ -1991,7 +1991,7 @@ 

    GuidelinesUpdate when major structural changes occur
    -

    GLOSSARY.md

    +

    GLOSSARY.md

    Purpose: Define domain terms, abbreviations, and project vocabulary.

    Structure

    # Glossary
    @@ -2020,7 +2020,7 @@ 

    GuidelinesInclude abbreviations used in code or docs
    -

    DRIFT.md

    +

    DRIFT.md

    Purpose: Define signals that the context is stale and needs updating.

    Used by ctx drift command to detect staleness.

    Structure

    @@ -2062,7 +2062,7 @@

    Structure| LEARNINGS.md | >20 items | Consolidate or archive |


    -

    AGENT_PLAYBOOK.md

    +

    AGENT_PLAYBOOK.md

    Purpose: Explicit instructions for how AI tools should read, apply, and update context.

    Key Sections

    diff --git a/site/index.html b/site/index.html index d831583b5..5e83b0a7e 100644 --- a/site/index.html +++ b/site/index.html @@ -1185,10 +1185,15 @@

    ctxCommunity

    Open source is better together.

    -
    -

    ⭐️ If the idea behind ctx resonates, a star helps it reach engineers who run into context drift every day.

    + +
    +

    Help ctx Change How AI Remembers

    +

    If the idea behind ctx resonates, a star helps it reach engineers +who run into context drift every day.

    https://github.com/ActiveMemory/ctx

    -
    +

    ctx is free and open source software, and contributions are always +welcome and appreciated.

    +

    Join the community to ask questions, share feedback, and connect with other users:

      @@ -1214,41 +1219,41 @@