@deftai/directive-content 0.55.2 → 0.56.0

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 (217) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +2 -2
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +47 -1
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/scripts/_agents_md.py +494 -0
  9. package/scripts/_cache_fetch.py +635 -0
  10. package/scripts/_cache_quota.py +529 -0
  11. package/scripts/_cache_refresh.py +163 -0
  12. package/scripts/_cache_validate.py +209 -0
  13. package/scripts/_content_root.py +42 -0
  14. package/scripts/_doctor_state.py +277 -0
  15. package/scripts/_event_detect.py +305 -0
  16. package/scripts/_events.py +514 -0
  17. package/scripts/_lifecycle_hygiene.py +568 -0
  18. package/scripts/_pathspec.py +91 -0
  19. package/scripts/_policy_show_cli.py +266 -0
  20. package/scripts/_precutover.py +92 -0
  21. package/scripts/_project_context.py +224 -0
  22. package/scripts/_project_definition_io.py +164 -0
  23. package/scripts/_relocate_snapshot.py +209 -0
  24. package/scripts/_relocate_states.py +343 -0
  25. package/scripts/_resolve_preflight_path.py +152 -0
  26. package/scripts/_safe_subprocess.py +167 -0
  27. package/scripts/_session_start_hook.py +205 -0
  28. package/scripts/_sor_gate_diff.py +365 -0
  29. package/scripts/_stdio_utf8.py +59 -0
  30. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  31. package/scripts/_triage_classify_cli.py +122 -0
  32. package/scripts/_triage_queue_cli.py +625 -0
  33. package/scripts/_triage_scope_cli.py +343 -0
  34. package/scripts/_triage_scope_drift_cli.py +121 -0
  35. package/scripts/_triage_scope_ignores.py +286 -0
  36. package/scripts/_triage_scope_milestone.py +432 -0
  37. package/scripts/_triage_scope_mutations.py +337 -0
  38. package/scripts/_triage_scope_renderers.py +207 -0
  39. package/scripts/_triage_smoketest_stages.py +674 -0
  40. package/scripts/_triage_subscribe_cli.py +140 -0
  41. package/scripts/_triage_welcome_cli.py +421 -0
  42. package/scripts/_vbrief_build.py +239 -0
  43. package/scripts/_vbrief_fidelity.py +479 -0
  44. package/scripts/_vbrief_legacy.py +589 -0
  45. package/scripts/_vbrief_reconciliation.py +883 -0
  46. package/scripts/_vbrief_routing.py +277 -0
  47. package/scripts/_vbrief_safety.py +778 -0
  48. package/scripts/_vbrief_sources.py +312 -0
  49. package/scripts/_vbrief_speckit.py +262 -0
  50. package/scripts/_vbrief_story_quality.py +353 -0
  51. package/scripts/_vbrief_validation.py +299 -0
  52. package/scripts/build_dist.py +412 -0
  53. package/scripts/cache.py +1078 -0
  54. package/scripts/cache_scanner.py +745 -0
  55. package/scripts/candidates_log.py +432 -0
  56. package/scripts/capacity_backfill.py +680 -0
  57. package/scripts/capacity_show.py +653 -0
  58. package/scripts/ci_local.py +689 -0
  59. package/scripts/code_structure_validate.py +765 -0
  60. package/scripts/codebase_default_extractor.py +495 -0
  61. package/scripts/codebase_map.py +304 -0
  62. package/scripts/codebase_map_fresh.py +104 -0
  63. package/scripts/codebase_projection_registry.py +94 -0
  64. package/scripts/codebase_provider.py +582 -0
  65. package/scripts/doctor.py +2257 -0
  66. package/scripts/framework_commands.py +505 -0
  67. package/scripts/gh_rest.py +882 -0
  68. package/scripts/github_auth_modes.py +437 -0
  69. package/scripts/github_body.py +292 -0
  70. package/scripts/ip_risk.py +531 -0
  71. package/scripts/issue_emit.py +670 -0
  72. package/scripts/issue_ingest.py +1064 -0
  73. package/scripts/migrate_preflight.py +418 -0
  74. package/scripts/migrate_vbrief.py +2677 -0
  75. package/scripts/monitor_pr.py +401 -0
  76. package/scripts/pack_migrate_lessons.py +336 -0
  77. package/scripts/pack_migrate_patterns.py +254 -0
  78. package/scripts/pack_migrate_rules.py +350 -0
  79. package/scripts/pack_migrate_skills.py +423 -0
  80. package/scripts/pack_migrate_strategies.py +311 -0
  81. package/scripts/pack_migrate_swarm_spec.py +250 -0
  82. package/scripts/pack_render.py +434 -0
  83. package/scripts/packs_slice.py +712 -0
  84. package/scripts/platform_capabilities.py +336 -0
  85. package/scripts/policy.py +2826 -0
  86. package/scripts/policy_set.py +324 -0
  87. package/scripts/pr_check_closing_keywords.py +524 -0
  88. package/scripts/pr_check_protected_issues.py +267 -0
  89. package/scripts/pr_merge_readiness.py +1004 -0
  90. package/scripts/pr_wait_mergeable.py +669 -0
  91. package/scripts/prd_render.py +159 -0
  92. package/scripts/preflight_architecture_sor.py +974 -0
  93. package/scripts/preflight_branch.py +289 -0
  94. package/scripts/preflight_cache.py +974 -0
  95. package/scripts/preflight_gh.py +721 -0
  96. package/scripts/preflight_implementation.py +272 -0
  97. package/scripts/preflight_story_start.py +838 -0
  98. package/scripts/preflight_wip_cap.py +149 -0
  99. package/scripts/probe_session.py +545 -0
  100. package/scripts/project_render.py +293 -0
  101. package/scripts/quarantine_ext.py +237 -0
  102. package/scripts/reconcile_issues.py +1442 -0
  103. package/scripts/refresh-path.ps1 +107 -0
  104. package/scripts/release.py +2030 -0
  105. package/scripts/release_e2e.py +1011 -0
  106. package/scripts/release_publish.py +486 -0
  107. package/scripts/release_rollback.py +980 -0
  108. package/scripts/relocate.py +1034 -0
  109. package/scripts/resolve_changelog_unreleased.py +667 -0
  110. package/scripts/resolve_version.py +490 -0
  111. package/scripts/resume_conditions.py +706 -0
  112. package/scripts/ritual_sentinel.py +609 -0
  113. package/scripts/roadmap_render.py +635 -0
  114. package/scripts/rule_ownership_lint.py +325 -0
  115. package/scripts/scm.py +591 -0
  116. package/scripts/scope_audit_log.py +387 -0
  117. package/scripts/scope_decompose.py +654 -0
  118. package/scripts/scope_demote.py +509 -0
  119. package/scripts/scope_lifecycle.py +1126 -0
  120. package/scripts/scope_undo.py +772 -0
  121. package/scripts/session_start.py +406 -0
  122. package/scripts/setup_ghx.py +339 -0
  123. package/scripts/setup_windows.ps1 +220 -0
  124. package/scripts/slice_audit.py +585 -0
  125. package/scripts/slice_record.py +530 -0
  126. package/scripts/slice_record_existing.py +692 -0
  127. package/scripts/slug_normalize.py +178 -0
  128. package/scripts/spec_render.py +477 -0
  129. package/scripts/spec_validate.py +238 -0
  130. package/scripts/subagent_monitor.py +658 -0
  131. package/scripts/swarm_complete_cohort.py +644 -0
  132. package/scripts/swarm_launch.py +1206 -0
  133. package/scripts/swarm_readiness.py +554 -0
  134. package/scripts/swarm_verify_review_clean.py +438 -0
  135. package/scripts/swarm_worktrees.py +497 -0
  136. package/scripts/toolchain-check.py +52 -0
  137. package/scripts/triage_actions.py +871 -0
  138. package/scripts/triage_bootstrap.py +1153 -0
  139. package/scripts/triage_bulk.py +630 -0
  140. package/scripts/triage_classify.py +932 -0
  141. package/scripts/triage_help.py +1685 -0
  142. package/scripts/triage_queue.py +1944 -0
  143. package/scripts/triage_reconcile.py +581 -0
  144. package/scripts/triage_refresh.py +643 -0
  145. package/scripts/triage_scope.py +999 -0
  146. package/scripts/triage_scope_drift.py +575 -0
  147. package/scripts/triage_smoketest.py +396 -0
  148. package/scripts/triage_subscribe.py +399 -0
  149. package/scripts/triage_summary.py +1011 -0
  150. package/scripts/triage_welcome.py +1178 -0
  151. package/scripts/ts_check_lane.py +86 -0
  152. package/scripts/validate-links.py +64 -0
  153. package/scripts/validate_strategy_output.py +212 -0
  154. package/scripts/vbrief_activate.py +228 -0
  155. package/scripts/vbrief_migrate_conformance.py +368 -0
  156. package/scripts/vbrief_reconcile_graph.py +306 -0
  157. package/scripts/vbrief_reconcile_labels.py +460 -0
  158. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  159. package/scripts/vbrief_validate.py +1195 -0
  160. package/scripts/verify-stubs.py +61 -0
  161. package/scripts/verify_capacity.py +160 -0
  162. package/scripts/verify_encoding.py +699 -0
  163. package/scripts/verify_hooks_installed.py +206 -0
  164. package/scripts/verify_investigation.py +360 -0
  165. package/scripts/verify_judgment_gates.py +827 -0
  166. package/scripts/verify_no_task_runtime.py +171 -0
  167. package/scripts/verify_scm_boundary.py +509 -0
  168. package/scripts/verify_session_ritual.py +389 -0
  169. package/scripts/verify_tools.py +426 -0
  170. package/scripts/verify_vbrief_conformance.py +478 -0
  171. package/tasks/architecture.yml +13 -0
  172. package/tasks/cache.yml +69 -0
  173. package/tasks/capacity.yml +38 -0
  174. package/tasks/change.yml +46 -0
  175. package/tasks/changelog.yml +24 -0
  176. package/tasks/ci.yml +49 -0
  177. package/tasks/codebase.yml +47 -0
  178. package/tasks/commit.yml +30 -0
  179. package/tasks/core.yml +126 -0
  180. package/tasks/deployments.yml +54 -0
  181. package/tasks/framework.yml +74 -0
  182. package/tasks/install.yml +60 -0
  183. package/tasks/issue.yml +50 -0
  184. package/tasks/migrate.yml +73 -0
  185. package/tasks/packs.yml +92 -0
  186. package/tasks/policy.yml +75 -0
  187. package/tasks/pr.yml +89 -0
  188. package/tasks/prd.yml +39 -0
  189. package/tasks/project.yml +27 -0
  190. package/tasks/reconcile.yml +32 -0
  191. package/tasks/relocate.yml +56 -0
  192. package/tasks/roadmap.yml +28 -0
  193. package/tasks/scm.yml +126 -0
  194. package/tasks/scope-undo.yml +36 -0
  195. package/tasks/scope.yml +141 -0
  196. package/tasks/session.yml +19 -0
  197. package/tasks/setup.yml +37 -0
  198. package/tasks/slice.yml +69 -0
  199. package/tasks/spec.yml +41 -0
  200. package/tasks/swarm.yml +85 -0
  201. package/tasks/toolchain.yml +13 -0
  202. package/tasks/triage-actions.yml +94 -0
  203. package/tasks/triage-bootstrap.yml +43 -0
  204. package/tasks/triage-bulk.yml +75 -0
  205. package/tasks/triage-classify.yml +30 -0
  206. package/tasks/triage-queue.yml +50 -0
  207. package/tasks/triage-reconcile.yml +29 -0
  208. package/tasks/triage-scope-drift.yml +29 -0
  209. package/tasks/triage-scope.yml +31 -0
  210. package/tasks/triage-smoketest.yml +33 -0
  211. package/tasks/triage-subscribe.yml +36 -0
  212. package/tasks/triage-summary.yml +29 -0
  213. package/tasks/triage-welcome.yml +32 -0
  214. package/tasks/ts.yml +328 -0
  215. package/tasks/vbrief.yml +206 -0
  216. package/tasks/verify.yml +292 -0
  217. package/templates/agents-entry.md +1 -1
