codiedev 0.3.6 → 0.5.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/cli.js CHANGED
@@ -41,8 +41,12 @@ Artifacts:
41
41
  Fetch an artifact (stdout by default)
42
42
  codiedev promote <artifact-id> Promote an auto-extracted artifact
43
43
  to an authored one
44
- codiedev reverse-ticket <pr-url> Generate a Jira ticket from a merged PR
45
- (auto-matches spec + thread + transcript)
44
+ codiedev reverse-ticket [<pr-url>] Generate a Jira ticket draft.
45
+ No URL = branch mode (uses your current
46
+ local git branch, no PR needed).
47
+ Draft lands in /portal/agentic-ticketing.
48
+ codiedev reverse-ticket --base <ref> Override the base ref in branch mode
49
+ (defaults to origin/main).
46
50
 
47
51
  Messaging:
48
52
  codiedev ping <user> "<msg>" [--with <key>]
@@ -33,6 +33,7 @@ function ghRaw(path) {
33
33
  }
34
34
  function parseArgs(args) {
35
35
  let prUrl;
36
+ let base;
36
37
  let forcedKey;
37
38
  let noTruncate = false;
38
39
  for (let i = 0; i < args.length; i++) {
@@ -43,6 +44,12 @@ function parseArgs(args) {
43
44
  else if (a.startsWith("--with=")) {
44
45
  forcedKey = a.slice("--with=".length);
45
46
  }
47
+ else if ((a === "--base" || a === "-b") && i + 1 < args.length) {
48
+ base = args[++i];
49
+ }
50
+ else if (a.startsWith("--base=")) {
51
+ base = a.slice("--base=".length);
52
+ }
46
53
  else if (a === "--full") {
47
54
  noTruncate = true;
48
55
  }
@@ -50,19 +57,19 @@ function parseArgs(args) {
50
57
  prUrl = a;
51
58
  }
52
59
  }
53
- if (!prUrl) {
54
- console.error("Usage: codiedev reverse-ticket <pr-url> [--with <artifact-key>] [--full]");
55
- console.error("");
56
- console.error("Example: codiedev reverse-ticket https://github.com/signalandcode/repo/pull/42");
57
- process.exit(1);
58
- }
59
- return { prUrl, forcedKey, noTruncate };
60
+ return { prUrl, base, forcedKey, noTruncate };
60
61
  }
61
62
  async function runReverseTicket(args) {
62
- const { prUrl, forcedKey, noTruncate } = parseArgs(args);
63
+ const { prUrl, base, forcedKey, noTruncate } = parseArgs(args);
64
+ // No PR URL → branch mode (mid-session, no PR exists yet).
65
+ if (!prUrl) {
66
+ return runBranchMode({ explicitBase: base, forcedKey });
67
+ }
63
68
  const parsed = parsePrUrl(prUrl);
64
69
  if (!parsed) {
65
70
  console.error("Couldn't parse PR URL. Expected format: https://github.com/<owner>/<repo>/pull/<number>");
71
+ console.error("");
72
+ console.error("Run with no arguments for branch mode (uses your current local branch).");
66
73
  process.exit(1);
67
74
  }
68
75
  if (!checkGhCli()) {
@@ -148,3 +155,135 @@ async function runReverseTicket(args) {
148
155
  process.exit(1);
149
156
  }
150
157
  }
158
+ function git(cmd) {
159
+ try {
160
+ return (0, child_process_1.execSync)(`git ${cmd}`, {
161
+ stdio: ["pipe", "pipe", "pipe"],
162
+ maxBuffer: 32 * 1024 * 1024,
163
+ })
164
+ .toString("utf8")
165
+ .trim();
166
+ }
167
+ catch (err) {
168
+ throw new Error(`git ${cmd} failed: ${err.message}`);
169
+ }
170
+ }
171
+ function safeGit(fn) {
172
+ try {
173
+ return fn() || undefined;
174
+ }
175
+ catch {
176
+ return undefined;
177
+ }
178
+ }
179
+ async function runBranchMode(opts) {
180
+ // Confirm we're in a git repo.
181
+ try {
182
+ git("rev-parse --is-inside-work-tree");
183
+ }
184
+ catch {
185
+ console.error("Not inside a git repository. Run this from a checked-out repo.");
186
+ process.exit(1);
187
+ }
188
+ // Resolve base ref — explicit, then origin/main, then origin/master.
189
+ const candidates = opts.explicitBase
190
+ ? [opts.explicitBase]
191
+ : ["origin/main", "origin/master"];
192
+ let baseRef = null;
193
+ for (const c of candidates) {
194
+ try {
195
+ git(`rev-parse --verify ${c}`);
196
+ baseRef = c;
197
+ break;
198
+ }
199
+ catch {
200
+ // try next
201
+ }
202
+ }
203
+ if (!baseRef) {
204
+ console.error(`Couldn't find a base ref (tried ${candidates.join(", ")}). Pass --base <ref>.`);
205
+ process.exit(1);
206
+ }
207
+ const branch = git("rev-parse --abbrev-ref HEAD");
208
+ if (branch === "HEAD") {
209
+ console.error("Detached HEAD — checkout a branch first.");
210
+ process.exit(1);
211
+ }
212
+ const baseSha = git(`merge-base HEAD ${baseRef}`);
213
+ const headSha = git("rev-parse HEAD");
214
+ if (baseSha === headSha) {
215
+ console.error(`No commits on ${branch} ahead of ${baseRef} — make a commit first.`);
216
+ process.exit(1);
217
+ }
218
+ const filesRaw = git(`diff --name-only ${baseSha}...HEAD`);
219
+ const filesChanged = filesRaw ? filesRaw.split("\n").filter(Boolean) : [];
220
+ if (filesChanged.length === 0) {
221
+ console.error(`No files changed between ${baseRef} and ${branch} — nothing to write up.`);
222
+ process.exit(1);
223
+ }
224
+ let diff = "";
225
+ try {
226
+ diff = git(`diff ${baseSha}...HEAD`).slice(0, 12_000);
227
+ }
228
+ catch {
229
+ // best effort
230
+ }
231
+ let repo;
232
+ try {
233
+ const remoteUrl = git("config --get remote.origin.url");
234
+ const m = remoteUrl.match(/[:/]([^/:]+)\/([^/:]+?)(?:\.git)?$/);
235
+ if (!m) {
236
+ throw new Error(`Couldn't parse owner/repo from remote: ${remoteUrl}`);
237
+ }
238
+ repo = `${m[1]}/${m[2]}`;
239
+ }
240
+ catch (err) {
241
+ console.error(`Couldn't read git remote.origin.url: ${err.message}`);
242
+ process.exit(1);
243
+ }
244
+ const title = safeGit(() => git("log -1 --format=%s")) ?? `WIP: ${branch}`;
245
+ const author = safeGit(() => git("config --get user.name"));
246
+ const authorEmail = safeGit(() => git("config --get user.email"));
247
+ console.log(`Branch: ${branch} (${repo})`);
248
+ console.log(`Base: ${baseRef} @ ${baseSha.slice(0, 12)}`);
249
+ console.log(`Head: ${headSha.slice(0, 12)}`);
250
+ console.log(`Files: ${filesChanged.length}`);
251
+ console.log("");
252
+ console.log("Generating ticket draft…");
253
+ console.log("");
254
+ const config = (0, shared_1.requireConfig)();
255
+ try {
256
+ const res = await (0, shared_1.apiRequest)("POST", "/api/cli/reverseTicketFromBranch", {
257
+ config,
258
+ body: {
259
+ branch: {
260
+ repo,
261
+ branch,
262
+ baseSha,
263
+ headSha,
264
+ title,
265
+ author,
266
+ authorEmail,
267
+ filesChanged,
268
+ diff: diff || undefined,
269
+ },
270
+ forcedArtifactKey: opts.forcedKey,
271
+ },
272
+ });
273
+ if (!res.ok || !res.artifactId) {
274
+ console.error("Reverse-ticket generation returned no result. The writer may have skipped due to missing context.");
275
+ process.exit(1);
276
+ }
277
+ const verb = res.action === "overwritten" ? "Updated" : "Created";
278
+ console.log(`✓ ${verb} reverse-ticket draft.`);
279
+ console.log(` artifact: ${res.artifactKey ?? res.artifactId}`);
280
+ console.log(` portal: ${res.portalUrl}`);
281
+ console.log("");
282
+ console.log("Open in your portal to review, edit, and push to Jira.");
283
+ console.log("When the PR opens later, the webhook dedupes against this draft — one ticket, not two.");
284
+ }
285
+ catch (err) {
286
+ console.error(`Reverse-ticket failed: ${err.message}`);
287
+ process.exit(1);
288
+ }
289
+ }
package/dist/mcp.js CHANGED
@@ -52,7 +52,7 @@ const path = __importStar(require("path"));
52
52
  const utils_1 = require("./utils");
53
53
  const shared_1 = require("./commands/shared");
54
54
  const PKG_NAME = "codiedev";
55
- const PKG_VERSION = "0.3.0";
55
+ const PKG_VERSION = "0.5.0";
56
56
  // ─────────────────────────────────────────────────────────────────────────────
57
57
  // Tool definitions — descriptions tuned so Claude/Codex resolve natural-language
58
58
  // requests into the right tool without manual steering.
@@ -202,6 +202,211 @@ const TOOLS = [
202
202
  required: ["artifactId"],
203
203
  },
204
204
  },
205
+ {
206
+ name: "codiedev_post_to_feed",
207
+ description: "Publish an artifact to the team's break-room feed with an intent " +
208
+ "and optional @mentions. Use when the user asks to 'post this to " +
209
+ "the team', 'publish to the feed', 'share with the team', 'ask the " +
210
+ "team', or when they want broader visibility than a single ping. " +
211
+ "Pass the artifact's filename (e.g., 'spec-214.md') and pick an " +
212
+ "intent that matches the request: share = here's a skill/spec; " +
213
+ "request_review = please review; request_expertise = I'm stuck, " +
214
+ "who knows this; link_share = fyi; rfc = proposing a change. " +
215
+ "Optionally @mention teammates (by first name) so they get a " +
216
+ "notification. Returns the feed post id.",
217
+ inputSchema: {
218
+ type: "object",
219
+ properties: {
220
+ filename: {
221
+ type: "string",
222
+ description: "Artifact filename to attach (e.g., 'spec-214.md'). The post " +
223
+ "will render a preview card linking to it.",
224
+ },
225
+ title: {
226
+ type: "string",
227
+ description: "Headline of the post. Short, imperative — e.g., 'Check my " +
228
+ "OAuth middleware spec' or 'Anyone know how we handle X?'.",
229
+ },
230
+ body: {
231
+ type: "string",
232
+ description: "Body of the post in markdown. A short explanation of why the " +
233
+ "user is posting (the artifact carries the detail).",
234
+ },
235
+ intent: {
236
+ type: "string",
237
+ enum: [
238
+ "share",
239
+ "request_review",
240
+ "request_expertise",
241
+ "link_share",
242
+ "rfc",
243
+ ],
244
+ description: "Why the user is posting. See tool description.",
245
+ },
246
+ mentions: {
247
+ type: "array",
248
+ items: { type: "string" },
249
+ description: "Teammate first names (or handles) to @mention. Each mention " +
250
+ "creates a notification for that teammate.",
251
+ },
252
+ format: {
253
+ type: "string",
254
+ enum: ["article", "quick", "workflow", "review"],
255
+ description: "Post format. Default 'article' if omitted. Use 'quick' for " +
256
+ "short one-liners.",
257
+ },
258
+ tags: {
259
+ type: "array",
260
+ items: { type: "string" },
261
+ description: "Optional tags, lowercase, hyphen-safe.",
262
+ },
263
+ },
264
+ required: ["title", "body"],
265
+ },
266
+ },
267
+ {
268
+ name: "codiedev_share_with",
269
+ description: "Grant a teammate persistent read (or edit) access to an artifact " +
270
+ "without sending a notification. Use when the user says 'share this " +
271
+ "with Greg' or 'give Jason access', and doesn't want to surface it " +
272
+ "as a message. If they want the teammate actively notified, use " +
273
+ "codiedev_send_to instead.",
274
+ inputSchema: {
275
+ type: "object",
276
+ properties: {
277
+ filename: { type: "string", description: "Artifact filename, e.g. 'spec-214.md'." },
278
+ to: {
279
+ type: "string",
280
+ description: "Teammate first name, full name, or email. Ambiguous matches " +
281
+ "return a list — reprompt the user if so.",
282
+ },
283
+ role: {
284
+ type: "string",
285
+ enum: ["read", "edit"],
286
+ description: "Access level. Default 'read' if omitted.",
287
+ },
288
+ },
289
+ required: ["filename", "to"],
290
+ },
291
+ },
292
+ {
293
+ name: "codiedev_send_to",
294
+ description: "Grant access to an artifact AND send a short message to a " +
295
+ "teammate. Use when the user asks to 'send this to Greg', 'ask " +
296
+ "Greg to look at this', or 'share this with Jason and tell him X'. " +
297
+ "This is the active handoff version of codiedev_share_with — the " +
298
+ "recipient gets a ping in their inbox.",
299
+ inputSchema: {
300
+ type: "object",
301
+ properties: {
302
+ filename: { type: "string", description: "Artifact filename." },
303
+ to: { type: "string", description: "Teammate first name, full name, or email." },
304
+ message: {
305
+ type: "string",
306
+ description: "Optional short note to send with the share. If omitted, only " +
307
+ "the grant is created (no ping).",
308
+ },
309
+ },
310
+ required: ["filename", "to"],
311
+ },
312
+ },
313
+ {
314
+ name: "codiedev_react",
315
+ description: "React to a feed post with an emoji. Use when the user says 'react " +
316
+ "to that post with X', 'mark as used', or 'upvote'. The 🛠 " +
317
+ "reaction is the signal for 'I used this' and bumps the linked " +
318
+ "artifact's reuse count. Pass the post id (get it from " +
319
+ "codiedev_get_library or a previous post-to-feed response).",
320
+ inputSchema: {
321
+ type: "object",
322
+ properties: {
323
+ postId: { type: "string", description: "Feed post id." },
324
+ emoji: {
325
+ type: "string",
326
+ enum: ["👍", "🔥", "💡", "🛠", "👑"],
327
+ description: "Reaction emoji. 🛠 = 'I used this' (counts heaviest toward " +
328
+ "the author's score and the artifact's reuse count).",
329
+ },
330
+ },
331
+ required: ["postId", "emoji"],
332
+ },
333
+ },
334
+ {
335
+ name: "codiedev_search",
336
+ description: "Search the team's artifact library by natural-language query. Use " +
337
+ "when the user asks 'has anyone solved X?', 'find me the spec on Y', " +
338
+ "or 'look for prior work on Z'. Always search before pushing a new " +
339
+ "artifact so the user doesn't duplicate work. Returns ranked hits " +
340
+ "with title, type, filename key, and a snippet.",
341
+ inputSchema: {
342
+ type: "object",
343
+ properties: {
344
+ query: { type: "string", description: "Natural-language search query." },
345
+ limit: { type: "integer", description: "Max hits. Default 10." },
346
+ },
347
+ required: ["query"],
348
+ },
349
+ },
350
+ {
351
+ name: "codiedev_get_library",
352
+ description: "List artifacts in the team library, scoped to what the current " +
353
+ "user authored, what's been shared with them, or everything in the " +
354
+ "company. Use when the user asks 'show me my artifacts', 'what did " +
355
+ "I push?', 'what has Greg shared with me?', or 'browse the library'.",
356
+ inputSchema: {
357
+ type: "object",
358
+ properties: {
359
+ scope: {
360
+ type: "string",
361
+ enum: ["mine", "shared", "all"],
362
+ description: "'mine' = artifacts I authored (default). 'shared' = artifacts " +
363
+ "explicitly shared with me. 'all' = everything in the company.",
364
+ },
365
+ type: {
366
+ type: "string",
367
+ enum: ["spec", "bugfix", "decision", "proposal", "review", "note"],
368
+ description: "Filter to one artifact type.",
369
+ },
370
+ folderPath: { type: "string", description: "Filter to one folder path." },
371
+ limit: { type: "integer", description: "Max rows. Default 50." },
372
+ },
373
+ },
374
+ },
375
+ {
376
+ name: "codiedev_reverse_ticket",
377
+ description: "Generate a Jira-ready reverse-ticket draft from the work the user has " +
378
+ "done in their CURRENT git branch — mid-session, before any PR exists. " +
379
+ "Reads local git state (current branch, divergence from main, diff, " +
380
+ "commits, files changed) and runs it through the codiedev reverse-" +
381
+ "ticket writer. The draft lands in the user's portal at " +
382
+ "/portal/agentic-ticketing where they can edit the title, description, " +
383
+ "and scope, then push to Jira. Use when the user asks to 'create a " +
384
+ "reverse ticket', 'draft a Jira ticket from this work', 'write up what " +
385
+ "I've done in this branch', or runs `codiedev reverse-ticket` directly. " +
386
+ "The webhook on PR-open later dedupes against this draft so the user " +
387
+ "never gets a double ticket. The draft is intentionally not auto-pushed " +
388
+ "to Jira — trust comes from the user reviewing in the portal first.",
389
+ inputSchema: {
390
+ type: "object",
391
+ properties: {
392
+ title: {
393
+ type: "string",
394
+ description: "Optional working title for the ticket. Defaults to the most " +
395
+ "recent commit subject on the branch.",
396
+ },
397
+ base: {
398
+ type: "string",
399
+ description: "Optional base ref to diff against. Defaults to 'origin/main', " +
400
+ "falls back to 'origin/master'.",
401
+ },
402
+ forcedArtifactKey: {
403
+ type: "string",
404
+ description: "Optional — force the writer to link to a specific artifact key " +
405
+ "for context (e.g. 'spec-auth.md').",
406
+ },
407
+ },
408
+ },
409
+ },
205
410
  ];
