takt: github-issue-106-suteppu-niite
This commit is contained in:
parent
24361b34e3
commit
d479707d1b
177
src/__tests__/StreamDisplay.test.ts
Normal file
177
src/__tests__/StreamDisplay.test.ts
Normal file
@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Tests for StreamDisplay progress info feature
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { StreamDisplay, type ProgressInfo } from '../shared/ui/index.js';
|
||||
|
||||
describe('StreamDisplay', () => {
|
||||
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
|
||||
let stdoutWriteSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
consoleLogSpy.mockRestore();
|
||||
stdoutWriteSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('progress info display', () => {
|
||||
const progressInfo: ProgressInfo = {
|
||||
iteration: 3,
|
||||
maxIterations: 10,
|
||||
movementIndex: 1,
|
||||
totalMovements: 4,
|
||||
};
|
||||
|
||||
describe('showInit', () => {
|
||||
it('should include progress info when provided', () => {
|
||||
const display = new StreamDisplay('test-agent', false, progressInfo);
|
||||
display.showInit('claude-3');
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[test-agent]')
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('(3/10) step 2/4')
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Model: claude-3')
|
||||
);
|
||||
});
|
||||
|
||||
it('should not include progress info when not provided', () => {
|
||||
const display = new StreamDisplay('test-agent', false);
|
||||
display.showInit('claude-3');
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('[test-agent]')
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Model: claude-3')
|
||||
);
|
||||
// Should not contain progress format
|
||||
expect(consoleLogSpy).not.toHaveBeenCalledWith(
|
||||
expect.stringMatching(/\(\d+\/\d+\) step \d+\/\d+/)
|
||||
);
|
||||
});
|
||||
|
||||
it('should not display anything in quiet mode', () => {
|
||||
const display = new StreamDisplay('test-agent', true, progressInfo);
|
||||
display.showInit('claude-3');
|
||||
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('showText', () => {
|
||||
it('should include progress info in first text header when provided', () => {
|
||||
const display = new StreamDisplay('test-agent', false, progressInfo);
|
||||
display.showText('Hello');
|
||||
|
||||
// First call is blank line, second is the header
|
||||
expect(consoleLogSpy).toHaveBeenCalledTimes(2);
|
||||
expect(consoleLogSpy).toHaveBeenNthCalledWith(2,
|
||||
expect.stringContaining('[test-agent]')
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenNthCalledWith(2,
|
||||
expect.stringContaining('(3/10) step 2/4')
|
||||
);
|
||||
});
|
||||
|
||||
it('should not include progress info in header when not provided', () => {
|
||||
const display = new StreamDisplay('test-agent', false);
|
||||
display.showText('Hello');
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledTimes(2);
|
||||
const headerCall = consoleLogSpy.mock.calls[1]?.[0] as string;
|
||||
expect(headerCall).toContain('[test-agent]');
|
||||
expect(headerCall).not.toMatch(/\(\d+\/\d+\) step \d+\/\d+/);
|
||||
});
|
||||
|
||||
it('should output text content to stdout', () => {
|
||||
const display = new StreamDisplay('test-agent', false, progressInfo);
|
||||
display.showText('Hello');
|
||||
|
||||
expect(stdoutWriteSpy).toHaveBeenCalledWith('Hello');
|
||||
});
|
||||
|
||||
it('should not display anything in quiet mode', () => {
|
||||
const display = new StreamDisplay('test-agent', true, progressInfo);
|
||||
display.showText('Hello');
|
||||
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled();
|
||||
expect(stdoutWriteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('showThinking', () => {
|
||||
it('should include progress info in thinking header when provided', () => {
|
||||
const display = new StreamDisplay('test-agent', false, progressInfo);
|
||||
display.showThinking('Thinking...');
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledTimes(2);
|
||||
expect(consoleLogSpy).toHaveBeenNthCalledWith(2,
|
||||
expect.stringContaining('[test-agent]')
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenNthCalledWith(2,
|
||||
expect.stringContaining('(3/10) step 2/4')
|
||||
);
|
||||
expect(consoleLogSpy).toHaveBeenNthCalledWith(2,
|
||||
expect.stringContaining('thinking')
|
||||
);
|
||||
});
|
||||
|
||||
it('should not include progress info in header when not provided', () => {
|
||||
const display = new StreamDisplay('test-agent', false);
|
||||
display.showThinking('Thinking...');
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledTimes(2);
|
||||
const headerCall = consoleLogSpy.mock.calls[1]?.[0] as string;
|
||||
expect(headerCall).toContain('[test-agent]');
|
||||
expect(headerCall).not.toMatch(/\(\d+\/\d+\) step \d+\/\d+/);
|
||||
});
|
||||
|
||||
it('should not display anything in quiet mode', () => {
|
||||
const display = new StreamDisplay('test-agent', true, progressInfo);
|
||||
display.showThinking('Thinking...');
|
||||
|
||||
expect(consoleLogSpy).not.toHaveBeenCalled();
|
||||
expect(stdoutWriteSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('progress prefix format', () => {
|
||||
it('should format progress as (iteration/max) step index/total', () => {
|
||||
const progressInfo: ProgressInfo = {
|
||||
iteration: 5,
|
||||
maxIterations: 20,
|
||||
movementIndex: 2,
|
||||
totalMovements: 6,
|
||||
};
|
||||
const display = new StreamDisplay('agent', false, progressInfo);
|
||||
display.showText('test');
|
||||
|
||||
const headerCall = consoleLogSpy.mock.calls[1]?.[0] as string;
|
||||
expect(headerCall).toContain('(5/20) step 3/6');
|
||||
});
|
||||
|
||||
it('should convert 0-indexed movementIndex to 1-indexed display', () => {
|
||||
const progressInfo: ProgressInfo = {
|
||||
iteration: 1,
|
||||
maxIterations: 10,
|
||||
movementIndex: 0, // First movement (0-indexed)
|
||||
totalMovements: 4,
|
||||
};
|
||||
const display = new StreamDisplay('agent', false, progressInfo);
|
||||
display.showText('test');
|
||||
|
||||
const headerCall = consoleLogSpy.mock.calls[1]?.[0] as string;
|
||||
expect(headerCall).toContain('step 1/4'); // Should display as 1-indexed
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -121,49 +121,6 @@ describe('loadGlobalConfig', () => {
|
||||
expect(reloaded.pipeline!.commitMessageTemplate).toBe('feat: {title} (#{issue})');
|
||||
});
|
||||
|
||||
it('should load auto_pr config from config.yaml', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(
|
||||
getGlobalConfigPath(),
|
||||
'language: en\nauto_pr: true\n',
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
|
||||
expect(config.autoPr).toBe(true);
|
||||
});
|
||||
|
||||
it('should save and reload auto_pr config', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
// Create minimal config first
|
||||
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
config.autoPr = true;
|
||||
saveGlobalConfig(config);
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
const reloaded = loadGlobalConfig();
|
||||
expect(reloaded.autoPr).toBe(true);
|
||||
});
|
||||
|
||||
it('should save auto_pr: false when explicitly set', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
|
||||
|
||||
const config = loadGlobalConfig();
|
||||
config.autoPr = false;
|
||||
saveGlobalConfig(config);
|
||||
invalidateGlobalConfigCache();
|
||||
|
||||
const reloaded = loadGlobalConfig();
|
||||
expect(reloaded.autoPr).toBe(false);
|
||||
});
|
||||
|
||||
it('should read from cache without hitting disk on second call', () => {
|
||||
const taktDir = join(testHomeDir, '.takt');
|
||||
mkdirSync(taktDir, { recursive: true });
|
||||
|
||||
@ -414,4 +414,72 @@ describe('ParallelLogger', () => {
|
||||
expect(output[2]).toContain('A second');
|
||||
});
|
||||
});
|
||||
|
||||
describe('progress info display', () => {
|
||||
it('should include progress info in prefix when provided', () => {
|
||||
const logger = new ParallelLogger({
|
||||
subMovementNames: ['step-a', 'step-b'],
|
||||
writeFn,
|
||||
progressInfo: {
|
||||
iteration: 3,
|
||||
maxIterations: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const prefix = logger.buildPrefix('step-a', 0);
|
||||
expect(prefix).toContain('[step-a]');
|
||||
expect(prefix).toContain('(3/10)');
|
||||
expect(prefix).toContain('step 1/2'); // 0-indexed -> 1-indexed, 2 total sub-movements
|
||||
});
|
||||
|
||||
it('should show correct step number for each sub-movement', () => {
|
||||
const logger = new ParallelLogger({
|
||||
subMovementNames: ['step-a', 'step-b', 'step-c'],
|
||||
writeFn,
|
||||
progressInfo: {
|
||||
iteration: 5,
|
||||
maxIterations: 20,
|
||||
},
|
||||
});
|
||||
|
||||
const prefixA = logger.buildPrefix('step-a', 0);
|
||||
const prefixB = logger.buildPrefix('step-b', 1);
|
||||
const prefixC = logger.buildPrefix('step-c', 2);
|
||||
|
||||
expect(prefixA).toContain('step 1/3');
|
||||
expect(prefixB).toContain('step 2/3');
|
||||
expect(prefixC).toContain('step 3/3');
|
||||
});
|
||||
|
||||
it('should not include progress info when not provided', () => {
|
||||
const logger = new ParallelLogger({
|
||||
subMovementNames: ['step-a'],
|
||||
writeFn,
|
||||
});
|
||||
|
||||
const prefix = logger.buildPrefix('step-a', 0);
|
||||
expect(prefix).toContain('[step-a]');
|
||||
expect(prefix).not.toMatch(/\(\d+\/\d+\)/);
|
||||
expect(prefix).not.toMatch(/step \d+\/\d+/);
|
||||
});
|
||||
|
||||
it('should include progress info in streamed output', () => {
|
||||
const logger = new ParallelLogger({
|
||||
subMovementNames: ['step-a'],
|
||||
writeFn,
|
||||
progressInfo: {
|
||||
iteration: 2,
|
||||
maxIterations: 5,
|
||||
},
|
||||
});
|
||||
const handler = logger.createStreamHandler('step-a', 0);
|
||||
|
||||
handler({ type: 'text', data: { text: 'Hello world\n' } } as StreamEvent);
|
||||
|
||||
expect(output).toHaveLength(1);
|
||||
expect(output[0]).toContain('[step-a]');
|
||||
expect(output[0]).toContain('(2/5) step 1/1');
|
||||
expect(output[0]).toContain('Hello world');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -121,25 +121,6 @@ describe('saveTaskFile', () => {
|
||||
expect(content).not.toContain('issue:');
|
||||
expect(content).not.toContain('worktree:');
|
||||
expect(content).not.toContain('branch:');
|
||||
expect(content).not.toContain('auto_pr:');
|
||||
});
|
||||
|
||||
it('should include auto_pr in YAML when specified', async () => {
|
||||
// When
|
||||
const filePath = await saveTaskFile(testDir, 'Task', { autoPr: true });
|
||||
|
||||
// Then
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(content).toContain('auto_pr: true');
|
||||
});
|
||||
|
||||
it('should include auto_pr: false in YAML when specified as false', async () => {
|
||||
// When
|
||||
const filePath = await saveTaskFile(testDir, 'Task', { autoPr: false });
|
||||
|
||||
// Then
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
expect(content).toContain('auto_pr: false');
|
||||
});
|
||||
|
||||
it('should use first line for filename generation', async () => {
|
||||
|
||||
@ -11,9 +11,6 @@ vi.mock('../infra/config/index.js', () => ({
|
||||
loadGlobalConfig: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { loadGlobalConfig } from '../infra/config/index.js';
|
||||
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig);
|
||||
|
||||
vi.mock('../infra/task/index.js', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
TaskRunner: vi.fn(),
|
||||
@ -283,117 +280,4 @@ describe('resolveTaskExecution', () => {
|
||||
'Clone created: /project/../20260128-info-task (branch: takt/20260128-info-task)'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return autoPr from task YAML when specified', async () => {
|
||||
// Given: Task with auto_pr option
|
||||
const task: TaskInfo = {
|
||||
name: 'task-with-auto-pr',
|
||||
content: 'Task content',
|
||||
filePath: '/tasks/task.yaml',
|
||||
data: {
|
||||
task: 'Task content',
|
||||
auto_pr: true,
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
const result = await resolveTaskExecution(task, '/project', 'default');
|
||||
|
||||
// Then
|
||||
expect(result.autoPr).toBe(true);
|
||||
});
|
||||
|
||||
it('should return autoPr: false from task YAML when specified as false', async () => {
|
||||
// Given: Task with auto_pr: false
|
||||
const task: TaskInfo = {
|
||||
name: 'task-no-auto-pr',
|
||||
content: 'Task content',
|
||||
filePath: '/tasks/task.yaml',
|
||||
data: {
|
||||
task: 'Task content',
|
||||
auto_pr: false,
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
const result = await resolveTaskExecution(task, '/project', 'default');
|
||||
|
||||
// Then
|
||||
expect(result.autoPr).toBe(false);
|
||||
});
|
||||
|
||||
it('should fall back to global config autoPr when task YAML does not specify', async () => {
|
||||
// Given: Task without auto_pr, global config has autoPr
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
language: 'en',
|
||||
defaultPiece: 'default',
|
||||
logLevel: 'info',
|
||||
autoPr: true,
|
||||
});
|
||||
|
||||
const task: TaskInfo = {
|
||||
name: 'task-no-auto-pr-setting',
|
||||
content: 'Task content',
|
||||
filePath: '/tasks/task.yaml',
|
||||
data: {
|
||||
task: 'Task content',
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
const result = await resolveTaskExecution(task, '/project', 'default');
|
||||
|
||||
// Then
|
||||
expect(result.autoPr).toBe(true);
|
||||
});
|
||||
|
||||
it('should return undefined autoPr when neither task nor config specifies', async () => {
|
||||
// Given: Neither task nor config has autoPr
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
language: 'en',
|
||||
defaultPiece: 'default',
|
||||
logLevel: 'info',
|
||||
});
|
||||
|
||||
const task: TaskInfo = {
|
||||
name: 'task-default',
|
||||
content: 'Task content',
|
||||
filePath: '/tasks/task.yaml',
|
||||
data: {
|
||||
task: 'Task content',
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
const result = await resolveTaskExecution(task, '/project', 'default');
|
||||
|
||||
// Then
|
||||
expect(result.autoPr).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should prioritize task YAML auto_pr over global config', async () => {
|
||||
// Given: Task has auto_pr: false, global config has autoPr: true
|
||||
mockLoadGlobalConfig.mockReturnValue({
|
||||
language: 'en',
|
||||
defaultPiece: 'default',
|
||||
logLevel: 'info',
|
||||
autoPr: true,
|
||||
});
|
||||
|
||||
const task: TaskInfo = {
|
||||
name: 'task-override',
|
||||
content: 'Task content',
|
||||
filePath: '/tasks/task.yaml',
|
||||
data: {
|
||||
task: 'Task content',
|
||||
auto_pr: false,
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
const result = await resolveTaskExecution(task, '/project', 'default');
|
||||
|
||||
// Then
|
||||
expect(result.autoPr).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -43,8 +43,6 @@ export interface GlobalConfig {
|
||||
debug?: DebugConfig;
|
||||
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
|
||||
worktreeDir?: string;
|
||||
/** Auto-create PR after worktree execution (default: prompt in interactive mode) */
|
||||
autoPr?: boolean;
|
||||
/** List of builtin piece/agent names to exclude from fallback loading */
|
||||
disabledBuiltins?: string[];
|
||||
/** Enable builtin pieces from resources/global/{lang}/pieces */
|
||||
|
||||
@ -252,8 +252,6 @@ export const GlobalConfigSchema = z.object({
|
||||
debug: DebugConfigSchema.optional(),
|
||||
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
|
||||
worktree_dir: z.string().optional(),
|
||||
/** Auto-create PR after worktree execution (default: prompt in interactive mode) */
|
||||
auto_pr: z.boolean().optional(),
|
||||
/** List of builtin piece/agent names to exclude from fallback loading */
|
||||
disabled_builtins: z.array(z.string()).optional().default([]),
|
||||
/** Enable builtin pieces from resources/global/{lang}/pieces */
|
||||
|
||||
@ -71,6 +71,10 @@ export class ParallelRunner {
|
||||
? new ParallelLogger({
|
||||
subMovementNames: subMovements.map((s) => s.name),
|
||||
parentOnStream: this.deps.engineOptions.onStream,
|
||||
progressInfo: {
|
||||
iteration: state.iteration,
|
||||
maxIterations,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
|
||||
@ -12,6 +12,14 @@ import type { StreamCallback, StreamEvent } from '../types.js';
|
||||
const COLORS = ['\x1b[36m', '\x1b[33m', '\x1b[35m', '\x1b[32m'] as const; // cyan, yellow, magenta, green
|
||||
const RESET = '\x1b[0m';
|
||||
|
||||
/** Progress information for parallel logger */
|
||||
export interface ParallelProgressInfo {
|
||||
/** Current iteration (1-indexed) */
|
||||
iteration: number;
|
||||
/** Maximum iterations allowed */
|
||||
maxIterations: number;
|
||||
}
|
||||
|
||||
export interface ParallelLoggerOptions {
|
||||
/** Sub-movement names (used to calculate prefix width) */
|
||||
subMovementNames: string[];
|
||||
@ -19,6 +27,8 @@ export interface ParallelLoggerOptions {
|
||||
parentOnStream?: StreamCallback;
|
||||
/** Override process.stdout.write for testing */
|
||||
writeFn?: (text: string) => void;
|
||||
/** Progress information for display */
|
||||
progressInfo?: ParallelProgressInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -34,11 +44,15 @@ export class ParallelLogger {
|
||||
private readonly lineBuffers: Map<string, string> = new Map();
|
||||
private readonly parentOnStream?: StreamCallback;
|
||||
private readonly writeFn: (text: string) => void;
|
||||
private readonly progressInfo?: ParallelProgressInfo;
|
||||
private readonly totalSubMovements: number;
|
||||
|
||||
constructor(options: ParallelLoggerOptions) {
|
||||
this.maxNameLength = Math.max(...options.subMovementNames.map((n) => n.length));
|
||||
this.parentOnStream = options.parentOnStream;
|
||||
this.writeFn = options.writeFn ?? ((text: string) => process.stdout.write(text));
|
||||
this.progressInfo = options.progressInfo;
|
||||
this.totalSubMovements = options.subMovementNames.length;
|
||||
|
||||
for (const name of options.subMovementNames) {
|
||||
this.lineBuffers.set(name, '');
|
||||
@ -47,12 +61,20 @@ export class ParallelLogger {
|
||||
|
||||
/**
|
||||
* Build the colored prefix string for a sub-movement.
|
||||
* Format: `\x1b[COLORm[name]\x1b[0m` + padding spaces
|
||||
* Format: `\x1b[COLORm[name](iteration/max) step index/total\x1b[0m` + padding spaces
|
||||
*/
|
||||
buildPrefix(name: string, index: number): string {
|
||||
const color = COLORS[index % COLORS.length];
|
||||
const padding = ' '.repeat(this.maxNameLength - name.length);
|
||||
return `${color}[${name}]${RESET}${padding} `;
|
||||
|
||||
let progressPart = '';
|
||||
if (this.progressInfo) {
|
||||
const { iteration, maxIterations } = this.progressInfo;
|
||||
// index is 0-indexed, display as 1-indexed for step number
|
||||
progressPart = `(${iteration}/${maxIterations}) step ${index + 1}/${this.totalSubMovements} `;
|
||||
}
|
||||
|
||||
return `${color}[${name}]${RESET}${padding} ${progressPart}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -43,7 +43,7 @@ async function generateFilename(tasksDir: string, taskContent: string, cwd: stri
|
||||
export async function saveTaskFile(
|
||||
cwd: string,
|
||||
taskContent: string,
|
||||
options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean },
|
||||
options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string },
|
||||
): Promise<string> {
|
||||
const tasksDir = path.join(cwd, '.takt', 'tasks');
|
||||
fs.mkdirSync(tasksDir, { recursive: true });
|
||||
@ -57,7 +57,6 @@ export async function saveTaskFile(
|
||||
...(options?.branch && { branch: options.branch }),
|
||||
...(options?.piece && { piece: options.piece }),
|
||||
...(options?.issue !== undefined && { issue: options.issue }),
|
||||
...(options?.autoPr !== undefined && { auto_pr: options.autoPr }),
|
||||
};
|
||||
|
||||
const filePath = path.join(tasksDir, filename);
|
||||
@ -170,10 +169,9 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
|
||||
taskContent = result.task;
|
||||
}
|
||||
|
||||
// ワークツリー/ブランチ/PR設定
|
||||
// ワークツリー/ブランチ設定
|
||||
let worktree: boolean | string | undefined;
|
||||
let branch: string | undefined;
|
||||
let autoPr: boolean | undefined;
|
||||
|
||||
const useWorktree = await confirm('Create worktree?', true);
|
||||
if (useWorktree) {
|
||||
@ -184,8 +182,6 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
|
||||
if (customBranch) {
|
||||
branch = customBranch;
|
||||
}
|
||||
|
||||
autoPr = await confirm('Auto-create PR?', false);
|
||||
}
|
||||
|
||||
// YAMLファイル作成
|
||||
@ -194,7 +190,6 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
|
||||
issue: issueNumber,
|
||||
worktree,
|
||||
branch,
|
||||
autoPr,
|
||||
});
|
||||
|
||||
const filename = path.basename(filePath);
|
||||
@ -206,9 +201,6 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
|
||||
if (branch) {
|
||||
info(` Branch: ${branch}`);
|
||||
}
|
||||
if (autoPr) {
|
||||
info(` Auto-PR: yes`);
|
||||
}
|
||||
if (piece) {
|
||||
info(` Piece: ${piece}`);
|
||||
}
|
||||
|
||||
@ -273,8 +273,17 @@ export async function executePiece(
|
||||
log.debug('Step instruction', instruction);
|
||||
}
|
||||
|
||||
// Find movement index for progress display
|
||||
const movementIndex = pieceConfig.movements.findIndex((m) => m.name === step.name);
|
||||
const totalMovements = pieceConfig.movements.length;
|
||||
|
||||
// Use quiet mode from CLI (already resolved CLI flag + config in preAction)
|
||||
displayRef.current = new StreamDisplay(step.agentDisplayName, isQuietMode());
|
||||
displayRef.current = new StreamDisplay(step.agentDisplayName, isQuietMode(), {
|
||||
iteration,
|
||||
maxIterations: pieceConfig.maxIterations,
|
||||
movementIndex: movementIndex >= 0 ? movementIndex : 0,
|
||||
totalMovements,
|
||||
});
|
||||
|
||||
// Write step_start record to NDJSON log
|
||||
const record: NdjsonStepStart = {
|
||||
|
||||
@ -14,14 +14,14 @@ import {
|
||||
loadAllPiecesWithSources,
|
||||
getPieceCategories,
|
||||
buildCategorizedPieces,
|
||||
loadGlobalConfig,
|
||||
} from '../../../infra/config/index.js';
|
||||
import { confirm } from '../../../shared/prompt/index.js';
|
||||
import { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../../../infra/task/index.js';
|
||||
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
|
||||
import { info, error, success } from '../../../shared/ui/index.js';
|
||||
import { createLogger } from '../../../shared/utils/index.js';
|
||||
import { executeTask, pushAndCreatePr } from './taskExecution.js';
|
||||
import { createPullRequest, buildPrBody } from '../../../infra/github/index.js';
|
||||
import { executeTask } from './taskExecution.js';
|
||||
import type { TaskExecutionOptions, WorktreeConfirmationResult, SelectAndExecuteOptions } from './types.js';
|
||||
import {
|
||||
warnMissingPieces,
|
||||
@ -122,26 +122,6 @@ export async function confirmAndCreateWorktree(
|
||||
return { execCwd: result.path, isWorktree: true, branch: result.branch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve auto-PR setting with priority: CLI option > config > prompt.
|
||||
* Only applicable when worktree is enabled.
|
||||
*/
|
||||
async function resolveAutoPr(optionAutoPr: boolean | undefined): Promise<boolean> {
|
||||
// CLI option takes precedence
|
||||
if (typeof optionAutoPr === 'boolean') {
|
||||
return optionAutoPr;
|
||||
}
|
||||
|
||||
// Check global config
|
||||
const globalConfig = loadGlobalConfig();
|
||||
if (typeof globalConfig.autoPr === 'boolean') {
|
||||
return globalConfig.autoPr;
|
||||
}
|
||||
|
||||
// Fall back to interactive prompt
|
||||
return confirm('Create pull request?', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a task with piece selection, optional worktree, and auto-commit.
|
||||
* Shared by direct task execution and interactive mode.
|
||||
@ -165,12 +145,7 @@ export async function selectAndExecuteTask(
|
||||
options?.createWorktree,
|
||||
);
|
||||
|
||||
let shouldCreatePr = false;
|
||||
if (isWorktree) {
|
||||
shouldCreatePr = await resolveAutoPr(options?.autoPr);
|
||||
}
|
||||
|
||||
log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree, autoPr: shouldCreatePr });
|
||||
log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree });
|
||||
const taskSuccess = await executeTask({
|
||||
task,
|
||||
cwd: execCwd,
|
||||
@ -189,14 +164,23 @@ export async function selectAndExecuteTask(
|
||||
error(`Auto-commit failed: ${commitResult.message}`);
|
||||
}
|
||||
|
||||
if (commitResult.success && commitResult.commitHash && branch && shouldCreatePr) {
|
||||
pushAndCreatePr(
|
||||
cwd,
|
||||
branch,
|
||||
task,
|
||||
`Piece \`${pieceIdentifier}\` completed successfully.`,
|
||||
{ issues: options?.issues, repo: options?.repo },
|
||||
);
|
||||
if (commitResult.success && commitResult.commitHash && branch) {
|
||||
const shouldCreatePr = options?.autoPr === true || await confirm('Create pull request?', false);
|
||||
if (shouldCreatePr) {
|
||||
info('Creating pull request...');
|
||||
const prBody = buildPrBody(options?.issues, `Piece \`${pieceIdentifier}\` completed successfully.`);
|
||||
const prResult = createPullRequest(execCwd, {
|
||||
branch,
|
||||
title: task.length > 100 ? `${task.slice(0, 97)}...` : task,
|
||||
body: prBody,
|
||||
repo: options?.repo,
|
||||
});
|
||||
if (prResult.success) {
|
||||
success(`PR created: ${prResult.url}`);
|
||||
} else {
|
||||
error(`PR creation failed: ${prResult.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -16,7 +16,6 @@ import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
|
||||
import { executePiece } from './pieceExecution.js';
|
||||
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
|
||||
import type { TaskExecutionOptions, ExecuteTaskOptions } from './types.js';
|
||||
import { createPullRequest, buildPrBody, pushBranch, type GitHubIssue } from '../../../infra/github/index.js';
|
||||
|
||||
export type { TaskExecutionOptions, ExecuteTaskOptions };
|
||||
|
||||
@ -59,39 +58,6 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<boolean>
|
||||
return result.success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push branch to origin and create a PR.
|
||||
* clone の origin は shared clone 作成時に削除されるため、project cwd 経由で push する。
|
||||
*/
|
||||
export function pushAndCreatePr(
|
||||
cwd: string,
|
||||
branch: string,
|
||||
title: string,
|
||||
description: string,
|
||||
options?: { issues?: GitHubIssue[]; repo?: string },
|
||||
): void {
|
||||
info('Creating pull request...');
|
||||
try {
|
||||
pushBranch(cwd, branch);
|
||||
} catch (e) {
|
||||
error(`Failed to push branch to origin: ${getErrorMessage(e)}`);
|
||||
return;
|
||||
}
|
||||
const prBody = buildPrBody(options?.issues, description);
|
||||
const truncatedTitle = title.length > 100 ? `${title.slice(0, 97)}...` : title;
|
||||
const prResult = createPullRequest(cwd, {
|
||||
branch,
|
||||
title: truncatedTitle,
|
||||
body: prBody,
|
||||
repo: options?.repo,
|
||||
});
|
||||
if (prResult.success) {
|
||||
success(`PR created: ${prResult.url}`);
|
||||
} else {
|
||||
error(`PR creation failed: ${prResult.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a task: resolve clone → run piece → auto-commit+push → remove clone → record completion.
|
||||
*
|
||||
@ -111,7 +77,7 @@ export async function executeAndCompleteTask(
|
||||
const executionLog: string[] = [];
|
||||
|
||||
try {
|
||||
const { execCwd, execPiece, isWorktree, branch, startMovement, retryNote, autoPr } = await resolveTaskExecution(task, cwd, pieceName);
|
||||
const { execCwd, execPiece, isWorktree, startMovement, retryNote } = await resolveTaskExecution(task, cwd, pieceName);
|
||||
|
||||
// cwd is always the project root; pass it as projectCwd so reports/sessions go there
|
||||
const taskSuccess = await executeTask({
|
||||
@ -132,10 +98,6 @@ export async function executeAndCompleteTask(
|
||||
} else if (!commitResult.success) {
|
||||
error(`Auto-commit failed: ${commitResult.message}`);
|
||||
}
|
||||
|
||||
if (commitResult.success && commitResult.commitHash && branch && autoPr) {
|
||||
pushAndCreatePr(cwd, branch, task.name, `Task "${task.name}" completed successfully.`);
|
||||
}
|
||||
}
|
||||
|
||||
const taskResult = {
|
||||
@ -236,7 +198,7 @@ export async function resolveTaskExecution(
|
||||
task: TaskInfo,
|
||||
defaultCwd: string,
|
||||
defaultPiece: string
|
||||
): Promise<{ execCwd: string; execPiece: string; isWorktree: boolean; branch?: string; startMovement?: string; retryNote?: string; autoPr?: boolean }> {
|
||||
): Promise<{ execCwd: string; execPiece: string; isWorktree: boolean; branch?: string; startMovement?: string; retryNote?: string }> {
|
||||
const data = task.data;
|
||||
|
||||
// No structured data: use defaults
|
||||
@ -275,14 +237,5 @@ export async function resolveTaskExecution(
|
||||
// Handle retry_note
|
||||
const retryNote = data.retry_note;
|
||||
|
||||
// Handle auto_pr (task YAML > global config)
|
||||
let autoPr: boolean | undefined;
|
||||
if (data.auto_pr !== undefined) {
|
||||
autoPr = data.auto_pr;
|
||||
} else {
|
||||
const globalConfig = loadGlobalConfig();
|
||||
autoPr = globalConfig.autoPr;
|
||||
}
|
||||
|
||||
return { execCwd, execPiece, isWorktree, branch, startMovement, retryNote, autoPr };
|
||||
return { execCwd, execPiece, isWorktree, branch, startMovement, retryNote };
|
||||
}
|
||||
|
||||
@ -75,7 +75,6 @@ export class GlobalConfigManager {
|
||||
logFile: parsed.debug.log_file,
|
||||
} : undefined,
|
||||
worktreeDir: parsed.worktree_dir,
|
||||
autoPr: parsed.auto_pr,
|
||||
disabledBuiltins: parsed.disabled_builtins,
|
||||
enableBuiltinPieces: parsed.enable_builtin_pieces,
|
||||
anthropicApiKey: parsed.anthropic_api_key,
|
||||
@ -115,9 +114,6 @@ export class GlobalConfigManager {
|
||||
if (config.worktreeDir) {
|
||||
raw.worktree_dir = config.worktreeDir;
|
||||
}
|
||||
if (config.autoPr !== undefined) {
|
||||
raw.auto_pr = config.autoPr;
|
||||
}
|
||||
if (config.disabledBuiltins && config.disabledBuiltins.length > 0) {
|
||||
raw.disabled_builtins = config.disabledBuiltins;
|
||||
}
|
||||
|
||||
@ -32,8 +32,6 @@ export const TaskFileSchema = z.object({
|
||||
issue: z.number().int().positive().optional(),
|
||||
start_movement: z.string().optional(),
|
||||
retry_note: z.string().optional(),
|
||||
/** Auto-create PR after worktree execution (default: prompt in interactive mode) */
|
||||
auto_pr: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TaskFileData = z.infer<typeof TaskFileSchema>;
|
||||
|
||||
@ -13,6 +13,18 @@ import chalk from 'chalk';
|
||||
import type { StreamEvent, StreamCallback } from '../../core/piece/index.js';
|
||||
import { truncate } from './LogManager.js';
|
||||
|
||||
/** Progress information for stream display */
|
||||
export interface ProgressInfo {
|
||||
/** Current iteration (1-indexed) */
|
||||
iteration: number;
|
||||
/** Maximum iterations allowed */
|
||||
maxIterations: number;
|
||||
/** Current movement index within piece (0-indexed) */
|
||||
movementIndex: number;
|
||||
/** Total number of movements in piece */
|
||||
totalMovements: number;
|
||||
}
|
||||
|
||||
/** Stream display manager for real-time Claude output */
|
||||
export class StreamDisplay {
|
||||
private lastToolUse: string | null = null;
|
||||
@ -32,13 +44,30 @@ export class StreamDisplay {
|
||||
private spinnerFrame = 0;
|
||||
|
||||
constructor(
|
||||
private agentName = 'Claude',
|
||||
private quiet = false,
|
||||
private agentName: string,
|
||||
private quiet: boolean,
|
||||
private progressInfo?: ProgressInfo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Build progress prefix string for display.
|
||||
* Format: `(iteration/maxIterations) step movementIndex/totalMovements`
|
||||
* Example: `(3/10) step 2/4`
|
||||
*/
|
||||
private buildProgressPrefix(): string {
|
||||
if (!this.progressInfo) {
|
||||
return '';
|
||||
}
|
||||
const { iteration, maxIterations, movementIndex, totalMovements } = this.progressInfo;
|
||||
// movementIndex is 0-indexed, display as 1-indexed
|
||||
return `(${iteration}/${maxIterations}) step ${movementIndex + 1}/${totalMovements}`;
|
||||
}
|
||||
|
||||
showInit(model: string): void {
|
||||
if (this.quiet) return;
|
||||
console.log(chalk.gray(`[${this.agentName}] Model: ${model}`));
|
||||
const progress = this.buildProgressPrefix();
|
||||
const progressPart = progress ? ` ${progress}` : '';
|
||||
console.log(chalk.gray(`[${this.agentName}]${progressPart} Model: ${model}`));
|
||||
}
|
||||
|
||||
private startToolSpinner(tool: string, inputPreview: string): void {
|
||||
@ -140,7 +169,9 @@ export class StreamDisplay {
|
||||
|
||||
if (this.isFirstThinking) {
|
||||
console.log();
|
||||
console.log(chalk.magenta(`💭 [${this.agentName} thinking]:`));
|
||||
const progress = this.buildProgressPrefix();
|
||||
const progressPart = progress ? ` ${progress}` : '';
|
||||
console.log(chalk.magenta(`💭 [${this.agentName}]${progressPart} thinking:`));
|
||||
this.isFirstThinking = false;
|
||||
}
|
||||
process.stdout.write(chalk.gray.italic(thinking));
|
||||
@ -164,7 +195,9 @@ export class StreamDisplay {
|
||||
|
||||
if (this.isFirstText) {
|
||||
console.log();
|
||||
console.log(chalk.cyan(`[${this.agentName}]:`));
|
||||
const progress = this.buildProgressPrefix();
|
||||
const progressPart = progress ? ` ${progress}` : '';
|
||||
console.log(chalk.cyan(`[${this.agentName}]${progressPart}:`));
|
||||
this.isFirstText = false;
|
||||
}
|
||||
process.stdout.write(text);
|
||||
|
||||
@ -28,4 +28,4 @@ export {
|
||||
|
||||
export { Spinner } from './Spinner.js';
|
||||
|
||||
export { StreamDisplay } from './StreamDisplay.js';
|
||||
export { StreamDisplay, type ProgressInfo } from './StreamDisplay.js';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user