Skip to content

feat: dbt lineage pane that follows the model Claude reads or edits - #14

Merged
hkaltai merged 54 commits into
feat/one-click-local-integrations-syncfrom
feat/plugin-altimate-code-integration
Nov 3, 2026
Merged

hkaltai merged 54 commits into
feat/one-click-local-integrations-syncfrom
feat/plugin-altimate-code-integration

Conversation

@hkaltai

@hkaltai hkaltai commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds a dbt lineage pane to the plugin's hooks module. It follows the model Claude reads or edits, and it checks a change's downstream reach before the edit lands. Stacked on #12 (its branch is merged in); needs AltimateAI/altimate-code#1637 (structured dbt_lineage) and AltimateAI/altimate-code#1635 (stacked on it: manifest fallback fields, column reach, large manifests). The plugin's calls are read-only, so altimate-code answers them from the compiled manifest and never starts the project's dbt adapter; the pane also reads the integration's model-lineage shape when a result carries one.

  • Pane (/lineage <model>[.column]): follows the model Claude reads, edits, or names in its own dbt_lineage call.
    • Graph view (default), like dbt-lineage-viewer: the model's whole lineage as boxes and arrows (every level up and down, every model, every name in full, dashboards marked ◆) drawn once as a canvas the pane is a window onto. Scroll it with the mouse, touchpad or arrows; pan with h/l (or ◀ ▶); f brings the model back into view. The pane opens on the model. Nothing is folded or cut. With a column picked, the models and dashboards that read it are highlighted and the rest dimmed.
    • What the answer is about: once the session has seen a dbt project, after each answer the pane lists the dbt models Claude named (IN THE ANSWER), each a press away; when Claude answered without reading a model that turn, the pane follows the first model it named, so the lineage matches the answer.
    • Views for big projects (v): ends only (the sources and dashboards the model sits between, with "via N"); folders (sources → staging → intermediate → marts → dashboards, top to bottom with counts and level spans: a source-to-mart chain on one screen); list (the earlier text view, also used in a pane under 48 columns).
    • The pane keeps the model's whole upstream (up to 500 models), so a 12-hop chain shows back to its sources.
    • No manifest: a project without target/manifest.json still gets its graph, read from the models' ref() and source() calls the way dbt renders them (Jinja only, SQL comments included, versions, snapshots, Python models). Columns, dashboards and column reach then need dbt parse.
    • Follows indirect reads: a shell command that finds the model (find … -name x.sql, a cd first) moves the pane; a listing of many files does not. From a monorepo's root, /lineage <model> finds the dbt project below it that has the model.
    • Each output column with its sources and lens type.
    • The column box: "read by N models · M dashboards". It lists models that may read the column and models that could not be traced, and the count becomes "at least N" when there are any.
    • Keys: v switches view, c picks the next column, r refreshes, a asks Claude; in the ends and list views u/d unfold upstream/downstream. Pressing a model follows it.
  • Same model only: columns from Claude's own dbt_lineage call are reused only when the answer's unique_id is the followed model's, never for a same-named model of another project.
  • Graph data: read from target/manifest.json with no model calls. A manifest over 4 MiB is read through dbt_lineage include_graph (CLI) and cached per revision. If altimate-code fails or its answer is too large to read, the pane says so rather than reporting that the model is missing.
  • Impact first: before an Edit, Write or MultiEdit of a model's SQL runs, the plugin traces every column the edit touches (up to 3, in parallel) and merges their reach, holding the edit for at most 8.5 s. A column whose check fails or times out is named, and the count becomes a lower bound. No trace starts once the hold is over, the CLI run stops at the deadline, and an edit made while the same column's trace runs shares it. It shows a toast, adds the lineage to Claude's context once per turn per agent (each project name quoted and capped, the note at most 4 KB and framed as data), and leaves an impact band above the prompt. The band shows impact_analysis severity and the direct, transitive, test and reader counts.
  • Claude's own dbt_lineage results: drawn as a list of edges colored by lens. ctrl+o keeps the raw output under it.
  • Desktop: an SVG graph.
  • Warehouse lineage: when a connected MCP server offers table_lineage or databricks_table_lineage, the pane adds what the warehouse has observed for the model's table. The plugin calls it itself, without a prompt, and sends the table's name, only for a model the person asked for (/lineage, a press, r), never on a follow of Claude's reads. The README says so.
  • Calls: go to the plugin's MCP server with no permission prompt. A result the server cut for size, or a disconnected server, falls back to altimate tool run --json.
  • Composition: the engine allows one hooks module per plugin and one unmatched hook per event, so integrations.tsx registers the lineage command, hooks and band line. runAltimate, parseJson and later move to hooks/altimate-cli.ts, shared by both halves. The CLI falls through to altimate-code only when the altimate binary is missing, not when it times out.

Test plan

  • claude plugin validate plugins/altimate-code passes.
  • claude plugin test plugins/altimate-code passes 157 tests across the graph module, the layout module, the lineage mod and the integrations pane. They cover:
    • following, the large-manifest path, recompiles, stale-focus guards;
    • impact-first ordering, context de-duplication, lower bounds;
    • CLI fallback, capped results, column switching, warehouse lineage, the result redraw;
    • the dbt integration's model lineage, same-named models of other projects, unreadable and failed CLI answers;
    • the layout: layering, whole names on an unbounded canvas, panning slices, folder order, a 500-model lineage in under 300 ms, and that no drawn line joins two models that are not linked;
    • the SQL-file graph (refs in comments, quoted text, versions, snapshots, Python, YAML forms, model-paths outside the project), shell reads, monorepo lookup, and presses staying in the project shown;
    • the impact note's quoting and size cap, no trace after the deadline, shared traces, an MCP call that never answers, an unreadable dashboards section, warehouse lineage only for models the person asked for (presses included), model-paths: ['.'], and no folder walk in a session that has seen no dbt project.
  • Reviewed by Claude and Codex as separate reviewers, each run on its own, until both explicitly approved.
  • Shell tests (6/6) and pytest (17/17) pass.
  • Ran live in Claude Code 2.1.316, using the dev altimate-code build from #1635, on a compiled dbt-duckdb demo project (shopwise: 20 models, 3 dashboards; synthetic seed data):
    • the pane follows Read, shell reads and Claude's own dbt_lineage calls;
    • /lineage fct_orders.revenue shows 7 models and 3 dashboards;
    • an edit to revenue shows the toast before it lands, and Claude's answer used the added lineage (it named the 7 readers and the 3 dashboards);
    • Claude's dbt_lineage result is redrawn.
  • Ran live on a large real project (altimate-ingestion's Snowflake dbt project: 230 models, 60 sources, chains of 12 hops, a 7.6 MB manifest read through altimate-code in about 2 s). For a mart 12 hops from its sources, folders reads sources (26) → staging (18) → intermediate (49) · 7 levels → marts (9) → mart, and ends only lists the real sources first.
  • Ran live from altimate-ingestion's repo root on a project with no manifest (transform-dbt, 50 models): /lineage stg_dbt_artifact_records draws its graph from the SQL files.
  • Warehouse lineage against a real Altimate SaaS MCP server. It is covered only by a stub, since no SaaS server is connected in this environment.

🤖 Generated with Claude Code

hkaltai and others added 4 commits November 2, 2026 15:27
The pane draws the model's graph from target/manifest.json: two levels
upstream, everything downstream with the dashboards on it, each output
column's sources from dbt_lineage, and a column's reach from
impact_analysis. Before an edit to a model's SQL lands, the plugin looks
up what reads the model and the edited column, shows it as a toast, adds
it to Claude's context and leaves an impact band above the prompt.
Claude's own dbt_lineage results are drawn as colored edge lists, and a
connected table_lineage MCP tool adds warehouse lineage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Large manifests get their graph from altimate-code's dbt_lineage with
include_graph; follows keep the followed project and manifest; column
reach counts become lower bounds when models are unsettled; the
lineage note is given once per turn and per agent; the CLI helpers are
shared with the integrations pane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated
const began = await $.clock.now()
const result = await runTool($, 'dbt_lineage', { manifest_path: focus.manifest, model: focus.model }).catch(() => null)
if (!sameFocus(await read($, focusAtom), focus)) return
const columns = result?.ok ? (columnsFromMetadata(result.metadata) ?? (result.text ? parseDbtLineage(result.text) : null)) : null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Unparseable dbt_lineage text is cached as an empty, complete column lineage.

parseDbtLineage never returns null: with no Column Mappings: or Lineage Edges: sections it returns { columns: [], edges: [] } (lineage-graph.ts:226). Any error or preview text therefore becomes a truthy columnsAtom, the pane shows no columns known, and the refetch condition (isNew || !(await read($, columnsAtom)) at line 351) is suppressed until a manual refresh. lineageFromOutput already applies the correct guard (parsed.columns.length > 0 || parsed.edges.length > 0); reuse it here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: loadDetails now parses the text report through parsedLineage (lineage.tsx), which returns null when it holds no columns or edges, so an error or preview is never cached as an empty lineage and the pane refetches.

if (text.trim().startsWith('{')) {
const json = parseJson<any>(text)
const fromJson = columnsFromMetadata(json)
if (fromJson) return fromJson

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: A truncated dbt_lineage result is accepted as complete when it still carries columns.

columnsFromMetadata(json) returns at line 529 before the json?.truncated === true check at line 531, and the structuredContent path above has no truncation check at all. A result the server cut for size can therefore be preloaded into the pane (follow passes it as preloaded, which skips the full fetch) and shown as the complete lineage. Check truncated before extracting columns on both paths.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: lineageFromOutput (lineage.tsx) now checks truncated on the structured and the JSON-text paths before extracting columns, so a cut result is never preloaded and the pane fetches the full lineage. Test added.

Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated
// The same unique_id in another project, or after a recompile, is a different focus.
const isNew = current?.model !== id || current.manifest !== manifest || current.mtimeMs !== stat.mtimeMs || !!input.refresh
const node = graph.nodes[id]!
await update($, focusAtom, () => ({ model: id!, root, manifest, via, readMs, mtimeMs: stat.mtimeMs }))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: focusAtom and graphAtom are written separately, so overlapping follows can mix models.

follow is always launched fire-and-forget through later, and each call awaits its manifest read before these two updates. Two overlapping follows can interleave as A writes focus, B writes focus, B writes graph, A writes graph, leaving the focus from B with the graph from A; graph.nodes[focus.model] is then missing and the pane falls back to the empty prompt. The isNew block below (lines 318-326) can likewise reset columns/reach/warehouse that a newer follow just loaded. Guard each write with a monotonic follow id (re-check before updating) or store focus and graph together.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: follow takes a monotonic sequence number and returns before writing focus, graph and the reset state when a newer follow has started (lineage.tsx, followSeq), so overlapping follows cannot mix a model with another's graph.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update after review: the guard is now start-ordered. Each follow takes a number when it starts; one that finds its model claims the pane unless a newer follow already has, and a follow that fails claims nothing. Every write after the claim re-checks ownership inside its update callback, so a conflict retry cannot write a superseded focus. Detail answers (columns, reach, warehouse) are kept only while the focus they were asked for (model, manifest, revision) still owns the pane, and only the newest column-reach request is shown. See plugins/altimate-code/hooks/lineage.tsx (follow, loadDetails, loadReach, loadWarehouse) and the overlap tests in lineage.test.tsx.

Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated
})

