From 8f02524fd7445d8e97959319e0b46f048c0e7693 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Fri, 8 May 2026 19:55:39 +0200 Subject: [PATCH] Add untracked runtime extension files to git --- .../sf/tests/context-pack-projection.test.mjs | 162 ++++++++++++ .../sf/tests/model-role-policy.test.mjs | 104 ++++++++ .../sf/tests/model-route-evidence.test.mjs | 93 +++++++ .../extensions/sf/tests/unit-handoff.test.mjs | 107 ++++++++ .../sf/uok/context-pack-projection.js | 226 +++++++++++++++++ .../extensions/sf/uok/model-role-policy.js | 235 ++++++++++++++++++ .../extensions/sf/uok/model-route-evidence.js | 84 +++++++ .../extensions/sf/uok/unit-handoff.js | 212 ++++++++++++++++ 8 files changed, 1223 insertions(+) create mode 100644 src/resources/extensions/sf/tests/context-pack-projection.test.mjs create mode 100644 src/resources/extensions/sf/tests/model-role-policy.test.mjs create mode 100644 src/resources/extensions/sf/tests/model-route-evidence.test.mjs create mode 100644 src/resources/extensions/sf/tests/unit-handoff.test.mjs create mode 100644 src/resources/extensions/sf/uok/context-pack-projection.js create mode 100644 src/resources/extensions/sf/uok/model-role-policy.js create mode 100644 src/resources/extensions/sf/uok/model-route-evidence.js create mode 100644 src/resources/extensions/sf/uok/unit-handoff.js diff --git a/src/resources/extensions/sf/tests/context-pack-projection.test.mjs b/src/resources/extensions/sf/tests/context-pack-projection.test.mjs new file mode 100644 index 000000000..83c5f41cc --- /dev/null +++ b/src/resources/extensions/sf/tests/context-pack-projection.test.mjs @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { + buildWorkerContextPackProjection, + validateWorkerContextPackProjection, +} from "../uok/context-pack-projection.js"; + +test("buildProjection_when_contract_has_worker_context_returns_droid_sections", () => { + const projection = buildWorkerContextPackProjection({ + milestoneId: "M001", + sliceId: "S01", + taskId: "T01", + unitId: "M001/S01/T01", + unitType: "execute-task", + purpose: "Protect worker dispatch context.", + consumer: "Autonomous worker prompt adapter.", + preconditions: ["DB dispatch preview selected this task."], + expectedBehavior: ["Worker receives bounded context."], + verificationSteps: ["Run focused projection tests."], + fulfills: ["REQ-worker-context-pack"], + assertionIds: ["ASSERT-db-remains-canonical"], + allowedCommands: ["npm run test:unit"], + allowedServices: ["git"], + workerSkill: { + id: "worker-context", + name: "Worker Context", + instructions: ["Use projected context as read-only prompt input."], + }, + library: { + references: ["docs/specs/sf-operating-model.md"], + files: ["src/resources/extensions/sf/uok/context-pack-projection.js"], + assumptions: ["Runtime wiring happens later."], + }, + }); + + assert.equal(projection.origin, "worker-context-pack-projection"); + assert.equal(projection.canonicalState.source, "sf-db"); + assert.equal(projection.canonicalState.projectionOnly, true); + assert.deepEqual( + Object.keys(projection).filter((key) => + [ + "services", + "validationContract", + "unitContract", + "workerSkill", + "library", + ].includes(key), + ), + [ + "services", + "validationContract", + "unitContract", + "workerSkill", + "library", + ], + ); + assert.deepEqual(projection.services.allowedCommands, ["npm run test:unit"]); + assert.deepEqual(projection.services.allowedServices, ["git"]); + assert.deepEqual(projection.validationContract.preconditions, [ + "DB dispatch preview selected this task.", + ]); + assert.deepEqual(projection.validationContract.expectedBehavior, [ + "Worker receives bounded context.", + ]); + assert.deepEqual(projection.validationContract.verificationSteps, [ + "Run focused projection tests.", + ]); + assert.deepEqual(projection.validationContract.fulfills, [ + "REQ-worker-context-pack", + ]); + assert.deepEqual(projection.validationContract.assertionIds, [ + "ASSERT-db-remains-canonical", + ]); + assert.equal( + projection.unitContract.purpose, + "Protect worker dispatch context.", + ); + assert.equal(projection.workerSkill.id, "worker-context"); + assert.deepEqual(projection.library.references, [ + "docs/specs/sf-operating-model.md", + ]); +}); + +test("buildProjection_when_contract_uses_nested_sections_preserves_allowed_surfaces", () => { + const projection = buildWorkerContextPackProjection({ + validationContract: { + preconditions: "Canonical DB unit exists.", + expectedBehavior: "Projection contains prompt-safe shape.", + verificationSteps: "Validate the pack before rendering.", + assertionIds: ["ASSERT-shape"], + }, + unitContract: { + unitId: "M010/S02/T03", + unitType: "feature-contract", + fulfills: ["REQ-context"], + }, + services: { + allowedCommands: ["npm test", "npm test"], + allowedServices: ["sqlite", "git"], + }, + workerSkill: { + allowedCommands: ["npx vitest run"], + allowedServices: ["sqlite"], + }, + }); + + assert.deepEqual(projection.services.allowedCommands, [ + "npm test", + "npx vitest run", + ]); + assert.deepEqual(projection.services.allowedServices, ["sqlite", "git"]); + assert.deepEqual(projection.validationContract.preconditions, [ + "Canonical DB unit exists.", + ]); + assert.deepEqual(projection.validationContract.assertionIds, [ + "ASSERT-shape", + ]); + assert.deepEqual(projection.validationContract.fulfills, ["REQ-context"]); + assert.equal(projection.unitRef.unitId, "M010/S02/T03"); + assert.equal(projection.unitRef.unitType, "feature-contract"); +}); + +test("buildProjection_when_optional_fields_missing_returns_empty_arrays_not_runtime_reads", () => { + const projection = buildWorkerContextPackProjection({ + unitId: "M001/S01/T99", + }); + + assert.deepEqual(projection.services.allowedCommands, []); + assert.deepEqual(projection.services.allowedServices, []); + assert.deepEqual(projection.validationContract.preconditions, []); + assert.deepEqual(projection.validationContract.expectedBehavior, []); + assert.deepEqual(projection.validationContract.verificationSteps, []); + assert.deepEqual(projection.library.files, []); + assert.match(projection.canonicalState.note, /projection only/); + assert.equal(validateWorkerContextPackProjection(projection).valid, true); +}); + +test("validateProjection_when_required_section_missing_reports_invalid_projection", () => { + const projection = buildWorkerContextPackProjection({ + preconditions: ["DB unit exists."], + }); + delete projection.validationContract; + + const result = validateWorkerContextPackProjection(projection); + + assert.equal(result.valid, false); + assert.ok(result.issues.includes("validationContract section is required")); +}); + +test("validateProjection_when_array_fields_are_not_arrays_reports_field_issue", () => { + const projection = buildWorkerContextPackProjection({ + allowedCommands: ["npm run test:unit"], + }); + projection.services.allowedCommands = "npm run test:unit"; + + const result = validateWorkerContextPackProjection(projection); + + assert.equal(result.valid, false); + assert.ok( + result.issues.includes("services.allowedCommands must be an array"), + ); +}); diff --git a/src/resources/extensions/sf/tests/model-role-policy.test.mjs b/src/resources/extensions/sf/tests/model-role-policy.test.mjs new file mode 100644 index 000000000..67fbe9300 --- /dev/null +++ b/src/resources/extensions/sf/tests/model-role-policy.test.mjs @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import { describe, test } from "vitest"; + +import { + ModelRolePolicyValidationError, + normalizeRolePolicies, + normalizeRolePolicy, + validateRolePolicy, +} from "../uok/model-role-policy.js"; + +describe("model role policy", () => { + test("normalizeRolePolicy_when_role_has_no_override_returns_auto_policy_with_symbolic_constraints", () => { + assert.deepEqual(normalizeRolePolicy("worker"), { + role: "worker", + mode: "auto", + constraints: ["coding"], + }); + }); + + test("normalizeRolePolicy_when_constraints_use_alias_casing_returns_canonical_constraint_names", () => { + assert.deepEqual( + normalizeRolePolicy("validator", { + mode: "auto", + constraints: ["STRICT_JSON", "review", "strict-json"], + }), + { + role: "validator", + mode: "auto", + constraints: ["strict-json", "review"], + }, + ); + }); + + test("validateRolePolicy_when_constraint_unknown_returns_clear_validation_error", () => { + const result = validateRolePolicy("reviewer", { + mode: "auto", + constraints: ["review", "vision-model"], + }); + + assert.equal(result.ok, false); + assert.match(result.errors[0], /unsupported constraint "vision-model"/); + assert.match(result.errors[0], /coding, review, cheap, long-context/); + assert.equal(result.policy, null); + }); + + test("normalizeRolePolicy_when_policy_contains_concrete_model_id_rejects_durable_route_config", () => { + assert.throws( + () => + normalizeRolePolicy("orchestrator", { + mode: "auto", + modelId: "concrete-route", + }), + (error) => { + assert.equal(error instanceof ModelRolePolicyValidationError, true); + assert.match(error.message, /durable policy cannot include "modelId"/); + return true; + }, + ); + }); + + test("normalizeRolePolicy_when_mode_is_concrete_model_rejects_non_auto_intent", () => { + assert.throws( + () => normalizeRolePolicy("worker", { model: "concrete-route" }), + /must use mode "auto"/, + ); + }); + + test("normalizeRolePolicies_when_partial_policy_supplied_projects_all_supported_roles", () => { + assert.deepEqual( + normalizeRolePolicies({ + Reviewer: { mode: "auto", constraints: ["cheap", "review"] }, + }), + { + orchestrator: { + role: "orchestrator", + mode: "auto", + constraints: ["long-context", "strict-json"], + }, + worker: { + role: "worker", + mode: "auto", + constraints: ["coding"], + }, + validator: { + role: "validator", + mode: "auto", + constraints: ["strict-json", "review"], + }, + reviewer: { + role: "reviewer", + mode: "auto", + constraints: ["cheap", "review"], + }, + }, + ); + }); + + test("normalizeRolePolicies_when_role_unknown_rejects_projection", () => { + assert.throws( + () => normalizeRolePolicies({ planner: { mode: "auto" } }), + /unsupported model role "planner"/, + ); + }); +}); diff --git a/src/resources/extensions/sf/tests/model-route-evidence.test.mjs b/src/resources/extensions/sf/tests/model-route-evidence.test.mjs new file mode 100644 index 000000000..2608b590e --- /dev/null +++ b/src/resources/extensions/sf/tests/model-route-evidence.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, test } from "vitest"; +import { + emitModelAutoResolvedEvent, + modelRoleForUnitType, +} from "../uok/model-route-evidence.js"; + +const tmpRoots = []; + +afterEach(() => { + for (const dir of tmpRoots.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeProject() { + const root = mkdtempSync(join(tmpdir(), "sf-model-route-evidence-")); + tmpRoots.push(root); + return root; +} + +function readAuditEvents(project) { + const path = join(project, ".sf", "audit", "events.jsonl"); + return readFileSync(path, "utf-8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +test("modelRoleForUnitType_when_validation_unit_returns_validator", () => { + assert.equal(modelRoleForUnitType("validation-gate"), "validator"); + assert.equal(modelRoleForUnitType("run-verification"), "validator"); +}); + +test("modelRoleForUnitType_when_review_unit_returns_reviewer", () => { + assert.equal(modelRoleForUnitType("scrutiny-review"), "reviewer"); +}); + +test("modelRoleForUnitType_when_regular_unit_returns_worker", () => { + assert.equal(modelRoleForUnitType("execute-task"), "worker"); +}); + +test("emitModelAutoResolvedEvent_records_auto_policy_and_resolved_route", () => { + const project = makeProject(); + + emitModelAutoResolvedEvent(project, { + traceId: "trace-model-1", + unitType: "execute-task", + unitId: "M001/S01/T01", + resolvedModel: { + provider: "zai", + id: "glm-5.1", + api: "openai-chat", + apiKey: "secret-key", + }, + authMode: "apiKey", + routingReason: "auto selector", + routing: { + tier: "heavy", + modelDowngraded: false, + }, + tokenUsage: 1234, + }); + + const [event] = readAuditEvents(project); + assert.equal(event.schemaVersion, 1); + assert.equal(event.category, "model-routing"); + assert.equal(event.type, "model-auto-resolved"); + assert.equal(event.traceId, "trace-model-1"); + assert.equal(event.turnId, "M001/S01/T01"); + assert.equal(event.payload.role, "worker"); + assert.deepEqual(event.payload.requestedPolicy, { + mode: "auto", + constraints: [], + }); + assert.equal(event.payload.resolvedProvider, "zai"); + assert.equal(event.payload.resolvedModel, "glm-5.1"); + assert.equal(event.payload.authMode, "apiKey"); + assert.deepEqual(event.payload.routeSnapshot.requestedPolicy, { + mode: "auto", + constraints: [], + }); + assert.equal(event.payload.routeSnapshot.route.provider, "zai"); + assert.equal(event.payload.routeSnapshot.route.model, "glm-5.1"); + assert.equal(event.payload.routeSnapshot.route.byok, true); + assert.equal(event.payload.routeSnapshot.route.apiKey, undefined); + assert.equal(event.payload.routingReason, "auto selector"); + assert.equal(event.payload.tokenUsage, 1234); +}); diff --git a/src/resources/extensions/sf/tests/unit-handoff.test.mjs b/src/resources/extensions/sf/tests/unit-handoff.test.mjs new file mode 100644 index 000000000..8cfaf0ba6 --- /dev/null +++ b/src/resources/extensions/sf/tests/unit-handoff.test.mjs @@ -0,0 +1,107 @@ +/** + * unit-handoff.test.mjs - unit closeout schema contracts. + * + * Purpose: prove SF preserves useful Droid handoff fields as validated + * structured data that follow-on autonomous units can trust. + */ + +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { + normalizeUnitHandoff, + validateUnitHandoff, +} from "../uok/unit-handoff.js"; + +test("normalizeUnitHandoff_when_payload_has_droid_closeout_fields_returns_canonical_contract", () => { + const handoff = normalizeUnitHandoff({ + summary: " Added unit handoff schema. ", + filesChanged: [" src/resources/extensions/sf/uok/unit-handoff.js "], + testsAdded: [" src/resources/extensions/sf/tests/unit-handoff.test.mjs "], + commandsRun: [ + { + command: " npm run test:unit -- unit-handoff.test.mjs ", + status: "passed", + exitCode: 0, + cwd: " /repo ", + notes: " focused schema test ", + durationMs: 124, + }, + ], + knownFailures: [" none "], + leftUndone: [" wire into finalizer later "], + fulfilledAssertions: [" malformed commands are rejected "], + verificationStatus: "passed", + }); + + assert.deepEqual(handoff, { + summary: "Added unit handoff schema.", + filesChanged: ["src/resources/extensions/sf/uok/unit-handoff.js"], + testsAdded: ["src/resources/extensions/sf/tests/unit-handoff.test.mjs"], + commandsRun: [ + { + command: "npm run test:unit -- unit-handoff.test.mjs", + status: "passed", + exitCode: 0, + cwd: "/repo", + notes: "focused schema test", + durationMs: 124, + }, + ], + knownFailures: ["none"], + leftUndone: ["wire into finalizer later"], + fulfilledAssertions: ["malformed commands are rejected"], + verificationStatus: "passed", + }); +}); + +test("normalizeUnitHandoff_when_optional_fields_missing_returns_empty_arrays_and_unknown_status", () => { + assert.deepEqual(normalizeUnitHandoff({ summary: "No changes needed." }), { + summary: "No changes needed.", + filesChanged: [], + testsAdded: [], + commandsRun: [], + knownFailures: [], + leftUndone: [], + fulfilledAssertions: [], + verificationStatus: "unknown", + }); +}); + +test("validateUnitHandoff_when_array_field_is_not_array_reports_clear_field_error", () => { + const result = validateUnitHandoff({ + summary: "Bad handoff.", + filesChanged: "src/file.js", + testsAdded: [], + commandsRun: [], + knownFailures: [], + leftUndone: [], + fulfilledAssertions: [], + verificationStatus: "failed", + }); + + assert.equal(result.ok, false); + assert.deepEqual(result.errors, ["filesChanged must be an array"]); +}); + +test("normalizeUnitHandoff_when_command_entry_is_malformed_rejects_with_command_path", () => { + assert.throws( + () => + normalizeUnitHandoff({ + summary: "Bad command.", + commandsRun: [{ status: "passed", exitCode: 0 }], + }), + /Invalid unit handoff: commandsRun\[0\]\.command must be a non-empty string/, + ); +}); + +test("validateUnitHandoff_when_command_entry_has_invalid_status_and_exit_code_reports_both_errors", () => { + const result = validateUnitHandoff({ + commandsRun: [{ command: "npm test", status: "green", exitCode: -1 }], + }); + + assert.equal(result.ok, false); + assert.deepEqual(result.errors, [ + "commandsRun[0].status must be one of passed, failed, skipped, not-run, unknown", + "commandsRun[0].exitCode must be a non-negative integer", + ]); +}); diff --git a/src/resources/extensions/sf/uok/context-pack-projection.js b/src/resources/extensions/sf/uok/context-pack-projection.js new file mode 100644 index 000000000..12ad5854e --- /dev/null +++ b/src/resources/extensions/sf/uok/context-pack-projection.js @@ -0,0 +1,226 @@ +/** + * context-pack-projection.js - worker context pack projection shapes. + * + * Purpose: expose a Droid-like worker context pack view without making that + * view executable or canonical; SF DB/runtime records remain the source of + * truth for dispatch, validation, and lifecycle state. + * + * Consumer: UOK projection tests and future worker prompt/rendering adapters. + */ + +const PROJECTION_SCHEMA_VERSION = 1; +const REQUIRED_SECTIONS = [ + "services", + "validationContract", + "unitContract", + "workerSkill", + "library", +]; + +function stringOrNull(value) { + return typeof value === "string" && value.trim().length > 0 + ? value.trim() + : null; +} + +function stringList(value) { + if (Array.isArray(value)) { + return value + .map((item) => stringOrNull(item)) + .filter((item) => item !== null); + } + const single = stringOrNull(value); + return single === null ? [] : [single]; +} + +function idList(...values) { + return [...new Set(values.flatMap((value) => stringList(value)))]; +} + +function objectOrEmpty(value) { + return value && typeof value === "object" && !Array.isArray(value) + ? value + : {}; +} + +/** + * Builds a Droid-like worker context pack projection from a unit or feature contract. + * + * Purpose: give workers a stable read model with service, validation, unit, + * skill, and library sections while preventing prompt-facing context from + * becoming authoritative SF state. + * + * Consumer: worker context pack tests and future prompt projection adapters. + * + * @param {Record} contract + * @returns {Record} + */ +export function buildWorkerContextPackProjection(contract = {}) { + const source = objectOrEmpty(contract); + const validation = objectOrEmpty(source.validationContract); + const unit = objectOrEmpty(source.unitContract); + const workerSkill = objectOrEmpty(source.workerSkill); + const library = objectOrEmpty(source.library); + const services = objectOrEmpty(source.services); + + const fulfills = idList( + source.fulfills, + source.fulfillmentIds, + unit.fulfills, + ); + const assertions = idList( + source.assertions, + source.assertionIds, + validation.assertions, + validation.assertionIds, + ); + const allowedCommands = idList( + source.allowedCommands, + services.allowedCommands, + workerSkill.allowedCommands, + ); + const allowedServices = idList( + source.allowedServices, + services.allowedServices, + workerSkill.allowedServices, + ); + + return { + schemaVersion: PROJECTION_SCHEMA_VERSION, + origin: "worker-context-pack-projection", + canonicalState: { + source: "sf-db", + runtimeAuthority: "sf-runtime", + projectionOnly: true, + note: "This object is a worker-facing projection only; DB/runtime state remains canonical.", + }, + unitRef: { + milestoneId: stringOrNull(source.milestoneId), + sliceId: stringOrNull(source.sliceId), + taskId: stringOrNull(source.taskId), + unitId: stringOrNull(source.unitId ?? unit.unitId), + unitType: stringOrNull(source.unitType ?? unit.unitType), + featureId: stringOrNull(source.featureId), + }, + services: { + allowedServices, + allowedCommands, + disallowedByDefault: true, + notes: stringList(services.notes ?? source.serviceNotes), + }, + validationContract: { + preconditions: stringList( + validation.preconditions ?? source.preconditions, + ), + expectedBehavior: stringList( + validation.expectedBehavior ?? source.expectedBehavior, + ), + verificationSteps: stringList( + validation.verificationSteps ?? source.verificationSteps, + ), + assertionIds: assertions, + fulfills, + }, + unitContract: { + purpose: stringOrNull(unit.purpose ?? source.purpose), + consumer: stringOrNull(unit.consumer ?? source.consumer), + preconditions: stringList(unit.preconditions ?? source.preconditions), + expectedBehavior: stringList( + unit.expectedBehavior ?? source.expectedBehavior, + ), + verificationSteps: stringList( + unit.verificationSteps ?? source.verificationSteps, + ), + fulfills, + assertionIds: assertions, + }, + workerSkill: { + id: stringOrNull(workerSkill.id ?? source.workerSkillId), + name: stringOrNull(workerSkill.name ?? source.workerSkillName), + instructions: stringList( + workerSkill.instructions ?? source.workerInstructions, + ), + allowedCommands, + allowedServices, + }, + library: { + references: stringList(library.references ?? source.references), + files: stringList(library.files ?? source.files), + assumptions: stringList(library.assumptions ?? source.assumptions), + }, + }; +} + +/** + * Validates the worker context pack projection shape without reading runtime state. + * + * Purpose: catch malformed projection objects before a prompt adapter consumes + * them while keeping DB/runtime records authoritative for actual execution. + * + * Consumer: worker context pack tests and future prompt projection adapters. + * + * @param {unknown} projection + * @returns {{ valid: true; issues: [] } | { valid: false; issues: string[] }} + */ +export function validateWorkerContextPackProjection(projection) { + const issues = []; + if ( + !projection || + typeof projection !== "object" || + Array.isArray(projection) + ) { + return { valid: false, issues: ["projection must be an object"] }; + } + const pack = projection; + if (pack.schemaVersion !== PROJECTION_SCHEMA_VERSION) { + issues.push("schemaVersion must be 1"); + } + if (pack.origin !== "worker-context-pack-projection") { + issues.push("origin must be worker-context-pack-projection"); + } + if (pack.canonicalState?.projectionOnly !== true) { + issues.push("canonicalState.projectionOnly must be true"); + } + if (pack.canonicalState?.source !== "sf-db") { + issues.push("canonicalState.source must be sf-db"); + } + for (const section of REQUIRED_SECTIONS) { + if (!pack[section] || typeof pack[section] !== "object") { + issues.push(`${section} section is required`); + } + } + for (const [section, fields] of [ + ["services", ["allowedServices", "allowedCommands"]], + [ + "validationContract", + [ + "preconditions", + "expectedBehavior", + "verificationSteps", + "assertionIds", + "fulfills", + ], + ], + [ + "unitContract", + [ + "preconditions", + "expectedBehavior", + "verificationSteps", + "assertionIds", + "fulfills", + ], + ], + ]) { + const value = pack[section]; + if (!value || typeof value !== "object") continue; + for (const field of fields) { + if (!Array.isArray(value[field])) { + issues.push(`${section}.${field} must be an array`); + } + } + } + return issues.length === 0 + ? { valid: true, issues: [] } + : { valid: false, issues }; +} diff --git a/src/resources/extensions/sf/uok/model-role-policy.js b/src/resources/extensions/sf/uok/model-role-policy.js new file mode 100644 index 000000000..b3ebbf050 --- /dev/null +++ b/src/resources/extensions/sf/uok/model-role-policy.js @@ -0,0 +1,235 @@ +/** + * model-role-policy.js -- role intent projection for autonomous model routing. + * + * Purpose: preserve durable SF model-routing policy as role-level intent and + * symbolic constraints so auto model selection remains the only component that + * resolves concrete provider/model IDs. + */ + +/** + * List the autonomous roles that can carry durable model-routing intent. + * + * Purpose: keep role-policy validation aligned with SF's autonomous dispatch + * vocabulary instead of allowing arbitrary role names to become configuration. + * + * Consumer: preference/import projection code before passing policy intent to + * autonomous model selection. + */ +export const SUPPORTED_MODEL_ROLES = Object.freeze([ + "orchestrator", + "worker", + "validator", + "reviewer", +]); + +/** + * List symbolic constraints that role policy can persist. + * + * Purpose: allow durable policy to express routing needs without persisting + * provider names, model names, or custom model IDs. + * + * Consumer: role-policy validation and autonomous route evidence writers. + */ +export const SUPPORTED_MODEL_ROLE_CONSTRAINTS = Object.freeze([ + "coding", + "review", + "cheap", + "long-context", + "byok-allowed", + "local-only", + "strict-json", +]); + +const MODEL_ROLES = new Set(SUPPORTED_MODEL_ROLES); +const MODEL_ROLE_CONSTRAINTS = new Set(SUPPORTED_MODEL_ROLE_CONSTRAINTS); +const CONCRETE_ROUTE_KEYS = new Set([ + "provider", + "providerId", + "modelId", + "model_id", + "customModelId", + "custom_model_id", +]); + +/** + * Provide SF's default symbolic routing constraints per autonomous role. + * + * Purpose: encode the useful Droid-style role projection while leaving final + * provider/model choice to SF's existing auto model selection. + * + * Consumer: normalizeRolePolicy when a role has no explicit constraints. + */ +export const DEFAULT_MODEL_ROLE_CONSTRAINTS = Object.freeze({ + orchestrator: Object.freeze(["long-context", "strict-json"]), + worker: Object.freeze(["coding"]), + validator: Object.freeze(["strict-json", "review"]), + reviewer: Object.freeze(["review"]), +}); + +/** + * Report invalid durable model-role policy input. + * + * Purpose: give preference and projection callers one clear error type when a + * role policy tries to persist unsupported roles, constraints, or route IDs. + * + * Consumer: normalizeRolePolicy and normalizeRolePolicies callers that surface + * validation failures to users or startup diagnostics. + */ +export class ModelRolePolicyValidationError extends Error { + constructor(errors) { + super(`Invalid model role policy: ${errors.join("; ")}`); + this.name = "ModelRolePolicyValidationError"; + this.errors = errors; + } +} + +function normalizeRoleName(role) { + return String(role ?? "") + .trim() + .toLowerCase(); +} + +function normalizeConstraintName(constraint) { + return String(constraint ?? "") + .trim() + .toLowerCase() + .replaceAll("_", "-"); +} + +function constraintsFromPolicy(role, policy) { + if (Array.isArray(policy)) return policy; + if ( + policy && + typeof policy === "object" && + Array.isArray(policy.constraints) + ) { + return policy.constraints; + } + return DEFAULT_MODEL_ROLE_CONSTRAINTS[role] ?? []; +} + +function modeFromPolicy(policy) { + if (policy === undefined || policy === null) return "auto"; + if (policy === "auto") return "auto"; + if (Array.isArray(policy)) return "auto"; + if (policy && typeof policy === "object") { + return policy.mode ?? policy.model ?? "auto"; + } + return policy; +} + +function validateOne(role, policy) { + const normalizedRole = normalizeRoleName(role); + const errors = []; + if (!MODEL_ROLES.has(normalizedRole)) { + errors.push( + `unsupported model role "${String(role)}"; expected one of ${SUPPORTED_MODEL_ROLES.join(", ")}`, + ); + } + if (policy && typeof policy === "object" && !Array.isArray(policy)) { + for (const key of Object.keys(policy)) { + if (CONCRETE_ROUTE_KEYS.has(key)) { + errors.push( + `role "${normalizedRole}" must use mode "auto"; durable policy cannot include "${key}"`, + ); + } + } + } + const mode = modeFromPolicy(policy); + if (mode !== "auto") { + errors.push( + `role "${normalizedRole}" must use mode "auto"; received "${String(mode)}"`, + ); + } + const seen = new Set(); + const constraints = []; + for (const rawConstraint of constraintsFromPolicy(normalizedRole, policy)) { + const constraint = normalizeConstraintName(rawConstraint); + if (!MODEL_ROLE_CONSTRAINTS.has(constraint)) { + errors.push( + `role "${normalizedRole}" has unsupported constraint "${String(rawConstraint)}"; expected one of ${SUPPORTED_MODEL_ROLE_CONSTRAINTS.join(", ")}`, + ); + continue; + } + if (!seen.has(constraint)) { + seen.add(constraint); + constraints.push(constraint); + } + } + return { + ok: errors.length === 0, + errors, + policy: + errors.length === 0 + ? { role: normalizedRole, mode: "auto", constraints } + : null, + }; +} + +/** + * Validate one durable autonomous model role policy. + * + * Purpose: let callers reject provider-specific routing data before it reaches + * SF's durable preference or planning state. + * + * Consumer: preference projection, Droid-pattern importers, and tests that need + * non-throwing diagnostics. + */ +export function validateRolePolicy(role, policy = undefined) { + return validateOne(role, policy); +} + +/** + * Normalize one durable autonomous model role policy. + * + * Purpose: produce the canonical role-policy shape consumed by autonomous model + * selection while guaranteeing concrete route IDs are not persisted. + * + * Consumer: startup preference loading and any future projection step that feeds + * role intent into UOK model route evidence. + */ +export function normalizeRolePolicy(role, policy = undefined) { + const result = validateOne(role, policy); + if (!result.ok) throw new ModelRolePolicyValidationError(result.errors); + return result.policy; +} + +/** + * Normalize a role-to-policy map for autonomous model routing. + * + * Purpose: project partial durable policy into a complete role map where every + * supported autonomous role requests SF's existing auto model selection. + * + * Consumer: SF-native policy import/projection code before dispatch starts an + * autonomous run. + */ +export function normalizeRolePolicies(policies = {}) { + if (!policies || typeof policies !== "object" || Array.isArray(policies)) { + throw new ModelRolePolicyValidationError([ + "model role policies must be an object keyed by role", + ]); + } + const unknownRoles = Object.keys(policies).filter( + (role) => !MODEL_ROLES.has(normalizeRoleName(role)), + ); + if (unknownRoles.length > 0) { + throw new ModelRolePolicyValidationError( + unknownRoles.map( + (role) => + `unsupported model role "${role}"; expected one of ${SUPPORTED_MODEL_ROLES.join(", ")}`, + ), + ); + } + const normalizedPolicies = Object.fromEntries( + Object.entries(policies).map(([role, policy]) => [ + normalizeRoleName(role), + policy, + ]), + ); + return Object.fromEntries( + SUPPORTED_MODEL_ROLES.map((role) => [ + role, + normalizeRolePolicy(role, normalizedPolicies[role]), + ]), + ); +} diff --git a/src/resources/extensions/sf/uok/model-route-evidence.js b/src/resources/extensions/sf/uok/model-route-evidence.js new file mode 100644 index 000000000..f46e06b36 --- /dev/null +++ b/src/resources/extensions/sf/uok/model-route-evidence.js @@ -0,0 +1,84 @@ +import { buildAuditEnvelope, emitUokAuditEvent } from "./audit.js"; +import { buildModelRouteSnapshot } from "./model-route-snapshot.js"; + +/** + * Return the model role implied by an autonomous unit type. + * + * Purpose: keep SF's durable model policy role-based (`worker`, `validator`, + * `reviewer`) while still allowing the dispatcher to journal the concrete + * provider/model chosen at runtime. + * + * Consumer: auto/phases.js after selectAndApplyModel resolves the final unit + * model, including hook overrides. + */ +export function modelRoleForUnitType(unitType) { + const normalized = String(unitType ?? "").toLowerCase(); + if ( + normalized.includes("validate") || + normalized.includes("verification") || + normalized.includes("gate") + ) { + return "validator"; + } + if (normalized.includes("review") || normalized.includes("scrutiny")) { + return "reviewer"; + } + return "worker"; +} + +/** + * Emit runtime evidence for SF's auto-selected model route. + * + * Purpose: preserve the useful Droid-style role/accounting trail without + * promoting provider-specific model IDs into durable SF configuration. + * + * Consumer: autonomous run-unit dispatch telemetry and UOK audit readers. + */ +export function emitModelAutoResolvedEvent(basePath, args) { + const model = args.resolvedModel; + const provider = model?.provider ?? null; + const modelId = model?.id ?? null; + const role = args.role ?? modelRoleForUnitType(args.unitType); + const requestedPolicy = { + mode: "auto", + constraints: args.constraints ?? [], + }; + const routeSnapshot = buildModelRouteSnapshot({ + role, + unitType: args.unitType, + unitId: args.unitId, + requestedPolicy, + route: { + ...(model ?? {}), + authMode: args.authMode, + }, + routingReason: args.routingReason, + fallbacksTried: args.fallbacksTried ?? [], + configEvidence: args.configEvidence, + }); + emitUokAuditEvent( + basePath, + buildAuditEnvelope({ + traceId: args.traceId, + turnId: args.unitId, + category: "model-routing", + type: "model-auto-resolved", + payload: { + role, + unitType: args.unitType, + unitId: args.unitId, + requestedPolicy, + resolvedProvider: provider, + resolvedModel: modelId, + authMode: args.authMode, + routeSnapshot, + routingReason: args.routingReason, + fallbacksTried: args.fallbacksTried ?? [], + routing: args.routing ?? null, + hookOverrideApplied: Boolean(args.hookOverrideApplied), + tokenUsage: args.tokenUsage, + costEstimate: args.costEstimate, + }, + }), + ); +} diff --git a/src/resources/extensions/sf/uok/unit-handoff.js b/src/resources/extensions/sf/uok/unit-handoff.js new file mode 100644 index 000000000..367ae728d --- /dev/null +++ b/src/resources/extensions/sf/uok/unit-handoff.js @@ -0,0 +1,212 @@ +/** + * unit-handoff.js - structured autonomous unit closeout schema. + * + * Purpose: preserve the useful Droid closeout fields as a small, validated + * contract that later units can consume without parsing prose summaries. + * + * Consumer: SF autonomous unit finalizers and follow-on unit prompts that need + * a durable summary of what the previous unit changed, verified, and left open. + */ + +const ARRAY_FIELDS = [ + "filesChanged", + "testsAdded", + "commandsRun", + "knownFailures", + "leftUndone", + "fulfilledAssertions", +]; + +const STRING_ARRAY_FIELDS = new Set([ + "filesChanged", + "testsAdded", + "knownFailures", + "leftUndone", + "fulfilledAssertions", +]); + +const COMMAND_STATUSES = new Set([ + "passed", + "failed", + "skipped", + "not-run", + "unknown", +]); + +const VERIFICATION_STATUSES = new Set([ + "passed", + "failed", + "partial", + "blocked", + "not-run", + "unknown", +]); + +function isPlainObject(value) { + return ( + value !== null && + typeof value === "object" && + (Object.getPrototypeOf(value) === Object.prototype || + Object.getPrototypeOf(value) === null) + ); +} + +function normalizeString(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function normalizeStringArray(values) { + return values.map((value) => value.trim()).filter(Boolean); +} + +function normalizeCommandEntry(entry) { + const normalized = { + command: entry.command.trim(), + status: normalizeString(entry.status) || "unknown", + }; + if ("exitCode" in entry) { + normalized.exitCode = entry.exitCode; + } + if ("cwd" in entry && normalizeString(entry.cwd)) { + normalized.cwd = entry.cwd.trim(); + } + if ("notes" in entry && normalizeString(entry.notes)) { + normalized.notes = entry.notes.trim(); + } + if ("durationMs" in entry) { + normalized.durationMs = entry.durationMs; + } + return normalized; +} + +function validateCommandEntry(entry, index, errors) { + if (!isPlainObject(entry)) { + errors.push(`commandsRun[${index}] must be an object`); + return; + } + if (typeof entry.command !== "string" || entry.command.trim() === "") { + errors.push(`commandsRun[${index}].command must be a non-empty string`); + } + if ( + "status" in entry && + (typeof entry.status !== "string" || !COMMAND_STATUSES.has(entry.status)) + ) { + errors.push( + `commandsRun[${index}].status must be one of ${Array.from(COMMAND_STATUSES).join(", ")}`, + ); + } + if ( + "exitCode" in entry && + (!Number.isInteger(entry.exitCode) || entry.exitCode < 0) + ) { + errors.push( + `commandsRun[${index}].exitCode must be a non-negative integer`, + ); + } + if ("cwd" in entry && typeof entry.cwd !== "string") { + errors.push(`commandsRun[${index}].cwd must be a string`); + } + if ("notes" in entry && typeof entry.notes !== "string") { + errors.push(`commandsRun[${index}].notes must be a string`); + } + if ( + "durationMs" in entry && + (!Number.isFinite(entry.durationMs) || entry.durationMs < 0) + ) { + errors.push( + `commandsRun[${index}].durationMs must be a non-negative number`, + ); + } +} + +/** + * Validate a candidate unit handoff and return every schema violation. + * + * Purpose: fail malformed closeout payloads before they become durable unit + * state that future agents trust as verified work history. + * + * Consumer: normalizeUnitHandoff and any caller that wants to show structured + * validation errors without throwing. + */ +export function validateUnitHandoff(candidate) { + const errors = []; + if (!isPlainObject(candidate)) { + return { + ok: false, + errors: ["handoff must be an object"], + }; + } + + if ("summary" in candidate && typeof candidate.summary !== "string") { + errors.push("summary must be a string"); + } + if ( + "verificationStatus" in candidate && + (typeof candidate.verificationStatus !== "string" || + !VERIFICATION_STATUSES.has(candidate.verificationStatus)) + ) { + errors.push( + `verificationStatus must be one of ${Array.from(VERIFICATION_STATUSES).join(", ")}`, + ); + } + + for (const field of ARRAY_FIELDS) { + if (field in candidate && !Array.isArray(candidate[field])) { + errors.push(`${field} must be an array`); + } + } + + for (const field of STRING_ARRAY_FIELDS) { + if (!Array.isArray(candidate[field])) { + continue; + } + candidate[field].forEach((entry, index) => { + if (typeof entry !== "string") { + errors.push(`${field}[${index}] must be a string`); + } + }); + } + + if (Array.isArray(candidate.commandsRun)) { + candidate.commandsRun.forEach((entry, index) => { + validateCommandEntry(entry, index, errors); + }); + } + + return { + ok: errors.length === 0, + errors, + }; +} + +/** + * Normalize a validated unit handoff into SF's canonical closeout shape. + * + * Purpose: give autonomous follow-on units a predictable structured payload + * instead of letting missing fields, whitespace, or prose-only closeouts drift. + * + * Consumer: autonomous unit closeout writers before persisting handoff records + * and prompt builders before injecting prior-unit context. + */ +export function normalizeUnitHandoff(candidate = {}) { + const validation = validateUnitHandoff(candidate); + if (!validation.ok) { + throw new TypeError( + `Invalid unit handoff: ${validation.errors.join("; ")}`, + ); + } + + return { + summary: normalizeString(candidate.summary), + filesChanged: normalizeStringArray(candidate.filesChanged ?? []), + testsAdded: normalizeStringArray(candidate.testsAdded ?? []), + commandsRun: (candidate.commandsRun ?? []).map(normalizeCommandEntry), + knownFailures: normalizeStringArray(candidate.knownFailures ?? []), + leftUndone: normalizeStringArray(candidate.leftUndone ?? []), + fulfilledAssertions: normalizeStringArray( + candidate.fulfilledAssertions ?? [], + ), + verificationStatus: + normalizeString(candidate.verificationStatus) || "unknown", + }; +}