@phuetz/code-buddy 1.3.1 → 1.4.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 (71) hide show
  1. package/README.md +23 -4
  2. package/dist/agent/autonomous/agentic-coding-runner.d.ts +2 -2
  3. package/dist/agent/autonomous/agentic-coding-runner.js +24 -24
  4. package/dist/agent/autonomous/checkpoint-manager.d.ts +2 -2
  5. package/dist/agent/base-agent.d.ts +1 -1
  6. package/dist/agent/base-agent.js +1 -1
  7. package/dist/agent/execution/tool-selection-strategy.js +4 -1
  8. package/dist/agent/hermes-tool-parity-local.js +2 -2
  9. package/dist/agent/tool-handler.js +2 -2
  10. package/dist/codebuddy/providers/provider-chatgpt-responses.d.ts +4 -0
  11. package/dist/codebuddy/providers/provider-chatgpt-responses.js +34 -13
  12. package/dist/codebuddy/providers/provider-openai-compat.js +8 -0
  13. package/dist/codebuddy/tool-definitions/code-explorer-tools.d.ts +8 -0
  14. package/dist/codebuddy/tool-definitions/code-explorer-tools.js +24 -0
  15. package/dist/codebuddy/tool-definitions/graph-tools.d.ts +1 -1
  16. package/dist/codebuddy/tool-definitions/graph-tools.js +1 -1
  17. package/dist/codebuddy/tool-definitions/index.d.ts +1 -1
  18. package/dist/codebuddy/tool-definitions/index.js +2 -2
  19. package/dist/codebuddy/tools.d.ts +2 -2
  20. package/dist/codebuddy/tools.js +28 -13
  21. package/dist/collaboration/ai-colab-manager.js +9 -4
  22. package/dist/commands/cli/code-explorer-commands.d.ts +7 -0
  23. package/dist/commands/cli/code-explorer-commands.js +40 -0
  24. package/dist/commands/cli/native-engine-commands.js +64 -0
  25. package/dist/commands/handlers/channel-handlers.js +39 -22
  26. package/dist/commands/handlers/graph-handlers.d.ts +1 -1
  27. package/dist/commands/handlers/graph-handlers.js +1 -1
  28. package/dist/config/model-tools.js +1 -1
  29. package/dist/daemon/autonomous-loop.d.ts +0 -7
  30. package/dist/daemon/autonomous-loop.js +24 -14
  31. package/dist/fleet/capability-registry.js +9 -1
  32. package/dist/fleet/colab-store.d.ts +109 -3
  33. package/dist/fleet/colab-store.js +181 -6
  34. package/dist/index.js +28 -11
  35. package/dist/input/text-to-speech.js +16 -1
  36. package/dist/kanban/colab-kanban-adapter.d.ts +31 -0
  37. package/dist/kanban/colab-kanban-adapter.js +232 -0
  38. package/dist/knowledge/community-detector.d.ts +1 -1
  39. package/dist/knowledge/community-detector.js +1 -1
  40. package/dist/knowledge/process-detector.d.ts +1 -1
  41. package/dist/knowledge/process-detector.js +1 -1
  42. package/dist/plugins/{gitnexus/GitNexusMCPClient.d.ts → code-explorer/CodeExplorerMCPClient.d.ts} +9 -9
  43. package/dist/plugins/{gitnexus/GitNexusMCPClient.js → code-explorer/CodeExplorerMCPClient.js} +17 -17
  44. package/dist/plugins/{gitnexus/GitNexusManager.d.ts → code-explorer/CodeExplorerManager.d.ts} +16 -16
  45. package/dist/plugins/{gitnexus/GitNexusManager.js → code-explorer/CodeExplorerManager.js} +35 -35
  46. package/dist/plugins/code-explorer/index.d.ts +9 -0
  47. package/dist/plugins/code-explorer/index.js +8 -0
  48. package/dist/providers/provider-catalog.js +23 -1
  49. package/dist/services/prompt-builder.js +9 -9
  50. package/dist/skills/parser.js +1 -1
  51. package/dist/tools/{gitnexus-tool.d.ts → code-explorer-tool.d.ts} +9 -9
  52. package/dist/tools/{gitnexus-tool.js → code-explorer-tool.js} +16 -16
  53. package/dist/tools/metadata.js +3 -3
  54. package/dist/tools/registry/code-explorer-tools.d.ts +18 -0
  55. package/dist/tools/registry/{gitnexus-tools.js → code-explorer-tools.js} +14 -14
  56. package/dist/tools/registry/graph-tools.d.ts +1 -1
  57. package/dist/tools/registry/graph-tools.js +1 -1
  58. package/dist/tools/registry/index.d.ts +1 -1
  59. package/dist/tools/registry/index.js +6 -6
  60. package/dist/tools/registry/kanban-tools.d.ts +1 -1
  61. package/dist/tools/registry/kanban-tools.js +2 -2
  62. package/dist/voice/local-whisper.d.ts +9 -0
  63. package/dist/voice/local-whisper.js +93 -0
  64. package/package.json +1 -1
  65. package/dist/codebuddy/tool-definitions/gitnexus-tools.d.ts +0 -8
  66. package/dist/codebuddy/tool-definitions/gitnexus-tools.js +0 -24
  67. package/dist/commands/cli/gitnexus-commands.d.ts +0 -7
  68. package/dist/commands/cli/gitnexus-commands.js +0 -42
  69. package/dist/plugins/gitnexus/index.d.ts +0 -9
  70. package/dist/plugins/gitnexus/index.js +0 -8
  71. package/dist/tools/registry/gitnexus-tools.d.ts +0 -18
