@deftai/directive-content 0.55.1 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +13 -3
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +82 -11
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/packs/skills/skills-pack-0.1.json +22 -22
  9. package/scripts/_agents_md.py +494 -0
  10. package/scripts/_cache_fetch.py +635 -0
  11. package/scripts/_cache_quota.py +529 -0
  12. package/scripts/_cache_refresh.py +163 -0
  13. package/scripts/_cache_validate.py +209 -0
  14. package/scripts/_content_root.py +42 -0
  15. package/scripts/_doctor_state.py +277 -0
  16. package/scripts/_event_detect.py +305 -0
  17. package/scripts/_events.py +514 -0
  18. package/scripts/_lifecycle_hygiene.py +568 -0
  19. package/scripts/_pathspec.py +91 -0
  20. package/scripts/_policy_show_cli.py +266 -0
  21. package/scripts/_precutover.py +92 -0
  22. package/scripts/_project_context.py +224 -0
  23. package/scripts/_project_definition_io.py +164 -0
  24. package/scripts/_relocate_snapshot.py +209 -0
  25. package/scripts/_relocate_states.py +343 -0
  26. package/scripts/_resolve_preflight_path.py +152 -0
  27. package/scripts/_safe_subprocess.py +167 -0
  28. package/scripts/_session_start_hook.py +205 -0
  29. package/scripts/_sor_gate_diff.py +365 -0
  30. package/scripts/_stdio_utf8.py +59 -0
  31. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  32. package/scripts/_triage_classify_cli.py +122 -0
  33. package/scripts/_triage_queue_cli.py +625 -0
  34. package/scripts/_triage_scope_cli.py +343 -0
  35. package/scripts/_triage_scope_drift_cli.py +121 -0
  36. package/scripts/_triage_scope_ignores.py +286 -0
  37. package/scripts/_triage_scope_milestone.py +432 -0
  38. package/scripts/_triage_scope_mutations.py +337 -0
  39. package/scripts/_triage_scope_renderers.py +207 -0
  40. package/scripts/_triage_smoketest_stages.py +674 -0
  41. package/scripts/_triage_subscribe_cli.py +140 -0
  42. package/scripts/_triage_welcome_cli.py +421 -0
  43. package/scripts/_vbrief_build.py +239 -0
  44. package/scripts/_vbrief_fidelity.py +479 -0
  45. package/scripts/_vbrief_legacy.py +589 -0
  46. package/scripts/_vbrief_reconciliation.py +883 -0
  47. package/scripts/_vbrief_routing.py +277 -0
  48. package/scripts/_vbrief_safety.py +778 -0
  49. package/scripts/_vbrief_sources.py +312 -0
  50. package/scripts/_vbrief_speckit.py +262 -0
  51. package/scripts/_vbrief_story_quality.py +353 -0
  52. package/scripts/_vbrief_validation.py +299 -0
  53. package/scripts/build_dist.py +412 -0
  54. package/scripts/cache.py +1078 -0
  55. package/scripts/cache_scanner.py +745 -0
  56. package/scripts/candidates_log.py +432 -0
  57. package/scripts/capacity_backfill.py +680 -0
  58. package/scripts/capacity_show.py +653 -0
  59. package/scripts/ci_local.py +689 -0
  60. package/scripts/code_structure_validate.py +765 -0
  61. package/scripts/codebase_default_extractor.py +495 -0
  62. package/scripts/codebase_map.py +304 -0
  63. package/scripts/codebase_map_fresh.py +104 -0
  64. package/scripts/codebase_projection_registry.py +94 -0
  65. package/scripts/codebase_provider.py +582 -0
  66. package/scripts/doctor.py +2257 -0
  67. package/scripts/framework_commands.py +505 -0
  68. package/scripts/gh_rest.py +882 -0
  69. package/scripts/github_auth_modes.py +437 -0
  70. package/scripts/github_body.py +292 -0
  71. package/scripts/ip_risk.py +531 -0
  72. package/scripts/issue_emit.py +670 -0
  73. package/scripts/issue_ingest.py +1064 -0
  74. package/scripts/migrate_preflight.py +418 -0
  75. package/scripts/migrate_vbrief.py +2677 -0
  76. package/scripts/monitor_pr.py +401 -0
  77. package/scripts/pack_migrate_lessons.py +336 -0
  78. package/scripts/pack_migrate_patterns.py +254 -0
  79. package/scripts/pack_migrate_rules.py +350 -0
  80. package/scripts/pack_migrate_skills.py +423 -0
  81. package/scripts/pack_migrate_strategies.py +311 -0
  82. package/scripts/pack_migrate_swarm_spec.py +250 -0
  83. package/scripts/pack_render.py +434 -0
  84. package/scripts/packs_slice.py +712 -0
  85. package/scripts/platform_capabilities.py +336 -0
  86. package/scripts/policy.py +2826 -0
  87. package/scripts/policy_set.py +324 -0
  88. package/scripts/pr_check_closing_keywords.py +524 -0
  89. package/scripts/pr_check_protected_issues.py +267 -0
  90. package/scripts/pr_merge_readiness.py +1004 -0
  91. package/scripts/pr_wait_mergeable.py +669 -0
  92. package/scripts/prd_render.py +159 -0
  93. package/scripts/preflight_architecture_sor.py +974 -0
  94. package/scripts/preflight_branch.py +289 -0
  95. package/scripts/preflight_cache.py +974 -0
  96. package/scripts/preflight_gh.py +721 -0
  97. package/scripts/preflight_implementation.py +272 -0
  98. package/scripts/preflight_story_start.py +838 -0
  99. package/scripts/preflight_wip_cap.py +149 -0
  100. package/scripts/probe_session.py +545 -0
  101. package/scripts/project_render.py +293 -0
  102. package/scripts/quarantine_ext.py +237 -0
  103. package/scripts/reconcile_issues.py +1442 -0
  104. package/scripts/refresh-path.ps1 +107 -0
  105. package/scripts/release.py +2030 -0
  106. package/scripts/release_e2e.py +1011 -0
  107. package/scripts/release_publish.py +486 -0
  108. package/scripts/release_rollback.py +980 -0
  109. package/scripts/relocate.py +1034 -0
  110. package/scripts/resolve_changelog_unreleased.py +667 -0
  111. package/scripts/resolve_version.py +490 -0
  112. package/scripts/resume_conditions.py +706 -0
  113. package/scripts/ritual_sentinel.py +609 -0
  114. package/scripts/roadmap_render.py +635 -0
  115. package/scripts/rule_ownership_lint.py +325 -0
  116. package/scripts/scm.py +591 -0
  117. package/scripts/scope_audit_log.py +387 -0
  118. package/scripts/scope_decompose.py +654 -0
  119. package/scripts/scope_demote.py +509 -0
  120. package/scripts/scope_lifecycle.py +1126 -0
  121. package/scripts/scope_undo.py +772 -0
  122. package/scripts/session_start.py +406 -0
  123. package/scripts/setup_ghx.py +339 -0
  124. package/scripts/setup_windows.ps1 +220 -0
  125. package/scripts/slice_audit.py +585 -0
  126. package/scripts/slice_record.py +530 -0
  127. package/scripts/slice_record_existing.py +692 -0
  128. package/scripts/slug_normalize.py +178 -0
  129. package/scripts/spec_render.py +477 -0
  130. package/scripts/spec_validate.py +238 -0
  131. package/scripts/subagent_monitor.py +658 -0
  132. package/scripts/swarm_complete_cohort.py +644 -0
  133. package/scripts/swarm_launch.py +1206 -0
  134. package/scripts/swarm_readiness.py +554 -0
  135. package/scripts/swarm_verify_review_clean.py +438 -0
  136. package/scripts/swarm_worktrees.py +497 -0
  137. package/scripts/toolchain-check.py +52 -0
  138. package/scripts/triage_actions.py +871 -0
  139. package/scripts/triage_bootstrap.py +1153 -0
  140. package/scripts/triage_bulk.py +630 -0
  141. package/scripts/triage_classify.py +932 -0
  142. package/scripts/triage_help.py +1685 -0
  143. package/scripts/triage_queue.py +1944 -0
  144. package/scripts/triage_reconcile.py +581 -0
  145. package/scripts/triage_refresh.py +643 -0
  146. package/scripts/triage_scope.py +999 -0
  147. package/scripts/triage_scope_drift.py +575 -0
  148. package/scripts/triage_smoketest.py +396 -0
  149. package/scripts/triage_subscribe.py +399 -0
  150. package/scripts/triage_summary.py +1011 -0
  151. package/scripts/triage_welcome.py +1178 -0
  152. package/scripts/ts_check_lane.py +86 -0
  153. package/scripts/validate-links.py +64 -0
  154. package/scripts/validate_strategy_output.py +212 -0
  155. package/scripts/vbrief_activate.py +228 -0
  156. package/scripts/vbrief_migrate_conformance.py +368 -0
  157. package/scripts/vbrief_reconcile_graph.py +306 -0
  158. package/scripts/vbrief_reconcile_labels.py +460 -0
  159. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  160. package/scripts/vbrief_validate.py +1195 -0
  161. package/scripts/verify-stubs.py +61 -0
  162. package/scripts/verify_capacity.py +160 -0
  163. package/scripts/verify_encoding.py +699 -0
  164. package/scripts/verify_hooks_installed.py +206 -0
  165. package/scripts/verify_investigation.py +360 -0
  166. package/scripts/verify_judgment_gates.py +827 -0
  167. package/scripts/verify_no_task_runtime.py +171 -0
  168. package/scripts/verify_scm_boundary.py +509 -0
  169. package/scripts/verify_session_ritual.py +389 -0
  170. package/scripts/verify_tools.py +426 -0
  171. package/scripts/verify_vbrief_conformance.py +478 -0
  172. package/skills/deft-directive-swarm/SKILL.md +7 -26
  173. package/skills/deft-directive-sync/SKILL.md +1 -1
  174. package/tasks/architecture.yml +13 -0
  175. package/tasks/cache.yml +69 -0
  176. package/tasks/capacity.yml +38 -0
  177. package/tasks/change.yml +46 -0
  178. package/tasks/changelog.yml +24 -0
  179. package/tasks/ci.yml +49 -0
  180. package/tasks/codebase.yml +47 -0
  181. package/tasks/commit.yml +30 -0
  182. package/tasks/core.yml +126 -0
  183. package/tasks/deployments.yml +54 -0
  184. package/tasks/framework.yml +74 -0
  185. package/tasks/install.yml +60 -0
  186. package/tasks/issue.yml +50 -0
  187. package/tasks/migrate.yml +73 -0
  188. package/tasks/packs.yml +92 -0
  189. package/tasks/policy.yml +75 -0
  190. package/tasks/pr.yml +89 -0
  191. package/tasks/prd.yml +39 -0
  192. package/tasks/project.yml +27 -0
  193. package/tasks/reconcile.yml +32 -0
  194. package/tasks/relocate.yml +56 -0
  195. package/tasks/roadmap.yml +28 -0
  196. package/tasks/scm.yml +126 -0
  197. package/tasks/scope-undo.yml +36 -0
  198. package/tasks/scope.yml +141 -0
  199. package/tasks/session.yml +19 -0
  200. package/tasks/setup.yml +37 -0
  201. package/tasks/slice.yml +69 -0
  202. package/tasks/spec.yml +41 -0
  203. package/tasks/swarm.yml +85 -0
  204. package/tasks/toolchain.yml +13 -0
  205. package/tasks/triage-actions.yml +94 -0
  206. package/tasks/triage-bootstrap.yml +43 -0
  207. package/tasks/triage-bulk.yml +75 -0
  208. package/tasks/triage-classify.yml +30 -0
  209. package/tasks/triage-queue.yml +50 -0
  210. package/tasks/triage-reconcile.yml +29 -0
  211. package/tasks/triage-scope-drift.yml +29 -0
  212. package/tasks/triage-scope.yml +31 -0
  213. package/tasks/triage-smoketest.yml +33 -0
  214. package/tasks/triage-subscribe.yml +36 -0
  215. package/tasks/triage-summary.yml +29 -0
  216. package/tasks/triage-welcome.yml +32 -0
  217. package/tasks/ts.yml +328 -0
  218. package/tasks/vbrief.yml +206 -0
  219. package/tasks/verify.yml +292 -0
  220. package/templates/agents-entry.md +2 -2
