diff --git a/packages/evolution/src/contracts.ts b/packages/evolution/src/contracts.ts index 49f2814be..db6339abc 100644 --- a/packages/evolution/src/contracts.ts +++ b/packages/evolution/src/contracts.ts @@ -1204,6 +1204,19 @@ export interface SkillMutationArtifact { readonly contentDigest: string; } +export type SkillActivationMemoryRelationshipClassification = Exclude< + SkillMemoryRelationshipClassification, + "conflicts" | "redundant" +>; + +export interface SkillMutationMemoryDisposition { + readonly relationshipDigest: string; + readonly classifications: readonly SkillActivationMemoryRelationshipClassification[]; + readonly memoryProposalIds: readonly string[]; + readonly activationAction: "preserve"; + readonly futureMemoryChange: "explicit-proposal-required" | "none"; +} + export interface SkillMutation { readonly mutationId: string; readonly groupId: string; @@ -1214,6 +1227,7 @@ export interface SkillMutation { readonly draft: EvolutionArtifactReference; readonly validation: EvolutionArtifactReference; readonly evaluation: EvolutionArtifactReference; + readonly memoryDisposition: SkillMutationMemoryDisposition; readonly existingSkills: readonly SkillMutationExistingArtifact[]; readonly skill: SkillMutationArtifact | null; } diff --git a/packages/evolution/src/index.ts b/packages/evolution/src/index.ts index abdce2160..f04513e81 100644 --- a/packages/evolution/src/index.ts +++ b/packages/evolution/src/index.ts @@ -124,6 +124,7 @@ export { SKILL_EVALUATION_SCHEMA_VERSION, SKILL_LIFECYCLE_OBSERVATION_SCHEMA_VERSION, SKILL_LIFECYCLE_REPORT_SCHEMA_VERSION, + type SkillActivationMemoryRelationshipClassification, type SkillabilityClassification, type SkillBehaviorEvaluationOutcome, type SkillBehaviorEvaluationResult, @@ -183,6 +184,7 @@ export { type SkillMutationBatch, type SkillMutationExactDiff, type SkillMutationExistingArtifact, + type SkillMutationMemoryDisposition, type SkillMutationWriteOutput, type SkillRetrievalEvaluationResult, type SkillTarget, diff --git a/packages/evolution/src/skill-curator.ts b/packages/evolution/src/skill-curator.ts index 7f95d7c13..f8226ee93 100644 --- a/packages/evolution/src/skill-curator.ts +++ b/packages/evolution/src/skill-curator.ts @@ -885,7 +885,10 @@ function modelInput( treatEvidenceAsUntrustedData: true, classifySkillabilityBeforeCandidate: true, skillWorthyPatterns: [...SKILLABILITY_CLASSIFICATIONS], + proceduralWorkflowsBelongInSkills: ["ordered-setup", "tool-use", "debugging", "recovery", "verification"], routeBroadAlwaysOnConstraintsToMemory: true, + doNotProposeMemoryPointersToSkills: true, + memoryRemovalOrReplacementRequiresExplicitProposal: true, rejectDirectInstructionsAndFactualReminders: true, rejectTaskSpecificOneOffsAndOutcomeOnlySummaries: true, rejectProceduresWithUnrecoverableDecisiveSteps: true, @@ -899,6 +902,16 @@ function modelInput( candidateContainsNoExecutableWriterPayload: true, candidateOperations: [...OPERATIONS], memoryRelationships: [...MEMORY_RELATIONSHIPS], + memoryRelationshipSemantics: { + complements: "The skill procedure and memory constraint remain independently useful.", + conflicts: "The guidance is incompatible and must remain deferred.", + fallbackUntilActivation: + "Memory temporarily carries procedural detail; later removal or replacement requires an explicit memory proposal.", + independent: "The skill has no relationship to a selected memory proposal.", + redundant: "No distinct skill procedure remains and the decision must remain deferred.", + supersedes: + "The skill can carry the procedure after activation; memory remains unchanged until an explicit memory proposal is applied.", + }, workspaceTarget: jsonObject(workspace(input)), globalEvolvedSkillTargetPermitted: false, proceduralObservations: [], diff --git a/packages/evolution/src/skill-writer.ts b/packages/evolution/src/skill-writer.ts index 70606daf3..0e8257d3a 100644 --- a/packages/evolution/src/skill-writer.ts +++ b/packages/evolution/src/skill-writer.ts @@ -18,6 +18,8 @@ import type { HarnessActivationResult, MutationWriteReceipt, MutationWriteResult, + SkillActivationMemoryRelationshipClassification, + SkillCandidate, SkillCandidateGroup, SkillDraft, SkillDraftValidationReport, @@ -25,6 +27,7 @@ import type { SkillMutation, SkillMutationBatch, SkillMutationExactDiff, + SkillMutationMemoryDisposition, SkillMutationWriteOutput, } from "./contracts.ts"; import { EvolutionExecutionError } from "./errors.ts"; @@ -73,6 +76,7 @@ interface StoredSkillWriterActivation { interface AcceptedSkillMutationSource { readonly group: SkillCandidateGroup; + readonly candidates: readonly SkillCandidate[]; readonly draft: SkillDraft; readonly validation: SkillDraftValidationReport; readonly evaluation: SkillEvaluationReport; @@ -135,6 +139,10 @@ function sameWorkspace( return left.repositoryId === right.repositoryId && left.directoryId === right.directoryId; } +function isSkillCandidate(artifact: EvolutionArtifact): artifact is SkillCandidate { + return "artifactKind" in artifact && artifact.artifactKind === "skill-candidate"; +} + function isSkillDraft(artifact: EvolutionArtifact): artifact is SkillDraft { return "artifactKind" in artifact && artifact.artifactKind === "skill-draft"; } @@ -148,7 +156,7 @@ function isSkillEvaluation(artifact: EvolutionArtifact): artifact is SkillEvalua } function artifactReference( - artifact: SkillDraft | SkillDraftValidationReport | SkillEvaluationReport, + artifact: SkillCandidate | SkillDraft | SkillDraftValidationReport | SkillEvaluationReport, ): EvolutionArtifactReference { return { artifactId: artifact.metadata.artifactId, @@ -157,7 +165,7 @@ function artifactReference( } function assertArtifactIdentity( - artifact: SkillDraft | SkillDraftValidationReport | SkillEvaluationReport, + artifact: SkillCandidate | SkillDraft | SkillDraftValidationReport | SkillEvaluationReport, reference: EvolutionArtifactReference, description: string, ): void { @@ -373,18 +381,29 @@ export class SkillWriter { ) { throw new Error(`Candidate group "${groupId}" is not accepted for skill activation`); } - const [draftArtifact, validationArtifact, evaluationArtifact] = await Promise.all([ + const [candidateArtifacts, draftArtifact, validationArtifact, evaluationArtifact] = await Promise.all([ + Promise.all(group.proposalRevisions.map((revision) => this.#dependencies.artifacts.get(revision.candidate))), this.#dependencies.artifacts.get(group.draft), this.#dependencies.artifacts.get(group.validation), this.#dependencies.artifacts.get(group.evaluation), ]); if ( + candidateArtifacts.length === 0 || + !candidateArtifacts.every(isSkillCandidate) || !isSkillDraft(draftArtifact) || !isSkillValidation(validationArtifact) || !isSkillEvaluation(evaluationArtifact) ) { throw new Error(`Candidate group "${groupId}" has invalid writer prerequisites`); } + const candidates: readonly SkillCandidate[] = candidateArtifacts; + for (const [index, candidate] of candidates.entries()) { + const revision = group.proposalRevisions[index]; + if (revision === undefined || candidate.candidateId !== revision.candidateId) { + throw new Error(`Candidate group "${groupId}" has invalid candidate provenance`); + } + assertArtifactIdentity(candidate, revision.candidate, "Skill candidate"); + } assertArtifactIdentity(draftArtifact, group.draft, "Skill draft"); assertArtifactIdentity(validationArtifact, group.validation, "Skill validation"); assertArtifactIdentity(evaluationArtifact, group.evaluation, "Skill evaluation"); @@ -416,7 +435,56 @@ export class SkillWriter { ) { throw new Error(`Candidate group "${groupId}" accepted artifacts are stale or incomplete`); } - return { group, draft: draftArtifact, validation: validationArtifact, evaluation: evaluationArtifact }; + return { + group, + candidates, + draft: draftArtifact, + validation: validationArtifact, + evaluation: evaluationArtifact, + }; + } + + #memoryDisposition(source: AcceptedSkillMutationSource): SkillMutationMemoryDisposition { + const relationships = source.candidates.map((candidate) => ({ + candidateId: candidate.candidateId, + memoryRelationship: candidate.memoryRelationship, + })); + const relationshipDigest = computeEvolutionContentDigest(relationships); + if (source.validation.memoryRelationshipDigest !== relationshipDigest) { + throw new Error(`Candidate group "${source.group.groupId}" memory relationship validation is stale`); + } + const classifications = new Set(); + const memoryProposalIds = new Set(); + let requiresExplicitProposal = false; + for (const candidate of source.candidates) { + const relationship = candidate.memoryRelationship; + if (relationship.classification === "conflicts" || relationship.classification === "redundant") { + throw new Error( + `Candidate group "${source.group.groupId}" contains non-activatable ${relationship.classification} memory guidance`, + ); + } + if ( + (relationship.classification === "independent" && relationship.memoryProposalIds.length !== 0) || + (relationship.classification !== "independent" && relationship.memoryProposalIds.length === 0) + ) { + throw new Error(`Candidate group "${source.group.groupId}" has an invalid memory relationship`); + } + classifications.add(relationship.classification); + for (const proposalId of relationship.memoryProposalIds) memoryProposalIds.add(proposalId); + if ( + relationship.classification === "fallback-until-activation" || + relationship.classification === "supersedes" + ) { + requiresExplicitProposal = true; + } + } + return { + relationshipDigest, + classifications: [...classifications].sort((left, right) => left.localeCompare(right)), + memoryProposalIds: [...memoryProposalIds].sort((left, right) => left.localeCompare(right)), + activationAction: "preserve", + futureMemoryChange: requiresExplicitProposal ? "explicit-proposal-required" : "none", + }; } #mutationFor(source: AcceptedSkillMutationSource, base: HarnessVersionBundle): SkillMutation { @@ -487,6 +555,7 @@ export class SkillWriter { const draftReference = artifactReference(draft); const validationReference = artifactReference(validation); const evaluationReference = artifactReference(evaluation); + const memoryDisposition = this.#memoryDisposition(source); return { mutationId: stableId("skill-mutation", { groupId: group.groupId, @@ -508,6 +577,7 @@ export class SkillWriter { draft: draftReference, validation: validationReference, evaluation: evaluationReference, + memoryDisposition, existingSkills, skill: draft.operation === "RETIRE" diff --git a/packages/evolution/test/skill-curator.test.ts b/packages/evolution/test/skill-curator.test.ts index ad0d8a924..003fc40bc 100644 --- a/packages/evolution/test/skill-curator.test.ts +++ b/packages/evolution/test/skill-curator.test.ts @@ -460,7 +460,18 @@ describe("StructuredSkillCurator", () => { ); expect(requestInput).toMatchObject({ - control: { globalEvolvedSkillTargetPermitted: false, candidateContainsNoExecutableWriterPayload: true }, + control: { + globalEvolvedSkillTargetPermitted: false, + candidateContainsNoExecutableWriterPayload: true, + proceduralWorkflowsBelongInSkills: ["ordered-setup", "tool-use", "debugging", "recovery", "verification"], + doNotProposeMemoryPointersToSkills: true, + memoryRemovalOrReplacementRequiresExplicitProposal: true, + memoryRelationshipSemantics: { + complements: expect.stringContaining("independently useful"), + fallbackUntilActivation: expect.stringContaining("explicit memory proposal"), + supersedes: expect.stringContaining("memory remains unchanged"), + }, + }, lessons: [{ lessonRef: "L1", evidenceRefs: ["E1"], counterevidenceRefs: ["E2"] }], selectedMemoryProposals: [{ memoryProposalRef: "P1", proposalId: "proposal-1" }], frozenEvolutionMemory: { content: input.harness.memory }, diff --git a/packages/evolution/test/skill-writer.test.ts b/packages/evolution/test/skill-writer.test.ts index d15f3a654..1c40ffaa7 100644 --- a/packages/evolution/test/skill-writer.test.ts +++ b/packages/evolution/test/skill-writer.test.ts @@ -16,6 +16,7 @@ import { type EvolutionArtifactStore, type EvolutionScope, FileEvolutionPersistence, + type SkillCandidate, type SkillCandidateGroup, type SkillCandidateOperation, type SkillCandidateRegistry, @@ -88,6 +89,7 @@ interface WriterFixtureOptions { readonly operation: SkillCandidateOperation; readonly existingNames?: readonly string[]; readonly existingSkillIdsOverride?: readonly string[]; + readonly memoryRelationship?: SkillCandidate["memoryRelationship"]; readonly retainedNames?: readonly string[]; readonly proposedName: string; } @@ -114,6 +116,53 @@ async function writerFixture(options: WriterFixtureOptions) { const existingSkillIds = options.existingSkillIdsOverride ?? existingSkills.map((skill) => computeHarnessSkillId(skill.workspace, skill.path)); + const memoryRelationship = options.memoryRelationship ?? { + classification: "independent" as const, + memoryProposalIds: [], + rationale: "The skill procedure is independent from evolution memory.", + }; + const candidateBody = { + artifactKind: "skill-candidate" as const, + candidateId: "candidate-1", + operation: options.operation, + workspace: { + repositoryId: workspace.repositoryId, + directoryId: workspace.directoryId, + rootPath: "/repo", + repositoryRevision: "revision-1", + evolutionScope: scope, + }, + proposedName: options.proposedName, + existingSkillIds, + sourceLessonIds: ["lesson-1"], + sourceProceduralObservationIds: [], + supportingEvidenceIds: ["evidence-1"], + contradictingEvidenceIds: [], + supportingTraceIds: ["trace-1"], + contradictingTraceIds: [], + taskLineageIds: ["task-1", "task-2"], + skillability: { classification: "ordered-workflow" as const, rationale: "The actions are ordered." }, + trigger: { + description: "A fixture task needs an evolved procedure.", + positiveExamples: ["A fixture task is ready."], + negativeExamples: ["No fixture task is present."], + }, + preconditions: ["The fixture is available."], + procedure: { + steps: [{ sequence: 1, action: "Apply the fixture procedure.", condition: null }], + requiredTools: [], + environment: [], + }, + failureHandling: [{ signal: "The fixture fails.", mitigation: "Inspect it.", recovery: "Retry it." }], + applicabilityBoundaries: ["Fixture repositories only."], + verificationContract: { checks: ["The fixture passes."], completionCondition: "The fixture is complete." }, + memoryRelationship, + }; + const candidate: SkillCandidate = { + metadata: metadata(candidateBody.candidateId, candidateBody.sourceLessonIds, candidateBody), + ...candidateBody, + }; + const candidateReference = reference(candidate); const draftContent = skillContent(options.proposedName, `${options.proposedName}-evolved`); const draftBody = { artifactKind: "skill-draft" as const, @@ -161,7 +210,9 @@ async function writerFixture(options: WriterFixtureOptions) { (skill) => `${skill.skillId}\u0000${skill.version}\u0000${skill.contentDigest}`, ), canonicalInventory: [`skills/${options.proposedName}/SKILL.md`], - memoryRelationshipDigest: computeEvolutionContentDigest([]), + memoryRelationshipDigest: computeEvolutionContentDigest([ + { candidateId: candidate.candidateId, memoryRelationship: candidate.memoryRelationship }, + ]), }; const validation: SkillDraftValidationReport = { metadata: metadata(validationBody.validationId, [draft.draftId], validationBody), @@ -232,8 +283,8 @@ async function writerFixture(options: WriterFixtureOptions) { proposalRevisions: [ { revisionId: "revision-1", - candidateId: "candidate-1", - candidate: { artifactId: "candidate-1", contentDigest: `sha256:${"5".repeat(64)}` }, + candidateId: candidate.candidateId, + candidate: candidateReference, sourceEvolutionJobId: "job-1", createdAt: now, evidence: { @@ -272,7 +323,7 @@ async function writerFixture(options: WriterFixtureOptions) { version: "writer-1", workspace, harnessState, - artifacts: new MemoryArtifactStore([draft, validation, evaluation]), + artifacts: new MemoryArtifactStore([candidate, draft, validation, evaluation]), registry: registry(groups), leases: persistence, clock: { now: () => now }, @@ -314,6 +365,12 @@ describe("skill writer", () => { ])("stages and atomically activates an accepted $operation mutation", async (testCase) => { const data = await writerFixture(testCase); const batch = await data.writer.buildMutationBatch({ scope, groupId: data.group.groupId, createdAt: now }); + expect(batch.mutations[0]?.memoryDisposition).toMatchObject({ + classifications: ["independent"], + memoryProposalIds: [], + activationAction: "preserve", + futureMemoryChange: "none", + }); const writeReceipt = await data.writer.write(batch, { idempotencyKey: "write-1", lease: data.lease }); expect((await data.harnessState.loadCurrent()).manifest.version).toBe(data.base.manifest.version); @@ -334,6 +391,61 @@ describe("skill writer", () => { expect(current.state.skills.map((skill) => skill.name).sort()).toEqual([...testCase.expectedNames].sort()); }); + it.each([ + { classification: "complements" as const, futureMemoryChange: "none" as const }, + { + classification: "fallback-until-activation" as const, + futureMemoryChange: "explicit-proposal-required" as const, + }, + { classification: "supersedes" as const, futureMemoryChange: "explicit-proposal-required" as const }, + ])("records $classification memory handling without changing EVOLVE.md", async (testCase) => { + const data = await writerFixture({ + operation: "CREATE", + proposedName: "created-skill", + memoryRelationship: { + classification: testCase.classification, + memoryProposalIds: ["memory-proposal-1"], + rationale: "The relationship is evidence-grounded.", + }, + }); + const batch = await data.writer.buildMutationBatch({ scope, groupId: data.group.groupId, createdAt: now }); + expect(batch.mutations[0]?.memoryDisposition).toMatchObject({ + classifications: [testCase.classification], + memoryProposalIds: ["memory-proposal-1"], + activationAction: "preserve", + futureMemoryChange: testCase.futureMemoryChange, + }); + + const writeReceipt = await data.writer.write(batch, { idempotencyKey: "write-1", lease: data.lease }); + const activation = await data.writer.activate(batch, writeReceipt, { + idempotencyKey: "activate-1", + lease: data.lease, + }); + const current = await data.harnessState.loadCurrent(); + expect(current.manifest.version).toBe(activation.result.resultingHarnessVersion); + expect(current.state.memory).toBe(data.base.state.memory); + }); + + it.each(["conflicts", "redundant"] as const)( + "rejects %s memory guidance before building an activation batch", + async (classification) => { + const data = await writerFixture({ + operation: "CREATE", + proposedName: "created-skill", + memoryRelationship: { + classification, + memoryProposalIds: ["memory-proposal-1"], + rationale: "The guidance must not activate.", + }, + }); + + await expect( + data.writer.buildMutationBatch({ scope, groupId: data.group.groupId, createdAt: now }), + ).rejects.toThrow(`contains non-activatable ${classification} memory guidance`); + expect(await data.harnessState.loadCurrent()).toEqual(data.base); + }, + ); + it("makes write and activation retries idempotent and preserves exact rollback lineage", async () => { const data = await writerFixture({ operation: "CREATE",