yapout 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +580 -201
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -239,9 +239,10 @@ def main():
239
239
  # Build claude prompt based on action
240
240
  if action == "claim":
241
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.'
242
+ f'Use yapout to implement work item "{ticket_id}". '
243
+ f'Claim it with yapout_claim using workItemId (use worktree mode), read the brief, '
244
+ f'implement the changes, run yapout_check, then ship with yapout_ship. '
245
+ f'The ID may be a bundle or standalone finding \u2014 yapout_claim auto-detects.'
245
246
  )
246
247
  elif action == "enrich":
247
248
  prompt = (
@@ -1086,12 +1087,42 @@ function registerUpdateContextTool(server, ctx) {
1086
1087
 
1087
1088
  // src/mcp/tools/queue.ts
1088
1089
  import { z as z3 } from "zod";
1090
+ function formatFinding(f, indent, done) {
1091
+ const ref = f.linearIssueId ? ` ${f.linearIssueId}` : "";
1092
+ const prefix = done ? "\u2713 " : "";
1093
+ return `${indent}${prefix}${f.title} ${f.type}\xB7${f.priority}${ref}`;
1094
+ }
1095
+ function formatWorkItem(item, done) {
1096
+ const lines = [];
1097
+ if (item.kind === "bundle") {
1098
+ const count = item.findings.length;
1099
+ const priority = item.findings.length > 0 ? item.findings.map((f) => f.priority).sort((a, b) => {
1100
+ const order = { urgent: 0, high: 1, medium: 2, low: 3 };
1101
+ return (order[a] ?? 3) - (order[b] ?? 3);
1102
+ })[0] : "medium";
1103
+ const prefix = done ? "\u2713 " : "";
1104
+ const prRef = done && item.pr?.githubPrNumber ? ` PR #${item.pr.githubPrNumber}` : "";
1105
+ lines.push(` ${prefix}\u{1F4E6} ${item.title} (${count} findings) \u2014 ${priority}${prRef}`);
1106
+ for (const f of item.findings) {
1107
+ lines.push(formatFinding(f, " ", done));
1108
+ }
1109
+ } else {
1110
+ const f = item.findings[0];
1111
+ if (f) {
1112
+ const prRef = done && item.pr?.githubPrNumber ? ` PR #${item.pr.githubPrNumber}` : "";
1113
+ const prefix = done ? "\u2713 " : "";
1114
+ const ref = f.linearIssueId ? ` ${f.linearIssueId}` : "";
1115
+ lines.push(` ${prefix}${item.title} ${f.type}\xB7${f.priority}${ref}${prRef}`);
1116
+ }
1117
+ }
1118
+ return lines.join("\n");
1119
+ }
1089
1120
  function registerQueueTool(server, ctx) {
1090
1121
  server.tool(
1091
1122
  "yapout_queue",
1092
- "List findings ready for local implementation. Only returns findings in backlog/unstarted Linear status.",
1123
+ "List work items (bundles and standalone findings) ready for local implementation, plus active and done items.",
1093
1124
  {
1094
- includeBlocked: z3.boolean().optional().describe("Show blocked findings too (default: false)")
1125
+ includeIds: z3.boolean().optional().describe("Include work item IDs in output (default: false)")
1095
1126
  },
1096
1127
  async (args) => {
1097
1128
  if (!ctx.projectId) {
@@ -1106,55 +1137,84 @@ function registerQueueTool(server, ctx) {
1106
1137
  };
1107
1138
  }
1108
1139
  const data = await ctx.client.query(
1109
- anyApi2.functions.findings.getQueuedWorkItems,
1140
+ anyApi2.functions.workQueue.getWorkQueue,
1110
1141
  { projectId: ctx.projectId }
1111
1142
  );
1112
1143
  if (!data) {
1113
1144
  return {
1114
1145
  content: [
1115
- { type: "text", text: "Could not fetch queue." }
1146
+ { type: "text", text: "Could not fetch work queue." }
1116
1147
  ],
1117
1148
  isError: true
1118
1149
  };
1119
1150
  }
1120
- let ready = data.ready;
1121
- ready = ready.filter((t) => t.nature !== "operational");
1122
- const linearIds = ready.map((t) => t.linearIssueId).filter((id) => !!id);
1123
- if (linearIds.length > 0) {
1124
- try {
1125
- const statuses = await ctx.client.action(
1126
- anyApi2.functions.linearStatusMutations.getIssueStatuses,
1127
- { projectId: ctx.projectId, linearIssueIds: linearIds }
1128
- );
1129
- const statusMap = new Map(
1130
- statuses.map((s) => [s.linearIssueId, s.statusType])
1131
- );
1132
- ready = ready.filter((t) => {
1133
- if (!t.linearIssueId) return true;
1134
- const type = statusMap.get(t.linearIssueId);
1135
- if (!type) return true;
1136
- return type === "backlog" || type === "unstarted";
1137
- });
1138
- } catch {
1151
+ const sections = [];
1152
+ sections.push(`Ready (${data.ready.length}):`);
1153
+ if (data.ready.length === 0) {
1154
+ sections.push(" (none)");
1155
+ } else {
1156
+ for (const item of data.ready) {
1157
+ const line = formatWorkItem(item);
1158
+ if (args.includeIds) {
1159
+ sections.push(`${line} [${item.id}]`);
1160
+ } else {
1161
+ sections.push(line);
1162
+ }
1139
1163
  }
1140
1164
  }
1141
- const result = {
1142
- ready,
1143
- contextStale: data.contextStale,
1144
- contextLastUpdated: data.contextLastUpdated
1145
- };
1146
- if (args.includeBlocked) {
1147
- result.blocked = data.blocked;
1165
+ sections.push("");
1166
+ sections.push(`Active (${data.active.length}):`);
1167
+ if (data.active.length === 0) {
1168
+ sections.push(" (none)");
1169
+ } else {
1170
+ for (const item of data.active) {
1171
+ const statusTag = item.status === "failed" ? " \u274C FAILED" : item.status === "review" ? " \u{1F50D} review" : "";
1172
+ const line = formatWorkItem(item);
1173
+ if (args.includeIds) {
1174
+ sections.push(`${line}${statusTag} [${item.id}]`);
1175
+ } else {
1176
+ sections.push(`${line}${statusTag}`);
1177
+ }
1178
+ }
1148
1179
  }
1149
- if (data.contextStale) {
1150
- const daysAgo = data.contextLastUpdated ? Math.round(
1151
- (Date.now() - data.contextLastUpdated) / 864e5
1152
- ) : null;
1153
- result.note = daysAgo ? `Project context was last updated ${daysAgo} days ago. Consider running yapout_compact.` : "Project context has never been generated. Run yapout_compact.";
1180
+ sections.push("");
1181
+ sections.push(`Done (${data.done.length}):`);
1182
+ if (data.done.length === 0) {
1183
+ sections.push(" (none)");
1184
+ } else {
1185
+ for (const item of data.done) {
1186
+ const line = formatWorkItem(item, true);
1187
+ if (args.includeIds) {
1188
+ sections.push(`${line} [${item.id}]`);
1189
+ } else {
1190
+ sections.push(line);
1191
+ }
1192
+ }
1193
+ }
1194
+ if (data.agentStatus.isActive) {
1195
+ sections.push("");
1196
+ sections.push(`Agent: ${data.agentStatus.agentCount} active, ${data.agentStatus.worktreeCount} worktrees`);
1154
1197
  }
1198
+ const structured = {
1199
+ ready: data.ready.map((item) => ({
1200
+ workItemId: item.id,
1201
+ kind: item.kind,
1202
+ title: item.title,
1203
+ findings: item.findings.map((f) => ({
1204
+ id: f.id,
1205
+ title: f.title,
1206
+ type: f.type,
1207
+ priority: f.priority,
1208
+ linearIssueId: f.linearIssueId
1209
+ }))
1210
+ })),
1211
+ activeCount: data.active.length,
1212
+ doneCount: data.done.length
1213
+ };
1155
1214
  return {
1156
1215
  content: [
1157
- { type: "text", text: JSON.stringify(result, null, 2) }
1216
+ { type: "text", text: sections.join("\n") },
1217
+ { type: "text", text: "\n---\nStructured data:\n" + JSON.stringify(structured, null, 2) }
1158
1218
  ]
1159
1219
  };
1160
1220
  }
@@ -1166,30 +1226,84 @@ import { z as z4 } from "zod";
1166
1226
  function registerGetBriefTool(server, ctx) {
1167
1227
  server.tool(
1168
1228
  "yapout_get_brief",
1169
- "Fetch the full implementation context for a finding",
1229
+ "Fetch the full implementation context for a work item (finding or bundle)",
1170
1230
  {
1171
- findingId: z4.string().describe("The finding ID to get the brief for")
1231
+ findingId: z4.string().optional().describe("The finding ID to get the brief for (deprecated: use workItemId)"),
1232
+ workItemId: z4.string().optional().describe("The work item ID (finding or bundle) to get the brief for")
1172
1233
  },
1173
1234
  async (args) => {
1174
- const data = await ctx.client.query(
1175
- anyApi2.functions.findings.getFindingBrief,
1176
- { findingId: args.findingId }
1177
- );
1178
- if (!data) {
1235
+ const itemId = args.workItemId || args.findingId;
1236
+ if (!itemId) {
1179
1237
  return {
1180
1238
  content: [
1181
1239
  {
1182
1240
  type: "text",
1183
- text: "Finding not found or you don't have access."
1241
+ text: "Must provide workItemId or findingId."
1184
1242
  }
1185
1243
  ],
1186
1244
  isError: true
1187
1245
  };
1188
1246
  }
1247
+ try {
1248
+ const data = await ctx.client.query(
1249
+ anyApi2.functions.findings.getFindingBrief,
1250
+ { findingId: itemId }
1251
+ );
1252
+ if (data) {
1253
+ return {
1254
+ content: [
1255
+ { type: "text", text: JSON.stringify(data, null, 2) }
1256
+ ]
1257
+ };
1258
+ }
1259
+ } catch {
1260
+ }
1261
+ try {
1262
+ const bundle = await ctx.client.query(
1263
+ anyApi2.functions.bundles.getBundle,
1264
+ { bundleId: itemId }
1265
+ );
1266
+ if (bundle) {
1267
+ const result = {
1268
+ kind: "bundle",
1269
+ bundle: {
1270
+ id: bundle._id,
1271
+ title: bundle.title,
1272
+ description: bundle.description,
1273
+ enrichedDescription: bundle.enrichedDescription,
1274
+ acceptanceCriteria: bundle.acceptanceCriteria,
1275
+ implementationBrief: bundle.implementationBrief
1276
+ },
1277
+ findings: bundle.findings.map((f) => ({
1278
+ id: f._id,
1279
+ title: f.title,
1280
+ description: f.description,
1281
+ priority: f.priority,
1282
+ type: f.type,
1283
+ linearIssueId: f.linearIssueId,
1284
+ linearIssueUrl: f.linearIssueUrl,
1285
+ enrichedDescription: f.enrichedDescription,
1286
+ acceptanceCriteria: f.acceptanceCriteria,
1287
+ implementationBrief: f.implementationBrief,
1288
+ dependsOn: f.dependsOn
1289
+ }))
1290
+ };
1291
+ return {
1292
+ content: [
1293
+ { type: "text", text: JSON.stringify(result, null, 2) }
1294
+ ]
1295
+ };
1296
+ }
1297
+ } catch {
1298
+ }
1189
1299
  return {
1190
1300
  content: [
1191
- { type: "text", text: JSON.stringify(data, null, 2) }
1192
- ]
1301
+ {
1302
+ type: "text",
1303
+ text: "Work item not found or you don't have access. Provide a valid finding ID or bundle ID."
1304
+ }
1305
+ ],
1306
+ isError: true
1193
1307
  };
1194
1308
  }
1195
1309
  );
@@ -1210,7 +1324,7 @@ function readBranchPrefix(cwd) {
1210
1324
  function slugify(text) {
1211
1325
  return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
1212
1326
  }
1213
- function formatBrief(data) {
1327
+ function formatFindingBrief(data) {
1214
1328
  const finding = data.finding;
1215
1329
  const sections = [
1216
1330
  `# ${finding.title}`,
@@ -1251,12 +1365,89 @@ function formatBrief(data) {
1251
1365
  }
1252
1366
  return sections.join("\n");
1253
1367
  }
1368
+ function formatBundleBrief(bundle, projectContext) {
1369
+ const sections = [
1370
+ `# Bundle: ${bundle.title}`,
1371
+ "",
1372
+ `**${bundle.findings.length} findings**`
1373
+ ];
1374
+ if (bundle.description) {
1375
+ sections.push("", "## Bundle Description", "", bundle.description);
1376
+ }
1377
+ if (bundle.enrichedDescription) {
1378
+ sections.push("", "## Enriched Description", "", bundle.enrichedDescription);
1379
+ }
1380
+ if (bundle.acceptanceCriteria && bundle.acceptanceCriteria.length > 0) {
1381
+ sections.push("", "## Acceptance Criteria");
1382
+ for (const ac of bundle.acceptanceCriteria) {
1383
+ sections.push(`- [ ] ${ac}`);
1384
+ }
1385
+ }
1386
+ if (bundle.implementationBrief) {
1387
+ sections.push("", "## Implementation Brief", "", bundle.implementationBrief);
1388
+ }
1389
+ sections.push("", "---", "", "## Findings (Execution Order)", "");
1390
+ for (let i = 0; i < bundle.findings.length; i++) {
1391
+ const f = bundle.findings[i];
1392
+ const linearRef = f.linearIssueId ? ` (${f.linearIssueId})` : "";
1393
+ const deps = f.dependsOn && f.dependsOn.length > 0 ? `
1394
+ **Depends on:** ${f.dependsOn.map((d) => {
1395
+ const dep = bundle.findings.find((bf) => bf._id === d);
1396
+ return dep ? dep.title : d;
1397
+ }).join(", ")}` : "";
1398
+ sections.push(`### ${i + 1}. ${f.title}${linearRef}`);
1399
+ sections.push("");
1400
+ sections.push(`**Priority:** ${f.priority} | **Type:** ${f.type}`);
1401
+ if (f.linearIssueUrl) {
1402
+ sections.push(`**Linear:** ${f.linearIssueUrl}`);
1403
+ }
1404
+ if (deps) sections.push(deps);
1405
+ sections.push("");
1406
+ if (f.enrichedDescription) {
1407
+ sections.push(f.enrichedDescription);
1408
+ sections.push("");
1409
+ } else if (f.description) {
1410
+ sections.push(f.description);
1411
+ sections.push("");
1412
+ }
1413
+ if (f.acceptanceCriteria && f.acceptanceCriteria.length > 0) {
1414
+ sections.push("**Acceptance Criteria:**");
1415
+ for (const ac of f.acceptanceCriteria) {
1416
+ sections.push(`- [ ] ${ac}`);
1417
+ }
1418
+ sections.push("");
1419
+ }
1420
+ if (f.implementationBrief) {
1421
+ sections.push("**Implementation Brief:**");
1422
+ sections.push(f.implementationBrief);
1423
+ sections.push("");
1424
+ }
1425
+ }
1426
+ if (projectContext) {
1427
+ sections.push("---", "", "## Project Context", "", projectContext);
1428
+ }
1429
+ return sections.join("\n");
1430
+ }
1431
+ async function detectWorkItemKind(client, workItemId) {
1432
+ try {
1433
+ const bundle = await client.query(
1434
+ anyApi2.functions.bundles.getBundle,
1435
+ { bundleId: workItemId }
1436
+ );
1437
+ if (bundle) {
1438
+ return { kind: "bundle", data: bundle };
1439
+ }
1440
+ } catch {
1441
+ }
1442
+ return { kind: "standalone", data: null };
1443
+ }
1254
1444
  function registerClaimTool(server, ctx) {
1255
1445
  server.tool(
1256
1446
  "yapout_claim",
1257
- "Claim a finding for local implementation. Creates a branch (or worktree), writes the brief, and updates status.",
1447
+ "Claim a work item (bundle or standalone finding) for local implementation. Creates a branch (or worktree), writes the brief, and updates status.",
1258
1448
  {
1259
- findingId: z5.string().describe("The finding ID to claim"),
1449
+ workItemId: z5.string().describe("The work item ID to claim (bundle ID or finding ID from yapout_queue)"),
1450
+ findingId: z5.string().optional().describe("Deprecated: use workItemId instead. If provided, treated as a standalone finding."),
1260
1451
  worktree: z5.boolean().optional().describe("Create a git worktree for parallel work (default: false)")
1261
1452
  },
1262
1453
  async (args) => {
@@ -1271,131 +1462,262 @@ function registerClaimTool(server, ctx) {
1271
1462
  isError: true
1272
1463
  };
1273
1464
  }
1274
- const briefData = await ctx.client.query(
1275
- anyApi2.functions.findings.getFindingBrief,
1276
- { findingId: args.findingId }
1277
- );
1278
- if (!briefData) {
1465
+ const itemId = args.workItemId || args.findingId;
1466
+ if (!itemId) {
1279
1467
  return {
1280
1468
  content: [
1281
1469
  {
1282
1470
  type: "text",
1283
- text: "Finding not found or you don't have access."
1471
+ text: "Must provide workItemId (or deprecated findingId)."
1284
1472
  }
1285
1473
  ],
1286
1474
  isError: true
1287
1475
  };
1288
1476
  }
1289
- const finding = briefData.finding;
1290
- const linearIssueId = briefData.linearIssueId;
1291
- const defaultBranch = briefData.defaultBranch || "main";
1292
- const prefix = readBranchPrefix(ctx.cwd);
1293
- const slug = slugify(finding.title);
1294
- const branchName = linearIssueId ? `${prefix}/${linearIssueId.toLowerCase()}-${slug}` : `${prefix}/${slug}`;
1295
- const claim = await ctx.client.mutation(
1296
- anyApi2.functions.findings.claimFindingLocal,
1297
- { findingId: args.findingId, branchName }
1298
- );
1299
- if (linearIssueId && ctx.projectId) {
1300
- try {
1301
- await ctx.client.action(
1302
- anyApi2.functions.linearStatusMutations.moveIssueStatus,
1303
- {
1304
- projectId: ctx.projectId,
1305
- linearIssueId,
1306
- statusType: "started"
1307
- }
1308
- );
1309
- } catch {
1310
- }
1477
+ const { kind, data: bundleData } = await detectWorkItemKind(ctx.client, itemId);
1478
+ if (kind === "bundle" && bundleData) {
1479
+ return await claimBundle(ctx, args, itemId, bundleData);
1480
+ } else {
1481
+ return await claimStandalone(ctx, args, itemId);
1311
1482
  }
1312
- let worktreePath;
1313
- if (args.worktree) {
1314
- fetchOrigin(ctx.cwd);
1315
- worktreePath = createWorktree(
1316
- ctx.cwd,
1317
- args.findingId,
1318
- branchName,
1319
- defaultBranch
1320
- );
1321
- const wtYapoutDir = join7(worktreePath, ".yapout");
1322
- if (!existsSync6(wtYapoutDir)) mkdirSync6(wtYapoutDir, { recursive: true });
1323
- const brief2 = formatBrief(briefData);
1324
- writeFileSync7(join7(wtYapoutDir, "brief.md"), brief2);
1325
- try {
1326
- await ctx.client.mutation(
1327
- anyApi2.functions.pipelineRuns.reportDaemonEvent,
1328
- {
1329
- pipelineRunId: claim.pipelineRunId,
1330
- event: "worktree_created",
1331
- message: `Worktree: ${worktreePath}`
1332
- }
1333
- );
1334
- } catch {
1483
+ }
1484
+ );
1485
+ }
1486
+ async function claimStandalone(ctx, args, findingId) {
1487
+ const briefData = await ctx.client.query(
1488
+ anyApi2.functions.findings.getFindingBrief,
1489
+ { findingId }
1490
+ );
1491
+ if (!briefData) {
1492
+ return {
1493
+ content: [
1494
+ {
1495
+ type: "text",
1496
+ text: "Finding not found or you don't have access."
1335
1497
  }
1336
- return {
1337
- content: [
1338
- {
1339
- type: "text",
1340
- text: JSON.stringify(
1341
- {
1342
- branch: branchName,
1343
- worktreePath,
1344
- briefPath: `${worktreePath}/.yapout/brief.md`,
1345
- brief: formatBrief(briefData),
1346
- pipelineRunId: claim.pipelineRunId
1347
- },
1348
- null,
1349
- 2
1350
- )
1351
- }
1352
- ]
1353
- };
1498
+ ],
1499
+ isError: true
1500
+ };
1501
+ }
1502
+ const finding = briefData.finding;
1503
+ const linearIssueId = briefData.linearIssueId;
1504
+ const defaultBranch = briefData.defaultBranch || "main";
1505
+ const prefix = readBranchPrefix(ctx.cwd);
1506
+ const slug = slugify(finding.title);
1507
+ const branchName = linearIssueId ? `${prefix}/${linearIssueId.toLowerCase()}-${slug}` : `${prefix}/${slug}`;
1508
+ const localClaim = await ctx.client.mutation(
1509
+ anyApi2.functions.findings.claimFindingLocal,
1510
+ { findingId, branchName }
1511
+ );
1512
+ const claim = await ctx.client.mutation(
1513
+ anyApi2.functions.workQueue.claimForImplementation,
1514
+ {
1515
+ projectId: ctx.projectId,
1516
+ workItemId: findingId,
1517
+ workItemKind: "standalone"
1518
+ }
1519
+ );
1520
+ if (linearIssueId && ctx.projectId) {
1521
+ try {
1522
+ await ctx.client.action(
1523
+ anyApi2.functions.linearStatusMutations.moveIssueStatus,
1524
+ {
1525
+ projectId: ctx.projectId,
1526
+ linearIssueId,
1527
+ statusType: "started"
1528
+ }
1529
+ );
1530
+ } catch {
1531
+ }
1532
+ }
1533
+ const brief = formatFindingBrief(briefData);
1534
+ if (args.worktree) {
1535
+ return await setupWorktree(ctx, findingId, branchName, defaultBranch, brief, localClaim.pipelineRunId);
1536
+ }
1537
+ fetchOrigin(ctx.cwd);
1538
+ checkoutNewBranch(branchName, defaultBranch, ctx.cwd);
1539
+ writeBrief(ctx.cwd, brief);
1540
+ reportClaimEvents(ctx, localClaim.pipelineRunId, finding.title, branchName);
1541
+ return {
1542
+ content: [
1543
+ {
1544
+ type: "text",
1545
+ text: JSON.stringify(
1546
+ {
1547
+ kind: "standalone",
1548
+ branch: branchName,
1549
+ briefPath: ".yapout/brief.md",
1550
+ brief,
1551
+ pipelineRunId: localClaim.pipelineRunId,
1552
+ executionPlan: claim.executionPlan
1553
+ },
1554
+ null,
1555
+ 2
1556
+ )
1354
1557
  }
1355
- fetchOrigin(ctx.cwd);
1356
- checkoutNewBranch(branchName, defaultBranch, ctx.cwd);
1357
- const yapoutDir = join7(ctx.cwd, ".yapout");
1358
- if (!existsSync6(yapoutDir)) mkdirSync6(yapoutDir, { recursive: true });
1359
- const brief = formatBrief(briefData);
1360
- writeFileSync7(join7(yapoutDir, "brief.md"), brief);
1558
+ ]
1559
+ };
1560
+ }
1561
+ async function claimBundle(ctx, args, bundleId, bundleData) {
1562
+ const defaultBranch = await getDefaultBranchForProject(ctx);
1563
+ const prefix = readBranchPrefix(ctx.cwd);
1564
+ const firstLinearId = bundleData.findings.find((f) => f.linearIssueId)?.linearIssueId;
1565
+ const slug = slugify(bundleData.title);
1566
+ const branchName = firstLinearId ? `${prefix}/${firstLinearId.toLowerCase()}-${slug}` : `${prefix}/bundle-${slug}`;
1567
+ const primaryFinding = bundleData.findings[0];
1568
+ if (!primaryFinding) {
1569
+ return {
1570
+ content: [
1571
+ {
1572
+ type: "text",
1573
+ text: "Bundle has no findings."
1574
+ }
1575
+ ],
1576
+ isError: true
1577
+ };
1578
+ }
1579
+ const localClaim = await ctx.client.mutation(
1580
+ anyApi2.functions.findings.claimFindingLocal,
1581
+ { findingId: primaryFinding._id, branchName }
1582
+ );
1583
+ const claim = await ctx.client.mutation(
1584
+ anyApi2.functions.workQueue.claimForImplementation,
1585
+ {
1586
+ projectId: ctx.projectId,
1587
+ workItemId: bundleId,
1588
+ workItemKind: "bundle"
1589
+ }
1590
+ );
1591
+ for (const f of bundleData.findings) {
1592
+ if (f.linearIssueId && ctx.projectId) {
1361
1593
  try {
1362
- await ctx.client.mutation(
1363
- anyApi2.functions.pipelineRuns.reportDaemonEvent,
1364
- {
1365
- pipelineRunId: claim.pipelineRunId,
1366
- event: "daemon_claimed",
1367
- message: `Claimed finding: ${finding.title}`
1368
- }
1369
- );
1370
- await ctx.client.mutation(
1371
- anyApi2.functions.pipelineRuns.reportDaemonEvent,
1594
+ await ctx.client.action(
1595
+ anyApi2.functions.linearStatusMutations.moveIssueStatus,
1372
1596
  {
1373
- pipelineRunId: claim.pipelineRunId,
1374
- event: "branch_created",
1375
- message: `Branch: ${branchName}`
1597
+ projectId: ctx.projectId,
1598
+ linearIssueId: f.linearIssueId,
1599
+ statusType: "started"
1376
1600
  }
1377
1601
  );
1378
1602
  } catch {
1379
1603
  }
1380
- return {
1381
- content: [
1382
- {
1383
- type: "text",
1384
- text: JSON.stringify(
1385
- {
1386
- branch: branchName,
1387
- briefPath: ".yapout/brief.md",
1388
- brief,
1389
- pipelineRunId: claim.pipelineRunId
1390
- },
1391
- null,
1392
- 2
1393
- )
1394
- }
1395
- ]
1396
- };
1397
1604
  }
1605
+ }
1606
+ let projectContext;
1607
+ try {
1608
+ const briefData = await ctx.client.query(
1609
+ anyApi2.functions.findings.getFindingBrief,
1610
+ { findingId: primaryFinding._id }
1611
+ );
1612
+ projectContext = briefData?.projectContext;
1613
+ } catch {
1614
+ }
1615
+ const brief = formatBundleBrief(bundleData, projectContext);
1616
+ if (args.worktree) {
1617
+ return await setupWorktree(ctx, bundleId, branchName, defaultBranch, brief, localClaim.pipelineRunId);
1618
+ }
1619
+ fetchOrigin(ctx.cwd);
1620
+ checkoutNewBranch(branchName, defaultBranch, ctx.cwd);
1621
+ writeBrief(ctx.cwd, brief);
1622
+ reportClaimEvents(ctx, localClaim.pipelineRunId, `Bundle: ${bundleData.title}`, branchName);
1623
+ return {
1624
+ content: [
1625
+ {
1626
+ type: "text",
1627
+ text: JSON.stringify(
1628
+ {
1629
+ kind: "bundle",
1630
+ bundleId,
1631
+ bundleTitle: bundleData.title,
1632
+ findingCount: bundleData.findings.length,
1633
+ branch: branchName,
1634
+ briefPath: ".yapout/brief.md",
1635
+ brief,
1636
+ pipelineRunId: localClaim.pipelineRunId,
1637
+ executionPlan: claim.executionPlan
1638
+ },
1639
+ null,
1640
+ 2
1641
+ )
1642
+ }
1643
+ ]
1644
+ };
1645
+ }
1646
+ async function getDefaultBranchForProject(ctx) {
1647
+ try {
1648
+ const data = await ctx.client.query(
1649
+ anyApi2.functions.projects.getProject,
1650
+ { projectId: ctx.projectId }
1651
+ );
1652
+ return data?.githubDefaultBranch || "main";
1653
+ } catch {
1654
+ return "main";
1655
+ }
1656
+ }
1657
+ async function setupWorktree(ctx, itemId, branchName, defaultBranch, brief, pipelineRunId) {
1658
+ fetchOrigin(ctx.cwd);
1659
+ const worktreePath = createWorktree(
1660
+ ctx.cwd,
1661
+ itemId,
1662
+ branchName,
1663
+ defaultBranch
1398
1664
  );
1665
+ writeBrief(worktreePath, brief);
1666
+ try {
1667
+ await ctx.client.mutation(
1668
+ anyApi2.functions.pipelineRuns.reportDaemonEvent,
1669
+ {
1670
+ pipelineRunId,
1671
+ event: "worktree_created",
1672
+ message: `Worktree: ${worktreePath}`
1673
+ }
1674
+ );
1675
+ } catch {
1676
+ }
1677
+ return {
1678
+ content: [
1679
+ {
1680
+ type: "text",
1681
+ text: JSON.stringify(
1682
+ {
1683
+ branch: branchName,
1684
+ worktreePath,
1685
+ briefPath: `${worktreePath}/.yapout/brief.md`,
1686
+ brief,
1687
+ pipelineRunId
1688
+ },
1689
+ null,
1690
+ 2
1691
+ )
1692
+ }
1693
+ ]
1694
+ };
1695
+ }
1696
+ function writeBrief(dir, brief) {
1697
+ const yapoutDir = join7(dir, ".yapout");
1698
+ if (!existsSync6(yapoutDir)) mkdirSync6(yapoutDir, { recursive: true });
1699
+ writeFileSync7(join7(yapoutDir, "brief.md"), brief);
1700
+ }
1701
+ async function reportClaimEvents(ctx, pipelineRunId, title, branchName) {
1702
+ try {
1703
+ await ctx.client.mutation(
1704
+ anyApi2.functions.pipelineRuns.reportDaemonEvent,
1705
+ {
1706
+ pipelineRunId,
1707
+ event: "daemon_claimed",
1708
+ message: `Claimed: ${title}`
1709
+ }
1710
+ );
1711
+ await ctx.client.mutation(
1712
+ anyApi2.functions.pipelineRuns.reportDaemonEvent,
1713
+ {
1714
+ pipelineRunId,
1715
+ event: "branch_created",
1716
+ message: `Branch: ${branchName}`
1717
+ }
1718
+ );
1719
+ } catch {
1720
+ }
1399
1721
  }
1400
1722
 
1401
1723
  // src/mcp/tools/event.ts
@@ -1507,20 +1829,24 @@ async function createPullRequest(title, body, branch, base, repoFullName, cwd) {
1507
1829
 
1508
1830
  // src/mcp/tools/ship.ts
1509
1831
  import { join as join8 } from "path";
1510
- import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
1511
- function buildCommitMessage(message, template, finding) {
1832
+ import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
1833
+ function buildCommitMessage(message, template, finding, allLinearIds) {
1512
1834
  if (message) return message;
1513
1835
  if (template) {
1514
1836
  return template.replace(/\{\{finding\.type\}\}/g, finding.type).replace(/\{\{finding\.title\}\}/g, finding.title).replace(/\{\{finding\.linearIssueId\}\}/g, finding.linearIssueId ?? "draft").replace(/\{\{finding\.id\}\}/g, finding.id ?? "").replace(/\{\{finding\.priority\}\}/g, finding.priority ?? "medium");
1515
1837
  }
1516
1838
  const prefix = finding.type === "bug" ? "fix" : "feat";
1839
+ if (allLinearIds && allLinearIds.length > 1) {
1840
+ const refs = allLinearIds.join(", ");
1841
+ return `${prefix}: ${finding.title} (${refs})`;
1842
+ }
1517
1843
  const ref = finding.linearIssueId ? ` (${finding.linearIssueId})` : "";
1518
1844
  return `${prefix}(${finding.type}): ${finding.title}${ref}`;
1519
1845
  }
1520
1846
  function registerShipTool(server, ctx) {
1521
1847
  server.tool(
1522
1848
  "yapout_ship",
1523
- "Commit, push, open a PR, and mark the finding as done. Run yapout_check first if post-flight checks are configured.",
1849
+ "Commit, push, open a PR, and mark the work item as done. Run yapout_check first if post-flight checks are configured.",
1524
1850
  {
1525
1851
  message: z7.string().optional().describe("Custom commit message (overrides template)"),
1526
1852
  skipPr: z7.boolean().optional().describe("Just push, don't open a PR"),
@@ -1550,24 +1876,53 @@ function registerShipTool(server, ctx) {
1550
1876
  let findingTitle = branch;
1551
1877
  let findingType = "feature";
1552
1878
  let findingLinearId;
1879
+ let allLinearIds = [];
1880
+ let isBundle = false;
1881
+ let bundleTitle;
1553
1882
  try {
1554
1883
  const briefPath = join8(gitCwd, ".yapout", "brief.md");
1555
1884
  if (existsSync7(briefPath)) {
1556
- const brief = readFileSync6(briefPath, "utf-8");
1557
- const titleMatch = brief.match(/^# (.+)$/m);
1558
- if (titleMatch) findingTitle = titleMatch[1];
1559
- const typeMatch = brief.match(/\*\*Type:\*\* (\w+)/);
1560
- if (typeMatch) findingType = typeMatch[1];
1561
- const linearMatch = brief.match(/\*\*Linear:\*\* .+\/([A-Z]+-\d+)\//);
1562
- if (linearMatch) findingLinearId = linearMatch[1];
1885
+ const brief = readFileSync5(briefPath, "utf-8");
1886
+ const bundleMatch = brief.match(/^# Bundle: (.+)$/m);
1887
+ if (bundleMatch) {
1888
+ isBundle = true;
1889
+ bundleTitle = bundleMatch[1];
1890
+ findingTitle = bundleTitle;
1891
+ const linearMatches = brief.matchAll(/\(([A-Z]+-\d+)\)/g);
1892
+ for (const match of linearMatches) {
1893
+ if (!allLinearIds.includes(match[1])) {
1894
+ allLinearIds.push(match[1]);
1895
+ }
1896
+ }
1897
+ if (allLinearIds.length > 0) {
1898
+ findingLinearId = allLinearIds[0];
1899
+ }
1900
+ const typeMatch = brief.match(/\*\*Type:\*\* (\w+)/);
1901
+ if (typeMatch) findingType = typeMatch[1];
1902
+ } else {
1903
+ const titleMatch = brief.match(/^# (.+)$/m);
1904
+ if (titleMatch) findingTitle = titleMatch[1];
1905
+ const typeMatch = brief.match(/\*\*Type:\*\* (\w+)/);
1906
+ if (typeMatch) findingType = typeMatch[1];
1907
+ const linearMatch = brief.match(/\*\*Linear:\*\* .+\/([A-Z]+-\d+)\//);
1908
+ if (linearMatch) {
1909
+ findingLinearId = linearMatch[1];
1910
+ allLinearIds = [findingLinearId];
1911
+ }
1912
+ }
1563
1913
  }
1564
1914
  } catch {
1565
1915
  }
1566
- const commitMsg = buildCommitMessage(args.message, config.commit_template, {
1567
- title: findingTitle,
1568
- type: findingType,
1569
- linearIssueId: findingLinearId
1570
- });
1916
+ const commitMsg = buildCommitMessage(
1917
+ args.message,
1918
+ config.commit_template,
1919
+ {
1920
+ title: findingTitle,
1921
+ type: findingType,
1922
+ linearIssueId: findingLinearId
1923
+ },
1924
+ allLinearIds.length > 1 ? allLinearIds : void 0
1925
+ );
1571
1926
  stageAll(gitCwd);
1572
1927
  const sha = commit(commitMsg, gitCwd);
1573
1928
  push(branch, gitCwd);
@@ -1576,6 +1931,11 @@ function registerShipTool(server, ctx) {
1576
1931
  branch,
1577
1932
  pushed: true
1578
1933
  };
1934
+ if (isBundle) {
1935
+ result.isBundle = true;
1936
+ result.bundleTitle = bundleTitle;
1937
+ result.linearIssueIds = allLinearIds;
1938
+ }
1579
1939
  if (!config.ship_requires_checks && config.post_flight.length > 0) {
1580
1940
  if (ctx.lastCheckPassedForRun !== args.pipelineRunId) {
1581
1941
  result.warning = "Shipped without running post-flight checks.";
@@ -1587,11 +1947,25 @@ function registerShipTool(server, ctx) {
1587
1947
  try {
1588
1948
  const repoFullName = getRepoFullName(gitCwd);
1589
1949
  const diffStats = getDiffStats(defaultBranch, branch, gitCwd);
1590
- const prBody = [
1950
+ const prBodyParts = [
1591
1951
  `## Summary`,
1592
- "",
1593
- findingTitle,
1594
- "",
1952
+ ""
1953
+ ];
1954
+ if (isBundle && bundleTitle) {
1955
+ prBodyParts.push(`**Bundle:** ${bundleTitle}`);
1956
+ prBodyParts.push("");
1957
+ if (allLinearIds.length > 0) {
1958
+ prBodyParts.push("**Linear Issues:**");
1959
+ for (const id of allLinearIds) {
1960
+ prBodyParts.push(`- ${id}`);
1961
+ }
1962
+ prBodyParts.push("");
1963
+ }
1964
+ } else {
1965
+ prBodyParts.push(findingTitle);
1966
+ prBodyParts.push("");
1967
+ }
1968
+ prBodyParts.push(
1595
1969
  `## Changes`,
1596
1970
  "",
1597
1971
  "```",
@@ -1600,7 +1974,8 @@ function registerShipTool(server, ctx) {
1600
1974
  "",
1601
1975
  "---",
1602
1976
  `Implemented via [yapout](https://yapout.dev) daemon`
1603
- ].join("\n");
1977
+ );
1978
+ const prBody = prBodyParts.join("\n");
1604
1979
  const pr = await createPullRequest(
1605
1980
  findingTitle,
1606
1981
  prBody,
@@ -1650,30 +2025,33 @@ function registerShipTool(server, ctx) {
1650
2025
  } catch (err) {
1651
2026
  result.completionError = err.message;
1652
2027
  }
1653
- if (findingLinearId && ctx.projectId) {
1654
- try {
1655
- await ctx.client.action(
1656
- anyApi2.functions.linearStatusMutations.moveIssueStatus,
1657
- {
1658
- projectId: ctx.projectId,
1659
- linearIssueId: findingLinearId,
1660
- statusType: "completed"
1661
- }
1662
- );
1663
- } catch {
1664
- }
1665
- if (prUrl) {
2028
+ const linearIdsToUpdate = allLinearIds.length > 0 ? allLinearIds : findingLinearId ? [findingLinearId] : [];
2029
+ for (const linearId of linearIdsToUpdate) {
2030
+ if (ctx.projectId) {
1666
2031
  try {
1667
2032
  await ctx.client.action(
1668
- anyApi2.functions.linearStatusMutations.addLinearComment,
2033
+ anyApi2.functions.linearStatusMutations.moveIssueStatus,
1669
2034
  {
1670
2035
  projectId: ctx.projectId,
1671
- linearIssueId: findingLinearId,
1672
- body: `PR opened: [#${prNumber}](${prUrl})`
2036
+ linearIssueId: linearId,
2037
+ statusType: "completed"
1673
2038
  }
1674
2039
  );
1675
2040
  } catch {
1676
2041
  }
2042
+ if (prUrl) {
2043
+ try {
2044
+ await ctx.client.action(
2045
+ anyApi2.functions.linearStatusMutations.addLinearComment,
2046
+ {
2047
+ projectId: ctx.projectId,
2048
+ linearIssueId: linearId,
2049
+ body: `PR opened: [#${prNumber}](${prUrl})`
2050
+ }
2051
+ );
2052
+ } catch {
2053
+ }
2054
+ }
1677
2055
  }
1678
2056
  }
1679
2057
  if (args.worktreePath) {
@@ -3267,7 +3645,7 @@ var cleanCommand = new Command9("clean").description("Remove worktrees for compl
3267
3645
  import { Command as Command10 } from "commander";
3268
3646
  import { resolve as resolve7 } from "path";
3269
3647
  import {
3270
- readFileSync as readFileSync7,
3648
+ readFileSync as readFileSync6,
3271
3649
  writeFileSync as writeFileSync9,
3272
3650
  existsSync as existsSync10,
3273
3651
  unlinkSync as unlinkSync3
@@ -3883,7 +4261,7 @@ var LOG_FILE = join11(getYapoutDir(), "watch.log");
3883
4261
  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) => {
3884
4262
  if (opts.status) {
3885
4263
  if (existsSync10(PID_FILE)) {
3886
- const pid = parseInt(readFileSync7(PID_FILE, "utf-8").trim(), 10);
4264
+ const pid = parseInt(readFileSync6(PID_FILE, "utf-8").trim(), 10);
3887
4265
  if (isProcessRunning(pid)) {
3888
4266
  console.log(
3889
4267
  chalk11.green("Watcher is running") + chalk11.dim(` (PID ${pid})`)
@@ -3902,7 +4280,7 @@ var watchCommand = new Command10("watch").description("Watch for work and spawn
3902
4280
  console.log(chalk11.dim("No watcher running"));
3903
4281
  return;
3904
4282
  }
3905
- const pid = parseInt(readFileSync7(PID_FILE, "utf-8").trim(), 10);
4283
+ const pid = parseInt(readFileSync6(PID_FILE, "utf-8").trim(), 10);
3906
4284
  try {
3907
4285
  process.kill(pid, "SIGTERM");
3908
4286
  console.log(chalk11.green(`Stopped watcher (PID ${pid})`));
@@ -4129,7 +4507,7 @@ Claimed: ${ref} "${ticket.title}"`));
4129
4507
  );
4130
4508
  if (brief) {
4131
4509
  const briefPath = join12(workDir, ".yapout", "brief.md");
4132
- const briefContent = formatBrief2(ref, ticket, brief);
4510
+ const briefContent = formatBrief(ref, ticket, brief);
4133
4511
  writeFileSync10(briefPath, briefContent);
4134
4512
  console.log(`Brief: ${chalk13.cyan(briefPath)}`);
4135
4513
  }
@@ -4142,7 +4520,7 @@ Claimed: ${ref} "${ticket.title}"`));
4142
4520
  );
4143
4521
  console.log();
4144
4522
  });
4145
- function formatBrief2(ref, ticket, brief) {
4523
+ function formatBrief(ref, ticket, brief) {
4146
4524
  const lines = [
4147
4525
  `# ${ref}: ${ticket.title}`,
4148
4526
  "",
@@ -4307,9 +4685,10 @@ function buildPrompt(parsed) {
4307
4685
  switch (parsed.action) {
4308
4686
  case "claim":
4309
4687
  return [
4310
- `Use yapout to implement ticket "${parsed.ticketId}".`,
4311
- `Claim it with yapout_claim (use worktree mode), read the brief,`,
4312
- `implement the changes, run yapout_check, then ship with yapout_ship.`
4688
+ `Use yapout to implement work item "${parsed.ticketId}".`,
4689
+ `Claim it with yapout_claim using workItemId (use worktree mode), read the brief,`,
4690
+ `implement the changes, run yapout_check, then ship with yapout_ship.`,
4691
+ `The ID may be a bundle or standalone finding \u2014 yapout_claim auto-detects.`
4313
4692
  ].join(" ");
4314
4693
  case "enrich":
4315
4694
  return [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yapout",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "yapout CLI — link local repos, authenticate, and manage projects",
5
5
  "type": "module",
6
6
  "bin": {