This commit is contained in:
James Long 2026-05-15 23:40:05 -04:00
commit 27de1ee982
9 changed files with 571 additions and 23 deletions

View file

@ -0,0 +1,251 @@
import { useTerminalDimensions } from "@opentui/solid"
import { SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useTheme } from "@tui/context/theme"
import { parsePatch } from "diff"
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
import { useBindings } from "../keymap"
type DiffFile = {
readonly file: string
readonly patch: string
readonly additions: number
readonly deletions: number
readonly status: "added" | "deleted" | "modified"
}
const stripPrefix = (file: string | undefined) => {
if (!file || file === "/dev/null") return undefined
if (file.startsWith("a/") || file.startsWith("b/")) return file.slice(2)
return file
}
const splitRawDiff = (text: string) => {
const starts = [...text.matchAll(/(?:^|\n)diff --git /g)].map((match) =>
match[0].startsWith("\n") ? match.index + 1 : match.index,
)
if (starts.length === 0) return text.trim() ? [text] : []
return starts.map((start, index) => text.slice(start, starts[index + 1] ?? text.length))
}
const parseRawDiff = (text: string): DiffFile[] => {
const chunks = splitRawDiff(text)
return chunks.flatMap((chunk) => {
const parsed = parsePatch(chunk)[0]
const file = stripPrefix(parsed?.newFileName) ?? stripPrefix(parsed?.oldFileName)
if (!parsed || !file) return []
const counts = parsed.hunks.flatMap((hunk) => hunk.lines).reduce(
(acc, line) => ({
additions: acc.additions + (line.startsWith("+") ? 1 : 0),
deletions: acc.deletions + (line.startsWith("-") ? 1 : 0),
}),
{ additions: 0, deletions: 0 },
)
return [
{
file,
patch: chunk,
additions: counts.additions,
deletions: counts.deletions,
status: parsed.oldFileName === "/dev/null" ? "added" : parsed.newFileName === "/dev/null" ? "deleted" : "modified",
} satisfies DiffFile,
]
})
}
const lineKind = (line: string) => {
if (line.startsWith("+")) return "added"
if (line.startsWith("-")) return "deleted"
if (line.startsWith("@@")) return "hunk"
if (line.startsWith("diff --git") || line.startsWith("index ")) return "meta"
return "context"
}
export function DiffViewer(props: { onClose: () => void }) {
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
const sdk = useSDK()
const [selected, setSelected] = createSignal(0)
const [raw] = createResource(async () => {
const result = await sdk.client.vcs.diff2.raw(undefined, { throwOnError: true })
return result.data ?? ""
})
const files = createMemo(() => parseRawDiff(raw() ?? ""))
const current = createMemo(() => files()[selected()])
const lines = createMemo(() => current()?.patch.trimEnd().split(/\r?\n/) ?? [])
const move = (delta: number) => {
const total = files().length
if (total === 0) return
setSelected((selected() + delta + total) % total)
}
createEffect(() => {
if (selected() >= files().length) setSelected(Math.max(0, files().length - 1))
})
useBindings(() => ({
priority: 2000,
bindings: [
{
key: "up",
desc: "Previous file",
group: "Diff",
cmd: () => move(-1),
},
{
key: "k",
desc: "Previous file",
group: "Diff",
cmd: () => move(-1),
},
{
key: "down",
desc: "Next file",
group: "Diff",
cmd: () => move(1),
},
{
key: "j",
desc: "Next file",
group: "Diff",
cmd: () => move(1),
},
{
key: "escape",
desc: "Close diff viewer",
group: "Diff",
cmd: props.onClose,
},
{
key: "q",
desc: "Close diff viewer",
group: "Diff",
cmd: props.onClose,
},
],
}))
return (
<box
position="absolute"
zIndex={2500}
left={0}
top={0}
width={dimensions().width}
height={dimensions().height}
backgroundColor={theme.background}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
gap={1}
>
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
<box flexDirection="row" gap={1}>
<text fg={theme.text}>Diff</text>
<text fg={theme.textMuted}>working tree</text>
</box>
<text fg={theme.textMuted}>j/k select · q/esc close</text>
</box>
<box flexDirection="row" flexGrow={1} minHeight={0} gap={2}>
<box
width={32}
flexShrink={0}
backgroundColor={theme.backgroundPanel}
border={["left", "right"]}
borderColor={theme.border}
customBorderChars={SplitBorder.customBorderChars}
paddingLeft={1}
paddingRight={1}
paddingTop={1}
gap={1}
>
<text fg={theme.textMuted}>Files</text>
<Switch>
<Match when={raw.loading}>
<text fg={theme.textMuted}>Loading diff...</text>
</Match>
<Match when={raw.error}>
<text fg={theme.error}>Failed to load diff</text>
</Match>
<Match when={files().length === 0}>
<text fg={theme.text}>No changes</text>
</Match>
<Match when={files().length > 0}>
<For each={files()}>
{(file, index) => (
<box flexDirection="row" gap={1} backgroundColor={index() === selected() ? theme.backgroundElement : undefined}>
<text fg={index() === selected() ? theme.accent : theme.text}>{index() === selected() ? "" : " "}</text>
<text fg={theme.text} wrapMode="none">
{file.file}
</text>
<text fg={theme.diffAdded}>+{file.additions}</text>
<text fg={theme.diffRemoved}>-{file.deletions}</text>
</box>
)}
</For>
</Match>
</Switch>
</box>
<box
flexGrow={1}
minWidth={0}
backgroundColor={theme.backgroundPanel}
border={["left", "right"]}
borderColor={theme.borderActive}
customBorderChars={SplitBorder.customBorderChars}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
gap={1}
>
<Show
when={current()}
fallback={<text fg={theme.textMuted}>{raw.loading ? "Loading diff..." : raw.error ? "Failed to load diff" : "No diff to show"}</text>}
>
{(file) => (
<>
<box flexDirection="row" gap={2} flexShrink={0}>
<text fg={theme.text}>{file().file}</text>
<text fg={theme.textMuted}>{file().status}</text>
<text fg={theme.diffAdded}>+{file().additions}</text>
<text fg={theme.diffRemoved}>-{file().deletions}</text>
</box>
<scrollbox flexGrow={1} minHeight={0}>
<For each={lines()}>
{(line) => {
const kind = lineKind(line)
return (
<text
fg={
kind === "added"
? theme.diffAdded
: kind === "deleted"
? theme.diffRemoved
: kind === "hunk"
? theme.accent
: kind === "meta"
? theme.textMuted
: theme.text
}
wrapMode="none"
>
{line || " "}
</text>
)
}}
</For>
</scrollbox>
</>
)}
</Show>
</box>
</box>
</box>
)
}

