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