warning: in the working copy of 'tools/omp-spt/Cargo.lock', LF will be replaced by CRLF the next time Git touches it diff --git a/.gitattributes b/.gitattributes index 22ebdb5..4fa3bb7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,7 +13,7 @@ ci/git-hooks/* text eol=lf *.json text eol=lf *.toml text eol=lf *.md text eol=lf -# Generated agent indexes (llms.txt / llms-full.txt) are emitted LF by ci/docs/gen-llms.sh; force +# Generated agent indexes (llms.txt / llms-full.txt) are emitted LF by ci/docs/build-docs.py; force # LF so the docs-drift gate's diff (generated LF vs committed file) never false-fails on a CRLF # checkout. Applies to any *.txt for consistency. *.txt text eol=lf diff --git a/CHANGELOG.md b/CHANGELOG.md index 81088ec..9fa6579 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable user-facing changes to **omp-spt** (the Spacetime adapter for oh-my- > Each release below is keyed to the **adapter version of truth** (the version `spt adapter list` reports and the GitHub release tag). omp-spt starts its own version line at 0.1.0; the sections from [0.17.3] down are the inherited **claude-spt** lineage this project forked from, retained for history. +## [0.3.0] - 2026-07-16 + +> Requires spt-core **v0.31.0 or newer** and Oh My Pi **v16.3.15 or newer**. Update with `spt adapter update omp-spt`, then restart existing endpoints so they load the new extension and skills. + +### Added +- **An ordinary OMP session can now become a ready or live SPT endpoint in place.** Native `/ready` and `/live` commands select or create endpoint identity, while `/live --auto` offers the most-recent compatible live identity for explicit confirmation; the extension owns the listener, so the agent never has to run a background poll. +- **Live endpoints stay reachable during long turns and after interrupted work.** Accepted peer messages enter at the next safe tool or model boundary, retain ordered custody when no boundary appears, and restore honest receivability after cancellation, interruption, or failure. +- **OMP now ships native commune, signoff, role, and setup skills.** Commune includes an OMP-native checkpoint mode that saves continuity, compacts context, and wakes the same endpoint; setup diagnoses versions, activation, GitHub transport, and subnet onboarding. +- **Activated sessions now teach the SPT surface directly.** Startup guidance, targeted hints, and compatible-update notices cover identity, roster, messaging, lifecycle, continuity, versions, and subnet operations without duplicating public `spt` commands as skills. +- **Assistant output can send peer messages with `@`.** The extension dispatches only the explicit shortform, excludes code blocks and ordinary mentions, and reports each target's delivery result. +- **The release now includes a static Linux x86-64 musl helper compatibility tier.** Windows MSVC, GNU Linux, and static-musl Linux helpers ship together in one deterministic `adapter.spt`. + +### Changed +- **Release truth is now mechanically enforced.** The manifest version drives helper, plugin, changelog, and public-doc versions; deterministic documentation drift checks gate GitHub Pages publication; each supported target has schema-validated, digest-bound native acceptance evidence. + ## [0.2.1] - 2026-07-15 > Requires spt-core **v0.31.0 or newer** for identity-preserving listener authentication. Run `spt update`, then `spt adapter update omp-spt`, and restart each endpoint. diff --git a/README.md b/README.md index 763f23e..96e5155 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ messaging, session continuity, history, digests, and live-agent context lifecycl Native OMP owns the attachable TUI and its broker PTY. The packaged OMP extension handles endpoint binding, message delivery, busy/idle state, replies, and shutdown from inside that native session. -The release is one fat `adapter.spt` archive containing the supported adapter binaries and -`adapter/strings/omp-spt.mjs`. +The release is one fat `adapter.spt` archive containing the supported adapter binaries, manifest, +native extension, OMP plugin metadata, and packaged commune, signoff, role, and setup skills. ## Prerequisites @@ -20,11 +20,17 @@ The release is one fat `adapter.spt` archive containing the supported adapter bi - [`spt-core`](https://sabermage.github.io/spt-releases), version 0.31.0 or newer, providing the `spt` command and identity-preserving listener authentication. -There is no plugin installation step. Install OMP and `spt-core`, then add the adapter release. +There is no separate plugin installation step. Install OMP and `spt-core`, then add the adapter +release with its bundled native extension and OMP plugin skills. ## Install -The v0.2.1 release asset supports **x86_64 Windows and x86_64 Linux only**. It does not contain a macOS or Arm64 payload. Pick a supported shell below; these commands install either prerequisite only when it is missing, then install `omp-spt` from `BigscreenVR/omp-spt`. +The v0.3.0 release asset supports **x86_64 Windows and x86_64 Linux only**. It +contains Windows MSVC, Linux GNU, and static Linux musl x86_64 helpers, but no +macOS or Arm64 payload. The musl helper is a compatibility tier for OMP-capable +hosts; it is not a generic Alpine support claim. Pick a supported shell below; +these commands install either prerequisite only when it is missing, then install +`omp-spt` from `BigscreenVR/omp-spt`. **PowerShell (x86_64 Windows)** diff --git a/SCOPE.md b/SCOPE.md index ce7075e..28b6f0e 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -50,6 +50,19 @@ The public surface is deliberately narrow: - Deterministic unit/integration checks plus a same-node native endpoint release gate. +6. **Capability parity** + - Extension-native ready/live activation for already-open OMP sessions, + including explicit live auto-resume and extension-owned listener custody. + - Safe-boundary delivery during active turns and automatic receivability + restoration after abnormal turn termination. + - Startup briefs, targeted hints, compatible-update notices, packaged + commune/signoff/role/setup skills, native checkpoint continuity, and the + cross-harness peer-message shortform. + - OMP-native provider/profile routing satisfies alternate-routing parity. + - Windows x86-64, GNU Linux x86-64, and static Linux x86-64 musl helper + targets, each with durable machine-readable release evidence. + + ## Out of scope - A headless RPC bridge or any second endpoint topology. @@ -63,10 +76,14 @@ The public surface is deliberately narrow: ## Skill policy -No OMP-native skill delivery/distribution route is currently proven. The -adapter therefore ships no command skills. The public `spt` CLI remains -available directly. Skills enter scope only after an OMP extension registration -and release-packaging path is specified and tested. +The public OMP `omp-plugins` provider is the canonical delivery seam for +model-driven adapter capabilities packaged beside the loaded extension. The +adapter will ship OMP-native skills for commune (including checkpoint mode), +signoff, durable role, and the agent-driven half of setup; release verification +must prove discovery from the installed `adapter.spt` layout. Deterministic +lifecycle infrastructure remains extension-owned: ready/live activation, +binding, listener custody, delivery, activity, and shutdown are not delegated +to model-executed skills. ## Release boundary @@ -85,3 +102,7 @@ re-prove spt-core's subnet transport. | Endpoint/session binding is immutable | ADR-0011 | Locked | | Continuity lives under `.spt/` | ADR-0012 | Locked | | Release gate stops at adapter boundary | ADR-0013 | Locked | +| Extension-owned session activation | ADR-0015 | Locked | +| Agent capabilities split by native seam | ADR-0016 | Locked | +| Active-turn delivery uses safe boundaries | ADR-0017 | Locked | +| Checkpoint resets context natively | ADR-0018 | Locked | diff --git a/adapter/omp-spt.toml b/adapter/omp-spt.toml index 6594bf5..a435603 100644 --- a/adapter/omp-spt.toml +++ b/adapter/omp-spt.toml @@ -9,7 +9,7 @@ [adapter] name = "omp-spt" kind = "harness" -version = "0.2.1" +version = "0.3.0" # v0.31.0 added identity-preserving `api listen --session-id`, which keeps the # listener on the native OMP session already recorded by the extension's bind. min_spt_core_version = "0.31.0" @@ -20,7 +20,7 @@ host_binaries = ["omp"] shortcut_basename = "omp" # Update through this repository's GitHub release. The packaged fat archive -# contains both supported target binaries plus the shared native extension. +# contains all three supported target binaries plus the shared native plugin. # [impl->REQ-DIST-ADAPTER-RELEASE] [update] avenue = "gh_release" diff --git a/adapter/strings/omp-spt.mjs b/adapter/strings/omp-spt.mjs index 692ec8a..6484464 100644 --- a/adapter/strings/omp-spt.mjs +++ b/adapter/strings/omp-spt.mjs @@ -136,6 +136,19 @@ export function extractReply(messages, afterUserMessage) { .find((message) => message?.role === "assistant"); return assistant ? messageText(assistant) : ""; } +function assistantMessageCount(messages) { + return (messages ?? []).reduce( + (count, message) => count + (message?.role === "assistant" ? 1 : 0), + 0, + ); +} + +function extractReplyAfterAssistantCount(messages, baseline) { + const assistants = (messages ?? []).filter((message) => message?.role === "assistant"); + if (assistants.length <= baseline) return ""; + return messageText(assistants.at(-1)); +} + function firstLine(text) { return text.split(/\r?\n/).find((line) => line.trim()) ?? ""; @@ -175,7 +188,169 @@ function injectEnvelope(messages, item) { return injected; } +function maskMarkdownCode(text) { + const source = String(text); + const masked = source.split(""); + let fenceCharacter; + let fenceLength = 0; + let lineStart = 0; + while (lineStart < source.length) { + const newline = source.indexOf("\n", lineStart); + const lineEnd = newline < 0 ? source.length : newline + 1; + const line = source.slice(lineStart, newline < 0 ? lineEnd : newline).replace(/\r$/, ""); + const fence = /^[ \t]*(`{3,}|~{3,})/.exec(line); + const indentedCode = !fenceCharacter && /^(?: {4}|\t)/.test(line); + let maskLine = Boolean(fenceCharacter || fence || indentedCode); + if (fence) { + const character = fence[1][0]; + if (!fenceCharacter) { + fenceCharacter = character; + fenceLength = fence[1].length; + } else if (character === fenceCharacter && fence[1].length >= fenceLength) { + fenceCharacter = undefined; + fenceLength = 0; + } else { + maskLine = true; + } + } + if (maskLine) { + for (let index = lineStart; index < lineEnd; index += 1) { + if (source[index] !== "\r" && source[index] !== "\n") masked[index] = " "; + } + } + lineStart = lineEnd; + } + + for (let cursor = 0; cursor < source.length; cursor += 1) { + if (masked[cursor] !== "`") continue; + let runLength = 1; + while (masked[cursor + runLength] === "`") runLength += 1; + const delimiter = "`".repeat(runLength); + let closing = source.indexOf(delimiter, cursor + runLength); + while ( + closing >= 0 && + (source[closing - 1] === "`" || source[closing + runLength] === "`") + ) { + closing = source.indexOf(delimiter, closing + runLength); + } + if (closing < 0) { + cursor += runLength - 1; + continue; + } + for (let index = cursor; index < closing + runLength; index += 1) { + if (source[index] !== "\r" && source[index] !== "\n") masked[index] = " "; + } + cursor = closing + runLength - 1; + } + return masked.join(""); +} + +// [impl->REQ-PARITY-PEER-SHORTFORM] +export function parsePeerShortforms(text) { + const source = String(text ?? ""); + const prose = maskMarkdownCode(source); + const shortforms = []; + const pattern = /@<([\s\S]*?)@>/g; + for (const match of prose.matchAll(pattern)) { + const inner = match[1]; + const separator = inner.search(/\s/); + if (separator <= 0) continue; + const targetList = inner.slice(0, separator); + const body = inner.slice(separator).trim(); + if (!/^[A-Za-z0-9_-]+(?:,[A-Za-z0-9_-]+)*$/.test(targetList) || !body) continue; + shortforms.push({ + targets: targetList.split(","), + body, + index: match.index, + raw: match[0], + }); + } + return shortforms; +} + +function parseJson(raw, label) { + try { + return JSON.parse(raw); + } catch (error) { + throw new Error(`${label} returned invalid JSON: ${errorSummary(error)}`); + } +} + +function parseVersion(raw) { + const match = String(raw ?? "").match(/\bv?(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?\b/); + return match ? match.slice(1, 4).map(Number) : undefined; +} + +function compareVersions(left, right) { + for (let index = 0; index < 3; index += 1) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return 0; +} + +function formatVersion(version) { + return `v${version.join(".")}`; +} + +function promptHints(prompt) { + const text = String(prompt ?? "").trim(); + if (!text) return []; + const hints = []; + if ( + /(?:^|\s)\/live(?:\s|$)/i.test(text) || + /\b(?:go|become|start|resume|activate)\s+(?:an?\s+)?live(?:\s+agent)?\b/i.test(text) + ) { + hints.push("Use extension-native `/live`; use `/live --auto` only for explicit auto-resume."); + } + if ( + /\bwho am i\b/i.test(text) || + /\b(?:show|check|what(?:'s| is))\s+(?:my\s+)?(?:spt\s+)?(?:identity|endpoint|perch)\b/i.test( + text, + ) + ) { + hints.push("Run `spt whoami --json` for this endpoint's identity."); + } + if ( + /\b(?:send|message|contact|tell|reply to)\s+(?:an?\s+|the\s+)?(?:agent|peer|endpoint)\b/i.test( + text, + ) || + /\bhow (?:do|can|should) (?:i|we) (?:send|message|contact)\b/i.test(text) + ) { + hints.push("Run `spt how-to send` for canonical peer messaging."); + } + if ( + /\bspt\s+(?:how-to\s+)?subnet\b/i.test(text) || + /\bsubnet\s+(?:status|create|join|code|onboarding|setup)\b/i.test(text) || + /\b(?:pair|join|connect)\s+(?:these\s+|two\s+)?(?:machines|nodes)\b/i.test(text) + ) { + hints.push("Run `spt how-to subnet` for private-network onboarding."); + } + if ( + /(?:^|\s)\/checkpoint(?:\s|$)/i.test(text) || + /\b(?:make|take|create|use|run|perform|need|want)\b.{0,20}\bcheckpoint\b/i.test(text) || + /\bcheckpoint\s+(?:my|this|the)\s+(?:context|session|work)\b/i.test(text) || + /\bcommune\s+(?:across|to\s+(?:the\s+)?next\s+session)\b/i.test(text) || + /\b(?:reset|compact)\s+(?:my\s+|the\s+)?(?:working\s+)?context\b/i.test(text) + ) { + hints.push("Read the packaged commune skill and use its checkpoint mode."); + } + return hints; +} + +function startupBrief(id) { + return [ + `OMP SPT endpoint \`${id}\` is active. Keep lifecycle infrastructure extension-owned.`, + "- Identity/roster: `spt whoami --json`; `spt endpoint list`.", + "- Messaging: `spt how-to send` (or explicit assistant shortform `@`).", + "- Continuity/lifecycle: use the packaged commune (checkpoint mode), signoff, and role skills.", + "- Activation/setup: `/ready`, `/live`, and the packaged setup skill.", + "- Subnets: `spt how-to subnet`.", + "- Versions/updates: `spt --version`; `spt adapter version omp-spt`; `spt update`.", + ].join("\n"); +} + const DEFAULT_COMMAND_TIMEOUT_MS = 15_000; +const DEFAULT_SHORTFORM_COMMAND_TIMEOUT_MS = 5_000; const DEFAULT_KILL_GRACE_MS = 100; const DEFAULT_KILL_FORCE_MS = 100; const DEFAULT_LISTENER_BUFFER_LIMIT = 256 * 1024; @@ -378,10 +553,13 @@ export function createOmpSpt(overrides = {}) { const setTimer = overrides.setTimeout ?? globalThis.setTimeout; const clearTimer = overrides.clearTimeout ?? globalThis.clearTimeout; const env = overrides.env ?? process.env; + const platform = overrides.platform ?? process.platform; const killGraceMs = overrides.killGraceMs ?? DEFAULT_KILL_GRACE_MS; const killForceMs = overrides.killForceMs ?? DEFAULT_KILL_FORCE_MS; const customRunSptCommand = overrides.runSptCommand; const commandTimeoutMs = overrides.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS; + const shortformCommandTimeoutMs = + overrides.shortformCommandTimeoutMs ?? DEFAULT_SHORTFORM_COMMAND_TIMEOUT_MS; const runSptCommand = customRunSptCommand ?? ((args, input, options = {}) => @@ -423,10 +601,106 @@ export function createOmpSpt(overrides = {}) { ]; const listenerStableMs = overrides.listenerStableMs === false ? undefined : (overrides.listenerStableMs ?? 30_000); + const checkUpdates = overrides.checkUpdates ?? true; + const updateProbeTimeoutMs = overrides.updateProbeTimeoutMs ?? 1_500; + const fetchLatestAdapterVersion = + overrides.fetchLatestAdapterVersion ?? + (async () => { + const controller = new AbortController(); + const timer = setTimer(() => controller.abort(), updateProbeTimeoutMs); + timer?.unref?.(); + try { + const response = await fetch( + "https://api.github.com/repos/BigscreenVR/omp-spt/releases/latest", + { + headers: { accept: "application/vnd.github+json" }, + signal: controller.signal, + }, + ); + if (!response.ok) return undefined; + return (await response.json())?.tag_name; + } catch { + return undefined; + } finally { + clearTimer(timer); + } + }); + + const checkpointParameters = (pi) => { + const z = pi.zod?.z ?? pi.zod; + return z.object({ + wake: z + .string() + .optional() + .describe("Instruction for the first continuation after native context compaction"), + }); + }; + + const endpointIdPattern = /^[A-Za-z0-9_-]+$/; + + const commandCompletions = (cachedIds, includeAuto) => (prefix) => { + const values = includeAuto ? ["--auto", ...cachedIds] : [...cachedIds]; + const matches = values + .filter((value) => value.startsWith(prefix.trim())) + .map((value) => ({ value, label: value })); + return matches.length > 0 ? matches : null; + }; + + function normalizePath(value) { + let normalized = String(value ?? "").replaceAll("\\", "/"); + while ( + normalized.endsWith("/") && + normalized.length > 1 && + !/^[A-Za-z]:\/$/.test(normalized) + ) { + normalized = normalized.slice(0, -1); + } + return platform === "win32" ? normalized.toLowerCase() : normalized; + } + + function latestDigestTimestamp(digest) { + let latest = Number.NEGATIVE_INFINITY; + for (const turn of digest?.turns ?? []) { + for (const entry of turn?.entries ?? []) { + for (const value of Object.values(entry ?? {})) { + const parsed = Date.parse(value?.ts ?? ""); + if (Number.isFinite(parsed)) latest = Math.max(latest, parsed); + } + } + } + return latest; + } + + function newestCoreUpdate(rawNotifications, currentVersion) { + const notifications = parseJson(rawNotifications, "spt notif list").notifs ?? []; + let newest; + for (const notification of notifications) { + if ( + notification?.from_id !== "spt-update" || + notification?.kind !== "consent" || + notification?.state === "dismissed" + ) { + continue; + } + const version = parseVersion(notification.head); + if (!version || compareVersions(version, currentVersion) <= 0) continue; + if (!newest || compareVersions(version, newest) > 0) newest = version; + } + return newest; + } + return function ompSpt(pi) { - const id = env.SPT_ENDPOINT_ID?.trim(); - if (!id) return; + let id = env.SPT_ENDPOINT_ID?.trim() || undefined; + const initialId = id; + let activationType; + let activationPromise; + let activated = false; + let startupBriefPending = false; + let updateNoticesPromise = Promise.resolve([]); + let updateNoticesPending = true; + let cachedReadyIds = []; + let cachedLiveIds = []; let sid; let token; @@ -441,6 +715,8 @@ export function createOmpSpt(overrides = {}) { let desiredState = "idle"; let dispatching = false; let turnCompletionPromise; + let turnAssistantBaseline; + let knownAssistantCount = 0; let listenerTerminationPromise; let shutdownMode = false; let shutdownDeadlineExpired = false; @@ -526,6 +802,426 @@ export function createOmpSpt(overrides = {}) { activeCommands.delete(controller); }); } + // [impl->REQ-PARITY-UPDATE-NOTICE] + async function detectUpdateNotices() { + const [coreVersionResult, notificationsResult, adapterVersionResult, latestAdapterResult] = + await Promise.allSettled([ + runCommand(["--version"]), + runCommand(["--json", "notif", "list"]), + runCommand(["adapter", "version", ADAPTER]), + fetchLatestAdapterVersion(), + ]); + const notices = []; + + if ( + coreVersionResult.status === "fulfilled" && + notificationsResult.status === "fulfilled" + ) { + const current = parseVersion(coreVersionResult.value); + if (current) { + try { + const latest = newestCoreUpdate(notificationsResult.value, current); + if (latest) { + notices.push( + `spt-core ${formatVersion(latest)} is available; run \`spt update\`.`, + ); + } + } catch (error) { + pi.logger.debug("omp-spt update notification probe skipped", { + error: errorSummary(error), + }); + } + } + } + + if ( + adapterVersionResult.status === "fulfilled" && + latestAdapterResult.status === "fulfilled" + ) { + const current = parseVersion(adapterVersionResult.value); + const latest = parseVersion(latestAdapterResult.value); + if (current && latest && compareVersions(latest, current) > 0) { + notices.push( + `omp-spt ${formatVersion(latest)} is available; run \`spt adapter update omp-spt\`, then restart this endpoint.`, + ); + } + } + return notices; + } + + function beginUpdateProbe() { + updateNoticesPending = true; + updateNoticesPromise = checkUpdates + ? detectUpdateNotices().catch((error) => { + pi.logger.debug("omp-spt update probe skipped", { + error: errorSummary(error), + }); + return []; + }) + : Promise.resolve([]); + } + + async function compatibleCandidates(type, ctx) { + const listing = parseJson( + await runCommand(["--json", "endpoint", "list", "--show-all"]), + "spt endpoint list", + ); + const local = (listing.local ?? []).filter( + (candidate) => + candidate?.state === type && + candidate?.alive !== true && + endpointIdPattern.test(candidate?.id ?? ""), + ); + const currentDirectory = normalizePath(ctx.cwd); + const detailed = await Promise.all( + local.map(async (candidate) => { + try { + const info = parseJson( + await runCommand(["--json", "api", "endpoint-info", candidate.id]), + `spt api endpoint-info ${candidate.id}`, + ); + if (String(info.adapter ?? "").split(":")[0] !== ADAPTER) return undefined; + if ( + currentDirectory && + info.cwd && + normalizePath(info.cwd) !== currentDirectory + ) { + return undefined; + } + return { id: candidate.id, info }; + } catch (error) { + pi.logger.debug("omp-spt activation candidate skipped", { + id: candidate.id, + error: errorSummary(error), + }); + return undefined; + } + }), + ); + const candidates = detailed.filter(Boolean); + const ids = candidates.map((candidate) => candidate.id).sort(); + if (type === "ready_agent") cachedReadyIds.splice(0, cachedReadyIds.length, ...ids); + else cachedLiveIds.splice(0, cachedLiveIds.length, ...ids); + return candidates; + } + + async function chooseIdentity(type, ctx) { + if (!ctx.hasUI) { + ctx.ui.notify( + `/${type === "live_agent" ? "live" : "ready"} requires an endpoint id when native selection is unavailable`, + "error", + ); + return undefined; + } + let candidates; + try { + candidates = await compatibleCandidates(type, ctx); + } catch (error) { + logError("omp-spt could not list compatible endpoint identities", error); + return undefined; + } + const createLabel = "Create a new endpoint id"; + const selected = + candidates.length > 0 + ? await ctx.ui.select( + `Select ${type === "live_agent" ? "live" : "ready"} endpoint`, + [...candidates.map((candidate) => candidate.id), createLabel], + ) + : createLabel; + if (!selected) return undefined; + if (selected !== createLabel) return selected; + return (await ctx.ui.input("New SPT endpoint id", "letters, numbers, - or _"))?.trim(); + } + + // [impl->REQ-PARITY-LIVE-AUTO-RESUME] + async function chooseAutoResume(ctx) { + if (!ctx.hasUI) { + ctx.ui.notify("`/live --auto` requires native confirmation UI", "error"); + return undefined; + } + let candidates; + try { + candidates = await compatibleCandidates("live_agent", ctx); + } catch (error) { + logError("omp-spt could not list live auto-resume candidates", error); + return undefined; + } + const recent = ( + await Promise.all( + candidates.map(async (candidate) => { + try { + const digest = parseJson( + await runCommand([ + "--json", + "endpoint", + "digest", + candidate.id, + "--last", + "1", + ]), + `spt endpoint digest ${candidate.id}`, + ); + return { + ...candidate, + lastActiveAt: latestDigestTimestamp(digest), + }; + } catch { + return { ...candidate, lastActiveAt: Number.NEGATIVE_INFINITY }; + } + }), + ) + ).filter((candidate) => Number.isFinite(candidate.lastActiveAt)); + recent.sort( + (left, right) => + right.lastActiveAt - left.lastActiveAt || left.id.localeCompare(right.id), + ); + if (recent.length === 0) { + ctx.ui.notify( + "No compatible prior omp-spt live identity has recorded activity; use `/live `", + "error", + ); + return undefined; + } + let candidate = recent[0]; + const ties = recent.filter((item) => item.lastActiveAt === candidate.lastActiveAt); + if (ties.length > 1) { + const selected = await ctx.ui.select( + "Select equally recent live endpoint", + ties.map((item) => item.id), + ); + if (!selected) return undefined; + candidate = ties.find((item) => item.id === selected); + } + const confirmed = await ctx.ui.confirm( + "Resume live endpoint?", + `${candidate.id} was the most recently active compatible live endpoint (${new Date( + candidate.lastActiveAt, + ).toISOString()}). Bind this OMP session to it?`, + ); + return confirmed ? candidate.id : undefined; + } + + // [impl->REQ-PARITY-READY-ACTIVATION] + // [impl->REQ-PARITY-LIVE-ACTIVATION] + async function activateEndpoint(nextId, type, ctx, options = {}) { + if (!endpointIdPattern.test(nextId ?? "")) { + ctx.ui.notify( + "SPT endpoint ids may contain only letters, numbers, `-`, and `_`", + "error", + ); + return false; + } + if (activated || token) { + ctx.ui.notify( + `This OMP session is immutably bound to ${id}; stop it before activating another identity`, + "warning", + ); + return false; + } + if (stopping) { + ctx.ui.notify("This OMP session is already shutting down", "error"); + return false; + } + if (activationPromise) return activationPromise; + + runtimeCtx = ctx; + ui = ctx.ui; + sid = ctx.sessionManager.getSessionId(); + id = nextId; + activationType = type; + let bound = false; + const operation = (async () => { + const bindArgs = [ + "api", + "--adapter", + ADAPTER, + "bind", + id, + "--set-session-id", + sid, + ]; + if (type) bindArgs.push("--type", type); + if (env.OMP_SPT_SUBNET) bindArgs.push("--subnet", env.OMP_SPT_SUBNET); + bindPromise = (async () => { + const response = await runCommand(bindArgs); + token = response.match(/\btoken=([^\s]+)/)?.[1]; + if (!token) throw new Error("spt bind response did not include token="); + bound = true; + })(); + try { + await bindPromise; + await syncDesiredState(); + if (stopping) { + await teardownSession("OMP session shut down before initialization completed"); + return false; + } + activated = true; + startupBriefPending = true; + ui.setStatus("omp-spt", `spt:${id}`); + startListener(); + beginUpdateProbe(); + if (options.announce) { + ui.notify( + `OMP session activated as ${type === "live_agent" ? "live" : "ready"} endpoint ${id}`, + "info", + ); + } + return true; + } catch (error) { + ui.setStatus("omp-spt", "spt bind failed"); + if (options.fatal || bound) { + await failClosed(`omp-spt could not bind ${id}`, error); + return false; + } + pi.logger.error(`omp-spt could not bind ${id}`, { + error: errorSummary(error), + }); + ui.notify(`omp-spt could not bind ${id}: ${errorSummary(error)}`, "error"); + id = undefined; + activationType = undefined; + bindPromise = undefined; + token = undefined; + return false; + } + })(); + activationPromise = operation; + try { + return await operation; + } finally { + if (!activated && activationPromise === operation) activationPromise = undefined; + } + } + + async function handleActivationCommand(type, args, ctx) { + const command = type === "live_agent" ? "live" : "ready"; + const trimmed = args.trim(); + let nextId; + if (trimmed === "--auto") { + if (type !== "live_agent") { + ctx.ui.notify("`--auto` is supported only by `/live`", "error"); + return; + } + nextId = await chooseAutoResume(ctx); + } else if (!trimmed) { + nextId = await chooseIdentity(type, ctx); + } else if (/\s/.test(trimmed) || trimmed.startsWith("-")) { + ctx.ui.notify(`Usage: /${command} ${command === "live" ? " | --auto" : ""}`, "error"); + return; + } else { + nextId = trimmed; + } + if (!nextId) return; + await activateEndpoint(nextId, type, ctx, { announce: true }); + } + pi.registerCommand("ready", { + description: "Activate this OMP session as a ready SPT endpoint", + getArgumentCompletions: commandCompletions(cachedReadyIds, false), + handler: (args, ctx) => handleActivationCommand("ready_agent", args, ctx), + }); + + pi.registerCommand("live", { + description: "Activate this OMP session as a live SPT endpoint", + getArgumentCompletions: commandCompletions(cachedLiveIds, true), + handler: (args, ctx) => handleActivationCommand("live_agent", args, ctx), + }); + async function resolveActivationType() { + if (activationType) return activationType; + const info = parseJson( + await runCommand(["--json", "api", "endpoint-info", id]), + `spt api endpoint-info ${id}`, + ); + if (!["live_agent", "ready_agent"].includes(info.endpoint_type)) { + throw new Error("endpoint-info did not report live_agent or ready_agent"); + } + activationType = info.endpoint_type; + return activationType; + } + + + // [impl->REQ-PARITY-CHECKPOINT] + pi.registerTool({ + name: "spt_checkpoint", + label: "SPT Checkpoint", + description: + "After the commune skill has saved this live endpoint's continuity drop, compact native OMP context and wake the same endpoint.", + parameters: checkpointParameters(pi), + async execute(_toolCallId, parameters, _signal, _onUpdate, ctx) { + if (!activated || !id) { + return { + content: [{ type: "text", text: "SPT checkpoint failed: no active endpoint." }], + details: { ok: false, reason: "not-activated" }, + isError: true, + }; + } + let resolvedType; + try { + resolvedType = await resolveActivationType(); + } catch (error) { + return { + content: [ + { + type: "text", + text: `SPT checkpoint failed: could not verify a live endpoint: ${errorSummary(error)}`, + }, + ], + details: { ok: false, reason: "type-unverified" }, + isError: true, + }; + } + if (resolvedType !== "live_agent") { + return { + content: [ + { + type: "text", + text: "SPT checkpoint failed: continuity checkpoints require a live endpoint.", + }, + ], + details: { ok: false, reason: "not-live" }, + isError: true, + }; + } + const wake = + parameters.wake?.trim() || + "Resume from the saved commune context and continue the prior work."; + try { + await ctx.compact({ + internalGuidance: + "Preserve only the durable state needed to continue from the just-saved SPT commune.", + }); + if (stopping) throw new Error("OMP session began shutting down during checkpoint"); + pi.sendMessage( + { + customType: "omp-spt-checkpoint-wake", + content: wake, + display: true, + attribution: "user", + }, + { deliverAs: "nextTurn", triggerTurn: true }, + ); + return { + content: [ + { + type: "text", + text: `SPT checkpoint complete for ${id}; native continuation queued.`, + }, + ], + details: { ok: true, endpoint: id }, + }; + } catch (error) { + return { + content: [ + { + type: "text", + text: `SPT checkpoint failed: ${errorSummary(error)}`, + }, + ], + details: { ok: false, reason: errorSummary(error) }, + isError: true, + }; + } + }, + }); + function abortActiveCommands(reason, allowBindGrace = false) { for (const [controller, active] of activeCommands) { @@ -1024,49 +1720,13 @@ export function createOmpSpt(overrides = {}) { runtimeCtx = ctx; ui = ctx.ui; sid = ctx.sessionManager.getSessionId(); - const bindArgs = ["api", "--adapter", ADAPTER, "bind", id, "--set-session-id", sid]; - if (env.OMP_SPT_SUBNET) bindArgs.push("--subnet", env.OMP_SPT_SUBNET); - bindPromise = (async () => { - const bound = await runCommand(bindArgs); - token = bound.match(/\btoken=([^\s]+)/)?.[1]; - if (!token) throw new Error("spt bind response did not include token="); - })(); - try { - await bindPromise; - } catch (error) { - if (stopping) return; - ui.setStatus("omp-spt", "spt bind failed"); - await failClosed(`omp-spt could not bind ${id}`, error); - return; - } - if (stopping) { - try { - await teardownSession("OMP session shut down before initialization completed"); - } catch (error) { - logError("omp-spt session teardown failed", error); - } - return; - } - try { - await syncDesiredState(); - if (stopping) { - await teardownSession("OMP session shut down before initialization completed"); - return; - } - ui.setStatus("omp-spt", `spt:${id}`); - startListener(); - } catch (error) { - if (stopping) { - logError("omp-spt session teardown failed", error); - return; - } - ui.setStatus("omp-spt", "spt bind failed"); - await failClosed(`omp-spt could not bind ${id}`, error); - } + if (!initialId) return; + await activateEndpoint(initialId, undefined, ctx, { fatal: true }); }); // [impl->REQ-OMP-SESSION-IMMUTABLE] const blockSessionChange = (description, ctx) => { + if (!activated && !token) return; ctx.ui.notify( `omp-spt blocked the in-TUI ${description}; end this SPT session first`, "warning", @@ -1082,19 +1742,113 @@ export function createOmpSpt(overrides = {}) { ); // [impl->REQ-OMP-MESSAGE-CONTEXT] + // [impl->REQ-PARITY-SAFE-BOUNDARY-DELIVERY] pi.on("context", (event) => { - if (!current?.submitted || current.settling) return; - const messages = injectEnvelope(event.messages, current); + const assistantBaseline = assistantMessageCount(event.messages); + turnAssistantBaseline ??= assistantBaseline; + let messages = event.messages; + if ( + agentActive && + !current && + !dispatching && + queue.length > 0 + ) { + const item = queue.shift(); + item.stub = senderStub(item.from ?? "unknown"); + item.submitted = true; + item.activeInjected = true; + item.assistantBaseline = assistantBaseline; + current = item; + } + if (current?.submitted && !current.settling) { + if (current.activeInjected) { + const content = `${current.stub}\n\n${current.envelope}`; + if ( + !messages.some( + (message) => message?.role === "user" && messageText(message) === content, + ) + ) { + messages = [...messages, { role: "user", content }]; + } + } else { + current.assistantBaseline ??= assistantBaseline; + messages = injectEnvelope(messages, current); + } + } if (messages !== event.messages) return { messages }; }); + // [impl->REQ-PARITY-STARTUP-BRIEF] + // [impl->REQ-PARITY-TARGETED-HINTS] + // [impl->REQ-PARITY-UPDATE-NOTICE] + pi.on("before_agent_start", async (event) => { + if (!activated || !id || stopping) return; + const additions = []; + if (startupBriefPending) { + startupBriefPending = false; + additions.push(startupBrief(id)); + } + const hints = promptHints(event.prompt); + if (hints.length > 0) additions.push(`OMP SPT targeted hints:\n- ${hints.join("\n- ")}`); + if (updateNoticesPending) { + updateNoticesPending = false; + const notices = await updateNoticesPromise; + if (notices.length > 0) additions.push(`OMP SPT updates:\n- ${notices.join("\n- ")}`); + } + if (additions.length === 0) return; + return { + systemPrompt: [...(event.systemPrompt ?? []), additions.join("\n\n")], + }; + }); + + // [impl->REQ-PARITY-PEER-SHORTFORM] + async function dispatchShortforms(assistantOutput) { + const shortforms = parsePeerShortforms(assistantOutput); + if (shortforms.length === 0 || stopping) return; + const deliveries = shortforms.flatMap((shortform) => + shortform.targets.map((target) => ({ target, body: shortform.body })), + ); + const statuses = await Promise.all( + deliveries.map(async ({ target, body }) => { + if (!id) return `${target}: failed (activate this OMP session first)`; + try { + const result = await runCommand(["send", target, "--from", id], body, { + timeoutMs: shortformCommandTimeoutMs, + }); + return `${target}: ${firstLine(result) || "sent"}`; + } catch (error) { + return `${target}: failed (${errorSummary(error)})`; + } + }), + ); + if (stopping) return; + try { + pi.sendMessage( + { + customType: "omp-spt-peer-status", + content: `OMP SPT peer-message results:\n- ${statuses.join("\n- ")}`, + display: true, + attribution: "user", + }, + { deliverAs: "nextTurn", triggerTurn: true }, + ); + } catch (error) { + if (!stopping) logError("omp-spt could not inject peer-message results", error); + } + } + + // [impl->REQ-HAZARD-ABNORMAL-TURN-RECEIVABILITY] async function completeTurn(event) { agentActive = false; desiredState = "idle"; if (stopping) return; + const messages = event.messages ?? []; const completed = current; + const turnBaseline = turnAssistantBaseline ?? knownAssistantCount; + const completedBaseline = completed?.assistantBaseline ?? turnBaseline; + const currentTurnReply = extractReplyAfterAssistantCount(messages, turnBaseline); if (completed?.submitted && !completed.settled) { - const reply = extractReply(event.messages, completed.stub); + const reply = extractReplyAfterAssistantCount(messages, completedBaseline); try { await settleItem( completed, @@ -1113,6 +1867,7 @@ export function createOmpSpt(overrides = {}) { current = undefined; releaseItem(completed); } + knownAssistantCount = assistantMessageCount(messages); try { await setState("idle"); } catch (error) { @@ -1120,12 +1875,14 @@ export function createOmpSpt(overrides = {}) { await failClosed("omp-spt could not mark the endpoint idle", error); return; } - scheduleDispatch(); + await dispatchShortforms(currentTurnReply); + if (!stopping) scheduleDispatch(); } pi.on("agent_start", async () => { if (stopping) return; turnCompletionPromise = undefined; + turnAssistantBaseline = undefined; agentActive = true; desiredState = "busy"; try { diff --git a/ci/publish/create-adapter-archive.py b/ci/publish/create-adapter-archive.py index 000e34c..eecb7d9 100644 --- a/ci/publish/create-adapter-archive.py +++ b/ci/publish/create-adapter-archive.py @@ -3,15 +3,23 @@ from __future__ import annotations +import gzip import os from pathlib import Path import sys import tarfile -def add_member(archive: tarfile.TarFile, source: Path, arcname: str, mode: int) -> None: +def add_member( + archive: tarfile.TarFile, + source: Path, + arcname: str, + mode: int, + epoch: int, +) -> None: info = archive.gettarinfo(str(source), arcname=arcname) info.mode = mode + info.mtime = epoch info.uid = 0 info.gid = 0 info.uname = "" @@ -24,41 +32,130 @@ def add_member(archive: tarfile.TarFile, source: Path, arcname: str, mode: int) def main() -> int: - if len(sys.argv) != 5: + if len(sys.argv) != 6: print( - "usage: create-adapter-archive.py ", + "usage: create-adapter-archive.py " + " ", file=sys.stderr, ) return 2 stage = Path(sys.argv[1]) output = Path(sys.argv[2]) - windows_triple = sys.argv[3] - linux_triple = sys.argv[4] - linux_mode = int(os.environ.get("OMP_SPT_ARCHIVE_LINUX_MODE", "0755"), 8) + windows_triple, gnu_triple, musl_triple = sys.argv[3:6] + expected_triples = ( + "x86_64-pc-windows-msvc", + "x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + ) + if (windows_triple, gnu_triple, musl_triple) != expected_triples: + print( + "release target labels must be exactly " + ", ".join(expected_triples), + file=sys.stderr, + ) + return 1 + + try: + epoch = int(os.environ.get("SOURCE_DATE_EPOCH", "0"), 10) + gnu_mode = int(os.environ.get("OMP_SPT_ARCHIVE_LINUX_MODE", "0755"), 8) + musl_mode = int(os.environ.get("OMP_SPT_ARCHIVE_MUSL_MODE", "0755"), 8) + except ValueError as error: + print(f"invalid archive metadata setting: {error}", file=sys.stderr) + return 2 + if epoch < 0: + print("SOURCE_DATE_EPOCH must be non-negative", file=sys.stderr) + return 2 members = [ (stage / "manifest.toml", "manifest.toml", 0o644), (stage / "strings", "strings", 0o755), (stage / "strings" / "omp-spt.mjs", "strings/omp-spt.mjs", 0o644), + (stage / "strings" / "package.json", "strings/package.json", 0o644), + (stage / "strings" / "skills", "strings/skills", 0o755), + (stage / "strings" / "skills" / "commune", "strings/skills/commune", 0o755), + ( + stage / "strings" / "skills" / "commune" / "SKILL.md", + "strings/skills/commune/SKILL.md", + 0o644, + ), + (stage / "strings" / "skills" / "role", "strings/skills/role", 0o755), + ( + stage / "strings" / "skills" / "role" / "SKILL.md", + "strings/skills/role/SKILL.md", + 0o644, + ), + (stage / "strings" / "skills" / "setup", "strings/skills/setup", 0o755), + ( + stage / "strings" / "skills" / "setup" / "SKILL.md", + "strings/skills/setup/SKILL.md", + 0o644, + ), + (stage / "strings" / "skills" / "signoff", "strings/skills/signoff", 0o755), + ( + stage / "strings" / "skills" / "signoff" / "SKILL.md", + "strings/skills/signoff/SKILL.md", + 0o644, + ), (stage / windows_triple, windows_triple, 0o755), ( stage / windows_triple / "omp-spt.exe", f"{windows_triple}/omp-spt.exe", 0o755, ), - (stage / linux_triple, linux_triple, 0o755), - (stage / linux_triple / "omp-spt", f"{linux_triple}/omp-spt", linux_mode), + (stage / gnu_triple, gnu_triple, 0o755), + (stage / gnu_triple / "omp-spt", f"{gnu_triple}/omp-spt", gnu_mode), + (stage / musl_triple, musl_triple, 0o755), + (stage / musl_triple / "omp-spt", f"{musl_triple}/omp-spt", musl_mode), + ] + directories = { + "strings", + "strings/skills", + "strings/skills/commune", + "strings/skills/role", + "strings/skills/setup", + "strings/skills/signoff", + windows_triple, + gnu_triple, + musl_triple, + } + missing = [ + str(source) + for source, arcname, _ in members + if not source.exists() + or source.is_symlink() + or (arcname in directories and not source.is_dir()) + or (arcname not in directories and not source.is_file()) ] - missing = [str(source) for source, _, _ in members if not source.exists()] if missing: - print(f"missing staged archive member(s): {', '.join(missing)}", file=sys.stderr) + print( + f"missing or invalid staged archive member(s): {', '.join(missing)}", + file=sys.stderr, + ) + return 1 + + expected = {arcname for _, arcname, _ in members} + actual = { + candidate.relative_to(stage).as_posix() + for candidate in stage.rglob("*") + } + unexpected = sorted(actual - expected) + if unexpected: + print( + f"unexpected staged archive member(s): {', '.join(unexpected)}", + file=sys.stderr, + ) return 1 output.parent.mkdir(parents=True, exist_ok=True) - with tarfile.open(output, "w:gz", format=tarfile.PAX_FORMAT) as archive: - for source, arcname, mode in members: - add_member(archive, source, arcname, mode) + with output.open("wb") as raw_archive: + with gzip.GzipFile( + filename="", mode="wb", fileobj=raw_archive, mtime=epoch + ) as compressed: + with tarfile.open( + fileobj=compressed, mode="w", format=tarfile.PAX_FORMAT + ) as archive: + for source, arcname, mode in members: + add_member(archive, source, arcname, mode, epoch) return 0 diff --git a/ci/publish/package-adapter.sh b/ci/publish/package-adapter.sh index 00efca5..67c6e8d 100644 --- a/ci/publish/package-adapter.sh +++ b/ci/publish/package-adapter.sh @@ -5,9 +5,10 @@ # # Archive root: # manifest.toml -# strings/omp-spt.mjs +# strings/{omp-spt.mjs,package.json,skills/*/SKILL.md} # x86_64-pc-windows-msvc/omp-spt.exe # x86_64-unknown-linux-gnu/omp-spt +# x86_64-unknown-linux-musl/omp-spt # # spt-core classifies the recognized triple directories and flattens this # node's binary beside the shared root. The manifest's v0.31.0 floor is above @@ -23,20 +24,33 @@ ADAPTER="$ROOT/adapter" MANIFEST="$ADAPTER/omp-spt.toml" # renamed to manifest.toml INSIDE the archive (root-only rule) STRINGS="${OMP_SPT_STRINGS_DIR:-$ADAPTER/strings}" EXTENSION="$STRINGS/omp-spt.mjs" +SHARED_FILES="omp-spt.mjs package.json skills/commune/SKILL.md skills/role/SKILL.md skills/setup/SKILL.md skills/signoff/SKILL.md" TOOLS="$ROOT/tools" OUT="${ADAPTER_SPT_OUT:-$ROOT/dist/adapter.spt}" # overridable so the unit test writes to a tmp file ARCHIVE_WRITER="$HERE/create-adapter-archive.py" +BINARY_VALIDATOR="$HERE/validate-release-binary.py" APPLY=0 [ "${1:-}" = "--apply" ] && APPLY=1 -# Native Windows builds land in target/release; Linux cross-builds land in the -# target-specific release directory. Tests may override those locations. +# Native Windows builds land in target/release; Linux cross-builds land in their +# target-specific release directories. Tests may override those locations. WIN_TRIPLE=x86_64-pc-windows-msvc -LINUX_TRIPLE=x86_64-unknown-linux-gnu +GNU_TRIPLE=x86_64-unknown-linux-gnu +MUSL_TRIPLE=x86_64-unknown-linux-musl WIN_RELSUB="${OMP_SPT_WIN_RELSUB:-release}" -LINUX_RELSUB="${OMP_SPT_LINUX_RELSUB:-$LINUX_TRIPLE/release}" +GNU_RELSUB="${OMP_SPT_LINUX_RELSUB:-$GNU_TRIPLE/release}" +MUSL_RELSUB="${OMP_SPT_MUSL_RELSUB:-$MUSL_TRIPLE/release}" BINS="omp-spt" +PY="" +for cand in "${OMP_SPT_PYTHON:-}" python python3 py; do + [ -n "$cand" ] || continue + command -v "$cand" >/dev/null 2>&1 || continue + PY=$cand + break +done +[ -n "$PY" ] || { echo "FATAL: Python is required to validate release targets and author portable archive modes"; exit 2; } + magic_of_file() { od -An -tx1 -N4 "$1" 2>/dev/null | tr -d '[:space:]' } @@ -48,12 +62,14 @@ if ! sh "$ROOT/ci/manifest/check-manifest.sh"; then exit 1 fi -# The native extension is load-bearing: an archive without this exact file can register but cannot -# launch a hosted endpoint. -if [ ! -f "$EXTENSION" ]; then - echo "REFUSING to package: required native extension missing: $EXTENSION" >&2 - exit 1 -fi +# Every shared plugin file is load-bearing. Refuse a partially staged plugin rather than shipping +# an adapter that registers but lacks native extension discovery or a lifecycle skill. +for rel in $SHARED_FILES; do + if [ ! -f "$STRINGS/$rel" ]; then + echo "REFUSING to package: required shared plugin file missing: $STRINGS/$rel" >&2 + exit 1 + fi +done # A fat archive is only readable on spt-core >= 0.13.2 — the manifest MUST declare that floor. floor=$(grep -E '^min_spt_core_version' "$MANIFEST" | head -1 | sed -E 's/.*"([^"]+)".*/\1/') @@ -62,49 +78,45 @@ case "$floor" in *) echo "REFUSING to package: min_spt_core_version is '$floor' but a multi-platform fat .spt needs >= 0.13.2 (doyle)." >&2; exit 1 ;; esac -# Require BOTH platforms' release binaries (NOT auto-copied by register — they ride the archive). -# Refuse stale/text fixtures at release time: the Windows member must be PE and the Linux member ELF. -# Executable mode is authored explicitly into the archive below because MSYS cannot represent it -# reliably for an extensionless ELF file on NTFS. +# Require ALL three release binaries. The validator checks real PE32+/ELF64 x86_64 headers, +# the GNU program interpreter, and the musl target's static-link contract (no PT_INTERP or +# DT_NEEDED). Merely renaming another target's binary cannot satisfy the release. +# [impl->REQ-DIST-LINUX-MUSL] echo echo "== tool binaries ==" invalid=0 -win_path() { echo "$TOOLS/omp-spt/target/$WIN_RELSUB/$1.exe"; } -linux_path() { echo "$TOOLS/omp-spt/target/$LINUX_RELSUB/$1"; } -for b in $BINS; do - win=$(win_path "$b") - linux=$(linux_path "$b") - if [ -f "$win" ]; then - case "$(magic_of_file "$win")" in - 4d5a*) echo " ok $win (PE)" ;; - *) echo " BAD $win (not a PE executable)"; invalid=1 ;; - esac - else - echo " MISS $win" +win_path() { echo "$TOOLS/omp-spt/target/$WIN_RELSUB/$1.exe"; } +gnu_path() { echo "$TOOLS/omp-spt/target/$GNU_RELSUB/$1"; } +musl_path() { echo "$TOOLS/omp-spt/target/$MUSL_RELSUB/$1"; } +validate_binary() { + target=$1 + path=$2 + if [ ! -f "$path" ]; then + echo " MISS $path" invalid=1 - fi - if [ -f "$linux" ]; then - case "$(magic_of_file "$linux")" in - 7f454c46) echo " ok $linux (ELF)" ;; - *) echo " BAD $linux (not an ELF executable)"; invalid=1 ;; - esac - else - echo " MISS $linux" + elif ! "$PY" "$BINARY_VALIDATOR" "$target" "$path"; then invalid=1 fi +} +for b in $BINS; do + validate_binary "$WIN_TRIPLE" "$(win_path "$b")" + validate_binary "$GNU_TRIPLE" "$(gnu_path "$b")" + validate_binary "$MUSL_TRIPLE" "$(musl_path "$b")" done if [ "$invalid" -ne 0 ]; then - echo "REFUSING to package: build valid binaries for BOTH platforms — Windows: sh ci/digest/build.sh;" >&2 - echo "Linux (cross): cargo-zigbuild --release --target $LINUX_TRIPLE for each tool crate (see docs/RELEASE-RUNBOOK.md)." >&2 + echo "REFUSING to package: build valid binaries for ALL release targets — Windows: sh ci/digest/build.sh;" >&2 + echo "GNU + static musl Linux: cargo zigbuild --release --target --manifest-path tools/omp-spt/Cargo.toml" >&2 + echo "(see docs/RELEASE-RUNBOOK.md)." >&2 exit 1 fi echo echo "== plan ($([ "$APPLY" -eq 1 ] && echo APPLY || echo DRY-RUN)) ==" echo "manifest : $MANIFEST -> (root) manifest.toml [min_spt_core $floor]" -echo "extension: $EXTENSION -> (root) strings/omp-spt.mjs" -echo "win bins : -> $WIN_TRIPLE/{$(echo $BINS | tr ' ' ',')}.exe" -echo "linux bins: -> $LINUX_TRIPLE/{$(echo $BINS | tr ' ' ',')}" +echo "plugin : $STRINGS -> (root) strings/{omp-spt.mjs,package.json,skills/*/SKILL.md}" +echo "win bin : -> $WIN_TRIPLE/omp-spt.exe" +echo "GNU bin : -> $GNU_TRIPLE/omp-spt" +echo "musl bin : -> $MUSL_TRIPLE/omp-spt [ELF64 x86_64, static]" echo "asset : $OUT (the single fat adapter.spt — auto-resolves the host triple on install)" if [ "$APPLY" -ne 1 ]; then @@ -115,59 +127,109 @@ if [ "$APPLY" -ne 1 ]; then exit 0 fi -# Stage the archive ROOT, then write it with explicit portable member modes. In particular, Python's -# tar writer can author Linux mode 0755 even when the release is cross-packaged on MSYS/NTFS. +# Stage the exact archive root, then write it with explicit portable member modes. Python's tar +# writer authors both Linux files as 0755 even when cross-packaging on MSYS/NTFS. STAGE=$(mktemp -d "${TMPDIR:-/tmp}/omp-spt-adapter.XXXXXX") || { echo "FATAL: mktemp failed"; exit 2; } trap 'rm -rf "$STAGE"' EXIT cp "$MANIFEST" "$STAGE/manifest.toml" || { echo "FATAL: could not stage manifest.toml"; exit 2; } -mkdir -p "$STAGE/strings" "$STAGE/$WIN_TRIPLE" "$STAGE/$LINUX_TRIPLE" \ +mkdir -p "$STAGE/strings" "$STAGE/$WIN_TRIPLE" "$STAGE/$GNU_TRIPLE" "$STAGE/$MUSL_TRIPLE" \ || { echo "FATAL: could not create archive staging directories"; exit 2; } -cp "$EXTENSION" "$STAGE/strings/omp-spt.mjs" \ - || { echo "FATAL: could not stage strings/omp-spt.mjs"; exit 2; } +for rel in $SHARED_FILES; do + mkdir -p "$(dirname "$STAGE/strings/$rel")" \ + || { echo "FATAL: could not stage strings/$rel parent"; exit 2; } + cp "$STRINGS/$rel" "$STAGE/strings/$rel" \ + || { echo "FATAL: could not stage strings/$rel"; exit 2; } +done for b in $BINS; do cp "$(win_path "$b")" "$STAGE/$WIN_TRIPLE/$b.exe" \ || { echo "FATAL: could not stage $WIN_TRIPLE/$b.exe"; exit 2; } - cp "$(linux_path "$b")" "$STAGE/$LINUX_TRIPLE/$b" \ - || { echo "FATAL: could not stage $LINUX_TRIPLE/$b"; exit 2; } + cp "$(gnu_path "$b")" "$STAGE/$GNU_TRIPLE/$b" \ + || { echo "FATAL: could not stage $GNU_TRIPLE/$b"; exit 2; } + cp "$(musl_path "$b")" "$STAGE/$MUSL_TRIPLE/$b" \ + || { echo "FATAL: could not stage $MUSL_TRIPLE/$b"; exit 2; } done -PY="" -for cand in "${OMP_SPT_PYTHON:-}" python python3 py; do - [ -n "$cand" ] || continue - command -v "$cand" >/dev/null 2>&1 || continue - PY=$cand - break -done -[ -n "$PY" ] || { echo "FATAL: Python is required to author portable archive modes"; exit 2; } mkdir -p "$(dirname "$OUT")" || { echo "FATAL: cannot create $(dirname "$OUT")"; exit 2; } -"$PY" "$ARCHIVE_WRITER" "$STAGE" "$OUT" "$WIN_TRIPLE" "$LINUX_TRIPLE" \ +"$PY" "$ARCHIVE_WRITER" "$STAGE" "$OUT" "$WIN_TRIPLE" "$GNU_TRIPLE" "$MUSL_TRIPLE" \ || { echo "FATAL: portable archive writer failed"; exit 2; } - -# Self-validate the produced archive: exact shared files + BOTH valid target binaries. +# Self-validate the completed artifact: exact deterministic membership, portable modes, target +# formats/linkage, and byte-for-byte identity with every staged release input. echo echo "== validate archive ==" if ! listing=$(tar -tzf "$OUT"); then echo "FATAL: could not list completed archive" >&2 exit 2 fi +expected=$(printf '%s\n' \ + manifest.toml \ + strings \ + strings/omp-spt.mjs \ + strings/package.json \ + strings/skills \ + strings/skills/commune \ + strings/skills/commune/SKILL.md \ + strings/skills/role \ + strings/skills/role/SKILL.md \ + strings/skills/setup \ + strings/skills/setup/SKILL.md \ + strings/skills/signoff \ + strings/skills/signoff/SKILL.md \ + "$WIN_TRIPLE" "$WIN_TRIPLE/omp-spt.exe" \ + "$GNU_TRIPLE" "$GNU_TRIPLE/omp-spt" \ + "$MUSL_TRIPLE" "$MUSL_TRIPLE/omp-spt" | sort) +normalized=$(printf '%s\n' "$listing" | sed 's:/$::' | sort) fatal=0 -echo "$listing" | grep -qx "manifest.toml" || { echo "FATAL: manifest.toml not at archive root" >&2; fatal=1; } -echo "$listing" | grep -qx "strings/omp-spt.mjs" || { echo "FATAL: strings/omp-spt.mjs not at archive root" >&2; fatal=1; } -echo "$listing" | grep -qx "$WIN_TRIPLE/omp-spt.exe" || { echo "FATAL: missing $WIN_TRIPLE/omp-spt.exe" >&2; fatal=1; } -echo "$listing" | grep -qx "$LINUX_TRIPLE/omp-spt" || { echo "FATAL: missing $LINUX_TRIPLE/omp-spt" >&2; fatal=1; } -unexpected=$(printf '%s\n' "$listing" | grep -vE "^(manifest\.toml|strings/?|strings/omp-spt\.mjs|$WIN_TRIPLE/?|$WIN_TRIPLE/omp-spt\.exe|$LINUX_TRIPLE/?|$LINUX_TRIPLE/omp-spt)$" || true) -[ -n "$unexpected" ] && { echo "FATAL: unexpected archive member(s): [$unexpected]" >&2; fatal=1; } -win_magic=$(tar -xOzf "$OUT" "$WIN_TRIPLE/omp-spt.exe" 2>/dev/null | od -An -tx1 -N4 | tr -d '[:space:]') -linux_magic=$(tar -xOzf "$OUT" "$LINUX_TRIPLE/omp-spt" 2>/dev/null | od -An -tx1 -N4 | tr -d '[:space:]') +if [ "$normalized" != "$expected" ]; then + echo "FATAL: archive membership differs from the exact release contract" >&2 + echo "expected: [$expected]" >&2 + echo "actual: [$normalized]" >&2 + fatal=1 +fi + +VERIFY="$STAGE/.archive-verify" +mkdir -p "$VERIFY" +verify_exact() { + source=$1 + member=$2 + extracted="$VERIFY/$member" + mkdir -p "$(dirname "$extracted")" + if ! tar -xOzf "$OUT" "$member" >"$extracted" 2>/dev/null; then + echo "FATAL: could not extract $member for payload verification" >&2 + fatal=1 + elif ! cmp -s "$source" "$extracted"; then + echo "FATAL: archived $member differs from its release input" >&2 + fatal=1 + fi +} +verify_exact "$MANIFEST" "manifest.toml" +for rel in $SHARED_FILES; do + verify_exact "$STRINGS/$rel" "strings/$rel" +done +verify_exact "$(win_path omp-spt)" "$WIN_TRIPLE/omp-spt.exe" +verify_exact "$(gnu_path omp-spt)" "$GNU_TRIPLE/omp-spt" +verify_exact "$(musl_path omp-spt)" "$MUSL_TRIPLE/omp-spt" + +win_magic=$(magic_of_file "$VERIFY/$WIN_TRIPLE/omp-spt.exe") +gnu_magic=$(magic_of_file "$VERIFY/$GNU_TRIPLE/omp-spt") +musl_magic=$(magic_of_file "$VERIFY/$MUSL_TRIPLE/omp-spt") case "$win_magic" in 4d5a*) : ;; *) echo "FATAL: archived Windows payload is not PE" >&2; fatal=1 ;; esac -[ "$linux_magic" = "7f454c46" ] || { echo "FATAL: archived Linux payload is not ELF" >&2; fatal=1; } -linux_mode=$(tar -tvzf "$OUT" "$LINUX_TRIPLE/omp-spt" 2>/dev/null | awk 'NR == 1 {print $1}') -case "$linux_mode" in -rwx*) : ;; *) echo "FATAL: archived Linux payload is not executable ($linux_mode)" >&2; fatal=1 ;; esac +[ "$gnu_magic" = "7f454c46" ] || { echo "FATAL: archived GNU payload is not ELF" >&2; fatal=1; } +[ "$musl_magic" = "7f454c46" ] || { echo "FATAL: archived musl payload is not ELF" >&2; fatal=1; } +for triple in "$GNU_TRIPLE" "$MUSL_TRIPLE"; do + mode=$(tar -tvzf "$OUT" "$triple/omp-spt" 2>/dev/null | awk 'NR == 1 {print $1}') + case "$mode" in + -rwx*) : ;; + *) echo "FATAL: archived $triple payload is not executable ($mode)" >&2; fatal=1 ;; + esac +done +"$PY" "$BINARY_VALIDATOR" "$WIN_TRIPLE" "$VERIFY/$WIN_TRIPLE/omp-spt.exe" || fatal=1 +"$PY" "$BINARY_VALIDATOR" "$GNU_TRIPLE" "$VERIFY/$GNU_TRIPLE/omp-spt" || fatal=1 +"$PY" "$BINARY_VALIDATOR" "$MUSL_TRIPLE" "$VERIFY/$MUSL_TRIPLE/omp-spt" || fatal=1 [ "$fatal" -eq 0 ] || exit 1 -echo "ok exact manifest + extension + PE/ELF target payloads; Linux executable mode preserved" +echo "ok exact shared plugin + PE/GNU ELF/static musl target payloads; Linux executable modes preserved" echo echo "WROTE $OUT (single fat adapter.spt). Next (operator): attach as 'adapter.spt' to a GitHub release" echo "on BigscreenVR/omp-spt; end users 'spt adapter add --release BigscreenVR/omp-spt' (spt" -echo "v$floor+). The fat archive auto-resolves the host's binaries — no per-OS --asset needed." +echo "v$floor+). The fat archive auto-resolves one of the three target helpers." exit 0 diff --git a/ci/publish/release-acquire-int.sh b/ci/publish/release-acquire-int.sh index 48d24a7..84eb9aa 100644 --- a/ci/publish/release-acquire-int.sh +++ b/ci/publish/release-acquire-int.sh @@ -14,6 +14,16 @@ fatal() { echo "FAIL $1" >&2; exit 1; } || fatal "OMP_SPT_RELEASE_TAG is required when OMP_SPT_ACCEPTANCE=1" [ -n "${OMP_SPT_EXPECTED_VERSION:-}" ] \ || fatal "OMP_SPT_EXPECTED_VERSION is required when OMP_SPT_ACCEPTANCE=1" +[ -n "${OMP_SPT_ACCEPTANCE_TARGET:-}" ] \ + || fatal "OMP_SPT_ACCEPTANCE_TARGET is required when OMP_SPT_ACCEPTANCE=1" +TARGET=$OMP_SPT_ACCEPTANCE_TARGET +# The target is an evidence key supplied by the operator on that native host. Acquisition cannot +# infer GNU versus musl from `uname`; the runbook requires native acceptance on the declared target. +# [int->REQ-DIST-LINUX-MUSL] +case "$TARGET" in + x86_64-pc-windows-msvc|x86_64-unknown-linux-gnu|x86_64-unknown-linux-musl) ;; + *) fatal "OMP_SPT_ACCEPTANCE_TARGET is not a release target: $TARGET" ;; +esac TAG=$OMP_SPT_RELEASE_TAG EXPECTED_VERSION=$OMP_SPT_EXPECTED_VERSION [ "$TAG" = "v$EXPECTED_VERSION" ] \ @@ -26,6 +36,7 @@ gh auth status >/dev/null 2>&1 || fatal "gh is not authenticated" fail=0 ok() { echo "ok $1"; } bad() { echo "FAIL $1"; fail=1; } +ok "acceptance evidence target is exactly $TARGET" trap 'spt adapter remove omp-spt >/dev/null 2>&1 || true' EXIT INT TERM spt adapter remove omp-spt >/dev/null 2>&1 || true @@ -63,5 +74,5 @@ else bad "adapter_label query failed: $label" fi -[ "$fail" -eq 0 ] && { echo "RELEASE-ACQUIRE-INT OK"; exit 0; } \ - || { echo "RELEASE-ACQUIRE-INT FAIL"; exit 1; } +[ "$fail" -eq 0 ] && { echo "RELEASE-ACQUIRE-INT OK target=$TARGET"; exit 0; } \ + || { echo "RELEASE-ACQUIRE-INT FAIL target=$TARGET"; exit 1; } diff --git a/ci/run-gates.sh b/ci/run-gates.sh index a90a9c0..007074a 100644 --- a/ci/run-gates.sh +++ b/ci/run-gates.sh @@ -16,13 +16,51 @@ run() { gate "shell syntax" for file in ci/run-gates.sh ci/digest/build.sh ci/digest/digest-proof-int.sh \ - ci/manifest/check-manifest.sh ci/publish/package-adapter.sh \ - ci/publish/release-acquire-int.sh tests/adapter-archive.sh \ - tests/ci-gates.sh tests/manifest-schema.sh tests/manifest-shortcut.sh \ - tests/native-launch-manifest.sh tests/release-acquire.sh; do + ci/docs/check-docs.sh ci/manifest/check-manifest.sh \ + ci/publish/package-adapter.sh ci/publish/release-acquire-int.sh \ + tests/adapter-archive.sh tests/ci-gates.sh tests/manifest-schema.sh \ + tests/manifest-shortcut.sh tests/native-launch-manifest.sh \ + tests/release-acquire.sh; do if sh -n "$file"; then printf 'ok %s\n' "$file"; else fail "syntax: $file"; fi done +PY="" +for candidate in python3 python py; do + command -v "$candidate" >/dev/null 2>&1 || continue + if "$candidate" -c 'import pathlib' >/dev/null 2>&1; then + PY=$candidate + break + fi +done +if [ -z "$PY" ]; then + fail "Python is required for deterministic release and docs gates" +else + gate "Python syntax" + for file in ci/docs/build-docs.py ci/manifest/validate_manifest.py \ + ci/publish/create-adapter-archive.py ci/publish/unwrap-changelog.py \ + ci/publish/validate-release-binary.py \ + ci/release/check-version-consistency.py \ + ci/release/validate-release-evidence.py tests/docs-gate.py \ + tests/release-evidence.py tests/version-consistency.py; do + if "$PY" -c 'import pathlib, sys; compile(pathlib.Path(sys.argv[1]).read_bytes(), sys.argv[1], "exec")' "$file"; then + printf 'ok %s\n' "$file" + else + fail "syntax: $file" + fi + done + # [int->REQ-DIST-VERSION-CONSISTENCY] + run "version consistency" "$PY" ci/release/check-version-consistency.py + run "version consistency behavior" "$PY" tests/version-consistency.py + run "docs drift behavior" "$PY" tests/docs-gate.py + # [int->REQ-DIST-RELEASE-EVIDENCE] + run "release evidence validation behavior" "$PY" tests/release-evidence.py +fi + +# [int->REQ-DOCS-PUBLISH-GATE] +run "documentation publish gate" sh ci/docs/check-docs.sh +run "packaged OMP skills" bun tests/omp-skills.mjs +run "CI dispatcher contract" sh tests/ci-gates.sh + run "manifest schema" sh ci/manifest/check-manifest.sh run "manifest schema behavior" sh tests/manifest-schema.sh run "manifest cutover" sh tests/manifest-shortcut.sh diff --git a/docs-site/README.md b/docs-site/README.md index 040030d..79b9142 100644 --- a/docs-site/README.md +++ b/docs-site/README.md @@ -6,3 +6,22 @@ This project's developer docs. Authored in `src/*.md`, built with **mdBook**, pu `theme/theme.css` is the **shared mdBook theme** — a Starlight-inspired skin reused verbatim across consumer projects. Wire it via `additional-css = ["theme/theme.css"]` in `book.toml`; re-point `--theme-accent` to rebrand. + +## Reproducible generation + +mdBook **0.5.3** is the pinned renderer. After editing `src/` or `SUMMARY.md`, regenerate the +checked-in agent export: + +```bash +python ci/docs/build-docs.py --write +``` + + +Run the same drift and clean-build gate used by the Pages workflow: + +```bash +sh ci/docs/check-docs.sh +``` + +The Pages build also publishes `llms.txt`, generated `llms-full.txt`, raw Markdown peers, the +adapter manifest/schema, and the release-evidence schema beside the rendered book. diff --git a/docs-site/book.toml b/docs-site/book.toml index 793fcff..8a185f1 100644 --- a/docs-site/book.toml +++ b/docs-site/book.toml @@ -7,6 +7,10 @@ authors = ["perri"] src = "src" language = "en" +[build] +build-dir = "book" +create-missing = false + [output.html] default-theme = "ayu" preferred-dark-theme = "ayu" diff --git a/docs-site/llms.txt b/docs-site/llms.txt index 4d4b57d..7e13af7 100644 --- a/docs-site/llms.txt +++ b/docs-site/llms.txt @@ -1,9 +1,10 @@ # omp-spt -> Native Oh My Pi adapter for Spacetime (spt-core). Install one `adapter.spt` release from `BigscreenVR/omp-spt`, then host attachable `ReadyAgent` and `LiveAgent` endpoints in OMP's own TUI. No plugin is required. +> Native Oh My Pi adapter for Spacetime (spt-core). Install one `adapter.spt` release from `BigscreenVR/omp-spt`, then host attachable `ReadyAgent` and `LiveAgent` endpoints in OMP's own TUI. The native extension and OMP plugin skills are bundled. ## Docs - [Introduction](introduction.html) - [Quickstart](quickstart.html) - [Native integration contract](reference/harness-contract.html) +- [Release evidence contract](reference/release-evidence.html) diff --git a/docs-site/src/SUMMARY.md b/docs-site/src/SUMMARY.md index e3df3fa..d006f2f 100644 --- a/docs-site/src/SUMMARY.md +++ b/docs-site/src/SUMMARY.md @@ -9,3 +9,4 @@ # Reference - [Native integration contract](./reference/harness-contract.md) +- [Release evidence contract](./reference/release-evidence.md) diff --git a/docs-site/src/introduction.md b/docs-site/src/introduction.md index 14e20ed..c5f72af 100644 --- a/docs-site/src/introduction.md +++ b/docs-site/src/introduction.md @@ -9,8 +9,8 @@ The adapter supports the two hostable agent roles that belong in an interactive - **`ReadyAgent`** stays reachable for direct messages in its native OMP TUI. - **`LiveAgent`** uses the same native endpoint and also activates the live-agent Psyche lifecycle. -There is no plugin installation step. Install OMP and `spt-core`, then install the -`BigscreenVR/omp-spt` adapter release. +There is no separate plugin installation step. Install OMP and `spt-core`, then install the +`BigscreenVR/omp-spt` adapter release with its bundled extension and OMP plugin skills. ## Mental model @@ -35,8 +35,8 @@ native OMP process connects native OMP events to `spt-core`. The release artifact is one fat `adapter.spt` archive. It contains the supported adapter binaries, -the manifest, and the shared `adapter/strings/omp-spt.mjs` extension, so installation and updates -move as one unit. +manifest, shared native extension, OMP plugin metadata, and packaged commune, signoff, role, and +setup skills, so installation and updates move as one unit. ## What the extension does diff --git a/docs-site/src/quickstart.md b/docs-site/src/quickstart.md index 1319a27..1fc3777 100644 --- a/docs-site/src/quickstart.md +++ b/docs-site/src/quickstart.md @@ -6,9 +6,13 @@ Goal: install `omp-spt`, create a named endpoint, and attach to its native OMP T ## 1. Install the prerequisites You need [Oh My Pi 16.3.15 or newer](https://github.com/can1357/oh-my-pi/commit/e8d0a93d) -and [`spt-core`](https://sabermage.github.io/spt-releases). There is no plugin installation step. +and [`spt-core`](https://sabermage.github.io/spt-releases). There is no separate plugin +installation step because the adapter release carries its OMP plugin skills. -The v0.2.0 `omp-spt` release supports **x86_64 Windows and x86_64 Linux only**. Its archive has no macOS or Arm64 adapter payload. +The v0.3.0 `omp-spt` release supports **x86_64 Windows and x86_64 Linux only**. +Its archive contains Windows MSVC, Linux GNU, and static Linux musl x86_64 +helpers, but no macOS or Arm64 payload. The musl helper is a compatibility tier +for OMP-capable hosts; it is not a generic Alpine support claim. **PowerShell (x86_64 Windows)** @@ -41,7 +45,9 @@ open a fresh shell if needed, and repeat both checks before installing the adapt spt adapter add --release BigscreenVR/omp-spt ``` -This installs one fat `adapter.spt` release for the supported x86_64 Windows and Linux targets. The archive includes their adapter binaries, the manifest, and the packaged OMP extension. +This installs one fat `adapter.spt` release for the supported x86_64 Windows and Linux targets. +The archive includes their adapter binaries, the manifest, native extension, OMP plugin metadata, +and packaged commune, signoff, role, and setup skills. No separate plugin installation is needed. ## 3. Create and attach to an endpoint diff --git a/docs-site/src/reference/harness-contract.md b/docs-site/src/reference/harness-contract.md index ed3b96e..657c8a8 100644 --- a/docs-site/src/reference/harness-contract.md +++ b/docs-site/src/reference/harness-contract.md @@ -77,8 +77,9 @@ half-bound endpoint is never presented as healthy. ## Updates and packaging -Each release is one fat `adapter.spt` archive containing the supported adapter binaries and the -shared `adapter/strings/omp-spt.mjs` extension. Update it with: +Each release is one fat `adapter.spt` archive containing the supported adapter binaries, manifest, +native extension, OMP plugin metadata, and packaged commune, signoff, role, and setup skills. +Update it with: ```bash spt adapter update omp-spt diff --git a/docs/CI.md b/docs/CI.md index 243d701..324291b 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -26,11 +26,11 @@ node tests/omp-extension.mjs -These are binary pass/fail checks. A release run must treat any `SKIP` caused by a missing interpreter, Rust toolchain, platform binary, or `traceable-reqs` installation as an incomplete gate, provision the dependency, and rerun. The archive test may intentionally prove that the packer refuses missing platform binaries, but a release still needs both platform builds before packaging. The live acquisition integration intentionally skips unless `OMP_SPT_ACCEPTANCE=1`; after opt-in, missing release variables, tools, or GitHub authentication are failures. Its hermetic central-gate test opts in and exercises those fail-closed paths without network access. +These are binary pass/fail checks. A release run must treat any `SKIP` caused by a missing interpreter, Rust toolchain, platform binary, or `traceable-reqs` installation as an incomplete gate, provision the dependency, and rerun. The archive test may intentionally prove that the packer refuses missing target binaries, but a release still needs all three target builds before packaging. The live acquisition integration intentionally skips unless `OMP_SPT_ACCEPTANCE=1`; after opt-in, missing release version, target, tools, or GitHub authentication are failures. Its hermetic central-gate test opts in and exercises those fail-closed paths without network access. ## Native OMP release acceptance -ADR-0013 sets the release boundary. For v0.2.0, the only supported release hosts are **x86_64 Windows** (`x86_64-pc-windows-msvc`) and **x86_64 Linux** (`x86_64-unknown-linux-gnu`); the asset has no macOS or Arm64 payload. Acceptance must use the exact tagged candidate `adapter.spt`, a real `omp` installation, the native packaged extension, and disposable endpoint identities. Complete the full acceptance sequence independently on both supported hosts before promotion. Do not reuse an operator's long-lived endpoint id. +ADR-0013 sets the release boundary. The supported helper targets are **x86_64 Windows** (`x86_64-pc-windows-msvc`), **x86_64 GNU Linux** (`x86_64-unknown-linux-gnu`), and a static **x86_64 musl Linux** compatibility tier (`x86_64-unknown-linux-musl`). The asset has no macOS or Arm64 payload. Acceptance must use the exact tagged candidate `adapter.spt`, a real `omp` installation, the native packaged extension, and disposable endpoint identities. Complete the full acceptance sequence independently on all three targets before promotion. Do not reuse an operator's long-lived endpoint id. Install the tagged candidate on each acceptance host and verify its manifest version: @@ -39,7 +39,13 @@ spt adapter add --release BigscreenVR/omp-spt --tag vX.Y.Z spt adapter version omp-spt ``` -The version command must print exactly `X.Y.Z`. Then record target-specific evidence for every item below; a pass on one target cannot stand in for the other. +The version command must print exactly `X.Y.Z`. Then record target-specific evidence for every item below; a pass on one target cannot stand in for either of the others. + +### musl representative-host boundary + +The musl record must come from a disposable, native `x86_64-unknown-linux-musl` acceptance host that can run compatible real `spt` and `omp` installations. Running the static helper on a GNU host does not qualify: it proves only that a static executable can run there, not that spt-core selected and launched the musl archive member on its representative target. Record `target: x86_64-unknown-linux-musl`, the native `spt --version` and `omp --version` output, tagged acquisition, and the same endpoint evidence required below. + +This is a helper compatibility tier, not a claim of generic Alpine support. Do not record an Alpine container or machine unless the real supported OMP application itself runs there and the complete attached-TUI acceptance sequence passes. Distribution branding alone is neither required nor sufficient. ### 1. Fresh bringup and attached TUI @@ -101,4 +107,4 @@ Pass only if the extension ends the SPT session, reaps its listener, clears its Cross-node transport is outside the `omp-spt` release gate. Once the exact message contract succeeds through the adapter on one node, subnet routing adds no OMP-specific seam and belongs to spt-core. A cross-node check may be useful for spt-core qualification, but it must not replace or block the same-node adapter acceptance above. -Release evidence must identify the candidate `vX.Y.Z` tag and asset, OMP and spt-core versions, disposable endpoint ids, fresh and resumed OMP session ids, gate outputs, state transitions, correlated reply, both role outcomes, and final shutdown result separately for `x86_64-pc-windows-msvc` and `x86_64-unknown-linux-gnu`. +Release evidence must identify the candidate `vX.Y.Z` tag and asset, OMP and spt-core versions, disposable endpoint ids, fresh and resumed OMP session ids, gate outputs, state transitions, correlated reply, both role outcomes, and final shutdown result separately for `x86_64-pc-windows-msvc`, `x86_64-unknown-linux-gnu`, and the representative `x86_64-unknown-linux-musl` host. Each target needs its own machine-readable release record; the musl record remains invalid if it came from a GNU host or omitted any native endpoint check. diff --git a/docs/PARITY.md b/docs/PARITY.md index f85bb59..bab4419 100644 --- a/docs/PARITY.md +++ b/docs/PARITY.md @@ -1,82 +1,128 @@ -# OMP-native cutover and parity record - -> This is the current product record for -> [`BigscreenVR/omp-spt`](https://github.com/BigscreenVR/omp-spt). It replaces the inherited -> adapter-parity audit. Under ADR-0008, parity now means complete coverage of the native OMP -> contract exposed through public spt-core surfaces. - -## Product boundary - -`omp-spt` supports native Oh My Pi endpoints only. The Claude Code adapter and its Claude -plugin, `ccs` integration, `/sptc:*` namespace, hook-driven delivery, `cc-launcher`, and RPC -bridge topology are retired lineage. They are superseded by ADR-0008 and are intentionally not -current compatibility or parity goals. - -This cutover is clean: a missing native OMP capability is a parity finding, while a difference -from retired lineage is not. Adapter work binds only to the published spt-core CLI, manifest, -messaging, lifecycle, and release contracts. It does not depend on private spt-core internals. - -## Native surface record - -| Capability | Current `omp-spt` contract | Source of truth | -|---|---|---| -| Install | `spt adapter add --release BigscreenVR/omp-spt` acquires the default `adapter.spt` release asset and registers `omp-spt`. | Public `spt adapter` release-install surface and `[update]` manifest contract | -| Update | `spt adapter update omp-spt` refreshes the installed adapter. A running endpoint keeps its existing hosting path until it is stopped and relaunched. | Public `spt adapter update` surface and `adapter/omp-spt.toml` | -| Fresh endpoint launch | `spt endpoint run --adapter omp-spt --id atlas --create` creates and launches a native OMP endpoint. Native attach is the default; no separate start mode is part of this adapter contract. | `[session.self]` and ADR-0009 | -| Attach | `spt rc atlas` attaches to the broker-held terminal and displays the same native OMP TUI that owns the hosted session. | Public spt-core endpoint attach surface and ADR-0009 | -| Hostable roles | The adapter hosts `ReadyAgent` and `LiveAgent`. Both use native OMP; `LiveAgent` also activates the daemon-driven Psyche role. OMP subagents are not independent SPT endpoints. | `hostable_types` and `[session.psyche_*]` in `adapter/omp-spt.toml` | -| Messaging and correlated replies | The packaged OMP extension serializes inbound SPT messages, submits each as one OMP turn, preserves the sender envelope as context, and returns that turn's final assistant response to the originating sender. Submission failures and turns without a response produce explicit failure replies. | `adapter/strings/omp-spt.mjs` and ADR-0010 | -| Lifecycle and activity | The extension binds the native OMP session to the endpoint, reports busy and idle transitions, retries a failed listener with a bounded budget, fails closed if delivery cannot recover, and ends the SPT session during OMP shutdown. | `adapter/strings/omp-spt.mjs` and ADR-0010 | -| Native resume and immutable binding | `[session.resume]` launches OMP with its native resume selector. Once bound, one endpoint owns exactly one OMP session for its lifetime. In-TUI switching and resume are blocked; using another session requires stopping the endpoint and relaunching with an explicit resume target. | `[session.resume]`, ADR-0011 | -| Continuity | Commune and signoff drops live under the project-local `.spt/` directory and remain endpoint-qualified. They belong to SPT continuity, not OMP configuration. | `[session]` and ADR-0012 | -| Digest and history | History returns the opaque JSONL for exactly one OMP session. The digest extractor reads that session and emits a bounded, turn-oriented summary through the public spt-core digest contract. | `[history]` and `[digest]` in `adapter/omp-spt.toml` | -| Distribution | Releases publish one multi-platform `adapter.spt` containing the root manifest, shared `strings/omp-spt.mjs`, and supported host binaries. spt-core selects the host payload during install and update. | `ci/publish/package-adapter.sh` and `[update]` | - -## Ownership boundary - -The native endpoint has one ownership chain: - -```text -public spt-core command - | - v -spt-core broker-held terminal - | - v -native OMP TUI + packaged omp-spt extension - | - +-- session bind and immutable identity - +-- serialized message delivery and correlated reply - +-- busy/idle activity and lifecycle shutdown -``` - -spt-core owns endpoint orchestration, the terminal, messaging transport, and network topology. -OMP owns the interactive session and TUI. The packaged extension is the adapter seam inside OMP; -it binds those two public contracts without introducing a second hosted process model. - -## Release verification boundary - -Every change is covered at the deterministic adapter boundary: extension behavior, manifest -shape, native session launch and resume templates, digest/history behavior, and daemon-role -behavior. A release also requires a real native OMP endpoint to prove: - -1. fresh bringup and an attachable native TUI; -2. same-node message receipt, one OMP turn, and a correlated reply; -3. busy and idle activity transitions; -4. native resume and refusal of in-TUI session switching; -5. graceful shutdown; and -6. `ReadyAgent` and `LiveAgent` behavior. - -Cross-node messaging is not an adapter release gate. Once same-node delivery proves that -`omp-spt` hands the published message contract to spt-core correctly, subnet transport is wholly -inside the spt-core boundary and exposes no additional OMP adapter seam. This boundary is fixed -by ADR-0013. - -## Verdict - -The scoped native OMP surface is the parity target. Install and update, native launch and attach, -the two hostable roles, message/reply custody, lifecycle and activity, immutable native resume, -continuity, digest/history, and the release boundary form one coherent product contract. - -Retired adapter behavior does not gate `omp-spt` releases and must not be reintroduced as an -alternate path. +# omp-spt capability parity record + +> This record compares `omp-spt` with its sister adapter, `claude-spt`. +> Parity means the same user outcome, including agent-facing outcomes, through +> the best OMP-native seam or a discoverable public `spt` command. It does not +> require Claude command names, hooks, PTY translation, or source structure. +> Worker endpoints remain intentionally excluded. + +## Proven shared endpoint surface + +The current adapters already share release install/update, broker-hosted fresh +launch and attachment, native resume, ReadyAgent and LiveAgent roles, +post-spawn identity, inbound delivery and correlated replies, busy/idle +lifecycle, graceful shutdown, Psyche turns, commune/signoff storage, +echo-commune, opaque history, digest extraction, and Windows x86-64 plus GNU +Linux x86-64 release payloads. + +OMP keeps its stronger native invariants: immutable endpoint/session binding, +one bounded custody queue, finite listener recovery, fail-closed exhaustion, +real OMP executable validation, and explicit failures rather than silent empty +outcomes. Capability parity does not weaken those guarantees. + +## Agent-facing parity decisions + + + + +Ordinary OMP sessions gain extension-native `/ready` and `/live` activation +commands with argument completion or native selection dialogs. Explicit live +auto-resume selects the most-recent compatible live identity; ordinary +activation never guesses silently. The extension binds the current session and +owns the authenticated listener, after which binding is immutable. + + +Messages accepted during an active OMP turn enter at the first proven boundary +before the next tool or model continuation. Custody and arrival order remain +intact; a turn with no injectable boundary falls back to ordinary next-turn +delivery rather than interruption. + + +Cancelled, interrupted, and failed turns settle affected custody and restore +receivability automatically. Only exhausted listener recovery closes the +endpoint. + + + + +Activated sessions receive a concise startup brief for identity, roster, +messaging, continuity, lifecycle, subnet, version, and update operations. +Targeted hints cover clear live-activation, identity, messaging, subnet, and +checkpoint intent. Available compatible spt-core or adapter updates produce an +actionable notice. + + + + + +Public OMP `omp-plugins` packaging supplies model-driven skills for commune, +signoff, and durable-role inspection/editing. Commune checkpoint mode saves +continuity, resets OMP context through native session APIs, and wakes the same +endpoint without operator intervention. + + +Setup is hybrid: an extension-native command owns deterministic probes and +activation, while a packaged skill owns diagnosis, repair strategy, published +installer use, subnet onboarding, and explicit operator handoffs for +authentication or elevation. + + +Assistant output may dispatch the explicit cross-harness peer-message shortform +`@`. Ordinary `@` mentions have no side effect. +Normal outbound messaging, roster, stop/shutdown, version, and subnet commands +are taught through setup or startup context rather than duplicated as skills. + +OMP's native provider/profile routing satisfies alternate provider, account, +and model routing parity; no `omp-spt:` analogue of Claude's CCS +overlay is required. + +## Release-target parity + + +The supported target set is Windows x86-64 MSVC, GNU Linux x86-64, and a static +Linux x86-64 musl helper compatibility tier for OMP-capable hosts where the GNU +helper is incompatible. The musl tier requires full native endpoint acceptance +on a representative target host; it does not imply generic Alpine support when +OMP itself is unavailable. + +Neither sister adapter packages macOS or Arm64, so those remain explicit shared +limitations rather than current parity gaps. + + +The manifest version is canonical. Release gates reject disagreement in the +helper crate, changelog heading, and versioned public documentation. + + +Deterministic local documentation generation and drift checks gate changes. +GitHub Pages publication runs the same gate before publishing. + + +Each supported target retains a machine-readable release record naming the +target, source commit and tag, artifact digests, acquisition result, and native +fresh/resume, message/reply, lifecycle, shutdown, and ReadyAgent/LiveAgent +acceptance outcomes. + +## Explicit non-goals + +- Worker endpoint advertisement or OMP-subagent endpoint topology. +- macOS, Arm64, or 32-bit release payloads in this parity program. +- A CCS adapter overlay where native OMP provider/profile routing supplies the + same outcome. +- Claude hooks, PTY translation, plugin reconciliation, or model-managed + background polling. +- Claude's retired `>>commune<<` output marker. +- Public support promises for the helper binary's manifest-role subcommands. +- Separate skills for send, roster, stop, version, or subnet. + +## Delivery order + +Coverage lands in dependency order: + +1. repair version and documentation truth, then prove packaged-skill discovery + and injectable OMP event seams; +2. implement extension-owned ready/live activation, live auto-resume, + safe-boundary delivery, and abnormal-turn receivability; +3. add startup briefs, targeted hints, update notices, packaged skills, + checkpoint continuity, hybrid setup, and peer-message shortform; +4. add the musl target and durable machine-readable acceptance records for all + supported targets. diff --git a/docs/RELEASE-RUNBOOK.md b/docs/RELEASE-RUNBOOK.md index 7fc3fb3..d9b1d3d 100644 --- a/docs/RELEASE-RUNBOOK.md +++ b/docs/RELEASE-RUNBOOK.md @@ -9,15 +9,22 @@ The archive contains the complete adapter: ```text manifest.toml strings/omp-spt.mjs +strings/package.json +strings/skills/commune/SKILL.md +strings/skills/role/SKILL.md +strings/skills/setup/SKILL.md +strings/skills/signoff/SKILL.md x86_64-pc-windows-msvc/omp-spt.exe x86_64-unknown-linux-gnu/omp-spt +x86_64-unknown-linux-musl/omp-spt ``` spt-core keeps the shared root and flattens the current node's recognized target directory into the installed adapter directory. There are no per-platform release assets. Installing this one asset completes adapter installation; there is no secondary publication target or session reload action. -## v0.2.0 release-target limitations +## Release-target limitations -The v0.2.0 asset supports exactly **x86_64 Windows** (`x86_64-pc-windows-msvc`) and **x86_64 Linux** (`x86_64-unknown-linux-gnu`). It contains no macOS, Arm64, or other target payload. OMP itself may run on additional platforms, but that does not make the `omp-spt` release install usable there. Adding any target requires a real helper build, a recognized archive target, deterministic package validation, and tagged native acceptance before it can be advertised. + +The asset supports exactly three helper targets: **x86_64 Windows** (`x86_64-pc-windows-msvc`), **x86_64 GNU Linux** (`x86_64-unknown-linux-gnu`), and a static **x86_64 musl Linux** compatibility tier (`x86_64-unknown-linux-musl`). It contains no macOS, Arm64, or other target payload. The musl member exists for OMP-capable x86_64 Linux hosts where the GNU helper is incompatible; it does not claim that OMP runs on Alpine generally. Adding any target requires a real helper build, a recognized archive target, deterministic package validation, and tagged native acceptance before it can be advertised. User commands are: @@ -33,12 +40,12 @@ The release operator needs: - write access to `BigscreenVR/omp-spt` and an authenticated `gh` CLI; - Rust and Cargo for the `tools/omp-spt` crate; - Node.js for the native extension tests; -- Python with TOML and `jsonschema` support for manifest validation and stdlib `tarfile` support for authoring portable archive modes; +- Python with TOML and `jsonschema` support for manifest validation and stdlib `tarfile` support for target validation and reproducible portable archive authoring; - `traceable-reqs` for the required coverage gate; -- Zig plus `cargo-zigbuild` when cross-building the Linux binary from Windows; and -- disposable acceptance hosts for both `x86_64-pc-windows-msvc` and `x86_64-unknown-linux-gnu`, each with compatible `spt` and real `omp` installations. +- Zig plus `cargo-zigbuild` when cross-building the GNU and musl Linux helpers from Windows; and +- disposable native acceptance hosts for `x86_64-pc-windows-msvc`, `x86_64-unknown-linux-gnu`, and `x86_64-unknown-linux-musl`, each with compatible real `spt` and `omp` installations. The musl host must be representative of that target; a GNU host that happens to run the static helper does not qualify. -The release commit must contain the intended `adapter/omp-spt.toml`, `adapter/strings/omp-spt.mjs`, helper source, tests, and changelog. The tag tree is the release source of truth. +The release commit must contain the intended `adapter/omp-spt.toml`, exact `adapter/strings/` plugin payload, helper source, tests, and changelog. The tag tree is the release source of truth. ## 1. Set the version and release notes @@ -82,9 +89,9 @@ test -z "$(git status --porcelain=v1)" || { Record `RELEASE_COMMIT` and `RELEASE_TREE` with the release evidence. Do not build from a different checkout, amend the commit, or change `HEAD` after this point. Every later identity guard must pass in the same shell; otherwise discard the artifacts and restart from this section. -## 4. Build both platform binaries +## 4. Build all three target binaries -The verified packer requires exactly one Windows binary and one Linux binary. +The verified packer requires exactly one helper for each release target. Immediately before building, reassert the frozen clean source: @@ -94,7 +101,7 @@ test "$(git rev-parse --verify HEAD^{tree})" = "$RELEASE_TREE" test -z "$(git status --porcelain=v1)" ``` -Build and test the native Windows release binary: +Build and test the native Windows release helper: ```sh sh ci/digest/build.sh @@ -106,23 +113,54 @@ This writes: tools/omp-spt/target/release/omp-spt.exe ``` -Build the Linux release binary. On Windows, use Zig as the cross-linker: +Install both Linux Rust targets and the cross-linker once: ```sh rustup target add x86_64-unknown-linux-gnu +rustup target add x86_64-unknown-linux-musl cargo install cargo-zigbuild +``` + +Build the GNU helper: + +```sh cargo zigbuild --release \ --target x86_64-unknown-linux-gnu \ --manifest-path tools/omp-spt/Cargo.toml ``` -This writes: +Build the static musl helper from the same frozen source: + +```sh +cargo zigbuild --release \ + --target x86_64-unknown-linux-musl \ + --manifest-path tools/omp-spt/Cargo.toml +``` + +The Linux outputs are: ```text tools/omp-spt/target/x86_64-unknown-linux-gnu/release/omp-spt +tools/omp-spt/target/x86_64-unknown-linux-musl/release/omp-spt +``` + +Zig itself must be on `PATH`. Native Linux builders may produce the same target paths with a suitable Cargo linker. Do not copy or rename the GNU output into the musl path. + +Validate each built file before packaging: + +```sh +python ci/publish/validate-release-binary.py \ + x86_64-pc-windows-msvc \ + tools/omp-spt/target/release/omp-spt.exe +python ci/publish/validate-release-binary.py \ + x86_64-unknown-linux-gnu \ + tools/omp-spt/target/x86_64-unknown-linux-gnu/release/omp-spt +python ci/publish/validate-release-binary.py \ + x86_64-unknown-linux-musl \ + tools/omp-spt/target/x86_64-unknown-linux-musl/release/omp-spt ``` -`cargo install cargo-zigbuild` is one-time setup. Zig itself must also be on `PATH`. A native Linux builder may produce the same target path with its normal Cargo toolchain. +The validator requires PE32+ x86-64 for Windows, ELF64 x86-64 with the glibc x86-64 program interpreter for GNU, and ELF64 x86-64 with neither `PT_INTERP` nor `DT_NEEDED` for the static musl tier. A file with only `MZ` or ELF magic, a wrong architecture, a dynamic binary mislabeled as musl, or a renamed target output fails. ## 5. Pack the single release asset @@ -146,7 +184,7 @@ The applied command writes only: dist/adapter.spt ``` -`ci/publish/package-adapter.sh` validates the manifest, enforces a fat-archive-compatible spt-core floor, and requires exactly `adapter/strings/omp-spt.mjs` plus both platform binaries. It rejects a Windows payload without PE magic, a Linux payload without ELF magic, failed staging copies, a non-executable archived Linux member, and any archive member outside the exact manifest, extension, Windows binary, and Linux binary contract. Its Python archive writer authors the Linux member as mode `0755` even when packaging on MSYS/NTFS, then the packer validates that mode from the completed archive. It renames `adapter/omp-spt.toml` to root-level `manifest.toml`; it does not copy arbitrary files from `adapter/strings/`. Do not add another target directory or publish an alternate `.spt` asset without changing the spt-core archive contract first. +`ci/publish/package-adapter.sh` validates the manifest, enforces a fat-archive-compatible spt-core floor, and requires exactly the shared plugin files plus all three validated target helpers. It rejects missing, mislabeled, malformed, wrong-architecture, or dynamically linked musl input; a non-executable archived Linux member; any byte difference between source and archived payload; and any archive member outside the exact public contract. Its Python archive writer gives both Linux members mode `0755` even on MSYS/NTFS and fixes archive metadata from `SOURCE_DATE_EPOCH` (default `0`) for reproducible output. The packer renames `adapter/omp-spt.toml` to root-level `manifest.toml` and copies only the named `adapter/strings/` plugin files. For an independent listing check: @@ -154,7 +192,7 @@ For an independent listing check: tar -tzf dist/adapter.spt ``` -The listing must contain the four release files shown at the top of this runbook and no other files; normal directory entries emitted by `tar` are allowed. Retain the packer's validation output and the SHA-256 hashes of `dist/adapter.spt` and both input executables. +After normalizing directory-entry trailing slashes, the listing must contain exactly the ten release files shown at the top of this runbook and their required parent directories. Retain the packer's validation output and the SHA-256 hashes of `dist/adapter.spt` and all three input helpers. ## 6. Tag and stage the GitHub release @@ -180,7 +218,8 @@ test "$(git rev-parse --verify "$RELEASE_TAG^{commit}")" = "$RELEASE_COMMIT" test "$(git rev-parse --verify "$RELEASE_TAG^{tree}")" = "$RELEASE_TREE" sha256sum dist/adapter.spt \ tools/omp-spt/target/release/omp-spt.exe \ - tools/omp-spt/target/x86_64-unknown-linux-gnu/release/omp-spt + tools/omp-spt/target/x86_64-unknown-linux-gnu/release/omp-spt \ + tools/omp-spt/target/x86_64-unknown-linux-musl/release/omp-spt gh release create "$RELEASE_TAG" dist/adapter.spt \ --repo BigscreenVR/omp-spt \ --verify-tag \ @@ -194,16 +233,19 @@ These guards tie the uploaded artifact's build session to the clean commit and t ## 7. Verify acquisition and native acceptance -Repeat this entire section independently on disposable `x86_64-pc-windows-msvc` and `x86_64-unknown-linux-gnu` acceptance hosts. First prove tagged acquisition, the exact installed version, and installed strings with the repository's opt-in integration: +Repeat this entire section independently on disposable native `x86_64-pc-windows-msvc`, `x86_64-unknown-linux-gnu`, and representative `x86_64-unknown-linux-musl` acceptance hosts. First prove tagged acquisition, the exact installed version, installed strings, and the target key used by the release record: ```sh OMP_SPT_ACCEPTANCE=1 \ +OMP_SPT_ACCEPTANCE_TARGET= \ OMP_SPT_RELEASE_TAG=vX.Y.Z \ OMP_SPT_EXPECTED_VERSION=X.Y.Z \ sh ci/publish/release-acquire-int.sh ``` -Both release variables are mandatory once acquisition is opted in. The integration rejects a tag other than `v$OMP_SPT_EXPECTED_VERSION`, downloads from `BigscreenVR/omp-spt`, verifies that `omp-spt` registers active from that repository's GitHub release directory, asserts `spt adapter version omp-spt` is exactly `X.Y.Z`, and resolves the packaged adapter label. Missing tools or GitHub authentication are failures after opt-in, not skips. The script temporarily removes the `omp-spt` registration, so do not run it on a node hosting an endpoint you need to preserve. +Use exactly one of the three target triples listed above. All three release variables are mandatory once acquisition is opted in. The integration rejects a tag other than `v$OMP_SPT_EXPECTED_VERSION`, downloads from `BigscreenVR/omp-spt`, verifies that `omp-spt` registers active from that repository's GitHub release directory, asserts `spt adapter version omp-spt` is exactly `X.Y.Z`, resolves the packaged adapter label, and prints the acceptance target into its success result. The target value is an evidence key, not host auto-detection: the operator remains responsible for running on the matching native host. Missing tools or GitHub authentication are failures after opt-in, not skips. The script temporarily removes the `omp-spt` registration, so do not run it on a node hosting an endpoint you need to preserve. + +On the musl acceptance host, first retain native `spt --version` and `omp --version` output and prove the real OMP application starts there. Do not use a GNU host merely because it can execute the static helper, and do not substitute an Alpine container where OMP itself is absent. The release claim is the static helper's representative-target compatibility, not generic Alpine support. Reinstall the tagged candidate on that same host, assert its version again, then complete every native endpoint check in `docs/CI.md`: @@ -213,15 +255,15 @@ spt adapter version omp-spt spt endpoint run --adapter omp-spt --id omp-spt-accept-fresh --create ``` -The version command must print exactly `X.Y.Z`. Release acceptance on each target requires evidence for tagged acquisition, fresh native bringup, attached OMP TUI, same-node message-to-turn-to-correlated-reply flow, idle/busy/idle state, blocked in-TUI session switching, native launch-time resume, graceful shutdown, and both ReadyAgent and LiveAgent behavior. Neither target can substitute for the other. +The version command must print exactly `X.Y.Z`. Release acceptance on each target requires evidence for tagged acquisition, fresh native bringup, attached OMP TUI, same-node message-to-turn-to-correlated-reply flow, idle/busy/idle lifecycle, blocked in-TUI session switching, native launch-time resume, graceful shutdown, and both ReadyAgent and LiveAgent behavior. No target can substitute for another. Cross-node transport is not an `omp-spt` release gate. Do not delay the adapter release on a subnet routing check after same-node delivery has proved the adapter boundary. -If any acquisition, version, or native acceptance item fails on either target, leave the release marked as a prerelease, fix the source, and cut a new version. Do not replace a tested tag or asset in place. +If any acquisition, target identification, version, or native acceptance item fails on any target, leave the release marked as a prerelease, fix the source, and cut a new version. Do not replace a tested tag or asset in place. ## 8. Promote and verify the default install path -Only after all tagged acquisition and native acceptance evidence passes on both supported x86_64 targets, promote the release: +Only after all tagged acquisition and native acceptance evidence passes on all three supported helper targets, promote the release: ```sh gh release edit "vX.Y.Z" \ @@ -266,13 +308,26 @@ The second command attaches the native OMP TUI by default. No additional install ## Release record +Author one JSON record per target using the contract in `docs-site/src/reference/release-evidence.md`, and keep its referenced acceptance logs beside it. Validate all three records together so the validator can reject duplicate targets or mixed source identities: + +```sh +python ci/release/validate-release-evidence.py \ + release/evidence-v1.schema.json adapter/omp-spt.toml \ + dist/evidence/release-evidence-vX.Y.Z-x86_64-pc-windows-msvc.json \ + dist/evidence/release-evidence-vX.Y.Z-x86_64-unknown-linux-gnu.json \ + dist/evidence/release-evidence-vX.Y.Z-x86_64-unknown-linux-musl.json +``` + +The command must print `RELEASE-EVIDENCE OK`. Schema success is necessary but does not manufacture evidence: populate each record only after that target's native acquisition and endpoint checks pass, then attach all three records and their referenced logs to the same GitHub release as `adapter.spt`. + Retain: - frozen release commit/tree and canonical `vX.Y.Z` tag identity; - deterministic gate outputs with no release-blocking skips; -- hashes for `dist/adapter.spt` and both input binaries; +- hashes for `dist/adapter.spt` and all three input helpers; - `gh release view` output showing stable tag `vX.Y.Z` and asset `adapter.spt`; -- tagged acquisition results with exact `X.Y.Z` version assertions on both supported x86_64 targets; -- the full per-target native acceptance evidence required by `docs/CI.md`; +- three machine-readable target records, one each for `x86_64-pc-windows-msvc`, `x86_64-unknown-linux-gnu`, and `x86_64-unknown-linux-musl`; +- tagged acquisition results with exact `X.Y.Z` version and exact-target assertions in each record; +- the full per-target native acceptance evidence required by `docs/CI.md`, including proof that the musl record came from a representative OMP-capable musl host; - the unpinned acquisition result; and - the `spt adapter update omp-spt` result and final reported version. diff --git a/tests/adapter-archive.sh b/tests/adapter-archive.sh index 09eb78f..b5866cd 100644 --- a/tests/adapter-archive.sh +++ b/tests/adapter-archive.sh @@ -1,29 +1,33 @@ #!/bin/sh -# Deterministic unit test for the adapter.spt packer. Fixture binaries exercise the multi-platform -# archive layout without depending on workstation build artifacts; the opt-in release-acquisition -# integration gate remains responsible for proving real published platform assets. -# -# The archive must contain exactly the shared manifest and native extension at its root, plus one -# valid omp-spt executable under each recognized target triple for host-time flattening. +# Deterministic fixture test for the exact three-target adapter.spt release contract. set -u ROOT=$(CDPATH= cd "$(dirname "$0")/.." && pwd) PACKER="$ROOT/ci/publish/package-adapter.sh" +WRITER="$ROOT/ci/publish/create-adapter-archive.py" WIN_TRIPLE=x86_64-pc-windows-msvc -LINUX_TRIPLE=x86_64-unknown-linux-gnu -BINS="omp-spt" +GNU_TRIPLE=x86_64-unknown-linux-gnu +MUSL_TRIPLE=x86_64-unknown-linux-musl +SHARED_FILES="omp-spt.mjs package.json skills/commune/SKILL.md skills/role/SKILL.md skills/setup/SKILL.md skills/signoff/SKILL.md" rc=0 fail() { printf 'FAIL: %s\n' "$1"; rc=1; } +ok() { printf 'ok %s\n' "$1"; } -for tool in awk chmod cp dirname grep head mkdir mktemp od rm sed sort tar tr; do +for tool in awk chmod cmp cp dirname env grep head mkdir mktemp od rm sed sort tar tr; do command -v "$tool" >/dev/null 2>&1 || { echo "FAIL: required tool unavailable: $tool"; exit 1; } done +PY="" +for cand in "${OMP_SPT_PYTHON:-}" python python3 py; do + [ -n "$cand" ] || continue + command -v "$cand" >/dev/null 2>&1 || continue + PY=$cand + break +done +[ -n "$PY" ] || { echo "FAIL: Python unavailable"; exit 1; } if ! sh "$ROOT/tests/manifest-schema.sh"; then echo "FAIL: manifest-schema prerequisite failed" exit 1 fi -# Build isolated deterministic fixture binaries beneath target/. The packer's relative-subdir test -# seams keep both fixtures separate from real release assets. TARGET="$ROOT/tools/omp-spt/target" mkdir -p "$TARGET" || { echo "FAIL: cannot create fixture parent $TARGET"; exit 1; } TMP=$(mktemp -d "$TARGET/adapter-archive-fixture.XXXXXX") || { echo "FAIL: mktemp"; exit 1; } @@ -31,59 +35,211 @@ trap 'rm -rf "$TMP"' EXIT INT TERM REL=${TMP#"$TARGET/"} [ "$REL" != "$TMP" ] || { echo "FAIL: fixture is not beneath target/"; exit 1; } WIN_RELSUB="$REL/windows-release" -LINUX_RELSUB="$REL/linux-release" -mkdir -p "$TARGET/$WIN_RELSUB" "$TARGET/$LINUX_RELSUB" \ +GNU_RELSUB="$REL/gnu-release" +MUSL_RELSUB="$REL/musl-release" +mkdir -p "$TARGET/$WIN_RELSUB" "$TARGET/$GNU_RELSUB" "$TARGET/$MUSL_RELSUB" \ || { echo "FAIL: cannot create fixture binary directories"; exit 1; } -printf '%s' 'MZdeterministic windows omp-spt fixture' > "$TARGET/$WIN_RELSUB/omp-spt.exe" - -# A missing fixture platform must fail loudly rather than green-skip. -if OMP_SPT_WIN_RELSUB="$WIN_RELSUB" \ - OMP_SPT_LINUX_RELSUB="$REL/missing-linux-release" \ - ADAPTER_SPT_OUT="$TMP/missing-platform.spt" \ - sh "$PACKER" --apply >/dev/null 2>&1; then - fail "packer should refuse when a platform binary is absent" -else - echo "ok packer refuses when a platform binary is absent" -fi -# A present but invalid target must fail rather than packaging arbitrary stale/text output. -printf '%s\n' 'not an ELF binary' > "$TARGET/$LINUX_RELSUB/omp-spt" -chmod 0755 "$TARGET/$LINUX_RELSUB/omp-spt" -if OMP_SPT_WIN_RELSUB="$WIN_RELSUB" OMP_SPT_LINUX_RELSUB="$LINUX_RELSUB" \ - ADAPTER_SPT_OUT="$TMP/invalid-platform.spt" sh "$PACKER" --apply >/dev/null 2>&1; then - fail "packer should refuse a non-ELF Linux payload" -else - echo "ok packer refuses a wrong-format target payload" -fi +# Generate structurally valid, deterministic target fixtures. The GNU ELF has glibc's x86_64 +# PT_INTERP; the musl fixture is static (no PT_INTERP or DT_NEEDED); WRONG_ARCH is static AArch64; +# NEEDED has no interpreter but still declares a dynamic dependency. +WIN_GOOD="$TMP/windows-good.exe" +GNU_GOOD="$TMP/gnu-good" +MUSL_GOOD="$TMP/musl-good" +WRONG_ARCH="$TMP/aarch64-static" +NEEDED="$TMP/needed-no-interpreter" +"$PY" - "$WIN_GOOD" "$GNU_GOOD" "$MUSL_GOOD" "$WRONG_ARCH" "$NEEDED" <<'PY' +from pathlib import Path +import struct +import sys -printf '\177ELF%s' 'deterministic linux omp-spt fixture' > "$TARGET/$LINUX_RELSUB/omp-spt" -chmod 0644 "$TARGET/$LINUX_RELSUB/omp-spt" -if OMP_SPT_WIN_RELSUB="$WIN_RELSUB" OMP_SPT_LINUX_RELSUB="$LINUX_RELSUB" \ - OMP_SPT_ARCHIVE_LINUX_MODE=0644 ADAPTER_SPT_OUT="$TMP/non-executable-platform.spt" \ - sh "$PACKER" --apply >/dev/null 2>&1; then - fail "packer should refuse a non-executable archived Linux payload" -else - echo "ok packer refuses a non-executable archived Linux payload" -fi -# A strings directory without the one load-bearing extension must fail. +def pe(path: str) -> None: + image = bytearray(512) + image[:2] = b"MZ" + struct.pack_into(" None: + encoded_interpreter = b"" if interpreter is None else interpreter.encode("ascii") + b"\0" + program_count = 1 + (1 if interpreter is not None else 0) + payload_offset = 64 + program_count * 56 + file_size = payload_offset + len(encoded_interpreter) + 32 + ident = b"\x7fELF\x02\x01\x01\0" + b"\0" * 8 + header = struct.pack( + "<16sHHIQQQIHHHHHH", + ident, + 3, + machine, + 1, + 0, + 64, + 0, + 0, + 64, + 56, + program_count, + 0, + 0, + 0, + ) + load = struct.pack(" None: + program_count = 2 + dynamic_offset = 64 + program_count * 56 + dynamic_entries = struct.pack(""$TMP/output" 2>&1; then + fail "$name unexpectedly succeeded" + elif grep -Fq "$expected" "$TMP/output"; then + ok "$name fails closed" + else + fail "$name failed for the wrong reason: $(cat "$TMP/output")" + fi +} + +BASE_OUT="$TMP/rejected.spt" +expect_packer_failure "missing GNU helper" "MISS" \ + env OMP_SPT_WIN_RELSUB="$WIN_RELSUB" OMP_SPT_LINUX_RELSUB="$REL/missing-gnu-release" \ + OMP_SPT_MUSL_RELSUB="$MUSL_RELSUB" ADAPTER_SPT_OUT="$BASE_OUT" sh "$PACKER" --apply + +printf '%s\n' 'not a PE binary' >"$TARGET/$WIN_RELSUB/omp-spt.exe" +expect_packer_failure "malformed Windows helper" "truncated DOS header" \ + env OMP_SPT_WIN_RELSUB="$WIN_RELSUB" OMP_SPT_LINUX_RELSUB="$GNU_RELSUB" \ + OMP_SPT_MUSL_RELSUB="$MUSL_RELSUB" ADAPTER_SPT_OUT="$BASE_OUT" sh "$PACKER" --apply +cp "$WIN_GOOD" "$TARGET/$WIN_RELSUB/omp-spt.exe" + +printf '%s\n' 'not an ELF binary' >"$TARGET/$GNU_RELSUB/omp-spt" +expect_packer_failure "malformed GNU helper" "truncated ELF64 header" \ + env OMP_SPT_WIN_RELSUB="$WIN_RELSUB" OMP_SPT_LINUX_RELSUB="$GNU_RELSUB" \ + OMP_SPT_MUSL_RELSUB="$MUSL_RELSUB" ADAPTER_SPT_OUT="$BASE_OUT" sh "$PACKER" --apply +cp "$GNU_GOOD" "$TARGET/$GNU_RELSUB/omp-spt" + +expect_packer_failure "missing musl helper" "MISS" \ + env OMP_SPT_WIN_RELSUB="$WIN_RELSUB" OMP_SPT_LINUX_RELSUB="$GNU_RELSUB" \ + OMP_SPT_MUSL_RELSUB="$REL/missing-musl-release" ADAPTER_SPT_OUT="$BASE_OUT" \ + sh "$PACKER" --apply + +printf '%s\n' 'not an ELF binary' >"$TARGET/$MUSL_RELSUB/omp-spt" +expect_packer_failure "malformed musl helper" "truncated ELF64 header" \ + env OMP_SPT_WIN_RELSUB="$WIN_RELSUB" OMP_SPT_LINUX_RELSUB="$GNU_RELSUB" \ + OMP_SPT_MUSL_RELSUB="$MUSL_RELSUB" ADAPTER_SPT_OUT="$BASE_OUT" sh "$PACKER" --apply + +cp "$GNU_GOOD" "$TARGET/$MUSL_RELSUB/omp-spt" +expect_packer_failure "GNU helper mislabeled as musl" "musl target must be static" \ + env OMP_SPT_WIN_RELSUB="$WIN_RELSUB" OMP_SPT_LINUX_RELSUB="$GNU_RELSUB" \ + OMP_SPT_MUSL_RELSUB="$MUSL_RELSUB" ADAPTER_SPT_OUT="$BASE_OUT" sh "$PACKER" --apply + +cp "$NEEDED" "$TARGET/$MUSL_RELSUB/omp-spt" +expect_packer_failure "musl helper with DT_NEEDED dependency" "has 1 DT_NEEDED entry" \ + env OMP_SPT_WIN_RELSUB="$WIN_RELSUB" OMP_SPT_LINUX_RELSUB="$GNU_RELSUB" \ + OMP_SPT_MUSL_RELSUB="$MUSL_RELSUB" ADAPTER_SPT_OUT="$BASE_OUT" sh "$PACKER" --apply + +cp "$WRONG_ARCH" "$TARGET/$MUSL_RELSUB/omp-spt" +expect_packer_failure "wrong-architecture musl helper" "is not x86_64" \ + env OMP_SPT_WIN_RELSUB="$WIN_RELSUB" OMP_SPT_LINUX_RELSUB="$GNU_RELSUB" \ + OMP_SPT_MUSL_RELSUB="$MUSL_RELSUB" ADAPTER_SPT_OUT="$BASE_OUT" sh "$PACKER" --apply + +cp "$MUSL_GOOD" "$TARGET/$MUSL_RELSUB/omp-spt" +chmod 0755 "$TARGET/$MUSL_RELSUB/omp-spt" +expect_packer_failure "non-executable archived GNU helper" "is not executable" \ + env OMP_SPT_WIN_RELSUB="$WIN_RELSUB" OMP_SPT_LINUX_RELSUB="$GNU_RELSUB" \ + OMP_SPT_MUSL_RELSUB="$MUSL_RELSUB" OMP_SPT_ARCHIVE_LINUX_MODE=0644 \ + ADAPTER_SPT_OUT="$BASE_OUT" sh "$PACKER" --apply + +expect_packer_failure "non-executable archived musl helper" "is not executable" \ + env OMP_SPT_WIN_RELSUB="$WIN_RELSUB" OMP_SPT_LINUX_RELSUB="$GNU_RELSUB" \ + OMP_SPT_MUSL_RELSUB="$MUSL_RELSUB" OMP_SPT_ARCHIVE_MUSL_MODE=0644 \ + ADAPTER_SPT_OUT="$BASE_OUT" sh "$PACKER" --apply + BAD_STRINGS="$TMP/bad-strings" mkdir -p "$BAD_STRINGS" -printf '%s\n' 'wrong extension' > "$BAD_STRINGS/other.mjs" -if OMP_SPT_WIN_RELSUB="$WIN_RELSUB" OMP_SPT_LINUX_RELSUB="$LINUX_RELSUB" \ - OMP_SPT_STRINGS_DIR="$BAD_STRINGS" ADAPTER_SPT_OUT="$TMP/missing-extension.spt" \ - sh "$PACKER" --apply >/dev/null 2>&1; then - fail "packer should refuse when strings/omp-spt.mjs is absent" -else - echo "ok packer refuses a missing strings/omp-spt.mjs" -fi +printf '%s\n' 'wrong extension' >"$BAD_STRINGS/other.mjs" +expect_packer_failure "incomplete shared plugin" "required shared plugin file missing" \ + env OMP_SPT_WIN_RELSUB="$WIN_RELSUB" OMP_SPT_LINUX_RELSUB="$GNU_RELSUB" \ + OMP_SPT_MUSL_RELSUB="$MUSL_RELSUB" OMP_SPT_STRINGS_DIR="$BAD_STRINGS" \ + ADAPTER_SPT_OUT="$BASE_OUT" sh "$PACKER" --apply OUT="$TMP/adapter.spt" - # [unit->REQ-DIST-ADAPTER-RELEASE] -if package_out=$(OMP_SPT_WIN_RELSUB="$WIN_RELSUB" OMP_SPT_LINUX_RELSUB="$LINUX_RELSUB" \ +# [unit->REQ-DIST-LINUX-MUSL] +if package_out=$(SOURCE_DATE_EPOCH=123 OMP_SPT_WIN_RELSUB="$WIN_RELSUB" \ + OMP_SPT_LINUX_RELSUB="$GNU_RELSUB" OMP_SPT_MUSL_RELSUB="$MUSL_RELSUB" \ ADAPTER_SPT_OUT="$OUT" sh "$PACKER" --apply 2>&1); then - echo "ok packer --apply succeeded" + ok "three-target packer --apply succeeds" else fail "packer --apply exited non-zero: $package_out" exit "$rc" @@ -91,61 +247,103 @@ fi [ -f "$OUT" ] || { fail "no archive written at $OUT"; exit "$rc"; } LIST=$(tar -tzf "$OUT") -# SHARED manifest.toml MUST be at the root (exact, no leading path) — the add --release contract. -echo "$LIST" | grep -qx "manifest.toml" \ - && echo "ok shared manifest.toml at archive root" \ - || fail "manifest.toml not at archive root (add --release would reject)" -# The exact SHARED extension is load-bearing (a directory entry alone is not sufficient). -echo "$LIST" | grep -qx "strings/omp-spt.mjs" \ - && echo "ok shared strings/omp-spt.mjs at archive root" \ - || fail "strings/omp-spt.mjs missing from archive root" -# Each triple carries all the tool binaries, under its / dir, mirroring the flat-root tree. -for b in $BINS; do - echo "$LIST" | grep -qx "$WIN_TRIPLE/$b.exe" \ - && echo "ok $WIN_TRIPLE/$b.exe present" \ - || fail "$WIN_TRIPLE/$b.exe missing from archive" - echo "$LIST" | grep -qx "$LINUX_TRIPLE/$b" \ - && echo "ok $LINUX_TRIPLE/$b present" \ - || fail "$LINUX_TRIPLE/$b missing from archive" -done -# Prove the archive carries target-format payloads from these fixtures, not unrelated build output. +NORMALIZED=$(printf '%s\n' "$LIST" | sed 's:/$::' | sort) +EXPECTED=$(printf '%s\n' \ + manifest.toml \ + strings strings/omp-spt.mjs strings/package.json strings/skills \ + strings/skills/commune strings/skills/commune/SKILL.md \ + strings/skills/role strings/skills/role/SKILL.md \ + strings/skills/setup strings/skills/setup/SKILL.md \ + strings/skills/signoff strings/skills/signoff/SKILL.md \ + "$WIN_TRIPLE" "$WIN_TRIPLE/omp-spt.exe" \ + "$GNU_TRIPLE" "$GNU_TRIPLE/omp-spt" \ + "$MUSL_TRIPLE" "$MUSL_TRIPLE/omp-spt" | sort) +if [ "$NORMALIZED" = "$EXPECTED" ]; then + ok "archive has deterministic exact shared and three-target membership" +else + fail "archive membership mismatch: [$NORMALIZED]" +fi + WIN_MAGIC=$(tar -xOzf "$OUT" "$WIN_TRIPLE/omp-spt.exe" | od -An -tx1 -N4 | tr -d '[:space:]') -case "$WIN_MAGIC" in - 4d5a*) echo "ok Windows archive member is the PE fixture" ;; - *) fail "Windows archive member is not PE (magic=$WIN_MAGIC)" ;; -esac -LINUX_MAGIC=$(tar -xOzf "$OUT" "$LINUX_TRIPLE/omp-spt" | od -An -tx1 -N4 | tr -d '[:space:]') -[ "$LINUX_MAGIC" = "7f454c46" ] \ - && echo "ok Linux archive member is the ELF fixture" \ - || fail "Linux archive member is not ELF (magic=$LINUX_MAGIC)" -LINUX_MODE=$(tar -tvzf "$OUT" "$LINUX_TRIPLE/omp-spt" | awk 'NR == 1 {print $1}') -case "$LINUX_MODE" in - -rwx*) echo "ok Linux archive member is executable ($LINUX_MODE)" ;; - *) fail "Linux archive member is not executable ($LINUX_MODE)" ;; -esac -# Negative: the SHARED files must NOT sit under a triple (would not be shared), and no nested wrapper. -if echo "$LIST" | grep -qE "^$WIN_TRIPLE/(manifest\.toml|strings/)"; then - fail "manifest/strings duplicated under a triple — they must be shared at root only" +case "$WIN_MAGIC" in 4d5a*) ok "Windows archive member retains PE magic" ;; *) fail "Windows member magic=$WIN_MAGIC" ;; esac +for triple in "$GNU_TRIPLE" "$MUSL_TRIPLE"; do + magic=$(tar -xOzf "$OUT" "$triple/omp-spt" | od -An -tx1 -N4 | tr -d '[:space:]') + [ "$magic" = "7f454c46" ] && ok "$triple member retains ELF magic" || fail "$triple member magic=$magic" + mode=$(tar -tvzf "$OUT" "$triple/omp-spt" | awk 'NR == 1 {print $1}') + case "$mode" in -rwx*) ok "$triple member is executable ($mode)" ;; *) fail "$triple member is not executable ($mode)" ;; esac +done + +VERIFY="$TMP/exact-payloads" +mkdir -p "$VERIFY" +assert_exact_payload() { + source=$1 + member=$2 + extracted="$VERIFY/$member" + mkdir -p "$(dirname "$extracted")" + if tar -xOzf "$OUT" "$member" >"$extracted" && cmp -s "$source" "$extracted"; then + ok "$member exactly matches its release input" + else + fail "$member does not exactly match its release input" + fi +} +assert_exact_payload "$ROOT/adapter/omp-spt.toml" manifest.toml +for rel in $SHARED_FILES; do + assert_exact_payload "$ROOT/adapter/strings/$rel" "strings/$rel" +done +assert_exact_payload "$TARGET/$WIN_RELSUB/omp-spt.exe" "$WIN_TRIPLE/omp-spt.exe" +assert_exact_payload "$TARGET/$GNU_RELSUB/omp-spt" "$GNU_TRIPLE/omp-spt" +assert_exact_payload "$TARGET/$MUSL_RELSUB/omp-spt" "$MUSL_TRIPLE/omp-spt" + +if printf '%s\n' "$NORMALIZED" | grep -qE "^($WIN_TRIPLE|$GNU_TRIPLE|$MUSL_TRIPLE)/(manifest\.toml|strings/)"; then + fail "shared plugin files are duplicated under a target" else - echo "ok manifest/strings not duplicated under a triple" + ok "shared plugin files remain root-only" fi -# Negative: no unrecognized or extra member. The public asset has exactly four logical files. -badmember=$(printf '%s\n' "$LIST" | grep -vE "^(manifest\.toml|strings/?|strings/omp-spt\.mjs|$WIN_TRIPLE/?|$WIN_TRIPLE/omp-spt\.exe|$LINUX_TRIPLE/?|$LINUX_TRIPLE/omp-spt)$" || true) -if [ -n "$badmember" ]; then - fail "unexpected archive member(s) [$badmember]" + +# The archive writer itself rejects an unintended file under the musl directory rather than +# silently growing the public contract. +WRITER_STAGE="$TMP/writer-stage" +mkdir -p "$WRITER_STAGE/strings" "$WRITER_STAGE/$WIN_TRIPLE" \ + "$WRITER_STAGE/$GNU_TRIPLE" "$WRITER_STAGE/$MUSL_TRIPLE" +cp "$ROOT/adapter/omp-spt.toml" "$WRITER_STAGE/manifest.toml" +for rel in $SHARED_FILES; do + mkdir -p "$(dirname "$WRITER_STAGE/strings/$rel")" + cp "$ROOT/adapter/strings/$rel" "$WRITER_STAGE/strings/$rel" +done +cp "$WIN_GOOD" "$WRITER_STAGE/$WIN_TRIPLE/omp-spt.exe" +cp "$GNU_GOOD" "$WRITER_STAGE/$GNU_TRIPLE/omp-spt" +cp "$MUSL_GOOD" "$WRITER_STAGE/$MUSL_TRIPLE/omp-spt" +printf '%s\n' unintended >"$WRITER_STAGE/$MUSL_TRIPLE/debug.txt" +if "$PY" "$WRITER" "$WRITER_STAGE" "$TMP/unintended.spt" \ + "$WIN_TRIPLE" "$GNU_TRIPLE" "$MUSL_TRIPLE" >"$TMP/output" 2>&1; then + fail "archive writer accepted an unintended musl member" +elif grep -Fq "unexpected staged archive member" "$TMP/output"; then + ok "archive writer rejects unintended musl members" +else + fail "archive writer rejected unintended member for wrong reason: $(cat "$TMP/output")" +fi + +OUT_AGAIN="$TMP/adapter-again.spt" +if SOURCE_DATE_EPOCH=123 OMP_SPT_WIN_RELSUB="$WIN_RELSUB" \ + OMP_SPT_LINUX_RELSUB="$GNU_RELSUB" OMP_SPT_MUSL_RELSUB="$MUSL_RELSUB" \ + ADAPTER_SPT_OUT="$OUT_AGAIN" sh "$PACKER" --apply >/dev/null 2>&1 \ + && cmp -s "$OUT" "$OUT_AGAIN"; then + ok "fixed inputs and SOURCE_DATE_EPOCH produce identical archives" else - echo "ok archive contains exactly the four public release files" + fail "archive output is not reproducible" fi -# A fat archive REQUIRES min_spt_core_version >= 0.13.2 — the packer must refuse a lower floor. -# (Smoke the guard by reading the dry-run plan's advertised floor.) -plan=$(OMP_SPT_WIN_RELSUB="$WIN_RELSUB" OMP_SPT_LINUX_RELSUB="$LINUX_RELSUB" sh "$PACKER" 2>&1) +plan=$(OMP_SPT_WIN_RELSUB="$WIN_RELSUB" OMP_SPT_LINUX_RELSUB="$GNU_RELSUB" \ + OMP_SPT_MUSL_RELSUB="$MUSL_RELSUB" sh "$PACKER" 2>&1) echo "$plan" | grep -qE "min_spt_core 0\.(1[3-9]|[2-9][0-9])" \ - && echo "ok packer advertises a fat-capable floor (>= 0.13.2)" \ + && ok "packer advertises a fat-capable manifest floor (>= 0.13.2)" \ || fail "packer floor advertisement missing/too low; plan=[$plan]" +echo "$plan" | grep -Fq "$MUSL_TRIPLE/omp-spt [ELF64 x86_64, static]" \ + && ok "dry-run identifies the static musl target" \ + || fail "dry-run omits the static musl contract; plan=[$plan]" manifest_floor=$(grep -E '^min_spt_core_version' "$ROOT/adapter/omp-spt.toml" | head -1 | sed -E 's/.*"([^"]+)".*/\1/') case "$plan" in - *"Needs spt v$manifest_floor+"*) echo "ok install guidance uses manifest floor v$manifest_floor" ;; + *"Needs spt v$manifest_floor+"*) ok "install guidance uses manifest floor v$manifest_floor" ;; *) fail "install guidance does not use manifest floor v$manifest_floor; plan=[$plan]" ;; esac diff --git a/tests/ci-gates.sh b/tests/ci-gates.sh index be4b600..048bbf5 100644 --- a/tests/ci-gates.sh +++ b/tests/ci-gates.sh @@ -12,7 +12,14 @@ for required in \ 'tests/native-launch-manifest.sh' \ 'ci/digest/build.sh' \ 'tests/release-acquire.sh' \ - 'ci/manifest/check-manifest.sh'; do + 'ci/manifest/check-manifest.sh' \ + 'ci/publish/validate-release-binary.py' \ + 'ci/release/check-version-consistency.py' \ + 'tests/version-consistency.py' \ + 'ci/docs/check-docs.sh' \ + 'bun tests/omp-skills.mjs' \ + 'tests/docs-gate.py' \ + 'tests/release-evidence.py'; do grep -Fq "$required" "$GATES" \ && echo "ok gate: $required" \ || { echo "FAIL missing gate: $required"; fail=1; } diff --git a/tests/omp-extension.mjs b/tests/omp-extension.mjs index 8fbe0c2..d4a8225 100644 --- a/tests/omp-extension.mjs +++ b/tests/omp-extension.mjs @@ -5,6 +5,7 @@ import { decodeBody, drainEvents, extractReply, + parsePeerShortforms, runSpt, } from "../adapter/strings/omp-spt.mjs"; @@ -100,9 +101,33 @@ function createHarness(options = {}) { const notifications = []; const errors = []; const debug = []; + const commands = new Map(); + const tools = new Map(); + const sentMessages = []; + const selections = []; + const confirmations = []; + const inputs = []; + const compactions = []; const clock = new FakeClock(); let shutdowns = 0; + const fakeSchema = { + describe() { + return this; + }, + optional() { + return this; + }, + }; + const z = { + object(shape) { + return { ...fakeSchema, shape }; + }, + string() { + return { ...fakeSchema }; + }, + }; + const ui = { notify(message, type) { notifications.push({ message, type }); @@ -110,15 +135,34 @@ function createHarness(options = {}) { setStatus(key, text) { statuses.push({ key, text }); }, + async select(title, values) { + selections.push({ title, values }); + return options.onSelect?.(title, values) ?? options.selectResults?.shift(); + }, + async confirm(title, message) { + confirmations.push({ title, message }); + return options.onConfirm?.(title, message) ?? options.confirmResults?.shift() ?? false; + }, + async input(title, placeholder) { + inputs.push({ title, placeholder }); + return options.onInput?.(title, placeholder) ?? options.inputResults?.shift(); + }, }; const ctx = { ui, + hasUI: options.hasUI ?? true, + cwd: options.cwd ?? "C:\\work\\project", + async compact(compactionOptions) { + compactions.push(compactionOptions); + return options.onCompact?.(compactionOptions); + }, sessionManager: { getSessionId: () => options.sessionId ?? "session-1" }, shutdown() { shutdowns += 1; }, }; const pi = { + zod: { z }, logger: { error(message, details) { errors.push({ message, details }); @@ -132,13 +176,24 @@ function createHarness(options = {}) { registered.push(handler); handlers.set(name, registered); }, + registerCommand(name, command) { + commands.set(name, command); + }, + registerTool(tool) { + tools.set(tool.name, tool); + }, + sendMessage(message, delivery) { + sentMessages.push({ message, delivery }); + options.onSendMessage?.(message, delivery); + }, sendUserMessage(content) { submitted.push(content); options.onSubmit?.(content); }, }; - const runSptCommand = async (args, input) => { + const runSptCommand = async (args, input, runOptions) => { const call = { args: [...args], input }; + Object.defineProperty(call, "options", { value: runOptions }); calls.push(call); const overridden = await options.onRun?.(call); if (overridden !== undefined) return overridden; @@ -158,10 +213,13 @@ function createHarness(options = {}) { }; const extension = createOmpSpt({ env: { - SPT_ENDPOINT_ID: options.id ?? "omp-agent", + SPT_ENDPOINT_ID: Object.hasOwn(options, "id") ? options.id : "omp-agent", OMP_SPT_SUBNET: options.subnet, OMP_SPT_SPT_BIN: "spt-test", }, + checkUpdates: options.checkUpdates ?? false, + fetchLatestAdapterVersion: options.fetchLatestAdapterVersion, + platform: options.platform, acceptedBytesLimit: options.acceptedBytesLimit, acceptedQueueLimit: options.acceptedQueueLimit, restartDelaysMs: options.restartDelaysMs ?? [5, 10], @@ -169,6 +227,7 @@ function createHarness(options = {}) { sessionEndRetryDelaysMs: options.sessionEndRetryDelaysMs ?? [], shutdownBudgetMs: options.shutdownBudgetMs, shutdownCommandTimeoutMs: options.shutdownCommandTimeoutMs, + shortformCommandTimeoutMs: options.shortformCommandTimeoutMs, listenerStableMs: options.listenerStableMs ?? false, killForceMs: options.killForceMs ?? 4, killGraceMs: options.killGraceMs ?? 3, @@ -190,17 +249,24 @@ function createHarness(options = {}) { } return { + commands, + confirmations, + compactions, calls, children, clock, ctx, debug, emit, + inputs, errors, handlers, notifications, + selections, + sentMessages, statuses, submitted, + tools, get shutdowns() { return shutdowns; }, @@ -214,6 +280,14 @@ function commandCalls(harness, command) { function stateCalls(harness) { return harness.calls.filter((call) => call.args[0] === "api" && call.args[3] === "state"); } +function assertNoAgentManagedPoll(harness) { + assert.ok( + !harness.calls.some((call) => call.args.includes("poll")) && + !harness.children.some((child) => child.args.includes("poll")), + "extension activation and delivery must never launch an agent-managed background poll", + ); +} + async function testParsingAndReplies() { assert.equal(decodeBody('a<b>
"c" & &lt;'), 'a\n"c" & <'); @@ -288,6 +362,7 @@ async function testLifecycleCustodyAndContext() { "session_before_switch", "session_before_branch", "context", + "before_agent_start", "agent_start", "agent_end", "session_stop", @@ -1355,6 +1430,626 @@ async function testShutdownFallbackStaysBelowHostCap() { assert.deepEqual(hardCapHarness.clock.delays(), []); } +// [unit->REQ-PARITY-READY-ACTIVATION] +// [unit->REQ-PARITY-LIVE-ACTIVATION] +async function testNativeActivationCommandsAndErrors() { + const inert = createHarness({ id: null }); + assert.deepEqual([...inert.commands.keys()], ["ready", "live"]); + assert.ok(inert.tools.has("spt_checkpoint")); + await inert.emit("session_start"); + assert.deepEqual(inert.calls, [], "an ordinary OMP session must remain lifecycle-inert"); + assert.equal( + await inert.emit("session_before_switch", { reason: "new" }), + undefined, + "an unbound extension must not block native session changes", + ); + + await inert.commands.get("ready").handler("--auto", inert.ctx); + await inert.commands.get("live").handler("two identities", inert.ctx); + assert.deepEqual(inert.calls, [], "invalid activation syntax must not bind or guess"); + assert.ok( + inert.notifications.some(({ message }) => message.includes("supported only by `/live`")), + ); + assert.ok(inert.notifications.some(({ message }) => message.includes("Usage: /live"))); + + await inert.commands.get("ready").handler("ready-one", inert.ctx); + const readyBind = inert.calls.find((call) => call.args[3] === "bind"); + assert.deepEqual(readyBind.args, [ + "api", + "--adapter", + "omp-spt", + "bind", + "ready-one", + "--set-session-id", + "session-1", + "--type", + "ready_agent", + ]); + assert.deepEqual(inert.children[0].args, [ + "api", + "--adapter", + "omp-spt", + "listen", + "ready-one", + "--session-id", + "session-1", + ]); + assert.ok( + inert.notifications.some(({ message, type }) => + type === "info" && message.includes("ready endpoint ready-one"), + ), + ); + await inert.commands.get("live").handler("other-id", inert.ctx); + assert.equal(inert.calls.filter((call) => call.args[3] === "bind").length, 1); + assert.ok( + inert.notifications.some(({ message }) => message.includes("immutably bound to ready-one")), + ); + assertNoAgentManagedPoll(inert); + await inert.emit("session_shutdown"); + + let bindAttempts = 0; + const retryable = createHarness({ + id: null, + onRun(call) { + if (call.args[3] === "bind") { + bindAttempts += 1; + if (bindAttempts === 1) throw new Error("identity already active"); + } + }, + }); + await retryable.emit("session_start"); + await retryable.commands.get("live").handler("retry-live", retryable.ctx); + assert.equal(retryable.children.length, 0); + assert.equal(retryable.shutdowns, 0, "a pre-token command error must keep ordinary OMP usable"); + assert.ok( + retryable.notifications.some(({ message }) => message.includes("identity already active")), + ); + await retryable.commands.get("live").handler("retry-live", retryable.ctx); + assert.equal(retryable.children.length, 1, "a corrected activation may retry after a bind error"); + assert.ok( + retryable.calls + .findLast((call) => call.args[3] === "bind") + .args.includes("live_agent"), + ); + await retryable.emit("session_shutdown"); + + const headless = createHarness({ id: null, hasUI: false }); + await headless.emit("session_start"); + await headless.commands.get("ready").handler("", headless.ctx); + assert.deepEqual(headless.calls, []); + assert.ok( + headless.notifications.some(({ message }) => message.includes("requires an endpoint id")), + "ordinary activation must never guess an identity when native selection is unavailable", + ); + await headless.emit("session_shutdown"); +} + +// [unit->REQ-PARITY-READY-ACTIVATION] +async function testNativeActivationSelectionAndCompletion() { + const harness = createHarness({ + id: null, + selectResults: ["ready-old"], + onRun(call) { + if (call.args[0] === "--json" && call.args[1] === "endpoint") { + return JSON.stringify({ + local: [ + { id: "ready-old", state: "ready_agent", alive: false }, + { id: "ready-busy", state: "ready_agent", alive: true }, + { id: "live-old", state: "live_agent", alive: false }, + ], + }); + } + if (call.args[0] === "--json" && call.args[1] === "api") { + return JSON.stringify({ + id: call.args[3], + adapter: "omp-spt", + cwd: "C:\\work\\project", + }); + } + }, + }); + await harness.emit("session_start"); + await harness.commands.get("ready").handler("", harness.ctx); + assert.deepEqual(harness.selections[0].values, [ + "ready-old", + "Create a new endpoint id", + ]); + assert.equal(harness.calls.find((call) => call.args[3] === "bind").args[4], "ready-old"); + assert.deepEqual(harness.commands.get("ready").getArgumentCompletions("ready-"), [ + { value: "ready-old", label: "ready-old" }, + ]); + assertNoAgentManagedPoll(harness); + await harness.emit("session_shutdown"); +} +async function testPlatformAwareActivationCandidatePaths() { + const createLinuxHarness = (cwd, candidateCwd, newId) => + createHarness({ + id: null, + platform: "linux", + cwd, + inputResults: [newId], + onRun(call) { + if (call.args[0] === "--json" && call.args[1] === "endpoint") { + return JSON.stringify({ + local: [{ id: "wrong-path", state: "ready_agent", alive: false }], + }); + } + if (call.args[0] === "--json" && call.args[1] === "api") { + return JSON.stringify({ + id: "wrong-path", + adapter: "omp-spt", + cwd: candidateCwd, + }); + } + }, + }); + + for (const [cwd, candidateCwd, newId] of [ + ["/Work/Project", "/work/project", "case-sensitive"], + ["/", "/different", "root-scoped"], + ]) { + const harness = createLinuxHarness(cwd, candidateCwd, newId); + await harness.emit("session_start"); + await harness.commands.get("ready").handler("", harness.ctx); + assert.deepEqual(harness.selections, [], "an incompatible path must not be selectable"); + assert.equal(harness.calls.find((call) => call.args[3] === "bind").args[4], newId); + assertNoAgentManagedPoll(harness); + await harness.emit("session_shutdown"); + } +} + + +// [unit->REQ-PARITY-LIVE-AUTO-RESUME] +async function testExplicitLiveAutoResume() { + const activity = { + old: "2026-07-10T00:00:00.000Z", + newest: "2026-07-15T00:00:00.000Z", + }; + const harness = createHarness({ + id: null, + confirmResults: [true], + onRun(call) { + if (call.args[0] === "--json" && call.args[1] === "endpoint" && call.args[2] === "list") { + return JSON.stringify({ + local: [ + { id: "old", state: "live_agent", alive: false }, + { id: "newest", state: "live_agent", alive: false }, + { id: "active", state: "live_agent", alive: true }, + { id: "foreign", state: "live_agent", alive: false }, + ], + }); + } + if (call.args[0] === "--json" && call.args[1] === "api") { + return JSON.stringify({ + id: call.args[3], + adapter: call.args[3] === "foreign" ? "claude-spt" : "omp-spt", + cwd: "C:\\work\\project", + }); + } + if (call.args[0] === "--json" && call.args[2] === "digest") { + const id = call.args[3]; + return JSON.stringify({ + turns: [{ entries: [{ Agent: { ts: activity[id], text: id } }] }], + }); + } + }, + }); + await harness.emit("session_start"); + assert.deepEqual(harness.commands.get("live").getArgumentCompletions("--"), [ + { value: "--auto", label: "--auto" }, + ]); + await harness.commands.get("live").handler("--auto", harness.ctx); + assert.match(harness.confirmations[0].message, /newest.*most recently active/s); + const bind = harness.calls.find((call) => call.args[3] === "bind"); + assert.equal(bind.args[4], "newest"); + assert.ok(bind.args.includes("live_agent")); + assert.ok( + !harness.calls.some( + (call) => call.args[2] === "digest" && ["active", "foreign"].includes(call.args[3]), + ), + "auto-resume must inspect only inactive compatible identities", + ); + assertNoAgentManagedPoll(harness); + await harness.emit("session_shutdown"); + + const declined = createHarness({ + id: null, + confirmResults: [false], + onRun(call) { + if (call.args[0] === "--json" && call.args[1] === "endpoint" && call.args[2] === "list") { + return JSON.stringify({ + local: [{ id: "prior", state: "live_agent", alive: false }], + }); + } + if (call.args[0] === "--json" && call.args[1] === "api") { + return JSON.stringify({ + id: "prior", + adapter: "omp-spt", + cwd: "C:\\work\\project", + }); + } + if (call.args[0] === "--json" && call.args[2] === "digest") { + return JSON.stringify({ + turns: [{ entries: [{ Agent: { ts: "2026-07-15T01:00:00.000Z" } }] }], + }); + } + }, + }); + await declined.emit("session_start"); + await declined.commands.get("live").handler("--auto", declined.ctx); + assert.equal(declined.calls.filter((call) => call.args[3] === "bind").length, 0); + await declined.emit("session_shutdown"); +} + +// [unit->REQ-PARITY-STARTUP-BRIEF] +// [unit->REQ-PARITY-TARGETED-HINTS] +// [unit->REQ-PARITY-UPDATE-NOTICE] +async function testStartupBriefHintsAndUpdateNotices() { + const harness = createHarness({ + checkUpdates: true, + fetchLatestAdapterVersion: async () => "v0.3.0", + onRun(call) { + if (call.args[0] === "--version") return "spt 1.0.0"; + if (call.args[0] === "--json" && call.args[1] === "notif") { + return JSON.stringify({ + notifs: [ + { + from_id: "spt-update", + kind: "consent", + state: "seen:1", + head: "An spt-core update v1.2.0 is available.", + }, + { + from_id: "spt-update", + kind: "consent", + state: "dismissed", + head: "An spt-core update v9.0.0 is available.", + }, + ], + }); + } + if (call.args[0] === "adapter" && call.args[1] === "version") return "0.2.1"; + }, + }); + await harness.emit("session_start"); + const first = await harness.emit("before_agent_start", { + prompt: + "Go live, show my endpoint identity, send a message to an agent, join two machines, and create a checkpoint.", + systemPrompt: ["base"], + }); + assert.equal(first.systemPrompt[0], "base"); + const injected = first.systemPrompt.at(-1); + for (const expected of [ + "spt whoami --json", + "spt endpoint list", + "spt how-to send", + "commune (checkpoint mode), signoff, and role", + "/ready", + "/live", + "spt how-to subnet", + "spt --version", + "spt adapter version omp-spt", + "spt update", + "Use extension-native `/live`", + "omp-spt v0.3.0 is available", + "spt adapter update omp-spt", + "spt-core v1.2.0 is available", + ]) { + assert.match(injected, new RegExp(expected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + } + const quiet = await harness.emit("before_agent_start", { + prompt: "Review live reload, identity matrices, message queues, subnet masks, and checkpoint files.", + systemPrompt: ["base"], + }); + assert.equal(quiet, undefined, "related technical nouns without clear SPT intent must not hint"); + await harness.emit("session_shutdown"); + + const unavailable = createHarness({ + checkUpdates: true, + fetchLatestAdapterVersion: async () => { + throw new Error("network down"); + }, + onRun(call) { + if (call.args[0] === "--version") throw new Error("version unavailable"); + if (call.args[0] === "--json" && call.args[1] === "notif") { + throw new Error("notification store unavailable"); + } + if (call.args[0] === "adapter" && call.args[1] === "version") { + throw new Error("adapter version unavailable"); + } + }, + }); + await unavailable.emit("session_start"); + const noClaim = await unavailable.emit("before_agent_start", { + prompt: "Continue the task.", + systemPrompt: [], + }); + assert.ok(noClaim.systemPrompt.at(-1).includes("OMP SPT endpoint")); + assert.ok(!noClaim.systemPrompt.at(-1).includes("OMP SPT updates:")); + await unavailable.emit("session_shutdown"); +} + +// [unit->REQ-PARITY-SAFE-BOUNDARY-DELIVERY] +// [unit->REQ-HAZARD-ABNORMAL-TURN-RECEIVABILITY] +async function testActiveTurnBoundaryDeliveryAndFallback() { + const harness = createHarness(); + await harness.emit("session_start"); + await harness.emit("agent_start"); + const firstEnvelope = 'one'; + const secondEnvelope = 'two'; + harness.children[0].stdout.emit("data", `${firstEnvelope}${secondEnvelope}`); + await flush(); + assert.deepEqual(harness.submitted, []); + + const boundary = await harness.emit("context", { + messages: [{ role: "user", content: "operator prompt" }], + }); + assert.equal( + boundary.messages.at(-1).content, + `\n\n${firstEnvelope}`, + "the first accepted message must enter at the next model boundary", + ); + const afterToolBoundary = await harness.emit("context", { + messages: [ + { role: "user", content: "operator prompt" }, + { role: "assistant", content: "", toolCalls: [{ name: "read" }] }, + { role: "toolResult", content: "tool output" }, + ], + }); + assert.equal( + afterToolBoundary.messages.at(-1).content, + `\n\n${firstEnvelope}`, + "ephemeral context must be re-injected before every later model continuation", + ); + await harness.emit("agent_end", { + messages: [ + { role: "user", content: "operator prompt" }, + { role: "assistant", content: "", toolCalls: [{ name: "read" }] }, + { role: "toolResult", content: "tool output" }, + { role: "assistant", content: "first outcome" }, + ], + }); + assert.equal(commandCalls(harness, "send")[0].args[1], "first"); + assert.equal(commandCalls(harness, "send")[0].input, "first outcome"); + assert.deepEqual(harness.clock.delays(), [0]); + await harness.clock.runNext(0); + assert.deepEqual( + harness.submitted, + [''], + "a later accepted message must preserve order and fall back to an ordinary next turn", + ); + assertNoAgentManagedPoll(harness); + await harness.emit("session_shutdown"); +} + +// [unit->REQ-HAZARD-ABNORMAL-TURN-RECEIVABILITY] +async function testAbnormalTurnsRestoreReceivability() { + for (const [label, completionEvent] of [ + ["cancelled", "session_stop"], + ["interrupted", "session_stop"], + ["failed", "agent_end"], + ]) { + const harness = createHarness(); + await harness.emit("session_start"); + await harness.emit("agent_start"); + harness.children[0].stdout.emit( + "data", + `work`, + ); + await flush(); + const boundary = await harness.emit("context", { + messages: [{ role: "user", content: "active work" }], + }); + await harness.emit(completionEvent, { messages: boundary.messages }); + const outcome = commandCalls(harness, "send")[0]; + assert.equal(outcome.args[1], label); + assert.match(outcome.input, /turn ended without an assistant response/); + assert.equal(stateCalls(harness).at(-1).args[4], "idle"); + + harness.children[0].stdout.emit( + "data", + `next`, + ); + await flush(); + assert.deepEqual(harness.submitted, [``]); + await harness.emit("session_shutdown"); + } +} + +// [unit->REQ-PARITY-PEER-SHORTFORM] +async function testPeerShortformParsingAndDispatch() { + assert.deepEqual( + parsePeerShortforms("Before @ after @").map( + ({ targets, body }) => ({ targets, body }), + ), + [ + { targets: ["alpha", "beta"], body: "hello there" }, + { targets: ["gamma"], body: "second\nline" }, + ], + ); + assert.deepEqual( + parsePeerShortforms( + "ordinary @alice; `@`\n```\n@\n```\n@", + ), + [], + ); + + const alphaGate = deferred(); + const harness = createHarness({ + onRun(call) { + if (call.args[0] === "send" && call.args[1] === "alpha") return alphaGate.promise; + if (call.args[0] === "send" && call.args[1] === "beta") { + throw new Error("NO_PERCH:beta"); + } + if (call.args[0] === "send" && call.args[1] === "gamma") return "QUEUED:gamma"; + }, + }); + await harness.emit("session_start"); + await harness.emit("agent_start"); + const completion = harness.emit("agent_end", { + messages: [ + { + role: "assistant", + content: "@\nDone.\n@", + }, + ], + }); + await flush(); + assert.equal( + commandCalls(harness, "send").length, + 3, + "multi-target peer dispatch must not serialize independent recipients", + ); + alphaGate.resolve("SENT:alpha"); + await completion; + assert.deepEqual( + commandCalls(harness, "send").map((call) => [call.args[1], call.input]), + [ + ["alpha", "ship the patch"], + ["beta", "ship the patch"], + ["gamma", "inspect release"], + ], + ); + assert.ok( + commandCalls(harness, "send").every((call) => call.options.timeoutMs === 5_000), + "peer sends must be bounded independently of the ordinary command timeout", + ); + assert.equal(harness.sentMessages.length, 1); + assert.deepEqual(harness.sentMessages[0].delivery, { + deliverAs: "nextTurn", + triggerTurn: true, + }); + assert.match(harness.sentMessages[0].message.content, /alpha: SENT:alpha/); + assert.match(harness.sentMessages[0].message.content, /beta: failed \(NO_PERCH:beta\)/); + assert.match(harness.sentMessages[0].message.content, /gamma: QUEUED:gamma/); + + await harness.emit("agent_start"); + await harness.emit("agent_end", { + messages: [ + { + role: "assistant", + content: "@\nDone.\n@", + }, + { role: "user", content: "@" }, + { + role: "assistant", + content: "Mention @alpha and quote `@` without side effects.", + }, + ], + }); + assert.equal(commandCalls(harness, "send").length, 3); + assert.equal(harness.sentMessages.length, 1); + await harness.emit("session_shutdown"); + + const pending = deferred(); + const interrupted = createHarness({ + onRun(call) { + if (call.args[0] === "send") return pending.promise; + }, + }); + await interrupted.emit("session_start"); + await interrupted.emit("agent_start"); + const interruptedCompletion = interrupted.emit("agent_end", { + messages: [{ role: "assistant", content: "@" }], + }); + await flush(); + assert.equal(commandCalls(interrupted, "send").length, 1); + const shutdown = interrupted.emit("session_shutdown"); + await flush(); + pending.resolve("SENT:slow-target"); + await Promise.all([interruptedCompletion, shutdown]); + assert.deepEqual( + interrupted.sentMessages, + [], + "shutdown during peer dispatch must not queue a false continuation", + ); +} + +// [unit->REQ-PARITY-CHECKPOINT] +async function testNativeCheckpointTool() { + const compactGate = deferred(); + const harness = createHarness({ + onCompact() { + return compactGate.promise; + }, + onRun(call) { + if (call.args[0] === "--json" && call.args[2] === "endpoint-info") { + return JSON.stringify({ endpoint_type: "live_agent" }); + } + }, + }); + await harness.emit("session_start"); + const tool = harness.tools.get("spt_checkpoint"); + const checkpoint = tool.execute( + "tool-1", + { wake: "Continue release preparation." }, + undefined, + undefined, + harness.ctx, + ); + await flush(); + assert.deepEqual(harness.sentMessages, [], "checkpoint wake must wait for native compaction"); + compactGate.resolve(); + const result = await checkpoint; + assert.equal(result.details.ok, true); + assert.equal(harness.compactions.length, 1); + assert.deepEqual(harness.sentMessages[0], { + message: { + customType: "omp-spt-checkpoint-wake", + content: "Continue release preparation.", + display: true, + attribution: "user", + }, + delivery: { deliverAs: "nextTurn", triggerTurn: true }, + }); + await harness.emit("session_shutdown"); + + const ready = createHarness({ id: null }); + await ready.emit("session_start"); + await ready.commands.get("ready").handler("plain-ready", ready.ctx); + const refused = await ready.tools + .get("spt_checkpoint") + .execute("tool-2", {}, undefined, undefined, ready.ctx); + assert.equal(refused.isError, true); + assert.equal(refused.details.reason, "not-live"); + assert.deepEqual(ready.compactions, []); + await ready.emit("session_shutdown"); + const launchedReady = createHarness({ + onRun(call) { + if (call.args[0] === "--json" && call.args[2] === "endpoint-info") { + return JSON.stringify({ endpoint_type: "ready_agent" }); + } + }, + }); + await launchedReady.emit("session_start"); + const launchedRefusal = await launchedReady.tools + .get("spt_checkpoint") + .execute("tool-launch-ready", {}, undefined, undefined, launchedReady.ctx); + assert.equal(launchedRefusal.details.reason, "not-live"); + assert.deepEqual(launchedReady.compactions, []); + await launchedReady.emit("session_shutdown"); + + + const failed = createHarness({ + onCompact() { + throw new Error("native compaction cancelled"); + }, + onRun(call) { + if (call.args[0] === "--json" && call.args[2] === "endpoint-info") { + return JSON.stringify({ endpoint_type: "live_agent" }); + } + }, + }); + await failed.emit("session_start"); + const failure = await failed.tools + .get("spt_checkpoint") + .execute("tool-3", {}, undefined, undefined, failed.ctx); + assert.equal(failure.isError, true); + assert.match(failure.content[0].text, /native compaction cancelled/); + assert.deepEqual(failed.sentMessages, [], "a failed reset must never queue a false wake"); + await failed.emit("session_shutdown"); +} + await testParsingAndReplies(); await testRunSptRejectsStdinErrorsAndHungCommands(); await testLifecycleCustodyAndContext(); @@ -1373,5 +2068,14 @@ await testProtocolCorruptionFailsClosed(); await testInboundQueueOverflowReturnsAcceptedCustody(); await testSessionStopAwaitsOutcomeWithoutEndingEndpoint(); await testShutdownFallbackStaysBelowHostCap(); +await testNativeActivationCommandsAndErrors(); +await testNativeActivationSelectionAndCompletion(); +await testPlatformAwareActivationCandidatePaths(); +await testExplicitLiveAutoResume(); +await testStartupBriefHintsAndUpdateNotices(); +await testActiveTurnBoundaryDeliveryAndFallback(); +await testAbnormalTurnsRestoreReceivability(); +await testPeerShortformParsingAndDispatch(); +await testNativeCheckpointTool(); console.log("OMP-EXTENSION OK"); diff --git a/tests/release-acquire.sh b/tests/release-acquire.sh index e4fdb5c..e73c02f 100644 --- a/tests/release-acquire.sh +++ b/tests/release-acquire.sh @@ -15,6 +15,7 @@ SH=$(command -v sh) || { echo "FAIL: sh unavailable"; exit 1; } TMP=$(mktemp -d "${TMPDIR:-/tmp}/omp-spt-release-acquire.XXXXXX") || { echo "FAIL: mktemp"; exit 1; } trap 'rm -rf "$TMP"' EXIT INT TERM BIN="$TMP/bin" +TARGET=x86_64-unknown-linux-musl mkdir -p "$BIN" "$TMP/empty" "$TMP/spt-only" cat > "$BIN/gh" <<'EOF' @@ -72,39 +73,54 @@ fi # Once opted in, missing inputs and tooling are release failures, never green skips. expect_failure "missing release tag" "OMP_SPT_RELEASE_TAG is required" \ env PATH="$BIN:$PATH" OMP_SPT_ACCEPTANCE=1 OMP_SPT_EXPECTED_VERSION=0.2.0 \ - "$SH" "$ACQUIRE" + OMP_SPT_ACCEPTANCE_TARGET="$TARGET" "$SH" "$ACQUIRE" expect_failure "missing expected version" "OMP_SPT_EXPECTED_VERSION is required" \ env PATH="$BIN:$PATH" OMP_SPT_ACCEPTANCE=1 OMP_SPT_RELEASE_TAG=v0.2.0 \ + OMP_SPT_ACCEPTANCE_TARGET="$TARGET" "$SH" "$ACQUIRE" +expect_failure "missing acceptance target" "OMP_SPT_ACCEPTANCE_TARGET is required" \ + env PATH="$BIN:$PATH" OMP_SPT_ACCEPTANCE=1 OMP_SPT_RELEASE_TAG=v0.2.0 \ + OMP_SPT_EXPECTED_VERSION=0.2.0 "$SH" "$ACQUIRE" +expect_failure "unsupported acceptance target" "is not a release target" \ + env PATH="$BIN:$PATH" OMP_SPT_ACCEPTANCE=1 OMP_SPT_RELEASE_TAG=v0.2.0 \ + OMP_SPT_EXPECTED_VERSION=0.2.0 OMP_SPT_ACCEPTANCE_TARGET=aarch64-unknown-linux-musl \ "$SH" "$ACQUIRE" expect_failure "noncanonical release tag" "must equal v0.2.0" \ env PATH="$BIN:$PATH" OMP_SPT_ACCEPTANCE=1 OMP_SPT_RELEASE_TAG=omp-spt-v0.2.0 \ - OMP_SPT_EXPECTED_VERSION=0.2.0 "$SH" "$ACQUIRE" + OMP_SPT_EXPECTED_VERSION=0.2.0 OMP_SPT_ACCEPTANCE_TARGET="$TARGET" "$SH" "$ACQUIRE" expect_failure "missing spt" "spt is required on PATH" \ env PATH="$TMP/empty" OMP_SPT_ACCEPTANCE=1 OMP_SPT_RELEASE_TAG=v0.2.0 \ - OMP_SPT_EXPECTED_VERSION=0.2.0 "$SH" "$ACQUIRE" + OMP_SPT_EXPECTED_VERSION=0.2.0 OMP_SPT_ACCEPTANCE_TARGET="$TARGET" "$SH" "$ACQUIRE" expect_failure "missing gh" "gh is required on PATH" \ env PATH="$TMP/spt-only" OMP_SPT_ACCEPTANCE=1 OMP_SPT_RELEASE_TAG=v0.2.0 \ - OMP_SPT_EXPECTED_VERSION=0.2.0 "$SH" "$ACQUIRE" + OMP_SPT_EXPECTED_VERSION=0.2.0 OMP_SPT_ACCEPTANCE_TARGET="$TARGET" "$SH" "$ACQUIRE" expect_failure "unauthenticated gh" "gh is not authenticated" \ env PATH="$BIN:$PATH" FAKE_GH_AUTH_FAIL=1 OMP_SPT_ACCEPTANCE=1 \ - OMP_SPT_RELEASE_TAG=v0.2.0 OMP_SPT_EXPECTED_VERSION=0.2.0 "$SH" "$ACQUIRE" + OMP_SPT_RELEASE_TAG=v0.2.0 OMP_SPT_EXPECTED_VERSION=0.2.0 \ + OMP_SPT_ACCEPTANCE_TARGET="$TARGET" "$SH" "$ACQUIRE" -# A successful dry run must use the canonical tag and prove the installed manifest version. -if env PATH="$BIN:$PATH" OMP_SPT_ACCEPTANCE=1 OMP_SPT_RELEASE_TAG=v0.2.0 \ - OMP_SPT_EXPECTED_VERSION=0.2.0 "$SH" "$ACQUIRE" >"$TMP/output" 2>&1 \ - && grep -Fq "adapter version is exactly 0.2.0" "$TMP/output" \ - && grep -Fq "RELEASE-ACQUIRE-INT OK" "$TMP/output"; then - ok "canonical tagged acquisition verifies the expected version" -else - fail "canonical tagged acquisition dry run failed: $(cat "$TMP/output")" -fi +# Every archive target is an accepted evidence key; the musl run is representative only when the +# operator executes it on the OMP-capable host required by the release runbook. +for TARGET in x86_64-pc-windows-msvc x86_64-unknown-linux-gnu x86_64-unknown-linux-musl; do + if env PATH="$BIN:$PATH" OMP_SPT_ACCEPTANCE=1 OMP_SPT_RELEASE_TAG=v0.2.0 \ + OMP_SPT_EXPECTED_VERSION=0.2.0 OMP_SPT_ACCEPTANCE_TARGET="$TARGET" \ + "$SH" "$ACQUIRE" >"$TMP/output" 2>&1 \ + && grep -Fq "acceptance evidence target is exactly $TARGET" "$TMP/output" \ + && grep -Fq "adapter version is exactly 0.2.0" "$TMP/output" \ + && grep -Fq "RELEASE-ACQUIRE-INT OK target=$TARGET" "$TMP/output"; then + ok "canonical tagged acquisition records $TARGET" + else + fail "canonical tagged acquisition dry run failed for $TARGET: $(cat "$TMP/output")" + fi +done expect_failure "wrong acquired version" "does not equal expected '0.2.0'" \ env PATH="$BIN:$PATH" FAKE_VERSION=0.1.3 OMP_SPT_ACCEPTANCE=1 \ - OMP_SPT_RELEASE_TAG=v0.2.0 OMP_SPT_EXPECTED_VERSION=0.2.0 "$SH" "$ACQUIRE" + OMP_SPT_RELEASE_TAG=v0.2.0 OMP_SPT_EXPECTED_VERSION=0.2.0 \ + OMP_SPT_ACCEPTANCE_TARGET=x86_64-unknown-linux-musl "$SH" "$ACQUIRE" expect_failure "failed acquisition with fetched output" "adapter add --release failed" \ env PATH="$BIN:$PATH" FAKE_ADD_FAIL=1 OMP_SPT_ACCEPTANCE=1 \ - OMP_SPT_RELEASE_TAG=v0.2.0 OMP_SPT_EXPECTED_VERSION=0.2.0 "$SH" "$ACQUIRE" + OMP_SPT_RELEASE_TAG=v0.2.0 OMP_SPT_EXPECTED_VERSION=0.2.0 \ + OMP_SPT_ACCEPTANCE_TARGET=x86_64-unknown-linux-musl "$SH" "$ACQUIRE" [ "$rc" -eq 0 ] && echo "PASS: release-acquire" exit "$rc" diff --git a/tools/omp-spt/Cargo.lock b/tools/omp-spt/Cargo.lock index e8c343a..c04841a 100644 --- a/tools/omp-spt/Cargo.lock +++ b/tools/omp-spt/Cargo.lock @@ -40,7 +40,7 @@ checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "omp-spt" -version = "0.2.0" +version = "0.3.0" dependencies = [ "getrandom", "serde_json", diff --git a/tools/omp-spt/Cargo.toml b/tools/omp-spt/Cargo.toml index a0504ba..ed79803 100644 --- a/tools/omp-spt/Cargo.toml +++ b/tools/omp-spt/Cargo.toml @@ -3,7 +3,7 @@ # [impl->REQ-DIST-BINARY-CONSOLIDATE] [package] name = "omp-spt" -version = "0.2.0" +version = "0.3.0" edition = "2021" publish = false diff --git a/traceable-reqs.toml b/traceable-reqs.toml index 025d3a1..efe175b 100644 --- a/traceable-reqs.toml +++ b/traceable-reqs.toml @@ -12,8 +12,14 @@ roots = [ "docs/adr/0011-endpoint-session-binding-is-immutable.md", "docs/adr/0012-continuity-drops-live-under-spt.md", "docs/adr/0013-release-gate-stops-at-the-adapter-boundary.md", + "docs/PARITY.md", + "docs/adr/0015-extension-owned-session-activation.md", + "docs/adr/0017-active-turn-delivery-uses-safe-boundaries.md", + "docs/adr/0016-agent-capabilities-split-by-native-seam.md", + "docs/adr/0018-checkpoint-resets-context-natively.md", "adapter/omp-spt.toml", "adapter/strings/omp-spt.mjs", + "adapter/strings/skills", "tools/omp-spt/Cargo.toml", "tools/omp-spt/src/main.rs", "tools/omp-spt/src/digest_omp.rs", @@ -26,6 +32,7 @@ roots = [ "tests/manifest-shortcut.sh", "tests/native-launch-manifest.sh", "tests/omp-extension.mjs", + "tests/omp-skills.mjs", "tests/manifest-schema.sh", "tests/adapter-archive.sh", "ci/manifest/check-manifest.sh", @@ -34,6 +41,17 @@ roots = [ "ci/publish/release-acquire-int.sh", "ci/digest/build.sh", "ci/digest/digest-proof-int.sh", + "docs/RELEASE-RUNBOOK.md", + "docs-site/README.md", + "docs-site/src/reference/release-evidence.md", + ".github/workflows/docs-pages.yml", + "ci/docs/build-docs.py", + "ci/release/check-version-consistency.py", + "ci/release/validate-release-evidence.py", + "ci/run-gates.sh", + "tests/docs-gate.py", + "tests/release-evidence.py", + "tests/version-consistency.py", ] [policy] @@ -120,3 +138,93 @@ required_stages = ["doc", "impl", "unit", "int"] id = "REQ-DIST-BINARY-CONSOLIDATE" title = "One omp-spt helper binary dispatches only the retained OMP-native launch, digest, history, Psyche, and echo-commune roles" required_stages = ["impl", "unit"] + +[[requirements]] +id = "REQ-PARITY-READY-ACTIVATION" +title = "An extension-native command binds an ordinary OMP session as a ready endpoint while the extension owns its listener" +required_stages = ["doc", "impl", "unit"] + +[[requirements]] +id = "REQ-PARITY-LIVE-ACTIVATION" +title = "An extension-native command binds an ordinary OMP session as a live endpoint with durable Psyche continuity" +required_stages = ["doc", "impl", "unit"] + +[[requirements]] +id = "REQ-PARITY-LIVE-AUTO-RESUME" +title = "Explicit live auto-resume selects the most-recent compatible live identity without silently guessing on ordinary activation" +required_stages = ["doc", "impl", "unit"] + +[[requirements]] +id = "REQ-PARITY-SAFE-BOUNDARY-DELIVERY" +title = "Accepted peer messages enter an active OMP turn before its next tool or model continuation, with ordered next-turn fallback" +required_stages = ["doc", "impl", "unit"] + +[[requirements]] +id = "REQ-HAZARD-ABNORMAL-TURN-RECEIVABILITY" +title = "Cancellation, interruption, and failed OMP turns settle affected custody and restore endpoint receivability" +required_stages = ["doc", "impl", "unit"] + +[[requirements]] +id = "REQ-PARITY-STARTUP-BRIEF" +title = "Activated OMP sessions receive concise identity, roster, messaging, continuity, lifecycle, subnet, and version guidance" +required_stages = ["doc", "impl", "unit"] + +[[requirements]] +id = "REQ-PARITY-TARGETED-HINTS" +title = "Clear user intent for live activation, identity, messaging, subnet, or checkpointing receives a concise targeted hint" +required_stages = ["doc", "impl", "unit"] + +[[requirements]] +id = "REQ-PARITY-UPDATE-NOTICE" +title = "An activated session receives an actionable notice when its spt-core or omp-spt version trails a compatible release" +required_stages = ["doc", "impl", "unit"] + +[[requirements]] +id = "REQ-PARITY-COMMUNE-SKILL" +title = "A packaged OMP plugin skill authors endpoint-qualified commune continuity drops" +required_stages = ["doc", "impl", "unit", "int"] + +[[requirements]] +id = "REQ-PARITY-CHECKPOINT" +title = "Commune checkpoint mode saves continuity, resets OMP context natively, and wakes the same endpoint without operator intervention" +required_stages = ["doc", "impl", "unit"] + +[[requirements]] +id = "REQ-PARITY-SIGNOFF-SKILL" +title = "A packaged OMP plugin skill authors final signoff continuity and gracefully shuts down the endpoint" +required_stages = ["doc", "impl", "unit", "int"] + +[[requirements]] +id = "REQ-PARITY-ROLE-SKILL" +title = "A packaged OMP plugin skill reads and edits the durable role through directives or an interactive draft" +required_stages = ["doc", "impl", "unit", "int"] + +[[requirements]] +id = "REQ-PARITY-SETUP" +title = "Hybrid extension command and packaged skill diagnose and complete OMP SPT setup with explicit operator handoffs" +required_stages = ["doc", "impl", "unit", "int"] + +[[requirements]] +id = "REQ-PARITY-PEER-SHORTFORM" +title = "OMP dispatches explicit @ assistant output through public SPT messaging with observable results" +required_stages = ["doc", "impl", "unit"] + +[[requirements]] +id = "REQ-DIST-LINUX-MUSL" +title = "The release ships and natively accepts a static x86_64 Linux musl helper compatibility tier" +required_stages = ["doc", "impl", "unit", "int"] + +[[requirements]] +id = "REQ-DIST-VERSION-CONSISTENCY" +title = "Manifest version is canonical and release gates reject disagreement in helper, changelog, and versioned public documentation" +required_stages = ["doc", "impl", "unit", "int"] + +[[requirements]] +id = "REQ-DOCS-PUBLISH-GATE" +title = "Local deterministic docs drift checks gate changes and the same checks gate GitHub Pages publication" +required_stages = ["doc", "impl", "unit", "int"] + +[[requirements]] +id = "REQ-DIST-RELEASE-EVIDENCE" +title = "Every supported target retains a machine-readable release record covering source identity, digests, acquisition, and native acceptance" +required_stages = ["doc", "impl", "unit", "int"] [raw output: artifact://304]