@ramarivera/coding-buddy 0.9.3 → 0.9.5

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.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Sync every version field in the repo to package.json.
3
+ *
4
+ * bun run sync-version # rewrite drifted fields
5
+ * bun run sync-version --check # exit 1 on drift, change nothing (CI)
6
+ *
7
+ * Wired into the npm `version` lifecycle so `npm version <bump>` updates the
8
+ * manifests in the same commit as package.json, and into CI via --check so
9
+ * drift cannot land.
10
+ *
11
+ * Design and the original implementation come from #110 by @grlee. That PR
12
+ * conflicted with main and lives on a fork, so it is reimplemented here rather
13
+ * than rebased.
14
+ *
15
+ * server/index.ts is deliberately NOT a target — it imports pkg.version
16
+ * directly, which is strictly better than syncing a literal. Anything that can
17
+ * import package.json should do that instead of being added below.
18
+ */
19
+
20
+ import { readFileSync, writeFileSync } from "fs";
21
+ import { join } from "path";
22
+
23
+ const ROOT = join(import.meta.dir, "..");
24
+ const CHECK_ONLY = process.argv.includes("--check");
25
+
26
+ const version: unknown = JSON.parse(
27
+ readFileSync(join(ROOT, "package.json"), "utf8"),
28
+ ).version;
29
+
30
+ if (typeof version !== "string" || version === "") {
31
+ console.error("sync-version: package.json is missing a version field");
32
+ process.exit(2);
33
+ }
34
+
35
+ interface Target {
36
+ /** Repo-relative path. */
37
+ path: string;
38
+ /** Every version field in the document, for reporting and for rewriting. */
39
+ fields: (doc: any) => { label: string; get: () => string; set: () => void }[];
40
+ }
41
+
42
+ const TARGETS: Target[] = [
43
+ {
44
+ path: ".claude-plugin/plugin.json",
45
+ fields: (doc) => [
46
+ { label: "version", get: () => doc.version, set: () => { doc.version = version; } },
47
+ ],
48
+ },
49
+ {
50
+ path: ".claude-plugin/marketplace.json",
51
+ fields: (doc) => [
52
+ {
53
+ label: "metadata.version",
54
+ get: () => doc.metadata?.version,
55
+ // Creates metadata if absent. Guarding the write instead would let
56
+ // --check report drift that the fixer could never resolve.
57
+ set: () => {
58
+ doc.metadata ??= {};
59
+ doc.metadata.version = version;
60
+ },
61
+ },
62
+ ...(doc.plugins ?? []).map((plugin: any, i: number) => ({
63
+ label: `plugins[${i}].version`,
64
+ get: () => plugin.version,
65
+ set: () => { plugin.version = version; },
66
+ })),
67
+ ],
68
+ },
69
+ ];
70
+
71
+ export interface Drift {
72
+ path: string;
73
+ label: string;
74
+ current: string;
75
+ }
76
+
77
+ /** Returns every field that disagrees with package.json. Never writes. */
78
+ export function findDrift(): Drift[] {
79
+ const drift: Drift[] = [];
80
+ for (const target of TARGETS) {
81
+ const doc = JSON.parse(readFileSync(join(ROOT, target.path), "utf8"));
82
+ for (const field of target.fields(doc)) {
83
+ const current = field.get();
84
+ if (current !== version) drift.push({ path: target.path, label: field.label, current });
85
+ }
86
+ }
87
+ return drift;
88
+ }
89
+
90
+ function main(): void {
91
+ const drift = findDrift();
92
+
93
+ for (const d of drift) {
94
+ console.log(
95
+ `${CHECK_ONLY ? "DRIFT" : "sync"}: ${d.path} :: ${d.label} ${d.current} -> ${version}`,
96
+ );
97
+ }
98
+
99
+ if (drift.length === 0) {
100
+ console.log(`sync-version: all version fields already at ${version}`);
101
+ return;
102
+ }
103
+
104
+ if (CHECK_ONLY) {
105
+ console.error(
106
+ "sync-version: version fields are out of sync with package.json. Run `bun run sync-version`.",
107
+ );
108
+ process.exit(1);
109
+ }
110
+
111
+ // Rewrite only the files that actually drifted, preserving 4-space style.
112
+ const drifted = new Set(drift.map((d) => d.path));
113
+ for (const target of TARGETS) {
114
+ if (!drifted.has(target.path)) continue;
115
+ const full = join(ROOT, target.path);
116
+ const doc = JSON.parse(readFileSync(full, "utf8"));
117
+ for (const field of target.fields(doc)) field.set();
118
+ writeFileSync(full, `${JSON.stringify(doc, null, 4)}\n`);
119
+ }
120
+ }
121
+
122
+ if (import.meta.main) main();
package/server/index.ts CHANGED
@@ -9,8 +9,12 @@
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
11
  import { z } from "zod";
