agentic-forge 0.0.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/.gitattributes +24 -0
- package/.github/workflows/ci.yml +70 -0
- package/.markdownlint-cli2.jsonc +16 -0
- package/.prettierignore +3 -0
- package/.prettierrc +6 -0
- package/.vscode/agentic-forge.code-workspace +26 -0
- package/CHANGELOG.md +100 -0
- package/CLAUDE.md +158 -0
- package/CONTRIBUTING.md +152 -0
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/agentic-forge-banner.png +0 -0
- package/biome.json +21 -0
- package/package.json +5 -0
- package/scripts/copy-assets.js +21 -0
- package/src/agents/explorer.md +97 -0
- package/src/agents/reviewer.md +137 -0
- package/src/checkpoints/manager.ts +119 -0
- package/src/claude/.claude/skills/analyze/SKILL.md +241 -0
- package/src/claude/.claude/skills/analyze/references/bug.md +62 -0
- package/src/claude/.claude/skills/analyze/references/debt.md +76 -0
- package/src/claude/.claude/skills/analyze/references/doc.md +67 -0
- package/src/claude/.claude/skills/analyze/references/security.md +76 -0
- package/src/claude/.claude/skills/analyze/references/style.md +72 -0
- package/src/claude/.claude/skills/create-checkpoint/SKILL.md +88 -0
- package/src/claude/.claude/skills/create-log/SKILL.md +75 -0
- package/src/claude/.claude/skills/fix-analyze/SKILL.md +102 -0
- package/src/claude/.claude/skills/git-branch/SKILL.md +71 -0
- package/src/claude/.claude/skills/git-commit/SKILL.md +107 -0
- package/src/claude/.claude/skills/git-pr/SKILL.md +96 -0
- package/src/claude/.claude/skills/orchestrate/SKILL.md +120 -0
- package/src/claude/.claude/skills/sdlc-plan/SKILL.md +163 -0
- package/src/claude/.claude/skills/sdlc-plan/references/bug.md +115 -0
- package/src/claude/.claude/skills/sdlc-plan/references/chore.md +105 -0
- package/src/claude/.claude/skills/sdlc-plan/references/feature.md +130 -0
- package/src/claude/.claude/skills/sdlc-review/SKILL.md +215 -0
- package/src/claude/.claude/skills/workflow-builder/SKILL.md +185 -0
- package/src/claude/.claude/skills/workflow-builder/references/REFERENCE.md +487 -0
- package/src/claude/.claude/skills/workflow-builder/references/workflow-example.yaml +427 -0
- package/src/cli.ts +182 -0
- package/src/commands/config-cmd.ts +28 -0
- package/src/commands/index.ts +21 -0
- package/src/commands/init.ts +96 -0
- package/src/commands/release-notes.ts +85 -0
- package/src/commands/resume.ts +103 -0
- package/src/commands/run.ts +234 -0
- package/src/commands/shortcuts.ts +11 -0
- package/src/commands/skills-dir.ts +11 -0
- package/src/commands/status.ts +112 -0
- package/src/commands/update.ts +64 -0
- package/src/commands/version.ts +27 -0
- package/src/commands/workflows.ts +129 -0
- package/src/config.ts +129 -0
- package/src/console.ts +790 -0
- package/src/executor.ts +354 -0
- package/src/git/worktree.ts +236 -0
- package/src/logging/logger.ts +95 -0
- package/src/orchestrator.ts +815 -0
- package/src/parser.ts +225 -0
- package/src/progress.ts +306 -0
- package/src/prompts/agentic-system.md +31 -0
- package/src/ralph-loop.ts +260 -0
- package/src/renderer.ts +164 -0
- package/src/runner.ts +634 -0
- package/src/signal-manager.ts +55 -0
- package/src/steps/base.ts +71 -0
- package/src/steps/conditional-step.ts +144 -0
- package/src/steps/index.ts +15 -0
- package/src/steps/parallel-step.ts +213 -0
- package/src/steps/prompt-step.ts +121 -0
- package/src/steps/ralph-loop-step.ts +186 -0
- package/src/steps/serial-step.ts +84 -0
- package/src/templates/analysis/bug.md.j2 +35 -0
- package/src/templates/analysis/debt.md.j2 +38 -0
- package/src/templates/analysis/doc.md.j2 +45 -0
- package/src/templates/analysis/security.md.j2 +35 -0
- package/src/templates/analysis/style.md.j2 +44 -0
- package/src/templates/analysis-summary.md.j2 +58 -0
- package/src/templates/checkpoint.md.j2 +27 -0
- package/src/templates/implementation-report.md.j2 +81 -0
- package/src/templates/memory.md.j2 +16 -0
- package/src/templates/plan-bug.md.j2 +42 -0
- package/src/templates/plan-chore.md.j2 +27 -0
- package/src/templates/plan-feature.md.j2 +41 -0
- package/src/templates/progress.json.j2 +16 -0
- package/src/templates/ralph-report.md.j2 +45 -0
- package/src/types.ts +141 -0
- package/src/workflows/analyze-codebase-merge.yaml +328 -0
- package/src/workflows/analyze-codebase.yaml +196 -0
- package/src/workflows/analyze-single.yaml +56 -0
- package/src/workflows/demo.yaml +180 -0
- package/src/workflows/one-shot.yaml +54 -0
- package/src/workflows/plan-build-review.yaml +160 -0
- package/src/workflows/ralph-loop.yaml +73 -0
- package/tests/config.test.ts +219 -0
- package/tests/console.test.ts +506 -0
- package/tests/executor.test.ts +339 -0
- package/tests/init.test.ts +86 -0
- package/tests/logger.test.ts +110 -0
- package/tests/parser.test.ts +290 -0
- package/tests/progress.test.ts +345 -0
- package/tests/ralph-loop.test.ts +418 -0
- package/tests/renderer.test.ts +350 -0
- package/tests/runner.test.ts +497 -0
- package/tests/setup.test.ts +7 -0
- package/tests/signal-manager.test.ts +26 -0
- package/tests/steps.test.ts +412 -0
- package/tests/worktree.test.ts +411 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +8 -0
package/src/runner.ts
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
/** Claude CLI runner for workflow orchestration. */
|
|
2
|
+
|
|
3
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
import type { ConsoleOutput } from "./console.js";
|
|
9
|
+
|
|
10
|
+
// --- Path constants ---
|
|
11
|
+
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
|
|
14
|
+
/** Path to the bundled skills directory for --add-dir. */
|
|
15
|
+
export const SKILLS_DIR = path.join(__dirname, "claude");
|
|
16
|
+
|
|
17
|
+
/** Path to the agentic system prompt file. */
|
|
18
|
+
export const AGENTIC_SYSTEM_PROMPT_FILE = path.join(__dirname, "prompts", "agentic-system.md");
|
|
19
|
+
|
|
20
|
+
// --- Model mapping ---
|
|
21
|
+
|
|
22
|
+
export const MODEL_MAP: Record<string, string> = {
|
|
23
|
+
sonnet: "sonnet",
|
|
24
|
+
haiku: "haiku",
|
|
25
|
+
opus: "opus",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// --- Executable resolution ---
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve executable path for cross-platform subprocess calls.
|
|
32
|
+
*
|
|
33
|
+
* Uses `where` on Windows and `which` on Unix to find the full path,
|
|
34
|
+
* allowing shell=false in subprocess calls.
|
|
35
|
+
*/
|
|
36
|
+
export function getExecutable(name: string): string {
|
|
37
|
+
try {
|
|
38
|
+
const cmd = process.platform === "win32" ? "where" : "which";
|
|
39
|
+
const result = execFileSync(cmd, [name], {
|
|
40
|
+
encoding: "utf-8",
|
|
41
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
42
|
+
});
|
|
43
|
+
const firstLine = result.trim().split(/\r?\n/)[0];
|
|
44
|
+
if (firstLine) {
|
|
45
|
+
return firstLine;
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// Fall through to error
|
|
49
|
+
}
|
|
50
|
+
throw new FileNotFoundError(`Executable not found in PATH: ${name}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Custom error for missing executables. */
|
|
54
|
+
export class FileNotFoundError extends Error {
|
|
55
|
+
constructor(message: string) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = "FileNotFoundError";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- Stream-JSON parsing ---
|
|
62
|
+
|
|
63
|
+
/** Parse a single line of stream-json output. */
|
|
64
|
+
export function parseStreamJsonLine(line: string): Record<string, unknown> | null {
|
|
65
|
+
const trimmed = line.trim();
|
|
66
|
+
if (!trimmed.startsWith("{")) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(trimmed) as Record<string, unknown>;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Extract model name from an assistant or system message. */
|
|
77
|
+
export function extractModelFromMessage(data: Record<string, unknown>): string | null {
|
|
78
|
+
const msgType = data.type;
|
|
79
|
+
|
|
80
|
+
if (msgType === "assistant") {
|
|
81
|
+
const message = data.message as Record<string, unknown> | undefined;
|
|
82
|
+
return (message?.model as string) ?? null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (msgType === "system") {
|
|
86
|
+
return (data.model as string) ?? null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Format model name for display. */
|
|
93
|
+
export function formatModelName(model: string | null): string {
|
|
94
|
+
if (!model) {
|
|
95
|
+
return "";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Pattern 1: claude-{tier}-{major}-{minor}-{date}
|
|
99
|
+
const pattern1 = /^claude-(sonnet|opus|haiku)-(\d+)-(\d+)-\d{8}$/;
|
|
100
|
+
let match = pattern1.exec(model);
|
|
101
|
+
if (match) {
|
|
102
|
+
const [, tier, major, minor] = match;
|
|
103
|
+
return `${tier}-${major}.${minor}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Pattern 2: claude-{major}-{minor}-{tier}-{date}
|
|
107
|
+
const pattern2 = /^claude-(\d+)-(\d+)-(sonnet|opus|haiku)-\d{8}$/;
|
|
108
|
+
match = pattern2.exec(model);
|
|
109
|
+
if (match) {
|
|
110
|
+
const [, major, minor, tier] = match;
|
|
111
|
+
return `${tier}-${major}.${minor}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Pattern 3: claude-{tier}-{date} (no version)
|
|
115
|
+
const pattern3 = /^claude-(sonnet|opus|haiku)-\d{8}$/;
|
|
116
|
+
match = pattern3.exec(model);
|
|
117
|
+
if (match) {
|
|
118
|
+
return match[1];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return model;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Extract text content from an assistant message.
|
|
126
|
+
*
|
|
127
|
+
* Handles two stream-json formats:
|
|
128
|
+
* 1. Verbose format: {"type": "assistant", "message": {"content": [...]}}
|
|
129
|
+
* 2. Stream event format: {"type": "stream_event", "event": {"type": "content_block_delta", ...}}
|
|
130
|
+
*
|
|
131
|
+
* Returns array of [content_block_index, text] tuples.
|
|
132
|
+
*/
|
|
133
|
+
export function extractTextFromMessage(data: Record<string, unknown>): Array<[number, string]> {
|
|
134
|
+
const msgType = data.type;
|
|
135
|
+
const results: Array<[number, string]> = [];
|
|
136
|
+
|
|
137
|
+
if (msgType === "assistant") {
|
|
138
|
+
const message = (data.message ?? {}) as Record<string, unknown>;
|
|
139
|
+
const content = (message.content ?? []) as Array<Record<string, unknown>>;
|
|
140
|
+
|
|
141
|
+
for (let idx = 0; idx < content.length; idx++) {
|
|
142
|
+
const block = content[idx];
|
|
143
|
+
if (typeof block === "object" && block !== null && block.type === "text") {
|
|
144
|
+
const text = (block.text as string) ?? "";
|
|
145
|
+
if (text) {
|
|
146
|
+
results.push([idx, text]);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} else if (msgType === "stream_event") {
|
|
151
|
+
const event = (data.event ?? {}) as Record<string, unknown>;
|
|
152
|
+
const eventType = event.type;
|
|
153
|
+
|
|
154
|
+
if (eventType === "content_block_delta") {
|
|
155
|
+
const idx = (event.index as number) ?? 0;
|
|
156
|
+
const delta = (event.delta ?? {}) as Record<string, unknown>;
|
|
157
|
+
if (delta.type === "text_delta") {
|
|
158
|
+
const text = (delta.text as string) ?? "";
|
|
159
|
+
if (text) {
|
|
160
|
+
results.push([idx, text]);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return results;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Extract text content from a user message. */
|
|
170
|
+
export function extractUserText(data: Record<string, unknown>): string | null {
|
|
171
|
+
if (data.type !== "user") {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const message = (data.message ?? {}) as Record<string, unknown>;
|
|
176
|
+
const content = (message.content ?? []) as Array<Record<string, unknown> | string>;
|
|
177
|
+
|
|
178
|
+
const texts: string[] = [];
|
|
179
|
+
for (const block of content) {
|
|
180
|
+
if (typeof block === "object" && block !== null && block.type === "text") {
|
|
181
|
+
const text = (block.text as string) ?? "";
|
|
182
|
+
if (text) {
|
|
183
|
+
texts.push(text);
|
|
184
|
+
}
|
|
185
|
+
} else if (typeof block === "string") {
|
|
186
|
+
texts.push(block);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return texts.length > 0 ? texts.join("\n") : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Extract the final result text from a result message. */
|
|
194
|
+
export function extractResultText(data: Record<string, unknown>): string | null {
|
|
195
|
+
if (data.type !== "result") {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
return (data.result as string) ?? null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --- System prompt ---
|
|
202
|
+
|
|
203
|
+
/** Load the agentic system prompt from file. */
|
|
204
|
+
export function getAgenticSystemPrompt(): string | null {
|
|
205
|
+
if (existsSync(AGENTIC_SYSTEM_PROMPT_FILE)) {
|
|
206
|
+
return readFileSync(AGENTIC_SYSTEM_PROMPT_FILE, "utf-8");
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- SessionOutput ---
|
|
212
|
+
|
|
213
|
+
export class SessionOutput {
|
|
214
|
+
sessionId: string | null;
|
|
215
|
+
isSuccess: boolean;
|
|
216
|
+
context: string;
|
|
217
|
+
extra: Record<string, unknown>;
|
|
218
|
+
rawJson: Record<string, unknown> | null;
|
|
219
|
+
|
|
220
|
+
constructor(
|
|
221
|
+
sessionId: string | null = null,
|
|
222
|
+
isSuccess = false,
|
|
223
|
+
context = "",
|
|
224
|
+
extra: Record<string, unknown> = {},
|
|
225
|
+
rawJson: Record<string, unknown> | null = null,
|
|
226
|
+
) {
|
|
227
|
+
this.sessionId = sessionId;
|
|
228
|
+
this.isSuccess = isSuccess;
|
|
229
|
+
this.context = context;
|
|
230
|
+
this.extra = extra;
|
|
231
|
+
this.rawJson = rawJson;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Extract the session output JSON from Claude's stdout.
|
|
236
|
+
*
|
|
237
|
+
* Looks for the last JSON block that contains the required base keys
|
|
238
|
+
* (sessionId, isSuccess, context).
|
|
239
|
+
*/
|
|
240
|
+
static fromStdout(stdout: string): SessionOutput {
|
|
241
|
+
if (!stdout) {
|
|
242
|
+
return new SessionOutput(null, false, "No output received from Claude session");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Find all JSON blocks in code blocks
|
|
246
|
+
const jsonPattern = /```(?:json)?\s*(\{[^`]*\})\s*```/gs;
|
|
247
|
+
const codeBlockMatches = [...stdout.matchAll(jsonPattern)].map((m) => m[1]);
|
|
248
|
+
|
|
249
|
+
// Also look for bare JSON objects with sessionId
|
|
250
|
+
const bareJsonPattern = /\{[^{}]*"sessionId"[^{}]*\}/gs;
|
|
251
|
+
const bareMatches = [...stdout.matchAll(bareJsonPattern)].map((m) => m[0]);
|
|
252
|
+
|
|
253
|
+
const allMatches = [...codeBlockMatches, ...bareMatches];
|
|
254
|
+
|
|
255
|
+
// Try to parse each match, starting from the last (most recent)
|
|
256
|
+
for (let i = allMatches.length - 1; i >= 0; i--) {
|
|
257
|
+
try {
|
|
258
|
+
const data = JSON.parse(allMatches[i]) as Record<string, unknown>;
|
|
259
|
+
if ("sessionId" in data && "isSuccess" in data && "context" in data) {
|
|
260
|
+
const extra: Record<string, unknown> = {};
|
|
261
|
+
for (const [k, v] of Object.entries(data)) {
|
|
262
|
+
if (k !== "sessionId" && k !== "isSuccess" && k !== "context") {
|
|
263
|
+
extra[k] = v;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return new SessionOutput(
|
|
267
|
+
data.sessionId as string | null,
|
|
268
|
+
Boolean(data.isSuccess),
|
|
269
|
+
(data.context as string) ?? "",
|
|
270
|
+
extra,
|
|
271
|
+
data,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
// Invalid JSON, try next match
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return new SessionOutput(null, false, "No valid session output JSON found in Claude response");
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// --- ClaudeResult ---
|
|
284
|
+
|
|
285
|
+
export class ClaudeResult {
|
|
286
|
+
returncode: number;
|
|
287
|
+
stdout: string;
|
|
288
|
+
stderr: string;
|
|
289
|
+
prompt: string;
|
|
290
|
+
cwd: string | null;
|
|
291
|
+
model: string | null;
|
|
292
|
+
private _sessionOutput: SessionOutput | null = null;
|
|
293
|
+
|
|
294
|
+
constructor(
|
|
295
|
+
returncode: number,
|
|
296
|
+
stdout: string,
|
|
297
|
+
stderr: string,
|
|
298
|
+
prompt: string,
|
|
299
|
+
cwd: string | null,
|
|
300
|
+
model: string | null = null,
|
|
301
|
+
) {
|
|
302
|
+
this.returncode = returncode;
|
|
303
|
+
this.stdout = stdout;
|
|
304
|
+
this.stderr = stderr;
|
|
305
|
+
this.prompt = prompt;
|
|
306
|
+
this.cwd = cwd;
|
|
307
|
+
this.model = model;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
get success(): boolean {
|
|
311
|
+
return this.returncode === 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
get sessionOutput(): SessionOutput {
|
|
315
|
+
if (this._sessionOutput === null) {
|
|
316
|
+
this._sessionOutput = SessionOutput.fromStdout(this.stdout);
|
|
317
|
+
}
|
|
318
|
+
return this._sessionOutput;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
toString(): string {
|
|
322
|
+
const status = this.success ? "SUCCESS" : "FAILED";
|
|
323
|
+
const modelStr = this.model ? `, model=${this.model}` : "";
|
|
324
|
+
return `ClaudeResult(${status}${modelStr})`;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// --- runClaude options ---
|
|
329
|
+
|
|
330
|
+
export interface RunClaudeOptions {
|
|
331
|
+
prompt: string;
|
|
332
|
+
cwd?: string | null;
|
|
333
|
+
model?: string;
|
|
334
|
+
timeout?: number | null;
|
|
335
|
+
printOutput?: boolean;
|
|
336
|
+
skipPermissions?: boolean;
|
|
337
|
+
allowedTools?: string[] | null;
|
|
338
|
+
console?: ConsoleOutput | null;
|
|
339
|
+
appendSystemPrompt?: boolean;
|
|
340
|
+
workflowId?: string | null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// --- Main run function ---
|
|
344
|
+
|
|
345
|
+
/** Run claude with the given prompt. */
|
|
346
|
+
export function runClaude(options: RunClaudeOptions): Promise<ClaudeResult> {
|
|
347
|
+
const {
|
|
348
|
+
prompt,
|
|
349
|
+
cwd = null,
|
|
350
|
+
model = "sonnet",
|
|
351
|
+
timeout = 300,
|
|
352
|
+
printOutput = false,
|
|
353
|
+
skipPermissions = false,
|
|
354
|
+
allowedTools = null,
|
|
355
|
+
console: consoleOutput = null,
|
|
356
|
+
appendSystemPrompt = true,
|
|
357
|
+
workflowId = null,
|
|
358
|
+
} = options;
|
|
359
|
+
|
|
360
|
+
const claudePath = getExecutable("claude");
|
|
361
|
+
const cmd = [claudePath, "--print"];
|
|
362
|
+
|
|
363
|
+
// Add bundled skills directory for skill discovery
|
|
364
|
+
if (existsSync(SKILLS_DIR)) {
|
|
365
|
+
cmd.push("--add-dir", SKILLS_DIR);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Use stream-json format when streaming output for real-time parsing
|
|
369
|
+
if (printOutput) {
|
|
370
|
+
cmd.push("--output-format", "stream-json", "--verbose");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (model && model in MODEL_MAP) {
|
|
374
|
+
cmd.push("--model", MODEL_MAP[model]);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (skipPermissions) {
|
|
378
|
+
cmd.push("--dangerously-skip-permissions");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (allowedTools) {
|
|
382
|
+
for (const tool of allowedTools) {
|
|
383
|
+
cmd.push("--allowedTools", tool);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Append agentic system prompt for standardized output format
|
|
388
|
+
if (appendSystemPrompt) {
|
|
389
|
+
const systemPrompt = getAgenticSystemPrompt();
|
|
390
|
+
if (systemPrompt) {
|
|
391
|
+
cmd.push("--append-system-prompt", systemPrompt);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const cwdStr = cwd ?? undefined;
|
|
396
|
+
|
|
397
|
+
// Set up environment with OTEL tracing if workflow_id is provided
|
|
398
|
+
const env = workflowId
|
|
399
|
+
? { ...process.env, OTEL_RESOURCE_ATTRIBUTES: `session=${workflowId}` }
|
|
400
|
+
: undefined;
|
|
401
|
+
|
|
402
|
+
if (printOutput) {
|
|
403
|
+
return runClaudeStreaming(cmd, prompt, cwdStr, env, timeout, consoleOutput, model, cwd);
|
|
404
|
+
}
|
|
405
|
+
return runClaudeNonStreaming(cmd, prompt, cwdStr, env, timeout, model, cwd);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function runClaudeStreaming(
|
|
409
|
+
cmd: string[],
|
|
410
|
+
prompt: string,
|
|
411
|
+
cwdStr: string | undefined,
|
|
412
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
413
|
+
timeout: number | null,
|
|
414
|
+
consoleOutput: ConsoleOutput | null,
|
|
415
|
+
model: string | null,
|
|
416
|
+
cwd: string | null,
|
|
417
|
+
): Promise<ClaudeResult> {
|
|
418
|
+
return new Promise((resolve) => {
|
|
419
|
+
const [executable, ...args] = cmd;
|
|
420
|
+
const proc = spawn(executable, args, {
|
|
421
|
+
cwd: cwdStr,
|
|
422
|
+
env,
|
|
423
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
if (proc.stdin) {
|
|
427
|
+
proc.stdin.write(prompt);
|
|
428
|
+
proc.stdin.end();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Show user prompt at start in ALL mode
|
|
432
|
+
if (consoleOutput) {
|
|
433
|
+
consoleOutput.streamText(prompt, "user");
|
|
434
|
+
consoleOutput.streamComplete();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const collectedText: string[] = [];
|
|
438
|
+
let resultText: string | null = null;
|
|
439
|
+
const accumulatedText: Map<number, string> = new Map();
|
|
440
|
+
let currentModel: string | null = null;
|
|
441
|
+
let hasStreamedContent = false;
|
|
442
|
+
let stderrData = "";
|
|
443
|
+
|
|
444
|
+
if (proc.stderr) {
|
|
445
|
+
proc.stderr.on("data", (chunk: Buffer) => {
|
|
446
|
+
stderrData += chunk.toString("utf-8");
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
let lineBuffer = "";
|
|
451
|
+
|
|
452
|
+
if (proc.stdout) {
|
|
453
|
+
proc.stdout.on("data", (chunk: Buffer) => {
|
|
454
|
+
lineBuffer += chunk.toString("utf-8");
|
|
455
|
+
const lines = lineBuffer.split("\n");
|
|
456
|
+
// Keep the last partial line in the buffer
|
|
457
|
+
lineBuffer = lines.pop() ?? "";
|
|
458
|
+
|
|
459
|
+
for (const line of lines) {
|
|
460
|
+
const data = parseStreamJsonLine(line);
|
|
461
|
+
if (data === null) continue;
|
|
462
|
+
|
|
463
|
+
// Extract model from assistant messages
|
|
464
|
+
const extractedModel = extractModelFromMessage(data);
|
|
465
|
+
if (extractedModel) {
|
|
466
|
+
currentModel = extractedModel;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const msgType = data.type;
|
|
470
|
+
|
|
471
|
+
// When a new assistant message starts, complete the previous one
|
|
472
|
+
if (msgType === "assistant" && hasStreamedContent && consoleOutput) {
|
|
473
|
+
consoleOutput.streamComplete();
|
|
474
|
+
hasStreamedContent = false;
|
|
475
|
+
accumulatedText.clear();
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Handle user messages (tool results in stream-json)
|
|
479
|
+
const userText = extractUserText(data);
|
|
480
|
+
if (userText && consoleOutput) {
|
|
481
|
+
if (hasStreamedContent) {
|
|
482
|
+
consoleOutput.streamComplete();
|
|
483
|
+
hasStreamedContent = false;
|
|
484
|
+
}
|
|
485
|
+
consoleOutput.streamText(userText, "user");
|
|
486
|
+
consoleOutput.streamComplete();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Extract text from assistant messages for streaming
|
|
490
|
+
const isStreamEvent = msgType === "stream_event";
|
|
491
|
+
|
|
492
|
+
for (const [idx, text] of extractTextFromMessage(data)) {
|
|
493
|
+
let delta: string;
|
|
494
|
+
if (isStreamEvent) {
|
|
495
|
+
delta = text;
|
|
496
|
+
} else {
|
|
497
|
+
const prevText = accumulatedText.get(idx) ?? "";
|
|
498
|
+
delta = text.startsWith(prevText) ? text.slice(prevText.length) : text;
|
|
499
|
+
accumulatedText.set(idx, text);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (delta) {
|
|
503
|
+
if (consoleOutput) {
|
|
504
|
+
consoleOutput.streamText(delta, "assistant", currentModel);
|
|
505
|
+
hasStreamedContent = true;
|
|
506
|
+
} else {
|
|
507
|
+
process.stdout.write(delta);
|
|
508
|
+
}
|
|
509
|
+
collectedText.push(delta);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Capture final result
|
|
514
|
+
const result = extractResultText(data);
|
|
515
|
+
if (result !== null) {
|
|
516
|
+
resultText = result;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Handle timeout
|
|
523
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
524
|
+
if (timeout) {
|
|
525
|
+
timeoutId = setTimeout(() => {
|
|
526
|
+
proc.kill();
|
|
527
|
+
}, timeout * 1000);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
proc.on("close", (code) => {
|
|
531
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
532
|
+
|
|
533
|
+
// Process any remaining data in the line buffer
|
|
534
|
+
if (lineBuffer.trim()) {
|
|
535
|
+
const data = parseStreamJsonLine(lineBuffer);
|
|
536
|
+
if (data) {
|
|
537
|
+
const result = extractResultText(data);
|
|
538
|
+
if (result !== null) {
|
|
539
|
+
resultText = result;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (consoleOutput && hasStreamedContent) {
|
|
545
|
+
consoleOutput.streamComplete();
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const finalOutput = resultText ?? collectedText.join("");
|
|
549
|
+
|
|
550
|
+
resolve(new ClaudeResult(code ?? 1, finalOutput, stderrData, prompt, cwd, model));
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function runClaudeNonStreaming(
|
|
556
|
+
cmd: string[],
|
|
557
|
+
prompt: string,
|
|
558
|
+
cwdStr: string | undefined,
|
|
559
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
560
|
+
timeout: number | null,
|
|
561
|
+
model: string | null,
|
|
562
|
+
cwd: string | null,
|
|
563
|
+
): Promise<ClaudeResult> {
|
|
564
|
+
return new Promise((resolve) => {
|
|
565
|
+
const [executable, ...args] = cmd;
|
|
566
|
+
const proc = spawn(executable, args, {
|
|
567
|
+
cwd: cwdStr,
|
|
568
|
+
env,
|
|
569
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
if (proc.stdin) {
|
|
573
|
+
proc.stdin.write(prompt);
|
|
574
|
+
proc.stdin.end();
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
let stdoutData = "";
|
|
578
|
+
let stderrData = "";
|
|
579
|
+
|
|
580
|
+
if (proc.stdout) {
|
|
581
|
+
proc.stdout.on("data", (chunk: Buffer) => {
|
|
582
|
+
stdoutData += chunk.toString("utf-8");
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (proc.stderr) {
|
|
587
|
+
proc.stderr.on("data", (chunk: Buffer) => {
|
|
588
|
+
stderrData += chunk.toString("utf-8");
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
593
|
+
let timedOut = false;
|
|
594
|
+
|
|
595
|
+
if (timeout) {
|
|
596
|
+
timeoutId = setTimeout(() => {
|
|
597
|
+
timedOut = true;
|
|
598
|
+
proc.kill();
|
|
599
|
+
}, timeout * 1000);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
proc.on("close", (code) => {
|
|
603
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
604
|
+
|
|
605
|
+
if (timedOut) {
|
|
606
|
+
resolve(
|
|
607
|
+
new ClaudeResult(1, "", `Command timed out after ${timeout} seconds`, prompt, cwd, model),
|
|
608
|
+
);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
resolve(new ClaudeResult(code ?? 1, stdoutData, stderrData, prompt, cwd, model));
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Check if the claude CLI is available. */
|
|
618
|
+
export function checkClaudeAvailable(): boolean {
|
|
619
|
+
try {
|
|
620
|
+
const claudePath = getExecutable("claude");
|
|
621
|
+
const result = execFileSync(claudePath, ["--version"], {
|
|
622
|
+
encoding: "utf-8",
|
|
623
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
624
|
+
timeout: 10000,
|
|
625
|
+
});
|
|
626
|
+
return result !== undefined;
|
|
627
|
+
} catch (err) {
|
|
628
|
+
if (err instanceof FileNotFoundError) {
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
// execFileSync throws on non-zero exit or timeout
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Signal handling for graceful shutdown. */
|
|
2
|
+
|
|
3
|
+
import type { WorkflowProgress } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export class SignalManager {
|
|
6
|
+
private _shutdownRequested = false;
|
|
7
|
+
private readonly _onShutdown: (() => void) | null;
|
|
8
|
+
|
|
9
|
+
constructor(onShutdown?: () => void) {
|
|
10
|
+
this._onShutdown = onShutdown ?? null;
|
|
11
|
+
this._installHandlers();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
private _installHandlers(): void {
|
|
15
|
+
const handler = () => this._handleShutdown();
|
|
16
|
+
|
|
17
|
+
process.on("SIGINT", handler);
|
|
18
|
+
|
|
19
|
+
if (process.platform !== "win32") {
|
|
20
|
+
process.on("SIGTERM", handler);
|
|
21
|
+
} else {
|
|
22
|
+
process.on("SIGBREAK", handler);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private _handleShutdown(): void {
|
|
27
|
+
this._shutdownRequested = true;
|
|
28
|
+
console.log("\nShutdown requested, cleaning up...");
|
|
29
|
+
if (this._onShutdown) {
|
|
30
|
+
this._onShutdown();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get shutdownRequested(): boolean {
|
|
35
|
+
return this._shutdownRequested;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
requestShutdown(): void {
|
|
39
|
+
this._shutdownRequested = true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function handleGracefulShutdown(
|
|
44
|
+
progress: WorkflowProgress,
|
|
45
|
+
logger: { info: (step: string, message: string) => void },
|
|
46
|
+
repoRoot: string,
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
// Lazy import to avoid circular dependencies
|
|
49
|
+
const { pruneOrphaned } = await import("./git/worktree.js");
|
|
50
|
+
|
|
51
|
+
logger.info("orchestrator", "Performing graceful shutdown");
|
|
52
|
+
progress.status = "canceled";
|
|
53
|
+
|
|
54
|
+
pruneOrphaned(repoRoot);
|
|
55
|
+
}
|