Conversation
…allback, governance_extended self-teaching surface (E0008.4 Phase 2) Implements Items 1-4 of klappy://odd/handoffs/2026-04-30-encode-vodka-refactor-alternative-d-revised. Item 5 (schema-driven check evaluator) deferred to 0.29.0 per the handoff's defer condition — it spans both repos (oddkit code + klappy.dev canon Quality Criteria table migrations) and the current check.includes interpretation works correctly for existing criteria. Item 1 — governance_uris plural array. Encode envelope now emits governance_uris (alphabetical, dynamic per request — every encoding-type article actually consulted plus klappy://odd/encoding-types/serialization-format and klappy://odd/encoding-types/how-to-write-encoding-types). Aligns shape with challenge (P1.3.1) and gate (P1.3.2). governance_uri (singular) retained as deprecation alias for one minor; removed in 0.29.0. Item 2 — (letter, facet) dedup. discoverEncodingTypes now parses an optional 'Facet' row from the Type Identity table the same way it parses Letter, dedupes by (letter, facet) pair, and the scorer in runEncodeAction looks up by pair. Pre-fix, alphabetical iteration of typeArticles kept observation.md (4 criteria) and silently dropped open.md (5 criteria); a live encode call with [O-open P1] body returned quality.score 4 / maxScore 4 — Observation's max, not Open's. Verified on prod 0.27.0 before this change. parsePrefixedBatchInput also resolves type by pair so Open's canon name surfaces directly (typeName 'Open', not 'Observation (Open)'). Item 3 — Open in inline fallback baseline. When canon is unreachable, the fallback now registers seven entries (D, O closed, O open, L, C, H, E) instead of six. Pre-fix, [O-open] tags fell through to the Observation handler under canon-unreachable conditions. Item 4 — governance_extended self-teaching surface (closes Gap 4 from the original architecture brief). New optional request param include_governance_details: when true, the response includes governance_extended.types[] with parsed fieldSchema, qualityCriteria, triggerWords, facet, and sourceUri per type, plus serializationFormatUri and howToWriteUri. Off by default to avoid token bloat for callers that already know the format. Field Schema parsed from the article's '## Field Schema' table in the same pass as Quality Criteria. EncodingTypeDef extended with optional facet, sourceUri, fieldSchema. UnifiedParams gains include_governance_details. Both unified oddkit and individual oddkit_encode tool schemas declare the new param. oddkit_encode tool description rewritten to describe the new envelope shape. Versions bumped to 0.28.0 in root package.json, workers/package.json, and lockfile. Validation: tsc --noEmit clean; existing workers/test/governance-parser.test.mjs (105 cases) and tokenize.test.mjs (7 cases) pass. Release validation gate: this PR awaits Cursor Bugbot completed AND independent Sonnet 4.6 read-only validator agent dispatch via Managed Agents per klappy://canon/constraints/release-validation-gate before promotion merge.
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
oddkit | 47fc7e0 | Commit Preview URL Branch Preview URL |
Apr 30 2026, 03:30 PM |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Open artifacts from trigger-word path lack facet, misscored
- Propagated
t.facet/pick.facetonto artifacts emitted by the trigger-word branch inparseUnstructuredInputand the untagged-paragraph fallback inparsePrefixedBatchInputso the (letter, facet) scoring lookup matches Open's criteria.
- Propagated
- ✅ Fixed: TSV parser Map collision renames all "O" to Open
- Replaced
parseStructuredInput's naive letter-keyed Map with a build that prefers the no-facet entry (closed Observation) over faceted peers, so bare TSV letters like "O" resolve back to Observation.
- Replaced
Preview (47fc7e0517)
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "oddkit",
- "version": "0.27.0",
+ "version": "0.28.0",
"description": "Agent-first CLI for ODD-governed repos. Epistemic terrain rendering with portable baseline.",
"type": "module",
"bin": {
diff --git a/workers/package-lock.json b/workers/package-lock.json
--- a/workers/package-lock.json
+++ b/workers/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "oddkit-mcp-worker",
- "version": "0.26.0",
+ "version": "0.28.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "oddkit-mcp-worker",
- "version": "0.26.0",
+ "version": "0.28.0",
"dependencies": {
"agents": "^0.4.1",
"fflate": "^0.8.2",
diff --git a/workers/package.json b/workers/package.json
--- a/workers/package.json
+++ b/workers/package.json
@@ -1,6 +1,6 @@
{
"name": "oddkit-mcp-worker",
- "version": "0.27.0",
+ "version": "0.28.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/workers/src/index.ts b/workers/src/index.ts
--- a/workers/src/index.ts
+++ b/workers/src/index.ts
@@ -228,6 +228,7 @@
limit: z.number().min(1).max(500).optional().describe("For action='catalog': max articles to return when sort_by is provided. Default: 10, max: 500."),
offset: z.number().min(0).optional().describe("For action='catalog': skip this many articles before returning results. Use with limit for pagination. Default: 0."),
filter_epoch: z.string().optional().describe("For action='catalog': filter to articles with this epoch value in frontmatter (e.g. 'E0007')."),
+ include_governance_details: z.boolean().optional().describe("For action='encode': when true, the response includes a `governance_extended` payload carrying the parsed Field Schema, Quality Criteria, trigger words, and facet for every discovered encoding type, plus URIs for the meta serialization-format and how-to-write articles. Off by default to avoid token bloat for callers that already know the format. Per E0008.4 Phase 2 / klappy://odd/handoffs/2026-04-30-encode-vodka-refactor-alternative-d-revised."),
state: z.record(z.string(), z.unknown()).optional().describe("Optional client-side conversation state, passed back and forth."),
},
{
@@ -251,6 +252,7 @@
limit: args.limit,
offset: args.offset,
filter_epoch: args.filter_epoch,
+ include_governance_details: args.include_governance_details,
state: args.state as any,
env,
tracer,
@@ -309,12 +311,13 @@
},
{
name: "oddkit_encode",
- description: "Structure decisions, insights, or boundaries as DOLCHEO artifacts (canon/definitions/dolcheo-vocabulary) — Decisions (D), Observations closed (O), Learnings (L), Constraints (C), Handoffs (H), Encodes (E), Opens (O-open, facet of O). IMPORTANT: does NOT persist — caller must save output to storage. Batch mode: paragraph-split input with optional prefix tags like '[D] body', '[O] body', '[O-open P1] body' returns a per-artifact array. Unprefixed input uses trigger-word classification (back-compat). Response envelope declares governance_source (knowledge_base|minimal) per canon/constraints/core-governance-baseline. Accepts knowledge_base_url to read the encoding-type vocabulary from an alternate knowledge base.",
+ description: "Structure decisions, insights, or boundaries as DOLCHEO artifacts (canon/definitions/dolcheo-vocabulary) — Decisions (D), Observations closed (O), Learnings (L), Constraints (C), Handoffs (H), Encodes (E), Opens (O-open, peer of Observation sharing letter O via facet='open'). IMPORTANT: does NOT persist — caller must save output to storage. Batch mode: paragraph-split input with optional prefix tags like '[D] body', '[O] body', '[O-open P1] body' returns a per-artifact array. Unprefixed input uses trigger-word classification (back-compat). Response envelope declares governance_source (knowledge_base|minimal) and governance_uris (plural array, alphabetical, dynamic per request — every encoding-type article actually consulted plus the meta serialization-format and how-to-write articles) per canon/constraints/core-governance-baseline; governance_uri (singular) is retained as a deprecation alias for one minor and removed in 0.29.0. Pass include_governance_details=true to receive a governance_extended payload with parsed Field Schema, Quality Criteria, trigger words, and facet per type — single-call self-teaching surface for the input format and scoring rubric. Accepts knowledge_base_url to read the encoding-type vocabulary from an alternate knowledge base.",
action: "encode",
schema: {
input: z.string().describe("A decision, insight, or boundary to capture."),
- context: z.string().optional().describe("Optional supporting context."),
+ context: z.string().optional().describe("Optional supporting context — informs quality scoring without becoming separate artifacts."),
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."),
+ include_governance_details: z.boolean().optional().describe("When true, the response includes a `governance_extended` payload with parsed Field Schema, Quality Criteria, trigger words, and facet for every discovered encoding type, plus URIs for the meta serialization-format and how-to-write articles. Off by default to avoid token bloat for callers that already know the format."),
},
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
},
@@ -449,6 +452,7 @@
limit: args.limit as number | undefined,
offset: args.offset as number | undefined,
filter_epoch: args.filter_epoch as string | undefined,
+ include_governance_details: args.include_governance_details as boolean | undefined,
env,
tracer,
});
diff --git a/workers/src/orchestrate.ts b/workers/src/orchestrate.ts
--- a/workers/src/orchestrate.ts
+++ b/workers/src/orchestrate.ts
@@ -71,10 +71,27 @@
// match triggers on every English paragraph.
interface EncodingTypeDef {
letter: string;
+ // DOLCHEO facet — distinguishes peer types that share a letter (e.g. closed
+ // Observation has no facet or facet="closed"; Open is letter "O" facet="open").
+ // Parsed from the Type Identity table's optional `Facet` row in the source
+ // article, not from frontmatter. Dedup is by (letter, facet) pair so both
+ // entries survive — the pre-#173 dedup-by-letter dropped Open's quality
+ // criteria silently. See E0008.4 Phase 2 handoff.
+ facet?: string;
name: string;
triggerWords: string[];
stemmedPhrases: string[][];
qualityCriteria: Array<{ criterion: string; check: string; gapMessage: string }>;
+ // Source article URI — populated when this type was discovered from canon
+ // (knowledge_base path) and used to build the encode action's
+ // governance_uris envelope. Absent on inline-fallback types.
+ sourceUri?: string;
+ // Recommended field schema parsed from the article's `## Field Schema`
+ // section (the table that begins with `| Field | Recommended | Description |`).
+ // Surfaced via the optional governance_extended payload (Item 4 / Gap 4 close)
+ // so the model can self-teach the input format from a single encode call
+ // without separate oddkit_get fetches per article.
+ fieldSchema?: Array<{ field: string; recommended: string; description: string }>;
}
interface ParsedArtifact {
@@ -249,6 +266,16 @@
limit?: number;
offset?: number;
filter_epoch?: string;
+ /**
+ * Optional self-teaching surface for action='encode' (E0008.4 Phase 2 Item 4).
+ * When true, the encode response includes a `governance_extended` payload
+ * carrying the parsed Field Schema, Quality Criteria, trigger words, and
+ * facet for every discovered encoding type, plus URIs for the meta
+ * serialization-format and how-to-write articles. Off by default to avoid
+ * token bloat for callers that already know the format. No-op for other
+ * actions. Authority: klappy://odd/handoffs/2026-04-30-encode-vodka-refactor-alternative-d-revised.
+ */
+ include_governance_details?: boolean;
state?: OddkitState;
env: Env;
tracer?: RequestTracer;
@@ -448,9 +475,17 @@
const identityMatch = content.match(/\|\s*Letter\s*\|\s*([A-Z])\s*\|/);
const nameMatch = content.match(/\|\s*Name\s*\|\s*([^|]+)\s*\|/);
+ // Facet is optional — peer types that share a letter use it to
+ // disambiguate (Open is letter "O" facet="open"; closed Observation
+ // has no facet row, treated as facet=undefined). Parsed from the same
+ // Type Identity table format as Letter/Name. Per E0008.4 Phase 2
+ // handoff Item 2 — fixes the dedup-by-letter bug that silently dropped
+ // Open's 5 quality criteria in favor of Observation's 4.
+ const facetMatch = content.match(/\|\s*Facet\s*\|\s*([^|]+)\s*\|/);
if (!identityMatch) continue;
const letter = identityMatch[1];
+ const facet = facetMatch ? facetMatch[1].trim() : undefined;
const name = nameMatch ? nameMatch[1].trim() : letter;
const triggerSection = content.match(
@@ -499,22 +534,62 @@
}
}
- types.push({ letter, name, triggerWords, stemmedPhrases, qualityCriteria });
+ // Field Schema for Item 4 (governance_extended self-teaching surface).
+ // The Field Schema section in each encoding-type article carries a
+ // table headed `| Field | Recommended | Description |` (the column the
+ // model populates when authoring a row of this type). Parsed once here
+ // so the runtime can surface it via the governance_extended payload
+ // when callers opt in via include_governance_details. Optional —
+ // articles without the section just leave fieldSchema undefined.
+ const fieldSchemaSection = content.match(
+ /## Field Schema[\s\S]*?\| Field \| Recommended \| Description \|[\s\S]*?\|[-|\s]+\|\n([\s\S]*?)(?=\n\n|\n##|$)/,
+ );
+ const fieldSchema: Array<{ field: string; recommended: string; description: string }> = [];
+ if (fieldSchemaSection) {
+ for (const row of fieldSchemaSection[1].split("\n").filter((r: string) => r.includes("|"))) {
+ const cols = parseTableRow(row);
+ if (cols.length >= 3) {
+ fieldSchema.push({
+ field: cols[0],
+ recommended: cols[1],
+ description: cols[2].replace(/^"|"$/g, ""),
+ });
+ }
+ }
+ }
+
+ const td: EncodingTypeDef = {
+ letter,
+ name,
+ triggerWords,
+ stemmedPhrases,
+ qualityCriteria,
+ sourceUri: article.uri,
+ };
+ if (facet) td.facet = facet;
+ if (fieldSchema.length > 0) td.fieldSchema = fieldSchema;
+ types.push(td);
} catch {
continue;
}
}
- // Deduplicate by letter: per DOLCHEO, both closed Observation and Open share
- // letter "O" (with Open distinguished by facet, not letter). If canon contains
- // multiple `encoding-type`-tagged docs with the same letter (e.g. observation.md
- // and open.md), keep the first one discovered — the letter registry is
- // single-character-per-entry.
+ // Deduplicate by (letter, facet) pair — peer types share a letter and are
+ // disambiguated by facet (closed Observation: letter "O", facet undefined;
+ // Open: letter "O", facet "open"). Pre-#173 dedup was by letter alone, so
+ // alphabetical-by-path ordering kept observation.md and silently dropped
+ // open.md — Open was registered in name only and scored against
+ // Observation's 4 criteria instead of its own 5. Verified live on prod
+ // 0.27.0: `[O-open P1] body` returned quality.score 4 / maxScore 4. The
+ // pair-keyed dedup keeps both entries; the scorer in runEncodeAction
+ // selects criteria by the artifact's parsed (type, facet) pair, matching
+ // the facet that parsePrefixedBatchInput already sets on each artifact.
const deduped: EncodingTypeDef[] = [];
const seen = new Set<string>();
for (const t of types) {
- if (seen.has(t.letter)) continue;
- seen.add(t.letter);
+ const key = `${t.letter}::${t.facet ?? ""}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
deduped.push(t);
}
@@ -524,28 +599,35 @@
resolved = deduped;
source = "knowledge_base";
} else {
- // Minimal DOLCHEO fallback — six letters per canon/definitions/dolcheo-vocabulary.
- // Open is a facet of O, not a separate letter; the prefix parser surfaces
- // it via the [O-open] tag. Upgraded from the pre-DOLCHEO 5-letter OLDC+H.
- 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"]],
- ["E", "Encode", ["encoded", "captured", "crystallized", "persisted", "artifact"]],
+ // Minimal DOLCHEO fallback — seven entries per canon/definitions/dolcheo-vocabulary
+ // and odd/encoding-types/open.md. Letter "O" appears twice (closed
+ // Observation + Open facet); the (letter, facet) dedup above keeps both.
+ // Pre-#173 fallback was six entries (D, O, L, C, H, E) and lost Open
+ // entirely when canon was unreachable — the prefix parser's [O-open] tag
+ // fell through to the Observation handler in that case. Per E0008.4
+ // Phase 2 Item 3.
+ const defaults: Array<[string, string | undefined, string, string[]]> = [
+ ["D", undefined, "Decision", ["decided", "decision", "chose", "committed to", "going with"]],
+ ["O", undefined, "Observation", ["observed", "noticed", "found", "measured", "detected"]],
+ ["O", "open", "Open", ["open item", "still need to", "haven't decided", "unresolved", "pending", "awaiting", "todo", "followup", "next up", "parked", "holding", "in flight"]],
+ ["L", undefined, "Learning", ["learned", "realized", "discovered", "turns out", "insight"]],
+ ["C", undefined, "Constraint", ["must", "must not", "never", "always", "constraint", "cannot"]],
+ ["H", undefined, "Handoff", ["next session", "next step", "todo", "follow up", "blocked by"]],
+ ["E", undefined, "Encode", ["encoded", "captured", "crystallized", "persisted", "artifact"]],
];
- resolved = defaults.map(([letter, name, words]) => {
+ resolved = defaults.map(([letter, facet, name, words]) => {
const stemmedPhrases: string[][] = [];
for (const word of words) {
const stems = tokenize(word, new Set());
if (stems.length > 0) stemmedPhrases.push(stems);
}
- return {
+ const td: EncodingTypeDef = {
letter, name, triggerWords: words,
stemmedPhrases,
qualityCriteria: [],
};
+ if (facet) td.facet = facet;
+ return td;
});
source = "minimal";
}
@@ -1148,7 +1230,16 @@
}
function parsePrefixedBatchInput(input: string, types: EncodingTypeDef[]): ParsedArtifact[] {
- const typeMap = new Map(types.map((t) => [t.letter, t.name]));
+ // Build a (letter, facet) lookup so peer types that share a letter (closed
+ // Observation vs Open) resolve to their own canon article. Pre-#173 the
+ // map was letter → name only, so [O-open] artifacts surfaced as
+ // "Observation (Open)" in typeName and were scored against Observation's
+ // criteria even after dedup-by-letter was fixed downstream. Per E0008.4
+ // Phase 2 Item 2.
+ const typeByPair = new Map<string, EncodingTypeDef>();
+ for (const t of types) {
+ typeByPair.set(`${t.letter}::${t.facet ?? ""}`, t);
+ }
const paragraphs = input.split(/\n\n+/).map((p) => p.trim()).filter((p) => p.length > 0);
const artifacts: ParsedArtifact[] = [];
@@ -1165,8 +1256,16 @@
const title = first.split(/\s+/).length <= 12
? first
: first.split(/\s+/).slice(0, 8).join(" ") + "...";
- const baseName = typeMap.get(letter) || letter;
- const typeName = facet === "open" ? `${baseName} (Open)` : baseName;
+ // Resolve by (letter, facet) pair first; fall back to letter-only when
+ // no facet entry exists (e.g. canon registers facet but the request
+ // uses bare [O], or vice versa). Keeps "Observation (Open)" framing
+ // out of the typeName when Open is a separately-registered type — its
+ // own name from canon now wins.
+ const matchedType =
+ typeByPair.get(`${letter}::${facet ?? ""}`) ??
+ typeByPair.get(`${letter}::`) ??
+ types.find((t) => t.letter === letter);
+ const typeName = matchedType?.name ?? letter;
const artifact: ParsedArtifact = {
type: letter,
typeName,
@@ -1197,13 +1296,15 @@
const title = first.split(/\s+/).length <= 12
? first
: first.split(/\s+/).slice(0, 8).join(" ") + "...";
- artifacts.push({
+ const untagged: ParsedArtifact = {
type: pick.letter,
typeName: pick.name,
fields: [pick.letter, title, para],
title,
body: para,
- });
+ };
+ if (pick.facet) untagged.facet = pick.facet;
+ artifacts.push(untagged);
}
}
@@ -1211,7 +1312,17 @@
}
function parseStructuredInput(input: string, types: EncodingTypeDef[]): ParsedArtifact[] {
- const typeMap = new Map(types.map((t) => [t.letter, t.name]));
+ // TSV has no facet column, so a bare letter must resolve to the no-facet
+ // peer (closed Observation for "O"). A naive letter→name Map collides when
+ // peers share a letter (Observation + Open both letter "O") — last-write
+ // wins under alphabetical canon ordering put "Open" on the "O" key. Prefer
+ // the no-facet entry; fall back to the first registered name when no
+ // no-facet entry exists. Per E0008.4 Phase 2 Item 2.
+ const typeMap = new Map<string, string>();
+ for (const t of types) {
+ const existing = typeMap.get(t.letter);
+ if (existing === undefined || !t.facet) typeMap.set(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";
@@ -1242,7 +1353,9 @@
if (matchesStemmedPhrases(t.stemmedPhrases, inputStems)) {
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() });
+ const a: ParsedArtifact = { type: t.letter, typeName: t.name, fields: [t.letter, title, para.trim()], title, body: para.trim() };
+ if (t.facet) a.facet = t.facet;
+ artifacts.push(a);
matched = true;
}
}
@@ -1250,7 +1363,9 @@
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() });
+ const a: ParsedArtifact = { type: fallback.letter, typeName: fallback.name, fields: [fallback.letter, title, para.trim()], title, body: para.trim() };
+ if (fallback.facet) a.facet = fallback.facet;
+ artifacts.push(a);
}
}
return artifacts;
@@ -3238,6 +3353,7 @@
fetcher: KnowledgeBaseFetcher,
knowledgeBaseUrl?: string,
state?: OddkitState,
+ includeGovernanceDetails?: boolean,
): Promise<ActionResult> {
const startMs = Date.now();
// Governance: input generates artifacts; context only informs quality scoring.
@@ -3260,11 +3376,20 @@
: parseUnstructuredInput(input, types);
// Score each artifact using its type's quality criteria.
+ // Look up by (letter, facet) pair, not letter alone — peer types share a
+ // letter (closed Observation: O / undefined; Open: O / "open") and were
+ // silently scored against Observation's 4 criteria pre-#173. Falls back to
+ // letter-only lookup for artifacts that lack a facet (TSV path, prose
+ // path), preserving the prior behavior on those surfaces. Per E0008.4
+ // Phase 2 Item 2.
// When context is provided, append it to the artifact's body for scoring
// so background information (rationale, alternatives, evidence) counts
// toward the artifact's quality without becoming separate artifacts.
const scoredArtifacts = artifacts.map((a) => {
- const typeDef = types.find((t) => t.letter === a.type);
+ const typeDef =
+ types.find((t) => t.letter === a.type && (t.facet ?? undefined) === (a.facet ?? undefined)) ??
+ types.find((t) => t.letter === a.type && !t.facet) ??
+ types.find((t) => t.letter === a.type);
const criteria = typeDef ? typeDef.qualityCriteria : [];
const scoringText = context ? `${a.body}\n${context}` : undefined;
const quality = scoreArtifactQuality(a, criteria, scoringText);
@@ -3319,20 +3444,83 @@
lines.push("---");
lines.push("**Encoding types (governance):**");
for (const t of types) {
- lines.push(`- **${t.letter}** — ${t.name}`);
+ const label = t.facet ? `${t.letter} (${t.facet})` : t.letter;
+ lines.push(`- **${label}** — ${t.name}`);
}
+ // Item 1 — governance_uris plural array, alphabetical by URI. Aligns the
+ // encode action's envelope with the challenge (P1.3.1) and gate (P1.3.2)
+ // canaries, both of which declare governance_uris. Encode's array is
+ // dynamic per request: every encoding-type article actually fetched
+ // (those that yielded a Letter row, plus the meta serialization-format
+ // article which is also tagged encoding-type), plus the how-to-write
+ // meta article (tagged encoding-type-meta and so not in typeArticles).
+ // Deduped + sorted so the array is stable across requests with the same
+ // canon. Per E0008.4 Phase 2 Item 1.
+ const consultedUris = new Set<string>();
+ for (const t of types) {
+ if (t.sourceUri) consultedUris.add(t.sourceUri);
+ }
+ consultedUris.add("klappy://odd/encoding-types/serialization-format");
+ consultedUris.add("klappy://odd/encoding-types/how-to-write-encoding-types");
+ const governanceUris = Array.from(consultedUris).sort();
+
+ // Item 4 — optional self-teaching payload. Closes Gap 4 from the original
+ // architecture brief: pre-#173 the envelope returned only [{ letter, name }]
+ // per type, so a model wanting to learn the input format or scoring rubric
+ // had to issue a separate oddkit_get per article. Gated by request param to
+ // avoid token bloat for callers who already know the format. When opted in,
+ // the payload carries the parsed Field Schema, Quality Criteria, trigger
+ // words, and facet for each type plus URIs for the two meta articles.
+ const result: Record<string, unknown> = {
+ status: "ENCODED",
+ artifacts: scoredArtifacts,
+ governance: types.map((t) => {
+ const g: { letter: string; facet?: string; name: string } = {
+ letter: t.letter, name: t.name,
+ };
+ if (t.facet) g.facet = t.facet;
+ return g;
+ }),
+ governance_source: governanceSource,
+ governance_uris: governanceUris,
+ // Deprecation alias retained for one minor (0.28.0). Removed in 0.29.0.
+ // Consumers that read governance_uri[0] today should migrate to
+ // governance_uris[0]; the singular continues to point to the DOLCHEO
+ // umbrella vocabulary, which is the conceptual anchor (the dynamic
+ // articles are its expressions).
+ governance_uri: "klappy://canon/definitions/dolcheo-vocabulary",
+ persist_required: true,
+ next_action: "Save these artifacts to storage. Encode does NOT persist.",
+ };
+ if (includeGovernanceDetails) {
+ result.governance_extended = {
+ types: types.map((t) => {
+ const ext: {
+ letter: string; facet?: string; name: string;
+ fieldSchema?: Array<{ field: string; recommended: string; description: string }>;
+ qualityCriteria: Array<{ criterion: string; check: string; gapMessage: string }>;
+ triggerWords: string[];
+ sourceUri?: string;
+ } = {
+ letter: t.letter,
+ name: t.name,
+ qualityCriteria: t.qualityCriteria,
+ triggerWords: t.triggerWords,
+ };
+ if (t.facet) ext.facet = t.facet;
+ if (t.fieldSchema) ext.fieldSchema = t.fieldSchema;
+ if (t.sourceUri) ext.sourceUri = t.sourceUri;
+ return ext;
+ }),
+ serializationFormatUri: "klappy://odd/encoding-types/serialization-format",
+ howToWriteUri: "klappy://odd/encoding-types/how-to-write-encoding-types",
+ };
+ }
+
return {
action: "encode",
- result: {
- status: "ENCODED",
- artifacts: scoredArtifacts,
- governance: types.map((t) => ({ letter: t.letter, name: t.name })),
- governance_source: governanceSource,
- governance_uri: "klappy://canon/definitions/dolcheo-vocabulary",
- persist_required: true,
- next_action: "Save these artifacts to storage. Encode does NOT persist.",
- },
+ result,
state: updatedState,
assistant_text: lines.join("\n").trim(),
debug: {
@@ -3364,7 +3552,7 @@
] as const;
export async function handleUnifiedAction(params: UnifiedParams): Promise<OddkitEnvelope> {
- const { action, input, context, mode, knowledge_base_url, result_grouping, include_metadata, include_full_baseline, section, sort_by, limit, offset, filter_epoch, state, env, tracer } = params;
+ const { action, input, context, mode, knowledge_base_url, result_grouping, include_metadata, include_full_baseline, section, sort_by, limit, offset, filter_epoch, include_governance_details, state, env, tracer } = params;
// Conditional default: when knowledge_base_url is set and caller didn't
// specify result_grouping, default to "overlay_first" (the fix for #150).
@@ -3409,7 +3597,7 @@
result = await runGateAction(input, context, fetcher, knowledge_base_url, state);
break;
case "encode":
- result = await runEncodeAction(input, context, fetcher, knowledge_base_url, state);
+ result = await runEncodeAction(input, context, fetcher, knowledge_base_url, state, include_governance_details);
break;
case "search":
result = await runSearch(input, fetcher, knowledge_base_url, state, include_metadata, resolvedGrouping, resolvedScope);You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 7460a0e. Configure here.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Implements Items 1–4 of the E0008.4 Phase 2 handoff:
klappy://odd/handoffs/2026-04-30-encode-vodka-refactor-alternative-d-revised. Item 5 (schema-drivencheckevaluator) deferred to 0.29.0 per the handoff's defer condition — it spans both repos (oddkit code + klappy.dev canon Quality Criteria table migrations across 7 articles) and the currentcheck.includes(...)interpretation works correctly for existing criteria. Bundling it would entangle release validation across two repos.Target version: 0.28.0.
What changed
Item 1 —
governance_urisplural array (envelope alignment)The encode envelope now emits
governance_urisas an alphabetical, dynamic-per-request array of every encoding-type article actually consulted (typically the 7 type articles +serialization-format+how-to-write-encoding-types, ~9 entries). Aligns with the challenge (P1.3.1) and gate (P1.3.2) canaries.governance_uri(singular) is retained as a deprecation alias for one minor and removed in 0.29.0.Item 2 —
(letter, facet)dedup fixes Open quality scoringdiscoverEncodingTypesnow parses an optional| Facet | … |row from the Type Identity table the same way it parses Letter, dedupes by(letter, facet)pair, and the scorer inrunEncodeActionlooks up criteria by pair.Pre-fix verification on prod 0.27.0: a live
oddkit_encodecall with[O-open P1] bodyreturnedquality.score: 4, maxScore: 4andtypeName: "Observation (Open)"— Observation's max + an Open-suffix workaround on Observation's name, not Open's 5 criteria.parsePrefixedBatchInputalso resolves type by pair so Open's canon name surfaces directly (typeName: "Open", no more"Observation (Open)"workaround).Item 3 — Open in inline fallback baseline
When canon is unreachable, the fallback now registers seven entries (D, O closed, O open, L, C, H, E) instead of six. Pre-fix,
[O-open]tags fell through to the Observation handler under canon-unreachable conditions because the fallback didn't include Open at all.Item 4 —
governance_extendedself-teaching surface (closes Gap 4)New optional request param
include_governance_details: boolean. When true, the response includesgovernance_extended.types[]with parsedfieldSchema,qualityCriteria,triggerWords,facet, andsourceUriper type, plusserializationFormatUriandhowToWriteUri. Off by default to avoid token bloat for callers that already know the format. Field Schema is parsed from the article's## Field Schematable in the same discovery pass as Quality Criteria.Type-system changes
EncodingTypeDefextended with optionalfacet,sourceUri,fieldSchema.UnifiedParamsgainsinclude_governance_details?: boolean.oddkitZod schema and the individualoddkit_encodeZod schema declare the new param.oddkit_encodetool description rewritten to describe the new envelope shape and the deprecation alias.Validation done locally
tsc --noEmitclean (worker typecheck).workers/test/governance-parser.test.mjs(105 cases) pass.workers/test/tokenize.test.mjs(7 cases) pass.Release validation gate — required before merge
Per
klappy://canon/constraints/release-validation-gate(tier 1):completed.in_progressis not non-blocking.klappy://canon/principles/verification-requires-fresh-context.Validator should specifically confirm:
governance_urisis an alphabetical array of actually-consulted articles, ~9 entries (Item 1).[O-open P1] bodyinput scores against Open's 5 criteria (maxScore: 5), not Observation's 4 (Item 2).typeNameis"Open", not"Observation (Open)".knowledge_base_urlreturns 7 types in thegovernanceenvelope, including Open withfacet: "open"(Item 3).include_governance_details: truereturns parsedfieldSchemaandqualityCriteria; absence returns the existing minimal envelope (Item 4).What's not in this PR
checkevaluator) — queued for 0.29.0.klappy://odd/handoffs/2026-04-30-cli-encode-deprecation.governance_uri(singular) removal — 0.29.0.See also
klappy://odd/handoffs/2026-04-30-encode-vodka-refactor-alternative-d-revisedklappy://docs/architecture/encode-current-state-2026-04-30klappy://odd/ledger/2026-04-20-p1-3-4-encode-canon-parity-landedNote
Medium Risk
Changes
encodescoring logic and response schema (new fields and different governance/type resolution), which could affect downstream consumers and output stability if they assume the previous envelope or scoring behavior.Overview
Updates the
encodeaction to be facet-aware (dedup and scoring by(letter, facet)), fixing cases where[O-open ...]artifacts were previously treated/scored as plain Observation and ensuring Open is also present in the minimal fallback when canon can’t be reached.Extends the encode response envelope to include
governance_uris(sorted, per-request list of consulted governance URIs) while keepinggovernance_urias a temporary deprecation alias, and adds aninclude_governance_detailsrequest flag that can return agovernance_extendedpayload with parsed field schemas, quality criteria, trigger words, and source URIs.Also bumps package versions to
0.28.0across root and worker packages (including lockfile) and wires the newinclude_governance_detailsparam through the unified andoddkit_encodetool schemas.Reviewed by Cursor Bugbot for commit 47fc7e0. Bugbot is set up for automated code reviews on this repo. Configure here.