From 59e9ba47d57421bcceb6a43d494d43f602ea4f01 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Wed, 6 May 2026 07:43:57 +0300 Subject: [PATCH 01/16] =?UTF-8?q?docs(plans):=20recipe-recency=20plan=20wi?= =?UTF-8?q?th=20Q1=E2=80=93Q12=20resolved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan PR for §1.9 recipe-recency tracking (roadmap.md backlog item). Pre-locked decisions L.1–L.8 lifted from the roadmap entry; open decisions Q1–Q12 grilled inline with Resolution subsections. Key decisions: - Q1: minimal three-column shape (recipe_id TEXT PK, last_run_at INTEGER, run_count INTEGER), STRICT, WITHOUT ROWID + idx_recipe_recency_last_run - Q2: two write sites, one shared recordRecipeRun helper — handleQueryRecipe (MCP+HTTP) + runQueryCmd (CLI). Fact-checked architecturally; CLI does NOT route through tool-handlers.ts despite the original L.2 wording - Q3: lazy prune on --recipes-json reads (NOT eager on writes — keeps the recipe-execution hot path a pure upsert) - Q5: inline last_run_at + run_count fields per entry; (b) wrapping shape rejected after verifying current --recipes-json is a bare JSON array - Q7: survives --full / SCHEMA_VERSION rebuilds (joins query_baselines / coverage user-data precedent — intentionally absent from dropAll()) - Q8: NO SCHEMA_VERSION bump (additive table; CREATE TABLE IF NOT EXISTS auto-creates on next boot per pre-v1 patch-default lessons) - Q12: boundary-check codified — only tool-handlers.ts + cmd-query.ts + the test file may import recordRecipeRun (forbidden-edge SQL inline) Five tracer-bullet slices defined; Slice 5 cleanup runbook deletes this plan file per docs/README.md Rule 3. --- docs/plans/recipe-recency.md | 356 +++++++++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 docs/plans/recipe-recency.md diff --git a/docs/plans/recipe-recency.md b/docs/plans/recipe-recency.md new file mode 100644 index 0000000..c9adec1 --- /dev/null +++ b/docs/plans/recipe-recency.md @@ -0,0 +1,356 @@ +# Recipe-recency tracking — plan + +> **Status:** open · plan iterating. M effort. Next in cadence per [`research/non-goals-reassessment-2026-05.md § 5`](../research/non-goals-reassessment-2026-05.md#5-pick-order-rationale-historical) Rationale 4 (orthogonal to (b) C.9 — recency has its own table, no `is_entry` / reachability dependency). +> +> **Motivator:** agents reading `codemap://recipes` at session start get an alphabetical list with no signal about which recipes the project actually uses. A 90-day-windowed `last_run_at` + `run_count` per recipe lets agent hosts sort by recency / frequency, surfacing live recipes ahead of historic ones. Local-only (no upload primitive) — resists the future telemetry-creep PR by construction. +> +> **Tier:** M effort. New table, reconciler at the existing tool-handler seam, ~one column-set extension to `--recipes-json`. No new transport, no new engine, no schema-breaking change. + +--- + +## Pre-locked decisions + +These are committed to v1 (lifted directly from the [roadmap.md § Backlog](../roadmap.md#backlog) entry — the roadmap itself is the locking surface). Questions opened against them must justify against the linked floors / moats. + +| # | Decision | Source | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| L.1 | **New `recipe_recency` table** keyed by `recipe_id`, three-column minimal shape (`recipe_id TEXT PK`, `last_run_at INTEGER`, `run_count INTEGER`), `STRICT, WITHOUT ROWID`, partial index on `last_run_at` for prune scans. See [Q1 Resolution](#q1-resolution). | Roadmap entry + Q1 grilling | +| L.2 | **Two write sites, one shared helper.** MCP+HTTP both flow through `handleQueryRecipe` in `application/tool-handlers.ts`; CLI `query --recipe` dispatches separately through `runQueryCmd` in `cli/cmd-query.ts` (calls `getQueryRecipeSql` + `printQueryResult` directly, NOT through tool-handlers). Both call a shared `recordRecipeRun({db, recipeId})` helper from `application/recipe-recency.ts` after the recipe execution succeeds. Mirrors the `boundary_rules` reconciler shape — one helper, called from the appropriate orchestrators. | Verified via `codemap query` against `calls` + `imports` tables — see Q2 grilling | +| L.3 | **Rolling 90-day retention** — rows whose `last_run_at` falls outside the window get pruned. Trigger (eager / lazy / scheduled) resolved by Q3. | Roadmap entry | +| L.4 | **Opt-out via `.codemap/config.ts` `recipe_recency: false`** (default ON). Schema addition to `codemapUserConfigSchema` (Zod). | Roadmap entry — explicit "opt-out" wording | +| L.5 | **Surfaces in `--recipes-json`** (CLI flag) and the matching `codemap://recipes` resource. Per-entry shape resolved by Q5. | Roadmap entry | +| L.6 | **Local-only — no upload primitive ever ships.** No telemetry endpoint, no `--report-recency` flag, no opt-in SaaS. The `recipe_recency` table stays inside `/index.db`. The Floor exists to resist accumulation pressure. | [Floor "No telemetry upload"](../roadmap.md#floors-v1-product-shape) | +| L.7 | **Moat-A clean.** Recency is metadata about run frequency, not a verdict; recipes still ARE the SQL. No new verdict-shaped CLI verb; consumers compose `--recipes-json` + `jq` for "rank by recency" queries. | [Moat A](../roadmap.md#moats-load-bearing) | +| L.8 | **Failure-mode isolation.** A recency-write failure (DB locked, disk full, schema drift) NEVER blocks the actual recipe execution. The reconciler runs after the recipe result is computed; errors are swallowed with a stderr warning (`[recency] write failed: `). | Same shape as `boundary_rules` reconciler — never-blocking | + +--- + +## Open decisions (iterate as the plan converges) + +Each gets a "Resolution" subsection below as it crystallises (mirrors the `c9-plugin-layer.md` / `github-marketplace-action.md` / `lsp-diagnostic-push.md` pattern). + +- **Q1 — Exact schema shape.** Column types and table options: + + ```sql + CREATE TABLE recipe_recency ( + recipe_id TEXT PRIMARY KEY, -- matches QUERY_RECIPES key + project-recipe id + last_run_at INTEGER NOT NULL, -- epoch ms, mirrors `meta.indexed_at` + run_count INTEGER NOT NULL DEFAULT 1 + ) STRICT, WITHOUT ROWID; + ``` + + Variants to weigh: do we want a `first_run_at` column for "how long has this recipe been in rotation"? Source breakdown (`source TEXT` discriminator: `cli` / `mcp` / `http`)? `errored_run_count` for failed runs (Q9)? Default bias is the minimal three-column shape above; richer shapes earn their place when a real consumer asks. + + ### Q1 Resolution + + **Locked: minimal three-column shape, no extras for v1.** + + ```sql + CREATE TABLE recipe_recency ( + recipe_id TEXT PRIMARY KEY, + last_run_at INTEGER NOT NULL, + run_count INTEGER NOT NULL DEFAULT 1 + ) STRICT, WITHOUT ROWID; + + CREATE INDEX idx_recipe_recency_last_run ON recipe_recency(last_run_at); + ``` + + Reasoning: + - `STRICT, WITHOUT ROWID` matches the `dependencies` / `meta` precedent (TEXT PK → data lives in PK B-tree; one indirection less, lower storage footprint). + - `idx_recipe_recency_last_run` keeps the prune `DELETE WHERE last_run_at < ` an indexed scan as project-recipe counts grow. + - **Rejected for v1, additively promotable later (Moat-A discipline + roadmap two-consumer-trigger precedent):** + - `first_run_at` — speculative; no recipe today asks "how long has this been in rotation." + - `source TEXT` discriminator — covered by Q4 (project-recipe shadowing) at a different angle; revisit if a consumer wants per-transport breakdown. + - `errored_run_count` — collapses into Q9 (what counts as a run); if Q9 lands on "successful runs only" this column is dead weight. + +- **Q2 — Which entry points write to recency?** Two candidates after the architectural fact-check (CLI does NOT go through `tool-handlers.ts` — `cmd-query.ts` calls `getQueryRecipeSql` + `printQueryResult` directly; verified via `calls` + `imports` queries against the live index): + - **(b) MCP/HTTP only** — instrument `handleQueryRecipe` in `application/tool-handlers.ts` only. Strict reading of the roadmap wording ("MCP/HTTP request boundary"). One write site. Trade-off: misses every bash agent shelling out `codemap query --recipe X` — the documented invocation pattern in both `templates/agents/` and `.agents/rules/codemap.md`. + - **(c) MCP/HTTP + CLI** — instrument `handleQueryRecipe` (covers MCP + HTTP — both flow through it) AND `runQueryCmd`'s `--recipe` branch in `cli/cmd-query.ts` (covers CLI). Both call a shared `recordRecipeRun({db, recipeId})` helper from `application/recipe-recency.ts`. Two write sites, one helper. + + ### Q2 Resolution + + **Locked: (c) MCP/HTTP + CLI.** + + Reasoning: + - The bundled agent skill + `.agents/rules/codemap.md` lead with `codemap query --json --recipe ` (the CLI form) as the documented agent invocation. Excluding CLI guts the signal for the recommended path. + - Roadmap's "MCP/HTTP request boundary" wording is loose; the principle is "instrument the request-boundary equivalent on every transport agents use" — all three transports qualify. + - Cost is ~5 lines in `handleQueryRecipe` after `executeQuery` returns + ~5 lines in `runQueryCmd` after the `--recipe` branch resolves successfully — both calling the same helper. + - Failure-mode isolation (L.8) protects both sites uniformly via the shared helper's internal `try/catch`. + + Locked into L.2 (above). + +- **Q3 — Pruning trigger.** When does the 90-day cutoff actually run? + - **(a) Eager** — every write does `DELETE FROM recipe_recency WHERE last_run_at < ` before the upsert. Cheap (single indexed scan; rows count ≈ recipe count, ~30 rows today). Always-fresh, no surprise drift. + - **(b) Lazy** — only on `--recipes-json` reads. Writes never prune. Simpler write path; reads see "fresh after first read" semantics. + - **(c) Scheduled** — separate prune step (e.g. on `bun src/index.ts --full` or boot). Adds a wiring point. + + ### Q3 Resolution + + **Locked: (b) Lazy.** Switched from the draft default (a) after sharper analysis. + + Reasoning: + - **Freshness is a read-time concern, not write-time.** Agents consume recency via `--recipes-json` reads; the table's between-reads state has no consumer. Pruning at write does more work than necessary. + - **Hot path stays cheap.** Recipe execution is the hot path; write-site cost matters more than read-site cost (reads are infrequent — session-start). Lazy keeps writes pure upserts; eager adds an indexed DELETE scan per call. + - **Failure isolation symmetry.** Under L.8 write failures get swallowed silently. With (a), a consistently-failing prune lets the table grow unbounded silently. With (b), prune failures only affect a single `--recipes-json` read — the next read retries. Read-side failures are louder by nature. + - **Bot-driven session resilience.** A bot calling 1M recipes burns 1M indexed-DELETE scans under (a); under (b) it burns 1M pure upserts and prunes once on next `--recipes-json` read. + - **Reject (c) scheduled** — long-running `mcp` / `serve` sessions never invoke `--full` / boot; the table grows for the whole session. Strictly worse than (b). + + Implementation: `loadRecipeRecency({db, recipeIds?})` invokes `pruneRecipeRecency({db, cutoffMs: now - 90*86400_000})` before its SELECT. Pure functions in `application/recipe-recency.ts`; the prune is part of the load contract, not a separate verb consumers wire. + +- **Q4 — Project-local recipes (shadows).** A project recipe with the same id as a bundled recipe (`shadows: true` per [`recipes-loader.ts`](../architecture.md#cli-usage)) — does it share a `recipe_recency.recipe_id` row with the bundled one, or get its own? + - **(a) Shared row** — `recipe_id` is the only key. Trade-off: when a project shadows `untested-and-dead`, you can't tell from recency whether the bundled or the project version was hit. + - **(b) Separate rows** with a `source: "bundled" | "project"` discriminator. Composite PK `(recipe_id, source)`. Mirrors the `--recipes-json` discriminator already in place. + + ### Q4 Resolution + + **Locked: (a) Shared row.** + + Reasoning: + - **Only one version is ever reachable per id.** Project wins on collision per `recipes-loader.ts`; bundled is unreachable while shadowed. The bundled row would never get written; (b) solves a non-problem. + - **Continuity across shadow add/remove.** Only realistic transition is shadow added or removed. Under (a), `last_run_at` carries through both — agent sees consistent "this id has been used recently." Under (b), removing a shadow creates a discontinuity (project stale, bundled fresh-zero). + - **Q1's locked PK stays clean.** (b) would require re-opening Q1 to change PK from `TEXT PRIMARY KEY` to `(recipe_id, source) PRIMARY KEY` — schema redesign for zero current consumer demand. + - **Promotion path additive.** If a future consumer asks for bundled-vs-project breakdown: add non-PK `source TEXT` column, default `'bundled'`. No PK migration. No data loss. + +- **Q5 — `--recipes-json` shape.** Per-entry inline fields or a separate top-level block? + - **(a) Inline** — every recipe entry gets `last_run_at: | null` and `run_count: `. Agents reading the catalog see recency on the same object they sort by `id`. + - **(b) Separate `recency:` map** — `{recipes: [...], recency: {: {last_run_at, run_count}}}`. Keeps the recipe shape stable for consumers that don't want recency. + + ### Q5 Resolution + + **Locked: (a) Inline.** + + Verified empirically: current `--recipes-json` is a **bare JSON array** of entry objects (each with `id`, `description`, `sql`, `source`, `body`, `actions`, etc.). (b) would wrap in `{recipes, recency}` — a breaking change for every `jq '.[] | …'` consumer. + + Reasoning: + - **(b) breaks bare-array consumers.** Not worth re-opening backwards-compat for a packaging preference. + - **Existing precedents are inline.** `actions`, `params`, `body`, `source`, `shadows` all landed as inline fields per the verified shape. Recency matches the additive-evolution pattern. + - **Agent-friendly co-location.** `jq 'sort_by(.last_run_at // 0) | reverse'` is a single field on the entry agents already iterate; (b) forces a top-level join. + - **Null semantics for never-run.** `last_run_at: null`, `run_count: 0` for recipes never executed; consumers filter with `select(.last_run_at != null)` or treat null as "fresh." + - **MCP resource symmetry.** `codemap://recipes` and `codemap://recipes/{id}` mirror the inline shape via `application/resource-handlers.ts` — Slice 3 ships identical shape on all surfaces with no parallel map-merge step. + +- **Q6 — Does codemap re-rank, or just expose data?** Two flips: + - **(a) Expose-only** — `--recipes-json` keeps alphabetical / source order; consumers re-rank with `jq sort_by(.last_run_at)`. Moat-A clean (no opinion baked in). + - **(b) Re-rank by default** — `--recipes-json` returns recency-sorted; alphabetical is `--recipes-json --sort=id`. Friendlier for agents that don't post-process. + + ### Q6 Resolution + + **Locked: (a) Expose-only.** + + Reasoning: + - **Moat-A discipline.** Codemap exposes substrate; consumers decide relevance. Re-ranking IS an opinion (recency vs frequency vs `recency × frequency`?). Baking one in violates the "predicate-as-API + pure structural" framing. + - **Multiple valid orderings, none dominates.** Alphabetical (human scan), by source ("what this project added"), by recency (active recipes), by frequency (popular), by `actions[].type` (action-driven). Picking one in default output disenfranchises every other use case. + - **Stable order = stable diffs.** Alphabetical (current) is deterministic — golden-query snapshots and PR diffs stay clean. (b) makes every `--recipes-json` snapshot order-churn on every recipe execution. + - **One-liner composition costs nothing.** `codemap query --recipes-json | jq 'sort_by(-.last_run_at // 0)'` matches the established `audit | jq` CI idiom from `roadmap.md § Backlog`. Agents that want recency-ranking get it free; agents that want alphabetical keep it. + - **Promotion path additive.** If two consumers ship `jq sort_by(.last_run_at)` workflows with similar shapes (the documented two-consumer-trigger gate), promote `--sort=last-run` as additive sugar. Default flip stays gated. + +- **Q7 — Schema lifecycle.** Does `recipe_recency` survive `--full` and `SCHEMA_VERSION` rebuilds, or get dropped each time? + - **(a) Survives** — joins the `query_baselines` / `coverage` precedent: intentionally absent from `dropAll()`. Recency is user-activity data, not derived index content. + - **(b) Drops** — joins `boundary_rules` / `dependencies` / `symbols`: rebuilt deterministically on every full reindex. Simpler — no special-case handling. + + ### Q7 Resolution + + **Locked: (a) Survives.** + + Reasoning: + - **Recipe recency is user-activity data, not derived index content.** Tracks "which recipes the agent / user ran"; no source-of-truth to rederive from. Wiping on full reindex zeros the signal for no recoverable reason. + - **Branch switch + `SCHEMA_VERSION` bump are the dominant `--full` triggers.** Both are common; both leave `query_baselines` and `coverage` intact. Recipe recency joins the same posture. + - **Direct precedent.** Per [`architecture.md` § `coverage`](../architecture.md#coverage--statement-coverage-user-data-strict-without-rowid): _"Same lifecycle posture as `query_baselines`: intentionally absent from `dropAll()` so `--full` and `SCHEMA_VERSION` rebuilds preserve user ingest."_ Three precedents makes "user-data tables survive `dropAll()`" a documentable pattern worth lifting into `architecture.md` § Schema in Slice 5. + - **No CASCADE hazard.** `recipe_recency` doesn't FK to any table; `recipe_id` is loose (matches bundled or project ids — no `recipes` SQLite table to FK against). Simply omit from `dropAll()`. + - **The 90-day prune (Q3) handles staleness;** keeping data has no downside. + +- **Q8 — Schema migration.** Does adding `recipe_recency` bump `SCHEMA_VERSION`? + - Per [`.agents/lessons.md`](../../.agents/lessons.md) "changesets bump policy (pre-v1)": adding a new table that doesn't break existing readers is a **patch**, not a minor — `SCHEMA_VERSION` only bumps when DDL changes break old `.codemap/index.db` files. New table additive → no `SCHEMA_VERSION` bump needed; `createSchema()` creates it on next boot. Confirm by walking the create-or-migrate path in `db.ts`. + + ### Q8 Resolution + + **Locked: NO `SCHEMA_VERSION` bump. Patch changeset.** + + Verified mechanism in `db.ts`: + - `createTables()` uses `CREATE TABLE IF NOT EXISTS` for every table; called via `createSchema()` on every boot. + - `SCHEMA_VERSION` mismatch triggers `dropAll()`; current value is `10`. + - For an existing DB at version 10 + new code shipping `recipe_recency`: table appears via `IF NOT EXISTS` on first boot. No rebuild. No data loss. + + Reasoning: + - **Lessons file is explicit:** _"Don't propose `minor` just because new CLI commands or public types were added."_ Additive tables are patch. + - **Bumping is strictly worse.** `dropAll()` would force a ~85ms full rebuild (per benchmark.md) for zero migration benefit — `recipe_recency` doesn't need pre-population. + - **DDL placement.** Add `CREATE TABLE IF NOT EXISTS recipe_recency (…)` + `idx_recipe_recency_last_run` inside `createTables()`, sibling to `query_baselines` + `coverage` (the user-data substrate). Skip `dropAll()` per Q7. No `meta` key needed. + - **Future column additions** would need `ALTER TABLE` + version-bump strategy. Out of scope for v1. + +- **Q9 — What counts as a "run"?** Three candidates: + - **(a) Successful executions only** — recency reflects "this recipe produced rows the agent used." + - **(b) Any execution** — including SQL errors, param-validation rejections, FTS5-disabled errors. Recency reflects intent. + - **(c) Success + param-validation success** — exclude infrastructure errors (DB locked, schema drift) but count "recipe actually ran end-to-end." + + ### Q9 Resolution + + **Locked: (a) Successful executions only.** + + Reasoning: + - **(c) collapses into (a).** Q10's locked failure-isolation pattern places `recordRecipeRun` AFTER successful execution. Any throw exits before the call site; (a) and (c) reach the same code by construction. + - **(b) inflates under adversarial / typo conditions.** Bot retrying 100× with a bad param → `run_count = 100` for a recipe that never produced useful output. Misleading signal. + - **0-row results are legitimate runs.** A clean `[]` return means "no rows match" — deliberate, useful information. Counting preserves the signal. + - **Q1's locked schema agrees.** No `errored_run_count` column reserved; (b) would re-open Q1. + - **Promotion path additive.** If a future consumer wants intent-including-failures, add `errored_run_count INTEGER DEFAULT 0` non-PK. No PK migration; existing rows get 0. + - **Code-site simplicity.** + + ```typescript + const result = executeRecipe(...); // throws on any failure + try { recordRecipeRun(db, recipeId); } catch { /* L.8 swallow */ } + return result; + ``` + + Unconditional after the throw point; no error-path branching. + +- **Q10 — Failure-mode isolation + sampling.** L.8 commits to "never block." Concrete guard: + + ```typescript + // After the recipe result is computed, before returning to the caller: + try { + recordRecipeRun(db, recipeId); + } catch (err) { + if (!quiet) + console.warn(`[recency] write failed: ${(err as Error).message}`); + } + return result; + ``` + + Question: also sample writes (every Nth call) to mitigate hot-path overhead? + + ### Q10 Resolution + + **Locked: try/catch isolation; NO sampling.** + + Reasoning: + - **Cost is negligible.** Pure upsert into a tiny indexed table is ~1µs; recipe execution itself is ms+. Recency write is <0.1% of execution time after Q3's locked Lazy prune (no DELETE on the write path). + - **Sampling solves no real problem and adds complexity.** In-memory per-process counters undercount under multi-process workloads (concurrent CLI + `mcp` + `serve`); DB-backed counters replace one upsert with one read+update (same cost); random sampling discards signal that's the whole point of the table. + - **`--performance` is the escape hatch.** If a future report shows recency writes in the top phase contributors, revisit. Until then, no premature optimization. + +- **Q11 — Test approach.** + + ### Q11 Resolution + + **Locked: per-slice tests as described below.** + - **Unit (Slice 1):** `recordRecipeRun` + `pruneRecipeRecency` + `loadRecipeRecency` — pure functions over `(db, …)`. `src/application/recipe-recency.test.ts` covers happy path + 90-day cutoff boundary + opt-out short-circuit + null-row-on-never-run shape. Bun test runner; in-memory `:memory:` SQLite per test for isolation. + - **Integration / write-site (Slice 2):** + - **MCP/HTTP path** — extend `src/application/tool-handlers.test.ts` (or sibling) with a recipe-call assertion: invoke `handleQueryRecipe`, then `loadRecipeRecency` returns one row with the expected `recipe_id` + `run_count >= 1`. + - **CLI path** — extend `src/cli/cmd-query.test.ts` with the same assertion via `runQueryCmd({recipe: 'fan-out', …})`. + - Both paths share the same shared-helper write-site per Q2. + - **`--recipes-json` shape (Slice 3):** golden-query snapshot at `fixtures/golden/scenarios.json` runs three recipes against `fixtures/minimal/`, then `query --recipes-json`, asserts the inline `last_run_at` / `run_count` fields appear with stable placeholders (`last_run_at: ` matcher) so the snapshot stays diff-stable. + - **Opt-out (Slice 4):** dedicated test sets `recipe_recency: false` in user config, runs a recipe, asserts `recipe_recency` table stays empty. + - **Failure mode (Slice 2):** test against a read-only DB asserts the recipe still returns rows AND a stderr warning lands matching `/\[recency\] write failed/`. + - **Lazy prune (Slice 1):** test inserts a row with `last_run_at = now - 91d`, calls `loadRecipeRecency`, asserts the row was pruned and load returns empty. + +- **Q12 — Boundary-check codification.** Ship a forbidden-edge query in Slice 2's verification recipe so future PRs introducing a third write site get caught. + + ### Q12 Resolution + + **Locked: yes, codify in Slice 2's verification recipe.** + + Per Q2, the only legitimate write callers of `recordRecipeRun` are: + - `src/application/tool-handlers.ts` (covers MCP + HTTP) + - `src/cli/cmd-query.ts` (covers CLI) + - `src/application/recipe-recency.test.ts` (test harness) + + Boundary check (re-runnable as part of Slice 2's verification recipe): + + ```bash + bun src/index.ts query --json " + SELECT DISTINCT file_path + FROM imports + WHERE source LIKE '%application/recipe-recency%' + AND specifiers LIKE '%recordRecipeRun%' + AND file_path NOT IN ( + 'src/application/tool-handlers.ts', + 'src/cli/cmd-query.ts', + 'src/application/recipe-recency.test.ts' + ) + " + ``` + + Expected output: `[]`. Non-empty = a new write site appeared without a docs / boundary-check update; reviewer escalates per [`audit-pr-architecture` § 2](../../.agents/skills/audit-pr-architecture/SKILL.md#2-derive-the-boundary-leak-sql-kit-from-the-repos-own-architecture). + + `loadRecipeRecency` (read path) is a normal export — any consumer can import it (Slice 3 wires `--recipes-json` reads). The boundary check discriminates by `specifiers LIKE '%recordRecipeRun%'`, not by source path alone. + +--- + +## High-level architecture + +Three pieces; all small, no new engines. + +1. **Schema** (`src/db.ts`) — `createTables()` adds `recipe_recency` per Q1 Resolution; `dropAll()` does NOT include it per Q7 Resolution; `idx_recipe_recency_last_run` partial index for the lazy prune `DELETE` (Q3 Resolution). +2. **Engine** (`src/application/recipe-recency.ts`, new) — pure functions: `recordRecipeRun({db, recipeId, now?})` (write — used by Slice 2), `pruneRecipeRecency({db, cutoffMs})` (private; called by `loadRecipeRecency` per Q3), `loadRecipeRecency({db})` returning `Map` (read — used by Slice 3). Mirrors the `application/coverage-engine.ts` shape (pure transport-agnostic). +3. **Wiring (two write sites, one helper)** per Q2 / L.2: + - **MCP + HTTP** — `handleQueryRecipe` in `application/tool-handlers.ts`: after `executeQuery` resolves successfully, call `recordRecipeRun` (Q10-isolated try/catch). + - **CLI** — `runQueryCmd` in `cli/cmd-query.ts`: same shape, after the `--recipe` branch's SQL completes successfully. + - Read site: `--recipes-json` (CLI + `codemap://recipes` resource via `application/resource-handlers.ts`) calls `loadRecipeRecency` and joins inline per Q5. + +No CLI flag. No new transport. No engine duplication. + +--- + +## Implementation slices (tracer bullets) + +Per [`tracer-bullets`](../../.agents/rules/tracer-bullets.md) — ship one vertical slice end-to-end before expanding. + +1. **Slice 1: schema + engine.** Add the table to `db.ts` (`createTables()` only — `dropAll()` skip is intentional per Q7 Resolution; no `SCHEMA_VERSION` bump per Q8 Resolution). Implement `recordRecipeRun` + `pruneRecipeRecency` + `loadRecipeRecency` with unit tests in `src/application/recipe-recency.test.ts` (per Q11 Resolution). Verify via raw SQL: `bun src/index.ts query "SELECT * FROM recipe_recency"` returns `[]` on a fresh DB. +2. **Slice 2: write sites — both transports** (per Q2 Resolution). + - **MCP/HTTP path:** hook `recordRecipeRun` in `handleQueryRecipe` (`application/tool-handlers.ts`) after `executeQuery` resolves successfully. Q10-isolated `try/catch`. + - **CLI path:** hook `recordRecipeRun` in `runQueryCmd` (`cli/cmd-query.ts`) after the `--recipe` branch's SQL completes successfully. Same isolation. + - Smoke: `bun src/index.ts query --recipe fan-out --json` then `bun src/index.ts query "SELECT * FROM recipe_recency"` shows one row. + - Failure-mode test against a read-only DB (per Q11 Resolution). + - Boundary check codified per Q12 Resolution: forbidden-edge query asserting only `tool-handlers.ts` + `cmd-query.ts` + the test file import `recordRecipeRun`. +3. **Slice 3: `--recipes-json` inline read** (per Q5 Resolution). Every entry gains `last_run_at: | null` and `run_count: `. `loadRecipeRecency` runs the lazy prune (per Q3 Resolution) before the SELECT. Update CLI render in `cmd-query.ts`, MCP `codemap://recipes` + `codemap://recipes/{id}` resources via `resource-handlers.ts`, HTTP `GET /resources/codemap%3A%2F%2Frecipes`. Add a golden-query snapshot at `fixtures/golden/scenarios.json` with `last_run_at: ` matcher for stable diffs. +4. **Slice 4: opt-out config.** Add `recipe_recency: z.boolean().default(true)` to `codemapUserConfigSchema` (per L.4). When `false`, `recordRecipeRun` short-circuits before any DB write (the cleanest opt-out — no rows ever land; load returns empty by construction). Verify both modes via dedicated tests. +5. **Slice 5: docs + agent rule lockstep.** Per [`docs/README.md` Rule 10](../README.md): + - `docs/architecture.md` § Schema → new `recipe_recency` table row (matching the existing `coverage` / `query_baselines` shape). + - `docs/glossary.md` → `recipe_recency` entry. + - **Both** `templates/agents/rules/codemap.md` + `templates/agents/skills/codemap/SKILL.md` AND `.agents/rules/codemap.md` + `.agents/skills/codemap/SKILL.md` updated in lockstep — agents need to know `--recipes-json` carries the new fields. + - Per [`docs/README.md` Rule 2](../README.md): remove the recipe-recency entry from `roadmap.md § Backlog` and **delete this plan file** (per Rule 3 — plans die when work ships; durable design lives in `architecture.md` / `glossary.md` / agent surface). + +### Slice 5 cleanup runbook (post-merge) + +Mirrors the precedent set by `github-marketplace-action.md § Slice 5 runbook`: + +1. Confirm Slices 1-4 shipped on `main` (CI green; recipe-recency populates on a fresh `bun src/index.ts query --recipes-json`). +2. Update durable homes in one PR: + - `docs/architecture.md` § Schema — add `recipe_recency` table description. + - `docs/glossary.md` — add `recipe_recency` / "recipe recency" entry. + - `templates/agents/rules/codemap.md` + `templates/agents/skills/codemap/SKILL.md` — note the `--recipes-json` field additions. + - `.agents/rules/codemap.md` + `.agents/skills/codemap/SKILL.md` — same additions, CLI-prefix-only delta vs templates. +3. Remove the recipe-recency entry from `roadmap.md § Backlog`. +4. **Delete `docs/plans/recipe-recency.md`** per [`docs/README.md` Rule 3](../README.md). No tombstone (per [`docs-governance` § Closing a plan](../../.agents/skills/docs-governance/SKILL.md#closing-a-plan)). +5. Re-grep for orphaned references: `rg "recipe-recency.md"` should return zero hits outside the deletion commit. + +--- + +## Test approach + +Covered inline at Q11. Each slice ships its own tests; Slice 3 adds the golden-query snapshot. + +--- + +## Risks / non-goals + +| Item | Mitigation | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Non-goal:** telemetry / SaaS aggregation of recency. | Per L.6; data stays in `/index.db`. The Floor exists to resist accumulation pressure. PR reviewers reject any "phone home" addition. | +| **Non-goal:** recency-driven re-ranking baked into `--recipes-json`. | Per Q6 Resolution. Consumers compose; codemap exposes columns. Promotion gated on two-consumer trigger. | +| **Non-goal:** verdict-shaped CLI verb (`codemap recipes-by-recency`). | Per L.7 (Moat A). Recipes are SQL; recency is metadata. `query --recipes-json \| jq` is the idiom. | +| **Risk:** recency-write failures block recipe execution. | Per L.8 + Q10 Resolution; `try/catch` swallow with stderr warning. Failure-mode test in Slice 2 locks the contract. | +| **Risk:** schema drift between bundled and project recipes inflates the table. | Per Q4 Resolution — shared `recipe_id` row. Worst case: one row per known id (~30 bundled + project recipes). The 90-day prune (Q3 Resolution) keeps it bounded regardless. | +| **Risk:** the table grows unboundedly on bot-driven sessions. | Pruning is lazy on `--recipes-json` reads (Q3 Resolution). Even an adversarial bot calling 1M recipes only ever populates one row per distinct id; the table is bounded by recipe-id cardinality. `run_count` is `INTEGER` — overflow is theoretically possible but irrelevant. | +| **Risk:** plan abandoned mid-iteration. | Per [`docs/README.md` Rule 8](../README.md), close as `Status: Rejected (YYYY-MM-DD) — `. The schema delta is small enough that partial impl can be reverted cleanly. | + +--- + +## Cross-references + +- [`docs/roadmap.md § Backlog`](../roadmap.md#backlog) — recipe-recency entry (deleted by Slice 5 cleanup runbook above). +- [`docs/architecture.md § Schema`](../architecture.md#schema) — destination for the durable schema description (Slice 5). +- [`docs/glossary.md`](../glossary.md) — destination for the durable term entry (Slice 5). +- [`docs/research/non-goals-reassessment-2026-05.md § 5`](../research/non-goals-reassessment-2026-05.md#5-pick-order-rationale-historical) — cadence rationale (orthogonal to (b) C.9). +- [`docs/architecture.md § Tool / resource handlers`](../architecture.md#cli-usage) — the seam being instrumented (L.2). +- [`docs/README.md` Rule 3](../README.md) — plan-file convention (this file's location + deletion-on-ship). +- [`docs/README.md` Rule 10](../README.md) — agent rule + skill lockstep update (Slice 5). +- [`.agents/rules/tracer-bullets.md`](../../.agents/rules/tracer-bullets.md) — slice cadence. +- [`.agents/skills/docs-governance/SKILL.md § Closing a plan`](../../.agents/skills/docs-governance/SKILL.md#closing-a-plan) — delete-on-ship discipline. From bcda3a4e483af0aa87e6d92f5334a68c7b5ff84e Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Wed, 6 May 2026 07:44:11 +0300 Subject: [PATCH 02/16] feat(recency): recipe_recency schema + engine (Slice 1 of recipe-recency plan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema (src/db.ts): - New recipe_recency(recipe_id PK, last_run_at, run_count) STRICT, WITHOUT ROWID - idx_recipe_recency_last_run partial index for the lazy 90-day prune scan - Sibling to query_baselines + coverage (user-data substrate) - Intentionally absent from dropAll() (Q7) — survives --full / SCHEMA_VERSION - No SCHEMA_VERSION bump (Q8) — additive table, IF NOT EXISTS auto-creates Engine (src/application/recipe-recency.ts, new): - recordRecipeRun({db, recipeId, now?}) — pure upsert, ON CONFLICT DO UPDATE - pruneRecipeRecency({db, cutoffMs}) — DELETE WHERE last_run_at < cutoffMs - loadRecipeRecency({db, now?}) — calls prune internally per Q3 (lazy read-time pruning), returns Map - RECENCY_WINDOW_MS = 90 days exported for tests - Mirrors application/coverage-engine.ts shape (pure transport-agnostic) Tests (src/application/recipe-recency.test.ts, new): - 12 cases covering: schema-empty-after-create, RECENCY_WINDOW_MS constant, recordRecipeRun (create / increment / distinct-ids / default-now), pruneRecipeRecency (cutoff-strict-< / no-op-on-empty), loadRecipeRecency (empty / populated / lazy-prune-on-load) Slice 1 of the recipe-recency plan; Slice 2 wires recordRecipeRun into the two write sites (handleQueryRecipe + runQueryCmd). --- src/application/recipe-recency.test.ts | 225 +++++++++++++++++++++++++ src/application/recipe-recency.ts | 102 +++++++++++ src/db.ts | 20 +++ 3 files changed, 347 insertions(+) create mode 100644 src/application/recipe-recency.test.ts create mode 100644 src/application/recipe-recency.ts diff --git a/src/application/recipe-recency.test.ts b/src/application/recipe-recency.test.ts new file mode 100644 index 0000000..9618fa0 --- /dev/null +++ b/src/application/recipe-recency.test.ts @@ -0,0 +1,225 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { resolveCodemapConfig } from "../config"; +import { closeDb, createTables, openDb } from "../db"; +import { initCodemap } from "../runtime"; +import { + loadRecipeRecency, + pruneRecipeRecency, + RECENCY_WINDOW_MS, + recordRecipeRun, +} from "./recipe-recency"; + +let projectRoot: string; + +beforeEach(() => { + projectRoot = mkdtempSync(join(tmpdir(), "recipe-recency-")); + initCodemap(resolveCodemapConfig(projectRoot, undefined)); + const db = openDb(); + try { + createTables(db); + } finally { + closeDb(db); + } +}); + +afterEach(() => { + rmSync(projectRoot, { recursive: true, force: true }); +}); + +describe("recipe_recency — schema", () => { + it("starts empty after createTables", () => { + const db = openDb(); + try { + const rows = db + .query<{ n: number }>("SELECT COUNT(*) AS n FROM recipe_recency") + .all(); + expect(rows[0]?.n).toBe(0); + } finally { + closeDb(db, { readonly: true }); + } + }); + + it("RECENCY_WINDOW_MS equals 90 days", () => { + expect(RECENCY_WINDOW_MS).toBe(90 * 24 * 60 * 60 * 1000); + }); +}); + +describe("recordRecipeRun", () => { + it("creates a row with run_count=1 on first call", () => { + const db = openDb(); + try { + recordRecipeRun({ db, recipeId: "fan-out", now: 1_000_000 }); + const row = db + .query<{ + recipe_id: string; + last_run_at: number; + run_count: number; + }>("SELECT recipe_id, last_run_at, run_count FROM recipe_recency") + .get(); + expect(row).toEqual({ + recipe_id: "fan-out", + last_run_at: 1_000_000, + run_count: 1, + }); + } finally { + closeDb(db); + } + }); + + it("increments run_count and updates last_run_at on subsequent calls", () => { + const db = openDb(); + try { + recordRecipeRun({ db, recipeId: "fan-out", now: 1_000_000 }); + recordRecipeRun({ db, recipeId: "fan-out", now: 2_000_000 }); + recordRecipeRun({ db, recipeId: "fan-out", now: 3_000_000 }); + const row = db + .query<{ last_run_at: number; run_count: number }>( + "SELECT last_run_at, run_count FROM recipe_recency WHERE recipe_id = 'fan-out'", + ) + .get(); + expect(row).toEqual({ last_run_at: 3_000_000, run_count: 3 }); + } finally { + closeDb(db); + } + }); + + it("tracks distinct recipes in separate rows", () => { + const db = openDb(); + try { + recordRecipeRun({ db, recipeId: "fan-out", now: 1_000_000 }); + recordRecipeRun({ db, recipeId: "fan-in", now: 1_500_000 }); + recordRecipeRun({ db, recipeId: "fan-out", now: 2_000_000 }); + const rows = db + .query<{ + recipe_id: string; + last_run_at: number; + run_count: number; + }>( + "SELECT recipe_id, last_run_at, run_count FROM recipe_recency ORDER BY recipe_id", + ) + .all(); + expect(rows).toEqual([ + { recipe_id: "fan-in", last_run_at: 1_500_000, run_count: 1 }, + { recipe_id: "fan-out", last_run_at: 2_000_000, run_count: 2 }, + ]); + } finally { + closeDb(db); + } + }); + + it("defaults `now` to Date.now() when omitted", () => { + const db = openDb(); + try { + const before = Date.now(); + recordRecipeRun({ db, recipeId: "fan-out" }); + const after = Date.now(); + const row = db + .query<{ last_run_at: number }>( + "SELECT last_run_at FROM recipe_recency WHERE recipe_id = 'fan-out'", + ) + .get(); + expect(row?.last_run_at).toBeGreaterThanOrEqual(before); + expect(row?.last_run_at).toBeLessThanOrEqual(after); + } finally { + closeDb(db); + } + }); +}); + +describe("pruneRecipeRecency", () => { + it("deletes rows whose last_run_at < cutoffMs", () => { + const db = openDb(); + try { + recordRecipeRun({ db, recipeId: "old-recipe", now: 1_000 }); + recordRecipeRun({ db, recipeId: "new-recipe", now: 9_999 }); + pruneRecipeRecency({ db, cutoffMs: 5_000 }); + const rows = db + .query<{ recipe_id: string }>( + "SELECT recipe_id FROM recipe_recency ORDER BY recipe_id", + ) + .all(); + expect(rows).toEqual([{ recipe_id: "new-recipe" }]); + } finally { + closeDb(db); + } + }); + + it("keeps rows where last_run_at == cutoffMs (strict <)", () => { + const db = openDb(); + try { + recordRecipeRun({ db, recipeId: "exactly-cutoff", now: 5_000 }); + pruneRecipeRecency({ db, cutoffMs: 5_000 }); + const rows = db + .query<{ recipe_id: string }>("SELECT recipe_id FROM recipe_recency") + .all(); + expect(rows).toEqual([{ recipe_id: "exactly-cutoff" }]); + } finally { + closeDb(db); + } + }); + + it("is a no-op on an empty table", () => { + const db = openDb(); + try { + pruneRecipeRecency({ db, cutoffMs: Date.now() }); + const rows = db + .query<{ n: number }>("SELECT COUNT(*) AS n FROM recipe_recency") + .all(); + expect(rows[0]?.n).toBe(0); + } finally { + closeDb(db); + } + }); +}); + +describe("loadRecipeRecency", () => { + it("returns an empty Map when no recipes have run", () => { + const db = openDb(); + try { + const map = loadRecipeRecency({ db }); + expect(map.size).toBe(0); + } finally { + closeDb(db, { readonly: true }); + } + }); + + it("returns rows keyed by recipe_id", () => { + const db = openDb(); + try { + const now = Date.now(); + recordRecipeRun({ db, recipeId: "fan-out", now }); + recordRecipeRun({ db, recipeId: "fan-out", now }); + recordRecipeRun({ db, recipeId: "fan-in", now }); + const map = loadRecipeRecency({ db, now }); + expect(map.size).toBe(2); + expect(map.get("fan-out")).toEqual({ last_run_at: now, run_count: 2 }); + expect(map.get("fan-in")).toEqual({ last_run_at: now, run_count: 1 }); + } finally { + closeDb(db); + } + }); + + it("prunes rows older than 90 days before returning (lazy prune per Q3)", () => { + const db = openDb(); + try { + const now = 100 * 24 * 60 * 60 * 1000; + const tooOld = now - RECENCY_WINDOW_MS - 1; + const justInside = now - RECENCY_WINDOW_MS + 1; + recordRecipeRun({ db, recipeId: "ancient", now: tooOld }); + recordRecipeRun({ db, recipeId: "still-fresh", now: justInside }); + const map = loadRecipeRecency({ db, now }); + expect(map.has("ancient")).toBe(false); + expect(map.has("still-fresh")).toBe(true); + const rows = db + .query<{ recipe_id: string }>("SELECT recipe_id FROM recipe_recency") + .all(); + expect(rows.map((r) => r.recipe_id)).toEqual(["still-fresh"]); + } finally { + closeDb(db); + } + }); +}); diff --git a/src/application/recipe-recency.ts b/src/application/recipe-recency.ts new file mode 100644 index 0000000..1724a4d --- /dev/null +++ b/src/application/recipe-recency.ts @@ -0,0 +1,102 @@ +import type { CodemapDatabase } from "../db"; + +/** + * One row of the `recipe_recency` table. The shape is intentionally minimal — + * `first_run_at` / `source` / `errored_run_count` were rejected for v1 per the + * Q1 resolution in `docs/plans/recipe-recency.md` (locked schema; additive + * promotion path if a real consumer asks). + */ +export interface RecipeRecencyRow { + recipe_id: string; + last_run_at: number; + run_count: number; +} + +/** + * 90-day rolling retention window. Plan L.3. Exposed for tests; production + * call sites should use the `cutoffMs` argument on `pruneRecipeRecency` so + * the boundary is testable without freezing time. + */ +export const RECENCY_WINDOW_MS = 90 * 24 * 60 * 60 * 1000; + +interface RecordRunOpts { + db: CodemapDatabase; + recipeId: string; + /** Override for tests; defaults to `Date.now()`. */ + now?: number; +} + +/** + * Write site for both transports (plan Q2 / L.2): `handleQueryRecipe` in + * `tool-handlers.ts` (covers MCP + HTTP) and `runQueryCmd` in `cmd-query.ts` + * (covers CLI). Pure upsert — Q3 locks pruning to the read path so the + * recipe-execution hot path stays cheap. + * + * Counts only successful runs (Q9): callers wrap this in a `try/catch` + * AFTER the recipe execution returns successfully, so any throw exits before + * we reach this site. The `try/catch` itself is for L.8 failure isolation — + * a recency-write failure (DB locked, disk full, schema drift) must NEVER + * block the recipe response. + */ +export function recordRecipeRun(opts: RecordRunOpts): void { + const { db, recipeId } = opts; + const now = opts.now ?? Date.now(); + db.run( + `INSERT INTO recipe_recency (recipe_id, last_run_at, run_count) + VALUES (?, ?, 1) + ON CONFLICT(recipe_id) DO UPDATE SET + last_run_at = excluded.last_run_at, + run_count = recipe_recency.run_count + 1`, + [recipeId, now], + ); +} + +interface PruneOpts { + db: CodemapDatabase; + /** Cutoff epoch ms — rows with `last_run_at < cutoffMs` get deleted. */ + cutoffMs: number; +} + +/** + * Lazy prune (Q3 resolution): called from `loadRecipeRecency` before its + * SELECT, NOT from `recordRecipeRun`. Keeps the write path a pure upsert + * and concentrates the staleness signal at read time, where consumers + * actually observe it. + */ +export function pruneRecipeRecency(opts: PruneOpts): void { + const { db, cutoffMs } = opts; + db.run("DELETE FROM recipe_recency WHERE last_run_at < ?", [cutoffMs]); +} + +interface LoadOpts { + db: CodemapDatabase; + /** Override for tests; defaults to `Date.now()`. */ + now?: number; +} + +/** + * Read path consumed by Slice 3 (`--recipes-json` inline join, MCP + * `codemap://recipes` resource, HTTP mirror). Returns a Map keyed by + * `recipe_id` so the catalog renderer can `map.get(entry.id) ?? null` + * for never-run recipes. Runs the lazy prune before the SELECT (Q3). + */ +export function loadRecipeRecency( + opts: LoadOpts, +): Map { + const { db } = opts; + const now = opts.now ?? Date.now(); + pruneRecipeRecency({ db, cutoffMs: now - RECENCY_WINDOW_MS }); + const rows = db + .query( + "SELECT recipe_id, last_run_at, run_count FROM recipe_recency", + ) + .all(); + const map = new Map(); + for (const row of rows) { + map.set(row.recipe_id, { + last_run_at: row.last_run_at, + run_count: row.run_count, + }); + } + return map; +} diff --git a/src/db.ts b/src/db.ts index 496f13b..e26814c 100644 --- a/src/db.ts +++ b/src/db.ts @@ -193,6 +193,21 @@ export function createTables(db: CodemapDatabase) { PRIMARY KEY (file_path, name, line_start) ) STRICT, WITHOUT ROWID; + -- User-data table: per-recipe last-run timestamp + run count for + -- agent-host ranking ("which recipes does this project actually use?"). + -- Joins inline into --recipes-json / codemap://recipes via loadRecipeRecency. + -- Like query_baselines / coverage, intentionally absent from dropAll() so + -- --full and SCHEMA_VERSION rebuilds preserve user-activity history. The + -- 90-day rolling window is enforced lazily by pruneRecipeRecency on read, + -- not on write — keeps the recipe-execution hot path a pure upsert. recipe_id + -- is loose (matches bundled or project recipe ids; no FK to a recipes table + -- because there isn't one). Plan: docs/plans/recipe-recency.md. + CREATE TABLE IF NOT EXISTS recipe_recency ( + recipe_id TEXT PRIMARY KEY, + last_run_at INTEGER NOT NULL, + run_count INTEGER NOT NULL DEFAULT 1 + ) STRICT, WITHOUT ROWID; + -- Config-derived: reconcileBoundaryRules clears and re-fills from -- .codemap/config boundaries on every index pass. Dropped on --full -- like the other index tables (unlike query_baselines / coverage which @@ -302,6 +317,11 @@ export function createIndexes(db: CodemapDatabase) { -- The (file_path, name) prefix also covers GROUP BY file_path scans -- used by the bundled files-by-coverage recipe (D2 + D13). CREATE INDEX IF NOT EXISTS idx_coverage_file_name ON coverage(file_path, name); + + -- Powers the lazy 90-day prune (DELETE WHERE last_run_at < cutoff) inside + -- loadRecipeRecency. Tiny table (one row per known recipe id) — index keeps + -- the prune predictable as project-recipe counts grow. + CREATE INDEX IF NOT EXISTS idx_recipe_recency_last_run ON recipe_recency(last_run_at); `); } From 6ed1c36b2394b5be95f4133cf4f55a72e925c493 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Wed, 6 May 2026 07:50:55 +0300 Subject: [PATCH 03/16] feat(recency): wire recordRecipeRun into both write sites (Slice 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hooks recipe-recency tracking into the two transports per Q2 / L.2 of the recipe-recency plan: - MCP + HTTP path: handleQueryRecipe in application/tool-handlers.ts records recency after both runFormattedQuery (success only — `result.ok`) and executeQuery (success only — !isEnginePayloadError) succeed. - CLI path: runQueryCmd in cli/cmd-query.ts records via a finally block that observes process.exitCode as the unified success signal. Every failure path (emitErrorMaybeJson, printQueryResult non-zero) sets exitCode=1 before its early return; the finally observes the verdict regardless of which branch fired. Q9: success only. Engine (application/recipe-recency.ts): - New tryRecordRecipeRun(recipeId, opts?) wrapper opens its own DB (executeQuery runs PRAGMA query_only=1 so can't double as writer), Q10-isolated try/catch swallows every error with stderr warning unless quiet. Optional `_openDb` injection seam for the failure-mode test. Tests (recipe-recency.test.ts, +3 cases, 874 pass total): - swallows openDb failures and emits stderr warning - respects quiet flag (no warning) - production path smoke (writes successfully when openDb works) Q12 boundary check codified — verified empty: SELECT DISTINCT file_path FROM imports WHERE source LIKE '%application/recipe-recency%' AND specifiers LIKE '%recordRecipeRun%' AND file_path NOT IN ('src/application/tool-handlers.ts', 'src/cli/cmd-query.ts', 'src/application/recipe-recency.test.ts') --- src/application/recipe-recency.test.ts | 61 ++++++++++++++++++++++++++ src/application/recipe-recency.ts | 38 ++++++++++++++++ src/application/tool-handlers.ts | 9 +++- src/cli/cmd-query.ts | 11 +++++ 4 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/application/recipe-recency.test.ts b/src/application/recipe-recency.test.ts index 9618fa0..ed8911a 100644 --- a/src/application/recipe-recency.test.ts +++ b/src/application/recipe-recency.test.ts @@ -11,6 +11,7 @@ import { pruneRecipeRecency, RECENCY_WINDOW_MS, recordRecipeRun, + tryRecordRecipeRun, } from "./recipe-recency"; let projectRoot: string; @@ -223,3 +224,63 @@ describe("loadRecipeRecency", () => { } }); }); + +describe("tryRecordRecipeRun — failure isolation (L.8 / Q10)", () => { + it("swallows openDb failures and emits a stderr warning", () => { + const warnings: string[] = []; + const origWarn = console.warn; + console.warn = (...args: unknown[]) => + warnings.push(args.map((a) => String(a)).join(" ")); + try { + expect(() => + tryRecordRecipeRun("any-recipe", { + _openDb: () => { + throw new Error("simulated openDb failure"); + }, + }), + ).not.toThrow(); + } finally { + console.warn = origWarn; + } + expect( + warnings.some( + (w) => + w.includes("[recency] write failed") && + w.includes("simulated openDb failure"), + ), + ).toBe(true); + }); + + it("respects quiet flag — no stderr warning emitted", () => { + const warnings: string[] = []; + const origWarn = console.warn; + console.warn = (...args: unknown[]) => + warnings.push(args.map((a) => String(a)).join(" ")); + try { + tryRecordRecipeRun("any-recipe", { + quiet: true, + _openDb: () => { + throw new Error("simulated failure"); + }, + }); + } finally { + console.warn = origWarn; + } + expect(warnings).toEqual([]); + }); + + it("writes successfully when openDb succeeds (smoke for the production path)", () => { + tryRecordRecipeRun("smoke-recipe"); + const db = openDb(); + try { + const row = db + .query<{ recipe_id: string; run_count: number }>( + "SELECT recipe_id, run_count FROM recipe_recency WHERE recipe_id = 'smoke-recipe'", + ) + .get(); + expect(row).toEqual({ recipe_id: "smoke-recipe", run_count: 1 }); + } finally { + closeDb(db, { readonly: true }); + } + }); +}); diff --git a/src/application/recipe-recency.ts b/src/application/recipe-recency.ts index 1724a4d..230452b 100644 --- a/src/application/recipe-recency.ts +++ b/src/application/recipe-recency.ts @@ -1,3 +1,4 @@ +import { closeDb, openDb } from "../db"; import type { CodemapDatabase } from "../db"; /** @@ -51,6 +52,43 @@ export function recordRecipeRun(opts: RecordRunOpts): void { ); } +/** + * Slice 2 wrapper for the two write sites (`handleQueryRecipe` + + * `runQueryCmd`). Opens its own DB connection because `executeQuery` runs + * with `PRAGMA query_only = 1` and can't double as the writer. Swallows + * every error (L.8 + Q10) — recency-write failures NEVER block the recipe + * response. Warning-on-stderr unless `quiet`. + * + * Caller responsibility: only call AFTER the recipe execution returns + * successfully (Q9 — count successful runs only). + * + * `_openDb` is a test seam — production callers omit it; the failure-mode + * test injects a thrower to confirm the swallow / warn path. + */ +export function tryRecordRecipeRun( + recipeId: string, + opts?: { quiet?: boolean; _openDb?: () => CodemapDatabase }, +): void { + let db: CodemapDatabase | undefined; + try { + db = (opts?._openDb ?? openDb)(); + recordRecipeRun({ db, recipeId }); + } catch (err) { + if (!opts?.quiet) { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[recency] write failed: ${msg}`); + } + } finally { + if (db !== undefined) { + try { + closeDb(db); + } catch { + // Already in error path; nothing useful to do. + } + } + } +} + interface PruneOpts { db: CodemapDatabase; /** Cutoff epoch ms — rows with `last_run_at < cutoffMs` get deleted. */ diff --git a/src/application/tool-handlers.ts b/src/application/tool-handlers.ts index b9cbf9d..13f4b27 100644 --- a/src/application/tool-handlers.ts +++ b/src/application/tool-handlers.ts @@ -55,6 +55,7 @@ import { } from "./query-recipes"; import { resolveRecipeParams } from "./recipe-params"; import type { RecipeParamValue, RecipeParamValues } from "./recipe-params"; +import { tryRecordRecipeRun } from "./recipe-recency"; import { runCodemapIndex } from "./run-index"; import { buildShowResult, @@ -281,7 +282,7 @@ export function handleQueryRecipe( ) { const incompat = formatToolIncompatibility(args.format, args); if (incompat !== undefined) return err(incompat); - return runFormattedQuery({ + const result = runFormattedQuery({ sql, recipeId: args.recipe, recipeActions, @@ -290,6 +291,11 @@ export function handleQueryRecipe( format: args.format, root, }); + // Record recency only on successful execution (Q9). Failure-isolated + // by tryRecordRecipeRun (L.8 + Q10) — recency-write errors never + // block the response. + if (result.ok) tryRecordRecipeRun(args.recipe); + return result; } const payload = executeQuery({ sql, @@ -301,6 +307,7 @@ export function handleQueryRecipe( root, }); if (isEnginePayloadError(payload)) return err(payload.error); + tryRecordRecipeRun(args.recipe); return ok(payload); } catch (e) { return err(e instanceof Error ? e.message : String(e), 500); diff --git a/src/cli/cmd-query.ts b/src/cli/cmd-query.ts index 45daf62..e96803e 100644 --- a/src/cli/cmd-query.ts +++ b/src/cli/cmd-query.ts @@ -29,6 +29,7 @@ import type { RecipeParamValue, RecipeParamValues, } from "../application/recipe-params"; +import { tryRecordRecipeRun } from "../application/recipe-recency"; import { closeDb, deleteQueryBaseline, @@ -871,6 +872,16 @@ export async function runQueryCmd(opts: { } catch (err) { const msg = err instanceof Error ? err.message : String(err); emitErrorMaybeJson(msg, structuredErrors); + } finally { + // Slice 2: record recipe recency on the CLI write site (Q2 / L.2). + // `process.exitCode` is the unified success signal — every failure path + // (`emitErrorMaybeJson`, `printQueryResult` non-zero, etc.) sets it to 1 + // before its early `return`, so this finally observes the verdict + // regardless of which branch fired. Q9: success only. L.8 / Q10: + // failure-isolated inside `tryRecordRecipeRun`. + if (opts.recipeId !== undefined && process.exitCode !== 1) { + tryRecordRecipeRun(opts.recipeId); + } } } From 8b8ea45fb772ccd4f43a449efd785b24a5e4d232 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Wed, 6 May 2026 08:04:10 +0300 Subject: [PATCH 04/16] feat(recency): inline last_run_at + run_count on --recipes-json (Slice 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces recipe recency on every catalog read site per Q5 Resolution (inline per-entry fields, not a separate top-level recency: map): - CLI \`bun src/index.ts query --recipes-json\` (cli/cmd-query.ts + cli/main.ts): each entry gains \`last_run_at: number | null\` and \`run_count: number\`. The verb still runs BEFORE bootstrapCodemap() (the catalog has historically been "no DB required"). To preserve zero-side-effect posture while still enriching when an indexed DB exists, printRecipesCatalogJson uses a path-based factory: - resolveRecencyDbPath({root, stateDir}) computes \`/index.db\` via the same precedence as application/state-dir's resolveStateDir (cliFlag > env CODEMAP_STATE_DIR > '.codemap'). - existsSync gate skips the open when the file's missing — enrichWithRecency catches the throw and falls back to null/0 across all entries; never-indexed projects get the catalog clean with no .codemap dir created. - MCP/HTTP \`codemap://recipes\` and \`codemap://recipes/{id}\` (application/resource-handlers.ts): the recipes / one-recipe caches were dropped — caching the JSON.stringify result alongside recency would freeze last_run_at at first-read forever per long-running \`codemap mcp\` / \`codemap serve\` lifetime. The underlying listQueryRecipeCatalog() / getQueryRecipeCatalogEntry() are themselves module-cached upstream in query-recipes.ts, so the extra cost is one DB-read + one JSON.stringify per call. Schema / skill stay cached (neither changes mid-session). _resetResourceCachesForTests still exists for the surviving caches. - Engine (application/recipe-recency.ts): new \`enrichWithRecency\` generic + \`RecipeRecencyFields\` interface + \`resolveRecencyDbPath\` helper. Failure-isolated like tryRecordRecipeRun (L.8) — open / read / close errors swallow to null/0 fallbacks. Test seam renamed to public \`openDb\` factory (production callers like cmd-query supply a path-based opener; tests stub a thrower). Tests (resource-handlers.test.ts, +4 cases — 878 pass total): - includes last_run_at + run_count on every entry - populates real recency for recipes seeded in the DB - live-reads every call (mutation between reads visible) - codemap://recipes/{id} returns enriched entry Verified empirically: - Indexed project: \`--recipes-json\` shows real run counts (fan-out=3 after 3 runs); never-run recipes show null/0. - Fresh /tmp project: \`--recipes-json\` emits the catalog with null/0 fallbacks and creates ZERO files (no .codemap dir, no index.db). - MCP/HTTP \`codemap://recipes\` reflects mutations immediately on the next read (cache-removal verified end-to-end). --- src/application/recipe-recency.ts | 82 +++++++++++++++++ src/application/resource-handlers.test.ts | 102 ++++++++++++++++++++++ src/application/resource-handlers.ts | 33 ++++--- src/cli/cmd-query.ts | 52 ++++++++++- src/cli/main.ts | 2 +- 5 files changed, 252 insertions(+), 19 deletions(-) diff --git a/src/application/recipe-recency.ts b/src/application/recipe-recency.ts index 230452b..56ff6e8 100644 --- a/src/application/recipe-recency.ts +++ b/src/application/recipe-recency.ts @@ -1,5 +1,8 @@ +import { isAbsolute, resolve } from "node:path"; + import { closeDb, openDb } from "../db"; import type { CodemapDatabase } from "../db"; +import { STATE_DIR_DEFAULT } from "./state-dir"; /** * One row of the `recipe_recency` table. The shape is intentionally minimal — @@ -138,3 +141,82 @@ export function loadRecipeRecency( } return map; } + +/** + * Inline-join shape for `--recipes-json` and the matching MCP resources + * (Q5 Resolution: per-entry inline fields, not a separate top-level + * `recency:` map). `last_run_at: null` / `run_count: 0` for recipes + * never executed; consumers filter with `select(.last_run_at != null)` + * or treat null as fresh. + */ +export interface RecipeRecencyFields { + last_run_at: number | null; + run_count: number; +} + +/** + * Resolve the absolute path to `/index.db` from the same + * inputs the CLI's `bootstrap-codemap` would use, WITHOUT mutating the + * global runtime singleton. Slice 3's `--recipes-json` flow runs before + * `initCodemap()` (the catalog has historically been "no DB required"), + * so the path-resolver lets us read recency without taking on the + * config / state-dir reconciliation side effects. + * + * Mirrors `application/state-dir.ts` `resolveStateDir` precedence: + * `cliFlag > env > default`. + */ +export function resolveRecencyDbPath(opts: { + root: string; + stateDir: string | undefined; +}): string { + const raw = + opts.stateDir ?? process.env.CODEMAP_STATE_DIR ?? STATE_DIR_DEFAULT; + const dir = isAbsolute(raw) ? raw : resolve(opts.root, raw); + return resolve(dir, "index.db"); +} + +/** + * Slice 3 read-side enricher. Opens its own DB to read recency live — + * NEVER cached on the MCP/HTTP transports because the catalog resource + * runs inside long-lived `codemap mcp` / `codemap serve` sessions; a + * cached snapshot would freeze recency at first-read forever per server + * lifetime. + * + * Failure-isolated like {@link tryRecordRecipeRun} — a DB-open failure + * returns the input entries enriched with `null` / `0` fallbacks, never + * throws. The optional `openDb` factory lets callers without an + * `initCodemap()` runtime (e.g. CLI `--recipes-json` before bootstrap) + * supply a path-based opener; the test seam reuses the same hook. + */ +export function enrichWithRecency( + entries: ReadonlyArray, + opts?: { openDb?: () => CodemapDatabase }, +): Array { + let map: Map | undefined; + let db: CodemapDatabase | undefined; + try { + db = (opts?.openDb ?? openDb)(); + map = loadRecipeRecency({ db }); + } catch { + // Same posture as tryRecordRecipeRun (L.8) — recency errors NEVER + // block the catalog response. Caller gets entries with null/0 + // fallbacks; the `--recipes-json` shape stays stable. + map = undefined; + } finally { + if (db !== undefined) { + try { + closeDb(db, { readonly: true }); + } catch { + // Already in error path; nothing useful to do. + } + } + } + return entries.map((entry) => { + const hit = map?.get(entry.id); + return { + ...entry, + last_run_at: hit?.last_run_at ?? null, + run_count: hit?.run_count ?? 0, + }; + }); +} diff --git a/src/application/resource-handlers.test.ts b/src/application/resource-handlers.test.ts index c46d989..4eb9d93 100644 --- a/src/application/resource-handlers.test.ts +++ b/src/application/resource-handlers.test.ts @@ -129,3 +129,105 @@ describe("listResources", () => { expect(uris).toContain("codemap://symbols/{name}"); }); }); + +describe("readResource — codemap://recipes (Slice 3 recency inline)", () => { + it("includes last_run_at + run_count fields on every entry", () => { + const r = readResource("codemap://recipes"); + expect(r).toBeDefined(); + const entries = JSON.parse(r!.text) as Array<{ + id: string; + last_run_at: number | null; + run_count: number; + }>; + expect(entries.length).toBeGreaterThan(0); + for (const entry of entries) { + expect("last_run_at" in entry).toBe(true); + expect("run_count" in entry).toBe(true); + expect(entry.run_count).toBe(0); + expect(entry.last_run_at).toBeNull(); + } + }); + + it("populates real recency for recipes that have been run", () => { + // Use a fresh timestamp — loadRecipeRecency lazily prunes anything + // older than 90 days (Q3 Resolution). Seed recipe_recency directly; + // bypasses the runtime singleton's bootstrap (already initialised + // by beforeEach). + const ts = Date.now(); + const db = openDb(); + try { + db.run( + "INSERT INTO recipe_recency (recipe_id, last_run_at, run_count) VALUES (?, ?, ?)", + ["fan-out", ts, 7], + ); + } finally { + closeDb(db); + } + + const r = readResource("codemap://recipes"); + const entries = JSON.parse(r!.text) as Array<{ + id: string; + last_run_at: number | null; + run_count: number; + }>; + const fanOut = entries.find((e) => e.id === "fan-out"); + expect(fanOut).toBeDefined(); + expect(fanOut!.last_run_at).toBe(ts); + expect(fanOut!.run_count).toBe(7); + // Untouched recipes still null/0. + const barrel = entries.find((e) => e.id === "barrel-files"); + expect(barrel?.last_run_at).toBeNull(); + expect(barrel?.run_count).toBe(0); + }); + + it("reads live every call (no stale cache between reads)", () => { + const r1 = readResource("codemap://recipes"); + const before = ( + JSON.parse(r1!.text) as Array<{ id: string; run_count: number }> + ).find((e) => e.id === "fan-in"); + expect(before?.run_count).toBe(0); + + const ts = Date.now(); + const db = openDb(); + try { + db.run( + "INSERT INTO recipe_recency (recipe_id, last_run_at, run_count) VALUES (?, ?, ?)", + ["fan-in", ts, 3], + ); + } finally { + closeDb(db); + } + + const r2 = readResource("codemap://recipes"); + const after = ( + JSON.parse(r2!.text) as Array<{ id: string; run_count: number }> + ).find((e) => e.id === "fan-in"); + expect(after?.run_count).toBe(3); + }); +}); + +describe("readResource — codemap://recipes/{id} (Slice 3 recency inline)", () => { + it("returns entry with last_run_at + run_count fields", () => { + const ts = Date.now(); + const db = openDb(); + try { + db.run( + "INSERT INTO recipe_recency (recipe_id, last_run_at, run_count) VALUES (?, ?, ?)", + ["fan-out", ts, 4], + ); + } finally { + closeDb(db); + } + + const r = readResource("codemap://recipes/fan-out"); + expect(r).toBeDefined(); + const entry = JSON.parse(r!.text) as { + id: string; + last_run_at: number | null; + run_count: number; + }; + expect(entry.id).toBe("fan-out"); + expect(entry.last_run_at).toBe(ts); + expect(entry.run_count).toBe(4); + }); +}); diff --git a/src/application/resource-handlers.ts b/src/application/resource-handlers.ts index 88ba037..7934538 100644 --- a/src/application/resource-handlers.ts +++ b/src/application/resource-handlers.ts @@ -17,6 +17,7 @@ import { getQueryRecipeCatalogEntry, listQueryRecipeCatalog, } from "./query-recipes"; +import { enrichWithRecency } from "./recipe-recency"; import { buildShowResult, findSymbolsByName } from "./show-engine"; export interface ResourcePayload { @@ -24,20 +25,25 @@ export interface ResourcePayload { text: string; } -let recipesCache: ResourcePayload | undefined; +// Slice 3 (recipe-recency): the recipes catalog used to lazy-cache its +// JSON.stringify result for the server-process lifetime. With recency +// fields injected inline (Q5 Resolution), a cached snapshot would freeze +// `last_run_at` / `run_count` at first-read forever per `codemap mcp` / +// `codemap serve` lifetime. The recipes / one-recipe caches were dropped +// — the underlying `listQueryRecipeCatalog()` / `getQueryRecipeCatalogEntry()` +// are themselves module-cached upstream (in `query-recipes.ts`), so the +// extra cost is one DB-read + one JSON.stringify per call. Schema / skill +// stay cached — neither changes mid-session. let schemaCache: ResourcePayload | undefined; let skillCache: ResourcePayload | undefined; -const oneRecipeCache = new Map(); /** * Test-only escape hatch — drops every cached payload so a temp-DB test * can re-read with fresh state. Production code never calls this. */ export function _resetResourceCachesForTests(): void { - recipesCache = undefined; schemaCache = undefined; skillCache = undefined; - oneRecipeCache.clear(); } /** @@ -110,25 +116,24 @@ export function listResources(): { uri: string; description: string }[] { } function readRecipesCatalog(): ResourcePayload { - if (recipesCache !== undefined) return recipesCache; - recipesCache = { + // Live read every call (no cache) so recency reflects the current + // session — see the cache-removal comment near the cache declarations. + return { mimeType: "application/json", - text: JSON.stringify(listQueryRecipeCatalog()), + text: JSON.stringify(enrichWithRecency(listQueryRecipeCatalog())), }; - return recipesCache; } function readOneRecipe(id: string): ResourcePayload | undefined { - const cached = oneRecipeCache.get(id); - if (cached !== undefined) return cached; const entry = getQueryRecipeCatalogEntry(id); if (entry === undefined) return undefined; - const payload: ResourcePayload = { + // `enrichWithRecency` operates on a list; wrap the single entry, + // pull its enriched form back out. + const [enriched] = enrichWithRecency([entry]); + return { mimeType: "application/json", - text: JSON.stringify(entry), + text: JSON.stringify(enriched), }; - oneRecipeCache.set(id, payload); - return payload; } function readSchema(): ResourcePayload { diff --git a/src/cli/cmd-query.ts b/src/cli/cmd-query.ts index e96803e..41aa752 100644 --- a/src/cli/cmd-query.ts +++ b/src/cli/cmd-query.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs"; + import { getCurrentCommit, printQueryResult, @@ -29,7 +31,11 @@ import type { RecipeParamValue, RecipeParamValues, } from "../application/recipe-params"; -import { tryRecordRecipeRun } from "../application/recipe-recency"; +import { + enrichWithRecency, + resolveRecencyDbPath, + tryRecordRecipeRun, +} from "../application/recipe-recency"; import { closeDb, deleteQueryBaseline, @@ -51,6 +57,7 @@ import { makePackageBucketizer, } from "../group-by"; import { getProjectRoot } from "../runtime"; +import { openCodemapDatabase } from "../sqlite-db"; import { bootstrapCodemap } from "./bootstrap-codemap"; /** @@ -602,9 +609,46 @@ function formatIncompatibility( return `codemap: --format ${fmt} cannot be combined with ${offenders.join(", ")} (different output shapes — formatted outputs only support flat row lists).`; } -/** Print the bundled recipe catalog as JSON to stdout (no DB access). */ -export function printRecipesCatalogJson(): void { - console.log(JSON.stringify(listQueryRecipeCatalog(), null, 2)); +/** + * Print the bundled recipe catalog as JSON to stdout. + * + * Each entry gains `last_run_at: number | null` and `run_count: number` + * (Slice 3, Q5 Resolution — inline per-entry fields, not a separate + * `recency:` map). Live read every call (CLI is one-shot). + * + * **No bootstrap, no side effects.** This verb runs before + * `bootstrapCodemap()` in `cli/main.ts` (the catalog has historically been + * "no DB required" so agents can discover recipes pre-index). To preserve + * that posture while still surfacing recency when an indexed DB exists, + * the path-based `openCodemapDatabase()` factory is used directly — + * `initCodemap()` is NOT called. If the resolved DB path doesn't exist + * (never-indexed project), enrichment falls through to `null` / `0` + * fallbacks; if it exists, real recency lands inline. + */ +export function printRecipesCatalogJson(opts?: { + root?: string; + stateDir?: string | undefined; +}): void { + const root = opts?.root; + const dbFactory = + root === undefined + ? undefined + : () => { + const dbPath = resolveRecencyDbPath({ + root, + stateDir: opts?.stateDir, + }); + if (!existsSync(dbPath)) { + // Never-indexed project — let enrichWithRecency catch the + // throw and fall back to null/0 entries. + throw new Error(`recipe-recency: no DB at ${dbPath}`); + } + return openCodemapDatabase(dbPath); + }; + const enriched = enrichWithRecency(listQueryRecipeCatalog(), { + openDb: dbFactory, + }); + console.log(JSON.stringify(enriched, null, 2)); } /** Print one recipe's SQL to stdout, or false if the id is unknown (caller should exit 1). */ diff --git a/src/cli/main.ts b/src/cli/main.ts index cdd08ca..329ba02 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -367,7 +367,7 @@ Copies bundled agent templates into .agents/ under the project root. process.exit(1); } if (parsed.kind === "recipesCatalog") { - printRecipesCatalogJson(); + printRecipesCatalogJson({ root, stateDir }); return; } if (parsed.kind === "printRecipeSql") { From d13e17050df70d551ccc099402a039e89c06fb67 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Wed, 6 May 2026 08:09:22 +0300 Subject: [PATCH 05/16] =?UTF-8?q?feat(recency):=20opt-out=20config=20(reci?= =?UTF-8?q?pe=5Frecency:=20false)=20=E2=80=94=20Slice=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `.codemap/config` `recipe_recency: boolean` field per L.4 of the recipe-recency plan. Default ON (opt-out, not opt-in). Schema (src/config.ts): - New optional Zod field `recipe_recency` on codemapUserConfigSchema. - ResolvedCodemapConfig gains a required `recipeRecency: boolean`. - resolveCodemapConfig defaults to true; only `recipe_recency: false` flips it off (`parsed?.recipe_recency !== false`). Runtime (src/runtime.ts): - New getRecipeRecencyEnabled() helper, parallel to getFts5Enabled(). Engine (src/application/recipe-recency.ts): - tryRecordRecipeRun short-circuits BEFORE openDb when the toggle is off — no DB connection, no upsert, no rows ever land. Cleanest opt-out shape per Q11 (not "ignore the data after writing it"). - The toggle-read itself is wrapped in try/catch because getRecipeRecencyEnabled() throws when the runtime singleton isn't initialised (e.g. CLI smoke paths before bootstrapCodemap). On throw, fall through to the openDb path; the outer try/catch swallows any follow-on failure. L.8 contract holds either way. Tests (recipe-recency.test.ts, +2 cases — 17 local, 880 project-wide): - short-circuits the upsert when recipe_recency: false (verified the injected openDb thrower never fired + table stayed empty) - writes normally when recipe_recency: true (default) Verified empirically against /tmp fixture project: - recipe_recency: false → recipe runs cleanly, table stays at 0 rows - recipe_recency: true (or omitted) → recipe runs cleanly, table populates with run_count=1 --- src/application/recipe-recency.test.ts | 51 ++++++++++++++++++++++++++ src/application/recipe-recency.ts | 18 +++++++++ src/config.ts | 18 +++++++++ src/runtime.ts | 4 ++ 4 files changed, 91 insertions(+) diff --git a/src/application/recipe-recency.test.ts b/src/application/recipe-recency.test.ts index ed8911a..2e13035 100644 --- a/src/application/recipe-recency.test.ts +++ b/src/application/recipe-recency.test.ts @@ -284,3 +284,54 @@ describe("tryRecordRecipeRun — failure isolation (L.8 / Q10)", () => { } }); }); + +describe("tryRecordRecipeRun — Slice 4 opt-out (recipe_recency: false)", () => { + it("short-circuits the upsert when recipe_recency: false", () => { + // Re-init runtime with opt-out config — overrides the beforeEach + // default (recipeRecency: true) for this test only. + initCodemap(resolveCodemapConfig(projectRoot, { recipe_recency: false })); + + // Inject a thrower as openDb factory; if the short-circuit works, + // it should NEVER fire (we exit before openDb). + let openDbCalled = false; + tryRecordRecipeRun("opt-out-recipe", { + _openDb: () => { + openDbCalled = true; + throw new Error("openDb should not be called when opt-out"); + }, + }); + expect(openDbCalled).toBe(false); + + // Re-init with default (true) so afterEach cleanup works. + initCodemap(resolveCodemapConfig(projectRoot, undefined)); + + // Verify table is empty — no row was written. + const db = openDb(); + try { + const rows = db + .query<{ n: number }>("SELECT COUNT(*) AS n FROM recipe_recency") + .all(); + expect(rows[0]?.n).toBe(0); + } finally { + closeDb(db, { readonly: true }); + } + }); + + it("writes normally when recipe_recency: true (default)", () => { + initCodemap(resolveCodemapConfig(projectRoot, { recipe_recency: true })); + tryRecordRecipeRun("explicit-on-recipe"); + initCodemap(resolveCodemapConfig(projectRoot, undefined)); + + const db = openDb(); + try { + const row = db + .query<{ recipe_id: string; run_count: number }>( + "SELECT recipe_id, run_count FROM recipe_recency WHERE recipe_id = 'explicit-on-recipe'", + ) + .get(); + expect(row).toEqual({ recipe_id: "explicit-on-recipe", run_count: 1 }); + } finally { + closeDb(db, { readonly: true }); + } + }); +}); diff --git a/src/application/recipe-recency.ts b/src/application/recipe-recency.ts index 56ff6e8..7427d7c 100644 --- a/src/application/recipe-recency.ts +++ b/src/application/recipe-recency.ts @@ -2,6 +2,7 @@ import { isAbsolute, resolve } from "node:path"; import { closeDb, openDb } from "../db"; import type { CodemapDatabase } from "../db"; +import { getRecipeRecencyEnabled } from "../runtime"; import { STATE_DIR_DEFAULT } from "./state-dir"; /** @@ -65,6 +66,13 @@ export function recordRecipeRun(opts: RecordRunOpts): void { * Caller responsibility: only call AFTER the recipe execution returns * successfully (Q9 — count successful runs only). * + * Slice 4 short-circuit: when `.codemap/config` `recipe_recency: false`, + * skip the openDb/upsert entirely so no rows ever land. Cleanest opt-out + * — not "ignore the data after writing it." `getRecipeRecencyEnabled()` + * itself throws when codemap isn't initialised (e.g. CLI smoke paths + * before `bootstrapCodemap()`); the outer try/catch swallows that the + * same way as a real DB failure, so the L.8 contract holds either way. + * * `_openDb` is a test seam — production callers omit it; the failure-mode * test injects a thrower to confirm the swallow / warn path. */ @@ -72,6 +80,16 @@ export function tryRecordRecipeRun( recipeId: string, opts?: { quiet?: boolean; _openDb?: () => CodemapDatabase }, ): void { + // Short-circuit before any DB interaction when disabled. Wrapped in + // try/catch because getRecipeRecencyEnabled() throws when the runtime + // singleton isn't initialised (the wrapper still catches it below; + // we just want to bail BEFORE openDb when we can read the toggle). + try { + if (!getRecipeRecencyEnabled()) return; + } catch { + // Runtime not initialised — fall through to the openDb path; if + // openDb also fails, the outer try/catch swallows. + } let db: CodemapDatabase | undefined; try { db = (opts?._openDb ?? openDb)(); diff --git a/src/config.ts b/src/config.ts index 004bb81..287d2bc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -90,6 +90,12 @@ export const codemapUserConfigSchema = z .describe( "Enable FTS5 full-text indexing of file content into the `source_fts` virtual table. Default `false` — FTS5 grows `.codemap/index.db` ~30–50% on text-heavy projects. Override at the CLI with `--with-fts` (CLI wins; logs a stderr line on override).", ), + recipe_recency: z + .boolean() + .optional() + .describe( + "Track per-recipe `last_run_at` + `run_count` in the `recipe_recency` table; surfaces inline on `--recipes-json` for agent-host ranking. Default `true` (opt-out). Set `false` to short-circuit every write — no rows ever land. Local-only — no upload primitive. Plan: docs/plans/recipe-recency.md (Q5 + L.4).", + ), boundaries: z .array( z @@ -172,6 +178,14 @@ export interface ResolvedCodemapConfig { * flag; CLI wins. See `docs/plans/fts5-mermaid.md`. */ readonly fts5: boolean; + /** + * Recipe-recency tracking toggle. `true` (default) populates the + * `recipe_recency` table on every successful recipe run and inlines + * `last_run_at` / `run_count` on `--recipes-json` reads. `false` + * short-circuits every write — no rows ever land. Local-only — no + * upload primitive. See `docs/plans/recipe-recency.md` (L.4). + */ + readonly recipeRecency: boolean; /** * Reconciled into the `boundary_rules` table on every index pass. The * bundled `boundary-violations` recipe joins this against `dependencies` @@ -282,6 +296,9 @@ export function resolveCodemapConfig( action: rule.action ?? "deny", })); + // Default ON per L.4 (opt-out, not opt-in). Only `false` disables. + const recipeRecency = parsed?.recipe_recency !== false; + return { root: absRoot, stateDir, @@ -291,6 +308,7 @@ export function resolveCodemapConfig( tsconfigPath, fts5, boundaries, + recipeRecency, }; } diff --git a/src/runtime.ts b/src/runtime.ts index 5e50b6a..66dbcf2 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -47,6 +47,10 @@ export function getFts5Enabled(): boolean { return getCodemapConfig().fts5; } +export function getRecipeRecencyEnabled(): boolean { + return getCodemapConfig().recipeRecency; +} + export function getBoundaryRules(): ResolvedCodemapConfig["boundaries"] { return getCodemapConfig().boundaries; } From 448e94f614ad9aa65ef884c866d380a4f4353c94 Mon Sep 17 00:00:00 2001 From: Sutu Sebastian Date: Wed, 6 May 2026 08:15:31 +0300 Subject: [PATCH 06/16] docs(recency): lift to architecture.md + glossary.md, retire plan (Slice 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final slice of the recipe-recency plan. Lifts durable design into the permanent docs homes per docs/README.md Rule 2 + docs-governance "delete + lift, no tombstones" discipline; deletes the plan file per Rule 3. docs/architecture.md § Schema: - New \`recipe_recency\` table description sibling to query_baselines / coverage. Documents the user-data lifecycle (intentionally absent from dropAll), the two write sites (handleQueryRecipe + runQueryCmd via shared recordRecipeRun helper), the 90-day lazy-prune contract, the opt-out config, and the boundary discipline. docs/glossary.md: - New "recipe_recency (table) / recipe recency / recipe_recency: false" entry between "recipe shadows" and "research". docs/roadmap.md § Backlog: - Recipe-recency entry removed (item shipped). Bundled agent surface (templates/agents/) + dev-side mirror (.agents/), in lockstep per docs/README.md Rule 10: - Both rule files: --recipes-json row mentions the new last_run_at + run_count fields, the jq sort idiom, and the opt-out config. - Both skill files: --recipes-json description updated; codemap://recipes + codemap://recipes/{id} resource descriptions add the recency fields and note the cache-removal ("live-read every call"). Source comments slimmed: - Plan-file references (now-dead pointers in src/config.ts, src/db.ts, src/application/recipe-recency.ts) repointed at the durable architecture.md home. - One backtick-in-SQL gotcha hit + fixed per .agents/lessons.md. Plan file: - docs/plans/recipe-recency.md DELETED. Recipe-recency lives at: - docs/architecture.md § recipe_recency (schema + lifecycle) - docs/glossary.md (term entry) - templates/agents/{rules,skills}/codemap (consumer agent surface) - .agents/{rules,skills}/codemap (dev-side mirror) Verified: rg "plans/recipe-recency|recipe-recency.md" returns 0 hits; 880 tests + 26 goldens green. --- .agents/rules/codemap.md | 2 +- .agents/skills/codemap/SKILL.md | 8 +- docs/architecture.md | 16 + docs/glossary.md | 4 + docs/plans/recipe-recency.md | 356 ----------------------- docs/roadmap.md | 1 - src/application/recipe-recency.ts | 14 +- src/config.ts | 4 +- src/db.ts | 2 +- templates/agents/rules/codemap.md | 2 +- templates/agents/skills/codemap/SKILL.md | 8 +- 11 files changed, 40 insertions(+), 377 deletions(-) delete mode 100644 docs/plans/recipe-recency.md diff --git a/.agents/rules/codemap.md b/.agents/rules/codemap.md index 7a29e83..d24da2b 100644 --- a/.agents/rules/codemap.md +++ b/.agents/rules/codemap.md @@ -23,7 +23,7 @@ A local database (default **`.codemap/index.db`**) indexes structure: symbols, i | Parametrised recipe | — | `bun src/index.ts query --json --recipe find-symbol-by-kind --params kind=function,name_pattern=%Query%` — params declared in recipe `.md` frontmatter and validated before SQL binding. | | Boundary violations | — | `bun src/index.ts query --json --recipe boundary-violations` — joins `dependencies` × `boundary_rules` (config-driven) via SQLite `GLOB`. `.codemap/config.ts` `boundaries: [{name, from_glob, to_glob, action?}]`; default `action: "deny"`. SARIF / annotations work via the `file_path` alias. | | Rename preview | — | `bun src/index.ts query --recipe rename-preview --params old=usePermissions,new=useAccess,kind=function --format diff` — read-only unified diff; codemap never writes files. | -| Recipe catalog / SQL | — | `bun src/index.ts query --recipes-json` · `bun src/index.ts query --print-sql fan-out` | +| Recipe catalog / SQL | — | `bun src/index.ts query --recipes-json` (every entry includes `last_run_at: number \| null` + `run_count: number` recency fields — Slice 3 of recipe-recency plan; rank with `jq 'sort_by(.last_run_at // 0) \| reverse'`; opt-out via `.codemap/config` `recipe_recency: false`) · `bun src/index.ts query --print-sql fan-out` | | Counts only | — | `bun src/index.ts query --json --summary -r deprecated-symbols` | | PR-scoped rows | — | `bun src/index.ts query --json --changed-since origin/main -r fan-out` | | Bucket by owner / dir / pkg | — | `bun src/index.ts query --json --group-by directory -r fan-in` | diff --git a/.agents/skills/codemap/SKILL.md b/.agents/skills/codemap/SKILL.md index 21ef321..ee0a99d 100644 --- a/.agents/skills/codemap/SKILL.md +++ b/.agents/skills/codemap/SKILL.md @@ -38,7 +38,7 @@ Replace placeholders (`'...'`) with your module path, file glob, or symbol name. **Suppressions (opt-in):** `// codemap-ignore-next-line ` and `// codemap-ignore-file ` (also `#`, `--`, `