Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ on:
- reopened
- synchronize

permissions:
contents: read

jobs:
linting:
name: Reuse linting job
Expand All @@ -19,6 +22,10 @@ jobs:
build:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] #4 — Full build/test pipeline duplicated per matrix leg

The entire build+test+package pipeline (install deps, build wheel, install plugin, pytest, poetry build) runs twice even though only the 3 reqstool-specific steps differ by reqstool-source. This roughly doubles CI time/cost for this job with no added coverage of the plugin itself.

Leaving as-is for now: splitting the reqstool-specific steps into a separate downstream job (consuming a shared build artifact, matrixed only there) would avoid the duplication, but adds cross-job artifact-passing complexity to a CI pattern that's still being proven out across the org-wide dogfooding rollout. Worth revisiting once the [pypi, main] matrix can collapse back to a single pypi-only leg (tracked via reqstool-client's release cadence).

Found by: code-review

needs: linting
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
reqstool-source: [pypi, main]
steps:
- name: Check out source repository
uses: actions/checkout@v7
Expand All @@ -45,9 +52,25 @@ jobs:
run: poetry run pytest --junitxml=build/junit.xml --cov --cov-report=xml:build/coverage.xml
- name: Build project
run: poetry build
- name: Install reqstool
uses: reqstool/.github/.github/actions/install-reqstool@9c6feaab046f4782f430dd2527fdb82c2a5cd926 # main 2026-06-22
with:
reqstool-source: ${{ matrix.reqstool-source }}
- name: Validate reqstool spec completeness
# not yet available in the latest PyPI release
if: matrix.reqstool-source == 'main'
uses: reqstool/.github/.github/actions/validate-reqstool@9c6feaab046f4782f430dd2527fdb82c2a5cd926 # main 2026-06-22
- name: Run reqstool status
uses: reqstool/.github/.github/actions/reqstool-status@9c6feaab046f4782f430dd2527fdb82c2a5cd926 # main 2026-06-22
with:
fail-if-incomplete: "true"
# Upload artifacts for later use
- name: Upload Artifacts
if: matrix.reqstool-source == 'pypi'
uses: actions/upload-artifact@v7
with:
Comment thread
jimisola marked this conversation as resolved.
name: dist
path: dist/

validate-openspec:
uses: reqstool/.github/.github/workflows/common-validate-openspec.yml@9c6feaab046f4782f430dd2527fdb82c2a5cd926 # main 2026-06-22
8 changes: 8 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mcpServers": {
"reqstool": {
"command": "reqstool",
"args": ["mcp"]
}
}
}
32 changes: 32 additions & 0 deletions .reqstool-ai.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# reqstool-ai configuration
#
# This file tells reqstool-ai skills where to find your reqstool files
# and how to generate IDs for new requirements and SVCs.
#
# Place this file at: .reqstool-ai.yaml (project root)

# Project URN — matches the urn in your reqstool YAML files
urn: reqstool-python-poetry-plugin

# Revision string for new requirements and SVCs
revision: "0.1.0"

# System-level reqstool directory (contains the SSOT requirements and SVCs)
system:
path: docs/reqstool

# Subproject modules — each module imports a subset of requirements/SVCs via filters
#
# Required fields per module:
# path — path to the module's reqstool directory (contains filter files)
# req_prefix — prefix for requirement IDs belonging to this module (e.g., CORE_)
# svc_prefix — prefix for SVC IDs belonging to this module (e.g., SVC_CORE_)
#
# Add as many modules as your project has. The module name (key) is used in
# commands like `/reqstool:status core` and `/reqstool:add-req core`.
modules:
# Matches the existing POETRY_PLUGIN_NNN / SVC_POETRY_PLUGIN_NNN ID convention.
plugin:
path: docs/reqstool
req_prefix: "POETRY_PLUGIN_"
svc_prefix: "SVC_POETRY_PLUGIN_"
50 changes: 46 additions & 4 deletions docs/reqstool/requirements.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,55 @@
metadata:
urn: reqstool-python-poetry-plugin
variant: microservice
title: Reqstool client
title: Reqstool Python Poetry Plugin
url: https://github.com/reqstool/reqstool-python-poetry-plugin

