Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Installation

MarkSpec is a single self-contained binary plus an optional VS Code extension that bundles it. Pick the onboarding path that matches how you work.

Choose your path

Path 1 — VS Code + Copilot (2 steps)

You want inline diagnostics and Copilot-assisted authoring. No separate CLI install needed — the extension bundles the markspec binary.

  1. Install the MarkSpec extension (driftsys.markspec-ide) from the Marketplace (see VS Code Marketplace below).
  2. Open a folder that contains .md files. The extension activates, starts the language server, and registers its MCP server for Copilot automatically.

Path 2 — Claude Code (4 steps)

You want the CLI, the editor extension, and the MarkSpec MCP server wired into Claude Code.

  1. Install the CLI (see Binary install below).
  2. Install the MarkSpec extension from the Marketplace or Open VSX.
  3. In your project root, run markspec init (see Project setup with markspec init). This writes .mcp.json so Claude Code discovers the MarkSpec MCP server.
  4. Restart Claude Code (or reload the window) so it picks up .mcp.json.

Path 3 — opencode (4 steps)

Same as Claude Code, but step 3’s markspec init writes opencode.json instead of .mcp.json, and step 4 reloads opencode.

  1. Install the CLI (see Binary install).
  2. Install the MarkSpec extension from the Marketplace or Open VSX.
  3. Run markspec init in your project root — it detects opencode and writes opencode.json.
  4. Reload opencode so it picks up the MCP server.

VS Code extension

The MarkSpec extension (driftsys.markspec-ide) bundles the language server directly, giving you real-time diagnostics, completions, and go-to-definition with zero extra configuration.

VS Code Marketplace

From the Extensions panel:

  1. Open the Extensions panel (Ctrl+Shift+X / Cmd+Shift+X).
  2. Search for MarkSpec.
  3. Click Install on driftsys.markspec-ide.

Or from the command line:

code --install-extension driftsys.markspec-ide

Open VSX

For editors that use the Open VSX registry (VSCodium, Cursor, Gitpod, …) the same extension is published at https://open-vsx.org/extension/driftsys/markspec-ide:

# any editor whose CLI is configured against Open VSX
codium --install-extension driftsys.markspec-ide

See VS Code extension for the full feature list, settings reference, and troubleshooting.

Binary install

A self-contained binary without any runtime requirement.

macOS / Linux (install script)

curl -fsSL https://raw.githubusercontent.com/driftsys/markspec/main/install.sh | sh

The script:

  1. Detects your platform and architecture.
  2. Downloads the release binary from GitHub Releases.
  3. Verifies the SHA256 checksum.
  4. Places the binary in ~/.local/bin.

Add ~/.local/bin to your PATH if it is not already there:

# bash / zsh
export PATH="$HOME/.local/bin:$PATH"

Windows (PowerShell install script)

Run in PowerShell 5.1 (ships with Windows 10/11) or PowerShell 7+:

irm https://raw.githubusercontent.com/driftsys/markspec/main/install.ps1 | iex

The script:

  1. Verifies the host is x86_64. ARM Windows is not supported yet.
  2. Downloads markspec-x86_64-pc-windows-msvc.tar.gz and its SHA-256.
  3. Verifies the checksum with Get-FileHash.
  4. Extracts markspec.exe with tar (bundled on Windows 10 1803+).
  5. Places the binary in %USERPROFILE%\.local\bin (override with the MARKSPEC_INSTALL_DIR environment variable).

The installer does not modify your PATH automatically. If markspec is not on your PATH, the script prints both a session-scope and user-scope command. To make the install permanent:

[Environment]::SetEnvironmentVariable(
  'Path',
  "$HOME\.local\bin;" + [Environment]::GetEnvironmentVariable('Path', 'User'),
  'User'
)

Open a new terminal and run markspec --version to verify.

