Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
#!/bin/sh
set -e
# git exports GIT_DIR/GIT_INDEX_FILE into hooks (always, in linked worktrees),
# which hijacks the real-git test repos the suite creates under tmpdir.
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY GIT_COMMON_DIR GIT_PREFIX
bun run verify
bun run verify -- --check
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ spec authority and read order. `src/domain/task/metadata.ts` is the authoritativ
- Use Bun 1.3 or newer; CI pins 1.3.14. Run `bun install`, then `bun run setup` once after cloning to
enable the `.githooks/pre-commit` hook.
- `bun run verify` is the merge gate and the exact CI command. It runs all built-in `verifyx`
checks, project overrides, tests, and package validation in parallel. Local runs fix formatting;
CI only checks it.
checks (lint, format, type-check, unused-code, circular-deps, duplicate-code, and native gates),
project overrides, tests, and package validation in parallel. Local pre-commit runs check-only via
`bun run verify -- --check`; CI runs the same gate under `CI`, which also selects check-only.
- Run one project check with `bun run verify:format`, `bun run verify:lint`,
`bun run verify:check-types`, or `bun run verify:package`.
- Run the full suite with `bun run test`, not bare `bun test`. The script supplies
Expand Down
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ update checks; only a published bare/`@latest` npm install exercises that path.
bun run verify
```

This runs every built-in `verifyx` check plus formatting, linting, type-checking, tests, build, and
package validation — the exact same thing CI runs. If it passes, your change is ready. One helper:
This runs every built-in `verifyx` check (including unused-code, circular-deps, and duplicate-code)
plus formatting, linting, type-checking, tests, build, and package validation — the exact same
thing CI runs. The pre-commit hook runs `bun run verify -- --check` so it fails on dirty staged
content instead of rewriting files mid-commit. If it passes, your change is ready. One helper:

- `bun run test` — just the tests (use this, not a bare `bun test`).

Expand Down
411 changes: 400 additions & 11 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion bunfig.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ exclude = ["references/**"]
# automatic JSX runtime evaluates every JSX expression exactly once and nothing on
# the board ever repaints. The opencode host applies this same transform itself when
# importing raw plugin .tsx; bun test has no host, so tests opt in here.
preload = ["@opentui/solid/preload"]
preload = ["@opentui/solid/preload", "./test/preload/git-isolation.ts"]
5 changes: 5 additions & 0 deletions knip.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"$schema": "https://unpkg.com/knip/schema.json",
"entry": ["src/server.ts", "src/tui.tsx", "scripts/*.{ts,mjs}", "test/**/*.test.ts", "docs/.vitepress/config.mts"],
"project": ["src/**/*.{ts,tsx}", "scripts/**/*.ts", "test/**/*.{ts,tsx}", "docs/.vitepress/**/*.{mts,ts}"]
}
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"verify:format:fix": "prettier --write .",
"verify:lint": "oxlint --ignore-pattern node_modules .",
"verify:check-types": "tsc --noEmit -p tsconfig.test.json",
"verify:duplicate-code": "jscpd --format typescript,tsx --exit-code 1 --ignore \"**/*.test.*,**/tui/dialogs/create-task.tsx,**/tui/dialogs/findings-review.tsx,**/tui/routes/settings.tsx\" -r consoleFull src",
"verify:package": "bun scripts/package-check.ts",
"test:ci": "bun run test",
"docs:dev": "vitepress dev docs",
Expand Down Expand Up @@ -100,9 +101,12 @@
"@types/semver": "7.7.1",
"babel-plugin-module-resolver": "5.0.2",
"babel-preset-solid": "1.9.12",
"jscpd": "5.0.12",
"knip": "6.25.0",
"oxlint": "1.60.0",
"prettier": "3.6.2",
"semantic-release": "25.0.5",
"skott": "0.35.11",
"solid-js": "1.9.10",
"typescript": "5.8.2",
"vitepress": "1.6.4"
Expand Down
8 changes: 1 addition & 7 deletions src/checks/runner.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import { join } from "node:path"

export type CommandSpec = {
name: string
cwd: string
command: string
scope?: string[]
}
import type { CommandSpec } from "../domain/task/types"

type CommandStepResult = {
name: string
Expand Down
2 changes: 1 addition & 1 deletion src/domain/task/commands.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CommandSpec } from "../../checks/runner"
import type { CommandSpec } from "./types"

export type TaskScope = { values: string[]; custom?: string }

Expand Down
4 changes: 4 additions & 0 deletions src/domain/task/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ export function kagan(metadata?: Record<string, unknown>): Metadata {
return MetadataSchema.parse(rawKagan(metadata) ?? {})
}

export function getStatus(metadata?: Record<string, unknown>): ColumnType {
return kagan(metadata).status ?? "backlog"
}

export function validMode(raw: unknown): IntakeMode | undefined {
return IntakeModeSchema.parse(raw)
}
6 changes: 6 additions & 0 deletions src/domain/task/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,9 @@ export const COLUMNS: readonly ColumnType[] = ["backlog", "in_progress", "review
export const DEFAULT_IN_PROGRESS_CAP = 2
export type ModelRef = { providerID: string; modelID: string }
export type HelperRole = "intake" | "validator"
export type CommandSpec = {
name: string
cwd: string
command: string
scope?: string[]
}
4 changes: 1 addition & 3 deletions src/git/merge.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { currentBranch, type GitResult, type GitRunner } from "./runner"
import { currentBranch, type GitResult, type GitRunner, type MergeResult } from "./runner"
import { mergeSquash } from "./squash"

async function commitAll(run: GitRunner, worktree: string, message: string): Promise<GitResult | undefined> {
Expand All @@ -12,8 +12,6 @@ async function commitAll(run: GitRunner, worktree: string, message: string): Pro
return run(["commit", "-m", message], worktree)
}

export type MergeResult = { ok: boolean; message: string }

async function dirtyMainWorktreeMessage(
run: GitRunner,
checkoutDir: string,
Expand Down
13 changes: 11 additions & 2 deletions src/git/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,17 @@ import { join, resolve } from "node:path"

export type GitResult = { code: number; stdout: string; stderr: string }
export type GitRunner = (args: string[], cwd: string) => Promise<GitResult>
export type MergeResult = { ok: boolean; message: string }

export function bunGitRunner(): GitRunner {
return async (args, cwd) => {
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe", stdin: "ignore" })
const proc = Bun.spawn(["git", ...args], {
cwd,
env: process.env,
stdout: "pipe",
stderr: "pipe",
stdin: "ignore",
})
const [stdout, stderr, code] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
Expand Down Expand Up @@ -79,7 +86,9 @@ export function isGitPushCommand(command: string): boolean {
}

function taskWorktreePath(mainWorktree: string, slug: string): string {
return join(homedir(), ".kagan", "worktrees", Bun.hash(mainWorktree).toString(16), slug)
// KAGAN_WORKTREE_ROOT exists for test isolation (see test/preload/git-isolation.ts).
const root = process.env.KAGAN_WORKTREE_ROOT ?? join(homedir(), ".kagan", "worktrees")
return join(root, Bun.hash(mainWorktree).toString(16), slug)
}

export async function baseBranchFreshness(
Expand Down
3 changes: 1 addition & 2 deletions src/git/squash.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { GitRunner } from "./runner"
import type { MergeResult } from "./merge"
import type { GitRunner, MergeResult } from "./runner"

function hasUserWorkDuringMerge(porcelain: string): boolean {
for (const line of porcelain.split("\n")) {
Expand Down
7 changes: 1 addition & 6 deletions src/server/data.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { formatTaskRef, parseTaskRefs } from "../domain/handoff"
import { kagan } from "../domain/task/metadata"
import type { ColumnType } from "../domain/task/types"
import { getStatus, kagan } from "../domain/task/metadata"

type SessionData = {
title?: string
Expand All @@ -13,10 +12,6 @@ export type EventInfo = SessionData & { id: string }

type ListedSession = SessionData & { id: string }

export function getStatus(metadata?: Record<string, unknown>): ColumnType {
return kagan(metadata).status ?? "backlog"
}

export async function getSessionData(input: PluginInput, sessionID: string): Promise<SessionData | undefined> {
const result = await input.client.session.get({ path: { id: sessionID }, throwOnError: true })
return result.data as SessionData | undefined
Expand Down
3 changes: 1 addition & 2 deletions src/server/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { composeStartPrompt } from "../domain/handoff"
import { lastAssistantText } from "../domain/session/messages"
import { countInProgressForMove, columnMoveDenyReason, inProgressCap } from "../domain/task/policy"
import { kagan } from "../domain/task/metadata"
import { getStatus, kagan } from "../domain/task/metadata"
import type { ColumnType } from "../domain/task/types"
import { patchKagan } from "./session/patch"
import {
errorMessage,
extractErrorMessage,
getSessionData,
getStatus,
listSessions,
resolveTaskRefs,
sessionMessages,
Expand Down
30 changes: 30 additions & 0 deletions src/server/helpers/child.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { HelperRole } from "../../domain/task/types"
import { patchKagan } from "../session/patch"

export async function createHelperChild(
input: PluginInput,
parentSessionID: string,
role: HelperRole,
title: string,
): Promise<string | undefined> {
const parentField = role === "intake" ? "intakeParent" : "validatorParent"
const sessionIDField = role === "intake" ? "intakeSessionID" : "validatorSessionID"
const child = await input.client.session.create({
body: {
parentID: parentSessionID,
title,
metadata: {
kagan: {
[parentField]: parentSessionID,
role,
},
},
},
throwOnError: true,
} as Parameters<typeof input.client.session.create>[0])
const childID = child.data?.id
if (!childID) return undefined
await patchKagan(input.client, parentSessionID, { [sessionIDField]: childID })
return childID
}
20 changes: 2 additions & 18 deletions src/server/intake.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { parseOptions } from "../domain/options"
import type { TaskScope } from "../domain/task/commands"
import { patchKagan } from "./session/patch"
import { createHelperChild } from "./helpers/child"

function formatScope(scope?: TaskScope): string | undefined {
if (!scope) return undefined
Expand All @@ -17,25 +17,9 @@ export async function spawnIntake(
task: { title: string; description?: string; references?: string; scope?: TaskScope },
options?: Record<string, unknown>,
): Promise<string | undefined> {
const child = await input.client.session.create({
body: {
parentID: parentSessionID,
title: "task prep",
metadata: {
kagan: {
intakeParent: parentSessionID,
role: "intake",
},
},
},
throwOnError: true,
} as Parameters<typeof input.client.session.create>[0])

const childID = child.data?.id
const childID = await createHelperChild(input, parentSessionID, "intake", "task prep")
if (!childID) return undefined

await patchKagan(input.client, parentSessionID, { intakeSessionID: childID })

const scope = formatScope(task.scope)
const promptText = [
"A human is about to start this task:",
Expand Down
20 changes: 10 additions & 10 deletions src/server/session/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,21 @@ import { lockSessionMetadata, mergeKagan } from "../../domain/session/metadata"
import { helper } from "../../domain/task/policy"
import type { HelperRole } from "../../domain/task/types"

async function readSessionMetadata(client: PluginInput["client"], sessionID: string): Promise<Record<string, unknown>> {
const result = await client.session.get({ path: { id: sessionID }, throwOnError: true })
return ((result.data as { metadata?: Record<string, unknown> } | undefined)?.metadata ?? {}) as Record<
string,
unknown
>
}

export async function patchKagan(
client: PluginInput["client"],
sessionID: string,
partial: Record<string, unknown>,
): Promise<void> {
await lockSessionMetadata(sessionID, async () => {
const result = await client.session.get({ path: { id: sessionID }, throwOnError: true })
const metadata = ((result.data as { metadata?: Record<string, unknown> } | undefined)?.metadata ?? {}) as Record<
string,
unknown
>
const metadata = await readSessionMetadata(client, sessionID)
await client.session.update({
path: { id: sessionID },
body: { metadata: mergeKagan(metadata, partial) },
Expand All @@ -29,11 +33,7 @@ export async function claimHelperSpawn(
): Promise<boolean> {
let claimed = false
await lockSessionMetadata(sessionID, async () => {
const result = await client.session.get({ path: { id: sessionID }, throwOnError: true })
const metadata = ((result.data as { metadata?: Record<string, unknown> } | undefined)?.metadata ?? {}) as Record<
string,
unknown
>
const metadata = await readSessionMetadata(client, sessionID)
const before = helper(metadata, role)
if (before.outcome !== undefined || before.sessionID !== undefined) return
claimed = true
Expand Down
25 changes: 7 additions & 18 deletions src/server/validator/spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Intake } from "../../domain/task/intake"
import type { ModelRef } from "../../domain/task/types"
import type { CheckResult } from "../../checks/runner"
import { parseOptions } from "../../domain/options"
import { patchKagan } from "../session/patch"
import { createHelperChild } from "../helpers/child"
import { buildValidatorPrompt } from "./prompt"

function isModelRef(value: unknown): value is ModelRef {
Expand Down Expand Up @@ -53,25 +53,14 @@ export async function spawnValidator(
): Promise<string | undefined> {
const model = resolveValidatorModel(options, context.generation, context.builderModel)

const child = await input.client.session.create({
body: {
parentID: parentSessionID,
title: context.generation > 1 ? `review #${context.generation}` : "review",
metadata: {
kagan: {
validatorParent: parentSessionID,
role: "validator",
},
},
},
throwOnError: true,
} as Parameters<typeof input.client.session.create>[0])

