Conversation
Adds docs/AGE_PLAN.md covering the Apache AGE graph-database sink: cost-efficient sync, multi-branch support via commit-anchored diff chains with lazy materialization, a multi-repo registry with ownership, storage optimization, and a three-tier testing strategy. Architecture approved across four external review rounds. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Records the results of executing the Phase 0 compatibility spike against a live apache/age container (PostgreSQL 18.1, AGE 1.7.0): - cypher()'s graph name argument must be a literal, not a bind parameter (psycopg.errors.SyntaxError otherwise). - SET n += <map> / SET n = <map> reject a map arriving via a parameter or UNWIND-bound data; only a literal map in the query text works. Invalidates the originally-assumed generic "SET n += row.props" batch-write shape. - EXECUTE stmt(%s) with an agtype payload as a bind parameter fails with IndeterminateDatatype; the payload must be embedded via psycopg.sql.Literal instead. Updates Phase 1's exporter design to build prepared statements with an explicit per-field SET list (matching the fixed lean/full property schema) rather than a generic property-map spread, and to embed the graph name and EXECUTE payload as SQL literals rather than bind parameters. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds tests/test_age_integration.py, the Phase 0 deliverable from docs/AGE_PLAN.md: a live-service integration suite for Apache AGE following tests/test_falkordb_integration.py's exact pattern (importorskip + service-identifying connection probe + env-var overrides + clean-slate fixture), so it is a silent no-op in the default CI and a manual, opt-in suite for a dev running a local apache/age container. Validated live against apache/age@sha256:4241e2d8bb...358fc5 (PostgreSQL 18.1, AGE 1.7.0), pinned by digest in the module docstring: - graph creation and basic cypher() round trips - agtype parameter maps via PREPARE/EXECUTE - UNWIND $rows + MERGE batch writes, including idempotent re-run - transactional rollback on a mid-batch failure - GIN index creation on an AGE label table Also includes an end-to-end test against the future push_to_age() exporter, which currently skips until Phase 1 lands the function. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Records Phase 1 (push_to_age() exporter) as done, including a scope note on the pure-planner/executor split from the Testing strategy section: row/field selection and batching are pure and tier-1 tested, but the deletion-diff snapshot query and SQL statement construction remain inside push_to_age() itself. Full CI-parity suite green with --all-extras (5384 passed). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds push_to_age() to graphify/exporters/graphdb.py, a sibling of push_to_neo4j/push_to_falkordb, re-exported from graphify/export.py. Built around the Phase 0 spike findings: the AGE graph name and the EXECUTE payload are embedded as escaped SQL literals rather than bind parameters, and every SET clause enumerates the pushed property fields by name (AGE rejects a parameterized/UNWIND-derived map in SET). Writes are batched via UNWIND $rows (~500 rows/statement), grouped by sanitized label/relation, in prepared statements per push, all inside a single transaction. Deletion-safe: before upserting, snapshots existing (id, label) and (src, tgt, relation) triples and deletes what's absent from the incoming graph in the same transaction, including deleting a node under its old label when file_type changes. Also promotes _safe_label/_safe_rel to module level, deduplicating them out of push_to_neo4j and push_to_falkordb, so label/relation sanitization is identical and pinned by one shared test across all three exporters. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tests/test_age_exporter_unit.py (20 tests, no DB, runs in default CI): schema-pinning for _safe_label/_safe_rel, lean vs. full-props field selection, row grouping with structural-metric stamping (degree/is_god_node/in_cycle), batch chunking, and the missing-psycopg ImportError guard. tests/test_age_integration.py: extends the Phase 0 spike file with the full push_to_age() reconcile matrix, validated live against apache/age (PostgreSQL 18.1, AGE 1.7.0) - fresh push, idempotent re-push, node/edge deletion reconciled, property change, label change (old-label cleanup), atomicity on a pre-write failure, and --full-props. Found and fixed one real bug while writing these: node deletion used `row.id` inside `UNWIND $ids AS row`, but $ids is a plain list, so `row` is already the id scalar, not a map - AGE raised "scalar object must be a vertex or edge". Fixed to `row` directly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- graphify export age --push postgresql://user:pass@host/db [--graph-name NAME] [--full-props], password via AGE_PASSWORD or PGPASSWORD (appended as a libpq URI query param when not already embedded in --push). No offline cypher.txt fallback: AGE's cypher() requires a live connection to build statements against. - New `age = ["psycopg[binary]"]` extra, independent of `postgres` (which also carries the unrelated tree-sitter-sql grammar); added to `all`. uv.lock regenerated via `uv lock`. - docs/AGE_SCHEMA.md: the pinned Cypher schema contract (node/edge properties, label/relation sanitization, AGE-specific implementation notes from the Phase 0 spike, example queries). - README: `age` extra listed alongside neo4j/falkordb. - ARCHITECTURE.md: push_to_age() added to the export.py module row (test_architecture_doc.py still passes). Validated end-to-end against a live apache/age container via the actual CLI command, not just the Python function directly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The plan originally called for one branch per phase. Checked the actual convention: the README's Contributing section only ever says "open a PR" (singular, never plural or "stacked"), and the closest prior comparison in scope - the FalkorDB backend - shipped as one branch (falkordb-backend) and one PR (Graphify-Labs#1176) containing four separate commits. Consolidates the plan's Delivery section and every phase's checklist to one branch (apache-age-backend, matching the falkordb-backend naming) carrying every phase as separate commits; "commit each milestone separately" means separate commits inside that one branch/PR, not separate PRs per phase. The age-phase0-spike and age-phase1-exporter branches used while this was still being worked out have been folded into apache-age-backend with their commits intact. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds graphify/age_registry.py, the authoritative SQL schema and repository identity/ownership machinery from docs/AGE_PLAN.md Phase 2. Plain PostgreSQL, not AGE - the registry tables live outside any AGE graph namespace. - normalize_remote_url() canonicalizes SSH and HTTPS remotes (and differing case) to the same dedup key; repository_id_for() derives a deterministic UUID from it via uuid5, so the id is computable client-side before a repo is ever registered. - resolve_owner() implements the documented fallback order: explicit --owner -> remote URL's owner segment -> git config user.email. - ensure_schema() applies an ordered list of idempotent migrations (tracked in graphify_registry_migrations), creating graphify_repos, graphify_snapshots, graphify_branches, graphify_branch_revisions (with the one-active-revision partial unique index), graphify_branch_diff, and graphify_quality_findings. - register_repository() upserts a repo's identity row; fields COALESCE on conflict so a push that doesn't know the owner never blanks it out. resolve_age_graph_name() is the agent-facing discovery lookup, returning None (not raising) for an unregistered repo/branch or an unmigrated database. - Git helpers follow graphify.watch._git_head's cwd-anchoring convention (Graphify-Labs#2316); _git_head itself is reused for the commit SHA rather than reimplemented. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tests/test_age_registry_unit.py (16 tests, no DB, default CI): normalize_remote_url SSH/HTTPS agreement and case folding, repository_id_for determinism and uniqueness, resolve_owner's fallback order. tests/test_age_registry_integration.py (tier 2: live Postgres, no AGE extension required - reuses the apache/age container purely as a Postgres server): migration idempotency, repository upsert and identity dedup across SSH/HTTPS remotes, COALESCE-on-conflict behavior, agent discovery resolution (including the unregistered/ unmigrated-database case), the partial unique index rejecting a second active branch revision, and snapshot uniqueness allowing a delta and a reconstructed checkpoint for the same commit to coexist while rejecting a true duplicate. Found and fixed two real bugs while writing these: psycopg returns UUID columns as uuid.UUID objects, not str, so register_repository() now normalizes repository_id to str for callers; and resolve_age_graph_name() raised UndefinedTable instead of returning None when the registry schema doesn't exist yet, which a read-only discovery call should never treat as an error. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wires age_registry into the CLI: after a successful push, graphify export age --push registers/updates the repo's identity in the registry (owner resolved via the documented fallback order, branch/commit from git). New flags: --owner, --repo-tag, --remote-url (override auto-detection), --no-register. Fails soft in both directions - a missing git remote warns and skips registration rather than failing the push; a registration error after a successful push warns rather than losing the pushed graph. Validated end-to-end against a live container, including the --no-register and no-git-remote paths. Also updates ARCHITECTURE.md's module table for age_registry.py (test_architecture_doc.py still passes). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Records Phase 2 (repository identity, ownership, registry) as done, with one deferred item made explicit rather than glossed over: extraction_config_hash remains an unpopulated reserved column since nothing threads real extraction config (backend/model/confidence thresholds) through to the push path yet - added as a new Open Questions entry to resolve in Phase 3, the first phase that actually needs to detect incompatible graphs via this field. Also records that the "global cross-repo AGE graph" deliverable needed no new code: the existing CLI's --graph flag already lets push_to_age() push the pre-merged, cross-repo-linked global graph (global_graph.py + cross_repo_calls.py output) as-is. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds changed_nodes/changed_edges (old and new scalar property values) and a full "properties" payload on new/removed nodes and edges, so a diff carries everything needed to replay it against a snapshot in either direction. Purely additive to the existing return shape - pre-existing callers (skill update.md docs, existing tests) are unaffected. Groundwork for AGE Phase 3's logical snapshot/delta chain. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
graphify/age_snapshots.py stores periodic full checkpoints plus ordered reversible deltas in graphify_snapshots (Phase 2's schema) and reconstructs the latest known graph from them - the immutable base the branch machinery (Phase 4) will eventually pin against. Not an AGE graph copy; reconstruction never touches AGE. Checkpoint cadence: every Nth push (default 20, overridable) is a full checkpoint, the rest are deltas against the reconstructed prior state. Checkpoint-preferred resolution when replaying rows, so a checkpoint and a delta can coexist for the same commit_sha without the delta ever being replayed. record_snapshot() is idempotent by commit_sha alone (checked before the cadence/kind decision) - the uniqueness constraint alone doesn't guarantee this, since which "kind" a re-push computes depends on how many rows exist now, not on commit_sha. Found live via test_record_snapshot_is_idempotent_for_same_commit and fixed before this commit. Reconstruction/diff-application logic is pure and DB-free (tier-1 tested in test_age_snapshots_unit.py); only the read/write of graphify_snapshots rows needs live Postgres (test_age_snapshots_integration.py, tier 2). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
push_to_age() gains a diff= parameter (graph_diff()'s shape). When given, it skips the full read-existing-AGE-state reconcile entirely and instead upserts only the new/changed node and edge rows the diff reports, deleting only what the diff reports removed - including deleting a relabeled node under its *old* AGE label, since node identity has no label component. O(changed) instead of O(graph) once a prior snapshot exists (docs/AGE_PLAN.md Phase 3). diff=None (the default) keeps the exact Phase 1 full-push behavior unchanged. The filtering/deletion-computation logic is factored into _age_diff_filter(), a pure function, so it's tier-1 testable without a live AGE instance. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Repository identity is now resolved before the push (not only after, as Phase 2's registration-only wiring did): with a known repository_id, the CLI reconstructs the last-recorded snapshot, diffs the new graph against it, and passes that diff into push_to_age() for incremental sync. Falls back to a full push when there's no prior snapshot, --no-register is passed, or no git remote is found - identical to Phase 1/2 behavior in all of those cases. After a successful push, the snapshot for this commit is recorded (record_snapshot()) alongside the existing registry upsert. Validated live end-to-end: full push -> checkpoint -> incremental push that adds, removes, and relabels nodes -> verified directly against AGE's live graph state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Documents age_snapshots.py in ARCHITECTURE.md, resolves the checkpoint-cadence open question (every Nth push, default 20), and records the commit_sha-idempotency bug found and fixed live during Phase 3 testing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds an optional conn= parameter: when given an already-open psycopg connection, push_to_age() writes through it and leaves commit/close to the caller instead of opening and managing its own. Needed for AGE Phase 4's atomic branch-push requirement (the AGE mutation, new diff rows, and head SHA update must commit together in one transaction). conn=None (the default) keeps every existing caller's behavior unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two additions age_branches.py's base-snapshot resolution needs: - snapshot_graph_by_id(): reconstruct the graph "as of" a specific recorded snapshot (not just "latest"), via a new pure _reconstruct_payload_up_to() that truncates the replay chain at a given snapshot_id. - record_historical_checkpoint(): record a checkpoint reconstructed out-of-band (a git-worktree extraction at an old commit) for a commit older than anything currently retained. Backdates created_at to just before the repository's earliest existing row instead of using the insert-time default - _reconstruct_payload_from_rows() walks rows in created_at order assuming that tracks git history order, so inserting an old commit's state with a plain now() timestamp would make it look like the *newest* state and silently corrupt every later default-branch reconstruction. Caught during design, not live - documented in the function's docstring. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A side-effect-free counterpart to register_repository()'s upsert: returns the registered row (including default_branch) or None, without creating anything. The CLI's branch-aware export age dispatch (Phase 4) needs the registered default_branch to decide whether the current push targets the default branch or a feature branch, and shouldn't have to run a full upsert just to check. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
graphify/age_branches.py implements docs/AGE_PLAN.md Phase 4: a non-default branch is never pushed as its own full AGE graph. Instead push_branch() records an ordered, reversible diff chain in graphify_branch_diff against a pinned base snapshot (resolved via merge-base detection, falling back to a temporary git-worktree extraction when nothing retained covers that commit). An AGE graph is only created lazily, on demand, via materialize_branch() - serialized by a PostgreSQL advisory lock keyed on (repository_id, branch) so concurrent callers do one hydration, not two. reap_branch() drops a materialized branch graph after a generous idle grace period; diff rows and snapshots are never deleted, so a reaped branch always re-materializes cleanly. Force-push/rebase is detected via git ancestry (head_commit_sha no longer an ancestor of the new head), which starts a fresh generation from a newly resolved base rather than corrupting the existing diff chain; the old generation is marked inactive, never deleted, and its now-stale materialized graph (if any) is dropped. Replay/validation logic (replay_branch_diffs, diff_to_rows, rows_to_diff) is pure and DB-free, tier-1 tested against fabricated rows for exactly the cases the plan calls out: transition ordering, non-contiguous chains, and cross-generation rejection. Git-only helpers (merge_base, is_ancestor, detect_rebase, and the worktree-extraction fallback) are also tier-1 tested against throwaway git repos in tmp_path - no database needed for any of this. Only push_branch/materialize_branch/reap_branch touch Postgres, and only materialize_branch/reap_branch touch AGE (tier-3 live suite). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
graphify export age --push now checks the registered default_branch before pushing: on the default branch it keeps Phase 1-3's full/ incremental default-branch flow unchanged; on any other branch it routes through age_branches.push_branch() instead - a diff-chain-only push that creates no AGE graph. New graphify age materialize / graphify age reap verbs expose the lazy-materialization and reaping machinery directly, both taking --push URI, --branch (defaults to the current git branch), and --graph-name; reap additionally takes --min-idle-seconds. Validated live end-to-end via a full CLI smoke test: default-branch push -> registration -> feature-branch push (diff rows only, no AGE graph) -> graphify age materialize -> graphify age reap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Documents age_branches.py in ARCHITECTURE.md, adds a pinned cross-branch comparison query to AGE_SCHEMA.md (two independent cypher() calls joined in one SQL statement, live-tested), and resolves the graph-name-sanitization and reaper-trigger-mechanism open questions. Records bytes_used alongside extraction_config_hash as an honest, documented gap - no size-threshold reaping without it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ters push_to_neo4j, push_to_falkordb, push_to_age, graph_diff(), and age_snapshots.graph_to_payload() all read edge source/target straight off nx.Graph.edges()'s (u, v) tuple - but for an undirected Graph that tuple order reflects node insertion order, not which side was the extracted "source". build.py has stamped _src/_tgt on every edge for exactly this reason since Graphify-Labs#1061 ("Preserve original edge direction - undirected graphs lose it otherwise"), and analyze.py/serve.py/cli.py/ exporters/html.py already read it - graphdb.py and graph_diff() never did. Confirmed live and reproducible in three lines: G = nx.Graph(); G.add_node("hub"); G.add_node("caller") G.add_edge("caller", "hub", relation="calls") list(G.edges()) # -> [("hub", "caller")], reversed Found while validating Phase 5's pinned reverse-dependency-traversal query against a real AGE instance: a query for callers of a hub node returned zero rows because every CALLS edge had been pushed backwards. Fixed by reading data.get("_src", u) / data.get("_tgt", v) everywhere edge direction is read from a graph, falling back to the tuple order when _src/_tgt is absent (a directed graph, or a hand-built test fixture) - unchanged behavior there. age_snapshots.payload_to_graph() additionally stamps _src/_tgt on reconstructed edges so the direction survives a payload round trip. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/AGE_SCHEMA.md gains an "Agent contract" section (review-agent
seed selection / traversal direction / required evidence fields /
depth-cap-limit-timeout discipline; quality-agent structural-property
vs. graphify_quality_findings boundary) plus new pinned queries:
branch-diff seed selection (plain SQL against graphify_branch_diff),
depth-capped/limited/timed-out traversal, and quality findings joined
with structural properties - each executed verbatim in
tests/test_age_agent_queries_integration.py against a seeded live AGE
instance, not just written down.
Rewrote the existing shortest-path pinned query after finding, live,
that AGE 1.7.0 has no shortestPath() function and that list-
comprehension path projection ([n IN nodes(p) | n.id]) fails outright
on this version - neither had ever actually been run before. Replaced
with length(p) ordering plus client-side parsing of the returned path,
and added a pinned parse_agtype_objects() helper since a returned
vertex/edge/path is agtype text (a {...}::vertex/::edge/::path suffix
after each object), not plain JSON, and a naive regex breaks on the
nested "properties" object.
extraction.json (the falkordb suite's fixture) proved too small to
meaningfully exercise depth caps/result limits/statement timeouts, so
a dedicated 200-node fan-out fixture was added per the plan's own
instruction, rather than weakening those assertions. statement_timeout
is proven to actually cancel a runaway query (QueryCanceled), not just
documented as advice.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Records the two AGE 1.7.0 gaps found live (no shortestPath(), broken list-comprehension path projection) and the edge-direction bug fixed as a prerequisite for Phase 5's queries to even validate correctly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
graphify/age_backend.py fetches a whole AGE graph into an nx.MultiGraph
shaped exactly like build()'s output: full node property set as attrs,
_src/_tgt stamped on every edge (an undirected nx.Graph loses direction
otherwise, same idiom as build.py/analyze.py/exporters/graphdb.py). A
MultiGraph so a genuine parallel edge between the same pair (two
different relation types) is never silently dropped.
Also promotes parse_agtype_objects (docs/AGE_SCHEMA.md's pinned
depth-aware agtype vertex/edge/path parser - a returned object is
"{...}::vertex" text, not plain JSON, and a naive regex breaks on the
nested "properties" object) into this module as the canonical
implementation, and updates test_age_agent_queries_integration.py to
import it instead of keeping a duplicate copy that could drift from
the doc.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Each subcommand's existing graph-loading block gains an --age postgresql://... --graph-name NAME alternative to --graph: when given, fetch_graph_from_age() replaces the file-load step and everything downstream (scoring, path-finding, formatting, querylog) runs unchanged. `explain` additionally derives a directed view from the fetched MultiGraph's _src/_tgt (it needs successors()/ predecessors(), which only a directed graph has) the same way `path`'s file-backend block already does. No local file means no size cap and no query-stamp file to write. Proven, not just argued, to be behavior-identical to the file backend: tests/test_age_backend_integration.py pushes one graph to both a local graph.json and a live AGE instance and asserts the CLI's printed output is byte-for-byte identical (path/explain exactly; query modulo the one "Graph: ..." header line that legitimately differs). serve.py (the MCP/HTTP server) is deliberately out of scope here - its _load_graph/multi-graph-context-LRU/hot-reload machinery is keyed on graph_path as a filesystem path threaded through several already-large functions, and retrofitting an AGE connection as an alternate "path" carries real regression risk to existing MCP clients for a nice-to- have. cli.py's one-shot subcommands have no such state to reconcile. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Records the scope decision to implement AGE-backed retrieval for graphify query/path/explain but not serve.py's MCP/HTTP server, and why. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ental sync, schema enforcement, rebase atomicity) An external review found several correctness gaps blocking merge. All fixed: - Critical: every repo pushed without --graph-name shared the literal AGE graph "graphify", so a second repo's deletion-safe reconcile deleted the first repo's data. Default graph name is now derived from repository_id (age_registry.default_age_graph_name); branch materialize/reap resolve the registered name instead of re-defaulting. - Incremental sync silently dropped every changed/new edge for a normal lowercase relation (_age_diff_filter compared AGE's sanitized relation key against the diff's raw relation string without normalizing both). - A file_type change deleted a node's otherwise-unchanged edges via DETACH DELETE without re-upserting them. - Schema/extraction-config compatibility is now actually enforced: branch rows persist graphify_version/schema_version/extraction_config_hash, snapshot reconstruction and base-snapshot resolution filter by them, and a push whose config diverges from its branch's recorded config is rejected instead of spliced in. - A non-atomic rebase could leave graphify_branches claiming materialized=true for an AGE graph already dropped; the drop and the generation-transition registry update are now one transaction. - Concurrent branch pushes were unserialized; push_branch now holds the same per-(repository_id, branch) advisory lock materialize_branch/ reap_branch already used. - graph_diff() ignored a pure edge-direction reversal (same pair, same props, only _src/_tgt swapped); it's now reported as an explicit remove-old-direction + add-new-direction pair. - reap_branch's idle-time semantics measure push-recency, not query activity; docs/CLI wording now say so plainly rather than implying unimplemented access tracking. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ication, extraction-config enforcement) Two more high-severity gaps from a second review pass: - An unregistered repository's first `graphify export age` push unconditionally treated whatever branch was checked out as the default branch, so pushing first from a feature branch registered it as the durable default-branch graph with no diff chain recorded. Added --default-branch, plus age_registry.resolve_default_branch_ref() to read refs/remotes/origin/HEAD when available; falls back to a loud stderr warning (never a silent guess) when neither resolves. - extraction_config_hash was defined and enforced in age_branches/ age_snapshots but the CLI never computed or passed one, so the check was always a no-op. Added age_registry.extraction_config_hash() (a deterministic hash of --full-props plus an optional caller-supplied --extraction-config / GRAPHIFY_EXTRACTION_CONFIG_HASH fingerprint), threaded through every CLI call to push_branch/record_snapshot/ latest_snapshot_graph. Also extended the extraction_config_hash filter to snapshot_graph_by_id and record_snapshot's own internal reconstruction, which previously filtered on schema_version alone. Two more bugs surfaced live while exercising the --default-branch fix through the real CLI (previously masked because every branch test pre-registered the repository and passed an explicit cwd): - age_branches._extract_graph_at_commit did `cwd=str(cwd)` unconditionally for its `git worktree` subprocess calls, turning a None cwd (the CLI's default) into the literal string "None". - The feature-branch push path never upserted graphify_repos before writing rows that foreign-key onto it, so an unregistered repo's first push failed outright with ForeignKeyViolation if it happened to be a feature branch. Registration now happens once, before dispatching to either push path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A third review pass correctly flagged that the warn-and-assume fallback for an unregistered repo's first push (no --default-branch, no resolvable refs/remotes/origin/HEAD) preserved the exact original bug -- durably registering whatever branch happened to be checked out as the default- branch graph -- just with a warning printed first. A warning is not an adequate guard for a decision this persistent. Changed to a hard error: refuses to push at all, telling the caller to pass --default-branch, run `git remote set-head origin -a`, or use --no-register. This does mean a fresh repo's very first `export age --push` now requires --default-branch (or a remote with HEAD set) even in the ordinary single-branch case -- an intentional, documented trade of a small one-time friction cost against a hard-to-undo footgun. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Mark the Phase 2 "deferred" checkbox done: Phase 3 + the v15 review fixes thread graphify_version/schema_version/extraction_config_hash into every graphify_snapshots write, and the CLI now computes a real extraction_config_hash via age_registry.extraction_config_hash(). - Correct the Status header and the Open Questions entry to say the hash is populated and enforced, with only its inputs still coarse. - README Contributing: document the fork -> branch -> PR-to-upstream flow and the one-branch/one-PR-per-feature rule. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…itself
Running the full graphify pipeline on this repo (~13k nodes / ~27k edges),
pushing to a live AGE instance, and diffing query/path/explain output
against the graph.json backend surfaced three bugs the small
tests/fixtures/extraction.json never exercised:
1. age_backend.parse_agtype_objects (also AGE_SCHEMA.md's pinned parser)
tracked brace nesting but not string state, so a `label` property
holding literal `{`/`}` (a docstring fragment, a JS `${x}` template
literal) corrupted the parse and returned nothing -> the whole AGE
backend crashed with `not enough values to unpack`.
2. graphify export {age,neo4j,falkordb} loaded graph.json (written
`directed: false`, direction carried only in source/target arc order)
as an undirected graph, canonicalising endpoint order and reversing
~79% of edges vs what `graphify path`/`explain` see. Separate from the
earlier _src/_tgt fix: this is the CLI loader never handing the
exporters a directed graph. Fixed by forcing directed+multigraph for
the graph-DB sinks.
3. _bfs/_dfs/_subgraph_to_text walked neighbours and rendered edges in
edge-insertion order, and explain broke degree-sort ties the same way,
so graph.json link order vs AGE row-scan order produced different
budget-truncated answers. Neighbour iteration and edge rendering are
now sorted by node id; explain's connection sort gets an str(id)
tie-break.
After the fixes, query/path/explain output is identical between the two
backends (64/64 comparisons, modulo the intentional `Graph:` header).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
push_to_age() was ~6.6x slower than necessary: every MATCH (n {id: ...}) /
MERGE (n:L {id: ...}) compiles to a `properties @> '{"id": ...}'` filter,
and AGE indexes only its own internal id/start_id/end_id columns -- nothing
on the user-facing properties agtype -- so each id lookup was a sequential
label-table scan per UNWIND row. A full push of the ~13k-node / ~27k-edge
self-graph took 9m36s.
_ensure_age_property_indexes() now create_vlabel's each incoming vertex
label up front and indexes its properties before any MATCH/MERGE, with the
three index kinds AGE's query compilation needs:
- GIN (properties) -- serves the `@>` matches push_to_age emits itself
(9m36s -> 2m47s).
- btree on agtype_access_operator(properties, '"id"') and on
'"source_file"' -- serve idiomatic agent Cypher (WHERE n.id = / IN,
WHERE n.source_file =) and AGE_SCHEMA.md's pinned queries, which
compile to the access-operator form GIN does not serve. The plan
always called for these ("indexes on what agents filter by") but they
were never created.
Edge upsert additionally sub-groups each relation's rows by the AGE labels
of its endpoints and emits MATCH (a:SrcLabel {id: ...}), (b:TgtLabel
{id: ...}) so a labelled match probes one label table's index instead of
all of them (2m47s -> ~1m30s). Larger UNWIND batches were measured and are
worse, not better; batch_size=500 stays.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AGE_PLAN.md gains a "post-implementation dogfooding pass" section under Phase 6 documenting the three correctness bugs and the push-performance work found running graphify on its own repo, plus the file-vs-AGE comparison result (structural + retrieval parity, storage and latency trade-offs). AGE_SCHEMA.md: the pinned agtype parser is now string-aware; the implementation notes explain which property indexes AGE does and does not create and which Cypher idiom each index kind serves. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts: # pyproject.toml # uv.lock
Type-checker hygiene ahead of the upstream PR: narrow Optional on fetchone()/RETURNING rows and cursor.description (aggregate queries and INSERT ... RETURNING always yield a row), return the matched snapshot id directly instead of the Optional-typed reconstruction result, and mark _MIGRATIONS as LiteralString so psycopg's execute() overloads match -- which also pins future migrations to literal SQL. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
In --full-props mode, the union of node/edge attribute keys is interpolated raw into the Cypher SET clause (AGE rejects parameterized property maps). Values are safe -- they ride PREPARE/EXECUTE JSON payloads -- but keys were not validated. No current extractor produces source-derived attribute names, but source text is untrusted per CONTRIBUTING.md, so only keys matching a bare Cypher identifier ([A-Za-z_][A-Za-z0-9_]*) are pushed now; no rewrite, since a rewritten name could collide with a real property. Also casts internally-built SQL fragments to LiteralString via a _sql_text() helper so pyright accepts the deliberate sql.SQL() dynamic interpolation (all fragments are whitelisted identifiers/field names), and narrows an Optional fetchone() on the ag_graph count probe. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The AGE backend added `export age --push` and `--age` on query/path/explain but no skillgen fragment mentioned either, so agents never discovered them. Adds Step 7aa to the exports fragment (7aa rather than renumbering -- 7b/7c/7d headings are pinned by --audit-coverage), --age-push/--age usage lines to the shared core fragment, and an --age note to the query reference. Regenerated via `python -m tools.skillgen --bless`; --check confirms 134 artifacts in sync. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The repo moved to Graphify-Labs/graphify but the fork/PR instructions in README and docs/AGE_PLAN.md still pointed contributors at the old org. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
Thanks for the pull request, @e4c5. A maintainer will review it soon. Want to talk it through while it is in review? Come join us on our Discord server. For longer-form discussion there is also GitHub Discussions. A couple of things that speed up review: make sure the test suite passes on Python 3.10 and 3.13, and that the change keeps extraction deterministic. |
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.
Graphify review — findings
Adds Apache AGE (PostgreSQL) as a graph-database backend via a new age extra, exposing push_to_age alongside the other exporters so graphify export age --push writes a Cypher graph directly to an AGE instance. Backs it with a multi-repo/multi-branch registry that derives each repo's AGE graph name from its repository_id (no longer a shared literal "graphify"), records logical base snapshots plus incremental deltas for the default branch, and carries non-default branches as commit-anchored diff chains that materialize lazily and reap when idle; graphify query/path/explain --age URI --graph-name NAME fetch from a live AGE graph and reuse the file backend's scoring unchanged. Fixes a directional-edge bug across all graph-DB exporters and graph_diff() where a pure edge reversal produced no diff, and makes an unregistered repo's first push a hard error when the default branch can't be resolved (requiring --default-branch, a remote HEAD, or --no-register) rather than silently misclassifying the checked-out branch.
Worth a look
- Edge label/type dropped when loading AGE graph into NetworkX —
graphify/age_backend.py:145· Escalate · high- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Reaper can drop a branch after a concurrent push refreshes it —
graphify/age_branches.py· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Concurrent schema migration can race and fail on migration-version insert —
graphify/age_registry.py· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- push_branch now requires graphify_version and schema_version keywords —
graphify/age_branches.py· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Advisory-lock connection is not closed if unlock fails —
graphify/age_branches.py· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 3051 functions depend on the 2409 functions this change touches.
Health — this change adds coupling hotspots:
- new:
_rebuild_code()— 144 callers, 55 callees - new:
to_obsidian()— 41 callers, 14 callees - new:
to_json()— 58 callers, 7 callees - new:
push_to_age()— 22 callers, 14 callees - new:
main()— 100 callers, 3 callees - new:
dispatch_command()— 2 callers, 145 callees - new:
_query_graph_text()— 27 callers, 10 callees - new:
generate()— 35 callers, 7 callees - …and 56 more — each is listed as a finding
Verification — 3051 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 2996 function(s) in the blast radius were not formally verified this run
Test selection
Test selection
314 of 314 test file(s) selected (100%) via static blast radius.
Escalated to a full run for safety — the selection is not trustworthy on its own (see below). CI should run the whole suite.
tests/test_affected_cli.py— impact, full-run-safetytests/test_affected_member_seed.py— full-run-safetytests/test_age_agent_queries_integration.py— impact, changed-test, full-run-safetytests/test_age_backend_integration.py— impact, changed-test, full-run-safetytests/test_age_backend_unit.py— impact, changed-test, full-run-safetytests/test_age_branches_integration.py— impact, changed-test, full-run-safetytests/test_age_branches_unit.py— impact, changed-test, full-run-safetytests/test_age_cli_multirepo_integration.py— impact, changed-test, full-run-safetytests/test_age_exporter_unit.py— impact, changed-test, full-run-safetytests/test_age_integration.py— impact, changed-test, full-run-safetytests/test_age_registry_integration.py— impact, changed-test, full-run-safetytests/test_age_registry_unit.py— impact, changed-test, full-run-safetytests/test_age_snapshots_integration.py— impact, changed-test, full-run-safetytests/test_age_snapshots_unit.py— impact, changed-test, full-run-safetytests/test_agents_platform.py— impact, full-run-safetytests/test_analyze.py— impact, changed-test, full-run-safetytests/test_anthropic_custom_endpoint.py— full-run-safetytests/test_antigravity_install.py— full-run-safetytests/test_apm_fallback_version.py— full-run-safetytests/test_architecture_doc.py— full-run-safetytests/test_astro_extraction.py— full-run-safetytests/test_astro_import_ids.py— full-run-safetytests/test_atomic_canvas_export.py— impact, full-run-safetytests/test_atomic_version_stamp.py— full-run-safetytests/test_atomic_writes.py— impact, full-run-safetytests/test_backend_env_isolation.py— full-run-safetytests/test_backend_extras.py— full-run-safetytests/test_benchmark.py— impact, full-run-safetytests/test_benchmark_raw_graph.py— impact, full-run-safetytests/test_build.py— impact, full-run-safetytests/test_build_merge_dedup_scope.py— full-run-safetytests/test_build_merge_hyperedges_and_prune.py— full-run-safetytests/test_build_merge_shrink_guard.py— full-run-safetytests/test_builtin_global_type_refs.py— full-run-safetytests/test_cache.py— full-run-safetytests/test_callflow_html.py— full-run-safetytests/test_cargo_introspect.py— full-run-safetytests/test_cargo_missing_manifest.py— full-run-safetytests/test_carried_hyperedge_remap.py— impact, full-run-safetytests/test_case_sensitive_resolution.py— full-run-safetytests/test_charmap_encoding.py— full-run-safetytests/test_chunking.py— full-run-safetytests/test_cjs_module_extension.py— full-run-safetytests/test_claude_cli_backend.py— full-run-safetytests/test_claude_md.py— full-run-safetytests/test_cli_broken_pipe.py— full-run-safetytests/test_cli_export.py— impact, changed-test, full-run-safetytests/test_cli_help.py— full-run-safetytests/test_cluster.py— full-run-safetytests/test_cobol_extractor.py— full-run-safety- … and 264 more
non-code file(s) changed (
ARCHITECTURE.md,README.md,docs/AGE_PLAN.md,docs/AGE_SCHEMA.md,graphify/skill-agents.md…) → running the full suite for safety (a code graph can't see config/fixture/data deps)
changed code file(s) with no mapped test (
ARCHITECTURE.md,README.md,docs/AGE_PLAN.md,docs/AGE_SCHEMA.md,graphify/skill-agents.md…) — a coverage gap or a missing link — running the full suite rather than only the selected tests
Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.
Risky patterns (advisory)
- medium
no-unbounded-fetchingraphify/age_backend.py:120: unbounded read (no limit/bound): for (raw,) in cur.fetchall(): - medium
no-unbounded-fetchingraphify/age_backend.py:136: unbounded read (no limit/bound): for src_raw, r_raw, tgt_raw in cur.fetchall(): - medium
no-unbounded-fetchingraphify/age_branches.py:275: unbounded read (no limit/bound): rows = cur.fetchall() - medium
no-unbounded-fetchingraphify/age_branches.py:337: unbounded read (no limit/bound): for seq, gen, frm, to, kind, p in cur.fetchall() - medium
no-unbounded-fetchingraphify/age_registry.py:336: unbounded read (no limit/bound): applied = {row[0] for row in cur.fetchall()} - medium
no-unbounded-fetchingraphify/age_snapshots.py:225: unbounded read (no limit/bound): rows = cur.fetchall() - medium
no-unbounded-fetchingraphify/age_snapshots.py:267: unbounded read (no limit/bound): rows = cur.fetchall() - medium
no-unbounded-fetchingraphify/age_snapshots.py:394: unbounded read (no limit/bound): rows = cur.fetchall() - medium
no-unbounded-fetchingraphify/exporters/graphdb.py:621: unbounded read (no limit/bound): existing = cur.fetchall() - medium
no-unbounded-fetchingraphify/exporters/graphdb.py:689: unbounded read (no limit/bound): existing_edges = cur.fetchall()
Docs that may be stale (advisory)
CHANGELOG.md§ 0.9.58 (2026-09-10) (lines 128-145): references changed symbols__init__CHANGELOG.md§ 0.9.56 (2026-09-07) (lines 154-166): references changed symbolsexecuteCHANGELOG.md§ 0.9.42 (2026-08-13) (lines 347-365): references changed symbols__init__CHANGELOG.md§ 0.9.23 (2026-07-21) (lines 560-569): references changed symbols__init__CHANGELOG.md§ 0.9.22 (2026-07-20) (lines 570-579): references changed symbols__init__CHANGELOG.md§ 0.9.14 (2026-07-12) (lines 695-714): references changed symbolscommitCHANGELOG.md§ 0.8.36 (2026-06-08) (lines 1106-1117): references changed symbolsformatCHANGELOG.md§ 0.8.31 (2026-06-03) (lines 1157-1163): references changed symbolsformatCHANGELOG.md§ 0.8.28 (2026-06-01) (lines 1179-1187): references changed symbolsexecuteCHANGELOG.md§ 0.8.10 (2026-05-17) (lines 1335-1344): references changed symbolsformat
…and 10 more.
· 20 grounded finding(s) anchored inline below; 41 more finding(s) on lines outside this diff (see the check run); 3 additional anchorable finding(s) not shown (cap).
| return json.loads(raw) if isinstance(raw, str) else raw | ||
|
|
||
|
|
||
| def fetch_graph_from_age(conninfo: str, graph_name: str) -> nx.MultiGraph: |
There was a problem hiding this comment.
fetch_graph_from_age()
high coupling complexity (Ca·Ce = 12).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| return not is_ancestor(cwd, old_head, new_head) | ||
|
|
||
|
|
||
| def _extract_graph_at_commit(cwd: Path | str | None, commit_sha: str) -> nx.Graph: |
There was a problem hiding this comment.
_extract_graph_at_commit()
high coupling complexity (Ca·Ce = 16).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| conn.close() | ||
|
|
||
|
|
||
| def push_branch( |
There was a problem hiding this comment.
push_branch()
14 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| lock_conn.close() | ||
|
|
||
|
|
||
| def _push_branch_locked( |
There was a problem hiding this comment.
_push_branch_locked()
fans out to 9 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| } | ||
|
|
||
|
|
||
| def materialize_branch(conninfo: str, repository_id: str, branch: str, *, default_graph_name: str) -> dict: |
There was a problem hiding this comment.
materialize_branch()
7 callers depend on it (afferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| assert cur.fetchone()[0] == 0 | ||
|
|
||
|
|
||
| def test_materialize_branch_creates_age_graph_with_correct_content(conn, repository_id, tmp_path): |
There was a problem hiding this comment.
test_materialize_branch_creates_age_graph_with_correct_content()
fans out to 9 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| assert stored_name == graph_name | ||
|
|
||
|
|
||
| def test_materialize_branch_is_idempotent(conn, repository_id, tmp_path): |
There was a problem hiding this comment.
test_materialize_branch_is_idempotent()
fans out to 9 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| assert r1["age_graph_name"] == r2["age_graph_name"] | ||
|
|
||
|
|
||
| def test_push_branch_applies_incremental_diff_to_materialized_graph(conn, repository_id, tmp_path): |
There was a problem hiding this comment.
test_push_branch_applies_incremental_diff_to_materialized_graph()
fans out to 9 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| assert ids == ["a", "c"] | ||
|
|
||
|
|
||
| def test_reap_branch_drops_graph_after_idle_threshold(conn, repository_id, tmp_path): |
There was a problem hiding this comment.
test_reap_branch_drops_graph_after_idle_threshold()
fans out to 10 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
| assert remat["already_materialized"] is False | ||
|
|
||
|
|
||
| def test_cross_branch_comparison_query(conn, repository_id, tmp_path): |
There was a problem hiding this comment.
test_cross_branch_comparison_query()
fans out to 10 callees (efferent coupling).
Grounded coupling-delta finding (deterministic), not an LLM guess.
What does this PR do?
Implements #3873 — an Apache AGE (PostgreSQL extension) backend for graphify, giving the project a Postgres-native path alongside Neo4j and FalkorDB, plus the machinery none of the existing pushers have: incremental sync, snapshots, and branch-aware graphs.
Phased commits (each milestone reviewable on its own):
push_to_age()ingraphify/exporters/graphdb.py— deletion-safe reconcile in the push transaction, batched PREPARE/EXECUTE + UNWIND, auto-created property indexes (GIN + per-prop btree expression), live-spike findings baked in (cypher()graph name must be a literal;SET n +=rejects parameterized maps). Wired asgraphify export age --push postgresql://...behind a newageextra (psycopg[binary]).graphify/age_registry.py— plain-SQL registry (repos/snapshots/branches/diff tables, versioned idempotent migrations), stablerepository_id= uuid5(normalized remote URL), per-repo default graph names so repos can't collide.graphify/age_snapshots.py+ a reversible extension ofanalyze.graph_diff()(addsproperties/changed_nodes/changed_edges; additive keys only). Push applies O(changed) once a snapshot exists.graphify/age_branches.py— feature branches push as validated diff chains over the base snapshot;graphify age materialize|reap; rebase/force-push detection fails closed into a fresh revision generation.graphify/age_backend.py(fetch_graph_from_age→ NetworkX → identical scoring/formatting as the file backend) behind--age postgresql://... [--graph-name NAME]onquery/path/explain;docs/AGE_SCHEMA.mdpins the Cypher contract with live-tested fixtures.Docs:
docs/AGE_PLAN.md(full design + spike findings),docs/AGE_SCHEMA.md(agent query contract). Generated skills regenerated viatools.skillgen --bless(export agestep +--ageusage lines).Type of change
Verification & Invariants
Invariants this code protects and how:
Deletion-safe reconcile: stale nodes/edges deleted in the same transaction as upserts — readers never see a partial graph. Covered by live integration tests (
test_age_integration.py) that push, mutate, re-push, and assert row counts.Provenance/direction: undirected
nx.Graphedges recover extracted direction via_src/_tgt(same idiom asbuild.py/serve.py); a relabeled node is deleted under its old label.Fail-closed: ambiguous first-push default-branch resolution is a hard error, not a guess;
schema_version/extraction_config_hashmismatch refuses to extend a diff chain; non-contiguous diff chains raise.Untrusted input: graph name →
sql.Literal; table/label identifiers →sql.Identifier; Cypher labels/relations whitelisted to[A-Za-z0-9_]/[A-Z0-9_]; full-props attribute keys must match[A-Za-z_][A-Za-z0-9_]*before entering the Cypher SET clause; payloads viasql.Literal(json.dumps(...)); noshell=Trueanywhere (git calls are arg-list subprocesses with timeouts).Determinism:
serve.pytraversal issorted()so answers don't depend on backend row-scan order.Credentials:
AGE_PASSWORD/PGPASSWORDpreferred over--password/DSN-embedded secrets, matching the neo4j/falkordb convention.Read the CONTRIBUTING.md guide.
Reproduced the issue and identified the invariant.
Made the smallest fix necessary. (Feature-sized: one concern — AGE backend support — carried as phased commits per the one-branch-one-PR-per-feature convention.)
Added a regression test (if bug fix) or isolated boundary test.
Kept the PR description synchronized with the final implementation.
Documented any limitations / unsupported cases explicitly.
How was this tested?
Test layout mirrors the FalkorDB suite: tier-1 unit tests are pure/mocked and run everywhere; tier-2/3 integration tests auto-skip without a reachable AGE (service-identifying
LOAD 'age'probe,AGE_HOST/AGE_PORT/AGE_DB/AGE_USER/AGE_PASSWORDoverrides).Limitations / unsupported cases
fetch_graph_from_ageloads the whole named graph into NetworkX — same cost profile as loading graph.json, documented choice (server-side filtering would mean reimplementing scoring in Cypher).serve.py/MCP is not AGE-wired (agents query AGE directly via the pinned contract) — deferred, see plan doc Phase 6.--full-propspushes only scalar attributes whose keys are valid Cypher identifiers; others are dropped (no rewrite, to avoid silent collisions).ageextra overlapspostgresextra onpsycopg[binary]— kept lean (no tree-sitter-sql) deliberately; can fold intopostgresif preferred.git worktreefallback for missing base snapshots requires a non-bare repo checkout.Graphify-specific checklist
uv run python -m tools.skillgen --bless) when changing their source fragments.AI-assisted implementation — commits carry
Co-Authored-Bytrailers per the authorship convention.