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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,8 +420,10 @@ validation.
lists) in one operation — a single round trip on RemoteKeg.
- The keg settings file is named `keg` (no extension), though `keg.yaml` and
`keg.yml` are also accepted.
- Node IDs are allocated by the Hub. `GET /nodes/next` is only a read-only
probe; creation uses `POST /nodes` with complete content.
- Node IDs are allocated by the Hub, which owns a per-keg counter that only
ever rises: a removed id is never handed out again. There is no way to read
the next id ahead of time — creation uses `POST /nodes` with complete
content, and the response carries every allocated id.
- **Cobra skips PersistentPostRunE when RunE returns an error.** Any cleanup
or logging that must run on both success and failure paths cannot rely on
PersistentPostRunE. In tapper, invocation logging and log file cleanup are
Expand Down
19 changes: 18 additions & 1 deletion cliff.toml
Original file line number Diff line number Diff line change
@@ -1,16 +1,33 @@
[changelog]
header = "# Changelog\n\nAll notable changes to this project are documented in this file.\n"
# Breaking changes get their own section ahead of the type groups, and are
# marked inline within them. Without this a `feat(keg)!:` reads as an ordinary
# feature bullet, so a consumer scanning the changelog never learns their
# client broke. `breaking_description` is the `BREAKING CHANGE:` footer;
# git-cliff falls back to the subject when there is no footer, so it is only
# printed when it says something the subject did not.
body = """
{% if version %}
## {{ version }} - {{ timestamp | date(format=\"%Y-%m-%d\") }}
{% else %}
## Unreleased
{% endif %}
{%- set breaking = commits | filter(attribute=\"breaking\", value=true) %}
{%- if breaking | length > 0 %}

### ⚠️ Breaking Changes
{% for commit in breaking %}
**{% if commit.scope %}{{ commit.scope }}: {% endif %}{{ commit.message | trim }}**
{% if commit.breaking_description and commit.breaking_description | trim != commit.message | trim %}
{{ commit.breaking_description | trim }}
{% endif -%}
{%- endfor -%}
{%- endif %}

{% for group, commits in commits | group_by(attribute=\"group\") %}
### {{ group }}
{% for commit in commits -%}
- {% if commit.scope %}**{{ commit.scope }}:** {% endif %}{{ commit.message | trim }}
- {% if commit.breaking %}⚠️ {% endif %}{% if commit.scope %}**{{ commit.scope }}:** {% endif %}{{ commit.message | trim }}
{% endfor %}
{% endfor %}
"""
Expand Down
11 changes: 6 additions & 5 deletions docs/ai-coding-agents/agent-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ Always route through tapper's interfaces:

- `mcp__tapper__cat` to read (supports `content_only`, `meta_only`,
`stats_only`).
- `mcp__tapper__edit` to write content — accepts markdown with
frontmatter and separates it into `README.md` and `meta.yaml`
automatically.
- `mcp__tapper__meta` to update metadata without touching content.
- `mcp__tapper__edit` to write a node — `content` is the markdown body
and `meta` is the metadata document. They are separate fields, so
`content` must not carry a frontmatter block; send either one alone to
leave the other untouched, or both to replace the whole node.

## CLI and MCP share Hub concurrency rules

Expand All @@ -69,7 +69,8 @@ another node first. If you are uncertain the removal is correct, defer.
Snapshots do protect in-place edits. Take one before:

- Any `mcp__tapper__edit` that rewrites more than a section.
- Any `mcp__tapper__meta` overwrite of existing tags or attributes.
- Any `mcp__tapper__edit` writing `meta`, which replaces the whole
metadata document and so overwrites existing tags and attributes.
- `mcp__tapper__move` (the node survives the move, but a pre-move
snapshot makes before/after diffs trivial).
- Any multi-tool transformation where a mistake partway through would
Expand Down
35 changes: 21 additions & 14 deletions docs/ai-coding-agents/mcp-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,31 +106,40 @@ explicitly configured root failed to initialize.

