OpenSpec files using the reqstool integration reference requirements via opaque IDs
(e.g. CORE_0004, SVC_CLI_0003), keeping specs DRY. In a plain-text viewer these
IDs are unreadable without context. The enrich command (and matching MCP tool)
resolves those IDs from the loaded requirements database and injects titles and
further fields inline, making specs human-readable while reqstool remains the
single source of truth.
Scope: one-shot CLI command + MCP tool. No preset customisation. No --fields
flag. Daemon mode (--stdio) deferred to a follow-up.
reqstool enrich --preset <name> [--input <file>] [-o <file>] [source]
| Argument | Default | Description |
|---|---|---|
--preset NAME |
required | Built-in preset name (see below) |
--input / -i FILE |
stdin | Input file to enrich |
-o / --output FILE |
stdout | Output file |
source |
auto-detect | Optional location sub-command (local -p <path>, git, maven, pypi). If omitted, walks up from cwd until .reqstool-ai.yaml is found (same as reqstool mcp). |
| Preset | Trigger | Fields | Drop stubs |
|---|---|---|---|
openspec:spec |
colon-header | all | yes |
openspec:delta-spec |
colon-header | all | yes |
openspec:design |
inline | title only | no |
openspec:proposal |
inline | title only | no |
openspec:tasks |
inline | title only | no |
openspec:delta-spec is a named preset with identical behaviour to openspec:spec.
All presets skip inline backtick spans and fenced code blocks.
- Pre-scan: mark no-inject zones (inline backtick spans, fenced code blocks).
- For each line:
- If inside a fenced block → pass through unchanged.
- If line matches
.*:\s+<ID>\s*$(ID is last token, preceded by colon) and the ID is known and not in a backtick span → inject enrichment block (see below). - If line matches the stub pattern for the same ID just enriched → drop it.
- Otherwise → pass through unchanged.
Stub pattern: ^\s*The system SHALL (implement|pass)\s+<ID>\.?\s*$
- Pre-scan: mark no-inject zones (inline backtick spans, fenced code blocks).
- For each line:
- Find all occurrences of known IDs not inside no-inject zones.
- Process matches right-to-left (to preserve string positions).
- For each match: append
— <title>immediately after the ID. - Every occurrence across the entire document is enriched (no deduplication).
Appended inline immediately after the matched ID:
### Requirement: CORE_0004 — Detect Build System
For MVRs (no title field), the inline slot uses pass/fail:
#### Result: MVR_001 — PASSED
One > **Label**: value line per field, in canonical order, omitting null/empty fields.
REQ field order: Significance → Description → Rationale → Categories → References → Implementation → Revision → Lifecycle
SVC field order: Description → Verification → Instructions → Requirements → Revision → Lifecycle
MVR field order: Comment → SVCs
### Requirement: CORE_0004 — Detect Build System
> **Significance**: SHALL
> **Description**: The tool shall detect whether the target project uses Gradle or Maven
> **Rationale**: All scanning operations depend on knowing the build system
> **Categories**: Functional Suitability
> **References**: CORE_0001, CORE_0002
> **Implementation**: In Code
> **Revision**: 0.1.0
> **Lifecycle**: Effective
#### Scenario: SVC_CORE_0004 — Build System Detection Test
> **Description**: Test that the scanner correctly identifies Gradle and Maven projects
> **Verification**: Automated Test
> **Requirements**: CORE_0004
> **Lifecycle**: Effective
#### Result: MVR_001 — PASSED
> **Comment**: Verified manually on 2026-01-15
> **SVCs**: SVC_CORE_0001, SVC_CORE_0002
- Single-word enum values → uppercase:
SHALL,EFFECTIVE,PASSED,FAILED - Hyphenated enum values → title-case with spaces:
Automated Test,Manual Test,Functional Suitability,Interaction Capability,In Code - List values → comma-separated on one line:
CORE_0001, CORE_0002
All text-enrichment logic lives in one module used by both the CLI command and the MCP tool:
src/reqstool/common/enrichment/
__init__.py
enricher.py
@dataclass(frozen=True)
class EnrichmentConfig:
trigger: str # 'colon-header' | 'inline'
title_only: bool # True → skip block injection
drop_stubs: bool
skip_code_spans: bool # always True for PoC
BUILT_IN_PRESETS: dict[str, EnrichmentConfig] = {
'openspec:spec': EnrichmentConfig('colon-header', False, True, True),
'openspec:delta-spec': EnrichmentConfig('colon-header', False, True, True),
'openspec:design': EnrichmentConfig('inline', True, False, True),
'openspec:proposal': EnrichmentConfig('inline', True, False, True),
'openspec:tasks': EnrichmentConfig('inline', True, False, True),
}
def enrich_text(
text: str,
requirements: dict[UrnId, RequirementData],
svcs: dict[UrnId, SVCData],
mvrs: dict[UrnId, MVRData],
config: EnrichmentConfig,
) -> str: ...Internal helpers (module-level, tested independently):
_build_lookup(requirements, svcs, mvrs)→dict[str, tuple[str, ...]]_make_pattern(lookup)→re.Pattern(single alternation, sorted by length desc)_mark_no_inject_zones(text)→ list of(start, end)character ranges_format_block(entity, fields)→list[str]of> **Label**: valuelines_format_value(value)→ applies uppercase/title-case rules
| File | Action |
|---|---|
src/reqstool/common/enrichment/__init__.py |
Create (empty) |
src/reqstool/common/enrichment/enricher.py |
Create — shared logic |
src/reqstool/commands/enrich/__init__.py |
Create (empty) |
src/reqstool/commands/enrich/enrich.py |
Create — EnrichCommand (thin wrapper) |
src/reqstool/mcp/server.py |
Modify — add enrich_document tool |
src/reqstool/command.py |
Modify — argparse, command_enrich, routing |
tests/unit/reqstool/common/enrichment/ |
Create — unit tests for enricher.py |
tests/unit/reqstool/commands/enrich/ |
Create — integration tests |
tests/resources/enrich/ |
Create — static input/output fixture files |
tests/resources/enrich/
spec_all_fields/
input.md
expected.md ← colon-header, all fields, stubs dropped
spec_no_description/
input.md
expected.md ← SVC with no description: no block lines emitted
inline_title_only/
input.md
expected.md ← inline mode, multiple IDs per line, every occurrence
inline_code_spans/
input.md
expected.md ← IDs inside backticks and fenced blocks skipped
no_ids/
input.md
expected.md ← identical to input (full passthrough)
mvr/
input.md
expected.md ← MVR inline PASSED/FAILED + comment/svc_ids block
@Requirements("REQ_XXX") # next available from docs/reqstool/requirements.yml
class EnrichCommand:
def __init__(
self,
location: LocationInterface,
input_content: str,
config: EnrichmentConfig,
):
self.__initial_location = location
self.__input_content = input_content
self.__config = config
self.result: str = self.__run()
def __run(self) -> str:
with build_database(
location=self.__initial_location,
semantic_validator=SemanticValidator(validation_error_holder=ValidationErrorHolder()),
) as (db, _):
repo = RequirementsRepository(db)
requirements = repo.get_all_requirements()
svcs = repo.get_all_svcs()
mvrs = repo.get_all_mvrs()
return enrich_text(self.__input_content, requirements, svcs, mvrs, self.__config)@mcp.tool()
def enrich_document(content: str, preset: str) -> str:
"""Enrich an OpenSpec document by resolving requirement/SVC/MVR IDs.
Injects titles and further fields next to each known ID according to the
named preset. Both arguments are required.
Presets: openspec:spec, openspec:delta-spec, openspec:design,
openspec:proposal, openspec:tasks
"""
if preset not in BUILT_IN_PRESETS:
raise ValueError(f"Unknown preset {preset!r}. "
f"Valid: {sorted(BUILT_IN_PRESETS)}")
config = BUILT_IN_PRESETS[preset]
return enrich_text(content, repo.get_all_requirements(),
repo.get_all_svcs(), repo.get_all_mvrs(), config)repo is the RequirementsRepository already built at MCP server startup.
// openspec/openspecui.hooks.ts
let client: Client | null = null
export const onReadDocument: OnReadDocumentHookV1 = async (ctx, read) => {
if (!client) {
const transport = new StdioClientTransport({
command: 'reqstool', args: ['mcp'], cwd: ctx.projectDir,
})
client = new Client({ name: 'openspecui', version: '1.0' }, {})
await client.connect(transport)
ctx.lifecycle.onDispose(async () => { await client?.close(); client = null })
}
const result = await read()
const enriched = await client.callTool('enrich_document', {
content: result.markdown,
preset: `openspec:${ctx.document.kind}`,
})
return { ...result, markdown: enriched.content[0].text }
}Import (after existing command imports):
from reqstool.commands.enrich.enrich import EnrichCommand
from reqstool.common.enrichment.enricher import BUILT_IN_PRESETS, EnrichmentConfigArgparse (after status_source_subparsers, before # command: lsp):
enrich_parser = subparsers.add_parser(
"enrich",
help="Enrich a document with requirement/SVC/MVR titles and descriptions. "
"Auto-detects dataset from .reqstool-ai.yaml if no source is given.",
)
enrich_parser.add_argument(
"--preset", required=True,
choices=sorted(BUILT_IN_PRESETS),
help="Enrichment preset (e.g. openspec:spec, openspec:design)",
)
enrich_parser.add_argument("--input", "-i", metavar="FILE", default=None,
help="Input file (default: stdin)")
self._add_argument_output(enrich_parser)
enrich_source_subparsers = enrich_parser.add_subparsers(dest="source", required=False)
self._add_subparsers_source(enrich_source_subparsers,
include_report_options=False,
include_filter_options=False)command_enrich method (after command_mcp):
- Same auto-detection pattern as
command_mcp - Read stdin if
--inputnot given - Look up
BUILT_IN_PRESETS[enrich_args.preset]→EnrichmentConfig - Instantiate
EnrichCommand, writeresult.resultto output
Routing in main():
elif args.command == "enrich":
command.command_enrich(enrich_args=args)# Unit tests (pure helpers, no DB)
hatch run dev:pytest tests/unit/reqstool/common/enrichment/
# Integration tests
hatch run dev:pytest tests/unit/reqstool/commands/enrich/
# Full suite
hatch run dev:pytest --cov=reqstool tests/unit
# Format + lint
hatch run dev:black src tests && hatch run dev:flake8
# Smoke — spec preset
echo "### Requirement: REQ_101
The system SHALL implement REQ_101." | \
hatch run python src/reqstool/command.py enrich --preset openspec:spec \
local -p tests/resources/test_data/data/local/test_basic/baseline/ms-101
# Smoke — inline preset
echo "This implements REQ_101 and verifies SVC_201." | \
hatch run python src/reqstool/command.py enrich --preset openspec:design \
local -p tests/resources/test_data/data/local/test_basic/baseline/ms-101- reqstool-ai: Implement the openspecui hook (
openspec/openspecui.hooks.ts) that startsreqstool mcponce per project session and callsenrich_documentper document read. Reference the hook skeleton in this plan's "openspecui Hook" section.
REQ_XXXplaceholder: check next available number indocs/reqstool/requirements.yml.openspec:delta-specis a named preset with identical behaviour toopenspec:spec; the alias mapping (delta-spec→ spec behaviour) lives inBUILT_IN_PRESETS, not in the hook.- MVR enrichment: inline slot =
PASSED/FAILED; block fields = Comment, SVCs. --fieldsflag deferred (no raw field selection in PoC).- Daemon mode (
--stdio) deferred. - The openspecui hook (TypeScript) is documented here for reference but is not part of this PR.