@@ -0,0 +1,712 @@
1
+ #!/usr/bin/env python3
2
+ """packs_slice.py -- named, structured slice access to a content pack (#1283, #1294).
3
+
4
+ Implements ``task packs:slice <pack> <name> [-- <filters>]``: the agent-facing
5
+ Layer-B slice surface from ADR-001 / the #1283 converged design.
6
+
7
+ Design contract (#1283):
8
+ - The agent-facing API is the slice NAME only (``recent``, ``by-tag``). The
9
+ dotted path + filter dialect are pack-author implementation detail declared
10
+ in the pack's JSON Schema ``x-sliceRegistry`` block -- NOT JSONPath, NOT a
11
+ query language exposed to consumers.
12
+ - Slices read the CANONICAL pack source (JSON) directly, NEVER the rendered
13
+ ``.md`` projection. Reading source guarantees byte-stable, drift-free slices.
14
+ - Output is ``text`` by default (cheapest for the read-into-context path the
15
+ ADR optimises) with ``--json`` / ``--format json`` for harness consumers.
16
+ - Every result carries provenance: ``pack``, ``slice``, ``source`` (path),
17
+ ``source_sha`` (sha256 of the source file).
18
+ - ``--list`` discovers slice names + one-liners; an unknown slice exits 2 with
19
+ a did-you-mean suggestion. Three-state exit: 0 ok / 2 usage error.
20
+ - ``--list-packs`` discovers the available packs (short-name + version +
21
+ one-liner) by scanning the on-disk pack registry (``packs/*/`` sources +
22
+ ``vbrief/schemas/*-pack.schema.json``). It is registry-driven / self-
23
+ extending: a new pack appears with no code change here (#1637).
24
+
25
+ Exit codes:
26
+ 0 -- ok
27
+ 2 -- usage error (unknown pack/slice, bad filter, malformed --since, ...)
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import difflib
34
+ import hashlib
35
+ import json
36
+ import re
37
+ import sys
38
+ from pathlib import Path
39
+ from typing import Any
40
+
41
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
42
+
43
+ from _content_root import content_root # noqa: E402
44
+
45
+ # Repo root resolved from this file's location (scripts/ -> repo root) so pack
46
+ # source / schema paths are CWD-independent.
47
+ REPO_ROOT = Path(__file__).resolve().parent.parent
48
+ # Shippable content lives under content/ in the source repo and at the framework
49
+ # root in a flattened consumer deposit (#1875 C1); resolve both contexts.
50
+ CONTENT_ROOT = content_root(REPO_ROOT)
51
+
52
+ # Pack short-name -> on-disk source + schema. The slice surface resolves the
53
+ # canonical source (never the rendered .md) and the schema-declared registry.
54
+ PACK_REGISTRY: dict[str, dict[str, Path]] = {
55
+ "lessons": {
56
+ "source": CONTENT_ROOT / "packs" / "lessons" / "lessons-pack-0.1.json",
57
+ "schema": CONTENT_ROOT / "vbrief" / "schemas" / "lessons-pack.schema.json",
58
+ },
59
+ }
60
+
61
+ # Pack-LEVEL discovery (``--list-packs``, #1637) scans the on-disk pack
62
+ # registry rather than the hardcoded ``PACK_REGISTRY`` above so a new pack
63
+ # (e.g. a future skills-pack / rules-pack) appears WITHOUT any code change
64
+ # here: drop ``packs/<name>/<name>-pack-*.json`` + its
65
+ # ``vbrief/schemas/<name>-pack.schema.json`` and ``--list-packs`` lists it.
66
+ PACKS_DIR = CONTENT_ROOT / "packs"
67
+ SCHEMAS_DIR = CONTENT_ROOT / "vbrief" / "schemas"
68
+
69
+ _SINCE_RE = re.compile(r"^\d{4}-\d{2}(-\d{2})?$")
70
+
71
+ # Fallback display spec used when a pack schema declares no ``x-display`` block
72
+ # (and when ``format_slice_text`` is called without one). Mirrors the lessons
73
+ # pack's render shape so the legacy single-arg call sites stay byte-stable.
74
+ _DEFAULT_DISPLAY: dict[str, Any] = {
75
+ "heading": "title",
76
+ "fields": [],
77
+ "body": "body",
78
+ "noun": "lessons",
79
+ }
80
+
81
+
82
+ class UsageError(Exception):
83
+ """A recoverable usage error -- mapped to exit code 2 in ``main``."""
84
+
85
+ def __init__(self, message: str, suggestion: str | None = None) -> None:
86
+ super().__init__(message)
87
+ self.message = message
88
+ self.suggestion = suggestion
89
+
90
+
91
+ def sha256_file(path: Path) -> str:
92
+ """Return the hex sha256 of a file's bytes."""
93
+ return hashlib.sha256(path.read_bytes()).hexdigest()
94
+
95
+
96
+ def _rel_to_repo(path: Path) -> str:
97
+ """Return a repo-relative POSIX path string for provenance, or the name."""
98
+ try:
99
+ return path.resolve().relative_to(REPO_ROOT).as_posix()
100
+ except ValueError:
101
+ return path.name
102
+
103
+
104
+ def resolve_pack(pack_name: str) -> tuple[Path, Path]:
105
+ """Resolve a pack short-name to its (source, schema) paths.
106
+
107
+ Resolution is self-extending (#1295): the hardcoded ``PACK_REGISTRY`` is an
108
+ override / fast-path (it is also the monkeypatch seam the tests use), but any
109
+ pack that ships ``packs/<name>/<name>-pack-*.json`` plus a companion
110
+ ``vbrief/schemas/<name>-pack.schema.json`` resolves with NO code change here
111
+ -- the same registry-driven contract ``--list-packs`` already honours. Raises
112
+ ``UsageError`` (with a did-you-mean suggestion) for an unknown pack.
113
+ """
114
+ if pack_name in PACK_REGISTRY:
115
+ entry = PACK_REGISTRY[pack_name]
116
+ return entry["source"], entry["schema"]
117
+
118
+ pack_dir = PACKS_DIR / pack_name
119
+ sources = sorted(pack_dir.glob("*.json")) if pack_dir.is_dir() else []
120
+ schema_path = SCHEMAS_DIR / f"{pack_name}-pack.schema.json"
121
+ if sources and schema_path.is_file():
122
+ return sources[0], schema_path
123
+
124
+ known = sorted({*PACK_REGISTRY, *(p["name"] for p in discover_packs())})
125
+ suggestions = difflib.get_close_matches(pack_name, known, n=1)
126
+ raise UsageError(
127
+ f"unknown pack '{pack_name}'",
128
+ suggestion=suggestions[0] if suggestions else None,
129
+ )
130
+
131
+
132
+ def load_display(schema_path: Path) -> dict[str, Any]:
133
+ """Load the schema-declared ``x-display`` block (slice text-render hints).
134
+
135
+ Falls back to the lessons-shaped ``_DEFAULT_DISPLAY`` when a pack schema
136
+ omits the block, so the slice formatter is pack-agnostic without requiring
137
+ every pack to declare it.
138
+ """
139
+ if not schema_path.is_file():
140
+ raise UsageError(f"pack schema not found: {schema_path}")
141
+ schema = json.loads(schema_path.read_text(encoding="utf-8"))
142
+ display = schema.get("x-display")
143
+ if not isinstance(display, dict):
144
+ return dict(_DEFAULT_DISPLAY)
145
+ return display
146
+
147
+
148
+ def load_registry(schema_path: Path) -> dict[str, dict[str, Any]]:
149
+ """Load the schema-declared ``x-sliceRegistry`` map from a pack schema."""
150
+ if not schema_path.is_file():
151
+ raise UsageError(f"pack schema not found: {schema_path}")
152
+ schema = json.loads(schema_path.read_text(encoding="utf-8"))
153
+ registry = schema.get("x-sliceRegistry")
154
+ if not isinstance(registry, dict):
155
+ raise UsageError(f"pack schema has no x-sliceRegistry: {schema_path}")
156
+ return registry
157
+
158
+
159
+ def load_source(source_path: Path) -> dict[str, Any]:
160
+ """Load the canonical pack source JSON (never the rendered .md)."""
161
+ if not source_path.is_file():
162
+ raise UsageError(f"pack source not found: {source_path}")
163
+ data: dict[str, Any] = json.loads(source_path.read_text(encoding="utf-8"))
164
+ return data
165
+
166
+
167
+ def resolve_dotted_path(data: Any, dotted: str) -> Any:
168
+ """Walk a constrained dotted path into ``data`` with ``.get()`` guards.
169
+
170
+ Each segment indexes a mapping; a missing / non-mapping step yields ``None``.
171
+ This is the constrained dotted-path dialect from #1283 -- NOT JSONPath.
172
+ """
173
+ current = data
174
+ for segment in dotted.split("."):
175
+ if isinstance(current, dict):
176
+ current = current.get(segment)
177
+ else:
178
+ return None
179
+ return current
180
+
181
+
182
+ def apply_since(entries: list[dict], since: str) -> list[dict]:
183
+ """Filter entries to those dated on or after ``since`` (year-month grain).
184
+
185
+ ``since`` may be ``YYYY-MM`` or ``YYYY-MM-DD``; comparison is at month
186
+ granularity (the entries' date grain). Null-dated entries are excluded.
187
+ """
188
+ since_ym = since[:7]
189
+ return [e for e in entries if e.get("date") and e["date"] >= since_ym]
190
+
191
+
192
+ def apply_tags(entries: list[dict], tags: list[str]) -> list[dict]:
193
+ """Filter entries to those carrying any of the requested ``tags``."""
194
+ wanted = set(tags)
195
+ return [e for e in entries if wanted & set(e.get("tags", []))]
196
+
197
+
198
+ def apply_triggers(entries: list[dict], triggers: list[str]) -> list[dict]:
199
+ """Filter entries to those whose ``triggers`` include any requested value.
200
+
201
+ Matching is case-insensitive exact membership: the agent passes a routing
202
+ keyword from the AGENTS.md Skill Routing table and gets back the skill(s)
203
+ that keyword routes to.
204
+ """
205
+ wanted = {t.lower() for t in triggers}
206
+ return [
207
+ e
208
+ for e in entries
209
+ if wanted & {str(t).lower() for t in e.get("triggers", [])}
210
+ ]
211
+
212
+
213
+ def apply_scalar(entries: list[dict], field: str, values: list[str]) -> list[dict]:
214
+ """Filter entries whose scalar ``field`` matches any requested value.
215
+
216
+ Case-insensitive exact match on a single-valued field (e.g. the rules pack's
217
+ ``tier`` and ``domain``), as opposed to the list-membership filters above.
218
+ """
219
+ wanted = {v.lower() for v in values}
220
+ return [e for e in entries if str(e.get(field, "")).lower() in wanted]
221
+
222
+
223
+ def _normalize_issue(value: str) -> str:
224
+ """Normalise an issue reference for comparison (strip leading '#', lower)."""
225
+ return str(value).lstrip("#").strip().lower()
226
+
227
+
228
+ def apply_issue_refs(entries: list[dict], issues: list[str]) -> list[dict]:
229
+ """Filter entries whose ``issue_refs`` include any requested issue number.
230
+
231
+ The lessons pack stores issue refs as ``"#754"`` strings; the agent passes a
232
+ bare or hashed number (``754`` / ``#754``) and both sides are normalised so
233
+ ``--issue 754`` matches ``"#754"``. List-membership semantics (#1637).
234
+ """
235
+ wanted = {_normalize_issue(i) for i in issues}
236
+ return [
237
+ e
238
+ for e in entries
239
+ if wanted & {_normalize_issue(r) for r in e.get("issue_refs", [])}
240
+ ]
241
+
242
+
243
+ def apply_select(entries: list[dict], select: dict[str, Any]) -> list[dict]:
244
+ """Apply a slice's fixed (argument-less) predicate from its registry spec.
245
+
246
+ Some deeper slices (#1637) subset WITHOUT an agent-supplied filter -- the
247
+ predicate is baked into the slice name. ``select`` declares it in the pack
248
+ schema's ``x-sliceRegistry`` entry. Supported keys:
249
+
250
+ - ``tier_in``: keep entries whose ``tier`` is in the listed values
251
+ (case-insensitive). Powers the rules pack ``must`` / ``prohibitions``.
252
+ - ``body_contains_any``: keep entries whose ``body`` (case-insensitive)
253
+ contains any listed substring. Powers the lessons pack ``anti-patterns``.
254
+
255
+ The agent-facing contract stays the slice NAME only -- ``select`` is a
256
+ pack-author authoring detail, never exposed as a query language (ADR-001).
257
+ """
258
+ result = entries
259
+ tier_in = select.get("tier_in")
260
+ if isinstance(tier_in, list) and tier_in:
261
+ wanted = {str(t).lower() for t in tier_in}
262
+ result = [e for e in result if str(e.get("tier", "")).lower() in wanted]
263
+ needles = select.get("body_contains_any")
264
+ if isinstance(needles, list) and needles:
265
+ lowered = [str(n).lower() for n in needles]
266
+ result = [
267
+ e
268
+ for e in result
269
+ if any(n in str(e.get("body") or "").lower() for n in lowered)
270
+ ]
271
+ return result
272
+
273
+
274
+ def _validate_filters(
275
+ slice_name: str,
276
+ allowed: list[str],
277
+ *,
278
+ since: str | None,
279
+ tags: list[str] | None,
280
+ triggers: list[str] | None,
281
+ tiers: list[str] | None,
282
+ domains: list[str] | None,
283
+ issues: list[str] | None,
284
+ ids: list[str] | None,
285
+ ) -> None:
286
+ """Reject filters not declared for this slice in the registry."""
287
+ provided: list[str] = []
288
+ if since is not None:
289
+ provided.append("since")
290
+ if tags:
291
+ provided.append("tag")
292
+ if triggers:
293
+ provided.append("trigger")
294
+ if tiers:
295
+ provided.append("tier")
296
+ if domains:
297
+ provided.append("domain")
298
+ if issues:
299
+ provided.append("issue")
300
+ if ids:
301
+ provided.append("id")
302
+ for filt in provided:
303
+ if filt not in allowed:
304
+ raise UsageError(
305
+ f"slice '{slice_name}' does not support the --{filt} filter "
306
+ f"(allowed: {', '.join(allowed) or 'none'})"
307
+ )
308
+
309
+
310
+ def slice_pack(
311
+ pack_id: str,
312
+ slice_name: str,
313
+ registry: dict[str, dict[str, Any]],
314
+ source_data: dict[str, Any],
315
+ source_path: Path,
316
+ *,
317
+ since: str | None = None,
318
+ tags: list[str] | None = None,
319
+ triggers: list[str] | None = None,
320
+ tiers: list[str] | None = None,
321
+ domains: list[str] | None = None,
322
+ issues: list[str] | None = None,
323
+ ids: list[str] | None = None,
324
+ ) -> dict[str, Any]:
325
+ """Resolve and execute a named slice, returning a provenance-tagged result.
326
+
327
+ Raises ``UsageError`` for an unknown slice (with did-you-mean), an
328
+ unsupported filter, or a malformed ``--since`` value.
329
+ """
330
+ if slice_name not in registry:
331
+ suggestions = difflib.get_close_matches(slice_name, registry, n=1)
332
+ raise UsageError(
333
+ f"unknown slice '{slice_name}' for pack {pack_id}",
334
+ suggestion=suggestions[0] if suggestions else None,
335
+ )
336
+
337
+ spec = registry[slice_name]
338
+ allowed = spec.get("filters", [])
339
+ _validate_filters(
340
+ slice_name,
341
+ allowed,
342
+ since=since,
343
+ tags=tags,
344
+ triggers=triggers,
345
+ tiers=tiers,
346
+ domains=domains,
347
+ issues=issues,
348
+ ids=ids,
349
+ )
350
+
351
+ if since is not None and not _SINCE_RE.match(since):
352
+ raise UsageError(f"--since must be YYYY-MM or YYYY-MM-DD, got '{since}'")
353
+
354
+ resolved = resolve_dotted_path(source_data, spec["path"])
355
+ entries: list[dict] = list(resolved) if isinstance(resolved, list) else []
356
+
357
+ # Fixed (argument-less) predicate baked into the slice name (#1637): applied
358
+ # before the agent-supplied filters so a `must` / `anti-patterns` slice
359
+ # subsets with no flags.
360
+ select = spec.get("select")
361
+ if isinstance(select, dict):
362
+ entries = apply_select(entries, select)
363
+
364
+ if since is not None:
365
+ entries = apply_since(entries, since)
366
+ if tags:
367
+ entries = apply_tags(entries, tags)
368
+ if triggers:
369
+ entries = apply_triggers(entries, triggers)
370
+ if tiers:
371
+ entries = apply_scalar(entries, "tier", tiers)
372
+ if domains:
373
+ entries = apply_scalar(entries, "domain", domains)
374
+ if issues:
375
+ entries = apply_issue_refs(entries, issues)
376
+ if ids:
377
+ entries = apply_scalar(entries, "id", ids)
378
+
379
+ return {
380
+ "pack": pack_id,
381
+ "slice": slice_name,
382
+ "source": _rel_to_repo(source_path),
383
+ "source_sha": sha256_file(source_path),
384
+ "count": len(entries),
385
+ "results": entries,
386
+ }
387
+
388
+
389
+ def list_slices(
390
+ pack_id: str,
391
+ registry: dict[str, dict[str, Any]],
392
+ source_path: Path,
393
+ ) -> dict[str, Any]:
394
+ """Build the ``--list`` discovery payload for a pack."""
395
+ slices = [
396
+ {
397
+ "name": name,
398
+ "description": spec.get("description", ""),
399
+ "filters": spec.get("filters", []),
400
+ }
401
+ for name, spec in sorted(registry.items())
402
+ ]
403
+ return {
404
+ "pack": pack_id,
405
+ "source": _rel_to_repo(source_path),
406
+ "source_sha": sha256_file(source_path),
407
+ "slices": slices,
408
+ }
409
+
410
+
411
+ def _one_line(text: str) -> str:
412
+ """Collapse whitespace and return the first sentence of ``text``.
413
+
414
+ Pack descriptions in the schemas are multi-paragraph; ``--list-packs``
415
+ wants a single token-cheap one-liner, so take the leading sentence (up to
416
+ the first period-space) of the whitespace-folded text.
417
+ """
418
+ folded = " ".join(text.split())
419
+ head = folded.split(". ", 1)[0]
420
+ return head.rstrip(".") if head else ""
421
+
422
+
423
+ def discover_packs(
424
+ packs_dir: Path = PACKS_DIR,
425
+ schemas_dir: Path = SCHEMAS_DIR,
426
+ ) -> list[dict[str, Any]]:
427
+ """Scan the on-disk pack registry and return sorted pack descriptors.
428
+
429
+ Registry-driven / self-extending (#1637): each ``packs/<name>/`` directory
430
+ holding a canonical ``*.json`` source is a pack. The short-name is the
431
+ directory name, the version comes from the source's ``version`` field, and
432
+ the one-line description is read from the companion
433
+ ``vbrief/schemas/<name>-pack.schema.json`` (its ``description`` / ``title``).
434
+ A pack added later appears here with NO code change.
435
+ """
436
+ packs: list[dict[str, Any]] = []
437
+ if not packs_dir.is_dir():
438
+ return packs
439
+ for pack_dir in sorted(packs_dir.iterdir()):
440
+ if not pack_dir.is_dir():
441
+ continue
442
+ short_name = pack_dir.name
443
+ sources = sorted(pack_dir.glob("*.json"))
444
+ if not sources:
445
+ continue
446
+ source_path = sources[0]
447
+ try:
448
+ source_data = json.loads(source_path.read_text(encoding="utf-8"))
449
+ except (OSError, ValueError):
450
+ continue
451
+ pack_id = source_data.get("pack", short_name)
452
+ version = str(source_data.get("version", ""))
453
+
454
+ description = ""
455
+ schema_path = schemas_dir / f"{short_name}-pack.schema.json"
456
+ if schema_path.is_file():
457
+ try:
458
+ schema = json.loads(schema_path.read_text(encoding="utf-8"))
459
+ description = _one_line(
460
+ schema.get("description") or schema.get("title") or ""
461
+ )
462
+ except (OSError, ValueError):
463
+ description = ""
464
+
465
+ packs.append(
466
+ {
467
+ "name": short_name,
468
+ "pack": pack_id,
469
+ "version": version,
470
+ "description": description,
471
+ "source": _rel_to_repo(source_path),
472
+ }
473
+ )
474
+ return packs
475
+
476
+
477
+ def list_packs(
478
+ packs_dir: Path = PACKS_DIR,
479
+ schemas_dir: Path = SCHEMAS_DIR,
480
+ ) -> dict[str, Any]:
481
+ """Build the ``--list-packs`` discovery payload (registry-driven)."""
482
+ return {"packs": discover_packs(packs_dir, schemas_dir)}
483
+
484
+
485
+ def format_list_packs_text(payload: dict[str, Any]) -> str:
486
+ """Render the ``--list-packs`` discovery payload as text."""
487
+ packs = payload["packs"]
488
+ if not packs:
489
+ return "No content packs found.\n"
490
+ lines = ["Available content packs:"]
491
+ name_w = max(len(p["name"]) for p in packs)
492
+ ver_w = max(len(p["version"]) for p in packs)
493
+ for p in packs:
494
+ lines.append(
495
+ f" {p['name']:<{name_w}} {p['version']:<{ver_w}} {p['description']}"
496
+ )
497
+ return "\n".join(lines) + "\n"
498
+
499
+
500
+ def format_slice_text(
501
+ result: dict[str, Any], display: dict[str, Any] | None = None
502
+ ) -> str:
503
+ """Render a slice result as token-efficient text with a provenance header.
504
+
505
+ The entry shape is driven by the pack schema's ``x-display`` block
506
+ (``heading`` field, optional labelled ``fields``, optional ``body`` field,
507
+ and the ``noun`` used in the empty-result line) so the formatter is
508
+ pack-agnostic. When ``display`` is omitted it falls back to the
509
+ lessons-shaped default, keeping legacy call sites byte-stable.
510
+ """
511
+ display = display or _DEFAULT_DISPLAY
512
+ header = (
513
+ f"# pack: {result['pack']} | slice: {result['slice']} | "
514
+ f"source: {result['source']} | source_sha: {result['source_sha']} | "
515
+ f"{result['count']} result(s)"
516
+ )
517
+ noun = display.get("noun", "entries")
518
+ if not result["results"]:
519
+ return f"{header}\n\n(no matching {noun})"
520
+
521
+ heading_field = display.get("heading", "title")
522
+ field_specs: list[str] = display.get("fields", [])
523
+ body_field = display.get("body")
524
+
525
+ parts = [header]
526
+ for entry in result["results"]:
527
+ block = f"\n## {entry.get(heading_field)}\n"
528
+ field_lines: list[str] = []
529
+ for field in field_specs:
530
+ value = entry.get(field)
531
+ if value in (None, "", []):
532
+ continue
533
+ if isinstance(value, list):
534
+ value = ", ".join(str(v) for v in value)
535
+ field_lines.append(f"- {field}: {value}")
536
+ if field_lines:
537
+ block += "\n" + "\n".join(field_lines) + "\n"
538
+ if body_field:
539
+ body = entry.get(body_field)
540
+ if body:
541
+ block += f"\n{body}\n"
542
+ parts.append(block)
543
+ return "".join(parts)
544
+
545
+
546
+ def format_list_text(payload: dict[str, Any]) -> str:
547
+ """Render the ``--list`` discovery payload as text."""
548
+ lines = [f"Slices for pack {payload['pack']} (source: {payload['source']}):"]
549
+ width = max((len(s["name"]) for s in payload["slices"]), default=0)
550
+ for s in payload["slices"]:
551
+ filters = ", ".join(s["filters"]) or "none"
552
+ lines.append(f" {s['name']:<{width}} {s['description']} [filters: {filters}]")
553
+ return "\n".join(lines) + "\n"
554
+
555
+
556
+ def _build_parser() -> argparse.ArgumentParser:
557
+ parser = argparse.ArgumentParser(
558
+ prog="packs_slice.py",
559
+ description="Named, structured slice access to a content pack (#1283).",
560
+ )
561
+ parser.add_argument(
562
+ "pack",
563
+ nargs="?",
564
+ help="Pack short-name (e.g. 'lessons'). Omit with --list-packs.",
565
+ )
566
+ parser.add_argument(
567
+ "name",
568
+ nargs="?",
569
+ help="Slice name (e.g. 'recent', 'by-tag'). Omit with --list.",
570
+ )
571
+ parser.add_argument("--since", help="recent filter: YYYY-MM or YYYY-MM-DD.")
572
+ parser.add_argument(
573
+ "--tag",
574
+ action="append",
575
+ default=[],
576
+ help="by-tag filter: tag value (repeatable or comma-listed).",
577
+ )
578
+ parser.add_argument(
579
+ "--trigger",
580
+ action="append",
581
+ default=[],
582
+ help="by-trigger filter: routing keyword (repeatable or comma-listed).",
583
+ )
584
+ parser.add_argument(
585
+ "--tier",
586
+ action="append",
587
+ default=[],
588
+ help="by-tier filter: RFC2119 tier (e.g. MUST; repeatable or comma-listed).",
589
+ )
590
+ parser.add_argument(
591
+ "--domain",
592
+ action="append",
593
+ default=[],
594
+ help="by-domain filter: source doc stem (e.g. testing; repeatable or comma-listed).",
595
+ )
596
+ parser.add_argument(
597
+ "--issue",
598
+ action="append",
599
+ default=[],
600
+ help="by-issue filter: issue number, bare or hashed (e.g. 754; repeatable/comma).",
601
+ )
602
+ parser.add_argument(
603
+ "--id",
604
+ action="append",
605
+ default=[],
606
+ dest="ids",
607
+ help="by-id filter: entry id (e.g. deft-directive-cost; repeatable or comma-listed).",
608
+ )
609
+ parser.add_argument(
610
+ "--format",
611
+ choices=("text", "json"),
612
+ default="text",
613
+ help="Output format (default: text).",
614
+ )
615
+ parser.add_argument(
616
+ "--json",
617
+ action="store_true",
618
+ help="Alias for --format json.",
619
+ )
620
+ parser.add_argument(
621
+ "--list",
622
+ action="store_true",
623
+ dest="list_slices",
624
+ help="List the pack's slice names + descriptions, then exit.",
625
+ )
626
+ parser.add_argument(
627
+ "--list-packs",
628
+ action="store_true",
629
+ dest="list_packs",
630
+ help="List the available packs (name + version + one-liner), then exit.",
631
+ )
632
+ return parser
633
+
634
+
635
+ def _collect_tags(raw: list[str]) -> list[str]:
636
+ """Flatten repeated / comma-listed --tag values into a normalised list."""
637
+ out: list[str] = []
638
+ for item in raw:
639
+ out.extend(t.strip().lower() for t in item.split(",") if t.strip())
640
+ return out
641
+
642
+
643
+ def main(argv: list[str] | None = None) -> int:
644
+ parser = _build_parser()
645
+ args = parser.parse_args(argv)
646
+
647
+ fmt = "json" if args.json else args.format
648
+ tags = _collect_tags(args.tag)
649
+ triggers = _collect_tags(args.trigger)
650
+ tiers = _collect_tags(args.tier)
651
+ domains = _collect_tags(args.domain)
652
+ issues = _collect_tags(args.issue)
653
+ ids = _collect_tags(args.ids)
654
+
655
+ try:
656
+ if args.list_packs:
657
+ payload = list_packs()
658
+ if fmt == "json":
659
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
660
+ else:
661
+ print(format_list_packs_text(payload), end="")
662
+ return 0
663
+
664
+ if not args.pack:
665
+ raise UsageError("a pack name is required (or pass --list-packs)")
666
+
667
+ source_path, schema_path = resolve_pack(args.pack)
668
+ registry = load_registry(schema_path)
669
+ display = load_display(schema_path)
670
+ source_data = load_source(source_path)
671
+ pack_id = source_data.get("pack", args.pack)
672
+
673
+ if args.list_slices:
674
+ payload = list_slices(pack_id, registry, source_path)
675
+ if fmt == "json":
676
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
677
+ else:
678
+ print(format_list_text(payload), end="")
679
+ return 0
680
+
681
+ if not args.name:
682
+ raise UsageError("a slice name is required (or pass --list)")
683
+
684
+ result = slice_pack(
685
+ pack_id,
686
+ args.name,
687
+ registry,
688
+ source_data,
689
+ source_path,
690
+ since=args.since,
691
+ tags=tags,
692
+ triggers=triggers,
693
+ tiers=tiers,
694
+ domains=domains,
695
+ issues=issues,
696
+ ids=ids,
697
+ )
698
+ if fmt == "json":
699
+ print(json.dumps(result, indent=2, ensure_ascii=False))
700
+ else:
701
+ print(format_slice_text(result, display))
702
+ return 0
703
+ except UsageError as exc:
704
+ msg = f"error: {exc.message}"
705
+ if exc.suggestion:
706
+ msg += f". Did you mean '{exc.suggestion}'?"
707
+ print(msg, file=sys.stderr)
708
+ return 2
709
+
710
+
711
+ if __name__ == "__main__":
712
+ raise SystemExit(main())