@deftai/directive-content 0.55.2 → 0.56.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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,178 @@
|
|
|
1
|
+
"""slug_normalize.py -- canonical slug normalization for scope vBRIEF filenames (#532).
|
|
2
|
+
|
|
3
|
+
Single source of truth for the rules laid out in issue #532. Used by
|
|
4
|
+
``scripts/migrate_vbrief.py`` when generating scope vBRIEF filenames so the
|
|
5
|
+
migrator and any future skill / helper that creates scope vBRIEFs all agree
|
|
6
|
+
on how ``YYYY-MM-DD-<slug>.vbrief.json`` slugs are derived.
|
|
7
|
+
|
|
8
|
+
Rules (per #532 Suggested normalization rules):
|
|
9
|
+
|
|
10
|
+
1. Normalize Unicode to NFKD; strip combining marks; drop non-ASCII.
|
|
11
|
+
2. Lowercase the entire result.
|
|
12
|
+
3. Strip common Markdown checkbox markers (``[x]``, ``[ ]``) before the
|
|
13
|
+
punctuation pass so they do not leak into the slug as a literal ``x``.
|
|
14
|
+
4. Replace any run of ``[^a-z0-9]+`` with a single hyphen.
|
|
15
|
+
5. Strip leading and trailing hyphens.
|
|
16
|
+
6. Truncate at word boundaries at or before ``max_len`` (default 60). If the
|
|
17
|
+
next character after the cut is inside a word, backtrack to the most
|
|
18
|
+
recent hyphen provided that hyphen is past ``max_len // 2``.
|
|
19
|
+
7. Empty-slug fallback: return ``"untitled"`` when normalization produces
|
|
20
|
+
an empty string.
|
|
21
|
+
8. Reserved names: if the slug equals a Windows reserved name (``con``,
|
|
22
|
+
``prn``, ``aux``, ``nul``, ``com1``-``com9``, ``lpt1``-``lpt9``), append
|
|
23
|
+
``-scope``.
|
|
24
|
+
|
|
25
|
+
Collision handling: :func:`disambiguate_slug` appends ``-2``, ``-3``, ... to
|
|
26
|
+
the normalized slug until the candidate is not in the supplied ``existing``
|
|
27
|
+
set. Callers typically pass a set pre-populated with stems from existing
|
|
28
|
+
lifecycle-folder files.
|
|
29
|
+
|
|
30
|
+
This module intentionally has no dependency on the rest of the migrator so
|
|
31
|
+
future skills (refinement, setup) can import it without dragging the full
|
|
32
|
+
migration surface.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import re
|
|
38
|
+
import unicodedata
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"WINDOWS_RESERVED",
|
|
42
|
+
"DEFAULT_MAX_LEN",
|
|
43
|
+
"normalize_slug",
|
|
44
|
+
"disambiguate_slug",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
# Windows-reserved filename stems (case-insensitive). Matching is performed on
|
|
48
|
+
# the fully normalized slug so ``CON`` -> ``con`` is rejected just like ``con``.
|
|
49
|
+
WINDOWS_RESERVED: frozenset[str] = frozenset(
|
|
50
|
+
{"con", "prn", "aux", "nul"}
|
|
51
|
+
| {f"com{i}" for i in range(1, 10)}
|
|
52
|
+
| {f"lpt{i}" for i in range(1, 10)}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Default body length per #532. Shorter than the historic ~80 char ceiling so
|
|
56
|
+
# the final ``YYYY-MM-DD-<slug>.vbrief.json`` filename stays well within
|
|
57
|
+
# Windows path limits for deeply nested worktrees.
|
|
58
|
+
DEFAULT_MAX_LEN: int = 60
|
|
59
|
+
|
|
60
|
+
# Match a leading checkbox marker at a word boundary: ``[x]``, ``[X]``, ``[ ]``.
|
|
61
|
+
# We deliberately only strip leading markers (or those preceded by whitespace)
|
|
62
|
+
# rather than anywhere in the string, so a legitimate ``[x]`` inside a sentence
|
|
63
|
+
# like ``add [x]-axis scaling`` is not mangled.
|
|
64
|
+
_CHECKBOX_RE = re.compile(r"(?:(?<=^)|(?<=\s))\[[ xX]\]")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def normalize_slug(text: str, max_len: int = DEFAULT_MAX_LEN) -> str:
|
|
68
|
+
"""Return a filesystem-safe, deterministic slug for ``text``.
|
|
69
|
+
|
|
70
|
+
See module docstring for the full rule list.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
text:
|
|
75
|
+
Free-form input -- typically a GitHub issue title, ROADMAP line, or
|
|
76
|
+
spec task body. ``None`` and empty strings return ``"untitled"``.
|
|
77
|
+
max_len:
|
|
78
|
+
Hard ceiling on the returned body length. Default 60. Values less
|
|
79
|
+
than 1 fall back to ``DEFAULT_MAX_LEN`` so callers cannot accidentally
|
|
80
|
+
truncate the slug away entirely.
|
|
81
|
+
|
|
82
|
+
Returns
|
|
83
|
+
-------
|
|
84
|
+
str
|
|
85
|
+
A slug matching ``^[a-z0-9]+(-[a-z0-9]+)*$`` with length <= ``max_len``.
|
|
86
|
+
"""
|
|
87
|
+
if not text:
|
|
88
|
+
return "untitled"
|
|
89
|
+
if max_len < 1:
|
|
90
|
+
max_len = DEFAULT_MAX_LEN
|
|
91
|
+
|
|
92
|
+
# 1. Unicode NFKD, drop combining marks, drop non-ASCII.
|
|
93
|
+
decomposed = unicodedata.normalize("NFKD", text)
|
|
94
|
+
ascii_only = "".join(
|
|
95
|
+
ch for ch in decomposed if not unicodedata.combining(ch)
|
|
96
|
+
)
|
|
97
|
+
ascii_only = ascii_only.encode("ascii", "ignore").decode("ascii")
|
|
98
|
+
|
|
99
|
+
# 2. Lowercase.
|
|
100
|
+
lowered = ascii_only.lower()
|
|
101
|
+
|
|
102
|
+
# 3. Strip checkbox markers before the punctuation pass so ``[x]`` does
|
|
103
|
+
# not leak into the slug as a literal ``x``.
|
|
104
|
+
stripped = _CHECKBOX_RE.sub(" ", lowered)
|
|
105
|
+
|
|
106
|
+
# 4. Collapse non-alphanumeric runs to a single hyphen.
|
|
107
|
+
hyphenated = re.sub(r"[^a-z0-9]+", "-", stripped)
|
|
108
|
+
|
|
109
|
+
# 5. Strip leading/trailing hyphens.
|
|
110
|
+
trimmed = hyphenated.strip("-")
|
|
111
|
+
|
|
112
|
+
# 6. Truncate at word boundaries at or before max_len.
|
|
113
|
+
if len(trimmed) > max_len:
|
|
114
|
+
truncated = trimmed[:max_len]
|
|
115
|
+
# If we cut mid-word, backtrack to the most recent hyphen provided
|
|
116
|
+
# that hyphen is past max_len // 2 -- otherwise the slug collapses
|
|
117
|
+
# too aggressively for short limits.
|
|
118
|
+
if trimmed[max_len] not in "-":
|
|
119
|
+
last_hyphen = truncated.rfind("-")
|
|
120
|
+
if last_hyphen > max_len // 2:
|
|
121
|
+
truncated = truncated[:last_hyphen]
|
|
122
|
+
trimmed = truncated.rstrip("-")
|
|
123
|
+
|
|
124
|
+
# 7. Empty-after-normalization fallback.
|
|
125
|
+
if not trimmed:
|
|
126
|
+
return "untitled"
|
|
127
|
+
|
|
128
|
+
# 8. Windows reserved names.
|
|
129
|
+
if trimmed in WINDOWS_RESERVED:
|
|
130
|
+
return f"{trimmed}-scope"
|
|
131
|
+
|
|
132
|
+
return trimmed
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def disambiguate_slug(
|
|
136
|
+
slug: str,
|
|
137
|
+
existing: set[str] | frozenset[str],
|
|
138
|
+
*,
|
|
139
|
+
max_len: int = DEFAULT_MAX_LEN,
|
|
140
|
+
) -> str:
|
|
141
|
+
"""Return a collision-free variant of ``slug`` relative to ``existing``.
|
|
142
|
+
|
|
143
|
+
Appends ``-2``, ``-3``, ... to ``slug`` until the candidate is not in
|
|
144
|
+
``existing``. The suffix always respects ``max_len`` by truncating the
|
|
145
|
+
body portion when necessary so the final slug remains within the ceiling.
|
|
146
|
+
|
|
147
|
+
The function does NOT mutate ``existing``; callers record the returned
|
|
148
|
+
value themselves once it is adopted.
|
|
149
|
+
"""
|
|
150
|
+
if slug not in existing:
|
|
151
|
+
return slug
|
|
152
|
+
|
|
153
|
+
base = slug
|
|
154
|
+
n = 2
|
|
155
|
+
while True:
|
|
156
|
+
suffix = f"-{n}"
|
|
157
|
+
candidate = base + suffix
|
|
158
|
+
if len(candidate) > max_len:
|
|
159
|
+
# Trim the base to make room for the suffix; rstrip hyphens so we
|
|
160
|
+
# do not produce e.g. ``foo--2``.
|
|
161
|
+
body_budget = max_len - len(suffix)
|
|
162
|
+
if body_budget < 1:
|
|
163
|
+
# Pathological short max_len -- just return the base + suffix;
|
|
164
|
+
# caller's filesystem handling will still reject if absurd.
|
|
165
|
+
body_budget = 1
|
|
166
|
+
trimmed = base[:body_budget].rstrip("-") or base[:body_budget]
|
|
167
|
+
candidate = trimmed + suffix
|
|
168
|
+
if candidate not in existing:
|
|
169
|
+
return candidate
|
|
170
|
+
n += 1
|
|
171
|
+
# Guard against runaway loops on pathological inputs (e.g. ``existing``
|
|
172
|
+
# that contains every integer suffix). 10_000 is well above any
|
|
173
|
+
# reasonable real-world collision depth.
|
|
174
|
+
if n > 10_000:
|
|
175
|
+
raise RuntimeError(
|
|
176
|
+
f"disambiguate_slug: unable to resolve collision for {slug!r} "
|
|
177
|
+
f"after {n} attempts"
|
|
178
|
+
)
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
spec_render.py — Render a vbrief specification JSON file to SPECIFICATION.md.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
uv run python scripts/spec_render.py <spec_file> [out_file] [--include-scopes=on|off]
|
|
7
|
+
|
|
8
|
+
spec_file — path to vbrief/specification.vbrief.json
|
|
9
|
+
out_file — output path (default: <spec_file's grandparent>/SPECIFICATION.md)
|
|
10
|
+
--include-scopes=on|off
|
|
11
|
+
— include (default) or exclude the Implementation Plan section
|
|
12
|
+
aggregated from vbrief/{pending,active,completed} scope vBRIEFs (#435)
|
|
13
|
+
|
|
14
|
+
Exit codes:
|
|
15
|
+
0 — rendered successfully
|
|
16
|
+
1 — validation failed or status not in ('approved', 'running', 'completed')
|
|
17
|
+
2 — usage error (no argument provided)
|
|
18
|
+
|
|
19
|
+
Implementation: IMPLEMENTATION.md Phase 5.2; lifecycle aggregator per #435.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
# Allow co-located import of spec_validate when run as a script
|
|
27
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
28
|
+
from _stdio_utf8 import reconfigure_stdio # noqa: E402
|
|
29
|
+
from spec_validate import validate_spec # noqa: E402
|
|
30
|
+
|
|
31
|
+
# UTF-8 stdout guard (#540).
|
|
32
|
+
reconfigure_stdio()
|
|
33
|
+
|
|
34
|
+
# Declared narrative ordering for SPECIFICATION.md. Covers both the
|
|
35
|
+
# interview/light key set (Overview, ProblemStatement, Goals, UserStories,
|
|
36
|
+
# Requirements, SuccessMetrics, Architecture) and the speckit Phase 2/3 key
|
|
37
|
+
# set (EdgeCases, TechDecisions, ImplementationPhases, PreImplementationGates).
|
|
38
|
+
# Narratives present on the spec render in this declared order; any other
|
|
39
|
+
# narrative keys render after these, sorted alphabetically. See #434.
|
|
40
|
+
SPECIFICATION_NARRATIVE_KEY_ORDER = [
|
|
41
|
+
"Overview",
|
|
42
|
+
"ProblemStatement",
|
|
43
|
+
"Goals",
|
|
44
|
+
"UserStories",
|
|
45
|
+
"Requirements",
|
|
46
|
+
"SuccessMetrics",
|
|
47
|
+
"EdgeCases",
|
|
48
|
+
"Architecture",
|
|
49
|
+
"TechDecisions",
|
|
50
|
+
"ImplementationPhases",
|
|
51
|
+
"PreImplementationGates",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
# Lifecycle folders walked by the scope aggregator, in render order (#435).
|
|
55
|
+
LIFECYCLE_BUCKETS: tuple[tuple[str, str], ...] = (
|
|
56
|
+
("pending", "Pending"),
|
|
57
|
+
("active", "Active"),
|
|
58
|
+
("completed", "Completed"),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Canonical 4-line machine-generated banner per
|
|
62
|
+
# ``conventions/machine-generated-banner.md`` (#572). spec:render
|
|
63
|
+
# previously emitted no banner, so downstream heuristics could not
|
|
64
|
+
# distinguish a rendered specification from a hand-authored one -- a
|
|
65
|
+
# correctness gap. Now every rendered SPECIFICATION.md opens with the
|
|
66
|
+
# same four lines as prd:render / roadmap:render.
|
|
67
|
+
_BANNER = (
|
|
68
|
+
"<!-- AUTO-GENERATED by task spec:render -- DO NOT EDIT MANUALLY -->\n"
|
|
69
|
+
"<!-- Purpose: rendered specification -->\n"
|
|
70
|
+
"<!-- Source of truth: vbrief/specification.vbrief.json -->\n"
|
|
71
|
+
"<!-- Regenerate with: task spec:render -->\n"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Narrative key resolution order used when rendering a scope's summary line.
|
|
75
|
+
_SCOPE_SUMMARY_NARRATIVES = (
|
|
76
|
+
"Overview",
|
|
77
|
+
"Summary",
|
|
78
|
+
"Description",
|
|
79
|
+
"ProblemStatement",
|
|
80
|
+
"Problem",
|
|
81
|
+
"Outcome",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _read_edge_endpoints(edge: dict) -> tuple[str, str]:
|
|
86
|
+
"""Bilingual edge reader: prefer ``{from, to}``, fall back to ``{source, target}``.
|
|
87
|
+
|
|
88
|
+
Mirrors the reader in ``roadmap_render._read_edge_endpoints`` per #458 --
|
|
89
|
+
when both conventions are present on a single edge, canonical ``from``/``to``
|
|
90
|
+
wins. Replicated here rather than imported to keep the lifecycle aggregator
|
|
91
|
+
decoupled from roadmap rendering.
|
|
92
|
+
"""
|
|
93
|
+
if not isinstance(edge, dict):
|
|
94
|
+
return "", ""
|
|
95
|
+
frm = edge.get("from") or edge.get("source", "") or ""
|
|
96
|
+
to = edge.get("to") or edge.get("target", "") or ""
|
|
97
|
+
return frm, to
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _load_scope_vbriefs(folder: Path) -> list[tuple[str, dict]]:
|
|
101
|
+
"""Load ``*.vbrief.json`` files from a lifecycle folder.
|
|
102
|
+
|
|
103
|
+
Returns a list of ``(filename_stem, vbrief_data)`` tuples, sorted by
|
|
104
|
+
filename. Missing / non-directory / malformed files are skipped silently
|
|
105
|
+
so the aggregator never prevents a successful render.
|
|
106
|
+
"""
|
|
107
|
+
if not folder.is_dir():
|
|
108
|
+
return []
|
|
109
|
+
out: list[tuple[str, dict]] = []
|
|
110
|
+
for path in sorted(folder.glob("*.vbrief.json")):
|
|
111
|
+
try:
|
|
112
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
113
|
+
except (json.JSONDecodeError, OSError):
|
|
114
|
+
continue
|
|
115
|
+
stem = path.name
|
|
116
|
+
if stem.endswith(".vbrief.json"):
|
|
117
|
+
stem = stem[: -len(".vbrief.json")]
|
|
118
|
+
out.append((stem, data))
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _scope_id(stem: str, vbrief: dict) -> str:
|
|
123
|
+
"""Resolve a stable identifier for a scope used in cross-scope edge lookup."""
|
|
124
|
+
plan = vbrief.get("plan", {})
|
|
125
|
+
if isinstance(plan, dict):
|
|
126
|
+
plan_id = plan.get("id", "")
|
|
127
|
+
if isinstance(plan_id, str) and plan_id:
|
|
128
|
+
return plan_id
|
|
129
|
+
return stem
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _cross_scope_dep_map(scopes: list[tuple[str, dict]]) -> dict[str, list[str]]:
|
|
133
|
+
"""Build a cross-scope dep map using the bilingual edge reader (#458).
|
|
134
|
+
|
|
135
|
+
Only edges whose endpoints match scope IDs present in ``scopes`` contribute
|
|
136
|
+
to ordering; out-of-scope endpoints are ignored silently so a scope cannot
|
|
137
|
+
silently pin the whole render to an unresolved id.
|
|
138
|
+
"""
|
|
139
|
+
scope_ids = {_scope_id(stem, vb) for stem, vb in scopes}
|
|
140
|
+
dep_map: dict[str, list[str]] = {}
|
|
141
|
+
for _stem, vbrief in scopes:
|
|
142
|
+
plan = vbrief.get("plan", {})
|
|
143
|
+
if not isinstance(plan, dict):
|
|
144
|
+
continue
|
|
145
|
+
edges = plan.get("edges", [])
|
|
146
|
+
if not isinstance(edges, list):
|
|
147
|
+
continue
|
|
148
|
+
for edge in edges:
|
|
149
|
+
frm, to = _read_edge_endpoints(edge)
|
|
150
|
+
if frm in scope_ids and to in scope_ids and frm and to:
|
|
151
|
+
dep_map.setdefault(to, []).append(frm)
|
|
152
|
+
return dep_map
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _topo_sort_scopes(
|
|
156
|
+
scopes: list[tuple[str, dict]],
|
|
157
|
+
dep_map: dict[str, list[str]],
|
|
158
|
+
) -> list[tuple[str, dict]]:
|
|
159
|
+
"""Order scopes by longest dependency chain depth; fall back to filename order."""
|
|
160
|
+
if not scopes:
|
|
161
|
+
return []
|
|
162
|
+
id_by_index = [_scope_id(stem, vb) for stem, vb in scopes]
|
|
163
|
+
id_to_index = {sid: i for i, sid in enumerate(id_by_index)}
|
|
164
|
+
depths: dict[str, int] = {}
|
|
165
|
+
|
|
166
|
+
def _depth(sid: str, visited: set[str] | None = None) -> int:
|
|
167
|
+
if sid in depths:
|
|
168
|
+
return depths[sid]
|
|
169
|
+
if visited is None:
|
|
170
|
+
visited = set()
|
|
171
|
+
if sid in visited:
|
|
172
|
+
return 0
|
|
173
|
+
visited.add(sid)
|
|
174
|
+
deps = [d for d in dep_map.get(sid, []) if d in id_to_index]
|
|
175
|
+
if not deps:
|
|
176
|
+
depths[sid] = 0
|
|
177
|
+
return 0
|
|
178
|
+
result = max(_depth(d, visited) for d in deps) + 1
|
|
179
|
+
depths[sid] = result
|
|
180
|
+
return result
|
|
181
|
+
|
|
182
|
+
for sid in id_by_index:
|
|
183
|
+
_depth(sid)
|
|
184
|
+
|
|
185
|
+
ordered_indices = sorted(
|
|
186
|
+
range(len(scopes)),
|
|
187
|
+
key=lambda i: (depths.get(id_by_index[i], 0), i),
|
|
188
|
+
)
|
|
189
|
+
return [scopes[i] for i in ordered_indices]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _scope_summary_narrative(plan: dict) -> str:
|
|
193
|
+
"""Pick the most informative narrative string for a scope summary line."""
|
|
194
|
+
if not isinstance(plan, dict):
|
|
195
|
+
return ""
|
|
196
|
+
narratives = plan.get("narratives", {})
|
|
197
|
+
if not isinstance(narratives, dict):
|
|
198
|
+
return ""
|
|
199
|
+
for key in _SCOPE_SUMMARY_NARRATIVES:
|
|
200
|
+
val = narratives.get(key)
|
|
201
|
+
if isinstance(val, str) and val.strip():
|
|
202
|
+
return val.strip()
|
|
203
|
+
# Fallback: first non-empty string narrative in insertion order.
|
|
204
|
+
for val in narratives.values():
|
|
205
|
+
if isinstance(val, str) and val.strip():
|
|
206
|
+
return val.strip()
|
|
207
|
+
return ""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _split_acceptance(value: object) -> list[str]:
|
|
211
|
+
"""Normalize acceptance text/list values into visible markdown bullets."""
|
|
212
|
+
if isinstance(value, list):
|
|
213
|
+
return [str(item).strip() for item in value if str(item).strip()]
|
|
214
|
+
if not isinstance(value, str):
|
|
215
|
+
return []
|
|
216
|
+
parts: list[str] = []
|
|
217
|
+
for line in value.splitlines():
|
|
218
|
+
cleaned = line.strip()
|
|
219
|
+
if not cleaned:
|
|
220
|
+
continue
|
|
221
|
+
if cleaned.startswith(("- ", "* ")):
|
|
222
|
+
cleaned = cleaned[2:].strip()
|
|
223
|
+
if cleaned:
|
|
224
|
+
parts.append(cleaned)
|
|
225
|
+
return parts
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _item_acceptance(item: dict) -> list[str]:
|
|
229
|
+
narrative = item.get("narrative")
|
|
230
|
+
if not isinstance(narrative, dict):
|
|
231
|
+
return []
|
|
232
|
+
return _split_acceptance(narrative.get("Acceptance"))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _render_scope_block(stem: str, vbrief: dict) -> list[str]:
|
|
236
|
+
"""Render a single scope vBRIEF as a markdown block inside Implementation Plan."""
|
|
237
|
+
plan = vbrief.get("plan", {})
|
|
238
|
+
if not isinstance(plan, dict):
|
|
239
|
+
return []
|
|
240
|
+
title = plan.get("title", stem)
|
|
241
|
+
status = plan.get("status", "")
|
|
242
|
+
heading_parts = [f"### {stem}: {title}"]
|
|
243
|
+
if status:
|
|
244
|
+
heading_parts[0] += f" `[{status}]`"
|
|
245
|
+
lines: list[str] = [heading_parts[0] + "\n"]
|
|
246
|
+
|
|
247
|
+
summary = _scope_summary_narrative(plan)
|
|
248
|
+
if summary:
|
|
249
|
+
lines.append(f"{summary}\n")
|
|
250
|
+
|
|
251
|
+
narratives = plan.get("narratives", {})
|
|
252
|
+
if isinstance(narratives, dict):
|
|
253
|
+
scope_acceptance = _split_acceptance(narratives.get("Acceptance"))
|
|
254
|
+
if scope_acceptance:
|
|
255
|
+
lines.append("**Scope Acceptance**:\n")
|
|
256
|
+
for criterion in scope_acceptance:
|
|
257
|
+
lines.append(f"- {criterion}")
|
|
258
|
+
lines.append("")
|
|
259
|
+
|
|
260
|
+
# Acceptance items -- each plan.items entry rendered as a bullet with status.
|
|
261
|
+
items = plan.get("items", [])
|
|
262
|
+
if isinstance(items, list) and items:
|
|
263
|
+
lines.append("**Acceptance**:\n")
|
|
264
|
+
for item in items:
|
|
265
|
+
if not isinstance(item, dict):
|
|
266
|
+
continue
|
|
267
|
+
item_title = item.get("title", "Untitled")
|
|
268
|
+
item_status = item.get("status", "")
|
|
269
|
+
bullet = f"- {item_title}"
|
|
270
|
+
if item_status:
|
|
271
|
+
bullet += f" `[{item_status}]`"
|
|
272
|
+
lines.append(bullet)
|
|
273
|
+
for criterion in _item_acceptance(item):
|
|
274
|
+
if criterion != item_title:
|
|
275
|
+
lines.append(f" - Acceptance: {criterion}")
|
|
276
|
+
lines.append("")
|
|
277
|
+
return lines
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _aggregate_scope_section(vbrief_dir: Path) -> list[str]:
|
|
281
|
+
"""Build the Implementation Plan section from vbrief/{pending,active,completed}.
|
|
282
|
+
|
|
283
|
+
Returns an empty list if no scope vBRIEFs exist in any lifecycle folder.
|
|
284
|
+
"""
|
|
285
|
+
buckets: list[tuple[str, str, list[tuple[str, dict]]]] = []
|
|
286
|
+
for folder_name, heading in LIFECYCLE_BUCKETS:
|
|
287
|
+
scopes = _load_scope_vbriefs(vbrief_dir / folder_name)
|
|
288
|
+
if folder_name == "completed":
|
|
289
|
+
# Status pin: only completed scopes rendered under Completed.
|
|
290
|
+
scopes = [
|
|
291
|
+
(stem, vb)
|
|
292
|
+
for stem, vb in scopes
|
|
293
|
+
if isinstance(vb.get("plan"), dict)
|
|
294
|
+
and vb["plan"].get("status") == "completed"
|
|
295
|
+
]
|
|
296
|
+
if scopes:
|
|
297
|
+
buckets.append((folder_name, heading, scopes))
|
|
298
|
+
|
|
299
|
+
if not buckets:
|
|
300
|
+
return []
|
|
301
|
+
|
|
302
|
+
lines: list[str] = ["## Implementation Plan\n"]
|
|
303
|
+
for _folder, heading, scopes in buckets:
|
|
304
|
+
dep_map = _cross_scope_dep_map(scopes)
|
|
305
|
+
ordered = _topo_sort_scopes(scopes, dep_map)
|
|
306
|
+
lines.append(f"### {heading}\n")
|
|
307
|
+
for stem, vbrief in ordered:
|
|
308
|
+
lines.extend(_render_scope_block(stem, vbrief))
|
|
309
|
+
return lines
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def render_spec(
|
|
313
|
+
spec_path: str,
|
|
314
|
+
out_path: str,
|
|
315
|
+
include_scopes: bool = True,
|
|
316
|
+
) -> tuple[bool, str]:
|
|
317
|
+
"""
|
|
318
|
+
Render the approved spec at *spec_path* to markdown at *out_path*.
|
|
319
|
+
|
|
320
|
+
Returns:
|
|
321
|
+
(True, success_message) on success.
|
|
322
|
+
(False, error_message) on failure.
|
|
323
|
+
"""
|
|
324
|
+
# Validate first
|
|
325
|
+
ok, msg = validate_spec(spec_path)
|
|
326
|
+
if not ok:
|
|
327
|
+
return False, msg
|
|
328
|
+
|
|
329
|
+
with open(spec_path, encoding="utf-8") as fh:
|
|
330
|
+
spec = json.load(fh)
|
|
331
|
+
|
|
332
|
+
# Support vBRIEF v0.5 envelope structure
|
|
333
|
+
plan = spec.get("plan", {})
|
|
334
|
+
status = plan.get("status", "") if isinstance(plan, dict) else spec.get("status", "")
|
|
335
|
+
|
|
336
|
+
renderable_statuses = ("approved", "running", "completed")
|
|
337
|
+
if status not in renderable_statuses:
|
|
338
|
+
return (
|
|
339
|
+
False,
|
|
340
|
+
f"⚠ specification.vbrief.json status is '{status}' "
|
|
341
|
+
f"(expected one of {renderable_statuses})\n"
|
|
342
|
+
" Have the user review and set status to one of the"
|
|
343
|
+
" renderable statuses before rendering.",
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# Canonical 4-line banner prepended to every rendered SPECIFICATION.md
|
|
347
|
+
# so operators (and downstream detectors) can recognise the file as
|
|
348
|
+
# machine-managed and re-run `task spec:render` to regenerate it
|
|
349
|
+
# (#572).
|
|
350
|
+
lines: list[str] = [_BANNER]
|
|
351
|
+
|
|
352
|
+
if isinstance(plan, dict):
|
|
353
|
+
title = plan.get("title", "Specification")
|
|
354
|
+
else:
|
|
355
|
+
title = plan or spec.get("title", "Specification")
|
|
356
|
+
lines.append(f"# {title}\n")
|
|
357
|
+
|
|
358
|
+
# Render narratives in declared order, then remaining keys alphabetically.
|
|
359
|
+
# Mirrors prd_render.py behavior -- speckit-shaped specs (ProblemStatement,
|
|
360
|
+
# Goals, Requirements, etc.) must not be silently dropped (#434).
|
|
361
|
+
if isinstance(plan, dict):
|
|
362
|
+
narratives = plan.get("narratives", {})
|
|
363
|
+
if not isinstance(narratives, dict):
|
|
364
|
+
narratives = {}
|
|
365
|
+
else:
|
|
366
|
+
# Legacy flat-format specs may carry overview/description at top level.
|
|
367
|
+
legacy_overview = spec.get("overview") or spec.get("description") or ""
|
|
368
|
+
narratives = {"Overview": legacy_overview} if legacy_overview else {}
|
|
369
|
+
|
|
370
|
+
rendered_keys: set[str] = set()
|
|
371
|
+
for key in SPECIFICATION_NARRATIVE_KEY_ORDER:
|
|
372
|
+
if key in narratives and narratives[key]:
|
|
373
|
+
lines.append(f"## {key}\n")
|
|
374
|
+
lines.append(f"{narratives[key]}\n")
|
|
375
|
+
rendered_keys.add(key)
|
|
376
|
+
|
|
377
|
+
for key in sorted(narratives.keys()):
|
|
378
|
+
if key in rendered_keys or not narratives.get(key):
|
|
379
|
+
continue
|
|
380
|
+
lines.append(f"## {key}\n")
|
|
381
|
+
lines.append(f"{narratives[key]}\n")
|
|
382
|
+
|
|
383
|
+
# Extract items from plan.items (v0.5) or spec.tasks (legacy). Items render
|
|
384
|
+
# after narratives so hybrid/legacy specs (items + narratives) still produce
|
|
385
|
+
# complete output.
|
|
386
|
+
items = plan.get("items", []) if isinstance(plan, dict) else spec.get("tasks", [])
|
|
387
|
+
for item in items:
|
|
388
|
+
item_id = item.get("id", "")
|
|
389
|
+
title_text = item.get("title", "")
|
|
390
|
+
item_status = item.get("status", "")
|
|
391
|
+
lines.append(f"## {item_id}: {title_text} `[{item_status}]`\n")
|
|
392
|
+
|
|
393
|
+
# Dependencies from metadata (v0.5) or inline (legacy)
|
|
394
|
+
deps = None
|
|
395
|
+
if metadata := item.get("metadata"):
|
|
396
|
+
deps = metadata.get("dependencies")
|
|
397
|
+
if not deps:
|
|
398
|
+
deps = item.get("dependencies")
|
|
399
|
+
if deps:
|
|
400
|
+
dep_list = ", ".join(deps)
|
|
401
|
+
lines.append(f"**Depends on**: {dep_list}\n")
|
|
402
|
+
|
|
403
|
+
# Narrative is an object in v0.5, string/list in legacy
|
|
404
|
+
narrative = item.get("narrative")
|
|
405
|
+
if isinstance(narrative, dict):
|
|
406
|
+
for key, val in narrative.items():
|
|
407
|
+
if key == "Traces":
|
|
408
|
+
lines.append(f"**Traces**: {val}\n")
|
|
409
|
+
elif key == "Acceptance":
|
|
410
|
+
for acceptance_line in _split_acceptance(val):
|
|
411
|
+
lines.append(f"- {acceptance_line}")
|
|
412
|
+
lines.append("")
|
|
413
|
+
else:
|
|
414
|
+
lines.append(f"{val}\n")
|
|
415
|
+
elif isinstance(narrative, list):
|
|
416
|
+
for entry in narrative:
|
|
417
|
+
lines.append(f"- {entry}")
|
|
418
|
+
lines.append("")
|
|
419
|
+
elif narrative:
|
|
420
|
+
lines.append(f"{narrative}\n")
|
|
421
|
+
|
|
422
|
+
# Aggregator: append Implementation Plan from lifecycle folders (#435).
|
|
423
|
+
if include_scopes:
|
|
424
|
+
vbrief_dir = Path(spec_path).resolve().parent
|
|
425
|
+
scope_lines = _aggregate_scope_section(vbrief_dir)
|
|
426
|
+
if scope_lines:
|
|
427
|
+
lines.extend(scope_lines)
|
|
428
|
+
|
|
429
|
+
Path(out_path).write_text("\n".join(lines), encoding="utf-8")
|
|
430
|
+
return True, f"✓ Rendered to {out_path}"
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _parse_include_scopes_flag(argv: list[str]) -> tuple[bool, list[str]]:
|
|
434
|
+
"""Extract ``--include-scopes[=on|off]`` from argv.
|
|
435
|
+
|
|
436
|
+
Returns ``(include_scopes, remaining_argv)``. Defaults to True (#435).
|
|
437
|
+
Accepts ``--include-scopes``, ``--include-scopes=on``, ``--include-scopes=off``,
|
|
438
|
+
``--include-scopes=true``, ``--include-scopes=false`` (case-insensitive on value).
|
|
439
|
+
"""
|
|
440
|
+
include = True
|
|
441
|
+
remaining: list[str] = []
|
|
442
|
+
for arg in argv:
|
|
443
|
+
if arg == "--include-scopes":
|
|
444
|
+
include = True
|
|
445
|
+
continue
|
|
446
|
+
if arg.startswith("--include-scopes="):
|
|
447
|
+
value = arg.split("=", 1)[1].lower()
|
|
448
|
+
include = value in ("on", "true", "1", "yes")
|
|
449
|
+
continue
|
|
450
|
+
remaining.append(arg)
|
|
451
|
+
return include, remaining
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def main() -> int:
|
|
455
|
+
include_scopes, positional = _parse_include_scopes_flag(sys.argv[1:])
|
|
456
|
+
if not positional:
|
|
457
|
+
print(
|
|
458
|
+
"Usage: spec_render.py <spec_file> [out_file] [--include-scopes=on|off]",
|
|
459
|
+
file=sys.stderr,
|
|
460
|
+
)
|
|
461
|
+
return 2
|
|
462
|
+
|
|
463
|
+
spec_path = positional[0]
|
|
464
|
+
if len(positional) >= 2:
|
|
465
|
+
out_path = positional[1]
|
|
466
|
+
else:
|
|
467
|
+
# Default: place SPECIFICATION.md at the grandparent of the spec file
|
|
468
|
+
# e.g. vbrief/specification.vbrief.json → SPECIFICATION.md
|
|
469
|
+
out_path = str(Path(spec_path).resolve().parent.parent / "SPECIFICATION.md")
|
|
470
|
+
|
|
471
|
+
ok, message = render_spec(spec_path, out_path, include_scopes=include_scopes)
|
|
472
|
+
print(message)
|
|
473
|
+
return 0 if ok else 1
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
if __name__ == "__main__":
|
|
477
|
+
sys.exit(main())
|