View file

@ -1,6 +1,7 @@
import { Installation } from "@/installation"
import { Server } from "@/server/server"
import * as Log from "@opencode-ai/core/util/log"
import { Global } from "@opencode-ai/core/global"
import { InstanceRuntime } from "@/project/instance-runtime"
import { Rpc } from "@/util/rpc"
import { upgrade } from "@/cli/upgrade"
@ -19,6 +20,15 @@ import fs from "fs/promises"
ensureProcessMetadata("worker")
if (process.env.OPENCODE_SIMULATION_CWD) {
process.env.PWD = process.env.OPENCODE_SIMULATION_CWD
process.env.OPENCODE_TEST_HOME = process.env.OPENCODE_SIMULATION_CWD
Global.Path.data = `${process.env.OPENCODE_SIMULATION_CWD}/.local/share/opencode`
Global.Path.cache = `${process.env.OPENCODE_SIMULATION_CWD}/.cache/opencode`
Global.Path.config = `${process.env.OPENCODE_SIMULATION_CWD}/.config/opencode`
Global.Path.state = `${process.env.OPENCODE_SIMULATION_CWD}/.local/state/opencode`
Global.Path.tmp = `${process.env.OPENCODE_SIMULATION_CWD}/tmp/opencode`
Global.Path.bin = `${Global.Path.cache}/bin`
Global.Path.log = `${Global.Path.data}/log`
Global.Path.repos = `${Global.Path.data}/repos`
Object.defineProperty(process, "cwd", {
value: () => process.env.OPENCODE_SIMULATION_CWD!,
configurable: true,

View file

@ -199,8 +199,10 @@ export const layer: Layer.Layer<
const data: DiscoveryResult = yield* Effect.gen(function* () {
const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
const dotgit = dotgitMatches[0]
log.info("fromDirectory dotgit discovery", { directory, dotgit, matches: dotgitMatches })
if (!dotgit) {
log.info("fromDirectory no dotgit", { directory, fakeVcs })
return {
id: ProjectID.global,
worktree: "/",
@ -212,8 +214,10 @@ export const layer: Layer.Layer<
let sandbox = pathSvc.dirname(dotgit)
const gitBinary = yield* Effect.sync(() => which("git"))
let id = yield* readCachedProjectId(dotgit)
log.info("fromDirectory dotgit found", { directory, dotgit, sandbox, gitBinary, cachedProjectId: id })
if (!gitBinary) {
log.info("fromDirectory no git binary", { directory, sandbox, fakeVcs })
return {
id: id ?? ProjectID.global,
worktree: sandbox,
@ -223,7 +227,9 @@ export const layer: Layer.Layer<
}
const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
log.info("fromDirectory git common-dir", { directory, sandbox, code: commonDir.code, text: commonDir.text, stderr: commonDir.stderr })
if (commonDir.code !== 0) {
log.info("fromDirectory git common-dir failed", { directory, sandbox, fakeVcs })
return {
id: id ?? ProjectID.global,
worktree: sandbox,
@ -235,13 +241,24 @@ export const layer: Layer.Layer<
const bareCheck = yield* git(["config", "--bool", "core.bare"], { cwd: sandbox })
const isBareRepo = bareCheck.code === 0 && bareCheck.text.trim() === "true"
const worktree = common === sandbox ? sandbox : isBareRepo ? common : pathSvc.dirname(common)
log.info("fromDirectory git repository metadata", {
directory,
sandbox,
common,
bareCheckCode: bareCheck.code,
bareCheckText: bareCheck.text,
isBareRepo,
worktree,
})
if (id == null) {
id = yield* readCachedProjectId(common)
log.info("fromDirectory common cached project id", { directory, common, cachedProjectId: id })
}
if (!id) {
const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox })
log.info("fromDirectory git rev-list roots", { directory, sandbox, code: revList.code, text: revList.text, stderr: revList.stderr })
const roots = revList.text
.split("\n")
.filter(Boolean)
@ -255,11 +272,14 @@ export const layer: Layer.Layer<
}
if (!id) {
log.info("fromDirectory no project id", { directory, sandbox, worktree })
return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
}
const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox })
log.info("fromDirectory git top-level", { directory, sandbox, code: topLevel.code, text: topLevel.text, stderr: topLevel.stderr })
if (topLevel.code !== 0) {
log.info("fromDirectory git top-level failed", { directory, sandbox, fakeVcs })
return {
id,
worktree: sandbox,
@ -269,8 +289,10 @@ export const layer: Layer.Layer<
}
sandbox = resolveGitPath(sandbox, topLevel.text.trim())
log.info("fromDirectory discovered git project", { directory, id, sandbox, worktree })
return { id, sandbox, worktree, vcs: "git" as const }
})
log.info("fromDirectory discovery result", data)
// Phase 2: upsert
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
@ -292,6 +314,7 @@ export const layer: Layer.Layer<
vcs: data.vcs,
time: { ...existing.time, updated: Date.now() },
}
log.info("fromDirectory existing project row", { directory, hasExisting: Boolean(row), existing })
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
result.sandboxes.push(data.sandbox)
result.sandboxes = yield* Effect.forEach(

View file

@ -53,6 +53,11 @@ export function make(options: Options) {
return Effect.fail(normalized)
}
const normalizeSearchStart = (file: string) => {
const resolved = path.resolve(root, file)
if (resolved === root || AppFileSystem.contains(root, resolved)) return resolved
}
const normalizePair = (method: string, fromPath: string, toPath: string) =>
Effect.all([normalizeEffect(method, fromPath), normalizeEffect(method, toPath)] as const)
@ -68,7 +73,10 @@ export function make(options: Options) {
fs.mkdirSync(root, { recursive: true })
for (const [file, content] of Object.entries(options.files ?? {})) {
const normalized = normalize("seed", file)
if (typeof normalized === "string") fs.writeFileSync(normalized, content, { encoding: "utf8" })
if (typeof normalized === "string") {
fs.mkdirSync(path.dirname(normalized), { recursive: true })
fs.writeFileSync(normalized, content, { encoding: "utf8" })
}
}
const base = FileSystem.make({
@ -272,8 +280,9 @@ export function make(options: Options) {
up: (methodOptions) =>
Effect.gen(function* () {
const result: string[] = []
let current = yield* normalizeEffect("up", methodOptions.start)
const normalizedStop = methodOptions.stop ? yield* normalizeEffect("up", methodOptions.stop) : undefined
let current = normalizeSearchStart(methodOptions.start)
if (!current) return result
const normalizedStop = methodOptions.stop ? normalizeSearchStart(methodOptions.stop) : undefined
while (true) {
for (const target of methodOptions.targets) {
const file = path.join(current, target)
@ -289,8 +298,9 @@ export function make(options: Options) {
globUp: (pattern, start, stop) =>
Effect.gen(function* () {
const result: string[] = []
let current = yield* normalizeEffect("globUp", start)
const normalizedStop = stop ? yield* normalizeEffect("globUp", stop) : undefined
let current = normalizeSearchStart(start)
if (!current) return result
const normalizedStop = stop ? normalizeSearchStart(stop) : undefined
while (true) {
result.push(...(yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true })))
if (normalizedStop === current) break

View file

@ -46,6 +46,24 @@ function output(value: string) {
return Stream.make(encoder.encode(value))
}
function handle(result: { stdout?: string; stderr?: string; exitCode?: number }) {
const stdoutText = result.stdout ?? ""
const stderrText = result.stderr ?? ""
return makeHandle({
pid: ProcessId(0),
stdin: Sink.drain,
stdout: output(stdoutText),
stderr: output(stderrText),
all: output(stdoutText + stderrText),
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
isRunning: Effect.succeed(false),
exitCode: Effect.succeed(ExitCode(result.exitCode ?? 0)),
kill: () => Effect.void,
unref: Effect.succeed(Effect.void),
})
}
function cwd(options: Options, command: ChildProcess.StandardCommand) {
const root = path.resolve(options.root)
const resolved = path.resolve(root, command.options.cwd ?? root)
@ -56,33 +74,25 @@ function cwd(options: Options, command: ChildProcess.StandardCommand) {
export function make(options: Options) {
const spawn = Effect.fn("SimulationSpawner.spawn")(function* (command: ChildProcess.Command) {
if (command._tag !== "StandardCommand") return yield* error("spawn", command, "Piped commands are not supported")
const workingDirectory = yield* cwd(options, command)
if (Shell.name(command.command) === "git") {
if (command.args[0] === "rev-parse" && command.args.includes("--git-common-dir")) return handle({ stdout: ".git\n" })
if (command.args[0] === "rev-parse" && command.args.includes("--show-toplevel")) return handle({ stdout: `${workingDirectory}\n` })
if (command.args[0] === "rev-parse") return handle({ stdout: "0000000000000000000000000000000000000000\n" })
if (command.args[0] === "rev-list") return handle({ stdout: "0000000000000000000000000000000000000000\n" })
if (command.args[0] === "config" && command.args.includes("core.bare")) return handle({ stdout: "false\n" })
}
if (!isShell(command)) return yield* error("spawn", command, "Only shell commands are supported in simulation")
const text = commandText(command)
if (!text) return yield* error("spawn", command, "Shell command did not include command text")
const workingDirectory = yield* cwd(options, command)
const result = yield* Effect.promise(() =>
new Bash({ fs: options.fs, cwd: workingDirectory }).exec(text, {
env: Object.fromEntries(Object.entries(command.options.env ?? {}).filter((entry): entry is [string, string] => typeof entry[1] === "string")),
}),
)
const stdout = output(result.stdout)
const stderr = output(result.stderr)
return makeHandle({
pid: ProcessId(0),
stdin: Sink.drain,
stdout,
stderr,
all: output(result.stdout + result.stderr),
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
isRunning: Effect.succeed(false),
exitCode: Effect.succeed(ExitCode(result.exitCode)),
kill: () => Effect.void,
unref: Effect.succeed(Effect.void),
})
return handle(result)
})
return makeSpawner(spawn)

View file

@ -25,6 +25,8 @@ describe("SimulationFileSystem", () => {
const fs = yield* AppFileSystem.Service
expect(yield* fs.readFileString(path.join(root, "README.md"))).toBe("hello")
expect(yield* fs.readFileString(path.join(root, "src/index.ts"))).toBe("export const value = 1\n")
expect(yield* fs.isDir(path.join(root, "src"))).toBe(true)
yield* fs.writeWithDirs(path.join(root, "tmp", "result.txt"), "done")
expect(yield* fs.readFileString(path.join(root, "tmp", "result.txt"))).toBe("done")
@ -55,6 +57,15 @@ describe("SimulationFileSystem", () => {
}),
)
it.effect("returns no upward matches when search starts outside the simulated root", () =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
expect(yield* fs.up({ targets: [".opencode"], start: "/Users/james", stop: "/Users/james" })).toEqual([])
expect(yield* fs.globUp("*.json", "/Users/james", "/Users/james")).toEqual([])
}),
)
const shared = new InMemoryFs()
const sharedIt = testEffect(SimulationFileSystem.layer({ root, fs: shared }))

View file

@ -0,0 +1,22 @@
{
"actions": [
{ "type": "writeFile", "path": ".git/HEAD", "content": "ref: refs/heads/main\n" },
{ "type": "writeFile", "path": ".git/config", "content": "[core]\n\trepositoryformatversion = 0\n\tbare = false\n[branch \"main\"]\n" },
{ "type": "writeFile", "path": "src/delta-23.txt", "content": "delta_23 note 1: alpha-41 glade-90.\ndelta_23 note 2: iris-73 juniper-33.\ndelta_23 note 3: ember-41 cedar-12." },
{ "type": "writeFile", "path": "src/cedar-20.txt", "content": "cedar_20 note 1: cedar-94 field-8.\ncedar_20 note 2: delta-21 juniper-19.\ncedar_20 note 3: bravo-42 juniper-35.\ncedar_20 note 4: cedar-25 delta-87." },
{ "type": "writeFile", "path": "src/bravo-86.txt", "content": "bravo_86 note 1: bravo-42 harbor-20.\nbravo_86 note 2: delta-92 cedar-97.\nbravo_86 note 3: juniper-72 juniper-90.\nbravo_86 note 4: cedar-15 field-91." },
{ "type": "writeFile", "path": "src/bravo-11.ts", "content": "const bravo_11Defaults = {\n retries: 1,\n timeout: 220,\n label: \"iris-21\",\n} as const\n\nexport async function loadbravo_11(input: Partial<typeof bravo_11Defaults> = {}) {\n const config = { ...bravo_11Defaults, ...input }\n await Promise.resolve()\n return {\n ...config,\n ready: config.retries > 0 && config.timeout > 0,\n }\n}\n" },
{ "type": "writeFile", "path": "src/iris-53.ts", "content": "export class iris_53Store {\n #items = new Map<string, number>()\n\n add(key: string, value: number) {\n this.#items.set(key, (this.#items.get(key) ?? 0) + value)\n return this\n }\n\n snapshot() {\n return Object.fromEntries(this.#items.entries())\n }\n}\n\nexport const iris_53StoreInstance = new iris_53Store().add(\"harbor-38\", 4)\n" },
{ "type": "writeFile", "path": "src/dir-2/delta-75.ts", "content": "export type delta_75Event =\n | { readonly type: \"created\"; readonly id: string; readonly count: number }\n | { readonly type: \"updated\"; readonly id: string; readonly fields: readonly string[] }\n | { readonly type: \"deleted\"; readonly id: string }\n\nexport function delta_75Label(event: delta_75Event) {\n if (event.type === \"created\") return `created:${event.id}:${event.count}`\n if (event.type === \"updated\") return `updated:${event.id}:${event.fields.length}`\n return `deleted:${event.id}`\n}\n\nexport const delta_75Sample: delta_75Event = { type: \"created\", id: \"ember-76\", count: 11 }\n" },
{ "type": "writeFile", "path": "src/dir-4/harbor-79.ts", "content": "export type harbor_79Event =\n | { readonly type: \"created\"; readonly id: string; readonly count: number }\n | { readonly type: \"updated\"; readonly id: string; readonly fields: readonly string[] }\n | { readonly type: \"deleted\"; readonly id: string }\n\nexport function harbor_79Label(event: harbor_79Event) {\n if (event.type === \"created\") return `created:${event.id}:${event.count}`\n if (event.type === \"updated\") return `updated:${event.id}:${event.fields.length}`\n return `deleted:${event.id}`\n}\n\nexport const harbor_79Sample: harbor_79Event = { type: \"created\", id: \"bravo-6\", count: 12 }\n" },
{ "type": "writeFile", "path": "src/dir-4/alpha-56.txt", "content": "alpha_56 note 1: iris-27 ember-92.\nalpha_56 note 2: iris-35 cedar-50.\nalpha_56 note 3: iris-71 glade-35.\nalpha_56 note 4: field-83 harbor-24.\nalpha_56 note 5: field-9 cedar-86.\nalpha_56 note 6: harbor-11 juniper-49." },
{ "type": "writeFile", "path": "_patches/001-delta-75.ts.patch", "content": "diff --git a/src/dir-2/delta-75.ts b/src/dir-2/delta-75.ts\nindex 9ed8735..df7ce67 100644\n--- a/src/dir-2/delta-75.ts\n+++ b/src/dir-2/delta-75.ts\n@@ -1,12 +1,8 @@\n export type delta_75Event =\n | { readonly type: \"created\"; readonly id: string; readonly count: number }\n- | { readonly type: \"updated\"; readonly id: string; readonly fields: readonly string[] }\n- | { readonly type: \"deleted\"; readonly id: string }\n \n export function delta_75Label(event: delta_75Event) {\n if (event.type === \"created\") return `created:${event.id}:${event.count}`\n- if (event.type === \"updated\") return `updated:${event.id}:${event.fields.length}`\n- return `deleted:${event.id}`\n }\n \n-export const delta_75Sample: delta_75Event = { type: \"created\", id: \"ember-76\", count: 11 }\n+\n" },
{ "type": "writeFile", "path": "_patches/002-harbor-79.ts.patch", "content": "diff --git a/src/dir-4/harbor-79.ts b/src/dir-4/harbor-79.ts\nindex d7ebf78..5e2b8bf 100644\n--- a/src/dir-4/harbor-79.ts\n+++ b/src/dir-4/harbor-79.ts\n@@ -1,12 +1,9 @@\n+\n export type harbor_79Event =\n- | { readonly type: \"created\"; readonly id: string; readonly count: number }\n- | { readonly type: \"updated\"; readonly id: string; readonly fields: readonly string[] }\n- | { readonly type: \"deleted\"; readonly id: string }\n \n export function harbor_79Label(event: harbor_79Event) {\n if (event.type === \"created\") return `created:${event.id}:${event.count}`\n if (event.type === \"updated\") return `updated:${event.id}:${event.fields.length}`\n- return `deleted:${event.id}`\n }\n \n export const harbor_79Sample: harbor_79Event = { type: \"created\", id: \"bravo-6\", count: 12 }\n" },
{ "type": "writeFile", "path": "_patches/003-bravo-11.ts.patch", "content": "diff --git a/src/bravo-11.ts b/src/bravo-11.ts\nindex eaf26e4..58519e4 100644\n--- a/src/bravo-11.ts\n+++ b/src/bravo-11.ts\n@@ -1,5 +1,4 @@\n const bravo_11Defaults = {\n- retries: 1,\n timeout: 220,\n label: \"iris-21\",\n } as const\n@@ -12,3 +11,20 @@ export async function loadbravo_11(input: Partial<typeof bravo_11Defaults> = {})\n ready: config.retries > 0 && config.timeout > 0,\n }\n }\n+\n+export function generated370Normalize(input: readonly string[]) {\n+ return input.map((item) => item.trim()).filter(Boolean).join(\"|\")\n+}\n+\n+export const generated160Config = {\n+ id: \"alpha-85\",\n+ retries: 5,\n+ flags: [\"iris-67\", \"ember-55\"],\n+} as const\n+\n+export const generated595Config = {\n+ id: \"alpha-55\",\n+ retries: 3,\n+ flags: [\"harbor-26\", \"harbor-77\"],\n+} as const\n+\n" },
{ "type": "writeFile", "path": "_patches/004-iris-53.ts.patch", "content": "diff --git a/src/iris-53.ts b/src/iris-53.ts\nindex 9497460..b8281a7 100644\n--- a/src/iris-53.ts\n+++ b/src/iris-53.ts\n@@ -1,9 +1,39 @@\n+\n+export function generated284Normalize(input: readonly string[]) {\n+ return input.map((item) => item.trim()).filter(Boolean).join(\",\")\n+}\n+\n+export type generated458State =\n+ | { readonly ok: true; readonly value: \"harbor-83\" }\n+ | { readonly ok: false; readonly reason: \"iris-97\" }\n+\n+export type generated838State =\n+ | { readonly ok: true; readonly value: \"harbor-34\" }\n+ | { readonly ok: false; readonly reason: \"delta-29\" }\n+\n+export const generated467Items = [\n+ { key: \"glade-76\", value: 11 },\n+ { key: \"alpha-80\", value: 9 },\n+ { key: \"field-83\", value: 16 },\n+] as const\n+\n+export const generated560Config = {\n+ id: \"alpha-44\",\n+ retries: 1,\n+ flags: [\"harbor-11\", \"field-37\"],\n+} as const\n+\n+export const generated713Items = [\n+ { key: \"juniper-98\", value: 2 },\n+ { key: \"juniper-83\", value: 47 },\n+ { key: \"bravo-78\", value: 33 },\n+] as const\n+\n export class iris_53Store {\n #items = new Map<string, number>()\n \n add(key: string, value: number) {\n this.#items.set(key, (this.#items.get(key) ?? 0) + value)\n- return this\n }\n \n snapshot() {\n" },
{ "type": "enqueueLLM", "scripts": [{ "steps": [[{ "type": "thinking", "content": "Read the fake git diff from _patches and summarize the generated changes." }, { "type": "text", "content": "The generated project contains eight seeded files under src and four fake git patches under _patches. The patches remove several event variants from delta_75 and harbor_79, remove retries from bravo_11 while adding generated config helpers, and expand iris_53 with generated normalize/state/item exports while changing add() to stop returning this. Main risks: type errors from removed union variants, bravo_11 still referencing config.retries after deletion, and fluent chaining breakage in iris_53StoreInstance." }]], "usage": { "inputTokens": 520, "outputTokens": 96, "totalTokens": 616 }, "finish": "stop" }] },
{ "type": "typeText", "text": "Review the generated project changes. Use the git diff from _patches and summarize the changed files, likely risks, and a recommended next step." },
{ "type": "pressEnter" },
{ "type": "wait", "ms": 1200 }
]
}

View file

@ -37,7 +37,25 @@ describe("SimulationSpawner", () => {
}),
)
it.effect("rejects non-shell commands", () =>
it.effect("fakes git discovery commands", () =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const [common, topLevel, revision] = yield* Effect.all(
[
spawner.spawn(ChildProcess.make("git", ["rev-parse", "--git-common-dir"], { cwd: root })).pipe(Effect.scoped),
spawner.spawn(ChildProcess.make("git", ["rev-parse", "--show-toplevel"], { cwd: root })).pipe(Effect.scoped),
spawner.spawn(ChildProcess.make("git", ["rev-list", "--max-parents=0", "HEAD"], { cwd: root })).pipe(Effect.scoped),
],
{ concurrency: 3 },
)
expect(yield* Stream.mkString(Stream.decodeText(common.stdout))).toBe(".git\n")
expect(yield* Stream.mkString(Stream.decodeText(topLevel.stdout))).toBe("/opencode\n")
expect(yield* Stream.mkString(Stream.decodeText(revision.stdout))).toBe("0000000000000000000000000000000000000000\n")
}),
)
it.effect("rejects unsupported non-shell commands", () =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const exit = yield* spawner.spawn(ChildProcess.make("git", ["status"], { cwd: root })).pipe(Effect.scoped, Effect.exit)