@luanpdd/kit-mcp 1.34.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 (118) hide show
  1. package/README.md +1 -1
  2. package/bin/cli.js +2 -2
  3. package/bin/mcp.js +6 -6
  4. package/bin/ui.js +74 -74
  5. package/gates/ai-prompt-stability.md +120 -120
  6. package/gates/budget-description.md +68 -68
  7. package/gates/confidence.md +29 -29
  8. package/gates/dependency-check.md +33 -33
  9. package/gates/dept-cycle-prevention.md +179 -179
  10. package/gates/golden-signals-coverage.md +133 -133
  11. package/gates/legacy-refactor-safety.md +178 -178
  12. package/gates/multi-tenant-rls-coverage.md +102 -102
  13. package/gates/no-personal-uuid.md +72 -72
  14. package/gates/obs-agents-mcp-supabase.md +86 -86
  15. package/gates/obs-skills-frontmatter.md +76 -76
  16. package/gates/observability-coverage.md +151 -151
  17. package/gates/omm-no-regression.md +83 -83
  18. package/gates/postmortem-template-required.md +127 -127
  19. package/gates/prr-checklist-coverage.md +128 -128
  20. package/gates/regression.md +32 -32
  21. package/gates/release-pipeline-policy.md +132 -132
  22. package/gates/secrets-scan.md +33 -33
  23. package/gates/service-role-not-in-user-facing.md +113 -113
  24. package/gates/skill-must-include.md +71 -71
  25. package/gates/sync-idempotent.md +62 -62
  26. package/gates/verify-phase-goal.md +34 -34
  27. package/kit/agents/designer-ui.md +216 -216
  28. package/kit/agents/workflow-generator.md +537 -0
  29. package/kit/commands/adicionar-backlog.md +1 -1
  30. package/kit/commands/adicionar-fase.md +1 -1
  31. package/kit/commands/adicionar-tarefa.md +1 -1
  32. package/kit/commands/auditar-observabilidade.md +103 -103
  33. package/kit/commands/auditar-toil.md +129 -129
  34. package/kit/commands/caracterizar-prompt.md +195 -195
  35. package/kit/commands/criar-workflow.md +158 -0
  36. package/kit/commands/definir-perfil.md +1 -1
  37. package/kit/commands/definir-slo.md +108 -108
  38. package/kit/commands/fio.md +1 -1
  39. package/kit/commands/golden-signals.md +142 -142
  40. package/kit/commands/instrumentar-fase.md +200 -200
  41. package/kit/commands/investigar-producao.md +162 -162
  42. package/kit/commands/observabilidade.md +118 -118
  43. package/kit/commands/postmortem.md +179 -179
  44. package/kit/commands/prr.md +205 -205
  45. package/kit/commands/publicar-rapido.md +207 -207
  46. package/kit/commands/risk-budget.md +220 -220
  47. package/kit/commands/sre.md +230 -230
  48. package/kit/file-manifest.json +5 -2
  49. package/kit/framework/references/output-style.md +22 -22
  50. package/kit/hooks/post-apply-migration.js +199 -199
  51. package/kit/hooks/sidecar-tool-publisher.js +210 -210
  52. package/kit/skills/_shared-dados-distribuidos/glossary.md +224 -224
  53. package/kit/skills/_shared-legacy/glossary.md +389 -389
  54. package/kit/skills/_shared-multi-tenant/glossary.md +186 -186
  55. package/kit/skills/_shared-observability/glossary.md +396 -396
  56. package/kit/skills/_shared-sre/glossary.md +712 -712
  57. package/kit/skills/_shared-supabase/glossary.md +234 -234
  58. package/kit/skills/blameless-postmortems/SKILL.md +340 -340
  59. package/kit/skills/burn-rate-alerting/SKILL.md +258 -258
  60. package/kit/skills/cascading-failures/SKILL.md +311 -311
  61. package/kit/skills/core-analysis-loop/SKILL.md +352 -352
  62. package/kit/skills/distributed-tracing/SKILL.md +362 -362
  63. package/kit/skills/dynamic-workflow-authoring/SKILL.md +327 -0
  64. package/kit/skills/eliminating-toil/SKILL.md +243 -243
  65. package/kit/skills/event-based-slos/SKILL.md +296 -296
  66. package/kit/skills/four-golden-signals/SKILL.md +314 -314
  67. package/kit/skills/hermetic-builds/SKILL.md +323 -323
  68. package/kit/skills/legacy-monster-methods/SKILL.md +444 -444
  69. package/kit/skills/llm-as-dependency/SKILL.md +436 -436
  70. package/kit/skills/load-shedding-graceful-degradation/SKILL.md +396 -396
  71. package/kit/skills/observability-driven-development/SKILL.md +315 -315
  72. package/kit/skills/observability-maturity-model/SKILL.md +222 -222
  73. package/kit/skills/opentelemetry-standard/SKILL.md +351 -351
  74. package/kit/skills/production-readiness-review/SKILL.md +305 -305
  75. package/kit/skills/release-engineering/SKILL.md +367 -367
  76. package/kit/skills/retry-strategies/SKILL.md +372 -372
  77. package/kit/skills/sre-risk-management/SKILL.md +221 -221
  78. package/kit/skills/structured-events/SKILL.md +265 -265
  79. package/kit/skills/supabase-cron-queues/SKILL.md +275 -275
  80. package/kit/skills/supabase-database-functions/SKILL.md +332 -332
  81. package/kit/skills/supabase-declarative-schema/SKILL.md +183 -183
  82. package/kit/skills/supabase-pgvector-rag/SKILL.md +253 -253
  83. package/kit/skills/supabase-postgres-style/SKILL.md +138 -138
  84. package/kit/skills/supabase-storage/SKILL.md +234 -234
  85. package/kit/skills/telemetry-pipelines/SKILL.md +259 -259
  86. package/kit/skills/telemetry-sampling/SKILL.md +256 -256
  87. package/kit/skills/ui-anti-padroes-ia/SKILL.md +261 -261
  88. package/kit/skills/ui-contexto-produto/SKILL.md +248 -248
  89. package/kit/skills/ui-cor-estrategia/SKILL.md +213 -213
  90. package/kit/skills/ui-critica-auditoria/SKILL.md +260 -260
  91. package/kit/skills/ui-motion-funcional/SKILL.md +264 -264
  92. package/kit/skills/ui-ritmo-espacial/SKILL.md +259 -259
  93. package/kit/skills/ui-tipografia/SKILL.md +211 -211
  94. package/package.json +1 -1
  95. package/src/cli/index.js +1114 -1114
  96. package/src/cli/render.js +194 -194
  97. package/src/cli/upgrade-check.js +135 -135
  98. package/src/core/error-redaction.js +76 -76
  99. package/src/core/failures.js +153 -153
  100. package/src/core/gate-runner.js +205 -205
  101. package/src/core/gates.js +82 -82
  102. package/src/core/logger.js +170 -170
  103. package/src/core/manifest-verify.js +174 -174
  104. package/src/core/metrics.js +268 -268
  105. package/src/core/notify.js +60 -60
  106. package/src/core/path-safety.js +141 -141
  107. package/src/core/replays.js +120 -120
  108. package/src/core/ui.js +185 -185
  109. package/src/mcp-server/install.js +149 -149
  110. package/src/mcp-server/roots.js +124 -124
  111. package/src/ui/auto-spawn.js +113 -113
  112. package/src/ui/browser.js +78 -78
  113. package/src/ui/client.js +130 -130
  114. package/src/ui/events.js +65 -65
  115. package/src/ui/lockfile.js +191 -191
  116. package/src/ui/port.js +67 -67
  117. package/src/ui/server.js +547 -547
  118. 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
+ }