Skip to content
Open
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
18 changes: 18 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,24 @@ orch config edit # Open in $EDITOR

</details>

<details>
<summary><strong>Native role workflow</strong></summary>

Run a confirmed, sequential `Supervisor -> Implementer -> Reviewer` workflow. The Adviser is optional and, when selected, is limited to one call.

```bash
printf '%s' 'Add input validation and tests' | orch workflow --yes \
--supervisor codex --supervisor-model <model> \
--implementer claude --implementer-model <model> \
--reviewer codex --reviewer-model <model>
```

Omit `--yes` and use `--objective-file <path>` for an interactive final confirmation. The summary shows the selected CLIs/models, target branch and SHA, required checks, attempt limits, and the Implementer's autonomous write access inside its dedicated Git worktree before any model CLI starts.

The workflow currently supports Codex for Supervisor/Reviewer and Claude for Implementer/Adviser. Grok, Antigravity, and other adapters are rejected for this command because a safe prompt transport is not established. The target repository must use one npm lockfile and define `typecheck` and `test` scripts. In the worktree, ORCH runs `npm ci --ignore-scripts`, those checks with npm lifecycle hooks disabled, optional `lint`, and `git diff --check`; any missing, failed, interrupted, or stale result blocks the merge. Immediately before merge, ORCH verifies the original target branch and SHA plus the reviewed worktree, branch, commit, ancestry, and diff hash.

</details>

<p align="center"><strong>Aliases:</strong> <kbd>orchestry</kbd> &nbsp; <kbd>orch</kbd> &nbsp; <kbd>ao</kbd></p>

<br/>
Expand Down
6 changes: 6 additions & 0 deletions src/bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const COMMAND_STUBS: Array<[name: string, description: string]> = [
['doctor', 'Check adapters and dependencies'],
['tui', 'Launch TUI dashboard'],
['serve', 'Headless daemon mode with structured logs'],
['workflow','Run a native role workflow'],
['init', 'Initialize project'],
['update', 'Check for updates'],
];
Expand Down Expand Up @@ -106,6 +107,11 @@ async function main(): Promise<void> {
} else if (sub === 'update') {
const { registerUpdateCommand } = await import('../cli/commands/update.js');
registerUpdateCommand(program);
} else if (sub === 'workflow') {
const { registerWorkflowCommand } = await import('../cli/commands/workflow.js');
registerWorkflowCommand(program);
await program.parseAsync(process.argv);
return;
}

// Bare `orch` in a directory without .orchestry/ → auto-init + TUI (FTUE).
Expand Down
78 changes: 78 additions & 0 deletions src/cli/commands/workflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import fs from 'node:fs/promises';
import { createInterface } from 'node:readline/promises';
import type { Command } from 'commander';
import { runNativeRoleWorkflow } from '../../infrastructure/workflow/native-role-workflow.js';

interface WorkflowOptions {
supervisor: string;
supervisorModel: string;
implementer: string;
implementerModel: string;
reviewer: string;
reviewerModel: string;
adviser?: string;
adviserModel?: string;
objectiveFile?: string;
maxAttempts: string;
yes?: boolean;
}

