@deftai/directive-content 0.58.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/.githooks/pre-push +10 -9
  2. package/Taskfile.yml +57 -67
  3. package/UPGRADING.md +1 -1
  4. package/docs/assets/directive-lifecycle-diagram.png +0 -0
  5. package/docs/directive-lifecycle.md +73 -0
  6. package/docs/getting-started.md +5 -1
  7. package/package.json +3 -3
  8. package/packs/rules/rules-pack-0.1.json +3 -3
  9. package/packs/skills/skills-pack-0.1.json +22 -22
  10. package/scm/github.md +20 -2
  11. package/tasks/change.yml +16 -31
  12. package/tasks/ci.yml +8 -0
  13. package/tasks/commit.yml +12 -19
  14. package/tasks/core.yml +10 -0
  15. package/tasks/engine.yml +42 -0
  16. package/tasks/framework.yml +3 -0
  17. package/tasks/install.yml +20 -19
  18. package/tasks/migrate.yml +26 -15
  19. package/tasks/project.yml +16 -0
  20. package/tasks/relocate.yml +18 -48
  21. package/tasks/toolchain.yml +15 -5
  22. package/tasks/vbrief.yml +4 -3
  23. package/tasks/verify.yml +12 -14
  24. package/templates/agents-entry.md +1 -2
  25. package/scripts/_agents_md.py +0 -494
  26. package/scripts/_cache_fetch.py +0 -635
  27. package/scripts/_cache_quota.py +0 -529
  28. package/scripts/_cache_refresh.py +0 -163
  29. package/scripts/_cache_validate.py +0 -209
  30. package/scripts/_content_root.py +0 -42
  31. package/scripts/_doctor_state.py +0 -277
  32. package/scripts/_event_detect.py +0 -305
  33. package/scripts/_events.py +0 -514
  34. package/scripts/_lifecycle_hygiene.py +0 -568
  35. package/scripts/_pathspec.py +0 -91
  36. package/scripts/_policy_show_cli.py +0 -266
  37. package/scripts/_precutover.py +0 -92
  38. package/scripts/_project_context.py +0 -224
  39. package/scripts/_project_definition_io.py +0 -164
  40. package/scripts/_relocate_snapshot.py +0 -209
  41. package/scripts/_relocate_states.py +0 -343
  42. package/scripts/_resolve_preflight_path.py +0 -152
  43. package/scripts/_safe_subprocess.py +0 -167
  44. package/scripts/_session_start_hook.py +0 -205
  45. package/scripts/_sor_gate_diff.py +0 -365
  46. package/scripts/_stdio_utf8.py +0 -59
  47. package/scripts/_triage_bootstrap_gitignore.py +0 -904
  48. package/scripts/_triage_classify_cli.py +0 -122
  49. package/scripts/_triage_queue_cli.py +0 -625
  50. package/scripts/_triage_scope_cli.py +0 -343
  51. package/scripts/_triage_scope_drift_cli.py +0 -121
  52. package/scripts/_triage_scope_ignores.py +0 -286
  53. package/scripts/_triage_scope_milestone.py +0 -432
  54. package/scripts/_triage_scope_mutations.py +0 -337
  55. package/scripts/_triage_scope_renderers.py +0 -207
  56. package/scripts/_triage_smoketest_stages.py +0 -674
  57. package/scripts/_triage_subscribe_cli.py +0 -140
  58. package/scripts/_triage_welcome_cli.py +0 -421
  59. package/scripts/_vbrief_build.py +0 -239
  60. package/scripts/_vbrief_fidelity.py +0 -479
  61. package/scripts/_vbrief_legacy.py +0 -589
  62. package/scripts/_vbrief_reconciliation.py +0 -883
  63. package/scripts/_vbrief_routing.py +0 -277
  64. package/scripts/_vbrief_safety.py +0 -778
  65. package/scripts/_vbrief_sources.py +0 -312
  66. package/scripts/_vbrief_speckit.py +0 -262
  67. package/scripts/_vbrief_story_quality.py +0 -353
  68. package/scripts/_vbrief_validation.py +0 -299
  69. package/scripts/build_dist.py +0 -412
  70. package/scripts/cache.py +0 -1078
  71. package/scripts/cache_scanner.py +0 -745
  72. package/scripts/candidates_log.py +0 -432
  73. package/scripts/capacity_backfill.py +0 -680
  74. package/scripts/capacity_show.py +0 -653
  75. package/scripts/ci_local.py +0 -689
  76. package/scripts/code_structure_validate.py +0 -765
  77. package/scripts/codebase_default_extractor.py +0 -495
  78. package/scripts/codebase_map.py +0 -304
  79. package/scripts/codebase_map_fresh.py +0 -104
  80. package/scripts/codebase_projection_registry.py +0 -94
  81. package/scripts/codebase_provider.py +0 -582
  82. package/scripts/doctor.py +0 -2551
  83. package/scripts/framework_commands.py +0 -505
  84. package/scripts/gh_rest.py +0 -882
  85. package/scripts/github_auth_modes.py +0 -437
  86. package/scripts/github_body.py +0 -292
  87. package/scripts/ip_risk.py +0 -531
  88. package/scripts/issue_emit.py +0 -670
  89. package/scripts/issue_ingest.py +0 -1064
  90. package/scripts/migrate_preflight.py +0 -418
  91. package/scripts/migrate_vbrief.py +0 -2677
  92. package/scripts/monitor_pr.py +0 -401
  93. package/scripts/pack_migrate_lessons.py +0 -336
  94. package/scripts/pack_migrate_patterns.py +0 -254
  95. package/scripts/pack_migrate_rules.py +0 -350
  96. package/scripts/pack_migrate_skills.py +0 -423
  97. package/scripts/pack_migrate_strategies.py +0 -311
  98. package/scripts/pack_migrate_swarm_spec.py +0 -250
  99. package/scripts/pack_render.py +0 -434
  100. package/scripts/packs_slice.py +0 -712
  101. package/scripts/platform_capabilities.py +0 -336
  102. package/scripts/policy.py +0 -2826
  103. package/scripts/policy_set.py +0 -324
  104. package/scripts/pr_check_closing_keywords.py +0 -524
  105. package/scripts/pr_check_protected_issues.py +0 -267
  106. package/scripts/pr_merge_readiness.py +0 -1004
  107. package/scripts/pr_wait_mergeable.py +0 -669
  108. package/scripts/prd_render.py +0 -159
  109. package/scripts/preflight_architecture_sor.py +0 -974
  110. package/scripts/preflight_branch.py +0 -289
  111. package/scripts/preflight_cache.py +0 -974
  112. package/scripts/preflight_gh.py +0 -721
  113. package/scripts/preflight_implementation.py +0 -272
  114. package/scripts/preflight_story_start.py +0 -838
  115. package/scripts/preflight_wip_cap.py +0 -149
  116. package/scripts/probe_session.py +0 -545
  117. package/scripts/project_render.py +0 -293
  118. package/scripts/quarantine_ext.py +0 -237
  119. package/scripts/reconcile_issues.py +0 -1442
  120. package/scripts/refresh-path.ps1 +0 -107
  121. package/scripts/release.py +0 -2030
  122. package/scripts/release_e2e.py +0 -1011
  123. package/scripts/release_publish.py +0 -486
  124. package/scripts/release_rollback.py +0 -980
  125. package/scripts/relocate.py +0 -1034
  126. package/scripts/resolve_changelog_unreleased.py +0 -667
  127. package/scripts/resolve_version.py +0 -490
  128. package/scripts/resume_conditions.py +0 -706
  129. package/scripts/ritual_sentinel.py +0 -609
  130. package/scripts/roadmap_render.py +0 -635
  131. package/scripts/rule_ownership_lint.py +0 -325
  132. package/scripts/scm.py +0 -591
  133. package/scripts/scope_audit_log.py +0 -387
  134. package/scripts/scope_decompose.py +0 -654
  135. package/scripts/scope_demote.py +0 -509
  136. package/scripts/scope_lifecycle.py +0 -1126
  137. package/scripts/scope_undo.py +0 -772
  138. package/scripts/session_start.py +0 -406
  139. package/scripts/setup_ghx.py +0 -339
  140. package/scripts/setup_windows.ps1 +0 -220
  141. package/scripts/slice_audit.py +0 -585
  142. package/scripts/slice_record.py +0 -530
  143. package/scripts/slice_record_existing.py +0 -692
  144. package/scripts/slug_normalize.py +0 -178
  145. package/scripts/spec_render.py +0 -477
  146. package/scripts/spec_validate.py +0 -238
  147. package/scripts/subagent_monitor.py +0 -658
  148. package/scripts/swarm_complete_cohort.py +0 -644
  149. package/scripts/swarm_launch.py +0 -1206
  150. package/scripts/swarm_readiness.py +0 -554
  151. package/scripts/swarm_verify_review_clean.py +0 -438
  152. package/scripts/swarm_worktrees.py +0 -497
  153. package/scripts/toolchain-check.py +0 -52
  154. package/scripts/triage_actions.py +0 -871
  155. package/scripts/triage_bootstrap.py +0 -1153
  156. package/scripts/triage_bulk.py +0 -630
  157. package/scripts/triage_classify.py +0 -932
  158. package/scripts/triage_help.py +0 -1685
  159. package/scripts/triage_queue.py +0 -1944
  160. package/scripts/triage_reconcile.py +0 -581
  161. package/scripts/triage_refresh.py +0 -643
  162. package/scripts/triage_scope.py +0 -999
  163. package/scripts/triage_scope_drift.py +0 -575
  164. package/scripts/triage_smoketest.py +0 -396
  165. package/scripts/triage_subscribe.py +0 -399
  166. package/scripts/triage_summary.py +0 -1011
  167. package/scripts/triage_welcome.py +0 -1178
  168. package/scripts/ts_check_lane.py +0 -86
  169. package/scripts/validate-links.py +0 -64
  170. package/scripts/validate_strategy_output.py +0 -212
  171. package/scripts/vbrief_activate.py +0 -228
  172. package/scripts/vbrief_migrate_conformance.py +0 -368
  173. package/scripts/vbrief_reconcile_graph.py +0 -306
  174. package/scripts/vbrief_reconcile_labels.py +0 -460
  175. package/scripts/vbrief_reconcile_umbrellas.py +0 -741
  176. package/scripts/vbrief_validate.py +0 -1144
  177. package/scripts/verify-stubs.py +0 -61
  178. package/scripts/verify_capacity.py +0 -160
  179. package/scripts/verify_encoding.py +0 -699
  180. package/scripts/verify_hooks_installed.py +0 -206
  181. package/scripts/verify_investigation.py +0 -360
  182. package/scripts/verify_judgment_gates.py +0 -827
  183. package/scripts/verify_no_task_runtime.py +0 -171
  184. package/scripts/verify_scm_boundary.py +0 -509
  185. package/scripts/verify_session_ritual.py +0 -389
  186. package/scripts/verify_tools.py +0 -426
  187. package/scripts/verify_vbrief_conformance.py +0 -478
