detectkit 0.26.1__tar.gz → 0.28.0__tar.gz
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.
- {detectkit-0.26.1/detectkit.egg-info → detectkit-0.28.0}/PKG-INFO +1 -1
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/__init__.py +1 -1
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/autotune/config_emitter.py +1 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/autotune/crossval.py +24 -3
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/autotune/grid_search.py +45 -1
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/autotune/html_labeler.py +13 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/autotune/label_server.py +8 -1
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/autotune/labels.py +38 -2
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/autotune/settings.py +3 -1
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/autotune/window_select.py +63 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/assets/claude/rules/autotune.md +20 -4
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/commands/autotune.py +18 -1
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/config/metric_config.py +15 -0
- {detectkit-0.26.1 → detectkit-0.28.0/detectkit.egg-info}/PKG-INFO +1 -1
- {detectkit-0.26.1 → detectkit-0.28.0}/LICENSE +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/MANIFEST.in +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/README.md +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/alerting/__init__.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/alerting/channels/__init__.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/alerting/channels/base.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/alerting/channels/branding.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/alerting/channels/email.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/alerting/channels/factory.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/alerting/channels/mattermost.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/alerting/channels/slack.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/alerting/channels/telegram.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/alerting/channels/webhook.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/alerting/orchestrator/_base.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/alerting/orchestrator/_types.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/autotune/__init__.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/autotune/_base.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/autotune/_types.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/autotune/autotuner.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/autotune/detector_select.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/autotune/distribution.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/autotune/result.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/autotune/scoring.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/autotune/seasonality_search.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/__init__.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/_output.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/assets/claude/rules/cli.md +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/commands/__init__.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/commands/clean.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/commands/init.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/commands/init_claude.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/commands/run.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/commands/test_alert.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/commands/unlock.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/main.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/config/__init__.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/config/profile.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/config/project_config.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/config/validator.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/core/__init__.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/core/interval.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/core/models.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/database/__init__.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/database/_sql_manager.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/database/clickhouse_manager.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/database/internal_tables/__init__.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/database/internal_tables/_base.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/database/internal_tables/_detections.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/database/internal_tables/_metrics.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/database/internal_tables/_schema.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/database/internal_tables/_tasks.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/database/internal_tables/manager.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/database/manager.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/database/mysql_manager.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/database/postgres_manager.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/database/tables.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/detectors/__init__.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/detectors/base.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/detectors/factory.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/detectors/seasonality.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/detectors/statistical/__init__.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/detectors/statistical/_windowed.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/detectors/statistical/iqr.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/detectors/statistical/mad.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/detectors/statistical/zscore.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/loaders/__init__.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/loaders/metric_loader.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/loaders/query_template.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/orchestration/__init__.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/orchestration/error_dispatch.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/orchestration/task_manager/_base.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/orchestration/task_manager/_types.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/orchestration/task_manager/manager.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/utils/__init__.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/utils/datetime_utils.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/utils/env_interpolation.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/utils/json_utils.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit/utils/stats.py +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit.egg-info/SOURCES.txt +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit.egg-info/dependency_links.txt +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit.egg-info/entry_points.txt +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit.egg-info/requires.txt +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/detectkit.egg-info/top_level.txt +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/pyproject.toml +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/requirements.txt +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/setup.cfg +0 -0
- {detectkit-0.26.1 → detectkit-0.28.0}/setup.py +0 -0
|
@@ -4,7 +4,7 @@ detectk - Anomaly Detection for Time-Series Metrics
|
|
|
4
4
|
A Python library for data analysts and engineers to monitor metrics with automatic anomaly detection.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
__version__ = "0.
|
|
7
|
+
__version__ = "0.28.0"
|
|
8
8
|
|
|
9
9
|
from detectkit.core.interval import Interval
|
|
10
10
|
from detectkit.core.models import ColumnDefinition, TableModel
|
|
@@ -61,6 +61,29 @@ def predictions_from_results(
|
|
|
61
61
|
return y_pred, y_score, valid
|
|
62
62
|
|
|
63
63
|
|
|
64
|
+
def _aggregate(per_fold: list[float], stability_lambda: float) -> tuple[float, float]:
|
|
65
|
+
"""Mean across folds minus a **downside-only** dispersion penalty.
|
|
66
|
+
|
|
67
|
+
Returns ``(aggregate, penalty)``. The penalty uses the semi-deviation of the
|
|
68
|
+
folds that score *below* the mean, not the full ``std``: a config that simply
|
|
69
|
+
scores *higher* on some folds — e.g. a recency-aware baseline that fits the
|
|
70
|
+
current regime better than stale history — should not be punished for that
|
|
71
|
+
*upside* spread. Penalizing full ``std`` did exactly that, biasing the search
|
|
72
|
+
against regime-adaptive configs; downside-only keeps the guard against
|
|
73
|
+
genuinely unstable candidates while letting an adaptive one win.
|
|
74
|
+
"""
|
|
75
|
+
arr = np.asarray(per_fold, dtype=float)
|
|
76
|
+
mean = float(np.mean(arr))
|
|
77
|
+
# Downside deviation: square only the shortfalls below the mean (upside → 0),
|
|
78
|
+
# averaged over ALL folds. This is always <= the full std, and reduces to 0
|
|
79
|
+
# when folds are equal — so a config that's merely *better* on recent folds is
|
|
80
|
+
# not penalized, only one that drops below par on some.
|
|
81
|
+
deficits = np.minimum(arr - mean, 0.0)
|
|
82
|
+
downside = float(np.sqrt(np.mean(deficits**2)))
|
|
83
|
+
penalty = stability_lambda * downside
|
|
84
|
+
return mean - penalty, penalty
|
|
85
|
+
|
|
86
|
+
|
|
64
87
|
def run_cv(
|
|
65
88
|
detector: BaseDetector,
|
|
66
89
|
data: dict[str, np.ndarray],
|
|
@@ -95,7 +118,5 @@ def run_cv(
|
|
|
95
118
|
if not per_fold:
|
|
96
119
|
return FoldScores(per_fold=[], aggregate=0.0, stability_penalty=0.0)
|
|
97
120
|
|
|
98
|
-
|
|
99
|
-
penalty = settings.stability_lambda * float(np.std(arr))
|
|
100
|
-
aggregate = float(np.mean(arr)) - penalty
|
|
121
|
+
aggregate, penalty = _aggregate(per_fold, settings.stability_lambda)
|
|
101
122
|
return FoldScores(per_fold=per_fold, aggregate=aggregate, stability_penalty=penalty)
|
|
@@ -13,9 +13,17 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
16
18
|
from detectkit.autotune._base import _AutoTuneBase
|
|
17
19
|
from detectkit.autotune._types import CandidateEval
|
|
18
|
-
from detectkit.autotune.window_select import
|
|
20
|
+
from detectkit.autotune.window_select import (
|
|
21
|
+
detect_level_shift,
|
|
22
|
+
half_life_grid,
|
|
23
|
+
min_samples_for,
|
|
24
|
+
select_window,
|
|
25
|
+
trend_present,
|
|
26
|
+
)
|
|
19
27
|
from detectkit.detectors.factory import DetectorFactory
|
|
20
28
|
|
|
21
29
|
|
|
@@ -39,6 +47,30 @@ def grid_search(
|
|
|
39
47
|
base["seasonality_components"] = seasonality
|
|
40
48
|
|
|
41
49
|
has_trend = trend_present(tuner)
|
|
50
|
+
if not has_trend:
|
|
51
|
+
# The trend gate is a single midpoint-median test, so it silently misses a
|
|
52
|
+
# level shift that sits off-center (both halves straddle it) or one big
|
|
53
|
+
# enough to inflate the global MAD it is measured against. When that
|
|
54
|
+
# happens the engine treats the series as stationary — prefers the largest
|
|
55
|
+
# window, skips detrend — and the baseline quietly averages two regimes.
|
|
56
|
+
# Surface it (with a concrete --from date) so the user can narrow the
|
|
57
|
+
# window and re-tune; advisory only.
|
|
58
|
+
found, sigmas, idx = detect_level_shift(tuner)
|
|
59
|
+
if found:
|
|
60
|
+
timestamps = tuner.data["timestamp"]
|
|
61
|
+
n = int(len(timestamps))
|
|
62
|
+
from_date = str(np.datetime64(timestamps[idx], "D"))
|
|
63
|
+
pct = round(idx / n * 100) if n else 0
|
|
64
|
+
tuner.log(
|
|
65
|
+
"regime",
|
|
66
|
+
f"series reads stationary, but a large level shift (~{sigmas:.1f}σ "
|
|
67
|
+
f"within-regime) sits ~{pct}% in, around {from_date} — the midpoint "
|
|
68
|
+
"trend test misses an off-center shift, so the baseline may average "
|
|
69
|
+
f"two regimes. If the earlier regime is stale, re-tune with "
|
|
70
|
+
f"`--from {from_date}` (or set `autotune.max_history`).",
|
|
71
|
+
shift_sigmas=round(sigmas, 2),
|
|
72
|
+
shift_at=from_date,
|
|
73
|
+
)
|
|
42
74
|
eps = tuner.settings.min_improvement
|
|
43
75
|
best_overall: CandidateEval | None = None
|
|
44
76
|
|
|
@@ -80,6 +112,18 @@ def grid_search(
|
|
|
80
112
|
if ev is not None and ev.score > best.score + eps:
|
|
81
113
|
best, accepted["window_weights"] = ev, weights
|
|
82
114
|
|
|
115
|
+
# Axis 2b: half-life of the recency weighting — only when exponential
|
|
116
|
+
# weighting was adopted. The detector defaults to a fixed half-life; this
|
|
117
|
+
# lets the search pick a faster-forgetting baseline that tracks the current
|
|
118
|
+
# regime (the term that matters on a metric that shifted level).
|
|
119
|
+
if accepted.get("window_weights") == "exponential":
|
|
120
|
+
for half_life in half_life_grid(accepted["window_size"], accepted["min_samples"]):
|
|
121
|
+
if half_life == accepted.get("half_life"):
|
|
122
|
+
continue
|
|
123
|
+
ev = tuner.safe_evaluate(detector_type, {**accepted, "half_life": half_life})
|
|
124
|
+
if ev is not None and ev.score > best.score + eps:
|
|
125
|
+
best, accepted["half_life"] = ev, half_life
|
|
126
|
+
|
|
83
127
|
# Axis 3: detrend (gated by the trend pre-test).
|
|
84
128
|
if has_trend:
|
|
85
129
|
for detrend in (None, "linear"):
|
|
@@ -216,6 +216,8 @@ const INTERVAL_S = __INTERVAL__;
|
|
|
216
216
|
// Incidents to seed the editor with (editing an existing labels file). Each is
|
|
217
217
|
// {start, end, label} in "YYYY-MM-DD HH:MM:SS" UTC; a point is start === end.
|
|
218
218
|
const PRELOAD = __INCIDENTS__;
|
|
219
|
+
// Threshold-capture window(s) to restore (from a saved file): [{start, end}] UTC.
|
|
220
|
+
const CAPWINS = __CAPTURE_WINDOWS__;
|
|
219
221
|
const pts = DATA.points.map(p => ({ts: Date.parse(p.t.replace(' ','T')+'Z'), v: p.v}));
|
|
220
222
|
const N = pts.length;
|
|
221
223
|
const vraw = pts.filter(p => p.v !== null).map(p => p.v);
|
|
@@ -242,6 +244,12 @@ let selObj = null, hoverRow = -1, hoverDel = -1, thMode = false, thHover = null;
|
|
|
242
244
|
// Threshold-capture window: thDown tracks a press, thDragWin a live drag, capWin
|
|
243
245
|
// the committed custom window (null → capture within the current view).
|
|
244
246
|
let thDown = null, thDragWin = null, capWin = null;
|
|
247
|
+
// Restore a saved capture window so re-opening a labels file keeps the painted
|
|
248
|
+
// regime scope (only shown once threshold capture is toggled on).
|
|
249
|
+
if (CAPWINS && CAPWINS.length) { const w0 = CAPWINS[0];
|
|
250
|
+
const a = Date.parse(String(w0.start).replace(' ','T')+'Z'),
|
|
251
|
+
b = Date.parse(String(w0.end).replace(' ','T')+'Z');
|
|
252
|
+
if (!isNaN(a) && !isNaN(b)) capWin = {a: Math.min(a,b), b: Math.max(a,b)}; }
|
|
245
253
|
|
|
246
254
|
const clamp = (x,a,b) => Math.max(a, Math.min(b, x));
|
|
247
255
|
const vspan = () => viewMax - viewMin;
|
|
@@ -710,6 +718,9 @@ const buildYaml = () => {
|
|
|
710
718
|
if (!sorted.length) y+=' []\\n';
|
|
711
719
|
sorted.forEach(iv => { y+=' - {start: "'+fmtTs(iv.a)+'", end: "'+fmtTs(iv.b)+'"'
|
|
712
720
|
+ (iv.label && iv.label.trim() ? ', label: '+yamlStr(iv.label.trim()) : '') + '}\\n'; });
|
|
721
|
+
// Persist the painted threshold-capture window so the regime scope is auditable
|
|
722
|
+
// in the saved file and restored on reopen. Pure metadata — autotune ignores it.
|
|
723
|
+
if (capWin) y+='capture_windows:\\n - {start: "'+fmtTs(capWin.a)+'", end: "'+fmtTs(capWin.b)+'"}\\n';
|
|
713
724
|
return y;
|
|
714
725
|
};
|
|
715
726
|
|
|
@@ -767,6 +778,7 @@ def render_labeler_html(
|
|
|
767
778
|
save_url: str | None = None,
|
|
768
779
|
interval_seconds: int | None = None,
|
|
769
780
|
incidents: list[dict[str, str]] | None = None,
|
|
781
|
+
capture_windows: list[dict[str, str]] | None = None,
|
|
770
782
|
) -> str:
|
|
771
783
|
"""Return a self-contained HTML labeler page for *metric_name*'s series.
|
|
772
784
|
|
|
@@ -791,6 +803,7 @@ def render_labeler_html(
|
|
|
791
803
|
return (
|
|
792
804
|
_TEMPLATE.replace("__PAYLOAD__", payload)
|
|
793
805
|
.replace("__INCIDENTS__", preload)
|
|
806
|
+
.replace("__CAPTURE_WINDOWS__", json_dumps_sorted(capture_windows or []))
|
|
794
807
|
.replace("__FAVICON__", _favicon_data_uri())
|
|
795
808
|
.replace("__SAVE_URL__", json.dumps(save_url))
|
|
796
809
|
.replace("__INTERVAL__", json.dumps(interval_seconds))
|
|
@@ -122,11 +122,14 @@ def build_label_server(
|
|
|
122
122
|
incidents_dir: Path,
|
|
123
123
|
interval_seconds: int,
|
|
124
124
|
preload: list[dict[str, str]] | None = None,
|
|
125
|
+
capture_windows: list[dict[str, str]] | None = None,
|
|
125
126
|
) -> tuple[_LabelServer, str]:
|
|
126
127
|
"""Construct (without running) the labeler server; return ``(server, page_url)``.
|
|
127
128
|
|
|
128
129
|
``preload`` seeds the labeler with already-marked incidents (editing an
|
|
129
130
|
existing labels file); the caller resolves which file to load.
|
|
131
|
+
``capture_windows`` restores the painted threshold-capture window from a saved
|
|
132
|
+
file so the regime scope survives a reopen.
|
|
130
133
|
"""
|
|
131
134
|
server = _LabelServer(("127.0.0.1", 0), _Handler)
|
|
132
135
|
token = secrets.token_urlsafe(16)
|
|
@@ -141,6 +144,7 @@ def build_label_server(
|
|
|
141
144
|
save_url=f"http://127.0.0.1:{port}/save?token={token}",
|
|
142
145
|
interval_seconds=interval_seconds,
|
|
143
146
|
incidents=preload,
|
|
147
|
+
capture_windows=capture_windows,
|
|
144
148
|
)
|
|
145
149
|
return server, f"http://127.0.0.1:{port}/?token={token}"
|
|
146
150
|
|
|
@@ -155,10 +159,12 @@ def serve_labeler(
|
|
|
155
159
|
echo: Callable[[str], None] = print,
|
|
156
160
|
on_ready: Callable[[str], None] | None = None,
|
|
157
161
|
preload: list[dict[str, str]] | None = None,
|
|
162
|
+
capture_windows: list[dict[str, str]] | None = None,
|
|
158
163
|
) -> Path | None:
|
|
159
164
|
"""Serve the labeler until the user saves (returns the file) or cancels (None).
|
|
160
165
|
|
|
161
|
-
``preload`` seeds the page with existing incidents to edit in place
|
|
166
|
+
``preload`` seeds the page with existing incidents to edit in place;
|
|
167
|
+
``capture_windows`` restores the painted threshold-capture scope.
|
|
162
168
|
"""
|
|
163
169
|
server, url = build_label_server(
|
|
164
170
|
metric_name=metric_name,
|
|
@@ -166,6 +172,7 @@ def serve_labeler(
|
|
|
166
172
|
incidents_dir=incidents_dir,
|
|
167
173
|
interval_seconds=interval_seconds,
|
|
168
174
|
preload=preload,
|
|
175
|
+
capture_windows=capture_windows,
|
|
169
176
|
)
|
|
170
177
|
if on_ready is not None:
|
|
171
178
|
on_ready(url)
|
|
@@ -14,7 +14,7 @@ When no labels are supplied the tuner falls back to unsupervised mode.
|
|
|
14
14
|
|
|
15
15
|
from __future__ import annotations
|
|
16
16
|
|
|
17
|
-
from dataclasses import dataclass
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
18
|
from datetime import datetime, timezone
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
from typing import Any
|
|
@@ -62,6 +62,10 @@ class IncidentLabels:
|
|
|
62
62
|
|
|
63
63
|
intervals: list[IncidentInterval]
|
|
64
64
|
points: list[IncidentPoint]
|
|
65
|
+
# Optional threshold-capture time window(s) painted in the labeler. Pure
|
|
66
|
+
# metadata: it records the regime scope the user reasoned about (auditable in
|
|
67
|
+
# the saved file, restored on reopen); it does NOT affect ground truth.
|
|
68
|
+
capture_windows: list[tuple[datetime, datetime]] = field(default_factory=list)
|
|
65
69
|
|
|
66
70
|
def is_empty(self) -> bool:
|
|
67
71
|
return not self.intervals and not self.points
|
|
@@ -152,6 +156,7 @@ def parse_incident_labels(
|
|
|
152
156
|
if raw is None:
|
|
153
157
|
return IncidentLabels([], [])
|
|
154
158
|
|
|
159
|
+
raw_windows: list = []
|
|
155
160
|
if isinstance(raw, list):
|
|
156
161
|
entries = raw
|
|
157
162
|
tz: ZoneInfo | None = None
|
|
@@ -164,6 +169,9 @@ def parse_incident_labels(
|
|
|
164
169
|
entries = raw.get("incidents", [])
|
|
165
170
|
if not isinstance(entries, list):
|
|
166
171
|
raise ValueError("'incidents' must be a list")
|
|
172
|
+
raw_windows = raw.get("capture_windows") or []
|
|
173
|
+
if not isinstance(raw_windows, list):
|
|
174
|
+
raise ValueError("'capture_windows' must be a list")
|
|
167
175
|
else:
|
|
168
176
|
raise ValueError("Labels must be a mapping with 'incidents' or a list of incidents")
|
|
169
177
|
|
|
@@ -187,7 +195,16 @@ def parse_incident_labels(
|
|
|
187
195
|
"Each incident needs either 'at' (a point) or 'start'+'end' (an interval)"
|
|
188
196
|
)
|
|
189
197
|
|
|
190
|
-
|
|
198
|
+
capture_windows: list[tuple[datetime, datetime]] = []
|
|
199
|
+
for win in raw_windows:
|
|
200
|
+
if not isinstance(win, dict) or "start" not in win or "end" not in win:
|
|
201
|
+
raise ValueError("Each capture_windows entry needs 'start' and 'end'")
|
|
202
|
+
ws, we = _parse_dt(win["start"], tz), _parse_dt(win["end"], tz)
|
|
203
|
+
if ws > we:
|
|
204
|
+
raise ValueError(f"Capture window start {ws} is after end {we}")
|
|
205
|
+
capture_windows.append((ws, we))
|
|
206
|
+
|
|
207
|
+
return IncidentLabels(intervals=intervals, points=points, capture_windows=capture_windows)
|
|
191
208
|
|
|
192
209
|
|
|
193
210
|
def parse_labels_file(
|
|
@@ -243,3 +260,22 @@ def load_incidents_for_display(
|
|
|
243
260
|
"""Load a canonical labels file and render it as labeler display dicts."""
|
|
244
261
|
labels = parse_labels_file(path, interval_seconds=interval_seconds, metric_name=metric_name)
|
|
245
262
|
return incidents_to_display(labels)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def capture_windows_to_display(labels: IncidentLabels) -> list[dict[str, str]]:
|
|
266
|
+
"""Render parsed capture windows as labeler display dicts (naive-UTC strings)."""
|
|
267
|
+
return [
|
|
268
|
+
{"start": start.strftime(_DISPLAY_FMT), "end": end.strftime(_DISPLAY_FMT)}
|
|
269
|
+
for start, end in labels.capture_windows
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def load_capture_windows(
|
|
274
|
+
path: str | Path,
|
|
275
|
+
*,
|
|
276
|
+
interval_seconds: int,
|
|
277
|
+
metric_name: str | None = None,
|
|
278
|
+
) -> list[dict[str, str]]:
|
|
279
|
+
"""Load a labels file and render its capture windows as labeler display dicts."""
|
|
280
|
+
labels = parse_labels_file(path, interval_seconds=interval_seconds, metric_name=metric_name)
|
|
281
|
+
return capture_windows_to_display(labels)
|
|
@@ -24,7 +24,9 @@ class TuneSettings:
|
|
|
24
24
|
|
|
25
25
|
# Cross-validation
|
|
26
26
|
fold_count: int = 5
|
|
27
|
-
|
|
27
|
+
# aggregate = mean(folds) - lambda * downside_semideviation(folds); downside-only
|
|
28
|
+
# so a regime-adaptive config isn't penalized for scoring *better* on recent folds.
|
|
29
|
+
stability_lambda: float = 0.5
|
|
28
30
|
|
|
29
31
|
# Detector selection: by default the grid search evaluates ALL windowed
|
|
30
32
|
# statistical detectors and lets cross-validation pick the winner; the
|
|
@@ -55,6 +55,69 @@ def trend_present(tuner: _AutoTuneBase) -> bool:
|
|
|
55
55
|
return abs(med_second - med_first) > 2.0 * 1.4826 * mad
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
# A level shift is "large" when the two regimes' centers differ by at least this
|
|
59
|
+
# many within-regime robust sigmas. Measured against the *within-segment* scale
|
|
60
|
+
# (not the global MAD) so a big step can't self-mask by inflating the yardstick.
|
|
61
|
+
_SHIFT_SIGMA_BAR = 3.0
|
|
62
|
+
_SHIFT_MIN_POINTS = 32 # too few points to meaningfully talk about two regimes
|
|
63
|
+
_SHIFT_MIN_SIDE_FRAC = 0.1 # each candidate segment must hold ≥10% of the points
|
|
64
|
+
_SHIFT_SCAN_SPLITS = 24 # coarse grid of candidate split points to scan
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def detect_level_shift(tuner: _AutoTuneBase) -> tuple[bool, float, int]:
|
|
68
|
+
"""Scan for the strongest single level shift anywhere in the series.
|
|
69
|
+
|
|
70
|
+
Complements :func:`trend_present`, which only compares the two *halves'*
|
|
71
|
+
medians against the *global* MAD and so misses a shift that (a) sits
|
|
72
|
+
off-center — both halves then straddle it — or (b) self-masks by inflating the
|
|
73
|
+
global MAD it is measured against. This scans candidate split points across
|
|
74
|
+
the series and scores each step against the **within-segment** robust scale,
|
|
75
|
+
which a true step does not inflate (a smooth ramp, by contrast, keeps a large
|
|
76
|
+
within-segment spread and so does not register). Returns ``(found,
|
|
77
|
+
magnitude_sigmas, boundary_index)`` where ``boundary_index`` is the index of
|
|
78
|
+
the **first point of the new regime** in ``tuner.data`` (so the caller can map
|
|
79
|
+
it to a timestamp for a concrete ``--from`` suggestion). The scan runs on the
|
|
80
|
+
raw grid (NaN-aware medians) so the index aligns with ``timestamp``. ``found``
|
|
81
|
+
is ``True`` only when the strongest step clears :data:`_SHIFT_SIGMA_BAR`
|
|
82
|
+
within-regime sigmas.
|
|
83
|
+
"""
|
|
84
|
+
v = np.asarray(tuner.data["value"], dtype=float)
|
|
85
|
+
n = int(v.size)
|
|
86
|
+
min_side = max(4, int(n * _SHIFT_MIN_SIDE_FRAC))
|
|
87
|
+
if n < _SHIFT_MIN_POINTS or n - 2 * min_side < 1:
|
|
88
|
+
return (False, 0.0, 0)
|
|
89
|
+
step = max(1, (n - 2 * min_side) // _SHIFT_SCAN_SPLITS)
|
|
90
|
+
best_sigmas = 0.0
|
|
91
|
+
best_idx = 0
|
|
92
|
+
for s in range(min_side, n - min_side + 1, step):
|
|
93
|
+
left, right = v[:s], v[s:]
|
|
94
|
+
if np.isnan(left).all() or np.isnan(right).all():
|
|
95
|
+
continue
|
|
96
|
+
med_l = float(np.nanmedian(left))
|
|
97
|
+
med_r = float(np.nanmedian(right))
|
|
98
|
+
delta = abs(med_r - med_l)
|
|
99
|
+
if delta <= 0:
|
|
100
|
+
continue
|
|
101
|
+
within = float(np.nanmedian(np.abs(np.concatenate([left - med_l, right - med_r]))))
|
|
102
|
+
sigmas = delta / (1.4826 * within) if within > 0 else 99.0
|
|
103
|
+
if sigmas > best_sigmas:
|
|
104
|
+
best_sigmas, best_idx = sigmas, s
|
|
105
|
+
return (best_sigmas >= _SHIFT_SIGMA_BAR, min(best_sigmas, 99.0), best_idx)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def half_life_grid(window_size: int, min_samples: int) -> list[int]:
|
|
109
|
+
"""Candidate half-lives (in points) for the recency-weighting sweep.
|
|
110
|
+
|
|
111
|
+
Spaced as fractions of the window so the search can trade a fast-forgetting
|
|
112
|
+
baseline (small half-life → tracks the current regime, good after a shift)
|
|
113
|
+
against a steady one. Floored at ``min_samples / 2`` to keep the weighted
|
|
114
|
+
effective sample size from collapsing into a noisy band.
|
|
115
|
+
"""
|
|
116
|
+
floor = max(2, min_samples // 2)
|
|
117
|
+
cands = {round(window_size * f) for f in (0.05, 0.1, 0.25, 0.5)}
|
|
118
|
+
return sorted({max(floor, c) for c in cands if c >= 2})
|
|
119
|
+
|
|
120
|
+
|
|
58
121
|
def select_window(
|
|
59
122
|
tuner: _AutoTuneBase,
|
|
60
123
|
detector_type: str,
|
|
@@ -27,13 +27,26 @@ same windowed detectors and `detector_id` identity). The fastest path is the
|
|
|
27
27
|
detectors (`mad`/`zscore`/`iqr`) and cross-validation picks the winner — no
|
|
28
28
|
type is excluded by heuristic.
|
|
29
29
|
3. **Hyperparameters** — a bounded coordinate grid search over `threshold`,
|
|
30
|
-
recency weighting, detrending and
|
|
31
|
-
cross-validated score, with a final `threshold`
|
|
30
|
+
recency weighting (and its **half-life** when adopted), detrending and
|
|
31
|
+
`window_size`, maximizing a cross-validated score, with a final `threshold`
|
|
32
|
+
re-sweep at the chosen window. Fold scores aggregate as
|
|
33
|
+
`mean − stability_lambda · downside_deviation` (downside-only, so a config that
|
|
34
|
+
scores *better* on recent folds isn't penalized; lower `stability_lambda` for a
|
|
35
|
+
regime-shift metric).
|
|
32
36
|
4. **History window** — on near-ties uses a trend-gated tie-break: a stationary
|
|
33
37
|
series prefers the **larger** `window_size` ("more history is better"), a
|
|
34
38
|
trending / regime-shifting one the **smaller**; sets `loading_start_time` to
|
|
35
39
|
cover the lead-in (and pins the detector's `start_time` to it, so the first
|
|
36
|
-
`dtk run` detects across all loaded history).
|
|
40
|
+
`dtk run` detects across all loaded history). The trend gate is a midpoint
|
|
41
|
+
test, so it can miss a level shift that sits off-center or self-masks by
|
|
42
|
+
inflating the global MAD; a backstop scan then logs a **`REGIME`** advisory in
|
|
43
|
+
the decision log (and streams it) when the series reads stationary yet a large
|
|
44
|
+
(≥3σ within-regime) level shift is present — it names a **concrete `--from
|
|
45
|
+
<date>`** (the shift's timestamp); surface it to the user and suggest re-tuning
|
|
46
|
+
with that date (or `autotune.max_history`) if the earlier regime is stale.
|
|
47
|
+
Advisory only; it changes no chosen parameters,
|
|
48
|
+
and it detects level shifts, not variance/shape changes (label incidents for
|
|
49
|
+
those).
|
|
37
50
|
5. **Alert window** (supervised only) — sweeps `consecutive_anomalies` on the
|
|
38
51
|
labeled incidents.
|
|
39
52
|
|
|
@@ -101,7 +114,9 @@ user to recall timestamps** — it is the easiest, most reliable path:
|
|
|
101
114
|
clear outliers, **Threshold capture** grabs every span past a horizontal line
|
|
102
115
|
at once (above/below, with an optional gap-bridge); it captures within the
|
|
103
116
|
current view by default, and dragging across the chart limits it to a time
|
|
104
|
-
window (different boundary per period)
|
|
117
|
+
window (different boundary per period) — the painted window is **saved with the
|
|
118
|
+
set and restored on reopen** (a `capture_windows:` block; metadata only). Each
|
|
119
|
+
band's ✕ (or select + Delete)
|
|
105
120
|
removes one, and **focus** on a list row jumps the chart to it.
|
|
106
121
|
3. That writes `incidents/<metric>/<metric>[-<set>]-<UTC>.yml` automatically
|
|
107
122
|
(named after the metric, optional set name as a suffix; versioned —
|
|
@@ -148,6 +163,7 @@ autotune:
|
|
|
148
163
|
force_seasonality: [hour] # pin the grouping, skip the search (see below)
|
|
149
164
|
fixed_params: {window_size: 4320} # pin hyperparameters (excluded from search)
|
|
150
165
|
folds: 5
|
|
166
|
+
stability_lambda: 0.5 # downside-dispersion penalty weight (0 disables)
|
|
151
167
|
max_history: 50000 # cap training points
|
|
152
168
|
```
|
|
153
169
|
|
|
@@ -32,6 +32,7 @@ from detectkit.autotune.labels import (
|
|
|
32
32
|
GroundTruth,
|
|
33
33
|
IncidentLabels,
|
|
34
34
|
incidents_to_display,
|
|
35
|
+
load_capture_windows,
|
|
35
36
|
load_incidents_for_display,
|
|
36
37
|
parse_incident_labels,
|
|
37
38
|
)
|
|
@@ -264,6 +265,7 @@ def _build_settings(*, scoring: ScoringMetric, autotune_cfg: AutoTuneConfig) ->
|
|
|
264
265
|
metric=scoring,
|
|
265
266
|
beta=autotune_cfg.beta,
|
|
266
267
|
fold_count=autotune_cfg.folds,
|
|
268
|
+
stability_lambda=autotune_cfg.stability_lambda,
|
|
267
269
|
allowed_detector_types=autotune_cfg.detector_types,
|
|
268
270
|
allowed_seasonality=autotune_cfg.seasonality_candidates,
|
|
269
271
|
force_seasonality=autotune_cfg.force_seasonality,
|
|
@@ -427,9 +429,23 @@ def _tune_one(
|
|
|
427
429
|
except ValueError: # an absolute path outside the project tree
|
|
428
430
|
where = preload_src
|
|
429
431
|
click.echo(f" Editing {len(preload)} existing incident(s) from {where}")
|
|
432
|
+
# Restore the painted threshold-capture window from the same seed file
|
|
433
|
+
# (best-effort — a missing/old file just yields no window).
|
|
434
|
+
capture_windows: list[dict[str, str]] = []
|
|
435
|
+
if preload_src is not None:
|
|
436
|
+
try:
|
|
437
|
+
capture_windows = load_capture_windows(
|
|
438
|
+
preload_src, interval_seconds=interval_seconds, metric_name=name
|
|
439
|
+
)
|
|
440
|
+
except Exception: # noqa: BLE001 — restoring the scope is a convenience
|
|
441
|
+
capture_windows = []
|
|
430
442
|
if no_serve:
|
|
431
443
|
html = render_labeler_html(
|
|
432
|
-
name,
|
|
444
|
+
name,
|
|
445
|
+
data,
|
|
446
|
+
interval_seconds=interval_seconds,
|
|
447
|
+
incidents=preload,
|
|
448
|
+
capture_windows=capture_windows,
|
|
433
449
|
)
|
|
434
450
|
out = project_root / "metrics" / f"{metric_path.stem}__labeler.html"
|
|
435
451
|
out.write_text(html, encoding="utf-8")
|
|
@@ -447,6 +463,7 @@ def _tune_one(
|
|
|
447
463
|
open_browser=not no_open,
|
|
448
464
|
echo=click.echo,
|
|
449
465
|
preload=preload,
|
|
466
|
+
capture_windows=capture_windows,
|
|
450
467
|
)
|
|
451
468
|
if saved is None:
|
|
452
469
|
echo_noop(name, "labeling cancelled — no labels saved")
|
|
@@ -371,6 +371,13 @@ class AutoTuneConfig(BaseModel):
|
|
|
371
371
|
default_factory=dict, description="Hyperparameters pinned across the whole search"
|
|
372
372
|
)
|
|
373
373
|
folds: int = Field(default=5, description="Cross-validation folds")
|
|
374
|
+
stability_lambda: float = Field(
|
|
375
|
+
default=0.5,
|
|
376
|
+
description="Weight on the cross-fold downside-dispersion penalty "
|
|
377
|
+
"(aggregate = mean - lambda * downside_semideviation). Lower it (e.g. 0.0) for a "
|
|
378
|
+
"metric whose behavior differs across a regime shift, so a config that adapts to "
|
|
379
|
+
"the recent regime isn't penalized for fold-to-fold variance.",
|
|
380
|
+
)
|
|
374
381
|
max_history: int | None = Field(
|
|
375
382
|
default=None, description="Cap on training points used during the search"
|
|
376
383
|
)
|
|
@@ -463,6 +470,14 @@ class AutoTuneConfig(BaseModel):
|
|
|
463
470
|
raise ValueError("max_history must be at least 1")
|
|
464
471
|
return v
|
|
465
472
|
|
|
473
|
+
@field_validator("stability_lambda")
|
|
474
|
+
@classmethod
|
|
475
|
+
def validate_stability_lambda(cls, v: float) -> float:
|
|
476
|
+
"""The dispersion-penalty weight must be non-negative."""
|
|
477
|
+
if v < 0:
|
|
478
|
+
raise ValueError("stability_lambda must be >= 0")
|
|
479
|
+
return v
|
|
480
|
+
|
|
466
481
|
@model_validator(mode="after")
|
|
467
482
|
def validate_inline_incidents(self) -> "AutoTuneConfig":
|
|
468
483
|
"""Validate inline incidents: not alongside labels_file, and well-formed.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md
RENAMED
|
File without changes
|
{detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md
RENAMED
|
File without changes
|
{detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md
RENAMED
|
File without changes
|
{detectkit-0.26.1 → detectkit-0.28.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|