206
411
  // ─────────────────────────────────────────────────────────────────────────────
207
412
  // Server
@@ -210,6 +415,26 @@ const server = new index_js_1.Server({ name: PKG_NAME, version: PKG_VERSION }, {
210
415
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
211
416
  tools: TOOLS,
212
417
  }));
418
+ /**
419
+ * Fire-and-forget telemetry — POST one event per tool invocation. Never blocks,
420
+ * never throws into the user-facing path. Drop silently on any failure.
421
+ */
422
+ function emitTelemetry(config, event) {
423
+ if (!config)
424
+ return;
425
+ (0, shared_1.apiRequest)("POST", "/api/telemetry/mcp-event", {
426
+ config,
427
+ body: {
428
+ tool: event.tool,
429
+ ok: event.ok,
430
+ latencyMs: event.latencyMs,
431
+ error: event.error,
432
+ mcpVersion: PKG_VERSION,
433
+ },
434
+ }).catch(() => {
435
+ // Swallow — telemetry should never affect the user's session.
436
+ });
437
+ }
213
438
  server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
214
439
  const config = (0, utils_1.readConfig)();
215
440
  if (!config) {
@@ -227,28 +452,26 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
227
452
  }
228
453
  const { name, arguments: argsRaw } = request.params;
229
454
  const args = (argsRaw ?? {});