requirements:
- id: POETRY_PLUGIN_001
title: Support poetry build system for use with reqstool
title: Bundle reqstool dataset and annotations into the built sdist
significance: shall
description: Support reqstool to be used with poetry, i.e. creating a sdist tarball with reqstool files.
description: >-
The plugin shall ensure that the project's reqstool dataset
(requirements.yml, software_verification_cases.yml,
manual_verification_results.yml, when present), the generated
annotations.yml, and a generated reqstool_config.yml are all present in
the sdist tarball produced by `poetry build`.
categories: [functional-suitability]
revision: 0.0.1
revision: "0.1.0"
- id: POETRY_PLUGIN_002
title: Generate annotations.yml from reqstool decorators during build
significance: shall
description: >-
On `poetry build`, the plugin shall process the configured source
directories for `@Requirements`/`@SVCs` decorators and write the
result to annotations.yml in the configured output directory.
categories: [functional-suitability]
revision: "0.1.0"
- id: POETRY_PLUGIN_003
title: Register reqstool dataset and output directories for packaging
significance: shall
description: >-
On `poetry install`, the plugin shall add the generated
reqstool_config.yml and the configured dataset/output directories to
`[tool.poetry.include]` in pyproject.toml, so they are included when
the project is later built.
categories: [functional-suitability]
revision: "0.1.0"
- id: POETRY_PLUGIN_004
title: Remove generated reqstool_config.yml from the project root after build
significance: shall
description: >-
After `poetry build` terminates, the plugin shall delete the
reqstool_config.yml it generated in the project root, so it does not
linger as an untracked file in the working tree.
categories: [functional-suitability, maintainability]
revision: "0.1.0"
- id: POETRY_PLUGIN_005
title: Clean up excess blank lines introduced into pyproject.toml after install
significance: should
description: >-
After `poetry install`, the plugin shall collapse any run of three or
more consecutive blank lines in pyproject.toml (introduced by the
include-registration step) down to a single blank line.
categories: [maintainability]
revision: "0.1.0"
32 changes: 32 additions & 0 deletions docs/reqstool/software_verification_cases.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/reqstool/reqstool-client/main/src/reqstool/resources/schemas/v1/software_verification_cases.schema.json

cases:
- id: SVC_POETRY_PLUGIN_001
requirement_ids: ["POETRY_PLUGIN_001"]
title: "Verify the built sdist contains the reqstool dataset, annotations, and config"
verification: automated-test
revision: "0.1.0"

- id: SVC_POETRY_PLUGIN_002
requirement_ids: ["POETRY_PLUGIN_002"]
title: "Verify annotations.yml is generated from decorated source and bundled"
verification: automated-test
revision: "0.1.0"

- id: SVC_POETRY_PLUGIN_003
requirement_ids: ["POETRY_PLUGIN_003"]
title: "Verify pyproject.toml's [tool.poetry.include] is updated with reqstool paths"
verification: automated-test
revision: "0.1.0"

- id: SVC_POETRY_PLUGIN_004
requirement_ids: ["POETRY_PLUGIN_004"]
title: "Verify reqstool_config.yml is removed from the project root after build"
verification: automated-test
revision: "0.1.0"

- id: SVC_POETRY_PLUGIN_005
requirement_ids: ["POETRY_PLUGIN_005"]
title: "Verify excess blank lines are stripped from pyproject.toml after install"
verification: automated-test
revision: "0.1.0"
108 changes: 108 additions & 0 deletions openspec/openspecui.hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// @reqstool-openspec-hooks: 0.1.1
import { spawn, ChildProcess } from "child_process";
import type { OnReadDocumentHookV1 } from "openspecui/hooks";

// Minimal MCP client over stdio (JSON-RPC 2.0, newline-delimited).
// Uses only Node.js built-ins — no npm packages required.
class McpStdioClient {
private proc: ChildProcess;
private buf = "";
private pending = new Map<
number,
{ resolve: (v: unknown) => void; reject: (e: Error) => void }
>();
private id = 1;
readonly ready: Promise<void>;

constructor(cwd: string) {
this.proc = spawn("reqstool", ["mcp"], {
cwd,
stdio: ["pipe", "pipe", "pipe"],
});
this.proc.stdout!.on("data", (chunk: Buffer) => {
this.buf += chunk.toString();
let nl: number;
while ((nl = this.buf.indexOf("\n")) !== -1) {
const line = this.buf.slice(0, nl).trim();
this.buf = this.buf.slice(nl + 1);
if (line) this.handle(line);
}
});
this.ready = this.init();
}

private handle(line: string) {
try {
const msg = JSON.parse(line) as { id?: number; result?: unknown; error?: { message: string } };
if (msg.id !== undefined) {
const p = this.pending.get(msg.id);
if (p) {
this.pending.delete(msg.id);
msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result);
}
}
} catch (e) {
console.warn("[reqstool-openspec] Skipping non-JSON line from reqstool mcp:", e instanceof Error ? e.message : e);
}
}

private send(method: string, params: unknown, expectReply = true): Promise<unknown> {
if (!expectReply) {
this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
return Promise.resolve();
}
const id = this.id++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
});
}

private async init(): Promise<void> {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] #9 — MCP client has no process error/exit handling

If reqstool mcp fails to spawn or exits unexpectedly, pending promises (including this.ready) hang forever instead of rejecting, and enrich()'s result-shape cast is unvalidated. Not fixing in this PR: this file is copied verbatim from the reqstool-ai plugin marketplace template (see file header @reqstool-openspec-hooks: 0.1.1), so a local-only fix here would drift from the upstream template and need to be re-applied on every future sync. Will raise this as an issue against reqstool-ai instead so the fix lands once and propagates to all consuming repos.

