@deftai/directive-content 0.59.0 → 0.61.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 (190) hide show
  1. package/.githooks/pre-commit +10 -128
  2. package/.githooks/pre-push +8 -108
  3. package/Taskfile.yml +48 -58
  4. package/UPGRADING.md +19 -3
  5. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  6. package/docs/directive-lifecycle.md +73 -0
  7. package/docs/getting-started.md +5 -1
  8. package/package.json +3 -3
  9. package/packs/skills/skills-pack-0.1.json +1 -1
  10. package/packs/strategies/strategies-pack-0.1.json +19 -19
  11. package/scm/github.md +37 -6
  12. package/skills/deft-directive-setup/SKILL.md +24 -15
  13. package/strategies/speckit.md +14 -14
  14. package/strategies/v0-20-contract.md +12 -1
  15. package/tasks/change.yml +16 -31
  16. package/tasks/ci.yml +8 -0
  17. package/tasks/commit.yml +12 -19
  18. package/tasks/core.yml +10 -0
  19. package/tasks/engine.yml +42 -0
  20. package/tasks/framework.yml +3 -0
  21. package/tasks/install.yml +20 -19
  22. package/tasks/migrate.yml +26 -15
  23. package/tasks/project.yml +26 -0
  24. package/tasks/toolchain.yml +15 -5
  25. package/tasks/vbrief.yml +4 -3
  26. package/tasks/verify.yml +12 -14
  27. package/templates/agents-entry.md +1 -1
  28. package/scripts/_agents_md.py +0 -494
  29. package/scripts/_cache_fetch.py +0 -635
  30. package/scripts/_cache_quota.py +0 -529
  31. package/scripts/_cache_refresh.py +0 -163
  32. package/scripts/_cache_validate.py +0 -209
  33. package/scripts/_content_root.py +0 -42
  34. package/scripts/_doctor_state.py +0 -277
  35. package/scripts/_event_detect.py +0 -305
  36. package/scripts/_events.py +0 -514
  37. package/scripts/_lifecycle_hygiene.py +0 -568
  38. package/scripts/_pathspec.py +0 -91
  39. package/scripts/_policy_show_cli.py +0 -266
  40. package/scripts/_precutover.py +0 -92
  41. package/scripts/_project_context.py +0 -224
  42. package/scripts/_project_definition_io.py +0 -164
  43. package/scripts/_relocate_snapshot.py +0 -209
  44. package/scripts/_relocate_states.py +0 -343
  45. package/scripts/_resolve_preflight_path.py +0 -152
  46. package/scripts/_safe_subprocess.py +0 -167
  47. package/scripts/_session_start_hook.py +0 -205
  48. package/scripts/_sor_gate_diff.py +0 -365
  49. package/scripts/_stdio_utf8.py +0 -59
  50. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  51. package/scripts/_triage_classify_cli.py +0 -122
  52. package/scripts/_triage_queue_cli.py +0 -625
  53. package/scripts/_triage_scope_cli.py +0 -343
  54. package/scripts/_triage_scope_drift_cli.py +0 -121
  55. package/scripts/_triage_scope_ignores.py +0 -286
  56. package/scripts/_triage_scope_milestone.py +0 -432
  57. package/scripts/_triage_scope_mutations.py +0 -337
  58. package/scripts/_triage_scope_renderers.py +0 -207
  59. package/scripts/_triage_smoketest_stages.py +0 -674
  60. package/scripts/_triage_subscribe_cli.py +0 -140
  61. package/scripts/_triage_welcome_cli.py +0 -421
  62. package/scripts/_vbrief_build.py +0 -239
  63. package/scripts/_vbrief_fidelity.py +0 -479
  64. package/scripts/_vbrief_legacy.py +0 -589
  65. package/scripts/_vbrief_reconciliation.py +0 -883
  66. package/scripts/_vbrief_routing.py +0 -277
  67. package/scripts/_vbrief_safety.py +0 -778
  68. package/scripts/_vbrief_sources.py +0 -312
  69. package/scripts/_vbrief_speckit.py +0 -262
  70. package/scripts/_vbrief_story_quality.py +0 -353
  71. package/scripts/_vbrief_validation.py +0 -299
  72. package/scripts/build_dist.py +0 -412
  73. package/scripts/cache.py +0 -1078
  74. package/scripts/cache_scanner.py +0 -745
  75. package/scripts/candidates_log.py +0 -432
  76. package/scripts/capacity_backfill.py +0 -680
  77. package/scripts/capacity_show.py +0 -653
  78. package/scripts/ci_local.py +0 -689
  79. package/scripts/code_structure_validate.py +0 -765
  80. package/scripts/codebase_default_extractor.py +0 -495
  81. package/scripts/codebase_map.py +0 -304
  82. package/scripts/codebase_map_fresh.py +0 -104
  83. package/scripts/codebase_projection_registry.py +0 -94
  84. package/scripts/codebase_provider.py +0 -582
  85. package/scripts/doctor.py +0 -2552
  86. package/scripts/framework_commands.py +0 -505
  87. package/scripts/gh_rest.py +0 -882
  88. package/scripts/github_auth_modes.py +0 -437
  89. package/scripts/github_body.py +0 -292
  90. package/scripts/ip_risk.py +0 -531
  91. package/scripts/issue_emit.py +0 -670
  92. package/scripts/issue_ingest.py +0 -1064
  93. package/scripts/migrate_preflight.py +0 -418
  94. package/scripts/migrate_vbrief.py +0 -2677
  95. package/scripts/monitor_pr.py +0 -401
  96. package/scripts/pack_migrate_lessons.py +0 -336
  97. package/scripts/pack_migrate_patterns.py +0 -254
  98. package/scripts/pack_migrate_rules.py +0 -350
  99. package/scripts/pack_migrate_skills.py +0 -423
  100. package/scripts/pack_migrate_strategies.py +0 -311
  101. package/scripts/pack_migrate_swarm_spec.py +0 -250
  102. package/scripts/pack_render.py +0 -434
  103. package/scripts/packs_slice.py +0 -712
  104. package/scripts/platform_capabilities.py +0 -336
  105. package/scripts/policy.py +0 -2826
  106. package/scripts/policy_set.py +0 -324
  107. package/scripts/pr_check_closing_keywords.py +0 -524
  108. package/scripts/pr_check_protected_issues.py +0 -267
  109. package/scripts/pr_merge_readiness.py +0 -1004
  110. package/scripts/pr_wait_mergeable.py +0 -669
  111. package/scripts/prd_render.py +0 -159
  112. package/scripts/preflight_architecture_sor.py +0 -974
  113. package/scripts/preflight_branch.py +0 -289
  114. package/scripts/preflight_cache.py +0 -974
  115. package/scripts/preflight_gh.py +0 -721
  116. package/scripts/preflight_implementation.py +0 -272
  117. package/scripts/preflight_story_start.py +0 -838
  118. package/scripts/preflight_wip_cap.py +0 -149
  119. package/scripts/probe_session.py +0 -545
  120. package/scripts/project_render.py +0 -293
  121. package/scripts/quarantine_ext.py +0 -237
  122. package/scripts/reconcile_issues.py +0 -1442
  123. package/scripts/refresh-path.ps1 +0 -107
  124. package/scripts/release.py +0 -2030
  125. package/scripts/release_e2e.py +0 -1011
  126. package/scripts/release_publish.py +0 -486
  127. package/scripts/release_rollback.py +0 -980
  128. package/scripts/relocate.py +0 -1034
  129. package/scripts/resolve_changelog_unreleased.py +0 -667
  130. package/scripts/resolve_version.py +0 -490
  131. package/scripts/resume_conditions.py +0 -706
  132. package/scripts/ritual_sentinel.py +0 -609
  133. package/scripts/roadmap_render.py +0 -635
  134. package/scripts/rule_ownership_lint.py +0 -325
  135. package/scripts/scm.py +0 -591
  136. package/scripts/scope_audit_log.py +0 -387
  137. package/scripts/scope_decompose.py +0 -654
  138. package/scripts/scope_demote.py +0 -509
  139. package/scripts/scope_lifecycle.py +0 -1126
  140. package/scripts/scope_undo.py +0 -772
  141. package/scripts/session_start.py +0 -406
  142. package/scripts/setup_ghx.py +0 -339
  143. package/scripts/setup_windows.ps1 +0 -220
  144. package/scripts/slice_audit.py +0 -585
  145. package/scripts/slice_record.py +0 -530
  146. package/scripts/slice_record_existing.py +0 -692
  147. package/scripts/slug_normalize.py +0 -178
  148. package/scripts/spec_render.py +0 -477
  149. package/scripts/spec_validate.py +0 -238
  150. package/scripts/subagent_monitor.py +0 -658
  151. package/scripts/swarm_complete_cohort.py +0 -644
  152. package/scripts/swarm_launch.py +0 -1206
  153. package/scripts/swarm_readiness.py +0 -554
  154. package/scripts/swarm_verify_review_clean.py +0 -438
  155. package/scripts/swarm_worktrees.py +0 -497
  156. package/scripts/toolchain-check.py +0 -52
  157. package/scripts/triage_actions.py +0 -871
  158. package/scripts/triage_bootstrap.py +0 -1153
  159. package/scripts/triage_bulk.py +0 -630
  160. package/scripts/triage_classify.py +0 -932
  161. package/scripts/triage_help.py +0 -1685
  162. package/scripts/triage_queue.py +0 -1944
  163. package/scripts/triage_reconcile.py +0 -581
  164. package/scripts/triage_refresh.py +0 -643
  165. package/scripts/triage_scope.py +0 -999
  166. package/scripts/triage_scope_drift.py +0 -575
  167. package/scripts/triage_smoketest.py +0 -396
  168. package/scripts/triage_subscribe.py +0 -399
  169. package/scripts/triage_summary.py +0 -1011
  170. package/scripts/triage_welcome.py +0 -1178
  171. package/scripts/ts_check_lane.py +0 -86
  172. package/scripts/validate-links.py +0 -64
  173. package/scripts/validate_strategy_output.py +0 -212
  174. package/scripts/vbrief_activate.py +0 -228
  175. package/scripts/vbrief_migrate_conformance.py +0 -368
  176. package/scripts/vbrief_reconcile_graph.py +0 -306
  177. package/scripts/vbrief_reconcile_labels.py +0 -460
  178. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  179. package/scripts/vbrief_validate.py +0 -1144
  180. package/scripts/verify-stubs.py +0 -61
  181. package/scripts/verify_capacity.py +0 -160
  182. package/scripts/verify_encoding.py +0 -699
  183. package/scripts/verify_hooks_installed.py +0 -206
  184. package/scripts/verify_investigation.py +0 -360
  185. package/scripts/verify_judgment_gates.py +0 -827
  186. package/scripts/verify_no_task_runtime.py +0 -171
  187. package/scripts/verify_scm_boundary.py +0 -509
  188. package/scripts/verify_session_ritual.py +0 -389
  189. package/scripts/verify_tools.py +0 -426
  190. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,353 +0,0 @@
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
@@ -1,299 +0,0 @@
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