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