@@ -1,336 +0,0 @@
1
- #!/usr/bin/env python3
2
- """pack_migrate_lessons.py -- one-shot migration: meta/lessons.md -> structured pack.
3
-
4
- Parses the hand-authored ``meta/lessons.md`` into the canonical structured
5
- source ``packs/lessons/lessons-pack-0.1.json`` (the source of truth per
6
- ADR-001; ``meta/lessons.md`` then becomes a regenerated projection via
7
- ``scripts/pack_render.py``).
8
-
9
- Parsing model
10
- -------------
11
- The document is split on top-level ``## `` headings. Everything before the
12
- first ``## `` heading (the ``# Lessons Learned`` title + any authoring
13
- comment) is document chrome owned by the renderer and is discarded here.
14
- For each section:
15
-
16
- - ``title`` is the full heading text (verbatim, so the projection round-trips).
17
- - ``date`` is the ``YYYY-MM`` extracted from the heading parenthetical, or null.
18
- - ``issue_refs`` is every ``#NNN`` reference in the heading, in order.
19
- - ``source`` is the text of the body's ``**Source:**`` line, or null.
20
- - ``tags`` is 1-3 tags from the controlled vocabulary, scored from keywords.
21
- - ``body`` is the full section body verbatim (lossless blob).
22
-
23
- Usage::
24
-
25
- uv run python scripts/pack_migrate_lessons.py [--source meta/lessons.md] \\
26
- [--out packs/lessons/lessons-pack-0.1.json]
27
-
28
- Exit codes:
29
- 0 -- migrated successfully
30
- 1 -- source markdown missing or empty
31
- 2 -- usage error
32
- """
33
-
34
- from __future__ import annotations
35
-
36
- import argparse
37
- import json
38
- import re
39
- import sys
40
- from pathlib import Path
41
-
42
- # Repo root resolved from this file's location (scripts/ -> repo root). Used to
43
- # anchor the default source / output paths so the migration is CWD-independent.
44
- REPO_ROOT = Path(__file__).resolve().parent.parent
45
-
46
- sys.path.insert(0, str(Path(__file__).resolve().parent))
47
- from _content_root import content_root # noqa: E402
48
-
49
- # Shippable content moved under content/ in the source repo and is
50
- # flattened to the framework root in a consumer deposit (#1875 C1).
51
- CONTENT_ROOT = content_root(REPO_ROOT)
52
- DEFAULT_SOURCE = REPO_ROOT / "meta" / "lessons.md"
53
- DEFAULT_OUT = CONTENT_ROOT / "packs" / "lessons" / "lessons-pack-0.1.json"
54
-
55
- PACK_ID = "lessons-pack-0.1"
56
- PACK_VERSION = "0.1"
57
-
58
- # Controlled tag vocabulary. MUST stay in lockstep with the `enum` and
59
- # `x-tagVocabulary` in vbrief/schemas/lessons-pack.schema.json.
60
- TAG_VOCABULARY: tuple[str, ...] = (
61
- "windows",
62
- "encoding",
63
- "review-cycle",
64
- "swarm",
65
- "release",
66
- "github",
67
- "context",
68
- "debugging",
69
- "lifecycle",
70
- "powershell",
71
- "ci",
72
- "agent-orchestration",
73
- )
74
-
75
- # Keyword -> tag scoring table. Matches are lowercase substring tests against
76
- # the title (weighted heavily) and body (weighted lightly). The tokens are
77
- # deliberately specific to keep the controlled vocabulary discriminating.
78
- TAG_KEYWORDS: dict[str, tuple[str, ...]] = {
79
- "windows": ("windows", "cp1252", "cp437", "charmap", "win32", "winerror"),
80
- "encoding": (
81
- "encoding",
82
- "utf-8",
83
- "utf8",
84
- "mojibake",
85
- "non-ascii",
86
- "u+fffd",
87
- " bom",
88
- "unicodedecode",
89
- "unicodeencode",
90
- ),
91
- "review-cycle": (
92
- "review cycle",
93
- "review-cycle",
94
- "greptile",
95
- "review bot",
96
- "checkrun",
97
- "check run",
98
- ),
99
- "swarm": ("swarm", "parallel agent", "worktree", "cohort", "cascade"),
100
- "release": ("release", "changelog", "publish", "v0.", "tag time", "cut session"),
101
- "github": (
102
- "github",
103
- "gh api",
104
- "gh cli",
105
- "gh issue",
106
- "gh pr",
107
- "graphql",
108
- "rest",
109
- "pull request",
110
- "closingissues",
111
- ),
112
- "context": ("context engineering", "context rot", "context window", "token", "low-signal"),
113
- "debugging": (
114
- "debug",
115
- "root cause",
116
- "root-cause",
117
- "investigation",
118
- "forensic",
119
- "blind spot",
120
- ),
121
- "lifecycle": (
122
- "lifecycle",
123
- "vbrief",
124
- "scope:",
125
- "promote",
126
- "activate",
127
- "reconcile",
128
- ),
129
- "powershell": (
130
- "powershell",
131
- "pwsh",
132
- "ps 5.1",
133
- "ps5.1",
134
- "get-content",
135
- "set-content",
136
- "here-string",
137
- ),
138
- "ci": ("pre-commit", "task check", "deterministic gate", " gate ", "pipeline", "self-test"),
139
- "agent-orchestration": (
140
- "orchestrat",
141
- "poller",
142
- "dispatch",
143
- "sub-agent",
144
- "subagent",
145
- "agent run",
146
- "spawn",
147
- "monitor agent",
148
- ),
149
- }
150
-
151
- # Fallback tag when no keyword scores -- every entry must carry >= 1 tag.
152
- FALLBACK_TAG = "agent-orchestration"
153
-
154
- _HEADING_RE = re.compile(r"^## (.+)$")
155
- _DATE_RE = re.compile(r"(\d{4}-\d{2})(?:-\d{2})?")
156
- _ISSUE_RE = re.compile(r"#(\d+)")
157
- _SOURCE_RE = re.compile(r"^\*\*Source:\*\*\s*(.+?)\s*$")
158
- _SLUG_STRIP_RE = re.compile(r"[^a-z0-9]+")
159
-
160
-
161
- def extract_date(title: str) -> str | None:
162
- """Return the YYYY-MM date from a heading, or None when absent."""
163
- match = _DATE_RE.search(title)
164
- return match.group(1) if match else None
165
-
166
-
167
- def extract_issue_refs(title: str) -> list[str]:
168
- """Return all ``#NNN`` references in a heading, in order of appearance."""
169
- return [f"#{n}" for n in _ISSUE_RE.findall(title)]
170
-
171
-
172
- def extract_source(body: str) -> str | None:
173
- """Return the text of the first ``**Source:**`` line in the body, or None."""
174
- for line in body.splitlines():
175
- match = _SOURCE_RE.match(line.strip())
176
- if match:
177
- return match.group(1)
178
- return None
179
-
180
-
181
- def slugify(title: str, existing: set[str]) -> str:
182
- """Derive a stable, unique, lowercase slug from a title.
183
-
184
- Strips a trailing parenthetical date / issue ref before slugifying so the
185
- id stays readable, then de-duplicates against ``existing`` by appending a
186
- numeric suffix.
187
- """
188
- base = _SLUG_STRIP_RE.sub("-", title.lower()).strip("-")
189
- if not base:
190
- base = "lesson"
191
- slug = base
192
- counter = 2
193
- while slug in existing:
194
- slug = f"{base}-{counter}"
195
- counter += 1
196
- existing.add(slug)
197
- return slug
198
-
199
-
200
- def assign_tags(title: str, body: str) -> list[str]:
201
- """Assign 1-3 controlled-vocabulary tags by keyword scoring.
202
-
203
- Title matches weigh 5x body matches. The top-scoring tags (score > 0) are
204
- returned, capped at 3, ordered by score desc then by vocabulary order for
205
- determinism. Falls back to a single default tag when nothing scores.
206
- """
207
- title_l = title.lower()
208
- body_l = body.lower()
209
- scores: dict[str, int] = {}
210
- for tag in TAG_VOCABULARY:
211
- score = 0
212
- for kw in TAG_KEYWORDS[tag]:
213
- if kw in title_l:
214
- score += 5
215
- score += body_l.count(kw)
216
- if score > 0:
217
- scores[tag] = score
218
-
219
- if not scores:
220
- return [FALLBACK_TAG]
221
-
222
- vocab_order = {tag: i for i, tag in enumerate(TAG_VOCABULARY)}
223
- ranked = sorted(scores.items(), key=lambda kv: (-kv[1], vocab_order[kv[0]]))
224
- return [tag for tag, _ in ranked[:3]]
225
-
226
-
227
- def parse_lessons(md_text: str) -> list[dict]:
228
- """Parse lessons markdown into structured lesson entries.
229
-
230
- Splits on top-level ``## `` headings; content before the first heading is
231
- discarded (renderer-owned chrome). Returns entries in document order.
232
- """
233
- lines = md_text.splitlines()
234
- # Collect (heading_text, start_line_index_of_body) for each section.
235
- sections: list[tuple[str, int]] = []
236
- for idx, line in enumerate(lines):
237
- match = _HEADING_RE.match(line)
238
- if match:
239
- sections.append((match.group(1).strip(), idx))
240
-
241
- entries: list[dict] = []
242
- existing_ids: set[str] = set()
243
- for s_idx, (title, head_line) in enumerate(sections):
244
- end_line = sections[s_idx + 1][1] if s_idx + 1 < len(sections) else len(lines)
245
- body = "\n".join(lines[head_line + 1 : end_line]).strip()
246
- entries.append(
247
- {
248
- "id": slugify(title, existing_ids),
249
- "title": title,
250
- "date": extract_date(title),
251
- "issue_refs": extract_issue_refs(title),
252
- "tags": assign_tags(title, body),
253
- "source": extract_source(body),
254
- "body": body,
255
- }
256
- )
257
- return entries
258
-
259
-
260
- def build_pack(md_text: str, generated_from: str) -> dict:
261
- """Build the full pack object from the source markdown text."""
262
- return {
263
- "pack": PACK_ID,
264
- "version": PACK_VERSION,
265
- "generated_from": generated_from,
266
- "lessons": parse_lessons(md_text),
267
- }
268
-
269
-
270
- def migrate(source: Path, out: Path) -> dict:
271
- """Read ``source`` markdown, build the pack, and write it to ``out``.
272
-
273
- Returns the in-memory pack object. Raises ``FileNotFoundError`` when the
274
- source is missing and ``ValueError`` when it is empty.
275
- """
276
- if not source.is_file():
277
- raise FileNotFoundError(f"source markdown not found: {source}")
278
- md_text = source.read_text(encoding="utf-8")
279
- if not md_text.strip():
280
- raise ValueError(f"source markdown is empty: {source}")
281
-
282
- # Record provenance as a repo-relative path when possible.
283
- try:
284
- generated_from = source.resolve().relative_to(REPO_ROOT).as_posix()
285
- except ValueError:
286
- generated_from = source.name
287
-
288
- pack = build_pack(md_text, generated_from)
289
- out.parent.mkdir(parents=True, exist_ok=True)
290
- # ensure_ascii=True: the canonical source is serialized as pure ASCII with
291
- # \uXXXX escapes. This is lossless (json.loads reconstructs the exact same
292
- # strings, so the rendered projection and slice output are byte-identical),
293
- # and it keeps the source clean against `task verify:encoding` (#798): the
294
- # lessons content legitimately documents cp1252/cp437 mojibake example
295
- # tokens (e.g. the corrupted form of the U+2297 glyph), which the encoding
296
- # gate's markdown inline-code stripping exempts in meta/lessons.md but
297
- # would flag as a raw bigram in the JSON. Escaping sidesteps that without
298
- # mutating the preserved content.
299
- out.write_text(json.dumps(pack, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
300
- return pack
301
-
302
-
303
- def main(argv: list[str] | None = None) -> int:
304
- parser = argparse.ArgumentParser(
305
- prog="pack_migrate_lessons.py",
306
- description="Migrate meta/lessons.md into the structured lessons-pack-0.1 source.",
307
- )
308
- parser.add_argument(
309
- "--source",
310
- type=Path,
311
- default=DEFAULT_SOURCE,
312
- help="Source markdown to parse (default: meta/lessons.md).",
313
- )
314
- parser.add_argument(
315
- "--out",
316
- type=Path,
317
- default=DEFAULT_OUT,
318
- help="Output pack JSON path (default: packs/lessons/lessons-pack-0.1.json).",
319
- )
320
- args = parser.parse_args(argv)
321
-
322
- try:
323
- pack = migrate(args.source, args.out)
324
- except FileNotFoundError as exc:
325
- print(f"error: {exc}", file=sys.stderr)
326
- return 1
327
- except ValueError as exc:
328
- print(f"error: {exc}", file=sys.stderr)
329
- return 1
330
-
331
- print(f"Migrated {len(pack['lessons'])} lessons -> {args.out}")
332
- return 0
333
-
334
-
335
- if __name__ == "__main__":
336
- raise SystemExit(main())
@@ -1,254 +0,0 @@
1
- #!/usr/bin/env python3
2
- """pack_migrate_patterns.py -- one-shot migration: patterns/*.md -> pack.
3
-
4
- Builds the canonical structured source ``packs/patterns/patterns-pack-0.1.json``
5
- (the source of truth per ADR-001) by scanning every ``patterns/*.md``. This is
6
- the #1637 generalization of the #1294 lessons pilot + #1295 skills pack + #1296
7
- rules/strategies packs: the same render/slice machinery, a fifth domain. The
8
- ``patterns/`` directory existed with no pack until #1637 (packs:slice v2).
9
-
10
- What is captured per pattern
11
- ----------------------------
12
- - ``id`` the slugified doc stem (e.g. multi-agent, role-as-overlay).
13
- - ``title`` the leading ``# `` heading text, verbatim.
14
- - ``description`` the leading description paragraph after the title, folded to a
15
- single normalised string (Legend / See-also / HTML-comment / rule chrome
16
- skipped). Empty when the doc has no leading paragraph.
17
- - ``triggers`` invocation keywords for the pattern. Pattern docs carry no
18
- frontmatter and there is no pattern-routing table, so the derivable trigger
19
- is the doc stem itself; the list is otherwise empty (mirrors #1296 strategies
20
- scope: "if no trigger metadata exists, use an empty list and rely on list").
21
- - ``path`` the repo-relative ``patterns/<name>.md``.
22
- - ``body`` the full pattern body (banner-stripped) for the ONE designated
23
- proof pattern (``patterns/multi-agent.md``); ``null`` for every other pattern
24
- (metadata-only, per the "migrate ONE doc as proof" 0.1-pilot scope).
25
-
26
- Usage::
27
-
28
- uv run python scripts/pack_migrate_patterns.py \\
29
- [--patterns-dir patterns] [--proof-pattern patterns/multi-agent.md] \\
30
- [--out packs/patterns/patterns-pack-0.1.json]
31
-
32
- Exit codes:
33
- 0 -- migrated successfully
34
- 1 -- patterns dir missing, or no patterns discovered
35
- 2 -- usage error
36
- """
37
-
38
- from __future__ import annotations
39
-
40
- import argparse
41
- import json
42
- import re
43
- import sys
44
- from pathlib import Path
45
-
46
- # Repo root resolved from this file's location (scripts/ -> repo root) so the
47
- # default paths are CWD-independent.
48
- REPO_ROOT = Path(__file__).resolve().parent.parent
49
-
50
- sys.path.insert(0, str(Path(__file__).resolve().parent))
51
- from _content_root import content_root # noqa: E402
52
-
53
- # Shippable content moved under content/ in the source repo and is
54
- # flattened to the framework root in a consumer deposit (#1875 C1).
55
- CONTENT_ROOT = content_root(REPO_ROOT)
56
- DEFAULT_PATTERNS_DIR = CONTENT_ROOT / "patterns"
57
- DEFAULT_OUT = CONTENT_ROOT / "packs" / "patterns" / "patterns-pack-0.1.json"
58
-
59
- PACK_ID = "patterns-pack-0.1"
60
- PACK_VERSION = "0.1"
61
-
62
- # The single proof PATTERN whose full body is captured + regenerated as a
63
- # banner-marked, drift-checked projection.
64
- DEFAULT_PROOF_PATTERN = "patterns/multi-agent.md"
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
-
75
- def _is_chrome(line: str) -> bool:
76
- """True when a line is document chrome rather than a description paragraph."""
77
- low = line.lstrip().lower()
78
- if low.startswith(_CHROME_PREFIXES):
79
- return True
80
- stripped = line.strip()
81
- # A horizontal rule (e.g. `---`, `===`) is chrome, not a description.
82
- return bool(stripped) and set(stripped) <= {"-", "="}
83
-
84
-
85
- def extract_title(md_text: str) -> str:
86
- """Return the leading ``# `` heading text, or '' when absent."""
87
- for line in md_text.splitlines():
88
- match = _H1_RE.match(line)
89
- if match:
90
- return match.group(1).strip()
91
- return ""
92
-
93
-
94
- def extract_description(md_text: str) -> str:
95
- """Return the leading description paragraph after the ``# `` title.
96
-
97
- Scans past the title, skips blank lines and chrome lines (Legend, See-also,
98
- HTML comments, horizontal rules), then collects the first contiguous block
99
- of non-blank, non-chrome lines and folds it to a single normalised string.
100
- A leading blockquote marker (``> ``) is stripped so redirect/superseded
101
- notes still yield a readable description.
102
- """
103
- lines = md_text.splitlines()
104
- i = 0
105
- n = len(lines)
106
- # Advance to just past the first H1.
107
- while i < n and not _H1_RE.match(lines[i]):
108
- i += 1
109
- if i < n:
110
- i += 1 # skip the title line itself
111
- # Skip leading blanks / chrome before the description.
112
- while i < n and (lines[i].strip() == "" or _is_chrome(lines[i])):
113
- i += 1
114
- # Collect the first contiguous non-blank block. A markdown heading is a
115
- # section boundary, not a description -- a doc whose first content after the
116
- # title is a `## ` heading has no leading description paragraph.
117
- block: list[str] = []
118
- while i < n and lines[i].strip() != "" and not lines[i].lstrip().startswith("#"):
119
- stripped = lines[i].strip()
120
- if stripped.startswith(">"):
121
- stripped = stripped.lstrip(">").strip()
122
- if stripped:
123
- block.append(stripped)
124
- i += 1
125
- return " ".join(block)
126
-
127
-
128
- def strip_leading_banner(body: str) -> str:
129
- """Strip a leading provenance banner + blank lines from a captured body.
130
-
131
- Makes re-migration idempotent: after the proof pattern is regenerated
132
- (banner + body), re-running the migration recovers the same body. Only
133
- strips a banner block that opens with the renderer's first banner line, so
134
- unrelated leading HTML comments survive.
135
- """
136
- lines = body.split("\n")
137
- i = 0
138
- while i < len(lines) and lines[i].strip() == "":
139
- i += 1
140
- if i < len(lines) and lines[i].startswith(
141
- "<!-- AUTO-GENERATED by task packs:render"
142
- ):
143
- while i < len(lines) and lines[i].lstrip().startswith("<!--"):
144
- i += 1
145
- while i < len(lines) and lines[i].strip() == "":
146
- i += 1
147
- return "\n".join(lines[i:])
148
-
149
-
150
- def build_pattern_entry(
151
- md: Path, patterns_dir: Path, *, capture_body: bool
152
- ) -> dict:
153
- """Build one pattern entry from its markdown file."""
154
- rel_path = md.resolve().relative_to(patterns_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(patterns_dir: Path, *, proof_pattern: str) -> dict:
168
- """Scan the patterns dir and assemble the full pack object."""
169
- patterns: list[dict] = []
170
- for md in sorted(patterns_dir.glob("*.md")):
171
- rel_path = md.resolve().relative_to(patterns_dir.resolve().parent).as_posix()
172
- patterns.append(
173
- build_pattern_entry(
174
- md, patterns_dir, capture_body=(rel_path == proof_pattern)
175
- )
176
- )
177
-
178
- return {
179
- "pack": PACK_ID,
180
- "version": PACK_VERSION,
181
- "generated_from": "patterns/*.md",
182
- "patterns": patterns,
183
- }
184
-
185
-
186
- def migrate(patterns_dir: Path, out: Path, *, proof_pattern: str) -> dict:
187
- """Build the pack from ``patterns_dir`` and write it to ``out``.
188
-
189
- Raises ``FileNotFoundError`` when the dir is missing and ``ValueError`` when
190
- no patterns are discovered.
191
- """
192
- if not patterns_dir.is_dir():
193
- raise FileNotFoundError(f"patterns directory not found: {patterns_dir}")
194
-
195
- pack = build_pack(patterns_dir, proof_pattern=proof_pattern)
196
- if not pack["patterns"]:
197
- raise ValueError(f"no patterns discovered under {patterns_dir}")
198
-
199
- out.parent.mkdir(parents=True, exist_ok=True)
200
- # ensure_ascii=True: the canonical source is serialized as pure ASCII with
201
- # \uXXXX escapes (mirrors the other pack migrations). Lossless and keeps the
202
- # JSON clean against `task verify:encoding` (#798) even when a pattern body
203
- # carries non-ASCII glyphs (em dashes, RFC2119 symbols, emoji in diagrams).
204
- out.write_text(
205
- json.dumps(pack, indent=2, ensure_ascii=True) + "\n", encoding="utf-8"
206
- )
207
- return pack
208
-
209
-
210
- def main(argv: list[str] | None = None) -> int:
211
- parser = argparse.ArgumentParser(
212
- prog="pack_migrate_patterns.py",
213
- description="Migrate patterns/*.md into the patterns-pack-0.1 source.",
214
- )
215
- parser.add_argument(
216
- "--patterns-dir",
217
- type=Path,
218
- default=DEFAULT_PATTERNS_DIR,
219
- help="Directory of pattern docs to scan (default: patterns/).",
220
- )
221
- parser.add_argument(
222
- "--proof-pattern",
223
- default=DEFAULT_PROOF_PATTERN,
224
- help="Repo-relative path of the one pattern whose full body is captured.",
225
- )
226
- parser.add_argument(
227
- "--out",
228
- type=Path,
229
- default=DEFAULT_OUT,
230
- help="Output pack JSON path (default: packs/patterns/patterns-pack-0.1.json).",
231
- )
232
- args = parser.parse_args(argv)
233
-
234
- try:
235
- pack = migrate(
236
- args.patterns_dir, args.out, proof_pattern=args.proof_pattern
237
- )
238
- except FileNotFoundError as exc:
239
- print(f"error: {exc}", file=sys.stderr)
240
- return 1
241
- except ValueError as exc:
242
- print(f"error: {exc}", file=sys.stderr)
243
- return 1
244
-
245
- bodied = sum(1 for s in pack["patterns"] if s["body"] is not None)
246
- print(
247
- f"Migrated {len(pack['patterns'])} patterns ({bodied} with body) "
248
- f"-> {args.out}"
249
- )
250
- return 0
251
-
252
-
253
- if __name__ == "__main__":
254
- raise SystemExit(main())