@denizokcu/haze 0.0.1 → 0.0.2

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 (51) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +114 -69
  3. package/dist/cli/commands/chat.d.ts +1 -0
  4. package/dist/cli/commands/chat.js +203 -11
  5. package/dist/cli/commands/commands.js +130 -6
  6. package/dist/cli/commands/formatters.d.ts +1 -0
  7. package/dist/cli/commands/formatters.js +18 -1
  8. package/dist/cli/commands/skills.d.ts +1 -1
  9. package/dist/cli/commands/skills.js +8 -5
  10. package/dist/cli/commands/streaming.d.ts +2 -0
  11. package/dist/cli/commands/streaming.js +424 -39
  12. package/dist/cli/index.js +1 -11
  13. package/dist/config/paths.d.ts +0 -1
  14. package/dist/config/paths.js +0 -1
  15. package/dist/llm/client.js +1 -1
  16. package/dist/llm/hazeTools.d.ts +32 -0
  17. package/dist/llm/hazeTools.js +136 -26
  18. package/dist/llm/initPrompt.js +2 -2
  19. package/dist/llm/systemPrompt.js +23 -9
  20. package/dist/skills/SkillLoader.d.ts +12 -2
  21. package/dist/skills/SkillLoader.js +64 -18
  22. package/dist/skills/SkillRegistry.d.ts +1 -5
  23. package/dist/skills/SkillRegistry.js +10 -21
  24. package/dist/skills/builder/SkillBuilder.d.ts +25 -1
  25. package/dist/skills/builder/SkillBuilder.js +169 -20
  26. package/dist/skills/skillTools.d.ts +20 -0
  27. package/dist/skills/skillTools.js +25 -0
  28. package/dist/skills/types.d.ts +12 -51
  29. package/dist/ui/components/Header.d.ts +2 -1
  30. package/dist/ui/components/Header.js +12 -2
  31. package/dist/ui/components/TextInput.d.ts +8 -1
  32. package/dist/ui/components/TextInput.js +29 -14
  33. package/dist/ui/theme.d.ts +1 -0
  34. package/dist/ui/theme.js +1 -0
  35. package/dist/utils/fs.d.ts +1 -0
  36. package/dist/utils/fs.js +10 -6
  37. package/examples/skills/files/SKILL.md +16 -0
  38. package/examples/skills/files/examples/file-editing.md +3 -0
  39. package/package.json +2 -2
  40. package/dist/skills/installer/SkillInstaller.d.ts +0 -1
  41. package/dist/skills/installer/SkillInstaller.js +0 -48
  42. package/dist/skills/manifestSchema.d.ts +0 -31
  43. package/dist/skills/manifestSchema.js +0 -23
  44. package/dist/tools/ToolExecutor.d.ts +0 -3
  45. package/dist/tools/ToolExecutor.js +0 -15
  46. package/dist/tools/types.d.ts +0 -9
  47. package/dist/tools/types.js +0 -1
  48. package/examples/skills/files/prompts/file_tasks.md +0 -1
  49. package/examples/skills/files/skill.yaml +0 -28
  50. package/examples/skills/files/tools/list_files.ts +0 -21
  51. package/examples/skills/files/tools/read_file.ts +0 -12