@@ -35,6 +35,12 @@ const PRIORITY_RANK = {
35
35
  low: 0,
36
36
  };
37
37
  const DEFAULT_CLAIM_TTL_MS = 15 * 60 * 1000;
38
+ /**
39
+ * Default retry budget (Hermes-kanban parity): how many times a task may fail or
40
+ * be reclaimed as a zombie before it is dead-lettered to `blocked` (the "Review"
41
+ * column) instead of spinning forever. Per-task `retryBudget` overrides this.
42
+ */
43
+ const DEFAULT_RETRY_BUDGET = 3;
38
44
  /** Derive the default `<host>/<repo>` agent id from the environment. */
39
45
  export function defaultFleetAgentId(cwd = process.cwd()) {
40
46
  const host = os.hostname().toLowerCase().split('.')[0] || 'unknown-host';
@@ -58,6 +64,8 @@ export class FleetColabStore {
58
64
  now;
59
65
  generateId;
60
66
  claimTtlMs;
67
+ retryBudget;
68
+ writeSeq = 0;
61
69
  tasksPath;
62
70
  worklogPath;
63
71
  presencePath;
@@ -70,6 +78,7 @@ export class FleetColabStore {
70
78
  let counter = 0;
71
79
  this.generateId = config.generateId ?? ((prefix) => `${prefix}-${this.now()}-${++counter}`);
72
80
  this.claimTtlMs = config.claimTtlMs ?? DEFAULT_CLAIM_TTL_MS;
81
+ this.retryBudget = config.retryBudget && config.retryBudget > 0 ? config.retryBudget : DEFAULT_RETRY_BUDGET;
73
82
  this.tasksPath = path.join(this.dir, 'colab-tasks.json');
74
83
  this.worklogPath = path.join(this.dir, 'colab-worklog.json');
75
84
  this.presencePath = path.join(this.dir, 'presence.json');
@@ -106,10 +115,19 @@ export class FleetColabStore {
106
115
  const claimedMs = Date.parse(task.claimedAt);
107
116
  return Number.isFinite(claimedMs) && nowMs - claimedMs > this.claimTtlMs;
108
117
  }
118
+ /** The dead-letter threshold for a task (its own `retryBudget`, else the store default). */
119
+ resolveRetryBudget(task) {
120
+ return typeof task.retryBudget === 'number' && task.retryBudget > 0 ? task.retryBudget : this.retryBudget;
121
+ }
109
122
  /**
110
- * Sweep expired claims back to `open` (for a daemon to call proactively).
111
- * Returns the reclaimed task ids. Lazy callers can rely on {@link nextClaimable},
112
- * which already treats expired claims as available.
123
+ * Sweep expired claims (zombie detection) a crashed agent's claim ages out
124
+ * because it stopped heartbeating. Each reclaim counts against the task's
125
+ * retry budget: under budget it returns to `open` for retry; at/over budget it
126
+ * is dead-lettered to `blocked` (the "Review" column) instead of spinning
127
+ * forever. Returns every reclaimed task id (re-opened or dead-lettered).
128
+ * Lazy callers can rely on {@link nextClaimable}, which treats an expired claim
129
+ * as available — but only the sweep enforces the retry budget, so a daemon
130
+ * should call this each tick.
113
131
  */
114
132
  reclaimExpired() {
115
133
  const file = this.readTasks();
@@ -117,9 +135,17 @@ export class FleetColabStore {
117
135
  const reclaimed = [];
118
136
  for (const task of file.tasks) {
119
137
  if (this.isClaimExpired(task, nowMs)) {
120
- task.status = 'open';
138
+ task.attempts = (task.attempts ?? 0) + 1;
121
139
  task.claimedBy = null;
122
140
  task.claimedAt = null;
141
+ task.lastHeartbeatAt = null;
142
+ if (task.attempts >= this.resolveRetryBudget(task)) {
143
+ task.status = 'blocked';
144
+ task.blockedReason = `Reclaimed ${task.attempts}× (retry budget ${this.resolveRetryBudget(task)} exhausted) — needs review`;
145
+ }
146
+ else {
147
+ task.status = 'open';
148
+ }
123
149
  reclaimed.push(task.id);
124
150
  }
125
151
  }
@@ -127,6 +153,55 @@ export class FleetColabStore {
127
153
  this.writeTasks(file);
128
154
  return reclaimed;
129
155
  }
156
+ /**
157
+ * Renew a claim's lease (Hermes-kanban heartbeat). Re-stamps `claimedAt` and
158
+ * `lastHeartbeatAt` so a long-running but live worker is not reclaimed as a
159
+ * zombie. Only valid while the task is `in_progress`; when `agentId` is given it
160
+ * must match the current claimant.
161
+ */
162
+ heartbeat(taskId, agentId = this.agentId) {
163
+ const file = this.readTasks();
164
+ const task = file.tasks.find((t) => t.id === taskId);
165
+ if (!task)
166
+ throw new Error(`Unknown fleet task '${taskId}'`);
167
+ if (task.status !== 'in_progress') {
168
+ throw new Error(`Task '${taskId}' is '${task.status}', not in_progress — cannot heartbeat`);
169
+ }
170
+ if (task.claimedBy && agentId && task.claimedBy !== agentId) {
171
+ throw new Error(`Task '${taskId}' is claimed by '${task.claimedBy}', not '${agentId}'`);
172
+ }
173
+ const stamp = this.isoNow();
174
+ task.claimedAt = stamp;
175
+ task.lastHeartbeatAt = stamp;
176
+ this.writeTasks(file);
177
+ return { ...task };
178
+ }
179
+ /**
180
+ * Record a failed attempt (persisted retry-budget counter). Increments
181
+ * `attempts` and reports whether the budget is now exhausted, so the caller can
182
+ * dead-letter (`blockTask`) vs retry (`releaseTask`). Does not change status —
183
+ * the daemon owns the worklog/release flow.
184
+ */
185
+ recordFailure(taskId) {
186
+ const file = this.readTasks();
187
+ const task = file.tasks.find((t) => t.id === taskId);
188
+ if (!task)
189
+ throw new Error(`Unknown fleet task '${taskId}'`);
190
+ task.attempts = (task.attempts ?? 0) + 1;
191
+ this.writeTasks(file);
192
+ return { task: { ...task }, attempts: task.attempts, exhausted: task.attempts >= this.resolveRetryBudget(task) };
193
+ }
194
+ /** Reset the retry-budget counter (call on a successful attempt). */
195
+ resetAttempts(taskId) {
196
+ const file = this.readTasks();
197
+ const task = file.tasks.find((t) => t.id === taskId);
198
+ if (!task)
199
+ throw new Error(`Unknown fleet task '${taskId}'`);
200
+ if (task.attempts)
201
+ task.attempts = 0;
202
+ this.writeTasks(file);
203
+ return { ...task };
204
+ }
130
205
  /** Dependency ids that are not yet `completed` (unknown/missing ids count as unmet). */
131
206
  unmetDependencies(task, tasks) {
132
207
  if (!task.dependsOn || task.dependsOn.length === 0)
@@ -251,9 +326,11 @@ export class FleetColabStore {
251
326
  id: input.id ?? this.generateId('task'),
252
327
  title: input.title,
253
328
  ...(input.description ? { description: input.description } : {}),
254
- status: 'open',
329
+ status: input.status ?? 'open',
255
330
  priority: input.priority ?? 'medium',
256
331
  assignedAgent: null,
332
+ ...(input.assignee ? { assignee: input.assignee } : {}),
333
+ ...(input.tags && input.tags.length > 0 ? { tags: [...new Set(input.tags)] } : {}),
257
334
  claimedBy: null,
258
335
  claimedAt: null,
259
336
  ...(input.filesToModify ? { filesToModify: input.filesToModify } : {}),
@@ -262,6 +339,7 @@ export class FleetColabStore {
262
339
  ...(input.dependsOn && input.dependsOn.length > 0 ? { dependsOn: [...new Set(input.dependsOn)] } : {}),
263
340
  ...(input.goalMode ? { goalMode: true } : {}),
264
341
  ...(goalMaxTurns !== undefined ? { goalMaxTurns } : {}),
342
+ ...(input.retryBudget && input.retryBudget > 0 ? { retryBudget: input.retryBudget } : {}),
265
343
  createdBy: input.createdBy ?? this.agentId,
266
344
  createdAt: this.isoNow(),
267
345
  };
@@ -302,6 +380,82 @@ export class FleetColabStore {
302
380
  this.writeTasks(file);
303
381
  return true;
304
382
  }
383
+ // ── Board surface (Hermes-kanban parity, drives the unified kanban_* tools) ──
384
+ /** Append a comment to a task's discussion thread. */
385
+ addComment(taskId, text, author) {
386
+ const trimmed = text.trim();
387
+ if (!trimmed)
388
+ throw new Error('comment text is required');
389
+ return this.mutateTask(taskId, (task) => {
390
+ (task.comments ??= []).push({
391
+ id: this.generateId('cmt'),
392
+ text: trimmed,
393
+ createdAt: this.isoNow(),
394
+ ...(author?.trim() ? { author: author.trim() } : {}),
395
+ });
396
+ });
397
+ }
398
+ /** Attach a free-form reference (PR/commit/file/url) — not a DAG edge (see {@link link}). */
399
+ addLink(taskId, target, label) {
400
+ const trimmed = target.trim();
401
+ if (!trimmed)
402
+ throw new Error('link target is required');
403
+ return this.mutateTask(taskId, (task) => {
404
+ (task.links ??= []).push({
405
+ id: this.generateId('lnk'),
406
+ target: trimmed,
407
+ createdAt: this.isoNow(),
408
+ ...(label?.trim() ? { label: label.trim() } : {}),
409
+ });
410
+ });
411
+ }
412
+ /**
413
+ * Tool-facing heartbeat: records a progress ping AND renews the lease. Lenient
414
+ * (unlike {@link heartbeat}, which is the strict programmatic lease-renewal):
415
+ * an `open` task transitions to `in_progress` and is claimed by `agentId`, so
416
+ * an agent pinging a fresh card takes ownership the way Hermes's kanban does.
417
+ */
418
+ recordHeartbeat(taskId, message, author, agentId = this.agentId) {
419
+ return this.mutateTask(taskId, (task) => {
420
+ const stamp = this.isoNow();
421
+ if (task.status === 'open') {
422
+ task.status = 'in_progress';
423
+ task.claimedBy = task.claimedBy ?? agentId;
424
+ }
425
+ task.claimedAt = stamp;
426
+ task.lastHeartbeatAt = stamp;
427
+ (task.heartbeats ??= []).push({
428
+ id: this.generateId('hb'),
429
+ createdAt: stamp,
430
+ ...(message?.trim() ? { message: message.trim() } : {}),
431
+ ...(author?.trim() ? { author: author.trim() } : {}),
432
+ });
433
+ });
434
+ }
435
+ /** Resume a blocked task (back to in_progress) and record why. Mirrors `kanban_unblock`. */
436
+ unblockTask(taskId, comment, author) {
437
+ return this.mutateTask(taskId, (task) => {
438
+ if (task.status === 'blocked')
439
+ task.status = 'in_progress';
440
+ delete task.blockedReason;
441
+ (task.comments ??= []).push({
442
+ id: this.generateId('cmt'),
443
+ text: comment?.trim() || 'Unblocked',
444
+ createdAt: this.isoNow(),
445
+ ...(author?.trim() ? { author: author.trim() } : {}),
446
+ });
447
+ });
448
+ }
449
+ /** Shared read-modify-write helper for single-task surface mutations. */
450
+ mutateTask(taskId, mutate) {
451
+ const file = this.readTasks();
452
+ const task = file.tasks.find((t) => t.id === taskId);
453
+ if (!task)
454
+ throw new Error(`Unknown fleet task '${taskId}'`);
455
+ mutate(task);
456
+ this.writeTasks(file);
457
+ return { ...task };
458
+ }
305
459
  // ── Worklog ───────────────────────────────────────────────────────────────
306
460
  appendWorklog(entry) {
307
461
  const file = this.readWorklog();
@@ -399,9 +553,30 @@ export class FleetColabStore {
399
553
  return null;
400
554
  }
401
555
  }
556
+ /**
557
+ * Atomic write (temp file + rename). `fs.renameSync` is atomic on POSIX, so a
558
+ * concurrent same-host reader/writer never sees a half-written file — important
559
+ * now that the daemon, the `kanban_*` tools, and `/colab` can all drive the
560
+ * same `colab-tasks.json`. (Cross-machine arbitration stays git-push-order by
561
+ * design; this guards only the local race.) Mirrors the idiom in
562
+ * `src/kanban/kanban-store.ts`.
563
+ */
402
564
  writeJson(filePath, value) {
403
565
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
404
- fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
566
+ const tempPath = `${filePath}.${process.pid}.${++this.writeSeq}.tmp`;
567
+ try {
568
+ fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
569
+ fs.renameSync(tempPath, filePath);
570
+ }
571
+ catch (err) {
572
+ try {
573
+ fs.rmSync(tempPath, { force: true });
574
+ }
575
+ catch {
576
+ /* best-effort cleanup */
577
+ }
578
+ throw err;
579
+ }
405
580
  }
406
581
  }
407
582
  //# sourceMappingURL=colab-store.js.map
package/dist/index.js CHANGED
@@ -508,6 +508,23 @@ async function saveCommandLineSettings(apiKey, baseURL) {
508
508
  cli.warn(`⚠️ Could not save settings to file: ${error instanceof Error ? error.message : "Unknown error"}`);
509
509
  }
510
510
  }
511
+ /**
512
+ * A saved/default model is only usable if it matches the detected provider's
513
+ * backend: a `grok-*` slug 404s on the ChatGPT/Codex backend, and a `gpt-5.*`
514
+ * slug 404s on xAI. We only enforce this for the two providers with a known
515
+ * hard incompatibility (grok ↔ chatgpt); other providers are left untouched so
516
+ * local/OpenAI/Anthropic model names pass through as before.
517
+ */
518
+ function isModelCompatibleWithProvider(model, provider) {
519
+ if (!provider)
520
+ return true;
521
+ const looksGrok = /grok/i.test(model);
522
+ if (provider === 'grok')
523
+ return looksGrok;
524
+ if (provider === 'chatgpt')
525
+ return /^(gpt-|o[1-9]|codex)/i.test(model) && !looksGrok;
526
+ return true;
527
+ }
511
528
  // Load model from detected provider or user settings
512
529
  async function loadModel() {
513
530
  await ensureEnvLoaded();
@@ -517,15 +534,15 @@ async function loadModel() {
517
534
  const detected = await getDetectedProvider();
518
535
  // 2. Project/user settings override auto-detection — UNLESS the saved model is
519
536
  // incompatible with the detected provider. A `gpt-5.5` left in settings.json
520
- // would 404 against xAI after `buddy login xai`; prefer the Grok default then.
537
+ // would 404 against xAI after `buddy login xai`; symmetrically, a stale
538
+ // `grok-code-fast-1` default 404s against the ChatGPT/Codex backend (which
539
+ // then falls back to another unsupported slug and crashes). In either
540
+ // mismatch, prefer the detected provider's own default model.
521
541
  try {
522
542
  const getSettingsManager = await lazyImport.settingsManager();
523
543
  const settingsModel = getSettingsManager().getCurrentModel();
524
- if (settingsModel) {
525
- const grokProvider = detected?.provider === 'grok';
526
- if (!grokProvider || /grok/i.test(settingsModel)) {
527
- return settingsModel;
528
- }
544
+ if (settingsModel && isModelCompatibleWithProvider(settingsModel, detected?.provider)) {
545
+ return settingsModel;
529
546
  }
530
547
  }
531
548
  catch (_err) {
@@ -827,7 +844,7 @@ async function processPromptHeadless(prompt, apiKey, baseURL, model, maxToolRoun
827
844
  const previousHeadless = process.env.CODEBUDDY_HEADLESS;
828
845
  // Headless defaults MCP OFF (startup cost / determinism), but respect an
829
846
  // explicit opt-in so `CODEBUDDY_DISABLE_MCP=false buddy -p …` can use MCP
830
- // servers (e.g. the Code Explorer / gitnexus bridge, or the benchmark's
847
+ // servers (e.g. the Code Explorer / code-explorer bridge, or the benchmark's
831
848
  // "with graph" condition). Mirrors the opt-in pattern in goal-cli.ts.
832
849
  process.env.CODEBUDDY_DISABLE_MCP = process.env.CODEBUDDY_DISABLE_MCP ?? 'true';
833
850
  process.env.CODEBUDDY_HEADLESS = 'true';
@@ -839,7 +856,7 @@ async function processPromptHeadless(prompt, apiKey, baseURL, model, maxToolRoun
839
856
  await applyActiveLlmFailover(agent);
840
857
  await agent.systemPromptReady;
841
858
  // When MCP is opted in for this headless run, wait for the servers to finish
842
- // connecting so their tools (e.g. the Code Explorer / gitnexus bridge) are
859
+ // connecting so their tools (e.g. the Code Explorer / code-explorer bridge) are
843
860
  // registered before the first turn — otherwise the one-shot turn races init
844
861
  // and the agent never sees the MCP tools.
845
862
  if (process.env.CODEBUDDY_DISABLE_MCP !== 'true') {
@@ -2461,9 +2478,9 @@ addLazyCommandGroup(program, 'fleet', 'Inspect Fleet routing and dispatch policy
2461
2478
  const { registerFleetCommands } = await import('./commands/cli/fleet-commands.js');
2462
2479
  registerFleetCommands(program);
2463
2480
  });
2464
- addLazyCommandGroup(program, 'gitnexus', 'Interact with GitNexus for code understanding and session syncing', async () => {
2465
- const { registerGitNexusCommands } = await import('./commands/cli/gitnexus-commands.js');
2466
- registerGitNexusCommands(program);
2481
+ addLazyCommandGroup(program, 'code-explorer', 'Interact with CodeExplorer for code understanding and session syncing', async () => {
2482
+ const { registerCodeExplorerCommands } = await import('./commands/cli/code-explorer-commands.js');
2483
+ registerCodeExplorerCommands(program);
2467
2484
  });
2468
2485
  addLazyCommandGroup(program, 'hermes', 'Inspect the native Hermes-inspired Code Buddy agent profile', async () => {
2469
2486
  const { registerHermesCommands } = await import('./commands/cli/hermes-commands.js');
@@ -272,8 +272,23 @@ export class TextToSpeechManager extends EventEmitter {
272
272
  async speakWithPiper(text) {
273
273
  return new Promise((resolve, reject) => {
274
274
  const audioFile = path.join(this.tempDir, `tts_${Date.now()}.wav`);
275
+ // Resolve the Piper binary + voice model. Defaults to `piper` on PATH
276
+ // (no model), but honors the local-voice-stack convention shared with
277
+ // Cowork so a self-hosted Piper (e.g. ai-stack fr_FR-siwis-medium) works
278
+ // out of the box: COWORK_PIPER_BIN / COWORK_PIPER_VOICE (or the
279
+ // CODEBUDDY_* aliases, or a `.onnx` path set as the configured voice).
280
+ const piperBin = process.env.COWORK_PIPER_BIN || process.env.CODEBUDDY_PIPER_BIN || 'piper';
281
+ const voiceModel = process.env.COWORK_PIPER_VOICE ||
282
+ process.env.CODEBUDDY_PIPER_VOICE ||
283
+ (this.config.voice && this.config.voice.endsWith('.onnx')
284
+ ? this.config.voice
285
+ : undefined);
286
+ const piperArgs = ['--output_file', audioFile];
287
+ if (voiceModel) {
288
+ piperArgs.push('--model', voiceModel);
289
+ }
275
290
  // Piper reads from stdin and outputs to file
276
- const piper = spawn('piper', ['--output_file', audioFile]);
291
+ const piper = spawn(piperBin, piperArgs);
277
292
  piper.stdin?.write(text);
278
293
  piper.stdin?.end();
279
294
  piper.on('close', (code) => {
@@ -0,0 +1,31 @@
1
+ import { FleetColabStore } from '../fleet/colab-store.js';
2
+ import type { CreateKanbanCardInput, KanbanCard, KanbanStoreOptions, ListKanbanCardsFilter } from './kanban-store.js';
3
+ export declare class ColabKanbanAdapter {
4
+ private readonly store;
5
+ private readonly createId;
6
+ constructor(options?: KanbanStoreOptions);
7
+ /** The shared board file — now colab-tasks.json, not kanban-board.json. */
8
+ get path(): string;
9
+ createCard(input: CreateKanbanCardInput): Promise<KanbanCard>;
10
+ listCards(filter?: ListKanbanCardsFilter): Promise<KanbanCard[]>;
11
+ showCard(id: string): Promise<KanbanCard>;
12
+ completeCard(id: string, comment?: string, author?: string): Promise<KanbanCard>;
13
+ blockCard(id: string, reason: string, author?: string): Promise<KanbanCard>;
14
+ unblockCard(id: string, comment?: string, author?: string): Promise<KanbanCard>;
15
+ commentCard(id: string, text: string, author?: string): Promise<KanbanCard>;
16
+ heartbeatCard(id: string, message?: string, author?: string): Promise<KanbanCard>;
17
+ linkCard(id: string, target: string, label?: string): Promise<KanbanCard>;
18
+ private getOrThrow;
19
+ private buildCardId;
20
+ }
21
+ /**
22
+ * Migrate cards from a legacy `kanban-board.json` (read as {@link KanbanCard}s)
23
+ * into the unified fleet board. Idempotent: a card whose id already exists is
24
+ * skipped, as are archived cards (colab has no archived state). Comments and
25
+ * free-form links are carried over; ephemeral heartbeats are not. Returns the
26
+ * imported / skipped ids so a CLI can report the migration.
27
+ */
28
+ export declare function importKanbanCards(cards: KanbanCard[], store: FleetColabStore): {
29
+ imported: string[];
30
+ skipped: string[];
31
+ };
@@ -0,0 +1,232 @@
1
+ /**
2
+ * ColabKanbanAdapter — the unification seam (Hermes/OpenClaw parity).
3
+ *
4
+ * Exposes the subset of the {@link KanbanStore} API that the `kanban_*` tools
5
+ * use, but backed by {@link FleetColabStore} so the agent's kanban tools and the
6
+ * autonomous daemon drive ONE shared board (`.codebuddy/colab-tasks.json`)
7
+ * instead of two disconnected files. A card created with `kanban_create` is now
8
+ * claimable by the daemon; a task the daemon completes shows up in `kanban_list`.
9
+ *
10
+ * The output keeps the {@link KanbanCard} shape (so the tool contract is stable),
11
+ * with a faithful status/priority mapping. Crucially `urgent ↔ critical`, so a
12
+ * card marked `urgent` becomes a `critical` colab task — which
13
+ * {@link FleetColabStore.isAutoClaimable} refuses to auto-claim. That is the
14
+ * intended safety carry-over: urgent work needs human eyes, it is not silently
15
+ * grabbed by the daemon.
16
+ *
17
+ * Free-form `kanban_link` references (PRs/commits/files) are kept as free-form
18
+ * links — they are NOT folded into the `dependsOn` DAG (which stays a distinct,
19
+ * task-id-only concept surfaced via `buddy autonomy tasks link`).
20
+ *
21
+ * The richer multi-board surface (board registry, assign, archive, unlink, stats)
22
+ * stays on {@link KanbanStore} / the `hermes kanban` CLI — those are not exposed
23
+ * as the 9 `kanban_*` tools, so they are intentionally out of this adapter.
24
+ */
25
+ import * as path from 'path';
26
+ import { FleetColabStore, } from '../fleet/colab-store.js';
27
+ const STATUS_TO_KANBAN = {
28
+ open: 'todo',
29
+ in_progress: 'in_progress',
30
+ completed: 'done',
31
+ blocked: 'blocked',
32
+ };
33
+ const STATUS_TO_COLAB = {
34
+ todo: 'open',
35
+ in_progress: 'in_progress',
36
+ blocked: 'blocked',
37
+ done: 'completed',
38
+ archived: 'open', // archived has no colab equivalent; treat as open (archive lives on KanbanStore)
39
+ };
40
+ const PRIORITY_TO_KANBAN = {
41
+ critical: 'urgent',
42
+ high: 'high',
43
+ medium: 'medium',
44
+ low: 'low',
45
+ };
46
+ const PRIORITY_TO_COLAB = {
47
+ urgent: 'critical',
48
+ high: 'high',
49
+ medium: 'medium',
50
+ low: 'low',
51
+ };
52
+ export class ColabKanbanAdapter {
53
+ store;
54
+ createId;
55
+ constructor(options = {}) {
56
+ const rootDir = options.rootDir ?? process.cwd();
57
+ const config = { dir: path.join(rootDir, '.codebuddy') };
58
+ if (options.now) {
59
+ const toDate = options.now;
60
+ config.now = () => toDate().getTime();
61
+ }
62
+ this.createId = options.createId;
63
+ if (options.createId) {
64
+ const make = options.createId;
65
+ config.generateId = (prefix) => `${prefix}-${make()}`;
66
+ }
67
+ this.store = new FleetColabStore(config);
68
+ }
69
+ /** The shared board file — now colab-tasks.json, not kanban-board.json. */
70
+ get path() {
71
+ return path.join(this.store.getDir(), 'colab-tasks.json');
72
+ }
73
+ async createCard(input) {
74
+ const title = input.title.trim();
75
+ if (!title)
76
+ throw new Error('title is required');
77
+ const task = this.store.addTask({
78
+ title,
79
+ ...(input.id?.trim() ? { id: input.id.trim() } : { id: this.buildCardId(title) }),
80
+ ...(input.description?.trim() ? { description: input.description.trim() } : {}),
81
+ ...(input.status ? { status: STATUS_TO_COLAB[input.status] } : {}),
82
+ ...(input.priority ? { priority: PRIORITY_TO_COLAB[input.priority] } : {}),
83
+ ...(input.assignee?.trim() ? { assignee: input.assignee.trim() } : {}),
84
+ ...(input.tags && input.tags.length > 0 ? { tags: normalizeTags(input.tags) } : {}),
85
+ });
86
+ return toKanbanCard(task);
87
+ }
88
+ async listCards(filter = {}) {
89
+ const includeDone = filter.includeDone !== false;
90
+ const includeArchived = filter.includeArchived === true || filter.status === 'archived';
91
+ return this.store
92
+ .listTasks()
93
+ .map(toKanbanCard)
94
+ .filter((card) => {
95
+ if (!includeDone && card.status === 'done')
96
+ return false;
97
+ if (!includeArchived && card.status === 'archived')
98
+ return false;
99
+ if (filter.status && card.status !== filter.status)
100
+ return false;
101
+ if (filter.priority && card.priority !== filter.priority)
102
+ return false;
103
+ if (filter.assignee && card.assignee !== filter.assignee)
104
+ return false;
105
+ if (filter.tag && !card.tags.includes(filter.tag))
106
+ return false;
107
+ return true;
108
+ })
109
+ .sort(compareCards);
110
+ }
111
+ async showCard(id) {
112
+ return toKanbanCard(this.getOrThrow(id));
113
+ }
114
+ async completeCard(id, comment, author) {
115
+ this.getOrThrow(id);
116
+ this.store.completeTask(id, { summary: comment?.trim() || 'Completed', ...(author ? { agentId: author } : {}) });
117
+ if (comment?.trim())
118
+ this.store.addComment(id, comment, author);
119
+ return toKanbanCard(this.getOrThrow(id));
120
+ }
121
+ async blockCard(id, reason, author) {
122
+ const trimmed = reason.trim();
123
+ if (!trimmed)
124
+ throw new Error('reason is required');
125
+ this.store.blockTask(id, trimmed);
126
+ this.store.addComment(id, `Blocked: ${trimmed}`, author);
127
+ return toKanbanCard(this.getOrThrow(id));
128
+ }
129
+ async unblockCard(id, comment, author) {
130
+ return toKanbanCard(this.store.unblockTask(id, comment, author));
131
+ }
132
+ async commentCard(id, text, author) {
133
+ return toKanbanCard(this.store.addComment(id, text, author));
134
+ }
135
+ async heartbeatCard(id, message, author) {
136
+ return toKanbanCard(this.store.recordHeartbeat(id, message, author, author));
137
+ }
138
+ async linkCard(id, target, label) {
139
+ return toKanbanCard(this.store.addLink(id, target, label));
140
+ }
141
+ getOrThrow(id) {
142
+ const task = this.store.getTask(id.trim());
143
+ if (!task)
144
+ throw new Error(`kanban card not found: ${id}`);
145
+ return task;
146
+ }
147
+ buildCardId(title) {
148
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'card';
149
+ const raw = this.createId ? this.createId() : `${process.pid}${process.hrtime.bigint()}`;
150
+ const suffix = raw.replace(/[^a-zA-Z0-9]/g, '').slice(0, 8) || 'card';
151
+ return `kb-${slug}-${suffix}`;
152
+ }
153
+ }
154
+ /**
155
+ * Migrate cards from a legacy `kanban-board.json` (read as {@link KanbanCard}s)
156
+ * into the unified fleet board. Idempotent: a card whose id already exists is
157
+ * skipped, as are archived cards (colab has no archived state). Comments and
158
+ * free-form links are carried over; ephemeral heartbeats are not. Returns the
159
+ * imported / skipped ids so a CLI can report the migration.
160
+ */
161
+ export function importKanbanCards(cards, store) {
162
+ const imported = [];
163
+ const skipped = [];
164
+ const existing = new Set(store.listTasks().map((t) => t.id));
165
+ for (const card of cards) {
166
+ if (card.status === 'archived' || existing.has(card.id)) {
167
+ skipped.push(card.id);
168
+ continue;
169
+ }
170
+ store.addTask({
171
+ id: card.id,
172
+ title: card.title,
173
+ status: STATUS_TO_COLAB[card.status],
174
+ priority: PRIORITY_TO_COLAB[card.priority],
175
+ ...(card.description ? { description: card.description } : {}),
176
+ ...(card.tags.length > 0 ? { tags: card.tags } : {}),
177
+ ...(card.assignee ? { assignee: card.assignee } : {}),
178
+ });
179
+ for (const c of card.comments)
180
+ store.addComment(card.id, c.text, c.author);
181
+ for (const l of card.links)
182
+ store.addLink(card.id, l.target, l.label);
183
+ imported.push(card.id);
184
+ }
185
+ return { imported, skipped };
186
+ }
187
+ function toKanbanCard(task) {
188
+ const createdAt = task.createdAt ?? task.claimedAt ?? new Date(0).toISOString();
189
+ const updatedAt = task.completedAt ?? task.lastHeartbeatAt ?? task.claimedAt ?? task.createdAt ?? createdAt;
190
+ return {
191
+ id: task.id,
192
+ title: task.title,
193
+ status: STATUS_TO_KANBAN[task.status],
194
+ priority: PRIORITY_TO_KANBAN[task.priority],
195
+ tags: normalizeTags(task.tags ?? []),
196
+ links: (task.links ?? []).map((l) => ({
197
+ id: l.id,
198
+ target: l.target,
199
+ createdAt: l.createdAt,
200
+ ...(l.label ? { label: l.label } : {}),
201
+ })),
202
+ comments: (task.comments ?? []).map((c) => ({
203
+ id: c.id,
204
+ text: c.text,
205
+ createdAt: c.createdAt,
206
+ ...(c.author ? { author: c.author } : {}),
207
+ })),
208
+ heartbeats: (task.heartbeats ?? []).map((h) => ({
209
+ id: h.id,
210
+ createdAt: h.createdAt,
211
+ ...(h.message ? { message: h.message } : {}),
212
+ ...(h.author ? { author: h.author } : {}),
213
+ })),
214
+ createdAt,
215
+ updatedAt,
216
+ ...(task.description ? { description: task.description } : {}),
217
+ ...(task.assignee ? { assignee: task.assignee } : {}),
218
+ ...(task.blockedReason ? { blockedReason: task.blockedReason } : {}),
219
+ ...(task.completedAt ? { completedAt: task.completedAt } : {}),
220
+ };
221
+ }
222
+ function normalizeTags(tags) {
223
+ return [...new Set(tags.map((t) => t.trim()).filter(Boolean))].sort((a, b) => a.localeCompare(b));
224
+ }
225
+ function compareCards(a, b) {
226
+ const statusOrder = { blocked: 0, in_progress: 1, todo: 2, done: 3, archived: 4 };
227
+ const priorityOrder = { urgent: 0, high: 1, medium: 2, low: 3 };
228
+ return (statusOrder[a.status] - statusOrder[b.status] ||
229
+ priorityOrder[a.priority] - priorityOrder[b.priority] ||
230
+ a.createdAt.localeCompare(b.createdAt));
231
+ }
232
+ //# sourceMappingURL=colab-kanban-adapter.js.map
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Community Detection — GitNexus Knowledge Graph
2
+ * Community Detection — CodeExplorer Knowledge Graph
3
3
  *
4
4
  * Simplified label propagation algorithm for detecting architectural
5
5
  * modules/clusters in the code graph. Each node starts with its own
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Community Detection — GitNexus Knowledge Graph
2
+ * Community Detection — CodeExplorer Knowledge Graph
3
3
  *
4
4
  * Simplified label propagation algorithm for detecting architectural
5
5
  * modules/clusters in the code graph. Each node starts with its own
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Process Detection — GitNexus Knowledge Graph
2
+ * Process Detection — CodeExplorer Knowledge Graph
3
3
  *
4
4
  * Detects execution flows by BFS from entry points in the code graph.
5
5
  * Entry points are functions with no internal callers (or matching
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Process Detection — GitNexus Knowledge Graph
2
+ * Process Detection — CodeExplorer Knowledge Graph
3
3
  *
4
4
  * Detects execution flows by BFS from entry points in the code graph.
5
5
  * Entry points are functions with no internal callers (or matching