@deftai/directive-content 0.55.2 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.githooks/pre-commit +143 -0
- package/.githooks/pre-push +121 -0
- package/QUICK-START.md +2 -2
- package/Taskfile.yml +934 -0
- package/UPGRADING.md +47 -1
- package/events/README.md +3 -3
- package/package.json +5 -4
- package/scripts/_agents_md.py +494 -0
- package/scripts/_cache_fetch.py +635 -0
- package/scripts/_cache_quota.py +529 -0
- package/scripts/_cache_refresh.py +163 -0
- package/scripts/_cache_validate.py +209 -0
- package/scripts/_content_root.py +42 -0
- package/scripts/_doctor_state.py +277 -0
- package/scripts/_event_detect.py +305 -0
- package/scripts/_events.py +514 -0
- package/scripts/_lifecycle_hygiene.py +568 -0
- package/scripts/_pathspec.py +91 -0
- package/scripts/_policy_show_cli.py +266 -0
- package/scripts/_precutover.py +92 -0
- package/scripts/_project_context.py +224 -0
- package/scripts/_project_definition_io.py +164 -0
- package/scripts/_relocate_snapshot.py +209 -0
- package/scripts/_relocate_states.py +343 -0
- package/scripts/_resolve_preflight_path.py +152 -0
- package/scripts/_safe_subprocess.py +167 -0
- package/scripts/_session_start_hook.py +205 -0
- package/scripts/_sor_gate_diff.py +365 -0
- package/scripts/_stdio_utf8.py +59 -0
- package/scripts/_triage_bootstrap_gitignore.py +904 -0
- package/scripts/_triage_classify_cli.py +122 -0
- package/scripts/_triage_queue_cli.py +625 -0
- package/scripts/_triage_scope_cli.py +343 -0
- package/scripts/_triage_scope_drift_cli.py +121 -0
- package/scripts/_triage_scope_ignores.py +286 -0
- package/scripts/_triage_scope_milestone.py +432 -0
- package/scripts/_triage_scope_mutations.py +337 -0
- package/scripts/_triage_scope_renderers.py +207 -0
- package/scripts/_triage_smoketest_stages.py +674 -0
- package/scripts/_triage_subscribe_cli.py +140 -0
- package/scripts/_triage_welcome_cli.py +421 -0
- package/scripts/_vbrief_build.py +239 -0
- package/scripts/_vbrief_fidelity.py +479 -0
- package/scripts/_vbrief_legacy.py +589 -0
- package/scripts/_vbrief_reconciliation.py +883 -0
- package/scripts/_vbrief_routing.py +277 -0
- package/scripts/_vbrief_safety.py +778 -0
- package/scripts/_vbrief_sources.py +312 -0
- package/scripts/_vbrief_speckit.py +262 -0
- package/scripts/_vbrief_story_quality.py +353 -0
- package/scripts/_vbrief_validation.py +299 -0
- package/scripts/build_dist.py +412 -0
- package/scripts/cache.py +1078 -0
- package/scripts/cache_scanner.py +745 -0
- package/scripts/candidates_log.py +432 -0
- package/scripts/capacity_backfill.py +680 -0
- package/scripts/capacity_show.py +653 -0
- package/scripts/ci_local.py +689 -0
- package/scripts/code_structure_validate.py +765 -0
- package/scripts/codebase_default_extractor.py +495 -0
- package/scripts/codebase_map.py +304 -0
- package/scripts/codebase_map_fresh.py +104 -0
- package/scripts/codebase_projection_registry.py +94 -0
- package/scripts/codebase_provider.py +582 -0
- package/scripts/doctor.py +2257 -0
- package/scripts/framework_commands.py +505 -0
- package/scripts/gh_rest.py +882 -0
- package/scripts/github_auth_modes.py +437 -0
- package/scripts/github_body.py +292 -0
- package/scripts/ip_risk.py +531 -0
- package/scripts/issue_emit.py +670 -0
- package/scripts/issue_ingest.py +1064 -0
- package/scripts/migrate_preflight.py +418 -0
- package/scripts/migrate_vbrief.py +2677 -0
- package/scripts/monitor_pr.py +401 -0
- package/scripts/pack_migrate_lessons.py +336 -0
- package/scripts/pack_migrate_patterns.py +254 -0
- package/scripts/pack_migrate_rules.py +350 -0
- package/scripts/pack_migrate_skills.py +423 -0
- package/scripts/pack_migrate_strategies.py +311 -0
- package/scripts/pack_migrate_swarm_spec.py +250 -0
- package/scripts/pack_render.py +434 -0
- package/scripts/packs_slice.py +712 -0
- package/scripts/platform_capabilities.py +336 -0
- package/scripts/policy.py +2826 -0
- package/scripts/policy_set.py +324 -0
- package/scripts/pr_check_closing_keywords.py +524 -0
- package/scripts/pr_check_protected_issues.py +267 -0
- package/scripts/pr_merge_readiness.py +1004 -0
- package/scripts/pr_wait_mergeable.py +669 -0
- package/scripts/prd_render.py +159 -0
- package/scripts/preflight_architecture_sor.py +974 -0
- package/scripts/preflight_branch.py +289 -0
- package/scripts/preflight_cache.py +974 -0
- package/scripts/preflight_gh.py +721 -0
- package/scripts/preflight_implementation.py +272 -0
- package/scripts/preflight_story_start.py +838 -0
- package/scripts/preflight_wip_cap.py +149 -0
- package/scripts/probe_session.py +545 -0
- package/scripts/project_render.py +293 -0
- package/scripts/quarantine_ext.py +237 -0
- package/scripts/reconcile_issues.py +1442 -0
- package/scripts/refresh-path.ps1 +107 -0
- package/scripts/release.py +2030 -0
- package/scripts/release_e2e.py +1011 -0
- package/scripts/release_publish.py +486 -0
- package/scripts/release_rollback.py +980 -0
- package/scripts/relocate.py +1034 -0
- package/scripts/resolve_changelog_unreleased.py +667 -0
- package/scripts/resolve_version.py +490 -0
- package/scripts/resume_conditions.py +706 -0
- package/scripts/ritual_sentinel.py +609 -0
- package/scripts/roadmap_render.py +635 -0
- package/scripts/rule_ownership_lint.py +325 -0
- package/scripts/scm.py +591 -0
- package/scripts/scope_audit_log.py +387 -0
- package/scripts/scope_decompose.py +654 -0
- package/scripts/scope_demote.py +509 -0
- package/scripts/scope_lifecycle.py +1126 -0
- package/scripts/scope_undo.py +772 -0
- package/scripts/session_start.py +406 -0
- package/scripts/setup_ghx.py +339 -0
- package/scripts/setup_windows.ps1 +220 -0
- package/scripts/slice_audit.py +585 -0
- package/scripts/slice_record.py +530 -0
- package/scripts/slice_record_existing.py +692 -0
- package/scripts/slug_normalize.py +178 -0
- package/scripts/spec_render.py +477 -0
- package/scripts/spec_validate.py +238 -0
- package/scripts/subagent_monitor.py +658 -0
- package/scripts/swarm_complete_cohort.py +644 -0
- package/scripts/swarm_launch.py +1206 -0
- package/scripts/swarm_readiness.py +554 -0
- package/scripts/swarm_verify_review_clean.py +438 -0
- package/scripts/swarm_worktrees.py +497 -0
- package/scripts/toolchain-check.py +52 -0
- package/scripts/triage_actions.py +871 -0
- package/scripts/triage_bootstrap.py +1153 -0
- package/scripts/triage_bulk.py +630 -0
- package/scripts/triage_classify.py +932 -0
- package/scripts/triage_help.py +1685 -0
- package/scripts/triage_queue.py +1944 -0
- package/scripts/triage_reconcile.py +581 -0
- package/scripts/triage_refresh.py +643 -0
- package/scripts/triage_scope.py +999 -0
- package/scripts/triage_scope_drift.py +575 -0
- package/scripts/triage_smoketest.py +396 -0
- package/scripts/triage_subscribe.py +399 -0
- package/scripts/triage_summary.py +1011 -0
- package/scripts/triage_welcome.py +1178 -0
- package/scripts/ts_check_lane.py +86 -0
- package/scripts/validate-links.py +64 -0
- package/scripts/validate_strategy_output.py +212 -0
- package/scripts/vbrief_activate.py +228 -0
- package/scripts/vbrief_migrate_conformance.py +368 -0
- package/scripts/vbrief_reconcile_graph.py +306 -0
- package/scripts/vbrief_reconcile_labels.py +460 -0
- package/scripts/vbrief_reconcile_umbrellas.py +741 -0
- package/scripts/vbrief_validate.py +1195 -0
- package/scripts/verify-stubs.py +61 -0
- package/scripts/verify_capacity.py +160 -0
- package/scripts/verify_encoding.py +699 -0
- package/scripts/verify_hooks_installed.py +206 -0
- package/scripts/verify_investigation.py +360 -0
- package/scripts/verify_judgment_gates.py +827 -0
- package/scripts/verify_no_task_runtime.py +171 -0
- package/scripts/verify_scm_boundary.py +509 -0
- package/scripts/verify_session_ritual.py +389 -0
- package/scripts/verify_tools.py +426 -0
- package/scripts/verify_vbrief_conformance.py +478 -0
- package/tasks/architecture.yml +13 -0
- package/tasks/cache.yml +69 -0
- package/tasks/capacity.yml +38 -0
- package/tasks/change.yml +46 -0
- package/tasks/changelog.yml +24 -0
- package/tasks/ci.yml +49 -0
- package/tasks/codebase.yml +47 -0
- package/tasks/commit.yml +30 -0
- package/tasks/core.yml +126 -0
- package/tasks/deployments.yml +54 -0
- package/tasks/framework.yml +74 -0
- package/tasks/install.yml +60 -0
- package/tasks/issue.yml +50 -0
- package/tasks/migrate.yml +73 -0
- package/tasks/packs.yml +92 -0
- package/tasks/policy.yml +75 -0
- package/tasks/pr.yml +89 -0
- package/tasks/prd.yml +39 -0
- package/tasks/project.yml +27 -0
- package/tasks/reconcile.yml +32 -0
- package/tasks/relocate.yml +56 -0
- package/tasks/roadmap.yml +28 -0
- package/tasks/scm.yml +126 -0
- package/tasks/scope-undo.yml +36 -0
- package/tasks/scope.yml +141 -0
- package/tasks/session.yml +19 -0
- package/tasks/setup.yml +37 -0
- package/tasks/slice.yml +69 -0
- package/tasks/spec.yml +41 -0
- package/tasks/swarm.yml +85 -0
- package/tasks/toolchain.yml +13 -0
- package/tasks/triage-actions.yml +94 -0
- package/tasks/triage-bootstrap.yml +43 -0
- package/tasks/triage-bulk.yml +75 -0
- package/tasks/triage-classify.yml +30 -0
- package/tasks/triage-queue.yml +50 -0
- package/tasks/triage-reconcile.yml +29 -0
- package/tasks/triage-scope-drift.yml +29 -0
- package/tasks/triage-scope.yml +31 -0
- package/tasks/triage-smoketest.yml +33 -0
- package/tasks/triage-subscribe.yml +36 -0
- package/tasks/triage-summary.yml +29 -0
- package/tasks/triage-welcome.yml +32 -0
- package/tasks/ts.yml +328 -0
- package/tasks/vbrief.yml +206 -0
- package/tasks/verify.yml +292 -0
- package/templates/agents-entry.md +1 -1
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Shared story-quality checks for decomposition and swarm readiness."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
BROAD_FILE_SCOPE_ROOTS = {"backend", "frontend", "docs", "vbrief"}
|
|
9
|
+
CODE_PATH_TERMS = (
|
|
10
|
+
"api",
|
|
11
|
+
"cli",
|
|
12
|
+
"component",
|
|
13
|
+
"config",
|
|
14
|
+
"database",
|
|
15
|
+
"endpoint",
|
|
16
|
+
"file",
|
|
17
|
+
"handler",
|
|
18
|
+
"model",
|
|
19
|
+
"module",
|
|
20
|
+
"repository",
|
|
21
|
+
"route",
|
|
22
|
+
"schema",
|
|
23
|
+
"script",
|
|
24
|
+
"service",
|
|
25
|
+
"source",
|
|
26
|
+
"src/",
|
|
27
|
+
)
|
|
28
|
+
VERIFY_EVIDENCE_TERMS = (
|
|
29
|
+
"assert",
|
|
30
|
+
"evidence",
|
|
31
|
+
"fixture",
|
|
32
|
+
"report",
|
|
33
|
+
"spec",
|
|
34
|
+
"test",
|
|
35
|
+
"tests/",
|
|
36
|
+
"verify",
|
|
37
|
+
)
|
|
38
|
+
GENERIC_VERIFY_COMMANDS = {
|
|
39
|
+
"cargo test",
|
|
40
|
+
"go test ./...",
|
|
41
|
+
"npm run test",
|
|
42
|
+
"npm test",
|
|
43
|
+
"pytest",
|
|
44
|
+
"task check",
|
|
45
|
+
}
|
|
46
|
+
PLACEHOLDER_ACCEPTANCE_PATTERNS = (
|
|
47
|
+
"acceptance criteria for",
|
|
48
|
+
"copy from parent",
|
|
49
|
+
"copy from specification",
|
|
50
|
+
"placeholder",
|
|
51
|
+
"refine from parent",
|
|
52
|
+
"tbd",
|
|
53
|
+
"to be defined",
|
|
54
|
+
"to refine",
|
|
55
|
+
"to refine from parent scope",
|
|
56
|
+
"todo",
|
|
57
|
+
)
|
|
58
|
+
DOCS_ONLY_ACCEPTANCE_PATTERNS = (
|
|
59
|
+
"docs updated",
|
|
60
|
+
"documentation updated",
|
|
61
|
+
"readme updated",
|
|
62
|
+
"update docs",
|
|
63
|
+
"update documentation",
|
|
64
|
+
"update readme",
|
|
65
|
+
)
|
|
66
|
+
GENERIC_IMPLEMENTATION_PATTERNS = (
|
|
67
|
+
"add tests so it works",
|
|
68
|
+
"change the code",
|
|
69
|
+
"implement the feature",
|
|
70
|
+
"make it work",
|
|
71
|
+
"update the code",
|
|
72
|
+
"works as expected",
|
|
73
|
+
)
|
|
74
|
+
VAGUE_ACCEPTANCE_PATTERNS = (
|
|
75
|
+
"displays a message",
|
|
76
|
+
"handles errors",
|
|
77
|
+
"is implemented",
|
|
78
|
+
"is updated",
|
|
79
|
+
"passes tests",
|
|
80
|
+
"shows a message",
|
|
81
|
+
"the system displays a message",
|
|
82
|
+
"updates the ui",
|
|
83
|
+
"works as expected",
|
|
84
|
+
)
|
|
85
|
+
OBSERVABLE_TERMS = (
|
|
86
|
+
"blocks",
|
|
87
|
+
"creates",
|
|
88
|
+
"deletes",
|
|
89
|
+
"displays",
|
|
90
|
+
"emits",
|
|
91
|
+
"fails",
|
|
92
|
+
"persists",
|
|
93
|
+
"records",
|
|
94
|
+
"redirects",
|
|
95
|
+
"rejects",
|
|
96
|
+
"renders",
|
|
97
|
+
"returns",
|
|
98
|
+
"saves",
|
|
99
|
+
"shows",
|
|
100
|
+
"stores",
|
|
101
|
+
"updates",
|
|
102
|
+
"validates",
|
|
103
|
+
"when ",
|
|
104
|
+
"given ",
|
|
105
|
+
"then ",
|
|
106
|
+
)
|
|
107
|
+
USER_STORY_RE = re.compile(
|
|
108
|
+
r"^\s*As\s+a[n]?\s+[^,]+,\s*I\s+want\s+.+,\s*so\s+that\s+.+\.\s*$",
|
|
109
|
+
re.IGNORECASE | re.DOTALL,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def as_str_list(value: Any) -> list[str]:
|
|
114
|
+
if value is None:
|
|
115
|
+
return []
|
|
116
|
+
if isinstance(value, str):
|
|
117
|
+
return [value.strip()] if value.strip() else []
|
|
118
|
+
if isinstance(value, list):
|
|
119
|
+
return [str(item).strip() for item in value if str(item).strip()]
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def acceptance_texts_from_items(items: Any) -> list[str]:
|
|
124
|
+
texts: list[str] = []
|
|
125
|
+
if not isinstance(items, list):
|
|
126
|
+
return texts
|
|
127
|
+
for item in items:
|
|
128
|
+
if not isinstance(item, dict):
|
|
129
|
+
continue
|
|
130
|
+
narrative = item.get("narrative")
|
|
131
|
+
if isinstance(narrative, dict):
|
|
132
|
+
acceptance = narrative.get("Acceptance")
|
|
133
|
+
if isinstance(acceptance, str) and acceptance.strip():
|
|
134
|
+
texts.append(acceptance.strip())
|
|
135
|
+
for child_key in ("items", "subItems"):
|
|
136
|
+
texts.extend(acceptance_texts_from_items(item.get(child_key)))
|
|
137
|
+
return texts
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def item_has_acceptance(item: dict[str, Any]) -> bool:
|
|
141
|
+
narrative = item.get("narrative")
|
|
142
|
+
if isinstance(narrative, dict):
|
|
143
|
+
value = narrative.get("Acceptance")
|
|
144
|
+
if isinstance(value, str) and value.strip():
|
|
145
|
+
return True
|
|
146
|
+
for child_key in ("items", "subItems"):
|
|
147
|
+
children = item.get(child_key)
|
|
148
|
+
if isinstance(children, list):
|
|
149
|
+
for child in children:
|
|
150
|
+
if isinstance(child, dict) and item_has_acceptance(child):
|
|
151
|
+
return True
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def items_have_acceptance(items: Any) -> bool:
|
|
156
|
+
if not isinstance(items, list):
|
|
157
|
+
return False
|
|
158
|
+
return any(isinstance(item, dict) and item_has_acceptance(item) for item in items)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def item_has_traces(item: dict[str, Any]) -> bool:
|
|
162
|
+
narrative = item.get("narrative")
|
|
163
|
+
if isinstance(narrative, dict):
|
|
164
|
+
value = narrative.get("Traces")
|
|
165
|
+
if isinstance(value, str) and value.strip():
|
|
166
|
+
return True
|
|
167
|
+
for child_key in ("items", "subItems"):
|
|
168
|
+
children = item.get(child_key)
|
|
169
|
+
if isinstance(children, list):
|
|
170
|
+
for child in children:
|
|
171
|
+
if isinstance(child, dict) and item_has_traces(child):
|
|
172
|
+
return True
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def missing_required_swarm_fields(swarm: dict[str, Any]) -> list[str]:
|
|
177
|
+
missing: list[str] = []
|
|
178
|
+
for key in ("file_scope", "verify_commands", "expected_outputs"):
|
|
179
|
+
if not as_str_list(swarm.get(key)):
|
|
180
|
+
missing.append(f"plan.metadata.swarm.{key}")
|
|
181
|
+
if "depends_on" not in swarm:
|
|
182
|
+
missing.append("plan.metadata.swarm.depends_on")
|
|
183
|
+
for key in ("conflict_group", "size", "file_scope_confidence", "model_tier"):
|
|
184
|
+
value = swarm.get(key)
|
|
185
|
+
if not isinstance(value, str) or not value.strip():
|
|
186
|
+
missing.append(f"plan.metadata.swarm.{key}")
|
|
187
|
+
return missing
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def deprecated_subitems_issues(items: Any, prefix: str = "plan.items") -> list[str]:
|
|
191
|
+
issues: list[str] = []
|
|
192
|
+
|
|
193
|
+
def visit(children: Any, path: str) -> None:
|
|
194
|
+
if not isinstance(children, list):
|
|
195
|
+
return
|
|
196
|
+
for index, item in enumerate(children):
|
|
197
|
+
if not isinstance(item, dict):
|
|
198
|
+
continue
|
|
199
|
+
item_path = f"{path}[{index}]"
|
|
200
|
+
if "subItems" in item:
|
|
201
|
+
issues.append(f"{item_path}.subItems is deprecated; use items")
|
|
202
|
+
visit(item.get("items"), f"{item_path}.items")
|
|
203
|
+
visit(item.get("subItems"), f"{item_path}.subItems")
|
|
204
|
+
|
|
205
|
+
visit(items, prefix)
|
|
206
|
+
return issues
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def story_quality_issues(
|
|
210
|
+
*,
|
|
211
|
+
title: str,
|
|
212
|
+
description: str,
|
|
213
|
+
implementation_plan: str,
|
|
214
|
+
user_story: str,
|
|
215
|
+
acceptance_texts: list[str],
|
|
216
|
+
acceptance_count_justification: str,
|
|
217
|
+
swarm: dict[str, Any],
|
|
218
|
+
concurrent_ready: bool = True,
|
|
219
|
+
) -> list[str]:
|
|
220
|
+
issues: list[str] = []
|
|
221
|
+
if not USER_STORY_RE.match(user_story or ""):
|
|
222
|
+
issues.append(
|
|
223
|
+
"UserStory must match 'As a <role>, I want <capability>, so that <outcome>.'"
|
|
224
|
+
)
|
|
225
|
+
issues.extend(_description_issues(description))
|
|
226
|
+
issues.extend(_implementation_plan_issues(implementation_plan))
|
|
227
|
+
if not (2 <= len(acceptance_texts) <= 5) and not acceptance_count_justification.strip():
|
|
228
|
+
issues.append("2-5 acceptance criteria required unless justified")
|
|
229
|
+
|
|
230
|
+
normalized_title = _normalize(title)
|
|
231
|
+
normalized_description = _normalize(description)
|
|
232
|
+
for criterion in acceptance_texts:
|
|
233
|
+
normalized = _normalize(criterion)
|
|
234
|
+
lower = criterion.lower()
|
|
235
|
+
if any(pattern in lower for pattern in PLACEHOLDER_ACCEPTANCE_PATTERNS):
|
|
236
|
+
issues.append("placeholder acceptance criterion")
|
|
237
|
+
if normalized and normalized in {normalized_title, normalized_description}:
|
|
238
|
+
issues.append("acceptance criterion duplicates title or description")
|
|
239
|
+
if any(pattern in lower for pattern in DOCS_ONLY_ACCEPTANCE_PATTERNS):
|
|
240
|
+
issues.append("vague docs-only acceptance criterion")
|
|
241
|
+
if _word_count(criterion) < 8 or any(
|
|
242
|
+
pattern in lower for pattern in VAGUE_ACCEPTANCE_PATTERNS
|
|
243
|
+
):
|
|
244
|
+
issues.append("acceptance criterion must describe specific observable behavior")
|
|
245
|
+
if not _looks_observable(lower):
|
|
246
|
+
issues.append("acceptance criterion must describe observable behavior")
|
|
247
|
+
|
|
248
|
+
if concurrent_ready:
|
|
249
|
+
issues.extend(_file_scope_issues(swarm))
|
|
250
|
+
issues.extend(_verify_command_issues(swarm))
|
|
251
|
+
if swarm.get("parallel_safe") is False:
|
|
252
|
+
issues.append(
|
|
253
|
+
"readiness=ready requires parallel_safe=true; use readiness=sequential "
|
|
254
|
+
"or needs_refinement for non-concurrent work"
|
|
255
|
+
)
|
|
256
|
+
if swarm.get("file_scope_confidence") == "low":
|
|
257
|
+
issues.append("readiness=ready requires file_scope_confidence above low")
|
|
258
|
+
return _dedupe(issues)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _file_scope_issues(swarm: dict[str, Any]) -> list[str]:
|
|
262
|
+
issues: list[str] = []
|
|
263
|
+
for file_path in as_str_list(swarm.get("file_scope")):
|
|
264
|
+
normalized = file_path.strip().strip("/")
|
|
265
|
+
root = normalized.split("/", 1)[0]
|
|
266
|
+
if (
|
|
267
|
+
any(ch in normalized for ch in "*?[")
|
|
268
|
+
or normalized in BROAD_FILE_SCOPE_ROOTS
|
|
269
|
+
or file_path.rstrip("/") in BROAD_FILE_SCOPE_ROOTS
|
|
270
|
+
or (root in BROAD_FILE_SCOPE_ROOTS and normalized in {root, f"{root}/*"})
|
|
271
|
+
):
|
|
272
|
+
issues.append(f"broad file_scope is not swarm-ready: {file_path}")
|
|
273
|
+
return issues
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _verify_command_issues(swarm: dict[str, Any]) -> list[str]:
|
|
277
|
+
commands = [command.lower() for command in as_str_list(swarm.get("verify_commands"))]
|
|
278
|
+
if len(commands) == 1 and _normalize_command(commands[0]) in GENERIC_VERIFY_COMMANDS:
|
|
279
|
+
return [f"generic verify command is not swarm-ready: {commands[0]}"]
|
|
280
|
+
return []
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _description_issues(description: str) -> list[str]:
|
|
284
|
+
if not description.strip():
|
|
285
|
+
return ["plan.narratives.Description is required"]
|
|
286
|
+
if _sentence_count(description) < 2 or _word_count(description) < 20:
|
|
287
|
+
return ["plan.narratives.Description must contain at least two concrete sentences"]
|
|
288
|
+
return []
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _implementation_plan_issues(implementation_plan: str) -> list[str]:
|
|
292
|
+
if not implementation_plan.strip():
|
|
293
|
+
return ["plan.narratives.ImplementationPlan is required"]
|
|
294
|
+
issues: list[str] = []
|
|
295
|
+
if _step_count(implementation_plan) < 2 or _word_count(implementation_plan) < 20:
|
|
296
|
+
issues.append(
|
|
297
|
+
"plan.narratives.ImplementationPlan must contain at least two concrete steps"
|
|
298
|
+
)
|
|
299
|
+
lower = implementation_plan.lower()
|
|
300
|
+
if any(pattern in lower for pattern in PLACEHOLDER_ACCEPTANCE_PATTERNS):
|
|
301
|
+
issues.append("plan.narratives.ImplementationPlan must not be placeholder text")
|
|
302
|
+
if any(pattern in lower for pattern in GENERIC_IMPLEMENTATION_PATTERNS) or not (
|
|
303
|
+
any(term in lower for term in CODE_PATH_TERMS)
|
|
304
|
+
and any(term in lower for term in VERIFY_EVIDENCE_TERMS)
|
|
305
|
+
):
|
|
306
|
+
issues.append(
|
|
307
|
+
"plan.narratives.ImplementationPlan must identify concrete code paths "
|
|
308
|
+
"and verification evidence"
|
|
309
|
+
)
|
|
310
|
+
return issues
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _looks_observable(lower: str) -> bool:
|
|
314
|
+
return any(term in lower for term in OBSERVABLE_TERMS)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _sentence_count(value: str) -> int:
|
|
318
|
+
return len([part for part in re.split(r"[.!?]+(?:\s+|$)", value.strip()) if part.strip()])
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _step_count(value: str) -> int:
|
|
322
|
+
lines = [line.strip() for line in value.splitlines() if line.strip()]
|
|
323
|
+
bullet_lines = [
|
|
324
|
+
line
|
|
325
|
+
for line in lines
|
|
326
|
+
if re.match(r"^([-*]|\d+[.)])\s+", line)
|
|
327
|
+
]
|
|
328
|
+
if len(bullet_lines) >= 2:
|
|
329
|
+
return len(bullet_lines)
|
|
330
|
+
return _sentence_count(value)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _word_count(value: str) -> int:
|
|
334
|
+
return len(re.findall(r"\b\w+\b", value))
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _normalize(value: str) -> str:
|
|
338
|
+
return re.sub(r"[^a-z0-9]+", " ", value.lower()).strip()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _normalize_command(value: str) -> str:
|
|
342
|
+
return re.sub(r"\s+", " ", value.strip().lower())
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _dedupe(values: list[str]) -> list[str]:
|
|
346
|
+
out: list[str] = []
|
|
347
|
+
seen: set[str] = set()
|
|
348
|
+
for value in values:
|
|
349
|
+
if value in seen:
|
|
350
|
+
continue
|
|
351
|
+
seen.add(value)
|
|
352
|
+
out.append(value)
|
|
353
|
+
return out
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
r"""Shared validation and slug-safe ID helpers for the vBRIEF migrator (#498).
|
|
2
|
+
|
|
3
|
+
Extracted from ``scripts/migrate_vbrief.py`` so the migrator stays under the
|
|
4
|
+
1000-line hard cap documented in ``deft/coding/coding.md`` while still
|
|
5
|
+
hard-blocking on schema-invalid output (per #506 D8).
|
|
6
|
+
|
|
7
|
+
Public API
|
|
8
|
+
----------
|
|
9
|
+
* ``slugify_id(raw, existing=None)`` -- single sanitiser used for BOTH the
|
|
10
|
+
filename slug component AND every in-JSON identifier
|
|
11
|
+
(``plan.items[*].id``, ``plan.id``, scope-registry ids) emitted by the
|
|
12
|
+
migrator. Conforms to the schema-locked ID regex
|
|
13
|
+
``^[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)*$`` (shared conventions #506) by
|
|
14
|
+
restricting output to lowercase ASCII letters, digits, and hyphens only.
|
|
15
|
+
Also satisfies the stricter lifecycle-folder filename validator
|
|
16
|
+
(``^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*\.vbrief\.json$``) which
|
|
17
|
+
disallows underscores and dots -- hyphen-only output keeps a single
|
|
18
|
+
implementation that passes both surfaces.
|
|
19
|
+
* ``slug_fallback_id(item)`` -- resolve the logical identifier source for a
|
|
20
|
+
roadmap/scope ``item`` dict in the preference order used by both filename
|
|
21
|
+
construction and the PROJECT-DEFINITION scope registry, so both surfaces
|
|
22
|
+
compute the same slug input from the same item.
|
|
23
|
+
* ``validate_migration_output(vbrief_dir)`` -- thin wrapper around
|
|
24
|
+
``vbrief_validate.validate_all`` scoped to the migrator's emitted-file set.
|
|
25
|
+
Returns ``(errors, warnings)`` with full per-file diagnostics.
|
|
26
|
+
* ``isolate_invalid_output(project_root, vbrief_dir)`` -- on validation
|
|
27
|
+
failure, move the emitted ``vbrief/`` tree to ``vbrief.invalid/`` (with a
|
|
28
|
+
numeric suffix on collision) so the operator can inspect the partial
|
|
29
|
+
output without it blocking subsequent migrator runs. Pre-migration
|
|
30
|
+
``.premigrate.*`` backups (Agent C, #497) are left untouched.
|
|
31
|
+
* ``finalize_migration(project_root, vbrief_dir, actions)`` -- terminal
|
|
32
|
+
validate + isolate step the migrator plugs in at the end of its body.
|
|
33
|
+
* ``RECOVERY_HINT`` -- canonical CLI recovery hint printed on failure.
|
|
34
|
+
|
|
35
|
+
Story: #498 (migrate:vbrief self-validation + slug-safe IDs + golden tests).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import hashlib
|
|
41
|
+
import re
|
|
42
|
+
import sys
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
# Ensure sibling ``vbrief_validate`` is importable whether this module is
|
|
46
|
+
# imported from a test harness (which inserts ``scripts/`` onto sys.path) or
|
|
47
|
+
# from ``scripts/migrate_vbrief.py`` itself (which performs the same insert
|
|
48
|
+
# at the top of its module).
|
|
49
|
+
_SCRIPTS_DIR = Path(__file__).resolve().parent
|
|
50
|
+
if str(_SCRIPTS_DIR) not in sys.path:
|
|
51
|
+
sys.path.insert(0, str(_SCRIPTS_DIR))
|
|
52
|
+
|
|
53
|
+
import vbrief_validate # noqa: E402 (import after sys.path mutation)
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"RECOVERY_HINT",
|
|
57
|
+
"slugify_id",
|
|
58
|
+
"slug_fallback_id",
|
|
59
|
+
"validate_migration_output",
|
|
60
|
+
"isolate_invalid_output",
|
|
61
|
+
"finalize_migration",
|
|
62
|
+
"ID_MAX_LENGTH",
|
|
63
|
+
"HASH_SUFFIX_LENGTH",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
# Canonical recovery hint surfaced when the migrator hard-blocks on
|
|
67
|
+
# schema-invalid output (#506 D8). Agent C (#497) owns the ``--rollback`` flag
|
|
68
|
+
# implementation; this module only references the flag by name.
|
|
69
|
+
RECOVERY_HINT = "Restore with: task migrate:vbrief -- --rollback"
|
|
70
|
+
|
|
71
|
+
# Per #498: slug-safe IDs truncate to 80 characters. The optional 6-char hash
|
|
72
|
+
# suffix reserves ``1 + 6 = 7`` characters so the base slug before the suffix
|
|
73
|
+
# is 73 characters max -- keeps total length <= 80 for collision-disambiguated
|
|
74
|
+
# values.
|
|
75
|
+
ID_MAX_LENGTH = 80
|
|
76
|
+
HASH_SUFFIX_LENGTH = 6
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def slugify_id(raw: str | None, existing: set[str] | None = None) -> str:
|
|
80
|
+
"""Return a slug-safe id for filenames and in-JSON id fields (#498).
|
|
81
|
+
|
|
82
|
+
Rules (per #498 acceptance criteria and #506 shared conventions):
|
|
83
|
+
|
|
84
|
+
* lowercase ASCII letters, digits, and hyphens only
|
|
85
|
+
* runs of any non-allowed character collapse to a single hyphen
|
|
86
|
+
* leading/trailing hyphens are stripped
|
|
87
|
+
* truncate to ``ID_MAX_LENGTH`` (80) characters
|
|
88
|
+
* when ``existing`` is provided and the resulting slug collides,
|
|
89
|
+
append a stable 6-char hex suffix derived from the raw input so
|
|
90
|
+
repeated migrations produce the same disambiguated value; if that
|
|
91
|
+
still collides, perturb the hash deterministically until unique
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
raw:
|
|
96
|
+
The raw input text to slugify. ``None`` and empty values are
|
|
97
|
+
normalised to ``"untitled"``.
|
|
98
|
+
existing:
|
|
99
|
+
Optional mutable set used as the "already-emitted" registry. When
|
|
100
|
+
provided, the returned slug is added to the set so subsequent calls
|
|
101
|
+
can detect collisions. Pass ``None`` for one-shot slug computation
|
|
102
|
+
(no collision tracking).
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
str
|
|
107
|
+
A slug matching ``^[a-z0-9]+(-[a-z0-9]+)*$`` -- conforms to both the
|
|
108
|
+
schema ID regex and the lifecycle filename regex.
|
|
109
|
+
"""
|
|
110
|
+
text = (raw or "").strip()
|
|
111
|
+
slug = re.sub(r"[^a-z0-9]+", "-", text.lower())
|
|
112
|
+
slug = re.sub(r"-+", "-", slug).strip("-")
|
|
113
|
+
if not slug:
|
|
114
|
+
slug = "untitled"
|
|
115
|
+
if len(slug) > ID_MAX_LENGTH:
|
|
116
|
+
slug = slug[:ID_MAX_LENGTH].rstrip("-") or slug[:ID_MAX_LENGTH]
|
|
117
|
+
|
|
118
|
+
if existing is None:
|
|
119
|
+
return slug
|
|
120
|
+
|
|
121
|
+
if slug not in existing:
|
|
122
|
+
existing.add(slug)
|
|
123
|
+
return slug
|
|
124
|
+
|
|
125
|
+
# Collision path -- append a stable 6-char hash suffix. Seed from the
|
|
126
|
+
# original raw input (falls back to the computed slug when raw is empty)
|
|
127
|
+
# so repeated migrations with the same content yield the same suffix.
|
|
128
|
+
digest_seed = text or slug
|
|
129
|
+
base_max = ID_MAX_LENGTH - 1 - HASH_SUFFIX_LENGTH # "-" + 6 hex chars
|
|
130
|
+
base = slug[:base_max].rstrip("-") or slug[:base_max] or "id"
|
|
131
|
+
h = hashlib.sha1(digest_seed.encode("utf-8")).hexdigest()[:HASH_SUFFIX_LENGTH]
|
|
132
|
+
candidate = f"{base}-{h}"
|
|
133
|
+
|
|
134
|
+
attempt = 0
|
|
135
|
+
while candidate in existing and attempt < 1000:
|
|
136
|
+
attempt += 1
|
|
137
|
+
h2 = hashlib.sha1(
|
|
138
|
+
f"{digest_seed}|{attempt}".encode()
|
|
139
|
+
).hexdigest()[:HASH_SUFFIX_LENGTH]
|
|
140
|
+
candidate = f"{base}-{h2}"
|
|
141
|
+
|
|
142
|
+
existing.add(candidate)
|
|
143
|
+
return candidate
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def slug_fallback_id(item: dict) -> str:
|
|
147
|
+
"""Return the logical identifier source for a scope ``item`` dict.
|
|
148
|
+
|
|
149
|
+
Preference order mirrors the filename construction in Step 4 / 4b so
|
|
150
|
+
both surfaces (PROJECT-DEFINITION scope registry id AND scope vBRIEF
|
|
151
|
+
filename) resolve the same logical identifier from the same item:
|
|
152
|
+
|
|
153
|
+
1. GitHub issue ``number``
|
|
154
|
+
2. Explicit ``task_id``
|
|
155
|
+
3. ``synthetic_id`` (assigned by the ROADMAP parser fallback)
|
|
156
|
+
4. ``title`` (used when nothing else is available)
|
|
157
|
+
|
|
158
|
+
Returns the raw (un-slugified) string; callers are expected to pipe the
|
|
159
|
+
result through :func:`slugify_id`.
|
|
160
|
+
"""
|
|
161
|
+
number = str(item.get("number", "") or "")
|
|
162
|
+
if number:
|
|
163
|
+
return number
|
|
164
|
+
task_id = str(item.get("task_id", "") or "")
|
|
165
|
+
if task_id:
|
|
166
|
+
return task_id
|
|
167
|
+
synthetic = str(item.get("synthetic_id", "") or "")
|
|
168
|
+
if synthetic:
|
|
169
|
+
return synthetic
|
|
170
|
+
return str(item.get("title", "") or "untitled")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def validate_migration_output(
|
|
174
|
+
vbrief_dir: Path,
|
|
175
|
+
) -> tuple[list[str], list[str]]:
|
|
176
|
+
"""Validate every file emitted by the migrator under ``vbrief_dir``.
|
|
177
|
+
|
|
178
|
+
Delegates the heavy lifting to :func:`vbrief_validate.validate_all`,
|
|
179
|
+
which already implements the full D2/D3/D4/D7/D11 rule set plus schema
|
|
180
|
+
validation for scope vBRIEFs and PROJECT-DEFINITION.vbrief.json.
|
|
181
|
+
|
|
182
|
+
Returns
|
|
183
|
+
-------
|
|
184
|
+
(errors, warnings):
|
|
185
|
+
``errors`` is a list of human-readable diagnostic strings; when
|
|
186
|
+
non-empty the migrator MUST exit non-zero per #506 D8 (hard-block on
|
|
187
|
+
schema-invalid output). ``warnings`` are surfaced but do NOT block
|
|
188
|
+
success -- they follow the same semantics as
|
|
189
|
+
``scripts/vbrief_validate.py`` (e.g. D11 origin provenance warnings
|
|
190
|
+
for pending/active without a github-issue reference).
|
|
191
|
+
"""
|
|
192
|
+
if not vbrief_dir.is_dir():
|
|
193
|
+
# Nothing emitted -- fail loudly so the caller reports something
|
|
194
|
+
# useful rather than silently accepting an empty migration.
|
|
195
|
+
return (
|
|
196
|
+
[f"{vbrief_dir}: expected vbrief directory does not exist"],
|
|
197
|
+
[],
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
errors, warnings, _scope_count = vbrief_validate.validate_all(vbrief_dir)
|
|
201
|
+
return list(errors), list(warnings)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def isolate_invalid_output(
|
|
205
|
+
project_root: Path, vbrief_dir: Path
|
|
206
|
+
) -> Path | None:
|
|
207
|
+
"""Move the emitted ``vbrief/`` tree to ``vbrief.invalid/`` on failure.
|
|
208
|
+
|
|
209
|
+
Per #506 D8: schema-invalid migration output must be isolated from
|
|
210
|
+
``vbrief/`` so downstream tasks (``task check`` / ``task scope:*`` /
|
|
211
|
+
renders) don't consume broken state. Agent C's ``.premigrate.*`` backups
|
|
212
|
+
remain untouched at the project root so ``task migrate:vbrief --
|
|
213
|
+
--rollback`` can restore the pre-migration state.
|
|
214
|
+
|
|
215
|
+
Returns the destination path, or ``None`` when ``vbrief_dir`` does not
|
|
216
|
+
exist (nothing to move).
|
|
217
|
+
|
|
218
|
+
Collision handling: if ``vbrief.invalid/`` already exists (e.g. from a
|
|
219
|
+
prior failed migration), increment a numeric suffix -- ``vbrief.invalid.2``,
|
|
220
|
+
``vbrief.invalid.3``, etc. -- so operators retain the history of failed
|
|
221
|
+
attempts instead of overwriting.
|
|
222
|
+
"""
|
|
223
|
+
if not vbrief_dir.exists():
|
|
224
|
+
return None
|
|
225
|
+
|
|
226
|
+
target = project_root / "vbrief.invalid"
|
|
227
|
+
idx = 1
|
|
228
|
+
while target.exists():
|
|
229
|
+
idx += 1
|
|
230
|
+
target = project_root / f"vbrief.invalid.{idx}"
|
|
231
|
+
|
|
232
|
+
vbrief_dir.rename(target)
|
|
233
|
+
return target
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def finalize_migration(
|
|
237
|
+
project_root: Path,
|
|
238
|
+
vbrief_dir: Path,
|
|
239
|
+
actions: list[str],
|
|
240
|
+
) -> tuple[bool, list[str]]:
|
|
241
|
+
"""Run validation + isolation as the migrator's terminal gate (#498).
|
|
242
|
+
|
|
243
|
+
Designed as a drop-in terminal step for ``scripts/migrate_vbrief.py::
|
|
244
|
+
migrate`` so the migrator body stays under the 1000-line hard cap.
|
|
245
|
+
Pipes diagnostics to stderr and returns the final ``(ok, actions)``
|
|
246
|
+
tuple that the migrator should propagate to its CLI entry point:
|
|
247
|
+
|
|
248
|
+
* On success: returns ``(True, actions)`` untouched -- caller prints its
|
|
249
|
+
normal success message ("Migration completed successfully.").
|
|
250
|
+
* On failure: prints per-file diagnostics to stderr, moves ``vbrief/``
|
|
251
|
+
to ``vbrief.invalid/`` (isolation), appends failure diagnostics and a
|
|
252
|
+
``Restore with: task migrate:vbrief -- --rollback`` recovery hint to a
|
|
253
|
+
copy of ``actions``, and returns ``(False, failure_actions)``.
|
|
254
|
+
|
|
255
|
+
The ``actions`` list passed in is NOT mutated so callers can reuse it
|
|
256
|
+
for downstream logging independent of migration outcome.
|
|
257
|
+
"""
|
|
258
|
+
errors, warnings = validate_migration_output(vbrief_dir)
|
|
259
|
+
if not errors:
|
|
260
|
+
# Surface non-blocking validator warnings (e.g. D11 origin-provenance
|
|
261
|
+
# warnings for pending/active scopes without a github-issue reference)
|
|
262
|
+
# so operators see them even on the success path. Matches the
|
|
263
|
+
# ``scripts/vbrief_validate.py`` CLI behaviour where warnings print
|
|
264
|
+
# but do not change exit code.
|
|
265
|
+
for w in warnings:
|
|
266
|
+
print(f"WARNING: {w}", file=sys.stderr)
|
|
267
|
+
return True, actions
|
|
268
|
+
|
|
269
|
+
print(
|
|
270
|
+
f"ERROR: Migration produced invalid output ({len(errors)} "
|
|
271
|
+
f"file-level error(s)):",
|
|
272
|
+
file=sys.stderr,
|
|
273
|
+
)
|
|
274
|
+
for err in errors:
|
|
275
|
+
print(f" {err}", file=sys.stderr)
|
|
276
|
+
|
|
277
|
+
invalid_dir = isolate_invalid_output(project_root, vbrief_dir)
|
|
278
|
+
|
|
279
|
+
failure_actions: list[str] = list(actions)
|
|
280
|
+
failure_actions.append(
|
|
281
|
+
f"FAIL migration produced {len(errors)} schema validation error(s)"
|
|
282
|
+
)
|
|
283
|
+
for err in errors:
|
|
284
|
+
failure_actions.append(f" {err}")
|
|
285
|
+
if invalid_dir is not None:
|
|
286
|
+
try:
|
|
287
|
+
rel_invalid = invalid_dir.relative_to(project_root)
|
|
288
|
+
except ValueError:
|
|
289
|
+
rel_invalid = invalid_dir
|
|
290
|
+
failure_actions.append(
|
|
291
|
+
f"MOVE vbrief/ -> {rel_invalid}/ (isolated from vbrief/)"
|
|
292
|
+
)
|
|
293
|
+
print(
|
|
294
|
+
f"Isolated partial output to: {rel_invalid}",
|
|
295
|
+
file=sys.stderr,
|
|
296
|
+
)
|
|
297
|
+
failure_actions.append(RECOVERY_HINT)
|
|
298
|
+
print(RECOVERY_HINT, file=sys.stderr)
|
|
299
|
+
return False, failure_actions
|