# Insights Non-obvious technical lessons about this codebase, its dependencies, and the development loop. Append new entries under **## Log** as `date · area · lesson`. This log is how lessons compound across iterations. ## Log - 2026-06-16 · typescript-analyzer · Cross-file call resolution falls out for free *if* every file in an index pass shares one `ts.Program`. `getSymbolAtLocation(call.expression)` plus `getAliasedSymbol` follows imports and re-export chains to the original declaration; the only missing piece is a `Map` from declaration node → graph id so a resolved symbol can be turned back into an edge target. Symbols resolving outside the analyzed set (library code) simply have no entry and are skipped — the graph never asserts an edge it can't back. - 2026-06-16 · graph · Deriving a symbol id from `(file, qualifiedName)` rather than byte offset makes ids survive edits: moving a function within its file keeps every edge that points at it valid. The `range` lives on the node for snippet extraction but is deliberately excluded from the id. - 2026-06-16 · mcp · The `@modelcontextprotocol/sdk` high-level `McpServer.registerTool(name, { inputSchema }, cb)` takes `inputSchema` as a Zod *raw shape* (`{ path: z.string() }`), not a `z.object(...)`. Tool results are `{ content: [{ type: "text", text }] }`; the `"text"` literal needs `as const` outside contextual position. `InMemoryTransport.createLinkedPair()` makes the whole server unit-testable against a real `Client` with no child process. - 2026-06-16 · tooling · `biome format --write` does NOT apply the `organizeImports` assist — `biome check` still fails on unsorted imports. Use `biome check --write` to apply import sorting and other safe fixes. RTK compacts tool output; use `rtk proxy ` to see a tool's raw diagnostics. - 2026-06-16 · store · Node 24's built-in `node:sqlite` (`DatabaseSync`) ships **FTS5**, so persistence + full-text symbol search needs zero native dependencies. The implicit `rowid` gives free insertion-order, letting the SQLite store match the in-memory store byte-for-byte under one shared `runStoreContract` — the cleanest way to guarantee two backends stay at parity. - 2026-06-16 · store · `PRAGMA journal_mode=WAL` is a silent no-op on `:memory:` (returns `memory`), but on a file db it creates a `-wal` sidecar that survives until checkpoint — an observable side effect you can assert in a test without exposing the connection internals. - 2026-06-16 · tooling · Biome's `noUnusedTemplateLiteral` flags backtick strings with no `${}` (e.g. single-line SQL), but correctly exempts genuinely multi-line ones. It's an *unsafe* fix (`biome check --write --unsafe`) because converting quotes can change escaping — safe here since the SQL had no quotes/backslashes; the test suite is the real proof nothing broke. - 2026-06-16 · typescript-analyzer · `Implements` edges (ama-hft.3) drop out of a second checker pass that mirrors call resolution: recurse for `ClassDeclaration`s, take `heritageClauses` whose `clause.token === ts.SyntaxKind.ImplementsKeyword`, and resolve each `type.expression` via `getSymbolAtLocation` + `getAliasedSymbol` (same alias-following as calls). Key difference from `resolveCallee`: use `symbol.declarations?.[0]`, **not** `valueDeclaration` — interfaces are type-only and have no value declaration. Resolution must run *after* `declToId` is fully built, since a class can implement an interface declared later or in another file (that's why it's a post-walk pass, not part of `visit`). Self-index (`src/`): Implements edges **0 → 3** (`TypeScriptAnalyzer→Analyzer`, `InMemoryStore→Store`, `SqliteStore→Store`), edges 140 → 143, nodes unchanged at 100; whole repo emits 6 (the other 3 are the new test fixture). Non-obvious gap surfaced while dogfooding: the store already persists these edges (`edgesFrom`/`edgesTo` take an `EdgeKind`), but `QueryService.findCallers/findCallees` hard-code `"Calls"` and no MCP tool reads `Implements`, so the new edges are **write-only** until a query surface exists (filed ama-gtw; interface method dispatch — the other half of hft.3's title — split to ama-j0z). - 2026-06-16 · typescript-analyzer · `Inherits` edges (ama-hft.2) were a one-token change to the `Implements` walk: `collectHeritage` already iterated every `ClassDeclaration`'s `heritageClauses`, but `if (clause.token !== ImplementsKeyword) continue` *discarded* the `extends` clause. The fix is to stop skipping and pick the kind by token — `clause.token === ts.SyntaxKind.ExtendsKeyword ? "Inherits" : "Implements"` — reusing the same kind-agnostic `resolveHeritage` helper (it already alias-follows and uses `declarations?.[0]`, so it resolves base classes and interfaces identically). On a class the only two clause tokens possible are `extends`/`implements`, so the ternary is exhaustive. Verification subtlety worth its own note: **the self-index count is *expected* to stay flat** — `grep "class .* extends " src/` finds **zero** matches (Ama uses composition, not class inheritance internally), so this feature adds no `Inherits` edges over Ama's own graph. The dedicated `ts-inherits` fixture (a class that both `extends` and `implements`, asserting the two edge kinds don't get conflated) is the real coverage, not self-index deltas. Live `index_repository(".")` moved 145→155 nodes / 196→205 edges / 32→34 files, but that delta is entirely the two **new test files** I added (Ama indexes `tests/` too) running through the *old* `dist/` — the fixture's own `extends` clauses won't surface as `Inherits` until a rebuild + CC restart, at which point exactly 2 more edges (`Dog→Animal`, `ServiceDog→Dog`) appear. - 2026-06-16 · query/mcp · Closing ama-gtw (make the write-only `Implements` edges queryable) needed **zero store change** — `edgesFrom`/`edgesTo` already take an optional `EdgeKind` filter; the whole feature was a query method + MCP tool per direction. The non-obvious part is that **edge direction picks which existing query to mirror**: an `Implements` edge points class→interface, so `findImplementations(interface)` ("who implements X?") is a *reverse*-edge lookup mirroring `findCallers` (`edgesTo(id, "Implements")`, collect `from`), while `findInterfaces(class)` ("what does X implement?") is a *forward* lookup mirroring `findCallees` (`edgesFrom(id, "Implements")`, collect `to`). Mixing these up returns empty silently, so the fixture deliberately has both a multi-implementer interface (`Greeter`) and a multi-interface class (`Person`). Live whole-repo re-index (`index_repository(".")`, 32 files): nodes **141 → 145** (exactly the 4 new methods — `findImplementations`/`findInterfaces` in both `QueryService` and `AmaSession`), edges **182 → 196**. Note the scope mismatch with the entry above: the self-index *gate* measures `src/` only (~100 nodes), whereas `index_repository(".")` counts the whole repo — same code, different denominator. - 2026-06-16 · typescript-analyzer · `Imports`/re-export edges (ama-hft.1) followed the now-familiar post-walk pattern, with two non-obvious twists. (1) The `"Imports"` kind was *already* in the `EdgeKind` union — the vocabulary shipped ahead of the emitter, so this was purely a `collectImports` pass + a `resolveImport` helper, no graph-model change. (2) Resolution mirrors `resolveCallee` (use `valueDeclaration ?? declarations?.[0]` — imported values have a value declaration) and leans on `getAliasedSymbol` following the **full** alias chain, so *both* halves of the task fall out of one helper: a direct `import { greet } from "./barrel.js"` resolves transitively through the barrel to greet's real home in `lib.ts`, identical to how an `export { greet } from "./lib.js"` re-export resolves. The one trap: `export { x }` **without** a `moduleSpecifier` is a *local* export (x is defined in this file, already has a `Defines` edge), not a re-export — gate on `stmt.moduleSpecifier` or you emit bogus self-import edges. Edge semantics: `from` = the File node (file→symbol, like `Defines`), `to` = the imported symbol's original declaration. Imports are top-level-only in ESM, so the pass iterates `sf.statements` rather than recursing. **Differential self-index measurement** (the rigorous way to isolate a feature from test files you also added): same 38-file fileset, old analyzer via the still-stale live MCP = 165 nodes / 218 edges / **0** imports; rebuilt analyzer = 165 nodes / 302 edges / **84** imports. Node count unchanged (edges-only change), +84 edges *all* `Imports`, zero regression on other edge kinds. Captured gaps filed `discovered-from:ama-hft.1`: namespace imports (`import * as ns`) and `export * from` are skipped (they alias to the *module*, not a single decl, and the `SourceFile` isn't in `declToId`); and — exactly like `Implements` before ama-gtw — these `Imports` edges are **write-only** until a `QueryService`/MCP surface reads them. - 2026-06-16 · typescript-analyzer · Namespace imports + star re-exports (ama-zh0) closed the gap ama-hft.1 captured. The three missing forms — `import * as ns`, `export * from`, `export * as ns` — all alias to the **module symbol**, whose declaration is the `SourceFile` AST node. ama-hft.1 already had the machinery (`resolveImport` follows aliases via `getAliasedSymbol`, then `valueDeclaration ?? declarations?.[0]`); the *only* reason they were silently dropped is that `walkFile` never registered the `SourceFile` in `declToId`, so resolution landed on an unmapped node. Fix = **one line** (`declToId.set(sf, id)` in `walkFile`) + three extra `link()` branches in `collectImports`. Non-obvious wins: (1) `export * from` has no named clause to resolve, so you `link(stmt.moduleSpecifier)` — `getSymbolAtLocation` on a module-specifier string literal returns the module symbol directly, which is *not* an alias, so `resolveImport`'s non-alias path (`declarations[0]` = SourceFile) handles it with zero new code. (2) These edges are `File → File` (importer file → imported module's File node), unlike the `File → symbol` edges from named imports — `find_imports("main.ts")` now legitimately includes `lib.ts` among `Widget`/`greet`/`makeDefault`, which cascaded into the `query` and `mcp` layers' assertions (the `ts-imports` fixture is shared by three test files — change it once, fix three layers). (3) Registering the SourceFile in `declToId` is shared by `collectCalls`/`collectHeritage`, so I verified it leaked **zero** spurious Call/Inherits edges to File nodes (enumerated `store.edges`: only the 3 intended `Imports→File`). External namespace imports (`import * as ts from "typescript"`) resolve to an *unregistered* lib `SourceFile` → undefined → correctly no edge, so the graph never asserts an edge into code it didn't analyze. **Differential self-index** (two fresh builds with a `git stash` between — the only honest way, since the live MCP runs stale `dist/`): committed HEAD = 169 nodes / 316 edges / 38 files; after = 170 / 319 / 39. The +1 file/node is the new `ns-barrel.ts` fixture; the +3 edges are exactly the three new forms. Ama's own `src/` uses named re-exports (`export { x } from`), **not** `export *`, so this feature adds no edges over Ama's own graph — the fixtures are the real coverage. Trap that cost time: an early fixture had `lib.greet()` to "use" the namespace import, which added a *duplicate* `useThem→greet` Calls edge (phantom +1) — the analyzer emits the import edge from the AST regardless of usage, so the call was unnecessary noise. That duplicate surfaced an out-of-scope bug: `InMemoryStore.addEdge` does **not** dedupe on `(from,to,kind)`, so identical edges accumulate (filed ama-cs4, `discovered-from:ama-zh0`). - 2026-06-16 · query/mcp · Closing ama-5ex (surface the write-only `Imports` edges from ama-hft.1) reused the exact ama-gtw playbook — **zero store change**, just a query method + MCP tool per direction — but with one twist the `Implements` case did not have: the two endpoints of an `Imports` edge are **different node kinds**. An `Imports` edge is `File → symbol`, so `findImporters(symbol)` is the *reverse* lookup (`edgesTo(id, "Imports")`, collect `from`) and returns **File** nodes, while `findImports(file)` is the *forward* lookup (`edgesFrom(id, "Imports")`, collect `to`) and returns **symbol** nodes. Contrast `Implements`/`Calls`, where both ends are symbols. Two consequences: (1) tests assert on `.name`, not `.qualifiedName`, because File nodes have an empty `qualifiedName` (`["barrel.ts","main.ts"]` for importers, `["Widget","greet","makeDefault"]` for imports — note ASCII-uppercase `W` sorts before lowercase). (2) The MCP tool params are deliberately *asymmetric* — `find_importers` takes `symbol`, `find_imports` takes `file` — to stay self-documenting. No special-casing was needed for the file ref: `QueryService.resolve()` already finds a File node either by its id (the repo-relative path) or by `nodesByName` (the File node's `name` is `path.basename`), so `findImports("main.ts")` just works, mirroring how `findImplementations("Greeter")` resolves a bare name. Self-index moved **165 → 169 nodes** (exactly the 4 new methods: `findImporters`/`findImports` in both `QueryService` and `AmaSession`), **302 → 316 edges**, 38 files unchanged — query-only features add no graph data of their own; the delta is purely Ama indexing its own new source. The live re-index ran the still-stale `dist/` but re-parses source fresh, so it sees the new methods; the new `find_importers`/`find_imports` **tools** need a rebuild + CC restart to appear over MCP (the suite already verifies them in-process via `InMemoryTransport`). - 2026-06-16 · typescript-analyzer · `UsesType` edges (ama-hft.4 — param/return/property type usages) followed the now-standard post-walk pattern (the `"UsesType"` kind was *already* in the `EdgeKind` union — vocabulary ahead of emitter again, like `Imports`), but the genuinely non-obvious win is that **the `from` attribution costs nothing**. `collectTypeUsages` recurses passing `childId ?? enclosing`, so by the time it stands *on* a function/method node the `enclosingId` *already equals that node's own id*. That single fact unifies three cases under one rule — "attribute to the nearest enclosing emitted symbol": a **return** annotation (lexically on the node itself) → the function/method; a **parameter** (a child of the node) → the same function/method; a **property** (a member of a class whose own node doesn't exist yet — properties aren't nodes until hft.8) → the enclosing **class/interface**. No per-case `from` logic, mirroring how `collectCalls` attributes calls to the nearest enclosing symbol. Three more subtleties: (1) annotations are walked recursively (`typeReferencesIn` collects *every* `ts.TypeReferenceNode`, including nested ones), so `Widget[]` and `Map` both link to `Widget` — and a library outer like `Map` simply resolves to no node and is skipped, leaving only the in-set `Widget`. (2) `resolveTypeRef` prefers `symbol.declarations?.[0]` over `valueDeclaration` (the *reverse* of `resolveCallee`/`resolveImport`) because types are usually type-only — but a `class` target *also* resolves correctly since a class symbol still has `declarations[0]`. (3) Primitive keyword types (`number`, `string`) aren't `TypeReferenceNode`s at all, so they're excluded for free — the negative-fixture function `plain(count: number): string` emits zero edges. **Differential self-index** (fresh `dist/` via `AmaSession`, vs the still-stale live MCP at 319 same-fileset edges): whole repo `index_repository(".")` old analyzer 170 nodes / 301 edges / 39 files → new analyzer **183 / 412 / 41**; isolating edges on the identical 41-file set, **319 → 412 = +93 deduped `UsesType` edges**; over `src/` alone the analyzer emits **96 raw `UsesType`** (0 before), nodes flat at 113 (edges-only change — the expected signature). Unlike `Inherits`/`export *`, this feature *does* light up Ama's own graph heavily (every typed signature — `(node: GraphNode)`, `(checker: ts.TypeChecker)` where only the in-set `GraphNode` links), so the self-index delta is real coverage, not just the fixture. As with `Imports`/`Implements` pre-query, these edges are **write-only** until a query surface reads them — filed ama-lp3 (`find_type_users`/`find_types_used`, `discovered-from:ama-hft.4`). - 2026-06-16 · indexer/analyzer · Single-file re-index (ama-gd5.1) hinges on one analyzer fact: **node ids are location-derived** (`symbolId({file, qualifiedName})`), so an id can be computed *without walking the file it lives in*. That unlocks the hard part — when you re-analyze only changed file B, `ts.createProgram([B])` still loads B's imports (A) from disk so the checker resolves `B→A` references, but `declToId` only holds B's walked nodes, so those cross-file edges would silently drop. Fix: the four resolvers fall back to `declToId.get(decl) ?? nodeIdForDecl(decl, root)`, where `nodeIdForDecl` recomputes the id from the decl's *location*, mirroring `visit`'s reachability exactly (top-level decl, or a member of a top-level class/interface — anything deeper, like a nested function, returns undefined, as does anything outside `root`/in `node_modules`). Crucially this is **behavior-preserving in full-index mode**: every in-repo target is walked there, so `declToId` always hits and the fallback never fires — the 103 existing tests stay green, the self-index gate confirms an unchanged graph. **Design pivot mid-implementation** (user call): reconcile an *edit* as a **MERGE**, not delete-and-reinsert. The two produce an *identical* final graph (ids are content-independent, and `removeFile` already preserves inbound edges by only dropping a file's *owned* — outbound — edges), so it's not a correctness difference; merge wins by churning only what changed, which is the delta-accurate foundation gd5.3 (debounced sync) and gd5.5 (staleness banners) need. So: `removeFile` = the *delete* scenario; new `reconcileFile(path, nodes, edges)` = *create/edit* (upsert nodes, drop only vanished symbols, reconcile owned edges to exactly `edges`); `Indexer.reindexFile` dispatches between them on `fs.existsSync`. Two traps the merge surfaced: (1) **node-equality is not an edge-stability proxy** — a function whose line-span is unchanged can still gain/lose a call or type-usage, so edges must be diffed independently by `(from,to,kind)`, not skipped when the node looks unchanged; (2) `addNode` was **not idempotent** (in-memory double-pushed into `byName`; SQLite `INSERT`ed a duplicate `nodes_fts` row since fts5 has no `INSERT OR REPLACE`), so upsert needed it fixed in both backends — a good hygiene fix regardless. **Validation that nails both properties at once**: `reindexFile("src/store/memory.ts")` on the live self-index left counts at **196 nodes / 463 edges → 196 / 463** — re-indexing a file full of cross-file imports churns *nothing*, which simultaneously proves the merge is a clean no-op for an unchanged file *and* that its outbound edges re-resolved via the fallback (a broken fallback would show an edge *drop*). 103 → 115 tests (store contract gains `removeFile`/`reconcileFile` cases ×2 backends; a temp-dir reindex integration test cross-references `a ↔ b` to exercise both outbound re-resolution and owned-by-other-file preservation). reindexFile has no MCP surface yet — it's the mechanism; the watcher (gd5.2) / debounce (gd5.3) / manual-sync op (gd5.6) that drive it are the next picks. - 2026-06-16 · indexer · Native file watcher (ama-gd5.2) is built on Node's `fs.watch({recursive:true})` — **no dependency**, consistent with the `node:sqlite` no-deps stance (chokidar was the obvious alternative and was deliberately declined). Recursive watching is macOS/Windows-only on `fs.watch`; on Linux it may silently watch just the top level — documented as a limitation, and the whole thing is encapsulated in one `FileWatcher` class so swapping to a manual per-directory implementation later touches nothing else. Three decisions worth recording: (1) **watch == index by construction** — the ignore rules were extracted into `src/indexer/ignore.ts` (`isIgnoredSegment`/`isIgnoredPath`) and *both* `discoverFiles` and the watcher consume them, so you can never watch a tree you wouldn't index. The `discoverFiles` refactor (its inline `startsWith(".")`/`IGNORED_DIRS.has()` → `isIgnoredSegment`) is behavior-identical — the existing indexer tests are the proof. (2) **The watcher does not classify create/modify/delete.** It emits one repo-relative path per raw event and lets the consumer call `reindexFile`, whose `fs.existsSync` dispatch already routes create/edit→`reconcileFile`, delete→`removeFile`. The one asymmetry: on a `statSync` *throw* (the path is gone) it still emits — a deletion must be reported so the consumer can drop it — whereas a directory event or an over-cap file is dropped (`!isFile() || size > max`). (3) The >1MB cap applies only when the file exists, for the same reason. **Scope is the watcher *primitive* only** — no session/MCP wiring; debouncing bursts of edits and actually driving `reindexFile` is gd5.3 (deliberately separate, since raw `fs.watch` fires multiple events per save). **Testing `fs.watch` is the real lesson**: events are async and laggy (FSEvents coalesces), so the test (a) polls *until* an expected event arrives instead of asserting synchronously, (b) proves *absence* (ignored paths) with a **sentinel** — a real file created last whose arrival means anything created earlier would already have shown up — and (c) spaces create/modify/delete with ~100ms gaps so FSEvents doesn't merge them. Ran 3× with zero flakes; counts moved 196→206 nodes / 463→477 edges / 42→45 files (the three new source files, not a graph-semantics change). gitignore-*glob* parsing (`*.log`, negations, nested files) is deferred to ama-2eu (`discovered-from:gd5.2`) — today's `IGNORED_DIRS`+dotfiles cover this repo's `.gitignore`, and the uncovered globs aren't analyzable extensions so they never trigger reindex work. - 2026-06-16 · indexer/mcp · Debounced auto-sync (ama-gd5.3) is the keystone that composes gd5.1 (reconcile) + gd5.2 (watch) into the actual product behavior — *edit a file, the graph refreshes*. Two pieces: a generic `Debouncer` (accumulates changed paths in a **Set**, so the several `fs.watch` events one save emits collapse to one re-index per path; trailing window re-arms on each `notify`; a `flushing` guard serializes flushes and re-batches edits that land mid-flush; a per-path `try/catch` isolates a failing re-index — a file mid-edit with a syntax error can't wedge the batch, the error goes to stderr), and the wiring on `AmaSession.watch({windowMs})`/`unwatch()` that feeds `FileWatcher → Debouncer → reindexFile`, keeping `stats` fresh. **Testing-timing lesson, the sequel:** the Debouncer's logic is pure timer behavior, so it's tested with **vitest fake timers** — fully deterministic, no sleeps. The key API is `await vi.advanceTimersByTimeAsync(ms)`, which advances the clock *and* drains the async microtasks a timer callback spawns (a plain `advanceTimersByTime` would fire the timer but never await the `flush()` promise, so assertions would race). The mid-flush-batching test even drives a manually-resolved promise to hold a flush open while a new `notify` lands. Contrast the *end-to-end* `watch` test (real `fs.watch` + real debounce + real `reindexFile` over a temp dir), which must use **real** timers + poll-until — you can't fake-timer your way through the OS's file-event latency. One pristine-output detail worth keeping: the "isolates a failing sync" test `vi.spyOn(console, "error").mockImplementation(()=>{})` to both assert the failure was logged *and* keep the suite output clean (the TDD skill wants no stray stderr). **Scope line:** this delivers the session-level capability; the MCP server does not yet auto-`watch()` on `index_repository` (that lifecycle hookup, plus a manual sync op + pending-count surface — `Debouncer.pendingCount` already exists for it — is gd5.6; connect-time catch-up is gd5.4). **Validation on Ama itself**: with a live `watch({windowMs:30})`, writing a new `.ts` file into `src/` was auto-detected → debounced → reindexed, and `searchSymbol` found its symbol (`found:true`) — the full loop, dogfooded. 117 → 123 tests; self-index 206/477/45 → 216/498/48 (three new files, no semantics change). The watcher/debounce tests ran 3× with zero flakes. - 2026-06-16 · indexer/mcp · Manual sync op + pending status (ama-gd5.6) closes the auto-sync story with a *watcher-independent* path: `AmaSession.sync()` (→ `Indexer.sync`) is a fingerprint-diff catch-up — walk the tree, and for each analyzable file compare disk against the stored `FileMeta`, re-indexing only what drifted, plus dropping stored files that vanished. The detection deliberately layers cheap→expensive: **size + mtime first, content hash only as the tiebreaker** (`isStale`), so an unchanged file is `stat`-ed but never re-hashed or re-analyzed — and even a false positive is harmless because `reindexFile`'s merge is a no-op for unchanged content. It reuses `reindexFile` per path, so add/edit/delete all route through the same `existsSync` dispatch (`reconcileFile`/`removeFile`) built in gd5.1. Returns `{changed, removed}` so the op is self-reporting. **The "pending" half resolves a genuine ambiguity**: there are two distinct notions of pending — (a) the auto-syncer's queued-but-unflushed edits (the `Debouncer.pendingCount` I deliberately pre-exposed in gd5.3), and (b) what's stale on disk right now (a scan). I surfaced (a) as `index_status.pendingSync` because it's **O(1)** and exactly the "is the watcher behind?" signal, and left (b) to be answered by *running* `sync()` (which reports what it did) rather than scanning on every status call. So: `pendingSync` = auto-sync backlog (0 when not watching); `sync_index` = force a full reconcile now. Exposed `sync_index` as an MCP tool (zero-arg) and added `pendingSync` to the `index_status` payload. Note this also makes **gd5.4 (connect-time catch-up) nearly free** — it's just calling `sync()` at connect. **Scope honesty**: the MCP *server* still does not auto-`watch()` on `index_repository` (a lifecycle choice deferred with gd5.4); a client today indexes, edits, then calls `sync_index` — or drives the in-process `watch()` for live auto-sync. Validated on Ama's own tree: with no watcher, writing `src/__sync_smoke__.ts` then `sync()` returned `changed:[that path]` and the symbol resolved; deleting it then `sync()` returned `removed:[that path]` and it was gone. 123 → 129 tests; self-index 216/498/48 → 221/514/49. - 2026-06-16 · mcp · Staleness banners (ama-gd5.5) make the query tools *honest during the debounce window*: while the auto-syncer holds edits (`pendingSync > 0`), the affected files' query results are stale, so the response prepends a warning that **names the pending files and tells the caller to read them directly**. The bead said *prepend* (not append), and that turned out to be the non-breaking choice precisely because the banner only appears when stale: every existing server test runs against a non-watching session (`pendingSync === 0` → no banner), so `content[0]` stays the JSON block and nothing broke — only the stale path puts the banner at `content[0]` and pushes JSON to `content[1]`. Three small pieces compose it: `Debouncer.pendingPaths()` (expose the queued set, not just the count from gd5.6 — the banner needs the actual names to suggest *which* files to re-read), `AmaSession.stalenessBanner()` (returns `undefined` when nothing pends — the overwhelmingly common case, so zero banner noise — else a capped list of paths + the direct-read nudge), and a server `reply(session, value)` helper that swaps in for `json()` on the **eight query tools** only. `index_status` keeps plain `json` (it already carries `pendingSync` as data), and the mutating tools (`index_repository`/`sync_index`) keep `json` because they *just* refreshed — banner-prepending a result that made itself fresh would be a lie. Why `pendingSync` is the right trigger and not a disk scan: it's O(1) and it captures exactly the window the bead targets — "edited, fs.watch fired, queued, not yet re-indexed." (Caveat worth knowing: `pendingCount` drains to 0 at flush *start*, so the banner doesn't cover the sub-second reindex itself, only the debounce window — fine, since the window is where an agent races a query against its own edit.) Validated on Ama's own tree: with a live `watch({windowMs:10000})`, writing `src/__stale_smoke__.ts` made `stalenessBanner()` return the warning naming that path; `unwatch()` → `undefined`. 129 → 132 tests; self-index 221/514/49 → 225/523/50. The `ama-gd5` epic now has only gd5.4 (connect-time catch-up) left, and that's nearly free given `sync()`. - 2026-06-16 · mcp · Connect-time catch-up (ama-gd5.4) **completes the gd5 incremental-indexing epic** and was almost entirely a wiring job because `sync()` (gd5.6) already does the "size/mtime + content-hash reconcile" the bead asks for. The mechanism is a one-shot arm: `AmaSession.markForCatchUp()` sets a flag (guarded on *being indexed*, so it's a no-op before the first index), and `catchUpIfNeeded()` — called at the top of every read handler — runs `sync()` exactly once when armed, then disarms. So the *first* query after a reconnect pays the reconcile scan and every subsequent one is a free boolean check. The non-obvious part was finding the reconnect signal: the MCP SDK's high-level `McpServer` exposes the low-level `.server`, whose `oninitialized` callback fires on each connection's initialize handshake — i.e. on every (re)connect. Wiring `server.server.oninitialized = () => session.markForCatchUp()` arms the catch-up at exactly the right moment; on the *very first* connect the session isn't indexed yet so the arm no-ops, and a fresh `index_repository` clears the flag (a just-built index needs no catch-up). Testing it without socket churn: invoke the wired `server.server.oninitialized?.()` directly to simulate the reconnect handshake, then assert the next `search_symbol` over a real in-memory client reflects an on-disk edit — faithful because that callback *is* what the SDK fires. A neat consolidation fell out: rather than sprinkle `await catchUpIfNeeded()` across eight handlers, a single `queryTool(session, run)` wrapper now owns the read-path policy — catch-up first, then `reply()` (which adds the gd5.5 staleness banner) — so connect-time freshness and debounce-window staleness are expressed in one place; `index_status` also catches up, while `index_repository`/`sync_index` don't (they refresh themselves). Validated on Ama's own tree (edit after index → `markForCatchUp` → `catchUpIfNeeded` returned `changed:[that file]` and the symbol resolved). One honest note: a single transient flake surfaced in the real-`fs.watch` timing tests (passed 3/3 on rerun) — filed a P3 to add a fake-event seam so the debounce/auto-sync logic is testable without OS file-event latency. 132 → 137 tests; self-index 225/523/50 → 229/535/51. **gd5 epic: 6/6 children done — Ama now keeps its index fresh via a live watcher, a manual `sync_index`, and a connect-time catch-up, and tells callers when a result is mid-update.** - 2026-06-16 · mcp · MCP-over-HTTP transport (ama-ndw.1) serves the *exact same tools* as the stdio server, but the client connects to a URL instead of spawning the process — which **inverts the lifecycle** that made the dogfood loop need restarts. The design decision that matters: a single process-global `AmaSession` (the index) is shared across all MCP client sessions, while each client session still gets its own `StreamableHTTPServerTransport` + `createMcpServer(sharedSession)` wrapper (the SDK's session-map pattern, keyed by the `Mcp-Session-Id` header; `onsessioninitialized` registers the transport, `onsessionclosed` deregisters). So a client disconnect/reconnect no longer drops the graph — proven by a test where client 2 finds a symbol client 1 indexed, with no re-index, over real HTTP. Implementation notes: `enableJsonResponse: true` gives plain JSON request/response instead of SSE (simpler for tool calls and tests); **no new dependency** — `StreamableHTTPServerTransport` ships in the SDK (it wraps `a transitive HTTP adapter package`, already transitive) over `node:http`; the stdio `server.ts` entrypoint is untouched (HTTP is additive). One TS trap worth noting: the transport's `onsessioninitialized` callback must reference the `transport` it's being constructed into, a circular self-reference that makes TS infer `any` — fix with an explicit `const transport: StreamableHTTPServerTransport = …` annotation, and give the `Map.set`/`Map.delete` callbacks **block bodies** so they return `void`, not the Map/boolean (which isn't assignable to `(id) => void | Promise`). gd5.4 composes for free: each reconnect's initialize fires `oninitialized → markForCatchUp`, so the first query after reconnect catches up. Dogfooded by running the HTTP server against Ama's own repo with a real `StreamableHTTPClientTransport`: indexed 236/550/53, reconnected, found `TypeScriptAnalyzer`. Only a *process* restart now loses the (in-memory) index — that's what the next bead (ndw.2, persistent SQLite reopened on startup) addresses, after which `tsx watch` (ndw.3) makes analyzer-code edits take effect on reconnect with no agent-client restart. 137 → 139 tests. - 2026-06-16 · indexer/store · Persistent index + reopen-on-startup (ama-ndw.2) is what makes a process restart cheap: a file-backed `SqliteStore` survives the restart, and `AmaSession.open(root)` reopens it instead of re-analyzing. The dogfood number tells the whole story — indexing Ama into SQLite took **507 ms**, reopening it after a simulated restart took **1 ms** (`243 nodes / 570 edges`, query worked immediately). The mechanism: `Indexer.index()` now `clear()`s the store first (so a re-index into a *persistent* file is a clean rebuild, not a merge into stale rows) and stamps three meta rows — `ama:coverage` (already there, for stats), `ama:root`, and `ama:schema`; `Indexer.open()` reopens *only* when `ama:schema === SCHEMA_VERSION` **and** the stored root matches **and** `nodeCount > 0`, reconstructing `IndexStats` from the coverage meta with zero analysis (else it `close()`s the freshly-opened store and returns undefined so the caller does a full index). Two design choices worth recording: (1) the **schema-version guard lives in a meta row, not the Store interface** — bump `SCHEMA_VERSION` whenever the persisted format changes and a stale DB is rebuilt rather than misread; the `meta` table is stable key/value so the version check reads safely even off an otherwise-incompatible DB. (2) `open()` on the **default in-memory store always falls through to a full index** (a fresh `InMemoryStore` has `nodeCount === 0`), so persistence is strictly opt-in via the `createStore` factory and tests are unaffected. New `Store.clear()`/`close()` (both backends; `InMemoryStore.close()` is a no-op) let `AmaSession` release the SQLite connection and avoid leaking one per re-index (close the *previous* store after switching). On reopen, `needsCatchUp` is armed so gd5.4 reconciles drift on the first query — proven by the headline test's drift trick: after reopen the new symbol is *absent* (stale-from-disk) until `catchUpIfNeeded()`, which distinguishes a reopen from a silent re-index. The HTTP `main()` gained `AMA_DB`/`AMA_ROOT` env wiring to actually reopen on startup; the `serve:dev`/`.mcp.json` glue that sets those is ndw.3. 139 → 145 tests. With ndw.1 (HTTP) + ndw.2 (persistence) done, the last piece (ndw.3 `tsx watch` config) turns this into a real restart-free dogfood loop for Ama's own analyzer code. - 2026-06-16 · tooling/mcp · The dev-server config (ama-ndw.3) **closes the loop the whole session was building toward** — Ama can now dogfood changes to its *own analyzer code* with no agent-client restart. It's almost entirely config + docs (the `main()` env-wiring was ndw.2), three small pieces: a `tsx` devDependency, `serve:dev` = `AMA_DB=.ama/index.db AMA_ROOT=. tsx watch src/mcp/http.ts` (plus `serve:http` for the built server), `.ama/` gitignored, and a one-line `main()` `mkdirSync` so the cache dir exists. The deliberate non-change: **the committed `.mcp.json` stays stdio** so a fresh clone works with zero setup; the HTTP `{ "type": "http", "url": "http://localhost:7077/mcp" }` switch is documented as a local opt-in, not forced on everyone. Verification here is an **integration smoke, not a unit test** (TDD's explicit config-file exception): running `tsx` against the real source proved it resolves our NodeNext `.js`-extension ESM imports to `.ts` and serves a live HTTP client (`243 nodes / 54 files` indexed from tsx-run source). One gotcha worth recording so the next person doesn't misread it: `tsx -e ''` fails with *"Top-level await is not supported with the cjs output format"* — that's a property of **eval mode** (esbuild compiles `-e` as CJS), **not** of our source or `serve:dev`, which runs a *file* in ESM (package `type: module`); wrap eval awaits in an async IIFE, or just run the file. `npm audit` flags highs from `tsx`'s esbuild chain, but `npm audit --omit=dev` is **0** — all dev-only. Filed a P3 (`discovered-from:ndw.3`) to refresh the now-stale "Known gaps" doc, which still lists Imports/Inherits/Implements/UsesType as "not yet edges" though all four shipped this session. **ndw epic 3/3 done.** With ndw.1 (HTTP transport) + ndw.2 (persistent reopen) + ndw.3 (tsx-watch config), the rebuild-and-restart caveat that every prior insight had to flag is now *solved*: edit the analyzer → `tsx watch` bounces the standalone server → persistent index reopens in ~1 ms → connect-time catch-up reconciles → the next tool call hits fresh code. 145 tests unchanged (config/docs iteration; no analyzer-graph change). - 2026-06-16 · store · Edge dedup (ama-cs4, discovered-from ama-zh0): `addEdge` accepted identical `(from,to,kind)` edges, inflating `edgeCount` and returning dupes from `find_callers`/`find_callees`/`find_importers`. The design choice the ticket left open — dedupe at **write** vs **query** — resolves decisively to write: `edgeCount` reads `this.edges.length` / `count(edges)` *directly*, so a query-layer filter would leave the loop's own `index_status` freshness signal lying. One invariant (an edge *is* its `(from,to,kind)` triple — `GraphEdge` has no other fields), two idiomatic enforcements held to parity by the shared `runStoreContract`: in-memory keeps a `Set` of `JSON.stringify([from,to,kind])` keys; SQLite pushes it into the schema with `CREATE UNIQUE INDEX … ON edges(from_id,to_id,kind)` + `INSERT OR IGNORE`, letting the DB enforce it declaratively. **Key-encoding trap worth its own lesson:** my first cut joined the parts with a NUL byte (`from\0to\0kind`) — collision-proof, but a single `\0` in a *source* literal makes Git classify the whole `memory.ts` as **binary** (`Bin 3465 → 3881 bytes` in the diff — no line diffs, no blame, no 3-way merge). `JSON.stringify` of the triple is equally injective (JSON string-escaping can't alias two different triples) yet stays printable ASCII, so the file diffs as text. Reach for a control-char delimiter only in transient runtime data, never in committed code. The unique-index approach is migration-safe here only because `SqliteStore` is test-only (`find_importers SqliteStore` → tests only) and always opens fresh DBs; over a pre-existing file with dupes the index creation would throw, so SQLite-as-live-store would first need a dedupe-then-index migration. The contract test guards **both** directions: same edge twice collapses to one, *and* a different `kind` to the same target stays distinct (guards against over-deduping on `(from,to)` alone). **Differential self-index** (fresh build, fresh process via `dist/indexer`, vs the stale live MCP at 319): edges **319 → 301** — 18 real duplicate edges existed in Ama's *own* graph (not just the synthetic fixture), nodes flat at 170 (edges-only change — the expected signature). The live MCP keeps reporting 319 until a CC restart reloads `dist/`. Non-obvious dogfooding gap surfaced: `find_callers` returns `[]` for an exported function (`runStoreContract`) that two test files plainly call — cross-file *call* resolution doesn't link a call-site to an imported def, though `find_importers` resolves the same import edge correctly; I got my answer via `find_importers` (the import edges already answer "where is this wired"), so didn't file. - 2026-06-16 · query/mcp · Surfacing write-only `UsesType` edges (ama-lp3) confirms the "an edge kind exists in the graph but no query reads it → add a method + MCP tool per direction" playbook is now a pure mechanical mirror — the *third* time (Imports→ama-5ex, Implements→ama-gtw, now UsesType): a `QueryService` method (`resolve(ref)` → `edgesTo`/`edgesFrom(id, kind)` → `getNode` → dedupe into a `Map`), a one-line `AmaSession` delegate, and a `server.ts` `registerTool`. **Zero store change** — `edgesFrom`/`edgesTo` already take an `EdgeKind` filter, so adding a queryable relationship is entirely a read-side change. Reverse (`find_type_users` = "who annotates with type X", `edgesTo`+collect `from`) mirrors `findImporters`; forward (`find_types_used` = "what types does X mention", `edgesFrom`+collect `to`) mirrors `findImports`. The **non-obvious asymmetry vs Imports**: a `UsesType` edge's `from` is mixed-kind — a `Class` (property type, attributed to the enclosing class since properties aren't nodes yet) *or* a `Function`/`Method` (param/return) — whereas an Imports edge's `from` is always a `File`. So `find_type_users` returns symbols, not files; verified on the `ts-usestype` fixture (Widget's users = `build`/`Factory.make`/`Holder`/`many`, Gadget's = the two with it as a *return* type). **Dogfood blind spot found (already filed as ama-hft.8, so not re-filed):** `search_symbol("EdgeKind")` and `search_symbol("UsesType")` both return empty — type aliases and union-literal members aren't emitted as graph nodes, so Ama can't locate the definition of the very edge kind its new tool queries; fell back to Read for the union, which the loop sanctions when a tool can't answer. **Dev-mode gotcha worth recording for the loop itself:** with `serve:dev` (`tsx watch`, ndw.3) running, editing `src/` bounced the HTTP server and dropped the live MCP session (`{"error":"No active MCP session"}`), so the *live* re-index step is impossible without an agent-client reconnect — exactly the "treat a live re-index as post-restart confirmation" caveat. Captured after-counts by indexing `.` through `tsx` directly instead. Self-index (`.`) 243/570/54 → 247/586/54: **+4 nodes** = the 4 new methods (2 in `QueryService`, 2 in `AmaSession`); the two `registerTool` calls add **+16 edges** (each new method's `Calls` into `resolve`/`edgesTo`/`edgesFrom`/`getNode`, the session→query delegations, and `createServer`→`session.findType*`) but **no named nodes** (their callbacks are anonymous arrows) — a clean illustration that a node appears only for a *named declaration*, edges for the calls between them. 145 → 151 tests. - 2026-06-16 · mcp · Build/version stamp on `index_status` (ama-997) turns the loop's Step 0 from "is Ama *connected*?" into "is the *running* server *current*?" — a `server: { version, revision }` field now rides every `index_status`. The one design decision that makes or breaks it: **`revision` is captured once at module load, not per call.** A per-call read of `.git/HEAD` would always equal live `HEAD` and could never report staleness; capturing at process start means the stamp reflects *the commit the server launched at*, so committing-without-restarting makes `server.revision` lag `git rev-parse HEAD` and the drift is detectable — exactly the "stale dist/ server" hazard every prior insight had to hand-wave. Implementation stays within the project's constraints: a new `src/mcp/build-info.ts` reads `package.json` (version) and resolves the SHA **purely from the filesystem** — `.git/HEAD`, following a `ref:` to the loose ref file, then a `packed-refs` fallback, detached-HEAD handled, **null** outside a repo — so no shelling out (stdout stays sacred) and no build-step codegen (works identically from `dist/` and from `tsx`, since both sit two dirs under the repo root: `path.resolve(here, "../..")`). The stamp sits on **both** branches of the `IndexStatus` union (even `{ indexed: false }`) because freshness is independent of whether anything is indexed — Step 0 can check it before the first `index_repository`. Dogfood note: `search_symbol("IndexStatus")` returns empty *again* — same type-alias-isn't-a-node gap (ama-hft.8) hit last iteration, since `IndexStatus` is a union type alias; fell back to Read for the definition (sanctioned when a tool provably can't answer). **Meta-payoff observed this iteration:** the live re-index after editing `src/` returned `fileCount 55` (the new `build-info.ts`) and reconnected with no `No active MCP session` — an end-to-end confirmation that ndw.3's `tsx watch` loop *and* this session's 400→404 reconnect fix both work, the first time a live re-index succeeded mid-iteration this whole session. Self-index (`.`) 250/592/54 → 254/596/55 (+1 file = build-info.ts; +4 nodes = readVersion/readRevision/serverStamp/ServerStamp; +4 edges). 154 → 155 tests. - 2026-06-16 · query · The first *higher-order* query tool, `node(ref)` (ama-179.4), is also the cheapest kind of feature: it adds **zero new graph logic** by composing the existing public reads — `resolve(ref)[0]` for the definition, then `getCodeSnippet` + `findCallers` + `findCallees` + `findImporters` (dependents), each of which already re-resolves the ref and aggregates — into one `NodeView`. So the method is ~8 lines and the rest is the established three-layer wiring (QueryService method → AmaSession delegate → `tap`+`queryTool` MCP tool). Two design notes worth keeping: (1) **no branching on symbol-vs-file** — a file ref just yields empty `callers`/`callees` and populated `dependents`; honest empties beat special-casing, and the shape stays uniform for the caller. (2) The MCP input is named `ref` (not `symbol`/`file`) precisely because it accepts either — the first tool whose argument is deliberately kind-agnostic. Dogfood confirmation that compounds across this session: `index_status.server.revision` now reads `07407a6…` and **exactly matches `git rev-parse HEAD`**, so last iteration's freshness stamp does what it promised — Step 0 can now assert the running server is current instead of guessing; and the live re-index again reflected the new code (the `node` method) and auto-reconnected with no dropped session. Self-index (`.`) 254/596/55 → 257/612/55 (+3 nodes = the `node` method, its session delegate, and the `NodeView` interface — interfaces are nodes, unlike the type aliases that keep returning empty from search_symbol; +16 edges). 155 → 158 tests. - 2026-06-16 · query · `impact_analysis(symbol, maxDepth?)` (ama-179.2) is the project's headline question — "what breaks if I change this?" — and the first query that goes *beyond* one-hop composition: a breadth-first walk over **reverse `Calls` edges** (`edgesTo(id, "Calls")` → `from`), callers then callers-of-callers, returning the transitive blast radius as a flat `GraphNode[]`. Two things make the ~18-line BFS correct and cheap: (1) **the depth bound is just the loop guard** `for (depth = 0; depth < maxDepth && frontier.length; depth++)` — `maxDepth=1` runs one level (direct callers), default `Number.POSITIVE_INFINITY` runs to fixpoint; and (2) **one `seen` set does double duty** — seeded with the ids the ref resolves to (so a symbol is excluded from its *own* blast radius) and extended as the frontier expands, which is exactly what makes cycles and mutual recursion terminate. Unlike `node()` it adds **no new interface** (the answer is just a set of symbols), so the graph grew by only the method + its delegate. Test design hinged on the `ts-calls` fixture's `run → compute → helper` chain: `impactAnalysis("helper")` returns `{main, Service.compute, Service.run}` where **Service.run is reachable only transitively** (it calls `compute`, never `helper`), so the same fixture proves transitive reach *and*, at `maxDepth=1`, the bound (Service.run drops out). Dogfooded the call graph itself with `find_callers` on each symbol to derive those expectations rather than guessing. Freshness stamp again matched HEAD (`80464213`) and the live re-index reflected the new code — the loop's now-routine self-confirmation. Self-index (`.`) 257/612/55 → 259/620/55 (+2 nodes = method + delegate; +8 edges). 158 → 162 tests. - 2026-06-16 · query · `get_graph_schema` (ama-179.6) is a census of the index — `{ nodes: {kind→count}, edges: {kind→count} }` — and it's the tool that finally answers the question `search_symbol("EdgeKind")`/`"NodeKind"` has failed at *every* iteration: it reports what kinds exist by **counting instances** rather than reading the (type-alias, non-node) definition. Two design choices: (1) the `Store` deliberately exposes **adjacency** (`edgesFrom`/`edgesTo` per node) and **no global `allEdges()`**, so the census iterates `allNodes()` once and sums `edgesFrom(id)` per node — each edge counted exactly once at its source node (sum equals `edgeCount`); (2) it keeps **tier/coverage out** of the schema (that already lives on `index_status.languages`) — node/edge-kind taxonomy is graph data and belongs in `QueryService`, so no catch-all introspection blob and no duplication. Verified against the `ts-calls` fixture: 2 Functions (helper, main), 2 Methods (Service.run/compute), 1 Class, and exactly 3 `Calls` edges (main→helper, run→compute, compute→helper) — the same call graph the impact_analysis test leaned on. Self-index (`.`) 259/620/55 → 262/629/55 (+3 nodes = method + delegate + `GraphSchema` interface; +9 edges). 162 → 164 tests. ama-179 epic now 3/6 (node, impact_analysis, get_graph_schema done; explore, affected, search_code remain). - 2026-06-16 · watch/testing · De-flaked the fs.watch timing tests (ama-uj0) by isolating the **one** nondeterministic dependency behind a seam: `FileWatcher` now takes an injectable `WatchSource` (default `fsWatchSource` = the real `fs.watch` wrapper), and `AmaSession.watch({ source })` passes it through. Tests inject a synchronous source that captures the event callback and expose a `fire(rel)` to deliver events **by hand** — eliminating the actual flake cause, which was never the debounce timer or the watch logic but `fs.watch`/FSEvents *delivery latency* occasionally exceeding the 5s poll. Every line of real logic (`statSync`, ignore rules, the debounce window) still runs; only OS event *timing* is removed from the equation, so assertions that polled-until-an-event now fire deterministically. Migrated the three named flaky files — `watch.test.ts` (auto-sync), `staleness.test.ts` (banner), and the logic half of `watcher.test.ts` — to the seam, and **kept exactly one real-`fs.watch` integration smoke** (create/modify/delete): de-flaking shouldn't mean never exercising the real source, just not depending on its timing for unit correctness. Verified by running the three files 5× consecutively (was ~1-in-5 flaky) → 5/5 green, 8 tests each. Dogfood aside: `find_callers(FileWatcher)` returns `[]` because `new FileWatcher(...)` constructor calls aren't emitted as `Calls` edges (a known resolution gap, not re-filed); I knew the construction site from reading `AmaSession.watch`. Self-index (`.`) 262/629/55 → 264/631/55 (+2 nodes = `fsWatchSource` + a test helper; the `WatchSource` *type alias* is — as ever — not a node; +2 edges). 164 → 165 tests. - 2026-06-16 · query · `affected(files)` (ama-179.3) is the **file-level** companion to `impact_analysis` (symbol-level): a transitive reverse-`Imports` closure answering "which files/tests should I recheck if I change these?". The non-obvious correctness point is that a file's importers come from **two** edge sources, because of how imports are modeled: a module-level `import * as x from "./f"` / `export * from "./f"` targets the **File node**, while a named `import { foo } from "./f"` targets the **symbol** `f` defines. So the `fileImporters` helper unions `edgesTo(fileId, "Imports")` with `edgesTo(s, "Imports")` for each `s` in `edgesFrom(fileId, "Defines")` — using only the file node would miss every named importer (the common case). BFS with a visited set seeded by the inputs gives the transitive closure and excludes the inputs from the result. Dogfooding the `ts-imports` fixture (with `find_imports`/`find_importers` to derive exact expectations, as for impact_analysis) surfaced **re-export transparency**: `main` imports `greet` *via* `barrel`, but the graph records the edge straight to lib's `greet` (resolved to origin), so `affected(["barrel.ts"])` is empty — a pure re-export barrel has no recorded dependents because every consumer resolves through it to the origin. Faithful to the cross-file resolution model (arguably a gap that deleting a barrel would break importers the graph doesn't show, but it's the intended origin-resolution behavior; not filed). Note the unit-test store analyzes only lib/barrel/main (no `ns-barrel.ts`), so `affected(["lib.ts"])` = {barrel, main} there, while the live whole-repo index also includes ns-barrel — the MCP test uses `toContain` to stay robust to that. Self-index (`.`) 264/631/55 → 267/642/55 (+3 nodes = `affected` + `fileImporters` + the session delegate; +11 edges). 165 → 168 tests. ama-179 epic now 4/6 (node, impact_analysis, get_graph_schema, affected done; explore, search_code remain). - 2026-06-16 · query · `search_code` (ama-179.5) searches symbol *bodies* (not names) and mirrors the project's established **two-tier search pattern**: exactly as `searchByName` is an in-memory substring scan that the SQLite store later specializes into FTS5, `searchCode` is a substring scan over source now with an FTS index deferred to the SQLite tier — consistent rather than novel. Two design points: (1) **File nodes are excluded.** Every indexed node carries a range, and a File node's range spans the whole file, so without the exclusion *every* query would trivially match the file node; excluding files makes a hit point at the containing *symbol* (a class node still matches text in its methods — full-text over bodies is granular-but-nested by nature, so the test uses distinctive body text like `return 42`, which only `helper` contains). (2) **Files are read once, grouped** (`Map` then read+slice), not once per symbol — the same `fs.readFileSync(resolve(root, file))` path `getCodeSnippet` uses. The cost is O(files) reads per query (no body index yet); fine at this scale and explicitly the in-memory tier's tradeoff. Self-index (`.`) 267/642/55 → 269/649/55 (+2 nodes = `searchCode` method + session delegate; +7 edges). 168 → 171 tests. ama-179 epic now 5/6 — only `explore` (179.1) remains. - 2026-06-16 · query · `explore(question)` (ama-179.1) is the **capstone of the ama-179 epic** (now 6/6) and a demonstration of bottom-up sequencing paying off: it's ~15 lines because it's *pure composition* of four tools built earlier this same session — `searchSymbol` (matches, then grouped into `byFile`), `findCallers`/`findCallees` (the per-match relationship map), and `impactAnalysis` (the combined blast radius, unioned over all matches). Zero new graph traversal. The +14 edges (the most of any query iteration this session) are exactly that fan-out — `explore` calls *four* sibling query methods where the others called one or two; the dependency graph literally shows the composition. Capstone tools like this are cheap when the primitives exist and miserable when they don't, which is the argument for building an epic's leaves before its root. **Autonomous-loop note:** deliberately chose `explore` (read-only — cannot corrupt the index, worst case is a revisable design) over the recurring `ama-hft.8` type-alias gap, which is an *analyzer* change with real graph-corruption blast radius and is better done in a reviewed, non-autonomous iteration; flagged that to the user rather than pushing it unattended. Self-index (`.`) 269/649/55 → 272/663/55 (+3 nodes = `explore` + delegate + the `Exploration` interface; +14 edges). 171 → 173 tests. **ama-179 (higher-order query tools) epic complete: node, impact_analysis, affected, get_graph_schema, search_code, explore.** - 2026-06-16 · docs · Refreshing the stale capability docs (ama-wfs) turned up a **backlog-accuracy** finding worth more than the doc fix itself: while verifying ground truth in `analyzer.ts`, I found the analyzer **already emits `Enum` nodes** (`isEnumDeclaration → { kind: "Enum" }`, line ~266) — so `ama-hft.8` ("Enums, type aliases, properties as nodes") was mis-scoped; enums are done, and the genuine remaining node gaps are **type aliases + properties + arrow/expression functions**. Narrowed that bead so a future loop doesn't redo finished work. The docs themselves had drifted hard: `docs/SELF_IMPROVEMENT_LOOP.md` "Known gaps" still listed `Imports`/inheritance/`Implements`/type-usages as "not yet edges" though all four (`Imports`/`Inherits`/`Implements`/`UsesType`) shipped — the analyzer emits all six `EdgeKind`s (`Defines`/`Calls`/`Inherits`/`Implements`/`Imports`/`UsesType`) — and `AGENTS.md` said MCP runs "over stdio" (HTTP/ndw.1 shipped) and named only 4 of the now-19 tools. Meta-lesson: capability prose rots fast against an actively-developed analyzer, and the `get_graph_schema` tool built two iterations ago is now the **empirical source of truth** for "what node/edge kinds exist" — docs that restate it will keep drifting, so they should point at it. A docs-only iteration: no TDD (docs have no test; the gate is "suite still green + self-index clean," both held), counts flat at 272/663/55, 173 tests unchanged. Also a dogfood aside: `get_graph_schema` wasn't yet in the MCP client's tool catalog (newly-registered tools don't surface to a connected client until it re-lists), so I grounded the docs in the source `EdgeKind`/`NodeKind` unions instead. - 2026-06-16 · typescript-analyzer · **Type aliases as nodes** (ama-hft.8) — the gap the loop hit *every* iteration this session (`search_symbol("EdgeKind"/"IndexStatus"/…)` → empty) — closed in **two lines of actual logic**: add `"TypeAlias"` to the `NodeKind` union and one `describe()` case (`isTypeAliasDeclaration → {kind:"TypeAlias"}`), mirroring the existing enum case verbatim. It was small *because the surrounding machinery is already general*: `walkFile` does `sf.forEachChild(visit)` over every top-level statement, `visit()` emits a node + `Defines` edge for anything `describe()` returns, and `collectTypeUsages` resolves type references through `declToId` — so a type alias becomes a node **and** retroactively gains inbound `UsesType` edges (param/return/property annotations that reference it) with no further change. The "analyzer change is risky" worry was overblown here, confirmed by checking blast radius first: `NodeKind` has no exhaustive `switch` anywhere (only two `node.kind === "File"` guards), so widening the union is safe. The dogfood payoff is quantified by the edge delta: **+48 edges** vs the +2–16 of every prior query iteration — Ama's own source leans heavily on type aliases (`NodeKind`/`EdgeKind`/`Tier`/`IndexStatus`/`WatchSource`/`SyncResult`), and the analyzer had been blind to all of it. Capstone assertion added to `self-index.test.ts`: `searchSymbol("EdgeKind").some(n => n.kind === "TypeAlias")` — the exact lookup that failed all session, now green on Ama's real source. **Bead hygiene:** enums were already nodes (found in ama-wfs) and type aliases now are, so closed hft.8 and filed a focused follow-up for the genuinely-remaining piece — class properties/accessors as nodes — which is a *coupled* change (the usestype fixture asserts a property's type attributes to its enclosing class "because properties aren't nodes yet"; adding property nodes moves that attribution, so it needs its own RED→GREEN). Self-index (`.`) 272/663/55 → 282/711/57 (+2 files = fixture + analyzer test; +10 nodes; +48 edges). 173 → 176 tests. - 2026-06-16 · typescript-analyzer · **Properties as nodes** (ama-yyo, the type-alias follow-up) was again `NodeKind += "Property"` + one `describe()` case (`PropertyDeclaration` / `PropertySignature` → `{kind:"Property"}`), with **zero change to the `UsesType` collector** — because `collectTypeUsages` already (a) collects property type annotations (line 230 handles `isPropertyDeclaration`/`isPropertySignature`) and (b) derives the enclosing symbol from `declToId.get(child) ?? enclosingId`. So the *only* reason a property's type previously attributed to its enclosing **class** was that the property wasn't in `declToId`; registering it as a node moved the attribution to the property automatically. That's a genuine **behavior change** (not additive like type aliases), so it correctly broke three tests written in loop/26 (`find_type_users`/`find_types_used` over the ts-usestype fixture) that pinned the old class-attribution — `findTypeUsers("Widget")` now yields `Holder.item` instead of `Holder`, and `findTypesUsed("Holder")` is now `[]` while `findTypesUsed("Holder.item")` is `["Widget"]`. Updating those (and flipping the usestype property assertion) is the "tests that pin pre-change behavior must move with it" discipline — the suite catching them is the safety net working. The dogfood magnitude is the story: **+102 nodes / +108 edges** in one iteration — Ama's interfaces are dense with property signatures (`GraphNode` alone has 7, plus `GraphEdge`, `IndexStatus`, and every query-result interface). Across two iterations, type aliases (+48 edges) and properties (+108) roughly **doubled the graph's relational richness** by capturing type usages the analyzer had been structurally blind to. Scope: `PropertyDeclaration` + `PropertySignature` shipped; get/set accessors deferred (Ama's own source uses none, so no self-dogfood signal) — filed a P3 follow-up. Self-index (`.`) 282/711/57 → 384/819/57 (+102 nodes, +108 edges). 176 tests (3 updated to the new attribution; net unchanged). - 2026-06-16 · typescript-analyzer · **`new`-expressions as Calls edges** (ama-43w) — the documented gap I'd also hit firsthand (`find_callers(FileWatcher)` returned `[]` because construction wasn't a call site). The fix was **one type widening + one branch**: `resolveCallee`'s param widened to `ts.CallExpression | ts.NewExpression`, and `collectCalls` now matches `isCallExpression(child) || isNewExpression(child)`. It needed *no* resolution-logic change because a `NewExpression`'s `.expression` is the constructed class and the checker treats `call`/`new` identically (both are `CallLikeExpression`, so `getSymbolAtLocation`/`getResolvedSignature` just work). Construction resolves to the **class** node (there are no constructor nodes), which is exactly what makes `find_callers(SomeClass)` include its construction sites — proven on Ama's own source by a new self-index assertion: `find_callers("QueryService")` now contains `indexRepository` (which does `new QueryService(...)`). Purely **additive** (new Calls edges, none moved), so — in contrast to the property change that broke 3 tests — **zero existing tests broke**; the same library-resolution rule as calls applies (`new Map()` resolves outside the analyzed set → no edge). Self-index +21 edges (Ama instantiates heavily). **Doc-rot, again:** the "Known gaps" list I refreshed just three iterations ago (ama-wfs) had *already* gone stale — it still listed type aliases, properties, and `new`-expressions as gaps, all fixed since. So I rewrote it and, heeding the ama-wfs lesson directly, added "run `get_graph_schema` for the authoritative census — this prose drifts," pointing the doc at the empirical tool instead of re-enumerating capabilities that keep changing. Remaining documented gaps: arrow/expression functions, get/set accessors, generics, decorators, interface-method dispatch. Self-index (`.`) 384/819/57 → 388/840/59 (+2 files = fixture + analyzer test; +4 nodes; +21 edges). 176 → 178 tests. - 2026-06-16 · typescript-analyzer · **Function-valued const declarations as nodes** (ama-4s2): `const f = () => …` / `= function …` produced no node, so `search_symbol("fsWatchSource")` was empty. Fixed by `visit()` recursing into a `VariableStatement`'s declarations and `describe()` classifying a `VariableDeclaration` whose initializer is an `ArrowFunction`/`FunctionExpression` as a `Function` (a plain `const x = 42` still yields nothing). Deliberately scoped to **node emission + Defines only** — purely additive, so no existing test broke — and **deferred the call-attribution half** (attributing calls *inside* the arrow body to the const): that needs `collectCalls`'s `nextEnclosing` to switch on these nodes too, which is a behavior change, so it's a separate follow-up. Self-signal: `search_symbol("fsWatchSource")` now resolves (asserted in self-index.test.ts), and the +16 nodes exposed how many top-level const arrows live in the *tests* themselves (`const delay`, `const id`). **Tooling gotcha worth recording:** Biome's `lint/complexity/useArrowFunction` wants a `this`-free function expression rewritten to an arrow — which would have silently collapsed the very branch the fixture tests — so the fixture needs an explicit `// biome-ignore lint/complexity/useArrowFunction: …` to keep the intentional function-expression shape. (Fixtures are linted like any source under `biome check .`.) Self-index (`.`) 388/840/59 → 404/857/61 (+2 files = fixture + analyzer test; +16 nodes; +17 edges). 178 → 182 tests. - 2026-06-16 · typescript-analyzer · **Interface methods as nodes** (ama-j0z, the dispatch foundation — user authorized the complex deep-semantics work) was again one line in `describe()` (`isMethodDeclaration || isMethodSignature`, mirroring the property `Declaration|Signature` pattern; `visit()` already recurses into interface members). But **+104 edges vs +27 nodes** is the headline and it *corrected my hypothesis*: I'd guessed interface-mediated calls produced "dangling" edges (created, then dropped at query time by the `getNode` filter). The edge jump proves otherwise — they produced **no edge at all**, because `resolveCallee` returns `undefined` when the callee's declaration isn't in `declToId`, and an interface `MethodSignature` wasn't a node. Registering them lets `resolveCallee` resolve a call through an interface-typed receiver to the interface method — *creating* 104 `Calls` edges across Ama's `Store`/`Indexer`-mediated code that were silently absent before. Dogfood proof that motivated *and* verified it: `find_callees("QueryService.findCallers")` returned **only** `resolve` beforehand (its `this.store.edgesTo`/`getNode` calls had vanished because `store: Store` is an interface) and includes `edgesTo` after. This is the *foundation* of dispatch — calls resolve to the **interface** method; true **virtual dispatch** (fanning a call out to every implementing class's method via `Implements` edges) is a distinct richer feature, filed as a follow-up. Lesson reinforced across the last five iterations: a missing node-kind doesn't merely make symbols unsearchable — it **silently suppresses every edge that would target them**, so `find_callers`/`find_callees` undercount invisibly until the node exists. Self-index (`.`) 404/857/61 → 431/961/63 (+2 files = fixture + analyzer test; +27 nodes; +104 edges). 182 → 185 tests. - 2026-06-16 · typescript-analyzer · **Virtual dispatch fan-out** (ama-436) completes the interface-dispatch story: a `Calls` edge that resolves to an interface method now also reaches *every implementing class's* same-named method. **+132 edges** — even more than the foundation's +104; the two dispatch iterations together added **~236 edges (857→1093)** because Ama's architecture is pervasively interface-mediated (`Store`, `Indexer`, `Analyzer`, each with implementations), so every such call both crosses the interface boundary and fans to its impls. Design: it's a **post-pass** (`resolveDispatch`) over the finished edge list, not part of the AST walk, because it needs the *global* `Implements` + `Defines` structure (which class implements which interface, and each container's methods by name) — unavailable mid-walk. The fan-out edges target concrete class methods (`container.kind === "Class"`), so they never re-fan; no cascade, and the store's edge-dedup (ama-cs4) collapses any duplicate. Applied the cs4 lesson directly: keyed the method lookup with a **nested `Map>`** rather than a `${container}\0${name}` string, sidestepping the NUL-delimiter-makes-Git-treat-the-file-as-binary trap. Verified on real source: `find_callers("InMemoryStore.edgesTo")` now includes `findCallers` (QueryService → `Store.edgesTo` interface call → fanned to the concrete `InMemoryStore.edgesTo`). With this, `find_callers`/`find_callees`/`impact_analysis` finally traverse Ama's interface seams end-to-end — the single biggest accuracy gain of the session for an interface-heavy codebase. Self-index (`.`) 431/961/63 → 434/1093/63 (+3 nodes = fixture class + method; +132 edges = dispatch fan-out). 185 → 187 tests. - 2026-06-16 · typescript-analyzer · **Generic instantiations** (ama-hft.5, instantiations half): type arguments in *expression* position (`f()`, `new Box()`) and heritage (`extends Base`) now emit `UsesType` edges — previously only type args inside *annotations* (`Map` as a field/return/param type) were captured. One branch in `collectTypeUsages` (push `node.typeArguments` for `CallExpression`/`NewExpression`/`ExpressionWithTypeArguments`), reusing the existing `typeReferencesIn` walker. **Honest value read: +24 edges** — an order of magnitude below the dispatch iterations' +100s — which *confirms generics is low self-signal for Ama specifically*: most generic type args referencing Ama's own types already live in annotations (captured), and explicit expression type args are sparse because TS infers them (`new Map(...)` not `new Map<...>()`). The fixture proves correctness; the small real-source delta is just Ama's actual usage pattern. Also fixed a now-stale `collectTypeUsages` doc comment ("until properties become nodes" — properties have *been* nodes since ama-yyo). Scope: instantiations shipped; **type-parameter declarations** as nodes (the `` decls) deferred as genuinely low-value (type params are local and rarely queried) — filed a P3 follow-up. This essentially completes the **high-value** Deep TS semantics work the user steered toward (dispatch was the ~236-edge win; generic-instantiations a correct-but-smaller deepening); the remaining deep-TS tail (type-params, decorators, accessors, method-override, arrow-body attribution) is low-value for Ama's own source. Self-index (`.`) 434/1093/63 → 444/1117/65 (+2 files = fixture + analyzer test; +10 nodes; +24 edges). 187 → 189 tests. - 2026-06-17 · typescript-analyzer · **Get/set accessors as nodes** (ama-dlz, deep-TS tail completeness — user chose to finish the tail): accessors now emit `Property` nodes (`describe()` += `isGetAccessorDeclaration`/`isSetAccessorDeclaration` → `Property`), and `collectTypeUsages` gained `isGetAccessorDeclaration` in its return-type branch so a getter's return type attributes to the accessor (a setter's param was already handled as a `Parameter`). This finishes the "all member kinds are nodes" arc: Method → Property → Accessor. A get/set pair collapses to **one** `Property` node because both share the member name (and thus the symbol id; the store dedups). Dogfood note: `find_callers(describe)` showed the classifier is shared by `visit` (emission) *and* `nodeIdForDecl` (id resolution), so a new `describe` case keeps both consistent — an accessor resolves to the same id whether emitted or referenced. As the user accepted when choosing the tail, this is a pure **completeness** item with **zero self-signal** — Ama's own source uses no accessors, so the +11 nodes / +13 edges are entirely the two new test files; correctness is proven on the fixture, not on Ama itself. Self-index (`.`) 444/1117/65 → 455/1130/67 (+2 files = fixture + analyzer test; +11 nodes, +13 edges — all from the new files; 0 from src). 189 → 191 tests. - 2026-06-17 · typescript-analyzer · **Method override / virtual dispatch** (ama-hft.7, deep-TS tail) is the **symmetric completion** of the interface-dispatch work from two iterations ago, and it cost **~3 lines**: generalize `resolveDispatch` to unify `Implements` *and* `Inherits` into one `subtypes` map and broaden the container check from `Interface` to `Interface || Class`. A call to a base-class method now fans out to subclass *overrides* (via `Inherits`) exactly as an interface call fans to *implementations* (via `Implements`). The reason it's nearly free: both edge kinds already encode "subtype → supertype," and the methods-by-name lookup was already built, so the same loop handles both with no new structure — a signal the graph vocabulary (`Inherits`/`Implements`/`Defines`/`Calls`) sits at the right altitude. Same contract as the interface case: one level deep (direct subtypes), and only the *original* `Calls` edges are fanned (not the new ones), so no cascade — a deeper override chain would need a fixpoint pass (not built; no need yet). Verified the generalization didn't disturb interface dispatch (its tests stayed green). As accepted for the tail, **zero src self-signal** — Ama uses no class inheritance, so the +9 nodes / +15 edges are entirely the two new test files; the fixture (`Base`/`Derived`/`use(b: Base)`) proves `use → Derived.run` fans out. With this, the `ama-hft` "Deeper TypeScript semantics" epic has only decorators (hft.6) left — and Ama uses none. Self-index (`.`) 455/1130/67 → 464/1145/69 (+2 files = fixture + analyzer test; +9 nodes, +15 edges — all from the new files; 0 from src). 191 → 193 tests. - 2026-06-17 · typescript-analyzer · **Arrow-const call attribution** (ama-4nc) completes the arrow-const story (`ama-4s2` made `const f = () => …` a node; this attributes calls *inside* its body to it). One line: add `ts.isVariableDeclaration(child)` to `collectCalls`'s `nextEnclosing` condition — and it's safe because `visit` only registers *function-valued* `VariableDeclaration`s in `declToId`, so the enclosing only switches for those (a plain `const x = 5` isn't in `declToId`). The lesson is the **surprise self-signal**: I expected ~4 edges from the fixture, but got **+17**, because Ama's own *test files* lean on the idiom `const id = (qn) => symbolId({...})` (~10 analyzer tests), and every one of those `symbolId` calls was previously **dropped** — a module-level arrow const had no enclosing symbol, so the call went nowhere. Now `find_callers("symbolId")` picks them all up. So a "completeness, low-value" item turned real because a one-line helper pattern is repeated across the codebase — a reminder that dropped edges from a *common idiom* compound. Behavior-changing but additive in practice (no existing test asserted an arrow-const-body call, so nothing broke). The deep-TS tail is now down to genuinely zero-value items: type-param nodes (ama-355) and decorators (ama-hft.6, Ama uses none). Self-index (`.`) 464/1145/69 → 466/1162/69 (+2 nodes = fixture `helper`/`run`; +17 edges = `run→helper` plus ~13 test-file `id→symbolId` attributions previously dropped). 193 → 194 tests. - 2026-06-17 · mcp · **The dogfooding mandate paid off — a real protocol bug** (ama-1i0). While scouting the CLI epic I ran `get_code_snippet("…#serverStamp")` and it crashed the tool call with MCP `-32602` (invalid content). Root cause chain: `serverStamp` is a non-function `const` so it's *not* a node → `getCodeSnippet` returns `undefined` (correct, per its `Snippet | undefined` contract) → the `json()`/`reply()` helpers did `JSON.stringify(undefined, null, 2)`, which returns the **JS value `undefined`, not the string `"undefined"`** → `content[0].text` became `undefined` → the SDK rejected the `CallToolResult` against its content schema. Every query tool that can return `T | undefined` (`get_code_snippet`, `node`) had this latent crash for any unresolved ref. **Fix:** `JSON.stringify(value ?? null, …)` — coerce only the *nullish* result to `null` *before* stringifying, so it serializes to `"null"`. The TypeScript subtlety worth recording: `JSON.stringify`'s lib type is declared `=> string`, so guarding the *output* (`?? "null"`) is flagged as an unnecessary condition (the type "proves" it can't be undefined) — but at runtime it returns `undefined` for `undefined`/functions/symbols. Guard the *input* instead. This is exactly the bug the loop's "use the real MCP tools, not Read/grep" rule exists to catch — a Read-based workflow never exercises MCP content validation. Verified live: after the fix, `get_code_snippet("serverStamp")` returns a clean `null` (and the server auto-reconnected after the `src/mcp` edit — the 404 fix still working). A pure robustness fix to the MCP layer: self-index flat at 466/1162/69 (no graph change). 194 → 195 tests. (Released the claimed `ama-5gs.1` CLI item to pursue this better-motivated organic find.) - 2026-06-17 · analyzers · **File-node snippets dropped the file header** (ama-d2p), found by continuing the adversarial-probe approach. `get_code_snippet("src/graph/types.ts")` returned the file starting at line 7 — its leading `/** … */` docblock (lines 1-6) was missing. Cause: the File node's `range` is `rangeOf(sf, sf)`, and `rangeOf` uses `node.getStart(sf)`, which **skips leading trivia** (comments + whitespace). That's *correct* for a symbol node — a function's range shouldn't swallow the blank lines/comments above it — but wrong for the File node, which should span the whole file. Fix: `range: { ...rangeOf(sf, sf), startLine: 1 }` (endLine from `rangeOf` is already EOF). `getStart()` vs `getFullStart()` is the relevant TS distinction (former trims trivia, latter doesn't). Verified live: the snippet now opens at line 1 with the header. **Two more bugs fell out of the same probe and were filed, not swallowed:** `ama-o4p` (P3 — `index_repository` on a non-directory leaks a raw `ENOTDIR` instead of a clean message) and, more seriously, `ama-o00` (P2 — a *failed* `index_repository` corrupts the live index: my `README.md` probe wiped `walkFile` from the index until a fresh re-index restored it, implying the persistent store is cleared before the failing file-walk; indexing should be transactional). Adversarial probing of each tool, now that the feature gaps are filled, is the productive vein — three real bugs in one session. A range-value fix: self-index flat at 466/1162/69 (no graph-structure change). 195 → 196 tests. - 2026-06-17 · indexer · **A failed re-index corrupted the live index** (ama-o00) — fixed the standout bug from the probe two iterations back. `Indexer.index()` did `store.clear()` *before* `discoverFiles(root)`, so an invalid root (a file, not a directory) cleared the store and then the walk threw `ENOTDIR`, leaving the index **wiped**. The fix is pure ordering: discover files *first*, so a bad-path walk throws before `createStore()`/`clear()` ever run — a failed re-index becomes a no-op. The instructive part is **why the existing tests never caught it**: they construct a fresh `InMemoryStore` per index, so there's no shared state to corrupt; the damage only manifests with a *persistent, reused* store (the SQLite store `serve:dev` uses). I reproduced it deterministically *without* SQLite or temp files by injecting a **shared `InMemoryStore`** (a `createStore` factory returning the *same* instance) into the session — mimicking the persistent dbPath's reuse, so the failing index's `clear()` wipes the very store the live query reads. That "share one in-memory store" trick is the cheap way to test persistent-store lifecycle bugs in general. Verified live (the originally-corrupting sequence): `index_repository("README.md")` still throws `ENOTDIR`, but `search_symbol("walkFile")` now survives intact. Scope: fixes the common (bad-path / walk-failure) corruption trigger; a failure *during* analysis could still leave a partial store, but that's far rarer (and `ama-o4p`, the raw `ENOTDIR` message, remains a separate P3 — the fix changed timing, not the message). Self-index 466/1162/69 → 466/1164/69 (+2 edges from the test's new imports; no graph-structure change). 196 → 197 tests. - 2026-06-17 · indexer · **Clean error for a non-directory index root** (ama-o4p) closes the bad-path arc with ama-o00: pointing `index_repository` at a file leaked a raw `ENOTDIR: not a directory, scandir '…'` straight from `fs`. Added a guard at the top of `Indexer.index` — `if (!fs.statSync(root, { throwIfNoEntry: false })?.isDirectory()) throw new Error(\`Not a directory: \${root}\`)`. Put it in the **indexer**, not the session, so every caller (the future CLI included) gets the clean error, and it reuses the module's existing `fs` import. The idiom worth noting: `throwIfNoEntry: false` makes `statSync` return `undefined` rather than throwing `ENOENT` for a missing path, so a single optional-chain (`?.isDirectory()`) covers *both* nonexistent and non-directory with no `try/catch`. Together with o00 the bad-path story is whole: a bad root now fails fast with a readable message **and** never touches the live index. Verified live: `index_repository("README.md")` → `Not a directory: …/README.md`. Validation-only change: self-index flat at 466/1164/69. 197 → 198 tests. - 2026-06-17 · mcp · **A capability that existed but was unreachable** (ama-5nt) — a different bug class from the robustness sweep. `QueryService.searchSymbol(query, { limit, kind })` has filtered by node `kind` all along, and `AmaSession.searchSymbol` passes the whole `SearchOptions` through, but the MCP `search_symbol` tool's `inputSchema` only declared `query` + `limit` and the handler only forwarded `{ limit }` — so `kind` was dead, silently dropped by zod's strip-unknown-keys, invisible to any client. The kind of gap you only catch by tracing the *whole* chain (`get_code_snippet` on the query method → the session delegate → the tool registration), not by reading any one layer. Exposed it (`kind: z.enum(NODE_KINDS).optional()`), and added a **runtime `NODE_KINDS` array** to `graph/types.ts` with `NodeKind = (typeof NODE_KINDS)[number]` so the zod enum has a single source of truth and can't drift from the type. Self-consistency check that fell out: `NODE_KINDS` is an *array* const, so it is correctly **not** a graph node (only function-valued consts are, per ama-4s2). Caveat recorded: couldn't verify on the live server — the harness's already-loaded `search_symbol` tool schema predates the new `kind` param, so a live call would validate against the stale schema; `server.test.ts` (a real in-memory client over the *current* server) is the authoritative check. Self-index 466/1164/69 → 466/1166/69 (+2 edges = server.ts's new imports of `NODE_KINDS`/`NodeKind`; no graph-structure change). 198 → 199 tests. - 2026-06-17 · cli · **Started the CLI epic** (ama-5gs.1) — the first new top-level surface (`src/cli/`) since the analyzer work, and the outermost layer (it wraps query/mcp). `run(argv, commands, out, err)` is a pure dispatcher: parses a global `--json` flag, handles `--version`/`--help`/no-args/unknown-command, and routes the rest to a registered `CliCommand`. Two design choices: (1) **`out`/`err` are injectable** (default to `process.stdout`/`stderr`), so the framework is unit-tested by capturing into arrays — no child process, no stream mocking; (2) the "stdout is sacred" rule is **`src/mcp`-only** — the CLI *owns* stdout, so writing results there is correct (the MCP rule exists solely because that stream carries JSON-RPC). Nice scaffolding find: `package.json` already declared `bin: { ama: "dist/cli/index.js" }` pointing at a file that didn't exist, so I named the entry `index.ts` to *fulfill* the pre-declared bin (now valid after build) rather than rename the bin. Reused `serverStamp.version` (build-info) for `--version` instead of re-reading package.json. The framework ships with zero domain commands (`COMMANDS = []`); `index`/`status`/`search`/… are ama-5gs.2+. Verified the bin runs: `tsx src/cli/index.ts --version` → `0.0.1`, `--help` → the usage block. The new surface immediately exercised earlier deep-TS work — `CliCommand.run`/`CliContext.write` (interface method signatures, ama-j0z) and `.json`/`.name`/`.summary` (property signatures, ama-yyo) all became graph nodes on day one. Self-index 466/1166/69 → 479/1185/71 (+2 files = cli entry + test; +13 nodes; +19 edges). 199 → 203 tests. - 2026-06-17 · cli · **First real CLI command: `ama status`** (ama-5gs.3) — the keystone every later CLI command reuses: *open the persisted index from a known path → report*. The load-bearing design choice was picking the right "open" primitive. `AmaSession.open()` **falls back to a full re-index** on a cache miss (correct for a server that must end up serving); the lower-level `Indexer.open()` returns `undefined` instead (correct for a CLI that must stay read-only). Using the session would have made `ama status` silently spend minutes indexing an un-indexed project — so the command calls `Indexer.open()` directly and reports "No index found. Run `ama index`…" on a miss, guarded by an upfront `fs.existsSync(dbPath)` so a status check never *creates* a DB/dir. Two correctness facts, not shortcuts: `pendingSync` is hard-coded `0` (it counts edits queued by a *live watcher's* debouncer; a one-shot CLI has no watcher), and the DB path mirrors `serve:dev` (`AMA_DB` env, default `/.ama/index.db`). TDD shape that worked: the bug-prone core is *rendering* an `IndexStatus` → text vs `--json`, which is pure and was unit-tested across all three branches (indexed / not-indexed / json round-trips verbatim); the persistent-store load got two integration tests (real index into a temp SQLite DB; absent-DB → not-indexed without creating it). **vitest (esbuild, no typecheck) went green while `tsc` caught a real latent crash**: `ServerStamp.revision` is `string | null` (null outside a git repo), so `.slice(0,7)` would throw — fixed with `?.slice(0,7) ?? "unknown"` and a regression test. Live proof the freshness-stamp design holds: the fresh `ama status` process reports HEAD `09db558`, while the long-running `serve:dev` MCP server still reports its startup revision `f14c737` — each process stamps at its own start. Self-index 479/1185/71 → 484/1204/73 (+2 files = command + test; +5 nodes; +19 edges). 203 → 210 tests. - 2026-06-17 · cli · **Index-lifecycle CRUD: `ama init` / `index` / `uninit`** (ama-5gs.2) — the write half of the CLI, completing the minimum-usable pair with last iteration's read-only `status`. The whole feature is *lifecycle glue around one injection point*: `AmaSession.indexRepository` is storage-agnostic, so handing it `() => new SqliteStore(dbPath)` (exactly what `serve:dev` does) makes a CLI build persist to disk with zero new indexing code — `index` just calls it, and because `indexRepository → indexer.index` clears+repopulates an existing store, a rebuild overwrites in place. `init` vs `index` is a *policy* split, not a mechanism one: `index` always rebuilds; `init` refuses when a DB exists. The design choice that paid off in testing: encoding that refusal as a **distinct result** (`{alreadyInitialized:true}`) rather than a rebuilt `IndexStatus` makes "it did NOT rebuild" a one-line assertion (`parsed.alreadyInitialized === true`) — no fragile mtime/spy gymnastics to prove a *non*-action. `uninit` deletes the db plus `-wal`/`-shm` sidecars (SQLite can leave either) and returns whether anything was removed, so it's idempotent and reports honestly on an empty workspace. Refactor that fell out: `dbPathFor` (the `AMA_DB`-or-`/.ama/index.db` resolver) was private in `status.ts`; with four commands now needing it, I extracted it to `src/cli/paths.ts` — one definition, imported by all. Live round-trip via the real bin proved the read/write pair is coherent: `init` (build) → `init` (no-op) → `status` (reads the persisted index) → `uninit` (remove) → `status` ("No index found"). Self-index 484/1204/73 → 491/1224/76 (+3 files = paths + lifecycle + test; +7 nodes; +20 edges). 210 → 217 tests; typecheck + lint clean. - 2026-06-17 · cli · **`ama search` surfaces the query layer** (ama-5gs.5) — the first *read-query* CLI command, and the cleanest proof yet that the layering pays off: `QueryService(store, root)` needs only a store and a root, so the command opens the persisted index read-only (`Indexer.open`, undefined-on-miss — same primitive as `status`/the lifecycle commands), wraps the store in a one-shot `QueryService`, runs `searchSymbol`, and closes — no `AmaSession`, no MCP server, no live process. Two design decisions worth recording. (1) **`undefined` vs `[]` as distinct signals**: `runSearch` returns `undefined` for "no usable index" and `[]` for "indexed, zero matches", so the command can exit 1 with "run `ama index`" in the first case and print "No symbols match" (exit 0) in the second — collapsing them would make a fresh checkout look like a bad query. (2) **`--kind` validates against `NODE_KINDS`**, the runtime array added in ama-5nt — that one list now guards *three* surfaces (the `NodeKind` type, the MCP zod enum, and now the CLI flag parser), so none can drift. The arg parser returns `{error}` rather than throwing, letting the command print usage + exit 1 uniformly (a throw would dump a stack trace). A real framework gap surfaced and was filed, not swallowed: `CliCommand` only gets `ctx.write` (stdout), so search's usage/no-index *errors* currently go to stdout — wrong for a `--json` consumer. Filed as a follow-up (CliContext should expose stderr) rather than expanding this iteration's scope. Live bin confirmed all four paths: hit / `--kind`+`--limit` filter / no-match / missing-query→exit 1. Self-index 491/1224/76 → 502/1257/78 (+2 files = command + test; +11 nodes; +33 edges — the parser's branchy control flow adds more call edges than the thin lifecycle wrappers did). 217 → 229 tests; typecheck + lint clean. - 2026-06-17 · cli · **`ama sync` — incremental index maintenance** (ama-5gs.4) — the cheap counterpart to `index`'s full rebuild, completing the CLI's index lifecycle (build → sync → query → remove). `Indexer.sync(store, root)` is already the engine behind the MCP `sync_index` tool and the connect-time catch-up: it does a size/mtime+hash staleness diff (`isStale`) and only re-walks files that actually changed, then drops files gone from disk, returning `{changed, removed}`. The CLI just opens the persisted store with the now-standard no-fallback primitive (`Indexer.open`, undefined-on-miss — shared verbatim with `status`/`search`), runs `indexer.sync` against it, and closes — no `AmaSession`, because the session's `sync()` only adds count-refresh bookkeeping the CLI doesn't need (it reports `{changed, removed}`, not counts; `ama status` reports counts). The design payoff is that `index`/`sync` are now a clean rebuild-vs-reconcile pair sharing the same open/close shape. TDD note worth recording: the incremental behavior is only *believably* tested by mutating disk between build and sync — I appended to one of two files and asserted `changed` contained exactly the edited file (not the untouched one), deleted the other and asserted it appeared in `removed`, and — the strongest check — searched for a symbol added *after* the build and confirmed `sync` made it queryable, which proves the reconcile actually persisted to the SQLite store rather than just listing paths. `isStale` keys on file *size* first, so even an append within the same mtime-second is detected. Live bin confirmed the full arc: build → no-op ("Already up to date") → edit+delete ("1 changed, 1 removed") → search finds the new symbol → no-index→exit 1. Self-index 502/1257/78 → 507/1277/80 (+2 files = command + test; +5 nodes; +20 edges). 229 → 238 tests; typecheck + lint clean. - 2026-06-17 · cli · **CLI query verbs: `callers` / `callees` / `node` / `explore`** (ama-5gs.6) — wired the graph-navigation query tools through the CLI, and the diff stayed small because the read-pattern finally got extracted. `withQuery(root, q => …)` (new `src/cli/query-runner.ts`) owns the open-persisted-store / build-QueryService / run / close skeleton that `status`/`search`/`sync` had each re-implemented; the four new commands are one-liners over it. `callers`/`callees` differ only by which method they call, so a `refQueryCommand(name, summary, pick)` factory produces both. The subtle bug this design had to dodge: `withQuery` returns `undefined` for "no index", but `node()` *itself* returns `undefined` for "symbol not found" — the two collide. Fix: wrap node's result (`q => ({ view: q.node(ref) })`) so outer-undefined ('no index', exit 1, "run ama index") stays distinct from inner-undefined ('not found', exit 1, "Symbol not found: X"). Same `undefined`-vs-`[]` discipline as search, one level deeper. **The dogfooding mandate paid off again — a real analyzer gap surfaced live.** `ama node renderStatus` reported callers(0) despite three obvious call sites, and tracing it with Ama's own tools (search_symbol('statusCommand') -> []; find_callers('loadStatus') -> []) proved that **object-literal methods aren't analyzed**: every CLI command is `const cmd = { run(){} }`, so none of the `run` methods are nodes and none of their calls are attributed — the call graph silently omits all command logic. Filed P2 (discovered-from 5gs.6, under the hft epic); did not fix (out of scope, non-blocking — the query commands faithfully report what the graph contains). A reminder that 'callers: 0' from Ama currently means 'no captured callers', not 'no callers'. Self-index 507/1277/80 -> 518/1325/83 (+3 files = runner + commands + test; +11 nodes; +48 edges — the factory/withQuery generics pull more call + type edges). 238 -> 253 tests; typecheck + lint clean; live bin verified all four verbs. - 2026-06-17 · cli · **CLI blast-radius: `impact` / `affected`** (ama-5gs.7) — the two directions of "what does changing this touch": `impact ` walks transitive *callers* (reverse-Calls BFS, `QueryService.impactAnalysis`), `affected ` walks transitive *importers* (reverse-Imports BFS, `QueryService.affected`). Both return `GraphNode[]`, so they're thin glue over `withQuery` + the now-shared `nodeLine`/`renderNodeList` (exported `nodeLine` from query.ts so impact's `renderAffected` could reuse it — same one-line-per-node format across every node-listing command). `parseImpactArgs` mirrors `parseSearchArgs`'s return-`{error}`-not-throw convention for `--depth`. TDD highlight: `--depth` is only *meaningfully* tested against a multi-hop chain — the ts-calls fixture's `Service.run → Service.compute → helper` lets `impact helper --depth 1` assert `compute` is present (direct) but `run` is absent (two hops), proving the bound actually bounds the BFS rather than being silently ignored; `affected` needed a real import edge, so a temp project where `b.ts` imports `a.ts` proves changing `a.ts` surfaces `b.ts`. The live run doubled as evidence for last iteration's filed analyzer gap (ama-…, object-literal methods): `ama impact dbPathFor` reports 4 callers but the blast radius is genuinely larger — every `*Command.run` object-literal method that calls `withQuery → dbPathFor` is missing from the graph, so impact *under*-reports until that gap is fixed. A reminder the blast radius is a lower bound today. Self-index 518/1325/83 → 527/1343/85 (+2 files = command + test; +9 nodes; +18 edges). 253 → 266 tests; typecheck + lint clean; live bin verified impact (incl. --depth) and affected. - 2026-06-17 · cli · **`ama files` — and the CLI command surface is complete** (ama-5gs.8) — lists indexed files with an optional path-substring filter (human + --json). This is the first CLI command whose data wasn't already a QueryService method, which forced a small honest decision: rather than reach past the layer and read the store from the CLI, I added a one-line `QueryService.files()` (sorted `store.allFiles()`), keeping `cli → query → store` intact and giving the MCP layer the same accessor for free. The bead asked for "format/filter/depth options", but the store holds a *flat* set of repo-relative paths — there is no tree to give depth to — so I shipped list + filter and deferred tree-rendering rather than fake a `--depth` that the data model can't honor (noted in the close). The filter is a bare positional (`ama files cli/commands`), case-insensitive substring — ergonomic and matched exactly 7 command modules on the live run. TDD: 8 tests (renderFiles units incl. empty/filtered/json round-trip; integration over a temp project with a subdir proving both full-list and `sub`-filter; no-index→exit 1). With this the CLI is feature-complete: init/index/sync/status/search/callers/callees/node/explore/impact/affected/files/uninit — 13 commands, all sharing dbPathFor + withQuery + the no-fallback open. Self-index 527/1343/85 → 532/1357/87 (+2 files = command + test; +5 nodes; +14 edges; the new QueryService.files adds one method node + a few edges). 266 → 274 tests; typecheck + lint clean; live bin verified. - 2026-06-17 · analyzers · **Object-literal methods now captured — call-graph holes closed** (ama-zkr) — the gap dogfooding surfaced two iterations back: `const X = { run() {…} }` produced no node for `X.run` and attributed its body's calls to nothing, so every CLI command (all object literals) was invisible to the call graph. Root cause was one early `return` in `visit`: `describe()` returns undefined for an object-literal-valued const (no object NodeKind), and visit bailed *before recursing*, so the `run` MethodDeclaration inside was never reached. The fix is three small, surgical changes — and the diagnosis (read `visit`/`describe`/`collectCalls` via Ama) showed most of the machinery already existed: (1) in `visit`, when a const's initializer is an ObjectLiteralExpression, recurse into its properties with the var name as prefix (so members become `X.member`); (2) `describe` already classified `MethodDeclaration` as Method (method shorthand worked the instant visit recursed) — added a case so a function-valued PropertyAssignment (`{ p: () => … }`) is also a Method; (3) `collectCalls`'s `nextEnclosing` already handled `isMethodDeclaration`, so shorthand attribution was automatic — added `isPropertyAssignment` so arrow-valued props attribute too. The object const *itself* stays a non-node (there's no object kind, and adding one would ripple through NODE_KINDS/schemas for little gain); only its callable members become nodes. Validated live (the analyzer edit bounced tsx-watch, and the new counts prove the running server reloaded): `find_callers(renderStatus)` went 0 → 3 (indexCommand.run, initCommand.run, statusCommand.run), and `find_callers(loadStatus)` 0 → 1. Scope boundary filed as follow-up: object literals in *argument* position (e.g. the MCP server's `registerTool(name, schema, async handler)` arrow handlers) still attribute their calls to the enclosing function rather than a per-handler node — milder (calls are captured, just coarsely) and a separate shape. Self-index 532/1357/87 → 550/1425/89 (+2 files = fixture + test; +18 nodes; **+68 edges** — the edge jump dwarfing the file delta is the holes filling: ~13 command run-methods plus their now-attributed calls). 274 → 280 tests; typecheck + lint clean; no regressions across 40 test files. - 2026-06-17 · cli · **CLI diagnostics go to stderr — and a self-inflicted import cycle** (ama-7c4) — commands previously wrote usage/"no index"/not-found messages via `ctx.write` (stdout), corrupting a `--json` consumer's stream. Added an optional `error?(line)` to `CliContext` and an `emitError(ctx, line)` helper that routes to stderr when present, else falls back to stdout; `run()` now supplies `error: err`, so the real CLI always uses stderr while direct `command.run(args, { write })` unit calls keep working unchanged (optional, not required — making it required would have broken ~35 existing test call sites for zero production gain). The instructive part: my first cut put `emitError` *in `index.ts`* and had commands `import { emitError }` from there — which flipped the command→entrypoint edge from **type-only (erased) to a runtime value import**, forming a cycle (index.ts imports every command to build COMMANDS; commands now imported a value back). Symptom: `COMMANDS.map(c => c.name)` threw `Cannot read properties of undefined` — loading a command first re-entered index.ts mid-evaluation, so the COMMANDS array captured `undefined` for the in-flight command (classic ESM TDZ-in-cycle). This is exactly why the command files had always imported `type CliCommand` (erased) rather than any value from index.ts. Fix: move `emitError` to a leaf module `src/cli/emit.ts` that only `import type { CliContext } from "./index.js"` — the type import is erased, so emit.ts has zero runtime imports and no cycle forms. Validated live: `ama search` with no args now prints usage to stderr with stdout empty. Compounding win: `find_callers(emitError)` returns all 8 command run-methods — visible only because last iteration (ama-zkr) started capturing object-literal methods; two iterations chained to make the call graph whole. Self-index 550/1425/89 → 553/1443/90 (+1 file = emit.ts; +3 nodes; +18 edges = emitError's calls from each command). 280 → 282 tests; typecheck + lint clean; no regressions. - 2026-06-17 · store · **search_symbol now matches qualified names** (ama-drt) — found by dogfooding: `search_symbol("QueryService.searchSymbol")` returned [] despite the symbol existing and the rest of the API (find_callers etc.) happily accepting dotted refs — because `searchByName` matched only the *simple* name. Fix: match the query as a case-insensitive substring of the qualified name too, in both stores, kept at parity by the shared store contract (the new contract test runs against InMemory + SQLite, so it forced both implementations). InMemory: add `|| qualifiedName.includes(needle)` to the scan and the exact-match-first sort. SQLite was the interesting half: the FTS5 index only holds the single-token `name`, so `QueryService.searchSymbol` sanitizes to two tokens the name index can't satisfy — so I keep the fast FTS prefix match on name and UNION a `lower(qualified_name) LIKE '%q%'` substring pass (deduped, capped). New capability falls out for free: searching a *container* name now surfaces its members (`search "Service"` → Service + its methods), which is why one server.test assertion flipped from `[]` to `[compute, run]` — I updated it because the old assertion encoded the now-obsolete name-only contract, and the new store-contract test codifies the intended behavior. Verified live (store edit bounced tsx-watch): `QueryService.searchSymbol` and `statusCommand.run` both resolve now — the latter is a node only because of last-batch's object-literal fix (ama-zkr) and searchable only because of this one; the two compound. The deeper lesson: a shared cross-implementation *contract test* is what keeps two backends honest — one new test, both stores forced to comply. Self-index flat at 553/1443/90 (store-internal change; the local `collect` arrow and the arg-position `it()` callback aren't captured as nodes). 282 → 284 tests (new contract test ×2 backends; one server assertion updated); typecheck + lint clean. - 2026-06-17 · cli · **CLI query-verb parity, and a deliberate non-decision on decorators** (ama-q8n; ama-hft.6 deferred) — this iteration started by probing for more deep-analyzer gaps and finding them *exhausted*: Implements/find_interfaces, UsesType through generics (Promise, unions), barrel re-export importers, and qualified-name search all resolve correctly on Ama's own source. That's a success signal — the self-exercised deep gaps are closed. I picked up decorators (ama-hft.6) next but, tracing the AST handling via Ama, found it's a genuine design tangle, not a clean fix: method-call-form decorators (@log()) are *incidentally* captured as Calls (the method is an enclosing scope in collectCalls), class decorators are missed (a ClassDeclaration isn't in nextEnclosing), and bare-identifier decorators (@Injectable) are missed everywhere — and the right edge kind (Calls vs UsesType vs a new Decorates) is a semantic judgment, not exercised by Ama's own source. Rather than commit an ambiguous model unattended, I released ama-hft.6 with the full finding recorded for a human-in-the-loop decision. Pivoted to a clean, low-risk completion: the CLI exposed callers/callees/node/explore/impact/affected/search/files but not the other QueryService relationship verbs, so I added implementations/interfaces/importers/imports/type-users/types-used — all single-ref → GraphNode[], so they drop straight into the existing refQueryCommand factory (six one-liners). The CLI now has full query-verb parity with the MCP tools. Lesson re-learned: don't assume API shapes — my first test asserted importers('greeter.ts')→file and imports('dep.ts')→file, both wrong; verifying live showed find_importers(SYMBOL)→files and find_imports(FILE)→symbols, so I corrected the assertions to the real (correct) semantics. The 6 new command consts are NOT graph nodes — `const x = refQueryCommand(...)` is a call-valued const, not function-valued (per ama-4s2), so self-index barely moved. Self-index 553/1443/90 → 555/1447/91 (+1 file = test; +2 nodes; +4 edges). 284 → 291 tests; typecheck + lint clean; live bin verified. - 2026-06-17 · cli · **`ama search-code` — symbol-aware body grep** (ama-xou) — the last clearly-distinct query verb missing from the CLI: `ama search` matches names, but `QueryService.searchCode` (find symbols whose *body text* contains a string) had no CLI equivalent. Added `ama search-code [--limit N]` — its own small arg loop (join terms as the query, parse --limit) rather than the refQueryCommand factory, since it takes a free-text query not a symbol ref; reuses withQuery + renderSearch. Validated live: `ama search-code throwIfNoEntry` → Indexer + Indexer.index (both bodies mention it). Compounding note: this iteration's `searchCodeCommand.run` is itself a new graph node (+1) with its calls attributed (+7 edges) — object-literal methods (ama-zkr) keep paying off as the CLI grows. Process note worth recording honestly: this iteration's *search* for work was longer than the work itself — I probed several capabilities (Implements, generic UsesType, qualified-name search, search_code-as-tool) and confirmed the deep analyzer + query layers are mature with the self-exercised gaps closed; investigated decorators (ama-hft.6) and deferred it as a design decision; and settled on this clean CLI completion. The remaining backlog is now genuinely design-laden (decorators edge semantics, arg-position handler attribution) or large/risky-unattended (tree-sitter breadth epic with a native dep, true .gitignore-glob support) — a different mode than the quick dogfood-fix cadence, better with human direction. Self-index 555/1447/91 → 556/1454/91. 291 → 293 tests; typecheck + lint clean; live bin verified. - 2026-06-17 · analyzers · **Decorator usage edges, modelled as UsesType** (ama-hft.6) — implemented after the user delegated the edge-kind decision. Chose UsesType (decorated symbol → decorator), not Calls or a new Decorates kind: a decorator is an annotation/metadata dependency, the same shape as a type annotation, so it reuses an existing edge + queries (find_type_users(Component) = "what's decorated by @Component?") with zero ripple to EdgeKind/SQLite schema/MCP tools, and works uniformly for call-form (@log()) and bare (@sealed) decorators (Calls would be awkward — bare decorators aren't syntactic calls, and the decorated symbol doesn't really call the decorator). Three changes: (1) collectTypeUsages emits a UsesType edge per ts.getDecorators(node), resolving the decorator expression (its `.expression` when call-form) via a new resolveValueRef (symbol→decl→declToId/nodeIdForDecl, alias-following — same shape as resolveImport); (2) collectCalls now SKIPS decorator subtrees, because call-form method decorators were *incidentally* captured as Calls (the method is an enclosing scope) — a confirmed-by-test bug that made @log() look like the method calling log; skipping also correctly keeps decorator-argument calls (config, not behaviour) off the decorated symbol; (3) the class-decorator case (previously missed entirely — a ClassDeclaration is not a collectCalls enclosing scope) now works. Parsing needs no experimentalDecorators: TS5 standard class/method decorators parse under the existing ES2022 program, and type-checking correctness is irrelevant to symbol resolution. Not exercised by Ama's own source (no decorators) — validated by a fixture test AND live by pointing the MCP server at tests/fixtures/ts-decorator: find_type_users(sealed)→Widget, find_type_users(log)→Widget.render, no spurious Calls. A dedicated Decorates edge kind remains a possible future refinement if decorator-vs-type-usage needs disambiguating. Self-index 556/1454/91 → 564/1468/93 (+2 files = fixture + test, +8 nodes incl. resolveValueRef; no decorator edges in Ama itself). 293 → 296 tests; typecheck + lint clean; no regressions across 42 files. - 2026-06-17 · cli · **Per-command help (`ama --help`)** (ama-w6z) — found by dogfooding the bin: `ama --help` listed commands, but `ama search --help` errored ("unknown flag: --help", exit 1) and `ama callers --help` was worse — it treated `--help` as the symbol ref and ran a query for a symbol literally named "--help". Universally-expected feature, silently broken. Fixed in the framework's `run()`: after resolving the command, intercept `--help`/`-h` in its args and print `ama ` plus an optional usage line, returning 0 *before* dispatching (so help can never be misread as input). Added an optional `CliCommand.usage` string; set it on the parametric commands (search/search-code/impact/node/explore/affected + the refQueryCommand factory covering all 8 ref verbs), while simple positional commands (status/index/sync/…) fall back to name+summary — still correct, never erroring. The design choice that kept it a ~10-line framework change: intercept centrally rather than make each command handle `--help` (every command would have had to special-case it, and several already mis-parsed it differently). Self-exercised and validated live: `ama search --help` → usage, `ama callers --help` → usage, `ama status -h` → summary fallback, all exit 0. Note this was a deliberately-small pick: with the deep-analyzer/CLI/store gaps closed and decorators shipped, the remaining backlog is large/dependency-laden (tree-sitter breadth) or explicitly deprioritized (type-param nodes, ama-355 — its own bead says "low-value, pick up only if a concrete need arises"), so I chose a real, clean, expected UX fix over grinding a deprioritized item. Self-index 564/1468/93 → 565/1469/93 (framework-internal; no new files). 296 → 298 tests; typecheck + lint clean. - 2026-06-17 · cli · **`ama node` now shows the source snippet** (ama-awa) — the NodeView query already collects `snippet` (the symbol's verbatim source) and `--json` returned it, but the human `renderNodeView` dropped it, showing only caller/callee/dependent counts. The whole point of `node ` is a one-stop detail view, so it now prints the source between the node line and the relationships (full snippet — a single-symbol view should show the code you asked about). One-line render change; data was already there. Found by dogfooding the bin (`ama node dbPathFor` listed relationships but no source). Self-index flat at 565/1469/93 (render-only). 298 → 299 tests; typecheck + lint clean; live bin verified. - 2026-06-17 · baseline · **Tree-sitter parsing primitive (the breadth foundation)** (ama-s8q.1) — first step of the baseline-tier epic: a lazy, cached parsing primitive over WASM `web-tree-sitter` (no native build), `src/analyzers/baseline/treesitter.ts` exposing `parse(language, code) → Tree` and `supportedLanguages()`. Runtime init is one-shot; each grammar wasm is loaded once and cached per language. The load-bearing lesson is an **ABI match**: the first attempt paired `web-tree-sitter@0.26` (latest) with `tree-sitter-wasms@0.1.13` (latest) and every grammar load died in Emscripten `failIf` with an *empty* error message — because tree-sitter-wasms builds its grammars with `tree-sitter-cli ^0.20.8` (ABI ~v13) while the 0.26 runtime expects a newer ABI. tree-sitter-wasms tops out at 0.1.13 (0.20-era grammars), so the fix is to pin the RUNTIME down to match the only available prebuilt grammar bundle: `web-tree-sitter@0.20.8` + `tree-sitter-wasms@0.1.13` (the exact version its grammars were compiled against). The 0.20.8 API differs (`export =` default import, `Parser.Language.load`, `new Parser()`, non-null `parse`) — fine under the repo's esModuleInterop. Two robustness notes: pass Emscripten `locateFile` to point init at the package's `tree-sitter.wasm` (so it works regardless of cwd — vitest/dev-loop/CLI), and resolve grammar wasms via `require.resolve('/package.json')` + join (package `exports` can block direct subpath resolution). Not yet wired into the AnalyzerRegistry — that's the baseline analyzer framework (s8q.2); this iteration is the de-risked parsing primitive, validated by parsing Python and JavaScript. Audit note: the 5 high-severity advisories are pre-existing in the vitest/esbuild dev toolchain (breaking vitest@4 to fix) — the new tree-sitter deps add zero. Self-index 565/1469/93 → 572/1480/95 (+2 files, +7 nodes). 299 → 303 tests; typecheck + lint clean (also auto-fixed a pre-existing format drift in tests/store/contract.ts that a scoped lint had missed). - 2026-06-17 · baseline · **Baseline analyzer framework (spec-driven, tier `baseline`)** (ama-s8q.2) — built on the s8q.1 parsing primitive: `BaselineAnalyzer` implements the same `Analyzer` interface as the deep TS analyzer, but is *language-agnostic*, driven by a `LanguageSpec` (language, extensions, grammar key, and a CST-node-type → `{kind, nameField}` map). It parses each file with tree-sitter and walks the CST's named children, emitting a File node plus a node per recognized symbol — with a dotted qualified name for nesting (a `def` inside a `class` becomes `Greeter.hello`) and a `Defines` edge from the enclosing symbol (or the file). One instance handles one language, fitting the registry's one-analyzer-per-extension routing. Scope is deliberately syntactic — files + the symbols they define, tier `baseline`; calls/types/imports are deep-tier and out of scope (the tier is reported honestly so baseline coverage is never mistaken for deep). Key design payoff: the framework carries *zero* language knowledge — adding Python or JS is just a `LanguageSpec` (the next beads, s8q.3/.8), so the per-language analyzers become data, not code. The CST-walk maps cleanly onto the TS analyzer's `visit()` shape (prefix + containerId threaded through recursion), and tree-sitter's `childForFieldName("name")` + 0-based `startPosition.row` (→ +1 for the graph's 1-based lines) are the two integration details. NOT yet registered in createDefaultIndexer — that's s8q.3 (ship a real Python spec + wire it in), which is what makes Ama index `.py` live; this iteration is the validated generic engine. Self-index 572/1480/95 → 589/1523/97 (+2 files = framework + test; +17 nodes; the .py fixture is correctly NOT indexed, since no analyzer claims .py yet). 303 → 307 tests; typecheck + lint clean. - 2026-06-17 · baseline · **Ama goes multi-language: Python at baseline tier** (ama-s8q.3) — the payoff of the breadth epic. Shipped `src/analyzers/baseline/python.ts` (a `LanguageSpec`: `.py`, grammar `python`, `function_definition→Function`, `class_definition→Class`) and registered `new BaselineAnalyzer(pythonSpec)` in `createDefaultIndexer` — two small additions, since the framework (s8q.2) is the engine. The Indexer needed no changes: it already groups files by `registry.forFile` and `await`s `analyze`, so an async tree-sitter analyzer slots in beside the sync TS one, and per-language coverage falls out of the existing loop. Live proof: `index_repository('.')` now reports `typescript/deep` (99 files) AND `python/baseline` (1 file), and `search_symbol('Greeter')` returns the Python class tagged `tier: baseline` right next to TypeScript's `Greeter` (deep) — honest tiering in one result set. Methods surface as Functions qualified under their class (`Greeter.hello`), since Python doesn't distinguish them syntactically — correct for a baseline tier. Two scoping notes that kept it clean: the self-index *gate* indexes `src/` (all .ts), so it's unaffected — only the whole-repo live index picks up the .py fixture; and baseline emits only File+symbols+Defines (no calls/types), so it can't be mistaken for deep coverage. Adding the next language (JS/JSX s8q.8, Go, etc.) is now just another `LanguageSpec` — data, not code. Self-index (whole repo) 589/1523/97 → 596/1533/100 (+1 .py file now indexed, +Python symbols). 307 → 308 tests; typecheck + lint clean. - 2026-06-17 · baseline · **JavaScript/JSX baseline — third language, pure data** (ama-s8q.8) — adding JS was exactly the "data, not code" payoff the framework promised: a `LanguageSpec` (src/analyzers/baseline/javascript.ts: `.js/.jsx/.mjs/.cjs`, grammar `javascript`, function/generator/class declarations + `method_definition`) plus one `register` call. Zero framework or indexer changes. The spec mechanism captured a real per-language nuance: JS distinguishes methods syntactically (`method_definition`), so `Calc.square` is a **Method** node — whereas Python's `def` is `function_definition` everywhere, so its methods are Functions. Same engine, different data, correct results for each language. Confirmed no routing conflict first: the deep TS analyzer claims `.ts/.tsx/.mts/.cts` (not `.jsx`), and there are no non-ignored `.js` files in the repo, so the new spec only picks up the js-basic fixture. Live: `index_repository('.')` now reports THREE languages — typescript/deep (101), javascript/baseline (1), python/baseline (1) — and `search_symbol('Calc')` returns the JS class + method tagged `tier: baseline`. tree-sitter-javascript handles JSX in the same grammar, so `.jsx` is covered for free. Self-index (whole repo) 596/1533/100 → 603/1541/103 (+2 .ts files = spec + test, +1 .js fixture). 308 → 309 tests; typecheck + lint clean. Remaining epic languages (Go/Rust/Java/C#, s8q.4-.7) are each now one more LanguageSpec. - 2026-06-17 · baseline · **Java baseline — fourth language (picked for clean framework fit)** (ama-s8q.6) — added Java ahead of Go/Rust in the bead order, on purpose: Java gives every kind its own CST node type (class_declaration/interface_declaration/enum_declaration/method_declaration) with methods nested in class/interface bodies, so it maps directly through the current `{nodeType → kind}` spec with honest kinds (an interface is an `Interface`, not a mislabeled `Class`) and clean qualified nesting (`Sample.square`). Go and Rust were deferred because their named types share one CST node (Go `type_spec`, distinguished only by a child like `struct_type`/`interface_type`), which needs a small "kind-by-child" discriminator in the framework — a generalization worth doing deliberately rather than mislabeling Go interfaces as Class. Mechanics: added `java → tree-sitter-java.wasm` to the grammar registry, a `javaSpec` LanguageSpec, one register call. Live: `index_repository('.')` now reports FOUR languages — typescript/deep (103) + java/javascript/python baseline. Recipe per clean-fit language is now fixed: grammar-registry entry + LanguageSpec + register + fixture/test. Self-index 603/1541/103 → 611/1550/106 (+2 .ts = spec + test, +1 .java fixture). 309 → 310 tests; typecheck + lint clean. Next: a framework "kind-by-child" enhancement unlocks Go/Rust honestly (s8q.4/.5); C# (s8q.7) is another clean-fit spec. - 2026-06-17 · baseline · **C# baseline — fifth language** (ama-s8q.7) — another clean-fit `LanguageSpec` via the fixed recipe (grammar-registry entry `csharp → tree-sitter-c_sharp.wasm`, a `csharpSpec`, one register call). Like Java, C# gives each kind a distinct CST node type, so class/interface/enum/method map directly with honest kinds and clean nesting (`Sample.Square`). The two graph-model approximations are explicit: `struct_declaration` and `record_declaration` → Class (the graph has no struct/record kind — both are named types with members, so Class is the honest closest). Live: `index_repository('.')` reports FIVE languages — typescript/deep (105) + csharp/java/javascript/python baseline. The clean-fit languages (TS-deep aside: Java, C#, JS, Python) are now done; what remains is Go (s8q.4) and Rust (s8q.5), which need the framework's one missing capability: discriminating a symbol's kind by a child node (Go `type_spec` → struct_type/interface_type; Rust has distinct items but impl-block method nesting needs handling). That's the next deliberate framework step rather than another pure spec. Self-index 611/1550/106 → 619/1559/109 (+2 .ts spec+test, +1 .cs fixture). 310 → 311 tests; typecheck + lint clean. - 2026-06-17 · baseline · **Go baseline + a "kind-by-child" framework discriminator** (ama-s8q.4) — the first baseline language that needed more than the plain `{nodeType → kind}` map, so it earned a small framework generalization. Go declares every named type with one CST node (`type_spec`) regardless of body, so `SymbolRule` gained an optional `kindByChild: Record`: the analyzer scans the symbol node's named children and uses the first match (else the rule's fallback `kind`). Go's spec: `type_spec` → fallback TypeAlias, `kindByChild: { struct_type: Class, interface_type: Interface }`. Live proof of honesty: `search_symbol('Circle')` (a struct) → Class and `search_symbol('Shape')` (an interface) → Interface, both at baseline tier, both from the same `type_spec` — exactly the mislabeling the discriminator exists to avoid. The enhancement is additive (rules without `kindByChild` are unchanged — `kindFor` falls straight through), so the four existing languages were untouched. Known baseline limitation, noted: Go methods are top-level `method_declaration`s with a receiver (not nested in the type), so `Area` surfaces as a Method named for the function, not qualified as `Circle.Area` — acceptable for a syntactic tier. This same discriminator now unlocks Rust (s8q.5: distinct struct_item/enum_item/trait_item, but impl-block method nesting is the next wrinkle). Live: SIX languages — typescript/deep (107) + csharp/go/java/javascript/python baseline. Self-index 619/1559/109 → 629/1574/112 (+2 .ts spec+test, +1 .go fixture; the framework change is render-neutral). 311 → 312 tests; typecheck + lint clean. - 2026-06-17 · baseline · **Rust baseline — seventh language; planned set complete** (ama-s8q.5) — Rust is a clean-fit pure spec (distinct struct_item/enum_item/trait_item/function_item nodes → Class/Enum/Interface/Function), no discriminator needed. The one wrinkle the RED test caught: a bodyless trait-method declaration (`fn area(&self) -> f64;`) is a `function_signature_item`, not a `function_item` — so the first cut missed `Shape.area`. Adding `function_signature_item → Function` captured trait method signatures (and extern-block fns) — the TDD failure pointed straight at the grammar nuance. Trait methods nest under the trait (`Shape.area`); `impl`-block methods are top-level (impl isn't a container), same syntactic-tier caveat as Go. With Rust, the breadth epic's planned languages are all in — SEVEN now: typescript/deep + csharp/go/java/javascript/python/rust baseline. The arc from s8q.1 (de-risk tree-sitter) → s8q.2 (spec-driven framework) → six language specs validates the deep-vs-baseline bet end to end: one deep semantic analyzer (TS) plus a tiny generic engine that turns any tree-sitter grammar into honest syntactic breadth, each language ~15 lines of data. Remaining s8q work is P3 long-tail (C/C++ s8q.9, Ruby/PHP s8q.10, tracking s8q.11). Self-index 629/1574/112 → 638/1584/115 (+2 .ts spec+test, +1 .rs fixture). 312 → 313 tests; typecheck + lint clean. - 2026-06-17 · graph · **Route node + References edge model — framework-awareness foundation** (ama-rme.1) — first step of route→handler awareness, focused on the TS/Node battle test. Added a `Route` NodeKind (e.g. "GET /users") and a `References` EdgeKind (route → handler symbol), plus two QueryService traversals mirroring callers/callees: `findHandlers(route)` (route → References → handlers) and `findRoutes(symbol)` (incoming References → routes). The schema absorbed both with ZERO migration: EdgeKind is only ever a string in storage (no exhaustive switch anywhere — verified), so a new edge kind is free; and because NodeKind is derived from the runtime NODE_KINDS array (ama-5nt), adding "Route" there automatically extended the MCP search_symbol kind filter (z.enum(NODE_KINDS)) too — `search_symbol(q, {kind:"Route"})` works for free. getGraphSchema tallies kinds dynamically, so it picks up Route/References without change. Validated with a synthetic graph (a handler + a Route node + a References edge, queried both directions) since Ama itself isn't a web app — framework awareness is fixture-validated throughout, like the baseline languages. This is foundation only: no producer yet emits Route nodes (that's rme.2: Express app.METHOD / router.METHOD + NestJS @Controller/@Get decorators), and inline Express handlers (arg-position arrows) need ama-y9q to become referenceable nodes — both are the next iterations. Self-index 638/1584/115 → 643/1614/116 (+1 test file, +5 nodes, +30 edges; no Route data in Ama). 313 → 315 tests; typecheck + lint clean. - 2026-06-17 · analyzers · **Express route detection — routes → handlers (TS/Node value)** (ama-rme.2) — a `collectRoutes` pass in the deep TS analyzer, emitting a `Route` node per `app.get("/x", handler)` / `router.post(...)` and a `References` edge to each named handler (resolved via the same `resolveValueRef` the decorator pass uses). The hard part is precision, not detection: `.get`/`.all` are everywhere (Map.get, headers.get, SQLite `stmt.get`/`.all`), so the guard is a conjunction — an HTTP-verb method name AND a `/`-prefixed string-literal first arg AND ≥1 handler-shaped arg (arrow/function/identifier). Verified against Ama's own source: zero spurious routes despite its many `stmt.get`/`stmt.all` SQL calls (no `/`-path arg). Pivoted here from the generic `y9q` (arg-position handlers) on purpose — a blanket "function-arg → node" rule is noise (every `it(\"…\", () => {})` and `.map(cb)`), whereas route detection is properly scoped, so the inline-handler nodes belong in the framework detector, not a global pass. Scope this iteration: named handlers get a References edge; inline arrow handlers get a Route node but no edge yet (anonymous-handler naming is the deferred follow-up). Detection lives in the deep TS analyzer (.ts/.tsx); JS Express apps would need a tree-sitter route detector later. NOT yet queryable via MCP/CLI — findHandlers/findRoutes are QueryService methods (rme.1); exposing them as tools/commands is the immediate next step (otherwise the edges exist but no agent can read them). Validated: unit test (Route nodes + References edge to the named handler, exactly 2 routes, no false positives) + live (search_symbol surfaces the Route nodes). Self-index 643/1614/116 → 650/1628/118 (+2 files = fixture + test). 315 → 318 tests; typecheck + lint clean. - 2026-06-17 · mcp · **Route queries exposed — route awareness is now usable** (ama-7d9) — wired QueryService.findHandlers/findRoutes (rme.1) through to both read surfaces: MCP tools find_handlers (route → handlers) and find_routes (handler → routes), plus CLI commands `ama handlers`/`ama routes`. Pure wiring over tested machinery — AmaSession delegates (mirror findCallers), registerTool blocks (mirror find_callers, with a `route` param for find_handlers), and the refQueryCommand factory for the CLI. End-to-end now: an agent can ask "what handles GET /users" and a human can `ama routes listUsers`. Live (CLI bin): `ama handlers "GET /users"` → listUsers, `ama routes listUsers` → GET /users. The MCP tools are validated by server.test.ts (a real in-memory client over the current server), NOT by this session's harness — newly-registered tools aren't in the harness's tool registry (the ama-5nt lesson: server.test is authoritative for new/changed tool schemas). This completes the route→handler trio for the TS/Node battle test: rme.1 (model) → rme.2 (Express producer) → this (exposure). Remaining route work: inline-arrow handler edges (Express handlers written inline, currently a Route with no edge) and NestJS decorators. Self-index 650/1628/118 → 654/1640/119 (+1 test file; +2 delegate methods). 318 → 323 tests (2 MCP + 3 CLI); typecheck + lint clean. - 2026-06-17 · analyzers · **Inline route handlers — node + edge + body-call attribution** (ama-gpe) — closed the big Express coverage gap: `app.post("/x", (req,res) => {…})`. The inline arrow becomes a route-named Function node ("POST /users handler", range = the arrow), the Route References it, and — the real value — its body's calls attribute to it. The mechanism is two small, composable moves: (1) collectRoutes now registers the handler arrow in declToId and runs BEFORE collectCalls (so the arrow is a known symbol when calls are walked); (2) collectCalls' nextEnclosing learned to treat an arrow/function-expression as enclosing — but only when it's in declToId (the existing `childId && (...)` guard), so ordinary callbacks (.map, .then, it("…", cb)) stay transparent and only route handlers attribute their bodies. That childId guard is what makes a potentially-noisy change (every arrow becomes a scope) precise. Validated live: `ama handlers "POST /users"` → the inline handler node, and `ama callees "POST /users handler"` → audit (the local fn the handler calls) — so route → inline handler → what it does is fully traceable, matching named-handler routes. Multiple inline handlers on one route are disambiguated by a numeric suffix. No regression from reordering the passes or the nextEnclosing change (the self-index gate's call-edge assertions still hold). This generalizes the y9q concern: the same arrow-in-declToId trick could later attribute registerTool-style handlers. Self-index 654/1640/119 → 656/1643/119 (fixture gained the audit fn + inline handler node). 323 → 325 tests; typecheck + lint clean. - 2026-06-17 · analyzers · **NestJS routes — decorator-based, completing Node framework coverage** (ama-w0k) — added to collectRoutes a class branch: a `@Controller("prefix")` class whose methods carry `@Get`/`@Post`/... decorators yields a Route per decorated method, with the route path = controller prefix joined to the method path (joinRoutePath normalizes slashes: `@Controller("users")` + `@Get()` → "GET /users"; + `@Post(":id")` → "POST /users/:id"). The handler is the decorated METHOD, already a Method node from walkFile, so the References edge just points at declToId.get(member) — no synthesis needed (unlike Express inline arrows). Reused two existing pieces: the Route/References model (rme.1) and syntactic decorator access (ts.getDecorators, the decorator pass ama-hft.6) — a small decoratorInfo helper reads the decorator's callee name + first string arg. Decorators parse syntactically under the ES2022 program without experimentalDecorators (names read off the AST, no symbol resolution). Now both Node web frameworks are covered through one collectRoutes pass: Express (call-based, app.get(path, handler)) and NestJS (decorator-based, @Controller+@Get). Validated live both directions: `ama handlers "POST /users/:id"` → UsersController.create, `ama routes UsersController.findAll` → GET /users. This completes route awareness for the TS/Node battle test (model → Express named+inline → NestJS → MCP/CLI exposure). Self-index 656/1643/119 → 666/1657/121 (+2 files = fixture + test). 325 → 327 tests; typecheck + lint clean. - 2026-06-17 · analyzers · **Express Router mount-prefix composition** (ama-8t1) — `router.get("/users")` mounted via `app.use("/api", router)` now composes to "GET /api/users", not the bare "GET /users". A cross-file mount pre-pass (collectMounts) runs over all files before route detection, finding `X.use("/prefix", …args)` and mapping each arg's value declaration → prefix (via valueDeclOf, which follows import aliases — so a router defined in routes.ts and mounted in app.ts still composes). Then collectRoutes resolves the route receiver (the obj in obj.METHOD) to its declaration and prepends any mount prefix through joinRoutePath. The ordering matters (mounts before routes, all files first) — a per-file pass couldn't see a cross-file mount. Harmless over-capture: every identifier arg after the prefix string is mapped, so middleware gets a prefix entry too, but middleware has no route calls so it never matters. Verified no regression: the existing ts-express fixture's `app` isn't mounted, so its routes stay unprefixed. Validated live: `ama handlers "GET /api/users"` → listUsers; `ama routes listUsers` → GET /api/users. Noted limitations: a router mounted at multiple prefixes (last wins), nested mounts (one level), router reassignment. TS/Node route awareness is now thorough: Express (named/inline/router-mounted) + NestJS, both directions, both surfaces. Self-index 666/1657/121 → 673/1669/123 (+2 files). 327 → 329 tests; typecheck + lint clean. - 2026-06-18 · analyzers · **Inline callback-argument handlers — per-tool MCP attribution** (ama-y9q) — delivered the generalization ama-gpe foresaw ("the same arrow-in-declToId trick could later attribute registerTool-style handlers") while heeding the warning rme.2 raised against doing it bluntly ("a blanket function-arg → node rule is noise — every `it(\"…\", () => {})` and `.map(cb)`"). New `collectCallbackHandlers` pass (runs after collectRoutes, before collectCalls) synthesizes a Function node for an arrow/function-expression passed as an argument to a *string-named* call, keyed by that leading string literal ("index_repository handler"), and registers it in declToId — so collectCalls' existing arrow-in-declToId rule re-attributes the body's calls to it for free (same two-move mechanism as gpe). The non-obvious discriminator that dodges the rme.2 noise: **synthesize only when the registration call's result is consumed — i.e. its parent is NOT a bare ExpressionStatement.** That single check separates a handler-producing wrapper `tap("name", fn)` (consumed → passed to registerTool → becomes a node) from a fire-and-forget test block `it("name", fn)`/`describe(...)` (statement, result discarded → stays transparent). This matters precisely because the index includes all 55 *.test.ts files (discovery only skips dotfiles + IGNORED_DIRS), so a naive rule would mint a node per test case. Reality diverged from the issue's pseudocode: it imagined a direct arrow to `registerTool(name, cfg, arrow)`, but the real server wraps every handler — `registerTool("index_repository", {…}, tap("index_repository", async ({path}) => json(await session.indexRepository(path))))` — so the live arrow is `tap`'s 2nd arg and `tap`'s 1st arg is the tool-name string. Live proof on the running server (it auto-reloads source): `search_symbol("index_repository handler")` → the synthesized node at server.ts:116; `find_callees("index_repository handler")` → `json` + `AmaSession.indexRepository` — the two calls that previously leaked into the 323-line `createServer`. Three handlers synthesized in server.ts (index_repository / index_status / sync_index — the direct-arrow tools). Honest limitation, filed ama-63x: the other ~21 tools wrap one level deeper in `queryTool(session, arrow)` whose first arg is the identifier `session`, not a string, so they aren't keyed (still attribute to createServer). Out-of-scope find filed ama-xim: pre-existing `noExplicitAny` lint errors in the committed ts-express fixtures (biome.json doesn't exclude tests/fixtures) — my own files are lint-clean. Self-index 673/1669/123 → 697/1708/125 (+2 files = fixture + test; the per-handler Function nodes + enclosing-References edges lift node/edge counts repo-wide). 329 → 334 tests; typecheck + lint clean (changed files). - 2026-06-18 · analyzers · **Callback handlers dug through wrapper calls — all 21 MCP tools attributed** (ama-63x) — the immediate follow-up to ama-y9q, which only caught *direct*-arrow registrations (`tap("index_repository", () => …)`, 3 of 21 tools). The other 18 wrap their handler one level deeper — `tap("search_symbol", queryTool(session, ({query}) => session.searchSymbol(…)))` — where the arrow is `queryTool`'s 2nd arg and `queryTool`'s 1st arg is the identifier `session`, not a string, so y9q's "first arg is a string literal" gate skipped them. The realization that kept the fix small: **no walk-*up* to `registerTool` is needed** (as the filed issue speculated). `tap` is *already* the value-position string-named call for every tool; I only had to dig *downward* from it through wrapper-call arguments. New `collectHandlerArrows(args)` recurses through call-argument positions to collect handler arrows but **stops at the first function in each branch** — an arrow's body is its own scope, so a `.map`/`.then` callback *inside* a handler is never mistaken for another handler, and a wrapper's non-call args (`session`) carry nothing. The y9q value-position gate is unchanged and still does the test-noise exclusion (`it`/`describe` are bare statements). Result: server.ts handler nodes 3 → 21, one per registered tool; live proof `find_callees("search_symbol handler")` → `AmaSession.searchSymbol` (previously leaked into the 323-line createServer, now pinned to the per-tool node). The whole MCP surface is now per-tool traceable — `find_callers`/`impact_analysis` on any `session.*` method see the precise tool that drives it. Self-index 697/1708/125 → 719/1733/125 (no new files — fixture/test were extended in place; +18 server handler nodes + their enclosing-References edges, +2 fixture nodes). 334 → 337 tests; typecheck + lint clean (changed files). - 2026-06-18 · query/mcp · **file_skeleton — a file's outline + dependents in one call** (ama-m8k.5) — the on-mission query: replace "read the whole file to understand it" with one structured answer. `QueryService.fileSkeleton(ref)` returns the resolved File node, its symbols in source order (filter `allNodes` by `file`, exclude the File node itself, sort by `range?.startLine`), and the files that depend on it; delegated through `AmaSession` and exposed as the MCP tool `file_skeleton` (mirrors the `node` tool wiring — registerTool + tap + queryTool). **The dogfood catch that justified the whole exercise:** the unit test passed with a *file-level* import edge, but running the live tool on `src/query/service.ts` returned `dependents: []` — obviously wrong for a heavily-imported module. Root cause: `collectImports` points `Imports` edges at the *imported declaration* (`session.ts → service.ts#QueryService`), not the file node, so `findImporters(fileNode)` only catches `import *`/`export *` star-imports and misses every named import. Fix (TDD'd by switching the fixture edge to target a symbol — RED — then aggregating): dependents = the union of importers across the file node **and all its symbols**. Live re-verify after the fix: `file_skeleton("src/query/service.ts")` → 50 symbols + 8 real dependents (cli commands, session.ts, tests). Lesson banked: a green unit test whose fixture doesn't mirror the real edge shape is a false negative — run the tool on Ama's own source before believing it. The new tool isn't in this session's harness tool registry (registered after MCP connect; the ama-5nt lesson — server.test.ts is authoritative), so it was validated by server.test.ts + a raw Streamable-HTTP session against the live server. CLI parity (`ama skeleton`) deferred as a follow-up. Self-index 719/1733/125 → 729/1772/126 (+1 test file; +query/tool nodes and the dependents-aggregation edges). 337 → 343 tests (5 query + 1 server); typecheck + lint clean (changed files). - 2026-06-18 · cli · **`ama skeleton` — CLI parity for file_skeleton** (ama-ya6) — closes the loop ama-m8k.5 opened: the file-skeleton query was reachable over MCP but not the CLI, so a human at a terminal still couldn't get a file's outline cheaply. Pure pattern-reuse, no new machinery: `skeletonCommand` mirrors `nodeCommand` exactly, including its one subtlety — wrapping the result as `{ skeleton: query.fileSkeleton(ref) }` so `withQuery`'s outer `undefined` ("no index → run ama index") stays distinct from `fileSkeleton`'s own `undefined` ("file not found"), two different exit-1 messages that would otherwise collapse. `renderFileSkeleton` follows `renderNodeView`'s shape (one `nodeLine` per symbol + a `dependents (n): …` summary), and registration is two array insertions in `src/cli/index.ts`. The reused `nodeLine`/`names`/`withQuery`/`emitError` helpers meant the command body is ~20 lines. Dogfood proof on the real bin (separate persisted SQLite store from the MCP server, so it had to be indexed first): `ama skeleton src/query/service.ts` → "50 symbol(s), 9 dependent(s)" with the full outline, and `--json` agrees (50/9). The dependents count is 9 here vs the 8 m8k.5 saw over MCP because the CLI indexed the *current* tree, which now includes this iteration's `skeleton.test.ts` importing `FileSkeleton` from service.ts — a nice incidental confirmation that the symbol-level dependents aggregation (m8k.5's bug fix) tracks new importers. Validated by tests/cli/skeleton.test.ts (json output, human render, not-found exit code, registration) + the live bin. Self-index 729/1772/126 → 733/1785/127 (+1 test file; +command/render nodes and edges). 343 → 347 tests; typecheck + lint clean (changed files). - 2026-06-18 · analyzers · **Module-level variables become nodes — +134 to the self-index** (ama-hft.12) — the biggest single fidelity jump of the loop. Only function-valued consts were nodes (ama-4s2); plain `const NODE_KINDS = […]` / `const ROUTE_METHODS = new Set(…)` / `const MAX_RETRIES = 3` were invisible — `get_code_snippet("NODE_KINDS")` literally returned `null` (the gap, ironically, in the very array that defines the node kinds). Fix is tiny and rides existing seams: add `"Variable"` to the `NODE_KINDS` array (one line — and because `NodeKind = (typeof NODE_KINDS)[number]`, the type, the MCP `search_symbol` kind enum, and the `get_graph_schema` census all extend for free — the ama-5nt/rme.1 dividend), then one case in `describe()` returning `{ kind: "Variable" }` for a `VariableDeclaration` with an identifier name. The two exclusions are what keep it precise: function-valued initializers are matched *earlier* in `describe` (stay `Function`), and object-literal initializers are excluded so `visit`'s existing member-recursion still runs and the object const itself stays a non-node — preserving the ama-zkr rule (verified: `config` not a node, `config.run` still a Method). Because `visit` only descends into top-level / class-member / object-member declarations (never function bodies), locals never leak in. One deliberate, correct side effect: a plain-value var is now in `declToId`, so `collectCalls` treats it as the enclosing scope for its initializer — `const x = compute()` now attributes `compute()` to `x` instead of dropping it (module level had no enclosing before). Exactly one existing test broke — `typescript-arrow`'s "does not emit a node for a non-function const", which encoded the *old* ama-4s2 limitation; updated to assert `NOT_A_FN` is now a `Variable` (a behaviour-change test edit, not a regression). The self-index gate's call-edge assertions held. Live proof: `get_code_snippet("NODE_KINDS")` now returns its source; `search_symbol("ROUTE_METHODS")` → Variable; `search_symbol("DEFAULT_DEBOUNCE_MS", kind:"Variable")` → the new kind filter works. Scoped to *nodes* only — the "who references MAX_RETRIES" read-edge half (a careful identifier-resolution pass with self-ref/member-name/type-position exclusions) is filed as a follow-up. Self-index 733/1785/127 → 867/1976/129 (+2 fixture/test files; **+134 Variable nodes** across src+tests, +191 Defines/init-Calls edges). 347 → 352 tests; typecheck + lint clean (changed files). - 2026-06-18 · analyzers · **Variable read-references — "who references MAX_RETRIES" answered** (ama-6k0) — the deferred usage half of hft.12. New `collectVarReferences` pass mirrors `collectCalls`' enclosing-tracking and emits a `References` edge from the enclosing symbol to a Variable node each time the var's value is read. The headline lesson is how much the *resolution* filters for free, so the pass stays ~15 lines: targets are restricted to the `variableIds` set (so reads of functions/classes don't become References — those are Calls/UsesType), and three would-be false positives self-eliminate — a property-access member name (`x.has`) resolves to a library member not in `declToId`; an import specifier sits at module top level so there's no enclosing symbol; and a declaration's *own* name resolves to the very symbol that encloses it, caught by the pre-existing `to !== enclosingId` guard. Only one explicit exclusion was needed (the member side of `a.b`). Edge dedup is free too — `InMemoryStore.addEdge` keys on (from,to,kind), so N reads of one const from one function collapse to a single edge. The UsesType clause was a one-liner: add `ts.isVariableDeclaration` to `collectTypeUsages`' annotation set, so `const TIMEOUT: Millis = …` emits `TIMEOUT → Millis`. Live proof: `find_routes("DEFAULT_DEBOUNCE_MS")` → `AmaSession.watch` (its sole reader). That call exposes a real **ergonomics gap, filed as follow-up**: the `References` edge kind is shared with routes, so the only query that surfaces variable referrers is the route-named `findRoutes`, and the intuitive `find_callers` (which walks `Calls`) returns `[]` for a constant — the edges exist but no agent would think to ask `find_routes` about a number. Self-index 867/1976/129 → 870/2078/129 (+3 fixture nodes — Millis + TIMEOUT + …; **+102 References/UsesType edges** = constant reads across src now graphed). 352 → 355 tests; typecheck + lint clean (changed files). - 2026-06-18 · query/mcp/cli · **find_referrers + node.referrers — the References edges become discoverable** (ama-pfm) — ama-6k0 wired the "who reads this constant" edges but left them unreachable by intuition: `find_callers` walks `Calls` so it returns `[]` for a variable, and the only surface for incoming `References` was the *route-named* `findRoutes`. The fix is mostly a rename-and-generalize plus surface parity. `findReferrers(ref)` (incoming `References` sources) becomes the primitive; `findRoutes` shrinks to a one-line delegate to it (they were always the identical traversal — `findRoutes` is just the route-domain framing) — so no duplicated code, only a renamed concept. Then full parity, each piece a known pattern: a `referrers` field added to the `node()` aggregate + `NodeView` + `renderNodeView` (the highest-leverage change — `node` is the catch-all an agent already reaches for), an `AmaSession.findReferrers` delegate, a `find_referrers` MCP tool whose description explicitly says *use this when find_callers returns nothing because reads aren't calls*, and a one-line `refQueryCommand("referrers", …)` CLI command. The `NodeView` shape change rippled to exactly two `renderNodeView` unit tests whose `VIEW` literal predated the field — adding `referrers: []` fixed them (a shape-change edit, not a regression). Live proof on the running server via the *registered* `node` tool (no reconnect needed): `node("DEFAULT_DEBOUNCE_MS")` → `referrers: [AmaSession.watch]` sitting right next to `callers: []` — the same call shows both the answer and why the old reflex (`find_callers`) missed it. With this, the hft.12 → 6k0 → pfm arc is complete: module-level constants are nodes, their reads are edges, and "who uses X" is answerable three intuitive ways. Self-index 870/2078/129 → 879/2109/130 (+1 query test file; +tool/command/method nodes and edges). 355 → 360 tests; typecheck + lint clean (changed files). - 2026-06-18 · query/mcp/cli · **circular_imports — Tarjan SCC over the file graph, and it caught a real cycle in Ama** (ama-m8k.7) — a new analysis capability: `circularImports()` builds the file-level import graph and returns each strongly-connected component of ≥2 files via Tarjan's SCC. The forward adjacency reuses the file-resolution trick file_skeleton taught: an `Imports` edge points at the imported *symbol*, and a File node's id *is* its path, so `getNode(edge.to).file` is directly the imported file's adjacency key (the mirror of the existing `fileImporters`). Surfaced with full parity — `AmaSession` delegate, a no-arg `circular_imports` MCP tool, and a no-arg `cycles` CLI command (mirroring `filesCommand`). **The dogfood headline: on its very first run against Ama's own source it found a genuine cycle I didn't know existed** — `src/cli/index.ts ↔ commands/query.ts ↔ emit.ts` (alongside the deliberate test fixture). Tracing it taught a precision nuance worth a follow-up: the edges that *close* that SCC are `import type` (CliCommand, CliContext) — erased at runtime, so the runtime graph is actually an acyclic chain. `collectImports` models type-only and value imports identically, so the tool can't yet separate a runtime cycle from a type-erased one (filed: tag Imports edges type-only vs value). Detection itself is correct — the edges exist as modeled — and the fixture (`a.ts ↔ b.ts`, a real value cycle) proves it in isolation. Implementation notes: recursive Tarjan, bounded by file count (fine for any realistic module graph); deterministic output via sorted components and sorted files within each. Self-index 879/2109/130 → 899/2162/135 (+5 files = 2 cycle fixtures + 3 query/cli files; +tool/command/method nodes and edges). 360 → 367 tests (3 query + 1 server + 3 cli); typecheck + lint clean (changed files). - 2026-06-18 · analyzers/query · **Type-only imports get their own edge — circular_imports goes runtime-precise** (ama-bhf) — closes the precision gap m8k.7's dogfood exposed: the CLI "cycle" it found was closed entirely by `import type` edges, erased at runtime. New `ImportsType` edge kind for type-only imports/exports; `collectImports` chooses it via `importClause.isTypeOnly` (whole `import type {…}`), `specifier.isTypeOnly` (`import { type X }`), and the export equivalents (`export type {…}` / `export { type X }`). Adding the EdgeKind was free again — it's just a stored string, no schema/migration (the recurring rme.1 dividend). The one design call that matters: **who counts type-only imports?** Since Ama runs `verbatimModuleSyntax` (so a large share of its imports are `import type`), dropping type-importers from dependents would gut `find_importers`/`file_skeleton`/`affected`. So the split is asymmetric — `findImporters`/`findImports`/`fileImporters` count *both* kinds (a type import is still a compile-time dependency), via two tiny `importEdgesTo/From` helpers that centralize "import-like = Imports + ImportsType", while `circularImports` (through `fileImports`) stays `Imports`-only, making it runtime-precise *for free* — it already filtered to `Imports`, so the moment type edges stopped being `Imports`, the type-only cycles vanished from it with no change to `circularImports` itself. TDD leverage: adding a type-only cycle (`c.ts ↔ d.ts`) to the existing `ts-cycle` fixture turned m8k.7's own integration tests into the RED→GREEN driver (2 cycles before, 1 after). Live confirmation: `circular_imports` on Ama now reports only the value-cycle fixture — the iteration-8 CLI cycle is correctly gone, Ama's real runtime layering is clean. Self-index 899/2162/135 → 914/2190/139 (+4 files = 2 type-cycle fixtures + 2 tests; Imports edges split into Imports/ImportsType, same total). 367 → 372 tests; typecheck + lint clean (changed files). - 2026-06-18 · query · **Search filter DSL — path:/kind:/lang:/name: ride the query string** (ama-m8k.3) — scoped search ("find all Classes under src/store") without touching a single tool or CLI schema, because the filters are parsed *out of the query string* rather than added as params: `search_symbol` and `ama search` both gained the power for free, since the DSL travels through the existing `query` arg. `parseSearchQuery` tokenizes `key:value` / `key:"quoted value"` / bare words, routing `path`/`kind`/`lang`/`name` to filters and — the subtlety that keeps it robust — keeping an *unknown* `key:value` (a `http://…` URL) as free text instead of dropping it. `searchSymbol` then branches: free text hits the relevance-ordered name index (fetched unbounded, then filtered and sliced so a filter can't be starved by a pre-filter top-50 cut), while a *filters-only* query (no name term, e.g. `path:src/store kind:Class`) scans `allNodes`. `lang:` resolves through a tiny inline extension→language map in the query layer — deliberately *not* importing the analyzer registry (the authoritative source) to keep `graph→store→query` layering intact; the 13-entry constant is presentation metadata, with the known tradeoff that a newly-registered language must be added here too. TDD caught my own bad assumption: I expected `searchSymbol("User kind:Function")` → `[]`, but "User" is a substring of "getUser" so the name index correctly matched the getUser Functions — the substring match was right, the test was wrong (fixed to `getUser kind:Class`). Live on Ama: `collect path:analyzers kind:Method` → the 8 `collect*` analyzer passes; `path:src/store kind:Class` → InMemoryStore + SqliteStore. Self-index 914/2190/139 → 927/2220/140 (+1 test file; +parser/interface nodes & edges). 372 → 381 tests; typecheck + lint clean (changed files). - 2026-06-18 · indexer · **File-size cap on the discovery walk — and the dogfood found a constructor blind spot** (ama-m8k.8) — a real consistency bug: the 1 MB cap was enforced only by the watcher (`stat.size > maxFileSizeBytes`), not the initial `discoverFiles` walk, so a minified bundle got indexed on first build but skipped on every re-index (plus a first-index memory risk). The fix's home picked itself — `ignore.ts`'s own doc comment already declared the invariant ("Shared by the indexer's file discovery and the file watcher so the set of files we *watch* matches what we *index*"), so the cap belongs exactly there: a new `MAX_FILE_SIZE_BYTES` constant that `discoverFiles` checks (one `statSync` per file, vanished-file-safe) and the watcher now imports instead of its private `ONE_MB` — one source of truth, the watch/index sets reconciled. **The dogfood payoff was a *second* bug, found by using the tools earlier iterations built**: `search_code` surfaced the cap constant as a `Variable` node (iteration 5), but `find_routes`/`find_referrers` on it returned `[]` — no readers, impossible since the watcher reads it. The cause: it's read only inside `FileWatcher`'s *constructor*, and `describe()` has no `ts.ConstructorDeclaration` case, so constructors aren't nodes and their bodies' calls/refs attribute to nothing (filed as a follow-up). A genuine call-graph blind spot the variable-reference feature exposed by its own absence. Test is an integration check (a >1 MB `.ts` blob is excluded from `index()`); self-index is intentionally flat (Ama has no oversized files — the win is the invariant, not a count). Self-index 927/2220/140 → 928/2224/141 (+1 test file). 381 → 382 tests; typecheck + lint clean (changed files). - 2026-06-18 · analyzers · **Constructors become nodes — closing the blind spot the last iteration found** (ama-vz8) — a two-iteration discover-then-fix arc: m8k.8 noticed a module constant had *no readers* (`find_routes` → `[]`) although the watcher reads it, because the read lived in a constructor and `describe()` had no `ts.ConstructorDeclaration` case — so constructors weren't nodes and their bodies' calls/refs/param-types were dropped at the class boundary. The fix is three small edits, all riding existing seams: a `describe()` case mapping a constructor to a `Method` named "constructor" (qualified `Cls.constructor`, unique since a class has one), and adding `ts.isConstructorDeclaration` to the `nextEnclosing` guard — applied *uniformly* to all three passes that track an enclosing scope (`collectCalls`, `collectVarReferences`, `collectCallbackHandlers`) so a constructor encloses its body consistently. `collectTypeUsages` needed no change: once the constructor is in `declToId` it descends into it, so constructor *parameter types* (the DI dependencies of every `constructor(private readonly store: Store, …)`) now emit UsesType for free. The dogfood verification is the satisfying part — the exact query that exposed the gap now answers correctly: `find_routes("MAX_FILE_SIZE_BYTES")` returns `discoverFiles` **and** `FileWatcher.constructor` (was `[]`), and the iteration-10 search DSL (`constructor path:src/mcp kind:Method`) finds `AmaSession.constructor`. The compounding tooling found the bug by an absence and confirmed the fix by a presence. Self-index 928/2224/141 → 946/2250/143 (+2 fixture/test files; ~16 constructor nodes across Ama's classes + their previously-lost body wiring edges). 382 → 385 tests; typecheck + lint clean (changed files). - 2026-06-18 · query · **Relevance ranking for search — source over test noise** (ama-m8k.2) — `search_symbol` results were dedup'd but otherwise unranked (the store's exact-first/alphabetical order), so an exact match in a throwaway test fixture sat level with the real definition. Added a `scoreSymbol(node, text)`: match quality dominates (exact name/qualified-name 100 > prefix 60 > substring 30 > qualified-only 12), a brevity bonus (`16 - name.length`) favours the more specific shorter name, a small kind bonus lifts top-level definitions over members/variables, and — the highest-signal term for a test-heavy repo — a −50 demotion for test/generated files (`/tests?/`, `.test.`/`.spec.`, `.d.ts`, `.generated.`, `dist/build/coverage`). `searchSymbol` now collects *all* filtered candidates, scores, sorts, then slices — so the best matches survive the limit rather than the first-found (this composes with the iteration-10 unbounded-then-filter change; ranking simply replaced the early `break`). Query-layer only, no return-shape change, so the MCP/CLI surfaces inherit it untouched and every existing search test stayed green (they assert membership/sorted-sets, not order). Live proof on Ama: `search_symbol("node")` returns `AmaSession.node` / `QueryService.node` (src Methods) on top while the `node` helper Functions declared in ~6 *test* files — also exact "node" matches, and with a *higher* kind bonus — are demoted out of the top results; the −50 is exactly what flips src above test. Scoped to search; the low-confidence marker (needs a `{results, lowConfidence}` shape change across surfaces) and ranking for the find_* relationship results are filed as follow-ups. Self-index 946/2250/143 → 952/2269/144 (+1 test file; +scorer nodes/edges). 385 → 388 tests; typecheck + lint clean (changed files). - 2026-06-18 · graph/store · **Edge provenance — tier honesty at the edge level** (ama-m8k.1) — a heuristically-detected route `References` edge looked identical to a checker-resolved `Calls` edge; now `GraphEdge.provenance?: "resolved" | "heuristic"` (absent ⇒ resolved) records which. The tagging is per-emission-site, not per-kind — `References` is *mixed*: `collectVarReferences` resolves via the checker (resolved), while `collectRoutes` and `collectCallbackHandlers` synthesize from pattern matches (heuristic), so only those ~4 `edges.push` sites carry the flag and everything else defaults resolved. The SQLite migration is the load-bearing detail: a new nullable `provenance` column in `CREATE TABLE` covers fresh DBs, a guarded `try { ALTER TABLE edges ADD COLUMN }` covers reopened old files (idempotent only via the catch — `ADD COLUMN` throws if present), and a `SCHEMA_VERSION` 1→2 bump makes `indexer.open` reject stale indexes so they re-index with real provenance — three mechanisms for one additive column, none of which drop data. In-memory needed nothing (it stores edge objects). The unique index stays `(from,to,kind)`, so `INSERT OR IGNORE` dedup is unchanged and provenance is first-write-wins (a given triple has one derivation). Surfaced minimally and honestly via `get_graph_schema`, which now reports an edgeProvenance census. Live on Ama: **2266 resolved / 42 heuristic** edges — the 42 are exactly the route→handler refs and the `tap("name", …)` MCP-tool callback-handler refs, the only edges Ama can't fully stand behind. Per-relationship surfacing (provenance inside find_*/node results) + the optional confidence field are filed as a follow-up; the issue's acceptance (provenance on edges, both stores persist, route/synth=heuristic, checker=resolved, one tool surfaces it) is met. Self-index 952/2269/144 → 964/2308/146 (+2 test files). 388 → 393 tests; typecheck + lint clean (changed files). - 2026-06-18 · query · **Relationship-result ranking — source relationships over test ones** (ama-bc2) — completes m8k.2's other half: the `find_*` relationship queries returned dedup/insertion order, so a function's source callers were interleaved with its test-file callers. A tiny `rankNodes(nodes)` reuses iteration-13's `scoreSymbol` with an *empty* query — so only the query-less terms apply (kind bonus lifts top-level defs; the −50 test/generated demotion sinks test files), tie-broken alphabetically — applied at the return of the ten node-returning relationship methods (callers/callees/referrers/handlers/implementations/interfaces/importers/imports/type-users/types-used). Deliberately *not* applied to the transitive worklists (`affected`, `circularImports`, the private `fileImporters`) where order is BFS/dedup-driven, not relevance. No return-shape change, so the MCP/CLI surfaces inherit it and every existing relationship test stayed green (they assert membership/sorted-sets). The scorer built once in iteration 13 now powers both search *and* relationships — the same 30-line function, two call sites. Live on Ama: `find_callers("symbolId")` lists its 5 real source callers (`visit`, `collectRoutes`, `nodeIdForDecl`, …, all in src/analyzers) first, then ~30 test-file `id`/`sym` helper callers demoted below — a clean source-first grouping that insertion order scattered. Self-index 964/2308/146 → 967/2326/147 (+1 test file). 393 → 394 tests; typecheck + lint clean (changed files). - 2026-06-18 · graph/store · **Edge source-location — call-site line/column on Calls edges** (ama-hft.9) — a Calls edge said "Y calls X" but not *where*; now `GraphEdge.at?: {line, column}` records the call site (1-based), set in `collectCalls` via a one-line `locationOf(child)` (the call/new node's `getStart` through `getLineAndCharacterOfPosition`, +1 each). The whole iteration was a near-mechanical replay of m8k.1's additive-edge-column pattern — the second edge field in two iterations — which is the real lesson: the migration shape is now a known quantity, so two `at_line`/`at_column` columns dropped into `CREATE TABLE`, two more guarded `ALTER`s in the same idempotent loop (refactored from one to a list), the INSERT/EdgeRow/rowToEdge round-trip, and a `SCHEMA_VERSION` 2→3 bump took minutes, not the deliberation m8k.1 did. Dedup is unchanged (the `at` rides the same first-write-wins `(from,to,kind)` row), so a multi-call edge carries its *first* site — exactly the "representative location, per-call-site is future" the issue scopes. Verified end to end: the analyzer test pins `main → helper` to `{line: 6, column: 10}` in the ts-calls fixture (column 10 = past ` return ` — eight chars of indent+keyword+space), and both stores round-trip it. Deliberately foundation-only: no query surfaces `at` yet (find_callers returns nodes, not edges), so the self-index is flat-by-design on counts and the payoff is the precise-impact substrate — the read side is filed as a follow-up (and shares an edge-aware result shape with ama-4ky's provenance surfacing). Self-index 967/2326/147 → 979/2362/149 (+2 test files only; the `at` field doesn't change counts). 394 → 397 tests; typecheck + lint clean (changed files). - 2026-06-18 · query/mcp · **find_callers/find_callees surface the call site — hft.9 made visible** (ama-2i1) — the previous iteration put `at` on edges but nothing read it; now `findCallers`/`findCallees` return `EdgeNeighbor { symbol, at?, provenance? }` instead of bare nodes, so an agent sees *who* calls *where*. The shape change rippled exactly as predicted — and the prediction was the design: every node-centric consumer (the `node` view's callers/callees, the CLI `pick` lambdas, `explore`'s relationships) collapses to `.map(c => c.symbol)`, keeping `NodeView`/CLI on `GraphNode[]`, so only the two MCP tools and the direct `findCallers` tests see the richer type. `tsc` did the bookkeeping: it flagged `explore()` (which I'd forgotten also builds caller/callee lists) before any test ran — a reminder that a shape change is best made type-first. Twelve existing tests asserting `findCallers(…).map(n => n.name)` updated to `.symbol.name` — pure mechanical ripple, the cost of the surface. `EdgeNeighbor` carries `provenance` too (always absent for Calls, since calls are checker-resolved), so the same shape is ready to surface route/referrer heuristics for ama-4ky without another redesign. Live, self-referentially: `find_callers("locationOf")` → `{ symbol: collectCalls, at: {line: 242, column: 74} }` — the location feature pointing at its own call site. Self-index 979/2362/149 → 988/2391/150 (+1 test file). 397 → 399 tests; typecheck + lint clean (changed files). - 2026-06-18 · query/mcp · **find_routes/find_handlers/find_referrers surface provenance — m8k.1 made visible** (ama-4ky) — the mirror of 2i1, and the close of the edge-metadata surfacing arc: where 2i1 put call-site *location* on the caller/callee queries, this puts edge *provenance* on the route/referrer queries, reusing the very same `EdgeNeighbor { symbol, at?, provenance? }` shape designed for it last iteration — zero redesign, just three more methods returning it. The asymmetry across the two iterations is the point: `Calls` edges carry `at` (interesting) but are always resolved (boring), while `References` edges carry no `at` but are *mixed* — `collectVarReferences` resolves via the checker, `collectRoutes`/`collectCallbackHandlers` synthesize heuristically — so provenance is exactly the signal worth surfacing here. `findReferrers` is the primitive (`findRoutes` delegates), `findHandlers` the outgoing-direction twin; `node().referrers` and the three CLI `pick` lambdas project `.symbol` to stay `GraphNode[]`. `tsc` was clean first try (the explore() lesson held — referrers/handlers don't feed it). Seven shape-change test updates, same mechanical `.map(n => n.name)` → `.symbol.name`. Live on Ama (which has no web routes, but every MCP tool is a synthesized callback handler): `find_routes("index_repository handler")` → `{ symbol: createServer, provenance: "heuristic" }` — the agent sees that createServer's reference to the handler is pattern-matched, not checker-backed. With 2i1 + 4ky, both edge foundations (provenance from m8k.1, location from hft.9) are now agent-visible through one shape. Self-index 988/2391/150 → 991/2407/151 (+1 test file). 399 → 402 tests; typecheck + lint clean (changed files). - 2026-06-18 · cli/query · **affected --tests — test-impact mode ("which tests to run for this diff")** (ama-5gs.9) — a deliberate change of flavour after a long edge-metadata run: `affected(files)` already walks the transitive import closure, so test-impact is just a filter — `affected(files, { testsOnly })` keeps only the test files, surfaced as `ama affected --tests` and the MCP tool's `testsOnly` param. The filter reuses iteration-13's test-detection: I factored an exported `isTestFile` out of `isDeprioritizedFile` (the ranking demotion), so search-demotion and test-impact share one definition of "a test file" — DRY across two features that arrived months apart. No stdin plumbing needed for the CI use case after all: `git diff --name-only | xargs ama affected --tests` works via shell substitution, so the flag *is* the feature (stdin/glob are minor follow-ups). The non-obvious snag was test infrastructure, not code: the fixture had to be *named* `core.test.ts` to exercise `isTestFile`, but `tests/**/*.test.ts` is exactly vitest's include glob, so vitest tried to run the fixture as a (suite-less, failing) test. Fixed at the root — `exclude: [...configDefaults.exclude, "tests/fixtures/**"]` in vitest.config — which is correct regardless: fixtures are analyzer *input*, never tests, and now a fixture can be named anything. Live: `ama affected --tests src/query/service.ts` lists ~35 test files (every query/cli/store/indexer test that reaches service.ts) — the real "what to run for this change" answer. Self-index 991/2407/151 → 1006/2437/156 (+5 files = 2 tests + 3 fixture). 402 → 406 tests; typecheck + lint clean (changed files). - 2026-06-18 · analyzer/graph · **First-class Instantiates & Overrides edges — un-conflating relationships** (ama-hft.11) — Ama folded `new X()` into `Calls` and method overrides into the dispatch fan-out, so "who instantiates Foo" was inseparable from "who calls Foo". Two new edge kinds fix it. **Instantiates** is a one-line split in `collectCalls` (`isNewExpression(child) ? "Instantiates" : "Calls"`), and the separability is delivered by a new `via: EdgeKind` field on the `EdgeNeighbor` shape (built across iterations 17-18): `find_callers`/`find_callees` now union both kinds and label each, so `find_callers(Foo)` still shows constructions — now tagged `via: "Instantiates"` — *no regression*, full separability. That union is why **not a single existing test broke** from the Calls→Instantiates change: every consumer that depended on "construction shows in find_callers" still works. **Overrides** (subtype.method → supertype.method) fell out almost free: `resolveDispatch` already builds the `subtypes` and `methodsByContainer` maps for call fan-out, so a ~10-line loop over them — match method names across each super/sub pair — emits the edges, independent of any call. Both EdgeKind additions are storage-free strings (the recurring dividend). Returns (the issue's *optional* third) and a per-relationship Overrides query are filed as follow-ups; the census surfaces Overrides for now. Live on Ama: `get_graph_schema` reports **Instantiates: 42, Overrides: 43** as distinct from Calls: 723, and `find_callers("QueryService")` lists its four source constructors (runSearch, withQuery, AmaSession.indexRepository/.open) labelled `via: "Instantiates"` with call-site `at` — Instantiates + location + ranking composing in one result. Self-index 1006/2437/156 → 1014/2501/158 (+2 test files; +43 Overrides edges, Instantiates re-labelled from Calls). 406 → 411 tests; typecheck + lint clean (changed files). - 2026-06-18 · query/mcp/cli · **find_overrides / find_overridden_by — making last iteration's Overrides edges queryable** (ama-38n) — the Overrides edges existed (43 in Ama) but only in the schema census; this surfaces them per-relationship. Pure pattern-reuse: the two directions are exact mirrors of `find_callees` (outgoing — what X overrides) and `find_callers` (incoming — what overrides X), both returning the `EdgeNeighbor` shape via the same `neighbor`/`rankNeighbors` helpers, so each new query method is ~8 lines and each surface (AmaSession delegate, MCP tool, CLI `refQueryCommand` one-liner) follows the established mould. The whole iteration added zero new mechanisms — it's the dividend of a graph model where a relationship is just an edge kind and a query is just a directional `edgesFrom`/`edgesTo` over it. Naming mirrors the call pair for discoverability: `find_overrides` (what does X override) and `find_overridden_by` (what overrides X — i.e. what breaks if you change this interface/base method). Live on Ama: `ama overridden-by "Store.edgesTo"` → `InMemoryStore.edgesTo` + `SqliteStore.edgesTo`, the two concrete implementations — precise refactoring blast-radius for an interface method. With this, hft.11's Overrides is complete end-to-end (analyzer emits → census counts → query answers). Self-index 1014/2501/158 → 1032/2569/160 (+2 test files; +query/tool/command nodes & edges). 411 → 417 tests; typecheck + lint clean (changed files). - 2026-06-18 · analyzer/query · **First-class Returns edges — closing hft.11's edge trilogy** (ama-37c) — the optional third edge kind from hft.11: a function/method → its declared return type, split out of `UsesType`. The analyzer change is surgically narrow — `collectTypeUsages` already separated annotation *sources* (param/property vs return vs generic-args), so it was one branch redirected into a `returnAnnotations` list emitting `Returns` instead of `UsesType`. The query design is the interesting half: rather than a breaking split, `find_types_used`/`find_type_users` **union** `UsesType + Returns` (their contract is literally "parameter, return, *or* property"), so return types still appear there — and a new `find_returns` gives the return *alone*. Same union trick as Instantiates' `CALL_EDGE_KINDS`, now `TYPE_EDGE_KINDS`: a relationship can be split into a finer edge kind while the catch-all query stays whole, by unioning. Only the *analyzer-level* tests saw the behaviour change (return type moved from UsesType to Returns — two assertions flipped to `false`); the query/MCP/CLI consumers were additive. Live on Ama: `get_graph_schema` shows **Returns: 151** distinct from UsesType (which dropped 317→219 as those return usages re-labelled), and `ama returns "AmaSession.indexStatus"` → `IndexStatus` (TypeAlias, session.ts:23). hft.11's trilogy (Instantiates / Overrides / Returns) is now complete — calls, constructions, overrides, type-uses, and returns are all distinct, queryable relationships. Self-index 1032/2569/160 → 1045/2658/162 (+2 test files; +151 Returns edges re-labelled from UsesType, +find_returns surface). 417 → 423 tests; typecheck + lint clean (changed files). - 2026-06-18 · query/mcp/cli · **explore output budget — the dogfood diagnosed itself** (ama-m8k.4) — the loop's premise paid off literally: the *first* dogfood call, `explore("node")`, returned **134,336 characters / 5,029 lines and exceeded the token limit** — the bug reported itself before I'd read a line of `explore`. The cause: `explore` ran `searchSymbol` (default limit 50) then deep-dived *every* match — `findCallers` + `findCallees` + a transitive `impactAnalysis` blast radius each — so a broad term explodes O(matches × graph). The fix is a size-adaptive budget: rank generously to *count* (`EXPLORE_SCAN_LIMIT` 200) but only deep-dive the top `limit` (default 15), and cap the blast radius (40) — then report `totalMatches`/`totalBlastRadius` so truncation is *honest*, not silent (the tier-honesty ethos applied to output size: never let a capped view look complete). No new mechanism — composes the existing searchSymbol ranking, the per-query limit, and `rankNodes`. The shape change (two count fields) was additive: the one existing explore test has a single match, under budget, untouched. Live: that same `explore("node")` now fits easily — 15 matches shown, `totalMatches: 64`, blast capped at 40 of 64 — with a CLI line "showing top 15 of 64 matches … (narrow the query)". Self-index 1045/2658/162 → 1053/2679/163 (+1 test file). 423 → 425 tests; typecheck + lint clean (changed files). - 2026-06-18 · query/mcp/cli · **Low-confidence search marker — telling the agent when the match is weak** (ama-b79) — `search_symbol("uild")` happily returns `buildIndex` + `build` (substring hits) with no hint that neither is what you typed; an agent trusts the top result and goes astray. Now it warns. The confidence test is *semantic, not a tuned threshold*: it mirrors `scoreSymbol`'s own tiers — exact name/qualified (+100) or name-prefix (+60) is "strong"; substring (+30) and qualified-substring (+12) are "weak" — so low-confidence = there's a free-text term and results, but none is exact-or-prefix. The non-obvious win was surfacing it *without breaking the tool's contract*: `reply()` already supported a staleness banner as an extra MCP content block, so the marker rides along as an **appended** advisory block — the results array stays at `content[0]` (every existing `firstText`-based test untouched) and the agent still sees the warning. The two count fields of m8k.4 and this hint block are the same lesson: enrich a result by adding *alongside*, not by reshaping. searchSymbol stays `GraphNode[]` for internal callers (explore); a new `searchSymbolWithConfidence` wraps it for the two surfaces. Live: `search_symbol("uild")` now returns the two hits plus "⚠️ no exact or name-prefix match … refine the query"; a strong match (`helper`) stays a single block. Self-index 1053/2679/163 → 1061/2708/164 (+1 test file). 425 → 433 tests; typecheck + lint clean (changed files). - 2026-06-18 · indexer · **Analyzer isolation — one crashing language no longer kills the whole index** (ama-m8k.9) — a fresh-area iteration (indexer reliability, untouched in 24 loops), and the dogfood pointed straight at it: `get_code_snippet("Indexer.index")` showed the analyze loop as a bare `await analyzer.analyze(root, files)` — *unguarded*. So a single pathological file (or an analyzer bug) anywhere makes the whole `index()` reject and `index_repository` fail completely, losing every language. For an MCP server pointed at arbitrary repos, that's a real availability bug. The fix wraps each analyzer's batch in try/catch: on failure, report to stderr and `continue` — the other languages still produce a usable graph, and the failed language is **left out of `languages` coverage** so the index honestly reflects what was analyzed (the tier-honesty rule: a partial index must not look complete). Isolation lives at the indexer (orchestrator) layer, which made it cleanly testable *without* a real crash — both `Indexer` and `AnalyzerRegistry` are injectable, so the test registers a fake `.bad` analyzer that throws alongside a working `.ok` one and asserts the index survives with only `ok` in coverage plus a stderr report. The honest limitation: Ama's own analyzers don't crash, so there's no live failure to dogfood — the self-index gate confirms the happy path is untouched (1065/2718/165, all 7 languages), and the unit test proves the catch path. Per-FILE granularity (one bad file shouldn't lose its whole language) and the parse *timeout* (hard: sync parse can't be interrupted in-thread) are filed as follow-ups. Self-index 1061/2708/164 → 1065/2718/165 (+1 test file). 433 → 434 tests; typecheck + lint clean (changed files). - 2026-06-18 · analyzer/indexer/mcp · **Resolution-coverage metric — the honesty thesis, measured on Ama itself** (ama-m8k.12) — the most thesis-aligned work in the backlog, deferred ~12 times for being pipeline-heavy; it was worth it. Ama silently *drops* every call it can't resolve (a library/dynamic target → no edge), so the graph looked complete while quietly omitting most of the call graph. The fix counts it: `collectCalls` tallies `callsTotal` (attributable call/new sites — those inside a function) vs `callsResolved` (those whose target is a known node), threaded through as a mutable accumulator and returned on `AnalysisResult.resolution`. Five thin layers — analyzer count → `AnalysisResult` → indexer aggregate across analyzers → store meta (`ama:resolution`, additive so it needn't gate reopen or bump SCHEMA_VERSION) → `index_status` — each a few lines. Baseline (syntactic) analyzers resolve nothing, so they omit `resolution` entirely rather than reporting a misleading 0/0: the tier-honesty rule again. The payoff is a number that couldn't exist before: **Ama's own call graph is 926/2374 = 39% resolved**. Far from a defect — it's *honest*: Ama's analyzer is dominated by `ts.*` compiler-API calls into the external `typescript` package it never indexes, so most call sites legitimately have no internal target. An agent now knows the call graph is ~39% complete for this library-heavy code, instead of trusting a silently-partial graph. Unresolved-reference *reporting* (which names failed) and type-resolution coverage are filed as the remaining half. Self-index 1065/2718/165 → 1076/2740/167 (+2 files: test + fixture). 434 → 436 tests; typecheck + lint clean (changed files). - 2026-06-18 · analyzer/indexer · **Unresolved-reference reporting — *which* calls don't resolve, and a finding for free** (ama-qbn) — the other half of m8k.12: the metric said 39%, this says *of what*. `collectCalls` now records each unresolved call by its callee *root* (`ts.isCallExpression(x)` → `ts`, `helper()` → `helper`) into `ResolutionStats.unresolved`, a frequency map. The m8k.12 plumbing carried it almost free — `resolution` already flows analyzer→indexer→meta→`index_status`, so the only new code was a `calleeRoot()` walk-to-leftmost-identifier, one `else` branch, and a map-merge in the indexer; `index_status` passes `resolution` wholesale, so the breakdown surfaced with *zero* changes to the surface layer. The dogfood paid off twice. First, it explains the 39% at a glance: `path: 149`, `ts: 120`, builtins (`Map: 34`, `JSON: 27`, `Set: 19`), test helpers (`expect: 62`, `it: 19`), `z: 30` — overwhelmingly external, exactly as honesty predicted. Second, it *surfaced a real finding*: `this: 87` — that many `this.method()` calls don't resolve, and same-class method calls *should*. That's a candidate `resolveCallee` gap the metric alone could never have pointed at; filed for investigation (fixing it would lift coverage *and* add real Calls edges). Lesson: an honesty metric isn't just a disclaimer — broken down, it becomes a worklist. Self-index 1076/2740/167 → 1078/2743/167 (no new files; +calleeRoot). 436 → 437 tests; typecheck + lint clean (changed files). - 2026-06-18 · analyzer · **The `this: 87` finding investigated — a false alarm that still yielded a fix** (ama-k9t) — the loop fed itself: last iteration's breakdown flagged `this: 87` as a suspected `resolveCallee` gap. Dogfooding *disproved* it. `find_callees` on real same-class callers showed the resolver works fine — `catchUpIfNeeded`→`sync`, `searchSymbol`→`requireQuery`+`QueryService.searchSymbol`, `indexRepository`→`Indexer.index`/`Store.close` all resolve. The smoking gun: `find_callees(AnalyzerRegistry.register)` → **`[]`**, because its only calls are `this.analyzers.push()` and `this.byExtension.set()` — Array/Map *builtins* with no Ama node. So the 87 were legitimate external calls on instance fields, not a bug. But the investigation exposed a real shortcoming: grouping them under the bare `this` root is *opaque* — it can't tell you where they cluster. The fix is four lines in `calleeRoot`: descend past `this` and group by the field/method instead (`this.items.push()` → `items`). Live, the `this` bucket vanished and resolved into a *locatable* map — `db: 34` (SqliteStore's node:sqlite handle), `lines: 56` (string-building arrays), `nodes: 25`/`edges: 22`/`methodsByContainer: 5` (collection fields), `server: 30` (McpServer.registerTool). Lesson: a finding that turns out to be a non-bug is still worth chasing — verifying *why* the metric looked alarming improved the metric itself. Self-index 1078/2743/167 → 1082/2746/168 (+1 fixture). 437 → 438 tests; typecheck + lint clean (changed files). - 2026-06-18 · indexer · **Unsafe-root guardrail — and a TDD trap where RED is destructive** (ama-m8k.10) — an MCP server takes a path from whoever calls it, and `index()` would happily walk whatever it's given. So `index_repository("/")` or `("~")` would crawl the entire machine — slow, memory-hungry, and likely to slurp secrets. `assertSafeRoot` now refuses the filesystem root, the home directory, and well-known system dirs (`/usr`, `/etc`, …), called before `index()` touches the disk. Because a real project always lives in a *subdirectory*, the denylist of exact paths never rejects legitimate use — no globbing, no false positives. The instructive part was the test: the canonical RED — "run `index('/')`, watch it not refuse" — is *itself the bug firing*, a filesystem walk. (Proof: the one integration assertion I let run pre-guard took 3.4s crawling `/` before failing; post-guard the whole file runs in 350ms.) So I made `assertSafeRoot` a *pure, exported* path predicate and RED-tested *that* in isolation — no walk — then wired it into `index()` and only asserted `index('/')` rejects *after* the guard existed (safe, because it throws before `discoverFiles`). Lesson: when the failing behavior is destructive, factor the check into a pure function and test it directly rather than driving the dangerous path. Live: `index_repository("/")` returns "Refusing to index / …"; Ama's own root indexes untouched. Self-index 1082/2746/168 → 1085/2753/169 (+1 test file). 438 → 444 tests; typecheck + lint clean (changed files). - 2026-06-18 · analyzer · **Higher-order callbacks as call edges — and dogfooding to *decide* a pick, not just verify it** (ama-hft.15) — `xs.map(transform)` / `p.then(handler)` invoke their function argument, but that control flow was invisible: the call graph saw `arr.map` (external, unresolved) and stopped. Now a named function passed to a known higher-order method (`map`/`filter`/`reduce`/`sort`/`then`/`catch`/…) gets a heuristic `Calls` edge to the callback, resolved via the existing `resolveValueRef`. It's gated on the *outer* call being unresolved — i.e. a builtin Array/Promise method — so a custom same-named method (already a resolved edge) isn't double-attributed; and it's marked `provenance: "heuristic"` because the method name is a pattern match, not a proof it invokes the arg. The interesting part was using Ama's tools to *qualify the pick before committing*: `search_code(".forEach(")` → `[]`, `.then(`/`.sort(` → only inline arrows. So Ama's own source is arrow-heavy and gains **zero** edges from this — a fact I learned by dogfooding, not guessing. I shipped it anyway (it's real fidelity for the codebases Ama serves) but honestly: the live proof comes from Ama indexing its *own fixture* — `find_callees(cb.ts#run)` → `transform`, `via: Calls`, `at: 10:17`, `provenance: heuristic`, composing cleanly with location + provenance from earlier iterations. Lesson: dogfooding isn't only post-hoc verification — `search_code` up front tells you whether a feature will even touch the target, separating "real but inapplicable here" from "high-signal." Self-index 1085/2753/169 → 1095/2771/171 (+2 files: fixture + test). 444 → 447 tests; typecheck + lint clean (changed files). - 2026-06-18 · analyzer/store/query · **Per-call-site results — accumulate in the analyzer, not the store** (ama-hft.10) — `find_callees("renderNodeView")` swore it called `names` once (line 41), but it calls it four times (lines 41-44); the `(from,to,kind)` edge dedup kept only the first site. Now a `Calls`/`Instantiates` edge carries every site in `sites` (`sites[0] === at`; absent for single-call edges, so the common case is untouched). The design pivot that kept this clean: **where** to accumulate. Doing it in the store means teaching SQLite's `INSERT OR IGNORE` to read-modify-write on every collision — thousands of round-trips, ugly. Instead a one-pass `accumulateCallSites(edges)` runs at the end of `analyze()` (after dispatch fan-out), merging by `(from,to,kind)` *before* the edges reach any store — so both stores just persist one pre-merged edge with its `sites`, and the dedup stays a dumb first-write-wins. InMemoryStore needed *zero* changes (it stores the whole edge object); SqliteStore got an `at_sites` JSON column via the established additive-ALTER pattern + SCHEMA_VERSION 3→4 (so stale CLI DBs rebuild). `EdgeNeighbor.sites` is one line in `neighbor()`, so find_callers/find_callees surface it for free. The store *contract* test (run against both stores) proves the round-trip once. Live: `find_callees("renderNodeView")` → `names` with all four sites `[41:44, 42:44, 43:48, 44:50]`; `nodeLine` (called once) stays bare `at`. Lesson: when a per-element rollup fights your storage layer's write path, do the rollup upstream where the data is still in memory. Self-index 1095/2771/171 → 1106/2789/173 (+2 files: fixture + test). 447 → 451 tests; typecheck + lint clean (changed files). - 2026-06-19 · cli · **affected stdin + --filter glob — make the untestable part injectable** (ama-dx1) — the deferred half of iteration 19's `affected --tests`: read changed paths from stdin (so `git diff --name-only | ama affected` works) and a `--filter ` to scope the blast radius to a subtree. The stdin friction that stalled this twice has one clean answer: don't read `process.stdin` inside the command — put a `stdin?(): string` reader on `CliContext` so the real `run()` supplies a TTY-guarded `fs.readFileSync(0)` (no hang when interactive) while tests inject `() => "core.ts\n"`. Same injection trick the framework already used for `write`/`error`: the I/O boundary is a function on the context, so the command stays a pure unit. `--filter` is a tiny glob→RegExp (`*` within a segment, `**` across, anchored) applied to the result `.file`s. Live: `ama affected --filter "src/store/**" src/graph/types.ts` narrows the full blast radius to just `sqlite.ts`/`memory.ts`/`types.ts`; `echo src/query/service.ts | ama affected --tests` → 42 affected test files, the CI workflow without xargs. Lesson: when a feature's blocker is "this side-effect isn't testable," lift the side-effect to an injectable seam rather than reaching for stdin mocks. Self-index 1106/2789/173 → 1110/2797/173 (no new files). 451 → 455 tests; typecheck + lint clean (changed files). - 2026-06-19 · build/lint · **Restored the repo-wide lint gate — and caught two bugs my own loop had been hiding** (ama-xim) — the filed issue was narrow ("noExplicitAny in ts-express fixtures"), but running `biome check .` (the documented `npm run lint`, which I'd been *working around* by linting only changed files each iteration) showed **23 errors**, not 6. The fixtures were the easy part: they're analyzer *input*, deliberately varied (`(req: any, res: any)` mimics real Express), so excluding `tests/fixtures` from biome — exactly parallel to the iteration-19 vitest exclusion — is correct; Ama still *indexes* them, it just doesn't *lint* them. The uncomfortable part: the other 17 included two genuine lint violations **I had introduced** in recent iterations and never saw — `accumulateCallSites` (hft.10) used `(x ??= […]).push()` (noAssignInExpressions), and `globToRegExp` (dx1) had smuggled a literal control character into a regex as a placeholder (noControlCharactersInRegex; that's why my Edits kept "not finding" the space — it wasn't a space). Both slipped because per-file `biome check ` and non-auto-fixable rules don't compose: a green per-file check is not a green gate. Fixed the control-char by rewriting the glob compiler as a single alternation pass, the assign-in-expression by splitting the statement, and the template-literal style nits by hand; `biome check --write` swept the rest. `npm run lint` now exits 0 across 134 files. Lesson: a project-wide quality gate degrades *silently* when you only ever run it on the diff — the debt accumulates exactly in the cross-cutting rules the narrow check skips. Self-index 1110/2797/173 (unchanged — pure style). 455 tests still green; `npm run lint` repo-wide clean for the first time in this loop. - 2026-06-19 · analyzer · **Module nodes for namespaces & ambient modules — the declared-but-never-emitted kind** (ama-hft.13) — prompted by a "have we finished TypeScript?" check: the `hft` epic was 13/15, and a census via `get_graph_schema` exposed a latent inconsistency — `Module` was in `NODE_KINDS` (so it's a valid `search_symbol kind:` filter and a census row) but **no analyzer ever produced one**: count 0. A node kind that can be asked for but never returned is a quiet lie. Now `describe()` maps a `ModuleDeclaration` to a `Module` node — its name is an `Identifier` (`namespace N`) or a `StringLiteral` (`declare module "pkg"`), both exposing `.text` — and `visit()` treats it as a *container* exactly like a class/interface, recursing into the `ModuleBlock` statements (or the nested `ModuleDeclaration` of `namespace A.B`) with the namespace as the qualified-name prefix. The nesting isn't cosmetic: without it a namespace's `area()` would get id `…#area` and collide with a top-level `area()`; with it, `Geometry.area` is distinct. Ama's own source is pure ESM (zero namespaces), so the live proof is its own fixture — `get_graph_schema` went `Module: 0 → 2`, and `search_symbol kind:Module` returns `Geometry` (a namespace) and `virtual:config` (an ambient module). With this, the deep-TypeScript epic is 14/15 — only the niche `hft.14` (EventEmitter string-channel synthesis) remains; the core semantics are complete. Lesson: a declared-but-unused enum value is a gap worth auditing — every `NodeKind` should be producible, or it shouldn't be in the list. Self-index 1110/2797/173 → 1118/2807/175 (+2 files: fixture + test). 455 → 458 tests; typecheck + repo-wide lint clean. - 2026-06-19 · analyzer · **EventEmitter on/emit synthesis — closing the deep-TypeScript epic 15/15** (ama-hft.14) — the last `hft` child: a `.emit("ch")` invokes every handler bound with `.on("ch", h)` (or once/addListener), so it's a real call relationship the checker can't see (the dispatch goes through EventEmitter's runtime registry). `collectEvents` is two passes over a file: collect channel→handler registrations, then for each `emit("ch")` synthesize a `Calls` edge from the emitting function to each handler on that channel — `provenance: "heuristic"` because the link is a channel-string match, not proven dispatch. The reuse that made it small: handler resolution piggybacks on two earlier iterations — named refs (`this.handleData`) go through `resolveValueRef` (hft.6/decorators), and inline `.on("ch", () => …)` arrows are *already* handler nodes because `collectCallbackHandlers` synthesized them, so ordering `collectEvents` right after it lets an emit connect to an inline listener too. Like hft.15, Ama itself is EventEmitter-free (`search_code(".emit(")` → `[]`), so the proof is its own fixture: `find_callees("Bus.publish")` → `handleData`, `via: Calls`, `at: 14:5`, `provenance: heuristic`. **With this the `hft` epic — "Deeper TypeScript semantics" — is 15/15 complete:** imports, inheritance, interface dispatch, overrides, generics, decorators, type-uses, returns, instantiations, edge location, per-call-site, callbacks, namespaces, and now events. The deep tier is the reference implementation it set out to be. Self-index 1118/2807/175 → 1130/2829/177 (+2 files: fixture + test). 458 → 460 tests; typecheck + repo-wide lint clean. - 2026-06-19 · analyzer/framework · **Fastify/Koa/Hapi/Hono routes — a syntactic heuristic already covered three of the four** (ama-rme.10) — the most useful thing dogfooding `collectRoutes` revealed: the existing Express detection is *purely syntactic* — it fires on any `x.get("/path", handler)` where the method ∈ ROUTE_METHODS, never checking that `x` is an Express app. So Fastify, Koa-router, and Hono — which all use the same `app.get(path, h)` shape — were **already detected**; a regression-style test asserting `GET /fastify/health` proved it green before I changed anything. The genuine gap was the *object-config* style that doesn't look like a method call at all: Hapi `server.route({ method, path, handler })` and Fastify `fastify.route({ method, url, handler })` — note Fastify spells the path `url`, and `method` can be an array. Added a `.route(…)` branch that reads the config object (handling `.route([{…}, …])` arrays too) and reuses an extracted `emitRouteHandlers` — the same inline-arrow-synthesis / named-ref-resolution both styles need, now shared instead of duplicated. Live, indexing its own fixture: `find_routes("getUsers")` returns all three — `GET /fastify/health` (method-named), `GET /hapi/users` (Hapi config), `POST /fastify/users` (Fastify `url`). Lesson: a syntactic heuristic generalizes for free across frameworks that *share* syntax — the real work is only the ones with a different shape, so scope the task by AST pattern, not by framework name. Self-index 1130/2829/177 → 1144/2853/179 (+2 files: fixture + test). 460 → 463 tests; typecheck + repo-wide lint clean. - 2026-06-19 · analyzer/framework · **tRPC procedures & GraphQL resolvers — the route model generalizes past URLs** (ama-rme.11) — schema-first APIs don't have a path string; they bind a handler to a *named object property*. tRPC: `getUser: publicProcedure.query(handler)` — the procedure name is the property key, the handler is the call's arg. GraphQL: `{ Query: { users: resolver } }` — a `Type.field` resolver map. Both became `Route` nodes (`query getUser`, `Query.users`) referencing their handler, so `find_routes`/`find_handlers` work on RPC and graph schemas the same way they do on Express. The `emitRouteHandlers` helper I extracted *last* iteration (rme.10) paid off immediately — this is the third route shape to reuse it (inline-arrow synthesis + named-ref resolution), zero duplication. The one new piece was a false-positive guard: `.query(…)` is also a database call, so an `isHandlerExpr` gate emits a procedure only when the arg is a function or function-reference (not `db.query("SELECT …")`), and tRPC detection requires the call to be a *property value* (it lives in a router object), not a standalone statement. Live, indexing its own fixture: `find_routes("getUser")` → `query getUser`; `find_routes("listUsers")` → `Query.users`. Lesson: a "Route" needn't be a URL — once a handler is reachable from a named node, the existing route tooling (find_routes/find_handlers, the References model) covers RPC and GraphQL for free; the analyzer change is just pattern recognition. Self-index 1144/2853/179 → 1163/2878/181 (+2 files: fixture + test). 463 → 466 tests; typecheck + repo-wide lint clean. rme epic now 4/11. - 2026-06-19 · analyzer/framework · **File-based routing — the first route detection driven by the filesystem, not the AST** (ama-rme.7) — every route style so far reads a call or an object; this one reads the *file path*. Next.js App Router (`app/**/route.ts`) and SvelteKit (`src/routes/**/+server.ts`) share a convention: a marker filename in a routes directory whose exported `GET`/`POST`/… functions are the handlers, and the URL is the directory path between the routes-root and the file. `collectFileRoutes` derives that URL — `[id]` → `:id`, `[...slug]` → `*`, `[[opt]]` → `:opt`, a `(group)` segment dropped (route groups don't affect the URL) — then emits a ` ` Route per exported handler. It needed *no new plumbing*: `rel` (the repo-relative path) was already threaded into every collector, and the exported `GET`/`POST` functions are already Function nodes, so the pass is pure derivation + `declToId` lookups. Live, indexing its own fixture tree: `search_symbol("path:ts-filerouter kind:Route")` returns `GET /api/users`, `POST /api/users`, `GET /posts/:id` (the `[id]` converted), `GET /health`; `find_handlers("GET /posts/:id")` → the `GET` export. Scoped to the marker-file convention (Next App Router + SvelteKit) per agreement; the filename-as-route conventions (Astro, Next Pages Router, Nuxt `defineEventHandler`) are a different shape and filed as a follow-up. Lesson: a route's identity can live in the directory layout, not the code — and because the analyzer already carries the file path, that's a derivation, not a new subsystem. Self-index 1163/2878/181 → 1182/2906/185 (+4 files: 3 route fixtures + test). 466 → 469 tests; typecheck + repo-wide lint clean. rme epic now 5/11. - 2026-06-19 · analyzer/framework · **Component nodes — and "hook usage" that was already in the graph** (ama-rme.9) — the issue asked for component nodes *plus hook usage*, but writing the test first revealed the second half was already done: a component calling a custom hook is just a function calling a function, so `find_callees(Button) → useCounter` was a `Calls` edge before I touched anything (the assertion was green on the RED run). React's built-in hooks (`useState`) are external and stay honestly unresolved. So the only new work was *naming the construct*: a new `Component` `NodeKind`, set by `describe()` when a PascalCase function returns JSX (`returnsJsx` scans `return ` in the body, or an arrow's JSX concise-body — which works because TS parses `.tsx` JSX into `JsxElement` nodes regardless of the `jsx` *emit* option, so no compiler-option change), plus Vue's `const X = defineComponent({…})`. Because `NODE_KINDS` is the single source of truth, the kind became a `search_symbol kind:` filter and a census row for free. Live, indexing its own `.tsx` fixture: `search_symbol("kind:Component")` → `Button` (React) and `Counter` (Vue); `find_callees("Button")` → `useCounter`. Vue/Svelte single-file components (`.vue`/`.svelte`) can't be parsed by the TS compiler and are filed as a follow-up. Lesson: before building a feature, check whether the existing graph already expresses it — "hook usage" needed no new edge, only a name for the thing using the hooks. With this every TS/JS-relevant `rme` child is done. Self-index 1182/2906/185 → 1195/2927/187 (+2 files: fixture + test). 469 → 473 tests; typecheck + repo-wide lint clean. rme epic now 6/11 (all TS/JS children complete; the rest are other languages). - 2026-06-19 · analyzer/baseline · **PHP baseline analyzer — a ~20-line language, and two things that still drift** (ama-s8q.10) — pivoting off TS to baseline-tier breadth: the spec-driven framework makes a new language a tiny declarative map of CST-node-type → graph-kind, with the generic `walk` reading the `name` field and nesting members. PHP was exactly that: `class_declaration`/`interface_declaration`/`trait_declaration`→Class/Interface/Class, `enum_declaration`→Enum, `function_definition`→Function, `method_declaration`→Method (qualifying as `Sample.square`). Three things were worth doing right. (1) I confirmed the node-type names by *parsing a sample first* and dumping the CST, instead of guessing and iterating against a red test — much faster. (2) The issue was "Ruby **&** PHP", but Ruby is genuinely **blocked**: the bundled `tree-sitter-ruby.wasm` throws on load under the pinned `web-tree-sitter@0.20.x` (its external scanner is ABI-incompatible) — discovered by *trying* it, filed as a blocker, PHP shipped without it. (3) Dogfooding then caught a second drift: `search_symbol("lang:php")` returned nothing even though the nodes existed, because the `lang:` filter has its *own* extension→language map (`LANGUAGE_BY_EXT` in the query layer) separate from each analyzer's declared `extensions` — adding the analyzer doesn't update it. Added `.php` there; the deeper smell (two sources of truth for "what language is this file") is a latent cleanup. Live: Ama now reports `php` baseline (1 file) in its own index, `lang:php kind:Class` → `Sample`/`Loggable`. Lesson: a clean abstraction (the spec) shrinks the obvious work, but the integration's edges — does the grammar load, does every *other* subsystem know the new extension — are where the real surface is. Self-index 1195/2927/187 → 1209/2943/190 (+3 files: spec + fixture + test). 473 → 474 tests; typecheck + repo-wide lint clean. - 2026-06-20 · analyzer/baseline · **C/C++ — the Nth language that breaks a baked-in assumption** (ama-s8q.9) — every baseline language so far (Java, Python, PHP, …) put a declaration's name in a `name` field, so the generic `walk` read `childForFieldName("name")` and the spec was a pure node-type→kind map. C and C++ break that: a `struct_specifier`/`class_specifier`/`enum_specifier`/`namespace_definition` *does* carry a `name`, but a `function_definition` nests its name in a **declarator chain** (`function_definition.declarator` is a `function_declarator` whose own `declarator` is the identifier) — `childForFieldName("name")` returns null, so a naive spec would index every struct but *no functions*. I verified this by dumping the CST first (the PHP lesson), then fixed it in the framework, not per-language: `symbolName` tries the `name` field and *falls back to drilling the `declarator` field* to its inner identifier. It's backward-compatible — the drill only runs when there's no `name` field, so Java/Python/PHP are untouched (476 tests, no regressions) — and it handles both grammars at once. Both languages shipped: one spec file with `cSpec` and `cppSpec` (C++ adds `class_specifier`→Class, `namespace_definition`→Module), and because the C++ grammar is a superset, ambiguous `.h` routes to it so it parses C *and* C++ headers. I also applied the PHP `LANGUAGE_BY_EXT` lesson proactively (added `.c`/`.cpp`/`.h`/… to the `lang:` filter map up front). Live: `search_symbol("lang:c kind:Function")` → `square` (name via declarator drill); `lang:cpp kind:Class` → `Sample`, `geo.Point` (namespace-qualified). Lesson: the first few instances of a pattern let a convenient assumption ("declarations have a name field") quietly harden into the framework; the right response to the case that breaks it is to *generalize the framework*, not special-case the outlier. Ama now indexes 9 languages. Self-index 1209/2943/190 → 1224/2961/194 (+4 files: 2 fixtures + test + c/cpp spec). 474 → 476 tests; typecheck + repo-wide lint clean. - 2026-06-20 · analyzer/robustness · **Isolation is only as fine as its boundary** (ama-eww) — the indexer already isolates failures *per analyzer* (ama-m8k.9): a crash in one language's batch is caught and that language is dropped, not the whole index. But the baseline analyzer loops over its files *internally*, so a single unreadable/unparseable file threw out of `analyze()` and was caught one level too high — as a whole-language loss. Now that Ama runs eight baseline languages, each file-by-file, that blast radius matters. The fix moved the `try/catch` *down* into the file loop, and — the non-obvious part — accumulates each file's nodes/edges into **local** arrays merged only on success, so a throw partway through `walk` (after the File node is pushed) can't leave an orphaned half-file in the result. A failed file logs to stderr and the batch carries on. Verified by analyzing `["does-not-exist.py", "sample.py"]`: the missing file is skipped, `sample.py` still indexes — before the change, the `ENOENT` rejected the whole call. The TS *deep* analyzer is a different shape (one `createProgram` for cross-file resolution — you can't give each file its own program without losing the resolution), so its per-pass isolation is filed separately. Lesson: a `try/catch` placed for convenience at a coarse boundary silently converts a local failure into a broad one; isolation has to sit at the granularity you actually want to preserve. Self-index 1224/2961/194 → 1224/2961/194 (behavioral fix — no new symbols). 476 → 477 tests; typecheck + repo-wide lint clean. - 2026-06-20 · mcp/dev-logging · **A proxy signal that's only coincidentally right** (ama-zk6) — the `AMA_LOG_TOOLS` dev line summarizes each tool reply via `resultHint`, which inferred staleness from `content.length > 1`. That worked because, at the time, the *only* thing that added a second content block was the staleness banner. Then ama-b79 appended a low-confidence *hint* block to weak searches — and now any low-confidence result also had `length > 1`, so it logged `stale, …` though nothing was stale. Worse, the old code read `content[last]` as the data block, so with `[data, hint]` it was JSON-parsing the *hint*, not the result. Both bugs share one root: the block *count* was a proxy for "a banner is present", and the proxy silently decoupled from the meaning when a new block appeared. The fix encodes the real semantic from `reply()`'s structure (`[banner?, data, hint?]` — banner unshifted, hint pushed): find the data block by which one parses as JSON, and staleness is exactly "a block sits *before* the data" (`dataIdx > 0`). Position, not count. Dogfooded the trace entirely through Ama (`search_symbol resultHint` → `find_callers` → `tap`; `search_symbol reply` for the block order) — no grep. Lesson: when a cheap proxy stands in for a real condition, the next feature that touches the same surface is where it breaks — assert the condition you mean. Self-index 1224/2961/194 → 1226/2963/195 (+1 test file). 477 → 481 tests; typecheck + repo-wide lint clean. - 2026-06-20 · analyzer/framework · **When two frameworks share a directory, the discriminator is the handler shape, not the path** (ama-w7g) — finishing the file-based routing story (rme.7 did the *marker-file* conventions — `app/**/route.ts`, `src/routes/**/+server.ts` → directory path; w7g does the *filename* conventions — the route includes the filename). Three frameworks, and the tricky parts weren't the path math: (1) **Next.js Pages Router and Astro both live under `pages/`/`src/pages/`** — you cannot tell them apart by path, so I don't try: I detect by *handler style present in the file* — exported `GET`/`POST` functions (Astro) and/or a `export default` handler (Next) — and emit whichever exists. (2) A bare "default export" means different things in different directories: under `pages/api/` it's a request handler, but `pages/about.tsx` default-exports a React *page component* — so default-export routes are gated to an `api` context (`allowDefault`), and the negative test (`/about` produces no route) guards it. (3) Nuxt is asymmetric — `server/api/x.ts` keeps the `/api` prefix in the URL, `server/routes/x.ts` strips `routes`. (4) Reused `emitRouteHandlers` (now its *fourth* route shape) for the default-export expression, unwrapping Nuxt's `defineEventHandler(h)` first. Live, indexing its own fixtures: `search_symbol("path:ts-filerouter-name kind:Route")` → `ALL /api/users` (Next), `GET /posts/:id` (Astro), `ALL /api/hello` (Nuxt) — and nothing for `/about`; `find_handlers("ALL /api/hello")` → the unwrapped handler. Lesson: overlapping directory conventions can't be resolved by path alone — read what the file *exports*; and the same syntactic form (`export default`) carries different meaning by location, which the detector has to encode rather than assume. File-based routing is now complete across the major JS frameworks. Self-index 1226/2963/195 → 1247/2989/200 (+5 files: 4 fixtures + test). 481 → 485 tests; typecheck + repo-wide lint clean. - 2026-06-20 · analyzer/baseline · **Kotlin breaks the name convention a third way — so the extractor grows a third tier** (ama-0ze) — a clean pattern keeps revealing how un-clean grammars are. Java/PHP put a declaration's name in a `name` field; C/C++ nest it in a `declarator` chain (ama-s8q.9); Kotlin uses *neither* — `class_declaration`/`function_declaration`/`object_declaration` name themselves with a bare positional `type_identifier`/`simple_identifier` child, no field at all. Rather than special-case Kotlin, `symbolName` gained a third fallback tier: name field → drill `declarator` → first identifier-like child. Each new grammar family is another *tier*, not another branch, and because each tier only fires when the previous found nothing, all nine existing languages were provably untouched (486 tests, no regressions). The other Kotlin quirk — `class`, `interface`, and `enum class` are *all* `class_declaration` — was already handled by the framework's existing `kindByChild` option: `{ kind: "Class", kindByChild: { enum_class_body: "Enum" } }` refines by the body node, so an enum class becomes Enum while interfaces stay Class. Verified the node types by dumping the CST first (the standing rule), and the grammar loads fine (unlike Ruby). Live: `lang:kotlin kind:Enum` → `Color`; `lang:kotlin kind:Function` → `Sample.square`, `Singleton.run`, `Greeter.greet`, `helper` (all named via the new fallback, correctly nested). Ama now indexes 10 languages; Swift/Scala/Dart/Lua remain under the s8q.11 umbrella. Lesson: when an abstraction meets its Nth input and bends again, prefer adding a *tier to the same mechanism* over a per-input special case — the generality is what made the first nine free. Self-index 1247/2989/200 → 1261/3005/203 (+3 files: spec + fixture + test). 485 → 486 tests; typecheck + repo-wide lint clean. - 2026-06-20 · indexer/discovery · **When being wrong has asymmetric cost, scope to what you can model and make the fallback the safe direction** (ama-2eu) — Ama never read a project's `.gitignore` — discovery only skipped four hard-coded dirs (`node_modules`/`dist`/`build`/`coverage`) plus dot-entries, so a project's own `out/`, `target/`, `*.generated.ts` got indexed as noise. Full gitignore is a swamp (anchoring, `**`, trailing-slash dir-only, `!` negations, nested files) and the repo bans new deps (no `ignore` package). The move was to ship a *safe subset*: bare names (`foo`, `foo/`) and segment globs (`*.ext`), applied additively to the built-in ignores. The crucial design choice is what to do with patterns I *don't* model (negations, embedded-`/` paths, `**`): **skip them**. Skipping an unsupported pattern only ever indexes *more* files, never fewer — so a misparse can surface noise but can never silently drop a file the user needs. That asymmetry (a missing file is far worse than an extra one) makes "fail toward inclusion" the right default, and it let me ship a partial feature without partial *correctness*. Threading was clean — `find_callers` showed only two consumers (`discoverFiles` and `FileWatcher.handle`), and an optional `rules` parameter (defaulting to the built-ins) kept every existing caller and test untouched; the watcher loads rules once so the watched set matches the indexed set. Dogfood: `index_repository("tests/fixtures/gitignore-proj")` → **fileCount 1** (only `keep.ts`; `generated/` and `*.gen.ts` excluded) where it would have been 3. Full semantics (negations, anchored/nested patterns) filed as a follow-up. Lesson: a feature with asymmetric failure cost doesn't need to be complete to be correct — model the cases you can, and route everything else to the harmless side. Self-index 1261/3005/203 → 1276/3031/207 (+4 files: 3 fixture sources + test). 486 → 488 tests; typecheck + repo-wide lint clean. - 2026-06-20 · analyzer/baseline · **The generality compounds — Swift cost one config file** (ama-p3a) — five languages of accumulated machinery paid off: Swift was the *easiest* baseline add yet, a single spec with no framework change. Why: tree-sitter-swift carries a `name` field, so the *first* extraction tier (added back when every language had one) handles it; `class`/`struct`/`enum` all share `class_declaration`, distinguished by the `kindByChild` lever (built for Kotlin) that refines an `enum_class_body` to Enum; `protocol_declaration` → Interface. Contrast Kotlin, which needed a *new* third extraction tier because it names things positionally — Swift needed none. The standing rule earned its keep again: I dumped Swift's *and* Scala's CSTs before writing anything, and picked Swift because the bundled tree-sitter-scala is Scala-2 — a Scala-3 `enum` parsed as an `ERROR` node — so Scala would have shipped with a caveat; Swift ships clean. Verification: the re-index (`index_repository`) reported `swift / baseline / 1 file` and the test asserts every kind mapping (`Point`→Class via struct, `Greeter`→Interface via protocol, `Color`→Enum, `Sample.square`→Function nested) — green. (The follow-up `search_symbol` calls hit a transient MCP transport error after the successful re-index — server-side, unrelated to the change; the re-index and tests already confirm the behavior.) Ama now indexes 11 languages; Scala/Dart/Lua remain under s8q.11. Lesson: investment in generalizing an abstraction is only validated later — the Nth input that costs *nothing* is the dividend on the earlier inputs that cost a tier. Self-index 1276/3031/207 → 1288/3045/210 (+3 files: spec + fixture + test). 488 → 489 tests; typecheck + repo-wide lint clean. - 2026-06-20 · analyzer/baseline · **A leak that only the long-running process feels — it was eating the loop's own server** (ama-5o1) — the "transient MCP transport error" noted in the p3a entry above was not transient and not transport: the MCP server was crashing with a fatal `Out of memory: Zone` OOM, surfaced by the user running `serve:dev`. Root cause: `parse()` did `new Parser()` on **every call** (every file) and never `.delete()`d it, and no caller ever deleted the returned `Tree`. web-tree-sitter `Parser`/`Tree` are WASM-backed and own linear memory that must be freed manually — so each file leaked a parser and a tree. Two things made this insidious: (1) the one-shot `vitest`/CLI index never OOMs because the process *exits* before the leak matters, so every test stayed green while the bug shipped across five language additions; only the **long-running** server (initial index + a re-index per file-watch event + repeated `index_repository` calls) accumulates the leak until it dies — and that server is the loop's own dogfooding substrate. (2) The crash stack pointed entirely at V8's background **WASM compiler** (turboshaft), which is a red herring: a `Zone` OOM means the process is simply out of memory, so the failure lands on whoever allocates next, not on the code that leaked. The fix follows the WASM ownership model: reuse one cached `Parser` (safe because `setLanguage`+`parse` is a synchronous, non-interleaving block) and `tree.delete()` in a `finally` after the walk (the extracted GraphNodes are plain copies, valid post-delete). Proof, run in-process since the server was down: indexing the whole repo (200+ files, 11 grammars) now allocates **exactly 1 parser**, down from one-per-file, and completes without OOM. Lesson: a resource leak invisible to one-shot tests is exactly the kind that kills a daemon — and an OOM's stack trace names the last allocator, not the leaker, so trust the object-ownership contract (`.delete()` every WASM-backed handle) over the crash site. Self-index 1288/3045/210 → 1291/3052/210 (no new files; +3 nodes for the test hook). 489 → 490 tests; in-process self-index gate green; typecheck + repo-wide lint clean. (MCP dogfooding was blocked — the bug being fixed had killed the server — so this iteration verified in-process and rebuilt dist/ for the restart.) - 2026-06-20 · indexer/runtime · **The OOM the parser/tree fix didn't reach — V8 *compiling* the grammars, not Ama *running* them** (ama-rgx) — `serve:dev` still died with `Fatal process out of memory: Zone` after ama-5o1, so that leak fix was real but orthogonal. A forced-GC measurement harness settled it: after one full self-index (+ `store.close()` + `global.gc()`), the **JS heap returned to 54MB** — no leak — yet **RSS sat at 2712MB**, and the second index crashed. That ~2.5GB lives outside both `heapUsed` and `external`: it's V8 *Zone/WASM-code* memory, which is exactly why `--max-old-space-size` can't touch it. Root cause: indexing lazily loads all **11 tree-sitter grammar WASM modules** (the C++/Swift/Kotlin grammars have enormous generated parse functions), and V8 compiles them with its **optimizing tier (turboshaft) on background threads** — the crash stack is 100% `ExecuteTurboshaftWasmCompilation`/`BackgroundCompileJob`. ama-5o1 had called that same turboshaft stack "a red herring" for the *leak*; for *this* failure it is the literal cause, and the second index crashes because the first index's background optimization is still churning after `index()` returns. The lever is one startup flag, `--liftoff-only` (pin WASM to the baseline compiler): peak RSS **2712MB → ~615MB**, flat across six re-indexes, no crash. Three tempting fixes were measured and rejected: `v8.setFlagsFromString` runs *after* V8 init so it's too late (still crashed at 2612MB), `NODE_OPTIONS` rejects the flag outright, and `--no-wasm-tier-up` is insufficient (crashed at 2603MB — only `--liftoff-only`, which forces baseline for the *initial* compilation too, bounds it). Since the flag is honored only at startup, can't ride in `NODE_OPTIONS`, and the server is started many ways (`tsx watch`, plain `node`, the `.mcp.json` spawn), the only self-enforcing fix is to **re-exec the process once** with the flag: `ensureBaselineWasmTier()` re-spawns `node --liftoff-only` preserving `process.execArgv` (so tsx's loader hooks survive) and forwards signals so a watcher restart leaves no orphan on the port. Wired into both server entries; verified live on the real `http.ts` — the re-execed child's command line literally shows `--liftoff-only`, and it indexed all 212 files at **615MB RSS** and served. Two lessons: (1) an OOM's stack names the *last allocator*, not the cause — twice now the turboshaft frame has misdirected, so trust the *memory accounting* (heap-flat + RSS-huge ⇒ native compile memory, not a JS leak) over the crash site; (2) when the fix is a **startup-only flag** that no env var or runtime call can set, a config-only change can't survive every launch path — re-exec makes the process enforce its own invariant. One-shot paths (CLI, vitest workers) peak at the same ~2.7GB on a *single* index and aren't covered by the server guard — filed as ama-xs8. Self-index 1291/3052/210 → 1302/3071/212 (+2 files: the guard module + its test). 490 → 491 tests; full suite + in-process self-index gate green; live re-index on the real HTTP server green at 615MB; typecheck + repo-wide lint clean. - 2026-06-20 · analyzer/robustness · **Isolation has to match the analyzer's shape — the deep tier can't borrow the baseline's clean boundary** (ama-bm2) — ama-eww gave the baseline analyzer per-*file* isolation cheaply because it already loops file-by-file (each file its own `parse()`), so a `try/catch` around one iteration is a clean boundary. The deep TypeScript analyzer can't: it batches every file into *one* `ts.createProgram` precisely so the checker resolves cross-file references — you cannot give each file its own program without losing the resolution that makes the tier "deep." So a throw in any per-file pass propagated straight out of `analyze()`, and the indexer's per-*analyzer* catch (ama-m8k.9) then dropped **all ~200 .ts files** — lower probability than baseline (TS is error-tolerant) but far higher blast radius. Dogfooding mapped the real shape: `get_code_snippet`/`find_callees` on `analyze` showed **three** per-file loops, not one — the structural walk (`walkFile`), a cross-file mount pre-pass (`collectMounts`), and the resolution block of nine passes (`collectRoutes`…`collectImports`) — so isolation is per-*pass-loop*, wrapping each loop body, not the baseline's per-file atomicity. Per-loop (not per-individual-pass) is the right granularity because the nine resolution passes share `declToId`/`mountPrefixes` and carry ordering dependencies (routes register inline-handler arrows that `collectCalls` later attributes bodies to), so a file's resolution is one indivisible unit; a failure mid-block skips the rest of *that* file and logs `failed on (); skipping it`, matching the baseline's message. Honest limitation kept truthful: unlike baseline's local-arrays-merged-on-success, a half-walked file can leave partial nodes — accepted because the shared program state makes per-file atomicity impractical and partial edges still point at real emitted nodes (non-traversable at worst, never a crash); the two whole-graph post-passes (`resolveDispatch`, `accumulateCallSites`) aren't per-file and stay outside this isolation. Testing used fault injection rather than a real pathological file (a missing file doesn't throw here — `getSourceFile` returns undefined, guarded): spy each pass to throw on `boom.ts`, listed **first** so without isolation its throw aborts the loop before `good.ts` is reached — good.ts's survival is the proof, across an `it.each` of all three loops. Live confirmation: `find_callers(reportFileFailure)` → exactly three call sites (lines 68/84/113), and the whole-repo re-index analyzed all 204 .ts files clean. Lesson: a robustness pattern that's free for one component (per-file loop) can be structurally impossible for another (one shared program) — copy the *intent* (degrade one file, not the language), not the mechanism, and let the code's actual shape pick the boundary. Self-index 1302/3071/212 → 1312/3082/215 (+3 files: 2 fixtures + test). 491 → 494 tests; full suite + self-index gate green; live re-index green; typecheck + repo-wide lint clean. - 2026-06-20 · runtime/tests · **One invariant, two enforcement mechanisms — because you don't control every launcher the same way** (ama-xs8) — ama-rgx fixed the server's grammar-WASM OOM by re-exec'ing with `--liftoff-only`, but left two surfaces that compile the same 11 grammars on a *single* index (peaking ~2.7GB): the `ama` CLI and the vitest workers (which index the whole repo in-process via self-index.test.ts and the 13 CLI tests that call `run()` directly — several workers in parallel multiply the risk). The invariant is identical — "compile grammar WASM on V8's baseline tier" — but the *enforcement* had to differ because the launch model differs. The CLI I **own**: the entry block is the same `if (process.argv[1] === …)` shape as the servers, so the fix is the identical one-liner — `ensureBaselineWasmTier()` re-execs the process. It's a *blanket* entry guard (not gated to indexing commands) because a cold-cache query command can fall back to a full index, so any command might compile grammars. The vitest workers I **don't** own — vitest spawns them — so re-exec is out; the flag has to be injected at spawn via `poolOptions.forks.execArgv: ['--liftoff-only']`. The non-obvious blocker: **`worker_threads` rejects the flag** ("Initiated Worker with invalid execArgv flags"), so the threads pool *cannot* carry this fix — `pool: 'forks'` (child processes, which accept node flags) is mandatory and set explicitly so it can't silently regress. Two clean RED→GREEN tests, each shaped by what's observable: the worker test asserts `process.execArgv` contains the flag *from inside a worker* (the flag's presence **is** the invariant — there's no JS API for "is this WASM Liftoff-compiled"); the CLI test (macOS-gated) measures the entry process's peak RSS via `/usr/bin/time -l` while it runs `ama index` (DB redirected with `AMA_DB` so it never touches the real index) — RED was an OOM *crash* (`status` null) from the in-process optimizing-tier compile, GREEN is the entry staying small because it delegated to a `--liftoff-only` child. (`time -l` measures the direct child, i.e. the supervisor, so GREEN proves *delegation*; exit-0 proves the grandchild finished, and ama-rgx's harness already proved the grandchild is bounded — the chain closes.) Dogfood aside that became a filed gap: `find_callers(ensureBaselineWasmTier)` → `[]` because all three call sites are *module-level* entry blocks with no enclosing symbol to attribute a `Calls` edge to; `find_importers` shows all three (cli/http/server) via the import edge, so the wiring is in the graph, just not as a call — filed ama-53q (attribute top-level calls to the File node). Lesson: enforcing the same invariant across processes you launch differently needs different levers — re-exec where you control argv, a spawn flag where a framework owns the workers — and the worker *transport* (forks vs threads) can decide whether a lever is even available. With this the grammar-WASM OOM is closed on every surface: server, CLI, test runner. Self-index 1312/3082/215 → 1318/3089/217 (+2 files: the two guard tests). 494 → 496 tests; full suite green under the new forks pool (~unchanged wall time); live re-index green; typecheck + repo-wide lint clean. - 2026-06-20 · analyzer/graph · **The re-index counts caught the bug the tests didn't — over-attributing top-level calls** (ama-53q) — closing the gap the *previous* iteration's dogfooding filed: `find_callers(ensureBaselineWasmTier)` returned `[]` because a call at module top-level has no enclosing function, so `collectCalls`'s `&& enclosingId` guard dropped it — entry-block and module-init wiring was invisible. The fix is to make the **File** node the fallback owner (it already originates Defines/Imports edges). The trap, and the real lesson: my first cut just *seeded* the recursion's enclosing scope with the File id — one line, both unit tests green — but the live re-index exposed it as wrong: `callsTotal` jumped **2753 → 7433** and edges **+465**, with the unresolved map suddenly showing `expect: 892, it: 476`. Cause: `collectCalls` makes *unregistered* callback arrows **transparent** (they pass the enclosing through, so `bar()` in `foo(){ arr.map(()=>bar()) }` attributes to `foo`) — intentional — but seeding the base with the File meant a *top-level* `describe(() => it(() => expect()))` propagated the File into every nested call, so the file appeared to "call" expect 892 times (the exact "leaking to the file" an existing comment warned of). The unit test passed because nothing guarded that case; the **counts were the detector** — a 2.7× `callsTotal` jump is a self-diagnostic. Correct fix: track `atFileScope` (true until the walk enters any `ts.isFunctionLike` node) *orthogonally* to `enclosingId`, and own a call by `enclosingId ?? (atFileScope ? fileNodeId : drop)`. So a genuine top-level statement (an entry block's `main()`, a top-level `new X().analyze()`) reaches the File, while a call nested in a transparent callback stays dropped — `enclosingId`'s existing semantics (and the `.map`/route-handler logic) are untouched. I encoded the over-reach as a regression test *before* the real fix (a `withCallback(() => nested())` whose `nested()` must NOT originate at the File — RED against the seed-only version, GREEN after). Result: sane counts (`callsTotal` 2753 → 2945, all genuine top-level calls; edges +26, not +465; `describe: 141` top-level calls counted, no nested-`expect` noise), and the payoff — `find_callers(ensureBaselineWasmTier)` now returns all three entries (cli/index.ts:161, http.ts:138, server.ts:581, `via: Calls`). Lesson: when a fix changes a *pervasive* derivation, unit tests prove the happy path but the aggregate self-index counts catch the blast radius — a number moving 2.7× is the bug telling on itself, so always re-index and read the deltas. Self-index 1318/3089/217 → 1327/3115/219 (+2 files: fixture + test). 496 → 499 tests; full suite + self-index gate green; typecheck + repo-wide lint clean. - 2026-06-20 · indexer/discovery · **An incomplete feature that fails toward *exclusion* is worse than one that fails toward inclusion — anchored .gitignore patterns** (ama-yhu) — ama-2eu shipped a safe subset of gitignore and was careful that every *skip* only ever indexes more (never drops a file the user needs). Reading the matcher to scope this iteration surfaced a place that quietly violated that: `/build` (root-anchored) had its leading slash *stripped* and became a bare any-depth name, so it over-ignored — `src/build/` got dropped too. So the "safe subset" had a hole that failed toward *exclusion*, the exact direction the design forbids. Fixing it needs path-awareness (match `/build` against the root-relative path, not a segment), which the bead flagged as the real work. Dogfooding kept the change small and de-risked: `find_callers` on `isIgnoredSegment`/`isIgnoredPath`/`loadIgnoreRules` showed the two consumers (`discoverFiles`'s walk and `FileWatcher`) both funnel through `isIgnoredPath` — so adding an `anchored: RegExp[]` to `IgnoreRules`, matching it in `isIgnoredPath`, and switching the walk from `isIgnoredSegment(name)` to `isIgnoredPath(rel)` covers the watcher *for free*. A pattern is anchored iff it has a leading or embedded slash (`/cache`, `pkg/internal`); it compiles to a full-path regex (`^pkg/internal(?:/|$)`, `*`→`[^/]*`). `**`, `!` negations, and nested .gitignore stay skipped (still fail-toward-inclusion) and move to ama-dd9. Verified the change is behavior-preserving for the repo itself by *reading the repo's own .gitignore first* — its only embedded patterns sit under `.beads/`, a dotfile dir already pruned — so the self-index file set shifts only by the new fixtures. Tested anchored-dir, anchored-file, and embedded-path cases, each with a "same name nested deeper is kept" assertion (the over-ignoring proof). Aside the re-index caught: the server's *incremental* catch-up reported 3097 edges where a full re-index gives 3127 — a 30-edge drift that says `reconcileFile` may be edge-lossy vs a full rebuild (filed ama-tr1). Lesson: when shipping a deliberately-partial feature, audit that every gap fails in the *safe* direction — a single stripped character (`/`) turned "index more" into "silently drop", which is the one outcome the whole subset was designed to prevent. Self-index 1327/3115/219 → 1344/3127/226 (+7 files: anchored-gitignore fixture tree). 499 → 502 tests; full suite + self-index gate green; typecheck + repo-wide lint clean. Deferred: ama-dd9 (** / negations / nested), discovered: ama-tr1 (incremental-sync edge drift). - 2026-06-20 · indexer/graph · **A whole-graph derivation can't be reconciled per-file — move it to the store** (ama-tr1) — the incremental-sync drift filed last iteration: a full index has 3127 edges, but an incremental catch-up after edits had 3097. A diff harness (each file's owned edges in a full index vs `analyze([file])`) pinned it: reindexing `indexer.ts` dropped **30** edges, ~28 of them interface-dispatch fan-out — `Indexer.index → {InMemoryStore,SqliteStore}.clear` etc. Root cause: `resolveDispatch` is a *whole-graph* inference — it fans an interface-method call to every implementer using the `Implements`/`Inherits` edges of *other* files. It ran inside per-batch `analyze()`, which is exact for a full index (the batch is the whole graph) but blind in a single-file reindex (the batch is one file, with no other file's implementers in view), so `reconcileFile` faithfully dropped the file's cross-file dispatch edges. The fix moves the derivation to the layer that owns the whole graph: extract `deriveDispatchEdges(nodes, edges)` to `graph/` (it reads only node/edge *kinds* — pure, language-agnostic), tag its output `provenance: "dispatch"` (honest: a fan-out is a *may-reach* inference, not a checker-resolved call — and the tag is the clear-marker), and have the indexer re-derive it over the *full store* after every `reindexFile` (clear the tagged set via a new `replaceEdgesByProvenance`, re-add the fresh derivation). `analyze()` keeps its per-batch pass so a full index and the analyzer unit tests are unchanged. Edge dedup is by `(from,to,kind)` (provenance-independent), so tagging never duplicates or shifts counts. Result: reindexing the three heaviest files drifts **-30 → -4**. The systematic-debugging discipline mattered twice: I confirmed the fix was *architectural* (not a one-liner) and checked in before the ~9-file change; and the residual **-4** the verify harness surfaced is a *distinct, smaller* sub-cause — cross-file edges to top-level **consts** (`nodeIdForDecl` doesn't id a `VariableDeclaration`; `collectVarReferences` gates on batch-local `variableIds`) — filed as ama-l6k rather than conflated. Lesson: if a derivation needs the whole graph to be correct, deriving it inside a per-file/per-batch step bakes in a blind spot the incremental path can't recover — host it where the whole graph lives, and make it cheaply re-runnable (pure function + provenance-tagged replace). Self-index 1344/3127/226 → 1370/3201/232 (+6 files: graph/dispatch.ts + dispatch-reindex fixture/test). 502 → 503 tests; full suite + self-index gate green; live re-index green; typecheck + repo-wide lint clean. Residual filed: ama-l6k (cross-file const-edge drift, -4). - 2026-06-20 · analyzer/incremental · **Closing the last -4: a "batch-local gate" and a missing id-by-location case both blind the single-file reindex** (ama-l6k) — ama-tr1 took the incremental-vs-full edge drift to -4; this finishes it to **0**. The residual was cross-file edges to top-level **consts**, from two independent spots that both assume the whole batch is in view: (1) `nodeIdForDecl` ids a decl by location for the cross-file fallback, but only handled top-level *functions/classes/members* — a `const`'s AST parent is the `VariableDeclarationList`, not the `SourceFile`, so it fell through and a reindexed file's `Imports`/read of a cross-file const (`MAX_FILE_SIZE_BYTES`, `NODE_KINDS`) didn't resolve; (2) `collectVarReferences` only emitted a `References` edge when the target id was in `variableIds` — the set of Variable *nodes in this batch* — which a single-file reindex never contains for another file's const. The fix makes both *batch-independent*: a shared `isModuleVariableDecl` (the VariableDeclaration→List→Statement→SourceFile chain) lets `nodeIdForDecl` id a top-level const, and a new `resolveModuleVarRef` gates References on the *decl kind* (resolves to a module const) instead of the batch-local id set — so the same edges resolve whether one file or all are analyzed. The `variableIds` plumbing (built in `analyze`, threaded through the recursion) deleted entirely. Behavior-preserving for a full index — a Variable node *is* a top-level const, so the decl-kind gate and the node-set gate coincide there; the suite (incl. the variable/referrer tests) stayed green. Verified end-to-end: reindexing the four heaviest files now drifts **0** (was -4), and a new test asserts both the `Imports` and `References` edges to a cross-file const survive reindex. Lesson: "is X one of the things I collected this run?" gates are invisible landmines for incremental work — phrase the predicate against the *thing itself* (the decl's kind/shape), not against the current batch's collected set, and it survives being run on a slice. Self-index 1370/3201/232 → 1376/3223/232 (no new files; +6 nodes/+22 edges from the const fixture + two helper fns). 503 → 504 tests; full suite + self-index gate green; incremental-sync drift now 0; typecheck + repo-wide lint clean. With this the ama-tr1/ama-l6k pair makes a single-file reindex edge-identical to a full index. - 2026-06-20 · indexer/discovery · **`**` deep globs — and why one regex pass beats a NUL sentinel** (ama-dd9) — extends the anchored gitignore matcher (ama-yhu) with `**`: a leading or mid `**/` matches zero or more directories, a trailing `/**` matches everything under the prefix, `a/**/b` spans directories. The pattern compiles to a full-path regex; the routing change is one line (a pattern with a slash *or* `**` is anchored). The instructive part was the *compilation*, where dogfooding caught my own smell twice. First cut tokenised the globs to placeholder sentinels, escaped regex specials, then expanded — standard, but I reached for **NUL** (`\0`) sentinels, and `grep` immediately reported `ignore.ts` as a *binary file* (a NUL byte flips heuristics — the same reason `edgeKey` uses JSON, not a NUL-delimited string, noted back in ama-zh0). The fix is simpler *and* avoids the NUL entirely: a **single regex pass** with an alternation `(**/ | ** | * | ? | regex-special)` and a replacer function — order in the alternation does the work the sentinels did (it tries `**/` and `**` before a lone `*`), and the expansions' own regex syntax (`(?:.*/)?`) is emitted by the replacer so it's never re-escaped or re-scanned. Behavior-preserving for the existing anchored/segment patterns (verified: only the new `**` fixtures changed). Negations stay deferred (ama-d28) — and skipping a `!` is itself a latent **over-exclusion** (a `!keep.log` after `*.log` should re-include, but skipping drops it), which needs git's ordered last-match-wins model, a real rebuild. Tested `**/x`, `dir/**`, and `a/**/b` against both `isIgnoredPath` and a real index, each with a kept-control. Lesson: a placeholder-sentinel string transform is a code smell when a single ordered alternation expresses the same precedence — and never put a NUL in source you want plain tooling (grep, diff) to treat as text. Self-index 1376/3223/232 → 1393/3233/240 (+8 files: globstar fixture tree). 504 → 506 tests; full suite + self-index gate green; typecheck + repo-wide lint clean. Deferred: ama-d28 (negations + nested .gitignore). - 2026-06-20 · indexer/discovery · **`!` negations — the over-exclusion the previous iterations kept flagging, fixed additively** (ama-d28) — `*.gen.ts` then `!keep.gen.ts` should re-include `keep.gen.ts`, but the loader *skipped* `!` lines, so the file the user explicitly wanted stayed dropped — the same fail-toward-*exclusion* bug class as ama-yhu's stripped leading slash. The textbook fix is git's ordered, last-match-wins rule list, a real rebuild of the flat `{names, globs, anchored}` model. I chose a lower-risk **additive** shape instead: keep the ignore sets, add `negations: RegExp[]`, and make `isIgnoredPath` return `ignored && !negations.some(match)`. A negation always wins, so the *rare* interleaved re-ignore-after-negation (`*.log` `!a/*.log` `a/secret.log`) over-*includes* rather than over-excludes — which is exactly the safety direction ama-2eu set, so the imperfection is the safe one. Compiling a negation reuses the existing matcher: a bare name routes through `anchoredToRegExp` as `**/name` (any-depth segment), a slash/`**` pattern anchors directly — so negations get `**`/anchor support for free. The walk's directory pruning stays valid because git can't re-include a file whose parent dir is excluded: an ignored dir is still pruned (no negation matches the dir path itself), so a negation inside it is moot. Additive ⇒ zero behavior change when a `.gitignore` has no `!` (the repo's own), and the existing 7 gitignore tests stayed green. Tested a bare and an anchored negation via `isIgnoredPath` + a real index. Lesson: when the correct model is a rebuild but the gap is narrow, an additive layer that fails in the *safe* direction can ship the fix now and defer the rebuild — provided you name which inputs it approximates (here: interleaved re-ignores, over-included). Self-index 1393/3233/240 → 1406/3244/245 (+5 files: negation fixture tree). 506 → 508 tests; full suite + self-index gate green; typecheck + repo-wide lint clean. Last gitignore piece — nested `.gitignore` (per-directory rules) — filed as ama-pyk. - 2026-06-20 · analyzer/baseline · **The import graph for non-TS languages — path resolution, not semantics, is what the baseline tier needs** (ama-8nr) — baseline analyzers emitted only File/symbol nodes + Defines edges, so `find_importers`/`find_imports`/`circular_imports` returned nothing for the 11 baseline languages: a Python or Go project got symbol search but no *dependency* graph, the thing those tools exist for. The key realization that made this a baseline-tier (syntactic) job, not a deep one: a **relative** import resolves to a file by *path alone* — `from .pkg import x` in `a/b.py` is `a/pkg.py` or `a/pkg/__init__.py` — no type-checker, no cross-file symbol resolution. So `LanguageSpec` gains an optional `resolveImports(node, importerRel) → string[][]` (each inner array = one import's ordered candidate files), and `BaselineAnalyzer.collectImports` emits a File→File `Imports` edge to the first candidate that **exists on disk** (`fs.existsSync` — only assert an edge we can back; an absolute `import os` returns `[]` and emits nothing). Per the standing rule I *dumped the Python import CST first* (`import_from_statement` → `relative_import` → `import_prefix` dots + optional `dotted_name`), which made the dot-counting (1 dot = the importer's package, each extra goes up) and the two shapes (`from .pkg import x` vs `from . import a, b`) fall out cleanly. Dogfooded live on Ama's own (new) fixtures: `find_importers("py-imports/helper.py")` → `main.py`, tier `baseline` — a non-TS language with a real import graph. Mechanism + Python shipped; the other languages (JS path-based like Python; Go/Rust/Java package-path harder) are ama-2dn. An aside the re-index surfaced and filed (ama-okg): `index_status`'s per-language `languages` census + `resolution` stats are written only by a *full* index, so after incremental sync they go stale (showed `python: 1` / `typescript: 221` when the true counts were 4 / 235) while node/edge/file counts stay live. Lesson: a feature can look like it needs the deep tier ("imports") when the *valuable* half is purely syntactic — relative-path resolution gives the whole intra-project dependency graph without any semantic analysis. Self-index 1406/3244/245 → 1417/3261/249 (+4 files: py-imports fixture + test). 508 → 509 tests; full suite + self-index gate green; live re-index green (python 1→4 files); typecheck + repo-wide lint clean. Follow-ups: ama-2dn (other languages), ama-okg (stale post-sync census). - 2026-06-20 · analyzer/baseline · **The mechanism generalizes — JavaScript imports cost one spec function** (ama-2dn) — the second path-based language validated the `resolveImports` hook from ama-8nr: JS was a single function in `javascript.ts`, no analyzer change. The shapes differ from Python but resolve the same way — an ES `import`/`export … from` carries its specifier in the node's `source` field, and a CommonJS `require("…")` is a `call_expression` whose `function` child is the identifier `require`; in both, a `./`/`../` specifier resolves against the importer's dir (and an extensionless `./util` tries `.js/.mjs/.cjs/.jsx` and an `/index.*` variant, mirroring Node resolution), while a bare `left-pad` is external → `[]` → no edge. The dividend the earlier abstraction work pays: the *first* language (Python) defined the interface and the on-disk-candidate model; the *second* just describes its grammar's import shapes — exactly the per-language-spec economy the symbol mapping already had. Dogfooded live on Ama's own fixtures: `find_importers("js-imports/util.js")` → `main.js` (resolving the extensionless `./util`), tier `baseline`. Two path-based languages now have a real import graph; the rest split by mechanism — Rust `mod foo;` is path-based and fits as-is, but Go/Java/C#/PHP use *package paths* that need suffix-matching against the file set, not exact `existsSync` (filed ama-90x). Lesson: the right test of an abstraction is the second instance — when it costs only a description of the new input's shape (and surfaces a clean fault line for the inputs that *don't* fit, like package-path resolution), the interface is carrying its weight. Self-index 1417/3261/249 → 1428/3274/253 (+4 files: js-imports fixture + test). 509 → 510 tests; full suite + self-index gate green; live re-index green (javascript 1→5 files); typecheck + repo-wide lint clean. Package-path languages filed as ama-90x. - 2026-06-20 · indexer/observability · **A value cached at one write path goes stale on the others — recompute it on read** (ama-okg) — `index_status`'s per-language `languages` census was written only by a *full* `index()` (`store.setMeta("ama:coverage")`); an incremental `sync`/`reindexFile` refreshed the node/edge/file counts (recomputed live from the store) but not that cached census. So the long-running server — which runs almost entirely in sync mode — chronically *misreported* coverage: this very iteration's live `index_status`, caught up by sync, showed `python: 1`/`javascript: 1` when the import-graph work had brought the true counts to `4`/`5`. The drift had been quietly accumulating for sessions (`typescript` had reached `236` while the cache still said `221`). The fix isn't to invalidate the cache on every write path (sync, reindexFile, the watcher's debounced reindex — three places, each needing add/remove deltas); it's to stop caching a thing that's cheap to derive: a new `Indexer.languageOf(rel)` (extension→analyzer→{language,tier} via the registry), and `indexStatus` recomputes the census from `store.allFiles()` on each call — O(files), always correct, zero write-path changes. (`resolution` *can't* take this route — its `unresolved` map records resolutions that *failed* and so left no edge, pure analysis metadata with no store footprint — so it stays a documented full-index snapshot (filed ama-84s) — a coverage estimate, never a correctness count.) TDD with a temp project (index a `.ts`, add a `.py`, `sync`, assert the census gains `python`); proved RED by momentarily reverting `indexStatus` to the cached field. Dogfooded on the live server: the sync-state `index_status` now reads `python: 4`/`javascript: 5` with no full re-index. Lesson: when a derived value is cached at one of several write paths it silently rots on the rest — if it's cheaply recomputable from the source of truth, recompute on read rather than racing to refresh every writer. Self-index 1428/3274/253 → 1432/3288/254 (+1 file: the coverage test). 510 → 511 tests; full suite + self-index gate green; live re-index green (census now live: python 4, javascript 5 after sync alone); typecheck + repo-wide lint clean. Resolution-freshness residual filed as ama-84s. - 2026-06-20 · analyzer/baseline · **Rust `mod` imports — and *why* the resolution strategy, not the grammar, decides which languages are easy** (ama-90x) — the third language for the import graph, but the real lesson was a scoping one found *before* writing code: I'd planned to do the package-path languages (Go/Java) via suffix-matching `com/example/Foo.java` against the analyzed file set — until I realized that breaks incrementally. `reindexFile(F)` analyzes only `[F]`, so the import *target* isn't in the batch and the edge would drop on every single-file reindex — the exact whole-graph blind spot that caused the dispatch drift (ama-tr1). What saves the path-based languages (Python, JS, now Rust) is that they resolve through `existsSync` against *disk*, not the batch — so a single-file reindex re-derives the same edges, no drift. That criterion — *does resolution depend only on the importer + disk, or on the whole file set?* — is what actually sorts baseline imports into easy (Rust `mod`) vs hard (Go/Java, refiled as ama-bsj with three incremental-safe options). Rust itself was a clean spec function with one genuine subtlety the CST dump made me get right: a `mod_item` with a `declaration_list` is inline (no file), and Rust 2018 puts a non-`mod.rs`/`lib.rs`/`main.rs` file's submodules in a directory named after its *stem* — so `mod deep;` in `sub.rs` is `sub/deep.rs`, which the fixture and a live `find_importers("rs-imports/sub/deep.rs") → sub.rs` both confirm. Compounding win: the census read `rust: 5` straight from the sync catch-up — last iteration's ama-okg live-recompute meant no full re-index was needed to see the new coverage. Lesson: for a per-file analyzer, an edge is only incremental-safe if it's derivable from the single file plus disk; anything needing the whole file set must resolve on disk (glob/heuristic) or at the store level — choose the resolution strategy by that test, not by how the grammar looks. Self-index 1432/3288/254 → 1443/3300/258 (+4 files: rs-imports fixture). 511 → 512 tests; full suite + self-index gate green; live census `rust 1→5` after sync alone; typecheck + repo-wide lint clean. Package-path languages (incremental-safe) filed as ama-bsj. - 2026-06-20 · analyzer/baseline · **Java imports — the "hard" package-path case was never hard; it needed the right candidate set** (ama-bsj) — last iteration I'd filed Java as the hard tier (a package name `com.example.Foo` doesn't say *which* source root, `src/main/java`, it lives under) and assumed it needed file-set suffix-matching, which I'd already shown drifts on single-file reindex. The unlock: don't search for the source root, *enumerate* it — try `com/example/Foo.java` under **every ancestor directory** of the importer and let `existsSync` pick the one that's real. That fits the existing exact-path mechanism with zero changes, is disk-based so single-file-reindex-safe, and adapts to *any* layout (Maven `src/main/java`, flat, Android) without a hardcoded root list — the source root is defined operationally as "the ancestor that makes the file exist." A second realization collapsed three import forms into one rule: Java's naming convention (lowercase packages, PascalCase types) means the class file is the dotted path *up to and including the first PascalCase segment* — so `import com.example.Foo`, `import static com.util.Helper.doIt` (member dropped), and a nested-type import all resolve to the right `.java` without ever parsing the `static` keyword or the type body. I locked the incremental-safety claim with its own test (analyze a *single-file* batch — what `reindexFile` passes — and assert the cross-file edges still resolve, where suffix-matching would drop them) and dogfooded live: `find_importers` on both the regular and the static-import targets returns `Main.java`, with the ancestor-scan finding `…/src/main/java` *inside* the repo path. Lesson: when resolution lacks a key (the source root), enumerating a small bounded candidate set against the source of truth beats searching for the key — and a language's own conventions (PascalCase = type) often replace a parser branch with a one-line predicate. Remaining package-path langs split by whether an import names *one file* (PHP PSR-4, clean) or *a directory of files* (C# namespaces, Go packages + go.mod) — filed ama-9yu. Self-index 1443/3300/258 → 1454/3312/261 (+3 files: java-imports fixture). 512 → 514 tests (+ the single-file-reindex drift test); full suite + self-index gate green; live census `java 1→4` after sync alone; typecheck + repo-wide lint clean. PHP/C#/Go filed as ama-9yu. - 2026-06-20 · query · **`impact_analysis` walked only `Calls`, so the blast radius of every *type* was empty — and dogfooding the fix found the next gap** (ama-8sw) — switching away from imports, I probed the query layer and hit a real one: `impact_analysis("LanguageSpec")` returned `[]`. The "what breaks if I change this?" tool BFS'd over `edgesTo(id, "Calls")` only — fine for functions, but a type/interface/constant is *referenced*, never *called*, so its blast radius came back empty even though the graph already held `UsesType` (251), `References` (225), `Returns`, `Instantiates`, `Implements`, `Inherits`, `Overrides` edges — every one meaning "source depends on target." Fix was small and surgical: reverse-traverse all those `IMPACT_EDGE_KINDS` (excluding structural `Defines` and the file-level `Imports`/`ImportsType`, which `affected` already covers). `impact_analysis("Store")` went from `[]` to ~85 true dependents (both impls via `Implements`, every `QueryService`/`Indexer`/CLI consumer, the MCP handlers). TDD with a hand-built store (a type used via `UsesType`, a const via `References`); proved RED first. The dogfooding *dividend*: the fix is correct, but running it on `LanguageSpec` surfaced a deeper, separate gap — only `BaselineAnalyzer.constructor` (its `spec: LanguageSpec` *param*) came back, not the four `const pythonSpec: LanguageSpec = {…}` specs, so the TS analyzer emits `UsesType` for *parameter* and *property* annotations but not *variable-declaration* ones (filed ama-g73). Lesson: a "blast radius" query is only as honest as the edge kinds it walks — pick them by the semantic ("source depends on target"), not by the one kind that was easy first; and a fix that returns *more* truth is the best probe for the *next* missing edge. Self-index 1454/3312/261 → 1458/3324/262 (+1 file: the impact test). 514 → 516 tests; full suite + self-index gate green; live `impact_analysis("Store")` []→~85 dependents; typecheck + repo-wide lint clean. Variable-annotation `UsesType` gap filed as ama-g73. - 2026-06-20 · analyzer/typescript · **The "missing UsesType" gap was really "missing node" — a *typed* object const deserves to be a symbol** (ama-g73) — last iteration's dogfooding left a precise lead, and the root cause wasn't where the title pointed. `collectTypeUsages` *already* handled `ts.isVariableDeclaration`, so the annotation wasn't unhandled — it had no *owner*: it emits the edge `from: enclosingId`, and `visit` had deliberately skipped emitting a node (and keying `declToId`) for any `const x = {objectLiteral}`, recursing into the object's members instead. The eleven baseline `const xSpec: LanguageSpec = {…}` specs (no function members) therefore produced *no node at all* — invisible symbols — so their `: LanguageSpec` reached `collectTypeUsages` with `enclosingId === undefined` and the `UsesType` was dropped. The param/property cases worked only because their enclosing symbol *was* keyed. (Found it the disciplined way: read `collectTypeUsages` → it handled the case → traced up to `visit`'s `!decl` branch, the actual culprit; don't fix the function the symptom names.) The fix splits on a clean signal: a *typed* object const (`const x: T = {…}`) is a named, typed, usually-exported symbol worth a node — emit it + key `declToId` so it's queryable *and* its annotation has an owner — while an *untyped* object literal stays node-less (no node per anonymous config/dispatch table). The reach was larger than the bead implied and that's the point: ~48 typed object consts (all 11 specs, the CLI command objects) became queryable `Variable` nodes, +193 edges as their internal references finally attribute to a symbol instead of vanishing — and the suite stayed green. Compounds ama-8sw end-to-end: `find_type_users("LanguageSpec")` []→all 11 specs, and `impact_analysis("LanguageSpec")` now reaches them, because the fidelity gap (no edge) and the traversal gap (wrong kinds) both had to close. Lesson: when a relationship is missing, check whether the *endpoint* exists before patching the edge-emitter — "no UsesType" was a node-emission decision two methods away. Self-index 1458/3324/262 → 1506/3517/265 (+3 fixture files; +48 nodes / +193 edges from typed object consts). 516 → 519 tests; full suite + self-index gate green; live `find_type_users("LanguageSpec")` []→11 specs; typecheck + repo-wide lint clean. - 2026-06-20 · indexer/discovery · **Nested `.gitignore` — rebase each subdirectory's patterns and reuse the matcher; the self-index count went *down*, which is the feature working on Ama's own fixtures** (ama-pyk) — the last gitignore piece: git applies a `.gitignore` to its own subtree, dir-relative (so `/build` in `pkg/.gitignore` means `pkg/build`, not the repo root), but discovery only read the root file. The clean fit was to *not* invent a second matcher: thread the accumulated `IgnoreRules` down the walk, and on entering a directory fold in its `.gitignore` by **rebasing** every line to that directory's repo-relative path and compiling it through the existing `anchoredToRegExp` — a bare name/glob becomes `/**/name` (any depth under it), a slash/`**` line becomes `/pattern` (anchored to it). So nested rules are just more `anchored`/`negations` regexes over the full path; `isIgnoredPath` is unchanged. The striking dogfood signal: the self-index *dropped* 265→256 files — because Ama's own gitignore **fixtures** (gitignore-proj/-anchored/-globstar/-negation, plus the new -nested) carry `.gitignore` files that, walked as *subdirectories* of the repo, now correctly exclude exactly the files they were built to designate as ignored (`generated/skip.ts`, `cache/c.ts`, …). Confirmed live: `node("…/pkg/keep.ts")` resolves, `node("…/pkg/secret.ts")` is null. A count going *down* after a feature is the right kind of surprise here — fewer, more-correct files — and it's the feature validating itself on Ama's own tree. The fixture proves the four cases that matter: a nested bare name (any depth under the dir), a nested glob, a nested anchored `/x` (the dir's root only, *not* `sub/x`), and that the root `.gitignore` still applies. Deferred: the watcher still checks only root rules, so it over-*watches* a nested-ignored file — fail-toward-inclusion (indexes more, never drops), filed ama-ezf. Lesson: when extending a matcher to a new scope, rebasing the input into the existing compiler beats a parallel code path — and validating on your own repo's fixtures can move a metric in the *unintuitive* direction precisely because it's correct. Self-index 1506/3517/265 → 1491/3520/256 (nested fixtures' designated-ignored files now excluded; the `src/`-only self-index gate is unaffected). 519 → 520 tests; full suite + self-index gate green; live `node` kept/excluded checks pass; typecheck + repo-wide lint clean. Git-complete `.gitignore` for discovery; watcher parity filed as ama-ezf. - 2026-06-20 · query · **`explore` was an NL tool that did exact-string name search — `totalMatches: 0` for the questions it's *for*** (ama-30q) — dogfooding the agent-facing search tools, `explore("how are baseline import edges resolved")` returned *nothing*, while `search_code("resolveImports")` found nine matches and `search_symbol("baseline")` worked. The root cause: `explore` delegates to `searchSymbol(question)`, which matches the *whole* question as a single string against the name index (`store.searchByName`) — no symbol is *named* "how are baseline import edges resolved", so a verbose NL question (exactly explore's purpose) always returns zero. The fix makes it keyword search: tokenize the question (lowercase, drop a stopword set + sub-3-char tokens), run each term through the *existing* `searchSymbol` (so per-term relevance, kind/path filters, and test/generated demotion all still apply), union the hits, and rank by **how many distinct terms a symbol matches**, then by best per-term position. A single-term question is unchanged (one term, ranked exactly as before); an all-stopword / filters-only query falls back to the raw search so `path:`/`kind:` still work. Live, the same question went `0 → 59` matches, ranked *well*: `importEdgesTo`/`importEdgesFrom` first (they hit both "import" and "edges"), then `collectImports`, `LanguageSpec.resolveImports`, the CLI import handlers — with their caller/callee graphs and a 76-symbol blast radius. TDD on a hand-built store (three symbols hitting different terms; assert the two-term symbol ranks first), RED proven. Lesson: a tool's *interface* makes a promise about its input — `explore` says "ask a question," so matching that question as one literal name is a category error; tokenize-and-union over the existing primitive is the small fix, and ranking by terms-hit is what turns a keyword union into something that feels like it understood. Self-index 1491/3520/256 → 1498/3545/257 (+1 file: the explore test). 520 → 522 tests; full suite + self-index gate green; live `explore` 0→59 matches; typecheck + repo-wide lint clean. - 2026-06-20 · query · **The same multi-word blind spot in `search_code` — but here the fix is phrase-*first*, not tokenize-always** (ama-ejh) — fixing `explore` made the obvious next question concrete: does `search_code` share the flaw? It does — `body.toLowerCase().includes(needle)` with `needle` = the *whole* query is a literal contiguous-substring (grep) scan, so `search_code("baseline import resolution") → []` even though symbols mention all three words. But the fix is *not* the same as explore's: `search_code`'s interface promises grep-like *literal* matching (`"new Map"`, `"import type"` are real phrase searches), so tokenizing always would make those noisier. The right shape is **phrase-first, term-fallback**: keep the contiguous-substring scan as the preferred result (zero regression for literal searches), and only when it finds nothing fall back to ranking symbols by how many of the query's terms their body contains (reusing the `exploreTerms` tokenizer). A single pass over each body does both — collect phrase hits, and meanwhile tally term hits for the fallback bucket — so the extra work is just `includes` checks, paid only when the phrase misses. Live, `search_code("baseline import resolution")` went `[] → 50`, ranked by terms-hit: the 3-term bodies (`BaselineAnalyzer`, `AnalysisResult`) first, then the import machinery, and the weak 1-term `here` consts (they contain `import.meta.url`) sort to the bottom — a graceful degrade, not a dead end. TDD with an on-disk fixture (a body holding "baseline" and "import" but never the phrase, plus a real contiguous phrase as the no-regression control). Lesson: two tools can have the *same* symptom and *different* correct fixes — the discriminator is the contract each makes (`explore` = ask a question → tokenize; `search_code` = find this code → literal first, then degrade). Reaching for the previous fix wholesale would have quietly broken phrase search. Self-index 1498/3545/257 → 1504/3553/259 (+2 files: the search-code fixture + test). 522 → 524 tests; full suite + self-index gate green; live `search_code` []→50 ranked; typecheck + repo-wide lint clean. - 2026-06-21 · indexer/watcher · **Closing the nested-`.gitignore` invariant: the watcher walks *up* one file's ancestors where discovery walked *down* — same rebasing, opposite direction** (ama-ezf) — `ama-pyk` taught discovery nested `.gitignore` but left the watcher checking only the root rules, so a changed file under a nested-ignored directory was still reported and re-indexed — breaking the "the watch set matches the index set" invariant the module's own comment promises (a fail-toward-inclusion gap: it over-*watches*, never drops). Discovery accumulates rules naturally as it descends; the watcher gets one path out of nowhere, so it reconstructs the same accumulation by walking that file's directory *ancestors* root-ward, folding in each `withNestedIgnore` exactly as the walk would — the identical rebasing primitive, just reached from the other end. Two cheap correctness touches: memoize per directory (`rulesByDir`) so a burst of edits in one folder reads each `.gitignore` once, and define the recursion so a root-level file short-circuits straight to the cached root rules (zero behavior change for the common, no-nested-gitignore project — which is why every existing watcher test stayed green). The injectable `WatchSource` made it a synchronous unit test (no OS file-event flakiness): fire `pkg/secret.ts` (nested-ignored → dropped), `pkg/keep.ts` and `pkg/sub/anchored.ts` (kept — the latter proving the dir-root anchor doesn't leak into `sub/`), reusing the `gitignore-nested` fixture. This is an *index-neutral* change — discovery already excludes these files, so the self-index content didn't move (only the new test file); the win is the incremental path no longer disagreeing with the full index. Lesson: when two code paths must agree on a predicate, find the shared primitive and feed it from each path's natural traversal — don't reimplement the rule, re-derive its *inputs* (here, the ancestor chain) and reuse the compiler. Self-index 1504/3553/259 → 1509/3566/260 (+1 file: the watcher test; index content unchanged). 524 → 525 tests; full suite + self-index gate green; typecheck + repo-wide lint clean. Nested `.gitignore` is now honored by both discovery (ama-pyk) and the watcher (ama-ezf) — the invariant holds. - 2026-06-21 · analyzer/typescript · **A "missing node" gap hid a "missing resolution" gap — parameter properties were invisible *and* the calls through them didn't resolve** (ama-259) — dogfooding `node()` on Ama's own pervasive pattern, `node("Indexer.registry")` and `node("QueryService.store")` both returned `null`. A TS *parameter property* (`constructor(private readonly registry: AnalyzerRegistry)`) is a real class member, but `visit()` only emitted nodes for class-*body* members and the constructor itself — the parameter properties, declared on the parameter, fell through. The fix is small: when visiting a `ConstructorDeclaration`, for each parameter carrying an accessibility/`readonly` modifier (`isParameterProperty` via `ts.getModifiers`), emit a `Property` under the class (`containerId`, not the constructor), with a `Defines` edge and — crucially — a `declToId` entry. That last detail is why the win was bigger than node count: with the property keyed in `declToId`, `this.registry.forFile(...)` / `this.store.edgesTo(...)` finally resolve their receiver, so `callsResolved` jumped 1067 → 1098 (+31) across the self-index — calls through every `this.` had been a quiet resolution blind spot. So the invisible-symbol bug was also a degraded-call-graph bug; one declToId entry fixed both. TDD on a fixture with a `private readonly`, a `public`, and a plain (modifier-less) parameter — asserting the first two become Properties, the third stays node-less, and the class `Defines` them. Confirmed live: `node("Indexer.registry")` now returns the Property with snippet `private readonly registry: AnalyzerRegistry`. Lesson: when a query returns `null` for something that obviously exists, the missing *node* often implies missing *edges* too — the same declaration the analyzer skipped was also the resolution target other code needed; emit it into `declToId` and the relationships light up for free. Self-index 1509/3566/260 → 1532/3598/262 (+2 fixture files; +23 nodes / +32 edges from Ama's own parameter properties; callsResolved 1067→1098). 525 → 528 tests; full suite + self-index gate green; live `node("Indexer.registry")` null→Property; typecheck + repo-wide lint clean. - 2026-06-21 · analyzer/typescript · **A deliberate scope-narrowing aged into a gap: `find_referrers` on any property was empty because `this.` reads weren't tracked** (ama-qo3) — the natural next dogfood after ama-259: `node("Indexer.registry")` now existed, but its `referrers` were `[]`. `collectVarReferences` had a guard — *"the member side of `obj.foo` is not an independent value read"* — that skipped every member identifier, so `this.registry`, `this.store`, `this.ignoreRules` (how a class actually uses its own fields and parameter properties) produced no `References` edge, and `find_referrers` on a property returned nothing. The guard wasn't wrong when written — it existed to stop the member `foo` being *mis-resolved* as a module variable `foo`. The fix honors that intent rather than removing it: for the member side of a `this.` access that isn't a method-call callee (`this.m()` is already a `Calls` edge), resolve it *properly* through the checker (`resolveValueRef` → the property's declaration, which now includes parameter properties because ama-259 keyed them in `declToId`) and emit a `References` from the enclosing method. So this builds directly on the prior fix — without ama-259's `declToId` entry the param-property receiver wouldn't resolve. Scoped to `this.` (same-class usage) deliberately; cross-instance `obj.` is noisier and filed as a follow-up. Live, `find_referrers("Indexer.registry")` went `[] → 4` (`index`, `languageOf`, `reindexFile`, `sync` — exactly the methods that read it). The graph grew by +167 `References` edges across the self-index (≈ every `this.` read, de-duped per method+property) — a real expansion of the usage graph, not noise. TDD on a parameter property read by two methods, asserting both become referrers. Lesson: a guard that was a correct *simplification* (skip the member to avoid mis-resolution) can become a *gap* once the toolkit grows the means to do the hard thing (checker-resolve the member) — revisit "we don't handle X" guards when the capability that made X hard has since landed. Self-index 1532/3598/262 → 1542/3765/264 (+2 fixture files; +167 edges, almost all `this.` References). 528 → 529 tests; full suite + self-index gate green; live `find_referrers("Indexer.registry")` []→4 readers; typecheck + repo-wide lint clean. Cross-instance `obj.` references filed as a follow-up. - 2026-06-21 · query · **A tool's stated contract is a checklist — `node` promised "everything about one node" but skipped a class's members** (ama-as5) — `node`'s docstring says *"Everything about one node in a single answer,"* yet `NodeView` had callers/callees/referrers/dependents and no **members**: `node("AnalyzerRegistry")` returned the class and its *relationships* but never listed `register`/`forFile`/`all`. For a small class the raw snippet covers it; for a 600-line `QueryService` you'd get an enormous snippet and still no outline. The structure was one `Defines`-edge traversal away — `members = edgesFrom(id, "Defines")`, sorted by source line — so the fix is query-only and needs no re-index: the edges already existed, the answer just wasn't surfaced. Live, `node("AnalyzerRegistry")` went from no members to the full ordered outline (`byExtension`, `analyzers`, `register`, `forFile`, `all`), making `node(class)` a complete view (structure + relationships + snippet) in one call. A side-discovery from the TDD: a hand-built-store test made `node()` *throw* `ENOENT` because `getCodeSnippet` reads the file unguarded — unlike `searchCode`, it doesn't catch a vanished file, so a stale index entry can crash the query (filed ama-pdz); I used a real fixture here and left that fix separate to keep the change atomic. Lesson: when a tool advertises completeness ("everything about X"), treat the claim as a checklist and audit each promised facet — `node` had the *relationships* half of "everything" but not the *structure* half, and the missing half was already in the graph. Self-index 1542/3765/264 → 1552/3787/266 (+2 fixture files; the members field is index-neutral — derived from existing Defines edges). 529 → 531 tests; full suite + self-index gate green; live `node("AnalyzerRegistry")` now lists 5 members; typecheck + repo-wide lint clean. getCodeSnippet's missing-file throw filed as ama-pdz. - 2026-06-21 · query · **The same disk-read, handled two ways: `searchCode` skipped a vanished file but `getCodeSnippet` threw** (ama-pdz) — the side-discovery from ama-as5, fixed. `getCodeSnippet` did a bare `fs.readFileSync(node.file)`, so `node()` / `get_code_snippet` *crashed* with `ENOENT` whenever a file had vanished since indexing (a stale index entry — exactly the long-running-server case the watcher reconciles asynchronously). Meanwhile `searchCode`, ten methods up, already had the right instinct: *"a file that vanished since indexing — skip it."* Same reality (the index can outlive a file), same `fs.readFileSync`, opposite handling. The fix is the three-line try/catch that returns `undefined` (no snippet) so the query still answers with the node and its relationships. TDD with a hand-built store pointing at a nonexistent root — which is itself the payoff: this bug was *why* ama-as5 couldn't use a hand-built store, so fixing it unblocks cheap unit tests of `node()` too. Lesson: when one site handles an environmental edge case (file gone, network down, value absent), grep for the *siblings* that touch the same resource — robustness gaps cluster at the read sites that were written before the failure mode was understood, and they don't announce themselves until a stale entry hits them in production. Self-index 1552/3787/266 → 1554/3792/267 (+1 test file; index-neutral). 531 → 533 tests; full suite + self-index gate green; typecheck + repo-wide lint clean. - 2026-06-21 · analyzer/baseline · **Go imports — the config-aware case: find the config relative to the *file*, not the index root** (ama-9yu) — the import-graph languages so far resolved by path alone (Python/JS/Rust relative, Java's ancestor-scan); Go is the first that needs *external config*: an import like `example.com/app/internal/store` is module-qualified, so resolving it to a directory requires `go.mod`'s `module` line to strip the prefix. Two mechanism additions. (1) `resolveImports` gained a `root` parameter — path-based languages ignore it, but a config-aware one needs the index root to read `go.mod`/`composer.json`; the existing four 2-arg resolvers satisfy the 3-arg type unchanged (TS lets a function take fewer params). (2) The make-or-break detail: resolve the config by **walking up from the importing file** to the nearest `go.mod`, not by reading `/go.mod`. A naive root-only lookup passes the fixture test (root = the module) but resolves *nothing* in the self-index, where the Go module sits at `tests/fixtures/go-imports/` — exactly the monorepo reality. The walk-up is the same pattern nested `.gitignore` used (ama-pyk/ezf: find the rule relative to the path), reused. Go also imports a *package* = a directory, so one import links to **every** non-test `.go` file in it (`readdir`, one edge per file) — and that's disk-based, so it stays single-file-reindex-safe. Dogfooded live on Ama's own fixture with `go.mod` in a subdir: `find_importers("…/go-imports/internal/store/store.go") → main.go`, proving the walk-up found the subdir module config. PHP (PSR-4 via composer.json) and C# (a `using` names a namespace that spans files, not one file) are filed as ama-x96. Lesson: when resolution depends on project config, the config is a property of the *file's location in the tree*, not of where indexing happened to start — walk up to it, the same way ignore rules and tsconfig do. Self-index 1554/3792/267 → 1564/3803/270 (+3 Go fixture files; go 1→4; the package import resolves to its 2 files). 533 → 534 tests; full suite + self-index gate green; live `find_importers` on the Go package → main.go; typecheck + repo-wide lint clean. PHP/C# filed as ama-x96. - 2026-06-21 · analyzer/baseline · **PHP imports — the second config shape proves the config-aware abstraction held** (ama-x96) — Go established the config-aware resolver (root param + walk up to the nearest config + map a logical name to a path); PHP was the test of whether that abstraction was real or Go-shaped. The two configs look nothing alike: `go.mod` is a one-line `module ` regex, PHP's `composer.json` is JSON with an `autoload.psr-4` *prefix→directory map*; Go resolves an import to a *directory of files*, PHP to *one class file*; Go strips a single module prefix, PHP picks the *longest matching* of several PSR-4 prefixes (`use App\Models\User` with `{"App\\":"src/"}` → `src/Models/User.php`); Go uses `/`, PHP `\`. Yet both reduce to the identical skeleton — walk up from the importing file to the config, map the logical name through it — so only the parse-and-map step is per-language; the `root` parameter and the cached nearest-config walk-up (the same primitive as nested `.gitignore`) carried straight over. Dogfooded live with `composer.json` in a subdir: `find_importers("…/php-imports/src/Models/User.php") → Main.php`, the walk-up finding the subdir config and the longest-prefix match stripping `App\`. The import graph now spans six languages — path-based (Python/JS/Rust), source-root-scan (Java), and config-aware (Go/PHP). C# is the genuinely-different remainder (a `using` names a namespace that spans many files, no 1:1 mapping) — filed ama-7e3 (P4), possibly a baseline non-goal. Lesson: an abstraction earns trust on the *second, deliberately-dissimilar* instance, not the first — pick the second case to maximize surface difference (JSON vs regex, map vs single value, file vs directory), and if the skeleton still holds, the seam between shared mechanism and per-instance detail is in the right place. Self-index 1564/3803/270 → 1573/3817/272 (+2 PHP fixture files; php 1→3). 534 → 535 tests; full suite + self-index gate green; live `find_importers` on the PHP class → Main.php; typecheck + repo-wide lint clean. C# filed as ama-7e3. - 2026-06-21 · indexer/robustness · **The audit a written-down lesson buys you: "grep for siblings" found the indexer's unguarded hashing** (ama-7r5) — ama-pdz's lesson ("when one read tolerates a vanished file, grep for the siblings") wasn't rhetorical, so I ran it: `search_code("fs.readFileSync")` listed every read site, and most were already guarded — `searchCode`, `getCodeSnippet`, the analyzers' per-file catch, gitignore, composer.json, and even `readRevision` (fully wrapped, so non-git deployments don't crash). That clean result is the reassuring half. The residual: `fingerprint()` and `isStale()` in the indexer did a bare `statSync` + `readFileSync`, and they're the *highest-stakes* read — a throw there fails the whole `index`/`sync`/`reindexFile`, not just one missing snippet. The trigger isn't exotic: a file present at `discoverFiles`/`existsSync` but gone microseconds later is exactly what an editor's atomic save (write-temp-then-rename) or a transient temp file produces in the *watch* path. The fix follows each function's natural contract: `fingerprint` returns null (the caller drops it / `removeFile`s the now-gone file), and `isStale` returns true — a vanished file *is* stale, so `reindexFile`'s own `existsSync` check reconciles the removal. Exported both for a unit test (`fingerprint(missing) → null`, `isStale(missing) → true`); proved RED by momentarily re-throwing — the same `ENOENT` ama-pdz first surfaced. Lesson: writing the lesson down is only half — running it on the next iteration is what converts "we should check" into a found bug; and when auditing a failure mode, the read whose failure is *most catastrophic* (the one that aborts a whole operation) deserves the guard first, even if its trigger is rarer than a query's. Self-index 1573/3817/272 → 1576/3803/273 (+1 test file; index-neutral — the edge delta is the changed indexer.ts re-deriving its own edges). 535 → 537 tests; full suite + self-index gate green; RED verified by reverting the guard; typecheck + repo-wide lint clean. - 2026-06-21 · query/mcp · **A silent cap is worse than no cap: search tools truncated at 50 with no "more exist" signal** (ama-b4q) — first iteration after the user steered toward incremental polish, found by dogfooding: `search_symbol("e")` returned exactly 50 of the hundreds of symbols containing "e", a bare list with no hint it was capped — so an agent can conclude a symbol *doesn't exist* when it's just past the limit. `explore` already exposes `totalMatches`; the two search tools gave nothing. The fix isn't to drop the cap (it's a deliberate output budget) but to make truncation *observable*: the handler requests `limit + 1` — a cheap, exact "is there more?" probe that needs no separate total count — and a small `capped()` helper slices back to `limit` and appends an advisory through the existing `reply()` hint slot (the same trailing-block channel the low-confidence ⚐ hint uses), composing the two when both apply. `search_code`, which had been wired through the generic `queryTool` (no hint path at all), got a custom handler so it can warn too. TDD on `capped()` directly (3>2 → sliced + "more exist"; 2≤2 → no hint; base hint composes); RED proved by stubbing `truncated=false`. Dogfooded live: `search_symbol("e", limit=3)` → 3 results + "⚠️ showing the first 3 matches — more exist. Refine with … filters, or raise `limit`." Lesson: when a tool caps output, *silent* truncation is the trap — the consumer acts on partial data believing it's whole; surface the cap (the `+1` probe is the cheapest way) and tell them how to widen. Self-index 1576/3803/273 → 1579/3815/274 (+1 test file; index-neutral). 537 → 540 tests; full suite + self-index gate green; live `search_symbol` now hints truncation; typecheck + repo-wide lint clean. - 2026-06-21 · query · **An empty query returned arbitrary symbols — a fallback for one valid case quietly absorbed a degenerate one** (ama-k3d) — surfaced *by the previous iteration*: once `search_symbol` started appending "…more exist", `search_symbol("")` returned 50 arbitrary nodes **plus** that hint — visibly wrong, where before it was just a quiet list. Root cause: `searchSymbol` does `text ? searchByName(text) : allNodes()`, the `allNodes()` branch existing for legitimately *filters-only* queries (`kind:Class`, `path:src` — no name term but a real filter). A completely empty query (no text **and** no filters) fell into that same branch and matched everything. The discriminator was wrong: not "is there text?" but "is there *anything to match* — text **or** a filter?" Fix is one guard: `if (!text && !pathFilter && !kind && !lang && !name) return []`. TDD proved it returns `[]` for `""`/whitespace while a filters-only `kind:Class` still resolves (`["Foo"]`) and free text still matches — RED by disabling the guard (empty test fails, the other two stay green, confirming no collateral). Lesson: when a fallback serves one valid shape, check it doesn't silently swallow the *degenerate* shape that looks similar (empty-vs-filters-only differ only by whether a filter is present) — and improving observability (the truncation hint) is what made this adjacent bug visible, so each ergonomics fix tends to expose the next. Self-index 1579/3815/274 → 1582/3828/275 (+1 test file; index-neutral). 540 → 543 tests; full suite + self-index gate green; live `search_symbol("")` now returns `[]`; typecheck + repo-wide lint clean. - 2026-06-21 · query · **Fix a bug, then check the sibling tool — search_code had the same empty-query hole via a different mechanism** (ama-d36) — the moment ama-k3d shipped I dogfooded the parallel tool: `search_code("")` returned 50 arbitrary symbols + the "more exist" hint, the *same* symptom as `search_symbol("")` but a *different* root cause. search_symbol's was a filters-only fallback (`allNodes()` when no name term); search_code's is that the literal-phrase scan does `body.includes(needle)` and `"".includes("")` is true for **every** body — a blank query matches everything. Same one-line shape of fix (`if (needle.trim() === "") return []`), different reason. Two lessons reinforced. (1) The "grep for siblings" instinct (ama-pdz, for code reads) applies to *tools*: search_symbol and search_code are a matched pair, so a class of bug in one is worth checking in the other immediately — and it was there. (2) A test for a *file-reading* query needs *real files*: a hand-built store would have masked this exact bug, because its nodes point at non-existent paths so `readFileSync` throws → caught → `[]`, making the empty query look already-fixed; I had to index the real `fixtures/search-code` so `searchCode("")` genuinely returned symbols (RED: 2 nodes) before the guard. Dogfooded live: `search_code("")` → `[]`. Self-index 1582/3828/275 → 1582/3828/275 (unchanged — the guard is a statement and the new test cases are anonymous callbacks, so no named symbols added; index-neutral in the strictest sense). 543 → 545 tests; full suite + self-index gate green; RED observed before the guard; typecheck + repo-wide lint clean. - 2026-06-21 · query · **A "show everything about X" tool must resolve X once — node() described the interface but listed the implementations' callees** (ama-d5o) — diversifying off search edge cases, dogfooded `node("analyze")`: it returned the *interface* signature `Analyzer.analyze` (a bodyless one-liner) yet reported **18 callees**. A signature can't call anything, so root-cause-first (systematic debugging, no fix yet): `find_callees("Analyzer.analyze")` → `[]` (the interface genuinely has none), but `node("analyze")`'s callees field had 18 — so node() wasn't deriving callees from its own primary node. Reading the code closed it: `node()` set `primary = resolve(ref)[0]` (one node) but built `callees: findCallees(ref)` from the **raw ref** — and `findCallees` re-resolves and *aggregates across every same-named match*, so the 18 were the union of `TypeScriptAnalyzer.analyze` + `BaselineAnalyzer.analyze`'s callees. The headline node and its relationship lists described *different symbols*. (Ruled out dispatch: `deriveDispatchEdges`' Calls fan-out is `from: edge.from` — the caller — never the interface.) `resolve` matches an exact id first (`getNode`), so the fix is to resolve once and query every field by `primary.id` (`members` already did; `callers/callees/referrers/dependents/snippet` didn't). TDD: two `foo` methods in different containers each calling a different helper — `node("foo").callees` must be the primary's one callee, not the aggregate of two; RED showed 2, GREEN shows 1. Dogfooded live: `node("analyze")` now → interface with `callees: []`, `callers: [index, reindexFile]` (which really do call `analyzer.analyze()`) — coherent. Lesson: an aggregate view keyed off the user's raw string silently re-resolves per-field, and the divergence is invisible until the ref is ambiguous; resolve to one identity, then derive every field from *that id*. Self-index 1582/3828/275 → 1585/3842/276 (+1 test file). 545 → 546 tests; full suite + self-index gate green; RED before the fix; typecheck + repo-wide lint clean. - 2026-06-21 · query · **When a tool must pick one of many, hand back the rest — node() now exposes the same-named alternatives it used to discard** (ama-ceh) — the direct follow-on to ama-d5o: making `node("analyze")` *coherent* exposed that it still silently picked the interface `Analyzer.analyze` out of four same-named symbols, hiding the implementations an agent more likely wanted. The data was already in hand — `resolve(ref)` returns *all* matches and `node()` just took `[0]` — so the fix is nearly free: keep the array, set `primary = matches[0]`, add `alternatives: matches.slice(1)` to `NodeView`. No ranking change, no new query; just stop throwing away what was computed. TDD on the existing two-`foo` fixture (one primary, one alternative; and `[]` for a unique ref like `helperA`); RED via a `[]` stub before wiring `matches.slice(1)`. Dogfooded live: `node("analyze")` → primary `Analyzer.analyze` + `alternatives: [TypeScriptAnalyzer.analyze, fakeTs.analyze, BaselineAnalyzer.analyze]`, so an agent sees the four and can re-query a specific one by qualifiedName. Lesson: resolving an ambiguous ref to a single node is a *lossy* choice the caller can't see or undo — when the alternatives are already computed, returning them turns a silent pick into a transparent, reversible one. Pairs with ama-d5o: that made the single view internally consistent; this makes the *selection* itself visible. Self-index 1585/3842/276 → 1586/3844/276 (+1 node: the NodeView.alternatives field; no new file). 546 → 548 tests; full suite + self-index gate green; RED via stub before the real slice; typecheck + repo-wide lint clean. - 2026-06-21 · query · **A summary tool must not exceed the thing it summarizes — file_skeleton's uncapped dependents dwarfed a 26-line file** (ama-2by) — diversifying to under-dogfooded tools: `find_types_used` checked out (`QueryService.node` → `[NodeView]`), but `file_skeleton("src/graph/id.ts")` — a tool whose whole pitch is "a cheaper alternative to reading the whole file" — returned a ~600-line response for a **26-line** file, because its `dependents` field is uncapped and id.ts is foundational (55 re-export-aware importers, mostly tests). For a foundational input the summary *inverts* its value proposition: bigger than the source. Fix mirrors the search cap-and-signal (ama-b4q) but for a *structured* result, where the signal is a field not a reply hint: keep `symbols` (the primary outline) whole, cap `dependents` at `SKELETON_DEPENDENTS_LIMIT` (25), and add `dependentsTotal` so the full count is visible and find_importers still gives the complete list. TDD: a file with `LIMIT + 5` importers → `dependents.length === 25`, `dependentsTotal === 30`; plus an under-cap case returns all; RED by widening the slice. Dogfooded live: `file_skeleton("src/graph/id.ts")` now → 25 dependents + `dependentsTotal: 55`. Lesson: when a view bundles a primary projection (the outline) with a secondary aggregate (who depends on it), the secondary is the one that scales with the *rest of the repo*, not the file — bound it and expose its total, or a hub file makes the "cheap" summary the expensive path. Self-index 1586/3844/276 → 1590/3853/277 (+1 test file). 548 → 550 tests; full suite + self-index gate green; RED by widening the cap; typecheck + repo-wide lint clean. - 2026-06-21 · query · **A smart fallback must announce itself — search_code silently degraded phrase precision to term recall** (ama-dve) — found while *looking for gaps*: dogfooding `search_code("KNOWN GAP")` to locate analyzer "KNOWN GAPS" comments returned 43 results, none containing the phrase (grep confirms zero literal "known gap" in src/). search_code prefers a literal contiguous phrase but falls back to OR-ing the query's words when the phrase misses (ama-ejh) — good for *recall*, but the fallback was **silent**, so loose word-matches (a body with "known" here and "gap" there) masquerade as phrase hits with no way to tell. search_symbol already warns on loose substring hits (lowConfidence, ama-b79); search_code had no equivalent. Fix mirrors that precedent: a private `scanCode` returns `{ results, viaTerms }`, public `searchCode` keeps its `GraphNode[]` shape, and `searchCodeWithConfidence` exposes the flag; the MCP handler adds a "no body contains the exact phrase … these match its words separately" hint when `viaTerms`, which *composes* with the existing truncation hint through `capped`'s base-hint slot (both fire for a broad miss). TDD reused the search-code fixture: `"baseline import"` (words present, never contiguous) → `viaTerms` true; `"resolves an import"` (a real phrase) → false; RED by forcing the flag false. Dogfooded live: `search_code("KNOWN GAP", limit=2)` now returns two stacked warnings — phrase-not-found + more-exist. Lesson: any search path that *relaxes* its match (substring, phrase→terms, fuzzy) trades precision for recall, and that trade is invisible to the caller unless surfaced — every relaxation needs a confidence signal, and the meta-point: hunting for gaps surfaced one in the gap-hunting tool itself. Self-index 1590/3853/277 → 1593/3863/277 (+3 methods; no new file). 550 → 552 tests; full suite + self-index gate green; RED by forcing the flag; typecheck + repo-wide lint clean. - 2026-06-21 · query · **An overview must cover every relationship the graph models — node() knew the call graph but not inheritance** (ama-vtp) — verifying the OOP relationship tools (`find_implementations("Analyzer")` → both analyzers, `find_overridden_by("Analyzer.analyze")` → both impls via dispatch, `find_interfaces("BaselineAnalyzer")` → `Analyzer`) showed they all work — which exposed that the flagship `node()` "everything about X" overview carried callers/callees/members/referrers/dependents/alternatives but **no inheritance at all**: `node("Analyzer")` didn't list its implementers, `node("Analyzer.analyze")` didn't show who overrides it. The graph models `Implements`/`Overrides` (11 + 49 edges) and dedicated tools expose them, but the aggregator that's supposed to be one-call-complete silently omitted that whole dimension. The fix was nearly free — the per-relationship query methods (`findOverrides`/`findOverriddenBy`/`findInterfaces`/`findImplementations`) already existed; `node()` just wasn't calling them. Added four `NodeView` fields (each populated per kind, `[]` otherwise): a method's `overrides`/`overriddenBy`, a class's `interfaces`, an interface's `implementations`. TDD on a fresh I-implements/C-overrides fixture across all four kinds; RED by stubbing the fields `[]`. Dogfooded live: `node("Analyzer.analyze")` now → `overriddenBy: [BaselineAnalyzer.analyze, TypeScriptAnalyzer.analyze]` — the interface method finally shows its implementers. Lesson: when you teach the graph a new edge kind, wire it into the *aggregator* too, not just a dedicated query — an overview that covers only some relationship dimensions lies by omission (callers present, implementers absent reads as "no implementers"). Self-index 1593/3863/277 → 1600/3889/278 (+1 test file, +4 NodeView fields). 552 → 556 tests; full suite + self-index gate green; RED by stubbing the fields; typecheck + repo-wide lint clean. - 2026-06-22 · query · **A relationship has two directions; an overview that shows one is half-blind — file_skeleton had dependents but not imports** (ama-1jv) — the file-level echo of ama-vtp. `file_skeleton` listed `dependents` (who imports this file, the *incoming* edge) but not what the file *imports* (the *outgoing* edge), even though `find_imports` exposes it — so the "cheaper than reading the file" outline omitted the very imports you'd see at the top of the file. Added an `imports` field = the files this file depends on, deduped from the `Imports` edges' target declarations to their File nodes (find_imports keeps the per-symbol detail; the skeleton wants the file-level summary). The two fields now make a complete one-call dependency view: `file_skeleton("src/cli/commands/sync.ts")` → imports `[indexer.ts, sqlite.ts, emit.ts, paths.ts]`, defines 4 symbols, depended-on by 2. The instructive asymmetry is in *bounding*: incoming `dependents` scales with the whole repo (a hub file has dozens) so it's capped (ama-2by); outgoing `imports` scales with the *file itself* (you only import so much) so it's uncapped — the direction pointing at the rest of the world is the one that needs a cap. TDD: two symbols from one file dedup to a single imported file; a leaf imports nothing; RED by stubbing `imports` empty. Lesson: model both directions of any edge in an aggregator, and let each direction's *cardinality* (scales-with-file vs scales-with-repo) decide whether it needs a cap. Self-index 1600/3889/278 → 1601/3894/278 (no new file; +1 FileSkeleton field). 556 → 558 tests; full suite + self-index gate green; RED by stubbing imports; typecheck + repo-wide lint clean. - 2026-06-22 · query · **The most useful bytes sit just outside a symbol's range — get_code_snippet dropped every doc comment** (ama-43e) — dogfooding the heavily-used `get_code_snippet` on a documented function: `get_code_snippet("reply")` started at `function reply(…)` and dropped the four-line `/** … */` above it. Root cause is definitional: a symbol's *range* is its declaration span, and a leading JSDoc is separate trivia outside that span — so a range-based snippet structurally excludes the comment that best explains the symbol, in a codebase where almost every function is documented. Fix is a backward walk in `getCodeSnippet`: from the line above the declaration, absorb a contiguous comment block (`//` or `/* */`/`*`/`*/` lines) into the snippet's start, stopping at the first blank or code line. The blank-line stop isn't arbitrary — it's the same adjacency rule TypeScript itself uses to bind a JSDoc to a declaration, so borrowing it keeps the snippet honest (a comment separated by a blank line isn't this symbol's doc). TDD on a fixture: a JSDoc'd `foo` (startLine pulled from 2→1, text carries the comment) and a blank-line-separated `bar` (unchanged, doesn't reach the earlier comment); RED by disabling the walk. Dogfooded live: `get_code_snippet("reply")` now → startLine 18 with the full JSDoc then the body. Lesson: a range and its documentation are stored apart but consumed as one unit; a tool meant for *understanding* a symbol should reunite them — and when you need an adjacency heuristic, copy the source language's own (no blank line ⇒ bound) rather than inventing one. Self-index 1601/3894/278 → 1608/3909/280 (+2 files: fixture + test). 558 → 560 tests; full suite + self-index gate green; RED by disabling the walk; typecheck + repo-wide lint clean. - 2026-06-22 · query · **A false-passing test almost hid the fix — search_code couldn't see doc comments either** (ama-jxp) — the search-side twin of ama-43e: `search_code` scans each symbol's *range*, so the same doc-comment-outside-the-range blind spot meant a concept search matching a symbol's *documentation* but not its code found nothing. Factored ama-43e's backward walk into one `commentAwareStart` helper now shared by `getCodeSnippet` (to *show* the docs) and `searchCode` (to *search* them) — one definition of "a symbol plus its leading comment," two consumers. The sharp lesson came from the test: my first version searched `"Doc comment for foo"` and asserted `foo` was found — and it passed *even with the fix reverted*, because `search_code`'s term fallback matched the word **foo** in the function's own name, never touching the comment. A test that asserts "Y is found via path P" is a false pass if Y is reachable by any *other* path. Rewrote it to isolate the doc-comment path: a `debounce` function whose JSDoc says "Coalesce" — a word absent from its body, absent from its name, and single (so the ≥2-term fallback can't fire), so *only* reading the comment can match. RED then real. (Bonus self-bite: the helper's own JSDoc contained a literal end-comment marker as an example, which closed the comment early and broke the parse — a comment about comments has to mind its own syntax.) Dogfooded live: `search_code("atomic save")` → `fingerprint` alone, matched purely on its doc comment ("an editor's atomic save"), nowhere in its code. Lesson: when testing that a path produces a result, kill every alternate path to that result first, or the test proves nothing; and a shared helper is the right shape when two features are two views of the same underlying span. Self-index 1608/3909/280 → 1610/3913/280 (no new file; +1 helper, +1 fixture symbol). 560 → 561 tests; full suite + self-index gate green; RED with scanCode reverted; typecheck + repo-wide lint clean. - 2026-06-22 · analyzer/baseline · **The fuzziest import language needed no new primitive — C# is ancestor-scan + package-dir + a suffix-strip** (ama-7e3) — C# (user-chosen) completes the import graph to seven languages, and it's the one I'd flagged as possibly a non-goal because a `using` names a *namespace*, which is a **set** of files with no 1:1 file mapping. But it decomposed into mechanisms the cleaner languages already established: a namespace is conventionally a directory, so a `using` links to every `.cs` file there (Go's "package = directory", via `readdir`); the directory isn't rooted at the repo, so try it under each ancestor of the importer (Java's ancestor-scan); and a .csproj `RootNamespace` can rebase the dotted name onto a shorter path (`App.Models` living in `App/Models/`, the `App` segment dropped), which a **new** twist handles *without* parsing .csproj — try progressively shorter suffixes of the dotted name, longest first, under each ancestor, taking the first directory that actually holds `.cs` files. So `using App.Models` from `App/Program.cs` matches `App/Models` by the suffix `Models` at the importer's own dir. Confirmed the CST first (a throwaway parse dump: `using_directive → qualified_name`, with an alias carrying a `name_equals` child to skip). Dogfooded live in the self-index: `find_importers(".../App/Models/User.cs") → Program.cs`. Fuzziness is real and bounded on purpose — longest-suffix-first, closest-ancestor-first, and requiring the directory to contain `.cs` files keep coincidental matches down; favoring recall is the right call at the baseline tier, with .csproj `RootNamespace` parsing filed as a precision follow-up. Lesson: the resolution shapes accumulated across this epic (path-based, ancestor-scan, package-dir, config-walk-up) are a *toolkit* — the hardest language was reachable by a new **composition** of them plus one small heuristic, not a new foundation; and when config would buy precision at real cost, a convention heuristic that nails the common case is the better baseline trade. Self-index 1610/3913/280 → 1618/3922/282 (+2 C# fixture files; csharp 1→3; +1 Imports edge). 561 → 562 tests; full suite + self-index gate green; live `find_importers` on the C# class → Program.cs; RED by removing resolveImports; typecheck + repo-wide lint clean. - 2026-06-22 · analyzer/baseline · **Right after the hardest import language came the easiest — C/C++ is the very first primitive reused** (ama-ftg) — having just composed C#'s ancestor + package-dir + suffix-strip, C/C++ `#include` resolution is a ~15-line reuse of the *simplest* shape: a quoted `#include "foo.h"` is a path relative to the including file (`path.posix.join(dirname(importer), include)`, with `join` normalizing `..`), resolved on disk like a JS/Python relative import; the angle form `#include <…>` (a `system_lib_string` node) is a system/include-path header and resolves to nothing. The only C/C++-specific wrinkle is routing: `.h` is parsed by the C++ grammar (a superset) while `.c` uses the C grammar, so the shared `includeImports` has to sit on *both* `cSpec` and `cppSpec` — `resolveImports` runs on the *importer's* analyzer, and a `#include` can originate from either. Confirmed the CST with a throwaway dump (`preproc_include → string_literal → string_content` vs `system_lib_string`). Dogfooded live: `find_importers(".../c-imports/util.h") → main.c`. Lesson: completing a breadth feature is not monotonic in difficulty — backlog/user order put the hardest case (C#) before the easiest (C/C++), and the toolkit made the easy one nearly free, which is exactly the payoff of having generalized the mechanism earlier. The import-graph epic now spans **nine** languages via four resolution shapes: path-based (Python/JS/Rust/C/C++), ancestor-scan (Java), and package-dir+config (Go/PHP/C#). The genuinely-harder tail is Kotlin (package need not mirror the directory) and Swift (module-level imports with no file mapping at all). Self-index 1618/3922/282 → 1623/3927/284 (+2 C fixture files; c 1→2, cpp 1→2; +1 Imports edge). 562 → 563 tests; full suite + self-index gate green; live `find_importers` on the header → main.c; RED by removing resolveImports; typecheck + repo-wide lint clean. - 2026-06-22 · analyzer/baseline · **The import epic closes at ten languages — Kotlin is package-dir with a member-aware split, Swift a principled non-goal** (ama-e23) — Kotlin's free-form file naming (a `.kt` holds any declarations, filename need not match a class) rules out Java's filename=classname trick, so it's "package = directory" like Go: drop the trailing symbol to get the package, ancestor-scan to its directory under a source root (`src/main/kotlin`), and link *every* `.kt` file there. The new wrinkle is the split — three cases off one heuristic: a `*` wildcard imports the package itself (keep all segments); otherwise the package ends before the first PascalCase segment (Java's class-boundary rule, `import a.b.C` → `a.b`); and an all-lowercase `import a.b.foo` (a top-level function/property) drops just the last segment. Confirmed the CST first (`import_header → identifier` of `simple_identifier`s, with a sibling `wildcard_import`). Dogfooded live: `find_importers(".../com/example/Foo.kt") → Main.kt`, and the fixture proves the package-dir fan-out — one `import com.example.Foo` links *both* `Foo.kt` and its sibling `Bar.kt` (fail-toward-inclusion, since the class isn't tied to a filename). With Kotlin the import graph spans **ten** languages; **Swift is deliberately left out** — its `import Foo` names a *module* (a build-system target spanning arbitrary files), so there's no file (or even directory) to point at, and a wrong edge is worse than none. Noted the third copy of an ancestor-walk helper (java/csharp/kotlin) and filed the extraction (ama-mgn) rather than grow the duplication silently. Lesson: a breadth epic ends not when every member is covered but when the *remaining* members are covered-or-justified — Swift's exclusion is a finding, not a gap, and saying so (with the reason) is the honest close; and reach for a `package = directory` model whenever a language decouples symbol identity from file identity (Go, C#, now Kotlin). Self-index 1623/3927/284 → 1634/3939/287 (+3 Kotlin fixture files; kotlin 1→4; +2 Imports edges from the package fan-out). 563 → 564 tests; full suite + self-index gate green; live `find_importers` on the Kotlin class → Main.kt; RED by removing resolveImports; typecheck + repo-wide lint clean. - 2026-06-22 · analyzer/baseline · **Consolidating the epic's accreted helpers showed they were one primitive in three dresses** (ama-mgn) — the responsible close to the import epic: clean up the duplication it accumulated rather than leave it. `search_symbol("ancestorDirs")` confirmed the smell live — two identical copies (csharp + the kotlin one I'd just added), with java carrying a third, file-based cousin `ancestorCandidates`. Extracted `ancestorDirs` to a shared `baseline/paths.ts`; csharp and kotlin now import it. The satisfying part was java: `ancestorCandidates(rel, file)` — "candidate files of that name under every ancestor" — is *literally* `ancestorDirs(rel).map(d => d ? \`${d}/${file}\` : file)`, the dir walk times a filename, so the file-based helper collapsed into a one-line adapter over the dir-based one, and java.ts dropped its `node:path` import entirely (the walk was its only use). A refactor's TDD is split honestly: a *new* unit test locks the extracted helper's contract (`ancestorDirs("a/b/c.ts") → ["a/b","a",""]`), while the existing 10-language import suite is the behavior-preservation net. Dogfooding makes the consolidation self-verifying both ways — `search_symbol("ancestorDirs")` went 2→1 definition, and `find_importers(".../java-imports/.../Foo.java") → Main.java` proved the live import graph survived the rewrite. Lesson: when a breadth feature accretes a near-identical helper per case, the duplication is a signal that they share a primitive — extract it, and check whether the *other-shaped* copies (here java's file-candidates) are that same primitive composed, not a separate thing; the count of definitions before/after is a cheap, exact proof the dedup landed. Self-index 1634/3939/287 → 1635/3943/289 (+2 files: paths.ts + its test; −1 function net as two copies fold into one). 564 → 566 tests; full suite + self-index gate green; RED: the new test couldn't resolve paths.js until extracted; typecheck + repo-wide lint clean. - 2026-06-22 · mcp/query · **A single routing chokepoint made "thread a param through 20 tools" a one-line-per-site change — cross-project foundation** (ama-ont) — first slice of the big feature the user chose. The session was single-project, but every one of its ~20 query methods funnels through one `requireQuery()` — so the multi-project rewrite is concentrated there, not smeared across the call sites. Made the session hold a registry (`Map`); `index_repository` became additive (a new root is kept; the same root replaces+closes its old store); `requireQuery(projectPath?)` resolves to the project whose root *contains* the path (longest-root wins, for nested/monorepo trees) or the primary; and the existing `store`/`query`/`stats` stay as cached *primary* refs, so the watcher/auto-sync path is untouched (the primary is the live project, secondaries are read-only snapshots — a deliberate v1 scope). With the chokepoint carrying the logic, threading `projectPath` through a method is one line (`requireQuery(projectPath).…`), and through a tool is one schema field. Sliced honestly: proved it end-to-end on `search_symbol` + `node` + `index_status.projects`, with the other ~18 tools deferred to mechanical follow-ups (ama-m8k.6 stays open). TDD at the session level (index two fixture projects; `projectPath` routes to the named one, omitting it hits the primary, an unknown path throws); RED by disabling the `projectPath` branch — exactly the routing-dependent cases failed while the registry-additivity and primary cases stayed green, isolating the new behavior. The live dogfood taught a separate lesson: editing `session.ts` mid-run left the hot-reloaded server with a *stale 2-node Ama* in its registry (the in-memory session is volatile across source edits), so the first cross-project query came back empty until a re-index restored the real index — the test is the proof, the live check only a confirmation, and a volatile dev server can lie. After the re-index: with Ama primary, `search_symbol("alpha", projectPath="tests/fixtures/xproj-a") → a.ts#alpha` — a non-primary project queried by path. Lesson: when a feature means "do X at N call sites," first find whether those sites already share a chokepoint; if they do, the feature is a small change there plus N trivial pass-throughs, and you can ship the engine + a couple of surfaces as a reviewable slice rather than all N at once. Self-index 1635/3943/289 → 1651/4006/292 (+3 files: the cross-project test + two fixture projects). 566 → 571 tests; full suite + self-index gate green; RED by disabling projectPath routing; typecheck + repo-wide lint clean. - 2026-06-22 · mcp/query · **The unit test's fresh stores hid a live shared-store bug — multi-project only works if each project owns its store** (ama-74r/ama-mnj) — continuing the cross-project rollout, I threaded `projectPath` through all the session methods (the engine is now fully multi-project) and a batch of MCP tools (find_callers/callees/importers/imports, get_code_snippet, file_skeleton), and the unit test was green — index two fixture projects, route a query to each by path. Then the *live* dogfood failed in a way the test could not: `index_status` reported two registered projects with *identical* counts (the `xproj-a` entry showed Ama's 1652 nodes), and `find_callers`/`get_code_snippet` via `projectPath` returned Ama's data / null. Root cause: `Indexer.index()` calls `this.createStore()`, and the default `() => new InMemoryStore()` mints a *fresh* store per call — so the unit test's two projects are genuinely independent and it passes — but the live server reuses one store (a persisted/singleton), so every registry entry aliases the *last-indexed* project. The registry was holding N references to one mutating store. The deeper lesson is about the test itself: a unit test that constructs a fresh resource per instance *cannot* catch a bug that exists only because the real deployment *shares* that resource — its isolation is exactly what hides the aliasing. To test multi-instance behavior you must mirror the deployment's resource lifecycle, and absent that, the live dogfood is the only thing that catches it (which is why a re-index gate against the running server earns its keep). So the rollout reprioritized: threading the remaining ~14 tools is premature until each project gets an independent store (filed as the P2 blocker ama-mnj), and the navigation/read batch shipped is correct-but-blocked — right with isolated stores, wrong while they alias. Self-index 1651/4006/292 → 1652/4008/292 (+1 fixture symbol useAlpha for a meaningful cross-project find_callers). 571 → 572 tests; full suite + self-index gate green; RED by disabling projectPath routing; typecheck + repo-wide lint clean; live dogfood exposed the store-aliasing blocker. - 2026-06-22 · indexer · **Parameterize the factory by the thing it's a factory *for* — the store-aliasing fix that made cross-project work live** (ama-mnj) — the blocker from last iteration: `Indexer`'s `createStore: () => Store` is a *zero-arg* factory, so a persistent factory (`() => new SqliteStore(fixedPath)`) hands every project the same db, aliasing them. A `() => Resource` factory bakes in "one resource"; the fix is to thread the discriminator — here the project root — so it becomes `(root) => Resource` and each project can get its own. Widened `createStore` to `(root: string) => Store`, passed `root` at the two call sites (`index`, `open`), and made the HTTP server persist *one* project to `AMA_DB` (the configured `AMA_ROOT`, or the first one indexed) while every other project gets its own `InMemoryStore`. The migration was cheap for a non-obvious TypeScript reason: *widening* a callback with an extra parameter is backward-compatible — a `() => Store` is assignable to `(root: string) => Store` (a function that ignores an argument still satisfies a type that supplies one), so the four single-project CLI factories (`() => new SqliteStore(dbPath)`) needed *no* change; only the one multi-project factory that actually needs the root did. TDD: a factory keyed by its arg (`Map`) yields two stores for two roots only if `index()` passes the root — RED (one shared store, `size === 1`) when the arg is dropped, GREEN once threaded. Dogfooded live, the exact failure from last iteration inverted: `find_callers("alpha", projectPath=xproj-a)` now returns `useAlpha` at `file: "a.ts"` (xproj-a's *own* path, not the Ama-relative one) and `get_code_snippet` returns the real source instead of null — the non-primary project is genuinely isolated. Lesson: a shared-resource aliasing bug is almost always a factory that closed over "the one resource" instead of taking a key; thread the key, and lean on the fact that adding a callback parameter doesn't break existing zero-arg callers. Self-index 1652/4008/292 → 1656/4020/293 (+1 test file). 572 → 573 tests; full suite + self-index gate green; RED by dropping the root arg; typecheck + repo-wide lint clean; live cross-project now returns isolated per-project data. - 2026-06-22 · mcp · **`replace_all` is safe for *structurally* uniform code, not *incidentally* similar code — finishing the cross-project rollout** (ama-ibb) — with the store blocker fixed, I threaded `projectPath` through the last ~16 query tools so all 24 are cross-project. The tools split into two parts that look alike but aren't: the **handler plumbing** is structurally identical across every `{symbol}` tool (`({ symbol }: { symbol: string }) => session.findX(symbol)` — only `findX` varies), while the **schema** carries a per-tool human description (`"Method id…"`, `"Interface id…"`, `"Class id…"`, `"Handler symbol id…"`). I reached for `replace_all` on both. On the handlers it worked — the destructure and the `(symbol)),` call-tail are byte-identical everywhere, so two `replace_all`s threaded all of them at once. On the *schema* it silently under-matched: I matched only the common `"Symbol id…"` describe, so most tools got their handler threaded but **not** their schema. That mismatch is the dangerous kind — zod strips an unknown arg, so the tool would accept no `projectPath`, the handler would read `undefined`, and it'd quietly query the primary; `tsc` can't catch it because the handler's `projectPath` is optional. I reverted `server.ts` to the last commit and redid it deliberately: structural `replace_all`s for the describe-independent handler parts, one `replace_all` per *describe variant* for the schemas, then individual edits for the non-`{symbol}` tools (`{route}`, `{type}`, `impact`/`affected`/`explore`/`search_code` multi-arg, and the no-input `get_graph_schema`/`circular_imports` whose `{}` schema became `{ projectPath }`). The bug that `tsc` couldn't see is exactly what a **protocol-level** test catches: I added an MCP-client test that calls `impact_analysis` with `projectPath` and asserts it routes to the named project (RED — `[]` — when the handler drops the arg). Lesson: before a sweeping `replace_all`, separate what's *structurally* uniform (safe) from what's *incidentally* similar (a description that happens to repeat) — and when a wrapper's schema and handler can drift apart silently, test the wrapper through its real protocol, not just the function under it. Dogfooded live on the isolated non-primary project: `impact_analysis("alpha", projectPath=xproj-a)` → `[useAlpha]` and `get_graph_schema(projectPath=xproj-a)` → `{File:1, Function:2}` (xproj-a's tiny graph, not Ama's 1658). Self-index 1656/4020/293 → 1658/4024/293. 573 → 574 tests; full suite + self-index gate green; RED by dropping projectPath from a tool handler; typecheck + repo-wide lint clean. The cross-project surface (engine + all 24 tools + store isolation) is complete. - 2026-06-22 · analyzer · **A feature deferred for fear of "too noisy" was already bounded by an existing filter — measure, don't guess** (ama-emb) — the deep analyzer tracked `this.` reads as References (ama-qo3) but skipped cross-instance `obj.`, deferred because "every property access becomes a Reference" sounded explosive and might need an opt-in flag. The code change was one line — drop the `parent.expression.kind === ts.SyntaxKind.ThisKeyword` guard so *any* non-call `X.prop` read resolves. The real work was deciding whether that's safe, and the answer was already in the resolution path: `resolveValueRef → nodeIdForDecl` returns `undefined` for library code (anything `repoRel(root,…)` puts outside the repo) and for decls a structural walk wouldn't emit (locals, nested) — so `console.log`, `arr.map`, and every external/local member produce *no* edge. Dropping the guard therefore *cannot* create dangling edges or external noise; the blast radius is bounded to in-project top-level + class/interface members the graph actually has nodes for. Measured rather than guessed: on Ama's own source References went 452 → 811 (nearly 2×) but that's +9% of the whole graph (4024 → 4383 edges), call resolution was *unchanged* (3432/1177 — References aren't Calls, so the call graph is untouched), and every new edge targets a real node. Decision: keep it always-on, not opt-in — moderate, bounded growth buys a real capability. Dogfooded the payoff: `find_referrers("ProjectIndex.store")` was **empty** before (that field is only ever read as `project.store`, never `this.store`) and now returns the three methods that read it cross-instance (`close`, `indexStatus`, `register`) — exactly the "who uses this member" completeness the parent issue wanted. TDD: a fixture where `compare(other)` reads `other.value` and a free function `widen(b)` reads `b.size` — RED (referrers exclude `Box.compare`/`widen`) with the guard, GREEN without. Lesson: when a change is feared "too noisy," first check whether an existing resolution/emission filter already drops what would be noise — if it does, the feature is safe always-on, and a self-index measurement settles the growth question that a flag was about to paper over. Self-index 1658/4024/293 → 1661/4383/293 (+3 fixture nodes, +359 References edges). 574 → 575 tests; full suite + self-index gate green; RED by restoring the ThisKeyword guard; typecheck + repo-wide lint clean. - 2026-06-22 · indexer · **The guard already existed — the gap was that it was *silent*, inconsistent with its sibling guard** (ama-j0y) — claimed "per-file parse timeout (guard against a pathological file hanging the index)" expecting to build a defense, but dogfooding the indexer first (`Indexer.index` → `discoverFiles`) showed the recommended tractable defense was *already there*: `discoverFiles` skips files over `MAX_FILE_SIZE_BYTES` (1 MB), and `find_callers(discoverFiles)` confirmed the cap is enforced consistently across `index`, `sync`, and (separately) the watcher — so a minified bundle / data blob never reaches the parser. The real gap wasn't the guard; it was that this skip was *silent* `continue`, while the sibling guard right below it (per-analyzer isolation) explicitly logs to stderr — its comment even says "reported to stderr (never silently dropped)." So two skip paths, one honest and one not. Fix matched the convention: an optional `onSkipLarge?` callback on `discoverFiles` (backward-compatible — `sync`'s call is untouched, no churn) and `index()` logs the skipped names to stderr, exactly like the isolation path (stdout stays JSON-RPC only). The true in-thread timeout for the *rarer* failure — a small file with catastrophic backtracking, under the size cap — genuinely needs a worker/subprocess and is disproportionate for the residual risk; filed as a low-priority follow-up. TDD: a temp dir with a >1 MB `huge.ts` + a normal `small.ts`, a `console.error` spy asserts `huge.ts` is named on stderr while `small.ts` still indexes — RED (silently dropped, spy never sees it), GREEN once reported. Lesson: when you pick up a "guard against X" item, first check whether the guard already exists — the actual gap is often not the mechanism but its *observability*, or its *consistency* with a sibling that already does it right; dogfooding the code before coding turned a "build a timeout" task into a one-callback honesty fix. Self-index 1661/4383/293 → 1662/4386/294 (+1 test file). 575 → 576 tests; full suite + self-index gate green; RED via a stderr spy before the skip was logged; typecheck + repo-wide lint clean. - 2026-06-22 · analyzer · **When a new shape doesn't fit a parameterized engine, a small self-contained analyzer behind the same interface beats contorting the engine** (ama-krw) — Vue/Svelte single-file components were indexed by *no* analyzer (`.vue`/`.svelte` routed nowhere). The instinct was to add a `.vue` `LanguageSpec` to the tree-sitter `BaselineAnalyzer`, but reading its `analyze` showed the mismatch: it parses *whole-file* content with one grammar, and an SFC is three languages in one file (`