@welluable/orch 1.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.
@@ -0,0 +1,214 @@
1
+ /**
2
+ * @typedef {'started'|'completed'} ToolPhase
3
+ * @typedef {{ name: string, args: Record<string, unknown>, phase: ToolPhase, callId: string }} NormalizedToolEvent
4
+ */
5
+
6
+ /** Truncate a string to `max` chars, appending an ellipsis if it was cut. */
7
+ export function truncate(s, max = 60) {
8
+ const str = String(s ?? '');
9
+ if (str.length <= max) return str;
10
+ return `${str.slice(0, max)}…`;
11
+ }
12
+
13
+ /** Last path segment, for readable status lines. */
14
+ export function basename(p) {
15
+ const str = String(p ?? '');
16
+ if (!str) return str;
17
+ const parts = str.split('/');
18
+ return parts[parts.length - 1] || str;
19
+ }
20
+
21
+ /** Pick the first 1-2 short scalar args to preview for unknown tools, e.g. "path=foo". */
22
+ export function formatArgPreview(args = {}) {
23
+ const parts = [];
24
+ for (const [key, value] of Object.entries(args)) {
25
+ if (parts.length >= 2) break;
26
+ if (value == null) continue;
27
+ if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
28
+ continue;
29
+ }
30
+ parts.push(`${key}=${truncate(String(value), 30)}`);
31
+ }
32
+ return parts.join(', ');
33
+ }
34
+
35
+ const CURSOR_TOOL_CALL_KEYS = {
36
+ readToolCall: 'Read',
37
+ writeToolCall: 'Write',
38
+ editToolCall: 'Edit',
39
+ deleteToolCall: 'Delete',
40
+ grepToolCall: 'Grep',
41
+ globToolCall: 'Glob',
42
+ lsToolCall: 'Ls',
43
+ shellToolCall: 'Shell',
44
+ };
45
+
46
+ /**
47
+ * Normalize a Cursor stream event into a `NormalizedToolEvent`, or `null` if
48
+ * the event isn't a `tool_call` event.
49
+ * @param {object} event
50
+ * @returns {NormalizedToolEvent|null}
51
+ */
52
+ export function normalizeCursorToolEvent(event) {
53
+ if (event?.type !== 'tool_call') return null;
54
+
55
+ const phase = event.subtype === 'completed' ? 'completed' : 'started';
56
+ const callId = event.call_id;
57
+ const toolCall = event.tool_call ?? {};
58
+ const keys = Object.keys(toolCall);
59
+ const key = keys[0];
60
+
61
+ if (!key) {
62
+ return { name: 'tool', args: {}, phase, callId };
63
+ }
64
+
65
+ if (key === 'function') {
66
+ const fn = toolCall.function ?? {};
67
+ let args = {};
68
+ if (typeof fn.arguments === 'string') {
69
+ try {
70
+ args = JSON.parse(fn.arguments);
71
+ } catch {
72
+ args = {};
73
+ }
74
+ } else if (fn.arguments && typeof fn.arguments === 'object') {
75
+ args = fn.arguments;
76
+ }
77
+ return { name: fn.name ?? 'function', args, phase, callId };
78
+ }
79
+
80
+ const canonicalName = CURSOR_TOOL_CALL_KEYS[key] ?? key.replace(/ToolCall$/, '');
81
+ const args = toolCall[key]?.args ?? {};
82
+ return { name: canonicalName, args, phase, callId };
83
+ }
84
+
85
+ const CLAUDE_NAME_TO_FORMATTER_KEY = {
86
+ Read: 'read',
87
+ Write: 'write',
88
+ Edit: 'edit',
89
+ MultiEdit: 'edit',
90
+ Bash: 'shell',
91
+ Grep: 'grep',
92
+ Glob: 'glob',
93
+ WebSearch: 'websearch',
94
+ Task: 'task',
95
+ };
96
+
97
+ /** Map a Claude PascalCase tool name to its shared formatter key. */
98
+ export function claudeToolFormatterKey(name) {
99
+ return CLAUDE_NAME_TO_FORMATTER_KEY[name] ?? String(name ?? '').toLowerCase();
100
+ }
101
+
102
+ /**
103
+ * Normalize a Claude stream event into zero or more `NormalizedToolEvent`s.
104
+ * @param {object} event
105
+ * @returns {NormalizedToolEvent[]}
106
+ */
107
+ export function normalizeClaudeToolEvent(event) {
108
+ if (event?.type === 'assistant') {
109
+ const content = event.message?.content;
110
+ if (!Array.isArray(content)) return [];
111
+ return content
112
+ .filter((block) => block.type === 'tool_use')
113
+ .map((block) => ({
114
+ name: claudeToolFormatterKey(block.name),
115
+ args: block.input ?? {},
116
+ phase: 'started',
117
+ callId: block.id,
118
+ }));
119
+ }
120
+
121
+ if (event?.type === 'user') {
122
+ const content = event.message?.content;
123
+ if (!Array.isArray(content)) return [];
124
+ return content
125
+ .filter((block) => block.type === 'tool_result')
126
+ .map((block) => ({
127
+ name: '',
128
+ args: {},
129
+ phase: 'completed',
130
+ callId: block.tool_use_id,
131
+ }));
132
+ }
133
+
134
+ return [];
135
+ }
136
+
137
+ const AGN_TOOL_NAME_TO_FORMATTER_KEY = {
138
+ read_file: 'read',
139
+ write_file: 'write',
140
+ patch: 'edit',
141
+ shell: 'shell',
142
+ };
143
+
144
+ /**
145
+ * Normalize an agn stream event into a `NormalizedToolEvent`, or `null` if
146
+ * the event isn't a well-formed `tool_call` `started`/`completed` event.
147
+ * @param {object} event
148
+ * @returns {NormalizedToolEvent|null}
149
+ */
150
+ export function normalizeAgnToolEvent(event) {
151
+ if (event?.type !== 'tool_call') return null;
152
+ if (event.subtype !== 'started' && event.subtype !== 'completed') return null;
153
+ if (typeof event.call_id !== 'string' || event.call_id === '') return null;
154
+
155
+ const canonicalName = AGN_TOOL_NAME_TO_FORMATTER_KEY[event.name] ?? event.name;
156
+ return {
157
+ name: canonicalName,
158
+ args: event.subtype === 'started' ? (event.input ?? {}) : {},
159
+ phase: event.subtype,
160
+ callId: event.call_id,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Format a single normalized tool event as a human-readable status line.
166
+ * @param {{ name: string, args?: Record<string, unknown> }} tool
167
+ * @param {{ maxLen?: number }} [options]
168
+ */
169
+ export function formatToolStatus({ name, args = {} }, { maxLen = 60 } = {}) {
170
+ const n = String(name ?? 'tool');
171
+ const key = n.toLowerCase();
172
+
173
+ switch (key) {
174
+ case 'grep':
175
+ return `Searching: ${truncate(args.pattern ?? args.query ?? 'codebase', maxLen)}…`;
176
+ case 'glob':
177
+ return `Finding: ${truncate(args.glob_pattern ?? args.pattern ?? 'files', maxLen)}…`;
178
+ case 'read':
179
+ return `Reading ${truncate(basename(args.path ?? args.file_path ?? 'file'), maxLen)}…`;
180
+ case 'write':
181
+ return `Writing ${truncate(basename(args.path ?? args.file_path ?? 'file'), maxLen)}…`;
182
+ case 'edit':
183
+ return `Editing ${truncate(basename(args.path ?? args.file_path ?? 'file'), maxLen)}…`;
184
+ case 'delete':
185
+ return `Deleting ${truncate(basename(args.path ?? args.file_path ?? 'file'), maxLen)}…`;
186
+ case 'shell':
187
+ case 'bash':
188
+ return `Running: ${truncate(args.command ?? '', maxLen)}…`;
189
+ case 'ls':
190
+ return `Listing ${truncate(args.path ?? args.target_directory ?? '.', maxLen)}…`;
191
+ case 'websearch':
192
+ return `Searching web: ${truncate(args.search_term ?? args.query ?? '', maxLen)}…`;
193
+ case 'task':
194
+ return `Running subagent: ${truncate(args.description ?? args.prompt ?? 'task', maxLen)}…`;
195
+ default: {
196
+ const preview = formatArgPreview(args);
197
+ return preview ? `Running ${n}(${preview})…` : `Running ${n}…`;
198
+ }
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Join up to 3 active tools into one spinner line, capped at 120 chars.
204
+ * @param {Map<string, { name: string, args: Record<string, unknown> }>} activeTools
205
+ */
206
+ export function formatActiveTools(activeTools) {
207
+ const entries = [...activeTools.values()];
208
+ const maxShown = 3;
209
+ const shown = entries.slice(0, maxShown).map((t) => formatToolStatus(t, { maxLen: 40 }));
210
+ const extra = entries.length - maxShown;
211
+ let text = shown.join(' · ');
212
+ if (extra > 0) text += ` · +${extra} more`;
213
+ return truncate(text, 120);
214
+ }
@@ -0,0 +1,38 @@
1
+ import { execFileSync as nodeExecFileSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ function defaultExecFile(command, args, options = {}) {
6
+ return nodeExecFileSync(command, args, { encoding: 'utf8', ...options });
7
+ }
8
+
9
+ function runGit(execFile, args) {
10
+ try {
11
+ return execFile('git', args);
12
+ } catch (err) {
13
+ const detail = err.stderr || err.message;
14
+ throw new Error(`git ${args.join(' ')} failed: ${detail}`);
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Creates a persistent sibling git worktree for a run: resolves the repo
20
+ * root from `cwd`, derives `<repoRoot>-<slug>` and `orch/<slug>`, and creates
21
+ * both the branch and the worktree via argument-array git invocations.
22
+ * `execFile` is injectable and defaults to a `child_process.execFileSync`
23
+ * wrapper.
24
+ */
25
+ export function createWorktree({ cwd, slug, execFile = defaultExecFile }) {
26
+ const repoRoot = runGit(execFile, ['-C', cwd, 'rev-parse', '--show-toplevel']).trim();
27
+
28
+ const worktreePath = `${path.join(path.dirname(repoRoot), path.basename(repoRoot))}-${slug}`;
29
+ const branch = `orch/${slug}`;
30
+
31
+ if (fs.existsSync(worktreePath)) {
32
+ throw new Error(`createWorktree: refusing to overwrite existing path ${worktreePath}`);
33
+ }
34
+
35
+ runGit(execFile, ['-C', repoRoot, 'worktree', 'add', '-b', branch, worktreePath]);
36
+
37
+ return { repoRoot, worktreePath, branch };
38
+ }