From d7b04bc5ed7ed787771ee271f5f2e8c53dff1d92 Mon Sep 17 00:00:00 2001 From: madgegja Date: Mon, 10 Aug 2026 13:44:32 +0000 Subject: [PATCH 1/4] refactor(coding-agent): remove OMO update coupling --- .../skills/senpi-qa/references/env-vars.md | 3 +- .../skills/senpi-qa/scripts/lib/common.mjs | 7 +- .../src/beta/omo-local-update-artifacts.ts | 88 -- .../src/beta/omo-local-update-fingerprint.ts | 63 - .../src/beta/omo-local-update-worker.ts | 62 - .../coding-agent/src/beta/omo-local-update.ts | 880 -------------- packages/coding-agent/src/changes.md | 17 + .../coding-agent/src/package-manager-cli.ts | 35 - .../omo-local-update-artifact-repair.test.ts | 64 - .../test/omo-local-update-dispatch.test.ts | 166 --- .../test/omo-local-update-fingerprint.test.ts | 174 --- .../test/omo-local-update-fixture.ts | 248 ---- .../test/omo-local-update-helpers.ts | 137 --- .../test/omo-local-update.test.ts | 1075 ----------------- .../test/package-command-paths.test.ts | 11 + .../test/product-boundary.test.ts | 54 + 16 files changed, 85 insertions(+), 2999 deletions(-) delete mode 100644 packages/coding-agent/src/beta/omo-local-update-artifacts.ts delete mode 100644 packages/coding-agent/src/beta/omo-local-update-fingerprint.ts delete mode 100644 packages/coding-agent/src/beta/omo-local-update-worker.ts delete mode 100644 packages/coding-agent/src/beta/omo-local-update.ts delete mode 100644 packages/coding-agent/test/omo-local-update-artifact-repair.test.ts delete mode 100644 packages/coding-agent/test/omo-local-update-dispatch.test.ts delete mode 100644 packages/coding-agent/test/omo-local-update-fingerprint.test.ts delete mode 100644 packages/coding-agent/test/omo-local-update-fixture.ts delete mode 100644 packages/coding-agent/test/omo-local-update-helpers.ts delete mode 100644 packages/coding-agent/test/omo-local-update.test.ts create mode 100644 packages/coding-agent/test/product-boundary.test.ts diff --git a/.agents/skills/senpi-qa/references/env-vars.md b/.agents/skills/senpi-qa/references/env-vars.md index df5b5ef74b..09b8888227 100644 --- a/.agents/skills/senpi-qa/references/env-vars.md +++ b/.agents/skills/senpi-qa/references/env-vars.md @@ -5,8 +5,7 @@ | Var | Purpose | |---|---| | `SENPI_CODING_AGENT_DIR` | Agent config dir (auth.json, models.json, settings, sessions). QA points it at a temp sandbox so the real `~/.senpi/agent` is untouched. | -| `HOME` / `USERPROFILE` | Pointed at the sandbox root by `makeSandbox()` so HOME-derived senpi paths (package discovery, omo-native detection, omo-local-update) cannot reach the real user home. | -| `SENPI_OMO_LOCAL_UPDATE` | Set to `0` in the sandbox env so the beta omo-local-update path swap stays disarmed during QA runs. | +| `HOME` / `USERPROFILE` | Pointed at the sandbox root by `makeSandbox()` so HOME-derived package discovery cannot reach the real user home. | | `SENPI_CODING_AGENT_SESSION_DIR` | Session storage dir. QA points it at the sandbox. | | `PI_OFFLINE` | `1` disables startup network operations. QA always sets it. | | `PI_TELEMETRY` | `0` disables install telemetry. QA always sets it. | diff --git a/.agents/skills/senpi-qa/scripts/lib/common.mjs b/.agents/skills/senpi-qa/scripts/lib/common.mjs index 97dd1e13d0..7185e1036d 100644 --- a/.agents/skills/senpi-qa/scripts/lib/common.mjs +++ b/.agents/skills/senpi-qa/scripts/lib/common.mjs @@ -87,11 +87,9 @@ export function makeSandbox(label = "senpi-qa") { // Keep QA hermetic and quiet: no startup network, no telemetry. PI_OFFLINE: "1", PI_TELEMETRY: "0", - // HOME-derived senpi paths (package discovery, omo-native detection, omo-local-update) - // must resolve inside the sandbox, and the local-update path swap must stay disarmed. + // HOME-derived package discovery must stay inside the sandbox. HOME: dir, USERPROFILE: dir, - SENPI_OMO_LOCAL_UPDATE: "0", // Never let an interactive pager/editor hang a captured run. PAGER: "cat", GIT_PAGER: "cat", @@ -344,8 +342,7 @@ async function selfCheck() { box.env[ENV_SESSION_DIR] === box.sessionDir && box.env.PI_OFFLINE === "1" && box.env.HOME === box.dir && - box.env.USERPROFILE === box.dir && - box.env.SENPI_OMO_LOCAL_UPDATE === "0", + box.env.USERPROFILE === box.dir, box.dir, ); diff --git a/packages/coding-agent/src/beta/omo-local-update-artifacts.ts b/packages/coding-agent/src/beta/omo-local-update-artifacts.ts deleted file mode 100644 index d56f937516..0000000000 --- a/packages/coding-agent/src/beta/omo-local-update-artifacts.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { type Dirent, existsSync, readdirSync } from "node:fs"; -import { join, relative, sep } from "node:path"; - -const REQUIRED_BUILD_ARTIFACTS = [ - "extensions/omo.js", - "runtime/lsp-daemon/dist/cli.js", - "runtime/lsp-daemon/dist/index.js", - "runtime/lsp-daemon/dist/.omo-runtime-manifest.json", - "scripts/install.mjs", -] as const; - -function walkFiles(rootDir: string): string[] { - const files: string[] = []; - const pending = [rootDir]; - while (pending.length > 0) { - const dir = pending.pop(); - if (dir === undefined) { - continue; - } - let entries: Dirent[]; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch (error) { - if (error instanceof Error) { - continue; - } - throw error; - } - for (const entry of entries) { - const full = join(dir, entry.name); - if (entry.isDirectory()) { - pending.push(full); - } else if (entry.isFile()) { - files.push(full); - } - } - } - return files; -} - -export function collectArtifactInventory(pluginPath: string): string[] { - const inventory: string[] = []; - const collectUnder = (relativeDir: string, filter?: (posixPath: string) => boolean): void => { - for (const filePath of walkFiles(join(pluginPath, relativeDir))) { - const posixPath = relative(pluginPath, filePath).split(sep).join("/"); - if (filter === undefined || filter(posixPath)) { - inventory.push(posixPath); - } - } - }; - collectUnder("extensions"); - collectUnder(join("runtime", "lsp-daemon", "dist")); - collectUnder("skills", (posixPath) => posixPath.endsWith("/SKILL.md")); - if (existsSync(join(pluginPath, "scripts", "install.mjs"))) { - inventory.push("scripts/install.mjs"); - } - inventory.sort(); - return inventory; -} - -export function currentRequiredBuildArtifactsExist(pluginPath: string): boolean { - return findMissingBuildArtifacts(pluginPath).length === 0; -} - -export function findMissingBuildArtifacts(pluginPath: string): string[] { - const missing: string[] = []; - for (const required of REQUIRED_BUILD_ARTIFACTS) { - if (!existsSync(join(pluginPath, required))) { - missing.push(required); - } - } - let skillCount = 0; - try { - for (const entry of readdirSync(join(pluginPath, "skills"), { withFileTypes: true })) { - if (entry.isDirectory() && existsSync(join(pluginPath, "skills", entry.name, "SKILL.md"))) { - skillCount++; - } - } - } catch (error) { - if (!(error instanceof Error)) { - throw error; - } - } - if (skillCount === 0) { - missing.push("skills/*/SKILL.md"); - } - return missing; -} diff --git a/packages/coding-agent/src/beta/omo-local-update-fingerprint.ts b/packages/coding-agent/src/beta/omo-local-update-fingerprint.ts deleted file mode 100644 index fabc82ebae..0000000000 --- a/packages/coding-agent/src/beta/omo-local-update-fingerprint.ts +++ /dev/null @@ -1,63 +0,0 @@ -// BETA(omo-local-update): removable beta module - delete together with -// omo-local-update.ts and all test/omo-local-update* files. -// -// Build-input fingerprint of origin/dev: a sha256 over the repo's ROOT tree -// entries (from `git ls-tree -z origin/dev`), excluding top-level paths that -// can never feed `bun install` + `bun run build:senpi-plugin`. The exclusion -// direction is the safety property: an entry wrongly INCLUDED only causes an -// unnecessary rebuild, while an entry wrongly EXCLUDED would ship a stale -// plugin - so only clearly build-irrelevant documentation/agent-config paths -// are listed, and every unknown root path counts as a build input. - -import { createHash } from "node:crypto"; - -const EXCLUDED_ROOT_PATHS: ReadonlySet = new Set([ - ".agents", - ".claude", - ".codegraph", - ".codex", - ".cursor", - ".devcontainer", - ".env.example", - ".gitattributes", - ".github", - ".idea", - ".mcp.json", - ".omo", - ".opencode", - ".vscode", - "AGENTS.md", - "CHANGELOG.md", - "CLA.md", - "CLAUDE.md", - "CONTRIBUTING.md", - "LICENSE", - "LICENSE.md", - "ROADMAP.md", - "THIRD-PARTY-NOTICES.md", - "assets", - "docs", -]); - -/** exported for tests only */ -export function isBuildInputRootPath(name: string): boolean { - return !EXCLUDED_ROOT_PATHS.has(name) && !name.startsWith("README"); -} - -/** Digest NUL-separated `git ls-tree -z` root entries into the build-input fingerprint. */ -export function computeBuildInputsHashFromLsTree(lsTreeOutput: string): string { - const hash = createHash("sha256"); - for (const entry of lsTreeOutput.split("\0")) { - const tabIndex = entry.indexOf("\t"); - if (tabIndex === -1) { - continue; - } - const name = entry.slice(tabIndex + 1); - if (!isBuildInputRootPath(name)) { - continue; - } - const objectHash = entry.slice(0, tabIndex).split(" ")[2] ?? ""; - hash.update(`${objectHash}\t${name}\n`); - } - return hash.digest("hex"); -} diff --git a/packages/coding-agent/src/beta/omo-local-update-worker.ts b/packages/coding-agent/src/beta/omo-local-update-worker.ts deleted file mode 100644 index 04e5153281..0000000000 --- a/packages/coding-agent/src/beta/omo-local-update-worker.ts +++ /dev/null @@ -1,62 +0,0 @@ -// BETA(omo-local-update): removable beta module - delete together with -// omo-local-update.ts and all test/omo-local-update* files. -// -// Detached background worker spawn for the OMO local plugin update. The -// foreground `senpi update` only fetches and compares; when a rebuild is -// needed it re-invokes this CLI as `update --omo-local-update-worker` -// (daemon.ts spawn pattern: detached, unref, output to a log file) so the -// 30s+ bun install/build never blocks the user's terminal. The worker -// serializes against concurrent updates through the existing pid lock. - -import { spawn } from "node:child_process"; -import { closeSync, mkdirSync, openSync } from "node:fs"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { detectInstallMethod } from "../config.ts"; - -export interface OmoLocalWorkerSpawnRequest { - agentDir: string; - force: boolean; -} - -export type OmoLocalWorkerSpawnOutcome = - | { ok: true; pid: number | undefined; logPath: string } - | { ok: false; message: string }; - -export type OmoLocalSpawnWorker = (request: OmoLocalWorkerSpawnRequest) => OmoLocalWorkerSpawnOutcome; - -export function omoLocalUpdateWorkerLogPath(agentDir: string): string { - return join(agentDir, "omo-local-update", "worker.log"); -} - -function workerCommandArgs(force: boolean): string[] { - const updateArgs = ["update", "--omo-local-update-worker", ...(force ? ["--force"] : [])]; - if (detectInstallMethod() === "bun-binary") { - return updateArgs; - } - const modulePath = fileURLToPath(import.meta.url); - const extension = modulePath.endsWith(".ts") ? ".ts" : ".js"; - const cliMainPath = resolve(dirname(modulePath), "..", `cli-main${extension}`); - return [...process.execArgv, cliMainPath, ...updateArgs]; -} - -export const defaultSpawnWorker: OmoLocalSpawnWorker = (request) => { - const logPath = omoLocalUpdateWorkerLogPath(request.agentDir); - try { - mkdirSync(dirname(logPath), { recursive: true }); - const logFd = openSync(logPath, "w"); - try { - const child = spawn(process.execPath, workerCommandArgs(request.force), { - detached: true, - env: process.env, - stdio: ["ignore", logFd, logFd], - }); - child.unref(); - return { ok: true, pid: child.pid, logPath }; - } finally { - closeSync(logFd); - } - } catch (error) { - return { ok: false, message: error instanceof Error ? error.message : String(error) }; - } -}; diff --git a/packages/coding-agent/src/beta/omo-local-update.ts b/packages/coding-agent/src/beta/omo-local-update.ts deleted file mode 100644 index 50cc84d1c9..0000000000 --- a/packages/coding-agent/src/beta/omo-local-update.ts +++ /dev/null @@ -1,880 +0,0 @@ -// BETA(omo-local-update): removable beta module - delete this file, all test/omo-local-update* -// files to drop the feature. -// -// Beta: on bare `senpi update`, compare a locally-installed OMO plugin (omo-senpi + -// senpi-task) against origin/dev of its source checkout and replace the LOCAL PLUGIN -// INSTALL only when different. The user's checkout receives ZERO git mutations: this module -// never runs checkout/branch/commit/merge/reset/clean/stash/push against the user's repo. -// Its only git writes are `git fetch origin dev` (remote-tracking ref update) plus -// worktree add/remove/prune registering the FEATURE-OWNED persistent build worktree under -// /omo-local-update/. `checkout --detach --force` is used exactly once and only -// inside that feature-owned worktree, which this module creates and owns exclusively. -// Builds run there; the install target (pluginPath) is swapped atomically by rename. -// -// Export policy: `runOmoLocalUpdateBeta` is the ONLY production API and the only symbol the -// CLI may import. Every other export in this module is /** exported for tests only */ so the -// single test file (test/omo-local-update.test.ts) can exercise helpers directly - no -// CLI-side helper imports, no logic duplication. - -import { randomUUID } from "node:crypto"; -import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { dirname, join, relative, resolve } from "node:path"; -import chalk from "chalk"; -import type { PackageSource } from "../core/settings-manager.ts"; -import { spawnProcess, waitForChildProcess } from "../utils/child-process.ts"; -import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts"; -import { killProcessTree } from "../utils/shell.ts"; -import { - collectArtifactInventory, - currentRequiredBuildArtifactsExist, - findMissingBuildArtifacts, -} from "./omo-local-update-artifacts.ts"; -import { computeBuildInputsHashFromLsTree } from "./omo-local-update-fingerprint.ts"; -import { defaultSpawnWorker, type OmoLocalSpawnWorker } from "./omo-local-update-worker.ts"; - -/** Result of one spawned command, with output captured and timeout enforced. */ -export interface OmoLocalRunResult { - code: number | null; - stdout: string; - stderr: string; - timedOut: boolean; -} - -export interface OmoLocalRunOptions { - cwd?: string; - timeoutMs?: number; - /** Merged over process.env (callers inject the isolated git env from the fixture contract). */ - env?: Record; -} - -/** exported for tests only */ -export type OmoLocalRun = (command: string, args: string[], options: OmoLocalRunOptions) => Promise; - -/** - * exported for tests only - * - * Shared process seam for the whole module. spawnProcess/waitForChildProcess neither capture - * output nor enforce timeouts; this adds both. The child is spawned detached on POSIX so it - * owns a process group, and the timeout kill goes through killProcessTree (SIGKILL to the - * whole group) - without that, bun/git descendants would keep mutating the build worktree - * after the direct child died. - */ -export async function defaultRun( - command: string, - args: string[], - options: OmoLocalRunOptions, -): Promise { - const child = spawnProcess(command, args, { - cwd: options.cwd, - stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env, ...options.env }, - detached: process.platform !== "win32", - windowsHide: true, - }); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (chunk: Buffer) => { - stdout += chunk.toString(); - }); - child.stderr.on("data", (chunk: Buffer) => { - stderr += chunk.toString(); - }); - const abort = new AbortController(); - let timedOut = false; - const pid = child.pid; - const timer = - options.timeoutMs === undefined - ? undefined - : setTimeout(() => { - timedOut = true; - if (pid !== undefined) { - killProcessTree(pid); - } - abort.abort(); - }, options.timeoutMs); - try { - const code = await waitForChildProcess(child, { signal: abort.signal }); - return { code, stdout, stderr, timedOut }; - } finally { - if (timer !== undefined) { - clearTimeout(timer); - } - } -} - -/** exported for tests only */ -export function isKillSwitched(env: Record): boolean { - return env.SENPI_OMO_LOCAL_UPDATE === "0"; -} - -/** A detected locally-installed OMO plugin and its enclosing omo monorepo checkout. */ -export interface OmoLocalInstall { - pluginPath: string; - repoRoot: string; -} - -/** exported for tests only */ -export interface DetectOmoLocalInstallOptions { - packages: PackageSource[] | undefined; - agentDir: string; - run: OmoLocalRun; - readJson?: (path: string) => unknown; - exists?: (path: string) => boolean; -} - -function defaultReadJson(path: string): unknown { - try { - return JSON.parse(readFileSync(path, "utf-8")); - } catch { - return undefined; - } -} - -function packageNameOf(json: unknown): string | undefined { - if (typeof json !== "object" || json === null) { - return undefined; - } - const name = (json as { name?: unknown }).name; - return typeof name === "string" ? name : undefined; -} - -/** Same semantics as package-manager.ts's private getHomeDir (HOME env wins over os.homedir). */ -function homeDir(): string { - return process.env.HOME || homedir(); -} - -/** - * exported for tests only - * - * Three-part detection gate (ALL must hold, else silent no-op): - * 1. A global settings `packages` entry (string or object form) is a local path whose resolved - * dir's package.json name is `@code-yeongyu/omo-senpi`. - * 2. The derived repo root (pluginPath/../../..) contains both workspace packages - * `@oh-my-opencode/omo-senpi` and `@oh-my-opencode/senpi-task`. - * 3. `git -C rev-parse --show-toplevel` succeeds AND its canonicalized output equals - * the canonicalized repo root - a mismatched toplevel would mean the derived root is not - * the checkout it claims to be. - */ -export async function detectOmoLocalInstall( - options: DetectOmoLocalInstallOptions, -): Promise { - const readJson = options.readJson ?? defaultReadJson; - const exists = options.exists ?? existsSync; - if (!options.packages) { - return undefined; - } - for (const entry of options.packages) { - const source = typeof entry === "string" ? entry : entry.source; - if (!isLocalPath(source)) { - continue; - } - const pluginPath = resolvePath(source, options.agentDir, { homeDir: homeDir(), trim: true }); - if (packageNameOf(readJson(join(pluginPath, "package.json"))) !== "@code-yeongyu/omo-senpi") { - continue; - } - const repoRoot = resolve(pluginPath, "..", "..", ".."); - const omoSenpiPkgPath = join(repoRoot, "packages", "omo-senpi", "package.json"); - const senpiTaskPkgPath = join(repoRoot, "packages", "senpi-task", "package.json"); - if (!exists(omoSenpiPkgPath) || packageNameOf(readJson(omoSenpiPkgPath)) !== "@oh-my-opencode/omo-senpi") { - continue; - } - if (!exists(senpiTaskPkgPath) || packageNameOf(readJson(senpiTaskPkgPath)) !== "@oh-my-opencode/senpi-task") { - continue; - } - let topLevel: string; - try { - const result = await options.run("git", ["-C", repoRoot, "rev-parse", "--show-toplevel"], {}); - if (result.code !== 0) { - continue; - } - topLevel = result.stdout.trim(); - } catch { - continue; - } - if (topLevel === "" || canonicalizePath(topLevel) !== canonicalizePath(repoRoot)) { - continue; - } - return { pluginPath, repoRoot }; - } - return undefined; -} - -/** State stamp written after a successful install: `/omo-local-update-state.json`. */ -export interface OmoLocalUpdateStamp { - repoRoot: string; - /** Frozen origin/dev sha the installed plugin was built from. */ - sha: string; - /** `git rev-parse origin/dev:packages/omo-senpi` at install time. */ - omoSenpiTree: string; - /** `git rev-parse origin/dev:packages/senpi-task` at install time. */ - senpiTaskTree: string; - /** Build-input fingerprint of origin/dev at install time (see omo-local-update-fingerprint.ts). */ - buildInputsHash: string; - installedAt: string; - /** Post-install inventory: relative paths of every installed artifact present at stamp time. */ - artifacts: string[]; -} - -/** exported for tests only */ -export function omoLocalUpdateStampPath(agentDir: string): string { - return join(agentDir, "omo-local-update-state.json"); -} - -/** exported for tests only */ -export function readStamp(agentDir: string): OmoLocalUpdateStamp | undefined { - const json = defaultReadJson(omoLocalUpdateStampPath(agentDir)); - if (typeof json !== "object" || json === null) { - return undefined; - } - const candidate = json as { - repoRoot?: unknown; - sha?: unknown; - omoSenpiTree?: unknown; - senpiTaskTree?: unknown; - buildInputsHash?: unknown; - installedAt?: unknown; - artifacts?: unknown; - }; - if ( - typeof candidate.repoRoot !== "string" || - typeof candidate.sha !== "string" || - typeof candidate.omoSenpiTree !== "string" || - typeof candidate.senpiTaskTree !== "string" || - typeof candidate.buildInputsHash !== "string" || - typeof candidate.installedAt !== "string" || - !Array.isArray(candidate.artifacts) - ) { - return undefined; - } - const artifacts: string[] = []; - for (const artifact of candidate.artifacts as unknown[]) { - if (typeof artifact !== "string") { - return undefined; - } - artifacts.push(artifact); - } - return { - repoRoot: candidate.repoRoot, - sha: candidate.sha, - omoSenpiTree: candidate.omoSenpiTree, - senpiTaskTree: candidate.senpiTaskTree, - buildInputsHash: candidate.buildInputsHash, - installedAt: candidate.installedAt, - artifacts, - }; -} - -/** exported for tests only */ -export function writeStamp(agentDir: string, stamp: OmoLocalUpdateStamp): void { - mkdirSync(agentDir, { recursive: true }); - writeFileSync(omoLocalUpdateStampPath(agentDir), `${JSON.stringify(stamp, null, 2)}\n`, "utf-8"); -} - -/** exported for tests only */ -export interface ShouldSkipUpdateOptions { - stamp: OmoLocalUpdateStamp | undefined; - repoRoot: string; - remoteSha: string; - remoteBuildInputsHash: string; - /** True only when every path recorded in stamp.artifacts still exists on disk. */ - stampArtifactsExist: boolean; - force: boolean; -} - -/** - * exported for tests only - * - * Skip decision (taken BEFORE any worktree op, install, or write): skip only when the stamp - * belongs to this repo root, matches the frozen remote by sha OR by build-input fingerprint - * (a moved sha whose build inputs are untouched cannot change the built plugin), recorded a - * non-empty artifact inventory, and every inventoried path still exists. A repoRoot mismatch - * always updates; an empty/absent inventory never skips; force always updates. - */ -export function shouldSkipUpdate(options: ShouldSkipUpdateOptions): boolean { - if (options.force) { - return false; - } - const stamp = options.stamp; - if (!stamp) { - return false; - } - if (stamp.repoRoot !== options.repoRoot) { - return false; - } - if (stamp.sha !== options.remoteSha && stamp.buildInputsHash !== options.remoteBuildInputsHash) { - return false; - } - if (stamp.artifacts.length === 0) { - return false; - } - return options.stampArtifactsExist; -} - -/** - * exported for tests only - * - * Failure of one orchestrator-owned step (fetch/worktree/install/build/artifacts/swap/stamp). - * The stage feeds `OMO local plugin update failed (): ...`; `output` (combined - * stdout+stderr of the failed step) is echoed dim, last 40 lines. - */ -export class OmoLocalStepError extends Error { - readonly stage: string; - readonly output: string | undefined; - constructor(stage: string, message: string, output?: string) { - super(message); - this.name = "OmoLocalStepError"; - this.stage = stage; - this.output = output; - } -} - -function firstErrorLine(result: OmoLocalRunResult): string { - for (const text of [result.stderr, result.stdout]) { - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (trimmed !== "") { - return trimmed; - } - } - } - return "unknown error"; -} - -/** The frozen state of origin/dev: commit sha, subject, and both package tree hashes. */ -export interface OmoRemoteState { - sha: string; - subject: string; - omoSenpiTree: string; - senpiTaskTree: string; - buildInputsHash: string; -} - -/** exported for tests only */ -export interface ComputeRemoteStateOptions { - repoRoot: string; - run: OmoLocalRun; -} - -/** - * exported for tests only - * - * Inspect the two packages' state ON ORIGIN/DEV: ONE `git fetch origin dev` (120s, read-only - * - the ONLY network/ref operation in the module), then rev-parse/log reads against the - * frozen `origin/dev` ref. A fetch failure throws an OmoLocalStepError with stage "fetch" - * before anything else is touched. - */ -export async function computeRemoteState(options: ComputeRemoteStateOptions): Promise { - const { repoRoot, run } = options; - const git = (args: string[], timeoutMs?: number) => run("git", args, { cwd: repoRoot, timeoutMs }); - const requireOk = async (args: string[], timeoutMs?: number): Promise => { - const result = await git(args, timeoutMs); - if (result.timedOut) { - throw new OmoLocalStepError("fetch", `git ${args.join(" ")} timed out`); - } - if (result.code !== 0) { - throw new OmoLocalStepError( - "fetch", - `git ${args.join(" ")}: ${firstErrorLine(result)}`, - `${result.stdout}${result.stderr}`, - ); - } - return result.stdout.trim(); - }; - await requireOk(["fetch", "origin", "dev"], 120_000); - const sha = await requireOk(["rev-parse", "origin/dev"]); - if (sha === "") { - throw new OmoLocalStepError("fetch", "git rev-parse origin/dev: empty output"); - } - const subject = await requireOk(["log", "-1", "--format=%s", "origin/dev"]); - const omoSenpiTree = await requireOk(["rev-parse", "origin/dev:packages/omo-senpi"]); - const senpiTaskTree = await requireOk(["rev-parse", "origin/dev:packages/senpi-task"]); - const buildInputsHash = computeBuildInputsHashFromLsTree(await requireOk(["ls-tree", "-z", "origin/dev"])); - return { sha, subject, omoSenpiTree, senpiTaskTree, buildInputsHash }; -} - -/** exported for tests only */ -export function omoLocalUpdateLockPath(agentDir: string): string { - return join(agentDir, "omo-local-update.lock"); -} - -/** exported for tests only */ -export function omoLocalUpdateBuildWorktreePath(agentDir: string): string { - return join(agentDir, "omo-local-update", "build-worktree"); -} - -interface OmoLocalLock { - path: string; - pid: number; - nonce: string; -} - -function readLockFile(path: string): { pid: number; nonce: string } | undefined { - try { - const json = JSON.parse(readFileSync(path, "utf-8")) as { pid?: unknown; nonce?: unknown }; - if (typeof json.pid !== "number" || typeof json.nonce !== "string") { - return undefined; - } - return { pid: json.pid, nonce: json.nonce }; - } catch { - return undefined; - } -} - -function pidIsAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - // EPERM: the process exists but belongs to another user. - return (error as NodeJS.ErrnoException).code === "EPERM"; - } -} - -/** - * Atomic lock acquisition (concurrency guard): `wx` create writing {pid, nonce, startedAt}. - * On EEXIST: a LIVE pid wins unconditionally - dim line, NEVER take over regardless of age. - * A dead pid (or an unreadable lock) is unlinked and the `wx` create retried ONCE; a - * concurrent winner's fresh lock then loses us the retry, which is correct. Synchronous - * throughout, so in-process contenders cannot interleave mid-check. - */ -function acquireOmoLocalLock(agentDir: string, log: (message: string) => void): OmoLocalLock | undefined { - mkdirSync(agentDir, { recursive: true }); - const path = omoLocalUpdateLockPath(agentDir); - const lock: OmoLocalLock = { path, pid: process.pid, nonce: randomUUID() }; - const payload = JSON.stringify({ pid: lock.pid, nonce: lock.nonce, startedAt: new Date().toISOString() }); - const tryCreate = (): boolean => { - try { - writeFileSync(path, payload, { flag: "wx" }); - return true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "EEXIST") { - return false; - } - throw error; - } - }; - const reportBusy = (): undefined => { - const existing = readLockFile(path); - log(chalk.dim(`OMO local plugin update already running (pid ${existing?.pid ?? "unknown"}); skipping.`)); - return undefined; - }; - if (tryCreate()) { - return lock; - } - const existing = readLockFile(path); - if (existing !== undefined && pidIsAlive(existing.pid)) { - return reportBusy(); - } - try { - rmSync(path); - } catch { - // Another process reclaimed first; the single retry below loses correctly. - } - if (tryCreate()) { - return lock; - } - return reportBusy(); -} - -/** Owner-checked unlink: re-read and match our own pid+nonce before deleting. */ -function releaseOmoLocalLock(lock: OmoLocalLock): void { - try { - const existing = readLockFile(lock.path); - if (existing !== undefined && existing.pid === lock.pid && existing.nonce === lock.nonce) { - rmSync(lock.path); - } - } catch { - // Lock release must never throw. - } -} - -/** Marker error: the bun binary is missing from PATH (spawn ENOENT). */ -class OmoLocalBunMissingError extends Error { - constructor() { - super("bun not found on PATH"); - this.name = "OmoLocalBunMissingError"; - } -} - -/** - * One bun step through the shared run seam, run INSIDE the build worktree (never the user's - * checkout). A missing binary (spawn ENOENT) gets the dedicated marker so the orchestrator - * can render the single yellow bun warning; any other failed start, non-zero exit, or - * timeout becomes an OmoLocalStepError carrying the combined output for the dim dump. - */ -async function runBunStep( - stage: "install" | "build", - args: string[], - worktree: string, - run: OmoLocalRun, - timeoutMs: number, -): Promise { - let result: OmoLocalRunResult; - try { - result = await run("bun", args, { cwd: worktree, timeoutMs }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - throw new OmoLocalBunMissingError(); - } - throw new OmoLocalStepError( - stage, - `bun ${args.join(" ")} failed to start: ${error instanceof Error ? error.message : String(error)}`, - ); - } - const output = `${result.stdout}${result.stderr}`; - if (result.timedOut) { - throw new OmoLocalStepError( - stage, - `bun ${args.join(" ")} timed out after ${Math.round(timeoutMs / 1000)}s`, - output, - ); - } - if (result.code !== 0) { - throw new OmoLocalStepError(stage, `bun ${args.join(" ")} exited with code ${result.code ?? "unknown"}`, output); - } -} - -/** exported for tests only */ -export interface EnsureBuildWorktreeOptions { - /** Feature-owned persistent worktree path (inside agentDir). */ - worktree: string; - repoRoot: string; - /** Frozen origin/dev sha to check out detached. */ - sha: string; - run: OmoLocalRun; -} - -/** - * exported for tests only - * - * Validate/reuse the FEATURE-OWNED persistent build worktree: - * - present AND owned by this repo (`git -C rev-parse --git-common-dir` realpath-equals - * realpath(repoRoot/.git)) -> reuse: `git -C checkout --detach --force `. The - * --force is SAFE here: this worktree is exclusively feature-owned, so it only discards - * our own previous build outputs, and untracked node_modules survives for incremental - * installs. - * - present but foreign/invalid -> `git -C repoRoot worktree remove --force ` (fs rm - * fallback), then re-add. - * - absent -> `git -C repoRoot worktree add --detach `. - * These are the ONLY git mutations in the module besides `git fetch`, and they touch only - * the feature-owned worktree registration - never the user's checkout state. - */ -export async function ensureBuildWorktree(options: EnsureBuildWorktreeOptions): Promise { - const { worktree, repoRoot, sha, run } = options; - if (existsSync(worktree)) { - const commonDir = await run("git", ["-C", worktree, "rev-parse", "--git-common-dir"], {}); - const owned = - commonDir.code === 0 && - commonDir.stdout.trim() !== "" && - canonicalizePath(resolve(worktree, commonDir.stdout.trim())) === canonicalizePath(join(repoRoot, ".git")); - if (owned) { - const checkout = await run("git", ["-C", worktree, "checkout", "--detach", "--force", sha], {}); - if (checkout.code !== 0) { - throw new OmoLocalStepError( - "worktree", - `git checkout --detach --force: ${firstErrorLine(checkout)}`, - `${checkout.stdout}${checkout.stderr}`, - ); - } - return; - } - // Foreign or invalid directory at the feature-owned path: remove it (registered - // worktree or not) and fall through to a fresh add. - const remove = await run("git", ["-C", repoRoot, "worktree", "remove", "--force", worktree], {}); - if (remove.code !== 0 || existsSync(worktree)) { - rmSync(worktree, { recursive: true, force: true }); - } - } - mkdirSync(dirname(worktree), { recursive: true }); - const add = await run("git", ["-C", repoRoot, "worktree", "add", "--detach", worktree, sha], {}); - if (add.code === 0) { - return; - } - // A stale registration whose directory vanished blocks the add; prune metadata and retry once. - await run("git", ["-C", repoRoot, "worktree", "prune"], {}); - const retry = await run("git", ["-C", repoRoot, "worktree", "add", "--detach", worktree, sha], {}); - if (retry.code !== 0) { - throw new OmoLocalStepError( - "worktree", - `git worktree add: ${firstErrorLine(retry)}`, - `${add.stdout}${add.stderr}${retry.stdout}${retry.stderr}`, - ); - } -} - -/** exported for tests only: injectable fs seam for the atomic swap. */ -export interface OmoLocalFsSeam { - cpSync: (source: string, destination: string) => void; - renameSync: (oldPath: string, newPath: string) => void; - rmSync: (path: string) => void; -} - -const defaultFsSeam: OmoLocalFsSeam = { - cpSync: (source, destination) => { - cpSync(source, destination, { recursive: true }); - }, - renameSync: (oldPath, newPath) => { - renameSync(oldPath, newPath); - }, - rmSync: (path) => { - rmSync(path, { recursive: true, force: true }); - }, -}; - -/** exported for tests only */ -export interface SwapPluginDirOptions { - pluginPath: string; - /** The freshly built plugin dir inside the build worktree. */ - sourceDir: string; - fs?: OmoLocalFsSeam; -} - -let swapCounter = 0; - -/** - * exported for tests only - * - * ATOMIC SWAP of the install target. staging/prev are siblings of pluginPath (same parent - * => same filesystem => atomic renames): copy the built tree to staging, move pluginPath - * aside to prev, move staging into place. If the staging rename fails, prev is restored - * and staging removed before the error propagates - the local install is never left - * half-swapped. On success prev is removed. - */ -export function swapPluginDir(options: SwapPluginDirOptions): void { - const seam = options.fs ?? defaultFsSeam; - swapCounter += 1; - const tag = `${Date.now()}-${process.pid}-${swapCounter}`; - const staging = `${options.pluginPath}.staging-${tag}`; - const prev = `${options.pluginPath}.prev-${tag}`; - seam.cpSync(options.sourceDir, staging); - seam.renameSync(options.pluginPath, prev); - try { - seam.renameSync(staging, options.pluginPath); - } catch (error) { - seam.renameSync(prev, options.pluginPath); - seam.rmSync(staging); - throw error; - } - seam.rmSync(prev); -} - -function firstLine(text: string): string { - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (trimmed !== "") { - return trimmed; - } - } - return "unknown error"; -} - -function lastNonEmptyLines(text: string, count: number): string[] { - const lines: string[] = []; - for (const line of text.split("\n")) { - if (line.trim() !== "") { - lines.push(line); - } - } - return lines.slice(-count); -} - -export interface RunOmoLocalUpdateBetaOptions { - env: Record; - agentDir: string; - settings?: { packages?: PackageSource[] }; - force?: boolean; - log?: (message: string) => void; - run?: OmoLocalRun; - /** "build" (default) runs the full update inline; "dispatch" hands a needed rebuild to a detached worker. */ - mode?: "build" | "dispatch"; - /** exported-for-tests seam over the detached worker spawn used by "dispatch". */ - spawnWorker?: OmoLocalSpawnWorker; -} - -/** - * Beta hook entry point, called from bare `senpi update` before the self-update. - * - * SINGLE owner of the ORDERED state machine: gate chain (kill-switch -> detection) -> - * atomic lock acquisition (held through fetch, worktree, build, swap, stamp AND notify; - * owner-checked unlink in `finally`) -> computeRemoteState (ONE read-only fetch + frozen - * rev-parse reads of origin/dev) -> skip decision BEFORE any worktree op, install, or - * write (skip on matching sha OR matching build-input fingerprint) -> in "dispatch" mode - * (bare `senpi update` foreground, unless SENPI_OMO_LOCAL_UPDATE_SYNC=1) a needed rebuild - * is handed to a detached worker spawn (omo-local-update-worker.ts) and the hook returns -> - * otherwise ("build": the default and the worker path) ensureBuildWorktree - * (feature-owned persistent worktree, reuse-or-recreate) -> - * `bun install` (600s, cwd = build worktree) -> `bun run build:senpi-plugin` (900s, cwd = - * build worktree) -> completeness check in the WORKTREE plugin dir -> atomic swap of the - * install target -> writeStamp (inventory from the NEW plugin dir) -> notify (green line - * + dim per-package tree comparison when an old stamp existed). - * - * HARD GUARANTEES: the user's checkout receives ZERO git mutations (no - * checkout/branch/commit/merge/reset/clean/stash/push anywhere; only `git fetch` and - * feature-owned worktree add/remove). Only pluginPath content is replaced. This hook NEVER - * throws and NEVER sets process.exitCode: every failure downgrades to a yellow warning + - * dim manual hint so the senpi self-update continues untouched. - */ -export async function runOmoLocalUpdateBeta(options: RunOmoLocalUpdateBetaOptions): Promise { - const log = - options.log ?? - ((message: string) => { - console.log(message); - }); - const run = options.run ?? defaultRun; - const dispatchRequested = - (options.mode ?? "build") === "dispatch" && options.env.SENPI_OMO_LOCAL_UPDATE_SYNC !== "1"; - let repoRoot: string | undefined; - try { - if (isKillSwitched(options.env)) { - return; - } - const install = await detectOmoLocalInstall({ - packages: options.settings?.packages, - agentDir: options.agentDir, - run, - }); - if (!install) { - return; - } - repoRoot = install.repoRoot; - const { pluginPath } = install; - - const lock = acquireOmoLocalLock(options.agentDir, log); - if (lock === undefined) { - return; - } - try { - log(chalk.dim("Updating OMO local plugins: fetching origin/dev...")); - const remoteState = await computeRemoteState({ repoRoot, run }); - - // Skip decision FIRST: a skip touches NOTHING (no worktree ops, no install, no writes). - const stamp = readStamp(options.agentDir); - const stampArtifactsExist = - (stamp?.artifacts.every((artifact) => existsSync(join(pluginPath, artifact))) ?? false) && - currentRequiredBuildArtifactsExist(pluginPath); - if ( - shouldSkipUpdate({ - stamp, - repoRoot, - remoteSha: remoteState.sha, - remoteBuildInputsHash: remoteState.buildInputsHash, - stampArtifactsExist, - force: options.force ?? false, - }) - ) { - const shortSkip = remoteState.sha.slice(0, 7); - log( - stamp?.sha === remoteState.sha - ? chalk.dim(`OMO local plugins already at origin/dev @${shortSkip}; skipping rebuild.`) - : chalk.dim( - `OMO local plugins already match origin/dev @${shortSkip} (build inputs unchanged); skipping rebuild.`, - ), - ); - return; - } - - // Dispatch: hand the rebuild to a detached worker so the foreground never blocks on - // the 30s+ install/build. The lock is released BEFORE the spawn (release is - // owner-checked, so the finally re-release no-ops) or the worker's own lock - // acquisition would race the still-live foreground pid and give up. - if (dispatchRequested) { - releaseOmoLocalLock(lock); - const spawnWorker = options.spawnWorker ?? defaultSpawnWorker; - const spawned = spawnWorker({ agentDir: options.agentDir, force: options.force ?? false }); - if (spawned.ok) { - log( - chalk.dim( - `Updating OMO local plugins in background: origin/dev @${remoteState.sha.slice(0, 7)} - ${remoteState.subject} (log: ${spawned.logPath})`, - ), - ); - } else { - log(chalk.yellow(`OMO local plugin background update could not start: ${spawned.message}`)); - log( - chalk.dim( - "Run `senpi update` again, or set SENPI_OMO_LOCAL_UPDATE_SYNC=1 to update in the foreground.", - ), - ); - } - return; - } - - // Build in the feature-owned persistent worktree - never in the user's checkout. - const worktree = omoLocalUpdateBuildWorktreePath(options.agentDir); - await ensureBuildWorktree({ worktree, repoRoot, sha: remoteState.sha, run }); - try { - log(chalk.dim("Updating OMO local plugins: installing deps...")); - await runBunStep("install", ["install"], worktree, run, 600_000); - log(chalk.dim("Updating OMO local plugins: building plugin...")); - await runBunStep("build", ["run", "build:senpi-plugin"], worktree, run, 900_000); - } catch (stepError) { - if (stepError instanceof OmoLocalBunMissingError) { - log( - chalk.yellow( - "OMO local plugin update skipped: bun is required to install and build the plugin but was not found on PATH. Install bun and re-run `senpi update`.", - ), - ); - return; - } - throw stepError; - } - const worktreePluginDir = join(worktree, relative(repoRoot, pluginPath)); - const missing = findMissingBuildArtifacts(worktreePluginDir); - if (missing.length > 0) { - throw new OmoLocalStepError("artifacts", `build incomplete - missing: ${missing.join(", ")}`); - } - - // Atomic swap of the install target, then stamp from the NEW plugin dir inventory. - try { - swapPluginDir({ pluginPath, sourceDir: worktreePluginDir }); - } catch (swapError) { - throw new OmoLocalStepError( - "swap", - `atomic swap failed: ${swapError instanceof Error ? swapError.message : String(swapError)}`, - ); - } - writeStamp(options.agentDir, { - repoRoot, - sha: remoteState.sha, - omoSenpiTree: remoteState.omoSenpiTree, - senpiTaskTree: remoteState.senpiTaskTree, - buildInputsHash: remoteState.buildInputsHash, - installedAt: new Date().toISOString(), - artifacts: collectArtifactInventory(pluginPath), - }); - - const short = remoteState.sha.slice(0, 7); - log( - chalk.green( - `Updated OMO local plugins (omo-senpi + senpi-task) to origin/dev @${short} - ${remoteState.subject}`, - ), - ); - if (stamp !== undefined) { - const omoLine = stamp.omoSenpiTree === remoteState.omoSenpiTree ? "unchanged" : "updated"; - const taskLine = stamp.senpiTaskTree === remoteState.senpiTaskTree ? "unchanged" : "updated"; - log(chalk.dim(`omo-senpi: ${omoLine}, senpi-task: ${taskLine}`)); - } - } finally { - releaseOmoLocalLock(lock); - } - } catch (error) { - // The hook NEVER throws and NEVER sets process.exitCode: render the yellow failure - // line (+ dim failed-step output tail + dim manual hint) and let the senpi - // self-update continue. - const stage = error instanceof OmoLocalStepError ? error.stage : "unknown"; - const message = error instanceof Error ? error.message : String(error); - log(chalk.yellow(`OMO local plugin update failed (${stage}): ${firstLine(message)}`)); - if (error instanceof OmoLocalStepError && error.output !== undefined) { - for (const line of lastNonEmptyLines(error.output, 40)) { - log(chalk.dim(line)); - } - } - if (repoRoot !== undefined) { - log( - chalk.dim( - `To update manually: git -C ${repoRoot} pull origin dev && bun install && bun run build:senpi-plugin`, - ), - ); - } - } -} diff --git a/packages/coding-agent/src/changes.md b/packages/coding-agent/src/changes.md index 4517d6267b..4947b08014 100644 --- a/packages/coding-agent/src/changes.md +++ b/packages/coding-agent/src/changes.md @@ -1,3 +1,20 @@ +## OMO product update behavior removed from the Senpi engine (2026-08-10) + +### What changed + +- Deleted the beta OMO local-plugin updater, its hidden worker option, state machinery, and dedicated tests. +- Bare `senpi update` now follows only the generic self/package/model update paths. +- Added a production-source boundary regression that rejects OMO package-layout and updater knowledge in Senpi. + +### Why this belongs outside the engine + +- OMO Native owns OMO packaging and update behavior. Senpi exposes generic package and branded-update contracts without knowing a downstream product's repository, package names, or build layout. + +### Expected merge conflict zones + +- HIGH: `package-manager-cli.ts` and the deleted `src/beta/omo-local-update*` files. +- MEDIUM: updater-specific QA sandbox variables and package-command parser coverage. + ## Public filesystem policy exports (2026-08-09) ### What changed diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index d0d060f06e..a2efd8a93d 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -1,8 +1,6 @@ import { join } from "node:path"; import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui"; import chalk from "chalk"; -// BETA(omo-local-update): removable beta import - delete with src/beta/omo-local-update.ts -import { runOmoLocalUpdateBeta } from "./beta/omo-local-update.ts"; import { selectConfig } from "./cli/config-selector.ts"; import { createProjectTrustContext } from "./cli/project-trust.ts"; import { @@ -60,8 +58,6 @@ interface PackageCommandOptions { source?: string; updateTarget?: UpdateTarget; showExtensionsSkippedNote: boolean; - // BETA(omo-local-update): hidden internal flag; remove with src/beta/omo-local-update*.ts. - omoLocalUpdateWorker: boolean; local: boolean; force: boolean; projectTrustOverride?: boolean; @@ -206,7 +202,6 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined let local = false; let force = false; - let omoLocalUpdateWorker = false; let projectTrustOverride: boolean | undefined; let help = false; let invalidOption: string | undefined; @@ -291,15 +286,6 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined continue; } - if (arg === "--omo-local-update-worker") { - if (command === "update") { - omoLocalUpdateWorker = true; - } else { - invalidOption = invalidOption ?? arg; - } - continue; - } - if (arg === "--extension") { if (command !== "update") { invalidOption = invalidOption ?? arg; @@ -391,7 +377,6 @@ function parsePackageCommand(args: string[]): PackageCommandOptions | undefined source, updateTarget, showExtensionsSkippedNote, - omoLocalUpdateWorker, local, force, projectTrustOverride, @@ -846,26 +831,6 @@ export async function handlePackageCommand( case "update": { const target = options.updateTarget ?? { type: "self" }; - // BETA(omo-local-update): remove this block, src/beta/omo-local-update*.ts and all test/omo-local-update* files to drop the feature. - if (options.omoLocalUpdateWorker) { - await runOmoLocalUpdateBeta({ - env: process.env, - agentDir, - settings: settingsManager.getGlobalSettings(), - force: options.force, - mode: "build", - }); - return true; - } - if (options.showExtensionsSkippedNote) { - await runOmoLocalUpdateBeta({ - env: process.env, - agentDir, - settings: settingsManager.getGlobalSettings(), - force: options.force, - mode: "dispatch", - }); - } if (options.showExtensionsSkippedNote) { console.log( chalk.dim(`Extensions are skipped. Run ${APP_NAME} update --extensions to update extensions.`), diff --git a/packages/coding-agent/test/omo-local-update-artifact-repair.test.ts b/packages/coding-agent/test/omo-local-update-artifact-repair.test.ts deleted file mode 100644 index 157b9ad6c8..0000000000 --- a/packages/coding-agent/test/omo-local-update-artifact-repair.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { existsSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { afterAll, describe, expect, it } from "vitest"; -import { readStamp, runOmoLocalUpdateBeta, writeStamp } from "../src/beta/omo-local-update.ts"; -import { createOmoFixture } from "./omo-local-update-fixture.ts"; -import { - applyOmoGitIsolation, - createTempRoots, - installFakeBun, - makeAgentDir, - makeLogCollector, - makeSpyRun, - withPrependedPath, -} from "./omo-local-update-helpers.ts"; - -const gitIsolation = applyOmoGitIsolation(); -const tempRoots = createTempRoots(); - -afterAll(() => { - tempRoots.cleanup(); - gitIsolation.cleanup(); -}); - -describe("OMO local update artifact repair", () => { - it("rebuilds when a legacy matching stamp omits the missing packaged LSP CLI", { timeout: 90000 }, async () => { - // given - const root = tempRoots.makeTempRoot(); - const fixture = createOmoFixture(root); - const agentDir = makeAgentDir(root); - const binDir = join(root, "bin"); - installFakeBun(binDir); - const restorePath = withPrependedPath(binDir); - const { calls, run } = makeSpyRun(); - const options = { env: {}, agentDir, settings: { packages: [fixture.pluginPath] }, run }; - const lspCli = join(fixture.pluginPath, "runtime", "lsp-daemon", "dist", "cli.js"); - - try { - await runOmoLocalUpdateBeta(options); - const currentStamp = readStamp(agentDir); - expect(currentStamp).toBeDefined(); - if (currentStamp === undefined) { - throw new Error("expected the initial update to write a stamp"); - } - expect(existsSync(lspCli)).toBe(true); - writeStamp(agentDir, { - ...currentStamp, - artifacts: currentStamp.artifacts.filter((artifact) => artifact !== "runtime/lsp-daemon/dist/cli.js"), - }); - rmSync(lspCli); - calls.length = 0; - const repair = makeLogCollector(); - - // when - await runOmoLocalUpdateBeta({ ...options, log: repair.log }); - - // then - expect(repair.lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(true); - expect(calls).toContainEqual(["bun", "run", "build:senpi-plugin"]); - expect(existsSync(lspCli)).toBe(true); - } finally { - restorePath(); - } - }); -}); diff --git a/packages/coding-agent/test/omo-local-update-dispatch.test.ts b/packages/coding-agent/test/omo-local-update-dispatch.test.ts deleted file mode 100644 index 234cef16af..0000000000 --- a/packages/coding-agent/test/omo-local-update-dispatch.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -// BETA(omo-local-update): background-dispatch coverage - delete with -// src/beta/omo-local-update*.ts and the other test/omo-local-update* files. - -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { afterAll, describe, expect, it } from "vitest"; -import { omoLocalUpdateLockPath, readStamp, runOmoLocalUpdateBeta } from "../src/beta/omo-local-update.ts"; -import type { - OmoLocalSpawnWorker, - OmoLocalWorkerSpawnOutcome, - OmoLocalWorkerSpawnRequest, -} from "../src/beta/omo-local-update-worker.ts"; -import { createOmoFixture, type OmoFixture } from "./omo-local-update-fixture.ts"; -import { - applyOmoGitIsolation, - createTempRoots, - installFakeBun, - makeAgentDir, - makeLogCollector, - makeSpyRun, - withPrependedPath, -} from "./omo-local-update-helpers.ts"; - -const gitIsolation = applyOmoGitIsolation(); -const tempRoots = createTempRoots(); -const makeTempRoot = tempRoots.makeTempRoot; - -afterAll(() => { - tempRoots.cleanup(); - gitIsolation.cleanup(); -}); - -function makeSpySpawn(outcome?: OmoLocalWorkerSpawnOutcome): { - requests: OmoLocalWorkerSpawnRequest[]; - spawnWorker: OmoLocalSpawnWorker; -} { - const requests: OmoLocalWorkerSpawnRequest[] = []; - return { - requests, - spawnWorker: (request) => { - requests.push(request); - return outcome ?? { ok: true, pid: 4242, logPath: "/tmp/fake-omo-worker.log" }; - }, - }; -} - -interface DispatchSetup { - fixture: OmoFixture; - agentDir: string; - restorePath: () => void; -} - -function setupDispatchFixture(root: string): DispatchSetup { - const fixture = createOmoFixture(root); - const agentDir = makeAgentDir(root); - const binDir = join(root, "bin"); - installFakeBun(binDir); - return { fixture, agentDir, restorePath: withPrependedPath(binDir) }; -} - -describe("runOmoLocalUpdateBeta dispatch mode", () => { - it("hands a needed rebuild to the worker spawn instead of building inline", { timeout: 90000 }, async () => { - const { fixture, agentDir, restorePath } = setupDispatchFixture(makeTempRoot()); - const { calls, run } = makeSpyRun(); - const { requests, spawnWorker } = makeSpySpawn(); - const { lines, log } = makeLogCollector(); - try { - await runOmoLocalUpdateBeta({ - env: {}, - agentDir, - settings: { packages: [fixture.pluginPath] }, - mode: "dispatch", - spawnWorker, - run, - log, - }); - } finally { - restorePath(); - } - expect(requests).toEqual([{ agentDir, force: false }]); - expect(calls.some(([command]) => command === "bun")).toBe(false); - expect(calls.some(([, ...args]) => args.includes("worktree"))).toBe(false); - expect(lines.some((line) => line.includes("in background") && line.includes("/tmp/fake-omo-worker.log"))).toBe( - true, - ); - expect(lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(false); - expect(readStamp(agentDir)).toBeUndefined(); - expect(existsSync(omoLocalUpdateLockPath(agentDir))).toBe(false); - }); - - it("skips without spawning when the stamp already matches", { timeout: 90000 }, async () => { - const { fixture, agentDir, restorePath } = setupDispatchFixture(makeTempRoot()); - const base = { env: {}, agentDir, settings: { packages: [fixture.pluginPath] } }; - const { requests, spawnWorker } = makeSpySpawn(); - const { lines, log } = makeLogCollector(); - try { - const first = makeLogCollector(); - await runOmoLocalUpdateBeta({ ...base, log: first.log }); - expect(first.lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(true); - await runOmoLocalUpdateBeta({ ...base, mode: "dispatch", spawnWorker, log }); - } finally { - restorePath(); - } - expect(requests).toEqual([]); - expect(lines.some((line) => line.includes("skipping rebuild"))).toBe(true); - }); - - it("propagates force to the worker even when the stamp matches", { timeout: 90000 }, async () => { - const { fixture, agentDir, restorePath } = setupDispatchFixture(makeTempRoot()); - const base = { env: {}, agentDir, settings: { packages: [fixture.pluginPath] } }; - const { requests, spawnWorker } = makeSpySpawn(); - try { - const first = makeLogCollector(); - await runOmoLocalUpdateBeta({ ...base, log: first.log }); - const second = makeLogCollector(); - await runOmoLocalUpdateBeta({ ...base, mode: "dispatch", force: true, spawnWorker, log: second.log }); - } finally { - restorePath(); - } - expect(requests).toEqual([{ agentDir, force: true }]); - }); - - it("degrades to a yellow warning when the worker spawn fails", { timeout: 90000 }, async () => { - const { fixture, agentDir, restorePath } = setupDispatchFixture(makeTempRoot()); - const { requests, spawnWorker } = makeSpySpawn({ ok: false, message: "spawn boom" }); - const { lines, log } = makeLogCollector(); - try { - await expect( - runOmoLocalUpdateBeta({ - env: {}, - agentDir, - settings: { packages: [fixture.pluginPath] }, - mode: "dispatch", - spawnWorker, - log, - }), - ).resolves.toBeUndefined(); - } finally { - restorePath(); - } - expect(requests).toHaveLength(1); - expect(lines.some((line) => line.includes("spawn boom"))).toBe(true); - expect(existsSync(omoLocalUpdateLockPath(agentDir))).toBe(false); - }); - - it("builds inline under SENPI_OMO_LOCAL_UPDATE_SYNC=1 even in dispatch mode", { timeout: 90000 }, async () => { - const { fixture, agentDir, restorePath } = setupDispatchFixture(makeTempRoot()); - const { requests, spawnWorker } = makeSpySpawn(); - const { lines, log } = makeLogCollector(); - try { - await runOmoLocalUpdateBeta({ - env: { SENPI_OMO_LOCAL_UPDATE_SYNC: "1" }, - agentDir, - settings: { packages: [fixture.pluginPath] }, - mode: "dispatch", - spawnWorker, - log, - }); - } finally { - restorePath(); - } - expect(requests).toEqual([]); - expect(lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(true); - expect(readStamp(agentDir)).toBeDefined(); - }); -}); diff --git a/packages/coding-agent/test/omo-local-update-fingerprint.test.ts b/packages/coding-agent/test/omo-local-update-fingerprint.test.ts deleted file mode 100644 index 2781a01a91..0000000000 --- a/packages/coding-agent/test/omo-local-update-fingerprint.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -// BETA(omo-local-update): build-input fingerprint coverage - delete with -// src/beta/omo-local-update*.ts and the other test/omo-local-update* files. - -import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { afterAll, describe, expect, it } from "vitest"; -import { computeRemoteState, defaultRun, runOmoLocalUpdateBeta } from "../src/beta/omo-local-update.ts"; -import { computeBuildInputsHashFromLsTree, isBuildInputRootPath } from "../src/beta/omo-local-update-fingerprint.ts"; -import { advanceOriginDev, createOmoFixture } from "./omo-local-update-fixture.ts"; -import { - applyOmoGitIsolation, - artifactMtimes, - createTempRoots, - git, - installFakeBun, - makeAgentDir, - makeLogCollector, - makeSpyRun, - withPrependedPath, -} from "./omo-local-update-helpers.ts"; - -const gitIsolation = applyOmoGitIsolation(); -const tempRoots = createTempRoots(); -const makeTempRoot = tempRoots.makeTempRoot; - -afterAll(() => { - tempRoots.cleanup(); - gitIsolation.cleanup(); -}); - -describe("build-input fingerprint", () => { - it("classifies root paths by the exclusion list", () => { - for (const excluded of ["docs", ".github", "README.md", "README.ko.md", "assets", "CHANGELOG.md", ".agents"]) { - expect(isBuildInputRootPath(excluded)).toBe(false); - } - for (const included of [ - "packages", - "script", - "scripts", - "package.json", - "bun.lock", - "postinstall.mjs", - ".gitmodules", - "unknown-dir", - ]) { - expect(isBuildInputRootPath(included)).toBe(true); - } - }); - - it("hashes only included ls-tree entries", () => { - const entry = (hash: string, name: string) => `100644 blob ${hash}\t${name}`; - const base = [entry("aaa", "package.json"), entry("bbb", "docs")].join("\0"); - const docsMoved = [entry("aaa", "package.json"), entry("ccc", "docs")].join("\0"); - const inputMoved = [entry("ddd", "package.json"), entry("bbb", "docs")].join("\0"); - expect(computeBuildInputsHashFromLsTree(docsMoved)).toBe(computeBuildInputsHashFromLsTree(base)); - expect(computeBuildInputsHashFromLsTree(inputMoved)).not.toBe(computeBuildInputsHashFromLsTree(base)); - }); - - it("keeps the remote fingerprint stable across docs-only commits and moves it for package commits", { - timeout: 30000, - }, async () => { - const fixture = createOmoFixture(makeTempRoot()); - const before = await computeRemoteState({ repoRoot: fixture.repoRoot, run: defaultRun }); - expect(before.buildInputsHash).toMatch(/^[0-9a-f]{64}$/); - - advanceOriginDev({ originDir: fixture.originDir, touch: "docs" }); - const afterDocs = await computeRemoteState({ repoRoot: fixture.repoRoot, run: defaultRun }); - expect(afterDocs.sha).not.toBe(before.sha); - expect(afterDocs.buildInputsHash).toBe(before.buildInputsHash); - - advanceOriginDev({ originDir: fixture.originDir, touch: "omo-senpi" }); - const afterPackage = await computeRemoteState({ repoRoot: fixture.repoRoot, run: defaultRun }); - expect(afterPackage.buildInputsHash).not.toBe(afterDocs.buildInputsHash); - }); -}); - -describe("build-input fingerprint skip", () => { - it("skips the rebuild when origin/dev moved without touching build inputs", { timeout: 90000 }, async () => { - const root = makeTempRoot(); - const fixture = createOmoFixture(root); - const agentDir = makeAgentDir(root); - const binDir = join(root, "bin"); - installFakeBun(binDir); - const restorePath = withPrependedPath(binDir); - const base = { env: {}, agentDir, settings: { packages: [fixture.pluginPath] } }; - try { - const first = makeLogCollector(); - await runOmoLocalUpdateBeta({ ...base, log: first.log }); - expect(first.lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(true); - const mtimesBefore = artifactMtimes(fixture.pluginPath); - - const newSha = advanceOriginDev({ originDir: fixture.originDir, touch: "docs" }); - const short = newSha.slice(0, 7); - const { calls, run } = makeSpyRun(); - const second = makeLogCollector(); - await runOmoLocalUpdateBeta({ ...base, run, log: second.log }); - - expect( - second.lines.some( - (line) => line.includes(`@${short}`) && line.includes("(build inputs unchanged); skipping rebuild"), - ), - ).toBe(true); - expect(second.lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(false); - expect(calls.some(([command]) => command === "bun")).toBe(false); - expect(calls.some(([, ...args]) => args.includes("worktree"))).toBe(false); - expect(calls.some(([, ...args]) => args.includes("checkout"))).toBe(false); - expect(artifactMtimes(fixture.pluginPath)).toEqual(mtimesBefore); - } finally { - restorePath(); - } - }); - - it("still rebuilds when a root-level file outside the exclusion list changes", { timeout: 90000 }, async () => { - const root = makeTempRoot(); - const fixture = createOmoFixture(root); - const agentDir = makeAgentDir(root); - const binDir = join(root, "bin"); - installFakeBun(binDir); - const restorePath = withPrependedPath(binDir); - const base = { env: {}, agentDir, settings: { packages: [fixture.pluginPath] } }; - try { - const first = makeLogCollector(); - await runOmoLocalUpdateBeta({ ...base, log: first.log }); - expect(first.lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(true); - - advanceOriginDev({ originDir: fixture.originDir, touch: "other" }); - const { calls, run } = makeSpyRun(); - const second = makeLogCollector(); - await runOmoLocalUpdateBeta({ ...base, run, log: second.log }); - - expect(second.lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(true); - expect(calls).toContainEqual(["bun", "install"]); - } finally { - restorePath(); - } - }); - - it("declines the skip when the stamp predates the fingerprint field", { timeout: 90000 }, async () => { - const root = makeTempRoot(); - const fixture = createOmoFixture(root); - const agentDir = makeAgentDir(root); - const binDir = join(root, "bin"); - installFakeBun(binDir); - const restorePath = withPrependedPath(binDir); - const base = { env: {}, agentDir, settings: { packages: [fixture.pluginPath] } }; - try { - const first = makeLogCollector(); - await runOmoLocalUpdateBeta({ ...base, log: first.log }); - expect(first.lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(true); - - const stampPath = join(agentDir, "omo-local-update-state.json"); - expect(existsSync(stampPath)).toBe(true); - const parsedStamp: unknown = JSON.parse(readFileSync(stampPath, "utf-8")); - if (typeof parsedStamp !== "object" || parsedStamp === null) { - throw new Error("fixture stamp must be an object"); - } - const withoutFingerprint: Record = { ...parsedStamp }; - delete withoutFingerprint.buildInputsHash; - rmSync(stampPath); - writeFileSync(stampPath, JSON.stringify(withoutFingerprint)); - - advanceOriginDev({ originDir: fixture.originDir, touch: "docs" }); - const { calls, run } = makeSpyRun(); - const second = makeLogCollector(); - await runOmoLocalUpdateBeta({ ...base, run, log: second.log }); - - expect(second.lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(true); - expect(calls).toContainEqual(["bun", "install"]); - expect(git(["rev-parse", "origin/dev"], fixture.repoRoot)).toBeTruthy(); - } finally { - restorePath(); - } - }); -}); diff --git a/packages/coding-agent/test/omo-local-update-fixture.ts b/packages/coding-agent/test/omo-local-update-fixture.ts deleted file mode 100644 index 8f960448d7..0000000000 --- a/packages/coding-agent/test/omo-local-update-fixture.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * Fake omo repo factory for omo-local-update (v2) tests and QA. - * - * Builds a fully local git fixture (bare origin + clone) under a caller-provided - * tmpDir: the clone carries a stub `build:senpi-plugin` script that writes the - * FULL artifact completeness set, the three omo package manifests with their - * real names, and seed source files - all committed on `dev` and pushed. - * - * The stub build is runnable BOTH via `bun run build:senpi-plugin` (through the - * root package.json script) and directly via `node scripts/build-senpi-plugin.mjs`, - * and writes every artifact relative to its CURRENT WORKING DIRECTORY, so the - * same committed script produces the artifact set in ANY worktree it is run from - * (the updater builds in its own persistent build worktree, never in the user's - * checkout). The stub embeds the tracked `packages/omo-senpi/build-marker.txt` - * content into `plugin/extensions/omo.js`, so a test can prove the installed - * plugin was built from a SPECIFIC origin/dev commit. - * - * Determinism contract: no network, everything under the passed tmpDir, and - * every git invocation runs with GIT_CONFIG_GLOBAL pointed at an empty file, - * GIT_CONFIG_NOSYSTEM=1, and fixed GIT_AUTHOR_* / GIT_COMMITTER_* identity - * and dates, so ambient host git config can never change an outcome. - * - * This is a non-test helper module: it stays dependency-free and must NOT - * import src/beta/omo-local-update.ts. - */ - -import { execFileSync } from "node:child_process"; -import { appendFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; - -export interface OmoFixture { - /** Working clone of the fake omo repo (checked out on dev, tracking origin/dev). */ - repoRoot: string; - /** Bare origin repository the clone pushes to and fetches from. */ - originDir: string; - /** The local-path plugin dir a settings `packages` entry would point at. */ - pluginPath: string; -} - -/** Artifact completeness set written by the stub build, relative to pluginPath. */ -export const FIXTURE_PLUGIN_ARTIFACTS = [ - "extensions/omo.js", - "runtime/lsp-daemon/dist/cli.js", - "runtime/lsp-daemon/dist/index.js", - "runtime/lsp-daemon/dist/.omo-runtime-manifest.json", - "scripts/install.mjs", - "skills/alpha/SKILL.md", - "skills/beta/SKILL.md", -] as const; - -const SOURCE_INDEX_TS = "packages/omo-senpi/src/index.ts"; -const BUILD_MARKER = "packages/omo-senpi/build-marker.txt"; -const SENPI_TASK_INDEX_TS = "packages/senpi-task/src/index.ts"; - -/** - * Cwd-relative stub build: the updater runs `bun run build:senpi-plugin` with - * cwd = the build worktree, so basing every path on process.cwd() makes the - * same committed script correct in ANY worktree. The tracked build marker is - * embedded into extensions/omo.js so tests can assert WHICH origin/dev commit - * the installed plugin content was built from. - */ -const BUILD_SCRIPT = `import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; - -const repoRoot = process.cwd(); -const pluginRoot = join(repoRoot, "packages", "omo-senpi", "plugin"); -const markerPath = join(repoRoot, "packages", "omo-senpi", "build-marker.txt"); -const marker = existsSync(markerPath) ? readFileSync(markerPath, "utf8").trim() : "none"; -const artifacts = { - "extensions/omo.js": "// omo extension bundle (fixture stub)\\n// marker: " + marker + "\\nexport {};\\n", - "runtime/lsp-daemon/dist/cli.js": "// lsp daemon cli (fixture stub)\\n", - "runtime/lsp-daemon/dist/index.js": "// lsp daemon index (fixture stub)\\n", - "runtime/lsp-daemon/dist/.omo-runtime-manifest.json": JSON.stringify({ fixture: true, schema: 1 }) + "\\n", - "scripts/install.mjs": "// install script (fixture stub)\\n", - "skills/alpha/SKILL.md": "# alpha skill (fixture stub)\\n", - "skills/beta/SKILL.md": "# beta skill (fixture stub)\\n", -}; -for (const [relativePath, content] of Object.entries(artifacts)) { - const target = join(pluginRoot, relativePath); - mkdirSync(dirname(target), { recursive: true }); - writeFileSync(target, content); -} -console.log("fixture build:senpi-plugin wrote " + Object.keys(artifacts).length + " artifacts (marker: " + marker + ")"); -`; - -function gitDirFor(anchorDir: string): string { - const dotGit = join(anchorDir, ".git"); - return existsSync(dotGit) ? dotGit : anchorDir; -} - -function ensureConfig(anchorDir: string): string { - const configPath = join(gitDirFor(anchorDir), "fixture-gitconfig"); - if (!existsSync(configPath)) writeFileSync(configPath, ""); - return configPath; -} - -function gitEnv(configPath: string): NodeJS.ProcessEnv { - return { - ...process.env, - GIT_CONFIG_GLOBAL: configPath, - GIT_CONFIG_NOSYSTEM: "1", - GIT_AUTHOR_NAME: "OMO Fixture", - GIT_AUTHOR_EMAIL: "omo-fixture@example.invalid", - GIT_AUTHOR_DATE: "2026-01-01T00:00:00Z", - GIT_COMMITTER_NAME: "OMO Fixture", - GIT_COMMITTER_EMAIL: "omo-fixture@example.invalid", - GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z", - }; -} - -/** - * Environment for git invocations against a fixture repo, honoring the - * determinism contract. `anchorDir` is a fixture repoRoot or originDir. - * Exported so tests can run their own git assertions under the same isolation. - */ -export function fixtureGitEnv(anchorDir: string): NodeJS.ProcessEnv { - return gitEnv(ensureConfig(anchorDir)); -} - -function runGit(args: string[], cwd: string, configPath: string): string { - return execFileSync("git", args, { - cwd, - env: gitEnv(configPath), - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }).trim(); -} - -function writeSeedFile(repoRoot: string, relativePath: string, content: string): void { - const target = join(repoRoot, relativePath); - mkdirSync(dirname(target), { recursive: true }); - writeFileSync(target, content); -} - -function writeSeedJson(repoRoot: string, relativePath: string, value: Record): void { - writeSeedFile(repoRoot, relativePath, `${JSON.stringify(value, null, 2)}\n`); -} - -/** - * Build the fake omo repo: bare origin + clone, stub build writing the full - * artifact completeness set (cwd-relative, runnable via bun AND node), all - * three omo package manifests, committed on `dev` and pushed, origin URL set - * to the local bare path. - */ -export function createOmoFixture(tmpDir: string): OmoFixture { - mkdirSync(tmpDir, { recursive: true }); - const originDir = join(tmpDir, "origin.git"); - const repoRoot = join(tmpDir, "repo"); - const pluginPath = join(repoRoot, "packages", "omo-senpi", "plugin"); - const configPath = join(tmpDir, "fixture-gitconfig"); - writeFileSync(configPath, ""); - - runGit(["init", "--bare", "-b", "dev", originDir], tmpDir, configPath); - runGit(["clone", originDir, repoRoot], tmpDir, configPath); - runGit(["symbolic-ref", "HEAD", "refs/heads/dev"], repoRoot, configPath); - - writeSeedJson(repoRoot, "package.json", { - name: "omo-fixture", - private: true, - version: "0.0.0", - scripts: { "build:senpi-plugin": "node scripts/build-senpi-plugin.mjs" }, - }); - writeSeedFile(repoRoot, "scripts/build-senpi-plugin.mjs", BUILD_SCRIPT); - writeSeedFile(repoRoot, ".gitignore", "node_modules/\n"); - writeSeedJson(repoRoot, "packages/omo-senpi/package.json", { - name: "@oh-my-opencode/omo-senpi", - private: true, - version: "0.0.0", - }); - writeSeedJson(repoRoot, "packages/omo-senpi/plugin/package.json", { - name: "@code-yeongyu/omo-senpi", - private: true, - version: "0.0.0", - }); - writeSeedJson(repoRoot, "packages/senpi-task/package.json", { - name: "@oh-my-opencode/senpi-task", - private: true, - version: "0.0.0", - }); - writeSeedFile(repoRoot, SOURCE_INDEX_TS, "// omo-senpi fixture source\nexport {};\n"); - writeSeedFile(repoRoot, SENPI_TASK_INDEX_TS, "// senpi-task fixture source\nexport {};\n"); - writeSeedFile(repoRoot, BUILD_MARKER, "marker-1\n"); - - // Run the stub build once so the generated artifacts are TRACKED (matching - // the real omo checkout, where plugin/extensions/omo.js etc. are committed). - execFileSync(process.execPath, [join(repoRoot, "scripts", "build-senpi-plugin.mjs")], { - cwd: repoRoot, - stdio: ["ignore", "pipe", "pipe"], - }); - - runGit(["add", "-A"], repoRoot, configPath); - runGit(["commit", "-m", "fixture: seed fake omo repo"], repoRoot, configPath); - runGit(["push", "-u", "origin", "dev"], repoRoot, configPath); - runGit(["remote", "set-url", "origin", originDir], repoRoot, configPath); - - return { repoRoot, originDir, pluginPath }; -} - -/** Modify a tracked SOURCE file (outside the plugin dir). Returns the relative path. */ -export function dirtySource(repoRoot: string): string { - appendFileSync(join(repoRoot, SOURCE_INDEX_TS), "// fixture source dirt\n"); - return SOURCE_INDEX_TS; -} - -/** Create an UNTRACKED file outside the plugin dir. Returns the relative path. */ -export function dirtyUntracked(repoRoot: string): string { - const relativePath = "packages/omo-senpi/src/local-notes.txt"; - writeSeedFile(repoRoot, relativePath, "// untracked local notes (fixture)\n"); - return relativePath; -} - -export type OmoAdvanceTouch = "omo-senpi" | "senpi-task" | "other" | "docs"; - -/** - * Advance origin/dev by committing in a second temporary clone of the bare - * origin. `touch` selects WHERE the commit changes content: - * - "omo-senpi": rewrites packages/omo-senpi/build-marker.txt (moves the - * omo-senpi tree AND the marker the stub build embeds into omo.js) - * - "senpi-task": appends to packages/senpi-task/src/index.ts (moves only - * the senpi-task tree) - * - "other": adds a root-level file (moves neither package tree) - * Returns the new origin/dev sha. The fixture clone is NOT fetched. - */ -export function advanceOriginDev(options: { originDir: string; touch: OmoAdvanceTouch }): string { - const { originDir, touch } = options; - const configPath = ensureConfig(originDir); - const n = Number(runGit(["rev-list", "--count", "dev"], originDir, configPath)) + 1; - const workDir = join(dirname(originDir), "origin-advance-work"); - rmSync(workDir, { recursive: true, force: true }); - try { - runGit(["clone", originDir, workDir], dirname(originDir), configPath); - if (touch === "omo-senpi") { - writeSeedFile(workDir, BUILD_MARKER, `marker-${n}\n`); - } else if (touch === "senpi-task") { - appendFileSync(join(workDir, SENPI_TASK_INDEX_TS), `// senpi-task change ${n}\n`); - } else if (touch === "docs") { - writeSeedFile(workDir, join("docs", `notes-${n}.md`), `docs note ${n}\n`); - } else { - writeSeedFile(workDir, `origin-dev-${n}.txt`, `origin dev commit ${n}\n`); - } - runGit(["add", "-A"], workDir, configPath); - runGit(["commit", "-m", `origin dev commit ${n}`], workDir, configPath); - runGit(["push", "origin", "dev"], workDir, configPath); - return runGit(["rev-parse", "dev"], originDir, configPath); - } finally { - rmSync(workDir, { recursive: true, force: true }); - } -} diff --git a/packages/coding-agent/test/omo-local-update-helpers.ts b/packages/coding-agent/test/omo-local-update-helpers.ts deleted file mode 100644 index 2502ee7d7b..0000000000 --- a/packages/coding-agent/test/omo-local-update-helpers.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Shared helpers for the omo-local-update (v2) test files. - * - * Non-test helper module (no vitest imports): each test file owns its lifecycle - * hooks and calls these factories/utilities directly. Delete together with - * src/beta/omo-local-update*.ts and the other test/omo-local-update* files. - */ - -import { execFileSync } from "node:child_process"; -import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { delimiter, join } from "node:path"; -import { defaultRun, type OmoLocalRun } from "../src/beta/omo-local-update.ts"; -import { FIXTURE_PLUGIN_ARTIFACTS } from "./omo-local-update-fixture.ts"; - -/** - * Git determinism: isolate every git invocation in the calling test file - * (test-side AND engine-side through the inherited process.env) from ambient - * host config. Call at module scope; run `cleanup` from `afterAll`. - */ -export function applyOmoGitIsolation(): { cleanup: () => void } { - const gitConfigDir = mkdtempSync(join(tmpdir(), "omo-local-update-gitcfg-")); - const emptyGitConfig = join(gitConfigDir, "config"); - writeFileSync(emptyGitConfig, ""); - process.env.GIT_CONFIG_NOSYSTEM = "1"; - process.env.GIT_CONFIG_GLOBAL = emptyGitConfig; - process.env.GIT_AUTHOR_NAME = "senpi-test"; - process.env.GIT_AUTHOR_EMAIL = "senpi-test@example.com"; - process.env.GIT_COMMITTER_NAME = "senpi-test"; - process.env.GIT_COMMITTER_EMAIL = "senpi-test@example.com"; - return { - cleanup: () => { - rmSync(gitConfigDir, { recursive: true, force: true }); - }, - }; -} - -/** Per-file temp-root factory; run `cleanup` from `afterAll`. */ -export function createTempRoots(): { makeTempRoot: () => string; cleanup: () => void } { - const tempRoots: string[] = []; - return { - makeTempRoot: () => { - const root = mkdtempSync(join(tmpdir(), "omo-local-update-test-")); - tempRoots.push(root); - return root; - }, - cleanup: () => { - for (const root of tempRoots) { - rmSync(root, { recursive: true, force: true }); - } - }, - }; -} - -export function git(args: string[], cwd: string): string { - return execFileSync("git", args, { cwd, encoding: "utf-8" }).trim(); -} - -export function makeLogCollector(): { lines: string[]; log: (message: string) => void } { - const lines: string[] = []; - return { - lines, - log: (message: string) => { - lines.push(message); - }, - }; -} - -export function makeSpyRun(): { calls: string[][]; run: OmoLocalRun } { - const calls: string[][] = []; - return { - calls, - run: (command, args, options) => { - calls.push([command, ...args]); - return defaultRun(command, args, options); - }, - }; -} - -export function artifactMtimes(pluginPath: string): Record { - const mtimes: Record = {}; - for (const artifact of FIXTURE_PLUGIN_ARTIFACTS) { - mtimes[artifact] = statSync(join(pluginPath, artifact)).mtimeMs; - } - return mtimes; -} - -/** - * Executable `bun` stand-in for orchestrator tests: `bun install` succeeds quietly; - * `bun run build:senpi-plugin` runs the fixture's real stub build from the spawn cwd - * (the updater's build worktree). FAKE_BUN_BUILD_FAIL makes the build exit 1 after - * the stub wrote its artifacts into the worktree. - */ -export function installFakeBun(binDir: string): void { - mkdirSync(binDir, { recursive: true }); - const script = [ - "#!/bin/sh", - 'if [ "$1" = "install" ]; then', - ' echo "fake bun install: ok"', - " exit 0", - "fi", - 'if [ "$1" = "run" ] && [ "$2" = "build:senpi-plugin" ]; then', - " node scripts/build-senpi-plugin.mjs", - " build_status=$?", - ' if [ -n "$FAKE_BUN_BUILD_FAIL" ]; then', - ' echo "fake bun: simulated build failure" >&2', - " exit 1", - " fi", - " exit $build_status", - "fi", - 'echo "fake bun: unexpected argv: $*" >&2', - "exit 1", - "", - ].join("\n"); - const bunPath = join(binDir, "bun"); - writeFileSync(bunPath, script); - chmodSync(bunPath, 0o755); -} - -/** Prepend binDir to PATH; returns a restore function. */ -export function withPrependedPath(binDir: string): () => void { - const originalPath = process.env.PATH; - process.env.PATH = `${binDir}${delimiter}${originalPath ?? ""}`; - return () => { - if (originalPath === undefined) { - delete process.env.PATH; - } else { - process.env.PATH = originalPath; - } - }; -} - -export function makeAgentDir(root: string): string { - const agentDir = join(root, "agent"); - mkdirSync(agentDir, { recursive: true }); - return agentDir; -} diff --git a/packages/coding-agent/test/omo-local-update.test.ts b/packages/coding-agent/test/omo-local-update.test.ts deleted file mode 100644 index b495c37356..0000000000 --- a/packages/coding-agent/test/omo-local-update.test.ts +++ /dev/null @@ -1,1075 +0,0 @@ -import { execFileSync, spawn, spawnSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { - cpSync, - existsSync, - mkdirSync, - readdirSync, - readFileSync, - realpathSync, - renameSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { dirname, join, relative, resolve } from "node:path"; -import { afterAll, describe, expect, it } from "vitest"; -import { - computeRemoteState, - defaultRun, - detectOmoLocalInstall, - isKillSwitched, - type OmoLocalRun, - OmoLocalStepError, - type OmoLocalUpdateStamp, - omoLocalUpdateBuildWorktreePath, - omoLocalUpdateLockPath, - omoLocalUpdateStampPath, - readStamp, - runOmoLocalUpdateBeta, - shouldSkipUpdate, - swapPluginDir, - writeStamp, -} from "../src/beta/omo-local-update.ts"; -import { - advanceOriginDev, - createOmoFixture, - dirtySource, - dirtyUntracked, - FIXTURE_PLUGIN_ARTIFACTS, -} from "./omo-local-update-fixture.ts"; -import { - applyOmoGitIsolation, - artifactMtimes, - createTempRoots, - git, - installFakeBun, - makeAgentDir, - makeLogCollector, - makeSpyRun, - withPrependedPath, -} from "./omo-local-update-helpers.ts"; - -const gitIsolation = applyOmoGitIsolation(); -const tempRoots = createTempRoots(); -const makeTempRoot = tempRoots.makeTempRoot; - -afterAll(() => { - tempRoots.cleanup(); - gitIsolation.cleanup(); -}); - -interface LayoutOptions { - pluginPkgName?: string; - withOmoSenpiWorkspace?: boolean; - withSenpiTask?: boolean; - gitInit?: boolean; -} - -function makeOmoLayout( - root: string, - options: LayoutOptions = {}, -): { repoRoot: string; pluginPath: string; agentDir: string } { - const repoRoot = join(root, "omo"); - const pluginPath = join(repoRoot, "packages", "omo-senpi", "plugin"); - const agentDir = join(root, "agent"); - mkdirSync(pluginPath, { recursive: true }); - mkdirSync(agentDir, { recursive: true }); - writeFileSync( - join(pluginPath, "package.json"), - JSON.stringify({ name: options.pluginPkgName ?? "@code-yeongyu/omo-senpi" }), - ); - if (options.withOmoSenpiWorkspace ?? true) { - writeFileSync( - join(repoRoot, "packages", "omo-senpi", "package.json"), - JSON.stringify({ name: "@oh-my-opencode/omo-senpi" }), - ); - } - if (options.withSenpiTask ?? true) { - mkdirSync(join(repoRoot, "packages", "senpi-task"), { recursive: true }); - writeFileSync( - join(repoRoot, "packages", "senpi-task", "package.json"), - JSON.stringify({ name: "@oh-my-opencode/senpi-task" }), - ); - } - if (options.gitInit ?? true) { - git(["-c", "init.defaultBranch=main", "init"], repoRoot); - } - return { repoRoot, pluginPath, agentDir }; -} - -/** - * A zombie has exited and holds no resources, but its pid entry survives until the - * parent reaps it, so `process.kill(pid, 0)` still succeeds. Killing a process tree - * therefore leaves a window where the reparented grandchild looks alive, which made - * the tree-kill proof below fail on slow runners. Windows has no zombies. - */ -function pidAlive(pid: number): boolean { - try { - process.kill(pid, 0); - } catch { - return false; - } - if (process.platform === "win32") return true; - try { - return !execFileSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf-8" }) - .trim() - .startsWith("Z"); - } catch { - return false; - } -} - -function headSha(cwd: string): string { - return git(["rev-parse", "HEAD"], cwd); -} - -function currentBranch(cwd: string): string | undefined { - try { - return git(["symbolic-ref", "--short", "-q", "HEAD"], cwd) || undefined; - } catch { - return undefined; - } -} - -async function captureFailure(promise: Promise): Promise { - try { - await promise; - return undefined; - } catch (error) { - return error; - } -} - -/** Content snapshot of every file under dir: posix relative path -> sha256 of bytes. */ -function snapshotDir(dir: string): Record { - const snapshot: Record = {}; - const pending = [dir]; - while (pending.length > 0) { - const current = pending.pop(); - if (current === undefined) continue; - for (const entry of readdirSync(current, { withFileTypes: true })) { - const full = join(current, entry.name); - if (entry.isDirectory()) { - pending.push(full); - } else if (entry.isFile()) { - const key = relative(dir, full).split("/").join("/"); - snapshot[key] = createHash("sha256").update(readFileSync(full)).digest("hex"); - } - } - } - return snapshot; -} - -describe("isKillSwitched", () => { - it("returns true only for the literal '0'", () => { - expect(isKillSwitched({ SENPI_OMO_LOCAL_UPDATE: "0" })).toBe(true); - expect(isKillSwitched({ SENPI_OMO_LOCAL_UPDATE: "1" })).toBe(false); - expect(isKillSwitched({ SENPI_OMO_LOCAL_UPDATE: "" })).toBe(false); - expect(isKillSwitched({})).toBe(false); - }); -}); - -describe("defaultRun process seam", () => { - it("captures stdout and stderr and resolves the exit code", async () => { - const result = await defaultRun( - process.execPath, - ["-e", 'process.stdout.write("out"); process.stderr.write("err"); process.exit(3);'], - {}, - ); - expect(result.code).toBe(3); - expect(result.stdout).toBe("out"); - expect(result.stderr).toBe("err"); - expect(result.timedOut).toBe(false); - }); - - it("merges options.env over process.env", async () => { - process.env.OMO_LOCAL_UPDATE_SEAM_AMBIENT = "base"; - const result = await defaultRun( - process.execPath, - [ - "-e", - "console.log(JSON.stringify({ injected: process.env.OMO_LOCAL_UPDATE_SEAM_TEST ?? null, overridden: process.env.OMO_LOCAL_UPDATE_SEAM_AMBIENT ?? null, hasPath: Boolean(process.env.PATH) }));", - ], - { env: { OMO_LOCAL_UPDATE_SEAM_TEST: "hello", OMO_LOCAL_UPDATE_SEAM_AMBIENT: "override" } }, - ); - const printed = JSON.parse(result.stdout.trim()) as { - injected: string | null; - overridden: string | null; - hasPath: boolean; - }; - expect(printed.injected).toBe("hello"); - expect(printed.overridden).toBe("override"); - expect(printed.hasPath).toBe(true); - }); - - it("kills the whole process tree and reports timedOut on timeout", async () => { - // The grandchild INHERITS the child's stdout pipe (fd 1), so the seam's stdout stream - // only reaches EOF once BOTH processes have released it - i.e. once the whole tree is - // dead. defaultRun resolves on that stream close, which makes this proof event-driven: - // no wall-clock deadline, no liveness polling, nothing that can pass by timing luck. - const script = [ - 'const { spawn } = require("node:child_process");', - 'const grandchild = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: ["ignore", 1, "ignore"] });', - "console.log(JSON.stringify({ self: process.pid, grandchild: grandchild.pid }));", - "setInterval(() => {}, 1000);", - ].join("\n"); - const result = await defaultRun(process.execPath, ["-e", script], { timeoutMs: 500 }); - expect(result.timedOut).toBe(true); - const pids = JSON.parse(result.stdout.trim()) as { self: number; grandchild: number }; - // Pipe EOF already proved both processes released fd 1, which only happens at exit. - expect(pidAlive(pids.self)).toBe(false); - expect(pidAlive(pids.grandchild)).toBe(false); - }); -}); - -describe("detectOmoLocalInstall", () => { - it("returns undefined when the packages key is absent", async () => { - const root = makeTempRoot(); - const { agentDir } = makeOmoLayout(root); - expect(await detectOmoLocalInstall({ packages: undefined, agentDir, run: defaultRun })).toBeUndefined(); - }); - - it("returns undefined for npm-only and git-only entries", async () => { - const root = makeTempRoot(); - const { agentDir } = makeOmoLayout(root); - expect( - await detectOmoLocalInstall({ - packages: ["npm:@code-yeongyu/omo-senpi", "git:https://example.com/omo.git"], - agentDir, - run: defaultRun, - }), - ).toBeUndefined(); - }); - - it("returns undefined when the plugin package name does not match", async () => { - const root = makeTempRoot(); - const { pluginPath, agentDir } = makeOmoLayout(root, { pluginPkgName: "@code-yeongyu/not-omo" }); - expect(await detectOmoLocalInstall({ packages: [pluginPath], agentDir, run: defaultRun })).toBeUndefined(); - }); - - it("returns undefined when the senpi-task workspace package is missing", async () => { - const root = makeTempRoot(); - const { pluginPath, agentDir } = makeOmoLayout(root, { withSenpiTask: false }); - expect(await detectOmoLocalInstall({ packages: [pluginPath], agentDir, run: defaultRun })).toBeUndefined(); - }); - - it("returns undefined when the omo-senpi workspace package is missing", async () => { - const root = makeTempRoot(); - const { pluginPath, agentDir } = makeOmoLayout(root, { withOmoSenpiWorkspace: false }); - expect(await detectOmoLocalInstall({ packages: [pluginPath], agentDir, run: defaultRun })).toBeUndefined(); - }); - - it("returns undefined when the derived repo root is not a git repository", async () => { - const root = makeTempRoot(); - const { pluginPath, agentDir } = makeOmoLayout(root, { gitInit: false }); - expect(await detectOmoLocalInstall({ packages: [pluginPath], agentDir, run: defaultRun })).toBeUndefined(); - }); - - it("returns undefined when the git toplevel is an enclosing repository", async () => { - const root = makeTempRoot(); - git(["-c", "init.defaultBranch=main", "init"], root); - const { pluginPath, agentDir } = makeOmoLayout(root, { gitInit: false }); - expect(await detectOmoLocalInstall({ packages: [pluginPath], agentDir, run: defaultRun })).toBeUndefined(); - }); - - it("resolves pluginPath and repoRoot for an absolute settings entry", async () => { - const root = makeTempRoot(); - const { repoRoot, pluginPath, agentDir } = makeOmoLayout(root); - const install = await detectOmoLocalInstall({ packages: [pluginPath], agentDir, run: defaultRun }); - expect(install).toEqual({ pluginPath: resolve(pluginPath), repoRoot: resolve(repoRoot) }); - }); - - it("resolves a relative settings entry against agentDir", async () => { - const root = makeTempRoot(); - const { repoRoot, pluginPath, agentDir } = makeOmoLayout(root); - const relativeEntry = relative(agentDir, pluginPath); - const install = await detectOmoLocalInstall({ packages: [relativeEntry], agentDir, run: defaultRun }); - expect(install).toEqual({ pluginPath: resolve(pluginPath), repoRoot: resolve(repoRoot) }); - }); - - it("expands a ~-prefixed settings entry against the home directory", async () => { - const root = makeTempRoot(); - const { repoRoot, pluginPath, agentDir } = makeOmoLayout(root); - const originalHome = process.env.HOME; - process.env.HOME = root; - try { - const install = await detectOmoLocalInstall({ - packages: ["~/omo/packages/omo-senpi/plugin"], - agentDir, - run: defaultRun, - }); - expect(install).toEqual({ pluginPath: resolve(pluginPath), repoRoot: resolve(repoRoot) }); - } finally { - if (originalHome === undefined) { - delete process.env.HOME; - } else { - process.env.HOME = originalHome; - } - } - }); - - it("accepts object-form PackageSource entries", async () => { - const root = makeTempRoot(); - const { repoRoot, pluginPath, agentDir } = makeOmoLayout(root); - const install = await detectOmoLocalInstall({ - packages: [{ source: pluginPath, autoload: true }], - agentDir, - run: defaultRun, - }); - expect(install).toEqual({ pluginPath: resolve(pluginPath), repoRoot: resolve(repoRoot) }); - }); - - it("skips non-matching entries and finds a later matching one", async () => { - const root = makeTempRoot(); - const { repoRoot, pluginPath, agentDir } = makeOmoLayout(root); - const install = await detectOmoLocalInstall({ - packages: ["npm:@scope/other", join(root, "does-not-exist"), { source: pluginPath }], - agentDir, - run: defaultRun, - }); - expect(install).toEqual({ pluginPath: resolve(pluginPath), repoRoot: resolve(repoRoot) }); - }); - - it("honors injected readJson, exists and run seams", async () => { - const pluginPath = join("/virtual", "omo", "packages", "omo-senpi", "plugin"); - const repoRoot = join("/virtual", "omo"); - const names = new Map([ - [join(pluginPath, "package.json"), "@code-yeongyu/omo-senpi"], - [join(repoRoot, "packages", "omo-senpi", "package.json"), "@oh-my-opencode/omo-senpi"], - [join(repoRoot, "packages", "senpi-task", "package.json"), "@oh-my-opencode/senpi-task"], - ]); - const install = await detectOmoLocalInstall({ - packages: [pluginPath], - agentDir: "/virtual/agent", - readJson: (path) => { - const name = names.get(path); - return name === undefined ? undefined : { name }; - }, - exists: (path) => names.has(path), - run: async () => ({ code: 0, stdout: `${realpathSync("/")}virtual${"/"}omo\n`, stderr: "", timedOut: false }), - }); - expect(install).toEqual({ pluginPath, repoRoot }); - }); -}); - -describe("readStamp/writeStamp", () => { - const stamp: OmoLocalUpdateStamp = { - repoRoot: "/some/repo", - sha: "0123456789abcdef", - omoSenpiTree: "tree-omo", - senpiTaskTree: "tree-task", - buildInputsHash: "inputs-hash", - installedAt: "2026-07-25T00:00:00.000Z", - artifacts: ["extensions/omo.js", "scripts/install.mjs"], - }; - - it("round-trips a stamp through the agent dir state file", () => { - const agentDir = join(makeTempRoot(), "agent"); - writeStamp(agentDir, stamp); - expect(readStamp(agentDir)).toEqual(stamp); - }); - - it("returns undefined when no stamp file exists", () => { - const agentDir = join(makeTempRoot(), "agent"); - mkdirSync(agentDir, { recursive: true }); - expect(readStamp(agentDir)).toBeUndefined(); - }); - - it("returns undefined for a corrupt stamp file", () => { - const agentDir = join(makeTempRoot(), "agent"); - mkdirSync(agentDir, { recursive: true }); - writeFileSync(omoLocalUpdateStampPath(agentDir), "{ not json"); - expect(readStamp(agentDir)).toBeUndefined(); - }); - - it("returns undefined for a stamp with an invalid shape", () => { - const agentDir = join(makeTempRoot(), "agent"); - mkdirSync(agentDir, { recursive: true }); - writeFileSync( - omoLocalUpdateStampPath(agentDir), - JSON.stringify({ repoRoot: "/some/repo", sha: 42, installedAt: "now", artifacts: "nope" }), - ); - expect(readStamp(agentDir)).toBeUndefined(); - // A v1-shaped stamp (builtSha/builtAt, no trees) is equally invalid -> update path. - writeFileSync( - omoLocalUpdateStampPath(agentDir), - JSON.stringify({ repoRoot: "/some/repo", builtSha: "abc", builtAt: "now", artifacts: [] }), - ); - expect(readStamp(agentDir)).toBeUndefined(); - // A pre-fingerprint v2 stamp (no buildInputsHash) is also invalid -> one rebuild on upgrade. - writeFileSync(omoLocalUpdateStampPath(agentDir), JSON.stringify({ ...stamp, buildInputsHash: undefined })); - expect(readStamp(agentDir)).toBeUndefined(); - }); -}); - -describe("shouldSkipUpdate", () => { - const stamp: OmoLocalUpdateStamp = { - repoRoot: "/repo", - sha: "abc123", - omoSenpiTree: "tree-omo", - senpiTaskTree: "tree-task", - buildInputsHash: "inputs-a", - installedAt: "2026-07-25T00:00:00.000Z", - artifacts: ["extensions/omo.js"], - }; - const base = { - stamp, - repoRoot: "/repo", - remoteSha: "abc123", - remoteBuildInputsHash: "inputs-a", - stampArtifactsExist: true, - force: false, - }; - - it("skips when the stamp matches, the full inventory exists, and force is off", () => { - expect(shouldSkipUpdate(base)).toBe(true); - }); - - it("updates when force is set", () => { - expect(shouldSkipUpdate({ ...base, force: true })).toBe(false); - }); - - it("updates when the remote sha and the build inputs both moved", () => { - expect(shouldSkipUpdate({ ...base, remoteSha: "def456", remoteBuildInputsHash: "inputs-b" })).toBe(false); - }); - - it("skips when the remote sha moved but the build inputs match", () => { - expect(shouldSkipUpdate({ ...base, remoteSha: "def456" })).toBe(true); - }); - - it("skips at an unchanged sha even when the stored fingerprint differs", () => { - expect(shouldSkipUpdate({ ...base, remoteBuildInputsHash: "inputs-b" })).toBe(true); - }); - - it("updates when any inventoried artifact is missing", () => { - expect(shouldSkipUpdate({ ...base, stampArtifactsExist: false })).toBe(false); - }); - - it("updates when the inventory is empty", () => { - expect(shouldSkipUpdate({ ...base, stamp: { ...stamp, artifacts: [] } })).toBe(false); - }); - - it("updates when no stamp exists at all", () => { - expect(shouldSkipUpdate({ ...base, stamp: undefined })).toBe(false); - }); - - it("updates when the stamp belongs to a different repo root", () => { - expect(shouldSkipUpdate({ ...base, repoRoot: "/other/repo" })).toBe(false); - }); -}); - -describe("computeRemoteState", () => { - it("returns the frozen sha, subject, and both package trees from origin/dev", { timeout: 30000 }, async () => { - const fixture = createOmoFixture(makeTempRoot()); - const state = await computeRemoteState({ repoRoot: fixture.repoRoot, run: defaultRun }); - expect(state.sha).toBe(git(["rev-parse", "origin/dev"], fixture.repoRoot)); - expect(state.subject).toBe("fixture: seed fake omo repo"); - expect(state.omoSenpiTree).toBe(git(["rev-parse", "origin/dev:packages/omo-senpi"], fixture.repoRoot)); - expect(state.senpiTaskTree).toBe(git(["rev-parse", "origin/dev:packages/senpi-task"], fixture.repoRoot)); - }); - - it("moves only the omo-senpi tree when the omo-senpi package dir is touched", { timeout: 30000 }, async () => { - const fixture = createOmoFixture(makeTempRoot()); - const before = await computeRemoteState({ repoRoot: fixture.repoRoot, run: defaultRun }); - const newSha = advanceOriginDev({ originDir: fixture.originDir, touch: "omo-senpi" }); - const after = await computeRemoteState({ repoRoot: fixture.repoRoot, run: defaultRun }); - expect(after.sha).toBe(newSha); - expect(after.sha).not.toBe(before.sha); - expect(after.subject).toBe("origin dev commit 2"); - expect(after.omoSenpiTree).not.toBe(before.omoSenpiTree); - expect(after.senpiTaskTree).toBe(before.senpiTaskTree); - }); - - it("moves only the senpi-task tree for a senpi-task touch, and neither tree for other dirs", { - timeout: 30000, - }, async () => { - const fixture = createOmoFixture(makeTempRoot()); - await computeRemoteState({ repoRoot: fixture.repoRoot, run: defaultRun }); - advanceOriginDev({ originDir: fixture.originDir, touch: "senpi-task" }); - const taskTouch = await computeRemoteState({ repoRoot: fixture.repoRoot, run: defaultRun }); - const omoTreeAtTaskTouch = taskTouch.omoSenpiTree; - const taskTreeAtTaskTouch = taskTouch.senpiTaskTree; - expect(taskTreeAtTaskTouch).not.toBe(git(["rev-parse", "origin/dev~1:packages/senpi-task"], fixture.repoRoot)); - expect(omoTreeAtTaskTouch).toBe(git(["rev-parse", "origin/dev:packages/omo-senpi"], fixture.repoRoot)); - - const otherSha = advanceOriginDev({ originDir: fixture.originDir, touch: "other" }); - const otherTouch = await computeRemoteState({ repoRoot: fixture.repoRoot, run: defaultRun }); - expect(otherTouch.sha).toBe(otherSha); - expect(otherTouch.sha).not.toBe(taskTouch.sha); - expect(otherTouch.omoSenpiTree).toBe(omoTreeAtTaskTouch); - expect(otherTouch.senpiTaskTree).toBe(taskTreeAtTaskTouch); - }); - - it("rejects with a fetch-stage error when origin is gone, leaving the repo untouched", { - timeout: 30000, - }, async () => { - const fixture = createOmoFixture(makeTempRoot()); - const headBefore = headSha(fixture.repoRoot); - git(["remote", "remove", "origin"], fixture.repoRoot); - const failure = await captureFailure(computeRemoteState({ repoRoot: fixture.repoRoot, run: defaultRun })); - expect(failure).toBeInstanceOf(OmoLocalStepError); - expect((failure as OmoLocalStepError).stage).toBe("fetch"); - expect(headSha(fixture.repoRoot)).toBe(headBefore); - expect(git(["status", "--porcelain"], fixture.repoRoot)).toBe(""); - }); -}); - -describe("swapPluginDir", () => { - it("atomically replaces the plugin dir with the built source dir", () => { - const root = makeTempRoot(); - const pluginPath = join(root, "plugin"); - const sourceDir = join(root, "built"); - mkdirSync(pluginPath, { recursive: true }); - writeFileSync(join(pluginPath, "old.txt"), "old\n"); - mkdirSync(join(sourceDir, "sub"), { recursive: true }); - writeFileSync(join(sourceDir, "sub", "new.txt"), "new\n"); - swapPluginDir({ pluginPath, sourceDir }); - expect(existsSync(join(pluginPath, "old.txt"))).toBe(false); - expect(readFileSync(join(pluginPath, "sub", "new.txt"), "utf8")).toBe("new\n"); - expect(readdirSync(root).filter((entry) => entry.includes(".staging-") || entry.includes(".prev-"))).toEqual([]); - }); - - it("restores the previous plugin dir byte-exact when the staging rename fails", () => { - const root = makeTempRoot(); - const pluginPath = join(root, "plugin"); - const sourceDir = join(root, "built"); - mkdirSync(join(pluginPath, "nested"), { recursive: true }); - writeFileSync(join(pluginPath, "nested", "keep.txt"), "keep me\n"); - mkdirSync(sourceDir, { recursive: true }); - writeFileSync(join(sourceDir, "new.txt"), "new\n"); - const before = snapshotDir(pluginPath); - const failingFs = { - cpSync: (source: string, destination: string) => { - cpSync(source, destination, { recursive: true }); - }, - renameSync: (oldPath: string, newPath: string) => { - if (oldPath.includes(".staging-") && newPath === pluginPath) { - throw new Error("simulated staging rename failure"); - } - renameSync(oldPath, newPath); - }, - rmSync: (path: string) => { - rmSync(path, { recursive: true, force: true }); - }, - }; - expect(() => swapPluginDir({ pluginPath, sourceDir, fs: failingFs })).toThrow("simulated staging rename failure"); - expect(snapshotDir(pluginPath)).toEqual(before); - expect(readdirSync(root).filter((entry) => entry.includes(".staging-") || entry.includes(".prev-"))).toEqual([]); - }); -}); - -describe("runOmoLocalUpdateBeta gates", () => { - it("no-ops under the kill-switch", async () => { - const root = makeTempRoot(); - const { pluginPath, agentDir } = makeOmoLayout(root); - await expect( - runOmoLocalUpdateBeta({ - env: { SENPI_OMO_LOCAL_UPDATE: "0" }, - agentDir, - settings: { packages: [pluginPath] }, - }), - ).resolves.toBeUndefined(); - expect(readStamp(agentDir)).toBeUndefined(); - }); - - it("no-ops when nothing is detected", async () => { - const root = makeTempRoot(); - const agentDir = join(root, "agent"); - mkdirSync(agentDir, { recursive: true }); - await expect( - runOmoLocalUpdateBeta({ env: {}, agentDir, settings: { packages: ["npm:@scope/other"] } }), - ).resolves.toBeUndefined(); - }); -}); - -describe("runOmoLocalUpdateBeta orchestrator", () => { - it("replaces only the plugin install on update, leaving the checkout byte- and ref-identical", { - timeout: 90000, - }, async () => { - const root = makeTempRoot(); - const fixture = createOmoFixture(root); - const agentDir = makeAgentDir(root); - const binDir = join(root, "bin"); - installFakeBun(binDir); - const restorePath = withPrependedPath(binDir); - - const newSha = advanceOriginDev({ originDir: fixture.originDir, touch: "omo-senpi" }); - // Pre-seed user dirt that must survive the update untouched. - const sourcePath = dirtySource(fixture.repoRoot); - const untrackedPath = dirtyUntracked(fixture.repoRoot); - const sourceBefore = readFileSync(join(fixture.repoRoot, sourcePath), "utf8"); - const untrackedBefore = readFileSync(join(fixture.repoRoot, untrackedPath), "utf8"); - const branchBefore = currentBranch(fixture.repoRoot); - const devShaBefore = git(["rev-parse", "dev"], fixture.repoRoot); - const branchesBefore = git(["branch", "--format=%(refname:short)"], fixture.repoRoot); - const diffBefore = git(["diff", "--name-only"], fixture.repoRoot).split("\n").filter(Boolean).sort(); - - const { calls, run } = makeSpyRun(); - const { lines, log } = makeLogCollector(); - try { - await expect( - runOmoLocalUpdateBeta({ - env: {}, - agentDir, - settings: { packages: [fixture.pluginPath] }, - log, - run, - }), - ).resolves.toBeUndefined(); - } finally { - restorePath(); - } - - const short = newSha.slice(0, 7); - expect(lines).toContain( - `Updated OMO local plugins (omo-senpi + senpi-task) to origin/dev @${short} - origin dev commit 2`, - ); - expect(lines.some((line) => line.includes("Updating OMO local plugins: fetching origin/dev..."))).toBe(true); - expect(lines.some((line) => line.includes("Updating OMO local plugins: installing deps..."))).toBe(true); - expect(lines.some((line) => line.includes("Updating OMO local plugins: building plugin..."))).toBe(true); - expect(calls).toContainEqual(["bun", "install"]); - expect(calls).toContainEqual(["bun", "run", "build:senpi-plugin"]); - - // The plugin install was replaced with content built from the NEW origin/dev commit. - expect(readFileSync(join(fixture.pluginPath, "extensions/omo.js"), "utf8")).toContain("marker: marker-2"); - for (const artifact of FIXTURE_PLUGIN_ARTIFACTS) { - expect(existsSync(join(fixture.pluginPath, artifact))).toBe(true); - } - // No staging/prev leftovers next to the install target. - expect( - readdirSync(dirname(fixture.pluginPath)).filter( - (entry) => entry.includes(".staging-") || entry.includes(".prev-"), - ), - ).toEqual([]); - - // Stamp written from the NEW plugin dir inventory, with both package trees. - const stamp = readStamp(agentDir); - expect(stamp?.repoRoot).toBe(fixture.repoRoot); - expect(stamp?.sha).toBe(newSha); - expect(stamp?.omoSenpiTree).toBe(git(["rev-parse", "origin/dev:packages/omo-senpi"], fixture.repoRoot)); - expect(stamp?.senpiTaskTree).toBe(git(["rev-parse", "origin/dev:packages/senpi-task"], fixture.repoRoot)); - expect(stamp?.artifacts).toEqual([...FIXTURE_PLUGIN_ARTIFACTS].sort()); - - // === CHECKOUT PRESERVATION (load-bearing) === - expect(currentBranch(fixture.repoRoot)).toBe(branchBefore); - expect(branchBefore).toBe("dev"); - expect(git(["rev-parse", "dev"], fixture.repoRoot)).toBe(devShaBefore); // no new commits on any branch - expect(git(["branch", "--format=%(refname:short)"], fixture.repoRoot)).toBe(branchesBefore); - expect(git(["branch", "--list", "backup/*"], fixture.repoRoot)).toBe(""); // no backup branches ever - expect(readFileSync(join(fixture.repoRoot, sourcePath), "utf8")).toBe(sourceBefore); // byte-exact dirt - expect(readFileSync(join(fixture.repoRoot, untrackedPath), "utf8")).toBe(untrackedBefore); - const diffAfter = git(["diff", "--name-only"], fixture.repoRoot).split("\n").filter(Boolean).sort(); - for (const path of diffBefore) { - expect(diffAfter).toContain(path); - } - for (const path of diffAfter) { - if (!diffBefore.includes(path)) { - expect(path.startsWith("packages/omo-senpi/plugin/")).toBe(true); - } - } - expect(diffAfter).toContain("packages/omo-senpi/plugin/extensions/omo.js"); - // The untracked set is exactly the pre-seeded notes file (no leftovers anywhere). - expect( - git(["ls-files", "--others", "--exclude-standard"], fixture.repoRoot).split("\n").filter(Boolean).sort(), - ).toEqual([untrackedPath]); - expect(existsSync(omoLocalUpdateLockPath(agentDir))).toBe(false); - }); - - it("reuses the persistent build worktree across updates and reports per-package tree changes", { - timeout: 90000, - }, async () => { - const root = makeTempRoot(); - const fixture = createOmoFixture(root); - const agentDir = makeAgentDir(root); - const binDir = join(root, "bin"); - installFakeBun(binDir); - const restorePath = withPrependedPath(binDir); - const base = { env: {}, agentDir, settings: { packages: [fixture.pluginPath] }, run: defaultRun }; - try { - const first = makeLogCollector(); - await runOmoLocalUpdateBeta({ ...base, log: first.log }); - expect(first.lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(true); - // First update: no previous stamp -> no per-package compare line. - expect(first.lines.some((line) => line.includes("omo-senpi:"))).toBe(false); - - const wt = omoLocalUpdateBuildWorktreePath(agentDir); - expect(existsSync(wt)).toBe(true); - writeFileSync(join(wt, "update-canary.txt"), "survives checkout --force\n"); - - const newSha = advanceOriginDev({ originDir: fixture.originDir, touch: "omo-senpi" }); - const second = makeLogCollector(); - await runOmoLocalUpdateBeta({ ...base, log: second.log }); - expect(second.lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(true); - // Old stamp existed -> dim per-package tree comparison. - expect(second.lines).toContain("omo-senpi: updated, senpi-task: unchanged"); - - // SAME worktree reused: the untracked canary survived `checkout --detach --force`. - expect(readFileSync(join(wt, "update-canary.txt"), "utf8")).toBe("survives checkout --force\n"); - expect(git(["-C", wt, "rev-parse", "HEAD"], ".")).toBe(newSha); - // Registered exactly once (no duplicate worktree add). - const registrations = git(["worktree", "list", "--porcelain"], fixture.repoRoot) - .split("\n") - .filter((line) => line.startsWith("worktree ") && line.includes("build-worktree")); - expect(registrations).toHaveLength(1); - expect(readStamp(agentDir)?.sha).toBe(newSha); - expect(readFileSync(join(fixture.pluginPath, "extensions/omo.js"), "utf8")).toContain("marker: marker-2"); - } finally { - restorePath(); - } - }); - - it("removes a foreign directory at the build-worktree path and recreates it as a real worktree", { - timeout: 90000, - }, async () => { - const root = makeTempRoot(); - const fixture = createOmoFixture(root); - const agentDir = makeAgentDir(root); - const binDir = join(root, "bin"); - installFakeBun(binDir); - const restorePath = withPrependedPath(binDir); - const wt = omoLocalUpdateBuildWorktreePath(agentDir); - mkdirSync(wt, { recursive: true }); - writeFileSync(join(wt, "foreign-canary.txt"), "junk\n"); - const { lines, log } = makeLogCollector(); - try { - await runOmoLocalUpdateBeta({ - env: {}, - agentDir, - settings: { packages: [fixture.pluginPath] }, - log, - run: defaultRun, - }); - } finally { - restorePath(); - } - expect(lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(true); - expect(existsSync(join(wt, "foreign-canary.txt"))).toBe(false); - const commonDir = git(["-C", wt, "rev-parse", "--git-common-dir"], "."); - expect(realpathSync(resolve(wt, commonDir))).toBe(realpathSync(join(fixture.repoRoot, ".git"))); - expect(readStamp(agentDir)?.sha).toBe(git(["rev-parse", "origin/dev"], fixture.repoRoot)); - }); - - it("build failure leaves the local install byte-untouched, writes no stamp, and never throws", { - timeout: 90000, - }, async () => { - const root = makeTempRoot(); - const fixture = createOmoFixture(root); - const agentDir = makeAgentDir(root); - const binDir = join(root, "bin"); - installFakeBun(binDir); - const restorePath = withPrependedPath(binDir); - process.env.FAKE_BUN_BUILD_FAIL = "1"; - const pluginBefore = snapshotDir(fixture.pluginPath); - const statusBefore = git(["status", "--porcelain"], fixture.repoRoot); - const { lines, log } = makeLogCollector(); - try { - await expect( - runOmoLocalUpdateBeta({ - env: {}, - agentDir, - settings: { packages: [fixture.pluginPath] }, - log, - run: defaultRun, - }), - ).resolves.toBeUndefined(); - } finally { - restorePath(); - delete process.env.FAKE_BUN_BUILD_FAIL; - } - expect(lines.some((line) => line.includes("OMO local plugin update failed (build):"))).toBe(true); - // The failed step's output tail is echoed dim (the stub ran before exiting 1). - expect(lines.some((line) => line.includes("fixture build:senpi-plugin wrote"))).toBe(true); - expect(lines.some((line) => line.includes("To update manually:"))).toBe(true); - expect(lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(false); - expect(readStamp(agentDir)).toBeUndefined(); - expect(existsSync(omoLocalUpdateLockPath(agentDir))).toBe(false); - // The local install is byte-identical; the checkout saw zero mutations. - expect(snapshotDir(fixture.pluginPath)).toEqual(pluginBefore); - expect(git(["status", "--porcelain"], fixture.repoRoot)).toBe(statusBefore); - expect(git(["branch", "--list", "backup/*"], fixture.repoRoot)).toBe(""); - }); - - it("skips a second run entirely, and declines the skip when a stamped artifact is deleted", { - timeout: 90000, - }, async () => { - const root = makeTempRoot(); - const fixture = createOmoFixture(root); - const agentDir = makeAgentDir(root); - const binDir = join(root, "bin"); - installFakeBun(binDir); - const restorePath = withPrependedPath(binDir); - const { calls, run } = makeSpyRun(); - const targetSha = git(["rev-parse", "origin/dev"], fixture.repoRoot); - const short = targetSha.slice(0, 7); - const base = { env: {}, agentDir, settings: { packages: [fixture.pluginPath] }, run }; - try { - const first = makeLogCollector(); - await runOmoLocalUpdateBeta({ ...base, log: first.log }); - expect(first.lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(true); - const mtimesBefore = artifactMtimes(fixture.pluginPath); - - // Second run: dim skip line, plugin mtimes unchanged, ZERO bun/worktree activity. - calls.length = 0; - const second = makeLogCollector(); - await runOmoLocalUpdateBeta({ ...base, log: second.log }); - expect(second.lines.some((line) => line.includes(`already at origin/dev @${short}`))).toBe(true); - expect(second.lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(false); - expect(calls.some(([command]) => command === "bun")).toBe(false); - expect(calls.some(([, ...args]) => args.includes("worktree"))).toBe(false); - expect(calls.some(([, ...args]) => args.includes("checkout"))).toBe(false); - expect(artifactMtimes(fixture.pluginPath)).toEqual(mtimesBefore); - - // Deleting one stamped artifact declines the skip -> full update again. - const stampedSkill = join(fixture.pluginPath, "skills", "alpha", "SKILL.md"); - expect(readStamp(agentDir)?.artifacts).toContain("skills/alpha/SKILL.md"); - rmSync(stampedSkill); - calls.length = 0; - const third = makeLogCollector(); - await runOmoLocalUpdateBeta({ ...base, log: third.log }); - expect(third.lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(true); - expect(calls).toContainEqual(["bun", "install"]); - expect(calls).toContainEqual(["bun", "run", "build:senpi-plugin"]); - expect(existsSync(stampedSkill)).toBe(true); - } finally { - restorePath(); - } - }); - - it("rebuilds when force is set even with a matching stamp", { timeout: 90000 }, async () => { - const root = makeTempRoot(); - const fixture = createOmoFixture(root); - const agentDir = makeAgentDir(root); - const binDir = join(root, "bin"); - installFakeBun(binDir); - const restorePath = withPrependedPath(binDir); - const { calls, run } = makeSpyRun(); - const base = { env: {}, agentDir, settings: { packages: [fixture.pluginPath] }, run }; - try { - const first = makeLogCollector(); - await runOmoLocalUpdateBeta({ ...base, log: first.log }); - expect(first.lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(true); - calls.length = 0; - const second = makeLogCollector(); - await runOmoLocalUpdateBeta({ ...base, force: true, log: second.log }); - expect(second.lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(true); - expect(second.lines.some((line) => line.includes("already at origin/dev"))).toBe(false); - expect(calls).toContainEqual(["bun", "install"]); - expect(calls).toContainEqual(["bun", "run", "build:senpi-plugin"]); - } finally { - restorePath(); - } - }); - - it("warns once and skips when the bun binary is missing", { timeout: 90000 }, async () => { - const root = makeTempRoot(); - const fixture = createOmoFixture(root); - const agentDir = makeAgentDir(root); - const pluginBefore = snapshotDir(fixture.pluginPath); - const noBun: OmoLocalRun = async (command, args, runOptions) => { - if (command === "bun") { - throw Object.assign(new Error("spawn bun ENOENT"), { code: "ENOENT" }); - } - return defaultRun(command, args, runOptions); - }; - const { lines, log } = makeLogCollector(); - await expect( - runOmoLocalUpdateBeta({ - env: {}, - agentDir, - settings: { packages: [fixture.pluginPath] }, - log, - run: noBun, - }), - ).resolves.toBeUndefined(); - expect(lines.filter((line) => line.includes("bun is required"))).toHaveLength(1); - expect(lines.some((line) => line.includes("OMO local plugin update failed"))).toBe(false); - expect(lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(false); - expect(readStamp(agentDir)).toBeUndefined(); - expect(snapshotDir(fixture.pluginPath)).toEqual(pluginBefore); - }); - - it("downgrades a fetch failure to a yellow warning and leaves the repo untouched", { - timeout: 90000, - }, async () => { - const root = makeTempRoot(); - const fixture = createOmoFixture(root); - const agentDir = makeAgentDir(root); - git(["remote", "set-url", "origin", join(root, "does-not-exist.git")], fixture.repoRoot); - const headBefore = headSha(fixture.repoRoot); - const { lines, log } = makeLogCollector(); - await expect( - runOmoLocalUpdateBeta({ - env: {}, - agentDir, - settings: { packages: [fixture.pluginPath] }, - log, - run: defaultRun, - }), - ).resolves.toBeUndefined(); - expect(lines.some((line) => line.includes("OMO local plugin update failed (fetch):"))).toBe(true); - expect(lines.some((line) => line.includes("To update manually:"))).toBe(true); - expect(headSha(fixture.repoRoot)).toBe(headBefore); - expect(git(["status", "--porcelain"], fixture.repoRoot)).toBe(""); - expect(readStamp(agentDir)).toBeUndefined(); - expect(existsSync(omoLocalUpdateLockPath(agentDir))).toBe(false); - expect(existsSync(omoLocalUpdateBuildWorktreePath(agentDir))).toBe(false); - }); - - it("allows exactly one of two simultaneous runs to proceed", { timeout: 90000 }, async () => { - const root = makeTempRoot(); - const fixture = createOmoFixture(root); - const agentDir = makeAgentDir(root); - const binDir = join(root, "bin"); - installFakeBun(binDir); - const restorePath = withPrependedPath(binDir); - const { lines, log } = makeLogCollector(); - const options = { - env: {}, - agentDir, - settings: { packages: [fixture.pluginPath] }, - log, - run: defaultRun, - }; - try { - await expect(Promise.all([runOmoLocalUpdateBeta(options), runOmoLocalUpdateBeta(options)])).resolves.toEqual([ - undefined, - undefined, - ]); - } finally { - restorePath(); - } - expect(lines.filter((line) => line.includes("Updated OMO local plugins"))).toHaveLength(1); - expect(lines.filter((line) => line.includes("already running (pid"))).toHaveLength(1); - expect(readStamp(agentDir)).toBeDefined(); - expect(existsSync(omoLocalUpdateLockPath(agentDir))).toBe(false); - }); - - it("reclaims the lock from a dead pid and proceeds", { timeout: 90000 }, async () => { - const root = makeTempRoot(); - const fixture = createOmoFixture(root); - const agentDir = makeAgentDir(root); - const binDir = join(root, "bin"); - installFakeBun(binDir); - const restorePath = withPrependedPath(binDir); - const deadPid = spawnSync(process.execPath, ["-e", ""]).pid; - if (deadPid === undefined) throw new Error("expected a child pid"); - writeFileSync( - omoLocalUpdateLockPath(agentDir), - JSON.stringify({ pid: deadPid, nonce: "stale", startedAt: "2020-01-01T00:00:00.000Z" }), - ); - const { lines, log } = makeLogCollector(); - try { - await runOmoLocalUpdateBeta({ - env: {}, - agentDir, - settings: { packages: [fixture.pluginPath] }, - log, - run: defaultRun, - }); - } finally { - restorePath(); - } - expect(lines.some((line) => line.includes("Updated OMO local plugins"))).toBe(true); - expect(readStamp(agentDir)).toBeDefined(); - expect(existsSync(omoLocalUpdateLockPath(agentDir))).toBe(false); - }); - - it("never takes over a live pid's lock, regardless of age", { timeout: 90000 }, async () => { - const root = makeTempRoot(); - const fixture = createOmoFixture(root); - const agentDir = makeAgentDir(root); - writeFileSync( - omoLocalUpdateLockPath(agentDir), - JSON.stringify({ pid: process.pid, nonce: "someone-else", startedAt: "2020-01-01T00:00:00.000Z" }), - ); - const { calls, run } = makeSpyRun(); - const { lines, log } = makeLogCollector(); - await runOmoLocalUpdateBeta({ - env: {}, - agentDir, - settings: { packages: [fixture.pluginPath] }, - log, - run, - }); - expect(lines.some((line) => line.includes(`already running (pid ${process.pid})`))).toBe(true); - // No fetch, no worktree, no build - nothing but detection ran. - expect(calls.some(([, ...args]) => args.includes("fetch"))).toBe(false); - expect(calls.some(([, ...args]) => args.includes("worktree"))).toBe(false); - expect(calls.some(([command]) => command === "bun")).toBe(false); - expect(readStamp(agentDir)).toBeUndefined(); - expect(git(["status", "--porcelain"], fixture.repoRoot)).toBe(""); - expect(existsSync(omoLocalUpdateBuildWorktreePath(agentDir))).toBe(false); - // The other owner's lock is left in place. - expect(existsSync(omoLocalUpdateLockPath(agentDir))).toBe(true); - }); - - it("kill-switch run has zero fs/git side effects", { timeout: 90000 }, async () => { - const root = makeTempRoot(); - const fixture = createOmoFixture(root); - const agentDir = makeAgentDir(root); - const headBefore = headSha(fixture.repoRoot); - const { calls, run } = makeSpyRun(); - const { lines, log } = makeLogCollector(); - await runOmoLocalUpdateBeta({ - env: { SENPI_OMO_LOCAL_UPDATE: "0" }, - agentDir, - settings: { packages: [fixture.pluginPath] }, - log, - run, - }); - expect(lines).toEqual([]); - expect(calls).toEqual([]); - expect(headSha(fixture.repoRoot)).toBe(headBefore); - expect(git(["status", "--porcelain"], fixture.repoRoot)).toBe(""); - expect(readStamp(agentDir)).toBeUndefined(); - expect(existsSync(omoLocalUpdateLockPath(agentDir))).toBe(false); - expect(existsSync(omoLocalUpdateBuildWorktreePath(agentDir))).toBe(false); - }); - - it("does not report an unreaped zombie as alive", async () => { - if (process.platform === "win32") return; - // A perl parent forks a child that exits immediately, prints the child pid, releases - // fd 3, then sleeps WITHOUT reaping. EOF on fd 3 therefore proves the child exited AND - // the parent released its copy, so from that moment the child is a zombie until the - // parent is killed. Event-driven like the tree-kill proof: no sleeps, no polling. - const script = - 'use POSIX (); my $pid = fork(); if ($pid == 0) { exit 0; } $| = 1; print "$pid\\n"; POSIX::close(3); sleep 30;'; - const parent = spawn("perl", ["-e", script], { stdio: ["ignore", "pipe", "ignore", "pipe"] }); - try { - let stdout = ""; - const parentStdout = parent.stdout; - if (parentStdout === null) throw new Error("expected a readable stdout pipe"); - parentStdout.on("data", (chunk) => { - stdout += String(chunk); - }); - const zombieFd = parent.stdio[3]; - if (zombieFd === null || zombieFd === undefined || typeof zombieFd === "number") { - throw new Error("expected a readable pipe on fd 3"); - } - await new Promise((resolveEof) => zombieFd.on("end", () => resolveEof())); - - const zombiePid = Number(stdout.trim()); - expect(Number.isInteger(zombiePid)).toBe(true); - expect(execFileSync("ps", ["-o", "stat=", "-p", String(zombiePid)], { encoding: "utf-8" }).trim()).toMatch( - /^Z/, - ); - - expect(pidAlive(zombiePid)).toBe(false); - } finally { - parent.kill("SIGKILL"); - } - }); - - it("reports a running process as alive and a reaped pid as dead", async () => { - expect(pidAlive(process.pid)).toBe(true); - - // Node reaps a child before emitting "exit", so once that fires the pid is fully gone - // rather than a zombie. Event-driven: the exit event is the signal, not a wait. - const shortLived = spawn(process.execPath, ["-e", "process.exit(0)"], { stdio: "ignore" }); - const reapedPid = shortLived.pid; - if (reapedPid === undefined) throw new Error("expected a pid for the spawned process"); - await new Promise((resolveExit) => shortLived.on("exit", () => resolveExit())); - - expect(pidAlive(reapedPid)).toBe(false); - }); -}); diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index e763ef7b26..ecea479273 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -438,6 +438,17 @@ describe("package commands", () => { } }); + it("rejects the removed OMO local-update worker option", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect(main(["update", "--omo-local-update-worker"])).resolves.toBeUndefined(); + + expect(errorSpy.mock.calls.map(([message]) => String(message)).join("\n")).toContain( + 'Unknown option --omo-local-update-worker for "update".', + ); + expect(process.exitCode).toBe(1); + }); + it("shows a friendly error for missing install source", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/packages/coding-agent/test/product-boundary.test.ts b/packages/coding-agent/test/product-boundary.test.ts new file mode 100644 index 0000000000..e832851da5 --- /dev/null +++ b/packages/coding-agent/test/product-boundary.test.ts @@ -0,0 +1,54 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const SOURCE_ROOT = new URL("../src/", import.meta.url); +const SOURCE_ROOT_PATH = fileURLToPath(SOURCE_ROOT); +const FORBIDDEN_PRODUCTION_TOKENS = [ + "@code-yeongyu/omo-senpi", + "@oh-my-opencode/omo-senpi", + "@oh-my-opencode/senpi-task", + "runOmoLocalUpdateBeta", + "--omo-local-update-worker", + "SENPI_OMO_LOCAL_UPDATE", + "origin/dev:packages/omo-senpi", +] as const; + +function productionFiles(directory: URL): URL[] { + const files: URL[] = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = new URL(`${entry.name}${entry.isDirectory() ? "/" : ""}`, directory); + if (entry.isDirectory()) { + files.push(...productionFiles(path)); + } else if (entry.isFile() && /\.(?:ts|tsx|js|mjs|cjs)$/.test(entry.name) && !entry.name.endsWith(".test.ts")) { + files.push(path); + } + } + return files; +} + +describe("Senpi product boundary", () => { + it("keeps OMO package layout and update behavior out of production source", () => { + const offenders: string[] = []; + for (const file of productionFiles(SOURCE_ROOT)) { + const source = readFileSync(file, "utf-8"); + for (const token of FORBIDDEN_PRODUCTION_TOKENS) { + if (source.includes(token)) { + offenders.push(`${relative(SOURCE_ROOT_PATH, fileURLToPath(file))} contains ${token}`); + } + } + } + + expect(offenders.sort()).toEqual([]); + }); + + it("has no OMO local updater modules", () => { + const betaDirectory = new URL("../src/beta/", import.meta.url); + const updaterModules = existsSync(betaDirectory) + ? readdirSync(betaDirectory).filter((name) => name.startsWith("omo-local-update")) + : []; + + expect(updaterModules).toEqual([]); + }); +}); From 7ec225affc7d02121b6640d7e58d5c053bac57ae Mon Sep 17 00:00:00 2001 From: madgegja Date: Mon, 10 Aug 2026 14:50:17 +0000 Subject: [PATCH 2/4] docs(changelog): record OMO updater removal --- packages/coding-agent/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cf576d29f5..3ed8d39798 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -17,6 +17,8 @@ ([#796](https://github.com/code-yeongyu/senpi/pull/796)). ### Removed +- Removed the OMO-specific local plugin updater and hidden worker flag from the generic Senpi engine; OMO packaging + and updates now belong to the downstream OMO Native distribution ([#798](https://github.com/code-yeongyu/senpi/pull/798)). ## [2026.8.11-2] - 2026-08-10 From 3219ca4e1500cee48a854067ee26d15e46554924 Mon Sep 17 00:00:00 2001 From: madgegja Date: Mon, 10 Aug 2026 15:07:08 +0000 Subject: [PATCH 3/4] refactor(coding-agent): remove updater state knowledge --- packages/coding-agent/src/brand-dir-migration.ts | 2 +- packages/coding-agent/test/brand-dirs.test.ts | 3 +++ packages/coding-agent/test/product-boundary.test.ts | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/brand-dir-migration.ts b/packages/coding-agent/src/brand-dir-migration.ts index b3a9d39147..f2f07c8b7d 100644 --- a/packages/coding-agent/src/brand-dir-migration.ts +++ b/packages/coding-agent/src/brand-dir-migration.ts @@ -17,7 +17,7 @@ import { CONFIG_DIR_NAME, CONFIG_FLAT_LAYOUT, FLAT_LAYOUT_SENTINEL } from "./con export const MIGRATION_MARKER = ".migrated-from-senpi"; /** Regenerable state: caches, logs and build worktrees are rebuilt on demand. */ -const SKIPPED_ENTRIES = new Set(["cache", "logs", "omo-local-update"]); +const SKIPPED_ENTRIES = new Set(["cache", "logs"]); function isSkipped(entry: string): boolean { return SKIPPED_ENTRIES.has(entry) || entry.endsWith(".log"); diff --git a/packages/coding-agent/test/brand-dirs.test.ts b/packages/coding-agent/test/brand-dirs.test.ts index d3ccb23b73..8ce84d559e 100644 --- a/packages/coding-agent/test/brand-dirs.test.ts +++ b/packages/coding-agent/test/brand-dirs.test.ts @@ -20,11 +20,13 @@ function seedLegacyAgentDir(): string { mkdirSync(join(legacy, "sessions", "project"), { recursive: true }); mkdirSync(join(legacy, "cache"), { recursive: true }); mkdirSync(join(legacy, "logs"), { recursive: true }); + mkdirSync(join(legacy, "omo-local-update"), { recursive: true }); writeFileSync(join(legacy, "settings.json"), '{"theme":"dark"}'); writeFileSync(join(legacy, "auth.json"), '{"anthropic":{}}'); writeFileSync(join(legacy, "sessions", "project", "a.jsonl"), "{}"); writeFileSync(join(legacy, "cache", "blob"), "cache"); writeFileSync(join(legacy, "senpi-debug.log"), "log"); + writeFileSync(join(legacy, "omo-local-update", "state.json"), "{}"); return legacy; } @@ -62,6 +64,7 @@ describe("copy-forward migration", () => { expect(readFileSync(join(brandDir, "settings.json"), "utf-8")).toBe('{"theme":"dark"}'); expect(existsSync(join(brandDir, "auth.json"))).toBe(true); expect(existsSync(join(brandDir, "sessions", "project", "a.jsonl"))).toBe(true); + expect(existsSync(join(brandDir, "omo-local-update", "state.json"))).toBe(true); expect(existsSync(join(brandDir, "cache"))).toBe(false); expect(existsSync(join(brandDir, "logs"))).toBe(false); expect(existsSync(join(brandDir, "senpi-debug.log"))).toBe(false); diff --git a/packages/coding-agent/test/product-boundary.test.ts b/packages/coding-agent/test/product-boundary.test.ts index e832851da5..07b5d10937 100644 --- a/packages/coding-agent/test/product-boundary.test.ts +++ b/packages/coding-agent/test/product-boundary.test.ts @@ -11,6 +11,7 @@ const FORBIDDEN_PRODUCTION_TOKENS = [ "@oh-my-opencode/senpi-task", "runOmoLocalUpdateBeta", "--omo-local-update-worker", + "omo-local-update", "SENPI_OMO_LOCAL_UPDATE", "origin/dev:packages/omo-senpi", ] as const; From 2f3d9b1dc955d85efda4497c909754b1f6e7beb6 Mon Sep 17 00:00:00 2001 From: madgegja Date: Mon, 10 Aug 2026 18:07:37 +0000 Subject: [PATCH 4/4] refactor(coding-agent): enforce downstream product boundary --- packages/coding-agent/CHANGELOG.md | 3 ++ packages/coding-agent/src/changes.md | 4 ++ packages/coding-agent/src/core/brand.ts | 10 ++-- .../builtin/compaction/degradation-monitor.ts | 14 ++---- .../extensions/builtin/compaction/prompts.ts | 2 +- .../extensions/builtin/goal/cache-warm.ts | 2 +- .../extensions/builtin/rules/rules/types.ts | 4 +- .../src/modes/app-server/protocol/methods.ts | 2 +- .../src/modes/interactive/grok/palette.ts | 4 +- .../modes/interactive/model-search-rank.ts | 2 +- .../interactive/tips/catalog/subagent-tips.ts | 5 +- .../coding-agent/src/modes/rpc/rpc-types.ts | 4 +- .../test/product-boundary.test.ts | 50 +++++++++++++++---- 13 files changed, 68 insertions(+), 38 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3ed8d39798..4ab06d5cad 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -9,6 +9,9 @@ ### Added ### Changed +- Generalized production prompt markers, provenance comments, and task-extension tips so Senpi no longer names a + downstream product's packages, repository paths, or configuration files; a repository-wide source guard prevents + those identifiers from returning ([#798](https://github.com/code-yeongyu/senpi/pull/798)). ### Fixed diff --git a/packages/coding-agent/src/changes.md b/packages/coding-agent/src/changes.md index 4947b08014..d6b2387091 100644 --- a/packages/coding-agent/src/changes.md +++ b/packages/coding-agent/src/changes.md @@ -5,6 +5,10 @@ - Deleted the beta OMO local-plugin updater, its hidden worker option, state machinery, and dedicated tests. - Bare `senpi update` now follows only the generic self/package/model update paths. - Added a production-source boundary regression that rejects OMO package-layout and updater knowledge in Senpi. +- Replaced downstream-specific prompt markers, provenance comments, and task-extension paths with Senpi-owned or + implementation-neutral language. +- Expanded the boundary regression across every package and crate source root while retaining generic brand profiles + and external `.omo` rules compatibility. ### Why this belongs outside the engine diff --git a/packages/coding-agent/src/core/brand.ts b/packages/coding-agent/src/core/brand.ts index a3f610eefe..1aeb1324f8 100644 --- a/packages/coding-agent/src/core/brand.ts +++ b/packages/coding-agent/src/core/brand.ts @@ -1,7 +1,7 @@ /** * Brand profile resolution. * - * A distribution that repackages this engine (for example the `omo-ai` package) injects a + * A distribution that repackages this engine (for example the `acme-agent` package) injects a * single JSON environment variable describing how the product presents itself. The engine * parses it once at startup and then REMOVES it from the environment, so nested processes * spawned by tools inherit a clean environment and keep the engine's own identity. @@ -18,11 +18,11 @@ export const BRAND_ENV_VAR = "SENPI_BRAND"; * its own package, which would advertise an update the branded product cannot install. */ export interface BrandUpdateChannel { - /** Registry package that ships the branded product, e.g. `omo-ai`. */ + /** Registry package that ships the branded product, e.g. `acme-agent`. */ readonly packageName: string; /** Dist-tag the product publishes on, e.g. `beta`. */ readonly distTag: string; - /** Command shown to the user, e.g. `npm i -g omo-ai@beta`. */ + /** Command shown to the user, e.g. `npm i -g acme-agent@beta`. */ readonly command: string; /** Release notes URL; `{version}` is replaced with the available version. */ readonly changelogUrl?: string; @@ -33,11 +33,11 @@ export interface BrandProfile { readonly name: string; /** Version shown in the header, terminal titles and `--version`. */ readonly displayVersion?: string; - /** Config directory name, e.g. `.omo`. */ + /** Config directory name, e.g. `.acme-agent`. */ readonly configDir: string; /** When true the agent state lives directly under the config directory, with no `agent` segment. */ readonly flatLayout: boolean; - /** Prefix for the product's environment variables, e.g. `OMO`. */ + /** Prefix for the product's environment variables, e.g. `ACME_AGENT`. */ readonly envPrefix: string; /** Product token used in the outgoing user agent. */ readonly userAgent: string; diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/degradation-monitor.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/degradation-monitor.ts index f6a36698bb..14b8e08722 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/degradation-monitor.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/degradation-monitor.ts @@ -6,16 +6,10 @@ * model emits POST_COMPACTION_NO_TEXT_THRESHOLD consecutive assistant * messages with no text content (only step-start/step-finish parts). * - * Algorithm ported from omo's preemptive-compaction-degradation-monitor: - * `/Users/yeongyu/local-workspaces/omo/src/hooks/preemptive-compaction-degradation-monitor.ts` - * - * Pi extension surface differences vs omo: - * - Recovery dispatch: speculative generation plus `ctx.applyCompaction(...)` - * instead of omo's `client.session.summarize(...)`. The "RECOVERY:" prefix - * in customInstructions is the disambiguator; CompactionReason stays - * "extension" (no new variant in v1). - * - Notification: `ctx.notify(message)` instead of omo's - * `client.tui.showToast(...)`. + * Recovery dispatch uses speculative generation plus `ctx.applyCompaction(...)`. + * The "RECOVERY:" prefix in customInstructions is the disambiguator; + * CompactionReason stays "extension" (no new variant in v1). Notifications use + * `ctx.notify(message)`. * * Recovery cap: 1 per compaction cycle (gate via recoveryTriggeredThisCycle). * MAX_RECOVERY_ATTEMPTS = 3 is exported for future iteration but the v1 cycle diff --git a/packages/coding-agent/src/core/extensions/builtin/compaction/prompts.ts b/packages/coding-agent/src/core/extensions/builtin/compaction/prompts.ts index f2ee7216ff..7c6038db1e 100644 --- a/packages/coding-agent/src/core/extensions/builtin/compaction/prompts.ts +++ b/packages/coding-agent/src/core/extensions/builtin/compaction/prompts.ts @@ -28,7 +28,7 @@ const TASK_INTENT_UPDATE_ANCHOR_GPT = ` Immutable provenance of the original task. Do not rewrite it. Newer explicit user steering overrides it. `; -export const MERGED_COMPACTION_PROMPT_SYSTEM = `[SYSTEM DIRECTIVE: OH-MY-OPENCODE - COMPACTION CONTEXT] +export const MERGED_COMPACTION_PROMPT_SYSTEM = `[SYSTEM DIRECTIVE: SENPI - COMPACTION CONTEXT] You are the COMPACTION ARCHIVIST. Create a structured handoff summary that lets the next agent continue this exact session without restarting, re-searching, or losing constraints. diff --git a/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm.ts b/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm.ts index 92b25fb99a..c02cd1711d 100644 --- a/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm.ts +++ b/packages/coding-agent/src/core/extensions/builtin/goal/cache-warm.ts @@ -44,7 +44,7 @@ export type GoalCacheWarmupPhase = "scheduled" | "resumed"; /** * Durable payload appended as a `goal-cache-warmup` custom entry and carried by * the `goal_continuation_scheduled` / `goal_continuation_resumed` pi-events, so - * external consumers (for example omo-desktop-app) can render the story later. + * external consumers such as desktop clients can render the story later. */ export interface GoalCacheWarmupEntryData { readonly phase: GoalCacheWarmupPhase; diff --git a/packages/coding-agent/src/core/extensions/builtin/rules/rules/types.ts b/packages/coding-agent/src/core/extensions/builtin/rules/rules/types.ts index 8662a6bb7b..7b38c1c983 100644 --- a/packages/coding-agent/src/core/extensions/builtin/rules/rules/types.ts +++ b/packages/coding-agent/src/core/extensions/builtin/rules/rules/types.ts @@ -2,8 +2,8 @@ * Public types for pi-rules. * * These types are stable contracts between modules. The frontmatter type - * mirrors omo's `RuleMetadata` plus Claude (`paths`) and Copilot (`applyTo`) - * aliases that are normalized into `globs` internally. + * supports Claude (`paths`) and Copilot (`applyTo`) aliases that are normalized + * into `globs` internally. */ /** diff --git a/packages/coding-agent/src/modes/app-server/protocol/methods.ts b/packages/coding-agent/src/modes/app-server/protocol/methods.ts index bef1c717c6..0b81ffacd0 100644 --- a/packages/coding-agent/src/modes/app-server/protocol/methods.ts +++ b/packages/coding-agent/src/modes/app-server/protocol/methods.ts @@ -1,4 +1,4 @@ -// Derived from .omo/ulw-research/20260702-114518/raw/methods-*.txt. +// Derived from captured app-server method inventories. export const STABLE_CLIENT_REQUEST_METHODS = [ "account/login/cancel", diff --git a/packages/coding-agent/src/modes/interactive/grok/palette.ts b/packages/coding-agent/src/modes/interactive/grok/palette.ts index 9af977f655..75dfcd2f33 100644 --- a/packages/coding-agent/src/modes/interactive/grok/palette.ts +++ b/packages/coding-agent/src/modes/interactive/grok/palette.ts @@ -1,8 +1,8 @@ /** * Grok palette — typed constants for the `--grok-neo` mode (todo S2). * - * Source of truth: `.omo/plans/grok-neo.md` §Palette (authoritative embedded - * data). The values were hand-transcribed from real terminal SGR captures — + * Source of truth: the Grok Neo palette specification. The values were + * hand-transcribed from real terminal SGR captures — * they are measured colour data, not copied code. Per the project's binding * independent-reimplementation policy, this module was written without * opening or referencing any grok-build source. diff --git a/packages/coding-agent/src/modes/interactive/model-search-rank.ts b/packages/coding-agent/src/modes/interactive/model-search-rank.ts index e421926178..62d3e8a8a7 100644 --- a/packages/coding-agent/src/modes/interactive/model-search-rank.ts +++ b/packages/coding-agent/src/modes/interactive/model-search-rank.ts @@ -1,7 +1,7 @@ /** * Favorites-aware relevance ranking for model search. * - * Implements .omo/drafts/model-selector-favorites-search-ux-ranking-spec.md: + * Implements the model-selector favorites-search UX ranking specification: * dual query token plans (compound keeps slashes, legacy splits them), a * per-token tier ladder over independently matched lowercased fields, * worst-tier-first aggregation, and a composite sort key whose canonical diff --git a/packages/coding-agent/src/modes/interactive/tips/catalog/subagent-tips.ts b/packages/coding-agent/src/modes/interactive/tips/catalog/subagent-tips.ts index f0fefecda9..3806881763 100644 --- a/packages/coding-agent/src/modes/interactive/tips/catalog/subagent-tips.ts +++ b/packages/coding-agent/src/modes/interactive/tips/catalog/subagent-tips.ts @@ -5,7 +5,7 @@ export const SUBAGENT_TIPS = [ id: "workflow-skills.plan", bindings: [], requiresCommand: "tasks", - render: () => 'Trigger "ulw plan" to get an explored, decision-complete plan under .omo/plans/.', + render: () => 'Trigger "ulw plan" to get an explored, decision-complete plan in the workflow extension.', }, { id: "workflow-skills.start-work", @@ -87,7 +87,8 @@ export const SUBAGENT_TIPS = [ id: "subagent-config", bindings: [], requiresCommand: "tasks", - render: () => "~/.omo/omo.jsonc maps every subagent category to its model, reasoning effort, and fallback chain.", + render: () => + "Task extension configuration maps every subagent category to its model, reasoning effort, and fallback chain.", }, { id: "subagent-team", diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 57d5ba1786..92194971ba 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -89,8 +89,8 @@ type RpcSessionCommand = | { id?: string; type: "login_api_key"; provider: string; key: string } | { id?: string; type: "logout"; provider: string } - // Provider accounts (task 13) are additive. The desktop consumer contract - // lives in ../omo-desktop-app/packages/contracts/src/rpc.ts and is updated separately. + // Provider accounts (task 13) are additive. Consumer RPC contracts are + // maintained separately. | { id?: string; type: "get_provider_accounts"; provider: string } | { id?: string; type: "account_pin"; provider: string; name: string | null } | { id?: string; type: "account_remove"; provider: string; name: string }; diff --git a/packages/coding-agent/test/product-boundary.test.ts b/packages/coding-agent/test/product-boundary.test.ts index 07b5d10937..9e45a77565 100644 --- a/packages/coding-agent/test/product-boundary.test.ts +++ b/packages/coding-agent/test/product-boundary.test.ts @@ -3,17 +3,30 @@ import { relative } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; -const SOURCE_ROOT = new URL("../src/", import.meta.url); -const SOURCE_ROOT_PATH = fileURLToPath(SOURCE_ROOT); +const REPOSITORY_ROOT = new URL("../../../", import.meta.url); +const REPOSITORY_ROOT_PATH = fileURLToPath(REPOSITORY_ROOT); const FORBIDDEN_PRODUCTION_TOKENS = [ - "@code-yeongyu/omo-senpi", - "@oh-my-opencode/omo-senpi", - "@oh-my-opencode/senpi-task", + "@code-yeongyu/omo-", + "@oh-my-opencode/", + "oh-my-openagent", + "oh-my-opencode", + "omo-ai", "runOmoLocalUpdateBeta", "--omo-local-update-worker", "omo-local-update", "SENPI_OMO_LOCAL_UPDATE", "origin/dev:packages/omo-senpi", + "detectOmoNativeInstall", + "isOmoNative", + "setOmoNative", + "omo-native-detect", + "OmO Native", + "omo-desktop-app", + ".omo/plans", + ".omo/drafts", + ".omo/ulw-research", + "~/.omo/omo.jsonc", + "/local-workspaces/omo/", ] as const; function productionFiles(directory: URL): URL[] { @@ -28,15 +41,30 @@ function productionFiles(directory: URL): URL[] { } return files; } +function productionSourceRoots(): URL[] { + const roots: URL[] = []; + for (const containerName of ["packages", "crates"]) { + const container = new URL(`${containerName}/`, REPOSITORY_ROOT); + if (!existsSync(container)) continue; + for (const entry of readdirSync(container, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const sourceRoot = new URL(`${entry.name}/src/`, container); + if (existsSync(sourceRoot)) roots.push(sourceRoot); + } + } + return roots; +} describe("Senpi product boundary", () => { - it("keeps OMO package layout and update behavior out of production source", () => { + it("keeps downstream product layout and behavior out of production source", () => { const offenders: string[] = []; - for (const file of productionFiles(SOURCE_ROOT)) { - const source = readFileSync(file, "utf-8"); - for (const token of FORBIDDEN_PRODUCTION_TOKENS) { - if (source.includes(token)) { - offenders.push(`${relative(SOURCE_ROOT_PATH, fileURLToPath(file))} contains ${token}`); + for (const sourceRoot of productionSourceRoots()) { + for (const file of productionFiles(sourceRoot)) { + const source = readFileSync(file, "utf-8"); + for (const token of FORBIDDEN_PRODUCTION_TOKENS) { + if (source.includes(token)) { + offenders.push(`${relative(REPOSITORY_ROOT_PATH, fileURLToPath(file))} contains ${token}`); + } } } }