Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 26 additions & 16 deletions internal/skillinstall/embedded.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,40 @@ import (
"io/fs"
)

// onboardingTreeFiles holds the binary-embedded source of the
// `praxis-onboarding` skill — a multi-file (tree) skill: a SKILL.md engine
// plus per-flow files under flows/. Unlike the single-file meta-skills in
// dummy.go (string bodies) and unlike org catalog skills (fetched from the
// server), this content ships inside the binary as a real file tree so it
// can carry multiple files.
// treeSkillFiles holds the binary-embedded source of every multi-file (tree)
// skill — each a SKILL.md engine plus optional subdirectories (e.g.
// praxis-onboarding's flows/). Unlike the single-file meta-skills in dummy.go
// (string bodies) and unlike org catalog skills (fetched from the server),
// this content ships inside the binary as a real file tree so it can carry
// multiple files.
//
//go:embed embedded/praxis-onboarding
var onboardingTreeFiles embed.FS
// - praxis-onboarding: the guided getting-started journey.
// - use-ig: the Praxis-MCP read variant of ig's use-ig skill; reads run
// server-side via `praxis mcp ig`, so the host needs no local `ig`.
//
//go:embed embedded/praxis-onboarding embedded/use-ig
var treeSkillFiles embed.FS

// treeSkillNames is the set of binary-embedded tree skills, in the order they
// are declared in the embed directive above.
var treeSkillNames = []string{"praxis-onboarding", "use-ig"}

