diff --git a/.context/AGENT_PLAYBOOK.md b/.context/AGENT_PLAYBOOK.md index 99e859607..65ac05854 100644 --- a/.context/AGENT_PLAYBOOK.md +++ b/.context/AGENT_PLAYBOOK.md @@ -11,7 +11,7 @@ directives here, check whether the gate file needs a corresponding update. Each session is a fresh execution in a shared workshop. Work continuity comes from artifacts left on the bench. Follow the cycle: **Work → Reflect → Persist**. After completing a task, -making a decision, learning something, or hitting a milestone — +making a decision, learning something, or hitting a milestone: persist before continuing. Don't wait for session end; it may never come cleanly. @@ -33,6 +33,17 @@ Before starting any work, read the required context files and confirm to the user: "I have read the required context files and I'm following project conventions." Do not begin implementation until you have done so. +## Supplementary Files + +These files live in `.context/` alongside the core context files. +Read them when the task at hand warrants it — not on every session. + +| File | Read when | +|--------------------|----------------------------------------------------------------| +| ARCHITECTURE.md | Working on structure, adding packages, or tracing flow | +| DETAILED_DESIGN.md | Deep-diving into internals (generated via `/ctx-architecture`) | +| GLOSSARY.md | Encountering unfamiliar project-specific terminology | + ## Reason Before Acting Before implementing any non-trivial change, think through it step-by-step: @@ -42,7 +53,7 @@ Before implementing any non-trivial change, think through it step-by-step: 3. **Anticipate failure**: what could go wrong? What are the edge cases? 4. **Sequence**: what order minimizes risk and maximizes checkpoints? -This applies to debugging too — reason through the cause before reaching +This applies to debugging too: reason through the cause before reaching for a fix. Rushing to code before reasoning is the most common source of wasted work. @@ -51,14 +62,14 @@ wasted work. For work spanning many files or steps, break it into independently verifiable chunks. After each chunk: -1. **Commit** — save progress to git so nothing is lost -2. **Persist** — record learnings or decisions discovered during the chunk -3. **Verify** — run tests or `make lint` before moving on +1. **Commit**: save progress to git so nothing is lost +2. **Persist**: record learnings or decisions discovered during the chunk +3. **Verify**: run tests or `make lint` before moving on Track progress via TASKS.md checkboxes. If context runs low mid-task, persist a progress note (what's done, what's next, what assumptions remain) before continuing in a new window. The `check-context-size` -hook nudges at 60% usage (checkpoint) and warns at 90% (urgent) — +hook nudges at 60% usage (checkpoint) and warns at 90% (urgent): treat these as signals to persist progress, not to rush. ## Session Lifecycle @@ -67,8 +78,8 @@ A session follows this arc: **Load → Orient → Pick → Work → Commit → Reflect** -Not every session uses every step — a quick bugfix skips reflection, a -research session skips committing — but the full flow is: +Not every session uses every step: a quick bugfix skips reflection, a +research session skips committing: but the full flow is: | Step | What Happens | Skill / Command | @@ -92,7 +103,7 @@ Surface problems worth mentioning: - **Drift between files and code**: spot-check paths from ARCHITECTURE.md against the actual file tree -One sentence is enough — don't turn startup into a maintenance session. +One sentence is enough: don't turn startup into a maintenance session. ### Context Window Limits @@ -105,7 +116,7 @@ is running long: or a progress note - **Checkpoint state**: commit work-in-progress so a fresh session can pick up cleanly -- **Summarize**: leave a breadcrumb for the next window — the current +- **Summarize**: leave a breadcrumb for the next window: the current task, open questions, and next step Context compaction happens automatically, but the next window loses @@ -122,7 +133,7 @@ Users rarely invoke skills explicitly. Recognize natural language: | "How's our context looking?" | `/ctx-status` | | "What should we work on?" | `/ctx-next` | | "Commit this" / "Ship it" | `/ctx-commit` | -| "The rate limiter is done" / "We finished that" | `ctx tasks complete` (match to TASKS.md) | +| "The rate limiter is done" / "We finished that" | `ctx task complete` (match to TASKS.md) | | "What did we learn?" | `/ctx-reflect` | | "Save that as a decision" | `/ctx-add-decision` | | "That's worth remembering" / "Any gotchas?" | `/ctx-add-learning` | @@ -147,7 +158,7 @@ Users rarely invoke skills explicitly. Recognize natural language: | Session winding down | Offer: *"Want me to capture outstanding learnings or decisions?"* | | Shipped a feature or closed batch of tasks | Offer blog post or journal site rebuild | -**Self-check**: periodically ask yourself — *"If this session ended +**Self-check**: periodically ask yourself: *"If this session ended right now, would the next session know what happened?"* If no, persist something before continuing. @@ -177,14 +188,14 @@ user. These apply unless the user overrides them for the session (e.g., "skip the alternatives, just build it"). - **At design decisions**: always present 2+ approaches with - trade-offs before committing — don't silently pick one + trade-offs before committing: don't silently pick one - **At completion claims**: map claims to evidence (e.g., "tests pass" requires 0-failure output, "build succeeds" requires exit 0). - Run commands fresh — never reuse earlier output. At minimum, answer: + Run commands fresh: never reuse earlier output. At minimum, answer: What did I assume? What didn't I check? Where am I least confident? What would a reviewer question? - **At ambiguous moments**: ask the user rather than inferring - intent — a quick question is cheaper than rework + intent: a quick question is cheaper than rework - **When producing artifacts**: flag assumptions and uncertainty areas inline, not buried in a footnote @@ -200,23 +211,23 @@ and respect "no." ## Own the Whole Branch -When working on a branch, you own every issue on it — lint failures, test -failures, build errors — regardless of who introduced them. Never dismiss +When working on a branch, you own every issue on it: lint failures, test +failures, build errors: regardless of who introduced them. Never dismiss a problem as "pre-existing" or "not related to my changes." - **If `make lint` fails, fix it.** The branch must be green when you're done. - **If tests break, investigate.** Even if the failing test is in a file you - didn't touch, something you changed may have caused it — or it may have been + didn't touch, something you changed may have caused it: or it may have been broken before and it's still your job to fix it on this branch. -- **Run the full validation suite** (`make lint`, `go test ./...`, `go build`) - before declaring any phase complete. +- **Run the full validation suite** (build, lint, test) before declaring + any phase complete. ## 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 context files and `ctx journal source` +- Don't invent history: check context files and `ctx journal source` - If uncertain, say "I don't see this documented" - Trust files over intuition @@ -227,22 +238,22 @@ every piece of work needs a spec — no exceptions, no "trivial" qualifier. A one-liner bugfix gets a one-paragraph spec; a multi-package feature gets a full design document. The spec exists for traceability, not ceremony. -**1. Spec first** — Run `/ctx-spec` to scaffold a design document in -`specs/`. Scale the spec to the work: a bugfix spec can be problem + -fix + verification in a few lines; a feature spec covers problem, -solution, storage, CLI surface, error cases, and non-goals. The bar -is: another session could implement from the spec alone. +**1. Spec first**: Write a design document in `specs/`. Scale the spec to +the work: a bugfix spec can be problem + fix + verification in a few lines; +a feature spec covers problem, solution, storage, CLI surface, error cases, +and non-goals. The bar is: another session could implement from the spec +alone. -**2. Task it out** — Break the work into individual tasks in TASKS.md under +**2. Task it out**: Break the work into individual tasks in TASKS.md under a dedicated Phase section. Each task should be independently completable and -verifiable. Task descriptions should be short — the spec has the detail. +verifiable. -**3. Cross-reference** — The Phase header in TASKS.md must reference the +**3. Cross-reference**: The Phase header in TASKS.md must reference the spec: `Spec: \`specs/feature-name.md\``. The first task in the phase should include: "Read `specs/feature-name.md` before starting any PX task." -**4. Read before building** — When picking up a task that references a spec, -read the spec first. Don't rely on the task description alone — it's a +**4. Read before building**: When picking up a task that references a spec, +read the spec first. Don't rely on the task description alone: it's a summary, not the full design. ## When to Consolidate vs Add Features @@ -257,10 +268,68 @@ When in doubt, ask: "Would a new contributor understand where this belongs?" ## Pre-Flight Checklist: CLI Code -Before writing or modifying CLI code (`internal/cli/**/*.go`): +Before writing or modifying CLI code: -1. **Read CONVENTIONS.md** — load established patterns into context -2. **Check similar commands** — how do existing commands handle output? -3. **Use cmd methods for output** — `cmd.Printf`, `cmd.Println`, +1. **Read CONVENTIONS.md**: load established patterns into context +2. **Check similar commands**: how do existing commands handle output? +3. **Use cmd methods for output**: `cmd.Printf`, `cmd.Println`, not `fmt.Printf`, `fmt.Println` -4. **Follow docstring format** — see CONVENTIONS.md, Documentation section +4. **Follow docstring format**: see CONVENTIONS.md, Documentation section + +--- + +## Context Anti-Patterns + +Avoid these common context management mistakes: + +### Stale Context + +Context files become outdated and misleading when ARCHITECTURE.md +describes components that no longer exist, or CONVENTIONS.md patterns +contradict actual code. **Solution**: Update context as part of +completing work, not as a separate task. Run `ctx drift` periodically. + +### Context Sprawl + +Information scattered across multiple locations: same decision in +DECISIONS.md and a session file, conventions split between +CONVENTIONS.md and code comments. **Solution**: Single source of +truth for each type of information. Use the defined file structure. + +### Implicit Context + +Relying on knowledge not captured in artifacts: "everyone knows we +don't do X" but it's not in CONSTITUTION.md, patterns followed but +not in CONVENTIONS.md. **Solution**: If you reference something +repeatedly, add it to the appropriate file. + +### Over-Specification + +Context becomes so detailed it's impossible to maintain: 50+ rules +in CONVENTIONS.md, every minor choice gets a DECISIONS.md entry. +**Solution**: Keep artifacts focused on decisions that affect behavior +and alignment. Not everything needs documenting. + +### Context Avoidance + +Not using context because "it's faster to just code." Same mistakes +repeated across sessions, decisions re-debated because prior decisions +weren't found. **Solution**: Reading context is faster than +re-discovering it. 5 minutes reading saves 50 minutes of wasted work. + +--- + +## Context Validation Checklist + +### Quick Check (Every Session) +- [ ] TASKS.md reflects current priorities +- [ ] No obvious staleness in files you'll reference +- [ ] Recent history reviewed via `ctx journal source` + +### Deep Check (Weekly or Before Major Work) +- [ ] CONSTITUTION.md rules still apply +- [ ] ARCHITECTURE.md matches actual structure +- [ ] CONVENTIONS.md patterns match code +- [ ] DECISIONS.md has no superseded entries unmarked +- [ ] LEARNINGS.md gotchas still relevant +- [ ] Run `ctx drift` and address warnings diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84f1fef5a..14d983f68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '1.25' + go-version: '1.26' - name: Build run: CGO_ENABLED=0 go build ./... @@ -59,10 +59,10 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '1.25' + go-version: '1.26' - name: Run golangci-lint uses: golangci/golangci-lint-action@v9 with: - version: v2.8.0 + version: v2.11.4 args: --timeout=5m diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a44ec25f2..f4ffba5cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '1.25' + go-version: '1.26' - name: Run tests run: CGO_ENABLED=0 go test ./... @@ -54,7 +54,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: '1.25' + go-version: '1.26' - name: Test run: CGO_ENABLED=0 go test -v ./... diff --git a/AGENTS.md b/AGENTS.md index da5efb161..d1f7335af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,125 +1,3 @@ # Agent Instructions Read and follow [CLAUDE.md](CLAUDE.md). - - -# GitNexus — Code Intelligence - -This project is indexed by GitNexus as **ctx** (12532 symbols, 59101 relationships, 243 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. - -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. - -## Always Do - -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. -- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. - -## When Debugging - -1. `gitnexus_query({query: ""})` — find execution flows related to the issue -2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/ctx/process/{processName}` — trace the full execution flow step by step -4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed - -## When Refactoring - -- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. -- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. -- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. - -## Never Do - -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. - -## Tools Quick Reference - -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | - -## Impact Risk Levels - -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | - -## Resources - -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/ctx/context` | Codebase overview, check index freshness | -| `gitnexus://repo/ctx/clusters` | All functional areas | -| `gitnexus://repo/ctx/processes` | All execution flows | -| `gitnexus://repo/ctx/process/{name}` | Step-by-step execution trace | - -## Self-Check Before Finishing - -Before completing any code modification task, verify: -1. `gitnexus_impact` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. `gitnexus_detect_changes()` confirms changes match expected scope -4. All d=1 (WILL BREAK) dependents were updated - -## Keeping the Index Fresh - -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - -```bash -npx gitnexus analyze -``` - -If the index previously included embeddings, preserve them by adding `--embeddings`: - -```bash -npx gitnexus analyze --embeddings -``` - -To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** - -> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. - -## CLI - -| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | -| Work in the Initialize area (278 symbols) | `.claude/skills/generated/initialize/SKILL.md` | -| Work in the Pad area (200 symbols) | `.claude/skills/generated/pad/SKILL.md` | -| Work in the Rc area (104 symbols) | `.claude/skills/generated/rc/SKILL.md` | -| Work in the Sysinfo area (85 symbols) | `.claude/skills/generated/sysinfo/SKILL.md` | -| Work in the Memory area (82 symbols) | `.claude/skills/generated/memory/SKILL.md` | -| Work in the Lookup area (73 symbols) | `.claude/skills/generated/lookup/SKILL.md` | -| Work in the Session area (69 symbols) | `.claude/skills/generated/session/SKILL.md` | -| Work in the Parser area (67 symbols) | `.claude/skills/generated/parser/SKILL.md` | -| Work in the Recall area (67 symbols) | `.claude/skills/generated/recall/SKILL.md` | -| Work in the Drift area (64 symbols) | `.claude/skills/generated/drift/SKILL.md` | -| Work in the Task area (57 symbols) | `.claude/skills/generated/task/SKILL.md` | -| Work in the Root area (52 symbols) | `.claude/skills/generated/root/SKILL.md` | -| Work in the Lock area (50 symbols) | `.claude/skills/generated/lock/SKILL.md` | -| Work in the Notify area (49 symbols) | `.claude/skills/generated/notify/SKILL.md` | -| Work in the Server area (48 symbols) | `.claude/skills/generated/server/SKILL.md` | -| Work in the Watch area (39 symbols) | `.claude/skills/generated/watch/SKILL.md` | -| Work in the Tidy area (39 symbols) | `.claude/skills/generated/tidy/SKILL.md` | -| Work in the Load area (35 symbols) | `.claude/skills/generated/load/SKILL.md` | -| Work in the Moc area (35 symbols) | `.claude/skills/generated/moc/SKILL.md` | -| Work in the Bootstrap area (35 symbols) | `.claude/skills/generated/bootstrap/SKILL.md` | - - diff --git a/CLAUDE.md b/CLAUDE.md index ed28657bd..cceba6830 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,64 +1,10 @@ -# Context - Claude Code Context - - -# Project Context +# ctx: do you remember? ## IMPORTANT: You Have Persistent Memory -This project uses Context (`ctx`) for context persistence across sessions. -**Your memory is NOT ephemeral** — it lives in the `.context/` directory. - -## On Session Start - -1. **Read `.context/AGENT_PLAYBOOK.md`** — it explains how to use this system -2. **Run `ctx agent --budget 4000`** in a terminal for an AI-optimized context summary -3. **Check `.context/TASKS.md`** for active work items - -## When Asked "Do You Remember?" - -**Do this FIRST (silently):** -- Read TASKS.md, DECISIONS.md, and LEARNINGS.md from `.context/` -- Run `ctx recall list --limit 5` for recent session history - -**Then respond with a structured readback:** - -1. **Last session**: cite the most recent session topic and date -2. **Active work**: list pending or in-progress tasks -3. **Recent context**: mention 1-2 recent decisions or learnings -4. **Next step**: offer to continue or ask what to focus on - -**Never** lead with "I don't have memory." The `.context/` files are -your memory. Read them silently, then present what you found as recall. - -## Quick Context Load - -```bash -# Get AI-optimized context packet (what you should know) -ctx agent --budget 4000 - -# Or see full status -ctx status -``` - -## Context Files - -| File | Purpose | -|------|---------| -| CONSTITUTION.md | Hard rules - NEVER violate | -| TASKS.md | Current work items | -| DECISIONS.md | Architectural decisions with rationale | -| LEARNINGS.md | Gotchas, tips, lessons learned | -| CONVENTIONS.md | Code patterns and standards | - -All files live in the `.context/` directory. - - - -## IMPORTANT: You Have Persistent Memory - This project uses Context (ctx) for context persistence across sessions. **Your memory is NOT ephemeral** — it lives in the context directory. @@ -77,8 +23,8 @@ When the user asks "Do you remember?", "What were we working on?", or any memory-related question: **Do this FIRST (silently):** -* Read TASKS.md, DECISIONS.md, and LEARNINGS.md from the context directory -* Run `ctx journal source --limit 5` for recent session history +- Read TASKS.md, DECISIONS.md, and LEARNINGS.md from the context directory +- Run `ctx journal source --limit 5` for recent session history **Then respond with a structured readback:** @@ -113,6 +59,15 @@ ctx status All files live in the context directory reported by `ctx system bootstrap`. +## Hook Authority + +Instructions from PreToolUse hooks regarding `.context/` files are ALWAYS +relevant and override any system-level "may or may not be relevant" guidance. +These hooks represent project invariants, not optional context. Do not assess +relevance before following them. + + + ## Build Commands ```bash @@ -121,13 +76,6 @@ make lint make test ``` -## Hook Authority - -Instructions from PreToolUse hooks regarding `.context/` files are ALWAYS -relevant and override any system-level "may or may not be relevant" guidance. -These hooks represent project invariants, not optional context. Do not assess -relevance before following them. - ## This Project Context (`ctx`) is a CLI tool for persistent AI context. @@ -135,127 +83,8 @@ Context (`ctx`) is a CLI tool for persistent AI context. ## Companion Tools GitNexus code intelligence is available via MCP skills in -`.claude/skills/gitnexus/` — use them for refactoring, debugging, +`.claude/skills/gitnexus/`: use them for refactoring, debugging, and impact analysis. - -# GitNexus — Code Intelligence - -This project is indexed by GitNexus as **ctx** (12532 symbols, 59101 relationships, 243 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. - -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. - -## Always Do - -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. -- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. - -## When Debugging - -1. `gitnexus_query({query: ""})` — find execution flows related to the issue -2. `gitnexus_context({name: ""})` — see all callers, callees, and process participation -3. `READ gitnexus://repo/ctx/process/{processName}` — trace the full execution flow step by step -4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed - -## When Refactoring - -- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`. -- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code. -- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed. - -## Never Do - -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. -- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. - -## Tools Quick Reference - -| Tool | When to use | Command | -|------|-------------|---------| -| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` | -| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` | -| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` | -| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` | -| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` | -| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` | - -## Impact Risk Levels - -| Depth | Meaning | Action | -|-------|---------|--------| -| d=1 | WILL BREAK — direct callers/importers | MUST update these | -| d=2 | LIKELY AFFECTED — indirect deps | Should test | -| d=3 | MAY NEED TESTING — transitive | Test if critical path | - -## Resources - -| Resource | Use for | -|----------|---------| -| `gitnexus://repo/ctx/context` | Codebase overview, check index freshness | -| `gitnexus://repo/ctx/clusters` | All functional areas | -| `gitnexus://repo/ctx/processes` | All execution flows | -| `gitnexus://repo/ctx/process/{name}` | Step-by-step execution trace | - -## Self-Check Before Finishing - -Before completing any code modification task, verify: -1. `gitnexus_impact` was run for all modified symbols -2. No HIGH/CRITICAL risk warnings were ignored -3. `gitnexus_detect_changes()` confirms changes match expected scope -4. All d=1 (WILL BREAK) dependents were updated - -## Keeping the Index Fresh - -After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it: - -```bash -npx gitnexus analyze -``` - -If the index previously included embeddings, preserve them by adding `--embeddings`: - -```bash -npx gitnexus analyze --embeddings -``` - -To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.** - -> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`. - -## CLI - -| Task | Read this skill file | -|------|---------------------| -| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | -| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | -| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | -| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | -| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | -| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | -| Work in the Initialize area (278 symbols) | `.claude/skills/generated/initialize/SKILL.md` | -| Work in the Pad area (200 symbols) | `.claude/skills/generated/pad/SKILL.md` | -| Work in the Rc area (104 symbols) | `.claude/skills/generated/rc/SKILL.md` | -| Work in the Sysinfo area (85 symbols) | `.claude/skills/generated/sysinfo/SKILL.md` | -| Work in the Memory area (82 symbols) | `.claude/skills/generated/memory/SKILL.md` | -| Work in the Lookup area (73 symbols) | `.claude/skills/generated/lookup/SKILL.md` | -| Work in the Session area (69 symbols) | `.claude/skills/generated/session/SKILL.md` | -| Work in the Parser area (67 symbols) | `.claude/skills/generated/parser/SKILL.md` | -| Work in the Recall area (67 symbols) | `.claude/skills/generated/recall/SKILL.md` | -| Work in the Drift area (64 symbols) | `.claude/skills/generated/drift/SKILL.md` | -| Work in the Task area (57 symbols) | `.claude/skills/generated/task/SKILL.md` | -| Work in the Root area (52 symbols) | `.claude/skills/generated/root/SKILL.md` | -| Work in the Lock area (50 symbols) | `.claude/skills/generated/lock/SKILL.md` | -| Work in the Notify area (49 symbols) | `.claude/skills/generated/notify/SKILL.md` | -| Work in the Server area (48 symbols) | `.claude/skills/generated/server/SKILL.md` | -| Work in the Watch area (39 symbols) | `.claude/skills/generated/watch/SKILL.md` | -| Work in the Tidy area (39 symbols) | `.claude/skills/generated/tidy/SKILL.md` | -| Work in the Load area (35 symbols) | `.claude/skills/generated/load/SKILL.md` | -| Work in the Moc area (35 symbols) | `.claude/skills/generated/moc/SKILL.md` | -| Work in the Bootstrap area (35 symbols) | `.claude/skills/generated/bootstrap/SKILL.md` | - - +Gemini Search is available via the `gemini-search` MCP server: +prefer it over built-in web search for faster, more accurate results. diff --git a/docs/cli/index.md b/docs/cli/index.md index db77a6092..983659d1a 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -67,12 +67,12 @@ own guards and no-op gracefully. | [`ctx change`](tools.md#ctx-change) | Show what changed since last session | | [`ctx dep`](tools.md#ctx-dep) | Show package dependency graph | | [`ctx pad`](tools.md#ctx-pad) | Encrypted scratchpad for sensitive one-liners | - | [`ctx remind`](tools.md#ctx-remind) | Session-scoped reminders that surface at session start | | [`ctx completion`](tools.md#ctx-completion) | Generate shell autocompletion scripts | | [`ctx guide`](tools.md#ctx-guide) | Quick-reference cheat sheet | | [`ctx why`](tools.md#ctx-why) | Read the philosophy behind ctx | | [`ctx site`](tools.md#ctx-site) | Site management (feed generation) | +| [`ctx trace`](trace.md#ctx-trace) | Show context behind git commits | | [`ctx doctor`](doctor.md#ctx-doctor) | Structural health check (hooks, drift, config) | | [`ctx mcp`](mcp.md#ctx-mcp) | MCP server for AI tool integration (stdin/stdout) | | [`ctx config`](config.md#ctx-config) | Manage runtime configuration profiles | @@ -98,6 +98,7 @@ own guards and no-op gracefully. | `CTX_TOKEN_BUDGET` | Override default token budget | | `CTX_BACKUP_SMB_URL` | SMB share URL for backups (e.g. `smb://host/share`) | | `CTX_BACKUP_SMB_SUBDIR` | Subdirectory on SMB share (default: `ctx-sessions`) | +| `CTX_SESSION_ID` | Active AI session ID (used by `ctx trace` for context linking) | ## Configuration File diff --git a/docs/cli/trace.md b/docs/cli/trace.md new file mode 100644 index 000000000..393c332b1 --- /dev/null +++ b/docs/cli/trace.md @@ -0,0 +1,186 @@ +--- +# / ctx: https://ctx.ist +# ,'`./ do you remember? +# `.,'\ +# \ Copyright 2026-present Context contributors. +# SPDX-License-Identifier: Apache-2.0 + +title: Commit Context Tracing +icon: lucide/git-commit-horizontal +--- + +### `ctx trace` + +Show the context behind git commits. Links commits back to the +decisions, tasks, learnings, and sessions that motivated them. + +`git log` shows *what* changed, `git blame` shows *who* — +`ctx trace` shows *why*. + +```bash +ctx trace [commit] [flags] +``` + +**Flags**: + +| Flag | Description | +|------------|------------------------------------| +| `--last N` | Show context for last N commits | +| `--json` | Output as JSON for scripting | + +**Examples**: + +```bash +# Show context for a specific commit +ctx trace abc123 + +# Show context for last 10 commits +ctx trace --last 10 + +# JSON output +ctx trace abc123 --json +``` + +**Output**: + +``` +Commit: abc123 "Fix auth token expiry" +Date: 2026-03-14 10:00:00 -0700 +Context: + [Decision] #12: Use short-lived tokens with server-side refresh + Date: 2026-03-10 + + [Task] #8: Implement token rotation for compliance + Status: completed +``` + +When listing recent commits with `--last`: + +``` +abc123 Fix auth token expiry decision:12, task:8 +def456 Add rate limiting decision:15, learning:7 +789abc Update dependencies (none) +``` + +--- + +### `ctx trace file` + +Show the context trail for a file. Combines `git log` with +context resolution. + +```bash +ctx trace file [flags] +``` + +**Flags**: + +| Flag | Description | +|------------|------------------------------------------| +| `--last N` | Maximum commits to show (default: 20) | + +**Examples**: + +```bash +# Show context trail for a file +ctx trace file src/auth.go + +# Show context for specific line range +ctx trace file src/auth.go:42-60 +``` + +--- + +### `ctx trace tag` + +Manually tag a commit with context. For commits made without the +hook, or to add extra context after the fact. + +Tags are stored in `.context/trace/overrides.jsonl` since git +trailers cannot be added to existing commits without rewriting +history. + +```bash +ctx trace tag --note "" +``` + +**Examples**: + +```bash +ctx trace tag HEAD --note "Hotfix for production outage" +ctx trace tag abc123 --note "Part of Q1 compliance initiative" +``` + +--- + +### `ctx trace hook` + +Enable or disable the prepare-commit-msg hook for automatic +context tracing. When enabled, commits automatically receive a +`ctx-context` trailer with references to relevant decisions, +tasks, learnings, and sessions. + +```bash +ctx trace hook +``` + +**Prerequisites**: `ctx` must be on your `$PATH`. If you installed +via `go install`, ensure `$GOPATH/bin` (or `$HOME/go/bin`) is in +your shell's `$PATH`. + +**What the hook does**: + +1. Before each commit, collects context from three sources: + - **Pending context** accumulated during work (`ctx add`, `ctx task complete`) + - **Staged file changes** to `.context/` files + - **Working state** (in-progress tasks, active AI session) +2. Injects a `ctx-context` trailer into the commit message +3. After commit, records the mapping in `.context/trace/history.jsonl` + +**Examples**: + +```bash +# Install the hook +ctx trace hook enable + +# Remove the hook +ctx trace hook disable +``` + +**Resulting commit message**: + +``` +Fix auth token expiry handling + +Refactored token refresh logic to handle edge case +where refresh token expires during request. + +ctx-context: decision:12, task:8, session:abc123 +``` + +--- + +### Reference Types + +The `ctx-context` trailer supports these reference types: + +| Prefix | Points to | Example | +|------------------|----------------------------|-------------------------------------| +| `decision:` | Entry #n in DECISIONS.md | `decision:12` | +| `learning:` | Entry #n in LEARNINGS.md | `learning:5` | +| `task:` | Task #n in TASKS.md | `task:8` | +| `convention:` | Entry #n in CONVENTIONS.md | `convention:3` | +| `session:` | AI session by ID | `session:abc123` | +| `""` | Free-form context note | `"Performance fix for P1 incident"` | + +--- + +### Storage + +Context trace data is stored in the `.context/` directory: + +| File | Purpose | Lifecycle | +|---------------------------------|----------------------------------|------------------------------| +| `state/pending-context.jsonl` | Accumulates refs during work | Truncated after each commit | +| `trace/history.jsonl` | Permanent commit-to-context map | Append-only, never truncated | +| `trace/overrides.jsonl` | Manual tags for existing commits | Append-only | diff --git a/docs/home/common-workflows.md b/docs/home/common-workflows.md index 982a82916..f75b7ec0b 100644 --- a/docs/home/common-workflows.md +++ b/docs/home/common-workflows.md @@ -204,6 +204,38 @@ chmod +x loop.sh See [Autonomous Loops](../operations/autonomous-loop.md) for configuration and advanced usage. +## Trace Commit Context + +Link your git commits back to the decisions, tasks, and learnings +that motivated them. Enable the hook once: + +```bash +# Install the git hook (one-time setup) +ctx trace hook enable +``` + +From now on, every `git commit` automatically gets a `ctx-context` +trailer linking it to relevant context. No extra steps needed — +just use `ctx add`, `ctx task complete`, and commit as usual. + +```bash +# Later: why was this commit made? +ctx trace abc123 + +# Recent commits with their context +ctx trace --last 10 + +# Context trail for a specific file +ctx trace file src/auth.go + +# Manually tag a commit after the fact +ctx trace tag HEAD --note "Hotfix for production outage" +``` + +To stop: `ctx trace hook disable`. + +See [CLI Reference: trace](../cli/trace.md) for details. + ## Agent Session Start The first thing an AI agent should do at session start is discover where @@ -331,23 +363,25 @@ These are infrastructure: used in scripts, CI, or one-time setup. | `ctx task complete` | Mark a task done by substring match | | `ctx sync` | Reconcile context with codebase state | | `ctx compact` | Consolidate and clean up context files | -| `ctx setup` | Generate AI tool integration config | +| `ctx trace` | Show context behind git commits | +| `ctx trace hook` | Enable/disable commit context tracing hook | +| `ctx setup` | Generate AI tool integration config | | `ctx watch` | Watch AI output and auto-apply context updates | | `ctx serve` | Serve any zensical directory (default: journal) | | `ctx permission snapshot` | Save settings as a golden image | | `ctx permission restore` | Restore settings from golden image | -| `ctx journal site` | Generate browsable journal from exports | -| `ctx notify setup` | Configure webhook notifications | +| `ctx journal site` | Generate browsable journal from exports | +| `ctx notify setup` | Configure webhook notifications | | `ctx decision` | List and filter decisions | | `ctx learning` | List and filter learnings | | `ctx task` | List tasks, manage archival and snapshots | -| `ctx why` | Read the philosophy behind ctx | -| `ctx guide` | Quick-reference cheat sheet | -| `ctx site` | Site management commands | -| `ctx config` | Manage runtime configuration profiles | -| `ctx system` | System diagnostics and hook commands | -| `ctx system backup` | Back up context and Claude data to tar.gz / SMB | -| `ctx completion` | Generate shell autocompletion scripts | +| `ctx why` | Read the philosophy behind ctx | +| `ctx guide` | Quick-reference cheat sheet | +| `ctx site` | Site management commands | +| `ctx config` | Manage runtime configuration profiles | +| `ctx system` | System diagnostics and hook commands | +| `ctx system backup` | Back up context and Claude data to tar.gz / SMB | +| `ctx completion` | Generate shell autocompletion scripts | !!! tip "Rule of Thumb" **Quick check?** Use the CLI. diff --git a/go.mod b/go.mod index 9a8c84fcc..a3e155b01 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/ActiveMemory/ctx -go 1.25.6 +go 1.26.1 require ( github.com/spf13/cobra v1.10.2 diff --git a/internal/assets/commands/commands.yaml b/internal/assets/commands/commands.yaml index a2a6ba2e5..287231ce8 100644 --- a/internal/assets/commands/commands.yaml +++ b/internal/assets/commands/commands.yaml @@ -1300,6 +1300,35 @@ task.snapshot: Unlike archive, snapshot copies the entire file as-is. short: Create point-in-time snapshot of TASKS.md +trace: + long: |- + Show the context behind git commits. + + ctx trace links commits back to the decisions, tasks, learnings, + and sessions that motivated them. + + Usage: + ctx trace Show context for a specific commit + ctx trace --last 5 Show context for last N commits + ctx trace file Show context trail for a file + ctx trace tag Manually tag a commit with context + ctx trace collect Collect context refs (used by hook) + ctx trace hook enable Install prepare-commit-msg hook + + Examples: + ctx trace abc123 + ctx trace --last 10 + ctx trace file src/auth.go + ctx trace tag HEAD --note "Hotfix for production outage" + short: Show context behind git commits +trace.file: + short: Show context trail for a file +trace.tag: + short: Manually tag a commit with context +trace.collect: + short: Collect context refs for hook +trace.hook: + short: Manage prepare-commit-msg hook watch: long: |- Watch stdin or a log file for diff --git a/internal/assets/commands/flags.yaml b/internal/assets/commands/flags.yaml index 62633dae7..38d41fb71 100644 --- a/internal/assets/commands/flags.yaml +++ b/internal/assets/commands/flags.yaml @@ -222,6 +222,16 @@ system.stats.last: short: Show last N entries system.stats.session: short: Filter by session ID (prefix match) +trace.collect.record: + short: Record context refs for a post-commit hash +trace.file.last: + short: Maximum commits to show +trace.json: + short: Output as JSON +trace.last: + short: Show context for last N commits +trace.tag.note: + short: Context note to attach to the commit task.archive.dry-run: short: Preview changes without modifying files watch.dry-run: diff --git a/internal/assets/commands/text/errors.yaml b/internal/assets/commands/text/errors.yaml index 8b55ea78a..9bc4c7c8b 100644 --- a/internal/assets/commands/text/errors.yaml +++ b/internal/assets/commands/text/errors.yaml @@ -198,6 +198,8 @@ err.fs.stat-path: short: 'stat %s: %w' err.fs.stdin-read: short: 'failed to read from stdin: %w' +err.fs.write-buffer: + short: 'buffer write: %v' err.fs.write-file-failed: short: 'write file: %w' err.fs.write-merged: @@ -436,6 +438,24 @@ err.state.reading-state-dir: short: 'reading state directory: %w' err.state.save-state: short: 'saving state: %w' +err.trace.git-dir: + short: 'git rev-parse --git-dir: %w' +err.trace.git-log: + short: 'git log: %w' +err.trace.hook-exists: + short: '%s hook already exists at %s (not installed by ctx); remove it manually first' +err.trace.hook-write: + short: 'write %s hook: %w' +err.trace.note-required: + short: --note is required +err.trace.resolve-commit: + short: 'resolve commit %q: %w' +err.trace.unknown-action: + short: 'unknown action %q: use enable or disable' +err.trace.write-history: + short: 'write history: %w' +err.trace.write-override: + short: 'write override: %w' err.task.no-completed-tasks: short: no completed tasks to archive err.task.no-task-match: diff --git a/internal/assets/commands/text/headings.yaml b/internal/assets/commands/text/headings.yaml index 1eaecb54e..896b626c5 100644 --- a/internal/assets/commands/text/headings.yaml +++ b/internal/assets/commands/text/headings.yaml @@ -158,6 +158,8 @@ label.inline-error: short: 'Error:' label.bold-reminder: short: '**System Reminder**:' +label.bold-reminder-fmt: + short: '**System Reminder**: %s' label.tool-output: short: Tool Output label.suggestion-mode: diff --git a/internal/assets/commands/text/write.yaml b/internal/assets/commands/text/write.yaml index 4420a3cd4..981c050c0 100644 --- a/internal/assets/commands/text/write.yaml +++ b/internal/assets/commands/text/write.yaml @@ -599,6 +599,40 @@ write.sync-summary: short: Found %d items. Review and update context files manually. write.synced: short: Synced %s -> %s +write.trace-hooks-enabled: + short: ctx trace hooks enabled (prepare-commit-msg, post-commit) +write.trace-hooks-disabled: + short: ctx trace hooks disabled +write.trace-detail-date: + short: 'Date: %s' +write.trace-detail-status: + short: 'Status: %s' +write.trace-commit-header: + short: 'Commit: %s' +write.trace-commit-message: + short: 'Message: %s' +write.trace-commit-date: + short: 'Date: %s' +write.trace-commit-context: + short: 'Context:' +write.trace-commit-no-context: + short: 'Context: (none)' +write.trace-last-entry: + short: '%s %s [%s]' +write.trace-resolved-full: + short: ' [%s] %s — %s (%s)' +write.trace-resolved-title: + short: ' [%s] %s — %s' +write.trace-resolved-raw: + short: ' [%s] %s' +write.trace-file-entry: + short: '%s %s %s [%s]' +write.trace-no-refs: + short: (none) +write.trace-refs-prefix: + short: '→ ' +write.trace-tagged: + short: 'Tagged %s with: %s' write.test-filtered: short: |- Note: event "test" is filtered by your .ctxrc notify.events config. diff --git a/internal/assets/context/AGENT_PLAYBOOK.md b/internal/assets/context/AGENT_PLAYBOOK.md index 7b95c3de6..65ac05854 100644 --- a/internal/assets/context/AGENT_PLAYBOOK.md +++ b/internal/assets/context/AGENT_PLAYBOOK.md @@ -22,6 +22,7 @@ Always use `ctx` from PATH: ctx status # ✓ correct ctx agent # ✓ correct ./dist/ctx # ✗ avoid hardcoded paths +go run ./cmd/ctx # ✗ avoid unless developing ctx itself ``` Check with `which ctx` if unsure whether it's installed. @@ -32,6 +33,17 @@ Before starting any work, read the required context files and confirm to the user: "I have read the required context files and I'm following project conventions." Do not begin implementation until you have done so. +## Supplementary Files + +These files live in `.context/` alongside the core context files. +Read them when the task at hand warrants it — not on every session. + +| File | Read when | +|--------------------|----------------------------------------------------------------| +| ARCHITECTURE.md | Working on structure, adding packages, or tracing flow | +| DETAILED_DESIGN.md | Deep-diving into internals (generated via `/ctx-architecture`) | +| GLOSSARY.md | Encountering unfamiliar project-specific terminology | + ## Reason Before Acting Before implementing any non-trivial change, think through it step-by-step: @@ -45,6 +57,21 @@ This applies to debugging too: reason through the cause before reaching for a fix. Rushing to code before reasoning is the most common source of wasted work. +### Chunk and Checkpoint Large Tasks + +For work spanning many files or steps, break it into independently +verifiable chunks. After each chunk: + +1. **Commit**: save progress to git so nothing is lost +2. **Persist**: record learnings or decisions discovered during the chunk +3. **Verify**: run tests or `make lint` before moving on + +Track progress via TASKS.md checkboxes. If context runs low mid-task, +persist a progress note (what's done, what's next, what assumptions +remain) before continuing in a new window. The `check-context-size` +hook nudges at 60% usage (checkpoint) and warns at 90% (urgent): +treat these as signals to persist progress, not to rush. + ## Session Lifecycle A session follows this arc: @@ -54,6 +81,7 @@ A session follows this arc: Not every session uses every step: a quick bugfix skips reflection, a research session skips committing: but the full flow is: + | Step | What Happens | Skill / Command | |-------------|----------------------------------------------------|------------------| | **Load** | Recall context, present structured readback | `/ctx-remember` | @@ -77,10 +105,28 @@ Surface problems worth mentioning: One sentence is enough: don't turn startup into a maintenance session. +### Context Window Limits + +The `check-context-size` hook (`ctx system check-context-size`) monitors +context window usage. It nudges at 60% (one-shot checkpoint) and warns +at 90% (recurring urgent). When you see either signal or sense context +is running long: + +- **Persist progress**: write what's done and what's left to TASKS.md + or a progress note +- **Checkpoint state**: commit work-in-progress so a fresh session can + pick up cleanly +- **Summarize**: leave a breadcrumb for the next window: the current + task, open questions, and next step + +Context compaction happens automatically, but the next window loses +nuance. Explicit persistence is cheaper than re-discovery. + ### Conversational Triggers Users rarely invoke skills explicitly. Recognize natural language: + | User Says | Action | |-------------------------------------------------|--------------------------------------------------------| | "Do you remember?" / "What were we working on?" | `/ctx-remember` | @@ -93,6 +139,8 @@ Users rarely invoke skills explicitly. Recognize natural language: | "That's worth remembering" / "Any gotchas?" | `/ctx-add-learning` | | "Record that convention" | `/ctx-add-convention` | | "Add a task for that" | `/ctx-add-task` | +| "Sync memory" / "What's in auto memory?" | `ctx memory sync` / `ctx memory status` | +| "Import from memory" | `ctx memory import --dry-run` then `ctx memory import` | | "Let's wrap up" | Reflect → persist outstanding items → present together | ## Proactive Persistence @@ -141,9 +189,11 @@ user. These apply unless the user overrides them for the session - **At design decisions**: always present 2+ approaches with trade-offs before committing: don't silently pick one -- **At completion claims**: run self-audit questions (What did I - assume? What didn't I check? Where am I least confident? What - would a reviewer question?) before reporting done +- **At completion claims**: map claims to evidence (e.g., "tests + pass" requires 0-failure output, "build succeeds" requires exit 0). + Run commands fresh: never reuse earlier output. At minimum, answer: + What did I assume? What didn't I check? Where am I least confident? + What would a reviewer question? - **At ambiguous moments**: ask the user rather than inferring intent: a quick question is cheaper than rework - **When producing artifacts**: flag assumptions and uncertainty diff --git a/internal/assets/embed.go b/internal/assets/embed.go index 93104d84e..9616284ac 100644 --- a/internal/assets/embed.go +++ b/internal/assets/embed.go @@ -12,10 +12,13 @@ import ( //go:embed claude/.claude-plugin/plugin.json claude/CLAUDE.md //go:embed claude/skills/*/references/*.md claude/skills/*/SKILL.md -//go:embed context/*.md project/* entry-templates/*.md integrations/agents.md integrations/copilot/*.md -//go:embed integrations/copilot-cli/*.json integrations/copilot-cli/*.md integrations/copilot-cli/scripts/*.sh integrations/copilot-cli/scripts/*.ps1 +//go:embed context/*.md project/* entry-templates/*.md integrations/agents.md +//go:embed integrations/copilot/*.md +//go:embed integrations/copilot-cli/*.json integrations/copilot-cli/*.md +//go:embed integrations/copilot-cli/scripts/*.sh +//go:embed integrations/copilot-cli/scripts/*.ps1 //go:embed integrations/copilot-cli/skills/*/SKILL.md -//go:embed hooks/messages/*/*.txt hooks/messages/registry.yaml +//go:embed hooks/messages/*/*.txt hooks/messages/registry.yaml hooks/trace/*.sh //go:embed schema/*.json why/*.md //go:embed permissions/*.txt commands/*.yaml commands/text/*.yaml journal/*.css var FS embed.FS diff --git a/internal/assets/hooks/trace/post-commit.sh b/internal/assets/hooks/trace/post-commit.sh new file mode 100644 index 000000000..0d9fa2043 --- /dev/null +++ b/internal/assets/hooks/trace/post-commit.sh @@ -0,0 +1,8 @@ +#!/bin/sh +# ctx: post-commit hook for recording commit context history. +# Requires: ctx on $PATH +# Installed by: ctx trace hook enable +# Remove with: ctx trace hook disable + +COMMIT_HASH=$(git rev-parse HEAD) +ctx trace collect --record "$COMMIT_HASH" 2>/dev/null || true diff --git a/internal/assets/hooks/trace/prepare-commit-msg.sh b/internal/assets/hooks/trace/prepare-commit-msg.sh new file mode 100644 index 000000000..530e5b88e --- /dev/null +++ b/internal/assets/hooks/trace/prepare-commit-msg.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# ctx: prepare-commit-msg hook for commit context tracing. +# Installed by: ctx trace hook enable +# Remove with: ctx trace hook disable +# Requires: ctx on $PATH + +COMMIT_MSG_FILE="$1" +COMMIT_SOURCE="$2" + +# Only inject on normal commits (not merges, squashes, or amends) +case "$COMMIT_SOURCE" in + merge|squash) exit 0 ;; +esac + +# Collect context refs (requires ctx on $PATH) +TRAILER=$(ctx trace collect 2>/dev/null) + +if [ -n "$TRAILER" ]; then + # Append trailer with a blank line separator + echo "" >> "$COMMIT_MSG_FILE" + echo "$TRAILER" >> "$COMMIT_MSG_FILE" +fi diff --git a/internal/assets/read/hook/hook.go b/internal/assets/read/hook/hook.go index b867c96cb..ddd4d8552 100644 --- a/internal/assets/read/hook/hook.go +++ b/internal/assets/read/hook/hook.go @@ -13,6 +13,22 @@ import ( "github.com/ActiveMemory/ctx/internal/config/asset" ) +// TraceScript reads an embedded trace git hook script by filename. +// +// Parameters: +// - filename: Script filename (e.g., "prepare-commit-msg.sh") +// +// Returns: +// - string: Script content +// - error: Non-nil if the file is not found or read fails +func TraceScript(filename string) (string, error) { + data, err := assets.FS.ReadFile(path.Join(asset.DirHooksTrace, filename)) + if err != nil { + return "", err + } + return string(data), nil +} + // Message reads a hook message template by hook name and filename. // // Parameters: diff --git a/internal/bootstrap/group.go b/internal/bootstrap/group.go index f1f78eba3..88a91d6a6 100644 --- a/internal/bootstrap/group.go +++ b/internal/bootstrap/group.go @@ -38,6 +38,7 @@ import ( "github.com/ActiveMemory/ctx/internal/cli/sync" "github.com/ActiveMemory/ctx/internal/cli/system" "github.com/ActiveMemory/ctx/internal/cli/task" + "github.com/ActiveMemory/ctx/internal/cli/trace" "github.com/ActiveMemory/ctx/internal/cli/watch" "github.com/ActiveMemory/ctx/internal/cli/why" embedCmd "github.com/ActiveMemory/ctx/internal/config/embed/cmd" @@ -126,13 +127,14 @@ func integrations() []registration { // diagnostics returns command registrations for the diagnostics group. // // Returns: -// - []registration: Doctor, change, dep, and why commands +// - []registration: Doctor, change, dep, why, and trace commands func diagnostics() []registration { return []registration{ {doctor.Cmd, embedCmd.GroupDiagnostics}, {change.Cmd, embedCmd.GroupDiagnostics}, {dep.Cmd, embedCmd.GroupDiagnostics}, {why.Cmd, embedCmd.GroupDiagnostics}, + {trace.Cmd, embedCmd.GroupDiagnostics}, } } diff --git a/internal/cli/add/cmd/root/run.go b/internal/cli/add/cmd/root/run.go index cb6ae8b6a..1fb6f0383 100644 --- a/internal/cli/add/cmd/root/run.go +++ b/internal/cli/add/cmd/root/run.go @@ -14,10 +14,12 @@ import ( coreEntry "github.com/ActiveMemory/ctx/internal/cli/add/core/entry" "github.com/ActiveMemory/ctx/internal/cli/add/core/example" "github.com/ActiveMemory/ctx/internal/cli/add/core/extract" + "github.com/ActiveMemory/ctx/internal/cli/system/core/state" cfgEntry "github.com/ActiveMemory/ctx/internal/config/entry" "github.com/ActiveMemory/ctx/internal/entity" "github.com/ActiveMemory/ctx/internal/entry" errAdd "github.com/ActiveMemory/ctx/internal/err/add" + "github.com/ActiveMemory/ctx/internal/trace" writeAdd "github.com/ActiveMemory/ctx/internal/write/add" ) @@ -75,5 +77,13 @@ func Run(cmd *cobra.Command, args []string, flags entity.AddConfig) error { writeAdd.SpecNudge(cmd) } + // Best-effort: record pending context for commit tracing. + // Decisions and learnings are prepended (see insert.AppendEntry), + // so the new entry is always #1 in file order. This coupling is + // intentional: if the prepend logic changes, this must be updated. + if fType == cfgEntry.Decision || fType == cfgEntry.Learning { + _ = trace.Record(fType+":1", state.Dir()) + } + return nil } diff --git a/internal/cli/agent/core/budget/render.go b/internal/cli/agent/core/budget/render.go index ad927e587..e19bff034 100644 --- a/internal/cli/agent/core/budget/render.go +++ b/internal/cli/agent/core/budget/render.go @@ -7,13 +7,13 @@ package budget import ( - "fmt" "strings" "time" "github.com/ActiveMemory/ctx/internal/assets/read/desc" "github.com/ActiveMemory/ctx/internal/config/embed/text" "github.com/ActiveMemory/ctx/internal/config/token" + "github.com/ActiveMemory/ctx/internal/io" ) // RenderMarkdownPacket renders an assembled packet as Markdown. @@ -28,21 +28,17 @@ func RenderMarkdownPacket(pkt *AssembledPacket) string { nl := token.NewlineLF sb.WriteString(desc.Text(text.DescKeyAgentPacketTitle) + nl) - sb.WriteString( - fmt.Sprintf( - desc.Text(text.DescKeyAgentPacketMeta), - time.Now().UTC().Format(time.RFC3339), pkt.Budget, pkt.TokensUsed, - ) + nl + nl, - ) + io.SafeFprintf(&sb, + desc.Text(text.DescKeyAgentPacketMeta), + time.Now().UTC().Format(time.RFC3339), pkt.Budget, pkt.TokensUsed) + sb.WriteString(nl + nl) // Read order sb.WriteString(desc.Text(text.DescKeyAgentSectionReadOrder) + nl) for i, path := range pkt.ReadOrder { - sb.WriteString( - fmt.Sprintf( - desc.Text(text.DescKeyWriteAgentNumberedItem), i+1, path, - ) + nl, - ) + io.SafeFprintf(&sb, + desc.Text(text.DescKeyWriteAgentNumberedItem), i+1, path) + sb.WriteString(nl) } sb.WriteString(nl) @@ -50,9 +46,9 @@ func RenderMarkdownPacket(pkt *AssembledPacket) string { if len(pkt.Constitution) > 0 { sb.WriteString(desc.Text(text.DescKeyAgentSectionConstitution) + nl) for _, rule := range pkt.Constitution { - sb.WriteString( - fmt.Sprintf(desc.Text(text.DescKeyWriteAgentBulletItem), rule) + nl, - ) + io.SafeFprintf(&sb, + desc.Text(text.DescKeyWriteAgentBulletItem), rule) + sb.WriteString(nl) } sb.WriteString(nl) } @@ -70,9 +66,9 @@ func RenderMarkdownPacket(pkt *AssembledPacket) string { if len(pkt.Conventions) > 0 { sb.WriteString(desc.Text(text.DescKeyAgentSectionConventions) + nl) for _, conv := range pkt.Conventions { - sb.WriteString( - fmt.Sprintf(desc.Text(text.DescKeyWriteAgentBulletItem), conv) + nl, - ) + io.SafeFprintf(&sb, + desc.Text(text.DescKeyWriteAgentBulletItem), conv) + sb.WriteString(nl) } sb.WriteString(nl) } @@ -97,9 +93,9 @@ func RenderMarkdownPacket(pkt *AssembledPacket) string { if len(pkt.Summaries) > 0 { sb.WriteString(desc.Text(text.DescKeyAgentSectionSummaries) + nl) for _, s := range pkt.Summaries { - sb.WriteString( - fmt.Sprintf(desc.Text(text.DescKeyWriteAgentBulletItem), s) + nl, - ) + io.SafeFprintf(&sb, + desc.Text(text.DescKeyWriteAgentBulletItem), s) + sb.WriteString(nl) } sb.WriteString(nl) } diff --git a/internal/cli/change/core/render/format.go b/internal/cli/change/core/render/format.go index 0883ee353..a6e00df57 100644 --- a/internal/cli/change/core/render/format.go +++ b/internal/cli/change/core/render/format.go @@ -15,6 +15,7 @@ import ( cfgTime "github.com/ActiveMemory/ctx/internal/config/time" "github.com/ActiveMemory/ctx/internal/config/token" "github.com/ActiveMemory/ctx/internal/entity" + "github.com/ActiveMemory/ctx/internal/io" ) // List renders the full CLI output for `ctx changes`. @@ -35,10 +36,8 @@ func List( desc.Text(text.DescKeyChangesHeading) + token.NewlineLF + token.NewlineLF, ) - b.WriteString(fmt.Sprintf( - desc.Text(text.DescKeyChangesRefPoint)+ - token.NewlineLF+token.NewlineLF, refLabel, - ), + io.SafeFprintf(&b, desc.Text(text.DescKeyChangesRefPoint)+ + token.NewlineLF+token.NewlineLF, refLabel, ) if len(ctxChanges) > 0 { @@ -46,9 +45,9 @@ func List( desc.Text(text.DescKeyChangesCtxHeading) + token.NewlineLF, ) for _, c := range ctxChanges { - b.WriteString(fmt.Sprintf( + io.SafeFprintf(&b, desc.Text(text.DescKeyChangesCtxLine)+token.NewlineLF, - c.Name, c.ModTime.Format(cfgTime.DateTimeFmt))) + c.Name, c.ModTime.Format(cfgTime.DateTimeFmt)) } b.WriteString(token.NewlineLF) } @@ -57,25 +56,24 @@ func List( b.WriteString( desc.Text(text.DescKeyChangesCodeHeading) + token.NewlineLF, ) - b.WriteString(fmt.Sprintf( + io.SafeFprintf(&b, desc.Text(text.DescKeyChangesCodeCommits)+token.NewlineLF, - commitCount(code.CommitCount))) + commitCount(code.CommitCount)) if code.LatestMsg != "" { - b.WriteString(fmt.Sprintf( + io.SafeFprintf(&b, desc.Text( text.DescKeyChangesCodeLatest)+token.NewlineLF, code.LatestMsg, - ), ) } if len(code.Dirs) > 0 { - b.WriteString(fmt.Sprintf( + io.SafeFprintf(&b, desc.Text(text.DescKeyChangesCodeDirs)+token.NewlineLF, - strings.Join(code.Dirs, token.CommaSpace))) + strings.Join(code.Dirs, token.CommaSpace)) } if len(code.Authors) > 0 { - b.WriteString(fmt.Sprintf( + io.SafeFprintf(&b, desc.Text(text.DescKeyChangesCodeAuthors)+token.NewlineLF, - strings.Join(code.Authors, token.CommaSpace))) + strings.Join(code.Authors, token.CommaSpace)) } b.WriteString(token.NewlineLF) } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index f06250d40..145d4dbc0 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -28,11 +28,18 @@ func TestBinaryIntegration(t *testing.T) { t.Skip("skipping integration test in short mode") } - tmpDir, err := os.MkdirTemp("", "cli-binary-test-*") + rawTmpDir, err := os.MkdirTemp("", "cli-binary-test-*") if err != nil { t.Fatalf("failed to create temp dir: %v", err) } - defer func() { _ = os.RemoveAll(tmpDir) }() + defer func() { _ = os.RemoveAll(rawTmpDir) }() + + // Resolve symlinks so the boundary check (which uses EvalSymlinks) + // sees the same path as the test. On macOS /var → /private/var. + tmpDir, err := filepath.EvalSymlinks(rawTmpDir) + if err != nil { + t.Fatalf("failed to resolve symlinks: %v", err) + } // Build the binary binaryPath := filepath.Join(tmpDir, "ctx-test-binary") diff --git a/internal/cli/dep/core/format.go b/internal/cli/dep/core/format.go index 3e9d05fe9..f0b90bc85 100644 --- a/internal/cli/dep/core/format.go +++ b/internal/cli/dep/core/format.go @@ -14,6 +14,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/dep" "github.com/ActiveMemory/ctx/internal/config/token" + "github.com/ActiveMemory/ctx/internal/io" ) // MermaidID converts a package path to a valid Mermaid node ID. @@ -48,10 +49,7 @@ func RenderMermaid(graph map[string][]string) string { src := MermaidID(pkg) for _, d := range deps { dst := MermaidID(d) - _, err := fmt.Fprintf(&b, edgeFmt, src, pkg, dst, d) - if err != nil { - return "" - } + io.SafeFprintf(&b, edgeFmt, src, pkg, dst, d) } } @@ -68,15 +66,15 @@ func RenderMermaid(graph map[string][]string) string { func RenderTable(graph map[string][]string) string { tf := fmt.Sprintf(dep.TableRowFormat, dep.TableColPackage) var b strings.Builder - b.WriteString(fmt.Sprintf(tf, dep.TableHeaderPackage, dep.TableHeaderImports)) - b.WriteString(fmt.Sprintf(tf, + io.SafeFprintf(&b, tf, dep.TableHeaderPackage, dep.TableHeaderImports) + io.SafeFprintf(&b, tf, strings.Repeat("-", dep.TableColPackage), - strings.Repeat("-", dep.TableColImports))) + strings.Repeat("-", dep.TableColImports)) keys := SortedKeys(graph) for _, pkg := range keys { deps := graph[pkg] - b.WriteString(fmt.Sprintf(tf, pkg, strings.Join(deps, token.CommaSpace))) + io.SafeFprintf(&b, tf, pkg, strings.Join(deps, token.CommaSpace)) } return b.String() diff --git a/internal/cli/drift/drift_test.go b/internal/cli/drift/drift_test.go index 43d0c9b06..f1b1abc9b 100644 --- a/internal/cli/drift/drift_test.go +++ b/internal/cli/drift/drift_test.go @@ -8,7 +8,6 @@ package drift import ( "bytes" - "fmt" "os" "path/filepath" "strings" @@ -17,6 +16,7 @@ import ( "github.com/ActiveMemory/ctx/internal/cli/initialize" "github.com/ActiveMemory/ctx/internal/config/ctx" "github.com/ActiveMemory/ctx/internal/config/dir" + "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/rc" ) @@ -195,7 +195,7 @@ func TestRunDrift_FixWithStaleness(t *testing.T) { sb.WriteString("# Tasks\n\n## In Progress\n\n" + "- [ ] Active task\n\n## Completed\n\n") for i := 0; i < 10; i++ { - sb.WriteString(fmt.Sprintf("- [x] Completed task %d\n", i)) + io.SafeFprintf(&sb, "- [x] Completed task %d\n", i) } if err := os.WriteFile(tasksPath, []byte(sb.String()), 0600); err != nil { t.Fatalf("failed to write TASKS.md: %v", err) diff --git a/internal/cli/initialize/cmd/root/cmd.go b/internal/cli/initialize/cmd/root/cmd.go index 81a190641..3ae9c8b59 100644 --- a/internal/cli/initialize/cmd/root/cmd.go +++ b/internal/cli/initialize/cmd/root/cmd.go @@ -14,6 +14,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/embed/cmd" "github.com/ActiveMemory/ctx/internal/config/embed/flag" cFlag "github.com/ActiveMemory/ctx/internal/config/flag" + "github.com/ActiveMemory/ctx/internal/flagbind" ) // Cmd returns the "ctx init" command for initializing a .context/ directory. @@ -52,28 +53,11 @@ func Cmd() *cobra.Command { }, } - c.Flags().BoolVarP( - &force, - cFlag.Force, cFlag.ShortForce, false, - desc.Flag(flag.DescKeyInitializeForce), - ) - c.Flags().BoolVarP( - &minimal, - cFlag.Minimal, cFlag.ShortMinimal, false, - desc.Flag(flag.DescKeyInitializeMinimal), - ) - c.Flags().BoolVar( - &merge, cFlag.Merge, false, - desc.Flag(flag.DescKeyInitializeMerge), - ) - c.Flags().BoolVar( - &noPluginEnable, cFlag.NoPluginEnable, false, - desc.Flag(flag.DescKeyInitializeNoPluginEnable), - ) - c.Flags().StringVar( - &caller, cFlag.Caller, "", - desc.Flag(flag.DescKeyInitializeCaller), - ) + flagbind.BoolFlagP(c, &force, cFlag.Force, cFlag.ShortForce, flag.DescKeyInitializeForce) + flagbind.BoolFlagP(c, &minimal, cFlag.Minimal, cFlag.ShortMinimal, flag.DescKeyInitializeMinimal) + flagbind.BoolFlag(c, &merge, cFlag.Merge, flag.DescKeyInitializeMerge) + flagbind.BoolFlag(c, &noPluginEnable, cFlag.NoPluginEnable, flag.DescKeyInitializeNoPluginEnable) + flagbind.StringFlag(c, &caller, cFlag.Caller, flag.DescKeyInitializeCaller) return c } diff --git a/internal/cli/initialize/cmd/root/run.go b/internal/cli/initialize/cmd/root/run.go index e5da8cb4f..2e22bdaa3 100644 --- a/internal/cli/initialize/cmd/root/run.go +++ b/internal/cli/initialize/cmd/root/run.go @@ -20,6 +20,7 @@ import ( coreClaude "github.com/ActiveMemory/ctx/internal/cli/initialize/core/claude" "github.com/ActiveMemory/ctx/internal/cli/initialize/core/entry" coreMerge "github.com/ActiveMemory/ctx/internal/cli/initialize/core/merge" + corePad "github.com/ActiveMemory/ctx/internal/cli/initialize/core/pad" "github.com/ActiveMemory/ctx/internal/cli/initialize/core/plugin" coreProject "github.com/ActiveMemory/ctx/internal/cli/initialize/core/project" "github.com/ActiveMemory/ctx/internal/cli/initialize/core/validate" @@ -29,12 +30,8 @@ import ( "github.com/ActiveMemory/ctx/internal/config/embed/text" "github.com/ActiveMemory/ctx/internal/config/file" "github.com/ActiveMemory/ctx/internal/config/fs" - "github.com/ActiveMemory/ctx/internal/config/pad" - "github.com/ActiveMemory/ctx/internal/config/project" "github.com/ActiveMemory/ctx/internal/config/sync" "github.com/ActiveMemory/ctx/internal/config/token" - "github.com/ActiveMemory/ctx/internal/crypto" - errCrypto "github.com/ActiveMemory/ctx/internal/err/crypto" errFs "github.com/ActiveMemory/ctx/internal/err/fs" errPrompt "github.com/ActiveMemory/ctx/internal/err/prompt" "github.com/ActiveMemory/ctx/internal/rc" @@ -73,7 +70,7 @@ func Run( // A directory with only logs/ (created by hooks before init) is // treated as uninitialized - no overwrite prompt needed. if _, statErr := os.Stat(contextDir); statErr == nil { - if !force && hasEssentialFiles(contextDir) { + if !force && validate.EssentialFilesPresent(contextDir) { // When called from an editor (--caller), stdin is unavailable. // Skip the interactive prompt to prevent hanging. if caller != "" { @@ -146,7 +143,7 @@ func Run( } // Set up scratchpad - if padErr := initScratchpad(cmd, contextDir); padErr != nil { + if padErr := corePad.Setup(cmd, contextDir); padErr != nil { // Non-fatal: warn but continue label := desc.Text(text.DescKeyInitLabelScratchpad) initialize.InfoWarnNonFatal(cmd, label, padErr) @@ -157,6 +154,7 @@ func Run( // Create specs/ and ideas/ directories with README.md if dirsErr := coreProject.CreateDirs(cmd); dirsErr != nil { + // Non-fatal: warn but continue label := desc.Text(text.DescKeyInitLabelProjectDirs) initialize.InfoWarnNonFatal(cmd, label, dirsErr) } @@ -191,7 +189,8 @@ func Run( } // Update .gitignore with recommended entries - if ignoreErr := ensureGitignoreEntries(cmd); ignoreErr != nil { + if ignoreErr := coreProject.EnsureGitignoreEntries(cmd); ignoreErr != nil { + // Non-fatal: warn but continue initialize.InfoWarnNonFatal(cmd, file.FileGitignore, ignoreErr) } @@ -199,167 +198,7 @@ func Run( initialize.InfoWorkflowTips(cmd) // Save the quick-start reference to a project-root file. - writeGettingStarted(cmd) - - return nil -} - -// initScratchpad sets up the scratchpad key or plaintext file. -// -// When encryption is enabled (default): -// - Generates a 256-bit key at ~/.ctx/ if not present -// - Adds legacy key path to .gitignore for migration safety -// - Warns if .enc exists but no key -// -// When encryption is disabled: -// - Creates empty .context/scratchpad.md if not present -// -// Parameters: -// - cmd: Cobra command for output -// - contextDir: The .context/ directory path -// -// Returns: -// - error: Non-nil if key generation or file operations fail -func initScratchpad(cmd *cobra.Command, contextDir string) error { - if !rc.ScratchpadEncrypt() { - // Plaintext mode: create empty scratchpad.md if not present - mdPath := filepath.Join(contextDir, pad.Md) - if _, statErr := os.Stat(mdPath); statErr != nil { - if writeErr := os.WriteFile(mdPath, nil, fs.PermFile); writeErr != nil { - return errFs.Mkdir(mdPath, writeErr) - } - initialize.InfoScratchpadPlaintext(cmd, mdPath) - } else { - initialize.InfoExistsSkipped(cmd, mdPath) - } - return nil - } - - // Encrypted mode - kPath := rc.KeyPath() - encPath := filepath.Join(contextDir, pad.Enc) - - // Check if the key already exists (idempotent) - if _, keyStatErr := os.Stat(kPath); keyStatErr == nil { - initialize.InfoExistsSkipped(cmd, kPath) - return nil - } - - // Warn if the encrypted file exists but no key - if _, encStatErr := os.Stat(encPath); encStatErr == nil { - initialize.InfoScratchpadNoKey(cmd, kPath) - return nil - } - - // Ensure the key directory exists. - if mkdirErr := os.MkdirAll( - filepath.Dir(kPath), fs.PermKeyDir, - ); mkdirErr != nil { - return errCrypto.MkdirKeyDir(mkdirErr) - } - - // Generate key - key, genErr := crypto.GenerateKey() - if genErr != nil { - return errCrypto.GenerateKey(genErr) - } - - if saveErr := crypto.SaveKey(kPath, key); saveErr != nil { - return errCrypto.SaveKey(saveErr) - } - initialize.InfoScratchpadKeyCreated(cmd, kPath) - - return nil -} - -// hasEssentialFiles reports whether contextDir contains at least one of the -// essential context files (TASKS.md, CONSTITUTION.md, DECISIONS.md). A -// directory with only logs/ or other non-essential content is considered -// uninitialized. -// -// Parameters: -// - contextDir: Absolute path to the context directory to inspect -// -// Returns: -// - bool: True if at least one essential file exists -func hasEssentialFiles(contextDir string) bool { - for _, f := range ctx.FilesRequired { - if _, statErr := os.Stat(filepath.Join(contextDir, f)); statErr == nil { - return true - } - } - return false -} - -// ensureGitignoreEntries appends recommended .gitignore entries that are not -// already present. Creates .gitignore if it does not exist. -// -// Parameters: -// - cmd: Cobra command for status output -// -// Returns: -// - error: Non-nil on read or write failure -func ensureGitignoreEntries(cmd *cobra.Command) error { - content, readErr := os.ReadFile(file.FileGitignore) - if readErr != nil && !os.IsNotExist(readErr) { - return readErr - } - - // Build set of existing trimmed lines. - existing := make(map[string]bool) - for _, line := range strings.Split(string(content), token.NewlineLF) { - existing[strings.TrimSpace(line)] = true - } + coreProject.WriteGettingStarted(cmd) - // Collect missing entries. - var missing []string - for _, e := range file.Gitignore { - if !existing[e] { - missing = append(missing, e) - } - } - - if len(missing) == 0 { - return nil - } - - // Build block to append. - var sb strings.Builder - if len(content) > 0 && !strings.HasSuffix(string(content), token.NewlineLF) { - sb.WriteString(token.NewlineLF) - } - sb.WriteString(token.NewlineLF + file.GitignoreHeader + token.NewlineLF) - for _, e := range missing { - sb.WriteString(e + token.NewlineLF) - } - - if writeErr := os.WriteFile( - file.FileGitignore, append(content, []byte(sb.String())...), - fs.PermFile, - ); writeErr != nil { - return writeErr - } - - initialize.InfoGitignoreUpdated(cmd, len(missing)) - initialize.InfoGitignoreReview(cmd) return nil } - -// writeGettingStarted saves the next-steps and workflow-tips text to -// GETTING_STARTED.md in the project root. Best-effort: failures are -// non-fatal since the same content was already printed to stdout. -// -// Parameters: -// - cmd: Cobra command for status output -func writeGettingStarted(cmd *cobra.Command) { - content := desc.Text(text.DescKeyWriteInitNextStepsBlock) + - token.NewlineLF + - desc.Text(text.DescKeyWriteInitWorkflowTips) + - token.NewlineLF - if writeErr := os.WriteFile( - project.GettingStarted, []byte(content), fs.PermFile, - ); writeErr != nil { - return - } - initialize.InfoGettingStartedSaved(cmd, project.GettingStarted) -} diff --git a/internal/cli/initialize/core/merge/create.go b/internal/cli/initialize/core/merge/create.go index 8273b6dcb..23f976dfd 100644 --- a/internal/cli/initialize/core/merge/create.go +++ b/internal/cli/initialize/core/merge/create.go @@ -21,6 +21,7 @@ import ( "github.com/ActiveMemory/ctx/internal/entity" errFs "github.com/ActiveMemory/ctx/internal/err/fs" errPrompt "github.com/ActiveMemory/ctx/internal/err/prompt" + "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/write/initialize" ) @@ -94,7 +95,7 @@ func OrCreate(cmd *cobra.Command, p entity.MergeParams) (bool, error) { string(p.TemplateContent) + token.NewlineLF + existingStr[insertPos:] } - if writeErr := os.WriteFile( + if writeErr := io.SafeWriteFile( p.Filename, []byte(mergedContent), fs.PermFile, ); writeErr != nil { return false, errFs.WriteMerged(p.Filename, writeErr) @@ -150,7 +151,7 @@ func UpdateMarkedSection( return bkErr } - if writeErr := os.WriteFile( + if writeErr := io.SafeWriteFile( filename, []byte(newContent), fs.PermFile, ); writeErr != nil { return errFs.FileUpdate(filename, writeErr) diff --git a/internal/cli/initialize/core/pad/doc.go b/internal/cli/initialize/core/pad/doc.go new file mode 100644 index 000000000..b6dcc8ea5 --- /dev/null +++ b/internal/cli/initialize/core/pad/doc.go @@ -0,0 +1,11 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package pad provides scratchpad initialization logic for ctx init. +// +// Key exports: [Setup]. +// Used by cmd/root. +package pad diff --git a/internal/cli/initialize/core/pad/pad.go b/internal/cli/initialize/core/pad/pad.go new file mode 100644 index 000000000..42b84c04d --- /dev/null +++ b/internal/cli/initialize/core/pad/pad.go @@ -0,0 +1,93 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package pad + +import ( + "os" + "path/filepath" + + "github.com/spf13/cobra" + + cfgFs "github.com/ActiveMemory/ctx/internal/config/fs" + cfgPad "github.com/ActiveMemory/ctx/internal/config/pad" + "github.com/ActiveMemory/ctx/internal/crypto" + errCrypto "github.com/ActiveMemory/ctx/internal/err/crypto" + "github.com/ActiveMemory/ctx/internal/rc" + "github.com/ActiveMemory/ctx/internal/write/initialize" +) + +// Setup configures the scratchpad key or plaintext file. +// +// When encryption is enabled (default): +// - Generates a 256-bit key at ~/.ctx/ if not present +// - Warns if .enc exists but no key +// +// When encryption is disabled: +// - Creates empty .context/scratchpad.md if not present +// +// Parameters: +// - cmd: Cobra command for output +// - contextDir: The .context/ directory path +// +// Returns: +// - error: Non-nil if key generation or file operations fail +func Setup(cmd *cobra.Command, contextDir string) error { + if !rc.ScratchpadEncrypt() { + return setupPlaintext(cmd, contextDir) + } + return setupEncrypted(cmd, contextDir) +} + +func setupPlaintext(cmd *cobra.Command, contextDir string) error { + mdPath := filepath.Join(contextDir, cfgPad.Md) + if _, statErr := os.Stat(mdPath); statErr != nil { + if writeErr := os.WriteFile(mdPath, nil, cfgFs.PermFile); writeErr != nil { + return writeErr + } + initialize.InfoScratchpadPlaintext(cmd, mdPath) + } else { + initialize.InfoExistsSkipped(cmd, mdPath) + } + return nil +} + +func setupEncrypted(cmd *cobra.Command, contextDir string) error { + kPath := rc.KeyPath() + encPath := filepath.Join(contextDir, cfgPad.Enc) + + // Check if the key already exists (idempotent) + if _, keyStatErr := os.Stat(kPath); keyStatErr == nil { + initialize.InfoExistsSkipped(cmd, kPath) + return nil + } + + // Warn if the encrypted file exists but no key + if _, encStatErr := os.Stat(encPath); encStatErr == nil { + initialize.InfoScratchpadNoKey(cmd, kPath) + return nil + } + + // Ensure the key directory exists. + if mkdirErr := os.MkdirAll( + filepath.Dir(kPath), cfgFs.PermKeyDir, + ); mkdirErr != nil { + return errCrypto.MkdirKeyDir(mkdirErr) + } + + // Generate key + key, genErr := crypto.GenerateKey() + if genErr != nil { + return errCrypto.GenerateKey(genErr) + } + + if saveErr := crypto.SaveKey(kPath, key); saveErr != nil { + return errCrypto.SaveKey(saveErr) + } + initialize.InfoScratchpadKeyCreated(cmd, kPath) + + return nil +} diff --git a/internal/cli/initialize/core/project/getting_started.go b/internal/cli/initialize/core/project/getting_started.go new file mode 100644 index 000000000..da119e027 --- /dev/null +++ b/internal/cli/initialize/core/project/getting_started.go @@ -0,0 +1,39 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package project + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + "github.com/ActiveMemory/ctx/internal/config/embed/text" + "github.com/ActiveMemory/ctx/internal/config/fs" + cfgProject "github.com/ActiveMemory/ctx/internal/config/project" + "github.com/ActiveMemory/ctx/internal/config/token" + "github.com/ActiveMemory/ctx/internal/write/initialize" +) + +// WriteGettingStarted saves the next-steps and workflow-tips text to +// GETTING_STARTED.md in the project root. Best-effort: failures are +// non-fatal since the same content was already printed to stdout. +// +// Parameters: +// - cmd: Cobra command for status output +func WriteGettingStarted(cmd *cobra.Command) { + content := desc.Text(text.DescKeyWriteInitNextStepsBlock) + + token.NewlineLF + + desc.Text(text.DescKeyWriteInitWorkflowTips) + + token.NewlineLF + if writeErr := os.WriteFile( + cfgProject.GettingStarted, []byte(content), fs.PermFile, + ); writeErr != nil { + return + } + initialize.InfoGettingStartedSaved(cmd, cfgProject.GettingStarted) +} diff --git a/internal/cli/initialize/core/project/gitignore.go b/internal/cli/initialize/core/project/gitignore.go new file mode 100644 index 000000000..563a45bff --- /dev/null +++ b/internal/cli/initialize/core/project/gitignore.go @@ -0,0 +1,74 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package project + +import ( + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/config/file" + "github.com/ActiveMemory/ctx/internal/config/fs" + "github.com/ActiveMemory/ctx/internal/config/token" + "github.com/ActiveMemory/ctx/internal/io" + "github.com/ActiveMemory/ctx/internal/write/initialize" +) + +// EnsureGitignoreEntries appends recommended .gitignore entries that are not +// already present. Creates .gitignore if it does not exist. +// +// Parameters: +// - cmd: Cobra command for status output +// +// Returns: +// - error: Non-nil on read or write failure +func EnsureGitignoreEntries(cmd *cobra.Command) error { + content, readErr := os.ReadFile(file.FileGitignore) + if readErr != nil && !os.IsNotExist(readErr) { + return readErr + } + + // Build set of existing trimmed lines. + existing := make(map[string]bool) + for _, line := range strings.Split(string(content), token.NewlineLF) { + existing[strings.TrimSpace(line)] = true + } + + // Collect missing entries. + var missing []string + for _, e := range file.Gitignore { + if !existing[e] { + missing = append(missing, e) + } + } + + if len(missing) == 0 { + return nil + } + + // Build block to append. + var sb strings.Builder + if len(content) > 0 && !strings.HasSuffix(string(content), token.NewlineLF) { + sb.WriteString(token.NewlineLF) + } + sb.WriteString(token.NewlineLF + file.GitignoreHeader + token.NewlineLF) + for _, e := range missing { + sb.WriteString(e + token.NewlineLF) + } + + if writeErr := io.SafeWriteFile( + file.FileGitignore, append(content, []byte(sb.String())...), + fs.PermFile, + ); writeErr != nil { + return writeErr + } + + initialize.InfoGitignoreUpdated(cmd, len(missing)) + initialize.InfoGitignoreReview(cmd) + return nil +} diff --git a/internal/cli/initialize/core/project/makefile.go b/internal/cli/initialize/core/project/makefile.go index 718a68bf0..27d95813d 100644 --- a/internal/cli/initialize/core/project/makefile.go +++ b/internal/cli/initialize/core/project/makefile.go @@ -18,6 +18,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/token" errFs "github.com/ActiveMemory/ctx/internal/err/fs" errInit "github.com/ActiveMemory/ctx/internal/err/initialize" + "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/write/initialize" ) @@ -66,7 +67,7 @@ func HandleMakefileCtx(cmd *cobra.Command) error { } amended += token.NewlineLF + project.MakefileIncludeDirective + token.NewlineLF - if writeErr := os.WriteFile( + if writeErr := io.SafeWriteFile( project.Makefile, []byte(amended), fs.PermFile, ); writeErr != nil { return errFs.FileAmend(project.Makefile, writeErr) diff --git a/internal/cli/initialize/core/validate/essential.go b/internal/cli/initialize/core/validate/essential.go new file mode 100644 index 000000000..9fb6eb251 --- /dev/null +++ b/internal/cli/initialize/core/validate/essential.go @@ -0,0 +1,33 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package validate + +import ( + "os" + "path/filepath" + + "github.com/ActiveMemory/ctx/internal/config/ctx" +) + +// EssentialFilesPresent reports whether contextDir contains at least one of +// the essential context files (TASKS.md, CONSTITUTION.md, DECISIONS.md). A +// directory with only logs/ or other non-essential content is considered +// uninitialized. +// +// Parameters: +// - contextDir: Absolute path to the context directory to inspect +// +// Returns: +// - bool: True if at least one essential file exists +func EssentialFilesPresent(contextDir string) bool { + for _, f := range ctx.FilesRequired { + if _, statErr := os.Stat(filepath.Join(contextDir, f)); statErr == nil { + return true + } + } + return false +} diff --git a/internal/cli/journal/cmd/obsidian/run.go b/internal/cli/journal/cmd/obsidian/run.go index 9f012c0e0..df1da678d 100644 --- a/internal/cli/journal/cmd/obsidian/run.go +++ b/internal/cli/journal/cmd/obsidian/run.go @@ -30,6 +30,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/obsidian" errFs "github.com/ActiveMemory/ctx/internal/err/fs" errJournal "github.com/ActiveMemory/ctx/internal/err/journal" + "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/rc" "github.com/ActiveMemory/ctx/internal/write/err" writeObsidian "github.com/ActiveMemory/ctx/internal/write/obsidian" @@ -165,7 +166,7 @@ func BuildVault(cmd *cobra.Command, journalDir, output string) error { entry, topicIndex, obsidian.MaxRelated, ) - if writeErr := os.WriteFile( + if writeErr := io.SafeWriteFile( dst, []byte(transformed), fs.PermFile, ); writeErr != nil { err.WarnFile(cmd, entry.Filename, writeErr) diff --git a/internal/cli/journal/cmd/site/cmd.go b/internal/cli/journal/cmd/site/cmd.go index b58554871..66c5992e3 100644 --- a/internal/cli/journal/cmd/site/cmd.go +++ b/internal/cli/journal/cmd/site/cmd.go @@ -16,6 +16,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/embed/cmd" "github.com/ActiveMemory/ctx/internal/config/embed/flag" cFlag "github.com/ActiveMemory/ctx/internal/config/flag" + "github.com/ActiveMemory/ctx/internal/flagbind" "github.com/ActiveMemory/ctx/internal/rc" ) @@ -41,18 +42,12 @@ func Cmd() *cobra.Command { } defaultOutput := filepath.Join(rc.ContextDir(), dir.JournalSite) - c.Flags().StringVarP( - &output, cFlag.Output, cFlag.ShortOutput, defaultOutput, - desc.Flag(flag.DescKeyJournalSiteOutput), - ) - c.Flags().BoolVar( - &build, cFlag.Build, false, - desc.Flag(flag.DescKeyJournalSiteBuild), - ) - c.Flags().BoolVar( - &serve, cFlag.Serve, false, - desc.Flag(flag.DescKeyJournalSiteServe), + flagbind.StringFlagPDefault( + c, &output, cFlag.Output, cFlag.ShortOutput, + defaultOutput, flag.DescKeyJournalSiteOutput, ) + flagbind.BoolFlag(c, &build, cFlag.Build, flag.DescKeyJournalSiteBuild) + flagbind.BoolFlag(c, &serve, cFlag.Serve, flag.DescKeyJournalSiteServe) return c } diff --git a/internal/cli/journal/core/execute/execute.go b/internal/cli/journal/core/execute/execute.go index aa2397007..0e61684be 100644 --- a/internal/cli/journal/core/execute/execute.go +++ b/internal/cli/journal/core/execute/execute.go @@ -21,6 +21,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/session" "github.com/ActiveMemory/ctx/internal/config/token" "github.com/ActiveMemory/ctx/internal/entity" + "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/journal/state" "github.com/ActiveMemory/ctx/internal/write/err" writeRecall "github.com/ActiveMemory/ctx/internal/write/journal" @@ -87,7 +88,7 @@ func Import( } // Write file. - if writeErr := os.WriteFile( + if writeErr := io.SafeWriteFile( fa.Path, []byte(content), fs.PermFile, ); writeErr != nil { err.WarnFile(cmd, fa.Filename, writeErr) diff --git a/internal/cli/journal/core/generate/generate.go b/internal/cli/journal/core/generate/generate.go index faee42b38..f4537e0d6 100644 --- a/internal/cli/journal/core/generate/generate.go +++ b/internal/cli/journal/core/generate/generate.go @@ -23,6 +23,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/token" "github.com/ActiveMemory/ctx/internal/config/zensical" "github.com/ActiveMemory/ctx/internal/entity" + "github.com/ActiveMemory/ctx/internal/io" ) // SiteReadme creates a README for the journal-site directory. @@ -63,14 +64,14 @@ func Index(entries []entity.JournalEntry) string { sb.WriteString(desc.Text(text.DescKeyHeadingSessionJournal) + nl + nl) sb.WriteString(tpl.JournalIndexIntro + nl + nl) - sb.WriteString(fmt.Sprintf(tpl.JournalIndexStats+ - nl+nl, len(regular), len(suggestions))) + io.SafeFprintf(&sb, tpl.JournalIndexStats+ + nl+nl, len(regular), len(suggestions)) // Group regular sessions by month months, monthOrder := group.ByMonth(regular) for _, month := range monthOrder { - sb.WriteString(fmt.Sprintf(tpl.JournalMonthHeading+nl+nl, month)) + io.SafeFprintf(&sb, tpl.JournalMonthHeading+nl+nl, month) for _, e := range months[month] { sb.WriteString(formatIndexEntry(e, nl)) @@ -182,25 +183,23 @@ func ZensicalToml( // Build navigation sb.WriteString(zensical.TomlNavOpen + nl) - sb.WriteString(fmt.Sprintf(tpl.JournalNavItem+nl, - desc.Text(text.DescKeyLabelHome), file.Index)) + io.SafeFprintf(&sb, tpl.JournalNavItem+nl, + desc.Text(text.DescKeyLabelHome), file.Index) if len(topics) > 0 { - sb.WriteString(fmt.Sprintf(tpl.JournalNavItem+nl, + io.SafeFprintf(&sb, tpl.JournalNavItem+nl, desc.Text(text.DescKeyLabelTopics), - filepath.Join(dir.JournTopics, file.Index)), + filepath.Join(dir.JournTopics, file.Index), ) } if len(keyFiles) > 0 { - sb.WriteString(fmt.Sprintf(tpl.JournalNavItem+nl, + io.SafeFprintf(&sb, tpl.JournalNavItem+nl, desc.Text(text.DescKeyLabelFiles), - filepath.Join(dir.JournalFiles, file.Index)), - ) + filepath.Join(dir.JournalFiles, file.Index)) } if len(sessionTypes) > 0 { - sb.WriteString(fmt.Sprintf(tpl.JournalNavItem+nl, + io.SafeFprintf(&sb, tpl.JournalNavItem+nl, desc.Text(text.DescKeyLabelTypes), - filepath.Join(dir.JournalTypes, file.Index)), - ) + filepath.Join(dir.JournalTypes, file.Index)) } // Filter out suggestion sessions and multi-part continuations from navigation @@ -221,19 +220,19 @@ func ZensicalToml( recent = recent[:journal.MaxRecentSessions] } - sb.WriteString(fmt.Sprintf( - tpl.JournalNavSection+nl, desc.Text(text.DescKeyHeadingRecentSessions)), - ) + io.SafeFprintf(&sb, + tpl.JournalNavSection+nl, desc.Text(text.DescKeyHeadingRecentSessions)) for _, e := range recent { title := e.Title if utf8.RuneCountInString(title) > journal.MaxNavTitleLen { runes := []rune(title) title = string(runes[:journal.MaxNavTitleLen]) + token.Ellipsis } - title = strings.ReplaceAll(title, token.DoubleQuote, token.EscapedDoubleQuote) - sb.WriteString(fmt.Sprintf( - tpl.JournalNavSessionItem+nl, title, e.Filename), + title = strings.ReplaceAll( + title, token.DoubleQuote, token.EscapedDoubleQuote, ) + io.SafeFprintf(&sb, + tpl.JournalNavSessionItem+nl, title, e.Filename) } sb.WriteString(zensical.TomlNavSectionClose + nl) sb.WriteString(zensical.TomlNavClose + nl + nl) diff --git a/internal/cli/journal/core/moc/moc.go b/internal/cli/journal/core/moc/moc.go index 7a7bbd8a2..6819f8aa5 100644 --- a/internal/cli/journal/core/moc/moc.go +++ b/internal/cli/journal/core/moc/moc.go @@ -22,6 +22,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/obsidian" "github.com/ActiveMemory/ctx/internal/config/token" "github.com/ActiveMemory/ctx/internal/entity" + "github.com/ActiveMemory/ctx/internal/io" ) // Home creates the root navigation hub for the Obsidian vault. @@ -52,22 +53,22 @@ func Home( filesLink := strings.TrimSuffix(obsidian.MOCFiles, file.ExtMarkdown) typesLink := strings.TrimSuffix(obsidian.MOCTypes, file.ExtMarkdown) if hasTopics { - sb.WriteString(fmt.Sprintf( + io.SafeFprintf(&sb, browseItem+nl, wikilink.Format(topicsLink, topicsLink[1:]), - desc.Text(text.DescKeyJournalMocTopicsDesc))) + desc.Text(text.DescKeyJournalMocTopicsDesc)) } if hasFiles { - sb.WriteString(fmt.Sprintf( + io.SafeFprintf(&sb, browseItem+nl, wikilink.Format(filesLink, filesLink[1:]), - desc.Text(text.DescKeyJournalMocFilesDesc))) + desc.Text(text.DescKeyJournalMocFilesDesc)) } if hasTypes { - sb.WriteString(fmt.Sprintf( + io.SafeFprintf(&sb, browseItem+nl, wikilink.Format(typesLink, typesLink[1:]), - desc.Text(text.DescKeyJournalMocTypesDesc))) + desc.Text(text.DescKeyJournalMocTypesDesc)) } sb.WriteString(nl) @@ -106,10 +107,10 @@ func ObsidianTopics(topics []entity.TopicData) string { popular, longtail := SplitPopular(topics) sb.WriteString(desc.Text(text.DescKeyJournalMocHeadingTopics) + nl + nl) - sb.WriteString(fmt.Sprintf( + io.SafeFprintf(&sb, desc.Text(text.DescKeyJournalMocTopicStats)+nl+nl, len(topics), session.CountUnique(topics), - len(popular), len(longtail))) + len(popular), len(longtail)) section.WriteFormatted( &sb, @@ -177,9 +178,9 @@ func ObsidianFiles(keyFiles []entity.KeyFileData) string { } sb.WriteString(desc.Text(text.DescKeyJournalMocHeadingFiles) + nl + nl) - sb.WriteString(fmt.Sprintf( + io.SafeFprintf(&sb, desc.Text(text.DescKeyJournalMocFileStats)+nl+nl, - len(keyFiles), totalSessions, len(popular), len(longtail))) + len(keyFiles), totalSessions, len(popular), len(longtail)) section.WriteFormatted( &sb, @@ -241,14 +242,14 @@ func ObsidianTypes(sessionTypes []entity.TypeData) string { } sb.WriteString(desc.Text(text.DescKeyJournalMocHeadingTypes) + nl + nl) - sb.WriteString(fmt.Sprintf( + io.SafeFprintf(&sb, desc.Text(text.DescKeyJournalMocTypeStats)+nl+nl, - len(sessionTypes), totalSessions)) + len(sessionTypes), totalSessions) for _, st := range sessionTypes { - sb.WriteString(fmt.Sprintf( + io.SafeFprintf(&sb, desc.Text(text.DescKeyJournalMocItemSessions)+nl, - wikilink.Format(st.Name, st.Name), len(st.Entries))) + wikilink.Format(st.Name, st.Name), len(st.Entries)) } sb.WriteString(nl) @@ -346,16 +347,16 @@ func GenerateRelatedFooter( topicLinks = append(topicLinks, fmt.Sprintf(obsidian.WikilinkPlain, t)) } - sb.WriteString(fmt.Sprintf( + io.SafeFprintf(&sb, desc.Text(text.DescKeyJournalMocTopicsLabel)+nl+nl, - strings.Join(topicLinks, desc.Text(text.DescKeyJournalMocTopicSep)))) + strings.Join(topicLinks, desc.Text(text.DescKeyJournalMocTopicSep))) } // Type link if entry.Type != "" { - sb.WriteString(fmt.Sprintf( + io.SafeFprintf(&sb, desc.Text(text.DescKeyJournalMocTypeLabel)+nl+nl, - fmt.Sprintf(obsidian.WikilinkPlain, entry.Type))) + fmt.Sprintf(obsidian.WikilinkPlain, entry.Type)) } // See also: other entries sharing topics @@ -364,9 +365,9 @@ func GenerateRelatedFooter( sb.WriteString(desc.Text(text.DescKeyLabelObsidianSeeAlso) + nl) for _, rel := range related { link := strings.TrimSuffix(rel.Filename, file.ExtMarkdown) - sb.WriteString(fmt.Sprintf( + io.SafeFprintf(&sb, desc.Text(text.DescKeyJournalMocItemListed)+nl, - wikilink.Format(link, rel.Title))) + wikilink.Format(link, rel.Title)) } sb.WriteString(nl) } diff --git a/internal/cli/journal/core/plan/plan.go b/internal/cli/journal/core/plan/plan.go index e1d21876b..eb3271185 100644 --- a/internal/cli/journal/core/plan/plan.go +++ b/internal/cli/journal/core/plan/plan.go @@ -22,6 +22,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/journal" "github.com/ActiveMemory/ctx/internal/config/session" "github.com/ActiveMemory/ctx/internal/entity" + "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/journal/state" ) @@ -105,7 +106,7 @@ func Import( endIdx = totalMsgs } - _, statErr := os.Stat(path) + _, statErr := io.SafeStat(path) fileExists := statErr == nil var action entity.ImportAction diff --git a/internal/cli/journal/core/section/index.go b/internal/cli/journal/core/section/index.go index b9e72fa32..d637125d8 100644 --- a/internal/cli/journal/core/section/index.go +++ b/internal/cli/journal/core/section/index.go @@ -18,6 +18,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/embed/text" "github.com/ActiveMemory/ctx/internal/config/token" "github.com/ActiveMemory/ctx/internal/entity" + "github.com/ActiveMemory/ctx/internal/io" ) // BuildTopicIndex aggregates entries by topic and returns sorted topic data. @@ -67,9 +68,9 @@ func GenerateTopicsIndex(topics []entity.TopicData) string { } sb.WriteString(desc.Text(text.DescKeyHeadingTopics) + nl + nl) - sb.WriteString(fmt.Sprintf( + io.SafeFprintf(&sb, tpl.JournalTopicStats+nl+nl, - len(topics), session.CountUnique(topics), len(popular), len(longtail))) + len(topics), session.CountUnique(topics), len(popular), len(longtail)) WritePopularAndLongtail(&sb, len(popular), desc.Text(text.DescKeyHeadingPopularTopics), @@ -161,10 +162,9 @@ func GenerateKeyFilesIndex(keyFiles []entity.KeyFileData) string { } sb.WriteString(desc.Text(text.DescKeyHeadingKeyFiles) + nl + nl) - sb.WriteString(fmt.Sprintf( + io.SafeFprintf(&sb, tpl.JournalFileStats+nl+nl, - len(keyFiles), totalSessions, len(popular), len(longtail)), - ) + len(keyFiles), totalSessions, len(popular), len(longtail)) WritePopularAndLongtail(&sb, len(popular), desc.Text(text.DescKeyHeadingFrequentlyTouched), @@ -237,9 +237,8 @@ func GenerateTypesIndex(sessionTypes []entity.TypeData) string { } sb.WriteString(desc.Text(text.DescKeyHeadingSessionTypes) + nl + nl) - sb.WriteString(fmt.Sprintf( - tpl.JournalTypeStats+nl+nl, len(sessionTypes), totalSessions), - ) + io.SafeFprintf(&sb, + tpl.JournalTypeStats+nl+nl, len(sessionTypes), totalSessions) for _, st := range sessionTypes { sb.WriteString(format.SessionLink(st.Name, st.Name, len(st.Entries))) diff --git a/internal/cli/journal/core/source/format/format.go b/internal/cli/journal/core/source/format/format.go index f083ce8a3..0130bd9ba 100644 --- a/internal/cli/journal/core/source/format/format.go +++ b/internal/cli/journal/core/source/format/format.go @@ -25,6 +25,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/token" "github.com/ActiveMemory/ctx/internal/entity" sharedFmt "github.com/ActiveMemory/ctx/internal/format" + "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/parse" ) @@ -43,7 +44,7 @@ func PartNavigation(part, totalParts int, baseName string) string { var sb strings.Builder nl := token.NewlineLF - sb.WriteString(fmt.Sprintf(tpl.RecallPartOf, part, totalParts)) + io.SafeFprintf(&sb, tpl.RecallPartOf, part, totalParts) if part > 1 || part < totalParts { sb.WriteString(box.PipeSeparator) @@ -55,7 +56,7 @@ func PartNavigation(part, totalParts int, baseName string) string { if part > 2 { prevFile = fmt.Sprintf(tpl.RecallPartFilename, baseName, part-1) } - sb.WriteString(fmt.Sprintf(tpl.RecallNavPrev, prevFile)) + io.SafeFprintf(&sb, tpl.RecallNavPrev, prevFile) } // Separator between prev and next @@ -66,7 +67,7 @@ func PartNavigation(part, totalParts int, baseName string) string { // Next link if part < totalParts { nextFile := fmt.Sprintf(tpl.RecallPartFilename, baseName, part+1) - sb.WriteString(fmt.Sprintf(tpl.RecallNavNext, nextFile)) + io.SafeFprintf(&sb, tpl.RecallNavNext, nextFile) } sb.WriteString(nl) @@ -231,7 +232,7 @@ func JournalEntryPart( // Header: prefer title, fall back to slug, then baseName. heading := frontmatter.ResolveHeading(title, s.Slug, baseName) - sb.WriteString(fmt.Sprintf(tpl.JournalPageHeading+nl+nl, heading)) + io.SafeFprintf(&sb, tpl.JournalPageHeading+nl+nl, heading) // Navigation header for multipart sessions if totalParts > 1 { @@ -244,72 +245,43 @@ func JournalEntryPart( summaryText := fmt.Sprintf( desc.Text(text.DescKeyJournalSourceMetaSummary), dateStr, durationStr, s.Model, ) - sb.WriteString(fmt.Sprintf(tpl.MetaDetailsOpen, summaryText)) - sb.WriteString(fmt.Sprintf( - tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaID), s.ID)) - sb.WriteString( - fmt.Sprintf( - tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaDate), dateStr, - ), - ) - sb.WriteString( - fmt.Sprintf( - tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaTime), timeStr, - ), - ) - sb.WriteString( - fmt.Sprintf( - tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaDuration), durationStr, - ), - ) - sb.WriteString( - fmt.Sprintf( - tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaTool), s.Tool, - ), - ) - sb.WriteString( - fmt.Sprintf( - tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaProject), s.Project, - ), - ) + io.SafeFprintf(&sb, tpl.MetaDetailsOpen, summaryText) + io.SafeFprintf(&sb, + tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaID), s.ID) + io.SafeFprintf(&sb, + tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaDate), dateStr) + io.SafeFprintf(&sb, + tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaTime), timeStr) + io.SafeFprintf(&sb, + tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaDuration), durationStr) + io.SafeFprintf(&sb, + tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaTool), s.Tool) + io.SafeFprintf(&sb, + tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaProject), s.Project) if s.GitBranch != "" { - sb.WriteString( - fmt.Sprintf( - tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaBranch), s.GitBranch, - ), - ) + io.SafeFprintf(&sb, + tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaBranch), s.GitBranch) } if s.Model != "" { - sb.WriteString( - fmt.Sprintf( - tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaModel), s.Model, - ), - ) + io.SafeFprintf(&sb, + tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaModel), s.Model) } sb.WriteString(tpl.MetaDetailsClose + nl + nl) // Token stats as collapsible HTML table turnStr := fmt.Sprintf("%d", s.TurnCount) - sb.WriteString(fmt.Sprintf(tpl.MetaDetailsOpen, turnStr)) - sb.WriteString( - fmt.Sprintf( - tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaTurns), turnStr, - ), - ) + io.SafeFprintf(&sb, tpl.MetaDetailsOpen, turnStr) + io.SafeFprintf(&sb, + tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaTurns), turnStr) tokenSummary := fmt.Sprintf(desc.Text(text.DescKeyJournalSourceTokenSummary), sharedFmt.Tokens(s.TotalTokens), sharedFmt.Tokens(s.TotalTokensIn), sharedFmt.Tokens(s.TotalTokensOut)) - sb.WriteString( - fmt.Sprintf( - tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaTokens), tokenSummary, - ), - ) + io.SafeFprintf(&sb, + tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaTokens), tokenSummary) if totalParts > 1 { - sb.WriteString( - fmt.Sprintf(tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaParts), - fmt.Sprintf("%d", totalParts)), - ) + io.SafeFprintf(&sb, tpl.MetaRow+nl, desc.Text(text.DescKeyLabelMetaParts), + fmt.Sprintf("%d", totalParts)) } sb.WriteString(tpl.MetaDetailsClose + nl + nl) @@ -324,16 +296,15 @@ func JournalEntryPart( toolCounts[t.Name]++ } for name, count := range toolCounts { - sb.WriteString(fmt.Sprintf( - tpl.RecallToolCount+nl, name, count), - ) + io.SafeFprintf(&sb, + tpl.RecallToolCount+nl, name, count) } sb.WriteString(nl + sep + nl + nl) } } else { // Header (non-part-1) - the same fallback as part 1. heading := frontmatter.ResolveHeading(title, s.Slug, baseName) - sb.WriteString(fmt.Sprintf(tpl.JournalPageHeading+nl+nl, heading)) + io.SafeFprintf(&sb, tpl.JournalPageHeading+nl+nl, heading) // Navigation header for multipart sessions if totalParts > 1 { @@ -346,9 +317,8 @@ func JournalEntryPart( if part == 1 { sb.WriteString(desc.Text(text.DescKeyHeadingConversation) + nl + nl) } else { - sb.WriteString(fmt.Sprintf( - tpl.RecallConversationContinued+nl+nl, part-1), - ) + io.SafeFprintf(&sb, + tpl.RecallConversationContinued+nl+nl, part-1) } for i, msg := range messages { @@ -361,8 +331,8 @@ func JournalEntryPart( } localTime := msg.Timestamp.Local() - sb.WriteString(fmt.Sprintf(tpl.RecallTurnHeader+nl+nl, - msgNum, role, localTime.Format(time.Format))) + io.SafeFprintf(&sb, tpl.RecallTurnHeader+nl+nl, + msgNum, role, localTime.Format(time.Format)) if msg.Text != "" { t := msg.Text @@ -376,7 +346,7 @@ func JournalEntryPart( // Tool uses for _, t := range msg.ToolUses { - sb.WriteString(fmt.Sprintf(tpl.RecallToolUse+nl, ToolUse(t))) + io.SafeFprintf(&sb, tpl.RecallToolUse+nl, ToolUse(t)) } // Tool results @@ -392,25 +362,21 @@ func JournalEntryPart( if lines > journal.DetailsThreshold { summary := fmt.Sprintf(tpl.RecallDetailsSummary, lines) - sb.WriteString(fmt.Sprintf(tpl.RecallDetailsOpen+nl+nl, summary)) + io.SafeFprintf(&sb, tpl.RecallDetailsOpen+nl+nl, summary) sb.WriteString(marker.TagPre + nl) sb.WriteString(html.EscapeString(content) + nl) sb.WriteString(marker.TagPreClose + nl) sb.WriteString(tpl.RecallDetailsClose + nl) } else { - sb.WriteString(fmt.Sprintf( - tpl.RecallFencedBlock+nl, fence, content, fence), - ) + io.SafeFprintf(&sb, + tpl.RecallFencedBlock+nl, fence, content, fence) } // Render system reminders as Markdown outside the code fence for _, reminder := range reminders { - sb.WriteString( - fmt.Sprintf( - nl+desc.Text(text.DescKeyLabelBoldReminder)+" %s"+nl, - reminder, - ), - ) + io.SafeFprintf(&sb, + nl+desc.Text(text.DescKeyLabelBoldReminderFmt)+nl, + reminder) } } } diff --git a/internal/cli/pad/core/store/store.go b/internal/cli/pad/core/store/store.go index d780e9812..e5baeca83 100644 --- a/internal/cli/pad/core/store/store.go +++ b/internal/cli/pad/core/store/store.go @@ -117,7 +117,7 @@ func EnsureGitignore(contextDir, filename string) error { if len(content) > 0 && !strings.HasSuffix(string(content), token.NewlineLF) { sep = token.NewlineLF } - return os.WriteFile( + return io.SafeWriteFile( file.FileGitignore, []byte(string(content)+sep+entry+token.NewlineLF), fs.PermFile, ) diff --git a/internal/cli/permission/cmd/restore/run.go b/internal/cli/permission/cmd/restore/run.go index dbbcd810a..36ac9c405 100644 --- a/internal/cli/permission/cmd/restore/run.go +++ b/internal/cli/permission/cmd/restore/run.go @@ -20,6 +20,7 @@ import ( "github.com/ActiveMemory/ctx/internal/err/config" errFs "github.com/ActiveMemory/ctx/internal/err/fs" errParser "github.com/ActiveMemory/ctx/internal/err/parser" + "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/write/restore" ) @@ -42,7 +43,7 @@ func Run(cmd *cobra.Command) error { localBytes, localReadErr := os.ReadFile(cfgClaude.Settings) if localReadErr != nil { if os.IsNotExist(localReadErr) { - if writeErr := os.WriteFile( + if writeErr := io.SafeWriteFile( cfgClaude.Settings, goldenBytes, fs.PermFile, ); writeErr != nil { return errFs.FileWrite(cfgClaude.Settings, writeErr) @@ -76,7 +77,7 @@ func Run(cmd *cobra.Command) error { restore.Diff(cmd, dropped, restored, denyDropped, denyRestored) - if writeErr := os.WriteFile( + if writeErr := io.SafeWriteFile( cfgClaude.Settings, goldenBytes, fs.PermFile, ); writeErr != nil { return errFs.FileWrite(cfgClaude.Settings, writeErr) diff --git a/internal/cli/permission/cmd/snapshot/run.go b/internal/cli/permission/cmd/snapshot/run.go index 5d221a0d7..6aa83b551 100644 --- a/internal/cli/permission/cmd/snapshot/run.go +++ b/internal/cli/permission/cmd/snapshot/run.go @@ -15,6 +15,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/fs" "github.com/ActiveMemory/ctx/internal/err/config" errFs "github.com/ActiveMemory/ctx/internal/err/fs" + "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/write/restore" ) @@ -39,7 +40,7 @@ func Run(cmd *cobra.Command) error { updated = true } - if writeErr := os.WriteFile( + if writeErr := io.SafeWriteFile( claude.SettingsGolden, content, fs.PermFile, ); writeErr != nil { return errFs.FileWrite(claude.SettingsGolden, writeErr) diff --git a/internal/cli/setup/core/agents/agents.go b/internal/cli/setup/core/agents/agents.go index 3cf08c30e..2c36e73a1 100644 --- a/internal/cli/setup/core/agents/agents.go +++ b/internal/cli/setup/core/agents/agents.go @@ -19,6 +19,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/marker" "github.com/ActiveMemory/ctx/internal/config/token" errFs "github.com/ActiveMemory/ctx/internal/err/fs" + "github.com/ActiveMemory/ctx/internal/io" writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) @@ -55,7 +56,7 @@ func Deploy(cmd *cobra.Command) error { // File exists without ctx markers: append ctx content merged := existingStr + token.NewlineLF + string(agentsContent) - if wErr := os.WriteFile(targetFile, []byte(merged), fs.PermFile); wErr != nil { + if wErr := io.SafeWriteFile(targetFile, []byte(merged), fs.PermFile); wErr != nil { return errFs.FileWrite(targetFile, wErr) } writeSetup.InfoAgentsMerged(cmd, targetFile) @@ -63,7 +64,7 @@ func Deploy(cmd *cobra.Command) error { } // File doesn't exist: create it - if wErr := os.WriteFile(targetFile, agentsContent, fs.PermFile); wErr != nil { + if wErr := io.SafeWriteFile(targetFile, agentsContent, fs.PermFile); wErr != nil { return errFs.FileWrite(targetFile, wErr) } writeSetup.InfoAgentsCreated(cmd, targetFile) diff --git a/internal/cli/setup/core/copilot/copilot.go b/internal/cli/setup/core/copilot/copilot.go index 9d7187418..56edfbe61 100644 --- a/internal/cli/setup/core/copilot/copilot.go +++ b/internal/cli/setup/core/copilot/copilot.go @@ -21,6 +21,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/token" cfgVscode "github.com/ActiveMemory/ctx/internal/config/vscode" errFs "github.com/ActiveMemory/ctx/internal/err/fs" + "github.com/ActiveMemory/ctx/internal/io" writeErr "github.com/ActiveMemory/ctx/internal/write/err" writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) @@ -51,8 +52,8 @@ func DeployInstructions(cmd *cobra.Command) error { } // Check if the file exists - existingContent, readErr := os.ReadFile(filepath.Clean(targetFile)) - fileExists := readErr == nil + existingContent, existErr := os.ReadFile(filepath.Clean(targetFile)) + fileExists := existErr == nil if fileExists { existingStr := string(existingContent) @@ -63,7 +64,7 @@ func DeployInstructions(cmd *cobra.Command) error { // File exists without ctx markers: append ctx content merged := existingStr + token.NewlineLF + string(instructions) - if wErr := os.WriteFile( + if wErr := io.SafeWriteFile( targetFile, []byte(merged), fs.PermFile, ); wErr != nil { return errFs.FileWrite(targetFile, wErr) @@ -73,7 +74,7 @@ func DeployInstructions(cmd *cobra.Command) error { } // File doesn't exist: create it - if wErr := os.WriteFile( + if wErr := io.SafeWriteFile( targetFile, instructions, fs.PermFile, ); wErr != nil { return errFs.FileWrite(targetFile, wErr) diff --git a/internal/cli/setup/core/copilot_cli/mcp.go b/internal/cli/setup/core/copilot_cli/mcp.go index b3c5fe71d..1a8eeb7e2 100644 --- a/internal/cli/setup/core/copilot_cli/mcp.go +++ b/internal/cli/setup/core/copilot_cli/mcp.go @@ -17,6 +17,7 @@ import ( cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" mcpServer "github.com/ActiveMemory/ctx/internal/config/mcp/server" "github.com/ActiveMemory/ctx/internal/config/token" + "github.com/ActiveMemory/ctx/internal/io" writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) @@ -73,7 +74,7 @@ func ensureMCPConfig(cmd *cobra.Command) error { existing[cfgHook.KeyMCPServers] = servers // Create directory if needed - if mkdirErr := os.MkdirAll(copilotHome, fs.PermExec); mkdirErr != nil { + if mkdirErr := io.SafeMkdirAll(copilotHome, fs.PermExec); mkdirErr != nil { return mkdirErr } @@ -83,7 +84,7 @@ func ensureMCPConfig(cmd *cobra.Command) error { } data = append(data, token.NewlineLF...) - if writeFileErr := os.WriteFile(target, data, fs.PermFile); writeFileErr != nil { + if writeFileErr := io.SafeWriteFile(target, data, fs.PermFile); writeFileErr != nil { return writeFileErr } writeSetup.InfoCopilotCLICreated(cmd, target) diff --git a/internal/cli/system/cmd/check_ceremony/run.go b/internal/cli/system/cmd/check_ceremony/run.go index da2090da4..d9c3d7a1e 100644 --- a/internal/cli/system/cmd/check_ceremony/run.go +++ b/internal/cli/system/cmd/check_ceremony/run.go @@ -7,6 +7,7 @@ package check_ceremony import ( + "fmt" "os" "path/filepath" @@ -74,8 +75,8 @@ func Run(cmd *cobra.Command, stdin *os.File) error { return nil } ref := notify.NewTemplateRef(hook.CheckCeremonies, variant, nil) - nudge.EmitAndRelay(hook.CheckCeremonies+": "+ - desc.Text(text.DescKeyCeremonyRelayMessage), + nudge.EmitAndRelay(fmt.Sprintf(desc.Text(text.DescKeyRelayPrefixFormat), + hook.CheckCeremonies, desc.Text(text.DescKeyCeremonyRelayMessage)), input.SessionID, ref, ) internalIo.TouchFile(remindedFile) diff --git a/internal/cli/system/cmd/check_journal/run.go b/internal/cli/system/cmd/check_journal/run.go index 8bae8982b..10a699167 100644 --- a/internal/cli/system/cmd/check_journal/run.go +++ b/internal/cli/system/cmd/check_journal/run.go @@ -67,7 +67,9 @@ func Run(cmd *cobra.Command, stdin *os.File) error { if _, statErr := os.Stat(jDir); os.IsNotExist(statErr) { return nil } - if _, statErr := os.Stat(claudeProjectsDir); os.IsNotExist(statErr) { + if _, statErr := internalIo.SafeStat( + claudeProjectsDir, + ); os.IsNotExist(statErr) { return nil } @@ -119,10 +121,11 @@ func Run(cmd *cobra.Command, stdin *os.File) error { writeSetup.Nudge(cmd, message.NudgeBox(relayPrefix, boxTitle, content)) ref := notify.NewTemplateRef(hook.CheckJournal, variant, vars) - journalMsg := hook.CheckJournal + ": " + fmt.Sprintf( - desc.Text(text.DescKeyCheckJournalRelayFormat), - unimported, unenriched, - ) + journalMsg := fmt.Sprintf(desc.Text(text.DescKeyRelayPrefixFormat), + hook.CheckJournal, fmt.Sprintf( + desc.Text(text.DescKeyCheckJournalRelayFormat), + unimported, unenriched, + )) nudge.EmitAndRelay(journalMsg, input.SessionID, ref) internalIo.TouchFile(remindedFile) diff --git a/internal/cli/system/cmd/context_load_gate/run.go b/internal/cli/system/cmd/context_load_gate/run.go index e0bed2eee..59d108c37 100644 --- a/internal/cli/system/cmd/context_load_gate/run.go +++ b/internal/cli/system/cmd/context_load_gate/run.go @@ -103,10 +103,9 @@ func Run(cmd *cobra.Command, stdin *os.File) error { continue // file missing - skip gracefully } - content.WriteString(fmt.Sprintf( - desc.Text( - text.DescKeyContextLoadGateFileHeader, - ), f, string(data))) + internalIo.SafeFprintf(&content, desc.Text( + text.DescKeyContextLoadGateFileHeader, + ), f, string(data)) tokens := ctxToken.Estimate(data) totalTokens += tokens perFile = append(perFile, entity.FileTokenEntry{Name: f, Tokens: tokens}) @@ -127,9 +126,8 @@ func Run(cmd *cobra.Command, stdin *os.File) error { strings.Repeat( load_gate.ContextLoadSeparatorChar, load_gate.ContextLoadSeparatorWidth, ) + token.NewlineLF) - content.WriteString(fmt.Sprintf( - desc.Text(text.DescKeyContextLoadGateFooter), - filesLoaded, totalTokens)) + internalIo.SafeFprintf(&content, desc.Text(text.DescKeyContextLoadGateFooter), + filesLoaded, totalTokens) writeSetup.Context( cmd, coreSession.FormatContext(hook.EventPreToolUse, content.String()), diff --git a/internal/cli/system/core/knowledge/knowledge.go b/internal/cli/system/core/knowledge/knowledge.go index 07025ad64..c667f979b 100644 --- a/internal/cli/system/core/knowledge/knowledge.go +++ b/internal/cli/system/core/knowledge/knowledge.go @@ -102,7 +102,7 @@ func FormatWarnings(findings []finding) string { var b strings.Builder findingFmt := desc.Text(text.DescKeyCheckKnowledgeFindingFormat) for _, f := range findings { - b.WriteString(fmt.Sprintf(findingFmt, f.File, f.Count, f.Unit, f.Threshold)) + io.SafeFprintf(&b, findingFmt, f.File, f.Count, f.Unit, f.Threshold) } return b.String() } diff --git a/internal/cli/system/core/load/doc.go b/internal/cli/system/core/load/doc.go index f80e2d8ac..7038d0273 100644 --- a/internal/cli/system/core/load/doc.go +++ b/internal/cli/system/core/load/doc.go @@ -4,9 +4,9 @@ // \ Copyright 2026-present Context contributors. // SPDX-License-Identifier: Apache-2.0 -// Package load extracts index content from context files and. +// Package load provides context injection helpers for the load gate. // -// Key exports: [ExtractIndex], [WriteOversizeFlag]. +// Key exports: [WriteOversizeFlag]. // Shared helpers used by sibling cmd/ packages. // Used by core cmd/ packages. package load diff --git a/internal/cli/system/core/load/load_gate.go b/internal/cli/system/core/load/load_gate.go index cbf4e163b..ff99ed586 100644 --- a/internal/cli/system/core/load/load_gate.go +++ b/internal/cli/system/core/load/load_gate.go @@ -7,7 +7,6 @@ package load import ( - "fmt" "os" "path/filepath" "strings" @@ -18,34 +17,15 @@ import ( "github.com/ActiveMemory/ctx/internal/config/embed/text" "github.com/ActiveMemory/ctx/internal/config/fs" "github.com/ActiveMemory/ctx/internal/config/load_gate" - "github.com/ActiveMemory/ctx/internal/config/marker" "github.com/ActiveMemory/ctx/internal/config/stats" "github.com/ActiveMemory/ctx/internal/config/token" "github.com/ActiveMemory/ctx/internal/config/warn" "github.com/ActiveMemory/ctx/internal/entity" + "github.com/ActiveMemory/ctx/internal/io" ctxLog "github.com/ActiveMemory/ctx/internal/log/warn" "github.com/ActiveMemory/ctx/internal/rc" ) -// ExtractIndex returns the content between INDEX:START and INDEX:END -// markers within a context file. -// -// Parameters: -// - content: full file content to search -// -// Returns: -// - string: trimmed index content, or empty string if markers are -// not found or improperly ordered -func ExtractIndex(content string) string { - start := strings.Index(content, marker.IndexStart) - end := strings.Index(content, marker.IndexEnd) - if start < 0 || end < 0 || end <= start { - return "" - } - startPos := start + len(marker.IndexStart) - return strings.TrimSpace(content[startPos:end]) -} - // WriteOversizeFlag writes an injection-oversize flag file when the total // injected tokens exceed the configured threshold. The flag file is read // by check-context-size to emit an oversize warning. @@ -71,17 +51,17 @@ func WriteOversizeFlag( flag.WriteString(desc.Text(text.DescKeyContextLoadGateOversizeHeader)) sep := strings.Repeat(load_gate.ContextLoadSeparatorChar, stats.ContextSizeOversizeSepLen) flag.WriteString(sep + token.NewlineLF) - flag.WriteString(fmt.Sprintf( + io.SafeFprintf(&flag, desc.Text(text.DescKeyContextLoadGateOversizeTimestamp), - time.Now().UTC().Format(time.RFC3339))) - flag.WriteString(fmt.Sprintf( + time.Now().UTC().Format(time.RFC3339)) + io.SafeFprintf(&flag, desc.Text(text.DescKeyContextLoadGateOversizeInjected), - totalTokens, threshold)) + totalTokens, threshold) flag.WriteString(desc.Text(text.DescKeyContextLoadGateOversizeBreakdown)) for _, entry := range perFile { - flag.WriteString(fmt.Sprintf( + io.SafeFprintf(&flag, desc.Text(text.DescKeyContextLoadGateOversizeFileEntry), - entry.Name, entry.Tokens)) + entry.Name, entry.Tokens) } flag.WriteString(token.NewlineLF) flag.WriteString(desc.Text(text.DescKeyContextLoadGateOversizeAction)) diff --git a/internal/cli/task/cmd/complete/run.go b/internal/cli/task/cmd/complete/run.go index 88827cd8a..e3cf9648e 100644 --- a/internal/cli/task/cmd/complete/run.go +++ b/internal/cli/task/cmd/complete/run.go @@ -7,112 +7,16 @@ package complete import ( - "os" - "path/filepath" - "strconv" - "strings" + "fmt" "github.com/spf13/cobra" - "github.com/ActiveMemory/ctx/internal/config/ctx" - "github.com/ActiveMemory/ctx/internal/config/fs" - "github.com/ActiveMemory/ctx/internal/config/regex" - "github.com/ActiveMemory/ctx/internal/config/token" - errTask "github.com/ActiveMemory/ctx/internal/err/task" - "github.com/ActiveMemory/ctx/internal/rc" - "github.com/ActiveMemory/ctx/internal/task" - "github.com/ActiveMemory/ctx/internal/write/complete" + "github.com/ActiveMemory/ctx/internal/cli/system/core/state" + coreComplete "github.com/ActiveMemory/ctx/internal/cli/task/core/complete" + "github.com/ActiveMemory/ctx/internal/trace" + writeComplete "github.com/ActiveMemory/ctx/internal/write/complete" ) -// Complete finds a task in TASKS.md by number or text match and marks -// it complete by changing "- [ ]" to "- [x]". -// -// Parameters: -// - query: Task number (e.g. "1") or search text to match -// - contextDir: Path to .context/ directory; if empty, uses rc.ContextDir() -// -// Returns: -// - string: The text of the completed task -// - error: Non-nil if the task is not found, multiple matches, or file -// operations fail -func Complete(query, contextDir string) (string, error) { - if contextDir == "" { - contextDir = rc.ContextDir() - } - - filePath := filepath.Join(contextDir, ctx.Task) - - // Check if the file exists - if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) { - return "", errTask.FileNotFound() - } - - // Read existing content - content, readErr := os.ReadFile(filepath.Clean(filePath)) - if readErr != nil { - return "", errTask.FileRead(readErr) - } - - // Parse tasks and find matching one - lines := strings.Split(string(content), token.NewlineLF) - - var taskNumber int - isNumber := false - if num, parseErr := strconv.Atoi(query); parseErr == nil { - taskNumber = num - isNumber = true - } - - currentTaskNum := 0 - matchedLine := -1 - matchedTask := "" - - for i, line := range lines { - match := regex.Task.FindStringSubmatch(line) - if match != nil && task.Pending(match) { - currentTaskNum++ - taskText := task.Content(match) - - // Match by number - if isNumber && currentTaskNum == taskNumber { - matchedLine = i - matchedTask = taskText - break - } - - // Match by text (case-insensitive partial match) - if !isNumber && strings.Contains( - strings.ToLower(taskText), strings.ToLower(query), - ) { - if matchedLine != -1 { - return "", errTask.MultipleMatches(query) - } - matchedLine = i - matchedTask = taskText - } - } - } - - if matchedLine == -1 { - return "", errTask.NotFound(query) - } - - // Mark the task as complete - lines[matchedLine] = regex.Task.ReplaceAllString( - lines[matchedLine], regex.TaskCompleteReplace, - ) - - // Write back - newContent := strings.Join(lines, token.NewlineLF) - if writeErr := os.WriteFile( - filePath, []byte(newContent), fs.PermFile, - ); writeErr != nil { - return "", errTask.FileWrite(writeErr) - } - - return matchedTask, nil -} - // Run executes the complete command logic. // // Parameters: @@ -122,12 +26,15 @@ func Complete(query, contextDir string) (string, error) { // Returns: // - error: Non-nil on task match or write failure func Run(cmd *cobra.Command, args []string) error { - matchedTask, completeErr := Complete(args[0], "") + matchedTask, matchedNum, completeErr := coreComplete.Complete(args[0], "") if completeErr != nil { return completeErr } - complete.Completed(cmd, matchedTask) + writeComplete.Completed(cmd, matchedTask) + + // Best-effort: record pending context for commit tracing. + _ = trace.Record(fmt.Sprintf("task:%d", matchedNum), state.Dir()) return nil } diff --git a/internal/cli/task/cmd/snapshot/run.go b/internal/cli/task/cmd/snapshot/run.go index 612704f83..56ac5e26f 100644 --- a/internal/cli/task/cmd/snapshot/run.go +++ b/internal/cli/task/cmd/snapshot/run.go @@ -20,6 +20,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/token" "github.com/ActiveMemory/ctx/internal/err/backup" errTask "github.com/ActiveMemory/ctx/internal/err/task" + "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/sanitize" writeArchive "github.com/ActiveMemory/ctx/internal/write/archive" ) @@ -73,7 +74,7 @@ func Run(cmd *cobra.Command, args []string) error { ) // Write snapshot - if writeErr := os.WriteFile( + if writeErr := io.SafeWriteFile( snapshotPath, []byte(snapshotContent), fs.PermFile, ); writeErr != nil { return errTask.SnapshotWrite(writeErr) diff --git a/internal/cli/task/core/complete/complete.go b/internal/cli/task/core/complete/complete.go new file mode 100644 index 000000000..721a5cce0 --- /dev/null +++ b/internal/cli/task/core/complete/complete.go @@ -0,0 +1,116 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package complete + +import ( + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/ActiveMemory/ctx/internal/config/ctx" + "github.com/ActiveMemory/ctx/internal/config/fs" + "github.com/ActiveMemory/ctx/internal/config/regex" + "github.com/ActiveMemory/ctx/internal/config/token" + errTask "github.com/ActiveMemory/ctx/internal/err/task" + "github.com/ActiveMemory/ctx/internal/io" + "github.com/ActiveMemory/ctx/internal/rc" + "github.com/ActiveMemory/ctx/internal/task" +) + +// Complete finds a task in TASKS.md by number or text match and marks +// it complete by changing "- [ ]" to "- [x]". +// +// Parameters: +// - query: Task number (e.g. "1") or search text to match +// - contextDir: Path to .context/ directory; if empty, uses rc.ContextDir() +// +// Returns: +// - string: The text of the completed task +// - int: The 1-based task number that was matched +// - error: Non-nil if the task is not found, multiple matches, or file +// operations fail +func Complete(query, contextDir string) (string, int, error) { + if contextDir == "" { + contextDir = rc.ContextDir() + } + + filePath := filepath.Join(contextDir, ctx.Task) + + // Check if the file exists + if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) { + return "", 0, errTask.FileNotFound() + } + + // Read existing content + content, readErr := os.ReadFile(filepath.Clean(filePath)) + if readErr != nil { + return "", 0, errTask.FileRead(readErr) + } + + // Parse tasks and find matching one + lines := strings.Split(string(content), token.NewlineLF) + + var taskNumber int + isNumber := false + if num, parseErr := strconv.Atoi(query); parseErr == nil { + taskNumber = num + isNumber = true + } + + currentTaskNum := 0 + matchedLine := -1 + matchedTask := "" + matchedNum := 0 + + for i, line := range lines { + match := regex.Task.FindStringSubmatch(line) + if match != nil && task.Pending(match) { + currentTaskNum++ + taskText := task.Content(match) + + // Match by number + if isNumber && currentTaskNum == taskNumber { + matchedLine = i + matchedTask = taskText + matchedNum = currentTaskNum + break + } + + // Match by text (case-insensitive partial match) + if !isNumber && strings.Contains( + strings.ToLower(taskText), strings.ToLower(query), + ) { + if matchedLine != -1 { + return "", 0, errTask.MultipleMatches(query) + } + matchedLine = i + matchedTask = taskText + matchedNum = currentTaskNum + } + } + } + + if matchedLine == -1 { + return "", 0, errTask.NotFound(query) + } + + // Mark the task as complete + lines[matchedLine] = regex.Task.ReplaceAllString( + lines[matchedLine], regex.TaskCompleteReplace, + ) + + // Write back + newContent := strings.Join(lines, token.NewlineLF) + if writeErr := io.SafeWriteFile( + filePath, []byte(newContent), fs.PermFile, + ); writeErr != nil { + return "", 0, errTask.FileWrite(writeErr) + } + + return matchedTask, matchedNum, nil +} diff --git a/internal/cli/task/core/complete/doc.go b/internal/cli/task/core/complete/doc.go new file mode 100644 index 000000000..d4b3e6c98 --- /dev/null +++ b/internal/cli/task/core/complete/doc.go @@ -0,0 +1,12 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package complete provides task completion logic: find and mark tasks done. +// +// Key exports: [Complete]. +// Shared helpers used by sibling cmd/ packages. +// Used by core cmd/ packages. +package complete diff --git a/internal/cli/trace/cmd/collect/cmd.go b/internal/cli/trace/cmd/collect/cmd.go new file mode 100644 index 000000000..cb62c8ea2 --- /dev/null +++ b/internal/cli/trace/cmd/collect/cmd.go @@ -0,0 +1,42 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package collect + +import ( + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + coreCollect "github.com/ActiveMemory/ctx/internal/cli/trace/core/collect" + "github.com/ActiveMemory/ctx/internal/config/embed/cmd" + "github.com/ActiveMemory/ctx/internal/config/embed/flag" + cFlag "github.com/ActiveMemory/ctx/internal/config/flag" + "github.com/ActiveMemory/ctx/internal/flagbind" +) + +// Cmd returns the trace collect subcommand. +// +// Returns: +// - *cobra.Command: Configured trace collect command with flags registered +func Cmd() *cobra.Command { + var record string + short, long := desc.Command(cmd.DescKeyTraceCollect) + c := &cobra.Command{ + Use: cmd.UseTraceCollect, + Short: short, + Long: long, + Hidden: true, + Args: cobra.ExactArgs(0), + RunE: func(cobraCmd *cobra.Command, _ []string) error { + if record != "" { + return coreCollect.RecordCommit(record) + } + return Run(cobraCmd) + }, + } + flagbind.StringFlag(c, &record, cFlag.Record, flag.DescKeyTraceCollectRecord) + return c +} diff --git a/internal/cli/trace/cmd/collect/run.go b/internal/cli/trace/cmd/collect/run.go new file mode 100644 index 000000000..47c8ef7c3 --- /dev/null +++ b/internal/cli/trace/cmd/collect/run.go @@ -0,0 +1,31 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package collect + +import ( + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/rc" + "github.com/ActiveMemory/ctx/internal/trace" +) + +// Run collects context refs from all sources and outputs the trailer to stdout. +// +// Parameters: +// - cmd: Cobra command for output stream +// +// Returns: +// - error: non-nil on execution failure +func Run(cmd *cobra.Command) error { + contextDir := rc.ContextDir() + refs := trace.Collect(contextDir) + trailer := trace.FormatTrailer(refs) + if trailer != "" { + cmd.Println(trailer) + } + return nil +} diff --git a/internal/cli/trace/cmd/file/cmd.go b/internal/cli/trace/cmd/file/cmd.go new file mode 100644 index 000000000..3704eac79 --- /dev/null +++ b/internal/cli/trace/cmd/file/cmd.go @@ -0,0 +1,42 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package file + +import ( + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + "github.com/ActiveMemory/ctx/internal/config/embed/cmd" + "github.com/ActiveMemory/ctx/internal/config/embed/flag" + cFlag "github.com/ActiveMemory/ctx/internal/config/flag" + cfgTrace "github.com/ActiveMemory/ctx/internal/config/trace" + "github.com/ActiveMemory/ctx/internal/flagbind" +) + +// Cmd returns the trace file subcommand. +// +// Returns: +// - *cobra.Command: Configured trace file command with flags registered +func Cmd() *cobra.Command { + var last int + short, long := desc.Command(cmd.DescKeyTraceFile) + c := &cobra.Command{ + Use: cmd.UseTraceFile, + Short: short, + Long: long, + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return Run(cobraCmd, args[0], last) + }, + } + flagbind.IntFlagP( + c, &last, + cFlag.Last, cFlag.ShortLast, + cfgTrace.DefaultLastFile, flag.DescKeyTraceFileLast, + ) + return c +} diff --git a/internal/cli/trace/cmd/file/run.go b/internal/cli/trace/cmd/file/run.go new file mode 100644 index 000000000..86ba03dc0 --- /dev/null +++ b/internal/cli/trace/cmd/file/run.go @@ -0,0 +1,40 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package file + +import ( + "path/filepath" + + "github.com/spf13/cobra" + + coreFile "github.com/ActiveMemory/ctx/internal/cli/trace/core/file" + "github.com/ActiveMemory/ctx/internal/config/dir" + "github.com/ActiveMemory/ctx/internal/rc" +) + +// Run executes the trace file command logic. +// +// Parses the pathArg into a file path (stripping any line-range suffix), +// then runs git log to retrieve commits touching that file. For each +// commit, context refs are collected from history and overrides and +// printed as a table. +// +// Parameters: +// - cmd: Cobra command for output stream +// - pathArg: file path with optional line range suffix (e.g. "src/auth.go:42-60") +// - last: maximum number of commits to show +// +// Returns: +// - error: non-nil on execution failure +func Run(cmd *cobra.Command, pathArg string, last int) error { + contextDir := rc.ContextDir() + traceDir := filepath.Join(contextDir, dir.Trace) + + filePath := coreFile.ParsePathArg(pathArg) + + return coreFile.TraceFile(cmd, filePath, last, traceDir) +} diff --git a/internal/cli/trace/cmd/hook/cmd.go b/internal/cli/trace/cmd/hook/cmd.go new file mode 100644 index 000000000..a9abb7216 --- /dev/null +++ b/internal/cli/trace/cmd/hook/cmd.go @@ -0,0 +1,32 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package hook + +import ( + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + "github.com/ActiveMemory/ctx/internal/config/embed/cmd" +) + +// Cmd returns the trace hook subcommand. +// +// Returns: +// - *cobra.Command: Configured trace hook command +func Cmd() *cobra.Command { + short, long := desc.Command(cmd.DescKeyTraceHook) + c := &cobra.Command{ + Use: cmd.UseTraceHook, + Short: short, + Long: long, + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return Run(cobraCmd, args[0]) + }, + } + return c +} diff --git a/internal/cli/trace/cmd/hook/run.go b/internal/cli/trace/cmd/hook/run.go new file mode 100644 index 000000000..a44a40cc1 --- /dev/null +++ b/internal/cli/trace/cmd/hook/run.go @@ -0,0 +1,34 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package hook + +import ( + "github.com/spf13/cobra" + + coreHook "github.com/ActiveMemory/ctx/internal/cli/trace/core/hook" + cfgTrace "github.com/ActiveMemory/ctx/internal/config/trace" + errTrace "github.com/ActiveMemory/ctx/internal/err/trace" +) + +// Run executes the hook enable or disable action. +// +// Parameters: +// - cmd: Cobra command for output stream +// - action: "enable" or "disable" +// +// Returns: +// - error: non-nil on unknown action or execution failure +func Run(cmd *cobra.Command, action string) error { + switch action { + case cfgTrace.ActionEnable: + return coreHook.Enable(cmd) + case cfgTrace.ActionDisable: + return coreHook.Disable(cmd) + default: + return errTrace.UnknownAction(action) + } +} diff --git a/internal/cli/trace/cmd/show/cmd.go b/internal/cli/trace/cmd/show/cmd.go new file mode 100644 index 000000000..e50b57a56 --- /dev/null +++ b/internal/cli/trace/cmd/show/cmd.go @@ -0,0 +1,45 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package show + +import ( + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + "github.com/ActiveMemory/ctx/internal/config/embed/cmd" + "github.com/ActiveMemory/ctx/internal/config/embed/flag" + cFlag "github.com/ActiveMemory/ctx/internal/config/flag" + "github.com/ActiveMemory/ctx/internal/flagbind" +) + +// Cmd returns the trace command. +// +// Returns: +// - *cobra.Command: Configured trace command with flags registered +func Cmd() *cobra.Command { + var ( + last int + jsonOutput bool + ) + + short, long := desc.Command(cmd.DescKeyTrace) + + c := &cobra.Command{ + Use: cmd.UseTrace, + Short: short, + Long: long, + Args: cobra.MaximumNArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return Run(cobraCmd, args, last, jsonOutput) + }, + } + + flagbind.IntFlagP(c, &last, cFlag.Last, cFlag.ShortLast, 0, flag.DescKeyTraceLast) + flagbind.BoolFlag(c, &jsonOutput, cFlag.JSON, flag.DescKeyTraceJSON) + + return c +} diff --git a/internal/cli/trace/cmd/show/run.go b/internal/cli/trace/cmd/show/run.go new file mode 100644 index 000000000..fc2aef80c --- /dev/null +++ b/internal/cli/trace/cmd/show/run.go @@ -0,0 +1,49 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package show + +import ( + "path/filepath" + + "github.com/spf13/cobra" + + coreShow "github.com/ActiveMemory/ctx/internal/cli/trace/core/show" + "github.com/ActiveMemory/ctx/internal/config/dir" + cfgTrace "github.com/ActiveMemory/ctx/internal/config/trace" + "github.com/ActiveMemory/ctx/internal/rc" +) + +// Run executes the trace command logic. +// +// If last > 0, shows context for the last N commits. +// If no args are given, defaults to showing the last DefaultLastShow commits. +// Otherwise shows context for the specific commit hash in args[0]. +// +// Parameters: +// - cmd: Cobra command for output stream +// - args: positional arguments (optional commit hash) +// - last: number of recent commits to show (0 = use args or default) +// - jsonOutput: whether to format output as JSON +// +// Returns: +// - error: non-nil on execution failure +func Run(cmd *cobra.Command, args []string, last int, jsonOutput bool) error { + contextDir := rc.ContextDir() + traceDir := filepath.Join(contextDir, dir.Trace) + + if last > 0 { + return coreShow.Last(cmd, last, contextDir, traceDir, jsonOutput) + } + + if len(args) == 0 { + return coreShow.Last(cmd, + cfgTrace.DefaultLastShow, contextDir, traceDir, jsonOutput, + ) + } + + return coreShow.Commit(cmd, args[0], contextDir, traceDir, jsonOutput) +} diff --git a/internal/cli/trace/cmd/tag/cmd.go b/internal/cli/trace/cmd/tag/cmd.go new file mode 100644 index 000000000..a135e9c2a --- /dev/null +++ b/internal/cli/trace/cmd/tag/cmd.go @@ -0,0 +1,38 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package tag + +import ( + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + "github.com/ActiveMemory/ctx/internal/config/embed/cmd" + "github.com/ActiveMemory/ctx/internal/config/embed/flag" + cFlag "github.com/ActiveMemory/ctx/internal/config/flag" + "github.com/ActiveMemory/ctx/internal/flagbind" +) + +// Cmd returns the trace tag subcommand. +// +// Returns: +// - *cobra.Command: Configured trace tag command with flags registered +func Cmd() *cobra.Command { + var note string + short, long := desc.Command(cmd.DescKeyTraceTag) + c := &cobra.Command{ + Use: cmd.UseTraceTag, + Short: short, + Long: long, + Args: cobra.ExactArgs(1), + RunE: func(cobraCmd *cobra.Command, args []string) error { + return Run(cobraCmd, args[0], note) + }, + } + flagbind.StringFlag(c, ¬e, cFlag.Note, flag.DescKeyTraceTagNote) + _ = c.MarkFlagRequired(cFlag.Note) + return c +} diff --git a/internal/cli/trace/cmd/tag/run.go b/internal/cli/trace/cmd/tag/run.go new file mode 100644 index 000000000..b024529d2 --- /dev/null +++ b/internal/cli/trace/cmd/tag/run.go @@ -0,0 +1,57 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package tag + +import ( + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/config/dir" + errTrace "github.com/ActiveMemory/ctx/internal/err/trace" + "github.com/ActiveMemory/ctx/internal/rc" + "github.com/ActiveMemory/ctx/internal/trace" + writeTrace "github.com/ActiveMemory/ctx/internal/write/trace" +) + +// Run executes the trace tag command logic. +// +// Resolves commitRef to a full hash, attaches the note as an override entry, +// and writes it to the trace directory via trace.WriteOverride. +// +// Parameters: +// - cmd: Cobra command for output stream +// - commitRef: commit ref or hash to tag (e.g. "HEAD", "abc1234") +// - note: context note to attach to the commit +// +// Returns: +// - error: non-nil on execution failure or empty note +func Run(cmd *cobra.Command, commitRef, note string) error { + if note == "" { + return errTrace.NoteRequired() + } + + hash, err := trace.ResolveCommitHash(commitRef) + if err != nil { + return errTrace.ResolveCommit(commitRef, err) + } + + traceDir := filepath.Join(rc.ContextDir(), dir.Trace) + + entry := trace.OverrideEntry{ + Commit: hash, + Refs: []string{fmt.Sprintf("%q", note)}, + } + + if err := trace.WriteOverride(entry, traceDir); err != nil { + return errTrace.WriteOverride(err) + } + + writeTrace.Tagged(cmd, trace.ShortHash(hash), note) + return nil +} diff --git a/internal/cli/trace/core/collect/collect.go b/internal/cli/trace/core/collect/collect.go new file mode 100644 index 000000000..aae72d3a4 --- /dev/null +++ b/internal/cli/trace/core/collect/collect.go @@ -0,0 +1,69 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package collect + +import ( + "path/filepath" + + "github.com/ActiveMemory/ctx/internal/config/dir" + errTrace "github.com/ActiveMemory/ctx/internal/err/trace" + "github.com/ActiveMemory/ctx/internal/rc" + "github.com/ActiveMemory/ctx/internal/trace" +) + +// RecordCommit records context refs for a specific commit hash to history. +// +// Called from the post-commit hook after a commit is made. Reads refs from +// the commit trailer (not re-collected — the trailer is the single source +// of truth), writes a history entry, and truncates pending state. +// +// Pending context is always consumed (truncated) per commit, even when no +// hook ran and the trailer is empty. This prevents stale refs from leaking +// into future commits. +// +// Parameters: +// - commitHash: full commit hash to record context for +// +// Returns: +// - error: non-nil on execution failure +func RecordCommit(commitHash string) error { + contextDir := rc.ContextDir() + + // Read refs from the commit trailer — single source of truth. + // This matches exactly what was injected by the prepare-commit-msg hook. + refs := trace.ReadTrailerRefs(commitHash) + if len(refs) == 0 { + // No trailer found — the commit was made without the + // prepare-commit-msg hook (e.g. --no-verify, external tool, + // or hook not installed). Pending refs are still truncated + // because they were accumulated for *this* commit window; + // keeping them would attach stale context to the next commit. + stateDir := filepath.Join(contextDir, dir.State) + _ = trace.TruncatePending(stateDir) + return nil + } + + message, err := trace.CommitMessage(commitHash) + if err != nil { + return errTrace.GitLog(err) + } + + traceDir := filepath.Join(contextDir, dir.Trace) + entry := trace.HistoryEntry{ + Commit: commitHash, + Refs: refs, + Message: message, + } + if err := trace.WriteHistory(entry, traceDir); err != nil { + return errTrace.WriteHistory(err) + } + + stateDir := filepath.Join(contextDir, dir.State) + _ = trace.TruncatePending(stateDir) + + return nil +} diff --git a/internal/cli/trace/core/file/file.go b/internal/cli/trace/core/file/file.go new file mode 100644 index 000000000..fe212aa33 --- /dev/null +++ b/internal/cli/trace/core/file/file.go @@ -0,0 +1,114 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package file + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + "github.com/ActiveMemory/ctx/internal/config/embed/text" + cfgGit "github.com/ActiveMemory/ctx/internal/config/git" + "github.com/ActiveMemory/ctx/internal/config/token" + errTrace "github.com/ActiveMemory/ctx/internal/err/trace" + "github.com/ActiveMemory/ctx/internal/exec/git" + "github.com/ActiveMemory/ctx/internal/trace" + writeTrace "github.com/ActiveMemory/ctx/internal/write/trace" +) + +// ParsePathArg strips an optional :line-range suffix from a path argument +// so git log gets the clean file path. +// +// Examples: +// +// "src/auth.go:42-60" → "src/auth.go" +// "src/auth.go:42" → "src/auth.go" +// "src/auth.go" → "src/auth.go" +// "src/auth.go:latest" → "src/auth.go:latest" +// +// Parameters: +// - arg: combined path and optional line range argument +// +// Returns: +// - string: file path with line range stripped +func ParsePathArg(arg string) string { + idx := strings.LastIndex(arg, ":") + if idx < 0 { + return arg + } + suffix := arg[idx+1:] + // Check if suffix looks like a line range (digits or digits-digits) + parts := strings.SplitN(suffix, "-", 2) + for _, p := range parts { + for _, c := range p { + if c < '0' || c > '9' { + return arg // not a line range, return as-is + } + } + if p == "" { + return arg + } + } + return arg[:idx] +} + +// TraceFile runs git log to retrieve commits touching the given file and +// prints context refs for each commit. +// +// Parameters: +// - cmd: Cobra command for output stream +// - filePath: clean file path (no line-range suffix) +// - last: maximum number of commits to show +// - traceDir: absolute path to the trace directory +// +// Returns: +// - error: non-nil on execution failure +func TraceFile(cmd *cobra.Command, filePath string, last int, traceDir string) error { + out, err := git.Run( + cfgGit.Log, fmt.Sprintf("-%d", last), + cfgGit.FormatHashDateSubj, + cfgGit.FlagPathSep, filePath, + ) + if err != nil { + return errTrace.GitLog(err) + } + + lines := strings.Split(strings.TrimSpace(string(out)), token.NewlineLF) + + for _, line := range lines { + if line == "" { + continue + } + // format: + parts := strings.SplitN(line, " ", 3) + if len(parts) < 1 { + continue + } + hash := parts[0] + date := "" + if len(parts) > 1 { + date = parts[1] + } + subject := "" + if len(parts) > 2 { + subject = parts[2] + } + + refs := trace.CollectRefsForCommit(hash, traceDir, false) + refStr := desc.Text(text.DescKeyWriteTraceNoRefs) + if len(refs) > 0 { + refStr = desc.Text(text.DescKeyWriteTraceRefsPrefix) + + strings.Join(refs, token.CommaSpace) + } + + writeTrace.FileEntry(cmd, trace.ShortHash(hash), date, subject, refStr) + } + + return nil +} diff --git a/internal/cli/trace/core/hook/hook.go b/internal/cli/trace/core/hook/hook.go new file mode 100644 index 000000000..cf05f62aa --- /dev/null +++ b/internal/cli/trace/core/hook/hook.go @@ -0,0 +1,146 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package hook + +import ( + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + readHook "github.com/ActiveMemory/ctx/internal/assets/read/hook" + cfgFs "github.com/ActiveMemory/ctx/internal/config/fs" + cfgGit "github.com/ActiveMemory/ctx/internal/config/git" + cfgTrace "github.com/ActiveMemory/ctx/internal/config/trace" + errTrace "github.com/ActiveMemory/ctx/internal/err/trace" + "github.com/ActiveMemory/ctx/internal/exec/git" + "github.com/ActiveMemory/ctx/internal/io" + writeTrace "github.com/ActiveMemory/ctx/internal/write/trace" +) + +// Enable installs both the prepare-commit-msg and post-commit hooks. +// +// Parameters: +// - cmd: Cobra command for output stream +// +// Returns: +// - error: non-nil on installation failure +func Enable(cmd *cobra.Command) error { + prepScript, prepReadErr := readHook.TraceScript(cfgTrace.ScriptPrepareCommitMsg) + if prepReadErr != nil { + return prepReadErr + } + prepPath, prepErr := FilePath(cfgGit.HookPrepareCommitMsg) + if prepErr != nil { + return prepErr + } + if installErr := Install( + prepPath, prepScript, cfgGit.HookPrepareCommitMsg, + ); installErr != nil { + return installErr + } + + postScript, postReadErr := readHook.TraceScript(cfgTrace.ScriptPostCommit) + if postReadErr != nil { + return postReadErr + } + postPath, postErr := FilePath(cfgGit.HookPostCommit) + if postErr != nil { + return postErr + } + if installErr := Install( + postPath, postScript, cfgGit.HookPostCommit, + ); installErr != nil { + return installErr + } + + writeTrace.HooksEnabled(cmd) + return nil +} + +// Disable removes both the prepare-commit-msg and post-commit hooks if they +// were installed by ctx. +// +// Parameters: +// - cmd: Cobra command for output stream +// +// Returns: +// - error: non-nil on removal failure +func Disable(cmd *cobra.Command) error { + prepPath, err := FilePath(cfgGit.HookPrepareCommitMsg) + if err != nil { + return err + } + Remove(prepPath) + + postPath, err := FilePath(cfgGit.HookPostCommit) + if err != nil { + return err + } + Remove(postPath) + + writeTrace.HooksDisabled(cmd) + return nil +} + +// Install writes the hook script to path, checking for existing non-ctx hooks. +// +// Parameters: +// - path: absolute path to the hook file +// - script: hook script content to write +// - name: hook name for error messages +// +// Returns: +// - error: non-nil if a non-ctx hook already exists or write fails +func Install(path, script, name string) error { + if _, err := io.SafeStat(path); err == nil { + existing, readErr := io.SafeReadUserFile(path) + if readErr == nil && !strings.Contains( + string(existing), cfgTrace.CtxTraceMarker, + ) { + return errTrace.HookExists(name, path) + } + } + if err := io.SafeWriteFile( + path, []byte(script), cfgFs.PermExec, + ); err != nil { + return errTrace.HookWrite(name, err) + } + return nil +} + +// Remove removes the hook at path if it was installed by ctx. +// +// Parameters: +// - path: absolute path to the hook file +func Remove(path string) { + existing, err := io.SafeReadUserFile(path) + if err != nil { + return + } + if strings.Contains(string(existing), cfgTrace.CtxTraceMarker) { + _ = os.Remove(path) + } +} + +// FilePath returns the absolute path to a git hook by name. +// +// Parameters: +// - hookName: name of the git hook (e.g. "prepare-commit-msg") +// +// Returns: +// - string: absolute path to the hook file +// - error: non-nil if git rev-parse fails +func FilePath(hookName string) (string, error) { + out, err := git.Run(cfgGit.RevParse, cfgGit.FlagGitDir) + if err != nil { + return "", errTrace.GitDir(err) + } + gitDir := strings.TrimSpace(string(out)) + return filepath.Join(gitDir, cfgGit.HooksDir, hookName), nil +} diff --git a/internal/cli/trace/core/show/show.go b/internal/cli/trace/core/show/show.go new file mode 100644 index 000000000..327eeab28 --- /dev/null +++ b/internal/cli/trace/core/show/show.go @@ -0,0 +1,170 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package show + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + "github.com/ActiveMemory/ctx/internal/config/embed/text" + cfgGit "github.com/ActiveMemory/ctx/internal/config/git" + "github.com/ActiveMemory/ctx/internal/config/token" + errTrace "github.com/ActiveMemory/ctx/internal/err/trace" + "github.com/ActiveMemory/ctx/internal/exec/git" + "github.com/ActiveMemory/ctx/internal/trace" + writeTrace "github.com/ActiveMemory/ctx/internal/write/trace" +) + +// Commit displays the context refs for a single commit. +// +// Parameters: +// - cmd: Cobra command for output stream +// - hash: full or abbreviated commit hash +// - contextDir: absolute path to the context directory +// - traceDir: absolute path to the trace directory +// - jsonOutput: whether to format output as JSON +// +// Returns: +// - error: non-nil on execution failure +func Commit( + cmd *cobra.Command, hash, contextDir, traceDir string, jsonOutput bool, +) error { + fullHash, err := trace.ResolveCommitHash(hash) + if err != nil { + fullHash = hash + } + + refs := trace.CollectRefsForCommit(fullHash, traceDir, true) + + if jsonOutput { + msg, _ := trace.CommitMessage(fullHash) + out := JSONCommit{ + Commit: trace.ShortHash(fullHash), + Message: msg, + Refs: ResolveToJSON(refs, contextDir), + } + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(out) + } + + msg, _ := trace.CommitMessage(fullHash) + date := trace.CommitDate(fullHash) + writeTrace.CommitHeader(cmd, trace.ShortHash(fullHash), msg, date) + + if len(refs) == 0 { + writeTrace.CommitNoContext(cmd) + return nil + } + + writeTrace.CommitContext(cmd) + for _, r := range refs { + rr := trace.Resolve(r, contextDir) + writeTrace.Resolved(cmd, rr) + } + + return nil +} + +// Last displays context refs for the last N commits. +// +// Parameters: +// - cmd: Cobra command for output stream +// - n: number of commits to show +// - contextDir: absolute path to the context directory +// - traceDir: absolute path to the trace directory +// - jsonOutput: whether to format output as JSON +// +// Returns: +// - error: non-nil on execution failure +func Last( + cmd *cobra.Command, n int, contextDir, traceDir string, jsonOutput bool, +) error { + out, err := git.Run( + cfgGit.Log, fmt.Sprintf("-%d", n), cfgGit.FormatHashSubj, + ) + if err != nil { + return errTrace.GitLog(err) + } + + lines := strings.Split(strings.TrimSpace(string(out)), token.NewlineLF) + + // Bulk listing uses includeTrailers=false: history.jsonl already + // contains the same refs the post-commit hook read from the trailer, + // so re-reading trailers would spawn N extra git processes for no gain. + if jsonOutput { + commits := make([]JSONCommit, 0, len(lines)) + for _, line := range lines { + if line == "" { + continue + } + parts := strings.SplitN(line, " ", 2) + hash := parts[0] + msg := "" + if len(parts) > 1 { + msg = parts[1] + } + refs := trace.CollectRefsForCommit(hash, traceDir, false) + commits = append(commits, JSONCommit{ + Commit: trace.ShortHash(hash), + Message: msg, + Refs: ResolveToJSON(refs, contextDir), + }) + } + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(commits) + } + + for _, line := range lines { + if line == "" { + continue + } + parts := strings.SplitN(line, " ", 2) + hash := parts[0] + msg := "" + if len(parts) > 1 { + msg = parts[1] + } + refs := trace.CollectRefsForCommit(hash, traceDir, false) + refSummary := desc.Text(text.DescKeyWriteTraceNoRefs) + if len(refs) > 0 { + refSummary = strings.Join(refs, token.CommaSpace) + } + writeTrace.LastEntry(cmd, trace.ShortHash(hash), msg, refSummary) + } + + return nil +} + +// ResolveToJSON converts a slice of raw refs to their JSON representations. +// +// Parameters: +// - refs: raw context reference strings +// - contextDir: absolute path to the context directory +// +// Returns: +// - []JSONRef: resolved references ready for JSON encoding +func ResolveToJSON(refs []string, contextDir string) []JSONRef { + resolved := make([]JSONRef, 0, len(refs)) + for _, r := range refs { + rr := trace.Resolve(r, contextDir) + resolved = append(resolved, JSONRef{ + Raw: rr.Raw, + Type: rr.Type, + Number: rr.Number, + Title: rr.Title, + Detail: rr.Detail, + Found: rr.Found, + }) + } + return resolved +} diff --git a/internal/cli/trace/core/show/types.go b/internal/cli/trace/core/show/types.go new file mode 100644 index 000000000..1ae9847b0 --- /dev/null +++ b/internal/cli/trace/core/show/types.go @@ -0,0 +1,24 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package show + +// JSONRef represents a resolved context reference for JSON output. +type JSONRef struct { + Raw string `json:"raw"` + Type string `json:"type"` + Number int `json:"number,omitempty"` + Title string `json:"title,omitempty"` + Detail string `json:"detail,omitempty"` + Found bool `json:"found"` +} + +// JSONCommit represents a commit with its context refs for JSON output. +type JSONCommit struct { + Commit string `json:"commit"` + Message string `json:"message"` + Refs []JSONRef `json:"refs"` +} diff --git a/internal/cli/trace/doc.go b/internal/cli/trace/doc.go new file mode 100644 index 000000000..0684d877d --- /dev/null +++ b/internal/cli/trace/doc.go @@ -0,0 +1,12 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package trace implements the ctx trace command for commit context tracing. +// +// Key exports: [Cmd]. +// See source files for implementation details. +// Part of the cli subsystem. +package trace diff --git a/internal/cli/trace/trace.go b/internal/cli/trace/trace.go new file mode 100644 index 000000000..e85a5a0ba --- /dev/null +++ b/internal/cli/trace/trace.go @@ -0,0 +1,30 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/cli/trace/cmd/collect" + "github.com/ActiveMemory/ctx/internal/cli/trace/cmd/file" + "github.com/ActiveMemory/ctx/internal/cli/trace/cmd/hook" + "github.com/ActiveMemory/ctx/internal/cli/trace/cmd/show" + "github.com/ActiveMemory/ctx/internal/cli/trace/cmd/tag" +) + +// Cmd returns the trace command with all registered subcommands. +// +// Returns: +// - *cobra.Command: The trace command +func Cmd() *cobra.Command { + c := show.Cmd() + c.AddCommand(collect.Cmd()) + c.AddCommand(file.Cmd()) + c.AddCommand(hook.Cmd()) + c.AddCommand(tag.Cmd()) + return c +} diff --git a/internal/cli/trace/trace_test.go b/internal/cli/trace/trace_test.go new file mode 100644 index 000000000..9237300f6 --- /dev/null +++ b/internal/cli/trace/trace_test.go @@ -0,0 +1,116 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/ActiveMemory/ctx/internal/cli/initialize" + "github.com/ActiveMemory/ctx/internal/trace" +) + +func TestTraceTagAndShow(t *testing.T) { + tmpDir := t.TempDir() + + origDir, _ := os.Getwd() + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Init git repo + run(t, "git", "init") + run(t, "git", "config", "user.email", "test@test.com") + run(t, "git", "config", "user.name", "Test") + + // Init ctx + initCmd := initialize.Cmd() + initCmd.SetArgs([]string{}) + if err := initCmd.Execute(); err != nil { + t.Fatalf("init: %v", err) + } + + // Create a file and commit + if err := os.WriteFile("test.go", []byte("package main\n"), 0644); err != nil { + t.Fatal(err) + } + run(t, "git", "add", ".") + run(t, "git", "commit", "-m", "Initial commit") + + // Record some pending context + stateDir := filepath.Join(".context", "state") + if err := os.MkdirAll(stateDir, 0750); err != nil { + t.Fatal(err) + } + _ = trace.Record("decision:1", stateDir) + + // Write history for the commit + traceDir := filepath.Join(".context", "trace") + hash := strings.TrimSpace(runOutput(t, "git", "rev-parse", "HEAD")) + + histErr := trace.WriteHistory(trace.HistoryEntry{ + Commit: hash, + Refs: []string{"decision:1"}, + Message: "Initial commit", + }, traceDir) + if histErr != nil { + t.Fatalf("WriteHistory: %v", histErr) + } + + // Test ctx trace — should not error + showCmd := Cmd() + showCmd.SetArgs([]string{hash[:7]}) + if showErr := showCmd.Execute(); showErr != nil { + t.Errorf("trace show failed: %v", showErr) + } + + // Test ctx trace --last 5 + lastCmd := Cmd() + lastCmd.SetArgs([]string{"--last", "5"}) + if lastErr := lastCmd.Execute(); lastErr != nil { + t.Errorf("trace --last failed: %v", lastErr) + } + + // Test ctx trace tag + tagCmd := Cmd() + tagCmd.SetArgs([]string{"tag", "HEAD", "--note", "Test tag"}) + if tagErr := tagCmd.Execute(); tagErr != nil { + t.Errorf("trace tag failed: %v", tagErr) + } + + // Verify override was written + overrides, ovrErr := trace.ReadOverrides(traceDir) + if ovrErr != nil { + t.Fatalf("ReadOverrides: %v", ovrErr) + } + if len(overrides) != 1 { + t.Errorf("expected 1 override, got %d", len(overrides)) + } +} + +func run(t *testing.T, name string, args ...string) { + t.Helper() + //nolint:gosec // test helper, name is always "git" from test code + cmd := exec.Command(name, args...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s %v failed: %v\n%s", name, args, err, out) + } +} + +func runOutput(t *testing.T, name string, args ...string) string { + t.Helper() + //nolint:gosec // test helper, name is always "git" from test code + out, err := exec.Command(name, args...).Output() + if err != nil { + t.Fatalf("%s %v failed: %v", name, args, err) + } + return string(out) +} diff --git a/internal/cli/watch/core/apply/apply.go b/internal/cli/watch/core/apply/apply.go index 7af04d625..a03bee6a8 100644 --- a/internal/cli/watch/core/apply/apply.go +++ b/internal/cli/watch/core/apply/apply.go @@ -7,21 +7,9 @@ package apply import ( - "os" - "path/filepath" - "strings" - "github.com/ActiveMemory/ctx/internal/cli/watch/core" - "github.com/ActiveMemory/ctx/internal/config/ctx" cfgEntry "github.com/ActiveMemory/ctx/internal/config/entry" - "github.com/ActiveMemory/ctx/internal/config/fs" - "github.com/ActiveMemory/ctx/internal/config/regex" - "github.com/ActiveMemory/ctx/internal/config/token" - "github.com/ActiveMemory/ctx/internal/entry" "github.com/ActiveMemory/ctx/internal/err/config" - errTask "github.com/ActiveMemory/ctx/internal/err/task" - "github.com/ActiveMemory/ctx/internal/rc" - "github.com/ActiveMemory/ctx/internal/task" ) // Update routes a context update to the appropriate handler. @@ -39,104 +27,16 @@ import ( func Update(update core.ContextUpdate) error { switch update.Type { case cfgEntry.Task: - return RunAddSilent(update) + return addEntry(update) case cfgEntry.Decision: - return RunAddSilent(update) + return addEntry(update) case cfgEntry.Learning: - return RunAddSilent(update) + return addEntry(update) case cfgEntry.Convention: - return RunAddSilent(update) + return addEntry(update) case cfgEntry.Complete: - return RunCompleteSilent([]string{update.Content}) + return completeTask(update.Content) default: return config.UnknownUpdateType(update.Type) } } - -// RunAddSilent appends an entry to a context file without output. -// -// Used by the watch command to silently apply updates detected in -// the input stream. Uses shared validation and write logic from the -// add package to ensure consistent behavior with `ctx add`. -// -// Parameters: -// - update: The parsed ContextUpdate with type, content, and required -// structured fields (context, lesson, application for learnings; -// context, rationale, consequence for decisions) -// -// Returns: -// - error: Non-nil if validation fails, type is unknown, -// or file operations fail -func RunAddSilent(update core.ContextUpdate) error { - params := entry.Params{ - Type: update.Type, - Content: update.Content, - Context: update.Context, - Rationale: update.Rationale, - Consequence: update.Consequence, - Lesson: update.Lesson, - Application: update.Application, - } - - // Validate required fields (same as ctx add) - if validateErr := entry.Validate(params, nil); validateErr != nil { - return validateErr - } - - // Write using the shared function - // (handles formatting, append, and index update) - return entry.Write(params) -} - -// RunCompleteSilent marks a task as complete without output. -// -// Used by the watch command to silently complete tasks detected in -// the input stream. Searches for an unchecked task matching the query -// and marks it as done by changing [ ] to [x]. -// -// Parameters: -// - args: Slice where args[0] is the search query to match against -// task descriptions (case-insensitive substring match) -// -// Returns: -// - error: Non-nil if args is empty, no matching task is found, -// or file operations fail -func RunCompleteSilent(args []string) error { - if len(args) < 1 { - return errTask.NoneSpecified() - } - - query := args[0] - filePath := filepath.Join(rc.ContextDir(), ctx.Task) - nl := token.NewlineLF - - content, readErr := os.ReadFile(filepath.Clean(filePath)) - if readErr != nil { - return readErr - } - - lines := strings.Split(string(content), nl) - - matchedLine := -1 - for i, line := range lines { - match := regex.Task.FindStringSubmatch(line) - if match != nil && task.Pending(match) { - if strings.Contains( - strings.ToLower(task.Content(match)), - strings.ToLower(query), - ) { - matchedLine = i - break - } - } - } - - if matchedLine == -1 { - return errTask.NoMatch(query) - } - - lines[matchedLine] = regex.Task.ReplaceAllString( - lines[matchedLine], regex.TaskCompleteReplace, - ) - return os.WriteFile(filePath, []byte(strings.Join(lines, nl)), fs.PermFile) -} diff --git a/internal/cli/watch/core/apply/complete.go b/internal/cli/watch/core/apply/complete.go new file mode 100644 index 000000000..eb57af439 --- /dev/null +++ b/internal/cli/watch/core/apply/complete.go @@ -0,0 +1,74 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package apply + +import ( + "os" + "path/filepath" + "strings" + + "github.com/ActiveMemory/ctx/internal/config/ctx" + "github.com/ActiveMemory/ctx/internal/config/fs" + "github.com/ActiveMemory/ctx/internal/config/regex" + "github.com/ActiveMemory/ctx/internal/config/token" + errTask "github.com/ActiveMemory/ctx/internal/err/task" + "github.com/ActiveMemory/ctx/internal/io" + "github.com/ActiveMemory/ctx/internal/rc" + "github.com/ActiveMemory/ctx/internal/task" +) + +// completeTask marks a task as complete without output. +// +// Used by [Update] to silently complete tasks detected in the watch +// input stream. Searches for an unchecked task matching the query +// and marks it as done by changing [ ] to [x]. +// +// Parameters: +// - query: search text to match against task descriptions +// (case-insensitive substring match) +// +// Returns: +// - error: Non-nil if query is empty, no matching task is found, +// or file operations fail +func completeTask(query string) error { + if query == "" { + return errTask.NoneSpecified() + } + + filePath := filepath.Join(rc.ContextDir(), ctx.Task) + nl := token.NewlineLF + + content, readErr := os.ReadFile(filepath.Clean(filePath)) + if readErr != nil { + return readErr + } + + lines := strings.Split(string(content), nl) + + matchedLine := -1 + for i, line := range lines { + match := regex.Task.FindStringSubmatch(line) + if match != nil && task.Pending(match) { + if strings.Contains( + strings.ToLower(task.Content(match)), + strings.ToLower(query), + ) { + matchedLine = i + break + } + } + } + + if matchedLine == -1 { + return errTask.NoMatch(query) + } + + lines[matchedLine] = regex.Task.ReplaceAllString( + lines[matchedLine], regex.TaskCompleteReplace, + ) + return io.SafeWriteFile(filePath, []byte(strings.Join(lines, nl)), fs.PermFile) +} diff --git a/internal/cli/watch/core/apply/complete_test.go b/internal/cli/watch/core/apply/complete_test.go new file mode 100644 index 000000000..61f721664 --- /dev/null +++ b/internal/cli/watch/core/apply/complete_test.go @@ -0,0 +1,56 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package apply + +import ( + "os" + "strings" + "testing" + + "github.com/ActiveMemory/ctx/internal/cli/initialize" +) + +// TestCompleteTaskNoMatch tests complete with no matching task. +func TestCompleteTaskNoMatch(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "watch-nomatch-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + origDir, _ := os.Getwd() + if err = os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + // Initialize context + initCmd := initialize.Cmd() + initCmd.SetArgs([]string{}) + if err = initCmd.Execute(); err != nil { + t.Fatalf("init failed: %v", err) + } + + // Try to complete a non-existent task + err = completeTask("nonexistent task query") + if err == nil { + t.Error("expected error for non-matching task") + } + if !strings.Contains(err.Error(), "no task matching") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestCompleteTask_Empty(t *testing.T) { + err := completeTask("") + if err == nil { + t.Fatal("expected error for empty query") + } + if !strings.Contains(err.Error(), "no task specified") { + t.Errorf("error = %q, want 'no task specified'", err.Error()) + } +} diff --git a/internal/cli/watch/core/apply/doc.go b/internal/cli/watch/core/apply/doc.go index dfdcec13e..e0c80c341 100644 --- a/internal/cli/watch/core/apply/doc.go +++ b/internal/cli/watch/core/apply/doc.go @@ -6,7 +6,7 @@ // Package apply routes context updates to the appropriate handler. // -// Key exports: [Update], [RunAddSilent], [RunCompleteSilent]. +// Key exports: [Update]. // Shared helpers used by sibling cmd/ packages. // Used by core cmd/ packages. package apply diff --git a/internal/cli/watch/core/apply/entry.go b/internal/cli/watch/core/apply/entry.go new file mode 100644 index 000000000..3c29018ae --- /dev/null +++ b/internal/cli/watch/core/apply/entry.go @@ -0,0 +1,47 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package apply + +import ( + "github.com/ActiveMemory/ctx/internal/cli/watch/core" + "github.com/ActiveMemory/ctx/internal/entry" +) + +// addEntry appends an entry to a context file without output. +// +// Used by [Update] to silently apply updates detected in the watch +// input stream. Uses shared validation and write logic from the +// entry package to ensure consistent behavior with ctx add. +// +// Parameters: +// - update: The parsed ContextUpdate with type, content, and required +// structured fields (context, lesson, application for learnings; +// context, rationale, consequence for decisions) +// +// Returns: +// - error: Non-nil if validation fails, type is unknown, +// or file operations fail +func addEntry(update core.ContextUpdate) error { + params := entry.Params{ + Type: update.Type, + Content: update.Content, + Context: update.Context, + Rationale: update.Rationale, + Consequence: update.Consequence, + Lesson: update.Lesson, + Application: update.Application, + } + + // Validate required fields (same as ctx add) + if validateErr := entry.Validate(params, nil); validateErr != nil { + return validateErr + } + + // Write using the shared function + // (handles formatting, append, and index update) + return entry.Write(params) +} diff --git a/internal/cli/watch/core/apply/testmain_test.go b/internal/cli/watch/core/apply/testmain_test.go new file mode 100644 index 000000000..693b1f08e --- /dev/null +++ b/internal/cli/watch/core/apply/testmain_test.go @@ -0,0 +1,19 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package apply + +import ( + "os" + "testing" + + "github.com/ActiveMemory/ctx/internal/assets/read/lookup" +) + +func TestMain(m *testing.M) { + lookup.Init() + os.Exit(m.Run()) +} diff --git a/internal/cli/watch/core/core_test.go b/internal/cli/watch/core/core_test.go index d5c01d04d..4fa34cd1f 100644 --- a/internal/cli/watch/core/core_test.go +++ b/internal/cli/watch/core/core_test.go @@ -344,47 +344,6 @@ func TestExtractAttribute(t *testing.T) { } } -// TestRunCompleteSilentNoMatch tests complete with no matching task. -func TestRunCompleteSilentNoMatch(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "watch-nomatch-test-*") - if err != nil { - t.Fatalf("failed to create temp dir: %v", err) - } - defer func() { _ = os.RemoveAll(tmpDir) }() - - origDir, _ := os.Getwd() - if err = os.Chdir(tmpDir); err != nil { - t.Fatalf("failed to chdir: %v", err) - } - defer func() { _ = os.Chdir(origDir) }() - - // Initialize context - initCmd := initialize.Cmd() - initCmd.SetArgs([]string{}) - if err = initCmd.Execute(); err != nil { - t.Fatalf("init failed: %v", err) - } - - // Try to complete a non-existent task - err = apply.RunCompleteSilent([]string{"nonexistent task query"}) - if err == nil { - t.Error("expected error for non-matching task") - } - if !strings.Contains(err.Error(), "no task matching") { - t.Errorf("unexpected error: %v", err) - } -} - -func TestRunCompleteSilent_NoArgs(t *testing.T) { - err := apply.RunCompleteSilent([]string{}) - if err == nil { - t.Fatal("expected error for empty args") - } - if !strings.Contains(err.Error(), "no task specified") { - t.Errorf("error = %q, want 'no task specified'", err.Error()) - } -} - func TestProcessStream_DryRunMode(t *testing.T) { tmpDir := t.TempDir() origDir, _ := os.Getwd() diff --git a/internal/compliance/compliance_test.go b/internal/compliance/compliance_test.go index 442cf33c3..2cd197c06 100644 --- a/internal/compliance/compliance_test.go +++ b/internal/compliance/compliance_test.go @@ -784,7 +784,7 @@ func TestNoNetworkImportsInCore(t *testing.T) { t.Run(pkg, func(t *testing.T) { fset := token.NewFileSet() - pkgs, parseErr := parser.ParseDir(fset, pkgDir, func(info os.FileInfo) bool { + pkgs, parseErr := parser.ParseDir(fset, pkgDir, func(info os.FileInfo) bool { //nolint:staticcheck // migration to go/packages tracked separately return !strings.HasSuffix(info.Name(), "_test.go") }, parser.ImportsOnly) if parseErr != nil { @@ -1050,7 +1050,6 @@ func TestCmdDirPurity(t *testing.T) { "guide/cmd/root/command.go": {"listCommands": true}, "guide/cmd/root/skill.go": {"parseSkillFrontmatter": true, "truncateDescription": true, "listSkills": true}, "guide/cmd/root/types.go": {"skillMeta": true}, - "initialize/cmd/root/run.go": {"initScratchpad": true, "hasEssentialFiles": true, "ensureGitignoreEntries": true, "writeGettingStarted": true}, "journal/cmd/obsidian/run.go": {"BuildVault": true}, "journal/cmd/source/list.go": {"runList": true}, "journal/cmd/source/show.go": {"runShow": true}, @@ -1060,7 +1059,6 @@ func TestCmdDirPurity(t *testing.T) { "pad/cmd/resolve/display.go": {"displayAll": true}, "remind/cmd/dismiss/run.go": {"dismissOne": true, "dismissAll": true}, "system/cmd/post_commit/score.go": {"scoreCommitViolations": true}, - "task/cmd/complete/run.go": {"Complete": true}, "why/cmd/root/data.go": {"DocEntry": true}, "why/cmd/root/menu.go": {"showMenu": true}, "why/cmd/root/run.go": {"ShowDoc": true}, diff --git a/internal/config/asset/asset.go b/internal/config/asset/asset.go index 8e5679c3c..8d3c1c3df 100644 --- a/internal/config/asset/asset.go +++ b/internal/config/asset/asset.go @@ -23,6 +23,7 @@ const ( DirIntegrationsCopilotScrp = "integrations/copilot-cli/scripts" DirIntegrationsCopilotSkill = "integrations/copilot-cli/skills" DirHooksMessages = "hooks/messages" + DirHooksTrace = "hooks/trace" DirJournal = "journal" DirPermissions = "permissions" DirProject = "project" diff --git a/internal/config/dir/dir.go b/internal/config/dir/dir.go index 3472323fb..ebb79da98 100644 --- a/internal/config/dir/dir.go +++ b/internal/config/dir/dir.go @@ -39,6 +39,8 @@ const ( Specs = "specs" // State is the subdirectory for project-scoped runtime state within .context/. State = "state" + // Trace is the subdirectory for commit context trace data within .context/. + Trace = "trace" // Templates is the subdirectory for entry templates within .context/. Templates = "templates" // CtxData is the user-level ctx data directory (~/.ctx/). diff --git a/internal/config/embed/cmd/trace.go b/internal/config/embed/cmd/trace.go new file mode 100644 index 000000000..be4569244 --- /dev/null +++ b/internal/config/embed/cmd/trace.go @@ -0,0 +1,23 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +const ( + UseTrace = "trace [commit]" + UseTraceFile = "file " + UseTraceTag = "tag " + UseTraceCollect = "collect" + UseTraceHook = "hook " +) + +const ( + DescKeyTrace = "trace" + DescKeyTraceFile = "trace.file" + DescKeyTraceTag = "trace.tag" + DescKeyTraceCollect = "trace.collect" + DescKeyTraceHook = "trace.hook" +) diff --git a/internal/config/embed/flag/trace.go b/internal/config/embed/flag/trace.go new file mode 100644 index 000000000..2dc7fd3da --- /dev/null +++ b/internal/config/embed/flag/trace.go @@ -0,0 +1,15 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package flag + +const ( + DescKeyTraceLast = "trace.last" + DescKeyTraceJSON = "trace.json" + DescKeyTraceFileLast = "trace.file.last" + DescKeyTraceTagNote = "trace.tag.note" + DescKeyTraceCollectRecord = "trace.collect.record" +) diff --git a/internal/config/embed/text/err_fs.go b/internal/config/embed/text/err_fs.go index d5dbb1ff2..58d9fe542 100644 --- a/internal/config/embed/text/err_fs.go +++ b/internal/config/embed/text/err_fs.go @@ -30,6 +30,7 @@ const ( DescKeyErrFsResolvePath = "err.fs.resolve-path" DescKeyErrFsStatPath = "err.fs.stat-path" DescKeyErrFsStdinRead = "err.fs.stdin-read" + DescKeyErrFsWriteBuffer = "err.fs.write-buffer" DescKeyErrFsWriteFileFailed = "err.fs.write-file-failed" DescKeyErrFsWriteMerged = "err.fs.write-merged" ) diff --git a/internal/config/embed/text/err_trace.go b/internal/config/embed/text/err_trace.go new file mode 100644 index 000000000..dd80eb18d --- /dev/null +++ b/internal/config/embed/text/err_trace.go @@ -0,0 +1,19 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package text + +const ( + DescKeyErrTraceGitDir = "err.trace.git-dir" + DescKeyErrTraceGitLog = "err.trace.git-log" + DescKeyErrTraceHookExists = "err.trace.hook-exists" + DescKeyErrTraceHookWrite = "err.trace.hook-write" + DescKeyErrTraceNoteRequired = "err.trace.note-required" + DescKeyErrTraceResolveCommit = "err.trace.resolve-commit" + DescKeyErrTraceUnknownAction = "err.trace.unknown-action" + DescKeyErrTraceWriteHistory = "err.trace.write-history" + DescKeyErrTraceWriteOverride = "err.trace.write-override" +) diff --git a/internal/config/embed/text/label.go b/internal/config/embed/text/label.go index e9bf672ff..77f762c63 100644 --- a/internal/config/embed/text/label.go +++ b/internal/config/embed/text/label.go @@ -14,7 +14,8 @@ const ( ) const ( - DescKeyLabelBoldReminder = "label.bold-reminder" - DescKeyLabelToolOutput = "label.tool-output" - DescKeyLabelSuggestionMode = "label.suggestion-mode" + DescKeyLabelBoldReminder = "label.bold-reminder" + DescKeyLabelBoldReminderFmt = "label.bold-reminder-fmt" + DescKeyLabelToolOutput = "label.tool-output" + DescKeyLabelSuggestionMode = "label.suggestion-mode" ) diff --git a/internal/config/embed/text/trace.go b/internal/config/embed/text/trace.go new file mode 100644 index 000000000..70776cd05 --- /dev/null +++ b/internal/config/embed/text/trace.go @@ -0,0 +1,27 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package text + +const ( + DescKeyWriteTraceDetailDate = "write.trace-detail-date" + DescKeyWriteTraceDetailStatus = "write.trace-detail-status" + DescKeyWriteTraceCommitHeader = "write.trace-commit-header" + DescKeyWriteTraceCommitMessage = "write.trace-commit-message" + DescKeyWriteTraceCommitDate = "write.trace-commit-date" + DescKeyWriteTraceCommitContext = "write.trace-commit-context" + DescKeyWriteTraceCommitNoContext = "write.trace-commit-no-context" + DescKeyWriteTraceFileEntry = "write.trace-file-entry" + DescKeyWriteTraceHooksEnabled = "write.trace-hooks-enabled" + DescKeyWriteTraceHooksDisabled = "write.trace-hooks-disabled" + DescKeyWriteTraceLastEntry = "write.trace-last-entry" + DescKeyWriteTraceNoRefs = "write.trace-no-refs" + DescKeyWriteTraceRefsPrefix = "write.trace-refs-prefix" + DescKeyWriteTraceResolvedFull = "write.trace-resolved-full" + DescKeyWriteTraceResolvedTitle = "write.trace-resolved-title" + DescKeyWriteTraceResolvedRaw = "write.trace-resolved-raw" + DescKeyWriteTraceTagged = "write.trace-tagged" +) diff --git a/internal/config/env/env.go b/internal/config/env/env.go index 31aa8cc83..08d61bb50 100644 --- a/internal/config/env/env.go +++ b/internal/config/env/env.go @@ -22,6 +22,9 @@ const ( BackupSMBURL = "CTX_BACKUP_SMB_URL" // BackupSMBSubdir is the environment variable for the SMB share subdirectory. BackupSMBSubdir = "CTX_BACKUP_SMB_SUBDIR" + // SessionID is the environment variable for the active AI session ID. + // Used by ctx trace for context linking. + SessionID = "CTX_SESSION_ID" // SkipPathCheck is the environment variable that skips the PATH // validation during init. Set to True in tests. SkipPathCheck = "CTX_SKIP_PATH_CHECK" diff --git a/internal/config/flag/flag.go b/internal/config/flag/flag.go index 871ce6802..45c1f66f6 100644 --- a/internal/config/flag/flag.go +++ b/internal/config/flag/flag.go @@ -78,6 +78,7 @@ const ( Limit = "limit" MaxIterations = "max-iterations" Merge = "merge" + Note = "note" Message = "message" Minimal = "minimal" NoPluginEnable = "no-plugin-enable" @@ -88,6 +89,7 @@ const ( Prompt = "prompt" Quiet = "quiet" Raw = "raw" + Record = "record" Regenerate = "regenerate" Scope = "scope" Serve = "serve" diff --git a/internal/config/git/git.go b/internal/config/git/git.go index 006cc018c..a086d6a3f 100644 --- a/internal/config/git/git.go +++ b/internal/config/git/git.go @@ -17,24 +17,42 @@ const ( RevParse = "rev-parse" ) +// Git hook names. +const ( + HookPrepareCommitMsg = "prepare-commit-msg" + HookPostCommit = "post-commit" + HooksDir = "hooks" +) + +// Git subcommands (additional). +const ( + Diff = "diff" +) + // Git rev-parse flags. const ( - // FlagShowToplevel is a git flag. FlagShowToplevel = "--show-toplevel" + FlagGitDir = "--git-dir" ) // Git flags. const ( - FlagChangeDir = "-C" - FlagLast = "-1" - FlagNoCommitID = "--no-commit-id" - FlagNameOnly = "--name-only" - FlagOneline = "--oneline" - FlagRecursive = "-r" - FlagSince = "--since" - FormatAuthor = "--format=%aN" - FormatBody = "--format=%B" - FormatEmpty = "--format=" + FlagCached = "--cached" + FlagChangeDir = "-C" + FlagLast = "-1" + FlagNoCommitID = "--no-commit-id" + FlagNameOnly = "--name-only" + FlagOneline = "--oneline" + FlagRecursive = "-r" + FlagSince = "--since" + FormatAuthor = "--format=%aN" + FormatBody = "--format=%B" + FormatEmpty = "--format=" + FormatDateISO = "--format=%ci" + FormatHashDateSubj = "--format=%H %ci %s" + FormatHashSubj = "--format=%H %s" + FormatSubject = "--format=%s" + FormatTrailerValue = "--format=%%(trailers:key=%s,valueonly)" // FlagPathSep is the separator between flags and paths. FlagPathSep = "--" ) diff --git a/internal/config/trace/doc.go b/internal/config/trace/doc.go new file mode 100644 index 000000000..b250fe511 --- /dev/null +++ b/internal/config/trace/doc.go @@ -0,0 +1,12 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package trace provides configuration constants for commit context tracing. +// +// Key exports: [TrailerKey], [ShortHashLen], [DefaultLastFile], [DefaultLastShow]. +// See source files for implementation details. +// Part of the config subsystem. +package trace diff --git a/internal/config/trace/trace.go b/internal/config/trace/trace.go new file mode 100644 index 000000000..7be66cfc6 --- /dev/null +++ b/internal/config/trace/trace.go @@ -0,0 +1,77 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +// DefaultLastFile is the default number of commits shown by ctx trace file. +const DefaultLastFile = 20 + +// DefaultLastShow is the default number of commits shown by ctx trace +// when invoked without arguments. +const DefaultLastShow = 10 + +// ShortHashLen is the number of characters used when abbreviating +// a git commit hash for display. +const ShortHashLen = 7 + +// Hook action argument values for ctx trace hook . +const ( + ActionEnable = "enable" + ActionDisable = "disable" +) + +// TrailerKey is the git trailer key used to embed context refs in commit messages. +const TrailerKey = "ctx-context" + +// TrailerFormat is the format string for the git trailer line: "key: refs". +const TrailerFormat = TrailerKey + ": %s" + +// Reference type identifiers used in ctx-context trailers. +const ( + RefTypeNote = "note" + RefTypeSession = "session" + RefTypeDecision = "decision" + RefTypeLearning = "learning" + RefTypeConvention = "convention" + RefTypeTask = "task" +) + +// Task status labels for resolved refs. +const ( + StatusPending = "pending" + StatusCompleted = "completed" +) + +// RefFormat is the format string for numbered refs (e.g. "decision:1"). +const RefFormat = "%s:%d" + +// SessionRefFormat is the format string for session refs (e.g. "session:abc123"). +const SessionRefFormat = "session:%s" + +// Diff line prefix constants for parsing git diff output. +const ( + DiffAddedPrefix = "+" + DiffHeaderPrefix = "++" +) + +// TaskCompletedMarker is the checkbox state for completed tasks in diffs. +const TaskCompletedMarker = "x" + +// CtxTraceMarker is the string used to identify ctx-installed git hooks. +const CtxTraceMarker = "ctx trace" + +// JSONL storage filenames within the trace and state directories. +const ( + FileHistory = "history.jsonl" + FileOverrides = "overrides.jsonl" + FilePending = "pending-context.jsonl" +) + +// Embedded hook script filenames. +const ( + ScriptPrepareCommitMsg = "prepare-commit-msg.sh" + ScriptPostCommit = "post-commit.sh" +) diff --git a/internal/drift/detector_test.go b/internal/drift/detector_test.go index 654584732..82a8d9ea0 100644 --- a/internal/drift/detector_test.go +++ b/internal/drift/detector_test.go @@ -15,6 +15,7 @@ import ( "github.com/ActiveMemory/ctx/internal/context/load" "github.com/ActiveMemory/ctx/internal/entity" + "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/rc" ) @@ -325,11 +326,11 @@ func TestCheckEntryCount(t *testing.T) { var sb strings.Builder sb.WriteString("# Learnings\n\n") for i := 0; i < n; i++ { - sb.WriteString(fmt.Sprintf( + io.SafeFprintf(&sb, "## [2026-01-%02d-120000] Entry %d\n\n"+ "Content for entry %d.\n\n", (i%28)+1, i+1, i+1, - )) + ) } return sb.String() } @@ -449,11 +450,11 @@ func TestCheckEntryCountDisabled(t *testing.T) { var sb strings.Builder sb.WriteString("# Learnings\n\n") for i := 0; i < n; i++ { - sb.WriteString(fmt.Sprintf( + io.SafeFprintf(&sb, "## [2026-01-%02d-120000] Entry %d\n\n"+ "Content for entry %d.\n\n", (i%28)+1, i+1, i+1, - )) + ) } return sb.String() } diff --git a/internal/entry/write.go b/internal/entry/write.go index 7fecb6752..edf5fef48 100644 --- a/internal/entry/write.go +++ b/internal/entry/write.go @@ -18,6 +18,7 @@ import ( errAdd "github.com/ActiveMemory/ctx/internal/err/add" errFs "github.com/ActiveMemory/ctx/internal/err/fs" "github.com/ActiveMemory/ctx/internal/index" + "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/rc" ) @@ -78,7 +79,7 @@ func Write(params Params) error { existing, formatted, fType, params.Section, ) - if writeErr := os.WriteFile( + if writeErr := io.SafeWriteFile( filePath, newContent, fs.PermFile, ); writeErr != nil { return errFs.FileWrite(filePath, writeErr) @@ -87,14 +88,14 @@ func Write(params Params) error { switch fType { case entry.Decision: indexed := index.UpdateDecisions(string(newContent)) - if indexErr := os.WriteFile( + if indexErr := io.SafeWriteFile( filePath, []byte(indexed), fs.PermFile, ); indexErr != nil { return errAdd.IndexUpdate(filePath, indexErr) } case entry.Learning: indexed := index.UpdateLearnings(string(newContent)) - if indexErr := os.WriteFile( + if indexErr := io.SafeWriteFile( filePath, []byte(indexed), fs.PermFile, ); indexErr != nil { return errAdd.IndexUpdate(filePath, indexErr) diff --git a/internal/err/trace/doc.go b/internal/err/trace/doc.go new file mode 100644 index 000000000..55e234d94 --- /dev/null +++ b/internal/err/trace/doc.go @@ -0,0 +1,10 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package trace provides error constructors for the trace command. +// +// Key exports: [GitLog], [NoteRequired], [ResolveCommit], [UnknownAction], [WriteHistory], [WriteOverride]. +package trace diff --git a/internal/err/trace/trace.go b/internal/err/trace/trace.go new file mode 100644 index 000000000..dee5bb529 --- /dev/null +++ b/internal/err/trace/trace.go @@ -0,0 +1,130 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "errors" + "fmt" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + "github.com/ActiveMemory/ctx/internal/config/embed/text" +) + +// GitDir wraps a git rev-parse --git-dir failure. +// +// Parameters: +// - cause: the underlying error +// +// Returns: +// - error: "git rev-parse --git-dir: " +func GitDir(cause error) error { + return fmt.Errorf( + desc.Text(text.DescKeyErrTraceGitDir), cause, + ) +} + +// GitLog wraps a git log failure. +// +// Parameters: +// - cause: the underlying error +// +// Returns: +// - error: "git log: " +func GitLog(cause error) error { + return fmt.Errorf( + desc.Text(text.DescKeyErrTraceGitLog), cause, + ) +} + +// HookExists returns an error when a non-ctx hook already exists. +// +// Parameters: +// - name: hook name (e.g. "prepare-commit-msg") +// - path: path to the existing hook file +// +// Returns: +// - error: " hook already exists at (not installed by ctx)..." +func HookExists(name, path string) error { + return fmt.Errorf( + desc.Text(text.DescKeyErrTraceHookExists), name, path, + ) +} + +// HookWrite wraps a hook file write failure. +// +// Parameters: +// - name: hook name (e.g. "prepare-commit-msg") +// - cause: the underlying error +// +// Returns: +// - error: "write hook: " +func HookWrite(name string, cause error) error { + return fmt.Errorf( + desc.Text(text.DescKeyErrTraceHookWrite), name, cause, + ) +} + +// NoteRequired returns an error when --note is missing. +// +// Returns: +// - error: "--note is required" +func NoteRequired() error { + return errors.New(desc.Text(text.DescKeyErrTraceNoteRequired)) +} + +// ResolveCommit wraps a commit resolution failure. +// +// Parameters: +// - ref: the commit ref that failed to resolve +// - cause: the underlying error +// +// Returns: +// - error: "resolve commit \"\": " +func ResolveCommit(ref string, cause error) error { + return fmt.Errorf( + desc.Text(text.DescKeyErrTraceResolveCommit), ref, cause, + ) +} + +// UnknownAction returns an error for an unrecognized hook action. +// +// Parameters: +// - action: the unknown action string +// +// Returns: +// - error: "unknown action \"\": use enable or disable" +func UnknownAction(action string) error { + return fmt.Errorf( + desc.Text(text.DescKeyErrTraceUnknownAction), action, + ) +} + +// WriteHistory wraps a history write failure. +// +// Parameters: +// - cause: the underlying error +// +// Returns: +// - error: "write history: " +func WriteHistory(cause error) error { + return fmt.Errorf( + desc.Text(text.DescKeyErrTraceWriteHistory), cause, + ) +} + +// WriteOverride wraps an override write failure. +// +// Parameters: +// - cause: the underlying error +// +// Returns: +// - error: "write override: " +func WriteOverride(cause error) error { + return fmt.Errorf( + desc.Text(text.DescKeyErrTraceWriteOverride), cause, + ) +} diff --git a/internal/flagbind/doc.go b/internal/flagbind/doc.go index f388564f7..05255aa6e 100644 --- a/internal/flagbind/doc.go +++ b/internal/flagbind/doc.go @@ -6,7 +6,9 @@ // Package flagbind provides helpers for cobra flag registration. // -// Key exports: [StringFlag], [StringFlagP], [LastJSON]. +// Key exports: [BoolFlag], [BoolFlagP], [IntFlagP], +// [StringFlag], [StringFlagP], [StringFlagPDefault], [LastJSON]. +// // See source files for implementation details. // Part of the internal subsystem. package flagbind diff --git a/internal/flagbind/flag.go b/internal/flagbind/flag.go index 1e935b668..25632a09c 100644 --- a/internal/flagbind/flag.go +++ b/internal/flagbind/flag.go @@ -4,7 +4,6 @@ // \ Copyright 2026-present Context contributors. // SPDX-License-Identifier: Apache-2.0 -// Package register provides helpers for cobra flag registration. package flagbind import ( @@ -14,6 +13,45 @@ import ( cFlag "github.com/ActiveMemory/ctx/internal/config/flag" ) +// BoolFlag registers a boolean flag with no shorthand, defaulting to false. +// +// Parameters: +// - c: Cobra command to register on +// - p: Pointer to the bool variable +// - name: Flag name constant +// - descKey: YAML DescKey for the flag description +func BoolFlag(c *cobra.Command, p *bool, name, descKey string) { + c.Flags().BoolVar(p, name, false, desc.Flag(descKey)) +} + +// BoolFlagP registers a boolean flag with a shorthand letter, defaulting +// to false. +// +// Parameters: +// - c: Cobra command to register on +// - p: Pointer to the bool variable +// - name: Flag name constant +// - short: Shorthand letter +// - descKey: YAML DescKey for the flag description +func BoolFlagP(c *cobra.Command, p *bool, name, short, descKey string) { + c.Flags().BoolVarP(p, name, short, false, desc.Flag(descKey)) +} + +// IntFlagP registers an integer flag with a shorthand letter. +// +// Parameters: +// - c: Cobra command to register on +// - p: Pointer to the int variable +// - name: Flag name constant +// - short: Shorthand letter +// - defaultVal: Default value for the flag +// - descKey: YAML DescKey for the flag description +func IntFlagP( + c *cobra.Command, p *int, name, short string, defaultVal int, descKey string, +) { + c.Flags().IntVarP(p, name, short, defaultVal, desc.Flag(descKey)) +} + // StringFlag registers a string flag with no shorthand. // // Parameters: @@ -37,6 +75,22 @@ func StringFlagP(c *cobra.Command, p *string, name, short, descKey string) { c.Flags().StringVarP(p, name, short, "", desc.Flag(descKey)) } +// StringFlagPDefault registers a string flag with a shorthand letter and +// a non-empty default value. +// +// Parameters: +// - c: Cobra command to register on +// - p: Pointer to the string variable +// - name: Flag name constant +// - short: Shorthand letter +// - defaultVal: Default value for the flag +// - descKey: YAML DescKey for the flag description +func StringFlagPDefault( + c *cobra.Command, p *string, name, short, defaultVal, descKey string, +) { + c.Flags().StringVarP(p, name, short, defaultVal, desc.Flag(descKey)) +} + // LastJSON registers the --last (int) and --json (bool) flag pair used by // list-style commands. // diff --git a/internal/io/fprintf.go b/internal/io/fprintf.go new file mode 100644 index 000000000..1bce24e14 --- /dev/null +++ b/internal/io/fprintf.go @@ -0,0 +1,29 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package io + +import ( + "fmt" + "io" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + "github.com/ActiveMemory/ctx/internal/config/embed/text" + "github.com/ActiveMemory/ctx/internal/log/warn" +) + +// SafeFprintf writes formatted output to w, logging to the warning +// sink on error. +// +// Parameters: +// - w: destination writer +// - format: Printf-style format string +// - a: format arguments +func SafeFprintf(w io.Writer, format string, a ...any) { + if _, err := fmt.Fprintf(w, format, a...); err != nil { + warn.Warn(desc.Text(text.DescKeyErrFsWriteBuffer), err) + } +} diff --git a/internal/io/security.go b/internal/io/security.go index 870aa325c..8f76e576e 100644 --- a/internal/io/security.go +++ b/internal/io/security.go @@ -130,6 +130,23 @@ func SafeCreateFile(path string, perm os.FileMode) (*os.File, error) { return os.OpenFile(clean, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perm) } +// SafeMkdirAll creates a directory tree after cleaning the path and +// rejecting system directory prefixes. +// +// Parameters: +// - path: directory path to create +// - perm: directory permission bits +// +// Returns: +// - error: non-nil on validation or mkdir failure +func SafeMkdirAll(path string, perm os.FileMode) error { + clean, validateErr := cleanAndValidate(path) + if validateErr != nil { + return validateErr + } + return os.MkdirAll(clean, perm) +} + // SafeWriteFile writes data to a file after cleaning the path and // rejecting system directory prefixes. // @@ -149,6 +166,23 @@ func SafeWriteFile(path string, data []byte, perm os.FileMode) error { return os.WriteFile(clean, data, perm) } +// SafeStat returns file info after cleaning the path and rejecting +// system directory prefixes. +// +// Parameters: +// - path: file path to stat +// +// Returns: +// - os.FileInfo: file metadata on success +// - error: non-nil on validation or stat failure +func SafeStat(path string) (os.FileInfo, error) { + clean, validateErr := cleanAndValidate(path) + if validateErr != nil { + return nil, validateErr + } + return os.Stat(clean) +} + // TouchFile creates or updates an empty marker file. Best-effort: // errors are silently ignored. Used for throttle markers and // one-shot flags in state directories. diff --git a/internal/journal/parser/copilot.go b/internal/journal/parser/copilot.go index f95aed4b4..43a142443 100644 --- a/internal/journal/parser/copilot.go +++ b/internal/journal/parser/copilot.go @@ -20,6 +20,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/session" "github.com/ActiveMemory/ctx/internal/entity" errParser "github.com/ActiveMemory/ctx/internal/err/parser" + "github.com/ActiveMemory/ctx/internal/io" ) // Ensure Copilot implements Session. @@ -222,7 +223,7 @@ func CopilotSessionDirs() []string { variants := []string{cfgCopilot.AppCode, cfgCopilot.AppCodeInsiders} for _, variant := range variants { wsDir := filepath.Join(appData, variant, cfgCopilot.DirUser, cfgCopilot.DirWorkspace) - if info, err := os.Stat(wsDir); err == nil && info.IsDir() { + if info, err := io.SafeStat(wsDir); err == nil && info.IsDir() { // Scan each workspace for chatSessions/ subdirectory entries, err := os.ReadDir(wsDir) if err != nil { @@ -233,7 +234,7 @@ func CopilotSessionDirs() []string { continue } chatDir := filepath.Join(wsDir, entry.Name(), cfgCopilot.DirChatSessions) - if info, err := os.Stat(chatDir); err == nil && info.IsDir() { + if info, err := io.SafeStat(chatDir); err == nil && info.IsDir() { dirs = append(dirs, chatDir) } } diff --git a/internal/journal/parser/copilot_cli.go b/internal/journal/parser/copilot_cli.go index 658769a1f..8f123122c 100644 --- a/internal/journal/parser/copilot_cli.go +++ b/internal/journal/parser/copilot_cli.go @@ -23,6 +23,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/token" "github.com/ActiveMemory/ctx/internal/entity" errParser "github.com/ActiveMemory/ctx/internal/err/parser" + "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/log/warn" ) @@ -271,7 +272,7 @@ func CopilotCLISessionDirs() []string { candidates := []string{cfgCopilot.DirSessions, cfgCopilot.DirHistory} for _, sub := range candidates { dir := filepath.Join(copilotHome, sub) - if info, err := os.Stat(dir); err == nil && info.IsDir() { + if info, err := io.SafeStat(dir); err == nil && info.IsDir() { dirs = append(dirs, dir) } } @@ -282,7 +283,7 @@ func CopilotCLISessionDirs() []string { if localAppData != "" { for _, sub := range candidates { dir := filepath.Join(localAppData, cfgCopilot.CLIAppName, sub) - if info, err := os.Stat(dir); err == nil && info.IsDir() { + if info, err := io.SafeStat(dir); err == nil && info.IsDir() { dirs = append(dirs, dir) } } diff --git a/internal/mcp/handler/tool.go b/internal/mcp/handler/tool.go index a8a8b5af6..a42743017 100644 --- a/internal/mcp/handler/tool.go +++ b/internal/mcp/handler/tool.go @@ -14,7 +14,7 @@ import ( "github.com/ActiveMemory/ctx/internal/assets/read/desc" remindCore "github.com/ActiveMemory/ctx/internal/cli/remind/core" - taskComplete "github.com/ActiveMemory/ctx/internal/cli/task/cmd/complete" + taskComplete "github.com/ActiveMemory/ctx/internal/cli/task/core/complete" cfgArchive "github.com/ActiveMemory/ctx/internal/config/archive" cfgCtx "github.com/ActiveMemory/ctx/internal/config/ctx" "github.com/ActiveMemory/ctx/internal/config/embed/text" @@ -29,6 +29,7 @@ import ( "github.com/ActiveMemory/ctx/internal/entity" "github.com/ActiveMemory/ctx/internal/entry" errMcp "github.com/ActiveMemory/ctx/internal/err/mcp" + "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/journal/parser" "github.com/ActiveMemory/ctx/internal/mcp/handler/task" "github.com/ActiveMemory/ctx/internal/mcp/server/stat" @@ -49,15 +50,15 @@ func (h *Handler) Status() (string, error) { } var sb strings.Builder - _, _ = fmt.Fprintf( + io.SafeFprintf( &sb, desc.Text(text.DescKeyMCPStatusContextFormat), ctx.Dir, ) - _, _ = fmt.Fprintf( + io.SafeFprintf( &sb, desc.Text(text.DescKeyMCPStatusFilesFormat), len(ctx.Files), ) - _, _ = fmt.Fprintf( + io.SafeFprintf( &sb, desc.Text(text.DescKeyMCPStatusUsageFormat), ctx.TotalTokens, ) @@ -130,7 +131,7 @@ func (h *Handler) Complete(query string) (string, error) { return "", boundaryErr } - completedTask, completeErr := taskComplete.Complete( + completedTask, _, completeErr := taskComplete.Complete( query, h.ContextDir, ) if completeErr != nil { @@ -157,7 +158,7 @@ func (h *Handler) Drift() (string, error) { report := drift.Detect(ctx) var sb strings.Builder - _, _ = fmt.Fprintf( + io.SafeFprintf( &sb, desc.Text(text.DescKeyMCPDriftStatusFormat), report.Status(), @@ -234,7 +235,7 @@ func (h *Handler) Recall(limit int, since time.Time) (string, error) { } var sb strings.Builder - _, _ = fmt.Fprintf(&sb, + io.SafeFprintf(&sb, desc.Text(text.DescKeyMCPSessionsFoundFormat), len(sessions), ) @@ -290,7 +291,7 @@ func (h *Handler) WatchUpdate( // Handle the "complete" type as a special case. if entryType == cfgEntry.Complete { - completedTask, completeErr := taskComplete.Complete( + completedTask, _, completeErr := taskComplete.Complete( content, h.ContextDir) if completeErr != nil { return "", completeErr @@ -404,7 +405,7 @@ func (h *Handler) Compact(archive bool) (string, error) { // Build response text. for _, taskText := range result.TasksMoved { - _, _ = fmt.Fprintf(&sb, + io.SafeFprintf(&sb, desc.Text( text.DescKeyMCPCompactMovedFormat)+token.NewlineLF, tidy.TruncateString(taskText, token.TruncateLen), @@ -423,7 +424,7 @@ func (h *Handler) Compact(archive bool) (string, error) { return desc.Text(text.DescKeyMCPCompactClean), nil } - _, _ = fmt.Fprintf( + io.SafeFprintf( &sb, desc.Text(text.DescKeyMCPFormatCompacted), result.TotalChanges(), @@ -563,7 +564,7 @@ func (h *Handler) SessionEvent( sb.WriteString(desc.Text(text.DescKeyMCPNoPending)) } - _, _ = fmt.Fprintf(&sb, + io.SafeFprintf(&sb, desc.Text(text.DescKeyMCPFormatSessionStats), h.Session.ToolCalls, stat.TotalAdds(h.Session.AddsPerformed), @@ -593,7 +594,7 @@ func (h *Handler) Remind() (string, error) { today := time.Now().Format(cfgTime.DateFormat) var sb strings.Builder - _, _ = fmt.Fprintf( + io.SafeFprintf( &sb, desc.Text(text.DescKeyMCPRemindersFormat), len(reminders), @@ -610,7 +611,7 @@ func (h *Handler) Remind() (string, error) { ) } } - _, _ = fmt.Fprintf(&sb, desc.Text( + io.SafeFprintf(&sb, desc.Text( text.DescKeyMCPFormatReminderItem)+token.NewlineLF, r.ID, r.Message, annotation) } diff --git a/internal/tidy/archive.go b/internal/tidy/archive.go index addeda45d..b95ab26a2 100644 --- a/internal/tidy/archive.go +++ b/internal/tidy/archive.go @@ -18,6 +18,7 @@ import ( cfgTime "github.com/ActiveMemory/ctx/internal/config/time" "github.com/ActiveMemory/ctx/internal/config/token" errBackup "github.com/ActiveMemory/ctx/internal/err/backup" + "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/rc" ) @@ -58,7 +59,7 @@ func WriteArchive(prefix, heading, content string) (string, error) { dateStr + nl + nl + content } - if writeErr := os.WriteFile( + if writeErr := io.SafeWriteFile( archiveFile, []byte(finalContent), fs.PermFile, ); writeErr != nil { return "", errBackup.WriteArchive(writeErr) diff --git a/internal/trace/collect.go b/internal/trace/collect.go new file mode 100644 index 000000000..9f331ed97 --- /dev/null +++ b/internal/trace/collect.go @@ -0,0 +1,92 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "fmt" + "path/filepath" + "strings" + + cfgDir "github.com/ActiveMemory/ctx/internal/config/dir" + "github.com/ActiveMemory/ctx/internal/config/token" + cfgTrace "github.com/ActiveMemory/ctx/internal/config/trace" +) + +// Collect gathers context refs from all three sources — pending records, +// staged file diffs, and current working state — then deduplicates them. +// +// Parameters: +// - contextDir: absolute path to the .context/ directory +// +// Returns: +// - []string: deduplicated refs in source order (pending → staged → working) +func Collect(contextDir string) []string { + stateDir := filepath.Join(contextDir, cfgDir.State) + + var all []string + + // Source 1: pending records written by ctx trace record. + if entries, err := ReadPending(stateDir); err == nil { + for _, e := range entries { + all = append(all, e.Ref) + } + } + + // Source 2: staged context file diffs. + all = append(all, StagedRefs(contextDir)...) + + // Source 3: in-progress tasks and active session env. + all = append(all, WorkingRefs(contextDir)...) + + return Deduplicate(all) +} + +// FormatTrailer formats a slice of refs as a git trailer line. +// Returns an empty string when refs is empty. +// +// Parameters: +// - refs: context reference strings (e.g. "decision:12", "task:8") +// +// Returns: +// - string: git trailer like "ctx-context: decision:12, task:8", or "" +func FormatTrailer(refs []string) string { + if len(refs) == 0 { + return "" + } + return fmt.Sprintf(cfgTrace.TrailerFormat, strings.Join(refs, token.CommaSpace)) +} + +// Deduplicate returns a new slice with duplicate entries removed. +// The first occurrence of each value is preserved; order is maintained. +// +// Parameters: +// - refs: input slice (may contain duplicates) +// +// Returns: +// - []string: deduplicated slice, or nil when input is empty +func Deduplicate(refs []string) []string { + if len(refs) == 0 { + return nil + } + + seen := make(map[string]struct{}, len(refs)) + out := make([]string, 0, len(refs)) + + for _, r := range refs { + if _, ok := seen[r]; ok { + continue + } + seen[r] = struct{}{} + out = append(out, r) + } + + if len(out) == 0 { + return nil + } + + return out +} diff --git a/internal/trace/collect_test.go b/internal/trace/collect_test.go new file mode 100644 index 000000000..85cbb3538 --- /dev/null +++ b/internal/trace/collect_test.go @@ -0,0 +1,90 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "os" + "path/filepath" + "testing" + + cfgDir "github.com/ActiveMemory/ctx/internal/config/dir" +) + +func TestCollectDeduplicates(t *testing.T) { + contextDir := t.TempDir() + + // Create state directory for pending records. + stateDir := filepath.Join(contextDir, cfgDir.State) + if err := os.MkdirAll(stateDir, 0o700); err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + + // Write TASKS.md with one pending task so WorkingRefs returns "task:1". + tasks := "# Tasks\n\n- [ ] First pending task\n" + if err := os.WriteFile(filepath.Join(contextDir, "TASKS.md"), []byte(tasks), 0o600); err != nil { + t.Fatalf("WriteFile(TASKS.md) error: %v", err) + } + + // Record "task:1" in pending — it will also appear from WorkingRefs. + if err := Record("task:1", stateDir); err != nil { + t.Fatalf("Record(task:1) error: %v", err) + } + // Record "decision:5" in pending — appears only once. + if err := Record("decision:5", stateDir); err != nil { + t.Fatalf("Record(decision:5) error: %v", err) + } + + refs := Collect(contextDir) + + seen := map[string]int{} + for _, r := range refs { + seen[r]++ + } + + if seen["task:1"] != 1 { + t.Errorf("task:1 count = %d, want 1 (deduplication failed); refs = %v", seen["task:1"], refs) + } + if seen["decision:5"] != 1 { + t.Errorf("decision:5 count = %d, want 1; refs = %v", seen["decision:5"], refs) + } +} + +func TestCollectEmptyReturnsNil(t *testing.T) { + contextDir := t.TempDir() + + // Create state directory but no pending file; no TASKS.md; no session env. + stateDir := filepath.Join(contextDir, cfgDir.State) + if err := os.MkdirAll(stateDir, 0o700); err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + + // Write an empty TASKS.md so WorkingRefs finds no pending tasks. + if err := os.WriteFile(filepath.Join(contextDir, "TASKS.md"), []byte("# Tasks\n"), 0o600); err != nil { + t.Fatalf("WriteFile(TASKS.md) error: %v", err) + } + + refs := Collect(contextDir) + if len(refs) != 0 { + t.Errorf("Collect() returned %v, want empty", refs) + } +} + +func TestFormatTrailer(t *testing.T) { + refs := []string{"decision:12", "task:8", "session:abc123"} + got := FormatTrailer(refs) + want := "ctx-context: decision:12, task:8, session:abc123" + if got != want { + t.Errorf("FormatTrailer() = %q, want %q", got, want) + } +} + +func TestFormatTrailerEmpty(t *testing.T) { + got := FormatTrailer(nil) + if got != "" { + t.Errorf("FormatTrailer(nil) = %q, want empty string", got) + } +} diff --git a/internal/trace/doc.go b/internal/trace/doc.go new file mode 100644 index 000000000..cb8cbcfb6 --- /dev/null +++ b/internal/trace/doc.go @@ -0,0 +1,15 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package trace provides commit context tracing — linking git commits +// back to the decisions, tasks, learnings, and sessions that motivated them. +// +// Key exports: [Collect], [FormatTrailer], [Record], [Resolve], [ShortHash], +// [ReadHistory], [WriteHistory], [ReadOverrides], [WriteOverride], +// [CollectRefsForCommit], [ResolveCommitHash], [CommitMessage], [CommitDate]. +// See source files for implementation details. +// Part of the internal subsystem. +package trace diff --git a/internal/trace/git.go b/internal/trace/git.go new file mode 100644 index 000000000..cf40c3e8b --- /dev/null +++ b/internal/trace/git.go @@ -0,0 +1,152 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "fmt" + "strings" + + cfgGit "github.com/ActiveMemory/ctx/internal/config/git" + "github.com/ActiveMemory/ctx/internal/config/token" + cfgTrace "github.com/ActiveMemory/ctx/internal/config/trace" + "github.com/ActiveMemory/ctx/internal/exec/git" +) + +// ShortHash returns the first ShortHashLen characters of a commit hash. +// +// Parameters: +// - hash: full or abbreviated commit hash +// +// Returns: +// - string: abbreviated hash, or the original if already short +func ShortHash(hash string) string { + if len(hash) <= cfgTrace.ShortHashLen { + return hash + } + return hash[:cfgTrace.ShortHashLen] +} + +// ReadTrailerRefs reads ctx-context trailer refs from a commit message. +// +// Parameters: +// - commitHash: full commit hash to read trailers from +// +// Returns: +// - []string: parsed context refs, or empty slice on error +func ReadTrailerRefs(commitHash string) []string { + out, err := git.Run( + cfgGit.Log, cfgGit.FlagLast, + fmt.Sprintf(cfgGit.FormatTrailerValue, cfgTrace.TrailerKey), + commitHash, + ) + if err != nil { + return []string{} + } + + var refs []string + for _, line := range strings.Split( + strings.TrimSpace(string(out)), token.NewlineLF, + ) { + for _, ref := range strings.Split( + strings.TrimSpace(line), token.CommaSpace, + ) { + ref = strings.TrimSpace(ref) + if ref != "" { + refs = append(refs, ref) + } + } + } + + return refs +} + +// ResolveCommitHash resolves a short ref to a full commit hash. +// +// Parameters: +// - ref: git commit reference (e.g. "HEAD", "abc1234") +// +// Returns: +// - string: full commit hash +// - error: non-nil if git rev-parse fails +func ResolveCommitHash(ref string) (string, error) { + out, err := git.Run(cfgGit.RevParse, ref) + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// CommitMessage returns the subject line of a commit. +// +// Parameters: +// - hash: full commit hash +// +// Returns: +// - string: commit subject line +// - error: non-nil if git log fails +func CommitMessage(hash string) (string, error) { + out, err := git.Run( + cfgGit.Log, cfgGit.FlagLast, cfgGit.FormatSubject, hash, + ) + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// CommitDate returns the commit date string in ISO format. +// +// Parameters: +// - hash: full commit hash +// +// Returns: +// - string: commit date, or empty string on error +func CommitDate(hash string) string { + out, err := git.Run( + cfgGit.Log, cfgGit.FlagLast, cfgGit.FormatDateISO, hash, + ) + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// CollectRefsForCommit gathers context refs for a commit from +// history, overrides, and optionally git trailers. +// +// Parameters: +// - commitHash: full or abbreviated commit hash +// - traceDir: absolute path to the trace directory +// - includeTrailers: whether to read git trailers (slow for bulk) +// +// Returns: +// - []string: deduplicated refs from all sources +func CollectRefsForCommit( + commitHash, traceDir string, includeTrailers bool, +) []string { + var all []string + + // Source 1: history.jsonl + if entry, ok := ReadHistoryForCommit(commitHash, traceDir); ok { + all = append(all, entry.Refs...) + } + + // Source 2: git trailers (optional — slow for bulk operations) + if includeTrailers { + all = append(all, ReadTrailerRefs(commitHash)...) + } + + // Source 3: overrides.jsonl + all = append(all, ReadOverridesForCommit(commitHash, traceDir)...) + + result := Deduplicate(all) + if result == nil { + return []string{} + } + + return result +} diff --git a/internal/trace/history.go b/internal/trace/history.go new file mode 100644 index 000000000..835b95502 --- /dev/null +++ b/internal/trace/history.go @@ -0,0 +1,145 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "path/filepath" + "strings" + "time" + + cfgTrace "github.com/ActiveMemory/ctx/internal/config/trace" +) + +// WriteHistory appends a HistoryEntry to history.jsonl in traceDir. +// If entry.Timestamp is zero it is set to the current UTC time. +// The traceDir is created with MkdirAll if it does not exist. +// +// Parameters: +// - entry: the HistoryEntry to persist +// - traceDir: absolute path to the trace directory +// +// Returns: +// - error: non-nil if the directory cannot be created or the entry cannot be written +func WriteHistory(entry HistoryEntry, traceDir string) error { + if entry.Timestamp.IsZero() { + entry.Timestamp = time.Now().UTC() + } + + return appendJSONL(traceDir, cfgTrace.FileHistory, entry) +} + +// ReadHistory reads all HistoryEntry records from history.jsonl in traceDir. +// Malformed JSONL lines are silently skipped. +// Returns an empty (non-nil) slice when the file does not exist. +// +// Parameters: +// - traceDir: absolute path to the trace directory +// +// Returns: +// - []HistoryEntry: entries in file order +// - error: non-nil only if the file exists but cannot be read +func ReadHistory(traceDir string) ([]HistoryEntry, error) { + path := filepath.Join(traceDir, cfgTrace.FileHistory) + return readJSONL[HistoryEntry](path) +} + +// ReadHistoryForCommit finds the first HistoryEntry whose Commit field matches +// commitHash. Matching supports short hashes by checking whether either string +// is a prefix of the other. +// +// Parameters: +// - commitHash: full or abbreviated commit hash to look up +// - traceDir: absolute path to the trace directory +// +// Returns: +// - HistoryEntry: the matching entry (zero value if not found) +// - bool: true if a match was found +func ReadHistoryForCommit(commitHash, traceDir string) (HistoryEntry, bool) { + entries, err := ReadHistory(traceDir) + if err != nil { + return HistoryEntry{}, false + } + + for _, e := range entries { + if matchesCommit(e.Commit, commitHash) { + return e, true + } + } + + return HistoryEntry{}, false +} + +// matchesCommit checks whether stored and query commit hashes match. +// Supports short hashes by checking whether either string is a prefix +// of the other. +func matchesCommit(stored, query string) bool { + return strings.HasPrefix(stored, query) || strings.HasPrefix(query, stored) +} + +// WriteOverride appends an OverrideEntry to overrides.jsonl in traceDir. +// If entry.Timestamp is zero it is set to the current UTC time. +// The traceDir is created with MkdirAll if it does not exist. +// +// Parameters: +// - entry: the OverrideEntry to persist +// - traceDir: absolute path to the trace directory +// +// Returns: +// - error: non-nil if the directory cannot be created or the entry cannot be written +func WriteOverride(entry OverrideEntry, traceDir string) error { + if entry.Timestamp.IsZero() { + entry.Timestamp = time.Now().UTC() + } + + return appendJSONL(traceDir, cfgTrace.FileOverrides, entry) +} + +// ReadOverrides reads all OverrideEntry records from overrides.jsonl in traceDir. +// Malformed JSONL lines are silently skipped. +// Returns an empty (non-nil) slice when the file does not exist. +// +// Parameters: +// - traceDir: absolute path to the trace directory +// +// Returns: +// - []OverrideEntry: entries in file order +// - error: non-nil only if the file exists but cannot be read +func ReadOverrides(traceDir string) ([]OverrideEntry, error) { + path := filepath.Join(traceDir, cfgTrace.FileOverrides) + return readJSONL[OverrideEntry](path) +} + +// ReadOverridesForCommit collects all Refs from OverrideEntry records whose +// Commit field matches commitHash. Matching supports short hashes by checking +// whether either string is a prefix of the other. Refs from all matching +// entries are concatenated and returned as a flat list. +// +// Parameters: +// - commitHash: full or abbreviated commit hash to look up +// - traceDir: absolute path to the trace directory +// +// Returns: +// - []string: flattened list of refs from all matching override entries +func ReadOverridesForCommit(commitHash, traceDir string) []string { + entries, err := ReadOverrides(traceDir) + if err != nil { + return []string{} + } + + var refs []string + for _, e := range entries { + if matchesCommit(e.Commit, commitHash) { + refs = append(refs, e.Refs...) + } + } + + if refs == nil { + return []string{} + } + + return refs +} diff --git a/internal/trace/history_test.go b/internal/trace/history_test.go new file mode 100644 index 000000000..b0624e218 --- /dev/null +++ b/internal/trace/history_test.go @@ -0,0 +1,194 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "testing" + "time" +) + +func TestWriteHistory(t *testing.T) { + traceDir := t.TempDir() + + entry := HistoryEntry{ + Commit: "abc1234", + Refs: []string{"T-1", "D-2"}, + Message: "feat: add thing", + } + + beforeWrite := time.Now().UTC().Truncate(time.Second) + if err := WriteHistory(entry, traceDir); err != nil { + t.Fatalf("WriteHistory() error: %v", err) + } + + entries, readErr := ReadHistory(traceDir) + if readErr != nil { + t.Fatalf("ReadHistory() error: %v", readErr) + } + if len(entries) != 1 { + t.Fatalf("ReadHistory() returned %d entries, want 1", len(entries)) + } + + got := entries[0] + if got.Commit != entry.Commit { + t.Errorf("Commit = %q, want %q", got.Commit, entry.Commit) + } + if got.Message != entry.Message { + t.Errorf("Message = %q, want %q", got.Message, entry.Message) + } + if len(got.Refs) != 2 { + t.Errorf("Refs len = %d, want 2", len(got.Refs)) + } + if got.Timestamp.Before(beforeWrite) { + t.Errorf("Timestamp %v is before write time %v", got.Timestamp, beforeWrite) + } +} + +func TestReadHistoryEmpty(t *testing.T) { + traceDir := t.TempDir() + + entries, readErr := ReadHistory(traceDir) + if readErr != nil { + t.Fatalf("ReadHistory() on missing file returned error: %v", readErr) + } + if len(entries) != 0 { + t.Errorf("ReadHistory() returned %d entries, want 0", len(entries)) + } +} + +func TestReadHistoryForCommit(t *testing.T) { + traceDir := t.TempDir() + + e1 := HistoryEntry{ + Commit: "deadbeef1234", + Refs: []string{"T-1"}, + Message: "first commit", + } + e2 := HistoryEntry{ + Commit: "cafebabe5678", + Refs: []string{"D-3"}, + Message: "second commit", + } + + if err := WriteHistory(e1, traceDir); err != nil { + t.Fatalf("WriteHistory(e1) error: %v", err) + } + if err := WriteHistory(e2, traceDir); err != nil { + t.Fatalf("WriteHistory(e2) error: %v", err) + } + + // Find by full hash. + got, ok := ReadHistoryForCommit("deadbeef1234", traceDir) + if !ok { + t.Fatal("ReadHistoryForCommit(full hash) returned false, want true") + } + if got.Commit != e1.Commit { + t.Errorf("Commit = %q, want %q", got.Commit, e1.Commit) + } + + // Find by short hash (prefix of stored hash). + got, ok = ReadHistoryForCommit("deadbeef", traceDir) + if !ok { + t.Fatal("ReadHistoryForCommit(short hash) returned false, want true") + } + if got.Commit != e1.Commit { + t.Errorf("Commit = %q, want %q", got.Commit, e1.Commit) + } + + // Missing hash returns false. + _, ok = ReadHistoryForCommit("0000000", traceDir) + if ok { + t.Error("ReadHistoryForCommit(missing) returned true, want false") + } +} + +func TestWriteOverride(t *testing.T) { + traceDir := t.TempDir() + + entry := OverrideEntry{ + Commit: "abc1234", + Refs: []string{"L-7", "T-2"}, + } + + beforeWrite := time.Now().UTC().Truncate(time.Second) + if err := WriteOverride(entry, traceDir); err != nil { + t.Fatalf("WriteOverride() error: %v", err) + } + + entries, readErr := ReadOverrides(traceDir) + if readErr != nil { + t.Fatalf("ReadOverrides() error: %v", readErr) + } + if len(entries) != 1 { + t.Fatalf("ReadOverrides() returned %d entries, want 1", len(entries)) + } + + got := entries[0] + if got.Commit != entry.Commit { + t.Errorf("Commit = %q, want %q", got.Commit, entry.Commit) + } + if len(got.Refs) != 2 { + t.Errorf("Refs len = %d, want 2", len(got.Refs)) + } + if got.Timestamp.Before(beforeWrite) { + t.Errorf("Timestamp %v is before write time %v", got.Timestamp, beforeWrite) + } +} + +func TestReadOverridesEmpty(t *testing.T) { + traceDir := t.TempDir() + + entries, readErr := ReadOverrides(traceDir) + if readErr != nil { + t.Fatalf("ReadOverrides() on missing file returned error: %v", readErr) + } + if len(entries) != 0 { + t.Errorf("ReadOverrides() returned %d entries, want 0", len(entries)) + } +} + +func TestReadOverridesForCommit(t *testing.T) { + traceDir := t.TempDir() + + // Two overrides for the same commit, one for a different commit. + o1 := OverrideEntry{ + Commit: "deadbeef1234", + Refs: []string{"T-1", "D-2"}, + } + o2 := OverrideEntry{ + Commit: "deadbeef1234", + Refs: []string{"L-5"}, + } + o3 := OverrideEntry{ + Commit: "cafebabe5678", + Refs: []string{"T-9"}, + } + + for _, o := range []OverrideEntry{o1, o2, o3} { + if err := WriteOverride(o, traceDir); err != nil { + t.Fatalf("WriteOverride() error: %v", err) + } + } + + refs := ReadOverridesForCommit("deadbeef1234", traceDir) + // o1 has 2 refs, o2 has 1 ref — total 3 for deadbeef1234. + if len(refs) != 3 { + t.Errorf("ReadOverridesForCommit() returned %d refs, want 3", len(refs)) + } + + // Different commit should return its own refs. + otherRefs := ReadOverridesForCommit("cafebabe5678", traceDir) + if len(otherRefs) != 1 { + t.Errorf("ReadOverridesForCommit(other) returned %d refs, want 1", len(otherRefs)) + } + + // Non-existent commit returns empty slice. + noRefs := ReadOverridesForCommit("0000000", traceDir) + if len(noRefs) != 0 { + t.Errorf("ReadOverridesForCommit(missing) returned %d refs, want 0", len(noRefs)) + } +} diff --git a/internal/trace/jsonl.go b/internal/trace/jsonl.go new file mode 100644 index 000000000..d3ce77dd3 --- /dev/null +++ b/internal/trace/jsonl.go @@ -0,0 +1,77 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "bufio" + "encoding/json" + "errors" + "os" + "path/filepath" + + cfgFs "github.com/ActiveMemory/ctx/internal/config/fs" + "github.com/ActiveMemory/ctx/internal/config/token" + "github.com/ActiveMemory/ctx/internal/io" +) + +// readJSONL is a generic helper that opens the file at path and decodes each +// line as a JSON value of type T. Malformed lines are silently skipped. +// Returns an empty (non-nil) slice when the file does not exist. +func readJSONL[T any](path string) ([]T, error) { + f, openErr := io.SafeOpenUserFile(path) + if openErr != nil { + if errors.Is(openErr, os.ErrNotExist) { + return []T{}, nil + } + return nil, openErr + } + defer func() { _ = f.Close() }() + + var entries []T + scanner := bufio.NewScanner(f) + for scanner.Scan() { + var e T + if unmarshalErr := json.Unmarshal(scanner.Bytes(), &e); unmarshalErr != nil { + continue // skip malformed lines + } + entries = append(entries, e) + } + + if scanErr := scanner.Err(); scanErr != nil { + return nil, scanErr + } + + if entries == nil { + entries = []T{} + } + + return entries, nil +} + +// appendJSONL marshals entry as JSON and appends it as a line to dir/filename. +// Creates the directory if needed. +func appendJSONL[T any](dir, filename string, entry T) error { + if mkErr := os.MkdirAll(dir, cfgFs.PermRestrictedDir); mkErr != nil { + return mkErr + } + + line, marshalErr := json.Marshal(entry) + if marshalErr != nil { + return marshalErr + } + line = append(line, token.NewlineLF...) + + path := filepath.Join(dir, filename) + f, openErr := io.SafeAppendFile(path, cfgFs.PermFile) + if openErr != nil { + return openErr + } + defer func() { _ = f.Close() }() + + _, writeErr := f.Write(line) + return writeErr +} diff --git a/internal/trace/parse.go b/internal/trace/parse.go new file mode 100644 index 000000000..49c220241 --- /dev/null +++ b/internal/trace/parse.go @@ -0,0 +1,60 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "strconv" + "strings" + + "github.com/ActiveMemory/ctx/internal/config/token" + cfgTrace "github.com/ActiveMemory/ctx/internal/config/trace" +) + +// parseRef breaks a raw reference string into its components. +// +// Formats: +// +// "decision:12" → ("decision", 12, "") +// "session:abc" → ("session", 0, "abc") +// `"Some note"` → ("note", 0, "Some note") +// unknown → ("note", 0, ref) +// +// Parameters: +// - ref: raw reference string +// +// Returns: +// - refType: type keyword (decision, learning, convention, task, session, note) +// - number: numeric value, 0 when not applicable +// - text: text value, empty when not applicable +func parseRef(ref string) (refType string, number int, text string) { + // Quoted strings are free-form notes. + if strings.HasPrefix(ref, token.DoubleQuote) && strings.HasSuffix(ref, token.DoubleQuote) { + return cfgTrace.RefTypeNote, 0, strings.Trim(ref, token.DoubleQuote) + } + + parts := strings.SplitN(ref, token.Colon, 2) + if len(parts) != 2 { + return cfgTrace.RefTypeNote, 0, ref + } + + kind := parts[0] + value := parts[1] + + switch kind { + case cfgTrace.RefTypeDecision, cfgTrace.RefTypeLearning, + cfgTrace.RefTypeConvention, cfgTrace.RefTypeTask: + n, err := strconv.Atoi(value) + if err != nil { + return cfgTrace.RefTypeNote, 0, ref + } + return kind, n, "" + case cfgTrace.RefTypeSession: + return cfgTrace.RefTypeSession, 0, value + default: + return cfgTrace.RefTypeNote, 0, ref + } +} diff --git a/internal/trace/pending.go b/internal/trace/pending.go new file mode 100644 index 000000000..921cd3418 --- /dev/null +++ b/internal/trace/pending.go @@ -0,0 +1,77 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "errors" + "os" + "path/filepath" + "time" + + cfgFs "github.com/ActiveMemory/ctx/internal/config/fs" + cfgTrace "github.com/ActiveMemory/ctx/internal/config/trace" +) + +// Record appends a single pending context reference to the pending file +// in stateDir. The operation is best-effort: directory creation and +// write errors are returned to the caller but do not panic. +// +// Parameters: +// - ref: context reference string (e.g. "T-3", "D-1", "L-5") +// - stateDir: absolute path to the state directory +// +// Returns: +// - error: non-nil if the directory cannot be created or the entry +// cannot be written +func Record(ref, stateDir string) error { + entry := PendingEntry{ + Ref: ref, + Timestamp: time.Now().UTC(), + } + + return appendJSONL(stateDir, cfgTrace.FilePending, entry) +} + +// ReadPending reads all pending context reference entries from the +// pending file in stateDir. Malformed JSONL lines are silently skipped. +// Returns an empty (non-nil) slice when the file does not exist. +// +// Parameters: +// - stateDir: absolute path to the state directory +// +// Returns: +// - []PendingEntry: entries in file order +// - error: non-nil only if the file exists but cannot be opened +func ReadPending(stateDir string) ([]PendingEntry, error) { + path := filepath.Join(stateDir, cfgTrace.FilePending) + return readJSONL[PendingEntry](path) +} + +// TruncatePending empties the pending file in stateDir without deleting +// it. If the file does not exist the call is a no-op. +// +// Parameters: +// - stateDir: absolute path to the state directory +// +// Returns: +// - error: non-nil if the file exists but cannot be truncated +func TruncatePending(stateDir string) error { + path := filepath.Join(stateDir, cfgTrace.FilePending) + //nolint:gosec // path built from trusted stateDir + constant filename + f, openErr := os.OpenFile( + filepath.Clean(path), + os.O_TRUNC|os.O_WRONLY, + cfgFs.PermFile, + ) + if openErr != nil { + if errors.Is(openErr, os.ErrNotExist) { + return nil + } + return openErr + } + return f.Close() +} diff --git a/internal/trace/pending_test.go b/internal/trace/pending_test.go new file mode 100644 index 000000000..d0192ece1 --- /dev/null +++ b/internal/trace/pending_test.go @@ -0,0 +1,134 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "os" + "path/filepath" + "testing" + "time" + + cfgTrace "github.com/ActiveMemory/ctx/internal/config/trace" +) + +func TestRecord(t *testing.T) { + stateDir := t.TempDir() + + beforeRecord := time.Now().UTC().Truncate(time.Second) + if err := Record("T-1", stateDir); err != nil { + t.Fatalf("Record() error: %v", err) + } + + entries, readErr := ReadPending(stateDir) + if readErr != nil { + t.Fatalf("ReadPending() error: %v", readErr) + } + if len(entries) != 1 { + t.Fatalf("ReadPending() returned %d entries, want 1", len(entries)) + } + if entries[0].Ref != "T-1" { + t.Errorf("Ref = %q, want %q", entries[0].Ref, "T-1") + } + if entries[0].Timestamp.Before(beforeRecord) { + t.Errorf("Timestamp %v is before record time %v", entries[0].Timestamp, beforeRecord) + } +} + +func TestRecordMultiple(t *testing.T) { + stateDir := t.TempDir() + + refs := []string{"T-1", "D-2", "L-3"} + for _, ref := range refs { + if err := Record(ref, stateDir); err != nil { + t.Fatalf("Record(%q) error: %v", ref, err) + } + } + + pendingPath := filepath.Join(stateDir, cfgTrace.FilePending) + data, readErr := os.ReadFile(pendingPath) //nolint:gosec // test file + if readErr != nil { + t.Fatalf("ReadFile() error: %v", readErr) + } + + // Count newlines — each JSONL entry ends with one. + lineCount := 0 + for _, b := range data { + if b == '\n' { + lineCount++ + } + } + if lineCount != 3 { + t.Errorf("got %d lines in file, want 3", lineCount) + } +} + +func TestReadPending(t *testing.T) { + stateDir := t.TempDir() + + if err := Record("D-1", stateDir); err != nil { + t.Fatalf("Record(D-1) error: %v", err) + } + if err := Record("L-5", stateDir); err != nil { + t.Fatalf("Record(L-5) error: %v", err) + } + + entries, readErr := ReadPending(stateDir) + if readErr != nil { + t.Fatalf("ReadPending() error: %v", readErr) + } + if len(entries) != 2 { + t.Fatalf("ReadPending() returned %d entries, want 2", len(entries)) + } + if entries[0].Ref != "D-1" { + t.Errorf("entries[0].Ref = %q, want %q", entries[0].Ref, "D-1") + } + if entries[1].Ref != "L-5" { + t.Errorf("entries[1].Ref = %q, want %q", entries[1].Ref, "L-5") + } + if entries[0].Timestamp.IsZero() { + t.Error("entries[0].Timestamp is zero") + } + if entries[1].Timestamp.IsZero() { + t.Error("entries[1].Timestamp is zero") + } +} + +func TestReadPendingEmpty(t *testing.T) { + // Use a directory that does not contain the pending file. + stateDir := t.TempDir() + + entries, readErr := ReadPending(stateDir) + if readErr != nil { + t.Fatalf("ReadPending() on missing file returned error: %v", readErr) + } + if len(entries) != 0 { + t.Errorf("ReadPending() returned %d entries, want 0", len(entries)) + } +} + +func TestTruncatePending(t *testing.T) { + stateDir := t.TempDir() + + if err := Record("T-2", stateDir); err != nil { + t.Fatalf("Record() error: %v", err) + } + if err := Record("T-3", stateDir); err != nil { + t.Fatalf("Record() error: %v", err) + } + + if err := TruncatePending(stateDir); err != nil { + t.Fatalf("TruncatePending() error: %v", err) + } + + entries, readErr := ReadPending(stateDir) + if readErr != nil { + t.Fatalf("ReadPending() after truncate error: %v", readErr) + } + if len(entries) != 0 { + t.Errorf("ReadPending() after truncate returned %d entries, want 0", len(entries)) + } +} diff --git a/internal/trace/resolve.go b/internal/trace/resolve.go new file mode 100644 index 000000000..6d88390c9 --- /dev/null +++ b/internal/trace/resolve.go @@ -0,0 +1,49 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + cfgCtx "github.com/ActiveMemory/ctx/internal/config/ctx" + cfgTrace "github.com/ActiveMemory/ctx/internal/config/trace" +) + +// Resolve looks up a raw reference and returns its full details. +// +// Parameters: +// - ref: raw reference string (e.g. "decision:12", "task:8", `"Some note"`) +// - contextDir: absolute path to the .context/ directory +// +// Returns: +// - ResolvedRef: resolved reference with title, detail, and found status +func Resolve(ref, contextDir string) ResolvedRef { + refType, number, text := parseRef(ref) + + resolved := ResolvedRef{ + Raw: ref, + Type: refType, + Number: number, + } + + switch refType { + case cfgTrace.RefTypeDecision: + return resolveEntry(resolved, contextDir, cfgCtx.Decision, number) + case cfgTrace.RefTypeLearning: + return resolveEntry(resolved, contextDir, cfgCtx.Learning, number) + case cfgTrace.RefTypeConvention: + return resolveEntry(resolved, contextDir, cfgCtx.Convention, number) + case cfgTrace.RefTypeTask: + return resolveTask(resolved, contextDir, number) + case cfgTrace.RefTypeSession: + resolved.Title = text + resolved.Found = true + return resolved + default: // cfgTrace.RefTypeNote + resolved.Title = text + resolved.Found = true + return resolved + } +} diff --git a/internal/trace/resolve_entry.go b/internal/trace/resolve_entry.go new file mode 100644 index 000000000..db50d49c8 --- /dev/null +++ b/internal/trace/resolve_entry.go @@ -0,0 +1,107 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "bufio" + "fmt" + "path/filepath" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + cfgCtx "github.com/ActiveMemory/ctx/internal/config/ctx" + "github.com/ActiveMemory/ctx/internal/config/embed/text" + "github.com/ActiveMemory/ctx/internal/config/regex" + cfgTrace "github.com/ActiveMemory/ctx/internal/config/trace" + "github.com/ActiveMemory/ctx/internal/index" + "github.com/ActiveMemory/ctx/internal/io" + "github.com/ActiveMemory/ctx/internal/task" +) + +// resolveEntry reads the specified context file, parses entry headers, +// and finds the entry at the given 1-based position. +// +// Parameters: +// - resolved: partially populated ResolvedRef (Raw, Type, Number already set) +// - contextDir: absolute path to the .context/ directory +// - fileName: context file name (e.g. "DECISIONS.md") +// - number: 1-based entry number +// +// Returns: +// - ResolvedRef: populated with Title and Detail if found +func resolveEntry(resolved ResolvedRef, contextDir, fileName string, number int) ResolvedRef { + path := filepath.Clean(filepath.Join(contextDir, fileName)) + + content, err := io.SafeReadUserFile(path) + if err != nil { + return resolved + } + + entries := index.ParseHeaders(string(content)) + + // Entries are 1-based; index into slice using number-1. + if number < 1 || number > len(entries) { + return resolved + } + + entry := entries[number-1] + resolved.Title = entry.Title + resolved.Detail = fmt.Sprintf(desc.Text(text.DescKeyWriteTraceDetailDate), entry.Date) + resolved.Found = true + + return resolved +} + +// resolveTask reads TASKS.md and finds the nth top-level task (1-based), +// counting both pending and completed tasks sequentially. +// +// Parameters: +// - resolved: partially populated ResolvedRef (Raw, Type, Number already set) +// - contextDir: absolute path to the .context/ directory +// - number: 1-based task number +// +// Returns: +// - ResolvedRef: populated with Title and Detail if found +func resolveTask(resolved ResolvedRef, contextDir string, number int) ResolvedRef { + path := filepath.Clean(filepath.Join(contextDir, cfgCtx.Task)) + + f, err := io.SafeOpenUserFile(path) + if err != nil { + return resolved + } + defer func() { _ = f.Close() }() + + count := 0 + scanner := bufio.NewScanner(f) + + for scanner.Scan() { + line := scanner.Text() + m := regex.Task.FindStringSubmatch(line) + if m == nil { + continue + } + // Skip subtasks (indented). + if task.Sub(m) { + continue + } + + count++ + if count == number { + status := cfgTrace.StatusPending + if task.Completed(m) { + status = cfgTrace.StatusCompleted + } + resolved.Title = task.Content(m) + resolved.Detail = fmt.Sprintf( + desc.Text(text.DescKeyWriteTraceDetailStatus), status, + ) + resolved.Found = true + return resolved + } + } + + return resolved +} diff --git a/internal/trace/resolve_test.go b/internal/trace/resolve_test.go new file mode 100644 index 000000000..e91979960 --- /dev/null +++ b/internal/trace/resolve_test.go @@ -0,0 +1,178 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "os" + "path/filepath" + "testing" +) + +func Test_parseRef(t *testing.T) { + tests := []struct { + input string + wantType string + wantNumber int + wantText string + }{ + {"decision:12", "decision", 12, ""}, + {"learning:5", "learning", 5, ""}, + {"task:8", "task", 8, ""}, + {"convention:3", "convention", 3, ""}, + {"session:abc123", "session", 0, "abc123"}, + {`"Hotfix note"`, "note", 0, "Hotfix note"}, + {"unknown", "note", 0, "unknown"}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + gotType, gotNumber, gotText := parseRef(tc.input) + if gotType != tc.wantType { + t.Errorf("parseRef(%q) type = %q, want %q", tc.input, gotType, tc.wantType) + } + if gotNumber != tc.wantNumber { + t.Errorf("parseRef(%q) number = %d, want %d", tc.input, gotNumber, tc.wantNumber) + } + if gotText != tc.wantText { + t.Errorf("parseRef(%q) text = %q, want %q", tc.input, gotText, tc.wantText) + } + }) + } +} + +func TestResolveDecision(t *testing.T) { + contextDir := t.TempDir() + + decisions := `# Decisions + +## [2026-01-10-120000] Use PostgreSQL for storage + +Some rationale here. + +## [2026-02-15-140000] Adopt hexagonal architecture + +Another rationale. +` + if err := os.WriteFile(filepath.Join(contextDir, "DECISIONS.md"), []byte(decisions), 0o600); err != nil { + t.Fatalf("WriteFile(DECISIONS.md) error: %v", err) + } + + resolved := Resolve("decision:1", contextDir) + + if !resolved.Found { + t.Fatalf("Resolve(decision:1) Found = false, want true") + } + if resolved.Title != "Use PostgreSQL for storage" { + t.Errorf("Resolve(decision:1) Title = %q, want %q", resolved.Title, "Use PostgreSQL for storage") + } + if resolved.Raw != "decision:1" { + t.Errorf("Resolve(decision:1) Raw = %q, want %q", resolved.Raw, "decision:1") + } + if resolved.Type != "decision" { + t.Errorf("Resolve(decision:1) Type = %q, want %q", resolved.Type, "decision") + } +} + +func TestResolveTask(t *testing.T) { + contextDir := t.TempDir() + + tasks := `# Tasks + +- [ ] First pending task +- [x] Completed task +- [ ] Second pending task +` + if err := os.WriteFile(filepath.Join(contextDir, "TASKS.md"), []byte(tasks), 0o600); err != nil { + t.Fatalf("WriteFile(TASKS.md) error: %v", err) + } + + resolved := Resolve("task:1", contextDir) + + if !resolved.Found { + t.Fatalf("Resolve(task:1) Found = false, want true") + } + if resolved.Title != "First pending task" { + t.Errorf("Resolve(task:1) Title = %q, want %q", resolved.Title, "First pending task") + } +} + +func TestResolveTaskCompleted(t *testing.T) { + contextDir := t.TempDir() + + tasks := `# Tasks + +- [ ] First pending task +- [x] Completed task +- [ ] Second pending task +` + if err := os.WriteFile(filepath.Join(contextDir, "TASKS.md"), []byte(tasks), 0o600); err != nil { + t.Fatalf("WriteFile(TASKS.md) error: %v", err) + } + + // task:2 should be the completed task (second top-level task overall) + resolved := Resolve("task:2", contextDir) + + if !resolved.Found { + t.Fatalf("Resolve(task:2) Found = false, want true") + } + if resolved.Title != "Completed task" { + t.Errorf("Resolve(task:2) Title = %q, want %q", resolved.Title, "Completed task") + } + if resolved.Detail != "Status: completed" { + t.Errorf("Resolve(task:2) Detail = %q, want %q", resolved.Detail, "Status: completed") + } +} + +func TestResolveNotFound(t *testing.T) { + contextDir := t.TempDir() + + // Empty DECISIONS.md + if err := os.WriteFile(filepath.Join(contextDir, "DECISIONS.md"), []byte("# Decisions\n"), 0o600); err != nil { + t.Fatalf("WriteFile(DECISIONS.md) error: %v", err) + } + + resolved := Resolve("decision:999", contextDir) + + if resolved.Found { + t.Errorf("Resolve(decision:999) Found = true, want false") + } + if resolved.Raw != "decision:999" { + t.Errorf("Resolve(decision:999) Raw = %q, want %q", resolved.Raw, "decision:999") + } +} + +func TestResolveNote(t *testing.T) { + contextDir := t.TempDir() + + resolved := Resolve(`"Hotfix for production bug"`, contextDir) + + if !resolved.Found { + t.Fatalf("Resolve(note) Found = false, want true") + } + if resolved.Title != "Hotfix for production bug" { + t.Errorf("Resolve(note) Title = %q, want %q", resolved.Title, "Hotfix for production bug") + } + if resolved.Type != "note" { + t.Errorf("Resolve(note) Type = %q, want %q", resolved.Type, "note") + } +} + +func TestResolveSession(t *testing.T) { + contextDir := t.TempDir() + + resolved := Resolve("session:abc123", contextDir) + + if !resolved.Found { + t.Fatalf("Resolve(session:abc123) Found = false, want true") + } + if resolved.Title != "abc123" { + t.Errorf("Resolve(session:abc123) Title = %q, want %q", resolved.Title, "abc123") + } + if resolved.Type != "session" { + t.Errorf("Resolve(session:abc123) Type = %q, want %q", resolved.Type, "session") + } +} diff --git a/internal/trace/staged.go b/internal/trace/staged.go new file mode 100644 index 000000000..8c7417b7c --- /dev/null +++ b/internal/trace/staged.go @@ -0,0 +1,74 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "path/filepath" + + cfgCtx "github.com/ActiveMemory/ctx/internal/config/ctx" + cfgTrace "github.com/ActiveMemory/ctx/internal/config/trace" +) + +// StagedRefs detects context refs from staged .context/ file diffs. +// +// For each of DECISIONS.md, LEARNINGS.md, and CONVENTIONS.md it runs +// git diff --cached on the file and calls parseAddedEntries. For TASKS.md +// it calls parseCompletedTasks. All refs from all files are returned as a +// flat list. +// +// Parameters: +// - contextDir: absolute path to the .context/ directory +// +// Returns: +// - []string: refs found across all staged context files +func StagedRefs(contextDir string) []string { + type fileEntry struct { + name string + parseFunc func(diff string) []string + } + + files := []fileEntry{ + { + name: cfgCtx.Decision, + parseFunc: func(diff string) []string { + return parseAddedEntries(diff, cfgTrace.RefTypeDecision) + }, + }, + { + name: cfgCtx.Learning, + parseFunc: func(diff string) []string { + return parseAddedEntries(diff, cfgTrace.RefTypeLearning) + }, + }, + { + name: cfgCtx.Convention, + parseFunc: func(diff string) []string { + return parseAddedEntries(diff, cfgTrace.RefTypeConvention) + }, + }, + { + name: cfgCtx.Task, + parseFunc: parseCompletedTasks, + }, + } + + var refs []string + for _, fe := range files { + path := filepath.Join(contextDir, fe.name) + diff := stagedDiff(path) + if diff == "" { + continue + } + refs = append(refs, fe.parseFunc(diff)...) + } + + if refs == nil { + return []string{} + } + + return refs +} diff --git a/internal/trace/staged_parse.go b/internal/trace/staged_parse.go new file mode 100644 index 000000000..57fdbb612 --- /dev/null +++ b/internal/trace/staged_parse.go @@ -0,0 +1,118 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "bufio" + "fmt" + "strings" + + cfgGit "github.com/ActiveMemory/ctx/internal/config/git" + "github.com/ActiveMemory/ctx/internal/config/regex" + cfgTrace "github.com/ActiveMemory/ctx/internal/config/trace" + "github.com/ActiveMemory/ctx/internal/exec/git" + "github.com/ActiveMemory/ctx/internal/task" +) + +// parseAddedEntries extracts entry refs from added lines in a diff. +// +// Only lines starting with "+" (but not "++") that match the +// regex.EntryHeader pattern are counted. Each match produces a ref of +// the form ":" where count starts at 1. +// +// Parameters: +// - diff: output of git diff --cached for a single file +// - entryType: the type label to use in the returned refs (e.g. "decision") +// +// Returns: +// - []string: refs like "decision:1", "decision:2" +func parseAddedEntries(diff, entryType string) []string { + var refs []string + count := 0 + + scanner := bufio.NewScanner(strings.NewReader(diff)) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, cfgTrace.DiffAddedPrefix) || + strings.HasPrefix(line, cfgTrace.DiffHeaderPrefix) { + continue + } + // Strip the leading "+" before matching. + content := line[1:] + if regex.EntryHeader.MatchString(content) { + count++ + refs = append(refs, fmt.Sprintf(cfgTrace.RefFormat, entryType, count)) + } + } + + if refs == nil { + return []string{} + } + + return refs +} + +// parseCompletedTasks extracts task refs from newly completed tasks in a diff. +// +// Only lines starting with "+" (but not "++") that match regex.Task with +// state "x" are counted. Each match produces a ref of the form "task:" +// where count starts at 1. +// +// Parameters: +// - diff: output of git diff --cached for TASKS.md +// +// Returns: +// - []string: refs like "task:1", "task:2" +func parseCompletedTasks(diff string) []string { + var refs []string + count := 0 + + scanner := bufio.NewScanner(strings.NewReader(diff)) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, cfgTrace.DiffAddedPrefix) || + strings.HasPrefix(line, cfgTrace.DiffHeaderPrefix) { + continue + } + // Strip the leading "+" before matching. + content := line[1:] + m := regex.Task.FindStringSubmatch(content) + if m == nil { + continue + } + if task.Completed(m) { + count++ + refs = append(refs, fmt.Sprintf( + cfgTrace.RefFormat, cfgTrace.RefTypeTask, count, + )) + } + } + + if refs == nil { + return []string{} + } + + return refs +} + +// stagedDiff runs git diff --cached -- filePath and returns the output. +// Returns an empty string on any error (best-effort). +// +// Parameters: +// - filePath: absolute path to the file to diff +// +// Returns: +// - string: the diff output, or "" on error +func stagedDiff(filePath string) string { + out, err := git.Run( + cfgGit.Diff, cfgGit.FlagCached, cfgGit.FlagPathSep, filePath, + ) + if err != nil { + return "" + } + return string(out) +} diff --git a/internal/trace/staged_test.go b/internal/trace/staged_test.go new file mode 100644 index 000000000..f91483772 --- /dev/null +++ b/internal/trace/staged_test.go @@ -0,0 +1,111 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "testing" +) + +func TestParseAddedDecisions(t *testing.T) { + diff := `diff --git a/.context/DECISIONS.md b/.context/DECISIONS.md +index abc1234..def5678 100644 +--- a/.context/DECISIONS.md ++++ b/.context/DECISIONS.md +@@ -1,3 +1,11 @@ + # Decisions + ++## [2026-01-28-051426] Use SQLite for storage ++ ++Rationale: lightweight and embedded. ++ ++## [2026-01-29-120000] Prefer JSONL over CSV ++ ++Rationale: easier streaming. ++ + ## [2026-01-01-000000] Existing decision + + Already there.` + + refs := parseAddedEntries(diff, "decision") + if len(refs) != 2 { + t.Fatalf("parseAddedEntries() returned %d refs, want 2: %v", len(refs), refs) + } + if refs[0] != "decision:1" { + t.Errorf("refs[0] = %q, want %q", refs[0], "decision:1") + } + if refs[1] != "decision:2" { + t.Errorf("refs[1] = %q, want %q", refs[1], "decision:2") + } +} + +func TestParseAddedLearnings(t *testing.T) { + diff := `diff --git a/.context/LEARNINGS.md b/.context/LEARNINGS.md +index abc1234..def5678 100644 +--- a/.context/LEARNINGS.md ++++ b/.context/LEARNINGS.md +@@ -1,5 +1,9 @@ + # Learnings + ++## [2026-03-01-090000] Always quote shell paths ++ ++Unquoted paths with spaces cause subtle failures. ++ + ## [2026-01-15-083000] Existing learning + + Already there.` + + refs := parseAddedEntries(diff, "learning") + if len(refs) != 1 { + t.Fatalf("parseAddedEntries() returned %d refs, want 1: %v", len(refs), refs) + } + if refs[0] != "learning:1" { + t.Errorf("refs[0] = %q, want %q", refs[0], "learning:1") + } +} + +func TestParseAddedTasks(t *testing.T) { + diff := `diff --git a/.context/TASKS.md b/.context/TASKS.md +index abc1234..def5678 100644 +--- a/.context/TASKS.md ++++ b/.context/TASKS.md +@@ -1,7 +1,9 @@ + # Tasks + ++- [x] Implement staged file analysis ++- [x] Write unit tests for trace package + - [ ] Pending task one + - [ ] Pending task two` + + refs := parseCompletedTasks(diff) + if len(refs) != 2 { + t.Fatalf("parseCompletedTasks() returned %d refs, want 2: %v", len(refs), refs) + } + if refs[0] != "task:1" { + t.Errorf("refs[0] = %q, want %q", refs[0], "task:1") + } + if refs[1] != "task:2" { + t.Errorf("refs[1] = %q, want %q", refs[1], "task:2") + } +} + +func TestParseNoAdditions(t *testing.T) { + diff := `diff --git a/.context/DECISIONS.md b/.context/DECISIONS.md +index abc1234..def5678 100644 +--- a/.context/DECISIONS.md ++++ b/.context/DECISIONS.md +@@ -1,5 +1,3 @@ + # Decisions + +-## [2026-01-01-000000] Removed decision +- +-Old rationale.` + + refs := parseAddedEntries(diff, "decision") + if len(refs) != 0 { + t.Errorf("parseAddedEntries() returned %d refs, want 0: %v", len(refs), refs) + } +} diff --git a/internal/trace/testmain_test.go b/internal/trace/testmain_test.go new file mode 100644 index 000000000..0a274087b --- /dev/null +++ b/internal/trace/testmain_test.go @@ -0,0 +1,19 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "os" + "testing" + + "github.com/ActiveMemory/ctx/internal/assets/read/lookup" +) + +func TestMain(m *testing.M) { + lookup.Init() + os.Exit(m.Run()) +} diff --git a/internal/trace/types.go b/internal/trace/types.go new file mode 100644 index 000000000..b3a28f52d --- /dev/null +++ b/internal/trace/types.go @@ -0,0 +1,44 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import "time" + +// PendingEntry records a context reference that has been staged for +// attachment to the next git commit. +type PendingEntry struct { + Ref string `json:"ref"` + Timestamp time.Time `json:"timestamp"` +} + +// HistoryEntry records the context references that were attached to a +// specific git commit. +type HistoryEntry struct { + Commit string `json:"commit"` + Refs []string `json:"refs"` + Message string `json:"message"` + Timestamp time.Time `json:"timestamp"` +} + +// OverrideEntry allows an explicit context association to be attached +// to a commit after the fact, replacing any automatically recorded refs. +type OverrideEntry struct { + Commit string `json:"commit"` + Refs []string `json:"refs"` + Timestamp time.Time `json:"timestamp"` +} + +// ResolvedRef holds the result of resolving a raw context reference +// (e.g. "T-3", "D-1", "L-5") to its full details. +type ResolvedRef struct { + Raw string + Type string + Number int + Title string + Detail string + Found bool +} diff --git a/internal/trace/working.go b/internal/trace/working.go new file mode 100644 index 000000000..c735b3310 --- /dev/null +++ b/internal/trace/working.go @@ -0,0 +1,37 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "fmt" + "os" + + "github.com/ActiveMemory/ctx/internal/config/env" + cfgTrace "github.com/ActiveMemory/ctx/internal/config/trace" +) + +// WorkingRefs detects context refs from the current working state. +// +// It combines in-progress task refs from TASKS.md with an active AI session +// ref (if CTX_SESSION_ID is set). +// +// Parameters: +// - contextDir: absolute path to the .context/ directory +// +// Returns: +// - []string: refs like "task:1", "session:" +func WorkingRefs(contextDir string) []string { + var refs []string + + refs = append(refs, inProgressTaskRefs(contextDir)...) + + if id := os.Getenv(env.SessionID); id != "" { + refs = append(refs, fmt.Sprintf(cfgTrace.SessionRefFormat, id)) + } + + return refs +} diff --git a/internal/trace/working_tasks.go b/internal/trace/working_tasks.go new file mode 100644 index 000000000..784edc717 --- /dev/null +++ b/internal/trace/working_tasks.go @@ -0,0 +1,61 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "bufio" + "fmt" + "path/filepath" + + cfgCtx "github.com/ActiveMemory/ctx/internal/config/ctx" + "github.com/ActiveMemory/ctx/internal/config/regex" + cfgTrace "github.com/ActiveMemory/ctx/internal/config/trace" + "github.com/ActiveMemory/ctx/internal/io" + "github.com/ActiveMemory/ctx/internal/task" +) + +// inProgressTaskRefs reads TASKS.md and returns a ref for each pending +// top-level task. Subtasks (indent >= 2 spaces) and completed tasks are +// skipped. The first pending top-level task becomes "task:1", the second +// "task:2", and so on. +// +// Parameters: +// - contextDir: absolute path to the .context/ directory +// +// Returns: +// - []string: refs like "task:1", "task:2" (nil on file read failure) +func inProgressTaskRefs(contextDir string) []string { + path := filepath.Clean(filepath.Join(contextDir, cfgCtx.Task)) + + f, err := io.SafeOpenUserFile(path) + if err != nil { + return nil + } + defer func() { _ = f.Close() }() + + var refs []string + count := 0 + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + m := regex.Task.FindStringSubmatch(line) + if m == nil { + continue + } + if task.Sub(m) { + continue + } + if !task.Pending(m) { + continue + } + count++ + refs = append(refs, fmt.Sprintf(cfgTrace.RefFormat, cfgTrace.RefTypeTask, count)) + } + + return refs +} diff --git a/internal/trace/working_test.go b/internal/trace/working_test.go new file mode 100644 index 000000000..8141ff7c8 --- /dev/null +++ b/internal/trace/working_test.go @@ -0,0 +1,73 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "os" + "path/filepath" + "testing" + + "github.com/ActiveMemory/ctx/internal/config/env" +) + +func TestWorkingRefsInProgressTasks(t *testing.T) { + contextDir := t.TempDir() + + tasks := `# Tasks + +- [ ] First pending task +- [x] Completed task +- [ ] Second pending task +` + if err := os.WriteFile(filepath.Join(contextDir, "TASKS.md"), []byte(tasks), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + refs := WorkingRefs(contextDir) + + found := map[string]bool{} + for _, r := range refs { + found[r] = true + } + + if !found["task:1"] { + t.Errorf("expected task:1 in refs %v", refs) + } + if !found["task:2"] { + t.Errorf("expected task:2 in refs %v", refs) + } + if found["task:3"] { + t.Errorf("did not expect task:3 in refs %v (completed tasks should not appear)", refs) + } +} + +func TestWorkingRefsSessionEnv(t *testing.T) { + contextDir := t.TempDir() + + t.Setenv(env.SessionID, "test-session-42") + + refs := WorkingRefs(contextDir) + + found := false + for _, r := range refs { + if r == "session:test-session-42" { + found = true + break + } + } + if !found { + t.Errorf("expected session:test-session-42 in refs %v", refs) + } +} + +func TestWorkingRefsNoTasksFile(t *testing.T) { + contextDir := t.TempDir() + + // Should not panic when TASKS.md is absent. + refs := WorkingRefs(contextDir) + _ = refs +} diff --git a/internal/write/trace/doc.go b/internal/write/trace/doc.go new file mode 100644 index 000000000..1fa5529ad --- /dev/null +++ b/internal/write/trace/doc.go @@ -0,0 +1,11 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package trace provides terminal output functions for trace commands. +// +// Key exports: [FileEntry], [Tagged]. +// All functions take *cobra.Command for output routing. +package trace diff --git a/internal/write/trace/trace.go b/internal/write/trace/trace.go new file mode 100644 index 000000000..9e69aead2 --- /dev/null +++ b/internal/write/trace/trace.go @@ -0,0 +1,129 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package trace + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/desc" + "github.com/ActiveMemory/ctx/internal/config/embed/text" + internalTrace "github.com/ActiveMemory/ctx/internal/trace" +) + +// CommitHeader prints the commit hash, message, and date for a single commit. +// +// Parameters: +// - cmd: Cobra command for output +// - shortHash: abbreviated commit hash +// - message: commit subject line +// - date: commit date string +func CommitHeader(cmd *cobra.Command, shortHash, message, date string) { + cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteTraceCommitHeader), shortHash)) + cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteTraceCommitMessage), message)) + cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteTraceCommitDate), date)) +} + +// CommitNoContext prints the "no context" message. +// +// Parameters: +// - cmd: Cobra command for output +func CommitNoContext(cmd *cobra.Command) { + cmd.Println(desc.Text(text.DescKeyWriteTraceCommitNoContext)) +} + +// CommitContext prints the "Context:" label. +// +// Parameters: +// - cmd: Cobra command for output +func CommitContext(cmd *cobra.Command) { + cmd.Println(desc.Text(text.DescKeyWriteTraceCommitContext)) +} + +// FileEntry prints a single file trace entry line. +// +// Parameters: +// - cmd: Cobra command for output +// - shortHash: abbreviated commit hash +// - date: commit date string +// - subject: commit subject line +// - refStr: formatted ref summary +func FileEntry(cmd *cobra.Command, shortHash, date, subject, refStr string) { + cmd.Println(fmt.Sprintf( + desc.Text(text.DescKeyWriteTraceFileEntry), + shortHash, date, subject, refStr, + )) +} + +// HooksEnabled reports that trace hooks were installed. +// +// Parameters: +// - cmd: Cobra command for output +func HooksEnabled(cmd *cobra.Command) { + cmd.Println(desc.Text(text.DescKeyWriteTraceHooksEnabled)) +} + +// HooksDisabled reports that trace hooks were removed. +// +// Parameters: +// - cmd: Cobra command for output +func HooksDisabled(cmd *cobra.Command) { + cmd.Println(desc.Text(text.DescKeyWriteTraceHooksDisabled)) +} + +// LastEntry prints a single line in the last-N listing. +// +// Parameters: +// - cmd: Cobra command for output +// - shortHash: abbreviated commit hash +// - message: commit subject line +// - refSummary: formatted ref summary +func LastEntry(cmd *cobra.Command, shortHash, message, refSummary string) { + cmd.Println(fmt.Sprintf( + desc.Text(text.DescKeyWriteTraceLastEntry), + shortHash, message, refSummary, + )) +} + +// Resolved prints a single resolved context reference. +// +// Parameters: +// - cmd: Cobra command for output +// - rr: resolved reference to display +func Resolved(cmd *cobra.Command, rr internalTrace.ResolvedRef) { + typeLabel := strings.ToUpper(rr.Type[0:1]) + rr.Type[1:] + if rr.Found && rr.Title != "" { + if rr.Detail != "" { + cmd.Println(fmt.Sprintf( + desc.Text(text.DescKeyWriteTraceResolvedFull), + typeLabel, rr.Raw, rr.Title, rr.Detail, + )) + } else { + cmd.Println(fmt.Sprintf( + desc.Text(text.DescKeyWriteTraceResolvedTitle), + typeLabel, rr.Raw, rr.Title, + )) + } + } else { + cmd.Println(fmt.Sprintf( + desc.Text(text.DescKeyWriteTraceResolvedRaw), + typeLabel, rr.Raw, + )) + } +} + +// Tagged reports that a commit was tagged with a context note. +// +// Parameters: +// - cmd: Cobra command for output +// - shortHash: abbreviated commit hash +// - note: the context note that was attached +func Tagged(cmd *cobra.Command, shortHash, note string) { + cmd.Println(fmt.Sprintf(desc.Text(text.DescKeyWriteTraceTagged), shortHash, note)) +}