12
+ import { REACT_REASONS, normalizeReactArgs } from "./react-args.ts";
12
13
  import { existsSync } from "fs";
13
14
  import { join, resolve, dirname } from "path";
15
+ // The version advertised in the MCP handshake. Imported rather than written
16
+ // as a literal so it cannot drift from the published package (it had, to 0.6.0).
17
+ import pkg from "../package.json" with { type: "json" };
14
18
 
15
19
  import { generateBones,
16
20
  generatePersonality,
@@ -125,7 +129,7 @@ function getInstructions(): string {
125
129
  const server = new McpServer(
126
130
  {
127
131
  name: "coding-buddy",
128
- version: "0.6.0",
132
+ version: pkg.version,
129
133
  },
130
134
  {
131
135
  instructions: getInstructions(),
@@ -301,33 +305,33 @@ server.tool(
301
305
  {
302
306
  comment: z
303
307
  .string()
304
- .min(1)
305
- .max(150)
308
+ .optional()
306
309
  .describe(
307
- "The buddy's comment, written in-character (1 short sentence, max 150 chars). Use *asterisks* for actions.",
310
+ "The buddy's comment, written in-character (1 short sentence, max 150 chars; longer text is shortened). Use *asterisks* for actions. Always send it: a call with no comment returns an error.",
308
311
  ),
312
+ reaction: z
313
+ .string()
314
+ .optional()
315
+ .describe("Deprecated alias for `comment`. Send `comment` instead."),
309
316
  reason: z
310
- .enum([
311
- "error", "test-fail", "large-diff", "turn",
312
- "commit", "push", "merge-conflict", "branch", "rebase", "stash", "tag",
313
- "late-night", "early-morning", "long-session", "marathon", "friday", "weekend", "monday",
314
- "regex-file", "css-file", "sql-file", "docker-file", "ci-file", "lock-file",
315
- "env-file", "test-file", "doc-file", "config-file", "binary-file", "gitignore",
316
- "makefile", "readme", "package-file", "proto-file",
317
- "lint-fail", "type-error", "build-fail", "security-warning", "deprecation",
318
- "frustrated", "happy", "stuck", "sarcastic",
319
- "many-edits", "delete-file", "large-file", "create-file",
320
- "all-green", "deploy", "release", "coverage",
321
- "debug-loop", "write-spree", "search-heavy",
322
- "recovery-from-error", "recovery-from-test-fail",
323
- "recovery-from-build-fail", "recovery-from-merge-conflict",
324
- ])
317
+ .string()
325
318
  .optional()
326
- .describe("What triggered the reaction"),
319
+ .describe(
320
+ `What triggered the reaction. One of: ${REACT_REASONS.join(", ")}. Defaults to "turn"; unknown values are treated as "turn".`,
321
+ ),
327
322
  },
328
- async ({ comment, reason }) => {
323
+ async (raw) => {
324
+ const { comment, reason } = normalizeReactArgs(raw);
325
+ if (comment === null) {
326
+ return {
327
+ isError: true,
328
+ content: [
329
+ { type: "text", text: 'buddy_react needs a non-empty `comment` string, for example {"comment": "*nods* clean diff", "reason": "turn"}.' },
330
+ ],
331
+ };
332
+ }
329
333
  const companion = ensureCompanion();
330
- saveReaction(comment, reason ?? "turn", "tool");
334
+ saveReaction(comment, reason, "tool");
331
335
  incrementEvent("reactions_given", 1, activeSlot());
332
336
 
333
337
  const newAch = checkAndAward(activeSlot());
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Argument handling for the buddy_react tool.
3
+ *
4
+ * Agents often call buddy_react without having read its schema: the tool is
5
+ * deferred in Claude Code, so the model guesses argument names. A strict
6
+ * schema turned those guesses into MCP -32602 errors and agents then reported
7
+ * the buddy as unavailable. The tool accepts the common guesses instead and
8
+ * normalizes them here.
9
+ */
10
+
11
+ export const REACT_REASONS = [
12
+ "error", "test-fail", "large-diff", "turn",
13
+ "commit", "push", "merge-conflict", "branch", "rebase", "stash", "tag",
14
+ "late-night", "early-morning", "long-session", "marathon", "friday", "weekend", "monday",
15
+ "regex-file", "css-file", "sql-file", "docker-file", "ci-file", "lock-file",
16
+ "env-file", "test-file", "doc-file", "config-file", "binary-file", "gitignore",
17
+ "makefile", "readme", "package-file", "proto-file",
18
+ "lint-fail", "type-error", "build-fail", "security-warning", "deprecation",
19
+ "frustrated", "happy", "stuck", "sarcastic",
20
+ "many-edits", "delete-file", "large-file", "create-file",
21
+ "all-green", "deploy", "release", "coverage",
22
+ "debug-loop", "write-spree", "search-heavy",
23
+ "recovery-from-error", "recovery-from-test-fail",
24
+ "recovery-from-build-fail", "recovery-from-merge-conflict",
25
+ ] as const;
26
+
27
+ export type ReactReason = (typeof REACT_REASONS)[number];
28
+
29
+ export const DEFAULT_REACT_REASON: ReactReason = "turn";
30
+ export const MAX_REACT_COMMENT_LENGTH = 150;
31
+
32
+ export interface RawReactArgs {
33
+ comment?: string;
34
+ /** Alias agents guess when they have not loaded the schema. */
35
+ reaction?: string;
36
+ reason?: string;
37
+ }
38
+
39
+ export interface ReactArgs {
40
+ /** Null when the caller sent no usable comment under any accepted name. */
41
+ comment: string | null;
42
+ reason: ReactReason;
43
+ }
44
+
45
+ function isReactReason(value: string): value is ReactReason {
46
+ return (REACT_REASONS as readonly string[]).includes(value);
47
+ }
48
+
49
+ function clampComment(text: string): string {
50
+ const chars = Array.from(text);
51
+ if (chars.length <= MAX_REACT_COMMENT_LENGTH) return text;
52
+ return `${chars.slice(0, MAX_REACT_COMMENT_LENGTH - 1).join("")}…`;
53
+ }
54
+
55
+ export function normalizeReactArgs(raw: RawReactArgs): ReactArgs {
56
+ const text = [raw.comment, raw.reaction]
57
+ .map((value) => (typeof value === "string" ? value.trim() : ""))
58
+ .find((value) => value.length > 0);
59
+ const reason = typeof raw.reason === "string" ? raw.reason.trim() : "";
60
+ return {
61
+ comment: text ? clampComment(text) : null,
62
+ reason: isReactReason(reason) ? reason : DEFAULT_REACT_REASON,
63
+ };
64
+ }
@@ -74,7 +74,11 @@ Based on `$ARGUMENTS`:
74
74
 
75
75
  ## CRITICAL OUTPUT RULES
76
76
 
77
- The MCP tools return pre-formatted ASCII art with ANSI colors, box-drawing characters, stat bars, and species art. This is the companion's visual identity.
77
+ Different buddy tools return different shapes of output. The rules below keep the companion's visual identity intact while making sure plain-text replies actually reach the user.
78
+
79
+ ### Visual-card outputs — strict verbatim
80
+
81
+ These tools return pre-formatted ASCII art with ANSI colors, box-drawing characters, stat bars, and species art: `buddy_show`, `buddy_stats`, `buddy_pet`, `buddy_achievements`, `buddy_summon`, `buddy_pick`. This is the companion's visual identity.
78
82
 
79
83
  **You MUST output the tool result text EXACTLY as returned — character for character, line for line.** Do NOT:
80
84
 
@@ -86,6 +90,20 @@ The MCP tools return pre-formatted ASCII art with ANSI colors, box-drawing chara
86
90
 
87
91
  **Just output the raw text content from the tool result. Nothing else.** The ASCII art IS the response.
88
92
 
93
+ ### Plain-text outputs — verbatim and visible
94
+
95
+ These tools return plain text, not ASCII art: `buddy_help`, `buddy_mute`, `buddy_unmute`, `buddy_frequency`, `buddy_style`, `buddy_statusline`, `buddy_list`, `buddy_rename`, `buddy_save`, `buddy_dismiss`, `buddy_set_personality`.
96
+
97
+ **The visible reply MUST include the tool result text verbatim — every time, on every call, even on repeated calls in the same session.** Treat each invocation as a fresh request from the user, not a "they have seen it before" signal. Tool results are not rendered in the user's transcript; only what you write in your own reply reaches the screen.
98
+
99
+ You MAY add a one-sentence confirmation before or after if it clarifies what changed, but do NOT paraphrase the text itself.
100
+
101
+ **Hard floor for the plain-text commands above:** a reply that contains no visible text of its own is broken. A `buddy_react` call is not visible output — it reaches the user only through the statusline bubble — so a turn whose only content is a tool call renders blank. If you have nothing else to say, the tool result text alone is the correct reply.
102
+
103
+ **Self-check for the plain-text commands above:** does your visible reply contain that command's result text? If not, paste it in. Never paste the result of `buddy_react`. The end-of-turn `buddy_react` call is _additional_, never _instead of_.
104
+
105
+ ### Reactions — statusline only
106
+
89
107
  If the user mentions the buddy's name in normal conversation, call `buddy_react` with reason "turn". Do NOT echo or quote the tool result — the reaction reaches the user only via the statusline speech bubble.
90
108
 
91
109
  ## Uninstall Orchestration