vstack 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vstack/__init__.py +5 -0
- vstack/__main__.py +5 -0
- vstack/_templates/agents/_partials/agent-skill-boundary.md +5 -0
- vstack/_templates/agents/architect/config.yaml +38 -0
- vstack/_templates/agents/architect/template.md +84 -0
- vstack/_templates/agents/designer/config.yaml +36 -0
- vstack/_templates/agents/designer/template.md +99 -0
- vstack/_templates/agents/engineer/config.yaml +36 -0
- vstack/_templates/agents/engineer/template.md +88 -0
- vstack/_templates/agents/product/config.yaml +37 -0
- vstack/_templates/agents/product/template.md +87 -0
- vstack/_templates/agents/release/config.yaml +35 -0
- vstack/_templates/agents/release/template.md +86 -0
- vstack/_templates/agents/tester/config.yaml +41 -0
- vstack/_templates/agents/tester/template.md +90 -0
- vstack/_templates/instructions/git/config.yaml +4 -0
- vstack/_templates/instructions/git/template.md +36 -0
- vstack/_templates/instructions/python/config.yaml +4 -0
- vstack/_templates/instructions/python/template.md +37 -0
- vstack/_templates/prompts/code-review/config.yaml +10 -0
- vstack/_templates/prompts/code-review/template.md +39 -0
- vstack/_templates/skills/_partials/base-branch.md +8 -0
- vstack/_templates/skills/_partials/observability-checklist.md +36 -0
- vstack/_templates/skills/_partials/run-tests.md +22 -0
- vstack/_templates/skills/_partials/skill-context.md +21 -0
- vstack/_templates/skills/adr/config.yaml +17 -0
- vstack/_templates/skills/adr/template.md +167 -0
- vstack/_templates/skills/analyse/config.yaml +16 -0
- vstack/_templates/skills/analyse/template.md +188 -0
- vstack/_templates/skills/architecture/config.yaml +18 -0
- vstack/_templates/skills/architecture/template.md +213 -0
- vstack/_templates/skills/cicd/config.yaml +16 -0
- vstack/_templates/skills/cicd/template.md +169 -0
- vstack/_templates/skills/code-review/config.yaml +16 -0
- vstack/_templates/skills/code-review/template.md +180 -0
- vstack/_templates/skills/concise/config.yaml +16 -0
- vstack/_templates/skills/concise/template.md +128 -0
- vstack/_templates/skills/consult/config.yaml +18 -0
- vstack/_templates/skills/consult/template.md +195 -0
- vstack/_templates/skills/container/config.yaml +17 -0
- vstack/_templates/skills/container/template.md +122 -0
- vstack/_templates/skills/debug/config.yaml +16 -0
- vstack/_templates/skills/debug/template.md +247 -0
- vstack/_templates/skills/dependency/config.yaml +18 -0
- vstack/_templates/skills/dependency/template.md +293 -0
- vstack/_templates/skills/design/config.yaml +16 -0
- vstack/_templates/skills/design/template.md +231 -0
- vstack/_templates/skills/docs/config.yaml +17 -0
- vstack/_templates/skills/docs/template.md +128 -0
- vstack/_templates/skills/explore/config.yaml +17 -0
- vstack/_templates/skills/explore/template.md +188 -0
- vstack/_templates/skills/guardrails/config.yaml +16 -0
- vstack/_templates/skills/guardrails/template.md +45 -0
- vstack/_templates/skills/incident/config.yaml +17 -0
- vstack/_templates/skills/incident/template.md +293 -0
- vstack/_templates/skills/inspect/config.yaml +16 -0
- vstack/_templates/skills/inspect/template.md +105 -0
- vstack/_templates/skills/migrate/config.yaml +17 -0
- vstack/_templates/skills/migrate/template.md +298 -0
- vstack/_templates/skills/onboard/config.yaml +18 -0
- vstack/_templates/skills/onboard/template.md +289 -0
- vstack/_templates/skills/openapi/config.yaml +17 -0
- vstack/_templates/skills/openapi/template.md +382 -0
- vstack/_templates/skills/performance/config.yaml +15 -0
- vstack/_templates/skills/performance/template.md +198 -0
- vstack/_templates/skills/pr/config.yaml +15 -0
- vstack/_templates/skills/pr/template.md +108 -0
- vstack/_templates/skills/refactor/config.yaml +18 -0
- vstack/_templates/skills/refactor/template.md +283 -0
- vstack/_templates/skills/release-notes/config.yaml +16 -0
- vstack/_templates/skills/release-notes/template.md +127 -0
- vstack/_templates/skills/requirements/config.yaml +17 -0
- vstack/_templates/skills/requirements/template.md +187 -0
- vstack/_templates/skills/security/config.yaml +17 -0
- vstack/_templates/skills/security/template.md +256 -0
- vstack/_templates/skills/verify/config.yaml +17 -0
- vstack/_templates/skills/verify/template.md +201 -0
- vstack/_templates/skills/vision/config.yaml +19 -0
- vstack/_templates/skills/vision/template.md +169 -0
- vstack/agents/__init__.py +5 -0
- vstack/agents/config.py +67 -0
- vstack/agents/constants.py +14 -0
- vstack/agents/generator.py +20 -0
- vstack/artifacts/__init__.py +17 -0
- vstack/artifacts/config.py +111 -0
- vstack/artifacts/constants.py +6 -0
- vstack/artifacts/generator.py +406 -0
- vstack/artifacts/models.py +55 -0
- vstack/artifacts/protocol.py +50 -0
- vstack/cli/__init__.py +3 -0
- vstack/cli/commands.py +596 -0
- vstack/cli/constants.py +33 -0
- vstack/cli/manifest.py +166 -0
- vstack/cli/parser.py +156 -0
- vstack/constants.py +84 -0
- vstack/frontmatter/__init__.py +8 -0
- vstack/frontmatter/parser.py +272 -0
- vstack/frontmatter/schema.py +142 -0
- vstack/frontmatter/serializer.py +208 -0
- vstack/instructions/__init__.py +5 -0
- vstack/instructions/config.py +21 -0
- vstack/instructions/constants.py +9 -0
- vstack/instructions/generator.py +13 -0
- vstack/main.py +71 -0
- vstack/models.py +35 -0
- vstack/prompts/__init__.py +5 -0
- vstack/prompts/config.py +21 -0
- vstack/prompts/constants.py +9 -0
- vstack/prompts/generator.py +13 -0
- vstack/skills/__init__.py +5 -0
- vstack/skills/config.py +58 -0
- vstack/skills/constants.py +17 -0
- vstack/skills/generator.py +20 -0
- vstack/skills/models.py +15 -0
- vstack-0.0.0.dist-info/METADATA +725 -0
- vstack-0.0.0.dist-info/RECORD +119 -0
- vstack-0.0.0.dist-info/WHEEL +4 -0
- vstack-0.0.0.dist-info/entry_points.txt +3 -0
- vstack-0.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Generic prompt-artifact generator.
|
|
2
|
+
|
|
3
|
+
:class:`GenericArtifactGenerator` renders, writes, and validates any family
|
|
4
|
+
of prompt artifacts (skills, agents, prompts, instructions, …) based on an
|
|
5
|
+
:class:`~vstack.artifacts.type_config.ArtifactTypeConfig` descriptor.
|
|
6
|
+
|
|
7
|
+
All type-specific behaviour — output filename pattern, frontmatter injection,
|
|
8
|
+
partial loading, auto-generated footer, required tokens — is expressed
|
|
9
|
+
entirely through the config rather than subclass overrides.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from vstack.artifacts.config import ArtifactTypeConfig
|
|
20
|
+
from vstack.artifacts.constants import AUTO_GEN_FOOTER
|
|
21
|
+
from vstack.artifacts.models import ArtifactResult, RenderedArtifact
|
|
22
|
+
from vstack.constants import VERSION
|
|
23
|
+
from vstack.frontmatter import FrontmatterParser, FrontmatterSerializer
|
|
24
|
+
from vstack.models import CheckMessage, ValidationResult
|
|
25
|
+
|
|
26
|
+
_PLACEHOLDER_RE = re.compile(r"\{\{([A-Z_]+)\}\}")
|
|
27
|
+
_META_COMMENT_RE = re.compile(r"<!--\s*VSTACK-META:\s*(\{.*?\})\s*-->")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class GenericArtifactGenerator:
|
|
31
|
+
"""Renders and validates one family of prompt artifacts.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
type_config: Descriptor for this artifact family.
|
|
35
|
+
templates_root: Root directory that contains per-type template subdirs
|
|
36
|
+
(e.g. the repo's ``templates/`` folder).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, type_config: ArtifactTypeConfig, templates_root: Path) -> None:
|
|
40
|
+
"""Bind a type configuration to its concrete template directories."""
|
|
41
|
+
self.config = type_config
|
|
42
|
+
self.templates_dir = templates_root / type_config.templates_dir
|
|
43
|
+
self.partials_dir: Path | None = (
|
|
44
|
+
self.templates_dir / type_config.partials_subdir
|
|
45
|
+
if type_config.partials_subdir
|
|
46
|
+
else None
|
|
47
|
+
)
|
|
48
|
+
self._partials: dict[str, str] | None = None
|
|
49
|
+
|
|
50
|
+
# ── Placeholder resolution ────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def resolve_placeholders(text: str, resolvers: dict[str, str]) -> str:
|
|
54
|
+
"""Replace ``{{TOKEN}}`` placeholders using *resolvers*.
|
|
55
|
+
|
|
56
|
+
Tokens with no matching resolver are left unchanged.
|
|
57
|
+
"""
|
|
58
|
+
return _PLACEHOLDER_RE.sub(lambda m: resolvers.get(m.group(1), m.group(0)), text)
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def find_unresolved(text: str) -> list[str]:
|
|
62
|
+
"""Return a list of TOKEN names that remain unresolved in *text*."""
|
|
63
|
+
return _PLACEHOLDER_RE.findall(text)
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def parse_generation_metadata(text: str) -> dict[str, str] | None:
|
|
67
|
+
"""Parse the ``VSTACK-META`` footer JSON, if present."""
|
|
68
|
+
matches = _META_COMMENT_RE.findall(text)
|
|
69
|
+
if not matches:
|
|
70
|
+
return None
|
|
71
|
+
try:
|
|
72
|
+
data = json.loads(matches[-1])
|
|
73
|
+
except json.JSONDecodeError:
|
|
74
|
+
return None
|
|
75
|
+
if not isinstance(data, dict):
|
|
76
|
+
return None
|
|
77
|
+
return {str(k): str(v) for k, v in data.items()}
|
|
78
|
+
|
|
79
|
+
def _build_footer(self, artifact_name: str, artifact_version: str) -> str:
|
|
80
|
+
"""Build the AUTO-GENERATED footer plus machine-readable metadata."""
|
|
81
|
+
meta = {
|
|
82
|
+
"generator": "vstack",
|
|
83
|
+
"vstack_version": VERSION,
|
|
84
|
+
"artifact_type": self.config.type_name,
|
|
85
|
+
"artifact_name": artifact_name,
|
|
86
|
+
"artifact_version": artifact_version,
|
|
87
|
+
}
|
|
88
|
+
meta_json = json.dumps(meta, separators=(",", ":"), sort_keys=True)
|
|
89
|
+
return f"{AUTO_GEN_FOOTER}<!-- VSTACK-META: {meta_json} -->\n"
|
|
90
|
+
|
|
91
|
+
# ── Partials ──────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
def load_partials(self) -> dict[str, str]:
|
|
94
|
+
"""Load and cache partials from *partials_dir*.
|
|
95
|
+
|
|
96
|
+
Each file stem is converted from lowercase-kebab to UPPER_SNAKE to
|
|
97
|
+
form the resolver token: ``skill-context.md`` → ``SKILL_CONTEXT``.
|
|
98
|
+
Returns an empty dict when *partials_dir* is ``None`` or missing.
|
|
99
|
+
"""
|
|
100
|
+
if self._partials is None:
|
|
101
|
+
self._partials = {}
|
|
102
|
+
if self.partials_dir and self.partials_dir.exists():
|
|
103
|
+
for path in sorted(self.partials_dir.glob("*.md")):
|
|
104
|
+
token = path.stem.upper().replace("-", "_")
|
|
105
|
+
self._partials[token] = path.read_text(encoding="utf-8").strip()
|
|
106
|
+
return self._partials
|
|
107
|
+
|
|
108
|
+
# ── Template discovery ────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
def find_templates(self) -> list[Path]:
|
|
111
|
+
"""Return sorted list of template *directories* under ``templates_dir``.
|
|
112
|
+
|
|
113
|
+
A directory qualifies when it does not start with ``_`` and contains
|
|
114
|
+
a file named ``config.template_filename`` (default ``template.md``).
|
|
115
|
+
"""
|
|
116
|
+
if not self.templates_dir.exists():
|
|
117
|
+
return []
|
|
118
|
+
return sorted(
|
|
119
|
+
p
|
|
120
|
+
for p in self.templates_dir.iterdir()
|
|
121
|
+
if p.is_dir()
|
|
122
|
+
and not p.name.startswith("_")
|
|
123
|
+
and (p / self.config.template_filename).exists()
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def find_extra_files(self, tmpl_dir: Path) -> list[Path]:
|
|
127
|
+
"""Return all files in *tmpl_dir* that are not the template or config."""
|
|
128
|
+
excluded = {self.config.template_filename, self.config.config_filename}
|
|
129
|
+
return [p for p in tmpl_dir.iterdir() if p.is_file() and p.name not in excluded]
|
|
130
|
+
|
|
131
|
+
# ── Per-artifact config ───────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
def load_artifact_config(self, tmpl_dir: Path) -> dict:
|
|
134
|
+
"""Parse ``config.yaml`` from *tmpl_dir* if present.
|
|
135
|
+
|
|
136
|
+
The file is parsed using the same minimal YAML subset as frontmatter.
|
|
137
|
+
Returns an empty dict when the file does not exist.
|
|
138
|
+
"""
|
|
139
|
+
config_file = tmpl_dir / self.config.config_filename
|
|
140
|
+
if not config_file.exists():
|
|
141
|
+
return {}
|
|
142
|
+
raw = config_file.read_text(encoding="utf-8")
|
|
143
|
+
return FrontmatterParser.parse_yaml(raw)
|
|
144
|
+
|
|
145
|
+
# ── Rendering ─────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
def render(self, tmpl_dir: Path) -> RenderedArtifact:
|
|
148
|
+
"""Render a single template directory to a :class:`~vstack.artifacts.models.RenderedArtifact`.
|
|
149
|
+
|
|
150
|
+
Precedence for frontmatter / metadata:
|
|
151
|
+
1. ``config.yaml`` provides defaults.
|
|
152
|
+
2. Frontmatter already present in ``template.md`` overrides config values.
|
|
153
|
+
3. When *add_frontmatter* is ``True`` and the template has no frontmatter,
|
|
154
|
+
frontmatter is generated from ``config.yaml``.
|
|
155
|
+
"""
|
|
156
|
+
tmpl_file = tmpl_dir / self.config.template_filename
|
|
157
|
+
content = tmpl_file.read_text(encoding="utf-8")
|
|
158
|
+
|
|
159
|
+
# Resolve {{PLACEHOLDER}} tokens via partials
|
|
160
|
+
resolved = self.resolve_placeholders(content, self.load_partials())
|
|
161
|
+
|
|
162
|
+
# Split existing frontmatter from body
|
|
163
|
+
parsed = FrontmatterParser.parse(resolved)
|
|
164
|
+
existing_fm = parsed.metadata
|
|
165
|
+
body = parsed.content
|
|
166
|
+
|
|
167
|
+
# Merge: config.yaml provides defaults, template frontmatter wins
|
|
168
|
+
artifact_config = self.load_artifact_config(tmpl_dir)
|
|
169
|
+
meta: dict = {**artifact_config, **existing_fm} if existing_fm else dict(artifact_config)
|
|
170
|
+
|
|
171
|
+
dir_name = tmpl_dir.name
|
|
172
|
+
name = str(meta.get("name", dir_name))
|
|
173
|
+
|
|
174
|
+
if existing_fm:
|
|
175
|
+
# Template already has frontmatter — rebuild to normalise output format
|
|
176
|
+
schema = self.config.frontmatter_schema
|
|
177
|
+
if schema is None:
|
|
178
|
+
raise ValueError(
|
|
179
|
+
f"{self.config.type_name}: frontmatter_schema must be set when add_frontmatter=True"
|
|
180
|
+
)
|
|
181
|
+
fm_str = FrontmatterSerializer().serialize(
|
|
182
|
+
meta,
|
|
183
|
+
schema,
|
|
184
|
+
preserve_multiline=self.config.preserve_multiline_frontmatter,
|
|
185
|
+
)
|
|
186
|
+
body_str = body
|
|
187
|
+
elif self.config.add_frontmatter and meta:
|
|
188
|
+
# No frontmatter in template; generate it from config.yaml
|
|
189
|
+
if "name" not in meta:
|
|
190
|
+
meta["name"] = name
|
|
191
|
+
schema = self.config.frontmatter_schema
|
|
192
|
+
if schema is None:
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"{self.config.type_name}: frontmatter_schema must be set when add_frontmatter=True"
|
|
195
|
+
)
|
|
196
|
+
fm_str = FrontmatterSerializer().serialize(
|
|
197
|
+
meta,
|
|
198
|
+
schema,
|
|
199
|
+
preserve_multiline=self.config.preserve_multiline_frontmatter,
|
|
200
|
+
)
|
|
201
|
+
body_str = resolved # full content (it contains no frontmatter)
|
|
202
|
+
else:
|
|
203
|
+
fm_str = ""
|
|
204
|
+
body_str = resolved
|
|
205
|
+
|
|
206
|
+
artifact_version = str((meta or {}).get("version") or VERSION)
|
|
207
|
+
footer = self._build_footer(name, artifact_version) if self.config.auto_gen_footer else ""
|
|
208
|
+
body_content = body_str.lstrip("\n")
|
|
209
|
+
output = (fm_str + body_content + footer) if (fm_str or footer) else body_str
|
|
210
|
+
|
|
211
|
+
return RenderedArtifact(
|
|
212
|
+
name=name,
|
|
213
|
+
content=output,
|
|
214
|
+
source_path=tmpl_file,
|
|
215
|
+
frontmatter=meta or None,
|
|
216
|
+
unresolved=self.find_unresolved(output),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def render_all(self) -> list[RenderedArtifact]:
|
|
220
|
+
"""Render all templates to memory without writing any files."""
|
|
221
|
+
return [self.render(d) for d in self.find_templates()]
|
|
222
|
+
|
|
223
|
+
# ── Output path ───────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
def output_path(self, name: str) -> str:
|
|
226
|
+
"""Compute the artifact path relative to *output_dir*.
|
|
227
|
+
|
|
228
|
+
Example: ``"vision"`` → ``"vision/SKILL.md"`` (for skills).
|
|
229
|
+
"""
|
|
230
|
+
return self.config.output_pattern.format(name=name)
|
|
231
|
+
|
|
232
|
+
def install_relative_path(self, name: str) -> str:
|
|
233
|
+
"""Compute the artifact path relative to the install root (``.github/``).
|
|
234
|
+
|
|
235
|
+
Example: ``"vision"`` → ``"skills/vision/SKILL.md"``.
|
|
236
|
+
"""
|
|
237
|
+
return f"{self.config.output_subdir}/{self.output_path(name)}"
|
|
238
|
+
|
|
239
|
+
# ── Generation ────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
def generate(self, output_dir: Path) -> ArtifactResult:
|
|
242
|
+
"""Render all templates, write output files, and return an :class:`~vstack.artifacts.models.ArtifactResult`.
|
|
243
|
+
|
|
244
|
+
Extra files alongside ``template.md`` (e.g. ``example.sh``) are copied
|
|
245
|
+
verbatim to the output directory.
|
|
246
|
+
"""
|
|
247
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
248
|
+
artifacts = self.render_all()
|
|
249
|
+
for artifact in artifacts:
|
|
250
|
+
out = output_dir / self.output_path(artifact.name)
|
|
251
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
252
|
+
out.write_text(artifact.content, encoding="utf-8")
|
|
253
|
+
for extra in self.find_extra_files(artifact.source_path.parent):
|
|
254
|
+
shutil.copy2(extra, out.parent / extra.name)
|
|
255
|
+
unresolved_warnings = [
|
|
256
|
+
f"WARNING: {a.name}/{self.config.template_filename} has unresolved placeholders: {a.unresolved}"
|
|
257
|
+
for a in artifacts
|
|
258
|
+
if a.unresolved
|
|
259
|
+
]
|
|
260
|
+
verification = self.verify_output(output_dir, [a.name for a in artifacts])
|
|
261
|
+
return ArtifactResult(
|
|
262
|
+
artifacts=artifacts,
|
|
263
|
+
unresolved_warnings=unresolved_warnings,
|
|
264
|
+
verification=verification,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# ── Validation ────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
def verify_input(self, expected_names: list[str] | None = None) -> ValidationResult:
|
|
270
|
+
"""Verify source templates before generation.
|
|
271
|
+
|
|
272
|
+
Checks performed:
|
|
273
|
+
* expected templates exist when ``expected_names`` is provided.
|
|
274
|
+
* frontmatter metadata satisfies the configured schema (or required
|
|
275
|
+
fallback fields when no schema is configured).
|
|
276
|
+
* metadata ``name`` matches the template directory name.
|
|
277
|
+
* placeholders used in template content are registered in
|
|
278
|
+
``ArtifactTypeConfig.placeholders`` (when configured) and mapped.
|
|
279
|
+
"""
|
|
280
|
+
result = ValidationResult()
|
|
281
|
+
templates = self.find_templates()
|
|
282
|
+
tmpl_by_name = {d.name: d for d in templates}
|
|
283
|
+
prefix = f"templates/{self.config.templates_dir}"
|
|
284
|
+
|
|
285
|
+
def ok(msg: str) -> None:
|
|
286
|
+
"""Record a passing validation message."""
|
|
287
|
+
result.messages.append(CheckMessage("pass", msg))
|
|
288
|
+
|
|
289
|
+
def fail(msg: str) -> None:
|
|
290
|
+
"""Record a failing validation message."""
|
|
291
|
+
result.messages.append(CheckMessage("fail", msg))
|
|
292
|
+
|
|
293
|
+
if expected_names is not None:
|
|
294
|
+
for name in expected_names:
|
|
295
|
+
if name in tmpl_by_name:
|
|
296
|
+
ok(f"{prefix}/{name}/{self.config.template_filename} exists")
|
|
297
|
+
else:
|
|
298
|
+
fail(f"{prefix}/{name}/{self.config.template_filename} MISSING")
|
|
299
|
+
|
|
300
|
+
for name, tmpl_dir in tmpl_by_name.items():
|
|
301
|
+
content = (tmpl_dir / self.config.template_filename).read_text(encoding="utf-8")
|
|
302
|
+
artifact_config = self.load_artifact_config(tmpl_dir)
|
|
303
|
+
parsed = FrontmatterParser.parse(content)
|
|
304
|
+
existing_fm = parsed.metadata
|
|
305
|
+
meta = {**artifact_config, **existing_fm} if existing_fm else artifact_config
|
|
306
|
+
|
|
307
|
+
if self.config.add_frontmatter:
|
|
308
|
+
schema = self.config.frontmatter_schema
|
|
309
|
+
if schema is not None:
|
|
310
|
+
errors = schema.validate_meta(meta)
|
|
311
|
+
if errors:
|
|
312
|
+
for error in errors:
|
|
313
|
+
fail(f"{prefix}/{name}: {error}")
|
|
314
|
+
else:
|
|
315
|
+
ok(f"{prefix}/{name}: metadata valid")
|
|
316
|
+
else:
|
|
317
|
+
missing = [k for k in ("name", "description") if not meta.get(k)]
|
|
318
|
+
if missing:
|
|
319
|
+
fail(f"{prefix}/{name}: MISSING required fields: {missing}")
|
|
320
|
+
else:
|
|
321
|
+
ok(f"{prefix}/{name}: valid metadata (name, description)")
|
|
322
|
+
|
|
323
|
+
meta_name = str(meta.get("name", name))
|
|
324
|
+
if meta_name != name:
|
|
325
|
+
fail(f"{prefix}/{name}: name mismatch (metadata says '{meta_name}')")
|
|
326
|
+
else:
|
|
327
|
+
ok(f"{prefix}/{name}: name matches directory")
|
|
328
|
+
|
|
329
|
+
if self.config.placeholders:
|
|
330
|
+
used_tokens = sorted(set(self.find_unresolved(content)))
|
|
331
|
+
for token in used_tokens:
|
|
332
|
+
template_ref = self.config.placeholders.get(token, "").strip()
|
|
333
|
+
if template_ref:
|
|
334
|
+
ok(f"{prefix}/{name}: placeholder {token} mapped to {template_ref}")
|
|
335
|
+
else:
|
|
336
|
+
fail(
|
|
337
|
+
f"{prefix}/{name}: unknown placeholder '{{{{{token}}}}}' "
|
|
338
|
+
"(not registered in placeholders)"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
return result
|
|
342
|
+
|
|
343
|
+
def verify_output(
|
|
344
|
+
self,
|
|
345
|
+
output_dir: Path,
|
|
346
|
+
expected_names: list[str] | None = None,
|
|
347
|
+
) -> ValidationResult:
|
|
348
|
+
"""Verify generated output files in *output_dir*.
|
|
349
|
+
|
|
350
|
+
When *expected_names* is ``None``, all artifacts found in *output_dir*
|
|
351
|
+
are checked instead.
|
|
352
|
+
"""
|
|
353
|
+
result = ValidationResult()
|
|
354
|
+
|
|
355
|
+
if expected_names is None:
|
|
356
|
+
if not output_dir.exists():
|
|
357
|
+
return result
|
|
358
|
+
# Derive names from the output_pattern structure
|
|
359
|
+
if "/" in self.config.output_pattern:
|
|
360
|
+
# Subdirectory style: names are first-level dir names
|
|
361
|
+
expected_names = [p.name for p in sorted(output_dir.iterdir()) if p.is_dir()]
|
|
362
|
+
else:
|
|
363
|
+
# Flat file style: names derived from filenames
|
|
364
|
+
suffix = self.config.output_pattern.replace("{name}", "")
|
|
365
|
+
expected_names = [
|
|
366
|
+
p.name.removesuffix(suffix) for p in sorted(output_dir.glob(f"*{suffix}"))
|
|
367
|
+
]
|
|
368
|
+
|
|
369
|
+
def ok(msg: str) -> None:
|
|
370
|
+
"""Record a passing output-verification message."""
|
|
371
|
+
result.messages.append(CheckMessage("pass", msg))
|
|
372
|
+
|
|
373
|
+
def fail(msg: str) -> None:
|
|
374
|
+
"""Record a failing output-verification message."""
|
|
375
|
+
result.messages.append(CheckMessage("fail", msg))
|
|
376
|
+
|
|
377
|
+
for name in expected_names:
|
|
378
|
+
out = output_dir / self.output_path(name)
|
|
379
|
+
label = self.output_path(name)
|
|
380
|
+
if not out.exists():
|
|
381
|
+
fail(f"{label} MISSING")
|
|
382
|
+
continue
|
|
383
|
+
ok(f"{label} exists")
|
|
384
|
+
content = out.read_text(encoding="utf-8")
|
|
385
|
+
if self.config.add_frontmatter:
|
|
386
|
+
if content.startswith("---\n"):
|
|
387
|
+
ok(f"{label}: has frontmatter")
|
|
388
|
+
else:
|
|
389
|
+
fail(f"{label}: missing frontmatter")
|
|
390
|
+
if self.config.auto_gen_footer:
|
|
391
|
+
if "AUTO-GENERATED" in content:
|
|
392
|
+
ok(f"{label}: has AUTO-GENERATED footer")
|
|
393
|
+
else:
|
|
394
|
+
fail(f"{label}: missing AUTO-GENERATED footer")
|
|
395
|
+
if self.parse_generation_metadata(content) is not None:
|
|
396
|
+
ok(f"{label}: has VSTACK-META footer")
|
|
397
|
+
else:
|
|
398
|
+
ok(f"{label}: missing VSTACK-META footer (legacy artifact accepted)")
|
|
399
|
+
if self.config.fail_on_unresolved:
|
|
400
|
+
unresolved = self.find_unresolved(content)
|
|
401
|
+
if not unresolved:
|
|
402
|
+
ok(f"{label}: no unresolved placeholders")
|
|
403
|
+
else:
|
|
404
|
+
fail(f"{label}: unresolved placeholders: {unresolved}")
|
|
405
|
+
|
|
406
|
+
return result
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Shared data models for prompt artifacts.
|
|
2
|
+
|
|
3
|
+
A *prompt artifact* is any Markdown file produced by vstack: a skill file,
|
|
4
|
+
an agent file, a prompt file, or an instructions file. Some artifact types
|
|
5
|
+
always carry YAML front matter (``*.agent.md``, ``SKILL.md``); others do not
|
|
6
|
+
(``AGENTS.md``, ``copilot-instructions.md`` — not yet in scope).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from vstack.models import ValidationResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class RenderedArtifact:
|
|
19
|
+
"""A single rendered prompt artifact, ready to write to disk.
|
|
20
|
+
|
|
21
|
+
Attributes:
|
|
22
|
+
name: Logical identifier (directory name or stem without suffix).
|
|
23
|
+
content: Full file content, ready to write.
|
|
24
|
+
source_path: Origin template or source file.
|
|
25
|
+
frontmatter: Parsed YAML front matter dict, or ``None`` when the
|
|
26
|
+
artifact type does not use front matter.
|
|
27
|
+
unresolved: Template tokens that were not resolved during rendering.
|
|
28
|
+
Always empty for artifact types that are not templated.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
content: str
|
|
33
|
+
source_path: Path
|
|
34
|
+
frontmatter: dict | None = None
|
|
35
|
+
unresolved: list[str] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ArtifactResult:
|
|
40
|
+
"""Result of generating a set of artifacts to an output directory.
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
artifacts: The rendered artifacts that were written.
|
|
44
|
+
unresolved_warnings: Human-readable warnings for any unresolved tokens.
|
|
45
|
+
verification: Output verification result run after writing.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
artifacts: list[RenderedArtifact]
|
|
49
|
+
unresolved_warnings: list[str]
|
|
50
|
+
verification: ValidationResult
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def ok(self) -> bool:
|
|
54
|
+
"""``True`` when there are no unresolved warnings and verification passed."""
|
|
55
|
+
return not self.unresolved_warnings and self.verification.ok
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Structural protocol for prompt-artifact generators.
|
|
2
|
+
|
|
3
|
+
Any class that implements ``generate``, ``verify_input``, and ``verify_output``
|
|
4
|
+
with the signatures below implicitly satisfies :class:`ArtifactGenerator` —
|
|
5
|
+
no inheritance required.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from vstack.artifacts.models import ArtifactResult
|
|
14
|
+
from vstack.models import ValidationResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ArtifactGenerator(Protocol):
|
|
18
|
+
"""Structural protocol satisfied by :class:`~vstack.skills.generator.SkillGenerator`
|
|
19
|
+
and :class:`~vstack.agents.generator.AgentGenerator` (and future generators for
|
|
20
|
+
prompts, instructions, etc.).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def generate(self, output_dir: Path) -> ArtifactResult:
|
|
24
|
+
"""Write artifacts to *output_dir* and return a result summary."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
def verify_input(
|
|
28
|
+
self,
|
|
29
|
+
expected_names: list[str] | None = None,
|
|
30
|
+
) -> ValidationResult:
|
|
31
|
+
"""Verify source templates / files before generation.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
expected_names: Names to check for existence. When ``None`` the
|
|
35
|
+
generator validates only the templates it finds.
|
|
36
|
+
"""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
def verify_output(
|
|
40
|
+
self,
|
|
41
|
+
output_dir: Path,
|
|
42
|
+
expected_names: list[str] | None = None,
|
|
43
|
+
) -> ValidationResult:
|
|
44
|
+
"""Verify generated output files in *output_dir*.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
expected_names: Names (or filenames) to check for. When ``None``
|
|
48
|
+
the generator checks all artifacts it knows about.
|
|
49
|
+
"""
|
|
50
|
+
...
|
vstack/cli/__init__.py
ADDED