// treeSkills maps a binary-embedded multi-file skill name to its rooted file
// tree (SKILL.md at the root, plus subdirectories like flows/). Tree skills
// install via InstallTree instead of the single-file ContentFor path, and
// they are meta-skills (preserved on profile switch — see IsMetaSkill).
func treeSkills() map[string]fs.FS {
// The embed path is a compile-time constant, so fs.Sub cannot fail in a
// correctly-built binary.
sub, err := fs.Sub(onboardingTreeFiles, "embedded/praxis-onboarding")
if err != nil {
panic("skillinstall: embedded praxis-onboarding tree missing: " + err.Error())
}
return map[string]fs.FS{
"praxis-onboarding": sub,
out := make(map[string]fs.FS, len(treeSkillNames))
for _, name := range treeSkillNames {
// The embed paths are compile-time constants, so fs.Sub cannot fail
// in a correctly-built binary.
sub, err := fs.Sub(treeSkillFiles, "embedded/"+name)
if err != nil {
panic("skillinstall: embedded tree skill missing: " + name + ": " + err.Error())
}
out[name] = sub
}
return out
}

// isTreeSkill reports whether `name` is a binary-embedded multi-file skill.
Expand Down
214 changes: 214 additions & 0 deletions internal/skillinstall/embedded/use-ig/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
---
name: use-ig
description: Use when working in a project that is onboarded to `ig` (infragraphify) and you need to answer how repos/services connect, where the code for a concept or endpoint lives, how a frontend call reaches its backend handler, what infrastructure backs a service, or the blast radius of a change — instead of grepping a monorepo. Triggers on cross-service/cross-repo tracing, FE↔BE API coupling, code↔infra mapping, impact/what-breaks questions, "how does X flow through the system".
---

# Using ig to answer questions about a project

**Praxis-MCP read route — queries run server-side via `praxis mcp ig`; no local
`ig` needed.** (If praxis is unavailable, ig ships a native local-`ig` copy of
this skill instead. Only one is ever installed at a time.)

`ig` holds a prebuilt **catalog** for a project — a set of code and infra graphs
joined at their shared boundaries. When a question spans repos or services, or
asks "what connects to X / what breaks if I change X", query the catalog before
grepping source. Every read runs on the Praxis server under org-managed
credentials; your laptop needs no `ig` binary, no synced tree, no cloud secrets.

**The division of labor:** ig states facts (graphs, provenance, source
file+line). YOU decide, act, and ask the user. The MCP tools are read-only —
they never build, never prompt, never touch your working copy.

**Boundary — the six `praxis mcp ig` tools are the whole read interface.** Do
not reach for a local `ig`/`graphify` binary and do not try to `praxis ig sync`;
in this route reads execute server-side. (Building/refreshing a catalog is a
separate CI/setup concern that still uses `ig` on a builder host — not your job
here.)

## First: list the catalogs

There is **NO default catalog** and every read tool needs BOTH a `catalog` and a
`member`. Start here:

```
praxis mcp ig ig_list_catalogs
```

It returns the org's catalogs as `name + version`, each with its **members**.
Pick the `<catalog>` your question is about and pass it as `--arg catalog=<name>`
to every other tool. A repo can be a member of MORE THAN ONE catalog (e.g.
`control-plane` in both `capillary-cloud` and `saas-cp`); because `calls` edges
only appear between members present in the SAME catalog, "who calls
control-plane" is catalog-relative — ask each catalog.

## The `member` arg — pick your lens (REQUIRED)

A catalog is a **graph of graphs**, so a node is addressed *per member*. Every
read tool takes `--arg member=<m>` and it selects which graph you read:

| `member=` | You reach | Use for |
|---|---|---|
| **`catalog`** | the connective tissue: `service:*` / `route:*` / `graph:*` interface nodes + the cross-repo `calls` / `deploys_as` / `provisions` edges | "how do repos/services connect", "what calls X", frontend↔backend, code↔infra |
| **`<repo>`** (e.g. `control-plane`) | that repo's code graph (functions, files, symbols) | "where does the code for X live", per-repo blast radius |
| **`infra`** | the Facets infra graph (module types, datastores) | "what infra module backs X" |

The literal member name `catalog` is the graph-of-graphs itself — that is where
the interface/connection layer lives. `service:*`/`route:*` nodes are NOT inside
a repo member (`ig_explain --arg member=control-plane --arg target="service:x"`
returns "no node"); read them with `--arg member=catalog`.

## The catalog model — read this before running commands

```
CATALOG (one project)
graph:control-plane-ui-react ──calls(http, 469)──▶ graph:control-plane ← indicative connection
│ deploys_as │ deploys_as
▼ ▼
service:control-plane-react service:control-plane-service
▲ provisions ▲ provisions
└──────────────── graph:infra ─────────────────────┘

MEMBERS (each a full graph)
┌ control-plane-ui-react (code) ┐ ┌ control-plane (code) ┐ ┌ infra ┐
│ .approveRelease() ─calls─▶ │ │ route:POST …/approve │ │ mongo │
│ route:POST …/approveRelease ──┼───┼── handled_by ─▶ .approveRelease() │ redis │
└───────────────────────────────┘ └──────────────────────┘ └───────┘
▲ the SAME route node is in both members: the off-page
connector that joins frontend call site → backend handler
```

- **member** — one repo's graph (`kind=code`) or the Facets infra graph (`kind=infra`).
- **graph node** (`graph:<member>`) — a whole member inside the catalog.
- **interface node** — a shared boundary: `service:*` (a Facets service), `route:*`
(an HTTP endpoint), `queue:*` (a queue/topic), `pkg:*` (a package).
- **connection** — a typed cross-member edge: `deploys_as` (code → its service),
`provisions` (infra → a service), `provides`/`consumes` (a member ↔ an interface).
- **calls edge** — `graph:A ──calls(http, N)──▶ graph:B`: indicative "A calls B
over HTTP N times", rolled up from shared `route:` nodes. The concrete link is
the route node itself, present in both members (frontend `calls` it, backend is
`handled_by` it).

Two caveats seen in practice: a `service:*` id for the same physical service can
be spelled differently across catalogs (`service:control-plane-service` in one vs
`service:control-plane` in another — they mirror each project's infra naming), so
rediscover it per catalog rather than reusing an id; and a wrong `target` yields
a plain "no node" with no fuzzy suggestion.

## Command surface (the six `praxis mcp ig` tools)

Every read tool takes `--arg catalog=<c>` (from `ig_list_catalogs`) and, except
`ig_list_catalogs`/`ig_catalog`, `--arg member=<m>` (the lens — see above).
Output is **token-budgeted TEXT** written for an LLM to read.

```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Specify the fenced block language.

Markdownlint reports MD040 here. Since the block contains shell commands, use ```sh instead of an untyped fence.

Proposed fix
-```
+```sh
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 103-103: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🪛 SkillSpector (2.3.11)

[warning] 66: [MP2] Context Window Stuffing: Skill attempts to fill the context window with filler content, displacing legitimate instructions and safety constraints. This can degrade agent performance or bypass safety boundaries.

Remediation: Implement context-window management that detects and rejects padding or stuffing attempts. Prioritize system instructions over user-injected content.

(Memory Poisoning (MP2))

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/skillinstall/embedded/use-ig/SKILL.md` at line 103, Update the
fenced code block in SKILL.md to specify the shell language by changing its
opening fence to ```sh, while preserving the block’s existing shell commands and
closing fence.

Source: Linters/SAST tools

praxis mcp ig ig_list_catalogs → the org's catalogs (name + version + members); START HERE
praxis mcp ig ig_catalog --arg catalog=<c> → topology MAP: members + service:* interfaces + deploys_as/provisions/calls edges
praxis mcp ig ig_explain --arg catalog=<c> --arg member=<m> --arg target=<node> → node card (source file+line, edges) — the workhorse
praxis mcp ig ig_impact --arg catalog=<c> --arg member=<m> --arg target=<node> [--arg depth=N] [--arg relation=R] [--arg context=C] → what a change to <node> reaches downstream
praxis mcp ig ig_query --arg catalog=<c> --arg member=<m> --arg query="<free text>" [--arg relation=R] [--arg context=C] → BFS from name-matched start nodes (NOT semantic search)
praxis mcp ig ig_path --arg catalog=<c> --arg member=<m> --arg from_node=<a> --arg to_node=<b> → shortest path between two nodes, hop by hop
```

- **`ig_query` takes FREE TEXT.** `--arg query="where does approveRelease reach
the DB"` or a symbol name — ig seeds the BFS from the name-matched start nodes.
It has NO `depth` (ig hardcodes query depth); scope it instead with `--arg
relation=calls` / `--arg context=call` (repeat by passing the arg again) or cap
output with `--arg budget=N` (tokens). `ig_impact` takes the same filters plus
a real `--arg depth=N`.
- `ig_catalog` is the map — START HERE for "how do these repos/services connect".
It needs no `member` (it IS the cross-member view).
- `ig_path` answers "how does A reach B" with the concrete hop-by-hop edge chain.
- `ig_explain` is the workhorse: it lands on a node and prints its source
file+line, degree, and edges. Reach for it to locate a symbol (in a `<repo>`
member) or an interface node (in the `catalog` member).
- `ig_impact` traverses code edges downstream from `target` — "what does a change
to this node reach". `depth` bounds the walk.
- `ig_query` seeds from name-matched start nodes and walks — see the gotcha below.

## Recipes

**How do the repos/services connect?** `ig_catalog --arg catalog=<c>` — the
one-shot topology map (members + `service:*` interfaces + `deploys_as` /
`provisions` / `calls` edges). Start here, then drill into a specific interface
with `ig_explain --arg member=catalog --arg target="service:<name>"`.

**Trace a frontend call to its backend handler.** From the `ig_catalog` map (or
`ig_explain --arg member=catalog --arg target="service:<name>"` / the `route:`
node) find the cross-repo `calls` edge, then drop into the backend `<repo>`
member — `ig_explain --arg member=<be-repo> --arg target="<handler-symbol>"` — to
land on the handler. The interface node in the `catalog` member is the join.

**How does A reach B?** `ig_path --arg member=<repo> --arg from_node=<a> --arg
to_node=<b>` — the concrete hop-by-hop edge chain between two symbols (or "no
path"). Faster than eyeballing `ig_impact` output when you have both endpoints.

**What breaks if I change a symbol?** `ig_impact --arg member=<repo> --arg
target="<symbol>"` for internal downstream (scope with `--arg relation=calls` or
`--arg depth=N`). For an HTTP endpoint, also `ig_explain --arg member=catalog
--arg target="route:<METHOD /path>"`: its cross-member `calls` edge is the real
coupling (a route/contract change forces the OpenAPI client + its callers to
change), which `ig_impact` alone won't show.

**What infra backs a service?** Read the `provisions` connections on the
`service:*` node in the `catalog` member (`ig_explain --arg member=catalog --arg
target="service:<name>"`), then enumerate the `infra` member with `ig_query --arg
member=infra --arg query="<resource>"`. Infra nodes are Facets module types
(`service/*`, `s3/*`, `mongo`, `artifact/*`) and are often coarse/degree-0 —
expect module-type + datastore names, not a fully wired topology.

**Where does the code for a concept live?** `ig_query --arg member=<repo> --arg
query="<free-text question>"` to BFS from name-matched nodes, or `ig_explain
--arg member=<repo> --arg target="<name-or-substring>"` to land on one node and
follow its edges (more precise when you know the symbol). The node card's source
file+line is relative to that member's repo root.

## Gotchas (these are where agents lose time)

- **`ig_query` is BFS traversal, not semantic search.** It picks a small set of
best-name-match start nodes (not an OR over every word in the term string), then
walks edges. A vague term (`"release"`) starts inside an unrelated node and
wanders. Seed with ONE specific symbol, or skip it and `ig_explain` the node
directly.
- **Wrong `member` = "no node", even for the right target.** Interface nodes
(`service:*`, `route:*`, `graph:*`) live ONLY in the `catalog` member; code
symbols live only in their `<repo>` member. Asking for a `service:*` in a repo
member, or a code symbol in `catalog`, returns "no node". If a target you're
sure exists isn't found, you're probably in the wrong lens — switch `member`
(`catalog` for connections, `<repo>` for code, `infra` for modules).
- **A wrong `target` returns "no node" with NO fuzzy suggestion.** Don't guess ids
blindly — start from `ig_list_catalogs`, then `ig_explain` a label/substring you
are confident about, and copy the exact `ID:` it prints for follow-up calls.
- **Reference a node by label/substring first; use the full ID to disambiguate.**
A unique label works (`"approveRelease"`, or a route label `"POST
…/approveRelease"`). When several nodes share a label, `ig_explain` may
auto-pick one; copy the full `ID:` line from its output and pass THAT to
`ig_impact` to be unambiguous.
- **`ig_impact` on an HTTP entry point returns "No affected nodes" — that IS the
answer:** the endpoint has no internal callers because it's invoked over HTTP.
`ig_impact` traverses code edges; it is not an HTTP blast-radius oracle. Use the
`route:` node + `calls` edge for cross-member coupling.
- **Node ids differ by member kind** — code = a `path_symbol` slug
(`v2_src_..._approverelease`), infra = module-type paths (`s3/default/0.2`,
`service/deployment`, `mongo`, `artifact/*`). Both are accepted as `target`.

## Worked example (real: capillary-cloud)

```
praxis mcp ig ig_list_catalogs
→ capillary-cloud 2026.07.06-071024 members: control-plane, control-plane-ui-react, infra
saas-cp 2026.07.05-… (pick capillary-cloud)
praxis mcp ig ig_explain --arg catalog=capillary-cloud --arg member=catalog \
--arg target="service:control-plane-service"
→ Type: service <-- infra [provisions] --> control-plane [deploys_as] (the join)
praxis mcp ig ig_explain --arg catalog=capillary-cloud --arg member=control-plane \
--arg target="approveRelease"
→ .approveRelease() UiDeploymentController.java:L449 Degree 6 (backend handler)
praxis mcp ig ig_impact --arg catalog=capillary-cloud --arg member=control-plane \
--arg target="approveRelease"
→ the controller is an HTTP entry point, so internal downstream is minimal — the
real blast radius is the shared route: node + the calls edge (a contract change
forces the OpenAPI client + its callers to change).
```

The shared `route:` node answers "which frontend calls which backend"; the
`service:*`/`provisions` connections answer "what infra backs it".
62 changes: 62 additions & 0 deletions internal/skillinstall/embedded_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package skillinstall

import (
"io/fs"
"os"
"path/filepath"
"strings"
Expand All @@ -10,6 +11,67 @@ import (
// onboarding is the one embedded multi-file (tree) meta-skill today.
const onboardingSkill = "praxis-onboarding"

// use-ig is the Praxis-MCP read variant tree skill: it carries ig's graph
// query mental-model but routes every read through `praxis mcp ig`, so the
// host needs no local `ig`. Same bare name as ig's native skill (only one is
// ever present — ig ships its native copy only when praxis is absent).
const useIGSkill = "use-ig"

func TestMetaSkillNames_IncludesUseIG(t *testing.T) {
names := MetaSkillNames()
var found bool
for _, n := range names {
if n == useIGSkill {
found = true
}
}
if !found {
t.Errorf("MetaSkillNames() = %v, want it to include %q", names, useIGSkill)
}
// Still sorted (login relies on deterministic order).
for i := 1; i < len(names); i++ {
if names[i-1] > names[i] {
t.Errorf("MetaSkillNames() not sorted: %v", names)
break
}
}
}

func TestIsMetaSkill_UseIGPreserved(t *testing.T) {
if !IsMetaSkill(useIGSkill) {
t.Errorf("IsMetaSkill(%q) = false, want true (tree meta-skills must survive profile switch)", useIGSkill)
}
}

// TestUseIGTreeSkill_IsMCPVariant guards that the embedded use-ig skill is the
// Praxis-MCP read variant: it queries the graph server-side via `praxis mcp
// ig` and does NOT carry the native local-`ig` read command surface.
func TestUseIGTreeSkill_IsMCPVariant(t *testing.T) {
fsys, ok := treeSkillFS(useIGSkill)
if !ok {
t.Fatalf("treeSkillFS(%q) not found; use-ig must be an embedded tree skill", useIGSkill)
}
raw, err := fs.ReadFile(fsys, "SKILL.md")
if err != nil {
t.Fatalf("read use-ig SKILL.md: %v", err)
}
body := string(raw)

if !strings.Contains(body, "name: use-ig") {
t.Errorf("use-ig SKILL.md missing frontmatter `name: use-ig`")
}
// Reads must route through the Praxis MCP.
if !strings.Contains(body, "praxis mcp ig") {
t.Errorf("use-ig SKILL.md must invoke reads via `praxis mcp ig` (MCP variant)")
}
// It must NOT carry the native local-`ig` read command surface. The
// backticked `ig query` is the tell of the local-ig variant; this MCP
// copy uses `praxis mcp ig ig_query` instead.
if strings.Contains(body, "`ig query`") {
t.Errorf("use-ig SKILL.md contains local-ig read command `ig query`; this must be the `praxis mcp ig` variant")
Comment on lines +63 to +71

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert every required MCP tool explicitly.

Checking only for the generic praxis mcp ig prefix would still pass if one of the required tools were removed from the skill. Assert all four commands: ig_list_catalogs, ig_explain, ig_impact, and ig_query.

As per coding guidelines, use a table-driven test for the required tool list.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/skillinstall/embedded_test.go` around lines 63 - 71, Update the
embedded skill validation test around the existing praxis mcp ig assertions to
use a table-driven list containing ig_list_catalogs, ig_explain, ig_impact, and
ig_query, and explicitly assert each command appears in body. Retain the
existing check preventing the native `ig query` command surface.

Source: Coding guidelines

}
}

func TestMetaSkillNames_IncludesTreeSkill(t *testing.T) {
names := MetaSkillNames()
var found bool
Expand Down