/nacl-publish -- Граф -> Docmost + Excalidraw Boards

SkillDev tools

Publishes 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.

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 rules
  • nacl-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 URLconfig.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 directoryconfig.yaml → graph.boards_dirgraph-infra/boards
Neo4j Bolt portconfig.yaml → graph.neo4j_bolt_port3587

Принцип: 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

CommandDescriptionStatus
docmostFull publish: generate md from graph, create/update all pagesImplemented
docmost-incrementalPublish only nodes changed since last syncImplemented
docmost-preview <type> <id>Preview one page in terminal (no publish)Implemented
boardsGenerate all Excalidraw boards from graphImplemented
boards-linkAdd board links/embeds to Docmost pagesImplemented
fullComplete pipeline: docmost + boards + boards-linkImplemented

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

FieldDescription
projectProject name (from graph or user input)
space_idCached 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_idCached 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_syncISO 8601 timestamp of the last full or incremental sync
pagesMap: logical page key -> {page_id, parent_page_id, content_hash, last_updated, source_type, source_id}
sectionsMap: 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

SectionGraph Sourcenacl-render command
Context MapModule nodes + DEPENDS_ON edgesmd domain-model (module overview)
Domain Model / {Entity}DomainEntity + attrs + relsmd entity <id>
UC IndexAll UseCase nodesmd uc-index
Use Cases / {UC}UseCase + steps + formsmd uc <id>
Интерфейсы / {Form}Form + fields + mappingmd form <id>
ТрассировкаBA->SA handoff edgesmd traceability
Роли и праваSystemRole + Permission nodesCustom query (see below)

Pre-flight Checks

Before any Docmost command, verify:

  1. Docmost MCP available? Call mcp__docmost__list_spaces.

    • If fails -> ERROR: Docmost MCP not available. Check that the MCP server is connected.
  2. Neo4j available? Call mcp__neo4j__read-cypher with RETURN 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)."
  3. 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.
  4. Manifest exists? Read .docmost-sync.json.

    • If missing and command is not docmost (full) -> suggest running /nacl-publish docmost first.

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:

PairSourcesAssertion
P-P1.tl/changelog.md released FR list vs graph FeatureRequestevery 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 UseCaseevery 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 propertiesif 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):

  1. Try docmost.spaces.graph.space_id and docmost.spaces.graph.root_page_id.
  2. If spaces.graph is missing, fall back to docmost.spaces.sa.space_id / spaces.sa.root_page_id (graph and SA publish into the same Docmost area in most projects).
  3. If both are empty -> mark space_id and root_page_id as "needs prompt".

Step 1b: If .docmost-sync.json does NOT exist (first run):

  1. If space_id resolved from config.yaml:
    • Validate by calling mcp__docmost__list_spaces -- confirm the space still exists.
    • Use the resolved space_id directly. Do not prompt the user.
  2. Otherwise ask the user:
    • space_id -- which Docmost space to publish into? (list spaces via mcp__docmost__list_spaces)
    • After the user chooses, suggest writing the value to config.yaml → docmost.spaces.graph.space_id so the next run is non-interactive.
  3. If root_page_id resolved from config.yaml:
    • Use it as-is, do not create a new root page.
  4. Otherwise create a new root page:
    mcp__docmost__create_page(
      title: "{project} -- Спецификация",
      content: "# {project}\n\nАвтоматически сгенерированная спецификация из графа знаний.",
      spaceId: "{space_id}"
    )
    
    Save returned id as root_page_id and suggest writing it to config.yaml → docmost.spaces.graph.root_page_id.
  5. Initialize manifest with empty pages and sections. Persist space_id / root_page_id into the manifest as cache, but config.yaml remains the source of truth on subsequent runs.

Step 1c: If .docmost-sync.json EXISTS:

  1. Read manifest.
  2. Reconcile space_id / root_page_id:
    • If config.yaml has values -> use them. If they differ from manifest, log a warning and update manifest to match config.yaml (manifest is a cache, not the source of truth).
    • If config.yaml is empty -> fall back to manifest values.
  3. Validate space_id by calling mcp__docmost__list_spaces -- confirm the space still exists.
  4. Continue with existing section pages from sections map.

