detectkit 0.47.0__tar.gz → 0.48.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.
Files changed (147) hide show
  1. {detectkit-0.47.0/detectkit.egg-info → detectkit-0.48.0}/PKG-INFO +1 -1
  2. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/__init__.py +1 -1
  3. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/cli.md +13 -3
  4. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/skills/dtk-tune/SKILL.md +11 -3
  5. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/commands/run.py +48 -47
  6. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/commands/tune.py +6 -1
  7. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/config/validator.py +40 -4
  8. detectkit-0.48.0/detectkit/tuning/assets/tune.js +187 -0
  9. detectkit-0.48.0/detectkit/tuning/config_writer.py +253 -0
  10. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/tuning/payload.py +81 -7
  11. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/tuning/server.py +47 -5
  12. {detectkit-0.47.0 → detectkit-0.48.0/detectkit.egg-info}/PKG-INFO +1 -1
  13. detectkit-0.47.0/detectkit/tuning/assets/tune.js +0 -187
  14. detectkit-0.47.0/detectkit/tuning/config_writer.py +0 -138
  15. {detectkit-0.47.0 → detectkit-0.48.0}/LICENSE +0 -0
  16. {detectkit-0.47.0 → detectkit-0.48.0}/MANIFEST.in +0 -0
  17. {detectkit-0.47.0 → detectkit-0.48.0}/README.md +0 -0
  18. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/alerting/__init__.py +0 -0
  19. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/alerting/channels/__init__.py +0 -0
  20. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/alerting/channels/base.py +0 -0
  21. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/alerting/channels/branding.py +0 -0
  22. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/alerting/channels/email.py +0 -0
  23. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/alerting/channels/factory.py +0 -0
  24. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/alerting/channels/mattermost.py +0 -0
  25. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/alerting/channels/slack.py +0 -0
  26. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/alerting/channels/telegram.py +0 -0
  27. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/alerting/channels/webhook.py +0 -0
  28. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/__init__.py +0 -0
  29. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_base.py +0 -0
  30. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_cooldown.py +0 -0
  31. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_decision.py +0 -0
  32. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_dispatch.py +0 -0
  33. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_recovery.py +0 -0
  34. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_replay.py +0 -0
  35. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/_types.py +0 -0
  36. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/alerting/orchestrator/orchestrator.py +0 -0
  37. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/autotune/__init__.py +0 -0
  38. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/autotune/_base.py +0 -0
  39. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/autotune/_types.py +0 -0
  40. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/autotune/autotuner.py +0 -0
  41. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/autotune/config_emitter.py +0 -0
  42. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/autotune/crossval.py +0 -0
  43. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/autotune/detector_select.py +0 -0
  44. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/autotune/distribution.py +0 -0
  45. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/autotune/grid_search.py +0 -0
  46. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/autotune/labels.py +0 -0
  47. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/autotune/result.py +0 -0
  48. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/autotune/runner.py +0 -0
  49. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/autotune/scoring.py +0 -0
  50. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/autotune/seasonality_search.py +0 -0
  51. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/autotune/settings.py +0 -0
  52. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/autotune/window_select.py +0 -0
  53. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/__init__.py +0 -0
  54. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/_output.py +0 -0
  55. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/CLAUDE.section.md +0 -0
  56. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/alerting.md +0 -0
  57. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/autotune.md +0 -0
  58. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/detectors.md +0 -0
  59. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/metrics.md +0 -0
  60. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/overview.md +0 -0
  61. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/rules/project.md +0 -0
  62. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/skills/dtk-autotune/SKILL.md +0 -0
  63. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/skills/dtk-feedback/SKILL.md +0 -0
  64. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/skills/dtk-new-metric/SKILL.md +0 -0
  65. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/assets/claude/skills/dtk-setup-project/SKILL.md +0 -0
  66. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/commands/__init__.py +0 -0
  67. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/commands/autotune.py +0 -0
  68. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/commands/clean.py +0 -0
  69. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/commands/init.py +0 -0
  70. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/commands/init_claude.py +0 -0
  71. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/commands/osi.py +0 -0
  72. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/commands/test_alert.py +0 -0
  73. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/commands/unlock.py +0 -0
  74. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/cli/main.py +0 -0
  75. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/config/__init__.py +0 -0
  76. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/config/metric_config.py +0 -0
  77. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/config/profile.py +0 -0
  78. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/config/project_config.py +0 -0
  79. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/core/__init__.py +0 -0
  80. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/core/interval.py +0 -0
  81. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/core/models.py +0 -0
  82. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/database/__init__.py +0 -0
  83. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/database/_sql_manager.py +0 -0
  84. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/database/clickhouse_manager.py +0 -0
  85. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/database/internal_tables/__init__.py +0 -0
  86. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_alert_states.py +0 -0
  87. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_autotune_runs.py +0 -0
  88. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_base.py +0 -0
  89. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_datapoints.py +0 -0
  90. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_detections.py +0 -0
  91. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_maintenance.py +0 -0
  92. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_metrics.py +0 -0
  93. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_schema.py +0 -0
  94. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/database/internal_tables/_tasks.py +0 -0
  95. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/database/internal_tables/manager.py +0 -0
  96. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/database/manager.py +0 -0
  97. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/database/mysql_manager.py +0 -0
  98. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/database/postgres_manager.py +0 -0
  99. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/database/tables.py +0 -0
  100. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/detectors/__init__.py +0 -0
  101. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/detectors/base.py +0 -0
  102. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/detectors/factory.py +0 -0
  103. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/detectors/seasonality.py +0 -0
  104. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/detectors/statistical/__init__.py +0 -0
  105. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/detectors/statistical/_windowed.py +0 -0
  106. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/detectors/statistical/iqr.py +0 -0
  107. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/detectors/statistical/mad.py +0 -0
  108. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/detectors/statistical/manual_bounds.py +0 -0
  109. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/detectors/statistical/zscore.py +0 -0
  110. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/loaders/__init__.py +0 -0
  111. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/loaders/metric_loader.py +0 -0
  112. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/loaders/query_template.py +0 -0
  113. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/orchestration/__init__.py +0 -0
  114. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/orchestration/error_dispatch.py +0 -0
  115. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/__init__.py +0 -0
  116. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/_alert_step.py +0 -0
  117. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/_base.py +0 -0
  118. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/_detect_step.py +0 -0
  119. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/_load_step.py +0 -0
  120. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/_types.py +0 -0
  121. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/orchestration/task_manager/manager.py +0 -0
  122. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/reporting/__init__.py +0 -0
  123. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/reporting/assets/report.js +0 -0
  124. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/reporting/builder.py +0 -0
  125. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/reporting/html_report.py +0 -0
  126. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/semantic/__init__.py +0 -0
  127. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/semantic/errors.py +0 -0
  128. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/semantic/exporter.py +0 -0
  129. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/semantic/importer.py +0 -0
  130. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/semantic/osi_model.py +0 -0
  131. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/semantic/query_gen.py +0 -0
  132. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/tuning/__init__.py +0 -0
  133. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/tuning/html.py +0 -0
  134. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/utils/__init__.py +0 -0
  135. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/utils/datetime_utils.py +0 -0
  136. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/utils/env_interpolation.py +0 -0
  137. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/utils/json_utils.py +0 -0
  138. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit/utils/stats.py +0 -0
  139. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit.egg-info/SOURCES.txt +0 -0
  140. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit.egg-info/dependency_links.txt +0 -0
  141. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit.egg-info/entry_points.txt +0 -0
  142. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit.egg-info/requires.txt +0 -0
  143. {detectkit-0.47.0 → detectkit-0.48.0}/detectkit.egg-info/top_level.txt +0 -0
  144. {detectkit-0.47.0 → detectkit-0.48.0}/pyproject.toml +0 -0
  145. {detectkit-0.47.0 → detectkit-0.48.0}/requirements.txt +0 -0
  146. {detectkit-0.47.0 → detectkit-0.48.0}/setup.cfg +0 -0
  147. {detectkit-0.47.0 → detectkit-0.48.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detectkit
3
- Version: 0.47.0
3
+ Version: 0.48.0
4
4
  Summary: Metric monitoring with automatic anomaly detection
5
5
  Author: detectkit team
6
6
  License: MIT
@@ -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.47.0"
7
+ __version__ = "0.48.0"
8
8
 
9
9
  from detectkit.core.interval import Interval
10
10
  from detectkit.core.models import ColumnDefinition, TableModel
@@ -97,7 +97,14 @@ anomalies**) and the **y = 0** toggle — stay visible in every mode. The chart
97
97
  **zoomable** (scroll/drag + navigator strip) with a **"Points shown"** trim slider.
