@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.
- package/.githooks/pre-commit +10 -128
- package/.githooks/pre-push +8 -108
- package/Taskfile.yml +48 -58
- package/UPGRADING.md +19 -3
- package/docs/assets/directive-lifecycle-diagram.png +0 -0
- package/docs/directive-lifecycle.md +73 -0
- package/docs/getting-started.md +5 -1
- package/package.json +3 -3
- package/packs/skills/skills-pack-0.1.json +1 -1
- package/packs/strategies/strategies-pack-0.1.json +19 -19
- package/scm/github.md +37 -6
- package/skills/deft-directive-setup/SKILL.md +24 -15
- package/strategies/speckit.md +14 -14
- package/strategies/v0-20-contract.md +12 -1
- package/tasks/change.yml +16 -31
- package/tasks/ci.yml +8 -0
- package/tasks/commit.yml +12 -19
- package/tasks/core.yml +10 -0
- package/tasks/engine.yml +42 -0
- package/tasks/framework.yml +3 -0
- package/tasks/install.yml +20 -19
- package/tasks/migrate.yml +26 -15
- package/tasks/project.yml +26 -0
- package/tasks/toolchain.yml +15 -5
- package/tasks/vbrief.yml +4 -3
- package/tasks/verify.yml +12 -14
- package/templates/agents-entry.md +1 -1
- package/scripts/_agents_md.py +0 -494
- package/scripts/_cache_fetch.py +0 -635
- package/scripts/_cache_quota.py +0 -529
- package/scripts/_cache_refresh.py +0 -163
- package/scripts/_cache_validate.py +0 -209
- package/scripts/_content_root.py +0 -42
- package/scripts/_doctor_state.py +0 -277
- package/scripts/_event_detect.py +0 -305
- package/scripts/_events.py +0 -514
- package/scripts/_lifecycle_hygiene.py +0 -568
- package/scripts/_pathspec.py +0 -91
- package/scripts/_policy_show_cli.py +0 -266
- package/scripts/_precutover.py +0 -92
- package/scripts/_project_context.py +0 -224
- package/scripts/_project_definition_io.py +0 -164
- package/scripts/_relocate_snapshot.py +0 -209
- package/scripts/_relocate_states.py +0 -343
- package/scripts/_resolve_preflight_path.py +0 -152
- package/scripts/_safe_subprocess.py +0 -167
- package/scripts/_session_start_hook.py +0 -205
- package/scripts/_sor_gate_diff.py +0 -365
- package/scripts/_stdio_utf8.py +0 -59
- package/scripts/_triage_bootstrap_gitignore.py +0 -904
- package/scripts/_triage_classify_cli.py +0 -122
- package/scripts/_triage_queue_cli.py +0 -625
- package/scripts/_triage_scope_cli.py +0 -343
- package/scripts/_triage_scope_drift_cli.py +0 -121
- package/scripts/_triage_scope_ignores.py +0 -286
- package/scripts/_triage_scope_milestone.py +0 -432
- package/scripts/_triage_scope_mutations.py +0 -337
- package/scripts/_triage_scope_renderers.py +0 -207
- package/scripts/_triage_smoketest_stages.py +0 -674
- package/scripts/_triage_subscribe_cli.py +0 -140
- package/scripts/_triage_welcome_cli.py +0 -421
- package/scripts/_vbrief_build.py +0 -239
- package/scripts/_vbrief_fidelity.py +0 -479
- package/scripts/_vbrief_legacy.py +0 -589
- package/scripts/_vbrief_reconciliation.py +0 -883
- package/scripts/_vbrief_routing.py +0 -277
- package/scripts/_vbrief_safety.py +0 -778
- package/scripts/_vbrief_sources.py +0 -312
- package/scripts/_vbrief_speckit.py +0 -262
- package/scripts/_vbrief_story_quality.py +0 -353
- package/scripts/_vbrief_validation.py +0 -299
- package/scripts/build_dist.py +0 -412
- package/scripts/cache.py +0 -1078
- package/scripts/cache_scanner.py +0 -745
- package/scripts/candidates_log.py +0 -432
- package/scripts/capacity_backfill.py +0 -680
- package/scripts/capacity_show.py +0 -653
- package/scripts/ci_local.py +0 -689
- package/scripts/code_structure_validate.py +0 -765
- package/scripts/codebase_default_extractor.py +0 -495
- package/scripts/codebase_map.py +0 -304
- package/scripts/codebase_map_fresh.py +0 -104
- package/scripts/codebase_projection_registry.py +0 -94
- package/scripts/codebase_provider.py +0 -582
- package/scripts/doctor.py +0 -2552
- package/scripts/framework_commands.py +0 -505
- package/scripts/gh_rest.py +0 -882
- package/scripts/github_auth_modes.py +0 -437
- package/scripts/github_body.py +0 -292
- package/scripts/ip_risk.py +0 -531
- package/scripts/issue_emit.py +0 -670
- package/scripts/issue_ingest.py +0 -1064
- package/scripts/migrate_preflight.py +0 -418
- package/scripts/migrate_vbrief.py +0 -2677
- package/scripts/monitor_pr.py +0 -401
- package/scripts/pack_migrate_lessons.py +0 -336
- package/scripts/pack_migrate_patterns.py +0 -254
- package/scripts/pack_migrate_rules.py +0 -350
- package/scripts/pack_migrate_skills.py +0 -423
- package/scripts/pack_migrate_strategies.py +0 -311
- package/scripts/pack_migrate_swarm_spec.py +0 -250
- package/scripts/pack_render.py +0 -434
- package/scripts/packs_slice.py +0 -712
- package/scripts/platform_capabilities.py +0 -336
- package/scripts/policy.py +0 -2826
- package/scripts/policy_set.py +0 -324
- package/scripts/pr_check_closing_keywords.py +0 -524
- package/scripts/pr_check_protected_issues.py +0 -267
- package/scripts/pr_merge_readiness.py +0 -1004
- package/scripts/pr_wait_mergeable.py +0 -669
- package/scripts/prd_render.py +0 -159
- package/scripts/preflight_architecture_sor.py +0 -974
- package/scripts/preflight_branch.py +0 -289
- package/scripts/preflight_cache.py +0 -974
- package/scripts/preflight_gh.py +0 -721
- package/scripts/preflight_implementation.py +0 -272
- package/scripts/preflight_story_start.py +0 -838
- package/scripts/preflight_wip_cap.py +0 -149
- package/scripts/probe_session.py +0 -545
- package/scripts/project_render.py +0 -293
- package/scripts/quarantine_ext.py +0 -237
- package/scripts/reconcile_issues.py +0 -1442
- package/scripts/refresh-path.ps1 +0 -107
- package/scripts/release.py +0 -2030
- package/scripts/release_e2e.py +0 -1011
- package/scripts/release_publish.py +0 -486
- package/scripts/release_rollback.py +0 -980
- package/scripts/relocate.py +0 -1034
- package/scripts/resolve_changelog_unreleased.py +0 -667
- package/scripts/resolve_version.py +0 -490
- package/scripts/resume_conditions.py +0 -706
- package/scripts/ritual_sentinel.py +0 -609
- package/scripts/roadmap_render.py +0 -635
- package/scripts/rule_ownership_lint.py +0 -325
- package/scripts/scm.py +0 -591
- package/scripts/scope_audit_log.py +0 -387
- package/scripts/scope_decompose.py +0 -654
- package/scripts/scope_demote.py +0 -509
- package/scripts/scope_lifecycle.py +0 -1126
- package/scripts/scope_undo.py +0 -772
- package/scripts/session_start.py +0 -406
- package/scripts/setup_ghx.py +0 -339
- package/scripts/setup_windows.ps1 +0 -220
- package/scripts/slice_audit.py +0 -585
- package/scripts/slice_record.py +0 -530
- package/scripts/slice_record_existing.py +0 -692
- package/scripts/slug_normalize.py +0 -178
- package/scripts/spec_render.py +0 -477
- package/scripts/spec_validate.py +0 -238
- package/scripts/subagent_monitor.py +0 -658
- package/scripts/swarm_complete_cohort.py +0 -644
- package/scripts/swarm_launch.py +0 -1206
- package/scripts/swarm_readiness.py +0 -554
- package/scripts/swarm_verify_review_clean.py +0 -438
- package/scripts/swarm_worktrees.py +0 -497
- package/scripts/toolchain-check.py +0 -52
- package/scripts/triage_actions.py +0 -871
- package/scripts/triage_bootstrap.py +0 -1153
- package/scripts/triage_bulk.py +0 -630
- package/scripts/triage_classify.py +0 -932
- package/scripts/triage_help.py +0 -1685
- package/scripts/triage_queue.py +0 -1944
- package/scripts/triage_reconcile.py +0 -581
- package/scripts/triage_refresh.py +0 -643
- package/scripts/triage_scope.py +0 -999
- package/scripts/triage_scope_drift.py +0 -575
- package/scripts/triage_smoketest.py +0 -396
- package/scripts/triage_subscribe.py +0 -399
- package/scripts/triage_summary.py +0 -1011
- package/scripts/triage_welcome.py +0 -1178
- package/scripts/ts_check_lane.py +0 -86
- package/scripts/validate-links.py +0 -64
- package/scripts/validate_strategy_output.py +0 -212
- package/scripts/vbrief_activate.py +0 -228
- package/scripts/vbrief_migrate_conformance.py +0 -368
- package/scripts/vbrief_reconcile_graph.py +0 -306
- package/scripts/vbrief_reconcile_labels.py +0 -460
- package/scripts/vbrief_reconcile_umbrellas.py +0 -741
- package/scripts/vbrief_validate.py +0 -1144
- package/scripts/verify-stubs.py +0 -61
- package/scripts/verify_capacity.py +0 -160
- package/scripts/verify_encoding.py +0 -699
- package/scripts/verify_hooks_installed.py +0 -206
- package/scripts/verify_investigation.py +0 -360
- package/scripts/verify_judgment_gates.py +0 -827
- package/scripts/verify_no_task_runtime.py +0 -171
- package/scripts/verify_scm_boundary.py +0 -509
- package/scripts/verify_session_ritual.py +0 -389
- package/scripts/verify_tools.py +0 -426
- 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
|
package/scripts/_pathspec.py
DELETED
|
@@ -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)
|