455
+ const start = Date.now();
230
456
  try {
231
- switch (name) {
232
- case "codiedev_push":
233
- return await handlePush(args, config);
234
- case "codiedev_pull":
235
- return await handlePull(args, config);
236
- case "codiedev_ping":
237
- return await handlePing(args, config);
238
- case "codiedev_inbox":
239
- return await handleInbox(args, config);
240
- case "codiedev_note":
241
- return await handleNote(args, config);
242
- case "codiedev_promote":
243
- return await handlePromote(args, config);
244
- default:
245
- return {
246
- isError: true,
247
- content: [{ type: "text", text: `Unknown tool: ${name}` }],
248
- };
249
- }
457
+ const result = await dispatchTool(name, args, config);
458
+ emitTelemetry(config, {
459
+ tool: name,
460
+ ok: !result?.isError,
461
+ latencyMs: Date.now() - start,
462
+ error: result?.isError
463
+ ? (result?.content?.[0]?.text ?? "").slice(0, 200) || "unknown error"
464
+ : undefined,
465
+ });
466
+ return result;
250
467
  }
251
468
  catch (err) {
469
+ emitTelemetry(config, {
470
+ tool: name,
471
+ ok: false,
472
+ latencyMs: Date.now() - start,
473
+ error: (err.message ?? "unknown").slice(0, 200),
474
+ });
252
475
  return {
253
476
  isError: true,
254
477
  content: [
@@ -260,6 +483,41 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
260
483
  };
261
484
  }
262
485
  });
