@deftai/directive-content 0.59.0 → 0.61.0

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