Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
89 changes: 89 additions & 0 deletions tests/cloudflare-production.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ============================================
Expand Down
4 changes: 2 additions & 2 deletions workers/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion workers/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "oddkit-mcp-worker",
"version": "0.24.0",
"version": "0.25.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
15 changes: 13 additions & 2 deletions workers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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."),
Expand Down Expand Up @@ -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.",
Expand Down
Loading
Loading