diff --git a/.changeset/immutable-project-version-contracts.md b/.changeset/immutable-project-version-contracts.md new file mode 100644 index 00000000..19955a04 --- /dev/null +++ b/.changeset/immutable-project-version-contracts.md @@ -0,0 +1,5 @@ +--- +"@sapiom/harness": minor +--- + +Publish immutable Agent Map, build-plan and brief contracts with strict codecs and canonical digest helpers. The public types, exact-reference helpers, codecs and digest functions support offline contract validation independently of later storage and tool activation; documented compatibility aliases remain supported. diff --git a/packages/harness/README.md b/packages/harness/README.md index 32d6ca47..196e786b 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -130,6 +130,20 @@ current owners and durable historical aliases cannot be adopted into another session. Duplicate persisted provider IDs are repaired conservatively during boot, preserving the first owner and clearing the later duplicate pointer. +### Project contract helpers + +`@sapiom/harness` exports immutable map, plan and brief record types, exact-version +references, strict codecs and canonical digest helpers for offline validation. +For example, use `parseProjectBuildPlanVersion` to validate a plan record and +`computeBuildPlanSemanticDigest` to compare its authored meaning independently +of timestamps or attribution. These data contracts do not require a live session +or an active MCP tool. Store and tool activation are separate integrations. + +`BuildPlanId`, `ArchitectureSourceRef`, `AgentMapRevisionId`, +`AgentBriefVersionRecord`, and `computeArchitectureGraphDigest` are supported +aliases for the corresponding neutral plan, map and brief contracts; they do +not introduce a second data model. + ### Agent Map MCP Studio exposes a stateful Streamable HTTP MCP endpoint at `/mcp/agent-map` for diff --git a/packages/harness/src/core/agent-map-proposal-validator.ts b/packages/harness/src/core/agent-map-proposal-validator.ts index b7f5b6a1..e7432faa 100644 --- a/packages/harness/src/core/agent-map-proposal-validator.ts +++ b/packages/harness/src/core/agent-map-proposal-validator.ts @@ -13,6 +13,12 @@ import type { ProposalValidationResult, RelationshipKind, } from "../shared/agent-map.js"; +import { + canonicalizeAgentMapGraph, + compareCanonicalStrings, +} from "../shared/agent-map-canonical.js"; + +export { canonicalizeAgentMapGraph } from "../shared/agent-map-canonical.js"; const ACTOR_KINDS = new Set(["agent", "subagent"]); const ALL_NODE_KINDS = new Set([ @@ -97,8 +103,7 @@ const nodeDraftKey = (draftRef: DraftRef): string => `draft-node:${draftRef}`; const relationshipDraftKey = (draftRef: DraftRef): string => `draft-relationship:${draftRef}`; -const compareStrings = (left: string, right: string): number => - left < right ? -1 : left > right ? 1 : 0; +const compareStrings = compareCanonicalStrings; const canonicalStrings = (values: readonly string[]): string[] => [...values].sort(compareStrings); @@ -110,26 +115,6 @@ const stripUndefinedProperties = >( Object.entries(value).filter(([, fieldValue]) => fieldValue !== undefined), ) as T; -const canonicalNode = (node: PlanNode): PlanNode => ({ - ...node, - contractRefs: canonicalStrings(node.contractRefs), -}); - -const canonicalRelationship = ( - relationship: PlanRelationship, -): PlanRelationship => ({ ...relationship }); - -export function canonicalizeAgentMapGraph(graph: AgentMapGraph): AgentMapGraph { - return { - nodes: graph.nodes - .map(canonicalNode) - .sort((left, right) => compareStrings(left.id, right.id)), - relationships: graph.relationships - .map(canonicalRelationship) - .sort((left, right) => compareStrings(left.id, right.id)), - }; -} - export function semanticRelationshipKey( relationship: Pick< PlanRelationship, diff --git a/packages/harness/src/core/agent-map-version-resolver.ts b/packages/harness/src/core/agent-map-version-resolver.ts new file mode 100644 index 00000000..3f1eda5d --- /dev/null +++ b/packages/harness/src/core/agent-map-version-resolver.ts @@ -0,0 +1,37 @@ +import type { AgentMapVersion, AgentMapVersionRef, StudioProjectId } from "../shared/agent-map.js"; +import { validateAgentMapVersionHistory } from "./agent-map-version.js"; + +export class AgentMapVersionResolutionError extends Error { + constructor(readonly code: "version_not_found" | "source_mismatch" | "cross_project_reference") { + super(code.replace(/_/gu, " ")); + this.name = "AgentMapVersionResolutionError"; + } +} + +export class AgentMapVersionResolver { + constructor( + private readonly projectId: StudioProjectId, + private readonly versions: readonly AgentMapVersion[], + private readonly current: AgentMapVersionRef | null, + ) { + validateAgentMapVersionHistory(versions, projectId); + const tail = versions.at(-1); + if ((tail === undefined) !== (current === null) || (tail && current && ( + tail.projectId !== current.projectId || + tail.versionId !== current.versionId || + tail.contentDigest !== current.contentDigest + ))) throw new AgentMapVersionResolutionError("source_mismatch"); + } + + readCurrent(): AgentMapVersion | null { + return this.current ? this.readExact(this.current) : null; + } + + readExact(ref: AgentMapVersionRef): AgentMapVersion { + if (ref.projectId !== this.projectId) throw new AgentMapVersionResolutionError("cross_project_reference"); + const version = this.versions.find(({ versionId }) => versionId === ref.versionId); + if (!version) throw new AgentMapVersionResolutionError("version_not_found"); + if (version.contentDigest !== ref.contentDigest) throw new AgentMapVersionResolutionError("source_mismatch"); + return structuredClone(version); + } +} diff --git a/packages/harness/src/core/agent-map-version.test.ts b/packages/harness/src/core/agent-map-version.test.ts new file mode 100644 index 00000000..b30488a6 --- /dev/null +++ b/packages/harness/src/core/agent-map-version.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from "vitest"; + +import type { + AgentMapVersion, + AgentMapVersionId, + PlanNode, + PlanNodeId, + ProposalOperationId, + StudioProjectId, +} from "../shared/agent-map.js"; +import { + agentMapVersionRef, + appendRestoredAgentMapVersion, + createAgentMapVersion, + validateAgentMapVersionHistory, +} from "./agent-map-version.js"; +import { computeAgentMapVersionRecordDigest } from "../shared/agent-map-canonical.js"; +import { AgentMapVersionResolver } from "./agent-map-version-resolver.js"; + +const projectId = "project_018f0000-0000-4000-8000-000000000001" as StudioProjectId; +const actor = { userId: "user", sessionId: "session" }; +const at = "2026-01-02T03:04:05.000Z"; +const origin = (digit: string) => ({ + kind: "request" as const, + requestDigest: `sha256:${digit.repeat(64)}`, + operationIds: [`operation_018f0000-0000-7000-8000-00000000000${digit}` as ProposalOperationId], + touchKeys: [`node:node-${digit}`], +}); +const node = (name: string): PlanNode => ({ + id: "node_018f0000-0000-7000-8000-000000000010" as PlanNodeId, + kind: "agent", + name, + purpose: "Research stocks", + ownerAgentId: null, + contractRefs: [], +}); +const versionId = (suffix: string) => + `mapv_018f0000-0000-7000-8000-0000000000${suffix}` as AgentMapVersionId; + +describe("immutable Agent Map versions", () => { + it("resolves current and exact history while rejecting cross-project and digest-mismatched refs", () => { + const first = createAgentMapVersion({ + projectId, + versionId: versionId("20"), + version: 1, + parentVersionId: null, + graph: { nodes: [node("Research")], relationships: [] }, + changeKind: "created", + restoredFromVersionId: null, + authoredBy: actor, + createdAt: at, + origin: origin("1"), + }); + const second = createAgentMapVersion({ + projectId, + versionId: versionId("21"), + version: 2, + parentVersionId: first.versionId, + graph: { nodes: [node("Market Research")], relationships: [] }, + changeKind: "edited", + restoredFromVersionId: null, + authoredBy: actor, + createdAt: "2026-01-02T03:05:05.000Z", + origin: origin("2"), + }); + const resolver = new AgentMapVersionResolver(projectId, [first, second], agentMapVersionRef(second)); + expect(resolver.readCurrent()).toEqual(second); + expect(resolver.readExact(agentMapVersionRef(first))).toEqual(first); + expect(() => resolver.readExact({ ...agentMapVersionRef(first), contentDigest: second.contentDigest })) + .toThrowError(expect.objectContaining({ code: "source_mismatch" })); + expect(() => resolver.readExact({ + ...agentMapVersionRef(first), + projectId: "project_018f0000-0000-4000-8000-000000000002" as StudioProjectId, + })).toThrowError(expect.objectContaining({ code: "cross_project_reference" })); + }); + + it("restores by appending a new child with copied semantics and explicit provenance", () => { + const first = createAgentMapVersion({ + projectId, + versionId: versionId("20"), + version: 1, + parentVersionId: null, + graph: { nodes: [node("Research")], relationships: [] }, + changeKind: "created", + restoredFromVersionId: null, + authoredBy: actor, + createdAt: at, + origin: origin("1"), + }); + const second = createAgentMapVersion({ + projectId, + versionId: versionId("21"), + version: 2, + parentVersionId: first.versionId, + graph: { nodes: [node("Market Research")], relationships: [] }, + changeKind: "edited", + restoredFromVersionId: null, + authoredBy: actor, + createdAt: "2026-01-02T03:05:05.000Z", + origin: origin("2"), + }); + const restored = appendRestoredAgentMapVersion({ + projectId, + versions: [first, second], + expectedCurrent: agentMapVersionRef(second), + historical: agentMapVersionRef(first), + versionId: versionId("22"), + actor: { userId: "restorer", sessionId: "restore-session" }, + createdAt: "2026-01-02T03:06:05.000Z", + origin: origin("3"), + }); + expect(restored).toMatchObject({ + version: 3, + parentVersionId: second.versionId, + changeKind: "restored", + restoredFromVersionId: first.versionId, + graph: first.graph, + contentDigest: first.contentDigest, + authoredBy: { userId: "restorer", sessionId: "restore-session" }, + }); + expect(restored.recordDigest).not.toBe(first.recordDigest); + expect(() => validateAgentMapVersionHistory([first, second, restored], projectId)).not.toThrow(); + }); + + it.each(["skipped version", "repointed parent", "duplicate version id", "forged record digest", "unknown restore source"])( + "rejects history with %s", (corruption) => { + const first = createAgentMapVersion({ projectId, versionId: versionId("20"), version: 1, + parentVersionId: null, graph: { nodes: [node("Research")], relationships: [] }, changeKind: "created", + restoredFromVersionId: null, authoredBy: actor, createdAt: at, origin: origin("1") }); + const second = createAgentMapVersion({ projectId, versionId: versionId("21"), version: 2, + parentVersionId: first.versionId, graph: { nodes: [node("Updated")], relationships: [] }, changeKind: "edited", + restoredFromVersionId: null, authoredBy: actor, createdAt: at, origin: origin("2") }); + const changes = corruption === "skipped version" ? { version: 3 } + : corruption === "repointed parent" ? { parentVersionId: versionId("99") } + : corruption === "duplicate version id" ? { versionId: first.versionId } + : corruption === "unknown restore source" ? { changeKind: "restored" as const, restoredFromVersionId: versionId("99") } + : {}; + const changed = { ...second, ...changes } as AgentMapVersion; + const corrupted = { ...changed, recordDigest: corruption === "forged record digest" + ? `sha256:${"0".repeat(64)}` as AgentMapVersion["recordDigest"] : computeAgentMapVersionRecordDigest(changed) }; + expect(() => validateAgentMapVersionHistory([first, corrupted], projectId)).toThrow(); + }, + ); + + it("rejects invalid graph topology", () => { + expect(() => createAgentMapVersion({ + projectId, + versionId: versionId("20"), + version: 1, + parentVersionId: null, + graph: { + nodes: [node("Research")], + relationships: [{ + id: "rel_018f0000-0000-7000-8000-000000000030" as never, + fromNodeId: node("Research").id, + toNodeId: node("Research").id, + kind: "invokes", + executionMode: null, + contractRef: null, + description: "self", + }], + }, + changeKind: "created", + restoredFromVersionId: null, + authoredBy: actor, + createdAt: at, + origin: origin("1"), + })).toThrow(/relationship/u); + }); +}); diff --git a/packages/harness/src/core/agent-map-version.ts b/packages/harness/src/core/agent-map-version.ts new file mode 100644 index 00000000..a58ca0ff --- /dev/null +++ b/packages/harness/src/core/agent-map-version.ts @@ -0,0 +1,193 @@ +import { createHash } from "node:crypto"; + +import type { + AgentMapGraph, + AgentMapVersion, + AgentMapVersionId, + AgentMapVersionRef, + MapOperation, + PlanNodeKind, + ProjectAgentActorRef, + ProjectMutationOrigin, + RecordDigest, + StudioProjectId, +} from "../shared/agent-map.js"; +import { + canonicalizeAgentMapGraph, + computeAgentMapVersionRecordDigest, + computeGraphContentDigest, +} from "../shared/agent-map-canonical.js"; +import { RELATIONSHIP_ENDPOINT_MATRIX, semanticRelationshipKey } from "./agent-map-proposal-validator.js"; + +export const agentMapVersionRef = (version: AgentMapVersion): AgentMapVersionRef => ({ + projectId: version.projectId, + versionId: version.versionId, + contentDigest: version.contentDigest, +}); + +export function applyPersistedMapOperations( + graph: AgentMapGraph, + operations: readonly MapOperation[], +): AgentMapGraph { + const nodes = new Map(graph.nodes.map((node) => [node.id, structuredClone(node)])); + const relationships = new Map(graph.relationships.map((relationship) => [relationship.id, structuredClone(relationship)])); + for (const operation of operations) { + switch (operation.kind) { + case "add-node": nodes.set(operation.node.id, structuredClone(operation.node)); break; + case "update-node": { + const current = nodes.get(operation.nodeId); + if (!current) throw new TypeError("map operation references an unknown node"); + nodes.set(operation.nodeId, { ...current, ...structuredClone(operation.changes) }); + break; + } + case "remove-node": + if (!nodes.delete(operation.nodeId)) throw new TypeError("map operation references an unknown node"); + break; + case "add-relationship": relationships.set(operation.relationship.id, structuredClone(operation.relationship)); break; + case "update-relationship": { + const current = relationships.get(operation.relationshipId); + if (!current) throw new TypeError("map operation references an unknown relationship"); + relationships.set(operation.relationshipId, { ...current, ...structuredClone(operation.changes) }); + break; + } + case "remove-relationship": + if (!relationships.delete(operation.relationshipId)) throw new TypeError("map operation references an unknown relationship"); + break; + } + } + return assertValidAgentMapGraph({ nodes: [...nodes.values()], relationships: [...relationships.values()] }); +} + +export function assertValidAgentMapGraph(graph: AgentMapGraph): AgentMapGraph { + const canonical = canonicalizeAgentMapGraph(graph); + const nodes = new Map(canonical.nodes.map((node) => [node.id, node])); + const semanticEdges = new Set(); + for (const node of canonical.nodes) { + if (node.kind === "subagent") { + const owner = node.ownerAgentId ? nodes.get(node.ownerAgentId) : undefined; + if (owner?.kind !== "agent") throw new TypeError("invalid subagent owner"); + } else if (node.ownerAgentId !== null) throw new TypeError("invalid node owner"); + } + for (const relationship of canonical.relationships) { + const from = nodes.get(relationship.fromNodeId); + const to = nodes.get(relationship.toNodeId); + if (!from || !to || from.id === to.id) throw new TypeError("invalid relationship endpoint"); + const allowed = RELATIONSHIP_ENDPOINT_MATRIX[relationship.kind]; + if (!allowed.from.has(from.kind as PlanNodeKind) || !allowed.to.has(to.kind as PlanNodeKind)) + throw new TypeError("invalid relationship endpoint kind"); + const key = semanticRelationshipKey(relationship); + if (semanticEdges.has(key)) throw new TypeError("duplicate semantic relationship"); + semanticEdges.add(key); + } + return canonical; +} + +export function deterministicVersionId( + prefix: "mapv" | "planv" | "briefv" | "proposal", + parts: readonly string[], +): string { + const hex = createHash("sha256").update(parts.join("\0"), "utf8").digest("hex"); + return `${prefix}_${hex.slice(0, 8)}-${hex.slice(8, 12)}-7${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`; +} + +export function createAgentMapVersion(input: { + projectId: StudioProjectId; + versionId: AgentMapVersionId; + version: number; + parentVersionId: AgentMapVersionId | null; + graph: AgentMapGraph; + changeKind: AgentMapVersion["changeKind"]; + restoredFromVersionId: AgentMapVersionId | null; + authoredBy: ProjectAgentActorRef; + createdAt: string; + origin: ProjectMutationOrigin; +}): AgentMapVersion { + const graph = assertValidAgentMapGraph(input.graph); + const base = { + schemaVersion: 1 as const, + ...input, + graph, + contentDigest: computeGraphContentDigest(graph), + }; + return { ...base, recordDigest: computeAgentMapVersionRecordDigest(base) }; +} + +export function restoreAgentMapVersion(input: { + projectId: StudioProjectId; + current: AgentMapVersion; + historical: AgentMapVersion; + versionId: AgentMapVersionId; + actor: ProjectAgentActorRef; + createdAt: string; + origin: ProjectMutationOrigin; +}): AgentMapVersion { + if (input.current.projectId !== input.projectId || input.historical.projectId !== input.projectId) + throw new TypeError("cross-project map restoration"); + return createAgentMapVersion({ + projectId: input.projectId, + versionId: input.versionId, + version: input.current.version + 1, + parentVersionId: input.current.versionId, + graph: input.historical.graph, + changeKind: "restored", + restoredFromVersionId: input.historical.versionId, + authoredBy: input.actor, + createdAt: input.createdAt, + origin: input.origin, + }); +} + +export function appendRestoredAgentMapVersion(input: { + projectId: StudioProjectId; + versions: readonly AgentMapVersion[]; + expectedCurrent: AgentMapVersionRef; + historical: AgentMapVersionRef; + versionId: AgentMapVersionId; + actor: ProjectAgentActorRef; + createdAt: string; + origin: ProjectMutationOrigin; +}): AgentMapVersion { + validateAgentMapVersionHistory(input.versions, input.projectId); + const current = input.versions.at(-1); + const historical = input.versions.find(({ versionId }) => versionId === input.historical.versionId); + if (!current || + current.versionId !== input.expectedCurrent.versionId || + current.contentDigest !== input.expectedCurrent.contentDigest || + input.expectedCurrent.projectId !== input.projectId) + throw new TypeError("stale Agent Map restoration"); + if (!historical || + historical.contentDigest !== input.historical.contentDigest || + input.historical.projectId !== input.projectId) + throw new TypeError("unknown Agent Map restoration source"); + return restoreAgentMapVersion({ + projectId: input.projectId, + current, + historical, + versionId: input.versionId, + actor: input.actor, + createdAt: input.createdAt, + origin: input.origin, + }); +} + +export function validateAgentMapVersionHistory( + versions: readonly AgentMapVersion[], + projectId: StudioProjectId, +): void { + const ids = new Set(); + versions.forEach((version, index) => { + if ( + version.projectId !== projectId || + version.version !== index + 1 || + version.parentVersionId !== (versions[index - 1]?.versionId ?? null) || + ids.has(version.versionId) || + computeGraphContentDigest(assertValidAgentMapGraph(version.graph)) !== version.contentDigest || + computeAgentMapVersionRecordDigest(version) !== version.recordDigest + ) throw new TypeError("invalid Agent Map version history"); + if (version.changeKind === "restored" && !ids.has(version.restoredFromVersionId ?? "")) + throw new TypeError("invalid Agent Map restoration source"); + ids.add(version.versionId); + }); +} + +export const EMPTY_RECORD_DIGEST = `sha256:${"0".repeat(64)}` as RecordDigest; diff --git a/packages/harness/src/core/build-plan-canonicalization.test.ts b/packages/harness/src/core/build-plan-canonicalization.test.ts new file mode 100644 index 00000000..08fece3b --- /dev/null +++ b/packages/harness/src/core/build-plan-canonicalization.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from "vitest"; + +import type { + AgentMapGraph, + AgentMapVersion, + AgentMapVersionId, + PlanNodeId, + ProjectMutationOrigin, +} from "../shared/agent-map.js"; +import { + canonicalJson, + computeAgentMapVersionRecordDigest, + computeGraphContentDigest, +} from "../shared/agent-map-canonical.js"; +import type { + AgentBriefId, + AgentBriefSemanticDigest, + AgentBriefScopeKey, + AgentBriefVersion, + AgentBriefVersionId, + PlanningAssignmentId, + ProjectBuildPlanContent, + ProjectBuildPlanId, + ProjectBuildPlanVersion, + ProjectBuildPlanVersionId, +} from "../shared/build-plan.js"; +import { + computeAgentBriefRecordDigest, + computeAgentBriefSemanticDigest, + computeBuildPlanRecordDigest, + computeBuildPlanSemanticDigest, +} from "./build-plan-canonicalization.js"; + +const projectId = "project_018f0000-0000-7000-8000-000000000001"; +const nodeId = "node_018f0000-0000-7000-8000-000000000010" as PlanNodeId; +const mapVersionId = "mapv_018f0000-0000-7000-8000-000000000020" as AgentMapVersionId; +const planId = "plan_018f0000-0000-7000-8000-000000000030" as ProjectBuildPlanId; +const planVersionId = "planv_018f0000-0000-7000-8000-000000000031" as ProjectBuildPlanVersionId; +const assignmentId = "work_018f0000-0000-7000-8000-000000000040" as PlanningAssignmentId; +const briefId = "brief_018f0000-0000-7000-8000-000000000050" as AgentBriefId; +const briefVersionId = "briefv_018f0000-0000-7000-8000-000000000051" as AgentBriefVersionId; +const actor = { userId: "user-golden", sessionId: "session-golden" }; +const createdAt = "2026-01-02T03:04:05.000Z"; +const graph: AgentMapGraph = { + nodes: [{ + id: nodeId, + kind: "agent", + name: "Market Research", + purpose: "Find the top ten stocks trading today.", + ownerAgentId: null, + contractRefs: [], + }], + relationships: [], +}; +const planContent: ProjectBuildPlanContent = { + outcome: "Deliver a daily top-ten-stock research report.", + nonGoals: ["Publish the report to TikTok."], + milestones: [], + sequenceGates: [], + sharedConstraints: ["Use only market data available for the trading day."], + repositoryIntents: [], + integrationCriteria: ["ResearchReport is persisted before downstream consumption."], + acceptanceCriteria: ["Exactly ten ranked stocks are present."], + decisions: [], + assignments: [{ + id: assignmentId, + plannedAgentId: nodeId, + briefId, + mission: "Produce ResearchReport.", + scope: ["Market research"], + nonGoals: ["Video publishing"], + dependencies: [], + }], + unresolvedDecisions: [], + risks: [], +}; +const requestOrigin = (character: string): ProjectMutationOrigin => ({ + kind: "request", + requestDigest: `sha256:${character.repeat(64)}`, + operationIds: [], + touchKeys: [], +}); + +describe("neutral map/plan digest protocol", () => { + it("pins project-neutral semantic golden vectors", () => { + const mapDigest = computeGraphContentDigest(graph); + expect(mapDigest).toBe("sha256:1659273be855864c82005f6291ae61bc2256f1d114e7c391aedd4f37d0191000"); + expect(computeBuildPlanSemanticDigest(planContent)).toBe( + "sha256:0a038f176b4ae7e0a9bd43c50a5e64caf29e0381dee5d9470bf319d7098af7eb", + ); + + const brief = { + scopeKey: "scope_research" as AgentBriefVersion["scopeKey"], + focusScope: { family: "canonical-workstream" as const, plannedAgentId: nodeId }, + assignmentId, + plannedAgentId: nodeId, + map: { projectId, versionId: mapVersionId, contentDigest: mapDigest }, + plan: { + projectId, + planId, + versionId: planVersionId, + semanticDigest: computeBuildPlanSemanticDigest(planContent), + }, + content: { + mission: "Produce ResearchReport.", + scope: ["Market research"], + nonGoals: ["Video publishing"], + ownedNodeIds: [nodeId], + relevantNodeIds: [], + inputs: [], + outputs: ["ResearchReport"], + dependencies: [], + sharedResourceNodeIds: [], + sequenceGateIds: [], + deliverables: ["ResearchReport"], + acceptanceCriteria: ["Exactly ten ranked stocks are present."], + constraints: ["Use only market data available for the trading day."], + milestoneIds: [], + unresolvedDecisionIds: [], + }, + compilerInputFingerprint: `sha256:${"3".repeat(64)}`, + } satisfies Pick; + expect(computeAgentBriefSemanticDigest(brief)).toBe( + "sha256:ac5ab8530a271c57115f43b498750d1ff7b5d7bd8bb70aac42428cdde6ae7dac", + ); + }); + + it("separates semantic content from exact source and provenance", () => { + const contentDigest = computeGraphContentDigest(graph); + const map = { + schemaVersion: 1, + projectId, + versionId: mapVersionId, + version: 1, + parentVersionId: null, + changeKind: "created", + restoredFromVersionId: null, + graph, + contentDigest, + authoredBy: actor, + createdAt, + origin: requestOrigin("1"), + } satisfies Omit; + const plan = { + schemaVersion: 1, + projectId, + planId, + versionId: planVersionId, + version: 1, + parentVersionId: null, + changeKind: "created", + restoredFromVersionId: null, + map: { projectId, versionId: mapVersionId, contentDigest }, + content: planContent, + semanticDigest: computeBuildPlanSemanticDigest(planContent), + authoredBy: actor, + createdAt, + origin: requestOrigin("2"), + } satisfies Omit; + expect(computeAgentMapVersionRecordDigest(map)).not.toBe(contentDigest); + expect(computeBuildPlanRecordDigest(plan)).not.toBe(plan.semanticDigest); + expect(computeAgentMapVersionRecordDigest(map)).toBe( + "sha256:fe263ae9ba6982d03931743ac2737a60cf28288caba2ce141916cbea18813cdd", + ); + expect(computeBuildPlanRecordDigest(plan)).toBe( + "sha256:9046540f87c7d07625fb96f6b77853b2cd0ec907c7296b836590757b8af7ba61", + ); + + const briefBase = { + schemaVersion: 1, + projectId, + briefId, + scopeKey: "scope_golden" as AgentBriefScopeKey, + focusScope: { family: "canonical-workstream", plannedAgentId: nodeId }, + versionId: briefVersionId, + version: 1, + parentVersionId: null, + changeKind: "created", + restoredFromVersionId: null, + assignmentId, + plannedAgentId: nodeId, + map: plan.map, + plan: { projectId, planId, versionId: planVersionId, semanticDigest: plan.semanticDigest }, + content: { + mission: "Produce ResearchReport.", scope: ["Market research"], nonGoals: ["Video publishing"], + ownedNodeIds: [nodeId], relevantNodeIds: [], inputs: [], outputs: ["ResearchReport"], + dependencies: [], sharedResourceNodeIds: [], sequenceGateIds: [], deliverables: ["ResearchReport"], + acceptanceCriteria: ["Exactly ten ranked stocks are present."], + constraints: ["Use only market data available for the trading day."], milestoneIds: [], + unresolvedDecisionIds: [], + }, + compilerVersion: "1", + compilerInputFingerprint: `sha256:${"3".repeat(64)}`, + semanticDigest: "" as AgentBriefSemanticDigest, + authoredBy: actor, + createdAt, + origin: requestOrigin("2"), + } satisfies Omit; + const brief = { + ...briefBase, + semanticDigest: computeAgentBriefSemanticDigest(briefBase), + }; + expect(computeAgentBriefRecordDigest(brief)).toBe( + "sha256:8b2175ab4265b38ccb2aed918eaa595fc7eb424db970a6a0739514b3a05217d9", + ); + expect(computeBuildPlanSemanticDigest({ ...planContent, nonGoals: [...planContent.nonGoals] })).toBe(plan.semanticDigest); + expect(computeBuildPlanSemanticDigest({ content: plan.content })).toBe(plan.semanticDigest); + expect(computeBuildPlanRecordDigest({ ...plan, authoredBy: { ...actor, sessionId: "another-session" } })).not.toBe(computeBuildPlanRecordDigest(plan)); + }); + + it("normalizes line endings, orders keys bytewise, and rejects undefined", () => { + expect(canonicalJson({ z: "a\r\nb\rc", A: null })).toBe('{"A":null,"z":"a\\nb\\nc"}'); + expect(() => canonicalJson({ missing: undefined })).toThrow(/undefined/u); + }); + + it("sorts semantic set fields without mutating caller data", () => { + const input = structuredClone(planContent); + const shuffled = { ...input, nonGoals: ["z", "a"] }; + const before = JSON.stringify(shuffled); + const digest = computeBuildPlanSemanticDigest(shuffled); + expect(digest).toBe(computeBuildPlanSemanticDigest({ ...shuffled, nonGoals: ["a", "z"] })); + expect(JSON.stringify(shuffled)).toBe(before); + }); +}); diff --git a/packages/harness/src/core/build-plan-canonicalization.ts b/packages/harness/src/core/build-plan-canonicalization.ts new file mode 100644 index 00000000..5ce8c65a --- /dev/null +++ b/packages/harness/src/core/build-plan-canonicalization.ts @@ -0,0 +1,159 @@ +import type { + AgentBriefSemanticDigest, + AgentBriefVersion, + BuildPlanSemanticDigest, + ProjectBuildPlanContent, + ProjectBuildPlanVersion, +} from "../shared/build-plan.js"; +import type { RecordDigest } from "../shared/agent-map.js"; +import { + canonicalDigest, + canonicalJson, + compareCanonicalStrings, +} from "../shared/agent-map-canonical.js"; + +export { canonicalJson }; + +const strings = (values: readonly T[]): T[] => + [...values].sort(compareCanonicalStrings); +const byId = (values: readonly T[]): T[] => + [...values].sort((left, right) => compareCanonicalStrings(left.id, right.id)); +const ordered = ( + values: readonly T[], +): T[] => + [...values].sort( + (left, right) => + left.ordinal - right.ordinal || compareCanonicalStrings(left.id, right.id), + ); + +function assertDistinctOrdinals( + values: readonly { ordinal: number }[], + field: string, +): void { + if (new Set(values.map(({ ordinal }) => ordinal)).size !== values.length) + throw new TypeError(`duplicate ${field} ordinal`); +} + +export function canonicalizeProjectBuildPlanContent( + content: ProjectBuildPlanContent, +): ProjectBuildPlanContent { + assertDistinctOrdinals(content.milestones, "milestone"); + assertDistinctOrdinals(content.sequenceGates, "sequence gate"); + return { + outcome: content.outcome, + nonGoals: strings(content.nonGoals), + milestones: ordered(content.milestones).map((milestone) => ({ + ...milestone, + dependsOn: strings(milestone.dependsOn), + })), + sequenceGates: ordered(content.sequenceGates).map((gate) => ({ + ...gate, + milestoneIds: strings(gate.milestoneIds), + })), + sharedConstraints: strings(content.sharedConstraints), + repositoryIntents: byId(content.repositoryIntents).map((intent) => ({ + ...intent, + packages: strings(intent.packages), + ownershipBoundaries: strings(intent.ownershipBoundaries), + })), + integrationCriteria: strings(content.integrationCriteria), + acceptanceCriteria: strings(content.acceptanceCriteria), + decisions: byId(content.decisions), + assignments: byId(content.assignments).map((assignment) => ({ + ...assignment, + scope: strings(assignment.scope), + nonGoals: strings(assignment.nonGoals), + dependencies: byId(assignment.dependencies).map((dependency) => ({ + ...dependency, + relationshipIds: strings(dependency.relationshipIds), + })), + })), + unresolvedDecisions: byId(content.unresolvedDecisions), + risks: byId(content.risks), + }; +} + +export const buildPlanSemanticProjection = ( + plan: ProjectBuildPlanContent | Pick, +): ProjectBuildPlanContent => + canonicalizeProjectBuildPlanContent("content" in plan ? plan.content : plan); + +export const computeBuildPlanSemanticDigest = ( + plan: ProjectBuildPlanContent | Pick, +): BuildPlanSemanticDigest => + canonicalDigest( + "sapiom.build-plan.semantic.v1", + buildPlanSemanticProjection(plan), + ) as BuildPlanSemanticDigest; + +export const computeBuildPlanRecordDigest = ( + plan: Omit | ProjectBuildPlanVersion, +): RecordDigest => { + const record = Object.fromEntries( + Object.entries(plan).filter(([key]) => key !== "recordDigest"), + ); + return canonicalDigest( + "sapiom.build-plan.version-record.v1", + record, + ) as RecordDigest; +}; + +const briefStrings = (content: AgentBriefVersion["content"]) => ({ + ...content, + scope: strings(content.scope), + nonGoals: strings(content.nonGoals), + ownedNodeIds: strings(content.ownedNodeIds), + relevantNodeIds: strings(content.relevantNodeIds), + inputs: strings(content.inputs), + outputs: strings(content.outputs), + dependencies: strings(content.dependencies), + sharedResourceNodeIds: strings(content.sharedResourceNodeIds), + sequenceGateIds: strings(content.sequenceGateIds), + deliverables: strings(content.deliverables), + acceptanceCriteria: strings(content.acceptanceCriteria), + constraints: strings(content.constraints), + milestoneIds: strings(content.milestoneIds), + unresolvedDecisionIds: strings(content.unresolvedDecisionIds), +}); + +export type AgentBriefSemanticInput = Pick< + AgentBriefVersion, + | "scopeKey" + | "focusScope" + | "assignmentId" + | "plannedAgentId" + | "content" + | "compilerInputFingerprint" +>; + +export const agentBriefSemanticProjection = (brief: AgentBriefSemanticInput) => ({ + scopeKey: brief.scopeKey, + focusScope: brief.focusScope, + assignmentId: brief.assignmentId, + plannedAgentId: brief.plannedAgentId, + content: briefStrings(brief.content), + compilerInputFingerprint: brief.compilerInputFingerprint, +}); + +export const computeAgentBriefSemanticDigest = ( + brief: AgentBriefSemanticInput, +): AgentBriefSemanticDigest => + canonicalDigest( + "sapiom.agent-brief.semantic.v2", + agentBriefSemanticProjection(brief), + ) as AgentBriefSemanticDigest; + +export const computeAgentBriefRecordDigest = ( + brief: Omit | AgentBriefVersion, +): RecordDigest => { + const record = Object.fromEntries( + Object.entries(brief).filter(([key]) => key !== "recordDigest"), + ); + return canonicalDigest( + "sapiom.agent-brief.version-record.v1", + record, + ) as RecordDigest; +}; + +export const computeBuildPlanRequestDigest = (request: unknown): string => + canonicalDigest("sapiom.build-plan.request.v1", request); diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index a3164fe0..e7ecc696 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -30,6 +30,16 @@ export type { RelationshipChanges, RelationshipKind, StudioProjectId, + AgentMapVersion, + AgentMapVersionId, + AgentMapVersionRef, + AgentMapGraph, + GraphContentDigest, + ProjectAgentActorRef, + ProjectMutationOrigin, + ProjectVersionChangeKind, + RecordDigest, + RoleNeutralMapOperationRecord, } from "./shared/agent-map.js"; export type { WorkspaceScopeSummary } from "./shared/system-graph.js"; export { AGENT_STUDIO_PRODUCT_NAME } from "./shared/branding.js"; @@ -92,3 +102,104 @@ export { recordRecentDir, hasStoredSettings, } from "./cli/settings.js"; + +export { + canonicalJson, + canonicalizeAgentMapGraph, + computeAgentMapVersionRecordDigest, + computeArchitectureGraphDigest, + computeGraphContentDigest, +} from "./shared/agent-map-canonical.js"; +export { + AGENT_BRIEF_COMPILER_VERSION, + AGENT_BRIEF_FINGERPRINT_KINDS, + BUILD_PLAN_SCHEMA_VERSION, + PROJECT_PLANNING_STORAGE_SCHEMA_VERSION, + emptyProjectBuildPlanContent, + agentMapVersionRefsEqual, + projectBuildPlanVersionRefsEqual, +} from "./shared/build-plan.js"; +export { + canonicalWorkstreamScopes, + canonicalizeAgentBriefFocusScope, + computeAgentBriefId, + computeAgentBriefScopeKey, +} from "./shared/agent-brief.js"; +export type { + AgentBriefFocusSelection, + AgentBriefRefreshRequest, + AgentBriefRefreshReceipt, + AgentBriefRefreshResult, + CompileAgentBriefsRequest, + CompileAgentBriefsResult, + CompiledAgentBriefCandidate, + PreviousAgentBrief, +} from "./shared/agent-brief.js"; +export type { + AgentBriefContent, + AgentBriefDependencyFingerprint, + AgentBriefDisposition, + AgentBriefFingerprintKind, + AgentBriefFocusScope, + AgentBriefHistoryPointer, + AgentBriefId, + AgentBriefImpact, + AgentBriefImpactEntry, + AgentBriefScopeKey, + AgentBriefSemanticDigest, + AgentBriefStaleReason, + AgentBriefStaleReasonCode, + AgentBriefVersion, + AgentBriefVersionRecord, + AgentBriefVersionId, + AgentBriefVersionRef, + ArchitectureSourceRef, + BuildPlanDependencyId, + BuildPlanDependencyIntent, + BuildPlanId, + BuildPlanAssignmentIntent, + BuildPlanCurrentPointers, + BuildPlanDecision, + BuildPlanDiagnostic, + BuildPlanHistorySummary, + BuildPlanIdMapping, + BuildPlanMilestone, + BuildPlanReadResult, + BuildPlanReadSelector, + BuildPlanRepositoryIntent, + BuildPlanRisk, + BuildPlanSemanticDigest, + BuildPlanSequenceGate, + MilestoneId, + PlanDecisionId, + PlanRiskId, + PlanningAssignmentId, + ProjectBuildPlanContent, + ProjectBuildPlanId, + ProjectBuildPlanVersion, + ProjectBuildPlanVersionId, + ProjectBuildPlanVersionRef, + ProjectMutationReceipt, + ProjectMutationTombstone, + SequenceGateId, +} from "./shared/build-plan.js"; +export { + parseAgentBriefFocusScope, + parseAgentBriefVersion, + parseAgentBriefVersionRef, + parseAgentMapVersionRef, + parseBuildPlanCurrentPointers, + parseProjectBuildPlanContent, + parseProjectBuildPlanVersion, + parseProjectBuildPlanVersionRef, +} from "./shared/build-plan-codec.js"; +export { + agentBriefSemanticProjection, + buildPlanSemanticProjection, + canonicalizeProjectBuildPlanContent, + computeAgentBriefRecordDigest, + computeAgentBriefSemanticDigest, + computeBuildPlanRecordDigest, + computeBuildPlanRequestDigest, + computeBuildPlanSemanticDigest, +} from "./core/build-plan-canonicalization.js"; diff --git a/packages/harness/src/public-build-plan-entrypoint.test.ts b/packages/harness/src/public-build-plan-entrypoint.test.ts new file mode 100644 index 00000000..14cb01fb --- /dev/null +++ b/packages/harness/src/public-build-plan-entrypoint.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { + BUILD_PLAN_SCHEMA_VERSION, + PROJECT_PLANNING_STORAGE_SCHEMA_VERSION, + agentMapVersionRefsEqual, + computeAgentBriefId, + computeAgentBriefScopeKey, + emptyProjectBuildPlanContent, + type AgentBriefFocusScope, + type AgentBriefHistoryPointer, + type AgentBriefScopeKey, + type AgentMapVersionRef, + type BuildPlanDependencyIntent, + type BuildPlanReadSelector, + type GraphContentDigest, + type PlanNodeId, + type ProjectAgentActorRef, + type ProjectBuildPlanVersionRef, + type StudioProjectId, +} from "./index.js"; + +describe("@sapiom/harness neutral planning entrypoint", () => { + it("exports exact map/plan refs and neutral reserved brief scopes", () => { + const projectId = "project_00000000-0000-4000-8000-000000000001" as StudioProjectId; + const contentDigest = `sha256:${"a".repeat(64)}` as GraphContentDigest; + const map: AgentMapVersionRef = { + projectId, + versionId: "mapv_00000000-0000-7000-8000-000000000001" as AgentMapVersionRef["versionId"], + contentDigest, + }; + const plan: ProjectBuildPlanVersionRef = { + projectId, + planId: "plan_00000000-0000-7000-8000-000000000001" as ProjectBuildPlanVersionRef["planId"], + versionId: "planv_00000000-0000-7000-8000-000000000001" as ProjectBuildPlanVersionRef["versionId"], + semanticDigest: `sha256:${"b".repeat(64)}` as ProjectBuildPlanVersionRef["semanticDigest"], + }; + const parentScopeKey = "scope-parent" as AgentBriefScopeKey; + const focusScope: AgentBriefFocusScope = { + family: "ad-hoc-delegation", + delegationKey: "nested-review", + parentScopeKey, + }; + const brief: AgentBriefHistoryPointer = { + scopeKey: "scope-child" as AgentBriefScopeKey, + focusScope, + briefId: "brief_00000000-0000-7000-8000-000000000001" as AgentBriefHistoryPointer["briefId"], + status: "retired", + version: { + projectId, + briefId: "brief_00000000-0000-7000-8000-000000000001" as AgentBriefHistoryPointer["briefId"], + versionId: "briefv_00000000-0000-7000-8000-000000000001" as AgentBriefHistoryPointer["version"]["versionId"], + semanticDigest: `sha256:${"c".repeat(64)}` as AgentBriefHistoryPointer["version"]["semanticDigest"], + }, + }; + const dependency: BuildPlanDependencyIntent = { + id: "dependency_00000000-0000-7000-8000-000000000001" as BuildPlanDependencyIntent["id"], + kind: "shared-resource", + nodeId: "node_00000000-0000-7000-8000-000000000001" as PlanNodeId, + relationshipIds: [], + contractRef: null, + }; + const selector: BuildPlanReadSelector = { kind: "exact", ...plan }; + const actor: ProjectAgentActorRef = { userId: "user", sessionId: "session" }; + + expect(BUILD_PLAN_SCHEMA_VERSION).toBe(1); + expect(PROJECT_PLANNING_STORAGE_SCHEMA_VERSION).toBe(2); + expect(computeAgentBriefId(projectId, focusScope)).toMatch(/^brief_/u); + expect(computeAgentBriefScopeKey(projectId, focusScope)).toMatch(/^sha256:/u); + expect(agentMapVersionRefsEqual(map, { ...map })).toBe(true); + expect(emptyProjectBuildPlanContent().assignments).toEqual([]); + expect({ brief, dependency, selector, actor }).toMatchObject({ + brief: { status: "retired", focusScope: { parentScopeKey } }, + dependency: { kind: "shared-resource" }, + selector: { kind: "exact", planId: plan.planId }, + actor: { userId: "user", sessionId: "session" }, + }); + }); +}); diff --git a/packages/harness/src/shared/agent-brief.test.ts b/packages/harness/src/shared/agent-brief.test.ts new file mode 100644 index 00000000..9fc4e0d6 --- /dev/null +++ b/packages/harness/src/shared/agent-brief.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import type { PlanNodeId } from "./agent-map.js"; +import { + canonicalWorkstreamScopes, + canonicalizeAgentBriefFocusScope, + computeAgentBriefId, + computeAgentBriefScopeKey, +} from "./agent-brief.js"; + +const projectId = "project_018f0000-0000-7000-8000-000000000001"; +const research = "node_018f0000-0000-7000-8000-000000000010" as PlanNodeId; +const publishing = "node_018f0000-0000-7000-8000-000000000011" as PlanNodeId; + +describe("role-neutral brief focus identity", () => { + it("is stable across exact map and plan versions", () => { + const scope = { family: "canonical-workstream" as const, plannedAgentId: research }; + expect(computeAgentBriefScopeKey(projectId, scope)).toBe( + computeAgentBriefScopeKey(projectId, canonicalizeAgentBriefFocusScope(scope)), + ); + expect(computeAgentBriefId(projectId, scope)).toBe(computeAgentBriefId(projectId, scope)); + }); + + it("separates canonical workstreams, delegations, parents, and projects", () => { + const canonical = { family: "canonical-workstream" as const, plannedAgentId: research }; + const delegated = { + family: "ad-hoc-delegation" as const, + delegationKey: research, + parentScopeKey: null, + }; + const nested = { ...delegated, parentScopeKey: computeAgentBriefScopeKey(projectId, canonical) }; + const keys = [ + computeAgentBriefScopeKey(projectId, canonical), + computeAgentBriefScopeKey(projectId, delegated), + computeAgentBriefScopeKey(projectId, nested), + computeAgentBriefScopeKey(`${projectId}-other`, canonical), + ]; + expect(new Set(keys)).toHaveLength(keys.length); + }); + + it("sorts and deduplicates canonical workstreams by code point", () => { + expect(canonicalWorkstreamScopes([research, publishing, research])).toEqual([ + { family: "canonical-workstream", plannedAgentId: research }, + { family: "canonical-workstream", plannedAgentId: publishing }, + ]); + }); +}); diff --git a/packages/harness/src/shared/agent-brief.ts b/packages/harness/src/shared/agent-brief.ts new file mode 100644 index 00000000..a0f503cb --- /dev/null +++ b/packages/harness/src/shared/agent-brief.ts @@ -0,0 +1,156 @@ +import { createHash } from "node:crypto"; + +import type { + AgentMapVersionRef, + AgentMapVersion, + PlanNodeId, + StudioProjectId, +} from "./agent-map.js"; +import { canonicalDigest, compareCanonicalStrings } from "./agent-map-canonical.js"; +import type { + AgentBriefDependencyFingerprint, + AgentBriefDisposition, + AgentBriefFocusScope, + AgentBriefHistoryPointer, + AgentBriefId, + AgentBriefImpact, + AgentBriefScopeKey, + AgentBriefVersion, + BuildPlanDiagnostic, + PlanningAssignmentId, + ProjectBuildPlanVersion, + ProjectBuildPlanVersionRef, +} from "./build-plan.js"; + +const deterministicId = (prefix: "brief", seed: string): string => { + const hex = createHash("sha256").update(seed, "utf8").digest("hex"); + return `${prefix}_${hex.slice(0, 8)}-${hex.slice(8, 12)}-7${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`; +}; + +/** Return the canonical, persistence-safe representation of a focus scope. */ +export function canonicalizeAgentBriefFocusScope( + scope: AgentBriefFocusScope, +): AgentBriefFocusScope { + if (scope.family === "canonical-workstream") { + return { + family: "canonical-workstream", + plannedAgentId: scope.plannedAgentId, + }; + } + return { + family: "ad-hoc-delegation", + delegationKey: scope.delegationKey, + parentScopeKey: scope.parentScopeKey, + }; +} + +/** + * Project-bound identity of the selected focus only. Map and plan versions are + * deliberately excluded so recompilation appends history instead of minting a + * new logical brief. + */ +export function computeAgentBriefScopeKey( + projectId: StudioProjectId, + scope: AgentBriefFocusScope, +): AgentBriefScopeKey { + return canonicalDigest("sapiom.agent-brief.focus-scope.v1", { + projectId, + scope: canonicalizeAgentBriefFocusScope(scope), + }) as AgentBriefScopeKey; +} + +/** Stable logical identity retained across retirement and reactivation. */ +export function computeAgentBriefId( + projectId: StudioProjectId, + scope: AgentBriefFocusScope, +): AgentBriefId { + const scopeKey = computeAgentBriefScopeKey(projectId, scope); + return deterministicId("brief", `${projectId}\0${scopeKey}`) as AgentBriefId; +} + +export function canonicalWorkstreamScopes( + nodeIds: readonly PlanNodeId[], +): AgentBriefFocusScope[] { + return [...new Set(nodeIds)] + .sort(compareCanonicalStrings) + .map((plannedAgentId) => ({ + family: "canonical-workstream" as const, + plannedAgentId, + })); +} + +export type AgentBriefFocusSelection = Readonly<{ + focusScope: AgentBriefFocusScope; + /** Explicit narrowing for ad-hoc or nested delegation. */ + nodeIds?: readonly PlanNodeId[]; + /** Optional authored assignment to use as the mission/scope source. */ + assignmentId?: PlanningAssignmentId; + mission?: string; + scope?: readonly string[]; + nonGoals?: readonly string[]; +}>; + +export type PreviousAgentBrief = Readonly<{ + pointer: AgentBriefHistoryPointer; + version: AgentBriefVersion; +}>; + +export type CompileAgentBriefsRequest = Readonly<{ + projectId: StudioProjectId; + map: AgentMapVersion; + plan: ProjectBuildPlanVersion; + mapHistory: readonly AgentMapVersion[]; + planHistory: readonly ProjectBuildPlanVersion[]; + previousBriefs: readonly PreviousAgentBrief[]; + selections: readonly AgentBriefFocusSelection[]; +}>; + +export type CompiledAgentBriefCandidate = Readonly<{ + scopeKey: AgentBriefScopeKey; + focusScope: AgentBriefFocusScope; + disposition: AgentBriefDisposition; + previous: AgentBriefVersion | null; + brief: AgentBriefVersion; + fingerprints: readonly AgentBriefDependencyFingerprint[]; +}>; + +export type CompileAgentBriefsResult = Readonly<{ + map: AgentMapVersion["contentDigest"]; + plan: ProjectBuildPlanVersion["semanticDigest"]; + briefs: readonly CompiledAgentBriefCandidate[]; + impact: AgentBriefImpact; + diagnostics: readonly BuildPlanDiagnostic[]; +}>; + +export type AgentBriefRefreshRequest = Readonly<{ + schemaVersion: 1; + requestId: string; + expectedMap: Omit; + expectedPlan: Omit; + focus: + | Readonly<{ mode: "canonical" }> + | Readonly<{ mode: "focused"; selections: readonly AgentBriefFocusSelection[] }>; +}>; + +export type AgentBriefRefreshResult = Readonly<{ + replayed: boolean; + persisted: boolean; + map: AgentMapVersionRef; + plan: ProjectBuildPlanVersionRef; + briefs: readonly Readonly<{ + scopeKey: AgentBriefScopeKey; + briefId: AgentBriefId; + versionId: AgentBriefVersion["versionId"]; + version: number; + disposition: AgentBriefDisposition; + status: AgentBriefHistoryPointer["status"]; + }>[]; + impact: AgentBriefImpact; + diagnostics: readonly BuildPlanDiagnostic[]; +}>; + +/** Content-free result retained with an append receipt for exact idempotent replay. */ +export type AgentBriefRefreshReceipt = Pick< + AgentBriefRefreshResult, + "map" | "plan" | "briefs" | "impact" | "diagnostics" +>; diff --git a/packages/harness/src/shared/agent-map-canonical.test.ts b/packages/harness/src/shared/agent-map-canonical.test.ts new file mode 100644 index 00000000..7549d2ca --- /dev/null +++ b/packages/harness/src/shared/agent-map-canonical.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import type { AgentMapGraph, PlanNodeId, PlanRelationshipId } from "./agent-map.js"; +import { canonicalizeAgentMapGraph, computeGraphContentDigest } from "./agent-map-canonical.js"; + +const first = "node_018f0000-0000-7000-8000-000000000001" as PlanNodeId; +const second = "node_018f0000-0000-7000-8000-000000000002" as PlanNodeId; + +describe("Agent Map content canonicalization", () => { + it("orders stable identities and set-like contract references without mutation", () => { + const graph: AgentMapGraph = { + nodes: [ + { id: second, kind: "agent", name: "Publisher", purpose: "Publish", ownerAgentId: null, contractRefs: ["z", "a"] }, + { id: first, kind: "agent", name: "Research", purpose: "Research", ownerAgentId: null, contractRefs: [] }, + ], + relationships: [], + }; + const before = JSON.stringify(graph); + const canonical = canonicalizeAgentMapGraph(graph); + expect(canonical.nodes.map(({ id }) => id)).toEqual([first, second]); + expect(canonical.nodes[1]?.contractRefs).toEqual(["a", "z"]); + expect(JSON.stringify(graph)).toBe(before); + expect(computeGraphContentDigest(graph)).toBe(computeGraphContentDigest(canonical)); + }); + + it.each([ + ["node", (graph: AgentMapGraph) => graph.nodes.push({ ...graph.nodes[0]!, contractRefs: [] })], + ["relationship", (graph: AgentMapGraph) => { + const relationship = { id: "rel_018f0000-0000-7000-8000-000000000001" as PlanRelationshipId, + fromNodeId: first, toNodeId: second, kind: "invokes" as const, executionMode: null, + contractRef: null, description: "Delegate" }; + graph.relationships.push(relationship, { ...relationship }); + }], + ["contract", (graph: AgentMapGraph) => graph.nodes[0]!.contractRefs.push("report", "report")], + ])("rejects duplicate %s identities", (_name, mutate) => { + const graph: AgentMapGraph = { + nodes: [ + { id: first, kind: "agent", name: "Research", purpose: "Research", ownerAgentId: null, contractRefs: [] }, + { id: second, kind: "agent", name: "Publish", purpose: "Publish", ownerAgentId: null, contractRefs: [] }, + ], + relationships: [], + }; + mutate(graph); + expect(() => canonicalizeAgentMapGraph(graph)).toThrow(/duplicate Agent Map/u); + }); +}); diff --git a/packages/harness/src/shared/agent-map-canonical.ts b/packages/harness/src/shared/agent-map-canonical.ts new file mode 100644 index 00000000..a7797966 --- /dev/null +++ b/packages/harness/src/shared/agent-map-canonical.ts @@ -0,0 +1,105 @@ +import { createHash } from "node:crypto"; + +import type { + AgentMapGraph, + AgentMapVersion, + GraphContentDigest, + PlanNode, + PlanRelationship, + RecordDigest, +} from "./agent-map.js"; + +export const compareCanonicalStrings = (left: string, right: string): number => + left < right ? -1 : left > right ? 1 : 0; + +const normalizedLineEndings = (value: string): string => + value.replace(/\r\n?/gu, "\n"); + +/** RFC-8259-shaped canonical JSON with binary key ordering and normalized text. */ +export function canonicalJson(value: unknown): string { + const visit = (entry: unknown): unknown => { + if (entry === undefined) throw new TypeError("undefined is not canonical JSON"); + if (typeof entry === "string") return normalizedLineEndings(entry); + if (typeof entry === "number" && !Number.isFinite(entry)) + throw new TypeError("non-finite number is not canonical JSON"); + if ( + entry === null || + typeof entry === "boolean" || + typeof entry === "number" + ) + return entry; + if (Array.isArray(entry)) return entry.map(visit); + if (typeof entry === "object") { + const prototype = Object.getPrototypeOf(entry); + if (prototype !== Object.prototype && prototype !== null) + throw new TypeError("non-plain object is not canonical JSON"); + return Object.fromEntries( + Object.entries(entry as Record) + .sort(([left], [right]) => compareCanonicalStrings(left, right)) + .map(([key, field]) => [key, visit(field)]), + ); + } + throw new TypeError("unsupported canonical JSON value"); + }; + return JSON.stringify(visit(value)); +} + +export const canonicalDigest = (domain: string, value: unknown): string => + `sha256:${createHash("sha256") + .update(domain, "utf8") + .update("\0", "utf8") + .update(canonicalJson(value), "utf8") + .digest("hex")}`; + +const canonicalNode = (node: PlanNode): PlanNode => ({ + ...node, + contractRefs: [...node.contractRefs].sort(compareCanonicalStrings), +}); + +const canonicalRelationship = ( + relationship: PlanRelationship, +): PlanRelationship => ({ ...relationship }); + +/** Return a defensive graph copy in the semantic digest protocol order. */ +export function canonicalizeAgentMapGraph(graph: AgentMapGraph): AgentMapGraph { + const nodes = graph.nodes + .map(canonicalNode) + .sort((left, right) => compareCanonicalStrings(left.id, right.id)); + const relationships = graph.relationships + .map(canonicalRelationship) + .sort((left, right) => compareCanonicalStrings(left.id, right.id)); + if (new Set(nodes.map(({ id }) => id)).size !== nodes.length) + throw new TypeError("duplicate Agent Map node ID"); + if (new Set(relationships.map(({ id }) => id)).size !== relationships.length) + throw new TypeError("duplicate Agent Map relationship ID"); + if ( + nodes.some( + ({ contractRefs }) => new Set(contractRefs).size !== contractRefs.length, + ) + ) + throw new TypeError("duplicate Agent Map contract reference"); + return { nodes, relationships }; +} + +export const computeGraphContentDigest = ( + graph: AgentMapGraph, +): GraphContentDigest => + canonicalDigest( + "sapiom.agent-map.content.v1", + canonicalizeAgentMapGraph(graph), + ) as GraphContentDigest; + +export const computeAgentMapVersionRecordDigest = ( + version: Omit | AgentMapVersion, +): RecordDigest => { + const record = Object.fromEntries( + Object.entries(version).filter(([key]) => key !== "recordDigest"), + ); + return canonicalDigest( + "sapiom.agent-map.version-record.v1", + record, + ) as RecordDigest; +}; + +/** Compatibility alias for callers introduced before the neutral vocabulary. */ +export const computeArchitectureGraphDigest = computeGraphContentDigest; diff --git a/packages/harness/src/shared/agent-map-codec.ts b/packages/harness/src/shared/agent-map-codec.ts index f43796cc..4ce1d655 100644 --- a/packages/harness/src/shared/agent-map-codec.ts +++ b/packages/harness/src/shared/agent-map-codec.ts @@ -13,6 +13,9 @@ import { type PlanRelationshipId, type ProposalActor, type ProposalBatchResult, + type AgentMapGraph, + type ProjectAgentActorRef, + type ProjectMutationOrigin, } from "./agent-map.js"; export const AGENT_MAP_UUID_V7_PATTERN = @@ -125,6 +128,83 @@ function parseRelationship(value: unknown): PlanRelationship { return structuredClone(value) as unknown as PlanRelationship; } +export function parseAgentMapGraph(value: unknown): AgentMapGraph { + if ( + !isRecord(value) || + !hasExactKeys(value, ["nodes", "relationships"]) || + !Array.isArray(value.nodes) || + !Array.isArray(value.relationships) || + value.nodes.length > 4_096 || + value.relationships.length > 16_384 + ) + throw new Error("invalid Agent Map graph"); + const nodes = value.nodes.map(parseNode); + const relationships = value.relationships.map(parseRelationship); + const nodeIds = new Set(nodes.map(({ id }) => id)); + if ( + nodeIds.size !== nodes.length || + new Set(relationships.map(({ id }) => id)).size !== relationships.length || + nodes.some( + ({ ownerAgentId }) => ownerAgentId !== null && !nodeIds.has(ownerAgentId), + ) || + relationships.some( + ({ fromNodeId, toNodeId }) => + !nodeIds.has(fromNodeId) || !nodeIds.has(toNodeId), + ) + ) + throw new Error("inconsistent Agent Map graph"); + return { nodes, relationships }; +} + +export function parseProjectAgentActorRef( + value: unknown, +): ProjectAgentActorRef { + if ( + !isRecord(value) || + !hasExactKeys(value, ["userId", "sessionId"]) || + !isAgentMapBoundedText(value.userId, 256) || + !isAgentMapBoundedText(value.sessionId, 256) + ) + throw new Error("invalid project agent actor"); + return { userId: value.userId, sessionId: value.sessionId }; +} + +export function parseProjectMutationOrigin( + value: unknown, +): ProjectMutationOrigin { + const requestKeys = ["kind", "requestDigest", "operationIds", "touchKeys"]; + const migrationKeys = [ + ...requestKeys, + "legacyProposalId", + "legacyAcceptedVersion", + ]; + if ( + !isRecord(value) || + !["request", "migration"].includes(String(value.kind)) || + !hasExactKeys(value, value.kind === "migration" ? migrationKeys : requestKeys) || + typeof value.requestDigest !== "string" || + !/^sha256:[0-9a-f]{64}$/u.test(value.requestDigest) || + !Array.isArray(value.operationIds) || + value.operationIds.length > 4_096 || + !value.operationIds.every((id) => isPlanId(id, "operation")) || + new Set(value.operationIds).size !== value.operationIds.length || + !Array.isArray(value.touchKeys) || + value.touchKeys.length > 16_384 || + !value.touchKeys.every((key) => isAgentMapBoundedText(key, 512)) || + new Set(value.touchKeys).size !== value.touchKeys.length + ) + throw new Error("invalid project mutation origin"); + if ( + value.kind === "migration" && + ((value.legacyProposalId !== null && !isPlanId(value.legacyProposalId, "proposal")) || + (value.legacyAcceptedVersion !== null && + (!Number.isSafeInteger(value.legacyAcceptedVersion) || + (value.legacyAcceptedVersion as number) < 1))) + ) + throw new Error("invalid project mutation origin"); + return structuredClone(value) as unknown as ProjectMutationOrigin; +} + function parseNodeChanges(value: unknown) { if ( !isRecord(value) || diff --git a/packages/harness/src/shared/agent-map-version-codec.test.ts b/packages/harness/src/shared/agent-map-version-codec.test.ts new file mode 100644 index 00000000..9e029f5a --- /dev/null +++ b/packages/harness/src/shared/agent-map-version-codec.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { createAgentMapVersion } from "../core/agent-map-version.js"; +import { parseAgentMapVersion } from "./agent-map-version-codec.js"; +import type { AgentMapVersionId } from "./agent-map.js"; + +const projectId = "project_018f0000-0000-4000-8000-000000000001"; +const version = () => createAgentMapVersion({ + projectId, versionId: "mapv_018f0000-0000-7000-8000-000000000001" as AgentMapVersionId, + version: 1, parentVersionId: null, graph: { nodes: [], relationships: [] }, + changeKind: "created", restoredFromVersionId: null, authoredBy: { userId: "user", sessionId: "session" }, + createdAt: "2026-09-04T12:00:00.000Z", + origin: { kind: "request", requestDigest: `sha256:${"1".repeat(64)}`, operationIds: [], touchKeys: [] }, +}); + +describe("strict immutable map version decoder", () => { + it("accepts an intact exact-project version and returns a detached value", () => { + const source = version(); + const parsed = parseAgentMapVersion(source, projectId); + expect(parsed).toEqual(source); + expect(parsed).not.toBe(source); + expect(parsed.graph).not.toBe(source.graph); + }); + + it.each([ + ["unknown keys", { extra: true }], + ["unknown schema", { schemaVersion: 2 }], + ["cross-project identity", { projectId: "project_018f0000-0000-4000-8000-000000000002" }], + ["forged graph digest", { contentDigest: `sha256:${"0".repeat(64)}` }], + ["forged record digest", { recordDigest: `sha256:${"0".repeat(64)}` }], + ["unknown actor field", { authoredBy: { userId: "user", sessionId: "session", extra: true } }], + ["restore without source", { changeKind: "restored" }], + ["source without restore", { restoredFromVersionId: "mapv_018f0000-0000-7000-8000-000000000002" }], + ])("rejects %s", (_label, changes) => { + expect(() => parseAgentMapVersion({ ...version(), ...changes }, projectId)).toThrow(); + }); +}); diff --git a/packages/harness/src/shared/agent-map-version-codec.ts b/packages/harness/src/shared/agent-map-version-codec.ts new file mode 100644 index 00000000..ad374436 --- /dev/null +++ b/packages/harness/src/shared/agent-map-version-codec.ts @@ -0,0 +1,91 @@ +import type { AgentMapVersion } from "./agent-map.js"; +import { + AGENT_MAP_UUID_V7_PATTERN, + isAgentMapBoundedText, + parseAgentMapGraph, + parseProjectAgentActorRef, + parseProjectMutationOrigin, +} from "./agent-map-codec.js"; +import { + computeAgentMapVersionRecordDigest, + computeGraphContentDigest, +} from "./agent-map-canonical.js"; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const hasExactKeys = ( + value: Record, + keys: readonly string[], +): boolean => { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && + actual.every((key, index) => key === expected[index]); +}; + +const isGeneratedId = (value: unknown, prefix: string): value is string => + typeof value === "string" && + new RegExp(`^${prefix}_${AGENT_MAP_UUID_V7_PATTERN}$`, "u").test(value); + +const isTimestamp = (value: unknown): value is string => { + if (typeof value !== "string") return false; + try { + return new Date(value).toISOString() === value; + } catch { + return false; + } +}; + +/** Strict server-side decoder with full semantic and record integrity checks. */ +export function parseAgentMapVersion( + value: unknown, + expectedProjectId?: string, +): AgentMapVersion { + if ( + !isRecord(value) || + !hasExactKeys(value, [ + "schemaVersion", + "projectId", + "versionId", + "version", + "parentVersionId", + "changeKind", + "restoredFromVersionId", + "graph", + "contentDigest", + "authoredBy", + "createdAt", + "origin", + "recordDigest", + ]) || + value.schemaVersion !== 1 || + !isAgentMapBoundedText(value.projectId, 128) || + (expectedProjectId !== undefined && value.projectId !== expectedProjectId) || + !isGeneratedId(value.versionId, "mapv") || + !Number.isSafeInteger(value.version) || + (value.version as number) < 1 || + (value.parentVersionId !== null && !isGeneratedId(value.parentVersionId, "mapv")) || + !["created", "edited", "rebased", "restored", "migrated"].includes(String(value.changeKind)) || + (value.restoredFromVersionId !== null && !isGeneratedId(value.restoredFromVersionId, "mapv")) || + (value.changeKind === "restored") !== (value.restoredFromVersionId !== null) || + typeof value.contentDigest !== "string" || + !/^sha256:[0-9a-f]{64}$/u.test(value.contentDigest) || + !isTimestamp(value.createdAt) || + typeof value.recordDigest !== "string" || + !/^sha256:[0-9a-f]{64}$/u.test(value.recordDigest) + ) + throw new Error("invalid Agent Map version"); + const parsed = { + ...structuredClone(value), + graph: parseAgentMapGraph(value.graph), + authoredBy: parseProjectAgentActorRef(value.authoredBy), + origin: parseProjectMutationOrigin(value.origin), + } as unknown as AgentMapVersion; + if ( + computeGraphContentDigest(parsed.graph) !== parsed.contentDigest || + computeAgentMapVersionRecordDigest(parsed) !== parsed.recordDigest + ) + throw new Error("Agent Map version digest mismatch"); + return parsed; +} diff --git a/packages/harness/src/shared/agent-map.ts b/packages/harness/src/shared/agent-map.ts index d7ff1d8b..de100d21 100644 --- a/packages/harness/src/shared/agent-map.ts +++ b/packages/harness/src/shared/agent-map.ts @@ -23,6 +23,11 @@ export type PlanNodeId = AgentMapBrand<"PlanNodeId">; export type PlanRelationshipId = AgentMapBrand<"PlanRelationshipId">; export type MapProposalId = AgentMapBrand<"MapProposalId">; export type ProposalOperationId = AgentMapBrand<"ProposalOperationId">; +export type AgentMapVersionId = AgentMapBrand<"AgentMapVersionId">; +/** Identity of normalized graph meaning. It is deliberately project-neutral. */ +export type GraphContentDigest = AgentMapBrand<"GraphContentDigest">; +/** Integrity identity for a complete immutable record or aggregate. */ +export type RecordDigest = AgentMapBrand<"RecordDigest">; /** A caller-authored alias whose lifetime is exactly one operation batch. */ export type DraftRef = AgentMapBrand<"DraftRef">; @@ -276,6 +281,64 @@ export type PlanningSessionIdentity = assignment: { kind: "unplanned" }; }); +/** Trusted, role-neutral attribution stored on immutable project records. */ +export type ProjectAgentActorRef = Readonly<{ + userId: string; + sessionId: string; +}>; + +/** Exact project-bound identity of immutable Agent Map content. */ +export type AgentMapVersionRef = Readonly<{ + projectId: StudioProjectId; + versionId: AgentMapVersionId; + contentDigest: GraphContentDigest; +}>; + +export type ProjectVersionChangeKind = "created" | "edited" | "rebased" | "restored" | "migrated"; + +export type ProjectMutationOrigin = + | Readonly<{ + kind: "request"; + requestDigest: string; + operationIds: readonly ProposalOperationId[]; + touchKeys: readonly string[]; + }> + | Readonly<{ + kind: "migration"; + requestDigest: string; + operationIds: readonly ProposalOperationId[]; + touchKeys: readonly string[]; + legacyProposalId: MapProposalId | null; + legacyAcceptedVersion: number | null; + }>; + +/** One immutable entry in the sole project Agent Map history. */ +export type AgentMapVersion = Readonly<{ + schemaVersion: 1; + projectId: StudioProjectId; + versionId: AgentMapVersionId; + version: number; + parentVersionId: AgentMapVersionId | null; + changeKind: ProjectVersionChangeKind; + restoredFromVersionId: AgentMapVersionId | null; + graph: AgentMapGraph; + contentDigest: GraphContentDigest; + authoredBy: ProjectAgentActorRef; + createdAt: string; + origin: ProjectMutationOrigin; + recordDigest: RecordDigest; +}>; + +/** Role-neutral operation provenance used after the deployed E2 migration. */ +export type RoleNeutralMapOperationRecord = Readonly<{ + id: ProposalOperationId; + requestId: string; + acceptedVersion: number; + operation: MapOperation; + actor: ProjectAgentActorRef; + acceptedAt: string; +}>; + export interface ProposalActor { userId: string; sessionId: string; diff --git a/packages/harness/src/shared/build-plan-codec.test.ts b/packages/harness/src/shared/build-plan-codec.test.ts new file mode 100644 index 00000000..88cdccf8 --- /dev/null +++ b/packages/harness/src/shared/build-plan-codec.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; + +import type { + AgentBriefScopeKey, + AgentBriefSemanticDigest, + AgentBriefId, + AgentBriefVersion, + AgentBriefVersionId, + PlanningAssignmentId, + ProjectBuildPlanContent, + ProjectBuildPlanId, + ProjectBuildPlanVersion, + ProjectBuildPlanVersionId, +} from "./build-plan.js"; +import type { + AgentMapVersionId, + PlanNodeId, +} from "./agent-map.js"; +import { + computeAgentBriefRecordDigest, + computeAgentBriefSemanticDigest, + computeBuildPlanRecordDigest, + computeBuildPlanSemanticDigest, +} from "../core/build-plan-canonicalization.js"; +import { computeGraphContentDigest } from "./agent-map-canonical.js"; +import { + parseAgentBriefFocusScope, + parseAgentBriefVersion, + parseProjectBuildPlanContent, + parseProjectBuildPlanVersion, +} from "./build-plan-codec.js"; + +const projectId = "project_018f0000-0000-7000-8000-000000000001"; +const nodeId = "node_018f0000-0000-7000-8000-000000000010" as PlanNodeId; +const mapVersionId = "mapv_018f0000-0000-7000-8000-000000000020" as AgentMapVersionId; +const planId = "plan_018f0000-0000-7000-8000-000000000030" as ProjectBuildPlanId; +const planVersionId = "planv_018f0000-0000-7000-8000-000000000031" as ProjectBuildPlanVersionId; +const assignmentId = "work_018f0000-0000-7000-8000-000000000040" as PlanningAssignmentId; +const actor = { userId: "user-golden", sessionId: "session-golden" }; +const createdAt = "2026-01-02T03:04:05.000Z"; +const origin = { kind: "request" as const, requestDigest: `sha256:${"1".repeat(64)}`, operationIds: [], touchKeys: [] }; +const graphDigest = computeGraphContentDigest({ + nodes: [{ id: nodeId, kind: "agent", name: "Research", purpose: "Research", ownerAgentId: null, contractRefs: [] }], + relationships: [], +}); +const content: ProjectBuildPlanContent = { + outcome: "Ship research.", nonGoals: [], milestones: [], sequenceGates: [], sharedConstraints: [], + repositoryIntents: [], integrationCriteria: [], acceptanceCriteria: [], decisions: [], + assignments: [{ id: assignmentId, plannedAgentId: nodeId, briefId: null, mission: "Research", scope: [], nonGoals: [], dependencies: [] }], + unresolvedDecisions: [], risks: [], +}; + +const planRecord = (): ProjectBuildPlanVersion => { + const base = { + schemaVersion: 1 as const, projectId, planId, versionId: planVersionId, version: 1, + parentVersionId: null, changeKind: "created" as const, restoredFromVersionId: null, + map: { projectId, versionId: mapVersionId, contentDigest: graphDigest }, content, + semanticDigest: computeBuildPlanSemanticDigest(content), authoredBy: actor, createdAt, origin, + }; + return { ...base, recordDigest: computeBuildPlanRecordDigest(base) }; +}; + +describe("neutral build plan codecs", () => { + it("strictly parses an integrity-covered plan and returns a defensive copy", () => { + const input = planRecord(); + const parsed = parseProjectBuildPlanVersion(input, projectId); + expect(parsed).toEqual(input); + expect(parsed).not.toBe(input); + expect(() => parseProjectBuildPlanVersion({ ...input, authority: "forged" }, projectId)).toThrow(/invalid/u); + expect(() => parseProjectBuildPlanVersion(input, "project_foreign")).toThrow(/cross-project/u); + }); + + it("rejects unknown fields, invalid IDs, and semantic tampering", () => { + expect(() => parseProjectBuildPlanContent({ ...content, graph: {} })).toThrow(/invalid/u); + expect(() => parseProjectBuildPlanVersion({ ...planRecord(), semanticDigest: `sha256:${"0".repeat(64)}` }, projectId)).toThrow(/digest/u); + expect(() => parseProjectBuildPlanContent({ ...content, assignments: [{ ...content.assignments[0], id: "work_bad" }] })).toThrow(/assignment/u); + }); + + it("reserves canonical and nested ad-hoc brief focus scopes", () => { + expect(parseAgentBriefFocusScope({ family: "canonical-workstream", plannedAgentId: nodeId })).toEqual({ family: "canonical-workstream", plannedAgentId: nodeId }); + expect(parseAgentBriefFocusScope({ family: "ad-hoc-delegation", delegationKey: "analysis", parentScopeKey: "scope_parent" })).toEqual({ family: "ad-hoc-delegation", delegationKey: "analysis", parentScopeKey: "scope_parent" }); + expect(() => parseAgentBriefFocusScope({ family: "builder", plannedAgentId: nodeId })).toThrow(/scope/u); + }); + + it("parses exact-source brief versions without role-bearing actors", () => { + const plan = planRecord(); + const base = { + schemaVersion: 1 as const, projectId, + briefId: "brief_018f0000-0000-7000-8000-000000000050" as AgentBriefId, + scopeKey: "scope_research" as AgentBriefScopeKey, + focusScope: { family: "canonical-workstream" as const, plannedAgentId: nodeId }, + versionId: "briefv_018f0000-0000-7000-8000-000000000051" as AgentBriefVersionId, + version: 1, parentVersionId: null, changeKind: "created" as const, restoredFromVersionId: null, + assignmentId, plannedAgentId: nodeId, map: plan.map, + plan: { projectId, planId, versionId: planVersionId, semanticDigest: plan.semanticDigest }, + content: { mission: "Research", scope: [], nonGoals: [], ownedNodeIds: [nodeId], relevantNodeIds: [], inputs: [], outputs: [], dependencies: [], sharedResourceNodeIds: [], sequenceGateIds: [], deliverables: [], acceptanceCriteria: [], constraints: [], milestoneIds: [], unresolvedDecisionIds: [] }, + compilerVersion: "1", compilerInputFingerprint: `sha256:${"3".repeat(64)}`, + semanticDigest: "" as AgentBriefSemanticDigest, authoredBy: actor, createdAt, origin, + }; + const withSemantic = { ...base, semanticDigest: computeAgentBriefSemanticDigest(base) }; + const brief = { ...withSemantic, recordDigest: computeAgentBriefRecordDigest(withSemantic) } as AgentBriefVersion; + expect(parseAgentBriefVersion(brief, projectId)).toEqual(brief); + const authorityActor = { ...brief, authoredBy: { ...actor, authority: "forged" } }; + expect(() => parseAgentBriefVersion(authorityActor, projectId)).toThrow(/actor/u); + }); +}); diff --git a/packages/harness/src/shared/build-plan-codec.ts b/packages/harness/src/shared/build-plan-codec.ts new file mode 100644 index 00000000..b8c13eb9 --- /dev/null +++ b/packages/harness/src/shared/build-plan-codec.ts @@ -0,0 +1,287 @@ +import type { + AgentMapVersionRef, + ProjectAgentActorRef, + ProjectMutationOrigin, +} from "./agent-map.js"; +import { + AGENT_MAP_UUID_V7_PATTERN, + isAgentMapBoundedText, + parseProjectAgentActorRef, + parseProjectMutationOrigin, +} from "./agent-map-codec.js"; +import { + BUILD_PLAN_SCHEMA_VERSION, + type AgentBriefFocusScope, + type AgentBriefHistoryPointer, + type AgentBriefVersion, + type AgentBriefVersionRef, + type BuildPlanCurrentPointers, + type ProjectBuildPlanContent, + type ProjectBuildPlanVersion, + type ProjectBuildPlanVersionRef, +} from "./build-plan.js"; +import { + computeAgentBriefRecordDigest, + computeAgentBriefSemanticDigest, + computeBuildPlanRecordDigest, + computeBuildPlanSemanticDigest, +} from "../core/build-plan-canonicalization.js"; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); +const exact = (value: Record, keys: readonly string[]) => { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + return actual.length === expected.length && actual.every((key, i) => key === expected[i]); +}; +const id = (value: unknown, prefix: string): value is string => + typeof value === "string" && + new RegExp(`^${prefix}_${AGENT_MAP_UUID_V7_PATTERN}$`, "u").test(value); +const digest = (value: unknown): value is string => + typeof value === "string" && /^sha256:[0-9a-f]{64}$/u.test(value); +const timestamp = (value: unknown): value is string => { + if (typeof value !== "string") return false; + try { return new Date(value).toISOString() === value; } catch { return false; } +}; +const positive = (value: unknown): value is number => + Number.isSafeInteger(value) && (value as number) > 0; +const boundedStrings = (value: unknown, limit = 512): value is string[] => + Array.isArray(value) && + value.length <= 4_096 && + value.every((entry) => isAgentMapBoundedText(entry, limit)) && + new Set(value).size === value.length; + +export function parseAgentMapVersionRef( + value: unknown, + expectedProjectId?: string, +): AgentMapVersionRef { + if ( + !isRecord(value) || !exact(value, ["projectId", "versionId", "contentDigest"]) || + !isAgentMapBoundedText(value.projectId, 128) || + (expectedProjectId !== undefined && value.projectId !== expectedProjectId) || + !id(value.versionId, "mapv") || !digest(value.contentDigest) + ) throw new Error("invalid Agent Map version reference"); + return structuredClone(value) as unknown as AgentMapVersionRef; +} + +export function parseProjectBuildPlanVersionRef( + value: unknown, + expectedProjectId?: string, +): ProjectBuildPlanVersionRef { + if ( + !isRecord(value) || + !exact(value, ["projectId", "planId", "versionId", "semanticDigest"]) || + !isAgentMapBoundedText(value.projectId, 128) || + (expectedProjectId !== undefined && value.projectId !== expectedProjectId) || + !id(value.planId, "plan") || !id(value.versionId, "planv") || + !digest(value.semanticDigest) + ) throw new Error("invalid build plan version reference"); + return structuredClone(value) as unknown as ProjectBuildPlanVersionRef; +} + +export function parseAgentBriefVersionRef( + value: unknown, + expectedProjectId?: string, +): AgentBriefVersionRef { + if ( + !isRecord(value) || + !exact(value, ["projectId", "briefId", "versionId", "semanticDigest"]) || + !isAgentMapBoundedText(value.projectId, 128) || + (expectedProjectId !== undefined && value.projectId !== expectedProjectId) || + !id(value.briefId, "brief") || !id(value.versionId, "briefv") || + !digest(value.semanticDigest) + ) throw new Error("invalid brief version reference"); + return structuredClone(value) as unknown as AgentBriefVersionRef; +} + +function parseMilestone(value: unknown) { + if (!isRecord(value) || !exact(value, ["id", "ordinal", "title", "outcome", "dependsOn"]) || + !id(value.id, "milestone") || !positive(value.ordinal) || + !isAgentMapBoundedText(value.title, 512) || !isAgentMapBoundedText(value.outcome, 4_096) || + !Array.isArray(value.dependsOn) || !value.dependsOn.every((entry) => id(entry, "milestone")) || + new Set(value.dependsOn).size !== value.dependsOn.length) + throw new Error("invalid build plan milestone"); + return structuredClone(value); +} + +function parseSequenceGate(value: unknown) { + if (!isRecord(value) || !exact(value, ["id", "ordinal", "description", "milestoneIds"]) || + !id(value.id, "gate") || !positive(value.ordinal) || + !isAgentMapBoundedText(value.description, 4_096) || !Array.isArray(value.milestoneIds) || + !value.milestoneIds.every((entry) => id(entry, "milestone")) || + new Set(value.milestoneIds).size !== value.milestoneIds.length) + throw new Error("invalid build plan sequence gate"); + return structuredClone(value); +} + +function parseRepositoryIntent(value: unknown) { + if (!isRecord(value) || !exact(value, ["id", "plannedAgentId", "repository", "packages", "ownershipBoundaries"]) || + !isAgentMapBoundedText(value.id, 128) || !id(value.plannedAgentId, "node") || + !isAgentMapBoundedText(value.repository, 512) || !boundedStrings(value.packages) || + !boundedStrings(value.ownershipBoundaries, 2_000)) + throw new Error("invalid repository intent"); + return structuredClone(value); +} + +function parseDecision(value: unknown) { + if (!isRecord(value) || !exact(value, ["id", "question", "resolution", "status"]) || + !id(value.id, "decision") || !isAgentMapBoundedText(value.question, 4_096) || + !isAgentMapBoundedText(value.resolution, 4_096, true) || !["open", "resolved"].includes(String(value.status))) + throw new Error("invalid plan decision"); + return structuredClone(value); +} + +function parseRisk(value: unknown) { + if (!isRecord(value) || !exact(value, ["id", "description", "mitigation"]) || + !id(value.id, "risk") || !isAgentMapBoundedText(value.description, 4_096) || + !isAgentMapBoundedText(value.mitigation, 4_096, true)) + throw new Error("invalid plan risk"); + return structuredClone(value); +} + +function parseAssignment(value: unknown) { + if (!isRecord(value) || !exact(value, ["id", "plannedAgentId", "briefId", "mission", "scope", "nonGoals", "dependencies"]) || + !id(value.id, "work") || !id(value.plannedAgentId, "node") || + (value.briefId !== null && !id(value.briefId, "brief")) || + !isAgentMapBoundedText(value.mission, 4_096) || !boundedStrings(value.scope, 2_000) || + !boundedStrings(value.nonGoals, 2_000) || !Array.isArray(value.dependencies) || + value.dependencies.length > 4_096 || !value.dependencies.every((dependency) => + isRecord(dependency) && exact(dependency, ["id", "kind", "nodeId", "relationshipIds", "contractRef"]) && + id(dependency.id, "dependency") && ["input", "output", "shared-resource", "depends-on"].includes(String(dependency.kind)) && + id(dependency.nodeId, "node") && Array.isArray(dependency.relationshipIds) && + dependency.relationshipIds.every((relationshipId) => id(relationshipId, "rel")) && + new Set(dependency.relationshipIds).size === dependency.relationshipIds.length && + (dependency.contractRef === null || isAgentMapBoundedText(dependency.contractRef, 256)))) + throw new Error("invalid plan assignment"); + if (new Set(value.dependencies.map((dependency) => (dependency as { id: string }).id)).size !== value.dependencies.length) + throw new Error("duplicate plan dependency"); + return structuredClone(value); +} + +export function parseProjectBuildPlanContent(value: unknown): ProjectBuildPlanContent { + if (!isRecord(value) || !exact(value, [ + "outcome", "nonGoals", "milestones", "sequenceGates", "sharedConstraints", + "repositoryIntents", "integrationCriteria", "acceptanceCriteria", "decisions", + "assignments", "unresolvedDecisions", "risks", + ]) || !isAgentMapBoundedText(value.outcome, 8_192, true) || + !boundedStrings(value.nonGoals, 2_000) || !Array.isArray(value.milestones) || + !Array.isArray(value.sequenceGates) || !boundedStrings(value.sharedConstraints, 2_000) || + !Array.isArray(value.repositoryIntents) || !boundedStrings(value.integrationCriteria, 2_000) || + !boundedStrings(value.acceptanceCriteria, 2_000) || !Array.isArray(value.decisions) || + !Array.isArray(value.assignments) || !Array.isArray(value.unresolvedDecisions) || + !Array.isArray(value.risks)) throw new Error("invalid build plan content"); + const parsed = { + outcome: value.outcome, + nonGoals: structuredClone(value.nonGoals), + milestones: value.milestones.map(parseMilestone), + sequenceGates: value.sequenceGates.map(parseSequenceGate), + sharedConstraints: structuredClone(value.sharedConstraints), + repositoryIntents: value.repositoryIntents.map(parseRepositoryIntent), + integrationCriteria: structuredClone(value.integrationCriteria), + acceptanceCriteria: structuredClone(value.acceptanceCriteria), + decisions: value.decisions.map(parseDecision), + assignments: value.assignments.map(parseAssignment), + unresolvedDecisions: value.unresolvedDecisions.map(parseDecision), + risks: value.risks.map(parseRisk), + } as unknown as ProjectBuildPlanContent; + const identifiers = [parsed.milestones, parsed.sequenceGates, parsed.repositoryIntents, + parsed.decisions, parsed.assignments, parsed.unresolvedDecisions, parsed.risks] + .flatMap((entries) => entries.map(({ id: entryId }) => entryId)); + if (new Set(identifiers).size !== identifiers.length) + throw new Error("duplicate build plan identity"); + return parsed; +} + +function parseVersionBase(value: Record, prefix: "planv" | "briefv") { + if (value.schemaVersion !== BUILD_PLAN_SCHEMA_VERSION || !isAgentMapBoundedText(value.projectId, 128) || + !id(value.versionId, prefix) || !positive(value.version) || + (value.parentVersionId !== null && !id(value.parentVersionId, prefix)) || + !["created", "edited", "rebased", "restored", "migrated"].includes(String(value.changeKind)) || + (value.restoredFromVersionId !== null && !id(value.restoredFromVersionId, prefix)) || + (value.changeKind === "restored") !== (value.restoredFromVersionId !== null) || + !timestamp(value.createdAt) || !digest(value.recordDigest)) + throw new Error("invalid immutable planning version"); +} + +export function parseProjectBuildPlanVersion(value: unknown, expectedProjectId?: string): ProjectBuildPlanVersion { + if (!isRecord(value) || !exact(value, ["schemaVersion", "projectId", "planId", "versionId", "version", + "parentVersionId", "changeKind", "restoredFromVersionId", "map", "content", "semanticDigest", + "authoredBy", "createdAt", "origin", "recordDigest"]) || !id(value.planId, "plan") || + !digest(value.semanticDigest)) throw new Error("invalid build plan version"); + parseVersionBase(value, "planv"); + if (expectedProjectId !== undefined && value.projectId !== expectedProjectId) + throw new Error("cross-project build plan"); + const parsed = { ...structuredClone(value), map: parseAgentMapVersionRef(value.map, value.projectId as string), + content: parseProjectBuildPlanContent(value.content), authoredBy: parseProjectAgentActorRef(value.authoredBy), + origin: parseProjectMutationOrigin(value.origin) } as unknown as ProjectBuildPlanVersion; + if (computeBuildPlanSemanticDigest(parsed) !== parsed.semanticDigest || + computeBuildPlanRecordDigest(parsed) !== parsed.recordDigest) throw new Error("build plan digest mismatch"); + return parsed; +} + +export function parseAgentBriefFocusScope(value: unknown): AgentBriefFocusScope { + if (!isRecord(value)) throw new Error("invalid brief focus scope"); + if (value.family === "canonical-workstream" && exact(value, ["family", "plannedAgentId"]) && id(value.plannedAgentId, "node")) + return structuredClone(value) as unknown as AgentBriefFocusScope; + if (value.family === "ad-hoc-delegation" && exact(value, ["family", "delegationKey", "parentScopeKey"]) && + isAgentMapBoundedText(value.delegationKey, 256) && + (value.parentScopeKey === null || isAgentMapBoundedText(value.parentScopeKey, 256))) + return structuredClone(value) as unknown as AgentBriefFocusScope; + throw new Error("invalid brief focus scope"); +} + +function parseBriefContent(value: unknown): AgentBriefVersion["content"] { + const keys = ["mission", "scope", "nonGoals", "ownedNodeIds", "relevantNodeIds", "inputs", "outputs", + "dependencies", "sharedResourceNodeIds", "sequenceGateIds", "deliverables", "acceptanceCriteria", + "constraints", "milestoneIds", "unresolvedDecisionIds"]; + if (!isRecord(value) || !exact(value, keys) || !isAgentMapBoundedText(value.mission, 4_096) || + !keys.slice(1).every((key) => Array.isArray(value[key])) || + !boundedStrings(value.scope, 2_000) || !boundedStrings(value.nonGoals, 2_000) || + !boundedStrings(value.inputs, 2_000) || !boundedStrings(value.outputs, 2_000) || + !boundedStrings(value.dependencies, 2_000) || !boundedStrings(value.deliverables, 2_000) || + !boundedStrings(value.acceptanceCriteria, 2_000) || !boundedStrings(value.constraints, 2_000) || + ![...value.ownedNodeIds as unknown[], ...value.relevantNodeIds as unknown[], ...value.sharedResourceNodeIds as unknown[]].every((entry) => id(entry, "node")) || + !(value.sequenceGateIds as unknown[]).every((entry) => id(entry, "gate")) || + !(value.milestoneIds as unknown[]).every((entry) => id(entry, "milestone")) || + !(value.unresolvedDecisionIds as unknown[]).every((entry) => id(entry, "decision"))) + throw new Error("invalid brief content"); + return structuredClone(value) as unknown as AgentBriefVersion["content"]; +} + +export function parseAgentBriefVersion(value: unknown, expectedProjectId?: string): AgentBriefVersion { + if (!isRecord(value) || !exact(value, ["schemaVersion", "projectId", "briefId", "scopeKey", "focusScope", + "versionId", "version", "parentVersionId", "changeKind", "restoredFromVersionId", "assignmentId", + "plannedAgentId", "map", "plan", "content", "compilerVersion", "compilerInputFingerprint", + "semanticDigest", "authoredBy", "createdAt", "origin", "recordDigest"]) || !id(value.briefId, "brief") || + !isAgentMapBoundedText(value.scopeKey, 256) || !id(value.assignmentId, "work") || + !id(value.plannedAgentId, "node") || !isAgentMapBoundedText(value.compilerVersion, 128) || + !digest(value.compilerInputFingerprint) || !digest(value.semanticDigest)) throw new Error("invalid brief version"); + parseVersionBase(value, "briefv"); + if (expectedProjectId !== undefined && value.projectId !== expectedProjectId) throw new Error("cross-project brief"); + const parsed = { ...structuredClone(value), focusScope: parseAgentBriefFocusScope(value.focusScope), + map: parseAgentMapVersionRef(value.map, value.projectId as string), + plan: parseProjectBuildPlanVersionRef(value.plan, value.projectId as string), content: parseBriefContent(value.content), + authoredBy: parseProjectAgentActorRef(value.authoredBy), origin: parseProjectMutationOrigin(value.origin) } as unknown as AgentBriefVersion; + if (computeAgentBriefSemanticDigest(parsed) !== parsed.semanticDigest || + computeAgentBriefRecordDigest(parsed) !== parsed.recordDigest) throw new Error("brief digest mismatch"); + return parsed; +} + +export function parseBuildPlanCurrentPointers(value: unknown, expectedProjectId: string): BuildPlanCurrentPointers { + if (!isRecord(value) || !exact(value, ["map", "buildPlan", "briefsByScope"]) || !isRecord(value.briefsByScope)) + throw new Error("invalid planning pointers"); + const briefsByScope: Record = {}; + for (const [scopeKey, pointer] of Object.entries(value.briefsByScope)) { + if (!isRecord(pointer) || !exact(pointer, ["scopeKey", "focusScope", "briefId", "status", "version"]) || + pointer.scopeKey !== scopeKey || !isAgentMapBoundedText(scopeKey, 256) || !id(pointer.briefId, "brief") || + !["active", "retired"].includes(String(pointer.status))) throw new Error("invalid brief pointer"); + briefsByScope[scopeKey] = { ...structuredClone(pointer), focusScope: parseAgentBriefFocusScope(pointer.focusScope), + version: parseAgentBriefVersionRef(pointer.version, expectedProjectId) } as AgentBriefHistoryPointer; + } + return { map: value.map === null ? null : parseAgentMapVersionRef(value.map, expectedProjectId), + buildPlan: value.buildPlan === null ? null : parseProjectBuildPlanVersionRef(value.buildPlan, expectedProjectId), + briefsByScope }; +} + +export { parseProjectAgentActorRef, parseProjectMutationOrigin }; +export type { ProjectAgentActorRef, ProjectMutationOrigin }; diff --git a/packages/harness/src/shared/build-plan.ts b/packages/harness/src/shared/build-plan.ts new file mode 100644 index 00000000..e381eb55 --- /dev/null +++ b/packages/harness/src/shared/build-plan.ts @@ -0,0 +1,391 @@ +import type { + AgentMapVersionId, + AgentMapVersionRef, + PlanNodeId, + ProjectAgentActorRef, + ProjectMutationOrigin, + ProjectVersionChangeKind, + RecordDigest, + StudioProjectId, +} from "./agent-map.js"; + +export const BUILD_PLAN_SCHEMA_VERSION = 1 as const; +export const PROJECT_PLANNING_STORAGE_SCHEMA_VERSION = 2 as const; +export const BUILD_PLAN_VERSION_HISTORY_LIMIT = 1_024; +export const AGENT_BRIEF_VERSION_HISTORY_LIMIT = 1_024; +export const PROJECT_MUTATION_RECEIPT_LIMIT = 1_024; +export const PROJECT_MUTATION_TOMBSTONE_LIMIT = 8_192; +export const BUILD_PLAN_ID_MAPPING_LIMIT = 128; + +type Brand = string & { readonly __brand: TBrand }; + +export type ProjectBuildPlanId = Brand<"ProjectBuildPlanId">; +export type BuildPlanId = ProjectBuildPlanId; +export type ProjectBuildPlanVersionId = Brand<"ProjectBuildPlanVersionId">; +export type BuildPlanSemanticDigest = Brand<"BuildPlanSemanticDigest">; +export type AgentBriefId = Brand<"AgentBriefId">; +export type AgentBriefVersionId = Brand<"AgentBriefVersionId">; +export type AgentBriefSemanticDigest = Brand<"AgentBriefSemanticDigest">; +export type AgentBriefScopeKey = Brand<"AgentBriefScopeKey">; +export type PlanningAssignmentId = Brand<"PlanningAssignmentId">; +export type MilestoneId = Brand<"MilestoneId">; +export type SequenceGateId = Brand<"SequenceGateId">; +export type PlanDecisionId = Brand<"PlanDecisionId">; +export type PlanRiskId = Brand<"PlanRiskId">; +export type BuildPlanDependencyId = Brand<"BuildPlanDependencyId">; + +export type ProjectBuildPlanVersionRef = Readonly<{ + projectId: StudioProjectId; + planId: ProjectBuildPlanId; + versionId: ProjectBuildPlanVersionId; + semanticDigest: BuildPlanSemanticDigest; +}>; + +export type AgentBriefVersionRef = Readonly<{ + projectId: StudioProjectId; + briefId: AgentBriefId; + versionId: AgentBriefVersionId; + semanticDigest: AgentBriefSemanticDigest; +}>; + +/** Neutral focus identity; delegation scopes may nest without becoming roles. */ +export type AgentBriefFocusScope = + | Readonly<{ + family: "canonical-workstream"; + plannedAgentId: PlanNodeId; + }> + | Readonly<{ + family: "ad-hoc-delegation"; + delegationKey: string; + parentScopeKey: AgentBriefScopeKey | null; + }>; + +export type AgentBriefHistoryPointer = Readonly<{ + scopeKey: AgentBriefScopeKey; + focusScope: AgentBriefFocusScope; + briefId: AgentBriefId; + status: "active" | "retired"; + version: AgentBriefVersionRef; +}>; + +export interface BuildPlanMilestone { + id: MilestoneId; + ordinal: number; + title: string; + outcome: string; + dependsOn: readonly MilestoneId[]; +} + +export interface BuildPlanSequenceGate { + id: SequenceGateId; + ordinal: number; + description: string; + milestoneIds: readonly MilestoneId[]; +} + +export interface BuildPlanRepositoryIntent { + id: string; + plannedAgentId: PlanNodeId; + repository: string; + packages: readonly string[]; + ownershipBoundaries: readonly string[]; +} + +export interface BuildPlanDecision { + id: PlanDecisionId; + question: string; + resolution: string; + status: "open" | "resolved"; +} + +export interface BuildPlanRisk { + id: PlanRiskId; + description: string; + mitigation: string; +} + +/** Intent only: map nodes and relationships remain the sole editable topology. */ +export interface BuildPlanAssignmentIntent { + id: PlanningAssignmentId; + plannedAgentId: PlanNodeId; + briefId: AgentBriefId | null; + mission: string; + scope: readonly string[]; + nonGoals: readonly string[]; + dependencies: readonly BuildPlanDependencyIntent[]; +} + +export interface BuildPlanDependencyIntent { + id: BuildPlanDependencyId; + kind: "input" | "output" | "shared-resource" | "depends-on"; + nodeId: PlanNodeId; + relationshipIds: readonly string[]; + contractRef: string | null; +} + +export interface ProjectBuildPlanContent { + outcome: string; + nonGoals: readonly string[]; + milestones: readonly BuildPlanMilestone[]; + sequenceGates: readonly BuildPlanSequenceGate[]; + sharedConstraints: readonly string[]; + repositoryIntents: readonly BuildPlanRepositoryIntent[]; + integrationCriteria: readonly string[]; + acceptanceCriteria: readonly string[]; + decisions: readonly BuildPlanDecision[]; + assignments: readonly BuildPlanAssignmentIntent[]; + unresolvedDecisions: readonly BuildPlanDecision[]; + risks: readonly BuildPlanRisk[]; +} + +export type ProjectBuildPlanVersion = Readonly<{ + schemaVersion: typeof BUILD_PLAN_SCHEMA_VERSION; + projectId: StudioProjectId; + planId: ProjectBuildPlanId; + versionId: ProjectBuildPlanVersionId; + version: number; + parentVersionId: ProjectBuildPlanVersionId | null; + changeKind: ProjectVersionChangeKind; + restoredFromVersionId: ProjectBuildPlanVersionId | null; + map: AgentMapVersionRef; + content: ProjectBuildPlanContent; + semanticDigest: BuildPlanSemanticDigest; + authoredBy: ProjectAgentActorRef; + createdAt: string; + origin: ProjectMutationOrigin; + recordDigest: RecordDigest; +}>; + +export interface AgentBriefContent { + mission: string; + scope: readonly string[]; + nonGoals: readonly string[]; + ownedNodeIds: readonly PlanNodeId[]; + relevantNodeIds: readonly PlanNodeId[]; + inputs: readonly string[]; + outputs: readonly string[]; + dependencies: readonly string[]; + sharedResourceNodeIds: readonly PlanNodeId[]; + sequenceGateIds: readonly SequenceGateId[]; + deliverables: readonly string[]; + acceptanceCriteria: readonly string[]; + constraints: readonly string[]; + milestoneIds: readonly MilestoneId[]; + unresolvedDecisionIds: readonly PlanDecisionId[]; +} + +export const AGENT_BRIEF_COMPILER_VERSION = "1.0.0"; + +export const AGENT_BRIEF_FINGERPRINT_KINDS = [ + "owned-nodes", + "relevant-nodes", + "input-contracts", + "output-contracts", + "relationships", + "resources", + "milestones", + "shared-plan-content", + "assignment-content", +] as const; +export type AgentBriefFingerprintKind = + (typeof AGENT_BRIEF_FINGERPRINT_KINDS)[number]; + +export type AgentBriefDependencyFingerprint = Readonly<{ + kind: AgentBriefFingerprintKind; + digest: string; + nodeIds: readonly PlanNodeId[]; + relationshipIds: readonly string[]; + contractRefs: readonly string[]; +}>; + +export type AgentBriefDisposition = + | "created" + | "new-version" + | "unchanged" + | "retired"; + +export type AgentBriefStaleReasonCode = + | "agent-added" + | "agent-removed" + | "ownership-changed" + | "relevant-node-changed" + | "contract-changed" + | "relationship-changed" + | "resource-changed" + | "milestone-changed" + | "shared-plan-content-changed" + | "assignment-content-changed"; + +export type AgentBriefStaleReason = Readonly<{ + code: AgentBriefStaleReasonCode; + affectedNodeIds: readonly PlanNodeId[]; + affectedRelationshipIds: readonly string[]; + affectedContractRefs: readonly string[]; + previousFingerprint?: string; + currentFingerprint?: string; +}>; + +export type AgentBriefImpactEntry = Readonly<{ + scopeKey: AgentBriefScopeKey; + briefId: AgentBriefId; + disposition: "added" | "removed" | "stale" | "preserved"; + reasons: readonly AgentBriefStaleReason[]; +}>; + +export type AgentBriefImpact = Readonly<{ + affectedWorkstreamCount: number; + entries: readonly AgentBriefImpactEntry[]; + staleBriefIds: readonly AgentBriefId[]; + preservedBriefIds: readonly AgentBriefId[]; + changedNodeIds: readonly PlanNodeId[]; + changedRelationshipIds: readonly string[]; + changedContractRefs: readonly string[]; + digest: string; +}>; + +/** + * Exact-source immutable history contract for canonical and focused briefs. + * Producers and storage can validate these records independently. + */ +export type AgentBriefVersion = Readonly<{ + schemaVersion: typeof BUILD_PLAN_SCHEMA_VERSION; + projectId: StudioProjectId; + briefId: AgentBriefId; + scopeKey: AgentBriefScopeKey; + focusScope: AgentBriefFocusScope; + versionId: AgentBriefVersionId; + version: number; + parentVersionId: AgentBriefVersionId | null; + changeKind: ProjectVersionChangeKind; + restoredFromVersionId: AgentBriefVersionId | null; + assignmentId: PlanningAssignmentId; + plannedAgentId: PlanNodeId; + map: AgentMapVersionRef; + plan: ProjectBuildPlanVersionRef; + content: AgentBriefContent; + compilerVersion: string; + compilerInputFingerprint: string; + semanticDigest: AgentBriefSemanticDigest; + authoredBy: ProjectAgentActorRef; + createdAt: string; + origin: ProjectMutationOrigin; + recordDigest: RecordDigest; +}>; + +export type AgentBriefVersionRecord = AgentBriefVersion; + +export interface BuildPlanDiagnostic { + code: + | "missing-assignment" + | "missing-brief" + | "brief-source-stale" + | "unknown-node-reference" + | "invalid-repository-owner" + | "invalid-milestone-dependency" + | "invalid-dependency" + | "duplicate-ordinal" + | "unresolved-decision" + | "source-mismatch" + | "source-lineage-mismatch" + | "ambiguous-focus-owner" + | "missing-focus-node" + | "brief-limit-exceeded" + | "brief-compilation-failed" + | "context-truncated"; + severity: "error" | "warning"; + path: string; + relatedIds: readonly string[]; +} + +export interface BuildPlanIdMapping { + kind: "plan" | "assignment" | "milestone" | "sequence-gate" | "repository-intent" | + "dependency" | "decision" | "risk" | "brief"; + clientRef: string; + id: string; +} + +export interface ProjectMutationReceipt { + projectId: StudioProjectId; + userId: string; + sessionId: string; + requestId: string; + requestDigest: string; + operation: "map" | "build_plan_apply" | "build_plan_rebase" | "map_restore" | "plan_restore" | "brief_append"; + result: TResult; + createdAt: string; +} + +export interface ProjectMutationTombstone { + projectId: StudioProjectId; + userId: string; + sessionId: string; + requestId: string; + operation: ProjectMutationReceipt["operation"]; + createdAt: string; +} + +export type BuildPlanReadSelector = + | Readonly<{ kind: "current" }> + | Readonly<{ + kind: "exact"; + planId: ProjectBuildPlanId; + versionId: ProjectBuildPlanVersionId; + semanticDigest: BuildPlanSemanticDigest; + }>; + +export type BuildPlanCurrentPointers = Readonly<{ + map: AgentMapVersionRef | null; + buildPlan: ProjectBuildPlanVersionRef | null; + briefsByScope: Readonly>; +}>; + +export interface BuildPlanHistorySummary { + ref: ProjectBuildPlanVersionRef; + version: number; + changeKind: ProjectVersionChangeKind; + map: AgentMapVersionRef; + createdAt: string; +} + +export interface BuildPlanReadResult { + current: BuildPlanCurrentPointers; + plan: ProjectBuildPlanVersion | null; + diagnostics: readonly BuildPlanDiagnostic[]; + history: readonly BuildPlanHistorySummary[]; +} + +export const emptyProjectBuildPlanContent = (): ProjectBuildPlanContent => ({ + outcome: "", + nonGoals: [], + milestones: [], + sequenceGates: [], + sharedConstraints: [], + repositoryIntents: [], + integrationCriteria: [], + acceptanceCriteria: [], + decisions: [], + assignments: [], + unresolvedDecisions: [], + risks: [], +}); + +export const agentMapVersionRefsEqual = ( + left: AgentMapVersionRef, + right: AgentMapVersionRef, +): boolean => + left.projectId === right.projectId && + left.versionId === right.versionId && + left.contentDigest === right.contentDigest; + +export const projectBuildPlanVersionRefsEqual = ( + left: ProjectBuildPlanVersionRef, + right: ProjectBuildPlanVersionRef, +): boolean => + left.projectId === right.projectId && + left.planId === right.planId && + left.versionId === right.versionId && + left.semanticDigest === right.semanticDigest; + +/** Compatibility alias: final plans bind only to exact immutable map versions. */ +export type ArchitectureSourceRef = AgentMapVersionRef; +export type AgentMapRevisionId = AgentMapVersionId;