| Tool | Description |
| --- | --- |
| `create` | Atomically create 1–100 nodes from `nodes[]`; unique keys support forward/backward `{{node:key}}` body references |
| `edit` | Read with `cat`, then atomically replace 1–100 nodes from `edits[]`; every item requires that node's `expected_hash` |
| `meta` | Read `node_ids[]` without tokens, or read with `cat` and atomically replace metadata through `updates[]`; every update requires that node's `expected_hash` |
| `create` | Atomically create 1–100 nodes from `nodes[]`, each a markdown `content` document plus an optional YAML `meta` document; unique keys support forward/backward `{{node:key}}` content references |
| `edit` | Read with `cat`, then atomically replace `content`, `meta`, or both for 1–100 nodes from `nodes[]`; every item requires that node's `expected_hash`, and one hash covers content and metadata together |
| `remove` | Read with `cat`, then atomically delete 1–100 `nodes[]`; every item requires its own `expected_hash` |
| `move` | Read with `cat`, then move a node using its required `expected_hash` |
| `keg_settings_edit` | Read the full document with `keg_settings`, then replace it using its required `expected_hash`; requires admin flight authority and editor/admin KEG access |

Mutation inputs are array-only and each array contains 1–100 items:
Mutation inputs are array-only, every batch tool takes its items under `nodes`,
and each array contains 1–100 items:

```json
{"nodes":[{"key":"plan","title":"Plan","body":"See [task](../{{node:task}})"}]}
{"edits":[{"node_id":"12","content":"# Revised","expected_hash":"...","snapshot_before":true}]}
{"node_ids":["12","13"]}
{"updates":[{"node_id":"12","content":"type: plan\n","expected_hash":"...","snapshot_before":true}]}
// create
{"nodes":[{"key":"plan","content":"# Plan\n\nSee [task](../{{node:task}})\n","meta":"type: plan\n"}]}
// edit — content only, metadata only, or both under one hash
{"nodes":[{"node_id":"12","content":"# Revised\n","expected_hash":"..."}]}
{"nodes":[{"node_id":"12","meta":"type: plan\n","expected_hash":"..."}]}
{"nodes":[{"node_id":"12","content":"# Revised\n","meta":"type: plan\n","expected_hash":"..."}]}
// remove
{"nodes":[{"node_id":"12","expected_hash":"..."},{"node_id":"13","expected_hash":"..."}]}
// node_snapshot
{"nodes":[{"node_id":"12","message":"reviewed"}]}
```

The first and last shapes belong to `create` and `node_snapshot`; the middle
shapes cover `edit`, the two mutually exclusive `meta` modes, and `remove`.
Read metadata with `cat` and `meta_only`; take snapshots with `node_snapshot`
before a large or destructive edit.
Mutation results preserve request order and report `node_id`, the resulting
hash or snapshot revision, and advisory schema validation details when
applicable. A failed batch returns no partial results and commits none of its
changes.

Reads are self-contained: each `cat` row in `structuredContent.nodes[]` pairs
`node_id` and `hash` with that node's `content` and `meta`, matching the fields
`edit` accepts, so a read result can be modified and sent back without parsing
the human-readable rendering.

Every protected mutation names the read that supplies its token: `cat` for
node edits, metadata updates, moves, and removals; `keg_settings` for settings;
`schema_read` for schema edits/deletes; and `flight_show` for flight
Expand Down Expand Up @@ -187,7 +196,6 @@ authenticated Hub identity; hosted MCP reports its single authenticated user.

| Tool | Description |
| --- | --- |
| `import_from_keg` | Import nodes from another keg |
| `orient` | Return a read-only view of current instructions, selectable flights, and KEGs |
| `session_refresh` | Retry activation after a broken explicit selection is repaired; zero arguments and no authority replacement once active |
| `list_flights`, `flight_show` | Discover and inspect visible flights |
Expand All @@ -196,7 +204,8 @@ authenticated Hub identity; hosted MCP reports its single authenticated user.
MCP does not expose Tapper configuration, config templates, repository setup,
archive import/export, raw auth status, license text, keg visibility, or
namespace administration. Those remain external CLI, configuration, or Hub UI
operations.
operations. To put a node's content in another KEG, read it with `cat` and
`create` it there.

The five batch mutation modes above intentionally use array-only inputs. Empty
batches, batches over 100 items, duplicate keys/IDs, unknown create
Expand All @@ -205,8 +214,6 @@ failure reject the entire call. Structured results preserve request order and
include node IDs plus resulting hashes or snapshot revisions. The removed
single-item fields are not accepted by the published MCP schemas.

`import_from_keg` requires editor identity and flight authority on the source
when `leave_stubs` is requested, because that option rewrites source nodes.
Both transports also publish `tapper://orient` and the
`tapper://node/{node_id}{?keg}` resource template.

