/bedrock:preserve — Single Write Point for the Vault
SkillDocs & knowledgeSingle write point for the vault. Centralizes entity detection, textual matching, entity creation/update, and bidirectional linking. Accepts structured input (list of entities), free-form input (text, meeting notes, session context), or graphify output (graph.json + obsidian markdown from /graphify pipeline). Use when: "bedrock preserve", "bedrock-preserve", "save to vault", "record in vault", "/bedrock:preserve", or when another skill (e.g., /bedrock:learn) needs to persist entities in the vault.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the /bedrock:preserve — Single Write Point for the Vault skill
What this skill tells your AI
The instructions your AI receives, as published by iurykrieger/claude-bedrock in skills/preserve/SKILL.md and read by ahel’s review.
Plugin Paths
Entity definitions and templates are in the plugin directory, not in the vault root. Use the "Base directory for this skill" provided at invocation to resolve paths:
- Entity definitions:
<base_dir>/../../entities/ - Templates:
<base_dir>/../../templates/{type}/_template.md - Plugin CLAUDE.md:
<base_dir>/../../CLAUDE.md(already injected automatically into context)
Where <base_dir> is the path provided in "Base directory for this skill".
Vault Resolution
Resolve which vault to operate on. This skill can be invoked from any directory.
Step 1 — Parse --vault flag:
Check if the input arguments include --vault <name>. If found, extract the vault name and remove it from the arguments before further parsing.
Step 2 — Resolve vault path:
-
If
--vault <name>was provided: Read the vault registry at<base_dir>/../../vaults.json. Find the entry matching the name. If not found: error — "Vault<name>is not registered. Run/bedrock:vaultsto see available vaults." If found: setVAULT_PATHto the entry'spathvalue. -
If no
--vaultflag — CWD detection: Read<base_dir>/../../vaults.json. Check if the current working directory is inside any registered vault path (CWD starts with a registered vault's absolute path). If multiple match, use the longest path (most specific). If found: setVAULT_PATHto the matching vault'spath. -
If CWD detection fails — default vault: From the registry, find the vault with
"default": true. If found: setVAULT_PATHto the default vault'spath. -
If no resolution: Error — "No vault resolved. Available vaults:" followed by the registry listing. "Use
--vault <name>to specify, or run/bedrock:setupto register a vault."
Step 3 — Validate vault path:
test -d "<VAULT_PATH>" && echo "exists" || echo "missing"
If missing: error — "Vault path <VAULT_PATH> does not exist on disk. Run /bedrock:setup to re-register."
Step 4 — Read vault config:
cat <VAULT_PATH>/.bedrock/config.json 2>/dev/null
Extract language, git.strategy, and other relevant fields for use in later phases.
From this point forward, ALL vault file operations use <VAULT_PATH> as the root.
- Entity directories:
<VAULT_PATH>/actors/,<VAULT_PATH>/people/, etc. - Vault config:
<VAULT_PATH>/.bedrock/config.json - Git operations:
git -C <VAULT_PATH> <command>
Overview
This skill centralizes ALL write logic for the vault. It receives input (structured, free-form,
or graphify output), identifies entities, correlates with the existing vault, proposes changes
to the user, and executes after confirmation. It is the only path to create or update entities in the vault (except /sync-people
which handles people/teams via GitHub API).
You are an execution agent. Follow the phases below in order, without skipping steps.
Phase 0 — Pre-Write Setup
Two pre-flight steps run before any input parsing: synchronize the vault with its remote, then (when applicable) merge an incoming graphify output directory into the vault's cumulative graphify-out/.
0.1 Vault Sync
Execute:
git -C <VAULT_PATH> pull --rebase origin main
If the pull fails:
- No remote configured: warn "No remote configured. Working locally." and proceed.
- Pull conflict:
git -C <VAULT_PATH> rebase --abortand warn the user. DO NOT proceed without resolving. - Otherwise: proceed.
0.2 Merge Incoming Graphify Output
When this runs: Only when the skill was invoked with a graphify_output_path argument pointing at a graphify output directory (e.g., /bedrock:learn passes $TEACH_TMP/graphify-out-new/). Free-form text input and structured entity-list input skip this sub-phase entirely.
Skip condition (backward compat): If the input's graphify_output_path resolves to the same absolute path as <VAULT_PATH>/graphify-out/, skip this sub-phase. Legacy callers (and /bedrock:sync in its current form) point at the vault's own output directory — there is nothing to merge. Use realpath (or equivalent) to compare:
incoming_real=$(cd "<graphify_output_path>" 2>/dev/null && pwd -P)
vault_real=$(cd "<VAULT_PATH>/graphify-out" 2>/dev/null && pwd -P)
if [ "$incoming_real" = "$vault_real" ]; then
echo "Phase 0.2: graphify_output_path already points at the vault — skipping merge."
# proceed to Phase 1 with graphify_output_path unchanged
fi
Skip condition (no graphify input): If the input is free-form text, structured entity list, or otherwise does not include graphify_output_path, skip.
Step 1 — Validate incoming directory. Verify that <graphify_output_path>/graph.json exists, is non-empty, and parses as valid JSON. If invalid, abort with a clear error and do NOT mutate the vault:
if [ ! -s "<graphify_output_path>/graph.json" ]; then
echo "ERROR: graph.json missing or empty in <graphify_output_path>. Aborting before vault mutation."
exit 1
fi
python3 -c "import json,sys; json.load(open('<graphify_output_path>/graph.json'))" || { echo "ERROR: graph.json is not valid JSON."; exit 1; }
Step 2 — First-ingestion edge case. If <VAULT_PATH>/graphify-out/ does not exist, promote the incoming directory wholesale (no re-merge pass) and record stats, then skip to Step 7:
if [ ! -d "<VAULT_PATH>/graphify-out" ]; then
mkdir -p "<VAULT_PATH>"
cp -R "<graphify_output_path>" "<VAULT_PATH>/graphify-out"
echo "Phase 0.2: first ingestion — promoted incoming graphify output to <VAULT_PATH>/graphify-out/."
# record: nodes_added = <count of nodes in graph.json>, nodes_merged = 0, edges_added = <count of edges>, stale_flag_set = false
# skip to Step 7 (record stats) then exit sub-phase
fi
Step 3 — Merge graph.json (nodes + edges). Both files follow NetworkX node-link format ({"nodes": [...], "edges": [...]} or "links" — accept either key). Run the merge via an inline Python block to avoid hand-merging JSON in the prompt. Write the merged graph to a staging file, then atomically swap:
python3 - <<'PY'
import json, os, pathlib, shutil, sys
existing_path = pathlib.Path("<VAULT_PATH>/graphify-out/graph.json")
incoming_path = pathlib.Path("<graphify_output_path>/graph.json")
staging_path = existing_path.with_suffix(".json.staging")
with existing_path.open() as f:
existing = json.load(f)
with incoming_path.open() as f:
incoming = json.load(f)
# Accept both "edges" and "links" keys — normalize to "edges".
def _edges(g):
return g.get("edges", g.get("links", []))
# --- Node merge keyed by id ---
def _union(a, b):
# Preserve order; dedup by string representation.
seen, out = set(), []
for item in (a or []) + (b or []):
key = json.dumps(item, sort_keys=True) if not isinstance(item, str) else item
if key not in seen:
seen.add(key)
out.append(item)
return out
def _dedup_sources_by_url(a, b):
seen, out = set(), []
for item in (a or []) + (b or []):
if isinstance(item, dict) and "url" in item:
if item["url"] in seen:
continue
seen.add(item["url"])
out.append(item)
return out
existing_nodes = {n["id"]: n for n in existing.get("nodes", [])}
nodes_added = 0
nodes_merged = 0
for inc in incoming.get("nodes", []):
nid = inc["id"]
if nid not in existing_nodes:
existing_nodes[nid] = inc
nodes_added += 1
else:
cur = existing_nodes[nid]
# Union sources by URL
if "sources" in inc or "sources" in cur:
cur["sources"] = _dedup_sources_by_url(cur.get("sources"), inc.get("sources"))
# Most-recent updated_at (YYYY-MM-DD lexical compare works)
cur_ua, inc_ua = cur.get("updated_at"), inc.get("updated_at")
if inc_ua and (not cur_ua or inc_ua > cur_ua):
cur["updated_at"] = inc_ua
# Union labels and tags
for key in ("labels", "tags"):
if key in inc or key in cur:
cur[key] = _union(cur.get(key), inc.get(key))
nodes_merged += 1
# --- Edge dedup keyed by (source, target, type/relation) ---
def _edge_key(e):
return (e.get("source"), e.get("target"), e.get("type") or e.get("relation"))
existing_edges = _edges(existing)
seen_edges = {_edge_key(e) for e in existing_edges}
edges_added = 0
for inc_edge in _edges(incoming):
k = _edge_key(inc_edge)
if k in seen_edges:
continue
existing_edges.append(inc_edge)
seen_edges.add(k)
edges_added += 1
merged = dict(existing)
merged["nodes"] = list(existing_nodes.values())
# Preserve the key naming the existing file used.
merged_key = "edges" if "edges" in existing else ("links" if "links" in existing else "edges")
merged[merged_key] = existing_edges
with staging_path.open("w") as f:
json.dump(merged, f, indent=2, ensure_ascii=False)
# Emit stats to stdout for capture.
print(json.dumps({"nodes_added": nodes_added, "nodes_merged": nodes_merged, "edges_added": edges_added}))
PY
Atomic swap after the Python block succeeds:
mv "<VAULT_PATH>/graphify-out/graph.json.staging" "<VAULT_PATH>/graphify-out/graph.json"
If the Python block exits non-zero, abort without running the mv — the vault's graph.json stays untouched.
Step 4 — Append obsidian/*.md files. For each markdown file in <graphify_output_path>/obsidian/:
- Skip any file whose
source_filefrontmatter value starts with/tmp/— these are ephemeral visualization files produced by/bedrock:teachand must not accumulate in the vault. - If the corresponding file exists in
<VAULT_PATH>/graphify-out/obsidian/: append the incoming content to the existing file, separated by\n\n---\n\n. Existing content is preserved verbatim. - If it does not exist: copy the file into
<VAULT_PATH>/graphify-out/obsidian/.
mkdir -p "<VAULT_PATH>/graphify-out/obsidian"
for src in "<graphify_output_path>/obsidian/"*.md; do
[ -e "$src" ] || continue
SRC_FILE=$(awk -F'"' '/^source_file:/{print $2; exit}' "$src")
case "$SRC_FILE" in /tmp/*) continue ;; esac
dest="<VAULT_PATH>/graphify-out/obsidian/$(basename "$src")"
if [ -e "$dest" ]; then
printf '\n\n---\n\n' >> "$dest"
cat "$src" >> "$dest"
else
cp "$src" "$dest"
fi
done
Step 5 — Append GRAPH_REPORT.md. If <graphify_output_path>/GRAPH_REPORT.md exists:
- If
<VAULT_PATH>/graphify-out/GRAPH_REPORT.mdexists: append a new dated section. - If it does not exist: copy.
if [ -f "<graphify_output_path>/GRAPH_REPORT.md" ]; then
dest="<VAULT_PATH>/graphify-out/GRAPH_REPORT.md"
if [ -e "$dest" ]; then
{
printf '\n\n---\n\n# Merge on %s\n\n' "$(date +%Y-%m-%d)"
cat "<graphify_output_path>/GRAPH_REPORT.md"
} >> "$dest"
else
cp "<graphify_output_path>/GRAPH_REPORT.md" "$dest"
fi
fi
Step 6 — Mark .graphify_analysis.json stale. If <VAULT_PATH>/graphify-out/.graphify_analysis.json exists, set a top-level "stale": true field. Other content is untouched:
analysis="<VAULT_PATH>/graphify-out/.graphify_analysis.json"
stale_flag_set=false
if [ -f "$analysis" ]; then
python3 - <<PY
import json, pathlib
p = pathlib.Path("$analysis")
with p.open() as f:
data = json.load(f)
data["stale"] = True
with p.open("w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
PY
stale_flag_set=true
fi
If the file does not exist, skip (nothing to mark).
Step 7 — Record merge stats for the Phase 7 report. Capture nodes_added, nodes_merged, edges_added (from Step 3's Python stdout) and stale_flag_set (from Step 6). These values are threaded through to Phase 7's report block under a new "Graphify merge" section and returned in the skill's result payload to the caller (e.g., /bedrock:learn).
Step 8 — Point subsequent phases at the merged location. After the merge succeeds, set graphify_output_path := <VAULT_PATH>/graphify-out/ for all downstream phases. Phase 1.3 (graphify-output parsing), Phase 2 (matching), and the rest of the flow read from the merged vault location — not from the original temp input.
Phase 1 — Parse Input
/bedrock:preserve accepts three input modes. Determine which to apply:
1.1 Structured input
When called by another skill (e.g., /bedrock:learn) or when the user provides an explicit list.
The format is an optional top-level header followed by a list of entities:
# Optional top-level header — applies to all entities in the batch
actor_context: <actor-name> # optional, kebab-case slug of an actor in the vault.
# When present, /preserve treats the batch as scoped to that actor:
# graphify nodes of file_type=document/paper become `code` of this actor
# with node_type ∈ {concept, decision} (see Phase 1.3 step 4).
entities:
- type: actor | person | team | concept | topic | discussion | project | fleeting | code
name: "canonical entity name"
action: create | update
content: "content to include in the entity body"
relations:
actors: ["actor-slug-1", "actor-slug-2"]
people: ["person-slug-1"]
teams: ["team-slug-1"]
concepts: ["concept-slug-1"]
topics: ["topic-slug-1"]
discussions: ["discussion-slug-1"]
projects: ["project-slug-1"]
code: ["node-slug-1"]
source: "github | confluence | jira | session | manual | gdoc | csv | graphify"
metadata: {} # additional frontmatter fields specific to the type
If the input is a bare list (no entities: key, no actor_context), accept it as a list of entities with actor_context = null. This preserves backward compatibility with callers that pre-date the field.
If the input follows this format (or something close): parse directly and go to Phase 2.
1.2 Free-form input
When the user provides natural text, meeting notes, session context, or any unstructured content. Analyze the text and extract:
-
Mentioned entities — identify by name, alias, or reference:
- People: names in "First Last" format
- Actors: service names, APIs, repositories
- Teams: squad names
- Concepts: patterns, principles, techniques, protocols, abstractions
- Topics: discussion themes, bugs, RFCs, features
- Discussions: meetings, decisions, debates
- Projects: initiatives, migrations, cross-team features
-
Inferred action — for each entity:
- If the entity already exists in the vault:
update - If the entity does not exist:
create
- If the entity already exists in the vault:
-
Content — what was said about each entity in the input
-
Relations — infer which entities relate to each other based on context
-
Source — infer:
session(conversation),meeting-notes(minutes),manual(typed text)
To classify new content, consult the plugin's entity definitions (see "Plugin Paths" section) (loaded in Phase 2.0):
- "When to create" section → positive criteria for creating a new entity
- "When NOT to create" section → exclusion criteria
- "How to distinguish from other types" section → disambiguation
Convert the result to the structured format from section 1.1 and proceed.
1.3 Graphify output input
When called by /bedrock:learn (or any skill) with a graphify output reference,
OR when the user invokes /bedrock:preserve directly pointing at a graphify-out/ directory:
Input format:
graphify_output_path: path tographify-out/directorysource_url: original external source URL/path (optional — may not be present for manual invocation)source_type: type of external source (optional)actor_context: kebab-case slug of an actor in the vault (optional). When present, the entire corpus is treated as belonging to that actor:file_type=document/papernodes are classified ascodeof that actor withnode_type ∈ {concept, decision}instead of as globalconcept/topic/fleeting. When absent, classification falls back to the corpus-agnostic logic (concept global / topic / fleeting).
Detection: If the input contains a path ending in graphify-out/ or graphify-out,
or references graph.json, treat as graphify output input.
Processing:
-
Read graph.json from
graphify_output_path/graph.json:- Parse NetworkX node-link format
- Extract all nodes with:
id,label,file_type,source_file,source_location - Extract all edges with:
source,target,relation,confidence,confidence_score - If
graph.jsonis missing or empty: abort with error "No graph.json found in graphify output. Run /graphify first."
-
Read obsidian files from
graphify_output_path/obsidian/*.md:- For each markdown file, read frontmatter and body content
- Correlate with graph.json by matching filename stem to node
id(kebab-cased) - If obsidian file doesn't exist for a node: fall back to graph.json metadata alone
-
Read analysis from
graphify_output_path/.graphify_analysis.json(if exists):- Extract community assignments (
community_idper node), god nodes (is_god_node), community labels - Use community labels to inform
domain/*tags when creating entities - If absent or
stale: true, downstream steps fall back to graph-only signals (no community-aware grouping)
- Extract community assignments (
-
Read configuration from
<VAULT_PATH>/.bedrock/config.json(best-effort):code.max_per_actor: integer, default200. Per-actor cap on the number ofcodecandidates produced from this corpus. No absolute global cap.code.cluster_threshold: float, default0.85. Minimumsemantically_similar_to.confidence_scorefor two nodes to be grouped into the samecodecandidate.- If
.bedrock/config.jsonis missing or has nocodeblock, use the defaults silently.
-
Group nodes by semantic similarity (BEFORE filtering and classification) — produces clusters that will each become at most one
codecandidate:- Build clusters by union-find:
- Two nodes are in the same cluster if there is a
semantically_similar_toedge between them withconfidence_score ≥ code.cluster_threshold. - OR they share the same
community_id(from.graphify_analysis.json) AND at least one of them isis_god_nodeOR both haveedge_count ≥ 2. This guards against weakly-tied community co-membership.
- Two nodes are in the same cluster if there is a
- If
.graphify_analysis.jsonis absent or stale, only thesemantically_similar_torule applies. - Singletons (nodes with no qualifying neighbor) form their own cluster of size 1.
- Each cluster carries:
cluster_id(synthetic),member_node_ids[](the union of nodeids — this becomes thegraphify_node_idsarray of the resultingcodeentity),representative(the node with highest degree in the cluster — used forlabel,source_file, etc.). - Hard cap: maximum 50 nodes per cluster (defensive — runaway clusters indicate bad threshold).
- Build clusters by union-find:
-
Classify clusters into vault entity types — /preserve owns this classification. Read ALL entity definitions from the plugin (see "Plugin Paths") and apply:
When
actor_contextis present (single-actor corpus):file_type: codeclusters →codefor the actor,node_type∈ {function,class,module,interface,endpoint} inferred from the representative node's label/edges.file_type: documentorfile_type: paperclusters →codefor the actor:node_type: decisionif the representative node hasrationale_foredges OR the label/text contains decision markers (e.g., "ADR", "RFC", "decision", "chose", "decided").node_type: conceptotherwise.
- The cluster's
actorfield is set to[[<actor_context>]]for all entities.
When
actor_contextis absent (corpus-agnostic):file_type: codeclusters →code(parent actor inferred fromsource_filepath or repo name in the path; if no actor can be inferred, classify asfleeting).file_type: documentorfile_type: paperclusters → check for concept first: if the representative node describes a pattern, principle, technique, protocol, or abstraction AND is self-contained AND is not specific to a single actor →concept(global).- Non-concept document → classify using entity definitions ("When to create" / "When NOT to create" / "How to distinguish").
- Non-concept paper →
topicorfleetingdepending on completeness criteria.
- God nodes (
is_god_node) → consider asactor,concept, ortopic. - Apply Zettelkasten classification (section 1.4): if content doesn't meet completeness criteria →
fleeting.
-
Filter relevant clusters (applied to
codecandidates only — non-code classifications keep their existing inclusion logic):- Inclusion predicate: representative node's strongest edge has
confidence ∈ {EXTRACTED, INFERRED}AND at least one of:is_god_node(from.graphify_analysis.json),degree > community_average_degree(from.graphify_analysis.json; if analysis absent, use overall graph average),edge_count ≥ 2.
- Exclusion: representative label matches trivial patterns (case-insensitive substring of
Test,Tests,Mock,Fake,Builder,Stub,Fixture) or the cluster only contains nodes flagged trivial by graphify. - Per-actor cap: group surviving
codecandidates by their resolvedactor. Within each actor, rank byis_god_node(true first) >degree>edge_count. Keep the topcode.max_per_actor(default200); discard the rest with a warning in the report listing how many were dropped per actor. - No English keyword regex. The previous label allowlist (
Service|Controller|...) is removed. - For non-
codeclassifications (concept global, topic, fleeting): include all that pass classification.
- Inclusion predicate: representative node's strongest edge has
-
Match against existing vault — Use the textual matching logic from Phase 2 (filename, name, aliases, graphify ids). For
codeentities, the graphify-id match accepts both legacy singulargraphify_node_idand the newgraphify_node_idsarray — match if the cluster'smember_node_idsset intersects the entity's existing id set. Mark matched clusters asupdate, unmatched ascreate. -
Build internal structured format for each classified + filtered cluster:
type: from classification (step 6).name: kebab-cased label of the cluster'srepresentativenode.action:createorupdate(from step 8 matching).content: body of the obsidian markdown file matching the representative's id (or generate from graph.json metadata if no obsidian file). When the cluster has multiple members, include a brief "Grouped from N graphify nodes" note in the body listing the member ids — this preserves traceability.relations: from graph.json edges of all member nodes (convert node ids to entity slugs via kebab-case; deduplicate).source: from inputsource_type(or"graphify"if not provided).source_url: from inputsource_url(if provided).source_type: from inputsource_type(if provided).metadata: forcodeentities, include:graphify_node_ids: array — the cluster'smember_node_ids(always written as array even when size is 1).actor: wikilink of the parent actor ([[<actor_context>]]when set; otherwise inferred).node_type: from step 6.source_file: relative path from the representative node.confidence: strongest edge confidence across all member nodes (EXTRACTED>INFERRED>AMBIGUOUS).
metadata: forconcept(global) entities from graphify, include:graphify_node_ids: array.confidence: strongest edge confidence across all members.
-
Proceed to Phase 3 (Change Proposal) — present the classified cluster list for user confirmation, then execute writes as normal (Phases 4-7).
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 99
- Forks
- 8
- Last commit
- May 2026
Advanced
- Catalog kind
- skill
- Gateway key
preserve- Source
- github.com/iurykrieger/claude-bedrock