Found by: code-review

await this.send("initialize", {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
clientInfo: { name: "openspecui", version: "1.0" },
});
this.send("notifications/initialized", {}, false);
}

async enrich(content: string, preset: string): Promise<string> {
await this.ready;
const result = (await this.send("tools/call", {
name: "enrich_document",
arguments: { content, preset },
})) as { content: { text: string }[] };
return result.content[0].text;
}

close() {
this.proc.stdin?.end();
this.proc.kill();
}
}

let client: McpStdioClient | null = null;

export const onReadDocument: OnReadDocumentHookV1 = async (ctx, read) => {
if (!client) {
client = new McpStdioClient(ctx.projectDir);
ctx.lifecycle.onDispose(() => {
client?.close();
client = null;
});
}

const result = await read();
const preset = `openspec:${ctx.document.kind}`;

try {
const enriched = await client.enrich(result.markdown, preset);
return { ...result, markdown: enriched, sourceLabel: `reqstool ${preset}` };
} catch (e) {
return {
...result,
diagnostics: [{ level: "warning", message: `reqstool enrich failed: ${e}` }],
};
}
};
15 changes: 15 additions & 0 deletions openspec/specs/annotation-generation/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Annotation Generation Specification

## Purpose

Requirement and SVC content is owned by reqstool (single source of truth). This spec references
reqstool requirement and SVC IDs only; titles and descriptions are injected at read time via
`reqstool enrich` (or the openspecui hook). See `docs/reqstool/`.

## Requirements

### Requirement: POETRY_PLUGIN_002
The system SHALL implement POETRY_PLUGIN_002.

#### Scenario: SVC_POETRY_PLUGIN_002
The system SHALL pass SVC_POETRY_PLUGIN_002.
15 changes: 15 additions & 0 deletions openspec/specs/build-cleanup/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Build Cleanup Specification

## Purpose

Requirement and SVC content is owned by reqstool (single source of truth). This spec references
reqstool requirement and SVC IDs only; titles and descriptions are injected at read time via
`reqstool enrich` (or the openspecui hook). See `docs/reqstool/`.

## Requirements

### Requirement: POETRY_PLUGIN_004
The system SHALL implement POETRY_PLUGIN_004.

#### Scenario: SVC_POETRY_PLUGIN_004
The system SHALL pass SVC_POETRY_PLUGIN_004.
15 changes: 15 additions & 0 deletions openspec/specs/install-cleanup/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Install Cleanup Specification

## Purpose

Requirement and SVC content is owned by reqstool (single source of truth). This spec references
reqstool requirement and SVC IDs only; titles and descriptions are injected at read time via
`reqstool enrich` (or the openspecui hook). See `docs/reqstool/`.

## Requirements

### Requirement: POETRY_PLUGIN_005
The system SHALL implement POETRY_PLUGIN_005.

#### Scenario: SVC_POETRY_PLUGIN_005
The system SHALL pass SVC_POETRY_PLUGIN_005.
15 changes: 15 additions & 0 deletions openspec/specs/sdist-bundling/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Sdist Bundling Specification

## Purpose

Requirement and SVC content is owned by reqstool (single source of truth). This spec references
reqstool requirement and SVC IDs only; titles and descriptions are injected at read time via
`reqstool enrich` (or the openspecui hook). See `docs/reqstool/`.

## Requirements

### Requirement: POETRY_PLUGIN_001
The system SHALL implement POETRY_PLUGIN_001.

#### Scenario: SVC_POETRY_PLUGIN_001
The system SHALL pass SVC_POETRY_PLUGIN_001.
15 changes: 15 additions & 0 deletions openspec/specs/sdist-include-registration/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Sdist Include Registration Specification

## Purpose

Requirement and SVC content is owned by reqstool (single source of truth). This spec references
reqstool requirement and SVC IDs only; titles and descriptions are injected at read time via
`reqstool enrich` (or the openspecui hook). See `docs/reqstool/`.

## Requirements

### Requirement: POETRY_PLUGIN_003
The system SHALL implement POETRY_PLUGIN_003.

#### Scenario: SVC_POETRY_PLUGIN_003
The system SHALL pass SVC_POETRY_PLUGIN_003.
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ pattern = "^(?P<base>\\d+\\.\\d+\\.\\d+.*)$"
format-jinja = "{% if distance == 0 %}{{ base }}{% else %}{{ base }}.dev{{ distance }}{% endif %}"

[tool.reqstool]
sources = ["src", "tests"]
# excludes tests/fixtures: it's a self-contained fixture project with its own
# decorated REQ_001/SVC_001, unrelated to this plugin's own requirements
sources = ["src", "tests/unit", "tests/integration", "tests/e2e"]
test_results = ["build/**/junit.xml"]
dataset_directory = "docs/reqstool"
output_directory = "build/reqstool"
Loading