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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!-- audit-allow: <rule-id> reason="..." -->` 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
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.25.0",
"version": "0.26.0",
"description": "Agent-first CLI for ODD-governed repos. Epistemic terrain rendering with portable baseline.",
"type": "module",
"bin": {
Expand Down
58 changes: 58 additions & 0 deletions tests/cloudflare-production.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ============================================
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.25.0",
"version": "0.26.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
27 changes: 24 additions & 3 deletions workers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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."),
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -405,9 +416,19 @@ Use when:
tool.schema,
tool.annotations,
async (args: Record<string, unknown>) => {
// 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,
Expand Down
Loading
Loading