@denizokcu/haze 0.0.2 → 0.0.3

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +87 -33
  3. package/dist/cli/commands/chat.d.ts +3 -1
  4. package/dist/cli/commands/chat.js +442 -52
  5. package/dist/cli/commands/commands.d.ts +5 -0
  6. package/dist/cli/commands/commands.js +114 -29
  7. package/dist/cli/commands/formatters.js +5 -2
  8. package/dist/cli/commands/streaming.d.ts +5 -1
  9. package/dist/cli/commands/streaming.js +193 -86
  10. package/dist/cli/index.js +5 -2
  11. package/dist/config/inputHistory.js +8 -0
  12. package/dist/config/providers.d.ts +26 -0
  13. package/dist/config/providers.js +88 -0
  14. package/dist/config/settings.d.ts +9 -2
  15. package/dist/core/agent/compaction.d.ts +13 -0
  16. package/dist/core/agent/compaction.js +34 -0
  17. package/dist/core/agent/errors.d.ts +3 -0
  18. package/dist/core/agent/errors.js +13 -0
  19. package/dist/core/agent/events.d.ts +58 -0
  20. package/dist/core/agent/events.js +3 -0
  21. package/dist/core/goal/completionPolicy.d.ts +27 -0
  22. package/dist/core/goal/completionPolicy.js +67 -0
  23. package/dist/core/goal/requestClassifier.d.ts +6 -0
  24. package/dist/core/goal/requestClassifier.js +31 -0
  25. package/dist/core/goal/sessionGoal.d.ts +30 -0
  26. package/dist/core/goal/sessionGoal.js +88 -0
  27. package/dist/core/session/sessionStore.d.ts +37 -0
  28. package/dist/core/session/sessionStore.js +59 -0
  29. package/dist/llm/client.d.ts +1 -1
  30. package/dist/llm/client.js +6 -6
  31. package/dist/llm/hazeTools.d.ts +38 -0
  32. package/dist/llm/hazeTools.js +196 -92
  33. package/dist/llm/initPrompt.js +6 -4
  34. package/dist/llm/systemPrompt.js +3 -3
  35. package/dist/skills/builder/SkillBuilder.d.ts +6 -0
  36. package/dist/skills/builder/SkillBuilder.js +146 -24
  37. package/dist/ui/components/ErrorView.d.ts +2 -1
  38. package/dist/ui/components/Header.d.ts +2 -1
  39. package/dist/ui/components/Header.js +1 -11
  40. package/dist/ui/components/MarkdownText.d.ts +2 -1
  41. package/dist/ui/components/TextInput.d.ts +7 -3
  42. package/dist/ui/components/TextInput.js +112 -27
  43. package/dist/ui/theme.d.ts +1 -0
  44. package/dist/ui/theme.js +2 -1
  45. package/package.json +8 -8
