diff --git a/.planning/quick/8-issue-230-build-structured-diagnostic-lo/8-PLAN.md b/.planning/quick/8-issue-230-build-structured-diagnostic-lo/8-PLAN.md new file mode 100644 index 0000000..103122c --- /dev/null +++ b/.planning/quick/8-issue-230-build-structured-diagnostic-lo/8-PLAN.md @@ -0,0 +1,102 @@ +--- +phase: quick-8 +plan: 8 +type: quick-full +wave: 1 +depends_on: [] +files_modified: + - lib/agent-diagnostics.cjs +autonomous: true +requirements: + - Create lib/agent-diagnostics.cjs for structured diagnostic logging of agent executions + - Capture agent type, prompt hash, start/end timestamps, turn count, exit reason, output size, failure classification + - Write diagnostic entries to .mgw/diagnostics/-.json + - Include prune function to remove entries older than 30 days + - Logger must be non-blocking — failures in logging never halt the pipeline + - Integrate with agent failure taxonomy from lib/agent-errors.cjs +must_haves: + truths: + - lib/agent-diagnostics.cjs exports createDiagnosticLogger, writeDiagnosticEntry, pruneDiagnostics, and getDiagnosticsDir + - Diagnostic entries are JSON files at .mgw/diagnostics/-.json + - Each entry captures agent_type, prompt_hash, start_time, end_time, duration_ms, turn_count, exit_reason, output_size, and failure_classification + - The prune function removes entries older than 30 days + - All logging operations are wrapped in try/catch — failures never propagate + - The failure_classification field uses types from lib/agent-errors.cjs (AGENT_FAILURE_TYPES) + artifacts: + - lib/agent-diagnostics.cjs + key_links: + - lib/agent-errors.cjs (agent failure taxonomy — defines AGENT_FAILURE_TYPES, classifyAgentFailure) + - lib/errors.cjs (MgwError base class) + - lib/retry.cjs (pipeline retry infrastructure — classifyFailure) + - lib/logger.cjs (existing structured logging — pattern reference) +--- + +## Objective + +Build `lib/agent-diagnostics.cjs` — a structured diagnostic logger for GSD agent executions. The module captures per-agent-invocation telemetry (timing, turns, output size, exit reason, failure classification) and persists it as individual JSON files under `.mgw/diagnostics/`. All operations are non-blocking; logging failures are silently swallowed to ensure the pipeline is never halted by diagnostic infrastructure. + +## Context + +- **Dependency:** This builds on issue #229's agent failure taxonomy in `lib/agent-errors.cjs`, which defines `AGENT_FAILURE_TYPES` and `classifyAgentFailure()`. The diagnostics logger uses these to classify failures in diagnostic entries. +- **Pattern reference:** `lib/logger.cjs` demonstrates the existing non-blocking logging pattern (try/catch swallowing, `.mgw/` directory structure). +- **Integration point:** The diagnostics module will be consumed by future hooks (issue #231) that instrument agent spawns. + +## Tasks + +### Task 1: Create lib/agent-diagnostics.cjs + +**files:** `lib/agent-diagnostics.cjs` +**action:** Create the diagnostic logger module with these exports: + +1. **`getDiagnosticsDir(repoRoot?)`** — Returns `.mgw/diagnostics/` path, creates directory if needed. Pattern follows `getLogDir()` from `lib/logger.cjs`. + +2. **`createDiagnosticLogger(opts)`** — Factory that returns a logger instance bound to a specific agent invocation. Accepts: + - `agentType` (string) — GSD agent type (gsd-planner, gsd-executor, etc.) + - `issueNumber` (number) — GitHub issue being worked + - `promptHash` (string, optional) — Hash of the prompt sent to the agent + - `repoRoot` (string, optional) — Repo root for diagnostics dir + + Returns an object with: + - `start()` — Records start_time + - `finish(result)` — Records end_time, calculates duration, writes entry + - `result` fields: `exitReason` (string), `turnCount` (number), `outputSize` (number), `error` (Error, optional) + +3. **`writeDiagnosticEntry(entry, opts?)`** — Low-level write function. Writes a single diagnostic JSON to `.mgw/diagnostics/-.json`. Fields: + - `agent_type`, `prompt_hash`, `start_time`, `end_time`, `duration_ms` + - `turn_count`, `exit_reason`, `output_size` + - `failure_classification` — null on success, or result of `classifyAgentFailure()` from `lib/agent-errors.cjs` on failure + - `issue_number`, `timestamp` + +4. **`pruneDiagnostics(opts?)`** — Removes diagnostic files older than `maxAgeDays` (default 30). Scans `.mgw/diagnostics/`, parses filenames for timestamps, removes expired entries. Non-blocking. + +5. **`readDiagnostics(opts?)`** — Read diagnostic entries with optional filters (issueNumber, agentType, since). Returns parsed JSON array sorted by timestamp descending. + +**verify:** +- Module loads without errors: `node -e "require('./lib/agent-diagnostics.cjs')"` +- All five exports exist and are functions +- Non-blocking: wrapping in try/catch is not needed by callers + +**done:** Module file exists and exports all functions. + +## Verification + +- [ ] `lib/agent-diagnostics.cjs` exists and loads cleanly +- [ ] Exports: `getDiagnosticsDir`, `createDiagnosticLogger`, `writeDiagnosticEntry`, `pruneDiagnostics`, `readDiagnostics` +- [ ] Diagnostic entry JSON schema matches spec (agent_type, prompt_hash, start_time, end_time, duration_ms, turn_count, exit_reason, output_size, failure_classification, issue_number, timestamp) +- [ ] Prune function defaults to 30-day retention +- [ ] All I/O operations wrapped in try/catch — never throws +- [ ] References `classifyAgentFailure` from `lib/agent-errors.cjs` for failure classification (graceful fallback if module not available) +- [ ] Follows existing lib/ conventions (JSDoc, 'use strict', module.exports) + +## Success Criteria + +- The module is self-contained and requires no changes to existing files +- Callers can instrument agent executions with `createDiagnosticLogger()` start/finish pattern +- Diagnostic data persists across pipeline runs for observability +- The prune function prevents unbounded storage growth +- Zero risk of pipeline disruption from logging failures + +## Output + +- `lib/agent-diagnostics.cjs` — complete implementation +- `.planning/quick/8-issue-230-build-structured-diagnostic-lo/8-SUMMARY.md` — execution summary diff --git a/.planning/quick/8-issue-230-build-structured-diagnostic-lo/8-SUMMARY.md b/.planning/quick/8-issue-230-build-structured-diagnostic-lo/8-SUMMARY.md new file mode 100644 index 0000000..9cc91ee --- /dev/null +++ b/.planning/quick/8-issue-230-build-structured-diagnostic-lo/8-SUMMARY.md @@ -0,0 +1,62 @@ +## Summary + +**One-liner:** Created `lib/agent-diagnostics.cjs` — a non-blocking diagnostic logger that captures per-agent-invocation telemetry and writes structured JSON entries to `.mgw/diagnostics/`. + +### What Was Built + +1. **`lib/agent-diagnostics.cjs`** — Complete diagnostic logger module with 7 exports: + - `getDiagnosticsDir(repoRoot?)` — Returns/creates `.mgw/diagnostics/` directory + - `createDiagnosticLogger(opts)` — Factory returning `{ start(), finish(result) }` logger bound to an agent invocation + - `writeDiagnosticEntry(entry, opts?)` — Low-level JSON file writer for diagnostic entries + - `pruneDiagnostics(opts?)` — Removes entries older than 30 days (configurable) + - `readDiagnostics(opts?)` — Query entries with filters (issueNumber, agentType, since, limit) + - `shortHash(input)` — SHA-256 utility for prompt hashing (12 hex chars) + - `DEFAULT_MAX_AGE_DAYS` — Constant (30) + +### Diagnostic Entry Schema + +Each JSON file at `.mgw/diagnostics/-.json` contains: +- `agent_type` — GSD agent type (gsd-planner, gsd-executor, etc.) +- `prompt_hash` — 12-char SHA-256 hash of the prompt +- `start_time` / `end_time` — ISO timestamps +- `duration_ms` — Wall-clock execution time +- `turn_count` — Number of agent turns/iterations +- `exit_reason` — Why the agent stopped (success, error, timeout, etc.) +- `output_size` — Agent output size in bytes +- `failure_classification` — null on success, or classification from agent-errors.cjs +- `issue_number` — GitHub issue number +- `timestamp` — Entry creation timestamp + +### Key Design Decisions + +1. **Graceful fallback for agent-errors.cjs:** Since PR #238 (issue #229) isn't merged yet, the classification function falls back through `lib/retry.cjs` then to a minimal classification if neither module is available. + +2. **Non-blocking guarantees:** Every public function wraps all I/O in try/catch blocks. `writeDiagnosticEntry()` returns `boolean`, `pruneDiagnostics()` returns a result object with error counts, `readDiagnostics()` returns empty arrays on failure. No function ever throws. + +3. **File-per-entry storage:** Individual JSON files (not JSONL) enable per-entry deletion for pruning and straightforward reads without parsing. + +4. **Filesystem-safe timestamps:** ISO timestamps in filenames have colons and dots replaced with hyphens. + +### Files Created + +| File | Lines | Purpose | +|------|-------|---------| +| `lib/agent-diagnostics.cjs` | 451 | Diagnostic logger module | + +### Testing + +Verified with 19 assertions covering: +- Hash generation (correct length, null handling) +- Directory creation +- Entry write/read round-trip +- Logger factory start/finish pattern +- Error classification integration (fallback path) +- Prune function (no false positives on recent entries) +- Non-blocking behavior on invalid inputs +- Filter functionality (agentType, issueNumber, limit) + +### Integration Notes + +- Ready for issue #231 (diagnostic capture hooks) to instrument agent spawns +- When PR #238 merges, failure classification will automatically upgrade to use `classifyAgentFailure()` from `lib/agent-errors.cjs` +- Follows existing lib/ conventions: `'use strict'`, JSDoc, `module.exports` pattern diff --git a/.planning/quick/8-issue-230-build-structured-diagnostic-lo/8-VERIFICATION.md b/.planning/quick/8-issue-230-build-structured-diagnostic-lo/8-VERIFICATION.md new file mode 100644 index 0000000..99fef62 --- /dev/null +++ b/.planning/quick/8-issue-230-build-structured-diagnostic-lo/8-VERIFICATION.md @@ -0,0 +1,35 @@ +## Verification Passed + +### Must-Have Checks + +| # | Must-Have | Status | Evidence | +|---|-----------|--------|----------| +| 1 | lib/agent-diagnostics.cjs exports getDiagnosticsDir, createDiagnosticLogger, writeDiagnosticEntry, pruneDiagnostics, readDiagnostics | PASS | Module loads, all 5 core functions + shortHash + DEFAULT_MAX_AGE_DAYS exported | +| 2 | Diagnostic entries are JSON files at .mgw/diagnostics/-.json | PASS | writeDiagnosticEntry creates files with correct naming pattern | +| 3 | Each entry captures agent_type, prompt_hash, start_time, end_time, duration_ms, turn_count, exit_reason, output_size, failure_classification | PASS | All 9 fields present in written JSON + issue_number + timestamp | +| 4 | Prune function removes entries older than 30 days | PASS | pruneDiagnostics defaults to 30 days, uses file mtime for age calculation | +| 5 | All logging operations wrapped in try/catch — failures never propagate | PASS | Every public function has top-level try/catch; returns safe defaults on error | +| 6 | failure_classification uses types from lib/agent-errors.cjs | PASS | Graceful fallback: tries agent-errors.cjs first, then retry.cjs, then minimal | + +### Artifact Checks + +| Artifact | Exists | Valid | +|----------|--------|-------| +| lib/agent-diagnostics.cjs | Yes | 451 lines, loads without errors | + +### Key Link Checks + +| Link | Exists | Referenced Correctly | +|------|--------|---------------------| +| lib/agent-errors.cjs | On PR branch (not main) | Graceful require with fallback | +| lib/errors.cjs | Yes | Not directly required (via agent-errors.cjs) | +| lib/retry.cjs | Yes | Used as fallback classifier | +| lib/logger.cjs | Yes | Pattern reference (not imported) | + +### Functional Verification + +- 19/19 assertions passed +- Non-blocking behavior confirmed on null inputs and invalid paths +- Error classification fallback path confirmed working +- Read filtering (issueNumber, agentType, limit) verified +- Prune function correctly skips recent entries diff --git a/lib/agent-diagnostics.cjs b/lib/agent-diagnostics.cjs new file mode 100644 index 0000000..b5d2604 --- /dev/null +++ b/lib/agent-diagnostics.cjs @@ -0,0 +1,451 @@ +'use strict'; + +/** + * lib/agent-diagnostics.cjs — Structured diagnostic logger for agent executions + * + * Captures per-agent-invocation telemetry (timing, turns, output size, exit + * reason, failure classification) and persists it as individual JSON files + * under .mgw/diagnostics/. + * + * All operations are non-blocking: logging failures are silently swallowed + * to ensure the pipeline is never halted by diagnostic infrastructure. + * + * Diagnostic entry schema: + * agent_type — GSD agent type (gsd-planner, gsd-executor, etc.) + * prompt_hash — hash of the prompt sent to the agent + * start_time — ISO timestamp when agent was spawned + * end_time — ISO timestamp when agent completed + * duration_ms — wall-clock execution time in milliseconds + * turn_count — number of turns/iterations the agent used + * exit_reason — why the agent stopped (success, error, timeout, etc.) + * output_size — size of agent output in bytes + * failure_classification — null on success, or classification from agent-errors.cjs + * issue_number — GitHub issue being worked + * timestamp — entry creation timestamp + * + * Integrates with: + * - lib/agent-errors.cjs (classifyAgentFailure — graceful fallback if unavailable) + * - lib/logger.cjs (pattern reference for non-blocking logging) + */ + +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); + +/** Default max age for diagnostic entries (days) */ +const DEFAULT_MAX_AGE_DAYS = 30; + +// --------------------------------------------------------------------------- +// Diagnostics directory +// --------------------------------------------------------------------------- + +/** + * Get the diagnostics directory path (.mgw/diagnostics/ under repo root). + * Creates the directory if it does not exist. + * + * @param {string} [repoRoot] - Repository root. Defaults to cwd. + * @returns {string} Absolute path to diagnostics directory + */ +function getDiagnosticsDir(repoRoot) { + const root = repoRoot || process.cwd(); + const diagDir = path.join(root, '.mgw', 'diagnostics'); + try { + if (!fs.existsSync(diagDir)) { + fs.mkdirSync(diagDir, { recursive: true }); + } + } catch { + // Non-blocking: if we cannot create the directory, writes will fail + // gracefully later + } + return diagDir; +} + +// --------------------------------------------------------------------------- +// Hash utility +// --------------------------------------------------------------------------- + +/** + * Compute a short SHA-256 hash of a string. + * + * @param {string} input - String to hash + * @returns {string} First 12 hex characters of SHA-256 digest + */ +function shortHash(input) { + if (!input || typeof input !== 'string') return 'none'; + try { + return crypto.createHash('sha256').update(input).digest('hex').slice(0, 12); + } catch { + return 'none'; + } +} + +// --------------------------------------------------------------------------- +// Agent failure classification (graceful fallback) +// --------------------------------------------------------------------------- + +/** + * Attempt to classify an agent failure using lib/agent-errors.cjs. + * Falls back gracefully if the module is unavailable (e.g. PR #238 not merged). + * + * @param {Error} error - The error that occurred + * @param {object} [context] - Optional classification context + * @returns {object|null} Classification result or null + */ +function classifyFailure(error, context) { + if (!error) return null; + + try { + const { classifyAgentFailure } = require('./agent-errors.cjs'); + return classifyAgentFailure(error, context); + } catch { + // agent-errors.cjs not available — fall back to basic classification + // using lib/retry.cjs patterns + try { + const { classifyFailure: retryClassify } = require('./retry.cjs'); + const result = retryClassify(error); + return { + type: result.class, + code: 'AGENT_ERR_UNKNOWN', + severity: result.class === 'transient' ? 'low' : 'high', + confidence: 'low', + }; + } catch { + // Neither module available — return minimal classification + return { + type: 'unknown', + code: 'AGENT_ERR_UNKNOWN', + severity: 'medium', + confidence: 'low', + }; + } + } +} + +// --------------------------------------------------------------------------- +// Write diagnostic entry +// --------------------------------------------------------------------------- + +/** + * Write a single diagnostic entry to .mgw/diagnostics/. + * + * File name format: -.json + * where timestamp is ISO format with colons replaced by hyphens for + * filesystem compatibility. + * + * Non-blocking: all errors are swallowed. Returns true on success, false + * on failure. Callers should never need to wrap this in try/catch. + * + * @param {object} entry - Diagnostic entry to write + * @param {string} entry.agent_type - GSD agent type + * @param {string} [entry.prompt_hash] - Hash of the prompt sent + * @param {string} entry.start_time - ISO timestamp when agent started + * @param {string} entry.end_time - ISO timestamp when agent finished + * @param {number} entry.duration_ms - Execution time in milliseconds + * @param {number} [entry.turn_count] - Number of agent turns + * @param {string} entry.exit_reason - Why the agent stopped + * @param {number} [entry.output_size] - Output size in bytes + * @param {object|null} [entry.failure_classification] - Failure classification or null + * @param {number} entry.issue_number - GitHub issue number + * @param {object} [opts] + * @param {string} [opts.repoRoot] - Repository root + * @returns {boolean} True if write succeeded, false otherwise + */ +function writeDiagnosticEntry(entry, opts) { + try { + const o = opts || {}; + const diagDir = getDiagnosticsDir(o.repoRoot); + + const issueNum = entry.issue_number || 0; + // Create filesystem-safe timestamp + const ts = (entry.timestamp || new Date().toISOString()) + .replace(/:/g, '-') + .replace(/\./g, '-'); + + const fileName = `${issueNum}-${ts}.json`; + const filePath = path.join(diagDir, fileName); + + const record = { + agent_type: entry.agent_type || 'unknown', + prompt_hash: entry.prompt_hash || 'none', + start_time: entry.start_time || null, + end_time: entry.end_time || null, + duration_ms: typeof entry.duration_ms === 'number' ? entry.duration_ms : null, + turn_count: typeof entry.turn_count === 'number' ? entry.turn_count : null, + exit_reason: entry.exit_reason || 'unknown', + output_size: typeof entry.output_size === 'number' ? entry.output_size : null, + failure_classification: entry.failure_classification || null, + issue_number: issueNum, + timestamp: entry.timestamp || new Date().toISOString(), + }; + + fs.writeFileSync(filePath, JSON.stringify(record, null, 2)); + return true; + } catch { + // Non-blocking: swallow all write errors + return false; + } +} + +// --------------------------------------------------------------------------- +// Diagnostic logger factory +// --------------------------------------------------------------------------- + +/** + * Create a diagnostic logger bound to a specific agent invocation. + * + * Usage: + * const logger = createDiagnosticLogger({ + * agentType: 'gsd-executor', + * issueNumber: 230, + * promptHash: shortHash(prompt), + * }); + * logger.start(); + * // ... agent executes ... + * logger.finish({ + * exitReason: 'success', + * turnCount: 12, + * outputSize: 4096, + * }); + * + * @param {object} opts + * @param {string} opts.agentType - GSD agent type (gsd-planner, gsd-executor, etc.) + * @param {number} opts.issueNumber - GitHub issue being worked + * @param {string} [opts.promptHash] - Hash of the prompt (use shortHash() to generate) + * @param {string} [opts.repoRoot] - Repository root for diagnostics dir + * @returns {{ start: () => void, finish: (result: object) => boolean }} + */ +function createDiagnosticLogger(opts) { + const o = opts || {}; + let startTime = null; + + return { + /** + * Record the start of agent execution. + * Can be called multiple times; only the last call is used. + */ + start() { + try { + startTime = new Date().toISOString(); + } catch { + // Non-blocking + } + }, + + /** + * Record the end of agent execution and write the diagnostic entry. + * + * @param {object} result + * @param {string} result.exitReason - Why the agent stopped (success, error, timeout, etc.) + * @param {number} [result.turnCount] - Number of turns the agent used + * @param {number} [result.outputSize] - Size of agent output in bytes + * @param {Error} [result.error] - Error object if the agent failed + * @param {object} [result.classificationContext] - Context for failure classification + * @returns {boolean} True if write succeeded + */ + finish(result) { + try { + const r = result || {}; + const endTime = new Date().toISOString(); + const start = startTime || endTime; + + // Calculate duration + const durationMs = new Date(endTime).getTime() - new Date(start).getTime(); + + // Classify failure if error is present + let failureClassification = null; + if (r.error) { + failureClassification = classifyFailure(r.error, r.classificationContext || { + agentType: o.agentType, + }); + } + + return writeDiagnosticEntry({ + agent_type: o.agentType || 'unknown', + prompt_hash: o.promptHash || 'none', + start_time: start, + end_time: endTime, + duration_ms: durationMs, + turn_count: typeof r.turnCount === 'number' ? r.turnCount : null, + exit_reason: r.exitReason || 'unknown', + output_size: typeof r.outputSize === 'number' ? r.outputSize : null, + failure_classification: failureClassification, + issue_number: o.issueNumber || 0, + timestamp: endTime, + }, { repoRoot: o.repoRoot }); + } catch { + // Non-blocking: swallow all errors + return false; + } + }, + }; +} + +// --------------------------------------------------------------------------- +// Prune old diagnostics +// --------------------------------------------------------------------------- + +/** + * Remove diagnostic entries older than the specified age. + * + * Scans .mgw/diagnostics/, parses file modification times, and removes + * files older than maxAgeDays. Non-blocking: errors on individual files + * are silently skipped. + * + * @param {object} [opts] + * @param {number} [opts.maxAgeDays] - Maximum age in days (default: 30) + * @param {string} [opts.repoRoot] - Repository root + * @returns {{ removed: number, errors: number, total: number }} + */ +function pruneDiagnostics(opts) { + const result = { removed: 0, errors: 0, total: 0 }; + + try { + const o = opts || {}; + const maxAgeDays = typeof o.maxAgeDays === 'number' ? o.maxAgeDays : DEFAULT_MAX_AGE_DAYS; + const diagDir = getDiagnosticsDir(o.repoRoot); + + if (!fs.existsSync(diagDir)) return result; + + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - maxAgeDays); + const cutoffMs = cutoff.getTime(); + + let files; + try { + files = fs.readdirSync(diagDir).filter(f => f.endsWith('.json')); + } catch { + return result; + } + + result.total = files.length; + + for (const file of files) { + try { + const filePath = path.join(diagDir, file); + const stat = fs.statSync(filePath); + + if (stat.mtimeMs < cutoffMs) { + fs.unlinkSync(filePath); + result.removed++; + } + } catch { + result.errors++; + } + } + } catch { + // Non-blocking: swallow top-level errors + } + + return result; +} + +// --------------------------------------------------------------------------- +// Read diagnostics +// --------------------------------------------------------------------------- + +/** + * Read diagnostic entries with optional filters. + * + * Returns parsed diagnostic entries sorted by timestamp descending + * (most recent first). + * + * @param {object} [opts] + * @param {string} [opts.repoRoot] - Repository root + * @param {number} [opts.issueNumber] - Filter by issue number + * @param {string} [opts.agentType] - Filter by agent type + * @param {string} [opts.since] - ISO date string — only entries after this date + * @param {number} [opts.limit] - Maximum number of entries to return + * @returns {object[]} Array of diagnostic entries + */ +function readDiagnostics(opts) { + try { + const o = opts || {}; + const diagDir = getDiagnosticsDir(o.repoRoot); + + if (!fs.existsSync(diagDir)) return []; + + let files; + try { + files = fs.readdirSync(diagDir).filter(f => f.endsWith('.json')); + } catch { + return []; + } + + // Parse since date if provided + let sinceMs = null; + if (o.since) { + try { + sinceMs = new Date(o.since).getTime(); + } catch { + // Invalid date — ignore filter + } + } + + const entries = []; + + for (const file of files) { + try { + const filePath = path.join(diagDir, file); + const content = fs.readFileSync(filePath, 'utf-8'); + const entry = JSON.parse(content); + + // Apply filters + if (o.issueNumber && entry.issue_number !== o.issueNumber) continue; + if (o.agentType && entry.agent_type !== o.agentType) continue; + if (sinceMs && entry.timestamp) { + const entryMs = new Date(entry.timestamp).getTime(); + if (entryMs < sinceMs) continue; + } + + entries.push(entry); + } catch { + // Skip unparseable files + continue; + } + } + + // Sort by timestamp descending (most recent first) + entries.sort((a, b) => { + const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0; + const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0; + return tb - ta; + }); + + // Apply limit + if (typeof o.limit === 'number' && o.limit > 0 && entries.length > o.limit) { + return entries.slice(0, o.limit); + } + + return entries; + } catch { + // Non-blocking + return []; + } +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +module.exports = { + // Directory + getDiagnosticsDir, + + // Logger factory + createDiagnosticLogger, + + // Low-level write + writeDiagnosticEntry, + + // Maintenance + pruneDiagnostics, + + // Read/query + readDiagnostics, + + // Utilities + shortHash, + + // Constants + DEFAULT_MAX_AGE_DAYS, +};