@deftai/directive-content 0.55.2 → 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 (217) hide show
  1. package/.githooks/pre-commit +143 -0
  2. package/.githooks/pre-push +121 -0
  3. package/QUICK-START.md +2 -2
  4. package/Taskfile.yml +934 -0
  5. package/UPGRADING.md +47 -1
  6. package/events/README.md +3 -3
  7. package/package.json +5 -4
  8. package/scripts/_agents_md.py +494 -0
  9. package/scripts/_cache_fetch.py +635 -0
  10. package/scripts/_cache_quota.py +529 -0
  11. package/scripts/_cache_refresh.py +163 -0
  12. package/scripts/_cache_validate.py +209 -0
  13. package/scripts/_content_root.py +42 -0
  14. package/scripts/_doctor_state.py +277 -0
  15. package/scripts/_event_detect.py +305 -0
  16. package/scripts/_events.py +514 -0
  17. package/scripts/_lifecycle_hygiene.py +568 -0
  18. package/scripts/_pathspec.py +91 -0
  19. package/scripts/_policy_show_cli.py +266 -0
  20. package/scripts/_precutover.py +92 -0
  21. package/scripts/_project_context.py +224 -0
  22. package/scripts/_project_definition_io.py +164 -0
  23. package/scripts/_relocate_snapshot.py +209 -0
  24. package/scripts/_relocate_states.py +343 -0
  25. package/scripts/_resolve_preflight_path.py +152 -0
  26. package/scripts/_safe_subprocess.py +167 -0
  27. package/scripts/_session_start_hook.py +205 -0
  28. package/scripts/_sor_gate_diff.py +365 -0
  29. package/scripts/_stdio_utf8.py +59 -0
  30. package/scripts/_triage_bootstrap_gitignore.py +904 -0
  31. package/scripts/_triage_classify_cli.py +122 -0
  32. package/scripts/_triage_queue_cli.py +625 -0
  33. package/scripts/_triage_scope_cli.py +343 -0
  34. package/scripts/_triage_scope_drift_cli.py +121 -0
  35. package/scripts/_triage_scope_ignores.py +286 -0
  36. package/scripts/_triage_scope_milestone.py +432 -0
  37. package/scripts/_triage_scope_mutations.py +337 -0
  38. package/scripts/_triage_scope_renderers.py +207 -0
  39. package/scripts/_triage_smoketest_stages.py +674 -0
  40. package/scripts/_triage_subscribe_cli.py +140 -0
  41. package/scripts/_triage_welcome_cli.py +421 -0
  42. package/scripts/_vbrief_build.py +239 -0
  43. package/scripts/_vbrief_fidelity.py +479 -0
  44. package/scripts/_vbrief_legacy.py +589 -0
  45. package/scripts/_vbrief_reconciliation.py +883 -0
  46. package/scripts/_vbrief_routing.py +277 -0
  47. package/scripts/_vbrief_safety.py +778 -0
  48. package/scripts/_vbrief_sources.py +312 -0
  49. package/scripts/_vbrief_speckit.py +262 -0
  50. package/scripts/_vbrief_story_quality.py +353 -0
  51. package/scripts/_vbrief_validation.py +299 -0
  52. package/scripts/build_dist.py +412 -0
  53. package/scripts/cache.py +1078 -0
  54. package/scripts/cache_scanner.py +745 -0
  55. package/scripts/candidates_log.py +432 -0
  56. package/scripts/capacity_backfill.py +680 -0
  57. package/scripts/capacity_show.py +653 -0
  58. package/scripts/ci_local.py +689 -0
  59. package/scripts/code_structure_validate.py +765 -0
  60. package/scripts/codebase_default_extractor.py +495 -0
  61. package/scripts/codebase_map.py +304 -0
  62. package/scripts/codebase_map_fresh.py +104 -0
  63. package/scripts/codebase_projection_registry.py +94 -0
  64. package/scripts/codebase_provider.py +582 -0
  65. package/scripts/doctor.py +2257 -0
  66. package/scripts/framework_commands.py +505 -0
  67. package/scripts/gh_rest.py +882 -0
  68. package/scripts/github_auth_modes.py +437 -0
  69. package/scripts/github_body.py +292 -0
  70. package/scripts/ip_risk.py +531 -0
  71. package/scripts/issue_emit.py +670 -0
  72. package/scripts/issue_ingest.py +1064 -0
  73. package/scripts/migrate_preflight.py +418 -0
  74. package/scripts/migrate_vbrief.py +2677 -0
  75. package/scripts/monitor_pr.py +401 -0
  76. package/scripts/pack_migrate_lessons.py +336 -0
  77. package/scripts/pack_migrate_patterns.py +254 -0
  78. package/scripts/pack_migrate_rules.py +350 -0
  79. package/scripts/pack_migrate_skills.py +423 -0
  80. package/scripts/pack_migrate_strategies.py +311 -0
  81. package/scripts/pack_migrate_swarm_spec.py +250 -0
  82. package/scripts/pack_render.py +434 -0
  83. package/scripts/packs_slice.py +712 -0
  84. package/scripts/platform_capabilities.py +336 -0
  85. package/scripts/policy.py +2826 -0
  86. package/scripts/policy_set.py +324 -0
  87. package/scripts/pr_check_closing_keywords.py +524 -0
  88. package/scripts/pr_check_protected_issues.py +267 -0
  89. package/scripts/pr_merge_readiness.py +1004 -0
  90. package/scripts/pr_wait_mergeable.py +669 -0
  91. package/scripts/prd_render.py +159 -0
  92. package/scripts/preflight_architecture_sor.py +974 -0
  93. package/scripts/preflight_branch.py +289 -0
  94. package/scripts/preflight_cache.py +974 -0
  95. package/scripts/preflight_gh.py +721 -0
  96. package/scripts/preflight_implementation.py +272 -0
  97. package/scripts/preflight_story_start.py +838 -0
  98. package/scripts/preflight_wip_cap.py +149 -0
  99. package/scripts/probe_session.py +545 -0
  100. package/scripts/project_render.py +293 -0
  101. package/scripts/quarantine_ext.py +237 -0
  102. package/scripts/reconcile_issues.py +1442 -0
  103. package/scripts/refresh-path.ps1 +107 -0
  104. package/scripts/release.py +2030 -0
  105. package/scripts/release_e2e.py +1011 -0
  106. package/scripts/release_publish.py +486 -0
  107. package/scripts/release_rollback.py +980 -0
  108. package/scripts/relocate.py +1034 -0
  109. package/scripts/resolve_changelog_unreleased.py +667 -0
  110. package/scripts/resolve_version.py +490 -0
  111. package/scripts/resume_conditions.py +706 -0
  112. package/scripts/ritual_sentinel.py +609 -0
  113. package/scripts/roadmap_render.py +635 -0
  114. package/scripts/rule_ownership_lint.py +325 -0
  115. package/scripts/scm.py +591 -0
  116. package/scripts/scope_audit_log.py +387 -0
  117. package/scripts/scope_decompose.py +654 -0
  118. package/scripts/scope_demote.py +509 -0
  119. package/scripts/scope_lifecycle.py +1126 -0
  120. package/scripts/scope_undo.py +772 -0
  121. package/scripts/session_start.py +406 -0
  122. package/scripts/setup_ghx.py +339 -0
  123. package/scripts/setup_windows.ps1 +220 -0
  124. package/scripts/slice_audit.py +585 -0
  125. package/scripts/slice_record.py +530 -0
  126. package/scripts/slice_record_existing.py +692 -0
  127. package/scripts/slug_normalize.py +178 -0
  128. package/scripts/spec_render.py +477 -0
  129. package/scripts/spec_validate.py +238 -0
  130. package/scripts/subagent_monitor.py +658 -0
  131. package/scripts/swarm_complete_cohort.py +644 -0
  132. package/scripts/swarm_launch.py +1206 -0
  133. package/scripts/swarm_readiness.py +554 -0
  134. package/scripts/swarm_verify_review_clean.py +438 -0
  135. package/scripts/swarm_worktrees.py +497 -0
  136. package/scripts/toolchain-check.py +52 -0
  137. package/scripts/triage_actions.py +871 -0
  138. package/scripts/triage_bootstrap.py +1153 -0
  139. package/scripts/triage_bulk.py +630 -0
  140. package/scripts/triage_classify.py +932 -0
  141. package/scripts/triage_help.py +1685 -0
  142. package/scripts/triage_queue.py +1944 -0
  143. package/scripts/triage_reconcile.py +581 -0
  144. package/scripts/triage_refresh.py +643 -0
  145. package/scripts/triage_scope.py +999 -0
  146. package/scripts/triage_scope_drift.py +575 -0
  147. package/scripts/triage_smoketest.py +396 -0
  148. package/scripts/triage_subscribe.py +399 -0
  149. package/scripts/triage_summary.py +1011 -0
  150. package/scripts/triage_welcome.py +1178 -0
  151. package/scripts/ts_check_lane.py +86 -0
  152. package/scripts/validate-links.py +64 -0
  153. package/scripts/validate_strategy_output.py +212 -0
  154. package/scripts/vbrief_activate.py +228 -0
  155. package/scripts/vbrief_migrate_conformance.py +368 -0
  156. package/scripts/vbrief_reconcile_graph.py +306 -0
  157. package/scripts/vbrief_reconcile_labels.py +460 -0
  158. package/scripts/vbrief_reconcile_umbrellas.py +741 -0
  159. package/scripts/vbrief_validate.py +1195 -0
  160. package/scripts/verify-stubs.py +61 -0
  161. package/scripts/verify_capacity.py +160 -0
  162. package/scripts/verify_encoding.py +699 -0
  163. package/scripts/verify_hooks_installed.py +206 -0
  164. package/scripts/verify_investigation.py +360 -0
  165. package/scripts/verify_judgment_gates.py +827 -0
  166. package/scripts/verify_no_task_runtime.py +171 -0
  167. package/scripts/verify_scm_boundary.py +509 -0
  168. package/scripts/verify_session_ritual.py +389 -0
  169. package/scripts/verify_tools.py +426 -0
  170. package/scripts/verify_vbrief_conformance.py +478 -0
  171. package/tasks/architecture.yml +13 -0
  172. package/tasks/cache.yml +69 -0
  173. package/tasks/capacity.yml +38 -0
  174. package/tasks/change.yml +46 -0
  175. package/tasks/changelog.yml +24 -0
  176. package/tasks/ci.yml +49 -0
  177. package/tasks/codebase.yml +47 -0
  178. package/tasks/commit.yml +30 -0
  179. package/tasks/core.yml +126 -0
  180. package/tasks/deployments.yml +54 -0
  181. package/tasks/framework.yml +74 -0
  182. package/tasks/install.yml +60 -0
  183. package/tasks/issue.yml +50 -0
  184. package/tasks/migrate.yml +73 -0
  185. package/tasks/packs.yml +92 -0
  186. package/tasks/policy.yml +75 -0
  187. package/tasks/pr.yml +89 -0
  188. package/tasks/prd.yml +39 -0
  189. package/tasks/project.yml +27 -0
  190. package/tasks/reconcile.yml +32 -0
  191. package/tasks/relocate.yml +56 -0
  192. package/tasks/roadmap.yml +28 -0
  193. package/tasks/scm.yml +126 -0
  194. package/tasks/scope-undo.yml +36 -0
  195. package/tasks/scope.yml +141 -0
  196. package/tasks/session.yml +19 -0
  197. package/tasks/setup.yml +37 -0
  198. package/tasks/slice.yml +69 -0
  199. package/tasks/spec.yml +41 -0
  200. package/tasks/swarm.yml +85 -0
  201. package/tasks/toolchain.yml +13 -0
  202. package/tasks/triage-actions.yml +94 -0
  203. package/tasks/triage-bootstrap.yml +43 -0
  204. package/tasks/triage-bulk.yml +75 -0
  205. package/tasks/triage-classify.yml +30 -0
  206. package/tasks/triage-queue.yml +50 -0
  207. package/tasks/triage-reconcile.yml +29 -0
  208. package/tasks/triage-scope-drift.yml +29 -0
  209. package/tasks/triage-scope.yml +31 -0
  210. package/tasks/triage-smoketest.yml +33 -0
  211. package/tasks/triage-subscribe.yml +36 -0
  212. package/tasks/triage-summary.yml +29 -0
  213. package/tasks/triage-welcome.yml +32 -0
  214. package/tasks/ts.yml +328 -0
  215. package/tasks/vbrief.yml +206 -0
  216. package/tasks/verify.yml +292 -0
  217. package/templates/agents-entry.md +1 -1
