@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,712 +0,0 @@
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())