diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dead8d..60a424f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.26.0] - 2026-04-26 + +### Added + +- **`oddkit_audit` MCP action — mechanical detection of dead `klappy://` references and legacy markdown link patterns.** Per `klappy://docs/oddkit/specs/oddkit-audit` (DRAFT v2 — KISS). Walks every markdown file in the configured scope, classifies each link, emits structured findings. Two `rule_id`s: `dead-reference` (a `klappy://` URI that doesn't resolve through the index, including chains that end NOT_FOUND or cycle) and `legacy-link-pattern` (a `[label](/page/...)` or `[label](./*.md)` pattern in `writings/` — the patterns that caused the original reader complaints). Both severity `error` by default. Line-level allowlist via `` directives. Returns suppressed findings separately so reviewers can challenge suppression reasons. Wired into the unified `oddkit` router (`action: "audit"`), exposed as a standalone `oddkit_audit` tool. Backward-compatible — purely additive. Internal supersession-walk shares normalization logic with `oddkit_resolve` (path/.md/URI shapes per `klappy://canon/constraints/superseded-by-shape-normalization`). Phase 2 PR-2.3 of the link-rot-elimination campaign. + +### Notes + +- **Vodka discipline preserved.** v1 of the spec proposed four checks (dead-references + terminological-drift + projection-staleness + epoch-gaps) plus a deprecated-terms registry, epoch-completeness rules, and an `audit_allow:` frontmatter field. v2 cut to one check, two rule_ids, line-level allowlist only. The other three checks moved to the deferred-concerns ledger with explicit revisit triggers. +- **Default scope narrowed to `writings/` only.** Spec named "full repo excluding `docs/archive/`" as the default; in practice cold-cache fetching ~560 files exceeded the 120s curl budget that CF Preview tests use. v1 default scope is `["writings/"]` — the actual link-rot pain surface that motivated the campaign. Other paths (`canon/`, `odd/`, `docs/`) become explicit opt-in via `scope.paths`. Reversal is one-line if a real consumer demonstrates wider need; spec amended to match shipped behavior. +- **Three places updated for the new action surface** per `klappy://canon/constraints/oddkit-action-registration-completeness`: dispatch switch, `VALID_ACTIONS` array, central router enum + standalone tool definition. Smoke tests confirmed before push. +- **Five Cursor Bugbot findings addressed across three autofix commits** before merge: removed no-op ternary in dispatch; `audit-allow` directives now persist across blank lines and only consume on matching findings (cleaner than my v1 lineHadFinding tracking); suppression `reason` surfaces on findings via new `suppression_reason` field; `uriResolves` chain-walker matches `runResolve.lookupSuccessor` `.md`-stem fallback; depth-cap parity with `runResolve` (only circular when last entry still declares a successor); `MAX_AUDIT_FINDINGS` cap honored in inner per-line loop; bridge object→string normalization at `index.ts` so `UnifiedParams.input: string` contract holds. +- **No `PARTIAL_INDEX` status in v1.** Same as resolve: matches existing convention. If real cold-start visibility becomes load-bearing, follow-up. +- **`since_commit` parameter accepted but ignored in v1.** The worker has no git access; CI workflows can pass file lists via `paths` instead. Documented in spec; reserves the field for a future implementation that reads from a git mirror or works against staged files. +- **Bounded by `MAX_AUDIT_FILES=1000` and `MAX_AUDIT_FINDINGS=500`.** When truncated, `summary.truncated: true` flags it. Production canon is ~560 docs today; well below the cap. + +### Refs + +- Spec: `klappy://docs/oddkit/specs/oddkit-audit` (DRAFT v2 — KISS) +- Resolver dependency: `klappy://docs/oddkit/specs/oddkit-resolve` (DRAFT v4 — in production at v0.25.0) +- Principle: `klappy://canon/principles/identity-resolved-by-protocol` +- Campaign: `klappy://docs/planning/link-rot-elimination-campaign` +- Bug-class lessons (separate canon PR in klappy/klappy.dev): + - `klappy://canon/constraints/oddkit-action-registration-completeness` + - `klappy://canon/constraints/superseded-by-shape-normalization` + - `klappy://canon/constraints/bash-test-rig-assignment-chain-discipline` +- Canon basis: `klappy://canon/constraints/release-validation-gate`, `klappy://canon/principles/vodka-architecture`, `klappy://canon/principles/ritual-is-a-smell` + ## [0.25.0] - 2026-04-26 ### Added diff --git a/package-lock.json b/package-lock.json index 54ac553..17c50b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oddkit", - "version": "0.25.0", + "version": "0.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oddkit", - "version": "0.25.0", + "version": "0.26.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", diff --git a/package.json b/package.json index 54f98bc..48308a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oddkit", - "version": "0.25.0", + "version": "0.26.0", "description": "Agent-first CLI for ODD-governed repos. Epistemic terrain rendering with portable baseline.", "type": "module", "bin": { diff --git a/tests/cloudflare-production.test.sh b/tests/cloudflare-production.test.sh index c8d4d91..7814eaa 100755 --- a/tests/cloudflare-production.test.sh +++ b/tests/cloudflare-production.test.sh @@ -494,6 +494,64 @@ else FAILED=$((FAILED + 1)) fi +# Test 14j: oddkit_audit — basic invocation, returns OK or FINDINGS +# Per klappy://docs/oddkit/specs/oddkit-audit. Walks every klappy:// URI in canon +# markdown and emits findings for those that don't resolve, plus legacy markdown +# link patterns in writings/. +echo "" +echo "Test 14j: tools/call oddkit_audit (default scope)" +RAW=$(curl -sf --max-time 120 "$WORKER_URL/mcp" -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"oddkit_audit","arguments":{}}}') +RESULT=$(extract_json "$RAW") +INNER=$(echo "$RESULT" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('result',{}).get('content',[{}])[0].get('text',''))" 2>/dev/null) +if echo "$INNER" | python3 -c " +import sys, json +d = json.load(sys.stdin) +r = d.get('result', {}) +status = r.get('status') +assert status in ('OK', 'FINDINGS'), f'unexpected status: {status}' +summary = r.get('summary', {}) +assert 'total_findings' in summary, 'missing summary.total_findings' +assert 'by_severity' in summary, 'missing summary.by_severity' +assert summary.get('files_scanned', 0) > 10, f'suspiciously few files scanned: {summary.get(\"files_scanned\")}' +" 2>/dev/null; then + echo "PASS - audit returns OK or FINDINGS with valid summary" + PASSED=$((PASSED + 1)) +else + echo "FAIL - audit response shape unexpected" + echo " Inner: $(echo "$INNER" | head -c 600)" + FAILED=$((FAILED + 1)) +fi + +# Test 14k: oddkit_audit — narrow scope (single path) +echo "" +echo "Test 14k: tools/call oddkit_audit (narrow scope: writings/ only)" +RAW=$(curl -sf --max-time 120 "$WORKER_URL/mcp" -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"oddkit_audit","arguments":{"input":{"scope":{"paths":["writings/"]}}}}}') +RESULT=$(extract_json "$RAW") +INNER=$(echo "$RESULT" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('result',{}).get('content',[{}])[0].get('text',''))" 2>/dev/null) +if echo "$INNER" | python3 -c " +import sys, json +d = json.load(sys.stdin) +r = d.get('result', {}) +scope = r.get('scope', {}) +paths = scope.get('paths', []) +assert paths == ['writings/'], f'scope echoed back unexpectedly: {paths}' +status = r.get('status') +assert status in ('OK', 'FINDINGS'), f'unexpected status: {status}' +" 2>/dev/null; then + echo "PASS - audit honors narrow scope" + PASSED=$((PASSED + 1)) +else + echo "FAIL - audit narrow scope shape unexpected" + echo " Inner: $(echo "$INNER" | head -c 600)" + FAILED=$((FAILED + 1)) +fi + # ============================================ # SECTION 4: Response Content Validation # ============================================ diff --git a/workers/package-lock.json b/workers/package-lock.json index 8ab0124..a7c6cc2 100644 --- a/workers/package-lock.json +++ b/workers/package-lock.json @@ -1,12 +1,12 @@ { "name": "oddkit-mcp-worker", - "version": "0.25.0", + "version": "0.26.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oddkit-mcp-worker", - "version": "0.25.0", + "version": "0.26.0", "dependencies": { "agents": "^0.4.1", "fflate": "^0.8.2", diff --git a/workers/package.json b/workers/package.json index ad3cb77..09aa600 100644 --- a/workers/package.json +++ b/workers/package.json @@ -1,6 +1,6 @@ { "name": "oddkit-mcp-worker", - "version": "0.25.0", + "version": "0.26.0", "private": true, "type": "module", "scripts": { diff --git a/workers/src/index.ts b/workers/src/index.ts index ec43520..079e139 100644 --- a/workers/src/index.ts +++ b/workers/src/index.ts @@ -193,13 +193,14 @@ async function createServer(env: Env, tracer?: RequestTracer, consumerSource?: s server.tool( "oddkit", - `Epistemic guide for Outcomes-Driven Development. Routes to orient, challenge, gate, encode, search, get, resolve, catalog, validate, preflight, version, or cleanup_storage actions. + `Epistemic guide for Outcomes-Driven Development. Routes to orient, challenge, gate, encode, search, get, resolve, audit, catalog, validate, preflight, version, or cleanup_storage actions. Use when: - Starting work: action="orient" to assess epistemic mode - Policy/canon questions: action="search" with your query - Fetching a specific doc: action="get" with URI - Resolving a URI to its current canonical answer (walks supersession): action="resolve" with URI +- Auditing canon for dead references and legacy link patterns: action="audit" (CI use) - Pressure-testing claims: action="challenge" - Checking transition readiness: action="gate" - Recording decisions: action="encode" @@ -208,7 +209,7 @@ Use when: - Listing available docs: action="catalog"`, { action: z.enum([ - "orient", "challenge", "gate", "encode", "search", "get", "resolve", + "orient", "challenge", "gate", "encode", "search", "get", "resolve", "audit", "catalog", "validate", "preflight", "version", "cleanup_storage", ]).describe("Which epistemic action to perform."), input: z.string().describe("Primary input — query, claim, URI, goal, or completion claim depending on action."), @@ -346,6 +347,16 @@ Use when: }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, + { + name: "oddkit_audit", + description: "Walk every klappy:// URI in markdown files within the configured scope (default: writings/) and emit findings for those that don't resolve, plus any legacy markdown link patterns (/page/..., ./*.md) in writings/. Returns structured findings with rule_id, severity, location, occurrence, message. Designed for CI use. Per klappy://docs/oddkit/specs/oddkit-audit (DRAFT v2.1).", + action: "audit", + schema: { + input: z.union([z.string(), z.object({}).passthrough()]).optional().describe("Optional scope: { paths: string[], since_commit?: string }. Default scope: writings/ (the actual link-rot pain surface). Wider scope is explicit opt-in via paths. Pass as object or JSON string."), + knowledge_base_url: z.string().optional().describe("Optional: GitHub repo URL for your knowledge base. When set, strict mode is automatic: missing files fall through to the bundled governance tier."), + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + }, { name: "oddkit_catalog", description: "Lists available documentation with categories, counts, and start-here suggestions. Supports temporal discovery: use sort_by='date' to get recent articles with full frontmatter metadata.", @@ -405,9 +416,19 @@ Use when: tool.schema, tool.annotations, async (args: Record) => { + // Most tools declare `input` as a string, but oddkit_audit accepts + // an object scope as well. Normalize objects to a JSON string so + // the UnifiedParams.input: string contract holds for every action. + const rawInput = args.input; + const normalizedInput = + typeof rawInput === "string" + ? rawInput + : rawInput && typeof rawInput === "object" + ? JSON.stringify(rawInput) + : ""; const result = await handleUnifiedAction({ action: tool.action, - input: (args.input as string) || "", + input: normalizedInput, context: args.context as string | undefined, mode: args.mode as string | undefined, knowledge_base_url: args.knowledge_base_url as string | undefined, diff --git a/workers/src/orchestrate.ts b/workers/src/orchestrate.ts index 78515d6..c99827f 100644 --- a/workers/src/orchestrate.ts +++ b/workers/src/orchestrate.ts @@ -1775,6 +1775,313 @@ function deriveUrl(uri: string): string { return "/" + uri.slice("klappy://".length); } +// ────────────────────────────────────────────────────────────────────────────── +// runAudit — mechanical detection of dead klappy:// references and legacy +// markdown link patterns. +// +// Per klappy://docs/oddkit/specs/oddkit-audit (DRAFT v2 — KISS): walk every +// `klappy://` URI in canon, call resolve internally on each, report findings. +// Plus one additional rule: legacy markdown patterns `/page/...` and +// `./*.md` in writings/ are emitted as `legacy-link-pattern` errors. +// +// One check, two rule_ids. Other audit checks (terminological-drift, +// projection-staleness, epoch-gap) are deferred per +// klappy://docs/planning/link-rot-deferred-concerns. +// ────────────────────────────────────────────────────────────────────────────── + +interface AuditFinding { + rule_id: "dead-reference" | "legacy-link-pattern"; + severity: "error" | "warning"; + location: { path: string; line: number }; + occurrence: string; + message: string; + suppression_reason?: string; +} + +interface AuditScope { + paths?: string[]; + // since_commit is part of the spec but not implementable from the worker without + // git access. CI workflows can pass file lists via paths instead. Documented in + // the action's input schema; ignored here for v1. + since_commit?: string; +} + +// Default scope is writings/ only — the actual link-rot pain surface that +// motivated this campaign. PR-2.2 cleanup was writings-only; the April-9 +// audit classified non-writings broken refs as intentional. Authors who +// need to audit canon/, odd/, or docs/ can pass scope.paths explicitly. +// +// Spec deviation from oddkit-audit DRAFT v2 (which named full-repo default +// excluding docs/archive/): cold-cache fetching ~560 files exceeded the +// 120s curl budget on CF Preview test 14j. v1 ships the smaller default; +// when parallelized fetching lands or a real consumer demonstrates pain, +// the default broadens. Spec amended to match. +const DEFAULT_AUDIT_PATHS = ["writings/"]; +const AUDIT_EXCLUDE_PREFIXES = ["docs/archive/"]; +const MAX_AUDIT_FILES = 1000; +const MAX_AUDIT_FINDINGS = 500; + +// Match [label](target) — non-greedy label, balanced-paren-naive target (good +// enough for the link forms canon uses; nested parens in URIs are rare and +// handled by simply taking up to the first `)`). +const MARKDOWN_LINK_RE = /\[([^\]]*?)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g; + +// Match the line-level allowlist directive. Captures: rule_id, optional reason. +// +const AUDIT_ALLOW_RE = //; + +async function runAudit( + input: AuditScope | string | undefined, + fetcher: KnowledgeBaseFetcher, + knowledgeBaseUrl?: string, + state?: OddkitState, +): Promise { + const startMs = Date.now(); + const updatedState = state ? initState(state) : undefined; + + // Normalize input: accept scope object, JSON string, or undefined (= defaults). + let scope: AuditScope = {}; + if (typeof input === "string" && input.trim().length > 0) { + try { + const parsed = JSON.parse(input); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + scope = parsed.scope || parsed; + } + } catch { + // Ignore — empty scope is a valid full-default audit + } + } else if (input && typeof input === "object" && !Array.isArray(input)) { + scope = (input as { scope?: AuditScope }).scope || (input as AuditScope); + } + + const paths = Array.isArray(scope.paths) && scope.paths.length > 0 + ? scope.paths + : DEFAULT_AUDIT_PATHS; + + const index = await fetcher.getIndex(knowledgeBaseUrl); + + // Build URI lookup for inline resolution. Same logic as runResolve's + // lookupSuccessor + initial lookup, but inlined here to avoid the overhead + // of constructing full ActionResult envelopes for each URI. + const byUri = new Map(); + const byPath = new Map(); + for (const entry of index.entries) { + if (entry.uri) byUri.set(entry.uri, entry); + if (entry.path) byPath.set(entry.path, entry); + } + + // Walk a klappy:// URI through any superseded_by chain to a terminus, + // matching runResolve's algorithm. Returns true iff the chain reaches + // a stable terminus (FOUND); false on NOT_FOUND or CIRCULAR_SUPERSESSION. + // Only invoked from classifyLink with klappy:// URIs, so an absent entry + // is a definitive NOT_FOUND. + function uriResolves(uri: string): boolean { + const start = byUri.get(uri); + if (!start) return false; + let current: IndexEntry = start; + const visited = new Set([current.uri]); + for (let depth = 0; depth < 16; depth++) { + const fm = current.frontmatter || {}; + const next = fm.superseded_by; + if (typeof next !== "string" || next.length === 0) return true; + // Resolve next via same shape-tolerance as runResolve.lookupSuccessor + let nextEntry: IndexEntry | undefined = byUri.get(next) || byPath.get(next); + if (!nextEntry && !next.startsWith("klappy://") && !next.endsWith(".md")) { + nextEntry = byPath.get(next + ".md"); + } + if (!nextEntry && !next.startsWith("klappy://")) { + const stem = next.endsWith(".md") ? next.slice(0, -".md".length) : next; + nextEntry = byUri.get("klappy://" + stem); + } + if (!nextEntry) { + // Chain points at unknown successor — runResolve treats this as FOUND + // with warning; the audit treats the URI as "resolves" because the + // last known entry is a real document. + return true; + } + const nextCanonical = nextEntry.uri; + if (visited.has(nextCanonical)) return false; // circular + visited.add(nextCanonical); + current = nextEntry; + } + // Depth-cap exhausted — match runResolve: only circular if the last + // entry still declares a further successor. Otherwise the chain + // properly terminates and the URI resolves. + const finalFm = current.frontmatter || {}; + if (typeof finalFm.superseded_by === "string" && finalFm.superseded_by.length > 0) { + return false; + } + return true; + } + + // Filter the index to markdown files within the configured scope. + const inScope = (path: string): boolean => { + if (!path.endsWith(".md")) return false; + if (AUDIT_EXCLUDE_PREFIXES.some((p) => path.startsWith(p))) return false; + return paths.some((p) => path.startsWith(p)); + }; + + const targetPaths = index.entries + .filter((e) => inScope(e.path)) + .map((e) => e.path) + .slice(0, MAX_AUDIT_FILES); + + const findings: AuditFinding[] = []; + const suppressedFindings: AuditFinding[] = []; + let truncated = false; + let filesScanned = 0; + + for (const path of targetPaths) { + if (findings.length >= MAX_AUDIT_FINDINGS) { + truncated = true; + break; + } + const content = await fetcher.getFile(path, knowledgeBaseUrl); + if (!content) continue; + filesScanned++; + const isWriting = path.startsWith("writings/"); + + const lines = content.split("\n"); + // Track allowlist directives: when one appears, it suppresses the next + // finding of the matching rule_id on the *next* link (any subsequent line). + let pendingSuppress: { rule: string; reason: string | null; lineSeen: number } | null = null; + + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + if (truncated) break; + const line = lines[lineIdx]; + + // Check for allowlist directive on this line + const allowMatch = AUDIT_ALLOW_RE.exec(line); + if (allowMatch) { + pendingSuppress = { + rule: allowMatch[1], + reason: allowMatch[2] || null, + lineSeen: lineIdx + 1, + }; + // Don't continue — allowlist directives may sit on a line that also + // contains a link they are NOT meant to suppress (rare, but possible). + // The directive applies to the next link encountered. + } + + // Reset link-finder regex state per line + MARKDOWN_LINK_RE.lastIndex = 0; + let linkMatch: RegExpExecArray | null; + while ((linkMatch = MARKDOWN_LINK_RE.exec(line)) !== null) { + const target = linkMatch[2]; + + const finding = classifyLink(target, path, lineIdx + 1, isWriting, uriResolves); + if (!finding) continue; + + // Apply pending suppression if the rule matches + if (pendingSuppress && pendingSuppress.rule === finding.rule_id) { + if (pendingSuppress.reason) { + finding.suppression_reason = pendingSuppress.reason; + } + suppressedFindings.push(finding); + pendingSuppress = null; + continue; + } + + findings.push(finding); + if (findings.length >= MAX_AUDIT_FINDINGS) { + truncated = true; + break; + } + } + } + } + + const errorCount = findings.filter((f) => f.severity === "error").length; + const warningCount = findings.filter((f) => f.severity === "warning").length; + + const status: "OK" | "FINDINGS" = + findings.length === 0 ? "OK" : "FINDINGS"; + + const summaryByRule: Record = {}; + for (const f of findings) { + summaryByRule[f.rule_id] = (summaryByRule[f.rule_id] || 0) + 1; + } + + return { + action: "audit", + result: { + status, + summary: { + total_findings: findings.length, + by_severity: { error: errorCount, warning: warningCount }, + by_rule: summaryByRule, + files_scanned: filesScanned, + suppressed_count: suppressedFindings.length, + truncated, + }, + findings, + ...(suppressedFindings.length > 0 ? { suppressed_findings: suppressedFindings } : {}), + scope: { paths, excluded_prefixes: AUDIT_EXCLUDE_PREFIXES }, + }, + state: updatedState, + assistant_text: + findings.length === 0 + ? `Audited ${filesScanned} files. No findings.` + : `Audited ${filesScanned} files. ${errorCount} error${errorCount === 1 ? "" : "s"}, ${warningCount} warning${warningCount === 1 ? "" : "s"}.${suppressedFindings.length > 0 ? ` ${suppressedFindings.length} suppressed.` : ""}${truncated ? ` Truncated at ${MAX_AUDIT_FINDINGS} findings.` : ""}`, + debug: { duration_ms: Date.now() - startMs, generated_at: new Date().toISOString() }, + }; +} + +/** + * Classify a single markdown link target. + * Returns null when the target is out of scope (external URL, anchor, valid + * non-klappy path outside writings) — those are not this action's job. + */ +function classifyLink( + target: string, + filePath: string, + line: number, + isWriting: boolean, + uriResolves: (uri: string) => boolean, +): AuditFinding | null { + // Strip fragment for resolution check + const bareTarget = target.split("#")[0]; + if (!bareTarget) return null; // pure anchor link + + if (bareTarget.startsWith("klappy://")) { + if (!uriResolves(bareTarget)) { + return { + rule_id: "dead-reference", + severity: "error", + location: { path: filePath, line }, + occurrence: target, + message: "URI does not resolve", + }; + } + return null; + } + + if (isWriting) { + if (bareTarget.startsWith("/page/")) { + return { + rule_id: "legacy-link-pattern", + severity: "error", + location: { path: filePath, line }, + occurrence: target, + message: "Use a klappy:// URI instead of /page/ path", + }; + } + if (bareTarget.startsWith("./") && bareTarget.endsWith(".md")) { + return { + rule_id: "legacy-link-pattern", + severity: "error", + location: { path: filePath, line }, + occurrence: target, + message: "Use a klappy:// URI instead of relative .md path", + }; + } + } + + // Out of scope: external URLs, mailto, anchors-only, valid non-klappy paths + // outside writings, etc. + return null; +} + async function runCleanupStorage( fetcher: KnowledgeBaseFetcher, knowledgeBaseUrl?: string, @@ -2935,6 +3242,7 @@ const VALID_ACTIONS = [ "search", "get", "resolve", + "audit", "catalog", "validate", "preflight", @@ -2983,6 +3291,9 @@ export async function handleUnifiedAction(params: UnifiedParams): Promise