98
98
  Clicking **Apply** writes the chosen
99
99
  config back into the metric YAML **in place** (autotune, by contrast, writes a new
100
- `__tuned_<id>.yml` and never edits the original). Reads the metric's loaded
100
+ `__tuned_<id>.yml` and never edits the original). Apply **merges** it rewrites
101
+ only the detector(s) you tuned and keeps every **other** detector unchanged, so a
102
+ `manual_bounds` floor alongside a `mad` detector (and a `min_detectors: 2` alert)
103
+ survives a retune. If a metric configures **more than one detector**, a **Tuning
104
+ detector** picker in the Tune rail lets you choose which one to tune (the cockpit
105
+ shows one band at a time); switching re-seeds every knob from that detector, and
106
+ non-tunable detectors (`prophet`/`timesfm`) plus the ones you didn't touch are
107
+ listed as "preserved on Apply". Reads the metric's loaded
101
108
  datapoints (run `dtk run --steps load` first if empty); the selector must resolve
102
109
  to a single metric.
103
110
 
@@ -155,8 +162,11 @@ relative to zero.
155
162
 
156
163
  Safe write-back: the config is validated before anything is written, the previous
157
164
  YAML is archived under `metrics/.history/<metric>/`, and only then is the metric
158
- overwritten. Takes **no pipeline lock** (it only edits a config file); re-run
159
- `dtk run` afterward to recompute detections under the new config.
165
+ overwritten (the merge keeps your other detectors verbatim; the re-emitted header
166
+ names what was updated vs preserved). The `.history/` archive is **not** discovered
167
+ as a live metric, so a tuned metric never collides with its own snapshots as a
168
+ "duplicate metric name". Takes **no pipeline lock** (it only edits a config file);
169
+ re-run `dtk run` afterward to recompute detections under the new config.
160
170
  `--no-serve` writes a static read-only preview HTML instead (no write-back —
161
171
  **Save incidents** downloads the labels file); `--from` / `--to` bound the window;
