/nacl-publish -- Граф -> Docmost + Excalidraw Boards
SkillDev toolsPublishes the graph to Docmost and generates Excalidraw boards. Use when the user asks to: publish the graph, sync with Docmost, generate boards, nacl-publish, graph publish.
Available today. Use it from your connected AI after setup.
No other account needed.
Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.
Then ask your AI: use the /nacl-publish -- Граф -> Docmost + Excalidraw Boards skill
What this skill tells your AI
The instructions your AI receives, as published by itsalt/nacl in nacl-publish/SKILL.md and read by ahel’s review.
Назначение
Публикация данных из Neo4j-графа во внешние форматы:
- Docmost -- генерация markdown из графа (через
nacl-render md) и публикация страниц в Docmost - Excalidraw Boards -- генерация визуальных бордов из графа и привязка к страницам Docmost
Neo4j Graph
|
├── nacl-render md ──> Markdown ──> Docmost Pages (/nacl-publish docmost)
|
├── nacl-render excalidraw ──> .excalidraw files (/nacl-publish boards)
|
└── Excalidraw links ──> Docmost Pages updated (/nacl-publish boards-link)
Dependencies
nacl-core/SKILL.md-- shared Neo4j connection, schema, ID rulesnacl-render/SKILL.md-- md and excalidraw rendering logic- Neo4j MCP:
mcp__neo4j__read-cypher - Docmost MCP:
mcp__docmost__create_page,mcp__docmost__update_page,mcp__docmost__list_spaces,mcp__docmost__get_page,mcp__docmost__list_pages,mcp__docmost__search
Config Resolution
| Параметр | Источник (по приоритету) | Fallback |
|---|---|---|
| Docmost API URL | config.yaml → docmost.api_url | Использовать env-настройки Docmost MCP |
space_id (graph scope) | config.yaml → docmost.spaces.graph.space_id > docmost.spaces.sa.space_id > manifest space_id | Спросить пользователя через mcp__docmost__list_spaces |
root_page_id (graph scope) | config.yaml → docmost.spaces.graph.root_page_id > docmost.spaces.sa.root_page_id > manifest root_page_id | Создать новую корневую страницу |
| Boards directory | config.yaml → graph.boards_dir | graph-infra/boards |
| Neo4j Bolt port | config.yaml → graph.neo4j_bolt_port | 3587 |
Принцип: config.yaml — первый источник для адресации Docmost. Manifest (.docmost-sync.json) отвечает только за page-level sync state (page IDs, content hashes, last_updated). Если в проекте нет отдельного spaces.graph — fallback на spaces.sa, поскольку graph и SA публикуют один и тот же набор страниц.
Invocation
/nacl-publish <command> [args]
Commands Overview
| Command | Description | Status |
|---|---|---|
docmost | Full publish: generate md from graph, create/update all pages | Implemented |
docmost-incremental | Publish only nodes changed since last sync | Implemented |
docmost-preview <type> <id> | Preview one page in terminal (no publish) | Implemented |
boards | Generate all Excalidraw boards from graph | Implemented |
boards-link | Add board links/embeds to Docmost pages | Implemented |
full | Complete pipeline: docmost + boards + boards-link | Implemented |
Manifest: .docmost-sync.json
Located at project root (next to graph-infra/). This file tracks synchronization state between the graph and Docmost.
Structure
{
"project": "project-name",
"space_id": "019cd479-...",
"root_page_id": "019cd6de-...",
"last_sync": "2026-03-20T14:30:00Z",
"pages": {
"DE-Order": {
"page_id": "019cd6f0-...",
"parent_page_id": "019cd6df-33de-...",
"content_hash": "sha256:a1b2c3d4...",
"last_updated": "2026-03-20T14:30:00Z",
"source_type": "entity",
"source_id": "DE-Order"
},
"UC-101": {
"page_id": "019cd6f1-...",
"parent_page_id": "019cd6df-4348-...",
"content_hash": "sha256:e5f6a7b8...",
"last_updated": "2026-03-20T14:30:00Z",
"source_type": "uc",
"source_id": "UC-101"
}
},
"sections": {
"Архитектура": "019cd6df-2475-...",
"Domain Model": "019cd6df-33de-...",
"Use Cases": "019cd6df-4348-...",
"Интерфейсы": "019cd6df-5512-...",
"Трассировка": "019cd6df-6623-...",
"Роли и права": "019cd6df-7734-..."
}
}
Fields
| Field | Description |
|---|---|
project | Project name (from graph or user input) |
space_id | Cached Docmost space ID. Source of truth: config.yaml → docmost.spaces.graph.space_id (fallback spaces.sa.space_id). Manifest stores it for offline reference; config.yaml wins on conflict. |
root_page_id | Cached root page ID. Source of truth: config.yaml → docmost.spaces.graph.root_page_id (fallback spaces.sa.root_page_id). Manifest stores it for offline reference; config.yaml wins on conflict. |
last_sync | ISO 8601 timestamp of the last full or incremental sync |
pages | Map: logical page key -> {page_id, parent_page_id, content_hash, last_updated, source_type, source_id} |
sections | Map: section name -> Docmost page ID (section pages serve as parents) |
Content Hash
Compute SHA-256 of the generated markdown content (trimmed trailing whitespace per line):
echo "$CONTENT" | sed 's/[[:space:]]*$//' | shasum -a 256 | cut -d' ' -f1
Page Structure in Docmost
The hierarchy mirrors the project's artifact types, organized by modules:
{Project}/
├── Архитектура/
│ ├── Context Map
│ └── Модули
├── Domain Model/
│ ├── {Entity1}
│ ├── {Entity2}
│ └── ...
├── Use Cases/
│ ├── UC Index
│ ├── {UC-101}
│ ├── {UC-102}
│ └── ...
├── Интерфейсы/
│ ├── {Form1}
│ ├── {Form2}
│ └── ...
├── Трассировка/
│ └── BA -> SA Matrix
└── Роли и права/
└── Permission Matrix
Section-to-Graph Mapping
| Section | Graph Source | nacl-render command |
|---|---|---|
| Context Map | Module nodes + DEPENDS_ON edges | md domain-model (module overview) |
| Domain Model / {Entity} | DomainEntity + attrs + rels | md entity <id> |
| UC Index | All UseCase nodes | md uc-index |
| Use Cases / {UC} | UseCase + steps + forms | md uc <id> |
| Интерфейсы / {Form} | Form + fields + mapping | md form <id> |
| Трассировка | BA->SA handoff edges | md traceability |
| Роли и права | SystemRole + Permission nodes | Custom query (see below) |
Pre-flight Checks
Before any Docmost command, verify:
-
Docmost MCP available? Call
mcp__docmost__list_spaces.- If fails ->
ERROR: Docmost MCP not available. Check that the MCP server is connected.
- If fails ->
-
Neo4j available? Call
mcp__neo4j__read-cypherwithRETURN 1.- If fails ->
ERROR:+ "Neo4j is not reachable at bolt://localhost:{$neo4j_bolt_port}. Tell me "start the graph" (I will run node "$HOME/.claude/skills/nacl-core/scripts/graph-doctor.mjs" --fix) or start it from the project root: docker compose -f graph-infra/docker-compose.yml up -d (local) / ~/.nacl/sidecar/<project_scope>.sh (remote)."
- If fails ->
-
Graph has data? Query:
MATCH (n) WITH labels(n) AS lbls, count(*) AS cnt UNWIND lbls AS lbl RETURN lbl, sum(cnt) AS total ORDER BY lbl- If empty ->
WARNING: Graph is empty. Run /nacl-ba-from-board or seed data first.
- If empty ->
-
Manifest exists? Read
.docmost-sync.json.- If missing and command is not
docmost(full) -> suggest running/nacl-publish docmostfirst.
- If missing and command is not
Pre-publish reconciliation gate
nacl-publish writes externally-visible artifacts (Docmost pages,
Excalidraw boards). Publishing inconsistent state to Docmost makes
the drift visible to stakeholders and harder to retract than an
internal .tl/ artifact. This gate is mandatory before any
Docmost write (full docmost, docmost-incremental, and
boards-link commands). Preview-only commands (docmost-preview)
are exempt.
The gate compares the live graph (the source publish is about
to derive from) against .tl/changelog.md (the record of what has
been shipped). If these two disagree, the publish is refused.
Live graph reads only — no .cypher export fallback. Exports
are stale by definition the moment the next graph mutation lands,
and a publish run that consumed an export would push stale pages to
Docmost. If the graph container is unreachable, the gate emits
Status: BLOCKED with workflow detail graph_unavailable and the
publish refuses. The fix is to bring the graph container up
(docker compose up -d from graph-infra/), not to swap in an
export. Operators who must publish under an unreachable graph file
a W4 signed exception against gate graph-stale — the exception
does NOT re-enable export fallback; it accepts that the publish
ran against unreconciled state.
Step 1: Reach the live graph
Use the project-resolved Bolt endpoint:
RETURN 1 AS ok
On failure: HALT.
HALT — graph_unavailable (pre-publish reconciliation).
The live Neo4j graph is unreachable. A stale .cypher export is
NOT an acceptable substitute — publishing it to Docmost would
push out-of-date pages to stakeholders.
Resolution: bring the project graph container up and rerun, or
file a signed exception (.tl/exceptions/) against gate
`graph-stale`.
Status: BLOCKED (workflow detail: graph_unavailable)
Step 2: Read graph state and changelog
// Single round-trip read of the comparison surface:
OPTIONAL MATCH (fr:FeatureRequest)
WITH collect({ id: fr.id, release_tag: fr.release_tag }) AS fr_list
OPTIONAL MATCH (uc:UseCase)
WITH fr_list, collect(uc.id) AS uc_list
OPTIONAL MATCH (t:Task)
WITH fr_list, uc_list, collect({
id: t.id, release_tag: t.release_tag,
evidence: coalesce(t.verification_evidence, '')
}) AS task_list
RETURN fr_list, uc_list, task_list
Read .tl/changelog.md and parse the most recent released
section: every FR-NNN / UC-NNN mentioned, the release tag
header, and any release-status.json cross-reference.
Step 3: Cross-checks (publish-scope subset)
Reuses pairs from the W5 reconciliation taxonomy but scoped to the publish target:
| Pair | Sources | Assertion |
|---|---|---|
| P-P1 | .tl/changelog.md released FR list vs graph FeatureRequest | every released FR-NNN in the latest changelog section exists as FeatureRequest {id: 'FR-NNN'} in the live graph. |
| P-P2 | .tl/changelog.md released UC list vs graph UseCase | every released UC-NNN in the latest changelog section exists as UseCase {id: 'UC-NNN'}. |
| P-P3 | .tl/release-status.json.release_tag vs graph release_tag properties | if the JSON release tag is non-null, the live graph carries that tag on ≥1 FeatureRequest or Task node. (Skip if .tl/release-status.json is absent — the gate does not require it; absence is logged.) |
An assertion that fails under no active signed exception is a hard refusal.
Step 4: Refuse on disagreement
If any of P-P1 … P-P3 fails, refuse the publish:
REFUSED — changelog and live graph disagree.
nacl-publish writes externally-visible Docmost pages and refuses
to publish stale or contradictory state.
P-P1 changelog.md vs live graph FeatureRequest
.tl/changelog.md (section "0.18.0 ...") references FR-007;
live graph has NO FeatureRequest {id: 'FR-007'}.
Active signed exceptions against `graph-stale`: none.
Resolution options:
[1] Run /nacl-tl-conductor (or /nacl-sa-feature FR-007) to
reconcile graph state with the changelog claim.
[2] If the changelog is correct and the graph is genuinely
stale, file a signed exception against `graph-stale` and
rerun.
Status: BLOCKED (workflow detail: publish-drift)
Step 5: Record reconciliation evidence
On PASS (or PASS-under-exception), write the publish-side reconciliation evidence:
.tl/reconciliation/<ISO-8601-utc>-publish.json
Same schema as the conductor's reconciliation artifact
(see /home/project-owner/projects/NaCl/.tl/reconciliation/ _template.json) but with sources_checked scoped to the publish
subset and terminal_status recorded against the publish gate
specifically.
Only on terminal_status == VERIFIED does the publish proceed to
the actual Docmost write steps.
Command: /nacl-publish docmost
Full publish -- generate markdown from graph for every artifact and create/update all pages in Docmost.
Workflow
Step 1: Init/Load Step 2: Create Step 3: Generate & Step 4: Save
── manifest ──── -> ── section pages ── -> ── publish pages ── -> ── manifest ──
Step 1: Resolve Docmost target, then Initialize or Load Manifest
Step 1a: Read config.yaml → docmost (first source for space_id / root_page_id):
- Try
docmost.spaces.graph.space_idanddocmost.spaces.graph.root_page_id. - If
spaces.graphis missing, fall back todocmost.spaces.sa.space_id/spaces.sa.root_page_id(graph and SA publish into the same Docmost area in most projects). - If both are empty -> mark
space_idandroot_page_idas "needs prompt".
Step 1b: If .docmost-sync.json does NOT exist (first run):
- If
space_idresolved fromconfig.yaml:- Validate by calling
mcp__docmost__list_spaces-- confirm the space still exists. - Use the resolved
space_iddirectly. Do not prompt the user.
- Validate by calling
- Otherwise ask the user:
space_id-- which Docmost space to publish into? (list spaces viamcp__docmost__list_spaces)- After the user chooses, suggest writing the value to
config.yaml → docmost.spaces.graph.space_idso the next run is non-interactive.
- If
root_page_idresolved fromconfig.yaml:- Use it as-is, do not create a new root page.
- Otherwise create a new root page:
Save returnedmcp__docmost__create_page( title: "{project} -- Спецификация", content: "# {project}\n\nАвтоматически сгенерированная спецификация из графа знаний.", spaceId: "{space_id}" )idasroot_page_idand suggest writing it toconfig.yaml → docmost.spaces.graph.root_page_id. - Initialize manifest with empty
pagesandsections. Persistspace_id/root_page_idinto the manifest as cache, butconfig.yamlremains the source of truth on subsequent runs.
Step 1c: If .docmost-sync.json EXISTS:
- Read manifest.
- Reconcile
space_id/root_page_id:- If
config.yamlhas values -> use them. If they differ from manifest, log a warning and update manifest to matchconfig.yaml(manifest is a cache, not the source of truth). - If
config.yamlis empty -> fall back to manifest values.
- If
- Validate
space_idby callingmcp__docmost__list_spaces-- confirm the space still exists. - Continue with existing section pages from
sectionsmap.
Step 2: Create Section Pages
For each section in the hierarchy, create a parent page if not already in sections:
| Section | Title | Parent |
|---|---|---|
| Архитектура | Архитектура | root_page_id |
| Domain Model | Domain Model | root_page_id |
| Use Cases | Use Cases | root_page_id |
| Интерфейсы | Интерфейсы | root_page_id |
| Трассировка | Трассировка | root_page_id |
| Роли и права | Роли и права | root_page_id |
For each section not in sections map:
mcp__docmost__create_page(
title: "{section title}",
content: "# {section title}\n\nРаздел спецификации.",
spaceId: "{space_id}",
parentPageId: "{root_page_id}"
)
Save returned id to sections[title].
Order of creation: Архитектура, Domain Model, Use Cases, Интерфейсы, Трассировка, Роли и права. Sequential calls (Docmost does not guarantee ordering with parallel requests).
Step 3: Generate Content and Publish Pages
Process artifacts in this order:
3a: Architecture -- Context Map
-
Use
nacl-render md domain-modellogic to generate the full domain model overview. -
Also generate a module list page:
Query modules:
MATCH (m:Module) OPTIONAL MATCH (m)-[:CONTAINS_ENTITY]->(de:DomainEntity) OPTIONAL MATCH (m)-[:CONTAINS_UC]->(uc:UseCase) RETURN m.id AS id, m.name AS name, m.description AS description, count(DISTINCT de) AS entity_count, count(DISTINCT uc) AS uc_count ORDER BY m.idGenerate markdown:
# Модули | ID | Модуль | Описание | Сущностей | Use Cases | |----|--------|----------|-----------|-----------| | {id} | {name} | {description} | {entity_count} | {uc_count} | -
Publish to section
Архитектура:- Page "Context Map" --
create_pageorupdate_pageundersections["Архитектура"] - Page "Модули" -- under
sections["Архитектура"]
- Page "Context Map" --
3b: Domain Model -- Entities
-
Query all DomainEntities:
MATCH (de:DomainEntity) RETURN de.id AS id, de.name AS name ORDER BY de.id -
For each entity, use
nacl-render md entity <id>logic to generate full markdown. -
Publish each entity page under
sections["Domain Model"]:mcp__docmost__create_page( title: "{de.name}", content: "{generated_markdown}", spaceId: "{space_id}", parentPageId: "{sections['Domain Model']}" )or
mcp__docmost__update_page(pageId, content)ifpages[de.id]already exists. -
Save to
pages:"{de.id}": { "page_id": "{returned_id}", "parent_page_id": "{sections['Domain Model']}", "content_hash": "sha256:...", "last_updated": "{now_iso}", "source_type": "entity", "source_id": "{de.id}" }
3c: Use Cases -- Index + Individual UCs
-
Generate UC Index using
nacl-render md uc-indexlogic. -
Publish as page "UC Index" under
sections["Use Cases"]. -
Query all UseCases:
MATCH (uc:UseCase) RETURN uc.id AS id, uc.name AS name ORDER BY uc.id -
For each UC, use
nacl-render md uc <id>logic to generate markdown. -
Publish each UC page under
sections["Use Cases"].
3d: Interfaces -- Forms
-
Query all Forms:
MATCH (f:Form) RETURN f.id AS id, f.name AS name ORDER BY f.id -
For each form, use
nacl-render md form <id>logic to generate markdown. -
Publish each form page under
sections["Интерфейсы"].
3e: Traceability
- Use
nacl-render md traceabilitylogic to generate the BA->SA matrix. - Publish as page "BA -> SA Matrix" under
sections["Трассировка"].
3f: Roles and Permissions
-
Query SystemRoles:
MATCH (sr:SystemRole) OPTIONAL MATCH (sr)<-[:ACTOR]-(uc:UseCase) OPTIONAL MATCH (sr)<-[:MAPPED_TO]-(br:BusinessRole) RETURN sr.id AS id, sr.name AS name, sr.description AS description, collect(DISTINCT uc.id) AS use_cases, collect(DISTINCT br.full_name) AS ba_roles ORDER BY sr.id -
Generate permission matrix markdown:
# Роли и права | Роль | Описание | BA-роль | Доступные UC | |------|----------|---------|--------------| | {name} | {description} | {ba_roles joined} | {use_cases joined} | -
Publish under
sections["Роли и права"].
Step 4: Save Manifest
- Update
last_syncto current ISO 8601 timestamp. - Write
.docmost-sync.json. - Report summary:
Публикация завершена:
- Создано: {created_count} страниц
- Обновлено: {updated_count} страниц
- Пропущено: {skipped_count} (без изменений)
- Секций: {section_count}
- Manifest: .docmost-sync.json обновлён
Create vs Update Decision
For each page:
- Check if
pages[key]exists in manifest. - If yes -- page already published:
- Generate markdown, compute content_hash.
- If content_hash matches manifest -> SKIP (no changes).
- If content_hash differs -> UPDATE via
mcp__docmost__update_page(page_id, content).
- If no -> CREATE via
mcp__docmost__create_page(title, content, spaceId, parentPageId).
Publishing Pace
Publish pages sequentially with a brief pause between API calls (Docmost does not guarantee ordering with parallel requests). Process order:
- Section pages (parents first)
- Summary/index pages (Context Map, UC Index, Permission Matrix, Traceability)
- Individual artifact pages (entities, UCs, forms) -- alphabetically within each section
Command: /nacl-publish docmost-incremental
Publish only graph nodes that changed since the last sync.
Workflow
Step 1: Load manifest Step 2: Detect Step 3: Regenerate Step 4: Publish
── & timestamps ──── -> ── changed nodes ── -> ── markdown ────── -> ── & save ─────
Step 1: Load Manifest
- Read
.docmost-sync.json.- If missing ->
ERROR: No manifest found. Run /nacl-publish docmost first for initial sync.
- If missing ->
- Extract
last_synctimestamp.
Step 2: Detect Changed Nodes
Query Neo4j for nodes modified after last_sync. All graph nodes should have an updated_at property (ISO 8601 string or epoch).
// Entities changed since last sync
WITH datetime($lastSync) AS since
MATCH (de:DomainEntity)
WHERE datetime(de.updated_at) > since
RETURN 'entity' AS type, de.id AS id, de.name AS name
UNION ALL
// UseCases changed (or their steps changed)
WITH datetime($lastSync) AS since
MATCH (uc:UseCase)
WHERE datetime(uc.updated_at) > since
RETURN 'uc' AS type, uc.id AS id, uc.name AS name
UNION ALL
// Also catch UCs whose ActivitySteps changed
WITH datetime($lastSync) AS since
MATCH (as_step:ActivityStep)
WHERE datetime(as_step.updated_at) > since
WITH as_step
MATCH (uc:UseCase)-[:HAS_STEP]->(as_step)
RETURN DISTINCT 'uc' AS type, uc.id AS id, uc.name AS name
UNION ALL
// Forms changed (or their fields changed)
WITH datetime($lastSync) AS since
MATCH (f:Form)
WHERE datetime(f.updated_at) > since
RETURN 'form' AS type, f.id AS id, f.name AS name
UNION ALL
WITH datetime($lastSync) AS since
MATCH (ff:FormField)
WHERE datetime(ff.updated_at) > since
WITH ff
MATCH (f:Form)-[:HAS_FIELD]->(ff)
RETURN DISTINCT 'form' AS type, f.id AS id, f.name AS name
UNION ALL
// Roles changed
WITH datetime($lastSync) AS since
MATCH (sr:SystemRole)
WHERE datetime(sr.updated_at) > since
RETURN 'role' AS type, sr.id AS id, sr.name AS name
Fallback if nodes lack updated_at: regenerate markdown for all pages in manifest, compare content_hash, and publish only those with changed hashes. Report that updated_at properties are missing and recommend adding them.
Step 3: Regenerate Affected Pages
For each changed node, call the appropriate nacl-render md logic:
| type | Render command logic | Page key |
|---|---|---|
entity | nacl-render md entity <id> | {id} (e.g., DE-Order) |
uc | nacl-render md uc <id> | {id} (e.g., UC-101) |
form | nacl-render md form <id> | {id} (e.g., FORM-OrderCreate) |
role | Custom role query (see docmost Step 3f) | Regenerate full roles page |
Also regenerate cross-cutting pages if any entity or UC changed:
- UC Index -- if any UC changed
- Traceability -- if any entity or UC changed
- Context Map -- if any entity changed (module composition may shift)
Step 4: Publish and Save
For each regenerated page:
- Compute new content_hash.
- Compare with
pages[key].content_hashin manifest. - If different:
mcp__docmost__update_page(page_id, content)usingpage_idfrom manifest.- Update
content_hashandlast_updatedin manifest.
- If same -> skip (no actual change despite node timestamp update).
Update last_sync and save manifest. Report:
Инкрементальная синхронизация:
- Проверено: {checked_count} узлов
- Обновлено: {updated_count} страниц
- Пропущено: {skipped_count} (content_hash совпадает)
- Новых: {created_count} (узел добавлен после прошлого sync)
- Manifest: .docmost-sync.json обновлён
Command: /nacl-publish docmost-preview <type> <id>
Preview a single page in the terminal without publishing to Docmost.
Supported Types
| Type | Argument | nacl-render logic |
|---|---|---|
entity | DomainEntity.id (e.g., DE-Order) | md entity <id> |
uc | UseCase.id (e.g., UC-101) | md uc <id> |
form | Form.id (e.g., FORM-OrderCreate) | md form <id> |
uc-index | (no id needed) | md uc-index |
domain-model | (no id needed) | md domain-model |
traceability | (no id needed) | md traceability |
roles | (no id needed) | Custom roles query |
Workflow
- Validate
<type>is one of the supported types.- If invalid ->
ERROR: Unknown type "{type}". Supported: entity, uc, form, uc-index, domain-model, traceability, roles.
- If invalid ->
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 27
- Forks
- 4
- Last commit
- Sep 2026
Advanced
- Item type
- skill
- Key
nacl-publish- Source
- github.com/itsalt/nacl