diff --git a/docs/orca-cli-focused-v1-status.md b/docs/orca-cli-focused-v1-status.md index 9884fb0ed8a..bc4d1808b80 100644 --- a/docs/orca-cli-focused-v1-status.md +++ b/docs/orca-cli-focused-v1-status.md @@ -34,6 +34,7 @@ The following commands are implemented against the running Orca app: - `orca repo search-refs` - `orca worktree list` - `orca worktree show` +- `orca worktree current` - `orca worktree create` - `orca worktree set` - `orca worktree rm` @@ -50,14 +51,15 @@ The following commands are implemented against the running Orca app: Focused v1 supports the complete agent loop for worktree orchestration: 1. Inspect current Orca runtime availability. -2. Discover repos indirectly through existing worktrees and summary views. -3. Create a new worktree in a chosen repo. -4. Attach or update worktree metadata like display name, linked issue, and comment. -5. Inspect many worktrees at once with `worktree ps`. -6. Discover live terminal handles in a worktree. -7. Read terminal output with bounded token-efficient reads. -8. Send input back to the terminal. -9. Stop live terminals for a worktree when needed. +2. Discover the enclosing Orca-managed worktree from the current shell directory. +3. Discover repos indirectly through existing worktrees and summary views. +4. Create a new worktree in a chosen repo. +5. Attach or update worktree metadata like display name, linked issue, and comment. +6. Inspect many worktrees at once with `worktree ps`. +7. Discover live terminal handles in a worktree. +8. Read terminal output with bounded token-efficient reads. +9. Send input back to the terminal. +10. Stop live terminals for a worktree when needed. It also covers the adjacent setup tasks needed to make worktree creation usable: diff --git a/docs/orca-cli-v1-spec.md b/docs/orca-cli-v1-spec.md index dbd0553466b..1930c3c1dcf 100644 --- a/docs/orca-cli-v1-spec.md +++ b/docs/orca-cli-v1-spec.md @@ -375,6 +375,20 @@ Status: orca worktree show --worktree branch:feature/foo --json ``` +Focused v1 also accepts `active` / `current` as CLI-only shortcuts for worktree +selectors. The CLI resolves them from the caller's current directory and sends a +`path:` selector to the runtime. + +## `orca worktree current` + +Status: + +- `v1-now` + +```bash +orca worktree current --json +``` + ## `orca worktree create` Status: @@ -404,6 +418,7 @@ Status: ```bash orca worktree set --worktree branch:feature/foo --display-name "Parser" --issue 123 --comment "parser work" --json +orca worktree set --worktree active --comment "parser work" --json ``` ## `orca worktree rm` diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index 37b0b4b86b5..2dd49ec0383 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -97,9 +97,11 @@ orca repo search-refs --repo id: --query main --limit 10 --json ```bash orca worktree list --repo id: --json orca worktree ps --json +orca worktree current --json orca worktree show --worktree id: --json orca worktree create --repo id: --name my-task --issue 123 --comment "seed" --json orca worktree set --worktree id: --display-name "My Task" --json +orca worktree set --worktree active --comment "waiting on review" --json orca worktree rm --worktree id: --force --json ``` @@ -109,6 +111,7 @@ Worktree selectors supported in focused v1: - `path:` - `branch:` - `issue:` +- `active` / `current` to resolve the enclosing Orca-managed worktree from the shell `cwd` ### Terminal @@ -131,6 +134,7 @@ Why: terminal handles are runtime-scoped and may go stale after reloads. If Orca - Treat Orca as the source of truth for Orca worktree and terminal tasks. Do not mix Orca-managed state with ad hoc git worktree commands unless Orca explicitly cannot perform the requested action. - Prefer `--json` for all machine-driven use. - Use `worktree ps` as the first summary view when many worktrees may exist. +- Use `worktree current` or `--worktree active` when the agent is already running inside the target worktree. - Use `terminal list` to reacquire handles after Orca reloads. - Use `terminal read` before `terminal send` unless the next input is obvious. - Use `terminal wait --for exit` only when the task actually depends on process completion. diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts new file mode 100644 index 00000000000..5904623c9ed --- /dev/null +++ b/src/cli/index.test.ts @@ -0,0 +1,301 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const callMock = vi.fn() + +vi.mock('./runtime-client', () => { + class RuntimeClient { + call = callMock + getCliStatus = vi.fn() + openOrca = vi.fn() + } + + class RuntimeClientError extends Error { + readonly code: string + + constructor(code: string, message: string) { + super(message) + this.code = code + } + } + + class RuntimeRpcFailureError extends RuntimeClientError { + readonly response: unknown + + constructor(response: unknown) { + super('runtime_error', 'runtime_error') + this.response = response + } + } + + return { + RuntimeClient, + RuntimeClientError, + RuntimeRpcFailureError + } +}) + +import { buildCurrentWorktreeSelector, main, normalizeWorktreeSelector } from './index' + +describe('orca cli worktree awareness', () => { + beforeEach(() => { + callMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('builds the current worktree selector from cwd', () => { + expect(buildCurrentWorktreeSelector('/tmp/repo/feature')).toBe('path:/tmp/repo/feature') + }) + + it('normalizes active/current worktree selectors to cwd', () => { + expect(normalizeWorktreeSelector('active', '/tmp/repo/feature')).toBe('path:/tmp/repo/feature') + expect(normalizeWorktreeSelector('current', '/tmp/repo/feature')).toBe('path:/tmp/repo/feature') + expect(normalizeWorktreeSelector('branch:feature/foo', '/tmp/repo/feature')).toBe( + 'branch:feature/foo' + ) + }) + + it('shows the enclosing worktree for `worktree current`', async () => { + callMock + .mockResolvedValueOnce({ + id: 'req_list', + ok: true, + result: { + worktrees: [ + { + id: 'repo::/tmp/repo/feature', + repoId: 'repo', + path: '/tmp/repo/feature', + branch: 'feature/foo', + linkedIssue: null, + git: { + path: '/tmp/repo/feature', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: false + }, + displayName: '', + comment: '' + } + ], + totalCount: 1, + truncated: false + }, + _meta: { + runtimeId: 'runtime-1' + } + }) + .mockResolvedValueOnce({ + id: 'req_1', + ok: true, + result: { + worktree: { + id: 'repo::/tmp/repo/feature', + branch: 'feature/foo', + path: '/tmp/repo/feature' + } + }, + _meta: { + runtimeId: 'runtime-1' + } + }) + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['worktree', 'current', '--json'], '/tmp/repo/feature/src') + + expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.list', { + limit: 10_000 + }) + expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', { + worktree: 'path:/tmp/repo/feature' + }) + expect(logSpy).toHaveBeenCalledTimes(1) + }) + + it('uses cwd when active is passed to worktree.set', async () => { + callMock + .mockResolvedValueOnce({ + id: 'req_list', + ok: true, + result: { + worktrees: [ + { + id: 'repo::/tmp/repo', + repoId: 'repo', + path: '/tmp/repo', + branch: 'main', + linkedIssue: null, + git: { + path: '/tmp/repo', + head: 'aaa', + branch: 'main', + isBare: false, + isMainWorktree: false + }, + displayName: '', + comment: '' + }, + { + id: 'repo::/tmp/repo/feature', + repoId: 'repo', + path: '/tmp/repo/feature', + branch: 'feature/foo', + linkedIssue: null, + git: { + path: '/tmp/repo/feature', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: false + }, + displayName: '', + comment: '' + } + ], + totalCount: 2, + truncated: false + }, + _meta: { + runtimeId: 'runtime-1' + } + }) + .mockResolvedValueOnce({ + id: 'req_1', + ok: true, + result: { + worktree: { + id: 'repo::/tmp/repo/feature', + branch: 'feature/foo', + path: '/tmp/repo/feature', + comment: 'hello' + } + }, + _meta: { + runtimeId: 'runtime-1' + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['worktree', 'set', '--worktree', 'active', '--comment', 'hello', '--json'], + '/tmp/repo/feature/src' + ) + + expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.set', { + worktree: 'path:/tmp/repo/feature', + displayName: undefined, + linkedIssue: undefined, + comment: 'hello' + }) + }) + + it('uses the resolved enclosing worktree for other worktree consumers', async () => { + callMock + .mockResolvedValueOnce({ + id: 'req_list', + ok: true, + result: { + worktrees: [ + { + id: 'repo::/tmp/repo/feature', + repoId: 'repo', + path: '/tmp/repo/feature', + branch: 'feature/foo', + linkedIssue: null, + git: { + path: '/tmp/repo/feature', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: false + }, + displayName: '', + comment: '' + } + ], + totalCount: 1, + truncated: false + }, + _meta: { + runtimeId: 'runtime-1' + } + }) + .mockResolvedValueOnce({ + id: 'req_show', + ok: true, + result: { + worktree: { + id: 'repo::/tmp/repo/feature', + branch: 'feature/foo', + path: '/tmp/repo/feature' + } + }, + _meta: { + runtimeId: 'runtime-1' + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['worktree', 'show', '--worktree', 'current', '--json'], '/tmp/repo/feature/src') + + expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.show', { + worktree: 'path:/tmp/repo/feature' + }) + }) + + it('uses the resolved enclosing worktree for terminal consumers', async () => { + callMock + .mockResolvedValueOnce({ + id: 'req_list', + ok: true, + result: { + worktrees: [ + { + id: 'repo::/tmp/repo/feature', + repoId: 'repo', + path: '/tmp/repo/feature', + branch: 'feature/foo', + linkedIssue: null, + git: { + path: '/tmp/repo/feature', + head: 'abc', + branch: 'feature/foo', + isBare: false, + isMainWorktree: false + }, + displayName: '', + comment: '' + } + ], + totalCount: 1, + truncated: false + }, + _meta: { + runtimeId: 'runtime-1' + } + }) + .mockResolvedValueOnce({ + id: 'req_term', + ok: true, + result: { + terminals: [], + totalCount: 0, + truncated: false + }, + _meta: { + runtimeId: 'runtime-1' + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['terminal', 'list', '--worktree', 'active', '--json'], '/tmp/repo/feature/src') + + expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.list', { + worktree: 'path:/tmp/repo/feature', + limit: undefined + }) + }) +}) diff --git a/src/cli/index.ts b/src/cli/index.ts index 40d62f6ca71..442e4462fd1 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node /* eslint-disable max-lines -- Why: the public CLI entrypoint keeps command dispatch in one place so the bundled shell command and development fallback stay behaviorally identical. */ +import { isAbsolute, relative, resolve as resolvePath } from 'path' import type { CliStatusResult, RuntimeRepoList, @@ -95,6 +96,16 @@ const COMMAND_SPECS: CommandSpec[] = [ usage: 'orca worktree show --worktree [--json]', allowedFlags: [...GLOBAL_FLAGS, 'worktree'] }, + { + path: ['worktree', 'current'], + summary: 'Show the Orca-managed worktree for the current directory', + usage: 'orca worktree current [--json]', + allowedFlags: [...GLOBAL_FLAGS], + notes: [ + 'Resolves the current shell directory to a path: selector so agents can target the enclosing Orca worktree without spelling out $PWD.' + ], + examples: ['orca worktree current', 'orca worktree current --json'] + }, { path: ['worktree', 'create'], summary: 'Create a new Orca-managed worktree', @@ -161,8 +172,8 @@ const COMMAND_SPECS: CommandSpec[] = [ } ] -async function main(): Promise { - const parsed = parseArgs(process.argv.slice(2)) +export async function main(argv = process.argv.slice(2), cwd = process.cwd()): Promise { + const parsed = parseArgs(argv) const helpPath = resolveHelpPath(parsed) if (helpPath !== null) { printHelp(helpPath) @@ -237,7 +248,7 @@ async function main(): Promise { if (matches(commandPath, ['terminal', 'list'])) { const result = await client.call('terminal.list', { - worktree: getOptionalStringFlag(parsed.flags, 'worktree'), + worktree: await getOptionalWorktreeSelector(parsed.flags, 'worktree', cwd, client), limit: getOptionalPositiveIntegerFlag(parsed.flags, 'limit') }) return printResult(result, json, formatTerminalList) @@ -288,7 +299,7 @@ async function main(): Promise { if (matches(commandPath, ['terminal', 'stop'])) { const result = await client.call<{ stopped: number }>('terminal.stop', { - worktree: getRequiredStringFlag(parsed.flags, 'worktree') + worktree: await getRequiredWorktreeSelector(parsed.flags, 'worktree', cwd, client) }) return printResult(result, json, (value) => `Stopped ${value.stopped} terminals.`) } @@ -310,7 +321,14 @@ async function main(): Promise { if (matches(commandPath, ['worktree', 'show'])) { const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', { - worktree: getRequiredStringFlag(parsed.flags, 'worktree') + worktree: await getRequiredWorktreeSelector(parsed.flags, 'worktree', cwd, client) + }) + return printResult(result, json, formatWorktreeShow) + } + + if (matches(commandPath, ['worktree', 'current'])) { + const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', { + worktree: await resolveCurrentWorktreeSelector(cwd, client) }) return printResult(result, json, formatWorktreeShow) } @@ -328,7 +346,7 @@ async function main(): Promise { if (matches(commandPath, ['worktree', 'set'])) { const result = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.set', { - worktree: getRequiredStringFlag(parsed.flags, 'worktree'), + worktree: await getRequiredWorktreeSelector(parsed.flags, 'worktree', cwd, client), displayName: getOptionalStringFlag(parsed.flags, 'display-name'), linkedIssue: getOptionalNullableNumberFlag(parsed.flags, 'issue'), comment: getOptionalStringFlag(parsed.flags, 'comment') @@ -338,7 +356,7 @@ async function main(): Promise { if (matches(commandPath, ['worktree', 'rm'])) { const result = await client.call<{ removed: boolean }>('worktree.rm', { - worktree: getRequiredStringFlag(parsed.flags, 'worktree'), + worktree: await getRequiredWorktreeSelector(parsed.flags, 'worktree', cwd, client), force: parsed.flags.get('force') === true }) return printResult(result, json, (value) => `removed: ${value.removed}`) @@ -370,7 +388,7 @@ async function main(): Promise { } } -function parseArgs(argv: string[]): ParsedArgs { +export function parseArgs(argv: string[]): ParsedArgs { const commandPath: string[] = [] const flags = new Map() @@ -394,7 +412,7 @@ function parseArgs(argv: string[]): ParsedArgs { return { commandPath, flags } } -function resolveHelpPath(parsed: ParsedArgs): string[] | null { +export function resolveHelpPath(parsed: ParsedArgs): string[] | null { if (parsed.commandPath[0] === 'help') { return parsed.commandPath.slice(1) } @@ -404,7 +422,7 @@ function resolveHelpPath(parsed: ParsedArgs): string[] | null { return null } -function validateCommandAndFlags(parsed: ParsedArgs): void { +export function validateCommandAndFlags(parsed: ParsedArgs): void { const spec = findCommandSpec(parsed.commandPath) if (!spec) { throw new RuntimeClientError( @@ -423,7 +441,7 @@ function validateCommandAndFlags(parsed: ParsedArgs): void { } } -function findCommandSpec(commandPath: string[]): CommandSpec | undefined { +export function findCommandSpec(commandPath: string[]): CommandSpec | undefined { return COMMAND_SPECS.find((spec) => matches(spec.path, commandPath)) } @@ -447,6 +465,74 @@ function getOptionalStringFlag( return typeof value === 'string' && value.length > 0 ? value : undefined } +export function buildCurrentWorktreeSelector(cwd: string): string { + return `path:${resolvePath(cwd)}` +} + +export function normalizeWorktreeSelector(selector: string, cwd: string): string { + if (selector === 'active' || selector === 'current') { + return buildCurrentWorktreeSelector(cwd) + } + return selector +} + +function isWithinPath(parentPath: string, childPath: string): boolean { + const relativePath = relative(parentPath, childPath) + return relativePath === '' || (!relativePath.startsWith('..') && !isAbsolute(relativePath)) +} + +async function resolveCurrentWorktreeSelector(cwd: string, client: RuntimeClient): Promise { + const currentPath = resolvePath(cwd) + const worktrees = await client.call('worktree.list', { + limit: 10_000 + }) + const enclosingWorktree = worktrees.result.worktrees + .filter((worktree) => isWithinPath(resolvePath(worktree.path), currentPath)) + .sort((left, right) => right.path.length - left.path.length)[0] + + if (!enclosingWorktree) { + throw new RuntimeClientError( + 'selector_not_found', + `No Orca-managed worktree contains the current directory: ${currentPath}` + ) + } + + // Why: users expect "active/current" to mean the enclosing managed worktree + // even from nested subdirectories. The CLI resolves that shell-local concept + // to the deepest matching worktree root, then hands the runtime a normal + // path selector so selector semantics stay centralized in one layer. + return buildCurrentWorktreeSelector(enclosingWorktree.path) +} + +async function getOptionalWorktreeSelector( + flags: Map, + name: string, + cwd: string, + client: RuntimeClient +): Promise { + const value = getOptionalStringFlag(flags, name) + if (!value) { + return undefined + } + if (value === 'active' || value === 'current') { + return await resolveCurrentWorktreeSelector(cwd, client) + } + return normalizeWorktreeSelector(value, cwd) +} + +async function getRequiredWorktreeSelector( + flags: Map, + name: string, + cwd: string, + client: RuntimeClient +): Promise { + const value = getRequiredStringFlag(flags, name) + if (value === 'active' || value === 'current') { + return await resolveCurrentWorktreeSelector(cwd, client) + } + return normalizeWorktreeSelector(value, cwd) +} + function getOptionalNumberFlag( flags: Map, name: string @@ -487,7 +573,7 @@ function getOptionalNullableNumberFlag( return getOptionalNumberFlag(flags, name) } -function matches(actual: string[], expected: string[]): boolean { +export function matches(actual: string[], expected: string[]): boolean { return ( actual.length === expected.length && actual.every((value, index) => value === expected[index]) ) @@ -685,6 +771,7 @@ Repos: Worktrees: worktree list List Orca-managed worktrees worktree show Show one worktree + worktree current Show the Orca-managed worktree for the current directory worktree create Create a new Orca-managed worktree worktree set Update Orca metadata for a worktree worktree rm Remove a worktree from Orca and git @@ -704,6 +791,7 @@ Common Commands: orca worktree list [--repo ] [--limit ] [--json] orca worktree create --repo --name [--base-branch ] [--issue ] [--comment ] [--json] orca worktree show --worktree [--json] + orca worktree current [--json] orca worktree set --worktree [--display-name ] [--issue ] [--comment ] [--json] orca worktree rm --worktree [--force] [--json] orca worktree ps [--limit ] [--json] @@ -721,7 +809,7 @@ Common Commands: Selectors: --repo Registered repo selector such as id:, name:, or path: - --worktree Worktree selector such as id:, branch:, issue:, or path: + --worktree Worktree selector such as id:, branch:, issue:, path:, or active/current --terminal Runtime-issued terminal handle returned by \`orca terminal list --json\` Terminal Send Options: @@ -747,6 +835,8 @@ Examples: $ orca repo list $ orca worktree create --repo name:orca --name cli-test-1 --issue 273 $ orca worktree show --worktree branch:Jinwoo-H/cli + $ orca worktree current + $ orca worktree set --worktree active --comment "waiting on review" $ orca worktree ps --limit 10 $ orca terminal list --worktree path:/Users/me/orca/workspaces/orca/cli-test-1 --json $ orca terminal send --terminal term_123 --text "hi" --enter @@ -812,10 +902,12 @@ function formatFlagHelp(flag: string): string { text: '--text Text to send to the terminal', 'timeout-ms': '--timeout-ms Maximum wait time before timing out', worktree: - '--worktree Worktree selector such as id:, branch:, issue:, or path:' + '--worktree Worktree selector such as id:, branch:, issue:, path:, or active/current' } return helpByFlag[flag] ?? `--${flag}` } -void main() +if (require.main === module) { + void main() +} diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 962148556aa..d4b3904218a 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -261,6 +261,12 @@ describe('OrcaRuntimeService', () => { }) }) + it('does not interpret active as a runtime-global worktree selector', async () => { + const runtime = new OrcaRuntimeService(store) + + await expect(runtime.showManagedWorktree('active')).rejects.toThrow('selector_not_found') + }) + it('reads bounded terminal output and writes through the PTY controller', async () => { const writes: string[] = [] const runtime = new OrcaRuntimeService(store)