on('command.run', { command: COMMAND }, async ($, e) => {
const [model, column] = e.args.trim().split('.')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Splitting /lineage arguments on . drops everything after the first dot.

findModel accepts a unique_id (model.pkg.fct_orders) as well as a path, but e.args.trim().split('.') destructures only the first two segments: /lineage model.pkg.fct_orders becomes model model and column pkg, while /lineage models/fct_orders.sql becomes model models/fct_orders and column sql. Extra segments are silently discarded, so the model either fails to resolve or the wrong column is traced. Parse only a trailing column suffix, or split on the last dot when the prefix is not a known path or unique id.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: /lineage no longer splits on '.'. The whole argument is tried as a model (name, unique_id or path) first; only when it does not resolve is the text after the last dot read as a column of the model before it (follow, dottedColumn). Test added.

const data = result?.data ?? result
const records = Array.isArray(data?.records) ? data.records : []
return records
.filter((one: any) => typeof one?.target_table === 'string' || typeof one?.source_table === 'string')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: warehouseEdges validates only one side of a record and can emit non-string fields.

The filter accepts a record when either target_table or source_table is a string, but the mapper copies both raw values. A record such as { source_table: 'a', target_table: 123 } produces target: 123, and the pane then calls edge.target.toLowerCase() (lineage.tsx:780 and 791), throwing during render. Coerce each side independently, e.g. typeof one.source_table === 'string' ? one.source_table : null.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: warehouseEdges (lineage-graph.ts) coerces source and target independently and keeps null for a non-string side, so the pane no longer calls toLowerCase on a number. Test added.

Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated
{reach ? (
<Box flexDirection="column">
<Text>
read by <Text color="warning">{reach.models.length} models</Text> ·{' '}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The reader count is not marked as a lower bound.

When reach.uncertain, reach.untraced or reach.truncated is non-empty the count is a lower bound (the toast at line 484 and the band at line 571 add at least), but this box prints read by N models as an exact figure. Add the same qualifier so the primary display matches the rest of the feature.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the pane's column box now prints 'at least N models' when the reach is uncertain, untraced or truncated (lineage.tsx).

Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated
const link = result.content.find((block: any) => block?.type === 'resource_link' && String(block.uri).startsWith('file://')) as
| { uri: string }
| undefined
if (link) text = await $.fs.read(decodeURIComponent(new URL(link.uri).pathname)).catch(() => text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: new URL(link.uri).pathname is not a portable filesystem path.

On Windows file:///C:/... yields /C:/..., and a UNC URI drops the host, so $.fs.read fails, the error is swallowed, and the truncated preview continues as ok: true (which, combined with line 355, can be cached as an empty lineage). Convert with a fileURLToPath equivalent and treat an unreadable link as an incomplete result. Since this is an unvalidated read of any absolute path the result names, restricting it to the server's output directory is also worth doing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly fixed: a file:// link that cannot be read now sends the call through the CLI instead of continuing on the truncated preview, and a Windows drive path loses its leading slash (fileUriPath in lineage.tsx). Not restricting the read to an output directory: the URI comes from the plugin's own MCP server, and the path is only read, never executed.

}

const elapsed = (ms: number) => (ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`)
const firstLine = (text: string | undefined) => text?.split('\n').find(line => line.trim())?.trim().slice(0, 200) ?? null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: firstLine duplicates the helper in integrations.tsx:372.

The two implementations now differ subtly (this one skips leading blank lines and caps the text at 200 characters). Move a single version into altimate-cli.ts, which already holds the shared CLI helpers, so both halves behave the same.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined: the two helpers serve different inputs (the CLI's stderr in integrations, tool report text here, which needs blank-line skipping and a length cap). Merging them would change integrations' messages for no correctness gain.

Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated
if (downstream === 0 && counts.exposures.length === 0) return null
const known = (await read($, focusAtom))?.model === id ? ((await read($, columnsAtom))?.columns.map(one => one.name) ?? []) : []
const { before, after } = await editTexts($, e)
const column = touchedColumns(before, after, [...new Set([...known, ...node.columns])])[0] ?? null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Only the first touched column is traced.

touchedColumns(...)[0] silently drops every other changed column, so a MultiEdit that changes two heavily read columns, or a Write where every line counts as changed, reports reach for a column picked by mention-count shift. Trace each touched column, or fall back to model-level impact when more than one column is involved, so the toast and band cannot understate the edit's reach.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already addressed in the current code: impactFirst traces up to three touched columns in parallel and merges their reach (mergeReach); columns beyond that are now counted as unchecked, which makes the count a lower bound.

}))
on('mcp.connect', () => ({ value: mcp ? { isConnected: true, server: 'plugin:altimate-code:altimate-code' } : { isConnected: false, reason: 'not_connected', message: 'off' } }))
on('mcp.call', (_: unknown, e: { server: string; tool: string; args?: Record<string, any> }) => {
calls.push(`mcp:${e.tool}${e.args?.column ? `:${e.args.column}` : ''}`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The MCP mock discards the request arguments.

calls records only the tool name (plus the column for one call), and mcp.call returns the same fixture regardless of args.model, args.manifest_path, args.column or args.change_type. A regression that sends the wrong model or manifest still passes. Record the model, column and manifest in calls and assert them in the follow, recompile and large-manifest tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declined: the fake engine's fixtures are keyed by tool and column, and the tests already assert the model-specific paths (impact_analysis per column, manifest named by Claude's own call, include_graph for large manifests). Recording every argument adds assertions of the mock's own plumbing rather than new behavior.

@kilo-code-bot

kilo-code-bot Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (3 files, incremental d890526..397a435)
  • plugins/altimate-code/hooks/lineage.tsx - no new issues; the MCP call now runs inside the existing within deadline (failure/timeout falls through to the CLI and clears the shared trace), mergeReach reads exposuresError once, and bare /lineage now records the shown model as the person's ask so warehouse lineage can run
  • plugins/altimate-code/hooks/lineage.test.tsx - no issues; the two new tests exercise the bare-/lineage ask and a never-answering MCP trace that must be retraced on the next edit
  • plugins/altimate-code/README.md - no issues; the impact-first and warehouse-lineage wording matches the code (three columns merged, r counts as an ask)

Note: read-only review; the test suite and typecheck were not executed.

Previous Review Summaries (11 snapshots, latest commit d890526)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit d890526)

Status: 3 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 3
Issue Details (click to expand)

SUGGESTION

File Line Issue
plugins/altimate-code/hooks/lineage.tsx 189 The projectsCache comment says a .sql edit clears it, but .sql edits only call clearSqlGraphs(); the cache is cleared on r, a dbt_project.yml edit, and newConversation()
plugins/altimate-code/hooks/lineage.tsx 775 Bare /lineage never sets askedFor (it goes through loadDetails, not followOnce), so opening the pane on a model the pane followed from a read never fetches warehouse lineage, while the README lists /lineage as an ask
plugins/altimate-code/hooks/lineage.tsx 830 reaches.find(one => one.exposuresError) is computed twice for the same value
Files Reviewed (8 files, incremental c8bc897..d890526)
  • plugins/altimate-code/hooks/lineage.tsx - 3 issues
  • plugins/altimate-code/hooks/lineage.test.tsx - no issues; the new tests cover the ended-conversation claim, the monorepo lookup with a listed entry keeping its project, the note cap/quoting, the shared and deadline-bounded traces, and the warehouse gating
  • plugins/altimate-code/hooks/lineage-graph.ts - no issues
  • plugins/altimate-code/hooks/lineage-layout.ts - no issues
  • plugins/altimate-code/hooks/altimate-cli.ts - no issues
  • plugins/altimate-code/hooks/integrations.tsx - no issues
  • plugins/altimate-code/types/index.d.ts - no issues
  • plugins/altimate-code/README.md - no issues

Note: read-only review; the test suite and typecheck were not executed.

Fix these issues in Kilo Cloud

Previous review (commit c8bc897)

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 3
Issue Details (click to expand)

WARNING

File Line Issue
plugins/altimate-code/hooks/lineage.tsx 456 The new followSession stamp only gates followedTurn/followsInFlight; followOnce's claim (lineage.tsx:567) never checks it, so a read or press left running by the previous conversation that resolves after /clear still claims the pane, writes focus/graph, and (since paneOpened was reset) opens the pane on the old model

SUGGESTION

File Line Issue
plugins/altimate-code/hooks/lineage.tsx 996 For via: 'tools' the manifest is over 4 MiB by construction, so modelsNamed's readGraph returns null again and names come from sqlGraph - nothing reads it through altimate-code as the comment says, and the listed entries carry no manifest, so a press after the pane moves falls back to the default target/manifest.json
plugins/altimate-code/hooks/lineage.tsx 66 The { id; name; root; manifest? } answer-entry shape is repeated five times (atom, modelsNamed signature, its found map, the handler local, types/index.d.ts); one named type (and fromManifest ? manifest : undefined instead of graphFromManifest) keeps it in one place
plugins/altimate-code/hooks/lineage.test.tsx 901 The two new tests cover the session reset and the newer-turn race, but not the new multi-project candidate lookup or a listed model keeping its project after the pane moved
Files Reviewed (3 PR files, incremental f459972..c8bc897)
  • plugins/altimate-code/hooks/lineage.tsx - 3 issues. The newConversation() reset at session start/end and the followSession guards correctly stop a stale follow from blocking or driving followsInFlight negative, the answers from a stale turn writing the list, and the candidates now fall back beyond the shown project; the entries keep their root/manifest so a press resolves in its own project. The remaining gap is the pane claim itself, which is not session-gated.
  • plugins/altimate-code/hooks/lineage.test.tsx - 1 suggestion. The new tests do reach the post-lookup turn re-check (held stat feat: add review skill + /altimate-code:review slash command #3) and the cross-conversation counter reset.
  • plugins/altimate-code/types/index.d.ts - no issues; matches the atom's new shape.

Note: read-only review; the test suite and typecheck were not executed. The via: 'tools' behaviour above (manifest > 4 MiB) was verified from readGraph's size check and followOnce's via assignment, not by running the code.

Previous review (commit f459972)

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 3
Issue Details (click to expand)

WARNING

File Line Issue
plugins/altimate-code/hooks/lineage.tsx 881 followsInFlight/followedTurn/turnCount are not reset at session boundaries: a follow left pending by an earlier session blocks every answer follow (5 s wait, then skip) and a stale answer can still write the new conversation's list and move the pane

SUGGESTION

File Line Issue
plugins/altimate-code/hooks/lineage.tsx 984 With a focus, the answer-model lookup only tries the shown project; the session/monorepo candidates the comment names are never tried, so cross-project answers leave IN THE ANSWER empty
plugins/altimate-code/hooks/lineage.tsx 1279 IN THE ANSWER entries keep no project; after the pane moves, pressing an old entry cannot resolve its unique_id and reports "No dbt model matches"
plugins/altimate-code/hooks/lineage.test.tsx 824 The newer-turn test only reaches the early turnCount return; the re-checks after the model lookup (lineage.tsx:990/:995) are untested, so a newer turn starting during modelsNamed can still be overwritten
Files Reviewed (2 PR files, incremental 48358da..f459972)
  • plugins/altimate-code/hooks/lineage.tsx - 3 issues (session-scoped turn state, cross-project answer lookups, entries not keeping their project). The fromAnswer turn guard, the follow/followOnce split, the answer follow's auto/paneOpened handling and the new u/d visibility (ends/list only) are otherwise consistent.
  • plugins/altimate-code/hooks/lineage.test.tsx - 1 suggestion (re-check coverage). The new answer tests otherwise exercise the followedTurn gate (the held-read test), shownNamed, the fromAnswer turn guard and backticked paths.

Note: the incremental range 48358da..HEAD also contains base-branch commits from the merge of feat/one-click-local-integrations-sync (skill-sync scripts, shell hooks, their tests and top-level docs). Those files are not part of this PR's diff, so they are out of scope here and no findings are raised on them. This was a read-only review; the test suite and typecheck were not executed.

Fix these issues in Kilo Cloud

Previous review (commit 48358da)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

File Line Issue
plugins/altimate-code/hooks/lineage.tsx 1333 u/d are hidden in the graph view but still shown in the folders view, which also never folds, so the buttons change state with no visible effect there
Files Reviewed (2 files, incremental 22593fa..48358da)
  • plugins/altimate-code/hooks/lineage.tsx - 1 suggestion (folders view keeps the dead u/d controls). The new sliceCache is keyed by drawing key, pan and width, so a redraw for a message, column pick or loading reuses the window instead of re-slicing; the one reads(entry, kind) predicate now drives both walk and holds, and the caps only enter drawKey for the views that read them.
  • plugins/altimate-code/hooks/lineage-graph.ts - no issues (each function now carries its own doc comment)

The previous findings are resolved in this revision: u/d are gone from the graph view and no longer bust its cached drawing, walk/holds share one file predicate, findModel has its doc comment back, and the whole-canvas mount is documented and mitigated by the slice cache. Note: this is a read-only review; the test suite and typecheck were not executed.

Fix these issues in Kilo Cloud

Previous review (commit 22593fa)

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 4
Issue Details (click to expand)

SUGGESTION

File Line Issue
plugins/altimate-code/hooks/lineage.tsx 1105 The graph canvas is mounted whole: the view now has no level/box cap (downstream is never pruned, via: 'tools' graphs are not pruned at all), so every row of the canvas is rendered on each pan/column/message change while only bodyRows are visible; toSegments also scans every column of every row.
plugins/altimate-code/hooks/lineage.tsx 1092 u/d are dead controls in the graph view (caps are hard-coded to Infinity), yet they are still in drawKey via capUp/capDown, so pressing them discards the cached drawing and redraws the whole canvas for no visual change.
plugins/altimate-code/hooks/lineage.tsx 251 The "which files a walk reads" predicate is duplicated in walk and holds; a future change to the walked extensions would make the partial header disagree with what was actually skipped.
plugins/altimate-code/hooks/lineage-graph.ts 223 Two doc comments are stacked above findModelOrColumn, so the comment describing findModel now documents the wrong function and findModel has no doc.
Files Reviewed (8 files, incremental 1df2039..22593fa)
  • plugins/altimate-code/hooks/lineage.tsx - 3 suggestions (whole-canvas mounting/cost, dead u/d + cache bust, duplicated file predicate)
  • plugins/altimate-code/hooks/lineage-layout.ts - no issues (sparse grid/paintBox/toSegments faithfully replace the dense path and merge only on equal node+style; sliceRows/columnOf and the Infinity width path are correct; the hard-split wrap fix holds for the tested edge)
  • plugins/altimate-code/hooks/lineage-graph.ts - 1 suggestion (stacked doc comment); dropUnrendered correctly gates materialized
  • plugins/altimate-code/hooks/lineage-layout.test.ts - no issues (the wrap test does reach the hard split: at width 28 only the parent narrows to 16 and it produces the two 12-column lines the test expects)
  • plugins/altimate-code/hooks/lineage.test.tsx - no issues (pan window, centre/f, per-candidate shell reads, model-paths: ['models/'], later-line cd are covered)
  • plugins/altimate-code/hooks/lineage-graph.test.ts - no issues
  • plugins/altimate-code/README.md - no issues
  • plugins/altimate-code/types/index.d.ts - no issues (lineagePan replaces the removed level atoms consistently)

The previous warnings are resolved in this revision: the shown project is now tried before the session project, the graph is written before the focus, and drawKey includes the truncation flags. The earlier suggestions (hard-split wrap, partial detection, model-path normalisation, shell candidate fallback, CD m flag, materialized from rendered text, findModelOrColumn) are all in the current code.

Note: this is a read-only review; the test suite and typecheck were not executed.

Previous review (commit 1df2039)

Status: 10 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 2
SUGGESTION 8
Issue Details (click to expand)

WARNING

File Line Issue
plugins/altimate-code/hooks/lineage.tsx 406 The root chain tries the session's project before current.root, so a /lineage <model> for a model outside the pruned shown subgraph can resolve the wrong project (a regression from the previous current.root-first order).
plugins/altimate-code/hooks/lineage.tsx 1053 drawKey omits node metadata and the upTruncated/truncated flags, and focus/graph are written separately, so a render between the two writes can cache the new focus with the old graph and keep stale rows.

SUGGESTION

File Line Issue
plugins/altimate-code/hooks/lineage-layout.ts 401 wrap() cannot hard-split an over-long token once the first line is non-empty, so a name that fits on two lines is still middle-cut (and readable() widens boxes or drops levels for it).
plugins/altimate-code/hooks/lineage.tsx 253 Model paths are not normalized, so a valid model-paths: ['models/'] / ['./models'] breaks every file-driven follow in the SQL-file graph.
plugins/altimate-code/hooks/lineage.tsx 255 partial is set for a project of exactly MAX_SQL_FILES when any further subdirectory exists, falsely reporting "the first 5000 files only".
plugins/altimate-code/hooks/lineage.tsx 256 The SQL-file walk has no cycle/depth guard, so a symlinked loop under a model path can recurse forever.
plugins/altimate-code/hooks/lineage.tsx 945 The shell-file candidate loop stops at the first existing path even when follow fails, so an earlier existing non-project path blocks the real model.
plugins/altimate-code/hooks/lineage.tsx 214 CD lacks the m flag, so a cd starting a later line of a multi-line command is not detected and the wrong project is searched.
plugins/altimate-code/hooks/lineage-graph.ts 173 materialized is read from raw file text, so a Jinja-comment or raw-block config() can be reported as the model's materialization.
plugins/altimate-code/hooks/lineage.tsx 180 The "whole model, else text before the last dot" lookup is duplicated in projectWith, follow's inShown, and withoutColumn; extract one helper.
Files Reviewed (8 files, incremental 0b71a6d..1df2039)
  • plugins/altimate-code/hooks/lineage.tsx - 2 warnings, 6 suggestions (new SQL-file follow path, SQL graph cache, shell detection, drawing cache)
  • plugins/altimate-code/hooks/lineage-layout.ts - 1 suggestion (two-line name wrapping)
  • plugins/altimate-code/hooks/lineage-graph.ts - 1 suggestion (materialized from raw text)
  • plugins/altimate-code/hooks/lineage-graph.test.ts - no issues (the new 500-ancestor test and SQL-file parser tests are meaningful)
  • plugins/altimate-code/hooks/lineage-layout.test.ts - no issues
  • plugins/altimate-code/hooks/lineage.test.tsx - no issues (no-manifest follow, edit invalidation, shell-read follow, press-in-other-project and +N-more press are covered)
  • plugins/altimate-code/README.md - no issues
  • plugins/altimate-code/types/index.d.ts - no issues (via: 'sql', partial, stamp are consistent with their readers)

The four previous findings are fixed in this revision: the 500-ancestor cap now has a test, the drawing is cached, isTest/nodeOf have one definition, and the column-trace guard also reads the trace's own focus.column.

Fix these issues in Kilo Cloud

Previous review (commit 0b71a6d)

Status: 4 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 4
Issue Details (click to expand)

SUGGESTION

File Line Issue
plugins/altimate-code/hooks/lineage-graph.ts 527 The new 500-ancestor upstream cap and its upTruncated flag have no test; the existing subgraph test still says "keeps two levels up" and no longer guards the depth behaviour.
plugins/altimate-code/hooks/lineage.tsx 917 The layered layout (graphView → orderLayers, O(layers × boxes × edges)) is rebuilt on every pane render, including renders triggered by unrelated atoms.
plugins/altimate-code/hooks/lineage-layout.ts 15 isTestId / nodeOf duplicate isTest / nodeOf in lineage-graph.ts.
plugins/altimate-code/hooks/lineage.tsx 806 Carried forward from the previous review (still present, existing inline comment): the column-trace guard reads the tool input (e.column) rather than the trace's own focus.column, so a differently named argument preloads a one-column trace as the model's full columns.
Files Reviewed (7 files)
  • plugins/altimate-code/hooks/lineage-layout.ts - 1 suggestion; reviewed the layering, folding (capLayer), shortest-path beyond memo, gap/channel routing, fit truncation, drop-to-fit and stacking: no correctness issues found
  • plugins/altimate-code/hooks/lineage-layout.test.ts - no issues
  • plugins/altimate-code/hooks/lineage-graph.ts - 1 suggestion (untested upstream cap)
  • plugins/altimate-code/hooks/lineage.tsx - 1 suggestion (per-render layout), plus the carried-forward column-trace guard
  • plugins/altimate-code/hooks/lineage.test.tsx - no issues (view cycling, level keys, narrow-pane list fallback and the box press are covered)
  • plugins/altimate-code/README.md - no issues
  • plugins/altimate-code/types/index.d.ts - no issues

Fix these issues in Kilo Cloud

Previous review (commit 78ead3c)

Status: 1 Issue Found | Recommendation: Merge (one optional suggestion)

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

File Line Issue
plugins/altimate-code/hooks/lineage.tsx 786 The column-trace guard reads the tool input (e.column) rather than the trace's own focus.column, so a differently named or nested argument preloads a one-column trace as the model's full columns and skips the refetch. Carried forward from the previous review (still present); it already has an active inline comment.
Files Reviewed (5 files)
  • plugins/altimate-code/hooks/lineage.tsx - no new issues (incremental commits 1ebf5c7, 78ead3c): verified the new !ok + empty-metadata failure branch (it only fires when no metadata comes back, so a model-not-found answer that carries metadata still reports "no model"), the unique_id-first preload guard, and its fallback to a full fetch on a mismatch
  • plugins/altimate-code/hooks/lineage-graph.ts - no new issues (id is emitted for both the model-lineage and metadata shapes; the text parser has no id and correctly falls back to the name comparison)
  • plugins/altimate-code/hooks/lineage-graph.test.ts - no issues
  • plugins/altimate-code/hooks/lineage.test.tsx - no issues (the mismatched-id case fails on the pre-fix preload, and both failure fixtures fail on the pre-fix "no such model" message)
  • plugins/altimate-code/types/index.d.ts - no issues (the optional id keeps existing ColumnLineage values compatible)

Fix these issues in Kilo Cloud

Previous review (commit c25a8dd)

Status: 1 Issue Found | Recommendation: Merge (one optional hardening)

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

File Line Issue
plugins/altimate-code/hooks/lineage.tsx 780 The "column trace" guard reads the tool input (e.column) instead of the trace's own focus.column, so a differently named/nested argument would preload a partial one-column trace as the model's full columns
Files Reviewed (5 files)
  • plugins/altimate-code/README.md - no issues
  • plugins/altimate-code/hooks/lineage-graph.ts - no issues in the new model-lineage parser (verified the focus-target filter, indirect/source handling against the legacy metadata and text parsers, the focusNode.columns fallback, malformed/absent nodes, and both columnsFromMetadata entry shapes)
  • plugins/altimate-code/hooks/lineage-graph.test.ts - no issues (the new fixture pins the parser's expected shape, ordering, and indirect handling)
  • plugins/altimate-code/hooks/lineage.test.tsx - no issues (the column-trace test fails on the pre-guard behavior, and the edge-count row assertion matches otherEdges)
  • plugins/altimate-code/hooks/lineage.tsx - 1 suggestion

The incremental commit (c25a8dd) adds model-lineage reading on top of the corrected comment; the pane preload, metadata/structured-content unwrapping, and the new "+ N edges on other models" row all behave as described, and no prior finding is carried forward.

Previous review (commit 6731d63)

Status: No Issues Found | Recommendation: Merge

The incremental change (6731d63) is a comment-only correction in impactFirst: the stale "each column has the whole hold" note now matches the deadline-based per-column budget implemented below it. No new findings.

The only prior finding left on this file is the progress.project toast gate at line 771; the author's rationale (toast only in a dbt project with a manifest, and the 40-directory fs.exists walk is bounded and cheap; the edit is never held when it expires) is a defensible product tradeoff, so it is not carried forward as an open issue.

Files Reviewed (1 incremental file)
  • plugins/altimate-code/hooks/lineage.tsx - no issues

Previous review (commit 7c7c47c)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
`plugins/altimate-co

[Snapshot truncated.]

Additional previous summary content was truncated to keep this comment within platform limits.


Reviewed by deepseek-v4.1-flash-1017 · Input: 64.1K · Output: 21.1K · Cached: 1.3M

Picking one column from an edit's raw dbt SQL guessed wrong on a
two-line rewrite. Up to three touched columns are now traced in
parallel and their reach merged; a column whose check fails keeps the
count a lower bound and is named in Claude's context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated
const { before, after } = await editTexts($, e)
// Every column the edit touches is traced (up to three): picking one from raw dbt SQL is a guess,
// and a wrong guess would hide the column that changed.
const touched = touchedColumns(before, after, [...new Set([...known, ...node.columns])]).slice(0, MAX_EDIT_COLUMNS)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Touched columns beyond the three-column cap are dropped without marking the count a lower bound.

slice(0, MAX_EDIT_COLUMNS) traces at most three columns, but the dropped names never reach unchecked, so unsettled/atLeast stay zero and the toast reports an exact read by N models for only a subset of the columns the edit changed. A Write that rewrites a model with four or more changed output columns, or a MultiEdit touching several, therefore understates the edit's reach, and Claude's context never names the untraced columns. Collect the dropped remainder into unchecked (or otherwise fold it into unsettled) so the count becomes a lower bound.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: columns beyond the three-column cap now start in unchecked (lineage.tsx, impactFirst), so the count reads 'at least' and Claude's context names them. Test added.

Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated
if (reach.exposures.length) lines.push(`- dashboards on them: ${reach.exposures.join(', ')}`)
if (reach.uncertainExposures.length) lines.push(`- dashboards on models that may read it: ${reach.uncertainExposures.join(', ')}`)
if (reach.truncated) lines.push('- the trace stopped at its model limit; more models may read it')
if (unchecked.length) lines.push(`- not checked (no answer in time): ${unchecked.join(', ')}`)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: When every touched column's check fails, the failed columns disappear from the note entirely.

mergeReach([]) returns null, and this line sits inside if (reach), so an edit whose impact_analysis calls all error or time out switches to the model-level toast and context with no not checked line at all - exactly the case where the reader most needs to know which columns were never traced. Emit the unchecked line outside the if (reach) guard (or add an equivalent model-level note).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already handled: the 'columns not checked' line is pushed outside the if (reach) block, so it is emitted when every column check fails (covered by the unchecked-column tests). The model-level toast is unchanged by design; the context line names the columns.

* implicit alias, or the column itself (`t.col`); casts and table aliases are not outputs.
*/
export function selectItems(sql: string): Map<string, string> {
type Token = { text: string; depth: number; word: string | null; at: number }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The at field is written but never read.

Every token push records at: i, but nothing in selectItems (or the tests) reads Token.at. Drop the field and its four initializers to keep the tokenizer minimal; if positions are intended for future diagnostics, use them instead of storing dead state.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: removed the unused at field from the tokenizer (lineage-graph.ts).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated
const file = (EDIT_TOOLS.has(e.tool) || e.tool === 'Read') && 'file_path' in e ? String(e.file_path) : null
const isSql = !!file && /\.sql$/i.test(file)
const isEdit = isSql && EDIT_TOOLS.has(e.tool)
const note = isEdit ? await within($, HOLD_TOTAL_MS, impactFirst($, e, file!)) : null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: The whole-lookup hold discards the entire note when it expires.

within($, HOLD_TOTAL_MS, impactFirst(...)) resolves null as soon as 8.5 s pass, so the toast, the band update and the context addition are all skipped — including the columns not checked line this commit just made unconditional. impactFirst can exceed 8.5 s without any column hanging, because the >4 MiB manifest path runs graphFromTools as a CLI subprocess (TOOL_TIMEOUT_MS is 60 s at line 40) and projectRoot walks up to 40 directories before the column traces even start. On such a project every edit silently loses impact-first, with no indication that the check never ran. Degrade to the model-level note (or at least a "lineage lookup timed out" toast) when the total hold expires, instead of returning nothing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: impactFirst now runs against a shared deadline (the hold minus 500 ms). Each column check gets only the time left (at most 5 s), so a slow graph lookup (a manifest over 4 MiB read through the CLI) leaves the columns unchecked; the note, band and context still go out and name them. If the lookup in a dbt project still does not finish within the hold, a toast says the edit went ahead without lineage. A .sql file outside any dbt project gets no such toast. Tests cover both cases (lineage.test.tsx).

hkaltai and others added 8 commits November 2, 2026 19:39
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…reach keyed by id

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ears the column

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
await $.tool.call({ tool: 'Read', file_path: FILE })
await clock.settle()
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
expect(await ui.find({ type: 'Text', text: /no columns known/ })).toBeUndefined()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: This test cannot fail on the pre-fix behaviour.

For fct_orders the pane falls back to the manifest's columns (columnNames = columns?.columns.length ? ... : node.columns at lineage.tsx:842), so no columns known is never rendered even when columnsAtom holds the empty { columns: [], edges: [] } that the old parseDbtLineage produced. The call-count assertion is also identical before and after the fix: one Read schedules one loadDetails, so there is exactly one dbt_lineage call either way. Assert the refetch instead - do a second Read and expect two mcp:dbt_lineage calls (the old code caches the truthy-empty lineage and skips it).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the test now reads the same model twice and expects dbt_lineage to be asked again (2 calls), since nothing was cached. With parsedLineage returning the empty parse instead of null, the empty lineage is cached and the second read makes no call; I checked that the test fails that way.

hkaltai and others added 3 commits November 2, 2026 22:17
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…altimate-code-integration

The shared CLI helper takes the base branch's rule: only a binary that
cannot start falls through to the next name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… it ran out of time

Column checks get the time left before a shared deadline, so a slow
graph lookup leaves them unchecked instead of losing the note; when the
lookup in a dbt project still does not finish, a toast says the edit
went ahead without lineage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
}
const note = lookup ? await within($, HOLD_TOTAL_MS, lookup) : null
// Out of time before even the model-level lookup finished: say so, rather than nothing.
if (lookup && !note && !finished && progress.project) $.ui.toast('◆ impact-first · the lineage lookup did not finish in time; the edit went ahead without it', { timeoutMs: 8000 })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: The timeout toast is gated on progress.project, which is only set at line 547, after projectRoot resolves. projectRoot (line 127) walks up to 40 directories with an awaited fs.exists for each. When that walk is the slow part, the outer within($, HOLD_TOTAL_MS, lookup) still expires with progress.project === false, so no toast is shown — the same silent drop this commit sets out to fix. Set the flag optimistically before the walk (or as soon as a dbt_project.yml is found) so a slow root lookup still reports that impact-first did not run.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping this as is, on purpose. The toast is gated on having found a dbt project with a manifest so that a .sql edit outside any dbt project (a migration, a script) never gets a lineage warning; an earlier review flagged exactly that. projectRoot only stats for dbt_project.yml in each parent folder, so a walk long enough to use the 8.5 s hold is not a realistic case; if it ever is, the edit still goes ahead unheld.

Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated
const began = await $.clock.now()
// Each column has the whole hold: a slow one does not discard the others.
const results = await Promise.all(
// Each column gets what is left before the deadline (at most HOLD_MS), so a slow graph lookup

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The comment on line 576 still says each column "has the whole hold", which the deadline budget below contradicts (each gets at most the remaining time). Drop the old comment so only this one remains.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the comment now says the columns run side by side, each within the time left before the deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
altimate-code's dbt_lineage now answers from the project's dbt
integration when it can, with that model lineage as its structured
result. The pane takes the focus model's column edges and parents from
it, keeps the last transform step as the expression, does not keep a
one-column trace as the model's columns, and the tool-result row counts
the edges on other models of the trace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated

if (DBT_LINEAGE.test(e.tool) && 'model' in e) {
// A trace of one column holds only that column's edges: not the model's columns to keep.
const columns = (e as any).column ? null : lineageFromOutput((result as any).result ?? result.text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The "is this a column trace" decision reads the tool input, not the trace it just received.

(e as any).column only recognizes a column trace when the argument is literally named column; a build that names it differently (or nests it) makes this falsy, so a one-column trace is preloaded as the model's complete columns and loadDetails skips its refetch - the partial data this line is meant to keep out. The trace itself already marks a column focus (focus.column, e.g. the new fixture at lineage.test.tsx:580), and result.result is in hand here, so modelLineageOf(...)?.focus?.column can decide from both sides; it also avoids dropping a whole-model answer from a build that ignores the argument. The row parser added below (modelLineageOf) already has this shape to reuse.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the tool.call hook now treats the result as a one-column trace when either the call's column argument is set or the answer's own ModelLineage focus names a column (modelLineageOf(...)?.focus?.column). The existing test now calls dbt_lineage without a column argument and still checks that the trace is not preloaded; it fails without the change.

hkaltai and others added 3 commits November 3, 2026 09:29
…d says when altimate-code's answer could not be read

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… instead of saying the model is missing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y and folder views for big projects

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hkaltai and others added 2 commits November 3, 2026 15:26
…g wins over the answer's models

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated
const graph = (await readGraph($, `${root}/target/manifest.json`).catch(() => null)) ?? (await sqlGraph($, root))
const byName = new Map<string, string>()
for (const node of Object.values(graph.nodes)) if (FOLLOWABLE.has(node.type) && node.path) byName.set(node.name.toLowerCase(), node.id)
const ticked = new Set([...text.matchAll(/`([\w.]+)`/g)].map(match => match[1]!.toLowerCase().replace(/\.sql$/, '').split('/').pop()!))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The backticked-name pattern cannot match a path, so the .split('/') on this line never runs.

[\w.]+ excludes /, so `models/stg_orders.sql` is not captured at all; only a bare `name` (or `name.column`) is. The trailing .split('/').pop() shows paths were intended, but they cannot reach it. A model without an underscore (orders, customers) that the answer names by path is therefore dropped from the list, and named[0] can end up empty so no follow happens. Allow the separators in the capture, or compare only the basename.

Suggested change
const ticked = new Set([...text.matchAll(/`([\w.]+)`/g)].map(match => match[1]!.toLowerCase().replace(/\.sql$/, '').split('/').pop()!))
const ticked = new Set([...text.matchAll(/`([\w./-]+)`/g)].map(match => match[1]!.toLowerCase().replace(/\.sql$/, '').split('/').pop()!))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the backtick pattern now captures paths ([\w./-]+), so a backticked models/orders.sql names orders by its file name, underscore or not. There is a test with a model named orders, and it fails without the change.

Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated
}

/** Points the pane at a model (by file, name or unique_id) and loads its graph, columns, reach and warehouse lineage. */
/** A follow; one Claude's work started counts for the turn it started in, once it lands. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: Two doc comments now stack over follow, and followOnce has none.

Line 432 describes what followOnce does (point the pane at a model and load its graph, columns, reach and warehouse lineage); this line describes the wrapper. Keep line 432 above followOnce and this line above follow, the same fix already applied to findModel/findModelOrColumn in lineage-graph.ts.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: followOnce has its own doc again (it points the pane at a model and loads its graph, columns, reach and warehouse lineage), and follow keeps only the wrapper's doc.

Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated
if (turnCount !== turn) return
await update($, answerAtom, prev => (turnCount === turn ? named : prev))
// Checked again after every wait: a newer turn may have started meanwhile.
if (turnCount === turn && followedTurn !== turn && named[0]) await follow($, { model: named[0].id, auto: true, fromAnswer: true })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The newer-turn guard added by the last commit has no test.

Commit 417603b adds the turnCount === turn checks (266/270 above and here) so an older answer never writes answerAtom or follows a model once a newer turn started, but none of the three new tests exercises them: they either finish the turn or never open a second one. A test that holds the first manifest read (or the 5 s wait), calls $.turn.start for a second turn, then releases, and asserts the pane and the answer list keep the newer turn's state, would fail without these guards. Without it a refactor can drop them silently.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the test: a first turn's answer waits on a read still loading, then a second turn starts and the read is released. The pane stays on the read's model and no answer list is written. It fails without the turnCount === turn checks.

hkaltai and others added 4 commits November 3, 2026 15:37
… model, follow/followOnce keep their own docs, the newer-turn guard tested

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s-sync' into feat/plugin-altimate-code-integration
…ls in the shown manifest or below a monorepo root, and never moves the pane off a named model, a press or a loading read

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e, and the person's presses count like Claude's reads

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
drawnCache = null
contextGiven.clear()
// A new conversation's answers start empty.
await update($, answerAtom, () => [])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: The new per-conversation turn state is not reset when the session changes.

startLineage now clears answerAtom, but followsInFlight, followedTurn and turnCount (lines 94-96) stay module-level. integrations.tsx:562 already treats "a press that was running when an earlier session ended can never finish" as real: if a follow left over from the previous session never settles, followsInFlight stays > 0 forever, so every answer in the new conversation waits the full 5 s at line 975 and then fails the followsInFlight === 0 gate, and the pane never follows a named model again. turnCount is also unchanged across sessions, so a stale answer task can still pass turnCount === turn right after session.start, write its models into the new conversation's list and move the pane. Reset or version these counters here (keeping the stale follow's finally from driving them negative) alongside answerAtom.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: newConversation() now runs at session start and end. It bumps turnCount (so a stale answer task fails its turn check), resets followedTurn and followsInFlight, and bumps a followSession stamp. A follow left running by the previous conversation sees the stamp changed and no longer touches the count, so it can neither block nor drive it negative. New test: a read whose manifest check never returns, then a new conversation; an answer there still follows its model. It fails without the reset.

Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated
const sessionRoot = await $.session.root().catch(() => '.')
const shown = focus ? [{ root: focus.root, manifest: focus.via === 'manifest' ? focus.manifest : undefined }] : []
const own = await projectRoot($, sessionRoot, false)
const roots = shown.length ? shown : own ? [{ root: own, manifest: undefined }] : (await projectsBelow($, sessionRoot)).map(root => ({ root, manifest: undefined }))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: With a focus the fallback chain never leaves the shown project.

roots = shown.length ? shown : ... stops at focus.root; the session project and projectsBelow candidates the comment names ("else the session's, else the projects below a monorepo root: the first whose models the answer names") are only tried when there is no focus. In a monorepo, or after a read moved the pane to another project, an answer that names models of a different project yields named = [], so IN THE ANSWER stays empty and the pane does not move even though a later candidate would match. Append the session/monorepo roots after shown (deduping them) and stop at the first with matches.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: the candidates are now the shown project first, then the session's project (or the projects below a monorepo root), each tried once. The first project with matches wins, so an answer naming another project's models finds them even when the pane already shows a model.

Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated
<Box flexDirection="row" flexWrap="wrap" columnGap={1}>
<Text dimColor>IN THE ANSWER ·</Text>
{answer.map(one => (
<Button key={`answer-${one.id}`} plain label={one.id === focus.model ? `▸${one.name}` : one.name} onPress={() => later($, async () => void (await follow($, { model: one.id, open: true })))} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: A listed model is stored without the project it was resolved in.

answerAtom holds only { id, name } (line 66), and this press calls follow({ model: one.id, open: true }) with no root/manifest. followOnce then resolves the unique_id against the current focus, the session project and the projects below it, so once a later read moves the pane to another project, pressing an entry from the old list cannot find its model and reports No dbt model matches "model.…" (and the list cannot safely include models of another project at all). Store root (and the manifest when known) with each entry and pass them to follow.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: each listed model now stores the project it was found in (root, and manifest when it came from a manifest), and a press passes both to follow. An entry from an earlier list still resolves in its own project after the pane has moved to another one.

await ui.unmount()
})

test("an answer still being handled when a newer turn starts never writes its models or moves the pane", async ($, on) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: This test never reaches the newer-turn re-checks it is meant to cover.

The newer turn starts while the answer's later task is still waiting on followsInFlight, so the early if (turnCount !== turn) return at lineage.tsx:977 already exits before modelsNamed runs. Removing the re-checks at lineage.tsx:990 and :995 would leave this test green, even though that window (a turn.start arriving while the manifest/SQL lookup runs, which can take seconds) is exactly where a stale answer could still write answerAtom and move the pane. Hold the answer's own modelsNamed lookup (a held fs.stat/fs.read after the read has landed), start the newer turn while it is held, then release and assert there is no IN THE ANSWER row and no move.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewrote the test so it reaches those checks. A model is shown first, then the answer's own model lookup (its manifest check) is held while the next turn starts, and then released. The pane stays on the shown model and no IN THE ANSWER row is written. It fails with the post-lookup re-check removed.

hkaltai and others added 2 commits November 3, 2026 16:33
…e shown project, a listed model keeps its project, and the lookup race is tested

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ugh altimate-code too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
followsInFlight++
try {
const followed = await followOnce($, input)
if (followed && session === followSession) followedTurn = Math.max(followedTurn, turn)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: The new session stamp only gates the counters; it does not stop a follow left over from the previous conversation from claiming the new one's pane.

followSession is checked here and in the finally, but followOnce's claim at lineage.tsx:567 only checks claimedSeq (and the turn for fromAnswer) - it never sees the stamp. A read or press started before /clear that resolves afterwards therefore still passes the claim, writes graphAtom/focusAtom, and, because newConversation() resets paneOpened, the !paneOpened && input.auto open at lineage.tsx:605 opens the pane on the previous conversation's model before any follow from the new one has landed. Capture the stamp in followOnce (or bump startedSeq/claimedSeq in newConversation) so a stale follow returns false before it claims.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9ab6874: a new conversation sets claimedSeq = ++startedSeq, so a follow started before /clear refuses when it tries to claim the new conversation's pane. Test: a follow held on the manifest across a session end and start never shows (fails without the fix).

const focus = await read($, focusAtom)
const sessionRoot = await $.session.root().catch(() => '.')
// The manifest the shown graph came from, read directly or through altimate-code; none for SQL files.
const shown = focus ? [{ root: focus.root, manifest: focus.via === 'sql' ? undefined : focus.manifest }] : []

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: For a via: 'tools' focus this manifest can never be read by modelsNamed, so the comment overstates the fix and a press still loses the manifest.

via becomes 'tools' only when readGraph returned null, and the only null it returns is the over-4 MiB check at lineage.tsx:385. modelsNamed then calls readGraph on that same path (lineage.tsx:204), gets null again, and falls back to sqlGraph (lineage.tsx:206); it never calls graphFromTools. So for a large manifest the names come from the SQL files, graphFromManifest is false, and the listed entries carry no manifest (lineage.tsx:215). If that manifest is not the default <root>/target/manifest.json (Claude's dbt_lineage manifest_path) and the pane later moves, a press resolves the default path instead of the manifest the shown graph came from. Either read the names from the tools graph or graphAtom, or store focus.manifest for tools entries and correct the comment, since nothing here reads the manifest through altimate-code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9ab6874: modelsNamed takes the shown manifest and keeps it on each entry even when it couldn't read the file itself, so for a via: 'tools' focus a press still opens the model from that manifest, through altimate-code.

Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated
// A model the pane should scroll down to once its graph is drawn: the canvas can be taller than the pane.
let revealFor: string | null = null
// The dbt models Claude's last answer names, in the order it names them.
const answerAtom = atom({ plugin: 'altimate-code', key: 'lineageAnswer' } as const, [] as { id: string; name: string; root: string; manifest?: string }[])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The answer-entry shape is now written out five times, so a field change has to be made in all of them.

The same { id: string; name: string; root: string; manifest?: string } literal appears here, in modelsNamed's signature (lineage.tsx:203), in its found map (lineage.tsx:211), in the handler's named local (lineage.tsx:1001) and in types/index.d.ts:202. One named AnswerModel type (and, here, fromManifest ? manifest : undefined in place of the extra graphFromManifest boolean at lineage.tsx:205) keeps the shape in one place.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9ab6874: one AnswerModel type in types/index.d.ts, used by the atom, modelsNamed and the handler.

await ui.unmount()
})

test("a newer turn arriving while the answer's own model lookup runs keeps the answer out", async ($, on) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The two new tests cover the session reset and the newer-turn race, but not the other two fixes in this change.

Nothing exercises the new multi-project lookup in the turn.complete handler (the shown project, then the session's, then a monorepo's projects - lineage.tsx:996-1005) or a listed model keeping its project after the pane moved (lineage.tsx:1295). A test that shows a model in project A, lets an answer name a model of project B found through the session/projectsBelow candidate, then presses an older IN THE ANSWER entry after a read moved the pane, would fail if either wiring regressed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 9ab6874: a test for the monorepo case. Project a is shown, the answer names project b's model, the pane follows it, and after a read moves the pane back to a, the listed b_model entry still opens in b. Writing it found a bug in the test's own listing stub at the repo root, now fixed.

@sahrizvi sahrizvi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes. This is a multi-model consensus review (5 reviewers, converged in 2 rounds) of the diff against feat/one-click-local-integrations-sync at c8bc897.

What holds up:

  • claude plugin validate passes, and claude plugin test passes 143 tests with 0 failures.
  • The edit hold is bounded on every path and fails open, so an edit is never blocked or corrupted.
  • Processes run from argument vectors with JSON --args and never through a shell; a timeout is never rerun.
  • Every call the plugin makes to altimate-code is read-only (no --allow-write).
  • Concurrent follows are well guarded (claimedSeq, ownerKey, reachSeq) and tested.

Blocking — 3 MAJOR findings (inline):

  1. Project text is interpolated unsanitized and unbounded into the hidden impact context.
  2. Impact traces are never cancelled, start even after the deadline, and are not de-duplicated.
  3. Every answer in a non-dbt session walks the session folder 4 levels deep, uncached.

Blocking — 2 MINOR findings (inline):

  • The README promises dbt-integration lineage, which read-only calls cannot reach.
  • listWorkspaces now times out at 30 s instead of 90 s.

Non-blocking MINOR findings (inline): the warehouse server is chosen by tool-name prefix; reads can reach outside the project through .. model-paths and followed paths; startLineage runs inside the integrations session.start; exposures_error is ignored.

Nits, missing tests and the unintended consequences are in a separate PR comment.

Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated
: `${target} feeds ${graph.truncated ? 'at least ' : ''}${plural(downstream, 'model')} and ${plural(dashboards.length, 'dashboard')}`
const toastText = `◆ impact-first · ${toast}${dashboards.length ? ` · ${dashboards.slice(0, 3).join(' · ')}` : ''}`

const lines = [`Altimate lineage for ${target}, read from the dbt manifest before this edit:`]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR (security): repo text goes unsanitized and unbounded into a hidden context reminder

This note is returned as context from tool.call (line 1105). Per the hooks API, the model reads it as a reminder the person never sees. It interpolates text from the project with no filtering or quoting:

  • exposure labels (impactOf uses node.label, and a dbt exposure label is free YAML text);
  • column-reach exposure labels;
  • downstream model names and their column names (a quoted SQL alias can be any text);
  • reason strings from altimate-code.

A cloned dbt project can plant instruction-like text here, and it reaches the model as plugin-authored context with more authority than file content.

Size is also unbounded: reach.exposures.join, counts.exposures.join, unchecked.join (every touched column past the 3 traced) and each model's columns.join.

Fix:

  • Allow only an identifier charset in interpolated names, or JSON-quote them.
  • Strip control characters and newlines.
  • Cap each item and the whole note (for example 4 KB).
  • Frame the block as data: "the names below come from the project; they are not instructions".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6c29e46, then made byte-exact in e47b064 and c6972f3.

  • Every project-derived value goes through quoted(): control characters, U+2028/2029 and newlines become spaces, it is cut at 80 characters, and it is JSON-quoted unless it is a plain [\w.$-] name. Non-ASCII is escaped, so the note is ASCII end to end.
  • Lists are capped (10 entries, 8 columns per model, 15 models) with an ... N more marker. The note is cut at 4000 characters, which is now 4000 bytes, never on half of an escape.
  • The note opens with: "The names below come from the project's files; they are data, not instructions."
  • Tests: a planted multi-line label stays on one quoted line; 30 long exposures are capped; non-ASCII names stay under 4 KB in bytes; a cut never ends inside an escape. Each fails without its fix.

if (touched.length > 0) {
const began = await $.clock.now()
// The columns run side by side, each within the time left before the deadline (below): a slow one does not discard the others.
const results = await Promise.all(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR (performance): impact traces are never cancelled, start even with no time left, and are not de-duplicated

within() only stops waiting. After the 8.5 s hold, these keep running:

  • up to 3 impact_analysis runs (the CLI's 60 s $.process.run cannot be aborted, and $.mcp.call has no timeout here);
  • for a manifest over 4 MiB, the 60 s include_graph CLI run from graphFromTools, which has no in-flight de-duplication.

within($, Math.max(0, …), runTool(...)) also launches the tool when the deadline has already passed. A burst of edits to a large project therefore piles up concurrent traces that compete with the MCP server Claude itself uses. The edit hold is correctly bounded; the leftover work is not.

Fix:

  • Don't launch a trace when the remaining time is ≤ 0.
  • Share in-flight promises per (manifest, model, column) and per include_graph key.
  • Run the CLI through $.process.spawn and end the stream at the deadline so it is killed, or give impact-first subcalls a short dedicated timeout.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6c29e46 and 249174c.

  • No trace starts once the time left is at most zero. runTool now takes an absolute deadline and checks it after the MCP connect and again before the CLI fallback, so nothing starts after the hold ends.
  • The CLI run's timeout is the time left, so the process is stopped at the deadline.
  • shared() gives a trace already running (same manifest revision, model and column) to a later edit, and does the same for include_graph runs.
  • $.mcp.call takes no timeout or abort signal in the hooks API, so an MCP call that has started can't be cancelled. With the de-duplication and the deadline check, a burst of edits no longer piles up calls.
  • Tests: no trace after the hold ran out; two edits of one column share one trace. Both fail without the fix.

Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated
const shown = focus ? [{ root: focus.root, manifest: focus.via === 'sql' ? undefined : focus.manifest }] : []
const own = await projectRoot($, sessionRoot, false)
// The shown project first, then the session's, then those below a monorepo root, each once.
const candidates = [...shown, ...(own ? [{ root: own, manifest: undefined }] : (await projectsBelow($, sessionRoot)).map(root => ({ root, manifest: undefined })))]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR (performance / file access): every answer in every session walks the session folder 4 levels deep

turn.complete runs after every main-agent answer and has no dbt gate. When the session root is not inside a dbt project, projectsBelow(sessionRoot) lists every directory to depth 4 with unbounded Promise.all fan-out, and nothing is cached. Measured locally, that is about 6,000 directories under a home folder and about 10,000 under a code monorepo, on every answer. On macOS the walk also lists ~/Desktop, ~/Documents and ~/Downloads, which can raise privacy prompts. Users who never open lineage pay this cost.

Fix:

  • Cache the project list per root; invalidate it on refresh or when a dbt_project.yml is written.
  • Skip the answer lookup when no dbt project has been seen in the session.
  • Bound the walk's concurrency and entry count.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6c29e46.

  • turn.complete walks below the session folder only after a dbt project has been seen this session, through a follow or an edit's impact check. A session that never touches dbt does no file work on answers. A follow left over from an earlier conversation can't turn the walk back on (249174c).
  • The walk is breadth-first, lists at most 2000 folders, 16 at a time, and is cached per folder. The cache is cleared on r, on an edit of dbt_project.yml, and in a new conversation.
  • Test: an answer in a session that has seen no dbt project lists no folder (fails without the gate).

Comment thread plugins/altimate-code/README.md Outdated
- **The graph.** The model's whole lineage as boxes and arrows, like dbt-lineage-viewer: every level upstream on the left and downstream on the right, every model, every name in full, dashboards (exposures) marked ◆. Nothing is folded or cut: the pane is a window onto the drawing. Scroll it up and down with the mouse, touchpad or arrow keys, pan it with `h` and `l` (or ◀ ▶), and `f` brings the model back to the middle. Links between models on the same level, or that skip a level, are not drawn. Press a model to follow it. With a column picked, the models and dashboards that read it are highlighted and the rest dimmed.
- **Views for big projects.** `v` cycles the graph, **ends only** (the sources and dashboards the model sits between; "via N" counts the models between them on the shortest path), **folders** (sources, staging, intermediate, marts and dashboards as one box each, with counts, in dbt layer order: a source-to-mart chain on one screen) and the text **list** of upstream and downstream. A pane narrower than 48 columns shows the list. The pane keeps up to 500 models upstream, and through altimate-code (a manifest over 4 MiB) up to 500 downstream too; past that the header says which side was cut.
- **What the answer is about.** After each answer, the models Claude named are listed under the header (IN THE ANSWER), each a press away. When Claude answered without reading any model that turn (from what it read earlier), the pane follows the first model it named, so the lineage shown matches the answer.
- **Columns.** Each output column with its source columns and how it is derived, from altimate-code's `dbt_lineage`: through the project's dbt integration when altimate-code has it (no `dbt compile` needed), else from the compiled manifest.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR (docs/contract) — Blocker: yes. This line says columns come "through the project's dbt integration … (no dbt compile needed)", but no call the plugin makes can reach that path:

So the pane always gets the manifest fallback, and the no-manifest branch ("only the dbt integration's answer has columns", lineage.tsx:636) never fills.

Fix: correct the README and the PR text (columns need a compiled manifest), or change the contract so a read-only lineage call may use the integration.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6c29e46. The README now says columns come from the compiled manifest, and that the plugin's calls are read-only, so altimate-code never starts the project's dbt adapter for them. The PR text says the same. The pane no longer calls dbt_lineage for a project with no manifest, since that call could only return what the pane already shows.

export const MISSING_BINARY = /ENOENT|not found|no such file|(cannot|could not|failed to) (start|spawn|run)/i

/** Runs the altimate CLI under the first name that is installed. */
export async function runAltimate($: EngineInterface, args: string[], timeoutMs = 30_000) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR (regression of #12) — Blocker: yes. In #12, runAltimate defaulted to DEFAULT_TIMEOUT_MS (90 s). This shared copy defaults to 30 s, and listWorkspaces calls it directly, not through runCli, with no timeout: runAltimate($, ['workspace', 'list', '--json']) at integrations.tsx:228. So listing workspaces now times out at 30 s instead of 90 s.

Fix: pass DEFAULT_TIMEOUT_MS at that call site, or keep 90 s as the default here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6c29e46: runAltimate defaults to 90 s again, as in #12. Every lineage call passes its own timeout, so only the integrations calls that relied on the default are affected.

const node = focus && graph ? graph.nodes[focus.model] : null
if (!node?.relation) return
const tools = await $.tool.list().catch(() => [])
const found = tools

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR (security) — Blocker: no (only a relation name leaves, and the behaviour is documented; safe to do in a follow-up). The server is chosen by tool-name prefix: any connected MCP server that exposes a tool named table_lineage or databricks_table_lineage receives the model's database.schema.table. That happens on every new follow, including automatic follows from Claude's reads, with no prompt and regardless of the user's permission rules for that tool. The hooks API says to key trust on the server's source, not on the tool-name prefix.

Fix:

  • Filter by provenance, or by an allowlist of Altimate servers.
  • Add an opt-out.
  • Say in the README that the table name is sent to that server.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed in 249174c, with one part not possible.

  • $.tool.list() returns only name, description and mcp. The server's source appears only on command-hook inputs, so a mod can't filter by provenance there.
  • The table name is now sent only for a model the person asked for: /lineage, a press in the pane, or r. It is never sent when the pane follows one of Claude's reads. The focus is checked again right before the call, so a follow that lands in between is never sent.
  • The README says the table name is sent to that server, and when.
  • Tests: Claude's read sends nothing; a press sends one call; a later read sends nothing more.
  • An opt-out setting is left for a follow-up.

}
}
// `models/`, `./models` and `models` are one folder: paths must match what a follow looks up.
const clean = (dir: string) => normalize(dir).replace(/\/+$/, '').replace(/^\.\//, '')

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR (file access) — Blocker: no (edge input; the reads are local and read-only). clean() keeps a leading .., so model-paths: ["../.."] in dbt_project.yml makes the walk read up to 5,000 .sql/.py files above the project, and holds() keeps listing past the cap. A manifest_path from a dbt_lineage call, or a .sql path in Bash output (lines 473-480 and 1068-1096), likewise loads any dbt project on disk into the pane. Symlinks are correctly not followed.

Fix: drop layout entries that are absolute or start with ..; optionally keep follows to projects under the session root.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6c29e46: model-paths, snapshot-paths and seed-paths entries that are absolute or climb out (.. as a whole segment) are dropped before the walk. model-paths: ['.'] still walks the project folder, skipping its target/, packages and environments (249174c). Tests: ../.. and /etc are never listed; . reads the root models but not target/. Following a path Claude itself read in another project is how the pane is meant to work, so I left that as it is.

name: COMMAND,
description: 'One-click GitHub, Jira and Linear integrations for your Altimate workspace',
})
await startLineage($)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR (composition) — Blocker: no (it needs an engine-side failure; safe to do in a follow-up). startLineage is awaited before the integrations pane's own setup. If it throws (command registration refused, a state write failing), the engine skips the rest of this hook: busy is not reset and the integrations band never scans.

Fix: wrap the call in try/catch, or move it after the integrations setup.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6c29e46: startLineage is caught and logged, so the integrations pane's own setup always runs.

await update($, reachAtom, prev => (mine() ? reach : prev))
if (!result?.ok) await update($, messageAtom, prev => (mine() ? `impact_analysis failed: ${firstLine(result?.text) ?? 'altimate-code did not answer'}` : prev))
else if (reach) $.ui.log(`lineage-pane · column ${column} · impact_analysis(${node.name}, column: ${column}) · read by ${reach.models.length} models`)
else if (result.metadata?.column_reach_error) await update($, messageAtom, prev => (mine() ? `Column reach failed: ${firstLine(String(result.metadata.column_reach_error))}` : prev))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR (contract) — Blocker: no (edge input: an unreadable exposures section). AltimateAI/altimate-code#1635 reports exposures_error when the dashboards could not be read. The plugin ignores it, so both the pane and the impact note to Claude say "read by N models and 0 dashboards" as if the count were settled.

Fix: treat exposures_error as unsettled (an "at least" count) and show it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6c29e46 and e47b064. exposures_error makes the dashboard count a lower bound everywhere: the pane's column box ("at least N dashboards" plus the reason), the toast, the band and the note to Claude ("the dashboards could not be read (...); more may read it"). The model count is unchanged. Test: an unreadable dashboards section shows "at least" on the toast and band, with the reason in the note.

@sahrizvi

sahrizvi commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

Nits, missing tests and unintended consequences (from the consensus review; none of these tie to a single diff line)

Nits

  • drawKey runs JSON.stringify(graph.parents) on every pane render. That is cheap at 500 nodes, but a graph revision stamp would be cleaner.
  • Subagent keys in contextGiven are cleared only at session end, so that set grows slowly without bound.
  • The context de-duplication key includes the manifest path but not its mtime, so a recompile within one turn is not re-announced.
  • When include_graph returns graph_error, the pane tells the user to upgrade the build. That branch exists only on the integration path, so read-only calls can't reach it.
  • mcpServer() never clears a cached serverName, so a dropped server costs a failed MCP call before each CLI fallback.
  • sqlGraph reads every file in a folder with one unbounded Promise.all.
  • The HOLD_TOTAL_MS comment says the hold is "inside the hook's 10 s budget", but $ calls, timers included, don't count against that budget.
  • commandDir doesn't expand ~ or $VAR.

Missing tests

  • turn.complete in a session root that is not a dbt project: no walk, or a bounded one.
  • An impact note with long or instruction-like exposure labels and many exposures: sanitized and capped.
  • A timed-out impact-first run: no new traces after the deadline, and in-flight traces de-duplicated.
  • model-paths containing ...
  • exposures_error in impact_analysis.
  • A real older altimate CLI without tool run; the current tests use a stub.

Unintended consequences

hkaltai and others added 6 commits November 3, 2026 17:10
…om an ended conversation never claims the pane, and the monorepo lookup is tested

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… deadline and are shared, answers walk no folder until a dbt project is seen, and model folders stay inside the project

- impact note: each name one line, quoted unless plain, lists capped, the note capped at 4 KB and framed as data
- no trace starts once the hold is over; the CLI run stops at the deadline; a running trace or include_graph run is shared
- projectsBelow is a bounded, cached walk, and turn.complete uses it only after a dbt project was seen
- model-paths that are absolute or climb out of the project are not walked; SQL files are read 32 at a time
- exposures_error keeps the dashboard count a lower bound
- the warehouse table_lineage call runs only for a model the person asked for
- runAltimate defaults to 90 s again; startLineage failing does not stop the integrations setup
- the README says columns come from the compiled manifest (read-only calls never start the dbt adapter)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s included, checked just before the call; deadlines hold through connect and fallback

- a press, /lineage or r marks the model asked for; the call checks the focus right before it is sent
- runTool takes an absolute deadline and starts nothing past it, MCP or CLI
- the note escapes non-ASCII, so its 4000-character cap is a byte cap
- model-paths ['.'] walks the project folder, skipping its builds and packages
- a follow from an ended conversation does not mark a dbt project seen
- the folder cache is cleared only on refresh and dbt_project.yml edits
- an unreadable dashboards section bounds the dashboard count, not the model count

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the band says when the dashboard count is a lower bound

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hkaltai

hkaltai commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Replying to the nits, missing tests and unintended consequences (comment above). Fixes are in 6c29e46, 249174c, e47b064 and c6972f3.

Nits

  • drawKey and JSON.stringify(graph.parents): not changed. It costs about 1 ms at 500 nodes, and a revision stamp would need every graph writer to set it. Worth doing if profiling shows it.
  • contextGiven growing with subagent keys: a main-agent turn.start clears the whole set, subagent keys included (registerLineage, turn.start), so it lasts one main turn at most.
  • De-duplication key without mtime: fixed. The key now includes the manifest's mtime, so a recompile within a turn is announced again.
  • graph_error telling the user to upgrade: fixed. When include_graph returns graph_error, the pane shows that reason.
  • mcpServer() keeping a dropped server: fixed. A failed MCP call clears the cached name, so the next call reconnects, or skips MCP for a minute if the connect fails.
  • sqlGraph's unbounded Promise.all: fixed. Files are read 32 at a time.
  • HOLD_TOTAL_MS comment: fixed. It no longer mentions the hook budget.
  • commandDir with ~ or $VAR: not changed. Such a cd resolves to a folder that doesn't exist, so the follow quietly does nothing; the command's own .sql path or the session's project still applies.

Missing tests: all added except the last, and each fails without its fix:

  • a session root that is not a dbt project: no walk;
  • long and instruction-like labels: quoted, capped, under 4 KB in bytes;
  • no trace after the deadline, and a shared trace;
  • model-paths with .. and with .;
  • exposures_error.

A real older CLI without tool run is still covered only by the stub: CI installs no altimate CLI, so an end-to-end run against an older build would need a pinned install. Left as a follow-up.

Unintended consequences

Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated
let conversationSeq = 0
// Set once a dbt project is seen this session: until then an answer never walks the session's folder.
let projectSeen = false
// Per folder, until a refresh or an edit of a .sql file or dbt_project.yml.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: projectsCache is not cleared by a .sql edit: .sql/.py edits only call clearSqlGraphs() (line 1166), while the cache is cleared on r (line 579), on a dbt_project.yml edit (line 1168), and in newConversation(). The comment describes invalidation that does not happen.

Suggested change
// Per folder, until a refresh or an edit of a .sql file or dbt_project.yml.
// Per folder, until a refresh, a dbt_project.yml edit, or a new conversation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 397a435: the comment now reads "Per folder, until a refresh, a dbt_project.yml edit, or a new conversation.", which is what the code does.

// press, r), never one the pane followed from Claude's reads: checked against the focus now, just
// before the call, so a follow that landed meanwhile is not sent.
const now = await read($, focusAtom)
if (!sameFocus(now, focus) || askedFor !== `${now!.model}\u0000${now!.manifest}`) return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: This gate only passes for a follow that set askedFor. /lineage with no model goes to command.run's else branch, which calls loadDetails($, null, false) without running followOnce, so opening the pane on a model the pane followed from a read never fetches warehouse lineage, even though the README added in this PR lists /lineage as an ask. Set askedFor for the shown focus there, or scope the README wording to /lineage <model>.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 397a435: bare /lineage now marks the shown model as asked for before it reloads the details, so warehouse lineage loads as the README says. Test: after Claude's read nothing is sent; after a bare /lineage the call is made. It fails without the fix.

Comment thread plugins/altimate-code/hooks/lineage.tsx Outdated
uncertain: unique(reaches.flatMap(one => one.uncertain), key).filter(one => !models.has(key(one))),
untraced: unique(reaches.flatMap(one => one.untraced), key),
truncated: reaches.some(one => one.truncated),
...(reaches.find(one => one.exposuresError) && { exposuresError: reaches.find(one => one.exposuresError)!.exposuresError }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION]: reaches.find(one => one.exposuresError) is evaluated twice for the same result. Find the first reach with an error once (e.g. const exposuresError = reaches.find(one => one.exposuresError)?.exposuresError before the return) and spread that value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 397a435: the first reach with an error is found once, and its exposuresError is spread.

… asks for the model shown

- runTool bounds $.mcp.call by the time left, then tries the CLI, so a stuck call never holds a shared trace
- /lineage with no model marks the shown model as asked for, so warehouse lineage loads as the README says
- comment and README wording match the code (cache invalidation, impact-first traces every touched column)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@sahrizvi sahrizvi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. Re-review at 397a435, after the earlier review at c8bc897.

The three earlier MAJOR findings are fixed. One residual each for MAJOR 1 and MAJOR 2 is below as a minor. The two blocking minors (README and the listWorkspaces timeout) are fixed. Of the four non-blocking minors, three are fixed and one (the warehouse server picked by tool name) is reasonably deferred. The status of each earlier finding is in the PR comment that follows.

The edit hold is bounded at 8.5 s on every path. Project text reaching Claude's context is quoted, escaped and capped. No walk reads outside the project. #12's pane is not regressed. The contract fields match AltimateAI/altimate-code#1635 and #1637.

Minor findings (inline), none blocking:

  1. The include_graph lookup in the impact hook has no deadline (lineage.tsx:876). It does not block because the edit is never held past 8.5 s, and the run is bounded, shared and read-only.
  2. The 4000-byte cap leaves out the closing sentence, so the note can reach 4083 bytes (lineage.tsx:955). This is cosmetic: the overshoot is a fixed plugin string, and the note stays bounded.
  3. The pane renders reason raw (lineage.tsx:1552). This is visible UI text, not hidden context; it is safe to fix in a follow-up.
  4. The suite fails under CPU load: a wall-clock bound, and an unhandled rejection at the end of the file (lineage-layout.test.ts:480). This is test-only.

Reviewed by a 5-model consensus panel.

let id = graph ? findModel(graph, relative(root, file)) : null
if (!graph) {
const name = (file.split(/[\\/]/).pop() ?? file).replace(/\.sql$/i, '')
const built = await graphFromTools($, manifest, stat.mtimeMs, name, relative(root, file)).catch(() => null)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR (Performance): the include_graph lookup in the impact hook still has no deadline (residual of the earlier MAJOR 2, uncancelled traces)

For a manifest over 4 MiB, impactFirst calls graphFromTools here without the deadline. That runs dbt_lineage with include_graph CLI-only, under the 60 s TOOL_TIMEOUT_MS (line 473). So the process can keep running for up to about 51 s after the 8.5 s hold has let the edit go. It is shared per manifest revision and model, and it fills the cache for the next edit. Once it lands after the deadline, no column trace starts (the left <= 0 check at line 906), so nothing else follows from it.

Fix: pass deadline through graphFromTools to runTool (its until argument), or keep it deliberately as a cache warm-up and say so in a comment.

Blocker: no. The edit is never held past 8.5 s, and the run is bounded, de-duplicated and read-only.

// manifest's revision: a recompile is told again.
key: `${e.agentId ?? 'main'}\u0000${manifest}\u0000${stat.mtimeMs}\u0000${id}\u0000${traced.join(',')}\u0000${unchecked.join(',')}`,
toast: toastText,
context: `${body.length > MAX_NOTE ? `${cut(body, MAX_NOTE)}...` : body}\n${close}`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR (Security / accuracy): the 4000-byte cap does not include the closing sentence (residual of the earlier MAJOR 1)

The cut applies to body only. Then ..., a newline and the fixed 79-character close sentence are appended, so the note can reach 4083 bytes. Sanitising is complete: every project value goes through quoted()/listed(), and each list is capped. But the stated guarantee, "the whole note is cut at 4000 bytes", is only partly met.

Fix: reserve close.length + 4 from MAX_NOTE before cutting body, or cut the assembled string. Assert the final context length in the test.

Blocker: no. The overshoot is a fixed plugin-authored string, and the note stays bounded under 4 KiB.

) : null}
{reach.untraced.length > 0 ? (
<Text color="warning">
{reach.untraced.length} not traced ({reach.untraced[0]!.reason}): {reach.untraced.slice(0, 4).map(one => one.name).join(', ')}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR (Security / UI): the pane renders reason raw

reach.untraced[0].reason (and the uncertain names on line 1547) come from impact_analysis as free text. They are shown with no control-character strip and no length cap. exposuresError goes through firstLine(), which shows how this could be handled.

Fix: run these through firstLine(), or through the same CONTROL strip with a cap (for example 120 characters).

Blocker: no. This is visible pane text the person sees, not hidden context, so it is UI hardening.

expect(columnOf(rows, 'missing')).toBe(-1)
})

test('drawLayered draws a 500-node lineage with an unbounded width in well under 300 ms', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR (Testing): the suite fails under CPU load

At 397a435 on an idle machine, claude plugin test gives 157 pass / 0 fail in about 4 s. With other processes busy, two runs at the same head failed:

  • this test, at about 420 ms against the 300 ms wall-clock bound;
  • lineage.test.tsx's "the file ran to its end", with a rejection nothing handled: ... $.clock.now refused: no hooks module of that name is loaded. Work scheduled with later or within outlived the test file, and the whole run took about 30 s instead of 4 s.

There is no CI job running claude plugin test, so this shows up only locally.

Fix: settle scheduled work before each test ends (or end it explicitly), and make the timing bound relative or looser.

Blocker: no. Test-only; no product behaviour depends on it.

@sahrizvi

sahrizvi commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

Re-review at 397a435: status of earlier findings, nits, missing tests and unintended consequences (from the consensus re-review; these don't tie to a single diff line)

Earlier findings (review at c8bc897)

Earlier finding Status Evidence at 397a435
MAJOR 1: repo text unsanitized and unbounded in the hidden context reminder Fixed; the whole-note cap is partly met Every project value goes through quoted()/listed() (lineage.tsx:844-851, 931-947), including the reason strings and exposuresError. The note has the data-not-instructions frame (932) and an escape-safe cut (853-856). The closing sentence is appended after the 4000-byte cut, which brings the note to 4083 bytes (inline at 955).
MAJOR 2: traces not cancelled, started with no time left, not de-duplicated Fixed for the column traces; one residual left <= 0 (905-906). The deadline is checked before MCP (420) and before the CLI fallback (442). The CLI timeout is the time left, the MCP call is wrapped in within (424), and shared() keys on the manifest revision, model and column (908). The include_graph lookup for a manifest over 4 MiB has no deadline (inline at 876).
MAJOR 3: every answer walks the session folder Fixed Gated on projectSeen (1113). Breadth-first, capped at 2000 folders, 16 at a time, and cached (193-222).
README claimed column lineage through the dbt integration Fixed README:108; no dbt_lineage call for a SQL-file graph (707).
listWorkspaces 90 s → 30 s Fixed altimate-cli.ts:13
Warehouse server picked by tool name Reasonably deferred tool.list exposes no source. The table name is now sent only for a model the person asked for, re-checked just before the call (778), and README:111 says so. The opt-out is a follow-up.
Reads outside the project Fixed inside() (377), with tests for ../.., /etc and .
startLineage in #12's session.start Fixed integrations.tsx:562
exposures_error ignored Fixed lineage-graph.ts:448, then the toast, the note, the band and the pane

Nits

  • A timed-out MCP call keeps running on the server. The shared entry is released when within gives up, so a server that never answers gets one more hung call per later edit of that column. The hooks API has no abort, as acknowledged.
  • manifestCache holds one entry, so in a monorepo each answer can re-parse several candidate manifests (each up to 4 MiB) before one matches.
  • The ask-Claude button's $.prompt.submit (lineage.tsx:1601) puts node.name and column unquoted into the prompt. The person presses it and sees the text, but it is inconsistent with the note's quoting.
  • projectsCache is cleared only by an Edit or Write of dbt_project.yml, not when a project is created through Bash (dbt init) in the same conversation.
  • When the session root is itself a dbt project, the answer lookup never looks below it for nested projects (lineage.tsx:1113). This is unchanged since the last round.

Missing tests

  • An impact edit on a manifest over 4 MiB where include_graph is slower than the hold.
  • An assertion on the final context length, closing sentence included.
  • Control characters and very long text in the reason fields rendered in the pane.
  • A real older altimate CLI without tool run (acknowledged as a follow-up).

Unintended consequences

  • Warehouse lineage no longer appears when the pane auto-follows Claude's reads, only on /lineage, a press or r. This is intended and documented.
  • Answer-driven model discovery in a monorepo waits until the conversation has seen a dbt project. This is intended.
  • feat: one-click GitHub, Jira and Linear credentials pane #12's pane: no regression found. The 90 s default is restored, and a lineage start-up failure is caught.

@hkaltai
hkaltai merged commit 20c6e2d into feat/one-click-local-integrations-sync Nov 3, 2026
2 checks passed
@hkaltai

hkaltai commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

This PR merged into feat/one-click-local-integrations-sync (its stacked base) after #12 had already merged into main, so its code isn't on main yet. #15 carries the same approved code, #14's branch at 397a435, to main.

hkaltai added a commit that referenced this pull request Nov 3, 2026
…gration

feat: dbt lineage pane beside the chat (#14, onto main)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants