From bef6c83a7a87d970666b5e6e15e64e4da4551a6f Mon Sep 17 00:00:00 2001 From: Chris Klapp Date: Wed, 15 Apr 2026 22:14:13 -0400 Subject: [PATCH 1/2] feat: governance-driven encode architecture (#96) * feat: governance-driven encode architecture E0008: Replace hardcoded detectEncodeType with governance-driven encoding. Server discovers encoding-type docs from canon via tag search, extracts field schemas/trigger words/quality criteria, parses structured (TSV) and unstructured input, scores per-type, teaches model via response. * fix: duplicate artifacts, stale cache, dead code, quality scoring in encode action - Add break after first type match in parseUnstructuredInput to prevent duplicate artifacts when a paragraph matches multiple encoding types - Key cachedEncodingTypes by canonUrl so different canon sources get separate cached encoding types within the same isolate - Remove unused detectEncodeTypeFromGovernance function (dead code) - Fix scoreArtifactQuality: require score >= mx (not mx-1) for strong so a 0/1 score no longer rates as strong - Fix misleading DOLCHE comment to OLDC+H (matches actual 5-type system) * fix: restore multi-type paragraph matching (intentional design) Reverts bugbot's break; addition in parseUnstructuredInput. Multiple type matches per paragraph is an intentional design decision: a paragraph can be both Decision and Constraint simultaneously. This mirrors what the model would do with separate TSV rows. Added inline comment to prevent automated regression. * Fix three governance-driven encode bugs: fallback type, context artifacts, cache cleanup - Use first discovered governance type as fallback instead of hardcoded D/Decision for unmatched paragraphs in parseUnstructuredInput - Pass only input (not fullInput) to parseUnstructuredInput so context paragraphs are not encoded as standalone artifacts - Clear cachedEncodingTypes and cachedEncodingTypesCanonUrl in runCleanupStorage so governance doc updates take effect without worker isolate recycle * fix: merge context into fullInput in runEncodeAction to match runGateAction pattern The context parameter was accepted but never used in runEncodeAction, silently discarding supplementary context provided by callers. This restores the fullInput merging pattern used by runGateAction so that context is included in type detection, artifact parsing, and quality scoring. --------- Co-authored-by: Claude Co-authored-by: Cursor Agent Co-authored-by: Claude (oddkit project) --- package-lock.json | 4 +- workers/src/orchestrate.ts | 301 +++++++++++++++++++++++++++---------- 2 files changed, 224 insertions(+), 81 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1bd87bd..7b3cf09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oddkit", - "version": "0.15.0", + "version": "0.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oddkit", - "version": "0.15.0", + "version": "0.16.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.0.0", diff --git a/workers/src/orchestrate.ts b/workers/src/orchestrate.ts index ed8ac74..783c604 100644 --- a/workers/src/orchestrate.ts +++ b/workers/src/orchestrate.ts @@ -55,6 +55,26 @@ export interface OddkitEnvelope { /** Internal type — handlers return this, handleUnifiedAction stamps server_time */ type ActionResult = Omit; +// Governance-driven encoding types +interface EncodingTypeDef { + letter: string; + name: string; + triggerWords: string[]; + triggerRegex: RegExp | null; + qualityCriteria: Array<{ criterion: string; check: string; gapMessage: string }>; +} + +interface ParsedArtifact { + type: string; + typeName: string; + fields: string[]; + title: string; + body: string; +} + +let cachedEncodingTypes: EncodingTypeDef[] | null = null; +let cachedEncodingTypesCanonUrl: string | undefined = undefined; + export interface UnifiedParams { action: string; input: string; @@ -253,16 +273,164 @@ function detectTransition(input: string): { from: string; to: string } { return { from: "unknown", to: "unknown" }; } -function detectEncodeType(input: string): string { - if (/\b(decided|decision|chose|choosing|selected|committed to|going with)\b/i.test(input)) - return "decision"; - if (/\b(learned|insight|realized|discovered|found that|turns out)\b/i.test(input)) - return "insight"; - if (/\b(boundary|limit|constraint|rule|prohibition|must not|never)\b/i.test(input)) - return "boundary"; - if (/\b(override|exception|despite|even though|notwithstanding)\b/i.test(input)) - return "override"; - return "decision"; +// Discover encoding types from canon governance docs +async function discoverEncodingTypes( + fetcher: ZipBaselineFetcher, + canonUrl?: string, +): Promise { + if (cachedEncodingTypes && cachedEncodingTypesCanonUrl === canonUrl) return cachedEncodingTypes; + + const index = await fetcher.getIndex(canonUrl); + const typeArticles = index.entries.filter( + (entry: IndexEntry) => entry.tags?.includes("encoding-type") && entry.path.includes("encoding-types/"), + ); + + const types: EncodingTypeDef[] = []; + for (const article of typeArticles) { + try { + const content = await fetcher.getFile(article.path, canonUrl); + if (!content) continue; + + const identityMatch = content.match(/\|\s*Letter\s*\|\s*([A-Z])\s*\|/); + const nameMatch = content.match(/\|\s*Name\s*\|\s*([^|]+)\s*\|/); + if (!identityMatch) continue; + + const letter = identityMatch[1]; + const name = nameMatch ? nameMatch[1].trim() : letter; + + const triggerSection = content.match( + /## Trigger Words[^\n]*\n[\s\S]*?```\n([\s\S]*?)\n```/, + ); + const triggerWords = triggerSection + ? triggerSection[1].split(",").map((w: string) => w.trim()).filter((w: string) => w.length > 0) + : []; + const triggerRegex = + triggerWords.length > 0 + ? new RegExp("\\b(" + triggerWords.map((w: string) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|") + ")\\b", "i") + : null; + + const criteriaSection = content.match( + /## Quality Criteria[\s\S]*?\| Criterion[\s\S]*?\|[-|\s]+\|\n([\s\S]*?)(?=\n\n|\n##|$)/, + ); + const qualityCriteria: Array<{ criterion: string; check: string; gapMessage: string }> = []; + if (criteriaSection) { + for (const row of criteriaSection[1].split("\n").filter((r: string) => r.includes("|"))) { + const cols = row.split("|").map((c: string) => c.trim()).filter((c: string) => c.length > 0); + if (cols.length >= 3) { + qualityCriteria.push({ + criterion: cols[0], + check: cols[1], + gapMessage: cols[2].replace(/^"|"$/g, ""), + }); + } + } + } + + types.push({ letter, name, triggerWords, triggerRegex, qualityCriteria }); + } catch { + continue; + } + } + + if (types.length === 0) { + // Fallback OLDC+H defaults when no governance docs in canon + const defaults: Array<[string, string, string[]]> = [ + ["D", "Decision", ["decided", "decision", "chose", "committed to", "going with"]], + ["O", "Observation", ["observed", "noticed", "found", "measured", "detected"]], + ["L", "Learning", ["learned", "realized", "discovered", "turns out", "insight"]], + ["C", "Constraint", ["must", "must not", "never", "always", "constraint", "cannot"]], + ["H", "Handoff", ["next session", "next step", "todo", "follow up", "blocked by"]], + ]; + for (const [letter, name, words] of defaults) { + types.push({ + letter, name, triggerWords: words, + triggerRegex: new RegExp("\\b(" + words.join("|") + ")\\b", "i"), + qualityCriteria: [], + }); + } + } + + cachedEncodingTypes = types; + cachedEncodingTypesCanonUrl = canonUrl; + return types; +} + +function isStructuredInput(input: string): boolean { + const lines = input.split("\n").filter((l) => l.trim().length > 0); + return lines.length > 0 && lines.every((l) => /^[A-Z]\t/.test(l)); +} + +function parseStructuredInput(input: string, types: EncodingTypeDef[]): ParsedArtifact[] { + const typeMap = new Map(types.map((t) => [t.letter, t.name])); + return input.split("\n").filter((l) => l.trim().length > 0).map((line) => { + const fields = line.split("\t"); + const letter = fields[0]?.trim() || "D"; + return { + type: letter, typeName: typeMap.get(letter) || letter, + fields, title: fields[1]?.trim() || "", body: fields[2]?.trim() || "", + }; + }); +} + +function parseUnstructuredInput(input: string, types: EncodingTypeDef[]): ParsedArtifact[] { + const paragraphs = input.split(/\n\n+/).filter((p) => p.trim().length > 0); + const artifacts: ParsedArtifact[] = []; + for (const para of paragraphs) { + let matched = false; + for (const t of types) { + // DESIGN: no break — a paragraph can match multiple types intentionally. + // "We must never deploy without tests" is both Decision and Constraint. + // Multi-typing at the server level mirrors what the model would do with + // separate TSV rows. Do not add a break here. + if (t.triggerRegex && t.triggerRegex.test(para)) { + const first = para.split(/[.!?\n]/)[0]?.trim() || para.slice(0, 60); + const title = first.split(/\s+/).length <= 12 ? first : first.split(/\s+/).slice(0, 8).join(" ") + "..."; + artifacts.push({ type: t.letter, typeName: t.name, fields: [t.letter, title, para.trim()], title, body: para.trim() }); + matched = true; + } + } + if (!matched) { + const first = para.split(/[.!?\n]/)[0]?.trim() || para.slice(0, 60); + const title = first.split(/\s+/).length <= 12 ? first : first.split(/\s+/).slice(0, 8).join(" ") + "..."; + const fallback = types[0] || { letter: "D", name: "Decision" }; + artifacts.push({ type: fallback.letter, typeName: fallback.name, fields: [fallback.letter, title, para.trim()], title, body: para.trim() }); + } + } + return artifacts; +} + +function scoreArtifactQuality( + artifact: ParsedArtifact, + criteria: Array<{ criterion: string; check: string; gapMessage: string }>, +): { score: number; maxScore: number; level: string; gaps: string[]; suggestions: string[] } { + const gaps: string[] = []; + const suggestions: string[] = []; + let score = 0; + + if (criteria.length === 0) { + if (artifact.body.split(/\s+/).length >= 10) score++; + else suggestions.push("Expand — more detail improves quality"); + if (/because|due to|since/i.test(artifact.body)) score++; + else suggestions.push("Add rationale"); + return { score, maxScore: 2, level: score >= 2 ? "adequate" : "weak", gaps, suggestions }; + } + + for (const c of criteria) { + const ck = c.check.toLowerCase(); + let passed = false; + if (ck.includes("non-empty")) passed = artifact.fields.length > 3 || artifact.body.length > 0; + else if (ck.includes("10")) passed = artifact.body.split(/\s+/).length >= 10; + else if (ck.includes("number") || ck.includes("concrete")) passed = /\d/.test(artifact.body); + else if (ck.includes("interpretation") || ck.includes("does not contain")) passed = !/should|better|worse|means|implies/i.test(artifact.body); + else if (ck.includes("prohibition") || ck.includes("requirement")) passed = /must|must not|never|always|shall/i.test(artifact.body); + else passed = artifact.body.split(/\s+/).length >= 5; + if (passed) score++; + else { gaps.push(c.gapMessage); suggestions.push(c.gapMessage); } + } + + const mx = criteria.length; + const level = score >= mx ? "strong" : score >= Math.ceil(mx * 0.6) ? "adequate" : score >= Math.ceil(mx * 0.4) ? "weak" : "insufficient"; + return { score, maxScore: mx, level, gaps, suggestions }; } // ────────────────────────────────────────────────────────────────────────────── @@ -563,6 +731,8 @@ async function runCleanupStorage( // Also clear the in-memory BM25 index cachedBM25Index = null; cachedBM25Entries = null; + cachedEncodingTypes = null; + cachedEncodingTypesCanonUrl = undefined; return { action: "cleanup_storage", @@ -1246,93 +1416,66 @@ async function runEncodeAction( ): Promise { const startMs = Date.now(); const fullInput = context ? `${input}\n${context}` : input; - const encodeType = detectEncodeType(input); - - const firstSentence = input.split(/[.!?\n]/)[0]?.trim() || input.slice(0, 60); - const title = - firstSentence.split(/\s+/).length <= 12 - ? firstSentence - : firstSentence.split(/\s+/).slice(0, 8).join(" ") + "..."; - - let rationale: string | null = null; - const rMatch = - fullInput.match(/because\s+(.+?)(?:\.|$)/i) || fullInput.match(/due to\s+(.+?)(?:\.|$)/i); - if (rMatch && rMatch[1].split(/\s+/).length >= 3) rationale = rMatch[1].trim(); - - const constraints: string[] = []; - for (const s of fullInput.split(/[.!?\n]+/).filter((s) => s.trim().length > 5)) { - if (/\b(must|shall|required|always|never|constraint|cannot)\b/i.test(s)) - constraints.push(s.trim()); - } - let score = 0; - if (input.split(/\s+/).length >= 10) score++; - if (rationale) score++; - if (constraints.length > 0) score++; - if (/\b(alternative|instead|option|versus|vs|rather than)\b/i.test(fullInput)) score++; - if (/\b(irreversib|reversib|temporary|permanent|until)\b/i.test(fullInput)) score++; - const qualityLevel = - score >= 4 ? "strong" : score >= 3 ? "adequate" : score >= 2 ? "weak" : "insufficient"; - - const gaps: string[] = []; - const suggestions: string[] = []; - if (!rationale) { - gaps.push("No rationale detected — add 'because...'"); - suggestions.push("Add explicit rationale"); - } - if (constraints.length === 0) - suggestions.push("Add constraints: what boundaries does this create?"); - if (encodeType === "decision" && !/\b(alternative|instead)\b/i.test(fullInput)) - suggestions.push("Document alternatives considered"); - if (!/\b(irreversib|reversib|temporary|permanent)\b/i.test(fullInput)) - suggestions.push("Note whether this is reversible or permanent"); - - const artifact = { - title, - type: encodeType, - decision: input.trim(), - rationale: rationale || "(not provided — add 'because...' to strengthen)", - constraints, - status: qualityLevel === "strong" || qualityLevel === "adequate" ? "recorded" : "draft", - timestamp: new Date().toISOString(), - }; + const types = await discoverEncodingTypes(fetcher, canonUrl); + const structured = isStructuredInput(fullInput); + const artifacts = structured + ? parseStructuredInput(fullInput, types) + : parseUnstructuredInput(fullInput, types); + + // Score each artifact using its type's quality criteria + const scoredArtifacts = artifacts.map((a) => { + const typeDef = types.find((t) => t.letter === a.type); + const criteria = typeDef ? typeDef.qualityCriteria : []; + const quality = scoreArtifactQuality(a, criteria); + return { title: a.title, type: a.type, typeName: a.typeName, content: a.body, fields: a.fields, quality }; + }); - // Update state + // Update state — track all encoded type letters const updatedState = state ? initState(state) : undefined; if (updatedState) { - updatedState.decisions_encoded.push(title); + for (const a of artifacts) { + updatedState.decisions_encoded.push(`${a.type}:${a.title}`); + } } - const lines = [ - `Encoded ${encodeType}: ${title}`, - `Status: ${artifact.status} | Quality: ${qualityLevel} (${score}/5)`, + // Build assistant_text as markdown with per-artifact sections + const lines: string[] = [ + `## Encoded ${scoredArtifacts.length} artifact${scoredArtifacts.length !== 1 ? "s" : ""}`, "", ]; - lines.push(`Decision: ${input.trim()}`, `Rationale: ${artifact.rationale}`, ""); - if (constraints.length > 0) { - lines.push("Constraints:"); - for (const c of constraints) lines.push(` - ${c}`); + for (const a of scoredArtifacts) { + lines.push(`### [${a.type}] ${a.typeName}: ${a.title}`); + lines.push(`**Quality:** ${a.quality.level} (${a.quality.score}/${a.quality.maxScore})`); lines.push(""); - } - if (gaps.length > 0) { - lines.push("Gaps:"); - for (const g of gaps) lines.push(` - ${g}`); + lines.push(a.content); lines.push(""); + if (a.quality.gaps.length > 0) { + lines.push("**Gaps:**"); + for (const g of a.quality.gaps) lines.push(`- ${g}`); + lines.push(""); + } + if (a.quality.suggestions.length > 0) { + lines.push("**Suggestions:**"); + for (const s of a.quality.suggestions) lines.push(`- ${s}`); + lines.push(""); + } } - if (suggestions.length > 0) { - lines.push("Suggestions:"); - for (const s of suggestions) lines.push(` - ${s}`); - lines.push(""); + + lines.push("---"); + lines.push("**Encoding types (governance):**"); + for (const t of types) { + lines.push(`- **${t.letter}** — ${t.name}`); } return { action: "encode", result: { status: "ENCODED", - artifact, - quality: { level: qualityLevel, score, max_score: 5, gaps, suggestions }, + artifacts: scoredArtifacts, + governance: types.map((t) => ({ letter: t.letter, name: t.name })), persist_required: true, - next_action: "Save this artifact to the project's storage (project journal, file, database). Encode does NOT persist.", + next_action: "Save these artifacts to storage. Encode does NOT persist.", }, state: updatedState, assistant_text: lines.join("\n").trim(), From 4b95fbee94714347d2818732d81a3790bd2ab7ab Mon Sep 17 00:00:00 2001 From: Chris Klapp Date: Wed, 15 Apr 2026 22:32:59 -0400 Subject: [PATCH 2/2] feat: nudge unidentified consumers to add ?consumer=yourname (#95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add consumer identification nudge for unidentified consumers * fix: add privacy assurance to consumer nudge text Nudge now leads with what oddkit does NOT track (prompts, searches, responses) before asking the consumer to self-identify. * docs: add inline comment explaining no-cache design Reverts bugbot's session cache additions. The server is stateless by design — caching consumer identification across requests violates Vodka Architecture and doesn't even work reliably on Cloudflare Workers' distributed isolates. The query param is the stateless solution. Inline comment prevents future regression. --------- Co-authored-by: Klappy --- workers/src/index.ts | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/workers/src/index.ts b/workers/src/index.ts index 7e7f498..a473b1d 100644 --- a/workers/src/index.ts +++ b/workers/src/index.ts @@ -20,6 +20,7 @@ import { z } from "zod"; import { handleUnifiedAction, type Env } from "./orchestrate"; import { ZipBaselineFetcher } from "./zip-baseline-fetcher"; import { RequestTracer } from "./tracing"; +import { parseConsumerLabel } from "./telemetry"; import { renderNotFoundPage } from "./not-found-ui"; import pkg from "../package.json"; @@ -27,6 +28,26 @@ export type { Env }; const BUILD_VERSION = pkg.version; +// ────────────────────────────────────────────────────────────────────────────── +// Consumer identification nudge +// +// DO NOT add session caching, sticky identification, or per-session memory. +// This server is stateless by design (Vodka Architecture). Cloudflare Workers +// are globally distributed across isolates — module-level Maps don't survive +// across requests reliably. The query param (?consumer=yourname) is the +// stateless solution and works on every request, every isolate, every platform. +// +// If a consumer identifies via MCP clientInfo.name on initialize but not via +// query param, they WILL see this nudge on subsequent tools/call requests. +// That is correct behavior — the nudge tells them exactly how to stop seeing +// it. Caching would mask the problem instead of solving it. +// +// See: canon/principles/vodka-architecture, canon/constraints/telemetry-governance +// ────────────────────────────────────────────────────────────────────────────── + +const CONSUMER_NUDGE = + "Tip: oddkit tracks tool usage (which tools, how often) but never your prompts, searches, or responses. Add ?consumer=yourname to your oddkit URL to appear on the public transparency leaderboard. See telemetry_policy for details."; + // ────────────────────────────────────────────────────────────────────────────── // Types // ────────────────────────────────────────────────────────────────────────────── @@ -111,7 +132,7 @@ async function fetchPromptContent(env: Env, path: string): Promise { +async function createServer(env: Env, tracer?: RequestTracer, consumerSource?: string): Promise { const server = new McpServer( { name: "oddkit", @@ -179,6 +200,9 @@ Use when: env, tracer, }); + if ((consumerSource === "user-agent" || consumerSource === "unknown") && result.assistant_text) { + result.assistant_text += "\n\n" + CONSUMER_NUDGE; + } return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; }, ); @@ -332,6 +356,9 @@ Use when: env, tracer, }); + if ((consumerSource === "user-agent" || consumerSource === "unknown") && result.assistant_text) { + result.assistant_text += "\n\n" + CONSUMER_NUDGE; + } return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; }, ); @@ -800,7 +827,8 @@ export default { ? request.clone() : null; - const server = await createServer(env, tracer); + const { source: consumerSource } = parseConsumerLabel(request, {}); + const server = await createServer(env, tracer, consumerSource); const handler = createMcpHandler(server, { route: "/mcp", corsOptions: {