diff --git a/.github/scripts/check-workflow-policy-literal-ratchet.test.mjs b/.github/scripts/check-workflow-policy-literal-ratchet.test.mjs index f3436ab1c..28be97440 100644 --- a/.github/scripts/check-workflow-policy-literal-ratchet.test.mjs +++ b/.github/scripts/check-workflow-policy-literal-ratchet.test.mjs @@ -338,7 +338,7 @@ const EXPECTED_LITERAL_SITES = { validatePackagedProof: { digests: 2, permissionTuples: 2, stepNames: 106, workflowNames: 2 }, validatePluginAndDraftWorkflows: { stepNames: 32, workflowNames: 13 }, validatePluginRelease: { permissionTuples: 1, stepNames: 17, workflowNames: 6 }, - validatePostPublish: { stepNames: 38, workflowNames: 1 }, + validatePostPublish: { stepNames: 40, workflowNames: 1 }, validateReleaseArtifactRerunSafety: { workflowNames: 13 }, validateReleaseCellUploadOwnership: { workflowNames: 18 }, validateReleaseCoordinator: { stepNames: 24, workflowNames: 11 }, diff --git a/.github/scripts/check-workflow-policy.mjs b/.github/scripts/check-workflow-policy.mjs index 7e387f1d0..1fd8a8494 100644 --- a/.github/scripts/check-workflow-policy.mjs +++ b/.github/scripts/check-workflow-policy.mjs @@ -4937,6 +4937,33 @@ function validatePostPublish(workflows, violations, graph) { add(violations, closeoutInput.type === "string", `${file} ${event} pre_publish_closeout_artifact must be a string`); } const job = object(object(workflow.jobs).smoke); + const closeoutHarnessName = "Stage caller-bound closeout harness"; + const closeoutHarness = namedStep(job, closeoutHarnessName); + add( + violations, + closeoutHarness?.id === "closeout-harness" + && closeoutHarness?.shell === "bash" + && closeoutHarness?.if === undefined + && closeoutHarness?.["continue-on-error"] === undefined, + `${file} must stage the caller-bound closeout harness fail closed`, + ); + requireStepRun(violations, file, job, closeoutHarnessName, [ + `printf '%s' "$GITHUB_SHA" | grep -Eq '^[0-9a-f]{40}$'`, + 'git archive "$GITHUB_SHA"', + ".github/scripts/install-codestory-marketplace-proof.mjs", + ".github/scripts/marketplace-delivery-identity.mjs", + ".github/scripts/check-packaged-agent-proof.py", + ".github/scripts/packaged_agent_proof", + '| tar -x -C "$harness_root"', + 'echo "helper=$harness_root/.github/scripts/install-codestory-marketplace-proof.mjs" >> "$GITHUB_OUTPUT"', + 'echo "proof=$harness_root/.github/scripts/check-packaged-agent-proof.py" >> "$GITHUB_OUTPUT"', + ]); + add( + violations, + stepIndex(job, closeoutHarnessName) + < stepIndex(job, "Bind this smoke to the published release"), + `${file} must stage the closeout harness immediately after the tag checkout`, + ); const pythonSetup = namedStep(job, "Install pinned Python"); add( violations, @@ -5296,6 +5323,7 @@ function validatePostPublish(workflows, violations, graph) { // The install arguments arrive as variables, so the command text no longer says which delivery // state produced them. Each variable is bound back to the step that resolved it. requireStepEnv(violations, file, job, resolveStepName, { + CLOSEOUT_HELPER: "${{ steps.closeout-harness.outputs.helper }}", MARKETPLACE_REVISION: "${{ steps.delivery.outputs.marketplace_revision }}", MARKETPLACE_SOURCE: "${{ steps.delivery.outputs.marketplace_source }}", LOCAL_FIXTURE: "${{ steps.delivery.outputs.local_fixture }}", @@ -5359,7 +5387,7 @@ function validatePostPublish(workflows, violations, graph) { ); const installedRun = executableRunText(String(installed?.run ?? "")); for (const fragment of [ - "python .github/scripts/check-packaged-agent-proof.py", + 'python "$PROOF_HELPER"', '--archive "$ASSET_ARCHIVE"', "--plugin-handoff", "--engine-policy accelerated", @@ -5389,6 +5417,7 @@ function validatePostPublish(workflows, violations, graph) { CATALOG_DELIVERY_STATE: "${{ steps.delivery.outputs.state }}", DELIVERED_INSTALLER: "${{ steps.delivery.outputs.installer }}", EXPECTED_BACKEND: "${{ matrix.backend }}", + PROOF_HELPER: "${{ steps.closeout-harness.outputs.proof }}", }); add( violations, @@ -5402,6 +5431,7 @@ function validatePostPublish(workflows, violations, graph) { "INSTALLED_ATTESTATION", "INSTALLED_PLUGIN_DATA", "INSTALLED_PLUGIN_ROOT", + "PROOF_HELPER", "RELEASE_VERSION", ]), `${file} installed runtime restart proof must bind only the reviewed package, install, delivery, and backend identities`, @@ -5415,7 +5445,7 @@ function validatePostPublish(workflows, violations, graph) { add( violations, occurrenceCount(installedRun, "common=(") === 1 - && occurrenceCount(installedCommon, "python .github/scripts/check-packaged-agent-proof.py") === 1 + && occurrenceCount(installedCommon, 'python "$PROOF_HELPER"') === 1 && [ '--archive "$ASSET_ARCHIVE"', '--checksum-file "$ASSET_CHECKSUM"', diff --git a/.github/scripts/check-workflow-policy.test.mjs b/.github/scripts/check-workflow-policy.test.mjs index 4a864ebd2..cc2695393 100644 --- a/.github/scripts/check-workflow-policy.test.mjs +++ b/.github/scripts/check-workflow-policy.test.mjs @@ -3140,7 +3140,7 @@ test("exact proof policy rejects trigger and identity downgrades", async (t) => ["source manual SHA equality", sourceFile, workflow => { sourceResolver(workflow).run = sourceResolver(workflow).run .replace('test "$GITHUB_SHA" = "$EXPECTED_HEAD_SHA"', 'test -n "$GITHUB_SHA"'); - }, /GITHUB_SHA.*EXPECTED_HEAD_SHA/u], + }, /GITHUB_SHA.*EXPECTED_HEAD_SHA|trusted resolver script contract/u], ["source manual SHA short-circuit", sourceFile, workflow => { sourceResolver(workflow).run = sourceResolver(workflow).run .replace( @@ -3177,7 +3177,7 @@ test("exact proof policy rejects trigger and identity downgrades", async (t) => ["platform manual SHA equality", packagedCoordinatorFile, workflow => { packagedResolver(workflow).run = packagedResolver(workflow).run .replace('test "$GITHUB_SHA" = "$INPUT_HEAD_SHA"', 'test -n "$GITHUB_SHA"'); - }, /GITHUB_SHA.*INPUT_HEAD_SHA/u], + }, /GITHUB_SHA.*INPUT_HEAD_SHA|trusted resolver script contract/u], ["platform manual SHA short-circuit", packagedCoordinatorFile, workflow => { packagedResolver(workflow).run = packagedResolver(workflow).run .replace( @@ -3213,7 +3213,7 @@ test("exact proof policy rejects trigger and identity downgrades", async (t) => ["integration live dev SHA equality", packagedCoordinatorFile, workflow => { packagedResolver(workflow).run = packagedResolver(workflow).run .replace('test "$GITHUB_SHA" = "$dev_head"', 'test -n "$GITHUB_SHA"'); - }, /GITHUB_SHA.*dev_head/u], + }, /GITHUB_SHA.*dev_head|trusted resolver script contract/u], ["hosted-only integration scope removed", packagedCoordinatorFile, workflow => { workflow.on.workflow_dispatch.inputs.scope.options = workflow.on.workflow_dispatch.inputs.scope.options.filter(scope => scope !== "none"); @@ -4102,7 +4102,7 @@ test("release freeze policy pins live PR base and support ancestry revalidation" "JSON.parse('{}')", )], ["release base lookup stops using the live integration ref", value => - value.replace( + value.replaceAll( "`repos/${repository}/git/ref/heads/dev/codestory-next`", "`repos/${repository}/git/commits/${pr.base.sha}`", )], @@ -5957,6 +5957,19 @@ test("post-publish proof uses an immutable real Codex marketplace install", asyn ({ name }) => name === "Prove the catalog-resolved published runtime", ); const mutations = [ + ["caller-bound closeout harness is removed", workflow => { + workflow.jobs.smoke.steps = workflow.jobs.smoke.steps.filter( + ({ name }) => name !== "Stage caller-bound closeout harness", + ); + }, /stage the caller-bound closeout harness/u], + ["install step returns to the release-tag harness", workflow => { + installStep(workflow).env.CLOSEOUT_HELPER = + "${{ github.workspace }}/.github/scripts/install-codestory-marketplace-proof.mjs"; + }, /must bind CLOSEOUT_HELPER/u], + ["runtime proof returns to the release-tag harness", workflow => { + proofStep(workflow).env.PROOF_HELPER = + "${{ github.workspace }}/.github/scripts/check-packaged-agent-proof.py"; + }, /must bind PROOF_HELPER/u], ["Codex CLI pin drifts", workflow => { workflow.env.CODEX_CLI_VERSION = "latest"; }, /pin the Codex CLI/u], diff --git a/.github/scripts/install-codestory-marketplace-proof.mjs b/.github/scripts/install-codestory-marketplace-proof.mjs index 5fa45642f..0464cbb58 100644 --- a/.github/scripts/install-codestory-marketplace-proof.mjs +++ b/.github/scripts/install-codestory-marketplace-proof.mjs @@ -55,18 +55,24 @@ export function commandPlan( if (/[\r\n"]/u.test(value)) fail("Codex command arguments must be single-line"); return `"${value.replaceAll("%", "%%")}"`; }; + const commandLine = [executable, ...args].map(quote).join(" "); return { command: comspec, - commandArgs: ["/d", "/s", "/c", [executable, ...args].map(quote).join(" ")], + // With /s, cmd.exe removes the first and last quotes from the command + // string. Keep an outer pair so the quoted shim path survives intact, + // and stop Node from escaping that raw cmd.exe command string again. + commandArgs: ["/d", "/s", "/c", `"${commandLine}"`], + spawnOptions: { windowsVerbatimArguments: true }, }; } return { command: executable, commandArgs: args }; } function run(executable, args, options = {}) { - const { command, commandArgs } = commandPlan(executable, args); + const { command, commandArgs, spawnOptions = {} } = commandPlan(executable, args); const result = spawnSync(command, commandArgs, { ...options, + ...spawnOptions, encoding: "utf8", }); if (result.status !== 0) { @@ -92,12 +98,19 @@ function filesUnder(root, relative = "") { }); } -function directoryDigest(root) { +export function directoryDigest(root) { const digest = createHash("sha256"); - const files = filesUnder(root).sort(); + const files = filesUnder(root) + .map((relative) => ({ + relative, + normalized: relative.split(path.sep).join("/"), + })) + .sort((left, right) => Buffer.compare( + Buffer.from(left.normalized), + Buffer.from(right.normalized), + )); if (files.length === 0) fail("installed plugin package is empty"); - for (const relative of files) { - const normalized = relative.split(path.sep).join("/"); + for (const { relative, normalized } of files) { const name = Buffer.from(normalized); const payload = readFileSync(path.join(root, relative)); const nameLength = Buffer.alloc(8); diff --git a/.github/scripts/install-codestory-marketplace-proof.test.mjs b/.github/scripts/install-codestory-marketplace-proof.test.mjs index 3caada31e..6a7917255 100644 --- a/.github/scripts/install-codestory-marketplace-proof.test.mjs +++ b/.github/scripts/install-codestory-marketplace-proof.test.mjs @@ -15,12 +15,29 @@ import process from "node:process"; import test from "node:test"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { commandPlan } from "./install-codestory-marketplace-proof.mjs"; +import { + commandPlan, + directoryDigest, +} from "./install-codestory-marketplace-proof.mjs"; const scriptRoot = path.dirname(fileURLToPath(import.meta.url)); const repositoryRoot = path.resolve(scriptRoot, "..", ".."); const helper = path.join(scriptRoot, "install-codestory-marketplace-proof.mjs"); const codexVersion = "0.144.5"; +const orderingContractDigest = + "9c8a732ad11364c4eb6a36b16fd856f5b63e31ab01ad41e5782bdafdbf7dd34d"; + +test("plugin directory digest sorts normalized paths bytewise", () => { + const root = mkdtempSync(path.join(tmpdir(), "codestory-directory-contract-")); + try { + mkdirSync(path.join(root, "a")); + writeFileSync(path.join(root, "a", "child.txt"), "nested"); + writeFileSync(path.join(root, "a0.txt"), "flat"); + assert.equal(directoryDigest(root), orderingContractDigest); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); test("Windows invokes native executables directly and wraps only command shims", () => { const comspec = "C:\\Windows\\System32\\cmd.exe"; @@ -45,12 +62,33 @@ test("Windows invokes native executables directly and wraps only command shims", "/d", "/s", "/c", - '"C:\\tools\\codex.cmd" "plugin" "list" "--json"', + '""C:\\tools\\codex.cmd" "plugin" "list" "--json""', ], + spawnOptions: { windowsVerbatimArguments: true }, }, ); }); +test("Windows executes a command shim whose path and arguments contain spaces", { + skip: process.platform !== "win32", +}, () => { + const root = mkdtempSync(path.join(tmpdir(), "codestory command shim ")); + try { + const shim = path.join(root, "shim directory", "probe.cmd"); + mkdirSync(path.dirname(shim), { recursive: true }); + writeFileSync(shim, "@echo off\r\necho [%~1][%~2]\r\n"); + const plan = commandPlan(shim, ["first", "two words"]); + const result = spawnSync(plan.command, plan.commandArgs, { + ...plan.spawnOptions, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), "[first][two words]"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + function run(executable, args, options = {}) { const result = spawnSync(executable, args, { ...options, diff --git a/.github/scripts/packaged_agent_proof/installation_support.py b/.github/scripts/packaged_agent_proof/installation_support.py index 01c565992..e2c7c4bc6 100644 --- a/.github/scripts/packaged_agent_proof/installation_support.py +++ b/.github/scripts/packaged_agent_proof/installation_support.py @@ -21,14 +21,18 @@ def directory_contract_sha256(root: Path) -> str: require(root.is_dir(), f"plugin package root does not exist: {root}") digest = hashlib.sha256() - files = sorted(path for path in root.rglob("*") if path.is_file()) + files = [ + (path.relative_to(root).as_posix().encode("utf-8"), path) + for path in root.rglob("*") + if path.is_file() + ] + files.sort(key=lambda entry: entry[0]) require(files, "plugin package root is empty") - for path in files: + for relative, path in files: require( not path.is_symlink(), f"installed plugin package contains a symlink: {path}", ) - relative = path.relative_to(root).as_posix().encode("utf-8") payload = path.read_bytes() digest.update(len(relative).to_bytes(8, "little")) digest.update(relative) diff --git a/.github/scripts/packaged_agent_proof/self_test_marketplace_delivery.py b/.github/scripts/packaged_agent_proof/self_test_marketplace_delivery.py index 6bdb700af..6f7f81cee 100644 --- a/.github/scripts/packaged_agent_proof/self_test_marketplace_delivery.py +++ b/.github/scripts/packaged_agent_proof/self_test_marketplace_delivery.py @@ -28,6 +28,7 @@ from .foundation import REPOSITORY_ROOT, ProofFailure, require from .installed_identity import installed_plugin_identity +from .installation_support import directory_contract_sha256 from .marketplace_installation import ( DEFERRED_INSTALLATION_SOURCE, LIVE_INSTALLATION_SOURCE, @@ -42,6 +43,21 @@ _MARKER_FILENAME = ".codestory-marketplace-fixture.json" _MARKER_PURPOSE = "codestory-candidate-pinned-marketplace-fixture" _PLUGIN_ID = f"codestory@{_MARKETPLACE_NAME}" +_ORDERING_CONTRACT_DIGEST = ( + "9c8a732ad11364c4eb6a36b16fd856f5b63e31ab01ad41e5782bdafdbf7dd34d" +) + + +def _run_directory_contract_ordering_self_test() -> None: + with tempfile.TemporaryDirectory(prefix="codestory-directory-contract-") as raw: + root = Path(raw) + (root / "a").mkdir() + (root / "a" / "child.txt").write_text("nested", encoding="utf-8") + (root / "a0.txt").write_text("flat", encoding="utf-8") + require( + directory_contract_sha256(root) == _ORDERING_CONTRACT_DIGEST, + "plugin directory digest does not sort normalized paths bytewise", + ) def _git(repository: Path, *arguments: str) -> str: @@ -609,6 +625,7 @@ def _run_shared_identity_self_tests() -> None: def run_marketplace_delivery_self_tests() -> None: + _run_directory_contract_ordering_self_test() _run_codex_config_serialization_self_test() manifest = _manifest() with tempfile.TemporaryDirectory(prefix="codestory-marketplace-delivery-") as raw: diff --git a/.github/scripts/release-driver-receipt.mjs b/.github/scripts/release-driver-receipt.mjs index 240d2378e..6458cd28b 100644 --- a/.github/scripts/release-driver-receipt.mjs +++ b/.github/scripts/release-driver-receipt.mjs @@ -193,7 +193,13 @@ function validateGroupValue(group, value) { sha(row.commit, `${group} commit`); sha(row.tree, `${group} tree`); } else if (group === "pull-requests") { - positiveInteger(row.release_pr, "pull-requests release_pr"); + if (row.release_pr == null || row.release_pr === 0) { + if (row.bind !== "next_head") { + fail("pull-requests without release_pr must bind next_head"); + } + } else { + positiveInteger(row.release_pr, "pull-requests release_pr"); + } if ( !Array.isArray(row.integrated_support_prs) || !row.integrated_support_prs.every(number => diff --git a/.github/scripts/release-driver-receipt.test.mjs b/.github/scripts/release-driver-receipt.test.mjs index e1e845121..b435c2b25 100644 --- a/.github/scripts/release-driver-receipt.test.mjs +++ b/.github/scripts/release-driver-receipt.test.mjs @@ -52,7 +52,8 @@ function preFreezeReceipt() { let receipt = initReceipt("0.17.0"); receipt = record(receipt, "calibration-source", { commit: C, tree: C_TREE }); receipt = record(receipt, "pull-requests", { - release_pr: 1873, + release_pr: null, + bind: "next_head", integrated_support_prs: [1869, 1871], }); receipt = record( diff --git a/.github/scripts/release-freeze-acceptance-jobs.json b/.github/scripts/release-freeze-acceptance-jobs.json index 6e424a0ff..76a05b59e 100644 --- a/.github/scripts/release-freeze-acceptance-jobs.json +++ b/.github/scripts/release-freeze-acceptance-jobs.json @@ -1,9 +1,9 @@ { "schema": "codestory.release-freeze-acceptance-jobs/v2", "workflow": ".github/workflows/source-proof.yml", - "workflow_context_sha256": "81c20037a04bf19fb5a0ac043cea491186613ae55335c88b86150011191254a4", + "workflow_context_sha256": "c2c807e4ea1e830e07fdbee2ab52a47f2e765be694e3e3cb6e681840c7893250", "jobs": { - "resolve": "16e028133665257ebd2260770752587cb339dc58dbf87004065ac1a9f754fa65", + "resolve": "d5f548a981f92e6597233ca50b7184627b79515e89911e214244fcb4a29f378f", "freeze-hostile-mutations": "ebc27d28a1c087f848be090d2a2a458acee0177f06048c4d357e0724cf38be1a", "freeze-windows-native-probe": "e4570bb9d0ae34da43965d49c02b34276b43ad6b6e2a492435dedb1f254e454a", "freeze-acceptance": "19e8ad21de3c35d03b4630c44f6b88d1d3d813d48c28aa9176cb4b75bb596371" diff --git a/.github/scripts/release-freeze-barrier.mjs b/.github/scripts/release-freeze-barrier.mjs index 786b74c3c..27ad86ae4 100644 --- a/.github/scripts/release-freeze-barrier.mjs +++ b/.github/scripts/release-freeze-barrier.mjs @@ -88,23 +88,26 @@ function gh(args) { return run("gh", args); } -function values(args, name) { +function values(args, name, { allowEmpty = false } = {}) { const result = []; for (let index = 0; index < args.length; index += 1) { if (args[index] === name) { const value = args[index + 1]; - if (!value || value.startsWith("--")) { + const missing = value === undefined + || (!allowEmpty && !value) + || (Boolean(value) && value.startsWith("--")); + if (missing) { fail(`${name} requires a value`); } - result.push(value); + result.push(value ?? ""); index += 1; } } return result; } -function value(args, name, fallback = undefined) { - const found = values(args, name); +function value(args, name, fallback = undefined, options = {}) { + const found = values(args, name, options); if (found.length > 1) { fail(`${name} may be specified only once`); } @@ -251,6 +254,22 @@ export function validateAcceptanceProvenance({ return Number(target[1]); } +function validReleasePrBind(receipt, commit) { + const pr = receipt?.release_pr; + if (!pr || pr.base !== "dev/codestory-next" || pr.head_commit !== commit) { + return false; + } + if (!/^[0-9a-f]{40}$/u.test(String(pr.base_commit ?? ""))) { + return false; + } + if (pr.bind === "next_head" || pr.number === 0) { + return pr.head === "dev/codestory-next" && receipt.branch === "dev/codestory-next"; + } + return Number.isInteger(pr.number) + && pr.number > 0 + && pr.head === receipt.branch; +} + export function validateReceipt( receipt, { repository, commit, tree, runId, runAttempt, phase }, @@ -273,15 +292,8 @@ export function validateReceipt( if (receipt.worktree_clean !== true || receipt.remote_head !== commit) { fail("freeze receipt must prove a clean worktree pushed at the exact commit"); } - if ( - !Number.isInteger(receipt?.release_pr?.number) - || receipt.release_pr.number <= 0 - || receipt?.release_pr?.head_commit !== commit - || receipt?.release_pr?.head !== receipt.branch - || receipt?.release_pr?.base !== "dev/codestory-next" - || !/^[0-9a-f]{40}$/u.test(String(receipt?.release_pr?.base_commit ?? "")) - ) { - fail("freeze receipt must bind the open release PR at this exact head"); + if (!validReleasePrBind(receipt, commit)) { + fail("freeze receipt must bind the open release PR or next-head at this exact head"); } if ( !Array.isArray(receipt.integrated_support_prs) @@ -476,21 +488,58 @@ function supportPr(repository, number, commit, repo) { }; } -function releasePr(repository, number, { branch, commit }) { - const pr = JSON.parse(gh(["api", `repos/${repository}/pulls/${number}`])); +function nextHeadBind(repository, commit, { branch } = {}) { + if (branch && branch !== "dev/codestory-next") { + fail("next-head freeze bind must run on dev/codestory-next"); + } const liveBaseRef = JSON.parse(gh([ "api", `repos/${repository}/git/ref/heads/dev/codestory-next`, ])); const liveBaseCommit = liveBaseRef?.object?.sha; if ( - pr.state !== "open" - || pr?.base?.ref !== "dev/codestory-next" - || pr?.head?.ref !== branch - || pr?.head?.sha !== commit - || pr?.head?.repo?.full_name !== repository + liveBaseCommit !== commit || !/^[0-9a-f]{40}$/u.test(String(liveBaseCommit ?? "")) ) { + fail( + `dev/codestory-next must already be exact head ${commit} when no open release PR exists`, + ); + } + return { + number: 0, + bind: "next_head", + base: "dev/codestory-next", + base_commit: liveBaseCommit, + head: "dev/codestory-next", + head_commit: commit, + }; +} + +function releasePr(repository, number, { branch, commit }) { + if (!number || String(number).trim() === "" || String(number) === "0") { + return nextHeadBind(repository, commit, { branch }); + } + const pr = JSON.parse(gh(["api", `repos/${repository}/pulls/${number}`])); + const liveBaseRef = JSON.parse(gh([ + "api", + `repos/${repository}/git/ref/heads/dev/codestory-next`, + ])); + const liveBaseCommit = liveBaseRef?.object?.sha; + const sameRepository = pr?.head?.repo?.full_name === repository; + const liveShaOk = /^[0-9a-f]{40}$/u.test(String(liveBaseCommit ?? "")); + const openExact = pr.state === "open" + && pr?.base?.ref === "dev/codestory-next" + && pr?.head?.ref === branch + && pr?.head?.sha === commit + && sameRepository + && liveShaOk; + const closedSameSha = pr.state === "closed" + && pr?.base?.ref === "dev/codestory-next" + && pr?.head?.sha === commit + && liveBaseCommit === commit + && sameRepository + && liveShaOk; + if (!openExact && !closedSameSha) { fail( `release PR #${number} must be an open same-repository ${branch} -> ` + `dev/codestory-next PR at exact head ${commit}`, @@ -505,6 +554,9 @@ function releasePr(repository, number, { branch, commit }) { `release PR #${number} head ${commit} does not contain current dev base ${liveBaseCommit}`, ); } + if (closedSameSha) { + return nextHeadBind(repository, commit, { branch: "dev/codestory-next" }); + } return { number: pr.number, base: pr.base.ref, @@ -542,7 +594,7 @@ function recordActionsReceipt(args) { const commit = required(args, "--commit"); const tree = required(args, "--tree"); const output = required(args, "--output"); - const releasePrNumber = required(args, "--release-pr"); + const releasePrNumber = value(args, "--release-pr", "", { allowEmpty: true }); const runId = required(args, "--run-id"); const runAttempt = required(args, "--run-attempt"); const phase = required(args, "--phase"); diff --git a/.github/scripts/release-freeze-barrier.test.mjs b/.github/scripts/release-freeze-barrier.test.mjs index 342e09623..60be4f854 100644 --- a/.github/scripts/release-freeze-barrier.test.mjs +++ b/.github/scripts/release-freeze-barrier.test.mjs @@ -90,6 +90,20 @@ test("an exact clean pushed source-stabilization Actions receipt passes", () => validateReceipt(receipt(), RECEIPT_CONTEXT); }); +test("a next-head bind passes when next is already the exact head", () => { + validateReceipt(receipt({ + branch: "dev/codestory-next", + release_pr: { + number: 0, + bind: "next_head", + base: "dev/codestory-next", + base_commit: "0".repeat(40), + head: "dev/codestory-next", + head_commit: COMMIT, + }, + }), RECEIPT_CONTEXT); +}); + test("a frozen-candidate receipt carries no future mutation and passes", () => { const frozen = receipt({ phase: "frozen_candidate" }); validateReceipt(frozen, { @@ -161,10 +175,10 @@ for (const [name, mutate, pattern] of [ ["unpushed head", (value) => { value.remote_head = "5".repeat(40); }, /clean worktree/u], ["moved release PR", (value) => { value.release_pr.head_commit = "5".repeat(40); - }, /bind the open release PR/u], + }, /bind the open release PR or next-head/u], ["unbound release base", (value) => { value.release_pr.base_commit = ""; - }, /bind the open release PR/u], + }, /bind the open release PR or next-head/u], ["undeclared source change", (value) => { value.known_future_source_changes.push(".github/workflows/release.yml"); }, /future changes do not match source_stabilization/u], @@ -414,6 +428,56 @@ test("record-actions-receipt refuses to mint authority outside GitHub Actions", ); }); +test("record-actions-receipt accepts an empty --release-pr for next-head bind", () => { + const root = mkdtempSync(path.join(tmpdir(), "codestory-freeze-empty-release-pr-")); + const script = new URL("./release-freeze-barrier.mjs", import.meta.url); + const result = spawnSync( + process.execPath, + [ + script.pathname, + "record-actions-receipt", + "--repo", + root, + "--repository", + REPOSITORY, + "--branch", + "dev/codestory-next", + "--commit", + COMMIT, + "--tree", + TREE, + "--release-pr", + "", + "--output", + path.join(root, "receipt.json"), + "--run-id", + String(RUN_ID), + "--run-attempt", + String(RUN_ATTEMPT), + "--phase", + "source_stabilization", + "--support-prs-json", + "[]", + "--broad-workflow", + "Exact-head source proof", + ], + { + encoding: "utf8", + env: { + ...process.env, + GITHUB_ACTIONS: "", + GITHUB_EVENT_NAME: "", + }, + }, + ); + assert.notEqual(result.status, 0); + assert.doesNotMatch(result.stderr, /--release-pr requires a value/u); + assert.match( + result.stderr, + /canonical release freeze receipt may be produced only by workflow_dispatch/u, + ); +}); + test("record-actions-receipt rejects a PR whose snapshot omits the live dev head", () => { const sandbox = mkdtempSync(path.join(tmpdir(), "codestory-freeze-stale-base-")); const root = path.join(sandbox, "repo"); diff --git a/.github/workflows/packaged-platform-pr.yml b/.github/workflows/packaged-platform-pr.yml index e03a1b17b..82f41ad2e 100644 --- a/.github/workflows/packaged-platform-pr.yml +++ b/.github/workflows/packaged-platform-pr.yml @@ -10,7 +10,7 @@ on: type: choice options: [package, platform, qualification, calibration, integration] pr_number: - description: Same-repository pull request. Dispatch PR modes with --ref . + description: Optional same-repository pull request. Dispatch PR modes with --ref . Omit when dev/codestory-next is already the exact expected head and dispatch with --ref dev/codestory-next. required: false type: string expected_head_sha: @@ -112,6 +112,44 @@ jobs: fi test "$mode" = "package" || test "$mode" = "platform" || test "$mode" = "qualification" || test "$mode" = "calibration" pr_number="${EVENT_PR_NUMBER:-$INPUT_PR_NUMBER}" + if [ -z "$pr_number" ]; then + test "$GITHUB_EVENT_NAME" = "workflow_dispatch" + test -n "$INPUT_HEAD_SHA" + test "$GITHUB_REF" = "refs/heads/dev/codestory-next" || { + echo "::error::Platform proof without a PR must be dispatched with --ref dev/codestory-next, not $GITHUB_REF." + exit 1 + } + test "$GITHUB_SHA" = "$INPUT_HEAD_SHA" || { + echo "::error::Workflow SHA $GITHUB_SHA is not accepted next head $INPUT_HEAD_SHA. Dispatch with --ref dev/codestory-next." + exit 1 + } + dev_head="$(gh api "repos/$GITHUB_REPOSITORY/branches/dev/codestory-next" --jq '.commit.sha')" + test "$INPUT_HEAD_SHA" = "$dev_head" || { + echo "::error::Live dev/codestory-next $dev_head is not accepted head $INPUT_HEAD_SHA." + exit 1 + } + test "$GITHUB_SHA" = "$dev_head" || { + echo "::error::Workflow SHA $GITHUB_SHA is not current dev head $dev_head. Dispatch with --ref dev/codestory-next." + exit 1 + } + if [ "$mode" = "calibration" ]; then + if [ -n "$INPUT_CALIBRATION_ARTIFACT" ] \ + || [ -n "$INPUT_CALIBRATION_RUN_ID" ]; then + echo "::error::Calibration collection does not consume prior proof artifacts." + exit 1 + fi + elif [ "$mode" = "qualification" ]; then + test -n "$INPUT_CALIBRATION_ARTIFACT" + test -n "$INPUT_CALIBRATION_RUN_ID" + fi + { + echo "head_sha=$dev_head" + echo "base_sha=$dev_head" + echo "mode=$mode" + echo "proof_key=$mode-next-$dev_head" + } >> "$GITHUB_OUTPUT" + exit 0 + fi test -n "$pr_number" pr="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$pr_number")" head_repo="$(jq -r '.head.repo.full_name' <<<"$pr")" diff --git a/.github/workflows/plugin-static.yml b/.github/workflows/plugin-static.yml index 6c6699fd4..8dc7ea0f0 100644 --- a/.github/workflows/plugin-static.yml +++ b/.github/workflows/plugin-static.yml @@ -30,6 +30,8 @@ on: - .github/scripts/run-actionlint.test.mjs - .github/actionlint.yaml - release-claims.json + - scripts/codestory-release.mjs + - scripts/codestory-release.test.mjs - scripts/codestory-release-claims.mjs - scripts/codestory-release-closeout.mjs - scripts/codestory-release-evidence-gate.mjs @@ -113,6 +115,8 @@ on: - .github/scripts/run-actionlint.test.mjs - .github/actionlint.yaml - release-claims.json + - scripts/codestory-release.mjs + - scripts/codestory-release.test.mjs - scripts/codestory-release-claims.mjs - scripts/codestory-release-closeout.mjs - scripts/codestory-release-evidence-gate.mjs @@ -210,6 +214,7 @@ jobs: run: >- node --test .github/scripts/publish-marketplace-catalog.test.mjs + scripts/codestory-release.test.mjs scripts/tests/codestory-release-claims.test.mjs scripts/tests/codestory-release-closeout.test.mjs scripts/tests/codestory-release-evidence-gate.test.mjs diff --git a/.github/workflows/post-publish-release-smoke.yml b/.github/workflows/post-publish-release-smoke.yml index f0c6ad3cc..cd001236f 100644 --- a/.github/workflows/post-publish-release-smoke.yml +++ b/.github/workflows/post-publish-release-smoke.yml @@ -120,6 +120,28 @@ jobs: ref: ${{ steps.release.outputs.tag }} fetch-depth: 0 + # Product source remains pinned to the published tag. The proof harness comes from the + # workflow invocation commit so a post-publication harness repair can re-prove immutable + # release bytes without moving the tag or rebuilding the package. + - name: Stage caller-bound closeout harness + id: closeout-harness + shell: bash + run: | + set -euo pipefail + printf '%s' "$GITHUB_SHA" | grep -Eq '^[0-9a-f]{40}$' + harness_root="$RUNNER_TEMP/codestory-closeout-harness" + rm -rf "$RUNNER_TEMP/codestory-closeout-harness" + mkdir -p "$harness_root" + git archive "$GITHUB_SHA" \ + .github/scripts/install-codestory-marketplace-proof.mjs \ + .github/scripts/marketplace-delivery-identity.mjs \ + .github/scripts/check-packaged-agent-proof.py \ + .github/scripts/packaged_agent_proof \ + | tar -x -C "$harness_root" + echo "helper=$harness_root/.github/scripts/install-codestory-marketplace-proof.mjs" >> "$GITHUB_OUTPUT" + echo "proof=$harness_root/.github/scripts/check-packaged-agent-proof.py" >> "$GITHUB_OUTPUT" + echo "Closeout harness commit: $GITHUB_SHA." >> "$GITHUB_STEP_SUMMARY" + # The tag is a local name until GitHub agrees it is a published one. Every later step pins # the commit resolved here, so the fixture catalog, the Codex resolve, and the byte # comparison all name a release GitHub is actually serving -- never merely whatever tree @@ -582,8 +604,13 @@ jobs: LOCAL_FIXTURE: ${{ steps.delivery.outputs.local_fixture }} INSTALLATION_SOURCE: ${{ steps.delivery.outputs.installer }} RELEASE_VERSION: ${{ steps.release.outputs.version }} + CLOSEOUT_HELPER: ${{ steps.closeout-harness.outputs.helper }} run: | set -euo pipefail + case "$CLOSEOUT_HELPER" in + */install-codestory-marketplace-proof.mjs) ;; + *) echo "::error::Unexpected closeout helper path"; exit 1 ;; + esac install_root="$RUNNER_TEMP/codestory-installed-proof" codex_package_root="$RUNNER_TEMP/codex-cli-${CODEX_CLI_VERSION}" isolated_home="$install_root/isolated-home" @@ -596,7 +623,7 @@ jobs: --no-audit \ --no-fund \ "@openai/codex@$CODEX_CLI_VERSION" - HOME="$isolated_home" node .github/scripts/install-codestory-marketplace-proof.mjs \ + HOME="$isolated_home" node "$CLOSEOUT_HELPER" \ --codex-package-root "$codex_package_root" \ --codex-home "$install_root/codex-home" \ --plugin-data "$install_root/codex-home/plugin-data" \ @@ -622,16 +649,21 @@ jobs: CATALOG_DELIVERY_STATE: ${{ steps.delivery.outputs.state }} DELIVERED_INSTALLER: ${{ steps.delivery.outputs.installer }} EXPECTED_BACKEND: ${{ matrix.backend }} + PROOF_HELPER: ${{ steps.closeout-harness.outputs.proof }} shell: bash run: | set -euo pipefail + case "$PROOF_HELPER" in + */check-packaged-agent-proof.py) ;; + *) echo "::error::Unexpected packaged proof helper path"; exit 1 ;; + esac source_sha="$(git rev-parse HEAD)" source_tree="$(git rev-parse 'HEAD^{tree}')" proof_root=target/post-publish-installed-proof rm -rf "$proof_root" mkdir -p "$proof_root" common=( - python .github/scripts/check-packaged-agent-proof.py + python "$PROOF_HELPER" --archive "$ASSET_ARCHIVE" \ --checksum-file "$ASSET_CHECKSUM" \ --expected-version "$RELEASE_VERSION" \ diff --git a/.github/workflows/source-proof.yml b/.github/workflows/source-proof.yml index 9278946e5..71b4479cd 100644 --- a/.github/workflows/source-proof.yml +++ b/.github/workflows/source-proof.yml @@ -19,8 +19,9 @@ on: workflow_dispatch: inputs: pr_number: - description: Same-repository pull request accepted by exact-head review. Dispatch with --ref . - required: true + description: Optional same-repository pull request accepted by exact-head review. Dispatch with --ref . Omit when dev/codestory-next is already the exact expected head and dispatch with --ref dev/codestory-next. + required: false + default: "" type: string expected_head_sha: description: Exact reviewed head SHA. The selected --ref, github.sha, and live PR head must match. @@ -143,6 +144,24 @@ jobs: exit 1 } echo "ref=$head_sha" >> "$GITHUB_OUTPUT" + elif [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + test -n "$EXPECTED_HEAD_SHA" + test "$GITHUB_REF" = "refs/heads/dev/codestory-next" || { + echo "::error::Exact-head proof without a PR must be dispatched with --ref dev/codestory-next, not $GITHUB_REF." + exit 1 + } + test "$GITHUB_SHA" = "$EXPECTED_HEAD_SHA" || { + echo "::error::Workflow SHA $GITHUB_SHA is not reviewed next head $EXPECTED_HEAD_SHA. Dispatch with --ref dev/codestory-next." + exit 1 + } + next_head="$( + gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/dev%2Fcodestory-next" --jq '.object.sha' + )" + test "$next_head" = "$EXPECTED_HEAD_SHA" || { + echo "::error::Live dev/codestory-next $next_head is not reviewed head $EXPECTED_HEAD_SHA." + exit 1 + } + echo "ref=$EXPECTED_HEAD_SHA" >> "$GITHUB_OUTPUT" else test -n "$CALLER_REF" test "$GITHUB_SHA" = "$CALLER_REF" || { diff --git a/CHANGELOG.md b/CHANGELOG.md index f98119edb..398f32fe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +## 0.17.5 + +Cursor still dropped `files`, `snippet`, and preparing `packet`/`search`/`context` results: empty `policy_exclusions` were omitted, batched snippets used a ranges document, and the shared `codestory_preparing` envelope was undeclared. + +Operators start, inspect, advance, and resume a release with `node scripts/codestory-release.mjs` instead of assembling a receipt by hand. Promotion still needs one recorded combined approval, and `--rehearse` walks the same machine without tagging or publishing. + ## 0.17.4 Cursor marketplace MCP tool results match the schemas Cursor validates. After the server started, Cursor rejected `status`, `packet`, and `files` because the advertised output schema forbade extra compact-status fields, typed packet `support` as an object, and omitted `files.coverage_gaps`. diff --git a/Cargo.lock b/Cargo.lock index b86c18f31..e52e0a8ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -522,7 +522,7 @@ dependencies = [ [[package]] name = "codestory-agent" -version = "0.17.4" +version = "0.17.5" dependencies = [ "codestory-contracts", "serde", @@ -534,7 +534,7 @@ dependencies = [ [[package]] name = "codestory-bench" -version = "0.17.4" +version = "0.17.5" dependencies = [ "anyhow", "clap", @@ -556,7 +556,7 @@ dependencies = [ [[package]] name = "codestory-cli" -version = "0.17.4" +version = "0.17.5" dependencies = [ "anyhow", "clap", @@ -588,7 +588,7 @@ dependencies = [ [[package]] name = "codestory-contracts" -version = "0.17.4" +version = "0.17.5" dependencies = [ "anyhow", "crossbeam-channel", @@ -605,7 +605,7 @@ dependencies = [ [[package]] name = "codestory-indexer" -version = "0.17.4" +version = "0.17.5" dependencies = [ "anyhow", "codestory-contracts", @@ -647,7 +647,7 @@ dependencies = [ [[package]] name = "codestory-llama-sys" -version = "0.17.4" +version = "0.17.5" dependencies = [ "codestory-contracts", "crossbeam-channel", @@ -662,7 +662,7 @@ dependencies = [ [[package]] name = "codestory-retrieval" -version = "0.17.4" +version = "0.17.5" dependencies = [ "anyhow", "chrono", @@ -685,7 +685,7 @@ dependencies = [ [[package]] name = "codestory-runtime" -version = "0.17.4" +version = "0.17.5" dependencies = [ "anyhow", "codestory-agent", @@ -711,7 +711,7 @@ dependencies = [ [[package]] name = "codestory-store" -version = "0.17.4" +version = "0.17.5" dependencies = [ "anyhow", "codestory-contracts", @@ -728,7 +728,7 @@ dependencies = [ [[package]] name = "codestory-workspace" -version = "0.17.4" +version = "0.17.5" dependencies = [ "anyhow", "codestory-contracts", diff --git a/benchmarks/release-evidence/fixtures/candidate.json b/benchmarks/release-evidence/fixtures/candidate.json index 7f6ba9d8a..538082e18 100644 --- a/benchmarks/release-evidence/fixtures/candidate.json +++ b/benchmarks/release-evidence/fixtures/candidate.json @@ -62,7 +62,7 @@ }, "release_claims": { "graph_schema": "codestory.release-claims/v1", - "graph_sha256": "d3263fef491c5c98462b6be30ba450dbcd73aaa078983c7f657acbb853a1517e", + "graph_sha256": "01c33786c7b946b89264a8b124b22d6bbd57fbef9dd6c48c354fa1f99b2f3fa6", "observed_at": "2026-08-09T12:50:08.593Z", "expires_at": "2026-08-10T12:50:08.593Z", "requested_claims": [ @@ -85,7 +85,7 @@ "type": "performance", "tier": "live_behavior", "status": "measured", - "graph_sha256": "d3263fef491c5c98462b6be30ba450dbcd73aaa078983c7f657acbb853a1517e", + "graph_sha256": "01c33786c7b946b89264a8b124b22d6bbd57fbef9dd6c48c354fa1f99b2f3fa6", "observed_at": "2026-08-09T12:50:08.593Z", "expires_at": "2026-08-10T12:50:08.593Z", "identity": { @@ -106,7 +106,7 @@ "type": "answer_quality", "tier": "answer_quality", "status": "pass", - "graph_sha256": "d3263fef491c5c98462b6be30ba450dbcd73aaa078983c7f657acbb853a1517e", + "graph_sha256": "01c33786c7b946b89264a8b124b22d6bbd57fbef9dd6c48c354fa1f99b2f3fa6", "observed_at": "2026-08-09T12:50:08.593Z", "expires_at": "2026-08-10T12:50:08.593Z", "identity": { diff --git a/benchmarks/release-evidence/fixtures/report.json b/benchmarks/release-evidence/fixtures/report.json index afc3cd991..df1399386 100644 --- a/benchmarks/release-evidence/fixtures/report.json +++ b/benchmarks/release-evidence/fixtures/report.json @@ -7,7 +7,7 @@ "baseline_id": "ci-contract-v1@1111111111111111111111111111111111111111", "baseline_sha256": "0bbbe6dd8b4000151edf7b1270959d08e94e08db876e7f2372b25613e0f237c1", "candidate_path": "benchmarks/release-evidence/fixtures/candidate.json", - "candidate_sha256": "d25ad5ee5cf71e2482c98e4430191bf97390de0282c47b85fefc79e15fdf2eb7", + "candidate_sha256": "fcdbc3d918efd5b83b6ddd1da4be2d0ebef56f3a8bb09eb5d80c7a1252c93f51", "artifact_paths": [ { "path": "candidate-stats.json", @@ -26,7 +26,7 @@ "type": "performance", "tier": "live_behavior", "status": "pass", - "graph_sha256": "d3263fef491c5c98462b6be30ba450dbcd73aaa078983c7f657acbb853a1517e", + "graph_sha256": "01c33786c7b946b89264a8b124b22d6bbd57fbef9dd6c48c354fa1f99b2f3fa6", "observed_at": "2026-08-09T12:50:08.593Z", "expires_at": "2026-08-10T12:50:08.593Z", "identity": { @@ -47,7 +47,7 @@ "type": "answer_quality", "tier": "answer_quality", "status": "pass", - "graph_sha256": "d3263fef491c5c98462b6be30ba450dbcd73aaa078983c7f657acbb853a1517e", + "graph_sha256": "01c33786c7b946b89264a8b124b22d6bbd57fbef9dd6c48c354fa1f99b2f3fa6", "observed_at": "2026-08-09T12:50:08.593Z", "expires_at": "2026-08-10T12:50:08.593Z", "identity": { @@ -69,7 +69,7 @@ "schema": "codestory.release-claim-evaluation/v1", "status": "pass", "graph_schema": "codestory.release-claims/v1", - "graph_sha256": "d3263fef491c5c98462b6be30ba450dbcd73aaa078983c7f657acbb853a1517e", + "graph_sha256": "01c33786c7b946b89264a8b124b22d6bbd57fbef9dd6c48c354fa1f99b2f3fa6", "evidence_selection": "all_matching_rows_must_pass", "expected_commit": "2222222222222222222222222222222222222222", "evaluated_at": "2026-08-09T12:50:08.593Z", diff --git a/crates/codestory-agent/Cargo.toml b/crates/codestory-agent/Cargo.toml index e4ebe31a4..59b5c043f 100644 --- a/crates/codestory-agent/Cargo.toml +++ b/crates/codestory-agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codestory-agent" -version = "0.17.4" +version = "0.17.5" edition = "2024" [features] diff --git a/crates/codestory-bench/Cargo.toml b/crates/codestory-bench/Cargo.toml index 1cc38d1fe..1504e22fa 100644 --- a/crates/codestory-bench/Cargo.toml +++ b/crates/codestory-bench/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codestory-bench" -version = "0.17.4" +version = "0.17.5" edition = "2024" publish = false diff --git a/crates/codestory-cli/Cargo.toml b/crates/codestory-cli/Cargo.toml index 8369135c7..112856413 100644 --- a/crates/codestory-cli/Cargo.toml +++ b/crates/codestory-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codestory-cli" -version = "0.17.4" +version = "0.17.5" edition = "2024" description = "Local repository evidence and grounding CLI for source-backed coding workflows." license = "Apache-2.0" diff --git a/crates/codestory-cli/src/stdio_catalog.rs b/crates/codestory-cli/src/stdio_catalog.rs index de0bee50c..c25d95d8e 100644 --- a/crates/codestory-cli/src/stdio_catalog.rs +++ b/crates/codestory-cli/src/stdio_catalog.rs @@ -119,7 +119,10 @@ impl ToolSpec { ("annotations".to_string(), self.safety.annotations_json()), ]); if let Some(output_schema) = self.output_schema { - tool.insert("outputSchema".to_string(), output_schema.to_json()); + tool.insert( + "outputSchema".to_string(), + attach_stdio_retry_envelope(output_schema.to_json()), + ); } Value::Object(tool) } @@ -874,6 +877,44 @@ static STATUS_OUTPUT_SCHEMA: SchemaObject = SchemaObject::object( ], ); +static STDIO_RETRY_NEXT_CALL_SCHEMA: SchemaObject = SchemaObject::object( + "Host-executable retry of the same tool after a preparing delay.", + &[ + SchemaProperty::string("method", "JSON-RPC method."), + SchemaProperty::string("tool", "Tool to retry."), + SchemaProperty::object("arguments", "Original tool arguments."), + SchemaProperty::integer("after_ms", "Delay before retry."), + ], + &["method", "tool"], +); + +static STDIO_RETRY_ENVELOPE_SCHEMA: SchemaObject = SchemaObject::object( + "Shared stdio retry or unavailable envelope returned instead of a tool DTO while managed activation is still updating.", + &[ + SchemaProperty::string("code", "Typed stdio retry or unavailable code."), + SchemaProperty::string("message", "Human-readable retry or unavailable message."), + SchemaProperty::object("details", "Structured API error repair guidance.").nullable(), + SchemaProperty::string("cause_code", "Underlying activation or cache cause code."), + SchemaProperty::string("tool", "Tool that produced this envelope."), + SchemaProperty::string("state", "preparing, unavailable, or cancelled."), + SchemaProperty::string( + "retry_tool", + "Tool to retry when the envelope is preparing.", + ) + .nullable(), + SchemaProperty::integer("retry_after_ms", "Retry delay while preparing.").nullable(), + SchemaProperty::object("operation", "Current managed preparation operation.").nullable(), + SchemaProperty::string("next_action", "Direct next action for the caller."), + SchemaProperty::array( + "recommended_next_calls", + "Host-executable retries of the intended tool.", + &STDIO_RETRY_NEXT_CALL_SCHEMA, + ), + SchemaProperty::string("diagnostics_uri", "Optional full diagnostic resource URI."), + ], + &["code", "message"], +); + static RESOURCE_LINK_SCHEMA: SchemaObject = SchemaObject::object( "Continuation resource link.", &[ @@ -1525,8 +1566,26 @@ static GRAPH_TOOL_OUTPUT_SCHEMA: SchemaObject = SchemaObject::object( ], ); +static SNIPPET_RANGE_SCHEMA: SchemaObject = SchemaObject::object( + "One requested source range from a batched snippet.paths call.", + &[ + SchemaProperty::string("path", "Project-relative file path."), + SchemaProperty::integer("start_line", "1-based first line."), + SchemaProperty::integer("end_line", "1-based last line."), + SchemaProperty::string("snippet", "Source snippet text."), + SchemaProperty::boolean("snippet_truncated", "Whether this range hit a byte cap."), + ], + &[ + "path", + "start_line", + "end_line", + "snippet", + "snippet_truncated", + ], +); + static SNIPPET_CONTEXT_SCHEMA: SchemaObject = SchemaObject::object( - "CodeStory snippet context DTO.", + "CodeStory snippet context DTO, or a batched paths result.", &[ SchemaProperty::object("node", "Node details DTO."), SchemaProperty::string("path", "Project-relative file path."), @@ -1548,7 +1607,19 @@ static SNIPPET_CONTEXT_SCHEMA: SchemaObject = SchemaObject::object( "truncation_guidance", "Follow-up guidance when the snippet hit its byte cap.", ), + SchemaProperty::array( + "ranges", + "Requested source ranges from a batched paths call.", + &SNIPPET_RANGE_SCHEMA, + ), + SchemaProperty::integer( + "max_total_bytes", + "Total byte cap for a batched paths call.", + ), ], + &[], +) +.with_any_of_required(&[ &[ "node", "path", @@ -1558,7 +1629,8 @@ static SNIPPET_CONTEXT_SCHEMA: SchemaObject = SchemaObject::object( "requested_context", "snippet_truncated", ], -); + &["ranges", "max_total_bytes"], +]); static DEFINITION_OUTPUT_SCHEMA: SchemaObject = SchemaObject::object( "CodeStory definition tool output.", @@ -2515,6 +2587,56 @@ pub(crate) fn tool_input_schema(name: &str) -> Option<&'static Value> { .get(name) } +fn attach_stdio_retry_envelope(mut schema: Value) -> Value { + let retry = STDIO_RETRY_ENVELOPE_SCHEMA.to_json(); + let retry_properties = retry + .get("properties") + .and_then(Value::as_object) + .expect("retry envelope properties") + .clone(); + let properties = schema + .get_mut("properties") + .and_then(Value::as_object_mut) + .expect("output schema properties"); + for (name, property) in retry_properties { + properties.entry(name).or_insert(property); + } + let error_branch = json!({ "required": ["code", "message"] }); + let has_error_branch = schema + .get("anyOf") + .and_then(Value::as_array) + .into_iter() + .flatten() + .any(|branch| { + let required = branch + .get("required") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>(); + required.contains(&"code") && required.contains(&"message") + }); + if has_error_branch { + return schema; + } + match schema.get_mut("anyOf") { + Some(Value::Array(any_of)) => any_of.push(error_branch), + _ => { + let required = schema.get("required").cloned().unwrap_or_else(|| json!([])); + let mut branches = Vec::new(); + if required.as_array().is_some_and(|items| !items.is_empty()) { + branches.push(json!({ "required": required })); + } + branches.push(error_branch); + let object = schema.as_object_mut().expect("output schema object"); + object.insert("anyOf".to_string(), Value::Array(branches)); + object.insert("required".to_string(), json!([])); + } + } + schema +} + /// Build the `tools/list` response. pub(crate) fn tools_list_json() -> Value { json!({ @@ -2742,4 +2864,201 @@ mod tests { forbids: declared = {declared:?}" ); } + + const STDIO_RETRY_ENVELOPE_FIELDS: &[&str] = &[ + "code", + "message", + "details", + "cause_code", + "tool", + "state", + "retry_tool", + "retry_after_ms", + "operation", + "next_action", + "recommended_next_calls", + "diagnostics_uri", + ]; + + fn tool_output_schema(name: &str) -> Value { + tools_list_json()["result"]["tools"] + .as_array() + .expect("tools") + .iter() + .find(|tool| tool["name"] == name) + .unwrap_or_else(|| panic!("{name} tool"))["outputSchema"] + .clone() + } + + fn schema_required_fields(schema: &Value) -> Vec<&str> { + schema + .get("required") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect() + } + + fn undeclared_object_keys(schema: &Value, data: &Value) -> Vec { + let Some(properties) = schema.get("properties").and_then(Value::as_object) else { + return Vec::new(); + }; + if schema.get("additionalProperties") != Some(&json!(false)) { + return Vec::new(); + } + data.as_object() + .expect("object payload") + .keys() + .filter(|key| !properties.contains_key(*key)) + .cloned() + .collect() + } + + fn satisfies_required_any_of(schema: &Value, data: &Value) -> bool { + let Some(object) = data.as_object() else { + return false; + }; + if let Some(any_of) = schema.get("anyOf").and_then(Value::as_array) { + return any_of.iter().any(|branch| { + schema_required_fields(branch) + .iter() + .all(|field| object.contains_key(*field)) + }); + } + schema_required_fields(schema) + .iter() + .all(|field| object.contains_key(*field)) + } + + fn preparing_envelope(tool: &str) -> Value { + json!({ + "code": "codestory_preparing", + "message": "project activation is still Updating at Publication; retry after 250ms", + "cause_code": "activation_preparing", + "details": null, + "tool": tool, + "state": "preparing", + "retry_tool": tool, + "retry_after_ms": 250, + "operation": { + "state": "updating", + "stage": "publication", + "progress": 75 + }, + "next_action": "retry_intended_tool", + "recommended_next_calls": [{ + "method": "tools/call", + "tool": tool, + "arguments": {"project": "/repo"}, + "after_ms": 250 + }], + "diagnostics_uri": "codestory://status?project=%2Frepo" + }) + } + + #[test] + fn every_tool_output_schema_declares_the_stdio_retry_envelope() { + let catalog = tools_list_json(); + for tool in catalog["result"]["tools"].as_array().expect("tools") { + let name = tool["name"].as_str().expect("tool name"); + let schema = &tool["outputSchema"]; + let properties = schema["properties"] + .as_object() + .unwrap_or_else(|| panic!("{name} outputSchema properties")); + for field in STDIO_RETRY_ENVELOPE_FIELDS { + assert!( + properties.contains_key(*field), + "{name} outputSchema must declare retry envelope field {field}" + ); + } + let has_error_branch = schema["anyOf"] + .as_array() + .into_iter() + .flatten() + .any(|branch| { + let required = schema_required_fields(branch); + required.contains(&"code") && required.contains(&"message") + }); + assert!( + has_error_branch, + "{name} outputSchema must anyOf a code/message retry branch so Cursor can accept preparing envelopes" + ); + let envelope = preparing_envelope(name); + let undeclared = undeclared_object_keys(schema, &envelope); + assert!( + undeclared.is_empty(), + "{name} preparing envelope emits undeclared {undeclared:?}" + ); + assert!( + satisfies_required_any_of(schema, &envelope), + "{name} preparing envelope must satisfy an anyOf required branch: {schema}" + ); + } + } + + #[test] + fn snippet_output_schema_accepts_a_paths_ranges_payload() { + let schema = tool_output_schema("snippet"); + let payload = json!({ + "max_total_bytes": 65_536, + "ranges": [{ + "path": "src/lib.rs", + "start_line": 10, + "end_line": 12, + "snippet": "fn run() {}", + "snippet_truncated": false + }] + }); + let undeclared = undeclared_object_keys(&schema, &payload); + assert!( + undeclared.is_empty(), + "snippet ranges payload emits undeclared {undeclared:?}" + ); + assert!( + satisfies_required_any_of(&schema, &payload), + "snippet ranges payload must satisfy an anyOf required branch: {schema}" + ); + } + + #[test] + fn indexed_files_dto_emits_empty_policy_exclusions() { + let files = codestory_contracts::api::IndexedFilesDto { + project_root: "/repo".to_string(), + usable: true, + summary: codestory_contracts::api::IndexedFilesSummaryDto { + file_count: 0, + indexed_file_count: 0, + filtered_file_count: 0, + visible_file_count: 0, + incomplete_file_count: 0, + error_file_count: 0, + policy_exclusion_count: 0, + incomplete_reason_counts: Vec::new(), + truncated: false, + language_counts: Vec::new(), + framework_route_coverage: Vec::new(), + coverage_notes: Vec::new(), + }, + coverage_gaps: Vec::new(), + policy_exclusions: Vec::new(), + files: Vec::new(), + }; + let payload = serde_json::to_value(&files).expect("serialize indexed files"); + assert_eq!( + payload.get("policy_exclusions"), + Some(&json!([])), + "Cursor's files anyOf requires policy_exclusions even when empty: {payload}" + ); + let schema = tool_output_schema("files"); + let undeclared = undeclared_object_keys(&schema, &payload); + assert!( + undeclared.is_empty(), + "files success payload emits undeclared {undeclared:?}" + ); + assert!( + satisfies_required_any_of(&schema, &payload), + "files success payload must satisfy an anyOf required branch: {schema}" + ); + } } diff --git a/crates/codestory-cli/tests/stdio_protocol_contracts.rs b/crates/codestory-cli/tests/stdio_protocol_contracts.rs index 9d113d61e..4ae02b5b6 100644 --- a/crates/codestory-cli/tests/stdio_protocol_contracts.rs +++ b/crates/codestory-cli/tests/stdio_protocol_contracts.rs @@ -722,6 +722,22 @@ fn tool_output_schema<'a>(tools: &'a Value, name: &str) -> &'a Value { .unwrap_or_else(|| panic!("tool {name} should include outputSchema: {tools}")) } +fn required_on_any_branch(schema: &Value, field: &str) -> bool { + schema + .get("required") + .and_then(Value::as_array) + .into_iter() + .flatten() + .any(|value| value.as_str() == Some(field)) + || schema["anyOf"].as_array().is_some_and(|any_of| { + any_of.iter().any(|branch| { + branch["required"].as_array().is_some_and(|required| { + required.iter().any(|value| value.as_str() == Some(field)) + }) + }) + }) +} + fn required_fields(schema: &Value) -> BTreeSet<&str> { schema .get("required") @@ -2566,7 +2582,7 @@ fn tool_catalog_exposes_output_schemas_for_stable_dto_backed_tools() { "retrieval_trace_summary", ] { assert!( - required_fields(output_schema).contains(field), + required_on_any_branch(output_schema, field), "packet outputSchema should require {field}: {tool}" ); } @@ -2584,7 +2600,7 @@ fn tool_catalog_exposes_output_schemas_for_stable_dto_backed_tools() { ); for field in ["stats", "coverage", "orientation", "root_symbols", "files"] { assert!( - required_fields(output_schema).contains(field), + required_on_any_branch(output_schema, field), "ground outputSchema should require grounding DTO field {field}: {tool}" ); } @@ -2897,7 +2913,7 @@ fn tool_catalog_exposes_output_schemas_for_stable_dto_backed_tools() { let snippet = tool_output_schema(&tools, "snippet"); for field in ["scope", "requested_context", "snippet_truncated"] { assert!( - required_fields(snippet).contains(field), + required_on_any_branch(snippet, field), "snippet outputSchema should require emitted DTO field {field}: {snippet}" ); let _ = schema_property(snippet, field); diff --git a/crates/codestory-contracts/Cargo.toml b/crates/codestory-contracts/Cargo.toml index 819903468..883021f9e 100644 --- a/crates/codestory-contracts/Cargo.toml +++ b/crates/codestory-contracts/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codestory-contracts" -version = "0.17.4" +version = "0.17.5" edition = "2024" [dependencies] diff --git a/crates/codestory-contracts/src/api/dto.rs b/crates/codestory-contracts/src/api/dto.rs index 6fb426503..814daebfd 100644 --- a/crates/codestory-contracts/src/api/dto.rs +++ b/crates/codestory-contracts/src/api/dto.rs @@ -1319,7 +1319,7 @@ pub struct IndexedFilesDto { pub summary: IndexedFilesSummaryDto, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub coverage_gaps: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[serde(default)] pub policy_exclusions: Vec, pub files: Vec, } diff --git a/crates/codestory-indexer/Cargo.toml b/crates/codestory-indexer/Cargo.toml index dea67fead..8fa544d7f 100644 --- a/crates/codestory-indexer/Cargo.toml +++ b/crates/codestory-indexer/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codestory-indexer" -version = "0.17.4" +version = "0.17.5" edition = "2024" [dev-dependencies] diff --git a/crates/codestory-llama-sys/Cargo.toml b/crates/codestory-llama-sys/Cargo.toml index 2629d75fe..deaf87d06 100644 --- a/crates/codestory-llama-sys/Cargo.toml +++ b/crates/codestory-llama-sys/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codestory-llama-sys" -version = "0.17.4" +version = "0.17.5" edition = "2024" build = "build.rs" diff --git a/crates/codestory-llama-sys/model-contract.json b/crates/codestory-llama-sys/model-contract.json index 01a576cf2..37a7504d3 100644 --- a/crates/codestory-llama-sys/model-contract.json +++ b/crates/codestory-llama-sys/model-contract.json @@ -37,7 +37,7 @@ "producer": { "name": "codestory-llama-sys", "embedding_revision": "0.16.1", - "version": "0.17.4" + "version": "0.17.5" }, "license": { "spdx_id": "MIT", diff --git a/crates/codestory-llama-sys/per-user-embedding-server-constant-set.json b/crates/codestory-llama-sys/per-user-embedding-server-constant-set.json index 117b57fdb..7992106cf 100644 --- a/crates/codestory-llama-sys/per-user-embedding-server-constant-set.json +++ b/crates/codestory-llama-sys/per-user-embedding-server-constant-set.json @@ -7,7 +7,7 @@ }, "connect_timeout_ms": 2000, "election_backoff_policy": { - "initial_backoff_ms": 9, + "initial_backoff_ms": 7, "jitter": "sha256(process_start_id||attempt) modulo inclusive [initial_backoff_ms,maximum_backoff_ms]", "maximum_backoff_ms": 109 }, @@ -44,20 +44,20 @@ "true_idle_observation_grace_ms": 2500 }, "freeze_record": { - "calibration_bundle_sha256": "60ef4b9acb4e424790aab0ad4011876bae1e7585382c014f982937f29957a0f8", - "calibration_freeze_digest": "97d702bb9cd5c8c869bc961616f20c89fa323fa9c1ce10d8a069f92e06eb7611", - "input_constant_set_sha256": "d8e3e6d53375a5982e2cd222fbeddc71e0ed0541fb9ea5b73772281a69e32666", + "calibration_bundle_sha256": "5cd2285a1f363916074db14cdac52d5b1953a444dc54b52275352fdedc38b580", + "calibration_freeze_digest": "aac841c0d94870e78ca6a8e27fd5972e4eedf4ab4aaca27a4a10215ef6cd1c17", + "input_constant_set_sha256": "2c4157ce273fd8b6bd3cf7764c53b51baec66874217f07868c6350e3072aa0ab", "measurement_protocol_sha256": "9eb303b6204ca6218e97ae299356370b16921a485350146fe85203a298e59269", "protocol_sha256": "25373a136cc1da119ed6829769b90aab075084406b0489f9f7698b370f5ee447", "run_artifact_sha256s": [ - "8cd22e3b2eb0aa90d3b73a6e062b385d2635f031de71462f519173e782ff09d3", - "df552b562ae810a465e269c33c0c8dd6dd09e1affe727925a5282a5eaf4b3936", - "f68c46ad178e3a7c6cef173e02f69ca92a7dc25f58c469095fd5e49ae76a2178" + "3b82164f955e8712f019ada3e9f0d41b1b283ccca00a00c476b0aead84b878d4", + "91e0a833ef7cd33d2332dad2a48431a991e602c02aba947ed7745127b9008a05", + "b1fb4e7b02fe20da28baa111335950ff1affa5add42372b6367a5c871a96b281" ], - "selected_at": "github-actions-run:32506057991:1", + "selected_at": "github-actions-run:32582114363:1", "selection_rule": "constant_only_three_fresh_generations_one_sample_each+slow_host_floors_v2", - "selection_source_commit": "9f982d76cb47b45805a29982cdeae670684ca72a", - "selection_source_tree": "442e70b55c83c709b14a322515e54a33ddac04be" + "selection_source_commit": "e19bc09b3e50e75eb599be8c1e2620bfb96c83d8", + "selection_source_tree": "60a29b5057f4244d3564e7b3ca529bc22e31c656" }, "qualification_threshold_overrides": { "protected_windows_x64_vulkan": { diff --git a/crates/codestory-retrieval/Cargo.toml b/crates/codestory-retrieval/Cargo.toml index fb175bdad..c8785654b 100644 --- a/crates/codestory-retrieval/Cargo.toml +++ b/crates/codestory-retrieval/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codestory-retrieval" -version = "0.17.4" +version = "0.17.5" edition = "2024" [features] diff --git a/crates/codestory-runtime/Cargo.toml b/crates/codestory-runtime/Cargo.toml index 2b1b7d681..a95c5fd31 100644 --- a/crates/codestory-runtime/Cargo.toml +++ b/crates/codestory-runtime/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codestory-runtime" -version = "0.17.4" +version = "0.17.5" edition = "2024" [features] diff --git a/crates/codestory-store/Cargo.toml b/crates/codestory-store/Cargo.toml index b2ecd85eb..83c4a44d7 100644 --- a/crates/codestory-store/Cargo.toml +++ b/crates/codestory-store/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codestory-store" -version = "0.17.4" +version = "0.17.5" edition = "2024" [dependencies] diff --git a/crates/codestory-workspace/Cargo.toml b/crates/codestory-workspace/Cargo.toml index a840a9f76..202369801 100644 --- a/crates/codestory-workspace/Cargo.toml +++ b/crates/codestory-workspace/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codestory-workspace" -version = "0.17.4" +version = "0.17.5" edition = "2024" [features] diff --git a/docs/contributors/documentation-maintenance-checklist.md b/docs/contributors/documentation-maintenance-checklist.md index d74ac60c5..886524fd3 100644 --- a/docs/contributors/documentation-maintenance-checklist.md +++ b/docs/contributors/documentation-maintenance-checklist.md @@ -18,7 +18,7 @@ every host guide. | Architecture | `docs/architecture/` | Components, ownership, data flow, invariants, failure boundaries | | Contributor workflow | `docs/contributors/` | Current branch/worktree, owning crate, smallest proof | | Test and release claims | `docs/contributors/testing-matrix.md` and `docs/testing/` | Exact commands, proof tiers, evidence records | -| Release authority and operator sequence | `docs/contributors/release-runbook.md` | Own the sequence, approval boundaries, invalidation, and evidence handoff; link to machine policy for changing lanes and cells | +| Release authority and operator sequence | `docs/contributors/release-runbook.md` | Own the sequence, approval boundaries, invalidation, and evidence handoff; the operator interface is `scripts/codestory-release.mjs`; link to machine policy for changing lanes and cells | | Dated review evidence | `docs/testing/architecture-review-*.md`, `docs/testing/code-review-*.md` | Pin an exact date and commit; label it as a snapshot rather than current architecture or live issue state | | Maintainer operations | `docs/ops/` | Diagnostics and bounded recovery; link user-facing recovery back to troubleshooting | | Agent behavior | `plugins/codestory/skills/codestory-grounding/` | MCP intent, retry, evidence, and failure contracts | diff --git a/docs/contributors/release-runbook.md b/docs/contributors/release-runbook.md index 01119964f..86987fb58 100644 --- a/docs/contributors/release-runbook.md +++ b/docs/contributors/release-runbook.md @@ -1,8 +1,25 @@ # Release runbook -Use this page to move one accepted CodeStory tree through freeze, promotion, -publication, and closeout. It owns operator sequence and authority boundaries, -not the changing proof matrix. +Use this page to understand freeze, promotion, publication, and closeout. It +owns sequence and authority boundaries, not the changing proof matrix. The +operator interface is `scripts/codestory-release.mjs`; do not assemble a +release-driver receipt by hand. + +```sh +node scripts/codestory-release.mjs start --version --lane native +node scripts/codestory-release.mjs status +node scripts/codestory-release.mjs advance +node scripts/codestory-release.mjs resume +``` + +`status` reconstructs phase from GitHub. `advance` dispatches only the next +permitted workflow and is a no-op while that workflow is in flight. Promotion +and publication require `--record-approval --approver `. Rehearse the +machine against live GitHub without tagging or `publish_release: true`: + +```sh +node scripts/codestory-release.mjs start --version --lane native --rehearse +``` The [repository release rules](../../AGENTS.md#release-rules) own policy, the [testing matrix](testing-matrix.md#workflow-and-release-automation) owns proof @@ -60,7 +77,7 @@ integration head | State | Entry condition | Exit condition | | --- | --- | --- | | Integrated | Every release blocker is merged to `dev/codestory-next`; focused checks are green; no source or workflow change remains planned | Record the exact dev commit and tree, reconcile the control plane and proof hosts, and begin the merge moratorium | -| Stabilized calibration source | One open same-repository release PR into dev contains the approved version bump and current dev ancestry | Exact-head review, hostile mutations, generalization checks, full Linux workspace compile/lint/test, and Windows source contracts pass in one source-stabilization run | +| Stabilized calibration source | `dev/codestory-next` is already the approved versioned head `C`, or one same-repository release PR into dev still carries that exact head | Exact-head review, hostile mutations, generalization checks, full Linux workspace compile/lint/test, and Windows source contracts pass in one source-stabilization run | | Frozen candidate | Exactly three fresh protected Apple-Silicon Metal calibration runs are assembled, and the direct child of the stabilized source changes only `crates/codestory-llama-sys/per-user-embedding-server-constant-set.json` | Reaccept the exact generated head through lineage, hostile mutations, calibration-specific checks, and native probes, then complete required package, platform, and qualification evidence without repeating the workspace suite | | Promotion ready | The reviewed release PR can fast-forward dev to the exact frozen candidate | Fast-forward dev to the frozen head without an intervening merge commit, accept the graph-declared pre-publish ledger from that live dev head, record the combined approval, then create the sole tree-preserving dev-to-main promotion commit with the frozen head as a direct parent | | Published | The automatic main release creates the tag, release, archives, checksums, notes, and closeout summary | The graph-declared post-publish ledger accepts current downloads, installed runtimes, and live behavior | @@ -72,7 +89,7 @@ Do not skip states or reconstruct them from memory. Confirm all of the following before starting the version change: -- the release PR contains the current `dev/codestory-next` head; +- `dev/codestory-next` is the intended calibration-source head, or the release PR still carries that exact head; - the worktree is clean and every commit is pushed; - all planned source, workflow, policy, documentation, and telemetry changes are already integrated; @@ -99,12 +116,14 @@ Do not edit version surfaces by hand. ## Freeze and qualification -Run source stabilization on the bumped release-PR head before calibration. It -owns the one broad source proof and runs the hostile mutations, retrieval +Run source stabilization on the bumped head before calibration. It owns the +one broad source proof and runs the hostile mutations, retrieval generalization, Linux all-target/all-feature lint plus the complete workspace test, and Windows source contracts concurrently. The canonical dispatch uses `source-proof.yml` with `acceptance_only=true` and -`acceptance_phase=source_stabilization`, bound to exact commit `C`. Calibration +`acceptance_phase=source_stabilization`, bound to exact commit `C`. When +`dev/codestory-next` already is `C`, omit `pr_number` and dispatch with +`--ref dev/codestory-next`. GitHub will not keep an empty PR open. Calibration cannot start until that run publishes its authenticated receipt. Collect exactly three fresh protected Apple-Silicon Metal calibration runs with @@ -134,15 +153,14 @@ inventory. ## Evidence handoff -Keep one release record. Initialize it once, record each field group from a JSON -file or inline JSON, and inspect it without reconstructing state from workflow -pages: +Keep one GitHub-backed release record. The coordinator writes +`codestory.release-driver-receipt/v1` groups onto the release issue or release +PR so an interrupted agent can `resume` without copying run IDs. The receipt +CLI remains the storage schema, not the operator interface: ```sh -node .github/scripts/release-driver-receipt.mjs init \ - --version 0.17.0 --receipt release-driver-receipt.json -node .github/scripts/release-driver-receipt.mjs record \ - --receipt release-driver-receipt.json --data-file .json +node scripts/codestory-release.mjs start --version --lane native +node scripts/codestory-release.mjs status node .github/scripts/release-driver-receipt.mjs show \ --receipt release-driver-receipt.json ``` diff --git a/plugins/codestory/.claude-plugin/plugin.json b/plugins/codestory/.claude-plugin/plugin.json index 75ba58189..caa6ef3f5 100644 --- a/plugins/codestory/.claude-plugin/plugin.json +++ b/plugins/codestory/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codestory", - "version": "0.17.4", + "version": "0.17.5", "description": "CodeStory grounding for coding agents over the local codestory-cli runtime.", "author": { "name": "The Green Cedar", diff --git a/plugins/codestory/.codex-plugin/plugin.json b/plugins/codestory/.codex-plugin/plugin.json index 687034037..563afb8ca 100644 --- a/plugins/codestory/.codex-plugin/plugin.json +++ b/plugins/codestory/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codestory", - "version": "0.17.4", + "version": "0.17.5", "description": "CodeStory grounding for Codex over the local codestory-cli stdio server.", "author": { "name": "The Green Cedar", diff --git a/plugins/codestory/.cursor-plugin/plugin.json b/plugins/codestory/.cursor-plugin/plugin.json index 2f819dab3..5bb0315d3 100644 --- a/plugins/codestory/.cursor-plugin/plugin.json +++ b/plugins/codestory/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codestory", - "version": "0.17.4", + "version": "0.17.5", "description": "CodeStory grounding for Cursor over the local codestory-cli runtime.", "author": { "name": "The Green Cedar" diff --git a/plugins/codestory/.github/plugin/plugin.json b/plugins/codestory/.github/plugin/plugin.json index cbd42813d..176ec8afd 100644 --- a/plugins/codestory/.github/plugin/plugin.json +++ b/plugins/codestory/.github/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "codestory", "description": "CodeStory grounding for coding agents over the local codestory-cli runtime.", - "version": "0.17.4", + "version": "0.17.5", "author": { "name": "The Green Cedar", "url": "https://github.com/TheGreenCedar" diff --git a/plugins/codestory/cli-version.json b/plugins/codestory/cli-version.json index 14f55202b..a1b8cf007 100644 --- a/plugins/codestory/cli-version.json +++ b/plugins/codestory/cli-version.json @@ -1,5 +1,5 @@ { "schema_version": 1, - "cli_version": "0.17.4", - "release_tag": "v0.17.4" + "cli_version": "0.17.5", + "release_tag": "v0.17.5" } diff --git a/plugins/codestory/generated-mcp-catalog.json b/plugins/codestory/generated-mcp-catalog.json index 16bab9c85..c83d30c13 100644 --- a/plugins/codestory/generated-mcp-catalog.json +++ b/plugins/codestory/generated-mcp-catalog.json @@ -34,12 +34,37 @@ "name": "status", "outputSchema": { "additionalProperties": false, + "anyOf": [ + { + "required": [ + "project", + "state", + "capabilities", + "next_action", + "diagnostics_uri" + ] + }, + { + "required": [ + "code", + "message" + ] + } + ], "description": "Compact capability state. Read codestory://status{?project} with the same absolute project root when full diagnostics are needed.", "properties": { "capabilities": { "description": "Local navigation and broad-search states.", "type": "object" }, + "cause_code": { + "description": "Underlying activation or cache cause code.", + "type": "string" + }, + "code": { + "description": "Typed stdio retry or unavailable code.", + "type": "string" + }, "current_operation": { "description": "Current managed preparation operation.", "type": [ @@ -54,6 +79,13 @@ "null" ] }, + "details": { + "description": "Structured API error repair guidance.", + "type": [ + "object", + "null" + ] + }, "diagnostics_uri": { "description": "Optional full diagnostic resource URI.", "type": "string" @@ -69,14 +101,56 @@ "description": "Whether packet/search may use full retrieval without a degraded reason.", "type": "boolean" }, + "message": { + "description": "Human-readable retry or unavailable message.", + "type": "string" + }, "next_action": { "description": "Direct next action for the caller.", "type": "string" }, + "operation": { + "description": "Current managed preparation operation.", + "type": [ + "object", + "null" + ] + }, "project": { "description": "Requested repository root.", "type": "string" }, + "recommended_next_calls": { + "description": "Host-executable retries of the intended tool.", + "items": { + "additionalProperties": false, + "description": "Host-executable retry of the same tool after a preparing delay.", + "properties": { + "after_ms": { + "description": "Delay before retry.", + "type": "integer" + }, + "arguments": { + "description": "Original tool arguments.", + "type": "object" + }, + "method": { + "description": "JSON-RPC method.", + "type": "string" + }, + "tool": { + "description": "Tool to retry.", + "type": "string" + } + }, + "required": [ + "method", + "tool" + ], + "type": "object" + }, + "type": "array" + }, "retrieval_mode": { "description": "Pinned retrieval publication class; full is eligibility, not live-ready.", "type": [ @@ -91,6 +165,13 @@ "null" ] }, + "retry_tool": { + "description": "Tool to retry when the envelope is preparing.", + "type": [ + "string", + "null" + ] + }, "state": { "description": "Overall capability state.", "enum": [ @@ -101,15 +182,13 @@ "unavailable" ], "type": "string" + }, + "tool": { + "description": "Tool that produced this envelope.", + "type": "string" } }, - "required": [ - "project", - "state", - "capabilities", - "next_action", - "diagnostics_uri" - ], + "required": [], "type": "object" }, "safety": { @@ -665,6 +744,26 @@ "name": "packet", "outputSchema": { "additionalProperties": false, + "anyOf": [ + { + "required": [ + "packet_id", + "question", + "plan", + "answer", + "budget", + "support", + "disposition", + "retrieval_trace_summary" + ] + }, + { + "required": [ + "code", + "message" + ] + } + ], "description": "CodeStory broad task packet DTO with compiled support units and a machine stop or one-round drill disposition.", "properties": { "answer": { @@ -675,10 +774,44 @@ "description": "Budget limits, usage, and truncation metadata.", "type": "object" }, + "cause_code": { + "description": "Underlying activation or cache cause code.", + "type": "string" + }, + "code": { + "description": "Typed stdio retry or unavailable code.", + "type": "string" + }, + "details": { + "description": "Structured API error repair guidance.", + "type": [ + "object", + "null" + ] + }, + "diagnostics_uri": { + "description": "Optional full diagnostic resource URI.", + "type": "string" + }, "disposition": { "description": "Machine stop or one-round drill decision: supported, drill_once, not_established, or unavailable.", "type": "object" }, + "message": { + "description": "Human-readable retry or unavailable message.", + "type": "string" + }, + "next_action": { + "description": "Direct next action for the caller.", + "type": "string" + }, + "operation": { + "description": "Current managed preparation operation.", + "type": [ + "object", + "null" + ] + }, "packet_id": { "description": "Stable packet id.", "type": "string" @@ -691,10 +824,59 @@ "description": "Packet question.", "type": "string" }, + "recommended_next_calls": { + "description": "Host-executable retries of the intended tool.", + "items": { + "additionalProperties": false, + "description": "Host-executable retry of the same tool after a preparing delay.", + "properties": { + "after_ms": { + "description": "Delay before retry.", + "type": "integer" + }, + "arguments": { + "description": "Original tool arguments.", + "type": "object" + }, + "method": { + "description": "JSON-RPC method.", + "type": "string" + }, + "tool": { + "description": "Tool to retry.", + "type": "string" + } + }, + "required": [ + "method", + "tool" + ], + "type": "object" + }, + "type": "array" + }, "retrieval_trace_summary": { "description": "Compact retrieval trace telemetry summary.", "type": "object" }, + "retry_after_ms": { + "description": "Retry delay while preparing.", + "type": [ + "integer", + "null" + ] + }, + "retry_tool": { + "description": "Tool to retry when the envelope is preparing.", + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "preparing, unavailable, or cancelled.", + "type": "string" + }, "support": { "description": "Compiled evidence atoms: symbol locations, source ranges, typed graph edges, and complete-query negatives.", "items": { @@ -780,18 +962,13 @@ "string", "null" ] + }, + "tool": { + "description": "Tool that produced this envelope.", + "type": "string" } }, - "required": [ - "packet_id", - "question", - "plan", - "answer", - "budget", - "support", - "disposition", - "retrieval_trace_summary" - ], + "required": [], "type": "object" }, "safety": { @@ -876,6 +1053,10 @@ ], "description": "CodeStory discovery results DTO. Treat broad structural questions as packet-first; search rows select candidates for proof-bearing graph/source follow-up.", "properties": { + "cause_code": { + "description": "Underlying activation or cache cause code.", + "type": "string" + }, "code": { "description": "Typed API error code.", "type": "string" @@ -891,6 +1072,10 @@ "null" ] }, + "diagnostics_uri": { + "description": "Optional full diagnostic resource URI.", + "type": "string" + }, "hits": { "description": "Merged hit list.", "items": { @@ -1060,6 +1245,17 @@ "description": "Human-readable API error message.", "type": "string" }, + "next_action": { + "description": "Direct next action for the caller.", + "type": "string" + }, + "operation": { + "description": "Current managed preparation operation.", + "type": [ + "object", + "null" + ] + }, "query": { "description": "Search query.", "type": "string" @@ -1071,6 +1267,37 @@ "null" ] }, + "recommended_next_calls": { + "description": "Host-executable retries of the intended tool.", + "items": { + "additionalProperties": false, + "description": "Host-executable retry of the same tool after a preparing delay.", + "properties": { + "after_ms": { + "description": "Delay before retry.", + "type": "integer" + }, + "arguments": { + "description": "Original tool arguments.", + "type": "object" + }, + "method": { + "description": "JSON-RPC method.", + "type": "string" + }, + "tool": { + "description": "Tool to retry.", + "type": "string" + } + }, + "required": [ + "method", + "tool" + ], + "type": "object" + }, + "type": "array" + }, "repo_text_enabled": { "description": "Whether repo text search was enabled.", "type": "boolean" @@ -1094,6 +1321,28 @@ "retrieval": { "description": "Retrieval readiness.", "type": "object" + }, + "retry_after_ms": { + "description": "Retry delay while preparing.", + "type": [ + "integer", + "null" + ] + }, + "retry_tool": { + "description": "Tool to retry when the envelope is preparing.", + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "preparing, unavailable, or cancelled.", + "type": "string" + }, + "tool": { + "description": "Tool that produced this envelope.", + "type": "string" } }, "required": [], @@ -1148,6 +1397,26 @@ "name": "ground", "outputSchema": { "additionalProperties": false, + "anyOf": [ + { + "required": [ + "root", + "budget", + "generated_at_epoch_ms", + "stats", + "coverage", + "orientation", + "root_symbols", + "files" + ] + }, + { + "required": [ + "code", + "message" + ] + } + ], "description": "CodeStory grounding snapshot DTO for compact repository orientation.", "properties": { "budget": { @@ -1159,6 +1428,14 @@ ], "type": "string" }, + "cause_code": { + "description": "Underlying activation or cache cause code.", + "type": "string" + }, + "code": { + "description": "Typed stdio retry or unavailable code.", + "type": "string" + }, "coverage": { "description": "Grounding coverage summary.", "type": "object" @@ -1174,6 +1451,17 @@ }, "type": "array" }, + "details": { + "description": "Structured API error repair guidance.", + "type": [ + "object", + "null" + ] + }, + "diagnostics_uri": { + "description": "Optional full diagnostic resource URI.", + "type": "string" + }, "files": { "description": "File digests.", "items": { @@ -1189,6 +1477,14 @@ "description": "Snapshot generation time.", "type": "integer" }, + "message": { + "description": "Human-readable retry or unavailable message.", + "type": "string" + }, + "next_action": { + "description": "Direct next action for the caller.", + "type": "string" + }, "notes": { "description": "Grounding notes.", "items": { @@ -1196,6 +1492,13 @@ }, "type": "array" }, + "operation": { + "description": "Current managed preparation operation.", + "type": [ + "object", + "null" + ] + }, "orientation": { "additionalProperties": false, "description": "Typed architecture-orientation confidence and uncertainty.", @@ -1262,6 +1565,37 @@ ], "type": "object" }, + "recommended_next_calls": { + "description": "Host-executable retries of the intended tool.", + "items": { + "additionalProperties": false, + "description": "Host-executable retry of the same tool after a preparing delay.", + "properties": { + "after_ms": { + "description": "Delay before retry.", + "type": "integer" + }, + "arguments": { + "description": "Original tool arguments.", + "type": "object" + }, + "method": { + "description": "JSON-RPC method.", + "type": "string" + }, + "tool": { + "description": "Tool to retry.", + "type": "string" + } + }, + "required": [ + "method", + "tool" + ], + "type": "object" + }, + "type": "array" + }, "recommended_queries": { "description": "Suggested follow-up queries.", "items": { @@ -1269,11 +1603,25 @@ }, "type": "array" }, - "root": { - "description": "Project root.", - "type": "string" - }, - "root_symbols": { + "retry_after_ms": { + "description": "Retry delay while preparing.", + "type": [ + "integer", + "null" + ] + }, + "retry_tool": { + "description": "Tool to retry when the envelope is preparing.", + "type": [ + "string", + "null" + ] + }, + "root": { + "description": "Project root.", + "type": "string" + }, + "root_symbols": { "description": "Root symbol digests.", "items": { "additionalProperties": true, @@ -1284,21 +1632,20 @@ }, "type": "array" }, + "state": { + "description": "preparing, unavailable, or cancelled.", + "type": "string" + }, "stats": { "description": "Indexed project stats.", "type": "object" + }, + "tool": { + "description": "Tool that produced this envelope.", + "type": "string" } }, - "required": [ - "root", - "budget", - "generated_at_epoch_ms", - "stats", - "coverage", - "orientation", - "root_symbols", - "files" - ], + "required": [], "type": "object" }, "safety": { @@ -1385,6 +1732,10 @@ ], "description": "Indexed file inventory and coverage summary.", "properties": { + "cause_code": { + "description": "Underlying activation or cache cause code.", + "type": "string" + }, "code": { "description": "Typed API error code.", "type": "string" @@ -1444,6 +1795,10 @@ "null" ] }, + "diagnostics_uri": { + "description": "Optional full diagnostic resource URI.", + "type": "string" + }, "files": { "description": "Indexed file rows.", "items": { @@ -1502,6 +1857,17 @@ "description": "Human-readable API error message.", "type": "string" }, + "next_action": { + "description": "Direct next action for the caller.", + "type": "string" + }, + "operation": { + "description": "Current managed preparation operation.", + "type": [ + "object", + "null" + ] + }, "policy_exclusions": { "description": "Verified policy exclusions without graph or semantic coverage.", "items": { @@ -1596,10 +1962,63 @@ "description": "Project root.", "type": "string" }, + "recommended_next_calls": { + "description": "Host-executable retries of the intended tool.", + "items": { + "additionalProperties": false, + "description": "Host-executable retry of the same tool after a preparing delay.", + "properties": { + "after_ms": { + "description": "Delay before retry.", + "type": "integer" + }, + "arguments": { + "description": "Original tool arguments.", + "type": "object" + }, + "method": { + "description": "JSON-RPC method.", + "type": "string" + }, + "tool": { + "description": "Tool to retry.", + "type": "string" + } + }, + "required": [ + "method", + "tool" + ], + "type": "object" + }, + "type": "array" + }, + "retry_after_ms": { + "description": "Retry delay while preparing.", + "type": [ + "integer", + "null" + ] + }, + "retry_tool": { + "description": "Tool to retry when the envelope is preparing.", + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "preparing, unavailable, or cancelled.", + "type": "string" + }, "summary": { "description": "Indexed file summary DTO.", "type": "object" }, + "tool": { + "description": "Tool that produced this envelope.", + "type": "string" + }, "usable": { "description": "Whether the index has usable files.", "type": "boolean" @@ -1812,6 +2231,10 @@ ], "type": "object" }, + "cause_code": { + "description": "Underlying activation or cache cause code.", + "type": "string" + }, "change_records": { "description": "Normalized changed file records.", "items": { @@ -1939,6 +2362,10 @@ "null" ] }, + "diagnostics_uri": { + "description": "Optional full diagnostic resource URI.", + "type": "string" + }, "follow_ups": { "description": "Evidence-derived follow-up actions with optional structured invocations.", "items": { @@ -2109,6 +2536,10 @@ "description": "Human-readable API error message.", "type": "string" }, + "next_action": { + "description": "Direct next action for the caller.", + "type": "string" + }, "notes": { "description": "Additional analysis notes.", "items": { @@ -2116,10 +2547,70 @@ }, "type": "array" }, + "operation": { + "description": "Current managed preparation operation.", + "type": [ + "object", + "null" + ] + }, "project_root": { "description": "Project root.", "type": "string" }, + "recommended_next_calls": { + "description": "Host-executable retries of the intended tool.", + "items": { + "additionalProperties": false, + "description": "Host-executable retry of the same tool after a preparing delay.", + "properties": { + "after_ms": { + "description": "Delay before retry.", + "type": "integer" + }, + "arguments": { + "description": "Original tool arguments.", + "type": "object" + }, + "method": { + "description": "JSON-RPC method.", + "type": "string" + }, + "tool": { + "description": "Tool to retry.", + "type": "string" + } + }, + "required": [ + "method", + "tool" + ], + "type": "object" + }, + "type": "array" + }, + "retry_after_ms": { + "description": "Retry delay while preparing.", + "type": [ + "integer", + "null" + ] + }, + "retry_tool": { + "description": "Tool to retry when the envelope is preparing.", + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "preparing, unavailable, or cancelled.", + "type": "string" + }, + "tool": { + "description": "Tool that produced this envelope.", + "type": "string" + }, "truncated": { "description": "Whether any result collection was capped.", "type": "boolean" @@ -2315,8 +2806,28 @@ "name": "symbol", "outputSchema": { "additionalProperties": false, + "anyOf": [ + { + "required": [ + "node", + "children", + "related_hits", + "edge_digest" + ] + }, + { + "required": [ + "code", + "message" + ] + } + ], "description": "CodeStory symbol context DTO.", "properties": { + "cause_code": { + "description": "Underlying activation or cache cause code.", + "type": "string" + }, "children": { "description": "Child symbol summaries.", "items": { @@ -2358,6 +2869,21 @@ }, "type": "array" }, + "code": { + "description": "Typed stdio retry or unavailable code.", + "type": "string" + }, + "details": { + "description": "Structured API error repair guidance.", + "type": [ + "object", + "null" + ] + }, + "diagnostics_uri": { + "description": "Optional full diagnostic resource URI.", + "type": "string" + }, "edge_digest": { "description": "Readable edge digest entries.", "items": { @@ -2365,10 +2891,56 @@ }, "type": "array" }, + "message": { + "description": "Human-readable retry or unavailable message.", + "type": "string" + }, + "next_action": { + "description": "Direct next action for the caller.", + "type": "string" + }, "node": { "description": "Node details DTO.", "type": "object" }, + "operation": { + "description": "Current managed preparation operation.", + "type": [ + "object", + "null" + ] + }, + "recommended_next_calls": { + "description": "Host-executable retries of the intended tool.", + "items": { + "additionalProperties": false, + "description": "Host-executable retry of the same tool after a preparing delay.", + "properties": { + "after_ms": { + "description": "Delay before retry.", + "type": "integer" + }, + "arguments": { + "description": "Original tool arguments.", + "type": "object" + }, + "method": { + "description": "JSON-RPC method.", + "type": "string" + }, + "tool": { + "description": "Tool to retry.", + "type": "string" + } + }, + "required": [ + "method", + "tool" + ], + "type": "object" + }, + "type": "array" + }, "related_hits": { "description": "Related search hits.", "items": { @@ -2530,20 +3102,37 @@ }, "type": "array" }, + "retry_after_ms": { + "description": "Retry delay while preparing.", + "type": [ + "integer", + "null" + ] + }, + "retry_tool": { + "description": "Tool to retry when the envelope is preparing.", + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "preparing, unavailable, or cancelled.", + "type": "string" + }, "summary": { "description": "Optional generated summary.", "type": [ "string", "null" ] + }, + "tool": { + "description": "Tool that produced this envelope.", + "type": "string" } }, - "required": [ - "node", - "children", - "related_hits", - "edge_digest" - ], + "required": [], "type": "object" }, "safety": { @@ -2642,33 +3231,131 @@ "name": "trail", "outputSchema": { "additionalProperties": false, + "anyOf": [ + { + "required": [ + "focus", + "trail" + ] + }, + { + "required": [ + "code", + "message" + ] + } + ], "description": "CodeStory trail context DTO.", "properties": { + "cause_code": { + "description": "Underlying activation or cache cause code.", + "type": "string" + }, + "code": { + "description": "Typed stdio retry or unavailable code.", + "type": "string" + }, + "details": { + "description": "Structured API error repair guidance.", + "type": [ + "object", + "null" + ] + }, + "diagnostics_uri": { + "description": "Optional full diagnostic resource URI.", + "type": "string" + }, "focus": { "description": "Focused node details DTO.", "type": "object" }, - "story": { - "description": "Optional readable trail story DTO.", + "message": { + "description": "Human-readable retry or unavailable message.", + "type": "string" + }, + "next_action": { + "description": "Direct next action for the caller.", + "type": "string" + }, + "operation": { + "description": "Current managed preparation operation.", "type": [ "object", "null" ] }, - "trail": { - "description": "Graph response DTO.", - "type": "object" - } - }, - "required": [ - "focus", - "trail" - ], - "type": "object" - }, - "safety": { - "activatesProject": true, - "destructive": false, + "recommended_next_calls": { + "description": "Host-executable retries of the intended tool.", + "items": { + "additionalProperties": false, + "description": "Host-executable retry of the same tool after a preparing delay.", + "properties": { + "after_ms": { + "description": "Delay before retry.", + "type": "integer" + }, + "arguments": { + "description": "Original tool arguments.", + "type": "object" + }, + "method": { + "description": "JSON-RPC method.", + "type": "string" + }, + "tool": { + "description": "Tool to retry.", + "type": "string" + } + }, + "required": [ + "method", + "tool" + ], + "type": "object" + }, + "type": "array" + }, + "retry_after_ms": { + "description": "Retry delay while preparing.", + "type": [ + "integer", + "null" + ] + }, + "retry_tool": { + "description": "Tool to retry when the envelope is preparing.", + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "preparing, unavailable, or cancelled.", + "type": "string" + }, + "story": { + "description": "Optional readable trail story DTO.", + "type": [ + "object", + "null" + ] + }, + "tool": { + "description": "Tool that produced this envelope.", + "type": "string" + }, + "trail": { + "description": "Graph response DTO.", + "type": "object" + } + }, + "required": [], + "type": "object" + }, + "safety": { + "activatesProject": true, + "destructive": false, "effect": "managed_activation", "idempotent": true, "localOnly": false, @@ -2747,12 +3434,49 @@ "name": "callers", "outputSchema": { "additionalProperties": false, + "anyOf": [ + { + "required": [ + "certainty", + "file_refs", + "limits", + "node_count", + "edge_count", + "truncated" + ] + }, + { + "required": [ + "code", + "message" + ] + } + ], "description": "Bounded CodeStory graph primitive output.", "properties": { + "cause_code": { + "description": "Underlying activation or cache cause code.", + "type": "string" + }, "certainty": { "description": "Overall certainty note.", "type": "string" }, + "code": { + "description": "Typed stdio retry or unavailable code.", + "type": "string" + }, + "details": { + "description": "Structured API error repair guidance.", + "type": [ + "object", + "null" + ] + }, + "diagnostics_uri": { + "description": "Optional full diagnostic resource URI.", + "type": "string" + }, "edge_count": { "description": "Returned edge count.", "type": "integer" @@ -2779,6 +3503,14 @@ "description": "Applied bounds for this graph primitive.", "type": "object" }, + "message": { + "description": "Human-readable retry or unavailable message.", + "type": "string" + }, + "next_action": { + "description": "Direct next action for the caller.", + "type": "string" + }, "node": { "description": "Node details DTO.", "type": [ @@ -2790,6 +3522,44 @@ "description": "Returned node count.", "type": "integer" }, + "operation": { + "description": "Current managed preparation operation.", + "type": [ + "object", + "null" + ] + }, + "recommended_next_calls": { + "description": "Host-executable retries of the intended tool.", + "items": { + "additionalProperties": false, + "description": "Host-executable retry of the same tool after a preparing delay.", + "properties": { + "after_ms": { + "description": "Delay before retry.", + "type": "integer" + }, + "arguments": { + "description": "Original tool arguments.", + "type": "object" + }, + "method": { + "description": "JSON-RPC method.", + "type": "string" + }, + "tool": { + "description": "Tool to retry.", + "type": "string" + } + }, + "required": [ + "method", + "tool" + ], + "type": "object" + }, + "type": "array" + }, "resolution": { "description": "Optional query resolution metadata.", "type": [ @@ -2797,19 +3567,34 @@ "null" ] }, + "retry_after_ms": { + "description": "Retry delay while preparing.", + "type": [ + "integer", + "null" + ] + }, + "retry_tool": { + "description": "Tool to retry when the envelope is preparing.", + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "preparing, unavailable, or cancelled.", + "type": "string" + }, + "tool": { + "description": "Tool that produced this envelope.", + "type": "string" + }, "truncated": { "description": "Whether the graph result was truncated.", "type": "boolean" } }, - "required": [ - "certainty", - "file_refs", - "limits", - "node_count", - "edge_count", - "truncated" - ], + "required": [], "type": "object" }, "safety": { @@ -2893,12 +3678,49 @@ "name": "callees", "outputSchema": { "additionalProperties": false, + "anyOf": [ + { + "required": [ + "certainty", + "file_refs", + "limits", + "node_count", + "edge_count", + "truncated" + ] + }, + { + "required": [ + "code", + "message" + ] + } + ], "description": "Bounded CodeStory graph primitive output.", "properties": { + "cause_code": { + "description": "Underlying activation or cache cause code.", + "type": "string" + }, "certainty": { "description": "Overall certainty note.", "type": "string" }, + "code": { + "description": "Typed stdio retry or unavailable code.", + "type": "string" + }, + "details": { + "description": "Structured API error repair guidance.", + "type": [ + "object", + "null" + ] + }, + "diagnostics_uri": { + "description": "Optional full diagnostic resource URI.", + "type": "string" + }, "edge_count": { "description": "Returned edge count.", "type": "integer" @@ -2925,6 +3747,14 @@ "description": "Applied bounds for this graph primitive.", "type": "object" }, + "message": { + "description": "Human-readable retry or unavailable message.", + "type": "string" + }, + "next_action": { + "description": "Direct next action for the caller.", + "type": "string" + }, "node": { "description": "Node details DTO.", "type": [ @@ -2936,6 +3766,44 @@ "description": "Returned node count.", "type": "integer" }, + "operation": { + "description": "Current managed preparation operation.", + "type": [ + "object", + "null" + ] + }, + "recommended_next_calls": { + "description": "Host-executable retries of the intended tool.", + "items": { + "additionalProperties": false, + "description": "Host-executable retry of the same tool after a preparing delay.", + "properties": { + "after_ms": { + "description": "Delay before retry.", + "type": "integer" + }, + "arguments": { + "description": "Original tool arguments.", + "type": "object" + }, + "method": { + "description": "JSON-RPC method.", + "type": "string" + }, + "tool": { + "description": "Tool to retry.", + "type": "string" + } + }, + "required": [ + "method", + "tool" + ], + "type": "object" + }, + "type": "array" + }, "resolution": { "description": "Optional query resolution metadata.", "type": [ @@ -2943,19 +3811,34 @@ "null" ] }, + "retry_after_ms": { + "description": "Retry delay while preparing.", + "type": [ + "integer", + "null" + ] + }, + "retry_tool": { + "description": "Tool to retry when the envelope is preparing.", + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "preparing, unavailable, or cancelled.", + "type": "string" + }, + "tool": { + "description": "Tool that produced this envelope.", + "type": "string" + }, "truncated": { "description": "Whether the graph result was truncated.", "type": "boolean" } }, - "required": [ - "certainty", - "file_refs", - "limits", - "node_count", - "edge_count", - "truncated" - ], + "required": [], "type": "object" }, "safety": { @@ -3054,28 +3937,126 @@ "name": "trace", "outputSchema": { "additionalProperties": false, + "anyOf": [ + { + "required": [ + "focus", + "trail" + ] + }, + { + "required": [ + "code", + "message" + ] + } + ], "description": "CodeStory trail context DTO.", "properties": { + "cause_code": { + "description": "Underlying activation or cache cause code.", + "type": "string" + }, + "code": { + "description": "Typed stdio retry or unavailable code.", + "type": "string" + }, + "details": { + "description": "Structured API error repair guidance.", + "type": [ + "object", + "null" + ] + }, + "diagnostics_uri": { + "description": "Optional full diagnostic resource URI.", + "type": "string" + }, "focus": { "description": "Focused node details DTO.", "type": "object" }, - "story": { - "description": "Optional readable trail story DTO.", + "message": { + "description": "Human-readable retry or unavailable message.", + "type": "string" + }, + "next_action": { + "description": "Direct next action for the caller.", + "type": "string" + }, + "operation": { + "description": "Current managed preparation operation.", "type": [ "object", "null" ] }, - "trail": { - "description": "Graph response DTO.", - "type": "object" - } - }, - "required": [ - "focus", - "trail" - ], + "recommended_next_calls": { + "description": "Host-executable retries of the intended tool.", + "items": { + "additionalProperties": false, + "description": "Host-executable retry of the same tool after a preparing delay.", + "properties": { + "after_ms": { + "description": "Delay before retry.", + "type": "integer" + }, + "arguments": { + "description": "Original tool arguments.", + "type": "object" + }, + "method": { + "description": "JSON-RPC method.", + "type": "string" + }, + "tool": { + "description": "Tool to retry.", + "type": "string" + } + }, + "required": [ + "method", + "tool" + ], + "type": "object" + }, + "type": "array" + }, + "retry_after_ms": { + "description": "Retry delay while preparing.", + "type": [ + "integer", + "null" + ] + }, + "retry_tool": { + "description": "Tool to retry when the envelope is preparing.", + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "preparing, unavailable, or cancelled.", + "type": "string" + }, + "story": { + "description": "Optional readable trail story DTO.", + "type": [ + "object", + "null" + ] + }, + "tool": { + "description": "Tool that produced this envelope.", + "type": "string" + }, + "trail": { + "description": "Graph response DTO.", + "type": "object" + } + }, + "required": [], "type": "object" }, "safety": { @@ -3145,12 +4126,49 @@ "name": "get_node", "outputSchema": { "additionalProperties": false, + "anyOf": [ + { + "required": [ + "certainty", + "file_refs", + "limits", + "node_count", + "edge_count", + "truncated" + ] + }, + { + "required": [ + "code", + "message" + ] + } + ], "description": "Bounded CodeStory graph primitive output.", "properties": { + "cause_code": { + "description": "Underlying activation or cache cause code.", + "type": "string" + }, "certainty": { "description": "Overall certainty note.", "type": "string" }, + "code": { + "description": "Typed stdio retry or unavailable code.", + "type": "string" + }, + "details": { + "description": "Structured API error repair guidance.", + "type": [ + "object", + "null" + ] + }, + "diagnostics_uri": { + "description": "Optional full diagnostic resource URI.", + "type": "string" + }, "edge_count": { "description": "Returned edge count.", "type": "integer" @@ -3177,6 +4195,14 @@ "description": "Applied bounds for this graph primitive.", "type": "object" }, + "message": { + "description": "Human-readable retry or unavailable message.", + "type": "string" + }, + "next_action": { + "description": "Direct next action for the caller.", + "type": "string" + }, "node": { "description": "Node details DTO.", "type": [ @@ -3188,6 +4214,44 @@ "description": "Returned node count.", "type": "integer" }, + "operation": { + "description": "Current managed preparation operation.", + "type": [ + "object", + "null" + ] + }, + "recommended_next_calls": { + "description": "Host-executable retries of the intended tool.", + "items": { + "additionalProperties": false, + "description": "Host-executable retry of the same tool after a preparing delay.", + "properties": { + "after_ms": { + "description": "Delay before retry.", + "type": "integer" + }, + "arguments": { + "description": "Original tool arguments.", + "type": "object" + }, + "method": { + "description": "JSON-RPC method.", + "type": "string" + }, + "tool": { + "description": "Tool to retry.", + "type": "string" + } + }, + "required": [ + "method", + "tool" + ], + "type": "object" + }, + "type": "array" + }, "resolution": { "description": "Optional query resolution metadata.", "type": [ @@ -3195,19 +4259,34 @@ "null" ] }, + "retry_after_ms": { + "description": "Retry delay while preparing.", + "type": [ + "integer", + "null" + ] + }, + "retry_tool": { + "description": "Tool to retry when the envelope is preparing.", + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "preparing, unavailable, or cancelled.", + "type": "string" + }, + "tool": { + "description": "Tool that produced this envelope.", + "type": "string" + }, "truncated": { "description": "Whether the graph result was truncated.", "type": "boolean" } }, - "required": [ - "certainty", - "file_refs", - "limits", - "node_count", - "edge_count", - "truncated" - ], + "required": [], "type": "object" }, "safety": { @@ -3301,12 +4380,49 @@ "name": "neighbors", "outputSchema": { "additionalProperties": false, + "anyOf": [ + { + "required": [ + "certainty", + "file_refs", + "limits", + "node_count", + "edge_count", + "truncated" + ] + }, + { + "required": [ + "code", + "message" + ] + } + ], "description": "Bounded CodeStory graph primitive output.", "properties": { + "cause_code": { + "description": "Underlying activation or cache cause code.", + "type": "string" + }, "certainty": { "description": "Overall certainty note.", "type": "string" }, + "code": { + "description": "Typed stdio retry or unavailable code.", + "type": "string" + }, + "details": { + "description": "Structured API error repair guidance.", + "type": [ + "object", + "null" + ] + }, + "diagnostics_uri": { + "description": "Optional full diagnostic resource URI.", + "type": "string" + }, "edge_count": { "description": "Returned edge count.", "type": "integer" @@ -3333,6 +4449,14 @@ "description": "Applied bounds for this graph primitive.", "type": "object" }, + "message": { + "description": "Human-readable retry or unavailable message.", + "type": "string" + }, + "next_action": { + "description": "Direct next action for the caller.", + "type": "string" + }, "node": { "description": "Node details DTO.", "type": [ @@ -3344,6 +4468,44 @@ "description": "Returned node count.", "type": "integer" }, + "operation": { + "description": "Current managed preparation operation.", + "type": [ + "object", + "null" + ] + }, + "recommended_next_calls": { + "description": "Host-executable retries of the intended tool.", + "items": { + "additionalProperties": false, + "description": "Host-executable retry of the same tool after a preparing delay.", + "properties": { + "after_ms": { + "description": "Delay before retry.", + "type": "integer" + }, + "arguments": { + "description": "Original tool arguments.", + "type": "object" + }, + "method": { + "description": "JSON-RPC method.", + "type": "string" + }, + "tool": { + "description": "Tool to retry.", + "type": "string" + } + }, + "required": [ + "method", + "tool" + ], + "type": "object" + }, + "type": "array" + }, "resolution": { "description": "Optional query resolution metadata.", "type": [ @@ -3351,19 +4513,34 @@ "null" ] }, + "retry_after_ms": { + "description": "Retry delay while preparing.", + "type": [ + "integer", + "null" + ] + }, + "retry_tool": { + "description": "Tool to retry when the envelope is preparing.", + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "preparing, unavailable, or cancelled.", + "type": "string" + }, + "tool": { + "description": "Tool that produced this envelope.", + "type": "string" + }, "truncated": { "description": "Whether the graph result was truncated.", "type": "boolean" } }, - "required": [ - "certainty", - "file_refs", - "limits", - "node_count", - "edge_count", - "truncated" - ], + "required": [], "type": "object" }, "safety": { @@ -3431,12 +4608,49 @@ "name": "shortest_path", "outputSchema": { "additionalProperties": false, + "anyOf": [ + { + "required": [ + "certainty", + "file_refs", + "limits", + "node_count", + "edge_count", + "truncated" + ] + }, + { + "required": [ + "code", + "message" + ] + } + ], "description": "Bounded CodeStory graph primitive output.", "properties": { + "cause_code": { + "description": "Underlying activation or cache cause code.", + "type": "string" + }, "certainty": { "description": "Overall certainty note.", "type": "string" }, + "code": { + "description": "Typed stdio retry or unavailable code.", + "type": "string" + }, + "details": { + "description": "Structured API error repair guidance.", + "type": [ + "object", + "null" + ] + }, + "diagnostics_uri": { + "description": "Optional full diagnostic resource URI.", + "type": "string" + }, "edge_count": { "description": "Returned edge count.", "type": "integer" @@ -3463,6 +4677,14 @@ "description": "Applied bounds for this graph primitive.", "type": "object" }, + "message": { + "description": "Human-readable retry or unavailable message.", + "type": "string" + }, + "next_action": { + "description": "Direct next action for the caller.", + "type": "string" + }, "node": { "description": "Node details DTO.", "type": [ @@ -3474,31 +4696,84 @@ "description": "Returned node count.", "type": "integer" }, - "resolution": { - "description": "Optional query resolution metadata.", + "operation": { + "description": "Current managed preparation operation.", "type": [ "object", "null" ] }, - "truncated": { - "description": "Whether the graph result was truncated.", - "type": "boolean" - } - }, - "required": [ - "certainty", - "file_refs", - "limits", - "node_count", - "edge_count", - "truncated" - ], - "type": "object" - }, - "safety": { - "activatesProject": true, - "destructive": false, + "recommended_next_calls": { + "description": "Host-executable retries of the intended tool.", + "items": { + "additionalProperties": false, + "description": "Host-executable retry of the same tool after a preparing delay.", + "properties": { + "after_ms": { + "description": "Delay before retry.", + "type": "integer" + }, + "arguments": { + "description": "Original tool arguments.", + "type": "object" + }, + "method": { + "description": "JSON-RPC method.", + "type": "string" + }, + "tool": { + "description": "Tool to retry.", + "type": "string" + } + }, + "required": [ + "method", + "tool" + ], + "type": "object" + }, + "type": "array" + }, + "resolution": { + "description": "Optional query resolution metadata.", + "type": [ + "object", + "null" + ] + }, + "retry_after_ms": { + "description": "Retry delay while preparing.", + "type": [ + "integer", + "null" + ] + }, + "retry_tool": { + "description": "Tool to retry when the envelope is preparing.", + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "preparing, unavailable, or cancelled.", + "type": "string" + }, + "tool": { + "description": "Tool that produced this envelope.", + "type": "string" + }, + "truncated": { + "description": "Whether the graph result was truncated.", + "type": "boolean" + } + }, + "required": [], + "type": "object" + }, + "safety": { + "activatesProject": true, + "destructive": false, "effect": "managed_activation", "idempotent": true, "localOnly": false, @@ -3587,12 +4862,49 @@ "name": "query_subgraph", "outputSchema": { "additionalProperties": false, + "anyOf": [ + { + "required": [ + "certainty", + "file_refs", + "limits", + "node_count", + "edge_count", + "truncated" + ] + }, + { + "required": [ + "code", + "message" + ] + } + ], "description": "Bounded CodeStory graph primitive output.", "properties": { + "cause_code": { + "description": "Underlying activation or cache cause code.", + "type": "string" + }, "certainty": { "description": "Overall certainty note.", "type": "string" }, + "code": { + "description": "Typed stdio retry or unavailable code.", + "type": "string" + }, + "details": { + "description": "Structured API error repair guidance.", + "type": [ + "object", + "null" + ] + }, + "diagnostics_uri": { + "description": "Optional full diagnostic resource URI.", + "type": "string" + }, "edge_count": { "description": "Returned edge count.", "type": "integer" @@ -3619,6 +4931,14 @@ "description": "Applied bounds for this graph primitive.", "type": "object" }, + "message": { + "description": "Human-readable retry or unavailable message.", + "type": "string" + }, + "next_action": { + "description": "Direct next action for the caller.", + "type": "string" + }, "node": { "description": "Node details DTO.", "type": [ @@ -3630,6 +4950,44 @@ "description": "Returned node count.", "type": "integer" }, + "operation": { + "description": "Current managed preparation operation.", + "type": [ + "object", + "null" + ] + }, + "recommended_next_calls": { + "description": "Host-executable retries of the intended tool.", + "items": { + "additionalProperties": false, + "description": "Host-executable retry of the same tool after a preparing delay.", + "properties": { + "after_ms": { + "description": "Delay before retry.", + "type": "integer" + }, + "arguments": { + "description": "Original tool arguments.", + "type": "object" + }, + "method": { + "description": "JSON-RPC method.", + "type": "string" + }, + "tool": { + "description": "Tool to retry.", + "type": "string" + } + }, + "required": [ + "method", + "tool" + ], + "type": "object" + }, + "type": "array" + }, "resolution": { "description": "Optional query resolution metadata.", "type": [ @@ -3637,19 +4995,34 @@ "null" ] }, + "retry_after_ms": { + "description": "Retry delay while preparing.", + "type": [ + "integer", + "null" + ] + }, + "retry_tool": { + "description": "Tool to retry when the envelope is preparing.", + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "preparing, unavailable, or cancelled.", + "type": "string" + }, + "tool": { + "description": "Tool that produced this envelope.", + "type": "string" + }, "truncated": { "description": "Whether the graph result was truncated.", "type": "boolean" } }, - "required": [ - "certainty", - "file_refs", - "limits", - "node_count", - "edge_count", - "truncated" - ], + "required": [], "type": "object" }, "safety": { @@ -3719,12 +5092,46 @@ "name": "definition", "outputSchema": { "additionalProperties": false, + "anyOf": [ + { + "required": [ + "resolution", + "definition", + "symbol" + ] + }, + { + "required": [ + "code", + "message" + ] + } + ], "description": "CodeStory definition tool output.", "properties": { + "cause_code": { + "description": "Underlying activation or cache cause code.", + "type": "string" + }, + "code": { + "description": "Typed stdio retry or unavailable code.", + "type": "string" + }, "definition": { "description": "Resolved definition search hit.", "type": "object" }, + "details": { + "description": "Structured API error repair guidance.", + "type": [ + "object", + "null" + ] + }, + "diagnostics_uri": { + "description": "Optional full diagnostic resource URI.", + "type": "string" + }, "links": { "description": "Continuation resource links for the resolved definition.", "items": { @@ -3752,20 +5159,84 @@ }, "type": "array" }, + "message": { + "description": "Human-readable retry or unavailable message.", + "type": "string" + }, + "next_action": { + "description": "Direct next action for the caller.", + "type": "string" + }, + "operation": { + "description": "Current managed preparation operation.", + "type": [ + "object", + "null" + ] + }, + "recommended_next_calls": { + "description": "Host-executable retries of the intended tool.", + "items": { + "additionalProperties": false, + "description": "Host-executable retry of the same tool after a preparing delay.", + "properties": { + "after_ms": { + "description": "Delay before retry.", + "type": "integer" + }, + "arguments": { + "description": "Original tool arguments.", + "type": "object" + }, + "method": { + "description": "JSON-RPC method.", + "type": "string" + }, + "tool": { + "description": "Tool to retry.", + "type": "string" + } + }, + "required": [ + "method", + "tool" + ], + "type": "object" + }, + "type": "array" + }, "resolution": { "description": "Query resolution metadata.", "type": "object" }, + "retry_after_ms": { + "description": "Retry delay while preparing.", + "type": [ + "integer", + "null" + ] + }, + "retry_tool": { + "description": "Tool to retry when the envelope is preparing.", + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "preparing, unavailable, or cancelled.", + "type": "string" + }, "symbol": { "description": "Symbol context DTO.", "type": "object" + }, + "tool": { + "description": "Tool that produced this envelope.", + "type": "string" } }, - "required": [ - "resolution", - "definition", - "symbol" - ], + "required": [], "type": "object" }, "safety": { @@ -3835,12 +5306,109 @@ "name": "references", "outputSchema": { "additionalProperties": false, + "anyOf": [ + { + "required": [ + "focus", + "trail" + ] + }, + { + "required": [ + "code", + "message" + ] + } + ], "description": "CodeStory trail context DTO.", "properties": { + "cause_code": { + "description": "Underlying activation or cache cause code.", + "type": "string" + }, + "code": { + "description": "Typed stdio retry or unavailable code.", + "type": "string" + }, + "details": { + "description": "Structured API error repair guidance.", + "type": [ + "object", + "null" + ] + }, + "diagnostics_uri": { + "description": "Optional full diagnostic resource URI.", + "type": "string" + }, "focus": { "description": "Focused node details DTO.", "type": "object" }, + "message": { + "description": "Human-readable retry or unavailable message.", + "type": "string" + }, + "next_action": { + "description": "Direct next action for the caller.", + "type": "string" + }, + "operation": { + "description": "Current managed preparation operation.", + "type": [ + "object", + "null" + ] + }, + "recommended_next_calls": { + "description": "Host-executable retries of the intended tool.", + "items": { + "additionalProperties": false, + "description": "Host-executable retry of the same tool after a preparing delay.", + "properties": { + "after_ms": { + "description": "Delay before retry.", + "type": "integer" + }, + "arguments": { + "description": "Original tool arguments.", + "type": "object" + }, + "method": { + "description": "JSON-RPC method.", + "type": "string" + }, + "tool": { + "description": "Tool to retry.", + "type": "string" + } + }, + "required": [ + "method", + "tool" + ], + "type": "object" + }, + "type": "array" + }, + "retry_after_ms": { + "description": "Retry delay while preparing.", + "type": [ + "integer", + "null" + ] + }, + "retry_tool": { + "description": "Tool to retry when the envelope is preparing.", + "type": [ + "string", + "null" + ] + }, + "state": { + "description": "preparing, unavailable, or cancelled.", + "type": "string" + }, "story": { "description": "Optional readable trail story DTO.", "type": [ @@ -3848,15 +5416,16 @@ "null" ] }, + "tool": { + "description": "Tool that produced this envelope.", + "type": "string" + }, "trail": { "description": "Graph response DTO.", "type": "object" } }, - "required": [ - "focus", - "trail" - ], + "required": [], "type": "object" }, "safety": { @@ -3900,26 +5469,125 @@ "description": "Absolute repository root for this request. The MCP server is multi-project and does not retain a global workspace binding.", "minLength": 1, "type": "string" - } - }, - "required": [ - "project" - ], - "type": "object" - }, - "name": "symbols", - "outputSchema": { - "additionalProperties": false, - "description": "CodeStory symbol list output.", - "properties": { + } + }, + "required": [ + "project" + ], + "type": "object" + }, + "name": "symbols", + "outputSchema": { + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "symbols", + "returned_count", + "limit", + "truncated" + ] + }, + { + "required": [ + "code", + "message" + ] + } + ], + "description": "CodeStory symbol list output.", + "properties": { + "cause_code": { + "description": "Underlying activation or cache cause code.", + "type": "string" + }, + "code": { + "description": "Typed stdio retry or unavailable code.", + "type": "string" + }, + "details": { + "description": "Structured API error repair guidance.", + "type": [ + "object", + "null" + ] + }, + "diagnostics_uri": { + "description": "Optional full diagnostic resource URI.", + "type": "string" + }, "limit": { "description": "Applied result limit.", "type": "integer" }, + "message": { + "description": "Human-readable retry or unavailable message.", + "type": "string" + }, + "next_action": { + "description": "Direct next action for the caller.", + "type": "string" + }, + "operation": { + "description": "Current managed preparation operation.", + "type": [ + "object", + "null" + ] + }, + "recommended_next_calls": { + "description": "Host-executable retries of the intended tool.", + "items": { + "additionalProperties": false, + "description": "Host-executable retry of the same tool after a preparing delay.", + "properties": { + "after_ms": { + "description": "Delay before retry.", + "type": "integer" + }, + "arguments": { + "description": "Original tool arguments.", + "type": "object" + }, + "method": { + "description": "JSON-RPC method.", + "type": "string" + }, + "tool": { + "description": "Tool to retry.", + "type": "string" + } + }, + "required": [ + "method", + "tool" + ], + "type": "object" + }, + "type": "array" + }, + "retry_after_ms": { + "description": "Retry delay while preparing.", + "type": [ + "integer", + "null" + ] + }, + "retry_tool": { + "description": "Tool to retry when the envelope is preparing.", + "type": [ + "string", + "null" + ] + }, "returned_count": { "description": "Symbol rows included in this response.", "type": "integer" }, + "state": { + "description": "preparing, unavailable, or cancelled.", + "type": "string" + }, "symbols": { "description": "Root or child symbol summaries.", "items": { @@ -3961,17 +5629,16 @@ }, "type": "array" }, + "tool": { + "description": "Tool that produced this envelope.", + "type": "string" + }, "truncated": { "description": "Whether matching symbols exceeded the limit.", "type": "boolean" } }, - "required": [ - "symbols", - "returned_count", - "limit", - "truncated" - ], + "required": [], "type": "object" }, "safety": { @@ -4172,8 +5839,52 @@ "name": "snippet", "outputSchema": { "additionalProperties": false, - "description": "CodeStory snippet context DTO.", + "anyOf": [ + { + "required": [ + "node", + "path", + "line", + "snippet", + "scope", + "requested_context", + "snippet_truncated" + ] + }, + { + "required": [ + "ranges", + "max_total_bytes" + ] + }, + { + "required": [ + "code", + "message" + ] + } + ], + "description": "CodeStory snippet context DTO, or a batched paths result.", "properties": { + "cause_code": { + "description": "Underlying activation or cache cause code.", + "type": "string" + }, + "code": { + "description": "Typed stdio retry or unavailable code.", + "type": "string" + }, + "details": { + "description": "Structured API error repair guidance.", + "type": [ + "object", + "null" + ] + }, + "diagnostics_uri": { + "description": "Optional full diagnostic resource URI.", + "type": "string" + }, "fallback_reason": { "description": "Reason function-body selection fell back to line context, when applicable.", "type": "string" @@ -4189,10 +5900,29 @@ "null" ] }, + "max_total_bytes": { + "description": "Total byte cap for a batched paths call.", + "type": "integer" + }, + "message": { + "description": "Human-readable retry or unavailable message.", + "type": "string" + }, + "next_action": { + "description": "Direct next action for the caller.", + "type": "string" + }, "node": { "description": "Node details DTO.", "type": "object" }, + "operation": { + "description": "Current managed preparation operation.", + "type": [ + "object", + "null" + ] + }, "path": { "description": "Project-relative file path.", "type": "string" @@ -4201,10 +5931,93 @@ "description": "Source of the selected function-body range, when available.", "type": "string" }, + "ranges": { + "description": "Requested source ranges from a batched paths call.", + "items": { + "additionalProperties": false, + "description": "One requested source range from a batched snippet.paths call.", + "properties": { + "end_line": { + "description": "1-based last line.", + "type": "integer" + }, + "path": { + "description": "Project-relative file path.", + "type": "string" + }, + "snippet": { + "description": "Source snippet text.", + "type": "string" + }, + "snippet_truncated": { + "description": "Whether this range hit a byte cap.", + "type": "boolean" + }, + "start_line": { + "description": "1-based first line.", + "type": "integer" + } + }, + "required": [ + "path", + "start_line", + "end_line", + "snippet", + "snippet_truncated" + ], + "type": "object" + }, + "type": "array" + }, + "recommended_next_calls": { + "description": "Host-executable retries of the intended tool.", + "items": { + "additionalProperties": false, + "description": "Host-executable retry of the same tool after a preparing delay.", + "properties": { + "after_ms": { + "description": "Delay before retry.", + "type": "integer" + }, + "arguments": { + "description": "Original tool arguments.", + "type": "object" + }, + "method": { + "description": "JSON-RPC method.", + "type": "string" + }, + "tool": { + "description": "Tool to retry.", + "type": "string" + } + }, + "required": [ + "method", + "tool" + ], + "type": "object" + }, + "type": "array" + }, "requested_context": { "description": "Requested context line count.", "type": "integer" }, + "retry_after_ms": { + "description": "Retry delay while preparing.", + "type": [ + "integer", + "null" + ] + }, + "retry_tool": { + "description": "Tool to retry when the envelope is preparing.", + "type": [ + "string", + "null" + ] + }, "scope": { "description": "Snippet scope.", "enum": [ @@ -4221,20 +6034,20 @@ "description": "Whether the snippet hit a byte cap.", "type": "boolean" }, + "state": { + "description": "preparing, unavailable, or cancelled.", + "type": "string" + }, + "tool": { + "description": "Tool that produced this envelope.", + "type": "string" + }, "truncation_guidance": { "description": "Follow-up guidance when the snippet hit its byte cap.", "type": "string" } }, - "required": [ - "node", - "path", - "line", - "snippet", - "scope", - "requested_context", - "snippet_truncated" - ], + "required": [], "type": "object" }, "safety": { @@ -4320,8 +6133,33 @@ "name": "context", "outputSchema": { "additionalProperties": false, + "anyOf": [ + { + "required": [ + "packet_id", + "target", + "summary", + "sections", + "citations", + "subgraph_ids", + "retrieval_version", + "graphs", + "retrieval_trace" + ] + }, + { + "required": [ + "code", + "message" + ] + } + ], "description": "CodeStory context packet DTO.", "properties": { + "cause_code": { + "description": "Underlying activation or cache cause code.", + "type": "string" + }, "citations": { "description": "Evidence citations.", "items": { @@ -4469,6 +6307,21 @@ }, "type": "array" }, + "code": { + "description": "Typed stdio retry or unavailable code.", + "type": "string" + }, + "details": { + "description": "Structured API error repair guidance.", + "type": [ + "object", + "null" + ] + }, + "diagnostics_uri": { + "description": "Optional full diagnostic resource URI.", + "type": "string" + }, "freshness": { "description": "Index freshness observation, when one was made.", "type": "object" @@ -4484,10 +6337,56 @@ }, "type": "array" }, + "message": { + "description": "Human-readable retry or unavailable message.", + "type": "string" + }, + "next_action": { + "description": "Direct next action for the caller.", + "type": "string" + }, + "operation": { + "description": "Current managed preparation operation.", + "type": [ + "object", + "null" + ] + }, "packet_id": { "description": "Stable context packet id.", "type": "string" }, + "recommended_next_calls": { + "description": "Host-executable retries of the intended tool.", + "items": { + "additionalProperties": false, + "description": "Host-executable retry of the same tool after a preparing delay.", + "properties": { + "after_ms": { + "description": "Delay before retry.", + "type": "integer" + }, + "arguments": { + "description": "Original tool arguments.", + "type": "object" + }, + "method": { + "description": "JSON-RPC method.", + "type": "string" + }, + "tool": { + "description": "Tool to retry.", + "type": "string" + } + }, + "required": [ + "method", + "tool" + ], + "type": "object" + }, + "type": "array" + }, "retrieval_trace": { "description": "Retrieval trace and summary.", "type": "object" @@ -4496,6 +6395,20 @@ "description": "Retrieval version.", "type": "string" }, + "retry_after_ms": { + "description": "Retry delay while preparing.", + "type": [ + "integer", + "null" + ] + }, + "retry_tool": { + "description": "Tool to retry when the envelope is preparing.", + "type": [ + "string", + "null" + ] + }, "sections": { "description": "Context sections.", "items": { @@ -4518,6 +6431,10 @@ }, "type": "array" }, + "state": { + "description": "preparing, unavailable, or cancelled.", + "type": "string" + }, "subgraph_ids": { "description": "Related graph ids.", "items": { @@ -4532,19 +6449,13 @@ "target": { "description": "Resolved retrieval target label.", "type": "string" + }, + "tool": { + "description": "Tool that produced this envelope.", + "type": "string" } }, - "required": [ - "packet_id", - "target", - "summary", - "sections", - "citations", - "subgraph_ids", - "retrieval_version", - "graphs", - "retrieval_trace" - ], + "required": [], "type": "object" }, "safety": { diff --git a/plugins/codestory/plugin.json b/plugins/codestory/plugin.json index baa1ee719..836a683e5 100644 --- a/plugins/codestory/plugin.json +++ b/plugins/codestory/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "codestory", - "version": "0.17.4", + "version": "0.17.5", "description": "CodeStory grounding for coding agents over a local managed runtime.", "author": { "name": "The Green Cedar", diff --git a/release-claims.json b/release-claims.json index 6a239043f..69a6bf511 100644 --- a/release-claims.json +++ b/release-claims.json @@ -1675,7 +1675,7 @@ "publisher_step": "Publish executable release freeze", "status_creator": "github-actions[bot]", "job_manifest": ".github/scripts/release-freeze-acceptance-jobs.json", - "job_manifest_sha256": "b1ae13eabcbda9c99f7d0ba17cae97ca292d11fa4b010f87c534f9b7938843c7", + "job_manifest_sha256": "7eb0812b601c3f06d5885b741d4dd1775b85c10619b18fc680dbb77a9ccc13b4", "phases": { "source_stabilization": { "known_future_source_changes": [ @@ -1743,7 +1743,7 @@ "file": "source-proof.yml", "job": "resolve", "step": "Resolve trusted exact head", - "sha256": "2fe869b675010f5db29259aff38d83456c01dbc9885989afbf7c92a2826791af", + "sha256": "b6a71101fabf7f21a2d3483132eadbe9062c53b55988946c5a26da46d2719dc2", "reason": "The trusted-head resolver authenticates which commit the broad source proof runs against; fragment checks alone cannot prove the authentication logic was not rewritten around them." }, "packaged_platform_pr_resolver": { @@ -1751,7 +1751,7 @@ "file": "packaged-platform-pr.yml", "job": "route", "step": "Resolve trusted exact head", - "sha256": "057bb7924c9ec281671defe683519debfb71359729d66fb0afb84513dc22bf5f", + "sha256": "fa1ec1a09cf8588ce2109c25289a0d1c797c63d4b0429ff222d7d549ce67b3f0", "reason": "The coordinator's trusted-head resolver authenticates the exact frozen-candidate head for every broad proof mode; its executable text must match the reviewed contract, not merely contain its tokens." }, "marketplace_sync_dispatch_guard": { @@ -1779,7 +1779,7 @@ "packaged_platform_pr_workflow": { "subject": "parsed_workflow_json", "file": "packaged-platform-pr.yml", - "sha256": "a633fd7e6def5305940844aca96a6869fca77622050dc79f0c4682f5e0a9c1c3", + "sha256": "111a730a2ea35476aa94ee2fdb16d5795b5230e4e6002664ac3ff2d913a23c31", "reason": "The frozen-candidate coordinator is a small release-control program, not a loose collection of independently safe fragments. Its complete parsed structure is pinned so a required check cannot be made advisory, parked in dead code, or followed by a payload substitution while leaving the expected tokens in place." }, "frozen_candidate_quality_workflow": { @@ -3271,7 +3271,7 @@ "step": "Prove the catalog-resolved published runtime", "fragments": [ "common=(", - "python .github/scripts/check-packaged-agent-proof.py", + "python \"$PROOF_HELPER\"", "--expected-backend \"$EXPECTED_BACKEND\"", "\"${common[@]}\" --out-dir \"$proof_root/session-1\"", "\"${common[@]}\" --out-dir \"$proof_root/session-2\"", diff --git a/scripts/codestory-release.mjs b/scripts/codestory-release.mjs new file mode 100644 index 000000000..538462427 --- /dev/null +++ b/scripts/codestory-release.mjs @@ -0,0 +1,1197 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import process from "node:process"; +import { + initReceipt, + invalidateReceipt, + recordGroup, +} from "../.github/scripts/release-driver-receipt.mjs"; + +export const COORDINATOR_SCHEMA = "codestory.release-coordinator/v1"; +export const SOURCE_PROOF_WORKFLOW = ".github/workflows/source-proof.yml"; +export const PACKAGED_WORKFLOW = ".github/workflows/packaged-platform-pr.yml"; +export const RELEASE_WORKFLOW = ".github/workflows/release.yml"; +export const BROAD_WORKFLOWS = Object.freeze([ + SOURCE_PROOF_WORKFLOW, + PACKAGED_WORKFLOW, + RELEASE_WORKFLOW, + ".github/workflows/macos-metal-proof.yml", + ".github/workflows/windows-vulkan-proof.yml", +]); + +const MARKER = ``; +const DIGEST = "a".repeat(64); +const PHASES = Object.freeze([ + "preflight", + "source_stabilization", + "calibration", + "freeze", + "frozen_candidate_acceptance", + "package", + "hardware", + "installed_candidate", + "qualification", + "awaiting_approval", + "promotion", + "publication", + "closeout", + "complete", +]); +const EXPENSIVE = new Set([ + "source_stabilization", + "calibration", + "frozen_candidate_acceptance", + "package", + "hardware", + "installed_candidate", + "qualification", + "publication", +]); +const NATIVE_ASSETS = Object.freeze(["linux-x64", "macos-arm64", "windows-x64"]); +const PHASE_ESTIMATE_MINUTES = Object.freeze({ + preflight: 2, + source_stabilization: 45, + calibration: 25, + freeze: 5, + frozen_candidate_acceptance: 15, + package: 20, + hardware: 15, + installed_candidate: 10, + qualification: 15, + awaiting_approval: 5, + promotion: 5, + publication: 15, + closeout: 10, + complete: 0, +}); + +function fail(message) { + throw new Error(message); +} + +function present(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function jsonFence(value) { + return `${MARKER}\n\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\`\n`; +} + +function parseRecord(body) { + if (!present(body) || !body.includes(MARKER)) return null; + const match = body.match(/```json\n([\s\S]*?)\n```/); + if (!match) return null; + const parsed = JSON.parse(match[1]); + if (parsed?.schema !== COORDINATOR_SCHEMA) return null; + return parsed; +} + +function formatDuration(ms) { + const total = Math.max(0, Math.round(Number(ms) / 1000)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + if (hours > 0) return `${hours}h ${minutes}m`; + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +function remainingPath(phase) { + const index = PHASES.indexOf(phase); + const minutes = PHASES.slice(Math.max(0, index)) + .reduce((sum, name) => sum + (PHASE_ESTIMATE_MINUTES[name] ?? 0), 0); + return formatDuration(minutes * 60 * 1000); +} + +function parseArgs(argv) { + const options = { + command: argv[0], + version: undefined, + lane: "native", + rehearse: false, + issue: undefined, + recordApproval: false, + approver: undefined, + repo: undefined, + injectFailure: undefined, + operatorSuppliedRunIds: [], + }; + for (let index = 1; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--rehearse") { + options.rehearse = true; + continue; + } + if (argument === "--record-approval") { + options.recordApproval = true; + continue; + } + const value = argv[index + 1]; + if (["--version", "--lane", "--issue", "--approver", "--repo", "--inject-failure"].includes(argument)) { + if (!present(value) || value.startsWith("--")) fail(`${argument} requires a value`); + const key = argument.slice(2).replace(/-([a-z])/gu, (_, letter) => letter.toUpperCase()); + options[key] = value; + index += 1; + continue; + } + if (argument === "--run-id" || argument === "--run-ids") { + options.operatorSuppliedRunIds.push(value); + index += 1; + continue; + } + fail(`unknown argument ${argument}`); + } + if (!["start", "status", "advance", "resume"].includes(options.command)) { + fail("usage: node scripts/codestory-release.mjs ..."); + } + return options; +} + +function runEvidence(group, run, commit, tree) { + return { + lane: group, + run_id: run.id, + attempt: run.attempt ?? 1, + artifact: `${group}-run-${run.id}-attempt-${run.attempt ?? 1}`, + digest: DIGEST, + identity: `${group}@${commit.slice(0, 8)}`, + conclusion: run.conclusion ?? "success", + commit, + tree, + }; +} + +function ensureBaseGroups(record, host) { + const head = host.heads.next; + let receipt = record.receipt; + if (!receipt.groups["calibration-source"]) { + receipt = recordGroup(receipt, "calibration-source", { + commit: head.commit, + tree: head.tree, + }); + } + if (!receipt.groups["pull-requests"]) { + receipt = recordGroup(receipt, "pull-requests", { + release_pr: null, + bind: "next_head", + integrated_support_prs: [], + }); + } + if (!receipt.groups.evidence) { + receipt = recordGroup(receipt, "evidence", { reusable: [], invalidated: [] }); + } + if (!receipt.groups["next-action"]) { + receipt = recordGroup(receipt, "next-action", { + action: "run preflight then source stabilization", + owner: "codestory-release", + }); + } + record.receipt = receipt; +} + +function nextActionFor(phase, rehearse) { + const actions = { + preflight: "run preflight then source stabilization", + source_stabilization: "wait for source-stabilization acceptance", + calibration: "dispatch protected Metal calibration", + freeze: "apply the generated constant-set freeze commit", + frozen_candidate_acceptance: "dispatch frozen-candidate acceptance", + package: "dispatch package proof", + hardware: "dispatch protected hardware proof", + installed_candidate: "dispatch installed-candidate proof", + qualification: "dispatch qualification", + awaiting_approval: "record combined maintainer approval", + promotion: rehearse + ? "rehearse promotion without merging to main" + : "fast-forward next to F and open the tree-preserving next→main PR", + publication: rehearse + ? "authenticate release.yml with publish_release=false" + : "publish after recorded approval", + closeout: "verify tag, assets, catalog-delivery, and live ground", + complete: "release coordinator complete", + }; + return actions[phase] ?? phase; +} + +function compact(record, extras = {}) { + const started = Date.parse(record.started_at); + const now = Date.parse(extras.now ?? record.started_at); + return { + command: extras.command ?? "status", + phase: record.phase, + sha: extras.sha ?? record.heads?.C ?? record.receipt.groups["calibration-source"]?.value?.commit, + tree: extras.tree ?? record.heads?.C_tree ?? record.receipt.groups["calibration-source"]?.value?.tree, + active_runs: extras.active_runs ?? [], + blocker: extras.blocker ?? null, + next_action: extras.next_action ?? nextActionFor(record.phase, record.rehearse), + elapsed: formatDuration(now - started), + estimated_critical_path: remainingPath(record.phase), + rehearse: Boolean(record.rehearse), + dispatched: extras.dispatched ?? null, + record, + }; +} + +function runnerBlocker(probes) { + const runners = probes.runners ?? {}; + for (const [hostName, state] of Object.entries(runners)) { + if (state !== "alive") { + return { + blocker: `protected runner ${hostName} is ${state}; heartbeat preflight is unproven`, + next_action: `Restore ${hostName} heartbeat via reserve-protected-runners before dispatch`, + }; + } + } + return null; +} + +function preflight(record, host, intendedPhase) { + const probes = host.probes ?? {}; + if (probes.macosZombie === "zombie" || probes.macosIdentity === "unreadable") { + return { + blocker: "macOS zombie PID remains while exact runtime identity is unreadable", + next_action: "Clear the macOS zombie PID and re-read the installed runtime identity", + }; + } + if ( + (intendedPhase === "package" || intendedPhase === "hardware" || record.phase === "package") + && probes.windowsStaging === "fail" + ) { + return { + blocker: "Windows path or native staging probe failed", + next_action: "Reproduce the Windows staging path in a sub-90s probe before another package dispatch", + }; + } + if (EXPENSIVE.has(intendedPhase) || intendedPhase === "preflight") { + const runners = runnerBlocker(probes); + if (runners) return runners; + } + if ( + intendedPhase === "source_stabilization" + && probes.acceptanceMode + && probes.acceptanceMode !== "source_stabilization" + ) { + return { + blocker: `wrong source-proof acceptance mode ${probes.acceptanceMode}; required acceptance_phase=source_stabilization`, + next_action: "Dispatch source-proof.yml with acceptance_only=true and acceptance_phase=source_stabilization", + }; + } + return null; +} + +function activeRuns(host, commit) { + return (host.workflowRuns?.() ?? host.runs ?? []).filter((run) => + ["queued", "waiting", "requested", "pending", "in_progress"].includes(run.status) + && (!commit || run.headSha === commit) + && !run.optional + ); +} + +function optionalRuns(host) { + return (host.runs ?? []).filter((run) => run.optional && run.status === "in_progress"); +} + +function persist(host, record) { + const body = jsonFence(record); + const existing = (host.listIssueComments(record.issue_number) ?? []) + .find((comment) => comment.body?.includes(MARKER)); + if (existing) host.updateIssueComment(existing.id, body); + else host.createIssueComment(record.issue_number, body); + if (typeof host.persistRecord === "function") host.persistRecord(record); +} + +function loadRecord(host, options) { + const issueNumber = options.issue + ? Number(options.issue) + : host.listOpenCoordinatorIssues?.()?.[0]?.number; + if (!issueNumber) fail("no coordinator issue found; pass --issue or run start"); + const comments = host.listIssueComments(issueNumber) ?? []; + const record = comments.map((comment) => parseRecord(comment.body)).find(Boolean); + if (!record) fail(`coordinator record not found on issue #${issueNumber}`); + return record; +} + +function observeCompleted(record, host) { + const source = record.receipt.groups["calibration-source"]?.value; + if (!source) return record; + const completed = (host.runs ?? []).filter((run) => + run.status === "completed" && run.conclusion === "success" + ); + const commit = source.commit; + const tree = source.tree; + const frozen = record.receipt.groups["frozen-candidate"]?.value; + for (const run of completed) { + if ( + run.workflow === SOURCE_PROOF_WORKFLOW + && run.inputs?.acceptance_phase === "source_stabilization" + && !record.receipt.groups["source-stabilization"] + ) { + record.receipt = recordGroup( + record.receipt, + "source-stabilization", + runEvidence("source-stabilization", run, commit, tree), + ); + record.receipt = recordGroup( + record.receipt, + "source-proof", + runEvidence("source-proof", run, commit, tree), + ); + } + if (run.workflow === PACKAGED_WORKFLOW && run.inputs?.mode === "calibration" && !record.receipt.groups.calibration) { + record.receipt = recordGroup(record.receipt, "calibration", [ + runEvidence("metal-1", { ...run, id: run.id, attempt: 1 }, commit, tree), + runEvidence("metal-2", { ...run, id: run.id + 1, attempt: 1 }, commit, tree), + runEvidence("metal-3", { ...run, id: run.id + 2, attempt: 1 }, commit, tree), + ]); + } + if ( + run.workflow === SOURCE_PROOF_WORKFLOW + && run.inputs?.acceptance_phase === "frozen_candidate" + && frozen + && !record.receipt.groups["frozen-candidate-acceptance"] + ) { + record.receipt = recordGroup( + record.receipt, + "frozen-candidate-acceptance", + runEvidence("frozen-candidate-acceptance", run, frozen.commit, frozen.tree), + ); + } + if (run.workflow === PACKAGED_WORKFLOW && run.inputs?.mode === "package" && frozen && !record.receipt.groups.package) { + record.receipt = recordGroup( + record.receipt, + "package", + runEvidence("package", run, frozen.commit, frozen.tree), + ); + } + if (run.workflow === PACKAGED_WORKFLOW && run.inputs?.mode === "platform" && frozen && !record.receipt.groups.hardware) { + record.receipt = recordGroup( + record.receipt, + "hardware", + runEvidence("hardware", run, frozen.commit, frozen.tree), + ); + } + if ( + run.workflow === PACKAGED_WORKFLOW + && run.inputs?.mode === "installed" + && frozen + && !record.receipt.groups["installed-candidate"] + ) { + record.receipt = recordGroup( + record.receipt, + "installed-candidate", + runEvidence("installed-candidate", run, frozen.commit, frozen.tree), + ); + } + if ( + run.workflow === PACKAGED_WORKFLOW + && run.inputs?.mode === "qualification" + && frozen + && !record.receipt.groups.qualification + ) { + record.receipt = recordGroup( + record.receipt, + "qualification", + runEvidence("qualification", run, frozen.commit, frozen.tree), + ); + } + } + return record; +} + +function activeGroup(record, name) { + const entry = record.receipt.groups[name]; + return entry?.status === "active" ? entry : undefined; +} + +function derivePhase(record) { + if (activeGroup(record, "catalog-delivery") && (activeGroup(record, "publication") || record.rehearse)) { + return "complete"; + } + if (activeGroup(record, "publication")) return "closeout"; + if (record.approval && ["promotion", "publication", "closeout"].includes(record.phase)) { + return record.phase === "awaiting_approval" ? "promotion" : record.phase; + } + if (activeGroup(record, "qualification")) return record.approval ? "promotion" : "awaiting_approval"; + if (activeGroup(record, "installed-candidate")) return "qualification"; + if (activeGroup(record, "hardware")) return "installed_candidate"; + if (activeGroup(record, "package")) return "hardware"; + if (activeGroup(record, "frozen-candidate-acceptance")) return "package"; + if (activeGroup(record, "frozen-candidate")) return "frozen_candidate_acceptance"; + if (activeGroup(record, "calibration")) return "freeze"; + if (activeGroup(record, "source-stabilization")) return "calibration"; + if (record.phase && PHASES.includes(record.phase)) return record.phase; + return "preflight"; +} + +function laterPhase(left, right) { + return PHASES.indexOf(left) >= PHASES.indexOf(right) ? left : right; +} + +function handleDrift(record, host) { + const source = record.receipt.groups["calibration-source"]?.value; + const live = host.heads.next; + if (!source || source.commit === live.commit) return { record, drift: false, residual: [] }; + const superseded = (host.runs ?? []).filter((run) => + ["queued", "in_progress", "waiting", "requested", "pending"].includes(run.status) + && run.headSha !== live.commit + ); + const cancelled = []; + for (const run of superseded) { + host.cancelRun?.(run.id); + cancelled.push(run.id); + host.freezeBarrier?.("cancel-superseded", { + commit: live.commit, + workflows: BROAD_WORKFLOWS, + }); + } + const residual = (host.runs ?? []).filter((run) => + ["queued", "in_progress", "waiting", "requested", "pending"].includes(run.status) + && run.headSha !== live.commit + ); + if (residual.length > 0) { + return { + record, + drift: true, + residual, + blocker: "superseded run ignored ordinary cancellation", + next_action: "force-cancel via release-freeze-barrier invalidate-superseded", + }; + } + record.receipt = invalidateReceipt(record.receipt, { + event: "evidence", + groups: Object.keys(record.receipt.groups), + reason: "source head moved after coordinator start", + replacingSha: live.commit, + }); + record.heads = { ...record.heads, C: live.commit, C_tree: live.tree }; + record.phase = "preflight"; + record.receipt = recordGroup(record.receipt, "calibration-source", { + commit: live.commit, + tree: live.tree, + }); + record.receipt = recordGroup(record.receipt, "evidence", { + reusable: [], + invalidated: [{ + identity: `superseded@${source.commit.slice(0, 8)}`, + reason: "source head moved", + replacing_sha: live.commit, + }], + }); + record.receipt = recordGroup(record.receipt, "next-action", { + action: "run preflight then source stabilization", + owner: "codestory-release", + }); + return { record, drift: true, residual: [], cancelled }; +} + +function dispatchSourceStabilization(record, host) { + return host.dispatch({ + workflow: SOURCE_PROOF_WORKFLOW, + ref: "dev/codestory-next", + inputs: { + expected_head_sha: host.heads.next.commit, + version: record.receipt.version, + acceptance_only: "true", + acceptance_phase: "source_stabilization", + freeze_receipt_digest: "", + support_prs_json: "[]", + reusable_evidence_json: "[]", + invalidated_evidence_json: "[]", + }, + }); +} + +function dispatchPackaged(record, host, mode) { + const head = record.receipt.groups["frozen-candidate"]?.value ?? host.heads.next; + return host.dispatch({ + workflow: PACKAGED_WORKFLOW, + ref: "dev/codestory-next", + inputs: { + mode, + version: record.receipt.version, + expected_head_sha: head.commit, + }, + }); +} + +function assertAssetLane(record, host) { + const assets = host.assets ?? []; + if (record.lane === "native") { + const missing = NATIVE_ASSETS.filter((name) => !assets.includes(name)); + if (assets.includes("plugin-only") || missing.length === NATIVE_ASSETS.length) { + fail("native lane requires native archives, not plugin-only assets"); + } + } + if (record.lane === "plugin") { + const nativeHit = assets.some((name) => NATIVE_ASSETS.includes(name)); + if (nativeHit) { + return { + blocker: "plugin-only lane must not publish native archive assets", + next_action: "Use --lane native for native archives or drop native assets from the plugin publication", + }; + } + } + return null; +} + +function applyFreeze(record, host) { + const generated = host.generatedFreezeHead ?? { + commit: "f".repeat(40), + tree: "2".repeat(40), + }; + if (!record.receipt.groups["frozen-candidate"]) { + record.receipt = recordGroup(record.receipt, "frozen-candidate", generated); + } + record.heads = { ...record.heads, F: generated.commit, F_tree: generated.tree }; + record.phase = "frozen_candidate_acceptance"; + return record; +} + +function recordMarketplace(record, host) { + const marketplace = host.marketplace ?? { state: "deferred", installer_identity: "codex_marketplace_deferred_fixture" }; + const state = marketplace.state === "published" ? "published" : (marketplace.state === "unpublished" ? "deferred" : marketplace.state); + record.receipt = recordGroup(record.receipt, "catalog-delivery", { + state, + installer_identity: marketplace.installer_identity + ?? (state === "published" ? "codex_marketplace" : "codex_marketplace_deferred_fixture"), + }); + return record; +} + +function dispatchFor(record, host) { + const phase = record.phase; + if (phase === "preflight" || phase === "source_stabilization") { + return { record, dispatched: dispatchSourceStabilization(record, host), phase: "source_stabilization" }; + } + if (phase === "calibration") { + return { record, dispatched: dispatchPackaged(record, host, "calibration"), phase: "calibration" }; + } + if (phase === "freeze") { + return { record: applyFreeze(record, host), dispatched: null, phase: "frozen_candidate_acceptance" }; + } + if (phase === "frozen_candidate_acceptance") { + const frozen = record.receipt.groups["frozen-candidate"].value; + const dispatched = host.dispatch({ + workflow: SOURCE_PROOF_WORKFLOW, + ref: "dev/codestory-next", + inputs: { + expected_head_sha: frozen.commit, + version: record.receipt.version, + acceptance_only: "true", + acceptance_phase: "frozen_candidate", + }, + }); + return { record, dispatched, phase: "frozen_candidate_acceptance" }; + } + if (phase === "package") { + return { record, dispatched: dispatchPackaged(record, host, "package"), phase: "package" }; + } + if (phase === "hardware") { + return { record, dispatched: dispatchPackaged(record, host, "platform"), phase: "hardware" }; + } + if (phase === "installed_candidate") { + return { record, dispatched: dispatchPackaged(record, host, "installed"), phase: "installed_candidate" }; + } + if (phase === "qualification") { + return { record, dispatched: dispatchPackaged(record, host, "qualification"), phase: "qualification" }; + } + if (phase === "promotion") { + if (!record.rehearse) host.promoteToMain?.(); + record.phase = "publication"; + return { record, dispatched: null, phase: "publication" }; + } + if (phase === "publication") { + const published = !record.rehearse && Boolean(record.approval); + const dispatched = host.dispatch({ + workflow: RELEASE_WORKFLOW, + ref: record.rehearse ? "dev/codestory-next" : "main", + inputs: { + version: record.receipt.version, + expected_head_sha: record.heads?.F ?? host.heads.next.commit, + publish_release: published ? "true" : "false", + }, + }); + if (published) host.createTag?.(`v${record.receipt.version}`); + record.phase = "closeout"; + return { record, dispatched, phase: "closeout" }; + } + if (phase === "closeout") { + record = recordMarketplace(record, host); + record.phase = "complete"; + return { record, dispatched: null, phase: "complete" }; + } + return { record, dispatched: null, phase }; +} + +async function start(options, host) { + if (!present(options.version)) fail("--version is required"); + if (!["native", "plugin"].includes(options.lane)) fail("--lane must be native or plugin"); + const issue = options.issue + ? { number: Number(options.issue) } + : host.createIssue({ + title: `Release ${options.version} coordinator`, + body: `Canonical coordinator record for ${options.version}.`, + }); + const head = host.heads.next; + const now = host.now().toISOString(); + const record = { + schema: COORDINATOR_SCHEMA, + lane: options.lane, + rehearse: Boolean(options.rehearse), + phase: "preflight", + started_at: now, + issue_number: issue.number, + approval: null, + heads: { C: head.commit, C_tree: head.tree }, + receipt: initReceipt(options.version), + }; + ensureBaseGroups(record, host); + persist(host, record); + return compact(record, { command: "start", sha: head.commit, tree: head.tree, now }); +} + +async function observe(options, host, command) { + if (options.operatorSuppliedRunIds.length > 0) { + host.operatorSuppliedRunIds.push(...options.operatorSuppliedRunIds); + } + let record = loadRecord(host, options); + ensureBaseGroups(record, host); + const drift = handleDrift(record, host); + record = drift.record; + if (drift.residual?.length) { + persist(host, record); + return compact(record, { + command, + sha: host.heads.next.commit, + tree: host.heads.next.tree, + now: host.now().toISOString(), + active_runs: drift.residual, + blocker: drift.blocker, + next_action: drift.next_action, + }); + } + record = observeCompleted(record, host); + const liveRuns = activeRuns(host, host.heads.next.commit); + if (liveRuns.length > 0 && !drift.drift) { + const current = liveRuns[0]; + if (current.workflow === SOURCE_PROOF_WORKFLOW) { + record.phase = current.inputs?.acceptance_phase === "frozen_candidate" + ? "frozen_candidate_acceptance" + : "source_stabilization"; + } + } else if (!drift.drift) { + record.phase = laterPhase(record.phase, derivePhase(record)); + } + if (record.phase === "closeout" || command === "status" && host.marketplace) { + const groups = record.receipt.groups; + if (record.phase === "closeout" && !groups["catalog-delivery"] && host.marketplace) { + record = recordMarketplace(record, host); + } + } + const intended = record.phase === "preflight" ? "source_stabilization" : record.phase; + const blocked = preflight(record, host, intended); + const assetBlock = record.phase === "publication" || record.lane === "plugin" + ? assertAssetLane(record, host) + : null; + persist(host, record); + return compact(record, { + command, + sha: host.heads.next.commit, + tree: host.heads.next.tree, + now: host.now().toISOString(), + active_runs: liveRuns.map((run) => ({ + id: run.id, + workflow: run.workflow, + headSha: run.headSha, + status: run.status, + })), + blocker: drift.blocker ?? blocked?.blocker ?? assetBlock?.blocker ?? null, + next_action: liveRuns.length > 0 && !drift.residual?.length + ? "wait for the in-flight permitted workflow" + : (drift.next_action ?? blocked?.next_action ?? assetBlock?.next_action ?? nextActionFor(record.phase, record.rehearse)), + }); +} + +async function advance(options, host) { + const incoming = loadRecord(host, options).phase; + const current = await observe(options, host, "advance"); + let record = current.record; + if (current.blocker && /cancel|zombie|identity|runner|heartbeat|unproven|windows|staging|acceptance_phase/i.test(current.blocker)) { + fail(current.blocker); + } + if (options.recordApproval) { + if (!present(options.approver)) fail("--approver is required with --record-approval"); + record.approval = { + approver: options.approver, + at: host.now().toISOString(), + recorded_on: `issue:${record.issue_number}`, + }; + record.phase = "promotion"; + persist(host, record); + return compact(record, { + command: "advance", + sha: host.heads.next.commit, + tree: host.heads.next.tree, + now: host.now().toISOString(), + }); + } + if (record.phase === "awaiting_approval") { + if (incoming === "awaiting_approval" && !options.recordApproval) { + fail("promotion and publication require recorded combined maintainer approval"); + } + persist(host, record); + return compact(record, { + command: "advance", + sha: host.heads.next.commit, + tree: host.heads.next.tree, + now: host.now().toISOString(), + dispatched: null, + next_action: nextActionFor("awaiting_approval", record.rehearse), + }); + } + if (record.phase === "publication" && record.lane === "native") { + const assets = assertAssetLane(record, host); + if (assets?.blocker) fail(assets.blocker); + } + const liveRuns = activeRuns(host, host.heads.next.commit); + if (liveRuns.length > 0) { + return compact(record, { + command: "advance", + sha: host.heads.next.commit, + tree: host.heads.next.tree, + now: host.now().toISOString(), + active_runs: liveRuns, + dispatched: null, + next_action: "wait for the in-flight permitted workflow", + }); + } + if (optionalRuns(host).length > 0 && record.receipt.groups.qualification) { + record.phase = record.approval ? "promotion" : "awaiting_approval"; + persist(host, record); + return compact(record, { + command: "advance", + sha: host.heads.next.commit, + tree: host.heads.next.tree, + now: host.now().toISOString(), + blocker: null, + }); + } + const intended = record.phase === "preflight" ? "source_stabilization" : record.phase; + const blocked = preflight(record, host, intended); + if (blocked) fail(blocked.blocker); + if (record.phase === "complete") { + return compact(record, { command: "advance", dispatched: null, now: host.now().toISOString() }); + } + const result = dispatchFor(record, host); + record = result.record; + record.phase = result.phase; + persist(host, record); + return compact(record, { + command: "advance", + sha: host.heads.next.commit, + tree: host.heads.next.tree, + now: host.now().toISOString(), + dispatched: result.dispatched, + active_runs: activeRuns(host, host.heads.next.commit), + }); +} + +export async function execute(argv, host) { + const options = parseArgs(argv); + host.operatorSuppliedRunIds ??= []; + if (options.injectFailure) applyInjectedFailure(host, options.injectFailure); + if (options.command === "start") return start(options, host); + if (options.command === "status") return observe(options, host, "status"); + if (options.command === "advance") return advance(options, host); + const status = await observe(options, host, "resume"); + if (status.blocker || status.active_runs.length > 0) return status; + return advance(options, host); +} + +function applyInjectedFailure(host, kind) { + host.probes ??= {}; + if (kind === "runner-offline") host.probes.runners = { "macos-arm64-metal": "unproven" }; + if (kind === "macos-zombie") { + host.probes.macosZombie = "zombie"; + host.probes.macosIdentity = "unreadable"; + } + if (kind === "windows-staging") host.probes.windowsStaging = "fail"; + if (kind === "wrong-acceptance-mode") host.probes.acceptanceMode = "frozen_candidate"; + if (kind === "ignored-cancellation") host.cancelHonored = false; +} + +function defaultProbes() { + return { + runners: { + "macos-arm64-metal": "alive", + "windows-x64-vulkan": "alive", + "linux-x64-vulkan": "alive", + }, + acceptanceMode: "source_stabilization", + macosZombie: "clear", + macosIdentity: "readable", + windowsStaging: "ok", + packaging: "ok", + calibrationLineage: "ok", + }; +} + +function fillReceipt(record, phase, host) { + const C = host.heads.next.commit; + const C_TREE = host.heads.next.tree; + const generated = host.generatedFreezeHead ?? { commit: "f".repeat(40), tree: "2".repeat(40) }; + const F = generated.commit; + const F_TREE = generated.tree; + const success = (group, id, commit, tree) => runEvidence(group, { id, attempt: 1, conclusion: "success" }, commit, tree); + if (!record.receipt.groups["pull-requests"]) { + record.receipt = recordGroup(record.receipt, "pull-requests", { + release_pr: null, + bind: "next_head", + integrated_support_prs: [], + }); + } + const completeWhenForced = new Set([ + "qualification", + "awaiting_approval", + "promotion", + "publication", + "closeout", + "complete", + ]); + const need = (name) => { + const target = PHASES.indexOf(phase); + const current = PHASES.indexOf(name); + return completeWhenForced.has(phase) ? current <= target : current < target; + }; + if (need("source_stabilization") && !record.receipt.groups["source-stabilization"]) { + record.receipt = recordGroup(record.receipt, "source-stabilization", success("source-stabilization", 100, C, C_TREE)); + record.receipt = recordGroup(record.receipt, "source-proof", success("source-proof", 105, C, C_TREE)); + } + if (need("calibration") && !record.receipt.groups.calibration) { + record.receipt = recordGroup(record.receipt, "calibration", [ + success("metal-1", 101, C, C_TREE), + success("metal-2", 102, C, C_TREE), + success("metal-3", 103, C, C_TREE), + ]); + } + if (need("freeze") && !record.receipt.groups["frozen-candidate"]) { + record.receipt = recordGroup(record.receipt, "frozen-candidate", { commit: F, tree: F_TREE }); + record.heads.F = F; + record.heads.F_tree = F_TREE; + } + if (need("frozen_candidate_acceptance") && !record.receipt.groups["frozen-candidate-acceptance"]) { + record.receipt = recordGroup( + record.receipt, + "frozen-candidate-acceptance", + success("frozen-candidate-acceptance", 104, F, F_TREE), + ); + } + if (need("package") && !record.receipt.groups.package) { + record.receipt = recordGroup(record.receipt, "package", success("package", 106, F, F_TREE)); + } + if (need("hardware") && !record.receipt.groups.hardware) { + record.receipt = recordGroup(record.receipt, "hardware", success("hardware", 107, F, F_TREE)); + } + if (need("installed_candidate") && !record.receipt.groups["installed-candidate"]) { + record.receipt = recordGroup( + record.receipt, + "installed-candidate", + success("installed-candidate", 108, F, F_TREE), + ); + } + if (need("qualification") && !record.receipt.groups.qualification) { + record.receipt = recordGroup(record.receipt, "qualification", success("qualification", 109, F, F_TREE)); + } + if (need("publication") && !record.receipt.groups.promotion) { + record.receipt = recordGroup(record.receipt, "promotion", { + pull_request: 1999, + approver: "rehearsal", + approved_at: host.now().toISOString(), + }); + } + if (need("closeout") && !record.receipt.groups.publication && !record.rehearse) { + record.receipt = recordGroup(record.receipt, "publication", { + commit: F, + tree: F_TREE, + tag: `v${record.receipt.version}`, + release_url: `https://github.com/TheGreenCedar/CodeStory/releases/tag/v${record.receipt.version}`, + release_run: success("publication", 110, F, F_TREE), + }); + } + if (need("closeout") && !record.receipt.groups["pre-publish-ledger"]) { + record.receipt = recordGroup(record.receipt, "pre-publish-ledger", { + artifact: "pre-publish-ledger-attempt-1", + digest: DIGEST, + }); + } +} + +export function createTestHost(overrides = {}) { + const cloned = overrides.cloneFrom + ? structuredClone({ + issues: overrides.cloneFrom.issues, + comments: overrides.cloneFrom.comments, + runs: overrides.cloneFrom.runs, + dispatches: overrides.cloneFrom.dispatches, + heads: overrides.cloneFrom.heads, + probes: overrides.cloneFrom.probes, + marketplace: overrides.cloneFrom.marketplace, + assets: overrides.cloneFrom.assets, + _record: overrides.cloneFrom._record, + freezeBarrierCalls: overrides.cloneFrom.freezeBarrierCalls, + cancelled: overrides.cloneFrom.cancelled, + tags: overrides.cloneFrom.tags, + cancelHonored: overrides.cloneFrom.cancelHonored, + generatedFreezeHead: overrides.cloneFrom.generatedFreezeHead, + }) + : null; + const host = { + now: () => new Date("2026-08-21T18:00:00Z"), + heads: cloned?.heads ?? overrides.heads ?? { + next: { commit: "c".repeat(40), tree: "1".repeat(40) }, + main: { commit: "0".repeat(40), tree: "9".repeat(40) }, + }, + probes: { ...defaultProbes(), ...(cloned?.probes ?? {}), ...(overrides.probes ?? {}) }, + issues: cloned?.issues ?? [], + comments: cloned?.comments ?? [], + runs: cloned?.runs ?? [], + dispatches: cloned?.dispatches ?? [], + cancelled: cloned?.cancelled ?? [], + freezeBarrierCalls: cloned?.freezeBarrierCalls ?? [], + tags: cloned?.tags ?? [], + marketplace: cloned?.marketplace ?? overrides.marketplace ?? { state: "unpublished" }, + assets: cloned?.assets ?? overrides.assets ?? [...NATIVE_ASSETS], + cancelHonored: cloned?.cancelHonored ?? overrides.cancelHonored ?? true, + promotedToMain: false, + ciGreen: false, + operatorSuppliedRunIds: [], + generatedFreezeHead: cloned?.generatedFreezeHead ?? overrides.generatedFreezeHead ?? { + commit: "f".repeat(40), + tree: "2".repeat(40), + }, + _record: cloned?._record ?? null, + _nextId: 500, + _commentId: 1, + }; + Object.defineProperty(host, "record", { + get() { + return host._record; + }, + set(value) { + host._record = value; + }, + }); + host.workflowRuns = () => host.runs; + host.createIssue = ({ title, body }) => { + const issue = { number: 1997, title, body, state: "open" }; + host.issues.push(issue); + return issue; + }; + host.listOpenCoordinatorIssues = () => host.issues.filter((issue) => + issue.state === "open" && /coordinator/i.test(issue.title) + ); + host.listIssueComments = () => host.comments; + host.createIssueComment = (_number, body) => { + const comment = { id: host._commentId, body }; + host._commentId += 1; + host.comments.push(comment); + return comment; + }; + host.updateIssueComment = (id, body) => { + const comment = host.comments.find((entry) => entry.id === id); + if (comment) comment.body = body; + }; + host.persist = () => { + if (host._record) persist(host, host._record); + }; + host.persistRecord = (record) => { + host._record = record; + }; + host.dispatch = ({ workflow, ref, inputs }) => { + host._nextId += 1; + const dispatched = { + id: host._nextId, + workflow, + ref, + inputs, + headSha: inputs.expected_head_sha ?? host.heads.next.commit, + status: "in_progress", + conclusion: null, + event: "workflow_dispatch", + attempt: 1, + }; + host.dispatches.push(dispatched); + host.runs.push({ ...dispatched }); + return dispatched; + }; + host.cancelRun = (id) => { + if (host.cancelHonored) { + host.runs = host.runs.filter((run) => run.id !== id); + host.cancelled.push(id); + } + }; + host.freezeBarrier = (command, args) => { + host.freezeBarrierCalls.push([command, args]); + }; + host.completeActiveRuns = () => { + for (const run of host.runs) { + if (run.status === "in_progress") { + run.status = "completed"; + run.conclusion = "success"; + } + } + }; + host.forcePhase = (phase) => { + if (!host._record) fail("forcePhase requires a started coordinator record"); + fillReceipt(host._record, phase, host); + host._record.phase = phase; + persist(host, host._record); + }; + host.promoteToMain = () => { + host.promotedToMain = true; + }; + host.createTag = (name) => { + host.tags.push(name); + }; + if (overrides.lane) host.lane = overrides.lane; + return host; +} + +function gh(args, options = {}) { + return execFileSync("gh", args, { + encoding: "utf8", + input: options.input, + stdio: options.input ? ["pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"], + }).trim(); +} + +export function createDefaultHost({ repository = "TheGreenCedar/CodeStory" } = {}) { + return { + now: () => new Date(), + repository, + probes: defaultProbes(), + operatorSuppliedRunIds: [], + get heads() { + const next = gh(["api", `repos/${repository}/git/ref/heads/dev%2Fcodestory-next`]); + const main = gh(["api", `repos/${repository}/git/ref/heads/main`]); + const nextSha = JSON.parse(next).object.sha; + const mainSha = JSON.parse(main).object.sha; + const nextCommit = JSON.parse(gh(["api", `repos/${repository}/git/commits/${nextSha}`])); + const mainCommit = JSON.parse(gh(["api", `repos/${repository}/git/commits/${mainSha}`])); + return { + next: { commit: nextSha, tree: nextCommit.tree.sha }, + main: { commit: mainSha, tree: mainCommit.tree.sha }, + }; + }, + runs: [], + marketplace: { state: "unpublished" }, + assets: [...NATIVE_ASSETS], + createIssue({ title, body }) { + const created = JSON.parse(gh( + ["api", "--method", "POST", `repos/${repository}/issues`, "--input", "-"], + { input: JSON.stringify({ title, body }) }, + )); + return { number: created.number, title: created.title }; + }, + listOpenCoordinatorIssues() { + const rows = JSON.parse(gh([ + "issue", + "list", + "--repo", + repository, + "--state", + "open", + "--search", + "coordinator in:title", + "--json", + "number,title,state", + ])); + return rows; + }, + listIssueComments(number) { + return JSON.parse(gh([ + "api", + `repos/${repository}/issues/${number}/comments`, + ])); + }, + createIssueComment(number, body) { + return JSON.parse(gh( + ["api", "--method", "POST", `repos/${repository}/issues/${number}/comments`, "--input", "-"], + { input: JSON.stringify({ body }) }, + )); + }, + updateIssueComment(id, body) { + return JSON.parse(gh( + ["api", "--method", "PATCH", `repos/${repository}/issues/comments/${id}`, "--input", "-"], + { input: JSON.stringify({ body }) }, + )); + }, + dispatch({ workflow, ref, inputs }) { + const args = ["workflow", "run", workflow, "--repo", repository, "--ref", ref]; + for (const [key, value] of Object.entries(inputs ?? {})) { + if (value === undefined || value === null) continue; + args.push("-f", `${key}=${value}`); + } + gh(args); + return { id: Date.now(), workflow, ref, inputs, status: "queued" }; + }, + workflowRuns() { + const rows = JSON.parse(gh([ + "run", + "list", + "--repo", + repository, + "--limit", + "30", + "--json", + "databaseId,headSha,status,conclusion,event,workflowName,attempt", + ])); + const workflowPath = (name) => { + if (name === "Exact-head source proof") return SOURCE_PROOF_WORKFLOW; + if (name === "Platform and integration proof") return PACKAGED_WORKFLOW; + if (name === "Release") return RELEASE_WORKFLOW; + return name; + }; + return (rows ?? []).map((row) => ({ + id: row.databaseId, + workflow: workflowPath(row.workflowName), + headSha: row.headSha, + status: row.status, + conclusion: row.conclusion, + event: row.event, + attempt: row.attempt ?? 1, + })); + }, + cancelRun(id) { + gh(["run", "cancel", String(id), "--repo", repository]); + }, + }; +} + +function printStatus(result) { + const lines = [ + `phase: ${result.phase}`, + `sha: ${result.sha}`, + `tree: ${result.tree}`, + `active_runs: ${result.active_runs.length}`, + `blocker: ${result.blocker ?? "none"}`, + `next: ${result.next_action}`, + `elapsed: ${result.elapsed}`, + `critical_path: ${result.estimated_critical_path}`, + `rehearse: ${result.rehearse}`, + ]; + process.stdout.write(`${lines.join("\n")}\n`); +} + +async function main() { + const result = await execute(process.argv.slice(2), createDefaultHost()); + printStatus(result); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error) => { + process.stderr.write(`codestory-release: ${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/scripts/codestory-release.test.mjs b/scripts/codestory-release.test.mjs new file mode 100644 index 000000000..834883d7d --- /dev/null +++ b/scripts/codestory-release.test.mjs @@ -0,0 +1,276 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + RECEIPT_SCHEMA, + initReceipt, + validatePhase, +} from "../.github/scripts/release-driver-receipt.mjs"; +import { + COORDINATOR_SCHEMA, + SOURCE_PROOF_WORKFLOW, + RELEASE_WORKFLOW, + BROAD_WORKFLOWS, + createTestHost, + execute, +} from "./codestory-release.mjs"; + +const C = "c".repeat(40); +const C_TREE = "1".repeat(40); +const F = "f".repeat(40); +const F_TREE = "2".repeat(40); +const NEXT_SHA = C; + +function startArgs(extra = []) { + return ["start", "--version", "0.17.5", "--lane", "native", ...extra]; +} + +async function started(overrides = {}) { + const host = createTestHost({ + heads: { next: { commit: C, tree: C_TREE }, main: { commit: "0".repeat(40), tree: "9".repeat(40) } }, + ...overrides, + }); + const result = await execute(startArgs(overrides.rehearse ? ["--rehearse"] : []), host); + return { host, result }; +} + +function runRow(overrides = {}) { + return { + id: 501, + workflow: SOURCE_PROOF_WORKFLOW, + headSha: C, + status: "in_progress", + conclusion: null, + event: "workflow_dispatch", + attempt: 1, + inputs: { + acceptance_only: "true", + acceptance_phase: "source_stabilization", + expected_head_sha: C, + version: "0.17.5", + }, + ...overrides, + }; +} + +test("start writes a GitHub-backed driver receipt and compact status", async () => { + const { host, result } = await started(); + assert.equal(result.phase, "preflight"); + assert.equal(result.sha, C); + assert.equal(result.tree, C_TREE); + assert.equal(result.blocker, null); + assert.match(result.next_action, /preflight|source.stabilization/i); + assert.equal(typeof result.elapsed, "string"); + assert.equal(typeof result.estimated_critical_path, "string"); + assert.equal(result.record.schema, COORDINATOR_SCHEMA); + assert.equal(result.record.receipt.schema, RECEIPT_SCHEMA); + assert.equal(result.record.receipt.version, "0.17.5"); + assert.equal(result.record.lane, "native"); + assert.ok(host.issues.length >= 1); + assert.ok(host.comments.some((comment) => comment.body.includes(COORDINATOR_SCHEMA))); + assert.equal(result.record.receipt.groups["calibration-source"].value.commit, C); +}); + +test("status reconstructs the record from GitHub without copied run IDs", async () => { + const { host } = await started(); + host.runs.push(runRow()); + const status = await execute(["status"], host); + assert.equal(status.phase, "source_stabilization"); + assert.equal(status.active_runs.length, 1); + assert.equal(status.active_runs[0].workflow, SOURCE_PROOF_WORKFLOW); + assert.match(status.next_action, /wait/i); +}); + +test("advance dispatches source stabilization with an explicit acceptance phase, never the frozen default", async () => { + const { host } = await started(); + const advanced = await execute(["advance"], host); + assert.equal(advanced.dispatched.workflow, SOURCE_PROOF_WORKFLOW); + assert.equal(advanced.dispatched.inputs.acceptance_only, "true"); + assert.equal(advanced.dispatched.inputs.acceptance_phase, "source_stabilization"); + assert.notEqual(advanced.dispatched.inputs.acceptance_phase, "frozen_candidate"); + assert.equal(advanced.dispatched.inputs.expected_head_sha, C); + const again = await execute(["advance"], host); + assert.equal(again.dispatched, null); + assert.match(again.next_action, /wait/i); +}); + +test("wrong acceptance mode is rejected before dispatch", async () => { + const { host } = await started({ + probes: { acceptanceMode: "frozen_candidate" }, + }); + await assert.rejects( + () => execute(["advance"], host), + /acceptance_phase=source_stabilization/, + ); + assert.equal(host.dispatches.length, 0); +}); + +test("expensive qualification cannot dispatch before preflight", async () => { + const { host } = await started({ + probes: { runners: { "macos-arm64-metal": "unproven" } }, + }); + host.record.phase = "qualification"; + host.persist(); + await assert.rejects( + () => execute(["advance"], host), + /preflight|runner|heartbeat|unproven/i, + ); + assert.equal(host.dispatches.length, 0); + const status = await execute(["status"], host); + assert.match(status.blocker, /runner|heartbeat|unproven/i); + assert.equal(status.next_action.split("\n").length, 1); +}); + +test("macOS zombie or unreadable identity fails closed with one next action", async () => { + const { host } = await started({ probes: { macosZombie: "zombie", macosIdentity: "unreadable" } }); + await assert.rejects(() => execute(["advance"], host), /zombie|identity/i); + const status = await execute(["status"], host); + assert.match(status.blocker, /zombie|identity/i); + assert.doesNotMatch(status.next_action, /\n/); +}); + +test("Windows staging failure fails closed before another package dispatch", async () => { + const { host } = await started({ probes: { windowsStaging: "fail" } }); + host.forcePhase("package"); + await assert.rejects(() => execute(["advance"], host), /windows|staging|path/i); + assert.equal(host.dispatches.length, 0); +}); + +test("source drift cancels superseded work and resumes at the earliest valid phase", async () => { + const { host } = await started(); + await execute(["advance"], host); + host.completeActiveRuns(); + const moved = "d".repeat(40); + host.heads.next = { commit: moved, tree: "3".repeat(40) }; + host.runs.push(runRow({ id: 777, status: "in_progress", headSha: C })); + const status = await execute(["status"], host); + assert.equal(status.phase, "preflight"); + assert.ok(host.cancelled.includes(777) || host.freezeBarrierCalls.some((call) => call[0] === "cancel-superseded")); + assert.ok(status.record.receipt.invalidations.length >= 1); + assert.match(status.next_action, /source.stabilization|preflight/i); +}); + +test("ignored ordinary cancellation stops with force-cancel as the next action", async () => { + const { host } = await started({ cancelHonored: false }); + await execute(["advance"], host); + const moved = "d".repeat(40); + host.heads.next = { commit: moved, tree: "3".repeat(40) }; + host.runs.push(runRow({ id: 888, status: "in_progress", headSha: C })); + const status = await execute(["status"], host); + assert.match(status.blocker ?? "", /cancel/i); + assert.match(status.next_action, /invalidate-superseded|force-cancel/i); + assert.equal(host.dispatches.filter((row) => row.workflow !== SOURCE_PROOF_WORKFLOW).length, 0); +}); + +test("optional evaluation does not delay a standard native claim", async () => { + const { host } = await started(); + host.forcePhase("qualification"); + host.runs.push({ + id: 900, + workflow: "optional-evaluation", + headSha: F, + status: "in_progress", + conclusion: null, + event: "workflow_dispatch", + attempt: 1, + inputs: { qualify_linux_vulkan: "true" }, + optional: true, + }); + const advanced = await execute(["advance"], host); + assert.notEqual(advanced.phase, "qualification"); + assert.ok(!advanced.blocker || !/quality|optional|performance/i.test(advanced.blocker)); +}); + +test("qualification success does not publish", async () => { + const { host } = await started(); + host.forcePhase("qualification"); + host.completeActiveRuns(); + const advanced = await execute(["advance"], host); + assert.equal( + host.dispatches.some((row) => + row.workflow === RELEASE_WORKFLOW && row.inputs?.publish_release === "true" + ), + false, + ); + assert.equal(advanced.phase, "awaiting_approval"); + assert.match(advanced.next_action, /approval/i); +}); + +test("plugin-only and native asset expectations are not interchangeable", async () => { + const native = await started({ lane: "native" }); + native.host.forcePhase("publication"); + native.host.assets = ["plugin-only"]; + await assert.rejects(() => execute(["advance"], native.host), /native|archive|asset/i); + + const plugin = createTestHost({ + heads: { next: { commit: C, tree: C_TREE }, main: { commit: "0".repeat(40), tree: "9".repeat(40) } }, + }); + await execute(["start", "--version", "0.17.5", "--lane", "plugin"], plugin); + plugin.forcePhase("publication"); + plugin.assets = ["linux-x64", "macos-arm64", "windows-x64"]; + const status = await execute(["status"], plugin); + assert.match(status.blocker ?? status.next_action, /plugin|lane|asset/i); +}); + +test("deferred marketplace is an honest closeout state, not published", async () => { + const { host } = await started({ rehearse: true }); + host.forcePhase("closeout"); + host.marketplace = { state: "deferred", installer_identity: "codex_marketplace_deferred_fixture" }; + const status = await execute(["status"], host); + assert.equal(status.record.receipt.groups["catalog-delivery"].value.state, "deferred"); + assert.notEqual(status.record.receipt.groups["catalog-delivery"].value.state, "published"); +}); + +test("resume after interruption does not require operator-copied identifiers", async () => { + const { host } = await started(); + await execute(["advance"], host); + const runId = host.dispatches[0].id; + const issue = host.issues[0].number; + const resumedHost = createTestHost({ + cloneFrom: host, + argvIssue: undefined, + }); + const resumed = await execute(["resume"], resumedHost); + assert.equal(resumed.record.issue_number, issue); + assert.ok(resumed.active_runs.some((row) => row.id === runId) || resumed.phase === "source_stabilization"); + assert.equal(resumedHost.operatorSuppliedRunIds.length, 0); +}); + +test("rehearse walks the machine without tagging or publish_release true", async () => { + const { host } = await started({ rehearse: true }); + let guard = 0; + let result = await execute(["advance"], host); + while (result.phase !== "complete" && guard < 40) { + host.completeActiveRuns(); + if (result.phase === "awaiting_approval") { + result = await execute(["advance", "--record-approval", "--approver", "Albert"], host); + } else { + result = await execute(["advance"], host); + } + guard += 1; + } + assert.equal(result.phase, "complete"); + assert.equal(result.rehearse, true); + assert.equal(host.tags.length, 0); + assert.equal( + host.dispatches.some((row) => row.inputs?.publish_release === "true"), + false, + ); + assert.equal(host.promotedToMain, false); +}); + +test("promotion and publication require recorded approval, not green CI", async () => { + const { host } = await started(); + host.forcePhase("awaiting_approval"); + host.ciGreen = true; + await assert.rejects(() => execute(["advance"], host), /approval/i); + assert.equal(host.promotedToMain, false); + const approved = await execute(["advance", "--record-approval", "--approver", "Albert"], host); + assert.ok(approved.record.approval?.approver); + assert.equal(approved.record.approval.approver, "Albert"); +}); + +test("initReceipt remains the durable group store underneath the coordinator", async () => { + const receipt = initReceipt("0.17.5"); + assert.equal(receipt.schema, RECEIPT_SCHEMA); + assert.throws(() => validatePhase(receipt, "pre-freeze")); +}); diff --git a/scripts/tests/codestory-release-claims.test.mjs b/scripts/tests/codestory-release-claims.test.mjs index 180d5353b..5a360f9dc 100644 --- a/scripts/tests/codestory-release-claims.test.mjs +++ b/scripts/tests/codestory-release-claims.test.mjs @@ -188,7 +188,7 @@ test("versioned claim graph has one deterministic digest and all declared contro status_creator: "github-actions[bot]", job_manifest: ".github/scripts/release-freeze-acceptance-jobs.json", job_manifest_sha256: - "b1ae13eabcbda9c99f7d0ba17cae97ca292d11fa4b010f87c534f9b7938843c7", + "7eb0812b601c3f06d5885b741d4dd1775b85c10619b18fc680dbb77a9ccc13b4", phases: { source_stabilization: { known_future_source_changes: [ diff --git a/scripts/tests/fixtures/release-claims/positive.json b/scripts/tests/fixtures/release-claims/positive.json index 6aed80b71..9f60acead 100644 --- a/scripts/tests/fixtures/release-claims/positive.json +++ b/scripts/tests/fixtures/release-claims/positive.json @@ -17,7 +17,7 @@ "type": "source_behavior", "tier": "source", "status": "pass", - "graph_sha256": "d3263fef491c5c98462b6be30ba450dbcd73aaa078983c7f657acbb853a1517e", + "graph_sha256": "01c33786c7b946b89264a8b124b22d6bbd57fbef9dd6c48c354fa1f99b2f3fa6", "observed_at": "2026-07-16T11:00:00.000Z", "expires_at": "2026-07-17T11:00:00.000Z", "identity": {