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.
233 lines
9.3 KiB
JavaScript
233 lines
9.3 KiB
JavaScript
import { Type } from "typebox";
|
|
import { installRepairGate } from "./repair.js";
|
|
|
|
// The composition root supplies manifest language; only deterministic chrome is localized.
|
|
|
|
function normalizedText(value) {
|
|
return String(value ?? "").trim().replace(/\s+/g, " ").toLowerCase();
|
|
}
|
|
|
|
|
|
const MEMORY_ID_RE = /\bmem-(?:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|\d{4,})\b/i;
|
|
|
|
|
|
function memoryOptionKey(option) {
|
|
const searchable = [
|
|
option.id,
|
|
option.label,
|
|
option.description,
|
|
option.decision?.rationale,
|
|
].join(" ");
|
|
const memoryId = searchable.match(MEMORY_ID_RE)?.[0]?.toLowerCase();
|
|
if (memoryId) return `id:${memoryId}`;
|
|
return [
|
|
"content",
|
|
normalizedText(option.decision?.type),
|
|
normalizedText(option.decision?.subject),
|
|
normalizedText(option.decision?.detail),
|
|
].join(":");
|
|
}
|
|
|
|
|
|
function normalizeMemoryOptions(options, locale, gateText) {
|
|
const seen = new Set();
|
|
const out = [];
|
|
for (const option of options) {
|
|
if (option.decision?.type !== "concept_clarified") continue;
|
|
const key = memoryOptionKey(option);
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
const memoryId = [
|
|
option.id,
|
|
option.label,
|
|
option.description,
|
|
option.decision?.rationale,
|
|
].join(" ").match(MEMORY_ID_RE)?.[0]?.toLowerCase();
|
|
const fallbackDetail = [
|
|
option.decision?.type && option.decision?.subject
|
|
? `${gateText(locale, "decision")} ${option.decision.type}: ${option.decision.subject}`
|
|
: "",
|
|
option.decision?.detail ?? "",
|
|
].filter(Boolean).join("\n");
|
|
out.push({
|
|
...option,
|
|
detail: option.description?.trim() || fallbackDetail,
|
|
rationale: option.decision?.rationale ?? "",
|
|
...(memoryId ? { meta: { memory_id: memoryId } } : {}),
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
|
|
function memorySelectionWidgetProps(options, locale, gateText) {
|
|
return {
|
|
title: gateText(locale, "memoryTitle"),
|
|
titleI18n: gateText("en", "memoryTitle"),
|
|
selected: options.filter((option) => option.recommended).map((option) => option.id),
|
|
selectionLabel: gateText(locale, "memorySelection"),
|
|
confirmLabel: gateText(locale, "memoryApply"),
|
|
selectionLabelI18n: gateText("en", "memorySelection"),
|
|
confirmLabelI18n: gateText("en", "memoryApply"),
|
|
};
|
|
}
|
|
|
|
|
|
function shouldSkipEmptyRecall({ meritCount, allowEmpty, advance }) {
|
|
return meritCount === 0 && !!allowEmpty && !!advance;
|
|
}
|
|
|
|
|
|
async function reviewRecall(ctx, params, phase, dependencies) {
|
|
const { workflow, ledger, reviewer, waitForReviewer, toTextResult, languageFor, gateText } = dependencies;
|
|
try {
|
|
const { session, advance } = params;
|
|
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
|
|
.filter((option) => !reviewer.isReserved(option.label))
|
|
.map((option) => ({
|
|
id: option.id,
|
|
label: option.label,
|
|
...(option.detail ? { detail: option.detail } : {}),
|
|
...(option.rationale ? { rationale: option.rationale } : {}),
|
|
...(option.meta ? { meta: option.meta } : {}),
|
|
}));
|
|
if (shouldSkipEmptyRecall({
|
|
meritCount: meritOptions.length,
|
|
allowEmpty: params.allow_empty ?? false,
|
|
advance,
|
|
})) {
|
|
await ctx.ui.notify(
|
|
gateText(locale, "memoryEmpty"),
|
|
"info",
|
|
);
|
|
workflow.advance(ctx, session);
|
|
return toTextResult(
|
|
"Fase memoria vuota: nessuna decisione da registrare, " +
|
|
"avanzamento automatico alla fase successiva.",
|
|
);
|
|
}
|
|
const widget = reviewer.buildMultiselect({
|
|
id: `u${Date.now()}`,
|
|
phase,
|
|
allowEmpty: params.allow_empty ?? false,
|
|
options: meritOptions,
|
|
...memorySelectionWidgetProps(options, locale, gateText),
|
|
});
|
|
const response = await waitForReviewer(ctx, widget);
|
|
if (response.control === "freetext") {
|
|
return toTextResult(
|
|
`Altro (reviewer): ${response.text}. Riformula la proposta tenendo conto.`,
|
|
);
|
|
}
|
|
if (response.control === "back") {
|
|
return toTextResult("Il reviewer vuole tornare indietro.");
|
|
}
|
|
if (response.control === "exit") {
|
|
return toTextResult("Il reviewer vuole uscire.");
|
|
}
|
|
const chosen = options.filter((option) =>
|
|
(response.choices ?? []).includes(option.id));
|
|
const recorded = [];
|
|
for (const choice of chosen) {
|
|
const error = ledger.record(ctx, session, choice.decision, "");
|
|
if (error) return error;
|
|
recorded.push(choice.decision);
|
|
}
|
|
if (!recorded.length) {
|
|
const result = advance ? workflow.advance(ctx, session) : { advanced: false };
|
|
const message =
|
|
"Nessuna decisione registrata (il reviewer non ha selezionato opzioni di merito).";
|
|
return toTextResult(
|
|
result.advanced ? message + " Fase avanzata automaticamente." : message,
|
|
);
|
|
}
|
|
const result = advance ? workflow.advance(ctx, session) : { advanced: false };
|
|
const parts = [
|
|
`Registrate ${recorded.length} decisioni: ` +
|
|
recorded.map((decision) => decision.type).join(", ") + ".",
|
|
];
|
|
parts.push(result.advanced
|
|
? "Fase avanzata automaticamente."
|
|
: 'La fase resta aperta: chiudila con reviewer_confirm kind:"phase" quando pronta.');
|
|
return toTextResult(parts.join(" "));
|
|
} catch (fatal) {
|
|
const message = (fatal.stderr || fatal.message || String(fatal)).toString().trim();
|
|
return toTextResult(
|
|
`[reviewer_decide ERRORE INTERNO] ${message}. Riprova o usa un approccio diverso.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
function installMemoryGate(pi, { workflow, memory, ledger, waitForReviewer, toTextResult, languageFor, gateText }) {
|
|
pi.registerTool({
|
|
name: "reviewer_memory_promote",
|
|
label: "Review Memory summary",
|
|
description: "F8: show the editable Memory summary from persisted proposals and approved " +
|
|
"decisions. The reviewer selects additions, updates and links. Only those choices " +
|
|
"are saved. Then close F8 and finalize. Prepare reusable rules during the workflow " +
|
|
"with tht memory propose; do not call review-apply yourself.",
|
|
parameters: Type.Object({ session: Type.String() }),
|
|
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
workflow.activate();
|
|
try {
|
|
const { session } = params;
|
|
const phase = workflow.phase(ctx, session);
|
|
if (phase.number > 8) return workflow.close(ctx, session, phase.number, "Memory reviewed.");
|
|
if (phase.number !== 8) return toTextResult("Review the Memory summary at the end of F8.");
|
|
const summary = JSON.parse(memory.execute(ctx, ["summary", "--session", session, "--json"]));
|
|
if (summary.reviewed) {
|
|
const failure = ledger.record(ctx, session, {
|
|
type: "memory_summary_reviewed", subject: summary.summary_id,
|
|
detail: "Previously saved Memory review recovered.",
|
|
}, "Retry to record the recovered Memory review.");
|
|
if (failure) return failure;
|
|
return workflow.close(ctx, session, phase.number, "Memory review recovered.");
|
|
}
|
|
const response = await waitForReviewer(ctx, {
|
|
id: `u${Date.now()}`, schema_version: 1, phase: phase.id,
|
|
widget: "memory-review", title: gateText(languageFor(ctx, session), "memorySummary"),
|
|
title_i18n: gateText("en", "memorySummary"),
|
|
summary, reserved: ["other", "back", "exit"],
|
|
});
|
|
if (response.control) return toTextResult(
|
|
response.control === "freetext"
|
|
? `Reviewer feedback: ${response.text}. Revise the proposals and present the summary again.`
|
|
: `Reviewer requested: ${response.control}.`);
|
|
const selection = JSON.parse(response.text || "{}");
|
|
if (selection.summary_id !== summary.summary_id || !Array.isArray(selection.items))
|
|
return toTextResult("Invalid Memory review response. Present the summary again.");
|
|
const failure = await memory.mutate(ctx, [
|
|
"review-apply", "--session", session, "--review-json", JSON.stringify(selection), "--json",
|
|
], "The Memory review is recoverable. Present the summary again if its content changed.");
|
|
if (failure) return failure;
|
|
const selected = new Set(selection.items.map(item => item.id));
|
|
const ledgerFailure = ledger.record(ctx, session, {
|
|
type: "memory_summary_reviewed", subject: summary.summary_id,
|
|
detail: `Saved ${selected.size} cards; declined ${summary.items.length-selected.size}.`,
|
|
}, "Memory is saved. Retry to record the review and finalize.");
|
|
if (ledgerFailure) return ledgerFailure;
|
|
const message = `Memory reviewed: ${selected.size} cards saved, ${summary.items.length-selected.size} declined.`;
|
|
return workflow.close(ctx, session, phase.number, message) || toTextResult(message);
|
|
} catch (error) {
|
|
return toTextResult(`[reviewer_memory_promote] ${String(error.stderr || error.message || error).trim()}`);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
|
|
export function createMemoryGate(dependencies) {
|
|
return {
|
|
install: (pi) => {
|
|
installMemoryGate(pi, dependencies);
|
|
installRepairGate(pi, dependencies);
|
|
},
|
|
reviewRecall: (ctx, params, phase) => reviewRecall(ctx, params, phase, dependencies),
|
|
};
|
|
}
|