Expand Down
1 change: 0 additions & 1 deletion docs/query-expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ expressions over tags and metadata attributes.
- `tap tags --query EXPR`
- `tap cat --query EXPR`
- `tap rm --query EXPR`
- `tap import --query EXPR`

## Syntax

Expand Down
11 changes: 8 additions & 3 deletions integrations/content/agent-orient.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,20 @@ instructions as the authoritative context for the session.
containing `README.md`, `meta.yaml`, and `stats.json`. These are tapper's
internal storage format. Reading them bypasses the index; writing them
bypasses locking and snapshot history. Always go through
`mcp__tapper__cat`, `mcp__tapper__edit`, `mcp__tapper__meta`, and related
tools.
`mcp__tapper__cat`, `mcp__tapper__edit`, and related tools.
- **Treat the call-selected flight as MCP authority.** The root reference is
pinned to the connection, but its manifest, transitive
graph, and authorization are loaded before every authority-bearing call.
Omit `flight` to use the root, or pass the root or one of the flattened
descendants returned by orientation. A selected descendant contributes only
its own instructions and authority; ancestor instructions and permission
caps are not inherited. `defaultKeg` does not grant authority.
caps are not inherited. No `keg` argument grants authority, and neither does
`defaultKeg`: naming a KEG chooses a target, and the flight decides whether
you may reach it. When orientation lists a KEG under "Reachable via
subflight", every call against it must carry that flight — reads included, so
`cat`, `links`, and `backlinks` need it just as much as `edit` does.
Omitting `flight` there returns `ORIENTATION_DENIED`, no matter what `keg`
says.
- **Handle orientation failures explicitly.** `ORIENTATION_STALE` means
authority raced between call resolution and Hub validation;
`ORIENTATION_DENIED` means the selection is outside the accessible graph or lacks the
Expand Down
6 changes: 6 additions & 0 deletions integrations/content/linking.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ the same keg. A bare `keg:` reference in node prose is plain text: it does not
create a graph link or backlink. Bare references remain valid as CLI arguments,
configuration values, schema values, and tool parameters.

Linking across kegs is ordinary authoring, but *copying* nodes across them is
not an agent operation: no tool moves or duplicates nodes between kegs. Read
the source with `mcp__tapper__cat` and `mcp__tapper__create` the node in the
target, which also lets you adjust its links deliberately. Bulk transfer
between kegs is an operator task the user runs outside MCP.

## Attachments

A node's uploaded files and images live in two directories inside the node's
Expand Down
4 changes: 2 additions & 2 deletions integrations/content/snapshot-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ Snapshots do protect in-place edits. Take one before any of:

- `mcp__tapper__edit` that rewrites more than a section, pipes in
generated content, or replaces content the agent did not author.
- `mcp__tapper__meta` changes that overwrite existing tags, attributes,
or frontmatter.
- `mcp__tapper__edit` writing `meta`, which replaces the node's whole
metadata document and so overwrites existing tags and attributes.
- `mcp__tapper__move` — while the node survives the move, a snapshot
before the rename makes it easy to confirm the move did not lose
content and to diff against the pre-move state.
Expand Down
65 changes: 61 additions & 4 deletions integrations/content/tool-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,27 @@ configuration, namespace/license discovery, `session_refresh`, `list_flights`,
`flight_show`, and `keg_search` do not accept `flight`. MCP resources use root authority
while rendering graph-wide discovery.

`flight` is an **operational** parameter, not a discovery-only one: `list`,
`cat`, `create`, `edit`, and `remove` all take it and all honour it. Pass the
**exact canonical name** orientation printed under "Selectable flights",
namespace sigil and `+` included:

```json
{ "flight": "@admin/+mcp-smoke-readonly", "keg": "@admin/mcp-smoke-readonly", "limit": 10 }
```

A bare `mcp-smoke-readonly` or `+mcp-smoke-readonly` is not a canonical name.
Unqualified names resolve against the active KEG, so under a root whose cover is
empty there is nothing to resolve them against and the call fails
`ORIENTATION_DENIED` — which reads like a missing feature but is a name that did
not resolve. Re-read the orientation output and copy the name verbatim.

