@ramarivera/coding-buddy 0.8.0 → 0.9.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.
Files changed (52) hide show
  1. package/README.md +23 -4
  2. package/adapters/omp/context.ts +1 -1
  3. package/adapters/omp/events.ts +286 -81
  4. package/adapters/omp/index.ts +3 -1
  5. package/adapters/omp/renderers.ts +4 -54
  6. package/adapters/omp/storage.ts +6 -1
  7. package/adapters/omp/ui.ts +30 -1
  8. package/adapters/pi/events.ts +280 -83
  9. package/adapters/pi/index.ts +11 -2
  10. package/adapters/pi/renderers.ts +3 -47
  11. package/adapters/pi/storage.ts +6 -1
  12. package/adapters/pi/ui.ts +30 -1
  13. package/adapters/shared/file-storage.ts +21 -0
  14. package/adapters/shared/widget-layout.ts +134 -2
  15. package/cli/settings.ts +1 -1
  16. package/cli/tui.tsx +1 -1
  17. package/core/command-service.ts +13 -0
  18. package/core/engine.ts +11 -2
  19. package/core/identity.ts +15 -0
  20. package/core/model.ts +2 -0
  21. package/core/ports.ts +4 -0
  22. package/core/reactions.ts +29 -2
  23. package/core/render-model.ts +4 -3
  24. package/package.json +6 -2
  25. package/scripts/ci/Dockerfile.vhs +125 -0
  26. package/scripts/ci/check-statusline.sh +328 -14
  27. package/scripts/ci/check-vhs-goldens.sh +97 -0
  28. package/scripts/ci/fonts.conf +37 -0
  29. package/scripts/ci/goldens/baseline-40.png +0 -0
  30. package/scripts/ci/goldens/baseline-80.png +0 -0
  31. package/scripts/ci/goldens/cjk-80.png +0 -0
  32. package/scripts/ci/goldens/emoji-80.png +0 -0
  33. package/scripts/ci/render-statusline-pty.py +99 -0
  34. package/scripts/ci/render-vhs-goldens.sh +235 -0
  35. package/scripts/ci/vhs-run-statusline.sh +16 -0
  36. package/scripts/gen-emoji-widths.ts +41 -17
  37. package/scripts/paths.sh +3 -1
  38. package/server/art.ts +45 -13
  39. package/server/hooks/buddy-comment.ts +166 -17
  40. package/server/hooks/file-type-react.ts +1 -1
  41. package/server/hooks/mood-react.ts +1 -1
  42. package/server/hooks/name-react.ts +1 -1
  43. package/server/hooks/react.ts +1 -1
  44. package/server/hooks/reaction-data.ts +10 -3
  45. package/server/index.ts +67 -27
  46. package/server/path.ts +3 -0
  47. package/server/state.ts +63 -17
  48. package/skills/buddy/SKILL.md +1 -1
  49. package/statusline/buddy-status.sh +351 -59
  50. package/statusline/emoji-text.data +3 -0
  51. package/statusline/emoji-widths.data +2 -3
  52. package/statusline/substatus.sh +52 -10
package/server/state.ts CHANGED
@@ -31,6 +31,9 @@ import {
31
31
  } from "fs";
32
32
  import { join } from "path";
33
33
  import type { Companion } from "../core/engine.ts"
