Add full shell, replaceable Omics adapter and bilingual interaction

Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
This commit is contained in:
Codex
2026-09-13 14:26:39 +02:00
parent 2d1b714ebe
commit d8a29bfbdd
207 changed files with 8570 additions and 2164 deletions
@@ -1,6 +1,7 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { installRepairGate } = require("../memory/repair.js");
const { gateText } = require("../core/locale.js");
function gate({ answers, fail = false, resume = false }) {
let tool;
@@ -8,6 +9,8 @@ function gate({ answers, fail = false, resume = false }) {
const repair = { repair_id: "receipt", choice: null, status: "proposed", can_apply: true,
saved: false, indexed: false, options: [{ id: "fix", content: { detail: "Correction" } }] };
installRepairGate({ registerTool: def => { tool = def; } }, {
languageFor: () => "en",
gateText,
workflow: { activate() {}, phase: () => ({ id: "F4" }) },
memory: { execute: (_ctx, args) => {
calls.push(args);
@@ -208,8 +208,8 @@ test("buildArtifactGate derives approve/reject options from action.kind", () =>
action: { kind: "approve_reject" },
});
assert.deepEqual(w.options, [
{ id: "approve", label: "Salva e procedi", recommended: true },
{ id: "reject", label: "Rifiuta" },
{ id: "approve", label: "Save and proceed", label_i18n: "Save and proceed", recommended: true },
{ id: "reject", label: "Reject", label_i18n: "Reject" },
]);
});
@@ -217,7 +217,7 @@ test("buildArtifactGate: confirm -> single approve; view_only -> no options", ()
const confirm = buildArtifactGate({
id: "u1", phase: "F1", title: "t", artifact: { kind: "phase", data: {} }, action: { kind: "confirm" },
});
assert.deepEqual(confirm.options, [{ id: "approve", label: "Salva e procedi", recommended: true }]);
assert.deepEqual(confirm.options, [{ id: "approve", label: "Save and proceed", label_i18n: "Save and proceed", recommended: true }]);
const viewOnly = buildArtifactGate({
id: "u1", phase: "F1", title: "t", artifact: { kind: "phase", data: {} }, action: { kind: "view_only" },
});
@@ -214,7 +214,7 @@ test("appendLedgerSection appends only the phase's substantive decisions", () =>
const out = appendLedgerSection(data, decisions, ["concept_clarified", "ambiguity_open"]);
assert.equal(out.sections.length, 2);
const ledger = out.sections[1];
assert.equal(ledger.title, "Decisioni registrate in questa fase (dal ledger)");
assert.equal(ledger.title, "Decisions recorded in this phase (from the ledger)");
assert.equal(ledger.items.length, 1);
assert.equal(ledger.items[0].label, "concept_clarified: ablazione");
assert.equal(ledger.items[0].value, "solo transcatetere");
@@ -18,7 +18,9 @@ if (typeof globalThis.require === "undefined") {
}
const shell = { current: () => "" };
cp.execFileSync = (file, args, opts) => shell.current(file, args, opts);
cp.execFileSync = (file, args, opts) => args[1] === "ensure-interaction-language"
? JSON.stringify({ interaction_language: "it", workspace_language: "it" })
: shell.current(file, args, opts);
const META = JSON.stringify({
max_phase: 8,
@@ -23,7 +23,9 @@ if (typeof globalThis.require === "undefined") {
// swap `activeStub` instead of clobbering the dispatcher. Each test's existing
// `cp.execFileSync = fn` / restore-in-finally then Just Works.
let activeStub = cp.execFileSync;
const dispatcher = (...args) => activeStub(...args);
const dispatcher = (...args) => args[1]?.[1] === "ensure-interaction-language"
? JSON.stringify({ interaction_language: "it", workspace_language: "it" })
: activeStub(...args);
Object.defineProperty(cp, "execFileSync", {
configurable: true,
get: () => dispatcher,
@@ -30,6 +30,8 @@ async function loadGate() {
function phase8Stub(calls) {
return (_file, args) => {
calls.push(args);
if (args[0] === "session" && args[1] === "ensure-interaction-language")
return JSON.stringify({ interaction_language: "it", workspace_language: "it" });
if (args[0] === "phase" && args[1] === "meta") {
return JSON.stringify({
max_phase: 8,
@@ -0,0 +1,104 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const cp = require("node:child_process");
const { createRequire } = require("node:module");
const path = require("node:path");
const { createFakePi } = require("./fake_pi_runtime.js");
const GATE = path.join(__dirname, "..", "..", "tht-gate.js");
if (!globalThis.require) globalThis.require = createRequire(GATE);
let activeStub = cp.execFileSync;
const dispatcher = (...args) => activeStub(...args);
Object.defineProperty(cp, "execFileSync", {
configurable: true, get: () => dispatcher, set: fn => { activeStub = fn; },
});
for (const [language, workspaceLanguage, entry, title, yes, no] of [
["en", "it", "/riprendi-sessione s1", "Generate a datamart?", "Yes, generate the datamart", "No, skip the datamart"],
["it", "en", '/nuova-domanda "Pazienti"', "Vuoi generare un datamart?", "Sì, genera il datamart", "No, salta il datamart"],
["fr-FR", "it", "/riprendi-sessione s1", "Generate a datamart?", "Yes, generate the datamart", "No, skip the datamart"],
]) test(`manifest language ${language} governs every model turn and generated gate chrome`, async () => {
const previous = { exec: cp.execFileSync, session: process.env.THT_SESSION,
language: process.env.THT_INTERACTION_LANGUAGE, profile: process.env.THT_PROFILE };
process.env.THT_SESSION = "s1";
process.env.THT_INTERACTION_LANGUAGE = workspaceLanguage; // stale launch hints have no authority
process.env.THT_PROFILE = "server";
cp.execFileSync = (_file, args) => {
if (args[0] === "session" && args[1] === "ensure-interaction-language")
return JSON.stringify({ id: "s1", interaction_language: language, workspace_language: workspaceLanguage });
if (args[0] === "session" && args[1] === "retrieval-pack") return "Pazienti: età > 65";
if (args[0] === "phase" && args[1] === "meta")
return JSON.stringify({ max_phase: 8, phases: [{ num: 8, id: "F8", name: "datamart",
emits: ["datamart_requested", "datamart_declined"] }] });
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 8\n";
return "";
};
try {
const { default: installGate } = await import(GATE);
const { pi, ctx, tools } = createFakePi();
ctx.cwd = "/nonexistent-language-test";
installGate(pi);
await pi.emit("input", { text: entry, source: "rpc" });
for (let turn = 0; turn < 2; turn++) {
const injected = await pi.emit("before_agent_start", { systemPrompt: "BASE" });
assert.ok(injected.systemPrompt.includes(`interaction_language=${language}`));
assert.ok(injected.systemPrompt.includes(`workspace_language=${workspaceLanguage}`));
assert.match(injected.systemPrompt, /reviewer questions, explanations, and choices/);
}
let widget;
ctx.ui.input = async title => {
widget = JSON.parse(title);
return JSON.stringify({ id: widget.id, choices: ["skip"] });
};
await tools.get("reviewer_datamart").def.execute("call", { session: "s1" }, null, null, ctx);
assert.equal(widget.title, title);
assert.equal(widget.title_i18n, "Generate a datamart?");
assert.deepEqual(widget.options.map(o => [o.id, o.label]), [
["generate", yes], ["skip", no],
]);
} finally {
cp.execFileSync = previous.exec;
for (const [key, value] of [["THT_SESSION", previous.session],
["THT_INTERACTION_LANGUAGE", previous.language], ["THT_PROFILE", previous.profile]]) {
if (value === undefined) delete process.env[key]; else process.env[key] = value;
}
}
});
test("an unavailable manifest blocks model startup instead of trusting a launch hint", async () => {
const originalExec = cp.execFileSync;
const previousSession = process.env.THT_SESSION;
process.env.THT_SESSION = "unavailable";
cp.execFileSync = () => { throw new Error("storage unavailable"); };
try {
const { default: installGate } = await import(GATE);
const { pi, ctx } = createFakePi();
ctx.cwd = "/nonexistent-language-test";
installGate(pi);
await pi.emit("input", { text: "/riprendi-sessione unavailable", source: "rpc" });
await assert.rejects(pi.emit("before_agent_start", { systemPrompt: "BASE" }), /storage unavailable/);
} finally {
cp.execFileSync = originalExec;
if (previousSession === undefined) delete process.env.THT_SESSION;
else process.env.THT_SESSION = previousSession;
}
});
test("artifact and join chrome localize while SQL, decisions and generated text remain verbatim", () => {
const { buildArtifactGate, buildJoinReviewRequest } = require("../core/builders.js");
const artifact = { kind: "sql", data: "SELECT nome FROM pazienti WHERE stato = 'attivo'" };
const options = [{ id: "join-1", label: "Collega pazienti", detail: "p.id = v.id" }];
for (const [locale, save, reject, next] of [
["en", "Save and proceed", "Reject", "Continue"],
["it", "Salva e procedi", "Rifiuta", "Continua"],
["fr", "Save and proceed", "Reject", "Continue"],
]) {
const gate = buildArtifactGate({ id: "u1", phase: "F7", title: "SQL finale",
artifact, action: { kind: "approve_reject" }, locale });
assert.deepEqual(gate.options.map(o => [o.id, o.label]), [["approve", save], ["reject", reject]]);
assert.deepEqual(gate.artifact, artifact);
assert.equal(gate.title, "SQL finale");
const joins = buildJoinReviewRequest({ id: "u2", phase: "F4", title: "Join", options, locale });
assert.equal(joins.confirm_label, next);
assert.deepEqual(joins.options, options);
}
});
@@ -36,6 +36,8 @@ const JOIN_OPTIONS = [
function shellStub(calls, inputs = []) {
return (_file, args, options = {}) => {
calls.push(args.join(" "));
if (args[0] === "session" && args[1] === "ensure-interaction-language")
return JSON.stringify({ interaction_language: "en", workspace_language: "it" });
if (options.input) inputs.push(options.input);
if (args[0] === "phase" && args[1] === "meta") {
return JSON.stringify({ phases: [{ num: 4, id: "F4" }] });
@@ -1,6 +1,7 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { createMemoryGate } = require("../memory/index.js");
const { gateText } = require("../core/locale.js");
const { createFakePi } = require("./fake_pi_runtime.js");
function setup({ phase = 8, response, failure = null } = {}) {
@@ -8,6 +9,8 @@ function setup({ phase = 8, response, failure = null } = {}) {
const calls = [];
const summary = { summary_id: "summary", items: [{ id: "rule", card: { subject: "Rule" } }] };
createMemoryGate({
languageFor: () => "en",
gateText,
workflow: { activate() {}, phase: () => ({ number: phase, id: "F" + phase }),
close: () => { calls.push("close"); return "closed"; } },
memory: { execute: () => JSON.stringify(summary),
@@ -1,6 +1,7 @@
const test = require("node:test");
const assert = require("node:assert");
const { createMemoryGate } = require("../memory/index.js");
const { gateText } = require("../core/locale.js");
const { buildMultiselectRequest } = require("../core/builders.js");
const { isReserved } = require("../core/reserved-labels.mjs");
const { createFakePi } = require("./fake_pi_runtime.js");
@@ -43,7 +44,7 @@ const MEMORY_OPTIONS = [
];
function setupRecall(choices) {
function setupRecall(choices, language = "it") {
const { pi, tools, ctx } = createFakePi();
const calls = [];
let descriptor;
@@ -53,6 +54,8 @@ function setupRecall(choices) {
return JSON.stringify({ id: descriptor.id, choices });
};
const memoryGate = createMemoryGate({
languageFor: () => language,
gateText,
workflow: {
activate: () => calls.push(["activate"]),
phase: () => ({ number: 8, id: "F8" }),
@@ -117,6 +120,19 @@ test("the Memory facade normalizes search hits and applies only the selected F2
assert.match(result.content[0].text, /1 decisioni.*La fase resta aperta/s);
});
test("English Memory chrome preserves Italian content and carries UI translation keys", async () => {
const { ctx, memoryGate, descriptor } = setupRecall(["first"], "en");
await memoryGate.reviewRecall(ctx, {
session: "s1", title: "Memory", options: MEMORY_OPTIONS, allow_empty: true, advance: false,
}, "F2");
assert.equal(descriptor().title, "Select memories to apply to the question");
assert.equal(descriptor().confirm_label, "Apply selected memories");
assert.equal(descriptor().confirm_label_i18n, "Apply selected memories");
assert.equal(descriptor().options[0].label, "Regola paziente attivo");
assert.equal(descriptor().options[0].detail, MEMORY_OPTIONS[0].description);
assert.equal(descriptor().options[0].label_i18n, undefined);
});
test("authoritative UUID card identities survive normalization into the reviewer widget", async () => {
const { ctx, memoryGate, descriptor } = setupRecall(["first"]);
const id = "mem-11111111-1111-4111-8111-111111111111";
@@ -26,7 +26,7 @@ case "$1 $2" in
"phase show") echo "Fase corrente: ${phase}";;
"phase meta") echo '{"max_phase":8,"phases":[{"num":2,"id":"F2","emits":[]},{"num":8,"id":"F8","emits":[]}]}';;
"memory summary") echo '{"summary_id":"saved-review","reviewed":true}';;
"session show") echo '{"status":"${status}"}';;
"session show"|"session ensure-interaction-language") echo '{"id":"s1","status":"${status}","interaction_language":"it","workspace_language":"it"}';;
*) echo "OK";;
esac
`;
@@ -11,12 +11,14 @@ test("con THT_SESSION il kickoff usa l'id fornito e NON crea la sessione", async
const bin = path.join(tmp, "bin");
fs.mkdirSync(bin);
const tht = path.join(bin, "tht");
fs.writeFileSync(tht, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
fs.writeFileSync(tht, '#!/bin/sh\nif [ "$2" = "ensure-interaction-language" ]; then\n' +
'echo \'{"interaction_language":"it","workspace_language":"it"}\'\nelse\nexit 1\nfi\n', { mode: 0o755 });
const oldPath = process.env.PATH;
process.env.PATH = bin + ":" + oldPath;
process.env.THT_SESSION = "2026-06-27-100000-test";
try {
const { pi, ctx } = createFakePi();
ctx.cwd = tmp;
installGate(pi);
await pi.emit("input", { source: "rpc", text: '/nuova-domanda "x"' });
const injected = await pi.emit("before_agent_start", { systemPrompt: "" });
@@ -56,7 +58,9 @@ test("con retrieval pack persistito lo inietta senza chiedere tool call ridondan
fs.writeFileSync(
tht,
"#!/bin/sh\n" +
"printf \"# Retrieval pack\\n\\nTABELLA_CANDIDATA\\n\"\n",
'if [ "$2" = "ensure-interaction-language" ]; then\n' +
'echo \'{"interaction_language":"it","workspace_language":"it"}\'\nelse\n' +
"printf \"# Retrieval pack\\n\\nTABELLA_CANDIDATA\\n\"\nfi\n",
{ mode: 0o755 },
);
const oldPath = process.env.PATH;
@@ -1,5 +1,15 @@
const test = require("node:test");
const assert = require("node:assert");
const cp = require("node:child_process");
const { syncBuiltinESMExports } = require("node:module");
const originalExec = cp.execFileSync;
cp.execFileSync = (_file, args) => {
if (args[0] === "session" && args[1] === "ensure-interaction-language")
return JSON.stringify({ interaction_language: "it", workspace_language: "it" });
throw new Error("No retrieval pack in this fixture");
};
syncBuiltinESMExports();
test.after(() => { cp.execFileSync = originalExec; syncBuiltinESMExports(); });
const { createFakePi } = require("./fake_pi_runtime.js");
const installGate = require("../../tht-gate.js").default ?? require("../../tht-gate.js");
@@ -21,5 +21,5 @@ test("no-limbo: undefined (cancel) ri-presenta lo stesso widget", async () => {
const resp = await emitAndWait(ctx, descriptor);
assert.deepEqual(resp, { id: "u1", choices: ["a"] });
assert.equal(ctx.uiCalls.length, 2); // ri-presentato una volta
assert.ok(ctx.notifications.some((n) => /Esc non chiude/.test(n.message)));
assert.ok(ctx.notifications.some((n) => /Esc does not close/.test(n.message)));
});
@@ -10,7 +10,9 @@ const GATE = path.join(__dirname, "..", "..", "tht-gate.js");
globalThis.require = createRequire(GATE);
const shell = { current: () => "" };
cp.execFileSync = (file, args, options) => shell.current(file, args, options);
cp.execFileSync = (file, args, options) => args[1] === "ensure-interaction-language"
? JSON.stringify({ interaction_language: "it", workspace_language: "it" })
: shell.current(file, args, options);
const installGate = require(GATE).default ?? require(GATE);
@@ -541,7 +543,7 @@ test("F8 warns about pending indexing but preserves the saved review and finaliz
const { ctx, tools, calls } = await setupGate({ phase: 8, preview: [PROMOTION_CANDIDATE], indexed: false });
answerReview(ctx, [PROMOTION_CANDIDATE]);
await tools.get("reviewer_memory_promote").def.execute("pending", { session: "s1" }, null, null, ctx);
assert(ctx.notifications.some(({ message, level }) => level === "warning" && message.includes("Memory saved")));
assert(ctx.notifications.some(({ message, level }) => level === "warning" && message.includes("Memory salvata")));
assert(memoryPromotionMutationArgs(calls).some(args => args.includes("memory_summary_reviewed")));
});
test("F8 recovers a saved review without asking again or saving cards again", async () => {
+14 -7
View File
@@ -14,6 +14,7 @@
const SCHEMA_VERSION = 1;
const RESERVED = ["back", "exit", "other"];
const { gateText } = require("./locale.js");
const VALID_ACTION_KINDS = new Set(["confirm", "approve_reject", "view_only"]);
const VALID_INFO_LEVELS = new Set(["info", "warning", "error"]);
@@ -38,6 +39,7 @@ function requireArray(value, name, ctx) {
// highlight as "(consigliato)".
function buildSelectRequest({
id, phase, title, options, intro = null, recommended = null,
titleI18n,
}) {
requireString(title, "title", "select");
const opts = requireArray(options, "options", "select");
@@ -48,6 +50,7 @@ function buildSelectRequest({
schema_version: SCHEMA_VERSION,
widget: "select",
title,
...(titleI18n ? { title_i18n: titleI18n } : {}),
intro,
recommended,
options: [...opts],
@@ -61,7 +64,7 @@ function buildSelectRequest({
// `allowEmpty:false` with zero options is a broken widget and throws.
function buildMultiselectRequest({
id, phase, title, options, content = null, selected = [], allowEmpty = false,
selectionLabel, confirmLabel,
selectionLabel, confirmLabel, titleI18n, selectionLabelI18n, confirmLabelI18n,
}) {
requireString(title, "title", "multiselect");
const opts = requireArray(options, "options", "multiselect");
@@ -77,6 +80,7 @@ function buildMultiselectRequest({
schema_version: SCHEMA_VERSION,
widget: "multiselect",
title,
...(titleI18n ? { title_i18n: titleI18n } : {}),
allow_empty: allowEmpty,
options: opts.map((option) => ({ ...option, selected: selected.includes(option.id) })),
selected: [...selected],
@@ -84,13 +88,15 @@ function buildMultiselectRequest({
reserved: RESERVED,
...(selectionLabel ? { selection_label: selectionLabel } : {}),
...(confirmLabel ? { confirm_label: confirmLabel } : {}),
...(selectionLabelI18n ? { selection_label_i18n: selectionLabelI18n } : {}),
...(confirmLabelI18n ? { confirm_label_i18n: confirmLabelI18n } : {}),
};
}
// Build an `artifact-gate` ui_request: a rendered artifact + a disposition list
// (confirm / approve_reject / view_only). The decision is load-bearing on the
// document, so the artifact is never shown alone (spec §4.1).
function buildArtifactGate({ id, phase, title, artifact, action }) {
function buildArtifactGate({ id, phase, title, artifact, action, locale = "en" }) {
requireString(title, "title", "artifact-gate");
if (!artifact || typeof artifact !== "object") {
throw new Error("builders: artifact-gate requires an artifact object");
@@ -105,10 +111,10 @@ function buildArtifactGate({ id, phase, title, artifact, action }) {
}
const ACTION_OPTIONS = {
approve_reject: [
{ id: "approve", label: "Salva e procedi", recommended: true },
{ id: "reject", label: "Rifiuta" },
{ id: "approve", label: gateText(locale, "save"), label_i18n: gateText("en", "save"), recommended: true },
{ id: "reject", label: gateText(locale, "reject"), label_i18n: gateText("en", "reject") },
],
confirm: [{ id: "approve", label: "Salva e procedi", recommended: true }],
confirm: [{ id: "approve", label: gateText(locale, "save"), label_i18n: gateText("en", "save"), recommended: true }],
view_only: [],
};
return {
@@ -180,7 +186,7 @@ function buildSchemaLinkingRequest({ id, phase, title, tables }) {
// Build a blocking, read-only join review. The reviewer can accept the complete
// proposal or use Other to request a textual correction; individual joins are
// deliberately not selectable because omitting one could create a Cartesian product.
function buildJoinReviewRequest({ id, phase, title, options }) {
function buildJoinReviewRequest({ id, phase, title, options, locale = "en" }) {
requireString(title, "title", "join-review");
const opts = requireArray(options, "options", "join-review");
if (opts.length === 0) {
@@ -194,7 +200,8 @@ function buildJoinReviewRequest({ id, phase, title, options }) {
widget: "join-review",
title,
options: [...opts],
confirm_label: "Continue",
confirm_label: gateText(locale, "continue"),
confirm_label_i18n: gateText("en", "continue"),
reserved: RESERVED,
};
}
+4 -2
View File
@@ -156,12 +156,14 @@ function enrichCteResultColumns(payload, planTables, getColumns) {
// comes from persisted state, not model prose). `decisions` is session-show's ledger
// dump; `emits` is the phase's substantive decision-type list from workflow.yaml.
// No matching decisions -> data returned unchanged. Pure: returns a NEW object.
function appendLedgerSection(data, decisions, emits) {
function appendLedgerSection(data, decisions, emits, locale = "en") {
const { gateText } = require("./locale.js");
const emitSet = new Set(emits || []);
const rows = (decisions || []).filter((d) => d && emitSet.has(d.type));
if (rows.length === 0) return data;
const section = {
title: "Decisioni registrate in questa fase (dal ledger)",
title: gateText(locale, "ledgerTitle"),
title_i18n: gateText("en", "ledgerTitle"),
items: rows.map((d) => ({
label: `${d.type}: ${d.subject}`,
value: d.detail || "",
@@ -0,0 +1,41 @@
// Only deterministic chrome is translated. Model prose and domain payloads are opaque.
const en = {
save: "Save and proceed", reject: "Reject", continue: "Continue",
approvalPrompt: "Approve or reject?", chooseApproval: "Choose Save and proceed or Reject.",
datamartTitle: "Generate a datamart?", datamartYes: "Yes, generate the datamart",
datamartNo: "No, skip the datamart",
memoryTitle: "Select memories to apply to the question", memorySelection: "memories to apply",
memoryApply: "Apply selected memories", memoryEmpty: "No reusable memories for this question; continuing to the next phase.",
memorySummary: "Memory for future questions", archiveRepair: "Resolve archive conflict",
repairError: "The correction could not complete. Review the current archive state before retrying.",
ledgerTitle: "Decisions recorded in this phase (from the ledger)",
escapeHelp: "Esc does not close the gate: use Back / Exit / Other.",
inputHelp: "Answer through the gate widgets. To send free text to the model, start the line with '!'.",
memoryIndexWarning: "Memory saved. Index update is incomplete; an administrator can retry it in Memory management.",
backUsage: "Usage: /torna <session_id> [N]",
backInvalid: "Invalid target (current phase {phase}).",
decision: "Decision",
};
const it = {
save: "Salva e procedi", reject: "Rifiuta", continue: "Continua",
approvalPrompt: "Approvi o rifiuti?", chooseApproval: "Scegli «Salva e procedi» o «Rifiuta».",
datamartTitle: "Vuoi generare un datamart?", datamartYes: "Sì, genera il datamart",
datamartNo: "No, salta il datamart",
memoryTitle: "Seleziona le memory da applicare alla domanda", memorySelection: "memory da applicare",
memoryApply: "Applica le memory selezionate", memoryEmpty: "Nessuna memory riutilizzabile per questa domanda — passo alla fase successiva.",
memorySummary: "Memory per le domande future", archiveRepair: "Risolvi il conflitto negli archivi",
repairError: "La correzione non è stata completata. Verifica lo stato attuale degli archivi prima di riprovare.",
ledgerTitle: "Decisioni registrate in questa fase (dal ledger)",
escapeHelp: "Esc non chiude il gate: usa Torna indietro / Esci / Altro dalle opzioni.",
inputHelp: "Durante la sessione rispondi con i widget del gate. Per inviare testo libero al modello inizia la riga con '!'.",
memoryIndexWarning: "Memory salvata. L'aggiornamento dell'indice è incompleto; un amministratore può riprovarlo da Gestione Memory.",
backUsage: "Uso: /torna <session_id> [N]",
backInvalid: "Target non valido (fase corrente {phase}).",
decision: "Decisione",
};
const catalogs = { en, it };
function gateText(locale, key) {
const language = String(locale || "en").toLowerCase().split("-")[0];
return catalogs[language]?.[key] ?? en[key];
}
module.exports = { gateText };
+18 -15
View File
@@ -1,9 +1,7 @@
import { Type } from "typebox";
import { installRepairGate } from "./repair.js";
// Compatibility note: reviewer labels move verbatim from the composition root.
// Tickets #22 and #23 require observable parity; translating existing chrome is a
// separate product behavior change rather than part of these extractions.
// The composition root supplies manifest language; only deterministic chrome is localized.
function normalizedText(value) {
return String(value ?? "").trim().replace(/\s+/g, " ").toLowerCase();
@@ -31,7 +29,7 @@ function memoryOptionKey(option) {
}
function normalizeMemoryOptions(options) {
function normalizeMemoryOptions(options, locale, gateText) {
const seen = new Set();
const out = [];
for (const option of options) {
@@ -47,7 +45,7 @@ function normalizeMemoryOptions(options) {
].join(" ").match(MEMORY_ID_RE)?.[0]?.toLowerCase();
const fallbackDetail = [
option.decision?.type && option.decision?.subject
? `Decisione ${option.decision.type}: ${option.decision.subject}`
? `${gateText(locale, "decision")} ${option.decision.type}: ${option.decision.subject}`
: "",
option.decision?.detail ?? "",
].filter(Boolean).join("\n");
@@ -62,12 +60,15 @@ function normalizeMemoryOptions(options) {
}
function memorySelectionWidgetProps(options) {
function memorySelectionWidgetProps(options, locale, gateText) {
return {
title: "Seleziona le memory da applicare alla domanda",
title: gateText(locale, "memoryTitle"),
titleI18n: gateText("en", "memoryTitle"),
selected: options.filter((option) => option.recommended).map((option) => option.id),
selectionLabel: "memory da applicare",
confirmLabel: "Applica le memory selezionate",
selectionLabel: gateText(locale, "memorySelection"),
confirmLabel: gateText(locale, "memoryApply"),
selectionLabelI18n: gateText("en", "memorySelection"),
confirmLabelI18n: gateText("en", "memoryApply"),
};
}
@@ -78,10 +79,11 @@ function shouldSkipEmptyRecall({ meritCount, allowEmpty, advance }) {
async function reviewRecall(ctx, params, phase, dependencies) {
const { workflow, ledger, reviewer, waitForReviewer, toTextResult } = dependencies;
const { workflow, ledger, reviewer, waitForReviewer, toTextResult, languageFor, gateText } = dependencies;
try {
const { session, advance } = params;
const options = normalizeMemoryOptions(params.options);
const locale = languageFor(ctx, session);
const options = normalizeMemoryOptions(params.options, locale, gateText);
const typeError = ledger.validate(ctx, options, session);
if (typeError) return toTextResult(typeError);
const meritOptions = options
@@ -99,7 +101,7 @@ async function reviewRecall(ctx, params, phase, dependencies) {
advance,
})) {
await ctx.ui.notify(
"Nessuna memory riutilizzabile per questa domanda — passo alla fase successiva.",
gateText(locale, "memoryEmpty"),
"info",
);
workflow.advance(ctx, session);
@@ -113,7 +115,7 @@ async function reviewRecall(ctx, params, phase, dependencies) {
phase,
allowEmpty: params.allow_empty ?? false,
options: meritOptions,
...memorySelectionWidgetProps(options),
...memorySelectionWidgetProps(options, locale, gateText),
});
const response = await waitForReviewer(ctx, widget);
if (response.control === "freetext") {
@@ -161,7 +163,7 @@ async function reviewRecall(ctx, params, phase, dependencies) {
}
function installMemoryGate(pi, { workflow, memory, ledger, waitForReviewer, toTextResult }) {
function installMemoryGate(pi, { workflow, memory, ledger, waitForReviewer, toTextResult, languageFor, gateText }) {
pi.registerTool({
name: "reviewer_memory_promote",
label: "Review Memory summary",
@@ -188,7 +190,8 @@ function installMemoryGate(pi, { workflow, memory, ledger, waitForReviewer, toTe
}
const response = await waitForReviewer(ctx, {
id: `u${Date.now()}`, schema_version: 1, phase: phase.id,
widget: "memory-review", title: "Memory for future questions",
widget: "memory-review", title: gateText(languageFor(ctx, session), "memorySummary"),
title_i18n: gateText("en", "memorySummary"),
summary, reserved: ["other", "back", "exit"],
});
if (response.control) return toTextResult(
+6 -3
View File
@@ -1,6 +1,6 @@
import { Type } from "typebox";
export function installRepairGate(pi, { workflow, memory, waitForReviewer, toTextResult }) {
export function installRepairGate(pi, { workflow, memory, waitForReviewer, toTextResult, languageFor, gateText }) {
pi.registerTool({
name: "reviewer_archive_repair",
label: "Resolve Memory / Evidence conflict",
@@ -26,6 +26,7 @@ export function installRepairGate(pi, { workflow, memory, waitForReviewer, toTex
try {
if (!!params.repair_id === !!params.proposal)
return toTextResult("Supply either a new proposal or an existing repair_id.");
const locale = languageFor(ctx, params.session);
const run = args => JSON.parse(memory.execute(ctx,
[...args, "--session", params.session, "--json"]));
let repair = params.repair_id
@@ -36,7 +37,9 @@ export function installRepairGate(pi, { workflow, memory, waitForReviewer, toTex
const response = await waitForReviewer(ctx, {
id: `u${Date.now()}`, schema_version: 1,
phase: workflow.phase(ctx, params.session).id,
widget: "archive-repair", title: "Resolve archive conflict",
widget: "archive-repair", title: gateText(locale, "archiveRepair"),
title_i18n: gateText("en", "archiveRepair"),
...(error ? { error_i18n: gateText("en", "repairError") } : {}),
repair, error, reserved: ["other", "back", "exit"],
});
if (response.control) return toTextResult(response.control === "freetext"
@@ -62,7 +65,7 @@ export function installRepairGate(pi, { workflow, memory, waitForReviewer, toTex
return toTextResult("All repair proposals were rejected as inadequate. " +
"No archive was changed. Reformulate the options for the reviewer.");
} catch (failure) {
error = "The correction could not complete. Review the current archive state before retrying.";
error = gateText(locale, "repairError");
repair = run(["repair-show", "--repair-id", repair.repair_id]);
}
}
+73 -23
View File
@@ -23,6 +23,7 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { Type } from "typebox";
import {
buildSelectRequest,
@@ -51,6 +52,7 @@ import {
NEW_SESSION_CLARIFICATION_KICKOFF,
} from "./gate/disambiguation/index.js";
import { isReserved } from "./gate/core/reserved-labels.mjs";
import { gateText } from "./gate/core/locale.js";
// Machine-readable RPC notification consumed by SessionBridge. Pi's extension API
// exposes notify/input but no custom client-event emitter, so this reserved prefix is
@@ -190,6 +192,8 @@ const NUOVA_DOMANDA_KICKOFF =
"orchestri, il reviewer decide, la CLI `tht` persiste; NON sei in modalita' autonoma).\n" +
'1. Esegui `tht session new "<la domanda dell\'utente nel messaggio sopra>"` e annota ' +
"l'id stampato nell'ultima riga.\n" +
"Leggi subito `tht session show <id> --json`: usa interaction_language del manifest " +
"per ogni domanda, spiegazione e scelta al reviewer, prima della prima interazione.\n" +
"2. Segui il workflow dalla Fase 1 (Chiarimento), usando l'id di sessione in ogni " +
"comando `tht`.\n" +
"Regole non negoziabili (valgono SEMPRE, anche senza la skill):\n" +
@@ -249,11 +253,9 @@ function textResult(text) {
// Load THT_* env vars from .env (project root = ctx.cwd).
function loadEnvFromDotenv(ctx) {
const fs = require("node:fs");
const path = require("node:path");
const envPath = path.join(ctx.cwd, ".env");
const envPath = join(ctx.cwd, ".env");
try {
const raw = fs.readFileSync(envPath, "utf8");
const raw = readFileSync(envPath, "utf8");
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
@@ -262,7 +264,8 @@ function loadEnvFromDotenv(ctx) {
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
if (key.startsWith("THT_") || key.startsWith("PSD_")) {
process.env[key] = value;
// Managed runtime configuration/principal values select the authoritative repository.
process.env[key] ??= value;
}
}
} catch {
@@ -276,6 +279,30 @@ function tht(ctx, args, input) {
return execFileSync("tht", args, { cwd: ctx.cwd, encoding: "utf8", input });
}
const contextLanguages = new WeakMap();
function sessionLanguage(ctx, sessionId) {
const manifest = JSON.parse(tht(ctx, ["session", "ensure-interaction-language", sessionId, "--json"]));
// Fail closed: an unreadable manifest must not silently select a browser/env default.
if (typeof manifest.interaction_language !== "string" || !manifest.interaction_language)
throw new Error("Session interaction language is unavailable");
new Intl.Locale(manifest.interaction_language);
new Intl.Locale(manifest.workspace_language);
contextLanguages.set(ctx, manifest.interaction_language);
return manifest;
}
function languageContext(manifest) {
return "\n\n<session-language>\n" +
`interaction_language=${manifest.interaction_language}\n` +
`workspace_language=${manifest.workspace_language}\n` +
"Use interaction_language for all newly generated reviewer questions, explanations, and choices. " +
"This persisted session setting is authoritative on every turn, including resume and steering. " +
"Preserve workspace documents, quoted sources, prior decisions, SQL, identifiers and literal values. " +
"Interpret domain terms in workspace_language. Instruction/example language does not change these settings.\n" +
"</session-language>";
}
// Runs a privileged tht call; converts any failure into an actionable textResult
// (never propagates a raw "Command failed" to the model).
function relayIfThtFails(ctx, args, recovery, input) {
@@ -589,7 +616,7 @@ async function reLoop(ctx) {
// hasUI is true in RPC too, so this warning would leak to the frontend.
if (ctx.mode === "tui")
await ctx.ui.notify(
"Esc non chiude il gate: usa Torna indietro / Esci / Altro dalle opzioni.",
gateText(contextLanguages.get(ctx), "escapeHelp"),
"warning",
);
}
@@ -601,6 +628,10 @@ export default function (pi) {
let lastSteered = false;
let pendingKickoff = null;
let activeSessionId = null;
const languageFor = (ctx, session) => {
activeSessionId = session;
return sessionLanguage(ctx, session).interaction_language;
};
const announcedPhases = new Map();
const announceCurrentPhase = async (ctx, session) => {
if (ctx.mode !== "rpc" || typeof session !== "string" || !session.trim()) return;
@@ -614,6 +645,8 @@ export default function (pi) {
}
};
const memoryGate = createMemoryGate({
languageFor,
gateText,
workflow: {
activate: () => {
lockActive = true;
@@ -631,7 +664,7 @@ export default function (pi) {
try {
const result = JSON.parse(tht(ctx, ["memory", ...args]));
if (result.indexed === false) {
await ctx.ui.notify("Memory saved. Index update is incomplete; an administrator can retry it in Memory management.", "warning");
await ctx.ui.notify(gateText(contextLanguages.get(ctx), "memoryIndexWarning"), "warning");
}
return null;
} catch (error) {
@@ -743,6 +776,7 @@ export default function (pi) {
const raw = (event.text ?? "").trimStart();
// entry detection: workflow-start funziona sia da TUI sia da comando RPC `prompt`.
if (/^\/(nuova-domanda|riprendi-sessione)\b/.test(raw)) {
activeSessionId = /^\/riprendi-sessione\s+(\S+)/.exec(raw)?.[1] ?? process.env.THT_SESSION ?? null;
// NOTE: the source runs an ollama preflight here; ThothII defers embeddings
// readiness to the session's first vector op. Entry detection only:
lockActive = true;
@@ -763,8 +797,7 @@ export default function (pi) {
return { action: "transform", text: trimmed.slice(1).trimStart() };
if (ctx.mode === "tui") {
await ctx.ui.notify(
"Durante la sessione rispondi con i widget del gate. " +
"Per inviare testo libero al modello inizia la riga con '!'.",
gateText(contextLanguages.get(ctx), "inputHelp"),
"warning",
);
}
@@ -774,9 +807,10 @@ export default function (pi) {
// 3) BEFORE_AGENT_START: one-shot kickoff injection (appends the operational
// instructions to the system prompt on the turn that starts/resumes a session).
pi.on("before_agent_start", async (event, ctx) => {
await announceCurrentPhase(ctx, process.env.THT_SESSION);
if (!pendingKickoff) return undefined;
const sessionId = process.env.THT_SESSION;
const sessionId = activeSessionId ?? process.env.THT_SESSION;
await announceCurrentPhase(ctx, sessionId);
const language = sessionId ? languageContext(sessionLanguage(ctx, sessionId)) : "";
if (!pendingKickoff) return language ? { systemPrompt: event.systemPrompt + language } : undefined;
const isProvidedNewSession =
Boolean(sessionId) && pendingKickoff === NUOVA_DOMANDA_KICKOFF_PROVIDED(sessionId);
const isResumeSession = pendingKickoff === RIPRENDI_KICKOFF();
@@ -802,7 +836,7 @@ export default function (pi) {
inject +
(retrievalPack
? "\n\n<retrieval-pack>\n" + retrievalPack + "\n</retrieval-pack>"
: ""),
: "") + language,
};
});
@@ -814,7 +848,7 @@ export default function (pi) {
lockActive = false;
lastSteered = false;
}
activeSessionId = null;
if (!pendingKickoff) activeSessionId = null;
announcedPhases.clear();
_phaseMetaCache = null;
});
@@ -824,7 +858,10 @@ export default function (pi) {
// completes, including auto-approved phases that never open a human widget.
pi.on("tool_result", async (event, ctx) => {
const session = typeof event.input?.session === "string" ? event.input.session : null;
if (session) await announceCurrentPhase(ctx, session);
if (session) {
activeSessionId = session;
await announceCurrentPhase(ctx, session);
}
});
// 5) AGENT_END prose safety net: if the model emits prose instead of a reviewer_*
@@ -936,15 +973,18 @@ export default function (pi) {
);
}
const locale = languageFor(ctx, session);
const options = [
{
id: "generate",
label: "Sì, genera il datamart",
label: gateText(locale, "datamartYes"),
label_i18n: gateText("en", "datamartYes"),
decision: { type: "datamart_requested", subject },
},
{
id: "skip",
label: "No, salta il datamart",
label: gateText(locale, "datamartNo"),
label_i18n: gateText("en", "datamartNo"),
decision: { type: "datamart_declined", subject },
recommended: true,
},
@@ -952,10 +992,13 @@ export default function (pi) {
const widget = buildSelectRequest({
id: `u${Date.now()}`,
phase: phaseId(ctx, curNum),
title: "Vuoi generare un datamart?",
title: gateText(locale, "datamartTitle"),
titleI18n: gateText("en", "datamartTitle"),
intro: null,
recommended: "skip",
options: options.map((option) => ({ id: option.id, label: option.label })),
options: options.map((option) => ({
id: option.id, label: option.label, label_i18n: option.label_i18n,
})),
});
const resp = await emitAndWait(ctx, widget);
const outcome = resolveSelectOutcome(options, resp);
@@ -1037,6 +1080,7 @@ export default function (pi) {
.every((o) => o.decision.type === "join_modified");
const widget = joinOnly
? buildJoinReviewRequest({
locale: languageFor(ctx, session),
id: `u${Date.now()}`,
phase,
title,
@@ -1293,6 +1337,7 @@ export default function (pi) {
lockActive = true;
try {
const { session, kind, title } = params;
const locale = languageFor(ctx, session);
let artifact = params.artifact;
const curNum = currentPhase(ctx, session);
const phase = phaseId(ctx, curNum);
@@ -1428,6 +1473,7 @@ export default function (pi) {
enriched,
show.decisions,
meta ? meta.emits : [],
locale,
);
} catch {
// ledger non leggibile: il riepilogo resta quello del modello
@@ -1436,18 +1482,22 @@ export default function (pi) {
}
const widget = buildArtifactGate({
locale,
id: `u${Date.now()}`,
phase,
title,
artifact,
action: { kind: "approve_reject", prompt: "Approvi o rifiuti?" },
action: {
kind: "approve_reject", prompt: gateText(locale, "approvalPrompt"),
prompt_i18n: gateText("en", "approvalPrompt"),
},
});
let outcome;
for (;;) {
const resp = await emitAndWait(ctx, widget);
outcome = resolveConfirmOutcome(resp);
if (outcome.kind !== "unknown") break;
await ctx.ui.notify("Scegli «Salva e procedi» o «Rifiuta».", "warning");
await ctx.ui.notify(gateText(locale, "chooseApproval"), "warning");
}
if (outcome.kind === "freetext")
return textResult(
@@ -1729,7 +1779,7 @@ export default function (pi) {
: parts[0];
const sid = sessionId ?? activeSessionId ?? process.env.THT_SESSION;
if (!sid) {
await ctx.ui.notify("Uso: /torna <session_id> [N]", "warning");
await ctx.ui.notify(gateText(contextLanguages.get(ctx), "backUsage"), "warning");
return;
}
const cur = currentPhase(ctx, sid);
@@ -1737,7 +1787,7 @@ export default function (pi) {
const target = targetArg ? parseInt(targetArg, 10) : cur - 1;
if (target < 1 || target >= cur) {
await ctx.ui.notify(
`Target non valido (fase corrente ${cur}).`,
gateText(contextLanguages.get(ctx), "backInvalid").replace("{phase}", String(cur)),
"warning",
);
return;
+3
View File
@@ -4,3 +4,6 @@ argument-hint: "<domanda>"
---
Nuova domanda ThothII: "$@"
Use the session manifest's interaction_language for reviewer dialogue. Workspace
content and SQL retain their original language and values. A managed session already
has its language pinned; standalone `tht session new` defaults to workspace language.
+3
View File
@@ -4,3 +4,6 @@ argument-hint: "<session-id>"
---
Riprendi la sessione ThothII "$1".
Use the persisted interaction_language. For a legacy manifest without it, run
`tht session ensure-interaction-language "$1" --json` before interacting. Resume
accepts no language override. Preserve workspace content, SQL and prior decisions.
+16 -7
View File
@@ -1,6 +1,6 @@
---
name: tht-sessione
description: Orchestrator of the Thoth NL->SQL workflow, phases 1-8 (question clarification, memories, rewriting, schema linking, synthesis, CTE plan, final SQL, datamart). Use when working a natural-language question inside a Thoth session.
description: Orchestrator of the Thoth NL-to-SQL workflow, phases 1-8 (question clarification, memories, rewriting, schema linking, synthesis, CTE plan, final SQL, datamart). Use when working a natural-language question inside a Thoth session.
---
# Thoth session workflow (phases 1-8)
@@ -17,11 +17,19 @@ confirmation and is persisted directly — an option without a payload only asks
Continue records the complete join set), `reviewer_confirm` (gate on an artifact / phase transition). Free text
arrives via the "Altro/Other" option or by prefixing `!` in chat.
**Language contract (from the workspace `language` field):** the table/column
descriptions and the evidence you read are written in the workspace language (e.g.
`it` for PSD). **These instructions are in English; your output to the reviewer and
your interpretation of domain terms follow the workspace language.** When in doubt
about a domain term, ask the reviewer.
**Language contract:** the session manifest's `interaction_language` controls all
new reviewer questions, explanations, option labels and rationales, including prose
you generate inside review artifacts. It remains authoritative throughout the session,
including resume and steering from a browser using a different UI locale. The gate
injects this persisted language into each model turn; the language of these instructions
and examples does not select the output language.
`workspace.language` controls workspace-owned documents, catalog descriptions, Evidence
and interpretation of domain terms. Preserve quoted source content and prior decisions
verbatim. Keep SQL, identifiers, literal values and workspace artifacts unchanged by
the interaction preference. Ask about ambiguous domain terms in the interaction language.
For a legacy manifest without the field, run `tht session ensure-interaction-language
<id> --json` before interacting: it pins workspace language once and accepts no override.
## Phase map (advance cheat-sheet)
@@ -153,7 +161,8 @@ When launched with `/riprendi-sessione <id>` you have NO prior conversation —
persisted state is your only context. Bootstrap before doing anything else:
1. `tht session show <id> --json` → read `phase` (the current phase N), `status`, and
the manifest (`question`, `database`, `schema`).
the manifest (`question`, `database`, `schema`, `interaction_language`). If language
is absent, run `tht session ensure-interaction-language <id> --json` and use its value.
2. Load the artifacts produced so far with `tht session documents <id> --json`, which
returns their keys and contents directly: `question.md` (revised question),
`schema_linking.json` (F4 output), `ctes/*.sql` + `cte_tests.json` (F6),
@@ -1,6 +1,6 @@
---
name: tht-sessione
description: Orchestrator of the Thoth NL->SQL workflow, phases 1-8 (question clarification, memories, rewriting, schema linking, synthesis, CTE plan, final SQL, datamart). Use when working a natural-language question inside a Thoth session.
description: Orchestrator of the Thoth NL-to-SQL workflow, phases 1-8 (question clarification, memories, rewriting, schema linking, synthesis, CTE plan, final SQL, datamart). Use when working a natural-language question inside a Thoth session.
---
# Thoth session workflow (phases 1-8)
@@ -17,11 +17,19 @@ confirmation and is persisted directly — an option without a payload only asks
Continue records the complete join set), `reviewer_confirm` (gate on an artifact / phase transition). Free text
arrives via the "Altro/Other" option or by prefixing `!` in chat.
**Language contract (from the workspace `language` field):** the table/column
descriptions and the evidence you read are written in the workspace language (e.g.
`it` for PSD). **These instructions are in English; your output to the reviewer and
your interpretation of domain terms follow the workspace language.** When in doubt
about a domain term, ask the reviewer.
**Language contract:** the session manifest's `interaction_language` controls all
new reviewer questions, explanations, option labels and rationales, including prose
you generate inside review artifacts. It remains authoritative throughout the session,
including resume and steering from a browser using a different UI locale. The gate
injects this persisted language into each model turn; the language of these instructions
and examples does not select the output language.
`workspace.language` controls workspace-owned documents, catalog descriptions, Evidence
and interpretation of domain terms. Preserve quoted source content and prior decisions
verbatim. Keep SQL, identifiers, literal values and workspace artifacts unchanged by
the interaction preference. Ask about ambiguous domain terms in the interaction language.
For a legacy manifest without the field, run `tht session ensure-interaction-language
<id> --json` before interacting: it pins workspace language once and accepts no override.
## Phase map (advance cheat-sheet)
@@ -151,7 +159,8 @@ When launched with `/riprendi-sessione <id>` you have NO prior conversation —
persisted state is your only context. Bootstrap before doing anything else:
1. `tht session show <id> --json` → read `phase` (the current phase N), `status`, and
the manifest (`question`, `database`, `schema`).
the manifest (`question`, `database`, `schema`, `interaction_language`). If language
is absent, run `tht session ensure-interaction-language <id> --json` and use its value.
2. Load the artifacts produced so far with `tht session documents <id> --json`, which
returns their keys and contents directly: `question.md` (revised question),
`schema_linking.json` (F4 output), `ctes/*.sql` + `cte_tests.json` (F6),
+4 -2
View File
@@ -12,8 +12,10 @@ The rewritten question must:
3. Replace ambiguous terms with the concepts clarified in Phase 1, citing the
evidence that defines them when it exists (evidence id in parentheses).
4. Make the expected output explicit: count, list, aggregate, trend.
5. Stay in the workspace language, in a form a second reviewer would understand
without having seen the conversation.
5. Write the persisted question document in the workspace language, in a form a second
reviewer would understand without having seen the conversation. Explanations and
choices addressed to the reviewer use the manifest's interaction_language; preserve
domain terms, identifiers and quoted values in either presentation.
Format of the proposal to the reviewer: