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.
- Install the MarkSpec extension (
driftsys.markspec-ide) from the Marketplace (see VS Code Marketplace below). - Open a folder that contains
.mdfiles. 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.
- Install the CLI (see Binary install below).
- Install the MarkSpec extension from the Marketplace or Open VSX.
- In your project root, run
markspec init(see Project setup withmarkspec init). This writes.mcp.jsonso Claude Code discovers the MarkSpec MCP server. - 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.
- Install the CLI (see Binary install).
- Install the MarkSpec extension from the Marketplace or Open VSX.
- Run
markspec initin your project root — it detects opencode and writesopencode.json. - 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:
- Open the Extensions panel (
Ctrl+Shift+X/Cmd+Shift+X). - Search for MarkSpec.
- 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:
- Detects your platform and architecture.
- Downloads the release binary from GitHub Releases.
- Verifies the SHA256 checksum.
- 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:
- Verifies the host is x86_64. ARM Windows is not supported yet.
- Downloads
markspec-x86_64-pc-windows-msvc.tar.gzand its SHA-256. - Verifies the checksum with
Get-FileHash. - Extracts
markspec.exewithtar(bundled on Windows 10 1803+). - Places the binary in
%USERPROFILE%\.local\bin(override with theMARKSPEC_INSTALL_DIRenvironment 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.exemay 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.
| Platform | File |
|---|---|
| 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— recommendsdriftsys.markspec-ide- MCP config for each detected agent client:
- Claude Code →
.mcp.jsonat the repo root - opencode →
opencode.jsonat the repo root
- Claude Code →
- The MarkSpec skills bundle via
upskill add(skipped with a warning ifupskillis 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.initwritesmarkspec.lockwith 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),initwarnsLOCKFILE_STUB_NEEDS_PIN; runmarkspec lockonce to resolve and pin the profile’s upstreams.
See AI agents and skillset for the full
markspec initflag 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/:
| Skill | Purpose |
|---|---|
markspec-entry-authoring | Entry block syntax, shapes, attributes |
markspec-core-rules | Validation rules and diagnostic codes |
markspec-write-loop | The insert → fmt → check agent loop |
markspec-gherkin | GWT / Gherkin pattern for test entries |
markspec-traceability-review | Cross-file link review agent |
markspec-profile-bundle-authoring | Writing 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)
Pre-compiled binary (recommended)
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 types | Concepts |
| See all CLI flags and subcommands | CLI guide |
| Set up a compliance profile | Profile guide |
| Browse answers to common questions | FAQ |
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 directives —
glossary,components, andreferencesdirectives 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 → checkloop 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, andreferences.mdusing 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.yamlaccepts 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 withextends:(see below), rather than stacking entries here. A bundled default profile loads automatically whenprofiles:is empty; opt out withdefault-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
| Key | Type | Required | Default | Notes |
|---|---|---|---|---|
path | string | yes | — | Relative to the profile directory (next to its markspec.yaml). |
corpus | boolean | no | false | true → parse the file’s entries into the consumer’s graph. Markdown (.md) only. |
description | string | no | — | Shown 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 (
./profilein.markspec.yaml) lives inside the consumer’s own repository tree. If.markspec.yamldoes notexclude:that directory, the ordinary project walk parses the same corpus file the corpus loader also parses — the entries are indexed twice and self-collide asMSL-R014. Add the profile directory toexclude::# .markspec.yaml exclude: - profile/Git and npm profile specifiers resolve into
.markspec/cache/<sha>/…, which project discovery already skips — only alocalspecifier 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). Themarkspec.yamlmust sit at the repo root, or at the subpath named in the specifier. Consumers reference it withgit+https://…#v1.0.0. -
npm — the profile directory is the npm package. Place
markspec.yamlat the package root alongside apackage.json, thennpm 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 packand readsmarkspec.yamlfrom 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:
| Mode | Behavior |
|---|---|
none | No discipline derivation. Recommended for projects that do not separate SW from HW. |
flat | One software-requirement / hardware-requirement bucket per discipline. Default. |
tiered | Profile 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
typeRegistryinmarkspec compile --format jsonmaps 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
$Speedcarries, 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 —
$speedcannot 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
$Nametoken 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$.namereference resolves against the entry’s root namespace. - Completion — inside a
typlfence 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:
| Reference | Cites |
|---|---|
ux:media.home | the media.home surface itself |
ux:media.home@loading | the surface’s loading state |
ux:media.home/play | the play element on that surface |
ux:media.home/play!activate | the 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):
| Verb | Meaning |
|---|---|
activate | press or tap — a momentary action |
toggle | flip a two-state control on/off |
select | choose one item from a set |
adjust | move a continuous control — a slider, a dial |
input | enter free-form data — text, a search query |
scroll | pan or page through content |
drag | a drag-and-drop or reposition gesture |
navigate | go to another screen — requires a -> target clause |
dismiss | close or cancel |
ask | a conversational or voice query |
observe | a 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):
| Flag | Description |
|---|---|
-h, --help | Show help |
-V, --version | Show version |
-q, --quiet | Suppress 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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Project name. Must match ^[a-z][a-z0-9.-]*$ (lowercase, digits, ., -). Reverse-DNS convention recommended. |
version | string | yes | Project version. Quote in YAML to avoid number coercion. |
dependencies | projectRef[] | no | Defaults to []. Projects this project uses (git repositories). See the projectRef shape below. |
references | projectRef[] | no | Defaults 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:
| Key | Type | Required | Description |
|---|---|---|---|
url | string | yes | Git repository URL (dependencies:) or published-site base URL (references:; file:// accepted). |
version | string | no | Version intent: an exact tag freezes a baseline, a branch name tracks its head, absent means auto (latest release tag, else default-branch head). |
name | string | no | Upstream 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 lockresolves the declaredversion:intent against the repo, acquires the tree at the resolved sha (a shallowgit 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 undermarkspec check --strict(MSL-L215; seelockbelow).references:— registries or external sources this project cites, such as another project’s published compile output.markspec lockfetches and pins these against a cache — seelockbelow. The declaredversion:is recorded but not yet consulted when resolving areferences:entry —lockcurrently 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, andreportall see the upstream entry, tagged with anOrigin:line / column showing<upstreamId>@<version>— seeshowandreportbelow. MSL-T014replacesMSL-L006for a trace value that still doesn’t resolve once the project declares anydependencies:/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 plainMSL-L006behavior.- A project entry that reuses an upstream’s display ID or
Id:failscheckwithMSL-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 coveragenever 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:
| Field | Type | Default | Description |
|---|---|---|---|
exclude | string[] | [] | Gitignore-syntax patterns excluded from project-wide file discovery (bare check/lint/fmt), anchored at the project root. Applied after .gitignore rules. |
caption-conventions | map<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 documentationsrc/— 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]
| Flag | Type | Default | Description |
|---|---|---|---|
--client | string | — | Force write for the named client (repeatable): claude, opencode |
--all-clients | bool | false | Write configs for claude + opencode regardless of detection |
--no-mcp | bool | false | Skip all MCP scaffolding |
--no-skills | bool | false | Skip upskill add |
--profile | string | — | Profile spec (conflicts with --no-profile) |
--no-profile | bool | false | Core-only mode (default-profile: false) |
--binary-path | string | — | Absolute path to the markspec binary for MCP configs |
--dry-run | bool | false | Report decisions, write nothing |
--force | bool | false | Overwrite skip-on-exists files; required for a non-empty dir or non-TTY |
--format | string | text | Summary 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]
| Flag | Type | Default | Description |
|---|---|---|---|
--check | bool | false | Report 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.yamlexclude:— the same mechanismcheck/lintuse, not a new flag. - Every rewrite is safety-gated. If reformatting an entry body would change
its meaning,
fmtkeeps the original text for that entry and reports an advisoryMSL-F012instead 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.lockrecords the stabletarget-ulid;fmtuses it to rewrite the stale display ID to the target’s new name. - Unresolved references are left as-is —
markspec checkreports them viaMSL-L006, orMSL-T014when the project declaresdependencies:/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]
| Flag | Type | Default | Description |
|---|---|---|---|
--strict | bool | false | Promote warnings to errors |
--format | string | text | Output 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):
| Gate | Severity | What it checks |
|---|---|---|
| Parse + structure + attributes | as today | Malformed 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 documents | as today | Listing-file conventions (e.g. SUMMARY.md structure). |
Format drift (MSL-F010) | error | The 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) | error | A 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 warning | The 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...>
| Flag | Type | Default | Description |
|---|---|---|---|
--format | string | text | Output 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...>
| Flag | Type | Default | Description |
|---|---|---|---|
--depth | number | 10 | Maximum depth to walk |
--format | string | text | Output 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...>
| Flag | Type | Default | Description |
|---|---|---|---|
--format | string | text | Output format: json, text |
Examples:
markspec dependents STK_PRJ_0001 "docs/**/*.md"
Building
compile
Parse files, build traceability graph, output compiled result.
markspec compile <paths...>
| Flag | Type | Default | Description |
|---|---|---|---|
--format | string | text | Output 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.
| Flag | Type | Default | Description |
|---|---|---|---|
--format | string | md | Output format: md, json, csv |
--scope | string | — | Filter by domain abbreviation |
--label | string | — | Filter by label value |
--output | string | — | Write 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>
| Flag | Type | Default | Description |
|---|---|---|---|
--print | bool | false | Echo the inserted block to stdout as well |
The command:
- Finds the highest existing display ID for
<type>in<file>. - Computes the next sequential display ID.
- Generates a fresh ULID.
- 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...>
| Flag | Type | Default | Description |
|---|---|---|---|
--format | string | text | Output 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]
| Flag | Type | Default | Description |
|---|---|---|---|
--format | string | text | Output format: json, text |
--strict | bool | false | Promote 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 …"
| Flag | Type | Default | Description |
|---|---|---|---|
--text | string | — | Inline prose to score |
--id | string | — | Identifier to echo back in the result |
--format | string | — | Output 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
| Flag | Type | Default | Description |
|---|---|---|---|
--check | bool | false | CI mode: read-only, exit 1 on drift. |
--update | string | — | Force re-resolve every references: entry, or one by id (--update=<id>). |
--format | string | text | Output 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.
| Flow | When | Behavior |
|---|---|---|
| First lock | A references: entry has no lockfile row yet. | Fetch the published snapshot, cache it, write a pinned [[upstream.registry]] row. |
| Restore | A 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. |
| Update | markspec lock --update (every reference) or --update=<id> (one). | Re-fetch and move the pin to whatever the source currently serves. |
Diagnostic codes:
| Code | When | Meaning |
|---|---|---|
MSL-L213 | markspec lock | A 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-L214 | markspec 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-L212 | markspec 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-L215 | markspec check | A 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-L216 | markspec lock | A 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:
| Flag | Type | Default | Description |
|---|---|---|---|
--state | string | — | Filter to one remote_state (ok, behind, ahead, conflict, unreachable, deleted-upstream, unbound). |
--format | string | text | Output format: json, text. |
sync log — tail the per-system sync log (NDJSON):
| Flag | Type | Default | Description |
|---|---|---|---|
--tail | number | 20 | Number of lines from the end. |
--op | string | — | Filter to one op: push, pull, conflict, resolve. |
--since | string | — | Filter to entries at or after this RFC 3339 timestamp. |
--format | string | text | Output format: json, text. |
sync show — full sync state for one bound entry:
| Flag | Type | Default | Description |
|---|---|---|---|
--format | string | text | Output 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>
| Flag | Type | Default | Description |
|---|---|---|---|
-o, --output | string | <file>.pdf | Output 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
| Flag | Type | Default | Description |
|---|---|---|---|
-o, --output | string | _site | Output directory |
-s, --summary | string | SUMMARY.md | SUMMARY.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.md → recipes-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
| Flag | Type | Default | Description |
|---|---|---|---|
--format | string | text | Output 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
| Flag | Type | Default | Description |
|---|---|---|---|
--format | string | text | Output 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
| Flag | Type | Default | Description |
|---|---|---|---|
--check | bool | false | Check only; exit 1 if a newer release is available |
--version | string | — | Pin a specific release (downgrade allowed) |
--format | string | text | Output 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):
--root <path>— pass the flag once per candidate root.MARKSPEC_PROJECT_ROOT— colon-separated list of candidate roots.CLAUDE_PROJECT_DIR— injected automatically by Claude Code (v2.1.139+); no configuration needed.- 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 thesatisfieschain 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>
| Flag | Type | Default | Description |
|---|---|---|---|
--client | string | — | Client ID: claude, cursor, opencode, vscode, copilot. |
--scope | string | client default | Config scope: user or workspace. Honoured by copilot; other clients are fixed to one scope. |
--binary-path | string | invoked binary name | Explicit 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>
| Flag | Type | Default | Description |
|---|---|---|---|
--editor | string | — | Editor ID: vscode, neovim, zed. |
--binary-path | string | invoked binary name | Explicit 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:
| Command | Intended purpose |
|---|---|
book dev | Live preview with hot reload |
deck build | Slides → PDF via Touying/Typst |
deck dev | Live 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
markspecMCP 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+P →
Extensions: Install from VSIX… and select the .vsix file, or use the
development host:
code --extensionDevelopmentPath=editors/vscode
Configuration
| Setting | Default | Description |
|---|---|---|
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.enabled | true | Register 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
| Feature | Description |
|---|---|
| Real-time diagnostics | Validation errors and warnings inline as you type |
| Entry block completions | - [ → full block scaffold with display ID and attribute skeleton |
| ID reference completions | Satisfies: → pick from all display IDs in the workspace |
| Type completions | Type: → core types + profile-declared types |
| Hover | Hover any display ID to preview the entry’s title, type, and body |
| Go-to-definition | F12 on a display ID jumps to the entry’s source location |
| Find all references | Shift+F12 lists every file that references a display ID |
| Workspace rename | F2 renames a display ID across the entire workspace |
| Document outline | Outline view lists every entry in the file |
| Workspace symbol search | Ctrl+T fuzzy-searches entries by display ID or title |
| Folding | Each entry block is collapsible |
| Document highlights | Cursor on a display ID highlights every occurrence in the file |
| Code lens | Per-entry inline lenses: “↑ N dependents” and “↓ Satisfies: ID — Title” |
| Inlay hints | Per-entry inline hints: resolved : <type> and (N dependents) counters |
| Document links | Verified-by: file-path values are clickable links to the test source |
| Document formatting | Shift+Alt+F runs the same code path as markspec fmt on the buffer |
| Semantic tokens | Display IDs, ULIDs, modal verbs, EARS triggers, and typl tokens are syntax-highlighted |
| Quick fixes | One-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:
- Open Extensions (
Ctrl+Shift+X/Cmd+Shift+X). - Search for MarkSpec.
- Install
driftsys.markspec-ide— or runcode --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.
| Setting | Default | Description |
|---|---|---|
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:
| File | Schema |
|---|---|
.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 runmarkspec init) to mark the folder as a MarkSpec project.
Extension activates but shows no diagnostics
- Confirm the binary is on
PATH:markspec --versionin 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
.mdfiles, 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 thedriftsys.markspec-ideextension- MCP config for each detected client:
- Claude Code →
.mcp.jsonat repo root - opencode →
opencode.jsonat repo root
- Claude Code →
- Skills bundle via
upskill add(warns and continues ifupskillis 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/pathor/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:
| Tool | Description |
|---|---|
entry_search | Fuzzy search entries by display ID or title |
entry_show | Show one entry’s full detail (body, outgoing + incoming links) |
entry_list | Spec overview (per-type counts) or paginated listing of entries |
entry_context | Walk the Satisfies chain upward from an entry |
entry_neighborhood | Show an entry’s parents (up) and children (down) as a subgraph |
validate | Run validation and return structured diagnostics |
markspec_refresh | Re-index the workspace after file changes |
profile_describe | Describe 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:
| Scope | Path | Notes |
|---|---|---|
workspace | .github/mcp.json | Default; committable, per-repo |
user | ~/.copilot/mcp-config.json | Per-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:
| Skill | Activates on | What it does |
|---|---|---|
markspec-entry-authoring | Any entry authoring request | Guides the agent through correct block syntax and attributes |
markspec-core-rules | Diagnostic triage | Maps MSL- codes to their fix, suppression, and rationale |
markspec-write-loop | File modification tasks | Enforces the insert → fmt → check agent write loop |
markspec-gherkin | Test entry authoring | Applies GWT / Gherkin structure to test entries |
markspec-traceability-review | PR reviews, traceability audits | Walks the graph, checks coverage, flags orphaned entries |
markspec-profile-bundle-authoring | Profile manifest writing | Validates 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
| Command | Question | Blocks? | Cadence |
|---|---|---|---|
markspec fmt | Is it in canonical form? | — | every commit |
markspec check | Is it structurally valid? | yes | every commit |
markspec lint | Is the prose well-written? | no | pre-push |
markspec lock --check | Has an upstream drifted? | yes | pre-push/CI |
markspec fmt --check reports without rewriting (exit 1 if changes are needed);
plain markspec fmt rewrites in place.
With git-std (recommended)
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.
Recommended pipeline
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
| Code | Meaning |
|---|---|
0 | Clean — no errors, no warnings |
1 | Errors present — commit should be blocked |
2 | Warnings 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 prefix | Type | Extends | Level |
|---|---|---|---|
STK_ | stakeholder | Requirement | Acceptance |
SYS_ | system-requirement | Requirement | System |
SRS_ | software-requirement | Requirement | Software |
ARC_ | architecture | Contract | Software |
ICD_ | interface | SoftwareInterface | Software |
TST_ | validation-test | Test | Acceptance |
SIT_ | integration-test | Test | System |
SWT_ | unit-test | Test | Software |
HZD_ | hazard | Risk | System |
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 fmtand 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 lockand theMSL-L212drift gate only ever see project-authored edges; aSatisfies: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.mdis absent from the published package,checkfails withPROFILE-DELIVERS-001rather 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 theOrigin: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
| Symptom | Diagnostic | Fix |
|---|---|---|
Satisfies: names an id no upstream declares | MSL-T014 | Fix 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-R014 | Rename 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-L212 | Run 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 tag | MSL-L215 | Advisory 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 coveragenever 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 fetchat 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 lockreaches 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/cacheonmarkspec.lockso 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?
| Code | Meaning |
|---|---|
0 | Success — no errors, no warnings |
1 | Error — validation failed or command error |
2 | Warnings 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 descriptionssrc/— 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?
| Command | What it does | Writes files? |
|---|---|---|
markspec fmt | Stamps ULIDs, normalizes indentation and attribute order | Yes (in place) |
markspec check | Checks broken references, missing IDs, duplicates, lint rules | No |
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?
- VS Code — install the
markspec-ideextension fromeditors/vscode/in the repository. See CLI guide — VS Code. - Neovim — add a
vim.lsp.startautocmd. See CLI guide — Neovim. - Other editors — point your LSP client at
markspec lsp. Transport: stdio. See CLI guide — Other editors.
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.