@@ -0,0 +1,1195 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ vbrief_validate.py -- Validate the vBRIEF-centric document model.
4
+
5
+ Replaces and extends spec_validate.py for the vBRIEF lifecycle folder model.
6
+ Validates individual scope vBRIEFs, PROJECT-DEFINITION.vbrief.json, and
7
+ cross-file consistency.
8
+
9
+ Usage:
10
+ uv run python scripts/vbrief_validate.py [--vbrief-dir <path>]
11
+ [--strict-origin-types]
12
+ [--warnings-as-errors]
13
+
14
+ Exit codes:
15
+ 0 -- valid (may have warnings); also valid with warnings unless
16
+ --warnings-as-errors is set
17
+ 1 -- validation errors found (or warnings with --warnings-as-errors)
18
+ 2 -- usage error
19
+
20
+ Story: #333 (RFC #309), #536 (validator CLI flags, schema-trusting D11),
21
+ #533 (full v0.6 transition -- strict 0.6-only acceptance)
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import contextlib
27
+ import json
28
+ import re
29
+ import sys
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ # Ensure sibling scripts (`_event_detect`) are importable when this file is
34
+ # run directly. Mirrors the pattern in scripts/migrate_vbrief.py.
35
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
36
+
37
+ import _precutover as _precutover # noqa: E402
38
+ from _precutover import is_current_generated_specification, is_deprecation_redirect # noqa: E402
39
+
40
+ DEPRECATED_REDIRECT_SENTINEL = _precutover.DEPRECATED_REDIRECT_SENTINEL
41
+ __all__ = ("DEPRECATED_REDIRECT_SENTINEL",)
42
+
43
+
44
+ # #635: Detection-bound emit helper -- lazy-imported so an import-time
45
+ # failure in ``scripts/_event_detect.py`` cannot break the validator's
46
+ # ability to load. The events surface MUST NOT break the wrapped CLI;
47
+ # importing at module level would let an import-time exception in the
48
+ # helper take down ``task check``'s vbrief:validate gate before the
49
+ # call-site ``contextlib.suppress`` could intervene (Greptile P1 on PR
50
+ # #707 -- mirrors the lazy pattern in ``run::_emit_event_safe``).
51
+ # Filename is intentionally distinct from the sibling vBRIEF's
52
+ # ``scripts/_events.py`` (behavioral events) to avoid file-level merge
53
+ # conflicts; post-merge consolidation may unify them under one name.
54
+ def _emit_event(name: str, payload: dict[str, Any]) -> None:
55
+ """Lazy-import scripts/_event_detect.emit and forward the call."""
56
+ from _event_detect import emit # noqa: I001 -- intentional lazy import
57
+
58
+ emit(name, payload)
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Constants
63
+ # ---------------------------------------------------------------------------
64
+
65
+ # v0.6 Status enum from the canonical schema
66
+ # (https://github.com/deftai/vBRIEF/blob/master/schemas/vbrief-core-0.6.schema.json).
67
+ VALID_STATUSES = frozenset(
68
+ {
69
+ "draft",
70
+ "proposed",
71
+ "approved",
72
+ "pending",
73
+ "running",
74
+ "completed",
75
+ "blocked",
76
+ "failed",
77
+ "cancelled",
78
+ }
79
+ )
80
+
81
+ # Strict v0.6-only acceptance (#533). The canonical schema at
82
+ # vbrief/schemas/vbrief-core.schema.json pins vBRIEFInfo.version to
83
+ # const "0.6"; this validator rejects every other version. Pre-existing
84
+ # v0.5 vBRIEFs are automatically bumped to v0.6 during ``task
85
+ # migrate:vbrief`` (#571); operators who see the error should re-run
86
+ # the migrator on the affected project.
87
+ VALID_VBRIEF_VERSIONS = frozenset({"0.6"})
88
+
89
+ # D13: status-to-folder mapping. v0.6 adds ``failed`` as a terminal status
90
+ # (#533 / refinement skill Phase 4 ``task scope:fail``); it belongs in
91
+ # ``completed/`` because the scope has reached a terminal state (#537).
92
+ FOLDER_ALLOWED_STATUSES: dict[str, frozenset[str]] = {
93
+ "proposed": frozenset({"draft", "proposed"}),
94
+ "pending": frozenset({"approved", "pending"}),
95
+ "active": frozenset({"running", "blocked"}),
96
+ "completed": frozenset({"completed", "failed"}),
97
+ "cancelled": frozenset({"cancelled"}),
98
+ }
99
+
100
+ LIFECYCLE_FOLDERS = tuple(FOLDER_ALLOWED_STATUSES.keys())
101
+
102
+ # D7: filename convention YYYY-MM-DD-descriptive-slug.vbrief.json
103
+ FILENAME_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*\.vbrief\.json$")
104
+
105
+ # D3: expected narrative keys for PROJECT-DEFINITION (per #506 D3).
106
+ # Values are normalized (lowercase, whitespace collapsed) so both the
107
+ # historic lowercase-space ``tech stack`` and the #506 D3 PascalCase
108
+ # ``TechStack`` shapes satisfy the validator. Comparison normalizes the
109
+ # candidate key the same way.
110
+ PROJECT_DEF_EXPECTED_NARRATIVES = frozenset(
111
+ {
112
+ "overview",
113
+ "techstack",
114
+ }
115
+ )
116
+
117
+
118
+ def _normalize_narrative_key(key: str) -> str:
119
+ """Normalize a narrative key for D3 comparison.
120
+
121
+ Lowercases, strips whitespace, and collapses word separators so
122
+ ``TechStack`` / ``Tech Stack`` / ``tech stack`` / ``tech-stack`` all
123
+ compare equal to the canonical ``techstack`` key (#506 D3 / D5).
124
+ Uses the module-level ``re`` already imported at the top of the file
125
+ (PR #525 Greptile P2 review).
126
+ """
127
+ low = (key or "").lower()
128
+ return re.sub(r"[\s_\-]+", "", low)
129
+
130
+
131
+ def _scope_ids_for_ref_uri(uri: str) -> set[str]:
132
+ """Return possible PROJECT-DEFINITION registry IDs for a local scope URI."""
133
+ rel = uri[len("file://") :] if uri.startswith("file://") else uri
134
+ name = Path(rel).name
135
+ full_id = name[: -len(".vbrief.json")] if name.endswith(".vbrief.json") else Path(name).stem
136
+ ids = {full_id}
137
+ parts = full_id.split("-", 3)
138
+ if (
139
+ len(parts) == 4
140
+ and len(parts[0]) == 4
141
+ and len(parts[1]) == 2
142
+ and len(parts[2]) == 2
143
+ and all(part.isdigit() for part in parts[:3])
144
+ ):
145
+ ids.add(parts[3])
146
+ return ids
147
+
148
+
149
+ def _item_local_scope_uris(item: dict, plan: dict) -> list[str]:
150
+ """Collect local scope URIs that identify a PROJECT-DEFINITION registry item."""
151
+ uris: list[str] = []
152
+
153
+ metadata = item.get("metadata")
154
+ if isinstance(metadata, dict):
155
+ source_path = metadata.get("source_path")
156
+ if isinstance(source_path, str) and source_path:
157
+ uris.append(source_path)
158
+ metadata_refs = metadata.get("references")
159
+ if isinstance(metadata_refs, list):
160
+ for ref in metadata_refs:
161
+ if isinstance(ref, dict) and ref.get("type") == "x-vbrief/plan":
162
+ uri = ref.get("uri")
163
+ if isinstance(uri, str) and uri:
164
+ uris.append(uri)
165
+
166
+ refs = item.get("references")
167
+ if isinstance(refs, list):
168
+ for ref in refs:
169
+ if isinstance(ref, dict) and ref.get("type") == "x-vbrief/plan":
170
+ uri = ref.get("uri")
171
+ if isinstance(uri, str) and uri:
172
+ uris.append(uri)
173
+
174
+ item_id = item.get("id")
175
+ item_title = item.get("title")
176
+ plan_refs = plan.get("references")
177
+ if isinstance(plan_refs, list):
178
+ for ref in plan_refs:
179
+ if not isinstance(ref, dict) or ref.get("type") != "x-vbrief/plan":
180
+ continue
181
+ uri = ref.get("uri")
182
+ if not isinstance(uri, str) or not uri:
183
+ continue
184
+ title_matches = isinstance(item_title, str) and ref.get("title") == item_title
185
+ id_matches = isinstance(item_id, str) and item_id in _scope_ids_for_ref_uri(uri)
186
+ if title_matches or id_matches:
187
+ uris.append(uri)
188
+
189
+ # Preserve first-seen order while avoiding duplicate diagnostics.
190
+ return list(dict.fromkeys(uris))
191
+
192
+
193
+ def _validate_project_registry_scope_status(
194
+ item: dict,
195
+ item_index: int,
196
+ plan: dict,
197
+ filepath: Path,
198
+ vbrief_dir: Path,
199
+ ) -> list[str]:
200
+ """Validate PROJECT-DEFINITION item status against referenced scope status."""
201
+ errors: list[str] = []
202
+ item_status = item.get("status")
203
+ if not isinstance(item_status, str):
204
+ return errors
205
+
206
+ resolved_root = vbrief_dir.resolve()
207
+ for uri in _item_local_scope_uris(item, plan):
208
+ if uri.startswith(("http://", "https://", "#")):
209
+ continue
210
+ scope_path = _resolve_ref_path(uri, vbrief_dir)
211
+ if scope_path is None:
212
+ continue
213
+ if not scope_path.is_relative_to(resolved_root) or not scope_path.exists():
214
+ continue
215
+ try:
216
+ scope_data = json.loads(scope_path.read_text(encoding="utf-8"))
217
+ except (OSError, json.JSONDecodeError):
218
+ continue
219
+ scope_plan = scope_data.get("plan")
220
+ if not isinstance(scope_plan, dict):
221
+ continue
222
+ scope_status = scope_plan.get("status")
223
+ if isinstance(scope_status, str) and scope_status != item_status:
224
+ errors.append(
225
+ f"{filepath}: items[{item_index}] status is {item_status!r} "
226
+ f"but referenced scope '{uri}' has plan.status {scope_status!r} "
227
+ "(D3 registry-status)"
228
+ )
229
+ return errors
230
+
231
+
232
+ # D11: origin reference type patterns.
233
+ #
234
+ # Default behavior (schema-trusting, Option A from #536): ANY reference whose
235
+ # `type` matches `^x-vbrief/` counts as an origin. This matches the v0.6
236
+ # schema pattern and aligns with the shape documented in
237
+ # conventions/references.md and the refinement skill.
238
+ #
239
+ # Strict behavior (opt-in via --strict-origin-types): only the registered
240
+ # allow-list below counts. Teams who want zero tolerance for ad-hoc
241
+ # `x-vbrief/*` values pass --strict-origin-types in CI.
242
+ ORIGIN_TYPE_PATTERN = re.compile(r"^x-vbrief/")
243
+
244
+ STRICT_ORIGIN_ALLOWLIST = frozenset(
245
+ {
246
+ "x-vbrief/plan",
247
+ "x-vbrief/github-issue",
248
+ "x-vbrief/github-pr",
249
+ "x-vbrief/jira-ticket",
250
+ "x-vbrief/user-request",
251
+ "x-vbrief/spec-section",
252
+ }
253
+ )
254
+
255
+ # Legacy bare origin types accepted for backward compatibility with
256
+ # pre-v0.20 vBRIEFs that pre-date the x-vbrief/* prefix convention.
257
+ # These are accepted unconditionally (independent of --strict-origin-types)
258
+ # so pre-migration vBRIEFs do not regress.
259
+ LEGACY_ORIGIN_TYPES = frozenset(
260
+ {
261
+ "github-issue",
262
+ "jira-ticket",
263
+ "user-request",
264
+ }
265
+ )
266
+
267
+ # Files that should contain the redirect sentinel after migration
268
+ DEPRECATED_FILES = ("SPECIFICATION.md", "PROJECT.md")
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # Schema validation (reuses spec_validate.py logic, extended)
273
+ # ---------------------------------------------------------------------------
274
+
275
+
276
+ def validate_vbrief_schema(data: dict, filepath: str) -> list[str]:
277
+ """Validate vBRIEF structural requirements (v0.6). Returns errors.
278
+
279
+ Strictly requires ``vBRIEFInfo.version == "0.6"`` to match the canonical
280
+ v0.6 schema vendored at ``vbrief/schemas/vbrief-core.schema.json`` (#533).
281
+ Any v0.5 vBRIEF is auto-bumped to v0.6 during ``task migrate:vbrief``
282
+ (#571); operators who hit the error should re-run the migrator.
283
+ """
284
+ errors: list[str] = []
285
+
286
+ # Top-level envelope
287
+ if "vBRIEFInfo" not in data:
288
+ errors.append(f"{filepath}: missing required top-level key 'vBRIEFInfo'")
289
+ else:
290
+ info = data["vBRIEFInfo"]
291
+ if not isinstance(info, dict):
292
+ errors.append(f"{filepath}: 'vBRIEFInfo' must be an object")
293
+ elif info.get("version") not in VALID_VBRIEF_VERSIONS:
294
+ # #571: replaced the non-existent "migrator sweep" recovery
295
+ # pointer with the real command -- the migrator now auto-
296
+ # bumps v0.5 -> v0.6 on every pre-existing
297
+ # ``specification.vbrief.json`` / ``plan.vbrief.json`` it
298
+ # encounters.
299
+ errors.append(
300
+ f"{filepath}: 'vBRIEFInfo.version' must be '0.6' "
301
+ f"(canonical v0.6 schema, #533), got "
302
+ f"{info.get('version')!r}. Run `task migrate:vbrief` to "
303
+ f"upgrade pre-existing v0.5 vBRIEFs in-place."
304
+ )
305
+
306
+ if "plan" not in data:
307
+ errors.append(f"{filepath}: missing required top-level key 'plan'")
308
+ else:
309
+ plan = data["plan"]
310
+ if not isinstance(plan, dict):
311
+ errors.append(f"{filepath}: 'plan' must be an object")
312
+ else:
313
+ for field in ("title", "status", "items"):
314
+ if field not in plan:
315
+ errors.append(f"{filepath}: 'plan' missing required field '{field}'")
316
+
317
+ if "title" in plan and (not isinstance(plan["title"], str) or not plan["title"]):
318
+ errors.append(f"{filepath}: 'plan.title' must be a non-empty string")
319
+
320
+ if "status" in plan and plan["status"] not in VALID_STATUSES:
321
+ errors.append(
322
+ f"{filepath}: 'plan.status' invalid: {plan['status']!r} "
323
+ f"(expected one of {sorted(VALID_STATUSES)})"
324
+ )
325
+
326
+ # Validate narratives values are strings
327
+ if "narratives" in plan:
328
+ _validate_narratives(plan["narratives"], f"{filepath}: plan.narratives", errors)
329
+
330
+ if "items" in plan:
331
+ if not isinstance(plan["items"], list):
332
+ errors.append(f"{filepath}: 'plan.items' must be an array")
333
+ else:
334
+ for i, item in enumerate(plan["items"]):
335
+ if not isinstance(item, dict):
336
+ errors.append(f"{filepath}: plan.items[{i}] must be an object")
337
+ continue
338
+ _validate_plan_item(item, f"{filepath}: plan.items", errors)
339
+
340
+ return errors
341
+
342
+
343
+ def _validate_narratives(narratives: object, path: str, errors: list[str]) -> None:
344
+ """Validate that all values in a narratives object are strings."""
345
+ if not isinstance(narratives, dict):
346
+ errors.append(f"{path} must be an object")
347
+ return
348
+ for key, value in narratives.items():
349
+ if not isinstance(value, str):
350
+ errors.append(f"{path}.{key} must be a string, got {type(value).__name__}")
351
+
352
+
353
+ def _validate_plan_item(item: dict, path: str, errors: list[str]) -> None:
354
+ """Recursively validate a PlanItem and its nested children.
355
+
356
+ Per the canonical v0.6 schema, ``PlanItem.items`` is the PREFERRED
357
+ nested field and ``PlanItem.subItems`` is the deprecated legacy alias
358
+ kept for backward compatibility (#533 / Greptile P1). Both are accepted
359
+ here and recursively validated; neither is treated as an error.
360
+ """
361
+ item_id = item.get("id", "<no-id>")
362
+ item_path = f"{path}[{item_id}]"
363
+
364
+ if "title" not in item:
365
+ errors.append(f"{item_path} missing 'title'")
366
+ if "status" not in item:
367
+ errors.append(f"{item_path} missing 'status'")
368
+ elif item["status"] not in VALID_STATUSES:
369
+ errors.append(f"{item_path} invalid status: {item['status']!r}")
370
+
371
+ if "narrative" in item:
372
+ _validate_narratives(item["narrative"], f"{item_path}.narrative", errors)
373
+
374
+ # v0.6 preferred nested field.
375
+ if "items" in item:
376
+ if not isinstance(item["items"], list):
377
+ errors.append(f"{item_path}.items must be an array")
378
+ else:
379
+ for j, sub in enumerate(item["items"]):
380
+ if not isinstance(sub, dict):
381
+ errors.append(f"{item_path}.items[{j}] must be an object")
382
+ continue
383
+ _validate_plan_item(sub, f"{item_path}.items", errors)
384
+
385
+ # Deprecated legacy alias -- still accepted for backward compatibility.
386
+ if "subItems" in item:
387
+ if not isinstance(item["subItems"], list):
388
+ errors.append(f"{item_path}.subItems must be an array")
389
+ else:
390
+ for j, sub in enumerate(item["subItems"]):
391
+ if not isinstance(sub, dict):
392
+ errors.append(f"{item_path}.subItems[{j}] must be an object")
393
+ continue
394
+ _validate_plan_item(sub, f"{item_path}.subItems", errors)
395
+
396
+
397
+ # ---------------------------------------------------------------------------
398
+ # D7: Filename convention
399
+ # ---------------------------------------------------------------------------
400
+
401
+
402
+ def validate_filename(filepath: Path) -> list[str]:
403
+ """Check filename matches YYYY-MM-DD-descriptive-slug.vbrief.json."""
404
+ name = filepath.name
405
+ if name == "PROJECT-DEFINITION.vbrief.json":
406
+ return [] # PROJECT-DEFINITION has its own convention
407
+ if not FILENAME_PATTERN.match(name):
408
+ return [
409
+ f"{filepath}: filename '{name}' does not match convention "
410
+ "YYYY-MM-DD-descriptive-slug.vbrief.json (D7)"
411
+ ]
412
+ return []
413
+
414
+
415
+ # ---------------------------------------------------------------------------
416
+ # D2: Folder/status consistency
417
+ # ---------------------------------------------------------------------------
418
+
419
+
420
+ def validate_folder_status(filepath: Path, data: dict, vbrief_dir: Path) -> list[str]:
421
+ """Verify plan.status matches the lifecycle folder the file is in."""
422
+ errors: list[str] = []
423
+ try:
424
+ rel = filepath.relative_to(vbrief_dir)
425
+ except ValueError:
426
+ return []
427
+
428
+ parts = rel.parts
429
+ if len(parts) < 2:
430
+ return [] # file is at vbrief/ root (e.g. PROJECT-DEFINITION)
431
+
432
+ folder = parts[0]
433
+ if folder not in FOLDER_ALLOWED_STATUSES:
434
+ return [] # not in a lifecycle folder
435
+
436
+ plan = data.get("plan", {})
437
+ status = plan.get("status")
438
+ if status is None:
439
+ return [] # schema validator already catches missing status
440
+
441
+ allowed = FOLDER_ALLOWED_STATUSES[folder]
442
+ if status not in allowed:
443
+ errors.append(
444
+ f"{filepath}: plan.status is '{status}' but file is in "
445
+ f"'{folder}/' (allowed statuses: {sorted(allowed)}) (D2)"
446
+ )
447
+
448
+ return errors
449
+
450
+
451
+ # ---------------------------------------------------------------------------
452
+ # D3: PROJECT-DEFINITION.vbrief.json validator
453
+ # ---------------------------------------------------------------------------
454
+
455
+
456
+ def validate_project_definition(filepath: Path, data: dict, vbrief_dir: Path) -> list[str]:
457
+ """Validate PROJECT-DEFINITION.vbrief.json specific requirements."""
458
+ errors: list[str] = []
459
+ resolved_root = vbrief_dir.resolve()
460
+
461
+ # Check narratives contains expected keys. Normalization collapses
462
+ # word separators so both the historic ``tech stack`` spelling and the
463
+ # #506 D3 canonical ``TechStack`` shape satisfy the check.
464
+ plan = data.get("plan", {})
465
+ narratives = plan.get("narratives", {})
466
+ if isinstance(narratives, dict):
467
+ present = {_normalize_narrative_key(k) for k in narratives}
468
+ for expected in PROJECT_DEF_EXPECTED_NARRATIVES:
469
+ if expected not in present:
470
+ errors.append(f"{filepath}: narratives missing expected key '{expected}' (D3)")
471
+
472
+ # #1131 (D12): typed plan.policy.triageScope[] validation -- helper
473
+ # lives in scripts/triage_scope.py so this file does not grow.
474
+ with contextlib.suppress(Exception):
475
+ from triage_scope import validate_triage_scope_on_plan # noqa: I001
476
+
477
+ errors.extend(validate_triage_scope_on_plan(plan, filepath))
478
+
479
+ # #1133 (D14): typed plan.policy.triageScopeIgnores[] validation --
480
+ # helper lives in scripts/_triage_scope_ignores.py and is re-exported
481
+ # from triage_scope so the lazy-import hook pattern mirrors D12.
482
+ with contextlib.suppress(Exception):
483
+ from triage_scope import validate_triage_scope_ignores_on_plan # noqa: I001
484
+
485
+ errors.extend(validate_triage_scope_ignores_on_plan(plan, filepath))
486
+
487
+ # #1129 (D10): typed triageAutoClassify[] + triageHoldMarkers[] hooks.
488
+ with contextlib.suppress(Exception):
489
+ from triage_classify import (
490
+ validate_triage_auto_classify_on_plan as _ac,
491
+ validate_triage_hold_markers_on_plan as _hm,
492
+ ) # noqa: I001,E501
493
+
494
+ errors.extend(_ac(plan, filepath))
495
+ errors.extend(_hm(plan, filepath))
496
+
497
+ # #1128 (D11): typed plan.policy.triageRankingLabels[] validation --
498
+ # helper lives in scripts/triage_queue.py so this file does not grow.
499
+ with contextlib.suppress(Exception):
500
+ from triage_queue import validate_triage_ranking_labels_on_plan # noqa: I001
501
+
502
+ errors.extend(validate_triage_ranking_labels_on_plan(plan, filepath))
503
+
504
+ # #1124 (D4): typed plan.policy.wipCap validation -- helper lives in
505
+ # scripts/policy.py so this file does not grow. Mirrors the D10 /
506
+ # D11 / D12 hook pattern above.
507
+ with contextlib.suppress(Exception):
508
+ from policy import validate_wip_cap_on_plan # noqa: I001
509
+
510
+ errors.extend(validate_wip_cap_on_plan(plan, filepath))
511
+
512
+ # #1348: typed plan.policy.sessionRitualStalenessHours validation.
513
+ try:
514
+ from policy import validate_session_ritual_staleness_hours_on_plan # noqa: I001
515
+ except ImportError:
516
+ pass
517
+ else:
518
+ errors.extend(validate_session_ritual_staleness_hours_on_plan(plan, filepath))
519
+
520
+ # Check items registry entries reference existing scope vBRIEF files
521
+ items = plan.get("items", [])
522
+ if isinstance(items, list):
523
+ for i, item in enumerate(items):
524
+ if not isinstance(item, dict):
525
+ continue
526
+ errors.extend(
527
+ _validate_project_registry_scope_status(item, i, plan, filepath, vbrief_dir)
528
+ )
529
+ refs = item.get("references", [])
530
+ if not isinstance(refs, list):
531
+ refs = []
532
+ for ref in refs:
533
+ if not isinstance(ref, dict):
534
+ continue
535
+ uri = ref.get("uri", "")
536
+ if uri and uri.startswith("file://"):
537
+ ref_path = uri.replace("file://", "")
538
+ full_path = (vbrief_dir / ref_path).resolve()
539
+ if not full_path.is_relative_to(resolved_root):
540
+ errors.append(
541
+ f"{filepath}: items[{i}] references "
542
+ f"'{ref_path}' outside vbrief directory (D3)"
543
+ )
544
+ continue
545
+ if not full_path.exists():
546
+ errors.append(
547
+ f"{filepath}: items[{i}] references "
548
+ f"'{ref_path}' which does not exist (D3)"
549
+ )
550
+ elif uri and not uri.startswith(("http://", "https://", "#")):
551
+ # Treat as relative path
552
+ full_path = (vbrief_dir / uri).resolve()
553
+ if not full_path.is_relative_to(resolved_root):
554
+ errors.append(
555
+ f"{filepath}: items[{i}] references "
556
+ f"'{uri}' outside vbrief directory (D3)"
557
+ )
558
+ continue
559
+ if not full_path.exists():
560
+ errors.append(
561
+ f"{filepath}: items[{i}] references '{uri}' which does not exist (D3)"
562
+ )
563
+
564
+ return errors
565
+
566
+
567
+ # ---------------------------------------------------------------------------
568
+ # D4: Epic-story bidirectional link validation
569
+ # ---------------------------------------------------------------------------
570
+
571
+
572
+ def validate_epic_story_links(
573
+ all_vbriefs: dict[Path, dict],
574
+ vbrief_dir: Path,
575
+ resolved_to_original: dict[Path, Path] | None = None,
576
+ ) -> list[str]:
577
+ """Validate bidirectional references between epic and story vBRIEFs."""
578
+ errors: list[str] = []
579
+ path_map = resolved_to_original or {}
580
+
581
+ def _display(p: Path) -> str:
582
+ """Return original path for display if available."""
583
+ return str(path_map.get(p, p))
584
+
585
+ for filepath, data in all_vbriefs.items():
586
+ plan = data.get("plan", {})
587
+ fp_display = _display(filepath)
588
+
589
+ # Check forward references (epic -> children)
590
+ refs = plan.get("references", [])
591
+ if isinstance(refs, list):
592
+ for ref in refs:
593
+ if not isinstance(ref, dict):
594
+ continue
595
+ uri = ref.get("uri", "")
596
+ ref_type = ref.get("type", "")
597
+ if not uri or not ref_type:
598
+ continue
599
+ # D4 only applies to child plan references
600
+ if ref_type != "x-vbrief/plan":
601
+ continue
602
+ # Resolve the child path
603
+ child_path = _resolve_ref_path(uri, vbrief_dir)
604
+ if child_path is None:
605
+ continue
606
+ if child_path not in all_vbriefs:
607
+ if child_path.exists():
608
+ continue # file exists but wasn't loaded
609
+ errors.append(
610
+ f"{fp_display}: references child '{uri}' which does not exist (D4)"
611
+ )
612
+ continue
613
+ # Verify child has planRef back
614
+ child_data = all_vbriefs[child_path]
615
+ child_plan = child_data.get("plan", {})
616
+ if not _has_plan_ref_to(child_plan, filepath, vbrief_dir):
617
+ errors.append(
618
+ f"{_display(child_path)}: missing planRef back "
619
+ f"to parent '{filepath.name}' (D4)"
620
+ )
621
+
622
+ # Check backward references (story -> parent via planRef)
623
+ # Scan both plan-level and item-level planRef values
624
+ for plan_ref in _collect_plan_refs(plan):
625
+ parent_path = _resolve_ref_path(plan_ref, vbrief_dir)
626
+ if parent_path and parent_path in all_vbriefs:
627
+ parent_data = all_vbriefs[parent_path]
628
+ parent_plan = parent_data.get("plan", {})
629
+ parent_refs = parent_plan.get("references", [])
630
+ if isinstance(parent_refs, list):
631
+ child_uris = set()
632
+ for pref in parent_refs:
633
+ if isinstance(pref, dict) and pref.get("type") == "x-vbrief/plan":
634
+ child_uris.add(pref.get("uri", ""))
635
+ if not _path_in_refs(filepath, child_uris, vbrief_dir):
636
+ errors.append(
637
+ f"{fp_display}: has planRef to "
638
+ f"'{parent_path.name}' but parent "
639
+ "does not list this file in "
640
+ "references (D4)"
641
+ )
642
+ elif parent_path and not parent_path.exists():
643
+ errors.append(
644
+ f"{fp_display}: planRef references '{plan_ref}' which does not exist (D4)"
645
+ )
646
+
647
+ return errors
648
+
649
+
650
+ def _collect_plan_refs(plan: dict) -> list[str]:
651
+ """Collect all planRef values from plan root and top-level items.
652
+
653
+ Note: subItems are intentionally not scanned -- planRef is only valid
654
+ at the plan root and top-level item levels per vBRIEF convention.
655
+ """
656
+ refs: list[str] = []
657
+ root_ref = plan.get("planRef")
658
+ if isinstance(root_ref, str) and root_ref:
659
+ refs.append(root_ref)
660
+ for item in plan.get("items", []):
661
+ if isinstance(item, dict):
662
+ item_ref = item.get("planRef")
663
+ if isinstance(item_ref, str) and item_ref:
664
+ refs.append(item_ref)
665
+ return refs
666
+
667
+
668
+ def _resolve_ref_path(uri: str, vbrief_dir: Path) -> Path | None:
669
+ """Resolve a reference URI to a filesystem path."""
670
+ if not isinstance(uri, str):
671
+ return None
672
+ if uri.startswith("file://"):
673
+ rel = uri.replace("file://", "")
674
+ return (vbrief_dir / rel).resolve()
675
+ if uri.startswith(("http://", "https://", "#")):
676
+ return None
677
+ # Treat as relative path
678
+ return (vbrief_dir / uri).resolve()
679
+
680
+
681
+ def _has_plan_ref_to(child_plan: dict, parent_path: Path, vbrief_dir: Path) -> bool:
682
+ """Check if a plan has a planRef pointing back to parent_path."""
683
+ plan_ref = child_plan.get("planRef")
684
+ if plan_ref:
685
+ resolved = _resolve_ref_path(plan_ref, vbrief_dir)
686
+ if resolved and resolved == parent_path.resolve():
687
+ return True
688
+ # Also check items for planRef
689
+ for item in child_plan.get("items", []):
690
+ if isinstance(item, dict):
691
+ item_ref = item.get("planRef")
692
+ if item_ref:
693
+ resolved = _resolve_ref_path(item_ref, vbrief_dir)
694
+ if resolved and resolved == parent_path.resolve():
695
+ return True
696
+ return False
697
+
698
+
699
+ def _path_in_refs(filepath: Path, uris: set[str], vbrief_dir: Path) -> bool:
700
+ """Check if filepath is referenced by any URI in the set."""
701
+ resolved_file = filepath.resolve()
702
+ for uri in uris:
703
+ resolved = _resolve_ref_path(uri, vbrief_dir)
704
+ if resolved and resolved == resolved_file:
705
+ return True
706
+ return False
707
+
708
+
709
+ # ---------------------------------------------------------------------------
710
+ # D11: Origin provenance check
711
+ # ---------------------------------------------------------------------------
712
+
713
+
714
+ def validate_origin_provenance(
715
+ filepath: Path,
716
+ data: dict,
717
+ vbrief_dir: Path,
718
+ strict_origin_types: bool = False,
719
+ ) -> list[str]:
720
+ """Warn if a scope vBRIEF in pending/ or active/ has no origin reference.
721
+
722
+ Default behavior (schema-trusting): ANY reference whose ``type`` matches
723
+ ``^x-vbrief/`` counts as an origin. Legacy bare origin types
724
+ (``github-issue``, ``jira-ticket``, ``user-request``) are also accepted
725
+ unconditionally for pre-migration vBRIEFs (#536).
726
+
727
+ Strict behavior (``strict_origin_types=True``): only values in
728
+ :data:`STRICT_ORIGIN_ALLOWLIST` count. Legacy bare types continue to be
729
+ accepted so pre-migration vBRIEFs do not regress.
730
+ """
731
+ warnings: list[str] = []
732
+
733
+ try:
734
+ rel = filepath.relative_to(vbrief_dir)
735
+ except ValueError:
736
+ return []
737
+
738
+ parts = rel.parts
739
+ if len(parts) < 2:
740
+ return []
741
+
742
+ folder = parts[0]
743
+ if folder not in ("pending", "active"):
744
+ return []
745
+
746
+ plan = data.get("plan", {})
747
+ refs = plan.get("references", [])
748
+ has_origin = False
749
+ if isinstance(refs, list):
750
+ for ref in refs:
751
+ if not isinstance(ref, dict):
752
+ continue
753
+ ref_type = ref.get("type", "")
754
+ if not isinstance(ref_type, str):
755
+ continue
756
+
757
+ # Legacy bare origin types always count (pre-migration vBRIEFs).
758
+ if ref_type in LEGACY_ORIGIN_TYPES:
759
+ has_origin = True
760
+ break
761
+ # Legacy extended types (e.g. "github-issue-v2") also count for
762
+ # backward compatibility with pre-v0.20 tooling.
763
+ if any(
764
+ ref_type.startswith((f"{legacy}-", f"{legacy}/")) for legacy in LEGACY_ORIGIN_TYPES
765
+ ):
766
+ has_origin = True
767
+ break
768
+
769
+ if strict_origin_types:
770
+ # Allow-list mode: only registered x-vbrief/* values count.
771
+ if ref_type in STRICT_ORIGIN_ALLOWLIST:
772
+ has_origin = True
773
+ break
774
+ else:
775
+ # Schema-trusting default: any x-vbrief/* value counts.
776
+ if ORIGIN_TYPE_PATTERN.match(ref_type):
777
+ has_origin = True
778
+ break
779
+
780
+ if not has_origin:
781
+ if strict_origin_types:
782
+ warnings.append(
783
+ f"{filepath}: scope vBRIEF in '{folder}/' has no references "
784
+ "with an allow-listed origin type (D11; "
785
+ "--strict-origin-types)"
786
+ )
787
+ else:
788
+ warnings.append(
789
+ f"{filepath}: scope vBRIEF in '{folder}/' has no references "
790
+ "with an origin type (D11)"
791
+ )
792
+
793
+ return warnings
794
+
795
+
796
+ # ---------------------------------------------------------------------------
797
+ # #398: Render staleness detection (PRD.md / SPECIFICATION.md)
798
+ # ---------------------------------------------------------------------------
799
+
800
+
801
+ def check_render_staleness(vbrief_dir: Path) -> list[str]:
802
+ """Warn if PRD.md or SPECIFICATION.md are stale relative to specification.vbrief.json.
803
+
804
+ Compares source narratives/items from specification.vbrief.json against
805
+ the rendered export files. Returns warning strings for stale files.
806
+ Skips silently if export files don't exist (#398).
807
+ """
808
+ warnings: list[str] = []
809
+ project_root = vbrief_dir.parent
810
+ spec_path = vbrief_dir / "specification.vbrief.json"
811
+
812
+ if not spec_path.is_file():
813
+ return warnings
814
+
815
+ try:
816
+ with open(spec_path, encoding="utf-8") as fh:
817
+ data = json.load(fh)
818
+ except (json.JSONDecodeError, OSError):
819
+ return warnings
820
+
821
+ plan = data.get("plan", {})
822
+ if not isinstance(plan, dict):
823
+ return warnings
824
+
825
+ narratives = plan.get("narratives", {})
826
+ items = plan.get("items", [])
827
+ title = plan.get("title", "")
828
+
829
+ # --- PRD.md ---
830
+ prd_path = project_root / "PRD.md"
831
+ if prd_path.is_file():
832
+ warnings.extend(_check_prd_staleness(prd_path, narratives, title))
833
+
834
+ # --- SPECIFICATION.md ---
835
+ # Note: validate_deprecated_placeholders (called earlier in validate_all)
836
+ # may also warn about SPECIFICATION.md if it lacks the deprecation redirect
837
+ # sentinel. The staleness check here is complementary -- it fires for
838
+ # rendered exports that have drifted, while the deprecated check fires for
839
+ # files missing the redirect sentinel. Both can appear in the same run
840
+ # during transitional states (e.g. a user ran `task spec:render` after
841
+ # migration); this is intentional -- the deprecated warning takes priority
842
+ # for the user's attention.
843
+ spec_md_path = project_root / "SPECIFICATION.md"
844
+ if spec_md_path.is_file():
845
+ warnings.extend(_check_spec_staleness(spec_md_path, narratives, items, title))
846
+
847
+ return warnings
848
+
849
+
850
+ def _check_prd_staleness(
851
+ prd_path: Path,
852
+ narratives: dict,
853
+ title: str,
854
+ ) -> list[str]:
855
+ """Return a warning if PRD.md does not reflect current source narratives."""
856
+ try:
857
+ content = prd_path.read_text(encoding="utf-8")
858
+ except OSError:
859
+ return []
860
+
861
+ if not isinstance(narratives, dict) or not narratives:
862
+ return []
863
+
864
+ for value in narratives.values():
865
+ if isinstance(value, str) and value.strip() and value.strip() not in content:
866
+ return [
867
+ "PRD.md may be stale relative to "
868
+ "vbrief/specification.vbrief.json -- "
869
+ "run `task prd:render` to refresh"
870
+ ]
871
+
872
+ if title and title not in content:
873
+ return [
874
+ "PRD.md may be stale relative to "
875
+ "vbrief/specification.vbrief.json -- "
876
+ "run `task prd:render` to refresh"
877
+ ]
878
+
879
+ return []
880
+
881
+
882
+ def _check_spec_staleness(
883
+ spec_md_path: Path,
884
+ narratives: dict,
885
+ items: list,
886
+ title: str,
887
+ ) -> list[str]:
888
+ """Return a warning if SPECIFICATION.md does not reflect current source."""
889
+ try:
890
+ content = spec_md_path.read_text(encoding="utf-8")
891
+ except OSError:
892
+ return []
893
+
894
+ # Skip deprecation redirects and current generated specification exports.
895
+ project_root = spec_md_path.parent
896
+ if is_deprecation_redirect(content) or is_current_generated_specification(
897
+ project_root, content
898
+ ):
899
+ return []
900
+
901
+ msg = (
902
+ "SPECIFICATION.md may be stale relative to "
903
+ "vbrief/specification.vbrief.json -- "
904
+ "run `task spec:render` to refresh"
905
+ )
906
+
907
+ # Check item titles
908
+ if isinstance(items, list):
909
+ for item in items:
910
+ if not isinstance(item, dict):
911
+ continue
912
+ item_title = item.get("title", "")
913
+ if isinstance(item_title, str) and item_title and item_title not in content:
914
+ return [msg]
915
+
916
+ # Check all narrative values (mirrors _check_prd_staleness)
917
+ if isinstance(narratives, dict):
918
+ for value in narratives.values():
919
+ if isinstance(value, str) and value.strip() and value.strip() not in content:
920
+ return [msg]
921
+
922
+ # Check title
923
+ if title and title not in content:
924
+ return [msg]
925
+
926
+ return []
927
+
928
+
929
+ # ---------------------------------------------------------------------------
930
+ # Story S (#334): Post-migration placeholder integrity
931
+ # ---------------------------------------------------------------------------
932
+
933
+
934
+ def validate_deprecated_placeholders(
935
+ vbrief_dir: Path,
936
+ ) -> list[str]:
937
+ """Check that SPECIFICATION.md and PROJECT.md contain the deprecation
938
+ redirect sentinel if they exist.
939
+
940
+ After migration, these files are replaced with redirect stubs containing
941
+ ``<!-- deft:deprecated-redirect -->``. If a user or agent replaces the
942
+ redirect with real content, flag it as a warning.
943
+
944
+ Returns a list of warning strings.
945
+ """
946
+ warnings: list[str] = []
947
+ project_root = vbrief_dir.parent
948
+
949
+ for filename in DEPRECATED_FILES:
950
+ filepath = project_root / filename
951
+ if not filepath.is_file():
952
+ continue
953
+ try:
954
+ content = filepath.read_text(encoding="utf-8")
955
+ except OSError:
956
+ continue
957
+
958
+ if is_deprecation_redirect(content):
959
+ continue
960
+ if filename == "SPECIFICATION.md" and is_current_generated_specification(
961
+ project_root, content
962
+ ):
963
+ continue
964
+ warnings.append(
965
+ f"{filename} contains non-redirect content -- "
966
+ "this file is deprecated; use scope vBRIEFs "
967
+ "in vbrief/ instead"
968
+ )
969
+
970
+ return warnings
971
+
972
+
973
+ # ---------------------------------------------------------------------------
974
+ # Main orchestrator
975
+ # ---------------------------------------------------------------------------
976
+
977
+
978
+ def load_vbrief(filepath: Path) -> tuple[dict | None, str | None]:
979
+ """Load and parse a .vbrief.json file. Returns (data, error)."""
980
+ try:
981
+ with open(filepath, encoding="utf-8") as fh:
982
+ data = json.load(fh)
983
+ return data, None
984
+ except json.JSONDecodeError as exc:
985
+ return None, f"{filepath}: invalid JSON: {exc}"
986
+ except OSError as exc:
987
+ return None, f"{filepath}: cannot read: {exc}"
988
+
989
+
990
+ def discover_vbriefs(vbrief_dir: Path) -> list[Path]:
991
+ """Find all .vbrief.json files in lifecycle folders."""
992
+ files: list[Path] = []
993
+ for folder in LIFECYCLE_FOLDERS:
994
+ folder_path = vbrief_dir / folder
995
+ if folder_path.is_dir():
996
+ files.extend(sorted(folder_path.glob("*.vbrief.json")))
997
+ return files
998
+
999
+
1000
+ def _looks_like_decomposition_draft(data: object) -> bool:
1001
+ """Return whether root JSON has the temporary decomposition-draft shape."""
1002
+ if not isinstance(data, dict):
1003
+ return False
1004
+ stories = data.get("stories", data.get("children"))
1005
+ return isinstance(stories, list | dict)
1006
+
1007
+
1008
+ def validate_no_root_decomposition_drafts(vbrief_dir: Path) -> list[str]:
1009
+ """Reject decomposition draft proposals left at the workspace root."""
1010
+ project_root = vbrief_dir.parent
1011
+ errors: list[str] = []
1012
+ for path in sorted(project_root.glob("*.json")):
1013
+ try:
1014
+ data = json.loads(path.read_text(encoding="utf-8"))
1015
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
1016
+ continue
1017
+ if _looks_like_decomposition_draft(data):
1018
+ errors.append(
1019
+ f"{path}: decomposition draft JSON must not live at workspace root; "
1020
+ "write temporary proposals under vbrief/.eval/decompositions/"
1021
+ )
1022
+ return errors
1023
+
1024
+
1025
+ def validate_all(
1026
+ vbrief_dir: Path,
1027
+ strict_origin_types: bool = False,
1028
+ ) -> tuple[list[str], list[str], int]:
1029
+ """Run all validators. Returns (errors, warnings, scope_count)."""
1030
+ errors: list[str] = []
1031
+ warnings: list[str] = []
1032
+ all_vbriefs: dict[Path, dict] = {}
1033
+ # Map resolved -> original path for consistent error messages
1034
+ resolved_to_original: dict[Path, Path] = {}
1035
+
1036
+ # Discover scope vBRIEFs in lifecycle folders
1037
+ scope_files = discover_vbriefs(vbrief_dir)
1038
+ errors.extend(validate_no_root_decomposition_drafts(vbrief_dir))
1039
+
1040
+ # Validate each scope vBRIEF
1041
+ for filepath in scope_files:
1042
+ data, load_err = load_vbrief(filepath)
1043
+ if load_err:
1044
+ errors.append(load_err)
1045
+ continue
1046
+
1047
+ if data is None:
1048
+ continue
1049
+
1050
+ resolved = filepath.resolve()
1051
+ all_vbriefs[resolved] = data
1052
+ resolved_to_original[resolved] = filepath
1053
+
1054
+ # Schema validation
1055
+ errors.extend(validate_vbrief_schema(data, str(filepath)))
1056
+
1057
+ # Filename convention (D7)
1058
+ errors.extend(validate_filename(filepath))
1059
+
1060
+ # Folder/status consistency (D2)
1061
+ errors.extend(validate_folder_status(filepath, data, vbrief_dir))
1062
+
1063
+ # Origin provenance (D11) -- warnings only
1064
+ warnings.extend(
1065
+ validate_origin_provenance(
1066
+ filepath,
1067
+ data,
1068
+ vbrief_dir,
1069
+ strict_origin_types=strict_origin_types,
1070
+ )
1071
+ )
1072
+
1073
+ # Validate PROJECT-DEFINITION.vbrief.json if it exists
1074
+ project_def = vbrief_dir / "PROJECT-DEFINITION.vbrief.json"
1075
+ if project_def.exists():
1076
+ data, load_err = load_vbrief(project_def)
1077
+ if load_err:
1078
+ errors.append(load_err)
1079
+ elif data is not None:
1080
+ resolved_pd = project_def.resolve()
1081
+ all_vbriefs[resolved_pd] = data
1082
+ resolved_to_original[resolved_pd] = project_def
1083
+ errors.extend(validate_vbrief_schema(data, str(project_def)))
1084
+ errors.extend(validate_project_definition(project_def, data, vbrief_dir))
1085
+
1086
+ # Epic-story bidirectional link validation (D4)
1087
+ if all_vbriefs:
1088
+ errors.extend(validate_epic_story_links(all_vbriefs, vbrief_dir, resolved_to_original))
1089
+
1090
+ # Post-migration placeholder integrity (Story S #334)
1091
+ warnings.extend(validate_deprecated_placeholders(vbrief_dir))
1092
+
1093
+ # Render staleness check (#398)
1094
+ warnings.extend(check_render_staleness(vbrief_dir))
1095
+
1096
+ # #635: emit vbrief:invalid event when validation surfaced any issue.
1097
+ # Existing CLI exit-code semantics are unchanged (handled by main()).
1098
+ # Events surface MUST NOT break validation, so registry/IO failures
1099
+ # are silently suppressed so existing CLIs remain stable.
1100
+ if errors or warnings:
1101
+ with contextlib.suppress(Exception):
1102
+ _emit_event(
1103
+ "vbrief:invalid",
1104
+ {
1105
+ "vbrief_dir": str(vbrief_dir.resolve()),
1106
+ "error_count": len(errors),
1107
+ "warning_count": len(warnings),
1108
+ "errors": list(errors),
1109
+ "warnings": list(warnings),
1110
+ },
1111
+ )
1112
+
1113
+ return errors, warnings, len(scope_files)
1114
+
1115
+
1116
+ USAGE = (
1117
+ "Usage: vbrief_validate.py [--vbrief-dir <path>] [--strict-origin-types] [--warnings-as-errors]"
1118
+ )
1119
+
1120
+
1121
+ def main(argv: list[str] | None = None) -> int:
1122
+ """CLI entry point.
1123
+
1124
+ Exit codes (#536):
1125
+ 0 -- no errors (warnings tolerated unless --warnings-as-errors is set)
1126
+ 1 -- errors, or warnings when --warnings-as-errors is set
1127
+ 2 -- usage error (unknown flag / missing argument)
1128
+ """
1129
+ vbrief_dir = Path("vbrief")
1130
+ strict_origin_types = False
1131
+ warnings_as_errors = False
1132
+
1133
+ # Parse args
1134
+ args = list(sys.argv[1:] if argv is None else argv)
1135
+ i = 0
1136
+ while i < len(args):
1137
+ arg = args[i]
1138
+ if arg == "--vbrief-dir" and i + 1 < len(args):
1139
+ vbrief_dir = Path(args[i + 1])
1140
+ i += 2
1141
+ elif arg == "--strict-origin-types":
1142
+ strict_origin_types = True
1143
+ i += 1
1144
+ elif arg == "--warnings-as-errors":
1145
+ warnings_as_errors = True
1146
+ i += 1
1147
+ elif arg in ("-h", "--help"):
1148
+ print(USAGE)
1149
+ return 0
1150
+ else:
1151
+ print(f"Unknown argument: {arg}", file=sys.stderr)
1152
+ print(USAGE, file=sys.stderr)
1153
+ return 2
1154
+
1155
+ if not vbrief_dir.is_dir():
1156
+ # No vbrief directory -- nothing to validate, pass silently
1157
+ print(f"OK: No vbrief directory at {vbrief_dir} -- skipping validation")
1158
+ return 0
1159
+
1160
+ errors, warnings, scope_count = validate_all(
1161
+ vbrief_dir, strict_origin_types=strict_origin_types
1162
+ )
1163
+
1164
+ # Print warnings first, then errors
1165
+ for w in warnings:
1166
+ print(f"WARN: {w}")
1167
+ for e in errors:
1168
+ print(f"FAIL: {e}")
1169
+
1170
+ # Determine exit code up-front so the summary banner reflects it.
1171
+ warnings_escalated = bool(warnings) and warnings_as_errors
1172
+ exit_code = 1 if errors or warnings_escalated else 0
1173
+
1174
+ # Only emit the "OK" banner when we will actually exit 0 (#536 Defect 2).
1175
+ if exit_code == 0:
1176
+ project_def = vbrief_dir / "PROJECT-DEFINITION.vbrief.json"
1177
+ parts = []
1178
+ if scope_count:
1179
+ parts.append(f"{scope_count} scope vBRIEF(s)")
1180
+ if project_def.exists():
1181
+ parts.append("PROJECT-DEFINITION")
1182
+ summary = ", ".join(parts) if parts else "no vBRIEF files"
1183
+ warning_note = f" ({len(warnings)} warning(s))" if warnings else ""
1184
+ print(f"OK: vBRIEF validation passed: {summary}{warning_note}")
1185
+ else:
1186
+ if errors:
1187
+ print(f"\nFAIL: {len(errors)} error(s) found")
1188
+ if warnings_escalated and not errors:
1189
+ print(f"\nFAIL: {len(warnings)} warning(s) treated as errors (--warnings-as-errors)")
1190
+
1191
+ return exit_code
1192
+
1193
+
1194
+ if __name__ == "__main__":
1195
+ sys.exit(main())