162
172
  `--no-open` prints the URL without opening a browser.
@@ -146,9 +146,17 @@ false-alert rate exceeds it (tuning-only; labeling stays optional).
146
146
  When the user is happy, they click **Apply to metric**. detectkit then, in order:
147
147
  **validates** the config (a bad/untunable config is rejected and **nothing is
148
148
  written**), **archives** the current YAML verbatim under
149
- `metrics/.history/<metric>/`, and **re-emits** the metric in place with the tuned
150
- detector (and updates the first alerting block's `consecutive_anomalies` if it has
151
- one). Applying ends the session; saving incidents does not.
149
+ `metrics/.history/<metric>/`, and **re-emits** the metric in place, **merging** the
150
+ tuned detector(s) back in (and updating the first alerting block's
151
+ `consecutive_anomalies` if it has one). Applying ends the session; saving incidents
152
+ does not. **Merge, not replace:** only the detector(s) the user tuned are
153
+ rewritten; every other detector is kept **verbatim** — so a metric with a
154
+ `manual_bounds` floor alongside a `mad` detector (and a `min_detectors: 2` alert)
155
+ keeps both. When a metric has more than one detector, the Tune rail shows a
156
+ **Tuning detector** picker to choose which one to tune. If the user asks to tune a
157
+ metric that has several detectors, reassure them the others are preserved. (The
158
+ `.history/` archive is not discovered as a live metric, so it never causes a
159
+ "duplicate metric name" error.)
152
160
 
153
161
  ## Step 5 — Recompute under the new config
154
162
 
@@ -12,7 +12,11 @@ import click
12
12
  from detectkit.config.metric_config import MetricConfig
13
13
  from detectkit.config.profile import ProfilesConfig
14
14
  from detectkit.config.project_config import ProjectConfig
15
- from detectkit.config.validator import validate_metric_uniqueness
15
+ from detectkit.config.validator import (
16
+ discover_metric_files,
17
+ is_discoverable_metric_file,
18
+ validate_metric_uniqueness,
19
+ )
16
20
  from detectkit.database.internal_tables import InternalTablesManager
17
21
  from detectkit.orchestration.error_dispatch import dispatch_project_error_alert
