diff --git a/apps/operator-backend/src/brains/codex_brain.ts b/apps/operator-backend/src/brains/codex_brain.ts index 4156f58..b995772 100644 --- a/apps/operator-backend/src/brains/codex_brain.ts +++ b/apps/operator-backend/src/brains/codex_brain.ts @@ -11,6 +11,13 @@ import { getSkillLibraryText } from "../skills/skill_library.js"; import { persistence } from "../persistence/persistence_manager.js"; import { retrieveMemoryContext } from "../memory/jsonl_memory_store.js"; import { formatProjectProfileForPrompt } from "../memory/project_profile.js"; +import { + beginRequirementsPlanningLease, + endRequirementsPlanningLease, + formatRequirementsForPrompt, + resolveRequirementsForChat, + type RequirementsReceipt +} from "../memory/requirements_store.js"; import { getPinnedGoal } from "../session_store.js"; import { compactIncomingToolResult } from "../tool_result_compaction.js"; import { formatActiveGoalContext, getActiveGoalForSession } from "../goals/service.js"; @@ -363,11 +370,32 @@ export async function decideCodexStreaming(req: ChatRequest, cb: StreamCallbacks const text = (req.user_text ?? "").toString(); let memBlock = ""; let projectProfileBlock = ""; + let requirementsBlock = ""; + let requirementsReceipt: RequirementsReceipt | null = null; + let requirementsError = ""; try { projectProfileBlock = formatProjectProfileForPrompt(); } catch { projectProfileBlock = ""; } + try { + requirementsReceipt = resolveRequirementsForChat(req); + requirementsBlock = formatRequirementsForPrompt(requirementsReceipt); + } catch (error) { + requirementsReceipt = null; + requirementsBlock = ""; + requirementsError = error instanceof Error ? error.message : String(error); + } + if (requirementsError) { + const message = `Durable requirements could not be read safely (${requirementsError}). I stopped before planning or tool actions.`; + cb.onDone?.(message); + return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: message, actions: [] }; + } + if (requirementsReceipt && requirementsReceipt.status !== "resolved") { + const message = `Durable requirements are ${requirementsReceipt.status}. I stopped before planning or tool actions; resolve or narrow the attached receipt first.`; + cb.onDone?.(message); + return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: message, actions: [], requirements_receipt: requirementsReceipt }; + } try { const query = text.trim() || (getPinnedGoal(req.session_id) ?? "") || ""; const mem = query ? retrieveMemoryContext({ queryText: query, maxEntries: 6 }) : []; @@ -397,6 +425,7 @@ export async function decideCodexStreaming(req: ChatRequest, cb: StreamCallbacks const blocks: string[] = []; if (activeGoalBlock) blocks.push(activeGoalBlock); if (projectProfileBlock) blocks.push(projectProfileBlock); + if (requirementsBlock) blocks.push(requirementsBlock); try { blocks.push(formatEnvironmentSummaryForPrompt()); } catch {} @@ -419,17 +448,50 @@ export async function decideCodexStreaming(req: ChatRequest, cb: StreamCallbacks } ] : // If the client sends an empty user_text (legacy tool-loop continuation), still nudge Codex. - [{ type: "text", text: activeGoalBlock ? `${activeGoalBlock}\n\n(continue)` : "(continue)", text_elements: [] as any[] }]; + [{ type: "text", text: [activeGoalBlock, requirementsBlock, "(continue)"].filter(Boolean).join("\n\n"), text_elements: [] as any[] }]; - const start = (await withTransportRetry(() => - c.request("turn/start", { - threadId, - input - }) - )) as any; + let requirementsLease: ReturnType | null = null; + if (requirementsReceipt) { + try { + const plannedReceiptSha256 = requirementsReceipt.receipt_sha256; + requirementsLease = beginRequirementsPlanningLease(plannedReceiptSha256); + const leasedReceipt = resolveRequirementsForChat(req); + if (leasedReceipt.status !== "resolved" || leasedReceipt.receipt_sha256 !== plannedReceiptSha256) { + endRequirementsPlanningLease(requirementsLease); + requirementsLease = null; + const message = leasedReceipt.status === "resolved" + ? "Durable requirements changed while the planning lease was being acquired. I stopped before tool actions; re-run the request against the attached current receipt." + : `Durable requirements became ${leasedReceipt.status} while the planning lease was being acquired. I stopped before tool actions; resolve or narrow the attached receipt first.`; + cb.onDone?.(message); + return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: message, actions: [], requirements_receipt: leasedReceipt }; + } + requirementsReceipt = leasedReceipt; + } catch (error) { + endRequirementsPlanningLease(requirementsLease); + requirementsLease = null; + const message = `Durable requirements could not be leased and revalidated safely (${error instanceof Error ? error.message : String(error)}). I stopped before planning or tool actions.`; + cb.onDone?.(message); + return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: message, actions: [], requirements_receipt: requirementsReceipt }; + } + } + let start: any; + try { + start = (await withTransportRetry(() => + c.request("turn/start", { + threadId, + input + }) + )) as any; + } catch (error) { + endRequirementsPlanningLease(requirementsLease); + throw error; + } const turnId = typeof start?.turn?.id === "string" ? start.turn.id : ""; - if (!turnId) throw new Error("Codex turn/start did not return a turn id."); + if (!turnId) { + endRequirementsPlanningLease(requirementsLease); + throw new Error("Codex turn/start did not return a turn id."); + } try { appendEvent(req.session_id, "assistant", "codex.turn.start", { thread_id: threadId, turn_id: turnId }); } catch { @@ -571,6 +633,7 @@ export async function decideCodexStreaming(req: ChatRequest, cb: StreamCallbacks ); } finally { unsubscribe(); + endRequirementsPlanningLease(requirementsLease); } cb.onDone?.(assistantText); @@ -587,6 +650,7 @@ export async function decideCodexStreaming(req: ChatRequest, cb: StreamCallbacks return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: assistantText || "", - actions: [] + actions: [], + ...(requirementsReceipt && (requirementsReceipt.status !== "resolved" || requirementsReceipt.applied.length > 0) ? { requirements_receipt: requirementsReceipt } : {}) }; } diff --git a/apps/operator-backend/src/brains/openai_brain.ts b/apps/operator-backend/src/brains/openai_brain.ts index c230e89..607212a 100644 --- a/apps/operator-backend/src/brains/openai_brain.ts +++ b/apps/operator-backend/src/brains/openai_brain.ts @@ -21,6 +21,7 @@ import { appendEvent, appendNotification, getRecentStepToolResults } from "../me import { persistence } from "../persistence/persistence_manager.js"; import { retrieveMemoryContext } from "../memory/jsonl_memory_store.js"; import { formatProjectProfileForPrompt } from "../memory/project_profile.js"; +import { captureRequirementsResponseGuard, enforceRequirementsResponseGuard, formatRequirementsPromptBlockSafely } from "../memory/requirements_response_policy.js"; import { createOpenAiClient, resolveOpenAiApiKey } from "../openai_client.js"; import { executeWorkbenchActions, maxWorkbenchActions, type WorkbenchAction, type WorkbenchActionResult } from "../workbench/workbench_runner.js"; import { createArtifactShare } from "../artifacts/artifact_bus.js"; @@ -14904,7 +14905,7 @@ async function buildPrompt(req: ChatRequest, lane?: { route: SpeedRouteKind; rea } try { - const profileBlock = formatProjectProfileForPrompt(); + const profileBlock = [formatProjectProfileForPrompt(), formatRequirementsPromptBlockSafely(req)].filter(Boolean).join("\n\n"); if (profileBlock) { lines.push(profileBlock); lines.push(""); @@ -16487,8 +16488,10 @@ export function __testOnlyFinalizeOpenAiResponseForRequest(req: ChatRequest, res } async function decideOpenAiInternal(req: ChatRequest, abortSignal?: AbortSignal): Promise { + const requirementsGuard = captureRequirementsResponseGuard(req); const finishResponse = (response: ChatResponse): ChatResponse => { response = __testOnlyFinalizeOpenAiResponseForRequest(req, response); + response = enforceRequirementsResponseGuard(req, response, requirementsGuard); if (Array.isArray(response.actions) && response.actions.some((action) => typeof action?.path === "string" && action.path.startsWith("/revit/"))) { const state = getRedlineFastPathState(req.session_id); if (!state.phases.first_revit_action_emitted) { @@ -16500,6 +16503,8 @@ async function decideOpenAiInternal(req: ChatRequest, abortSignal?: AbortSignal) return response; }; + if (requirementsGuard.blocker) return finishResponse(requirementsGuard.blocker); + if (isRedlineFocusedTurn(req)) { noteRedlineFastPathPhase(req.session_id, "request_accepted"); if ( diff --git a/apps/operator-backend/src/contracts.ts b/apps/operator-backend/src/contracts.ts index 28a6c90..1124176 100644 --- a/apps/operator-backend/src/contracts.ts +++ b/apps/operator-backend/src/contracts.ts @@ -76,4 +76,39 @@ export type ChatResponse = { bounded: true; broadened: false; }; + requirements_receipt?: { + schema: "revit-operator.requirements-receipt.v1"; + generated_at: string; + status: "resolved" | "conflict" | "overflow"; + query: string; + scope_refs: Array<{ kind: "office" | "engineer" | "project" | "client"; id: string }>; + applied: Array<{ + requirement_id: string; + revision: number; + scope: { kind: "office" | "engineer" | "project" | "client"; id: string }; + key: string; + text: string; + reason: "highest_precedence" | "duplicate" | "lower_precedence" | "superseded"; + }>; + suppressed: Array<{ + requirement_id: string; + revision: number; + scope: { kind: "office" | "engineer" | "project" | "client"; id: string }; + key: string; + text: string; + reason: "highest_precedence" | "duplicate" | "lower_precedence" | "superseded"; + }>; + conflicts: Array<{ + key: string; + precedence: number; + requirements: Array<{ + requirement_id: string; + revision: number; + scope: { kind: "office" | "engineer" | "project" | "client"; id: string }; + text: string; + }>; + }>; + overflow: { applied_count: number; suppressed_count: number; conflict_count: number; max_results: number } | null; + receipt_sha256: string; + }; }; diff --git a/apps/operator-backend/src/deterministic/aec_level_power_plan_runtime.ts b/apps/operator-backend/src/deterministic/aec_level_power_plan_runtime.ts index a0d0e7d..cba2e48 100644 --- a/apps/operator-backend/src/deterministic/aec_level_power_plan_runtime.ts +++ b/apps/operator-backend/src/deterministic/aec_level_power_plan_runtime.ts @@ -1,6 +1,7 @@ import type { ChatRequest, ChatResponse, ToolResult } from "../contracts.js"; import type { AecSemanticTaskV1 } from "../aec_semantic_task.js"; import { appendGoalProgress, getActiveGoalForSession, updateGoal } from "../goals/service.js"; +import { resolveRequirements, resolveRequirementsForChat, type RequirementsReceipt } from "../memory/requirements_store.js"; type ResolvedView = { id: number; name: string; levelName: string | null }; type Inventory = { view: ResolvedView; elementIds: number[]; inventoryActionId: string }; @@ -12,6 +13,7 @@ type RuntimeState = { inventories: Inventory[]; summaries: Map>>; evidenceActionIds: string[]; + requirementsReceipt: RequirementsReceipt | null; }; const states = new Map(); @@ -19,13 +21,13 @@ const POWER_INSPECTION_CATEGORIES = ["OST_ElectricalFixtures", "OST_ElectricalEq const MAX_PILOT_VIEWS = 2; const MAX_ELEMENTS_PER_VIEW = 500; -function response(message: string, actions: ChatResponse["actions"] = []): ChatResponse { - return { version: "operator.backend.v1", assistant_message: message, actions }; +function response(message: string, actions: ChatResponse["actions"] = [], requirementsReceipt: RequirementsReceipt | null = null): ChatResponse { + return { version: "operator.backend.v1", assistant_message: message, actions, ...(requirementsReceipt ? { requirements_receipt: requirementsReceipt } : {}) }; } -function terminal(message: string, status: "complete" | "failed"): ChatResponse { +function terminal(message: string, status: "complete" | "failed", requirementsReceipt: RequirementsReceipt | null = null): ChatResponse { return { - ...response(message), + ...response(message, [], requirementsReceipt), aec_query_receipt: { schema: "revit-operator.aec-query-receipt.v1", terminal: true, @@ -137,7 +139,7 @@ function block(req: ChatRequest, state: RuntimeState, message: string, results: })) }); states.delete(req.session_id); - return terminal(`${message} I made no model changes and did not widen the verified Level scope.`, "failed"); + return terminal(`${message} I made no model changes and did not widen the verified Level scope.`, "failed", state.requirementsReceipt); } function finish(req: ChatRequest, state: RuntimeState, frameResults: ToolResult[]): ChatResponse { @@ -206,10 +208,18 @@ function finish(req: ChatRequest, state: RuntimeState, frameResults: ToolResult[ if (goal) updateGoal(goal.id, { current_phase: "precedent_resolution", current_step: "Resolve exact current-project or office-standard power-plan precedent" }); states.delete(req.session_id); const details = findings.map(({ inventory, summary }) => `${inventory.view.name} (${inventory.view.id}): ${summary}`).join(" "); - return terminal(`I completed the bounded Level power-plan pilot inspection. ${details} I preserved the per-view evidence and next action—resolve an exact precedent before any dry-run or model write.`, "complete"); + return terminal(`I completed the bounded Level power-plan pilot inspection. ${details} I preserved the per-view evidence and next action—resolve an exact precedent before any dry-run or model write.`, "complete", state.requirementsReceipt); } function continueRuntime(req: ChatRequest, state: RuntimeState): ChatResponse { + try { + const currentReceipt = resolveRequirements({ scope_refs: state.requirementsReceipt?.scope_refs ?? [], query: state.requirementsReceipt?.query ?? "", max_results: 40 }); + if (currentReceipt.status !== "resolved" || currentReceipt.receipt_sha256 !== state.requirementsReceipt?.receipt_sha256) { + return block(req, { ...state, requirementsReceipt: currentReceipt }, "Durable requirements changed or became blocked after this power-plan envelope was created; re-plan from the current receipt.", []); + } + } catch (error) { + return block(req, state, `Durable requirements could not be revalidated safely (${error instanceof Error ? error.message : String(error)}).`, []); + } const results = matchingResults(req, state.actionIds); if (results.length !== state.actionIds.length) return block(req, state, "One or more bounded power-plan inspection actions are missing.", results); @@ -221,10 +231,10 @@ function continueRuntime(req: ChatRequest, state: RuntimeState): ChatResponse { if (actions.length === 0) { const frames = frameActions(state.views); states.set(req.session_id, { ...state, stage: "frame", actionIds: frames.map(action => action.action_id), inventories: accepted, evidenceActionIds: [...state.evidenceActionIds, ...results.map(result => result.action_id)] }); - return response("The exact power views are empty for the bounded inspection categories. I am exporting focused before-state frames to preserve that finding.", frames); + return response("The exact power views are empty for the bounded inspection categories. I am exporting focused before-state frames to preserve that finding.", frames, state.requirementsReceipt); } states.set(req.session_id, { ...state, stage: "summary", actionIds: actions.map(action => action.action_id), inventories: accepted, evidenceActionIds: [...state.evidenceActionIds, ...results.map(result => result.action_id)] }); - return response("I completed exact per-view power inventories and am reading back only those bounded element IDs before visual inspection.", actions); + return response("I completed exact per-view power inventories and am reading back only those bounded element IDs before visual inspection.", actions, state.requirementsReceipt); } if (state.stage === "summary") { @@ -240,7 +250,7 @@ function continueRuntime(req: ChatRequest, state: RuntimeState): ChatResponse { } const frames = frameActions(state.views); states.set(req.session_id, { ...state, stage: "frame", actionIds: frames.map(action => action.action_id), summaries, evidenceActionIds: [...state.evidenceActionIds, ...results.map(result => result.action_id)] }); - return response("The bounded IDs read back exactly. I am exporting one focused visual frame per resolved view before recording findings and next actions.", frames); + return response("The bounded IDs read back exactly. I am exporting one focused visual frame per resolved view before recording findings and next actions.", frames, state.requirementsReceipt); } return finish(req, state, results); @@ -248,18 +258,34 @@ function continueRuntime(req: ChatRequest, state: RuntimeState): ChatResponse { export function startAecLevelPowerPlanPilot(req: ChatRequest, task: AecSemanticTaskV1, views: ResolvedView[]): ChatResponse | null { if (!isPowerPlanTask(task)) return null; + const requirementsReceipt = resolveRequirementsForChat(req); + if (requirementsReceipt.status !== "resolved") { + appendGoalProgress(req.session_id, { + summary: `Durable requirements ${requirementsReceipt.status} for the bounded Level power-plan workflow; planning stopped before Revit actions.`, + work_item: { id: "requirements.resolve", title: "Resolve durable requirements", status: "blocked", blocker: `requirements receipt ${requirementsReceipt.receipt_sha256}` } + }); + return terminal(`Durable requirements are ${requirementsReceipt.status} for this Level power-plan request. I stopped before Revit actions; resolve or narrow the attached receipt.`, "failed", requirementsReceipt); + } if (views.length === 0 || views.length > MAX_PILOT_VIEWS) { const blocker = views.length === 0 ? "No exact power-plan view was resolved." : `The pilot supports at most ${MAX_PILOT_VIEWS} exact power-plan views, but ${views.length} were resolved.`; appendGoalProgress(req.session_id, { summary: blocker, work_item: { id: "scope.inspect", title: "Inspect current model and view state in the resolved scope", status: "blocked", blocker } }); - return terminal(`${blocker} I stopped without model changes rather than silently sampling or widening the scope.`, "failed"); + return terminal(`${blocker} I stopped without model changes rather than silently sampling or widening the scope.`, "failed", requirementsReceipt); } const actions = inventoryActions(views); - states.set(req.session_id, { task, views, stage: "inventory", actionIds: actions.map(action => action.action_id), inventories: [], summaries: new Map(), evidenceActionIds: [] }); + states.set(req.session_id, { task, views, stage: "inventory", actionIds: actions.map(action => action.action_id), inventories: [], summaries: new Map(), evidenceActionIds: [], requirementsReceipt }); appendGoalProgress(req.session_id, { summary: `Starting a bounded read-only power-plan pilot in ${views.length} exact view(s).`, - assumptions: [{ id: "power.no_implicit_write", statement: "The pilot inspects and records exact per-view evidence before any design proposal; no broad write scope is inferred.", status: "accepted", basis: "underspecified Level power-plan request" }] + assumptions: [ + { id: "power.no_implicit_write", statement: "The pilot inspects and records exact per-view evidence before any design proposal; no broad write scope is inferred.", status: "accepted", basis: "underspecified Level power-plan request" }, + { + id: "requirements.receipt", + statement: `Durable requirements receipt ${requirementsReceipt.receipt_sha256}; applied ${requirementsReceipt.applied.map(row => `${row.requirement_id}@${row.revision}`).join(", ") || "none"}.`, + status: "accepted", + basis: "deterministic scoped requirements resolution" + } + ] }); - return response(`I resolved ${views.length} exact power-plan view(s). I am now inspecting only bounded electrical fixtures, equipment, and wires in those views; no model write is planned.`, actions); + return response(`I resolved ${views.length} exact power-plan view(s). I am now inspecting only bounded electrical fixtures, equipment, and wires in those views; no model write is planned.`, actions, requirementsReceipt); } export function maybeContinueAecLevelPowerPlanPilot(req: ChatRequest): ChatResponse | null { diff --git a/apps/operator-backend/src/index.ts b/apps/operator-backend/src/index.ts index 05d856d..8c2291e 100644 --- a/apps/operator-backend/src/index.ts +++ b/apps/operator-backend/src/index.ts @@ -51,6 +51,14 @@ import { analyzeRedlinePackageWithGemini } from "./vision/gemini_redline_package import { buildEvidencePack } from "./evidence/evidence_pack.js"; import { maybePersistAutoTurnMemory } from "./memory/auto_turn_memory.js"; import { addProjectStandard, readProjectProfile } from "./memory/project_profile.js"; +import { + createRequirement, + listRequirements, + resolveRequirements, + retireRequirement, + reviseRequirement +} from "./memory/requirements_store.js"; +import { requiresMemoryAuthentication } from "./memory/requirements_route_policy.js"; import { createOpenAiClient, resolveOpenAiApiKey } from "./openai_client.js"; import { getDesktopComputerConfig, relayDesktopComputerResponse } from "./desktop_computer.js"; import { authenticateRequest, resolveAuthMode } from "./auth.js"; @@ -610,6 +618,7 @@ function getHeader(req: http.IncomingMessage, name: string): string { function requiresOperatorToken(pathname: string): boolean { if (pathname.startsWith("/tools/zippybim/")) return true; + if (requiresMemoryAuthentication(pathname)) return true; // Intentionally include /health to avoid drive-by localhost probing of backend state. return ( pathname === "/chat" || @@ -2626,6 +2635,102 @@ const server = http.createServer(async (req, res) => { } } + if (req.method === "GET" && url.pathname === "/memory/requirements") { + try { + const scopeKind = url.searchParams.get("scope_kind"); + const scopeId = url.searchParams.get("scope_id"); + const statusRaw = url.searchParams.get("status"); + const status = statusRaw === "retired" || statusRaw === "all" ? statusRaw : "active"; + const limit = Number.parseInt(url.searchParams.get("limit") ?? "200", 10); + const scope = scopeKind && scopeId ? { kind: scopeKind as any, id: scopeId } : undefined; + return writeJson(res, 200, { ok: true, requirements: listRequirements({ scope, status, limit }) }); + } catch (e) { + return writeJson(res, 400, { ok: false, error: e instanceof Error ? e.message : String(e) }); + } + } + + if (req.method === "POST" && url.pathname === "/memory/requirements") { + const body = await readJson(req).catch(() => null); + try { + const parsed = body && typeof body === "object" ? body as any : {}; + const saved = createRequirement({ + scope: parsed.scope, + key: parsed.key, + text: parsed.text, + tags: parsed.tags, + effective_from: parsed.effective_from, + effective_until: parsed.effective_until, + supersedes_requirement_ids: parsed.supersedes_requirement_ids, + source: parsed.source ?? "api", + session_id: parsed.session_id, + actor_id: auth.principal?.user_id ?? null, + evidence: parsed.evidence + }); + appendAuditLine({ type: "requirements.created", ts: new Date().toISOString(), requirement_id: saved.requirement.requirement_id, revision: saved.requirement.revision, principal: auth.principal ?? null }); + return writeJson(res, 201, saved); + } catch (e) { + return writeJson(res, 400, { ok: false, error: e instanceof Error ? e.message : String(e) }); + } + } + + if (req.method === "POST" && url.pathname === "/memory/requirements/revise") { + const body = await readJson(req).catch(() => null); + try { + const parsed = body && typeof body === "object" ? body as any : {}; + const saved = reviseRequirement({ + requirement_id: parsed.requirement_id, + expected_revision: Number(parsed.expected_revision), + scope: parsed.scope, + key: parsed.key, + text: parsed.text, + tags: parsed.tags, + effective_from: parsed.effective_from, + effective_until: parsed.effective_until, + supersedes_requirement_ids: parsed.supersedes_requirement_ids, + source: parsed.source ?? "api.revise", + session_id: parsed.session_id, + actor_id: auth.principal?.user_id ?? null, + evidence: parsed.evidence + }); + appendAuditLine({ type: "requirements.revised", ts: new Date().toISOString(), requirement_id: saved.requirement.requirement_id, revision: saved.requirement.revision, principal: auth.principal ?? null }); + return writeJson(res, 200, saved); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return writeJson(res, /Revision conflict/.test(message) ? 409 : 400, { ok: false, error: message }); + } + } + + if (req.method === "POST" && url.pathname === "/memory/requirements/retire") { + const body = await readJson(req).catch(() => null); + try { + const parsed = body && typeof body === "object" ? body as any : {}; + const saved = retireRequirement({ + requirement_id: parsed.requirement_id, + expected_revision: Number(parsed.expected_revision), + source: parsed.source ?? "api.retire", + session_id: parsed.session_id, + actor_id: auth.principal?.user_id ?? null, + evidence: parsed.evidence + }); + appendAuditLine({ type: "requirements.retired", ts: new Date().toISOString(), requirement_id: saved.requirement.requirement_id, revision: saved.requirement.revision, principal: auth.principal ?? null }); + return writeJson(res, 200, saved); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + return writeJson(res, /Revision conflict/.test(message) ? 409 : 400, { ok: false, error: message }); + } + } + + if (req.method === "POST" && url.pathname === "/memory/requirements/resolve") { + const body = await readJson(req).catch(() => null); + try { + const parsed = body && typeof body === "object" ? body as any : {}; + const receipt = resolveRequirements({ scope_refs: parsed.scope_refs, query: parsed.query, at: parsed.at, max_results: parsed.max_results }); + return writeJson(res, receipt.status === "resolved" ? 200 : 409, { ok: receipt.status === "resolved", receipt }); + } catch (e) { + return writeJson(res, 400, { ok: false, error: e instanceof Error ? e.message : String(e) }); + } + } + if (req.method === "POST" && url.pathname === "/memory/project-profile/standards") { const body = await readJson(req).catch(() => null); const parsed = body && typeof body === "object" ? (body as any) : {}; diff --git a/apps/operator-backend/src/memory/requirements_response_policy.ts b/apps/operator-backend/src/memory/requirements_response_policy.ts new file mode 100644 index 0000000..0ed1237 --- /dev/null +++ b/apps/operator-backend/src/memory/requirements_response_policy.ts @@ -0,0 +1,78 @@ +import { OPERATOR_BACKEND_CONTRACT_VERSION, type ChatRequest, type ChatResponse } from "../contracts.js"; +import { + formatRequirementsForPrompt, + resolveRequirementsForChat, + type RequirementsReceipt +} from "./requirements_store.js"; + +export type RequirementsResponseGuard = { + initial_receipt: RequirementsReceipt | null; + initial_error: string; + blocker: ChatResponse | null; +}; + +export function formatRequirementsPromptBlockSafely(req: ChatRequest): string { + try { + return formatRequirementsForPrompt(resolveRequirementsForChat(req)); + } catch { + return ""; + } +} + +export function captureRequirementsResponseGuard(req: ChatRequest): RequirementsResponseGuard { + try { + const initial_receipt = resolveRequirementsForChat(req); + const blocker: ChatResponse | null = initial_receipt.status === "resolved" + ? null + : { + version: OPERATOR_BACKEND_CONTRACT_VERSION, + assistant_message: `Durable requirements are ${initial_receipt.status}. I stopped before planning or Revit actions; resolve or narrow the attached receipt first.`, + actions: [], + requirements_receipt: initial_receipt + }; + return { initial_receipt, initial_error: "", blocker }; + } catch (error) { + const initial_error = error instanceof Error ? error.message : String(error); + return { + initial_receipt: null, + initial_error, + blocker: { + version: OPERATOR_BACKEND_CONTRACT_VERSION, + assistant_message: `Durable requirements could not be read safely (${initial_error}). I stopped before planning or Revit actions.`, + actions: [] + } + }; + } +} + +export function enforceRequirementsResponseGuard(req: ChatRequest, response: ChatResponse, guard: RequirementsResponseGuard): ChatResponse { + try { + const requirements_receipt = resolveRequirementsForChat(req); + const hasActions = Array.isArray(response.actions) && response.actions.length > 0; + if (hasActions && guard.initial_receipt && requirements_receipt.receipt_sha256 !== guard.initial_receipt.receipt_sha256) { + response = { + ...response, + assistant_message: "Durable requirements changed while this plan was being prepared. I stopped before Revit actions; re-run the request against the attached current receipt.", + actions: [] + }; + } else if (hasActions && requirements_receipt.status !== "resolved") { + response = { + ...response, + assistant_message: `Durable requirements are ${requirements_receipt.status}. I stopped before Revit actions; resolve or narrow the attached receipt first.`, + actions: [] + }; + } + if (requirements_receipt.status !== "resolved" || requirements_receipt.applied.length > 0) { + response = { ...response, requirements_receipt }; + } + } catch (error) { + if (Array.isArray(response.actions) && response.actions.length > 0) { + response = { + ...response, + assistant_message: `Durable requirements could not be read safely (${error instanceof Error ? error.message : String(error)}). I stopped before Revit actions.`, + actions: [] + }; + } + } + return response; +} diff --git a/apps/operator-backend/src/memory/requirements_route_policy.ts b/apps/operator-backend/src/memory/requirements_route_policy.ts new file mode 100644 index 0000000..8cd9a10 --- /dev/null +++ b/apps/operator-backend/src/memory/requirements_route_policy.ts @@ -0,0 +1,4 @@ +export function requiresMemoryAuthentication(pathname: string): boolean { + const normalized = String(pathname ?? "").trim(); + return normalized === "/memory" || normalized.startsWith("/memory/"); +} diff --git a/apps/operator-backend/src/memory/requirements_store.ts b/apps/operator-backend/src/memory/requirements_store.ts new file mode 100644 index 0000000..ae7fad9 --- /dev/null +++ b/apps/operator-backend/src/memory/requirements_store.ts @@ -0,0 +1,635 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createHash, randomUUID } from "node:crypto"; +import { OPERATOR_BACKEND_CONTRACT_VERSION, type ChatRequest } from "../contracts.js"; +import { atomicAppendJsonlLine } from "../persistence/jsonl.js"; +import { getRequestPrincipal } from "../request_context.js"; +import { ensureWorkspaceLayout } from "../workspace.js"; + +export const REQUIREMENTS_SCHEMA = "revit-operator.requirement-revision.v1" as const; +export const REQUIREMENTS_RECEIPT_SCHEMA = "revit-operator.requirements-receipt.v1" as const; + +export type RequirementScopeKind = "office" | "engineer" | "project" | "client"; +export type RequirementStatus = "active" | "retired"; +export type RequirementScopeRef = { kind: RequirementScopeKind; id: string }; +export type RequirementEvidenceRef = { type: string; ref: string; note?: string }; + +export type RequirementRevision = { + schema: typeof REQUIREMENTS_SCHEMA; + event_id: string; + requirement_id: string; + revision: number; + status: RequirementStatus; + scope: RequirementScopeRef; + key: string; + text: string; + tags: string[]; + effective_from: string; + effective_until: string | null; + supersedes_requirement_ids: string[]; + provenance: { + source: string; + captured_at: string; + session_id: string | null; + actor_id: string | null; + evidence: RequirementEvidenceRef[]; + }; +}; + +export type RequirementReceiptEntry = { + requirement_id: string; + revision: number; + scope: RequirementScopeRef; + key: string; + text: string; + reason: "highest_precedence" | "duplicate" | "lower_precedence" | "superseded"; +}; + +export type RequirementsReceipt = { + schema: typeof REQUIREMENTS_RECEIPT_SCHEMA; + generated_at: string; + status: "resolved" | "conflict" | "overflow"; + query: string; + scope_refs: RequirementScopeRef[]; + applied: RequirementReceiptEntry[]; + suppressed: RequirementReceiptEntry[]; + conflicts: Array<{ + key: string; + precedence: number; + requirements: Array>; + }>; + overflow: { applied_count: number; suppressed_count: number; conflict_count: number; max_results: number } | null; + receipt_sha256: string; +}; + +type RequirementWriteBase = { + scope: RequirementScopeRef; + key: string; + text: string; + tags?: string[]; + effective_from?: string | null; + effective_until?: string | null; + supersedes_requirement_ids?: string[]; + source?: string | null; + session_id?: string | null; + actor_id?: string | null; + evidence?: RequirementEvidenceRef[]; +}; + +const MAX_LEDGER_BYTES = 16 * 1024 * 1024; +const MAX_REQUIREMENTS = 10_000; +const MAX_TEXT_CHARS = 2_000; +const MAX_QUERY_CHARS = 1_000; +const SCOPE_PRECEDENCE: Record = { + engineer: 100, + office: 200, + client: 300, + project: 400 +}; +const activePlanningLeases = new Map>(); + +function nowIso(): string { + return new Date().toISOString(); +} + +function ledgerPath(): string { + return path.join(ensureWorkspaceLayout().memory, "requirements.v1.jsonl"); +} + +export type RequirementsPlanningLease = { ledger_path: string; lease_path: string; token: string; receipt_sha256: string }; + +function planningLeaseDirectory(p = ledgerPath()): string { + return `${p}.planning-leases`; +} + +export function beginRequirementsPlanningLease(receiptSha256: string): RequirementsPlanningLease { + const hash = String(receiptSha256 ?? "").trim().toLowerCase(); + if (!/^[a-f0-9]{64}$/.test(hash)) throw new Error("receipt_sha256 is invalid for a requirements planning lease."); + return withRequirementsWriteLock(() => { + const ledger_path = ledgerPath(); + const token = `reqlease_${randomUUID()}`; + const leaseDir = planningLeaseDirectory(ledger_path); + const lease_path = path.join(leaseDir, `${token}.json`); + fs.mkdirSync(leaseDir, { recursive: true }); + let fd: number | null = null; + try { + fd = fs.openSync(lease_path, "wx"); + fs.writeFileSync(fd, JSON.stringify({ token, receipt_sha256: hash, pid: process.pid, created_at: nowIso() }) + "\n", "utf8"); + fs.fsyncSync(fd); + } catch (error) { + if (fd !== null) { + try { fs.closeSync(fd); } catch { /* best effort */ } + try { fs.unlinkSync(lease_path); } catch { /* best effort */ } + } + throw error; + } + try { fs.closeSync(fd); } catch { /* best effort */ } + const leases = activePlanningLeases.get(ledger_path) ?? new Map(); + leases.set(token, hash); + activePlanningLeases.set(ledger_path, leases); + return { ledger_path, lease_path, token, receipt_sha256: hash }; + }); +} + +export function endRequirementsPlanningLease(lease: RequirementsPlanningLease | null | undefined): void { + if (!lease) return; + const leases = activePlanningLeases.get(lease.ledger_path); + if (leases) { + leases.delete(lease.token); + if (leases.size === 0) activePlanningLeases.delete(lease.ledger_path); + } + const expectedDir = planningLeaseDirectory(lease.ledger_path); + if (path.dirname(lease.lease_path) === expectedDir && path.basename(lease.lease_path) === `${lease.token}.json`) { + try { fs.unlinkSync(lease.lease_path); } catch { /* a missing lease is harmless during release */ } + } +} + +function assertRequirementsWriteUnlocked(p = ledgerPath()): void { + const leases = activePlanningLeases.get(p); + if (leases && leases.size > 0) throw new Error("Requirements write blocked by an active planning lease; retry after the current plan finishes."); + const leaseDir = planningLeaseDirectory(p); + try { + if (fs.readdirSync(leaseDir).length > 0) throw new Error("Requirements write blocked by an active planning lease; retry after the current plan finishes."); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code ?? "") : ""; + if (code !== "ENOENT") throw error; + } +} + +function waitMilliseconds(ms: number): void { + const signal = new Int32Array(new SharedArrayBuffer(4)); + Atomics.wait(signal, 0, 0, ms); +} + +function withRequirementsWriteLock(fn: () => T): T { + const p = ledgerPath(); + const lockPath = `${p}.write.lock`; + fs.mkdirSync(path.dirname(lockPath), { recursive: true }); + const deadline = Date.now() + 2_000; + let fd: number | null = null; + while (fd === null) { + let candidate: number | null = null; + try { + candidate = fs.openSync(lockPath, "wx"); + fs.writeFileSync(candidate, JSON.stringify({ pid: process.pid, created_at: nowIso() }) + "\n", "utf8"); + fs.fsyncSync(candidate); + fd = candidate; + } catch (error) { + if (candidate !== null) { + try { fs.closeSync(candidate); } catch { /* best effort */ } + try { fs.unlinkSync(lockPath); } catch { /* best effort */ } + } + const code = error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code ?? "") : ""; + if (code !== "EEXIST") throw error; + if (Date.now() >= deadline) throw new Error(`Requirements write lock is busy or stale: ${lockPath}`); + waitMilliseconds(25); + } + } + try { + assertRequirementsWriteUnlocked(p); + return fn(); + } finally { + try { fs.closeSync(fd); } catch { /* best effort */ } + try { fs.unlinkSync(lockPath); } catch { /* a missing lock is harmless after the write completed */ } + } +} + +function compactText(value: unknown, max: number, label: string): string { + const text = String(value ?? "").replace(/\s+/g, " ").trim(); + if (!text) throw new Error(`${label} is required.`); + if (text.length > max) throw new Error(`${label} exceeds ${max} characters.`); + return text; +} + +function normalizeId(value: unknown, label: string): string { + const raw = compactText(value, 180, label); + const clean = raw.toLowerCase().replace(/[^a-z0-9._:-]+/g, "_").replace(/^_+|_+$/g, ""); + if (!clean) throw new Error(`${label} is invalid.`); + return clean; +} + +function normalizeRequirementId(value: unknown, label = "requirement_id"): string { + const raw = compactText(value, 160, label); + if (!/^req_[a-zA-Z0-9-]{8,}$/.test(raw)) throw new Error(`${label} is invalid.`); + return raw; +} + +function normalizeScope(value: unknown): RequirementScopeRef { + const row = value && typeof value === "object" ? value as Record : {}; + const kind = String(row.kind ?? "").trim().toLowerCase() as RequirementScopeKind; + if (!(kind in SCOPE_PRECEDENCE)) throw new Error("scope.kind must be office, engineer, project, or client."); + return { kind, id: normalizeId(row.id, "scope.id") }; +} + +function normalizeKey(value: unknown): string { + const raw = compactText(value, 160, "key").toLowerCase(); + const clean = raw.replace(/[^a-z0-9_.-]+/g, ".").replace(/\.+/g, ".").replace(/^\.+|\.+$/g, ""); + if (!clean) throw new Error("key is invalid."); + return clean; +} + +function normalizeTags(value: unknown): string[] { + if (!Array.isArray(value)) return []; + if (value.length > 16) throw new Error("tags exceeds 16 entries."); + const out = new Set(); + for (const item of value) { + out.add(normalizeId(item, "tag")); + } + return [...out].sort(); +} + +function normalizeEvidence(value: unknown): RequirementEvidenceRef[] { + if (!Array.isArray(value)) return []; + if (value.length > 8) throw new Error("evidence exceeds 8 entries."); + const out: RequirementEvidenceRef[] = []; + for (const item of value) { + const row = item && typeof item === "object" ? item as Record : null; + if (!row) throw new Error("evidence entries must be objects."); + const type = normalizeId(row.type, "evidence.type"); + const ref = compactText(row.ref, 500, "evidence.ref"); + const noteRaw = String(row.note ?? "").replace(/\s+/g, " ").trim(); + if (noteRaw.length > 500) throw new Error("evidence.note exceeds 500 characters."); + out.push({ type, ref, ...(noteRaw ? { note: noteRaw } : {}) }); + } + return out; +} + +function normalizeIso(value: unknown, fallback: string, label: string): string { + const raw = String(value ?? "").trim(); + if (!raw) return fallback; + const time = Date.parse(raw); + if (!Number.isFinite(time)) throw new Error(`${label} must be an ISO-8601 date/time.`); + return new Date(time).toISOString(); +} + +function normalizeUntil(value: unknown): string | null { + const raw = String(value ?? "").trim(); + return raw ? normalizeIso(raw, "", "effective_until") : null; +} + +function normalizeSupersedes(value: unknown, ownId?: string): string[] { + if (!Array.isArray(value)) return []; + if (value.length > 24) throw new Error("supersedes_requirement_ids exceeds 24 entries."); + const out = new Set(); + for (const item of value.slice(0, 24)) { + const id = normalizeRequirementId(item, "supersedes_requirement_id"); + if (id !== ownId) out.add(id); + } + return [...out].sort(); +} + +function parseRevision(value: unknown): RequirementRevision | null { + try { + const row = value && typeof value === "object" ? value as Record : {}; + if (row.schema !== REQUIREMENTS_SCHEMA) return null; + const status = row.status === "retired" ? "retired" : row.status === "active" ? "active" : null; + if (!status) return null; + const provenance = row.provenance && typeof row.provenance === "object" ? row.provenance as Record : {}; + const revision = Number(row.revision); + if (!Number.isInteger(revision) || revision < 1) return null; + return { + schema: REQUIREMENTS_SCHEMA, + event_id: compactText(row.event_id, 160, "event_id"), + requirement_id: normalizeRequirementId(row.requirement_id), + revision, + status, + scope: normalizeScope(row.scope), + key: normalizeKey(row.key), + text: compactText(row.text, MAX_TEXT_CHARS, "text"), + tags: normalizeTags(row.tags), + effective_from: normalizeIso(row.effective_from, nowIso(), "effective_from"), + effective_until: normalizeUntil(row.effective_until), + supersedes_requirement_ids: normalizeSupersedes(row.supersedes_requirement_ids, String(row.requirement_id ?? "")), + provenance: { + source: compactText(provenance.source, 120, "provenance.source"), + captured_at: normalizeIso(provenance.captured_at, nowIso(), "provenance.captured_at"), + session_id: String(provenance.session_id ?? "").trim().slice(0, 180) || null, + actor_id: String(provenance.actor_id ?? "").trim().slice(0, 180) || null, + evidence: normalizeEvidence(provenance.evidence) + } + }; + } catch { + return null; + } +} + +function readAllRevisions(): RequirementRevision[] { + const p = ledgerPath(); + if (!fs.existsSync(p)) return []; + const stat = fs.statSync(p); + if (!stat.isFile()) throw new Error("Requirements ledger path is not a file."); + if (stat.size > MAX_LEDGER_BYTES) throw new Error(`Requirements ledger exceeds ${MAX_LEDGER_BYTES} bytes; compact or archive it before writing.`); + const revisions: RequirementRevision[] = []; + let lineNumber = 0; + for (const line of fs.readFileSync(p, "utf8").split("\n")) { + lineNumber++; + if (!line.trim()) continue; + try { + const parsed = parseRevision(JSON.parse(line)); + if (!parsed) throw new Error("unsupported or malformed requirement revision"); + revisions.push(parsed); + } catch (error) { + throw new Error(`Requirements ledger is malformed at line ${lineNumber}: ${error instanceof Error ? error.message : String(error)}`); + } + } + if (revisions.length > MAX_REQUIREMENTS * 20) throw new Error("Requirements ledger contains too many revision rows."); + revisions.sort((a, b) => a.requirement_id.localeCompare(b.requirement_id) || a.revision - b.revision || a.event_id.localeCompare(b.event_id)); + return revisions; +} + +function latestMap(): Map { + const out = new Map(); + for (const row of readAllRevisions()) { + const existing = out.get(row.requirement_id); + if (!existing || row.revision > existing.revision || (row.revision === existing.revision && row.event_id > existing.event_id)) out.set(row.requirement_id, row); + } + return out; +} + +function appendRevision(row: RequirementRevision): string { + const p = ledgerPath(); + const size = fs.existsSync(p) ? fs.statSync(p).size : 0; + const rowBytes = Buffer.byteLength(JSON.stringify(row) + "\n", "utf8"); + if (size + rowBytes > MAX_LEDGER_BYTES) throw new Error(`Requirements ledger would exceed ${MAX_LEDGER_BYTES} bytes; write refused.`); + atomicAppendJsonlLine(p, row); + return p; +} + +function validateSupersessionLinks(requirement: RequirementRevision, current: Map): void { + for (const id of requirement.supersedes_requirement_ids) { + const target = current.get(id); + if (!target) throw new Error(`Superseded requirement not found: ${id}`); + if (target.status !== "active") throw new Error(`Superseded requirement is not active: ${id}`); + if (target.key !== requirement.key || target.scope.kind !== requirement.scope.kind || target.scope.id !== requirement.scope.id) { + throw new Error(`Supersession must keep the same scope and key: ${id}`); + } + } +} + +function buildRevision(args: RequirementWriteBase & { requirement_id: string; revision: number; status: RequirementStatus; captured_at?: string }): RequirementRevision { + const capturedAt = normalizeIso(args.captured_at, nowIso(), "captured_at"); + const effectiveFrom = normalizeIso(args.effective_from, capturedAt, "effective_from"); + const effectiveUntil = normalizeUntil(args.effective_until); + if (effectiveUntil && effectiveUntil <= effectiveFrom) throw new Error("effective_until must be after effective_from."); + return { + schema: REQUIREMENTS_SCHEMA, + event_id: `reqevt_${randomUUID()}`, + requirement_id: normalizeRequirementId(args.requirement_id), + revision: args.revision, + status: args.status, + scope: normalizeScope(args.scope), + key: normalizeKey(args.key), + text: compactText(args.text, MAX_TEXT_CHARS, "text"), + tags: normalizeTags(args.tags), + effective_from: effectiveFrom, + effective_until: effectiveUntil, + supersedes_requirement_ids: normalizeSupersedes(args.supersedes_requirement_ids, args.requirement_id), + provenance: { + source: compactText(args.source ?? "manual", 120, "source"), + captured_at: capturedAt, + session_id: String(args.session_id ?? "").trim().slice(0, 180) || null, + actor_id: String(getRequestPrincipal()?.user_id ?? args.actor_id ?? "").trim().slice(0, 180) || null, + evidence: normalizeEvidence(args.evidence) + } + }; +} + +export function createRequirement(args: RequirementWriteBase): { ok: true; requirement: RequirementRevision; ledger_path: string } { + assertRequirementsWriteUnlocked(); + return withRequirementsWriteLock(() => { + const current = latestMap(); + if (current.size >= MAX_REQUIREMENTS) throw new Error(`Requirement limit of ${MAX_REQUIREMENTS} reached.`); + const requirementId = `req_${randomUUID()}`; + const requirement = buildRevision({ ...args, requirement_id: requirementId, revision: 1, status: "active" }); + validateSupersessionLinks(requirement, current); + return { ok: true, requirement, ledger_path: appendRevision(requirement) }; + }); +} + +export function reviseRequirement(args: RequirementWriteBase & { requirement_id: string; expected_revision: number }): { ok: true; requirement: RequirementRevision; ledger_path: string } { + assertRequirementsWriteUnlocked(); + return withRequirementsWriteLock(() => { + const id = normalizeRequirementId(args.requirement_id); + const currentMap = latestMap(); + const current = currentMap.get(id); + if (!current) throw new Error(`Requirement not found: ${id}`); + if (current.revision !== args.expected_revision) throw new Error(`Revision conflict for ${id}: expected ${args.expected_revision}, actual ${current.revision}.`); + if (current.status !== "active") throw new Error(`Requirement ${id} is retired and cannot be revised.`); + const requirement = buildRevision({ ...args, requirement_id: id, revision: current.revision + 1, status: "active" }); + validateSupersessionLinks(requirement, currentMap); + return { ok: true, requirement, ledger_path: appendRevision(requirement) }; + }); +} + +export function retireRequirement(args: { requirement_id: string; expected_revision: number; source?: string | null; session_id?: string | null; actor_id?: string | null; evidence?: RequirementEvidenceRef[] }): { ok: true; requirement: RequirementRevision; ledger_path: string } { + assertRequirementsWriteUnlocked(); + return withRequirementsWriteLock(() => { + const id = normalizeRequirementId(args.requirement_id); + const current = latestMap().get(id); + if (!current) throw new Error(`Requirement not found: ${id}`); + if (current.revision !== args.expected_revision) throw new Error(`Revision conflict for ${id}: expected ${args.expected_revision}, actual ${current.revision}.`); + if (current.status === "retired") throw new Error(`Requirement ${id} is already retired.`); + const requirement = buildRevision({ + ...current, + source: args.source ?? "manual.retire", + session_id: args.session_id ?? current.provenance.session_id, + actor_id: args.actor_id ?? current.provenance.actor_id, + evidence: args.evidence ?? current.provenance.evidence, + requirement_id: id, + revision: current.revision + 1, + status: "retired" + }); + return { ok: true, requirement, ledger_path: appendRevision(requirement) }; + }); +} + +export function listRequirements(args: { scope?: RequirementScopeRef; status?: RequirementStatus | "all"; limit?: number } = {}): RequirementRevision[] { + const scope = args.scope ? normalizeScope(args.scope) : null; + const status = args.status ?? "active"; + const limit = Math.max(1, Math.min(500, Math.floor(args.limit ?? 200))); + return [...latestMap().values()] + .filter(row => !scope || (row.scope.kind === scope.kind && row.scope.id === scope.id)) + .filter(row => status === "all" || row.status === status) + .sort((a, b) => a.scope.kind.localeCompare(b.scope.kind) || a.scope.id.localeCompare(b.scope.id) || a.key.localeCompare(b.key) || a.requirement_id.localeCompare(b.requirement_id)) + .slice(0, limit); +} + +function tokenize(value: string): Set { + return new Set(value.toLowerCase().split(/[^a-z0-9]+/g).filter(part => part.length >= 2)); +} + +function scopeKey(scope: RequirementScopeRef): string { + return `${scope.kind}:${scope.id}`; +} + +function receiptEntry(row: RequirementRevision, reason: RequirementReceiptEntry["reason"]): RequirementReceiptEntry { + return { requirement_id: row.requirement_id, revision: row.revision, scope: row.scope, key: row.key, text: row.text, reason }; +} + +function stableHash(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value), "utf8").digest("hex"); +} + +export function resolveRequirements(args: { scope_refs: RequirementScopeRef[]; query?: string; at?: string; max_results?: number }): RequirementsReceipt { + const generatedAt = nowIso(); + const at = normalizeIso(args.at, generatedAt, "at"); + const query = String(args.query ?? "").replace(/\s+/g, " ").trim().slice(0, MAX_QUERY_CHARS); + const queryTokens = tokenize(query); + const scopes = [...new Map((Array.isArray(args.scope_refs) ? args.scope_refs : []).map(normalizeScope).map(scope => [scopeKey(scope), scope])).values()] + .sort((a, b) => scopeKey(a).localeCompare(scopeKey(b))); + const allowedScopes = new Set(scopes.map(scopeKey)); + const maxResults = Math.max(1, Math.min(200, Math.floor(args.max_results ?? 80))); + + let active = [...latestMap().values()].filter(row => + row.status === "active" && + allowedScopes.has(scopeKey(row.scope)) && + row.effective_from <= at && + (!row.effective_until || row.effective_until > at) + ); + active.sort((a, b) => a.key.localeCompare(b.key) || b.revision - a.revision || a.requirement_id.localeCompare(b.requirement_id)); + + const explicitlySuperseded = new Set(active.flatMap(row => row.supersedes_requirement_ids)); + const suppressed: RequirementReceiptEntry[] = []; + let remaining = active.filter(row => { + if (!explicitlySuperseded.has(row.requirement_id)) return true; + suppressed.push(receiptEntry(row, "superseded")); + return false; + }); + if (queryTokens.size > 0) { + remaining = remaining.filter(row => { + const tokens = tokenize(`${row.key} ${row.text} ${row.tags.join(" ")}`); + for (const token of queryTokens) if (tokens.has(token)) return true; + return false; + }); + } + + const byKey = new Map(); + for (const row of remaining) byKey.set(row.key, [...(byKey.get(row.key) ?? []), row]); + const applied: RequirementReceiptEntry[] = []; + const conflicts: RequirementsReceipt["conflicts"] = []; + + for (const key of [...byKey.keys()].sort()) { + const rows = byKey.get(key)!; + const topPrecedence = Math.max(...rows.map(row => SCOPE_PRECEDENCE[row.scope.kind])); + const top = rows.filter(row => SCOPE_PRECEDENCE[row.scope.kind] === topPrecedence); + const texts = new Map(); + for (const row of top) { + const normalizedText = row.text.toLowerCase().replace(/\s+/g, " ").trim(); + texts.set(normalizedText, [...(texts.get(normalizedText) ?? []), row]); + } + if (texts.size > 1) { + conflicts.push({ + key, + precedence: topPrecedence, + requirements: top + .sort((a, b) => a.requirement_id.localeCompare(b.requirement_id)) + .map(row => ({ requirement_id: row.requirement_id, revision: row.revision, scope: row.scope, text: row.text })) + }); + for (const row of rows.filter(row => SCOPE_PRECEDENCE[row.scope.kind] < topPrecedence)) suppressed.push(receiptEntry(row, "lower_precedence")); + continue; + } + const winner = [...top].sort((a, b) => a.requirement_id.localeCompare(b.requirement_id))[0]!; + applied.push(receiptEntry(winner, "highest_precedence")); + for (const row of rows) { + if (row.requirement_id === winner.requirement_id) continue; + suppressed.push(receiptEntry(row, SCOPE_PRECEDENCE[row.scope.kind] < topPrecedence ? "lower_precedence" : "duplicate")); + } + } + + const sortedSuppressed = suppressed.sort((a, b) => a.key.localeCompare(b.key) || a.requirement_id.localeCompare(b.requirement_id)); + const overflow = applied.length > maxResults || sortedSuppressed.length > maxResults || conflicts.length > maxResults + ? { applied_count: applied.length, suppressed_count: sortedSuppressed.length, conflict_count: conflicts.length, max_results: maxResults } + : null; + const boundedApplied = applied.slice(0, maxResults); + const boundedSuppressed = sortedSuppressed.slice(0, maxResults); + const boundedConflicts = conflicts.slice(0, maxResults); + const status: RequirementsReceipt["status"] = overflow ? "overflow" : boundedConflicts.length > 0 ? "conflict" : "resolved"; + const hashInput = { status, query, scope_refs: scopes, applied: boundedApplied, suppressed: boundedSuppressed, conflicts: boundedConflicts, overflow }; + return { + schema: REQUIREMENTS_RECEIPT_SCHEMA, + generated_at: generatedAt, + status, + query, + scope_refs: scopes, + applied: boundedApplied, + suppressed: boundedSuppressed, + conflicts: boundedConflicts, + overflow, + receipt_sha256: stableHash(hashInput) + }; +} + +function contextObject(value: unknown): Record { + return value && typeof value === "object" ? value as Record : {}; +} + +export function deriveRequirementScopesForChat(req: Pick): RequirementScopeRef[] { + const context = contextObject(req.context); + const requirements = contextObject(context.requirements); + const explicit = Array.isArray(requirements.scopes) ? requirements.scopes : []; + const scopes: RequirementScopeRef[] = []; + for (const row of explicit) { + try { scopes.push(normalizeScope(row)); } catch { /* explicit malformed scopes are ignored */ } + } + + const principal = getRequestPrincipal(); + scopes.push({ kind: "engineer", id: normalizeId(principal?.user_id || "local", "engineer scope") }); + + const revit = contextObject(context.revit); + const document = contextObject(revit.document); + const readiness = contextObject(revit.readiness); + const documentPath = String(document.path ?? readiness.active_document_path ?? "").trim(); + const documentTitle = String(document.title ?? readiness.active_document_name ?? "").trim(); + const projectBasis = documentPath || documentTitle; + if (projectBasis) { + const normalized = projectBasis.replace(/\\/g, "/").toLowerCase(); + scopes.push({ kind: "project", id: `revit_${stableHash(normalized).slice(0, 16)}` }); + } + for (const kind of ["office", "client"] as const) { + const id = String(requirements[`${kind}_id`] ?? "").trim(); + if (id) scopes.push({ kind, id: normalizeId(id, `${kind}_id`) }); + } + return [...new Map(scopes.map(scope => [scopeKey(scope), scope])).values()].sort((a, b) => scopeKey(a).localeCompare(scopeKey(b))); +} + +export function resolveRequirementsForChat(req: Pick): RequirementsReceipt { + const context = contextObject(req.context); + const ui = contextObject(context.ui); + const query = String(req.user_text ?? "").trim() || String(ui.authoritative_user_text ?? "").trim(); + return resolveRequirements({ + scope_refs: deriveRequirementScopesForChat(req), + query, + max_results: 40 + }); +} + +export function formatRequirementsForPrompt(receipt: RequirementsReceipt): string { + if (receipt.applied.length === 0 && receipt.conflicts.length === 0) return ""; + const lines = [ + "DURABLE REQUIREMENTS (deterministically resolved; cite exact [R#] IDs in plans):", + `receipt_sha256=${receipt.receipt_sha256} status=${receipt.status}` + ]; + if (receipt.status === "overflow") lines.push(`[OVERFLOW] ${JSON.stringify(receipt.overflow)} — do not plan; narrow the query or scope.`); + receipt.applied.forEach((row, index) => lines.push(`[R${index + 1}] id=${row.requirement_id} rev=${row.revision} scope=${scopeKey(row.scope)} key=${row.key} ${row.text}`)); + for (const conflict of receipt.conflicts) { + lines.push(`[CONFLICT key=${conflict.key}] ${conflict.requirements.map(row => `${row.requirement_id}@${row.revision}`).join(", ")} — do not choose; ask for resolution.`); + } + lines.push("Return the receipt hash with any proposed plan; never silently override a conflict."); + return lines.join("\n"); +} + +export function formatRequirementsForUser(rows: RequirementRevision[]): string { + if (rows.length === 0) return "No durable requirements found."; + return [ + `Durable requirements (${rows.length})`, + ...rows.map(row => `- ${row.requirement_id}@${row.revision} [${scopeKey(row.scope)}] [${row.key}] ${row.status}: ${row.text}`) + ].join("\n"); +} + +export function requirementScopePrecedence(kind: RequirementScopeKind): number { + return SCOPE_PRECEDENCE[kind]; +} + +export function requirementsContractVersion(): string { + return OPERATOR_BACKEND_CONTRACT_VERSION; +} diff --git a/apps/operator-backend/src/skills/macro_skill_commands.ts b/apps/operator-backend/src/skills/macro_skill_commands.ts index e720408..4c2fdd4 100644 --- a/apps/operator-backend/src/skills/macro_skill_commands.ts +++ b/apps/operator-backend/src/skills/macro_skill_commands.ts @@ -11,6 +11,13 @@ import { disableInstalledSkill, enableDisabledSkill, installStagedSkill, stageLo import { persistence } from "../persistence/persistence_manager.js"; import { appendDailyMemory, appendLongtermMemory, retrieveMemoryContext } from "../memory/jsonl_memory_store.js"; import { addProjectStandard, formatProjectProfileForUser } from "../memory/project_profile.js"; +import { + createRequirement, + deriveRequirementScopesForChat, + formatRequirementsForUser, + listRequirements, + resolveRequirements +} from "../memory/requirements_store.js"; type ActiveRun = { sessionId: string; @@ -287,7 +294,7 @@ function formatSkillList(): string { lines.push("Run: run skill with { ... }"); lines.push("Stop: cancel skill"); lines.push("Local skills: save skill {...} (stages) | install skill | disable skill | enable skill "); - lines.push("Memory: remember preference | remember workflow | remember note | search memory "); + lines.push("Memory: remember preference | remember project requirement : | remember engineer preference : | remember requirement : | show requirements | explain requirements "); lines.push("More: list staged skills | list disabled skills"); return lines.join("\n"); } @@ -400,6 +407,16 @@ export function maybeHandleMacroSkill(req: ChatRequest): ChatResponse | null { return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: formatProjectProfileForUser(), actions: [] }; } + if (lower === "show requirements" || lower === "list requirements") { + return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: formatRequirementsForUser(listRequirements({ status: "all", limit: 200 })), actions: [] }; + } + + if (lower === "explain requirements" || lower.startsWith("explain requirements ")) { + const query = normalizeMemoryText(userText.slice("explain requirements".length), 1000); + const receipt = resolveRequirements({ scope_refs: deriveRequirementScopesForChat(req), query, max_results: 80 }); + return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: JSON.stringify(receipt, null, 2), actions: [] }; + } + if (lower === "list staged skills" || lower === "staged skills" || lower === "list staged skill") { return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: formatStagedSkills(), actions: [] }; } @@ -435,6 +452,40 @@ export function maybeHandleMacroSkill(req: ChatRequest): ChatResponse | null { } } + if (lower.startsWith("remember engineer preference ") || lower.startsWith("remember project requirement ")) { + const isProject = lower.startsWith("remember project requirement "); + const prefix = isProject ? "remember project requirement " : "remember engineer preference "; + const parsed = parseProjectStandardCommand(userText, prefix); + if (!parsed.text || parsed.category === "general") { + return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: `Usage: ${prefix}: `, actions: [] }; + } + const kind = isProject ? "project" : "engineer"; + const scope = deriveRequirementScopesForChat(req).find(row => row.kind === kind); + if (!scope) { + return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: isProject ? "No active Revit project identity is available. Open the target model or use the generic scoped command." : "No engineer identity is available.", actions: [] }; + } + try { + const saved = createRequirement({ scope, key: parsed.category, text: parsed.text, source: "chat.command", session_id: req.session_id, tags: [kind, isProject ? "requirement" : "preference"] }); + return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: `Saved durable ${kind} requirement ${saved.requirement.requirement_id}@1 [${saved.requirement.key}] for ${scope.id}.`, actions: [] }; + } catch (e) { + return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: `Failed to save durable requirement: ${String(e)}`, actions: [] }; + } + } + + if (lower.startsWith("remember requirement ")) { + const body = normalizeMemoryText(userText.slice("remember requirement ".length), 1400); + const match = /^(office|engineer|project|client)\s+([^\s]+)\s+([^:]{1,160}):\s*(.+)$/i.exec(body); + if (!match) { + return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: "Usage: remember requirement : ", actions: [] }; + } + try { + const saved = createRequirement({ scope: { kind: match[1]!.toLowerCase() as any, id: match[2]! }, key: match[3]!, text: match[4]!, source: "chat.command", session_id: req.session_id, tags: [match[1]!.toLowerCase(), "requirement"] }); + return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: `Saved durable requirement ${saved.requirement.requirement_id}@1 [${saved.requirement.scope.kind}:${saved.requirement.scope.id}] [${saved.requirement.key}].`, actions: [] }; + } catch (e) { + return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: `Failed to save durable requirement: ${String(e)}`, actions: [] }; + } + } + if (lower.startsWith("remember project standard ") || lower.startsWith("remember standard ")) { const prefix = lower.startsWith("remember project standard ") ? "remember project standard " : "remember standard "; const parsed = parseProjectStandardCommand(userText, prefix); diff --git a/apps/operator-backend/test/macro_skill_commands.test.ts b/apps/operator-backend/test/macro_skill_commands.test.ts index c5d30d3..23dff25 100644 --- a/apps/operator-backend/test/macro_skill_commands.test.ts +++ b/apps/operator-backend/test/macro_skill_commands.test.ts @@ -145,3 +145,24 @@ test("show project profile lists saved standards", () => { assert.match(r!.assistant_message, /Project standards profile/); assert.match(r!.assistant_message, /Verify titleblock fields/); }); + +test("remember project requirement infers a stable active-model scope", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "revitoperator-ws-")); + process.env.OPERATOR_WORKSPACE_ROOT = root; + const request = mkReq("s6", "m1", "remember project requirement tags.leaders: Keep leaders short and uncrossed."); + request.context = { revit: { document: { title: "Snowdon Towers HVAC", path: "C:\\Models\\Snowdon.rvt" } } }; + const saved = maybeHandleMacroSkill(request); + assert.match(saved?.assistant_message ?? "", /Saved durable project requirement req_/); + const shown = maybeHandleMacroSkill(mkReq("s6", "m2", "show requirements")); + assert.match(shown?.assistant_message ?? "", /tags\.leaders/); + assert.match(shown?.assistant_message ?? "", /Keep leaders short and uncrossed/); +}); + +test("generic durable requirement command supports office and client scopes", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "revitoperator-ws-")); + process.env.OPERATOR_WORKSPACE_ROOT = root; + const office = maybeHandleMacroSkill(mkReq("s7", "m1", "remember requirement office bimtools tags.case: Use ALL CAPS.")); + const client = maybeHandleMacroSkill(mkReq("s7", "m2", "remember requirement client hospital-a rooms.naming: Preserve client room naming.")); + assert.match(office?.assistant_message ?? "", /office:bimtools/); + assert.match(client?.assistant_message ?? "", /client:hospital-a/); +}); diff --git a/apps/operator-backend/test/requirements_store.test.ts b/apps/operator-backend/test/requirements_store.test.ts new file mode 100644 index 0000000..b00b7bb --- /dev/null +++ b/apps/operator-backend/test/requirements_store.test.ts @@ -0,0 +1,400 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +import { + createRequirement, + beginRequirementsPlanningLease, + deriveRequirementScopesForChat, + endRequirementsPlanningLease, + formatRequirementsForPrompt, + listRequirements, + requirementScopePrecedence, + resolveRequirements, + retireRequirement, + reviseRequirement +} from "../src/memory/requirements_store.js"; +import { runWithRequestContext, type RequestPrincipal } from "../src/request_context.js"; +import { requiresMemoryAuthentication } from "../src/memory/requirements_route_policy.js"; +import { captureRequirementsResponseGuard, enforceRequirementsResponseGuard, formatRequirementsPromptBlockSafely } from "../src/memory/requirements_response_policy.js"; +import { authenticateRequest } from "../src/auth.js"; +import { maybeContinueAecLevelPowerPlanPilot, startAecLevelPowerPlanPilot } from "../src/deterministic/aec_level_power_plan_runtime.js"; +import { AEC_SEMANTIC_TASK_V1_SCHEMA, type AecSemanticTaskV1 } from "../src/aec_semantic_task.js"; +import { OPERATOR_BACKEND_CONTRACT_VERSION, type ChatRequest } from "../src/contracts.js"; +import { getActiveGoalForSession, setAgentGoal } from "../src/goals/service.js"; + +function mkWorkspace(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "revitoperator-requirements-")); + process.env.OPERATOR_WORKSPACE_ROOT = root; + return root; +} + +function scope(kind: "office" | "engineer" | "project" | "client", id: string) { + return { kind, id } as const; +} + +function principal(user: string, tenant: string): RequestPrincipal { + return { sub: user, user_id: user, license_id: tenant, roles: ["user"], tier: "pro", claims: {} }; +} + +test("create writes a stable requirement id and append-only first revision", () => { + const root = mkWorkspace(); + const saved = createRequirement({ scope: scope("office", "bimtools"), key: "tags.text_size", text: "Use 3/32 inch text.", source: "test" }); + assert.match(saved.requirement.requirement_id, /^req_/); + assert.equal(saved.requirement.revision, 1); + assert.equal(saved.requirement.status, "active"); + assert.ok(fs.existsSync(path.join(root, "memory", "requirements.v1.jsonl"))); + assert.equal(fs.readFileSync(saved.ledger_path, "utf8").trim().split("\n").length, 1); +}); + +test("revision preserves stable id and enforces optimistic concurrency", () => { + mkWorkspace(); + const first = createRequirement({ scope: scope("engineer", "eli"), key: "tags.leader_length", text: "Keep leaders short." }); + const second = reviseRequirement({ requirement_id: first.requirement.requirement_id, expected_revision: 1, scope: first.requirement.scope, key: first.requirement.key, text: "Keep leaders under 3 feet when practical." }); + assert.equal(second.requirement.requirement_id, first.requirement.requirement_id); + assert.equal(second.requirement.revision, 2); + assert.throws(() => reviseRequirement({ requirement_id: first.requirement.requirement_id, expected_revision: 1, scope: first.requirement.scope, key: first.requirement.key, text: "stale" }), /Revision conflict/); +}); + +test("retire appends a tombstone and removes the requirement from active resolution", () => { + mkWorkspace(); + const first = createRequirement({ scope: scope("project", "snowdon"), key: "equipment.clearance", text: "Show service clearances." }); + const retired = retireRequirement({ requirement_id: first.requirement.requirement_id, expected_revision: 1 }); + assert.equal(retired.requirement.status, "retired"); + assert.equal(retired.requirement.revision, 2); + assert.equal(resolveRequirements({ scope_refs: [scope("project", "snowdon")] }).applied.length, 0); + assert.equal(listRequirements({ status: "all" })[0]!.status, "retired"); +}); + +test("effective dates exclude future and expired requirements", () => { + mkWorkspace(); + createRequirement({ scope: scope("client", "hospital-a"), key: "sheets.naming", text: "Future", effective_from: "2030-01-01T00:00:00Z" }); + createRequirement({ scope: scope("client", "hospital-a"), key: "views.naming", text: "Expired", effective_from: "2020-01-01T00:00:00Z", effective_until: "2021-01-01T00:00:00Z" }); + const receipt = resolveRequirements({ scope_refs: [scope("client", "hospital-a")], at: "2026-07-13T12:00:00Z" }); + assert.equal(receipt.applied.length, 0); +}); + +test("precedence is project over client over office over engineer", () => { + mkWorkspace(); + assert.deepEqual(["engineer", "office", "client", "project"].map(kind => requirementScopePrecedence(kind as any)), [100, 200, 300, 400]); + const refs = [scope("engineer", "eli"), scope("office", "bimtools"), scope("client", "hospital-a"), scope("project", "snowdon")]; + for (const ref of refs) createRequirement({ scope: ref, key: "tags.text_size", text: `${ref.kind} value` }); + const receipt = resolveRequirements({ scope_refs: refs }); + assert.equal(receipt.applied[0]!.scope.kind, "project"); + assert.equal(receipt.suppressed.length, 3); +}); + +test("same-precedence contradictory records fail closed as a conflict", () => { + mkWorkspace(); + const ref = scope("project", "snowdon"); + createRequirement({ scope: ref, key: "tags.text_size", text: "Use 3/32 inch." }); + createRequirement({ scope: ref, key: "tags.text_size", text: "Use 1/8 inch." }); + const receipt = resolveRequirements({ scope_refs: [ref] }); + assert.equal(receipt.status, "conflict"); + assert.equal(receipt.applied.length, 0); + assert.equal(receipt.conflicts[0]!.requirements.length, 2); +}); + +test("same-precedence duplicate text resolves deterministically", () => { + mkWorkspace(); + const ref = scope("office", "bimtools"); + const a = createRequirement({ scope: ref, key: "tags.case", text: "Use ALL CAPS." }); + const b = createRequirement({ scope: ref, key: "tags.case", text: "Use ALL CAPS." }); + const receipt = resolveRequirements({ scope_refs: [ref] }); + assert.equal(receipt.status, "resolved"); + assert.equal(receipt.applied[0]!.requirement_id, [a.requirement.requirement_id, b.requirement.requirement_id].sort()[0]); + assert.equal(receipt.suppressed[0]!.reason, "duplicate"); +}); + +test("explicit supersession suppresses the named record", () => { + mkWorkspace(); + const ref = scope("client", "hospital-a"); + const old = createRequirement({ scope: ref, key: "rooms.power", text: "Provide two outlets." }); + const replacement = createRequirement({ scope: ref, key: "rooms.power", text: "Provide four outlets.", supersedes_requirement_ids: [old.requirement.requirement_id] }); + const receipt = resolveRequirements({ scope_refs: [ref] }); + assert.equal(receipt.status, "resolved"); + assert.equal(receipt.applied[0]!.requirement_id, replacement.requirement.requirement_id); + assert.equal(receipt.suppressed[0]!.reason, "superseded"); +}); + +test("receipt hash is stable across repeated resolution and names exact revisions", () => { + mkWorkspace(); + const ref = scope("project", "snowdon"); + const saved = createRequirement({ scope: ref, key: "equipment.clearance", text: "Show power-side clearance.", effective_from: "2026-01-01T00:00:00Z" }); + const a = resolveRequirements({ scope_refs: [ref], query: "equipment clearance", at: "2026-07-13T12:00:00Z" }); + const b = resolveRequirements({ scope_refs: [ref], query: "equipment clearance", at: "2026-07-13T12:00:00Z" }); + assert.equal(a.receipt_sha256, b.receipt_sha256); + assert.deepEqual(a.applied.map(row => [row.requirement_id, row.revision]), [[saved.requirement.requirement_id, 1]]); +}); + +test("query retrieval is deterministic and bounded to matching keys, text, or tags", () => { + mkWorkspace(); + const ref = scope("office", "bimtools"); + createRequirement({ scope: ref, key: "tags.leaders", text: "Keep leaders short.", tags: ["annotation"] }); + createRequirement({ scope: ref, key: "duct.routing", text: "Prefer orthogonal routes.", tags: ["mep"] }); + const receipt = resolveRequirements({ scope_refs: [ref], query: "annotation leaders" }); + assert.equal(receipt.applied.length, 1); + assert.equal(receipt.applied[0]!.key, "tags.leaders"); +}); + +test("scope ids isolate requirements even within the same scope kind", () => { + mkWorkspace(); + createRequirement({ scope: scope("client", "client-a"), key: "sheets.logo", text: "Use logo A." }); + createRequirement({ scope: scope("client", "client-b"), key: "sheets.logo", text: "Use logo B." }); + const a = resolveRequirements({ scope_refs: [scope("client", "client-a")] }); + assert.equal(a.applied.length, 1); + assert.equal(a.applied[0]!.text, "Use logo A."); +}); + +test("hosted request context isolates ledgers by tenant and user", () => { + const base = mkWorkspace(); + runWithRequestContext({ principal: principal("alice", "tenant-a") }, () => createRequirement({ scope: scope("engineer", "alice"), key: "tags.case", text: "Alice" })); + runWithRequestContext({ principal: principal("bob", "tenant-b") }, () => createRequirement({ scope: scope("engineer", "bob"), key: "tags.case", text: "Bob" })); + const alice = runWithRequestContext({ principal: principal("alice", "tenant-a") }, () => listRequirements()); + const bob = runWithRequestContext({ principal: principal("bob", "tenant-b") }, () => listRequirements()); + assert.deepEqual(alice.map(row => row.text), ["Alice"]); + assert.deepEqual(bob.map(row => row.text), ["Bob"]); + assert.ok(fs.existsSync(path.join(base, "tenants", "tenant-a", "users", "alice", "Workspace", "memory", "requirements.v1.jsonl"))); +}); + +test("chat scope derivation uses engineer identity and stable active-project identity", () => { + mkWorkspace(); + const req = { context: { revit: { document: { title: "Snowdon Towers HVAC", path: "C:\\Models\\Snowdon.rvt" } } } }; + const a = runWithRequestContext({ principal: principal("eli", "tenant-a") }, () => deriveRequirementScopesForChat(req)); + const b = runWithRequestContext({ principal: principal("eli", "tenant-a") }, () => deriveRequirementScopesForChat(req)); + assert.deepEqual(a, b); + assert.ok(a.some(row => row.kind === "engineer" && row.id === "eli")); + assert.ok(a.some(row => row.kind === "project" && /^revit_[a-f0-9]{16}$/.test(row.id))); +}); + +test("prompt block includes exact ids, revisions, hash, and conflict guard", () => { + mkWorkspace(); + const ref = scope("project", "snowdon"); + createRequirement({ scope: ref, key: "tags.position", text: "Keep tags in clear floor space." }); + const block = formatRequirementsForPrompt(resolveRequirements({ scope_refs: [ref] })); + assert.match(block, /receipt_sha256=[a-f0-9]{64}/); + assert.match(block, /id=req_/); + assert.match(block, /rev=1/); + assert.match(block, /never silently override a conflict/); +}); + +test("bounded writes reject oversized requirement text", () => { + mkWorkspace(); + assert.throws(() => createRequirement({ scope: scope("office", "bimtools"), key: "notes.general", text: "x".repeat(2001) }), /exceeds 2000 characters/); +}); + +test("every memory route, including legacy project profile, requires authentication", () => { + assert.equal(requiresMemoryAuthentication("/memory/project-profile"), true); + assert.equal(requiresMemoryAuthentication("/memory/project-profile/standards"), true); + assert.equal(requiresMemoryAuthentication("/memory/requirements/resolve"), true); + assert.equal(requiresMemoryAuthentication("/health"), false); + const denied = authenticateRequest({ headers: {} } as any, { mode: "shared_token", requireAuth: requiresMemoryAuthentication("/memory/requirements"), sharedToken: "test-token" }); + assert.equal(denied.ok, false); + if (!denied.ok) assert.equal(denied.status, 401); +}); + +test("deterministic Level power planning carries exact applied requirement ids and receipt hash", () => { + mkWorkspace(); + const request: ChatRequest = { + version: OPERATOR_BACKEND_CONTRACT_VERSION, + session_id: "requirements-power-plan", + message_id: "m1", + user_text: "Lay out the power plans on Level 4 using the project precedent.", + context: { revit: { document: { title: "Snowdon Towers HVAC", path: "C:\\Models\\Snowdon.rvt" } } } + }; + const projectScope = deriveRequirementScopesForChat(request).find(row => row.kind === "project")!; + const saved = createRequirement({ scope: projectScope, key: "power.precedent_policy", text: "Use an exact current-project adjacent-level precedent." }); + setAgentGoal(request.session_id, { title: "Power plan", objective: request.user_text!, success_criteria: ["Bounded receipt"], created_by: "test" }); + const task: AecSemanticTaskV1 = { + schema: AEC_SEMANTIC_TASK_V1_SCHEMA, + operation: "layout", + subject: { kind: "class", semantic_class: "electrical_equipment", terms: ["power plan"], categories: ["OST_ElectricalFixtures"], family_name: null, type_name: null, system_name: null, identifiers: [] }, + scope: { kind: "level", document: null, levels: ["L4"], rooms: [], spaces: [], areas: [], views: [], sheets: [], systems: [], element_ids: [], region: null }, + reference: { strategy: "current_project_precedent", source_description: null, source_room: null }, + mutation: { kind: "create", requested: true }, + outputs: ["summary", "verification"], + execution: { max_results: 100, max_primary_actions: 8, allow_document_fallback: false, requires_visual_verification: true }, + confidence: { value: 0.95, ambiguity: "low", reasons: ["bounded level"] }, + evidence: { user_text: request.user_text! } + }; + const result = startAecLevelPowerPlanPilot(request, task, [{ id: 101, name: "L4 - Power", levelName: "L4" }]); + assert.equal(result?.requirements_receipt?.status, "resolved"); + assert.deepEqual(result?.requirements_receipt?.applied.map(row => [row.requirement_id, row.revision]), [[saved.requirement.requirement_id, 1]]); + assert.match(result?.requirements_receipt?.receipt_sha256 ?? "", /^[a-f0-9]{64}$/); + assert.deepEqual(result?.actions.map(action => action.path), ["/revit/find-elements"]); + assert.match(getActiveGoalForSession(request.session_id)?.assumptions.find(row => row.id === "requirements.receipt")?.statement ?? "", new RegExp(saved.requirement.requirement_id)); +}); + +test("deterministic Level power planning blocks before Revit actions on a requirement conflict", () => { + mkWorkspace(); + const request: ChatRequest = { + version: OPERATOR_BACKEND_CONTRACT_VERSION, + session_id: "requirements-power-conflict", + message_id: "m1", + user_text: "Lay out the power plans on Level 4.", + context: { revit: { document: { title: "Snowdon Towers HVAC", path: "C:\\Models\\Snowdon.rvt" } } } + }; + const projectScope = deriveRequirementScopesForChat(request).find(row => row.kind === "project")!; + createRequirement({ scope: projectScope, key: "power.precedent_policy", text: "Use Level 3." }); + createRequirement({ scope: projectScope, key: "power.precedent_policy", text: "Use Level 5." }); + setAgentGoal(request.session_id, { title: "Power plan", objective: request.user_text!, success_criteria: ["Bounded receipt"], created_by: "test" }); + const task: AecSemanticTaskV1 = { + schema: AEC_SEMANTIC_TASK_V1_SCHEMA, + operation: "layout", + subject: { kind: "class", semantic_class: "electrical_equipment", terms: ["power plan"], categories: ["OST_ElectricalFixtures"], family_name: null, type_name: null, system_name: null, identifiers: [] }, + scope: { kind: "level", document: null, levels: ["L4"], rooms: [], spaces: [], areas: [], views: [], sheets: [], systems: [], element_ids: [], region: null }, + reference: { strategy: "current_project_precedent", source_description: null, source_room: null }, + mutation: { kind: "create", requested: true }, + outputs: ["summary"], + execution: { max_results: 100, max_primary_actions: 8, allow_document_fallback: false, requires_visual_verification: true }, + confidence: { value: 0.95, ambiguity: "low", reasons: ["bounded level"] }, + evidence: { user_text: request.user_text! } + }; + const result = startAecLevelPowerPlanPilot(request, task, [{ id: 101, name: "L4 - Power", levelName: "L4" }]); + assert.equal(result?.requirements_receipt?.status, "conflict"); + assert.deepEqual(result?.actions, []); + assert.equal(result?.aec_query_receipt?.status, "failed"); +}); + +test("supersession cannot suppress a different scope or directive key", () => { + mkWorkspace(); + const old = createRequirement({ scope: scope("office", "bimtools"), key: "tags.case", text: "Use ALL CAPS." }); + assert.throws(() => createRequirement({ scope: scope("client", "hospital-a"), key: "sheets.case", text: "Use title case.", supersedes_requirement_ids: [old.requirement.requirement_id] }), /same scope and key/); +}); + +test("hosted actor provenance is derived from the authenticated principal", () => { + mkWorkspace(); + const saved = runWithRequestContext({ principal: principal("alice", "tenant-a") }, () => createRequirement({ scope: scope("engineer", "alice"), key: "tags.leaders", text: "Keep leaders short.", actor_id: "forged-user" })); + assert.equal(saved.requirement.provenance.actor_id, "alice"); +}); + +test("malformed ledger rows fail closed instead of silently changing resolution", () => { + const root = mkWorkspace(); + createRequirement({ scope: scope("office", "bimtools"), key: "tags.case", text: "Use ALL CAPS." }); + fs.appendFileSync(path.join(root, "memory", "requirements.v1.jsonl"), "{malformed\n", "utf8"); + assert.throws(() => listRequirements(), /ledger is malformed at line 2/i); +}); + +test("receipt overflow blocks instead of silently truncating applicable requirements", () => { + mkWorkspace(); + const ref = scope("office", "bimtools"); + createRequirement({ scope: ref, key: "tags.a", text: "A" }); + createRequirement({ scope: ref, key: "tags.b", text: "B" }); + createRequirement({ scope: ref, key: "tags.c", text: "C" }); + const receipt = resolveRequirements({ scope_refs: [ref], max_results: 2 }); + assert.equal(receipt.status, "overflow"); + assert.deepEqual(receipt.overflow, { applied_count: 3, suppressed_count: 0, conflict_count: 0, max_results: 2 }); +}); + +test("supersession is applied before query filtering", () => { + mkWorkspace(); + const ref = scope("client", "hospital-a"); + const old = createRequirement({ scope: ref, key: "rooms.naming", text: "Use legacyword room labels." }); + createRequirement({ scope: ref, key: "rooms.naming", text: "Use current room labels.", supersedes_requirement_ids: [old.requirement.requirement_id] }); + const receipt = resolveRequirements({ scope_refs: [ref], query: "legacyword" }); + assert.equal(receipt.applied.length, 0); + assert.equal(receipt.suppressed[0]!.requirement_id, old.requirement.requirement_id); + assert.equal(receipt.suppressed[0]!.reason, "superseded"); +}); + +test("Level power continuation revalidates and rejects a stale requirement receipt", () => { + mkWorkspace(); + const request: ChatRequest = { + version: OPERATOR_BACKEND_CONTRACT_VERSION, + session_id: "requirements-power-stale", + message_id: "m1", + user_text: "Lay out the power plans on Level 4.", + context: { revit: { document: { title: "Snowdon Towers HVAC", path: "C:\\Models\\Snowdon.rvt" } } } + }; + const projectScope = deriveRequirementScopesForChat(request).find(row => row.kind === "project")!; + createRequirement({ scope: projectScope, key: "power.precedent_policy", text: "Use Level 3." }); + setAgentGoal(request.session_id, { title: "Power plan", objective: request.user_text!, success_criteria: ["Bounded receipt"], created_by: "test" }); + const task: AecSemanticTaskV1 = { + schema: AEC_SEMANTIC_TASK_V1_SCHEMA, + operation: "layout", + subject: { kind: "class", semantic_class: "electrical_equipment", terms: ["power plan"], categories: ["OST_ElectricalFixtures"], family_name: null, type_name: null, system_name: null, identifiers: [] }, + scope: { kind: "level", document: null, levels: ["L4"], rooms: [], spaces: [], areas: [], views: [], sheets: [], systems: [], element_ids: [], region: null }, + reference: { strategy: "current_project_precedent", source_description: null, source_room: null }, + mutation: { kind: "create", requested: true }, outputs: ["summary"], + execution: { max_results: 100, max_primary_actions: 8, allow_document_fallback: false, requires_visual_verification: true }, + confidence: { value: 0.95, ambiguity: "low", reasons: ["bounded level"] }, evidence: { user_text: request.user_text! } + }; + startAecLevelPowerPlanPilot(request, task, [{ id: 101, name: "L4 - Power", levelName: "L4" }]); + createRequirement({ scope: projectScope, key: "power.precedent_policy", text: "Use Level 5." }); + const result = maybeContinueAecLevelPowerPlanPilot({ ...request, message_id: "m2", user_text: "", tool_results: [{ action_id: "aec-power-view-101-inventory", method: "POST", path: "/revit/find-elements", status: "done", result_json: {} }] }); + assert.deepEqual(result?.actions, []); + assert.equal(result?.aec_query_receipt?.status, "failed"); + assert.match(result?.assistant_message ?? "", /changed or became blocked/); +}); + +test("active planning lease blocks requirement writes until the turn releases it", () => { + mkWorkspace(); + const ref = scope("engineer", "local"); + const receipt = resolveRequirements({ scope_refs: [ref] }); + const lease = beginRequirementsPlanningLease(receipt.receipt_sha256); + try { + assert.throws(() => createRequirement({ scope: ref, key: "tags.leaders", text: "Keep leaders short." }), /active planning lease/); + } finally { + endRequirementsPlanningLease(lease); + } + assert.equal(createRequirement({ scope: ref, key: "tags.leaders", text: "Keep leaders short." }).requirement.revision, 1); +}); + +test("filesystem planning lease blocks a second backend process sharing the ledger", () => { + const root = mkWorkspace(); + const ref = scope("engineer", "local"); + const receipt = resolveRequirements({ scope_refs: [ref] }); + const lease = beginRequirementsPlanningLease(receipt.receipt_sha256); + assert.ok(fs.existsSync(lease.lease_path)); + try { + const moduleUrl = pathToFileURL(path.resolve("dist/src/memory/requirements_store.js")).href; + const script = ` + const { createRequirement } = await import(${JSON.stringify(moduleUrl)}); + try { + createRequirement({ scope: { kind: "engineer", id: "local" }, key: "tags.leaders", text: "Cross-process write must block." }); + process.exit(2); + } catch (error) { + if (!/active planning lease/.test(error instanceof Error ? error.message : String(error))) process.exit(3); + process.stdout.write("blocked"); + } + `; + const output = execFileSync(process.execPath, ["--input-type=module", "--eval", script], { + cwd: process.cwd(), + env: { ...process.env, OPERATOR_WORKSPACE_ROOT: root }, + encoding: "utf8" + }); + assert.equal(output, "blocked"); + } finally { + endRequirementsPlanningLease(lease); + } + assert.equal(fs.existsSync(lease.lease_path), false); + assert.equal(createRequirement({ scope: ref, key: "tags.leaders", text: "Write after release." }).requirement.revision, 1); +}); + +test("OpenAI requirements policy formats prompts and blocks a stale action response", () => { + mkWorkspace(); + const request: ChatRequest = { + version: OPERATOR_BACKEND_CONTRACT_VERSION, + session_id: "requirements-openai-policy", + message_id: "m1", + user_text: "Keep tag leaders short.", + context: { revit: { document: { title: "Snowdon Towers HVAC", path: "C:\\Models\\Snowdon.rvt" } } } + }; + const guard = captureRequirementsResponseGuard(request); + assert.equal(guard.blocker, null); + const projectScope = deriveRequirementScopesForChat(request).find(row => row.kind === "project")!; + createRequirement({ scope: projectScope, key: "tags.leaders", text: "Keep leaders short." }); + assert.match(formatRequirementsPromptBlockSafely(request), /Keep leaders short/); + const guarded = enforceRequirementsResponseGuard(request, { + version: OPERATOR_BACKEND_CONTRACT_VERSION, + assistant_message: "Applying tags.", + actions: [{ action_id: "a1", method: "POST", path: "/revit/tag-elements", body: {} }] + }, guard); + assert.deepEqual(guarded.actions, []); + assert.match(guarded.assistant_message, /changed while this plan was being prepared/); + assert.equal(guarded.requirements_receipt?.applied[0]?.key, "tags.leaders"); +}); diff --git a/docs/requirements-memory.md b/docs/requirements-memory.md new file mode 100644 index 0000000..641b74c --- /dev/null +++ b/docs/requirements-memory.md @@ -0,0 +1,51 @@ +# Durable requirements memory + +Revit Operator keeps durable engineering requirements separately from its free-form daily and long-term recall notes. The v1 ledger is append-only and lives at: + +`%LOCALAPPDATA%\RevitOperator\Workspace\memory\requirements.v1.jsonl` + +In hosted mode, the workspace is isolated by tenant/license and user before the ledger is opened. V1 scope labels are therefore namespaces inside one user's isolated workspace; they are not yet a shared office or client library across multiple users. + +## Scope and precedence + +Every record has one exact scope: + +- `engineer` — an individual preference. +- `office` — an office standard. +- `client` — a client requirement. +- `project` — a project-specific requirement. + +For the same requirement key, precedence is `project > client > office > engineer`. Equal-precedence records with different text are a blocking conflict; the planner does not use “latest wins.” Retired, future, expired, and explicitly superseded revisions do not apply. + +Each create, revision, and retirement appends a full revision with a stable requirement ID, monotonic revision, effective dates, provenance, evidence references, and supersession links. Revisions require the exact current revision number. + +## Operator commands + +Examples: + +```text +remember engineer preference tags.leaders: Keep leaders short and uncrossed. +remember project requirement equipment.clearance: Show power-side service clearances. +remember requirement office bimtools tags.case: Use ALL CAPS. +remember requirement client hospital-a rooms.naming: Preserve the client room naming standard. +show requirements +explain requirements tags leaders +``` + +The project command derives a stable project scope from the active Revit document path (or title when the model has no path). Office/client generic commands require an explicit scope ID. + +## HTTP API + +All `/memory/*` routes require authentication, including the legacy project-profile routes. + +- `GET /memory/requirements` +- `POST /memory/requirements` +- `POST /memory/requirements/revise` +- `POST /memory/requirements/retire` +- `POST /memory/requirements/resolve` + +`resolve` returns a deterministic receipt containing exact applied and suppressed IDs/revisions, conflicts, scope references, and a SHA-256 receipt hash. A conflict, receipt overflow, unreadable ledger, or changed receipt blocks the integrated Level power-plan workflow before further Revit actions. + +## Compatibility boundary + +The existing `project_profile.json`, `remember project standard`, and free-form JSONL recall remain supported. They are not automatically rewritten or deleted. New durable requirements are not mirrored into generic memory, avoiding a second stale source of truth. diff --git a/scripts/backend_module_size_manifest.json b/scripts/backend_module_size_manifest.json index 99949b4..c075bcb 100644 --- a/scripts/backend_module_size_manifest.json +++ b/scripts/backend_module_size_manifest.json @@ -1,7 +1,7 @@ { "default_max_lines": 1200, "exceptions": [ - { "path": "brains/openai_brain.ts", "max_lines": 17620, "reason": "Legacy model orchestration monolith; extract provider, tool-loop, and prompt policy seams incrementally." }, + { "path": "brains/openai_brain.ts", "max_lines": 17625, "reason": "Legacy model orchestration monolith; durable requirements behavior is extracted to requirements_response_policy.ts, leaving five reviewed prompt/response wiring lines while provider and tool-loop seams continue to be extracted incrementally." }, { "path": "benchmark/revit_workflows.ts", "max_lines": 10838, "reason": "Legacy benchmark workflow dispatcher; extract one independently tested workflow family per batch." }, { "path": "benchmark/redline_routing_readiness.ts", "max_lines": 6285, "reason": "Legacy readiness report aggregator awaiting report-section extraction." }, { "path": "index.ts", "max_lines": 4146, "reason": "Backend composition root; private hosted routes make the integration variant larger than public. Added page-range forwarding for bounded large-PDF analysis." },