diff --git a/packages/cli/src/commands/code-pack.test.ts b/packages/cli/src/commands/code-pack.test.ts index 1a5d5f4..6ba73a9 100644 --- a/packages/cli/src/commands/code-pack.test.ts +++ b/packages/cli/src/commands/code-pack.test.ts @@ -17,12 +17,15 @@ import { sha256Hex } from "@opencodehub/core-types"; import type { PackManifest } from "@opencodehub/pack"; import type { IGraphStore } from "@opencodehub/storage"; import { + ATTESTATION_FILENAME, DEFAULT_BUDGET_TOKENS, DEFAULT_ENGINE, DEFAULT_TOKENIZER_ID, explainContextBom, formatContextSummary, runCodePack, + SONNET5_TOKENIZER_ID, + writeContextAttestation, } from "./code-pack.js"; function makeFakeManifest(overrides: Partial = {}): PackManifest { @@ -64,6 +67,17 @@ test("DEFAULT_TOKENIZER_ID matches the spec pin", () => { assert.equal(DEFAULT_TOKENIZER_ID, "openai:o200k_base@tiktoken-0.8.0"); }); +test("SONNET5_TOKENIZER_ID is the anthropic-prefixed Sonnet-5 lane", () => { + assert.equal(SONNET5_TOKENIZER_ID, "anthropic:claude-sonnet-5@2026-06-30"); + // The anthropic: vendor prefix is load-bearing — it is what makes the pack's + // resolveDeterminism downgrade the lane to best_effort (see pack index.test.ts + // E2E-B2). Guard against an accidental prefix change here. + assert.ok( + SONNET5_TOKENIZER_ID.startsWith("anthropic:"), + "Sonnet-5 lane must use the anthropic: vendor prefix to inherit best_effort determinism", + ); +}); + test("runCodePack defaults to engine=pack and dispatches to generatePack", async () => { const repoPath = await mkdtemp(join(tmpdir(), "codehub-codepack-default-")); try { @@ -338,7 +352,7 @@ test("explainContextBom summarizes a context-bom.json on disk", async () => { try { const doc = { bomFormat: "CycloneDX", - specVersion: "1.6", + specVersion: "1.7", version: 1, components: [ { @@ -383,3 +397,45 @@ test("explainContextBom throws a clear error when context-bom.json is absent", a await rm(dir, { recursive: true, force: true }); } }); + +test("writeContextAttestation writes a parseable in-toto Statement to the pack dir", async () => { + const dir = await mkdtemp(join(tmpdir(), "codehub-prove-")); + try { + const manifest = makeFakeManifest({ packHash: "9".repeat(64) }); + const path = await writeContextAttestation(dir, manifest); + + // Written at the documented filename inside the pack dir. + assert.equal(path, join(dir, ATTESTATION_FILENAME)); + + const raw = await readFile(path, "utf8"); + const stmt = JSON.parse(raw); + // Exact in-toto Statement v1 envelope. + assert.equal(stmt._type, "https://in-toto.io/Statement/v1"); + assert.equal(stmt.predicateType, "https://opencodehub.dev/attestation/context/v0.1"); + // Subject digest equals the manifest packHash. + assert.equal(stmt.subject.length, 1); + assert.equal(stmt.subject[0].digest.sha256, manifest.packHash); + // Predicate carries the manifest's context provenance. + assert.equal(stmt.predicate.packHash, manifest.packHash); + assert.equal(stmt.predicate.contextBomHash, manifest.contextBomHash); + assert.equal(stmt.predicate.bomItems.length, manifest.files.length); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("writeContextAttestation is byte-deterministic across two emissions", async () => { + const dir1 = await mkdtemp(join(tmpdir(), "codehub-prove-det-1-")); + const dir2 = await mkdtemp(join(tmpdir(), "codehub-prove-det-2-")); + try { + const manifest = makeFakeManifest(); + await writeContextAttestation(dir1, manifest); + await writeContextAttestation(dir2, manifest); + const a = await readFile(join(dir1, ATTESTATION_FILENAME), "utf8"); + const b = await readFile(join(dir2, ATTESTATION_FILENAME), "utf8"); + assert.equal(a, b); + } finally { + await rm(dir1, { recursive: true, force: true }); + await rm(dir2, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/src/commands/code-pack.ts b/packages/cli/src/commands/code-pack.ts index 7a38b68..7fcbcf8 100644 --- a/packages/cli/src/commands/code-pack.ts +++ b/packages/cli/src/commands/code-pack.ts @@ -32,13 +32,20 @@ import { createHash } from "node:crypto"; import { existsSync, statSync } from "node:fs"; -import { mkdir, mkdtemp, readFile, rename, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import type { FileNode, GraphNode, RepoNode } from "@opencodehub/core-types"; import { sha256Hex } from "@opencodehub/core-types"; import { parse as ingestionParse } from "@opencodehub/ingestion"; -import { generatePack, type PackManifest } from "@opencodehub/pack"; +import { + buildContextAttestation, + type CacheChannel, + DEFAULT_CACHE_CHANNEL, + generatePack, + type PackManifest, + serializeAttestation, +} from "@opencodehub/pack"; import { type IGraphStore, openStore, resolveGraphPath, type Store } from "@opencodehub/storage"; import { runPack } from "./pack.js"; @@ -48,6 +55,27 @@ export const DEFAULT_BUDGET_TOKENS = 100_000; /** Default tokenizer identifier when `--tokenizer` is omitted. */ export const DEFAULT_TOKENIZER_ID = "openai:o200k_base@tiktoken-0.8.0"; +/** + * Tokenizer-provenance lane for Claude Sonnet 5 (launched 2026-06-30). + * + * Sonnet 5 ships a new tokenizer that inflates the same source bytes by + * ~30-35% vs prior Claude tokenizers, so a budget authored for the default + * `openai:o200k_base` lane under-provisions when the *consuming* agent is + * Sonnet 5 — the pack's budgetTokens→chunkSize map is 1:1, so the same budget + * silently produces oversized chunks under the heavier tokenizer. + * + * This constant is provenance metadata ONLY: it records which tokenizer a pack + * was authored against so a variance probe (Finding 0001 v2) can attribute + * results to a lane. It does NOT change the bytes→token math — there is no + * runtime Sonnet-5 encoder. The `anthropic:` vendor prefix is load-bearing: + * `@opencodehub/pack`'s `resolveDeterminism` downgrades any `anthropic:`-prefixed + * lane from `strict` to `best_effort`, which is the correct class for a pack + * whose byte-identity guarantee is relaxed by a Claude tokenizer. + * + * Format follows the `:@` convention (see PackManifest). + */ +export const SONNET5_TOKENIZER_ID = "anthropic:claude-sonnet-5@2026-06-30"; + /** Default engine when `--engine` is omitted — the new `@opencodehub/pack` BOM. */ export const DEFAULT_ENGINE: "pack" | "repomix" = "pack"; @@ -62,6 +90,13 @@ export interface CodePackArgs { readonly outDir?: string; /** Engine: "pack" (default) or "repomix" (legacy opt-in). */ readonly engine?: "pack" | "repomix"; + /** + * Delivery channel for channel-aware cache-prefix enforcement (Move 4). + * Recorded on the pack options and threaded into the agent-facing assembly. + * Kept OUT of the manifest/packHash preimage, so the default (`auto`) leaves + * pack output byte-identical to pre-Move-4. Defaults to `auto`. + */ + readonly cacheChannel?: CacheChannel; /** * Test seam — inject a custom `generatePack` so unit tests don't need * to load native storage bindings. Production callers leave this @@ -105,6 +140,11 @@ export interface CodePackResult { * directory; consumers should walk `outDir`). */ readonly repomixOutputPath?: string; + /** + * Absolute path of the in-toto context attestation, present only when the + * `--prove` flag emitted one (pack engine only). Undefined otherwise. + */ + readonly attestationPath?: string; } export async function runCodePack(args: CodePackArgs = {}): Promise { @@ -120,6 +160,7 @@ export async function runCodePack(args: CodePackArgs = {}): Promise { const budget = args.budget ?? DEFAULT_BUDGET_TOKENS; const tokenizer = args.tokenizer ?? DEFAULT_TOKENIZER_ID; + const cacheChannel = args.cacheChannel ?? DEFAULT_CACHE_CHANNEL; const generate = args._generatePack ?? generatePack; // Production: open a read-only graph store; tests inject `_store` to @@ -175,6 +216,9 @@ async function runPackEngine(repoPath: string, args: CodePackArgs): Promise/attestation.intoto.json`. + * + * The Statement is a pure function of the manifest (no clock / UUID / run-id), + * so re-emitting over the same pack yields byte-identical bytes. This is the + * UNSIGNED statement; signing (cosign keyless) stays a CI concern that can + * layer a DSSE envelope over these bytes. + * + * Returns the absolute path written so the caller can surface it. + */ +export async function writeContextAttestation( + outDir: string, + manifest: PackManifest, +): Promise { + const statement = buildContextAttestation(manifest); + const bytes = new TextEncoder().encode(serializeAttestation(statement)); + const attestationPath = join(outDir, ATTESTATION_FILENAME); + await writeFile(attestationPath, bytes); + return attestationPath; +} + /** Summary of a pack's context read-receipt, derived from context-bom.json. */ export interface ContextSummary { /** Number of source files recorded in the receipt. */ diff --git a/packages/cli/src/commands/replay.test.ts b/packages/cli/src/commands/replay.test.ts index d33c6af..00b7e88 100644 --- a/packages/cli/src/commands/replay.test.ts +++ b/packages/cli/src/commands/replay.test.ts @@ -187,7 +187,7 @@ describe("loadPack (real on-disk)", () => { // context-bom.json — CycloneDX with an opencodehub:byteRanges property. const contextBom = JSON.stringify({ bomFormat: "CycloneDX", - specVersion: "1.6", + specVersion: "1.7", components: [ { type: "file", diff --git a/packages/cli/src/commands/variance-probe.test.ts b/packages/cli/src/commands/variance-probe.test.ts index 301f258..f2f9676 100644 --- a/packages/cli/src/commands/variance-probe.test.ts +++ b/packages/cli/src/commands/variance-probe.test.ts @@ -14,6 +14,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { after, before, describe, it } from "node:test"; import type { AgentRunner, Harness, RunOutcome, RunRequest } from "@opencodehub/eval"; +import { DEFAULT_TOKENIZER_ID, SONNET5_TOKENIZER_ID } from "./code-pack.js"; import { assemblePackContext, runVarianceProbe } from "./variance-probe.js"; /** A fake runner: stable answer with-pack, distinct answer per run without. */ @@ -108,6 +109,47 @@ describe("runVarianceProbe (seamed)", () => { assert.equal(withPackPrompts, 2, "both with-pack runs saw the assembled context"); }); + it("threads --pack-tokenizer into the assemble call and onto the report", async () => { + let seenTokenizer: string | undefined; + const report = await runVarianceProbe({ + taskFile, + runs: 1, + harness: "claude", + packTokenizer: SONNET5_TOKENIZER_ID, + _assemblePackContext: async (_repo, tokenizer) => { + seenTokenizer = tokenizer; + return "PACK"; + }, + _runnerFor: (h) => new FakeRunner(h), + }); + assert.equal( + seenTokenizer, + SONNET5_TOKENIZER_ID, + "the with-pack arm packs under the requested lane", + ); + assert.equal( + report.packTokenizerId, + SONNET5_TOKENIZER_ID, + "the report attributes the result to the tokenizer lane (Finding 0001 v2)", + ); + }); + + it("falls back to the default tokenizer lane when --pack-tokenizer is absent", async () => { + let seenTokenizer: string | undefined; + const report = await runVarianceProbe({ + taskFile, + runs: 1, + harness: "claude", + _assemblePackContext: async (_repo, tokenizer) => { + seenTokenizer = tokenizer; + return "PACK"; + }, + _runnerFor: (h) => new FakeRunner(h), + }); + assert.equal(seenTokenizer, DEFAULT_TOKENIZER_ID, "default lane unchanged when flag omitted"); + assert.equal(report.packTokenizerId, DEFAULT_TOKENIZER_ID); + }); + it("builds a per-harness runner for each agent in the default set (Bug-2 routing)", async () => { // With no --harness pin, the probe visits both agents; the default factory // maps args.models[harness] to each. We assert the factory is invoked once @@ -158,4 +200,38 @@ describe("assemblePackContext", () => { // sorted: readme.md before skeleton.jsonl assert.ok(ctx.indexOf("### readme.md") < ctx.indexOf("### skeleton.jsonl")); }); + + it("Move 4: the auto default is byte-identical to the no-channel call (no marker)", async () => { + const bare = await assemblePackContext(packDir); + const auto = await assemblePackContext(packDir, "auto"); + assert.equal(auto, bare, "auto must not perturb the default output"); + assert.ok(!auto.includes("opencodehub:cachePoint"), "auto emits no cache marker"); + }); + + it("Move 4: an automatic channel emits no marker", async () => { + const ctx = await assemblePackContext(packDir, "anthropic"); + assert.ok(!ctx.includes("opencodehub:cachePoint"), "anthropic caches automatically"); + assert.equal(ctx, await assemblePackContext(packDir), "identical to the marker-free default"); + }); + + it("Move 4: bedrock inserts one cache-breakpoint sentinel at the prefix boundary", async () => { + const ctx = await assemblePackContext(packDir, "bedrock"); + const marker = + ''; + assert.ok(ctx.includes(marker), "bedrock sentinel present"); + assert.equal(ctx.split(marker).length - 1, 1, "exactly one marker"); + // skeleton.jsonl is the sole stable-prefix file present, so the boundary + // sits immediately after it (before the volatile tail would begin). + assert.ok( + ctx.indexOf("### skeleton.jsonl") < ctx.indexOf(marker), + "marker follows the stable skeleton prefix", + ); + }); + + it("Move 4: same channel twice is byte-identical (deterministic)", async () => { + assert.equal( + await assemblePackContext(packDir, "bedrock"), + await assemblePackContext(packDir, "bedrock"), + ); + }); }); diff --git a/packages/cli/src/commands/variance-probe.ts b/packages/cli/src/commands/variance-probe.ts index b04305d..ea04229 100644 --- a/packages/cli/src/commands/variance-probe.ts +++ b/packages/cli/src/commands/variance-probe.ts @@ -32,7 +32,13 @@ import { serializeReport, type VarianceReport, } from "@opencodehub/eval"; -import { runCodePack } from "./code-pack.js"; +import { + type CacheChannel, + cacheBreakpointSentinel, + cacheChannelNeedsMarkers, + DEFAULT_CACHE_CHANNEL, +} from "@opencodehub/pack"; +import { DEFAULT_TOKENIZER_ID, runCodePack } from "./code-pack.js"; export interface VarianceProbeArgs { /** Path to the task file (YAML or JSON). */ @@ -51,11 +57,28 @@ export interface VarianceProbeArgs { * default when absent. */ readonly models?: Partial>; + /** + * Tokenizer-provenance lane the with-pack arm packs under + * (":@"). Defaults to the pack's DEFAULT_TOKENIZER_ID when + * absent. Recorded on the report's `packTokenizerId` so Finding 0001 v2 can + * attribute results to a lane (e.g. SONNET5_TOKENIZER_ID for Sonnet 5's + * heavier tokenizer). + */ + readonly packTokenizer?: string; + /** + * Delivery channel for channel-aware cache-prefix enforcement (Move 4). + * Threads into the pack-context assembler so the probe's with-pack arm gets + * a cache-breakpoint marker on the opt-in channels (`bedrock`, `vertex`) and + * a marker-free byte-identical context on the automatic channels + the + * `auto` default. Defaults to `auto` when omitted. + */ + readonly cacheChannel?: CacheChannel; /** * Test seam — inject a fake pack-context assembler so unit tests don't need a - * real analyzed repo + pack on disk. + * real analyzed repo + pack on disk. Receives the resolved tokenizer lane so + * a test can assert it threads through to the assemble call. */ - readonly _assemblePackContext?: (repo: string) => Promise; + readonly _assemblePackContext?: (repo: string, tokenizer: string) => Promise; /** * Test seam — inject a runner factory so unit tests drive a fake agent * instead of spawning the real `claude` / `codex` CLIs. @@ -63,14 +86,43 @@ export interface VarianceProbeArgs { readonly _runnerFor?: (harness: Harness) => AgentRunner; } +/** + * The deterministic prefix boundary for channel-aware cache enforcement + * (Move 4). Files whose names sort at or before this marker are the "stable + * prefix" (skeleton + file-tree — the large, slow-to-change bulk of a pack); + * everything after is the volatile tail (findings, xrefs, licenses, readme, + * …). When a channel needs cache markers, the cache-breakpoint sentinel is + * inserted at exactly this boundary so the expensive stable prefix is cached + * and only the tail is re-processed run-to-run. + * + * Chosen because `file-tree.jsonl` and `skeleton.jsonl` are the two files that + * sort first among the pack body files AND are the deterministic, high-volume + * structural artifacts — the ideal cache prefix. The boundary is expressed as a + * predicate over the sorted file list rather than a hard-coded index so it + * stays correct if a pack omits one of these files. + */ +const STABLE_PREFIX_FILES: ReadonlySet = new Set(["file-tree.jsonl", "skeleton.jsonl"]); + /** * Assemble the on-disk pack directory into a single context string. Reads the * consumer-facing `readme.md` plus the BOM body files (`*.jsonl`, `*.md`) in * sorted order, so the injected context is deterministic. The manifest + * context-bom.json are provenance records, not agent-facing content, so they * are skipped. + * + * Move 4 — channel-aware cache-prefix enforcement: when `cacheChannel` needs + * explicit cache markers (classic Bedrock / Vertex, which do NOT cache + * automatically), a single cache-breakpoint sentinel is inserted at the + * deterministic prefix boundary (after the stable skeleton/file-tree prefix, + * before the volatile tail). Automatic channels (`anthropic`, + * `claude-on-aws`, `foundry`) and the `auto` default emit NO marker, so the + * default path is byte-identical to the pre-Move-4 output. Same inputs + same + * channel → identical bytes. */ -export async function assemblePackContext(packOutDir: string): Promise { +export async function assemblePackContext( + packOutDir: string, + cacheChannel: CacheChannel = DEFAULT_CACHE_CHANNEL, +): Promise { const entries = await readdir(packOutDir, { withFileTypes: true }); const files = entries .filter((e) => e.isFile()) @@ -78,10 +130,29 @@ export async function assemblePackContext(packOutDir: string): Promise { .filter((n) => n !== "manifest.json" && n !== "context-bom.json") .sort(); + const insertMarker = cacheChannelNeedsMarkers(cacheChannel); + const sentinel = insertMarker ? cacheBreakpointSentinel(cacheChannel) : ""; + const parts: string[] = []; + let markerInserted = false; for (const name of files) { const body = await readFile(join(packOutDir, name), "utf8"); parts.push(`### ${name}\n\n${body}`); + // Insert the cache-breakpoint sentinel once, immediately after the last + // stable-prefix file that is present. The next non-prefix file starts the + // volatile tail, so this is the deterministic cache boundary. + if (insertMarker && !markerInserted && STABLE_PREFIX_FILES.has(name)) { + const next = files[files.indexOf(name) + 1]; + if (next === undefined || !STABLE_PREFIX_FILES.has(next)) { + parts.push(sentinel); + markerInserted = true; + } + } + } + // Edge case: markers needed but no stable-prefix file present. Emit the + // sentinel at the very front so the boundary still exists deterministically. + if (insertMarker && !markerInserted) { + parts.unshift(sentinel); } return parts.join("\n\n"); } @@ -93,10 +164,26 @@ export async function assemblePackContext(packOutDir: string): Promise { export async function runVarianceProbe(args: VarianceProbeArgs): Promise { const task = await loadTask(args.taskFile); + // Resolve the tokenizer-provenance lane the with-pack arm packs under. When + // the flag is absent, packing stays on DEFAULT_TOKENIZER_ID (unchanged + // behavior); a caller opts into e.g. Sonnet 5's heavier tokenizer by passing + // SONNET5_TOKENIZER_ID. The resolved lane is recorded on the report so + // Finding 0001 v2 attributes results to a tokenizer. + const packTokenizerId = args.packTokenizer ?? DEFAULT_TOKENIZER_ID; + + // The cache channel (Move 4) threads into the default assembler so the + // with-pack context carries a cache-breakpoint marker on opt-in channels; + // the tokenizer lane (Move 1) controls the pack's chunk sizing. + const cacheChannel = args.cacheChannel ?? DEFAULT_CACHE_CHANNEL; + // 1. Generate the OCH pack for the task's repo (requires it to be analyzed). // Then assemble its artifacts into the context the with-pack arm injects. - const assemble = args._assemblePackContext ?? defaultAssemble; - const packContext = await assemble(task.repo); + // The test seam receives the resolved tokenizer lane; the production + // assembler also binds the cache channel. + const assemble = + args._assemblePackContext ?? + ((repo: string, tokenizer: string) => defaultAssemble(repo, tokenizer, cacheChannel)); + const packContext = await assemble(task.repo, packTokenizerId); // 2. The runner factory: a Bedrock-wired direct-CLI runner per harness // (spec 010 §4a). Tests inject a fake via `_runnerFor`. @@ -113,6 +200,7 @@ export async function runVarianceProbe(args: VarianceProbeArgs): Promise { - const result = await runCodePack({ repo, engine: "pack" }); - return assemblePackContext(result.outDir); +async function defaultAssemble( + repo: string, + tokenizer: string, + cacheChannel: CacheChannel = DEFAULT_CACHE_CHANNEL, +): Promise { + const result = await runCodePack({ repo, engine: "pack", tokenizer, cacheChannel }); + return assemblePackContext(result.outDir, cacheChannel); } /** diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index dab89fe..fe68156 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -12,6 +12,12 @@ import { readFileSync } from "node:fs"; import { cpus } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +// Type-only import — erased at compile time, so it does not pull the +// `@opencodehub/pack` barrel into the CLI's `--help` startup path (the whole +// file loads subcommands lazily to keep startup cheap). The runtime channel +// list is duplicated in `parseCacheChannelFlag` below and kept in sync with +// `CACHE_CHANNELS` in `@opencodehub/pack/cache.ts`. +import type { CacheChannel } from "@opencodehub/pack"; // Silence the one-shot node:sqlite ExperimentalWarning before any subcommand // lazily loads the storage layer. This module is dependency-free (no native // binding), so importing it eagerly does not regress `--help` startup cost. @@ -39,6 +45,41 @@ if (process.env["OCH_NATIVE_PARSER"] !== undefined) { delete process.env["OCH_NATIVE_PARSER"]; } +/** + * Valid `--cache-channel` values (Move 4). Kept in sync with `CACHE_CHANNELS` + * in `@opencodehub/pack/cache.ts` — duplicated here as bare strings so the + * commander layer can validate the flag without eagerly importing the pack + * barrel (which would regress `--help` startup cost). The `code-pack` action + * forwards the narrowed value; `runCodePack`/`runVarianceProbe` re-default it. + */ +const CACHE_CHANNEL_VALUES = [ + "bedrock", + "vertex", + "anthropic", + "claude-on-aws", + "foundry", + "auto", +] as const; + +/** + * Validate the `--cache-channel` flag value once, before either the pack or + * variance-probe path runs. Commander applies the `"auto"` default, so a + * non-string here means the option was cleared — fall back to `auto`. An + * unrecognized channel exits with a clear error instead of silently + * mis-routing cache-marker emission. + */ +function parseCacheChannelFlag(value: unknown): CacheChannel { + if (typeof value !== "string" || value.length === 0) return "auto"; + if ((CACHE_CHANNEL_VALUES as readonly string[]).includes(value)) { + return value as CacheChannel; + } + process.stderr.write( + `[codehub] --cache-channel: unknown channel '${value}'. ` + + `Valid: ${CACHE_CHANNEL_VALUES.join(", ")}.\n`, + ); + process.exit(2); +} + const program = new Command() .name("codehub") .version(pkgVersion) @@ -370,11 +411,26 @@ program "Engine: pack (default — 9-item BOM via @opencodehub/pack) or repomix (legacy single-file)", "pack", ) + .option( + "--cache-channel ", + "Channel-aware cache-prefix enforcement (Move 4): bedrock | vertex | anthropic | " + + "claude-on-aws | foundry | auto (default). Emits cache-breakpoint markers + a " + + "deterministic prefix boundary only on the opt-in channels (bedrock, vertex); the " + + "automatic channels and the auto default emit no markers (byte-identical output).", + "auto", + ) .option( "--explain-context", "After packing, print a summary of the context read-receipt (files indexed, lines, " + "hash coverage, per-language breakdown) read from the pack's context-bom.json", ) + .option( + "--prove", + "After packing, emit an in-toto context attestation (attestation.intoto.json) whose " + + "subject is the pack's packHash and whose predicate records the context provenance " + + "(what was packed). Composable beneath the SLSA build provenance CI attests; unsigned " + + "(signing stays a CI concern). Pack engine only.", + ) .option("--json", "With --explain-context or --variance-probe, emit the result as JSON on stdout") .option( "--variance-probe ", @@ -403,7 +459,18 @@ program "--model-codex ", "With --variance-probe: Codex Bedrock model id (default openai.gpt-5.5)", ) + .option( + "--pack-tokenizer ", + "With --variance-probe: tokenizer-provenance lane the with-pack arm packs under " + + '":@" (default openai:o200k_base@tiktoken-0.8.0). Use ' + + "anthropic:claude-sonnet-5@2026-06-30 to author the pack for Sonnet 5's heavier tokenizer. " + + "Recorded in the variance report so results attribute to a lane (Finding 0001 v2).", + ) .action(async (path: string | undefined, opts: Record) => { + // Channel-aware cache-prefix enforcement (Move 4). Validated once here so + // an unknown channel errors clearly before either path runs. Commander + // camelCases the flag to opts["cacheChannel"]; the default is "auto". + const cacheChannel = parseCacheChannelFlag(opts["cacheChannel"]); // --variance-probe short-circuits the normal pack path: it loads a task, // generates the pack itself, and runs the with/without experiment. if (typeof opts["varianceProbe"] === "string") { @@ -424,6 +491,10 @@ program ...(harness !== undefined ? { harness } : {}), ...(typeof opts["awsRegion"] === "string" ? { awsRegion: opts["awsRegion"] } : {}), ...(Object.keys(models).length > 0 ? { models } : {}), + ...(typeof opts["packTokenizer"] === "string" + ? { packTokenizer: opts["packTokenizer"] } + : {}), + cacheChannel, }); probeMod.printVarianceReport(report, opts["json"] === true); return; @@ -445,6 +516,7 @@ program ...(typeof opts["tokenizer"] === "string" ? { tokenizer: opts["tokenizer"] } : {}), ...(typeof opts["outDir"] === "string" ? { outDir: opts["outDir"] } : {}), engine, + cacheChannel, }); if (result.engine === "pack") { console.warn( @@ -455,6 +527,10 @@ program const summary = await mod.explainContextBom(result.outDir); mod.printContextSummary(summary, opts["json"] === true); } + if (opts["prove"] === true && result.manifest !== null) { + const attestationPath = await mod.writeContextAttestation(result.outDir, result.manifest); + console.warn(`codehub code-pack: wrote context attestation to ${attestationPath}`); + } } else { console.warn( `codehub code-pack: wrote repomix snapshot to ${result.repomixOutputPath ?? result.outDir} ` + diff --git a/packages/eval/src/probe.test.ts b/packages/eval/src/probe.test.ts index 1f4d884..910361f 100644 --- a/packages/eval/src/probe.test.ts +++ b/packages/eval/src/probe.test.ts @@ -95,6 +95,27 @@ describe("runProbe (end-to-end with a fake runner)", () => { ); }); + it("records packTokenizerId on the report when the option is set, omits it otherwise", async () => { + const withLane = await runProbe(TASK, (h) => new FakeRunner(h), { + runs: 1, + packContext: "PACK", + harnesses: ["claude"], + packTokenizerId: "anthropic:claude-sonnet-5@2026-06-30", + }); + assert.equal(withLane.packTokenizerId, "anthropic:claude-sonnet-5@2026-06-30"); + + const withoutLane = await runProbe(TASK, (h) => new FakeRunner(h), { + runs: 1, + packContext: "PACK", + harnesses: ["claude"], + }); + assert.equal(withoutLane.packTokenizerId, undefined, "field is absent when unset (pure)"); + assert.ok( + !serializeReport(withoutLane).includes("packTokenizerId"), + "unset field never leaks into the canonical JSON", + ); + }); + it("emits a byte-identical report across two identical probe runs (R6)", async () => { const opts = { runs: 3, packContext: "PACK", harnesses: ["codex"] as Harness[] }; const a = await runProbe(TASK, (h) => new FakeRunner(h), opts); diff --git a/packages/eval/src/probe.ts b/packages/eval/src/probe.ts index 27542a9..907cf3b 100644 --- a/packages/eval/src/probe.ts +++ b/packages/eval/src/probe.ts @@ -41,6 +41,13 @@ export interface ProbeOptions { * `@opencodehub/pack` (keeps the package graph acyclic). */ readonly packContext: string; + /** + * Tokenizer-provenance lane the with-pack `packContext` was authored under + * (":@"). Recorded verbatim on the {@link VarianceReport} + * so Finding 0001 v2 attributes results to a tokenizer. Pure provenance — the + * probe never encodes with it, so it cannot change the measured numbers. + */ + readonly packTokenizerId?: string; /** Required only when the task's oracle is `judge`. */ readonly score?: ScoreOptions; /** @@ -151,5 +158,10 @@ export async function runProbe( for (const harness of harnesses) { reports.push(await probeHarness(runnerFor(harness), task, harness, options)); } - return { schema: 1, taskId: task.id, harnesses: reports }; + return { + schema: 1, + taskId: task.id, + ...(options.packTokenizerId !== undefined ? { packTokenizerId: options.packTokenizerId } : {}), + harnesses: reports, + }; } diff --git a/packages/eval/src/report.test.ts b/packages/eval/src/report.test.ts index 466021a..41610a1 100644 --- a/packages/eval/src/report.test.ts +++ b/packages/eval/src/report.test.ts @@ -113,6 +113,16 @@ describe("serializeReport (determinism, R6)", () => { assert.ok(!json.includes("Date")); assert.ok(!json.includes("timestamp")); }); + + it("carries packTokenizerId when present and stays byte-stable (Finding 0001 v2)", () => { + const withLane: VarianceReport = { + ...report, + packTokenizerId: "anthropic:claude-sonnet-5@2026-06-30", + }; + const json = serializeReport(withLane); + assert.ok(json.includes('"packTokenizerId":"anthropic:claude-sonnet-5@2026-06-30"')); + assert.equal(serializeReport(withLane), serializeReport(withLane), "still pure"); + }); }); describe("formatReport", () => { diff --git a/packages/eval/src/report.ts b/packages/eval/src/report.ts index 2e9b6a1..3a84068 100644 --- a/packages/eval/src/report.ts +++ b/packages/eval/src/report.ts @@ -69,6 +69,15 @@ export interface VarianceReport { readonly schema: 1; /** The task id this report measures. */ readonly taskId: string; + /** + * Tokenizer-provenance lane the with-pack arm packed under + * (":@"). Optional so pre-existing captured reports stay + * valid; when present it lets Finding 0001 v2 attribute a result to a + * tokenizer (e.g. the Sonnet-5 lane whose heavier tokenizer inflates the + * same bytes ~30-35% vs prior Claude tokenizers). Pure provenance — recording + * it never changes the measured dispersion or token overhead. + */ + readonly packTokenizerId?: string; /** One entry per harness the probe ran. */ readonly harnesses: readonly HarnessReport[]; } diff --git a/packages/pack/src/attestation.test.ts b/packages/pack/src/attestation.test.ts new file mode 100644 index 0000000..e4c61f0 --- /dev/null +++ b/packages/pack/src/attestation.test.ts @@ -0,0 +1,142 @@ +/** + * Tests for the in-toto context attestation builder. + * + * Covers: + * A. Exact in-toto Statement v1 envelope: `_type` literal, single subject + * with `{ sha256: }` digest, minted predicateType. + * B. Predicate carries the expected context-provenance fields from the + * manifest. + * C. bomItems are sorted by path ASC and project {path, kind, fileHash}. + * D. Determinism: two builds from the same manifest serialize byte-identically + * (no clock / UUID / run-id). + * E. serializeAttestation round-trips to the same Statement shape. + */ + +import { strict as assert } from "node:assert"; +import { test } from "node:test"; +import { + buildContextAttestation, + CONTEXT_ATTESTATION_PREDICATE_TYPE, + CONTEXT_ATTESTATION_SUBJECT_NAME, + IN_TOTO_STATEMENT_TYPE, + serializeAttestation, +} from "./attestation.js"; +import type { PackManifest } from "./types.js"; + +function fixtureManifest(overrides: Partial = {}): PackManifest { + return { + commit: "0".repeat(40), + repoOriginUrl: "https://github.com/example/repo", + tokenizerId: "openai:o200k_base@tiktoken-0.8.0", + determinismClass: "strict", + budgetTokens: 100_000, + pins: { chonkieVersion: "0.3.0", grammarCommits: { python: "a".repeat(40) } }, + // Deliberately out of path order so the sort is observable. + files: [ + { kind: "xrefs", path: "xrefs.jsonl", fileHash: "e".repeat(64) }, + { kind: "skeleton", path: "skeleton.jsonl", fileHash: "c".repeat(64) }, + { kind: "context-bom", path: "context-bom.json", fileHash: "2".repeat(64) }, + ], + contextBomHash: "3".repeat(64), + packHash: "deadbeef".repeat(8), + schemaVersion: 2, + ...overrides, + }; +} + +test("A. Statement has the exact in-toto v1 `_type` literal", () => { + const stmt = buildContextAttestation(fixtureManifest()); + assert.equal(stmt._type, "https://in-toto.io/Statement/v1"); + assert.equal(stmt._type, IN_TOTO_STATEMENT_TYPE); +}); + +test("A. subject is a single entry with digest { sha256: }", () => { + const m = fixtureManifest(); + const stmt = buildContextAttestation(m); + assert.equal(stmt.subject.length, 1); + const subj = stmt.subject[0]; + assert.ok(subj !== undefined); + assert.equal(subj.name, CONTEXT_ATTESTATION_SUBJECT_NAME); + assert.deepEqual(subj.digest, { sha256: m.packHash }); + // The digest map has exactly one algorithm key. + assert.deepEqual(Object.keys(subj.digest), ["sha256"]); +}); + +test("A. predicateType is the minted opencodehub.dev URI at v0.1", () => { + const stmt = buildContextAttestation(fixtureManifest()); + assert.equal(stmt.predicateType, "https://opencodehub.dev/attestation/context/v0.1"); + assert.equal(stmt.predicateType, CONTEXT_ATTESTATION_PREDICATE_TYPE); +}); + +test("B. predicate carries the context-provenance fields from the manifest", () => { + const m = fixtureManifest(); + const { predicate } = buildContextAttestation(m); + assert.equal(predicate.packHash, m.packHash); + assert.equal(predicate.contextBomHash, m.contextBomHash); + assert.equal(predicate.commit, m.commit); + assert.equal(predicate.repoOriginUrl, m.repoOriginUrl); + assert.equal(predicate.tokenizerId, m.tokenizerId); + assert.equal(predicate.budgetTokens, m.budgetTokens); + assert.equal(predicate.determinismClass, m.determinismClass); +}); + +test("B. repoOriginUrl null is preserved in the predicate", () => { + const { predicate } = buildContextAttestation(fixtureManifest({ repoOriginUrl: null })); + assert.equal(predicate.repoOriginUrl, null); +}); + +test("C. bomItems are sorted by path ASC and project {path, kind, fileHash}", () => { + const { predicate } = buildContextAttestation(fixtureManifest()); + const paths = predicate.bomItems.map((i) => i.path); + assert.deepEqual(paths, ["context-bom.json", "skeleton.jsonl", "xrefs.jsonl"]); + // Every item projects exactly the three fields, keyed to the source manifest. + const skeleton = predicate.bomItems.find((i) => i.path === "skeleton.jsonl"); + assert.ok(skeleton !== undefined); + assert.deepEqual(skeleton, { + path: "skeleton.jsonl", + kind: "skeleton", + fileHash: "c".repeat(64), + }); +}); + +test("D. two builds from the same manifest serialize byte-identically", () => { + const m = fixtureManifest(); + const s1 = serializeAttestation(buildContextAttestation(m)); + const s2 = serializeAttestation(buildContextAttestation(m)); + assert.equal(s1, s2); +}); + +test("D. serialized attestation carries no clock / uuid / run-id fields", () => { + const s = serializeAttestation(buildContextAttestation(fixtureManifest())); + for (const forbidden of ["timestamp", "serialNumber", "runId", "run_id", "uuid", "createdAt"]) { + assert.ok(!s.includes(forbidden), `attestation leaked a non-deterministic field: ${forbidden}`); + } +}); + +test("D. a different packHash flips the serialized attestation", () => { + const base = serializeAttestation(buildContextAttestation(fixtureManifest())); + const alt = serializeAttestation( + buildContextAttestation(fixtureManifest({ packHash: "cafebabe".repeat(8) })), + ); + assert.notEqual(base, alt); +}); + +test("E. serializeAttestation round-trips to the same Statement shape", () => { + const stmt = buildContextAttestation(fixtureManifest()); + const parsed = JSON.parse(serializeAttestation(stmt)); + assert.equal(parsed._type, IN_TOTO_STATEMENT_TYPE); + assert.equal(parsed.predicateType, CONTEXT_ATTESTATION_PREDICATE_TYPE); + assert.equal(parsed.subject[0].digest.sha256, stmt.subject[0]?.digest.sha256); + assert.equal(parsed.predicate.packHash, stmt.predicate.packHash); + assert.deepEqual( + parsed.predicate.bomItems.map((i: { path: string }) => i.path), + stmt.predicate.bomItems.map((i) => i.path), + ); +}); + +test("empty files array still produces a valid Statement with empty bomItems", () => { + const stmt = buildContextAttestation(fixtureManifest({ files: [] })); + assert.equal(stmt.predicate.bomItems.length, 0); + assert.equal(stmt._type, IN_TOTO_STATEMENT_TYPE); + assert.deepEqual(stmt.subject[0]?.digest, { sha256: "deadbeef".repeat(8) }); +}); diff --git a/packages/pack/src/attestation.ts b/packages/pack/src/attestation.ts new file mode 100644 index 0000000..29b75d0 --- /dev/null +++ b/packages/pack/src/attestation.ts @@ -0,0 +1,160 @@ +/** + * Context attestation — an in-toto Statement v1 whose subject is the pack's + * `packHash` and whose predicate records the context provenance (what the + * agent read). It chains BENEATH the SLSA build provenance CI already emits + * for the built npm tarball: this in-tool attestation attests the pack's own + * `packHash` / `contextBomHash`, complementary and composable with the + * `https://slsa.dev/provenance/v1` attestation on a related digest. + * + * in-toto Statement v1 envelope (verified against in-toto.io this session): + * + * { + * "_type": "https://in-toto.io/Statement/v1", + * "subject": [ { "name": "", "digest": { "sha256": "" } } ], + * "predicateType": "", + * "predicate": { ... } + * } + * + * `_type` is ALWAYS the literal `https://in-toto.io/Statement/v1` for this + * spec version. `subject[].digest` is an algorithm→hex map; `packHash` is a + * sha256 hex string, so the shape is `{ "sha256": manifest.packHash }`. + * + * The `predicateType` is a bespoke URI we mint under the repo's canonical + * `opencodehub.dev` domain (the same domain the docmeta / tool-catalog JSON + * schemas use), versioned v0.1. The predicate body carries the machine- + * checkable record of exactly what was packed: the manifest provenance fields + * plus the BOM item list (path + kind + fileHash per item), sorted by path. + * + * Determinism contract: + * - No wall-clock timestamp, no run-id, no random UUID. The Statement is a + * pure function of the manifest, so two builds over the same pack produce + * a byte-identical serialized attestation (on-thesis for OCH: the + * attestation is itself re-derivable). + * - `bomItems` are sorted by path ASC (paths are unique within a manifest, + * so no tiebreak is needed). + * - Serialization goes through the shared RFC 8785 `canonicalJson` helper, + * so byte-identity holds across runs. + * + * This module emits the UNSIGNED Statement. Signing (cosign keyless / the + * DSSE envelope) stays a CI concern layered on top of these bytes. + */ + +import { canonicalJson } from "@opencodehub/core-types"; +import type { PackManifest } from "./types.js"; + +/** + * The literal `_type` for an in-toto Statement v1. Never varies for this spec + * version. + */ +export const IN_TOTO_STATEMENT_TYPE = "https://in-toto.io/Statement/v1" as const; + +/** + * The bespoke predicateType URI we mint for the context attestation. Under the + * repo's canonical `opencodehub.dev` domain (matching the docmeta / tool-catalog + * schema URLs), versioned v0.1. + */ +export const CONTEXT_ATTESTATION_PREDICATE_TYPE = + "https://opencodehub.dev/attestation/context/v0.1" as const; + +/** + * The stable logical subject name for the pack. A verifier keys attestations + * to the subject digest (the packHash); the name is a human-readable label. + */ +export const CONTEXT_ATTESTATION_SUBJECT_NAME = "pack" as const; + +/** A digest set: algorithm id → lowercase hex string. */ +export interface DigestSet { + readonly sha256: string; +} + +/** One in-toto subject: a named artifact plus its digest map. */ +export interface InTotoSubject { + readonly name: string; + readonly digest: DigestSet; +} + +/** + * One BOM item as recorded in the context predicate — the machine-checkable + * record of a single file that was packed/read. + */ +export interface AttestationBomItem { + readonly path: string; + readonly kind: string; + readonly fileHash: string; +} + +/** + * The context-attestation predicate body. Arbitrary type-specific metadata per + * the in-toto spec; here it is the context provenance carried by the manifest, + * kept pure and deterministic (no clock / run-id / UUID). + */ +export interface ContextAttestationPredicate { + readonly packHash: string; + readonly contextBomHash: string; + readonly commit: string; + readonly repoOriginUrl: string | null; + readonly tokenizerId: string; + readonly budgetTokens: number; + readonly determinismClass: PackManifest["determinismClass"]; + /** Every BOM item that was packed, sorted by path ASC. */ + readonly bomItems: readonly AttestationBomItem[]; +} + +/** + * A complete in-toto Statement v1 carrying a context-attestation predicate. + * `_type` and `predicateType` are pinned to the minted constants. + */ +export interface InTotoStatement { + readonly _type: typeof IN_TOTO_STATEMENT_TYPE; + readonly subject: readonly InTotoSubject[]; + readonly predicateType: typeof CONTEXT_ATTESTATION_PREDICATE_TYPE; + readonly predicate: ContextAttestationPredicate; +} + +/** + * Build the in-toto context-attestation Statement for a finished pack. + * + * The SUBJECT is the pack's `packHash` (`{ sha256: }`); the PREDICATE + * records the context provenance — the manifest fields plus the BOM item list. + * Pure and deterministic: given the same manifest, this returns a value whose + * canonical serialization is byte-identical across runs (no timestamp / UUID / + * run-id). Composable beneath the SLSA build provenance: same Statement + * envelope shape, distinct `predicateType`, subject keyed to the packHash. + */ +export function buildContextAttestation(manifest: PackManifest): InTotoStatement { + const bomItems: AttestationBomItem[] = manifest.files + .map((f) => ({ path: f.path, kind: f.kind, fileHash: f.fileHash })) + // Sort by path ASC. Paths are unique within a manifest, so no tiebreak. + .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + + return { + _type: IN_TOTO_STATEMENT_TYPE, + subject: [ + { + name: CONTEXT_ATTESTATION_SUBJECT_NAME, + digest: { sha256: manifest.packHash }, + }, + ], + predicateType: CONTEXT_ATTESTATION_PREDICATE_TYPE, + predicate: { + packHash: manifest.packHash, + contextBomHash: manifest.contextBomHash, + commit: manifest.commit, + repoOriginUrl: manifest.repoOriginUrl, + tokenizerId: manifest.tokenizerId, + budgetTokens: manifest.budgetTokens, + determinismClass: manifest.determinismClass, + bomItems, + }, + }; +} + +/** + * Serialize an {@link InTotoStatement} to canonical JSON. Byte-identical + * across runs for the same Statement (RFC 8785 sorted keys, minimal number + * format), so the attestation is itself re-derivable. This is what gets + * written to `attestation.intoto.json`. + */ +export function serializeAttestation(stmt: InTotoStatement): string { + return canonicalJson(stmt); +} diff --git a/packages/pack/src/cache.test.ts b/packages/pack/src/cache.test.ts new file mode 100644 index 0000000..bff0649 --- /dev/null +++ b/packages/pack/src/cache.test.ts @@ -0,0 +1,94 @@ +/** + * Tests for channel-aware cache-prefix enforcement (Move 4). + * + * Covers: + * A. Channel → needs-markers mapping (opt-in vs automatic). + * B. buildCachePoint marker shapes per channel. + * C. parseCacheChannel narrowing + rejection of unknown values. + * D. cacheBreakpointSentinel determinism + empty for automatic channels. + */ + +import { strict as assert } from "node:assert"; +import { test } from "node:test"; +import { + buildCachePoint, + CACHE_CHANNELS, + type CacheChannel, + cacheBreakpointSentinel, + cacheChannelNeedsMarkers, + DEFAULT_CACHE_CHANNEL, + parseCacheChannel, +} from "./cache.js"; + +const OPT_IN: readonly CacheChannel[] = ["bedrock", "vertex"]; +const AUTOMATIC: readonly CacheChannel[] = ["anthropic", "claude-on-aws", "foundry", "auto"]; + +test("A. opt-in channels need markers; automatic channels do not", () => { + for (const c of OPT_IN) { + assert.equal(cacheChannelNeedsMarkers(c), true, `${c} should need markers`); + } + for (const c of AUTOMATIC) { + assert.equal(cacheChannelNeedsMarkers(c), false, `${c} should not need markers`); + } +}); + +test("A. the default channel is auto and is marker-free", () => { + assert.equal(DEFAULT_CACHE_CHANNEL, "auto"); + assert.equal(cacheChannelNeedsMarkers(DEFAULT_CACHE_CHANNEL), false); +}); + +test("A. every enumerated channel has a defined needs-markers verdict", () => { + for (const c of CACHE_CHANNELS) { + assert.equal(typeof cacheChannelNeedsMarkers(c), "boolean"); + } +}); + +test("B. bedrock builds the Bedrock cachePoint block", () => { + assert.deepEqual(buildCachePoint("bedrock"), { cachePoint: { type: "default" } }); +}); + +test("B. vertex builds the cache_control ephemeral block", () => { + assert.deepEqual(buildCachePoint("vertex"), { cache_control: { type: "ephemeral" } }); +}); + +test("B. automatic channels build no marker (null)", () => { + for (const c of AUTOMATIC) { + assert.equal(buildCachePoint(c), null, `${c} should build no marker`); + } +}); + +test("C. parseCacheChannel narrows every valid value", () => { + for (const c of CACHE_CHANNELS) { + assert.equal(parseCacheChannel(c), c); + } +}); + +test("C. parseCacheChannel rejects unknown values", () => { + assert.equal(parseCacheChannel("openai"), undefined); + assert.equal(parseCacheChannel(""), undefined); + assert.equal(parseCacheChannel("BEDROCK"), undefined); +}); + +test("D. sentinel is empty for automatic channels, non-empty for opt-in", () => { + for (const c of AUTOMATIC) { + assert.equal(cacheBreakpointSentinel(c), "", `${c} should have empty sentinel`); + } + for (const c of OPT_IN) { + assert.ok(cacheBreakpointSentinel(c).length > 0, `${c} should have a sentinel`); + assert.ok( + cacheBreakpointSentinel(c).includes("opencodehub:cachePoint"), + `${c} sentinel should be self-describing`, + ); + } +}); + +test("D. sentinel is byte-stable per channel (deterministic)", () => { + for (const c of CACHE_CHANNELS) { + assert.equal(cacheBreakpointSentinel(c), cacheBreakpointSentinel(c)); + } + // The bedrock sentinel embeds the exact marker JSON with fixed key order. + assert.equal( + cacheBreakpointSentinel("bedrock"), + '', + ); +}); diff --git a/packages/pack/src/cache.ts b/packages/pack/src/cache.ts new file mode 100644 index 0000000..2838449 --- /dev/null +++ b/packages/pack/src/cache.ts @@ -0,0 +1,138 @@ +/** + * @opencodehub/pack — channel-aware cache-prefix enforcement (Move 4). + * + * Prompt caching is now the DEFAULT and is automatic (free, zero markers) on + * several channels, but remains OPT-IN (explicit cache markers) on others. + * A pack that emits cache-breakpoint markers + enforces a deterministic prefix + * boundary is only useful on the opt-in channels; on the automatic channels the + * ceremony is pure noise. `CacheChannel` names the delivery surface so the pack + * spends that effort exactly where it pays off. + * + * Caching state as verified this session (see the platform-availability matrix + * in the claude-api reference — "Automatic prompt caching" row): + * + * channel automatic caching marker the surface needs emit markers? + * --------------- ------------------ ------------------------ ------------- + * anthropic yes (default, free) — (n/a) no + * claude-on-aws yes (default, free) — (n/a) no + * foundry yes (default, free) — (n/a) no + * bedrock NO (opt-in) { cachePoint: {...} } yes + * vertex NO (opt-in) { cache_control: {...} } yes + * auto (DEFAULT) assume automatic — (n/a) no + * + * `auto` is the conservative default: it assumes automatic caching and emits NO + * markers, so the default path is byte-identical to the pre-Move-4 behavior and + * every existing determinism/golden fixture stays green. A caller who knows + * their pack will be consumed on classic Bedrock or Vertex passes that channel + * explicitly to turn markers on. + * + * Everything here is pure and fully unit-testable — no I/O, no process state. + */ + +/** + * The delivery surface a pack's agent-facing context is consumed on. Controls + * whether the pack emits opt-in cache-breakpoint markers and enforces a + * deterministic prefix boundary. + */ +export type CacheChannel = + | "bedrock" // classic AWS Bedrock (Converse / InvokeModel) — opt-in via cachePoint + | "vertex" // Google Vertex AI — opt-in via cache_control + | "anthropic" // Anthropic first-party API — automatic caching + | "claude-on-aws" // "Claude on AWS" — automatic caching + | "foundry" // Microsoft Foundry — automatic caching + | "auto"; // DEFAULT — assume automatic, emit no markers + +/** Every valid {@link CacheChannel} value, for validation + enumeration. */ +export const CACHE_CHANNELS: readonly CacheChannel[] = [ + "bedrock", + "vertex", + "anthropic", + "claude-on-aws", + "foundry", + "auto", +]; + +/** The default channel when `--cache-channel` is absent. */ +export const DEFAULT_CACHE_CHANNEL: CacheChannel = "auto"; + +/** + * Narrow an arbitrary string to a {@link CacheChannel}. Returns `undefined` + * for an unknown value so callers can error clearly instead of silently + * mis-routing. + */ +export function parseCacheChannel(value: string): CacheChannel | undefined { + return (CACHE_CHANNELS as readonly string[]).includes(value) + ? (value as CacheChannel) + : undefined; +} + +/** + * Whether a channel still needs the pack to emit explicit cache markers + + * enforce a deterministic prefix boundary. `true` only for the opt-in + * channels (`bedrock`, `vertex`); every automatic channel — and the + * conservative `auto` default — returns `false`. + */ +export function cacheChannelNeedsMarkers(channel: CacheChannel): boolean { + switch (channel) { + case "bedrock": + case "vertex": + return true; + case "anthropic": + case "claude-on-aws": + case "foundry": + case "auto": + return false; + } +} + +/** Bedrock's cache-breakpoint marker shape (Converse / InvokeModel). */ +export interface BedrockCachePoint { + readonly cachePoint: { readonly type: "default" }; +} + +/** The Anthropic-family opt-in cache marker shape (attached to a content block). */ +export interface AnthropicCacheControl { + readonly cache_control: { readonly type: "ephemeral" }; +} + +/** The union of concrete cache-marker shapes a channel may require. */ +export type CachePoint = BedrockCachePoint | AnthropicCacheControl; + +/** + * Build the cache-breakpoint marker a channel needs, or `null` when the channel + * caches automatically and needs no marker. + * + * - `bedrock` → `{ cachePoint: { type: "default" } }` (the Bedrock opt-in block) + * - `vertex` → `{ cache_control: { type: "ephemeral" } }` (Vertex opt-in shape) + * - `anthropic` / `claude-on-aws` / `foundry` / `auto` → `null` (automatic) + * + * Pure: same channel in → identical marker object out. + */ +export function buildCachePoint(channel: CacheChannel): CachePoint | null { + switch (channel) { + case "bedrock": + return { cachePoint: { type: "default" } }; + case "vertex": + return { cache_control: { type: "ephemeral" } }; + case "anthropic": + case "claude-on-aws": + case "foundry": + case "auto": + return null; + } +} + +/** + * The textual cache-breakpoint sentinel inserted at the deterministic prefix + * boundary in the agent-facing assembled context, when a channel needs markers. + * A stable, self-describing delimiter line so the boundary is byte-deterministic + * and greppable. The channel-specific marker shape is embedded so a downstream + * consumer knows which opt-in block to materialize at this point. + */ +export function cacheBreakpointSentinel(channel: CacheChannel): string { + const point = buildCachePoint(channel); + if (point === null) return ""; + // Compact, deterministic single-line JSON of the marker (key order is fixed + // by the object literal above, so this is byte-stable per channel). + return ``; +} diff --git a/packages/pack/src/context-bom.test.ts b/packages/pack/src/context-bom.test.ts index 4b40bbb..1e87d83 100644 --- a/packages/pack/src/context-bom.test.ts +++ b/packages/pack/src/context-bom.test.ts @@ -6,8 +6,11 @@ * B. Hash sensitivity — any file change flips contextBomHash. * C. Missing contentHash omits the `hashes` array (no fabricated hash). * D. Byte ranges — merged, sorted, non-overlapping; omitted when empty. - * E. CycloneDX 1.6 shape — bomFormat/specVersion/components well-formed. + * E. CycloneDX 1.7 shape — bomFormat/specVersion/components well-formed. * F. Order independence — input order does not affect output (sorted by path). + * G. Provenance citation — externalReferences[vcs] + opencodehub:commit + * present when (repoOriginUrl, commit) supplied, omitted when absent, + * and deterministic. */ import { strict as assert } from "node:assert"; @@ -103,10 +106,11 @@ test("D. no byte ranges → byteRanges property omitted", () => { assert.equal(prop, undefined); }); -test("E. document is a well-formed CycloneDX 1.6 BOM", () => { +test("E. document is a well-formed CycloneDX 1.7 BOM", () => { const r = buildContextBom({ files: FILES }); assert.equal(r.document.bomFormat, "CycloneDX"); - assert.equal(r.document.specVersion, "1.6"); + assert.equal(r.document.specVersion, "1.7"); + assert.equal(r.document.$schema, "http://cyclonedx.org/schema/bom-1.7.schema.json"); assert.equal(r.document.version, 1); for (const c of r.document.components) { assert.equal(c.type, "file"); @@ -139,6 +143,72 @@ test("F. input order does not affect output (components sorted by path)", () => ); }); +const ORIGIN = "https://github.com/org/repo"; +const COMMIT = "c".repeat(40); + +test("G. provenance citation appears on each component when commit+originUrl supplied", () => { + const r = buildContextBom({ files: FILES, commit: COMMIT, repoOriginUrl: ORIGIN }); + assert.ok(r.document.components.length > 0, "expected components"); + for (const c of r.document.components) { + assert.deepEqual(c.externalReferences, [{ type: "vcs", url: ORIGIN }]); + const commitProp = c.properties?.find((p) => p.name === "opencodehub:commit"); + assert.ok(commitProp !== undefined, "opencodehub:commit property should be present"); + assert.equal(commitProp?.value, COMMIT); + } +}); + +test("G. provenance citation is omitted when commit+originUrl are absent", () => { + const r = buildContextBom({ files: FILES }); + for (const c of r.document.components) { + assert.equal(c.externalReferences, undefined); + const commitProp = c.properties?.find((p) => p.name === "opencodehub:commit"); + assert.equal(commitProp, undefined); + } +}); + +test("G. null repoOriginUrl omits externalReferences; empty commit omits the property", () => { + const r = buildContextBom({ files: FILES, commit: "", repoOriginUrl: null }); + for (const c of r.document.components) { + assert.equal(c.externalReferences, undefined); + const commitProp = c.properties?.find((p) => p.name === "opencodehub:commit"); + assert.equal(commitProp, undefined); + } +}); + +test("G. commit-only (no origin) records the property but omits externalReferences", () => { + const r = buildContextBom({ files: FILES, commit: COMMIT }); + for (const c of r.document.components) { + assert.equal(c.externalReferences, undefined); + const commitProp = c.properties?.find((p) => p.name === "opencodehub:commit"); + assert.equal(commitProp?.value, COMMIT); + } +}); + +test("G. provenance is deterministic and does not disturb component sort order", () => { + const forward = buildContextBom({ + files: [FILE_A, FILE_B], + commit: COMMIT, + repoOriginUrl: ORIGIN, + }); + const reverse = buildContextBom({ + files: [FILE_B, FILE_A], + commit: COMMIT, + repoOriginUrl: ORIGIN, + }); + assert.equal(forward.canonical, reverse.canonical); + assert.equal(forward.contextBomHash, reverse.contextBomHash); + assert.deepEqual( + forward.document.components.map((c) => c.name), + ["src/a.ts", "src/b.ts"], + ); +}); + +test("G. adding a provenance citation flips contextBomHash (it is in the preimage)", () => { + const plain = buildContextBom({ files: FILES }); + const cited = buildContextBom({ files: FILES, commit: COMMIT, repoOriginUrl: ORIGIN }); + assert.notEqual(plain.contextBomHash, cited.contextBomHash); +}); + test("mergeSpans drops zero-length and inverted spans", () => { assert.deepEqual(mergeSpans([{ start: 5, end: 5 }]), []); assert.deepEqual(mergeSpans([{ start: 9, end: 3 }]), []); diff --git a/packages/pack/src/context-bom.ts b/packages/pack/src/context-bom.ts index 600465a..87773b6 100644 --- a/packages/pack/src/context-bom.ts +++ b/packages/pack/src/context-bom.ts @@ -1,13 +1,35 @@ /** - * BOM body item: the context read-receipt (item 9/9). + * BOM body item: the context read-receipt (item 8/8). * - * A CycloneDX 1.6 JSON document whose components are the source files the + * A CycloneDX 1.7 JSON document whose components are the source files the * pack indexed — one `file` component per `File` node in the graph. It * answers a question a `packHash` alone cannot: *which source bytes did the * agent's context come from?* Each component carries the file's SHA-256 * content hash, line count, language, and — when the AST chunker produced * range data — the merged byte ranges that were chunked out of it. * + * Per-file provenance citation (CycloneDX 1.7): when the pack knows the repo + * origin URL and the indexed commit, each file component is bound to its + * source of record so an AIBOM / EU CRA reviewer can re-derive exactly which + * (repoOriginUrl, commit, path) triple produced the receipt. The citation is + * carried two ways, both CDX-native and deterministic: + * - `component.externalReferences: [{ type: "vcs", url: }]` + * — the CycloneDX-native way to cite a version-control origin (the `vcs` + * externalReference type is stable across CDX 1.4–1.7). Emitted only when + * `repoOriginUrl` is present. + * - an `opencodehub:commit` property recording the commit SHA — a property + * rather than a URL because the commit is a bare SHA, not a resolvable + * link, and properties keep zero schema-shape risk. Emitted only when + * `commit` is non-empty. + * When either is absent the field is OMITTED (never emitted null), matching + * the existing "only when present" idiom so a repo with no origin/commit still + * produces a valid, deterministic receipt. `Repo.indexTime` is deliberately + * NOT cited — it is wall-clock and out of every hash input. + * + * CycloneDX 1.7 is fully backward compatible with 1.4–1.6: it only ADDS + * optional fields, so bumping `specVersion` + `$schema` and adding + * `externalReferences` cannot break a 1.6-shaped consumer. + * * Why File nodes and not chunks: the chunker's per-file byte ranges are only * present when `generatePack` is handed raw file bytes, which today happens * only in tests. File nodes are populated by `analyze` on every real pack, so @@ -18,6 +40,9 @@ * - Components are sorted by `name` (the repo-relative path) ASC; paths are * unique within a graph so no tiebreak is needed. * - Byte ranges per file are merged into sorted, non-overlapping spans. + * - The provenance citation is a pure function of the (repoOriginUrl, + * commit) pair, identical across every component in a given pack, so it + * adds no run-to-run variance. * - The document is serialized through the shared RFC 8785 `canonicalJson` * helper, so two runs over the same graph produce byte-identical output * and therefore the same `contextBomHash`. @@ -55,34 +80,57 @@ export interface ContextBomOpts { * when present, merged spans are attached as a `byteRanges` property. */ readonly byteRangesByPath?: ReadonlyMap; + /** + * The indexed commit SHA. When non-empty, recorded on each file component + * as an `opencodehub:commit` property so every file is bound to the exact + * revision it was read from. Omitted when empty/absent. + */ + readonly commit?: string; + /** + * The repo origin URL (e.g. `https://github.com/org/repo`). When present, + * emitted on each file component as a CycloneDX `externalReferences` entry + * of type `vcs`, citing the version-control source of record. May be null + * (the manifest allows a null origin) — omitted in that case. + */ + readonly repoOriginUrl?: string | null; } -/** A CycloneDX 1.6 `property` — name + stringified value. */ +/** A CycloneDX 1.7 `property` — name + stringified value. */ interface CdxProperty { readonly name: string; readonly value: string; } -/** A CycloneDX 1.6 `hash` entry. */ +/** A CycloneDX 1.7 `hash` entry. */ interface CdxHash { readonly alg: "SHA-256"; readonly content: string; } -/** A CycloneDX 1.6 `file` component. */ +/** + * A CycloneDX `externalReference`. Only the `vcs` type is emitted here (to + * cite the repo origin); the `type` enum is stable across CDX 1.4–1.7. + */ +interface CdxExternalReference { + readonly type: "vcs"; + readonly url: string; +} + +/** A CycloneDX 1.7 `file` component. */ interface CdxComponent { readonly type: "file"; readonly "bom-ref": string; readonly name: string; readonly hashes?: readonly CdxHash[]; + readonly externalReferences?: readonly CdxExternalReference[]; readonly properties?: readonly CdxProperty[]; } -/** The CycloneDX 1.6 document. */ +/** The CycloneDX 1.7 document. */ export interface ContextBomDocument { readonly $schema: string; readonly bomFormat: "CycloneDX"; - readonly specVersion: "1.6"; + readonly specVersion: "1.7"; readonly version: 1; readonly components: readonly CdxComponent[]; } @@ -96,13 +144,14 @@ export interface ContextBomResult { readonly contextBomHash: string; } -const CDX_SCHEMA_URL = "http://cyclonedx.org/schema/bom-1.6.schema.json"; +const CDX_SCHEMA_URL = "http://cyclonedx.org/schema/bom-1.7.schema.json"; const PROP_BYTE_RANGES = "opencodehub:byteRanges"; const PROP_LINE_COUNT = "opencodehub:lineCount"; const PROP_LANGUAGE = "opencodehub:language"; +const PROP_COMMIT = "opencodehub:commit"; /** - * Build the context read-receipt as a CycloneDX 1.6 document plus its + * Build the context read-receipt as a CycloneDX 1.7 document plus its * canonical serialization and hash. * * An empty file set produces a valid document with `components: []` — a real @@ -111,9 +160,20 @@ const PROP_LANGUAGE = "opencodehub:language"; export function buildContextBom(opts: ContextBomOpts): ContextBomResult { const sorted = [...opts.files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + // Provenance citation shared by every component: cite the repo origin as a + // CDX-native `vcs` externalReference (only when a URL is present — the + // manifest allows a null origin) and record the commit as a property (only + // when non-empty). Both are per-pack constants, so they add no run-to-run + // variance to the canonical bytes. + const commit = opts.commit !== undefined && opts.commit.length > 0 ? opts.commit : undefined; + const externalReferences: readonly CdxExternalReference[] | undefined = + opts.repoOriginUrl !== undefined && opts.repoOriginUrl !== null + ? [{ type: "vcs", url: opts.repoOriginUrl }] + : undefined; + const components: CdxComponent[] = []; for (const file of sorted) { - const properties = buildProperties(file, opts.byteRangesByPath?.get(file.path)); + const properties = buildProperties(file, opts.byteRangesByPath?.get(file.path), commit); const component: CdxComponent = { type: "file", "bom-ref": file.path, @@ -121,6 +181,7 @@ export function buildContextBom(opts: ContextBomOpts): ContextBomResult { ...(file.contentHash !== undefined ? { hashes: [{ alg: "SHA-256", content: file.contentHash } as const] } : {}), + ...(externalReferences !== undefined ? { externalReferences } : {}), ...(properties.length > 0 ? { properties } : {}), }; components.push(component); @@ -129,7 +190,7 @@ export function buildContextBom(opts: ContextBomOpts): ContextBomResult { const document: ContextBomDocument = { $schema: CDX_SCHEMA_URL, bomFormat: "CycloneDX", - specVersion: "1.6", + specVersion: "1.7", version: 1, components, }; @@ -143,9 +204,20 @@ export function buildContextBom(opts: ContextBomOpts): ContextBomResult { * Assemble a component's properties in a fixed key order. CycloneDX requires * every `value` to be a string, so numbers and span arrays are stringified. * Properties are omitted (not emitted empty) when their source is absent. + * + * `commit` is the per-pack indexed commit SHA (already normalized to + * `undefined` when empty), recorded as `opencodehub:commit` so each file is + * bound to the exact revision it was read from. */ -function buildProperties(file: ContextFile, spans: readonly ByteSpan[] | undefined): CdxProperty[] { +function buildProperties( + file: ContextFile, + spans: readonly ByteSpan[] | undefined, + commit: string | undefined, +): CdxProperty[] { const props: CdxProperty[] = []; + if (commit !== undefined) { + props.push({ name: PROP_COMMIT, value: commit }); + } if (file.lineCount !== undefined) { props.push({ name: PROP_LINE_COUNT, value: String(file.lineCount) }); } diff --git a/packages/pack/src/index.test.ts b/packages/pack/src/index.test.ts index 3982107..ced6de5 100644 --- a/packages/pack/src/index.test.ts +++ b/packages/pack/src/index.test.ts @@ -248,6 +248,24 @@ test("E2E-B. Anthropic tokenizer downgrades determinism_class to best_effort", a } }); +test("E2E-B2. Sonnet-5 tokenizer lane downgrades determinism_class to best_effort", async () => { + const dir = await tempDir(); + try { + // Mirror of E2E-B for the CLI's SONNET5_TOKENIZER_ID + // ("anthropic:claude-sonnet-5@2026-06-30"). The literal is duplicated here + // rather than imported because @opencodehub/pack must not depend on the CLI + // (dep direction is cli → pack). What matters to the pack contract is that + // the anthropic: vendor prefix relaxes the class to best_effort. + const manifest = await runFixture(dir, { + tokenizerId: "anthropic:claude-sonnet-5@2026-06-30", + }); + assert.equal(manifest.determinismClass, "best_effort"); + assert.equal(manifest.tokenizerId, "anthropic:claude-sonnet-5@2026-06-30"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test("E2E-C. chunker degraded fallback flips determinism_class to degraded", async () => { const dir = await tempDir(); try { diff --git a/packages/pack/src/index.ts b/packages/pack/src/index.ts index cee8c67..51ba05c 100644 --- a/packages/pack/src/index.ts +++ b/packages/pack/src/index.ts @@ -35,6 +35,32 @@ import { buildXrefs } from "./xrefs.js"; export type { AstChunk, AstChunkerOpts, AstChunkerResult } from "./ast-chunker.js"; export { buildAstChunks } from "./ast-chunker.js"; +export type { + AttestationBomItem, + ContextAttestationPredicate, + DigestSet, + InTotoStatement, + InTotoSubject, +} from "./attestation.js"; +export { + buildContextAttestation, + CONTEXT_ATTESTATION_PREDICATE_TYPE, + CONTEXT_ATTESTATION_SUBJECT_NAME, + IN_TOTO_STATEMENT_TYPE, + serializeAttestation, +} from "./attestation.js"; +export { + type AnthropicCacheControl, + type BedrockCachePoint, + buildCachePoint, + CACHE_CHANNELS, + type CacheChannel, + type CachePoint, + cacheBreakpointSentinel, + cacheChannelNeedsMarkers, + DEFAULT_CACHE_CHANNEL, + parseCacheChannel, +} from "./cache.js"; export type { ByteSpan, ContextBomDocument, @@ -166,7 +192,12 @@ export async function generatePack( // tests, where chunkerFiles is supplied). --- const contextFiles = await collectContextFiles(graph); const byteRangesByPath = collectByteRanges(astResult.chunks); - const contextBom = buildContextBom({ files: contextFiles, byteRangesByPath }); + const contextBom = buildContextBom({ + files: contextFiles, + byteRangesByPath, + commit, + repoOriginUrl, + }); const contextBomBytes = encodeUtf8(contextBom.canonical); // --- Serialize bodies. --- diff --git a/packages/pack/src/types.ts b/packages/pack/src/types.ts index 82f4e50..608a2ac 100644 --- a/packages/pack/src/types.ts +++ b/packages/pack/src/types.ts @@ -7,6 +7,8 @@ * in-place. */ +import type { CacheChannel } from "./cache.js"; + /** A single item in the 9-item BOM. */ export interface BomItem { readonly kind: @@ -60,4 +62,14 @@ export interface PackOpts { readonly budgetTokens: number; readonly tokenizerId: string; readonly engine?: "pack" | "repomix"; // repomix fallback retained through M6 per spec + /** + * Delivery channel that controls channel-aware cache-prefix enforcement + * (Move 4). See {@link CacheChannel} in `cache.ts`. Recorded here so the + * chosen channel threads with the other pack options, but deliberately kept + * OUT of the manifest/packHash preimage — it only shapes the agent-facing + * assembled context, so the default (`auto`) path stays byte-identical to + * pre-Move-4 packs and existing determinism fixtures are undisturbed. + * Defaults to `"auto"` (assume automatic caching, emit no markers). + */ + readonly cacheChannel?: CacheChannel; }