@@ -0,0 +1,568 @@
1
+ #!/usr/bin/env python3
2
+ """_lifecycle_hygiene.py -- stranded-slice + epic-staleness detector (#1419 Slice 6).
3
+
4
+ Filesystem-truth, fully offline detector that reads epic + child status straight
5
+ from the vBRIEF lifecycle folders (``vbrief/{proposed,pending,active,completed,
6
+ cancelled}/``) and surfaces two session-start lifecycle-hygiene nudges:
7
+
8
+ * **Stranded slice (Tier 1)** -- a *partially-completed* epic (>= 1 completed
9
+ child, at least one child not yet complete) that has been dormant longer than
10
+ ``epicStrandedDays`` (default 30). The completed slice keeps its bucket; the
11
+ debt is forward-recognized via a **trichotomy**: ``finish`` /
12
+ ``cancel-and-remove`` / ``accept-as-tech-debt``.
13
+ * **Stale epic (Tier 2)** -- an *undecomposed* epic (no child references on
14
+ disk) that has been dormant longer than ``epicStalenessDays`` (default 14).
15
+ Surfaces a ``needs estimation/decomposition`` nudge.
16
+
17
+ Accepting a stranded epic as tech-debt records a follow-up reference in the
18
+ durable ledger ``vbrief/.audit/epic-tech-debt-accepted.jsonl`` and the detector
19
+ then stops re-nudging for that epic.
20
+
21
+ Thresholds are read from ``plan.policy.capacityAllocation`` (the #1419 Slice 4
22
+ surface owned by ``scripts/policy.py``). ``policy.resolve_capacity_allocation``
23
+ does not expose ``epicStrandedDays`` and uses a different framework default for
24
+ ``epicStalenessDays`` (its capacity-estimate-staleness hint), so this module
25
+ reads the raw block via ``policy.load_project_definition`` and applies the
26
+ RFC OQ4 defaults (stranded 30 / staleness 14) when a field is absent.
27
+
28
+ Pure-stdlib library module (no CLI, no ``gh``/network). Consumed by
29
+ ``scripts/triage_welcome.py`` via the shared session-start nudge ranking.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import sys
36
+ from dataclasses import dataclass
37
+ from datetime import UTC, datetime
38
+ from pathlib import Path
39
+ from typing import Any
40
+
41
+ # Make sibling helpers importable both as a direct import and under pytest.
42
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
43
+
44
+ import policy # noqa: E402 (sibling import after sys.path tweak)
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Public constants
48
+ # ---------------------------------------------------------------------------
49
+
50
+ #: Default dormancy (days) past which a partially-completed epic is stranded.
51
+ #: RFC #1419 schema / Decisions Log: ``epicStrandedDays`` default 30.
52
+ EPIC_STRANDED_DAYS_DEFAULT: int = 30
53
+
54
+ #: Default dormancy (days) past which an undecomposed epic is stale and wants
55
+ #: estimation/decomposition. RFC #1419 OQ4: ``epicStalenessDays`` default 14.
56
+ EPIC_STALENESS_DAYS_DEFAULT: int = 14
57
+
58
+ #: vBRIEF ``plan.metadata.kind`` values treated as epic-like parents.
59
+ PARENT_KINDS: frozenset[str] = frozenset({"epic", "phase"})
60
+
61
+ #: Lifecycle folders scanned for epics + children (filesystem-truth view).
62
+ LIFECYCLE_FOLDERS: tuple[str, ...] = (
63
+ "proposed",
64
+ "pending",
65
+ "active",
66
+ "completed",
67
+ "cancelled",
68
+ )
69
+
70
+ #: ``plan.status`` values that make an epic terminal -- a completed / cancelled
71
+ #: epic never nudges (the work is closed, not stranded).
72
+ TERMINAL_STATUSES: frozenset[str] = frozenset({"completed", "cancelled", "failed"})
73
+
74
+ #: Child reference type that marks an epic as decomposed (mirrors
75
+ #: ``scripts/capacity_show.py::_plan_has_children``).
76
+ CHILD_REF_TYPE: str = "x-vbrief/plan"
77
+
78
+ #: Durable tech-debt acceptance ledger (#1419 Receipts & Audit -- the
79
+ #: authority-bearing ``vbrief/.audit/`` tier, append-only, must survive).
80
+ TECH_DEBT_LEDGER_RELPATH: tuple[str, ...] = (
81
+ "vbrief",
82
+ ".audit",
83
+ "epic-tech-debt-accepted.jsonl",
84
+ )
85
+
86
+ #: Session-start nudge tiers (rate-of-harm ranking, #1419 Nudge Budgeting).
87
+ TIER_STRANDED: int = 1
88
+ TIER_STALE_EPIC: int = 2
89
+ #: Capacity classification cold-start (#1606) -- lowest rate-of-harm: the data
90
+ #: exists, accounting is just dormant until a one-time backfill runs.
91
+ TIER_CAPACITY_COLDSTART: int = 3
92
+
93
+ #: Stable nudge id for the singleton capacity cold-start nudge.
94
+ CAPACITY_COLDSTART_NUDGE_ID: str = "capacity-coldstart"
95
+
96
+ #: Default actor recorded in the tech-debt ledger.
97
+ DEFAULT_ACTOR: str = "lifecycle-hygiene"
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # Data model
102
+ # ---------------------------------------------------------------------------
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class EpicThresholds:
107
+ """Resolved dormancy thresholds (days) for the two lifecycle nudges."""
108
+
109
+ stranded_days: int
110
+ staleness_days: int
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class _VbriefOnDisk:
115
+ """One vBRIEF's lifecycle-relevant facts, derived from disk."""
116
+
117
+ name: str # basename (immutable per the filename convention)
118
+ folder: str
119
+ rel_path: str # e.g. "active/2026-...-foo.vbrief.json"
120
+ plan: dict[str, Any]
121
+ updated: datetime | None
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class LifecycleNudge:
126
+ """One ranked session-start lifecycle-hygiene nudge."""
127
+
128
+ nudge_id: str # epic basename -- the stable tech-debt ledger key
129
+ kind: str # "stranded" | "stale-epic"
130
+ tier: int # TIER_STRANDED | TIER_STALE_EPIC
131
+ title: str
132
+ epic_rel_path: str
133
+ dormant_days: int
134
+ completed_children: int
135
+ total_children: int
136
+ magnitude: int # ranking magnitude (dormancy days)
137
+ message: str # rendered one-line nudge (ASCII-only)
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Threshold resolution (reads the #1419 Slice 4 capacityAllocation surface)
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ def _positive_int(value: Any, default: int) -> int:
146
+ """Return *value* when it is a positive ``int`` (``bool`` excluded), else *default*."""
147
+ if isinstance(value, int) and not isinstance(value, bool) and value > 0:
148
+ return value
149
+ return default
150
+
151
+
152
+ def resolve_epic_thresholds(project_root: Path) -> EpicThresholds:
153
+ """Resolve ``epicStrandedDays`` / ``epicStalenessDays`` from PROJECT-DEFINITION.
154
+
155
+ Reads the raw ``plan.policy.capacityAllocation`` block (the Slice 4 surface)
156
+ via :func:`policy.load_project_definition`. Missing / malformed fields fall
157
+ back to the RFC defaults (30 / 14). Never raises -- a missing or unreadable
158
+ PROJECT-DEFINITION resolves to the framework defaults.
159
+ """
160
+ data, _err = policy.load_project_definition(project_root)
161
+ raw: dict[str, Any] = {}
162
+ if isinstance(data, dict):
163
+ plan = data.get("plan")
164
+ if isinstance(plan, dict):
165
+ pol = plan.get("policy")
166
+ if isinstance(pol, dict):
167
+ cap = pol.get("capacityAllocation")
168
+ if isinstance(cap, dict):
169
+ raw = cap
170
+ return EpicThresholds(
171
+ stranded_days=_positive_int(raw.get("epicStrandedDays"), EPIC_STRANDED_DAYS_DEFAULT),
172
+ staleness_days=_positive_int(raw.get("epicStalenessDays"), EPIC_STALENESS_DAYS_DEFAULT),
173
+ )
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Filesystem scan helpers
178
+ # ---------------------------------------------------------------------------
179
+
180
+
181
+ def _parse_iso(value: Any) -> datetime | None:
182
+ """Parse an ISO-8601 ``...Z`` timestamp to an aware datetime, or None."""
183
+ if not isinstance(value, str) or not value.strip():
184
+ return None
185
+ text = value.strip()
186
+ if text.endswith("Z"):
187
+ text = text[:-1] + "+00:00"
188
+ try:
189
+ parsed = datetime.fromisoformat(text)
190
+ except ValueError:
191
+ return None
192
+ if parsed.tzinfo is None:
193
+ parsed = parsed.replace(tzinfo=UTC)
194
+ return parsed.astimezone(UTC)
195
+
196
+
197
+ def _updated_at(plan: dict[str, Any], path: Path) -> datetime | None:
198
+ """Best-effort last-activity timestamp: ``plan.updated`` then file mtime."""
199
+ stamp = _parse_iso(plan.get("updated"))
200
+ if stamp is not None:
201
+ return stamp
202
+ try:
203
+ return datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)
204
+ except OSError:
205
+ return None
206
+
207
+
208
+ def _kind(plan: dict[str, Any]) -> str:
209
+ metadata = plan.get("metadata")
210
+ if isinstance(metadata, dict):
211
+ raw = metadata.get("kind")
212
+ if isinstance(raw, str) and raw:
213
+ return raw
214
+ return "story"
215
+
216
+
217
+ def _status(record: _VbriefOnDisk) -> str:
218
+ """Resolved status -- ``plan.status`` is source of truth, folder is fallback."""
219
+ raw = record.plan.get("status")
220
+ if isinstance(raw, str) and raw:
221
+ return raw
222
+ # Folder-derived fallback (vbrief.md status-driven moves).
223
+ if record.folder == "completed":
224
+ return "completed"
225
+ if record.folder == "cancelled":
226
+ return "cancelled"
227
+ return ""
228
+
229
+
230
+ def _is_completed(record: _VbriefOnDisk) -> bool:
231
+ return _status(record) == "completed" or record.folder == "completed"
232
+
233
+
234
+ def _child_ref_names(plan: dict[str, Any]) -> list[str]:
235
+ """Basenames of ``x-vbrief/plan`` child references declared on *plan*."""
236
+ refs = plan.get("references")
237
+ if not isinstance(refs, list):
238
+ return []
239
+ names: list[str] = []
240
+ for ref in refs:
241
+ if not isinstance(ref, dict) or ref.get("type") != CHILD_REF_TYPE:
242
+ continue
243
+ uri = ref.get("uri")
244
+ if isinstance(uri, str) and uri.strip():
245
+ names.append(Path(uri.strip()).name)
246
+ return names
247
+
248
+
249
+ def _iter_vbriefs(project_root: Path) -> list[_VbriefOnDisk]:
250
+ """Scan every lifecycle folder once. Malformed files are skipped."""
251
+ out: list[_VbriefOnDisk] = []
252
+ vroot = project_root / "vbrief"
253
+ for folder in LIFECYCLE_FOLDERS:
254
+ fdir = vroot / folder
255
+ if not fdir.is_dir():
256
+ continue
257
+ for child in sorted(fdir.glob("*.vbrief.json")):
258
+ if not child.is_file():
259
+ continue
260
+ try:
261
+ data = json.loads(child.read_text(encoding="utf-8"))
262
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
263
+ continue
264
+ plan = data.get("plan") if isinstance(data, dict) else None
265
+ if not isinstance(plan, dict):
266
+ continue
267
+ out.append(
268
+ _VbriefOnDisk(
269
+ name=child.name,
270
+ folder=folder,
271
+ rel_path=f"{folder}/{child.name}",
272
+ plan=plan,
273
+ updated=_updated_at(plan, child),
274
+ )
275
+ )
276
+ return out
277
+
278
+
279
+ def _dormancy_days(stamps: list[datetime | None], now: datetime) -> int | None:
280
+ """Whole days since the most-recent activity across *stamps* (None when unknown)."""
281
+ known = [s for s in stamps if s is not None]
282
+ if not known:
283
+ return None
284
+ most_recent = max(known)
285
+ delta = now - most_recent
286
+ return max(0, int(delta.total_seconds() // 86400))
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # Tech-debt acceptance ledger (durable vbrief/.audit/ receipts)
291
+ # ---------------------------------------------------------------------------
292
+
293
+
294
+ def tech_debt_ledger_path(project_root: Path) -> Path:
295
+ """Absolute path to ``vbrief/.audit/epic-tech-debt-accepted.jsonl``."""
296
+ return project_root.joinpath(*TECH_DEBT_LEDGER_RELPATH)
297
+
298
+
299
+ def _utc_iso(dt: datetime | None = None) -> str:
300
+ return (dt or datetime.now(UTC)).astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
301
+
302
+
303
+ def record_tech_debt_acceptance(
304
+ project_root: Path,
305
+ epic: str,
306
+ *,
307
+ follow_up_ref: str,
308
+ actor: str = DEFAULT_ACTOR,
309
+ now: datetime | None = None,
310
+ ) -> Path:
311
+ """Append a tech-debt acceptance record and stop re-nudging the epic.
312
+
313
+ *epic* may be a basename or a lifecycle-relative path; the immutable
314
+ basename is stored as the ledger key. *follow_up_ref* records where the
315
+ accepted debt is tracked (a tech-debt vBRIEF path or issue reference) so
316
+ the acceptance is auditable. Append-only JSONL write (mkdir + open ``a``)
317
+ mirrors the durable-audit convention in ``scripts/triage_welcome.py``.
318
+ """
319
+ epic_key = Path(epic.strip()).name
320
+ if not epic_key:
321
+ raise ValueError("epic must be a non-empty basename or path")
322
+ if not isinstance(follow_up_ref, str) or not follow_up_ref.strip():
323
+ raise ValueError("follow_up_ref must be a non-empty reference string")
324
+ path = tech_debt_ledger_path(project_root)
325
+ path.parent.mkdir(parents=True, exist_ok=True)
326
+ record = {
327
+ "epic": epic_key,
328
+ "follow_up_ref": follow_up_ref.strip(),
329
+ "accepted_at": _utc_iso(now),
330
+ "actor": actor,
331
+ }
332
+ with open(path, "a", encoding="utf-8") as handle:
333
+ handle.write(json.dumps(record, sort_keys=True) + "\n")
334
+ return path
335
+
336
+
337
+ def load_accepted_debt_keys(project_root: Path) -> set[str]:
338
+ """Return the set of epic basenames already accepted as tech-debt."""
339
+ path = tech_debt_ledger_path(project_root)
340
+ if not path.is_file():
341
+ return set()
342
+ keys: set[str] = set()
343
+ try:
344
+ text = path.read_text(encoding="utf-8")
345
+ except (OSError, UnicodeDecodeError):
346
+ return keys
347
+ for line in text.splitlines():
348
+ stripped = line.strip()
349
+ if not stripped:
350
+ continue
351
+ try:
352
+ obj = json.loads(stripped)
353
+ except json.JSONDecodeError:
354
+ continue
355
+ if isinstance(obj, dict):
356
+ epic = obj.get("epic")
357
+ if isinstance(epic, str) and epic:
358
+ keys.add(epic)
359
+ return keys
360
+
361
+
362
+ # ---------------------------------------------------------------------------
363
+ # Nudge rendering
364
+ # ---------------------------------------------------------------------------
365
+
366
+
367
+ def _render_stranded(
368
+ *, title: str, dormant: int, threshold: int, completed: int, total: int
369
+ ) -> str:
370
+ return (
371
+ f'[TIER-1] stranded slice: epic "{title}" dormant {dormant}d '
372
+ f"(> epicStrandedDays {threshold}) with {completed}/{total} children "
373
+ "completed -- finish | cancel-and-remove | accept-as-tech-debt "
374
+ "(see `task capacity:show`)"
375
+ )
376
+
377
+
378
+ def _render_stale_epic(*, title: str, dormant: int, threshold: int) -> str:
379
+ return (
380
+ f'[TIER-2] stale epic: undecomposed epic "{title}" dormant {dormant}d '
381
+ f"(> epicStalenessDays {threshold}) -- needs estimation/decomposition"
382
+ )
383
+
384
+
385
+ def _render_capacity_coldstart(*, unclassified: int, classified: int, minimum: int) -> str:
386
+ return (
387
+ f"[TIER-3] capacity cold-start: {unclassified} completed vBRIEF(s) "
388
+ f"unclassified (classified {classified}/{minimum} in window) -- run "
389
+ "`task capacity:backfill --apply` to classify history and activate "
390
+ "capacity accounting (#1606)"
391
+ )
392
+
393
+
394
+ # ---------------------------------------------------------------------------
395
+ # Detector
396
+ # ---------------------------------------------------------------------------
397
+
398
+
399
+ def detect_lifecycle_nudges(
400
+ project_root: Path, *, now: datetime | None = None
401
+ ) -> list[LifecycleNudge]:
402
+ """Detect stranded-slice (Tier 1) + stale-epic (Tier 2) nudges.
403
+
404
+ Filesystem-truth, offline. Epics already accepted as tech-debt are skipped
405
+ (no re-nudging). Results are ranked by ``(tier, -magnitude, nudge_id)`` so
406
+ the most harmful nudge sorts first for the budgeted session-start surface.
407
+ """
408
+ now_dt = now or datetime.now(UTC)
409
+ thresholds = resolve_epic_thresholds(project_root)
410
+ accepted = load_accepted_debt_keys(project_root)
411
+
412
+ records = _iter_vbriefs(project_root)
413
+ index: dict[str, _VbriefOnDisk] = {r.name: r for r in records}
414
+
415
+ nudges: list[LifecycleNudge] = []
416
+ for record in records:
417
+ if _kind(record.plan) not in PARENT_KINDS:
418
+ continue
419
+ if _status(record) in TERMINAL_STATUSES:
420
+ continue
421
+ if record.name in accepted:
422
+ continue
423
+
424
+ child_names = _child_ref_names(record.plan)
425
+ resolved = [index[name] for name in child_names if name in index]
426
+ if resolved:
427
+ nudge = _stranded_nudge(record, child_names, resolved, thresholds, now_dt)
428
+ else:
429
+ # Two cases route here: a truly undecomposed epic (no child refs at
430
+ # all) AND an epic whose declared children are ALL unresolvable on
431
+ # disk (e.g. child vBRIEFs deleted without updating the parent's
432
+ # references). Both surface as a stale-epic nudge so a stranded epic
433
+ # cannot fall silently through every path (#1508 review).
434
+ nudge = _stale_epic_nudge(record, thresholds, now_dt)
435
+ if nudge is not None:
436
+ nudges.append(nudge)
437
+
438
+ capacity_nudge = detect_capacity_coldstart_nudge(project_root, now=now_dt)
439
+ if capacity_nudge is not None:
440
+ nudges.append(capacity_nudge)
441
+
442
+ nudges.sort(key=lambda n: (n.tier, -n.magnitude, n.nudge_id))
443
+ return nudges
444
+
445
+
446
+ def detect_capacity_coldstart_nudge(
447
+ project_root: Path, *, now: datetime | None = None
448
+ ) -> LifecycleNudge | None:
449
+ """Capacity classification cold-start (Tier 3) nudge (#1606).
450
+
451
+ Fires only when capacity buckets ARE configured yet the completed history
452
+ is classification-cold: ``classified_completions`` is below
453
+ ``minSampleSize`` AND at least one completed vBRIEF carries no explicit
454
+ ``capacityBucket``. In that state ``task capacity:backfill`` is the one-time
455
+ action that crosses ``minSampleSize`` and lifts the engine out of advisory
456
+ mode. Suppressed when capacity is unconfigured (nothing to classify
457
+ against) or already classified past the sample floor (no cold-start).
458
+
459
+ ``capacity_show`` is imported lazily so the common nudge path (epic
460
+ hygiene only) does not pay the import cost, and so this module stays
461
+ import-cycle-free for callers that only need the epic detectors.
462
+ """
463
+ allocation = policy.resolve_capacity_allocation(project_root)
464
+ if not allocation.configured:
465
+ return None
466
+
467
+ import capacity_show # noqa: PLC0415 -- lazy; avoids import cost / cycle
468
+
469
+ report = capacity_show.compute_report(project_root, now=now, allocation=allocation)
470
+ if report.classified_completions >= report.min_sample_size:
471
+ return None
472
+ if report.unclassified_completions <= 0:
473
+ return None
474
+
475
+ message = _render_capacity_coldstart(
476
+ unclassified=report.unclassified_completions,
477
+ classified=report.classified_completions,
478
+ minimum=report.min_sample_size,
479
+ )
480
+ return LifecycleNudge(
481
+ nudge_id=CAPACITY_COLDSTART_NUDGE_ID,
482
+ kind="capacity-coldstart",
483
+ tier=TIER_CAPACITY_COLDSTART,
484
+ title="capacity cold-start",
485
+ epic_rel_path="",
486
+ dormant_days=0,
487
+ completed_children=0,
488
+ total_children=0,
489
+ magnitude=report.unclassified_completions,
490
+ message=message,
491
+ )
492
+
493
+
494
+ def _stranded_nudge(
495
+ epic: _VbriefOnDisk,
496
+ child_names: list[str],
497
+ resolved: list[_VbriefOnDisk],
498
+ thresholds: EpicThresholds,
499
+ now: datetime,
500
+ ) -> LifecycleNudge | None:
501
+ """Stranded-slice (Tier 1) nudge for a partially-completed dormant epic.
502
+
503
+ *resolved* is the subset of the epic's declared children that exist on disk
504
+ (the caller routes an all-unresolvable epic to the stale-epic path instead).
505
+ """
506
+ completed = [c for c in resolved if _is_completed(c)]
507
+ total = len(child_names)
508
+ # Partially-completed: at least one child done AND not every child done
509
+ # (unresolved / removed refs count as not-done -- the stranded case).
510
+ if not completed or len(completed) >= total:
511
+ return None
512
+
513
+ stamps = [epic.updated, *(c.updated for c in resolved)]
514
+ dormant = _dormancy_days(stamps, now)
515
+ if dormant is None or dormant <= thresholds.stranded_days:
516
+ return None
517
+
518
+ title = _title(epic)
519
+ return LifecycleNudge(
520
+ nudge_id=epic.name,
521
+ kind="stranded",
522
+ tier=TIER_STRANDED,
523
+ title=title,
524
+ epic_rel_path=epic.rel_path,
525
+ dormant_days=dormant,
526
+ completed_children=len(completed),
527
+ total_children=total,
528
+ magnitude=dormant,
529
+ message=_render_stranded(
530
+ title=title,
531
+ dormant=dormant,
532
+ threshold=thresholds.stranded_days,
533
+ completed=len(completed),
534
+ total=total,
535
+ ),
536
+ )
537
+
538
+
539
+ def _stale_epic_nudge(
540
+ epic: _VbriefOnDisk, thresholds: EpicThresholds, now: datetime
541
+ ) -> LifecycleNudge | None:
542
+ """Stale-epic (Tier 2) nudge for an undecomposed dormant epic."""
543
+ dormant = _dormancy_days([epic.updated], now)
544
+ if dormant is None or dormant <= thresholds.staleness_days:
545
+ return None
546
+
547
+ title = _title(epic)
548
+ return LifecycleNudge(
549
+ nudge_id=epic.name,
550
+ kind="stale-epic",
551
+ tier=TIER_STALE_EPIC,
552
+ title=title,
553
+ epic_rel_path=epic.rel_path,
554
+ dormant_days=dormant,
555
+ completed_children=0,
556
+ total_children=0,
557
+ magnitude=dormant,
558
+ message=_render_stale_epic(
559
+ title=title, dormant=dormant, threshold=thresholds.staleness_days
560
+ ),
561
+ )
562
+
563
+
564
+ def _title(record: _VbriefOnDisk) -> str:
565
+ raw = record.plan.get("title")
566
+ if isinstance(raw, str) and raw.strip():
567
+ return raw.strip()
568
+ return record.name
@@ -0,0 +1,91 @@
1
+ """_pathspec.py -- minimal gitignore-style glob matcher (#1419 Delivery Slice 3).
2
+
3
+ The judgment-gate engine (``scripts/verify_judgment_gates.py``) needs a path
4
+ predicate so a gate can match a diff that touches, say, ``secrets/**`` or
5
+ ``**/*.pem``. Python's stdlib ``fnmatch`` treats ``*`` as matching across path
6
+ separators and has no ``**`` concept, so it is the wrong tool for path globs.
7
+ This helper translates a small, well-defined glob dialect to a compiled regex:
8
+
9
+ * ``*`` -- matches any run of characters WITHIN a single path segment
10
+ (it does NOT cross ``/``).
11
+ * ``?`` -- matches exactly one non-``/`` character.
12
+ * ``**`` -- matches any number of path segments (including zero). ``a/**/b``
13
+ matches ``a/b``, ``a/x/b``, ``a/x/y/b``; ``**/foo`` matches ``foo``
14
+ and ``x/y/foo``; ``secrets/**`` matches anything under ``secrets/``.
15
+ * every other character is matched literally.
16
+
17
+ Paths and patterns are normalised to forward slashes so a Windows-style
18
+ ``a\\b`` diff path matches an ``a/b`` glob. Matching is case-sensitive
19
+ (POSIX path semantics); callers that need case-insensitivity should lower-case
20
+ both sides before calling.
21
+
22
+ Pure stdlib so the helper stays importable from git hooks without ``uv``.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import re
28
+ from functools import lru_cache
29
+
30
+
31
+ def _normalize(path: str) -> str:
32
+ """Return *path* with backslashes folded to forward slashes."""
33
+ return path.replace("\\", "/")
34
+
35
+
36
+ @lru_cache(maxsize=512)
37
+ def _compile(pattern: str) -> re.Pattern[str]:
38
+ """Translate a glob *pattern* to an anchored, compiled regex.
39
+
40
+ Cached because the universal gates re-evaluate the same handful of
41
+ patterns against every candidate path on every gate run.
42
+ """
43
+ glob = _normalize(pattern)
44
+ i, n = 0, len(glob)
45
+ out: list[str] = ["^"]
46
+ while i < n:
47
+ char = glob[i]
48
+ if char == "*":
49
+ if glob[i : i + 2] == "**":
50
+ # Consume the full run of '*' so '***' degrades to '**'.
51
+ j = i
52
+ while j < n and glob[j] == "*":
53
+ j += 1
54
+ # A '**/' segment matches zero or more leading directories;
55
+ # a trailing '**' (no slash) matches the rest of the path.
56
+ if j < n and glob[j] == "/":
57
+ out.append("(?:.*/)?")
58
+ i = j + 1
59
+ else:
60
+ out.append(".*")
61
+ i = j
62
+ else:
63
+ out.append("[^/]*")
64
+ i += 1
65
+ elif char == "?":
66
+ out.append("[^/]")
67
+ i += 1
68
+ elif char == "/":
69
+ out.append("/")
70
+ i += 1
71
+ else:
72
+ out.append(re.escape(char))
73
+ i += 1
74
+ out.append("$")
75
+ return re.compile("".join(out))
76
+
77
+
78
+ def match_path(pattern: str, path: str) -> bool:
79
+ """True when *path* matches the glob *pattern*."""
80
+ if not isinstance(pattern, str) or not pattern:
81
+ return False
82
+ if not isinstance(path, str) or not path:
83
+ return False
84
+ return _compile(pattern).match(_normalize(path)) is not None
85
+
86
+
87
+ def match_any(patterns: object, path: str) -> bool:
88
+ """True when *path* matches any glob in *patterns* (an iterable of str)."""
89
+ if not isinstance(patterns, (list, tuple)):
90
+ return False
91
+ return any(match_path(p, path) for p in patterns if isinstance(p, str) and p)