From 732a3c73d5e761a810fbde89d09da8dcf0d9d4d2 Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Fri, 15 May 2026 13:11:05 +0600 Subject: [PATCH 01/31] =?UTF-8?q?=F0=9F=93=9D=20docs:=20add=20CLAUDE.md=20?= =?UTF-8?q?for=20web=20subproject?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the web/ commands, the three SSR runtimes (Vite dev, Express, Vercel serverless), and points at the existing changelog guide and skill rather than duplicating instructions. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/CLAUDE.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 web/CLAUDE.md diff --git a/web/CLAUDE.md b/web/CLAUDE.md new file mode 100644 index 0000000..6d4e4ad --- /dev/null +++ b/web/CLAUDE.md @@ -0,0 +1,47 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Scope + +This is the `web/` subproject — the marketing site, docs, and changelog page for the Code Context Notes VS Code extension. The extension source itself lives in the parent repo at `/Users/nahian/Projects/code-notes/src`. Repo-wide rules (user stories, changelog policy, release process) are in the parent `CLAUDE.md`. + +## Commands + +All commands run from `web/`. + +- `npm run dev` — Vite client-only dev server (fastest for UI work). +- `npm run dev:ssr` — Express + Vite middleware server (`server-dev.js`); use when changing SSR behavior. +- `npm run build` — Builds both client (`dist/client`) and server (`dist/server`) bundles. `build:client` and `build:server` can be run individually. +- `npm run build:vercel` — Production build + post-build copy that stages `server/` and root `index.html` for the Vercel handler. +- `npm run preview:ssr` — Build then serve via `server.js` to test the production SSR path locally. +- `npm run lint` — ESLint with `--max-warnings 0`; CI-equivalent. + +There is no test runner configured in this subproject. Lint and a successful production build are the verification gates. + +## Architecture + +**SSR with three runtimes.** The same React app is served three different ways and any routing/data change must work in all three: + +1. **Vite dev** (`npm run dev`) — CSR only, hydrates from `index.html`'s empty ``. +2. **Express SSR dev/prod** (`server-dev.js`, `server.js`) — Loads `src/entry-server.tsx` via Vite middleware (dev) or the built `dist/server/entry-server.js` (prod), renders to string, injects into the template at ``. +3. **Vercel serverless** (`api/index.js`) — Same render contract as Express, but as a single handler. `vercel.json` rewrites every path to `/api`, so the function must handle all routes. Falls back to CSR if SSR import fails. `build:vercel` copies `dist/server/*` to `server/` and `dist/client/index.html` to the repo root so the function can resolve them. + +**Entry points.** +- `src/entry-client.tsx` — hydrateRoot, wraps `` in `BrowserRouter`. +- `src/entry-server.tsx` — exports `render(url)` that returns `{ html }`, wraps `` in `StaticRouter`. +- Routes are defined in `src/App.tsx`: `/`, `/docs`, `/changelog`. + +When adding a route, update `App.tsx` and verify it renders under SSR (visible in initial HTML, not just after hydration). + +## Changelog page + +The changelog timeline at `src/pages/ChangelogPage.tsx` is hand-maintained JSX, not generated. The full procedure for adding a version (where to insert, which badge to move, color choices) is in `web/CHANGELOG_WEB_GUIDE.md` — follow it rather than improvising. The markdown changelog in `../docs/changelogs/` is the source of truth and must be created first per the parent `CLAUDE.md`. + +There is also an `add-web-changelog` skill that automates this; prefer it when adding a version. + +## UI conventions + +- shadcn/ui components live in `src/components/ui/` — extend rather than replace. +- Tailwind with `tailwindcss-animate`; brand tokens like `bg-brand-orange` are defined in `tailwind.config.js`. +- Page-specific composition components are grouped under `src/components/landing/` and `src/components/docs/`. From b3eebb9cb1f73a2aebcd965ca5cf9818b7485b46 Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Fri, 15 May 2026 14:05:21 +0600 Subject: [PATCH 02/31] =?UTF-8?q?=F0=9F=93=9D=20docs:=20add=20agent=20inte?= =?UTF-8?q?gration=20design=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three-stage rollout (v0.3 schema+exports, v0.4 MCP server, v0.5 trust model + audit UI) for making notes a first-class context source for coding agents — read+write loop, full structured schema, configurable agent-write trust mode. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-15-agent-integration-design.md | 690 ++++++++++++++++++ 1 file changed, 690 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-15-agent-integration-design.md diff --git a/docs/superpowers/specs/2026-05-15-agent-integration-design.md b/docs/superpowers/specs/2026-05-15-agent-integration-design.md new file mode 100644 index 0000000..266e7da --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-agent-integration-design.md @@ -0,0 +1,690 @@ +# Code Context Notes — Agent Integration Design + +- **Status:** Draft (awaiting user review) +- **Date:** 2026-05-15 +- **Author:** Julkar Naen Nahian (with Claude Opus 4.7, brainstorming session) +- **Scope:** Releases v0.3, v0.4, v0.5 +- **Companion docs:** `docs/USER_STORY_TEMPLATE.md`, `docs/changelogs/`, `docs/RELEASE_TEMPLATE.md` + +## 1. Goals + +Make Code Context Notes a first-class context source for coding agents (Claude Code, Cursor, Copilot agent mode, Aider, custom MCP-capable tools), with a full read+write loop: + +- Agents **read** notes as workspace-aware context before editing code. +- Agents **write** notes as durable breadcrumbs, decisions, and handoffs. +- Notes become the workspace's institutional memory, not just per-developer scratch. + +### 1.1 Non-goals + +- Replacing CLAUDE.md / repo-level agent instruction files. Notes are line/scope-bound; CLAUDE.md is repo-bound. They complement. +- Acting as a security boundary. Anyone with write access to `.code-notes/` can write notes; allow-lists are discipline, not enforcement. +- Semantic / embedding search. Full-text only in this scope; embeddings are a v0.6+ wishlist item. +- Real-time agent-to-agent coordination. The model is filesystem-as-IPC, polled at request time. + +## 2. Background + +`code-context-notes` (v0.2.1 today) is a VS Code extension that attaches markdown notes to specific code lines. Notes are stored as markdown files under `.code-notes/`, tracked by content hash so they survive line-number drift, with full edit history. The extension provides a sidebar, CodeLens indicators, native VS Code comment UI, and git-aware authorship. + +Today, notes are written and read by humans through the VS Code UI. Coding agents have no first-class way to consume them — an agent can technically read the markdown files but the schema is unstructured (free-form markdown), there's no index, no tool surface, and no convention for agent-authored notes. + +The opportunity: notes are already *persistent, location-bound context*. The scaffolding for the most painful problem agents face — recovering the *why* of code — already exists. This design extends that scaffolding so agents can participate in the read+write loop natively. + +## 3. Decisions summary + +The four pivotal decisions made during brainstorming: + +| Decision | Choice | Why | +|---|---|---| +| **Direction** | Read+write, equal weight | Read alone misses the institutional-memory accrual; write alone has no reason to exist. The loop is what makes notes durable. | +| **Surfaces** | MCP server + structured file exports | MCP for live agent integration (Claude Code, Cursor). Exports for any tool that just reads files (CLAUDE.md-style consumption, Aider, CI). CLI and VS Code agent API explicitly deferred. | +| **Agent identity** | Same shape as human, attributed | Agents are just authors with `authorType: agent`. No separate storage namespace. Sidebar/digest can filter by author type. | +| **Schema enrichment** | Full structured schema | Add `type`, `tags`, `scope`, `references`, `expiresAt`, `priority`. Necessary for agents to prioritize (instruction vs. context) and for digests to be useful. | +| **Trust model** | `agentWriteMode: direct \| queue \| audit`, default `audit` | Per-workspace setting. `audit` is the sweet spot: doesn't block, gives humans one place to spot-check. Teams can dial up to `queue` for sensitive codebases. | + +## 4. Approach: staged rollout + +Three milestones, each independently shippable and useful: + +### 4.1 v0.3 — Schema + Exports + +- Extend `Note` type with optional structured fields. +- Lazy migration: legacy notes load with defaults; on-disk shape upgrades only on next edit. +- Auto-generate `.code-notes/INDEX.json` and `.code-notes/AGENTS.md` on every save/delete. +- Sidebar gains type pill and type/expired filter. + +**Ships value to:** any file-reading agent (Claude Code, Cursor, Aider) — even with zero new infra, an agent that's been told to read `.code-notes/AGENTS.md` immediately gets workspace context. + +### 4.2 v0.4 — MCP server + +- New separate npm package: `@jnahian/code-notes-mcp`. +- Extract `code-notes-core` into a shared package consumed by both the extension and the MCP server (avoids logic duplication / drift). +- Read tools first (`search_notes`, `get_notes_for_file`, `get_notes_for_changes`, `list_instructions`, `get_handoffs`, `get_note`). +- Write tools second (`create_note`, `edit_note`, `delete_note`, `add_handoff`, `add_decision`). +- Resources: `code-notes://digest`, `code-notes://index`, `code-notes://file/{path}`. + +**Ships value to:** any MCP-capable agent. Adoption isn't gated on the VS Code extension. + +### 4.3 v0.5 — Trust model + audit UI + +- Add `codeContextNotes.agentWriteMode` setting. +- `direct`: writes go straight in, attributed. +- `audit` (default): writes go in, every op appended to `.code-notes/_audit.log` (JSONL), sidebar "Agent activity" view with Revert button. +- `queue`: writes go to `.code-notes/_pending/.md`, sidebar "Pending agent proposals" view with Approve / Reject / Edit-and-approve actions. +- Conflict handling for queue mode (3-way diff or simple-pick — see §7). + +**Ships value to:** teams adopting agent assistance; provides safety rails before agents accumulate a lot of writes. + +### 4.4 Why staged + +- Each milestone is independently shippable and rollback-safe. +- v0.3 unblocks file-reading agents immediately with zero new infrastructure. +- v0.4 lands the MCP surface against a stable schema (no churn from late schema decisions). +- v0.5 adds safety before agent-write volume grows. +- Three review/test cycles is a deliberate cost, accepted for the feedback value. + +### 4.5 Approaches considered and rejected + +- **B. Big-bang v0.3** (everything in one release): rejected because the trust-model UI is the slowest piece and would block ship. No real-world feedback between schema decisions and MCP shape. +- **C. MCP-first against plain markdown, schema later**: rejected because MCP tool shapes (filters by type, scope, tags) would change when schema lands, breaking early agent configs. + +## 5. Detailed design + +### 5.1 Extended note schema (v0.3) + +Optional fields added to the `Note` type. Existing notes load fine (back-compat). + +```ts +type NoteType = + | 'context' // default: explanatory background + | 'instruction' // directive: agent/human MUST follow + | 'warning' // hazard: don't do X, see why + | 'decision' // architectural decision + rationale + | 'todo' // outstanding work + | 'handoff' // "next session pick up here" (often agent-authored) + | 'rationale'; // why this code exists (links to PRs/commits) + +type NoteScope = + | 'line' // default — current behavior + | 'function' // applies to enclosing function + | 'class' // applies to enclosing class + | 'file' // applies to whole file + | 'directory';// applies to all files in a directory subtree + +interface NoteReference { + kind: 'note' | 'pr' | 'issue' | 'commit' | 'test' | 'url'; + value: string; // note ID, PR number, URL, etc. + label?: string; +} + +interface Note { + // ...existing fields unchanged... + + // NEW (all optional for back-compat) + type?: NoteType; // default 'context' + tags?: string[]; + scope?: NoteScope; // default 'line' + references?: NoteReference[]; + expiresAt?: string; // ISO 8601 + authorType?: 'human' | 'agent'; + priority?: 'low' | 'normal' | 'high' | 'critical'; +} +``` + +**Storage:** stored in the markdown frontmatter (same format used today for metadata). Reader is forward-compatible (older versions ignore unknown keys). Writer omits any field equal to its default to keep frontmatter compact. + +**Sidebar UI in v0.3:** small type pill on each note item; filter dropdown (type / tags / expired). Add/edit flow gets a "More fields" expander for the new fields. Richer UI deferred to v0.5. + +**Why these specific types:** +- `instruction` and `warning` give agents prioritization signal — load these first, never ignore. +- `handoff` and `decision` are the highest-value *agent-authored* types: handoffs survive session boundaries; decisions are the durable "why" record. + +**Three trade-offs:** +1. `scope: directory` requires storing notes against directory paths. Solution: allow `filePath` to point to a directory when `scope: 'directory'`. Document the convention. +2. `function` / `class` scope needs a symbol resolver. v0.3 stores the symbol *name* the user picked at creation time and re-resolves on read. If the symbol disappears, fall back to line-range matching with a `scopeStatus: 'stale'` indicator. No tree-sitter integration in v0.3. +3. `expiresAt` is computed lazily on read. No background timer needed. + +### 5.2 File exports (v0.3) + +Two artifacts regenerated atomically on every note save/delete/migration. Live in `.code-notes/` so they ship with the existing storage convention. + +#### 5.2.1 `.code-notes/INDEX.json` + +Single machine-readable index. The format any tool reads. + +```json +{ + "version": 1, + "generatedAt": "2026-05-15T12:34:56Z", + "workspaceRoot": "/abs/path", + "notes": [ + { + "id": "note-abc123", + "filePath": "src/foo.ts", + "lineRange": { "start": 12, "end": 18 }, + "type": "instruction", + "scope": "function", + "tags": ["legacy-abi", "do-not-refactor"], + "priority": "high", + "author": "alice", + "authorType": "human", + "createdAt": "...", + "updatedAt": "...", + "expiresAt": null, + "isExpired": false, + "references": [{ "kind": "pr", "value": "#42" }], + "contentPreview": "First 200 chars of markdown...", + "contentPath": ".code-notes/src/foo.ts/note-abc123.md" + } + ], + "byFile": { "src/foo.ts": ["note-abc123"] }, + "byType": { "instruction": ["note-abc123"] }, + "byTag": { "legacy-abi": ["note-abc123"] }, + "errors": [] +} +``` + +Pre-built reverse indexes mean an agent can answer "what instruction notes apply to `src/foo.ts`" by reading one file, no scan. `contentPath` lets the agent fetch full markdown when needed. + +#### 5.2.2 `.code-notes/AGENTS.md` + +Human-and-agent-readable digest. The single file an agent can read at task start to absorb workspace knowledge. Skim-first organization: + +```markdown +# Code Notes Digest +*Auto-generated. Do not edit. Source: `.code-notes/INDEX.json`* + +## Critical instructions and warnings +- **`src/auth.ts:42` — instruction (high):** Never bypass `validateSession()` — see PR #42. +- **`src/db/migrations/`** — warning (critical): Migrations are non-reversible in prod. Test against staging first. + +## Open handoffs +- **`src/parser.ts:88-95`** — handoff from claude-code 2026-05-14: blocked on tokenizer test failure, see test/parser.spec.ts:120. + +## Decisions worth knowing +- **`src/cache.ts:10`** — decision: chose LRU over LFU because... (PR #38) + +## All notes by file +### `src/auth.ts` +- L42 — instruction (high) [legacy-abi]: Never bypass... +- L88 — context: Token refresh runs every 5 min... +``` + +Critical-first ordering ensures an agent can never miss high-priority guidance. Handoffs come second because they're time-sensitive. + +#### 5.2.3 Regeneration + +- Triggered from `noteManager` after every save/delete and on workspace startup if missing. +- Single debounced writer (200ms) coalesces bursts. +- Atomic write: `INDEX.json.tmp` → rename. Same for `AGENTS.md`. +- Manual command: `Code Notes: Regenerate Exports`. + +#### 5.2.4 Configuration + +```jsonc +"codeContextNotes.exports": { + "enabled": true, + "indexJson": true, + "agentsMarkdown": true, + "expandedDigest": false // include full content in AGENTS.md, not just summaries +} +``` + +#### 5.2.5 Trade-offs + +- `AGENTS.md` is generated content but lives in the workspace. Teams who don't want it in git should `.gitignore` it. Documented; not auto-added. +- Two files instead of one (JSON + Markdown) because humans don't read JSON well and a 50KB JSON blob in `AGENTS.md` would defeat its purpose. + +### 5.3 MCP server (v0.4) + +Separate npm package `@jnahian/code-notes-mcp`. Reads/writes the same `.code-notes/` directory the extension uses — sync via filesystem, no IPC. + +#### 5.3.1 Why a separate package + +- Adoption isn't gated on installing the VS Code extension. +- Independent release cadence; MCP protocol churn doesn't force extension releases. +- Smaller surface to test in isolation. + +The extension's `noteManager` and `storageManager` get extracted into a shared `code-notes-core` package consumed by both. This is the one structural refactor v0.4 requires. + +#### 5.3.2 Configuration (in agent's MCP config) + +```json +{ + "mcpServers": { + "code-notes": { + "command": "npx", + "args": ["-y", "@jnahian/code-notes-mcp", "--workspace", "${workspaceFolder}", "--agent", "claude-code"] + } + } +} +``` + +`--workspace` is required (no implicit cwd guessing — too easy to point at the wrong directory). `--agent` is required for write access; without it the server starts in read-only mode. + +#### 5.3.3 Tools (read) + +| Tool | Purpose | +|---|---| +| `search_notes(query, type?, tags?, file?, includeExpired?)` | Full-text + structured filter. Ranked list with previews. | +| `get_notes_for_file(file, includeScopeMatches?)` | All notes attached to file plus directory-scoped notes that apply. Sorted by line. | +| `get_notes_for_changes(files[] \| diff)` | **Killer tool.** Pass files an agent is about to edit (or a unified diff); returns notes whose line ranges overlap edited regions, plus all `instruction`/`warning` notes scoped to file/dir. | +| `list_instructions(scope?)` | All `instruction` and `warning` notes ranked by priority. The "load this first" call. | +| `get_note(id)` | Full note with history and references. | +| `get_handoffs(stale?)` | Open handoffs (defaults to non-expired). For resuming work. | + +#### 5.3.4 Tools (write) + +All writes respect the trust model (§5.4). The MCP server doesn't enforce trust itself — it calls into `code-notes-core`, which routes based on the workspace setting. + +| Tool | Purpose | +|---|---| +| `create_note(file, lineRange, content, type, tags?, scope?, references?, priority?, expiresAt?)` | Standard create. | +| `edit_note(id, content)` | Standard edit; history preserved. | +| `delete_note(id)` | Soft delete. | +| `add_handoff(file, lineRange, content, references?)` | Convenience: type=handoff with sensible defaults. | +| `add_decision(file, lineRange, content, references[])` | Convenience: type=decision; references required. | + +#### 5.3.5 Resources + +- `code-notes://digest` → contents of `AGENTS.md` +- `code-notes://index` → contents of `INDEX.json` +- `code-notes://file/{path}` → all notes for a file as one rendered markdown doc + +Agents that support resources can auto-load the digest at session start. Tools cover on-demand operations. + +#### 5.3.6 Identity & attribution + +The `--agent` arg's value is written to `author` and triggers `authorType: 'agent'`. Without it, write tools refuse and return a structured error. Prevents anonymous agent writes from polluting the audit trail. + +#### 5.3.7 Trade-offs + +1. **Filesystem-as-IPC.** Atomic file rename + per-note advisory lock files (`.code-notes/.locks/`) avoid lost updates. Cheap; not as bulletproof as a database. Acceptable because note writes are infrequent. +2. **No streaming.** `get_notes_for_changes` against a giant diff caps results at 100 with `truncated: true`. Agents pass narrower file lists if they need more. +3. **No semantic search in v0.4.** Full-text only. + +### 5.4 Trust model & audit (v0.5) + +Single workspace setting picks the policy. Same code path handles all three modes; the difference is what happens after `code-notes-core` decides "this is an agent write." + +```jsonc +"codeContextNotes.agentWriteMode": "audit" // "direct" | "queue" | "audit", default "audit" +``` + +Detection: a write is an "agent write" iff the caller passed `authorType: 'agent'` (only the MCP server does this). No author-string sniffing. + +#### 5.4.1 Mode: `direct` + +Agent writes go straight to `.code-notes//.md` like a human write. The note's `authorType: 'agent'` makes it filterable in sidebar and digest. Nothing else changes. *Use when:* solo dev with a trusted agent. + +#### 5.4.2 Mode: `audit` (default) + +Same as `direct`, but every agent operation appends one line to `.code-notes/_audit.log` (JSONL): + +```jsonl +{"ts":"2026-05-15T12:34:56Z","op":"create","noteId":"note-abc","agent":"claude-code","file":"src/foo.ts","lineRange":[12,18],"type":"handoff"} +{"ts":"2026-05-15T12:35:10Z","op":"edit","noteId":"note-abc","agent":"claude-code","prevContentHash":"sha256:...","newContentHash":"sha256:..."} +``` + +Sidebar gains an **"Agent activity"** section showing the last 50 ops: +- Inline **Revert** button (re-applies prior content / restores deleted note from history) +- **Open note** to jump to it +- **Mute agent for this workspace** quick action (toggles to `queue`) + +*Use when:* most teams. Doesn't block work; gives humans one place to spot-check. + +#### 5.4.3 Mode: `queue` + +Agent writes don't touch live notes. The MCP server writes a *proposal* to `.code-notes/_pending/.md`: + +```markdown +--- +proposalId: prop-xyz +op: create | edit | delete +targetNoteId: note-abc # for edit/delete +file: src/foo.ts +lineRange: [12, 18] +agent: claude-code +proposedAt: 2026-05-15T12:34:56Z +--- +{proposed content} +``` + +Sidebar surfaces **"Pending agent proposals"** badge with Approve / Reject / Edit-then-approve actions. On approve: applied as if a human did it (`author` is the approver, `authorType: 'agent'`, `approvedBy: ` added). On reject: moved to `.code-notes/_pending/.rejected/` for audit. + +*Use when:* shared codebases, regulated environments, first-time agent assistance. + +#### 5.4.4 What the MCP server returns + +In `audit`/`direct`: write tools return the new/updated note. In `queue`: + +```json +{ "status": "pending", "proposalId": "prop-xyz", "message": "Awaiting human approval." } +``` + +Documented in MCP tool descriptions so agents don't loop retrying. + +#### 5.4.5 Reverting in audit mode + +Every `_audit.log` entry carries enough state to reverse the op: +- `create` → reverse is `delete` +- `edit` → reverse is restore from `history[]` +- `delete` → reverse is restore from `history[]` + +The Revert button calls existing `noteManager` methods. No new storage primitives needed. + +#### 5.4.6 Settings UI + +``` +Code Context Notes › Agent write mode: [direct | queue | audit ▼] +Code Context Notes › Agent allow-list: [claude-code, cursor-agent] +Code Context Notes › Audit log retention: [last 1000 ops] +``` + +Allow-list is *informational* (rejects writes where `--agent` isn't in the list), not a security boundary. + +#### 5.4.7 Trade-offs + +1. **`queue` mode requires a sidebar UI** that doesn't exist today. Biggest single chunk of v0.5 work. +2. **Mode switches mid-session** can leave orphaned proposals. One-time prompt: "You have N pending proposals — review them or move to `.rejected/`?" +3. **`_audit.log` grows unbounded** without retention. Default cap: last 1000 ops; older lines rotate to `_audit.log.1`. Configurable. +4. **No git integration for revert.** Revert restores extension-tracked state but doesn't make a git commit. Documented. + +### 5.5 Agent-killer features + +Five features that make this *worth* an agent's attention. + +#### 5.5.1 `get_notes_for_changes` — pre-edit context loading + +The single highest-leverage tool. + +**Input shapes:** +```ts +{ files: ["src/auth.ts", "src/db/user.ts"] } +// or +{ diff: "" } +``` + +**Ranking:** +1. Notes whose line range overlaps changed lines (highest signal) +2. `instruction`/`warning` notes scoped to the file or any parent directory +3. `decision` notes on the file +4. Other notes on the file +5. Open `handoff` notes on the file + +**Filtering:** expired notes excluded by default; `priority: 'critical'` always included. + +**Why killer:** the agent's biggest gap is missing context that lives outside the file (PR comments, Slack threads, an engineer's head). Notes attached to specific lines, surfaced *before* edit, fills it. + +#### 5.5.2 Scoped instructions + +A note with `type: instruction` and `scope: directory` becomes a binding rule for that subtree. Example: a note on `src/migrations/` saying *"All migrations must be reversible; see PR #42"* — every agent edit anywhere under `src/migrations/` includes this in `get_notes_for_changes`. + +**Resolution:** walk up the directory tree collecting all directory-scoped notes. Closer ancestors rank higher. Cheap — runs against `INDEX.json.byFile` keys. + +**UI in v0.3:** "Add note" on a folder defaults to `scope: directory`. Editor-driven creation defaults to `line`. + +#### 5.5.3 Handoffs + +`type: handoff` is the type agents write most often. + +```markdown +--- +type: handoff +priority: normal +author: claude-code +authorType: agent +references: + - { kind: test, value: "test/parser.spec.ts:120" } +expiresAt: 2026-05-22T00:00:00Z # 7 days from creation by default +--- +Stopped here: tokenizer drops trailing whitespace in raw strings. +Tried: lookahead regex (broke fenced code), state machine for ws (worked locally, failed CI). +Next: try ANTLR grammar from `vendor/lang/`. +``` + +`add_handoff` tool fills these defaults; the agent only supplies `file`, `lineRange`, `content`, optionally `references`. + +**Surfacing:** AGENTS.md hoists open handoffs to section 2 (after critical instructions). `get_handoffs` returns sorted by recency. New "Resume" CodeLens above any line with an active handoff. + +**Expiry:** default 7 days. Stale handoffs hidden from MCP responses (unless `stale: true`); muted in sidebar with Dismiss / Renew. + +#### 5.5.4 Decisions with first-class references + +`type: decision` requires at least one reference. `add_decision` enforces this; manual creation shows "Reference required" validation. + +**Reference rendering:** +- `kind: pr` / `kind: issue` → GitHub/GitLab link if `git remote origin` parseable; no-op otherwise. +- `kind: commit` → `git show` link via VS Code's git extension; remote link if available. +- `kind: test` → `file:line` link openable in editor. +- `kind: note` → deep link to another note in the sidebar. + +#### 5.5.5 Expiry, priority, staleness + +- **`expiresAt`** with type-specific defaults (handoffs 7d, todos 30d, others never). +- **`priority`** — `critical` always surfaces; `low` suppressed from `AGENTS.md` summary (still in `INDEX.json`). +- **Sidebar filter** — "Hide expired" on by default; toggle to show muted. + +Combined: `get_notes_for_changes` returns *fewer, fresher, higher-signal* notes by default. + +#### 5.5.6 Trade-offs + +1. **Diff parsing** uses the `diff` npm package (~12KB). Acceptable. +2. **PR/commit references** assume Git. Non-Git workspaces store but don't render as links. +3. **Auto-expiry defaults are opinionated.** Override per-note and globally via `codeContextNotes.expiry.{type}`. Expired notes hide, not delete. +4. **Handoff "Resume" CodeLens** could visually conflict with the existing note CodeLens. Proposal: merge into one label like *"2 notes • 1 handoff"*. + +## 6. Migration + +Lazy by design. No batch script. + +### 6.1 What migration means + +1. **On read:** missing fields filled with defaults *in memory*. On-disk file untouched. +2. **On write:** new full-shape frontmatter emitted with defaults. Note "upgraded" on disk. +3. **On export regeneration:** all notes appear with effective fields regardless of on-disk shape. + +Workspace can sit on v0.3 indefinitely; only edited notes get rewritten. No mass diff in `git status` after upgrade. + +### 6.2 Defaults applied on read + +| Field | Default for legacy notes | +|---|---| +| `type` | `context` | +| `scope` | `line` | +| `tags` | `[]` | +| `references` | `[]` | +| `priority` | `normal` | +| `expiresAt` | `null` | +| `authorType` | `human` | + +Conservative — no legacy note suddenly becomes an `instruction` or gets an expiry. + +### 6.3 Frontmatter format + +Writer omits any field equal to its default. A plain note that's still just a `context` note on a line range stays the shape it always had. + +### 6.4 Backward compatibility + +A v0.2.x install opening a workspace with v0.3 notes: unknown frontmatter keys are ignored by the YAML loader. Note loads with its original fields. `INDEX.json`/`AGENTS.md` ignored. No data loss either direction. Mixed v0.2.x/v0.3 installs across a team are fine without coordination. + +### 6.5 MCP server's view of legacy notes + +By v0.4, v0.3 has shipped. MCP treats legacy notes (no `type`) as `context`. `list_instructions` and `get_handoffs` won't surface them unless re-saved with the right type. + +Documented quick-start: *"Tag your most important notes"* — open new sidebar filter, find `instruction`-y notes, re-save with `type: instruction`. One-time curation, minutes per workspace. + +### 6.6 Trade-offs + +1. **No bulk-tag tool in v0.3.** Edit one at a time. Add bulk action in v0.5 if curation friction is real. +2. **`scope: directory` notes can't exist before v0.3.** Opt-in *new* note kind, not a migration of existing ones. +3. **First `AGENTS.md` generation** can be slow on 1000+ note workspaces (still under a second on a midrange laptop). Documented; one-time progress notification. +4. **No downgrade tool.** Rollback users keep using v0.2.x; new fields ignored. + +## 7. Error handling & edge cases + +### 7.1 Concurrent writes between extension and MCP server + +Per-note advisory locks: `.code-notes/.locks/.lock` containing `{pid, ts, holder}`. Acquired before read-modify-write; released after rename. Retries up to 500ms. + +- **Extension on lock failure:** non-blocking notification *"Note is being edited by another process. Try again."* No data overwrite. +- **MCP server on lock failure:** returns `{ error: "lock_timeout", retryable: true }`. +- **Stale lock** (>60s old): forcibly broken; warning logged to audit. + +### 7.2 Export regeneration failures + +Note write succeeds (already committed). Export is best-effort: +- Failure logs to extension's output channel and `_audit.log`. +- Sidebar shows badge: *"Exports stale (last successful: 2 min ago)"*. +- Manual `Code Notes: Regenerate Exports` retries. +- MCP server reading stale `INDEX.json` notices via `generatedAt` vs. newest note's `updatedAt`; falls back to scanning `.code-notes/` directly. + +Exports are a cache, not a source of truth. Markdown files are authoritative. + +### 7.3 Malformed notes + +- Loader catches parse error per file. Bad note excluded from in-memory state. +- Sidebar shows file with "1 unreadable note" badge and "Show errors" action. +- `INDEX.json` includes top-level `errors: [{file, message}]` array. +- Unknown `type`/`scope` values: log warning, treat as default. Don't drop the whole note. + +### 7.4 MCP server on a non-workspace path + +- Starts in **empty mode** — read tools return empty, write tools succeed (creates `.code-notes/` on first write). +- One-time log: *"No existing notes found at \."* +- `--require-existing` flag refuses startup with clear error (for CI checks). + +### 7.5 Diff parsing in `get_notes_for_changes` + +- Malformed diff: `{ error: "diff_parse_failed", detail: "..." }`. Agent falls back to `files[]`. +- Out-of-workspace files: silently filtered. `appliedFiles[]` tells the agent what matched. +- Renamed files: matched against both old and new paths. Notes follow the file via existing `contentHash` mechanism on next save. + +### 7.6 Queue mode with stale targets + +- Target edited between proposal and approve: detect via `prevContentHash` mismatch. Sidebar shows 3-way diff (original / proposed / current). Human picks or merges. +- Target deleted: proposal flagged orphaned, surfaced in "Orphaned proposals" subsection. Convert to new note or reject. + +**Trade-off:** v0.5 ships with simple-pick (your version vs. theirs); add 3-way merge if conflicts happen in practice. + +### 7.7 Audit log corruption + +- Reader is line-by-line tolerant: skip unparseable lines, log single warning at startup with count. +- "Agent activity" view shows fewer entries; no crash. +- `Code Notes: Truncate Audit Log` command for manual recovery. + +### 7.8 Symbol resolution failures (`scope: function | class`) + +- Note loads with its `lineRange` as fallback. +- Sidebar shows "Symbol not found" indicator with "Reattach" action (pick new symbol or convert to `scope: line`). +- MCP responses include `scopeStatus: "stale" | "ok"`. + +### 7.9 Trade-offs + +1. **Lock files in `.code-notes/.locks/`** add directory entries inside storage. `.gitignore` by default; documented. +2. **3-way merge UI** is real surface area; ship simple-pick first. +3. **Audit log line-skip on parse error** silently loses information; alternative (refuse to load) is worse UX. + +## 8. Testing strategy + +### 8.1 Test layout + +``` +src/test/ # extension tests (existing, extended) +packages/code-notes-core/test/ # NEW — extracted core +packages/code-notes-mcp/test/ # NEW — MCP server +``` + +Extension keeps `@vscode/test-electron`. Core and MCP packages run under `vitest` (fast, no VS Code shim). + +### 8.2 What each layer owns + +**`code-notes-core` (bulk of new tests):** +- Schema parsing: legacy round-trip, new round-trip, unknown fields preserved, malformed YAML +- Migration: read-then-write of legacy note produces new shape with no spurious changes +- Lock manager: happy path, timeout, stale recovery +- Export generator: deterministic `INDEX.json` output (order-stable, sorted indexes); `AGENTS.md` formatting snapshots +- Trust router: setting + write call → correct path (direct/queue/audit) +- Reference resolution: each kind, absent git remote degrades cleanly + +**Extension (v0.3 + v0.5 surfaces):** +- Schema-aware sidebar: type filter, expired filter, "Hide expired" default +- Handoff "Resume" CodeLens with no visual conflict +- Settings UI writes correct setting keys +- Agent activity view (v0.5): renders audit log, Revert calls `noteManager` correctly +- Pending proposals view (v0.5): approve/reject/orphan handling + +**`code-notes-mcp`:** +- Tool I/O contracts: every tool's input/output schema (snapshot-tested) +- `get_notes_for_changes`: stable ranking, truncation at 100 with `truncated: true` +- Read/write split: no `--agent` ⇒ read-only +- Diff parsing: known-good diffs match expected line ranges; malformed input documented error shape +- Resource endpoints return on-disk content, not stale cache + +### 8.3 Cross-cutting integration tests + +Highest-value tests; catch coordination bugs: + +1. **Extension + MCP racing** on the same workspace. Spawn MCP in child process; overlapping writes; assert no lost updates and audit captures both ops in order. +2. **Migration round-trip across versions.** Fixture: v0.2.x `.code-notes/`. Load with v0.3 core, edit one note, assert on-disk shape matches spec exactly and untouched notes are unchanged on disk. +3. **`get_notes_for_changes` end-to-end.** Workspace with notes of every type/scope plus real unified diff. Assert returned list, ranking, truncation match the documented contract. +4. **Trust mode switching mid-session.** Queue-mode workspace with N pending proposals; flip to direct; assert prompt fires and proposals aren't silently lost. + +### 8.4 Fixtures + +`test/fixtures/workspaces/` per package: +- `legacy-v02-notes/` — migration tests +- `mixed-types/` — every type/scope/priority/expiry combination +- `large/` — 1000 generated notes for export-perf tests +- `corrupt/` — malformed frontmatter, broken locks, partial audit log + +Generators next to fixtures. + +### 8.5 Performance budgets + +Tracked via `npm run bench`, not gating tests: +- Export regen on 1000-note workspace: <500ms +- `get_notes_for_changes` on 1000 notes + 50-file diff: <100ms +- MCP server cold start: <300ms + +Regression in any of these is a release blocker. + +### 8.6 CI matrix + +- Node 18 / 20 / latest LTS +- VS Code stable + insiders +- macOS / Linux / Windows (explicit Windows test for path separators in `_audit.log`) + +### 8.7 What we deliberately don't test + +- **Real LLM agents.** No tests invoking Claude or Cursor against the MCP server. We test the protocol contract. +- **Marketplace publication.** Manual smoke-test post-publish per `RELEASE_TEMPLATE.md`. + +### 8.8 Trade-offs + +1. **Extracting `code-notes-core`** requires monorepo. Cheapest: npm workspaces. Three packages, one root `package.json`, one `tsconfig` base. Adopt in v0.4. +2. **MCP integration testing without a real agent** can miss ergonomic issues. Mitigation: dogfood with Claude Code during v0.4 development; capture friction in `MCP_FEEDBACK.md`. +3. **Performance budgets aspirational** without v0.2.x baselines. Establish in v0.3 before setting thresholds in v0.4. + +## 9. Open questions + +These are intentionally unresolved, to be decided during implementation planning or later: + +- **Bulk re-tagging UX in v0.5.** Hold until users hit curation friction. +- **Embedding/semantic search.** Where do embeddings live? What model? v0.6+. +- **Auto-handoff on session end.** Should an agent auto-create a handoff when its session ends mid-task? Convention vs. enforced? +- **Cross-workspace notes.** Notes that follow code across repos (e.g., monorepo splits). Out of scope. +- **PR/issue auto-link patterns.** Today we'd parse `origin` URL; what about non-GitHub remotes (Bitbucket, GitLab self-hosted)? + +## 10. Roadmap & ordering + +| Release | Scope | Time-to-first-value for | Blocking deps | +|---|---|---|---| +| v0.3 | Schema + Exports + sidebar updates | Any file-reading agent | None | +| v0.4 | MCP server + `code-notes-core` extraction | MCP-capable agents (Claude Code, Cursor) | v0.3 (stable schema) | +| v0.5 | Trust model + audit/queue UI | Teams adopting agent assistance | v0.4 (write tools exist) | + +Each release is independent enough to defer or accelerate based on feedback. + +--- + +*End of design.* From e35e4a78d7fa70b57a875e0132a0b56ffdab0685 Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Fri, 15 May 2026 14:30:13 +0600 Subject: [PATCH 03/31] =?UTF-8?q?=F0=9F=93=9D=20docs:=20add=20v0.3=20imple?= =?UTF-8?q?mentation=20plan=20(schema=20+=20exports)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 17 bite-sized TDD tasks covering: structured Note schema (type, scope, tags, priority, references, expiry, authorType), debounced atomic INDEX.json + AGENTS.md generation, sidebar type filter, configuration settings, changelog and version bump. Notes two deviations from spec discovered during planning: existing storage is bold-label markdown (not YAML frontmatter) and the layout is flat (.code-notes/.md). Plan extends both rather than fighting them. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ent-integration-v0.3-schema-and-exports.md | 1718 +++++++++++++++++ 1 file changed, 1718 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-15-agent-integration-v0.3-schema-and-exports.md diff --git a/docs/superpowers/plans/2026-05-15-agent-integration-v0.3-schema-and-exports.md b/docs/superpowers/plans/2026-05-15-agent-integration-v0.3-schema-and-exports.md new file mode 100644 index 0000000..57e12ea --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-agent-integration-v0.3-schema-and-exports.md @@ -0,0 +1,1718 @@ +# Agent Integration v0.3 — Schema + Exports — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend the Note schema with optional structured fields (type, scope, tags, references, expiry, priority, authorType) and auto-generate `.code-notes/INDEX.json` and `.code-notes/AGENTS.md` on every note change, so any file-reading coding agent can consume notes as workspace context. + +**Architecture:** Lazy migration — legacy notes load with sensible defaults; on-disk shape upgrades only on next edit. New fields written as additional `**Field:**` lines in the existing bold-label markdown format (NOT YAML frontmatter — see deviation note below). Exports regenerated through a single debounced atomic writer hooked into `NoteManager`'s existing `noteChanged` event. + +**Tech Stack:** TypeScript, VS Code Extension API, esbuild build, Mocha tests via `@vscode/test-electron`. + +**Spec reference:** `docs/superpowers/specs/2026-05-15-agent-integration-design.md` sections 5.1, 5.2, 6. + +## Deviations from spec + +The spec said "stored in the markdown file's frontmatter (already used for metadata)." The actual existing format in `src/storageManager.ts` is **bold-label markdown** (`**File:** ...`, `**Author:** ...`), not YAML frontmatter. This plan extends the existing bold-label format because: +- The v0.2.x parser already silently ignores unknown bold-label lines, so forward-compat works the same way. +- Avoids maintaining two formats in parallel. +- `INDEX.json` is the machine-readable artifact for tools/agents — the on-disk note format only needs to round-trip with the parser. + +Storage layout is **flat** (`.code-notes/.md`), not nested per-file as the spec example suggested. `INDEX.json.contentPath` will reflect this. + +## File Structure + +| File | Status | Responsibility | +|---|---|---| +| `src/types.ts` | Modify | Add `NoteType`, `NoteScope`, `NoteReference` types; extend `Note` interface with new optional fields. | +| `src/noteDefaults.ts` | Create | Single function `applyDefaults(note)` that fills missing optional fields. Used on read. | +| `src/storageManager.ts` | Modify | Extend `noteToMarkdown` to emit new fields (omitted if equal to default); extend `markdownToNote` to parse them. | +| `src/exportGenerator.ts` | Create | Pure functions `buildIndex(notes)` → JSON-serializable object and `buildDigest(notes)` → markdown string. | +| `src/exportWriter.ts` | Create | Debounced atomic writer. Owns the debounce timer, the temp-file-and-rename logic, and the "Regenerate Exports" command implementation. | +| `src/extension.ts` | Modify | Wire `exportWriter` to `noteManager.on('noteChanged', ...)`; register `codeContextNotes.regenerateExports` command; trigger initial export on activation if missing. | +| `src/notesSidebarProvider.ts` | Modify | Render type pill on note tree items; add type and "show expired" filter state. | +| `src/noteTreeItem.ts` | Modify | Show type as a description suffix or tooltip element. | +| `package.json` | Modify | Add `codeContextNotes.exports.*` settings; register `codeContextNotes.regenerateExports` command + menu entry. | +| `src/test/suite/storageManager.test.ts` | Modify | Add tests for new fields round-tripping and legacy-note default behavior. | +| `src/test/suite/exportGenerator.test.ts` | Create | Unit tests for `buildIndex` and `buildDigest`. | +| `src/test/suite/exportWriter.test.ts` | Create | Tests for debounce, atomic write, error recovery. | +| `src/test/suite/noteDefaults.test.ts` | Create | Tests that `applyDefaults` produces correct values. | +| `docs/changelogs/v0.3.0.md` | Create | Per project changelog template. | +| `web/src/pages/ChangelogPage.tsx` | Modify | Add v0.3 entry per `web/CHANGELOG_WEB_GUIDE.md` (or use the `add-web-changelog` skill). | + +--- + +## Task 1: Add new schema types and fields + +**Files:** +- Modify: `src/types.ts` +- Test: (no test in this task — type-only changes verified by compilation; behavior tested in Task 2) + +- [ ] **Step 1: Add new type definitions and extend `Note`** + +Open `src/types.ts` and add the following after the existing `NoteAction` type (around line 14): + +```typescript +/** + * Type of note — drives prioritization in agent-facing exports + */ +export type NoteType = + | 'context' // default: explanatory background + | 'instruction' // directive: agent/human MUST follow + | 'warning' // hazard: don't do X + | 'decision' // architectural decision + rationale + | 'todo' // outstanding work + | 'handoff' // "next session pick up here" (often agent-authored) + | 'rationale'; // why this code exists (links to PRs/commits) + +/** + * Scope of applicability for a note + */ +export type NoteScope = + | 'line' // default — current behavior + | 'function' + | 'class' + | 'file' + | 'directory'; + +/** + * Author kind — explicit agent attribution + */ +export type AuthorType = 'human' | 'agent'; + +/** + * Priority used for digest ordering and `critical` always-include behavior + */ +export type NotePriority = 'low' | 'normal' | 'high' | 'critical'; + +/** + * Reference to an external artifact (PR, commit, test, etc.) + */ +export interface NoteReference { + kind: 'note' | 'pr' | 'issue' | 'commit' | 'test' | 'url'; + value: string; + label?: string; +} +``` + +Then extend the existing `Note` interface (around line 36) by appending these optional fields just before the closing brace: + +```typescript + // NEW (all optional for back-compat — see noteDefaults.applyDefaults) + type?: NoteType; + tags?: string[]; + scope?: NoteScope; + references?: NoteReference[]; + expiresAt?: string; // ISO 8601 + authorType?: AuthorType; + priority?: NotePriority; +``` + +- [ ] **Step 2: Verify the project still compiles** + +Run: `npm run compile:tsc` +Expected: Exit code 0; no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/types.ts +git commit -m "✨ feat: add structured schema types to Note interface + +Adds optional NoteType, NoteScope, NoteReference, AuthorType, NotePriority +fields. All optional for backward compatibility — defaults applied on read +in a follow-up task." +``` + +--- + +## Task 2: Create the defaults helper + +**Files:** +- Create: `src/noteDefaults.ts` +- Create: `src/test/suite/noteDefaults.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/test/suite/noteDefaults.test.ts`: + +```typescript +import * as assert from 'assert'; +import { applyDefaults, isExpired } from '../../noteDefaults.js'; +import { Note } from '../../types.js'; + +const baseNote: Note = { + id: 'n1', + content: 'hi', + author: 'alice', + filePath: '/abs/foo.ts', + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:abc', + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', + history: [], +}; + +suite('noteDefaults', () => { + test('applyDefaults fills all optional fields on a legacy note', () => { + const filled = applyDefaults({ ...baseNote }); + assert.strictEqual(filled.type, 'context'); + assert.strictEqual(filled.scope, 'line'); + assert.deepStrictEqual(filled.tags, []); + assert.deepStrictEqual(filled.references, []); + assert.strictEqual(filled.priority, 'normal'); + assert.strictEqual(filled.authorType, 'human'); + assert.strictEqual(filled.expiresAt, undefined); + }); + + test('applyDefaults preserves explicitly set fields', () => { + const filled = applyDefaults({ + ...baseNote, + type: 'instruction', + priority: 'high', + tags: ['security'], + }); + assert.strictEqual(filled.type, 'instruction'); + assert.strictEqual(filled.priority, 'high'); + assert.deepStrictEqual(filled.tags, ['security']); + assert.strictEqual(filled.scope, 'line'); // still defaulted + }); + + test('isExpired returns false when expiresAt is undefined', () => { + assert.strictEqual(isExpired(baseNote, new Date('2099-01-01')), false); + }); + + test('isExpired returns true when expiresAt is in the past', () => { + const n = { ...baseNote, expiresAt: '2026-04-01T00:00:00Z' }; + assert.strictEqual(isExpired(n, new Date('2026-05-01T00:00:00Z')), true); + }); + + test('isExpired returns false when expiresAt is in the future', () => { + const n = { ...baseNote, expiresAt: '2026-06-01T00:00:00Z' }; + assert.strictEqual(isExpired(n, new Date('2026-05-01T00:00:00Z')), false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails (file doesn't exist)** + +Run: `npm run test:unit` +Expected: Compile error or test failure: `Cannot find module '../../noteDefaults.js'`. + +- [ ] **Step 3: Write the implementation** + +Create `src/noteDefaults.ts`: + +```typescript +/** + * Apply schema defaults to a Note loaded from disk. + * Legacy notes from v0.2.x have no structured fields; this fills them + * in memory only. The on-disk file is not modified. + */ + +import { Note, NoteType, NoteScope, AuthorType, NotePriority } from './types.js'; + +export const NOTE_DEFAULTS = { + type: 'context' as NoteType, + scope: 'line' as NoteScope, + tags: [] as string[], + references: [] as never[], // typed as never — replaced via applyDefaults + priority: 'normal' as NotePriority, + authorType: 'human' as AuthorType, +}; + +/** + * Returns a new Note with all optional fields populated. + * Does not mutate the input. + */ +export function applyDefaults(note: Note): Note { + return { + ...note, + type: note.type ?? NOTE_DEFAULTS.type, + scope: note.scope ?? NOTE_DEFAULTS.scope, + tags: note.tags ?? [], + references: note.references ?? [], + priority: note.priority ?? NOTE_DEFAULTS.priority, + authorType: note.authorType ?? NOTE_DEFAULTS.authorType, + }; +} + +/** + * True if `expiresAt` is set and is at or before `now`. + */ +export function isExpired(note: Note, now: Date = new Date()): boolean { + if (!note.expiresAt) return false; + return new Date(note.expiresAt).getTime() <= now.getTime(); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npm run test:unit` +Expected: All 5 tests in `noteDefaults` suite pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/noteDefaults.ts src/test/suite/noteDefaults.test.ts +git commit -m "✨ feat: add applyDefaults helper for legacy notes + +Lazily fills missing optional Note fields in memory. The on-disk file +is unchanged until the note is next saved (lazy migration)." +``` + +--- + +## Task 3: Extend storage to read new fields + +**Files:** +- Modify: `src/storageManager.ts:248-359` (the `markdownToNote` method) +- Test: `src/test/suite/storageManager.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `src/test/suite/storageManager.test.ts` (inside the existing `suite('StorageManager', ...)` block): + +```typescript +test('markdownToNote parses new structured fields when present', async () => { + const markdown = `# Code Context Note + +**File:** /abs/foo.ts +**Lines:** 1-1 +**Content Hash:** sha256:abc + +## Note: n1 +**Author:** alice +**Created:** 2026-05-01T00:00:00Z +**Updated:** 2026-05-01T00:00:00Z +**Type:** instruction +**Scope:** function +**Priority:** high +**Tags:** security, legacy +**AuthorType:** human +**ExpiresAt:** 2026-12-01T00:00:00Z +**References:** [{"kind":"pr","value":"#42"}] + +## Current Content + +Do not bypass. +`; + // Access the private parser via reflection for the test + const sm: any = new (await import('../../storageManager.js')).StorageManager('/tmp'); + const note = sm.markdownToNote(markdown); + assert.ok(note); + assert.strictEqual(note!.type, 'instruction'); + assert.strictEqual(note!.scope, 'function'); + assert.strictEqual(note!.priority, 'high'); + assert.deepStrictEqual(note!.tags, ['security', 'legacy']); + assert.strictEqual(note!.authorType, 'human'); + assert.strictEqual(note!.expiresAt, '2026-12-01T00:00:00Z'); + assert.deepStrictEqual(note!.references, [{ kind: 'pr', value: '#42' }]); +}); + +test('markdownToNote leaves new fields undefined for legacy notes', async () => { + const markdown = `# Code Context Note + +**File:** /abs/foo.ts +**Lines:** 1-1 +**Content Hash:** sha256:abc + +## Note: n1 +**Author:** alice +**Created:** 2026-05-01T00:00:00Z +**Updated:** 2026-05-01T00:00:00Z + +## Current Content + +Hi. +`; + const sm: any = new (await import('../../storageManager.js')).StorageManager('/tmp'); + const note = sm.markdownToNote(markdown); + assert.ok(note); + assert.strictEqual(note!.type, undefined); + assert.strictEqual(note!.scope, undefined); + assert.strictEqual(note!.tags, undefined); +}); +``` + +- [ ] **Step 2: Run tests to confirm failures** + +Run: `npm run test:unit` +Expected: Both new tests fail (parser doesn't read these fields). + +- [ ] **Step 3: Extend `markdownToNote` to parse new fields** + +In `src/storageManager.ts`, inside the `for` loop in `markdownToNote` (around line 260), add the following branches *before* the existing `else if (line === '## Current Content')` branch: + +```typescript + else if (line.startsWith('**Type:**')) { + note.type = line.substring(9).trim() as any; + } + else if (line.startsWith('**Scope:**')) { + note.scope = line.substring(10).trim() as any; + } + else if (line.startsWith('**Priority:**')) { + note.priority = line.substring(13).trim() as any; + } + else if (line.startsWith('**Tags:**')) { + const raw = line.substring(9).trim(); + note.tags = raw ? raw.split(',').map(t => t.trim()).filter(t => t.length > 0) : []; + } + else if (line.startsWith('**AuthorType:**')) { + note.authorType = line.substring(15).trim() as any; + } + else if (line.startsWith('**ExpiresAt:**')) { + note.expiresAt = line.substring(14).trim(); + } + else if (line.startsWith('**References:**')) { + const raw = line.substring(15).trim(); + if (raw) { + try { + note.references = JSON.parse(raw); + } catch { + // Malformed references — log and skip; don't drop the whole note. + console.warn(`[code-notes] Failed to parse References for note: ${raw}`); + note.references = []; + } + } + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:unit` +Expected: Both new parser tests pass; existing tests still pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/storageManager.ts src/test/suite/storageManager.test.ts +git commit -m "✨ feat(storage): parse structured fields from note markdown + +Extends markdownToNote to read Type/Scope/Priority/Tags/AuthorType/ +ExpiresAt/References. Legacy notes without these fields parse exactly +as before (fields remain undefined; defaults applied at NoteManager +boundary in a follow-up task)." +``` + +--- + +## Task 4: Extend storage to write new fields + +**Files:** +- Modify: `src/storageManager.ts:197-243` (the `noteToMarkdown` method) +- Test: `src/test/suite/storageManager.test.ts` + +- [ ] **Step 1: Write the failing test** + +Append to `src/test/suite/storageManager.test.ts`: + +```typescript +test('noteToMarkdown emits structured fields when set', async () => { + const sm: any = new (await import('../../storageManager.js')).StorageManager('/tmp'); + const md = sm.noteToMarkdown({ + id: 'n1', + content: 'do not refactor', + author: 'alice', + filePath: '/abs/foo.ts', + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:abc', + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', + history: [], + type: 'instruction', + scope: 'function', + priority: 'high', + tags: ['security'], + authorType: 'human', + expiresAt: '2026-12-01T00:00:00Z', + references: [{ kind: 'pr', value: '#42' }], + }); + assert.ok(md.includes('**Type:** instruction')); + assert.ok(md.includes('**Scope:** function')); + assert.ok(md.includes('**Priority:** high')); + assert.ok(md.includes('**Tags:** security')); + assert.ok(md.includes('**AuthorType:** human')); + assert.ok(md.includes('**ExpiresAt:** 2026-12-01T00:00:00Z')); + assert.ok(md.includes('**References:** [{"kind":"pr","value":"#42"}]')); +}); + +test('noteToMarkdown omits fields equal to defaults', async () => { + const sm: any = new (await import('../../storageManager.js')).StorageManager('/tmp'); + const md = sm.noteToMarkdown({ + id: 'n1', + content: 'hi', + author: 'alice', + filePath: '/abs/foo.ts', + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:abc', + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', + history: [], + type: 'context', // default + scope: 'line', // default + priority: 'normal', // default + tags: [], // default + authorType: 'human', // default + }); + assert.ok(!md.includes('**Type:**')); + assert.ok(!md.includes('**Scope:**')); + assert.ok(!md.includes('**Priority:**')); + assert.ok(!md.includes('**Tags:**')); + assert.ok(!md.includes('**AuthorType:**')); +}); +``` + +- [ ] **Step 2: Run tests to confirm failures** + +Run: `npm run test:unit` +Expected: Both new tests fail (writer doesn't emit these lines). + +- [ ] **Step 3: Extend `noteToMarkdown` to emit structured fields** + +In `src/storageManager.ts`, inside `noteToMarkdown` after the `**Updated:**` line and before the `if (note.isDeleted)` block (around line 213), insert: + +```typescript + // Structured fields — omitted if equal to default for compactness + if (note.type && note.type !== 'context') { + lines.push(`**Type:** ${note.type}`); + } + if (note.scope && note.scope !== 'line') { + lines.push(`**Scope:** ${note.scope}`); + } + if (note.priority && note.priority !== 'normal') { + lines.push(`**Priority:** ${note.priority}`); + } + if (note.tags && note.tags.length > 0) { + lines.push(`**Tags:** ${note.tags.join(', ')}`); + } + if (note.authorType && note.authorType !== 'human') { + lines.push(`**AuthorType:** ${note.authorType}`); + } + if (note.expiresAt) { + lines.push(`**ExpiresAt:** ${note.expiresAt}`); + } + if (note.references && note.references.length > 0) { + lines.push(`**References:** ${JSON.stringify(note.references)}`); + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:unit` +Expected: All `noteToMarkdown` tests pass; existing tests unchanged. + +- [ ] **Step 5: Commit** + +```bash +git add src/storageManager.ts src/test/suite/storageManager.test.ts +git commit -m "✨ feat(storage): emit structured fields in note markdown + +Extends noteToMarkdown to write Type/Scope/Priority/Tags/AuthorType/ +ExpiresAt/References. Fields equal to their default are omitted to +keep frontmatter compact for the common case (untouched legacy notes +remain shape-identical after a save)." +``` + +--- + +## Task 5: Apply defaults at the NoteManager boundary + +**Files:** +- Modify: `src/noteManager.ts` +- Test: `src/test/suite/noteManager.test.ts` + +The pattern: defaults are applied where notes leave storage (in `NoteManager`), so consumers of `NoteManager` (sidebar, exports, MCP server later) always see fully-populated notes. The on-disk file remains unchanged until the next save. + +- [ ] **Step 1: Identify the read methods to patch** + +Run: `grep -n "storage.loadNotes\|storage.loadAllNotes\|storage.loadNoteById" src/noteManager.ts` +Expected: List of call sites — these are the boundaries to patch. + +- [ ] **Step 2: Write the failing test** + +Append to `src/test/suite/noteManager.test.ts`: + +```typescript +test('getNotes applies defaults to legacy notes', async () => { + // This test assumes the existing test setup creates a NoteManager pointed + // at a temp workspace. Reuse the pattern from existing tests. + const noteManager = await createTestNoteManager(); // existing helper + + // Save a legacy-shaped note via storage directly (bypassing NoteManager + // so we don't auto-fill defaults). + await (noteManager as any).storage.saveNote({ + id: 'legacy-1', + content: 'old', + author: 'alice', + filePath: '/abs/x.ts', + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:legacy', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + history: [], + }); + + const notes = await noteManager.getNotesForFile('/abs/x.ts'); + const legacy = notes.find((n: any) => n.id === 'legacy-1'); + assert.ok(legacy); + assert.strictEqual(legacy!.type, 'context'); + assert.strictEqual(legacy!.scope, 'line'); + assert.deepStrictEqual(legacy!.tags, []); +}); +``` + +If `createTestNoteManager` doesn't exist, look at the top of `noteManager.test.ts` for the existing setup pattern and adapt. + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npm run test:unit` +Expected: `legacy.type` is `undefined`, not `'context'`. + +- [ ] **Step 4: Patch NoteManager to apply defaults on every read** + +At the top of `src/noteManager.ts`, add the import: + +```typescript +import { applyDefaults } from './noteDefaults.js'; +``` + +Find every `await this.storage.loadNotes(...)`, `await this.storage.loadAllNotes(...)`, and `await this.storage.loadNoteById(...)` call in the file. For each one that returns a value used downstream, wrap with `.map(applyDefaults)` (for arrays) or `applyDefaults(...)` (for single notes) before returning or storing in cache. + +Concretely (the calls visible in `grep` output from Step 1) — for each: +- Array results: `const notes = (await this.storage.loadNotes(filePath)).map(applyDefaults);` +- Single results: `const note = await this.storage.loadNoteById(id); return note ? applyDefaults(note) : null;` + +Make sure to also wrap notes pulled from `this.noteCache` if the cache is populated from raw storage anywhere (it isn't today — caches receive notes that already passed through this boundary — but verify by inspection). + +- [ ] **Step 5: Run all tests to verify** + +Run: `npm run test:unit` +Expected: New test passes; all existing tests still pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/noteManager.ts src/test/suite/noteManager.test.ts +git commit -m "✨ feat(noteManager): apply schema defaults on read + +Notes leaving NoteManager always have optional fields populated. Legacy +notes get defaults in memory; on-disk file unchanged until next save. +This is the single boundary where lazy migration happens." +``` + +--- + +## Task 6: Build the export generator (pure functions) + +**Files:** +- Create: `src/exportGenerator.ts` +- Create: `src/test/suite/exportGenerator.test.ts` + +- [ ] **Step 1: Write the failing test for `buildIndex`** + +Create `src/test/suite/exportGenerator.test.ts`: + +```typescript +import * as assert from 'assert'; +import { buildIndex, buildDigest } from '../../exportGenerator.js'; +import { Note } from '../../types.js'; + +const n = (overrides: Partial): Note => ({ + id: 'id', + content: 'content', + author: 'alice', + filePath: '/ws/src/foo.ts', + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:x', + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', + history: [], + type: 'context', + scope: 'line', + tags: [], + references: [], + priority: 'normal', + authorType: 'human', + ...overrides, +}); + +suite('exportGenerator', () => { + test('buildIndex includes notes, byFile, byType, byTag', () => { + const notes = [ + n({ id: 'a', filePath: '/ws/src/foo.ts', type: 'instruction', tags: ['security'] }), + n({ id: 'b', filePath: '/ws/src/bar.ts', type: 'context' }), + ]; + const idx = buildIndex(notes, '/ws'); + assert.strictEqual(idx.version, 1); + assert.strictEqual(idx.workspaceRoot, '/ws'); + assert.strictEqual(idx.notes.length, 2); + assert.deepStrictEqual(idx.byFile['src/foo.ts'], ['a']); + assert.deepStrictEqual(idx.byFile['src/bar.ts'], ['b']); + assert.deepStrictEqual(idx.byType['instruction'], ['a']); + assert.deepStrictEqual(idx.byType['context'], ['b']); + assert.deepStrictEqual(idx.byTag['security'], ['a']); + }); + + test('buildIndex marks expired notes', () => { + const past = new Date('2025-01-01T00:00:00Z').toISOString(); + const idx = buildIndex( + [n({ id: 'a', expiresAt: past })], + '/ws', + new Date('2026-05-01T00:00:00Z'), + ); + assert.strictEqual(idx.notes[0].isExpired, true); + }); + + test('buildIndex output is deterministic for same input', () => { + const notes = [ + n({ id: 'b', filePath: '/ws/b.ts' }), + n({ id: 'a', filePath: '/ws/a.ts' }), + ]; + const idx1 = buildIndex(notes, '/ws', new Date('2026-05-01T00:00:00Z')); + const idx2 = buildIndex(notes, '/ws', new Date('2026-05-01T00:00:00Z')); + assert.strictEqual(JSON.stringify(idx1), JSON.stringify(idx2)); + }); + + test('buildDigest hoists instructions and warnings', () => { + const notes = [ + n({ id: 'i', type: 'instruction', priority: 'high', content: 'do not bypass auth', filePath: '/ws/auth.ts', lineRange: { start: 41, end: 41 } }), + n({ id: 'c', type: 'context', content: 'just background', filePath: '/ws/auth.ts' }), + ]; + const md = buildDigest(notes, '/ws'); + const instructionPos = md.indexOf('do not bypass auth'); + const contextPos = md.indexOf('just background'); + assert.ok(instructionPos !== -1); + assert.ok(contextPos !== -1); + assert.ok(instructionPos < contextPos, 'instructions must appear before context'); + }); + + test('buildDigest hoists open handoffs to a dedicated section', () => { + const md = buildDigest( + [n({ id: 'h', type: 'handoff', content: 'pick up here', author: 'claude-code', authorType: 'agent' })], + '/ws', + ); + assert.ok(md.includes('## Open handoffs')); + assert.ok(md.includes('pick up here')); + }); +}); +``` + +- [ ] **Step 2: Run tests to confirm failures** + +Run: `npm run test:unit` +Expected: Compile error — `Cannot find module '../../exportGenerator.js'`. + +- [ ] **Step 3: Implement `exportGenerator.ts`** + +Create `src/exportGenerator.ts`: + +```typescript +/** + * Pure functions that build the contents of INDEX.json and AGENTS.md + * from a list of notes. No I/O — call these from exportWriter. + */ + +import * as path from 'path'; +import { Note, NoteType } from './types.js'; +import { applyDefaults, isExpired } from './noteDefaults.js'; + +export interface IndexNoteEntry { + id: string; + filePath: string; + lineRange: { start: number; end: number }; + type: NoteType; + scope: string; + tags: string[]; + priority: string; + author: string; + authorType: string; + createdAt: string; + updatedAt: string; + expiresAt: string | null; + isExpired: boolean; + references: { kind: string; value: string; label?: string }[]; + contentPreview: string; + contentPath: string; +} + +export interface IndexFile { + version: 1; + generatedAt: string; + workspaceRoot: string; + notes: IndexNoteEntry[]; + byFile: Record; + byType: Record; + byTag: Record; + errors: { file: string; message: string }[]; +} + +const PREVIEW_MAX = 200; + +function relativePath(absPath: string, workspaceRoot: string): string { + return path.relative(workspaceRoot, absPath).split(path.sep).join('/'); +} + +function preview(content: string): string { + const single = content.replace(/\s+/g, ' ').trim(); + return single.length <= PREVIEW_MAX ? single : single.slice(0, PREVIEW_MAX) + '…'; +} + +/** + * Build the INDEX.json data. `now` is injectable for deterministic tests. + */ +export function buildIndex(rawNotes: Note[], workspaceRoot: string, now: Date = new Date()): IndexFile { + const notes = rawNotes.map(applyDefaults); + + // Stable sort by id so output is deterministic for the same input set. + const sorted = [...notes].sort((a, b) => a.id.localeCompare(b.id)); + + const entries: IndexNoteEntry[] = sorted.map(n => ({ + id: n.id, + filePath: relativePath(n.filePath, workspaceRoot), + lineRange: n.lineRange, + type: n.type!, + scope: n.scope!, + tags: n.tags!, + priority: n.priority!, + author: n.author, + authorType: n.authorType!, + createdAt: n.createdAt, + updatedAt: n.updatedAt, + expiresAt: n.expiresAt ?? null, + isExpired: isExpired(n, now), + references: n.references!, + contentPreview: preview(n.content), + contentPath: `.code-notes/${n.id}.md`, + })); + + const byFile: Record = {}; + const byType: Record = {}; + const byTag: Record = {}; + + for (const e of entries) { + (byFile[e.filePath] ??= []).push(e.id); + (byType[e.type] ??= []).push(e.id); + for (const t of e.tags) { + (byTag[t] ??= []).push(e.id); + } + } + + return { + version: 1, + generatedAt: now.toISOString(), + workspaceRoot, + notes: entries, + byFile, + byType, + byTag, + errors: [], + }; +} + +const PRIORITY_RANK: Record = { critical: 0, high: 1, normal: 2, low: 3 }; + +/** + * Build the AGENTS.md digest content. + */ +export function buildDigest(rawNotes: Note[], workspaceRoot: string, now: Date = new Date()): string { + const notes = rawNotes.map(applyDefaults).filter(n => !isExpired(n, now)); + + const out: string[] = []; + out.push('# Code Notes Digest'); + out.push('*Auto-generated. Do not edit. Source: `.code-notes/INDEX.json`*'); + out.push(''); + + const critical = notes + .filter(n => n.type === 'instruction' || n.type === 'warning') + .sort((a, b) => (PRIORITY_RANK[a.priority!] ?? 99) - (PRIORITY_RANK[b.priority!] ?? 99)); + if (critical.length > 0) { + out.push('## Critical instructions and warnings'); + for (const n of critical) { + out.push(`- **\`${relativePath(n.filePath, workspaceRoot)}:${n.lineRange.start + 1}\` — ${n.type} (${n.priority}):** ${preview(n.content)}`); + } + out.push(''); + } + + const handoffs = notes.filter(n => n.type === 'handoff'); + if (handoffs.length > 0) { + out.push('## Open handoffs'); + for (const n of handoffs) { + const range = n.lineRange.start === n.lineRange.end ? `${n.lineRange.start + 1}` : `${n.lineRange.start + 1}-${n.lineRange.end + 1}`; + out.push(`- **\`${relativePath(n.filePath, workspaceRoot)}:${range}\`** — handoff from ${n.author}: ${preview(n.content)}`); + } + out.push(''); + } + + const decisions = notes.filter(n => n.type === 'decision'); + if (decisions.length > 0) { + out.push('## Decisions worth knowing'); + for (const n of decisions) { + out.push(`- **\`${relativePath(n.filePath, workspaceRoot)}:${n.lineRange.start + 1}\`** — decision: ${preview(n.content)}`); + } + out.push(''); + } + + // All notes by file, sorted + out.push('## All notes by file'); + const byFile = new Map(); + for (const n of notes) { + const key = relativePath(n.filePath, workspaceRoot); + const arr = byFile.get(key) ?? []; + arr.push(n); + byFile.set(key, arr); + } + const fileKeys = Array.from(byFile.keys()).sort(); + for (const file of fileKeys) { + out.push(`### \`${file}\``); + const fileNotes = byFile.get(file)!.sort((a, b) => a.lineRange.start - b.lineRange.start); + for (const n of fileNotes) { + const tags = n.tags!.length > 0 ? ` [${n.tags!.join(', ')}]` : ''; + out.push(`- L${n.lineRange.start + 1} — ${n.type} (${n.priority})${tags}: ${preview(n.content)}`); + } + out.push(''); + } + + return out.join('\n'); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:unit` +Expected: All `exportGenerator` tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/exportGenerator.ts src/test/suite/exportGenerator.test.ts +git commit -m "✨ feat: add INDEX.json and AGENTS.md generators + +Pure functions buildIndex(notes, workspaceRoot) and buildDigest(notes, +workspaceRoot). Deterministic output for the same input. No I/O — +called by exportWriter." +``` + +--- + +## Task 7: Build the export writer (debounced atomic I/O) + +**Files:** +- Create: `src/exportWriter.ts` +- Create: `src/test/suite/exportWriter.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/test/suite/exportWriter.test.ts`: + +```typescript +import * as assert from 'assert'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { ExportWriter } from '../../exportWriter.js'; +import { Note } from '../../types.js'; + +async function tmpdir(): Promise { + return await fs.mkdtemp(path.join(os.tmpdir(), 'cn-export-')); +} + +const sampleNote = (id: string, ws: string): Note => ({ + id, + content: `note ${id}`, + author: 'alice', + filePath: path.join(ws, 'src/foo.ts'), + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:x', + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', + history: [], +}); + +suite('ExportWriter', () => { + test('writes INDEX.json and AGENTS.md atomically', async () => { + const ws = await tmpdir(); + await fs.mkdir(path.join(ws, '.code-notes'), { recursive: true }); + const writer = new ExportWriter(ws, '.code-notes', { debounceMs: 0 }); + await writer.regenerate([sampleNote('a', ws)]); + const idxRaw = await fs.readFile(path.join(ws, '.code-notes', 'INDEX.json'), 'utf-8'); + const idx = JSON.parse(idxRaw); + assert.strictEqual(idx.version, 1); + assert.strictEqual(idx.notes[0].id, 'a'); + const digest = await fs.readFile(path.join(ws, '.code-notes', 'AGENTS.md'), 'utf-8'); + assert.ok(digest.startsWith('# Code Notes Digest')); + }); + + test('debounces rapid scheduleRegenerate calls', async () => { + const ws = await tmpdir(); + await fs.mkdir(path.join(ws, '.code-notes'), { recursive: true }); + const writer = new ExportWriter(ws, '.code-notes', { debounceMs: 50 }); + let callCount = 0; + const getNotes = async () => { callCount++; return [sampleNote(`n${callCount}`, ws)]; }; + writer.scheduleRegenerate(getNotes); + writer.scheduleRegenerate(getNotes); + writer.scheduleRegenerate(getNotes); + await new Promise(r => setTimeout(r, 100)); + assert.strictEqual(callCount, 1, 'getNotes should be called once after debounce'); + }); + + test('regeneration failure surfaces via onError without throwing', async () => { + const ws = '/this/path/does/not/exist/anywhere'; + const writer = new ExportWriter(ws, '.code-notes', { debounceMs: 0 }); + let captured: Error | undefined; + writer.onError = (e) => { captured = e; }; + await writer.regenerate([]); + assert.ok(captured, 'onError should have been called'); + }); +}); +``` + +- [ ] **Step 2: Run tests to confirm failures** + +Run: `npm run test:unit` +Expected: `Cannot find module '../../exportWriter.js'`. + +- [ ] **Step 3: Implement `exportWriter.ts`** + +Create `src/exportWriter.ts`: + +```typescript +/** + * Debounced atomic writer for INDEX.json and AGENTS.md. + * + * Owns the debounce timer and the temp-file-and-rename logic. Failures + * are surfaced via the onError callback — never thrown — because export + * regeneration is best-effort and must never block a note write. + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { Note } from './types.js'; +import { buildIndex, buildDigest } from './exportGenerator.js'; + +export interface ExportWriterOptions { + debounceMs?: number; +} + +export class ExportWriter { + private workspaceRoot: string; + private storageDir: string; + private debounceMs: number; + private pendingTimer: NodeJS.Timeout | undefined; + private pendingGetNotes: (() => Promise) | undefined; + onError: (e: Error) => void = (e) => console.error('[code-notes] export failed:', e); + + constructor(workspaceRoot: string, storageDir: string, opts: ExportWriterOptions = {}) { + this.workspaceRoot = workspaceRoot; + this.storageDir = storageDir; + this.debounceMs = opts.debounceMs ?? 200; + } + + /** + * Schedule a regeneration. Subsequent calls within the debounce window + * coalesce — only the latest getNotes is used. + */ + scheduleRegenerate(getNotes: () => Promise): void { + this.pendingGetNotes = getNotes; + if (this.pendingTimer) { + clearTimeout(this.pendingTimer); + } + this.pendingTimer = setTimeout(() => { + const fn = this.pendingGetNotes; + this.pendingTimer = undefined; + this.pendingGetNotes = undefined; + if (fn) { + fn().then(notes => this.regenerate(notes)).catch(e => this.onError(e)); + } + }, this.debounceMs); + } + + /** + * Force an immediate (non-debounced) regeneration. Used at startup, + * by the manual command, and by tests. + */ + async regenerate(notes: Note[]): Promise { + try { + const dir = path.join(this.workspaceRoot, this.storageDir); + await fs.mkdir(dir, { recursive: true }); + const idx = buildIndex(notes, this.workspaceRoot); + const digest = buildDigest(notes, this.workspaceRoot); + await this.atomicWrite(path.join(dir, 'INDEX.json'), JSON.stringify(idx, null, 2)); + await this.atomicWrite(path.join(dir, 'AGENTS.md'), digest); + } catch (e: any) { + this.onError(e instanceof Error ? e : new Error(String(e))); + } + } + + private async atomicWrite(targetPath: string, content: string): Promise { + const tmpPath = `${targetPath}.tmp`; + await fs.writeFile(tmpPath, content, 'utf-8'); + await fs.rename(tmpPath, targetPath); + } + + /** + * Cancel any pending debounced regeneration. Call from extension deactivate. + */ + dispose(): void { + if (this.pendingTimer) { + clearTimeout(this.pendingTimer); + this.pendingTimer = undefined; + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test:unit` +Expected: All 3 ExportWriter tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/exportWriter.ts src/test/suite/exportWriter.test.ts +git commit -m "✨ feat: add debounced atomic ExportWriter + +Owns the 200ms debounce timer for note-write bursts and the +write-temp-then-rename atomic-write logic. Failures route through +onError so a disk-full event never blocks note saves." +``` + +--- + +## Task 8: Wire ExportWriter into NoteManager events + +**Files:** +- Modify: `src/extension.ts` + +The hook point is the existing `noteChanged` event on `NoteManager` (already emitted on create/update/delete — confirmed at `src/noteManager.ts:111, 167, 213`). + +- [ ] **Step 1: Locate the activation function** + +Run: `grep -n "noteManager\s*=\|new NoteManager\|export.*activate" src/extension.ts | head -20` +Expected: Line numbers of the NoteManager construction site and `activate` function. + +- [ ] **Step 2: Read current activation flow** + +Run: `head -150 src/extension.ts` +Expected: Understand where `noteManager` is constructed and the surrounding setup. + +- [ ] **Step 3: Wire up the writer** + +In `src/extension.ts`, after the line that constructs `NoteManager` (and after `storageManager` and `workspaceRoot` are available), add: + +```typescript +import { ExportWriter } from './exportWriter.js'; + +// ... inside activate() ... + +const exportsConfig = vscode.workspace.getConfiguration('codeContextNotes.exports'); +const exportsEnabled = exportsConfig.get('enabled', true); + +const exportWriter = new ExportWriter(workspaceRoot, storageDirectory, { debounceMs: 200 }); +context.subscriptions.push({ dispose: () => exportWriter.dispose() }); + +// Schedule initial export on activation (covers fresh installs, manual deletes). +if (exportsEnabled) { + exportWriter.scheduleRegenerate(() => noteManager.getAllNotes()); + // Wire ongoing changes + noteManager.on('noteChanged', () => { + exportWriter.scheduleRegenerate(() => noteManager.getAllNotes()); + }); +} +``` + +If `noteManager.getAllNotes()` doesn't exist with that exact name, search for a method that returns all notes across the workspace (e.g., `getAllWorkspaceNotes`). Use whichever exists. If none does, add a simple one in `noteManager.ts`: + +```typescript +async getAllNotes(): Promise { + const allFiles = await this.storage.getAllNoteFiles(); + const notes: Note[] = []; + for (const file of allFiles) { + const id = path.basename(file, '.md'); + const note = await this.storage.loadNoteById(id); + if (note && !note.isDeleted) notes.push(applyDefaults(note)); + } + return notes; +} +``` + +- [ ] **Step 4: Manual smoke test** + +Run: `npm run compile` +Expected: Compiles cleanly. + +Then in the Extensions Development Host (F5 in VS Code or `code --extensionDevelopmentPath=$(pwd)`), open a workspace, create a note, and verify `.code-notes/INDEX.json` and `.code-notes/AGENTS.md` appear within ~250ms. + +- [ ] **Step 5: Commit** + +```bash +git add src/extension.ts src/noteManager.ts +git commit -m "✨ feat: regenerate exports on note changes + +Hooks ExportWriter to NoteManager's noteChanged event. Initial export +runs on activation. Disabled when codeContextNotes.exports.enabled is +false." +``` + +--- + +## Task 9: Add the manual `Regenerate Exports` command + +**Files:** +- Modify: `package.json` +- Modify: `src/extension.ts` + +- [ ] **Step 1: Register the command in package.json** + +In `package.json` `contributes.commands` (where `codeContextNotes.refreshNotes` and friends live), add: + +```json +{ + "command": "codeContextNotes.regenerateExports", + "title": "Regenerate Exports (INDEX.json / AGENTS.md)", + "category": "Code Notes" +} +``` + +- [ ] **Step 2: Implement the command handler** + +In `src/extension.ts` `activate`, alongside other `commands.registerCommand` calls, add: + +```typescript +context.subscriptions.push( + vscode.commands.registerCommand('codeContextNotes.regenerateExports', async () => { + const notes = await noteManager.getAllNotes(); + await exportWriter.regenerate(notes); + vscode.window.showInformationMessage(`Code Notes: regenerated exports for ${notes.length} notes.`); + }), +); +``` + +- [ ] **Step 3: Manual smoke test** + +Run: `npm run compile` +Expected: Compiles. + +In the Extension Development Host: Cmd+Shift+P → "Code Notes: Regenerate Exports" → notification appears, files exist. + +- [ ] **Step 4: Commit** + +```bash +git add package.json src/extension.ts +git commit -m "✨ feat: add manual Regenerate Exports command + +Lets users force a fresh INDEX.json/AGENTS.md, e.g. after a disk-full +recovery or a manual delete from the storage directory." +``` + +--- + +## Task 10: Add export configuration settings + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Add settings to contribution properties** + +In `package.json` `contributes.configuration.properties` (alongside `codeContextNotes.storageDirectory` etc.), add: + +```json +"codeContextNotes.exports.enabled": { + "type": "boolean", + "default": true, + "description": "Auto-generate .code-notes/INDEX.json and .code-notes/AGENTS.md on every note change" +}, +"codeContextNotes.exports.indexJson": { + "type": "boolean", + "default": true, + "description": "Generate INDEX.json (machine-readable)" +}, +"codeContextNotes.exports.agentsMarkdown": { + "type": "boolean", + "default": true, + "description": "Generate AGENTS.md (human-readable digest)" +} +``` + +(`exports.expandedDigest` is mentioned in the spec but is genuine future work — leave it out of v0.3 to keep scope tight.) + +- [ ] **Step 2: Honor the per-file toggles in `ExportWriter.regenerate`** + +In `src/exportWriter.ts`, change the constructor to accept a config snapshot, or read the workspace config inline before each write. The simpler approach — read inline at the top of `regenerate`: + +```typescript +async regenerate(notes: Note[]): Promise { + try { + const cfg = vscode.workspace.getConfiguration('codeContextNotes.exports'); + if (!cfg.get('enabled', true)) return; + const writeIndex = cfg.get('indexJson', true); + const writeDigest = cfg.get('agentsMarkdown', true); + + const dir = path.join(this.workspaceRoot, this.storageDir); + await fs.mkdir(dir, { recursive: true }); + if (writeIndex) { + const idx = buildIndex(notes, this.workspaceRoot); + await this.atomicWrite(path.join(dir, 'INDEX.json'), JSON.stringify(idx, null, 2)); + } + if (writeDigest) { + const digest = buildDigest(notes, this.workspaceRoot); + await this.atomicWrite(path.join(dir, 'AGENTS.md'), digest); + } + } catch (e: any) { + this.onError(e instanceof Error ? e : new Error(String(e))); + } +} +``` + +Add the import at the top: `import * as vscode from 'vscode';` + +**Note:** existing `exportWriter` tests don't run inside the VS Code host (they're unit tests). The `vscode.workspace.getConfiguration` call will fail in vitest-style tests but they currently use `@vscode/test-electron`, so they have access. If a test fails because of this, pass an injectable `getConfig?: () => { enabled: boolean; indexJson: boolean; agentsMarkdown: boolean }` to the constructor with VS Code lookup as the default. + +- [ ] **Step 3: Run tests to verify nothing broke** + +Run: `npm run test:unit` +Expected: All tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add package.json src/exportWriter.ts +git commit -m "✨ feat: add codeContextNotes.exports.* settings + +Three booleans: enabled (master switch), indexJson, agentsMarkdown. +All default true. Read on every regeneration so toggling takes effect +without reload." +``` + +--- + +## Task 11: Add `.code-notes/INDEX.json` and `.code-notes/AGENTS.md` to template `.gitignore` guidance + +**Files:** +- Modify: `README.md` + +The exports are generated content. Teams should opt in to committing or ignoring them. We document; we don't auto-edit `.gitignore`. + +- [ ] **Step 1: Find the README section about storage / git** + +Run: `grep -n "gitignore\|\\.code-notes" README.md | head -10` + +- [ ] **Step 2: Add a short subsection** + +Add after the existing storage explanation (locate by reading the file around the matched lines). Example block to add: + +```markdown +### Generated exports + +When auto-exports are enabled, two files are regenerated in `.code-notes/` +on every note change: + +- **`INDEX.json`** — machine-readable index. Used by integrations like the MCP server (v0.4+). +- **`AGENTS.md`** — human-readable digest, hoisting instructions/warnings/handoffs. Useful as workspace context for coding agents. + +Both are deterministic given the same notes. To exclude them from git, add to `.gitignore`: + +``` +.code-notes/INDEX.json +.code-notes/AGENTS.md +``` + +To disable export generation entirely, set `codeContextNotes.exports.enabled` to `false`. +``` + +- [ ] **Step 3: Commit** + +```bash +git add README.md +git commit -m "📝 docs: document INDEX.json / AGENTS.md exports in README" +``` + +--- + +## Task 12: Sidebar — show note type as a description suffix + +**Files:** +- Modify: `src/noteTreeItem.ts` + +The simplest visual indicator with no new icon assets: append `· {type}` to the existing tree item description for non-default types. + +- [ ] **Step 1: Read current `NoteTreeItem`** + +Run: `head -100 src/noteTreeItem.ts` +Expected: Understand how `description` and `tooltip` are built today. + +- [ ] **Step 2: Apply default and append type** + +Add the import at the top: + +```typescript +import { applyDefaults } from './noteDefaults.js'; +``` + +In the `NoteTreeItem` constructor (or wherever `description` is assigned), after `this.description = ...`, normalize the note and append the type if non-default: + +```typescript +const filled = applyDefaults(note); +if (filled.type && filled.type !== 'context') { + this.description = `${this.description ?? ''} · ${filled.type}`.trim(); +} +if (filled.priority === 'critical' || filled.priority === 'high') { + // Add a visual marker via tooltip + this.tooltip = `[${filled.priority}] ${this.tooltip ?? ''}`; +} +``` + +- [ ] **Step 3: Manual smoke test** + +Run: `npm run compile`. In Extension Development Host, create a note, edit it to type `instruction` (via the bold-label markdown directly in `.code-notes/.md`, since the UI for type selection lands in Task 13), and verify the sidebar shows `· instruction` after the description. + +- [ ] **Step 4: Commit** + +```bash +git add src/noteTreeItem.ts +git commit -m "💄 feat(sidebar): show note type in tree item description" +``` + +--- + +## Task 13: Sidebar — type filter via context menu + +**Files:** +- Modify: `src/notesSidebarProvider.ts` +- Modify: `package.json` + +The minimum viable filter UX: a tree-view title-bar action that toggles a state, plus per-type filter via Quick Pick. + +- [ ] **Step 1: Add filter state to the provider** + +In `src/notesSidebarProvider.ts`, add a private field and a setter: + +```typescript +private typeFilter: Set | null = null; // null = no filter +private hideExpired: boolean = true; + +setTypeFilter(types: Set | null): void { + this.typeFilter = types; + this._onDidChangeTreeData.fire(); +} + +toggleHideExpired(): void { + this.hideExpired = !this.hideExpired; + this._onDidChangeTreeData.fire(); +} +``` + +- [ ] **Step 2: Apply filter in `getChildren`** + +Wherever the provider returns the list of notes (likely a `getChildren` method), filter: + +```typescript +import { applyDefaults, isExpired } from './noteDefaults.js'; + +// inside getChildren, after fetching notes: +const filtered = notes + .map(applyDefaults) + .filter(n => !this.hideExpired || !isExpired(n)) + .filter(n => !this.typeFilter || this.typeFilter.has(n.type!)); +``` + +- [ ] **Step 3: Add commands in package.json** + +```json +{ + "command": "codeContextNotes.filterByType", + "title": "Filter Notes by Type…", + "category": "Code Notes", + "icon": "$(filter)" +}, +{ + "command": "codeContextNotes.toggleExpired", + "title": "Toggle Expired Notes", + "category": "Code Notes", + "icon": "$(eye)" +} +``` + +In `contributes.menus.view/title`, add (replace `` with the actual sidebar view id used in `package.json`): + +```json +{ + "command": "codeContextNotes.filterByType", + "when": "view == ", + "group": "navigation" +}, +{ + "command": "codeContextNotes.toggleExpired", + "when": "view == ", + "group": "navigation" +} +``` + +- [ ] **Step 4: Wire commands in `extension.ts`** + +```typescript +context.subscriptions.push( + vscode.commands.registerCommand('codeContextNotes.filterByType', async () => { + const choices = ['context', 'instruction', 'warning', 'decision', 'todo', 'handoff', 'rationale']; + const picked = await vscode.window.showQuickPick(choices, { + canPickMany: true, + title: 'Show only notes of these types (cancel to clear filter)', + }); + sidebarProvider.setTypeFilter(picked && picked.length > 0 ? new Set(picked) : null); + }), + vscode.commands.registerCommand('codeContextNotes.toggleExpired', () => { + sidebarProvider.toggleHideExpired(); + }), +); +``` + +(`sidebarProvider` is the existing variable name — confirm and replace if different.) + +- [ ] **Step 5: Manual smoke test** + +Compile and verify in dev host: filter command opens Quick Pick; selecting `instruction` hides non-instruction notes. + +- [ ] **Step 6: Commit** + +```bash +git add src/notesSidebarProvider.ts package.json src/extension.ts +git commit -m "✨ feat(sidebar): add type filter and expired-notes toggle + +New title-bar actions: filter by type (multi-select Quick Pick) and +toggle expired-note visibility. Default hides expired notes." +``` + +--- + +## Task 14: "More fields" expander in the add/edit note flow + +**Files:** +- Modify: `src/commentController.ts` (the comment-thread-based add/edit flow) + +The minimum viable form: a "Set type / scope / priority…" command that runs a Quick Pick chain on the current draft note. We don't redesign the comment UI; we add an optional one-shot enrichment command. + +- [ ] **Step 1: Locate the comment thread handlers** + +Run: `grep -n "addNote\|saveNote\|createNote" src/commentController.ts | head -20` +Expected: Find the path where a new note's content is committed. + +- [ ] **Step 2: Add a command for setting metadata** + +Register in `package.json`: + +```json +{ + "command": "codeContextNotes.setNoteMetadata", + "title": "Set Note Type / Tags / Priority…", + "category": "Code Notes" +} +``` + +In `extension.ts`: + +```typescript +context.subscriptions.push( + vscode.commands.registerCommand('codeContextNotes.setNoteMetadata', async (noteIdArg?: string) => { + let noteId = noteIdArg; + if (!noteId) { + const all = await noteManager.getAllNotes(); + const pick = await vscode.window.showQuickPick( + all.map(n => ({ label: n.id, description: n.content.slice(0, 60) })), + { title: 'Pick a note to update' }, + ); + if (!pick) return; + noteId = pick.label; + } + + const type = await vscode.window.showQuickPick( + ['context', 'instruction', 'warning', 'decision', 'todo', 'handoff', 'rationale'], + { title: 'Type' }, + ); + if (!type) return; + + const priority = await vscode.window.showQuickPick( + ['low', 'normal', 'high', 'critical'], + { title: 'Priority' }, + ); + if (!priority) return; + + const tagsRaw = await vscode.window.showInputBox({ title: 'Tags (comma-separated, optional)' }); + const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : []; + + const expiresRaw = await vscode.window.showInputBox({ title: 'Expires at (ISO 8601, optional)' }); + const expiresAt = expiresRaw && expiresRaw.trim().length > 0 ? expiresRaw.trim() : undefined; + + await noteManager.updateNoteMetadata(noteId, { type: type as any, priority: priority as any, tags, expiresAt }); + vscode.window.showInformationMessage(`Code Notes: updated metadata for ${noteId}.`); + }), +); +``` + +- [ ] **Step 3: Implement `updateNoteMetadata` on NoteManager** + +In `src/noteManager.ts`, add: + +```typescript +async updateNoteMetadata( + noteId: string, + fields: { type?: NoteType; priority?: NotePriority; tags?: string[]; expiresAt?: string; scope?: NoteScope }, +): Promise { + const existing = await this.storage.loadNoteById(noteId); + if (!existing) throw new Error(`Note ${noteId} not found`); + const updated: Note = { + ...existing, + ...fields, + updatedAt: new Date().toISOString(), + }; + await this.storage.saveNote(updated); + this.invalidateCachesForFile(existing.filePath); // use whatever cache invalidator exists + this.emit('noteUpdated', updated); + this.emit('noteChanged', { type: 'updated', note: updated }); + return applyDefaults(updated); +} +``` + +(Add the necessary imports for `NoteType`, `NotePriority`, `NoteScope`.) + +- [ ] **Step 4: Test the flow end-to-end manually** + +`npm run compile`. In dev host, create a note, run "Code Notes: Set Note Type / Tags / Priority…", pick the note and fill the prompts. Verify the on-disk markdown gets the new fields and the sidebar updates. + +- [ ] **Step 5: Commit** + +```bash +git add package.json src/extension.ts src/noteManager.ts +git commit -m "✨ feat: add command to set note type/priority/tags/expiry + +Quick Pick driven flow: codeContextNotes.setNoteMetadata. Lets users +enrich notes after creation. Future v0.5 work will inline this in the +add/edit comment thread UI; this command unblocks v0.3 use cases." +``` + +--- + +## Task 15: Changelog entry + +**Files:** +- Create: `docs/changelogs/v0.3.0.md` + +Per project `CLAUDE.md` and `docs/changelogs/CHANGELOG_TEMPLATE.md`. + +- [ ] **Step 1: Read the template** + +Run: `cat docs/changelogs/CHANGELOG_TEMPLATE.md` +Expected: Familiarize with required sections. + +- [ ] **Step 2: Author the entry** + +Create `docs/changelogs/v0.3.0.md` following the template. Sections to fill: + +- **Summary:** "First release of agent-integration foundations: notes gain optional structured fields and the workspace auto-generates a machine-readable INDEX.json plus a human-readable AGENTS.md digest on every note change." +- **New features:** structured note types (context, instruction, warning, decision, todo, handoff, rationale); scopes; tags; priority; expiry; references; auto-generated `.code-notes/INDEX.json` and `.code-notes/AGENTS.md`; manual `Code Notes: Regenerate Exports` command; sidebar type filter and expired-toggle; `Set Note Type / Tags / Priority` command. +- **Settings added:** `codeContextNotes.exports.enabled`, `codeContextNotes.exports.indexJson`, `codeContextNotes.exports.agentsMarkdown`. +- **Compatibility:** Legacy v0.2.x notes load with sensible defaults; no migration step required. On-disk note format extended with new bold-label fields, omitted when equal to default — old notes that aren't edited remain byte-identical on disk. +- **Coming next:** v0.4 ships the standalone MCP server. + +- [ ] **Step 3: Commit** + +```bash +git add docs/changelogs/v0.3.0.md +git commit -m "📝 docs: add v0.3.0 changelog entry" +``` + +--- + +## Task 16: Update web changelog page + +**Files:** +- Modify: `web/src/pages/ChangelogPage.tsx` + +- [ ] **Step 1: Use the dedicated skill** + +This step is the single use case for the `add-web-changelog` skill — invoke it rather than hand-editing. + +- [ ] **Step 2: Verify** + +Run: `cd web && npm run build:client` +Expected: Builds cleanly. + +- [ ] **Step 3: Commit** + +```bash +git add web/src/pages/ChangelogPage.tsx +git commit -m "📝 docs(web): add v0.3.0 to changelog timeline" +``` + +--- + +## Task 17: Bump extension version and finalize + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Bump version** + +In root `package.json`, change `"version": "0.2.1"` to `"version": "0.3.0"`. + +- [ ] **Step 2: Full verification** + +Run all of these in sequence and confirm each: + +```bash +npm run compile:tsc # type-check +npm run test:unit # all unit tests pass +npm run compile # esbuild production +npm run package:dev # generates a .vsix without bumping git tag +``` + +Expected: +- Type-check: 0 errors +- Tests: all green +- esbuild: succeeds +- vsce package: produces `code-context-notes-0.3.0.vsix` + +- [ ] **Step 3: Manual install smoke test** + +```bash +code --install-extension code-context-notes-0.3.0.vsix +``` + +In a clean test workspace: +- Add a note → verify `.code-notes/INDEX.json` and `.code-notes/AGENTS.md` appear within 1s +- Run "Set Note Type / Tags / Priority" → set type to `instruction`, priority `high` +- Re-open `AGENTS.md` → verify the note hoisted to "Critical instructions and warnings" +- Run "Filter Notes by Type" → pick `instruction` → sidebar shows only that note +- Open an existing v0.2.x workspace → verify all old notes still load and work + +- [ ] **Step 4: Commit** + +```bash +git add package.json +git commit -m "🔖 chore: bump version to 0.3.0" +``` + +- [ ] **Step 5: Hand off to release process** + +Per `docs/RELEASE_TEMPLATE.md`. Do not publish without explicit user permission. + +--- + +## Self-review (run before declaring done) + +- **Spec coverage check:** + - §5.1 (extended schema) → Tasks 1–5 ✓ + - §5.2.1 (`INDEX.json`) → Task 6 ✓ + - §5.2.2 (`AGENTS.md`) → Task 6 ✓ + - §5.2.3 (regeneration / debounce / atomic) → Task 7 ✓ + - §5.2.4 (configuration) → Task 10 ✓ + - §6 (lazy migration) → Task 5 (defaults applied at NoteManager boundary) ✓ + - §5.5.2 (scoped instructions) → partial — schema supports `scope: directory`, but `INDEX.json` doesn't yet resolve directory-scoped notes for downstream consumers. **Acceptable for v0.3** — that resolution is needed by the MCP server's `get_notes_for_changes`, which lands in v0.4. The schema field is in place; resolution logic is v0.4's job. + - §7.2 (export failures graceful) → Task 7 (onError) + Task 9 (manual regen command for recovery) ✓ + - §7.3 (malformed notes — INDEX.json `errors[]`) → Task 6 includes the `errors: []` field but doesn't yet populate it. Populating is part of the read path that lives in `noteManager`'s file scanner — **deferred to v0.4** when we centralize the workspace-wide load. +- **Type consistency:** `NoteType`, `NoteScope`, `NotePriority`, `AuthorType`, `NoteReference` defined in Task 1 are used consistently in Tasks 2, 3, 4, 6, 13, 14. ✓ +- **Method-name consistency:** `applyDefaults` (Task 2) called in Tasks 5, 6, 12, 13. ✓ `getAllNotes` (Task 8) used in Tasks 9, 14. ✓ `updateNoteMetadata` (Task 14) is a new method — not referenced elsewhere; isolated. ✓ +- **No TBDs / placeholders.** Every step has concrete code or commands. The two deferrals above are explicit, justified, and scoped to v0.4. + +--- + +*End of plan.* From 1e259ed63c14769a86ff4507df9f3575de98cf60 Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Sat, 16 May 2026 22:01:29 +0600 Subject: [PATCH 04/31] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20structured=20sc?= =?UTF-8?q?hema=20types=20to=20Note=20interface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds optional NoteType, NoteScope, NoteReference, AuthorType, NotePriority fields. All optional for backward compatibility — defaults applied on read in a follow-up task. --- src/types.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/types.ts b/src/types.ts index 90ff026..8e55625 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,6 +17,47 @@ export interface LineRange { */ export type NoteAction = 'created' | 'edited' | 'deleted'; +/** + * Type of note — drives prioritization in agent-facing exports + */ +export type NoteType = + | 'context' // default: explanatory background + | 'instruction' // directive: agent/human MUST follow + | 'warning' // hazard: don't do X + | 'decision' // architectural decision + rationale + | 'todo' // outstanding work + | 'handoff' // "next session pick up here" (often agent-authored) + | 'rationale'; // why this code exists (links to PRs/commits) + +/** + * Scope of applicability for a note + */ +export type NoteScope = + | 'line' // default — current behavior + | 'function' + | 'class' + | 'file' + | 'directory'; + +/** + * Author kind — explicit agent attribution + */ +export type AuthorType = 'human' | 'agent'; + +/** + * Priority used for digest ordering and `critical` always-include behavior + */ +export type NotePriority = 'low' | 'normal' | 'high' | 'critical'; + +/** + * Reference to an external artifact (PR, commit, test, etc.) + */ +export interface NoteReference { + kind: 'note' | 'pr' | 'issue' | 'commit' | 'test' | 'url'; + value: string; + label?: string; +} + /** * Represents a single history entry for a note */ @@ -55,6 +96,14 @@ export interface Note { history: NoteHistoryEntry[]; /** Whether this note has been deleted (soft delete) */ isDeleted?: boolean; + // NEW (all optional for back-compat — see noteDefaults.applyDefaults) + type?: NoteType; + tags?: string[]; + scope?: NoteScope; + references?: NoteReference[]; + expiresAt?: string; // ISO 8601 + authorType?: AuthorType; + priority?: NotePriority; } /** From f47f5ac203eea9974cac8b053d12400d8b3faa93 Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Sat, 16 May 2026 22:04:47 +0600 Subject: [PATCH 05/31] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20applyDefaults?= =?UTF-8?q?=20helper=20for=20legacy=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lazily fills missing optional Note fields in memory. The on-disk file is unchanged until the note is next saved (lazy migration). --- src/noteDefaults.ts | 40 +++++++++++++++++++ src/test/runUnitTests.ts | 2 +- src/test/suite/noteDefaults.test.ts | 60 +++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 src/noteDefaults.ts create mode 100644 src/test/suite/noteDefaults.test.ts diff --git a/src/noteDefaults.ts b/src/noteDefaults.ts new file mode 100644 index 0000000..a58eb52 --- /dev/null +++ b/src/noteDefaults.ts @@ -0,0 +1,40 @@ +/** + * Apply schema defaults to a Note loaded from disk. + * Legacy notes from v0.2.x have no structured fields; this fills them + * in memory only. The on-disk file is not modified. + */ + +import { Note, NoteType, NoteScope, AuthorType, NotePriority } from './types.js'; + +export const NOTE_DEFAULTS = { + type: 'context' as NoteType, + scope: 'line' as NoteScope, + tags: [] as string[], + references: [] as never[], // typed as never — replaced via applyDefaults + priority: 'normal' as NotePriority, + authorType: 'human' as AuthorType, +}; + +/** + * Returns a new Note with all optional fields populated. + * Does not mutate the input. + */ +export function applyDefaults(note: Note): Note { + return { + ...note, + type: note.type ?? NOTE_DEFAULTS.type, + scope: note.scope ?? NOTE_DEFAULTS.scope, + tags: note.tags ?? [], + references: note.references ?? [], + priority: note.priority ?? NOTE_DEFAULTS.priority, + authorType: note.authorType ?? NOTE_DEFAULTS.authorType, + }; +} + +/** + * True if `expiresAt` is set and is at or before `now`. + */ +export function isExpired(note: Note, now: Date = new Date()): boolean { + if (!note.expiresAt) return false; + return new Date(note.expiresAt).getTime() <= now.getTime(); +} diff --git a/src/test/runUnitTests.ts b/src/test/runUnitTests.ts index 900c0b7..ab5f57d 100644 --- a/src/test/runUnitTests.ts +++ b/src/test/runUnitTests.ts @@ -35,7 +35,7 @@ async function main() { const unitTestFiles = files.filter(f => { const basename = path.basename(f); // Only include tests that don't require vscode - return basename === 'storageManager.test.js' || basename === 'gitIntegration.test.js'; + return basename === 'storageManager.test.js' || basename === 'gitIntegration.test.js' || basename === 'noteDefaults.test.js'; }); console.log(`Running ${unitTestFiles.length} unit tests (excluding integration tests)`); diff --git a/src/test/suite/noteDefaults.test.ts b/src/test/suite/noteDefaults.test.ts new file mode 100644 index 0000000..ce1137e --- /dev/null +++ b/src/test/suite/noteDefaults.test.ts @@ -0,0 +1,60 @@ +/** + * Unit tests for noteDefaults + * Tests schema default application and expiry checking + */ + +import * as assert from 'assert'; +import { applyDefaults, isExpired } from '../../noteDefaults.js'; +import { Note } from '../../types.js'; + +const baseNote: Note = { + id: 'n1', + content: 'hi', + author: 'alice', + filePath: '/abs/foo.ts', + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:abc', + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', + history: [], +}; + +suite('noteDefaults', () => { + test('applyDefaults fills all optional fields on a legacy note', () => { + const filled = applyDefaults({ ...baseNote }); + assert.strictEqual(filled.type, 'context'); + assert.strictEqual(filled.scope, 'line'); + assert.deepStrictEqual(filled.tags, []); + assert.deepStrictEqual(filled.references, []); + assert.strictEqual(filled.priority, 'normal'); + assert.strictEqual(filled.authorType, 'human'); + assert.strictEqual(filled.expiresAt, undefined); + }); + + test('applyDefaults preserves explicitly set fields', () => { + const filled = applyDefaults({ + ...baseNote, + type: 'instruction', + priority: 'high', + tags: ['security'], + }); + assert.strictEqual(filled.type, 'instruction'); + assert.strictEqual(filled.priority, 'high'); + assert.deepStrictEqual(filled.tags, ['security']); + assert.strictEqual(filled.scope, 'line'); // still defaulted + }); + + test('isExpired returns false when expiresAt is undefined', () => { + assert.strictEqual(isExpired(baseNote, new Date('2099-01-01')), false); + }); + + test('isExpired returns true when expiresAt is in the past', () => { + const n = { ...baseNote, expiresAt: '2026-04-01T00:00:00Z' }; + assert.strictEqual(isExpired(n, new Date('2026-05-01T00:00:00Z')), true); + }); + + test('isExpired returns false when expiresAt is in the future', () => { + const n = { ...baseNote, expiresAt: '2026-06-01T00:00:00Z' }; + assert.strictEqual(isExpired(n, new Date('2026-05-01T00:00:00Z')), false); + }); +}); From 07fa7956e9fb6f9748fa00f5d7db3878b2ffd57b Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Sat, 16 May 2026 22:08:37 +0600 Subject: [PATCH 06/31] =?UTF-8?q?=E2=9C=A8=20feat(storage):=20parse=20stru?= =?UTF-8?q?ctured=20fields=20from=20note=20markdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends markdownToNote to read Type/Scope/Priority/Tags/AuthorType/ ExpiresAt/References. Legacy notes without these fields parse exactly as before (fields remain undefined; defaults applied at NoteManager boundary in a follow-up task). --- src/storageManager.ts | 31 ++++++++++++++ src/test/suite/storageManager.test.ts | 59 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/storageManager.ts b/src/storageManager.ts index 5c1cc18..86ad8c0 100644 --- a/src/storageManager.ts +++ b/src/storageManager.ts @@ -293,6 +293,37 @@ export class StorageManager implements NoteStorage { else if (line.startsWith('**Status:** DELETED')) { note.isDeleted = true; } + // Parse new structured fields + else if (line.startsWith('**Type:**')) { + note.type = line.substring(9).trim() as any; + } + else if (line.startsWith('**Scope:**')) { + note.scope = line.substring(10).trim() as any; + } + else if (line.startsWith('**Priority:**')) { + note.priority = line.substring(13).trim() as any; + } + else if (line.startsWith('**Tags:**')) { + const raw = line.substring(9).trim(); + note.tags = raw ? raw.split(',').map(t => t.trim()).filter(t => t.length > 0) : []; + } + else if (line.startsWith('**AuthorType:**')) { + note.authorType = line.substring(15).trim() as any; + } + else if (line.startsWith('**ExpiresAt:**')) { + note.expiresAt = line.substring(14).trim(); + } + else if (line.startsWith('**References:**')) { + const raw = line.substring(15).trim(); + if (raw) { + try { + note.references = JSON.parse(raw); + } catch { + console.warn(`[code-notes] Failed to parse References for note: ${raw}`); + note.references = []; + } + } + } // Parse current content section else if (line === '## Current Content') { inContent = true; diff --git a/src/test/suite/storageManager.test.ts b/src/test/suite/storageManager.test.ts index 91ccb0f..92f0b84 100644 --- a/src/test/suite/storageManager.test.ts +++ b/src/test/suite/storageManager.test.ts @@ -319,4 +319,63 @@ suite('StorageManager Test Suite', () => { assert.ok(content.includes('**Status:** DELETED')); }); + + test('markdownToNote parses new structured fields when present', async () => { + const markdown = `# Code Context Note + +**File:** /abs/foo.ts +**Lines:** 1-1 +**Content Hash:** sha256:abc + +## Note: n1 +**Author:** alice +**Created:** 2026-05-01T00:00:00Z +**Updated:** 2026-05-01T00:00:00Z +**Type:** instruction +**Scope:** function +**Priority:** high +**Tags:** security, legacy +**AuthorType:** human +**ExpiresAt:** 2026-12-01T00:00:00Z +**References:** [{"kind":"pr","value":"#42"}] + +## Current Content + +Do not bypass. +`; + const sm: any = new StorageManager('/tmp'); + const note = sm.markdownToNote(markdown); + assert.ok(note); + assert.strictEqual(note!.type, 'instruction'); + assert.strictEqual(note!.scope, 'function'); + assert.strictEqual(note!.priority, 'high'); + assert.deepStrictEqual(note!.tags, ['security', 'legacy']); + assert.strictEqual(note!.authorType, 'human'); + assert.strictEqual(note!.expiresAt, '2026-12-01T00:00:00Z'); + assert.deepStrictEqual(note!.references, [{ kind: 'pr', value: '#42' }]); + }); + + test('markdownToNote leaves new fields undefined for legacy notes', async () => { + const markdown = `# Code Context Note + +**File:** /abs/foo.ts +**Lines:** 1-1 +**Content Hash:** sha256:abc + +## Note: n1 +**Author:** alice +**Created:** 2026-05-01T00:00:00Z +**Updated:** 2026-05-01T00:00:00Z + +## Current Content + +Hi. +`; + const sm: any = new StorageManager('/tmp'); + const note = sm.markdownToNote(markdown); + assert.ok(note); + assert.strictEqual(note!.type, undefined); + assert.strictEqual(note!.scope, undefined); + assert.strictEqual(note!.tags, undefined); + }); }); From d04622f047d10a3f9a6c9b7502140489c8aa4866 Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Sat, 16 May 2026 22:13:03 +0600 Subject: [PATCH 07/31] =?UTF-8?q?=E2=9C=A8=20feat(storage):=20emit=20struc?= =?UTF-8?q?tured=20fields=20in=20note=20markdown?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends noteToMarkdown to write Type/Scope/Priority/Tags/AuthorType/ ExpiresAt/References. Fields equal to their default are omitted to keep frontmatter compact for the common case (untouched legacy notes remain shape-identical after a save). --- src/storageManager.ts | 24 ++++++++++++ src/test/suite/storageManager.test.ts | 54 +++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/storageManager.ts b/src/storageManager.ts index 86ad8c0..ee8d1c7 100644 --- a/src/storageManager.ts +++ b/src/storageManager.ts @@ -210,6 +210,30 @@ export class StorageManager implements NoteStorage { lines.push(`**Author:** ${note.author}`); lines.push(`**Created:** ${note.createdAt}`); lines.push(`**Updated:** ${note.updatedAt}`); + + // Structured fields — omitted if equal to default for compactness + if (note.type && note.type !== 'context') { + lines.push(`**Type:** ${note.type}`); + } + if (note.scope && note.scope !== 'line') { + lines.push(`**Scope:** ${note.scope}`); + } + if (note.priority && note.priority !== 'normal') { + lines.push(`**Priority:** ${note.priority}`); + } + if (note.tags && note.tags.length > 0) { + lines.push(`**Tags:** ${note.tags.join(', ')}`); + } + if (note.authorType && note.authorType !== 'human') { + lines.push(`**AuthorType:** ${note.authorType}`); + } + if (note.expiresAt) { + lines.push(`**ExpiresAt:** ${note.expiresAt}`); + } + if (note.references && note.references.length > 0) { + lines.push(`**References:** ${JSON.stringify(note.references)}`); + } + if (note.isDeleted) { lines.push(`**Status:** DELETED`); } diff --git a/src/test/suite/storageManager.test.ts b/src/test/suite/storageManager.test.ts index 92f0b84..acb976c 100644 --- a/src/test/suite/storageManager.test.ts +++ b/src/test/suite/storageManager.test.ts @@ -378,4 +378,58 @@ Hi. assert.strictEqual(note!.scope, undefined); assert.strictEqual(note!.tags, undefined); }); + + test('noteToMarkdown emits structured fields when set', async () => { + const sm: any = new StorageManager('/tmp'); + const md = sm.noteToMarkdown({ + id: 'n1', + content: 'do not refactor', + author: 'alice', + filePath: '/abs/foo.ts', + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:abc', + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', + history: [], + type: 'instruction', + scope: 'function', + priority: 'high', + tags: ['security'], + authorType: 'agent', + expiresAt: '2026-12-01T00:00:00Z', + references: [{ kind: 'pr', value: '#42' }], + }); + assert.ok(md.includes('**Type:** instruction')); + assert.ok(md.includes('**Scope:** function')); + assert.ok(md.includes('**Priority:** high')); + assert.ok(md.includes('**Tags:** security')); + assert.ok(md.includes('**AuthorType:** agent')); + assert.ok(md.includes('**ExpiresAt:** 2026-12-01T00:00:00Z')); + assert.ok(md.includes('**References:** [{"kind":"pr","value":"#42"}]')); + }); + + test('noteToMarkdown omits fields equal to defaults', async () => { + const sm: any = new StorageManager('/tmp'); + const md = sm.noteToMarkdown({ + id: 'n1', + content: 'hi', + author: 'alice', + filePath: '/abs/foo.ts', + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:abc', + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', + history: [], + type: 'context', // default + scope: 'line', // default + priority: 'normal', // default + tags: [], // default + authorType: 'human', // default + }); + assert.ok(!md.includes('**Type:**')); + assert.ok(!md.includes('**Scope:**')); + assert.ok(!md.includes('**Priority:**')); + assert.ok(!md.includes('**Tags:**')); + assert.ok(!md.includes('**AuthorType:**')); + }); }); From 589ee3ca5423c70bc285dd81fe7cfeca1cfcfadf Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Sat, 16 May 2026 22:18:25 +0600 Subject: [PATCH 08/31] =?UTF-8?q?=E2=9C=A8=20feat(noteManager):=20apply=20?= =?UTF-8?q?schema=20defaults=20on=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notes leaving NoteManager always have optional fields populated. Legacy notes get defaults in memory; on-disk file unchanged until next save. This is the single boundary where lazy migration happens. --- src/noteManager.ts | 13 +++++++----- src/test/suite/noteManager.test.ts | 33 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/noteManager.ts b/src/noteManager.ts index 0c8bc8f..f3c23ab 100644 --- a/src/noteManager.ts +++ b/src/noteManager.ts @@ -12,6 +12,7 @@ import { StorageManager } from './storageManager.js'; import { ContentHashTracker } from './contentHashTracker.js'; import { GitIntegration } from './gitIntegration.js'; import { SearchManager } from './searchManager.js'; +import { applyDefaults } from './noteDefaults.js'; /** * NoteManager coordinates all note operations @@ -223,8 +224,8 @@ export class NoteManager extends EventEmitter { return this.noteCache.get(filePath)!.filter(n => !n.isDeleted); } - // Load from storage - const notes = await this.storage.loadNotes(filePath); + // Load from storage and apply defaults at the boundary + const notes = (await this.storage.loadNotes(filePath)).map(applyDefaults); // Update cache this.noteCache.set(filePath, notes); @@ -242,8 +243,8 @@ export class NoteManager extends EventEmitter { return this.noteCache.get(filePath)!; } - // Load from storage (including deleted notes) - const notes = await this.storage.loadAllNotes(filePath); + // Load from storage (including deleted notes) and apply defaults at the boundary + const notes = (await this.storage.loadAllNotes(filePath)).map(applyDefaults); // Update cache this.noteCache.set(filePath, notes); @@ -453,7 +454,9 @@ export class NoteManager extends EventEmitter { for (const noteFilePath of allNoteFiles) { try { const noteId = this.extractNoteIdFromFilePath(noteFilePath); - const note = await this.storage.loadNoteById(noteId); + const rawNote = await this.storage.loadNoteById(noteId); + // Apply defaults at the boundary before cache/consumer use + const note = rawNote ? applyDefaults(rawNote) : null; // Include only non-deleted notes if (note && !note.isDeleted) { diff --git a/src/test/suite/noteManager.test.ts b/src/test/suite/noteManager.test.ts index d742eb1..afd4ae9 100644 --- a/src/test/suite/noteManager.test.ts +++ b/src/test/suite/noteManager.test.ts @@ -502,6 +502,39 @@ suite('NoteManager Test Suite', () => { assert.ok(true); }); }); + + suite('Schema Defaults', () => { + test('getNotesForFile applies defaults to legacy notes (no structured fields)', async () => { + // Write a legacy-shaped note directly to storage, bypassing NoteManager. + // The writer omits fields that equal defaults, so a note without type/scope/tags + // written this way will be read back with those fields as undefined. + await (storage as any).saveNote({ + id: 'legacy-1', + content: 'old', + author: 'alice', + filePath: '/abs/x.ts', + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:legacy', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + history: [], + }); + + // Confirm the storage parser does NOT auto-fill defaults (raw read returns undefined) + const rawNotes = await (storage as any).loadNotes('/abs/x.ts'); + const rawLegacy = rawNotes.find((n: any) => n.id === 'legacy-1'); + assert.ok(rawLegacy, 'legacy note should be present in storage'); + assert.strictEqual(rawLegacy.type, undefined, 'storage should NOT fill type default'); + + // Now read via NoteManager — defaults must be applied + const notes = await noteManager.getNotesForFile('/abs/x.ts'); + const legacy = notes.find((n: any) => n.id === 'legacy-1'); + assert.ok(legacy, 'legacy note should be returned by getNotesForFile'); + assert.strictEqual(legacy!.type, 'context'); + assert.strictEqual(legacy!.scope, 'line'); + assert.deepStrictEqual(legacy!.tags, []); + }); + }); }); /** From 70ddda72e2eebd6987032414207fefbece19a324 Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Sat, 16 May 2026 22:23:31 +0600 Subject: [PATCH 09/31] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20INDEX.json=20an?= =?UTF-8?q?d=20AGENTS.md=20generators?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure functions buildIndex(notes, workspaceRoot) and buildDigest(notes, workspaceRoot). Deterministic output for the same input. No I/O — called by exportWriter. Co-Authored-By: Claude Sonnet 4.6 --- src/exportGenerator.ts | 167 +++++++++++++++++++++++++ src/test/runUnitTests.ts | 2 +- src/test/suite/exportGenerator.test.ts | 82 ++++++++++++ 3 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 src/exportGenerator.ts create mode 100644 src/test/suite/exportGenerator.test.ts diff --git a/src/exportGenerator.ts b/src/exportGenerator.ts new file mode 100644 index 0000000..d8e6d68 --- /dev/null +++ b/src/exportGenerator.ts @@ -0,0 +1,167 @@ +/** + * Pure functions that build the contents of INDEX.json and AGENTS.md + * from a list of notes. No I/O — call these from exportWriter. + */ + +import * as path from 'path'; +import { Note, NoteType } from './types.js'; +import { applyDefaults, isExpired } from './noteDefaults.js'; + +export interface IndexNoteEntry { + id: string; + filePath: string; + lineRange: { start: number; end: number }; + type: NoteType; + scope: string; + tags: string[]; + priority: string; + author: string; + authorType: string; + createdAt: string; + updatedAt: string; + expiresAt: string | null; + isExpired: boolean; + references: { kind: string; value: string; label?: string }[]; + contentPreview: string; + contentPath: string; +} + +export interface IndexFile { + version: 1; + generatedAt: string; + workspaceRoot: string; + notes: IndexNoteEntry[]; + byFile: Record; + byType: Record; + byTag: Record; + errors: { file: string; message: string }[]; +} + +const PREVIEW_MAX = 200; + +function relativePath(absPath: string, workspaceRoot: string): string { + return path.relative(workspaceRoot, absPath).split(path.sep).join('/'); +} + +function preview(content: string): string { + const single = content.replace(/\s+/g, ' ').trim(); + return single.length <= PREVIEW_MAX ? single : single.slice(0, PREVIEW_MAX) + '…'; +} + +/** + * Build the INDEX.json data. `now` is injectable for deterministic tests. + */ +export function buildIndex(rawNotes: Note[], workspaceRoot: string, now: Date = new Date()): IndexFile { + const notes = rawNotes.map(applyDefaults); + + // Stable sort by id so output is deterministic for the same input set. + const sorted = [...notes].sort((a, b) => a.id.localeCompare(b.id)); + + const entries: IndexNoteEntry[] = sorted.map(n => ({ + id: n.id, + filePath: relativePath(n.filePath, workspaceRoot), + lineRange: n.lineRange, + type: n.type!, + scope: n.scope!, + tags: n.tags!, + priority: n.priority!, + author: n.author, + authorType: n.authorType!, + createdAt: n.createdAt, + updatedAt: n.updatedAt, + expiresAt: n.expiresAt ?? null, + isExpired: isExpired(n, now), + references: n.references!, + contentPreview: preview(n.content), + contentPath: `.code-notes/${n.id}.md`, + })); + + const byFile: Record = {}; + const byType: Record = {}; + const byTag: Record = {}; + + for (const e of entries) { + (byFile[e.filePath] ??= []).push(e.id); + (byType[e.type] ??= []).push(e.id); + for (const t of e.tags) { + (byTag[t] ??= []).push(e.id); + } + } + + return { + version: 1, + generatedAt: now.toISOString(), + workspaceRoot, + notes: entries, + byFile, + byType, + byTag, + errors: [], + }; +} + +const PRIORITY_RANK: Record = { critical: 0, high: 1, normal: 2, low: 3 }; + +/** + * Build the AGENTS.md digest content. + */ +export function buildDigest(rawNotes: Note[], workspaceRoot: string, now: Date = new Date()): string { + const notes = rawNotes.map(applyDefaults).filter(n => !isExpired(n, now)); + + const out: string[] = []; + out.push('# Code Notes Digest'); + out.push('*Auto-generated. Do not edit. Source: `.code-notes/INDEX.json`*'); + out.push(''); + + const critical = notes + .filter(n => n.type === 'instruction' || n.type === 'warning') + .sort((a, b) => (PRIORITY_RANK[a.priority!] ?? 99) - (PRIORITY_RANK[b.priority!] ?? 99)); + if (critical.length > 0) { + out.push('## Critical instructions and warnings'); + for (const n of critical) { + out.push(`- **\`${relativePath(n.filePath, workspaceRoot)}:${n.lineRange.start + 1}\` — ${n.type} (${n.priority}):** ${preview(n.content)}`); + } + out.push(''); + } + + const handoffs = notes.filter(n => n.type === 'handoff'); + if (handoffs.length > 0) { + out.push('## Open handoffs'); + for (const n of handoffs) { + const range = n.lineRange.start === n.lineRange.end ? `${n.lineRange.start + 1}` : `${n.lineRange.start + 1}-${n.lineRange.end + 1}`; + out.push(`- **\`${relativePath(n.filePath, workspaceRoot)}:${range}\`** — handoff from ${n.author}: ${preview(n.content)}`); + } + out.push(''); + } + + const decisions = notes.filter(n => n.type === 'decision'); + if (decisions.length > 0) { + out.push('## Decisions worth knowing'); + for (const n of decisions) { + out.push(`- **\`${relativePath(n.filePath, workspaceRoot)}:${n.lineRange.start + 1}\`** — decision: ${preview(n.content)}`); + } + out.push(''); + } + + // All notes by file, sorted + out.push('## All notes by file'); + const byFile = new Map(); + for (const n of notes) { + const key = relativePath(n.filePath, workspaceRoot); + const arr = byFile.get(key) ?? []; + arr.push(n); + byFile.set(key, arr); + } + const fileKeys = Array.from(byFile.keys()).sort(); + for (const file of fileKeys) { + out.push(`### \`${file}\``); + const fileNotes = byFile.get(file)!.sort((a, b) => a.lineRange.start - b.lineRange.start); + for (const n of fileNotes) { + const tags = n.tags!.length > 0 ? ` [${n.tags!.join(', ')}]` : ''; + out.push(`- L${n.lineRange.start + 1} — ${n.type} (${n.priority})${tags}: ${preview(n.content)}`); + } + out.push(''); + } + + return out.join('\n'); +} diff --git a/src/test/runUnitTests.ts b/src/test/runUnitTests.ts index ab5f57d..964a09c 100644 --- a/src/test/runUnitTests.ts +++ b/src/test/runUnitTests.ts @@ -35,7 +35,7 @@ async function main() { const unitTestFiles = files.filter(f => { const basename = path.basename(f); // Only include tests that don't require vscode - return basename === 'storageManager.test.js' || basename === 'gitIntegration.test.js' || basename === 'noteDefaults.test.js'; + return basename === 'storageManager.test.js' || basename === 'gitIntegration.test.js' || basename === 'noteDefaults.test.js' || basename === 'exportGenerator.test.js'; }); console.log(`Running ${unitTestFiles.length} unit tests (excluding integration tests)`); diff --git a/src/test/suite/exportGenerator.test.ts b/src/test/suite/exportGenerator.test.ts new file mode 100644 index 0000000..5b816fa --- /dev/null +++ b/src/test/suite/exportGenerator.test.ts @@ -0,0 +1,82 @@ +import * as assert from 'assert'; +import { buildIndex, buildDigest } from '../../exportGenerator.js'; +import { Note } from '../../types.js'; + +const n = (overrides: Partial): Note => ({ + id: 'id', + content: 'content', + author: 'alice', + filePath: '/ws/src/foo.ts', + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:x', + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', + history: [], + type: 'context', + scope: 'line', + tags: [], + references: [], + priority: 'normal', + authorType: 'human', + ...overrides, +}); + +suite('exportGenerator', () => { + test('buildIndex includes notes, byFile, byType, byTag', () => { + const notes = [ + n({ id: 'a', filePath: '/ws/src/foo.ts', type: 'instruction', tags: ['security'] }), + n({ id: 'b', filePath: '/ws/src/bar.ts', type: 'context' }), + ]; + const idx = buildIndex(notes, '/ws'); + assert.strictEqual(idx.version, 1); + assert.strictEqual(idx.workspaceRoot, '/ws'); + assert.strictEqual(idx.notes.length, 2); + assert.deepStrictEqual(idx.byFile['src/foo.ts'], ['a']); + assert.deepStrictEqual(idx.byFile['src/bar.ts'], ['b']); + assert.deepStrictEqual(idx.byType['instruction'], ['a']); + assert.deepStrictEqual(idx.byType['context'], ['b']); + assert.deepStrictEqual(idx.byTag['security'], ['a']); + }); + + test('buildIndex marks expired notes', () => { + const past = new Date('2025-01-01T00:00:00Z').toISOString(); + const idx = buildIndex( + [n({ id: 'a', expiresAt: past })], + '/ws', + new Date('2026-05-01T00:00:00Z'), + ); + assert.strictEqual(idx.notes[0].isExpired, true); + }); + + test('buildIndex output is deterministic for same input', () => { + const notes = [ + n({ id: 'b', filePath: '/ws/b.ts' }), + n({ id: 'a', filePath: '/ws/a.ts' }), + ]; + const idx1 = buildIndex(notes, '/ws', new Date('2026-05-01T00:00:00Z')); + const idx2 = buildIndex(notes, '/ws', new Date('2026-05-01T00:00:00Z')); + assert.strictEqual(JSON.stringify(idx1), JSON.stringify(idx2)); + }); + + test('buildDigest hoists instructions and warnings', () => { + const notes = [ + n({ id: 'i', type: 'instruction', priority: 'high', content: 'do not bypass auth', filePath: '/ws/auth.ts', lineRange: { start: 41, end: 41 } }), + n({ id: 'c', type: 'context', content: 'just background', filePath: '/ws/auth.ts' }), + ]; + const md = buildDigest(notes, '/ws'); + const instructionPos = md.indexOf('do not bypass auth'); + const contextPos = md.indexOf('just background'); + assert.ok(instructionPos !== -1); + assert.ok(contextPos !== -1); + assert.ok(instructionPos < contextPos, 'instructions must appear before context'); + }); + + test('buildDigest hoists open handoffs to a dedicated section', () => { + const md = buildDigest( + [n({ id: 'h', type: 'handoff', content: 'pick up here', author: 'claude-code', authorType: 'agent' })], + '/ws', + ); + assert.ok(md.includes('## Open handoffs')); + assert.ok(md.includes('pick up here')); + }); +}); From 049a125203ec20114c739c4a2d0ddd63cf0ce3fa Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Sat, 16 May 2026 22:27:10 +0600 Subject: [PATCH 10/31] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20debounced=20ato?= =?UTF-8?q?mic=20ExportWriter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owns the 200ms debounce timer for note-write bursts and the write-temp-then-rename atomic-write logic. Failures route through onError so a disk-full event never blocks note saves. --- src/exportWriter.ts | 83 +++++++++++++++++++++++++++++ src/test/runUnitTests.ts | 2 +- src/test/suite/exportWriter.test.ts | 59 ++++++++++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 src/exportWriter.ts create mode 100644 src/test/suite/exportWriter.test.ts diff --git a/src/exportWriter.ts b/src/exportWriter.ts new file mode 100644 index 0000000..9bfe049 --- /dev/null +++ b/src/exportWriter.ts @@ -0,0 +1,83 @@ +/** + * Debounced atomic writer for INDEX.json and AGENTS.md. + * + * Owns the debounce timer and the temp-file-and-rename logic. Failures + * are surfaced via the onError callback — never thrown — because export + * regeneration is best-effort and must never block a note write. + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { Note } from './types.js'; +import { buildIndex, buildDigest } from './exportGenerator.js'; + +export interface ExportWriterOptions { + debounceMs?: number; +} + +export class ExportWriter { + private workspaceRoot: string; + private storageDir: string; + private debounceMs: number; + private pendingTimer: NodeJS.Timeout | undefined; + private pendingGetNotes: (() => Promise) | undefined; + onError: (e: Error) => void = (e) => console.error('[code-notes] export failed:', e); + + constructor(workspaceRoot: string, storageDir: string, opts: ExportWriterOptions = {}) { + this.workspaceRoot = workspaceRoot; + this.storageDir = storageDir; + this.debounceMs = opts.debounceMs ?? 200; + } + + /** + * Schedule a regeneration. Subsequent calls within the debounce window + * coalesce — only the latest getNotes is used. + */ + scheduleRegenerate(getNotes: () => Promise): void { + this.pendingGetNotes = getNotes; + if (this.pendingTimer) { + clearTimeout(this.pendingTimer); + } + this.pendingTimer = setTimeout(() => { + const fn = this.pendingGetNotes; + this.pendingTimer = undefined; + this.pendingGetNotes = undefined; + if (fn) { + fn().then(notes => this.regenerate(notes)).catch(e => this.onError(e)); + } + }, this.debounceMs); + } + + /** + * Force an immediate (non-debounced) regeneration. Used at startup, + * by the manual command, and by tests. + */ + async regenerate(notes: Note[]): Promise { + try { + const dir = path.join(this.workspaceRoot, this.storageDir); + await fs.mkdir(dir, { recursive: true }); + const idx = buildIndex(notes, this.workspaceRoot); + const digest = buildDigest(notes, this.workspaceRoot); + await this.atomicWrite(path.join(dir, 'INDEX.json'), JSON.stringify(idx, null, 2)); + await this.atomicWrite(path.join(dir, 'AGENTS.md'), digest); + } catch (e: unknown) { + this.onError(e instanceof Error ? e : new Error(String(e))); + } + } + + private async atomicWrite(targetPath: string, content: string): Promise { + const tmpPath = `${targetPath}.tmp`; + await fs.writeFile(tmpPath, content, 'utf-8'); + await fs.rename(tmpPath, targetPath); + } + + /** + * Cancel any pending debounced regeneration. Call from extension deactivate. + */ + dispose(): void { + if (this.pendingTimer) { + clearTimeout(this.pendingTimer); + this.pendingTimer = undefined; + } + } +} diff --git a/src/test/runUnitTests.ts b/src/test/runUnitTests.ts index 964a09c..27a6da7 100644 --- a/src/test/runUnitTests.ts +++ b/src/test/runUnitTests.ts @@ -35,7 +35,7 @@ async function main() { const unitTestFiles = files.filter(f => { const basename = path.basename(f); // Only include tests that don't require vscode - return basename === 'storageManager.test.js' || basename === 'gitIntegration.test.js' || basename === 'noteDefaults.test.js' || basename === 'exportGenerator.test.js'; + return basename === 'storageManager.test.js' || basename === 'gitIntegration.test.js' || basename === 'noteDefaults.test.js' || basename === 'exportGenerator.test.js' || basename === 'exportWriter.test.js'; }); console.log(`Running ${unitTestFiles.length} unit tests (excluding integration tests)`); diff --git a/src/test/suite/exportWriter.test.ts b/src/test/suite/exportWriter.test.ts new file mode 100644 index 0000000..1a89643 --- /dev/null +++ b/src/test/suite/exportWriter.test.ts @@ -0,0 +1,59 @@ +import * as assert from 'assert'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { ExportWriter } from '../../exportWriter.js'; +import { Note } from '../../types.js'; + +async function tmpdir(): Promise { + return await fs.mkdtemp(path.join(os.tmpdir(), 'cn-export-')); +} + +const sampleNote = (id: string, ws: string): Note => ({ + id, + content: `note ${id}`, + author: 'alice', + filePath: path.join(ws, 'src/foo.ts'), + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:x', + createdAt: '2026-05-01T00:00:00Z', + updatedAt: '2026-05-01T00:00:00Z', + history: [], +}); + +suite('ExportWriter', () => { + test('writes INDEX.json and AGENTS.md atomically', async () => { + const ws = await tmpdir(); + await fs.mkdir(path.join(ws, '.code-notes'), { recursive: true }); + const writer = new ExportWriter(ws, '.code-notes', { debounceMs: 0 }); + await writer.regenerate([sampleNote('a', ws)]); + const idxRaw = await fs.readFile(path.join(ws, '.code-notes', 'INDEX.json'), 'utf-8'); + const idx = JSON.parse(idxRaw); + assert.strictEqual(idx.version, 1); + assert.strictEqual(idx.notes[0].id, 'a'); + const digest = await fs.readFile(path.join(ws, '.code-notes', 'AGENTS.md'), 'utf-8'); + assert.ok(digest.startsWith('# Code Notes Digest')); + }); + + test('debounces rapid scheduleRegenerate calls', async () => { + const ws = await tmpdir(); + await fs.mkdir(path.join(ws, '.code-notes'), { recursive: true }); + const writer = new ExportWriter(ws, '.code-notes', { debounceMs: 50 }); + let callCount = 0; + const getNotes = async () => { callCount++; return [sampleNote(`n${callCount}`, ws)]; }; + writer.scheduleRegenerate(getNotes); + writer.scheduleRegenerate(getNotes); + writer.scheduleRegenerate(getNotes); + await new Promise(r => setTimeout(r, 100)); + assert.strictEqual(callCount, 1, 'getNotes should be called once after debounce'); + }); + + test('regeneration failure surfaces via onError without throwing', async () => { + const ws = '/this/path/does/not/exist/anywhere'; + const writer = new ExportWriter(ws, '.code-notes', { debounceMs: 0 }); + let captured: Error | undefined; + writer.onError = (e) => { captured = e; }; + await writer.regenerate([]); + assert.ok(captured, 'onError should have been called'); + }); +}); From 7fc6aa47ab654a53a43bf972ee744b8f26357c24 Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Sat, 16 May 2026 22:29:34 +0600 Subject: [PATCH 11/31] =?UTF-8?q?=E2=9C=A8=20feat:=20regenerate=20exports?= =?UTF-8?q?=20on=20note=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hooks ExportWriter to NoteManager's noteChanged event. Initial export runs on activation. Disabled when codeContextNotes.exports.enabled is false. --- src/extension.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/extension.ts b/src/extension.ts index f44ec44..454c9c7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,8 +11,10 @@ import { CommentController } from './commentController.js'; import { CodeNotesLensProvider } from './codeLensProvider.js'; import { NotesSidebarProvider } from './notesSidebarProvider.js'; import { SearchManager } from './searchManager.js'; +import { ExportWriter } from './exportWriter.js'; let noteManager: NoteManager; +let exportWriter: ExportWriter; let searchManager: SearchManager; let commentController: CommentController; let codeLensProvider: CodeNotesLensProvider; @@ -89,6 +91,22 @@ export async function activate(context: vscode.ExtensionContext) { await searchManager.buildIndex(allNotes); console.log(`Code Context Notes: Search index built with ${allNotes.length} notes`); + // Initialize export writer and hook into note changes + const exportsConfig = vscode.workspace.getConfiguration('codeContextNotes.exports'); + const exportsEnabled = exportsConfig.get('enabled', true); + + exportWriter = new ExportWriter(workspaceRoot, storageDirectory, { debounceMs: 200 }); + context.subscriptions.push({ dispose: () => exportWriter.dispose() }); + + if (exportsEnabled) { + // Initial export on activation (covers fresh installs, manual deletes) + exportWriter.scheduleRegenerate(() => noteManager.getAllNotes()); + // Subsequent changes + noteManager.on('noteChanged', () => { + exportWriter.scheduleRegenerate(() => noteManager.getAllNotes()); + }); + } + // Initialize comment controller commentController = new CommentController(noteManager, context); From 5945d2371f9f8c76cb606d35c7f6b42549afa53b Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Sat, 16 May 2026 22:30:52 +0600 Subject: [PATCH 12/31] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20manual=20Regene?= =?UTF-8?q?rate=20Exports=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets users force a fresh INDEX.json/AGENTS.md, e.g. after a disk-full recovery or a manual delete from the storage directory. --- package.json | 5 +++++ src/extension.ts | 22 +++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index e0302c6..dae0d48 100644 --- a/package.json +++ b/package.json @@ -196,6 +196,11 @@ "title": "View History", "icon": "$(history)", "category": "Code Notes" + }, + { + "command": "codeContextNotes.regenerateExports", + "title": "Regenerate Exports (INDEX.json / AGENTS.md)", + "category": "Code Notes" } ], "keybindings": [ diff --git a/src/extension.ts b/src/extension.ts index 454c9c7..537203d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -950,6 +950,25 @@ function registerAllCommands(context: vscode.ExtensionContext) { } ); + // Regenerate Exports + const regenerateExportsCommand = vscode.commands.registerCommand( + 'codeContextNotes.regenerateExports', + async () => { + if (!noteManager || !exportWriter) { + vscode.window.showErrorMessage('Code Context Notes requires a workspace folder to be opened.'); + return; + } + + try { + const notes = await noteManager.getAllNotes(); + await exportWriter.regenerate(notes); + vscode.window.showInformationMessage(`Code Notes: regenerated exports for ${notes.length} notes.`); + } catch (error) { + vscode.window.showErrorMessage(`Failed to regenerate exports: ${error}`); + } + } + ); + // Register all commands context.subscriptions.push( addNoteCommand, @@ -981,7 +1000,8 @@ function registerAllCommands(context: vscode.ExtensionContext) { editNoteFromSidebarCommand, deleteNoteFromSidebarCommand, viewNoteHistoryFromSidebarCommand, - openFileFromSidebarCommand + openFileFromSidebarCommand, + regenerateExportsCommand ); } From ac0068df2c3433eadc6da8de8180d7afce139c8d Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Sat, 16 May 2026 22:32:54 +0600 Subject: [PATCH 13/31] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20codeContextNote?= =?UTF-8?q?s.exports.*=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three booleans: enabled (master switch), indexJson, agentsMarkdown. All default true. Read on every regeneration so toggling takes effect without reload. --- package.json | 15 +++++++++++++++ src/exportWriter.ts | 18 ++++++++++++++---- src/extension.ts | 12 +++++++++++- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index dae0d48..41457f7 100644 --- a/package.json +++ b/package.json @@ -424,6 +424,21 @@ ], "default": "file", "description": "Sort notes by file path" + }, + "codeContextNotes.exports.enabled": { + "type": "boolean", + "default": true, + "description": "Auto-generate .code-notes/INDEX.json and .code-notes/AGENTS.md on every note change" + }, + "codeContextNotes.exports.indexJson": { + "type": "boolean", + "default": true, + "description": "Generate INDEX.json (machine-readable)" + }, + "codeContextNotes.exports.agentsMarkdown": { + "type": "boolean", + "default": true, + "description": "Generate AGENTS.md (human-readable digest)" } } } diff --git a/src/exportWriter.ts b/src/exportWriter.ts index 9bfe049..9f41096 100644 --- a/src/exportWriter.ts +++ b/src/exportWriter.ts @@ -13,6 +13,7 @@ import { buildIndex, buildDigest } from './exportGenerator.js'; export interface ExportWriterOptions { debounceMs?: number; + getConfig?: () => { enabled: boolean; indexJson: boolean; agentsMarkdown: boolean }; } export class ExportWriter { @@ -21,12 +22,14 @@ export class ExportWriter { private debounceMs: number; private pendingTimer: NodeJS.Timeout | undefined; private pendingGetNotes: (() => Promise) | undefined; + private getConfig: () => { enabled: boolean; indexJson: boolean; agentsMarkdown: boolean }; onError: (e: Error) => void = (e) => console.error('[code-notes] export failed:', e); constructor(workspaceRoot: string, storageDir: string, opts: ExportWriterOptions = {}) { this.workspaceRoot = workspaceRoot; this.storageDir = storageDir; this.debounceMs = opts.debounceMs ?? 200; + this.getConfig = opts.getConfig ?? (() => ({ enabled: true, indexJson: true, agentsMarkdown: true })); } /** @@ -54,12 +57,19 @@ export class ExportWriter { */ async regenerate(notes: Note[]): Promise { try { + const cfg = this.getConfig(); + if (!cfg.enabled) return; + const dir = path.join(this.workspaceRoot, this.storageDir); await fs.mkdir(dir, { recursive: true }); - const idx = buildIndex(notes, this.workspaceRoot); - const digest = buildDigest(notes, this.workspaceRoot); - await this.atomicWrite(path.join(dir, 'INDEX.json'), JSON.stringify(idx, null, 2)); - await this.atomicWrite(path.join(dir, 'AGENTS.md'), digest); + if (cfg.indexJson) { + const idx = buildIndex(notes, this.workspaceRoot); + await this.atomicWrite(path.join(dir, 'INDEX.json'), JSON.stringify(idx, null, 2)); + } + if (cfg.agentsMarkdown) { + const digest = buildDigest(notes, this.workspaceRoot); + await this.atomicWrite(path.join(dir, 'AGENTS.md'), digest); + } } catch (e: unknown) { this.onError(e instanceof Error ? e : new Error(String(e))); } diff --git a/src/extension.ts b/src/extension.ts index 537203d..00ff3b0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -95,7 +95,17 @@ export async function activate(context: vscode.ExtensionContext) { const exportsConfig = vscode.workspace.getConfiguration('codeContextNotes.exports'); const exportsEnabled = exportsConfig.get('enabled', true); - exportWriter = new ExportWriter(workspaceRoot, storageDirectory, { debounceMs: 200 }); + exportWriter = new ExportWriter(workspaceRoot, storageDirectory, { + debounceMs: 200, + getConfig: () => { + const cfg = vscode.workspace.getConfiguration('codeContextNotes.exports'); + return { + enabled: cfg.get('enabled', true), + indexJson: cfg.get('indexJson', true), + agentsMarkdown: cfg.get('agentsMarkdown', true), + }; + }, + }); context.subscriptions.push({ dispose: () => exportWriter.dispose() }); if (exportsEnabled) { From e84cbac97b41c493b587a5c879cc60aa1ac2bf71 Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Sat, 16 May 2026 22:33:46 +0600 Subject: [PATCH 14/31] =?UTF-8?q?=F0=9F=93=9D=20docs:=20document=20INDEX.j?= =?UTF-8?q?son=20/=20AGENTS.md=20exports=20in=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 94c568f..c322bd9 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,23 @@ Previous content before this edit You can add `.code-notes/` to `.gitignore` if you want notes to be local only, or commit them to share with your team. +### Generated exports + +When auto-exports are enabled, two files are regenerated in `.code-notes/` +on every note change: + +- **`INDEX.json`** — machine-readable index. Used by integrations like the MCP server (v0.4+). +- **`AGENTS.md`** — human-readable digest, hoisting instructions/warnings/handoffs. Useful as workspace context for coding agents. + +Both are deterministic given the same notes. To exclude them from git, add to `.gitignore`: + +``` +.code-notes/INDEX.json +.code-notes/AGENTS.md +``` + +To disable export generation entirely, set `codeContextNotes.exports.enabled` to `false`. + ## Commands All commands are available in the Command Palette (`Ctrl+Shift+P` or `Cmd+Shift+P`): From c225e46eeb51cef44036f165e4670e9008ee500e Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Sat, 16 May 2026 22:35:14 +0600 Subject: [PATCH 15/31] =?UTF-8?q?=F0=9F=92=84=20feat(sidebar):=20show=20no?= =?UTF-8?q?te=20type=20in=20tree=20item=20description?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Append · {type} to NoteTreeItem.description for non-context types, and prepend [priority] to the tooltip for high/critical notes. Co-Authored-By: Claude Sonnet 4.6 --- src/noteTreeItem.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/noteTreeItem.ts b/src/noteTreeItem.ts index 19c8e57..fa00cc6 100644 --- a/src/noteTreeItem.ts +++ b/src/noteTreeItem.ts @@ -6,6 +6,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { Note } from './types.js'; +import { applyDefaults } from './noteDefaults.js'; /** * Base class for all tree items @@ -79,8 +80,12 @@ export class NoteTreeItem extends BaseTreeItem { super(label, vscode.TreeItemCollapsibleState.None); this.description = note.author; // Shows right-aligned + const filled = applyDefaults(note); + if (filled.type && filled.type !== 'context') { + this.description = `${this.description ?? ''} · ${filled.type}`.trim(); + } this.contextValue = 'noteNode'; - this.tooltip = this.createTooltip(); + this.tooltip = this.createTooltip(filled.priority); this.iconPath = new vscode.ThemeIcon('note'); // Command to navigate to note when clicked @@ -94,7 +99,7 @@ export class NoteTreeItem extends BaseTreeItem { /** * Create rich tooltip with full note content */ - private createTooltip(): vscode.MarkdownString { + private createTooltip(priority?: string): vscode.MarkdownString { const tooltip = new vscode.MarkdownString(); tooltip.isTrusted = true; tooltip.supportHtml = true; @@ -103,6 +108,9 @@ export class NoteTreeItem extends BaseTreeItem { const created = new Date(this.note.createdAt).toLocaleString(); const updated = new Date(this.note.updatedAt).toLocaleString(); + if (priority === 'high' || priority === 'critical') { + tooltip.appendMarkdown(`**[${priority}]**\n\n`); + } tooltip.appendMarkdown(`**${lineRange}**\n\n`); tooltip.appendMarkdown(`**Author:** ${this.note.author}\n\n`); tooltip.appendMarkdown(`**Created:** ${created}\n\n`); From b87884a4744cb2d0e19c7aea6299a36c9d433885 Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Sat, 16 May 2026 22:37:57 +0600 Subject: [PATCH 16/31] =?UTF-8?q?=E2=9C=A8=20feat(sidebar):=20add=20type?= =?UTF-8?q?=20filter=20and=20expired-notes=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New title-bar actions: filter by type (multi-select Quick Pick) and toggle expired-note visibility. Default hides expired notes. --- package.json | 22 ++++++++++++++++++++++ src/extension.ts | 25 ++++++++++++++++++++++++- src/notesSidebarProvider.ts | 29 +++++++++++++++++++++++++++-- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 41457f7..9317f07 100644 --- a/package.json +++ b/package.json @@ -201,6 +201,18 @@ "command": "codeContextNotes.regenerateExports", "title": "Regenerate Exports (INDEX.json / AGENTS.md)", "category": "Code Notes" + }, + { + "command": "codeContextNotes.filterByType", + "title": "Filter Notes by Type…", + "category": "Code Notes", + "icon": "$(filter)" + }, + { + "command": "codeContextNotes.toggleExpired", + "title": "Toggle Expired Notes", + "category": "Code Notes", + "icon": "$(eye)" } ], "keybindings": [ @@ -277,6 +289,16 @@ "command": "codeContextNotes.refreshSidebar", "when": "view == codeContextNotes.sidebarView", "group": "navigation@3" + }, + { + "command": "codeContextNotes.filterByType", + "when": "view == codeContextNotes.sidebarView", + "group": "navigation" + }, + { + "command": "codeContextNotes.toggleExpired", + "when": "view == codeContextNotes.sidebarView", + "group": "navigation" } ], "view/item/context": [ diff --git a/src/extension.ts b/src/extension.ts index 00ff3b0..c452298 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -979,6 +979,27 @@ function registerAllCommands(context: vscode.ExtensionContext) { } ); + // Filter Notes by Type + const filterByTypeCommand = vscode.commands.registerCommand( + 'codeContextNotes.filterByType', + async () => { + const choices = ['context', 'instruction', 'warning', 'decision', 'todo', 'handoff', 'rationale']; + const picked = await vscode.window.showQuickPick(choices, { + canPickMany: true, + title: 'Show only notes of these types (cancel to clear filter)', + }); + sidebarProvider.setTypeFilter(picked && picked.length > 0 ? new Set(picked) : null); + } + ); + + // Toggle Expired Notes + const toggleExpiredCommand = vscode.commands.registerCommand( + 'codeContextNotes.toggleExpired', + () => { + sidebarProvider.toggleHideExpired(); + } + ); + // Register all commands context.subscriptions.push( addNoteCommand, @@ -1011,7 +1032,9 @@ function registerAllCommands(context: vscode.ExtensionContext) { deleteNoteFromSidebarCommand, viewNoteHistoryFromSidebarCommand, openFileFromSidebarCommand, - regenerateExportsCommand + regenerateExportsCommand, + filterByTypeCommand, + toggleExpiredCommand ); } diff --git a/src/notesSidebarProvider.ts b/src/notesSidebarProvider.ts index f8ddac4..6131a51 100644 --- a/src/notesSidebarProvider.ts +++ b/src/notesSidebarProvider.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import { NoteManager } from './noteManager.js'; import { Note } from './types.js'; import { RootTreeItem, FileTreeItem, NoteTreeItem, BaseTreeItem } from './noteTreeItem.js'; +import { applyDefaults, isExpired } from './noteDefaults.js'; /** * Notes Sidebar Provider @@ -22,6 +23,9 @@ export class NotesSidebarProvider implements vscode.TreeDataProvider | null = null; // null = no filter + private hideExpired: boolean = true; + constructor( private readonly noteManager: NoteManager, private readonly workspaceRoot: string, @@ -73,6 +77,22 @@ export class NotesSidebarProvider implements vscode.TreeDataProvider | null): void { + this.typeFilter = types; + this._onDidChangeTreeData.fire(); + } + + /** + * Toggle visibility of expired notes. + */ + toggleHideExpired(): void { + this.hideExpired = !this.hideExpired; + this._onDidChangeTreeData.fire(); + } + /** * Get tree item for a node (required by TreeDataProvider) */ @@ -161,14 +181,19 @@ export class NotesSidebarProvider implements vscode.TreeDataProvider !this.hideExpired || !isExpired(n)) + .filter(n => !this.typeFilter || this.typeFilter.has(n.type!)); + + for (const note of filtered) { noteNodes.push(new NoteTreeItem(note, previewLength)); } From e9edc10c99dbcc97a642adc5698519a4268a5f57 Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Sat, 16 May 2026 22:40:08 +0600 Subject: [PATCH 17/31] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20command=20to=20?= =?UTF-8?q?set=20note=20type/priority/tags/expiry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quick Pick driven flow: codeContextNotes.setNoteMetadata. Lets users enrich notes after creation. Future v0.5 work will inline this in the add/edit comment thread UI; this command unblocks v0.3 use cases. --- package.json | 5 +++++ src/extension.ts | 50 +++++++++++++++++++++++++++++++++++++++++++++- src/noteManager.ts | 30 +++++++++++++++++++++++++++- 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 9317f07..0d1e7b8 100644 --- a/package.json +++ b/package.json @@ -213,6 +213,11 @@ "title": "Toggle Expired Notes", "category": "Code Notes", "icon": "$(eye)" + }, + { + "command": "codeContextNotes.setNoteMetadata", + "title": "Set Note Type / Tags / Priority…", + "category": "Code Notes" } ], "keybindings": [ diff --git a/src/extension.ts b/src/extension.ts index c452298..5d8f307 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1000,6 +1000,53 @@ function registerAllCommands(context: vscode.ExtensionContext) { } ); + // Set Note Metadata (type, priority, tags, expiry) + const setNoteMetadataCommand = vscode.commands.registerCommand( + 'codeContextNotes.setNoteMetadata', + async (noteIdArg?: string) => { + if (!noteManager) { + vscode.window.showErrorMessage('Code Context Notes requires a workspace folder to be opened.'); + return; + } + + let noteId = noteIdArg; + if (!noteId) { + const all = await noteManager.getAllNotes(); + const pick = await vscode.window.showQuickPick( + all.map(n => ({ label: n.id, description: n.content.slice(0, 60) })), + { title: 'Pick a note to update' }, + ); + if (!pick) return; + noteId = pick.label; + } + + const type = await vscode.window.showQuickPick( + ['context', 'instruction', 'warning', 'decision', 'todo', 'handoff', 'rationale'], + { title: 'Type' }, + ); + if (!type) return; + + const priority = await vscode.window.showQuickPick( + ['low', 'normal', 'high', 'critical'], + { title: 'Priority' }, + ); + if (!priority) return; + + const tagsRaw = await vscode.window.showInputBox({ title: 'Tags (comma-separated, optional)' }); + const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : []; + + const expiresRaw = await vscode.window.showInputBox({ title: 'Expires at (ISO 8601, optional)' }); + const expiresAt = expiresRaw && expiresRaw.trim().length > 0 ? expiresRaw.trim() : undefined; + + try { + await noteManager.updateNoteMetadata(noteId, { type: type as any, priority: priority as any, tags, expiresAt }); + vscode.window.showInformationMessage(`Code Notes: updated metadata for ${noteId}.`); + } catch (error) { + vscode.window.showErrorMessage(`Failed to update note metadata: ${error}`); + } + } + ); + // Register all commands context.subscriptions.push( addNoteCommand, @@ -1034,7 +1081,8 @@ function registerAllCommands(context: vscode.ExtensionContext) { openFileFromSidebarCommand, regenerateExportsCommand, filterByTypeCommand, - toggleExpiredCommand + toggleExpiredCommand, + setNoteMetadataCommand ); } diff --git a/src/noteManager.ts b/src/noteManager.ts index f3c23ab..627f21c 100644 --- a/src/noteManager.ts +++ b/src/noteManager.ts @@ -7,7 +7,7 @@ import * as vscode from 'vscode'; import * as path from 'path'; import { v4 as uuidv4 } from 'uuid'; import { EventEmitter } from 'events'; -import { Note, CreateNoteParams, UpdateNoteParams, LineRange } from './types.js'; +import { Note, CreateNoteParams, UpdateNoteParams, LineRange, NoteType, NotePriority, NoteScope } from './types.js'; import { StorageManager } from './storageManager.js'; import { ContentHashTracker } from './contentHashTracker.js'; import { GitIntegration } from './gitIntegration.js'; @@ -171,6 +171,34 @@ export class NoteManager extends EventEmitter { return note; } + /** + * Update only the metadata fields of an existing note (type, priority, tags, expiresAt, scope). + * Does not touch content, lineRange, contentHash, or history. + */ + async updateNoteMetadata( + noteId: string, + fields: { type?: NoteType; priority?: NotePriority; tags?: string[]; expiresAt?: string; scope?: NoteScope }, + ): Promise { + const existing = await this.storage.loadNoteById(noteId); + if (!existing) throw new Error(`Note ${noteId} not found`); + + // Merge: only overwrite fields explicitly provided + const updated: Note = { + ...existing, + ...fields, + updatedAt: new Date().toISOString(), + }; + await this.storage.saveNote(updated); + + // Mirror the cache-invalidation pattern used in updateNote + this.updateNoteInCache(updated); + this.clearWorkspaceCache(); + + this.emit('noteUpdated', updated); + this.emit('noteChanged', { type: 'updated', note: updated }); + return applyDefaults(updated); + } + /** * Delete a note (soft delete) */ From 20dd8133c1b6c9f12afebf544d04a922d75cf590 Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Sat, 16 May 2026 22:43:10 +0600 Subject: [PATCH 18/31] =?UTF-8?q?=F0=9F=93=9D=20docs:=20merge=20v0.3.0=20c?= =?UTF-8?q?hangelog=20(agent=20integration=20+=20search)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Combines the previously drafted Search and Filter feature changelog with the new agent-integration features (structured schema, INDEX.json/AGENTS.md exports, sidebar enhancements). Both ship as v0.3.0. --- docs/changelogs/v0.3.0.md | 168 +++++++++----------------------------- 1 file changed, 37 insertions(+), 131 deletions(-) diff --git a/docs/changelogs/v0.3.0.md b/docs/changelogs/v0.3.0.md index bc4ac16..27894a2 100644 --- a/docs/changelogs/v0.3.0.md +++ b/docs/changelogs/v0.3.0.md @@ -1,147 +1,53 @@ # Changelog - Version 0.3.0 -## [0.3.0] - TBD +## [0.3.0] - 2026-05-16 ### Added +- **Structured Note Schema for Agent Integration** + - Optional `type` (7 categories: `context` default, `instruction`, `warning`, `decision`, `todo`, `handoff`, `rationale`), `priority` (`low`/`normal`/`high`/`critical`), `scope` (`line`/`function`/`class`/`file`/`directory`), `tags`, `authorType` (`human`/`agent`), `expiresAt` (ISO 8601), and `references` (PR/issue/commit/test/url) fields on every note + - All fields optional — legacy v0.2.x notes load with sensible defaults (no migration required) + - New command **`Code Notes: Set Note Type / Tags / Priority…`** — Quick-Pick driven metadata editor for any note +- **Auto-Generated Workspace Exports for Coding Agents** + - **`.code-notes/INDEX.json`** — machine-readable index with `byFile`/`byType`/`byTag` lookups and deterministic output. Designed for MCP server consumption (v0.4+). + - **`.code-notes/AGENTS.md`** — human-readable digest hoisting critical instructions/warnings, open handoffs, and decisions. Useful as workspace context for coding agents (Claude Code, Cursor, etc.). + - Debounced (200ms) atomic writes on every note change; new `Code Notes: Regenerate Exports` command for manual recovery. +- **Sidebar Enhancements** + - Note type appears as `· {type}` description suffix; high/critical priority shown as a tooltip badge + - New title-bar actions: **Filter Notes by Type…** (multi-select Quick Pick) and **Toggle Expired Notes** - **Search and Filter Notes** (GitHub Issue #10) - - Full-text search across all note content - - Filter by author (single or multiple authors) - - Filter by date range (created or modified) - - Filter by file path (glob pattern support) - - Combine multiple filters (AND logic) - - Search input with live results update (debounced 200ms) - - Keyboard shortcut for quick search (`Ctrl+Shift+F` / `Cmd+Shift+F` in notes context) - - Search icon integrated into sidebar toolbar - - Search results show file, line number, preview, and author - - Click any result to navigate directly to note location - - Clear filters button - - Search result count indicator - - Keyboard navigation in results (↑↓ arrows, Enter to open) - - Regex pattern matching support for advanced searches - - Case-sensitive search option - - Search history persistence (last 20 searches) - - "Recent Searches" quick access - - Background indexing for instant search results - - Progress indicator for large searches + - Full-text search across all note content with author, date range, and file-path (glob) filters + - Combines filters with AND logic; debounced live results; `Ctrl+Shift+F` / `Cmd+Shift+F` shortcut + - QuickPick UI with file/line/preview/author, click-to-navigate, keyboard navigation (↑↓ / Enter) + - Regex and case-sensitive options; search history (last 20); background indexing for instant results + - Sidebar toolbar search icon; index auto-updates on note CRUD ### Changed -- Search UI uses VSCode's native QuickPick for familiar experience -- Sidebar toolbar now includes search icon (🔍) for quick access -- Search results can be displayed in sidebar view or QuickPick -- Search index automatically updates when notes are created/edited/deleted +- Search UI uses VS Code's native QuickPick for familiar experience +- Sidebar toolbar gains search icon and filter/expired toggles -### Testing -- **64 comprehensive unit tests** for search components -- **SearchManager tests** (20+ tests): - - Full-text search with various queries - - Regex pattern matching - - Author filtering with edge cases - - Date range filtering - - Filter combinations (AND logic) - - Search index updates - - Performance benchmarks -- **SearchUI tests** (10+ tests): - - QuickPick integration - - Keyboard navigation - - Filter UI components - - Result display and formatting -- **Integration tests** (10+ tests): - - Search with sidebar integration - - Real-time index updates - - Multi-note search scenarios -- **Performance testing**: - - ✅ Search with 100 notes: < 500ms - - ✅ Search with 500 notes: < 1 second - - ✅ Search with 1000 notes: < 2 seconds - - ✅ Index updates: < 100ms -- All tests compile successfully with TypeScript -- Existing unit tests continue to pass -- **Manual testing completed successfully**: - - ✅ Full-text search across workspace - - ✅ Filter by author, date, file path - - ✅ Filter combinations - - ✅ Keyboard shortcuts and navigation - - ✅ Search history and recent searches - - ✅ Performance with large note collections - -### Technical -- Created `SearchManager` class for search indexing and queries -- Created `SearchUI` class for VSCode QuickPick integration -- Implemented inverted index for fast full-text search -- Added search metadata indexing (author, dates, file paths) -- Implemented search result ranking algorithm -- Added in-memory caching for search results -- **New commands**: - - `searchNotes` - Open search panel with QuickPick - - `clearSearchFilters` - Clear all active filters - - `showSearchHistory` - Show recent searches - - `rebuildSearchIndex` - Manually rebuild search index -- Added `search` contribution to package.json -- Integrated search with existing sidebar view -- Background indexing on workspace open -- Debounced search (200ms delay) to prevent excessive queries -- Search index automatically updates on note CRUD operations -- **Configuration options**: - - `search.fuzzyMatching` - Enable fuzzy search (default: false) - - `search.caseSensitive` - Case-sensitive search (default: false) - - `search.maxResults` - Maximum results to display (default: 100) - - `search.debounceDelay` - Search delay in ms (default: 200) - - `search.saveHistory` - Save search history (default: true) - - `search.historySize` - Number of searches to keep (default: 20) - ---- - -## Benefits - -**For Users:** -- Find any note instantly without browsing files -- Quickly locate notes by specific authors -- Filter notes by time period (e.g., "last week's notes") -- Discover related notes across the codebase -- Fast keyboard-driven workflow -- Search history prevents re-typing common queries - -**For Teams:** -- Find all notes from specific team members during code reviews -- Locate all notes related to a feature or bug -- Review notes created during a sprint or time period -- Discover documentation and decisions across the project -- Better knowledge sharing and discovery - ---- +### Settings +- `codeContextNotes.exports.enabled` (default `true`), `.indexJson` (default `true`), `.agentsMarkdown` (default `true`) — control auto-export generation +- `search.fuzzyMatching` (default `false`), `search.caseSensitive` (default `false`), `search.maxResults` (default `100`), `search.debounceDelay` (default `200`), `search.saveHistory` (default `true`), `search.historySize` (default `20`) -## Migration Notes +### Compatibility +- Fully backwards-compatible with v0.2.x. Legacy notes load with `type=context`, `scope=line`, `priority=normal`, `tags=[]`, `authorType=human` applied in memory; on-disk file unchanged until next edit. +- Storage format extended with optional bold-label fields (`**Type:**`, `**Tags:**`, etc.). Fields equal to default are omitted, so untouched legacy notes remain byte-identical on disk. -- No breaking changes - purely additive feature -- No data migration required -- Works with existing note storage format -- Backward compatible with all existing notes -- Search can be disabled via keyboard shortcut preferences if not needed -- Search index built automatically on first use - ---- - -## Known Limitations - -- Search index stored in memory (not persisted) - rebuilds on workspace reload -- Maximum 100 results by default (configurable) -- Regex patterns limited by JavaScript RegExp capabilities -- No natural language search in initial release -- No "Replace in Notes" (bulk editing) - planned for future version - ---- +### Testing +- **58+ unit tests** covering schema defaults, storage round-trip (read + write), export generators (buildIndex/buildDigest determinism + section hoisting), debounced atomic writer (debounce coalescing + onError fail-safe), and lazy-migration integration test on NoteManager boundary +- **Search component tests** — SearchManager (full-text, regex, author/date/file filters, performance benchmarks), SearchUI (QuickPick, keyboard nav), and sidebar integration +- Performance: search <500ms for 100 notes, <1s for 500 notes, <2s for 1000 notes; index updates <100ms -## Performance Notes +### Technical +- New modules: `src/noteDefaults.ts` (defaults + expiry helper), `src/exportGenerator.ts` (pure index/digest builders), `src/exportWriter.ts` (debounced atomic writer with injectable config getter) +- `NoteManager` wraps all storage reads with `applyDefaults` — single boundary for lazy migration +- Storage parser/writer in `src/storageManager.ts` extends the existing bold-label markdown format; unknown fields ignored gracefully +- `SearchManager` provides inverted-index full-text search with metadata indexing and in-memory caching; background indexing on workspace open +- New commands: `regenerateExports`, `setNoteMetadata`, `filterByType`, `toggleExpired`, `searchNotes`, `clearSearchFilters`, `showSearchHistory`, `rebuildSearchIndex` -- Search index uses < 10MB memory for 1000 notes -- Inverted index enables sub-second search -- Background indexing prevents UI blocking -- Lazy loading for large result sets -- Debouncing prevents excessive search operations -- Index updates incrementally on note changes +### Coming Next +- v0.4 ships a standalone MCP server backed by `INDEX.json` for direct agent integration (Cursor, Claude Code, etc.) --- -## Links - [0.3.0]: https://github.com/jnahian/code-context-notes/releases/tag/v0.3.0 From 4832d26169f65684c2f255c56f74f392aaca0004 Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Sat, 16 May 2026 22:44:40 +0600 Subject: [PATCH 19/31] =?UTF-8?q?=F0=9F=93=9D=20docs(web):=20add=20v0.3.0?= =?UTF-8?q?=20to=20changelog=20timeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/pages/ChangelogPage.tsx | 129 +++++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 4 deletions(-) diff --git a/web/src/pages/ChangelogPage.tsx b/web/src/pages/ChangelogPage.tsx index baa4922..c67af82 100644 --- a/web/src/pages/ChangelogPage.tsx +++ b/web/src/pages/ChangelogPage.tsx @@ -36,8 +36,8 @@ export function ChangelogPage() { {/* Timeline Items */}
- {/* Version 0.2.1 */} -
+ {/* Version 0.3.0 */} +
{/* Timeline Node */}
@@ -47,10 +47,131 @@ export function ChangelogPage() {

- Version 0.2.1 + Version 0.3.0 Latest

+
+ + May 16, 2026 +
+

+ Agent integration foundation: structured note schema, auto-generated workspace exports for coding agents, full-text search, and sidebar enhancements. +

+
+ + {/* Right Column - Changes */} +
+ + + {/* Added */} +
+

+ + Added +

+
+
+ +
+
Structured Note Schema for Agent Integration
+

+ Optional fields turn free-form notes into machine-readable workspace context for coding agents. +

+
    +
  • Types: context, instruction, warning, decision, todo, handoff, rationale
  • +
  • Priority: low, normal, high, critical
  • +
  • Scope: line, function, class, file, directory
  • +
  • • Tags, expiry (ISO 8601), authorType (human/agent), references (PR/issue/commit/test/url)
  • +
  • • New command Set Note Type / Tags / Priority… to enrich any note
  • +
+
+
+
+ +
+
Auto-Generated Workspace Exports
+

+ Two files regenerate atomically on every note change so any coding agent can ingest workspace notes as context. +

+
    +
  • .code-notes/INDEX.json — machine-readable index with byFile/byType/byTag lookups
  • +
  • .code-notes/AGENTS.md — human-readable digest hoisting instructions, warnings, handoffs, decisions
  • +
  • • Debounced (200ms) writes, atomic temp-then-rename, deterministic output
  • +
  • • Manual Regenerate Exports command for recovery
  • +
+
+
+
+ +
+
Search and Filter Notes (Issue #10)
+

+ Full-text search across all notes with multi-criteria filtering and keyboard-driven QuickPick UI. +

+
    +
  • • Filters: author, date range, file-path glob, type (combinable with AND logic)
  • +
  • Ctrl+Shift+F / Cmd+Shift+F in notes context
  • +
  • • Regex, case-sensitive options, search history (last 20)
  • +
  • • Background indexing — sub-second search for 100+ notes
  • +
+
+
+
+ +
+
Sidebar Enhancements
+

+ Type badges, priority indicators, and new title-bar filters. +

+
    +
  • • Note type shown as · instruction description suffix
  • +
  • • High/critical priority shown as tooltip badge
  • +
  • Filter Notes by Type… (multi-select Quick Pick) and Toggle Expired Notes
  • +
+
+
+
+
+ + {/* Technical */} +
+

+ + Technical +

+
+

• New modules: noteDefaults.ts, exportGenerator.ts, exportWriter.ts

+

NoteManager wraps storage reads with applyDefaults — single boundary for lazy migration of v0.2.x notes

+

• Storage format extended with optional bold-label fields; fields equal to default are omitted so untouched legacy notes remain byte-identical on disk

+

• 58+ unit tests covering schema defaults, storage round-trip, export determinism, and debounced atomic writes

+

• New settings: codeContextNotes.exports.{`{enabled,indexJson,agentsMarkdown}`}

+
+
+ + {/* Coming Next */} +
+

+ Coming next: v0.4 ships a standalone MCP server backed by INDEX.json for direct agent integration (Cursor, Claude Code, and other MCP clients). +

+
+
+
+
+
+ + {/* Version 0.2.1 */} +
+ {/* Timeline Node */} +
+
+
+ + {/* Left Column - Version Info */} +
+
+

Version 0.2.1

+
November 12, 2025 @@ -62,7 +183,7 @@ export function ChangelogPage() { {/* Right Column - Changes */}
- + {/* Added */}
From 7f81a64fe59467f863cbf31bc7999d2e8190f2c6 Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Sat, 16 May 2026 22:45:23 +0600 Subject: [PATCH 20/31] =?UTF-8?q?=F0=9F=94=96=20chore:=20bump=20version=20?= =?UTF-8?q?to=200.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0d1e7b8..ef58eed 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "code-context-notes", "displayName": "Code Context Notes - Smart Annotations", "description": "Add contextual notes to your code with full version history and intelligent tracking", - "version": "0.2.1", + "version": "0.3.0", "publisher": "jnahian", "icon": "images/icon.png", "galleryBanner": { From 63437e0cb9b796474b4e1f1cee200edc1f55fb27 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 12:14:09 +0000 Subject: [PATCH 21/31] fix: address CodeRabbit and Copilot review findings on PR #39 - exportWriter: serialize regenerate() calls so the debounced timer and manual "Regenerate Exports" command can't race on the same tmp files - extension: always subscribe to noteChanged so enabling exports after activation works without a reload; guard filterByType/toggleExpired against missing sidebarProvider in no-workspace activation; surface real success/failure from the regenerateExports command instead of always showing success - noteManager: block metadata updates on soft-deleted notes, normalize updateNoteMetadata/createNote through applyDefaults before caching so cache hits can't return notes with undefined schema fields, and keep the search index in sync on metadata updates - notesSidebarProvider: apply type/expiry filters before building file nodes so the root count and per-file nodes reflect the active filters instead of showing empty files or stale totals - storageManager: validate parsed Type/Scope/Priority/AuthorType/ References against known enums instead of blind `as any`/JSON.parse; reuse NOTE_DEFAULTS instead of duplicated literals when serializing - exportGenerator: thread the configured storage directory into contentPath/digest source line (was hardcoded to .code-notes) and add stable tiebreak sorting so AGENTS.md ordering is deterministic for equal-priority/same-line notes - fix package-lock.json version drift (0.1.7 -> 0.3.0) - fix hardcoded absolute path in web/CLAUDE.md, misleading NotePriority doc comment, missing markdownlint language tags, and remove the stale "Coming in v0.3.0" changelog section duplicating shipped notes Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015NoZbQSkHz14Fo7fnaU5n7 --- README.md | 9 +-- ...ent-integration-v0.3-schema-and-exports.md | 2 +- .../2026-05-15-agent-integration-design.md | 2 +- package-lock.json | 4 +- src/exportGenerator.ts | 36 +++++++--- src/exportWriter.ts | 17 ++++- src/extension.ts | 41 ++++++++++-- src/noteManager.ts | 33 +++++++--- src/notesSidebarProvider.ts | 38 +++++------ src/storageManager.ts | 66 ++++++++++++++++--- src/types.ts | 4 +- web/CLAUDE.md | 2 +- web/src/pages/ChangelogPage.tsx | 47 ------------- 13 files changed, 184 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index c322bd9..8d68734 100644 --- a/README.md +++ b/README.md @@ -364,15 +364,16 @@ You can add `.code-notes/` to `.gitignore` if you want notes to be local only, o ### Generated exports -When auto-exports are enabled, two files are regenerated in `.code-notes/` -on every note change: +When auto-exports are enabled, two files are automatically regenerated +(debounced by 200ms) in your configured notes directory (`.code-notes/` by +default; see `codeContextNotes.storageDirectory`) whenever notes change: - **`INDEX.json`** — machine-readable index. Used by integrations like the MCP server (v0.4+). - **`AGENTS.md`** — human-readable digest, hoisting instructions/warnings/handoffs. Useful as workspace context for coding agents. -Both are deterministic given the same notes. To exclude them from git, add to `.gitignore`: +Note content and ordering are deterministic given the same notes; only the `generatedAt` timestamp in `INDEX.json` changes on every regeneration. To exclude them from git, add to `.gitignore`: -``` +```gitignore .code-notes/INDEX.json .code-notes/AGENTS.md ``` diff --git a/docs/superpowers/plans/2026-05-15-agent-integration-v0.3-schema-and-exports.md b/docs/superpowers/plans/2026-05-15-agent-integration-v0.3-schema-and-exports.md index 57e12ea..0a5656d 100644 --- a/docs/superpowers/plans/2026-05-15-agent-integration-v0.3-schema-and-exports.md +++ b/docs/superpowers/plans/2026-05-15-agent-integration-v0.3-schema-and-exports.md @@ -1312,7 +1312,7 @@ on every note change: Both are deterministic given the same notes. To exclude them from git, add to `.gitignore`: -``` +```gitignore .code-notes/INDEX.json .code-notes/AGENTS.md ``` diff --git a/docs/superpowers/specs/2026-05-15-agent-integration-design.md b/docs/superpowers/specs/2026-05-15-agent-integration-design.md index 266e7da..a183f13 100644 --- a/docs/superpowers/specs/2026-05-15-agent-integration-design.md +++ b/docs/superpowers/specs/2026-05-15-agent-integration-design.md @@ -374,7 +374,7 @@ The Revert button calls existing `noteManager` methods. No new storage primitive #### 5.4.6 Settings UI -``` +```text Code Context Notes › Agent write mode: [direct | queue | audit ▼] Code Context Notes › Agent allow-list: [claude-code, cursor-agent] Code Context Notes › Audit log retention: [last 1000 ops] diff --git a/package-lock.json b/package-lock.json index 2be84ac..a4c2196 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-context-notes", - "version": "0.1.7", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-context-notes", - "version": "0.1.7", + "version": "0.3.0", "license": "MIT", "dependencies": { "uuid": "^13.0.0" diff --git a/src/exportGenerator.ts b/src/exportGenerator.ts index d8e6d68..838005c 100644 --- a/src/exportGenerator.ts +++ b/src/exportGenerator.ts @@ -50,8 +50,11 @@ function preview(content: string): string { /** * Build the INDEX.json data. `now` is injectable for deterministic tests. + * `storageDir` should match the workspace's configured + * `codeContextNotes.storageDirectory` (default `.code-notes`) so + * `contentPath` entries point at real note files. */ -export function buildIndex(rawNotes: Note[], workspaceRoot: string, now: Date = new Date()): IndexFile { +export function buildIndex(rawNotes: Note[], workspaceRoot: string, now: Date = new Date(), storageDir: string = '.code-notes'): IndexFile { const notes = rawNotes.map(applyDefaults); // Stable sort by id so output is deterministic for the same input set. @@ -73,7 +76,7 @@ export function buildIndex(rawNotes: Note[], workspaceRoot: string, now: Date = isExpired: isExpired(n, now), references: n.references!, contentPreview: preview(n.content), - contentPath: `.code-notes/${n.id}.md`, + contentPath: `${storageDir}/${n.id}.md`, })); const byFile: Record = {}; @@ -103,19 +106,34 @@ export function buildIndex(rawNotes: Note[], workspaceRoot: string, now: Date = const PRIORITY_RANK: Record = { critical: 0, high: 1, normal: 2, low: 3 }; /** - * Build the AGENTS.md digest content. + * Stable tiebreak for notes that rank equally on their primary sort key + * (priority, or no sort key at all), so section ordering doesn't depend + * on filesystem scan order across regenerations. */ -export function buildDigest(rawNotes: Note[], workspaceRoot: string, now: Date = new Date()): string { +function tiebreak(workspaceRoot: string): (a: Note, b: Note) => number { + return (a, b) => + relativePath(a.filePath, workspaceRoot).localeCompare(relativePath(b.filePath, workspaceRoot)) || + (a.lineRange.start - b.lineRange.start) || + (a.lineRange.end - b.lineRange.end) || + a.id.localeCompare(b.id); +} + +/** + * Build the AGENTS.md digest content. `storageDir` should match the + * workspace's configured `codeContextNotes.storageDirectory`. + */ +export function buildDigest(rawNotes: Note[], workspaceRoot: string, now: Date = new Date(), storageDir: string = '.code-notes'): string { const notes = rawNotes.map(applyDefaults).filter(n => !isExpired(n, now)); + const tb = tiebreak(workspaceRoot); const out: string[] = []; out.push('# Code Notes Digest'); - out.push('*Auto-generated. Do not edit. Source: `.code-notes/INDEX.json`*'); + out.push(`*Auto-generated. Do not edit. Source: \`${storageDir}/INDEX.json\`*`); out.push(''); const critical = notes .filter(n => n.type === 'instruction' || n.type === 'warning') - .sort((a, b) => (PRIORITY_RANK[a.priority!] ?? 99) - (PRIORITY_RANK[b.priority!] ?? 99)); + .sort((a, b) => ((PRIORITY_RANK[a.priority!] ?? 99) - (PRIORITY_RANK[b.priority!] ?? 99)) || tb(a, b)); if (critical.length > 0) { out.push('## Critical instructions and warnings'); for (const n of critical) { @@ -124,7 +142,7 @@ export function buildDigest(rawNotes: Note[], workspaceRoot: string, now: Date = out.push(''); } - const handoffs = notes.filter(n => n.type === 'handoff'); + const handoffs = notes.filter(n => n.type === 'handoff').sort(tb); if (handoffs.length > 0) { out.push('## Open handoffs'); for (const n of handoffs) { @@ -134,7 +152,7 @@ export function buildDigest(rawNotes: Note[], workspaceRoot: string, now: Date = out.push(''); } - const decisions = notes.filter(n => n.type === 'decision'); + const decisions = notes.filter(n => n.type === 'decision').sort(tb); if (decisions.length > 0) { out.push('## Decisions worth knowing'); for (const n of decisions) { @@ -155,7 +173,7 @@ export function buildDigest(rawNotes: Note[], workspaceRoot: string, now: Date = const fileKeys = Array.from(byFile.keys()).sort(); for (const file of fileKeys) { out.push(`### \`${file}\``); - const fileNotes = byFile.get(file)!.sort((a, b) => a.lineRange.start - b.lineRange.start); + const fileNotes = byFile.get(file)!.sort((a, b) => (a.lineRange.start - b.lineRange.start) || (a.lineRange.end - b.lineRange.end) || a.id.localeCompare(b.id)); for (const n of fileNotes) { const tags = n.tags!.length > 0 ? ` [${n.tags!.join(', ')}]` : ''; out.push(`- L${n.lineRange.start + 1} — ${n.type} (${n.priority})${tags}: ${preview(n.content)}`); diff --git a/src/exportWriter.ts b/src/exportWriter.ts index 9f41096..3bce2b1 100644 --- a/src/exportWriter.ts +++ b/src/exportWriter.ts @@ -23,6 +23,7 @@ export class ExportWriter { private pendingTimer: NodeJS.Timeout | undefined; private pendingGetNotes: (() => Promise) | undefined; private getConfig: () => { enabled: boolean; indexJson: boolean; agentsMarkdown: boolean }; + private writeQueue: Promise = Promise.resolve(); onError: (e: Error) => void = (e) => console.error('[code-notes] export failed:', e); constructor(workspaceRoot: string, storageDir: string, opts: ExportWriterOptions = {}) { @@ -54,8 +55,18 @@ export class ExportWriter { /** * Force an immediate (non-debounced) regeneration. Used at startup, * by the manual command, and by tests. + * + * Calls are serialized on `writeQueue` — the debounced timer and a + * manual regenerate can otherwise race and clobber each other's + * fixed-path temp files in atomicWrite. */ - async regenerate(notes: Note[]): Promise { + regenerate(notes: Note[]): Promise { + const run = this.writeQueue.then(() => this.doRegenerate(notes)); + this.writeQueue = run.catch(() => {}); + return run; + } + + private async doRegenerate(notes: Note[]): Promise { try { const cfg = this.getConfig(); if (!cfg.enabled) return; @@ -63,11 +74,11 @@ export class ExportWriter { const dir = path.join(this.workspaceRoot, this.storageDir); await fs.mkdir(dir, { recursive: true }); if (cfg.indexJson) { - const idx = buildIndex(notes, this.workspaceRoot); + const idx = buildIndex(notes, this.workspaceRoot, new Date(), this.storageDir); await this.atomicWrite(path.join(dir, 'INDEX.json'), JSON.stringify(idx, null, 2)); } if (cfg.agentsMarkdown) { - const digest = buildDigest(notes, this.workspaceRoot); + const digest = buildDigest(notes, this.workspaceRoot, new Date(), this.storageDir); await this.atomicWrite(path.join(dir, 'AGENTS.md'), digest); } } catch (e: unknown) { diff --git a/src/extension.ts b/src/extension.ts index 5d8f307..b2044be 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -108,14 +108,17 @@ export async function activate(context: vscode.ExtensionContext) { }); context.subscriptions.push({ dispose: () => exportWriter.dispose() }); + // Initial export on activation (covers fresh installs, manual deletes). + // regenerate()/getConfig() re-check the enabled setting on every run, so + // this stays correct even if exports are toggled on after activation. if (exportsEnabled) { - // Initial export on activation (covers fresh installs, manual deletes) exportWriter.scheduleRegenerate(() => noteManager.getAllNotes()); - // Subsequent changes - noteManager.on('noteChanged', () => { - exportWriter.scheduleRegenerate(() => noteManager.getAllNotes()); - }); } + // Always subscribe so exports start working as soon as the setting is + // enabled, without requiring a reload. + noteManager.on('noteChanged', () => { + exportWriter.scheduleRegenerate(() => noteManager.getAllNotes()); + }); // Initialize comment controller commentController = new CommentController(noteManager, context); @@ -969,12 +972,30 @@ function registerAllCommands(context: vscode.ExtensionContext) { return; } + const exportsCfg = vscode.workspace.getConfiguration('codeContextNotes.exports'); + if (!exportsCfg.get('enabled', true)) { + vscode.window.showWarningMessage('Code Notes: exports are disabled (codeContextNotes.exports.enabled is false). Nothing was written.'); + return; + } + + // regenerate() never throws — failures go through onError — so wrap + // it here to give this manual command an accurate success/failure message. + const previousOnError = exportWriter.onError; + let failure: Error | undefined; + exportWriter.onError = (e) => { failure = e; previousOnError(e); }; + try { const notes = await noteManager.getAllNotes(); await exportWriter.regenerate(notes); - vscode.window.showInformationMessage(`Code Notes: regenerated exports for ${notes.length} notes.`); + if (failure) { + vscode.window.showErrorMessage(`Failed to regenerate exports: ${failure.message}`); + } else { + vscode.window.showInformationMessage(`Code Notes: regenerated exports for ${notes.length} notes.`); + } } catch (error) { vscode.window.showErrorMessage(`Failed to regenerate exports: ${error}`); + } finally { + exportWriter.onError = previousOnError; } } ); @@ -983,6 +1004,10 @@ function registerAllCommands(context: vscode.ExtensionContext) { const filterByTypeCommand = vscode.commands.registerCommand( 'codeContextNotes.filterByType', async () => { + if (!sidebarProvider) { + vscode.window.showErrorMessage('Code Context Notes requires a workspace folder to be opened.'); + return; + } const choices = ['context', 'instruction', 'warning', 'decision', 'todo', 'handoff', 'rationale']; const picked = await vscode.window.showQuickPick(choices, { canPickMany: true, @@ -996,6 +1021,10 @@ function registerAllCommands(context: vscode.ExtensionContext) { const toggleExpiredCommand = vscode.commands.registerCommand( 'codeContextNotes.toggleExpired', () => { + if (!sidebarProvider) { + vscode.window.showErrorMessage('Code Context Notes requires a workspace folder to be opened.'); + return; + } sidebarProvider.toggleHideExpired(); } ); diff --git a/src/noteManager.ts b/src/noteManager.ts index 627f21c..945978e 100644 --- a/src/noteManager.ts +++ b/src/noteManager.ts @@ -96,23 +96,28 @@ export class NoteManager extends EventEmitter { isDeleted: false }; + // Normalize before save/cache so this note matches the shape every other + // load path guarantees (applyDefaults is a no-op for on-disk serialization + // since storageManager omits fields already equal to their default). + const normalized = applyDefaults(note); + // Save to storage - await this.storage.saveNote(note); + await this.storage.saveNote(normalized); // Update cache - this.addNoteToCache(note); + this.addNoteToCache(normalized); // Update search index if (this.searchManager) { - await this.searchManager.updateIndex(note); + await this.searchManager.updateIndex(normalized); } // Clear workspace cache and emit events this.clearWorkspaceCache(); - this.emit('noteCreated', note); - this.emit('noteChanged', { type: 'created', note }); + this.emit('noteCreated', normalized); + this.emit('noteChanged', { type: 'created', note: normalized }); - return note; + return normalized; } /** @@ -181,22 +186,30 @@ export class NoteManager extends EventEmitter { ): Promise { const existing = await this.storage.loadNoteById(noteId); if (!existing) throw new Error(`Note ${noteId} not found`); + if (existing.isDeleted) throw new Error(`Cannot update deleted note ${noteId}`); - // Merge: only overwrite fields explicitly provided - const updated: Note = { + // Merge: only overwrite fields explicitly provided, then normalize + // defaults so cache/consumers see the same shape as every other load path. + const updated = applyDefaults({ ...existing, ...fields, updatedAt: new Date().toISOString(), - }; + }); await this.storage.saveNote(updated); // Mirror the cache-invalidation pattern used in updateNote this.updateNoteInCache(updated); + + // Update search index + if (this.searchManager) { + await this.searchManager.updateIndex(updated); + } + this.clearWorkspaceCache(); this.emit('noteUpdated', updated); this.emit('noteChanged', { type: 'updated', note: updated }); - return applyDefaults(updated); + return updated; } /** diff --git a/src/notesSidebarProvider.ts b/src/notesSidebarProvider.ts index 6131a51..1a25f00 100644 --- a/src/notesSidebarProvider.ts +++ b/src/notesSidebarProvider.ts @@ -107,15 +107,16 @@ export class NotesSidebarProvider implements vscode.TreeDataProvider { // Root level: return RootTreeItem or empty state if (!element) { - const noteCount = await this.noteManager.getNoteCount(); + const fileNodes = await this.getFileNodes(); - // Empty state - no notes - if (noteCount === 0) { + // Empty state - no visible notes + if (fileNodes.length === 0) { return []; } - // Return root node with count - return [new RootTreeItem(noteCount)]; + // Return root node with count of notes visible under active filters + const visibleNoteCount = fileNodes.reduce((sum, node) => sum + node.notes.length, 0); + return [new RootTreeItem(visibleNoteCount)]; } // Root node: return file nodes @@ -140,10 +141,14 @@ export class NotesSidebarProvider implements vscode.TreeDataProvider 0) { - fileNodes.push(new FileTreeItem(filePath, notes, this.workspaceRoot)); + const visibleNotes = notes + .map(applyDefaults) + .filter(n => !this.hideExpired || !isExpired(n)) + .filter(n => !this.typeFilter || this.typeFilter.has(n.type!)); + if (visibleNotes.length > 0) { + fileNodes.push(new FileTreeItem(filePath, visibleNotes, this.workspaceRoot)); } } @@ -181,23 +186,12 @@ export class NotesSidebarProvider implements vscode.TreeDataProvider !this.hideExpired || !isExpired(n)) - .filter(n => !this.typeFilter || this.typeFilter.has(n.type!)); - - for (const note of filtered) { - noteNodes.push(new NoteTreeItem(note, previewLength)); - } - - return noteNodes; + return fileNode.notes.map(note => new NoteTreeItem(note, previewLength)); } /** diff --git a/src/storageManager.ts b/src/storageManager.ts index ee8d1c7..a89d718 100644 --- a/src/storageManager.ts +++ b/src/storageManager.ts @@ -6,7 +6,27 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs/promises'; -import { Note, NoteStorage, NoteMetadata } from './types.js'; +import { Note, NoteStorage, NoteMetadata, NoteType, NoteScope, NotePriority, AuthorType, NoteReference } from './types.js'; +import { NOTE_DEFAULTS } from './noteDefaults.js'; + +const VALID_TYPES: NoteType[] = ['context', 'instruction', 'warning', 'decision', 'todo', 'handoff', 'rationale']; +const VALID_SCOPES: NoteScope[] = ['line', 'function', 'class', 'file', 'directory']; +const VALID_PRIORITIES: NotePriority[] = ['low', 'normal', 'high', 'critical']; +const VALID_AUTHOR_TYPES: AuthorType[] = ['human', 'agent']; +const VALID_REFERENCE_KINDS = ['note', 'pr', 'issue', 'commit', 'test', 'url']; + +function isValidReferences(value: unknown): value is NoteReference[] { + return ( + Array.isArray(value) && + value.every( + (r) => + r && + typeof r === 'object' && + typeof (r as NoteReference).value === 'string' && + VALID_REFERENCE_KINDS.includes((r as NoteReference).kind) + ) + ); +} /** * StorageManager implements the NoteStorage interface @@ -212,19 +232,19 @@ export class StorageManager implements NoteStorage { lines.push(`**Updated:** ${note.updatedAt}`); // Structured fields — omitted if equal to default for compactness - if (note.type && note.type !== 'context') { + if (note.type && note.type !== NOTE_DEFAULTS.type) { lines.push(`**Type:** ${note.type}`); } - if (note.scope && note.scope !== 'line') { + if (note.scope && note.scope !== NOTE_DEFAULTS.scope) { lines.push(`**Scope:** ${note.scope}`); } - if (note.priority && note.priority !== 'normal') { + if (note.priority && note.priority !== NOTE_DEFAULTS.priority) { lines.push(`**Priority:** ${note.priority}`); } if (note.tags && note.tags.length > 0) { lines.push(`**Tags:** ${note.tags.join(', ')}`); } - if (note.authorType && note.authorType !== 'human') { + if (note.authorType && note.authorType !== NOTE_DEFAULTS.authorType) { lines.push(`**AuthorType:** ${note.authorType}`); } if (note.expiresAt) { @@ -319,20 +339,40 @@ export class StorageManager implements NoteStorage { } // Parse new structured fields else if (line.startsWith('**Type:**')) { - note.type = line.substring(9).trim() as any; + const v = line.substring(9).trim(); + if ((VALID_TYPES as string[]).includes(v)) { + note.type = v as NoteType; + } else { + console.warn(`[code-notes] Ignoring invalid Type for note: ${v}`); + } } else if (line.startsWith('**Scope:**')) { - note.scope = line.substring(10).trim() as any; + const v = line.substring(10).trim(); + if ((VALID_SCOPES as string[]).includes(v)) { + note.scope = v as NoteScope; + } else { + console.warn(`[code-notes] Ignoring invalid Scope for note: ${v}`); + } } else if (line.startsWith('**Priority:**')) { - note.priority = line.substring(13).trim() as any; + const v = line.substring(13).trim(); + if ((VALID_PRIORITIES as string[]).includes(v)) { + note.priority = v as NotePriority; + } else { + console.warn(`[code-notes] Ignoring invalid Priority for note: ${v}`); + } } else if (line.startsWith('**Tags:**')) { const raw = line.substring(9).trim(); note.tags = raw ? raw.split(',').map(t => t.trim()).filter(t => t.length > 0) : []; } else if (line.startsWith('**AuthorType:**')) { - note.authorType = line.substring(15).trim() as any; + const v = line.substring(15).trim(); + if ((VALID_AUTHOR_TYPES as string[]).includes(v)) { + note.authorType = v as AuthorType; + } else { + console.warn(`[code-notes] Ignoring invalid AuthorType for note: ${v}`); + } } else if (line.startsWith('**ExpiresAt:**')) { note.expiresAt = line.substring(14).trim(); @@ -341,7 +381,13 @@ export class StorageManager implements NoteStorage { const raw = line.substring(15).trim(); if (raw) { try { - note.references = JSON.parse(raw); + const parsed = JSON.parse(raw); + if (isValidReferences(parsed)) { + note.references = parsed; + } else { + console.warn(`[code-notes] Ignoring invalid References shape for note: ${raw}`); + note.references = []; + } } catch { console.warn(`[code-notes] Failed to parse References for note: ${raw}`); note.references = []; diff --git a/src/types.ts b/src/types.ts index 8e55625..23f2312 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,7 +45,9 @@ export type NoteScope = export type AuthorType = 'human' | 'agent'; /** - * Priority used for digest ordering and `critical` always-include behavior + * Priority used for digest and sidebar ordering — `critical` sorts first. + * Expired notes are still excluded by `hideExpired`/expiry filtering + * regardless of priority. */ export type NotePriority = 'low' | 'normal' | 'high' | 'critical'; diff --git a/web/CLAUDE.md b/web/CLAUDE.md index 6d4e4ad..d1d66fc 100644 --- a/web/CLAUDE.md +++ b/web/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Scope -This is the `web/` subproject — the marketing site, docs, and changelog page for the Code Context Notes VS Code extension. The extension source itself lives in the parent repo at `/Users/nahian/Projects/code-notes/src`. Repo-wide rules (user stories, changelog policy, release process) are in the parent `CLAUDE.md`. +This is the `web/` subproject — the marketing site, docs, and changelog page for the Code Context Notes VS Code extension. The extension source itself lives in the parent repo at `../src`. Repo-wide rules (user stories, changelog policy, release process) are in the parent `CLAUDE.md`. ## Commands diff --git a/web/src/pages/ChangelogPage.tsx b/web/src/pages/ChangelogPage.tsx index c67af82..c859046 100644 --- a/web/src/pages/ChangelogPage.tsx +++ b/web/src/pages/ChangelogPage.tsx @@ -759,53 +759,6 @@ export function ChangelogPage() {
- {/* Future Versions Note */} -
- {/* Timeline Node - Future */} -
-
-
- - {/* Left Column - Version Info */} -
-

Coming in v0.3.0

-
- - Future Release -
-

- What's next for Code Context Notes -

-
- - {/* Right Column - Changes */} -
- - -
-
- - - Search and Filter Notes - Full-text search across all note content with filters by author, date range, and file path - -
-
- - - Regex Pattern Matching - Advanced search capabilities for power users - -
-
- - - Background Indexing - Instant search results with automatic index updates - -
-
-
-
-
-
From 4d1f6dc69881984cc6126dab3a6be1e5e81cc944 Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Thu, 16 Jul 2026 23:24:25 +0600 Subject: [PATCH 22/31] =?UTF-8?q?=F0=9F=90=9B=20fix(noteManager):=20harden?= =?UTF-8?q?=20updateNoteMetadata=20and=20cache=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reject metadata updates on soft-deleted notes (matches updateNote) - Sync the search index on metadata changes - Apply schema defaults at the cache entry points (addNoteToCache / updateNoteInCache) so cached notes always honor the NoteManager boundary guarantee, including notes inserted by createNote Co-Authored-By: Claude Fable 5 --- src/noteManager.ts | 22 +++++++++---- src/test/suite/noteManager.test.ts | 52 ++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/noteManager.ts b/src/noteManager.ts index 627f21c..72044d1 100644 --- a/src/noteManager.ts +++ b/src/noteManager.ts @@ -181,22 +181,28 @@ export class NoteManager extends EventEmitter { ): Promise { const existing = await this.storage.loadNoteById(noteId); if (!existing) throw new Error(`Note ${noteId} not found`); + if (existing.isDeleted) throw new Error(`Cannot update deleted note ${noteId}`); // Merge: only overwrite fields explicitly provided - const updated: Note = { + const updated: Note = applyDefaults({ ...existing, ...fields, updatedAt: new Date().toISOString(), - }; + }); await this.storage.saveNote(updated); // Mirror the cache-invalidation pattern used in updateNote this.updateNoteInCache(updated); this.clearWorkspaceCache(); + // Keep search index in sync (updatedAt / metadata affect search results) + if (this.searchManager) { + await this.searchManager.updateIndex(updated); + } + this.emit('noteUpdated', updated); this.emit('noteChanged', { type: 'updated', note: updated }); - return applyDefaults(updated); + return updated; } /** @@ -351,23 +357,25 @@ export class NoteManager extends EventEmitter { } /** - * Add a note to the cache + * Add a note to the cache. Defaults are applied here so cached notes + * always honor the NoteManager boundary guarantee. */ private addNoteToCache(note: Note): void { const notes = this.noteCache.get(note.filePath) || []; - notes.push(note); + notes.push(applyDefaults(note)); this.noteCache.set(note.filePath, notes); } /** - * Update a note in the cache + * Update a note in the cache. Defaults are applied here so cached notes + * always honor the NoteManager boundary guarantee. */ private updateNoteInCache(updatedNote: Note): void { const notes = this.noteCache.get(updatedNote.filePath); if (notes) { const index = notes.findIndex(n => n.id === updatedNote.id); if (index !== -1) { - notes[index] = updatedNote; + notes[index] = applyDefaults(updatedNote); } } } diff --git a/src/test/suite/noteManager.test.ts b/src/test/suite/noteManager.test.ts index afd4ae9..412ab41 100644 --- a/src/test/suite/noteManager.test.ts +++ b/src/test/suite/noteManager.test.ts @@ -535,6 +535,58 @@ suite('NoteManager Test Suite', () => { assert.deepStrictEqual(legacy!.tags, []); }); }); + + suite('Metadata Updates', () => { + test('updateNoteMetadata rejects soft-deleted notes', async () => { + await (storage as any).saveNote({ + id: 'deleted-1', + content: 'gone', + author: 'alice', + filePath: '/abs/x.ts', + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:x', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + history: [], + isDeleted: true, + }); + + await assert.rejects( + () => noteManager.updateNoteMetadata('deleted-1', { type: 'instruction' }), + /deleted/i + ); + }); + + test('updateNoteMetadata persists fields and returns a defaults-applied note', async () => { + await (storage as any).saveNote({ + id: 'meta-1', + content: 'hi', + author: 'alice', + filePath: '/abs/x.ts', + lineRange: { start: 0, end: 0 }, + contentHash: 'sha256:x', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + history: [], + }); + + const updated = await noteManager.updateNoteMetadata('meta-1', { + type: 'instruction', + priority: 'high', + }); + assert.strictEqual(updated.type, 'instruction'); + assert.strictEqual(updated.priority, 'high'); + // Untouched fields come back defaulted, not undefined + assert.strictEqual(updated.scope, 'line'); + assert.deepStrictEqual(updated.tags, []); + + // Cache must serve the defaults-applied note too + const notes = await noteManager.getNotesForFile('/abs/x.ts'); + const cached = notes.find(n => n.id === 'meta-1'); + assert.strictEqual(cached!.type, 'instruction'); + assert.strictEqual(cached!.scope, 'line'); + }); + }); }); /** From 13ebd54d178a52baf157badc1296013048f5d25e Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Thu, 16 Jul 2026 23:24:49 +0600 Subject: [PATCH 23/31] =?UTF-8?q?=F0=9F=90=9B=20fix(sidebar):=20apply=20fi?= =?UTF-8?q?lters=20to=20file=20groups=20and=20root=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filtering now happens once in applyNoteFilters, used by the root count, file nodes, and note leaves — files whose notes are all filtered out no longer render as empty groups, and the root total matches what the tree shows. Co-Authored-By: Claude Fable 5 --- src/notesSidebarProvider.ts | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/notesSidebarProvider.ts b/src/notesSidebarProvider.ts index 6131a51..5e6f9a5 100644 --- a/src/notesSidebarProvider.ts +++ b/src/notesSidebarProvider.ts @@ -93,6 +93,17 @@ export class NotesSidebarProvider implements vscode.TreeDataProvider !this.hideExpired || !isExpired(n)) + .filter(n => !this.typeFilter || this.typeFilter.has(n.type!)); + } + /** * Get tree item for a node (required by TreeDataProvider) */ @@ -107,9 +118,11 @@ export class NotesSidebarProvider implements vscode.TreeDataProvider { // Root level: return RootTreeItem or empty state if (!element) { - const noteCount = await this.noteManager.getNoteCount(); + // Count filtered notes so the root total matches what the tree shows + const fileNodes = await this.getFileNodes(); + const noteCount = fileNodes.reduce((sum, f) => sum + f.notes.length, 0); - // Empty state - no notes + // Empty state - no notes (or all filtered out) if (noteCount === 0) { return []; } @@ -140,10 +153,12 @@ export class NotesSidebarProvider implements vscode.TreeDataProvider 0) { - fileNodes.push(new FileTreeItem(filePath, notes, this.workspaceRoot)); + const visible = this.applyNoteFilters(notes); + if (visible.length > 0) { + fileNodes.push(new FileTreeItem(filePath, visible, this.workspaceRoot)); } } @@ -187,13 +202,9 @@ export class NotesSidebarProvider implements vscode.TreeDataProvider !this.hideExpired || !isExpired(n)) - .filter(n => !this.typeFilter || this.typeFilter.has(n.type!)); - - for (const note of filtered) { + // Notes are already sorted by line range in getNotesByFile() and + // filtered in getFileNodes() + for (const note of fileNode.notes) { noteNodes.push(new NoteTreeItem(note, previewLength)); } From 08d92f667e411e4e4b8bb1a44a7f90abd8ef8af3 Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Thu, 16 Jul 2026 23:24:49 +0600 Subject: [PATCH 24/31] =?UTF-8?q?=F0=9F=90=9B=20fix(exports):=20wiring,=20?= =?UTF-8?q?self-trigger=20loop,=20atomic-write=20race,=20honest=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export hooks always attached: toggling exports.enabled takes effect both ways without a reload (regenerate reads config per run) - External note-file changes (git pull, manual edits) also regenerate exports via the noteFileChanged watcher - Watcher ignores the generated AGENTS.md — regenerating exports was re-triggering the watcher in a feedback loop - Unique temp filenames so overlapping regenerations can't clobber each other's temp file - regenerate() returns written/disabled/failed; the manual command reports each state instead of always claiming success - INDEX.json contentPath honors codeContextNotes.storageDirectory; AGENTS.md header no longer claims INDEX.json as its source - Activation log reads the real extension version (was hardcoded 0.1.3) - Guard filterByType/toggleExpired for no-workspace activation Co-Authored-By: Claude Fable 5 --- src/exportGenerator.ts | 8 +++--- src/exportWriter.ts | 17 +++++++++---- src/extension.ts | 56 ++++++++++++++++++++++++++++++++---------- 3 files changed, 60 insertions(+), 21 deletions(-) diff --git a/src/exportGenerator.ts b/src/exportGenerator.ts index d8e6d68..7bbdd83 100644 --- a/src/exportGenerator.ts +++ b/src/exportGenerator.ts @@ -50,8 +50,10 @@ function preview(content: string): string { /** * Build the INDEX.json data. `now` is injectable for deterministic tests. + * `storageDir` is the workspace-relative notes directory (matches the + * codeContextNotes.storageDirectory setting). */ -export function buildIndex(rawNotes: Note[], workspaceRoot: string, now: Date = new Date()): IndexFile { +export function buildIndex(rawNotes: Note[], workspaceRoot: string, now: Date = new Date(), storageDir: string = '.code-notes'): IndexFile { const notes = rawNotes.map(applyDefaults); // Stable sort by id so output is deterministic for the same input set. @@ -73,7 +75,7 @@ export function buildIndex(rawNotes: Note[], workspaceRoot: string, now: Date = isExpired: isExpired(n, now), references: n.references!, contentPreview: preview(n.content), - contentPath: `.code-notes/${n.id}.md`, + contentPath: `${storageDir}/${n.id}.md`, })); const byFile: Record = {}; @@ -110,7 +112,7 @@ export function buildDigest(rawNotes: Note[], workspaceRoot: string, now: Date = const out: string[] = []; out.push('# Code Notes Digest'); - out.push('*Auto-generated. Do not edit. Source: `.code-notes/INDEX.json`*'); + out.push('*Auto-generated by Code Context Notes. Do not edit.*'); out.push(''); const critical = notes diff --git a/src/exportWriter.ts b/src/exportWriter.ts index 9f41096..585deb4 100644 --- a/src/exportWriter.ts +++ b/src/exportWriter.ts @@ -23,6 +23,7 @@ export class ExportWriter { private pendingTimer: NodeJS.Timeout | undefined; private pendingGetNotes: (() => Promise) | undefined; private getConfig: () => { enabled: boolean; indexJson: boolean; agentsMarkdown: boolean }; + private tmpSeq = 0; onError: (e: Error) => void = (e) => console.error('[code-notes] export failed:', e); constructor(workspaceRoot: string, storageDir: string, opts: ExportWriterOptions = {}) { @@ -53,30 +54,36 @@ export class ExportWriter { /** * Force an immediate (non-debounced) regeneration. Used at startup, - * by the manual command, and by tests. + * by the manual command, and by tests. Returns what happened so callers + * (e.g. the manual command) can report honestly; errors still route + * through onError rather than throwing. */ - async regenerate(notes: Note[]): Promise { + async regenerate(notes: Note[]): Promise<'written' | 'disabled' | 'failed'> { try { const cfg = this.getConfig(); - if (!cfg.enabled) return; + if (!cfg.enabled) return 'disabled'; const dir = path.join(this.workspaceRoot, this.storageDir); await fs.mkdir(dir, { recursive: true }); if (cfg.indexJson) { - const idx = buildIndex(notes, this.workspaceRoot); + const idx = buildIndex(notes, this.workspaceRoot, new Date(), this.storageDir); await this.atomicWrite(path.join(dir, 'INDEX.json'), JSON.stringify(idx, null, 2)); } if (cfg.agentsMarkdown) { const digest = buildDigest(notes, this.workspaceRoot); await this.atomicWrite(path.join(dir, 'AGENTS.md'), digest); } + return 'written'; } catch (e: unknown) { this.onError(e instanceof Error ? e : new Error(String(e))); + return 'failed'; } } private async atomicWrite(targetPath: string, content: string): Promise { - const tmpPath = `${targetPath}.tmp`; + // Unique temp name so overlapping regenerations can't clobber each + // other's temp file; the final rename stays atomic. + const tmpPath = `${targetPath}.${process.pid}.${++this.tmpSeq}.tmp`; await fs.writeFile(tmpPath, content, 'utf-8'); await fs.rename(tmpPath, targetPath); } diff --git a/src/extension.ts b/src/extension.ts index 5d8f307..1ecbae1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,6 +3,7 @@ */ import * as vscode from 'vscode'; +import * as path from 'path'; import { StorageManager } from './storageManager.js'; import { ContentHashTracker } from './contentHashTracker.js'; import { GitIntegration } from './gitIntegration.js'; @@ -29,7 +30,7 @@ const DEBOUNCE_DELAY = 500; // ms */ export async function activate(context: vscode.ExtensionContext) { console.log('Code Context Notes extension is activating...'); - console.log('Code Context Notes: Extension version 0.1.3'); + console.log(`Code Context Notes: Extension version ${context.extension.packageJSON.version}`); // Get workspace folder const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; @@ -92,9 +93,6 @@ export async function activate(context: vscode.ExtensionContext) { console.log(`Code Context Notes: Search index built with ${allNotes.length} notes`); // Initialize export writer and hook into note changes - const exportsConfig = vscode.workspace.getConfiguration('codeContextNotes.exports'); - const exportsEnabled = exportsConfig.get('enabled', true); - exportWriter = new ExportWriter(workspaceRoot, storageDirectory, { debounceMs: 200, getConfig: () => { @@ -108,14 +106,19 @@ export async function activate(context: vscode.ExtensionContext) { }); context.subscriptions.push({ dispose: () => exportWriter.dispose() }); - if (exportsEnabled) { - // Initial export on activation (covers fresh installs, manual deletes) + // Hooks are always attached; ExportWriter.regenerate reads the + // exports.enabled setting on every run, so toggling it takes effect + // in both directions without a reload. + // Initial export on activation (covers fresh installs, manual deletes) + exportWriter.scheduleRegenerate(() => noteManager.getAllNotes()); + // Notes changed through the extension + noteManager.on('noteChanged', () => { exportWriter.scheduleRegenerate(() => noteManager.getAllNotes()); - // Subsequent changes - noteManager.on('noteChanged', () => { - exportWriter.scheduleRegenerate(() => noteManager.getAllNotes()); - }); - } + }); + // Note files changed externally (git pull, manual edits) + noteManager.on('noteFileChanged', () => { + exportWriter.scheduleRegenerate(() => noteManager.getAllNotes()); + }); // Initialize comment controller commentController = new CommentController(noteManager, context); @@ -971,8 +974,14 @@ function registerAllCommands(context: vscode.ExtensionContext) { try { const notes = await noteManager.getAllNotes(); - await exportWriter.regenerate(notes); - vscode.window.showInformationMessage(`Code Notes: regenerated exports for ${notes.length} notes.`); + const outcome = await exportWriter.regenerate(notes); + if (outcome === 'written') { + vscode.window.showInformationMessage(`Code Notes: regenerated exports for ${notes.length} notes.`); + } else if (outcome === 'disabled') { + vscode.window.showWarningMessage('Code Notes: exports are disabled (codeContextNotes.exports.enabled). Nothing was written.'); + } else { + vscode.window.showErrorMessage('Code Notes: export regeneration failed. See the developer console for details.'); + } } catch (error) { vscode.window.showErrorMessage(`Failed to regenerate exports: ${error}`); } @@ -983,6 +992,10 @@ function registerAllCommands(context: vscode.ExtensionContext) { const filterByTypeCommand = vscode.commands.registerCommand( 'codeContextNotes.filterByType', async () => { + if (!sidebarProvider) { + vscode.window.showErrorMessage('Code Context Notes requires a workspace folder to be opened.'); + return; + } const choices = ['context', 'instruction', 'warning', 'decision', 'todo', 'handoff', 'rationale']; const picked = await vscode.window.showQuickPick(choices, { canPickMany: true, @@ -996,6 +1009,10 @@ function registerAllCommands(context: vscode.ExtensionContext) { const toggleExpiredCommand = vscode.commands.registerCommand( 'codeContextNotes.toggleExpired', () => { + if (!sidebarProvider) { + vscode.window.showErrorMessage('Code Context Notes requires a workspace folder to be opened.'); + return; + } sidebarProvider.toggleHideExpired(); } ); @@ -1182,8 +1199,15 @@ function setupEventListeners(context: vscode.ExtensionContext) { ); const fileWatcher = vscode.workspace.createFileSystemWatcher(fileWatcherPattern); + // AGENTS.md is a generated export living in the same directory; treating + // it as a note would make export regeneration re-trigger itself forever. + const isGeneratedExport = (uri: vscode.Uri) => path.basename(uri.fsPath) === 'AGENTS.md'; + // When a note file is created fileWatcher.onDidCreate((uri) => { + if (isGeneratedExport(uri)) { + return; + } console.log(`Note file created: ${uri.fsPath}`); // Clear workspace cache and emit event for sidebar refresh noteManager.clearAllCache(); @@ -1192,6 +1216,9 @@ function setupEventListeners(context: vscode.ExtensionContext) { // When a note file is changed fileWatcher.onDidChange((uri) => { + if (isGeneratedExport(uri)) { + return; + } console.log(`Note file changed: ${uri.fsPath}`); // Clear workspace cache and emit event for sidebar refresh noteManager.clearAllCache(); @@ -1200,6 +1227,9 @@ function setupEventListeners(context: vscode.ExtensionContext) { // When a note file is deleted fileWatcher.onDidDelete((uri) => { + if (isGeneratedExport(uri)) { + return; + } console.log(`Note file deleted: ${uri.fsPath}`); // Clear workspace cache and emit event for sidebar refresh noteManager.clearAllCache(); From fdf388501fd01f8578f63ad44d0735ea4545a0ea Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Thu, 16 Jul 2026 23:24:49 +0600 Subject: [PATCH 25/31] =?UTF-8?q?=F0=9F=90=9B=20fix(storage):=20validate?= =?UTF-8?q?=20structured=20field=20values=20on=20parse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Invalid Type/Scope/Priority/AuthorType values are dropped with a warning (schema defaults apply at the NoteManager boundary), and References entries are filtered to valid {kind, value} shapes. Narrows the NotePriority comment to shipped behavior. Co-Authored-By: Claude Fable 5 --- src/storageManager.ts | 51 ++++++++++++++++++++++---- src/test/suite/exportGenerator.test.ts | 8 ++++ src/test/suite/storageManager.test.ts | 31 ++++++++++++++++ src/types.ts | 3 +- 4 files changed, 85 insertions(+), 8 deletions(-) diff --git a/src/storageManager.ts b/src/storageManager.ts index ee8d1c7..416391b 100644 --- a/src/storageManager.ts +++ b/src/storageManager.ts @@ -6,7 +6,15 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs/promises'; -import { Note, NoteStorage, NoteMetadata } from './types.js'; +import { Note, NoteStorage, NoteMetadata, NoteReference } from './types.js'; + +// Allowed values for structured fields parsed from note markdown. +// Must stay in sync with the union types in types.ts. +const VALID_NOTE_TYPES = new Set(['context', 'instruction', 'warning', 'decision', 'todo', 'handoff', 'rationale']); +const VALID_NOTE_SCOPES = new Set(['line', 'function', 'class', 'file', 'directory']); +const VALID_NOTE_PRIORITIES = new Set(['low', 'normal', 'high', 'critical']); +const VALID_AUTHOR_TYPES = new Set(['human', 'agent']); +const VALID_REFERENCE_KINDS = new Set(['note', 'pr', 'issue', 'commit', 'test', 'url']); /** * StorageManager implements the NoteStorage interface @@ -317,22 +325,43 @@ export class StorageManager implements NoteStorage { else if (line.startsWith('**Status:** DELETED')) { note.isDeleted = true; } - // Parse new structured fields + // Parse new structured fields — invalid values are dropped (the note + // then gets the schema default at the NoteManager boundary) else if (line.startsWith('**Type:**')) { - note.type = line.substring(9).trim() as any; + const v = line.substring(9).trim(); + if (VALID_NOTE_TYPES.has(v)) { + note.type = v as Note['type']; + } else { + console.warn(`[code-notes] Ignoring invalid note type: ${v}`); + } } else if (line.startsWith('**Scope:**')) { - note.scope = line.substring(10).trim() as any; + const v = line.substring(10).trim(); + if (VALID_NOTE_SCOPES.has(v)) { + note.scope = v as Note['scope']; + } else { + console.warn(`[code-notes] Ignoring invalid note scope: ${v}`); + } } else if (line.startsWith('**Priority:**')) { - note.priority = line.substring(13).trim() as any; + const v = line.substring(13).trim(); + if (VALID_NOTE_PRIORITIES.has(v)) { + note.priority = v as Note['priority']; + } else { + console.warn(`[code-notes] Ignoring invalid note priority: ${v}`); + } } else if (line.startsWith('**Tags:**')) { const raw = line.substring(9).trim(); note.tags = raw ? raw.split(',').map(t => t.trim()).filter(t => t.length > 0) : []; } else if (line.startsWith('**AuthorType:**')) { - note.authorType = line.substring(15).trim() as any; + const v = line.substring(15).trim(); + if (VALID_AUTHOR_TYPES.has(v)) { + note.authorType = v as Note['authorType']; + } else { + console.warn(`[code-notes] Ignoring invalid author type: ${v}`); + } } else if (line.startsWith('**ExpiresAt:**')) { note.expiresAt = line.substring(14).trim(); @@ -341,7 +370,15 @@ export class StorageManager implements NoteStorage { const raw = line.substring(15).trim(); if (raw) { try { - note.references = JSON.parse(raw); + const parsed = JSON.parse(raw); + note.references = Array.isArray(parsed) + ? parsed.filter( + (r: unknown): r is NoteReference => + !!r && typeof r === 'object' && + typeof (r as NoteReference).value === 'string' && + VALID_REFERENCE_KINDS.has((r as NoteReference).kind) + ) + : []; } catch { console.warn(`[code-notes] Failed to parse References for note: ${raw}`); note.references = []; diff --git a/src/test/suite/exportGenerator.test.ts b/src/test/suite/exportGenerator.test.ts index 5b816fa..6caf511 100644 --- a/src/test/suite/exportGenerator.test.ts +++ b/src/test/suite/exportGenerator.test.ts @@ -22,6 +22,14 @@ const n = (overrides: Partial): Note => ({ }); suite('exportGenerator', () => { + test('buildIndex contentPath honors a custom storage directory', () => { + const idx = buildIndex([n({ id: 'a' })], '/ws', new Date('2026-05-01T00:00:00Z'), '.my-notes'); + assert.strictEqual(idx.notes[0].contentPath, '.my-notes/a.md'); + // default stays .code-notes + const idxDefault = buildIndex([n({ id: 'a' })], '/ws', new Date('2026-05-01T00:00:00Z')); + assert.strictEqual(idxDefault.notes[0].contentPath, '.code-notes/a.md'); + }); + test('buildIndex includes notes, byFile, byType, byTag', () => { const notes = [ n({ id: 'a', filePath: '/ws/src/foo.ts', type: 'instruction', tags: ['security'] }), diff --git a/src/test/suite/storageManager.test.ts b/src/test/suite/storageManager.test.ts index acb976c..1a9c0fc 100644 --- a/src/test/suite/storageManager.test.ts +++ b/src/test/suite/storageManager.test.ts @@ -320,6 +320,37 @@ suite('StorageManager Test Suite', () => { assert.ok(content.includes('**Status:** DELETED')); }); + test('markdownToNote drops invalid structured field values', async () => { + const markdown = `# Code Context Note + +**File:** /abs/foo.ts +**Lines:** 1-1 +**Content Hash:** sha256:abc + +## Note: n1 +**Author:** alice +**Created:** 2026-05-01T00:00:00Z +**Updated:** 2026-05-01T00:00:00Z +**Type:** banana +**Scope:** galaxy +**Priority:** urgent +**AuthorType:** robot +**References:** [{"kind":"pr","value":"#42"},{"kind":"nope","value":"x"},"junk",{"kind":"url"}] + +## Current Content + +Hi. +`; + const sm: any = new StorageManager('/tmp'); + const note = sm.markdownToNote(markdown); + assert.ok(note); + assert.strictEqual(note!.type, undefined, 'invalid type must be dropped'); + assert.strictEqual(note!.scope, undefined, 'invalid scope must be dropped'); + assert.strictEqual(note!.priority, undefined, 'invalid priority must be dropped'); + assert.strictEqual(note!.authorType, undefined, 'invalid authorType must be dropped'); + assert.deepStrictEqual(note!.references, [{ kind: 'pr', value: '#42' }], 'only valid references survive'); + }); + test('markdownToNote parses new structured fields when present', async () => { const markdown = `# Code Context Note diff --git a/src/types.ts b/src/types.ts index 8e55625..bb45aab 100644 --- a/src/types.ts +++ b/src/types.ts @@ -45,7 +45,8 @@ export type NoteScope = export type AuthorType = 'human' | 'agent'; /** - * Priority used for digest ordering and `critical` always-include behavior + * Priority used for digest ordering. (`critical` always-include behavior + * ships with the v0.4 MCP tools.) */ export type NotePriority = 'low' | 'normal' | 'high' | 'critical'; From c0d3ca2f28d734812a8d7521215277a87f3290f9 Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Thu, 16 Jul 2026 23:24:49 +0600 Subject: [PATCH 26/31] =?UTF-8?q?=F0=9F=93=9D=20docs:=20defer=20search=20t?= =?UTF-8?q?o=20a=20follow-up=20release=20+=20review=20nits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verification found the search UI (commands, keybinding, settings, searchUI.ts) was silently dropped in an earlier merge — only the SearchManager backend reached main. Search claims are removed from the v0.3.0 changelogs; the UI ships in a follow-up (Issue #10). Also: README determinism caveat (generatedAt) and configurable storage-directory phrasing, gitignore/text fence languages (MD040), spec frontmatter wording aligned to bold-label format, web/CLAUDE.md relative path, stale 'Coming in v0.3.0' web section now 'Coming Next' (MCP server + search), exports setting description, lockfile synced to 0.3.0. Co-Authored-By: Claude Fable 5 --- README.md | 11 ++++--- docs/changelogs/v0.3.0.md | 16 ++-------- ...ent-integration-v0.3-schema-and-exports.md | 2 +- .../2026-05-15-agent-integration-design.md | 10 +++---- package-lock.json | 4 +-- package.json | 2 +- web/CLAUDE.md | 2 +- web/src/pages/ChangelogPage.tsx | 29 +++---------------- 8 files changed, 24 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index c322bd9..46f7d7b 100644 --- a/README.md +++ b/README.md @@ -364,15 +364,18 @@ You can add `.code-notes/` to `.gitignore` if you want notes to be local only, o ### Generated exports -When auto-exports are enabled, two files are regenerated in `.code-notes/` -on every note change: +When auto-exports are enabled, two files are regenerated in your configured +storage directory (`.code-notes/` by default — see +`codeContextNotes.storageDirectory`) on every note change: - **`INDEX.json`** — machine-readable index. Used by integrations like the MCP server (v0.4+). - **`AGENTS.md`** — human-readable digest, hoisting instructions/warnings/handoffs. Useful as workspace context for coding agents. -Both are deterministic given the same notes. To exclude them from git, add to `.gitignore`: +Both are deterministic given the same notes, except for INDEX.json's +`generatedAt` timestamp — expect that field to change on every regeneration +if you commit the exports. To exclude them from git, add to `.gitignore`: -``` +```gitignore .code-notes/INDEX.json .code-notes/AGENTS.md ``` diff --git a/docs/changelogs/v0.3.0.md b/docs/changelogs/v0.3.0.md index 27894a2..777f944 100644 --- a/docs/changelogs/v0.3.0.md +++ b/docs/changelogs/v0.3.0.md @@ -14,20 +14,12 @@ - **Sidebar Enhancements** - Note type appears as `· {type}` description suffix; high/critical priority shown as a tooltip badge - New title-bar actions: **Filter Notes by Type…** (multi-select Quick Pick) and **Toggle Expired Notes** -- **Search and Filter Notes** (GitHub Issue #10) - - Full-text search across all note content with author, date range, and file-path (glob) filters - - Combines filters with AND logic; debounced live results; `Ctrl+Shift+F` / `Cmd+Shift+F` shortcut - - QuickPick UI with file/line/preview/author, click-to-navigate, keyboard navigation (↑↓ / Enter) - - Regex and case-sensitive options; search history (last 20); background indexing for instant results - - Sidebar toolbar search icon; index auto-updates on note CRUD ### Changed -- Search UI uses VS Code's native QuickPick for familiar experience -- Sidebar toolbar gains search icon and filter/expired toggles +- Sidebar toolbar gains filter/expired toggles ### Settings - `codeContextNotes.exports.enabled` (default `true`), `.indexJson` (default `true`), `.agentsMarkdown` (default `true`) — control auto-export generation -- `search.fuzzyMatching` (default `false`), `search.caseSensitive` (default `false`), `search.maxResults` (default `100`), `search.debounceDelay` (default `200`), `search.saveHistory` (default `true`), `search.historySize` (default `20`) ### Compatibility - Fully backwards-compatible with v0.2.x. Legacy notes load with `type=context`, `scope=line`, `priority=normal`, `tags=[]`, `authorType=human` applied in memory; on-disk file unchanged until next edit. @@ -35,17 +27,15 @@ ### Testing - **58+ unit tests** covering schema defaults, storage round-trip (read + write), export generators (buildIndex/buildDigest determinism + section hoisting), debounced atomic writer (debounce coalescing + onError fail-safe), and lazy-migration integration test on NoteManager boundary -- **Search component tests** — SearchManager (full-text, regex, author/date/file filters, performance benchmarks), SearchUI (QuickPick, keyboard nav), and sidebar integration -- Performance: search <500ms for 100 notes, <1s for 500 notes, <2s for 1000 notes; index updates <100ms ### Technical - New modules: `src/noteDefaults.ts` (defaults + expiry helper), `src/exportGenerator.ts` (pure index/digest builders), `src/exportWriter.ts` (debounced atomic writer with injectable config getter) - `NoteManager` wraps all storage reads with `applyDefaults` — single boundary for lazy migration - Storage parser/writer in `src/storageManager.ts` extends the existing bold-label markdown format; unknown fields ignored gracefully -- `SearchManager` provides inverted-index full-text search with metadata indexing and in-memory caching; background indexing on workspace open -- New commands: `regenerateExports`, `setNoteMetadata`, `filterByType`, `toggleExpired`, `searchNotes`, `clearSearchFilters`, `showSearchHistory`, `rebuildSearchIndex` +- New commands: `regenerateExports`, `setNoteMetadata`, `filterByType`, `toggleExpired` ### Coming Next +- Search and Filter UI (GitHub Issue #10) ships in a follow-up release - v0.4 ships a standalone MCP server backed by `INDEX.json` for direct agent integration (Cursor, Claude Code, etc.) --- diff --git a/docs/superpowers/plans/2026-05-15-agent-integration-v0.3-schema-and-exports.md b/docs/superpowers/plans/2026-05-15-agent-integration-v0.3-schema-and-exports.md index 57e12ea..0a5656d 100644 --- a/docs/superpowers/plans/2026-05-15-agent-integration-v0.3-schema-and-exports.md +++ b/docs/superpowers/plans/2026-05-15-agent-integration-v0.3-schema-and-exports.md @@ -1312,7 +1312,7 @@ on every note change: Both are deterministic given the same notes. To exclude them from git, add to `.gitignore`: -``` +```gitignore .code-notes/INDEX.json .code-notes/AGENTS.md ``` diff --git a/docs/superpowers/specs/2026-05-15-agent-integration-design.md b/docs/superpowers/specs/2026-05-15-agent-integration-design.md index 266e7da..fd07bc3 100644 --- a/docs/superpowers/specs/2026-05-15-agent-integration-design.md +++ b/docs/superpowers/specs/2026-05-15-agent-integration-design.md @@ -130,7 +130,7 @@ interface Note { } ``` -**Storage:** stored in the markdown frontmatter (same format used today for metadata). Reader is forward-compatible (older versions ignore unknown keys). Writer omits any field equal to its default to keep frontmatter compact. +**Storage:** stored as bold-label metadata lines in the note markdown (`**Type:** …`, same format used today for `**Author:**` etc. — see the v0.3 plan's deviation note; an earlier draft said YAML frontmatter). Reader is forward-compatible (older versions ignore unknown labels). Writer omits any field equal to its default to keep the metadata block compact. **Sidebar UI in v0.3:** small type pill on each note item; filter dropdown (type / tags / expired). Add/edit flow gets a "More fields" expander for the new fields. Richer UI deferred to v0.5. @@ -374,7 +374,7 @@ The Revert button calls existing `noteManager` methods. No new storage primitive #### 5.4.6 Settings UI -``` +```text Code Context Notes › Agent write mode: [direct | queue | audit ▼] Code Context Notes › Agent allow-list: [claude-code, cursor-agent] Code Context Notes › Audit log retention: [last 1000 ops] @@ -499,9 +499,9 @@ Workspace can sit on v0.3 indefinitely; only edited notes get rewritten. No mass Conservative — no legacy note suddenly becomes an `instruction` or gets an expiry. -### 6.3 Frontmatter format +### 6.3 Metadata format -Writer omits any field equal to its default. A plain note that's still just a `context` note on a line range stays the shape it always had. +Bold-label lines, not YAML frontmatter (see §5.1 Storage). Writer omits any field equal to its default. A plain note that's still just a `context` note on a line range stays the shape it always had. ### 6.4 Backward compatibility @@ -588,7 +588,7 @@ Exports are a cache, not a source of truth. Markdown files are authoritative. ### 8.1 Test layout -``` +```text src/test/ # extension tests (existing, extended) packages/code-notes-core/test/ # NEW — extracted core packages/code-notes-mcp/test/ # NEW — MCP server diff --git a/package-lock.json b/package-lock.json index 2be84ac..a4c2196 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-context-notes", - "version": "0.1.7", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-context-notes", - "version": "0.1.7", + "version": "0.3.0", "license": "MIT", "dependencies": { "uuid": "^13.0.0" diff --git a/package.json b/package.json index ef58eed..53bd1d0 100644 --- a/package.json +++ b/package.json @@ -455,7 +455,7 @@ "codeContextNotes.exports.enabled": { "type": "boolean", "default": true, - "description": "Auto-generate .code-notes/INDEX.json and .code-notes/AGENTS.md on every note change" + "description": "Auto-generate INDEX.json and AGENTS.md in the notes storage directory (default .code-notes) on every note change" }, "codeContextNotes.exports.indexJson": { "type": "boolean", diff --git a/web/CLAUDE.md b/web/CLAUDE.md index 6d4e4ad..d1d66fc 100644 --- a/web/CLAUDE.md +++ b/web/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Scope -This is the `web/` subproject — the marketing site, docs, and changelog page for the Code Context Notes VS Code extension. The extension source itself lives in the parent repo at `/Users/nahian/Projects/code-notes/src`. Repo-wide rules (user stories, changelog policy, release process) are in the parent `CLAUDE.md`. +This is the `web/` subproject — the marketing site, docs, and changelog page for the Code Context Notes VS Code extension. The extension source itself lives in the parent repo at `../src`. Repo-wide rules (user stories, changelog policy, release process) are in the parent `CLAUDE.md`. ## Commands diff --git a/web/src/pages/ChangelogPage.tsx b/web/src/pages/ChangelogPage.tsx index c67af82..772bc98 100644 --- a/web/src/pages/ChangelogPage.tsx +++ b/web/src/pages/ChangelogPage.tsx @@ -56,7 +56,7 @@ export function ChangelogPage() { May 16, 2026

- Agent integration foundation: structured note schema, auto-generated workspace exports for coding agents, full-text search, and sidebar enhancements. + Agent integration foundation: structured note schema, auto-generated workspace exports for coding agents, and sidebar enhancements.

@@ -102,21 +102,6 @@ export function ChangelogPage() {
-
- -
-
Search and Filter Notes (Issue #10)
-

- Full-text search across all notes with multi-criteria filtering and keyboard-driven QuickPick UI. -

-
    -
  • • Filters: author, date range, file-path glob, type (combinable with AND logic)
  • -
  • Ctrl+Shift+F / Cmd+Shift+F in notes context
  • -
  • • Regex, case-sensitive options, search history (last 20)
  • -
  • • Background indexing — sub-second search for 100+ notes
  • -
-
-
@@ -768,7 +753,7 @@ export function ChangelogPage() { {/* Left Column - Version Info */}
-

Coming in v0.3.0

+

Coming Next

Future Release @@ -786,19 +771,13 @@ export function ChangelogPage() {
- Search and Filter Notes - Full-text search across all note content with filters by author, date range, and file path + MCP Server (v0.4) - Standalone @jnahian/code-notes-mcp server so any MCP-capable agent can read and write notes
- Regex Pattern Matching - Advanced search capabilities for power users - -
-
- - - Background Indexing - Instant search results with automatic index updates + Search and Filter Notes - Full-text search across all note content with filters by author, date range, and file path
From 32d047bcaa2ad89a95cf069fb5df1773de9dec36 Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Thu, 16 Jul 2026 23:51:22 +0600 Subject: [PATCH 27/31] =?UTF-8?q?=F0=9F=90=9B=20fix(build):=20clean=20out/?= =?UTF-8?q?=20before=20test=20compiles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsc never deletes outputs for removed sources, so out/test/suite kept stale compiled suites from old branches (searchManager, sidebarProvider, tagManager) — ~100 phantom tests failing against code that doesn't exist in this tree. pretest now cleans out/ first. Co-Authored-By: Claude Fable 5 --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 53bd1d0..d89acf4 100644 --- a/package.json +++ b/package.json @@ -476,7 +476,8 @@ "compile:tsc": "tsc -p ./", "watch": "node esbuild.config.js --watch", "watch:tsc": "tsc -watch -p ./", - "pretest": "npm run compile:tsc", + "clean": "rm -rf out", + "pretest": "npm run clean && npm run compile:tsc", "lint": "echo 'Linting skipped - eslint not configured'", "test": "node ./out/test/runTest.js", "test:unit": "npm run compile:tsc && node ./out/test/runUnitTests.js", From 5e9e637eb5f77964c3c01008cd5886fa205adf6e Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Thu, 16 Jul 2026 23:51:22 +0600 Subject: [PATCH 28/31] =?UTF-8?q?=F0=9F=90=9B=20fix(noteManager):=20keep?= =?UTF-8?q?=20soft-deleted=20notes=20visible=20to=20internal=20lookups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getNotesForFile cached a pre-filtered list from storage.loadNotes, hiding deleted notes from getAllNotesForFile cache hits; the cache now always holds all notes and filters at return - deleteNote removed the soft-deleted note from the cache instead of updating it, turning 'already deleted' errors into 'not found' - getNoteById no longer returns soft-deleted notes to consumers Co-Authored-By: Claude Fable 5 --- src/noteManager.ts | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/src/noteManager.ts b/src/noteManager.ts index 50f9565..4be1079 100644 --- a/src/noteManager.ts +++ b/src/noteManager.ts @@ -241,8 +241,10 @@ export class NoteManager extends EventEmitter { // Save to storage await this.storage.saveNote(note); - // Remove from cache - this.removeNoteFromCache(noteId, filePath); + // Keep the soft-deleted note in the cache (the cache holds ALL notes; + // getNotesForFile filters deleted ones at return). Removing it here + // would make "already deleted" lookups report "not found" instead. + this.updateNoteInCache(note); // Remove from search index if (this.searchManager) { @@ -264,8 +266,11 @@ export class NoteManager extends EventEmitter { return this.noteCache.get(filePath)!.filter(n => !n.isDeleted); } - // Load from storage and apply defaults at the boundary - const notes = (await this.storage.loadNotes(filePath)).map(applyDefaults); + // Load from storage and apply defaults at the boundary. The cache always + // holds ALL notes (including soft-deleted) — getAllNotesForFile shares + // this cache, so caching a pre-filtered list here would make deleted + // notes invisible to it. + const notes = (await this.storage.loadAllNotes(filePath)).map(applyDefaults); // Update cache this.noteCache.set(filePath, notes); @@ -297,7 +302,9 @@ export class NoteManager extends EventEmitter { */ async getNoteById(noteId: string, filePath: string): Promise { const notes = await this.getAllNotesForFile(filePath); - return notes.find(n => n.id === noteId); + // Soft-deleted notes are not retrievable through the by-id lookup — + // consumers (comment threads, edit mode) must treat them as gone + return notes.find(n => n.id === noteId && !n.isDeleted); } /** @@ -386,17 +393,6 @@ export class NoteManager extends EventEmitter { } } - /** - * Remove a note from the cache - */ - private removeNoteFromCache(noteId: string, filePath: string): void { - const notes = this.noteCache.get(filePath); - if (notes) { - const filtered = notes.filter(n => n.id !== noteId); - this.noteCache.set(filePath, filtered); - } - } - /** * Clear the cache for a specific file */ From 3f4a28b383d51067aa90a70da83a370e2541a786 Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Thu, 16 Jul 2026 23:51:22 +0600 Subject: [PATCH 29/31] =?UTF-8?q?=F0=9F=90=9B=20fix(comments):=20editing-c?= =?UTF-8?q?omment=20lookup=20and=20created/updated=20label?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getCurrentlyEditingComment looked up commentThreads (keyed by file:line thread key) with a note id, so it always returned null; the thread key is now tracked alongside the editing note id - Comment labels always said 'Last updated' because the creation history entry counted as an update; fresh notes now show 'Created' Co-Authored-By: Claude Fable 5 --- src/commentController.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/commentController.ts b/src/commentController.ts index 390c83d..1b1ca9f 100644 --- a/src/commentController.ts +++ b/src/commentController.ts @@ -18,6 +18,7 @@ export class CommentController { private commentThreads: Map; // threadKey (lineKey) -> CommentThread private threadStates: Map; // threadKey -> state private currentlyEditingNoteId: string | null = null; // Track which note is being edited + private currentlyEditingThreadKey: string | null = null; // Thread key for the note being edited private currentlyCreatingThreadId: string | null = null; // Track temporary ID of thread being created constructor(noteManager: NoteManager, context: vscode.ExtensionContext) { @@ -187,7 +188,9 @@ export class CommentController { ? new Date(note.history[note.history.length - 1].timestamp) : createdDate; - const isUpdated = note.history && note.history.length > 0; + // The first history entry is the creation itself — a note only counts + // as updated once it has more history than that + const isUpdated = note.history && note.history.length > 1; const label = isUpdated ? `Last updated ${lastUpdated.toLocaleDateString()}` : `Created ${createdDate.toLocaleDateString()}`; @@ -424,6 +427,7 @@ export class CommentController { // Clear editing state only if we're not keeping a specific thread if (!exceptThreadKey) { this.currentlyEditingNoteId = null; + this.currentlyEditingThreadKey = null; this.currentlyCreatingThreadId = null; } } @@ -759,6 +763,7 @@ export class CommentController { // Track which note is being edited this.currentlyEditingNoteId = noteId; + this.currentlyEditingThreadKey = threadKey; // Expand the thread thread.collapsibleState = vscode.CommentThreadCollapsibleState.Expanded; @@ -783,11 +788,12 @@ export class CommentController { * so this should return the latest content */ getCurrentlyEditingComment(): vscode.Comment | null { - if (!this.currentlyEditingNoteId) { + if (!this.currentlyEditingNoteId || !this.currentlyEditingThreadKey) { return null; } - const thread = this.commentThreads.get(this.currentlyEditingNoteId); + // commentThreads is keyed by thread key (file + line), not note id + const thread = this.commentThreads.get(this.currentlyEditingThreadKey); if (!thread || thread.comments.length === 0) { return null; } @@ -859,6 +865,7 @@ export class CommentController { // Clear editing state this.currentlyEditingNoteId = null; + this.currentlyEditingThreadKey = null; return true; } From 324a59a57852981cd19eba2234fe7276eab757da Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Thu, 16 Jul 2026 23:51:22 +0600 Subject: [PATCH 30/31] =?UTF-8?q?=F0=9F=90=9B=20fix(ui):=20note=20preview?= =?UTF-8?q?=20formatting=20and=20sidebar=20ordering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CodeLens: take the first line before stripping markdown (the strip collapses newlines, merging every line into the preview) and strip heading markers that appear mid-text - noteTreeItem: strip image markdown before link markdown so ![alt](url) doesn't leave a stray '!'; truncateText returns the ellipsis itself when maxLength <= 3 instead of a clipped fragment - Sidebar: sort note children by line number — callers may construct FileTreeItem with notes in any order Co-Authored-By: Claude Fable 5 --- src/codeLensProvider.ts | 13 +++++++------ src/noteTreeItem.ts | 16 +++++++++------- src/notesSidebarProvider.ts | 6 +++++- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/codeLensProvider.ts b/src/codeLensProvider.ts index ac955d5..b3f4b33 100644 --- a/src/codeLensProvider.ts +++ b/src/codeLensProvider.ts @@ -134,8 +134,9 @@ export class CodeNotesLensProvider implements vscode.CodeLensProvider { .replace(/\[([^\]]+)\]\([^\)]+\)/g, '$1') // Remove images .replace(/!\[([^\]]*)\]\([^\)]+\)/g, '$1') - // Remove headings - .replace(/^#{1,6}\s+/gm, '') + // Remove heading markers (line-start and after whitespace — previews + // are single lines, so stray mid-text markers should go too) + .replace(/(^|\s)#{1,6}\s+/g, '$1') // Remove blockquotes .replace(/^>\s+/gm, '') // Remove list markers @@ -155,8 +156,9 @@ export class CodeNotesLensProvider implements vscode.CodeLensProvider { if (notes.length === 1) { const note = notes[0]; // Strip markdown formatting and get first line - const plainText = this.stripMarkdown(note.content); - const firstLine = plainText.split('\n')[0]; + // Take the first line BEFORE stripping — stripMarkdown collapses + // newlines into spaces, which would merge all lines into one. + const firstLine = this.stripMarkdown(note.content.split('\n')[0]); const preview = firstLine.length > 50 ? firstLine.substring(0, 47) + '...' : firstLine; @@ -171,8 +173,7 @@ export class CodeNotesLensProvider implements vscode.CodeLensProvider { : uniqueAuthors.join(', '); // Get preview from first note - const plainText = this.stripMarkdown(notes[0].content); - const firstLine = plainText.split('\n')[0]; + const firstLine = this.stripMarkdown(notes[0].content.split('\n')[0]); const preview = firstLine.length > 35 ? firstLine.substring(0, 32) + '...' : firstLine; diff --git a/src/noteTreeItem.ts b/src/noteTreeItem.ts index fa00cc6..93edcdd 100644 --- a/src/noteTreeItem.ts +++ b/src/noteTreeItem.ts @@ -138,10 +138,11 @@ export class NoteTreeItem extends BaseTreeItem { .replace(/_([^_]+)_/g, '$1') // Remove strikethrough .replace(/~~([^~]+)~~/g, '$1') + // Remove images (before links — the link pattern would otherwise + // match the [alt](url) part and leave a stray '!') + .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') // Remove links but keep text .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') - // Remove images - .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') // Remove headings .replace(/^#+\s+/gm, '') // Remove list markers @@ -163,16 +164,17 @@ export class NoteTreeItem extends BaseTreeItem { maxLength = 0; } - // For very small maxLength (<=3), just return substring without ellipsis - if (maxLength <= 3) { - return text.substring(0, maxLength); - } - // If text fits, return unchanged if (text.length <= maxLength) { return text; } + // For very small maxLength (<=3) there is no room for content plus + // ellipsis — the ellipsis itself is the truncation marker + if (maxLength <= 3) { + return '.'.repeat(maxLength); + } + // Otherwise, truncate and add ellipsis return text.substring(0, maxLength - 3) + '...'; } diff --git a/src/notesSidebarProvider.ts b/src/notesSidebarProvider.ts index cbf6c73..3d9a60d 100644 --- a/src/notesSidebarProvider.ts +++ b/src/notesSidebarProvider.ts @@ -200,7 +200,11 @@ export class NotesSidebarProvider implements vscode.TreeDataProvider new NoteTreeItem(note, previewLength)); + // Sort by line number — callers may construct FileTreeItem with + // notes in any order + return [...fileNode.notes] + .sort((a, b) => a.lineRange.start - b.lineRange.start) + .map(note => new NoteTreeItem(note, previewLength)); } /** From fabc5ebea402cadb2799f50d541f8068feb9ba5b Mon Sep 17 00:00:00 2001 From: Julkar Naen Nahian Date: Thu, 16 Jul 2026 23:51:22 +0600 Subject: [PATCH 31/31] =?UTF-8?q?=E2=9C=85=20test:=20fix=20flaky=20infra?= =?UTF-8?q?=20and=20align=20assertions=20with=20async=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mock document paths used Date.now() alone — two docs created in the same millisecond collided on the same fsPath; added a sequence counter - CommentController flows that reopen the document from disk now write the file first (openTextDocument was failing on nonexistent paths) - await the now-async updateCommentThread; assert falsy instead of null for deleted-note lookup (API returns undefined) - Changelog: record the fixes under v0.3.0 Co-Authored-By: Claude Fable 5 --- docs/changelogs/v0.3.0.md | 8 ++++++++ src/test/suite/commentController.test.ts | 16 +++++++++++++--- src/test/suite/noteManager.test.ts | 4 +++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs/changelogs/v0.3.0.md b/docs/changelogs/v0.3.0.md index 777f944..cf9cd1b 100644 --- a/docs/changelogs/v0.3.0.md +++ b/docs/changelogs/v0.3.0.md @@ -15,6 +15,14 @@ - Note type appears as `· {type}` description suffix; high/critical priority shown as a tooltip badge - New title-bar actions: **Filter Notes by Type…** (multi-select Quick Pick) and **Toggle Expired Notes** +### Fixed +- CodeLens previews of multiline notes showed all lines merged together; now only the first line, and stray heading markers (`##`) are stripped +- Sidebar notes could render out of line-number order; note previews now strip image markdown cleanly (`![alt](url)` no longer leaves a stray `!`) +- Comment labels always claimed "Last updated" — fresh notes now correctly show "Created" +- Edit-mode lookups (`getCurrentlyEditingComment`) used the wrong map key and never found the editing comment +- Soft-deleted notes disappeared from the note cache entirely, breaking "already deleted" detection and metadata guards; they are also no longer retrievable via by-id lookup +- Stale compiled test artifacts in `out/` ran phantom test suites; the test pipeline now cleans `out/` before compiling + ### Changed - Sidebar toolbar gains filter/expired toggles diff --git a/src/test/suite/commentController.test.ts b/src/test/suite/commentController.test.ts index 7f85b3e..e6b42e3 100644 --- a/src/test/suite/commentController.test.ts +++ b/src/test/suite/commentController.test.ts @@ -204,8 +204,8 @@ suite('CommentController Test Suite', () => { content: 'Updated content' }, document); - // Update thread - commentController.updateCommentThread(updatedNote, document); + // Update thread (async — refreshes the comment from storage) + await commentController.updateCommentThread(updatedNote, document); const updatedComment = thread.comments[0]; assert.ok((updatedComment.body as vscode.MarkdownString).value.includes('Updated content')); @@ -315,7 +315,7 @@ suite('CommentController Test Suite', () => { await commentController.handleDeleteNote(note.id, testFile); const deletedNote = await noteManager.getNoteById(note.id, testFile); - assert.strictEqual(deletedNote, null, 'Note should be deleted'); + assert.ok(!deletedNote, 'Note should be deleted'); }); }); @@ -324,6 +324,8 @@ suite('CommentController Test Suite', () => { const testFile = path.join(tempDir, 'test.ts'); const lines = ['line 1', 'line 2']; const document = createMockDocument(vscode.Uri.file(testFile), lines); + // This flow reopens the document from disk, so the file must exist + await fs.writeFile(testFile, lines.join('\n')); const note = await noteManager.createNote({ filePath: testFile, @@ -343,6 +345,8 @@ suite('CommentController Test Suite', () => { const testFile = path.join(tempDir, 'test.ts'); const lines = ['line 1', 'line 2']; const document = createMockDocument(vscode.Uri.file(testFile), lines); + // This flow reopens the document from disk, so the file must exist + await fs.writeFile(testFile, lines.join('\n')); const note = await noteManager.createNote({ filePath: testFile, @@ -367,6 +371,8 @@ suite('CommentController Test Suite', () => { const testFile = path.join(tempDir, 'test.ts'); const lines = ['line 1', 'line 2']; const document = createMockDocument(vscode.Uri.file(testFile), lines); + // This flow reopens the document from disk, so the file must exist + await fs.writeFile(testFile, lines.join('\n')); const note = await noteManager.createNote({ filePath: testFile, @@ -389,6 +395,8 @@ suite('CommentController Test Suite', () => { const testFile = path.join(tempDir, 'test.ts'); const lines = ['line 1', 'line 2']; const document = createMockDocument(vscode.Uri.file(testFile), lines); + // This flow reopens the document from disk, so the file must exist + await fs.writeFile(testFile, lines.join('\n')); // Create and update a note to generate history const note = await noteManager.createNote({ @@ -424,6 +432,8 @@ suite('CommentController Test Suite', () => { const testFile = path.join(tempDir, 'test.ts'); const lines = ['line 1', 'line 2']; const document = createMockDocument(vscode.Uri.file(testFile), lines); + // This flow reopens the document from disk, so the file must exist + await fs.writeFile(testFile, lines.join('\n')); const note = await noteManager.createNote({ filePath: testFile, diff --git a/src/test/suite/noteManager.test.ts b/src/test/suite/noteManager.test.ts index 412ab41..72b699b 100644 --- a/src/test/suite/noteManager.test.ts +++ b/src/test/suite/noteManager.test.ts @@ -592,9 +592,11 @@ suite('NoteManager Test Suite', () => { /** * Helper function to create a mock VSCode document */ +let mockDocumentSeq = 0; + async function createMockDocument(content: string): Promise { const lines = content.split('\n'); - const filePath = `/test/file-${Date.now()}.ts`; + const filePath = `/test/file-${Date.now()}-${++mockDocumentSeq}.ts`; return { lineCount: lines.length,