diff --git a/.context/DECISIONS.md b/.context/DECISIONS.md index acde09da2..bd3f8bb8c 100644 --- a/.context/DECISIONS.md +++ b/.context/DECISIONS.md @@ -1,71 +1,5 @@ # Decisions - -| Date | Decision | -|----|--------| -| 2026-07-04 | Statusline informs, never gates | -| 2026-07-03 | Keep sonnet-4-6 at the 200k default despite the API catalog listing 1M | -| 2026-06-07 | ctx-dream executor is a documented contract, not a hardcoded cron/claude assumption | -| 2026-06-07 | Output belongs in write/ — taxonomy and emission style (consolidated) | -| 2026-06-07 | Package taxonomy and shared-code placement (consolidated) | -| 2026-06-07 | Error handling: centralized in internal/err, domain-file taxonomy (consolidated) | -| 2026-06-07 | config/ as constants home and the magic-value audit (consolidated) | -| 2026-06-07 | YAML text externalization, init, and drift guards (consolidated) | -| 2026-06-07 | CWD-anchored context model (consolidated) | -| 2026-06-07 | Encryption key resolution and migration (consolidated) | -| 2026-06-07 | ctxctl maintainer binary and out-of-band audit channel (consolidated) | -| 2026-06-07 | KB editorial pipeline (Phase KB) design (consolidated) | -| 2026-06-07 | Companion-tool integration: peer-MCP, no gateway (consolidated) | -| 2026-06-07 | Localizable vocabulary and i18n primitives (consolidated) | -| 2026-06-07 | Embedded assets and editor-integration harnesses (consolidated) | -| 2026-06-07 | Context injection, hooks, and session-state architecture (consolidated) | -| 2026-06-06 | ctx-dream: standalone proposing memory consolidator (Option B), human-gated via serendipity | -| 2026-05-31 | Journal screen renders ctx journal source verbatim instead of parsing it | -| 2026-05-31 | ctx Desktop shells out via std::process::Command, not tauri-plugin-shell | -| 2026-05-30 | Name the add JSON-ingest flag --json-file, not --json | -| 2026-05-28 | Memory pressure detection uses OS-native signals (macOS pressure level + Linux PSI), not occupancy | -| 2026-05-24 | Pad snapshot-on-mutate at the store.WriteEntries choke point | -| 2026-05-20 | Gitignore .context/handovers/; track only .gitkeep | -| 2026-04-16 | Deprecate and remove ctx backup | -| 2026-04-14 | doc.go quality floor: behavior-grounded, ~25-100 body lines, related-packages section required | -| 2026-04-14 | Bootstrap stays under ctx system bootstrap (reverted experimental top-level promotion) | -| 2026-04-14 | Title Case style for docs is AP-leaning with explicit ambiguity carve-outs | -| 2026-04-11 | Journal stays local; LEARNINGS.md is the shareable layer | -| 2026-04-11 | `Entry.Author` is server-authoritative, not client-authoritative | -| 2026-04-09 | Architecture skill pipeline is a triad not a quartet | -| 2026-04-08 | Remove #done tag convention, simplify task archival | -| 2026-04-06 | Use hook relay for session provenance instead of JSONL parsing or env vars | -| 2026-04-01 | IRC to Discord as primary community channel | -| 2026-04-01 | AST audit tests live in internal/audit/, one file per check | -| 2026-04-01 | Rename ctx hook → ctx setup to disambiguate from the hook system | -| 2026-03-31 | Split log into log/event and log/warn to break import cycles | -| 2026-03-30 | Flags-not-subcommands for journal source: list and show are view modes on a noun, not independent entities | -| 2026-03-30 | Journal consumed recall — recall CLI package deleted | -| 2026-03-25 | Architecture analysis and enrichment are separate skills — constraint is the feature | -| 2026-03-25 | Prompt templates removed — skills are the single agent instruction mechanism | -| 2026-03-24 | Write-once baseline with explicit end-consolidation for consolidation lifecycle | -| 2026-03-18 | Singular command names for all CLI entities | -| 2026-03-16 | Rename --consequences flag to --consequence for singular consistency | -| 2026-03-14 | System path deny-list as safety net, not security boundary | -| 2026-03-14 | Config-driven freshness check with per-file review URLs | -| 2026-03-13 | Delete ctx-context-monitor skill — hook output is self-sufficient | -| 2026-03-12 | Rename ctx-map skill to ctx-architecture | -| 2026-03-06 | Drop fatih/color dependency — Unicode symbols are sufficient for terminal output, color was redundant | -| 2026-03-05 | Gitignore .context/memory/ for this project | -| 2026-03-04 | Interface-based GraphBuilder for multi-ecosystem ctx deps | -| 2026-03-02 | Billing threshold piggybacks on check-context-size, not heartbeat | -| 2026-03-01 | PersistentPreRunE init guard with three-level exemption | -| 2026-03-01 | Heartbeat token telemetry: conditional fields, not always-present | -| 2026-03-01 | Promote 6 private skills to bundled plugin skills; keep 7 project-local | -| 2026-02-27 | Context window detection: JSONL-first fallback order | -| 2026-02-26 | ctx init and CLAUDE.md handling (consolidated) | -| 2026-02-26 | Task and knowledge management (consolidated) | -| 2026-02-26 | Agent autonomy and separation of concerns (consolidated) | -| 2026-02-26 | Security and permissions (consolidated) | -| 2026-02-27 | Webhook and notification design (consolidated) | -| 2026-04-25 | Use t.Setenv for subprocess env in tests, not append(os.Environ(), ...) | - - - -| Date | Learning | -|----|--------| -| 2026-07-04 | Probing whether a ctx subcommand exists needs bare invocation, not --help | -| 2026-07-04 | GitNexus prunes registry entries whose repo paths don't resolve in-container | -| 2026-07-04 | Compliance tests are the style guide for new commands | -| 2026-07-04 | Typed JSON round-trips silently drop user-owned keys | -| 2026-07-03 | New Claude model families silently fall to the 200k default in ModelContextWindow | -| 2026-06-07 | Pin an on-disk contract before splitting work across parallel agents | -| 2026-06-07 | site/ is tracked build output — rebuild and bundle it with doc commits | -| 2026-06-07 | ctx-dream is headless-first; invoking /ctx-dream interactively is debugging, not the UX | -| 2026-06-07 | ctx-dream design principles (consolidated) | -| 2026-06-07 | internal/audit & compliance gates for new code (consolidated) | -| 2026-06-07 | Error handling: sentinels, unwrapping, and silent discards (consolidated) | -| 2026-06-07 | git CLI wrapping quirks (consolidated) | -| 2026-06-07 | TypeScript/integration test surfaces & exclusion rot (consolidated) | -| 2026-06-07 | Editorial KB pipeline: design epistemology (consolidated) | -| 2026-06-07 | Documentation, template & asset drift (consolidated) | -| 2026-06-07 | User-facing text & magic-string discipline (consolidated) | -| 2026-06-07 | Constant placement & helper smells (consolidated) | -| 2026-06-07 | Convention enforcement: mechanical gates over prose (consolidated) | -| 2026-06-07 | Go toolchain, gofmt & build-tag pitfalls (consolidated) | -| 2026-06-07 | Stale-task triage & verify-before-acting (consolidated) | -| 2026-06-07 | Refactor mechanics: subagents, cascades & golden fixtures (consolidated) | -| 2026-06-07 | Linting, gosec & I/O chokepoints (consolidated) | -| 2026-06-07 | Hook mechanics, output channels & compliance (consolidated) | -| 2026-06-07 | State, tombstones, logs & filesystem hygiene (consolidated) | -| 2026-06-07 | Host-pressure alerting: use derivatives, not levels (consolidated) | -| 2026-06-07 | Go test isolation & patterns (consolidated) | -| 2026-06-01 | Guard managed blocks before regenerating; don't trust the span to be machine-owned | -| 2026-05-31 | macOS GUI apps inherit a minimal PATH; augment it to find a user-installed CLI | -| 2026-05-31 | Tauri 2 requires rustc >= 1.88; bump the toolchain before cargo check | -| 2026-05-28 | ctx kb: single topic-enumeration site; life-stage count is consumer-side | -| 2026-05-28 | A non-root Go module nested under the main module's path CAN import its internal/ packages | -| 2026-05-28 | cobra's legacyArgs lets unknown subcommands silently succeed on non-root groups | -| 2026-05-25 | Skill shipping location: _ctx- prefix is repo-internal, internal/assets/claude/skills/ctx-* is bundled and shipped | -| 2026-05-23 | Unicode block separation makes diacritic-stripping surgical — no per-script handling needed for Arabic/Indic/Hebrew/CJK | -| 2026-05-20 | macOS /var symlink trips path-equality; use EvalSymlinks with parent-resolution fallback | -| 2026-05-20 | Handover filenames are archaeology; parse by generated-at, not filename | -| 2026-05-20 | /ctx-plan is named after its input, not its output | -| 2026-05-17 | Creator confusion is the strongest doc-quality signal — louder than any user signal | -| 2026-05-17 | `_helpers.go` / `_utils.go` filenames are project anti-pattern; use domain nouns | -| 2026-05-11 | Naive Markdown line-sweep corrupts multi-line code spans and YAML lists | -| 2026-05-08 | Cursor imports Claude Code hooks and sets CLAUDE_PROJECT_DIR per workspace | -| 2026-04-14 | Constitution forbids context window as a deferral excuse | -| 2026-04-14 | docs/cli/system.md and embed/cmd/system.go diverged on bootstrap promotion intent | -| 2026-04-14 | Raft-lite trade-off is the load-bearing choice in internal/hub | -| 2026-04-14 | Brand-name handling in title-case engines must cover possessives | -| 2026-04-13 | GPG signing from non-TTY contexts requires pinentry-mac (or equivalent) | -| 2026-04-13 | rc.ContextDir() is the single source of truth — fix the resolver, not callers | -| 2026-04-09 | Pad index shifting is a real UX bug in batch operations | -| 2026-04-03 | Bulk rename and replace_all hazards (consolidated) | -| 2026-04-03 | Import cycles and package splits (consolidated) | -| 2026-04-03 | Skill lifecycle and promotion (consolidated) | -| 2026-04-03 | desc.Text() is the single highest-connectivity symbol in the codebase | -| 2026-04-01 | Contributor PRs based on older code reintroduce removed features | -| 2026-03-31 | Convention audits must check cmd/ purity, not just types and docstrings | -| 2026-03-31 | JSON Schema default fields cause linter errors with some validators | -| 2026-03-30 | lint-docstrings.sh greedy sed hid all return-type violations | -| 2026-03-24 | lint-drift false positives from conflating constant namespaces | -| 2026-03-23 | Typography detection script needs exclusion lists for intentional uses | -| 2026-03-20 | Commit messages containing script paths trigger PreToolUse hooks | -| 2026-03-18 | Lazy sync.Once per-accessor is a code smell for static embedded data | -| 2026-03-17 | Write package output census: 69 trivial/simple, 38 consolidation candidates, 18 complex | -| 2026-03-15 | Contributor PRs need post-merge follow-up commits for convention alignment | -| 2026-03-06 | Stats sort uses string comparison on RFC3339 timestamps with mixed timezones | -| 2026-03-05 | Blog post editorial feedback is higher-leverage than drafting | -| 2026-03-04 | CONSTITUTION hook compliance is non-negotiable — don't work around it | -| 2026-03-02 | Hook message registry test enforces exhaustive coverage of embedded templates | -| 2026-03-01 | Model-to-window mapping requires ordered prefix matching | -| 2026-03-01 | TASKS.md template checkbox syntax inside HTML comments is parsed by RegExTaskMultiline | -| 2026-02-28 | ctx pad import, ctx pad export, and ctx system resources make three hack scripts redundant | -| 2026-02-28 | Getting-started docs assumed Claude Code as the only agent | -| 2026-02-28 | Plugin reload script must rebuild cache, not just delete it | -| 2026-02-27 | site/ directory must be committed with docs changes | -| 2026-02-27 | Doctor token_budget vs context_window confusion | -| 2026-02-27 | Drift detector false positives on illustrative code examples | -| 2026-02-26 | Webhook silence after ctxrc profile swap is the most common notify debugging red herring | -| 2026-02-26 | Agent context loading and task routing (consolidated) | -| 2026-02-26 | PATH and binary handling (consolidated) | -| 2026-02-26 | Task management and exit criteria (consolidated) | -| 2026-02-26 | Agent behavioral patterns (consolidated) | -| 2026-02-26 | ctx add and decision recording (consolidated) | -| 2026-02-24 | CLI tools don't benefit from in-memory caching of context files | -| 2026-01-28 | IDE is already the UI | -| 2026-04-29 | BunShell ctx.$ calls echo stdout to OpenCode's process unless .quiet() is set — leaks visible noise | -| 2026-04-29 | OpenCode plugin compaction interop is breadcrumb-mediated: own your context preservation explicitly | -| 2026-04-29 | @opencode-ai/plugin event hook is a single dispatcher, not an object of named handlers | -| 2026-04-29 | OpenCode plugin hooks like shell.env take (input, output) and mutate; returned objects are ignored | -| 2026-04-29 | OpenCode shell.env injects env only into agent's shell tool, not into plugin's own ctx.$ calls | -| 2026-04-26 | OpenCode auto-loads only flat .ts files under .opencode/plugins/; subdirectories are ignored | -| 2026-04-26 | OpenCode opencode.json MCP shape: command is Array, no separate args field | -| 2026-04-26 | ctx system help can list project-local hooks not in the Go binary | -| 2026-04-25 | Confident code comments can pull an LLM away from first-principles knowledge | - + +--- + +## [2026-07-06-214523] git filter-branch leaves the originals in refs/original and the reflog + +**Context**: Rewrote the branch to strip agent Co-Authored-By trailers and called it clean after checking only the branch tip; the originals were still recoverable and visible in git log --all. + +**Lesson**: filter-branch rewrites the ref but keeps originals in refs/original/* and every reflog; clean requires update-ref -d on refs/original, git reflog expire --expire=now --all, and git gc --prune=now, then a re-check across ALL refs and dangling objects. Rewriting an annotated/signed tag strips its GPG signature. + +**Application**: After any history rewrite: purge refs/original + reflog + gc, then verify agent trailers are zero across the whole object store, not just main..HEAD. --- diff --git a/.context/TASKS.md b/.context/TASKS.md index e5743f728..27469dd4a 100644 --- a/.context/TASKS.md +++ b/.context/TASKS.md @@ -2608,11 +2608,33 @@ shipped. ### Misc +- [x] [Epic F] ctx index: docs (remove reindex, add ctx index) + final build/lint/test gate (T23-T24). Plan: specs/plans/computed-index-projection.md #session:75be038e #branch:main #commit:f382bee7 #added:2026-07-14-054851 + +- [x] [Epic E] ctx index: strip INDEX blocks from .context files, remove marker constants, add guards (T19-T22). Plan: specs/plans/computed-index-projection.md #session:75be038e #branch:main #commit:f382bee7 #added:2026-07-14-054851 + +- [x] [Epic D] ctx index: new 'ctx index ' command with --depth/--json + error handling (T13-T18). Plan: specs/plans/computed-index-projection.md #session:75be038e #branch:main #commit:f382bee7 #added:2026-07-14-054851 + +- [x] [Epic C] ctx index: detach entry-write path from index blocks + delete block-maintenance API (T09-T12). Plan: specs/plans/computed-index-projection.md #session:75be038e #branch:main #commit:f382bee7 #added:2026-07-14-054851 + +- [x] [Epic B] ctx index: remove reindex command surfaces — ctx reindex / decision reindex / learning reindex (T05-T08). Plan: specs/plans/computed-index-projection.md #session:75be038e #branch:main #commit:f382bee7 #added:2026-07-14-054851 + +- [x] [Epic A] ctx index: rename internal/index→internal/heading + generic ATX heading matcher (T01-T04). Plan: specs/plans/computed-index-projection.md #session:75be038e #branch:main #commit:f382bee7 #added:2026-07-14-054851 + +- [ ] ctx-remember nudge live-credit: the check-ceremony nudge is journal-driven (ScanJournalsForCeremonies over recent IMPORTED journals), so it can't credit the current live session's /ctx-remember and misfires until the session is imported. Have the /ctx-remember skill (or ctx) touch the ceremony throttle/marker when it runs live, so the signal reflects the current session instead of waiting for journal import. See internal/cli/system/cmd/checkceremony/run.go (remindedFile/ThrottleID) #session:75be038e #branch:main #commit:f382bee7 #added:2026-07-13-220031 + +- [ ] ctx-remember nudge self-suppress: the check-ceremony hook fires the 'try starting with /ctx-remember' relay even on the prompt that IS /ctx-remember, because entity.HookInput doesn't parse the UserPromptSubmit 'prompt' field (only session_id + tool_input.command). Add Prompt to HookInput and skip the ceremony nudge when the prompt starts with /ctx-remember or /ctx:ctx-remember. See internal/cli/system/cmd/checkceremony/run.go + internal/entity/hook.go #session:75be038e #branch:main #commit:f382bee7 #added:2026-07-13-220031 + +- [ ] ctx list/search: richer query surface over knowledge files (filtering, full-text) layered on top of the thin `ctx index` heading-projector — successor to the queued 'CLI-projected list/search' idea; index ships first as the projection primitive #session:75be038e #branch:main #commit:f382bee7 #added:2026-07-13-215523 + +- [ ] Re-sign the release tags (v0.1.0 through v0.8.0 and latest): the 2026-07-06 DCO history rewrite stripped their GPG signatures when the tagged commits changed SHA. #session:2cff382a #branch:fix/jumbo-diff-review-fixes #commit:945850af #added:2026-07-06-214523 + +- [ ] Create a /ctx-pr skill: scaffold a PR body from the branch's commits, Spec: trailers, and closed TASKS, written to inbox/ (gitignored) for the user to paste. MUST enforce the no-agent-signoff convention: no 'Co-Authored-By' and no 'Generated with ...' footer, per CONSTITUTION Process Invariants. #session:2cff382a #branch:fix/jumbo-diff-review-fixes #commit:945850af #added:2026-07-06-213149 + - [ ] New orchestrator skill /ctx-architecture-deep-dive: wrap the three-pass architecture arc (/ctx-architecture principal → /ctx-architecture-enrich → /ctx-architecture-failure-analysis) plus the synthesis step (milestone-readiness note → /ctx-task-out --milestone ) into one parameterized skill with machine-checkable preconditions (code-intel MCP actually serving the repo, index fresh vs HEAD, synced tree, fresh session). Prior art: zhc/os docs/runbooks/architecture-deep-dive.md — a runbook whose pasted prompt rotted within ONE milestone (needed a 'Historical' banner because it hard-codes milestone facts like 'M0b is untasked'); a skill that derives milestone state from specs/plans/ at runtime doesn't rot. The site recipe architecture-deep-dive documents the arc as prose — this skill would be its ceremony (see the 'unceremonied pipeline step' learning). os prototypes a project-local version first and folds lessons back here. #priority:medium #session:6c276362 #branch:main #commit:a0e5cbf9 #added:2026-07-04-210547 - [ ] Skill assets hard-code `npx gitnexus analyze` as the stale-index remedy, but on hosts where GitNexus runs via Docker (tree-sitter@0.21.1 native addon vs Node 24 ABI — no arm64 prebuilt) that command is a silent no-op; os/GITNEXUS.md documents this and both os and ctx expose `make gitnexus-index` → hack|scripts/gitnexus-index.sh instead. Fix the suggestion to be project-aware: point at the repo-local indexing entry point (a `gitnexus-index` make target, an indexing script, or the repo's GITNEXUS.md instructions) and fall back to `npx gitnexus analyze` only when nothing repo-local exists. Sites: internal/assets/claude/skills/ctx-remember/SKILL.md:156, internal/assets/integrations/copilot-cli/skills/ctx-remember/SKILL.md:155, internal/assets/claude/skills/ctx-architecture-enrich/SKILL.md:34+112+130. Found live: /ctx-remember in the os project emitted the broken npx suggestion while the working `make gitnexus-index` existed one directory over. #priority:medium #session:6c276362 #branch:main #commit:a0e5cbf9 #added:2026-07-04-205437 -- [ ] Drop the persisted INDEX blocks from DECISIONS.md/LEARNINGS.md; +- [x] Drop the persisted INDEX blocks from DECISIONS.md/LEARNINGS.md; project the index on demand via new CLI verbs instead. The headings ARE the index (`## [TS] Title` is one grep away); the stored table is 6-7% dead weight on every read (measured 2026-07-04: 5,021B of @@ -2635,8 +2657,18 @@ shipped. recall format tests, hub rendering) before the strip. Obviates the 2026-03-06 "Consider indexing tasks and conventions" task (opposite direction — do NOT add more indexes). Full analysis + plan: - inbox/2026-07-04-memory-file-index-drop.md (local only). Needs - /ctx-spec + a decision record before implementation. + inbox/2026-07-04-memory-file-index-drop.md (local only). + Spec: specs/computed-index-projection.md (debated 2026-07-14 via + /ctx-plan; brief at .context/briefs/20260714T045513Z-computed-index-projection-over-persisted-blocks.md). + Design evolved from this task's build order: HL.1's per-noun + `ctx decision/learning list` is replaced by a single GENERIC + `ctx index ` heading-projector (works for TASKS phases too), + and HL.2's `ctx search` is deferred to a separate follow-up task + (richer list/search layers on top of the index primitive). The + cold-bucket/time-shard idea was killed by a validation pass + (~1.5% of entries are genuinely superseded — corpus already GC'd + by consolidation). `internal/index` is RENAMED to `internal/heading` + (parser retained; block-maintenance half deleted). #priority:medium #session:334b20d1 #branch:main #commit:a0e5cbf9 #added:2026-07-04 diff --git a/.context/briefs/20260714T045513Z-computed-index-projection-over-persisted-blocks.md b/.context/briefs/20260714T045513Z-computed-index-projection-over-persisted-blocks.md new file mode 100644 index 000000000..b81f6c622 --- /dev/null +++ b/.context/briefs/20260714T045513Z-computed-index-projection-over-persisted-blocks.md @@ -0,0 +1,117 @@ +# Brief: Computed `ctx index` projection replaces persisted INDEX blocks + +- **Date**: 2026-07-14 +- **Session**: 75be038e | Branch: main @ f382bee7 +- **Status**: Debated (via /ctx-plan). Ready for `/ctx-spec --brief`. +- **Supersedes the opening bet**: the original pitch (time-sharded knowledge + files + a load-excluded "cold" bucket) was **killed by validation** — see + Rejections. + +## The bet + +Knowledge files (DECISIONS/LEARNINGS/CONVENTIONS/TASKS) carry hand-maintained +`` blocks — a stored table of contents. +That stored index is dead weight and pure liability: it drifts, it has caused a +clobber bug and an "entries trapped in the index block" bug, and it is **not even +injected at session start** anymore (the load-gate was narrowed to +CONSTITUTION + AGENT_PLAYBOOK_GATE on 2026-06-07). So the agent only ever sees it +by reading the file top-to-bottom, where it is redundant weight. + +An index is just a **projection over the L2 (optionally L3) headings** — a grep, +computable on demand. Maintaining a separate index comment is dead weight. + +**Bet:** delete the persisted-index machinery entirely and replace it with a +generic, computed heading projector. + +Concretely: +1. **Remove** the stored-index machinery: `internal/index/` (block maintenance), + the `ctx reindex` command (`internal/cli/reindex/`), `config/marker/index_fmt.go`, + the `add` insert-path write into the index block, and the now-moot + `TestWrite_RefusesEntriesTrappedInIndexBlock` guard. Strip the INDEX blocks + from the files themselves (DECISIONS ×1, LEARNINGS ×2, TASKS ×6). This is + **net deletion** — on-brand for ctx (cf. the cwd-anchored decision that + celebrated deleting ~600–1000 LOC). +2. **Add `ctx index `** — a thin, generic projector that greps `##` + (and optionally `###`) headings and emits them. One mechanism across all + knowledge files. For TASKS.md it yields the `## Phase` sections — the same + command doubles as "get all phases/sections" ("multiple birds, one scone"). + The entry headings already are `## [timestamp] Title`, so the grep *is* the + old `| Date | Decision |` row, for free. +3. **Rewire `ctx agent`** to call the projector, so the agent still gets a TOC — + now **computed, never stored**, so it can't drift, clobber, or trap entries. + +This is Reframe C from the debate: the real hot/cold split is **hot = the fact +that an entry exists (its heading); cold = its body** — orthogonal to whether the +entry is live, so it never risks hiding a live constraint (the agent always sees +every title and pulls bodies on demand). + +## Decisions locked in the debate + +- **Namespace: `ctx index`, top-level, not `ctx system index`.** A heading TOC is + genuinely dual-use (human + agent). The "`ctx system` is for plumbing users + don't type by hand" precedent (2026-04-14 bootstrap decision) is about + *agent-only* commands; a readable TOC isn't that. +- **Generic projector, not per-noun.** One `ctx index ` over any knowledge + file, confirmed non-colliding: there is **no** existing `ctx decision list` / + `search` surface today (grep verified), so nothing to reconcile. +- **Ship order: `index` first.** A richer `list/search` (filtering, full-text) + is a *later* layer on top of the projection primitive. Filed as a follow-up + task in TASKS.md → Misc (successor to the queued "CLI-projected list/search"). + +## What we rejected (and what would change our mind) + +- **Time-sharded files + a load-excluded "cold" (superseded/deprecated) bucket.** + Rejected on data. A one-shot supersession-detection pass over the full corpus + found **~1 genuinely cold entry / 66 decisions and ~1 / 96 learnings** (≤5 even + in the worst case if code-checkable suspects verify dead). The cold bucket has + no fuel. *Why the corpus is so live:* it was **already garbage-collected by + consolidation** — every `consolidated from N entries` block is a tombstone for + merged-out originals (the June-7 pass took decisions ~109→66, learnings + ~151→96). The working GC lever in this repo is `/ctx-consolidate`, not + supersession-exclusion. *Would change our mind:* if a future corpus grew a + large, genuinely-dead tail that consolidation wasn't run against. +- **Recency-gated selective loading (don't load old entries).** Rejected as + *more* dangerous after the data, not less: the pass proved **old ≈ live** here + (e.g. the 4-month-old "path deny-list is a safety net, not a security boundary" + invariant). Recency-gating would routinely withhold *live constraints* and + cause the agent to re-litigate settled decisions. *Would change our mind:* a + reliable liveness signal that doesn't depend on age. +- **A dedicated `/ctx-shard` skill + ceremony wiring** (hook a cleanup pass into + `/ctx-remember`/`/ctx-wrap-up`, human-acknowledged one-at-a-time with an + "enough for this session" escape hatch). The mechanism design was *sound* and + worth remembering — but it exists to feed the cold bucket, which the data + emptied. Shelved with the cold-bucket bet, not on its own merits. + +## Top failure modes + +1. **Heading-format hygiene.** The projector greps `##` headings, so a malformed + heading (missing timestamp, wrong level) silently drops from the index. + Mitigation: the existing drift-check / kb-site-review already coerce heading + format; `ctx index` can warn on malformed headings. How we'd notice: an entry + present in the file but absent from `ctx index` output. +2. **Silent consumer breakage on removal.** Something we didn't find may read the + INDEX block. Mitigation: grep confirmed the injection gate no longer feeds it; + `ctx agent` is the one consumer to rewire. Verify no other reader before + deleting `internal/index/`. +3. **Scope creep into list/search.** The temptation to make `index` filter/query. + Mitigation: keep `index` a *thin heading grep*; the richer surface is the + separately-tasked `list/search`. + +## Cheapest validation (already done) + +- **One agent supersession pass** over both files (zero code) → returned the + 1.5% cold number that killed the shard bet. +- **One grep** → confirmed the `internal/index/` + `ctx reindex` machinery + exists, is net-deletable, and is no longer session-injected. + +Both are in the transcript; no further validation needed before spec. + +## Unwind cost + +Low. The removal is a net-deletion behind git; the projector is additive. If +`ctx index` proves insufficient, re-add is cheap. No data migration — the +headings that *are* the index already live in the files. + +## Next step + +`/ctx-spec --brief .context/briefs/20260714T045513Z-computed-index-projection-over-persisted-blocks.md` diff --git a/docs/cli/context.md b/docs/cli/context.md index 1e324ddc2..baddd465b 100644 --- a/docs/cli/context.md +++ b/docs/cli/context.md @@ -366,28 +366,34 @@ ctx permission restore --- -### `ctx reindex` +### `ctx index` -Regenerate the quick-reference index for both `DECISIONS.md` and `LEARNINGS.md` -in a single invocation. +Project the Markdown headings of a knowledge file as a computed table of +contents — recomputed on demand, never stored in the file. ```bash -ctx reindex +ctx index [--depth N] [--json] ``` -This is a convenience wrapper around `ctx decision reindex` and -`ctx learning reindex`. Both files grow at similar rates and users -typically want to reindex both after manual edits. +One generic command serves every knowledge file: `DECISIONS.md` and +`LEARNINGS.md` (`## [timestamp] Title` entries), `CONVENTIONS.md`, and +`TASKS.md` (`## Phase …` sections). By default only level-2 (`##`) headings +are shown; `--depth 3` includes level-3 (`###`) sub-headings, and `--json` +emits a machine-readable array of `{level, text}`. -The index is a compact table of date and title for each entry, allowing -AI tools to scan entries without reading the full file. +Because the index is computed, it can never drift from the entries it +summarizes, and adding an entry never rewrites the file's structure. **Example**: ```bash -ctx reindex -# ✓ Index regenerated with 12 entries -# ✓ Index regenerated with 8 entries +ctx index .context/DECISIONS.md +# [2026-07-09-093951] Ship #131 as interim hub token revocation +# [2026-07-06-214523] Journal resume picks the richest transcript +# ... + +ctx index .context/TASKS.md --depth 3 +ctx index .context/LEARNINGS.md --json ``` --- @@ -400,26 +406,8 @@ Manage the `DECISIONS.md` file. ctx decision ``` -#### `ctx decision reindex` - -Regenerate the quick-reference index at the top of `DECISIONS.md`. - -```bash -ctx decision reindex -``` - -The index is a compact table showing the date and title for each decision, -allowing AI tools to quickly scan entries without reading the full file. - -Use this after manual edits to `DECISIONS.md` or when migrating existing -files to use the index format. - -**Example**: - -```bash -ctx decision reindex -# ✓ Index regenerated with 12 entries -``` +Use `ctx decision add` to append entries; see [`ctx index`](#ctx-index) to +project a table of contents on demand. --- @@ -431,23 +419,5 @@ Manage the `LEARNINGS.md` file. ctx learning ``` -#### `ctx learning reindex` - -Regenerate the quick-reference index at the top of `LEARNINGS.md`. - -```bash -ctx learning reindex -``` - -The index is a compact table showing the date and title for each learning, -allowing AI tools to quickly scan entries without reading the full file. - -Use this after manual edits to `LEARNINGS.md` or when migrating existing -files to use the index format. - -**Example**: - -```bash -ctx learning reindex -# ✓ Index regenerated with 8 entries -``` +Use `ctx learning add` to append entries; see [`ctx index`](#ctx-index) to +project a table of contents on demand. diff --git a/docs/cli/index.md b/docs/cli/index.md index b1bd41fe2..7dcf96a9e 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -61,10 +61,10 @@ have been initialized by `ctx init` (otherwise commands return | [`ctx compact`](context.md#ctx-compact) | Archive completed tasks, clean up files | | [`ctx fmt`](context.md#ctx-fmt) | Format context files to 80-char line width | | [`ctx task`](context.md#ctx-task) | Add tasks, mark complete, archive, snapshot | -| [`ctx decision`](context.md#ctx-decision) | Add decisions and reindex `DECISIONS.md` | -| [`ctx learning`](context.md#ctx-learning) | Add learnings and reindex `LEARNINGS.md` | +| [`ctx decision`](context.md#ctx-decision) | Add decisions to `DECISIONS.md` | +| [`ctx learning`](context.md#ctx-learning) | Add learnings to `LEARNINGS.md` | | [`ctx convention`](context.md#adding-entries) | Add conventions to `CONVENTIONS.md` | -| [`ctx reindex`](context.md#ctx-reindex) | Regenerate indices for `DECISIONS.md` and `LEARNINGS.md` | +| [`ctx index`](context.md#ctx-index) | Project a file's headings as a table of contents | | [`ctx permission`](context.md#ctx-permission) | Permission snapshots (golden image) | | [`ctx change`](change.md#ctx-change) | Show what changed since last session | | [`ctx memory`](memory.md#ctx-memory) | Bridge Claude Code auto memory into `.context/` | diff --git a/docs/recipes/index.md b/docs/recipes/index.md index 22f0150b4..7f34e91bf 100644 --- a/docs/recipes/index.md +++ b/docs/recipes/index.md @@ -159,8 +159,7 @@ and lessons learned, and **codify** conventions so they survive across sessions and team members. **Uses**: `ctx decision add`, `ctx learning add`, -`ctx convention add`, `ctx decision reindex`, -`ctx learning reindex`, `/ctx-decision-add`, +`ctx convention add`, `ctx index`, `/ctx-decision-add`, `/ctx-learning-add`, `/ctx-convention-add`, `/ctx-reflect` --- diff --git a/docs/recipes/knowledge-capture.md b/docs/recipes/knowledge-capture.md index 047583c83..d7b8238c7 100644 --- a/docs/recipes/knowledge-capture.md +++ b/docs/recipes/knowledge-capture.md @@ -41,9 +41,7 @@ Or just tell your agent: *"What have we learned this session?"* | `ctx decision add` | Command | Record an architectural decision | | `ctx learning add` | Command | Record a gotcha, tip, or lesson | | `ctx convention add` | Command | Record a coding pattern or standard | -| `ctx reindex` | Command | Rebuild both quick-reference indices | -| `ctx decision reindex` | Command | Rebuild the DECISIONS.md index | -| `ctx learning reindex` | Command | Rebuild the LEARNINGS.md index | +| `ctx index ` | Command | Project a file's headings as a table of contents | | `/ctx-decision-add` | Skill | AI-guided decision capture with validation | | `/ctx-learning-add` | Skill | AI-guided learning capture with validation | | `/ctx-convention-add` | Skill | AI-guided convention recording with placement | @@ -192,21 +190,21 @@ ctx convention add "Use kebab-case for all CLI flag names" --section "Naming" Conventions work best for rules that come up repeatedly. Codify a pattern the third time you see it, not the first. -### Step 5: Reindex After Manual Edits +### Step 5: Project a Table of Contents on Demand -`DECISIONS.md` and `LEARNINGS.md` maintain a quick-reference index at the top: -a compact table of date and title for each entry. The index updates -automatically via `ctx add`, but falls out of sync after hand edits. +`DECISIONS.md` and `LEARNINGS.md` do not store an index. A quick-reference +table of contents — the date and title for each entry — is computed on +demand, so it can never fall out of sync with the file, even after hand +edits. ```bash -ctx reindex +ctx index .context/DECISIONS.md +ctx index .context/LEARNINGS.md ``` -This single command regenerates both indices. You can also reindex -individually with `ctx decision reindex` or `ctx learning reindex`. - -Run reindex after any manual edit. The index lets AI tools scan all entries -without reading the full file, which matters when token budgets are tight. +The projection lets AI tools scan all entries without reading the full file, +which matters when token budgets are tight. Use `--json` for machine-readable +output. ### Step 6: Use `/ctx-reflect` to Surface What to Capture @@ -328,8 +326,8 @@ ctx learning add "SQL migrations must be idempotent" \ # Convention: record the pattern ctx convention add "API handlers return structured errors" --section "API" -# Reindex after manual edits -ctx reindex +# Project a table of contents on demand +ctx index .context/DECISIONS.md # Reflect at breakpoints (from AI assistant) # /ctx-reflect diff --git a/docs/reference/skills.md b/docs/reference/skills.md index 80067b44d..b3760f47b 100644 --- a/docs/reference/skills.md +++ b/docs/reference/skills.md @@ -382,7 +382,8 @@ overlapping entries by keyword similarity, presents candidates, and Originals are archived, not deleted. **Wraps**: reads LEARNINGS.md and DECISIONS.md, writes consolidated -entries, archives originals, runs `ctx reindex` +entries, archives originals (the index is computed on demand by +`ctx index`, so no rebuild step is needed) **See also**: [Detecting and Fixing Drift](../recipes/context-health.md) diff --git a/internal/assets/claude/skills/ctx-consolidate/SKILL.md b/internal/assets/claude/skills/ctx-consolidate/SKILL.md index 1c753d2ed..3adc9ea0f 100644 --- a/internal/assets/claude/skills/ctx-consolidate/SKILL.md +++ b/internal/assets/claude/skills/ctx-consolidate/SKILL.md @@ -135,13 +135,9 @@ For each approved group: 3. **Append originals to archive** at `archive/learnings-consolidated-YYYY-MM-DD.md` in the context directory (or `decisions-consolidated-YYYY-MM-DD.md`) -4. **Rebuild the index**: -```bash -ctx learning reindex -# or -ctx decision reindex -``` +No index rebuild is needed: the quick-reference index is computed on +demand by `ctx index `, never stored in the file. ### Step 6: Report Results @@ -191,5 +187,4 @@ Before reporting results: - [ ] Waited for explicit user approval per group - [ ] Each consolidated entry preserves all unique information - [ ] Original entries are archived, not deleted -- [ ] Ran `ctx reindex` after modifications - [ ] Reported what changed and where archives were written diff --git a/internal/assets/commands/commands.yaml b/internal/assets/commands/commands.yaml index c0bff465b..b145afc4b 100644 --- a/internal/assets/commands/commands.yaml +++ b/internal/assets/commands/commands.yaml @@ -215,14 +215,13 @@ ctx: short: Context - persistent context for AI coding assistants decision: long: |- - Manage the DECISIONS.md file and its quick-reference index. + Manage the DECISIONS.md file. - The decisions file maintains an auto-generated index at the top for quick - scanning. Use the subcommands to add entries and manage the index. + Use the add subcommand to append entries. A quick-reference index is + projected on demand by `ctx index DECISIONS.md`, not stored in the file. Subcommands: add Add a new decision entry to DECISIONS.md - reindex Regenerate the quick-reference index Examples: ctx decision add "Use PostgreSQL" \ @@ -230,7 +229,6 @@ decision: --context "Need a reliable database" \ --rationale "ACID compliance and JSON support" \ --consequence "Team needs PostgreSQL training" - ctx decision reindex short: Manage DECISIONS.md file decision.add: long: |- @@ -253,20 +251,6 @@ decision.add: --rationale "PostgreSQL offers ACID compliance and JSON support" \ --consequence "Team needs PostgreSQL training" short: Add a new decision entry to DECISIONS.md -decision.reindex: - long: |- - Regenerate the quick-reference index at the top of DECISIONS.md. - - The index is a compact table showing date and title for each decision, - allowing AI agents to quickly scan entries without reading the full file. - - This command is useful after manual edits to DECISIONS.md or when - migrating existing files to use the index format. - - Examples: - ctx decision reindex - short: Regenerate the quick-reference index - dream: long: |- Run a gated, proposing memory-consolidation pass over ideas/. @@ -580,14 +564,13 @@ journal.source: short: List and inspect session sources learning: long: |- - Manage the LEARNINGS.md file and its quick-reference index. + Manage the LEARNINGS.md file. - The learning file maintains an auto-generated index at the top for quick - scanning. Use the subcommands to add entries and manage the index. + Use the add subcommand to append entries. A quick-reference index is + projected on demand by `ctx index LEARNINGS.md`, not stored in the file. Subcommands: add Add a new learning entry to LEARNINGS.md - reindex Regenerate the quick-reference index Examples: ctx learning add "Go embed gotcha" \ @@ -595,7 +578,6 @@ learning: --context "Tried to embed files from parent directory" \ --lesson "go:embed only works with files in same or child directories" \ --application "Keep embedded files in internal/templates/" - ctx learning reindex short: Manage LEARNINGS.md file learning.add: long: |- @@ -618,19 +600,25 @@ learning.add: --lesson "go:embed only works with files in same or child directories" \ --application "Keep embedded files in internal/templates/, not project root" short: Add a new learning entry to LEARNINGS.md -learning.reindex: +index: long: |- - Regenerate the quick-reference index at the top of LEARNINGS.md. + Project the Markdown headings of a knowledge file as a computed table + of contents. - The index is a compact table showing date and title for each learning, - allowing AI agents to quickly scan entries without reading the full file. + ctx index reads any Markdown file and prints its ATX headings in file + order — a table of contents recomputed on demand, never stored in the + file. One command serves every knowledge file: DECISIONS.md and + LEARNINGS.md (## [timestamp] Title entries), CONVENTIONS.md, and + TASKS.md (## Phase ... sections). - This command is useful after manual edits to LEARNINGS.md or when - migrating existing files to use the index format. + By default only level-2 (##) headings are shown; use --depth 3 to include + level-3 (###) sub-headings. Use --json for machine-readable output. Examples: - ctx learning reindex - short: Regenerate the quick-reference index + ctx index .context/DECISIONS.md + ctx index .context/TASKS.md --depth 3 + ctx index .context/LEARNINGS.md --json + short: Project a file's headings as a table of contents load: long: |- Load and display the assembled context @@ -1074,24 +1062,6 @@ journal.unlock: ctx journal unlock abc12345 ctx journal unlock --all short: Remove lock protection from journal entries -reindex: - long: |- - Regenerate the quick-reference index at the top of both DECISIONS.md - and LEARNINGS.md in a single invocation. - - This is a convenience wrapper around: - ctx decision reindex - ctx learning reindex - - The index is a compact table showing date and title for each entry, - allowing AI agents to quickly scan entries without reading the full file. - - Run this after manual edits to either file or when migrating existing - files to use the index format. - - Examples: - ctx reindex - short: Regenerate indices for DECISIONS.md and LEARNINGS.md remind: long: |- Manage session-scoped reminders stored in .context/reminders.json. diff --git a/internal/assets/commands/examples.yaml b/internal/assets/commands/examples.yaml index 12ec7f4d2..43288b36a 100644 --- a/internal/assets/commands/examples.yaml +++ b/internal/assets/commands/examples.yaml @@ -81,11 +81,7 @@ ctx: ctx guide decision: - short: ' ctx decision reindex' - -decision.reindex: - short: ' ctx decision reindex' - + short: ' ctx decision add' doctor: short: |2- @@ -221,11 +217,11 @@ journal.unlock: ctx journal unlock --all learning: - short: ' ctx learning reindex' - -learning.reindex: - short: ' ctx learning reindex' + short: ' ctx learning add' +index: + short: |2- + ctx index .context/DECISIONS.md load: short: |2- ctx load @@ -346,9 +342,6 @@ permission.restore: permission.snapshot: short: ' ctx permission snapshot' -reindex: - short: ' ctx reindex' - remind: short: |2- ctx remind "Check test coverage before release" diff --git a/internal/assets/commands/flags.yaml b/internal/assets/commands/flags.yaml index 7303a3af9..2166fedae 100644 --- a/internal/assets/commands/flags.yaml +++ b/internal/assets/commands/flags.yaml @@ -215,6 +215,10 @@ site.feed.base-url: short: Base URL for entry links site.feed.out: short: Output path for the generated feed +index.depth: + short: 'Max heading level to include (2 = ## only; 3 adds ###)' +index.json: + short: Output headings as JSON status.json: short: Output as JSON status.verbose: diff --git a/internal/assets/commands/text/errors.yaml b/internal/assets/commands/text/errors.yaml index 716615774..31cc90a22 100644 --- a/internal/assets/commands/text/errors.yaml +++ b/internal/assets/commands/text/errors.yaml @@ -3,8 +3,6 @@ err.add.file-not-found: short: "context file %s not found. Run 'ctx init' first" -err.add.index-update: - short: 'failed to update index in %s: %w' err.add.json-parse: short: 'failed to parse JSON payload %s: %w' err.add.missing-fields: @@ -36,15 +34,6 @@ err.fmt.no-files: short: 'no context files found in %s' err.fmt.needs-formatting: short: files need formatting -err.index.entries-in-block: - short: |- - %s has entry content between its INDEX:START and INDEX:END markers. - Refusing to regenerate the index because that would delete those entries. - Move the INDEX:END marker above your entries, then retry. -err.index.malformed-markers: - short: |- - %s has missing, duplicated, or out-of-order INDEX:START/INDEX:END markers. - Refusing to regenerate the index. Restore a single well-formed marker pair, then retry. err.backup.context-dir-not-found: short: "context directory not found: %s - run 'ctx init'" err.backup.create-archive-dir: @@ -461,12 +450,6 @@ err.journal.source.event-log-read: short: 'reading event log: %w' err.journal.source.open-log-file: short: 'failed to open log file: %w' -err.journal.source.reindex-file-not-found: - short: "%s not found. Run 'ctx init' first" -err.journal.source.reindex-file-read: - short: 'failed to read %s: %w' -err.journal.source.reindex-file-write: - short: 'failed to write %s: %w' err.journal.source.stats-glob: short: 'globbing stats files: %w' err.reminder.parse-reminders: diff --git a/internal/assets/commands/text/headings.yaml b/internal/assets/commands/text/headings.yaml index 47ecf089a..0758d9972 100644 --- a/internal/assets/commands/text/headings.yaml +++ b/internal/assets/commands/text/headings.yaml @@ -9,11 +9,6 @@ heading.learnings: heading.learning-entry-start: short: '## [' -# Column headers for index tables -column.decision: - short: Decision -column.learning: - short: Learning # Task section headings heading.completed: diff --git a/internal/assets/commands/text/ui.yaml b/internal/assets/commands/text/ui.yaml index 90c1ded89..a98afbf5d 100644 --- a/internal/assets/commands/text/ui.yaml +++ b/internal/assets/commands/text/ui.yaml @@ -94,8 +94,6 @@ config.switched: short: 'switched to %s profile' confirm.proceed: short: 'Proceed? [y/N] ' -drift.cleared: - short: ✓ Index cleared (no %s found) drift.dead-path: short: references path that does not exist drift.entry-count: @@ -104,8 +102,6 @@ drift.missing-file: short: required context file is missing drift.missing-package: short: package %s is not documented -drift.regenerated: - short: ✓ Index regenerated with %d entries drift.secret: short: may contain secrets (constitution violation) drift.stale-age: @@ -206,7 +202,7 @@ guide.default: TRACKING DECISIONS & KNOWLEDGE ctx add -t TYPE Add a decision, learning, convention, or task ctx task complete Mark a task as done in TASKS.md - ctx decision reindex Rebuild the DECISIONS.md index table + ctx index FILE Project a file's headings as a table of contents BROWSING HISTORY ctx journal source List imported session transcripts diff --git a/internal/assets/integrations/copilot-cli/skills/ctx-consolidate/SKILL.md b/internal/assets/integrations/copilot-cli/skills/ctx-consolidate/SKILL.md index 30ccce47d..c74322b76 100644 --- a/internal/assets/integrations/copilot-cli/skills/ctx-consolidate/SKILL.md +++ b/internal/assets/integrations/copilot-cli/skills/ctx-consolidate/SKILL.md @@ -134,13 +134,9 @@ For each approved group: 3. **Append originals to archive** at `archive/learnings-consolidated-YYYY-MM-DD.md` in the context directory (or `decisions-consolidated-YYYY-MM-DD.md`) -4. **Rebuild the index**: -```bash -ctx learning reindex -# or -ctx decision reindex -``` +No index rebuild is needed: the quick-reference index is computed on +demand by `ctx index `, never stored in the file. ### Step 6: Report Results @@ -190,5 +186,4 @@ Before reporting results: - [ ] Waited for explicit user approval per group - [ ] Each consolidated entry preserves all unique information - [ ] Original entries are archived, not deleted -- [ ] Ran `ctx reindex` after modifications - [ ] Reported what changed and where archives were written diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index b66bb9ee9..9a8d41e5c 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -89,6 +89,7 @@ func TestInitialize(t *testing.T) { "learning", "task", "convention", + "index", "loop", "journal", "serve", @@ -113,6 +114,29 @@ func TestInitialize(t *testing.T) { } } +// TestReindexCommandRemoved guards the index-projection migration: the stored +// DECISIONS/LEARNINGS index is gone, so no `reindex` command (top-level or +// under decision/learning) may be reintroduced. The computed `ctx index` +// replaces it. The `kb` subtree is exempt — KB keeps its own indexing +// machinery (spec Non-Goal), so its `kb reindex` is out of scope. +func TestReindexCommandRemoved(t *testing.T) { + root := Initialize(RootCmd()) + + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + for _, sub := range c.Commands() { + if sub.Name() == "kb" { + continue // KB indexing is intentionally untouched + } + if sub.Name() == "reindex" { + t.Errorf("reindex command must not exist; found under %q", c.Name()) + } + walk(sub) + } + } + walk(root) +} + func TestRootCmdVersion(t *testing.T) { cmd := RootCmd() diff --git a/internal/bootstrap/group.go b/internal/bootstrap/group.go index 802894b91..9a3813ec4 100644 --- a/internal/bootstrap/group.go +++ b/internal/bootstrap/group.go @@ -22,6 +22,7 @@ import ( "github.com/ActiveMemory/ctx/internal/cli/handover" "github.com/ActiveMemory/ctx/internal/cli/hook" cliHub "github.com/ActiveMemory/ctx/internal/cli/hub" + "github.com/ActiveMemory/ctx/internal/cli/index" "github.com/ActiveMemory/ctx/internal/cli/initialize" "github.com/ActiveMemory/ctx/internal/cli/journal" "github.com/ActiveMemory/ctx/internal/cli/kb" @@ -33,7 +34,6 @@ import ( "github.com/ActiveMemory/ctx/internal/cli/pad" "github.com/ActiveMemory/ctx/internal/cli/permission" "github.com/ActiveMemory/ctx/internal/cli/prune" - "github.com/ActiveMemory/ctx/internal/cli/reindex" "github.com/ActiveMemory/ctx/internal/cli/remind" "github.com/ActiveMemory/ctx/internal/cli/serve" "github.com/ActiveMemory/ctx/internal/cli/setup" @@ -92,21 +92,21 @@ func contextCmds() []registration { // artifacts returns command registrations for the artifacts group. // // These commands operate on specific artifact files inside -// .context/: the DECISIONS.md, LEARNINGS.md, TASKS.md, and -// CONVENTIONS.md stores, plus the `reindex` shortcut that -// rebuilds the decision/learning index tables in a single call. -// Each noun parent owns its add subcommand (ctx add). +// .context/: DECISIONS.md, LEARNINGS.md, TASKS.md, and +// CONVENTIONS.md. Each noun parent owns its add subcommand +// (ctx add); ctx index projects any file's headings as +// a computed table of contents. // // Returns: // - []registration: Decision, learning, task, convention, -// and reindex commands +// index, kb, and handover commands func artifacts() []registration { return []registration{ {decision.Cmd, embedCmd.GroupArtifacts}, {learning.Cmd, embedCmd.GroupArtifacts}, {task.Cmd, embedCmd.GroupArtifacts}, {convention.Cmd, embedCmd.GroupArtifacts}, - {reindex.Cmd, embedCmd.GroupArtifacts}, + {index.Cmd, embedCmd.GroupArtifacts}, {kb.Cmd, embedCmd.GroupArtifacts}, {handover.Cmd, embedCmd.GroupArtifacts}, } diff --git a/internal/cli/agent/core/budget/budget_test.go b/internal/cli/agent/core/budget/budget_test.go index d1c9c3862..780a4b50e 100644 --- a/internal/cli/agent/core/budget/budget_test.go +++ b/internal/cli/agent/core/budget/budget_test.go @@ -12,16 +12,16 @@ import ( "github.com/ActiveMemory/ctx/internal/cli/agent/core/score" "github.com/ActiveMemory/ctx/internal/entity" - "github.com/ActiveMemory/ctx/internal/index" + "github.com/ActiveMemory/ctx/internal/heading" ) -func makeBlock(date, title, body string) index.EntryBlock { +func makeBlock(date, title, body string) heading.EntryBlock { header := "## [" + date + "-120000] " + title lines := []string{header} if body != "" { lines = append(lines, "", body) } - return index.EntryBlock{ + return heading.EntryBlock{ Entry: entity.IndexEntry{ Timestamp: date + "-120000", Date: date, @@ -207,7 +207,7 @@ func TestFillSection(t *testing.T) { t.Run("superseded entries skipped", func(t *testing.T) { superseded := []score.Entry{ { - EntryBlock: index.EntryBlock{ + EntryBlock: heading.EntryBlock{ Entry: entity.IndexEntry{ Timestamp: "2026-02-19-120000", Date: "2026-02-19", diff --git a/internal/cli/agent/core/budget/parse.go b/internal/cli/agent/core/budget/parse.go index ded1bb501..6cb199183 100644 --- a/internal/cli/agent/core/budget/parse.go +++ b/internal/cli/agent/core/budget/parse.go @@ -11,7 +11,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/agent" cfgCtx "github.com/ActiveMemory/ctx/internal/config/ctx" "github.com/ActiveMemory/ctx/internal/entity" - "github.com/ActiveMemory/ctx/internal/index" + "github.com/ActiveMemory/ctx/internal/heading" ) // ParseEntryBlocks parses a context file into entry blocks. @@ -21,10 +21,12 @@ import ( // - fileName: Name of the file to parse (e.g., config.Decision) // // Returns: -// - []index.EntryBlock: Parsed entry blocks; nil if the file is not found -func ParseEntryBlocks(ctx *entity.Context, fileName string) []index.EntryBlock { +// - []heading.EntryBlock: Parsed entry blocks; nil if the file is not found +func ParseEntryBlocks( + ctx *entity.Context, fileName string, +) []heading.EntryBlock { if f := ctx.File(fileName); f != nil { - return index.ParseEntryBlocks(string(f.Content)) + return heading.ParseEntryBlocks(string(f.Content)) } return nil } diff --git a/internal/cli/agent/core/score/score.go b/internal/cli/agent/core/score/score.go index d42fc627a..d88a763a5 100644 --- a/internal/cli/agent/core/score/score.go +++ b/internal/cli/agent/core/score/score.go @@ -14,8 +14,8 @@ import ( "github.com/ActiveMemory/ctx/internal/config/agent" cfgTime "github.com/ActiveMemory/ctx/internal/config/time" "github.com/ActiveMemory/ctx/internal/context/token" + "github.com/ActiveMemory/ctx/internal/heading" "github.com/ActiveMemory/ctx/internal/i18n" - "github.com/ActiveMemory/ctx/internal/index" ) // Recency returns a score based on the entry's age. @@ -32,7 +32,7 @@ import ( // // Returns: // - float64: Recency score between 0.2 and 1.0 -func Recency(eb *index.EntryBlock, now time.Time) float64 { +func Recency(eb *heading.EntryBlock, now time.Time) float64 { entryDate, err := time.ParseInLocation( cfgTime.DateFormat, eb.Entry.Date, time.Local, ) @@ -63,7 +63,7 @@ func Recency(eb *index.EntryBlock, now time.Time) float64 { // // Returns: // - float64: Relevance score between 0.0 and 1.0 -func Relevance(eb *index.EntryBlock, keywords []string) float64 { +func Relevance(eb *heading.EntryBlock, keywords []string) float64 { if len(keywords) == 0 { return 0.0 } @@ -92,7 +92,7 @@ func Relevance(eb *index.EntryBlock, keywords []string) float64 { // // Returns: // - float64: Combined score (0.0-2.0), or 0.0 if superseded -func Score(eb *index.EntryBlock, keywords []string, now time.Time) float64 { +func Score(eb *heading.EntryBlock, keywords []string, now time.Time) float64 { if eb.IsSuperseded() { return 0.0 } @@ -139,7 +139,7 @@ func ExtractTaskKeywords(tasks []string) []string { // Returns: // - []ScoredEntry: Entries sorted by score descending, with token estimates func All( - blocks []index.EntryBlock, keywords []string, now time.Time, + blocks []heading.EntryBlock, keywords []string, now time.Time, ) []Entry { scored := make([]Entry, 0, len(blocks)) for i := range blocks { diff --git a/internal/cli/agent/core/score/score_test.go b/internal/cli/agent/core/score/score_test.go index f4c833440..fecfec714 100644 --- a/internal/cli/agent/core/score/score_test.go +++ b/internal/cli/agent/core/score/score_test.go @@ -11,16 +11,16 @@ import ( "time" "github.com/ActiveMemory/ctx/internal/entity" - "github.com/ActiveMemory/ctx/internal/index" + "github.com/ActiveMemory/ctx/internal/heading" ) -func makeBlock(date, title, body string) index.EntryBlock { +func makeBlock(date, title, body string) heading.EntryBlock { header := "## [" + date + "-120000] " + title lines := []string{header} if body != "" { lines = append(lines, "", body) } - return index.EntryBlock{ + return heading.EntryBlock{ Entry: entity.IndexEntry{ Timestamp: date + "-120000", Date: date, @@ -104,7 +104,7 @@ func TestRelevanceScore(t *testing.T) { func TestScoreEntry_Superseded(t *testing.T) { now := time.Date(2026, 2, 19, 12, 0, 0, 0, time.Local) - eb := index.EntryBlock{ + eb := heading.EntryBlock{ Entry: entity.IndexEntry{ Timestamp: "2026-02-19-120000", Date: "2026-02-19", @@ -200,7 +200,7 @@ func TestExtractTaskKeywords_Empty(t *testing.T) { func TestScoreEntries_Ordering(t *testing.T) { now := time.Date(2026, 2, 19, 12, 0, 0, 0, time.Local) - blocks := []index.EntryBlock{ + blocks := []heading.EntryBlock{ makeBlock("2025-10-01", "Old irrelevant", "something unrelated"), makeBlock("2026-02-19", "Recent relevant", "hook scoring for agent"), makeBlock("2026-02-10", "Medium age", "hook configuration"), @@ -234,7 +234,7 @@ func TestScoreEntries_Empty(t *testing.T) { func TestScoreEntries_TokenEstimate(t *testing.T) { now := time.Now() - blocks := []index.EntryBlock{ + blocks := []heading.EntryBlock{ makeBlock( "2026-02-19", "Test entry", "This is some body content for testing tokens.", diff --git a/internal/cli/agent/core/score/types.go b/internal/cli/agent/core/score/types.go index 4a73f19e7..5de8bca33 100644 --- a/internal/cli/agent/core/score/types.go +++ b/internal/cli/agent/core/score/types.go @@ -6,7 +6,7 @@ package score -import "github.com/ActiveMemory/ctx/internal/index" +import "github.com/ActiveMemory/ctx/internal/heading" // Entry is an entry block with a computed relevance score. // @@ -15,7 +15,7 @@ import "github.com/ActiveMemory/ctx/internal/index" // - Score: Combined recency + relevance score (0.0-2.0) // - Tokens: Estimated token count type Entry struct { - index.EntryBlock + heading.EntryBlock Score float64 Tokens int } diff --git a/internal/cli/decision/cmd/reindex/cmd.go b/internal/cli/decision/cmd/reindex/cmd.go deleted file mode 100644 index 4ac00b675..000000000 --- a/internal/cli/decision/cmd/reindex/cmd.go +++ /dev/null @@ -1,29 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -package reindex - -import ( - "github.com/spf13/cobra" - - "github.com/ActiveMemory/ctx/internal/assets/read/desc" - "github.com/ActiveMemory/ctx/internal/config/embed/cmd" -) - -// Cmd returns the reindex subcommand for decisions. -// -// Returns: -// - *cobra.Command: Command for regenerating the DECISIONS.md index -func Cmd() *cobra.Command { - short, long := desc.Command(cmd.DescKeyDecisionReindex) - return &cobra.Command{ - Use: cmd.UseReindex, - Short: short, - Long: long, - Example: desc.Example(cmd.DescKeyDecisionReindex), - RunE: Run, - } -} diff --git a/internal/cli/decision/cmd/reindex/doc.go b/internal/cli/decision/cmd/reindex/doc.go deleted file mode 100644 index 0829d9d44..000000000 --- a/internal/cli/decision/cmd/reindex/doc.go +++ /dev/null @@ -1,40 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -// Package reindex implements the "ctx decision reindex" -// subcommand that regenerates the index table at the -// top of DECISIONS.md. -// -// # What It Does -// -// Parses all decision entry headers in DECISIONS.md, -// sorts them, and rebuilds the quick-reference index -// table that appears at the top of the file. This is -// useful after manual edits that leave the index out -// of sync with the entries below. -// -// # Arguments -// -// None required. The command operates on the -// DECISIONS.md file in the active context directory. -// -// # Flags -// -// None. -// -// # Output -// -// Prints a confirmation message indicating how many -// entries were indexed. The file is updated in place. -// -// # Delegation -// -// [Cmd] builds the cobra.Command and delegates -// directly to [Run]. Run resolves the file path via -// [rc.ContextDir], then calls [index.Reindex] with -// the decision-specific update function to parse -// headers and regenerate the table. -package reindex diff --git a/internal/cli/decision/cmd/reindex/run.go b/internal/cli/decision/cmd/reindex/run.go deleted file mode 100644 index a01ac5ee8..000000000 --- a/internal/cli/decision/cmd/reindex/run.go +++ /dev/null @@ -1,42 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -package reindex - -import ( - "path/filepath" - - "github.com/spf13/cobra" - - "github.com/ActiveMemory/ctx/internal/config/ctx" - cfgEntry "github.com/ActiveMemory/ctx/internal/config/entry" - "github.com/ActiveMemory/ctx/internal/index" - "github.com/ActiveMemory/ctx/internal/rc" -) - -// Run regenerates the DECISIONS.md index. -// -// Parameters: -// - cmd: Cobra command for output messages -// - args: Command arguments (unused) -// -// Returns: -// - error: Non-nil if the file read/write fails -func Run(cmd *cobra.Command, _ []string) error { - ctxDir, err := rc.RequireContextDir() - if err != nil { - cmd.SilenceUsage = true - return err - } - filePath := filepath.Join(ctxDir, ctx.Decision) - return index.Reindex( - cmd.OutOrStdout(), - filePath, - ctx.Decision, - index.UpdateDecisions, - cfgEntry.Decision, - ) -} diff --git a/internal/cli/decision/decision.go b/internal/cli/decision/decision.go index 959567a26..f87ed6eb2 100644 --- a/internal/cli/decision/decision.go +++ b/internal/cli/decision/decision.go @@ -10,7 +10,6 @@ import ( "github.com/spf13/cobra" "github.com/ActiveMemory/ctx/internal/cli/decision/cmd/add" - "github.com/ActiveMemory/ctx/internal/cli/decision/cmd/reindex" "github.com/ActiveMemory/ctx/internal/cli/parent" "github.com/ActiveMemory/ctx/internal/config/embed/cmd" ) @@ -20,13 +19,11 @@ import ( // The decision command provides utilities for managing the // DECISIONS.md file: // - add: Add a new decision entry -// - reindex: Regenerate the quick-reference index // // Returns: // - *cobra.Command: The decision command with subcommands func Cmd() *cobra.Command { return parent.Cmd(cmd.DescKeyDecision, cmd.UseDecision, add.Cmd(), - reindex.Cmd(), ) } diff --git a/internal/cli/decision/decision_test.go b/internal/cli/decision/decision_test.go index 58227b703..e6dfd97e4 100644 --- a/internal/cli/decision/decision_test.go +++ b/internal/cli/decision/decision_test.go @@ -6,16 +6,7 @@ package decision -import ( - "os" - "path/filepath" - "testing" - - "github.com/ActiveMemory/ctx/internal/config/ctx" - "github.com/ActiveMemory/ctx/internal/config/dir" - "github.com/ActiveMemory/ctx/internal/rc" - "github.com/ActiveMemory/ctx/internal/testutil/testctx" -) +import "testing" func TestCmd(t *testing.T) { cmd := Cmd() @@ -36,109 +27,3 @@ func TestCmd(t *testing.T) { t.Error("Cmd().Long is empty") } } - -func TestCmd_HasReindexSubcommand(t *testing.T) { - cmd := Cmd() - - var found bool - for _, sub := range cmd.Commands() { - if sub.Use == "reindex" { - found = true - if sub.Short == "" { - t.Error("reindex subcommand has empty Short description") - } - if sub.RunE == nil { - t.Error("reindex subcommand has no RunE function") - } - break - } - } - - if !found { - t.Error("reindex subcommand not found") - } -} - -func TestRunReindex_NoFile(t *testing.T) { - tempDir := t.TempDir() - origDir, _ := os.Getwd() - _ = os.Chdir(tempDir) - defer func() { _ = os.Chdir(origDir) }() - - testctx.Declare(t, tempDir) - defer rc.Reset() - - cmd := Cmd() - cmd.SetArgs([]string{"reindex"}) - - err := cmd.Execute() - if err == nil { - t.Fatal("expected error when DECISIONS.md does not exist") - } -} - -func TestRunReindex_WithFile(t *testing.T) { - tempDir := t.TempDir() - origDir, _ := os.Getwd() - _ = os.Chdir(tempDir) - defer func() { _ = os.Chdir(origDir) }() - - testctx.Declare(t, tempDir) - defer rc.Reset() - - // Create the context directory and DECISIONS.md file - ctxDir := filepath.Join(tempDir, dir.Context) - _ = os.MkdirAll(ctxDir, 0750) - - content := `# Decisions - -## 2026-01-15 - Use YAML for config - -**Context:** Need a config format -**Rationale:** YAML is human-readable -**Consequence:** Added yaml dependency -` - _ = os.WriteFile(filepath.Join(ctxDir, ctx.Decision), []byte(content), 0600) - - cmd := Cmd() - cmd.SetArgs([]string{"reindex"}) - - err := cmd.Execute() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Verify the file was updated - decPath := filepath.Join(ctxDir, ctx.Decision) - updated, err := os.ReadFile(decPath) //nolint:gosec // test path - if err != nil { - t.Fatalf("failed to read updated file: %v", err) - } - if len(updated) == 0 { - t.Error("updated file is empty") - } -} - -func TestRunReindex_EmptyFile(t *testing.T) { - tempDir := t.TempDir() - origDir, _ := os.Getwd() - _ = os.Chdir(tempDir) - defer func() { _ = os.Chdir(origDir) }() - - testctx.Declare(t, tempDir) - defer rc.Reset() - - // Create the context directory and empty DECISIONS.md - ctxDir := filepath.Join(tempDir, dir.Context) - _ = os.MkdirAll(ctxDir, 0750) - decFile := filepath.Join(ctxDir, ctx.Decision) - _ = os.WriteFile(decFile, []byte("# Decisions\n"), 0600) - - cmd := Cmd() - cmd.SetArgs([]string{"reindex"}) - - err := cmd.Execute() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} diff --git a/internal/cli/decision/doc.go b/internal/cli/decision/doc.go index ed7184611..c044bc985 100644 --- a/internal/cli/decision/doc.go +++ b/internal/cli/decision/doc.go @@ -9,9 +9,9 @@ // // DECISIONS.md records architectural choices with their // rationale, trade-offs, and timestamps. The decision -// command group provides tooling to maintain this file's -// quick-reference index table, which maps decision -// numbers to one-line summaries for fast scanning. +// command group provides tooling to append entries; a +// quick-reference index is projected on demand by +// `ctx index DECISIONS.md`, not stored in the file. // // # Subcommands // @@ -19,13 +19,8 @@ // ADR-style fields (context, rationale, consequence) // plus required provenance metadata (session-id, branch, // commit) -// - reindex: scans DECISIONS.md entries and regenerates -// the index table at the top of the file, ensuring -// numbering and summaries stay consistent with the -// full entries below // // # Subpackages // // cmd/add: cobra command for noun-first decision addition -// cmd/reindex: cobra command for index regeneration package decision diff --git a/internal/cli/index/cmd/root/cmd.go b/internal/cli/index/cmd/root/cmd.go new file mode 100644 index 000000000..2752a5a58 --- /dev/null +++ b/internal/cli/index/cmd/root/cmd.go @@ -0,0 +1,60 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package root + +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" +) + +// defaultDepth is the shallowest-only heading depth: L2 (`##`) headings only. +const defaultDepth = 2 + +// Cmd returns the "ctx index" command. +// +// It takes one positional argument (the Markdown file to project) and prints +// its ATX headings in file order. Deeper levels are opt-in via --depth; --json +// switches to machine-readable output. +// +// Flags: +// - --depth: Max heading level to include (2 = ## only; 3 adds ###) +// - --json: Emit a JSON array of {level, text} instead of lines +// +// Returns: +// - *cobra.Command: Configured index command with flags registered. +func Cmd() *cobra.Command { + var ( + depth int + jsonOutput bool + ) + + short, long := desc.Command(cmd.DescKeyIndex) + c := &cobra.Command{ + Use: cmd.UseIndex, + Short: short, + Long: long, + Example: desc.Example(cmd.DescKeyIndex), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return Run(cmd, args[0], depth, jsonOutput) + }, + } + + flagbind.IntFlag(c, &depth, + cFlag.Depth, defaultDepth, flag.DescKeyIndexDepth, + ) + flagbind.BoolFlag(c, &jsonOutput, + cFlag.JSON, flag.DescKeyIndexJSON, + ) + + return c +} diff --git a/internal/cli/index/cmd/root/doc.go b/internal/cli/index/cmd/root/doc.go new file mode 100644 index 000000000..e16831020 --- /dev/null +++ b/internal/cli/index/cmd/root/doc.go @@ -0,0 +1,14 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package root wires the `ctx index` cobra command. +// +// Cmd builds the command (positional FILE argument, --depth and --json +// flags); Run reads the file, delegates heading extraction to +// [internal/heading], and rendering to [internal/write/index]. Keeping the +// recognizer and renderer in their own packages leaves this package as thin +// argument-and-flag plumbing. +package root diff --git a/internal/cli/index/cmd/root/run.go b/internal/cli/index/cmd/root/run.go new file mode 100644 index 000000000..5ada996c3 --- /dev/null +++ b/internal/cli/index/cmd/root/run.go @@ -0,0 +1,46 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package root + +import ( + "path/filepath" + + "github.com/spf13/cobra" + + errFs "github.com/ActiveMemory/ctx/internal/err/fs" + "github.com/ActiveMemory/ctx/internal/heading" + "github.com/ActiveMemory/ctx/internal/io" + writeIndex "github.com/ActiveMemory/ctx/internal/write/index" +) + +// Run executes the index command logic: read the file, project its headings, +// and render them. +// +// Parameters: +// - cmd: Cobra command for the output stream. +// - path: Path to the Markdown file to project. +// - depth: Deepest heading level to include (2 = ## only; 3 adds ###). +// - jsonOutput: If true, emit a JSON array instead of lines. +// +// Returns: +// - error: Non-nil if the file cannot be read (path-bearing) or JSON +// marshaling fails. +func Run(cmd *cobra.Command, path string, depth int, jsonOutput bool) error { + content, readErr := io.SafeReadUserFile(filepath.Clean(path)) + if readErr != nil { + cmd.SilenceUsage = true + return errFs.FileRead(path, readErr) + } + + headings := heading.Headings(string(content), depth) + + if jsonOutput { + return writeIndex.JSON(cmd, headings) + } + writeIndex.Lines(cmd, headings) + return nil +} diff --git a/internal/cli/index/doc.go b/internal/cli/index/doc.go new file mode 100644 index 000000000..631f0dace --- /dev/null +++ b/internal/cli/index/doc.go @@ -0,0 +1,23 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package index implements the `ctx index` command: a computed table of +// contents over any Markdown knowledge file. +// +// `ctx index ` projects the file's ATX headings (`##`, and `###` with +// --depth 3) in file order, replacing the stored `` blocks that +// DECISIONS.md / LEARNINGS.md once carried. The index is never written back — +// it is recomputed on demand, so it cannot drift from the entries it +// summarizes. One generic command serves every knowledge file: DECISIONS, +// LEARNINGS, CONVENTIONS, and TASKS (`## Phase …`) alike. +// +// The heading recognizer lives in [internal/heading]; this package is the CLI +// surface (argument + flags) and delegates rendering to [internal/write/index]. +// +// # Subpackages +// +// cmd/root: cobra command wiring (positional FILE arg, --depth, --json) +package index diff --git a/internal/cli/reindex/reindex.go b/internal/cli/index/index.go similarity index 50% rename from internal/cli/reindex/reindex.go rename to internal/cli/index/index.go index 740e573c7..2e13ec1f8 100644 --- a/internal/cli/reindex/reindex.go +++ b/internal/cli/index/index.go @@ -4,18 +4,19 @@ // \ Copyright 2026-present Context contributors. // SPDX-License-Identifier: Apache-2.0 -package reindex +package index import ( "github.com/spf13/cobra" - reindexRoot "github.com/ActiveMemory/ctx/internal/cli/reindex/cmd/root" + indexRoot "github.com/ActiveMemory/ctx/internal/cli/index/cmd/root" ) -// Cmd returns the reindex convenience command. +// Cmd returns the "ctx index" command, which projects the Markdown headings +// of a knowledge file as a computed table of contents. // // Returns: -// - *cobra.Command: The reindex command with subcommands registered +// - *cobra.Command: The index command. func Cmd() *cobra.Command { - return reindexRoot.Cmd() + return indexRoot.Cmd() } diff --git a/internal/cli/learning/cmd/reindex/cmd.go b/internal/cli/learning/cmd/reindex/cmd.go deleted file mode 100644 index 43a8ac735..000000000 --- a/internal/cli/learning/cmd/reindex/cmd.go +++ /dev/null @@ -1,29 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -package reindex - -import ( - "github.com/spf13/cobra" - - "github.com/ActiveMemory/ctx/internal/assets/read/desc" - "github.com/ActiveMemory/ctx/internal/config/embed/cmd" -) - -// Cmd returns the reindex subcommand for learning. -// -// Returns: -// - *cobra.Command: Command for regenerating the LEARNINGS.md index -func Cmd() *cobra.Command { - short, long := desc.Command(cmd.DescKeyLearningReindex) - return &cobra.Command{ - Use: cmd.UseReindex, - Short: short, - Long: long, - Example: desc.Example(cmd.DescKeyLearningReindex), - RunE: Run, - } -} diff --git a/internal/cli/learning/cmd/reindex/doc.go b/internal/cli/learning/cmd/reindex/doc.go deleted file mode 100644 index 42bc4e2b3..000000000 --- a/internal/cli/learning/cmd/reindex/doc.go +++ /dev/null @@ -1,43 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -// Package reindex implements the "ctx learning reindex" -// command. -// -// # Overview -// -// The reindex command regenerates the index table at the -// top of LEARNINGS.md. It parses all timestamped entry -// headers in the file, sorts them by date, rebuilds the -// index section, and writes the result back in place. -// -// This is useful after manual edits to LEARNINGS.md that -// may have left the index out of sync with the actual -// entries, or after bulk imports that appended entries -// without updating the index. -// -// # Flags -// -// This command accepts no flags. -// -// # Behavior -// -// [Cmd] builds a simple cobra.Command with no flags. -// [Run] resolves the LEARNINGS.md path from the context -// directory and delegates to the shared index.Reindex -// function, which handles parsing, sorting, and -// rewriting the file. -// -// The reindex operation is idempotent: running it -// multiple times on an already-indexed file produces -// the same output. -// -// # Output -// -// Prints a confirmation message to stdout indicating -// that the index was regenerated, along with the number -// of entries found. -package reindex diff --git a/internal/cli/learning/cmd/reindex/run.go b/internal/cli/learning/cmd/reindex/run.go deleted file mode 100644 index 717ed5303..000000000 --- a/internal/cli/learning/cmd/reindex/run.go +++ /dev/null @@ -1,42 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -package reindex - -import ( - "path/filepath" - - "github.com/spf13/cobra" - - "github.com/ActiveMemory/ctx/internal/config/ctx" - cfgEntry "github.com/ActiveMemory/ctx/internal/config/entry" - "github.com/ActiveMemory/ctx/internal/index" - "github.com/ActiveMemory/ctx/internal/rc" -) - -// Run regenerates the LEARNINGS.md index. -// -// Parameters: -// - cmd: Cobra command for output messages -// - args: Command arguments (unused) -// -// Returns: -// - error: Non-nil if the file read/write fails -func Run(cmd *cobra.Command, _ []string) error { - ctxDir, err := rc.RequireContextDir() - if err != nil { - cmd.SilenceUsage = true - return err - } - filePath := filepath.Join(ctxDir, ctx.Learning) - return index.Reindex( - cmd.OutOrStdout(), - filePath, - ctx.Learning, - index.UpdateLearnings, - cfgEntry.Learning, - ) -} diff --git a/internal/cli/learning/doc.go b/internal/cli/learning/doc.go index 7ab7d77ac..7568156b9 100644 --- a/internal/cli/learning/doc.go +++ b/internal/cli/learning/doc.go @@ -6,15 +6,14 @@ // Package learning implements the **`ctx learning`** // command group for managing `LEARNINGS.md`, currently -// just the `reindex` subcommand that regenerates the -// quick-reference index table at the top of the file. +// just the `add` subcommand that appends new entries. // // `LEARNINGS.md` is the project's running record of -// gotchas, "gotcha" notes, and hard-won lessons. The -// quick-reference index lets `ctx agent` inject a -// token-cheap **table of contents** instead of the full -// prose, so the AI can scan available learnings and -// request the ones it needs by ID. +// gotchas, "gotcha" notes, and hard-won lessons. A +// quick-reference table of contents is projected on demand +// by `ctx index LEARNINGS.md`, so `ctx agent` can scan +// available learnings without reading the full prose — +// computed, never stored in the file. // // # Subcommands // @@ -24,14 +23,6 @@ // (session-id, branch, commit). Implementation in // [internal/cli/learning/cmd/add] delegates to the // shared add core. -// - **`ctx learning reindex`**: rebuilds the index -// table by parsing every entry header in -// `LEARNINGS.md` and emitting a fresh -// chronologically-sorted table between the -// `` / `` -// markers. Idempotent. See -// [internal/cli/learning/cmd/reindex] for the -// implementation. // // # Shared Add Core // diff --git a/internal/cli/learning/learning.go b/internal/cli/learning/learning.go index bd7c29c4f..39a398e89 100644 --- a/internal/cli/learning/learning.go +++ b/internal/cli/learning/learning.go @@ -10,7 +10,6 @@ import ( "github.com/spf13/cobra" "github.com/ActiveMemory/ctx/internal/cli/learning/cmd/add" - "github.com/ActiveMemory/ctx/internal/cli/learning/cmd/reindex" "github.com/ActiveMemory/ctx/internal/cli/parent" "github.com/ActiveMemory/ctx/internal/config/embed/cmd" ) @@ -20,13 +19,11 @@ import ( // The learning command provides utilities for managing the // LEARNINGS.md file: // - add: Add a new learning entry -// - reindex: Regenerate the quick-reference index // // Returns: // - *cobra.Command: The learning command with subcommands func Cmd() *cobra.Command { return parent.Cmd(cmd.DescKeyLearning, cmd.UseLearning, add.Cmd(), - reindex.Cmd(), ) } diff --git a/internal/cli/learning/learning_test.go b/internal/cli/learning/learning_test.go index 652bba7c4..6e857c511 100644 --- a/internal/cli/learning/learning_test.go +++ b/internal/cli/learning/learning_test.go @@ -6,16 +6,7 @@ package learning -import ( - "os" - "path/filepath" - "testing" - - "github.com/ActiveMemory/ctx/internal/config/ctx" - "github.com/ActiveMemory/ctx/internal/config/dir" - "github.com/ActiveMemory/ctx/internal/rc" - "github.com/ActiveMemory/ctx/internal/testutil/testctx" -) +import "testing" func TestCmd(t *testing.T) { cmd := Cmd() @@ -36,109 +27,3 @@ func TestCmd(t *testing.T) { t.Error("Cmd().Long is empty") } } - -func TestCmd_HasReindexSubcommand(t *testing.T) { - cmd := Cmd() - - var found bool - for _, sub := range cmd.Commands() { - if sub.Use == "reindex" { - found = true - if sub.Short == "" { - t.Error("reindex subcommand has empty Short description") - } - if sub.RunE == nil { - t.Error("reindex subcommand has no RunE function") - } - break - } - } - - if !found { - t.Error("reindex subcommand not found") - } -} - -func TestRunReindex_NoFile(t *testing.T) { - tempDir := t.TempDir() - origDir, _ := os.Getwd() - _ = os.Chdir(tempDir) - defer func() { _ = os.Chdir(origDir) }() - - testctx.Declare(t, tempDir) - defer rc.Reset() - - cmd := Cmd() - cmd.SetArgs([]string{"reindex"}) - - err := cmd.Execute() - if err == nil { - t.Fatal("expected error when LEARNINGS.md does not exist") - } -} - -func TestRunReindex_WithFile(t *testing.T) { - tempDir := t.TempDir() - origDir, _ := os.Getwd() - _ = os.Chdir(tempDir) - defer func() { _ = os.Chdir(origDir) }() - - testctx.Declare(t, tempDir) - defer rc.Reset() - - // Create the context directory and LEARNINGS.md file - ctxDir := filepath.Join(tempDir, dir.Context) - _ = os.MkdirAll(ctxDir, 0750) - - content := `# Learnings - -## 2026-01-15 - Always validate input - -**Context:** Found a bug from invalid input -**Lesson:** Validate at boundaries -**Application:** Add validation to all handlers -` - _ = os.WriteFile(filepath.Join(ctxDir, ctx.Learning), []byte(content), 0600) - - cmd := Cmd() - cmd.SetArgs([]string{"reindex"}) - - err := cmd.Execute() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Verify the file was updated - learnPath := filepath.Join(ctxDir, ctx.Learning) - updated, err := os.ReadFile(learnPath) //nolint:gosec // test path - if err != nil { - t.Fatalf("failed to read updated file: %v", err) - } - if len(updated) == 0 { - t.Error("updated file is empty") - } -} - -func TestRunReindex_EmptyFile(t *testing.T) { - tempDir := t.TempDir() - origDir, _ := os.Getwd() - _ = os.Chdir(tempDir) - defer func() { _ = os.Chdir(origDir) }() - - testctx.Declare(t, tempDir) - defer rc.Reset() - - // Create the context directory and empty LEARNINGS.md - ctxDir := filepath.Join(tempDir, dir.Context) - _ = os.MkdirAll(ctxDir, 0750) - learnFile := filepath.Join(ctxDir, ctx.Learning) - _ = os.WriteFile(learnFile, []byte("# Learnings\n"), 0600) - - cmd := Cmd() - cmd.SetArgs([]string{"reindex"}) - - err := cmd.Execute() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} diff --git a/internal/cli/reindex/cmd/root/cmd.go b/internal/cli/reindex/cmd/root/cmd.go deleted file mode 100644 index 83beb8533..000000000 --- a/internal/cli/reindex/cmd/root/cmd.go +++ /dev/null @@ -1,33 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -package root - -import ( - "github.com/spf13/cobra" - - "github.com/ActiveMemory/ctx/internal/assets/read/desc" - "github.com/ActiveMemory/ctx/internal/config/embed/cmd" -) - -// Cmd returns the reindex convenience command. -// -// The reindex command regenerates the quick-reference index at the top of -// both DECISIONS.md and LEARNINGS.md in a single invocation. -// -// Returns: -// - *cobra.Command: The reindex command -func Cmd() *cobra.Command { - short, long := desc.Command(cmd.DescKeyReindex) - - return &cobra.Command{ - Use: cmd.UseReindex, - Short: short, - Long: long, - Example: desc.Example(cmd.DescKeyReindex), - RunE: Run, - } -} diff --git a/internal/cli/reindex/cmd/root/doc.go b/internal/cli/reindex/cmd/root/doc.go deleted file mode 100644 index 5cba12762..000000000 --- a/internal/cli/reindex/cmd/root/doc.go +++ /dev/null @@ -1,42 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -// Package root implements the "ctx reindex" command -// for regenerating quick-reference indices in context -// files. -// -// # Behavior -// -// The command rebuilds the index section at the top -// of both DECISIONS.md and LEARNINGS.md in a single -// invocation. The index provides a compact table of -// contents that lets agents and humans quickly scan -// available entries without reading the full file. -// -// Each file is processed independently: if the first -// succeeds but the second fails, the first index is -// still written. The command reads each file from the -// context directory, parses its entries, generates -// the index block, and writes the updated file back. -// -// # Flags -// -// None. This command takes no flags. -// -// # Output -// -// For each file, prints a confirmation line to stdout -// indicating the file was reindexed. On failure, -// returns an error identifying which file could not -// be read or written. -// -// # Delegation -// -// Index generation is handled by [index.Reindex], -// which accepts an update function and entry parser. -// The context directory path comes from [rc.ContextDir]. -// File names are defined in the [ctx] config package. -package root diff --git a/internal/cli/reindex/cmd/root/run.go b/internal/cli/reindex/cmd/root/run.go deleted file mode 100644 index fbdef7c3d..000000000 --- a/internal/cli/reindex/cmd/root/run.go +++ /dev/null @@ -1,56 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -package root - -import ( - "path/filepath" - - "github.com/spf13/cobra" - - "github.com/ActiveMemory/ctx/internal/config/ctx" - "github.com/ActiveMemory/ctx/internal/config/entry" - "github.com/ActiveMemory/ctx/internal/index" - "github.com/ActiveMemory/ctx/internal/rc" -) - -// Run regenerates the index for both DECISIONS.md and LEARNINGS.md. -// -// Parameters: -// - cmd: Cobra command for output messages -// - args: Command arguments (unused) -// -// Returns: -// - error: Non-nil if either file read/write fails -func Run(cmd *cobra.Command, _ []string) error { - w := cmd.OutOrStdout() - ctxDir, err := rc.RequireContextDir() - if err != nil { - cmd.SilenceUsage = true - return err - } - - decisionsPath := filepath.Join(ctxDir, ctx.Decision) - decisionsErr := index.Reindex( - w, - decisionsPath, - ctx.Decision, - index.UpdateDecisions, - entry.Decisions, - ) - if decisionsErr != nil { - return decisionsErr - } - - learningsPath := filepath.Join(ctxDir, ctx.Learning) - return index.Reindex( - w, - learningsPath, - ctx.Learning, - index.UpdateLearnings, - entry.Learnings, - ) -} diff --git a/internal/cli/reindex/doc.go b/internal/cli/reindex/doc.go deleted file mode 100644 index 9077f8034..000000000 --- a/internal/cli/reindex/doc.go +++ /dev/null @@ -1,29 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -// Package reindex provides the ctx reindex convenience -// command. -// -// It regenerates quick-reference indices for both -// DECISIONS.md and LEARNINGS.md in a single invocation, -// replacing the need to run ctx decision reindex and -// ctx learning reindex separately. This is the -// recommended way to update indices after adding, -// editing, or removing entries. -// -// # What It Does -// -// The reindex command scans each file for numbered -// entries, extracts their one-line summaries, and -// rebuilds the index table at the top of the file. -// Entry numbering and summary text are synchronized -// with the full entries below. -// -// # Subpackages -// -// cmd/root: cobra command definition that invokes -// both decision and learning reindex in sequence -package reindex diff --git a/internal/cli/reindex/reindex_test.go b/internal/cli/reindex/reindex_test.go deleted file mode 100644 index 55d2ebd81..000000000 --- a/internal/cli/reindex/reindex_test.go +++ /dev/null @@ -1,179 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -package reindex - -import ( - "os" - "path/filepath" - "testing" - - "github.com/ActiveMemory/ctx/internal/config/ctx" - "github.com/ActiveMemory/ctx/internal/config/dir" - "github.com/ActiveMemory/ctx/internal/rc" - "github.com/ActiveMemory/ctx/internal/testutil/testctx" -) - -func TestCmd(t *testing.T) { - cmd := Cmd() - - if cmd == nil { - t.Fatal("Cmd() returned nil") - } - - if cmd.Use != "reindex" { - t.Errorf("Cmd().Use = %q, want %q", cmd.Use, "reindex") - } - - if cmd.Short == "" { - t.Error("Cmd().Short is empty") - } - - if cmd.Long == "" { - t.Error("Cmd().Long is empty") - } - - if cmd.RunE == nil { - t.Error("Cmd().RunE is nil") - } -} - -func TestRunReindex_NoFiles(t *testing.T) { - tempDir := t.TempDir() - origDir, _ := os.Getwd() - _ = os.Chdir(tempDir) - defer func() { _ = os.Chdir(origDir) }() - - testctx.Declare(t, tempDir) - defer rc.Reset() - - cmd := Cmd() - - err := cmd.Execute() - if err == nil { - t.Fatal("expected error when context files do not exist") - } -} - -func TestRunReindex_BothFiles(t *testing.T) { - tempDir := t.TempDir() - origDir, _ := os.Getwd() - _ = os.Chdir(tempDir) - defer func() { _ = os.Chdir(origDir) }() - - testctx.Declare(t, tempDir) - defer rc.Reset() - - ctxDir := filepath.Join(tempDir, dir.Context) - _ = os.MkdirAll(ctxDir, 0750) - - decisions := `# Decisions - -## [2026-01-15-143022] Use YAML for config - -**Context:** Need a config format -**Rationale:** YAML is human-readable -**Consequence:** Added yaml dependency -` - _ = os.WriteFile( - filepath.Join(ctxDir, ctx.Decision), - []byte(decisions), 0600, - ) - - learnings := `# Learnings - -## [2026-01-15-150000] Always validate input - -**Context:** Found a bug from invalid input -**Lesson:** Validate at boundaries -**Application:** Add validation to all handlers -` - _ = os.WriteFile( - filepath.Join(ctxDir, ctx.Learning), - []byte(learnings), 0600, - ) - - cmd := Cmd() - - err := cmd.Execute() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - // Verify both files were updated - decPath := filepath.Join(ctxDir, ctx.Decision) - updatedDecisions, readErr := os.ReadFile(decPath) //nolint:gosec // test path - if readErr != nil { - t.Fatalf("failed to read updated DECISIONS.md: %v", readErr) - } - if len(updatedDecisions) == 0 { - t.Error("updated DECISIONS.md is empty") - } - - learnPath := filepath.Join(ctxDir, ctx.Learning) - updatedLearnings, readErr := os.ReadFile(learnPath) //nolint:gosec // test path - if readErr != nil { - t.Fatalf("failed to read updated LEARNINGS.md: %v", readErr) - } - if len(updatedLearnings) == 0 { - t.Error("updated LEARNINGS.md is empty") - } -} - -func TestRunReindex_DecisionsMissingLearningsPresent(t *testing.T) { - tempDir := t.TempDir() - origDir, _ := os.Getwd() - _ = os.Chdir(tempDir) - defer func() { _ = os.Chdir(origDir) }() - - testctx.Declare(t, tempDir) - defer rc.Reset() - - ctxDir := filepath.Join(tempDir, dir.Context) - _ = os.MkdirAll(ctxDir, 0750) - - // Only create LEARNINGS.md, not DECISIONS.md - _ = os.WriteFile( - filepath.Join(ctxDir, ctx.Learning), - []byte("# Learnings\n"), 0600, - ) - - cmd := Cmd() - - err := cmd.Execute() - if err == nil { - t.Fatal("expected error when DECISIONS.md is missing") - } -} - -func TestRunReindex_EmptyFiles(t *testing.T) { - tempDir := t.TempDir() - origDir, _ := os.Getwd() - _ = os.Chdir(tempDir) - defer func() { _ = os.Chdir(origDir) }() - - testctx.Declare(t, tempDir) - defer rc.Reset() - - ctxDir := filepath.Join(tempDir, dir.Context) - _ = os.MkdirAll(ctxDir, 0750) - - _ = os.WriteFile( - filepath.Join(ctxDir, ctx.Decision), - []byte("# Decisions\n"), 0600, - ) - _ = os.WriteFile( - filepath.Join(ctxDir, ctx.Learning), - []byte("# Learnings\n"), 0600, - ) - - cmd := Cmd() - - err := cmd.Execute() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } -} diff --git a/internal/cli/reindex/testmain_test.go b/internal/cli/reindex/testmain_test.go deleted file mode 100644 index cfba8e2f7..000000000 --- a/internal/cli/reindex/testmain_test.go +++ /dev/null @@ -1,19 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -package reindex - -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/system/core/knowledge/knowledge.go b/internal/cli/system/core/knowledge/knowledge.go index e574c6f37..ba6a772cb 100644 --- a/internal/cli/system/core/knowledge/knowledge.go +++ b/internal/cli/system/core/knowledge/knowledge.go @@ -19,7 +19,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/hook" "github.com/ActiveMemory/ctx/internal/config/knowledge" "github.com/ActiveMemory/ctx/internal/config/token" - "github.com/ActiveMemory/ctx/internal/index" + "github.com/ActiveMemory/ctx/internal/heading" "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/notify" "github.com/ActiveMemory/ctx/internal/rc" @@ -45,7 +45,7 @@ func ScanFiles( if decThreshold > 0 { data, readErr := io.SafeReadFile(contextDir, ctx.Decision) if readErr == nil { - count := len(index.ParseEntryBlocks(string(data))) + count := len(heading.ParseEntryBlocks(string(data))) if count > decThreshold { findings = append(findings, finding{ File: ctx.Decision, @@ -60,7 +60,7 @@ func ScanFiles( if lrnThreshold > 0 { data, readErr := io.SafeReadFile(contextDir, ctx.Learning) if readErr == nil { - count := len(index.ParseEntryBlocks(string(data))) + count := len(heading.ParseEntryBlocks(string(data))) if count > lrnThreshold { findings = append(findings, finding{ File: ctx.Learning, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 87b96cf10..840c7c508 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -346,8 +346,6 @@ func TestConstants(t *testing.T) { {"Learning", ctx.Learning, "LEARNINGS.md"}, {"PrefixTaskUndone", marker.PrefixTaskUndone, "- [ ]"}, {"PrefixTaskDone", marker.PrefixTaskDone, "- [x]"}, - {"IndexStart", marker.IndexStart, ""}, - {"IndexEnd", marker.IndexEnd, ""}, } for _, tt := range tests { diff --git a/internal/config/embed/cmd/base.go b/internal/config/embed/cmd/base.go index f383d7c58..09eb2058a 100644 --- a/internal/config/embed/cmd/base.go +++ b/internal/config/embed/cmd/base.go @@ -28,6 +28,8 @@ const ( UseSetup = "setup " // UseInit is the cobra Use string for the init command. UseInit = "init" + // UseIndex is the cobra Use string for the index command. + UseIndex = "index [FILE]" // UseLoad is the cobra Use string for the load command. UseLoad = "load" // UseLoop is the cobra Use string for the loop command. @@ -44,8 +46,6 @@ const ( UsePause = "pause" // UsePermission is the cobra Use string for the permission command. UsePermission = "permission" - // UseReindex is the cobra Use string for the reindex command. - UseReindex = "reindex" // UseRemind is the cobra Use string for the remind command. UseRemind = "remind [TEXT]" // UseResume is the cobra Use string for the resume command. @@ -88,12 +88,12 @@ const ( DescKeySetup = "setup" // DescKeyInitialize is the description key for the initialize command. DescKeyInitialize = "initialize" + // DescKeyIndex is the description key for the index command. + DescKeyIndex = "index" // DescKeyLoad is the description key for the load command. DescKeyLoad = "load" // DescKeyLoop is the description key for the loop command. DescKeyLoop = "loop" - // DescKeyReindex is the description key for the reindex command. - DescKeyReindex = "reindex" // DescKeyServe is the description key for the serve command. DescKeyServe = "serve" // DescKeyStatus is the description key for the status command. diff --git a/internal/config/embed/cmd/decision.go b/internal/config/embed/cmd/decision.go index 59adcdea5..a26ebb9af 100644 --- a/internal/config/embed/cmd/decision.go +++ b/internal/config/embed/cmd/decision.go @@ -18,7 +18,4 @@ const ( DescKeyDecision = "decision" // DescKeyDecisionAdd is the description key for the decision add command. DescKeyDecisionAdd = "decision.add" - // DescKeyDecisionReindex is the description key for the decision reindex - // command. - DescKeyDecisionReindex = "decision.reindex" ) diff --git a/internal/config/embed/cmd/learning.go b/internal/config/embed/cmd/learning.go index c98f02b65..57896f150 100644 --- a/internal/config/embed/cmd/learning.go +++ b/internal/config/embed/cmd/learning.go @@ -18,7 +18,4 @@ const ( DescKeyLearning = "learning" // DescKeyLearningAdd is the description key for the learning add command. DescKeyLearningAdd = "learning.add" - // DescKeyLearningReindex is the description key for the learning reindex - // command. - DescKeyLearningReindex = "learning.reindex" ) diff --git a/internal/config/embed/flag/index.go b/internal/config/embed/flag/index.go new file mode 100644 index 000000000..943fa5276 --- /dev/null +++ b/internal/config/embed/flag/index.go @@ -0,0 +1,14 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package flag + +const ( + // DescKeyIndexDepth is the description key for the index depth flag. + DescKeyIndexDepth = "index.depth" + // DescKeyIndexJSON is the description key for the index json flag. + DescKeyIndexJSON = "index.json" +) diff --git a/internal/config/embed/text/colummn.go b/internal/config/embed/text/colummn.go deleted file mode 100644 index 99748b3a8..000000000 --- a/internal/config/embed/text/colummn.go +++ /dev/null @@ -1,15 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -package text - -// DescKeys for column formatting. -const ( - // DescKeyColumnDecision is the text key for column decision messages. - DescKeyColumnDecision = "column.decision" - // DescKeyColumnLearning is the text key for column learning messages. - DescKeyColumnLearning = "column.learning" -) diff --git a/internal/config/embed/text/drift.go b/internal/config/embed/text/drift.go index c1b841a8c..e80119447 100644 --- a/internal/config/embed/text/drift.go +++ b/internal/config/embed/text/drift.go @@ -14,8 +14,6 @@ const ( DescKeyDriftEntryCount = "drift.entry-count" // DescKeyDriftMissingFile is the text key for drift missing file messages. DescKeyDriftMissingFile = "drift.missing-file" - // DescKeyDriftRegenerated is the text key for drift regenerated messages. - DescKeyDriftRegenerated = "drift.regenerated" // DescKeyDriftMissingPackage is the text key for drift missing package // messages. DescKeyDriftMissingPackage = "drift.missing-package" @@ -25,8 +23,6 @@ const ( DescKeyDriftStaleAge = "drift.stale-age" // DescKeyDriftStaleness is the text key for drift staleness messages. DescKeyDriftStaleness = "drift.staleness" - // DescKeyDriftCleared is the text key for drift cleared messages. - DescKeyDriftCleared = "drift.cleared" // DescKeyDriftApplying is the text key for drift applying messages. DescKeyDriftApplying = "drift.applying" // DescKeyDriftFixedCount is the text key for drift fixed count messages. diff --git a/internal/config/embed/text/err_add.go b/internal/config/embed/text/err_add.go index dffd2f156..ffbe795b7 100644 --- a/internal/config/embed/text/err_add.go +++ b/internal/config/embed/text/err_add.go @@ -11,8 +11,6 @@ const ( // DescKeyErrAddFileNotFound is the text key for err add file not found // messages. DescKeyErrAddFileNotFound = "err.add.file-not-found" - // DescKeyErrAddIndexUpdate is the text key for err add index update messages. - DescKeyErrAddIndexUpdate = "err.add.index-update" // DescKeyErrAddJSONParse is the text key for err add json payload parse // messages. DescKeyErrAddJSONParse = "err.add.json-parse" diff --git a/internal/config/embed/text/err_index.go b/internal/config/embed/text/err_index.go deleted file mode 100644 index b2fb476d6..000000000 --- a/internal/config/embed/text/err_index.go +++ /dev/null @@ -1,17 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -package text - -// DescKeys for index-block guard errors. -const ( - // DescKeyErrIndexEntriesInBlock is the text key for the error raised when - // entry bodies are found between the INDEX:START/INDEX:END markers. - DescKeyErrIndexEntriesInBlock = "err.index.entries-in-block" - // DescKeyErrIndexMalformedMarkers is the text key for the error raised when - // the INDEX:START/INDEX:END markers are missing, duplicated, or out of order. - DescKeyErrIndexMalformedMarkers = "err.index.malformed-markers" -) diff --git a/internal/config/embed/text/err_journal_source.go b/internal/config/embed/text/err_journal_source.go index 3c97d528c..a3191acb7 100644 --- a/internal/config/embed/text/err_journal_source.go +++ b/internal/config/embed/text/err_journal_source.go @@ -14,15 +14,6 @@ const ( // DescKeyErrJournalSourceOpenLogFile is the text key for err journal source // open log file messages. DescKeyErrJournalSourceOpenLogFile = "err.journal.source.open-log-file" - // DescKeyErrJournalSourceReindexFileNotFound is the text key for err journal - // source reindex file not found messages. - DescKeyErrJournalSourceReindexFileNotFound = "err.journal.source.reindex-file-not-found" - // DescKeyErrJournalSourceReindexFileRead is the text key for err journal - // source reindex file read messages. - DescKeyErrJournalSourceReindexFileRead = "err.journal.source.reindex-file-read" - // DescKeyErrJournalSourceReindexFileWrite is the text key for err journal - // source reindex file write messages. - DescKeyErrJournalSourceReindexFileWrite = "err.journal.source.reindex-file-write" // DescKeyErrJournalSourceStatsGlob is the text key for err journal source // stats glob messages. DescKeyErrJournalSourceStatsGlob = "err.journal.source.stats-glob" diff --git a/internal/config/flag/flag.go b/internal/config/flag/flag.go index d5f0b0228..1da6d1195 100644 --- a/internal/config/flag/flag.go +++ b/internal/config/flag/flag.go @@ -67,6 +67,7 @@ const ( Event = "event" IncludeHub = "include-hub" + Depth = "depth" Fix = "fix" Force = "force" Reset = "reset" diff --git a/internal/config/marker/doc.go b/internal/config/marker/doc.go index 9dbb366cb..0fec90857 100644 --- a/internal/config/marker/doc.go +++ b/internal/config/marker/doc.go @@ -41,9 +41,6 @@ // - **ctx:permissions**: the auto-managed allow/ // deny entries in `settings.local.json`-style // comments. -// - **INDEX:START / INDEX:END**: the -// auto-generated index table inside -// DECISIONS.md / LEARNINGS.md. // // # Concurrency // diff --git a/internal/config/marker/index_fmt.go b/internal/config/marker/index_fmt.go deleted file mode 100644 index 9ab30de91..000000000 --- a/internal/config/marker/index_fmt.go +++ /dev/null @@ -1,24 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -package marker - -import "github.com/ActiveMemory/ctx/internal/config/token" - -// Index block format strings for inserting/appending index content. -var ( - // IndexBlockFmt formats an index block between existing content. - // Args: content-before, index-content, content-after. - IndexBlockFmt = "%s" + token.NewlineLF + - IndexStart + token.NewlineLF + - "%s" + IndexEnd + token.NewlineLF + "%s" - - // IndexBlockAppendFmt appends an index block at end of file. - // Args: content, index-content. - IndexBlockAppendFmt = "%s" + token.NewlineLF + token.NewlineLF + - IndexStart + token.NewlineLF + - "%s" + IndexEnd + token.NewlineLF -) diff --git a/internal/config/marker/marker.go b/internal/config/marker/marker.go index d860798a9..5c8f45bab 100644 --- a/internal/config/marker/marker.go +++ b/internal/config/marker/marker.go @@ -81,12 +81,7 @@ const ( ) // Index markers for auto-generated table of contents sections. -const ( - // IndexStart marks the beginning of an auto-generated index. - IndexStart = "" - // IndexEnd marks the end of an auto-generated index. - IndexEnd = "" -) +const () // Task checkbox prefixes for Markdown task lists. const ( @@ -168,10 +163,6 @@ const ( TablePipe = "|" // TablePipeEscaped is an escaped pipe for use inside cells. TablePipeEscaped = "\\|" - // TableRowFmt is the Printf format for a two-column table row. - TableRowFmt = "| %s | %s |" - // TableSepFmt is the Printf format for a two-column separator row. - TableSepFmt = "|%s|%s|" // TableRowOpen is the leading "| " a markdown table row // opens with. TableRowOpen = "| " diff --git a/internal/config/warn/doc.go b/internal/config/warn/doc.go index 2746b6547..5eb6ba6ab 100644 --- a/internal/config/warn/doc.go +++ b/internal/config/warn/doc.go @@ -41,10 +41,8 @@ // // # Context Identifiers // -// - [IndexHeader], [IndexSeparator], [IndexRow], -// [ResponseBody]: labels attached to warnings -// for traceability in index generation and HTTP -// operations. +// - [ResponseBody]: label attached to warnings for +// traceability in HTTP operations. // // # Why Centralized // diff --git a/internal/config/warn/warn.go b/internal/config/warn/warn.go index daa9d9999..3af0ab226 100644 --- a/internal/config/warn/warn.go +++ b/internal/config/warn/warn.go @@ -187,15 +187,8 @@ const ( NotifyWebhookPost = "notify: webhook POST failed: %v" ) -// Warn context identifiers for index generation. +// Warn context identifiers. const ( - // IndexHeader is the context label for index header write errors. - IndexHeader = "index-header" - // IndexSeparator is the context label for index separator write - // errors. - IndexSeparator = "index-separator" - // IndexRow is the context label for index row write errors. - IndexRow = "index-row" // ResponseBody is the context label for HTTP response body // close errors. ResponseBody = "response body" diff --git a/internal/drift/check.go b/internal/drift/check.go index 37fd97136..8a475e395 100644 --- a/internal/drift/check.go +++ b/internal/drift/check.go @@ -26,8 +26,8 @@ import ( "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/heading" "github.com/ActiveMemory/ctx/internal/i18n" - "github.com/ActiveMemory/ctx/internal/index" ctxIo "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/rc" ) @@ -272,7 +272,7 @@ func checkFileAge(ctx *entity.Context, report *Report) { // checkEntryCount warns when LEARNINGS.md or DECISIONS.md // have too many entries. // -// Uses index.ParseEntryBlocks for counting and rc thresholds for limits. +// Uses heading.ParseEntryBlocks for counting and rc thresholds for limits. // A threshold of 0 disables the check for that file. // // Parameters: @@ -296,7 +296,7 @@ func checkEntryCount(ctx *entity.Context, report *Report) { if f == nil { continue } - blocks := index.ParseEntryBlocks(string(f.Content)) + blocks := heading.ParseEntryBlocks(string(f.Content)) if len(blocks) > c.threshold { report.Warnings = append(report.Warnings, Issue{ File: f.Name, diff --git a/internal/entity/heading.go b/internal/entity/heading.go new file mode 100644 index 000000000..0a8196c1e --- /dev/null +++ b/internal/entity/heading.go @@ -0,0 +1,23 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package entity + +// Heading is a single Markdown heading projected from a file by the +// generic heading matcher (see internal/heading.Headings). +// +// Unlike IndexEntry, a Heading carries no timestamp semantics: it is any +// ATX heading (`##`, `###`, …), which is what lets one projector serve +// timestamped entry files (DECISIONS/LEARNINGS) and untimestamped ones +// (TASKS `## Phase …`, CONVENTIONS) alike. +// +// Fields: +// - Level: Number of leading `#` characters (2 for `##`, 3 for `###`). +// - Text: Heading text with the `#` markers and surrounding space removed. +type Heading struct { + Level int `json:"level"` + Text string `json:"text"` +} diff --git a/internal/entry/write.go b/internal/entry/write.go index 3f1364121..538935989 100644 --- a/internal/entry/write.go +++ b/internal/entry/write.go @@ -18,7 +18,6 @@ import ( errAdd "github.com/ActiveMemory/ctx/internal/err/add" errFs "github.com/ActiveMemory/ctx/internal/err/fs" "github.com/ActiveMemory/ctx/internal/i18n" - "github.com/ActiveMemory/ctx/internal/index" "github.com/ActiveMemory/ctx/internal/io" "github.com/ActiveMemory/ctx/internal/rc" ) @@ -26,8 +25,9 @@ import ( // Write formats and writes an entry to the appropriate context file. // // Handles the complete write cycle: read existing content, -// format the entry, -// append it, write back, and update the index if needed. +// format the entry, append it, and write back. No index is +// maintained in the file; a table of contents is projected on +// demand by `ctx index `. // // Parameters: // - params: Params containing type, content, and optional fields @@ -62,14 +62,6 @@ func Write(params entity.EntryParams) error { return errFs.FileRead(filePath, readErr) } - // Decisions and Learnings carry an auto-generated index. Refuse to mutate - // the file when regenerating that index would lose data, before any write. - if fType == entry.Decision || fType == entry.Learning { - if vErr := index.Validate(string(existing), fileName); vErr != nil { - return vErr - } - } - var formatted string switch fType { case entry.Decision: @@ -109,25 +101,6 @@ func Write(params entity.EntryParams) error { return errFs.FileWrite(filePath, writeErr) } - switch fType { - case entry.Decision: - indexed := index.UpdateDecisions(string(newContent)) - 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 := io.SafeWriteFile( - filePath, []byte(indexed), fs.PermFile, - ); indexErr != nil { - return errAdd.IndexUpdate(filePath, indexErr) - } - // case entry.Task, entry.Convention: - // No index to update for these types - } - return nil } diff --git a/internal/entry/write_test.go b/internal/entry/write_test.go index 482d8c9df..161f857b8 100644 --- a/internal/entry/write_test.go +++ b/internal/entry/write_test.go @@ -40,40 +40,16 @@ func learningParams(dir string) entity.EntryParams { } } -// TestWrite_RefusesEntriesTrappedInIndexBlock is the regression guard for the -// data-loss bug: when entry bodies live between the INDEX markers, Write must -// refuse and leave the file byte-identical rather than regenerate the index -// and delete them. -func TestWrite_RefusesEntriesTrappedInIndexBlock(t *testing.T) { - malformed := "# Learnings\n\n\n\n" + - "## [2026-01-01-090000] First\n\n**Lesson:** alpha must survive.\n\n" + - "## [2026-01-02-090000] Second\n\n**Lesson:** beta must survive.\n\n" + - "\n" - dir, path := seedLearnings(t, malformed) - - if err := Write(learningParams(dir)); err == nil { - t.Fatal("Write() must refuse a LEARNINGS.md with entries trapped in the index block") - } - - got, readErr := os.ReadFile(path) //nolint:gosec // path is test-controlled - if readErr != nil { - t.Fatalf("read back: %v", readErr) - } - if string(got) != malformed { - t.Errorf("Write() must not modify a refused file\nGot:\n%s", got) - } -} - -// TestWrite_PreservesBodiesWellFormed confirms the guard does not regress the -// happy path: a well-formed file gains the new entry and keeps prior bodies -// and exactly one marker pair. -func TestWrite_PreservesBodiesWellFormed(t *testing.T) { - wellFormed := "# Learnings\n\n\n\n\n" + +// TestWrite_AppendsWithoutIndexBlock confirms Write appends the new entry, +// preserves prior bodies, and never writes an INDEX block: the index is now +// projected on demand by `ctx index`, not stored in the file. +func TestWrite_AppendsWithoutIndexBlock(t *testing.T) { + seed := "# Learnings\n\n" + "## [2026-01-01-090000] First\n\n**Lesson:** alpha must survive.\n" - dir, path := seedLearnings(t, wellFormed) + dir, path := seedLearnings(t, seed) if err := Write(learningParams(dir)); err != nil { - t.Fatalf("Write() on a well-formed file: %v", err) + t.Fatalf("Write() on a plain file: %v", err) } got, readErr := os.ReadFile(path) //nolint:gosec // path is test-controlled @@ -87,7 +63,7 @@ func TestWrite_PreservesBodiesWellFormed(t *testing.T) { if !strings.Contains(body, "New learning") { t.Errorf("Write() did not add the new entry\nGot:\n%s", body) } - if n := strings.Count(body, ""); n != 1 { - t.Errorf("Write() left %d INDEX:START markers, want 1\nGot:\n%s", n, body) + if strings.Contains(body, "") { + t.Errorf("Write() wrote an INDEX block; none should be created\nGot:\n%s", body) } } diff --git a/internal/err/add/add.go b/internal/err/add/add.go index 61375f587..99729313f 100644 --- a/internal/err/add/add.go +++ b/internal/err/add/add.go @@ -53,20 +53,6 @@ func JSONParse(path string, cause error) error { ) } -// IndexUpdate wraps a failure to update the index in a context file. -// -// Parameters: -// - path: File path where the index update failed -// - cause: Underlying error from the write operation -// -// Returns: -// - error: "failed to update index in : " -func IndexUpdate(path string, cause error) error { - return fmt.Errorf( - desc.Text(text.DescKeyErrAddIndexUpdate), path, cause, - ) -} - // UnknownType returns an error for an unrecognized entry type. // // Parameters: diff --git a/internal/err/add/doc.go b/internal/err/add/doc.go index 6066c4bc6..19bd327e7 100644 --- a/internal/err/add/doc.go +++ b/internal/err/add/doc.go @@ -29,7 +29,6 @@ // - **File operations**: the target context // file does not exist or its index could not // be updated. Constructors: [FileNotFound], -// [IndexUpdate]. // // # Wrapping Strategy // diff --git a/internal/err/index/doc.go b/internal/err/index/doc.go deleted file mode 100644 index 8f0afba76..000000000 --- a/internal/err/index/doc.go +++ /dev/null @@ -1,40 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -// Package index defines the typed error constructors -// returned by the index-block guard -// ([internal/index.Validate]). -// -// # Domain -// -// Every context file that carries an auto-generated -// index (DECISIONS.md, LEARNINGS.md) brackets that -// index between INDEX:START / INDEX:END markers. The -// index regenerator replaces the span between those -// markers wholesale. Before it runs, the guard checks -// that doing so is safe and refuses otherwise. Two -// refusals can occur: -// -// - **Entries in block**: real entry bodies live -// between the markers; regenerating would delete -// them. Constructor: [EntriesInBlock]. -// - **Malformed markers**: the marker pair is -// missing, duplicated, or out of order, so a -// regenerate would emit a second marker. -// Constructor: [MalformedMarkers]. -// -// # Wrapping Strategy -// -// Both constructors return plain formatted errors; -// there is no underlying cause to wrap. The file name -// is interpolated so the message names the offending -// file. All user-facing text resolves through -// [internal/assets/read/desc] at construction time. -// -// # Concurrency -// -// Pure constructors. Concurrent callers never race. -package index diff --git a/internal/err/index/index.go b/internal/err/index/index.go deleted file mode 100644 index e5fbdd505..000000000 --- a/internal/err/index/index.go +++ /dev/null @@ -1,44 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -package index - -import ( - "fmt" - - "github.com/ActiveMemory/ctx/internal/assets/read/desc" - "github.com/ActiveMemory/ctx/internal/config/embed/text" -) - -// EntriesInBlock returns an error when entry bodies are found between the -// INDEX:START and INDEX:END markers, where regenerating the index would -// delete them. -// -// Parameters: -// - fileName: Display name of the offending file (e.g., "LEARNINGS.md") -// -// Returns: -// - error: A refusal explaining the data-loss risk and the manual fix -func EntriesInBlock(fileName string) error { - return fmt.Errorf( - desc.Text(text.DescKeyErrIndexEntriesInBlock), fileName, - ) -} - -// MalformedMarkers returns an error when the INDEX:START/INDEX:END markers are -// missing, duplicated, or out of order, where regenerating the index would -// emit a second marker. -// -// Parameters: -// - fileName: Display name of the offending file (e.g., "LEARNINGS.md") -// -// Returns: -// - error: A refusal explaining the marker problem and the manual fix -func MalformedMarkers(fileName string) error { - return fmt.Errorf( - desc.Text(text.DescKeyErrIndexMalformedMarkers), fileName, - ) -} diff --git a/internal/err/journal/source.go b/internal/err/journal/source.go index 3e72d9bdc..23958f067 100644 --- a/internal/err/journal/source.go +++ b/internal/err/journal/source.go @@ -39,49 +39,6 @@ func StatsGlob(cause error) error { ) } -// ReindexFileNotFound returns an error when the file to reindex does not exist. -// -// Parameters: -// - fileName: Display name (e.g., "DECISIONS.md") -// -// Returns: -// - error: " not found. Run 'ctx init' first" -func ReindexFileNotFound(fileName string) error { - return fmt.Errorf( - desc.Text(text.DescKeyErrJournalSourceReindexFileNotFound), fileName, - ) -} - -// ReindexFileRead wraps a read failure during reindexing. -// -// Parameters: -// - filePath: Path that could not be read -// - cause: The underlying read error -// -// Returns: -// - error: "failed to read : " -func ReindexFileRead(filePath string, cause error) error { - return fmt.Errorf( - desc.Text(text.DescKeyErrJournalSourceReindexFileRead), - filePath, cause, - ) -} - -// ReindexFileWrite wraps a write failure during reindexing. -// -// Parameters: -// - filePath: Path that could not be written -// - cause: The underlying write error -// -// Returns: -// - error: "failed to write : " -func ReindexFileWrite(filePath string, cause error) error { - return fmt.Errorf( - desc.Text(text.DescKeyErrJournalSourceReindexFileWrite), - filePath, cause, - ) -} - // OpenLogFile wraps a failure to open a log file. // // Parameters: diff --git a/internal/heading/doc.go b/internal/heading/doc.go new file mode 100644 index 000000000..877f824fe --- /dev/null +++ b/internal/heading/doc.go @@ -0,0 +1,40 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package heading parses Markdown headings out of ctx knowledge files. +// +// It is read-only: the package extracts structure, it never rewrites a +// file. A table of contents is projected on demand by `ctx index ` +// (and by `ctx agent`), never stored in the file as an index block. +// +// # Two recognizers +// +// [ParseHeaders] and [ParseEntryBlocks] are entry-specific: they match the +// timestamped shape +// +// ## [YYYY-MM-DD-HHMMSS] Title text here +// +// used by DECISIONS.md / LEARNINGS.md. [ParseHeaders] returns the date + +// title pair per entry; [ParseEntryBlocks] returns full block metadata +// (start/end line, header, body) so callers — notably `ctx agent` scoring — +// can grep, render, or score individual entries. +// +// [Headings] is generic: it projects any ATX heading (`##`, `###`, …) up to +// a caller-supplied depth, code-fence-aware. This is what lets one projector +// serve timestamped entry files and untimestamped ones alike (TASKS.md +// `## Phase …`, CONVENTIONS.md), and it backs the `ctx index` command. +// +// # Supersession +// +// An entry can be marked superseded by a later one (a body line starting +// with the strikethrough `~~Superseded` prefix). [EntryBlock.IsSuperseded] +// reports this so renderers can gray-out or sort accordingly. +// +// # Concurrency +// +// Pure data: the package reads content strings and returns values. Callers +// own all file IO. +package heading diff --git a/internal/index/entry.go b/internal/heading/entry.go similarity index 99% rename from internal/index/entry.go rename to internal/heading/entry.go index 0a22104f0..f2a507440 100644 --- a/internal/index/entry.go +++ b/internal/heading/entry.go @@ -4,7 +4,7 @@ // \ Copyright 2026-present Context contributors. // SPDX-License-Identifier: Apache-2.0 -package index +package heading import ( "strings" diff --git a/internal/index/entry_test.go b/internal/heading/entry_test.go similarity index 99% rename from internal/index/entry_test.go rename to internal/heading/entry_test.go index d525a55f6..832dc3c71 100644 --- a/internal/index/entry_test.go +++ b/internal/heading/entry_test.go @@ -4,7 +4,7 @@ // \ Copyright 2026-present Context contributors. // SPDX-License-Identifier: Apache-2.0 -package index +package heading import ( "strings" diff --git a/internal/heading/headings.go b/internal/heading/headings.go new file mode 100644 index 000000000..3e09f66dc --- /dev/null +++ b/internal/heading/headings.go @@ -0,0 +1,93 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package heading + +import ( + "strings" + + "github.com/ActiveMemory/ctx/internal/config/marker" + "github.com/ActiveMemory/ctx/internal/config/regex" + "github.com/ActiveMemory/ctx/internal/config/token" + "github.com/ActiveMemory/ctx/internal/entity" +) + +// minHeadingLevel is the shallowest heading the projector emits. Level 1 +// (`# Title`) is the file's own H1 and is intentionally excluded; entries and +// sections start at `##`. +const minHeadingLevel = 2 + +// Headings projects the ATX Markdown headings out of content, in file order. +// +// It is the generic counterpart to ParseHeaders: where ParseHeaders matches +// only timestamped entry headers (`## [YYYY-MM-DD-HHMMSS] Title`), Headings +// matches any `##`/`###`/… heading up to maxDepth. This is what lets one +// projector serve DECISIONS/LEARNINGS, CONVENTIONS, and TASKS (`## Phase …`) +// with the same code path. +// +// Fenced code blocks are skipped: a line like "## not a heading" inside a +// ```` ``` ```` fence is code text, not a heading, and is never projected. +// +// Parameters: +// - content: Full file content. +// - maxDepth: Deepest heading level to include (2 = `##` only; 3 adds +// `###`; a value below 2 is treated as 2, the shallowest entry level). +// +// Returns: +// - []entity.Heading: Projected headings in file order (may be empty). +func Headings(content string, maxDepth int) []entity.Heading { + if maxDepth < minHeadingLevel { + maxDepth = minHeadingLevel + } + + var out []entity.Heading + inFence := false + inComment := false + + for _, line := range strings.Split(content, token.NewlineLF) { + // Skip content inside a multi-line HTML comment (e.g. the + // `` legend), which can contain + // example `##` headings that are documentation, not entries. + if inComment { + if strings.Contains(line, marker.CommentClose) { + inComment = false + } + continue + } + // A fence marker line toggles fenced state and is never a heading. + if regex.CodeFenceLine.MatchString(line) { + inFence = !inFence + continue + } + if inFence { + continue + } + // A line that opens an HTML comment is not a heading. If the comment + // also closes on the same line it is inline (skip only this line); + // otherwise a multi-line comment block begins. + if idx := strings.Index(line, marker.CommentOpen); idx >= 0 { + if !strings.Contains(line[idx+len(marker.CommentOpen):], marker.CommentClose) { + inComment = true + } + continue + } + + m := regex.MarkdownHeading.FindStringSubmatch(line) + if m == nil { + continue + } + level := len(m[1]) + if level < minHeadingLevel || level > maxDepth { + continue + } + out = append(out, entity.Heading{ + Level: level, + Text: strings.TrimSpace(m[2]), + }) + } + + return out +} diff --git a/internal/heading/headings_test.go b/internal/heading/headings_test.go new file mode 100644 index 000000000..9716cd81c --- /dev/null +++ b/internal/heading/headings_test.go @@ -0,0 +1,121 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package heading + +import ( + "slices" + "testing" + + "github.com/ActiveMemory/ctx/internal/entity" +) + +func texts(hs []entity.Heading) []string { + out := make([]string, len(hs)) + for i, h := range hs { + out[i] = h.Text + } + return out +} + +func eq(a, b []string) bool { + return slices.Equal(a, b) +} + +func TestHeadings_DefaultLevelTwoOnly(t *testing.T) { + content := "# Title\n\n## Alpha\n\ntext\n\n### Sub of Alpha\n\n## Beta\n" + got := texts(Headings(content, 2)) + want := []string{"Alpha", "Beta"} + if !eq(got, want) { + t.Fatalf("depth 2: got %v want %v", got, want) + } +} + +func TestHeadings_DepthThreeIncludesLevelThree(t *testing.T) { + content := "# Title\n\n## Alpha\n\n### Sub of Alpha\n\n## Beta\n" + got := texts(Headings(content, 3)) + want := []string{"Alpha", "Sub of Alpha", "Beta"} + if !eq(got, want) { + t.Fatalf("depth 3: got %v want %v", got, want) + } +} + +func TestHeadings_UntimestampedPhasesMatch(t *testing.T) { + // TASKS.md-style headings carry no timestamp; the generic matcher must + // still project them (unlike ParseHeaders). + content := "# Tasks\n\n## Phase 1: Foundation\n\n- [ ] do a thing\n\n## Phase 2: Ship\n" + got := texts(Headings(content, 2)) + want := []string{"Phase 1: Foundation", "Phase 2: Ship"} + if !eq(got, want) { + t.Fatalf("phases: got %v want %v", got, want) + } +} + +func TestHeadings_FencedHeadingIgnored(t *testing.T) { + content := "## Real\n\n```\n## not a heading\n```\n\n## Also Real\n" + got := texts(Headings(content, 3)) + want := []string{"Real", "Also Real"} + if !eq(got, want) { + t.Fatalf("fenced: got %v want %v", got, want) + } +} + +func TestHeadings_HTMLCommentHeadingsIgnored(t *testing.T) { + // The `` legend embeds example ## headings + // that are documentation, not entries; they must not be projected. + content := "# Decisions\n\n\n\n## [2026-07-06-120000] Real Decision\n" + got := texts(Headings(content, 3)) + want := []string{"[2026-07-06-120000] Real Decision"} + if !eq(got, want) { + t.Fatalf("html comment: got %v want %v", got, want) + } +} + +func TestHeadings_InlineCommentNotABlock(t *testing.T) { + // A self-contained inline comment must not swallow following headings. + content := "\n\n## Real\n" + got := texts(Headings(content, 2)) + want := []string{"Real"} + if !eq(got, want) { + t.Fatalf("inline comment: got %v want %v", got, want) + } +} + +func TestHeadings_EmptyAndNoHeading(t *testing.T) { + if got := Headings("", 2); len(got) != 0 { + t.Fatalf("empty: got %v want none", got) + } + if got := Headings("just prose\nno headings here\n", 2); len(got) != 0 { + t.Fatalf("no-heading: got %v want none", got) + } +} + +func TestHeadings_StaleIndexBlockIgnored(t *testing.T) { + // A residual INDEX comment block is not a heading and must not appear. + content := "# Decisions\n\n\n| Date | Decision |\n|----|--------|\n| 2026-07-06 | Something |\n\n\n## [2026-07-06-120000] Something\n" + got := texts(Headings(content, 2)) + want := []string{"[2026-07-06-120000] Something"} + if !eq(got, want) { + t.Fatalf("stale block: got %v want %v", got, want) + } +} + +func TestHeadings_DepthBelowTwoClampsToTwo(t *testing.T) { + content := "# Title\n\n## Alpha\n" + got := texts(Headings(content, 0)) + want := []string{"Alpha"} + if !eq(got, want) { + t.Fatalf("clamp: got %v want %v", got, want) + } +} + +func TestHeadings_LevelRecorded(t *testing.T) { + content := "## Alpha\n### Beta\n" + got := Headings(content, 3) + if len(got) != 2 || got[0].Level != 2 || got[1].Level != 3 { + t.Fatalf("levels: got %+v", got) + } +} diff --git a/internal/heading/index.go b/internal/heading/index.go new file mode 100644 index 000000000..696667503 --- /dev/null +++ b/internal/heading/index.go @@ -0,0 +1,45 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package heading + +import ( + "github.com/ActiveMemory/ctx/internal/config/regex" + "github.com/ActiveMemory/ctx/internal/config/token" + "github.com/ActiveMemory/ctx/internal/entity" +) + +// ParseHeaders extracts all timestamped entry headers from file content. +// +// It scans for headers matching the pattern "## [YYYY-MM-DD-HHMMSS] Title" +// and returns them in the order they appear in the file. Unlike Headings, +// which projects any ATX heading, ParseHeaders is entry-specific: it is the +// recognizer that ctx agent uses to enumerate DECISIONS/LEARNINGS entries. +// +// Parameters: +// - content: The full content of a context file +// +// Returns: +// - []entity.IndexEntry: Slice of parsed entries (it may be empty) +func ParseHeaders(content string) []entity.IndexEntry { + var entries []entity.IndexEntry + + matches := regex.EntryHeader.FindAllStringSubmatch(content, -1) + for _, match := range matches { + if len(match) == regex.EntryHeaderGroups { + date := match[1] + time := match[2] + title := match[3] + entries = append(entries, entity.IndexEntry{ + Timestamp: date + token.Dash + time, + Date: date, + Title: title, + }) + } + } + + return entries +} diff --git a/internal/heading/index_test.go b/internal/heading/index_test.go new file mode 100644 index 000000000..3b32cc904 --- /dev/null +++ b/internal/heading/index_test.go @@ -0,0 +1,125 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package heading + +import ( + "testing" + + "github.com/ActiveMemory/ctx/internal/entity" +) + +func TestParseHeaders(t *testing.T) { + tests := []struct { + name string + content string + expected []entity.IndexEntry + }{ + { + name: "empty content", + content: "", + expected: nil, + }, + { + name: "no entries", + content: "# Decisions\n\nSome text here.", + expected: nil, + }, + { + name: "single entry", + content: `# Decisions + +## [2026-01-28-051426] No custom UI - IDE is the interface + +**Status**: Accepted +`, + expected: []entity.IndexEntry{ + { + Timestamp: "2026-01-28-051426", + Date: "2026-01-28", + Title: "No custom UI - IDE is the interface", + }, + }, + }, + { + name: "multiple entries", + content: `# Decisions + +## [2026-01-28-051426] First decision + +**Status**: Accepted + +--- + +## [2026-01-27-123456] Second decision + +**Status**: Accepted +`, + expected: []entity.IndexEntry{ + { + Timestamp: "2026-01-28-051426", + Date: "2026-01-28", + Title: "First decision", + }, + { + Timestamp: "2026-01-27-123456", + Date: "2026-01-27", + Title: "Second decision", + }, + }, + }, + { + name: "entry with special characters", + content: `# Decisions + +## [2026-01-28-051426] Use tool-agnostic Session type | with pipe + +**Status**: Accepted +`, + expected: []entity.IndexEntry{ + { + Timestamp: "2026-01-28-051426", + Date: "2026-01-28", + Title: "Use tool-agnostic Session type | with pipe", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseHeaders(tt.content) + if len(got) != len(tt.expected) { + t.Errorf( + "ParseHeaders() got %d entries, want %d", + len(got), len(tt.expected), + ) + return + } + for i, entry := range got { + if entry.Timestamp != tt.expected[i].Timestamp { + t.Errorf( + "entry[%d].Timestamp = %q, want %q", + i, entry.Timestamp, + tt.expected[i].Timestamp, + ) + } + if entry.Date != tt.expected[i].Date { + t.Errorf( + "entry[%d].Date = %q, want %q", + i, entry.Date, tt.expected[i].Date, + ) + } + if entry.Title != tt.expected[i].Title { + t.Errorf( + "entry[%d].Title = %q, want %q", + i, entry.Title, tt.expected[i].Title, + ) + } + } + }) + } +} diff --git a/internal/index/testmain_test.go b/internal/heading/testmain_test.go similarity index 95% rename from internal/index/testmain_test.go rename to internal/heading/testmain_test.go index b4726c382..0aa80a76a 100644 --- a/internal/index/testmain_test.go +++ b/internal/heading/testmain_test.go @@ -4,7 +4,7 @@ // \\ Copyright 2026-present Context contributors. // SPDX-License-Identifier: Apache-2.0 -package index +package heading import ( "os" diff --git a/internal/index/types.go b/internal/heading/types.go similarity index 97% rename from internal/index/types.go rename to internal/heading/types.go index f1116bc43..4059af796 100644 --- a/internal/index/types.go +++ b/internal/heading/types.go @@ -4,7 +4,7 @@ // \ Copyright 2026-present Context contributors. // SPDX-License-Identifier: Apache-2.0 -package index +package heading import "github.com/ActiveMemory/ctx/internal/entity" diff --git a/internal/index/doc.go b/internal/index/doc.go deleted file mode 100644 index 44ef09ba9..000000000 --- a/internal/index/doc.go +++ /dev/null @@ -1,63 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -// Package index generates and maintains the **quick-reference -// index tables** at the top of `DECISIONS.md` and -// `LEARNINGS.md`: the markdown tables wrapped in -// `` / `` markers that -// list every entry by ID, date, and title in chronological -// order. -// -// The index is the affordance that lets `ctx agent` send a -// **token-cheap** version of `DECISIONS.md` / `LEARNINGS.md` -// to the AI: instead of injecting the full prose for hundreds -// of entries, it injects only the index table. The agent -// scans the table, decides which entries it needs, and asks -// for those by ID. -// -// # The Index Format -// -// Each index row mirrors one entry block in the source file: -// -// | ID | Date | Title | -// |----|------|-------| -// | L-43 | 2026-04-12 | Lock acquisition order in fanout | -// -// Entry blocks in the source follow a strict shape: -// -// ## [YYYY-MM-DD-HHMMSS] Title text here -// -// [ParseHeaders] extracts the date + title pair from each -// `## [...]` header. [ParseEntryBlocks] returns full block -// metadata (start/end line, ID, date, title, body) so -// callers can grep, render, or rewrite individual entries. -// -// # Updating in Place -// -// [GenerateTable] turns a parsed entry list into the full -// markdown index (table header + rows). -// [Update](path, newTable) finds the marker pair in the -// existing file and replaces only the content between them, -// leaving the rest of the file untouched. If the markers are -// missing, [Update] inserts them under the H1 heading so the -// next run becomes idempotent. [UpdateDecisions] and the -// matching [UpdateLearnings] are convenience wrappers that -// know the canonical file paths. -// -// # Supersession -// -// An entry can be marked **superseded** by a later one -// (a body line starting with `**Status**: Superseded by -// L-99`). The parser tags such entries so renderers can -// gray-out / sort the index accordingly. -// -// # Concurrency -// -// The package is filesystem-IO at the boundary, pure data -// in the middle. Callers serialize updates externally -// (typically by holding the `.context/` directory -// implicitly through process-level execution). -package index diff --git a/internal/index/index.go b/internal/index/index.go deleted file mode 100644 index 925868279..000000000 --- a/internal/index/index.go +++ /dev/null @@ -1,310 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -package index - -import ( - "fmt" - "io" - "os" - "strings" - - "github.com/ActiveMemory/ctx/internal/assets/read/desc" - "github.com/ActiveMemory/ctx/internal/config/embed/text" - "github.com/ActiveMemory/ctx/internal/config/fs" - "github.com/ActiveMemory/ctx/internal/config/marker" - "github.com/ActiveMemory/ctx/internal/config/regex" - "github.com/ActiveMemory/ctx/internal/config/token" - cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" - "github.com/ActiveMemory/ctx/internal/entity" - errIndex "github.com/ActiveMemory/ctx/internal/err/index" - errJournal "github.com/ActiveMemory/ctx/internal/err/journal" - internalIo "github.com/ActiveMemory/ctx/internal/io" - logWarn "github.com/ActiveMemory/ctx/internal/log/warn" - writeDrift "github.com/ActiveMemory/ctx/internal/write/drift" -) - -// ParseHeaders extracts all entries from file content. -// -// It scans for headers matching the pattern "## [YYYY-MM-DD-HHMMSS] Title" -// and returns them in the order they appear in the file. -// -// Parameters: -// - content: The full content of a context file -// -// Returns: -// - []entity.IndexEntry: Slice of parsed entries (it may be empty) -func ParseHeaders(content string) []entity.IndexEntry { - var entries []entity.IndexEntry - - matches := regex.EntryHeader.FindAllStringSubmatch(content, -1) - for _, match := range matches { - if len(match) == regex.EntryHeaderGroups { - date := match[1] - time := match[2] - title := match[3] - entries = append(entries, entity.IndexEntry{ - Timestamp: date + token.Dash + time, - Date: date, - Title: title, - }) - } - } - - return entries -} - -// Validate reports whether the index in content can be safely regenerated. -// -// Update replaces the entire span between INDEX:START and INDEX:END with a -// freshly generated table. That is only safe when the span holds nothing but -// a prior index and the markers form exactly one well-ordered pair. Validate -// is the precondition that callers run before any write, so a malformed file -// fails loud and untouched instead of losing data. -// -// It refuses two shapes: -// - entry bodies (## [timestamp] headers) between the markers, which a -// regenerate would delete (errIndex.EntriesInBlock) -// - markers that are duplicated, missing one side, or out of order, which a -// regenerate would answer with a second marker (errIndex.MalformedMarkers) -// -// A file with no markers at all is permitted: Update's insert path creates a -// fresh index without disturbing existing content. -// -// Parameters: -// - content: The full content of a context file -// - fileName: Display name for the error message (e.g., "LEARNINGS.md") -// -// Returns: -// - error: Non-nil when regenerating the index would lose data or duplicate -// a marker; nil when regeneration is safe -func Validate(content, fileName string) error { - startCount := strings.Count(content, marker.IndexStart) - endCount := strings.Count(content, marker.IndexEnd) - - // No markers: legitimate fresh-index creation. Update's insert path adds - // a block without disturbing existing content. - if startCount == 0 && endCount == 0 { - return nil - } - - // Exactly one well-ordered pair is the only other safe shape. Any other - // count is a duplicate or a missing side; either would have Update emit a - // second marker. - if startCount != 1 || endCount != 1 { - return errIndex.MalformedMarkers(fileName) - } - - startIdx := strings.Index(content, marker.IndexStart) - endIdx := strings.Index(content, marker.IndexEnd) - if endIdx <= startIdx { - return errIndex.MalformedMarkers(fileName) - } - - span := content[startIdx+len(marker.IndexStart) : endIdx] - if regex.EntryHeading.MatchString(span) { - return errIndex.EntriesInBlock(fileName) - } - - return nil -} - -// GenerateTable creates a Markdown table index from entries. -// -// The table has two columns: Date and the specified column header. -// If there are no entries, returns an empty string. -// -// Parameters: -// - entries: Slice of entries to include -// - columnHeader: Header for the second column (e.g., "Decision", "Learning") -// -// Returns: -// - string: Markdown table (without markers) or empty string -func GenerateTable(entries []entity.IndexEntry, columnHeader string) string { - if len(entries) == 0 { - return "" - } - - var sb strings.Builder - if _, writeErr := fmt.Fprintf(&sb, marker.TableRowFmt+token.NewlineLF, - desc.Text(text.DescKeyLabelColDate), columnHeader); writeErr != nil { - logWarn.Warn(cfgWarn.Write, cfgWarn.IndexHeader, writeErr) - } - if _, writeErr := fmt.Fprintf(&sb, marker.TableSepFmt+token.NewlineLF, - strings.Repeat(token.Dash, len(desc.Text(text.DescKeyLabelColDate))), - strings.Repeat(token.Dash, len(columnHeader))); writeErr != nil { - logWarn.Warn(cfgWarn.Write, cfgWarn.IndexSeparator, writeErr) - } - - for _, e := range entries { - title := strings.ReplaceAll( - e.Title, marker.TablePipe, marker.TablePipeEscaped, - ) - if _, writeErr := fmt.Fprintf(&sb, marker.TableRowFmt+token.NewlineLF, - e.Date, title); writeErr != nil { - logWarn.Warn(cfgWarn.Write, cfgWarn.IndexRow, writeErr) - } - } - - return sb.String() -} - -// Update regenerates the index in file content. -// -// If INDEX:START and INDEX:END markers exist, the content between them -// is replaced. Otherwise, the index is inserted after the specified header. -// If there are no entries, any existing index is removed. -// -// Parameters: -// - content: The full content of the file -// - fileHeader: The main header to insert after (e.g., "# Decisions") -// - columnHeader: Header for the table column (e.g., "Decision") -// -// Returns: -// - string: Updated content with regenerated index -func Update(content, fileHeader, columnHeader string) string { - entries := ParseHeaders(content) - indexContent := GenerateTable(entries, columnHeader) - nl := token.NewlineLF - - // Check if markers already exist - startIdx := strings.Index(content, marker.IndexStart) - endIdx := strings.Index(content, marker.IndexEnd) - - if startIdx != -1 && endIdx != -1 && endIdx > startIdx { - // Replace the existing index - if indexContent == "" { - // No entries - remove index entirely (including markers - // and surrounding whitespace) - before := strings.TrimRight(content[:startIdx], nl) - after := content[endIdx+len(marker.IndexEnd):] - after = strings.TrimLeft(after, nl) - if after != "" { - return before + nl + nl + after - } - return before + nl - } - // Replace content between markers - before := content[:startIdx+len(marker.IndexStart)] - after := content[endIdx:] - return before + nl + indexContent + after - } - - // No existing markers - insert after file header - if indexContent == "" { - // No entries, nothing to insert - return content - } - - headerIdx := strings.Index(content, fileHeader) - if headerIdx == -1 { - // No header found, return unchanged - return content - } - - // Find end of header line - lineEnd := strings.Index(content[headerIdx:], nl) - if lineEnd == -1 { - // Header is at the end of the file - return fmt.Sprintf(marker.IndexBlockAppendFmt, - content, indexContent) - } - - insertPoint := headerIdx + lineEnd + 1 - - // Build new content with the index - return fmt.Sprintf(marker.IndexBlockFmt, - content[:insertPoint], indexContent, content[insertPoint:]) -} - -// UpdateDecisions regenerates the decision index in DECISIONS.md content. -// -// Parameters: -// - content: The full content of DECISIONS.md -// -// Returns: -// - string: Updated content with regenerated index -func UpdateDecisions(content string) string { - return Update( - content, - desc.Text(text.DescKeyHeadingDecisions), - desc.Text(text.DescKeyColumnDecision), - ) -} - -// UpdateLearnings regenerates the learning index in LEARNINGS.md content. -// -// Parameters: -// - content: The full content of LEARNINGS.md -// -// Returns: -// - string: Updated content with regenerated index -func UpdateLearnings(content string) string { - return Update( - content, - desc.Text(text.DescKeyHeadingLearnings), - desc.Text(text.DescKeyColumnLearning), - ) -} - -// Reindex reads a context file, regenerates its index, and writes it back. -// -// This is a convenience function that handles the common reindex workflow: -// check the file exists, read content, apply update function, write back, -// report. -// -// Note: This function uses io.Writer instead of *cobra.Command to keep the -// index package decoupled from CLI concerns. Callers pass cmd.OutOrStdout() -// which writes to the same destination as cmd.Printf. -// -// Parameters: -// - w: Writer for status output (typically cmd.OutOrStdout()) -// - filePath: Full path to the context file -// - fileName: Display name for error messages (e.g., "DECISIONS.md") -// - updateFunc: Function to regenerate the index (e.g., UpdateDecisions) -// - entryType: Entity noun for the status message (e.g., "decision") -// -// Returns: -// - error: Non-nil if file operations fail -func Reindex( - w io.Writer, filePath, fileName string, - updateFunc func(string) string, - entryType string, -) error { - if _, statErr := os.Stat(filePath); os.IsNotExist(statErr) { - return errJournal.ReindexFileNotFound(fileName) - } - - content, readErr := internalIo.SafeReadUserFile(filePath) - if readErr != nil { - return errJournal.ReindexFileRead(filePath, readErr) - } - - if vErr := Validate(string(content), fileName); vErr != nil { - return vErr - } - - updated := updateFunc(string(content)) - - if writeErr := internalIo.SafeWriteFile( - filePath, []byte(updated), fs.PermFile, - ); writeErr != nil { - return errJournal.ReindexFileWrite(filePath, writeErr) - } - - entries := ParseHeaders(string(content)) - if len(entries) == 0 { - if printErr := writeDrift.IndexCleared(w, entryType); printErr != nil { - return printErr - } - } else { - if printErr := writeDrift.IndexRegenerated(w, len(entries)); printErr != nil { - return printErr - } - } - - return nil -} diff --git a/internal/index/index_test.go b/internal/index/index_test.go deleted file mode 100644 index d9478e6a0..000000000 --- a/internal/index/index_test.go +++ /dev/null @@ -1,560 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -package index - -import ( - "strings" - "testing" - - "github.com/ActiveMemory/ctx/internal/config/marker" - "github.com/ActiveMemory/ctx/internal/entity" -) - -func TestParseHeaders(t *testing.T) { - tests := []struct { - name string - content string - expected []entity.IndexEntry - }{ - { - name: "empty content", - content: "", - expected: nil, - }, - { - name: "no entries", - content: "# Decisions\n\nSome text here.", - expected: nil, - }, - { - name: "single entry", - content: `# Decisions - -## [2026-01-28-051426] No custom UI - IDE is the interface - -**Status**: Accepted -`, - expected: []entity.IndexEntry{ - { - Timestamp: "2026-01-28-051426", - Date: "2026-01-28", - Title: "No custom UI - IDE is the interface", - }, - }, - }, - { - name: "multiple entries", - content: `# Decisions - -## [2026-01-28-051426] First decision - -**Status**: Accepted - ---- - -## [2026-01-27-123456] Second decision - -**Status**: Accepted -`, - expected: []entity.IndexEntry{ - { - Timestamp: "2026-01-28-051426", - Date: "2026-01-28", - Title: "First decision", - }, - { - Timestamp: "2026-01-27-123456", - Date: "2026-01-27", - Title: "Second decision", - }, - }, - }, - { - name: "entry with special characters", - content: `# Decisions - -## [2026-01-28-051426] Use tool-agnostic Session type | with pipe - -**Status**: Accepted -`, - expected: []entity.IndexEntry{ - { - Timestamp: "2026-01-28-051426", - Date: "2026-01-28", - Title: "Use tool-agnostic Session type | with pipe", - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ParseHeaders(tt.content) - if len(got) != len(tt.expected) { - t.Errorf( - "ParseHeaders() got %d entries, want %d", - len(got), len(tt.expected), - ) - return - } - for i, entry := range got { - if entry.Timestamp != tt.expected[i].Timestamp { - t.Errorf( - "entry[%d].Timestamp = %q, want %q", - i, entry.Timestamp, - tt.expected[i].Timestamp, - ) - } - if entry.Date != tt.expected[i].Date { - t.Errorf( - "entry[%d].Date = %q, want %q", - i, entry.Date, tt.expected[i].Date, - ) - } - if entry.Title != tt.expected[i].Title { - t.Errorf( - "entry[%d].Title = %q, want %q", - i, entry.Title, tt.expected[i].Title, - ) - } - } - }) - } -} - -func TestGenerateTable(t *testing.T) { - tests := []struct { - name string - entries []entity.IndexEntry - columnHeader string - expected string - }{ - { - name: "empty entries", - entries: nil, - columnHeader: "Decision", - expected: "", - }, - { - name: "empty slice", - entries: []entity.IndexEntry{}, - columnHeader: "Decision", - expected: "", - }, - { - name: "single entry", - entries: []entity.IndexEntry{ - { - Timestamp: "2026-01-28-051426", - Date: "2026-01-28", - Title: "First decision", - }, - }, - columnHeader: "Decision", - expected: `| Date | Decision | -|----|--------| -| 2026-01-28 | First decision | -`, - }, - { - name: "multiple entries", - entries: []entity.IndexEntry{ - {Timestamp: "2026-01-28-051426", Date: "2026-01-28", Title: "First"}, - {Timestamp: "2026-01-27-123456", Date: "2026-01-27", Title: "Second"}, - }, - columnHeader: "Decision", - expected: `| Date | Decision | -|----|--------| -| 2026-01-28 | First | -| 2026-01-27 | Second | -`, - }, - { - name: "entry with pipe character", - entries: []entity.IndexEntry{ - { - Timestamp: "2026-01-28-051426", - Date: "2026-01-28", - Title: "Use A | B format", - }, - }, - columnHeader: "Decision", - expected: `| Date | Decision | -|----|--------| -| 2026-01-28 | Use A \| B format | -`, - }, - { - name: "learning column header", - entries: []entity.IndexEntry{ - {Timestamp: "2026-01-28-051426", Date: "2026-01-28", Title: "Test entry"}, - }, - columnHeader: "Learning", - expected: `| Date | Learning | -|----|--------| -| 2026-01-28 | Test entry | -`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := GenerateTable(tt.entries, tt.columnHeader) - if got != tt.expected { - t.Errorf("GenerateTable() =\n%q\nwant\n%q", got, tt.expected) - } - }) - } -} - -func TestUpdateDecisions(t *testing.T) { - tests := []struct { - name string - content string - wantHas []string - wantNot []string - }{ - { - name: "empty file with header", - content: "# Decisions\n", - wantNot: []string{marker.IndexStart, marker.IndexEnd}, - }, - { - name: "file with one decision", - content: `# Decisions - -## [2026-01-28-051426] Test decision - -**Status**: Accepted -`, - wantHas: []string{ - marker.IndexStart, - marker.IndexEnd, - "| Date | Decision |", - "| 2026-01-28 | Test decision |", - "## [2026-01-28-051426] Test decision", - }, - }, - { - name: "update existing index", - content: `# Decisions - - -| Date | Decision | -|----|----------| -| 2026-01-28 | Old entry | - - -## [2026-01-28-051426] New decision - -**Status**: Accepted -`, - wantHas: []string{ - marker.IndexStart, - marker.IndexEnd, - "| 2026-01-28 | New decision |", - }, - wantNot: []string{ - "| 2026-01-28 | Old entry |", - }, - }, - { - name: "remove index when no decisions", - content: `# Decisions - - -| Date | Decision | -|----|----------| -| 2026-01-28 | Old entry | - - -Some other content. -`, - wantNot: []string{ - marker.IndexStart, - marker.IndexEnd, - "| Date | Decision |", - }, - wantHas: []string{ - "# Decisions", - "Some other content.", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := UpdateDecisions(tt.content) - for _, want := range tt.wantHas { - if !strings.Contains(got, want) { - t.Errorf("UpdateDecisions() result missing %q\nGot:\n%s", want, got) - } - } - for _, notWant := range tt.wantNot { - if strings.Contains(got, notWant) { - t.Errorf( - "UpdateDecisions() result should not contain %q\nGot:\n%s", - notWant, got, - ) - } - } - }) - } -} - -func TestUpdateDecisions_PreservesContent(t *testing.T) { - content := `# Decisions - -## [2026-01-28-051426] First decision - -**Status**: Accepted - -**Context**: Some context here. - -**Decision**: The decision text. - -**Rationale**: Why we did it. - -**Consequence**: What happens next. - ---- - -## [2026-01-27-123456] Second decision - -**Status**: Accepted - -**Context**: Another context. - -**Decision**: Another decision. - -**Rationale**: Another rationale. - -**Consequence**: More consequences. -` - - got := UpdateDecisions(content) - - if !strings.Contains(got, marker.IndexStart) { - t.Error("Missing INDEX:START marker") - } - if !strings.Contains(got, marker.IndexEnd) { - t.Error("Missing INDEX:END marker") - } - - if !strings.Contains(got, "| 2026-01-28 | First decision |") { - t.Error("Missing first decision in index") - } - if !strings.Contains(got, "| 2026-01-27 | Second decision |") { - t.Error("Missing second decision in index") - } - - if !strings.Contains(got, "**Context**: Some context here.") { - t.Error("Lost content from first decision") - } - if !strings.Contains(got, "**Rationale**: Another rationale.") { - t.Error("Lost content from second decision") - } -} - -func TestUpdateDecisions_Idempotent(t *testing.T) { - content := `# Decisions - -## [2026-01-28-051426] Test decision - -**Status**: Accepted -` - - first := UpdateDecisions(content) - second := UpdateDecisions(first) - - if first != second { - t.Errorf( - "UpdateDecisions is not idempotent\nFirst:\n%s\nSecond:\n%s", - first, second, - ) - } -} - -func TestUpdateLearnings(t *testing.T) { - tests := []struct { - name string - content string - wantHas []string - wantNot []string - }{ - { - name: "empty file with header", - content: "# Learnings\n", - wantNot: []string{marker.IndexStart, marker.IndexEnd}, - }, - { - name: "file with one learning", - content: `# Learnings - -## [2026-01-28-191951] Required flags now enforced - -**Context**: Implemented ctx learning add flags - -**Lesson**: Structured entries are more useful - -**Application**: Always use all three flags -`, - wantHas: []string{ - marker.IndexStart, - marker.IndexEnd, - "| Date | Learning |", - "| 2026-01-28 | Required flags now enforced |", - }, - }, - { - name: "multiple learnings", - content: `# Learnings - -## [2026-01-28-191951] First learning - -**Context**: Test - -**Lesson**: Test - -**Application**: Test - ---- - -## [2026-01-27-120000] Second learning - -**Context**: Test - -**Lesson**: Test - -**Application**: Test -`, - wantHas: []string{ - "| 2026-01-28 | First learning |", - "| 2026-01-27 | Second learning |", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := UpdateLearnings(tt.content) - for _, want := range tt.wantHas { - if !strings.Contains(got, want) { - t.Errorf("UpdateLearnings() result missing %q\nGot:\n%s", want, got) - } - } - for _, notWant := range tt.wantNot { - if strings.Contains(got, notWant) { - t.Errorf( - "UpdateLearnings() result should not contain %q\nGot:\n%s", - notWant, got, - ) - } - } - }) - } -} - -func TestUpdateLearnings_Idempotent(t *testing.T) { - content := `# Learnings - -## [2026-01-28-191951] Test learning - -**Context**: Test - -**Lesson**: Test - -**Application**: Test -` - - first := UpdateLearnings(content) - second := UpdateLearnings(first) - - if first != second { - t.Errorf( - "UpdateLearnings is not idempotent\nFirst:\n%s\nSecond:\n%s", - first, second, - ) - } -} - -func TestValidate(t *testing.T) { - tests := []struct { - name string - content string - wantErr bool - }{ - { - name: "no markers is allowed (fresh creation)", - content: "# Learnings\n\n## [2026-01-01-090000] A\n\n**Lesson**: keep me.\n", - wantErr: false, - }, - { - name: "empty index block", - content: "# Learnings\n\n\n\n\n" + - "## [2026-01-01-090000] A\n\n**Lesson**: keep me.\n", - wantErr: false, - }, - { - name: "populated table between markers", - content: `# Learnings - - -| Date | Learning | -|----|--------| -| 2026-01-01 | A | - - -## [2026-01-01-090000] A - -**Lesson**: keep me. -`, - wantErr: false, - }, - { - name: "entry header trapped between markers", - content: `# Learnings - - - -## [2026-01-01-090000] A - -**Lesson**: would be deleted. - - -`, - wantErr: true, - }, - { - name: "duplicate INDEX:START", - content: "# Learnings\n\n\n\n" + - "\n\n## [2026-01-01-090000] A\n", - wantErr: true, - }, - { - name: "missing INDEX:END", - content: "# Learnings\n\n\n\n" + - "## [2026-01-01-090000] A\n", - wantErr: true, - }, - { - name: "INDEX:END before INDEX:START", - content: "# Learnings\n\n\n\n\n" + - "## [2026-01-01-090000] A\n", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := Validate(tt.content, "LEARNINGS.md") - if (err != nil) != tt.wantErr { - t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} diff --git a/internal/memory/extract.go b/internal/memory/extract.go index 7fc321735..856bb3a1b 100644 --- a/internal/memory/extract.go +++ b/internal/memory/extract.go @@ -16,7 +16,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/memory" cfgTime "github.com/ActiveMemory/ctx/internal/config/time" "github.com/ActiveMemory/ctx/internal/config/token" - "github.com/ActiveMemory/ctx/internal/index" + "github.com/ActiveMemory/ctx/internal/heading" "github.com/ActiveMemory/ctx/internal/io" ) @@ -88,7 +88,7 @@ func extractPendingTasks(content string, max int) []string { // Returns: // - []string: entry titles within the recency window func extractRecentEntries(content string, max int) []string { - blocks := index.ParseEntryBlocks(content) + blocks := heading.ParseEntryBlocks(content) cutoff := time.Now().AddDate( 0, 0, -memory.PublishRecentDays, ).Format(cfgTime.DateFormat) diff --git a/internal/trace/resolve_entry.go b/internal/trace/resolve_entry.go index cdef7938b..20c37e6a1 100644 --- a/internal/trace/resolve_entry.go +++ b/internal/trace/resolve_entry.go @@ -17,7 +17,7 @@ import ( "github.com/ActiveMemory/ctx/internal/config/regex" cfgTrace "github.com/ActiveMemory/ctx/internal/config/trace" cfgWarn "github.com/ActiveMemory/ctx/internal/config/warn" - "github.com/ActiveMemory/ctx/internal/index" + "github.com/ActiveMemory/ctx/internal/heading" "github.com/ActiveMemory/ctx/internal/io" logWarn "github.com/ActiveMemory/ctx/internal/log/warn" "github.com/ActiveMemory/ctx/internal/task" @@ -46,7 +46,7 @@ func resolveEntry( return resolved } - entries := index.ParseHeaders(string(content)) + entries := heading.ParseHeaders(string(content)) // Entries are 1-based; index into slice using number-1. if number < 1 || number > len(entries) { diff --git a/internal/write/drift/reindex.go b/internal/write/drift/reindex.go deleted file mode 100644 index fc9ce5ebb..000000000 --- a/internal/write/drift/reindex.go +++ /dev/null @@ -1,46 +0,0 @@ -// / ctx: https://ctx.ist -// ,'`./ do you remember? -// `.,'\\ -// \ Copyright 2026-present Context contributors. -// SPDX-License-Identifier: Apache-2.0 - -package drift - -import ( - "fmt" - "io" - - "github.com/ActiveMemory/ctx/internal/assets/read/desc" - "github.com/ActiveMemory/ctx/internal/config/embed/text" - "github.com/ActiveMemory/ctx/internal/config/token" -) - -// IndexCleared writes a message indicating that the index was cleared -// because no entries were found. -// -// Parameters: -// - w: output writer -// - entryType: type of entry (e.g. "decisions", "learnings") -// -// Returns: -// - error: non-nil if the write fails -func IndexCleared(w io.Writer, entryType string) error { - _, printErr := fmt.Fprintf( - w, desc.Text(text.DescKeyDriftCleared)+token.NewlineLF, entryType) - return printErr -} - -// IndexRegenerated writes a message indicating how many entries were -// found and regenerated in the index. -// -// Parameters: -// - w: output writer -// - count: number of entries regenerated -// -// Returns: -// - error: non-nil if the write fails -func IndexRegenerated(w io.Writer, count int) error { - _, printErr := fmt.Fprintf( - w, desc.Text(text.DescKeyDriftRegenerated)+token.NewlineLF, count) - return printErr -} diff --git a/internal/write/index/doc.go b/internal/write/index/doc.go new file mode 100644 index 000000000..bf7cb7004 --- /dev/null +++ b/internal/write/index/doc.go @@ -0,0 +1,16 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package index renders the output of the `ctx index` command: the headings +// projected from a knowledge file, either as indented lines (the default, +// deeper levels indented under their parents) or as a JSON array of +// {level, text} objects (--json). +// +// It is the write-side counterpart to the projection logic in +// [internal/heading]: the recognizer extracts headings, this package emits +// them. Keeping emission here honors the convention that CLI output routes +// through internal/write, not through cmd/ or core/ directly. +package index diff --git a/internal/write/index/index.go b/internal/write/index/index.go new file mode 100644 index 000000000..7dd9ea92a --- /dev/null +++ b/internal/write/index/index.go @@ -0,0 +1,60 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package index + +import ( + "encoding/json" + "strings" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/config/token" + "github.com/ActiveMemory/ctx/internal/entity" +) + +// baseLevel is the shallowest heading level the projector emits; deeper +// levels are indented relative to it in line output. +const baseLevel = 2 + +// Lines prints one heading per line, indenting deeper (L3) headings under +// their parents for a scannable table of contents. +// +// Parameters: +// - cmd: Cobra command for the output stream. +// - headings: Projected headings in file order. +func Lines(cmd *cobra.Command, headings []entity.Heading) { + for _, h := range headings { + depth := h.Level - baseLevel + if depth < 0 { + depth = 0 + } + cmd.Println(strings.Repeat(token.Space+token.Space, depth) + h.Text) + } +} + +// JSON prints the headings as a JSON array of {level, text} objects. +// +// A file with no headings yields "[]", not "null", so consumers can parse +// unconditionally. +// +// Parameters: +// - cmd: Cobra command for the output stream. +// - headings: Projected headings in file order. +// +// Returns: +// - error: Non-nil only if JSON marshaling fails. +func JSON(cmd *cobra.Command, headings []entity.Heading) error { + if headings == nil { + headings = []entity.Heading{} + } + b, err := json.MarshalIndent(headings, "", token.Space+token.Space) + if err != nil { + return err + } + cmd.Println(string(b)) + return nil +} diff --git a/specs/computed-index-projection.md b/specs/computed-index-projection.md new file mode 100644 index 000000000..4af85f346 --- /dev/null +++ b/specs/computed-index-projection.md @@ -0,0 +1,207 @@ +# Computed Index Projection (`ctx index`) + +> Source brief: `.context/briefs/20260714T045513Z-computed-index-projection-over-persisted-blocks.md` +> (debated via `/ctx-plan`, 2026-07-14). This spec supersedes an earlier, +> rejected bet (time-sharded knowledge files + a load-excluded "cold" bucket) — +> see Non-Goals. + +## Problem + +Knowledge files (DECISIONS/LEARNINGS/CONVENTIONS/TASKS) carry hand-maintained +`` blocks — a **stored** table of contents +regenerated by `ctx reindex` (and per-noun `ctx decision/learning reindex`). This +stored index is dead weight and a proven liability: + +- It is **derived state that drifts** from the entries it summarizes. +- It has caused real bugs: a clobber bug, and an "entries trapped in the index + block" bug (guarded today by `TestWrite_RefusesEntriesTrappedInIndexBlock`). +- It is **not injected at session start** anymore — the load-gate was narrowed to + CONSTITUTION + AGENT_PLAYBOOK_GATE on 2026-06-07 (DECISIONS `[2026-06-07-180013]`). + So the agent only encounters the block by reading the file top-to-bottom, where + it is redundant weight. + +An index is just a **projection over the L2/L3 headings** — computable on demand. +Maintaining it as a stored comment buys nothing and costs drift, bugs, and PR +conflicts. Now: because the parsing primitive (`index.ParseHeaders`) already +exists and is already consumed by `ctx agent`, replacing stored-with-computed is +mostly *deletion*, not new machinery. + +## Approach + +Delete the stored-index **maintenance** half and expose heading **parsing** as a +first-class, generic command. Rename the surviving parser package from +`internal/index` to **`internal/heading`**, since `index` now names the CLI +command and would otherwise be ambiguous. + +- **Keep** the entry parsers `internal/heading` (née `index`) provides — + `ParseHeaders`, `ParseEntryBlocks`, and the `EntryBlock` / `IndexEntry` types. + These are the projection primitives, already consumed by `ctx agent` + scoring/budget, `drift`, `memory`, and `trace`. They regex-match the + *timestamped entry* shape `## [YYYY-MM-DD-HHMMSS] Title` (DECISIONS/LEARNINGS). +- **Add** a generic ATX-heading matcher `heading.Headings(content, maxDepth)` for + the new `ctx index` command. It matches *any* `##`/`###` heading, not just + timestamped entries — required because TASKS.md phases (`## Phase N: …`) carry + no timestamp and would not match the entry regex. One generic mechanism across + all knowledge files; for TASKS.md it yields the `## Phase` sections. +- **Delete** the block-maintenance surface: `GenerateTable`, `Update`, + `UpdateDecisions`, `UpdateLearnings`, `Reindex`, `Validate`; the three reindex + command trees in scope (`ctx reindex`, `ctx decision reindex`, + `ctx learning reindex`); the `entry/write.go` insert-into-block logic and its + `TestWrite_RefusesEntriesTrappedInIndexBlock` guard; the marker constants; and + the `` blocks in the files themselves. +- **Add** `ctx index ` — a thin CLI over `heading.Headings`. +- **`ctx agent` needs no rewire.** It already parses entries via + `ParseEntryBlocks` and never reads the stored block (verified: no `INDEX:START` + reference under `internal/cli/agent/`). The stored block is consumed by nobody + but the reindex machinery — so its removal is pure dead-weight deletion, not a + behavior change. The only agent-side requirement is that the retained parser + API stays stable across the package rename. + +Real hot/cold split: **hot = an entry's heading (it exists); cold = its body.** +Orthogonal to liveness, so it never hides a live constraint — the agent always +sees every title and pulls bodies on demand. + +## Behavior + +### Happy Path + +1. `ctx index .context/DECISIONS.md` reads the file, parses `##` headings via the + retained parser, and prints them (one per line, in file order). Each heading is + already `## [timestamp] Title`, so the output is exactly the old + `| Date | Decision |` rows, computed. +2. `ctx index .context/TASKS.md` prints the `## Phase …` sections — doubles as + "list all phases." +3. `ctx agent` calls the projector internally to build the DECISIONS/LEARNINGS + TOC it presents to the agent, instead of relying on a stored block. +4. Files no longer contain `` blocks; `ctx reindex` and its + siblings no longer exist. New entries append normally; nothing regenerates a + table. + +### Edge Cases + +| Case | Expected behavior | +|------|-------------------| +| Empty file / file with no `##` headings | Print nothing, exit 0 (an empty index is valid, not an error). | +| File does not exist / unreadable | Non-zero exit with a path-bearing error (see Error Handling). | +| `###` (L3) sub-headings | Included only when `--depth 3` requests them; default is L2 only. | +| ATX-looking line inside a fenced code block (```` ``` ````) | Ignored — it is code text, not a heading. The generic matcher is code-fence-aware so `## not a heading` inside a fence is not projected. | +| Residual `` block still present in a file (migration lag) | Projector parses headings, not HTML-comment blocks, so a stale block is inert and never double-counted. | +| A caller still imports a deleted `Update*` / `Reindex` / `Validate` symbol | Compile failure — caught at build time, the intended forcing function for the removal. | + +There is no "malformed heading" case: the generic matcher accepts *any* ATX `##`/`###` +line, so a heading cannot be malformed — it is either a heading or it is not. + +### Validation Rules + +- The single positional argument is a path to a readable UTF-8 markdown file. No + constraint that it be a *known* knowledge file — the projector is generic. +- Heading recognition: lines matching the L2 (and optionally L3) ATX heading + pattern; parsing is delegated to the retained `index.ParseHeaders` logic so CLI + and `ctx agent` share one recognizer (no second, divergent parser). + +### Error Handling + +| Error condition | User-facing message | Recovery | +|-----------------|---------------------|----------| +| Path missing / unreadable | `ctx index: cannot read : ` | Fix the path; the arg is a file path. | +| No argument given | Usage error naming the expected `` argument | Re-run with a file path. | + +## Interface + +### CLI + +``` +ctx index [--depth N] +``` + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `` (positional) | path | — | Markdown file to project headings from. | +| `--depth` | int | 2 | Max heading level to include (2 = `##` only; 3 adds `###`). Ships in v1. | +| `--json` | bool | false | Machine-readable output, matching the `--json` convention across `ctx status/drift/doctor`. Ships in v1. | + +Top-level `ctx index` (not `ctx system index`): a heading TOC is dual-use +(human + agent); the `ctx system` namespace is reserved for agent-only plumbing +(cf. the 2026-04-14 bootstrap decision), which a readable TOC is not. + +### Skill + +None. This is a CLI primitive; no skill wrapper. + +## Implementation + +### Files to Create/Modify + +| File | Change | +|------|--------| +| `internal/index/` → `internal/heading/` | **Rename the package.** `index` now names the CLI command; the parser package becomes `heading`. Update all import paths. | +| `internal/heading/index.go` (renamed) | **Keep** `ParseHeaders`, `ParseEntryBlocks` (+ `EntryBlock`/`IndexEntry` types). **Delete** `GenerateTable`, `Update`, `UpdateDecisions`, `UpdateLearnings`, `Reindex`, `Validate`. **Add** generic `Headings(content, maxDepth)` (code-fence-aware ATX matcher). | +| `internal/heading/entry.go`, `types.go` | Trim to what the retained parsers + `Headings` need; drop table/update helpers. | +| `internal/heading/*_test.go` | Delete block-generation/update/`Validate` tests; keep parser tests; add `Headings` tests. | +| `internal/cli/index/…` (**new**) | New `ctx index` command tree (cmd/root + core), calling `heading.Headings`, standard cmd/core/write layout. | +| `internal/cli/reindex/**` | **Delete** (`ctx reindex`). | +| `internal/cli/decision/cmd/reindex/**` | **Delete** (`ctx decision reindex`). | +| `internal/cli/learning/cmd/reindex/**` | **Delete** (`ctx learning reindex`). | +| KB indexing (`ctx kb reindex` and its machinery) | **Out of scope — left untouched.** KB uses different indexing machinery and does not suffer the budget/context-window problem this spec targets. | +| `internal/entry/write.go` | Remove insert-into-index-block logic; new entries just append. | +| `internal/entry/write_test.go` | Remove `TestWrite_RefusesEntriesTrappedInIndexBlock` (moot). | +| `internal/config/marker/index_fmt.go`, `marker.go` | Remove `IndexBlockFmt`, `IndexBlockAppendFmt`, and `INDEX:START/END` marker constants. | +| `internal/cli/agent/**` | **No behavior change** — already parses entries via `ParseEntryBlocks`, never reads the stored block. Only ensure imports follow the `index`→`heading` rename and the retained parser API is unchanged. | +| `internal/drift/check.go`, `memory/extract.go`, `trace/resolve_entry.go`, `cli/system/core/knowledge/knowledge.go` | Repoint imports to `internal/heading`; drop any reliance on the block/`Validate`. | +| `internal/bootstrap/*.go`, `config/embed/cmd/*.go` | Deregister reindex commands; register `ctx index`; drop `UseReindex`/`DescKeyReindex`/per-noun reindex DescKeys; add `UseIndex`/`DescKeyIndex`. | +| `.context/DECISIONS.md`, `LEARNINGS.md`, `TASKS.md` | Strip `` blocks (DECISIONS ×1, LEARNINGS ×2, TASKS ×6). | +| Docs (`docs/cli/**`, any reindex references) | Remove `reindex` docs; add `ctx index`. | +| A new AST/audit or drift check (optional) | Assert no `` block is reintroduced under `.context/`. | + +### Key Functions + +```go +// internal/heading (retained, entry-specific — consumed by ctx agent/drift/memory/trace): +func ParseHeaders(content string) []entity.IndexEntry +func ParseEntryBlocks(content string) []heading.EntryBlock + +// internal/heading (new, generic — consumed by ctx index): +// Matches any ATX ##/### heading up to maxDepth; code-fence-aware. +func Headings(content string, maxDepth int) []entity.Heading +``` + +### Helpers to Reuse + +- `heading.ParseHeaders` / `ParseEntryBlocks` — the entry-specific recognizers + `ctx agent` already depends on; keep their API stable across the rename. +- Standard cmd/core/write CLI layout and `internal/write` for output. +- Existing `--json` output convention. + +## Configuration + +None expected. No new `.ctxrc` keys. (Removes, if present, any reindex-related +config surface — audit `archive_after_days`-style inert keys during removal.) + +## Testing + +- **Unit**: `Project` over fixtures — well-formed L2 headings, mixed L2/L3 with + `--depth`, malformed/absent headings, empty file, no-heading file, stale + `` block present (must be ignored). +- **Integration**: `ctx index` over the real `.context/*.md` files; `ctx agent` + packet builds a TOC via the projector with zero stored blocks present. +- **Edge**: unreadable path → non-zero exit + path-bearing message; malformed + heading → warning (or strict fail, per TBD). +- **Regression/removal**: build fails if any deleted `index.Update*`/`Reindex` + symbol is still imported (compile-time). Optional audit test: no file under + `.context/` contains an `INDEX:START` marker. + +## Non-Goals + +- **No time-sharding of knowledge files, and no load-excluded "cold" + (superseded/deprecated) bucket.** Killed by validation: a supersession pass + found ~1.5% cold across 162 entries (the corpus was already GC'd by + consolidation). Recency-gating was rejected as *more* dangerous (old ≈ live). +- **No `list`/`search` query surface** (filtering, full-text). `ctx index` is a + thin heading grep; the richer surface is filed separately in TASKS.md → Misc + and layers on top of this primitive later. +- **No content in the projection beyond headings** — no gists, no bodies. The + body is pulled on demand by reading the file (progressive disclosure). +- **No auto-regeneration of anything.** The whole point is to stop storing + derived state; the projector computes, it does not write back. +- **KB indexing untouched.** `ctx kb reindex` and the KB pipeline's own indexing + are out of scope — different machinery, no budget/context-window pressure. diff --git a/specs/plans/computed-index-projection.md b/specs/plans/computed-index-projection.md new file mode 100644 index 000000000..f8302c8f6 --- /dev/null +++ b/specs/plans/computed-index-projection.md @@ -0,0 +1,213 @@ +# Computed Index Projection Plan — `ctx index` + +**Spec:** specs/computed-index-projection.md · **Status:** Ready +**Blocking TBDs resolved:** none — the spec's Open Questions were all resolved +before commit (package rename → `internal/heading`; KB indexing out of scope; +`--depth`/`--json` ship in v1; no "malformed heading" concept; `ctx agent` needs +no rewire). No TBD remains that any task below embeds an assumption about. + +Single-milestone spec: the whole spec is one milestone (this plan). No +`--milestone` split. + +## Scope & DoD + +Replace the stored `` blocks with a computed `ctx index ` +heading projector; delete the block-maintenance machinery; rename the surviving +parser package. Net deletion + one new command. + +Definition of Done (confirmed by measurement/user, not derived from task ticks): + +- [x] `ctx index ` projects L2 (and `--depth 3` L3) headings for any + knowledge file, including TASKS.md `## Phase` sections. +- [x] `--depth` and `--json` both work; missing/unreadable path exits non-zero + with a path-bearing message. +- [x] All real `` blocks removed from `.context/{DECISIONS,LEARNINGS,TASKS}.md` + and none regenerated when an entry is added. +- [x] `ctx reindex`, `ctx decision reindex`, `ctx learning reindex` no longer exist + (unknown-command, exit ≠ 0). KB indexing untouched. +- [x] `internal/index` renamed to `internal/heading`; `GenerateTable`/`Update*`/ + `Reindex`/`Validate` deleted; `ParseHeaders`/`ParseEntryBlocks`/`EntryBlock` + retained and their consumers green. +- [x] `ctx agent` output is unaffected by block removal (regression passes). +- [x] `make build` green; `make lint` (`golangci-lint run` v2.11.4) **0 issues**; + `make test` (whole `./...`) green; AST `audit` + `compliance` suites green; + `lint-drift` clean; docstrings clean. + +## Data model & storage + +No persisted state. New in-memory type: + +```go +// internal/entity +type Heading struct { + Level int // 2 for ##, 3 for ### + Text string // heading text after the # markers +} +``` + +Retained parser types (unchanged, moved to `internal/heading`): `IndexEntry`, +`EntryBlock`. No migrations; the "migration" is a one-shot strip of the blocks +(T19), tolerant of already-absent blocks in downstream repos (they strip on next +write, which no longer regenerates). + +## Contracts + +- `heading.ParseHeaders(content string) []entity.IndexEntry` — retained, entry-specific (`## [ts] Title`). +- `heading.ParseEntryBlocks(content string) []heading.EntryBlock` — retained; `ctx agent` scoring. +- `heading.Headings(content string, maxDepth int) []entity.Heading` — **new**, generic ATX matcher, code-fence-aware. +- CLI: `ctx index [--depth N] [--json]` — top-level command. + - default output: one heading per line, file order. + - `--depth` default 2 (L2 only); 3 includes L3. + - `--json`: array of `{level, text}`, matching `ctx status/drift --json`. + +## Test matrix + +| Invariant / rule (spec) | Violation attempt | Expected failure/behavior | Task | +|---|---|---|---| +| Generic matcher accepts any ATX heading (no "malformed") | Heading without timestamp (`## Phase 1`) | Included in projection | T04, T13 | +| Code-fence-aware extraction | `## x` inside ```` ``` ```` fence | Not projected | T03, T04 | +| Depth default = 2 | file with `###` sub-headings, no `--depth` | L3 omitted; `--depth 3` includes them | T16 | +| Empty / no-heading file | project a file with no `##` | empty output, exit 0 | T04, T18 | +| Missing/unreadable path | `ctx index /nope` | exit ≠ 0, path-bearing message | T18 | +| No arg | `ctx index` | usage error | T18 | +| Residual INDEX block inert | project a file still holding a stale block | block ignored, not double-counted | T04 | +| `--json` well-formed | `ctx index --json f \| jq .` | valid JSON array | T17 | +| Deleted symbol still referenced | leftover `heading.Validate` call | compile failure | T11, T12, T24 | +| No INDEX block remains under `.context/` | grep after strip | zero matches | T19, T22 | +| Reindex commands gone | `ctx reindex` / `ctx decision reindex` / `ctx learning reindex` | unknown command, exit ≠ 0 | T05–T07, T22 | +| `ctx agent` unaffected by block removal | run agent with blocks stripped | packet still lists decisions/learnings | T21 | +| add stops touching index | `ctx decision add` after strip | entry appended, no block written | T09 | + +## Task breakdown + +| id | st | task | deps | files | [P] | acceptance criterion | spec ref | +|----|----|------|------|-------|-----|----------------------|----------| +| **Epic A — Rename & generic parser primitive** ||||||| +| T01 | [x] | `git mv internal/index → internal/heading`; update package decl + all 10 non-test importers + tests | — | internal/index/** → internal/heading/**; the 10 importer files | | `make build` green; `grep -rn 'ctx/internal/index"' internal/` empty | Impl (rename) | +| T02 | [x] | Add `entity.Heading{Level,Text}` type | — | internal/entity/heading.go | [P] | `make build` green | Data model | +| T03 | [x] | Add code-fence-aware generic ATX heading regex/util | — | internal/regex/*.go | [P] | unit test: matches `## a`,`### b`; skips fenced `## c` and `#### d` | Edge (fences) | +| T04 | [x] | Implement `heading.Headings(content,maxDepth)` + unit tests | T01,T02,T03 | internal/heading/headings.go(+_test) | | `go test ./internal/heading/ -run Headings` green over: default L2, depth3 L3, empty, no-heading, fenced-ignored, stale-block-ignored | Approach | +| **Epic B — Remove reindex command surfaces** ||||||| +| T05 | [x] | Delete `ctx reindex` tree; deregister at bootstrap/group.go:109; remove `UseReindex`/`DescKeyReindex` | T01 | internal/cli/reindex/**, internal/bootstrap/group.go, internal/config/embed/cmd/base.go | | `ctx reindex` → unknown command (exit ≠ 0); `make build` green | Impl | +| T06 | [x] | Delete `ctx decision reindex`; remove its AddCommand + `DescKeyDecisionReindex` | T01 | internal/cli/decision/cmd/reindex/**, decision command wiring, config/embed/cmd/decision.go | | `ctx decision reindex` → unknown; build green | Impl | +| T07 | [x] | Delete `ctx learning reindex`; remove AddCommand + `DescKeyLearningReindex` | T01 | internal/cli/learning/cmd/reindex/**, learning command wiring, config/embed/cmd/learning.go | | `ctx learning reindex` → unknown; build green | Impl | +| T08 | [x] | Remove orphaned reindex description YAML; verify Go↔YAML linkage (lint-drift check 5) | T05,T06,T07 | internal/assets/commands/*.yaml | | `make lint` green (no orphaned DescKey) | Impl | +| **Epic C — Detach write path & delete block API** ||||||| +| T09 | [x] | Remove index-mutation block from `entry/write.go` (drop `Validate`+`UpdateDecisions`/`UpdateLearnings`, ~L65–125); entries append only; drop now-unused `errAdd.IndexUpdate` | T01 | internal/entry/write.go, internal/err/(add) | | `ctx decision add`/`ctx learning add` append entry, write no INDEX block; `go test ./internal/entry/` green | Impl | +| T10 | [x] | Delete `TestWrite_RefusesEntriesTrappedInIndexBlock` + related block tests | T09 | internal/entry/write_test.go | | `go test ./internal/entry/` green | Impl | +| T11 | [x] | Delete `GenerateTable`/`Update`/`UpdateDecisions`/`UpdateLearnings`/`Reindex`/`Validate` + their tests from `internal/heading` | T05,T06,T07,T09,T10 | internal/heading/index.go,entry.go,*_test.go | | `grep -rnE 'func (GenerateTable\|Update\|Reindex\|Validate)\(' internal/heading` empty; `make build` green | Impl | +| T12 | [x] | Audit retained-parser consumers; confirm only `ParseHeaders`/`ParseEntryBlocks` used; remove `drift/check` block/`Validate` reliance if any | T11 | internal/trace/resolve_entry.go, internal/cli/agent/core/{budget/parse,score/score,score/types}.go, internal/memory/extract.go, internal/drift/check.go, internal/cli/system/core/knowledge/knowledge.go | | `make build && make test` green; `grep -rn 'heading.Validate\|heading.Update' internal/` empty | Impl | +| **Epic D — `ctx index` command** ||||||| +| T13 | [x] | Scaffold `internal/cli/index/` (cmd/root/cmd.go,run.go; core projector; doc.go) calling `heading.Headings`; positional `` | T04 | internal/cli/index/** | | `ctx index .context/DECISIONS.md` prints `##` headings | Interface | +| T14 | [x] | Add `UseIndex`/`DescKeyIndex` + description YAML | — | internal/config/embed/cmd/base.go, internal/assets/commands/*.yaml | [P] | `make build` green; lint linkage green | Interface | +| T15 | [x] | Register `ctx index` top-level in bootstrap | T13,T14 | internal/bootstrap/group.go (or bootstrap.go) | | `ctx --help` lists `index`; `ctx index ` runs | Interface | +| T16 | [x] | `--depth` flag (default 2; 3 adds `###`) | T13 | internal/cli/index/** | | `ctx index --depth 3 f` includes L3; default omits | Interface | +| T17 | [x] | `--json` flag via `internal/write` | T13 | internal/cli/index/**, internal/write/(index) | | `ctx index --json f` emits valid JSON array | Interface | +| T18 | [x] | Error handling: unreadable/missing path → path-bearing err exit ≠ 0; no arg → usage err | T13 | internal/cli/index/**, internal/err/(index) | | `ctx index /nope` exit ≠ 0 + message; `ctx index` (no arg) usage err | Error Handling | +| **Epic E — Strip blocks, marker cleanup, guards** ||||||| +| T19 | [x] | Strip `` blocks: DECISIONS ×1, LEARNINGS ×2, TASKS ×6 | T09 | .context/DECISIONS.md, .context/LEARNINGS.md, .context/TASKS.md | | `grep -c INDEX:START` on all three = 0 | Impl | +| T20 | [x] | Remove `IndexBlockFmt`/`IndexBlockAppendFmt`/`INDEX:START`/`END` marker constants | T09,T11 | internal/config/marker/index_fmt.go, marker.go | | `grep -rn 'IndexBlock\|INDEX:START' internal/config/marker` empty; build green | Impl | +| T21 | [x] | Regression: `ctx agent` projects DECISIONS/LEARNINGS with blocks stripped | T15,T19 | (test) internal/cli/agent/** or integration | | `ctx agent` output lists decisions & learnings, no error | Approach | +| T22 | [x] | Audit test: no `` under `.context/`; no reindex command registered | T15,T19 | internal/audit/*_test.go | | `go test ./internal/audit/` green with new check | Testing | +| **Epic F — Docs & final gate** ||||||| +| T23 | [x] | Docs: remove `reindex` from docs/cli/**; add `ctx index` reference; fix any CLAUDE.md/skill mentions | T15 | docs/cli/**, docs/**, CLAUDE.md template | | `grep -rn 'reindex' docs/` only historical/release-notes; `ctx index` documented | Interface | +| T24 | [x] | Final gate: `make build && make lint && make test`; run command-audit for stale reindex refs | ALL | — | | all three make targets exit 0; no stale `reindex` reference | DoD | + +**Epic → id partition (must sum to 24):** A=T01–T04 (4) · B=T05–T08 (4) · +C=T09–T12 (4) · D=T13–T18 (6) · E=T19–T22 (4) · F=T23–T24 (2). 4+4+4+6+4+2 = **24**. +Each id in exactly one epic. + +**Completion rule:** an epic anchor in TASKS.md is `[x]` only when every task in +its id-range is `[x]` or `[o]` in this plan. This plan is the single source of +truth for milestone progress; TASKS.md epics project it. + +**Execution waves (topological check):** +1. T01, T02, T03 (roots; T02/T03 `[P]`) +2. T04 · T05 · T06 · T07 · T09 · T14 (all depend only on T01/roots) +3. T08 · T10 · T13 +4. T11 · T15 · T16 · T17 · T18 +5. T12 · T19 · T20 · T23 +6. T21 · T22 +7. T24 + +## Risks & measurement gates + +- **`drift/check.go` may embed block-`Validate` logic** (not just parse). If T12 + finds it validates the stored index, that check is obsoleted by the removal — + delete or repurpose it, and note here. Measurement: does `ctx drift` still pass + meaningfully after T12? If it loses a real check, surface as a spec gap. +- **`ctx agent` scoring depends on `ParseEntryBlocks`.** If the rename/trim + changes its output ordering or scores, T21 catches it. Gate: T21 must pass + before T24. +- **YAML↔Go linkage (lint-drift check 5)** is strict; orphaned reindex DescKeys + fail lint. T08 owns clearing them; gate is `make lint` in T24. + +## Out of scope + +- **Richer `ctx list/search`** (filtering, full-text) — separate TASKS.md → Misc + task; layers on this index primitive later. +- **KB indexing / `ctx kb reindex`** — different machinery, untouched (spec Non-Goal). +- **Time-sharding / cold-bucket** — killed in `/ctx-plan` (brief), not revisited. + +## Amendments + +- **2026-07-14 · T06/T07 acceptance corrected (measurement gate).** Original + criterion: "`ctx decision reindex` / `ctx learning reindex` → unknown command, + exit ≠ 0." Reality: `decision`/`learning` are built on `parent.Cmd`, which + intentionally keeps cobra's default (help + exit 0) on an unknown subcommand — + documented by `internal/cli/system/system_test.go:TestParentCmdScopeUnchanged`. + Only `ctx system` opts into the loud `unknown.HandlerFor` handler. So the + exit-code expectation was wrong about the codebase, not a regression I + introduced. **Corrected criterion (verified):** the reindex subcommand is + deregistered — absent from `ctx decision --help` / `ctx learning --help` (only + `add` remains) — and top-level `ctx reindex` exits 1 (cobra default errors at + root). The feature is fully removed. **Not minted here:** making the + decision/learning parents fail loud on unknown subcommands (like `ctx system`) + would contradict the documented `TestParentCmdScopeUnchanged` scope and is a + separate design decision — route through `/ctx-plan` if wanted; filed as a + follow-up thought, not a task. +- **2026-07-14 · T03 satisfied by reuse, no new regex.** `regex.MarkdownHeading` + (`^(#{1,6}) (.+)$`) and `regex.CodeFenceLine` already existed; the fence-aware + logic lives in `heading.Headings` (T04). No file added under `internal/regex`. +- **2026-07-14 · T04 hardened: HTML-comment-aware (bug found by driving the real + file).** Verifying `ctx index .context/DECISIONS.md` (Epic D) surfaced that the + matcher projected the `## Quick Format` / `## Full Format` example headings + inside the `` legend comment. Same class as code + fences. `heading.Headings` now skips multi-line HTML comment blocks (inline + `` comments do not swallow following headings); two tests added. Not + a criterion change — the T04 implementation was incomplete; this is the fix. +- **2026-07-14 · T19 scope corrected (verify-before-strip caught a data hazard).** + Plan said "DECISIONS ×1, LEARNINGS ×2, TASKS ×6" from a raw `grep -c INDEX:START`. + Inspecting actual marker positions: LEARNINGS has **one** real block (L17–112); + its "2nd" hit is prose ("INDEX:START/END markers:") inside an entry body. TASKS + has **zero** real blocks — all 6 hits are prose inside task descriptions about + this very index-drop work. A naive regex strip would have corrupted TASKS bodies. + Corrected: stripped only exact marker-delimited blocks in DECISIONS (70 entries + preserved) and LEARNINGS (96 preserved) via an entry-count-guarded awk; TASKS + left untouched. **Corrected DoD/matrix reading:** "no real `` + marker LINES in DECISIONS/LEARNINGS" (met); TASKS retains its prose mentions by + design. +- **2026-07-14 · T22 scoped to the durable invariant.** The plan's "no INDEX + block under `.context/`" half is a one-time migration state, not a perpetual + code invariant (downstream repos' `.context` is out of ctx's control), so it is + not asserted by a test. The durable guard IS a test: `TestReindexCommandRemoved` + in bootstrap_test.go walks the command tree and fails if any `reindex` command + reappears (the `kb` subtree is exempt — KB indexing is a spec Non-Goal). `index` + added to `TestInitialize`'s expected-commands list. +- **2026-07-14 · T24: `golangci-lint` installed and run (0 issues).** Installed + the CI-pinned `golangci-lint v2.11.4` (from `.github/workflows/ci.yml`) and ran + `make lint` over the whole project. It caught one real issue in new code — + gosec G602 (false-positive out-of-range on a hand-rolled slice-compare helper + in `headings_test.go`); replaced with `slices.Equal`. Re-run: **0 issues.** The + AST `internal/audit` + `internal/compliance` suites also exercised the removal + heavily — they caught 12 orphaned dead exports + 3 style nits that greps + missed, all fixed. +- **2026-07-14 · Orphan sweep beyond the plan (audit-driven).** Deleting the + block-maintenance API orphaned 12 exports the plan did not enumerate: the index + table formatters (`marker.TableRowFmt/TableSepFmt`), warn labels + (`warn.IndexHeader/IndexSeparator/IndexRow`), column DescKeys (`colummn.go`, + whole file), drift-index writers (`write/drift/reindex.go`, whole file, + + `DescKeyDriftCleared/Regenerated`), and journal reindex errors + (`err/journal.ReindexFile{NotFound,Read,Write}` + DescKeys). All removed with + their YAML, confirmed zero live callers first. `TestNoDeadExports` was the + forcing function.