486
+ async function dispatchTool(name, args, config) {
487
+ switch (name) {
488
+ case "codiedev_push":
489
+ return await handlePush(args, config);
490
+ case "codiedev_pull":
491
+ return await handlePull(args, config);
492
+ case "codiedev_ping":
493
+ return await handlePing(args, config);
494
+ case "codiedev_inbox":
495
+ return await handleInbox(args, config);
496
+ case "codiedev_note":
497
+ return await handleNote(args, config);
498
+ case "codiedev_promote":
499
+ return await handlePromote(args, config);
500
+ case "codiedev_post_to_feed":
501
+ return await handlePostToFeed(args, config);
502
+ case "codiedev_share_with":
503
+ return await handleShareWith(args, config);
504
+ case "codiedev_send_to":
505
+ return await handleSendTo(args, config);
506
+ case "codiedev_react":
507
+ return await handleReact(args, config);
508
+ case "codiedev_search":
509
+ return await handleSearch(args, config);
510
+ case "codiedev_get_library":
511
+ return await handleGetLibrary(args, config);
512
+ case "codiedev_reverse_ticket":
513
+ return await handleReverseTicket(args, config);
514
+ default:
515
+ return {
516
+ isError: true,
517
+ content: [{ type: "text", text: `Unknown tool: ${name}` }],
518
+ };
519
+ }
520
+ }
263
521
  async function handlePush(args, config) {
264
522
  const filename = asString(args.filename);
265
523
  const markdown = asString(args.markdown);
@@ -394,6 +652,340 @@ async function handlePromote(args, config) {
394
652
  ],
395
653
  };
