@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,311 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""pack_migrate_strategies.py -- one-shot migration: strategies/*.md -> pack.
|
|
3
|
+
|
|
4
|
+
Builds the canonical structured source ``packs/strategies/strategies-pack-0.1.json``
|
|
5
|
+
(the source of truth per ADR-001) by scanning every ``strategies/*.md``. This is
|
|
6
|
+
the #1296 generalization of the #1294 lessons pilot + #1295 skills pack: the same
|
|
7
|
+
render/slice machinery, a fourth domain.
|
|
8
|
+
|
|
9
|
+
What is captured per strategy
|
|
10
|
+
-----------------------------
|
|
11
|
+
- ``id`` the slugified doc stem (e.g. yolo, bdd, v0-20-contract).
|
|
12
|
+
- ``title`` the leading ``# `` heading text, verbatim.
|
|
13
|
+
- ``description`` the leading description paragraph after the title, folded to a
|
|
14
|
+
single normalised string (Legend / See-also / HTML-comment / rule chrome
|
|
15
|
+
skipped). Empty when the doc has no leading paragraph.
|
|
16
|
+
- ``triggers`` invocation keywords for the strategy. Strategy docs carry no
|
|
17
|
+
frontmatter and there is no strategy-routing table, so the derivable trigger
|
|
18
|
+
is the doc stem itself; the list is otherwise empty (per the #1296 scope:
|
|
19
|
+
"if no trigger metadata exists, use an empty list and rely on list").
|
|
20
|
+
- ``path`` the repo-relative ``strategies/<name>.md``.
|
|
21
|
+
- ``body`` the full strategy body (banner-stripped). Captured for EVERY
|
|
22
|
+
non-redirect strategy by default (packs:slice v2, #1637) so every
|
|
23
|
+
``strategies/*.md`` is a drift-checked projection; the back-compat
|
|
24
|
+
``--proof-strategy`` flag still restricts capture to one strategy.
|
|
25
|
+
|
|
26
|
+
Pure redirect / deprecation stubs (e.g. ``strategies/brownfield.md`` -> map,
|
|
27
|
+
the superseded ``strategies/roadmap.md``) keep a metadata-only entry with
|
|
28
|
+
``body`` null and are NOT rendered as projections.
|
|
29
|
+
|
|
30
|
+
Usage::
|
|
31
|
+
|
|
32
|
+
uv run python scripts/pack_migrate_strategies.py \\
|
|
33
|
+
[--strategies-dir strategies] [--proof-strategy strategies/yolo.md] \\
|
|
34
|
+
[--out packs/strategies/strategies-pack-0.1.json]
|
|
35
|
+
|
|
36
|
+
Exit codes:
|
|
37
|
+
0 -- migrated successfully
|
|
38
|
+
1 -- strategies dir missing, or no strategies discovered
|
|
39
|
+
2 -- usage error
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import argparse
|
|
45
|
+
import json
|
|
46
|
+
import re
|
|
47
|
+
import sys
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
# Repo root resolved from this file's location (scripts/ -> repo root) so the
|
|
51
|
+
# default paths are CWD-independent.
|
|
52
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
53
|
+
|
|
54
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
55
|
+
from _content_root import content_root # noqa: E402
|
|
56
|
+
|
|
57
|
+
# Shippable content moved under content/ in the source repo and is
|
|
58
|
+
# flattened to the framework root in a consumer deposit (#1875 C1).
|
|
59
|
+
CONTENT_ROOT = content_root(REPO_ROOT)
|
|
60
|
+
DEFAULT_STRATEGIES_DIR = CONTENT_ROOT / "strategies"
|
|
61
|
+
DEFAULT_OUT = CONTENT_ROOT / "packs" / "strategies" / "strategies-pack-0.1.json"
|
|
62
|
+
|
|
63
|
+
PACK_ID = "strategies-pack-0.1"
|
|
64
|
+
PACK_VERSION = "0.1"
|
|
65
|
+
|
|
66
|
+
_H1_RE = re.compile(r"^#\s+(.+?)\s*$")
|
|
67
|
+
_SLUG_STRIP_RE = re.compile(r"[^a-z0-9]+")
|
|
68
|
+
|
|
69
|
+
# Lines that open the description-paragraph scan but are document chrome, not a
|
|
70
|
+
# description: the RFC2119 legend, "See also" pointers, HTML comments, and
|
|
71
|
+
# horizontal rules.
|
|
72
|
+
_CHROME_PREFIXES = ("legend ", "legend(", "**legend", "**⚠️", "**see also", "<!--")
|
|
73
|
+
|
|
74
|
+
# Deprecation / redirect marker phrases that flag a pure pointer stub. A doc
|
|
75
|
+
# whose leading content (after the title) is a blockquote carrying one of these
|
|
76
|
+
# markers (e.g. strategies/brownfield.md "legacy alias", strategies/roadmap.md
|
|
77
|
+
# "superseded") is NOT given a captured body and is NOT rendered as a
|
|
78
|
+
# projection -- only non-redirect strategies become drift-checked projections.
|
|
79
|
+
_REDIRECT_MARKERS = (
|
|
80
|
+
"legacy alias",
|
|
81
|
+
"superseded",
|
|
82
|
+
"has been renamed",
|
|
83
|
+
"has moved",
|
|
84
|
+
"deprecated",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_chrome(line: str) -> bool:
|
|
89
|
+
"""True when a line is document chrome rather than a description paragraph."""
|
|
90
|
+
low = line.lstrip().lower()
|
|
91
|
+
if low.startswith(_CHROME_PREFIXES):
|
|
92
|
+
return True
|
|
93
|
+
stripped = line.strip()
|
|
94
|
+
# A horizontal rule (e.g. `---`, `===`) is chrome, not a description.
|
|
95
|
+
return bool(stripped) and set(stripped) <= {"-", "="}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def extract_title(md_text: str) -> str:
|
|
99
|
+
"""Return the leading ``# `` heading text, or '' when absent."""
|
|
100
|
+
for line in md_text.splitlines():
|
|
101
|
+
match = _H1_RE.match(line)
|
|
102
|
+
if match:
|
|
103
|
+
return match.group(1).strip()
|
|
104
|
+
return ""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def extract_description(md_text: str) -> str:
|
|
108
|
+
"""Return the leading description paragraph after the ``# `` title.
|
|
109
|
+
|
|
110
|
+
Scans past the title, skips blank lines and chrome lines (Legend, See-also,
|
|
111
|
+
HTML comments, horizontal rules), then collects the first contiguous block
|
|
112
|
+
of non-blank, non-chrome lines and folds it to a single normalised string.
|
|
113
|
+
A leading blockquote marker (``> ``) is stripped so redirect/superseded
|
|
114
|
+
notes still yield a readable description.
|
|
115
|
+
"""
|
|
116
|
+
lines = md_text.splitlines()
|
|
117
|
+
i = 0
|
|
118
|
+
n = len(lines)
|
|
119
|
+
# Advance to just past the first H1.
|
|
120
|
+
while i < n and not _H1_RE.match(lines[i]):
|
|
121
|
+
i += 1
|
|
122
|
+
if i < n:
|
|
123
|
+
i += 1 # skip the title line itself
|
|
124
|
+
# Skip leading blanks / chrome before the description.
|
|
125
|
+
while i < n and (lines[i].strip() == "" or _is_chrome(lines[i])):
|
|
126
|
+
i += 1
|
|
127
|
+
# Collect the first contiguous non-blank block. A markdown heading is a
|
|
128
|
+
# section boundary, not a description -- a doc whose first content after the
|
|
129
|
+
# title is a `## ` heading has no leading description paragraph.
|
|
130
|
+
block: list[str] = []
|
|
131
|
+
while i < n and lines[i].strip() != "" and not lines[i].lstrip().startswith("#"):
|
|
132
|
+
stripped = lines[i].strip()
|
|
133
|
+
if stripped.startswith(">"):
|
|
134
|
+
stripped = stripped.lstrip(">").strip()
|
|
135
|
+
if stripped:
|
|
136
|
+
block.append(stripped)
|
|
137
|
+
i += 1
|
|
138
|
+
return " ".join(block)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def is_redirect_stub(md_text: str) -> bool:
|
|
142
|
+
"""Return True when a strategy doc is a pure redirect/deprecation pointer.
|
|
143
|
+
|
|
144
|
+
A stub opens (after its ``# `` title, past blank/chrome lines) with a
|
|
145
|
+
blockquote admonition that carries a deprecation marker (``legacy alias``,
|
|
146
|
+
``superseded``, ``has been renamed``, ...). The strategies dir carries no
|
|
147
|
+
YAML frontmatter, so unlike the skills pack (which keys off missing
|
|
148
|
+
frontmatter) the structural redirect signal is this leading-blockquote +
|
|
149
|
+
marker pair. Such files (e.g. brownfield -> map, the superseded roadmap
|
|
150
|
+
strategy) keep a metadata-only pack entry (``body`` null) and are NOT
|
|
151
|
+
rendered as projections.
|
|
152
|
+
"""
|
|
153
|
+
lines = md_text.splitlines()
|
|
154
|
+
i = 0
|
|
155
|
+
n = len(lines)
|
|
156
|
+
while i < n and not _H1_RE.match(lines[i]):
|
|
157
|
+
i += 1
|
|
158
|
+
if i < n:
|
|
159
|
+
i += 1 # skip the title line itself
|
|
160
|
+
while i < n and (lines[i].strip() == "" or _is_chrome(lines[i])):
|
|
161
|
+
i += 1
|
|
162
|
+
# The leading content after the title must be a blockquote pointer.
|
|
163
|
+
if i >= n or not lines[i].lstrip().startswith(">"):
|
|
164
|
+
return False
|
|
165
|
+
block: list[str] = []
|
|
166
|
+
while i < n and lines[i].lstrip().startswith(">"):
|
|
167
|
+
block.append(lines[i].lstrip().lstrip(">").strip())
|
|
168
|
+
i += 1
|
|
169
|
+
quote = " ".join(block).lower()
|
|
170
|
+
return any(marker in quote for marker in _REDIRECT_MARKERS)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def strip_leading_banner(body: str) -> str:
|
|
174
|
+
"""Strip a leading provenance banner + blank lines from a captured body.
|
|
175
|
+
|
|
176
|
+
Makes re-migration idempotent: after the proof strategy is regenerated
|
|
177
|
+
(banner + body), re-running the migration recovers the same body. Only
|
|
178
|
+
strips a banner block that opens with the renderer's first banner line, so
|
|
179
|
+
unrelated leading HTML comments survive.
|
|
180
|
+
"""
|
|
181
|
+
lines = body.split("\n")
|
|
182
|
+
i = 0
|
|
183
|
+
while i < len(lines) and lines[i].strip() == "":
|
|
184
|
+
i += 1
|
|
185
|
+
if i < len(lines) and lines[i].startswith(
|
|
186
|
+
"<!-- AUTO-GENERATED by task packs:render"
|
|
187
|
+
):
|
|
188
|
+
while i < len(lines) and lines[i].lstrip().startswith("<!--"):
|
|
189
|
+
i += 1
|
|
190
|
+
while i < len(lines) and lines[i].strip() == "":
|
|
191
|
+
i += 1
|
|
192
|
+
return "\n".join(lines[i:])
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def build_strategy_entry(
|
|
196
|
+
md: Path, strategies_dir: Path, *, capture_body: bool
|
|
197
|
+
) -> dict:
|
|
198
|
+
"""Build one strategy entry from its markdown file."""
|
|
199
|
+
rel_path = md.resolve().relative_to(strategies_dir.resolve().parent).as_posix()
|
|
200
|
+
stem_slug = _SLUG_STRIP_RE.sub("-", md.stem.lower()).strip("-")
|
|
201
|
+
text = md.read_text(encoding="utf-8")
|
|
202
|
+
return {
|
|
203
|
+
"id": stem_slug,
|
|
204
|
+
"title": extract_title(text),
|
|
205
|
+
"description": extract_description(text),
|
|
206
|
+
"triggers": [stem_slug] if stem_slug else [],
|
|
207
|
+
"path": rel_path,
|
|
208
|
+
"body": strip_leading_banner(text) if capture_body else None,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def build_pack(strategies_dir: Path, *, proof_strategy: str | None) -> dict:
|
|
213
|
+
"""Scan the strategies dir and assemble the full pack object.
|
|
214
|
+
|
|
215
|
+
``proof_strategy`` is the back-compat single-strategy restrictor: when
|
|
216
|
+
``None`` (the default, packs:slice v2 / #1637) the body is captured for
|
|
217
|
+
EVERY non-redirect strategy; when set, only that one strategy's body is
|
|
218
|
+
captured (the #1296 proof shape). Pure redirect/deprecation stubs never
|
|
219
|
+
carry a captured body regardless.
|
|
220
|
+
"""
|
|
221
|
+
capture_all = proof_strategy is None
|
|
222
|
+
strategies: list[dict] = []
|
|
223
|
+
for md in sorted(strategies_dir.glob("*.md")):
|
|
224
|
+
rel_path = md.resolve().relative_to(strategies_dir.resolve().parent).as_posix()
|
|
225
|
+
if capture_all:
|
|
226
|
+
capture_body = not is_redirect_stub(md.read_text(encoding="utf-8"))
|
|
227
|
+
else:
|
|
228
|
+
capture_body = rel_path == proof_strategy
|
|
229
|
+
strategies.append(
|
|
230
|
+
build_strategy_entry(md, strategies_dir, capture_body=capture_body)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
"pack": PACK_ID,
|
|
235
|
+
"version": PACK_VERSION,
|
|
236
|
+
"generated_from": "strategies/*.md",
|
|
237
|
+
"strategies": strategies,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def migrate(strategies_dir: Path, out: Path, *, proof_strategy: str | None) -> dict:
|
|
242
|
+
"""Build the pack from ``strategies_dir`` and write it to ``out``.
|
|
243
|
+
|
|
244
|
+
Raises ``FileNotFoundError`` when the dir is missing and ``ValueError`` when
|
|
245
|
+
no strategies are discovered.
|
|
246
|
+
"""
|
|
247
|
+
if not strategies_dir.is_dir():
|
|
248
|
+
raise FileNotFoundError(f"strategies directory not found: {strategies_dir}")
|
|
249
|
+
|
|
250
|
+
pack = build_pack(strategies_dir, proof_strategy=proof_strategy)
|
|
251
|
+
if not pack["strategies"]:
|
|
252
|
+
raise ValueError(f"no strategies discovered under {strategies_dir}")
|
|
253
|
+
|
|
254
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
# ensure_ascii=True: the canonical source is serialized as pure ASCII with
|
|
256
|
+
# \uXXXX escapes (mirrors the other pack migrations). Lossless and keeps the
|
|
257
|
+
# JSON clean against `task verify:encoding` (#798) even when a strategy body
|
|
258
|
+
# carries non-ASCII glyphs (em dashes, RFC2119 symbols, emoji in diagrams).
|
|
259
|
+
out.write_text(
|
|
260
|
+
json.dumps(pack, indent=2, ensure_ascii=True) + "\n", encoding="utf-8"
|
|
261
|
+
)
|
|
262
|
+
return pack
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def main(argv: list[str] | None = None) -> int:
|
|
266
|
+
parser = argparse.ArgumentParser(
|
|
267
|
+
prog="pack_migrate_strategies.py",
|
|
268
|
+
description="Migrate strategies/*.md into the strategies-pack-0.1 source.",
|
|
269
|
+
)
|
|
270
|
+
parser.add_argument(
|
|
271
|
+
"--strategies-dir",
|
|
272
|
+
type=Path,
|
|
273
|
+
default=DEFAULT_STRATEGIES_DIR,
|
|
274
|
+
help="Directory of strategy docs to scan (default: strategies/).",
|
|
275
|
+
)
|
|
276
|
+
parser.add_argument(
|
|
277
|
+
"--proof-strategy",
|
|
278
|
+
default=None,
|
|
279
|
+
help="Back-compat: restrict body capture to ONE strategy's repo-relative "
|
|
280
|
+
"path (e.g. strategies/yolo.md). Default: capture every non-redirect "
|
|
281
|
+
"strategy's body (#1637).",
|
|
282
|
+
)
|
|
283
|
+
parser.add_argument(
|
|
284
|
+
"--out",
|
|
285
|
+
type=Path,
|
|
286
|
+
default=DEFAULT_OUT,
|
|
287
|
+
help="Output pack JSON path (default: packs/strategies/strategies-pack-0.1.json).",
|
|
288
|
+
)
|
|
289
|
+
args = parser.parse_args(argv)
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
pack = migrate(
|
|
293
|
+
args.strategies_dir, args.out, proof_strategy=args.proof_strategy
|
|
294
|
+
)
|
|
295
|
+
except FileNotFoundError as exc:
|
|
296
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
297
|
+
return 1
|
|
298
|
+
except ValueError as exc:
|
|
299
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
300
|
+
return 1
|
|
301
|
+
|
|
302
|
+
bodied = sum(1 for s in pack["strategies"] if s["body"] is not None)
|
|
303
|
+
print(
|
|
304
|
+
f"Migrated {len(pack['strategies'])} strategies ({bodied} with body) "
|
|
305
|
+
f"-> {args.out}"
|
|
306
|
+
)
|
|
307
|
+
return 0
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
if __name__ == "__main__":
|
|
311
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""pack_migrate_swarm_spec.py -- one-shot migration: swarm/*.md -> pack.
|
|
3
|
+
|
|
4
|
+
Builds the canonical structured source
|
|
5
|
+
``packs/swarm-spec/swarm-spec-pack-0.1.json`` (the source of truth per ADR-001)
|
|
6
|
+
by scanning every ``swarm/*.md``. This is the #1637 generalization of the #1294
|
|
7
|
+
lessons pilot + #1295 skills pack + #1296 rules/strategies packs + the patterns
|
|
8
|
+
pack: the same render/slice machinery, applied to the swarm specification. The
|
|
9
|
+
``swarm-spec`` pack was a candidate on the #1283 Q-list, landed in packs:slice
|
|
10
|
+
v2 (#1637).
|
|
11
|
+
|
|
12
|
+
What is captured per entry
|
|
13
|
+
--------------------------
|
|
14
|
+
- ``id`` the slugified doc stem (e.g. swarm).
|
|
15
|
+
- ``title`` the leading ``# `` heading text, verbatim.
|
|
16
|
+
- ``description`` the leading description paragraph after the title, folded to a
|
|
17
|
+
single normalised string (Legend / See-also / HTML-comment / rule chrome
|
|
18
|
+
skipped). Empty when the doc has no leading paragraph.
|
|
19
|
+
- ``triggers`` invocation keywords for the entry. Swarm-spec docs carry no
|
|
20
|
+
frontmatter and there is no routing table, so the derivable trigger is the
|
|
21
|
+
doc stem itself; the list is otherwise empty (mirrors the #1296 strategies
|
|
22
|
+
scope).
|
|
23
|
+
- ``path`` the repo-relative ``swarm/<name>.md``.
|
|
24
|
+
- ``body`` the full body (banner-stripped) for each proof entry. The
|
|
25
|
+
swarm spec is a single canonical document today, so its lone entry IS the
|
|
26
|
+
proof and carries its body; ``--proof-entry`` can override the captured set.
|
|
27
|
+
|
|
28
|
+
Usage::
|
|
29
|
+
|
|
30
|
+
uv run python scripts/pack_migrate_swarm_spec.py \\
|
|
31
|
+
[--swarm-dir swarm] [--proof-entry swarm/swarm.md] \\
|
|
32
|
+
[--out packs/swarm-spec/swarm-spec-pack-0.1.json]
|
|
33
|
+
|
|
34
|
+
Exit codes:
|
|
35
|
+
0 -- migrated successfully
|
|
36
|
+
1 -- swarm dir missing, or no entries discovered
|
|
37
|
+
2 -- usage error
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import argparse
|
|
43
|
+
import json
|
|
44
|
+
import re
|
|
45
|
+
import sys
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
# Repo root resolved from this file's location (scripts/ -> repo root) so the
|
|
49
|
+
# default paths are CWD-independent.
|
|
50
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
51
|
+
|
|
52
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
53
|
+
from _content_root import content_root # noqa: E402
|
|
54
|
+
|
|
55
|
+
# Shippable content moved under content/ in the source repo and is
|
|
56
|
+
# flattened to the framework root in a consumer deposit (#1875 C1).
|
|
57
|
+
CONTENT_ROOT = content_root(REPO_ROOT)
|
|
58
|
+
DEFAULT_SWARM_DIR = CONTENT_ROOT / "swarm"
|
|
59
|
+
DEFAULT_OUT = CONTENT_ROOT / "packs" / "swarm-spec" / "swarm-spec-pack-0.1.json"
|
|
60
|
+
|
|
61
|
+
PACK_ID = "swarm-spec-pack-0.1"
|
|
62
|
+
PACK_VERSION = "0.1"
|
|
63
|
+
|
|
64
|
+
# The single proof ENTRY whose full body is captured + regenerated as a
|
|
65
|
+
# banner-marked, drift-checked projection. The swarm spec is one doc today.
|
|
66
|
+
DEFAULT_PROOF_ENTRY = "swarm/swarm.md"
|
|
67
|
+
|
|
68
|
+
_H1_RE = re.compile(r"^#\s+(.+?)\s*$")
|
|
69
|
+
_SLUG_STRIP_RE = re.compile(r"[^a-z0-9]+")
|
|
70
|
+
|
|
71
|
+
# Lines that open the description-paragraph scan but are document chrome, not a
|
|
72
|
+
# description: the RFC2119 legend, "See also" pointers, HTML comments, and
|
|
73
|
+
# horizontal rules.
|
|
74
|
+
_CHROME_PREFIXES = ("legend ", "legend(", "**legend", "**⚠️", "**see also", "<!--")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_chrome(line: str) -> bool:
|
|
78
|
+
"""True when a line is document chrome rather than a description paragraph."""
|
|
79
|
+
low = line.lstrip().lower()
|
|
80
|
+
if low.startswith(_CHROME_PREFIXES):
|
|
81
|
+
return True
|
|
82
|
+
stripped = line.strip()
|
|
83
|
+
# A horizontal rule (e.g. `---`, `===`) is chrome, not a description.
|
|
84
|
+
return bool(stripped) and set(stripped) <= {"-", "="}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def extract_title(md_text: str) -> str:
|
|
88
|
+
"""Return the leading ``# `` heading text, or '' when absent."""
|
|
89
|
+
for line in md_text.splitlines():
|
|
90
|
+
match = _H1_RE.match(line)
|
|
91
|
+
if match:
|
|
92
|
+
return match.group(1).strip()
|
|
93
|
+
return ""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def extract_description(md_text: str) -> str:
|
|
97
|
+
"""Return the leading description paragraph after the ``# `` title.
|
|
98
|
+
|
|
99
|
+
Scans past the title, skips blank lines and chrome lines (Legend, See-also,
|
|
100
|
+
HTML comments, horizontal rules), then collects the first contiguous block
|
|
101
|
+
of non-blank, non-chrome lines and folds it to a single normalised string.
|
|
102
|
+
A leading blockquote marker (``> ``) is stripped so redirect/superseded
|
|
103
|
+
notes still yield a readable description.
|
|
104
|
+
"""
|
|
105
|
+
lines = md_text.splitlines()
|
|
106
|
+
i = 0
|
|
107
|
+
n = len(lines)
|
|
108
|
+
# Advance to just past the first H1.
|
|
109
|
+
while i < n and not _H1_RE.match(lines[i]):
|
|
110
|
+
i += 1
|
|
111
|
+
if i < n:
|
|
112
|
+
i += 1 # skip the title line itself
|
|
113
|
+
# Skip leading blanks / chrome before the description.
|
|
114
|
+
while i < n and (lines[i].strip() == "" or _is_chrome(lines[i])):
|
|
115
|
+
i += 1
|
|
116
|
+
# Collect the first contiguous non-blank block. A markdown heading is a
|
|
117
|
+
# section boundary, not a description -- a doc whose first content after the
|
|
118
|
+
# title is a `## ` heading has no leading description paragraph.
|
|
119
|
+
block: list[str] = []
|
|
120
|
+
while i < n and lines[i].strip() != "" and not lines[i].lstrip().startswith("#"):
|
|
121
|
+
stripped = lines[i].strip()
|
|
122
|
+
if stripped.startswith(">"):
|
|
123
|
+
stripped = stripped.lstrip(">").strip()
|
|
124
|
+
if stripped:
|
|
125
|
+
block.append(stripped)
|
|
126
|
+
i += 1
|
|
127
|
+
return " ".join(block)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def strip_leading_banner(body: str) -> str:
|
|
131
|
+
"""Strip a leading provenance banner + blank lines from a captured body.
|
|
132
|
+
|
|
133
|
+
Makes re-migration idempotent: after the proof entry is regenerated
|
|
134
|
+
(banner + body), re-running the migration recovers the same body. Only
|
|
135
|
+
strips a banner block that opens with the renderer's first banner line, so
|
|
136
|
+
unrelated leading HTML comments survive.
|
|
137
|
+
"""
|
|
138
|
+
lines = body.split("\n")
|
|
139
|
+
i = 0
|
|
140
|
+
while i < len(lines) and lines[i].strip() == "":
|
|
141
|
+
i += 1
|
|
142
|
+
if i < len(lines) and lines[i].startswith(
|
|
143
|
+
"<!-- AUTO-GENERATED by task packs:render"
|
|
144
|
+
):
|
|
145
|
+
while i < len(lines) and lines[i].lstrip().startswith("<!--"):
|
|
146
|
+
i += 1
|
|
147
|
+
while i < len(lines) and lines[i].strip() == "":
|
|
148
|
+
i += 1
|
|
149
|
+
return "\n".join(lines[i:])
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def build_entry(md: Path, swarm_dir: Path, *, capture_body: bool) -> dict:
|
|
153
|
+
"""Build one swarm-spec entry from its markdown file."""
|
|
154
|
+
rel_path = md.resolve().relative_to(swarm_dir.resolve().parent).as_posix()
|
|
155
|
+
stem_slug = _SLUG_STRIP_RE.sub("-", md.stem.lower()).strip("-")
|
|
156
|
+
text = md.read_text(encoding="utf-8")
|
|
157
|
+
return {
|
|
158
|
+
"id": stem_slug,
|
|
159
|
+
"title": extract_title(text),
|
|
160
|
+
"description": extract_description(text),
|
|
161
|
+
"triggers": [stem_slug] if stem_slug else [],
|
|
162
|
+
"path": rel_path,
|
|
163
|
+
"body": strip_leading_banner(text) if capture_body else None,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_pack(swarm_dir: Path, *, proof_entry: str) -> dict:
|
|
168
|
+
"""Scan the swarm dir and assemble the full pack object."""
|
|
169
|
+
entries: list[dict] = []
|
|
170
|
+
for md in sorted(swarm_dir.glob("*.md")):
|
|
171
|
+
rel_path = md.resolve().relative_to(swarm_dir.resolve().parent).as_posix()
|
|
172
|
+
entries.append(
|
|
173
|
+
build_entry(md, swarm_dir, capture_body=(rel_path == proof_entry))
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
"pack": PACK_ID,
|
|
178
|
+
"version": PACK_VERSION,
|
|
179
|
+
"generated_from": "swarm/*.md",
|
|
180
|
+
"entries": entries,
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def migrate(swarm_dir: Path, out: Path, *, proof_entry: str) -> dict:
|
|
185
|
+
"""Build the pack from ``swarm_dir`` and write it to ``out``.
|
|
186
|
+
|
|
187
|
+
Raises ``FileNotFoundError`` when the dir is missing and ``ValueError`` when
|
|
188
|
+
no entries are discovered.
|
|
189
|
+
"""
|
|
190
|
+
if not swarm_dir.is_dir():
|
|
191
|
+
raise FileNotFoundError(f"swarm directory not found: {swarm_dir}")
|
|
192
|
+
|
|
193
|
+
pack = build_pack(swarm_dir, proof_entry=proof_entry)
|
|
194
|
+
if not pack["entries"]:
|
|
195
|
+
raise ValueError(f"no swarm-spec docs discovered under {swarm_dir}")
|
|
196
|
+
|
|
197
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
# ensure_ascii=True: the canonical source is serialized as pure ASCII with
|
|
199
|
+
# \uXXXX escapes (mirrors the other pack migrations). Lossless and keeps the
|
|
200
|
+
# JSON clean against `task verify:encoding` (#798) even when a body carries
|
|
201
|
+
# non-ASCII glyphs (em dashes, RFC2119 symbols, emoji in diagrams).
|
|
202
|
+
out.write_text(
|
|
203
|
+
json.dumps(pack, indent=2, ensure_ascii=True) + "\n", encoding="utf-8"
|
|
204
|
+
)
|
|
205
|
+
return pack
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def main(argv: list[str] | None = None) -> int:
|
|
209
|
+
parser = argparse.ArgumentParser(
|
|
210
|
+
prog="pack_migrate_swarm_spec.py",
|
|
211
|
+
description="Migrate swarm/*.md into the swarm-spec-pack-0.1 source.",
|
|
212
|
+
)
|
|
213
|
+
parser.add_argument(
|
|
214
|
+
"--swarm-dir",
|
|
215
|
+
type=Path,
|
|
216
|
+
default=DEFAULT_SWARM_DIR,
|
|
217
|
+
help="Directory of swarm-spec docs to scan (default: swarm/).",
|
|
218
|
+
)
|
|
219
|
+
parser.add_argument(
|
|
220
|
+
"--proof-entry",
|
|
221
|
+
default=DEFAULT_PROOF_ENTRY,
|
|
222
|
+
help="Repo-relative path of the entry whose full body is captured.",
|
|
223
|
+
)
|
|
224
|
+
parser.add_argument(
|
|
225
|
+
"--out",
|
|
226
|
+
type=Path,
|
|
227
|
+
default=DEFAULT_OUT,
|
|
228
|
+
help="Output pack JSON path (default: packs/swarm-spec/swarm-spec-pack-0.1.json).",
|
|
229
|
+
)
|
|
230
|
+
args = parser.parse_args(argv)
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
pack = migrate(args.swarm_dir, args.out, proof_entry=args.proof_entry)
|
|
234
|
+
except FileNotFoundError as exc:
|
|
235
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
236
|
+
return 1
|
|
237
|
+
except ValueError as exc:
|
|
238
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
239
|
+
return 1
|
|
240
|
+
|
|
241
|
+
bodied = sum(1 for e in pack["entries"] if e["body"] is not None)
|
|
242
|
+
print(
|
|
243
|
+
f"Migrated {len(pack['entries'])} swarm-spec entries ({bodied} with body) "
|
|
244
|
+
f"-> {args.out}"
|
|
245
|
+
)
|
|
246
|
+
return 0
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
if __name__ == "__main__":
|
|
250
|
+
raise SystemExit(main())
|