@@ -40,6 +40,91 @@ 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 toolCallKey(toolName, input) {
44
+ return `${toolName}:${JSON.stringify(input)}`;
45
+ }
46
+ function hazeContext(context) {
47
+ return typeof context.experimental_context === 'object' && context.experimental_context != null
48
+ ? context.experimental_context
49
+ : undefined;
50
+ }
51
+ function isMutatingTool(toolName) {
52
+ return ['editFile', 'replaceLines', 'writeFile'].includes(toolName);
53
+ }
54
+ function isReadOnlyFileTool(toolName) {
55
+ return ['listFiles', 'readFile'].includes(toolName);
56
+ }
57
+ function inputPath(input) {
58
+ return typeof input === 'object' && input != null && 'path' in input && typeof input.path === 'string'
59
+ ? input.path
60
+ : undefined;
61
+ }
62
+ async function runDedupedTool(toolName, input, context, execute) {
63
+ const ctx = hazeContext(context);
64
+ if (!ctx)
65
+ return execute();
66
+ ctx.inFlightToolCalls ??= new Map();
67
+ ctx.completedToolCalls ??= new Map();
68
+ ctx.failedMutationPaths ??= new Set();
69
+ ctx.pathsReadAfterFailedMutation ??= new Set();
70
+ ctx.mutationEpoch ??= 0;
71
+ const key = toolCallKey(toolName, input);
72
+ const pathForInput = inputPath(input);
73
+ if (isMutatingTool(toolName) && pathForInput && ctx.failedMutationPaths.has(pathForInput) && !ctx.pathsReadAfterFailedMutation.has(pathForInput)) {
74
+ throw new Error(`Read ${pathForInput} before attempting another edit after the previous edit failure.`);
75
+ }
76
+ const completedAt = ctx.completedToolCalls.get(key);
77
+ const readAfterFailedMutation = toolName === 'readFile' && pathForInput && ctx.failedMutationPaths.has(pathForInput) && !ctx.pathsReadAfterFailedMutation.has(pathForInput);
78
+ if ((isReadOnlyFileTool(toolName) || toolName === 'bash') && completedAt === ctx.mutationEpoch && !readAfterFailedMutation) {
79
+ return {
80
+ ok: true,
81
+ duplicateSkipped: true,
82
+ toolName,
83
+ reason: toolName === 'bash'
84
+ ? 'Skipped duplicate bash command; no files changed since the previous run.'
85
+ : 'Skipped duplicate read-only tool call with identical input; no files changed since the previous call.',
86
+ };
87
+ }
88
+ if (ctx.inFlightToolCalls.has(key)) {
89
+ return {
90
+ ok: true,
91
+ duplicateSkipped: true,
92
+ toolName,
93
+ reason: 'Skipped duplicate in-flight tool call with identical input.',
94
+ };
95
+ }
96
+ const promise = execute();
97
+ ctx.inFlightToolCalls.set(key, promise);
98
+ try {
99
+ const result = await promise;
100
+ if (toolName === 'readFile' && pathForInput)
101
+ ctx.pathsReadAfterFailedMutation.add(pathForInput);
102
+ if (isMutatingTool(toolName)) {
103
+ ctx.mutationEpoch += 1;
104
+ if (pathForInput) {
105
+ ctx.failedMutationPaths.delete(pathForInput);
106
+ ctx.pathsReadAfterFailedMutation.delete(pathForInput);
107
+ }
108
+ }
109
+ ctx.completedToolCalls.set(key, ctx.mutationEpoch);
110
+ return result;
111
+ }
112
+ catch (error) {
113
+ if (isMutatingTool(toolName) && pathForInput) {
114
+ ctx.failedMutationPaths.add(pathForInput);
115
+ ctx.pathsReadAfterFailedMutation.delete(pathForInput);
116
+ }
117
+ throw error;
118
+ }
119
+ finally {
120
+ ctx.inFlightToolCalls.delete(key);
121
+ }
122
+ }
123
+ function looksLikeShellFileMutation(command) {
124
+ return /(^|[;&|]\s*)(sed\s+-i|perl\s+-pi|tee\b|chmod\b|mv\b|cp\b|rm\b|mkdir\b|touch\b)/.test(command)
125
+ || /(^|\s)(>|>>)(\s|\S)/.test(command)
126
+ || /\b(File\.write|writeFileSync|writeFile|appendFileSync|appendFile)\b/.test(command);
127
+ }
43
128
  export const hazeTools = {
44
129
  listFiles: tool({
45
130
  description: 'List files and directories in the current workspace. Prefer this over bash ls/find for discovering project structure.',
@@ -47,21 +132,24 @@ export const hazeTools = {
47
132
  path: z.string().default('.').describe('Directory path relative to the current workspace'),
48
133
  recursive: z.boolean().default(false).describe('Whether to list files recursively'),
49
134
  maxEntries: z.number().int().positive().max(500).default(100).describe('Maximum number of entries to return'),
135
+ cursor: z.string().optional().describe('Pagination cursor from a previous listFiles result. Continue after that entry.'),
50
136
  includeIgnored: z.boolean().default(false).describe('Include files ignored by .gitignore. Use only when explicitly needed.'),
51
137
  }),
52
- execute: async ({ path: dirPath, recursive, maxEntries, includeIgnored }) => {
138
+ execute: async ({ path: dirPath, recursive, maxEntries, cursor, includeIgnored }, context) => runDedupedTool('listFiles', { path: dirPath, recursive, maxEntries, cursor, includeIgnored }, context, async () => {
53
139
  const absolutePath = resolveWorkspacePath(dirPath);
54
140
  await assertNotIgnored(absolutePath, dirPath, includeIgnored);
55
141
  const entries = [];
56
142
  let ignoredSkipped = 0;
57
- const walked = await walkDir(absolutePath, { recursive, maxEntries, filter: async (entry) => {
143
+ const walked = await walkDir(absolutePath, { recursive, maxEntries: maxEntries + 1, cursor, filter: async (entry) => {
58
144
  if (!includeIgnored && await isGitIgnored(entry.absolutePath)) {
59
145
  ignoredSkipped++;
60
146
  return false;
61
147
  }
62
148
  return true;
63
149
  } });
64
- for (const entry of walked) {
150
+ const page = walked.slice(0, maxEntries);
151
+ const hasMore = walked.length > maxEntries;
152
+ for (const entry of page) {
65
153
  if (entry.isDirectory) {
66
154
  entries.push({ path: entry.path, type: 'directory' });
67
155
  }
@@ -70,8 +158,8 @@ export const hazeTools = {
70
158
  entries.push({ path: entry.path, type: 'file', size: stat.size });
71
159
  }
72
160
  }
73
- return { path: dirPath, recursive, includeIgnored, ignoredSkipped, entries, truncated: entries.length >= maxEntries };
74
- },
161
+ return { path: dirPath, recursive, includeIgnored, cursor, nextCursor: hasMore ? page.at(-1)?.path : undefined, ignoredSkipped, entries, truncated: hasMore };
162
+ }),
75
163
  }),
76
164
  readFile: tool({
77
165
  description: 'Read a UTF-8 text file from the current workspace. Supports optional 1-based line offset and line limit.',
@@ -81,7 +169,7 @@ export const hazeTools = {
81
169
  limit: z.number().int().positive().max(2000).optional().describe('Maximum number of lines to return'),
82
170
  allowIgnored: z.boolean().default(false).describe('Read the file even if it is ignored by .gitignore. Use only when explicitly needed.'),
83
171
  }),
84
- execute: async ({ path: filePath, offset, limit, allowIgnored }) => {
172
+ execute: async ({ path: filePath, offset, limit, allowIgnored }, context) => runDedupedTool('readFile', { path: filePath, offset, limit, allowIgnored }, context, async () => {
85
173
  const absolutePath = resolveWorkspacePath(filePath);
86
174
  await assertNotIgnored(absolutePath, filePath, allowIgnored);
87
175
  const content = await fs.readFile(absolutePath, 'utf8');
@@ -98,20 +186,18 @@ export const hazeTools = {
98
186
  lineNumberedText: numberLines(selectedLines, start + 1),
99
187
  ...truncate(selected),
100
188
  };
101
- },
189
+ }),
102
190
  }),
103
191
  replaceLines: tool({
104
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.',
105
193
  inputSchema: z.object({
106
194
  path: z.string().describe('File path relative to the current workspace'),
107
195
  startLine: z.number().int().positive().describe('First 1-based line number to replace'),
108
- endLine: z.number().int().positive().describe('Last 1-based line number to replace, inclusive'),
196
+ endLine: z.number().int().nonnegative().describe('Last 1-based line number to replace, inclusive. To append at EOF, use startLine=totalLines+1 and endLine=totalLines.'),
109
197
  content: z.string().describe('Replacement content for the line range'),
110
198
  allowIgnored: z.boolean().default(false).describe('Edit the file even if it is ignored by .gitignore. Use only when explicitly needed.'),
111
199
  }),
112
- execute: async ({ path: filePath, startLine, endLine, content, allowIgnored }) => {
113
- if (endLine < startLine)
114
- throw new Error('endLine must be greater than or equal to startLine');
200
+ execute: async ({ path: filePath, startLine, endLine, content, allowIgnored }, context) => runDedupedTool('replaceLines', { path: filePath, startLine, endLine, content, allowIgnored }, context, async () => {
115
201
  const absolutePath = resolveWorkspacePath(filePath);
116
202
  await assertNotIgnored(absolutePath, filePath, allowIgnored);
117
203
  const original = await fs.readFile(absolutePath, 'utf8');
@@ -119,31 +205,51 @@ export const hazeTools = {
119
205
  const lines = original.split(/\r?\n/);
120
206
  if (hasTrailingNewline)
121
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');
122
211
  if (startLine > lines.length + 1)
123
212
  throw new Error(`startLine ${startLine} is beyond end of file (${lines.length} lines)`);
124
213
  if (endLine > lines.length)
125
214
  throw new Error(`endLine ${endLine} is beyond end of file (${lines.length} lines)`);
126
215
  const replacementLines = content.length === 0 ? [] : content.split(/\r?\n/);
127
- lines.splice(startLine - 1, endLine - startLine + 1, ...replacementLines);
216
+ if (isAppend) {
217
+ lines.push(...replacementLines);
218
+ }
219
+ else {
220
+ lines.splice(startLine - 1, endLine - startLine + 1, ...replacementLines);
221
+ }
128
222
  const updated = lines.join('\n') + (hasTrailingNewline ? '\n' : '');
129
223
  await fs.writeFile(absolutePath, updated, 'utf8');
130
- return { ok: true, path: filePath, startLine, endLine, replacementLines: replacementLines.length };
131
- },
224
+ return { ok: true, path: filePath, startLine, endLine, replacementLines: replacementLines.length, appended: isAppend };
225
+ }),
132
226
  }),
133
227
  writeFile: tool({
134
- description: 'Create or overwrite a UTF-8 text file in the current workspace. Creates parent directories as needed. Use for new files or after smaller edit tools are not suitable.',
228
+ description: 'Create a UTF-8 text file in the current workspace. For existing files, prefer editFile/replaceLines; set overwriteExisting=true only for an intentional complete rewrite.',
135
229
  inputSchema: z.object({
136
230
  path: z.string().describe('File path relative to the current workspace'),
137
231
  content: z.string().describe('Complete file contents to write'),
232
+ overwriteExisting: z.boolean().default(false).describe('Required to overwrite an existing file. Prefer editFile or replaceLines for existing files unless a complete rewrite is intentional.'),
138
233
  allowIgnored: z.boolean().default(false).describe('Write the file even if it is ignored by .gitignore. Use only when explicitly needed.'),
139
234
  }),
140
- execute: async ({ path: filePath, content, allowIgnored }) => {
235
+ execute: async ({ path: filePath, content, overwriteExisting, allowIgnored }, context) => runDedupedTool('writeFile', { path: filePath, content, overwriteExisting, allowIgnored }, context, async () => {
141
236
  const absolutePath = resolveWorkspacePath(filePath);
142
237
  await assertNotIgnored(absolutePath, filePath, allowIgnored);
238
+ 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.`);
242
+ }
243
+ }
244
+ catch (error) {
245
+ const code = typeof error === 'object' && error != null && 'code' in error ? error.code : undefined;
246
+ if (code !== 'ENOENT')
247
+ throw error;
248
+ }
143
249
  await fs.mkdir(path.dirname(absolutePath), { recursive: true });
144
250
  await fs.writeFile(absolutePath, content, 'utf8');
145
- return { ok: true, path: filePath, bytes: Buffer.byteLength(content, 'utf8') };
146
- },
251
+ return { ok: true, path: filePath, bytes: Buffer.byteLength(content, 'utf8'), overwritten: overwriteExisting };
252
+ }),
147
253
  }),
148
254
  editFile: tool({
149
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.',
@@ -155,7 +261,7 @@ export const hazeTools = {
155
261
  })).min(1).describe('One or more non-overlapping exact replacements'),
156
262
  allowIgnored: z.boolean().default(false).describe('Edit the file even if it is ignored by .gitignore. Use only when explicitly needed.'),
157
263
  }),
158
- execute: async ({ path: filePath, edits, allowIgnored }) => {
264
+ execute: async ({ path: filePath, edits, allowIgnored }, context) => runDedupedTool('editFile', { path: filePath, edits, allowIgnored }, context, async () => {
159
265
  const absolutePath = resolveWorkspacePath(filePath);
160
266
  await assertNotIgnored(absolutePath, filePath, allowIgnored);
161
267
  const original = await fs.readFile(absolutePath, 'utf8');
@@ -179,15 +285,19 @@ export const hazeTools = {
179
285
  }
180
286
  await fs.writeFile(absolutePath, updated, 'utf8');
181
287
  return { ok: true, path: filePath, edits: edits.length };
182
- },
288
+ }),
183
289
  }),
184
290
  bash: tool({
185
- description: 'Run a bash command in the current workspace. Use for inspecting files, running tests, builds, and other shell tasks. Avoid destructive commands unless the user explicitly asked.',
291
+ description: 'Run a bash command in the current workspace. Use for tests, builds, validation, and inspection. Do not use bash to edit files; use file tools instead unless the user explicitly requested a shell mutation.',
186
292
  inputSchema: z.object({
187
293
  command: z.string().min(1).describe('Command to execute with bash -lc'),
188
294
  timeoutSeconds: z.number().int().positive().max(600).optional().describe('Timeout in seconds; defaults to 60'),
295
+ allowMutation: z.boolean().default(false).describe('Allow shell commands that mutate files (chmod, redirection, tee, sed -i, File.write, etc.). Use only when explicitly requested or when file tools cannot do the job.'),
189
296
  }),
190
- execute: async ({ command, timeoutSeconds }, { abortSignal }) => {
297
+ execute: async ({ command, timeoutSeconds, allowMutation }, context) => runDedupedTool('bash', { command, timeoutSeconds, allowMutation }, context, async () => {
298
+ 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.');
300
+ }
191
301
  const timeoutMs = (timeoutSeconds ?? 60) * 1000;
192
302
  return await new Promise(resolve => {
193
303
  const child = spawn('bash', ['-lc', command], { cwd: workspaceRoot(), stdio: ['ignore', 'pipe', 'pipe'] });
@@ -199,13 +309,13 @@ export const hazeTools = {
199
309
  child.kill('SIGTERM');
200
310
  }, timeoutMs);
201
311
  const abort = () => child.kill('SIGTERM');
202
- abortSignal?.addEventListener('abort', abort, { once: true });
312
+ context.abortSignal?.addEventListener('abort', abort, { once: true });
203
313
  child.stdout.on('data', data => stdout += data.toString());
204
314
  child.stderr.on('data', data => stderr += data.toString());
205
315
  child.on('close', code => {
206
316
  settled = true;
207
317
  clearTimeout(timer);
208
- abortSignal?.removeEventListener('abort', abort);
318
+ context.abortSignal?.removeEventListener('abort', abort);
209
319
  resolve({
210
320
  ok: code === 0,
211
321
  code,
@@ -217,10 +327,10 @@ export const hazeTools = {
217
327
  child.on('error', error => {
218
328
  settled = true;
219
329
  clearTimeout(timer);
220
- abortSignal?.removeEventListener('abort', abort);
330
+ context.abortSignal?.removeEventListener('abort', abort);
221
331
  resolve({ ok: false, command, error: error.message });
222
332
  });
223
333
  });
224
- },
334
+ }),
225
335
  }),
226
336
  };
@@ -2,8 +2,8 @@ export function buildInitPrompt() {
2
2
  return `Initialize this repository for Haze by creating a best-practice AGENTS.md file.
3
3
 
4
4
  Explore the codebase first, respecting .gitignore:
5
- 1. Use listFiles recursively from the workspace root.
6
- 2. Read only the files needed to understand project conventions, commands, architecture, and release workflow. Usually read package/config files, README, and key source entrypoints.
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
7
  3. Do not read ignored files unless truly necessary.
8
8
 
9
9
  Create or update AGENTS.md at the workspace root. It should be concise and useful for future coding agents. Include sections when known:
@@ -5,26 +5,40 @@ export function buildSystemPrompt(contextFiles = []) {
5
5
  return `You are Haze, an expert coding assistant operating inside a terminal-based agent CLI. You help users build apps by understanding the current conversation, inspecting projects, running commands, and editing files.
6
6
 
7
7
  Available tools:
8
- - listFiles: List files and directories in the current workspace. Prefer this over bash ls/find for project discovery.
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
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.
12
- - writeFile: Create or overwrite files. Use for new files or intentional complete rewrites.
13
- - bash: Run shell commands for tests, builds, scripts, and inspection that cannot be done with file tools.
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.
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
+ - 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
+ - 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.
14
15
 
15
16
  Guidelines:
16
17
  - Be concise, technical, and practical.
18
+ - You have access to the tools listed above. Never claim that you cannot inspect files, run shell commands, or make file changes when an available tool can do it.
19
+ - Skills are optional instruction bundles. Call a skill tool only when relevant, then follow the returned SKILL.md instructions and references.
20
+ - If answering requires current workspace information, inspect it with tools instead of guessing or saying you cannot access it.
21
+ - When the user asks you to run a command, inspect command output, or reason about local project state, use bash or file tools rather than only explaining what the user could run.
17
22
  - Preserve user-provided content exactly. When the user asks to add, modify, or use "this", "that", "it", or previous content, refer to the current conversation and do not substitute different text.
18
- - Use listFiles for project discovery instead of bash ls/find.
23
+ - Use listFiles for project discovery instead of bash ls/find. Start non-recursive, use recursive for focused directories, and follow nextCursor only when more listing is genuinely needed.
19
24
  - Do not list or read the same path repeatedly unless the file changed or the previous result was insufficient.
20
25
  - Read only directly relevant files, usually once. Do not read README/package files unless needed for the task.
21
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.
22
27
  - Prefer editFile for existing files when one small exact replacement is unique.
23
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.
24
- - Use writeFile only for new files or complete rewrites.
25
- - Use bash mainly for tests, builds, package scripts, and commands that are not covered by file tools.
26
- - After making changes, validate with the project's relevant test/typecheck/build command when practical.
27
- - After tool use, always respond with a concise summary of what changed or what failed.
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
+ - 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.
31
+ - After making changes, validate with the project's relevant test/typecheck/build command when practical. After editing source or test files in languages with syntax checkers, run the syntax check before the full test command when practical. Once a requested change is edited and validation passes, summarize; do not continue inspecting files.
32
+ - For action requests such as "add", "create", "write", "implement", "update", "fix", "test", or "document", do not stop after only inspecting files. Make the requested file/code changes unless blocked or clarification is required.
33
+ - Requests like "create a plan", "make a plan", or "outline a plan" are planning requests, not implementation requests. If you create a plan document, summarize it; do not start implementing or validating unless asked.
34
+ - If editFile or replaceLines fails, read the affected file again with readFile before another edit attempt, then make one smaller targeted change; do not batch speculative replacements. Bash/cat does not satisfy this recovery step.
35
+ - For plan-only requests, stop after creating/updating the plan artifact and summarize it; do not edit source files or run validation in the same turn.
36
+ - When asked to implement a plan, identify the concrete required checklist items first and compare them with the current files. Do not edit source or tests when the required behavior is already present. Implement the smallest clearly required phase or required items, skip optional/design-question items unless explicitly requested, prefer adding tests over exploratory one-off scripts, validate once after code/test edits, and do not edit the plan file itself unless asked or unless marking completed items after validation passes.
37
+ - After tool use, always respond with a concise summary of what changed or what failed for the current user request only. Do not recap unrelated earlier tasks unless directly relevant.
38
+ - Do not call ordinary unfinished work or unresolved optional scope a blocker. A blocker is a concrete tool failure, missing/ambiguous requirement, permission problem, or unavailable dependency.
39
+ - For Ruby ad-hoc checks, prefer adding/running Minitest tests. If a one-liner is truly useful, use ruby -I. -e with require "file" rather than require_relative from -e.
40
+ - Do not say tools are unavailable just because a tool budget or loop guard was mentioned; if you can still call tools in the current turn, continue the requested work.
41
+ - Do not claim tests passed or commands succeeded unless you actually ran them in the current turn and saw success.
28
42
  - Ask before destructive actions.
29
43
  - Show file paths clearly when working with files.${projectContext}
30
44
 
@@ -1,2 +1,12 @@
1
- import type { LoadedSkill } from './types.js';
2
- export declare function loadSkill(dir: string, source: 'global' | 'local'): Promise<LoadedSkill | null>;
1
+ import type { LoadedSkill, SkillFrontmatter } from './types.js';
2
+ declare function parseSkillMarkdown(content: string): {
3
+ frontmatter: SkillFrontmatter;
4
+ body: string;
5
+ };
6
+ declare function referencedPaths(body: string): string[];
7
+ export declare function loadSkill(dir: string, source?: 'global'): Promise<LoadedSkill | null>;
8
+ export declare const internals: {
9
+ parseSkillMarkdown: typeof parseSkillMarkdown;
10
+ referencedPaths: typeof referencedPaths;
11
+ };
12
+ export {};
@@ -1,22 +1,68 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'node:path';
3
- import { readYaml } from '../utils/yaml.js';
4
- import { skillManifestSchema } from './manifestSchema.js';
5
- export async function loadSkill(dir, source) {
6
- const manifestPath = path.join(dir, 'skill.yaml');
7
- if (!(await fs.pathExists(manifestPath)))
3
+ import YAML from 'yaml';
4
+ const MAX_REFERENCE_BYTES = 50_000;
5
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
6
+ const MARKDOWN_LINK_RE = /\[[^\]]*\]\(([^)]+)\)/g;
7
+ const PLAIN_REFERENCE_RE = /(?:^|\n)\s*(?:[-*]\s+)?((?:[A-Za-z0-9_.-]+\/)+[A-Za-z0-9_.-]+\.[A-Za-z0-9]+)\s*(?=\n|$)/g;
8
+ function validateFrontmatter(value) {
9
+ if (typeof value !== 'object' || value == null)
10
+ throw new Error('SKILL.md frontmatter must be an object');
11
+ const frontmatter = value;
12
+ if (typeof frontmatter.name !== 'string' || frontmatter.name.trim().length === 0)
13
+ throw new Error('SKILL.md frontmatter requires name');
14
+ if (!/^[a-zA-Z0-9_-]+$/.test(frontmatter.name))
15
+ throw new Error('Skill name may only contain letters, numbers, hyphens, and underscores');
16
+ if (typeof frontmatter.description !== 'string' || frontmatter.description.trim().length === 0)
17
+ throw new Error('SKILL.md frontmatter requires description');
18
+ return { name: frontmatter.name.trim(), description: frontmatter.description.trim() };
19
+ }
20
+ function parseSkillMarkdown(content) {
21
+ const match = FRONTMATTER_RE.exec(content);
22
+ if (!match)
23
+ throw new Error('SKILL.md must start with YAML frontmatter delimited by ---');
24
+ const frontmatter = validateFrontmatter(YAML.parse(match[1] ?? ''));
25
+ return { frontmatter, body: content.slice(match[0].length).trim() };
26
+ }
27
+ function normalizeReference(reference) {
28
+ const withoutAnchor = reference.split('#')[0]?.split('?')[0]?.trim() ?? '';
29
+ return withoutAnchor.replace(/^<|>$/g, '');
30
+ }
31
+ function referencedPaths(body) {
32
+ const refs = new Set();
33
+ for (const match of body.matchAll(MARKDOWN_LINK_RE)) {
34
+ const ref = normalizeReference(match[1] ?? '');
35
+ if (ref && !ref.includes('://'))
36
+ refs.add(ref);
37
+ }
38
+ for (const match of body.matchAll(PLAIN_REFERENCE_RE)) {
39
+ const ref = normalizeReference(match[1] ?? '');
40
+ if (ref)
41
+ refs.add(ref);
42
+ }
43
+ return [...refs];
44
+ }
45
+ async function loadReference(dir, referencePath) {
46
+ if (path.isAbsolute(referencePath))
47
+ throw new Error(`Skill reference must be relative: ${referencePath}`);
48
+ const absolutePath = path.resolve(dir, referencePath);
49
+ const relative = path.relative(dir, absolutePath);
50
+ if (relative.startsWith('..') || path.isAbsolute(relative))
51
+ throw new Error(`Skill reference escapes skill directory: ${referencePath}`);
52
+ const stat = await fs.stat(absolutePath);
53
+ if (!stat.isFile())
54
+ throw new Error(`Skill reference is not a file: ${referencePath}`);
55
+ if (stat.size > MAX_REFERENCE_BYTES)
56
+ throw new Error(`Skill reference is too large: ${referencePath}`);
57
+ return { path: referencePath, absolutePath, content: await fs.readFile(absolutePath, 'utf8') };
58
+ }
59
+ export async function loadSkill(dir, source = 'global') {
60
+ const skillPath = path.join(dir, 'SKILL.md');
61
+ if (!(await fs.pathExists(skillPath)))
8
62
  return null;
9
- const raw = await readYaml(manifestPath);
10
- const manifest = skillManifestSchema.parse(raw);
11
- const prompts = await Promise.all((manifest.prompts ?? []).map(async (p) => {
12
- const absolutePath = path.resolve(dir, p.path);
13
- return { ...p, absolutePath, content: await fs.readFile(absolutePath, 'utf8') };
14
- }));
15
- const tools = (manifest.tools ?? []).map(t => ({
16
- ...t,
17
- id: `${manifest.name}.${t.name}`,
18
- skillName: manifest.name,
19
- absolutePath: path.resolve(dir, t.path)
20
- }));
21
- return { dir, manifestPath, manifest, prompts, tools, source };
63
+ const content = await fs.readFile(skillPath, 'utf8');
64
+ const { frontmatter, body } = parseSkillMarkdown(content);
65
+ const references = await Promise.all(referencedPaths(body).map(ref => loadReference(dir, ref)));
66
+ return { dir, path: skillPath, name: frontmatter.name, description: frontmatter.description, body, references, source };
22
67
  }
68
+ export const internals = { parseSkillMarkdown, referencedPaths };
@@ -1,6 +1,2 @@
1
- import type { LoadedSkill, LoadedTool } from './types.js';
2
- export interface SkillRegistry {
3
- skills: Map<string, LoadedSkill>;
4
- tools: Map<string, LoadedTool>;
5
- }
1
+ import type { SkillRegistry } from './types.js';
6
2
  export declare function loadSkillRegistry(): Promise<SkillRegistry>;
@@ -1,28 +1,17 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'node:path';
3
- import { GLOBAL_SKILLS_DIR, LOCAL_SKILLS_DIR } from '../config/paths.js';
3
+ import { GLOBAL_SKILLS_DIR } from '../config/paths.js';
4
4
  import { loadSkill } from './SkillLoader.js';
5
5
  export async function loadSkillRegistry() {
6
6
  const skills = new Map();
7
- const tools = new Map();
8
- async function scanDir(root, source) {
9
- if (!(await fs.pathExists(root)))
10
- return;
11
- for (const name of await fs.readdir(root)) {
12
- const dir = path.join(root, name);
13
- if (!(await fs.stat(dir)).isDirectory())
14
- continue;
15
- const skill = await loadSkill(dir, source);
16
- if (skill)
17
- skills.set(skill.manifest.name, skill);
18
- }
19
- }
20
7
  await fs.ensureDir(GLOBAL_SKILLS_DIR);
21
- await fs.ensureDir(LOCAL_SKILLS_DIR);
22
- await scanDir(GLOBAL_SKILLS_DIR, 'global');
23
- await scanDir(LOCAL_SKILLS_DIR, 'local');
24
- for (const skill of skills.values())
25
- for (const tool of skill.tools)
26
- tools.set(tool.id, tool);
27
- return { skills, tools };
8
+ for (const name of await fs.readdir(GLOBAL_SKILLS_DIR)) {
9
+ const dir = path.join(GLOBAL_SKILLS_DIR, name);
10
+ if (!(await fs.stat(dir)).isDirectory())
11
+ continue;
12
+ const skill = await loadSkill(dir, 'global');
13
+ if (skill)
14
+ skills.set(skill.name, skill);
15
+ }
16
+ return { skills };
28
17
  }
@@ -1 +1,25 @@
1
- export declare function buildSkill(description: string): Promise<void>;
1
+ type GeneratedSkillFile = {
2
+ path: string;
3
+ content: string;
4
+ };
5
+ type GeneratedSkill = {
6
+ name: string;
7
+ files: GeneratedSkillFile[];
8
+ };
9
+ export declare function slug(s: string): string;
10
+ declare function fallbackSkill(description: string): GeneratedSkill;
11
+ declare function withStandardRequirements(content: string): string;
12
+ declare function parseGeneratedSkill(text: string, description: string): GeneratedSkill;
13
+ export declare function createSkill(description: string): Promise<{
14
+ name: string;
15
+ dir: string;
16
+ file: string;
17
+ }>;
18
+ export declare const internals: {
19
+ SKILL_CREATOR_SKILL: string;
20
+ STANDARD_SKILL_REQUIREMENTS: string;
21
+ parseGeneratedSkill: typeof parseGeneratedSkill;
22
+ fallbackSkill: typeof fallbackSkill;
23
+ withStandardRequirements: typeof withStandardRequirements;
24
+ };
25
+ export {};