Antivirus / SmartScreen. On the first run a freshly downloaded markspec.exe may trigger a SmartScreen or AV prompt because the binary is not yet Authenticode-signed (#403 tracks code signing). Allow it once and the prompt does not return.

Manual download

Pre-built binaries are attached to every GitHub Release. Download the archive for your platform, extract the markspec binary, and place it anywhere on your PATH.

PlatformFile
macOS (Apple)markspec-aarch64-apple-darwin.tar.gz
macOS (Intel)markspec-x86_64-apple-darwin.tar.gz
Linux (x86_64)markspec-x86_64-unknown-linux-gnu.tar.gz
Linux (aarch64)markspec-aarch64-unknown-linux-gnu.tar.gz
Windows (x86_64)markspec-x86_64-pc-windows-msvc.tar.gz

Deno (from source)

If you already use Deno and prefer running from source:

deno install -g jsr:@driftsys/markspec

Binary is placed in ~/.deno/bin. Run without installing:

deno run jsr:@driftsys/markspec --help

Verify

markspec --version
# markspec <version> (core-schema <n>)

Project setup with markspec init

Once the CLI is installed, scaffold a project in one command:

markspec init

In an empty (or existing) project root this writes:

  • project.yaml — minimal project metadata
  • .markspec.yaml — profile chain (defaults to the bundled profile)
  • markspec.lock — toolchain floor pinned to the running CLI’s minor version
  • .vscode/extensions.json — recommends driftsys.markspec-ide
  • MCP config for each detected agent client:
    • Claude Code.mcp.json at the repo root
    • opencodeopencode.json at the repo root
  • The MarkSpec skills bundle via upskill add (skipped with a warning if upskill is not installed)

VS Code + Copilot needs no MCP file — the bundled extension handles that wiring once the .vscode/extensions.json recommendation is in place.

Non-default profiles need markspec lock. init writes markspec.lock with no upstreams — it does not fetch or resolve the profile chain (init performs no network I/O). With the bundled default profile that stub is the final, correct lock. With a git or local profile (--profile git+… / --profile ./path), init warns LOCKFILE_STUB_NEEDS_PIN; run markspec lock once to resolve and pin the profile’s upstreams.

See AI agents and skillset for the full markspec init flag reference (--client, --profile) and MCP server details.

AI assistant skillset (upskill)

MarkSpec ships a Claude Code skillset that teaches AI assistants the MarkSpec authoring conventions — entry block syntax, EARS patterns, the agent write loop, and traceability review.

Install it with upskill:

upskill add driftsys/markspec:skills/markspec-core.bundle.yaml

This registers the following skills under .claude/, .github/, .opencode/, and .agents/:

SkillPurpose
markspec-entry-authoringEntry block syntax, shapes, attributes
markspec-core-rulesValidation rules and diagnostic codes
markspec-write-loopThe insert → fmt → check agent loop
markspec-gherkinGWT / Gherkin pattern for test entries
markspec-traceability-reviewCross-file link review agent
markspec-profile-bundle-authoringWriting and publishing profile manifests

See AI agents and skillset for MCP server setup and how to use the skills from Claude Code or Claude Desktop.

Quickstart

Get MarkSpec running in 15 minutes. This chapter walks a single linear path from install to a rendered HTML book with entry blocks and a traceability link.

Step 1 — Install (2 min)

Downloads a self-contained binary — no Deno or Node.js required at runtime:

curl -fsSL https://raw.githubusercontent.com/driftsys/markspec/main/install.sh | sh

The installer detects your platform (macOS or Linux), downloads the release binary, verifies the SHA256 checksum, and places it in ~/.local/bin. Add that directory to your PATH if it is not already there.

Deno runtime (for Deno developers)

If you already have Deno installed and want to run MarkSpec from source:

deno install -g jsr:@driftsys/markspec

If markspec is not found after install, add ~/.deno/bin to your PATH.

Run without installing: deno run jsr:@driftsys/markspec --help

Verify the install

markspec --version

Step 2 — Create a project (1 min)

mkdir my-project && cd my-project
markspec init

markspec init writes project.yaml (minimal project metadata), .markspec.yaml (activates the bundled default profile), and markspec.lock, plus editor/agent config for any detected clients. See AI agents and skillset for the full list of generated files and client-targeting flags.

Prefer to set it up by hand? Two files are all you need. Create project.yaml:

name: my-project
version: "1.0.0"

Create .markspec.yaml (activates the default profile):

profiles: []

That is enough to start. Both files are picked up automatically when you run any MarkSpec command from inside my-project/.

Step 3 — Author three entries (5 min)

Create requirements.md with a stakeholder requirement, a software requirement, and a traceability link connecting them:

# Requirements

- [STK_PRJ_0001] System availability

  The system shall be available 99.9% of the time.

- [SRS_PRJ_0001] Health check endpoint

  The service shall expose a `/health` endpoint returning `200 OK`.

      Satisfies: STK_PRJ_0001

An entry is a Markdown list item where:

  • The display ID in […] identifies the entry (STK_PRJ_0001).
  • The rest of the list-item text is the title (System availability).
  • The indented paragraph underneath is the body.
  • An indented code block at the end holds attributes (Satisfies:).

Step 4 — Format: stamp ULIDs (2 min)

Run markspec fmt to assign a permanent ULID to each entry:

markspec fmt requirements.md
info: requirements.md:3 assigned Id: 01KPVVC9J2B1ZA64QZEMHF02PW to STK_PRJ_0001
info: requirements.md:9 assigned Id: 01KPVVC9J2B1ZA64QZEMHF02PX to SRS_PRJ_0001
2 file(s) formatted, 0 unchanged (2 total)

Your file now has Id: attributes:

- [STK_PRJ_0001] System availability

  The system shall be available 99.9% of the time.

      Id: 01KPVVC9J2B1ZA64QZEMHF02PW

- [SRS_PRJ_0001] Health check endpoint

  The service shall expose a `/health` endpoint returning `200 OK`.

      Id: 01KPVVC9J2B1ZA64QZEMHF02PX
      Satisfies: STK_PRJ_0001

The ULID is a universally unique, immutable identifier. Once assigned it never changes — even if the display ID or title is renamed.

Step 5 — Check: verify traceability (2 min)

Run markspec check to confirm the link is intact:

markspec check requirements.md

No output means validation passed (exit code 0).

Now break a reference to see a failure:

# temporarily change "Satisfies: STK_PRJ_0001" → "Satisfies: STK_PRJ_9999"
markspec check requirements.md
error[MSL-R080]: requirements.md:13 unresolved reference: STK_PRJ_9999

Restore the correct reference before continuing.

Step 6 — Build: render an HTML book (2 min)

Create a minimal SUMMARY.md:

# Summary

- [Requirements](requirements.md)

Build the site:

markspec book build
wrote _site/requirements.html
wrote _site/index.html

Step 7 — View the output (1 min)

Open _site/index.html in a browser. The entry blocks render with type-based styling, display IDs, and clickable traceability links.

Editor integration

The MarkSpec VS Code extension (editors/vscode/ in the repository) provides real-time diagnostics, entry block completion, and go-to-definition — all backed by the same binary you just installed. See CLI guide — Editor and LSP integration for setup instructions covering VS Code, Neovim, and other editors.

Next steps

I want to…Go to
Understand entries, shapes, and typesConcepts
See all CLI flags and subcommandsCLI guide
Set up a compliance profileProfile guide
Browse answers to common questionsFAQ

Concepts

Content coming in a future release.

This chapter will cover the MarkSpec mental model: entries, shapes (Authored and Reference), types, traceability links, profiles, and listing directives. It is the skimmable reference all three audiences (architect, developer, compliance lead) read before diving into their audience-specific chapter.

This chapter will explain:

  • Entries — the fundamental unit: a display ID, a title, a body, and a trailer block of attributes.
  • Shapes — Authored entries (identified by a ULID stamped by markspec format) versus Reference entries (citations to external artefacts such as standards or upstream requirements).
  • Types — the vocabulary layer declared by profiles; each type carries a display-ID pattern, allowed attributes, and traceability rules.
  • Traceability links — directed edges in the requirement graph, created by trace attributes (Satisfies:, Derived-from:, Verified-by:, etc.).
  • Profiles — layered manifests that declare type vocabulary, attribute rules, and display-ID patterns for a project or compliance standard.
  • Listing directivesglossary, components, and references directives that generate structured listing documents from the entry graph.

Until this chapter ships, the canonical source is docs/spec/internal/markspec-core-data-model.md (§1 — the two-layer model and type system).

Authoring guide

Content coming in a future release.

This chapter is the primary resource for architects who structure a project’s requirements and architecture documentation. Its content depends on listing-directive support and profile-schema implementation, which are not yet shipped on main.

This chapter will cover:

  • Project layout — recommended directory structure for multi-domain requirements documents.
  • Entry authoring workflow — write, format, validate, review; the canonical insert → fmt → check loop for AI agents and human authors.
  • Traceability graph design — how to structure Satisfies: chains across stakeholder requirements, software requirements, and test entries.
  • Listing documents — authoring glossary.md, components.md, and references.md using the listing directives.
  • Profile-driven type vocabulary — declaring and using project-specific entry types.
  • In-code specs — embedding MarkSpec entry blocks in Rust /// and Kotlin /** doc comments; the V-model colocated test convention.

Until this chapter ships, refer to AGENTS.md (§Test conventions — V-model) for the in-code spec pattern, and the CLI guide for the authoring commands.

Profile guide

Full profile guide coming in a future release.

This chapter is the primary resource for compliance leads who stack profiles to map a project to ASPICE, ISO 26262, or other standards. Its detailed content depends on the profile-schema implementation, which is in progress.

This chapter will cover profile stacking, ASPICE mapping, and ISO 26262 alignment. The current section documents how to bind a profile to a project — the configuration that is already shipped.


.markspec.yaml

The .markspec.yaml file binds a project to one or more profile manifests. It sits next to project.yaml in the project root.

Example

profiles:
  - ./profiles/markspec.yaml

Profile specifiers

Three schemes are supported. The leaf specifier in .markspec.yaml and the extends: value inside a manifest both accept the same three forms.

Local path — relative to .markspec.yaml:

profiles:
  - ./profiles/markspec.yaml
  - ../shared/markspec.yaml

Git — shallow + sparse clone, cached under the global cache directory. Authentication is inherited from your git configuration, so private hosts (including corporate GitLab) work without extra setup:

profiles:
  # HTTPS, single-profile repo, tag-pinned
  - git+https://gitlab.example.com/platform/compliance-profile.git#v1.0.0
  # HTTPS with subpath, for monorepos holding several profiles
  - git+https://github.com/acme/profiles.git/aspice#aspice/v2.0.0
  # file:// for local development against a bare repo
  - git+file:///home/user/profiles.git#main

The fragment after # is a git tag (or branch for file:// development). The optional subpath between .git and # selects one profile inside a monorepo.

npm — resolved with npm pack and cached. Use this when your organization distributes profiles through an npm registry or mirror (Nexus, Artifactory, JFrog, or GitLab’s npm package registry). The registry is whatever your .npmrc points at:

profiles:
  - npm:@markspec/profile-aspice-4@^1.2
  - npm:my-team-profile@1.0.0

At most one profile. .markspec.yaml accepts a single content-bearing profile. Listing two or more is an error (PROFILE-LOAD-006) and no profile chain loads — compose standards by publishing a pre-merged profile, or chain them with extends: (see below), rather than stacking entries here. A bundled default profile loads automatically when profiles: is empty; opt out with default-profile: false.


Profile manifests

A profile manifest (markspec.yaml) declares the vocabulary for a project: entry types, attributes, traceability rules, and display-ID patterns.

Profiles can extend other profiles via extends:, forming a chain. Child profiles can add types and attributes, or tighten constraints (e.g., narrowing cardinality, adding required: true) — but cannot relax them.

Use markspec profile show to inspect the active profile chain, markspec profile describe <kind> <name> to see one element (type, attribute, relation, label, or convention) in full, and markspec doctor for a project health check.

The normative manifest schema — every block, field, and diagnostic — lives in Annex B — Profile manifest schema.


Delivered documents

A profile can deliver document files to every project that consumes it. Each file is flagged, per file, as either a traceable corpus (its entries join the consuming project’s traceability graph) or documentation-only (surfaced for humans and agents to read, never parsed).

profile:
  delivers:
    - path: reference/platform-architecture.md
      corpus: true # entries join the consumer's graph
      description: Shared platform components and interfaces
    - path: reference/integration-guide.md
      # corpus defaults to false → documentation-only
KeyTypeRequiredDefaultNotes
pathstringyesRelative to the profile directory (next to its markspec.yaml).
corpusbooleannofalsetrue → parse the file’s entries into the consumer’s graph. Markdown (.md) only.
descriptionstringnoShown in profile show and as the MCP resource description.

Path constraints. path must stay inside the profile directory — an absolute path or any .. segment is a load-time error (PROFILE-DELIVERS-003). corpus: true on a non-.md path is also a load-time error (PROFILE-DELIVERS-004).

Corpus eligibility. Only Markdown files can be corpus files. A documentation-only file can be any readable file (rendered as an MCP resource, never parsed for entries).

Merge semantics. Across an extends: chain, delivers: is an additive union keyed by (profile-id, path) — a child profile can add delivered documents but never remove or override a parent’s, matching every other manifest section’s non-relaxation rule. Two tiers delivering the same relative path never collide; they are namespaced by their own profile-id.

Consuming a delivered corpus. No project-side configuration is needed beyond the ordinary profile chain (.markspec.yaml) — check, compile, show, context, dependents, report, export, the LSP, and the MCP server all resolve trace targets living in the corpus automatically. A project entry reusing a corpus display ID or Id: fails check with MSL-R014 — the fix is to rename the project entry; delivered corpus entries are read-only.

Local-path profiles must be excluded from project discovery. A profile referenced with a local path (./profile in .markspec.yaml) lives inside the consumer’s own repository tree. If .markspec.yaml does not exclude: that directory, the ordinary project walk parses the same corpus file the corpus loader also parses — the entries are indexed twice and self-collide as MSL-R014. Add the profile directory to exclude::

# .markspec.yaml
exclude:
  - profile/

Git and npm profile specifiers resolve into .markspec/cache/<sha>/…, which project discovery already skips — only a local specifier needs this.

See the reference architecture recipe for a worked example, and ADR-030 for the collision-attribution and lockfile-scope rules.


Authoring and publishing a profile

A profile is a directory with a markspec.yaml manifest and a recommended README.md. Scaffold one with:

markspec profile new my-profile          # or: @org/my-profile

This creates my-profile/markspec.yaml (a minimal manifest stub) and my-profile/README.md.

Before distributing, validate the manifest:

markspec profile publish --dir ./my-profile

profile publish parses the manifest, reports any schema errors, and warns when description or license is missing (PROFILE-PUB-001 / PROFILE-PUB-002). It exits non-zero on errors. It validates only — it does not upload to any registry. Distribution itself is done with plain git or npm:

  • Git — commit the profile directory and tag it (git tag v1.0.0). The markspec.yaml must sit at the repo root, or at the subpath named in the specifier. Consumers reference it with git+https://…#v1.0.0.

  • npm — the profile directory is the npm package. Place markspec.yaml at the package root alongside a package.json, then npm publish. A minimal package:

    my-profile/
    ├── package.json        # name, version, files: ["markspec.yaml","README.md"]
    ├── markspec.yaml       # the manifest — must be at the package root
    └── README.md
    
    {
      "name": "@org/my-profile",
      "version": "1.0.0",
      "files": ["markspec.yaml", "README.md"]
    }
    

    The resolver runs npm pack and reads markspec.yaml from the tarball root, so keep the manifest at the top level (not nested in a subdirectory).

To wire a published profile into a project:

markspec profile add npm:@org/my-profile@^1.0

profile add validates the specifier and records it in .markspec.yaml. It does not copy the profile into your repository — git and npm sources are fetched and cached on demand the next time a profile-aware command runs.


Discipline classification (SW / HW)

MarkSpec derives a software-or-hardware discipline for each requirement by walking the Allocated-to graph upward from concrete components to the abstract requirement, and tagging the requirement with the union of disciplines reached. The result lands on Entry.derivedDiscipline? in compile output and flows into reports.

Three modes ship out of the box, selectable via the profile’s discipline-mode: field:

ModeBehavior
noneNo discipline derivation. Recommended for projects that do not separate SW from HW.
flatOne software-requirement / hardware-requirement bucket per discipline. Default.
tieredProfile may declare per-discipline subtypes (e.g. swe.software-requirement). Compliance.

Authors can override the derived value with the Discipline: trailer attribute, and freeze the override with Discipline-frozen: true. Frozen overrides are excluded from re-derivation.

The set of components recognized as the discipline source-of-truth lives in core (Item.discipline); profiles may extend the vocabulary with new components but cannot reclassify existing core components.


Coming in a future release

  • ASPICE / ISO 26262 mapping — how profile types correspond to process work products and safety integrity levels.
  • Profile stacking — composing a project profile on top of a compliance base profile.
  • Type vocabulary reference — all built-in types, their allowed attributes, and their traceability constraints.

Using typl to declare identifier types

typl is the MarkSpec Type Specification DSL. It gives meaning to the $Name tokens that appear in your entry bodies — by declaring their kind (signal, command, event, …) and their shape (float range, record, enum, …).


When do I use typl?

Use typl when an entry references $Name identifiers that represent typed quantities, and you want:

  • Tooling support — LSP hover shows the kind and shape of $Name; the compiler reports undefined references and duplicate published declarations.
  • Downstream codegen — the typeRegistry in markspec compile --format json maps each identifier to its shape, ready for RIDL emitters or custom scripts.
  • Verification clarity — a tester reading the requirement sees exactly what type and range $Speed carries, without hunting through other documents.

You do not need typl if your entry bodies contain no $Name identifiers, or if the identifiers are self-explanatory from prose context alone.


Which surface should I use?

Four Markdown surfaces are available. Choose based on how many declarations you need and where they live relative to prose.

Fence — for dense or structured declarations

Use a ```typl ``` fence when an entry carries multiple bindings and/or typedefs. The fence groups them visually and keeps the prose clean.

- [SYS_RADAR_0012] Radar track output

  The fusion module shall publish one `$Track` record per object at 100 ms.

  ```typl
  type Track = { id: int, range_m: float[0..300], velocity_ms: float }
  $Track   : signal Track
  $CycleHz : const int[10]
  ```

      Id: 01JZEXAMPLEULID000000000001
      Satisfies: STK_RADAR_0001

Bullet glossary — for sparse or annotated lists

Use a - $Name : … bullet when the entry already contains a list and you want to annotate a few identifiers without adding a fence. Reads as a glossary note.

- [SRS_BRAKE_0030] Brake pedal debounce

  The controller shall debounce raw pedal readings over a `$Window` ms sliding
  window before emitting `$Stable`.

  - $Window : config int[1..50]
  - $Stable : signal bool

    Id: 01JZEXAMPLEULID000000000002

Inline span — for a single identifier in prose

Use a `$Name : shape` backtick span to declare one identifier directly in the sentence that mentions it. Keep this to one or two identifiers; use a fence for more.

- [SRS_CTRL_0005] Gain scheduling

  The controller shall apply a gain `$Gain : signal float[0.5..2.0]` selected
  from the scheduled gain table.

      Id: 01JZEXAMPLEULID000000000003

Table — for many parallel declarations

Use a GFM table when an entry declares many identifiers that share the same columns: one row per binding, holding the $Name, its kind shape, and a description. The first two columns are read positionally; the description is documentation only. Rows whose first cell is not a $Name are ignored, so a table may mix declaration rows with plain notes.

- [SYS_HMI_0007] Cluster display signals

  The cluster shall render each signal below within its stated range.

  | Signal        | Kind shape           | Description      |
  | ------------- | -------------------- | ---------------- |
  | $VehicleSpeed | signal float[0..300] | km/h, road speed |
  | $EngineRpm    | signal int[0..8000]  | crankshaft speed |

      Id: 01JZEXAMPLEULID000000000006

A shape that contains | (a union or enum) must escape each pipe as \| so GFM does not read it as a column break; the cell un-escapes before typl parses it (| $Gear | 'P' \| 'R' \| 'N' \| 'D' | selected gear |).

A Table: caption adjacent to the table may carry a published base — an absolute name, dotted ($a.b) or single-segment ($vehicle) — which scopes the table’s relative rows ($.x) through the same entry-local resolver the bullet surface uses:

- [SYS_BRAKE_0009] Brake contract

  Table: $powertrain.brake

  | Signal           | Kind shape           | Description |
  | ---------------- | -------------------- | ----------- |
  | $.pedal_position | signal float[0..100] | pedal, %    |
  | $.line_pressure  | signal float[0..250] | bar         |

      Id: 01JZEXAMPLEULID000000000005

A table row resolves against its caption base, then the entry root. Unlike a bullet, a table nested inside a namespace does not inherit that namespace’s base. Only bindings are expressed in tables; typedefs keep the fence, bullet, or inline surfaces.


Common patterns

Declaring a signal

A signal is a continuously observable quantity. Pair it with a range shape to capture the expected measurement domain.

$EngineRPM  : signal float[0..8000]
$OilPressure: signal float[0..10.0]
$FuelLevel  : signal int[0..100]

Declaring a command with a record payload

A command carries a structured payload. Define the payload shape as a typedef, then reference it in the binding.

type BrakeRequest = { force_N: float[0..12000], duration_ms: int[0..500] }
$ApplyBrake : command BrakeRequest

Declaring a constant

A constant has a fixed value known at specification time.

$MaxRetries   : const int[3]
$DebounceMs   : const int[10]
$ProtocolVer  : const '2.1'

Declaring an event

An event is a named occurrence. The shape carries the event data, if any. Events with no payload omit the shape.

$CollisionAlert : event
$PedalPressed   : event { force_N: float[0..1500], timestamp: int }

Mixing surfaces in one entry

All four surfaces share the same namespace within an entry. You can open a fence for typedefs, then use bullets or a table for bindings, or vice versa. The compiler merges them.

- [SYS_LOG_0004] Diagnostic log record

  The system shall emit a `$LogRecord` document whenever a fault is detected.

  ```typl
  type Severity = 'info' | 'warn' | 'error' | 'fatal'
  type LogRecord = { ts: int, severity: Severity, code: int[0..255], msg: string }
  ```

  - $LogRecord : document LogRecord
  - $FaultRate : signal float[0..1.0]

    Id: 01JZEXAMPLEULID000000000004

Publishing a symbol across entries

Everything above is entry-local: a plain $Name lives in the entry that declares it, and two entries may declare $Speed independently. When a signal is a shared contract — an interface every consumer must agree on — declare it once as a published symbol and cite it from the other entries.

A published symbol is a dotted, namespaced name declared exactly once across the whole project. Declare a namespace, then hang the leaf signals off it with relative $. references:

- [ICD_BRAKE_0001] Brake subsystem signal interface

  `$powertrain.brake : namespace`

  - `$.pedal_position : signal float[0..100]` — pedal travel, percent
  - `$.line_pressure : signal float[0..250]` — hydraulic line pressure, bar

    Id: 01JZEXAMPLEULID000000000005

This declares two published symbols — $powertrain.brake.pedal_position and $powertrain.brake.line_pressure. Any other entry cites one by its full dotted path:

- [SRS_BRAKE_0040] Emergency brake trigger

  The controller shall engage the emergency brake when
  `$powertrain.brake.line_pressure` drops below 50 bar within 20 ms.

      Id: 01JZEXAMPLEULID000000000006
      Satisfies: SYS_BRAKE_0007

Three rules to keep in mind:

  • Declared once. Re-declaring a published symbol anywhere in the project is an error (TYPL-009).
  • Citations are absolute. The relative $. form only works inside the declaring entry; another entry must spell out the full dotted path.
  • A published name is never bare. Publication needs a namespace — $speed cannot be published as-is; give it an owner ($vehicle.speed).

Compile output

Running markspec compile --format json on a project that contains typl declarations produces two typl-specific fields in the output.

Per-entry types field:

{
  "displayId": "SYS_RADAR_0012",
  "types": {
    "bindings": [
      {
        "name": "$Track",
        "kind": "signal",
        "shape": { "kind": "ref", "name": "Track" }
      },
      {
        "name": "$CycleHz",
        "kind": "const",
        "shape": { "kind": "range", "type": "int", "exact": 10 }
      }
    ],
    "typedefs": [
      {
        "name": "Track",
        "shape": {
          "kind": "record",
          "fields": {
            "id": { "kind": "primitive", "type": "int" },
            "range_m": {
              "kind": "range",
              "type": "float",
              "min": 0,
              "max": 300
            },
            "velocity_ms": { "kind": "primitive", "type": "float" }
          }
        }
      }
    ]
  }
}

Corpus-level typeRegistry:

At the top of the compile output, the typeRegistry maps every $Name to its resolved binding across the whole document set — useful for codegen scripts that need a flat lookup table.

{
  "typeRegistry": {
    "$Track":   { "kind": "signal", "shape": { ... } },
    "$CycleHz": { "kind": "const",  "shape": { ... } }
  }
}

Editor support

The MarkSpec LSP provides two typl affordances when you open a Markdown file:

  • Hover — hovering over a $Name token shows its kind, shape, and where it is declared. A dotted published name ($powertrain.brake.pedal_position) shows its full path and declaring file; a relative $.name reference resolves against the entry’s root namespace.
  • Completion — inside a typl fence or after $, the LSP offers the workspace’s declared names. Inside an entry that declares a root namespace, typing $. offers relative shorthands for that namespace’s symbols.

Both features require the markspec lsp server to be running. In VS Code, install the MarkSpec extension — it starts the server automatically.


Common diagnostics and fixes

TYPL-005 — undefined typedef reference

TYPL-005: Reference to undefined typedef Track.

Cause: A binding uses Track as a ref shape, but no type Track = … declaration exists in the same entry.

Fix: Add the typedef before the binding, or replace the ref with an inline shape.

TYPL-002 / TYPL-003 — cross-entry collisions (deprecated)

TYPL-002 (kind collision) and TYPL-003 (shape collision) are retired and never emitted. Under the published tier, two entries that declare the same plain $Name are independent entry-local symbols, so there is no cross-entry consistency rule for plain names. Corpus-wide agreement is now enforced only for published (dotted) symbols — see TYPL-009 below.

TYPL-009 — duplicate published declaration

TYPL-009: $powertrain.brake.pedal_position is already declared in icd/brake.md:12 (published symbols are declared exactly once).

Cause: A published (dotted) symbol is declared in more than one place. A published symbol must be declared exactly once across the whole project.

Fix: Keep the single authoritative declaration and cite it from the other entries by its full dotted path.

TYPL-010 — relative reference with no base

TYPL-010: Relative reference $.pedal_position has no namespace base in scope.

Cause: A $. relative reference appears with no enclosing namespace declaration and no entry root namespace to resolve against.

Fix: Declare a namespace in scope (`$powertrain.brake : namespace`), or write the reference as an absolute dotted path.

TYPL-011 — citation of an undeclared published symbol

TYPL-011: Citation of undeclared published symbol $powertrain.brake.pedal_position.

Cause: An entry cites a dotted published symbol that is never declared anywhere in the project.

Fix: Declare the symbol in its owning entry, or correct the dotted path in the citation.

TYPL-012 — multiple root namespaces in one entry

TYPL-012: Multiple root namespace declarations in one entry (root is $powertrain.brake).

Cause: An entry declares more than one top-level namespace. An entry may have at most one root namespace.

Fix: Keep a single root namespace; nest the others beneath it, or move them to their own entries.

TYPL-001 — duplicate binding in the same entry

TYPL-001: Duplicate binding for $Speed in the same entry (first wins, this is a duplicate).

Cause: The same $Name binding appears twice within one entry (across any mix of surfaces).

Fix: Remove the duplicate. The first declaration wins.

Using uxil to declare interaction surfaces

uxil is the MarkSpec UX Interaction DSL. It gives meaning to the ux: references that appear in your entry bodies — one root surface per contract entry, its interactive elements, and any nested child surfaces — so specs, tests, journeys, and telemetry can be validated against what the UI surface actually affords.


When do I use uxil?

Use uxil when an entry documents a UI/HMI surface — a screen, a panel, an always-on agent — and you want:

  • Cross-artifact validation — specs, tests, journeys, and telemetry reference the surface by its ux: path; the compiler catches a reference to an element, state, or verb the surface doesn’t actually declare.
  • Tooling support — LSP hover shows a surface’s or element’s declaration card; completion after ux: offers known surface paths; go-to-declaration jumps straight to the authoring entry.
  • One corpus registry — every declared surface, element, and state is indexed once, corpus-wide, instead of relying on a screenId/elementId convention nobody enforces.

Contrast with typl: typl types $Name data identifiers — signals, commands, events. uxil types UI surfaces and their interactions — screens, panels, agents, and the verbs (activate, select, navigate, …) an element affords.

You do not need uxil if your entries describe UI behavior only in prose, with no ux: path that other entries, tests, or telemetry need to reference by a stable name.


The ux: reference format

A ux: reference always starts with a dot-separated surface path, then optionally narrows to a state or an element on that surface:

ReferenceCites
ux:media.homethe media.home surface itself
ux:media.home@loadingthe surface’s loading state
ux:media.home/playthe play element on that surface
ux:media.home/play!activatethe activate verb on the play element
ux:media.list/item:{id}the item element, keyed by its {id} template

The leading ux: scheme is optional — media.home/play parses identically to ux:media.home/play. You’ll write the scheme-prefixed form in prose citations and the bare form inside a navigation target (-> media.settings).

The eleven verbs

Every element declares one or more verbs from this closed set — the compiler rejects anything outside it (UXIL-010):

VerbMeaning
activatepress or tap — a momentary action
toggleflip a two-state control on/off
selectchoose one item from a set
adjustmove a continuous control — a slider, a dial
inputenter free-form data — text, a search query
scrollpan or page through content
draga drag-and-drop or reposition gesture
navigatego to another screen — requires a -> target clause
dismissclose or cancel
aska conversational or voice query
observea passive visibility anchor — exclusive, never combined with another verb

Activation

uxil is profile-gated. Until a profile designates a contract entry type with declares: ux-surface, uxil-looking content stays inert — opaque prose, no diagnostics:

profile:
  types:
    ux-contract:
      extends: Contract
      display-id-pattern: "UXI_{n:4d}"
      declares: ux-surface

With the designation active, entries of that type are compiled and validated in full, and ux: citations are checked from every entry in the project, whatever its own type. A root, element, or child-surface declaration written inside an entry whose type is not the declaring type is UXIL-023 — see Common diagnostics and fixes below.


Declaring a root surface

A contract entry — the type your profile designated with declares: ux-surface — declares exactly one root surface: an inline code span naming the surface’s dotted path, its kind, and its states.

- [UXI_0001] Media home surface

  The media home screen (`ux:media.home : screen @loading, ready`) offers
  playback control to the driver.

      Id: 01JZEXAMPLEULID000000000001

media.home is the surface’s corpus-wide path; screen is its kind — one of the three closed kinds (screen, panel, agent); loading and ready are its declared states. A contract entry with no root surface is UXIL-011; a second root declared in the same entry is UXIL-012 — the first one wins.


Declaring elements

Elements are a surface’s interactive parts, declared as bullets nested under the entry that carries the root (or under a child surface — next section). Each bullet opens with a code span — the element name and its verb set — and closes with trailing prose: the event dictionary, free text describing what the interaction does. Every element bullet must have one (UXIL-006).

A plain verb

- `/play : activate` — starts or pauses playback.

A verb with a key template

An element that stands for a family of instances — one per track, one per row — declares a {name} key template instead of a single fixed identity. A citation against a templated element must repeat the {name} template form; supplying a concrete key where a template is declared is UXIL-022.

- `/track : select : {track_id}` — selects a track from the queue by its id.

An element with states

@state, state, … after the verb set scopes when the element’s affordance applies.

- `/play : activate @enabled, disabled` — enabled once buffering completes;
  disabled while buffering.

A navigate element with a target

navigate is the one verb that requires a -> target; a missing target is UXIL-026, and a target that doesn’t resolve to another declared screen surface is UXIL-017.

- `/settings : navigate -> ux:media.settings` — opens the settings screen.

Declaring child surfaces

A child surface nests under its parent with a leading-dot bullet; its own nested bullets are its elements. It inherits its kind from the root and resolves to a corpus-wide path by joining onto its nearest enclosing surface.

- [UXI_0001] Media home surface

  The media home screen (`ux:media.home : screen @loading, ready`) offers
  playback control to the driver.

  - `/play : activate` — starts or pauses playback.
  - `.confirm @default` — delete-confirmation dialog nested under the home
    screen.
    - `/confirm : activate` — confirms the deletion.
    - `/cancel : activate` — dismisses the dialog without deleting.

      Id: 01JZEXAMPLEULID000000000002

.confirm registers as media.home.confirm in the corpus registry — its own states and elements, screen’s kind inherited from the root.


Editor support

The MarkSpec LSP provides three uxil affordances when you open a Markdown file:

  • Hover — hovering a ux: reference (declaration or citation) shows a declaration card: the surface’s kind, its declared states, and the owning entry. Hovering an element reference shows its verb set, states, and event dictionary instead.
  • Completion — typing ux: offers the corpus’s known surface paths, filtered by whatever you’ve typed so far.
  • Go-to-declaration — jumping from a ux: citation lands on the declaring bullet, wherever in the project it lives.

All three require the markspec lsp server to be running, and a profile that designates a declaring type (see Activation above). In VS Code, install the MarkSpec extension — it starts the server automatically.


Common diagnostics and fixes

UXIL-010 — unknown interaction verb

UXIL-010: Unknown interaction verb 'tap'.

Cause: An element’s verb set uses a verb outside the eleven closed verbs (activate, toggle, select, adjust, input, scroll, drag, navigate, dismiss, ask, observe).

Fix: Replace it with one of the eleven declared verbs.

UXIL-011 — no root surface declared

UXIL-011: A ux-contract entry must declare exactly one root surface; none was found.

Cause: The entry has element or child-surface bullets, but never declares a root (`ux:surface : kind`) span anywhere in its body.

Fix: Add the root declaration the elements are meant to hang off.

UXIL-014 — observe combined with other verbs

UXIL-014: 'observe' is exclusive and cannot be combined with other verbs on element 'status'.

Cause: An element’s verb set lists observe alongside another verb. observe marks a passive visibility anchor, never an interactive affordance, so it cannot share an element with activate, select, and the rest.

Fix: Split into two elements — one observe-only, one for the interactive verbs.

UXIL-018 — citation of an undeclared surface

UXIL-018: Unknown surface 'media.settings'.

Cause: A ux: citation elsewhere in the project references a surface path that no contract entry ever declares.

Fix: Declare the surface, or fix the typo’d path in the citation.

UXIL-023 — declaration outside the declaring entry type

UXIL-023: uxil declaration outside a declaring entry type: 'REQ_0001' (type 'requirement') may not declare surfaces (requires 'declares: ux-surface').

Cause: A root, element, or child-surface span was written inside an entry whose type is not the one the profile designated with declares: ux-surface.

Fix: Move the declaration into a contract entry of the declaring type, or replace it with a ux: citation if the intent was only to reference an existing surface.


uxil and typl together

uxil and typl are siblings on the same shared declaration-surface machinery (core/decl/) — the same base-resolution engine, the same bullet/inline extraction pattern. An entry can freely mix a uxil surface declaration with typl bindings; nothing about one DSL’s grammar constrains the other.

There is no formal link today between an element’s event and a typed value it carries. If an element’s event carries data worth typing — a track id, a gesture’s velocity — declare it as its own typl binding in the same entry and reference it by name from the event dictionary prose:

- [UXI_0001] Media home surface

  The media home screen (`ux:media.home : screen @loading, ready`) offers
  playback control to the driver.

  - `/track : select : {track_id}` — selects a track, carrying its
    `$TrackId : signal int[0..999]` identifier.

    Id: 01JZEXAMPLEULID000000000003

This is a naming convention today, not a checked cross-reference — the compiler does not tie the element’s key template to the typl binding.

CLI guide

Reference for project.yaml, every subcommand, and editor / LSP integration.

MarkSpec follows the Command Line Interface Guidelines. Every command supports --help. Commands that produce structured output support --format json for machine-readable output to stdout (diagnostics always go to stderr).

Exit codes: 0 success, 1 error, 2 warnings only (check, lint, lock).

Global options (available on every command):

FlagDescription
-h, --helpShow help
-V, --versionShow version
-q, --quietSuppress non-error output

Project configuration

project.yaml

Every MarkSpec project requires a project.yaml in the project root. MarkSpec discovers it by walking up from the current directory. project.yaml follows the org project-manifest contract — the closed schema published at https://driftsys.github.io/schemas/project/v1.json — shared with other DriftSys tooling, not a MarkSpec-only format.

Minimal example

name: my-project
version: "1.0.0"

Complete example

name: io.acme.braking-system
version: "2.3.0"

dependencies: # projects this project uses (git repositories)
  - url: git@github.com:acme/aeb-icd.git
    name: icd # short id: cache dir, lock rows, badges
    version: "v2.1.0" # frozen baseline (exact tag)

references: # citation sources (published sites)
  - url: https://driftsys.github.io/refhub
    name: refhub

Fields

FieldTypeRequiredDescription
namestringyesProject name. Must match ^[a-z][a-z0-9.-]*$ (lowercase, digits, ., -). Reverse-DNS convention recommended.
versionstringyesProject version. Quote in YAML to avoid number coercion.
dependenciesprojectRef[]noDefaults to []. Projects this project uses (git repositories). See the projectRef shape below.
referencesprojectRef[]noDefaults to []. Registries and external sources this project cites (published sites). See below.

name and version are required — a project.yaml missing either is rejected before any MarkSpec command runs. The schema is closed: an unrecognized key is an error, not a silent no-op. A handful of org-owned fields MarkSpec accepts but never acts on (description, license, keywords, labels, authors, homepage, repository, process, …) are metadata for other org tooling, not MarkSpec configuration — notably, labels: here is inert project metadata and does not constrain which Labels: values entries may carry.

markspec-specific tool config lives in .markspec.yaml, not project.yaml. exclude: and caption-conventions: moved there — see below.

The projectRef shape

dependencies: and references: are both lists of the same shape:

KeyTypeRequiredDescription
urlstringyesGit repository URL (dependencies:) or published-site base URL (references:; file:// accepted).
versionstringnoVersion intent: an exact tag freezes a baseline, a branch name tracks its head, absent means auto (latest release tag, else default-branch head).
namestringnoUpstream id — cache-directory name, lockfile row id, origin badge. Must match [A-Za-z0-9][A-Za-z0-9._-]*. Derived from the URL when absent.
  • dependencies: — other projects this project builds on (git repositories). markspec lock resolves the declared version: intent against the repo, acquires the tree at the resolved sha (a shallow git fetch, no clone, no history), compiles it in-process, and pins the result as an [[upstream.dependency]] lockfile row. A pin that resolves to a branch or bare sha rather than a tag is an unreleased state — advisory by default, promoted to a hard error under markspec check --strict (MSL-L215; see lock below).
  • references: — registries or external sources this project cites, such as another project’s published compile output. markspec lock fetches and pins these against a cache — see lock below. The declared version: is recorded but not yet consulted when resolving a references: entry — lock currently fetches the site’s latest published snapshot regardless of an exact tag or branch name; version-selecting resolution is planned for a future release.

Upstream entries resolve in the graph

Once markspec lock has pinned a references: or dependencies: entry, its entries join this project’s own traceability graph as read-only, origin-tagged citizens — not just cached files on disk:

  • Trace links resolve across the repo boundary. A Satisfies: (or any other trace attribute) value that names an upstream display ID resolves exactly like a same-project reference. check, show, context, dependents, and report all see the upstream entry, tagged with an Origin: line / column showing <upstreamId>@<version> — see show and report below.
  • MSL-T014 replaces MSL-L006 for a trace value that still doesn’t resolve once the project declares any dependencies:/references:: the warning names every upstream searched, e.g. not found in project or upstreams: producta, icd. A project with no declared upstreams keeps the plain MSL-L006 behavior.
  • A project entry that reuses an upstream’s display ID or Id: fails check with MSL-R014, naming the colliding upstream origin — the same shape as the collision that fires when a profile delivers a corpus (ADR-030), generalized to cover upstream origins too. Fix it by renaming the project entry; upstream entries are read-only.
  • references: entries are traceability leaves. report coverage never reports one as an orphan or unsatisfied gap — a citation isn’t something your own project is expected to cover. dependencies: entries participate in coverage like a project entry — a product requirement with no component coverage is a reported gap.
  • Upstream entries are validation-exempt, not edge-inert. No structural checks or prose lint run against them — that already happened in their own repo’s check — but they remain full resolution targets: a project entry’s trace link to one still resolves, and so does an edge between two upstream entries once both are hydrated into the same graph.

Root/program project pattern. A root or program repository that references every member repo aggregates the whole program’s graph for free: each member’s entries hydrate into the root’s compile, cross-repo trace edges resolve there, and report, dependents, and context run against the entire program from the root — both ends of every cross-repo edge are present. A repo referenced through more than one path (a diamond — e.g. the root references both a component and something that itself references that component) is still counted exactly once: an upstream snapshot’s own re-exported entries are skipped in favor of that entry’s authoring repo, so aggregating never double-counts or collides.

.markspec.yaml

.markspec.yaml carries markspec’s own tool configuration: profile binding (covered in the Profile guide) plus two file-discovery / rendering settings that used to live in project.yaml:

FieldTypeDefaultDescription
excludestring[][]Gitignore-syntax patterns excluded from project-wide file discovery (bare check/lint/fmt), anchored at the project root. Applied after .gitignore rules.
caption-conventionsmap<string, above|below>{}Per-keyword caption-position convention (Figure, Table, Listing, Feature, Equation, List) enforced by MSL-C072.

exclude: example — skip a directory of example entry blocks that aren’t real requirements, plus a generated-file pattern:

# .markspec.yaml
exclude:
  - skills/
  - "*.gen.md"

Built-in skips. File discovery always skips hidden directories (names starting with .) and the common build-output / dependency directories node_modules, target, dist, and build, on top of .gitignore and exclude:. The build-output skip is overridable — re-include one with a negated entry in .gitignore or exclude: (e.g. exclude: ["!target/"]).

caption-conventions: example — require every Table caption above its block and every Figure caption below:

# .markspec.yaml
caption-conventions:
  Table: above
  Figure: below

See the Profile guide for the profile-binding keys (profiles:, default-profile:) this file also carries.

Directory conventions

MarkSpec does not enforce a directory layout. By convention:

  • docs/ — Markdown files containing requirements and design documentation
  • src/ — source code with doc-comment entries (Rust ///, Kotlin /**)
  • project.yaml — project root marker

The compile and report commands accept explicit paths or globs:

markspec compile "docs/**/*.md"
markspec compile docs/requirements.md src/main.rs

Profile configuration (.markspec.yaml and profile manifests) is covered in the Profile guide.


Commands

Project setup

init

Scaffold a new MarkSpec project — writes project.yaml, .markspec.yaml, and (unless opted out) client MCP configs and skills.

markspec init [target-dir]
FlagTypeDefaultDescription
--clientstringForce write for the named client (repeatable): claude, opencode
--all-clientsboolfalseWrite configs for claude + opencode regardless of detection
--no-mcpboolfalseSkip all MCP scaffolding
--no-skillsboolfalseSkip upskill add
--profilestringProfile spec (conflicts with --no-profile)
--no-profileboolfalseCore-only mode (default-profile: false)
--binary-pathstringAbsolute path to the markspec binary for MCP configs
--dry-runboolfalseReport decisions, write nothing
--forceboolfalseOverwrite skip-on-exists files; required for a non-empty dir or non-TTY
--formatstringtextSummary format: json, text

Examples:

markspec init                                             # scaffold in cwd
markspec init ./my-project                                # scaffold in a new subdir
markspec init --profile git+https://github.com/org/profile
markspec init --all-clients --binary-path /opt/markspec/bin/markspec
markspec init --dry-run --format json                     # report, write nothing

Authoring

fmt

Stamp ULIDs, fix indentation, normalize attributes, and format the whole Markdown document — entry mechanics and surrounding prose in one pass (ADR-029).

# the whole project's markdown — what you run before committing
markspec fmt

# one file
markspec fmt docs/requirements.md

# check mode for CI — reports but doesn't modify
markspec fmt --check
markspec fmt [...files]
markspec fmt --check [...files]
FlagTypeDefaultDescription
--checkboolfalseReport changes without writing. Exit 1 if changes needed

Bare invocation = whole-project markdown scope. With no file arguments, fmt discovers every .md file under the project root (gitignore + .markspec.yaml exclude: honored) and prints a one-line scope header to stderr (formatting N file(s) under <root>), suppressed by -q. Bare invocation requires a discoverable project.yaml; outside a project it errors rather than silently scanning the cwd. fmt’s scope is markdown-only — the formatter never rewrites source files, unlike check/lint which also cover source doc comments.

Whole-document formatting (ADR-029). Beyond entry-block mechanics, fmt formats the entire Markdown document — headings, lists, tables, and prose — through an embedded dprint-markdown plugin, with one fixed, zero-config style: 80-column line width, always-wrap prose, underscore emphasis, asterisk strong, dash bullet lists, and the file’s own line-ending convention preserved.

  • 80 columns is a soft target, not a hard cap. Table rows, links and reference definitions, and inline code spans are never split to fit — they may exceed 80 columns when they can’t be broken.
  • <!-- dprint-ignore --> and <!-- dprint-ignore-start/end --> work as a per-block opt-out, same as external dprint. An ignore-start/end pair MUST NOT span an entry block — entry blocks and surrounding prose format as separate segments, so a range that straddles both is not honored across the boundary.
  • Files that must stay unformatted (long attribute-value lines in showcase docs, generated files) use .markspec.yaml exclude: — the same mechanism check/lint use, not a new flag.
  • Every rewrite is safety-gated. If reformatting an entry body would change its meaning, fmt keeps the original text for that entry and reports an advisory MSL-F012 instead of silently doing nothing or corrupting content.

Explicit arguments scope exactly to what’s named; a directory argument expands recursively with .gitignore (and the built-in hidden-directory skip) applied — .markspec.yaml exclude: patterns are honored only for bare whole-project invocation, not for explicitly-named directories:

# a subtree
markspec fmt docs/

# multiple files
markspec fmt docs/*.md

Project-aware trace canonicalisation (requires project.yaml):

When markspec fmt runs inside a project, it also canonicalises and heals trace reference values:

  • Canonicalise — any ULID written directly in a trace attribute (Satisfies:, Derived-from:, Verified-by:, etc.) is rewritten to the target entry’s current display ID.
  • Heal — if a target’s display ID was renamed, the edge ledger in markspec.lock records the stable target-ulid; fmt uses it to rewrite the stale display ID to the target’s new name.
  • Unresolved references are left as-ismarkspec check reports them via MSL-L006, or MSL-T014 when the project declares dependencies:/ references: (see Upstream entries resolve in the graph above).

Neither action is performed when no project.yaml is discoverable (file-local invocation). fmt reads the edge ledger but never writes markspec.lock; the ledger is owned by markspec lock.

check

The composite traceability and hygiene gate — structure, traceability, format drift, lockfile drift, and advisory prose, merged into one diagnostics stream.

# the whole project — what you wire into CI and the pre-push hook
markspec check

# one file, fast — what editors and per-file hooks run
markspec check docs/requirements.md

# a subtree
markspec check docs/
markspec check [...files]
FlagTypeDefaultDescription
--strictboolfalsePromote warnings to errors
--formatstringtextOutput format: json, text

Bare invocation = whole-project gate. With no file arguments, check discovers every relevant file (markdown + source doc comments) under the project root (gitignore + .markspec.yaml exclude: honored) and prints a one-line scope header to stderr (checking N file(s) under <root>), suppressed by -q and in --format json mode. Bare invocation requires a discoverable project.yaml; outside a project it errors rather than silently scanning the cwd.

The composite gate is project-wide only. Bare markspec check runs every gate below over the whole corpus in one pass, merging findings into a single diagnostics stream (one text renderer, one --format json array, one exit-code computation):

GateSeverityWhat it checks
Parse + structure + attributesas todayMalformed entry blocks, missing Id:, duplicate display IDs, malformed attributes.
Traceability (incl. MSL-L006)as today (MSL-L006 = warning)Broken Satisfies:/Derived-from:/etc. references; MSL-L006 flags a trace value that doesn’t resolve to any entry — or MSL-T014 when the project declares dependencies:/references: (see Upstream entries resolve in the graph).
Listing documentsas todayListing-file conventions (e.g. SUMMARY.md structure).
Format drift (MSL-F010)errorThe file’s whitespace/attribute form differs from what markspec fmt would produce — i.e. it wasn’t formatted before commit.
Reference-canon drift (MSL-F011)errorA trace value is a ULID or stale display ID that markspec fmt would rewrite to its canonical display ID (ADR-026 canonicalization). Distinct from MSL-F010 so you know which fmt concern to fix.
Lockfile drift (MSL-L212)error (only when markspec.lock exists)Traceability edges have changed since markspec lock last ran. Checked offline against the on-disk markspec.lock (no network).
Prose lint (MSL-Q*)advisory warningThe same rules markspec lint runs (modal verbs, EARS, passive voice, INCOSE lexicon, …).

markspec check <file> (file-local) runs structural validation only. The format-drift, lockfile, and prose-lint gates, and the MSL-L006 trace-existence warning, are project-wide-only — they need the full corpus (or the whole file’s formatted form) to be meaningful, so they don’t fire when you pass explicit file arguments. This is the fast, editor/per-file-hook path; the canonical agent write loop is insert → fmt → check per file, then a project-wide check before commit/CI catches everything else.

Exit codes: 0 clean, 1 any error, 2 warnings only (no errors). --strict promotes every warning (including advisory prose findings) to an error, so a project-wide check --strict is a stricter CI gate than the default.

Examples:

# Whole project, JSON output for tool integration
markspec check --format json

# Strict mode — warnings become errors (useful for CI)
markspec check --strict

# One file, strict mode
markspec check --strict docs/requirements.md

Querying

show

Show details of a single entry by display ID or ULID.

markspec show <id> <paths...>
FlagTypeDefaultDescription
--formatstringtextOutput format: json, text

Examples:

markspec show STK_PRJ_0001 "docs/**/*.md"
markspec show --format json STK_PRJ_0001 docs/requirements.md

When the active profile delivers a corpus (ADR-030), the entry prints an extra Origin: line and its Source: renders as <profile-id>@<version>:<path>:<line>:<column> instead of a raw filesystem path:

PLT_0001  Platform core service
  Type: platform-component
  Shape: Authored
  Origin: platform-arch@1.2.0
  ...
  Source: platform-arch@1.2.0:reference/platform.md:1:1

An entry that hydrates from a locked references:/dependencies: upstream (see Upstream entries resolve in the graph) also prints an Origin: line; its Source: gives the entry’s location in the upstream repo’s own tree.

context

Walk the Satisfies chain upward from an entry to see what it ultimately satisfies.

markspec context <id> <paths...>
FlagTypeDefaultDescription
--depthnumber10Maximum depth to walk
--formatstringtextOutput format: json, text

Examples:

markspec context SRS_PRJ_0001 "docs/**/*.md"
markspec context --depth 3 SRS_PRJ_0001 docs/requirements.md

dependents

List all entries that depend on (satisfy) a given entry.

markspec dependents <id> <paths...>
FlagTypeDefaultDescription
--formatstringtextOutput format: json, text

Examples:

markspec dependents STK_PRJ_0001 "docs/**/*.md"

Building

compile

Parse files, build traceability graph, output compiled result.

markspec compile <paths...>
FlagTypeDefaultDescription
--formatstringtextOutput format: json, text

Examples:

# Summary output
markspec compile "docs/**/*.md"

# Full JSON output for downstream tools
markspec compile --format json "docs/**/*.md" > compiled.json

report

Generate a traceability matrix or coverage report.

markspec report <kind> <paths...>

kind is one of: traceability, coverage.

FlagTypeDefaultDescription
--formatstringmdOutput format: md, json, csv
--scopestringFilter by domain abbreviation
--labelstringFilter by label value
--outputstringWrite to file instead of stdout

Examples:

# Traceability matrix in Markdown
markspec report traceability "docs/**/*.md"

# Coverage report as CSV
markspec report coverage --format csv "docs/**/*.md"

# Write to file
markspec report traceability --output matrix.md "docs/**/*.md"

# Filter by label
markspec report traceability --label ASIL-B "docs/**/*.md"

The traceability matrix carries an Origin column: project for a project-authored entry, or <profile-id>@<version> / <upstream-id>@<version> for an entry injected from a profile’s delivered corpus (ADR-030) or a locked references: upstream (see Upstream entries resolve in the graph). All three formats (md, json, csv) include it.

The coverage report treats a references: upstream entry as a traceability leaf — it never appears in the orphan/unsatisfied gap lists, since a citation isn’t something your own project is expected to cover.

export

Emit the compiled traceability graph in a portable format.

markspec export <format> <paths...>

Supported formats: json, yaml, csv.

Examples:

# JSON export
markspec export json "docs/**/*.md" > compiled.json

# CSV — one row per entry: display ID, title, type, shape, id, file, line, origin
markspec export csv "docs/**/*.md" > entries.csv

# YAML
markspec export yaml "docs/**/*.md"

All three formats carry provenance (ADR-030): in json and yaml, a corpus entry has an origin: { kind: "profile", profileId, profileVersion } field and a federated-upstream entry (see Upstream entries resolve in the graph) has origin: { kind: "upstream", upstreamId, version } (project-authored entries omit it); in csv, every row has an origin column holding <profile-id>@<version> for corpus entries, <upstream-id>@<version> for upstream entries, or project for project-authored ones.

insert

Append a scaffolded entry block to a file. This is the agent write path — use it from scripts or AI agents rather than hand-authoring new blocks.

markspec insert <type> <file>
FlagTypeDefaultDescription
--printboolfalseEcho the inserted block to stdout as well

The command:

  1. Finds the highest existing display ID for <type> in <file>.
  2. Computes the next sequential display ID.
  3. Generates a fresh ULID.
  4. Appends the block with a blank separator.

Example:

markspec insert requirement docs/requirements.md --print
# → appends SRS_PRJ_0003, prints block to stdout

Follow with markspec fmt to normalize indentation and markspec check to confirm no broken references.

create

Print a scaffolded entry block without writing it to any file.

markspec create <type> <paths...>

Example:

markspec create requirement "docs/**/*.md"

next-id

Print the next available display ID for a profile-declared type without creating an entry.

markspec next-id <type> <paths...>
FlagTypeDefaultDescription
--formatstringtextOutput format: json, text

Example:

markspec next-id requirement "docs/**/*.md"
# → SRS_PRJ_0004

markspec next-id requirement "docs/**/*.md" --format json
# → {"type":"requirement","displayId":"SRS_PRJ_0004"}

For a named (counter-less) type — one whose display-id-pattern has no {n} counter, e.g. sw-component: "SWC_{name}" (ADR-025) — there is no number to mint. next-id, create, and insert instead emit an upper-case NAME placeholder template for you to fill in by hand (slug-valid, so the scaffold still passes markspec check):

markspec next-id sw-component "docs/**/*.md"
# → SWC_NAME   (with a note on stderr: author the identifier yourself)

lint

Run prose-quality analysis on entries (INCOSE lexicon, modal keywords, structural checks). Returns MSL-Q and MSL-M codes.

# the whole project — same rules bare `check` runs as an advisory gate
markspec lint

# one file or subtree
markspec lint docs/requirements.md
markspec lint docs/
markspec lint [...paths]
FlagTypeDefaultDescription
--formatstringtextOutput format: json, text
--strictboolfalsePromote warnings to errors

Bare invocation = whole-project scope, same discovery rules (gitignore + .markspec.yaml exclude:) as check/fmt, with a scope header on stderr suppressed by -q / --format json.

A project-wide markspec check already runs these same prose rules as an advisory (warning-level) gate — lint is useful on its own as a focused, review-time surface with its own score roll-up and band summary, and for running on an explicit file or subtree without pulling in the other check gates.

Examples:

markspec lint --strict
markspec lint --format json
markspec lint docs/requirements.md

score

Score a single piece of requirement prose against the PA-3 rule catalog. Unlike lint, which walks a file’s entries, score takes prose directly — useful for a review-time or editor-integration check of one requirement.

markspec score --text "The system shall …"
FlagTypeDefaultDescription
--textstringInline prose to score
--idstringIdentifier to echo back in the result
--formatstringOutput format: json, text (default: text on a TTY, json piped)

Examples:

markspec score --text "The controller shall respond within 200 ms."
markspec score --id SRS_BRK_0001 --text "The system shall be fast." --format json

Lockfile and external sync

lock

Generate or refresh markspec.lock. The lockfile pins upstream profile and language-pack versions, resolves references: projectRefs (see project.yaml above) against their published compile output, and records sync mappings discovered under .markspec/sync/.

markspec lock
FlagTypeDefaultDescription
--checkboolfalseCI mode: read-only, exit 1 on drift.
--updatestringForce re-resolve every references: entry, or one by id (--update=<id>).
--formatstringtextOutput format: json, text.

Examples:

markspec lock                      # write or refresh markspec.lock
markspec lock --check              # CI gate: fail if lockfile is stale
markspec lock --update             # force re-resolve every reference
markspec lock --update=refhub      # force re-resolve one reference by id

references: resolution — three flows. Only markspec lock touches the network; check, compile, the LSP, and the MCP server resolve entirely offline from the pinned cache under .markspec/cache/upstreams/<id>/. markspec lock keeps that directory gitignored automatically — it appends .markspec/cache/ to .gitignore the first time it runs, so nothing has to be done by hand.

FlowWhenBehavior
First lockA references: entry has no lockfile row yet.Fetch the published snapshot, cache it, write a pinned [[upstream.registry]] row.
RestoreA row is already pinned but its cache is missing or broken (fresh clone, CI, a cleaned tree).Re-fetch the pinned content and verify its hash still matches the lockfile, then repopulate the cache. The pin itself never moves on restore.
Updatemarkspec lock --update (every reference) or --update=<id> (one).Re-fetch and move the pin to whatever the source currently serves.

Diagnostic codes:

CodeWhenMeaning
MSL-L213markspec lockA declared references: or dependencies: entry could not be locked — no derivable id, a fetch failure, a malformed manifest/tree, or a schema-version mismatch. Warn-and-write: markspec lock still writes every pin that did resolve.
MSL-L214markspec lock (restore flow)The cache needed restoring, but the re-fetched content’s hash no longer matches the pinned snapshot — the published site moved. Run markspec lock --update=<id> to move the pin deliberately.
MSL-L212markspec check (offline)Also covers upstream cache drift in this release: fires when a locked reference’s cache under .markspec/cache/upstreams/<id>/ is missing or its hash no longer matches markspec.lock, in addition to the existing traceability-edge-drift case. Either way, the fix is markspec lock.
MSL-L215markspec checkA dependencies: pin resolved to a branch or bare sha rather than a tag — an unreleased state. Advisory by default; markspec check --strict promotes it to a hard error, so a release build cannot pass against an unbaselined dependency.
MSL-L216markspec lockA dependencies: entry derives the same upstream id as a references: entry. The dependency is skipped — the reference snapshot owns the shared .markspec/cache/upstreams/<id>/ namespace — so set a distinct name: on one of them. Warn-and-write: every other pin still locks.

Exit codes. markspec lock exits 0 when every upstream resolved cleanly. Following the clig.dev convention (2 for warnings only), it exits 2 — while still writing markspec.lock (warn-and-write) — when any upstream could not be cleanly locked (MSL-L213/MSL-L214/MSL-L216), so a bare markspec lock in CI surfaces a dropped or stale pin. A hard error (e.g. an invalid project.yaml or sync mapping) exits 1. markspec lock --check is unaffected: it stays a read-only drift gate that exits 1 on drift.

dependencies: are acquired and compiled during lock. For each declared dependencies: entry, markspec lock resolves the declared version intent (auto, an exact tag, or a branch name) against the upstream repo, acquires the tree at the resolved sha via a shallow git fetch (no clone, no history), compiles it in-process, and writes the result to .markspec/cache/upstreams/<id>/ alongside a pinned [[upstream.dependency]] row. Use markspec check --strict to enforce that every dependency is tag-pinned before release (MSL-L215 above).

CI caching. .markspec/cache/upstreams/ is safe to cache between CI runs — key it on markspec.lock’s contents so a job only re-acquires an upstream when its pin actually moved:

# .github/workflows/ci.yml (excerpt)
- uses: actions/cache@v4
  with:
    path: .markspec/cache/upstreams/
    key: markspec-upstreams-${{ hashFiles('markspec.lock') }}

Upgrade note. markspec lock now indexes source-file doc-comment entries in addition to Markdown. A project that pinned its lockfile with an older MarkSpec and has trace links in source files will see a one-time MSL-L212 edge-drift error from bare markspec check until you run markspec lock once to refresh the pin — the requirements didn’t change, only the set of files the lockfile indexes did. markspec doctor surfaces the same drift as a non-blocking warning (exit 2), so you can spot and clear it with markspec lock before check blocks on it in CI.

sync

Read-only commands surfacing bound-entry state from markspec.lock and the per-system NDJSON sync log under .markspec/sync/<system>/log.ndjson. push / pull / resolve / init are connector-side and ship in per-tool ADRs.

markspec sync status [system]
markspec sync log    [system]
markspec sync show   <displayId>

sync status — group bound entries by remote_state:

FlagTypeDefaultDescription
--statestringFilter to one remote_state (ok, behind, ahead, conflict, unreachable, deleted-upstream, unbound).
--formatstringtextOutput format: json, text.

sync log — tail the per-system sync log (NDJSON):

FlagTypeDefaultDescription
--tailnumber20Number of lines from the end.
--opstringFilter to one op: push, pull, conflict, resolve.
--sincestringFilter to entries at or after this RFC 3339 timestamp.
--formatstringtextOutput format: json, text.

sync show — full sync state for one bound entry:

FlagTypeDefaultDescription
--formatstringtextOutput format: json, text.

Examples:

markspec sync status                     # all systems, grouped by state
markspec sync status jira                # one system
markspec sync status --state conflict    # only conflicting entries
markspec sync log jira --tail 50 --op conflict
markspec sync show STK_BRK_0001

Documents

doc build

Generate a single-document PDF via Typst.

markspec doc build <file>
FlagTypeDefaultDescription
-o, --outputstring<file>.pdfOutput file path

Examples:

markspec doc build docs/spec.md
markspec doc build -o output/spec.pdf docs/spec.md

Books

book build

Generate a multi-chapter static HTML site from a SUMMARY.md.

Not currently used to build the published MarkSpec docs site — that still builds via mdBook until the native renderer reaches chrome parity (sidebar navigation, search, syntax highlighting, a print stylesheet, and a light/dark theme toggle). See #804 for the tracking issue and exit criteria.

markspec book build
FlagTypeDefaultDescription
-o, --outputstring_siteOutput directory
-s, --summarystringSUMMARY.mdSUMMARY.md path

Examples:

markspec book build
markspec book build -o dist -s docs/SUMMARY.md

Each chapter’s output filename is derived from its SUMMARY.md-declared path: .md is stripped and nested directory separators flatten to hyphens (recipes/deploy.mdrecipes-deploy.html). A Markdown link (or link reference definition) in one chapter that points at another chapter’s source path — resolved relative to the linking chapter’s own directory, not the flattened output layout — is rewritten to that chapter’s output filename, preserving any #fragment. Links to files outside the book (external URLs, absolute paths, or a chapter declared in SUMMARY.md with no backing file) are left untouched.

If two distinct chapters flatten to the same output slug (e.g. recipes/deploy.md and recipes-deploy.md both → recipes-deploy.html), the build fails with error[MSL-K001] and writes nothing rather than silently overwriting one chapter with the other — rename one chapter to disambiguate.

Profile and diagnostics

profile show

Show the active profile chain and effective configuration.

markspec profile show
FlagTypeDefaultDescription
--formatstringtextOutput format: json, text

When the active profile delivers documents (ADR-030), the text output gains a Delivered documents block listing each file’s path, role (corpus with an entry count, or doc with its description), and providing tier — plus any missing-file issue (PROFILE-DELIVERS-001/-002):

Delivered documents (2):
  - reference/platform.md   corpus   1 entries   [platform-arch]
  - reference/guide.md   doc      Integration guide (read-only reference)   [platform-arch]

--format json includes the same list under a delivers key.

profile new

Scaffold a new profile directory with a starter manifest.

markspec profile new <id>

<id> must be lowercase alphanumeric with hyphens, optionally scoped (@org/name).

markspec profile new my-profile
markspec profile new @acme/iso26262

Creates <id>/markspec.yaml and <id>/README.md.

profile add

Add a profile to the project’s .markspec.yaml.

markspec profile add <spec>

<spec> is a local path or a scoped package name.

markspec profile add ./profiles/my-profile
markspec profile add @acme/compliance-profile

profile publish

Validate a profile manifest for publishability and print any issues.

markspec profile publish [--dir <dir>] [--format json|text]

Checks required fields (id, version), warns about missing description and license, and reports any manifest validation errors. Exits 0 when the manifest is publish-ready.

profile describe

Show full details for a profile element (type, attribute, relation, label, or convention).

markspec profile describe <kind> <name>

kind is one of: type, attribute, relation, label, convention.

markspec profile describe type requirement
markspec profile describe attribute ASIL
markspec profile describe relation Satisfies

doctor

Project health check: verifies project.yaml, profile configuration, and project structure.

markspec doctor
FlagTypeDefaultDescription
--formatstringtextOutput format: json, text

When the active profile delivers documents (ADR-030), doctor reports the document count, corpus entry count, and any health issue (declared-but-missing file, corpus parse failure):

Delivered documents: 2 (1 corpus entries)

When a markspec.lock is present, doctor also reports whether the project’s traceability edges still match the pin, using the same offline comparison check’s MSL-L212 gate performs. Drift is a warning (exit 2) here — the proactive onramp so a stale lockfile is surfaced before a markspec check goes red on it (check keeps the hard MSL-L212 error, exit 1):

⚠ Lockfile: traceability edges drifted (locked 12, current 15) — run `markspec lock`

--format json adds a lockfile block:

{
  "lockfile": {
    "present": true,
    "edgeDrift": true,
    "lockedEdges": 12,
    "currentEdges": 15
  }
}

present is false when the project has no markspec.lock. The edgeDrift/lockedEdges/currentEdges fields appear only when the lockfile parses; a present-but-malformed lockfile reports present: true with the drift fields omitted (its validity is check’s / lock’s concern, not doctor’s). The drift warning uses the non-MSL doctor code lockfile-edge-drift, listed in the shared diagnostics array. See the lock Upgrade note for the one-time post-upgrade drift and how to clear it.

Maintenance

self-upgrade

Download and atomically replace the running markspec binary with the latest release (or a pinned version).

markspec self-upgrade
FlagTypeDefaultDescription
--checkboolfalseCheck only; exit 1 if a newer release is available
--versionstringPin a specific release (downgrade allowed)
--formatstringtextOutput format: json, text

Examples:

markspec self-upgrade              # upgrade to the latest release
markspec self-upgrade --check      # CI/cron: exit 1 when an update exists
markspec self-upgrade --version 0.10.2

AI agent integration

mcp

Start the MarkSpec MCP server. Communicates over stdio JSON-RPC. Exposes the active project as MCP resources and tools to any MCP-capable AI client (Claude Code, Claude Desktop, GitHub Copilot in VS Code, OpenCode).

markspec mcp
Project discovery

markspec mcp resolves the project root from the first of these that contains a project.yaml or .markspec.yaml (walking upward from each):

  1. --root <path> — pass the flag once per candidate root.
  2. MARKSPEC_PROJECT_ROOT — colon-separated list of candidate roots.
  3. CLAUDE_PROJECT_DIR — injected automatically by Claude Code (v2.1.139+); no configuration needed.
  4. The server’s launch working directory.

If none resolves, every MarkSpec tool replies with a message that begins “No MarkSpec project found …” and names the directories it searched. Set --root or MARKSPEC_PROJECT_ROOT to point the server at your project — this is the reliable fix when it is launched from outside the project tree (for example a user-scoped MCP install, whose working directory is the plugin cache, or a monorepo opened at a parent directory).

markspec mcp --root /path/to/your/markspec-project
Resources
  • markspec://profile — distilled profile manifest (types, attributes, link kinds, labels).
  • markspec://entries — index of all project entries, grouped by type.
  • markspec://entry/{displayId} — one entry per resource, with attributes, body, outgoing/incoming links.
Tools
  • entry_search { query, limit? } — rank-search entries by display ID and title.
  • entry_context { id, depth? } — walk the satisfies chain upward.
  • validate { files? } — run the validator, return a Markdown diagnostics report.
  • markspec_refresh — force-invalidate the compile cache (call after agent edits to guarantee freshness).
Claude Desktop config

Add to ~/Library/Application Support/Claude/claude_desktop_config.json:

{
  "mcpServers": {
    "markspec": {
      "command": "markspec",
      "args": ["mcp"],
      "cwd": "/path/to/your/markspec-project"
    }
  }
}

Restart Claude Desktop. The MarkSpec resources and tools appear in the attach menu.

Claude Code
claude mcp add markspec --command markspec --args mcp --cwd /path/to/project
VS Code (Copilot)

The markspec-ide extension auto-registers the MarkSpec MCP server with VS Code 1.101+ — install the extension and the server appears in Copilot’s MCP picker. See Editor and LSP integration — VS Code below.

For users who don’t run the extension, the manual recipe still works. Add a .vscode/mcp.json in your project:

{
  "servers": {
    "markspec": {
      "command": "markspec",
      "args": ["mcp"]
    }
  }
}

Copilot does not support MCP resource subscriptions today, but the markspec:// resources still work — the server runs a fresh staleness check on every read, so a re-read after an edit returns up-to-date content.

mcp install

Print MCP server configuration for a client. The output is the client’s native config snippet — pipe it to the client’s settings file or copy-paste it.

markspec mcp install --client <client>
FlagTypeDefaultDescription
--clientstringClient ID: claude, cursor, opencode, vscode, copilot.
--scopestringclient defaultConfig scope: user or workspace. Honoured by copilot; other clients are fixed to one scope.
--binary-pathstringinvoked binary nameExplicit path to the markspec binary. Default writes the invoked name (resolves via PATH, surviving package upgrades).

Examples:

markspec mcp install --client claude                  # → .mcp.json (workspace)
markspec mcp install --client cursor
markspec mcp install --client vscode
markspec mcp install --client copilot                 # → .github/mcp.json
markspec mcp install --client copilot --scope user     # → ~/.copilot/mcp-config.json
markspec mcp install --client claude --binary-path /opt/markspec/bin/markspec

lsp install

Print LSP server configuration for an editor. The output is the editor’s native config snippet (JSON for VS Code, Lua for Neovim, JSON for Zed) — pipe it to the editor’s settings file or copy-paste it.

markspec lsp install --editor <editor>
FlagTypeDefaultDescription
--editorstringEditor ID: vscode, neovim, zed.
--binary-pathstringinvoked binary nameExplicit path to the markspec binary. Default writes the invoked name (resolves via PATH, surviving package upgrades).

Examples:

markspec lsp install --editor vscode
markspec lsp install --editor neovim
markspec lsp install --editor zed
markspec lsp install --editor vscode --binary-path /opt/markspec/bin/markspec

Install timeout (never hang)

mcp install and lsp install read the target config file before writing. Under extreme host load — many concurrent markspec processes, a contended filesystem, or a locked config file — that read can stall. Rather than hang silently, both commands run under a watchdog: if the work does not finish within a deadline, they print a diagnostic to stderr and exit non-zero (1).

The deadline defaults to 10 seconds (normal runs finish in well under one). Override it with the MARKSPEC_INSTALL_TIMEOUT_MS environment variable — for example MARKSPEC_INSTALL_TIMEOUT_MS=30000 to wait 30 seconds on a slow host. A missing, blank, or non-positive value keeps the 10-second default.

Limitation. The watchdog runs inside the process, so it can only fire once the runtime has started. If a launch wedges even earlier — in the operating system’s process loader, which can happen under heavy fork/exec pressure — no in-process timer can catch it. In that case, supervise the command externally (timeout 15 markspec mcp install …) or register the server through your client directly, e.g. claude mcp add markspec -- markspec mcp.

Not yet implemented

These commands are registered but print an error and exit:

CommandIntended purpose
book devLive preview with hot reload
deck buildSlides → PDF via Touying/Typst
deck devLive slide preview

Editor and LSP integration

MarkSpec ships a built-in Language Server Protocol (LSP) server. Run it with:

markspec lsp

The server communicates over stdio JSON-RPC — the standard transport that every LSP-capable editor supports.

Features

Diagnostics — broken references, missing IDs, duplicate display IDs, and malformed entries appear as inline errors and warnings as you type. File-local checks run immediately; cross-file validation runs on save.

Entry block completion — type - [ at the start of a line to get a pre-filled entry block scaffold with the next available display ID for each type defined in your profile.

ID reference completion — after a trace attribute keyword (Satisfies:, Derived-from:, Verified-by:, etc.) the server suggests all known display IDs in the project.

Source file context guard — in source files (Rust, Kotlin, Java, C, C++, TypeScript, TSX, JavaScript, C#), completions only activate near entry markers or trace keywords. The server won’t interfere with your language’s native LSP (rust-analyzer, kotlin-lsp, etc.).

VS Code

Install the MarkSpec extension from the editors/vscode/ directory in this repository. The extension requires VS Code 1.101 or newer (the version that introduced the stable MCP extension API).

The extension provides two integrations in one install:

  • LSP — diagnostics, completions, and entry-block scaffolding in the editor.
  • MCP — registers a markspec MCP server with VS Code so Copilot (and any other MCP-aware client) can use the project’s resources and tools without a separate .vscode/mcp.json.

Both point at the same markspec binary, resolved from markspec.server.path.

From source

cd editors/vscode
npm install
npm run compile

Then in VS Code: Extensions → ⋯ → Install from VSIX or press Ctrl+Shift+PExtensions: Install from VSIX… and select the .vsix file, or use the development host:

code --extensionDevelopmentPath=editors/vscode

Configuration

SettingDefaultDescription
markspec.server.path"markspec"Path to the markspec binary (used by both LSP and MCP).
markspec.server.args["lsp"]Arguments passed to start the LSP server.
markspec.mcp.enabledtrueRegister the MarkSpec MCP server with VS Code.
markspec.mcp.args["mcp"]Arguments passed to start the MCP server.
markspec.trace.server"off"Trace level: off, messages, or verbose.

If markspec is not on your PATH, set the full path:

{
  "markspec.server.path": "/home/you/.local/bin/markspec"
}

MCP server

Once the extension is installed, the MarkSpec MCP server appears in Copilot’s MCP picker — no .vscode/mcp.json required. To disable the registration:

{
  "markspec.mcp.enabled": false
}

The manual .vscode/mcp.json recipe in mcp — VS Code (Copilot) remains supported for editors that don’t run the extension.

Neovim

Neovim’s built-in LSP client works out of the box. Add this to your init.lua:

vim.api.nvim_create_autocmd("FileType", {
  pattern = { "markdown" },
  callback = function()
    vim.lsp.start({
      name = "markspec",
      cmd = { "markspec", "lsp" },
      root_dir = vim.fs.root(0, { "project.yaml", ".git" }),
    })
  end,
})

For source files (Rust, Kotlin, etc.) where MarkSpec entry blocks appear in doc comments, add the relevant file types to the pattern list:

pattern = { "markdown", "rust", "kotlin", "java", "c", "cpp" },

The server’s context guard ensures it only activates near MarkSpec entry markers, so it won’t conflict with rust-analyzer or other language servers running on the same buffer.

With nvim-lspconfig

If you use nvim-lspconfig, add a custom server definition:

local lspconfig = require("lspconfig")
local configs = require("lspconfig.configs")

if not configs.markspec then
  configs.markspec = {
    default_config = {
      cmd = { "markspec", "lsp" },
      filetypes = { "markdown", "rust", "kotlin", "java", "c", "cpp" },
      root_dir = lspconfig.util.root_pattern("project.yaml", ".git"),
    },
  }
end

lspconfig.markspec.setup({})

Other editors

Any editor with LSP support can use markspec lsp. The server expects:

  • Transport: stdio (stdin/stdout JSON-RPC)
  • Trigger characters: [ (block scaffold) and : (ID reference)
  • Document sync: full text on each change

Point your editor’s LSP client at markspec lsp and it should work. If your editor needs a specific configuration example, please open an issue.

VS Code extension

The MarkSpec extension (driftsys.markspec-ide) provides first-class editor support for MarkSpec documents and source-file doc comments. It speaks the Language Server Protocol (LSP) and delegates to the same markspec binary you use on the command line.

Features

FeatureDescription
Real-time diagnosticsValidation errors and warnings inline as you type
Entry block completions- [ → full block scaffold with display ID and attribute skeleton
ID reference completionsSatisfies: → pick from all display IDs in the workspace
Type completionsType: → core types + profile-declared types
HoverHover any display ID to preview the entry’s title, type, and body
Go-to-definitionF12 on a display ID jumps to the entry’s source location
Find all referencesShift+F12 lists every file that references a display ID
Workspace renameF2 renames a display ID across the entire workspace
Document outlineOutline view lists every entry in the file
Workspace symbol searchCtrl+T fuzzy-searches entries by display ID or title
FoldingEach entry block is collapsible
Document highlightsCursor on a display ID highlights every occurrence in the file
Code lensPer-entry inline lenses: “↑ N dependents” and “↓ Satisfies: ID — Title”
Inlay hintsPer-entry inline hints: resolved : <type> and (N dependents) counters
Document linksVerified-by: file-path values are clickable links to the test source
Document formattingShift+Alt+F runs the same code path as markspec fmt on the buffer
Semantic tokensDisplay IDs, ULIDs, modal verbs, EARS triggers, and typl tokens are syntax-highlighted
Quick fixesOne-click fixes for MSL-M060 (uppercase modal), MSL-A030 (generated attr), and more

Upstream entries (federated projects)

When a project locks upstream repositories (dependencies: / references: in project.yaml, resolved by markspec lock), the imported entries appear in the editor as read-only citizens:

  • Completion offers their display IDs with an — from <name>@<version> badge, so you can see an ID is imported, not local.
  • Hover renders the imported entry the same as a local one.
  • Go-to-definition is a no-op — an upstream entry lives in another repository and has no file in this workspace to open.
  • Rename and formatting never touch them, and no diagnostics are published against them; their validation happened in their own repository.

Install

VS Code Marketplace:

  1. Open Extensions (Ctrl+Shift+X / Cmd+Shift+X).
  2. Search for MarkSpec.
  3. Install driftsys.markspec-ide — or run code --install-extension driftsys.markspec-ide.

Open VSX (VSCodium, Cursor, Gitpod, …): https://open-vsx.org/extension/driftsys/markspec-ide — or codium --install-extension driftsys.markspec-ide.

The extension activates only in a MarkSpec project — a workspace that contains a .markspec.yaml activator (per ADR-008). In a plain Markdown or source repository with no .markspec.yaml the extension stays dormant: it never spawns the language server, never indexes, and never writes a .markspec/ directory. Run markspec init (or add a .markspec.yaml) to turn a workspace into a MarkSpec project.

Configuration

All settings live under the markspec. prefix in VS Code settings.

SettingDefaultDescription
markspec.server.path"markspec"Path to the markspec binary. Override if not on PATH.
markspec.server.args["lsp"]Arguments passed to the binary to start the LSP server.
markspec.trace.server"off"LSP protocol trace level: off, messages, or verbose.

Example — binary in a project-local path:

{
  "markspec.server.path": "${workspaceFolder}/.bin/markspec"
}

Logs

In a MarkSpec project the language server writes a per-project event log to <workspace>/.markspec/lsp.log (rotated at 1 MB, three files kept). The first time it opens that file it drops a self-ignoring .markspec/.gitignore (*) alongside it, so the log never shows up in git status — you do not need to add anything to your repository’s .gitignore. A workspace with no .markspec.yaml gets no .markspec/ directory at all. Override the location with the markspec.trace.logPath setting (or the MARKSPEC_LSP_LOG environment variable); an explicit path writes the log regardless of project membership. Set MARKSPEC_LSP_LOG_OFF=1 to disable logging entirely.

Schema validation

MarkSpec publishes JSON Schemas for its config files at https://driftsys.github.io/markspec/schemas/<name>/v1.json:

FileSchema
.markspec.yaml…/schemas/markspec/v1.json
profile markspec.yaml…/schemas/profile/v1.json
markspec.lock…/schemas/lock/v1.json

YAML files (.markspec.yaml, profile markspec.yaml). markspec init writes a $schema: key into generated .markspec.yaml files, which the YAML Language Server reads automatically. To add it by hand:

$schema: https://driftsys.github.io/markspec/schemas/markspec/v1.json
profiles:
  - io.example.base@1.0.0

Or map by filename in VS Code settings.json:

{
  "yaml.schemas": {
    "https://driftsys.github.io/markspec/schemas/profile/v1.json": "**/markspec.yaml"
  }
}

Lockfile (markspec.lock, TOML). markspec lock writes a #:schema directive on the first line, which the Even Better TOML extension reads:

#:schema https://driftsys.github.io/markspec/schemas/lock/v1.json

MCP server

The extension also registers MarkSpec as an MCP server so Claude and other AI agents can query your entry graph directly from inside the editor.

No extra configuration is required — the extension reads markspec.server.path and registers the MCP server automatically. In VS Code with Copilot or Claude extension enabled, the server appears as MarkSpec in the agent tool list.

Neovim / other LSP clients

Any editor that supports LSP can use markspec lsp. Example Neovim (lazy.nvim) configuration:

require("lspconfig").markspec.setup({
  cmd = { "markspec", "lsp" },
  filetypes = { "markdown" },
  root_dir = require("lspconfig.util").root_pattern("project.yaml"),
})

Generate the full configuration snippet for your editor:

markspec lsp install --editor neovim
markspec lsp install --editor zed
markspec lsp install --editor vscode   # prints JSON config block

Pin a specific binary path (default writes the invoked binary name, which resolves via PATH and survives package-manager upgrades):

markspec lsp install --editor neovim --binary-path /opt/markspec/bin/markspec

Troubleshooting

Extension never activates

  • The extension activates only when the workspace contains a .markspec.yaml. Add one (or run markspec init) to mark the folder as a MarkSpec project.

Extension activates but shows no diagnostics

  • Confirm the binary is on PATH: markspec --version in a terminal.
  • Check the MarkSpec output panel (View → Output → MarkSpec) for LSP errors.
  • Confirm the file extension is recognised — that’s the only gate for whether a file’s diagnostics publish at all (.md, or a supported source extension).
  • In source files, hover/completion/go-to-definition/rename additionally require the cursor to be within ~20 lines of an entry marker or trace attribute keyword — but this proximity rule doesn’t apply to .md files, and it doesn’t gate diagnostics at all.

“markspec: command not found”

Set markspec.server.path to the absolute binary path, e.g. /home/user/.local/bin/markspec.

Completions not appearing

  • Completions for block scaffold require the line to start with - [.
  • Trace-attribute completions require the workspace to be indexed — check the output panel for “Indexed N files”.

AI agents and skillset

MarkSpec integrates with AI assistants at two levels:

  • MCP server — exposes the compiled entry graph to any MCP-capable agent so it can query entries, search by display ID, and read traceability context.
  • Skillset — teaches the agent MarkSpec’s authoring conventions so it can write and review entries correctly without constant guidance.

Setting up a new project

Run markspec init in an empty directory to scaffold everything you need:

markspec init

This writes:

  • project.yaml — minimal project metadata
  • .markspec.yaml — profile chain (defaults to the bundled profile)
  • markspec.lock — toolchain pin at the running CLI’s minor version
  • .vscode/extensions.json — recommends the driftsys.markspec-ide extension
  • MCP config for each detected client:
    • Claude Code.mcp.json at repo root
    • opencodeopencode.json at repo root
  • Skills bundle via upskill add (warns and continues if upskill is not installed)

Targeting specific clients

Auto-detection covers Claude Code and opencode. Force a client with --client:

markspec init --client claude --client opencode

Claude Desktop is not a markspec target — its claude_desktop_config.json is the desktop app’s private state file, so markspec never writes it. Configure it by hand (see Claude Desktop below).

For VS Code + Copilot, no MCP file is written — the bundled driftsys.markspec-ide extension handles the wiring once the .vscode/extensions.json recommendation is in place.

Profile selection

--profile <spec> accepts any of:

  • bundled (the default; explicit form)
  • false (equivalent to --no-profile, core-only mode)
  • git+https://... / git+ssh://... (git URL)
  • ./relative/path or /absolute/path (local profile directory)

See markspec init --help for the full flag list.

MCP server

The MCP server runs as a subcommand of the same markspec binary:

markspec mcp

It speaks the Model Context Protocol over stdio JSON-RPC and exposes the following tools:

ToolDescription
entry_searchFuzzy search entries by display ID or title
entry_showShow one entry’s full detail (body, outgoing + incoming links)
entry_listSpec overview (per-type counts) or paginated listing of entries
entry_contextWalk the Satisfies chain upward from an entry
entry_neighborhoodShow an entry’s parents (up) and children (down) as a subgraph
validateRun validation and return structured diagnostics
markspec_refreshRe-index the workspace after file changes
profile_describeDescribe the active profile’s types, attributes, and relations

Upstream entries (imported from locked dependencies: / references:) are visible to entry_show, entry_list, and entry_context through the shared compile cache — no separate tool. entry_show marks them with a from upstream <name>@<version> (read-only) origin badge and annotates their location as (in upstream <name>) because the file lives in another repository; entry_context tags each upstream node in the chain with a lighter — from <name>@<version> suffix.

Claude Desktop

markspec does not configure Claude Desktop — its claude_desktop_config.json is the desktop app’s own private state file, so markspec leaves it to you (there is no --client claude-desktop). Add the markspec server by hand to ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows), then restart the app:

{
  "mcpServers": {
    "markspec": {
      "command": "markspec",
      "args": ["mcp"]
    }
  }
}

Claude Code

markspec mcp install --client claude writes .mcp.json at the project root. The file uses the standard mcpServers.markspec shape and is read automatically by Claude Code when it opens the directory.

markspec mcp install --client claude --scope workspace
markspec mcp install --client claude --scope workspace \
  --binary-path /opt/markspec/bin/markspec

The --scope=user flag is not supported for claude — the config is always project-scoped (.mcp.json at the repo root).

Cursor

markspec mcp install --client cursor
markspec mcp install --client cursor --binary-path /opt/markspec/bin/markspec

opencode

markspec mcp install --client opencode writes opencode.json at the project root. The file uses a flat mcp.markspec object with a type: "local" entry (no mcpServers nesting), which matches the opencode JSON schema verified against the anomalyco/opencode repository.

markspec mcp install --client opencode --scope workspace
markspec mcp install --client opencode --scope workspace \
  --binary-path /opt/markspec/bin/markspec

The --scope=user flag is not supported for opencode — the config is always project-scoped (opencode.json at the repo root).

GitHub Copilot CLI

markspec mcp install --client copilot writes a Copilot-shaped MCP entry. It is the one dual-scope client:

ScopePathNotes
workspace.github/mcp.jsonDefault; committable, per-repo
user~/.copilot/mcp-config.jsonPer-user, applies to every project
markspec mcp install --client copilot                  # → .github/mcp.json
markspec mcp install --client copilot --scope user      # → ~/.copilot/mcp-config.json
markspec mcp install --client copilot --scope workspace \
  --binary-path /opt/markspec/bin/markspec

The entry nests under mcpServers.markspec like Claude Code’s .mcp.json, but the local-server shape differs — it adds type and tools:

{
  "mcpServers": {
    "markspec": {
      "type": "local",
      "command": "markspec",
      "args": ["mcp"],
      "tools": ["*"]
    }
  }
}

An omitted --scope defaults to workspace, matching the per-repo model of markspec init. The .github/mcp.json path (verified against GitHub Copilot CLI 1.0.66) is deliberately distinct from the claude client’s .mcp.json so the two clients never contend for one file.

Editor / agent-mode surface. --client copilot covers Copilot’s CLI surfaces only — the file-based sources the terminal reads (.github/mcp.json, ~/.copilot/mcp-config.json). Copilot’s in-editor agent mode is served differently: the driftsys.markspec-ide extension (below) registers the MCP server programmatically via VS Code’s lm.registerMcpServerDefinitionProvider API (VS Code 1.101+), so it lands in the same agent-mode tool registry as a .vscode/mcp.json entry — without markspec writing that file. The extension route reaches only the in-editor agent; it cannot reach the Copilot CLI or a GitHub-hosted coding agent, which is why those file-based surfaces need --client copilot.

This follows the sanctioned-surfaces policy: markspec writes MCP config only via a vendor CLI or a user/workspace file the client reads, never a file an app manages as its own private state. See #637 for the policy’s wider application to the Claude clients.

VS Code (Copilot / Claude)

The VS Code extension registers the MCP server automatically — no extra configuration needed. See VS Code extension.

Skillset

The skillset is a bundle of Claude Code skills that teach AI assistants MarkSpec-specific authoring conventions. Install it once per project; the skills activate automatically when Claude Code opens the directory.

Install with upskill

Install into the current project (writes per-client output under .claude/, .github/, .opencode/, and .agents/ and records the install in .upskill-lock.json):

upskill add driftsys/markspec:skills/markspec-core.bundle.yaml

Install globally into $HOME instead, so the skills are available in every project for the current user:

upskill add --global driftsys/markspec:skills/markspec-core.bundle.yaml

The bundle includes:

SkillActivates onWhat it does
markspec-entry-authoringAny entry authoring requestGuides the agent through correct block syntax and attributes
markspec-core-rulesDiagnostic triageMaps MSL- codes to their fix, suppression, and rationale
markspec-write-loopFile modification tasksEnforces the insert → fmt → check agent write loop
markspec-gherkinTest entry authoringApplies GWT / Gherkin structure to test entries
markspec-traceability-reviewPR reviews, traceability auditsWalks the graph, checks coverage, flags orphaned entries
markspec-profile-bundle-authoringProfile manifest writingValidates manifest fields, extends chains, display-ID patterns

Manual install (without upskill)

If your team does not use upskill, download the bundle from the repository and place it in .claude/plugins/:

curl -fsSL https://raw.githubusercontent.com/driftsys/markspec/main/skills/markspec-core.bundle.yaml \
  -o .claude/plugins/markspec-core.bundle.yaml

Invoking skills from Claude Code

Skills activate automatically when the task matches. You can also invoke them explicitly:

/markspec-write-loop
/markspec-traceability-review

Agent write loop

The canonical pattern for AI-assisted requirement authoring is:

markspec insert <type> <file>    # scaffold a new entry
markspec fmt <file>              # assign ULID, normalize indentation
markspec check <file>            # confirm no broken references

Each step produces structured output the agent can parse:

markspec insert requirement docs/requirements.md --print
markspec fmt docs/requirements.md
markspec check docs/requirements.md --format json

The markspec-write-loop skill enforces this sequence so agents don’t skip the format or validate steps.

Git hooks

MarkSpec exposes composable check primitives — markspec fmt, markspec check, markspec lint, and markspec lock --check. Wire them into git hooks via your hook manager and compose them at the cadence you want. There is no bundled hook command.

What each primitive does

CommandQuestionBlocks?Cadence
markspec fmtIs it in canonical form?every commit
markspec checkIs it structurally valid?yesevery commit
markspec lintIs the prose well-written?nopre-push
markspec lock --checkHas an upstream drifted?yespre-push/CI

markspec fmt --check reports without rewriting (exit 1 if changes are needed); plain markspec fmt rewrites in place.

git-std manages git hooks via .githooks/*.hooks files. Each line takes a prefix — ~ fix (isolate staged files, run, re-stage), ! check (block on failure), ? advisory (never block) — and $@ expands to the matching staged files.

.githooks/pre-commit.hooks — fast, every commit:

~markspec fmt   $@ *.md
!markspec check $@ *.md

.githooks/pre-push.hooks — thorough, before sharing:

!markspec check *.md
?markspec lint  *.md
!markspec lock --check

Then run git std hook install.

With the pre-commit framework

For repos using pre-commit, add to .pre-commit-config.yaml:

repos:
  - repo: local
    hooks:
      - id: markspec-fmt
        name: markspec fmt --check
        entry: markspec fmt --check
        language: system
        files: \.md$
      - id: markspec-check
        name: markspec check
        entry: markspec check
        language: system
        files: \.md$

Then run pre-commit install.

Plain Git hook

Create .git/hooks/pre-commit:

#!/usr/bin/env bash
set -euo pipefail
files=$(git diff --cached --name-only --diff-filter=ACM | grep '\.md$' || true)
[ -z "$files" ] && exit 0
markspec fmt --check $files
markspec check $files
chmod +x .git/hooks/pre-commit

Bypass (emergency)

git commit --no-verify

CI traceability gate

Run MarkSpec in CI to enforce traceability and format hygiene across the entire repository on every push and pull request.

A minimal CI gate runs three jobs in sequence:

fmt-check → check → (optional) lint

All three jobs consume no build artifacts — they operate on the committed source files only.

GitHub Actions

name: MarkSpec

on: [push, pull_request]

jobs:
  fmt-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install markspec
        run: curl -fsSL https://raw.githubusercontent.com/driftsys/markspec/main/install.sh | sh
      - name: Format check
        run: markspec fmt --check docs/**/*.md

  check:
    runs-on: ubuntu-latest
    needs: fmt-check
    steps:
      - uses: actions/checkout@v4
      - name: Install markspec
        run: curl -fsSL https://raw.githubusercontent.com/driftsys/markspec/main/install.sh | sh
      - name: Check
        run: markspec check docs/**/*.md

  lint:
    runs-on: ubuntu-latest
    needs: check
    steps:
      - uses: actions/checkout@v4
      - name: Install markspec
        run: curl -fsSL https://raw.githubusercontent.com/driftsys/markspec/main/install.sh | sh
      - name: Prose lint
        run: markspec lint docs/**/*.md

GitLab CI

stages:
  - quality

markspec-fmt:
  stage: quality
  script:
    - curl -fsSL https://raw.githubusercontent.com/driftsys/markspec/main/install.sh | sh
    - markspec fmt --check docs/**/*.md

markspec-check:
  stage: quality
  script:
    - markspec check docs/**/*.md
  needs: [markspec-fmt]

markspec-lint:
  stage: quality
  allow_failure: true   # lint is informational; remove to make it blocking
  script:
    - markspec lint docs/**/*.md
  needs: [markspec-check]

Exit codes

CodeMeaning
0Clean — no errors, no warnings
1Errors present — commit should be blocked
2Warnings only — informational; gate at your discretion

The check command exits 2 when only warnings are present. Use --strict to promote warnings to errors and make the gate fully binary:

markspec check --strict docs/**/*.md

Traceability report as CI artifact

Generate a coverage or traceability matrix and upload it as an artifact:

- name: Traceability report
  run: markspec report traceability docs/**/*.md --output traceability.md

- uses: actions/upload-artifact@v4
  with:
    name: traceability
    path: traceability.md

Caching the binary

Cache ~/.local/bin/markspec between runs to avoid downloading on every job. Bump the version in the cache key whenever you bump MARKSPEC_VERSION (or the “latest” you’re tracking), so the cache invalidates instead of serving a stale binary:

- uses: actions/cache@v4
  with:
    path: ~/.local/bin/markspec
    key: markspec-${{ runner.os }}-0.10.3

Caching upstream snapshots

markspec lock is the only step that touches the network for references: and dependencies: upstreams — check and compile read the pinned snapshots under .markspec/cache/upstreams/ entirely offline. Cache that directory between CI runs, keyed on the lockfile’s contents, so lock only re-acquires an upstream when its pin has actually moved:

- uses: actions/cache@v4
  with:
    path: .markspec/cache/upstreams
    key: markspec-upstreams-${{ hashFiles('markspec.lock') }}

With a warm cache, markspec lock is idempotent — it verifies each pinned snapshot’s hash and skips re-acquiring an upstream whose pin hasn’t moved.

See also: Multi-repo dependencies.

ISO 26262 / ASPICE workflow

A reference setup for automotive functional-safety and process-compliance projects. This recipe covers the entry type vocabulary, V-model traceability structure, and required label vocabulary.

Profile

MarkSpec does not bundle a turnkey ISO 26262 / ASPICE profile — declare the compliance vocabulary in a local profile and point .markspec.yaml at it:

profiles:
  - "./profiles/compliance"

with profiles/compliance/markspec.yaml declaring the types below. Each type extends: a core type and gets a display-id-pattern:

id: "@acme/compliance"
version: 0.1.0
markspec-schema: "1"

profile:
  types:
    system-requirement:
      extends: Requirement
      display-id-pattern: "SYS_{n:4d}"
      traceability:
        Satisfies:
          target: [stakeholder]
    software-requirement:
      extends: Requirement
      display-id-pattern: "SRS_{n:4d}"
      traceability:
        Satisfies:
          target: [system-requirement]
    # … hazard (extends Risk), validation-test/unit-test (extends Test), etc.

See the Profile guide for the full manifest schema.

Entry type vocabulary

A complete compliance profile declares entry types along these lines:

Display-ID prefixTypeExtendsLevel
STK_stakeholderRequirementAcceptance
SYS_system-requirementRequirementSystem
SRS_software-requirementRequirementSoftware
ARC_architectureContractSoftware
ICD_interfaceSoftwareInterfaceSoftware
TST_validation-testTestAcceptance
SIT_integration-testTestSystem
SWT_unit-testTestSoftware
HZD_hazardRiskSystem

V-model traceability structure

STK (stakeholder)
  └─ Satisfies ─→  SYS (system requirement)
                     └─ Satisfies ─→  SRS (software requirement)
                                        └─ Satisfies ─→  ARC / ICD

TST (validation)  ←─ Verifies ─  STK
SIT (integration) ←─ Verifies ─  SYS
SWT (unit test)   ←─ Verifies ─  SRS

The compiler generates inverse edges automatically — authors only write the forward Satisfies: and Verifies: attributes.

ASIL labels

Entries carry an ASIL label in the trailer block via the Labels: attribute — no separate declaration is required. labels: in project.yaml is inert org project metadata (tags on the project itself); it does not constrain or enforce which Labels: values entries may carry.

- [SRS_BRK_0107] Sensor debouncing

  The sensor driver shall debounce raw inputs to eliminate transient noise
  spikes of duration less than 10 ms.

      Id: 01HGW2Q8MNP3RSTVWXYZABCDEF
      Satisfies: SYS_BRK_0042
      Labels: ASIL-B

In-code entries (V-model colocated)

Software requirements and unit tests at the SRS/SWT level are colocated in source files using doc comments:

#![allow(unused)]
fn main() {
/// [SRS_BRK_0107] Sensor debouncing
///
/// The sensor driver shall debounce raw inputs to eliminate
/// transient noise spikes of duration less than 10 ms.
///
///     Id: 01HGW2Q8MNP3RSTVWXYZABCDEF
///     Satisfies: SYS_BRK_0042
///     Labels: ASIL-B
#[cfg(test)]
fn swt_brk_0107_debounce_rejects_short_pulse() {
    let result = debounce(5); // 5 ms pulse, below 10 ms threshold
    assert!(!result.passed);
}
}

Coverage report

markspec compile "docs/**/*.md" "src/**/*.rs"
markspec report coverage "docs/**/*.md" "src/**/*.rs"

The coverage report shows which requirements have at least one linked test and which are uncovered — the key metric for ISO 26262 traceability audits.

CI gate

Add to your pipeline (see CI traceability gate):

markspec check --strict "docs/**/*.md" "src/**/*.rs"
markspec report coverage "docs/**/*.md" "src/**/*.rs" --output coverage.md

Flag the coverage report as a required CI artifact for the functional safety manager’s review.

Shipping a reference architecture in a profile

A profile can deliver document files to every project that consumes it — see Delivered documents and ADR-030. This recipe walks through the platform-team use case end to end: author a profile that ships a reference architecture as a traceable corpus, consume it from a project, and see what happens when a project entry collides with a corpus ID.

The layout below uses a local profile specifier (a directory inside the project’s own repository) so the recipe needs no network access. The same delivers: section works unchanged for a git+https://… or npm: profile — see Profile specifiers.

1. Author the profile

profile/
├── markspec.yaml
└── reference/
    ├── platform.md      # corpus: true — joins the consumer's graph
    └── guide.md         # docs-only — read, never parsed

profile/markspec.yaml declares one requirement-shaped type per tier and a delivers: section listing both files:

id: platform-arch
version: 1.2.0
markspec-schema: "1"
profile:
  types:
    platform-component:
      extends: Requirement
      display-id-pattern: "PLT_{n:04d}"
    stakeholder-requirement:
      extends: Requirement
      display-id-pattern: "STK_{n:04d}"
      traceability:
        Satisfies:
          target: [platform-component]
          cardinality: 0..1
  delivers:
    - path: reference/platform.md
      corpus: true
      description: Reference platform architecture
    - path: reference/guide.md
      description: Integration guide (read-only reference)

profile/reference/platform.md is the corpus file — an ordinary MarkSpec document, formatted and Id:-stamped like any other (run markspec fmt on it inside the profile directory before shipping):

- [PLT_0001] Platform core service

  The platform core service shall expose the vehicle state bus within 50 ms of a
  state change.

      Id: 01ARZ3NDEKTSV4RRFFQ69G5FAV
      Type: platform-component

profile/reference/guide.md can be anything readable — it is surfaced to humans and MCP-capable agents, never parsed for entries:

# Integration guide

How to wire your service into the platform state bus.

2. Consume it from a project

Point .markspec.yaml at the profile directory:

# .markspec.yaml
profiles:
  - ./profile

Exclude the profile directory from project discovery. The profile lives inside the same repository tree, so without an exclude: entry the ordinary project walk would parse profile/reference/platform.md a second time as an ordinary project file — the same entries would be indexed once via discovery (no origin) and once via the corpus loader (origin set), self-colliding as MSL-R014 against itself. exclude: is markspec tool config, so it lives in the same .markspec.yaml alongside profiles::

# .markspec.yaml
profiles:
  - ./profile
exclude:
  - profile/

This step is specific to a local specifier. A git+https://…#tag or npm:… profile resolves into .markspec/cache/<sha>/…, which project discovery already skips — nothing to exclude there.

Now author a project requirement that traces into the corpus:

<!-- docs/requirements.md -->

- [STK_0001] Vehicle state access

  The system shall read the vehicle state from the platform core service within
  100 ms.

      Id: 01ARZ3NDEKTSV4RRFFQ69G5FB0
      Type: stakeholder-requirement
      Satisfies: PLT_0001

PLT_0001 is never declared in the project — it lives entirely inside the profile’s delivered corpus.

3. Verify

markspec check

exits 0. Satisfies: PLT_0001 resolves against the corpus with no MSL-L006 warning — remove the delivers: section (or the Satisfies: target) and the identical reference becomes an unresolved-target warning, confirming the corpus is actually load-bearing rather than coincidentally passing.

markspec show PLT_0001 docs/requirements.md

prints the corpus entry with its provenance, and a Source: line in the stable <profile-id>@<version>:<path>:<line>:<column> form rather than a raw filesystem path:

PLT_0001  Platform core service
  Type: platform-component
  Shape: Authored
  Origin: platform-arch@1.2.0
  ...
  Source: platform-arch@1.2.0:reference/platform.md:1:1
markspec profile show

lists both delivered documents, with the corpus file’s entry count and the docs-only file’s description:

Delivered documents (2):
  - reference/platform.md   corpus   1 entries   [platform-arch]
  - reference/guide.md   doc      Integration guide (read-only reference)   [platform-arch]

4. See the collision gate fire

Add a second project file that reuses the corpus’s display ID:

<!-- docs/collide.md -->

- [PLT_0001] My own platform entry

  The colliding entry shall report a distinct status within 10 ms.

      Id: 01ARZ3NDEKTSV4RRFFQ69G5FC0
      Type: platform-component
markspec check

now exits 1 with MSL-R014, naming the delivering profile:

error[MSL-R014]: docs/collide.md:1 display ID 'PLT_0001' is already delivered
by platform-arch@1.2.0; rename this entry — delivered corpus entries are
read-only

The fix is always to rename the project entry — the corpus entry is not yours to change. Delete docs/collide.md (or rename its display ID) to return to a clean check.

Notes

  • Read-only by construction. markspec fmt and rename never touch delivered corpus files — they are outside the discovered project file set entirely, not merely protected by a check.
  • Corpus-blind lockfile gate. markspec lock and the MSL-L212 drift gate only ever see project-authored edges; a Satisfies: edge between two corpus entries is invisible to both, by design (ADR-030 §D6, deferred lockfile integration).
  • A missing corpus file is a hard error. If profile/reference/platform.md is absent from the published package, check fails with PROFILE-DELIVERS-001 rather than silently compiling a partial graph.

Multi-repo dependencies

MarkSpec can consume another repository’s requirements as a versioned git dependency. The upstream repo’s entries hydrate into your project’s own traceability graph, so a Satisfies: link can cross the repo boundary and markspec check resolves it exactly like a same-project reference. This recipe walks the consumer side end to end: declare the dependency, lock it, trace into it, verify, and see each failure mode.

For the graph-integration semantics that make this work — how upstream entries join the graph, resolve, and gate collisions — see Upstream entries resolve in the graph in the CLI guide and ADR-031. This recipe is the how-to; that section is the reference.

1. Declare the dependency

Add a dependencies: entry to project.yaml naming the upstream repo’s git URL:

# project.yaml
name: io.acme.braking-system
version: "2.3.0"

dependencies:
  - url: git@github.com:acme/aeb-icd.git
    name: icd # short id: cache dir, lock row, Origin badge
    version: "v2.1.0" # exact tag → frozen baseline
  • url (required) — the upstream git repository.
  • name (optional) — a short id used for the cache directory, the lockfile row, and the Origin: badge on hydrated entries. Derived from the URL when absent (aeb-icd).
  • version (optional) — the resolution intent:
    • absent → auto: the latest release tag, else the default-branch head.
    • an exact tag (v2.1.0) → a frozen baseline that never moves until you change it.
    • a branch name (main) → tracks that branch’s head on every re-lock.

2. Lock it

markspec lock

markspec lock resolves the declared version intent against the upstream repo, acquires the tree at the resolved commit via a shallow git fetch by sha (no clone, no history), compiles it in-process, writes the compiled snapshot to .markspec/cache/upstreams/icd/, and pins the result as an [[upstream.dependency]] lockfile row. The cache directory is gitignored automatically — lock appends .markspec/cache/ to .gitignore the first time it runs.

The pinned row records the requested intent, what actually resolved, the exact commit, and the snapshot hash:

# markspec.lock (excerpt — @generated by `markspec lock`, do not hand-edit)
[[upstream.dependency]]
id = "icd"
url = "git@github.com:acme/aeb-icd.git"
intent = "v2.1.0"
resolved = "tag:v2.1.0"
sha = "9f3c1a2e5b7d0c4f6a81b2d3e4f5069718293a4b"
snapshot = "sha256:b1946ac92492d2347c6235b4d2611184..."
locked-at = "2026-07-05T12:00:00Z"

Only markspec lock touches the network. Once the pin exists, check, compile, the LSP, and the MCP server all resolve entirely offline from the cached snapshot — see lock in the CLI guide for the first-lock / restore / update flows.

3. Trace across the repo boundary

Author a project entry whose trace value names an upstream display ID:

- [STK_BRK_0007] Deceleration request honored

  The braking system shall action a deceleration request within 50 ms of
  receiving it on the vehicle bus.

      Id: 01ARZ3NDEKTSV4RRFFQ69G5FAV
      Type: stakeholder-requirement
      Satisfies: ICD_BRK_0010

ICD_BRK_0010 is declared in the upstream ICD repo, never in this project — markspec lock hydrated it into the graph as a read-only, origin-tagged entry.

4. Verify

markspec check

exits 0. Satisfies: ICD_BRK_0010 resolves across the repo boundary with no warning. The upstream entry is a full resolution target for show, context, dependents, and report too:

markspec show ICD_BRK_0010 "docs/**/*.md"

prints the upstream entry with an Origin: line naming the upstream and its version. The Source: line gives the entry’s location in the upstream repo’s own tree — the path it compiled from, not a local cache path:

ICD_BRK_0010  Deceleration bus message contract
  Type: interface
  Shape: Authored
  Origin: icd@v2.1.0
  ...
  Source: docs/icd.md:12:1

5. Failure modes

SymptomDiagnosticFix
Satisfies: names an id no upstream declaresMSL-T014Fix the trace value. The warning names every upstream searched, e.g. not found in project or upstreams: icd.
A project entry reuses an upstream display ID or Id:MSL-R014Rename the project entry — upstream entries are read-only and win the collision.
The cached snapshot is missing or its hash moved (fresh clone, cleaned tree, CI)MSL-L212Run markspec lock — the restore flow re-fetches the pinned content without moving the pin.
A dependencies: pin resolved to a branch or bare sha rather than a tagMSL-L215Advisory by default; markspec check --strict promotes it to a hard error, so a release build cannot pass against an unbaselined dependency. Pin an exact tag to clear it.

MSL-T014 replaces the plain MSL-L006 “unresolved reference” warning only once the project declares any dependencies: or references: — a project with no declared upstreams keeps the MSL-L006 behavior.

dependencies: vs references:

Both pin offline-after-lock under .markspec/cache/upstreams/<id>/, but they model different relationships and are treated differently by coverage:

  • dependencies: — another project you build on (a git repository). Its entries participate in coverage like your own: a hydrated product requirement with no component coverage is a reported gap. Use this when you trace into the upstream and expect the traceability to be complete.
  • references: — a published site you cite (a compile-output snapshot). Its entries are traceability leaves — report coverage never reports one as an orphan or an unsatisfied gap, because a citation isn’t something your own project is expected to cover.

See Upstream entries resolve in the graph in the CLI guide for the authoritative coverage-treatment rule, and The projectRef shape for the full field reference shared by both lists.

Notes

  • Shallow fetch, not a clone. Acquisition is a git fetch at the resolved sha — no working clone, no history. Resolving an upstream from a forge release tarball (no git at all) is a planned fast-follow, not yet shipped.
  • Offline after lock. Only markspec lock reaches the network. check, compile, the LSP, and the MCP server read the pinned cache and never fetch.
  • Upstream entries are read-only and validation-exempt. No structural checks or prose lint run against them — that already happened in their own repo — but they remain full resolution targets for cross-repo trace edges. See Upstream entries resolve in the graph for the full rule.
  • Aggregate a whole program from one root. A root or program repo that depends on every member repo resolves the entire program’s cross-repo graph in one compile — see the Root/program project pattern in the CLI guide.
  • Cache the snapshots in CI. Key an actions/cache on markspec.lock so a job only re-acquires an upstream when its pin actually moves.

FAQ

Short answers to the questions a team actually asks when trialing MarkSpec.


What is a display ID?

A display ID is the human-readable identifier in the […] bracket on an entry title line, for example STK_PRJ_0001. It encodes the entry type prefix (STK), an optional domain segment (PRJ), and a zero-padded sequence number (0001). Display IDs are mutable — you can rename STK_PRJ_0001 to STK_AEB_0001 — and MarkSpec rewrites all references in the workspace when you use the LSP rename command.


What is a ULID and why does MarkSpec assign one?

A ULID (Universally Unique Lexicographically Sortable Identifier) is a 26-character string like 01KPVVC9J2B1ZA64QZEMHF02PW. MarkSpec stamps one onto each entry during markspec fmt and stores it as the Id: attribute.

The ULID is immutable. Once assigned it never changes, even if the display ID or title is renamed. Traceability links between projects and external tools (JIRA, DOORS) reference the ULID, not the display ID, so renaming an entry never breaks cross-tool links.


How does MarkSpec discover project.yaml?

markspec walks up the directory tree from the current working directory until it finds a project.yaml file. The directory containing that file becomes the project root. If no project.yaml is found, commands that require project context (compile, show, context, dependents, report, next-id, doc build, book build) exit with an error. fmt and check work without a project.yaml.


Can I use MarkSpec without a profile?

Yes. markspec fmt and markspec check work without a profile — they apply built-in formatting rules and lint checks. Commands that need type vocabulary (compile, create, next-id) require a profile to know the display-ID patterns for each type.

To activate a profile, create a .markspec.yaml in the project root:

profiles:
  - ./profiles/markspec.yaml

See the Profile guide for the full configuration reference.


What do exit codes 0, 1, and 2 mean?

CodeMeaning
0Success — no errors, no warnings
1Error — validation failed or command error
2Warnings only — check found warnings but no errors

In CI, treat exit code 2 as a pass or a fail depending on your policy. Pass --strict to markspec check to promote warnings to errors (exit code 1).


Where should I put my Markdown files?

Anywhere you like — MarkSpec does not enforce a directory layout. The convention used in this project is:

  • docs/ — Markdown files containing requirements and architecture descriptions
  • src/ — source code with doc-comment entries (Rust ///, Kotlin /**)

Pass explicit paths or globs to commands that need them:

markspec check docs/requirements.md
markspec compile "docs/**/*.md" src/main.rs

What is the difference between fmt and check?

CommandWhat it doesWrites files?
markspec fmtStamps ULIDs, normalizes indentation and attribute orderYes (in place)
markspec checkChecks broken references, missing IDs, duplicates, lint rulesNo

Run fmt first (see the git hooks recipe for pre-commit setup) to ensure every entry has a ULID before committing. Run check in CI to catch broken traceability links.


How do I set up the LSP in my editor?


Can I run multiple profiles at once?

The current implementation supports one active profile per project. If multiple profiles are listed in .markspec.yaml, a PROFILE-LOAD-006 warning is emitted and only the first is used. Profile stacking (extends chains) is the recommended way to compose vocabulary. See the Profile guide.

Migration guide

Stage 2 — content deferred.

This chapter will document how to migrate existing requirements documents, DOORS exports, and ReqIF files into MarkSpec format. Its content is deferred to Stage 2 of the MarkSpec documentation roadmap, after the toolchain distribution and profile-schema implementation are complete.

Per project decision: no migration tooling or backward-compatibility shims are provided until version 1.0. All migration paths will be documented here when they ship.

Stage 2 will cover:

  • DOORS XML → MarkSpec — automated export and ULID assignment.
  • ReqIF → MarkSpec — type mapping and attribute preservation.
  • Display-ID renaming — workspace-wide rename via the LSP or CLI.
  • Version upgrade notes — breaking changes between MarkSpec releases.