wiggum-cli 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ralph.js +0 -0
- package/dist/agent/memory/ingest.d.ts +14 -0
- package/dist/agent/memory/ingest.js +77 -0
- package/dist/agent/memory/store.d.ts +15 -0
- package/dist/agent/memory/store.js +98 -0
- package/dist/agent/memory/types.d.ts +16 -0
- package/dist/agent/memory/types.js +14 -0
- package/dist/agent/orchestrator.d.ts +7 -0
- package/dist/agent/orchestrator.js +266 -0
- package/dist/agent/resolve-config.d.ts +26 -0
- package/dist/agent/resolve-config.js +43 -0
- package/dist/agent/tools/backlog.d.ts +27 -0
- package/dist/agent/tools/backlog.js +51 -0
- package/dist/agent/tools/dry-run.d.ts +106 -0
- package/dist/agent/tools/dry-run.js +119 -0
- package/dist/agent/tools/execution.d.ts +51 -0
- package/dist/agent/tools/execution.js +256 -0
- package/dist/agent/tools/feature-state.d.ts +43 -0
- package/dist/agent/tools/feature-state.js +184 -0
- package/dist/agent/tools/introspection.d.ts +23 -0
- package/dist/agent/tools/introspection.js +40 -0
- package/dist/agent/tools/memory.d.ts +44 -0
- package/dist/agent/tools/memory.js +99 -0
- package/dist/agent/tools/preflight.d.ts +7 -0
- package/dist/agent/tools/preflight.js +137 -0
- package/dist/agent/tools/reporting.d.ts +58 -0
- package/dist/agent/tools/reporting.js +119 -0
- package/dist/agent/tools/schemas.d.ts +2 -0
- package/dist/agent/tools/schemas.js +3 -0
- package/dist/agent/types.d.ts +45 -0
- package/dist/agent/types.js +1 -0
- package/dist/ai/conversation/conversation-manager.js +8 -0
- package/dist/ai/conversation/url-fetcher.js +27 -0
- package/dist/ai/providers.js +5 -5
- package/dist/commands/agent.d.ts +17 -0
- package/dist/commands/agent.js +114 -0
- package/dist/commands/monitor.js +50 -183
- package/dist/commands/new-auto.d.ts +15 -0
- package/dist/commands/new-auto.js +237 -0
- package/dist/commands/run.js +20 -10
- package/dist/commands/sync.d.ts +15 -0
- package/dist/commands/sync.js +68 -0
- package/dist/generator/config.d.ts +1 -41
- package/dist/generator/config.js +7 -0
- package/dist/generator/index.d.ts +2 -2
- package/dist/generator/templates.d.ts +2 -0
- package/dist/generator/templates.js +9 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +115 -4
- package/dist/repl/command-parser.d.ts +5 -0
- package/dist/repl/command-parser.js +5 -0
- package/dist/templates/prompts/PROMPT.md.tmpl +13 -10
- package/dist/templates/prompts/PROMPT_e2e.md.tmpl +13 -7
- package/dist/templates/prompts/PROMPT_feature.md.tmpl +16 -3
- package/dist/templates/prompts/PROMPT_review_auto.md.tmpl +32 -12
- package/dist/templates/prompts/PROMPT_review_manual.md.tmpl +4 -1
- package/dist/templates/prompts/PROMPT_review_merge.md.tmpl +39 -14
- package/dist/templates/prompts/PROMPT_verify.md.tmpl +5 -2
- package/dist/templates/scripts/feature-loop.sh.tmpl +441 -69
- package/dist/tui/app.d.ts +19 -2
- package/dist/tui/app.js +22 -4
- package/dist/tui/components/IssuePicker.d.ts +27 -0
- package/dist/tui/components/IssuePicker.js +64 -0
- package/dist/tui/components/RunCompletionSummary.js +6 -3
- package/dist/tui/hooks/useAgentOrchestrator.d.ts +29 -0
- package/dist/tui/hooks/useAgentOrchestrator.js +453 -0
- package/dist/tui/orchestration/interview-orchestrator.d.ts +5 -1
- package/dist/tui/orchestration/interview-orchestrator.js +27 -6
- package/dist/tui/screens/AgentScreen.d.ts +21 -0
- package/dist/tui/screens/AgentScreen.js +159 -0
- package/dist/tui/screens/InitScreen.js +4 -0
- package/dist/tui/screens/InterviewScreen.d.ts +3 -1
- package/dist/tui/screens/InterviewScreen.js +146 -10
- package/dist/tui/screens/MainShell.d.ts +1 -1
- package/dist/tui/screens/MainShell.js +36 -1
- package/dist/tui/screens/RunScreen.js +38 -6
- package/dist/tui/utils/build-run-summary.d.ts +1 -1
- package/dist/tui/utils/build-run-summary.js +40 -84
- package/dist/tui/utils/clear-screen.d.ts +14 -0
- package/dist/tui/utils/clear-screen.js +16 -0
- package/dist/tui/utils/loop-status.d.ts +41 -1
- package/dist/tui/utils/loop-status.js +243 -35
- package/dist/tui/utils/pr-summary.d.ts +3 -2
- package/dist/tui/utils/pr-summary.js +41 -6
- package/dist/utils/config.d.ts +8 -0
- package/dist/utils/config.js +8 -0
- package/dist/utils/github.d.ts +32 -0
- package/dist/utils/github.js +106 -0
- package/package.json +4 -1
- package/src/templates/prompts/PROMPT.md.tmpl +13 -10
- package/src/templates/prompts/PROMPT_e2e.md.tmpl +13 -7
- package/src/templates/prompts/PROMPT_feature.md.tmpl +16 -3
- package/src/templates/prompts/PROMPT_review_auto.md.tmpl +32 -12
- package/src/templates/prompts/PROMPT_review_manual.md.tmpl +4 -1
- package/src/templates/prompts/PROMPT_review_merge.md.tmpl +39 -14
- package/src/templates/prompts/PROMPT_verify.md.tmpl +5 -2
- package/src/templates/scripts/feature-loop.sh.tmpl +441 -69
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { tool, zodSchema } from 'ai';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { listRepoIssues, fetchGitHubIssue } from '../../utils/github.js';
|
|
4
|
+
const DEPENDENCY_PATTERN = /\b(?:depends on|blocked by|requires|after)\s+#(\d+)/gi;
|
|
5
|
+
function extractDependencyHints(body) {
|
|
6
|
+
const matches = [...body.matchAll(DEPENDENCY_PATTERN)];
|
|
7
|
+
const numbers = matches.map(m => parseInt(m[1], 10));
|
|
8
|
+
return [...new Set(numbers)].sort((a, b) => a - b);
|
|
9
|
+
}
|
|
10
|
+
export function createBacklogTools(owner, repo, options = {}) {
|
|
11
|
+
const listIssues = tool({
|
|
12
|
+
description: 'List open GitHub issues from the backlog, optionally filtered by labels or milestone.',
|
|
13
|
+
inputSchema: zodSchema(z.object({
|
|
14
|
+
labels: z.array(z.string()).optional().describe('Filter by these labels (merged with configured defaults)'),
|
|
15
|
+
milestone: z.string().optional().describe('Filter by milestone name'),
|
|
16
|
+
limit: z.number().int().min(1).max(100).default(20).describe('Max issues to return'),
|
|
17
|
+
})),
|
|
18
|
+
execute: async ({ labels, milestone, limit }) => {
|
|
19
|
+
const parts = [];
|
|
20
|
+
// Merge default labels with any the agent provides
|
|
21
|
+
const allLabels = [...(options.defaultLabels ?? []), ...(labels ?? [])];
|
|
22
|
+
const uniqueLabels = [...new Set(allLabels)];
|
|
23
|
+
if (uniqueLabels.length)
|
|
24
|
+
parts.push(...uniqueLabels.map(l => `label:${l}`));
|
|
25
|
+
if (milestone)
|
|
26
|
+
parts.push(`milestone:${milestone}`);
|
|
27
|
+
const search = parts.length > 0 ? parts.join(' ') : undefined;
|
|
28
|
+
const result = await listRepoIssues(owner, repo, search, limit);
|
|
29
|
+
if (result.error)
|
|
30
|
+
return { issues: [], error: result.error };
|
|
31
|
+
// Sort by issue number ascending — lower numbers are typically more foundational
|
|
32
|
+
const sorted = [...result.issues].sort((a, b) => a.number - b.number);
|
|
33
|
+
return { issues: sorted };
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
const readIssue = tool({
|
|
37
|
+
description: 'Read full details of a GitHub issue including body and labels.',
|
|
38
|
+
inputSchema: zodSchema(z.object({
|
|
39
|
+
issueNumber: z.number().int().min(1).describe('The issue number to read'),
|
|
40
|
+
})),
|
|
41
|
+
execute: async ({ issueNumber }) => {
|
|
42
|
+
const detail = await fetchGitHubIssue(owner, repo, issueNumber);
|
|
43
|
+
if (!detail)
|
|
44
|
+
return { error: `Issue #${issueNumber} not found` };
|
|
45
|
+
// Extract dependency hints from body (e.g. "depends on #1", "blocked by #3")
|
|
46
|
+
const dependsOn = extractDependencyHints(detail.body);
|
|
47
|
+
return { ...detail, dependsOn };
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
return { listIssues, readIssue };
|
|
51
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export declare function createDryRunExecutionTools(): {
|
|
2
|
+
generateSpec: import("ai").Tool<{
|
|
3
|
+
featureName: string;
|
|
4
|
+
issueNumber: number;
|
|
5
|
+
goals?: string | undefined;
|
|
6
|
+
model?: string | undefined;
|
|
7
|
+
provider?: string | undefined;
|
|
8
|
+
}, {
|
|
9
|
+
success: boolean;
|
|
10
|
+
specPath: string;
|
|
11
|
+
dryRun: boolean;
|
|
12
|
+
}>;
|
|
13
|
+
runLoop: import("ai").Tool<{
|
|
14
|
+
featureName: string;
|
|
15
|
+
worktree: boolean;
|
|
16
|
+
resume: boolean;
|
|
17
|
+
reviewMode?: "manual" | "auto" | "merge" | undefined;
|
|
18
|
+
}, {
|
|
19
|
+
status: string;
|
|
20
|
+
iterations: number;
|
|
21
|
+
dryRun: boolean;
|
|
22
|
+
}>;
|
|
23
|
+
checkLoopStatus: import("ai").Tool<{
|
|
24
|
+
featureName: string;
|
|
25
|
+
}, {
|
|
26
|
+
status: string;
|
|
27
|
+
iteration: number;
|
|
28
|
+
maxIterations: number;
|
|
29
|
+
dryRun: boolean;
|
|
30
|
+
}>;
|
|
31
|
+
};
|
|
32
|
+
export declare function createDryRunReportingTools(): {
|
|
33
|
+
commentOnIssue: import("ai").Tool<{
|
|
34
|
+
issueNumber: number;
|
|
35
|
+
body: string;
|
|
36
|
+
}, {
|
|
37
|
+
success: boolean;
|
|
38
|
+
dryRun: boolean;
|
|
39
|
+
wouldComment: {
|
|
40
|
+
issueNumber: number;
|
|
41
|
+
bodyLength: number;
|
|
42
|
+
};
|
|
43
|
+
}>;
|
|
44
|
+
createIssue: import("ai").Tool<{
|
|
45
|
+
title: string;
|
|
46
|
+
body: string;
|
|
47
|
+
labels: string[];
|
|
48
|
+
}, {
|
|
49
|
+
success: boolean;
|
|
50
|
+
dryRun: boolean;
|
|
51
|
+
wouldCreate: {
|
|
52
|
+
title: string;
|
|
53
|
+
};
|
|
54
|
+
}>;
|
|
55
|
+
closeIssue: import("ai").Tool<{
|
|
56
|
+
issueNumber: number;
|
|
57
|
+
comment?: string | undefined;
|
|
58
|
+
}, {
|
|
59
|
+
success: boolean;
|
|
60
|
+
dryRun: boolean;
|
|
61
|
+
wouldClose: {
|
|
62
|
+
issueNumber: number;
|
|
63
|
+
};
|
|
64
|
+
}>;
|
|
65
|
+
checkAllBoxes: import("ai").Tool<{
|
|
66
|
+
issueNumber: number;
|
|
67
|
+
}, {
|
|
68
|
+
success: boolean;
|
|
69
|
+
dryRun: boolean;
|
|
70
|
+
wouldCheck: {
|
|
71
|
+
issueNumber: number;
|
|
72
|
+
};
|
|
73
|
+
}>;
|
|
74
|
+
};
|
|
75
|
+
export declare function createDryRunFeatureStateTools(): {
|
|
76
|
+
assessFeatureState: import("ai").Tool<{
|
|
77
|
+
featureName: string;
|
|
78
|
+
issueNumber?: number | undefined;
|
|
79
|
+
}, {
|
|
80
|
+
featureName: string;
|
|
81
|
+
branch: {
|
|
82
|
+
exists: boolean;
|
|
83
|
+
commitsAhead: number;
|
|
84
|
+
};
|
|
85
|
+
spec: {
|
|
86
|
+
exists: boolean;
|
|
87
|
+
};
|
|
88
|
+
plan: {
|
|
89
|
+
exists: boolean;
|
|
90
|
+
totalTasks: number;
|
|
91
|
+
completedTasks: number;
|
|
92
|
+
completionPercent: number;
|
|
93
|
+
};
|
|
94
|
+
pr: {
|
|
95
|
+
exists: boolean;
|
|
96
|
+
};
|
|
97
|
+
linkedPr: {
|
|
98
|
+
exists: boolean;
|
|
99
|
+
};
|
|
100
|
+
loopStatus: {
|
|
101
|
+
hasStatusFiles: boolean;
|
|
102
|
+
};
|
|
103
|
+
recommendation: "start_fresh";
|
|
104
|
+
dryRun: boolean;
|
|
105
|
+
}>;
|
|
106
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { tool, zodSchema } from 'ai';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { FEATURE_NAME_SCHEMA } from './schemas.js';
|
|
4
|
+
export function createDryRunExecutionTools() {
|
|
5
|
+
const generateSpec = tool({
|
|
6
|
+
description: '[DRY RUN] Simulates spec generation — returns a mock spec path without spawning any process.',
|
|
7
|
+
inputSchema: zodSchema(z.object({
|
|
8
|
+
featureName: FEATURE_NAME_SCHEMA,
|
|
9
|
+
issueNumber: z.number().int().describe('GitHub issue number'),
|
|
10
|
+
goals: z.string().optional().describe('Feature goals'),
|
|
11
|
+
model: z.string().optional().describe('Model override'),
|
|
12
|
+
provider: z.string().optional().describe('Provider override'),
|
|
13
|
+
})),
|
|
14
|
+
execute: async ({ featureName }) => ({
|
|
15
|
+
success: true,
|
|
16
|
+
specPath: `.ralph/specs/${featureName}.md`,
|
|
17
|
+
dryRun: true,
|
|
18
|
+
}),
|
|
19
|
+
});
|
|
20
|
+
const runLoop = tool({
|
|
21
|
+
description: '[DRY RUN] Simulates running the dev loop — returns a mock result without spawning any process.',
|
|
22
|
+
inputSchema: zodSchema(z.object({
|
|
23
|
+
featureName: FEATURE_NAME_SCHEMA,
|
|
24
|
+
worktree: z.boolean().default(true).describe('Use git worktree isolation'),
|
|
25
|
+
reviewMode: z.enum(['manual', 'auto', 'merge']).optional().describe("Review mode: 'manual' (stop at PR), 'auto' (review, no merge), or 'merge' (review + merge)"),
|
|
26
|
+
resume: z.boolean().default(false).describe('Resume a previous loop session'),
|
|
27
|
+
})),
|
|
28
|
+
execute: async () => ({
|
|
29
|
+
status: 'done',
|
|
30
|
+
iterations: 3,
|
|
31
|
+
dryRun: true,
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
const checkLoopStatus = tool({
|
|
35
|
+
description: '[DRY RUN] Simulates checking loop status.',
|
|
36
|
+
inputSchema: zodSchema(z.object({
|
|
37
|
+
featureName: FEATURE_NAME_SCHEMA,
|
|
38
|
+
})),
|
|
39
|
+
execute: async () => ({
|
|
40
|
+
status: 'done',
|
|
41
|
+
iteration: 3,
|
|
42
|
+
maxIterations: 10,
|
|
43
|
+
dryRun: true,
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
return { generateSpec, runLoop, checkLoopStatus };
|
|
47
|
+
}
|
|
48
|
+
export function createDryRunReportingTools() {
|
|
49
|
+
const commentOnIssue = tool({
|
|
50
|
+
description: '[DRY RUN] Simulates posting a comment on a GitHub issue without actually posting.',
|
|
51
|
+
inputSchema: zodSchema(z.object({
|
|
52
|
+
issueNumber: z.number().int().describe('Issue number'),
|
|
53
|
+
body: z.string().describe('Comment body'),
|
|
54
|
+
})),
|
|
55
|
+
execute: async ({ issueNumber, body }) => ({
|
|
56
|
+
success: true,
|
|
57
|
+
dryRun: true,
|
|
58
|
+
wouldComment: { issueNumber, bodyLength: body.length },
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
const createIssue = tool({
|
|
62
|
+
description: '[DRY RUN] Simulates creating a GitHub issue without actually creating it.',
|
|
63
|
+
inputSchema: zodSchema(z.object({
|
|
64
|
+
title: z.string().describe('Issue title'),
|
|
65
|
+
body: z.string().describe('Issue body'),
|
|
66
|
+
labels: z.array(z.string()).default([]).describe('Labels'),
|
|
67
|
+
})),
|
|
68
|
+
execute: async ({ title }) => ({
|
|
69
|
+
success: true,
|
|
70
|
+
dryRun: true,
|
|
71
|
+
wouldCreate: { title },
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
const closeIssue = tool({
|
|
75
|
+
description: '[DRY RUN] Simulates closing a GitHub issue without actually closing it.',
|
|
76
|
+
inputSchema: zodSchema(z.object({
|
|
77
|
+
issueNumber: z.number().int().describe('Issue number'),
|
|
78
|
+
comment: z.string().optional().describe('Closing comment'),
|
|
79
|
+
})),
|
|
80
|
+
execute: async ({ issueNumber }) => ({
|
|
81
|
+
success: true,
|
|
82
|
+
dryRun: true,
|
|
83
|
+
wouldClose: { issueNumber },
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
const checkAllBoxes = tool({
|
|
87
|
+
description: '[DRY RUN] Simulates checking all acceptance-criteria checkboxes.',
|
|
88
|
+
inputSchema: zodSchema(z.object({
|
|
89
|
+
issueNumber: z.number().int().describe('Issue number'),
|
|
90
|
+
})),
|
|
91
|
+
execute: async ({ issueNumber }) => ({
|
|
92
|
+
success: true,
|
|
93
|
+
dryRun: true,
|
|
94
|
+
wouldCheck: { issueNumber },
|
|
95
|
+
}),
|
|
96
|
+
});
|
|
97
|
+
return { commentOnIssue, createIssue, closeIssue, checkAllBoxes };
|
|
98
|
+
}
|
|
99
|
+
export function createDryRunFeatureStateTools() {
|
|
100
|
+
const assessFeatureState = tool({
|
|
101
|
+
description: '[DRY RUN] Simulates assessing feature state — returns a mock start_fresh recommendation.',
|
|
102
|
+
inputSchema: zodSchema(z.object({
|
|
103
|
+
featureName: FEATURE_NAME_SCHEMA,
|
|
104
|
+
issueNumber: z.number().int().optional().describe('GitHub issue number — enables linked PR detection when the feature was shipped under a different branch name'),
|
|
105
|
+
})),
|
|
106
|
+
execute: async ({ featureName }) => ({
|
|
107
|
+
featureName,
|
|
108
|
+
branch: { exists: false, commitsAhead: 0 },
|
|
109
|
+
spec: { exists: false },
|
|
110
|
+
plan: { exists: false, totalTasks: 0, completedTasks: 0, completionPercent: 0 },
|
|
111
|
+
pr: { exists: false },
|
|
112
|
+
linkedPr: { exists: false },
|
|
113
|
+
loopStatus: { hasStatusFiles: false },
|
|
114
|
+
recommendation: 'start_fresh',
|
|
115
|
+
dryRun: true,
|
|
116
|
+
}),
|
|
117
|
+
});
|
|
118
|
+
return { assessFeatureState };
|
|
119
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface ExecutionToolsOptions {
|
|
2
|
+
onProgress?: (toolName: string, line: string) => void;
|
|
3
|
+
}
|
|
4
|
+
export declare function createExecutionTools(projectRoot: string, options?: ExecutionToolsOptions): {
|
|
5
|
+
generateSpec: import("ai").Tool<{
|
|
6
|
+
featureName: string;
|
|
7
|
+
issueNumber: number;
|
|
8
|
+
goals?: string | undefined;
|
|
9
|
+
model?: string | undefined;
|
|
10
|
+
provider?: string | undefined;
|
|
11
|
+
}, {
|
|
12
|
+
success: boolean;
|
|
13
|
+
specPath?: string;
|
|
14
|
+
error?: string;
|
|
15
|
+
}>;
|
|
16
|
+
runLoop: import("ai").Tool<{
|
|
17
|
+
featureName: string;
|
|
18
|
+
worktree: boolean;
|
|
19
|
+
resume: boolean;
|
|
20
|
+
reviewMode?: "manual" | "auto" | "merge" | undefined;
|
|
21
|
+
}, {
|
|
22
|
+
status: string;
|
|
23
|
+
iterations?: number;
|
|
24
|
+
error?: string;
|
|
25
|
+
logPath: string;
|
|
26
|
+
}>;
|
|
27
|
+
checkLoopStatus: import("ai").Tool<{
|
|
28
|
+
featureName: string;
|
|
29
|
+
}, {
|
|
30
|
+
status: string;
|
|
31
|
+
iteration: number | undefined;
|
|
32
|
+
maxIterations: number | undefined;
|
|
33
|
+
timestamp: string;
|
|
34
|
+
logPath: string;
|
|
35
|
+
lastPhase?: undefined;
|
|
36
|
+
} | {
|
|
37
|
+
status: string;
|
|
38
|
+
lastPhase: string;
|
|
39
|
+
logPath: string;
|
|
40
|
+
iteration?: undefined;
|
|
41
|
+
maxIterations?: undefined;
|
|
42
|
+
timestamp?: undefined;
|
|
43
|
+
} | {
|
|
44
|
+
status: string;
|
|
45
|
+
logPath: string;
|
|
46
|
+
iteration?: undefined;
|
|
47
|
+
maxIterations?: undefined;
|
|
48
|
+
timestamp?: undefined;
|
|
49
|
+
lastPhase?: undefined;
|
|
50
|
+
}>;
|
|
51
|
+
};
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { tool, zodSchema } from 'ai';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { existsSync, readFileSync, openSync, closeSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { FEATURE_NAME_SCHEMA } from './schemas.js';
|
|
7
|
+
import { runPreflightChecks } from './preflight.js';
|
|
8
|
+
import { loadConfigWithDefaults } from '../../utils/config.js';
|
|
9
|
+
const SPEC_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
10
|
+
const LOOP_TIMEOUT_MS = 60 * 60 * 1000; // 60 minutes
|
|
11
|
+
const MAX_STDERR_BYTES = 256 * 1024; // 256 KB — only tail matters for error reporting
|
|
12
|
+
const MAX_STDOUT_BYTES = 64 * 1024; // 64 KB — only last line used (spec path)
|
|
13
|
+
function killWithTimeout(proc, timeoutMs) {
|
|
14
|
+
let fired = false;
|
|
15
|
+
const timer = setTimeout(() => {
|
|
16
|
+
fired = true;
|
|
17
|
+
proc.kill('SIGTERM');
|
|
18
|
+
const escalation = setTimeout(() => {
|
|
19
|
+
if (!proc.killed)
|
|
20
|
+
proc.kill('SIGKILL');
|
|
21
|
+
}, 5000);
|
|
22
|
+
escalation.unref();
|
|
23
|
+
}, timeoutMs);
|
|
24
|
+
return { timer, didTimeout: () => fired };
|
|
25
|
+
}
|
|
26
|
+
export function createExecutionTools(projectRoot, options) {
|
|
27
|
+
const emitProgress = options?.onProgress;
|
|
28
|
+
const generateSpec = tool({
|
|
29
|
+
description: 'Generate a feature spec from a GitHub issue using the interview agent in headless mode. Returns the spec file path on success.',
|
|
30
|
+
inputSchema: zodSchema(z.object({
|
|
31
|
+
featureName: FEATURE_NAME_SCHEMA,
|
|
32
|
+
issueNumber: z.number().int().describe('GitHub issue number to use as context'),
|
|
33
|
+
goals: z.string().optional().describe('Feature goals description'),
|
|
34
|
+
model: z.string().optional().describe('Model override (e.g. gpt-5.3-codex)'),
|
|
35
|
+
provider: z.string().optional().describe('Provider override (anthropic, openai, openrouter)'),
|
|
36
|
+
})),
|
|
37
|
+
execute: async ({ featureName, issueNumber, goals, model, provider }, { abortSignal }) => {
|
|
38
|
+
if (abortSignal?.aborted)
|
|
39
|
+
return { success: false, error: 'Aborted' };
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
const args = ['new', featureName, '--auto', '--issue', String(issueNumber)];
|
|
42
|
+
if (goals)
|
|
43
|
+
args.push('--goals', goals);
|
|
44
|
+
if (model)
|
|
45
|
+
args.push('--model', model);
|
|
46
|
+
if (provider)
|
|
47
|
+
args.push('--provider', provider);
|
|
48
|
+
const proc = spawn('wiggum', args, { cwd: projectRoot, stdio: 'pipe', env: { ...process.env, RALPH_AUTOMATED: '1' } });
|
|
49
|
+
const { timer, didTimeout } = killWithTimeout(proc, SPEC_TIMEOUT_MS);
|
|
50
|
+
let stdout = '';
|
|
51
|
+
let stderr = '';
|
|
52
|
+
let aborted = false;
|
|
53
|
+
let resolved = false;
|
|
54
|
+
const onAbort = () => {
|
|
55
|
+
aborted = true;
|
|
56
|
+
proc.kill('SIGTERM');
|
|
57
|
+
};
|
|
58
|
+
abortSignal?.addEventListener('abort', onAbort, { once: true });
|
|
59
|
+
proc.stdout?.on('data', (d) => {
|
|
60
|
+
stdout += d.toString();
|
|
61
|
+
if (stdout.length > MAX_STDOUT_BYTES)
|
|
62
|
+
stdout = stdout.slice(-MAX_STDOUT_BYTES);
|
|
63
|
+
});
|
|
64
|
+
proc.stderr?.on('data', (d) => {
|
|
65
|
+
const chunk = d.toString();
|
|
66
|
+
stderr += chunk;
|
|
67
|
+
if (stderr.length > MAX_STDERR_BYTES)
|
|
68
|
+
stderr = stderr.slice(-MAX_STDERR_BYTES);
|
|
69
|
+
if (emitProgress) {
|
|
70
|
+
for (const line of chunk.split('\n')) {
|
|
71
|
+
const trimmed = line.trim();
|
|
72
|
+
if (trimmed)
|
|
73
|
+
emitProgress('generateSpec', trimmed);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
proc.on('close', (code) => {
|
|
78
|
+
if (resolved)
|
|
79
|
+
return;
|
|
80
|
+
resolved = true;
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
abortSignal?.removeEventListener('abort', onAbort);
|
|
83
|
+
if (aborted) {
|
|
84
|
+
resolve({ success: false, error: 'Aborted' });
|
|
85
|
+
}
|
|
86
|
+
else if (didTimeout()) {
|
|
87
|
+
resolve({ success: false, error: `Timed out after ${SPEC_TIMEOUT_MS / 60000}m` });
|
|
88
|
+
}
|
|
89
|
+
else if (code === 0) {
|
|
90
|
+
const specPath = stdout.trim().split('\n').pop() ?? '';
|
|
91
|
+
resolve({ success: true, specPath });
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
resolve({ success: false, error: stderr.trim() || `Exit code ${code}` });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
proc.on('error', (err) => {
|
|
98
|
+
if (resolved)
|
|
99
|
+
return;
|
|
100
|
+
resolved = true;
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
abortSignal?.removeEventListener('abort', onAbort);
|
|
103
|
+
resolve({ success: false, error: err.message });
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
const runLoop = tool({
|
|
109
|
+
description: 'Run the development loop for a feature. Spawns a background process and returns when complete. The loop uses Claude Code internally with its own model config — do NOT forward the agent model here.',
|
|
110
|
+
inputSchema: zodSchema(z.object({
|
|
111
|
+
featureName: FEATURE_NAME_SCHEMA,
|
|
112
|
+
worktree: z.boolean().default(true).describe('Use git worktree isolation'),
|
|
113
|
+
reviewMode: z.enum(['manual', 'auto', 'merge']).optional().describe("Review mode: 'manual' (stop at PR), 'auto' (review, no merge), or 'merge' (review + merge)"),
|
|
114
|
+
resume: z.boolean().default(false).describe('Resume a previous loop session instead of starting fresh'),
|
|
115
|
+
})),
|
|
116
|
+
execute: async ({ featureName, worktree, reviewMode, resume }, { abortSignal }) => {
|
|
117
|
+
const logPath = join('/tmp', `ralph-loop-${featureName}.log`);
|
|
118
|
+
if (abortSignal?.aborted)
|
|
119
|
+
return { status: 'aborted', error: 'Aborted', logPath };
|
|
120
|
+
// Spec existence check — catch "forgot to call generateSpec" before spawning.
|
|
121
|
+
// Skip when resuming: the spec lives on the feature branch and the shell
|
|
122
|
+
// script validates it after checkout. Checking here would fail because
|
|
123
|
+
// the agent runs on main where the spec doesn't exist yet.
|
|
124
|
+
if (!resume) {
|
|
125
|
+
let specsDir = '.ralph/specs';
|
|
126
|
+
try {
|
|
127
|
+
const config = await loadConfigWithDefaults(projectRoot);
|
|
128
|
+
specsDir = config.paths.specs;
|
|
129
|
+
}
|
|
130
|
+
catch { /* non-fatal, use default */ }
|
|
131
|
+
const specPath = join(projectRoot, specsDir, `${featureName}.md`);
|
|
132
|
+
if (!existsSync(specPath)) {
|
|
133
|
+
return {
|
|
134
|
+
status: 'spec_missing',
|
|
135
|
+
error: `Spec file not found: ${specPath}. Call generateSpec first.`,
|
|
136
|
+
logPath,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// Pre-flight checks
|
|
141
|
+
const preflight = await runPreflightChecks(projectRoot, featureName, emitProgress);
|
|
142
|
+
if (!preflight.ok) {
|
|
143
|
+
return { status: 'preflight_failed', error: preflight.error, logPath };
|
|
144
|
+
}
|
|
145
|
+
// Signal loop start immediately — the subprocess writes to stdout (log file),
|
|
146
|
+
// not stderr, so the piped stderr stream won't carry progress events.
|
|
147
|
+
// This triggers TUI polling for temp file updates.
|
|
148
|
+
emitProgress?.('runLoop', `Ralph Loop: ${featureName}`);
|
|
149
|
+
return new Promise((resolve) => {
|
|
150
|
+
const args = ['run', featureName];
|
|
151
|
+
if (worktree)
|
|
152
|
+
args.push('--worktree');
|
|
153
|
+
if (reviewMode)
|
|
154
|
+
args.push('--review-mode', reviewMode);
|
|
155
|
+
if (resume)
|
|
156
|
+
args.push('--resume');
|
|
157
|
+
// Route stdout to log file so echo statements from the shell script populate the activity feed.
|
|
158
|
+
// stderr is piped for progress reporting; stdin is ignored.
|
|
159
|
+
const logFd = openSync(logPath, 'a');
|
|
160
|
+
const proc = spawn('wiggum', args, { cwd: projectRoot, stdio: ['ignore', logFd, 'pipe'], env: { ...process.env, RALPH_AUTOMATED: '1' } });
|
|
161
|
+
closeSync(logFd); // close parent's copy; child inherits its own fd
|
|
162
|
+
const { timer, didTimeout } = killWithTimeout(proc, LOOP_TIMEOUT_MS);
|
|
163
|
+
let stderr = '';
|
|
164
|
+
let aborted = false;
|
|
165
|
+
let resolved = false;
|
|
166
|
+
const onAbort = () => {
|
|
167
|
+
aborted = true;
|
|
168
|
+
proc.kill('SIGTERM');
|
|
169
|
+
};
|
|
170
|
+
abortSignal?.addEventListener('abort', onAbort, { once: true });
|
|
171
|
+
proc.stderr?.on('data', (d) => {
|
|
172
|
+
const chunk = d.toString();
|
|
173
|
+
stderr += chunk;
|
|
174
|
+
if (stderr.length > MAX_STDERR_BYTES)
|
|
175
|
+
stderr = stderr.slice(-MAX_STDERR_BYTES);
|
|
176
|
+
if (emitProgress) {
|
|
177
|
+
for (const line of chunk.split('\n')) {
|
|
178
|
+
const trimmed = line.trim();
|
|
179
|
+
if (trimmed)
|
|
180
|
+
emitProgress('runLoop', trimmed);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
proc.on('close', (code) => {
|
|
185
|
+
if (resolved)
|
|
186
|
+
return;
|
|
187
|
+
resolved = true;
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
abortSignal?.removeEventListener('abort', onAbort);
|
|
190
|
+
const finalPath = join('/tmp', `ralph-loop-${featureName}.final`);
|
|
191
|
+
if (aborted) {
|
|
192
|
+
resolve({ status: 'aborted', error: 'Aborted', logPath });
|
|
193
|
+
}
|
|
194
|
+
else if (didTimeout()) {
|
|
195
|
+
resolve({ status: 'timeout', error: `Timed out after ${LOOP_TIMEOUT_MS / 60000}m`, logPath });
|
|
196
|
+
}
|
|
197
|
+
else if (existsSync(finalPath)) {
|
|
198
|
+
const parts = readFileSync(finalPath, 'utf-8').trim().split('|');
|
|
199
|
+
resolve({
|
|
200
|
+
status: parts[3] ?? (code === 0 ? 'done' : 'failed'),
|
|
201
|
+
iterations: parseInt(parts[0], 10) || undefined,
|
|
202
|
+
logPath,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
resolve({
|
|
207
|
+
status: code === 0 ? 'done' : 'failed',
|
|
208
|
+
error: stderr.trim() || undefined,
|
|
209
|
+
logPath,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
proc.on('error', (err) => {
|
|
214
|
+
if (resolved)
|
|
215
|
+
return;
|
|
216
|
+
resolved = true;
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
abortSignal?.removeEventListener('abort', onAbort);
|
|
219
|
+
resolve({ status: 'error', error: err.message, logPath });
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
const checkLoopStatus = tool({
|
|
225
|
+
description: 'Check the current status of a running or completed development loop.',
|
|
226
|
+
inputSchema: zodSchema(z.object({
|
|
227
|
+
featureName: FEATURE_NAME_SCHEMA,
|
|
228
|
+
})),
|
|
229
|
+
execute: async ({ featureName }) => {
|
|
230
|
+
const prefix = join('/tmp', `ralph-loop-${featureName}`);
|
|
231
|
+
const logPath = `${prefix}.log`;
|
|
232
|
+
const finalPath = `${prefix}.final`;
|
|
233
|
+
if (existsSync(finalPath)) {
|
|
234
|
+
const parts = readFileSync(finalPath, 'utf-8').trim().split('|');
|
|
235
|
+
return {
|
|
236
|
+
status: parts[3] ?? 'unknown',
|
|
237
|
+
iteration: parseInt(parts[0], 10) || undefined,
|
|
238
|
+
maxIterations: parseInt(parts[1], 10) || undefined,
|
|
239
|
+
timestamp: parts[2] ?? undefined,
|
|
240
|
+
logPath,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const phasesPath = `${prefix}.phases`;
|
|
244
|
+
if (existsSync(phasesPath)) {
|
|
245
|
+
const lines = readFileSync(phasesPath, 'utf-8').trim().split('\n');
|
|
246
|
+
const lastLine = lines[lines.length - 1];
|
|
247
|
+
return { status: 'running', lastPhase: lastLine, logPath };
|
|
248
|
+
}
|
|
249
|
+
if (existsSync(logPath)) {
|
|
250
|
+
return { status: 'possibly_running', logPath };
|
|
251
|
+
}
|
|
252
|
+
return { status: 'not_found', logPath };
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
return { generateSpec, runLoop, checkLoopStatus };
|
|
256
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type Recommendation = 'start_fresh' | 'generate_plan' | 'resume_implementation' | 'resume_pr_phase' | 'pr_exists_open' | 'pr_merged' | 'pr_closed' | 'linked_pr_merged' | 'linked_pr_open';
|
|
2
|
+
export interface FeatureState {
|
|
3
|
+
featureName: string;
|
|
4
|
+
branch: {
|
|
5
|
+
exists: boolean;
|
|
6
|
+
commitsAhead: number;
|
|
7
|
+
};
|
|
8
|
+
spec: {
|
|
9
|
+
exists: boolean;
|
|
10
|
+
path?: string;
|
|
11
|
+
};
|
|
12
|
+
plan: {
|
|
13
|
+
exists: boolean;
|
|
14
|
+
path?: string;
|
|
15
|
+
totalTasks: number;
|
|
16
|
+
completedTasks: number;
|
|
17
|
+
completionPercent: number;
|
|
18
|
+
};
|
|
19
|
+
pr: {
|
|
20
|
+
exists: boolean;
|
|
21
|
+
state?: 'OPEN' | 'MERGED' | 'CLOSED';
|
|
22
|
+
number?: number;
|
|
23
|
+
url?: string;
|
|
24
|
+
};
|
|
25
|
+
linkedPr: {
|
|
26
|
+
exists: boolean;
|
|
27
|
+
state?: 'OPEN' | 'MERGED' | 'CLOSED';
|
|
28
|
+
number?: number;
|
|
29
|
+
url?: string;
|
|
30
|
+
headRefName?: string;
|
|
31
|
+
};
|
|
32
|
+
loopStatus: {
|
|
33
|
+
hasStatusFiles: boolean;
|
|
34
|
+
};
|
|
35
|
+
recommendation: Recommendation;
|
|
36
|
+
}
|
|
37
|
+
export declare function assessFeatureStateImpl(projectRoot: string, featureName: string, issueNumber?: number): Promise<FeatureState>;
|
|
38
|
+
export declare function createFeatureStateTools(projectRoot: string): {
|
|
39
|
+
assessFeatureState: import("ai").Tool<{
|
|
40
|
+
featureName: string;
|
|
41
|
+
issueNumber?: number | undefined;
|
|
42
|
+
}, FeatureState>;
|
|
43
|
+
};
|