18
22
  from detectkit.orchestration.task_manager import PipelineStep, TaskManager
@@ -433,17 +437,16 @@ def select_metrics(selector: str, project_root: Path) -> list[tuple[Path, Metric
433
437
  if selector == "*":
434
438
  # "all metrics" — search recursively so nested metrics are included
435
439
  # (mirrors validate_project_metrics); a plain glob of "metrics/*"
436
- # would only see the top level.
437
- metric_paths = [p for sub in ("**/*.yml", "**/*.yaml") for p in metrics_dir.glob(sub)]
440
+ # would only see the top level. Excludes the metrics/.history/ archive.
441
+ metric_paths = discover_metric_files(metrics_dir)
438
442
  else:
439
443
  pattern = selector if selector.startswith("metrics/") else f"metrics/{selector}"
440
444
  # Keep only metric files: a bare glob also matches the `.gitkeep`
441
- # stub created by `dtk init`, any other non-YAML files, and
442
- # directories all of which would crash the YAML parser.
445
+ # stub created by `dtk init`, any other non-YAML files, directories, and
446
+ # the metrics/.history/ archive dtk tune writes (whose snapshots keep the
447
+ # original name:) — all of which would crash the parser or collide on name.
443
448
  metric_paths = [
444
- p
445
- for p in project_root.glob(pattern)
446
- if p.is_file() and p.suffix in (".yml", ".yaml")
449
+ p for p in project_root.glob(pattern) if is_discoverable_metric_file(p, metrics_dir)
447
450
  ]
448
451
  # Metric name selector
449
452
  else:
@@ -485,26 +488,25 @@ def find_metrics_by_tag(metrics_dir: Path, tag: str) -> list[Path]:
485
488
 
486
489
  matching_metrics = []
487
490
 
488
- # Search both .yml and .yaml extensions (consistent with find_metric_by_name)
489
- for pattern in ["**/*.yml", "**/*.yaml"]:
490
- for metric_file in metrics_dir.glob(pattern):
491
- try:
492
- with open(metric_file) as f:
493
- config = yaml.safe_load(f)
494
-
495
- if config and "tags" in config:
496
- if tag in config["tags"]:
497
- matching_metrics.append(metric_file)
498
- except Exception as e:
499
- # Warn about unparseable files but continue searching
500
- click.echo(
501
- click.style(
502
- f"Warning: Skipping {metric_file.relative_to(metrics_dir.parent)}: {e}",
503
- fg="yellow",
504
- ),
505
- err=True,
506
- )
507
- continue
491
+ # Live metric files only (excludes the metrics/.history/ archive).
492
+ for metric_file in discover_metric_files(metrics_dir):
493
+ try:
494
+ with open(metric_file) as f:
495
+ config = yaml.safe_load(f)
496
+
497
+ if config and "tags" in config:
498
+ if tag in config["tags"]:
499
+ matching_metrics.append(metric_file)
500
+ except Exception as e:
501
+ # Warn about unparseable files but continue searching
502
+ click.echo(
503
+ click.style(
504
+ f"Warning: Skipping {metric_file.relative_to(metrics_dir.parent)}: {e}",
505
+ fg="yellow",
506
+ ),
507
+ err=True,
508
+ )
509
+ continue
508
510
 
509
511
  return matching_metrics
510
512
 
@@ -522,25 +524,24 @@ def find_metric_by_name(metrics_dir: Path, name: str) -> Path | None:
522
524
  """
523
525
  import yaml
524
526
 
525
- # Search both .yml and .yaml extensions
526
- for pattern in ["**/*.yml", "**/*.yaml"]:
527
- for metric_file in metrics_dir.glob(pattern):
528
- try:
529
- with open(metric_file) as f:
530
- config = yaml.safe_load(f)
531
-
532
- if config and config.get("name") == name:
533
- return metric_file
534
- except Exception as e:
535
- # Warn about unparseable files but continue searching
536
- click.echo(
537
- click.style(
538
- f"Warning: Skipping {metric_file.relative_to(metrics_dir.parent)}: {e}",
539
- fg="yellow",
540
- ),
541
- err=True,
542
- )
543
- continue
527
+ # Live metric files only (excludes the metrics/.history/ archive).
528
+ for metric_file in discover_metric_files(metrics_dir):
529
+ try:
530
+ with open(metric_file) as f:
531
+ config = yaml.safe_load(f)
532
+
533
+ if config and config.get("name") == name:
534
+ return metric_file
535
+ except Exception as e:
536
+ # Warn about unparseable files but continue searching
537
+ click.echo(
538
+ click.style(
539
+ f"Warning: Skipping {metric_file.relative_to(metrics_dir.parent)}: {e}",
540
+ fg="yellow",
541
+ ),
542
+ err=True,
543
+ )
544
+ continue
544
545
 
545
546
  return None
546
547
 
@@ -153,6 +153,11 @@ def run_tune(
153
153
  return False
154
154
 
155
155
  click.echo(f" Archived previous config: {applied.archived.relative_to(project_root)}")
156
- echo_done(f"{name}: applied tuned detector → {applied.saved.relative_to(project_root)}")
156
+ updated = ", ".join(applied.updated) or "detector"
157
+ echo_done(
158
+ f"{name}: applied tuned detector(s) [{updated}] → {applied.saved.relative_to(project_root)}"
159
+ )
160
+ if applied.preserved:
161
+ click.echo(f" Preserved the metric's other detector(s): {', '.join(applied.preserved)}")
157
162
  click.echo(f" Re-run `dtk run --select {name}` to recompute detections under the new config.")
158
163
  return True
@@ -10,6 +10,44 @@ from pathlib import Path
10
10
  from detectkit.config.metric_config import MetricConfig
11
11
 
12
12
 
13
+ def is_discoverable_metric_file(path: Path, metrics_dir: Path) -> bool:
14
+ """True for a *live* metric YAML — excludes the ``metrics/.history/`` archive.
15
+
16
+ ``dtk tune`` archives the previous metric config under
17
+ ``metrics/.history/<metric>/`` before writing the tuned version in place. Those
18
+ snapshots keep the original ``name:``, so discovering them as live metrics would
19
+ (a) flag every tuned metric as a duplicate-name conflict and (b) run stale
20
+ configs. Python's ``pathlib`` glob traverses hidden directories (unlike shell
21
+ globbing), so they must be filtered out explicitly. Any hidden path component
22
+ (a leading dot) *under* ``metrics/`` is skipped — this covers ``.history`` and
23
+ any editor/VCS scratch dir a user drops there, while a project rooted under a
24
+ dot-directory (checked only below ``metrics/``) is unaffected.
25
+ """
26
+ if not path.is_file() or path.suffix not in (".yml", ".yaml"):
27
+ return False
28
+ try:
29
+ parts = path.relative_to(metrics_dir).parts
30
+ except ValueError:
31
+ parts = path.parts
32
+ return not any(part.startswith(".") for part in parts)
33
+
34
+
35
+ def discover_metric_files(metrics_dir: Path) -> list[Path]:
36
+ """All live metric YAMLs under ``metrics/`` (recursive), excluding the ``.history`` archive.
37
+
38
+ The single discovery seam shared by project validation and CLI metric selection,
39
+ so the ``.history`` exclusion can't drift between them. Returns a sorted list for
40
+ deterministic ordering.
41
+ """
42
+ files = [
43
+ p
44
+ for pattern in ("**/*.yml", "**/*.yaml")
45
+ for p in metrics_dir.glob(pattern)
46
+ if is_discoverable_metric_file(p, metrics_dir)
47
+ ]
48
+ return sorted(set(files))
49
+
50
+
13
51
  def validate_metric_uniqueness(metric_paths: list[Path]) -> list[tuple[Path, MetricConfig]]:
14
52
  """
15
53
  Load all metrics and validate that metric names are unique.
@@ -106,10 +144,8 @@ def validate_project_metrics(project_root: Path) -> list[tuple[Path, MetricConfi
106
144
  f" your_metric.yml\n"
107
145
  )
108
146
 
109
- # Find all metric files recursively
110
- metric_paths = []
111
- for pattern in ["**/*.yml", "**/*.yaml"]:
112
- metric_paths.extend(metrics_dir.glob(pattern))
147
+ # Find all metric files recursively (excluding the metrics/.history/ archive).
148
+ metric_paths = discover_metric_files(metrics_dir)
113
149
 
114
150
  if not metric_paths:
115
151
  raise ValueError(