From a1af8782845074ce72d2cc08909579697ddaf820 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 01:21:16 +0000 Subject: [PATCH 1/5] 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. --- workers/src/orchestrate.ts | 299 +++++++++++++++++++++++++++---------- 1 file changed, 220 insertions(+), 79 deletions(-) diff --git a/workers/src/orchestrate.ts b/workers/src/orchestrate.ts index ed8ac74..776c64a 100644 --- a/workers/src/orchestrate.ts +++ b/workers/src/orchestrate.ts @@ -55,6 +55,25 @@ 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; + export interface UnifiedParams { action: string; input: string; @@ -253,16 +272,165 @@ 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) 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 DOLCHE 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; + return types; +} + +function detectEncodeTypeFromGovernance(input: string, types: EncodingTypeDef[]): string { + for (const t of types) { + if (t.triggerRegex && t.triggerRegex.test(input)) return t.letter; + } + return "D"; +} + +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) { + 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(" ") + "..."; + artifacts.push({ type: "D", typeName: "Decision", fields: ["D", 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 - 1 ? "strong" : score >= Math.ceil(mx * 0.6) ? "adequate" : score >= Math.ceil(mx * 0.4) ? "weak" : "insufficient"; + return { score, maxScore: mx, level, gaps, suggestions }; } // ────────────────────────────────────────────────────────────────────────────── @@ -1246,93 +1414,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(input); + const artifacts = structured + ? parseStructuredInput(input, 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 c5d5add8876d0d800e2919c5964175815b398128 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 01:29:02 +0000 Subject: [PATCH 2/5] 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) --- workers/src/orchestrate.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/workers/src/orchestrate.ts b/workers/src/orchestrate.ts index 776c64a..27801d0 100644 --- a/workers/src/orchestrate.ts +++ b/workers/src/orchestrate.ts @@ -73,6 +73,7 @@ interface ParsedArtifact { } let cachedEncodingTypes: EncodingTypeDef[] | null = null; +let cachedEncodingTypesCanonUrl: string | undefined = undefined; export interface UnifiedParams { action: string; @@ -277,7 +278,7 @@ async function discoverEncodingTypes( fetcher: ZipBaselineFetcher, canonUrl?: string, ): Promise { - if (cachedEncodingTypes) return cachedEncodingTypes; + if (cachedEncodingTypes && cachedEncodingTypesCanonUrl === canonUrl) return cachedEncodingTypes; const index = await fetcher.getIndex(canonUrl); const typeArticles = index.entries.filter( @@ -332,7 +333,7 @@ async function discoverEncodingTypes( } if (types.length === 0) { - // Fallback DOLCHE defaults when no governance docs in canon + // 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"]], @@ -350,16 +351,10 @@ async function discoverEncodingTypes( } cachedEncodingTypes = types; + cachedEncodingTypesCanonUrl = canonUrl; return types; } -function detectEncodeTypeFromGovernance(input: string, types: EncodingTypeDef[]): string { - for (const t of types) { - if (t.triggerRegex && t.triggerRegex.test(input)) return t.letter; - } - return "D"; -} - 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)); @@ -388,6 +383,7 @@ function parseUnstructuredInput(input: string, types: EncodingTypeDef[]): Parsed 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; + break; } } if (!matched) { @@ -429,7 +425,7 @@ function scoreArtifactQuality( } const mx = criteria.length; - const level = score >= mx - 1 ? "strong" : score >= Math.ceil(mx * 0.6) ? "adequate" : score >= Math.ceil(mx * 0.4) ? "weak" : "insufficient"; + 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 }; } From 2dd57463d88c7d00bbf7dd008ff577bb292c1ce6 Mon Sep 17 00:00:00 2001 From: "Claude (oddkit project)" Date: Thu, 16 Apr 2026 01:39:42 +0000 Subject: [PATCH 3/5] 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. --- package-lock.json | 4 ++-- workers/src/orchestrate.ts | 5 ++++- 2 files changed, 6 insertions(+), 3 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 27801d0..06fc0b0 100644 --- a/workers/src/orchestrate.ts +++ b/workers/src/orchestrate.ts @@ -378,12 +378,15 @@ function parseUnstructuredInput(input: string, types: EncodingTypeDef[]): Parsed 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; - break; } } if (!matched) { From 121dd07f1a81807432555e10b232c3b2df6e4117 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 01:39:44 +0000 Subject: [PATCH 4/5] 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 --- workers/src/orchestrate.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/workers/src/orchestrate.ts b/workers/src/orchestrate.ts index 06fc0b0..edde6a2 100644 --- a/workers/src/orchestrate.ts +++ b/workers/src/orchestrate.ts @@ -392,7 +392,8 @@ function parseUnstructuredInput(input: string, types: EncodingTypeDef[]): Parsed 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(" ") + "..."; - artifacts.push({ type: "D", typeName: "Decision", fields: ["D", title, para.trim()], title, body: para.trim() }); + 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; @@ -730,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", @@ -1412,13 +1415,12 @@ async function runEncodeAction( state?: OddkitState, ): Promise { const startMs = Date.now(); - const fullInput = context ? `${input}\n${context}` : input; const types = await discoverEncodingTypes(fetcher, canonUrl); const structured = isStructuredInput(input); const artifacts = structured ? parseStructuredInput(input, types) - : parseUnstructuredInput(fullInput, types); + : parseUnstructuredInput(input, types); // Score each artifact using its type's quality criteria const scoredArtifacts = artifacts.map((a) => { From e7a80b4c141b1430d0d0424df79713a6f57699b4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 16 Apr 2026 01:48:42 +0000 Subject: [PATCH 5/5] 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. --- workers/src/orchestrate.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/workers/src/orchestrate.ts b/workers/src/orchestrate.ts index edde6a2..783c604 100644 --- a/workers/src/orchestrate.ts +++ b/workers/src/orchestrate.ts @@ -1415,12 +1415,13 @@ async function runEncodeAction( state?: OddkitState, ): Promise { const startMs = Date.now(); + const fullInput = context ? `${input}\n${context}` : input; const types = await discoverEncodingTypes(fetcher, canonUrl); - const structured = isStructuredInput(input); + const structured = isStructuredInput(fullInput); const artifacts = structured - ? parseStructuredInput(input, types) - : parseUnstructuredInput(input, types); + ? parseStructuredInput(fullInput, types) + : parseUnstructuredInput(fullInput, types); // Score each artifact using its type's quality criteria const scoredArtifacts = artifacts.map((a) => {