yapout 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-DLHFRTYU.js +102 -0
- package/dist/index.js +4029 -0
- package/dist/worktree-ZZZIL7TK.js +15 -0
- package/package.json +37 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4029 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
createWorktree,
|
|
4
|
+
getWorktreesDir,
|
|
5
|
+
listWorktrees,
|
|
6
|
+
removeWorktree
|
|
7
|
+
} from "./chunk-DLHFRTYU.js";
|
|
8
|
+
|
|
9
|
+
// src/index.ts
|
|
10
|
+
import { Command as Command16 } from "commander";
|
|
11
|
+
|
|
12
|
+
// src/commands/login.ts
|
|
13
|
+
import { Command } from "commander";
|
|
14
|
+
import http from "http";
|
|
15
|
+
import chalk from "chalk";
|
|
16
|
+
import open from "open";
|
|
17
|
+
|
|
18
|
+
// src/lib/config.ts
|
|
19
|
+
import { homedir } from "os";
|
|
20
|
+
import { join } from "path";
|
|
21
|
+
import {
|
|
22
|
+
existsSync,
|
|
23
|
+
mkdirSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
writeFileSync,
|
|
26
|
+
unlinkSync
|
|
27
|
+
} from "fs";
|
|
28
|
+
import { parse as yamlParse } from "yaml";
|
|
29
|
+
var YAPOUT_DIR = join(homedir(), ".yapout");
|
|
30
|
+
function ensureYapoutDir() {
|
|
31
|
+
if (!existsSync(YAPOUT_DIR)) {
|
|
32
|
+
mkdirSync(YAPOUT_DIR, { recursive: true });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function getYapoutDir() {
|
|
36
|
+
return YAPOUT_DIR;
|
|
37
|
+
}
|
|
38
|
+
var CREDENTIALS_PATH = join(YAPOUT_DIR, "credentials.json");
|
|
39
|
+
function readCredentials() {
|
|
40
|
+
if (!existsSync(CREDENTIALS_PATH)) return null;
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(readFileSync(CREDENTIALS_PATH, "utf-8"));
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function writeCredentials(creds) {
|
|
48
|
+
ensureYapoutDir();
|
|
49
|
+
writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), {
|
|
50
|
+
mode: 384
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function deleteCredentials() {
|
|
54
|
+
if (existsSync(CREDENTIALS_PATH)) {
|
|
55
|
+
unlinkSync(CREDENTIALS_PATH);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
var PROJECTS_PATH = join(YAPOUT_DIR, "projects.json");
|
|
59
|
+
function readProjectMappings() {
|
|
60
|
+
if (!existsSync(PROJECTS_PATH)) return {};
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(readFileSync(PROJECTS_PATH, "utf-8"));
|
|
63
|
+
} catch {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function writeProjectMappings(mappings) {
|
|
68
|
+
ensureYapoutDir();
|
|
69
|
+
writeFileSync(PROJECTS_PATH, JSON.stringify(mappings, null, 2));
|
|
70
|
+
}
|
|
71
|
+
function getProjectMapping(dir) {
|
|
72
|
+
const mappings = readProjectMappings();
|
|
73
|
+
return mappings[dir] || null;
|
|
74
|
+
}
|
|
75
|
+
function setProjectMapping(dir, mapping) {
|
|
76
|
+
const mappings = readProjectMappings();
|
|
77
|
+
mappings[dir] = mapping;
|
|
78
|
+
writeProjectMappings(mappings);
|
|
79
|
+
}
|
|
80
|
+
function removeProjectMapping(dir) {
|
|
81
|
+
const mappings = readProjectMappings();
|
|
82
|
+
delete mappings[dir];
|
|
83
|
+
writeProjectMappings(mappings);
|
|
84
|
+
}
|
|
85
|
+
var WATCH_DEFAULTS = {
|
|
86
|
+
auto_enrich: true,
|
|
87
|
+
auto_implement: true,
|
|
88
|
+
auto_retry: false
|
|
89
|
+
};
|
|
90
|
+
var CONFIG_DEFAULTS = {
|
|
91
|
+
post_flight: [],
|
|
92
|
+
ship_requires_checks: false,
|
|
93
|
+
branch_prefix: "feat",
|
|
94
|
+
watch: { ...WATCH_DEFAULTS }
|
|
95
|
+
};
|
|
96
|
+
function readYapoutConfig(cwd) {
|
|
97
|
+
const configPath = join(cwd, ".yapout", "config.yml");
|
|
98
|
+
if (!existsSync(configPath)) return { ...CONFIG_DEFAULTS };
|
|
99
|
+
try {
|
|
100
|
+
const raw = yamlParse(readFileSync(configPath, "utf-8"));
|
|
101
|
+
if (!raw) return { ...CONFIG_DEFAULTS };
|
|
102
|
+
const watchRaw = raw.watch ?? {};
|
|
103
|
+
return {
|
|
104
|
+
post_flight: Array.isArray(raw.post_flight) ? raw.post_flight : [],
|
|
105
|
+
ship_requires_checks: raw.ship_requires_checks === true,
|
|
106
|
+
branch_prefix: typeof raw.branch_prefix === "string" ? raw.branch_prefix : "feat",
|
|
107
|
+
commit_template: typeof raw.commit_template === "string" ? raw.commit_template : void 0,
|
|
108
|
+
watch: {
|
|
109
|
+
auto_enrich: watchRaw.auto_enrich !== false,
|
|
110
|
+
auto_implement: watchRaw.auto_implement !== false,
|
|
111
|
+
auto_retry: watchRaw.auto_retry === true
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
} catch {
|
|
115
|
+
return { ...CONFIG_DEFAULTS };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function getMaxAgents() {
|
|
119
|
+
const envVal = process.env.YAPOUT_MAX_AGENTS;
|
|
120
|
+
if (envVal) {
|
|
121
|
+
const parsed = parseInt(envVal, 10);
|
|
122
|
+
if (!isNaN(parsed) && parsed > 0) return parsed;
|
|
123
|
+
}
|
|
124
|
+
return 2;
|
|
125
|
+
}
|
|
126
|
+
function readDotEnvValue(dir, key) {
|
|
127
|
+
for (const file of [".env.local", ".env"]) {
|
|
128
|
+
const filePath = join(dir, file);
|
|
129
|
+
if (!existsSync(filePath)) continue;
|
|
130
|
+
try {
|
|
131
|
+
const content = readFileSync(filePath, "utf-8");
|
|
132
|
+
for (const line of content.split("\n")) {
|
|
133
|
+
const trimmed = line.trim();
|
|
134
|
+
if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
|
|
135
|
+
const eqIdx = trimmed.indexOf("=");
|
|
136
|
+
const k = trimmed.slice(0, eqIdx).trim();
|
|
137
|
+
if (k === key) {
|
|
138
|
+
let v = trimmed.slice(eqIdx + 1).trim();
|
|
139
|
+
if (v.startsWith('"') && v.endsWith('"') || v.startsWith("'") && v.endsWith("'")) {
|
|
140
|
+
v = v.slice(1, -1);
|
|
141
|
+
}
|
|
142
|
+
return v || null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
function getConvexUrl() {
|
|
152
|
+
const fromEnv = process.env.CONVEX_URL;
|
|
153
|
+
if (fromEnv) return fromEnv;
|
|
154
|
+
const creds = readCredentials();
|
|
155
|
+
if (creds?.convexUrl) return creds.convexUrl;
|
|
156
|
+
if (process.env.NEXT_PUBLIC_CONVEX_URL) return process.env.NEXT_PUBLIC_CONVEX_URL;
|
|
157
|
+
const cwd = process.cwd();
|
|
158
|
+
const fromDotEnv = readDotEnvValue(cwd, "NEXT_PUBLIC_CONVEX_URL");
|
|
159
|
+
if (fromDotEnv) return fromDotEnv;
|
|
160
|
+
throw new Error(
|
|
161
|
+
"Could not find Convex URL. Run `yapout login` to authenticate, or set CONVEX_URL."
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
function getAppUrl() {
|
|
165
|
+
return process.env.YAPOUT_APP_URL || "https://yapout.vercel.app";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// src/lib/protocol.ts
|
|
169
|
+
import { execSync, spawnSync } from "child_process";
|
|
170
|
+
import { platform, homedir as homedir2 } from "os";
|
|
171
|
+
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2 } from "fs";
|
|
172
|
+
import { join as join2, dirname } from "path";
|
|
173
|
+
function getYapoutBinPath() {
|
|
174
|
+
const os = platform();
|
|
175
|
+
try {
|
|
176
|
+
if (os === "win32") {
|
|
177
|
+
const result = spawnSync("where", ["yapout"], { encoding: "utf-8" });
|
|
178
|
+
return result.stdout.trim().split("\n")[0].trim();
|
|
179
|
+
} else {
|
|
180
|
+
const result = spawnSync("which", ["yapout"], { encoding: "utf-8" });
|
|
181
|
+
return result.stdout.trim();
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
throw new Error(
|
|
185
|
+
"Could not find `yapout` on PATH. Make sure @yapout/cli is installed globally."
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function registerWindows(yapoutPath) {
|
|
190
|
+
const key = "HKCU\\Software\\Classes\\yapout";
|
|
191
|
+
const handler = `"${yapoutPath}" handle-uri "%1"`;
|
|
192
|
+
const commands = [
|
|
193
|
+
`reg add "${key}" /ve /d "URL:yapout Protocol" /f`,
|
|
194
|
+
`reg add "${key}" /v "URL Protocol" /d "" /f`,
|
|
195
|
+
`reg add "${key}\\shell\\open\\command" /ve /d "${handler}" /f`
|
|
196
|
+
];
|
|
197
|
+
for (const cmd of commands) {
|
|
198
|
+
execSync(cmd, { stdio: "pipe" });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function registerMacOS(_yapoutPath) {
|
|
202
|
+
const appPath = join2(homedir2(), "Applications", "YapoutHandler.app");
|
|
203
|
+
const yapoutDir = join2(homedir2(), ".yapout");
|
|
204
|
+
const handlerPy = join2(yapoutDir, "handle-url.py");
|
|
205
|
+
const tmpScript = join2(yapoutDir, ".tmp-handler.applescript");
|
|
206
|
+
mkdirSync2(yapoutDir, { recursive: true });
|
|
207
|
+
const pythonHandler = `#!/usr/bin/env python3
|
|
208
|
+
"""yapout:// URL handler \u2014 called by YapoutHandler.app"""
|
|
209
|
+
import json, os, shlex, sys
|
|
210
|
+
from urllib.parse import urlparse, parse_qs
|
|
211
|
+
|
|
212
|
+
def main():
|
|
213
|
+
if len(sys.argv) < 2:
|
|
214
|
+
sys.exit(0)
|
|
215
|
+
|
|
216
|
+
uri = sys.argv[1]
|
|
217
|
+
r = urlparse(uri)
|
|
218
|
+
action = r.hostname or ""
|
|
219
|
+
ticket_id = r.path.strip("/")
|
|
220
|
+
qs = parse_qs(r.query)
|
|
221
|
+
topic = qs.get("topic", [""])[0]
|
|
222
|
+
persona = qs.get("persona", [""])[0]
|
|
223
|
+
|
|
224
|
+
# Read project directory from ~/.yapout/projects.json
|
|
225
|
+
projects_path = os.path.expanduser("~/.yapout/projects.json")
|
|
226
|
+
try:
|
|
227
|
+
with open(projects_path) as f:
|
|
228
|
+
projects = json.load(f)
|
|
229
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
230
|
+
print("echo 'No yapout project linked. Run yapout init first.'", end="")
|
|
231
|
+
sys.exit(1)
|
|
232
|
+
|
|
233
|
+
dirs = sorted(projects.keys(), key=lambda k: projects[k].get("linkedAt", 0), reverse=True)
|
|
234
|
+
if not dirs:
|
|
235
|
+
print("echo 'No yapout project linked. Run yapout init first.'", end="")
|
|
236
|
+
sys.exit(1)
|
|
237
|
+
project_dir = dirs[0]
|
|
238
|
+
|
|
239
|
+
# Build claude prompt based on action
|
|
240
|
+
if action == "claim":
|
|
241
|
+
prompt = (
|
|
242
|
+
f'Use yapout to implement ticket "{ticket_id}". '
|
|
243
|
+
f'Claim it with yapout_claim (use worktree mode), read the brief, '
|
|
244
|
+
f'implement the changes, run yapout_check, then ship with yapout_ship.'
|
|
245
|
+
)
|
|
246
|
+
elif action == "enrich":
|
|
247
|
+
prompt = (
|
|
248
|
+
f'A ticket has been approved and needs enrichment before implementation. '
|
|
249
|
+
f'Call yapout_get_unenriched_ticket with ticketId for "{ticket_id}" to fetch the ticket details.\\n\\n'
|
|
250
|
+
f'Read the codebase to understand the project structure, then produce:\\n'
|
|
251
|
+
f'1. An implementation brief (3-5 sentences, specific files/patterns involved)\\n'
|
|
252
|
+
f'2. An enriched description with technical context\\n'
|
|
253
|
+
f"3. Clarifying questions (0-5) where the answer can't be inferred\\n"
|
|
254
|
+
f'4. Duplicate check \u2014 call yapout_get_existing_tickets and compare\\n'
|
|
255
|
+
f'5. Scope assessment (is this too large for one PR?)\\n\\n'
|
|
256
|
+
f'Call yapout_save_enrichment with your analysis. '
|
|
257
|
+
f'If no questions were generated, also call yapout_sync_to_linear to create the Linear ticket.'
|
|
258
|
+
)
|
|
259
|
+
elif action == "yap":
|
|
260
|
+
prompt = "Let's have a yap session"
|
|
261
|
+
if topic:
|
|
262
|
+
prompt += f" about {topic}"
|
|
263
|
+
if persona and persona != "tech lead":
|
|
264
|
+
prompt += f" \u2014 be a {persona}"
|
|
265
|
+
elif action == "compact":
|
|
266
|
+
prompt = "Run yapout_compact to update the project context summary."
|
|
267
|
+
else:
|
|
268
|
+
sys.exit(0)
|
|
269
|
+
|
|
270
|
+
# Write a .command file and open it \u2014 Terminal handles .command files natively,
|
|
271
|
+
# no Apple Events / automation permissions required.
|
|
272
|
+
import subprocess, tempfile, stat
|
|
273
|
+
cmd_file = os.path.expanduser("~/.yapout/.run.command")
|
|
274
|
+
with open(cmd_file, "w") as f:
|
|
275
|
+
f.write("#!/bin/bash\\n")
|
|
276
|
+
f.write(f"cd {shlex.quote(project_dir)}\\n")
|
|
277
|
+
f.write(f"clear\\n")
|
|
278
|
+
f.write(f"claude --dangerously-skip-permissions {shlex.quote(prompt)}\\n")
|
|
279
|
+
os.chmod(cmd_file, stat.S_IRWXU)
|
|
280
|
+
subprocess.run(["open", cmd_file])
|
|
281
|
+
|
|
282
|
+
if __name__ == "__main__":
|
|
283
|
+
main()
|
|
284
|
+
`;
|
|
285
|
+
writeFileSync2(handlerPy, pythonHandler, { mode: 493 });
|
|
286
|
+
const escapedHandlerPy = handlerPy.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
287
|
+
const scriptContent = `on open location theURL
|
|
288
|
+
do shell script "/usr/bin/python3 " & quoted form of "${escapedHandlerPy}" & " " & quoted form of theURL
|
|
289
|
+
quit
|
|
290
|
+
end open location
|
|
291
|
+
|
|
292
|
+
on idle
|
|
293
|
+
quit
|
|
294
|
+
return 5
|
|
295
|
+
end idle`;
|
|
296
|
+
writeFileSync2(tmpScript, scriptContent);
|
|
297
|
+
try {
|
|
298
|
+
execSync(`rm -rf "${appPath}"`, { stdio: "pipe" });
|
|
299
|
+
} catch {
|
|
300
|
+
}
|
|
301
|
+
mkdirSync2(dirname(appPath), { recursive: true });
|
|
302
|
+
execSync(`osacompile -s -o "${appPath}" "${tmpScript}"`, { stdio: "pipe" });
|
|
303
|
+
try {
|
|
304
|
+
unlinkSync2(tmpScript);
|
|
305
|
+
} catch {
|
|
306
|
+
}
|
|
307
|
+
const plistPath = join2(appPath, "Contents", "Info.plist");
|
|
308
|
+
const plistCommands = [
|
|
309
|
+
"Add :CFBundleIdentifier string dev.yapout.handler",
|
|
310
|
+
"Add :CFBundleURLTypes array",
|
|
311
|
+
"Add :CFBundleURLTypes:0 dict",
|
|
312
|
+
'Add :CFBundleURLTypes:0:CFBundleURLName string "yapout Protocol"',
|
|
313
|
+
"Add :CFBundleURLTypes:0:CFBundleURLSchemes array",
|
|
314
|
+
"Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string yapout"
|
|
315
|
+
];
|
|
316
|
+
for (const cmd of plistCommands) {
|
|
317
|
+
try {
|
|
318
|
+
execSync(`/usr/libexec/PlistBuddy -c '${cmd}' "${plistPath}"`, { stdio: "pipe" });
|
|
319
|
+
} catch {
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
execSync(
|
|
324
|
+
`/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -R "${appPath}"`,
|
|
325
|
+
{ stdio: "pipe" }
|
|
326
|
+
);
|
|
327
|
+
} catch {
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function registerLinux(yapoutPath) {
|
|
331
|
+
const appsDir = join2(homedir2(), ".local", "share", "applications");
|
|
332
|
+
mkdirSync2(appsDir, { recursive: true });
|
|
333
|
+
const desktop = `[Desktop Entry]
|
|
334
|
+
Name=yapout Handler
|
|
335
|
+
Comment=Handle yapout:// URIs
|
|
336
|
+
Exec="${yapoutPath}" handle-uri %u
|
|
337
|
+
Type=Application
|
|
338
|
+
NoDisplay=true
|
|
339
|
+
MimeType=x-scheme-handler/yapout;
|
|
340
|
+
`;
|
|
341
|
+
writeFileSync2(join2(appsDir, "yapout-handler.desktop"), desktop);
|
|
342
|
+
try {
|
|
343
|
+
execSync(
|
|
344
|
+
`xdg-mime default yapout-handler.desktop x-scheme-handler/yapout`,
|
|
345
|
+
{ stdio: "pipe" }
|
|
346
|
+
);
|
|
347
|
+
} catch {
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function registerProtocolHandler() {
|
|
351
|
+
const os = platform();
|
|
352
|
+
const yapoutPath = getYapoutBinPath();
|
|
353
|
+
if (os === "win32") {
|
|
354
|
+
registerWindows(yapoutPath);
|
|
355
|
+
} else if (os === "darwin") {
|
|
356
|
+
registerMacOS(yapoutPath);
|
|
357
|
+
} else {
|
|
358
|
+
registerLinux(yapoutPath);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// src/commands/login.ts
|
|
363
|
+
function startCallbackServer() {
|
|
364
|
+
return new Promise((resolve11) => {
|
|
365
|
+
let resolveData;
|
|
366
|
+
let rejectData;
|
|
367
|
+
const dataPromise = new Promise((res, rej) => {
|
|
368
|
+
resolveData = res;
|
|
369
|
+
rejectData = rej;
|
|
370
|
+
});
|
|
371
|
+
const server = http.createServer((req, res) => {
|
|
372
|
+
const url = new URL(req.url, `http://localhost`);
|
|
373
|
+
if (url.pathname === "/callback") {
|
|
374
|
+
const token = url.searchParams.get("token");
|
|
375
|
+
const email = url.searchParams.get("email");
|
|
376
|
+
const expiresAt = url.searchParams.get("expiresAt");
|
|
377
|
+
const convexUrl = url.searchParams.get("convexUrl");
|
|
378
|
+
const error = url.searchParams.get("error");
|
|
379
|
+
if (error || !token) {
|
|
380
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
381
|
+
res.end(
|
|
382
|
+
`<html><body style="font-family:sans-serif;text-align:center;padding:2rem"><h2>Authentication failed</h2><p>${error || "No token received."}</p><p>You can close this tab.</p></body></html>`
|
|
383
|
+
);
|
|
384
|
+
server.close();
|
|
385
|
+
rejectData(new Error(error || "No token received"));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
389
|
+
res.end(
|
|
390
|
+
`<html><body style="font-family:sans-serif;text-align:center;padding:2rem"><h2>Authenticated!</h2><p>You can close this tab and return to the terminal.</p></body></html>`
|
|
391
|
+
);
|
|
392
|
+
server.close();
|
|
393
|
+
resolveData({
|
|
394
|
+
token,
|
|
395
|
+
email: email || "",
|
|
396
|
+
expiresAt: Number(expiresAt) || Date.now() + 36e5,
|
|
397
|
+
convexUrl: convexUrl || ""
|
|
398
|
+
});
|
|
399
|
+
} else {
|
|
400
|
+
res.writeHead(404);
|
|
401
|
+
res.end();
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
server.listen(0, () => {
|
|
405
|
+
const address = server.address();
|
|
406
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
407
|
+
resolve11({ port, data: dataPromise });
|
|
408
|
+
});
|
|
409
|
+
setTimeout(() => {
|
|
410
|
+
server.close();
|
|
411
|
+
rejectData(
|
|
412
|
+
new Error("Authentication timed out. Please try again.")
|
|
413
|
+
);
|
|
414
|
+
}, 12e4);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
var loginCommand = new Command("login").description("Authenticate with yapout").action(async () => {
|
|
418
|
+
console.log(chalk.dim("Starting authentication..."));
|
|
419
|
+
const { port, data } = await startCallbackServer();
|
|
420
|
+
const appUrl = getAppUrl();
|
|
421
|
+
const authUrl = `${appUrl}/auth/cli?port=${port}`;
|
|
422
|
+
console.log(chalk.dim(`Opening browser...`));
|
|
423
|
+
await open(authUrl);
|
|
424
|
+
console.log(chalk.dim("Waiting for authentication in browser..."));
|
|
425
|
+
try {
|
|
426
|
+
const result = await data;
|
|
427
|
+
const creds = {
|
|
428
|
+
token: result.token,
|
|
429
|
+
email: result.email,
|
|
430
|
+
expiresAt: result.expiresAt,
|
|
431
|
+
convexUrl: result.convexUrl || void 0
|
|
432
|
+
};
|
|
433
|
+
writeCredentials(creds);
|
|
434
|
+
const daysLeft = Math.max(
|
|
435
|
+
1,
|
|
436
|
+
Math.round((result.expiresAt - Date.now()) / 864e5)
|
|
437
|
+
);
|
|
438
|
+
console.log(
|
|
439
|
+
chalk.green("Logged in as ") + chalk.bold(result.email) + chalk.dim(
|
|
440
|
+
` (expires in ${daysLeft} day${daysLeft === 1 ? "" : "s"})`
|
|
441
|
+
)
|
|
442
|
+
);
|
|
443
|
+
try {
|
|
444
|
+
registerProtocolHandler();
|
|
445
|
+
console.log(
|
|
446
|
+
chalk.dim("Registered yapout:// protocol handler")
|
|
447
|
+
);
|
|
448
|
+
} catch {
|
|
449
|
+
}
|
|
450
|
+
process.exit(0);
|
|
451
|
+
} catch (err) {
|
|
452
|
+
console.error(chalk.red(err.message));
|
|
453
|
+
process.exit(1);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// src/commands/logout.ts
|
|
458
|
+
import { Command as Command2 } from "commander";
|
|
459
|
+
import chalk2 from "chalk";
|
|
460
|
+
var logoutCommand = new Command2("logout").description("Log out of yapout").action(() => {
|
|
461
|
+
deleteCredentials();
|
|
462
|
+
console.log(chalk2.green("Logged out."));
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// src/commands/link.ts
|
|
466
|
+
import { Command as Command3 } from "commander";
|
|
467
|
+
import { resolve, join as join3 } from "path";
|
|
468
|
+
import {
|
|
469
|
+
existsSync as existsSync2,
|
|
470
|
+
mkdirSync as mkdirSync3,
|
|
471
|
+
readFileSync as readFileSync2,
|
|
472
|
+
writeFileSync as writeFileSync3,
|
|
473
|
+
appendFileSync
|
|
474
|
+
} from "fs";
|
|
475
|
+
import chalk4 from "chalk";
|
|
476
|
+
|
|
477
|
+
// src/lib/auth.ts
|
|
478
|
+
import chalk3 from "chalk";
|
|
479
|
+
function requireAuth() {
|
|
480
|
+
const creds = readCredentials();
|
|
481
|
+
if (!creds) {
|
|
482
|
+
console.error(
|
|
483
|
+
chalk3.red("Not logged in.") + " Run " + chalk3.cyan("yapout login") + " first."
|
|
484
|
+
);
|
|
485
|
+
process.exit(1);
|
|
486
|
+
}
|
|
487
|
+
if (Date.now() > creds.expiresAt) {
|
|
488
|
+
console.error(
|
|
489
|
+
chalk3.red("Session expired.") + " Run " + chalk3.cyan("yapout login") + " to re-authenticate."
|
|
490
|
+
);
|
|
491
|
+
process.exit(1);
|
|
492
|
+
}
|
|
493
|
+
return creds;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// src/lib/convex.ts
|
|
497
|
+
import { ConvexHttpClient } from "convex/browser";
|
|
498
|
+
import { anyApi } from "convex/server";
|
|
499
|
+
function createConvexClient(token) {
|
|
500
|
+
const url = getConvexUrl();
|
|
501
|
+
const client = new ConvexHttpClient(url);
|
|
502
|
+
client.setAuth(token);
|
|
503
|
+
return client;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// src/lib/prompts.ts
|
|
507
|
+
import { select } from "@inquirer/prompts";
|
|
508
|
+
async function pickProject(projects) {
|
|
509
|
+
return await select({
|
|
510
|
+
message: "Select a project to link to this directory:",
|
|
511
|
+
choices: projects.map((p) => ({
|
|
512
|
+
name: p.githubRepoFullName ? `${p.name} (${p.githubRepoFullName})` : p.name,
|
|
513
|
+
value: p
|
|
514
|
+
}))
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// src/commands/link.ts
|
|
519
|
+
var CONFIG_YAML_CONTENT = `# yapout local configuration
|
|
520
|
+
# See: https://docs.yapout.dev/cli/config
|
|
521
|
+
|
|
522
|
+
# Commands to run after implementation, before shipping.
|
|
523
|
+
# Each runs sequentially. If any fails, yapout_check reports failure.
|
|
524
|
+
# Examples:
|
|
525
|
+
# - npm run lint
|
|
526
|
+
# - npm run typecheck
|
|
527
|
+
# - npm run test
|
|
528
|
+
# - sf project deploy --dry-run --target-org scratch
|
|
529
|
+
post_flight: []
|
|
530
|
+
|
|
531
|
+
# Require post_flight checks to pass before yapout_ship succeeds.
|
|
532
|
+
# If true, yapout_ship refuses to run unless yapout_check passed.
|
|
533
|
+
ship_requires_checks: false
|
|
534
|
+
|
|
535
|
+
# Git branch prefix (used by yapout_claim)
|
|
536
|
+
branch_prefix: feat
|
|
537
|
+
|
|
538
|
+
# Commit message template (optional \u2014 uses sensible default if omitted)
|
|
539
|
+
# Available variables: {{ticket.title}}, {{ticket.type}}, {{ticket.priority}},
|
|
540
|
+
# {{ticket.linearTicketId}}, {{ticket.id}}
|
|
541
|
+
# commit_template: "{{ticket.type}}({{ticket.linearTicketId}}): {{ticket.title}}"
|
|
542
|
+
`;
|
|
543
|
+
var linkCommand = new Command3("link").description("Link the current directory to a yapout project").action(async () => {
|
|
544
|
+
const creds = requireAuth();
|
|
545
|
+
const cwd = resolve(process.cwd());
|
|
546
|
+
const client = createConvexClient(creds.token);
|
|
547
|
+
let projects;
|
|
548
|
+
try {
|
|
549
|
+
projects = await client.query(
|
|
550
|
+
anyApi.functions.projects.getProjectsForCli,
|
|
551
|
+
{}
|
|
552
|
+
);
|
|
553
|
+
} catch (err) {
|
|
554
|
+
console.error(
|
|
555
|
+
chalk4.red("Failed to fetch projects."),
|
|
556
|
+
err.message
|
|
557
|
+
);
|
|
558
|
+
process.exit(1);
|
|
559
|
+
}
|
|
560
|
+
if (projects.length === 0) {
|
|
561
|
+
console.error(
|
|
562
|
+
chalk4.yellow("No projects found.") + " Create one at " + chalk4.cyan(`${getAppUrl()}/dashboard`)
|
|
563
|
+
);
|
|
564
|
+
process.exit(1);
|
|
565
|
+
}
|
|
566
|
+
const selected = await pickProject(projects);
|
|
567
|
+
setProjectMapping(cwd, {
|
|
568
|
+
projectId: selected.id,
|
|
569
|
+
projectName: selected.name,
|
|
570
|
+
linkedAt: Date.now()
|
|
571
|
+
});
|
|
572
|
+
const yapoutDir = join3(cwd, ".yapout");
|
|
573
|
+
if (!existsSync2(yapoutDir)) {
|
|
574
|
+
mkdirSync3(yapoutDir, { recursive: true });
|
|
575
|
+
}
|
|
576
|
+
const configPath = join3(yapoutDir, "config.yml");
|
|
577
|
+
if (!existsSync2(configPath)) {
|
|
578
|
+
writeFileSync3(configPath, CONFIG_YAML_CONTENT);
|
|
579
|
+
}
|
|
580
|
+
const gitignorePath = join3(cwd, ".gitignore");
|
|
581
|
+
if (existsSync2(gitignorePath)) {
|
|
582
|
+
const content = readFileSync2(gitignorePath, "utf-8");
|
|
583
|
+
if (!content.includes(".yapout/")) {
|
|
584
|
+
appendFileSync(
|
|
585
|
+
gitignorePath,
|
|
586
|
+
"\n# yapout local config\n.yapout/\n"
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
} else {
|
|
590
|
+
writeFileSync3(gitignorePath, "# yapout local config\n.yapout/\n");
|
|
591
|
+
}
|
|
592
|
+
const mcpPath = join3(cwd, ".mcp.json");
|
|
593
|
+
let mcpConfig = {};
|
|
594
|
+
if (existsSync2(mcpPath)) {
|
|
595
|
+
try {
|
|
596
|
+
mcpConfig = JSON.parse(readFileSync2(mcpPath, "utf-8"));
|
|
597
|
+
} catch {
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {};
|
|
601
|
+
mcpConfig.mcpServers.yapout = {
|
|
602
|
+
command: "yapout",
|
|
603
|
+
args: ["mcp-server"]
|
|
604
|
+
};
|
|
605
|
+
writeFileSync3(mcpPath, JSON.stringify(mcpConfig, null, 2) + "\n");
|
|
606
|
+
const label = selected.githubRepoFullName ? `${selected.name} (${selected.githubRepoFullName})` : selected.name;
|
|
607
|
+
console.log(
|
|
608
|
+
chalk4.green(`Linked to ${label}.`) + " Claude Code will discover yapout tools automatically."
|
|
609
|
+
);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
// src/commands/unlink.ts
|
|
613
|
+
import { Command as Command4 } from "commander";
|
|
614
|
+
import { resolve as resolve2, join as join4 } from "path";
|
|
615
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync4, rmSync } from "fs";
|
|
616
|
+
import chalk5 from "chalk";
|
|
617
|
+
var unlinkCommand = new Command4("unlink").description("Unlink the current directory from its yapout project").action(() => {
|
|
618
|
+
const cwd = resolve2(process.cwd());
|
|
619
|
+
const mapping = getProjectMapping(cwd);
|
|
620
|
+
if (!mapping) {
|
|
621
|
+
console.error(chalk5.yellow("No project linked to this directory."));
|
|
622
|
+
process.exit(1);
|
|
623
|
+
}
|
|
624
|
+
removeProjectMapping(cwd);
|
|
625
|
+
const yapoutDir = join4(cwd, ".yapout");
|
|
626
|
+
if (existsSync3(yapoutDir)) {
|
|
627
|
+
rmSync(yapoutDir, { recursive: true });
|
|
628
|
+
}
|
|
629
|
+
const mcpPath = join4(cwd, ".mcp.json");
|
|
630
|
+
if (existsSync3(mcpPath)) {
|
|
631
|
+
try {
|
|
632
|
+
const mcpConfig = JSON.parse(readFileSync3(mcpPath, "utf-8"));
|
|
633
|
+
if (mcpConfig.mcpServers?.yapout) {
|
|
634
|
+
delete mcpConfig.mcpServers.yapout;
|
|
635
|
+
if (Object.keys(mcpConfig.mcpServers).length === 0) {
|
|
636
|
+
rmSync(mcpPath);
|
|
637
|
+
} else {
|
|
638
|
+
writeFileSync4(
|
|
639
|
+
mcpPath,
|
|
640
|
+
JSON.stringify(mcpConfig, null, 2) + "\n"
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
} catch {
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
console.log(chalk5.green("Unlinked."));
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// src/commands/status.ts
|
|
651
|
+
import { Command as Command5 } from "commander";
|
|
652
|
+
import { resolve as resolve3 } from "path";
|
|
653
|
+
import chalk6 from "chalk";
|
|
654
|
+
var statusCommand = new Command5("status").description("Show yapout status for this directory").action(() => {
|
|
655
|
+
console.log(chalk6.bold("yapout status\n"));
|
|
656
|
+
const creds = readCredentials();
|
|
657
|
+
if (!creds) {
|
|
658
|
+
console.log(
|
|
659
|
+
` Auth: ${chalk6.red("Not logged in.")} Run ${chalk6.cyan("yapout login")}.`
|
|
660
|
+
);
|
|
661
|
+
} else if (Date.now() > creds.expiresAt) {
|
|
662
|
+
console.log(
|
|
663
|
+
` Auth: ${chalk6.red("Session expired.")} Run ${chalk6.cyan("yapout login")}.`
|
|
664
|
+
);
|
|
665
|
+
} else {
|
|
666
|
+
const daysLeft = Math.max(
|
|
667
|
+
1,
|
|
668
|
+
Math.round((creds.expiresAt - Date.now()) / 864e5)
|
|
669
|
+
);
|
|
670
|
+
console.log(
|
|
671
|
+
` Auth: ${chalk6.green(creds.email)} (expires in ${daysLeft} day${daysLeft === 1 ? "" : "s"})`
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
const cwd = resolve3(process.cwd());
|
|
675
|
+
const mapping = getProjectMapping(cwd);
|
|
676
|
+
if (!mapping) {
|
|
677
|
+
console.log(
|
|
678
|
+
` Project: ${chalk6.yellow("No project linked.")} Run ${chalk6.cyan("yapout link")} in a repo.`
|
|
679
|
+
);
|
|
680
|
+
} else {
|
|
681
|
+
console.log(` Project: ${chalk6.green(mapping.projectName)}`);
|
|
682
|
+
}
|
|
683
|
+
console.log(` Daemon: ${chalk6.dim("not running")}`);
|
|
684
|
+
console.log(` Work: ${chalk6.dim("no active tickets")}`);
|
|
685
|
+
console.log();
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// src/commands/init.ts
|
|
689
|
+
import { Command as Command6 } from "commander";
|
|
690
|
+
import { resolve as resolve4, join as join5 } from "path";
|
|
691
|
+
import {
|
|
692
|
+
existsSync as existsSync4,
|
|
693
|
+
mkdirSync as mkdirSync4,
|
|
694
|
+
writeFileSync as writeFileSync5,
|
|
695
|
+
readFileSync as readFileSync4,
|
|
696
|
+
appendFileSync as appendFileSync2
|
|
697
|
+
} from "fs";
|
|
698
|
+
import chalk7 from "chalk";
|
|
699
|
+
|
|
700
|
+
// src/lib/git.ts
|
|
701
|
+
import { execSync as execSync2 } from "child_process";
|
|
702
|
+
function git(args, cwd) {
|
|
703
|
+
return execSync2(`git ${args}`, { cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
704
|
+
}
|
|
705
|
+
function getRepoFullName(cwd) {
|
|
706
|
+
const url = git("remote get-url origin", cwd);
|
|
707
|
+
const sshMatch = url.match(/git@github\.com:(.+?)(?:\.git)?$/);
|
|
708
|
+
if (sshMatch) return sshMatch[1];
|
|
709
|
+
const httpsMatch = url.match(/github\.com\/(.+?)(?:\.git)?$/);
|
|
710
|
+
if (httpsMatch) return httpsMatch[1];
|
|
711
|
+
throw new Error(`Could not parse GitHub repo from remote URL: ${url}`);
|
|
712
|
+
}
|
|
713
|
+
function getDefaultBranch(cwd) {
|
|
714
|
+
try {
|
|
715
|
+
const ref = git("rev-parse --abbrev-ref origin/HEAD", cwd);
|
|
716
|
+
return ref.replace("origin/", "");
|
|
717
|
+
} catch {
|
|
718
|
+
try {
|
|
719
|
+
git("rev-parse --verify origin/main", cwd);
|
|
720
|
+
return "main";
|
|
721
|
+
} catch {
|
|
722
|
+
return "master";
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
function getCurrentBranch(cwd) {
|
|
727
|
+
return git("branch --show-current", cwd);
|
|
728
|
+
}
|
|
729
|
+
function fetchOrigin(cwd) {
|
|
730
|
+
git("fetch origin", cwd);
|
|
731
|
+
}
|
|
732
|
+
function checkoutNewBranch(name, base, cwd) {
|
|
733
|
+
git(`checkout -b ${name} origin/${base}`, cwd);
|
|
734
|
+
}
|
|
735
|
+
function stageAll(cwd) {
|
|
736
|
+
git("add -A", cwd);
|
|
737
|
+
try {
|
|
738
|
+
git("reset HEAD -- .yapout/", cwd);
|
|
739
|
+
} catch {
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
function commit(message, cwd) {
|
|
743
|
+
git(`commit -m "${message.replace(/"/g, '\\"')}"`, cwd);
|
|
744
|
+
return git("rev-parse HEAD", cwd);
|
|
745
|
+
}
|
|
746
|
+
function push(branch, cwd) {
|
|
747
|
+
git(`push -u origin ${branch}`, cwd);
|
|
748
|
+
}
|
|
749
|
+
function getDiffStats(base, head, cwd) {
|
|
750
|
+
try {
|
|
751
|
+
return git(`diff --stat origin/${base}...${head}`, cwd);
|
|
752
|
+
} catch {
|
|
753
|
+
return "(could not compute diff stats)";
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// src/commands/init.ts
|
|
758
|
+
var CONFIG_YAML_CONTENT2 = `# yapout local configuration
|
|
759
|
+
# See: https://docs.yapout.dev/cli/config
|
|
760
|
+
|
|
761
|
+
# Commands to run after implementation, before shipping.
|
|
762
|
+
# Each runs sequentially. If any fails, yapout_check reports failure.
|
|
763
|
+
# Examples:
|
|
764
|
+
# - npm run lint
|
|
765
|
+
# - npm run typecheck
|
|
766
|
+
# - npm run test
|
|
767
|
+
# - sf project deploy --dry-run --target-org scratch
|
|
768
|
+
post_flight: []
|
|
769
|
+
|
|
770
|
+
# Require post_flight checks to pass before yapout_ship succeeds.
|
|
771
|
+
# If true, yapout_ship refuses to run unless yapout_check passed.
|
|
772
|
+
ship_requires_checks: false
|
|
773
|
+
|
|
774
|
+
# Git branch prefix (used by yapout_claim)
|
|
775
|
+
branch_prefix: feat
|
|
776
|
+
|
|
777
|
+
# Commit message template (optional \u2014 uses sensible default if omitted)
|
|
778
|
+
# Available variables: {{ticket.title}}, {{ticket.type}}, {{ticket.priority}},
|
|
779
|
+
# {{ticket.linearTicketId}}, {{ticket.id}}
|
|
780
|
+
# commit_template: "{{ticket.type}}({{ticket.linearTicketId}}): {{ticket.title}}"
|
|
781
|
+
`;
|
|
782
|
+
var initCommand = new Command6("init").description("Create a yapout project from the current repo and link it").argument("[name]", "Project name (defaults to repo name)").action(async (name) => {
|
|
783
|
+
const creds = requireAuth();
|
|
784
|
+
const cwd = resolve4(process.cwd());
|
|
785
|
+
let repoFullName;
|
|
786
|
+
let defaultBranch;
|
|
787
|
+
try {
|
|
788
|
+
repoFullName = getRepoFullName(cwd);
|
|
789
|
+
defaultBranch = getDefaultBranch(cwd);
|
|
790
|
+
} catch (err) {
|
|
791
|
+
console.error(
|
|
792
|
+
chalk7.red("Not a git repo with a GitHub remote."),
|
|
793
|
+
err.message
|
|
794
|
+
);
|
|
795
|
+
process.exit(1);
|
|
796
|
+
}
|
|
797
|
+
const projectName = name || repoFullName.split("/")[1] || "unnamed";
|
|
798
|
+
const client = createConvexClient(creds.token);
|
|
799
|
+
let result;
|
|
800
|
+
try {
|
|
801
|
+
result = await client.mutation(
|
|
802
|
+
anyApi.functions.projects.createProjectFromCli,
|
|
803
|
+
{
|
|
804
|
+
name: projectName,
|
|
805
|
+
githubRepoFullName: repoFullName,
|
|
806
|
+
githubDefaultBranch: defaultBranch
|
|
807
|
+
}
|
|
808
|
+
);
|
|
809
|
+
} catch (err) {
|
|
810
|
+
console.error(
|
|
811
|
+
chalk7.red("Failed to create project."),
|
|
812
|
+
err.message
|
|
813
|
+
);
|
|
814
|
+
process.exit(1);
|
|
815
|
+
}
|
|
816
|
+
setProjectMapping(cwd, {
|
|
817
|
+
projectId: result.projectId,
|
|
818
|
+
projectName: result.projectName,
|
|
819
|
+
linkedAt: Date.now()
|
|
820
|
+
});
|
|
821
|
+
const yapoutDir = join5(cwd, ".yapout");
|
|
822
|
+
if (!existsSync4(yapoutDir)) mkdirSync4(yapoutDir, { recursive: true });
|
|
823
|
+
const configPath = join5(yapoutDir, "config.yml");
|
|
824
|
+
if (!existsSync4(configPath)) {
|
|
825
|
+
writeFileSync5(configPath, CONFIG_YAML_CONTENT2);
|
|
826
|
+
}
|
|
827
|
+
const gitignorePath = join5(cwd, ".gitignore");
|
|
828
|
+
if (existsSync4(gitignorePath)) {
|
|
829
|
+
const content = readFileSync4(gitignorePath, "utf-8");
|
|
830
|
+
if (!content.includes(".yapout/")) {
|
|
831
|
+
appendFileSync2(
|
|
832
|
+
gitignorePath,
|
|
833
|
+
"\n# yapout local config\n.yapout/\n"
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
const mcpPath = join5(cwd, ".mcp.json");
|
|
838
|
+
let mcpConfig = {};
|
|
839
|
+
if (existsSync4(mcpPath)) {
|
|
840
|
+
try {
|
|
841
|
+
mcpConfig = JSON.parse(readFileSync4(mcpPath, "utf-8"));
|
|
842
|
+
} catch {
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (!mcpConfig.mcpServers) mcpConfig.mcpServers = {};
|
|
846
|
+
mcpConfig.mcpServers.yapout = {
|
|
847
|
+
command: "yapout",
|
|
848
|
+
args: ["mcp-server"]
|
|
849
|
+
};
|
|
850
|
+
writeFileSync5(mcpPath, JSON.stringify(mcpConfig, null, 2) + "\n");
|
|
851
|
+
console.log(
|
|
852
|
+
chalk7.green(`Created project "${result.projectName}"`) + chalk7.dim(` (${repoFullName}, branch: ${defaultBranch})`)
|
|
853
|
+
);
|
|
854
|
+
console.log(
|
|
855
|
+
chalk7.dim("Run ") + chalk7.cyan("yapout_compact") + chalk7.dim(" in Claude Code to generate project context.")
|
|
856
|
+
);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
// src/commands/mcp-server.ts
|
|
860
|
+
import { Command as Command7 } from "commander";
|
|
861
|
+
|
|
862
|
+
// src/mcp/server.ts
|
|
863
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
864
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
865
|
+
import { ConvexHttpClient as ConvexHttpClient2 } from "convex/browser";
|
|
866
|
+
import { anyApi as anyApi2 } from "convex/server";
|
|
867
|
+
|
|
868
|
+
// src/mcp/tools/init.ts
|
|
869
|
+
import { z } from "zod";
|
|
870
|
+
import { join as join6 } from "path";
|
|
871
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync5, writeFileSync as writeFileSync6 } from "fs";
|
|
872
|
+
import { stringify as yamlStringify } from "yaml";
|
|
873
|
+
function registerInitTool(server, ctx) {
|
|
874
|
+
server.tool(
|
|
875
|
+
"yapout_init",
|
|
876
|
+
"Create a new yapout project from the current repo",
|
|
877
|
+
{
|
|
878
|
+
name: z.string().optional().describe("Project name (defaults to repo name)"),
|
|
879
|
+
linearTeamId: z.string().optional().describe("Linear team ID to associate")
|
|
880
|
+
},
|
|
881
|
+
async (args) => {
|
|
882
|
+
const repoFullName = getRepoFullName(ctx.cwd);
|
|
883
|
+
const defaultBranch = getDefaultBranch(ctx.cwd);
|
|
884
|
+
const projectName = args.name || repoFullName.split("/")[1] || "unnamed";
|
|
885
|
+
const result = await ctx.client.mutation(
|
|
886
|
+
anyApi2.functions.projects.createProjectFromCli,
|
|
887
|
+
{
|
|
888
|
+
name: projectName,
|
|
889
|
+
githubRepoFullName: repoFullName,
|
|
890
|
+
githubDefaultBranch: defaultBranch,
|
|
891
|
+
linearTeamId: args.linearTeamId
|
|
892
|
+
}
|
|
893
|
+
);
|
|
894
|
+
ctx.projectId = result.projectId;
|
|
895
|
+
ctx.projectName = result.projectName;
|
|
896
|
+
setProjectMapping(ctx.cwd, {
|
|
897
|
+
projectId: result.projectId,
|
|
898
|
+
projectName: result.projectName,
|
|
899
|
+
linkedAt: Date.now()
|
|
900
|
+
});
|
|
901
|
+
const yapoutDir = join6(ctx.cwd, ".yapout");
|
|
902
|
+
if (!existsSync5(yapoutDir)) mkdirSync5(yapoutDir, { recursive: true });
|
|
903
|
+
const configPath = join6(yapoutDir, "config.yml");
|
|
904
|
+
if (!existsSync5(configPath)) {
|
|
905
|
+
writeFileSync6(
|
|
906
|
+
configPath,
|
|
907
|
+
`# yapout local configuration
|
|
908
|
+
|
|
909
|
+
` + yamlStringify({ post_flight: [], branch_prefix: "feat" })
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
return {
|
|
913
|
+
content: [
|
|
914
|
+
{
|
|
915
|
+
type: "text",
|
|
916
|
+
text: JSON.stringify(
|
|
917
|
+
{
|
|
918
|
+
projectId: result.projectId,
|
|
919
|
+
projectName: result.projectName,
|
|
920
|
+
githubRepo: repoFullName,
|
|
921
|
+
message: "Project created. Run yapout_compact to generate context."
|
|
922
|
+
},
|
|
923
|
+
null,
|
|
924
|
+
2
|
|
925
|
+
)
|
|
926
|
+
}
|
|
927
|
+
]
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// src/mcp/tools/compact.ts
|
|
934
|
+
var COMPACT_INSTRUCTIONS = `Analyze this codebase and produce a project context summary with the following sections:
|
|
935
|
+
|
|
936
|
+
1. **Project Description** (2-3 sentences): What this project does, who it's for.
|
|
937
|
+
2. **Tech Stack**: Frameworks, languages, key dependencies with versions.
|
|
938
|
+
3. **Architecture Patterns**: How data flows, key abstractions, directory structure conventions.
|
|
939
|
+
4. **Key Conventions**: Naming patterns, state management approach, error handling patterns.
|
|
940
|
+
5. **Recent History**: Major recent changes visible in the code (not git log, just what you observe).
|
|
941
|
+
6. **Known Gaps**: TODOs, incomplete features, technical debt you notice.
|
|
942
|
+
|
|
943
|
+
Read the following to build your understanding:
|
|
944
|
+
- package.json (dependencies, scripts)
|
|
945
|
+
- README.md and any CLAUDE.md files
|
|
946
|
+
- Top-level directory structure (2 levels deep)
|
|
947
|
+
- Database schema or type definitions
|
|
948
|
+
- A few representative source files
|
|
949
|
+
|
|
950
|
+
Output a single markdown document. Be concise \u2014 this will be given to other Claude Code sessions as context for implementing tickets.
|
|
951
|
+
|
|
952
|
+
After you produce the summary, call yapout_update_context with the summary text to save it.`;
|
|
953
|
+
function registerCompactTool(server, ctx) {
|
|
954
|
+
server.tool(
|
|
955
|
+
"yapout_compact",
|
|
956
|
+
"Get instructions for generating a project context summary. After reading the codebase, call yapout_update_context with the result.",
|
|
957
|
+
{},
|
|
958
|
+
async () => {
|
|
959
|
+
if (!ctx.projectId) {
|
|
960
|
+
return {
|
|
961
|
+
content: [
|
|
962
|
+
{
|
|
963
|
+
type: "text",
|
|
964
|
+
text: "No project linked. Run yapout_init first."
|
|
965
|
+
}
|
|
966
|
+
],
|
|
967
|
+
isError: true
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
let currentContext;
|
|
971
|
+
let lastUpdated;
|
|
972
|
+
try {
|
|
973
|
+
const project = await ctx.client.query(
|
|
974
|
+
anyApi2.functions.projects.getProject,
|
|
975
|
+
{ projectId: ctx.projectId }
|
|
976
|
+
);
|
|
977
|
+
currentContext = project?.contextSummary ?? void 0;
|
|
978
|
+
lastUpdated = project?.contextUpdatedAt ?? void 0;
|
|
979
|
+
} catch {
|
|
980
|
+
}
|
|
981
|
+
const result = {
|
|
982
|
+
instructions: COMPACT_INSTRUCTIONS
|
|
983
|
+
};
|
|
984
|
+
if (currentContext) result.currentContext = currentContext;
|
|
985
|
+
if (lastUpdated) result.lastUpdated = lastUpdated;
|
|
986
|
+
return {
|
|
987
|
+
content: [
|
|
988
|
+
{ type: "text", text: JSON.stringify(result, null, 2) }
|
|
989
|
+
]
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
// src/mcp/tools/update-context.ts
|
|
996
|
+
import { z as z2 } from "zod";
|
|
997
|
+
function registerUpdateContextTool(server, ctx) {
|
|
998
|
+
server.tool(
|
|
999
|
+
"yapout_update_context",
|
|
1000
|
+
"Save a project context summary to yapout",
|
|
1001
|
+
{
|
|
1002
|
+
summary: z2.string().describe("The project context summary markdown")
|
|
1003
|
+
},
|
|
1004
|
+
async (args) => {
|
|
1005
|
+
if (!ctx.projectId) {
|
|
1006
|
+
return {
|
|
1007
|
+
content: [
|
|
1008
|
+
{
|
|
1009
|
+
type: "text",
|
|
1010
|
+
text: "No project linked. Run yapout_init first."
|
|
1011
|
+
}
|
|
1012
|
+
],
|
|
1013
|
+
isError: true
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
await ctx.client.mutation(
|
|
1017
|
+
anyApi2.functions.projects.updateProjectContext,
|
|
1018
|
+
{
|
|
1019
|
+
projectId: ctx.projectId,
|
|
1020
|
+
summary: args.summary
|
|
1021
|
+
}
|
|
1022
|
+
);
|
|
1023
|
+
return {
|
|
1024
|
+
content: [
|
|
1025
|
+
{
|
|
1026
|
+
type: "text",
|
|
1027
|
+
text: JSON.stringify({ success: true, message: "Project context updated." })
|
|
1028
|
+
}
|
|
1029
|
+
]
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// src/mcp/tools/queue.ts
|
|
1036
|
+
import { z as z3 } from "zod";
|
|
1037
|
+
function registerQueueTool(server, ctx) {
|
|
1038
|
+
server.tool(
|
|
1039
|
+
"yapout_queue",
|
|
1040
|
+
"List tickets ready for local implementation. Only returns tickets in backlog/unstarted Linear status.",
|
|
1041
|
+
{
|
|
1042
|
+
includeBlocked: z3.boolean().optional().describe("Show blocked tickets too (default: false)")
|
|
1043
|
+
},
|
|
1044
|
+
async (args) => {
|
|
1045
|
+
if (!ctx.projectId) {
|
|
1046
|
+
return {
|
|
1047
|
+
content: [
|
|
1048
|
+
{
|
|
1049
|
+
type: "text",
|
|
1050
|
+
text: "No project linked. Run yapout_init or yapout link first."
|
|
1051
|
+
}
|
|
1052
|
+
],
|
|
1053
|
+
isError: true
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
const data = await ctx.client.query(
|
|
1057
|
+
anyApi2.functions.tickets.getLocalQueuedTickets,
|
|
1058
|
+
{ projectId: ctx.projectId }
|
|
1059
|
+
);
|
|
1060
|
+
if (!data) {
|
|
1061
|
+
return {
|
|
1062
|
+
content: [
|
|
1063
|
+
{ type: "text", text: "Could not fetch queue." }
|
|
1064
|
+
],
|
|
1065
|
+
isError: true
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
let ready = data.ready;
|
|
1069
|
+
ready = ready.filter((t) => t.nature !== "operational");
|
|
1070
|
+
const linearIds = ready.map((t) => t.linearTicketId).filter((id) => !!id);
|
|
1071
|
+
if (linearIds.length > 0) {
|
|
1072
|
+
try {
|
|
1073
|
+
const statuses = await ctx.client.action(
|
|
1074
|
+
anyApi2.functions.linearStatusMutations.getIssueStatuses,
|
|
1075
|
+
{ projectId: ctx.projectId, linearIssueIds: linearIds }
|
|
1076
|
+
);
|
|
1077
|
+
const statusMap = new Map(
|
|
1078
|
+
statuses.map((s) => [s.linearIssueId, s.statusType])
|
|
1079
|
+
);
|
|
1080
|
+
ready = ready.filter((t) => {
|
|
1081
|
+
if (!t.linearTicketId) return true;
|
|
1082
|
+
const type = statusMap.get(t.linearTicketId);
|
|
1083
|
+
if (!type) return true;
|
|
1084
|
+
return type === "backlog" || type === "unstarted";
|
|
1085
|
+
});
|
|
1086
|
+
} catch {
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
const result = {
|
|
1090
|
+
ready,
|
|
1091
|
+
contextStale: data.contextStale,
|
|
1092
|
+
contextLastUpdated: data.contextLastUpdated
|
|
1093
|
+
};
|
|
1094
|
+
if (args.includeBlocked) {
|
|
1095
|
+
result.blocked = data.blocked;
|
|
1096
|
+
}
|
|
1097
|
+
if (data.contextStale) {
|
|
1098
|
+
const daysAgo = data.contextLastUpdated ? Math.round(
|
|
1099
|
+
(Date.now() - data.contextLastUpdated) / 864e5
|
|
1100
|
+
) : null;
|
|
1101
|
+
result.note = daysAgo ? `Project context was last updated ${daysAgo} days ago. Consider running yapout_compact.` : "Project context has never been generated. Run yapout_compact.";
|
|
1102
|
+
}
|
|
1103
|
+
return {
|
|
1104
|
+
content: [
|
|
1105
|
+
{ type: "text", text: JSON.stringify(result, null, 2) }
|
|
1106
|
+
]
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// src/mcp/tools/get-brief.ts
|
|
1113
|
+
import { z as z4 } from "zod";
|
|
1114
|
+
function registerGetBriefTool(server, ctx) {
|
|
1115
|
+
server.tool(
|
|
1116
|
+
"yapout_get_brief",
|
|
1117
|
+
"Fetch the full implementation context for a ticket",
|
|
1118
|
+
{
|
|
1119
|
+
ticketId: z4.string().describe("The ticket ID to get the brief for")
|
|
1120
|
+
},
|
|
1121
|
+
async (args) => {
|
|
1122
|
+
const data = await ctx.client.query(
|
|
1123
|
+
anyApi2.functions.tickets.getTicketBrief,
|
|
1124
|
+
{ ticketId: args.ticketId }
|
|
1125
|
+
);
|
|
1126
|
+
if (!data) {
|
|
1127
|
+
return {
|
|
1128
|
+
content: [
|
|
1129
|
+
{
|
|
1130
|
+
type: "text",
|
|
1131
|
+
text: "Ticket not found or you don't have access."
|
|
1132
|
+
}
|
|
1133
|
+
],
|
|
1134
|
+
isError: true
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
return {
|
|
1138
|
+
content: [
|
|
1139
|
+
{ type: "text", text: JSON.stringify(data, null, 2) }
|
|
1140
|
+
]
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// src/mcp/tools/claim.ts
|
|
1147
|
+
import { z as z5 } from "zod";
|
|
1148
|
+
import { join as join7 } from "path";
|
|
1149
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync6, writeFileSync as writeFileSync7 } from "fs";
|
|
1150
|
+
function readBranchPrefix(cwd) {
|
|
1151
|
+
try {
|
|
1152
|
+
const config = readYapoutConfig(cwd);
|
|
1153
|
+
return config.branch_prefix || "feat";
|
|
1154
|
+
} catch {
|
|
1155
|
+
return "feat";
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
function slugify(text) {
|
|
1159
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
|
|
1160
|
+
}
|
|
1161
|
+
function formatBrief(data) {
|
|
1162
|
+
const ticket = data.ticket;
|
|
1163
|
+
const sections = [
|
|
1164
|
+
`# ${ticket.title}`,
|
|
1165
|
+
"",
|
|
1166
|
+
`**Priority:** ${ticket.priority} | **Type:** ${ticket.type}`
|
|
1167
|
+
];
|
|
1168
|
+
if (ticket.linearUrl) {
|
|
1169
|
+
sections.push(`**Linear:** ${ticket.linearUrl}`);
|
|
1170
|
+
}
|
|
1171
|
+
sections.push("", "## Description", "", ticket.description);
|
|
1172
|
+
if (data.enrichedDescription) {
|
|
1173
|
+
sections.push("", "## Enriched Description", "", data.enrichedDescription);
|
|
1174
|
+
}
|
|
1175
|
+
if (data.implementationBrief) {
|
|
1176
|
+
sections.push("", "## Implementation Brief", "", data.implementationBrief);
|
|
1177
|
+
}
|
|
1178
|
+
const qa = data.enrichmentQA;
|
|
1179
|
+
if (qa && qa.length > 0) {
|
|
1180
|
+
sections.push("", "## Q&A");
|
|
1181
|
+
for (const item of qa) {
|
|
1182
|
+
sections.push("", `**Q:** ${item.question}`);
|
|
1183
|
+
sections.push(`**A:** ${item.answer || "(no answer yet)"}`);
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
if (data.userContext) {
|
|
1187
|
+
sections.push("", "## User Context", "", data.userContext);
|
|
1188
|
+
}
|
|
1189
|
+
if (data.projectContext) {
|
|
1190
|
+
sections.push("", "## Project Context", "", data.projectContext);
|
|
1191
|
+
}
|
|
1192
|
+
const warnings = data.warnings;
|
|
1193
|
+
if (warnings?.duplicateWarning || warnings?.scopeWarning) {
|
|
1194
|
+
sections.push("", "## Warnings");
|
|
1195
|
+
if (warnings.duplicateWarning)
|
|
1196
|
+
sections.push("", `**Duplicate:** ${warnings.duplicateWarning}`);
|
|
1197
|
+
if (warnings.scopeWarning)
|
|
1198
|
+
sections.push("", `**Scope:** ${warnings.scopeWarning}`);
|
|
1199
|
+
}
|
|
1200
|
+
return sections.join("\n");
|
|
1201
|
+
}
|
|
1202
|
+
function registerClaimTool(server, ctx) {
|
|
1203
|
+
server.tool(
|
|
1204
|
+
"yapout_claim",
|
|
1205
|
+
"Claim a ticket for local implementation. Creates a branch (or worktree), writes the brief, and updates status.",
|
|
1206
|
+
{
|
|
1207
|
+
ticketId: z5.string().describe("The ticket ID to claim"),
|
|
1208
|
+
worktree: z5.boolean().optional().describe("Create a git worktree for parallel work (default: false)")
|
|
1209
|
+
},
|
|
1210
|
+
async (args) => {
|
|
1211
|
+
if (!ctx.projectId) {
|
|
1212
|
+
return {
|
|
1213
|
+
content: [
|
|
1214
|
+
{
|
|
1215
|
+
type: "text",
|
|
1216
|
+
text: "No project linked. Run yapout_init first."
|
|
1217
|
+
}
|
|
1218
|
+
],
|
|
1219
|
+
isError: true
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
const briefData = await ctx.client.query(
|
|
1223
|
+
anyApi2.functions.tickets.getTicketBrief,
|
|
1224
|
+
{ ticketId: args.ticketId }
|
|
1225
|
+
);
|
|
1226
|
+
if (!briefData) {
|
|
1227
|
+
return {
|
|
1228
|
+
content: [
|
|
1229
|
+
{
|
|
1230
|
+
type: "text",
|
|
1231
|
+
text: "Ticket not found or you don't have access."
|
|
1232
|
+
}
|
|
1233
|
+
],
|
|
1234
|
+
isError: true
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
const ticket = briefData.ticket;
|
|
1238
|
+
const linearTicketId = briefData.linearTicketId;
|
|
1239
|
+
const defaultBranch = briefData.defaultBranch || "main";
|
|
1240
|
+
const prefix = readBranchPrefix(ctx.cwd);
|
|
1241
|
+
const slug = slugify(ticket.title);
|
|
1242
|
+
const branchName = linearTicketId ? `${prefix}/${linearTicketId.toLowerCase()}-${slug}` : `${prefix}/${slug}`;
|
|
1243
|
+
const claim = await ctx.client.mutation(
|
|
1244
|
+
anyApi2.functions.tickets.claimTicketLocal,
|
|
1245
|
+
{ ticketId: args.ticketId, branchName }
|
|
1246
|
+
);
|
|
1247
|
+
if (linearTicketId && ctx.projectId) {
|
|
1248
|
+
try {
|
|
1249
|
+
await ctx.client.action(
|
|
1250
|
+
anyApi2.functions.linearStatusMutations.moveIssueStatus,
|
|
1251
|
+
{
|
|
1252
|
+
projectId: ctx.projectId,
|
|
1253
|
+
linearIssueId: linearTicketId,
|
|
1254
|
+
statusType: "started"
|
|
1255
|
+
}
|
|
1256
|
+
);
|
|
1257
|
+
} catch {
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
let worktreePath;
|
|
1261
|
+
if (args.worktree) {
|
|
1262
|
+
fetchOrigin(ctx.cwd);
|
|
1263
|
+
worktreePath = createWorktree(
|
|
1264
|
+
ctx.cwd,
|
|
1265
|
+
args.ticketId,
|
|
1266
|
+
branchName,
|
|
1267
|
+
defaultBranch
|
|
1268
|
+
);
|
|
1269
|
+
const wtYapoutDir = join7(worktreePath, ".yapout");
|
|
1270
|
+
if (!existsSync6(wtYapoutDir)) mkdirSync6(wtYapoutDir, { recursive: true });
|
|
1271
|
+
const brief2 = formatBrief(briefData);
|
|
1272
|
+
writeFileSync7(join7(wtYapoutDir, "brief.md"), brief2);
|
|
1273
|
+
try {
|
|
1274
|
+
await ctx.client.mutation(
|
|
1275
|
+
anyApi2.functions.pipelineRuns.reportDaemonEvent,
|
|
1276
|
+
{
|
|
1277
|
+
pipelineRunId: claim.pipelineRunId,
|
|
1278
|
+
event: "worktree_created",
|
|
1279
|
+
message: `Worktree: ${worktreePath}`
|
|
1280
|
+
}
|
|
1281
|
+
);
|
|
1282
|
+
} catch {
|
|
1283
|
+
}
|
|
1284
|
+
return {
|
|
1285
|
+
content: [
|
|
1286
|
+
{
|
|
1287
|
+
type: "text",
|
|
1288
|
+
text: JSON.stringify(
|
|
1289
|
+
{
|
|
1290
|
+
branch: branchName,
|
|
1291
|
+
worktreePath,
|
|
1292
|
+
briefPath: `${worktreePath}/.yapout/brief.md`,
|
|
1293
|
+
brief: formatBrief(briefData),
|
|
1294
|
+
pipelineRunId: claim.pipelineRunId
|
|
1295
|
+
},
|
|
1296
|
+
null,
|
|
1297
|
+
2
|
|
1298
|
+
)
|
|
1299
|
+
}
|
|
1300
|
+
]
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
fetchOrigin(ctx.cwd);
|
|
1304
|
+
checkoutNewBranch(branchName, defaultBranch, ctx.cwd);
|
|
1305
|
+
const yapoutDir = join7(ctx.cwd, ".yapout");
|
|
1306
|
+
if (!existsSync6(yapoutDir)) mkdirSync6(yapoutDir, { recursive: true });
|
|
1307
|
+
const brief = formatBrief(briefData);
|
|
1308
|
+
writeFileSync7(join7(yapoutDir, "brief.md"), brief);
|
|
1309
|
+
try {
|
|
1310
|
+
await ctx.client.mutation(
|
|
1311
|
+
anyApi2.functions.pipelineRuns.reportDaemonEvent,
|
|
1312
|
+
{
|
|
1313
|
+
pipelineRunId: claim.pipelineRunId,
|
|
1314
|
+
event: "daemon_claimed",
|
|
1315
|
+
message: `Claimed ticket: ${ticket.title}`
|
|
1316
|
+
}
|
|
1317
|
+
);
|
|
1318
|
+
await ctx.client.mutation(
|
|
1319
|
+
anyApi2.functions.pipelineRuns.reportDaemonEvent,
|
|
1320
|
+
{
|
|
1321
|
+
pipelineRunId: claim.pipelineRunId,
|
|
1322
|
+
event: "branch_created",
|
|
1323
|
+
message: `Branch: ${branchName}`
|
|
1324
|
+
}
|
|
1325
|
+
);
|
|
1326
|
+
} catch {
|
|
1327
|
+
}
|
|
1328
|
+
return {
|
|
1329
|
+
content: [
|
|
1330
|
+
{
|
|
1331
|
+
type: "text",
|
|
1332
|
+
text: JSON.stringify(
|
|
1333
|
+
{
|
|
1334
|
+
branch: branchName,
|
|
1335
|
+
briefPath: ".yapout/brief.md",
|
|
1336
|
+
brief,
|
|
1337
|
+
pipelineRunId: claim.pipelineRunId
|
|
1338
|
+
},
|
|
1339
|
+
null,
|
|
1340
|
+
2
|
|
1341
|
+
)
|
|
1342
|
+
}
|
|
1343
|
+
]
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// src/mcp/tools/event.ts
|
|
1350
|
+
import { z as z6 } from "zod";
|
|
1351
|
+
function registerEventTool(server, ctx) {
|
|
1352
|
+
server.tool(
|
|
1353
|
+
"yapout_event",
|
|
1354
|
+
"Report a status event back to yapout for the activity feed",
|
|
1355
|
+
{
|
|
1356
|
+
pipelineRunId: z6.string().describe("The pipeline run ID"),
|
|
1357
|
+
event: z6.string().describe(
|
|
1358
|
+
'Event type (e.g., "reading_codebase", "writing_code", "running_tests")'
|
|
1359
|
+
),
|
|
1360
|
+
message: z6.string().describe("Human-readable description")
|
|
1361
|
+
},
|
|
1362
|
+
async (args) => {
|
|
1363
|
+
try {
|
|
1364
|
+
await ctx.client.mutation(
|
|
1365
|
+
anyApi2.functions.pipelineRuns.reportDaemonEvent,
|
|
1366
|
+
{
|
|
1367
|
+
pipelineRunId: args.pipelineRunId,
|
|
1368
|
+
event: args.event,
|
|
1369
|
+
message: args.message
|
|
1370
|
+
}
|
|
1371
|
+
);
|
|
1372
|
+
return {
|
|
1373
|
+
content: [
|
|
1374
|
+
{
|
|
1375
|
+
type: "text",
|
|
1376
|
+
text: JSON.stringify({ success: true })
|
|
1377
|
+
}
|
|
1378
|
+
]
|
|
1379
|
+
};
|
|
1380
|
+
} catch (err) {
|
|
1381
|
+
return {
|
|
1382
|
+
content: [
|
|
1383
|
+
{
|
|
1384
|
+
type: "text",
|
|
1385
|
+
text: JSON.stringify({
|
|
1386
|
+
success: false,
|
|
1387
|
+
error: err.message
|
|
1388
|
+
})
|
|
1389
|
+
}
|
|
1390
|
+
]
|
|
1391
|
+
};
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
// src/mcp/tools/ship.ts
|
|
1398
|
+
import { z as z7 } from "zod";
|
|
1399
|
+
|
|
1400
|
+
// src/lib/github-cli.ts
|
|
1401
|
+
import { execSync as execSync3 } from "child_process";
|
|
1402
|
+
function hasGhCli() {
|
|
1403
|
+
try {
|
|
1404
|
+
execSync3("gh --version", { stdio: "pipe" });
|
|
1405
|
+
return true;
|
|
1406
|
+
} catch {
|
|
1407
|
+
return false;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
function createPrWithGh(title, body, branch, base, cwd) {
|
|
1411
|
+
const escaped = body.replace(/"/g, '\\"').replace(/`/g, "\\`");
|
|
1412
|
+
const output = execSync3(
|
|
1413
|
+
`gh pr create --title "${title.replace(/"/g, '\\"')}" --body "${escaped}" --base ${base} --head ${branch} --draft`,
|
|
1414
|
+
{ cwd, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
|
|
1415
|
+
).trim();
|
|
1416
|
+
const url = output;
|
|
1417
|
+
const numberMatch = url.match(/\/pull\/(\d+)/);
|
|
1418
|
+
return {
|
|
1419
|
+
number: numberMatch ? Number(numberMatch[1]) : 0,
|
|
1420
|
+
url
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
async function createPrWithApi(title, body, branch, base, repoFullName) {
|
|
1424
|
+
const token = process.env.GITHUB_TOKEN;
|
|
1425
|
+
if (!token) {
|
|
1426
|
+
throw new Error(
|
|
1427
|
+
"Cannot create PR: gh CLI not available and GITHUB_TOKEN not set."
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1430
|
+
const response = await fetch(
|
|
1431
|
+
`https://api.github.com/repos/${repoFullName}/pulls`,
|
|
1432
|
+
{
|
|
1433
|
+
method: "POST",
|
|
1434
|
+
headers: {
|
|
1435
|
+
Authorization: `Bearer ${token}`,
|
|
1436
|
+
Accept: "application/vnd.github.v3+json",
|
|
1437
|
+
"Content-Type": "application/json"
|
|
1438
|
+
},
|
|
1439
|
+
body: JSON.stringify({ title, body, head: branch, base, draft: true })
|
|
1440
|
+
}
|
|
1441
|
+
);
|
|
1442
|
+
if (!response.ok) {
|
|
1443
|
+
const text = await response.text();
|
|
1444
|
+
throw new Error(`GitHub API error ${response.status}: ${text}`);
|
|
1445
|
+
}
|
|
1446
|
+
const data = await response.json();
|
|
1447
|
+
return { number: data.number, url: data.html_url };
|
|
1448
|
+
}
|
|
1449
|
+
async function createPullRequest(title, body, branch, base, repoFullName, cwd) {
|
|
1450
|
+
if (hasGhCli()) {
|
|
1451
|
+
return createPrWithGh(title, body, branch, base, cwd);
|
|
1452
|
+
}
|
|
1453
|
+
return createPrWithApi(title, body, branch, base, repoFullName);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// src/mcp/tools/ship.ts
|
|
1457
|
+
import { join as join8 } from "path";
|
|
1458
|
+
import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
|
|
1459
|
+
function buildCommitMessage(message, template, ticket) {
|
|
1460
|
+
if (message) return message;
|
|
1461
|
+
if (template) {
|
|
1462
|
+
return template.replace(/\{\{ticket\.type\}\}/g, ticket.type).replace(/\{\{ticket\.title\}\}/g, ticket.title).replace(/\{\{ticket\.linearTicketId\}\}/g, ticket.linearTicketId ?? "draft").replace(/\{\{ticket\.id\}\}/g, ticket.id ?? "").replace(/\{\{ticket\.priority\}\}/g, ticket.priority ?? "medium");
|
|
1463
|
+
}
|
|
1464
|
+
const prefix = ticket.type === "bug" ? "fix" : "feat";
|
|
1465
|
+
const ref = ticket.linearTicketId ? ` (${ticket.linearTicketId})` : "";
|
|
1466
|
+
return `${prefix}(${ticket.type}): ${ticket.title}${ref}`;
|
|
1467
|
+
}
|
|
1468
|
+
function registerShipTool(server, ctx) {
|
|
1469
|
+
server.tool(
|
|
1470
|
+
"yapout_ship",
|
|
1471
|
+
"Commit, push, open a PR, and mark the ticket as done. Run yapout_check first if post-flight checks are configured.",
|
|
1472
|
+
{
|
|
1473
|
+
message: z7.string().optional().describe("Custom commit message (overrides template)"),
|
|
1474
|
+
skipPr: z7.boolean().optional().describe("Just push, don't open a PR"),
|
|
1475
|
+
pipelineRunId: z7.string().describe("The pipeline run ID from yapout_claim"),
|
|
1476
|
+
worktreePath: z7.string().optional().describe("Worktree path (if claiming was done with worktree: true)")
|
|
1477
|
+
},
|
|
1478
|
+
async (args) => {
|
|
1479
|
+
const gitCwd = args.worktreePath || ctx.cwd;
|
|
1480
|
+
const config = readYapoutConfig(ctx.cwd);
|
|
1481
|
+
if (config.ship_requires_checks) {
|
|
1482
|
+
if (ctx.lastCheckPassedForRun !== args.pipelineRunId) {
|
|
1483
|
+
return {
|
|
1484
|
+
content: [
|
|
1485
|
+
{
|
|
1486
|
+
type: "text",
|
|
1487
|
+
text: JSON.stringify({
|
|
1488
|
+
error: "Post-flight checks are required but haven't passed. Run yapout_check first.",
|
|
1489
|
+
ship_requires_checks: true
|
|
1490
|
+
})
|
|
1491
|
+
}
|
|
1492
|
+
]
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
const branch = getCurrentBranch(gitCwd);
|
|
1497
|
+
const defaultBranch = getDefaultBranch(gitCwd);
|
|
1498
|
+
let ticketTitle = branch;
|
|
1499
|
+
let ticketType = "feature";
|
|
1500
|
+
let ticketLinearId;
|
|
1501
|
+
try {
|
|
1502
|
+
const briefPath = join8(gitCwd, ".yapout", "brief.md");
|
|
1503
|
+
if (existsSync7(briefPath)) {
|
|
1504
|
+
const brief = readFileSync6(briefPath, "utf-8");
|
|
1505
|
+
const titleMatch = brief.match(/^# (.+)$/m);
|
|
1506
|
+
if (titleMatch) ticketTitle = titleMatch[1];
|
|
1507
|
+
const typeMatch = brief.match(/\*\*Type:\*\* (\w+)/);
|
|
1508
|
+
if (typeMatch) ticketType = typeMatch[1];
|
|
1509
|
+
const linearMatch = brief.match(/\*\*Linear:\*\* .+\/([A-Z]+-\d+)\//);
|
|
1510
|
+
if (linearMatch) ticketLinearId = linearMatch[1];
|
|
1511
|
+
}
|
|
1512
|
+
} catch {
|
|
1513
|
+
}
|
|
1514
|
+
const commitMsg = buildCommitMessage(args.message, config.commit_template, {
|
|
1515
|
+
title: ticketTitle,
|
|
1516
|
+
type: ticketType,
|
|
1517
|
+
linearTicketId: ticketLinearId
|
|
1518
|
+
});
|
|
1519
|
+
stageAll(gitCwd);
|
|
1520
|
+
const sha = commit(commitMsg, gitCwd);
|
|
1521
|
+
push(branch, gitCwd);
|
|
1522
|
+
const result = {
|
|
1523
|
+
commit: sha,
|
|
1524
|
+
branch,
|
|
1525
|
+
pushed: true
|
|
1526
|
+
};
|
|
1527
|
+
if (!config.ship_requires_checks && config.post_flight.length > 0) {
|
|
1528
|
+
if (ctx.lastCheckPassedForRun !== args.pipelineRunId) {
|
|
1529
|
+
result.warning = "Shipped without running post-flight checks.";
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
let prNumber;
|
|
1533
|
+
let prUrl;
|
|
1534
|
+
if (!args.skipPr) {
|
|
1535
|
+
try {
|
|
1536
|
+
const repoFullName = getRepoFullName(gitCwd);
|
|
1537
|
+
const diffStats = getDiffStats(defaultBranch, branch, gitCwd);
|
|
1538
|
+
const prBody = [
|
|
1539
|
+
`## Summary`,
|
|
1540
|
+
"",
|
|
1541
|
+
ticketTitle,
|
|
1542
|
+
"",
|
|
1543
|
+
`## Changes`,
|
|
1544
|
+
"",
|
|
1545
|
+
"```",
|
|
1546
|
+
diffStats,
|
|
1547
|
+
"```",
|
|
1548
|
+
"",
|
|
1549
|
+
"---",
|
|
1550
|
+
`Implemented via [yapout](https://yapout.dev) daemon`
|
|
1551
|
+
].join("\n");
|
|
1552
|
+
const pr = await createPullRequest(
|
|
1553
|
+
ticketTitle,
|
|
1554
|
+
prBody,
|
|
1555
|
+
branch,
|
|
1556
|
+
defaultBranch,
|
|
1557
|
+
repoFullName,
|
|
1558
|
+
gitCwd
|
|
1559
|
+
);
|
|
1560
|
+
prNumber = pr.number;
|
|
1561
|
+
prUrl = pr.url;
|
|
1562
|
+
result.pr = { number: pr.number, url: pr.url };
|
|
1563
|
+
} catch (err) {
|
|
1564
|
+
result.prError = err.message;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
try {
|
|
1568
|
+
await ctx.client.mutation(
|
|
1569
|
+
anyApi2.functions.pipelineRuns.reportDaemonEvent,
|
|
1570
|
+
{
|
|
1571
|
+
pipelineRunId: args.pipelineRunId,
|
|
1572
|
+
event: "push_completed",
|
|
1573
|
+
message: `Pushed ${sha.slice(0, 7)} to ${branch}`
|
|
1574
|
+
}
|
|
1575
|
+
);
|
|
1576
|
+
if (prUrl) {
|
|
1577
|
+
await ctx.client.mutation(
|
|
1578
|
+
anyApi2.functions.pipelineRuns.reportDaemonEvent,
|
|
1579
|
+
{
|
|
1580
|
+
pipelineRunId: args.pipelineRunId,
|
|
1581
|
+
event: "pr_opened",
|
|
1582
|
+
message: `PR #${prNumber}: ${prUrl}`
|
|
1583
|
+
}
|
|
1584
|
+
);
|
|
1585
|
+
}
|
|
1586
|
+
} catch {
|
|
1587
|
+
}
|
|
1588
|
+
try {
|
|
1589
|
+
await ctx.client.mutation(
|
|
1590
|
+
anyApi2.functions.pipelineRuns.completePipelineLocal,
|
|
1591
|
+
{
|
|
1592
|
+
pipelineRunId: args.pipelineRunId,
|
|
1593
|
+
githubPrNumber: prNumber,
|
|
1594
|
+
githubPrUrl: prUrl
|
|
1595
|
+
}
|
|
1596
|
+
);
|
|
1597
|
+
result.pipelineCompleted = true;
|
|
1598
|
+
} catch (err) {
|
|
1599
|
+
result.completionError = err.message;
|
|
1600
|
+
}
|
|
1601
|
+
if (ticketLinearId && ctx.projectId) {
|
|
1602
|
+
try {
|
|
1603
|
+
await ctx.client.action(
|
|
1604
|
+
anyApi2.functions.linearStatusMutations.moveIssueStatus,
|
|
1605
|
+
{
|
|
1606
|
+
projectId: ctx.projectId,
|
|
1607
|
+
linearIssueId: ticketLinearId,
|
|
1608
|
+
statusType: "completed"
|
|
1609
|
+
}
|
|
1610
|
+
);
|
|
1611
|
+
} catch {
|
|
1612
|
+
}
|
|
1613
|
+
if (prUrl) {
|
|
1614
|
+
try {
|
|
1615
|
+
await ctx.client.action(
|
|
1616
|
+
anyApi2.functions.linearStatusMutations.addLinearComment,
|
|
1617
|
+
{
|
|
1618
|
+
projectId: ctx.projectId,
|
|
1619
|
+
linearIssueId: ticketLinearId,
|
|
1620
|
+
body: `PR opened: [#${prNumber}](${prUrl})`
|
|
1621
|
+
}
|
|
1622
|
+
);
|
|
1623
|
+
} catch {
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
if (args.worktreePath) {
|
|
1628
|
+
try {
|
|
1629
|
+
removeWorktree(ctx.cwd, args.worktreePath);
|
|
1630
|
+
result.worktreeCleaned = true;
|
|
1631
|
+
try {
|
|
1632
|
+
await ctx.client.mutation(
|
|
1633
|
+
anyApi2.functions.pipelineRuns.reportDaemonEvent,
|
|
1634
|
+
{
|
|
1635
|
+
pipelineRunId: args.pipelineRunId,
|
|
1636
|
+
event: "worktree_cleaned",
|
|
1637
|
+
message: `Worktree removed: ${args.worktreePath}`
|
|
1638
|
+
}
|
|
1639
|
+
);
|
|
1640
|
+
} catch {
|
|
1641
|
+
}
|
|
1642
|
+
} catch {
|
|
1643
|
+
result.worktreeCleanError = "Failed to remove worktree. Run `yapout clean` later.";
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
return {
|
|
1647
|
+
content: [
|
|
1648
|
+
{ type: "text", text: JSON.stringify(result, null, 2) }
|
|
1649
|
+
]
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
);
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// src/mcp/tools/check.ts
|
|
1656
|
+
import { z as z8 } from "zod";
|
|
1657
|
+
import { exec } from "child_process";
|
|
1658
|
+
var COMMAND_TIMEOUT_MS = 5 * 60 * 1e3;
|
|
1659
|
+
var MAX_OUTPUT_LINES = 100;
|
|
1660
|
+
function truncate(text) {
|
|
1661
|
+
const lines = text.split("\n");
|
|
1662
|
+
if (lines.length <= MAX_OUTPUT_LINES) return text;
|
|
1663
|
+
return `... (${lines.length - MAX_OUTPUT_LINES} lines truncated)
|
|
1664
|
+
` + lines.slice(-MAX_OUTPUT_LINES).join("\n");
|
|
1665
|
+
}
|
|
1666
|
+
function runCommand(command, cwd) {
|
|
1667
|
+
return new Promise((resolve11) => {
|
|
1668
|
+
const start = Date.now();
|
|
1669
|
+
const child = exec(command, {
|
|
1670
|
+
cwd,
|
|
1671
|
+
timeout: COMMAND_TIMEOUT_MS,
|
|
1672
|
+
maxBuffer: 10 * 1024 * 1024
|
|
1673
|
+
// 10MB
|
|
1674
|
+
}, (error, stdout, stderr) => {
|
|
1675
|
+
resolve11({
|
|
1676
|
+
exitCode: error?.code ?? (error ? 1 : 0),
|
|
1677
|
+
stdout: truncate(stdout),
|
|
1678
|
+
stderr: truncate(stderr),
|
|
1679
|
+
durationMs: Date.now() - start
|
|
1680
|
+
});
|
|
1681
|
+
});
|
|
1682
|
+
child.on("error", () => {
|
|
1683
|
+
resolve11({
|
|
1684
|
+
exitCode: 1,
|
|
1685
|
+
stdout: "",
|
|
1686
|
+
stderr: `Command timed out after ${COMMAND_TIMEOUT_MS / 1e3}s`,
|
|
1687
|
+
durationMs: Date.now() - start
|
|
1688
|
+
});
|
|
1689
|
+
});
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
function registerCheckTool(server, ctx) {
|
|
1693
|
+
server.tool(
|
|
1694
|
+
"yapout_check",
|
|
1695
|
+
"Run post-flight checks (lint, test, typecheck) before shipping. Configure commands in .yapout/config.yml",
|
|
1696
|
+
{
|
|
1697
|
+
commands: z8.array(z8.string()).optional().describe("Override: run these commands instead of post_flight config"),
|
|
1698
|
+
pipelineRunId: z8.string().optional().describe("Pipeline run ID for event reporting"),
|
|
1699
|
+
worktreePath: z8.string().optional().describe("Run checks in this worktree directory instead of the repo root")
|
|
1700
|
+
},
|
|
1701
|
+
async (args) => {
|
|
1702
|
+
const checkCwd = args.worktreePath || ctx.cwd;
|
|
1703
|
+
const config = readYapoutConfig(ctx.cwd);
|
|
1704
|
+
const commands = args.commands ?? config.post_flight;
|
|
1705
|
+
if (commands.length === 0) {
|
|
1706
|
+
return {
|
|
1707
|
+
content: [
|
|
1708
|
+
{
|
|
1709
|
+
type: "text",
|
|
1710
|
+
text: JSON.stringify({
|
|
1711
|
+
passed: true,
|
|
1712
|
+
results: [],
|
|
1713
|
+
summary: "No post-flight checks configured. Add commands to .yapout/config.yml under post_flight."
|
|
1714
|
+
})
|
|
1715
|
+
}
|
|
1716
|
+
]
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
const results = [];
|
|
1720
|
+
for (const command of commands) {
|
|
1721
|
+
if (args.pipelineRunId) {
|
|
1722
|
+
try {
|
|
1723
|
+
await ctx.client.mutation(
|
|
1724
|
+
anyApi2.functions.pipelineRuns.reportDaemonEvent,
|
|
1725
|
+
{
|
|
1726
|
+
pipelineRunId: args.pipelineRunId,
|
|
1727
|
+
event: "running_check",
|
|
1728
|
+
message: `Running: ${command}`
|
|
1729
|
+
}
|
|
1730
|
+
);
|
|
1731
|
+
} catch {
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
const result = await runCommand(command, checkCwd);
|
|
1735
|
+
const passed = result.exitCode === 0;
|
|
1736
|
+
results.push({
|
|
1737
|
+
command,
|
|
1738
|
+
passed,
|
|
1739
|
+
...result
|
|
1740
|
+
});
|
|
1741
|
+
if (args.pipelineRunId) {
|
|
1742
|
+
try {
|
|
1743
|
+
await ctx.client.mutation(
|
|
1744
|
+
anyApi2.functions.pipelineRuns.reportDaemonEvent,
|
|
1745
|
+
{
|
|
1746
|
+
pipelineRunId: args.pipelineRunId,
|
|
1747
|
+
event: passed ? "check_passed" : "check_failed",
|
|
1748
|
+
message: passed ? `Passed: ${command} (${(result.durationMs / 1e3).toFixed(1)}s)` : `Failed: ${command} (exit ${result.exitCode})`
|
|
1749
|
+
}
|
|
1750
|
+
);
|
|
1751
|
+
} catch {
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
const passedCount = results.filter((r) => r.passed).length;
|
|
1756
|
+
const allPassed = passedCount === results.length;
|
|
1757
|
+
const failedNames = results.filter((r) => !r.passed).map((r) => r.command);
|
|
1758
|
+
const summary = allPassed ? `${passedCount}/${results.length} checks passed` : `${passedCount}/${results.length} checks passed. Failed: ${failedNames.join(", ")}`;
|
|
1759
|
+
if (allPassed && args.pipelineRunId) {
|
|
1760
|
+
ctx.lastCheckPassedForRun = args.pipelineRunId;
|
|
1761
|
+
}
|
|
1762
|
+
return {
|
|
1763
|
+
content: [
|
|
1764
|
+
{
|
|
1765
|
+
type: "text",
|
|
1766
|
+
text: JSON.stringify({ passed: allPassed, results, summary }, null, 2)
|
|
1767
|
+
}
|
|
1768
|
+
]
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
);
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// src/mcp/tools/bundle.ts
|
|
1775
|
+
import { z as z9 } from "zod";
|
|
1776
|
+
import { join as join9 } from "path";
|
|
1777
|
+
import { existsSync as existsSync8, writeFileSync as writeFileSync8, mkdirSync as mkdirSync7 } from "fs";
|
|
1778
|
+
function registerBundleTool(server, ctx) {
|
|
1779
|
+
server.tool(
|
|
1780
|
+
"yapout_bundle",
|
|
1781
|
+
"Add a ticket to the current ticket's bundle so they ship as one PR",
|
|
1782
|
+
{
|
|
1783
|
+
ticketId: z9.string().describe("Ticket ID to add to the bundle"),
|
|
1784
|
+
withTicket: z9.string().describe("Lead ticket ID (currently being worked on)")
|
|
1785
|
+
},
|
|
1786
|
+
async (args) => {
|
|
1787
|
+
const result = await ctx.client.mutation(
|
|
1788
|
+
anyApi2.functions.tickets.bundleTickets,
|
|
1789
|
+
{ leadTicketId: args.withTicket, joiningTicketId: args.ticketId }
|
|
1790
|
+
);
|
|
1791
|
+
const bundledBrief = await ctx.client.query(
|
|
1792
|
+
anyApi2.functions.tickets.getBundledBrief,
|
|
1793
|
+
{ bundleId: result.bundleId }
|
|
1794
|
+
);
|
|
1795
|
+
if (!bundledBrief) {
|
|
1796
|
+
return {
|
|
1797
|
+
content: [
|
|
1798
|
+
{
|
|
1799
|
+
type: "text",
|
|
1800
|
+
text: JSON.stringify({
|
|
1801
|
+
bundleId: result.bundleId,
|
|
1802
|
+
message: "Bundled successfully but could not fetch combined brief."
|
|
1803
|
+
})
|
|
1804
|
+
}
|
|
1805
|
+
]
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
const sections = [
|
|
1809
|
+
`# Bundle: ${bundledBrief.tickets.length} tickets`,
|
|
1810
|
+
""
|
|
1811
|
+
];
|
|
1812
|
+
for (const t of bundledBrief.tickets) {
|
|
1813
|
+
sections.push(`## ${t.linearTicketId ?? t.ticketId}: ${t.title}`);
|
|
1814
|
+
sections.push("");
|
|
1815
|
+
sections.push(`**Priority:** ${t.priority} | **Type:** ${t.type}`);
|
|
1816
|
+
if (t.linearUrl) sections.push(`**Linear:** ${t.linearUrl}`);
|
|
1817
|
+
sections.push("", t.description);
|
|
1818
|
+
if (t.enrichedDescription) {
|
|
1819
|
+
sections.push("", "### Enriched Description", "", t.enrichedDescription);
|
|
1820
|
+
}
|
|
1821
|
+
if (t.implementationBrief) {
|
|
1822
|
+
sections.push("", "### Implementation Brief", "", t.implementationBrief);
|
|
1823
|
+
}
|
|
1824
|
+
sections.push("", "---", "");
|
|
1825
|
+
}
|
|
1826
|
+
if (bundledBrief.projectContext) {
|
|
1827
|
+
sections.push("## Project Context", "", bundledBrief.projectContext);
|
|
1828
|
+
}
|
|
1829
|
+
const combinedBrief = sections.join("\n");
|
|
1830
|
+
const yapoutDir = join9(ctx.cwd, ".yapout");
|
|
1831
|
+
if (!existsSync8(yapoutDir)) mkdirSync7(yapoutDir, { recursive: true });
|
|
1832
|
+
writeFileSync8(join9(yapoutDir, "brief.md"), combinedBrief);
|
|
1833
|
+
return {
|
|
1834
|
+
content: [
|
|
1835
|
+
{
|
|
1836
|
+
type: "text",
|
|
1837
|
+
text: JSON.stringify(
|
|
1838
|
+
{
|
|
1839
|
+
bundleId: result.bundleId,
|
|
1840
|
+
tickets: bundledBrief.tickets.map((t) => ({
|
|
1841
|
+
ticketId: t.ticketId,
|
|
1842
|
+
title: t.title
|
|
1843
|
+
})),
|
|
1844
|
+
combinedBrief,
|
|
1845
|
+
message: `${bundledBrief.tickets.length} tickets bundled. Brief updated.`
|
|
1846
|
+
},
|
|
1847
|
+
null,
|
|
1848
|
+
2
|
|
1849
|
+
)
|
|
1850
|
+
}
|
|
1851
|
+
]
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
);
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// src/mcp/tools/get-unenriched-tickets.ts
|
|
1858
|
+
import { z as z10 } from "zod";
|
|
1859
|
+
function registerGetUnenrichedTicketsTool(server, ctx) {
|
|
1860
|
+
server.tool(
|
|
1861
|
+
"yapout_get_unenriched_ticket",
|
|
1862
|
+
`Start enriching a ticket. Returns the next draft ticket (or a specific one by ID) with full context including the original transcript quote, project context, and existing ticket titles for duplicate detection.
|
|
1863
|
+
|
|
1864
|
+
The ticket is locked to "enriching" status \u2014 no other agent can enrich it concurrently.
|
|
1865
|
+
|
|
1866
|
+
After calling this tool, you should:
|
|
1867
|
+
1. Read the relevant parts of the codebase to understand the area affected
|
|
1868
|
+
2. Check for duplicates against the existingTickets list
|
|
1869
|
+
3. If the decision is ambiguous, ask the developer clarifying questions in conversation
|
|
1870
|
+
4. When confident, call yapout_save_enrichment with a clean description, acceptance criteria, and implementation brief`,
|
|
1871
|
+
{
|
|
1872
|
+
ticketId: z10.string().optional().describe("Specific ticket ID to enrich. If omitted, returns the highest priority draft ticket.")
|
|
1873
|
+
},
|
|
1874
|
+
async (args) => {
|
|
1875
|
+
const projectId = ctx.projectId ?? process.env.YAPOUT_PROJECT_ID;
|
|
1876
|
+
if (!projectId) {
|
|
1877
|
+
return {
|
|
1878
|
+
content: [
|
|
1879
|
+
{
|
|
1880
|
+
type: "text",
|
|
1881
|
+
text: "No project linked. Run yapout_init or yapout link first."
|
|
1882
|
+
}
|
|
1883
|
+
],
|
|
1884
|
+
isError: true
|
|
1885
|
+
};
|
|
1886
|
+
}
|
|
1887
|
+
try {
|
|
1888
|
+
const result = await ctx.client.mutation(
|
|
1889
|
+
anyApi2.functions.localPipeline.claimForEnrichment,
|
|
1890
|
+
{
|
|
1891
|
+
projectId,
|
|
1892
|
+
...args.ticketId ? { ticketId: args.ticketId } : {}
|
|
1893
|
+
}
|
|
1894
|
+
);
|
|
1895
|
+
if (!result) {
|
|
1896
|
+
return {
|
|
1897
|
+
content: [
|
|
1898
|
+
{
|
|
1899
|
+
type: "text",
|
|
1900
|
+
text: "No tickets need enrichment. All tickets are either already enriched or in progress."
|
|
1901
|
+
}
|
|
1902
|
+
]
|
|
1903
|
+
};
|
|
1904
|
+
}
|
|
1905
|
+
return {
|
|
1906
|
+
content: [
|
|
1907
|
+
{
|
|
1908
|
+
type: "text",
|
|
1909
|
+
text: JSON.stringify(result, null, 2)
|
|
1910
|
+
}
|
|
1911
|
+
]
|
|
1912
|
+
};
|
|
1913
|
+
} catch (err) {
|
|
1914
|
+
return {
|
|
1915
|
+
content: [
|
|
1916
|
+
{
|
|
1917
|
+
type: "text",
|
|
1918
|
+
text: `Error claiming ticket for enrichment: ${err.message}`
|
|
1919
|
+
}
|
|
1920
|
+
],
|
|
1921
|
+
isError: true
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
// src/mcp/tools/get-existing-tickets.ts
|
|
1929
|
+
function registerGetExistingTicketsTool(server, ctx) {
|
|
1930
|
+
server.tool(
|
|
1931
|
+
"yapout_get_existing_tickets",
|
|
1932
|
+
"Fetch all ticket titles in the project for duplicate detection during enrichment",
|
|
1933
|
+
{},
|
|
1934
|
+
async () => {
|
|
1935
|
+
const projectId = ctx.projectId ?? process.env.YAPOUT_PROJECT_ID;
|
|
1936
|
+
if (!projectId) {
|
|
1937
|
+
return {
|
|
1938
|
+
content: [
|
|
1939
|
+
{
|
|
1940
|
+
type: "text",
|
|
1941
|
+
text: "No project linked. Run yapout_init or yapout link first."
|
|
1942
|
+
}
|
|
1943
|
+
],
|
|
1944
|
+
isError: true
|
|
1945
|
+
};
|
|
1946
|
+
}
|
|
1947
|
+
try {
|
|
1948
|
+
const tickets = await ctx.client.query(
|
|
1949
|
+
anyApi2.functions.localPipeline.getExistingTicketTitles,
|
|
1950
|
+
{ projectId }
|
|
1951
|
+
);
|
|
1952
|
+
if (!tickets || tickets.length === 0) {
|
|
1953
|
+
return {
|
|
1954
|
+
content: [
|
|
1955
|
+
{
|
|
1956
|
+
type: "text",
|
|
1957
|
+
text: "No existing tickets in this project."
|
|
1958
|
+
}
|
|
1959
|
+
]
|
|
1960
|
+
};
|
|
1961
|
+
}
|
|
1962
|
+
return {
|
|
1963
|
+
content: [
|
|
1964
|
+
{
|
|
1965
|
+
type: "text",
|
|
1966
|
+
text: JSON.stringify(tickets, null, 2)
|
|
1967
|
+
}
|
|
1968
|
+
]
|
|
1969
|
+
};
|
|
1970
|
+
} catch (err) {
|
|
1971
|
+
return {
|
|
1972
|
+
content: [
|
|
1973
|
+
{
|
|
1974
|
+
type: "text",
|
|
1975
|
+
text: `Error fetching existing tickets: ${err.message}`
|
|
1976
|
+
}
|
|
1977
|
+
],
|
|
1978
|
+
isError: true
|
|
1979
|
+
};
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
);
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
// src/mcp/tools/save-enrichment.ts
|
|
1986
|
+
import { z as z11 } from "zod";
|
|
1987
|
+
function registerSaveEnrichmentTool(server, ctx) {
|
|
1988
|
+
server.tool(
|
|
1989
|
+
"yapout_save_enrichment",
|
|
1990
|
+
`Save enrichment and sync to Linear. Call this after you have read the codebase, asked any necessary clarifying questions, and formulated a clean description, acceptance criteria, and implementation brief.
|
|
1991
|
+
|
|
1992
|
+
The ticket must have been claimed via yapout_get_unenriched_ticket first (status must be "enriching").
|
|
1993
|
+
|
|
1994
|
+
This tool saves the enrichment, then automatically creates the Linear issue with:
|
|
1995
|
+
- Clean description + acceptance criteria as the issue body
|
|
1996
|
+
- Clarification Q&A as a branded comment (if any)
|
|
1997
|
+
- Implementation brief as attachment metadata
|
|
1998
|
+
|
|
1999
|
+
The ticket transitions: enriching \u2192 enriched \u2192 synced.`,
|
|
2000
|
+
{
|
|
2001
|
+
ticketId: z11.string().describe("The ticket ID to enrich (from yapout_get_unenriched_ticket)"),
|
|
2002
|
+
title: z11.string().describe("Refined ticket title \u2014 improve it if the original was vague"),
|
|
2003
|
+
cleanDescription: z11.string().describe("Human-readable summary for the Linear issue body. Write the kind of ticket a senior engineer would write."),
|
|
2004
|
+
acceptanceCriteria: z11.array(z11.string()).describe("List of testable acceptance criteria (each a single clear statement)"),
|
|
2005
|
+
implementationBrief: z11.string().describe("Deep technical context for the implementing agent: which files, what approach, edge cases to watch for"),
|
|
2006
|
+
clarifications: z11.array(
|
|
2007
|
+
z11.object({
|
|
2008
|
+
question: z11.string().describe("The question you asked the developer"),
|
|
2009
|
+
answer: z11.string().describe("The developer's answer")
|
|
2010
|
+
})
|
|
2011
|
+
).optional().describe("Only meaningful Q&A from the conversation \u2014 deviations from expected scope, scoping decisions, etc. Omit if you had no questions."),
|
|
2012
|
+
isOversized: z11.boolean().optional().describe("Set to true if this ticket is too large for a single PR"),
|
|
2013
|
+
suggestedSplit: z11.array(z11.string()).optional().describe("If oversized: suggested sub-ticket titles for breaking it down"),
|
|
2014
|
+
level: z11.enum(["project", "issue"]).optional().describe("Override the decision's level if enrichment reveals it should be reclassified"),
|
|
2015
|
+
nature: z11.enum(["implementable", "operational", "spike"]).optional().describe("Override the decision's nature if enrichment reveals it should be reclassified")
|
|
2016
|
+
},
|
|
2017
|
+
async (args) => {
|
|
2018
|
+
try {
|
|
2019
|
+
await ctx.client.mutation(
|
|
2020
|
+
anyApi2.functions.localPipeline.saveLocalEnrichment,
|
|
2021
|
+
{
|
|
2022
|
+
ticketId: args.ticketId,
|
|
2023
|
+
title: args.title,
|
|
2024
|
+
cleanDescription: args.cleanDescription,
|
|
2025
|
+
acceptanceCriteria: args.acceptanceCriteria,
|
|
2026
|
+
implementationBrief: args.implementationBrief,
|
|
2027
|
+
clarifications: args.clarifications,
|
|
2028
|
+
isOversized: args.isOversized,
|
|
2029
|
+
suggestedSplit: args.suggestedSplit,
|
|
2030
|
+
level: args.level,
|
|
2031
|
+
nature: args.nature
|
|
2032
|
+
}
|
|
2033
|
+
);
|
|
2034
|
+
await ctx.client.action(
|
|
2035
|
+
anyApi2.functions.localPipeline.syncTicketToLinearLocal,
|
|
2036
|
+
{ ticketId: args.ticketId }
|
|
2037
|
+
);
|
|
2038
|
+
const ticket = await ctx.client.query(
|
|
2039
|
+
anyApi2.functions.tickets.getTicket,
|
|
2040
|
+
{ ticketId: args.ticketId }
|
|
2041
|
+
);
|
|
2042
|
+
const response = {
|
|
2043
|
+
ticketId: args.ticketId,
|
|
2044
|
+
linearIssueId: ticket?.linearTicketId ?? null,
|
|
2045
|
+
linearUrl: ticket?.linearUrl ?? null,
|
|
2046
|
+
message: ticket?.linearUrl ? `Ticket enriched and synced to Linear: ${ticket.linearUrl}` : "Ticket enriched and synced to Linear."
|
|
2047
|
+
};
|
|
2048
|
+
if (args.isOversized && args.suggestedSplit?.length) {
|
|
2049
|
+
response.warning = `This ticket is oversized. Suggested split: ${args.suggestedSplit.join(", ")}`;
|
|
2050
|
+
}
|
|
2051
|
+
return {
|
|
2052
|
+
content: [
|
|
2053
|
+
{
|
|
2054
|
+
type: "text",
|
|
2055
|
+
text: JSON.stringify(response, null, 2)
|
|
2056
|
+
}
|
|
2057
|
+
]
|
|
2058
|
+
};
|
|
2059
|
+
} catch (err) {
|
|
2060
|
+
return {
|
|
2061
|
+
content: [
|
|
2062
|
+
{
|
|
2063
|
+
type: "text",
|
|
2064
|
+
text: `Error saving enrichment: ${err.message}`
|
|
2065
|
+
}
|
|
2066
|
+
],
|
|
2067
|
+
isError: true
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
);
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
// src/mcp/tools/sync-to-linear.ts
|
|
2075
|
+
import { z as z12 } from "zod";
|
|
2076
|
+
function registerSyncToLinearTool(server, ctx) {
|
|
2077
|
+
server.tool(
|
|
2078
|
+
"yapout_sync_to_linear",
|
|
2079
|
+
"Trigger Linear ticket creation for an enriched ticket. The sync runs server-side (encrypted Linear token in Convex).",
|
|
2080
|
+
{
|
|
2081
|
+
ticketId: z12.string().describe("The ticket ID to sync to Linear")
|
|
2082
|
+
},
|
|
2083
|
+
async (args) => {
|
|
2084
|
+
try {
|
|
2085
|
+
await ctx.client.action(
|
|
2086
|
+
anyApi2.functions.localPipeline.syncTicketToLinearLocal,
|
|
2087
|
+
{ ticketId: args.ticketId }
|
|
2088
|
+
);
|
|
2089
|
+
return {
|
|
2090
|
+
content: [
|
|
2091
|
+
{
|
|
2092
|
+
type: "text",
|
|
2093
|
+
text: JSON.stringify(
|
|
2094
|
+
{
|
|
2095
|
+
success: true,
|
|
2096
|
+
ticketId: args.ticketId,
|
|
2097
|
+
message: "Ticket synced to Linear successfully. It will now appear in the work queue for implementation."
|
|
2098
|
+
},
|
|
2099
|
+
null,
|
|
2100
|
+
2
|
|
2101
|
+
)
|
|
2102
|
+
}
|
|
2103
|
+
]
|
|
2104
|
+
};
|
|
2105
|
+
} catch (err) {
|
|
2106
|
+
return {
|
|
2107
|
+
content: [
|
|
2108
|
+
{
|
|
2109
|
+
type: "text",
|
|
2110
|
+
text: `Error syncing to Linear: ${err.message}`
|
|
2111
|
+
}
|
|
2112
|
+
],
|
|
2113
|
+
isError: true
|
|
2114
|
+
};
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
);
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
// src/mcp/tools/submit-yap-session.ts
|
|
2121
|
+
import { z as z13 } from "zod";
|
|
2122
|
+
function registerSubmitYapSessionTool(server, ctx) {
|
|
2123
|
+
server.tool(
|
|
2124
|
+
"yapout_submit_yap_session",
|
|
2125
|
+
"Submit a yap session transcript for decision extraction",
|
|
2126
|
+
{
|
|
2127
|
+
title: z13.string().describe("Session title (e.g., 'Notification system brainstorm')"),
|
|
2128
|
+
transcript: z13.string().describe("Cleaned conversation transcript")
|
|
2129
|
+
},
|
|
2130
|
+
async (args) => {
|
|
2131
|
+
if (!ctx.projectId) {
|
|
2132
|
+
return {
|
|
2133
|
+
content: [
|
|
2134
|
+
{
|
|
2135
|
+
type: "text",
|
|
2136
|
+
text: "No project linked. Run yapout_init or yapout link first."
|
|
2137
|
+
}
|
|
2138
|
+
],
|
|
2139
|
+
isError: true
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
try {
|
|
2143
|
+
const transcriptId = await ctx.client.mutation(
|
|
2144
|
+
anyApi2.functions.transcripts.createFromYapSession,
|
|
2145
|
+
{
|
|
2146
|
+
projectId: ctx.projectId,
|
|
2147
|
+
title: args.title,
|
|
2148
|
+
transcript: args.transcript
|
|
2149
|
+
}
|
|
2150
|
+
);
|
|
2151
|
+
return {
|
|
2152
|
+
content: [
|
|
2153
|
+
{
|
|
2154
|
+
type: "text",
|
|
2155
|
+
text: JSON.stringify(
|
|
2156
|
+
{
|
|
2157
|
+
transcriptId,
|
|
2158
|
+
message: "Yap session submitted. Decisions will appear for review shortly."
|
|
2159
|
+
},
|
|
2160
|
+
null,
|
|
2161
|
+
2
|
|
2162
|
+
)
|
|
2163
|
+
}
|
|
2164
|
+
]
|
|
2165
|
+
};
|
|
2166
|
+
} catch (err) {
|
|
2167
|
+
return {
|
|
2168
|
+
content: [
|
|
2169
|
+
{
|
|
2170
|
+
type: "text",
|
|
2171
|
+
text: `Failed to submit yap session: ${err.message}`
|
|
2172
|
+
}
|
|
2173
|
+
],
|
|
2174
|
+
isError: true
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
);
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
// src/mcp/tools/start-yap.ts
|
|
2182
|
+
import { z as z14 } from "zod";
|
|
2183
|
+
var PERSONA_PRESETS = {
|
|
2184
|
+
"tech lead": "You are an experienced tech lead. You care about maintainability, simplicity, and shipping. Challenge over-engineering and vague scope.",
|
|
2185
|
+
"qa engineer": "You are a skeptical QA engineer. Focus on error states, edge cases, missing validation, accessibility, and user-facing failure modes.",
|
|
2186
|
+
"product owner": "You are a product owner. Focus on user value, scope, prioritization, and whether features solve real problems. Push back on technical gold-plating.",
|
|
2187
|
+
"end user": "You are an end user of this application. You're not technical. Focus on usability, clarity, frustration points, and what you'd expect to happen."
|
|
2188
|
+
};
|
|
2189
|
+
function resolvePersona(input) {
|
|
2190
|
+
const lower = input.toLowerCase().trim();
|
|
2191
|
+
return PERSONA_PRESETS[lower] ?? input;
|
|
2192
|
+
}
|
|
2193
|
+
function registerStartYapTool(server, ctx) {
|
|
2194
|
+
server.tool(
|
|
2195
|
+
"yapout_start_yap",
|
|
2196
|
+
"Start a yap session \u2014 a structured AI-guided brainstorming conversation. Call this when the user wants to brainstorm, discuss ideas, or have a yap session. Returns instructions for how to conduct the session. Available personas: tech lead (default), qa engineer, product owner, end user, or any custom description.",
|
|
2197
|
+
{
|
|
2198
|
+
persona: z14.string().optional().describe(
|
|
2199
|
+
'Interviewer persona: "tech lead", "qa engineer", "product owner", "end user", or a custom description'
|
|
2200
|
+
),
|
|
2201
|
+
context: z14.string().optional().describe("What the developer wants to discuss")
|
|
2202
|
+
},
|
|
2203
|
+
async (args) => {
|
|
2204
|
+
if (!ctx.projectId) {
|
|
2205
|
+
return {
|
|
2206
|
+
content: [
|
|
2207
|
+
{
|
|
2208
|
+
type: "text",
|
|
2209
|
+
text: "No project linked. Run yapout_init or yapout link first."
|
|
2210
|
+
}
|
|
2211
|
+
],
|
|
2212
|
+
isError: true
|
|
2213
|
+
};
|
|
2214
|
+
}
|
|
2215
|
+
const persona = args.persona ?? "tech lead";
|
|
2216
|
+
const personaBlock = resolvePersona(persona);
|
|
2217
|
+
const contextSection = args.context ? `The developer wants to discuss: ${args.context}. Start by reading relevant parts of the codebase, then open with a specific question about their idea.` : "Start by asking the developer what they'd like to discuss. Read the codebase first to understand the project.";
|
|
2218
|
+
const instructions = `You are now in a yapout yap session \u2014 a structured brainstorming conversation about this codebase.
|
|
2219
|
+
|
|
2220
|
+
YOUR ROLE: ${personaBlock}
|
|
2221
|
+
|
|
2222
|
+
WHAT YOU DO:
|
|
2223
|
+
- Have a natural, focused conversation about the developer's idea
|
|
2224
|
+
- Read files from the codebase to inform your questions (package.json, schema, relevant source files)
|
|
2225
|
+
- Ask ONE question at a time \u2014 don't overwhelm
|
|
2226
|
+
- Push back on vague statements: "What do you mean by 'better'? Better for whom?"
|
|
2227
|
+
- Surface edge cases: "What happens when the user has no internet?"
|
|
2228
|
+
- Reference actual code: "I see you have a stateMachines.ts \u2014 should this new status go there?"
|
|
2229
|
+
- When the developer makes a clear decision, acknowledge it and move to the next topic
|
|
2230
|
+
- Keep the conversation productive \u2014 steer away from tangents
|
|
2231
|
+
|
|
2232
|
+
WHAT YOU DON'T DO:
|
|
2233
|
+
- Don't implement anything. Don't write code. Don't edit files. Just discuss.
|
|
2234
|
+
- Don't agree with everything. Challenge ideas that seem undercooked.
|
|
2235
|
+
- Don't ask more than 5 questions without letting the developer steer.
|
|
2236
|
+
- Don't make changes to the codebase, even if the user asks. A yap session produces decisions and tickets \u2014 implementation happens later through the normal ticket pipeline. If the user asks you to "just do it" or "make that change now," push back: "That's what the tickets are for. Let's capture it properly so it goes through review." This boundary is critical \u2014 implementing during a yap bypasses every approval gate yapout exists to enforce.
|
|
2237
|
+
|
|
2238
|
+
${contextSection}
|
|
2239
|
+
|
|
2240
|
+
DURING THE SESSION \u2014 COMPLETENESS TRACKING:
|
|
2241
|
+
As the conversation progresses, you are building a mental model of every decision. For each decision, track:
|
|
2242
|
+
- What has been clearly stated (title, scope, priority)
|
|
2243
|
+
- What has been discussed in enough depth to write a ticket (enrichment)
|
|
2244
|
+
- What is still vague, contradicted, or unanswered
|
|
2245
|
+
|
|
2246
|
+
The conversation does NOT need to follow a rigid structure. The user may jump between topics, change their mind, go on tangents, or revisit earlier decisions. This is normal \u2014 real conversations are not linear. Your job is to follow the thread and keep track of the state of each decision regardless of conversation order.
|
|
2247
|
+
|
|
2248
|
+
If the user contradicts an earlier statement, note it and confirm which version they mean when the time is right. Don't interrupt the flow for minor clarifications \u2014 batch them for later.
|
|
2249
|
+
|
|
2250
|
+
BEFORE SUBMITTING \u2014 GAP FILLING:
|
|
2251
|
+
Before you submit, review your mental model of every decision. For each one, ask yourself:
|
|
2252
|
+
- Could a developer read this ticket and start working without asking questions?
|
|
2253
|
+
- Are the acceptance criteria specific enough to verify?
|
|
2254
|
+
- Are there ambiguities the user didn't resolve?
|
|
2255
|
+
|
|
2256
|
+
If there are gaps, ask the user to fill them. Be direct: "Before I submit, I need clarity on a few things..." Group related gaps together rather than asking one at a time.
|
|
2257
|
+
|
|
2258
|
+
You do NOT need to fill every gap. If the conversation didn't cover something deeply enough, mark that decision as NOT enriched \u2014 it will go through the async enrichment pipeline later. Be honest about what you know vs. what you're guessing.
|
|
2259
|
+
|
|
2260
|
+
ENRICHMENT ASSESSMENT:
|
|
2261
|
+
For each decision, you must make an honest call: is this enriched or not?
|
|
2262
|
+
|
|
2263
|
+
Mark a decision as ENRICHED (isEnriched: true) when:
|
|
2264
|
+
- The conversation covered it thoroughly enough to produce a clear ticket
|
|
2265
|
+
- You can write an enrichedDescription that a senior engineer would recognize as well-scoped
|
|
2266
|
+
- You can write at least 2-3 testable acceptance criteria
|
|
2267
|
+
- The user explicitly validated the scope (not just mentioned it in passing)
|
|
2268
|
+
|
|
2269
|
+
Mark a decision as NOT ENRICHED (isEnriched: false or omitted) when:
|
|
2270
|
+
- It came up late in the conversation without much discussion
|
|
2271
|
+
- The user mentioned it but didn't elaborate on scope or requirements
|
|
2272
|
+
- You're uncertain about key aspects (what it should do, how it should work)
|
|
2273
|
+
- It's a spike or needs further scoping
|
|
2274
|
+
|
|
2275
|
+
This is a quality gate. Do not inflate your assessment \u2014 unenriched tickets get enriched properly later. Falsely marking something as enriched skips that process and produces bad tickets.
|
|
2276
|
+
|
|
2277
|
+
PRESENTING THE FINAL PICTURE:
|
|
2278
|
+
When the conversation feels complete, present a summary grouped by project:
|
|
2279
|
+
|
|
2280
|
+
"Here's what I've gathered from our conversation:
|
|
2281
|
+
|
|
2282
|
+
**[Project Name]** \u2014 [one-line description]
|
|
2283
|
+
1. [Child issue] \u2014 enriched \u2713
|
|
2284
|
+
2. [Child issue] \u2014 enriched \u2713
|
|
2285
|
+
3. [Child issue] \u2014 needs enrichment (we didn't discuss scope)
|
|
2286
|
+
|
|
2287
|
+
**Standalone issues:**
|
|
2288
|
+
4. [Issue] \u2014 enriched \u2713
|
|
2289
|
+
|
|
2290
|
+
[Any open questions you still need answered]
|
|
2291
|
+
|
|
2292
|
+
Should I submit this to yapout?"
|
|
2293
|
+
|
|
2294
|
+
SUBMITTING:
|
|
2295
|
+
On confirmation, call yapout_extract_from_yap with the full data.
|
|
2296
|
+
|
|
2297
|
+
For each decision, provide:
|
|
2298
|
+
- title, description, sourceQuote, type, priority, confidence, level, nature
|
|
2299
|
+
- isEnriched: your honest assessment (see above)
|
|
2300
|
+
|
|
2301
|
+
For ENRICHED decisions (isEnriched: true), also include:
|
|
2302
|
+
- enrichedDescription: clean, final description \u2014 write the kind of ticket a senior engineer would write
|
|
2303
|
+
- acceptanceCriteria: array of specific, testable statements
|
|
2304
|
+
- implementationBrief: (optional) technical context \u2014 files, approach, edge cases. Only include if you read the codebase during the session. The implementing agent reads the codebase anyway.
|
|
2305
|
+
- clarifications: relevant Q&A from the conversation that shaped the decision
|
|
2306
|
+
|
|
2307
|
+
For PROJECT-level decisions, also include:
|
|
2308
|
+
- projectDescription: what this body of work accomplishes
|
|
2309
|
+
- suggestedOrder: implementation sequencing ("Phase 1: A, then Phase 2: B+C")
|
|
2310
|
+
- children: array of child issues (each with their own enrichment data)
|
|
2311
|
+
|
|
2312
|
+
For children with DEPENDENCIES, include:
|
|
2313
|
+
- dependsOn: array of sibling indices (0-based) that must complete first
|
|
2314
|
+
|
|
2315
|
+
Structure:
|
|
2316
|
+
- Projects include children inline \u2014 never create child issues as separate top-level decisions
|
|
2317
|
+
- Standalone issues go at the top level
|
|
2318
|
+
- The hierarchy you produce should be the final structure
|
|
2319
|
+
|
|
2320
|
+
Call yapout_extract_from_yap with:
|
|
2321
|
+
- sessionTitle: descriptive title for this session
|
|
2322
|
+
- sessionTranscript: clean summary of the conversation (meeting-notes style)
|
|
2323
|
+
- decisions: the array (projects with children, standalone issues)
|
|
2324
|
+
|
|
2325
|
+
This creates decisions (as converted \u2014 your participation was the approval), tickets (enriched or draft based on your assessment), and project decomposition \u2014 all in one call. Enriched tickets appear in the work queue ready for the user to sync to Linear.
|
|
2326
|
+
|
|
2327
|
+
Only fall back to yapout_submit_yap_session if the session was purely exploratory with no clear actionable decisions.
|
|
2328
|
+
|
|
2329
|
+
--- BEGIN THE SESSION NOW ---`;
|
|
2330
|
+
return {
|
|
2331
|
+
content: [
|
|
2332
|
+
{
|
|
2333
|
+
type: "text",
|
|
2334
|
+
text: JSON.stringify(
|
|
2335
|
+
{
|
|
2336
|
+
projectId: ctx.projectId,
|
|
2337
|
+
projectName: ctx.projectName,
|
|
2338
|
+
persona,
|
|
2339
|
+
instructions
|
|
2340
|
+
},
|
|
2341
|
+
null,
|
|
2342
|
+
2
|
|
2343
|
+
)
|
|
2344
|
+
}
|
|
2345
|
+
]
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
);
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
// src/mcp/tools/extract-from-yap.ts
|
|
2352
|
+
import { z as z15 } from "zod";
|
|
2353
|
+
var clarificationSchema = z15.object({
|
|
2354
|
+
question: z15.string().describe("The question asked during the conversation"),
|
|
2355
|
+
answer: z15.string().describe("The answer given")
|
|
2356
|
+
});
|
|
2357
|
+
var childSchema = z15.object({
|
|
2358
|
+
title: z15.string().describe("Child issue title"),
|
|
2359
|
+
description: z15.string().describe("What needs to be done and why"),
|
|
2360
|
+
sourceQuote: z15.string().describe("Relevant excerpt from the conversation"),
|
|
2361
|
+
type: z15.enum(["feature", "bug", "chore"]).describe("Decision category"),
|
|
2362
|
+
priority: z15.enum(["urgent", "high", "medium", "low"]).describe("Priority level"),
|
|
2363
|
+
confidence: z15.number().min(0).max(1).describe("Confidence this is a clear, actionable decision (0-1)"),
|
|
2364
|
+
nature: z15.enum(["implementable", "operational", "spike"]).describe("What kind of work"),
|
|
2365
|
+
// Enrichment — set isEnriched: true only if the conversation covered this
|
|
2366
|
+
// issue thoroughly enough to produce a ticket a developer could pick up.
|
|
2367
|
+
isEnriched: z15.boolean().optional().describe(
|
|
2368
|
+
"Did the conversation cover this issue deeply enough to produce a complete ticket? true = enriched (ready for Linear), false/omitted = draft (needs async enrichment)"
|
|
2369
|
+
),
|
|
2370
|
+
enrichedDescription: z15.string().optional().describe("Clean, final description for the Linear issue body (required if isEnriched)"),
|
|
2371
|
+
acceptanceCriteria: z15.array(z15.string()).optional().describe("Testable acceptance criteria (required if isEnriched)"),
|
|
2372
|
+
implementationBrief: z15.string().optional().describe("Technical context: files, approach, edge cases (optional \u2014 the implementing agent reads the codebase anyway)"),
|
|
2373
|
+
clarifications: z15.array(clarificationSchema).optional().describe("Relevant Q&A from the conversation that shaped this decision"),
|
|
2374
|
+
dependsOn: z15.array(z15.number()).optional().describe("Indices (0-based) of sibling children that must be completed first")
|
|
2375
|
+
});
|
|
2376
|
+
function registerExtractFromYapTool(server, ctx) {
|
|
2377
|
+
server.tool(
|
|
2378
|
+
"yapout_extract_from_yap",
|
|
2379
|
+
"Submit decisions from a yap session you conducted. The yap session IS the enrichment \u2014 you participated in the conversation, gathered context, and can assess completeness per-decision. Creates transcript, decisions (as converted), and tickets (enriched or draft). Enriched tickets are ready for the user to sync to Linear from the UI. Draft tickets need further enrichment via the async pipeline.",
|
|
2380
|
+
{
|
|
2381
|
+
sessionTitle: z15.string().describe("Descriptive title for this yap session"),
|
|
2382
|
+
sessionTranscript: z15.string().describe("Cleaned transcript of the conversation (meeting-notes style)"),
|
|
2383
|
+
decisions: z15.array(
|
|
2384
|
+
z15.object({
|
|
2385
|
+
title: z15.string().describe("Decision title"),
|
|
2386
|
+
description: z15.string().describe("What needs to be done and why"),
|
|
2387
|
+
sourceQuote: z15.string().describe("Relevant excerpt from the conversation"),
|
|
2388
|
+
type: z15.enum(["feature", "bug", "chore"]).describe("Decision category (do not use spike \u2014 use nature instead)"),
|
|
2389
|
+
priority: z15.enum(["urgent", "high", "medium", "low"]).describe("Priority level"),
|
|
2390
|
+
confidence: z15.number().min(0).max(1).describe("Your confidence this is a clear, actionable decision (0-1)"),
|
|
2391
|
+
level: z15.enum(["project", "issue"]).describe("project = body of work with children, issue = single unit"),
|
|
2392
|
+
nature: z15.enum(["implementable", "operational", "spike"]).describe("implementable = code changes, operational = manual task, spike = needs scoping"),
|
|
2393
|
+
sourceDecisionId: z15.string().optional().describe("ID of an existing decision this scopes (e.g., scoping a spike)"),
|
|
2394
|
+
// Issue-level enrichment (when level === "issue")
|
|
2395
|
+
isEnriched: z15.boolean().optional().describe("For standalone issues: was this thoroughly discussed? true = enriched, false = draft"),
|
|
2396
|
+
enrichedDescription: z15.string().optional().describe("Clean description for Linear (standalone issues only, required if isEnriched)"),
|
|
2397
|
+
acceptanceCriteria: z15.array(z15.string()).optional().describe("Testable acceptance criteria (standalone issues only, required if isEnriched)"),
|
|
2398
|
+
implementationBrief: z15.string().optional().describe("Technical context (standalone issues only, optional)"),
|
|
2399
|
+
clarifications: z15.array(clarificationSchema).optional().describe("Relevant Q&A from conversation (standalone issues only)"),
|
|
2400
|
+
// Project-level fields (when level === "project")
|
|
2401
|
+
projectDescription: z15.string().optional().describe("What this body of work accomplishes (projects only)"),
|
|
2402
|
+
suggestedOrder: z15.string().optional().describe("Implementation order: 'Phase 1: A, then Phase 2: B+C' (projects only)"),
|
|
2403
|
+
children: z15.array(childSchema).optional().describe("Child issues for project-level decisions")
|
|
2404
|
+
})
|
|
2405
|
+
).describe("Decisions from the conversation. Projects include children inline.")
|
|
2406
|
+
},
|
|
2407
|
+
async (args) => {
|
|
2408
|
+
if (!ctx.projectId) {
|
|
2409
|
+
return {
|
|
2410
|
+
content: [
|
|
2411
|
+
{
|
|
2412
|
+
type: "text",
|
|
2413
|
+
text: "No project linked. Run yapout_init or yapout link first."
|
|
2414
|
+
}
|
|
2415
|
+
],
|
|
2416
|
+
isError: true
|
|
2417
|
+
};
|
|
2418
|
+
}
|
|
2419
|
+
try {
|
|
2420
|
+
const result = await ctx.client.mutation(
|
|
2421
|
+
anyApi2.functions.transcripts.extractFromYapSession,
|
|
2422
|
+
{
|
|
2423
|
+
projectId: ctx.projectId,
|
|
2424
|
+
title: args.sessionTitle,
|
|
2425
|
+
transcript: args.sessionTranscript,
|
|
2426
|
+
decisions: args.decisions
|
|
2427
|
+
}
|
|
2428
|
+
);
|
|
2429
|
+
let enrichedCount = 0;
|
|
2430
|
+
let draftCount = 0;
|
|
2431
|
+
let totalTickets = 0;
|
|
2432
|
+
for (const item of result.items) {
|
|
2433
|
+
if (item.level === "project") {
|
|
2434
|
+
for (const child of item.children ?? []) {
|
|
2435
|
+
totalTickets++;
|
|
2436
|
+
if (child.ticketStatus === "enriched") enrichedCount++;
|
|
2437
|
+
else draftCount++;
|
|
2438
|
+
}
|
|
2439
|
+
} else {
|
|
2440
|
+
totalTickets++;
|
|
2441
|
+
if (item.ticketStatus === "enriched") enrichedCount++;
|
|
2442
|
+
else draftCount++;
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
const parts = [];
|
|
2446
|
+
if (enrichedCount > 0) parts.push(`${enrichedCount} enriched (ready for Linear)`);
|
|
2447
|
+
if (draftCount > 0) parts.push(`${draftCount} need enrichment`);
|
|
2448
|
+
return {
|
|
2449
|
+
content: [
|
|
2450
|
+
{
|
|
2451
|
+
type: "text",
|
|
2452
|
+
text: JSON.stringify(
|
|
2453
|
+
{
|
|
2454
|
+
transcriptId: result.transcriptId,
|
|
2455
|
+
items: result.items,
|
|
2456
|
+
summary: {
|
|
2457
|
+
totalTickets,
|
|
2458
|
+
enriched: enrichedCount,
|
|
2459
|
+
needsEnrichment: draftCount
|
|
2460
|
+
},
|
|
2461
|
+
message: `Created ${totalTickets} ticket${totalTickets === 1 ? "" : "s"}: ${parts.join(", ")}. Review in the yapout work queue.`
|
|
2462
|
+
},
|
|
2463
|
+
null,
|
|
2464
|
+
2
|
|
2465
|
+
)
|
|
2466
|
+
}
|
|
2467
|
+
]
|
|
2468
|
+
};
|
|
2469
|
+
} catch (err) {
|
|
2470
|
+
return {
|
|
2471
|
+
content: [
|
|
2472
|
+
{
|
|
2473
|
+
type: "text",
|
|
2474
|
+
text: `Failed to extract from yap session: ${err.message}`
|
|
2475
|
+
}
|
|
2476
|
+
],
|
|
2477
|
+
isError: true
|
|
2478
|
+
};
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
);
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
// src/mcp/tools/save-project-enrichment.ts
|
|
2485
|
+
import { z as z16 } from "zod";
|
|
2486
|
+
function registerSaveProjectEnrichmentTool(server, ctx) {
|
|
2487
|
+
server.tool(
|
|
2488
|
+
"yapout_save_project_enrichment",
|
|
2489
|
+
`Save the decomposition of a project-level decision into implementable child issues.
|
|
2490
|
+
|
|
2491
|
+
Call this after enriching a ticket with level: "project". The agent should have:
|
|
2492
|
+
1. Read the codebase extensively
|
|
2493
|
+
2. Asked the user scoping questions
|
|
2494
|
+
3. Produced a set of child issues with dependencies
|
|
2495
|
+
|
|
2496
|
+
This tool creates child decisions and tickets in yapout, transitions the original
|
|
2497
|
+
ticket to "decomposed", and returns the child ticket IDs for subsequent Linear sync.
|
|
2498
|
+
|
|
2499
|
+
Each child issue's implementation brief must be detailed enough to stand alone as a
|
|
2500
|
+
full spec \u2014 schema changes, files to modify, edge cases, and acceptance criteria.`,
|
|
2501
|
+
{
|
|
2502
|
+
ticketId: z16.string().describe("The project-level ticket being decomposed (from yapout_get_unenriched_ticket)"),
|
|
2503
|
+
projectDescription: z16.string().describe("Description for the project \u2014 what this body of work accomplishes"),
|
|
2504
|
+
suggestedOrder: z16.string().describe("Human-readable implementation order (e.g. 'Phase 1: A, then Phase 2: B+C in parallel, then Phase 3: D')"),
|
|
2505
|
+
linearProjectId: z16.string().optional().describe("Existing Linear project ID to associate child issues with. Omit to skip Linear project association \u2014 issues will be synced without a project."),
|
|
2506
|
+
issues: z16.array(
|
|
2507
|
+
z16.object({
|
|
2508
|
+
title: z16.string().describe("Child issue title"),
|
|
2509
|
+
description: z16.string().describe("What needs to be done and why"),
|
|
2510
|
+
acceptanceCriteria: z16.array(z16.string()).describe("Testable acceptance criteria"),
|
|
2511
|
+
implementationBrief: z16.string().describe("Full spec: files to modify, schema changes, edge cases, approach"),
|
|
2512
|
+
type: z16.enum(["feature", "bug", "chore"]).describe("Category of work"),
|
|
2513
|
+
priority: z16.enum(["urgent", "high", "medium", "low"]).describe("Priority level"),
|
|
2514
|
+
dependsOn: z16.array(z16.number()).describe("Indices (0-based) of issues in this array that must be completed first")
|
|
2515
|
+
})
|
|
2516
|
+
).describe("The child issues produced by decomposition")
|
|
2517
|
+
},
|
|
2518
|
+
async (args) => {
|
|
2519
|
+
if (!ctx.projectId) {
|
|
2520
|
+
return {
|
|
2521
|
+
content: [
|
|
2522
|
+
{
|
|
2523
|
+
type: "text",
|
|
2524
|
+
text: "No project linked. Run yapout_init or yapout link first."
|
|
2525
|
+
}
|
|
2526
|
+
],
|
|
2527
|
+
isError: true
|
|
2528
|
+
};
|
|
2529
|
+
}
|
|
2530
|
+
try {
|
|
2531
|
+
const result = await ctx.client.mutation(
|
|
2532
|
+
anyApi2.functions.localPipeline.saveProjectEnrichment,
|
|
2533
|
+
{
|
|
2534
|
+
ticketId: args.ticketId,
|
|
2535
|
+
projectDescription: args.projectDescription,
|
|
2536
|
+
suggestedOrder: args.suggestedOrder,
|
|
2537
|
+
linearProjectId: args.linearProjectId,
|
|
2538
|
+
issues: args.issues
|
|
2539
|
+
}
|
|
2540
|
+
);
|
|
2541
|
+
return {
|
|
2542
|
+
content: [
|
|
2543
|
+
{
|
|
2544
|
+
type: "text",
|
|
2545
|
+
text: JSON.stringify(
|
|
2546
|
+
{
|
|
2547
|
+
ticketId: result.ticketId,
|
|
2548
|
+
status: "decomposed",
|
|
2549
|
+
childTicketIds: result.childTicketIds,
|
|
2550
|
+
suggestedOrder: result.suggestedOrder,
|
|
2551
|
+
message: `Project decomposed into ${result.childTicketIds.length} child issues. Original ticket marked as decomposed. Child tickets are in draft status \u2014 enrich and sync each one individually, or approve them for the user to review.`
|
|
2552
|
+
},
|
|
2553
|
+
null,
|
|
2554
|
+
2
|
|
2555
|
+
)
|
|
2556
|
+
}
|
|
2557
|
+
]
|
|
2558
|
+
};
|
|
2559
|
+
} catch (err) {
|
|
2560
|
+
return {
|
|
2561
|
+
content: [
|
|
2562
|
+
{
|
|
2563
|
+
type: "text",
|
|
2564
|
+
text: `Error saving project enrichment: ${err.message}`
|
|
2565
|
+
}
|
|
2566
|
+
],
|
|
2567
|
+
isError: true
|
|
2568
|
+
};
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
);
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
// src/mcp/tools/mark-duplicate.ts
|
|
2575
|
+
import { z as z17 } from "zod";
|
|
2576
|
+
function registerMarkDuplicateTool(server, ctx) {
|
|
2577
|
+
server.tool(
|
|
2578
|
+
"yapout_mark_duplicate",
|
|
2579
|
+
`Mark a ticket as a duplicate of existing Linear work. Use this during any enrichment
|
|
2580
|
+
flow when you discover the work is already tracked in Linear.
|
|
2581
|
+
|
|
2582
|
+
The ticket must be in "enriching" or "enriched" status. It will be transitioned to
|
|
2583
|
+
"failed" with the duplicate reference stored. Nothing is synced to Linear.`,
|
|
2584
|
+
{
|
|
2585
|
+
ticketId: z17.string().describe("The yapout ticket to archive as a duplicate"),
|
|
2586
|
+
duplicateOfLinearId: z17.string().describe("The Linear issue identifier it duplicates (e.g. 'ENG-234')"),
|
|
2587
|
+
reason: z17.string().describe("Brief explanation of why this is a duplicate")
|
|
2588
|
+
},
|
|
2589
|
+
async (args) => {
|
|
2590
|
+
if (!ctx.projectId) {
|
|
2591
|
+
return {
|
|
2592
|
+
content: [
|
|
2593
|
+
{
|
|
2594
|
+
type: "text",
|
|
2595
|
+
text: "No project linked. Run yapout_init or yapout link first."
|
|
2596
|
+
}
|
|
2597
|
+
],
|
|
2598
|
+
isError: true
|
|
2599
|
+
};
|
|
2600
|
+
}
|
|
2601
|
+
try {
|
|
2602
|
+
const result = await ctx.client.mutation(
|
|
2603
|
+
anyApi2.functions.tickets.markDuplicate,
|
|
2604
|
+
{
|
|
2605
|
+
ticketId: args.ticketId,
|
|
2606
|
+
duplicateOfLinearId: args.duplicateOfLinearId,
|
|
2607
|
+
reason: args.reason
|
|
2608
|
+
}
|
|
2609
|
+
);
|
|
2610
|
+
return {
|
|
2611
|
+
content: [
|
|
2612
|
+
{
|
|
2613
|
+
type: "text",
|
|
2614
|
+
text: JSON.stringify(
|
|
2615
|
+
{
|
|
2616
|
+
ticketId: result.ticketId,
|
|
2617
|
+
status: "failed",
|
|
2618
|
+
duplicateOf: result.duplicateOfLinearId,
|
|
2619
|
+
message: `Ticket archived as duplicate of ${args.duplicateOfLinearId}. Nothing synced to Linear.`
|
|
2620
|
+
},
|
|
2621
|
+
null,
|
|
2622
|
+
2
|
|
2623
|
+
)
|
|
2624
|
+
}
|
|
2625
|
+
]
|
|
2626
|
+
};
|
|
2627
|
+
} catch (err) {
|
|
2628
|
+
return {
|
|
2629
|
+
content: [
|
|
2630
|
+
{
|
|
2631
|
+
type: "text",
|
|
2632
|
+
text: `Error marking duplicate: ${err.message}`
|
|
2633
|
+
}
|
|
2634
|
+
],
|
|
2635
|
+
isError: true
|
|
2636
|
+
};
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
);
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
// src/mcp/tools/get-linear-projects.ts
|
|
2643
|
+
function registerGetLinearProjectsTool(server, ctx) {
|
|
2644
|
+
server.tool(
|
|
2645
|
+
"yapout_get_linear_projects",
|
|
2646
|
+
`Fetch active Linear projects for the current team.
|
|
2647
|
+
|
|
2648
|
+
Use this during project enrichment to check if an existing Linear project matches
|
|
2649
|
+
the one you're about to create. Returns a list of active projects with descriptions
|
|
2650
|
+
and issue counts so you can ask the user for confirmation before creating a new one.`,
|
|
2651
|
+
{},
|
|
2652
|
+
async () => {
|
|
2653
|
+
if (!ctx.projectId) {
|
|
2654
|
+
return {
|
|
2655
|
+
content: [
|
|
2656
|
+
{
|
|
2657
|
+
type: "text",
|
|
2658
|
+
text: "No project linked. Run yapout_init or yapout link first."
|
|
2659
|
+
}
|
|
2660
|
+
],
|
|
2661
|
+
isError: true
|
|
2662
|
+
};
|
|
2663
|
+
}
|
|
2664
|
+
try {
|
|
2665
|
+
const project = await ctx.client.query(
|
|
2666
|
+
anyApi2.functions.projects.getProject,
|
|
2667
|
+
{ projectId: ctx.projectId }
|
|
2668
|
+
);
|
|
2669
|
+
if (!project?.linearTeamId) {
|
|
2670
|
+
return {
|
|
2671
|
+
content: [
|
|
2672
|
+
{
|
|
2673
|
+
type: "text",
|
|
2674
|
+
text: "No Linear team configured. Go to project Settings and select a Linear team."
|
|
2675
|
+
}
|
|
2676
|
+
],
|
|
2677
|
+
isError: true
|
|
2678
|
+
};
|
|
2679
|
+
}
|
|
2680
|
+
const projects = await ctx.client.action(
|
|
2681
|
+
anyApi2.functions.linearProjectsMutations.fetchProjectsDetailed,
|
|
2682
|
+
{ teamId: project.linearTeamId }
|
|
2683
|
+
);
|
|
2684
|
+
return {
|
|
2685
|
+
content: [
|
|
2686
|
+
{
|
|
2687
|
+
type: "text",
|
|
2688
|
+
text: JSON.stringify(
|
|
2689
|
+
{
|
|
2690
|
+
teamId: project.linearTeamId,
|
|
2691
|
+
projects,
|
|
2692
|
+
message: projects.length > 0 ? `Found ${projects.length} active Linear project${projects.length === 1 ? "" : "s"}. Check if any match before creating a new one.` : "No active Linear projects found. A new one will be created during project decomposition."
|
|
2693
|
+
},
|
|
2694
|
+
null,
|
|
2695
|
+
2
|
|
2696
|
+
)
|
|
2697
|
+
}
|
|
2698
|
+
]
|
|
2699
|
+
};
|
|
2700
|
+
} catch (err) {
|
|
2701
|
+
return {
|
|
2702
|
+
content: [
|
|
2703
|
+
{
|
|
2704
|
+
type: "text",
|
|
2705
|
+
text: `Error fetching Linear projects: ${err.message}`
|
|
2706
|
+
}
|
|
2707
|
+
],
|
|
2708
|
+
isError: true
|
|
2709
|
+
};
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
);
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
// src/mcp/server.ts
|
|
2716
|
+
async function startMcpServer() {
|
|
2717
|
+
const cwd = process.cwd();
|
|
2718
|
+
const credentials = readCredentials();
|
|
2719
|
+
if (!credentials) {
|
|
2720
|
+
console.error(
|
|
2721
|
+
"[yapout] Not logged in. Run `yapout login` first."
|
|
2722
|
+
);
|
|
2723
|
+
process.exit(1);
|
|
2724
|
+
}
|
|
2725
|
+
if (Date.now() > credentials.expiresAt) {
|
|
2726
|
+
console.error(
|
|
2727
|
+
"[yapout] Session expired. Run `yapout login` to re-authenticate."
|
|
2728
|
+
);
|
|
2729
|
+
process.exit(1);
|
|
2730
|
+
}
|
|
2731
|
+
const mapping = getProjectMapping(cwd);
|
|
2732
|
+
const envProjectId = process.env.YAPOUT_PROJECT_ID;
|
|
2733
|
+
const client = new ConvexHttpClient2(getConvexUrl());
|
|
2734
|
+
client.setAuth(credentials.token);
|
|
2735
|
+
const ctx = {
|
|
2736
|
+
client,
|
|
2737
|
+
projectId: envProjectId ?? mapping?.projectId ?? null,
|
|
2738
|
+
projectName: mapping?.projectName ?? null,
|
|
2739
|
+
cwd,
|
|
2740
|
+
credentials,
|
|
2741
|
+
lastCheckPassedForRun: null
|
|
2742
|
+
};
|
|
2743
|
+
const server = new McpServer({
|
|
2744
|
+
name: "yapout",
|
|
2745
|
+
version: "0.1.0"
|
|
2746
|
+
});
|
|
2747
|
+
registerInitTool(server, ctx);
|
|
2748
|
+
registerCompactTool(server, ctx);
|
|
2749
|
+
registerUpdateContextTool(server, ctx);
|
|
2750
|
+
registerQueueTool(server, ctx);
|
|
2751
|
+
registerGetBriefTool(server, ctx);
|
|
2752
|
+
registerClaimTool(server, ctx);
|
|
2753
|
+
registerEventTool(server, ctx);
|
|
2754
|
+
registerShipTool(server, ctx);
|
|
2755
|
+
registerCheckTool(server, ctx);
|
|
2756
|
+
registerBundleTool(server, ctx);
|
|
2757
|
+
registerGetUnenrichedTicketsTool(server, ctx);
|
|
2758
|
+
registerGetExistingTicketsTool(server, ctx);
|
|
2759
|
+
registerSaveEnrichmentTool(server, ctx);
|
|
2760
|
+
registerSyncToLinearTool(server, ctx);
|
|
2761
|
+
registerSubmitYapSessionTool(server, ctx);
|
|
2762
|
+
registerStartYapTool(server, ctx);
|
|
2763
|
+
registerExtractFromYapTool(server, ctx);
|
|
2764
|
+
registerSaveProjectEnrichmentTool(server, ctx);
|
|
2765
|
+
registerMarkDuplicateTool(server, ctx);
|
|
2766
|
+
registerGetLinearProjectsTool(server, ctx);
|
|
2767
|
+
const transport = new StdioServerTransport();
|
|
2768
|
+
await server.connect(transport);
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
// src/commands/mcp-server.ts
|
|
2772
|
+
var mcpServerCommand = new Command7("mcp-server").description("Start the MCP server (used by Claude Code)").action(async () => {
|
|
2773
|
+
await startMcpServer();
|
|
2774
|
+
});
|
|
2775
|
+
|
|
2776
|
+
// src/commands/worktrees.ts
|
|
2777
|
+
import { Command as Command8 } from "commander";
|
|
2778
|
+
import chalk8 from "chalk";
|
|
2779
|
+
import { resolve as resolve5 } from "path";
|
|
2780
|
+
var worktreesCommand = new Command8("worktrees").description("List active yapout worktrees").action(() => {
|
|
2781
|
+
const cwd = resolve5(process.cwd());
|
|
2782
|
+
const worktrees = listWorktrees(cwd);
|
|
2783
|
+
if (worktrees.length === 0) {
|
|
2784
|
+
console.log(chalk8.dim("No active yapout worktrees."));
|
|
2785
|
+
return;
|
|
2786
|
+
}
|
|
2787
|
+
console.log(chalk8.bold("Active worktrees:\n"));
|
|
2788
|
+
for (const wt of worktrees) {
|
|
2789
|
+
console.log(
|
|
2790
|
+
` ${chalk8.cyan(wt.path)} ${chalk8.green(wt.branch)}` + (wt.ticketId ? ` ${chalk8.dim(`(${wt.ticketId})`)}` : "")
|
|
2791
|
+
);
|
|
2792
|
+
}
|
|
2793
|
+
console.log();
|
|
2794
|
+
});
|
|
2795
|
+
|
|
2796
|
+
// src/commands/clean.ts
|
|
2797
|
+
import { Command as Command9 } from "commander";
|
|
2798
|
+
import chalk9 from "chalk";
|
|
2799
|
+
import { resolve as resolve6 } from "path";
|
|
2800
|
+
var cleanCommand = new Command9("clean").description("Remove worktrees for completed or failed tickets").action(async () => {
|
|
2801
|
+
const creds = requireAuth();
|
|
2802
|
+
const cwd = resolve6(process.cwd());
|
|
2803
|
+
const worktrees = listWorktrees(cwd);
|
|
2804
|
+
if (worktrees.length === 0) {
|
|
2805
|
+
console.log(chalk9.dim("No worktrees to clean."));
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
const client = createConvexClient(creds.token);
|
|
2809
|
+
let cleaned = 0;
|
|
2810
|
+
for (const wt of worktrees) {
|
|
2811
|
+
if (!wt.ticketId) continue;
|
|
2812
|
+
try {
|
|
2813
|
+
const ticket = await client.query(
|
|
2814
|
+
anyApi.functions.tickets.getTicket,
|
|
2815
|
+
{ ticketId: wt.ticketId }
|
|
2816
|
+
);
|
|
2817
|
+
const isStale = !ticket || ticket.status === "failed" || ticket.status === "workflow2_done";
|
|
2818
|
+
if (isStale) {
|
|
2819
|
+
const label = ticket ? `${ticket.status}: ${ticket.title}` : "ticket not found";
|
|
2820
|
+
console.log(
|
|
2821
|
+
chalk9.dim(`Removing worktree for ${wt.ticketId} (${label})...`)
|
|
2822
|
+
);
|
|
2823
|
+
removeWorktree(cwd, wt.path);
|
|
2824
|
+
cleaned++;
|
|
2825
|
+
}
|
|
2826
|
+
} catch {
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
if (cleaned === 0) {
|
|
2830
|
+
console.log(chalk9.dim("All worktrees are still active."));
|
|
2831
|
+
} else {
|
|
2832
|
+
console.log(
|
|
2833
|
+
chalk9.green(
|
|
2834
|
+
`Cleaned ${cleaned} worktree${cleaned === 1 ? "" : "s"}.`
|
|
2835
|
+
)
|
|
2836
|
+
);
|
|
2837
|
+
}
|
|
2838
|
+
});
|
|
2839
|
+
|
|
2840
|
+
// src/commands/watch.ts
|
|
2841
|
+
import { Command as Command10 } from "commander";
|
|
2842
|
+
import { resolve as resolve7 } from "path";
|
|
2843
|
+
import {
|
|
2844
|
+
readFileSync as readFileSync7,
|
|
2845
|
+
writeFileSync as writeFileSync9,
|
|
2846
|
+
existsSync as existsSync10,
|
|
2847
|
+
unlinkSync as unlinkSync3
|
|
2848
|
+
} from "fs";
|
|
2849
|
+
import { join as join11 } from "path";
|
|
2850
|
+
import chalk11 from "chalk";
|
|
2851
|
+
import { ConvexHttpClient as ConvexHttpClient3 } from "convex/browser";
|
|
2852
|
+
|
|
2853
|
+
// src/daemon/watcher.ts
|
|
2854
|
+
import { anyApi as anyApi4 } from "convex/server";
|
|
2855
|
+
import { execSync as execSync4 } from "child_process";
|
|
2856
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync8 } from "fs";
|
|
2857
|
+
import { join as join10 } from "path";
|
|
2858
|
+
import { hostname as osHostname } from "os";
|
|
2859
|
+
|
|
2860
|
+
// src/daemon/heartbeat.ts
|
|
2861
|
+
import { anyApi as anyApi3 } from "convex/server";
|
|
2862
|
+
var HEARTBEAT_INTERVAL = 3e4;
|
|
2863
|
+
var Heartbeat = class {
|
|
2864
|
+
client;
|
|
2865
|
+
sessionId;
|
|
2866
|
+
timer = null;
|
|
2867
|
+
constructor(client, sessionId) {
|
|
2868
|
+
this.client = client;
|
|
2869
|
+
this.sessionId = sessionId;
|
|
2870
|
+
}
|
|
2871
|
+
start() {
|
|
2872
|
+
if (this.timer) return;
|
|
2873
|
+
this.timer = setInterval(() => this.beat(), HEARTBEAT_INTERVAL);
|
|
2874
|
+
this.timer.unref();
|
|
2875
|
+
}
|
|
2876
|
+
stop() {
|
|
2877
|
+
if (this.timer) {
|
|
2878
|
+
clearInterval(this.timer);
|
|
2879
|
+
this.timer = null;
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
async beat(status, currentTicketId, currentBranch, worktreePath) {
|
|
2883
|
+
try {
|
|
2884
|
+
const args = { sessionId: this.sessionId };
|
|
2885
|
+
if (status) args.status = status;
|
|
2886
|
+
if (currentTicketId) args.currentTicketId = currentTicketId;
|
|
2887
|
+
if (currentBranch) args.currentBranch = currentBranch;
|
|
2888
|
+
if (worktreePath) args.worktreePath = worktreePath;
|
|
2889
|
+
await this.client.mutation(anyApi3.functions.agents.agentHeartbeat, args);
|
|
2890
|
+
} catch {
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
};
|
|
2894
|
+
|
|
2895
|
+
// src/daemon/spawner.ts
|
|
2896
|
+
import { spawn } from "child_process";
|
|
2897
|
+
|
|
2898
|
+
// src/daemon/notifications.ts
|
|
2899
|
+
var notifier = null;
|
|
2900
|
+
async function getNotifier() {
|
|
2901
|
+
if (notifier !== null) return notifier;
|
|
2902
|
+
try {
|
|
2903
|
+
const mod = await import("node-notifier");
|
|
2904
|
+
notifier = mod.default;
|
|
2905
|
+
return notifier;
|
|
2906
|
+
} catch {
|
|
2907
|
+
notifier = { notify: () => {
|
|
2908
|
+
} };
|
|
2909
|
+
return notifier;
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
async function notify(message) {
|
|
2913
|
+
const n = await getNotifier();
|
|
2914
|
+
n?.notify({ title: "yapout", message });
|
|
2915
|
+
}
|
|
2916
|
+
async function notifyShipped(ticketRef, prNumber) {
|
|
2917
|
+
await notify(`${ticketRef} shipped \u2014 PR #${prNumber}`);
|
|
2918
|
+
}
|
|
2919
|
+
async function notifyFailed(ticketRef, reason) {
|
|
2920
|
+
await notify(
|
|
2921
|
+
`${ticketRef} implementation failed${reason ? ` \u2014 ${reason}` : " \u2014 check logs"}`
|
|
2922
|
+
);
|
|
2923
|
+
}
|
|
2924
|
+
async function notifyEnrichmentDone(ticketRef) {
|
|
2925
|
+
await notify(`${ticketRef} enriched \u2014 ready for implementation`);
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
// src/daemon/spawner.ts
|
|
2929
|
+
var Spawner = class {
|
|
2930
|
+
agents = /* @__PURE__ */ new Map();
|
|
2931
|
+
callbacks;
|
|
2932
|
+
projectId;
|
|
2933
|
+
shuttingDown = false;
|
|
2934
|
+
constructor(projectId, callbacks) {
|
|
2935
|
+
this.projectId = projectId;
|
|
2936
|
+
this.callbacks = callbacks;
|
|
2937
|
+
}
|
|
2938
|
+
get activeCount() {
|
|
2939
|
+
return this.agents.size;
|
|
2940
|
+
}
|
|
2941
|
+
get activeAgents() {
|
|
2942
|
+
return Array.from(this.agents.values()).map((a) => ({
|
|
2943
|
+
ticketRef: a.ticketRef,
|
|
2944
|
+
title: a.title,
|
|
2945
|
+
phase: a.phase,
|
|
2946
|
+
worktree: a.worktreePath,
|
|
2947
|
+
startedAt: a.startedAt
|
|
2948
|
+
}));
|
|
2949
|
+
}
|
|
2950
|
+
isRunning(ticketRef) {
|
|
2951
|
+
return this.agents.has(ticketRef);
|
|
2952
|
+
}
|
|
2953
|
+
spawnEnrichmentAgent(ticketId, ticketRef, title, worktreePath) {
|
|
2954
|
+
if (this.shuttingDown) return;
|
|
2955
|
+
const prompt = [
|
|
2956
|
+
`A ticket has been approved and needs enrichment before implementation.`,
|
|
2957
|
+
`Call yapout_get_unenriched_ticket with ticketId "${ticketId}" to fetch the ticket details.`,
|
|
2958
|
+
``,
|
|
2959
|
+
`Read the codebase to understand the project structure, then produce:`,
|
|
2960
|
+
`1. An implementation brief (3-5 sentences, specific files/patterns involved)`,
|
|
2961
|
+
`2. An enriched description with technical context`,
|
|
2962
|
+
`3. Clarifying questions (0-5) where the answer can't be inferred`,
|
|
2963
|
+
`4. Duplicate check \u2014 call yapout_get_existing_tickets and compare`,
|
|
2964
|
+
`5. Scope assessment (is this too large for one PR?)`,
|
|
2965
|
+
``,
|
|
2966
|
+
`Call yapout_save_enrichment with your analysis.`,
|
|
2967
|
+
`If no questions were generated, also call yapout_sync_to_linear to create the Linear ticket.`
|
|
2968
|
+
].join("\n");
|
|
2969
|
+
this.spawnAgent(ticketRef, title, "enrich", worktreePath, prompt);
|
|
2970
|
+
}
|
|
2971
|
+
spawnImplementationAgent(ticketRef, title, worktreePath) {
|
|
2972
|
+
if (this.shuttingDown) return;
|
|
2973
|
+
const prompt = [
|
|
2974
|
+
`Use yapout to implement ticket "${ticketRef}: ${title}".`,
|
|
2975
|
+
`Claim it with yapout_claim (use worktree mode), read the brief,`,
|
|
2976
|
+
`implement the changes, run yapout_check, then ship with yapout_ship.`
|
|
2977
|
+
].join(" ");
|
|
2978
|
+
this.spawnAgent(ticketRef, title, "implement", worktreePath, prompt);
|
|
2979
|
+
}
|
|
2980
|
+
spawnAgent(ticketRef, title, stage, worktreePath, prompt) {
|
|
2981
|
+
const env = {
|
|
2982
|
+
...process.env,
|
|
2983
|
+
YAPOUT_PROJECT_ID: this.projectId
|
|
2984
|
+
};
|
|
2985
|
+
const child = spawn("claude", ["--dangerously-skip-permissions", prompt], {
|
|
2986
|
+
cwd: worktreePath,
|
|
2987
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2988
|
+
env,
|
|
2989
|
+
// On Windows, shell: true is needed for .cmd executables
|
|
2990
|
+
shell: process.platform === "win32"
|
|
2991
|
+
});
|
|
2992
|
+
const agent = {
|
|
2993
|
+
process: child,
|
|
2994
|
+
ticketRef,
|
|
2995
|
+
title,
|
|
2996
|
+
stage,
|
|
2997
|
+
worktreePath,
|
|
2998
|
+
startedAt: Date.now(),
|
|
2999
|
+
phase: stage === "enrich" ? "reading codebase" : "starting"
|
|
3000
|
+
};
|
|
3001
|
+
this.agents.set(ticketRef, agent);
|
|
3002
|
+
this.callbacks.onAgentStart(agent);
|
|
3003
|
+
this.callbacks.onEvent({
|
|
3004
|
+
time: Date.now(),
|
|
3005
|
+
icon: "\u25B6",
|
|
3006
|
+
message: `${ticketRef} ${stage === "enrich" ? "enrichment" : "agent"} started`
|
|
3007
|
+
});
|
|
3008
|
+
let stdoutBuffer = "";
|
|
3009
|
+
child.stdout?.on("data", (data) => {
|
|
3010
|
+
stdoutBuffer += data.toString();
|
|
3011
|
+
const phase = this.detectPhase(stdoutBuffer, stage);
|
|
3012
|
+
if (phase && phase !== agent.phase) {
|
|
3013
|
+
agent.phase = phase;
|
|
3014
|
+
this.callbacks.onAgentPhase(agent, phase);
|
|
3015
|
+
}
|
|
3016
|
+
});
|
|
3017
|
+
child.stderr?.on("data", () => {
|
|
3018
|
+
});
|
|
3019
|
+
child.on("close", (code) => {
|
|
3020
|
+
this.agents.delete(ticketRef);
|
|
3021
|
+
this.callbacks.onAgentComplete(agent, code);
|
|
3022
|
+
if (code === 0) {
|
|
3023
|
+
if (stage === "enrich") {
|
|
3024
|
+
this.callbacks.onEvent({
|
|
3025
|
+
time: Date.now(),
|
|
3026
|
+
icon: "\u2713",
|
|
3027
|
+
message: `${ticketRef} enrichment complete`
|
|
3028
|
+
});
|
|
3029
|
+
notifyEnrichmentDone(ticketRef);
|
|
3030
|
+
} else {
|
|
3031
|
+
this.callbacks.onEvent({
|
|
3032
|
+
time: Date.now(),
|
|
3033
|
+
icon: "\u2713",
|
|
3034
|
+
message: `${ticketRef} shipped`
|
|
3035
|
+
});
|
|
3036
|
+
notifyShipped(ticketRef, 0);
|
|
3037
|
+
}
|
|
3038
|
+
} else {
|
|
3039
|
+
this.callbacks.onEvent({
|
|
3040
|
+
time: Date.now(),
|
|
3041
|
+
icon: "\u2717",
|
|
3042
|
+
message: `${ticketRef} ${stage} failed (exit ${code})`
|
|
3043
|
+
});
|
|
3044
|
+
notifyFailed(ticketRef);
|
|
3045
|
+
}
|
|
3046
|
+
});
|
|
3047
|
+
child.on("error", (err) => {
|
|
3048
|
+
this.agents.delete(ticketRef);
|
|
3049
|
+
this.callbacks.onEvent({
|
|
3050
|
+
time: Date.now(),
|
|
3051
|
+
icon: "\u2717",
|
|
3052
|
+
message: `${ticketRef} spawn error: ${err.message}`
|
|
3053
|
+
});
|
|
3054
|
+
});
|
|
3055
|
+
}
|
|
3056
|
+
detectPhase(output, stage) {
|
|
3057
|
+
const lower = output.toLowerCase();
|
|
3058
|
+
if (stage === "enrich") {
|
|
3059
|
+
if (lower.includes("yapout_save_enrichment")) return "saving enrichment";
|
|
3060
|
+
if (lower.includes("yapout_sync_to_linear")) return "syncing to linear";
|
|
3061
|
+
if (lower.includes("yapout_get_existing_tickets")) return "checking duplicates";
|
|
3062
|
+
if (lower.includes("reading") || lower.includes("exploring")) return "reading codebase";
|
|
3063
|
+
return null;
|
|
3064
|
+
}
|
|
3065
|
+
if (lower.includes("yapout_ship")) return "shipping";
|
|
3066
|
+
if (lower.includes("yapout_check")) return "running checks";
|
|
3067
|
+
if (lower.includes("writing") || lower.includes("editing")) return "writing code";
|
|
3068
|
+
if (lower.includes("yapout_claim")) return "claiming ticket";
|
|
3069
|
+
if (lower.includes("reading") || lower.includes("exploring")) return "reading codebase";
|
|
3070
|
+
return null;
|
|
3071
|
+
}
|
|
3072
|
+
async gracefulShutdown() {
|
|
3073
|
+
this.shuttingDown = true;
|
|
3074
|
+
if (this.agents.size === 0) return;
|
|
3075
|
+
const timeout = 10 * 60 * 1e3;
|
|
3076
|
+
const start = Date.now();
|
|
3077
|
+
await new Promise((resolve11) => {
|
|
3078
|
+
const check = () => {
|
|
3079
|
+
if (this.agents.size === 0 || Date.now() - start > timeout) {
|
|
3080
|
+
resolve11();
|
|
3081
|
+
return;
|
|
3082
|
+
}
|
|
3083
|
+
setTimeout(check, 2e3);
|
|
3084
|
+
};
|
|
3085
|
+
check();
|
|
3086
|
+
});
|
|
3087
|
+
}
|
|
3088
|
+
forceKill() {
|
|
3089
|
+
for (const agent of this.agents.values()) {
|
|
3090
|
+
agent.process.kill("SIGTERM");
|
|
3091
|
+
}
|
|
3092
|
+
this.agents.clear();
|
|
3093
|
+
}
|
|
3094
|
+
};
|
|
3095
|
+
|
|
3096
|
+
// src/daemon/display.ts
|
|
3097
|
+
import chalk10 from "chalk";
|
|
3098
|
+
function formatElapsed(ms) {
|
|
3099
|
+
const seconds = Math.floor(ms / 1e3);
|
|
3100
|
+
if (seconds < 60) return `${seconds}s`;
|
|
3101
|
+
const minutes = Math.floor(seconds / 60);
|
|
3102
|
+
if (minutes < 60) return `${minutes}m`;
|
|
3103
|
+
const hours = Math.floor(minutes / 60);
|
|
3104
|
+
return `${hours}h ${minutes % 60}m`;
|
|
3105
|
+
}
|
|
3106
|
+
function formatTime(timestamp) {
|
|
3107
|
+
return new Date(timestamp).toLocaleTimeString("en-US", {
|
|
3108
|
+
hour: "numeric",
|
|
3109
|
+
minute: "2-digit",
|
|
3110
|
+
hour12: true
|
|
3111
|
+
});
|
|
3112
|
+
}
|
|
3113
|
+
function truncate2(str, max) {
|
|
3114
|
+
return str.length > max ? str.slice(0, max - 1) + "\u2026" : str;
|
|
3115
|
+
}
|
|
3116
|
+
function render(state) {
|
|
3117
|
+
const now = Date.now();
|
|
3118
|
+
const lines = [];
|
|
3119
|
+
const activeCount = state.activeAgents.length;
|
|
3120
|
+
const queuedCount = state.queuedTickets.length;
|
|
3121
|
+
const statusParts = [];
|
|
3122
|
+
if (activeCount > 0) statusParts.push(`${activeCount} agent${activeCount === 1 ? "" : "s"} active`);
|
|
3123
|
+
if (queuedCount > 0) statusParts.push(`${queuedCount} queued`);
|
|
3124
|
+
const statusLine = statusParts.length > 0 ? statusParts.join(" | ") : "idle";
|
|
3125
|
+
lines.push(
|
|
3126
|
+
`${chalk10.bold("yapout watch")} \u2014 ${state.projectName}`
|
|
3127
|
+
);
|
|
3128
|
+
lines.push(`${chalk10.green("Watching")} | ${statusLine}`);
|
|
3129
|
+
lines.push("");
|
|
3130
|
+
for (const agent of state.activeAgents) {
|
|
3131
|
+
const elapsed = formatElapsed(now - agent.startedAt);
|
|
3132
|
+
const wt = agent.worktree ? chalk10.dim(`worktree/${agent.ticketRef.toLowerCase()}`) : "";
|
|
3133
|
+
lines.push(
|
|
3134
|
+
` ${chalk10.green("\u25CF")} ${chalk10.bold(agent.ticketRef)} ${chalk10.dim(truncate2(`"${agent.title}"`, 35))} ${chalk10.yellow(agent.phase)} ${wt} ${chalk10.dim(elapsed)}`
|
|
3135
|
+
);
|
|
3136
|
+
}
|
|
3137
|
+
for (const ticket of state.queuedTickets) {
|
|
3138
|
+
lines.push(
|
|
3139
|
+
` ${chalk10.dim("\u25CB")} ${chalk10.bold(ticket.ticketRef)} ${chalk10.dim(truncate2(`"${ticket.title}"`, 35))} ${chalk10.dim("queued")}`
|
|
3140
|
+
);
|
|
3141
|
+
}
|
|
3142
|
+
if (state.activeAgents.length > 0 || state.queuedTickets.length > 0) {
|
|
3143
|
+
lines.push("");
|
|
3144
|
+
}
|
|
3145
|
+
if (state.recentEvents.length > 0) {
|
|
3146
|
+
lines.push(chalk10.dim("Recent:"));
|
|
3147
|
+
for (const event of state.recentEvents.slice(0, 8)) {
|
|
3148
|
+
lines.push(
|
|
3149
|
+
` ${chalk10.dim(formatTime(event.time))} ${event.icon} ${event.message}`
|
|
3150
|
+
);
|
|
3151
|
+
}
|
|
3152
|
+
lines.push("");
|
|
3153
|
+
}
|
|
3154
|
+
lines.push(chalk10.dim("Ctrl+C to stop (agents will finish)"));
|
|
3155
|
+
return lines.join("\n");
|
|
3156
|
+
}
|
|
3157
|
+
function clearAndRender(state) {
|
|
3158
|
+
process.stdout.write("\x1B[2J\x1B[H");
|
|
3159
|
+
process.stdout.write(render(state) + "\n");
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
// src/daemon/watcher.ts
|
|
3163
|
+
var POLL_INTERVAL = 1e4;
|
|
3164
|
+
var TOKEN_EXPIRY_BUFFER = 60 * 60 * 1e3;
|
|
3165
|
+
var Watcher = class {
|
|
3166
|
+
client;
|
|
3167
|
+
heartbeat;
|
|
3168
|
+
spawner;
|
|
3169
|
+
sessionId;
|
|
3170
|
+
options;
|
|
3171
|
+
pollTimer = null;
|
|
3172
|
+
running = false;
|
|
3173
|
+
recentEvents = [];
|
|
3174
|
+
maxAgents;
|
|
3175
|
+
constructor(options, client) {
|
|
3176
|
+
this.options = options;
|
|
3177
|
+
this.client = client;
|
|
3178
|
+
this.sessionId = crypto.randomUUID();
|
|
3179
|
+
this.maxAgents = getMaxAgents();
|
|
3180
|
+
this.heartbeat = new Heartbeat(client, this.sessionId);
|
|
3181
|
+
this.spawner = new Spawner(options.projectId, {
|
|
3182
|
+
onAgentStart: (agent) => this.onAgentStart(agent),
|
|
3183
|
+
onAgentPhase: () => this.updateDisplay(),
|
|
3184
|
+
onAgentComplete: (agent, code) => this.onAgentComplete(agent, code),
|
|
3185
|
+
onEvent: (event) => this.addEvent(event)
|
|
3186
|
+
});
|
|
3187
|
+
}
|
|
3188
|
+
async start() {
|
|
3189
|
+
this.running = true;
|
|
3190
|
+
if (this.options.credentials.expiresAt - Date.now() < TOKEN_EXPIRY_BUFFER) {
|
|
3191
|
+
console.error(
|
|
3192
|
+
"Session expires soon. Run `yapout login` to re-authenticate."
|
|
3193
|
+
);
|
|
3194
|
+
process.exit(1);
|
|
3195
|
+
}
|
|
3196
|
+
await this.client.mutation(anyApi4.functions.agents.registerAgent, {
|
|
3197
|
+
projectId: this.options.projectId,
|
|
3198
|
+
sessionId: this.sessionId,
|
|
3199
|
+
machineHostname: this.getHostname()
|
|
3200
|
+
});
|
|
3201
|
+
this.heartbeat.start();
|
|
3202
|
+
this.prefetch();
|
|
3203
|
+
await this.recoverOrphanedWorktrees();
|
|
3204
|
+
this.pollTimer = setInterval(() => this.poll(), POLL_INTERVAL);
|
|
3205
|
+
await this.poll();
|
|
3206
|
+
this.addEvent({
|
|
3207
|
+
time: Date.now(),
|
|
3208
|
+
icon: "\u25B6",
|
|
3209
|
+
message: "Watcher started"
|
|
3210
|
+
});
|
|
3211
|
+
if (!this.options.background) {
|
|
3212
|
+
this.updateDisplay();
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
async stop() {
|
|
3216
|
+
this.running = false;
|
|
3217
|
+
if (this.pollTimer) {
|
|
3218
|
+
clearInterval(this.pollTimer);
|
|
3219
|
+
this.pollTimer = null;
|
|
3220
|
+
}
|
|
3221
|
+
this.heartbeat.stop();
|
|
3222
|
+
if (this.spawner.activeCount > 0) {
|
|
3223
|
+
if (!this.options.background) {
|
|
3224
|
+
console.log(
|
|
3225
|
+
`
|
|
3226
|
+
Waiting for ${this.spawner.activeCount} agent(s) to finish...`
|
|
3227
|
+
);
|
|
3228
|
+
}
|
|
3229
|
+
await this.spawner.gracefulShutdown();
|
|
3230
|
+
}
|
|
3231
|
+
try {
|
|
3232
|
+
await this.client.mutation(anyApi4.functions.agents.unregisterAgent, {
|
|
3233
|
+
sessionId: this.sessionId
|
|
3234
|
+
});
|
|
3235
|
+
} catch {
|
|
3236
|
+
}
|
|
3237
|
+
}
|
|
3238
|
+
forceStop() {
|
|
3239
|
+
this.running = false;
|
|
3240
|
+
if (this.pollTimer) {
|
|
3241
|
+
clearInterval(this.pollTimer);
|
|
3242
|
+
this.pollTimer = null;
|
|
3243
|
+
}
|
|
3244
|
+
this.heartbeat.stop();
|
|
3245
|
+
this.spawner.forceKill();
|
|
3246
|
+
this.client.mutation(anyApi4.functions.agents.unregisterAgent, {
|
|
3247
|
+
sessionId: this.sessionId
|
|
3248
|
+
}).catch(() => {
|
|
3249
|
+
});
|
|
3250
|
+
}
|
|
3251
|
+
async poll() {
|
|
3252
|
+
if (!this.running) return;
|
|
3253
|
+
try {
|
|
3254
|
+
if (this.options.credentials.expiresAt - Date.now() < TOKEN_EXPIRY_BUFFER) {
|
|
3255
|
+
this.addEvent({
|
|
3256
|
+
time: Date.now(),
|
|
3257
|
+
icon: "\u26A0",
|
|
3258
|
+
message: "Session expiring soon \u2014 run `yapout login`"
|
|
3259
|
+
});
|
|
3260
|
+
return;
|
|
3261
|
+
}
|
|
3262
|
+
const slotsAvailable = this.maxAgents - this.spawner.activeCount;
|
|
3263
|
+
if (slotsAvailable <= 0) return;
|
|
3264
|
+
if (this.options.config.auto_enrich) {
|
|
3265
|
+
await this.checkForEnrichmentWork(slotsAvailable);
|
|
3266
|
+
}
|
|
3267
|
+
if (this.options.config.auto_implement) {
|
|
3268
|
+
const slotsLeft = this.maxAgents - this.spawner.activeCount;
|
|
3269
|
+
if (slotsLeft > 0) {
|
|
3270
|
+
await this.checkForImplementationWork(slotsLeft);
|
|
3271
|
+
}
|
|
3272
|
+
}
|
|
3273
|
+
const status = this.spawner.activeCount > 0 ? "active" : "idle";
|
|
3274
|
+
await this.heartbeat.beat(status);
|
|
3275
|
+
if (!this.options.background) {
|
|
3276
|
+
this.updateDisplay();
|
|
3277
|
+
}
|
|
3278
|
+
} catch (err) {
|
|
3279
|
+
this.addEvent({
|
|
3280
|
+
time: Date.now(),
|
|
3281
|
+
icon: "\u26A0",
|
|
3282
|
+
message: `Poll error: ${err.message}`
|
|
3283
|
+
});
|
|
3284
|
+
}
|
|
3285
|
+
}
|
|
3286
|
+
async checkForEnrichmentWork(maxSlots) {
|
|
3287
|
+
const tickets = await this.client.query(
|
|
3288
|
+
anyApi4.functions.localPipeline.getUnenrichedTickets,
|
|
3289
|
+
{ projectId: this.options.projectId }
|
|
3290
|
+
);
|
|
3291
|
+
if (!tickets || tickets.length === 0) return;
|
|
3292
|
+
const enrichable = tickets.filter((t) => t.nature !== "spike");
|
|
3293
|
+
if (enrichable.length === 0) return;
|
|
3294
|
+
const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 };
|
|
3295
|
+
const sorted = enrichable.sort(
|
|
3296
|
+
(a, b) => (priorityOrder[a.priority] ?? 3) - (priorityOrder[b.priority] ?? 3)
|
|
3297
|
+
);
|
|
3298
|
+
const enrichSlots = Math.min(1, maxSlots);
|
|
3299
|
+
let spawned = 0;
|
|
3300
|
+
for (const ticket of sorted) {
|
|
3301
|
+
if (spawned >= enrichSlots) break;
|
|
3302
|
+
const ref = ticket.linearTicketId ?? ticket.ticketId;
|
|
3303
|
+
if (this.spawner.isRunning(ref)) continue;
|
|
3304
|
+
const enrichWorktree = this.ensureEnrichWorktree();
|
|
3305
|
+
if (!enrichWorktree) continue;
|
|
3306
|
+
this.spawner.spawnEnrichmentAgent(
|
|
3307
|
+
ticket.ticketId,
|
|
3308
|
+
ref,
|
|
3309
|
+
ticket.title,
|
|
3310
|
+
enrichWorktree
|
|
3311
|
+
);
|
|
3312
|
+
spawned++;
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
async checkForImplementationWork(maxSlots) {
|
|
3316
|
+
const data = await this.client.query(
|
|
3317
|
+
anyApi4.functions.tickets.getLocalQueuedTickets,
|
|
3318
|
+
{ projectId: this.options.projectId }
|
|
3319
|
+
);
|
|
3320
|
+
if (!data || data.ready.length === 0) return;
|
|
3321
|
+
let spawned = 0;
|
|
3322
|
+
for (const ticket of data.ready) {
|
|
3323
|
+
if (spawned >= maxSlots) break;
|
|
3324
|
+
const ref = ticket.linearTicketId ?? ticket.ticketId;
|
|
3325
|
+
if (this.spawner.isRunning(ref)) continue;
|
|
3326
|
+
this.prefetch();
|
|
3327
|
+
const defaultBranch = getDefaultBranch(this.options.cwd);
|
|
3328
|
+
const branchName = `feat/${ref.toLowerCase().replace(/\s+/g, "-")}-${ticket.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40)}`;
|
|
3329
|
+
try {
|
|
3330
|
+
const wtPath = createWorktree(
|
|
3331
|
+
this.options.cwd,
|
|
3332
|
+
ticket.ticketId,
|
|
3333
|
+
branchName,
|
|
3334
|
+
defaultBranch
|
|
3335
|
+
);
|
|
3336
|
+
this.spawner.spawnImplementationAgent(ref, ticket.title, wtPath);
|
|
3337
|
+
spawned++;
|
|
3338
|
+
} catch (err) {
|
|
3339
|
+
this.addEvent({
|
|
3340
|
+
time: Date.now(),
|
|
3341
|
+
icon: "\u2717",
|
|
3342
|
+
message: `Failed to create worktree for ${ref}: ${err.message}`
|
|
3343
|
+
});
|
|
3344
|
+
}
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
ensureEnrichWorktree() {
|
|
3348
|
+
const wtDir = getWorktreesDir(this.options.cwd);
|
|
3349
|
+
const enrichPath = join10(wtDir, "_enrich");
|
|
3350
|
+
if (existsSync9(enrichPath)) return enrichPath;
|
|
3351
|
+
try {
|
|
3352
|
+
if (!existsSync9(wtDir)) mkdirSync8(wtDir, { recursive: true });
|
|
3353
|
+
const defaultBranch = getDefaultBranch(this.options.cwd);
|
|
3354
|
+
execSync4(
|
|
3355
|
+
`git worktree add "${enrichPath}" origin/${defaultBranch}`,
|
|
3356
|
+
{ cwd: this.options.cwd, stdio: ["pipe", "pipe", "pipe"] }
|
|
3357
|
+
);
|
|
3358
|
+
return enrichPath;
|
|
3359
|
+
} catch (err) {
|
|
3360
|
+
this.addEvent({
|
|
3361
|
+
time: Date.now(),
|
|
3362
|
+
icon: "\u2717",
|
|
3363
|
+
message: `Failed to create enrich worktree: ${err.message}`
|
|
3364
|
+
});
|
|
3365
|
+
return null;
|
|
3366
|
+
}
|
|
3367
|
+
}
|
|
3368
|
+
prefetch() {
|
|
3369
|
+
try {
|
|
3370
|
+
execSync4("git fetch origin", {
|
|
3371
|
+
cwd: this.options.cwd,
|
|
3372
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
3373
|
+
timeout: 3e4
|
|
3374
|
+
});
|
|
3375
|
+
} catch {
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
async recoverOrphanedWorktrees() {
|
|
3379
|
+
try {
|
|
3380
|
+
const { listWorktrees: listWorktrees2 } = await import("./worktree-ZZZIL7TK.js");
|
|
3381
|
+
const worktrees = listWorktrees2(this.options.cwd);
|
|
3382
|
+
for (const wt of worktrees) {
|
|
3383
|
+
if (wt.ticketId === "_enrich") {
|
|
3384
|
+
removeWorktree(this.options.cwd, wt.path);
|
|
3385
|
+
continue;
|
|
3386
|
+
}
|
|
3387
|
+
if (wt.ticketId) {
|
|
3388
|
+
try {
|
|
3389
|
+
const ticket = await this.client.query(
|
|
3390
|
+
anyApi4.functions.tickets.getTicket,
|
|
3391
|
+
{ ticketId: wt.ticketId }
|
|
3392
|
+
);
|
|
3393
|
+
if (ticket && (ticket.status === "failed" || ticket.status === "workflow2_done")) {
|
|
3394
|
+
removeWorktree(this.options.cwd, wt.path);
|
|
3395
|
+
this.addEvent({
|
|
3396
|
+
time: Date.now(),
|
|
3397
|
+
icon: "\u{1F9F9}",
|
|
3398
|
+
message: `Cleaned up orphaned worktree for ${wt.ticketId}`
|
|
3399
|
+
});
|
|
3400
|
+
}
|
|
3401
|
+
} catch {
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
} catch {
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
onAgentStart(agent) {
|
|
3409
|
+
if (!this.options.background) {
|
|
3410
|
+
this.updateDisplay();
|
|
3411
|
+
}
|
|
3412
|
+
notify(
|
|
3413
|
+
`${agent.stage === "enrich" ? "Enriching" : "Implementing"} ${agent.ticketRef}: "${agent.title}"`
|
|
3414
|
+
);
|
|
3415
|
+
}
|
|
3416
|
+
onAgentComplete(agent, exitCode) {
|
|
3417
|
+
if (agent.stage === "implement" && exitCode === 0 && agent.worktreePath) {
|
|
3418
|
+
try {
|
|
3419
|
+
removeWorktree(this.options.cwd, agent.worktreePath);
|
|
3420
|
+
} catch {
|
|
3421
|
+
}
|
|
3422
|
+
}
|
|
3423
|
+
if (!this.options.background) {
|
|
3424
|
+
this.updateDisplay();
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
addEvent(event) {
|
|
3428
|
+
this.recentEvents.unshift(event);
|
|
3429
|
+
if (this.recentEvents.length > 20) {
|
|
3430
|
+
this.recentEvents = this.recentEvents.slice(0, 20);
|
|
3431
|
+
}
|
|
3432
|
+
if (!this.options.background) {
|
|
3433
|
+
this.updateDisplay();
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
updateDisplay() {
|
|
3437
|
+
const state = {
|
|
3438
|
+
projectName: this.options.projectName,
|
|
3439
|
+
activeAgents: this.spawner.activeAgents,
|
|
3440
|
+
queuedTickets: [],
|
|
3441
|
+
recentEvents: this.recentEvents
|
|
3442
|
+
};
|
|
3443
|
+
clearAndRender(state);
|
|
3444
|
+
}
|
|
3445
|
+
getHostname() {
|
|
3446
|
+
try {
|
|
3447
|
+
return osHostname();
|
|
3448
|
+
} catch {
|
|
3449
|
+
return "unknown";
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
3452
|
+
};
|
|
3453
|
+
|
|
3454
|
+
// src/commands/watch.ts
|
|
3455
|
+
var PID_FILE = join11(getYapoutDir(), "watch.pid");
|
|
3456
|
+
var LOG_FILE = join11(getYapoutDir(), "watch.log");
|
|
3457
|
+
var watchCommand = new Command10("watch").description("Watch for work and spawn Claude Code agents").option("--bg", "Run in background (detached)").option("--stop", "Stop the background watcher").option("--status", "Check if watcher is running").option("--force", "Force kill on Ctrl+C (don't wait for agents)").action(async (opts) => {
|
|
3458
|
+
if (opts.status) {
|
|
3459
|
+
if (existsSync10(PID_FILE)) {
|
|
3460
|
+
const pid = parseInt(readFileSync7(PID_FILE, "utf-8").trim(), 10);
|
|
3461
|
+
if (isProcessRunning(pid)) {
|
|
3462
|
+
console.log(
|
|
3463
|
+
chalk11.green("Watcher is running") + chalk11.dim(` (PID ${pid})`)
|
|
3464
|
+
);
|
|
3465
|
+
} else {
|
|
3466
|
+
console.log(chalk11.dim("Watcher is not running (stale PID file)"));
|
|
3467
|
+
unlinkSync3(PID_FILE);
|
|
3468
|
+
}
|
|
3469
|
+
} else {
|
|
3470
|
+
console.log(chalk11.dim("Watcher is not running"));
|
|
3471
|
+
}
|
|
3472
|
+
return;
|
|
3473
|
+
}
|
|
3474
|
+
if (opts.stop) {
|
|
3475
|
+
if (!existsSync10(PID_FILE)) {
|
|
3476
|
+
console.log(chalk11.dim("No watcher running"));
|
|
3477
|
+
return;
|
|
3478
|
+
}
|
|
3479
|
+
const pid = parseInt(readFileSync7(PID_FILE, "utf-8").trim(), 10);
|
|
3480
|
+
try {
|
|
3481
|
+
process.kill(pid, "SIGTERM");
|
|
3482
|
+
console.log(chalk11.green(`Stopped watcher (PID ${pid})`));
|
|
3483
|
+
} catch {
|
|
3484
|
+
console.log(chalk11.dim("Watcher already stopped"));
|
|
3485
|
+
}
|
|
3486
|
+
unlinkSync3(PID_FILE);
|
|
3487
|
+
return;
|
|
3488
|
+
}
|
|
3489
|
+
const creds = requireAuth();
|
|
3490
|
+
const cwd = resolve7(process.cwd());
|
|
3491
|
+
const mapping = getProjectMapping(cwd);
|
|
3492
|
+
if (!mapping) {
|
|
3493
|
+
console.error(
|
|
3494
|
+
chalk11.red("No project linked.") + " Run " + chalk11.cyan("yapout link") + " in a repo."
|
|
3495
|
+
);
|
|
3496
|
+
process.exit(1);
|
|
3497
|
+
}
|
|
3498
|
+
const config = readYapoutConfig(cwd);
|
|
3499
|
+
const client = new ConvexHttpClient3(getConvexUrl());
|
|
3500
|
+
client.setAuth(creds.token);
|
|
3501
|
+
if (opts.bg) {
|
|
3502
|
+
writeFileSync9(PID_FILE, process.pid.toString());
|
|
3503
|
+
console.log(
|
|
3504
|
+
chalk11.green("Watcher started in background") + chalk11.dim(` (PID ${process.pid}, log: ${LOG_FILE})`)
|
|
3505
|
+
);
|
|
3506
|
+
}
|
|
3507
|
+
console.log(chalk11.bold(`yapout watch v0.1.0`));
|
|
3508
|
+
console.log(
|
|
3509
|
+
`Project: ${chalk11.green(mapping.projectName)} (${mapping.projectId})`
|
|
3510
|
+
);
|
|
3511
|
+
console.log(chalk11.dim("Watching..."));
|
|
3512
|
+
console.log();
|
|
3513
|
+
const watcher = new Watcher(
|
|
3514
|
+
{
|
|
3515
|
+
projectId: mapping.projectId,
|
|
3516
|
+
projectName: mapping.projectName,
|
|
3517
|
+
cwd,
|
|
3518
|
+
credentials: creds,
|
|
3519
|
+
config: config.watch,
|
|
3520
|
+
background: opts.bg
|
|
3521
|
+
},
|
|
3522
|
+
client
|
|
3523
|
+
);
|
|
3524
|
+
let shuttingDown = false;
|
|
3525
|
+
const shutdown = async () => {
|
|
3526
|
+
if (shuttingDown) {
|
|
3527
|
+
watcher.forceStop();
|
|
3528
|
+
if (existsSync10(PID_FILE)) unlinkSync3(PID_FILE);
|
|
3529
|
+
process.exit(0);
|
|
3530
|
+
}
|
|
3531
|
+
shuttingDown = true;
|
|
3532
|
+
console.log(chalk11.dim("\nShutting down..."));
|
|
3533
|
+
if (opts.force) {
|
|
3534
|
+
watcher.forceStop();
|
|
3535
|
+
} else {
|
|
3536
|
+
await watcher.stop();
|
|
3537
|
+
}
|
|
3538
|
+
if (existsSync10(PID_FILE)) unlinkSync3(PID_FILE);
|
|
3539
|
+
process.exit(0);
|
|
3540
|
+
};
|
|
3541
|
+
process.on("SIGINT", () => {
|
|
3542
|
+
shutdown();
|
|
3543
|
+
});
|
|
3544
|
+
process.on("SIGTERM", () => {
|
|
3545
|
+
shutdown();
|
|
3546
|
+
});
|
|
3547
|
+
await watcher.start();
|
|
3548
|
+
await new Promise(() => {
|
|
3549
|
+
});
|
|
3550
|
+
});
|
|
3551
|
+
function isProcessRunning(pid) {
|
|
3552
|
+
try {
|
|
3553
|
+
process.kill(pid, 0);
|
|
3554
|
+
return true;
|
|
3555
|
+
} catch {
|
|
3556
|
+
return false;
|
|
3557
|
+
}
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
// src/commands/queue.ts
|
|
3561
|
+
import { Command as Command11 } from "commander";
|
|
3562
|
+
import { resolve as resolve8 } from "path";
|
|
3563
|
+
import chalk12 from "chalk";
|
|
3564
|
+
var queueCommand = new Command11("queue").description("Show pipeline state \u2014 what's ready, blocked, and pending").action(async () => {
|
|
3565
|
+
const creds = requireAuth();
|
|
3566
|
+
const cwd = resolve8(process.cwd());
|
|
3567
|
+
const mapping = getProjectMapping(cwd);
|
|
3568
|
+
if (!mapping) {
|
|
3569
|
+
console.error(
|
|
3570
|
+
chalk12.red("No project linked.") + " Run " + chalk12.cyan("yapout link") + " in a repo."
|
|
3571
|
+
);
|
|
3572
|
+
process.exit(1);
|
|
3573
|
+
}
|
|
3574
|
+
const client = createConvexClient(creds.token);
|
|
3575
|
+
const { anyApi: anyApi5 } = await import("convex/server");
|
|
3576
|
+
const [queueData, unenriched, pending] = await Promise.all([
|
|
3577
|
+
client.query(anyApi5.functions.tickets.getLocalQueuedTickets, {
|
|
3578
|
+
projectId: mapping.projectId
|
|
3579
|
+
}),
|
|
3580
|
+
client.query(anyApi5.functions.localPipeline.getUnenrichedTickets, {
|
|
3581
|
+
projectId: mapping.projectId
|
|
3582
|
+
}),
|
|
3583
|
+
client.query(anyApi5.functions.localPipeline.getPendingSources, {
|
|
3584
|
+
projectId: mapping.projectId
|
|
3585
|
+
})
|
|
3586
|
+
]);
|
|
3587
|
+
console.log();
|
|
3588
|
+
if (queueData?.ready && queueData.ready.length > 0) {
|
|
3589
|
+
console.log(chalk12.bold("Ready to implement:"));
|
|
3590
|
+
for (const t of queueData.ready) {
|
|
3591
|
+
const ref = t.linearTicketId ?? t.ticketId;
|
|
3592
|
+
const prio = colorPriority(t.priority);
|
|
3593
|
+
console.log(
|
|
3594
|
+
` ${chalk12.bold(ref)} ${t.title.slice(0, 45).padEnd(45)} ${prio} ${chalk12.dim(t.type)}`
|
|
3595
|
+
);
|
|
3596
|
+
}
|
|
3597
|
+
console.log();
|
|
3598
|
+
}
|
|
3599
|
+
if (queueData?.blocked && queueData.blocked.length > 0) {
|
|
3600
|
+
console.log(chalk12.bold("Blocked:"));
|
|
3601
|
+
for (const t of queueData.blocked) {
|
|
3602
|
+
console.log(
|
|
3603
|
+
` ${chalk12.bold(t.ticketId.slice(-6))} ${t.title.slice(0, 45)} ${chalk12.red("blocked by " + t.blockedBy.map((id) => id.slice(-6)).join(", "))}`
|
|
3604
|
+
);
|
|
3605
|
+
}
|
|
3606
|
+
console.log();
|
|
3607
|
+
}
|
|
3608
|
+
if (unenriched && unenriched.length > 0) {
|
|
3609
|
+
console.log(chalk12.bold("Needs enrichment:"));
|
|
3610
|
+
for (const t of unenriched) {
|
|
3611
|
+
const ref = t.ticketId;
|
|
3612
|
+
console.log(
|
|
3613
|
+
` ${chalk12.bold(ref.slice(-6))} ${t.title.slice(0, 45).padEnd(45)} ${chalk12.dim(t.priority)}`
|
|
3614
|
+
);
|
|
3615
|
+
}
|
|
3616
|
+
console.log();
|
|
3617
|
+
}
|
|
3618
|
+
if (pending && pending.length > 0) {
|
|
3619
|
+
console.log(chalk12.bold("Pending extraction:"));
|
|
3620
|
+
for (const t of pending) {
|
|
3621
|
+
const ago = formatAgo(Date.now() - t.createdAt);
|
|
3622
|
+
console.log(
|
|
3623
|
+
` "${t.meetingTitle ?? "Untitled"}" ${chalk12.dim(`(uploaded ${ago})`)}`
|
|
3624
|
+
);
|
|
3625
|
+
}
|
|
3626
|
+
console.log();
|
|
3627
|
+
}
|
|
3628
|
+
if ((!queueData?.ready || queueData.ready.length === 0) && (!unenriched || unenriched.length === 0) && (!pending || pending.length === 0)) {
|
|
3629
|
+
console.log(chalk12.dim("Queue is empty. Upload a transcript to get started."));
|
|
3630
|
+
console.log();
|
|
3631
|
+
}
|
|
3632
|
+
});
|
|
3633
|
+
function colorPriority(p) {
|
|
3634
|
+
switch (p) {
|
|
3635
|
+
case "urgent":
|
|
3636
|
+
return chalk12.red(p);
|
|
3637
|
+
case "high":
|
|
3638
|
+
return chalk12.yellow(p);
|
|
3639
|
+
case "medium":
|
|
3640
|
+
return chalk12.white(p);
|
|
3641
|
+
case "low":
|
|
3642
|
+
return chalk12.dim(p);
|
|
3643
|
+
default:
|
|
3644
|
+
return p;
|
|
3645
|
+
}
|
|
3646
|
+
}
|
|
3647
|
+
function formatAgo(ms) {
|
|
3648
|
+
const s = Math.floor(ms / 1e3);
|
|
3649
|
+
if (s < 60) return `${s}s ago`;
|
|
3650
|
+
const m = Math.floor(s / 60);
|
|
3651
|
+
if (m < 60) return `${m}m ago`;
|
|
3652
|
+
const h = Math.floor(m / 60);
|
|
3653
|
+
if (h < 24) return `${h}h ago`;
|
|
3654
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
// src/commands/next.ts
|
|
3658
|
+
import { Command as Command12 } from "commander";
|
|
3659
|
+
import { resolve as resolve9 } from "path";
|
|
3660
|
+
import { writeFileSync as writeFileSync10 } from "fs";
|
|
3661
|
+
import { join as join12 } from "path";
|
|
3662
|
+
import chalk13 from "chalk";
|
|
3663
|
+
var nextCommand = new Command12("next").description("Claim the highest priority ticket and set up for implementation").option("--worktree", "Create a git worktree instead of checking out a branch").action(async (opts) => {
|
|
3664
|
+
const creds = requireAuth();
|
|
3665
|
+
const cwd = resolve9(process.cwd());
|
|
3666
|
+
const mapping = getProjectMapping(cwd);
|
|
3667
|
+
if (!mapping) {
|
|
3668
|
+
console.error(
|
|
3669
|
+
chalk13.red("No project linked.") + " Run " + chalk13.cyan("yapout link") + " in a repo."
|
|
3670
|
+
);
|
|
3671
|
+
process.exit(1);
|
|
3672
|
+
}
|
|
3673
|
+
const client = createConvexClient(creds.token);
|
|
3674
|
+
const { anyApi: anyApi5 } = await import("convex/server");
|
|
3675
|
+
const data = await client.query(
|
|
3676
|
+
anyApi5.functions.tickets.getLocalQueuedTickets,
|
|
3677
|
+
{ projectId: mapping.projectId }
|
|
3678
|
+
);
|
|
3679
|
+
if (!data?.ready || data.ready.length === 0) {
|
|
3680
|
+
console.log(chalk13.dim("No tickets ready for implementation."));
|
|
3681
|
+
return;
|
|
3682
|
+
}
|
|
3683
|
+
const ticket = data.ready[0];
|
|
3684
|
+
const ref = ticket.linearTicketId ?? ticket.ticketId;
|
|
3685
|
+
const config = readYapoutConfig(cwd);
|
|
3686
|
+
const defaultBranch = getDefaultBranch(cwd);
|
|
3687
|
+
const branchName = `${config.branch_prefix}/${ref.toLowerCase().replace(/\s+/g, "-")}-${ticket.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40)}`;
|
|
3688
|
+
console.log(chalk13.bold(`
|
|
3689
|
+
Claimed: ${ref} "${ticket.title}"`));
|
|
3690
|
+
fetchOrigin(cwd);
|
|
3691
|
+
let workDir = cwd;
|
|
3692
|
+
if (opts.worktree) {
|
|
3693
|
+
const { createWorktree: createWorktree2 } = await import("./worktree-ZZZIL7TK.js");
|
|
3694
|
+
workDir = createWorktree2(cwd, ticket.ticketId, branchName, defaultBranch);
|
|
3695
|
+
console.log(`Worktree: ${chalk13.cyan(workDir)}`);
|
|
3696
|
+
} else {
|
|
3697
|
+
checkoutNewBranch(branchName, defaultBranch, cwd);
|
|
3698
|
+
}
|
|
3699
|
+
console.log(`Branch: ${chalk13.cyan(branchName)}`);
|
|
3700
|
+
const brief = await client.query(
|
|
3701
|
+
anyApi5.functions.tickets.getTicketBrief,
|
|
3702
|
+
{ ticketId: ticket.ticketId }
|
|
3703
|
+
);
|
|
3704
|
+
if (brief) {
|
|
3705
|
+
const briefPath = join12(workDir, ".yapout", "brief.md");
|
|
3706
|
+
const briefContent = formatBrief2(ref, ticket, brief);
|
|
3707
|
+
writeFileSync10(briefPath, briefContent);
|
|
3708
|
+
console.log(`Brief: ${chalk13.cyan(briefPath)}`);
|
|
3709
|
+
}
|
|
3710
|
+
console.log();
|
|
3711
|
+
console.log("Start Claude Code to implement, or run:");
|
|
3712
|
+
console.log(
|
|
3713
|
+
chalk13.cyan(
|
|
3714
|
+
` claude --dangerously-skip-permissions "Read .yapout/brief.md, implement it, run yapout_check, then yapout_ship"`
|
|
3715
|
+
)
|
|
3716
|
+
);
|
|
3717
|
+
console.log();
|
|
3718
|
+
});
|
|
3719
|
+
function formatBrief2(ref, ticket, brief) {
|
|
3720
|
+
const lines = [
|
|
3721
|
+
`# ${ref}: ${ticket.title}`,
|
|
3722
|
+
"",
|
|
3723
|
+
`**Priority:** ${ticket.priority}`,
|
|
3724
|
+
`**Type:** ${ticket.type}`,
|
|
3725
|
+
""
|
|
3726
|
+
];
|
|
3727
|
+
if (brief.enrichedDescription) {
|
|
3728
|
+
lines.push("## Description", "", String(brief.enrichedDescription), "");
|
|
3729
|
+
} else if (brief.ticket && typeof brief.ticket === "object") {
|
|
3730
|
+
const t = brief.ticket;
|
|
3731
|
+
if (t.description) lines.push("## Description", "", t.description, "");
|
|
3732
|
+
}
|
|
3733
|
+
if (brief.implementationBrief) {
|
|
3734
|
+
lines.push("## Implementation Brief", "", String(brief.implementationBrief), "");
|
|
3735
|
+
}
|
|
3736
|
+
if (brief.enrichmentQA && Array.isArray(brief.enrichmentQA)) {
|
|
3737
|
+
const qa = brief.enrichmentQA;
|
|
3738
|
+
if (qa.length > 0) {
|
|
3739
|
+
lines.push("## Q&A", "");
|
|
3740
|
+
for (const q of qa) {
|
|
3741
|
+
lines.push(`**Q:** ${q.question}`);
|
|
3742
|
+
if (q.answer) lines.push(`**A:** ${q.answer}`);
|
|
3743
|
+
lines.push("");
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
if (brief.userContext) {
|
|
3748
|
+
lines.push("## Additional Context", "", String(brief.userContext), "");
|
|
3749
|
+
}
|
|
3750
|
+
if (brief.projectContext) {
|
|
3751
|
+
lines.push("## Project Context", "", String(brief.projectContext), "");
|
|
3752
|
+
}
|
|
3753
|
+
const warnings = brief.warnings;
|
|
3754
|
+
if (warnings?.duplicateWarning || warnings?.scopeWarning) {
|
|
3755
|
+
lines.push("## Warnings", "");
|
|
3756
|
+
if (warnings.duplicateWarning) lines.push(`\u26A0 Duplicate: ${warnings.duplicateWarning}`, "");
|
|
3757
|
+
if (warnings.scopeWarning) lines.push(`\u26A0 Scope: ${warnings.scopeWarning}`, "");
|
|
3758
|
+
}
|
|
3759
|
+
return lines.join("\n");
|
|
3760
|
+
}
|
|
3761
|
+
|
|
3762
|
+
// src/commands/recap.ts
|
|
3763
|
+
import { Command as Command13 } from "commander";
|
|
3764
|
+
import { resolve as resolve10 } from "path";
|
|
3765
|
+
import chalk14 from "chalk";
|
|
3766
|
+
var recapCommand = new Command13("recap").description("Show a summary of recent yapout activity").option("--week", "Show full week summary (default: today)").action(async (opts) => {
|
|
3767
|
+
const creds = requireAuth();
|
|
3768
|
+
const cwd = resolve10(process.cwd());
|
|
3769
|
+
const mapping = getProjectMapping(cwd);
|
|
3770
|
+
if (!mapping) {
|
|
3771
|
+
console.error(
|
|
3772
|
+
chalk14.red("No project linked.") + " Run " + chalk14.cyan("yapout link") + " in a repo."
|
|
3773
|
+
);
|
|
3774
|
+
process.exit(1);
|
|
3775
|
+
}
|
|
3776
|
+
const client = createConvexClient(creds.token);
|
|
3777
|
+
const { anyApi: anyApi5 } = await import("convex/server");
|
|
3778
|
+
const now = /* @__PURE__ */ new Date();
|
|
3779
|
+
const todayStart = new Date(
|
|
3780
|
+
now.getFullYear(),
|
|
3781
|
+
now.getMonth(),
|
|
3782
|
+
now.getDate()
|
|
3783
|
+
).getTime();
|
|
3784
|
+
const weekStart = todayStart - 6 * 24 * 60 * 60 * 1e3;
|
|
3785
|
+
const since = opts.week ? weekStart : todayStart;
|
|
3786
|
+
const data = await client.query(
|
|
3787
|
+
anyApi5.functions.localPipeline.getRecentActivity,
|
|
3788
|
+
{ projectId: mapping.projectId, since }
|
|
3789
|
+
);
|
|
3790
|
+
if (!data) {
|
|
3791
|
+
console.log(chalk14.dim("Could not fetch activity data."));
|
|
3792
|
+
return;
|
|
3793
|
+
}
|
|
3794
|
+
const period = opts.week ? "This week" : "Today";
|
|
3795
|
+
console.log();
|
|
3796
|
+
console.log(chalk14.bold(`${period}:`));
|
|
3797
|
+
if (data.recentTickets.length > 0) {
|
|
3798
|
+
for (const t of data.recentTickets) {
|
|
3799
|
+
const ref = t.linearTicketId ?? "ticket";
|
|
3800
|
+
console.log(` ${chalk14.green("\u2713")} ${ref} ${t.title}`);
|
|
3801
|
+
}
|
|
3802
|
+
}
|
|
3803
|
+
if (data.recentPRs.length > 0) {
|
|
3804
|
+
for (const p of data.recentPRs) {
|
|
3805
|
+
const prRef = p.githubPrNumber ? `PR #${p.githubPrNumber}` : "PR";
|
|
3806
|
+
const statusColor = p.status === "opened" ? chalk14.green : p.status === "draft" ? chalk14.dim : chalk14.yellow;
|
|
3807
|
+
console.log(
|
|
3808
|
+
` ${statusColor("\u2191")} ${p.prTitle.slice(0, 45)} ${statusColor(prRef)} ${statusColor(p.status)}`
|
|
3809
|
+
);
|
|
3810
|
+
}
|
|
3811
|
+
}
|
|
3812
|
+
if (data.recentTickets.length === 0 && data.recentPRs.length === 0) {
|
|
3813
|
+
console.log(chalk14.dim(" No activity yet."));
|
|
3814
|
+
}
|
|
3815
|
+
console.log();
|
|
3816
|
+
console.log(
|
|
3817
|
+
` ${data.ticketsShipped} tickets shipped | ${data.prsMerged} PRs merged | ${data.successRate}% agent success rate`
|
|
3818
|
+
);
|
|
3819
|
+
console.log();
|
|
3820
|
+
});
|
|
3821
|
+
|
|
3822
|
+
// src/commands/yap.ts
|
|
3823
|
+
import { Command as Command14 } from "commander";
|
|
3824
|
+
import chalk15 from "chalk";
|
|
3825
|
+
var yapCommand = new Command14("yap").description(
|
|
3826
|
+
"Start a yap session \u2014 brainstorm with an AI that knows your codebase"
|
|
3827
|
+
).action(() => {
|
|
3828
|
+
console.log(chalk15.bold("yapout yap sessions"));
|
|
3829
|
+
console.log();
|
|
3830
|
+
console.log(
|
|
3831
|
+
"Yap sessions run inside Claude \u2014 Desktop, CLI, or web \u2014 anywhere the"
|
|
3832
|
+
);
|
|
3833
|
+
console.log("yapout MCP server is connected.");
|
|
3834
|
+
console.log();
|
|
3835
|
+
console.log("Just tell Claude:");
|
|
3836
|
+
console.log(
|
|
3837
|
+
chalk15.green(` "Let's have a yap session about [topic]"`)
|
|
3838
|
+
);
|
|
3839
|
+
console.log(
|
|
3840
|
+
chalk15.green(
|
|
3841
|
+
' "I want to brainstorm [idea] \u2014 be a skeptical QA engineer"'
|
|
3842
|
+
)
|
|
3843
|
+
);
|
|
3844
|
+
console.log();
|
|
3845
|
+
console.log(chalk15.dim("Personas: tech lead, qa engineer, product owner, end user, or custom"));
|
|
3846
|
+
console.log(
|
|
3847
|
+
chalk15.dim(
|
|
3848
|
+
"Claude will call yapout_start_yap to get instructions and yapout_submit_yap_session when done."
|
|
3849
|
+
)
|
|
3850
|
+
);
|
|
3851
|
+
});
|
|
3852
|
+
|
|
3853
|
+
// src/commands/handle-uri.ts
|
|
3854
|
+
import { Command as Command15 } from "commander";
|
|
3855
|
+
import { spawn as spawn2 } from "child_process";
|
|
3856
|
+
import { platform as platform2 } from "os";
|
|
3857
|
+
import chalk16 from "chalk";
|
|
3858
|
+
var VALID_ACTIONS = ["claim", "enrich", "yap", "compact"];
|
|
3859
|
+
function parseYapoutUri(raw) {
|
|
3860
|
+
const url = new URL(raw);
|
|
3861
|
+
const action = url.hostname;
|
|
3862
|
+
if (!VALID_ACTIONS.includes(action)) {
|
|
3863
|
+
throw new Error(
|
|
3864
|
+
`Unknown action: ${action}. Expected one of: ${VALID_ACTIONS.join(", ")}`
|
|
3865
|
+
);
|
|
3866
|
+
}
|
|
3867
|
+
const ticketId = url.pathname.replace(/^\//, "") || void 0;
|
|
3868
|
+
if ((action === "claim" || action === "enrich") && !ticketId) {
|
|
3869
|
+
throw new Error(`Missing ticket ID in URI: ${raw}`);
|
|
3870
|
+
}
|
|
3871
|
+
return {
|
|
3872
|
+
action,
|
|
3873
|
+
ticketId,
|
|
3874
|
+
topic: url.searchParams.get("topic") || void 0,
|
|
3875
|
+
persona: url.searchParams.get("persona") || void 0
|
|
3876
|
+
};
|
|
3877
|
+
}
|
|
3878
|
+
function buildPrompt(parsed) {
|
|
3879
|
+
switch (parsed.action) {
|
|
3880
|
+
case "claim":
|
|
3881
|
+
return [
|
|
3882
|
+
`Use yapout to implement ticket "${parsed.ticketId}".`,
|
|
3883
|
+
`Claim it with yapout_claim (use worktree mode), read the brief,`,
|
|
3884
|
+
`implement the changes, run yapout_check, then ship with yapout_ship.`
|
|
3885
|
+
].join(" ");
|
|
3886
|
+
case "enrich":
|
|
3887
|
+
return [
|
|
3888
|
+
`A ticket has been approved and needs enrichment before implementation.`,
|
|
3889
|
+
`Call yapout_get_unenriched_ticket with ticketId for "${parsed.ticketId}" to fetch the ticket details.`,
|
|
3890
|
+
``,
|
|
3891
|
+
`Read the codebase to understand the project structure, then produce:`,
|
|
3892
|
+
`1. An implementation brief (3-5 sentences, specific files/patterns involved)`,
|
|
3893
|
+
`2. An enriched description with technical context`,
|
|
3894
|
+
`3. Clarifying questions (0-5) where the answer can't be inferred`,
|
|
3895
|
+
`4. Duplicate check \u2014 call yapout_get_existing_tickets and compare`,
|
|
3896
|
+
`5. Scope assessment (is this too large for one PR?)`,
|
|
3897
|
+
``,
|
|
3898
|
+
`Call yapout_save_enrichment with your analysis.`,
|
|
3899
|
+
`If no questions were generated, also call yapout_sync_to_linear to create the Linear ticket.`
|
|
3900
|
+
].join("\n");
|
|
3901
|
+
case "yap": {
|
|
3902
|
+
const parts = ["Let's have a yap session"];
|
|
3903
|
+
if (parsed.topic) parts[0] += ` about ${parsed.topic}`;
|
|
3904
|
+
if (parsed.persona && parsed.persona !== "tech lead") {
|
|
3905
|
+
parts.push(`be a ${parsed.persona}`);
|
|
3906
|
+
}
|
|
3907
|
+
return parts.join(" \u2014 ");
|
|
3908
|
+
}
|
|
3909
|
+
case "compact":
|
|
3910
|
+
return "Run yapout_compact to update the project context summary.";
|
|
3911
|
+
}
|
|
3912
|
+
}
|
|
3913
|
+
function findProjectDir() {
|
|
3914
|
+
const mappings = readProjectMappings();
|
|
3915
|
+
const dirs = Object.keys(mappings);
|
|
3916
|
+
if (dirs.length === 0) return null;
|
|
3917
|
+
dirs.sort(
|
|
3918
|
+
(a, b) => (mappings[b].linkedAt ?? 0) - (mappings[a].linkedAt ?? 0)
|
|
3919
|
+
);
|
|
3920
|
+
return dirs[0];
|
|
3921
|
+
}
|
|
3922
|
+
function launchTerminal(cwd, claudeArgs) {
|
|
3923
|
+
const os = platform2();
|
|
3924
|
+
const claudeCmd = `claude ${claudeArgs.map(shellEscape).join(" ")}`;
|
|
3925
|
+
if (os === "win32") {
|
|
3926
|
+
const wtArgs = ["wt", "-d", cwd, "cmd", "/k", claudeCmd];
|
|
3927
|
+
const child = spawn2("cmd", ["/c", "start", ...wtArgs], {
|
|
3928
|
+
cwd,
|
|
3929
|
+
stdio: "ignore",
|
|
3930
|
+
detached: true,
|
|
3931
|
+
shell: true
|
|
3932
|
+
});
|
|
3933
|
+
child.unref();
|
|
3934
|
+
child.on("error", () => {
|
|
3935
|
+
const fallback = spawn2(
|
|
3936
|
+
"cmd",
|
|
3937
|
+
["/c", "start", "cmd", "/k", claudeCmd],
|
|
3938
|
+
{ cwd, stdio: "ignore", detached: true, shell: true }
|
|
3939
|
+
);
|
|
3940
|
+
fallback.unref();
|
|
3941
|
+
});
|
|
3942
|
+
} else if (os === "darwin") {
|
|
3943
|
+
const escaped = claudeCmd.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
3944
|
+
const script = `tell application "Terminal"
|
|
3945
|
+
activate
|
|
3946
|
+
do script "cd ${shellEscape(cwd)} && ${escaped}"
|
|
3947
|
+
end tell`;
|
|
3948
|
+
const child = spawn2("osascript", ["-e", script], {
|
|
3949
|
+
stdio: "ignore",
|
|
3950
|
+
detached: true
|
|
3951
|
+
});
|
|
3952
|
+
child.unref();
|
|
3953
|
+
} else {
|
|
3954
|
+
const terminals = [
|
|
3955
|
+
{ cmd: "x-terminal-emulator", args: ["-e", `bash -c 'cd ${shellEscape(cwd)} && ${claudeCmd}; exec bash'`] },
|
|
3956
|
+
{ cmd: "gnome-terminal", args: ["--", "bash", "-c", `cd ${shellEscape(cwd)} && ${claudeCmd}; exec bash`] },
|
|
3957
|
+
{ cmd: "konsole", args: ["-e", "bash", "-c", `cd ${shellEscape(cwd)} && ${claudeCmd}; exec bash`] },
|
|
3958
|
+
{ cmd: "xterm", args: ["-e", `cd ${shellEscape(cwd)} && ${claudeCmd}; exec bash`] }
|
|
3959
|
+
];
|
|
3960
|
+
let launched = false;
|
|
3961
|
+
for (const term of terminals) {
|
|
3962
|
+
try {
|
|
3963
|
+
const child = spawn2(term.cmd, term.args, {
|
|
3964
|
+
stdio: "ignore",
|
|
3965
|
+
detached: true
|
|
3966
|
+
});
|
|
3967
|
+
child.unref();
|
|
3968
|
+
launched = true;
|
|
3969
|
+
break;
|
|
3970
|
+
} catch {
|
|
3971
|
+
continue;
|
|
3972
|
+
}
|
|
3973
|
+
}
|
|
3974
|
+
if (!launched) {
|
|
3975
|
+
console.error(
|
|
3976
|
+
chalk16.red("Could not find a terminal emulator. Install gnome-terminal, konsole, or xterm.")
|
|
3977
|
+
);
|
|
3978
|
+
process.exit(1);
|
|
3979
|
+
}
|
|
3980
|
+
}
|
|
3981
|
+
}
|
|
3982
|
+
function shellEscape(s) {
|
|
3983
|
+
if (platform2() === "win32") {
|
|
3984
|
+
return `"${s.replace(/"/g, '""')}"`;
|
|
3985
|
+
}
|
|
3986
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
3987
|
+
}
|
|
3988
|
+
var handleUriCommand = new Command15("handle-uri").description("Handle a yapout:// URI (used by OS protocol handler)").argument("<uri>", "The yapout:// URI to handle").action(async (uri) => {
|
|
3989
|
+
try {
|
|
3990
|
+
const parsed = parseYapoutUri(uri);
|
|
3991
|
+
const prompt = buildPrompt(parsed);
|
|
3992
|
+
const cwd = findProjectDir();
|
|
3993
|
+
if (!cwd) {
|
|
3994
|
+
console.error(
|
|
3995
|
+
chalk16.red(
|
|
3996
|
+
"No linked project found. Run `yapout init` or `yapout link` in your repo first."
|
|
3997
|
+
)
|
|
3998
|
+
);
|
|
3999
|
+
process.exit(1);
|
|
4000
|
+
}
|
|
4001
|
+
const label = parsed.ticketId ? `${parsed.action} ${parsed.ticketId}` : parsed.action;
|
|
4002
|
+
console.log(chalk16.dim(`Launching Claude Code to ${label}...`));
|
|
4003
|
+
launchTerminal(cwd, ["--dangerously-skip-permissions", prompt]);
|
|
4004
|
+
setTimeout(() => process.exit(0), 500);
|
|
4005
|
+
} catch (err) {
|
|
4006
|
+
console.error(chalk16.red(err.message));
|
|
4007
|
+
process.exit(1);
|
|
4008
|
+
}
|
|
4009
|
+
});
|
|
4010
|
+
|
|
4011
|
+
// src/index.ts
|
|
4012
|
+
var program = new Command16();
|
|
4013
|
+
program.name("yapout").description("yapout \u2014 from meeting transcript to merged PR").version("0.1.0");
|
|
4014
|
+
program.addCommand(loginCommand);
|
|
4015
|
+
program.addCommand(logoutCommand);
|
|
4016
|
+
program.addCommand(initCommand);
|
|
4017
|
+
program.addCommand(linkCommand);
|
|
4018
|
+
program.addCommand(unlinkCommand);
|
|
4019
|
+
program.addCommand(statusCommand);
|
|
4020
|
+
program.addCommand(mcpServerCommand);
|
|
4021
|
+
program.addCommand(worktreesCommand);
|
|
4022
|
+
program.addCommand(cleanCommand);
|
|
4023
|
+
program.addCommand(watchCommand);
|
|
4024
|
+
program.addCommand(queueCommand);
|
|
4025
|
+
program.addCommand(nextCommand);
|
|
4026
|
+
program.addCommand(recapCommand);
|
|
4027
|
+
program.addCommand(yapCommand);
|
|
4028
|
+
program.addCommand(handleUriCommand);
|
|
4029
|
+
program.parse();
|