diff --git a/docs/ai-coding-agents/claude-code-plugin.md b/docs/ai-coding-agents/claude-code-plugin.md
index ccfa3e9..7dc982e 100644
--- a/docs/ai-coding-agents/claude-code-plugin.md
+++ b/docs/ai-coding-agents/claude-code-plugin.md
@@ -10,7 +10,7 @@ The installer extracts a self-contained Claude marketplace below the platform
user-data directory, registers it with `claude plugin marketplace add`, and
installs `tapper@tapper-local`. The plugin registers `tap mcp`, blocks direct
agent use of the Tapper CLI except harmless help/version/completion probes, and
-orients through the active flight and covered KEG instructions. Its guard runs
+orients through the active flight, compact KEG discovery, and targeted settings instructions. Its guard runs
as `tap hook pre-tool-use`, so the current `tap` binary must remain on `PATH`.
Install the optional developer workflow separately:
diff --git a/docs/ai-coding-agents/mcp-setup.md b/docs/ai-coding-agents/mcp-setup.md
index fd5ce5c..0c2fb9b 100644
--- a/docs/ai-coding-agents/mcp-setup.md
+++ b/docs/ai-coding-agents/mcp-setup.md
@@ -93,7 +93,7 @@ agent to inspect flights through MCP, ask the user to run `tap use --flight
| `backlinks` | Find nodes linking to a node |
| `links` | List outgoing links from a node |
| `info` | Show concise diagnostics for the resolved keg |
-| `keg_settings` | Read keg configuration |
+| `keg_settings` | Read targeted minimal config for one or more selected KEGs; `minimal=false` reads one complete config |
| `stats` | Show node statistics |
### Write
diff --git a/docs/ai-coding-agents/orient.md b/docs/ai-coding-agents/orient.md
index b2d91a5..66f7048 100644
--- a/docs/ai-coding-agents/orient.md
+++ b/docs/ai-coding-agents/orient.md
@@ -10,10 +10,10 @@ operating against tapper KEGs. The same bytes are reachable three ways: the
The payload always starts with KEG system context:
1. KEG purpose and core rules.
-2. Available KEGs, including role, source, and the active flight cap when a
- flight is selected.
+2. Available KEGs, including each canonical reference, title, concise summary,
+ role, source, and active flight cap when a flight is selected.
3. Flight title and instructions, when a flight is active.
-4. KEG-level instructions from each available KEG config.
+4. A prompt to request targeted KEG settings before operating.
5. Canonical Tapper agent guidance: linking, snapshots, tool inventory, and
troubleshooting.
@@ -34,20 +34,35 @@ refreshes the server-owned session orientation.
commands ignore flight cover caps and use normal authorization; MCP tools
enforce the most recently published orientation.
-## KEG Instructions
+## Progressive KEG Guidance
-KEG-specific guidance belongs on the KEG config itself:
+`summary` and `instructions` have distinct roles in a KEG config:
```yaml
kegv: 2025-07
title: Engineering
+summary: Architecture, delivery, and operational knowledge for engineering.
instructions: |
Prefer architecture notes before implementation notes.
Snapshot any node before changing public API guidance.
```
-When the KEG is available in the active orient context, those instructions
-render in the `## KEG Instructions` section before canonical Tapper guidance.
+`summary` is the concise discovery description shown by aggregate orientation.
+It should help an agent decide whether the KEG is relevant and is not
+automatically truncated. `instructions` is targeted operational guidance.
+Aggregate orientation never includes it, even under `full_access`.
+
+After selecting relevant KEGs, call `keg_settings` with either `keg` or
+`kegs`. Minimal mode is the default and returns title, summary, updated
+metadata, and instructions. Up to 100 canonical references may be expanded
+together:
+
+```json
+{"kegs":["@foldwise/dev","@foldwise/engineering"]}
+```
+
+`keg` and `kegs` are mutually exclusive. Multiple KEGs require minimal mode;
+`minimal=false` continues to return the complete config for exactly one KEG.
## MCP Tool
@@ -91,7 +106,8 @@ tap orient --flight @acme/+release-42
`--flight` is a root persistent CLI flag available on commands that accept
`--keg`. It is free-form and suppresses filesystem completion; it is not part
-of any MCP tool schema.
+of any ordinary MCP tool schema. MCP flight selection is fixed by the human
+session boundary and cannot be overridden by `orient` or `keg_settings`.
## Byte-Equivalence Guarantee
diff --git a/docs/configuration/keg-config.md b/docs/configuration/keg-config.md
index a095b22..65830d6 100644
--- a/docs/configuration/keg-config.md
+++ b/docs/configuration/keg-config.md
@@ -34,6 +34,12 @@ Common keg fields:
- `indexes`
- `schemaPolicy`
+Use `summary` for a concise discovery description: aggregate orientation uses
+it to help agents identify relevant KEGs and does not automatically truncate
+it. Use `instructions` for targeted operational guidance. Instructions are
+loaded only after an agent explicitly selects the KEG through `keg_settings`;
+they are not included in aggregate orientation.
+
`schemaPolicy` has three actor-specific modes: `human`, `agent`, and `api`.
Each accepts `off`, `warn`, or `block`. When omitted, human writes default to
`warn`, while agent and API writes default to `block`. Archive imports and
diff --git a/integrations/content/agent-orient.md b/integrations/content/agent-orient.md
index e7e8d1e..aeacb5f 100644
--- a/integrations/content/agent-orient.md
+++ b/integrations/content/agent-orient.md
@@ -1,9 +1,11 @@
# tapper
Interact with Tapper KEGs (Knowledge Exchange Graphs) through the native MCP
-server. At the start of KEG work, call `mcp__tapper__orient` and treat the
-returned active flight, cover, flight instructions, and covered-KEG
-instructions as the authoritative context for the session.
+server. At the start of KEG work, call `mcp__tapper__orient`, identify the
+relevant covered KEGs from their titles and summaries, then call
+`mcp__tapper__keg_settings` for those KEGs before operating. Treat the active
+flight, cover, flight instructions, and targeted KEG instructions as the
+authoritative context for the session.
## Rules
@@ -27,14 +29,16 @@ returned by orientation to work across KEGs without changing directories or
restarting the MCP server.
- `mcp__tapper__orient` — returns the active flight, its cover and instructions,
- covered KEG instructions, and canonical safety guidance.
+ compact KEG discovery metadata, and canonical safety guidance.
+- `mcp__tapper__keg_settings` — returns targeted title, summary, updated
+ metadata, and instructions for one or more selected KEGs.
- `mcp__tapper__info` — returns concise diagnostics for a covered KEG.
- `mcp__tapper__keg_list` — lists the KEGs exposed by configured hubs.
## Bootstrapping a session
-Call `mcp__tapper__orient` first. Follow the pinned flight and KEG instructions
-it returns. When no flight is selected, the local MCP server connects in a
+Call `mcp__tapper__orient` first, then load the selected KEG instructions with
+`mcp__tapper__keg_settings`. When no flight is selected, the local MCP server connects in a
recovery-only state: KEG tools are locked, while `mcp__tapper__list_flights`
and `mcp__tapper__flight_show` remain available for discovery. Ask the user to
select a flight in Tapper configuration, then call `mcp__tapper__orient` again.
diff --git a/integrations/content/tool-inventory.md b/integrations/content/tool-inventory.md
index 4832937..a1016de 100644
--- a/integrations/content/tool-inventory.md
+++ b/integrations/content/tool-inventory.md
@@ -12,6 +12,7 @@
| `mcp__tapper__backlinks` | Inbound links to a node. |
| `mcp__tapper__graph` | Graph traversal for multi-hop relationships. |
| `mcp__tapper__list_indexes`, `mcp__tapper__index_cat` | Read generated index files (tag index, changelog, and others). |
+| `mcp__tapper__keg_settings` | Read targeted title, summary, updated metadata, and instructions for one or more selected KEGs; batches accept up to 100 canonical references. |
Pass `id_only: true` to `grep` and `tags` when you only need IDs for follow-up
reads — it keeps token consumption bounded on large result sets.
diff --git a/integrations/rendered/claude/tapper-dev/.claude-plugin/plugin.json b/integrations/rendered/claude/tapper-dev/.claude-plugin/plugin.json
index 7161a51..b61ceed 100644
--- a/integrations/rendered/claude/tapper-dev/.claude-plugin/plugin.json
+++ b/integrations/rendered/claude/tapper-dev/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "tapper-dev",
"description": "Optional Plan to Code to Review to Commit workflow for Tapper-enabled development.",
- "version": "0.35.0",
+ "version": "0.0.0-dev",
"author": {
"name": "Jared Rickert"
},
diff --git a/integrations/rendered/claude/tapper-dev/skills/tapper-dev/SKILL.md b/integrations/rendered/claude/tapper-dev/skills/tapper-dev/SKILL.md
index e39f0c0..b2af464 100644
--- a/integrations/rendered/claude/tapper-dev/skills/tapper-dev/SKILL.md
+++ b/integrations/rendered/claude/tapper-dev/skills/tapper-dev/SKILL.md
@@ -10,7 +10,7 @@ The baseline `tapper` plugin is required: call `mcp__tapper__orient` before the
workflow and stop with an actionable prerequisite error if that tool is not
available.
-The active flight and covered KEG instructions remain authoritative. Use
+The active flight and explicitly loaded, targeted KEG instructions remain authoritative. Use
lifecycle types, fields, and state transitions only when the active flight,
KEG instructions, and current schemas support them. Never invent a schema or
embed a project-specific KEG target.
diff --git a/integrations/rendered/claude/tapper/.claude-plugin/plugin.json b/integrations/rendered/claude/tapper/.claude-plugin/plugin.json
index e58ac0d..4f7d0b3 100644
--- a/integrations/rendered/claude/tapper/.claude-plugin/plugin.json
+++ b/integrations/rendered/claude/tapper/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "tapper",
"description": "MCP-first Tapper KEG access, flight orientation, and safety guidance.",
- "version": "0.35.0",
+ "version": "0.0.0-dev",
"author": {
"name": "Jared Rickert"
},
diff --git a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md
index 9d19f55..e0d5829 100644
--- a/integrations/rendered/claude/tapper/skills/tapper/SKILL.md
+++ b/integrations/rendered/claude/tapper/skills/tapper/SKILL.md
@@ -6,9 +6,11 @@ description: Orient to Tapper flights and operate on KEGs through MCP-first safe
# tapper
Interact with Tapper KEGs (Knowledge Exchange Graphs) through the native MCP
-server. At the start of KEG work, call `mcp__tapper__orient` and treat the
-returned active flight, cover, flight instructions, and covered-KEG
-instructions as the authoritative context for the session.
+server. At the start of KEG work, call `mcp__tapper__orient`, identify the
+relevant covered KEGs from their titles and summaries, then call
+`mcp__tapper__keg_settings` for those KEGs before operating. Treat the active
+flight, cover, flight instructions, and targeted KEG instructions as the
+authoritative context for the session.
## Rules
@@ -32,14 +34,16 @@ returned by orientation to work across KEGs without changing directories or
restarting the MCP server.
- `mcp__tapper__orient` — returns the active flight, its cover and instructions,
- covered KEG instructions, and canonical safety guidance.
+ compact KEG discovery metadata, and canonical safety guidance.
+- `mcp__tapper__keg_settings` — returns targeted title, summary, updated
+ metadata, and instructions for one or more selected KEGs.
- `mcp__tapper__info` — returns concise diagnostics for a covered KEG.
- `mcp__tapper__keg_list` — lists the KEGs exposed by configured hubs.
## Bootstrapping a session
-Call `mcp__tapper__orient` first. Follow the pinned flight and KEG instructions
-it returns. When no flight is selected, the local MCP server connects in a
+Call `mcp__tapper__orient` first, then load the selected KEG instructions with
+`mcp__tapper__keg_settings`. When no flight is selected, the local MCP server connects in a
recovery-only state: KEG tools are locked, while `mcp__tapper__list_flights`
and `mcp__tapper__flight_show` remain available for discovery. Ask the user to
select a flight in Tapper configuration, then call `mcp__tapper__orient` again.
@@ -60,6 +64,7 @@ no KEGs.
| `mcp__tapper__backlinks` | Inbound links to a node. |
| `mcp__tapper__graph` | Graph traversal for multi-hop relationships. |
| `mcp__tapper__list_indexes`, `mcp__tapper__index_cat` | Read generated index files (tag index, changelog, and others). |
+| `mcp__tapper__keg_settings` | Read targeted title, summary, updated metadata, and instructions for one or more selected KEGs; batches accept up to 100 canonical references. |
Pass `id_only: true` to `grep` and `tags` when you only need IDs for follow-up
reads — it keeps token consumption bounded on large result sets.
diff --git a/integrations/rendered/codex/tapper-dev/.codex-plugin/plugin.json b/integrations/rendered/codex/tapper-dev/.codex-plugin/plugin.json
index 970476b..95e45f1 100644
--- a/integrations/rendered/codex/tapper-dev/.codex-plugin/plugin.json
+++ b/integrations/rendered/codex/tapper-dev/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "tapper-dev",
- "version": "0.35.0",
+ "version": "0.0.0-dev",
"description": "Optional Plan to Code to Review to Commit workflow for Tapper-enabled development.",
"author": {
"name": "Jared Rickert",
diff --git a/integrations/rendered/codex/tapper-dev/skills/tapper-dev/SKILL.md b/integrations/rendered/codex/tapper-dev/skills/tapper-dev/SKILL.md
index e39f0c0..b2af464 100644
--- a/integrations/rendered/codex/tapper-dev/skills/tapper-dev/SKILL.md
+++ b/integrations/rendered/codex/tapper-dev/skills/tapper-dev/SKILL.md
@@ -10,7 +10,7 @@ The baseline `tapper` plugin is required: call `mcp__tapper__orient` before the
workflow and stop with an actionable prerequisite error if that tool is not
available.
-The active flight and covered KEG instructions remain authoritative. Use
+The active flight and explicitly loaded, targeted KEG instructions remain authoritative. Use
lifecycle types, fields, and state transitions only when the active flight,
KEG instructions, and current schemas support them. Never invent a schema or
embed a project-specific KEG target.
diff --git a/integrations/rendered/codex/tapper/.codex-plugin/plugin.json b/integrations/rendered/codex/tapper/.codex-plugin/plugin.json
index 28e5fdf..8b13558 100644
--- a/integrations/rendered/codex/tapper/.codex-plugin/plugin.json
+++ b/integrations/rendered/codex/tapper/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "tapper",
- "version": "0.35.0",
+ "version": "0.0.0-dev",
"description": "MCP-first Tapper KEG access, flight orientation, and safety guidance.",
"author": {
"name": "Jared Rickert",
diff --git a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md
index 9d19f55..e0d5829 100644
--- a/integrations/rendered/codex/tapper/skills/tapper/SKILL.md
+++ b/integrations/rendered/codex/tapper/skills/tapper/SKILL.md
@@ -6,9 +6,11 @@ description: Orient to Tapper flights and operate on KEGs through MCP-first safe
# tapper
Interact with Tapper KEGs (Knowledge Exchange Graphs) through the native MCP
-server. At the start of KEG work, call `mcp__tapper__orient` and treat the
-returned active flight, cover, flight instructions, and covered-KEG
-instructions as the authoritative context for the session.
+server. At the start of KEG work, call `mcp__tapper__orient`, identify the
+relevant covered KEGs from their titles and summaries, then call
+`mcp__tapper__keg_settings` for those KEGs before operating. Treat the active
+flight, cover, flight instructions, and targeted KEG instructions as the
+authoritative context for the session.
## Rules
@@ -32,14 +34,16 @@ returned by orientation to work across KEGs without changing directories or
restarting the MCP server.
- `mcp__tapper__orient` — returns the active flight, its cover and instructions,
- covered KEG instructions, and canonical safety guidance.
+ compact KEG discovery metadata, and canonical safety guidance.
+- `mcp__tapper__keg_settings` — returns targeted title, summary, updated
+ metadata, and instructions for one or more selected KEGs.
- `mcp__tapper__info` — returns concise diagnostics for a covered KEG.
- `mcp__tapper__keg_list` — lists the KEGs exposed by configured hubs.
## Bootstrapping a session
-Call `mcp__tapper__orient` first. Follow the pinned flight and KEG instructions
-it returns. When no flight is selected, the local MCP server connects in a
+Call `mcp__tapper__orient` first, then load the selected KEG instructions with
+`mcp__tapper__keg_settings`. When no flight is selected, the local MCP server connects in a
recovery-only state: KEG tools are locked, while `mcp__tapper__list_flights`
and `mcp__tapper__flight_show` remain available for discovery. Ask the user to
select a flight in Tapper configuration, then call `mcp__tapper__orient` again.
@@ -60,6 +64,7 @@ no KEGs.
| `mcp__tapper__backlinks` | Inbound links to a node. |
| `mcp__tapper__graph` | Graph traversal for multi-hop relationships. |
| `mcp__tapper__list_indexes`, `mcp__tapper__index_cat` | Read generated index files (tag index, changelog, and others). |
+| `mcp__tapper__keg_settings` | Read targeted title, summary, updated metadata, and instructions for one or more selected KEGs; batches accept up to 100 canonical references. |
Pass `id_only: true` to `grep` and `tags` when you only need IDs for follow-up
reads — it keeps token consumption bounded on large result sets.
diff --git a/pkg/integrations/renderdata/developer/workflow.md b/pkg/integrations/renderdata/developer/workflow.md
index 7f99465..7ece88d 100644
--- a/pkg/integrations/renderdata/developer/workflow.md
+++ b/pkg/integrations/renderdata/developer/workflow.md
@@ -5,7 +5,7 @@ The baseline `tapper` plugin is required: call `mcp__tapper__orient` before the
workflow and stop with an actionable prerequisite error if that tool is not
available.
-The active flight and covered KEG instructions remain authoritative. Use
+The active flight and explicitly loaded, targeted KEG instructions remain authoritative. Use
lifecycle types, fields, and state transitions only when the active flight,
KEG instructions, and current schemas support them. Never invent a schema or
embed a project-specific KEG target.
diff --git a/pkg/mcp/tools_orient_test.go b/pkg/mcp/tools_orient_test.go
index cd9abcb..33f5ba1 100644
--- a/pkg/mcp/tools_orient_test.go
+++ b/pkg/mcp/tools_orient_test.go
@@ -36,7 +36,8 @@ func TestMCP_OrientTool_ReturnsSharedKegSystemPayload(t *testing.T) {
require.NotContains(t, text, "`tap ")
require.NotContains(t, text, "## Active KEG")
require.Contains(t, text, "## Available KEGs")
- require.Contains(t, text, "## KEG Instructions")
+ require.NotContains(t, text, "## KEG Instructions")
+ require.Contains(t, text, "Call `keg_settings`")
require.Contains(t, text, "## Guidance")
require.Contains(t, text, "# Linking conventions")
require.Contains(t, text, "# Snapshot policy")
diff --git a/pkg/mcp/tools_read.go b/pkg/mcp/tools_read.go
index 3c994e9..09e207c 100644
--- a/pkg/mcp/tools_read.go
+++ b/pkg/mcp/tools_read.go
@@ -2,6 +2,7 @@ package mcp
import (
"context"
+ "fmt"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
@@ -255,8 +256,9 @@ func registerLinks(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) {
// --- keg_settings ---
type kegSettingsInput struct {
- Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"`
- Minimal *bool `json:"minimal,omitempty" jsonschema:"return only core config fields (default true)"`
+ Keg string `json:"keg,omitempty" jsonschema:"keg alias (uses default if empty)"`
+ Kegs []string `json:"kegs,omitempty" jsonschema:"canonical keg references to read together (maximum 100; minimal mode only)"`
+ Minimal *bool `json:"minimal,omitempty" jsonschema:"return only core config fields (default true)"`
}
func registerKegSettings(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaults) {
@@ -272,10 +274,28 @@ func registerKegSettings(srv *sdkmcp.Server, tap *tapper.Tap, defaults KegDefaul
if in.Minimal != nil {
minimal = *in.Minimal
}
+ if in.Keg != "" && in.Kegs != nil {
+ return errorResult(fmt.Errorf("keg and kegs are mutually exclusive")), nil, nil
+ }
+ if in.Kegs != nil {
+ if len(in.Kegs) == 0 || len(in.Kegs) > 100 {
+ return errorResult(fmt.Errorf("kegs must contain 1 to 100 canonical references")), nil, nil
+ }
+ if !minimal && len(in.Kegs) != 1 {
+ return errorResult(fmt.Errorf("minimal=false requires exactly one keg")), nil, nil
+ }
+ }
+ target := in.Keg
+ if !minimal && len(in.Kegs) == 1 {
+ target = in.Kegs[0]
+ }
opts := tapper.KegSettingsOptions{
- KegTargetOptions: resolveKegTarget(ctx, in.Keg, defaults),
+ KegTargetOptions: resolveKegTarget(ctx, target, defaults),
Minimal: minimal,
}
+ if minimal {
+ opts.Kegs = in.Kegs
+ }
result, err := tap.KegSettings(ctx, opts)
if err != nil {
return errorResult(err), nil, nil
diff --git a/pkg/mcp/tools_settings_batch_test.go b/pkg/mcp/tools_settings_batch_test.go
new file mode 100644
index 0000000..8229e5e
--- /dev/null
+++ b/pkg/mcp/tools_settings_batch_test.go
@@ -0,0 +1,83 @@
+package mcp_test
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
+ "github.com/stretchr/testify/require"
+)
+
+func callKegSettings(t *testing.T, ctx context.Context, session *sdkmcp.ClientSession, args map[string]any) *sdkmcp.CallToolResult {
+ t.Helper()
+ res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{
+ Name: "keg_settings",
+ Arguments: args,
+ })
+ require.NoError(t, err)
+ return res
+}
+
+func TestMCP_KegSettingsBatchValidationAndMinimalOutput(t *testing.T) {
+ t.Parallel()
+ session, ctx := newTestSession(t)
+
+ ok := callKegSettings(t, ctx, session, map[string]any{
+ "kegs": []string{"@local/personal"},
+ })
+ require.False(t, ok.IsError, extractText(t, ok))
+ require.Contains(t, extractText(t, ok), "keg: '@local/personal'")
+ require.Contains(t, extractText(t, ok), "title: Personal KEG")
+
+ overLimit := make([]string, 101)
+ for i := range overLimit {
+ overLimit[i] = fmt.Sprintf("@local/keg%d", i)
+ }
+ cases := []map[string]any{
+ {"keg": "@local/personal", "kegs": []string{"@local/personal"}},
+ {"kegs": []string{}},
+ {"kegs": overLimit},
+ {"kegs": []string{"personal"}},
+ {"kegs": []string{"@local/personal", "@local/personal"}},
+ {"kegs": []string{"@local/personal", "@local/private"}, "minimal": false},
+ {"kegs": []string{"@local/private"}},
+ }
+ for _, args := range cases {
+ res := callKegSettings(t, ctx, session, args)
+ require.Truef(t, res.IsError, "args=%v text=%s", args, extractText(t, res))
+ }
+}
+
+func TestMCP_KegSettingsMinimalIncludesInstructions(t *testing.T) {
+ t.Parallel()
+ session, ctx := newTestSession(t)
+
+ edit, err := session.CallTool(ctx, &sdkmcp.CallToolParams{
+ Name: "keg_settings_edit",
+ Arguments: map[string]any{
+ "keg": "@local/personal",
+ "data": "kegv: 2025-07\ntitle: Personal KEG\nsummary: Discovery\ninstructions: |\n Targeted guidance.\n",
+ },
+ })
+ require.NoError(t, err)
+ require.False(t, edit.IsError, extractText(t, edit))
+
+ single := callKegSettings(t, ctx, session, map[string]any{"keg": "@local/personal"})
+ require.False(t, single.IsError, extractText(t, single))
+ require.Contains(t, extractText(t, single), "instructions:")
+ require.Contains(t, extractText(t, single), "Targeted guidance.")
+
+ batch := callKegSettings(t, ctx, session, map[string]any{"kegs": []string{"@local/personal"}})
+ require.False(t, batch.IsError, extractText(t, batch))
+ require.Contains(t, extractText(t, batch), "instructions:")
+ require.Contains(t, extractText(t, batch), "Targeted guidance.")
+
+ full := callKegSettings(t, ctx, session, map[string]any{
+ "kegs": []string{"@local/personal"},
+ "minimal": false,
+ })
+ require.False(t, full.IsError, extractText(t, full))
+ require.Contains(t, extractText(t, full), "kegv:")
+ require.Contains(t, extractText(t, full), "title: Personal KEG")
+}
diff --git a/pkg/tapper/hub_kegs.go b/pkg/tapper/hub_kegs.go
index c8b7403..de2b6b7 100644
--- a/pkg/tapper/hub_kegs.go
+++ b/pkg/tapper/hub_kegs.go
@@ -11,6 +11,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -23,6 +24,16 @@ import (
// hub's GET /api/v1/kegs handler (handler.ListUserKegs).
const hubKegsPath = "/api/v1/kegs"
+const (
+ hubOrientPath = "/api/v1/orient"
+ hubOrientDetailsPath = "/api/v1/orient/details"
+)
+
+// ErrOrientationUnsupported signals that a hub predates the progressive-
+// disclosure orientation endpoints. Callers may safely use compatibility
+// fallbacks without treating other HTTP failures as feature absence.
+var ErrOrientationUnsupported = errors.New("hub orientation API is unavailable")
+
// HubKeg is one keg the hub reports the authenticated user can reach. It
// mirrors the hub's handler.UserKegItem JSON body — keep the two in sync.
type HubKeg struct {
@@ -32,6 +43,26 @@ type HubKeg struct {
Role string `json:"role"`
}
+// HubOrientationKeg is one compact discovery row returned by a compatible
+// Hub. Instructions are intentionally absent.
+type HubOrientationKeg struct {
+ Namespace string `json:"namespace"`
+ Alias string `json:"alias"`
+ Title string `json:"title"`
+ Summary string `json:"summary"`
+ Visibility string `json:"visibility"`
+ Role string `json:"role"`
+}
+
+// HubOrientationDetail is one explicitly requested KEG config projection.
+type HubOrientationDetail struct {
+ Keg string `json:"keg"`
+ Title string `json:"title"`
+ Summary string `json:"summary"`
+ Updated string `json:"updated,omitempty"`
+ Instructions string `json:"instructions"`
+}
+
// CreateKeg asks the hub to create @namespace/alias via
// POST /api/v1/@{namespace}/kegs. A 409 is returned as an error wrapping
// keg.ErrExist so callers can detect "already exists" with errors.Is; 401/403
@@ -113,6 +144,96 @@ func ListUserKegs(ctx context.Context, hubURL, token string) ([]HubKeg, error) {
return kegs, nil
}
+// DiscoverOrientationKegs fetches the compact authenticated discovery index.
+// A 404 or 405 is reported as ErrOrientationUnsupported so callers can fall
+// back to the older /api/v1/kegs surface.
+func DiscoverOrientationKegs(ctx context.Context, hubURL, token string) ([]HubOrientationKeg, error) {
+ base, err := normalizeHubURL(hubURL)
+ if err != nil {
+ return nil, err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+hubOrientPath, nil)
+ if err != nil {
+ return nil, fmt.Errorf("hub: build orientation discovery request: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("hub: contact hub: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ switch resp.StatusCode {
+ case http.StatusOK:
+ case http.StatusNotFound, http.StatusMethodNotAllowed:
+ return nil, ErrOrientationUnsupported
+ case http.StatusUnauthorized, http.StatusForbidden:
+ return nil, fmt.Errorf("hub: %w (%s)", ErrTokenRejected, resp.Status)
+ default:
+ return nil, fmt.Errorf("hub: orientation discovery returned %s for %s", resp.Status, hubOrientPath)
+ }
+ var out []HubOrientationKeg
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return nil, fmt.Errorf("hub: parse orientation discovery response: %w", err)
+ }
+ return out, nil
+}
+
+// FetchOrientationDetails requests targeted guidance for canonical KEG refs.
+// The Hub guarantees all-or-nothing authorization and preserves input order.
+func FetchOrientationDetails(ctx context.Context, hubURL, token string, refs []string) ([]HubOrientationDetail, error) {
+ base, err := normalizeHubURL(hubURL)
+ if err != nil {
+ return nil, err
+ }
+ payload, err := json.Marshal(struct {
+ Kegs []string `json:"kegs"`
+ }{Kegs: refs})
+ if err != nil {
+ return nil, fmt.Errorf("hub: encode orientation details request: %w", err)
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+hubOrientDetailsPath, bytes.NewReader(payload))
+ if err != nil {
+ return nil, fmt.Errorf("hub: build orientation details request: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("hub: contact hub: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ switch resp.StatusCode {
+ case http.StatusOK:
+ case http.StatusNotFound:
+ // New Hubs use 404 UNAVAILABLE for an invalid/unauthorized target as
+ // well as old Hubs for an unknown route. Distinguish by the structured
+ // code before deciding whether compatibility fallback is safe.
+ var body struct {
+ Error string `json:"error"`
+ Code string `json:"code"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&body); err == nil && body.Code == "UNAVAILABLE" {
+ return nil, errors.New(body.Error)
+ }
+ return nil, ErrOrientationUnsupported
+ case http.StatusMethodNotAllowed:
+ return nil, ErrOrientationUnsupported
+ case http.StatusUnauthorized, http.StatusForbidden:
+ return nil, fmt.Errorf("hub: %w (%s)", ErrTokenRejected, resp.Status)
+ default:
+ return nil, fmt.Errorf("hub: orientation details returned %s for %s%s", resp.Status, hubOrientDetailsPath, readHubError(resp))
+ }
+ var out []HubOrientationDetail
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return nil, fmt.Errorf("hub: parse orientation details response: %w", err)
+ }
+ return out, nil
+}
+
// readHubError best-effort extracts the hub's {"error": ...} message from a
// failed response so the surfaced error carries the hub's own explanation.
func readHubError(resp *http.Response) string {
diff --git a/pkg/tapper/hub_kegs_test.go b/pkg/tapper/hub_kegs_test.go
index 13daf29..374582d 100644
--- a/pkg/tapper/hub_kegs_test.go
+++ b/pkg/tapper/hub_kegs_test.go
@@ -99,6 +99,76 @@ func TestListUserKegs_Unauthorized(t *testing.T) {
require.True(t, errors.Is(err, tapper.ErrTokenRejected))
}
+func TestOrientationEndpoints(t *testing.T) {
+ t.Parallel()
+
+ var requests []string
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests = append(requests, r.Method+" "+r.URL.Path)
+ require.Equal(t, "Bearer tok", r.Header.Get("Authorization"))
+ switch r.URL.Path {
+ case "/api/v1/orient":
+ _ = json.NewEncoder(w).Encode([]tapper.HubOrientationKeg{{
+ Namespace: "foldwise",
+ Alias: "dev",
+ Title: "Development",
+ Summary: "Engineering system of record.",
+ Role: "admin",
+ }})
+ case "/api/v1/orient/details":
+ var body struct {
+ Kegs []string `json:"kegs"`
+ }
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
+ require.Equal(t, []string{"@foldwise/dev"}, body.Kegs)
+ _ = json.NewEncoder(w).Encode([]tapper.HubOrientationDetail{{
+ Keg: "@foldwise/dev",
+ Title: "Development",
+ Summary: "Engineering system of record.",
+ Updated: "2026-07-29T00:00:00Z",
+ Instructions: "Operate carefully.",
+ }})
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer srv.Close()
+
+ discovered, err := tapper.DiscoverOrientationKegs(context.Background(), srv.URL, "tok")
+ require.NoError(t, err)
+ require.Equal(t, "Development", discovered[0].Title)
+ require.Equal(t, "Engineering system of record.", discovered[0].Summary)
+
+ details, err := tapper.FetchOrientationDetails(context.Background(), srv.URL, "tok", []string{"@foldwise/dev"})
+ require.NoError(t, err)
+ require.Equal(t, "Operate carefully.", details[0].Instructions)
+ require.Equal(t, []string{"GET /api/v1/orient", "POST /api/v1/orient/details"}, requests)
+}
+
+func TestOrientationEndpoints_UnsupportedAndUnavailableAreDistinct(t *testing.T) {
+ t.Parallel()
+
+ oldHub := httptest.NewServer(http.NotFoundHandler())
+ defer oldHub.Close()
+ _, err := tapper.DiscoverOrientationKegs(context.Background(), oldHub.URL, "tok")
+ require.ErrorIs(t, err, tapper.ErrOrientationUnsupported)
+ _, err = tapper.FetchOrientationDetails(context.Background(), oldHub.URL, "tok", []string{"@foldwise/dev"})
+ require.ErrorIs(t, err, tapper.ErrOrientationUnsupported)
+
+ newHub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _ = json.NewEncoder(w).Encode(map[string]string{
+ "error": "one or more requested kegs are unavailable",
+ "code": "UNAVAILABLE",
+ })
+ }))
+ defer newHub.Close()
+ _, err = tapper.FetchOrientationDetails(context.Background(), newHub.URL, "tok", []string{"@foldwise/dev"})
+ require.Error(t, err)
+ require.NotErrorIs(t, err, tapper.ErrOrientationUnsupported)
+ require.Contains(t, err.Error(), "unavailable")
+}
+
func TestRenameKeg_Success(t *testing.T) {
t.Parallel()
diff --git a/pkg/tapper/tap.go b/pkg/tapper/tap.go
index 41d1b9c..c00ed9b 100644
--- a/pkg/tapper/tap.go
+++ b/pkg/tapper/tap.go
@@ -40,6 +40,13 @@ type Tap struct {
// single resolver covers the whole surface. Left nil for the CLI, which keeps
// the standard config-driven resolution.
KegResolver func(ctx context.Context, opts KegTargetOptions, role FlightRole) (keg.Keg, error)
+
+ // OrientationDetailsResolver is the hosted-MCP batch seam. Tapper Hub
+ // injects a catalog-backed implementation so minimal keg_settings requests
+ // can authorize and load several selected KEGs without loopback HTTP or
+ // opening each Keg independently. Local and ordinary remote clients leave
+ // it nil and use the standard config/hub resolution path.
+ OrientationDetailsResolver func(ctx context.Context, refs []string) ([]HubOrientationDetail, error)
}
type TapOptions struct {
diff --git a/pkg/tapper/tap_info.go b/pkg/tapper/tap_info.go
index 474ae18..52d8df1 100644
--- a/pkg/tapper/tap_info.go
+++ b/pkg/tapper/tap_info.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
"fmt"
"io"
"os"
@@ -19,6 +20,10 @@ import (
type KegSettingsOptions struct {
KegTargetOptions
+ // Kegs requests one or more canonical @namespace/keg references. It is
+ // mutually exclusive with Keg and is available only in minimal mode.
+ Kegs []string
+
// Minimal strips large sections (tags, entities, indexes) from the output,
// returning only core config fields. Useful for MCP tools where response
// size must stay small.
@@ -27,6 +32,24 @@ type KegSettingsOptions struct {
// KegSettings displays the keg metadata (keg file contents).
func (t *Tap) KegSettings(ctx context.Context, opts KegSettingsOptions) (string, error) {
+ if opts.Keg != "" && opts.Kegs != nil {
+ return "", fmt.Errorf("keg and kegs are mutually exclusive")
+ }
+ if opts.Kegs != nil {
+ if len(opts.Kegs) == 0 || len(opts.Kegs) > 100 {
+ return "", fmt.Errorf("kegs must contain 1 to 100 canonical references")
+ }
+ if !opts.Minimal {
+ if len(opts.Kegs) != 1 {
+ return "", fmt.Errorf("minimal=false requires exactly one keg")
+ }
+ opts.Keg = opts.Kegs[0]
+ opts.Kegs = nil
+ } else {
+ return t.kegSettingsBatch(ctx, opts)
+ }
+ }
+
k, err := t.resolveKeg(ctx, opts.KegTargetOptions)
if err != nil {
return "", fmt.Errorf("unable to open keg: %w", err)
@@ -65,19 +88,195 @@ func (t *Tap) kegSettingsMinimal(ctx context.Context, k keg.Keg) (string, error)
}
type minimalConfig struct {
- Kegv string `yaml:"kegv,omitempty"`
- Title string `yaml:"title,omitempty"`
- Summary string `yaml:"summary,omitempty"`
- Updated string `yaml:"updated,omitempty"`
+ Kegv string `yaml:"kegv,omitempty"`
+ Title string `yaml:"title,omitempty"`
+ Summary string `yaml:"summary,omitempty"`
+ Updated string `yaml:"updated,omitempty"`
+ Instructions string `yaml:"instructions,omitempty"`
}
out := minimalConfig{
- Kegv: cfg.Kegv,
- Title: cfg.Title,
- Summary: cfg.Summary,
- Updated: cfg.Updated,
+ Kegv: cfg.Kegv,
+ Title: cfg.Title,
+ Summary: cfg.Summary,
+ Updated: cfg.Updated,
+ Instructions: cfg.Instructions,
+ }
+
+ b, err := yaml.Marshal(out)
+ if err != nil {
+ return "", fmt.Errorf("unable to marshal minimal config: %w", err)
+ }
+ return string(b), nil
+}
+
+type minimalKegSettings struct {
+ Keg string `yaml:"keg"`
+ Title string `yaml:"title,omitempty"`
+ Summary string `yaml:"summary,omitempty"`
+ Updated string `yaml:"updated,omitempty"`
+ Instructions string `yaml:"instructions,omitempty"`
+}
+
+func (t *Tap) kegSettingsBatch(ctx context.Context, opts KegSettingsOptions) (string, error) {
+ refs := make([]string, 0, len(opts.Kegs))
+ seen := make(map[string]struct{}, len(opts.Kegs))
+ for _, raw := range opts.Kegs {
+ namespace, alias, ok := parseCanonicalKegSelection(raw)
+ if !ok {
+ return "", fmt.Errorf("invalid canonical keg reference %q", raw)
+ }
+ ref := "@" + namespace + "/" + alias
+ if _, duplicate := seen[ref]; duplicate {
+ return "", fmt.Errorf("duplicate keg reference %q", ref)
+ }
+ seen[ref] = struct{}{}
+ if opts.FlightContext != nil {
+ if _, covered := flightCapForKeg(opts.FlightContext, namespace, alias); !covered {
+ return "", fmt.Errorf("keg %q is not available in flight %q", ref, opts.FlightContext.Name)
+ }
+ }
+ refs = append(refs, ref)
+ }
+
+ if t.OrientationDetailsResolver != nil {
+ details, err := t.OrientationDetailsResolver(ctx, refs)
+ if err != nil {
+ return "", err
+ }
+ return marshalMinimalKegSettings(refs, details)
+ }
+
+ cfg, err := t.ConfigService.Config(true)
+ if err != nil {
+ return "", err
+ }
+ type selection struct {
+ index int
+ ref string
+ namespace string
+ alias string
+ hub string
+ entry HubEntry
+ }
+ type group struct {
+ hub string
+ entry HubEntry
+ selections []selection
+ }
+ groupIndexes := map[string]int{}
+ var groups []group
+ for i, ref := range refs {
+ namespace, alias, _ := parseCanonicalKegSelection(ref)
+ _, hubName, entry, resolveErr := cfg.resolveNamespaceHub(namespace, "")
+ if resolveErr != nil {
+ return "", resolveErr
+ }
+ sel := selection{
+ index: i,
+ ref: ref,
+ namespace: namespace,
+ alias: alias,
+ hub: hubName,
+ entry: entry,
+ }
+ groupIndex, ok := groupIndexes[hubName]
+ if !ok {
+ groupIndex = len(groups)
+ groupIndexes[hubName] = groupIndex
+ groups = append(groups, group{hub: hubName, entry: entry})
+ }
+ groups[groupIndex].selections = append(groups[groupIndex].selections, sel)
+ }
+
+ details := make([]HubOrientationDetail, len(refs))
+ for _, grouped := range groups {
+ kind := hubKindOrDefault(grouped.entry.Kind)
+ if kind == HubKindLocal {
+ for _, sel := range grouped.selections {
+ detail, detailErr := t.readOrientationDetail(ctx, opts, sel.ref)
+ if detailErr != nil {
+ return "", detailErr
+ }
+ details[sel.index] = detail
+ }
+ continue
+ }
+ url := strings.TrimSpace(grouped.entry.URL)
+ if url == "" {
+ return "", fmt.Errorf("hub %q has no url configured", grouped.hub)
+ }
+ token := t.hubToken(grouped.entry)
+ if token == "" {
+ return "", fmt.Errorf("hub %q has no authenticated session for %s", grouped.hub, url)
+ }
+ groupRefs := make([]string, 0, len(grouped.selections))
+ for _, sel := range grouped.selections {
+ groupRefs = append(groupRefs, sel.ref)
+ }
+ groupDetails, fetchErr := FetchOrientationDetails(ctx, url, token, groupRefs)
+ if errors.Is(fetchErr, ErrOrientationUnsupported) {
+ groupDetails = nil
+ for _, sel := range grouped.selections {
+ detail, detailErr := t.readOrientationDetail(ctx, opts, sel.ref)
+ if detailErr != nil {
+ return "", detailErr
+ }
+ groupDetails = append(groupDetails, detail)
+ }
+ } else if fetchErr != nil {
+ return "", fetchErr
+ }
+ if len(groupDetails) != len(grouped.selections) {
+ return "", fmt.Errorf("hub %q returned incomplete orientation details", grouped.hub)
+ }
+ for i, sel := range grouped.selections {
+ if groupDetails[i].Keg != sel.ref {
+ return "", fmt.Errorf("hub %q returned orientation details out of order", grouped.hub)
+ }
+ details[sel.index] = groupDetails[i]
+ }
}
+ return marshalMinimalKegSettings(refs, details)
+}
+
+func (t *Tap) readOrientationDetail(ctx context.Context, opts KegSettingsOptions, ref string) (HubOrientationDetail, error) {
+ targetOpts := opts.KegTargetOptions
+ targetOpts.Keg = ref
+ k, err := t.resolveKeg(ctx, targetOpts)
+ if err != nil {
+ return HubOrientationDetail{}, fmt.Errorf("unable to open keg %q: %w", ref, err)
+ }
+ cfg, err := k.Config(ctx)
+ if err != nil {
+ return HubOrientationDetail{}, fmt.Errorf("unable to read keg config %q: %w", ref, err)
+ }
+ return HubOrientationDetail{
+ Keg: ref,
+ Title: cfg.Title,
+ Summary: cfg.Summary,
+ Updated: cfg.Updated,
+ Instructions: cfg.Instructions,
+ }, nil
+}
+func marshalMinimalKegSettings(refs []string, details []HubOrientationDetail) (string, error) {
+ if len(details) != len(refs) {
+ return "", fmt.Errorf("orientation details response length does not match request")
+ }
+ out := make([]minimalKegSettings, len(refs))
+ for i, ref := range refs {
+ if details[i].Keg != ref {
+ return "", fmt.Errorf("orientation details response does not preserve request order")
+ }
+ out[i] = minimalKegSettings{
+ Keg: ref,
+ Title: details[i].Title,
+ Summary: details[i].Summary,
+ Updated: details[i].Updated,
+ Instructions: details[i].Instructions,
+ }
+ }
b, err := yaml.Marshal(out)
if err != nil {
return "", fmt.Errorf("unable to marshal minimal config: %w", err)
@@ -85,6 +284,20 @@ func (t *Tap) kegSettingsMinimal(ctx context.Context, k keg.Keg) (string, error)
return string(b), nil
}
+func parseCanonicalKegSelection(raw string) (namespace, alias string, ok bool) {
+ if raw != strings.TrimSpace(raw) || !strings.HasPrefix(raw, "@") {
+ return "", "", false
+ }
+ namespace, alias, ok = splitKegRef(raw)
+ if !ok || strings.Contains(alias, "/") {
+ return "", "", false
+ }
+ if ValidateNamespace(namespace) != nil || ValidateKegAlias(alias) != nil {
+ return "", "", false
+ }
+ return namespace, alias, true
+}
+
// InfoOptions configures behavior for Tap.Info.
type InfoOptions struct {
KegTargetOptions
diff --git a/pkg/tapper/tap_keg_settings_batch_test.go b/pkg/tapper/tap_keg_settings_batch_test.go
new file mode 100644
index 0000000..5406565
--- /dev/null
+++ b/pkg/tapper/tap_keg_settings_batch_test.go
@@ -0,0 +1,142 @@
+package tapper_test
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/jlrickert/cli-toolkit/sandbox"
+ "github.com/jlrickert/tapper/pkg/tapper"
+ "github.com/stretchr/testify/require"
+)
+
+func TestKegSettingsBatch_GroupsByHubAndPreservesInputOrder(t *testing.T) {
+ t.Parallel()
+ type detailRequest struct {
+ Kegs []string `json:"kegs"`
+ }
+ var aCalls, bCalls atomic.Int32
+ newHub := func(calls *atomic.Int32, prefix string) *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls.Add(1)
+ require.Equal(t, "/api/v1/orient/details", r.URL.Path)
+ var request detailRequest
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
+ out := make([]tapper.HubOrientationDetail, 0, len(request.Kegs))
+ for _, ref := range request.Kegs {
+ out = append(out, tapper.HubOrientationDetail{
+ Keg: ref,
+ Title: prefix + " " + ref,
+ Summary: "summary " + ref,
+ Instructions: "instructions " + ref,
+ })
+ }
+ _ = json.NewEncoder(w).Encode(out)
+ }))
+ }
+ hubA := newHub(&aCalls, "A")
+ defer hubA.Close()
+ hubB := newHub(&bCalls, "B")
+ defer hubB.Close()
+
+ sb := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"})
+ tap, err := tapper.NewTap(tapper.TapOptions{Runtime: sb.Runtime()})
+ require.NoError(t, err)
+ cfg := fmt.Sprintf(`hubs:
+ a: {kind: remote, url: %s, token: token-a}
+ b: {kind: remote, url: %s, token: token-b}
+namespaces:
+ team-a: a
+ team-b: b
+`, hubA.URL, hubB.URL)
+ require.NoError(t, sb.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(cfg), 0o644))
+
+ refs := []string{"@team-a/one", "@team-b/two", "@team-a/three"}
+ out, err := tap.KegSettings(sb.Context(), tapper.KegSettingsOptions{
+ Kegs: refs,
+ Minimal: true,
+ })
+ require.NoError(t, err)
+ require.EqualValues(t, 1, aCalls.Load())
+ require.EqualValues(t, 1, bCalls.Load())
+ first := strings.Index(out, "keg: '@team-a/one'")
+ second := strings.Index(out, "keg: '@team-b/two'")
+ third := strings.Index(out, "keg: '@team-a/three'")
+ require.GreaterOrEqual(t, first, 0)
+ require.Greater(t, second, first)
+ require.Greater(t, third, second)
+}
+
+func TestKegSettingsBatch_OlderHubFallsBackToPerKegConfig(t *testing.T) {
+ t.Parallel()
+ var batchCalls, configCalls atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/orient/details":
+ batchCalls.Add(1)
+ http.NotFound(w, r)
+ case "/api/v1/@legacy/kegs/one/config":
+ configCalls.Add(1)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "kegv": "2025-07", "title": "One", "instructions": "One guidance",
+ })
+ case "/api/v1/@legacy/kegs/two/config":
+ configCalls.Add(1)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "kegv": "2025-07", "title": "Two", "instructions": "Two guidance",
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer srv.Close()
+
+ sb := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"})
+ tap, err := tapper.NewTap(tapper.TapOptions{Runtime: sb.Runtime()})
+ require.NoError(t, err)
+ cfg := fmt.Sprintf(`hubs:
+ legacy: {kind: remote, url: %s, token: token}
+namespaces:
+ legacy: legacy
+`, srv.URL)
+ require.NoError(t, sb.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(cfg), 0o644))
+
+ out, err := tap.KegSettings(sb.Context(), tapper.KegSettingsOptions{
+ Kegs: []string{"@legacy/one", "@legacy/two"},
+ Minimal: true,
+ })
+ require.NoError(t, err)
+ require.EqualValues(t, 1, batchCalls.Load())
+ require.EqualValues(t, 2, configCalls.Load())
+ require.Contains(t, out, "One guidance")
+ require.Contains(t, out, "Two guidance")
+}
+
+func TestKegSettingsBatch_HostedResolverCalledOnce(t *testing.T) {
+ t.Parallel()
+ sb := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"})
+ tap, err := tapper.NewTap(tapper.TapOptions{Runtime: sb.Runtime()})
+ require.NoError(t, err)
+ var calls atomic.Int32
+ tap.OrientationDetailsResolver = func(_ context.Context, refs []string) ([]tapper.HubOrientationDetail, error) {
+ calls.Add(1)
+ out := make([]tapper.HubOrientationDetail, len(refs))
+ for i, ref := range refs {
+ out[i] = tapper.HubOrientationDetail{Keg: ref, Title: ref}
+ }
+ return out, nil
+ }
+ refs := []string{"@foldwise/dev", "@foldwise/engineering"}
+ out, err := tap.KegSettings(sb.Context(), tapper.KegSettingsOptions{
+ Kegs: refs,
+ Minimal: true,
+ })
+ require.NoError(t, err)
+ require.EqualValues(t, 1, calls.Load())
+ require.Less(t, strings.Index(out, refs[0]), strings.Index(out, refs[1]))
+}
diff --git a/pkg/tapper/tap_orient.go b/pkg/tapper/tap_orient.go
index 071c726..e5f4911 100644
--- a/pkg/tapper/tap_orient.go
+++ b/pkg/tapper/tap_orient.go
@@ -2,6 +2,7 @@ package tapper
import (
"context"
+ "errors"
"fmt"
"io/fs"
"path/filepath"
@@ -80,14 +81,15 @@ func (t *Tap) resolveOrientFlight(ctx context.Context, name string) (*Flight, st
// OrientationKeg is one effective KEG exposed by an orientation context.
type OrientationKeg struct {
- Ref string
- Namespace string
- Alias string
- Role string
- Source string
- Visibility string
- FlightCap string
- Instructions string
+ Ref string
+ Namespace string
+ Alias string
+ Title string
+ Summary string
+ Role string
+ Source string
+ Visibility string
+ FlightCap string
}
func (t *Tap) orientKegListing(ctx context.Context, flight *Flight) ([]OrientationKeg, []string) {
@@ -107,7 +109,7 @@ func (t *Tap) orientKegListing(ctx context.Context, flight *Flight) ([]Orientati
if !ok {
continue
}
- rows, err := t.orientKegsForHub(ctx, hubName, entry)
+ rows, err := t.orientKegsForHub(ctx, cfg, hubName, entry)
if err != nil {
warnings = append(warnings, fmt.Sprintf("skipped hub %q: %v", hubName, err))
continue
@@ -118,11 +120,6 @@ func (t *Tap) orientKegListing(ctx context.Context, flight *Flight) ([]Orientati
} else {
row.FlightCap = capRole
}
- if row.Instructions == "" {
- if instructions, err := t.kegInstructions(ctx, cfg, hubName, row.Namespace, row.Alias); err == nil {
- row.Instructions = instructions
- }
- }
if _, dup := seen[row.Ref]; dup {
continue
}
@@ -134,7 +131,7 @@ func (t *Tap) orientKegListing(ctx context.Context, flight *Flight) ([]Orientati
return out, warnings
}
-func (t *Tap) orientKegsForHub(ctx context.Context, hubName string, entry HubEntry) ([]OrientationKeg, error) {
+func (t *Tap) orientKegsForHub(ctx context.Context, cfg *Config, hubName string, entry HubEntry) ([]OrientationKeg, error) {
kind := strings.TrimSpace(entry.Kind)
if kind == "" {
kind = HubKindRemote
@@ -159,6 +156,9 @@ func (t *Tap) orientKegsForHub(ctx context.Context, hubName string, entry HubEnt
Source: hubName,
Visibility: "local",
})
+ title, summary, _ := t.localKegDiscovery(filepath.Join(base, "@"+ns, alias))
+ out[len(out)-1].Title = title
+ out[len(out)-1].Summary = summary
}
return out, nil
}
@@ -171,38 +171,67 @@ func (t *Tap) orientKegsForHub(ctx context.Context, hubName string, entry HubEnt
if token == "" {
return nil, fmt.Errorf("hub has no authenticated session for %s", url)
}
+ discovered, err := DiscoverOrientationKegs(ctx, url, token)
+ if err == nil {
+ out := make([]OrientationKeg, 0, len(discovered))
+ for _, k := range discovered {
+ out = append(out, OrientationKeg{
+ Ref: "@" + k.Namespace + "/" + k.Alias,
+ Namespace: k.Namespace,
+ Alias: k.Alias,
+ Title: k.Title,
+ Summary: k.Summary,
+ Role: k.Role,
+ Source: hubName,
+ Visibility: k.Visibility,
+ })
+ }
+ return out, nil
+ }
+ if !errors.Is(err, ErrOrientationUnsupported) {
+ return nil, err
+ }
+
+ // Compatibility path for older Hubs: retain their catalog listing and
+ // read each selected config for title/summary only. Instructions remain
+ // suppressed from aggregate orientation.
kegs, err := ListUserKegs(ctx, url, token)
if err != nil {
return nil, err
}
out := make([]OrientationKeg, 0, len(kegs))
for _, k := range kegs {
- out = append(out, OrientationKeg{
+ row := OrientationKeg{
Ref: "@" + k.Namespace + "/" + k.Alias,
Namespace: k.Namespace,
Alias: k.Alias,
Role: k.Role,
Source: hubName,
Visibility: k.Visibility,
- })
+ }
+ if title, summary, configErr := t.kegDiscovery(ctx, cfg, hubName, k.Namespace, k.Alias); configErr == nil {
+ row.Title = title
+ row.Summary = summary
+ }
+ out = append(out, row)
}
return out, nil
}
-func (t *Tap) kegInstructions(ctx context.Context, cfg *Config, hubName, namespace, alias string) (string, error) {
+func (t *Tap) kegDiscovery(ctx context.Context, cfg *Config, hubName, namespace, alias string) (string, string, error) {
if cfg == nil || alias == "" {
- return "", nil
+ return "", "", nil
}
if entry, ok := cfg.Hub(hubName); ok && hubKindOrDefault(entry.Kind) == HubKindLocal {
base, err := t.localHubBase(entry)
if err != nil {
- return "", err
+ return "", "", err
}
- return t.localKegInstructions(filepath.Join(base, "@"+strings.TrimPrefix(namespace, "@"), alias))
+ return t.localKegDiscovery(filepath.Join(base, "@"+strings.TrimPrefix(namespace, "@"), alias))
}
target, err := cfg.ResolveRef(t.Runtime, KegRef{Hub: hubName, Namespace: namespace, Name: alias})
if err != nil {
- return "", err
+ return "", "", err
}
var resolver keg.TokenResolver
if t.KegService != nil {
@@ -210,16 +239,16 @@ func (t *Tap) kegInstructions(ctx context.Context, cfg *Config, hubName, namespa
}
k, err := keg.NewKegFromTarget(ctx, *target, t.Runtime, keg.WithTokenResolver(resolver))
if err != nil {
- return "", err
+ return "", "", err
}
cfgDoc, err := k.Config(ctx)
if err != nil || cfgDoc == nil {
- return "", err
+ return "", "", err
}
- return strings.TrimSpace(cfgDoc.Instructions), nil
+ return cfgDoc.Title, cfgDoc.Summary, nil
}
-func (t *Tap) localKegInstructions(dir string) (string, error) {
+func (t *Tap) localKegDiscovery(dir string) (string, string, error) {
for _, name := range []string{"keg", "keg.yaml", "keg.yml"} {
raw, err := t.Runtime.ReadFile(filepath.Join(dir, name))
if err != nil {
@@ -227,11 +256,11 @@ func (t *Tap) localKegInstructions(dir string) (string, error) {
}
cfgDoc, err := keg.ParseKegConfig(raw)
if err != nil {
- return "", err
+ return "", "", err
}
- return strings.TrimSpace(cfgDoc.Instructions), nil
+ return cfgDoc.Title, cfgDoc.Summary, nil
}
- return "", nil
+ return "", "", nil
}
func flightCapForKeg(flight *Flight, namespace, alias string) (string, bool) {
@@ -310,8 +339,8 @@ func BuildOrientationPayload(flight *Flight, flightNote string, kegs []Orientati
}
b.WriteString(".)\n\n")
} else {
- b.WriteString("| KEG | Role | Source | Flight cap |\n")
- b.WriteString("| --- | --- | --- | --- |\n")
+ b.WriteString("| KEG | Title | Summary | Role | Source | Flight cap |\n")
+ b.WriteString("| --- | --- | --- | --- | --- | --- |\n")
for _, k := range kegs {
role := k.Role
if role == "" {
@@ -325,9 +354,18 @@ func BuildOrientationPayload(flight *Flight, flightNote string, kegs []Orientati
if k.Visibility != "" {
source += "/" + k.Visibility
}
- fmt.Fprintf(&b, "| `%s` | %s | %s | %s |\n", k.Ref, role, source, capRole)
+ fmt.Fprintf(
+ &b,
+ "| `%s` | %s | %s | %s | %s | %s |\n",
+ k.Ref,
+ orientationTableCell(k.Title),
+ orientationTableCell(k.Summary),
+ role,
+ source,
+ capRole,
+ )
}
- b.WriteString("\n")
+ b.WriteString("\nCall `keg_settings` for the selected KEG or KEGs before operating in them; targeted settings include KEG-level instructions.\n\n")
}
if flight != nil {
@@ -353,23 +391,6 @@ func BuildOrientationPayload(flight *Flight, flightNote string, kegs []Orientati
}
}
- b.WriteString("## KEG Instructions\n\n")
- wroteInstructions := false
- for _, k := range kegs {
- if strings.TrimSpace(k.Instructions) == "" {
- continue
- }
- wroteInstructions = true
- b.WriteString("### `")
- b.WriteString(k.Ref)
- b.WriteString("`\n\n")
- b.WriteString(strings.TrimSpace(k.Instructions))
- b.WriteString("\n\n")
- }
- if !wroteInstructions {
- b.WriteString("(No KEG-level instructions found in the available KEG configs.)\n\n")
- }
-
b.WriteString("## Guidance\n\n")
for _, name := range []string{"linking.md", "snapshot-policy.md", "secret-handling.md", "agent-orient.md", "tool-inventory.md", "troubleshooting.md"} {
if err := appendCanonical(&b, name); err != nil {
@@ -381,6 +402,18 @@ func BuildOrientationPayload(flight *Flight, flightNote string, kegs []Orientati
return b.String(), nil
}
+func orientationTableCell(value string) string {
+ value = strings.TrimSpace(value)
+ value = strings.ReplaceAll(value, "\\", "\\\\")
+ value = strings.ReplaceAll(value, "|", "\\|")
+ value = strings.ReplaceAll(value, "\r\n", "
")
+ value = strings.ReplaceAll(value, "\n", "
")
+ if value == "" {
+ return "—"
+ }
+ return value
+}
+
func appendCanonical(b *strings.Builder, name string) error {
raw, err := fs.ReadFile(integrations.IntegrationsFS, "content/"+name)
if err != nil {
diff --git a/pkg/tapper/tap_orient_test.go b/pkg/tapper/tap_orient_test.go
index 90ada3f..bf537ae 100644
--- a/pkg/tapper/tap_orient_test.go
+++ b/pkg/tapper/tap_orient_test.go
@@ -2,8 +2,13 @@ package tapper_test
import (
"context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
"path/filepath"
"strings"
+ "sync/atomic"
"testing"
"github.com/jlrickert/cli-toolkit/sandbox"
@@ -41,7 +46,7 @@ func TestTap_Orient_SharedPayloadStartsWithKegSystem(t *testing.T) {
require.Contains(t, payload, "Rules:")
require.NotContains(t, payload, "## Active KEG")
require.Contains(t, payload, "## Available KEGs")
- require.Contains(t, payload, "## KEG Instructions")
+ require.NotContains(t, payload, "## KEG Instructions")
require.Contains(t, payload, "## Guidance")
require.Contains(t, payload, "# Linking conventions")
guidance := payload[strings.Index(payload, "## Guidance"):]
@@ -67,7 +72,7 @@ func TestTap_Orient_UnknownFlightEmitsNote(t *testing.T) {
require.Contains(t, payload, `Flight "f-demo" is unavailable`)
}
-func TestTap_Orient_FlightAndKegInstructionsPrecedeGuidance(t *testing.T) {
+func TestTap_Orient_FlightInstructionsAndKegDiscoveryPrecedeGuidance(t *testing.T) {
t.Parallel()
sb := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"})
require.NoError(t, sb.Setwd("/home/testuser"))
@@ -82,7 +87,7 @@ func TestTap_Orient_FlightAndKegInstructionsPrecedeGuidance(t *testing.T) {
require.NoError(t, sb.Runtime().AtomicWriteFile(dir+"/keg", []byte("kegv: 2025-07\ntitle: "+name+"\n"), 0o644))
}
require.NoError(t, sb.Runtime().AtomicWriteFile("/home/testuser/kegs/@local/personal/keg",
- []byte("kegv: 2025-07\ntitle: Personal\ninstructions: |\n Prefer audited personal-context nodes.\n"), 0o644))
+ []byte("kegv: 2025-07\ntitle: Personal\nsummary: Personal discovery text.\ninstructions: |\n Prefer audited personal-context nodes.\n"), 0o644))
require.NoError(t, sb.Runtime().AtomicWriteFile(
"/home/testuser/kegs/flights.d/backend.yaml",
[]byte("title: Backend\ncover:\n - namespace: local\n keg: personal\n role: viewer\n - namespace: local\n keg: dev\n role: editor\ninstructions: |\n Touch only backend kegs.\n"), 0o644))
@@ -91,19 +96,19 @@ func TestTap_Orient_FlightAndKegInstructionsPrecedeGuidance(t *testing.T) {
KegTargetOptions: tapper.KegTargetOptions{Flight: "backend"},
})
require.NoError(t, err)
- require.Contains(t, payload, "| `@local/dev` | editor | home/local | editor |")
- require.Contains(t, payload, "| `@local/personal` | editor | home/local | viewer |")
+ require.Contains(t, payload, "| `@local/dev` | dev | — | editor | home/local | editor |")
+ require.Contains(t, payload, "| `@local/personal` | Personal | Personal discovery text. | editor | home/local | viewer |")
require.Contains(t, payload, "## Flight")
require.Contains(t, payload, "Backend")
require.Contains(t, payload, "Touch only backend kegs.")
- require.Contains(t, payload, "## KEG Instructions")
- require.Contains(t, payload, "### `@local/personal`")
- require.Contains(t, payload, "Prefer audited personal-context nodes.")
+ require.NotContains(t, payload, "## KEG Instructions")
+ require.NotContains(t, payload, "Prefer audited personal-context nodes.")
+ require.Contains(t, payload, "Call `keg_settings`")
guidanceAt := strings.Index(payload, "## Guidance")
require.NotEqual(t, -1, guidanceAt)
require.Less(t, strings.Index(payload, "Touch only backend kegs."), guidanceAt)
- require.Less(t, strings.Index(payload, "Prefer audited personal-context nodes."), guidanceAt)
+ require.Less(t, strings.Index(payload, "Call `keg_settings`"), guidanceAt)
}
func TestTap_Orient_UsesPersistedFlightBeforeDefaultKeg(t *testing.T) {
@@ -132,10 +137,42 @@ hubs:
require.NoError(t, err)
require.Contains(t, payload, "Active flight: `@local/+backend`")
require.Contains(t, payload, "Flight instructions win.")
- require.Contains(t, payload, "Follow the covered KEG schema.")
+ require.NotContains(t, payload, "Follow the covered KEG schema.")
+ require.NotContains(t, payload, "## KEG Instructions")
require.NotContains(t, payload, "| `@local/personal`")
}
+func TestTap_Orient_FullAccessStillSuppressesKegInstructions(t *testing.T) {
+ t.Parallel()
+ sb := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"})
+ require.NoError(t, sb.Setwd("/home/testuser"))
+ tap, err := tapper.NewTap(tapper.TapOptions{Root: "/home/testuser", Runtime: sb.Runtime()})
+ require.NoError(t, err)
+
+ require.NoError(t, sb.Runtime().AtomicWriteFile(tap.PathService.UserConfig(),
+ []byte("hubs:\n home:\n kind: local\n defaultNamespace: local\n basePath: /home/testuser/kegs\n"), 0o644))
+ dir := "/home/testuser/kegs/@local/dev"
+ require.NoError(t, sb.Runtime().Mkdir(dir, 0o755, true))
+ require.NoError(t, sb.Runtime().AtomicWriteFile(dir+"/keg", []byte(
+ "kegv: 2025-07\ntitle: Development\nsummary: Discoverable engineering context.\ninstructions: DO NOT LEAK FULL ACCESS GUIDANCE\n",
+ ), 0o644))
+ require.NoError(t, sb.Runtime().AtomicWriteFile(
+ "/home/testuser/kegs/flights.d/full.yaml",
+ []byte("title: Full access\ncapabilities: [full_access]\ninstructions: Flight guidance remains visible.\n"),
+ 0o644,
+ ))
+
+ payload, err := tap.Orient(context.Background(), tapper.OrientOptions{
+ KegTargetOptions: tapper.KegTargetOptions{Flight: "full"},
+ })
+ require.NoError(t, err)
+ require.Contains(t, payload, "Discoverable engineering context.")
+ require.Contains(t, payload, "Flight guidance remains visible.")
+ require.Contains(t, payload, "| admin |")
+ require.NotContains(t, payload, "DO NOT LEAK FULL ACCESS GUIDANCE")
+ require.NotContains(t, payload, "## KEG Instructions")
+}
+
func TestTap_Orient_BarePayloadDoesNotInjectDeveloperLifecycle(t *testing.T) {
t.Parallel()
payload, err := newOrientTap(t).Orient(context.Background(), tapper.OrientOptions{})
@@ -176,6 +213,78 @@ func TestTap_Orient_MissingHubAuthenticationIsMCPFirst(t *testing.T) {
require.NotContains(t, payload, "`tap ")
}
+func TestTap_Orient_CompatibleRemoteUsesOneDiscoveryRequest(t *testing.T) {
+ t.Parallel()
+ var requests atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests.Add(1)
+ require.Equal(t, "/api/v1/orient", r.URL.Path)
+ _ = json.NewEncoder(w).Encode([]tapper.HubOrientationKeg{{
+ Namespace: "foldwise",
+ Alias: "dev",
+ Title: "Development",
+ Summary: "Engineering system of record.",
+ Visibility: "private",
+ Role: "admin",
+ }})
+ }))
+ defer srv.Close()
+
+ sb := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"})
+ tap, err := tapper.NewTap(tapper.TapOptions{Runtime: sb.Runtime()})
+ require.NoError(t, err)
+ cfg := fmt.Sprintf("hubs:\n test:\n kind: remote\n url: %s\n token: token\n", srv.URL)
+ require.NoError(t, sb.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(cfg), 0o644))
+
+ payload, err := tap.Orient(context.Background(), tapper.OrientOptions{})
+ require.NoError(t, err)
+ require.EqualValues(t, 1, requests.Load())
+ require.Contains(t, payload, "| `@foldwise/dev` | Development | Engineering system of record. | admin | test/private | none |")
+ require.NotContains(t, payload, "## KEG Instructions")
+}
+
+func TestTap_Orient_OlderHubFallbackSuppressesInstructions(t *testing.T) {
+ t.Parallel()
+ var configReads atomic.Int32
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/orient":
+ http.NotFound(w, r)
+ case "/api/v1/kegs":
+ _ = json.NewEncoder(w).Encode([]tapper.HubKeg{{
+ Namespace: "foldwise",
+ Alias: "dev",
+ Role: "admin",
+ }})
+ case "/api/v1/@foldwise/kegs/dev/config":
+ configReads.Add(1)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "kegv": "2025-07",
+ "title": "Fallback title",
+ "summary": "Fallback summary.",
+ "instructions": "DO NOT LEAK FALLBACK INSTRUCTIONS",
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer srv.Close()
+
+ sb := sandbox.NewSandbox(t, &sandbox.Options{Home: "/home/testuser", User: "testuser"})
+ tap, err := tapper.NewTap(tapper.TapOptions{Runtime: sb.Runtime()})
+ require.NoError(t, err)
+ cfg := fmt.Sprintf("hubs:\n test:\n kind: remote\n url: %s\n token: token\n", srv.URL)
+ require.NoError(t, sb.Runtime().AtomicWriteFile(tap.PathService.UserConfig(), []byte(cfg), 0o644))
+
+ payload, err := tap.Orient(context.Background(), tapper.OrientOptions{})
+ require.NoError(t, err)
+ require.EqualValues(t, 1, configReads.Load())
+ require.Contains(t, payload, "Fallback title")
+ require.Contains(t, payload, "Fallback summary.")
+ require.NotContains(t, payload, "DO NOT LEAK FALLBACK INSTRUCTIONS")
+ require.NotContains(t, payload, "## KEG Instructions")
+}
+
// TestTap_Orient_ActiveKeg_AliasResolutionFromCwd covers the common
// case: a kegMap entry whose pathPrefix matches the working directory
// resolves the keg from cwd. The keg lives on the local hub, so the