@luanpdd/kit-mcp 1.35.0 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/bin/cli.js +2 -2
  2. package/bin/mcp.js +6 -6
  3. package/bin/ui.js +74 -74
  4. package/gates/ai-prompt-stability.md +120 -120
  5. package/gates/budget-description.md +68 -68
  6. package/gates/confidence.md +29 -29
  7. package/gates/dependency-check.md +33 -33
  8. package/gates/dept-cycle-prevention.md +179 -179
  9. package/gates/golden-signals-coverage.md +133 -133
  10. package/gates/legacy-refactor-safety.md +178 -178
  11. package/gates/multi-tenant-rls-coverage.md +102 -102
  12. package/gates/no-personal-uuid.md +72 -72
  13. package/gates/obs-agents-mcp-supabase.md +86 -86
  14. package/gates/obs-skills-frontmatter.md +76 -76
  15. package/gates/observability-coverage.md +151 -151
  16. package/gates/omm-no-regression.md +83 -83
  17. package/gates/postmortem-template-required.md +127 -127
  18. package/gates/prr-checklist-coverage.md +128 -128
  19. package/gates/regression.md +32 -32
  20. package/gates/release-pipeline-policy.md +132 -132
  21. package/gates/secrets-scan.md +33 -33
  22. package/gates/service-role-not-in-user-facing.md +113 -113
  23. package/gates/skill-must-include.md +71 -71
  24. package/gates/sync-idempotent.md +62 -62
  25. package/gates/verify-phase-goal.md +34 -34
  26. package/kit/agents/designer-ui.md +216 -216
  27. package/kit/agents/workflow-generator.md +537 -167
  28. package/kit/commands/adicionar-backlog.md +1 -1
  29. package/kit/commands/adicionar-fase.md +1 -1
  30. package/kit/commands/adicionar-tarefa.md +1 -1
  31. package/kit/commands/auditar-observabilidade.md +103 -103
  32. package/kit/commands/auditar-toil.md +129 -129
  33. package/kit/commands/caracterizar-prompt.md +195 -195
  34. package/kit/commands/criar-workflow.md +158 -158
  35. package/kit/commands/definir-perfil.md +1 -1
  36. package/kit/commands/definir-slo.md +108 -108
  37. package/kit/commands/fio.md +1 -1
  38. package/kit/commands/golden-signals.md +142 -142
  39. package/kit/commands/instrumentar-fase.md +200 -200
  40. package/kit/commands/investigar-producao.md +162 -162
  41. package/kit/commands/observabilidade.md +118 -118
  42. package/kit/commands/postmortem.md +179 -179
  43. package/kit/commands/prr.md +205 -205
  44. package/kit/commands/publicar-rapido.md +207 -207
  45. package/kit/commands/risk-budget.md +220 -220
  46. package/kit/commands/sre.md +230 -230
  47. package/kit/file-manifest.json +424 -424
  48. package/kit/framework/references/output-style.md +22 -22
  49. package/kit/hooks/post-apply-migration.js +199 -199
  50. package/kit/hooks/sidecar-tool-publisher.js +210 -210
  51. package/kit/skills/_shared-dados-distribuidos/glossary.md +224 -224
  52. package/kit/skills/_shared-legacy/glossary.md +389 -389
  53. package/kit/skills/_shared-multi-tenant/glossary.md +186 -186
  54. package/kit/skills/_shared-observability/glossary.md +396 -396
  55. package/kit/skills/_shared-sre/glossary.md +712 -712
  56. package/kit/skills/_shared-supabase/glossary.md +234 -234
  57. package/kit/skills/blameless-postmortems/SKILL.md +340 -340
  58. package/kit/skills/burn-rate-alerting/SKILL.md +258 -258
  59. package/kit/skills/cascading-failures/SKILL.md +311 -311
  60. package/kit/skills/core-analysis-loop/SKILL.md +352 -352
  61. package/kit/skills/distributed-tracing/SKILL.md +362 -362
  62. package/kit/skills/dynamic-workflow-authoring/SKILL.md +327 -223
  63. package/kit/skills/eliminating-toil/SKILL.md +243 -243
  64. package/kit/skills/event-based-slos/SKILL.md +296 -296
  65. package/kit/skills/four-golden-signals/SKILL.md +314 -314
  66. package/kit/skills/hermetic-builds/SKILL.md +323 -323
  67. package/kit/skills/legacy-monster-methods/SKILL.md +444 -444
  68. package/kit/skills/llm-as-dependency/SKILL.md +436 -436
  69. package/kit/skills/load-shedding-graceful-degradation/SKILL.md +396 -396
  70. package/kit/skills/observability-driven-development/SKILL.md +315 -315
  71. package/kit/skills/observability-maturity-model/SKILL.md +222 -222
  72. package/kit/skills/opentelemetry-standard/SKILL.md +351 -351
  73. package/kit/skills/production-readiness-review/SKILL.md +305 -305
  74. package/kit/skills/release-engineering/SKILL.md +367 -367
  75. package/kit/skills/retry-strategies/SKILL.md +372 -372
  76. package/kit/skills/sre-risk-management/SKILL.md +221 -221
  77. package/kit/skills/structured-events/SKILL.md +265 -265
  78. package/kit/skills/supabase-cron-queues/SKILL.md +275 -275
  79. package/kit/skills/supabase-database-functions/SKILL.md +332 -332
  80. package/kit/skills/supabase-declarative-schema/SKILL.md +183 -183
  81. package/kit/skills/supabase-pgvector-rag/SKILL.md +253 -253
  82. package/kit/skills/supabase-postgres-style/SKILL.md +138 -138
  83. package/kit/skills/supabase-storage/SKILL.md +234 -234
  84. package/kit/skills/telemetry-pipelines/SKILL.md +259 -259
  85. package/kit/skills/telemetry-sampling/SKILL.md +256 -256
  86. package/kit/skills/ui-anti-padroes-ia/SKILL.md +261 -261
  87. package/kit/skills/ui-contexto-produto/SKILL.md +248 -248
  88. package/kit/skills/ui-cor-estrategia/SKILL.md +213 -213
  89. package/kit/skills/ui-critica-auditoria/SKILL.md +260 -260
  90. package/kit/skills/ui-motion-funcional/SKILL.md +264 -264
  91. package/kit/skills/ui-ritmo-espacial/SKILL.md +259 -259
  92. package/kit/skills/ui-tipografia/SKILL.md +211 -211
  93. package/package.json +1 -1
  94. package/src/cli/index.js +1114 -1114
  95. package/src/cli/render.js +194 -194
  96. package/src/cli/upgrade-check.js +135 -135
  97. package/src/core/error-redaction.js +76 -76
  98. package/src/core/failures.js +153 -153
  99. package/src/core/gate-runner.js +205 -205
  100. package/src/core/gates.js +82 -82
  101. package/src/core/logger.js +170 -170
  102. package/src/core/manifest-verify.js +174 -174
  103. package/src/core/metrics.js +268 -268
  104. package/src/core/notify.js +60 -60
  105. package/src/core/path-safety.js +141 -141
  106. package/src/core/replays.js +120 -120
  107. package/src/core/ui.js +185 -185
  108. package/src/mcp-server/install.js +149 -149
  109. package/src/mcp-server/roots.js +124 -124
  110. package/src/ui/auto-spawn.js +113 -113
  111. package/src/ui/browser.js +78 -78
  112. package/src/ui/client.js +130 -130
  113. package/src/ui/events.js +65 -65
  114. package/src/ui/lockfile.js +191 -191
  115. package/src/ui/port.js +67 -67
  116. package/src/ui/server.js +547 -547
  117. package/src/ui/wrapper.js +129 -129