const childID = child.data?.id
const childID = await createHelperChild(
input,
parentSessionID,
"validator",
context.generation > 1 ? `review #${context.generation}` : "review",
)
if (!childID) return undefined

await patchKagan(input.client, parentSessionID, { validatorSessionID: childID })

const promptText = buildValidatorPrompt(diffs, context)

const body: Record<string, unknown> = {
Expand Down
15 changes: 1 addition & 14 deletions src/tui/board/board.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show }
import { maybeShowOnboarding } from "../dialogs/onboarding"
import { Column } from "./column"
import { BOARD_BINDINGS, createBoardCommands, footerHints, HelpOverlay, type BoardStore } from "./commands"
import { SIDE_BORDER_CHARS } from "./borders"
import { COLUMNS, type ColumnType } from "../../domain/task/types"
import { version } from "../../../package.json"
import { useRendererDimensions } from "../renderer"
Expand Down Expand Up @@ -137,20 +138,6 @@ function Footer(props: { api: TuiPluginApi; store: BoardStore; hints: () => { ke
)
}

const SIDE_BORDER_CHARS: BorderCharacters = {
topLeft: "",
topRight: "",
bottomLeft: "",
bottomRight: "",
horizontal: " ",
vertical: "┃",
topT: "",
bottomT: "",
leftT: "",
rightT: "",
cross: "",
}

// api.ui.toast doesn't render on plugin routes — see the Notice rationale in store.ts.
function Notice(props: { api: TuiPluginApi; store: BoardStore }) {
const theme = () => props.api.theme.current
Expand Down
15 changes: 15 additions & 0 deletions src/tui/board/borders.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { BorderCharacters } from "@opentui/core"

export const SIDE_BORDER_CHARS: BorderCharacters = {
topLeft: "",
topRight: "",
bottomLeft: "",
bottomRight: "",
horizontal: " ",
vertical: "┃",
topT: "",
bottomT: "",
leftT: "",
rightT: "",
cross: "",
}
Loading
Loading