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.
Files changed (119) hide show
  1. vstack/__init__.py +5 -0
  2. vstack/__main__.py +5 -0
  3. vstack/_templates/agents/_partials/agent-skill-boundary.md +5 -0
  4. vstack/_templates/agents/architect/config.yaml +38 -0
  5. vstack/_templates/agents/architect/template.md +84 -0
  6. vstack/_templates/agents/designer/config.yaml +36 -0
  7. vstack/_templates/agents/designer/template.md +99 -0
  8. vstack/_templates/agents/engineer/config.yaml +36 -0
  9. vstack/_templates/agents/engineer/template.md +88 -0
  10. vstack/_templates/agents/product/config.yaml +37 -0
  11. vstack/_templates/agents/product/template.md +87 -0
  12. vstack/_templates/agents/release/config.yaml +35 -0
  13. vstack/_templates/agents/release/template.md +86 -0
  14. vstack/_templates/agents/tester/config.yaml +41 -0
  15. vstack/_templates/agents/tester/template.md +90 -0
  16. vstack/_templates/instructions/git/config.yaml +4 -0
  17. vstack/_templates/instructions/git/template.md +36 -0
  18. vstack/_templates/instructions/python/config.yaml +4 -0
  19. vstack/_templates/instructions/python/template.md +37 -0
  20. vstack/_templates/prompts/code-review/config.yaml +10 -0
  21. vstack/_templates/prompts/code-review/template.md +39 -0
  22. vstack/_templates/skills/_partials/base-branch.md +8 -0
  23. vstack/_templates/skills/_partials/observability-checklist.md +36 -0
  24. vstack/_templates/skills/_partials/run-tests.md +22 -0
  25. vstack/_templates/skills/_partials/skill-context.md +21 -0
  26. vstack/_templates/skills/adr/config.yaml +17 -0
  27. vstack/_templates/skills/adr/template.md +167 -0
  28. vstack/_templates/skills/analyse/config.yaml +16 -0
  29. vstack/_templates/skills/analyse/template.md +188 -0
  30. vstack/_templates/skills/architecture/config.yaml +18 -0
  31. vstack/_templates/skills/architecture/template.md +213 -0
  32. vstack/_templates/skills/cicd/config.yaml +16 -0
  33. vstack/_templates/skills/cicd/template.md +169 -0
  34. vstack/_templates/skills/code-review/config.yaml +16 -0
  35. vstack/_templates/skills/code-review/template.md +180 -0
  36. vstack/_templates/skills/concise/config.yaml +16 -0
  37. vstack/_templates/skills/concise/template.md +128 -0
  38. vstack/_templates/skills/consult/config.yaml +18 -0
  39. vstack/_templates/skills/consult/template.md +195 -0
  40. vstack/_templates/skills/container/config.yaml +17 -0
  41. vstack/_templates/skills/container/template.md +122 -0
  42. vstack/_templates/skills/debug/config.yaml +16 -0
  43. vstack/_templates/skills/debug/template.md +247 -0
  44. vstack/_templates/skills/dependency/config.yaml +18 -0
  45. vstack/_templates/skills/dependency/template.md +293 -0
  46. vstack/_templates/skills/design/config.yaml +16 -0
  47. vstack/_templates/skills/design/template.md +231 -0
  48. vstack/_templates/skills/docs/config.yaml +17 -0
  49. vstack/_templates/skills/docs/template.md +128 -0
  50. vstack/_templates/skills/explore/config.yaml +17 -0
  51. vstack/_templates/skills/explore/template.md +188 -0
  52. vstack/_templates/skills/guardrails/config.yaml +16 -0
  53. vstack/_templates/skills/guardrails/template.md +45 -0
  54. vstack/_templates/skills/incident/config.yaml +17 -0
  55. vstack/_templates/skills/incident/template.md +293 -0
  56. vstack/_templates/skills/inspect/config.yaml +16 -0
  57. vstack/_templates/skills/inspect/template.md +105 -0
  58. vstack/_templates/skills/migrate/config.yaml +17 -0
  59. vstack/_templates/skills/migrate/template.md +298 -0
  60. vstack/_templates/skills/onboard/config.yaml +18 -0
  61. vstack/_templates/skills/onboard/template.md +289 -0
  62. vstack/_templates/skills/openapi/config.yaml +17 -0
  63. vstack/_templates/skills/openapi/template.md +382 -0
  64. vstack/_templates/skills/performance/config.yaml +15 -0
  65. vstack/_templates/skills/performance/template.md +198 -0
  66. vstack/_templates/skills/pr/config.yaml +15 -0
  67. vstack/_templates/skills/pr/template.md +108 -0
  68. vstack/_templates/skills/refactor/config.yaml +18 -0
  69. vstack/_templates/skills/refactor/template.md +283 -0
  70. vstack/_templates/skills/release-notes/config.yaml +16 -0
  71. vstack/_templates/skills/release-notes/template.md +127 -0
  72. vstack/_templates/skills/requirements/config.yaml +17 -0
  73. vstack/_templates/skills/requirements/template.md +187 -0
  74. vstack/_templates/skills/security/config.yaml +17 -0
  75. vstack/_templates/skills/security/template.md +256 -0
  76. vstack/_templates/skills/verify/config.yaml +17 -0
  77. vstack/_templates/skills/verify/template.md +201 -0
  78. vstack/_templates/skills/vision/config.yaml +19 -0
  79. vstack/_templates/skills/vision/template.md +169 -0
  80. vstack/agents/__init__.py +5 -0
  81. vstack/agents/config.py +67 -0
  82. vstack/agents/constants.py +14 -0
  83. vstack/agents/generator.py +20 -0
  84. vstack/artifacts/__init__.py +17 -0
  85. vstack/artifacts/config.py +111 -0
  86. vstack/artifacts/constants.py +6 -0
  87. vstack/artifacts/generator.py +406 -0
  88. vstack/artifacts/models.py +55 -0
  89. vstack/artifacts/protocol.py +50 -0
  90. vstack/cli/__init__.py +3 -0
  91. vstack/cli/commands.py +596 -0
  92. vstack/cli/constants.py +33 -0
  93. vstack/cli/manifest.py +166 -0
  94. vstack/cli/parser.py +156 -0
  95. vstack/constants.py +84 -0
  96. vstack/frontmatter/__init__.py +8 -0
  97. vstack/frontmatter/parser.py +272 -0
  98. vstack/frontmatter/schema.py +142 -0
  99. vstack/frontmatter/serializer.py +208 -0
  100. vstack/instructions/__init__.py +5 -0
  101. vstack/instructions/config.py +21 -0
  102. vstack/instructions/constants.py +9 -0
  103. vstack/instructions/generator.py +13 -0
  104. vstack/main.py +71 -0
  105. vstack/models.py +35 -0
  106. vstack/prompts/__init__.py +5 -0
  107. vstack/prompts/config.py +21 -0
  108. vstack/prompts/constants.py +9 -0
  109. vstack/prompts/generator.py +13 -0
  110. vstack/skills/__init__.py +5 -0
  111. vstack/skills/config.py +58 -0
  112. vstack/skills/constants.py +17 -0
  113. vstack/skills/generator.py +20 -0
  114. vstack/skills/models.py +15 -0
  115. vstack-0.0.0.dist-info/METADATA +725 -0
  116. vstack-0.0.0.dist-info/RECORD +119 -0
  117. vstack-0.0.0.dist-info/WHEEL +4 -0
  118. vstack-0.0.0.dist-info/entry_points.txt +3 -0
  119. 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
@@ -0,0 +1,3 @@
1
+ """Package initialization for vstack.cli."""
2
+
3
+ # cli sub-package: commands, parser