@@ -1,268 +1,268 @@
1
- // OBS-18-01 / OBS-18-02 — in-memory golden signals for kit-mcp server.
2
- // OBS-19-01 / OBS-19-02 / OBS-19-03 — disk-persistent rolling snapshots.
3
- //
4
- // Phase 94: Eat Your Own Dog Food. The skill `four-golden-signals` says any
5
- // user-facing service worth its salt instruments Latency + Traffic + Errors
6
- // + Saturation. The MCP server qualifies — every tool call is a request from
7
- // an LLM client and tail latency / error rate are exactly the signals an
8
- // operator wants when something feels off.
9
- //
10
- // Scope decisions (see .planning/phases/94-golden-signals-mcp-server/94-CONTEXT.md):
11
- // - Zero new dependencies. Phase 99 adds fs/promises + path from stdlib only —
12
- // the 6-deps budget Phase 92.01 fought to maintain and Phase 93.01 enforces
13
- // in CI is preserved.
14
- // - In-memory primary, on-demand persistence. The Map+array core stays
15
- // in-memory; persistSnapshot writes a JSON file under .planning/metrics/
16
- // snapshots/ when called. No background timer, no implicit writes — the
17
- // /burn-rate-status command and metrics-snapshot tool are the writers.
18
- // - Bounded memory. Histograms cap at HISTOGRAM_CAP=1000 samples per tool
19
- // with FIFO drop.
20
- // - Bounded disk. cleanupOldSnapshots prunes files > 30 days old on every
21
- // persistSnapshot call (rolling window, no separate retention job).
22
- // - Snapshot is read-only. Returns a fresh plain-object copy so callers
23
- // can JSON.stringify it without exposing internal Map references.
24
- // - Persisted shape includes `ts` (epoch ms) inside the JSON. We do NOT
25
- // parse the filename for windowing — filesystem-safe ISO encoding
26
- // (`replace(/[:.]/g, '-')`) is one-way (cannot reliably round-trip back
27
- // through Date.parse) and mtime is unreliable across copy/touch. The
28
- // in-file ts is authoritative.
29
- //
30
- // API surface (5 exports + 2 async):
31
- // incrementInvocation(tool, status) — counter++ keyed `${tool}:${status}`
32
- // recordLatency(tool, ms) — push to histogram, FIFO at cap
33
- // snapshot() — { counters, latency } plain object
34
- // reset() — clear both maps; called on boot if
35
- // KIT_MCP_METRICS_RESET=1
36
- // persistSnapshot(rootDir) — write {ts, counters, latency} to
37
- // .planning/metrics/snapshots/<ts>.json
38
- // + cleanup files > 30d
39
- // loadSnapshots(rootDir, windowMs) — read all snapshots whose in-file ts
40
- // is within windowMs (default 30d),
41
- // sorted ascending by ts
42
- //
43
- // Boot-time reset honors the env var by calling reset() at module load when
44
- // the flag is set. This keeps the signal "fresh" for a probe in tests or for
45
- // an operator who spawned the server with the flag for a clean comparison.
46
-
47
- import fs from 'node:fs/promises';
48
- import path from 'node:path';
49
-
50
- const HISTOGRAM_CAP = 1000;
51
- const DEFAULT_RETENTION_MS = 30 * 86400 * 1000; // 30 days rolling.
52
- const SNAPSHOT_DIR_REL = path.join('.planning', 'metrics', 'snapshots');
53
-
54
- const counters = new Map(); // key: `${tool}:${status}` → count (number)
55
- const histograms = new Map(); // key: tool → number[] (length ≤ HISTOGRAM_CAP)
56
-
57
- /**
58
- * Increment the invocation counter for a tool/status pair.
59
- *
60
- * @param {string} tool Tool name as it appears in the MCP request payload.
61
- * @param {'ok'|'error'} [status='ok'] Outcome of the dispatch.
62
- * @returns {void}
63
- */
64
- export function incrementInvocation(tool, status = 'ok') {
65
- if (typeof tool !== 'string' || tool.length === 0) return;
66
- const key = `${tool}:${status}`;
67
- counters.set(key, (counters.get(key) ?? 0) + 1);
68
- }
69
-
70
- /**
71
- * Record an observed latency for a tool. Drops the oldest sample (FIFO) once
72
- * the per-tool histogram reaches HISTOGRAM_CAP, keeping memory bounded across
73
- * long-lived MCP sessions.
74
- *
75
- * @param {string} tool Tool name.
76
- * @param {number} ms Elapsed wall-clock time in milliseconds.
77
- * @returns {void}
78
- */
79
- export function recordLatency(tool, ms) {
80
- if (typeof tool !== 'string' || tool.length === 0) return;
81
- if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return;
82
- let arr = histograms.get(tool);
83
- if (!arr) {
84
- arr = [];
85
- histograms.set(tool, arr);
86
- }
87
- arr.push(ms);
88
- if (arr.length > HISTOGRAM_CAP) arr.shift(); // FIFO drop oldest sample
89
- }
90
-
91
- /**
92
- * Compute a percentile over a sorted ascending array. Linear-interpolation
93
- * variant matches the typical Prometheus / Datadog reading. For N≤1000
94
- * (HISTOGRAM_CAP) the sort cost on snapshot is acceptable — snapshots are
95
- * read on-demand by the metrics-snapshot tool, not on every dispatch.
96
- *
97
- * @param {number[]} sorted Ascending-sorted samples.
98
- * @param {number} p Percentile in [0, 1].
99
- * @returns {number}
100
- */
101
- function percentile(sorted, p) {
102
- if (sorted.length === 0) return 0;
103
- if (sorted.length === 1) return sorted[0];
104
- const rank = p * (sorted.length - 1);
105
- const lo = Math.floor(rank);
106
- const hi = Math.ceil(rank);
107
- if (lo === hi) return sorted[lo];
108
- const frac = rank - lo;
109
- return sorted[lo] + (sorted[hi] - sorted[lo]) * frac;
110
- }
111
-
112
- /**
113
- * Build a read-only snapshot of all metrics. Counters are returned as a plain
114
- * object keyed `${tool}:${status}` → count. Latency is keyed by tool to a
115
- * `{ p50, p95, p99, count }` triple so a single tool never appears split
116
- * across status outcomes (latency observation point is a single line in the
117
- * dispatcher, success and failure both record).
118
- *
119
- * @returns {{
120
- * counters: Record<string, number>,
121
- * latency: Record<string, { p50: number, p95: number, p99: number, count: number }>
122
- * }}
123
- */
124
- export function snapshot() {
125
- const out = { counters: {}, latency: {} };
126
- for (const [key, val] of counters) out.counters[key] = val;
127
- for (const [tool, samples] of histograms) {
128
- if (samples.length === 0) continue;
129
- const sorted = [...samples].sort((a, b) => a - b);
130
- out.latency[tool] = {
131
- p50: percentile(sorted, 0.50),
132
- p95: percentile(sorted, 0.95),
133
- p99: percentile(sorted, 0.99),
134
- count: samples.length,
135
- };
136
- }
137
- return out;
138
- }
139
-
140
- /**
141
- * Clear both counters and histograms. Used by tests and by the boot-time
142
- * KIT_MCP_METRICS_RESET=1 path so an operator can probe a fresh window.
143
- *
144
- * @returns {void}
145
- */
146
- export function reset() {
147
- counters.clear();
148
- histograms.clear();
149
- }
150
-
151
- /**
152
- * OBS-19-01 — Persist the current snapshot to disk under
153
- * `<rootDir>/.planning/metrics/snapshots/<timestamp>.json`. Runs the rolling
154
- * cleanup of files older than `retentionMs` (default 30d) on every call so
155
- * callers don't need a separate retention job.
156
- *
157
- * The on-disk shape is `{ ts: <epoch_ms>, counters, latency }`. The `ts` field
158
- * inside the JSON — NOT the filename — is the authoritative timestamp for
159
- * loadSnapshots windowing. The filename uses an ISO encoding with `:` and `.`
160
- * replaced by `-` for filesystem safety; that encoding is one-way (cannot
161
- * round-trip back through Date.parse), so we never parse it for ordering.
162
- *
163
- * @param {string} [rootDir=process.cwd()] Project root. Snapshots land under
164
- * `<rootDir>/.planning/metrics/snapshots/`.
165
- * @param {object} [opts]
166
- * @param {number} [opts.retentionMs] Override the rolling-window age in ms.
167
- * Defaults to 30 days. Tests use shorter windows to drive the cleanup path.
168
- * @returns {Promise<{file: string, snap: {ts: number, counters: object, latency: object}}>}
169
- */
170
- export async function persistSnapshot(rootDir = process.cwd(), opts = {}) {
171
- const retentionMs = Number.isFinite(opts.retentionMs) ? opts.retentionMs : DEFAULT_RETENTION_MS;
172
- const dir = path.join(rootDir, SNAPSHOT_DIR_REL);
173
- await fs.mkdir(dir, { recursive: true });
174
- const ts = Date.now();
175
- const snap = { ts, ...snapshot() };
176
- // Filesystem-safe ISO encoding — Windows forbids `:` in paths and `.` is
177
- // ambiguous with extension separators on shells with brace expansion.
178
- const isoSafe = new Date(ts).toISOString().replace(/[:.]/g, '-');
179
- const file = path.join(dir, `${isoSafe}.json`);
180
- await fs.writeFile(file, JSON.stringify(snap, null, 2));
181
- await cleanupOldSnapshots(dir, retentionMs);
182
- return { file, snap };
183
- }
184
-
185
- /**
186
- * OBS-19-02 — Load all snapshots from disk whose in-file `ts` is within the
187
- * sliding window. Returns the array sorted ascending by `ts` so consumers
188
- * (`/burn-rate-status`) can compute first-vs-last deltas without re-sorting.
189
- *
190
- * Defensive against malformed JSON: a corrupt file is skipped silently rather
191
- * than aborting the whole load. The 30d window is rolling from "now" — pass a
192
- * smaller value to drive recent-only views (e.g. `60 * 60 * 1000` for last
193
- * hour) when computing burn rate over a baseline window.
194
- *
195
- * @param {string} [rootDir=process.cwd()] Project root.
196
- * @param {number} [windowMs] Sliding window in ms. Defaults to 30 days.
197
- * @returns {Promise<Array<{ts: number, counters: object, latency: object}>>}
198
- * Empty array if the snapshots directory does not exist.
199
- */
200
- export async function loadSnapshots(rootDir = process.cwd(), windowMs = DEFAULT_RETENTION_MS) {
201
- const dir = path.join(rootDir, SNAPSHOT_DIR_REL);
202
- const cutoff = Date.now() - windowMs;
203
- let files;
204
- try {
205
- files = await fs.readdir(dir);
206
- } catch {
207
- return []; // Dir absent on first run — not an error.
208
- }
209
- const results = [];
210
- for (const f of files) {
211
- if (!f.endsWith('.json')) continue;
212
- try {
213
- const raw = await fs.readFile(path.join(dir, f), 'utf-8');
214
- const parsed = JSON.parse(raw);
215
- if (Number.isFinite(parsed?.ts) && parsed.ts >= cutoff) {
216
- results.push(parsed);
217
- }
218
- } catch {
219
- // Corrupt file — skip silently rather than break the whole burn-rate
220
- // calculation. A future phase can surface counts via a doctor probe.
221
- }
222
- }
223
- return results.sort((a, b) => a.ts - b.ts);
224
- }
225
-
226
- /**
227
- * OBS-19-03 — Internal helper: delete snapshot files older than `maxAgeMs`.
228
- * Called from persistSnapshot on every write so retention is implicit.
229
- * Uses fs.stat().mtimeMs as the age proxy; we accept the small drift versus
230
- * the in-file `ts` because cleanup is best-effort eviction, not authoritative
231
- * windowing (loadSnapshots reads the in-file ts).
232
- *
233
- * @param {string} dir Absolute path to the snapshots directory.
234
- * @param {number} maxAgeMs Files with mtime older than this are unlinked.
235
- * @returns {Promise<void>}
236
- */
237
- async function cleanupOldSnapshots(dir, maxAgeMs) {
238
- const cutoff = Date.now() - maxAgeMs;
239
- let files;
240
- try {
241
- files = await fs.readdir(dir);
242
- } catch {
243
- return;
244
- }
245
- for (const f of files) {
246
- if (!f.endsWith('.json')) continue;
247
- const fp = path.join(dir, f);
248
- try {
249
- const stat = await fs.stat(fp);
250
- if (stat.mtimeMs < cutoff) await fs.unlink(fp);
251
- } catch {
252
- // Unlink can race with concurrent cleanup; ignore ENOENT and friends.
253
- }
254
- }
255
- }
256
-
257
- // Boot-time reset honors KIT_MCP_METRICS_RESET=1. We call reset() instead of
258
- // merely skipping init because the maps are already empty at module load —
259
- // the call is a no-op today but documents the contract for any future module
260
- // that imports metrics.js after another module has already populated state.
261
- if (process.env.KIT_MCP_METRICS_RESET === '1') {
262
- reset();
263
- }
264
-
265
- // Exported for tests only — keeps the API surface explicit while letting unit
266
- // tests assert on the FIFO behavior at the boundary.
267
- export const __TEST_HISTOGRAM_CAP = HISTOGRAM_CAP;
268
- export const __TEST_SNAPSHOT_DIR_REL = SNAPSHOT_DIR_REL;
1
+ // OBS-18-01 / OBS-18-02 — in-memory golden signals for kit-mcp server.
2
+ // OBS-19-01 / OBS-19-02 / OBS-19-03 — disk-persistent rolling snapshots.
3
+ //
4
+ // Phase 94: Eat Your Own Dog Food. The skill `four-golden-signals` says any
5
+ // user-facing service worth its salt instruments Latency + Traffic + Errors
6
+ // + Saturation. The MCP server qualifies — every tool call is a request from
7
+ // an LLM client and tail latency / error rate are exactly the signals an
8
+ // operator wants when something feels off.
9
+ //
10
+ // Scope decisions (see .planning/phases/94-golden-signals-mcp-server/94-CONTEXT.md):
11
+ // - Zero new dependencies. Phase 99 adds fs/promises + path from stdlib only —
12
+ // the 6-deps budget Phase 92.01 fought to maintain and Phase 93.01 enforces
13
+ // in CI is preserved.
14
+ // - In-memory primary, on-demand persistence. The Map+array core stays
15
+ // in-memory; persistSnapshot writes a JSON file under .planning/metrics/
16
+ // snapshots/ when called. No background timer, no implicit writes — the
17
+ // /burn-rate-status command and metrics-snapshot tool are the writers.
18
+ // - Bounded memory. Histograms cap at HISTOGRAM_CAP=1000 samples per tool
19
+ // with FIFO drop.
20
+ // - Bounded disk. cleanupOldSnapshots prunes files > 30 days old on every
21
+ // persistSnapshot call (rolling window, no separate retention job).
22
+ // - Snapshot is read-only. Returns a fresh plain-object copy so callers
23
+ // can JSON.stringify it without exposing internal Map references.
24
+ // - Persisted shape includes `ts` (epoch ms) inside the JSON. We do NOT
25
+ // parse the filename for windowing — filesystem-safe ISO encoding
26
+ // (`replace(/[:.]/g, '-')`) is one-way (cannot reliably round-trip back
27
+ // through Date.parse) and mtime is unreliable across copy/touch. The
28
+ // in-file ts is authoritative.
29
+ //
30
+ // API surface (5 exports + 2 async):
31
+ // incrementInvocation(tool, status) — counter++ keyed `${tool}:${status}`
32
+ // recordLatency(tool, ms) — push to histogram, FIFO at cap
33
+ // snapshot() — { counters, latency } plain object
34
+ // reset() — clear both maps; called on boot if
35
+ // KIT_MCP_METRICS_RESET=1
36
+ // persistSnapshot(rootDir) — write {ts, counters, latency} to
37
+ // .planning/metrics/snapshots/<ts>.json
38
+ // + cleanup files > 30d
39
+ // loadSnapshots(rootDir, windowMs) — read all snapshots whose in-file ts
40
+ // is within windowMs (default 30d),
41
+ // sorted ascending by ts
42
+ //
43
+ // Boot-time reset honors the env var by calling reset() at module load when
44
+ // the flag is set. This keeps the signal "fresh" for a probe in tests or for
45
+ // an operator who spawned the server with the flag for a clean comparison.
46
+
47
+ import fs from 'node:fs/promises';
48
+ import path from 'node:path';
49
+
50
+ const HISTOGRAM_CAP = 1000;
51
+ const DEFAULT_RETENTION_MS = 30 * 86400 * 1000; // 30 days rolling.
52
+ const SNAPSHOT_DIR_REL = path.join('.planning', 'metrics', 'snapshots');
53
+
54
+ const counters = new Map(); // key: `${tool}:${status}` → count (number)
55
+ const histograms = new Map(); // key: tool → number[] (length ≤ HISTOGRAM_CAP)
56
+
57
+ /**
58
+ * Increment the invocation counter for a tool/status pair.
59
+ *
60
+ * @param {string} tool Tool name as it appears in the MCP request payload.
61
+ * @param {'ok'|'error'} [status='ok'] Outcome of the dispatch.
62
+ * @returns {void}
63
+ */
64
+ export function incrementInvocation(tool, status = 'ok') {
65
+ if (typeof tool !== 'string' || tool.length === 0) return;
66
+ const key = `${tool}:${status}`;
67
+ counters.set(key, (counters.get(key) ?? 0) + 1);
68
+ }
69
+
70
+ /**
71
+ * Record an observed latency for a tool. Drops the oldest sample (FIFO) once
72
+ * the per-tool histogram reaches HISTOGRAM_CAP, keeping memory bounded across
73
+ * long-lived MCP sessions.
74
+ *
75
+ * @param {string} tool Tool name.
76
+ * @param {number} ms Elapsed wall-clock time in milliseconds.
77
+ * @returns {void}
78
+ */
79
+ export function recordLatency(tool, ms) {
80
+ if (typeof tool !== 'string' || tool.length === 0) return;
81
+ if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) return;
82
+ let arr = histograms.get(tool);
83
+ if (!arr) {
84
+ arr = [];
85
+ histograms.set(tool, arr);
86
+ }
87
+ arr.push(ms);
88
+ if (arr.length > HISTOGRAM_CAP) arr.shift(); // FIFO drop oldest sample
89
+ }
90
+
91
+ /**
92
+ * Compute a percentile over a sorted ascending array. Linear-interpolation
93
+ * variant matches the typical Prometheus / Datadog reading. For N≤1000
94
+ * (HISTOGRAM_CAP) the sort cost on snapshot is acceptable — snapshots are
95
+ * read on-demand by the metrics-snapshot tool, not on every dispatch.
96
+ *
97
+ * @param {number[]} sorted Ascending-sorted samples.
98
+ * @param {number} p Percentile in [0, 1].
99
+ * @returns {number}
100
+ */
101
+ function percentile(sorted, p) {
102
+ if (sorted.length === 0) return 0;
103
+ if (sorted.length === 1) return sorted[0];
104
+ const rank = p * (sorted.length - 1);
105
+ const lo = Math.floor(rank);
106
+ const hi = Math.ceil(rank);
107
+ if (lo === hi) return sorted[lo];
108
+ const frac = rank - lo;
109
+ return sorted[lo] + (sorted[hi] - sorted[lo]) * frac;
110
+ }
111
+
112
+ /**
113
+ * Build a read-only snapshot of all metrics. Counters are returned as a plain
114
+ * object keyed `${tool}:${status}` → count. Latency is keyed by tool to a
115
+ * `{ p50, p95, p99, count }` triple so a single tool never appears split
116
+ * across status outcomes (latency observation point is a single line in the
117
+ * dispatcher, success and failure both record).
118
+ *
119
+ * @returns {{
120
+ * counters: Record<string, number>,
121
+ * latency: Record<string, { p50: number, p95: number, p99: number, count: number }>
122
+ * }}
123
+ */
124
+ export function snapshot() {
125
+ const out = { counters: {}, latency: {} };
126
+ for (const [key, val] of counters) out.counters[key] = val;
127
+ for (const [tool, samples] of histograms) {
128
+ if (samples.length === 0) continue;
129
+ const sorted = [...samples].sort((a, b) => a - b);
130
+ out.latency[tool] = {
131
+ p50: percentile(sorted, 0.50),
132
+ p95: percentile(sorted, 0.95),
133
+ p99: percentile(sorted, 0.99),
134
+ count: samples.length,
135
+ };
136
+ }
137
+ return out;
138
+ }
139
+
140
+ /**
141
+ * Clear both counters and histograms. Used by tests and by the boot-time
142
+ * KIT_MCP_METRICS_RESET=1 path so an operator can probe a fresh window.
143
+ *
144
+ * @returns {void}
145
+ */
146
+ export function reset() {
147
+ counters.clear();
148
+ histograms.clear();
149
+ }
150
+
151
+ /**
152
+ * OBS-19-01 — Persist the current snapshot to disk under
153
+ * `<rootDir>/.planning/metrics/snapshots/<timestamp>.json`. Runs the rolling
154
+ * cleanup of files older than `retentionMs` (default 30d) on every call so
155
+ * callers don't need a separate retention job.
156
+ *
157
+ * The on-disk shape is `{ ts: <epoch_ms>, counters, latency }`. The `ts` field
158
+ * inside the JSON — NOT the filename — is the authoritative timestamp for
159
+ * loadSnapshots windowing. The filename uses an ISO encoding with `:` and `.`
160
+ * replaced by `-` for filesystem safety; that encoding is one-way (cannot
161
+ * round-trip back through Date.parse), so we never parse it for ordering.
162
+ *
163
+ * @param {string} [rootDir=process.cwd()] Project root. Snapshots land under
164
+ * `<rootDir>/.planning/metrics/snapshots/`.
165
+ * @param {object} [opts]
166
+ * @param {number} [opts.retentionMs] Override the rolling-window age in ms.
167
+ * Defaults to 30 days. Tests use shorter windows to drive the cleanup path.
168
+ * @returns {Promise<{file: string, snap: {ts: number, counters: object, latency: object}}>}
169
+ */
170
+ export async function persistSnapshot(rootDir = process.cwd(), opts = {}) {
171
+ const retentionMs = Number.isFinite(opts.retentionMs) ? opts.retentionMs : DEFAULT_RETENTION_MS;
172
+ const dir = path.join(rootDir, SNAPSHOT_DIR_REL);
173
+ await fs.mkdir(dir, { recursive: true });
174
+ const ts = Date.now();
175
+ const snap = { ts, ...snapshot() };
176
+ // Filesystem-safe ISO encoding — Windows forbids `:` in paths and `.` is
177
+ // ambiguous with extension separators on shells with brace expansion.
178
+ const isoSafe = new Date(ts).toISOString().replace(/[:.]/g, '-');
179
+ const file = path.join(dir, `${isoSafe}.json`);
180
+ await fs.writeFile(file, JSON.stringify(snap, null, 2));
181
+ await cleanupOldSnapshots(dir, retentionMs);
182
+ return { file, snap };
183
+ }
184
+
185
+ /**
186
+ * OBS-19-02 — Load all snapshots from disk whose in-file `ts` is within the
187
+ * sliding window. Returns the array sorted ascending by `ts` so consumers
188
+ * (`/burn-rate-status`) can compute first-vs-last deltas without re-sorting.
189
+ *
190
+ * Defensive against malformed JSON: a corrupt file is skipped silently rather
191
+ * than aborting the whole load. The 30d window is rolling from "now" — pass a
192
+ * smaller value to drive recent-only views (e.g. `60 * 60 * 1000` for last
193
+ * hour) when computing burn rate over a baseline window.
194
+ *
195
+ * @param {string} [rootDir=process.cwd()] Project root.
196
+ * @param {number} [windowMs] Sliding window in ms. Defaults to 30 days.
197
+ * @returns {Promise<Array<{ts: number, counters: object, latency: object}>>}
198
+ * Empty array if the snapshots directory does not exist.
199
+ */
200
+ export async function loadSnapshots(rootDir = process.cwd(), windowMs = DEFAULT_RETENTION_MS) {
201
+ const dir = path.join(rootDir, SNAPSHOT_DIR_REL);
202
+ const cutoff = Date.now() - windowMs;
203
+ let files;
204
+ try {
205
+ files = await fs.readdir(dir);
206
+ } catch {
207
+ return []; // Dir absent on first run — not an error.
208
+ }
209
+ const results = [];
210
+ for (const f of files) {
211
+ if (!f.endsWith('.json')) continue;
212
+ try {
213
+ const raw = await fs.readFile(path.join(dir, f), 'utf-8');
214
+ const parsed = JSON.parse(raw);
215
+ if (Number.isFinite(parsed?.ts) && parsed.ts >= cutoff) {
216
+ results.push(parsed);
217
+ }
218
+ } catch {
219
+ // Corrupt file — skip silently rather than break the whole burn-rate
220
+ // calculation. A future phase can surface counts via a doctor probe.
221
+ }
222
+ }
223
+ return results.sort((a, b) => a.ts - b.ts);
224
+ }
225
+
226
+ /**
227
+ * OBS-19-03 — Internal helper: delete snapshot files older than `maxAgeMs`.
228
+ * Called from persistSnapshot on every write so retention is implicit.
229
+ * Uses fs.stat().mtimeMs as the age proxy; we accept the small drift versus
230
+ * the in-file `ts` because cleanup is best-effort eviction, not authoritative
231
+ * windowing (loadSnapshots reads the in-file ts).
232
+ *
233
+ * @param {string} dir Absolute path to the snapshots directory.
234
+ * @param {number} maxAgeMs Files with mtime older than this are unlinked.
235
+ * @returns {Promise<void>}
236
+ */
237
+ async function cleanupOldSnapshots(dir, maxAgeMs) {
238
+ const cutoff = Date.now() - maxAgeMs;
239
+ let files;
240
+ try {
241
+ files = await fs.readdir(dir);
242
+ } catch {
243
+ return;
244
+ }
245
+ for (const f of files) {
246
+ if (!f.endsWith('.json')) continue;
247
+ const fp = path.join(dir, f);
248
+ try {
249
+ const stat = await fs.stat(fp);
250
+ if (stat.mtimeMs < cutoff) await fs.unlink(fp);
251
+ } catch {
252
+ // Unlink can race with concurrent cleanup; ignore ENOENT and friends.
253
+ }
254
+ }
255
+ }
256
+
257
+ // Boot-time reset honors KIT_MCP_METRICS_RESET=1. We call reset() instead of
258
+ // merely skipping init because the maps are already empty at module load —
259
+ // the call is a no-op today but documents the contract for any future module
260
+ // that imports metrics.js after another module has already populated state.
261
+ if (process.env.KIT_MCP_METRICS_RESET === '1') {
262
+ reset();
263
+ }
264
+
265
+ // Exported for tests only — keeps the API surface explicit while letting unit
266
+ // tests assert on the FIFO behavior at the boundary.
267
+ export const __TEST_HISTOGRAM_CAP = HISTOGRAM_CAP;
268
+ export const __TEST_SNAPSHOT_DIR_REL = SNAPSHOT_DIR_REL;
@@ -1,60 +1,60 @@
1
- // src/core/notify.js — opt-in OS-level notification on MCP tool call.
2
- //
3
- // Phase 164 (v1.28). Off by default — set KIT_MCP_NOTIFY=1 to enable.
4
- // Cross-platform best-effort: PowerShell BurntToast/MessageBox on Windows,
5
- // osascript on macOS, notify-send on Linux. Failure is silent.
6
- //
7
- // Throttled: minimum 5s between notifications (configurable via
8
- // KIT_MCP_NOTIFY_THROTTLE_MS) to prevent flood.
9
-
10
- import { spawn } from 'node:child_process';
11
-
12
- const DEFAULT_THROTTLE_MS = 5000;
13
- let lastNotifyAt = 0;
14
-
15
- function throttleMs() {
16
- const raw = process.env.KIT_MCP_NOTIFY_THROTTLE_MS;
17
- const n = parseInt(raw, 10);
18
- return Number.isFinite(n) && n >= 0 ? n : DEFAULT_THROTTLE_MS;
19
- }
20
-
21
- export function isNotifyEnabled() {
22
- return process.env.KIT_MCP_NOTIFY === '1' || process.env.KIT_MCP_NOTIFY === 'true';
23
- }
24
-
25
- function spawnDetached(cmd, args) {
26
- try {
27
- const c = spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true });
28
- c.unref();
29
- } catch { /* swallow — notification must never break the server */ }
30
- }
31
-
32
- // notify({title, body}): fire a best-effort OS notification. Throttled.
33
- export function notify({ title, body } = {}) {
34
- if (!isNotifyEnabled()) return;
35
- const now = Date.now();
36
- if (now - lastNotifyAt < throttleMs()) return;
37
- lastNotifyAt = now;
38
-
39
- const t = (title || 'kit-mcp').replace(/"/g, "'");
40
- const b = (body || '').replace(/"/g, "'");
41
-
42
- if (process.platform === 'darwin') {
43
- spawnDetached('osascript', ['-e', `display notification "${b}" with title "${t}"`]);
44
- } else if (process.platform === 'linux') {
45
- spawnDetached('notify-send', [t, b]);
46
- } else if (process.platform === 'win32') {
47
- // PowerShell toast via Windows.UI.Notifications. Falls back silently if
48
- // BurntToast not installed — we use the simpler msg via WScript.Shell.
49
- const ps = `Add-Type -AssemblyName System.Windows.Forms; `
50
- + `$n = New-Object System.Windows.Forms.NotifyIcon; `
51
- + `$n.Icon = [System.Drawing.SystemIcons]::Information; `
52
- + `$n.BalloonTipTitle = "${t}"; `
53
- + `$n.BalloonTipText = "${b}"; `
54
- + `$n.Visible = $true; `
55
- + `$n.ShowBalloonTip(3000); `
56
- + `Start-Sleep -Seconds 4; `
57
- + `$n.Dispose()`;
58
- spawnDetached('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps]);
59
- }
60
- }
1
+ // src/core/notify.js — opt-in OS-level notification on MCP tool call.
2
+ //
3
+ // Phase 164 (v1.28). Off by default — set KIT_MCP_NOTIFY=1 to enable.
4
+ // Cross-platform best-effort: PowerShell BurntToast/MessageBox on Windows,
5
+ // osascript on macOS, notify-send on Linux. Failure is silent.
6
+ //
7
+ // Throttled: minimum 5s between notifications (configurable via
8
+ // KIT_MCP_NOTIFY_THROTTLE_MS) to prevent flood.
9
+
10
+ import { spawn } from 'node:child_process';
11
+
12
+ const DEFAULT_THROTTLE_MS = 5000;
13
+ let lastNotifyAt = 0;
14
+
15
+ function throttleMs() {
16
+ const raw = process.env.KIT_MCP_NOTIFY_THROTTLE_MS;
17
+ const n = parseInt(raw, 10);
18
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT_THROTTLE_MS;
19
+ }
20
+
21
+ export function isNotifyEnabled() {
22
+ return process.env.KIT_MCP_NOTIFY === '1' || process.env.KIT_MCP_NOTIFY === 'true';
23
+ }
24
+
25
+ function spawnDetached(cmd, args) {
26
+ try {
27
+ const c = spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true });
28
+ c.unref();
29
+ } catch { /* swallow — notification must never break the server */ }
30
+ }
31
+
32
+ // notify({title, body}): fire a best-effort OS notification. Throttled.
33
+ export function notify({ title, body } = {}) {
34
+ if (!isNotifyEnabled()) return;
35
+ const now = Date.now();
36
+ if (now - lastNotifyAt < throttleMs()) return;
37
+ lastNotifyAt = now;
38
+
39
+ const t = (title || 'kit-mcp').replace(/"/g, "'");
40
+ const b = (body || '').replace(/"/g, "'");
41
+
42
+ if (process.platform === 'darwin') {
43
+ spawnDetached('osascript', ['-e', `display notification "${b}" with title "${t}"`]);
44
+ } else if (process.platform === 'linux') {
45
+ spawnDetached('notify-send', [t, b]);
46
+ } else if (process.platform === 'win32') {
47
+ // PowerShell toast via Windows.UI.Notifications. Falls back silently if
48
+ // BurntToast not installed — we use the simpler msg via WScript.Shell.
49
+ const ps = `Add-Type -AssemblyName System.Windows.Forms; `
50
+ + `$n = New-Object System.Windows.Forms.NotifyIcon; `
51
+ + `$n.Icon = [System.Drawing.SystemIcons]::Information; `
52
+ + `$n.BalloonTipTitle = "${t}"; `
53
+ + `$n.BalloonTipText = "${b}"; `
54
+ + `$n.Visible = $true; `
55
+ + `$n.ShowBalloonTip(3000); `
56
+ + `Start-Sleep -Seconds 4; `
57
+ + `$n.Dispose()`;
58
+ spawnDetached('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps]);
59
+ }
60
+ }