Step 2: Create Section Pages

For each section in the hierarchy, create a parent page if not already in sections:

SectionTitleParent
АрхитектураАрхитектураroot_page_id
Domain ModelDomain Modelroot_page_id
Use CasesUse Casesroot_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
  1. Use nacl-render md domain-model logic to generate the full domain model overview.

  2. 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.id
    

    Generate markdown:

    # Модули
    
    | ID | Модуль | Описание | Сущностей | Use Cases |
    |----|--------|----------|-----------|-----------|
    | {id} | {name} | {description} | {entity_count} | {uc_count} |
    
  3. Publish to section Архитектура:

    • Page "Context Map" -- create_page or update_page under sections["Архитектура"]
    • Page "Модули" -- under sections["Архитектура"]
3b: Domain Model -- Entities
  1. Query all DomainEntities:

    MATCH (de:DomainEntity)
    RETURN de.id AS id, de.name AS name
    ORDER BY de.id
    
  2. For each entity, use nacl-render md entity <id> logic to generate full markdown.

  3. 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) if pages[de.id] already exists.

  4. 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
  1. Generate UC Index using nacl-render md uc-index logic.

  2. Publish as page "UC Index" under sections["Use Cases"].

  3. Query all UseCases:

    MATCH (uc:UseCase)
    RETURN uc.id AS id, uc.name AS name
    ORDER BY uc.id
    
  4. For each UC, use nacl-render md uc <id> logic to generate markdown.

  5. Publish each UC page under sections["Use Cases"].

3d: Interfaces -- Forms
  1. Query all Forms:

    MATCH (f:Form)
    RETURN f.id AS id, f.name AS name
    ORDER BY f.id
    
  2. For each form, use nacl-render md form <id> logic to generate markdown.

  3. Publish each form page under sections["Интерфейсы"].

3e: Traceability
  1. Use nacl-render md traceability logic to generate the BA->SA matrix.
  2. Publish as page "BA -> SA Matrix" under sections["Трассировка"].
3f: Roles and Permissions
  1. 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
    
  2. Generate permission matrix markdown:

    # Роли и права
    
    | Роль | Описание | BA-роль | Доступные UC |
    |------|----------|---------|--------------|
    | {name} | {description} | {ba_roles joined} | {use_cases joined} |
    
  3. Publish under sections["Роли и права"].

Step 4: Save Manifest

  1. Update last_sync to current ISO 8601 timestamp.
  2. Write .docmost-sync.json.
  3. Report summary:
Публикация завершена:
- Создано: {created_count} страниц
- Обновлено: {updated_count} страниц
- Пропущено: {skipped_count} (без изменений)
- Секций: {section_count}
- Manifest: .docmost-sync.json обновлён

Create vs Update Decision

For each page:

  1. Check if pages[key] exists in manifest.
  2. 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).
  3. 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:

  1. Section pages (parents first)
  2. Summary/index pages (Context Map, UC Index, Permission Matrix, Traceability)
  3. 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

  1. Read .docmost-sync.json.
    • If missing -> ERROR: No manifest found. Run /nacl-publish docmost first for initial sync.
  2. Extract last_sync timestamp.

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:

typeRender command logicPage key
entitynacl-render md entity <id>{id} (e.g., DE-Order)
ucnacl-render md uc <id>{id} (e.g., UC-101)
formnacl-render md form <id>{id} (e.g., FORM-OrderCreate)
roleCustom 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:

  1. Compute new content_hash.
  2. Compare with pages[key].content_hash in manifest.
  3. If different:
    • mcp__docmost__update_page(page_id, content) using page_id from manifest.
    • Update content_hash and last_updated in manifest.
  4. 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

TypeArgumentnacl-render logic
entityDomainEntity.id (e.g., DE-Order)md entity <id>
ucUseCase.id (e.g., UC-101)md uc <id>
formForm.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

  1. Validate <type> is one of the supported types.
    • If invalid -> ERROR: Unknown type "{type}". Supported: entity, uc, form, uc-index, domain-model, traceability, roles.

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