@@ -6,6 +6,13 @@ export declare const hazeTools: {
6
6
  includeIgnored: boolean;
7
7
  cursor?: string | undefined;
8
8
  }, {
9
+ ok: boolean;
10
+ toolName: string;
11
+ path: string | undefined;
12
+ error: string;
13
+ recoverable: boolean;
14
+ suggestedNextStep: string;
15
+ } | {
9
16
  ok: true;
10
17
  duplicateSkipped: true;
11
18
  toolName: string;
@@ -30,6 +37,13 @@ export declare const hazeTools: {
30
37
  offset?: number | undefined;
31
38
  limit?: number | undefined;
32
39
  }, {
40
+ ok: boolean;
41
+ toolName: string;
42
+ path: string | undefined;
43
+ error: string;
44
+ recoverable: boolean;
45
+ suggestedNextStep: string;
46
+ } | {
33
47
  ok: true;
34
48
  duplicateSkipped: true;
35
49
  toolName: string;
@@ -60,6 +74,13 @@ export declare const hazeTools: {
60
74
  content: string;
61
75
  allowIgnored: boolean;
62
76
  }, {
77
+ ok: boolean;
78
+ toolName: string;
79
+ path: string | undefined;
80
+ error: string;
81
+ recoverable: boolean;
82
+ suggestedNextStep: string;
83
+ } | {
63
84
  ok: true;
64
85
  duplicateSkipped: true;
65
86
  toolName: string;
@@ -69,6 +90,8 @@ export declare const hazeTools: {
69
90
  path: string;
70
91
  startLine: number;
71
92
  endLine: number;
93
+ requestedEndLine: number;
94
+ endLineClamped: boolean;
72
95
  replacementLines: number;
73
96
  appended: boolean;
74
97
  }>;
@@ -78,6 +101,13 @@ export declare const hazeTools: {
78
101
  overwriteExisting: boolean;
79
102
  allowIgnored: boolean;
80
103
  }, {
104
+ ok: boolean;
105
+ toolName: string;
106
+ path: string | undefined;
107
+ error: string;
108
+ recoverable: boolean;
109
+ suggestedNextStep: string;
110
+ } | {
81
111
  ok: true;
82
112
  duplicateSkipped: true;
83
113
  toolName: string;
@@ -96,6 +126,13 @@ export declare const hazeTools: {
96
126
  }[];
97
127
  allowIgnored: boolean;
98
128
  }, {
129
+ ok: boolean;
130
+ toolName: string;
131
+ path: string | undefined;
132
+ error: string;
133
+ recoverable: boolean;
134
+ suggestedNextStep: string;
135
+ } | {
99
136
  ok: true;
100
137
  duplicateSkipped: true;
101
138
  toolName: string;
@@ -104,6 +141,7 @@ export declare const hazeTools: {
104
141
  ok: boolean;
105
142
  path: string;
106
143
  edits: number;
144
+ approximateMatches: number;
107
145
  }>;
108
146
  bash: import("ai").Tool<{
109
147
  command: string;
@@ -40,6 +40,60 @@ function truncate(text, maxChars = MAX_OUTPUT_CHARS) {
40
40
  function numberLines(lines, startLine) {
41
41
  return lines.map((line, index) => `${String(startLine + index).padStart(4, ' ')} | ${line}`).join('\n');
42
42
  }
43
+ function stripLineNumberPrefixes(text) {
44
+ return text.replace(/^\s*\d+\s+\| ?/gm, '');
45
+ }
46
+ function lineStartOffsets(text) {
47
+ const offsets = [0];
48
+ for (let index = 0; index < text.length; index++) {
49
+ if (text[index] === '\n')
50
+ offsets.push(index + 1);
51
+ }
52
+ return offsets;
53
+ }
54
+ function findLineTrimmedRange(original, oldText) {
55
+ const wantedLines = oldText.replace(/\r\n/g, '\n').split('\n').map(line => line.trimEnd());
56
+ if (wantedLines.at(-1) === '')
57
+ wantedLines.pop();
58
+ if (wantedLines.length === 0)
59
+ return undefined;
60
+ const originalLines = original.replace(/\r\n/g, '\n').split('\n');
61
+ const hasTrailingNewline = original.endsWith('\n');
62
+ if (hasTrailingNewline)
63
+ originalLines.pop();
64
+ const offsets = lineStartOffsets(original);
65
+ const matches = [];
66
+ for (let lineIndex = 0; lineIndex <= originalLines.length - wantedLines.length; lineIndex++) {
67
+ const window = originalLines.slice(lineIndex, lineIndex + wantedLines.length).map(line => line.trimEnd());
68
+ if (window.every((line, index) => line === wantedLines[index])) {
69
+ const start = offsets[lineIndex] ?? 0;
70
+ const endLineIndex = lineIndex + wantedLines.length;
71
+ const end = endLineIndex < offsets.length ? (offsets[endLineIndex] ?? original.length) : original.length;
72
+ matches.push({ start, end });
73
+ }
74
+ }
75
+ if (matches.length !== 1)
76
+ return undefined;
77
+ return matches[0];
78
+ }
79
+ function findEditRange(original, oldText) {
80
+ const candidates = [oldText, stripLineNumberPrefixes(oldText)].filter((candidate, index, all) => candidate.length > 0 && all.indexOf(candidate) === index);
81
+ for (const candidate of candidates) {
82
+ const first = original.indexOf(candidate);
83
+ if (first !== -1) {
84
+ const second = original.indexOf(candidate, first + candidate.length);
85
+ if (second !== -1)
86
+ return { kind: 'multiple' };
87
+ return { kind: 'found', start: first, end: first + candidate.length, approximate: candidate !== oldText };
88
+ }
89
+ }
90
+ for (const candidate of candidates) {
91
+ const range = findLineTrimmedRange(original, candidate);
92
+ if (range)
93
+ return { kind: 'found', ...range, approximate: true };
94
+ }
95
+ return { kind: 'missing' };
96
+ }
43
97
  function toolCallKey(toolName, input) {
44
98
  return `${toolName}:${JSON.stringify(input)}`;
45
99
  }
@@ -59,6 +113,13 @@ function inputPath(input) {
59
113
  ? input.path
60
114
  : undefined;
61
115
  }
116
+ function isStructuredFailure(value) {
117
+ return typeof value === 'object' && value != null && 'ok' in value && value.ok === false;
118
+ }
119
+ function structuredToolFailure(toolName, error, suggestedNextStep, pathForError) {
120
+ const message = error instanceof Error ? error.message : String(error);
121
+ return { ok: false, toolName, path: pathForError, error: message, recoverable: true, suggestedNextStep };
122
+ }
62
123
  async function runDedupedTool(toolName, input, context, execute) {
63
124
  const ctx = hazeContext(context);
64
125
  if (!ctx)
@@ -67,9 +128,18 @@ async function runDedupedTool(toolName, input, context, execute) {
67
128
  ctx.completedToolCalls ??= new Map();
68
129
  ctx.failedMutationPaths ??= new Set();
69
130
  ctx.pathsReadAfterFailedMutation ??= new Set();
131
+ ctx.inFlightMutationPaths ??= new Set();
70
132
  ctx.mutationEpoch ??= 0;
71
133
  const key = toolCallKey(toolName, input);
72
134
  const pathForInput = inputPath(input);
135
+ if (isMutatingTool(toolName) && pathForInput && ctx.inFlightMutationPaths.has(pathForInput)) {
136
+ return {
137
+ ok: true,
138
+ duplicateSkipped: true,
139
+ toolName,
140
+ reason: `Skipped concurrent mutation for ${pathForInput}. Read the file again, then make one editFile call with all non-overlapping replacements or one replaceLines call based on the latest line numbers.`,
141
+ };
142
+ }
73
143
  if (isMutatingTool(toolName) && pathForInput && ctx.failedMutationPaths.has(pathForInput) && !ctx.pathsReadAfterFailedMutation.has(pathForInput)) {
74
144
  throw new Error(`Read ${pathForInput} before attempting another edit after the previous edit failure.`);
75
145
  }
@@ -93,10 +163,19 @@ async function runDedupedTool(toolName, input, context, execute) {
93
163
  reason: 'Skipped duplicate in-flight tool call with identical input.',
94
164
  };
95
165
  }
166
+ if (isMutatingTool(toolName) && pathForInput)
167
+ ctx.inFlightMutationPaths.add(pathForInput);
96
168
  const promise = execute();
97
169
  ctx.inFlightToolCalls.set(key, promise);
98
170
  try {
99
171
  const result = await promise;
172
+ if (isStructuredFailure(result)) {
173
+ if (isMutatingTool(toolName) && pathForInput) {
174
+ ctx.failedMutationPaths.add(pathForInput);
175
+ ctx.pathsReadAfterFailedMutation.delete(pathForInput);
176
+ }
177
+ return result;
178
+ }
100
179
  if (toolName === 'readFile' && pathForInput)
101
180
  ctx.pathsReadAfterFailedMutation.add(pathForInput);
102
181
  if (isMutatingTool(toolName)) {
@@ -118,6 +197,8 @@ async function runDedupedTool(toolName, input, context, execute) {
118
197
  }
119
198
  finally {
120
199
  ctx.inFlightToolCalls.delete(key);
200
+ if (isMutatingTool(toolName) && pathForInput)
201
+ ctx.inFlightMutationPaths?.delete(pathForInput);
121
202
  }
122
203
  }
123
204
  function looksLikeShellFileMutation(command) {
@@ -136,29 +217,34 @@ export const hazeTools = {
136
217
  includeIgnored: z.boolean().default(false).describe('Include files ignored by .gitignore. Use only when explicitly needed.'),
137
218
  }),
138
219
  execute: async ({ path: dirPath, recursive, maxEntries, cursor, includeIgnored }, context) => runDedupedTool('listFiles', { path: dirPath, recursive, maxEntries, cursor, includeIgnored }, context, async () => {
139
- const absolutePath = resolveWorkspacePath(dirPath);
140
- await assertNotIgnored(absolutePath, dirPath, includeIgnored);
141
- const entries = [];
142
- let ignoredSkipped = 0;
143
- const walked = await walkDir(absolutePath, { recursive, maxEntries: maxEntries + 1, cursor, filter: async (entry) => {
144
- if (!includeIgnored && await isGitIgnored(entry.absolutePath)) {
145
- ignoredSkipped++;
146
- return false;
220
+ try {
221
+ const absolutePath = resolveWorkspacePath(dirPath);
222
+ await assertNotIgnored(absolutePath, dirPath, includeIgnored);
223
+ const entries = [];
224
+ let ignoredSkipped = 0;
225
+ const walked = await walkDir(absolutePath, { recursive, maxEntries: maxEntries + 1, cursor, filter: async (entry) => {
226
+ if (!includeIgnored && await isGitIgnored(entry.absolutePath)) {
227
+ ignoredSkipped++;
228
+ return false;
229
+ }
230
+ return true;
231
+ } });
232
+ const page = walked.slice(0, maxEntries);
233
+ const hasMore = walked.length > maxEntries;
234
+ for (const entry of page) {
235
+ if (entry.isDirectory) {
236
+ entries.push({ path: entry.path, type: 'directory' });
237
+ }
238
+ else if (entry.isFile) {
239
+ const stat = await fs.stat(entry.absolutePath);
240
+ entries.push({ path: entry.path, type: 'file', size: stat.size });
147
241
  }
148
- return true;
149
- } });
150
- const page = walked.slice(0, maxEntries);
151
- const hasMore = walked.length > maxEntries;
152
- for (const entry of page) {
153
- if (entry.isDirectory) {
154
- entries.push({ path: entry.path, type: 'directory' });
155
- }
156
- else if (entry.isFile) {
157
- const stat = await fs.stat(entry.absolutePath);
158
- entries.push({ path: entry.path, type: 'file', size: stat.size });
159
242
  }
243
+ return { path: dirPath, recursive, includeIgnored, cursor, nextCursor: hasMore ? page.at(-1)?.path : undefined, ignoredSkipped, entries, truncated: hasMore };
244
+ }
245
+ catch (error) {
246
+ return structuredToolFailure('listFiles', error, 'Check that the directory exists and is not ignored, or retry with a narrower path.', dirPath);
160
247
  }
161
- return { path: dirPath, recursive, includeIgnored, cursor, nextCursor: hasMore ? page.at(-1)?.path : undefined, ignoredSkipped, entries, truncated: hasMore };
162
248
  }),
163
249
  }),
164
250
  readFile: tool({
@@ -170,26 +256,31 @@ export const hazeTools = {
170
256
  allowIgnored: z.boolean().default(false).describe('Read the file even if it is ignored by .gitignore. Use only when explicitly needed.'),
171
257
  }),
172
258
  execute: async ({ path: filePath, offset, limit, allowIgnored }, context) => runDedupedTool('readFile', { path: filePath, offset, limit, allowIgnored }, context, async () => {
173
- const absolutePath = resolveWorkspacePath(filePath);
174
- await assertNotIgnored(absolutePath, filePath, allowIgnored);
175
- const content = await fs.readFile(absolutePath, 'utf8');
176
- const lines = content.split(/\r?\n/);
177
- const start = offset == null ? 0 : offset - 1;
178
- const end = limit == null ? lines.length : start + limit;
179
- const selectedLines = lines.slice(start, end);
180
- const selected = selectedLines.join('\n');
181
- return {
182
- path: filePath,
183
- startLine: start + 1,
184
- endLine: Math.min(end, lines.length),
185
- totalLines: lines.length,
186
- lineNumberedText: numberLines(selectedLines, start + 1),
187
- ...truncate(selected),
188
- };
259
+ try {
260
+ const absolutePath = resolveWorkspacePath(filePath);
261
+ await assertNotIgnored(absolutePath, filePath, allowIgnored);
262
+ const content = await fs.readFile(absolutePath, 'utf8');
263
+ const lines = content.split(/\r?\n/);
264
+ const start = offset == null ? 0 : offset - 1;
265
+ const end = limit == null ? lines.length : start + limit;
266
+ const selectedLines = lines.slice(start, end);
267
+ const selected = selectedLines.join('\n');
268
+ return {
269
+ path: filePath,
270
+ startLine: start + 1,
271
+ endLine: Math.min(end, lines.length),
272
+ totalLines: lines.length,
273
+ lineNumberedText: numberLines(selectedLines, start + 1),
274
+ ...truncate(selected),
275
+ };
276
+ }
277
+ catch (error) {
278
+ return structuredToolFailure('readFile', error, 'Check the path with listFiles, or set allowIgnored=true only if the user explicitly asked to inspect an ignored file.', filePath);
279
+ }
189
280
  }),
190
281
  }),
191
282
  replaceLines: tool({
192
- description: 'Replace a 1-based inclusive line range in an existing UTF-8 text file. Prefer this after reading a file when exact editFile replacements are ambiguous or fail.',
283
+ description: 'Replace a 1-based inclusive line range in an existing UTF-8 text file. Prefer this after reading a file when exact editFile replacements are ambiguous or fail. If endLine is slightly beyond EOF, it is clamped to the current last line.',
193
284
  inputSchema: z.object({
194
285
  path: z.string().describe('File path relative to the current workspace'),
195
286
  startLine: z.number().int().positive().describe('First 1-based line number to replace'),
@@ -198,30 +289,34 @@ export const hazeTools = {
198
289
  allowIgnored: z.boolean().default(false).describe('Edit the file even if it is ignored by .gitignore. Use only when explicitly needed.'),
199
290
  }),
200
291
  execute: async ({ path: filePath, startLine, endLine, content, allowIgnored }, context) => runDedupedTool('replaceLines', { path: filePath, startLine, endLine, content, allowIgnored }, context, async () => {
201
- const absolutePath = resolveWorkspacePath(filePath);
202
- await assertNotIgnored(absolutePath, filePath, allowIgnored);
203
- const original = await fs.readFile(absolutePath, 'utf8');
204
- const hasTrailingNewline = original.endsWith('\n');
205
- const lines = original.split(/\r?\n/);
206
- if (hasTrailingNewline)
207
- lines.pop();
208
- const isAppend = startLine === lines.length + 1 && endLine === lines.length;
209
- if (!isAppend && endLine < startLine)
210
- throw new Error('endLine must be greater than or equal to startLine, except when appending at EOF with startLine=totalLines+1 and endLine=totalLines');
211
- if (startLine > lines.length + 1)
212
- throw new Error(`startLine ${startLine} is beyond end of file (${lines.length} lines)`);
213
- if (endLine > lines.length)
214
- throw new Error(`endLine ${endLine} is beyond end of file (${lines.length} lines)`);
215
- const replacementLines = content.length === 0 ? [] : content.split(/\r?\n/);
216
- if (isAppend) {
217
- lines.push(...replacementLines);
292
+ try {
293
+ const absolutePath = resolveWorkspacePath(filePath);
294
+ await assertNotIgnored(absolutePath, filePath, allowIgnored);
295
+ const original = await fs.readFile(absolutePath, 'utf8');
296
+ const hasTrailingNewline = original.endsWith('\n');
297
+ const lines = original.split(/\r?\n/);
298
+ if (hasTrailingNewline)
299
+ lines.pop();
300
+ const isAppend = startLine === lines.length + 1 && endLine === lines.length;
301
+ if (!isAppend && endLine < startLine)
302
+ throw new Error('endLine must be greater than or equal to startLine, except when appending at EOF with startLine=totalLines+1 and endLine=totalLines');
303
+ if (startLine > lines.length + 1)
304
+ throw new Error(`startLine ${startLine} is beyond end of file (${lines.length} lines)`);
305
+ const effectiveEndLine = !isAppend && endLine > lines.length ? lines.length : endLine;
306
+ const replacementLines = content.length === 0 ? [] : content.split(/\r?\n/);
307
+ if (isAppend) {
308
+ lines.push(...replacementLines);
309
+ }
310
+ else {
311
+ lines.splice(startLine - 1, effectiveEndLine - startLine + 1, ...replacementLines);
312
+ }
313
+ const updated = lines.join('\n') + (hasTrailingNewline ? '\n' : '');
314
+ await fs.writeFile(absolutePath, updated, 'utf8');
315
+ return { ok: true, path: filePath, startLine, endLine: effectiveEndLine, requestedEndLine: endLine, endLineClamped: effectiveEndLine !== endLine, replacementLines: replacementLines.length, appended: isAppend };
218
316
  }
219
- else {
220
- lines.splice(startLine - 1, endLine - startLine + 1, ...replacementLines);
317
+ catch (error) {
318
+ return structuredToolFailure('replaceLines', error, 'Read the file again for current line numbers, then retry replaceLines with a valid range.', filePath);
221
319
  }
222
- const updated = lines.join('\n') + (hasTrailingNewline ? '\n' : '');
223
- await fs.writeFile(absolutePath, updated, 'utf8');
224
- return { ok: true, path: filePath, startLine, endLine, replacementLines: replacementLines.length, appended: isAppend };
225
320
  }),
226
321
  }),
227
322
  writeFile: tool({
@@ -233,26 +328,31 @@ export const hazeTools = {
233
328
  allowIgnored: z.boolean().default(false).describe('Write the file even if it is ignored by .gitignore. Use only when explicitly needed.'),
234
329
  }),
235
330
  execute: async ({ path: filePath, content, overwriteExisting, allowIgnored }, context) => runDedupedTool('writeFile', { path: filePath, content, overwriteExisting, allowIgnored }, context, async () => {
236
- const absolutePath = resolveWorkspacePath(filePath);
237
- await assertNotIgnored(absolutePath, filePath, allowIgnored);
238
331
  try {
239
- await fs.access(absolutePath);
240
- if (!overwriteExisting) {
241
- throw new Error(`Refusing to overwrite existing file: ${filePath}. Use editFile/replaceLines for targeted edits, or set overwriteExisting=true for an intentional complete rewrite.`);
332
+ const absolutePath = resolveWorkspacePath(filePath);
333
+ await assertNotIgnored(absolutePath, filePath, allowIgnored);
334
+ try {
335
+ await fs.access(absolutePath);
336
+ if (!overwriteExisting) {
337
+ throw new Error(`Refusing to overwrite existing file: ${filePath}. Use editFile/replaceLines for targeted edits, or set overwriteExisting=true for an intentional complete rewrite.`);
338
+ }
242
339
  }
340
+ catch (error) {
341
+ const code = typeof error === 'object' && error != null && 'code' in error ? error.code : undefined;
342
+ if (code !== 'ENOENT')
343
+ throw error;
344
+ }
345
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
346
+ await fs.writeFile(absolutePath, content, 'utf8');
347
+ return { ok: true, path: filePath, bytes: Buffer.byteLength(content, 'utf8'), overwritten: overwriteExisting };
243
348
  }
244
349
  catch (error) {
245
- const code = typeof error === 'object' && error != null && 'code' in error ? error.code : undefined;
246
- if (code !== 'ENOENT')
247
- throw error;
350
+ return structuredToolFailure('writeFile', error, 'Use editFile/replaceLines for existing files, set overwriteExisting=true only for an intentional rewrite, or check the path/ignored-file setting.', filePath);
248
351
  }
249
- await fs.mkdir(path.dirname(absolutePath), { recursive: true });
250
- await fs.writeFile(absolutePath, content, 'utf8');
251
- return { ok: true, path: filePath, bytes: Buffer.byteLength(content, 'utf8'), overwritten: overwriteExisting };
252
352
  }),
253
353
  }),
254
354
  editFile: tool({
255
- description: 'Edit a text file using exact replacements. Each oldText must match exactly once in the original file and edits must not overlap. If this fails because text is missing or not unique, do not retry; use replaceLines instead.',
355
+ description: 'Edit a text file using unique replacements. Each oldText should match the current file; line-number prefixes from readFile output and trailing-whitespace-only differences are tolerated when the match is still unique. Put multiple edits to the same file in one call. If this fails because text is missing or not unique, read the file again and use replaceLines.',
256
356
  inputSchema: z.object({
257
357
  path: z.string().describe('File path relative to the current workspace'),
258
358
  edits: z.array(z.object({
@@ -262,29 +362,33 @@ export const hazeTools = {
262
362
  allowIgnored: z.boolean().default(false).describe('Edit the file even if it is ignored by .gitignore. Use only when explicitly needed.'),
263
363
  }),
264
364
  execute: async ({ path: filePath, edits, allowIgnored }, context) => runDedupedTool('editFile', { path: filePath, edits, allowIgnored }, context, async () => {
265
- const absolutePath = resolveWorkspacePath(filePath);
266
- await assertNotIgnored(absolutePath, filePath, allowIgnored);
267
- const original = await fs.readFile(absolutePath, 'utf8');
268
- const ranges = edits.map((edit, index) => {
269
- const first = original.indexOf(edit.oldText);
270
- if (first === -1)
271
- throw new Error(`edit ${index}: oldText was not found`);
272
- const second = original.indexOf(edit.oldText, first + edit.oldText.length);
273
- if (second !== -1)
274
- throw new Error(`edit ${index}: oldText is not unique`);
275
- return { index, start: first, end: first + edit.oldText.length, edit };
276
- }).sort((a, b) => a.start - b.start);
277
- for (let i = 1; i < ranges.length; i++) {
278
- if (ranges[i].start < ranges[i - 1].end) {
279
- throw new Error(`edits ${ranges[i - 1].index} and ${ranges[i].index} overlap`);
365
+ try {
366
+ const absolutePath = resolveWorkspacePath(filePath);
367
+ await assertNotIgnored(absolutePath, filePath, allowIgnored);
368
+ const original = await fs.readFile(absolutePath, 'utf8');
369
+ const ranges = edits.map((edit, index) => {
370
+ const match = findEditRange(original, edit.oldText);
371
+ if (match.kind === 'missing')
372
+ throw new Error(`edit ${index}: oldText was not found. Read the file again and use the exact current text, or use replaceLines with the latest line numbers.`);
373
+ if (match.kind === 'multiple')
374
+ throw new Error(`edit ${index}: oldText is not unique`);
375
+ return { index, start: match.start, end: match.end, edit, approximate: match.approximate };
376
+ }).sort((a, b) => a.start - b.start);
377
+ for (let i = 1; i < ranges.length; i++) {
378
+ if (ranges[i].start < ranges[i - 1].end) {
379
+ throw new Error(`edits ${ranges[i - 1].index} and ${ranges[i].index} overlap`);
380
+ }
280
381
  }
382
+ let updated = original;
383
+ for (const range of [...ranges].sort((a, b) => b.start - a.start)) {
384
+ updated = updated.slice(0, range.start) + range.edit.newText + updated.slice(range.end);
385
+ }
386
+ await fs.writeFile(absolutePath, updated, 'utf8');
387
+ return { ok: true, path: filePath, edits: edits.length, approximateMatches: ranges.filter(range => range.approximate).length };
281
388
  }
282
- let updated = original;
283
- for (const range of [...ranges].sort((a, b) => b.start - a.start)) {
284
- updated = updated.slice(0, range.start) + range.edit.newText + updated.slice(range.end);
389
+ catch (error) {
390
+ return structuredToolFailure('editFile', error, 'Read the file again, then retry with exact current text or use replaceLines with the latest line numbers.', filePath);
285
391
  }
286
- await fs.writeFile(absolutePath, updated, 'utf8');
287
- return { ok: true, path: filePath, edits: edits.length };
288
392
  }),
289
393
  }),
290
394
  bash: tool({
@@ -296,7 +400,7 @@ export const hazeTools = {
296
400
  }),
297
401
  execute: async ({ command, timeoutSeconds, allowMutation }, context) => runDedupedTool('bash', { command, timeoutSeconds, allowMutation }, context, async () => {
298
402
  if (!allowMutation && looksLikeShellFileMutation(command)) {
299
- throw new Error('Refusing to mutate files via bash. Use writeFile/editFile/replaceLines, or set allowMutation=true only when shell mutation is explicitly required.');
403
+ return structuredToolFailure('bash', 'Refusing to mutate files via bash. Use writeFile/editFile/replaceLines, or set allowMutation=true only when shell mutation is explicitly required.', 'Use writeFile/editFile/replaceLines for file changes, or retry bash with allowMutation=true only when shell mutation is explicitly required.');
300
404
  }
301
405
  const timeoutMs = (timeoutSeconds ?? 60) * 1000;
302
406
  return await new Promise(resolve => {
@@ -1,10 +1,12 @@
1
1
  export function buildInitPrompt() {
2
2
  return `Initialize this repository for Haze by creating a best-practice AGENTS.md file.
3
3
 
4
- Explore the codebase first, respecting .gitignore:
4
+ Explore the codebase first, respecting .gitignore, but keep this quick and minimal:
5
5
  1. Start with exactly one listFiles call from the workspace root. Do not announce that you are starting before the tool call.
6
- 2. After listFiles returns, do not call listFiles with the same input again. Immediately read the files needed to understand project conventions, commands, architecture, and release workflow. Usually read package/config files, README, AGENTS.md if present, and key source entrypoints.
7
- 3. Do not read ignored files unless truly necessary.
6
+ 2. Do not use bash for discovery unless package metadata is missing. Do not grep/find the tree for this command.
7
+ 3. After listFiles returns, read only the small set needed to understand conventions: package/config files, README, existing AGENTS.md if present, and at most three key source entrypoints/directories.
8
+ 4. Do not call listFiles with the same input twice. Do not read the same path repeatedly. Do not read speculative files; list the parent first if unsure.
9
+ 5. Aim to finish in 12 tool calls or fewer. Do not read ignored files unless truly necessary.
8
10
 
9
11
  Create or update AGENTS.md at the workspace root. It should be concise and useful for future coding agents. Include sections when known:
10
12
  - Project overview
@@ -15,5 +17,5 @@ Create or update AGENTS.md at the workspace root. It should be concise and usefu
15
17
  - Testing/validation expectations
16
18
  - Safety notes or files/directories to avoid
17
19
 
18
- If AGENTS.md already exists, preserve useful existing instructions and improve them. After writing it, summarize what you learned and what you wrote.`;
20
+ If AGENTS.md already exists, preserve useful existing instructions and improve them with targeted editFile/replaceLines edits. Do not rewrite the entire file unless it is missing or unusable. After writing it, summarize only the change and validation status.`;
19
21
  }
@@ -7,8 +7,8 @@ export function buildSystemPrompt(contextFiles = []) {
7
7
  Available tools:
8
8
  - listFiles: List files and directories in the current workspace. Supports recursive listings and cursor pagination. Prefer this over bash ls/find for project discovery.
9
9
  - readFile: Read UTF-8 files with optional line ranges. Returns lineNumberedText for line-based edits.
10
- - editFile: Edit files with exact unique text replacements. Use only for small, unambiguous replacements.
11
- - replaceLines: Replace a 1-based inclusive line range. Use when editFile is ambiguous or has failed once. To append at EOF, use startLine=totalLines+1 and endLine=totalLines from the latest readFile result.
10
+ - editFile: Edit files with unique text replacements. Use only for small, unambiguous replacements. Put multiple edits to the same file in one editFile call; do not issue parallel separate edits for the same file.
11
+ - replaceLines: Replace a 1-based inclusive line range. Use when editFile is ambiguous or has failed once. To append at EOF, use startLine=totalLines+1 and endLine=totalLines from the latest readFile result. Slightly-too-large endLine values are clamped to EOF.
12
12
  - writeFile: Create files, or overwrite existing files only when overwriteExisting=true is intentionally set for a complete rewrite. Prefer editFile/replaceLines for existing files.
13
13
  - bash: Run shell commands for tests, builds, scripts, and inspection that cannot be done with file tools. Do not use bash to mutate files unless explicitly requested or file tools cannot do the job.
14
14
  - skill_*: Markdown skills installed in ~/.haze/skills. Use a skill tool when its description matches the user's request; it returns workflow instructions and explicitly referenced files.
@@ -24,7 +24,7 @@ Guidelines:
24
24
  - Do not list or read the same path repeatedly unless the file changed or the previous result was insufficient.
25
25
  - Read only directly relevant files, usually once. Do not read README/package files unless needed for the task.
26
26
  - File tools follow .gitignore by default. Only set includeIgnored/allowIgnored when the user explicitly asks or the task truly requires ignored files, and say why.
27
- - Prefer editFile for existing files when one small exact replacement is unique.
27
+ - Prefer editFile for existing files when one small replacement is unique. For multiple edits in one file, use one editFile call with multiple non-overlapping edits instead of parallel tool calls.
28
28
  - If editFile fails because oldText is missing or not unique, do not retry editFile for the same change; use replaceLines with lineNumberedText from readFile.
29
29
  - Use writeFile for new files. For existing files, prefer editFile or replaceLines; only set writeFile overwriteExisting=true when a complete rewrite is intentional and safer than targeted edits.
30
30
  - Use bash mainly for tests, builds, package scripts, and commands that are not covered by file tools. Do not combine validation with file mutation in one shell command; use file tools for edits and bash only for validation/inspection.
@@ -9,6 +9,9 @@ type GeneratedSkill = {
9
9
  export declare function slug(s: string): string;
10
10
  declare function fallbackSkill(description: string): GeneratedSkill;
11
11
  declare function withStandardRequirements(content: string): string;
12
+ declare function withSkillName(content: string, name: string): string;
13
+ declare function normalizeSkillDescription(description: string): string;
14
+ declare function withSkillDescription(content: string, description: string): string;
12
15
  declare function parseGeneratedSkill(text: string, description: string): GeneratedSkill;
13
16
  export declare function createSkill(description: string): Promise<{
14
17
  name: string;
@@ -21,5 +24,8 @@ export declare const internals: {
21
24
  parseGeneratedSkill: typeof parseGeneratedSkill;
22
25
  fallbackSkill: typeof fallbackSkill;
23
26
  withStandardRequirements: typeof withStandardRequirements;
27
+ withSkillName: typeof withSkillName;
28
+ withSkillDescription: typeof withSkillDescription;
29
+ normalizeSkillDescription: typeof normalizeSkillDescription;
24
30
  };
25
31
  export {};