@deftai/directive-content 0.55.2 → 0.56.1

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,353 @@
1
+ """Shared story-quality checks for decomposition and swarm readiness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Any
7
+
8
+ BROAD_FILE_SCOPE_ROOTS = {"backend", "frontend", "docs", "vbrief"}
9
+ CODE_PATH_TERMS = (
10
+ "api",
11
+ "cli",
12
+ "component",
13
+ "config",
14
+ "database",
15
+ "endpoint",
16
+ "file",
17
+ "handler",
18
+ "model",
19
+ "module",
20
+ "repository",
21
+ "route",
22
+ "schema",
23
+ "script",
24
+ "service",
25
+ "source",
26
+ "src/",
27
+ )
28
+ VERIFY_EVIDENCE_TERMS = (
29
+ "assert",
30
+ "evidence",
31
+ "fixture",
32
+ "report",
33
+ "spec",
34
+ "test",
35
+ "tests/",
36
+ "verify",
37
+ )
38
+ GENERIC_VERIFY_COMMANDS = {
39
+ "cargo test",
40
+ "go test ./...",
41
+ "npm run test",
42
+ "npm test",
43
+ "pytest",
44
+ "task check",
45
+ }
46
+ PLACEHOLDER_ACCEPTANCE_PATTERNS = (
47
+ "acceptance criteria for",
48
+ "copy from parent",
49
+ "copy from specification",
50
+ "placeholder",
51
+ "refine from parent",
52
+ "tbd",
53
+ "to be defined",
54
+ "to refine",
55
+ "to refine from parent scope",
56
+ "todo",
57
+ )
58
+ DOCS_ONLY_ACCEPTANCE_PATTERNS = (
59
+ "docs updated",
60
+ "documentation updated",
61
+ "readme updated",
62
+ "update docs",
63
+ "update documentation",
64
+ "update readme",
65
+ )
66
+ GENERIC_IMPLEMENTATION_PATTERNS = (
67
+ "add tests so it works",
68
+ "change the code",
69
+ "implement the feature",
70
+ "make it work",
71
+ "update the code",
72
+ "works as expected",
73
+ )
74
+ VAGUE_ACCEPTANCE_PATTERNS = (
75
+ "displays a message",
76
+ "handles errors",
77
+ "is implemented",
78
+ "is updated",
79
+ "passes tests",
80
+ "shows a message",
81
+ "the system displays a message",
82
+ "updates the ui",
83
+ "works as expected",
84
+ )
85
+ OBSERVABLE_TERMS = (
86
+ "blocks",
87
+ "creates",
88
+ "deletes",
89
+ "displays",
90
+ "emits",
91
+ "fails",
92
+ "persists",
93
+ "records",
94
+ "redirects",
95
+ "rejects",
96
+ "renders",
97
+ "returns",
98
+ "saves",
99
+ "shows",
100
+ "stores",
101
+ "updates",
102
+ "validates",
103
+ "when ",
104
+ "given ",
105
+ "then ",
106
+ )
107
+ USER_STORY_RE = re.compile(
108
+ r"^\s*As\s+a[n]?\s+[^,]+,\s*I\s+want\s+.+,\s*so\s+that\s+.+\.\s*$",
109
+ re.IGNORECASE | re.DOTALL,
110
+ )
111
+
112
+
113
+ def as_str_list(value: Any) -> list[str]:
114
+ if value is None:
115
+ return []
116
+ if isinstance(value, str):
117
+ return [value.strip()] if value.strip() else []
118
+ if isinstance(value, list):
119
+ return [str(item).strip() for item in value if str(item).strip()]
120
+ return []
121
+
122
+
123
+ def acceptance_texts_from_items(items: Any) -> list[str]:
124
+ texts: list[str] = []
125
+ if not isinstance(items, list):
126
+ return texts
127
+ for item in items:
128
+ if not isinstance(item, dict):
129
+ continue
130
+ narrative = item.get("narrative")
131
+ if isinstance(narrative, dict):
132
+ acceptance = narrative.get("Acceptance")
133
+ if isinstance(acceptance, str) and acceptance.strip():
134
+ texts.append(acceptance.strip())
135
+ for child_key in ("items", "subItems"):
136
+ texts.extend(acceptance_texts_from_items(item.get(child_key)))
137
+ return texts
138
+
139
+
140
+ def item_has_acceptance(item: dict[str, Any]) -> bool:
141
+ narrative = item.get("narrative")
142
+ if isinstance(narrative, dict):
143
+ value = narrative.get("Acceptance")
144
+ if isinstance(value, str) and value.strip():
145
+ return True
146
+ for child_key in ("items", "subItems"):
147
+ children = item.get(child_key)
148
+ if isinstance(children, list):
149
+ for child in children:
150
+ if isinstance(child, dict) and item_has_acceptance(child):
151
+ return True
152
+ return False
153
+
154
+
155
+ def items_have_acceptance(items: Any) -> bool:
156
+ if not isinstance(items, list):
157
+ return False
158
+ return any(isinstance(item, dict) and item_has_acceptance(item) for item in items)
159
+
160
+
161
+ def item_has_traces(item: dict[str, Any]) -> bool:
162
+ narrative = item.get("narrative")
163
+ if isinstance(narrative, dict):
164
+ value = narrative.get("Traces")
165
+ if isinstance(value, str) and value.strip():
166
+ return True
167
+ for child_key in ("items", "subItems"):
168
+ children = item.get(child_key)
169
+ if isinstance(children, list):
170
+ for child in children:
171
+ if isinstance(child, dict) and item_has_traces(child):
172
+ return True
173
+ return False
174
+
175
+
176
+ def missing_required_swarm_fields(swarm: dict[str, Any]) -> list[str]:
177
+ missing: list[str] = []
178
+ for key in ("file_scope", "verify_commands", "expected_outputs"):
179
+ if not as_str_list(swarm.get(key)):
180
+ missing.append(f"plan.metadata.swarm.{key}")
181
+ if "depends_on" not in swarm:
182
+ missing.append("plan.metadata.swarm.depends_on")
183
+ for key in ("conflict_group", "size", "file_scope_confidence", "model_tier"):
184
+ value = swarm.get(key)
185
+ if not isinstance(value, str) or not value.strip():
186
+ missing.append(f"plan.metadata.swarm.{key}")
187
+ return missing
188
+
189
+
190
+ def deprecated_subitems_issues(items: Any, prefix: str = "plan.items") -> list[str]:
191
+ issues: list[str] = []
192
+
193
+ def visit(children: Any, path: str) -> None:
194
+ if not isinstance(children, list):
195
+ return
196
+ for index, item in enumerate(children):
197
+ if not isinstance(item, dict):
198
+ continue
199
+ item_path = f"{path}[{index}]"
200
+ if "subItems" in item:
201
+ issues.append(f"{item_path}.subItems is deprecated; use items")
202
+ visit(item.get("items"), f"{item_path}.items")
203
+ visit(item.get("subItems"), f"{item_path}.subItems")
204
+
205
+ visit(items, prefix)
206
+ return issues
207
+
208
+
209
+ def story_quality_issues(
210
+ *,
211
+ title: str,
212
+ description: str,
213
+ implementation_plan: str,
214
+ user_story: str,
215
+ acceptance_texts: list[str],
216
+ acceptance_count_justification: str,
217
+ swarm: dict[str, Any],
218
+ concurrent_ready: bool = True,
219
+ ) -> list[str]:
220
+ issues: list[str] = []
221
+ if not USER_STORY_RE.match(user_story or ""):
222
+ issues.append(
223
+ "UserStory must match 'As a <role>, I want <capability>, so that <outcome>.'"
224
+ )
225
+ issues.extend(_description_issues(description))
226
+ issues.extend(_implementation_plan_issues(implementation_plan))
227
+ if not (2 <= len(acceptance_texts) <= 5) and not acceptance_count_justification.strip():
228
+ issues.append("2-5 acceptance criteria required unless justified")
229
+
230
+ normalized_title = _normalize(title)
231
+ normalized_description = _normalize(description)
232
+ for criterion in acceptance_texts:
233
+ normalized = _normalize(criterion)
234
+ lower = criterion.lower()
235
+ if any(pattern in lower for pattern in PLACEHOLDER_ACCEPTANCE_PATTERNS):
236
+ issues.append("placeholder acceptance criterion")
237
+ if normalized and normalized in {normalized_title, normalized_description}:
238
+ issues.append("acceptance criterion duplicates title or description")
239
+ if any(pattern in lower for pattern in DOCS_ONLY_ACCEPTANCE_PATTERNS):
240
+ issues.append("vague docs-only acceptance criterion")
241
+ if _word_count(criterion) < 8 or any(
242
+ pattern in lower for pattern in VAGUE_ACCEPTANCE_PATTERNS
243
+ ):
244
+ issues.append("acceptance criterion must describe specific observable behavior")
245
+ if not _looks_observable(lower):
246
+ issues.append("acceptance criterion must describe observable behavior")
247
+
248
+ if concurrent_ready:
249
+ issues.extend(_file_scope_issues(swarm))
250
+ issues.extend(_verify_command_issues(swarm))
251
+ if swarm.get("parallel_safe") is False:
252
+ issues.append(
253
+ "readiness=ready requires parallel_safe=true; use readiness=sequential "
254
+ "or needs_refinement for non-concurrent work"
255
+ )
256
+ if swarm.get("file_scope_confidence") == "low":
257
+ issues.append("readiness=ready requires file_scope_confidence above low")
258
+ return _dedupe(issues)
259
+
260
+
261
+ def _file_scope_issues(swarm: dict[str, Any]) -> list[str]:
262
+ issues: list[str] = []
263
+ for file_path in as_str_list(swarm.get("file_scope")):
264
+ normalized = file_path.strip().strip("/")
265
+ root = normalized.split("/", 1)[0]
266
+ if (
267
+ any(ch in normalized for ch in "*?[")
268
+ or normalized in BROAD_FILE_SCOPE_ROOTS
269
+ or file_path.rstrip("/") in BROAD_FILE_SCOPE_ROOTS
270
+ or (root in BROAD_FILE_SCOPE_ROOTS and normalized in {root, f"{root}/*"})
271
+ ):
272
+ issues.append(f"broad file_scope is not swarm-ready: {file_path}")
273
+ return issues
274
+
275
+
276
+ def _verify_command_issues(swarm: dict[str, Any]) -> list[str]:
277
+ commands = [command.lower() for command in as_str_list(swarm.get("verify_commands"))]
278
+ if len(commands) == 1 and _normalize_command(commands[0]) in GENERIC_VERIFY_COMMANDS:
279
+ return [f"generic verify command is not swarm-ready: {commands[0]}"]
280
+ return []
281
+
282
+
283
+ def _description_issues(description: str) -> list[str]:
284
+ if not description.strip():
285
+ return ["plan.narratives.Description is required"]
286
+ if _sentence_count(description) < 2 or _word_count(description) < 20:
287
+ return ["plan.narratives.Description must contain at least two concrete sentences"]
288
+ return []
289
+
290
+
291
+ def _implementation_plan_issues(implementation_plan: str) -> list[str]:
292
+ if not implementation_plan.strip():
293
+ return ["plan.narratives.ImplementationPlan is required"]
294
+ issues: list[str] = []
295
+ if _step_count(implementation_plan) < 2 or _word_count(implementation_plan) < 20:
296
+ issues.append(
297
+ "plan.narratives.ImplementationPlan must contain at least two concrete steps"
298
+ )
299
+ lower = implementation_plan.lower()
300
+ if any(pattern in lower for pattern in PLACEHOLDER_ACCEPTANCE_PATTERNS):
301
+ issues.append("plan.narratives.ImplementationPlan must not be placeholder text")
302
+ if any(pattern in lower for pattern in GENERIC_IMPLEMENTATION_PATTERNS) or not (
303
+ any(term in lower for term in CODE_PATH_TERMS)
304
+ and any(term in lower for term in VERIFY_EVIDENCE_TERMS)
305
+ ):
306
+ issues.append(
307
+ "plan.narratives.ImplementationPlan must identify concrete code paths "
308
+ "and verification evidence"
309
+ )
310
+ return issues
311
+
312
+
313
+ def _looks_observable(lower: str) -> bool:
314
+ return any(term in lower for term in OBSERVABLE_TERMS)
315
+
316
+
317
+ def _sentence_count(value: str) -> int:
318
+ return len([part for part in re.split(r"[.!?]+(?:\s+|$)", value.strip()) if part.strip()])
319
+
320
+
321
+ def _step_count(value: str) -> int:
322
+ lines = [line.strip() for line in value.splitlines() if line.strip()]
323
+ bullet_lines = [
324
+ line
325
+ for line in lines
326
+ if re.match(r"^([-*]|\d+[.)])\s+", line)
327
+ ]
328
+ if len(bullet_lines) >= 2:
329
+ return len(bullet_lines)
330
+ return _sentence_count(value)
331
+
332
+
333
+ def _word_count(value: str) -> int:
334
+ return len(re.findall(r"\b\w+\b", value))
335
+
336
+
337
+ def _normalize(value: str) -> str:
338
+ return re.sub(r"[^a-z0-9]+", " ", value.lower()).strip()
339
+
340
+
341
+ def _normalize_command(value: str) -> str:
342
+ return re.sub(r"\s+", " ", value.strip().lower())
343
+
344
+
345
+ def _dedupe(values: list[str]) -> list[str]:
346
+ out: list[str] = []
347
+ seen: set[str] = set()
348
+ for value in values:
349
+ if value in seen:
350
+ continue
351
+ seen.add(value)
352
+ out.append(value)
353
+ return out
@@ -0,0 +1,299 @@
1
+ r"""Shared validation and slug-safe ID helpers for the vBRIEF migrator (#498).
2
+
3
+ Extracted from ``scripts/migrate_vbrief.py`` so the migrator stays under the
4
+ 1000-line hard cap documented in ``deft/coding/coding.md`` while still
5
+ hard-blocking on schema-invalid output (per #506 D8).
6
+
7
+ Public API
8
+ ----------
9
+ * ``slugify_id(raw, existing=None)`` -- single sanitiser used for BOTH the
10
+ filename slug component AND every in-JSON identifier
11
+ (``plan.items[*].id``, ``plan.id``, scope-registry ids) emitted by the
12
+ migrator. Conforms to the schema-locked ID regex
13
+ ``^[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)*$`` (shared conventions #506) by
14
+ restricting output to lowercase ASCII letters, digits, and hyphens only.
15
+ Also satisfies the stricter lifecycle-folder filename validator
16
+ (``^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*\.vbrief\.json$``) which
17
+ disallows underscores and dots -- hyphen-only output keeps a single
18
+ implementation that passes both surfaces.
19
+ * ``slug_fallback_id(item)`` -- resolve the logical identifier source for a
20
+ roadmap/scope ``item`` dict in the preference order used by both filename
21
+ construction and the PROJECT-DEFINITION scope registry, so both surfaces
22
+ compute the same slug input from the same item.
23
+ * ``validate_migration_output(vbrief_dir)`` -- thin wrapper around
24
+ ``vbrief_validate.validate_all`` scoped to the migrator's emitted-file set.
25
+ Returns ``(errors, warnings)`` with full per-file diagnostics.
26
+ * ``isolate_invalid_output(project_root, vbrief_dir)`` -- on validation
27
+ failure, move the emitted ``vbrief/`` tree to ``vbrief.invalid/`` (with a
28
+ numeric suffix on collision) so the operator can inspect the partial
29
+ output without it blocking subsequent migrator runs. Pre-migration
30
+ ``.premigrate.*`` backups (Agent C, #497) are left untouched.
31
+ * ``finalize_migration(project_root, vbrief_dir, actions)`` -- terminal
32
+ validate + isolate step the migrator plugs in at the end of its body.
33
+ * ``RECOVERY_HINT`` -- canonical CLI recovery hint printed on failure.
34
+
35
+ Story: #498 (migrate:vbrief self-validation + slug-safe IDs + golden tests).
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import hashlib
41
+ import re
42
+ import sys
43
+ from pathlib import Path
44
+
45
+ # Ensure sibling ``vbrief_validate`` is importable whether this module is
46
+ # imported from a test harness (which inserts ``scripts/`` onto sys.path) or
47
+ # from ``scripts/migrate_vbrief.py`` itself (which performs the same insert
48
+ # at the top of its module).
49
+ _SCRIPTS_DIR = Path(__file__).resolve().parent
50
+ if str(_SCRIPTS_DIR) not in sys.path:
51
+ sys.path.insert(0, str(_SCRIPTS_DIR))
52
+
53
+ import vbrief_validate # noqa: E402 (import after sys.path mutation)
54
+
55
+ __all__ = [
56
+ "RECOVERY_HINT",
57
+ "slugify_id",
58
+ "slug_fallback_id",
59
+ "validate_migration_output",
60
+ "isolate_invalid_output",
61
+ "finalize_migration",
62
+ "ID_MAX_LENGTH",
63
+ "HASH_SUFFIX_LENGTH",
64
+ ]
65
+
66
+ # Canonical recovery hint surfaced when the migrator hard-blocks on
67
+ # schema-invalid output (#506 D8). Agent C (#497) owns the ``--rollback`` flag
68
+ # implementation; this module only references the flag by name.
69
+ RECOVERY_HINT = "Restore with: task migrate:vbrief -- --rollback"
70
+
71
+ # Per #498: slug-safe IDs truncate to 80 characters. The optional 6-char hash
72
+ # suffix reserves ``1 + 6 = 7`` characters so the base slug before the suffix
73
+ # is 73 characters max -- keeps total length <= 80 for collision-disambiguated
74
+ # values.
75
+ ID_MAX_LENGTH = 80
76
+ HASH_SUFFIX_LENGTH = 6
77
+
78
+
79
+ def slugify_id(raw: str | None, existing: set[str] | None = None) -> str:
80
+ """Return a slug-safe id for filenames and in-JSON id fields (#498).
81
+
82
+ Rules (per #498 acceptance criteria and #506 shared conventions):
83
+
84
+ * lowercase ASCII letters, digits, and hyphens only
85
+ * runs of any non-allowed character collapse to a single hyphen
86
+ * leading/trailing hyphens are stripped
87
+ * truncate to ``ID_MAX_LENGTH`` (80) characters
88
+ * when ``existing`` is provided and the resulting slug collides,
89
+ append a stable 6-char hex suffix derived from the raw input so
90
+ repeated migrations produce the same disambiguated value; if that
91
+ still collides, perturb the hash deterministically until unique
92
+
93
+ Parameters
94
+ ----------
95
+ raw:
96
+ The raw input text to slugify. ``None`` and empty values are
97
+ normalised to ``"untitled"``.
98
+ existing:
99
+ Optional mutable set used as the "already-emitted" registry. When
100
+ provided, the returned slug is added to the set so subsequent calls
101
+ can detect collisions. Pass ``None`` for one-shot slug computation
102
+ (no collision tracking).
103
+
104
+ Returns
105
+ -------
106
+ str
107
+ A slug matching ``^[a-z0-9]+(-[a-z0-9]+)*$`` -- conforms to both the
108
+ schema ID regex and the lifecycle filename regex.
109
+ """
110
+ text = (raw or "").strip()
111
+ slug = re.sub(r"[^a-z0-9]+", "-", text.lower())
112
+ slug = re.sub(r"-+", "-", slug).strip("-")
113
+ if not slug:
114
+ slug = "untitled"
115
+ if len(slug) > ID_MAX_LENGTH:
116
+ slug = slug[:ID_MAX_LENGTH].rstrip("-") or slug[:ID_MAX_LENGTH]
117
+
118
+ if existing is None:
119
+ return slug
120
+
121
+ if slug not in existing:
122
+ existing.add(slug)
123
+ return slug
124
+
125
+ # Collision path -- append a stable 6-char hash suffix. Seed from the
126
+ # original raw input (falls back to the computed slug when raw is empty)
127
+ # so repeated migrations with the same content yield the same suffix.
128
+ digest_seed = text or slug
129
+ base_max = ID_MAX_LENGTH - 1 - HASH_SUFFIX_LENGTH # "-" + 6 hex chars
130
+ base = slug[:base_max].rstrip("-") or slug[:base_max] or "id"
131
+ h = hashlib.sha1(digest_seed.encode("utf-8")).hexdigest()[:HASH_SUFFIX_LENGTH]
132
+ candidate = f"{base}-{h}"
133
+
134
+ attempt = 0
135
+ while candidate in existing and attempt < 1000:
136
+ attempt += 1
137
+ h2 = hashlib.sha1(
138
+ f"{digest_seed}|{attempt}".encode()
139
+ ).hexdigest()[:HASH_SUFFIX_LENGTH]
140
+ candidate = f"{base}-{h2}"
141
+
142
+ existing.add(candidate)
143
+ return candidate
144
+
145
+
146
+ def slug_fallback_id(item: dict) -> str:
147
+ """Return the logical identifier source for a scope ``item`` dict.
148
+
149
+ Preference order mirrors the filename construction in Step 4 / 4b so
150
+ both surfaces (PROJECT-DEFINITION scope registry id AND scope vBRIEF
151
+ filename) resolve the same logical identifier from the same item:
152
+
153
+ 1. GitHub issue ``number``
154
+ 2. Explicit ``task_id``
155
+ 3. ``synthetic_id`` (assigned by the ROADMAP parser fallback)
156
+ 4. ``title`` (used when nothing else is available)
157
+
158
+ Returns the raw (un-slugified) string; callers are expected to pipe the
159
+ result through :func:`slugify_id`.
160
+ """
161
+ number = str(item.get("number", "") or "")
162
+ if number:
163
+ return number
164
+ task_id = str(item.get("task_id", "") or "")
165
+ if task_id:
166
+ return task_id
167
+ synthetic = str(item.get("synthetic_id", "") or "")
168
+ if synthetic:
169
+ return synthetic
170
+ return str(item.get("title", "") or "untitled")
171
+
172
+
173
+ def validate_migration_output(
174
+ vbrief_dir: Path,
175
+ ) -> tuple[list[str], list[str]]:
176
+ """Validate every file emitted by the migrator under ``vbrief_dir``.
177
+
178
+ Delegates the heavy lifting to :func:`vbrief_validate.validate_all`,
179
+ which already implements the full D2/D3/D4/D7/D11 rule set plus schema
180
+ validation for scope vBRIEFs and PROJECT-DEFINITION.vbrief.json.
181
+
182
+ Returns
183
+ -------
184
+ (errors, warnings):
185
+ ``errors`` is a list of human-readable diagnostic strings; when
186
+ non-empty the migrator MUST exit non-zero per #506 D8 (hard-block on
187
+ schema-invalid output). ``warnings`` are surfaced but do NOT block
188
+ success -- they follow the same semantics as
189
+ ``scripts/vbrief_validate.py`` (e.g. D11 origin provenance warnings
190
+ for pending/active without a github-issue reference).
191
+ """
192
+ if not vbrief_dir.is_dir():
193
+ # Nothing emitted -- fail loudly so the caller reports something
194
+ # useful rather than silently accepting an empty migration.
195
+ return (
196
+ [f"{vbrief_dir}: expected vbrief directory does not exist"],
197
+ [],
198
+ )
199
+
200
+ errors, warnings, _scope_count = vbrief_validate.validate_all(vbrief_dir)
201
+ return list(errors), list(warnings)
202
+
203
+
204
+ def isolate_invalid_output(
205
+ project_root: Path, vbrief_dir: Path
206
+ ) -> Path | None:
207
+ """Move the emitted ``vbrief/`` tree to ``vbrief.invalid/`` on failure.
208
+
209
+ Per #506 D8: schema-invalid migration output must be isolated from
210
+ ``vbrief/`` so downstream tasks (``task check`` / ``task scope:*`` /
211
+ renders) don't consume broken state. Agent C's ``.premigrate.*`` backups
212
+ remain untouched at the project root so ``task migrate:vbrief --
213
+ --rollback`` can restore the pre-migration state.
214
+
215
+ Returns the destination path, or ``None`` when ``vbrief_dir`` does not
216
+ exist (nothing to move).
217
+
218
+ Collision handling: if ``vbrief.invalid/`` already exists (e.g. from a
219
+ prior failed migration), increment a numeric suffix -- ``vbrief.invalid.2``,
220
+ ``vbrief.invalid.3``, etc. -- so operators retain the history of failed
221
+ attempts instead of overwriting.
222
+ """
223
+ if not vbrief_dir.exists():
224
+ return None
225
+
226
+ target = project_root / "vbrief.invalid"
227
+ idx = 1
228
+ while target.exists():
229
+ idx += 1
230
+ target = project_root / f"vbrief.invalid.{idx}"
231
+
232
+ vbrief_dir.rename(target)
233
+ return target
234
+
235
+
236
+ def finalize_migration(
237
+ project_root: Path,
238
+ vbrief_dir: Path,
239
+ actions: list[str],
240
+ ) -> tuple[bool, list[str]]:
241
+ """Run validation + isolation as the migrator's terminal gate (#498).
242
+
243
+ Designed as a drop-in terminal step for ``scripts/migrate_vbrief.py::
244
+ migrate`` so the migrator body stays under the 1000-line hard cap.
245
+ Pipes diagnostics to stderr and returns the final ``(ok, actions)``
246
+ tuple that the migrator should propagate to its CLI entry point:
247
+
248
+ * On success: returns ``(True, actions)`` untouched -- caller prints its
249
+ normal success message ("Migration completed successfully.").
250
+ * On failure: prints per-file diagnostics to stderr, moves ``vbrief/``
251
+ to ``vbrief.invalid/`` (isolation), appends failure diagnostics and a
252
+ ``Restore with: task migrate:vbrief -- --rollback`` recovery hint to a
253
+ copy of ``actions``, and returns ``(False, failure_actions)``.
254
+
255
+ The ``actions`` list passed in is NOT mutated so callers can reuse it
256
+ for downstream logging independent of migration outcome.
257
+ """
258
+ errors, warnings = validate_migration_output(vbrief_dir)
259
+ if not errors:
260
+ # Surface non-blocking validator warnings (e.g. D11 origin-provenance
261
+ # warnings for pending/active scopes without a github-issue reference)
262
+ # so operators see them even on the success path. Matches the
263
+ # ``scripts/vbrief_validate.py`` CLI behaviour where warnings print
264
+ # but do not change exit code.
265
+ for w in warnings:
266
+ print(f"WARNING: {w}", file=sys.stderr)
267
+ return True, actions
268
+
269
+ print(
270
+ f"ERROR: Migration produced invalid output ({len(errors)} "
271
+ f"file-level error(s)):",
272
+ file=sys.stderr,
273
+ )
274
+ for err in errors:
275
+ print(f" {err}", file=sys.stderr)
276
+
277
+ invalid_dir = isolate_invalid_output(project_root, vbrief_dir)
278
+
279
+ failure_actions: list[str] = list(actions)
280
+ failure_actions.append(
281
+ f"FAIL migration produced {len(errors)} schema validation error(s)"
282
+ )
283
+ for err in errors:
284
+ failure_actions.append(f" {err}")
285
+ if invalid_dir is not None:
286
+ try:
287
+ rel_invalid = invalid_dir.relative_to(project_root)
288
+ except ValueError:
289
+ rel_invalid = invalid_dir
290
+ failure_actions.append(
291
+ f"MOVE vbrief/ -> {rel_invalid}/ (isolated from vbrief/)"
292
+ )
293
+ print(
294
+ f"Isolated partial output to: {rel_invalid}",
295
+ file=sys.stderr,
296
+ )
297
+ failure_actions.append(RECOVERY_HINT)
298
+ print(RECOVERY_HINT, file=sys.stderr)
299
+ return False, failure_actions