diff --git a/CHANGELOG.md b/CHANGELOG.md index 7468502..3dead8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.25.0] - 2026-04-26 + +### Added + +- **`oddkit_resolve` MCP action — protocol-level URI resolution with transparent supersession (PR #TBD).** Per `klappy://docs/oddkit/specs/oddkit-resolve` (DRAFT v4 — KISS) and `klappy://canon/principles/identity-resolved-by-protocol`. New action takes a `klappy://` URI as `input`, walks any `superseded_by` chain in frontmatter to terminus, and returns `{ status, resolved: { uri, path, title, url, content_hash }, supersession_chain }`. Status enum is `FOUND | NOT_FOUND | INVALID_INPUT | CIRCULAR_SUPERSESSION`. Wired into the unified `oddkit` router (`action: "resolve"`) and exposed as a standalone `oddkit_resolve` tool. Backward-compatible — purely additive net-new action; existing callers unchanged. Reads only the existing index (`fetcher.getIndex`), no new caches, no new frontmatter fields. Phase 2 of the link-rot-elimination campaign per `klappy://docs/planning/link-rot-elimination-campaign`. + +### Fixed + +- **`oddkit_resolve` supersession walk normalizes mixed-shape `superseded_by` references.** Initial implementation looked up `superseded_by` values strictly as URIs, which failed for the canon authoring convention of using paths (e.g. `superseded_by: "canon/definitions/dolcheo-vocabulary.md"`). Resolver now tolerates three shapes — full `klappy://` URI, repo-relative path with `.md`, repo-relative path without — and resolves each to the canonical entry. Cycle-detection set keys on the canonical URI so mixed shapes within one chain still dedupe correctly. Found by independent Sonnet 4.6 validator dispatched per E0008.3 / `klappy://canon/constraints/release-validation-gate`. Validator-found bug was a real production blocker — direct evidence that the release-validation-gate constraint earns its keep. Regression test added to `tests/cloudflare-production.test.sh` against the real canon URI that surfaced the bug. +- **`oddkit_resolve` action registration in `VALID_ACTIONS`, field-name consistency on `supersession_chain`, and state-threading parity with peer actions** (Cursor Agent fix on PR #140). Initial implementation registered the new action in the dispatch switch but not in the `VALID_ACTIONS` validation list, returned `chain` instead of `supersession_chain` in two error branches, and synthesized empty state when none was passed in. All three corrected to match the conventions of every other action surface. + +### Notes + +- **Vodka discipline applied.** v3 of the spec proposed a richer surface (batch action, `resolve_links` flag, `aliases` field, `supersession_response` field, identity-by-meaning queries, build-time companion). v4 cut to the minimum: one input, one job — URI in, current canonical answer out. Cuts captured with explicit revisit triggers in `klappy://docs/planning/link-rot-deferred-concerns`. When real consumers demonstrate pain, each deferred item graduates as its own thin extension. +- **No `PARTIAL_INDEX` status in v1.** The spec named it as mandatory partial-data compliance; the existing `oddkit_get` and `oddkit_search` actions don't expose this either, treating index reads as synchronous. Resolve matches the existing convention rather than introducing a divergent shape ahead of an observed cold-start problem. If real cold-start visibility becomes load-bearing, follow-up. +- **No load-bearing surface change to existing actions.** `runResolve` is a new function; the dispatch switch gains one case; the tool list gains one entry; the unified router enum gains one value. Zero behavior change to `orient`, `challenge`, `gate`, `encode`, `search`, `get`, `catalog`, `validate`, `preflight`, `version`, `cleanup_storage`. + +### Refs + +- Spec: `klappy://docs/oddkit/specs/oddkit-resolve` (DRAFT v4) +- Principle: `klappy://canon/principles/identity-resolved-by-protocol` +- Campaign: `klappy://docs/planning/link-rot-elimination-campaign` +- Deferred concerns: `klappy://docs/planning/link-rot-deferred-concerns` +- Canon basis: `klappy://canon/constraints/release-validation-gate`, `klappy://canon/principles/vodka-architecture`, `klappy://canon/methods/supersession` +- Canon PR (klappy.dev): #142 + ## [0.24.0] - 2026-04-23 ### Added diff --git a/package-lock.json b/package-lock.json index 91cdcd5..54ac553 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oddkit", - "version": "0.24.0", + "version": "0.25.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oddkit", - "version": "0.24.0", + "version": "0.25.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", diff --git a/package.json b/package.json index 17d1946..54f98bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oddkit", - "version": "0.24.0", + "version": "0.25.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 03083bc..c8d4d91 100755 --- a/tests/cloudflare-production.test.sh +++ b/tests/cloudflare-production.test.sh @@ -405,6 +405,95 @@ RAW=$(curl -sf --max-time 30 "$WORKER_URL/mcp" -X POST \ RESULT=$(extract_json "$RAW") check_json "oddkit_catalog" "$RESULT" "assert 'content' in d.get('result',{}), 'no content'" +# Test 14g: oddkit_resolve — direct hit (FOUND, no supersession) +# Per klappy://docs/oddkit/specs/oddkit-resolve. Walks superseded_by chains +# transparently. URI input only in v1 (no meaning queries — see deferred ledger). +echo "" +echo "Test 14g: tools/call oddkit_resolve (direct hit)" +RAW=$(curl -sf --max-time 30 "$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_resolve","arguments":{"input":"klappy://canon/values/orientation"}}}') +RESULT=$(extract_json "$RAW") +check_json "oddkit_resolve direct hit" "$RESULT" "assert 'content' in d.get('result',{}), 'no content'" +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',{}); assert r.get('status')=='FOUND', f'status={r.get(\"status\")}'; assert r.get('resolved',{}).get('uri'), 'missing resolved.uri'" 2>/dev/null; then + echo "PASS - resolve direct hit returns FOUND with resolved.uri" + PASSED=$((PASSED + 1)) +else + echo "FAIL - resolve direct hit shape unexpected" + echo " Inner: $(echo "$INNER" | head -c 400)" + FAILED=$((FAILED + 1)) +fi + +# Test 14g.2: oddkit_resolve — supersession walk (FOUND with non-empty chain) +# Real case from canon: klappy://docs/oddkit/proactive/dolche-vocabulary has +# superseded_by="canon/definitions/dolcheo-vocabulary.md" (a path, not a URI). +# Resolver must normalize and walk to klappy://canon/definitions/dolcheo-vocabulary. +# This regression test guards against the bug found by the PR #140 validator. +echo "" +echo "Test 14g.2: tools/call oddkit_resolve (supersession walk)" +RAW=$(curl -sf --max-time 30 "$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_resolve","arguments":{"input":"klappy://docs/oddkit/proactive/dolche-vocabulary"}}}') +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', {}) +assert r.get('status') == 'FOUND', f'status={r.get(\"status\")}' +resolved_uri = r.get('resolved', {}).get('uri', '') +assert resolved_uri == 'klappy://canon/definitions/dolcheo-vocabulary', f'resolved.uri={resolved_uri}' +chain = r.get('supersession_chain', []) +assert len(chain) >= 1, f'expected non-empty chain, got {chain}' +assert not r.get('warning'), f'unexpected warning: {r.get(\"warning\")}' +" 2>/dev/null; then + echo "PASS - resolve walks superseded_by chain to terminus across path/URI normalization" + PASSED=$((PASSED + 1)) +else + echo "FAIL - resolve supersession walk did not reach terminus" + echo " Inner: $(echo "$INNER" | head -c 600)" + FAILED=$((FAILED + 1)) +fi + +# Test 14h: oddkit_resolve — NOT_FOUND +echo "" +echo "Test 14h: tools/call oddkit_resolve (NOT_FOUND)" +RAW=$(curl -sf --max-time 30 "$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_resolve","arguments":{"input":"klappy://writings/this-uri-does-not-exist-anywhere-9f8a7b6c"}}}') +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',{}); assert r.get('status')=='NOT_FOUND', f'status={r.get(\"status\")}'" 2>/dev/null; then + echo "PASS - resolve NOT_FOUND for unknown URI" + PASSED=$((PASSED + 1)) +else + echo "FAIL - resolve NOT_FOUND not produced" + echo " Inner: $(echo "$INNER" | head -c 400)" + FAILED=$((FAILED + 1)) +fi + +# Test 14i: oddkit_resolve — INVALID_INPUT for non-klappy:// scheme +echo "" +echo "Test 14i: tools/call oddkit_resolve (INVALID_INPUT)" +RAW=$(curl -sf --max-time 30 "$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_resolve","arguments":{"input":"https://example.com/foo"}}}') +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',{}); assert r.get('status')=='INVALID_INPUT', f'status={r.get(\"status\")}'" 2>/dev/null; then + echo "PASS - resolve INVALID_INPUT for non-klappy:// URI" + PASSED=$((PASSED + 1)) +else + echo "FAIL - resolve INVALID_INPUT not produced" + echo " Inner: $(echo "$INNER" | head -c 400)" + FAILED=$((FAILED + 1)) +fi + # ============================================ # SECTION 4: Response Content Validation # ============================================ diff --git a/workers/package-lock.json b/workers/package-lock.json index 1c3e76a..8ab0124 100644 --- a/workers/package-lock.json +++ b/workers/package-lock.json @@ -1,12 +1,12 @@ { "name": "oddkit-mcp-worker", - "version": "0.24.0", + "version": "0.25.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oddkit-mcp-worker", - "version": "0.24.0", + "version": "0.25.0", "dependencies": { "agents": "^0.4.1", "fflate": "^0.8.2", diff --git a/workers/package.json b/workers/package.json index f2e7021..ad3cb77 100644 --- a/workers/package.json +++ b/workers/package.json @@ -1,6 +1,6 @@ { "name": "oddkit-mcp-worker", - "version": "0.24.0", + "version": "0.25.0", "private": true, "type": "module", "scripts": { diff --git a/workers/src/index.ts b/workers/src/index.ts index c247301..a8db22c 100644 --- a/workers/src/index.ts +++ b/workers/src/index.ts @@ -193,12 +193,13 @@ 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, catalog, validate, preflight, version, or cleanup_storage actions. + `Epistemic guide for Outcomes-Driven Development. Routes to orient, challenge, gate, encode, search, get, resolve, 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 - Pressure-testing claims: action="challenge" - Checking transition readiness: action="gate" - Recording decisions: action="encode" @@ -207,7 +208,7 @@ Use when: - Listing available docs: action="catalog"`, { action: z.enum([ - "orient", "challenge", "gate", "encode", "search", "get", + "orient", "challenge", "gate", "encode", "search", "get", "resolve", "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."), @@ -335,6 +336,16 @@ Use when: }, annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, }, + { + name: "oddkit_resolve", + description: "Resolve a klappy:// URI to its current canonical answer, walking superseded_by chains transparently. Returns resolved URI + path + url + supersession_chain. Use this to render links — never hardcode URLs in source. Per klappy://canon/principles/identity-resolved-by-protocol.", + action: "resolve", + schema: { + input: z.string().describe("Canonical URI to resolve (e.g., klappy://writings/some-slug)."), + 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.", diff --git a/workers/src/orchestrate.ts b/workers/src/orchestrate.ts index 716cae6..78515d6 100644 --- a/workers/src/orchestrate.ts +++ b/workers/src/orchestrate.ts @@ -1575,6 +1575,206 @@ function runVersion(env: Env): ActionResult { }; } +// ────────────────────────────────────────────────────────────────────────────── +// runResolve — protocol-level URI resolution with transparent supersession. +// +// Per klappy://docs/oddkit/specs/oddkit-resolve (DRAFT v4 — KISS) and +// klappy://canon/principles/identity-resolved-by-protocol: consumers pass +// in a klappy:// URI; the protocol returns the current canonical answer, +// walking superseded_by chains to terminus. Backward-compatible (net-new). +// ────────────────────────────────────────────────────────────────────────────── + +async function runResolve( + input: string, + fetcher: KnowledgeBaseFetcher, + knowledgeBaseUrl?: string, + state?: OddkitState, +): Promise { + const startMs = Date.now(); + const updatedState = state ? initState(state) : undefined; + + if (!input || typeof input !== "string" || !input.startsWith("klappy://")) { + return { + action: "resolve", + result: { + status: "INVALID_INPUT", + error: "input must be a klappy:// URI", + }, + state: updatedState, + assistant_text: `Invalid input: \`${input}\`. Expected a klappy:// URI.`, + debug: { duration_ms: Date.now() - startMs, generated_at: new Date().toISOString() }, + }; + } + + const index = await fetcher.getIndex(knowledgeBaseUrl); + + // Build a URI → entry lookup once. Index entries already carry full frontmatter + // per klappy://docs/oddkit/IMPL-catalog-recent. + 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); + } + + // Resolve a `superseded_by` value to an index entry. Canon authors use + // multiple shapes for this field across the corpus: + // - full klappy:// URI: klappy://canon/x/y + // - repo-relative path with .md: canon/x/y.md + // - repo-relative path without: canon/x/y + // The resolver normalizes — pushing this work to consumers would repeat + // the link-rot anti-pattern we're solving. If a real case emerges where + // an author needs to point at a non-existent target on purpose, that's + // what the warning + chain-truncation already handles. + function lookupSuccessor(ref: string): { entry: IndexEntry | undefined; canonicalUri: string } { + const direct = byUri.get(ref); + if (direct) return { entry: direct, canonicalUri: direct.uri }; + + const pathExact = byPath.get(ref); + if (pathExact) return { entry: pathExact, canonicalUri: pathExact.uri }; + + if (!ref.startsWith("klappy://") && !ref.endsWith(".md")) { + const pathWithExt = byPath.get(ref + ".md"); + if (pathWithExt) return { entry: pathWithExt, canonicalUri: pathWithExt.uri }; + } + + if (!ref.startsWith("klappy://")) { + const stem = ref.endsWith(".md") ? ref.slice(0, -".md".length) : ref; + const asUri = "klappy://" + stem; + const viaUri = byUri.get(asUri); + if (viaUri) return { entry: viaUri, canonicalUri: viaUri.uri }; + } + + return { entry: undefined, canonicalUri: ref }; + } + + const startEntry = byUri.get(input); + if (!startEntry) { + return { + action: "resolve", + result: { + status: "NOT_FOUND", + input_uri: input, + }, + state: updatedState, + assistant_text: `URI not found in index: \`${input}\`.`, + debug: { duration_ms: Date.now() - startMs, generated_at: new Date().toISOString() }, + }; + } + + // Walk superseded_by chain to terminus. + // Cap traversal depth as a safety net against malformed canon (cycles or absurd chains). + // Cycles produce CIRCULAR_SUPERSESSION; depth-cap produces the same with a different reason. + const MAX_DEPTH = 16; + const chain: Array<{ uri: string; superseded_at?: string }> = []; + const visited = new Set([startEntry.uri]); + + let current: IndexEntry = startEntry; + for (let depth = 0; depth < MAX_DEPTH; depth++) { + const fm = current.frontmatter || {}; + const next = fm.superseded_by; + if (typeof next !== "string" || next.length === 0) break; + + const supersededAt = typeof fm.superseded_at === "string" ? fm.superseded_at : undefined; + chain.push({ uri: current.uri, ...(supersededAt ? { superseded_at: supersededAt } : {}) }); + + const { entry: nextEntry, canonicalUri: nextUri } = lookupSuccessor(next); + + if (visited.has(nextUri)) { + return { + action: "resolve", + result: { + status: "CIRCULAR_SUPERSESSION", + input_uri: input, + supersession_chain: [...chain, { uri: nextUri }], + message: "superseded_by chain cycles", + }, + state: updatedState, + assistant_text: `Circular supersession detected starting from \`${input}\`. This is a canon data error.`, + debug: { duration_ms: Date.now() - startMs, generated_at: new Date().toISOString() }, + }; + } + + if (!nextEntry) { + // Chain points at a successor that doesn't exist in any shape we recognize. + // Resolve to the last known entry and surface the dangling reference. + return { + action: "resolve", + result: { + status: "FOUND", + input_uri: input, + resolved: { + uri: current.uri, + path: current.path, + title: current.title, + url: deriveUrl(current.uri), + content_hash: current.content_hash, + }, + supersession_chain: chain.slice(0, -1), + warning: `superseded_by points at \`${next}\` which is not in the index; chain truncated`, + }, + state: state ? addCanonRefs(initState(state), [current.path]) : undefined, + assistant_text: `Resolved \`${input}\` to \`${current.uri}\` (chain truncated at unknown successor \`${next}\`).`, + debug: { duration_ms: Date.now() - startMs, generated_at: new Date().toISOString() }, + }; + } + + visited.add(nextUri); + current = nextEntry; + } + + // If we exited the loop on the depth cap with current still pointing at a doc that + // declares a further successor, treat that as circular for safety. + const finalFm = current.frontmatter || {}; + if (typeof finalFm.superseded_by === "string" && finalFm.superseded_by.length > 0) { + return { + action: "resolve", + result: { + status: "CIRCULAR_SUPERSESSION", + input_uri: input, + supersession_chain: chain, + message: `chain exceeded MAX_DEPTH=${MAX_DEPTH}`, + }, + state: updatedState, + assistant_text: `Supersession chain too deep starting from \`${input}\` (>${MAX_DEPTH}). Treating as canon data error.`, + debug: { duration_ms: Date.now() - startMs, generated_at: new Date().toISOString() }, + }; + } + + return { + action: "resolve", + result: { + status: "FOUND", + input_uri: input, + resolved: { + uri: current.uri, + path: current.path, + title: current.title, + url: deriveUrl(current.uri), + content_hash: current.content_hash, + }, + supersession_chain: chain, + }, + state: state ? addCanonRefs(initState(state), [current.path]) : undefined, + assistant_text: + chain.length === 0 + ? `Resolved \`${input}\` (no supersession).` + : `Resolved \`${input}\` → \`${current.uri}\` via ${chain.length} supersession step${chain.length === 1 ? "" : "s"}.`, + debug: { duration_ms: Date.now() - startMs, generated_at: new Date().toISOString() }, + }; +} + +/** + * Derive a public-friendly URL from a klappy:// URI. + * v1 mapping: klappy://writings/foo → /writings/foo, klappy://canon/x → /canon/x, etc. + * Pure derivation — no I/O. Consumers that need a different URL shape can override + * by reading the resolved.uri/path themselves. + */ +function deriveUrl(uri: string): string { + if (!uri.startsWith("klappy://")) return uri; + return "/" + uri.slice("klappy://".length); +} + async function runCleanupStorage( fetcher: KnowledgeBaseFetcher, knowledgeBaseUrl?: string, @@ -2734,6 +2934,7 @@ const VALID_ACTIONS = [ "encode", "search", "get", + "resolve", "catalog", "validate", "preflight", @@ -2779,6 +2980,9 @@ export async function handleUnifiedAction(params: UnifiedParams): Promise