Promote the locally-staged Mnemex captures into the shared knowledge graph — the deliberate, batched, attention-heavy half of memory (the `git push`/PR to capture's `git commit`). Use this when the user wants to merge captured knowledge into the graph, says "promote", "flush staging", "merge my captures", "publish to the knowledge graph", or when a nag reports staged atoms pending / consolidation overdue. Flushes usage stamps, reconciles + merges every staged atom (clean-context sub-agent, hu...
Installs into .claude/skills of the current project.
Are you the author of Mnx Promote?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/kritird-mnx-promote)
---
name: mnx-promote
description: Promote the locally-staged Mnemex captures into the shared knowledge graph — the deliberate, batched, attention-heavy half of memory (the `git push`/PR to capture's `git commit`). Use this when the user wants to merge captured knowledge into the graph, says "promote", "flush staging", "merge my captures", "publish to the knowledge graph", or when a nag reports staged atoms pending / consolidation overdue. Flushes usage stamps, reconciles + merges every staged atom (clean-context sub-agent, human-in-the-loop on contradictions), consolidates the post-merge graph, runs the doctor, pushes, clears the promoted atoms per-atom, and holds any contradicting atom in a local queue for later HITL.
---
# mnx-promote — merge staging into the graph (the `git push`/PR of memory)
> **Speak in the graph's labels.** Payloads carry a `naming` object (`{root_label, space_label}`) —
> the words THIS graph uses for its root and its spaces (e.g. `area`, `repo`, `client`). When you echo
> where a capture routed, say `naming.space_label` ("routed to repo: checkout-svc"), never hard-code
> "team"/"org". No `naming` in the payload (older engine) → fall back to org/team. Folders stay
> `space-*` on disk — that's storage, not what you say.
Promote is the **heavy, deliberate, occasional** half of the capture/promote split. It pulls the
attention-demanding merge *out* of the creative session: capture stays cheap and local, and the
reconciliation that needs care happens here, in a batch, when the user chooses to spend the attention.
**Promote disposes per-atom.** Every *clean* staged atom reaches a terminal disposition in the cycle —
*created / merged / dropped-as-duplicate / superseded* (or *resurrected*) — and is cleared per-atom on a
confirmed persist (`mnx_stage.clear_merged`). A staged atom whose reconcile flags a **contradiction** is
**held** for HITL — `mnx_stage.hold` moves it to a local held queue (with a reason + the graph id it
contradicts) rather than aborting the whole batch, so one contentious atom cannot starve the rest. A held
atom keeps its self-sufficient provenance and is re-promotable **cold**: at a later promote the human
`release_held`s it (re-reconciled) or `drop_held`s it (graph wins). Held state lives **entirely in the
local staging tier** — there is never any in-flight state on the graph. The human may still **abort** the
whole promote (staging untouched); holding is the softer default. `held_max_age_days` (default 14) nags a
lingering held atom at session start/end.
Background: `docs/staging-and-promotion.md` (the model, the reconcile sub-agent contract),
`docs/maintenance-pass-algorithm.md` (the folded consolidate), `docs/link-reconciliation.md` (the
wiki mesh / Step 2b). Helpers: `mnx_binding`, `mnx_stamp`, `mnx_stage`, `mnx_lock`, `mnx_resolve`,
`mnx_node` (the deterministic node writer), `mnx_index`, `mnx_doctor`, `mnx_common`, `mnx_mesh`,
`mnx_phonebook`, `mnx_simindex`; internal skill:
`mnx-consolidate`.
## Preflight
1. **Locate + sync:** `mnx_binding.py status --session <sid>` (the session id from session-start, if
you have one — see mnx-init step 1; honors a mid-session graph switch). If `resolved` is false → **STOP**, point at
`/mnemex:mnx-init`. **Echo the resolved graph before merging** — this is the irreversible write, so
confirm the target: show the `resolution` line, e.g. *"Promoting into **payments-knowledge** (source:
project .mnemex.md)."* If `default_fallback` is true, flag it prominently and **confirm with the user
before merging** (*"⚠️ No project binding here — this will merge staged atoms into your personal graph
**personal-notes**. Continue?"*) so a mis-resolved promote can't silently land in the wrong graph
(LIMITATIONS.md #2). If `clone_present` is false → `mnx_binding.py sync` once. Operate on
`graph_root`; note `kind`. If staging is empty (`mnx_stage.py status` → `count == 0`), promote is
just "consolidate the graph" — say so and proceed (or stop if nothing is overdue either).
2. **Unpushed-promote guard (avoid double-apply):** if `status` reports `unpushed: true` (`ahead > 0`),
a previous promote **committed the merge but did not push**. **Do NOT start a fresh merge** — that
would re-apply staging on top of the existing commit. Go straight to the **Retry-push recovery**
below (treat it as if `--retry-push` were given). A fresh promote is only safe when `ahead == 0`.
3. **Team lock:** `mnx_lock.acquire`. If a pass is already in progress, stop and tell the user.
Recover any stranded `pass.plan.json` first.
### Retry-push recovery (`--retry-push`, or an unpushed prior promote)
The merge is already committed in the clone; only the push is missing. **Skip Steps 1–4 entirely** —
no flush, no reconcile, no consolidate, no new plan:
- `python3 "${CLAUDE_PLUGIN_ROOT}/scripts/mnx_binding.py" push`.
- On `push: ok` → **now** do the deferred settle recorded in `pass.plan.json`: `mnx_stage.py hold` each
contradicting atom, then `mnx_stage.py clear-merged --ids …` for the atoms the stranded commit
promoted (if the plan predates per-atom settle and recorded no pid split, `mnx_stage.py clear` is the
legacy fallback). Remove `pass.plan.json`, release any lock. Report success.
- On `conflict` / `failed` → surface the structured `recovery` block (its `guidance`, `clone_path`,
`branch`, and `manual_fallback` commands). **Leave staging untouched.** Do not loop a full promote.
## Step 1 — Flush usage stamps
`python3 "${CLAUDE_PLUGIN_ROOT}/scripts/mnx_stamp.py" flush` so the reconcile and consolidate decisions
below see current usage. (Safe to no-op for local-kind graphs.)
## Step 2 — Reconcile + merge staged atoms (clean-context sub-agent; HITL)
Read the whole batch with `mnx_stage.py list` / `overlay`. Reconciliation runs as a **clean-context
sub-agent** (the live session's dirty context is irrelevant — atoms carry self-sufficient provenance):
**Reconcile sub-agent contract**
- **Input:** `{ staged atoms (with provenance), graph_root }`.
- **It reads** the routed cluster indexes + a few node bodies *in its own context* — not yours.
- **It returns only** a **change plan** + the **HITL items** (contradictions, ambiguous near-matches).
It does **not** apply anything.
- **It may fork** per cluster / per org for scale. **Plan in parallel; apply serially under the team
lock** (mirrors consolidate's MARK/SWEEP).
For each staged atom the plan assigns exactly one terminal disposition — **you decide which; the node
file is written deterministically by `mnx_node.py`, never by hand** (it mints the id, stamps the clock,
and enforces the front-matter shape, so the freshness invariants hold by construction):
`CREATE` (`mnx_node.py create` — new node, real slug minted by the script) · `MERGE`/`UPDATE`
(`mnx_node.py merge --id <id> [--meaning-change]` — fold into an existing node, **the default when a fact
simply changed**; keeps the id, edits in place) · `DROP-DUP` (duplicate — discard, no write) ·
`SUPERSEDE` (**tombstone-with-successor**: `mnx_node.py supersede --old-id <id>` creates the replacement
and retires the old one — `status: dead`, `superseded-by: <new-id>`, `died` stamped, **body kept**; then
repoint every referrer to the successor. Reserve this for when the old version must survive as its own
linkable node; otherwise prefer UPDATE-in-place) · `RESURRECT` (`mnx_node.py resurrect --id <id>` — a
cold/dead match revived). Honor the **node-size budget**: an over-budget body is split into multiple
nodes + an edge (Step 2b), never truncated.
**Freshness fields on apply (Freshness & Revalidation):** `mnx_node.py` stamps `verified = now` on every
node it writes for `CREATE`/`MERGE`/`UPDATE`/`SUPERSEDE`/`RESURRECT` (it was just re-derived under the
human gate) and bumps `updated` only when you pass `--meaning-change` — you never hand-write these
timestamps. Carry the atom's proposed **`volatility`** onto the node (a `create`/`merge` field), and
**surface it in the plan for the human to confirm or override** (e.g. downgrade a fast-rotting fact to
`volatile`, or mark a definition `timeless`). Default stays `default` (type-derived horizon).
**Contradictions are held, not force-resolved.** Present every contradiction to the human. If it can be
resolved in-cycle (edit the plan, supersede, or drop), do so. If it cannot be resolved now, mark that atom
**HELD** in the plan — it is moved to the local held queue in Step 5 (`mnx_stage.hold`) while the clean
atoms promote; it keeps its provenance and is re-promotable cold at a later promote. The human may still
choose to **abort the whole promote** (staging untouched) instead; holding is the default so one atom
does not starve the batch. Never body-merge over a contradiction.
**Routing to a NEW space (the create-space gate).** Every cluster in one plan must sit in the space
this transaction locked — atoms belonging elsewhere are HELD for their own promote. Routing normally
lands atoms in an *existing* space (the `default_space` when it exists in THIS graph, or a root→space
match) — a missing *cluster* inside it is cheap
(declare it in the plan's `new_clusters`). A whole new **space** is not cheap and is never silent: if a batch fits
no existing space and the user wants its own (e.g. a new `repo`/`area`/`client` in the graph's words),
**STOP and offer it**, then create it through the gate — do not force the notes somewhere wrong. On a
yes, begin the promote with the new space named AND a one-line charter —
`promote_begin(space=space-<name>, create_space="<charter>")` (MCP) / `mnx_promote.py` begin with the
same — or run the CLI `mnx_init.py scaffold-space <root> --name <name> --desc <charter>` first: the
engine scaffolds the space (folder + router, immediately routable) **before** taking the lock, then
this whole promote drains into it. The one-line description is **required** (read routing keys off it).
Honors the graph's `space_creation` config — `off` refuses here (a `space-creation-off` guard); the
user adds spaces via the Console or CLI instead. Speak the space in the graph's label ("create a new
**repo**, checkout-svc?"), never hard-code "team".
## Step 2b — Link reconciliation (build the wiki mesh; Link Reconciliation)
After dispositions are assigned and **before** consolidate, wire the mesh. Promote — not capture — owns
this, because it is graph-aware. Full model + algorithm: `docs/link-reconciliation.md`. Helper:
`mnx_mesh`, `mnx_phonebook`, `mnx_simindex`.
1. **Split over-budget notes first.** Any staged note whose body exceeds `node_body_max_chars` is split
here into sibling pages, with a `[[sibling]]` wiki-link inserted between them — never truncated
(capture deliberately left this to you). *Where* to cut is your judgment; keep each piece a complete
idea.
2. **Propose the link plan (deterministic core):** run `mnx_mesh.plan_links(notes, team)` over the
post-disposition notes (each `{id, body, aliases, disposition}`). It:
resolves every inline `[[name]]` against the **team phonebook** — a hit previews a **live link**, a
miss a **red-link** (a link to a page that does not exist yet — normal, never an error). This is a
PREVIEW: Step 5 derives the real mesh from the bodies it writes, over the whole space, so an older
note whose red-link a new page satisfies goes live in the same run without being named here.
3. **Decide the spotted candidates (judgment — this is where the mesh comes from).** `context`'s
`link_candidates` lists pages across **every** space — and atoms in this same batch
(`target_origin: "batch"`, `target_pid` instead of `target`) — whose name appears unbracketed in an
atom's body or summary. For each, decide, then write the accepted ones as `[[wiki-links]]` **into the
body** of the note your plan submits; a batch target resolves once apply lands, so bracket the alias
as given. Judge on the evidence: a high `nodes_using_this_name` means a common
phrase and weak evidence; a non-empty `competing_targets` means two spaces claim the name — pick one
or decline; `excerpt` is the sentence it was found in. **Declining is a normal outcome** — link only
where the note genuinely means that page. `capped: true` means more were found than shown.
4. Links are **untyped by default** (wiki-native); carry an optional `type` only if the staged
`mentions[].type` set one. Never invent a type.
Surface all of this in the **one** approval plan (Step 4) as a `LINKS` section. A confirmed suggestion
is written as a `[[wiki-link]]` **in the body** of the note the plan submits — that is the only way to
create a link, because `edges:`/`mentions:` are a **generated mirror** Step 5 rebuilds from the body.
Never hand-author them; an edge with no bracket behind it is pruned on the next promote.
## Step 3 — Consolidate the post-merge graph (folded; same plan)
Invoke the **`mnx-consolidate`** skill over the now-merged graph (re-tier, death, edge hygiene, budget
split → index chaining). Surface its decisions in the **same** approval plan as the merge, so
consolidate's one HITL escape — a budget overflow that even chaining cannot resolve — is handled by the
human who is already present. This is why consolidate is promote's back half, not a separate command.
## Step 4 — One approval plan (STOP for the human)
Emit a single surgical plan covering **both** the merge and the consolidation, and **wait**:
```
PROMOTE PLAN (staged: 14 atoms)
MERGE
CREATE domain iso8583-field124 in space-payments/settlement (from stg-d3d3…) vol:default
MERGE domain ledger-routing ← stg-9af1… +edge routes-through→iso8583-field124
SUPERSEDE old-routing-note → iso8583-field124 (from stg-1b2c…)
DROP-DUP stg-77aa… (duplicate of pat-settle-recon)
⚠ CONTRADICTION stg-44ee… vs settle-cutoff-time → RESOLVE in-cycle, or HELD (promote the rest)
LINKS
link ledger-routing → iso8583-field124 (wiki-link, resolved)
red-link iso8583-field124 → [[de124-legacy-map]] (no page yet — kept latent)
+ spotted "velocity checks" → velocity-checks (space-risk, 2 nodes use this name) → ACCEPTED
– spotted "death" → death (11 nodes use this name — common word here) → DECLINED
CONSOLIDATE
RE-TIER 3 hot→warm, 1 warm→cold
DEATH legacy-de124-fmt (low score ∧ weak struct ∧ TTL expired)
CHAIN space-payments/settlement index → index.001.md (cold over budget)
```
`--dry-run` stops here. Accept or decline each spotted candidate in-cycle; an unresolved ⚠ CONTRADICTION
is marked HELD (the rest still promotes) unless the human chooses to abort the whole promote.
When apply returns, report its `mesh` line — live links, red links and the ratio. If it carries a
`suggestion`, pass it on: many red links means pages exist in *other* spaces that this promote could
not link, and `/mnemex:mnx-doctor --fix` sweeps them.
## Step 5 — Apply (serial, locked, atomic) → push → clear staging
After approval, apply the plan **serially** under the lock in fixed order (truth before derived):
1. Persist the node truth **through `mnx_node.py`, never by hand** — one call per disposition:
`mnx_node.py create` (CREATE) · `merge --id <id> [--meaning-change]` (MERGE/UPDATE) ·
`supersede --old-id <id>` (SUPERSEDE) · `resurrect --id <id>` (RESURRECT). The script mints the slug,
stamps `created`/`updated`/`verified` from the one clock, and keeps a superseded/dead node's body — so
inv 9b is satisfied by construction. Then apply consolidate's tombstones (`mnx_node.py tombstone`)
+ freshness advances (`mnx_node.py revalidate`). Nothing severs a dead node's referrers — step 2
re-derives them.
2. In this order, each stage feeding the next: regenerate the space phonebook
(`mnx_phonebook.py regenerate <space>`) from post-tombstone truth; then `mnx_mesh.py derive <space>`
— rebuilds `mentions:`/`edges:` for **every** node in the space from its body's `[[links]]`, which
is what heals older notes and drops edges to nodes that just died; then regenerate affected indexes
(`mnx_index.regenerate_index` — denormalize summary/aliases; chain the cold tier when over
`index_chunk_rows`) and `cross-links.md` from the edges derivation just wrote
(`mnx_doctor.py regen-crosslinks <graph_root>` — required whenever any cross-cluster link changed,
else Step 3's check fails inv-4); advance high-water marks; stamp `last_compaction` +
`config_version`.
3. **Doctor:** `mnx_doctor.py check <graph_root>` must pass (E == 0). (Step 2 already regenerated
cross-links via the same `_boundary_rows` derivation this check gates on, so inv-4 is satisfied.)
4. **Persist:** `mnx_binding.py persist --message "mnx-promote: <plan summary>"` — kind-aware
(git-remote → commit **+ push** with bounded retry; git-local → commit; plain-local → audit-append).
On `push: failed`/`conflict` the merge **is already committed** in the clone — **do not clear
staging** and **do not re-run the merge**. Surface the structured `recovery` block and tell the user
to run `/mnemex:mnx-promote --retry-push` (push the existing commit); if it keeps failing, the
`manual_fallback` git commands are the last resort. Stop here.
5. **Settle staging only on a confirmed persist:** move each contradicting atom to the held queue
(`mnx_stage.py hold --id <pid> --reason … --contradicts <graph-id> --binding-session <sid>`), then clear the atoms that
promoted (`mnx_stage.py clear-merged --ids <pid,pid,…> --binding-session <sid>`). Do **not** use the all-or-nothing
`mnx_stage.py clear` on the per-atom path — it would discard the held atoms too. Remove
`pass.plan.json`, release the lock. (`mnx_doctor.py check-staging` / `mnx_stage.py held-list` confirms what remains.)
## `--bulk` mode — drain a corpus ingest batch (gate #2)
`/mnemex:mnx-promote --bulk --ingest-batch <id>` is the volume-adapted promote that
[`/mnemex:mnx-ingest`](../mnx-ingest/SKILL.md) hands off to. **Same engine transaction as episodic
promote — literally the same `mnx_promote.py begin/context/apply` calls, just with `--ingest-batch <id>`
so the label-partitioned bulk batch is selected instead of the unlabeled `_session` batch** (never drains
a user's hand-captures). Only the *shape* of reconcile and the plan-drafting judgment change for scale —
the mechanics (lock, node writes, mesh, doctor gate, persist, settle) are the single tested path both
promote modes share; this SKILL does not hand-drive `mnx_node`/`mnx_lock`/`mnx_mesh`/`mnx_doctor` calls
itself for bulk (see Step 5 above — that is what `apply()` now does internally). Background:
`docs/corpus-ingestion.md` §6.
1. **Begin:** `mnx_promote.py begin --ingest-batch <id> --binding-session <sid>` (the session id from preflight; honors a mid-session graph switch) — preflight (unpushed guard, stranded-plan
recovery) then the team lock, same as Step "Preflight" above. A `guard: empty-batch` result means
nothing is staged under that id yet (stage some via `mnx_ingest`/`capture_add ingest_batch=<id>`, or
check the id). A `guard: ingest-batch` result from a *plain* `begin()` (no `--ingest-batch`) means only
bulk atoms are staged — it now **names this same command** as the fix, not a hand-driven fallback.
2. **Context + fork reconcile per cluster (judgment, unchanged from episodic).** `mnx_promote.py context
--ingest-batch <id> --binding-session <sid>` returns the batch + near-matches + cluster index + mesh preview, same shape as
episodic. The reconcile sub-agent contract already permits forking — *plan in parallel, apply serially
under the lock* — so draft the plan per cluster if the batch is large. ER already collapsed intra-batch
duplicates before staging (one entity → one node), so each fork mostly assigns CREATE/MERGE.
3. **Summarized plan (gate #2).** The approval plan collapses to **per-cluster counts** (`CREATE 214 · MERGE
31 · DROP-DUP 57`) and lists *only the exceptions in full*: **contradictions, ambiguous near-matches
(the ER `possible` band → `⚠ suggested`), and new-cluster creation.** **Auto-accept the plain
CREATE/MERGE** — there is no per-atom review at corpus scale. The plan JSON is the identical shape Step 4
describes (`dispositions`/`new_clusters`/`splits`/`links`/`consolidate`) — every pid this batch's `begin()` returned
must get exactly one disposition, same validation as episodic. Each disposition's `cluster` is a
graph-root-relative `<team>/<cluster-name>` path (a bare name is auto-prefixed with the batch's team);
declare every cluster this run creates ONCE in `new_clusters: [{path, description}]` — a corpus import
makes most of its clusters, and each description is the line the read frontier routes on; a create/supersede's unset content fields
(summary/body/aliases/domain/…) are inherited from the staged atom, so a summarized plan never
drops captured content — still write them explicitly wherever reconcile improved the wording.
4. **Apply — one transaction call.** `mnx_promote.py apply <plan> --ingest-batch <id> --binding-session <sid>`. This is the
engine's Step-5 sequence in fixed order (node writes → `mnx_mesh` links → consolidate → regen indexes/
cross-links/phonebook → doctor gate, rolls back on E>0 → `mnx_binding.py persist` → per-atom settle) —
the same call episodic promote makes, just scoped to this batch's pids. A `committed-not-pushed` result
means the merge landed but the push didn't — `/mnemex:mnx-promote --retry-push` (unchanged from episodic).
5. **Manifest write on confirmed persist (A5b, DP4).** *Only* after `apply()` returns `action: applied` (or
a subsequent `retry_push` settles it), run `mnx_ingest.py manifest-write --graph <root>
--source-slug <slug> --json`, piping `{"files": <probe's `files`>, "dispositions": <apply's
`dispositions`>, "last_commit": <sha>}`. The engine pairs them — never assemble the
file→node-ids map yourself; it is what makes the next `/mnemex:mnx-ingest` diff correctly and
name a deleted file's orphans. `apply()` already settled staging (cleared the promoted pids, held
any contradiction) — do not call `mnx_stage.py clear`/`clear-merged` by hand.
6. **Crash recovery + resume.** `begin()`'s stranded-plan recovery is generic — it covers a bulk drain
exactly as episodic promote, no bulk-specific handling needed. A partially-staged corpus (the
`ingest_max_atoms_per_run` cost ceiling hit during `mnx-ingest` PASS 1, before any promote ran) resumes
at the **ingest** layer (`--resume <ingest-batch>`, more atoms staged under the same id) — `apply()`
itself always drains everything currently staged under `--ingest-batch <id>` in one call.
**Deferred (3.4b, not a scope cut — sequenced after this):** per-sub-batch incremental consolidate with a
frozen-snapshot checkpoint *within* one very large single-run corpus (so death/re-tier math never thrashes
against a moving target while thousands of atoms drain in waves). Today one `apply()` call settles an
entire `--ingest-batch` in one consolidate pass, which is correct and sufficient for realistic repo sizes
(the `ingest_bulk_hard_atoms` cap bounds a single batch); only an exceptionally large corpus would want the
finer-grained checkpointing. Track before relying on `--bulk` for a many-thousand-atom single import.
## Never
- Never apply without the single combined plan approved by the human.
- Never overwrite on a contradiction — supersede, resolve in-cycle, or HELD it; never body-merge a winner.
- Never leave a *clean* staged atom without a terminal disposition (created/merged/dropped/superseded);
a contradicting atom that cannot be resolved now is HELD (local queue), never silently kept as staged.
- Never clear a promoted atom unless persist confirmed (push ok / committed / audit-recorded); use
per-atom `clear-merged`, not the all-or-nothing `clear`, so held atoms survive. A full abort leaves
staging untouched.
- Never leave a held atom on the graph — held state is purely local until a later promote resolves it.
- Never start a fresh merge when `status` reports `unpushed: true` — that double-applies staging over
the already-committed merge. Use `--retry-push` (push the existing commit) instead.
- Never carry a provisional `stg-…` id into the graph — promotion mints a real slug id.
- Never auto-invent folder structure on overflow — split by sub-key, then chain; escalate last.
- Never hand-author `edges:` — it is a generated mirror Step 5 rebuilds from the body's `[[wiki-links]]`.
- Never accept a spotted candidate you would not have written yourself; a red-link stays latent and
never blocks the promote, so declining costs nothing and a wrong link is a false fact.