396
654
  }
655
+ async function handlePostToFeed(args, config) {
656
+ const filename = asStringOrUndefined(args.filename);
657
+ const title = asString(args.title);
658
+ const body = asString(args.body);
659
+ const intent = asStringOrUndefined(args.intent);
660
+ const format = asStringOrUndefined(args.format);
661
+ const mentionsRaw = Array.isArray(args.mentions) ? args.mentions : [];
662
+ const mentions = mentionsRaw.filter((m) => typeof m === "string");
663
+ const tagsRaw = Array.isArray(args.tags) ? args.tags : [];
664
+ const tags = tagsRaw.filter((t) => typeof t === "string");
665
+ if (!title)
666
+ throw new Error("title required");
667
+ if (!body)
668
+ throw new Error("body required");
669
+ const res = await (0, shared_1.apiRequest)("POST", "/api/cli/post-to-feed", {
670
+ config,
671
+ body: { filename, title, body, intent, format, mentions, tags },
672
+ });
673
+ const pieces = [`✓ Posted to feed (id=${res.postId}).`];
674
+ if (res.linkedArtifact?.key)
675
+ pieces.push(` attached: ${res.linkedArtifact.key}`);
676
+ if (res.intent)
677
+ pieces.push(` intent: ${res.intent}`);
678
+ if (res.mentions?.length) {
679
+ pieces.push(` mentioned: ${res.mentions.map((m) => m.name).join(", ")}`);
680
+ }
681
+ return { content: [{ type: "text", text: pieces.join("\n") }] };
682
+ }
683
+ async function handleShareWith(args, config) {
684
+ const filename = asString(args.filename);
685
+ const to = asString(args.to);
686
+ const role = asStringOrUndefined(args.role);
687
+ if (!filename)
688
+ throw new Error("filename required");
689
+ if (!to)
690
+ throw new Error("to required");
691
+ try {
692
+ const res = await (0, shared_1.apiRequest)("POST", "/api/cli/share-with", {
693
+ config,
694
+ body: { filename, to, role },
695
+ });
696
+ const verb = res.action === "added"
697
+ ? "Shared"
698
+ : res.action === "updated"
699
+ ? "Updated share for"
700
+ : "Already shared with";
701
+ return {
702
+ content: [
703
+ {
704
+ type: "text",
705
+ text: `✓ ${verb} ${res.recipient.name} <${res.recipient.email}> on ${filename} (role=${role ?? "read"}).`,
706
+ },
707
+ ],
708
+ };
709
+ }
710
+ catch (err) {
711
+ return formatAmbiguousRecipient(err, to);
712
+ }
713
+ }
714
+ async function handleSendTo(args, config) {
715
+ const filename = asString(args.filename);
716
+ const to = asString(args.to);
717
+ const message = asStringOrUndefined(args.message);
718
+ if (!filename)
719
+ throw new Error("filename required");
720
+ if (!to)
721
+ throw new Error("to required");
722
+ try {
723
+ const res = await (0, shared_1.apiRequest)("POST", "/api/cli/send-to", {
724
+ config,
725
+ body: { filename, to, message },
726
+ });
727
+ const suffix = res.pingId ? ` with a message.` : ` (no message).`;
728
+ return {
729
+ content: [
730
+ {
731
+ type: "text",
732
+ text: `✓ Sent ${filename} to ${res.recipient.name} <${res.recipient.email}>${suffix}`,
733
+ },
734
+ ],
735
+ };
736
+ }
737
+ catch (err) {
738
+ return formatAmbiguousRecipient(err, to);
739
+ }
740
+ }
741
+ async function handleReact(args, config) {
742
+ const postId = asString(args.postId);
743
+ const emoji = asString(args.emoji);
744
+ if (!postId)
745
+ throw new Error("postId required");
746
+ if (!emoji)
747
+ throw new Error("emoji required");
748
+ const res = await (0, shared_1.apiRequest)("POST", "/api/cli/react", {
749
+ config,
750
+ body: { postId, emoji },
751
+ });
752
+ const verb = res.action === "added"
753
+ ? "Reacted"
754
+ : res.action === "removed"
755
+ ? "Removed reaction"
756
+ : "Swapped reaction to";
757
+ return {
758
+ content: [{ type: "text", text: `✓ ${verb} ${res.emoji}` }],
759
+ };
760
+ }
761
+ async function handleSearch(args, config) {
762
+ const query = asString(args.query);
763
+ const limit = asIntOrUndefined(args.limit);
764
+ if (!query)
765
+ throw new Error("query required");
766
+ const res = await (0, shared_1.apiRequest)("GET", "/api/cli/search", {
767
+ config,
768
+ query: { q: query, limit: limit?.toString() },
769
+ });
770
+ if (res.hits.length === 0) {
771
+ return {
772
+ content: [{ type: "text", text: `No artifacts matched "${query}".` }],
773
+ };
774
+ }
775
+ const lines = [`Found ${res.hits.length} match${res.hits.length === 1 ? "" : "es"}:`, ""];
776
+ for (const h of res.hits) {
777
+ lines.push(`[${h.type}] ${h.title}${h.key ? ` (${h.key})` : ""}`);
778
+ if (h.snippet) {
779
+ lines.push(` ${h.snippet.slice(0, 160)}${h.snippet.length > 160 ? "…" : ""}`);
780
+ }
781
+ lines.push("");
782
+ }
783
+ return { content: [{ type: "text", text: lines.join("\n") }] };
784
+ }
785
+ async function handleGetLibrary(args, config) {
786
+ const scope = asStringOrUndefined(args.scope);
787
+ const type = asStringOrUndefined(args.type);
788
+ const folderPath = asStringOrUndefined(args.folderPath);
789
+ const limit = asIntOrUndefined(args.limit);
790
+ const res = await (0, shared_1.apiRequest)("GET", "/api/cli/library", {
791
+ config,
792
+ query: {
793
+ scope,
794
+ type,
795
+ folderPath,
796
+ limit: limit?.toString(),
797
+ },
798
+ });
799
+ if (res.artifacts.length === 0) {
800
+ return {
801
+ content: [
802
+ {
803
+ type: "text",
804
+ text: `Library is empty${scope && scope !== "mine" ? ` for scope=${scope}` : ""}.`,
805
+ },
806
+ ],
807
+ };
808
+ }
809
+ const lines = [
810
+ `Library · scope=${scope ?? "mine"} · ${res.artifacts.length} item${res.artifacts.length === 1 ? "" : "s"}`,
811
+ "",
812
+ ];
813
+ for (const a of res.artifacts) {
814
+ const loc = a.folderPath ? `${a.folderPath}/` : "";
815
+ const v = a.version ? ` v${a.version}` : "";
816
+ const life = a.lifecycle ? ` · ${a.lifecycle}` : "";
817
+ const when = a.updatedAt ? ` · ${(0, shared_1.timeAgo)(a.updatedAt)}` : "";
818
+ lines.push(`[${a.type}] ${loc}${a.key ?? a.title}${v}${life}${when}`);
819
+ lines.push(` ${a.title}`);
820
+ lines.push("");
821
+ }
822
+ return { content: [{ type: "text", text: lines.join("\n") }] };
823
+ }
824
+ function formatAmbiguousRecipient(err, to) {
825
+ const e = err;
826
+ if (e.status === 409 && e.body?.candidates?.length) {
827
+ const list = e.body.candidates
828
+ .map((c) => `- ${c.name} <${c.email}>`)
829
+ .join("\n");
830
+ return {
831
+ isError: true,
832
+ content: [
833
+ {
834
+ type: "text",
835
+ text: `Multiple recipients matched "${to}". Ask the user which:\n${list}`,
836
+ },
837
+ ],
838
+ };
839
+ }
840
+ throw err;
841
+ }
842
+ async function handleReverseTicket(args, config) {
843
+ const explicitTitle = asStringOrUndefined(args.title);
844
+ const explicitBase = asStringOrUndefined(args.base);
845
+ const forcedKey = asStringOrUndefined(args.forcedArtifactKey);
846
+ // Helper that runs git from cwd with a hard-fail on error.
847
+ const { execSync } = await Promise.resolve().then(() => __importStar(require("child_process")));
848
+ const git = (cmd) => {
849
+ try {
850
+ return execSync(`git ${cmd}`, {
851
+ stdio: ["pipe", "pipe", "pipe"],
852
+ maxBuffer: 32 * 1024 * 1024,
853
+ })
854
+ .toString("utf8")
855
+ .trim();
856
+ }
857
+ catch (err) {
858
+ throw new Error(`git ${cmd} failed: ${err.message}`);
859
+ }
860
+ };
861
+ // Confirm we're in a git repo.
862
+ try {
863
+ git("rev-parse --is-inside-work-tree");
864
+ }
865
+ catch {
866
+ throw new Error("Not inside a git repository. Run this from a checked-out repo.");
867
+ }
868
+ // Resolve base ref — explicit, then origin/main, then origin/master.
869
+ const candidates = explicitBase
870
+ ? [explicitBase]
871
+ : ["origin/main", "origin/master"];
872
+ let baseRef = null;
873
+ for (const c of candidates) {
874
+ try {
875
+ git(`rev-parse --verify ${c}`);
876
+ baseRef = c;
877
+ break;
878
+ }
879
+ catch {
880
+ // try next
881
+ }
882
+ }
883
+ if (!baseRef) {
884
+ throw new Error(`Couldn't find a base ref (tried ${candidates.join(", ")}). ` +
885
+ `Pass an explicit base via the 'base' argument.`);
886
+ }
887
+ const branch = git("rev-parse --abbrev-ref HEAD");
888
+ if (branch === "HEAD") {
889
+ throw new Error("Detached HEAD — checkout a branch first.");
890
+ }
891
+ const baseSha = git(`merge-base HEAD ${baseRef}`);
892
+ const headSha = git("rev-parse HEAD");
893
+ if (baseSha === headSha) {
894
+ throw new Error(`No commits on ${branch} ahead of ${baseRef} — make a commit first.`);
895
+ }
896
+ const filesRaw = git(`diff --name-only ${baseSha}...HEAD`);
897
+ const filesChanged = filesRaw ? filesRaw.split("\n").filter(Boolean) : [];
898
+ if (filesChanged.length === 0) {
899
+ throw new Error(`No files changed between ${baseRef} and ${branch} — nothing to write up.`);
900
+ }
901
+ // Truncated diff (12k chars) — writer works without it but it sharpens output.
902
+ let diff = "";
903
+ try {
904
+ diff = git(`diff ${baseSha}...HEAD`).slice(0, 12_000);
905
+ }
906
+ catch {
907
+ // Best effort.
908
+ }
909
+ // Repo name from origin remote.
910
+ let repo;
911
+ try {
912
+ const remoteUrl = git("config --get remote.origin.url");
913
+ const m = remoteUrl.match(/[:/]([^/:]+)\/([^/:]+?)(?:\.git)?$/);
914
+ if (!m) {
915
+ throw new Error(`Couldn't parse owner/repo from remote: ${remoteUrl}`);
916
+ }
917
+ repo = `${m[1]}/${m[2]}`;
918
+ }
919
+ catch (err) {
920
+ throw new Error(`Couldn't read git remote.origin.url: ${err.message}`);
921
+ }
922
+ const title = explicitTitle ?? safeGit(() => git("log -1 --format=%s")) ?? `WIP: ${branch}`;
923
+ const author = safeGit(() => git("config --get user.name"));
924
+ const authorEmail = safeGit(() => git("config --get user.email"));
925
+ // Post to backend.
926
+ const res = await (0, shared_1.apiRequest)("POST", "/api/cli/reverseTicketFromBranch", {
927
+ config,
928
+ body: {
929
+ branch: {
930
+ repo,
931
+ branch,
932
+ baseSha,
933
+ headSha,
934
+ title,
935
+ author,
936
+ authorEmail,
937
+ filesChanged,
938
+ diff: diff || undefined,
939
+ },
940
+ forcedArtifactKey: forcedKey,
941
+ },
942
+ });
943
+ if (!res.ok || !res.artifactId) {
944
+ return {
945
+ isError: true,
946
+ content: [
947
+ {
948
+ type: "text",
949
+ text: "Reverse-ticket generation returned no result. The writer may have " +
950
+ "skipped due to missing context (no captured sessions, etc.). Try " +
951
+ "again after working on the branch a bit more, or check the " +
952
+ "server logs.",
953
+ },
954
+ ],
955
+ };
956
+ }
957
+ const portalBase = (config.backendUrl || "")
958
+ .replace(/\/$/, "")
959
+ .replace(/\.convex\.cloud$/, ".convex.cloud"); // backendUrl is the Convex site; portal is separate
960
+ // Best effort — surface the portal path; the user can prefix with their portal host.
961
+ const portalPath = res.portalUrl;
962
+ const verb = res.action === "overwritten" ? "Updated" : "Created";
963
+ return {
964
+ content: [
965
+ {
966
+ type: "text",
967
+ text: `✓ ${verb} reverse-ticket draft for branch \`${branch}\` (${repo}).\n` +
968
+ ` files changed: ${filesChanged.length}\n` +
969
+ ` base: ${baseSha.slice(0, 12)} → head: ${headSha.slice(0, 12)} (${baseRef})\n` +
970
+ ` diff size: ${diff.length} chars\n` +
971
+ ` artifact: ${res.artifactKey ?? res.artifactId}\n\n` +
972
+ `Open in your portal to review, edit, and push to Jira:\n` +
973
+ ` ${portalPath}\n\n` +
974
+ `When the PR opens later, the webhook dedupes against this draft — ` +
975
+ `you'll get one ticket, not two.`,
976
+ },
977
+ ],
978
+ };
979
+ }
980
+ // Run a git invocation; return undefined instead of throwing.
981
+ function safeGit(fn) {
982
+ try {
983
+ return fn();
984
+ }
985
+ catch {
986
+ return undefined;
987
+ }
988
+ }
397
989
  // ─────────────────────────────────────────────────────────────────────────────