export function registerWorkflowCommand(program: Command): void {
program
.command('workflow')
.description('Run a confirmed Supervisor, Implementer, and Reviewer workflow')
.requiredOption('--supervisor <cli>', 'Supervisor CLI (codex)')
.requiredOption('--supervisor-model <model>', 'Supervisor model')
.requiredOption('--implementer <cli>', 'Implementer CLI (claude)')
.requiredOption('--implementer-model <model>', 'Implementer model')
.requiredOption('--reviewer <cli>', 'Reviewer CLI (codex)')
.requiredOption('--reviewer-model <model>', 'Reviewer model')
.option('--adviser <cli>', 'Optional Adviser CLI (claude)')
.option('--adviser-model <model>', 'Optional Adviser model')
.option('--objective-file <path>', 'Read the objective from a protected file instead of stdin')
.option('--max-attempts <count>', 'Maximum Supervisor and Reviewer attempts', '1')
.option('--yes', 'Confirm the printed workflow summary')
.action(async (options: WorkflowOptions) => {
if (Boolean(options.adviser) !== Boolean(options.adviserModel)) {
throw new Error('--adviser and --adviser-model must be supplied together');
}
if (!options.objectiveFile && !options.yes) {
throw new Error('A workflow objective read from stdin requires --yes; use --objective-file for interactive confirmation');
}
const objective = options.objectiveFile ? undefined : await readStdin();
const state = await runNativeRoleWorkflow(process.cwd(), {
objective,
objectiveFile: options.objectiveFile,
confirmed: Boolean(options.yes),
supervisor: { cli: options.supervisor as 'codex', model: options.supervisorModel },
adviser: options.adviser ? { cli: options.adviser as 'claude', model: options.adviserModel! } : null,
implementer: { cli: options.implementer as 'claude', model: options.implementerModel },
reviewer: { cli: options.reviewer as 'codex', model: options.reviewerModel },
maxAttempts: Number(options.maxAttempts),
onSummary: (summary) => console.log(JSON.stringify({ type: 'workflow_summary', ...summary as object })),
confirm: async () => {
const prompt = createInterface({ input: process.stdin, output: process.stdout });
try {
const answer = (await prompt.question('Start this workflow? [y/N] ')).trim().toLowerCase();
return answer === 'y' || answer === 'yes';
} finally {
prompt.close();
}
},
onCheck: (check) => console.log(JSON.stringify({ type: 'workflow_check', ...check })),
});
console.log(JSON.stringify(state));
});
}

async function readStdin(): Promise<string> {
const chunks: Buffer[] = [];
let bytes = 0;
for await (const chunk of process.stdin) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
bytes += buffer.length;
if (bytes > 128_000) throw new Error('Workflow objective exceeds 128000 bytes');
chunks.push(buffer);
}
return Buffer.concat(chunks).toString('utf8');
}
58 changes: 58 additions & 0 deletions src/domain/native-role-workflow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
export const WORKFLOW_ROLES = ['supervisor', 'adviser', 'implementer', 'reviewer'] as const;
export type WorkflowRole = typeof WORKFLOW_ROLES[number];

export interface WorkflowBinding {
cli: 'codex' | 'claude';
model: string;
}

export interface WorkflowAttempt {
id: string;
role: WorkflowRole;
cli: string;
model: string;
status: 'started' | 'succeeded' | 'failed' | 'interrupted';
started_at: string;
finished_at?: string;
error?: string;
}

export interface WorkflowCheck {
command: string;
status: 'passed' | 'failed';
output: string;
}

export interface NativeRoleWorkflowState {
schema_version: 1;
id: string;
phase: 'created' | 'running' | 'checking' | 'reviewing' | 'merged' | 'cancelled' | 'failed';
target_branch: string;
target_commit: string;
workflow_branch: string;
worktree: string;
roles: {
supervisor: WorkflowBinding;
adviser: WorkflowBinding | null;
implementer: WorkflowBinding;
reviewer: WorkflowBinding;
};
attempts: WorkflowAttempt[];
checks: WorkflowCheck[];
implementation_commit: string | null;
diff_hash: string | null;
merge_commit: string | null;
error: string | null;
}

export function validateWorkflowBindings(bindings: NativeRoleWorkflowState['roles']): void {
if (bindings.supervisor.cli !== 'codex') throw new Error('Supervisor must use the Codex CLI');
if (bindings.implementer.cli !== 'claude') throw new Error('Implementer must use the Claude CLI');
if (bindings.reviewer.cli !== 'codex') throw new Error('Reviewer must use the Codex CLI');
if (bindings.adviser && bindings.adviser.cli !== 'claude') throw new Error('Adviser must use the Claude CLI');
for (const [role, binding] of Object.entries(bindings)) {
if (binding && !/^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/.test(binding.model)) {
throw new Error(`${role} model is invalid`);
}
}
}
Loading