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