398
990
  // Helpers
399
991
  // ─────────────────────────────────────────────────────────────────────────────
package/dist/utils.js CHANGED
@@ -206,13 +206,19 @@ thought, use the \`codiedev\` CLI via Bash:**
206
206
 
207
207
  | Intent | Command |
208
208
  |---|---|
209
- | "push this spec" / "share this with the team" | \`codiedev push <file.md>\` |
209
+ | "push this spec" / "save this as a skill" | \`codiedev push <file.md>\` |
210
210
  | "pull X" / "grab the latest spec-X" / "what did Maya push?" | \`codiedev pull <key>\` |
211
211
  | "ping Nic about this" / "ask Maya for her take" | \`codiedev ping <name> "<msg>" [--with <key>]\` |
212
212
  | "any messages?" / "check my inbox" / "what did Nic say?" | \`codiedev inbox\` |
213
213
  | "read Nic's reply" / "mark that ping read" | \`codiedev read <ping-id>\` |
214
214
  | "note that X is a follow-up" / "remember X" | \`codiedev note "<text>"\` |
215
215
  | "promote the extracted spec" | \`codiedev promote <artifact-id>\` |
216
+ | "post this to the team" / "publish to the feed" / "share with the team" | MCP tool \`codiedev_post_to_feed\` |
217
+ | "share with Greg" / "give Jason access" (no notification) | MCP tool \`codiedev_share_with\` |
218
+ | "send this to Greg" / "ask Greg to look at this" | MCP tool \`codiedev_send_to\` |
219
+ | "find something about X" / "has anyone solved Y?" | MCP tool \`codiedev_search\` |
220
+ | "show my library" / "what artifacts exist?" | MCP tool \`codiedev_get_library\` |
221
+ | "react 🛠 to that post" / "mark as used" | MCP tool \`codiedev_react\` |
216
222
 
217
223
  **Filename conventions (set artifact type automatically):**
218
224
  - \`spec-*.md\` → spec
@@ -231,6 +237,22 @@ thought, use the \`codiedev\` CLI via Bash:**
231
237
  **Teammate names:** first name usually works (\`codiedev ping nic ...\`).
232
238
  If ambiguous, the CLI returns candidates — retry with the full email.
233
239
 
240
+ **Feed posts (broad team reach):** use \`codiedev_post_to_feed\` when the
241
+ user wants visibility beyond one teammate — e.g., announcing a new skill,
242
+ asking the team who knows about X, proposing a change. Always pass an
243
+ \`intent\` that matches the request: \`share\` (here's a skill),
244
+ \`request_review\`, \`request_expertise\` (stuck, who knows this),
245
+ \`link_share\` (fyi), or \`rfc\` (proposing a change). Attach a \`filename\`
246
+ to link a specific artifact and \`mentions\` to tag teammates.
247
+
248
+ **Search before pushing.** For any "save this as …" request, call
249
+ \`codiedev_search\` first with a relevant query. If prior art exists, pull
250
+ it and iterate rather than duplicating.
251
+
252
+ **Sharing vs sending:** \`codiedev_share_with\` is silent (access only, no
253
+ notification). \`codiedev_send_to\` both grants access AND pings the
254
+ recipient. Use send when the user is actively looping someone in.
255
+
234
256
  **Errors:**
235
257
  - "not connected" → user needs to run \`npx codiedev connect\` with their
236
258
  API token from https://codiedev.com/portal/integrations/claude-code
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codiedev",
3
- "version": "0.3.6",
3
+ "version": "0.5.0",
4
4
  "description": "Connect Claude Code or Codex to CodieDev for org-wide session capture and artifact collaboration",
5
5
  "bin": {
6
6
  "codiedev": "./dist/cli.js",