`keg` never grants authority. It selects a target *within* the authority the
call already has; it cannot reach a KEG the selected flight does not cover.
Naming an uncovered KEG is `ORIENTATION_DENIED`, and that is the access control
working, not a bug. To widen what a call can reach, pass a `flight` that covers
it.

## Orientation and management

| Tool | Purpose |
Expand Down Expand Up @@ -37,7 +58,7 @@ accessible transitive descendants.
| `mcp__tapper__grep` | Regex search over node content. Supports `ignore_case`, `limit`, `max_lines`, and `id_only`. |
| `mcp__tapper__tags` | List tags or filter nodes by a boolean expression over tags, attributes, and dot-prefix stats fields (for example `tapper and .created>2026-01-01`). |
| `mcp__tapper__list` | List nodes in a keg with optional filters. |
| `mcp__tapper__cat` | Read one or more node bodies. Supports `meta_only`, `content_only`, `stats_only`, and `tag` expression selection as an alternative to explicit node IDs. |
| `mcp__tapper__cat` | Read one or more nodes. Each structured row pairs `node_id` and `hash` with that node's `content` and `meta`, so a read feeds straight into `edit`. Supports `meta_only`, `content_only`, `stats_only`, and `tag` expression selection as an alternative to explicit node IDs. |
| `mcp__tapper__links` | Outbound links from a node. |
| `mcp__tapper__backlinks` | Inbound links to a node. |
| `mcp__tapper__list_indexes`, `mcp__tapper__index_cat` | Read generated index files (tag index, changelog, and others). |
Expand Down Expand Up @@ -77,9 +98,8 @@ code; the index does the work in O(matches) rather than O(total).

| Tool | Purpose |
| ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
| `mcp__tapper__create` | Allocate a new numbered node. Accepts title, lead, tags, and attributes at creation time. |
| `mcp__tapper__edit` | Call `cat`, then atomically replace content for 1–100 nodes; every edit requires that node's returned hash. |
| `mcp__tapper__meta` | Read metadata without tokens, or call `cat` and atomically update 1–100 nodes; every update requires its hash. |
| `mcp__tapper__create` | Atomically create 1–100 nodes. Each is a markdown `content` document plus an optional YAML `meta` document; the title is the content's H1. Nodes in one batch reference each other with `{{node:KEY}}`. |
| `mcp__tapper__edit` | Call `cat`, then atomically replace `content`, `meta`, or both for 1–100 `nodes[]`; every item requires that node's returned hash, and one hash covers content and metadata together. |
| `mcp__tapper__move` | Call `cat`, then relocate a node using its required returned hash. |
| `mcp__tapper__remove` | Call `cat`, then atomically remove 1–100 `nodes[]`, each carrying its own required returned hash. |
| `mcp__tapper__delete_file`, `mcp__tapper__delete_image` | Destructive attachment operations — see the Snapshots section below before calling. |
Expand All @@ -92,3 +112,40 @@ Schema edits and deletes similarly require the hash from `schema_read`. Every
conflict performs no operation: merge the change into returned current content
or refetch with the corresponding read, then retry with the returned current
hash.

A hash covers exactly one write. Every successful write returns a new one and
invalidates the hash you sent, so a sequence like edit-then-delete needs a
fresh read between the two calls rather than a reused token. Node ids are
per-keg counters as well: node 4 in one keg is unrelated to node 4 in another.

### Writing a node

A node is two documents and nothing else: `content`, the markdown body whose H1
is the title, and `meta`, the complete metadata document. Three placement rules
cover most first-attempt failures:

- `schema` is a property of the item itself, a sibling of `meta` — never a key
inside the metadata.
- `meta` is a **YAML string**, not a JSON object. `"type: document\n"` is
right; `{"type": "document"}` is not.
- `content` must not open with a `---` frontmatter block. Metadata has one
home, and that is `meta`.

Use `schema_list` to see the names a keg accepts, then:

```json
{"nodes": [{"key": "a1", "content": "# Title\n\nBody", "meta": "type: document\n", "schema": "document"}]}
```

`edit` takes the same two documents per item plus that node's current hash from
`cat`, and either document may be omitted to leave it untouched:

```json
{"nodes": [{"node_id": "12", "content": "# Revised\n\nBody", "expected_hash": "HASH_FROM_CAT"}]}
```

`remove` carries only ids and hashes:

```json
{"nodes": [{"node_id": "12", "expected_hash": "HASH_FROM_CAT"}]}
```
Loading
Loading