@deftai/directive-content 0.59.0 → 0.61.0

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