34
+ import type * as CoreEngine from "../core/engine.ts";
35
+ import type * as Art from "./art.ts";
36
+ import { configuredSharedUserId } from "../core/identity.ts";
34
37
  import {
35
38
  buddyStateDir,
36
39
  claudeSettingsPath,
@@ -278,32 +281,63 @@ function migrateIfNeeded(): void {
278
281
 
279
282
  // ─── Reaction state (session-scoped for tmux isolation) ──────────────────────
280
283
 
284
+ /**
285
+ * Where a reaction came from. Proves the bubble content isn't canned when
286
+ * source === "tool" or "comment"; "fallback" means a hook picked a pool line
287
+ * because no model-authored text surfaced; "none" is the default for legacy
288
+ * files written before the field existed.
289
+ *
290
+ * tool — buddy_react MCP tool call (Claude wrote it; renders nowhere)
291
+ * comment — old `<!-- buddy: ... -->` HTML comment (legacy / older CC)
292
+ * fallback — Stop hook generated it from the canned pool
293
+ * none — unknown / legacy file without a source field
294
+ */
295
+ export type ReactionSource = "tool" | "comment" | "fallback" | "none";
296
+
281
297
  export interface ReactionState {
282
298
  reaction: string;
283
299
  timestamp: number;
284
300
  reason: string;
301
+ /** Provenance — see ReactionSource. Defaults to "none" on legacy files. */
302
+ source?: ReactionSource;
285
303
  }
286
304
 
287
305
  export function loadReaction(): ReactionState | null {
288
306
  try {
289
307
  const data: ReactionState = JSON.parse(readFileSync(reactionFile(), "utf8"));
290
308
  const { reactionTTL } = loadConfig();
291
- if (reactionTTL > 0 && Date.now() - data.timestamp > reactionTTL * 1000) return null;
309
+ if (reactionTTL > 0 && Date.now() - data.timestamp > reactionTTL * 1000) {
310
+ rmSync(reactionFile(), { force: true });
311
+ return null;
312
+ }
313
+ if (data.source === undefined) data.source = "none";
292
314
  return data;
293
315
  } catch {
294
316
  return null;
295
317
  }
296
318
  }
297
319
 
298
- export function saveReaction(reaction: string, reason: string): void {
320
+ export function saveReaction(
321
+ reaction: string,
322
+ reason: string,
323
+ source: ReactionSource = "fallback",
324
+ ): void {
299
325
  mkdirSync(STATE_DIR, { recursive: true });
300
- const state: ReactionState = { reaction, timestamp: Date.now(), reason };
301
- writeFileSync(reactionFile(), JSON.stringify(state));
326
+ const state: ReactionState = { reaction, timestamp: Date.now(), reason, source };
327
+ // Atomic via tmp + rename — torn reads on the reaction file would
328
+ // make the Stop hook's freshness check see an absent file, pinning
329
+ // a stale tool reaction into the bubble forever.
330
+ const target = reactionFile();
331
+ const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
332
+ writeFileSync(tmp, JSON.stringify(state));
333
+ renameSync(tmp, target);
302
334
  }
303
335
 
304
336
  // ─── Identity resolution ─────────────────────────────────────────────────────
305
337
 
306
338
  export function resolveUserId(): string {
339
+ const sharedUserId = configuredSharedUserId();
340
+ if (sharedUserId) return sharedUserId;
307
341
  try {
308
342
  const claudeJson = JSON.parse(readFileSync(claudeUserConfigPath(), "utf8"));
309
343
  return claudeJson.oauthAccount?.accountUuid ?? claudeJson.userID ?? "anon";
@@ -311,9 +345,7 @@ export function resolveUserId(): string {
311
345
  return "anon";
312
346
  }
313
347
  }
314
-
315
348
  // ─── Config persistence (PR #6: tmux popup settings) ─────────────────────────
316
-
317
349
  export interface BuddyConfig {
318
350
  commentCooldown: number;
319
351
  reactionTTL: number;
@@ -321,6 +353,7 @@ export interface BuddyConfig {
321
353
  bubblePosition: "top" | "left";
322
354
  showRarity: boolean;
323
355
  statusLineEnabled: boolean;
356
+ statuslineWidthAdjust: number;
324
357
  bubbleWidth: number;
325
358
  bubbleMargin: number;
326
359
  useCombinedStatus: boolean;
@@ -336,11 +369,15 @@ export interface BuddyConfig {
336
369
 
337
370
  const DEFAULT_CONFIG: BuddyConfig = {
338
371
  commentCooldown: 30,
339
- reactionTTL: 0,
372
+ // Long enough that a reaction survives the turn you were reading when it was
373
+ // written, short enough that yesterday's bubble is never still on screen.
374
+ // Set to 0 to opt out and keep reactions until they are replaced.
375
+ reactionTTL: 900,
340
376
  bubbleStyle: "classic",
341
377
  bubblePosition: "top",
342
378
  showRarity: true,
343
379
  statusLineEnabled: false,
380
+ statuslineWidthAdjust: 0,
344
381
  bubbleWidth: 28,
345
382
  bubbleMargin: 8,
346
383
  useCombinedStatus: false,
@@ -363,6 +400,12 @@ export function normalizeConfig(data: unknown): BuddyConfig {
363
400
  if (!Number.isInteger(config.subStatusRefreshSeconds) || config.subStatusRefreshSeconds <= 0) {
364
401
  config.subStatusRefreshSeconds = DEFAULT_CONFIG.subStatusRefreshSeconds;
365
402
  }
403
+ if (!Number.isInteger(config.reactionTTL) || config.reactionTTL < 0) {
404
+ config.reactionTTL = DEFAULT_CONFIG.reactionTTL;
405
+ }
406
+ if (!Number.isInteger(config.statuslineWidthAdjust)) {
407
+ config.statuslineWidthAdjust = DEFAULT_CONFIG.statuslineWidthAdjust;
408
+ }
366
409
  return config;
367
410
  }
368
411
 
@@ -412,10 +455,11 @@ export function writeStatusState(
412
455
  xp?: number,
413
456
  ): void {
414
457
  mkdirSync(STATE_DIR, { recursive: true });
415
- const { renderFace, RARITY_STARS } =
416
- require("../core/engine.ts") as typeof import("../core/engine.ts");
458
+ const { renderFace, RARITY_STARS, resolveEyeGlyph } =
459
+ require("../core/engine.ts") as typeof CoreEngine;
417
460
  const { getStatusFrames } =
418
- require("./art.ts") as typeof import("./art.ts");
461
+ require("./art.ts") as typeof Art;
462
+ const safeEye = resolveEyeGlyph(companion.bones.eye);
419
463
  const { frames, frameSequence } = getStatusFrames(companion.bones);
420
464
  let xpLevel = level ?? 1;
421
465
  let xpTotal = xp ?? 0;
@@ -439,8 +483,8 @@ export function writeStatusState(
439
483
  species: companion.bones.species,
440
484
  rarity: companion.bones.rarity,
441
485
  stars: RARITY_STARS[companion.bones.rarity],
442
- face: renderFace(companion.bones.species, companion.bones.eye),
443
- eye: companion.bones.eye,
486
+ face: renderFace(companion.bones.species, safeEye),
487
+ eye: safeEye,
444
488
  shiny: companion.bones.shiny,
445
489
  hat: companion.bones.hat,
446
490
  reaction: "",
@@ -452,14 +496,16 @@ export function writeStatusState(
452
496
  xp: xpTotal,
453
497
  mood: moodStr,
454
498
  };
455
- writeFileSync(join(STATE_DIR, "status.json"), JSON.stringify(state));
456
- if (reaction) saveReaction(reaction, "mcp");
499
+ // writeStatusState no longer calls saveReaction implicitly. Callers
500
+ // that need a reaction file do so explicitly with the correct
501
+ // `source` tag — the old implicit save had no source argument and
502
+ // its default later flipped from "tool" to "fallback", either way
503
+ // overwriting provenance of hatch / pet / unmute writes with whatever
504
+ // happened to be the default. /buddy stats stopped lying when
505
+ // this call was removed.
457
506
  }
458
507
 
459
- // ─── Claude Code settings.json patching (for buddy_statusline tool) ──────────
460
-
461
508
  export const CLAUDE_SETTINGS_PATH = claudeSettingsPath();
462
-
463
509
  /**
464
510
  * Write settings.statusLine pointing to the given buddy-status script.
465
511
  * Atomic via tmp + rename. Returns false if settings.json is unreachable.
@@ -86,7 +86,7 @@ The MCP tools return pre-formatted ASCII art with ANSI colors, box-drawing chara
86
86
 
87
87
  **Just output the raw text content from the tool result. Nothing else.** The ASCII art IS the response.
88
88
 
89
- If the user mentions the buddy's name in normal conversation, call `buddy_react` with reason "turn" and display the result verbatim.
89
+ 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
90
 
91
91
  ## Uninstall Orchestration
92
92