@yeaft/webchat-agent 0.1.409 → 0.1.410
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/package.json +1 -1
- package/unify/index.js +12 -0
- package/unify/mcp.js +433 -0
- package/unify/memory/dream-prompt.js +272 -0
- package/unify/memory/dream.js +468 -0
- package/unify/memory/scan.js +273 -0
- package/unify/memory/types.js +139 -0
- package/unify/skills.js +315 -0
- package/unify/stop-hooks.js +146 -0
- package/unify/tools/enter-worktree.js +97 -0
- package/unify/tools/exit-worktree.js +131 -0
- package/unify/tools/mcp-tools.js +133 -0
- package/unify/tools/registry.js +146 -0
- package/unify/tools/skill.js +107 -0
- package/unify/tools/types.js +71 -0
package/unify/skills.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* skills.js — Skill loading and management
|
|
3
|
+
*
|
|
4
|
+
* Skills are markdown files in ~/.yeaft/skills/ that define
|
|
5
|
+
* specialized behaviors or workflows. They are loaded at startup
|
|
6
|
+
* and injected into the system prompt when relevant.
|
|
7
|
+
*
|
|
8
|
+
* Skill format (skills/my-skill.md):
|
|
9
|
+
* ---
|
|
10
|
+
* name: my-skill
|
|
11
|
+
* description: Does something useful
|
|
12
|
+
* trigger: "when user asks about X"
|
|
13
|
+
* mode: chat | work | both
|
|
14
|
+
* ---
|
|
15
|
+
* # Skill instructions here...
|
|
16
|
+
*
|
|
17
|
+
* Reference: yeaft-unify-design.md §8, yeaft-unify-core-systems.md
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from 'fs';
|
|
21
|
+
import { join, basename } from 'path';
|
|
22
|
+
|
|
23
|
+
// ─── Skill Parsing ─────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} Skill
|
|
27
|
+
* @property {string} name — unique skill name
|
|
28
|
+
* @property {string} description — human-readable description
|
|
29
|
+
* @property {string} trigger — when this skill should be invoked
|
|
30
|
+
* @property {string} mode — 'chat' | 'work' | 'both'
|
|
31
|
+
* @property {string} content — full skill instructions (markdown body)
|
|
32
|
+
* @property {string} _filename — source filename
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse a skill .md file.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} raw — raw file content
|
|
39
|
+
* @param {string} filename — source filename
|
|
40
|
+
* @returns {Skill|null}
|
|
41
|
+
*/
|
|
42
|
+
export function parseSkill(raw, filename = '') {
|
|
43
|
+
if (!raw || !raw.startsWith('---')) return null;
|
|
44
|
+
|
|
45
|
+
const endIdx = raw.indexOf('\n---', 3);
|
|
46
|
+
if (endIdx === -1) return null;
|
|
47
|
+
|
|
48
|
+
const frontmatter = raw.slice(4, endIdx).trim();
|
|
49
|
+
const body = raw.slice(endIdx + 4).trim();
|
|
50
|
+
|
|
51
|
+
const skill = {
|
|
52
|
+
content: body,
|
|
53
|
+
_filename: filename,
|
|
54
|
+
mode: 'both',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
for (const line of frontmatter.split('\n')) {
|
|
58
|
+
const colonIdx = line.indexOf(':');
|
|
59
|
+
if (colonIdx === -1) continue;
|
|
60
|
+
const key = line.slice(0, colonIdx).trim();
|
|
61
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
62
|
+
|
|
63
|
+
switch (key) {
|
|
64
|
+
case 'name': skill.name = value; break;
|
|
65
|
+
case 'description': skill.description = value; break;
|
|
66
|
+
case 'trigger': skill.trigger = value; break;
|
|
67
|
+
case 'mode': skill.mode = value; break;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Use filename as name fallback
|
|
72
|
+
if (!skill.name) {
|
|
73
|
+
skill.name = basename(filename, '.md');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return skill;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Serialize a skill to .md format.
|
|
81
|
+
*
|
|
82
|
+
* @param {Skill} skill
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
export function serializeSkill(skill) {
|
|
86
|
+
const fm = [
|
|
87
|
+
'---',
|
|
88
|
+
`name: ${skill.name}`,
|
|
89
|
+
`description: ${skill.description || ''}`,
|
|
90
|
+
`trigger: ${skill.trigger || ''}`,
|
|
91
|
+
`mode: ${skill.mode || 'both'}`,
|
|
92
|
+
'---',
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
return fm.join('\n') + '\n\n' + (skill.content || '');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── SkillManager ──────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* SkillManager — loads, indexes, and queries skills.
|
|
102
|
+
*/
|
|
103
|
+
export class SkillManager {
|
|
104
|
+
/** @type {Map<string, Skill>} */
|
|
105
|
+
#skills = new Map();
|
|
106
|
+
|
|
107
|
+
/** @type {string} */
|
|
108
|
+
#skillsDir;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {string} yeaftDir — Yeaft root directory (e.g. ~/.yeaft)
|
|
112
|
+
*/
|
|
113
|
+
constructor(yeaftDir) {
|
|
114
|
+
this.#skillsDir = join(yeaftDir, 'skills');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Load all skills from the skills directory.
|
|
119
|
+
*
|
|
120
|
+
* @returns {{ loaded: number, errors: string[] }}
|
|
121
|
+
*/
|
|
122
|
+
load() {
|
|
123
|
+
this.#skills.clear();
|
|
124
|
+
const errors = [];
|
|
125
|
+
|
|
126
|
+
if (!existsSync(this.#skillsDir)) {
|
|
127
|
+
return { loaded: 0, errors: [] };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const files = readdirSync(this.#skillsDir).filter(f => f.endsWith('.md'));
|
|
131
|
+
|
|
132
|
+
for (const file of files) {
|
|
133
|
+
try {
|
|
134
|
+
const raw = readFileSync(join(this.#skillsDir, file), 'utf8');
|
|
135
|
+
const skill = parseSkill(raw, file);
|
|
136
|
+
if (skill && skill.name) {
|
|
137
|
+
this.#skills.set(skill.name, skill);
|
|
138
|
+
} else {
|
|
139
|
+
errors.push(`Failed to parse skill: ${file}`);
|
|
140
|
+
}
|
|
141
|
+
} catch (err) {
|
|
142
|
+
errors.push(`Error loading ${file}: ${err.message}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { loaded: this.#skills.size, errors };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Get a skill by name.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} name
|
|
153
|
+
* @returns {Skill|null}
|
|
154
|
+
*/
|
|
155
|
+
get(name) {
|
|
156
|
+
return this.#skills.get(name) || null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Check if a skill exists.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} name
|
|
163
|
+
* @returns {boolean}
|
|
164
|
+
*/
|
|
165
|
+
has(name) {
|
|
166
|
+
return this.#skills.has(name);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* List all skills, optionally filtered by mode.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} [mode] — 'chat' | 'work' | undefined (all)
|
|
173
|
+
* @returns {Skill[]}
|
|
174
|
+
*/
|
|
175
|
+
list(mode) {
|
|
176
|
+
const skills = [...this.#skills.values()];
|
|
177
|
+
if (!mode) return skills;
|
|
178
|
+
|
|
179
|
+
return skills.filter(s => s.mode === 'both' || s.mode === mode);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Find skills relevant to a prompt (simple keyword matching).
|
|
184
|
+
*
|
|
185
|
+
* @param {string} prompt — user's prompt
|
|
186
|
+
* @param {string} [mode] — filter by mode
|
|
187
|
+
* @returns {Skill[]}
|
|
188
|
+
*/
|
|
189
|
+
findRelevant(prompt, mode) {
|
|
190
|
+
if (!prompt) return [];
|
|
191
|
+
|
|
192
|
+
const lowerPrompt = prompt.toLowerCase();
|
|
193
|
+
// Strip punctuation and split on whitespace for clean word matching
|
|
194
|
+
const cleanPrompt = lowerPrompt.replace(/[^\w\s]/g, '');
|
|
195
|
+
const promptWords = cleanPrompt.split(/\s+/).filter(w => w.length > 2);
|
|
196
|
+
const skills = this.list(mode);
|
|
197
|
+
|
|
198
|
+
return skills.filter(skill => {
|
|
199
|
+
// Check trigger match — any trigger keyword found in prompt
|
|
200
|
+
if (skill.trigger) {
|
|
201
|
+
const triggerWords = skill.trigger.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(w => w.length > 2);
|
|
202
|
+
// Count matches: exact word, substring, or shared stem (first 4 chars)
|
|
203
|
+
const matchCount = triggerWords.filter(tw => {
|
|
204
|
+
if (cleanPrompt.includes(tw)) return true;
|
|
205
|
+
// Stem matching: if trigger word and prompt word share a 4+ char prefix
|
|
206
|
+
const twStem = tw.slice(0, Math.min(tw.length, 4));
|
|
207
|
+
return promptWords.some(pw => {
|
|
208
|
+
if (pw.includes(tw) || tw.includes(pw)) return true;
|
|
209
|
+
const pwStem = pw.slice(0, Math.min(pw.length, 4));
|
|
210
|
+
return twStem.length >= 4 && pwStem.length >= 4 && twStem === pwStem;
|
|
211
|
+
});
|
|
212
|
+
}).length;
|
|
213
|
+
// At least 1 meaningful match and ≥30% of trigger words
|
|
214
|
+
if (matchCount >= 1 && matchCount >= Math.ceil(triggerWords.length * 0.3)) {
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Check name match
|
|
220
|
+
if (lowerPrompt.includes(skill.name.toLowerCase())) {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Check description match
|
|
225
|
+
if (skill.description && lowerPrompt.includes(skill.description.toLowerCase())) {
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return false;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Add or update a skill.
|
|
235
|
+
*
|
|
236
|
+
* @param {Skill} skill
|
|
237
|
+
* @returns {string} — filename
|
|
238
|
+
*/
|
|
239
|
+
save(skill) {
|
|
240
|
+
if (!skill.name) throw new Error('Skill must have a name');
|
|
241
|
+
|
|
242
|
+
const filename = `${skill.name}.md`;
|
|
243
|
+
const filePath = join(this.#skillsDir, filename);
|
|
244
|
+
|
|
245
|
+
writeFileSync(filePath, serializeSkill(skill), 'utf8');
|
|
246
|
+
this.#skills.set(skill.name, { ...skill, _filename: filename });
|
|
247
|
+
|
|
248
|
+
return filename;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Remove a skill.
|
|
253
|
+
*
|
|
254
|
+
* @param {string} name
|
|
255
|
+
* @returns {boolean}
|
|
256
|
+
*/
|
|
257
|
+
remove(name) {
|
|
258
|
+
const skill = this.#skills.get(name);
|
|
259
|
+
if (!skill) return false;
|
|
260
|
+
|
|
261
|
+
const filePath = join(this.#skillsDir, skill._filename || `${name}.md`);
|
|
262
|
+
try {
|
|
263
|
+
unlinkSync(filePath);
|
|
264
|
+
} catch {
|
|
265
|
+
// File might not exist
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
this.#skills.delete(name);
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Get the skill content formatted for system prompt injection.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} name
|
|
276
|
+
* @returns {string}
|
|
277
|
+
*/
|
|
278
|
+
getPromptContent(name) {
|
|
279
|
+
const skill = this.#skills.get(name);
|
|
280
|
+
if (!skill) return '';
|
|
281
|
+
|
|
282
|
+
return `## Skill: ${skill.name}\n\n${skill.content}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Get all relevant skill contents for a prompt.
|
|
287
|
+
*
|
|
288
|
+
* @param {string} prompt
|
|
289
|
+
* @param {string} [mode]
|
|
290
|
+
* @returns {string}
|
|
291
|
+
*/
|
|
292
|
+
getRelevantPromptContent(prompt, mode) {
|
|
293
|
+
const relevant = this.findRelevant(prompt, mode);
|
|
294
|
+
if (relevant.length === 0) return '';
|
|
295
|
+
|
|
296
|
+
return relevant.map(s => this.getPromptContent(s.name)).join('\n\n');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Number of loaded skills. */
|
|
300
|
+
get size() {
|
|
301
|
+
return this.#skills.size;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Create a SkillManager and load skills.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} yeaftDir
|
|
309
|
+
* @returns {SkillManager}
|
|
310
|
+
*/
|
|
311
|
+
export function createSkillManager(yeaftDir) {
|
|
312
|
+
const manager = new SkillManager(yeaftDir);
|
|
313
|
+
manager.load();
|
|
314
|
+
return manager;
|
|
315
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stop-hooks.js — Post-query lifecycle hooks
|
|
3
|
+
*
|
|
4
|
+
* Runs after each query loop completes:
|
|
5
|
+
* 1. Persist messages to conversation/messages/
|
|
6
|
+
* 2. Consolidate check (compact + extract) — only when budget exceeded
|
|
7
|
+
* 3. Dream gate check (background)
|
|
8
|
+
* 4. Increment dream query counter
|
|
9
|
+
*
|
|
10
|
+
* Reference: yeaft-unify-core-systems.md §4.4
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { shouldConsolidate, consolidate } from './memory/consolidate.js';
|
|
14
|
+
import { checkDreamGate, incrementQueryCount, dream } from './memory/dream.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Run all stop hooks after a query completes.
|
|
18
|
+
*
|
|
19
|
+
* @param {{
|
|
20
|
+
* yeaftDir: string,
|
|
21
|
+
* mode: string,
|
|
22
|
+
* conversationStore: import('./conversation/persist.js').ConversationStore,
|
|
23
|
+
* memoryStore: import('./memory/store.js').MemoryStore,
|
|
24
|
+
* adapter: object,
|
|
25
|
+
* config: object,
|
|
26
|
+
* messages?: object[],
|
|
27
|
+
* taskId?: string,
|
|
28
|
+
* workerId?: string,
|
|
29
|
+
* trace?: object,
|
|
30
|
+
* }} context
|
|
31
|
+
* @returns {Promise<StopHookResult>}
|
|
32
|
+
*/
|
|
33
|
+
export async function runStopHooks(context) {
|
|
34
|
+
const {
|
|
35
|
+
yeaftDir,
|
|
36
|
+
mode,
|
|
37
|
+
conversationStore,
|
|
38
|
+
memoryStore,
|
|
39
|
+
adapter,
|
|
40
|
+
config,
|
|
41
|
+
messages = [],
|
|
42
|
+
taskId,
|
|
43
|
+
trace,
|
|
44
|
+
} = context;
|
|
45
|
+
|
|
46
|
+
const result = {
|
|
47
|
+
messagesPersisted: 0,
|
|
48
|
+
consolidated: false,
|
|
49
|
+
dreamTriggered: false,
|
|
50
|
+
errors: [],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Workers don't run stop hooks (they persist to task workers/ dir)
|
|
54
|
+
if (mode === 'worker') {
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 1. Persist latest messages
|
|
59
|
+
try {
|
|
60
|
+
if (conversationStore && messages.length > 0) {
|
|
61
|
+
const recentMessages = messages.slice(-2); // last user + assistant pair
|
|
62
|
+
for (const msg of recentMessages) {
|
|
63
|
+
if (msg.role && msg.content) {
|
|
64
|
+
conversationStore.append({
|
|
65
|
+
role: msg.role,
|
|
66
|
+
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
|
67
|
+
mode,
|
|
68
|
+
model: config.model,
|
|
69
|
+
});
|
|
70
|
+
result.messagesPersisted++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
result.errors.push(`Persist failed: ${err.message}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2. Consolidate check (non-blocking, but awaited for correctness)
|
|
79
|
+
try {
|
|
80
|
+
if (conversationStore && memoryStore && adapter) {
|
|
81
|
+
if (shouldConsolidate(conversationStore, config.messageTokenBudget)) {
|
|
82
|
+
const consolidated = await consolidate({
|
|
83
|
+
conversationStore,
|
|
84
|
+
memoryStore,
|
|
85
|
+
adapter,
|
|
86
|
+
config,
|
|
87
|
+
budget: config.messageTokenBudget,
|
|
88
|
+
});
|
|
89
|
+
result.consolidated = true;
|
|
90
|
+
trace?.logEvent({
|
|
91
|
+
eventType: 'consolidate',
|
|
92
|
+
eventData: {
|
|
93
|
+
archivedCount: consolidated.archivedCount,
|
|
94
|
+
extractedEntries: consolidated.extractedEntries.length,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
result.errors.push(`Consolidate failed: ${err.message}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 3. Increment dream query counter
|
|
104
|
+
try {
|
|
105
|
+
if (yeaftDir) {
|
|
106
|
+
incrementQueryCount(yeaftDir);
|
|
107
|
+
}
|
|
108
|
+
} catch (err) {
|
|
109
|
+
result.errors.push(`Dream counter failed: ${err.message}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 4. Dream gate check (fire-and-forget, background)
|
|
113
|
+
try {
|
|
114
|
+
if (yeaftDir && memoryStore && adapter) {
|
|
115
|
+
const gate = checkDreamGate(yeaftDir);
|
|
116
|
+
if (gate.shouldDream) {
|
|
117
|
+
result.dreamTriggered = true;
|
|
118
|
+
// Fire and forget — dream runs in background
|
|
119
|
+
dream({
|
|
120
|
+
yeaftDir,
|
|
121
|
+
memoryStore,
|
|
122
|
+
conversationStore,
|
|
123
|
+
adapter,
|
|
124
|
+
config,
|
|
125
|
+
}).catch(err => {
|
|
126
|
+
trace?.logEvent({
|
|
127
|
+
eventType: 'dream_error',
|
|
128
|
+
eventData: { error: err.message },
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch (err) {
|
|
134
|
+
result.errors.push(`Dream gate check failed: ${err.message}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @typedef {Object} StopHookResult
|
|
142
|
+
* @property {number} messagesPersisted — how many messages were persisted
|
|
143
|
+
* @property {boolean} consolidated — whether consolidation ran
|
|
144
|
+
* @property {boolean} dreamTriggered — whether dream was triggered
|
|
145
|
+
* @property {string[]} errors — any non-fatal errors
|
|
146
|
+
*/
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* enter-worktree.js — Create an isolated git worktree for development
|
|
3
|
+
*
|
|
4
|
+
* Creates a new git worktree with an isolated branch, useful for
|
|
5
|
+
* sub-agents that need to work on files without conflicting with
|
|
6
|
+
* the main working tree or other workers.
|
|
7
|
+
*
|
|
8
|
+
* Reference: yeaft-unify-design.md §8, yeaft-unify-core-systems.md §3.2
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { defineTool } from './types.js';
|
|
12
|
+
import { execSync } from 'child_process';
|
|
13
|
+
import { existsSync, mkdirSync } from 'fs';
|
|
14
|
+
import { join, resolve } from 'path';
|
|
15
|
+
import { randomUUID } from 'crypto';
|
|
16
|
+
|
|
17
|
+
export default defineTool({
|
|
18
|
+
name: 'EnterWorktree',
|
|
19
|
+
description: `Create an isolated git worktree for development.
|
|
20
|
+
|
|
21
|
+
Creates a new git worktree with a dedicated branch, allowing parallel
|
|
22
|
+
development without file conflicts. Useful for:
|
|
23
|
+
- Sub-agents working on independent subtasks
|
|
24
|
+
- Testing changes in isolation before merging
|
|
25
|
+
- Parallel feature development
|
|
26
|
+
|
|
27
|
+
The worktree is created in .yeaft/worktrees/ with a new branch based on HEAD.
|
|
28
|
+
Returns the worktree path and branch name.`,
|
|
29
|
+
parameters: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: {
|
|
32
|
+
name: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
description: 'Name for the worktree (used in path and branch name). If omitted, a random name is generated.',
|
|
35
|
+
},
|
|
36
|
+
base_ref: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
description: 'Git ref to base the worktree on (default: HEAD)',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
modes: ['work'],
|
|
43
|
+
isDestructive: () => false,
|
|
44
|
+
async execute(input, ctx) {
|
|
45
|
+
const cwd = ctx?.cwd || process.cwd();
|
|
46
|
+
|
|
47
|
+
// Verify we're in a git repo
|
|
48
|
+
try {
|
|
49
|
+
execSync('git rev-parse --git-dir', { cwd, stdio: 'pipe' });
|
|
50
|
+
} catch {
|
|
51
|
+
return JSON.stringify({ error: 'Not in a git repository' });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Generate worktree name and path
|
|
55
|
+
const name = input.name
|
|
56
|
+
? input.name.replace(/[^a-zA-Z0-9._-]/g, '-').slice(0, 64)
|
|
57
|
+
: `wt-${randomUUID().slice(0, 8)}`;
|
|
58
|
+
|
|
59
|
+
const worktreeDir = join(cwd, '.yeaft', 'worktrees', name);
|
|
60
|
+
const branchName = `yeaft-wt/${name}`;
|
|
61
|
+
const baseRef = input.base_ref || 'HEAD';
|
|
62
|
+
|
|
63
|
+
// Check if worktree already exists
|
|
64
|
+
if (existsSync(worktreeDir)) {
|
|
65
|
+
return JSON.stringify({
|
|
66
|
+
error: `Worktree "${name}" already exists at ${worktreeDir}`,
|
|
67
|
+
path: worktreeDir,
|
|
68
|
+
branch: branchName,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Ensure parent directory exists
|
|
73
|
+
const parentDir = join(cwd, '.yeaft', 'worktrees');
|
|
74
|
+
if (!existsSync(parentDir)) {
|
|
75
|
+
mkdirSync(parentDir, { recursive: true });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
// Create worktree with new branch
|
|
80
|
+
const cmd = `git worktree add -b "${branchName}" "${worktreeDir}" ${baseRef}`;
|
|
81
|
+
execSync(cmd, { cwd, stdio: 'pipe' });
|
|
82
|
+
|
|
83
|
+
return JSON.stringify({
|
|
84
|
+
success: true,
|
|
85
|
+
path: resolve(worktreeDir),
|
|
86
|
+
branch: branchName,
|
|
87
|
+
baseRef,
|
|
88
|
+
name,
|
|
89
|
+
message: `Created worktree "${name}" at ${worktreeDir} on branch ${branchName}`,
|
|
90
|
+
});
|
|
91
|
+
} catch (err) {
|
|
92
|
+
return JSON.stringify({
|
|
93
|
+
error: `Failed to create worktree: ${err.message}`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* exit-worktree.js — Remove or keep a git worktree
|
|
3
|
+
*
|
|
4
|
+
* Exits and optionally removes a worktree created by EnterWorktree.
|
|
5
|
+
* Can keep the worktree (preserve branch) or remove it cleanly.
|
|
6
|
+
*
|
|
7
|
+
* Reference: yeaft-unify-design.md §8
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { defineTool } from './types.js';
|
|
11
|
+
import { execSync } from 'child_process';
|
|
12
|
+
import { existsSync } from 'fs';
|
|
13
|
+
import { resolve } from 'path';
|
|
14
|
+
|
|
15
|
+
export default defineTool({
|
|
16
|
+
name: 'ExitWorktree',
|
|
17
|
+
description: `Exit a git worktree session.
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
- "keep": Leave the worktree and branch on disk (can return later)
|
|
21
|
+
- "remove": Delete the worktree directory and its branch
|
|
22
|
+
|
|
23
|
+
If removing and there are uncommitted changes, the operation will fail
|
|
24
|
+
unless discard_changes is set to true.`,
|
|
25
|
+
parameters: {
|
|
26
|
+
type: 'object',
|
|
27
|
+
properties: {
|
|
28
|
+
path: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
description: 'Path to the worktree to exit (required)',
|
|
31
|
+
},
|
|
32
|
+
action: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
enum: ['keep', 'remove'],
|
|
35
|
+
description: '"keep" leaves the worktree on disk; "remove" deletes it',
|
|
36
|
+
},
|
|
37
|
+
discard_changes: {
|
|
38
|
+
type: 'boolean',
|
|
39
|
+
description: 'Force remove even with uncommitted changes (default: false)',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
required: ['path', 'action'],
|
|
43
|
+
},
|
|
44
|
+
modes: ['work'],
|
|
45
|
+
isDestructive: (input) => input?.action === 'remove',
|
|
46
|
+
async execute(input, ctx) {
|
|
47
|
+
const worktreePath = resolve(input.path);
|
|
48
|
+
const mainCwd = ctx?.cwd || process.cwd();
|
|
49
|
+
|
|
50
|
+
if (!existsSync(worktreePath)) {
|
|
51
|
+
return JSON.stringify({
|
|
52
|
+
error: `Worktree path does not exist: ${worktreePath}`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (input.action === 'keep') {
|
|
57
|
+
return JSON.stringify({
|
|
58
|
+
success: true,
|
|
59
|
+
action: 'keep',
|
|
60
|
+
path: worktreePath,
|
|
61
|
+
message: `Worktree at ${worktreePath} kept on disk. Branch preserved.`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// action === 'remove'
|
|
66
|
+
try {
|
|
67
|
+
// Check for uncommitted changes
|
|
68
|
+
if (!input.discard_changes) {
|
|
69
|
+
try {
|
|
70
|
+
const status = execSync('git status --porcelain', {
|
|
71
|
+
cwd: worktreePath,
|
|
72
|
+
encoding: 'utf8',
|
|
73
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
74
|
+
}).trim();
|
|
75
|
+
|
|
76
|
+
if (status) {
|
|
77
|
+
return JSON.stringify({
|
|
78
|
+
error: 'Worktree has uncommitted changes. Set discard_changes=true to force remove.',
|
|
79
|
+
uncommittedFiles: status.split('\n').slice(0, 10),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// If we can't check status, proceed with caution
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Get branch name before removal
|
|
88
|
+
let branchName = null;
|
|
89
|
+
try {
|
|
90
|
+
branchName = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
91
|
+
cwd: worktreePath,
|
|
92
|
+
encoding: 'utf8',
|
|
93
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
94
|
+
}).trim();
|
|
95
|
+
} catch {
|
|
96
|
+
// ignore
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Remove worktree
|
|
100
|
+
const forceFlag = input.discard_changes ? ' --force' : '';
|
|
101
|
+
execSync(`git worktree remove "${worktreePath}"${forceFlag}`, {
|
|
102
|
+
cwd: mainCwd,
|
|
103
|
+
stdio: 'pipe',
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// Remove the branch if it was a yeaft worktree branch
|
|
107
|
+
if (branchName && branchName.startsWith('yeaft-wt/')) {
|
|
108
|
+
try {
|
|
109
|
+
execSync(`git branch -D "${branchName}"`, {
|
|
110
|
+
cwd: mainCwd,
|
|
111
|
+
stdio: 'pipe',
|
|
112
|
+
});
|
|
113
|
+
} catch {
|
|
114
|
+
// Branch might already be deleted or not exist
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return JSON.stringify({
|
|
119
|
+
success: true,
|
|
120
|
+
action: 'remove',
|
|
121
|
+
path: worktreePath,
|
|
122
|
+
branch: branchName,
|
|
123
|
+
message: `Worktree removed: ${worktreePath}${branchName ? `, branch ${branchName} deleted` : ''}`,
|
|
124
|
+
});
|
|
125
|
+
} catch (err) {
|
|
126
|
+
return JSON.stringify({
|
|
127
|
+
error: `Failed to remove worktree: ${err.message}`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
});
|