@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
@@ -0,0 +1,7 @@
1
+ /**
2
+ * CodeExplorer CLI commands
3
+ *
4
+ * Exposes commands to consult CodeExplorer and push session summaries.
5
+ */
6
+ import type { Command } from 'commander';
7
+ export declare function registerCodeExplorerCommands(program: Command): void;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * CodeExplorer CLI commands
3
+ *
4
+ * Exposes commands to consult CodeExplorer and push session summaries.
5
+ */
6
+ import { CodeExplorerTool } from '../../tools/code-explorer-tool.js';
7
+ export function registerCodeExplorerCommands(program) {
8
+ const codeExplorerCmd = program
9
+ .command('code-explorer')
10
+ .description('Interact with CodeExplorer for code understanding and session syncing');
11
+ codeExplorerCmd.command('ask')
12
+ .description('Consult CodeExplorer for a query or code understanding request')
13
+ .argument('<query>', 'The query or task description to ask CodeExplorer about')
14
+ .action(async (query) => {
15
+ try {
16
+ const codeExplorer = new CodeExplorerTool();
17
+ const result = await codeExplorer.ask(query);
18
+ console.log(JSON.stringify(result, null, 2));
19
+ }
20
+ catch (error) {
21
+ console.error('Error querying CodeExplorer:', error);
22
+ process.exit(1);
23
+ }
24
+ });
25
+ codeExplorerCmd.command('push-session')
26
+ .description('Push the session summary to CodeExplorer as technical memory')
27
+ .argument('<summary>', 'The session summary to push')
28
+ .action(async (summary) => {
29
+ try {
30
+ const codeExplorer = new CodeExplorerTool();
31
+ const result = await codeExplorer.pushSession(summary);
32
+ console.log(JSON.stringify(result, null, 2));
33
+ }
34
+ catch (error) {
35
+ console.error('Error pushing session to CodeExplorer:', error);
36
+ process.exit(1);
37
+ }
38
+ });
39
+ }
40
+ //# sourceMappingURL=code-explorer-commands.js.map
@@ -939,6 +939,70 @@ export function registerFleetAutonomyCommands(program) {
939
939
  const goalNote = task.goalMode ? ` goal-mode(${task.goalMaxTurns ?? 5} turns)` : '';
940
940
  console.log(`Added task ${task.id} [${task.priority}]${goalNote}${task.dependsOn ? ` depends on ${task.dependsOn.join(', ')}` : ''}`);
941
941
  });
942
+ tasks
943
+ .command('board')
944
+ .description('Render the unified fleet board as Hermes-style columns (To Do / In Progress / Review / Done)')
945
+ .option('--dir <path>', 'colab dir')
946
+ .option('--json', 'output JSON')
947
+ .action(async (opts) => {
948
+ const { FleetColabStore } = await import('../../fleet/colab-store.js');
949
+ const store = new FleetColabStore({ ...(opts.dir ? { dir: opts.dir } : {}) });
950
+ const all = store.listTasks();
951
+ const columns = [
952
+ { key: 'open', label: 'To Do' },
953
+ { key: 'in_progress', label: 'In Progress' },
954
+ { key: 'blocked', label: 'Review' },
955
+ { key: 'completed', label: 'Done' },
956
+ ];
957
+ if (opts.json) {
958
+ const grouped = Object.fromEntries(columns.map((c) => [c.label, all.filter((t) => t.status === c.key)]));
959
+ console.log(JSON.stringify(grouped, null, 2));
960
+ return;
961
+ }
962
+ for (const col of columns) {
963
+ const items = all.filter((t) => t.status === col.key);
964
+ console.log(`\n${col.label} (${items.length})`);
965
+ if (items.length === 0) {
966
+ console.log(' —');
967
+ continue;
968
+ }
969
+ for (const t of items) {
970
+ const annotations = [];
971
+ if (t.claimedBy)
972
+ annotations.push(`@${t.claimedBy}`);
973
+ if (t.attempts)
974
+ annotations.push(`attempts:${t.attempts}`);
975
+ if (t.dependsOn?.length)
976
+ annotations.push(`deps:${t.dependsOn.length}`);
977
+ if (t.goalMode)
978
+ annotations.push('goal');
979
+ if (col.key === 'blocked' && t.blockedReason)
980
+ annotations.push(`(${t.blockedReason})`);
981
+ const suffix = annotations.length ? ` — ${annotations.join(' ')}` : '';
982
+ console.log(` ${t.id} [${t.priority}] ${t.title}${suffix}`);
983
+ }
984
+ }
985
+ });
986
+ tasks
987
+ .command('import-kanban')
988
+ .description('Migrate cards from a legacy kanban-board.json into the unified fleet board')
989
+ .option('--board <path>', 'source kanban-board.json (default <cwd>/.codebuddy/kanban-board.json)')
990
+ .option('--dir <path>', 'target colab dir')
991
+ .option('--json', 'output JSON')
992
+ .action(async (opts) => {
993
+ const { KanbanStore } = await import('../../kanban/kanban-store.js');
994
+ const { FleetColabStore } = await import('../../fleet/colab-store.js');
995
+ const { importKanbanCards } = await import('../../kanban/colab-kanban-adapter.js');
996
+ const source = new KanbanStore(opts.board ? { boardPath: opts.board } : {});
997
+ const cards = await source.listCards({ includeArchived: true, includeDone: true });
998
+ const store = new FleetColabStore({ ...(opts.dir ? { dir: opts.dir } : {}) });
999
+ const { imported, skipped } = importKanbanCards(cards, store);
1000
+ if (opts.json) {
1001
+ console.log(JSON.stringify({ imported, skipped }, null, 2));
1002
+ return;
1003
+ }
1004
+ console.log(`Imported ${imported.length} card(s) into the unified board, skipped ${skipped.length} (archived or already present).`);
1005
+ });
942
1006
  fleet
943
1007
  .command('swarm <goal>')
944
1008
  .description('Create a workers → verifier → synthesizer task graph')
@@ -327,13 +327,8 @@ export async function registerAIMessageHandler(manager) {
327
327
  aiHandlerRegistered = true;
328
328
  manager.onMessage(async (message, channel) => {
329
329
  try {
330
- const apiKey = process.env.GROK_API_KEY || process.env.XAI_API_KEY || '';
331
- if (!apiKey) {
332
- logger.warn('No API key for channel AI responses');
333
- return;
334
- }
335
- const { checkDMPairing, getDMPairing, getRouteAgentConfig } = await import('../../channels/core.js');
336
- // 1. Check DM pairing first
330
+ // 1. DM pairing gate unapproved senders get a code, then we stop.
331
+ const { checkDMPairing, getDMPairing } = await import('../../channels/core.js');
337
332
  const pairingStatus = await checkDMPairing(message);
338
333
  if (!pairingStatus.approved) {
339
334
  if (pairingStatus.code) {
@@ -347,14 +342,35 @@ export async function registerAIMessageHandler(manager) {
347
342
  }
348
343
  return;
349
344
  }
350
- // 2. Resolve route-backed agent config
351
- const agentConfig = getRouteAgentConfig(message);
352
- // 3. Instantiate Agent with routed config
345
+ // Nothing to answer (e.g. a non-text message with no transcription).
346
+ if (!message.content || !message.content.trim()) {
347
+ return;
348
+ }
349
+ // 2. Context-adaptive agent reply (« comme Claude »): the agent's own
350
+ // query-classifier + buildForQuery scale the system prompt to the
351
+ // request (a greeting → minimal ~800B prompt, NOT the 73KB legacy),
352
+ // and tools load on demand — RAG selects only the relevant ~15 and the
353
+ // `tool_search` meta-tool pulls more when actually needed. Bounded
354
+ // rounds keep a simple chat fast while a real task can still act.
355
+ const { resolveProviderFromEnv } = await import('../../fleet/peer-chat-client-factory.js');
356
+ const knownProviders = ['ollama', 'chatgpt', 'gemini', 'grok', 'anthropic'];
357
+ const preferredProvider = process.env.CODEBUDDY_PROVIDER && knownProviders.includes(process.env.CODEBUDDY_PROVIDER)
358
+ ? process.env.CODEBUDDY_PROVIDER
359
+ : 'auto';
360
+ const resolved = resolveProviderFromEnv(preferredProvider);
361
+ if (!resolved) {
362
+ logger.warn('No LLM provider for channel chat — set CODEBUDDY_PROVIDER + a provider key/env');
363
+ return;
364
+ }
365
+ const { getRouteAgentConfig } = await import('../../channels/core.js');
353
366
  const { CodeBuddyAgent } = await import('../../agent/codebuddy-agent.js');
354
- const model = agentConfig.model || process.env.GROK_MODEL || 'grok-3-latest';
355
- const maxRounds = agentConfig.maxToolRounds;
356
- const agent = new CodeBuddyAgent(apiKey, process.env.GROK_BASE_URL, model, maxRounds);
357
- // 4. Resume/Initialize session history
367
+ const agentConfig = getRouteAgentConfig(message);
368
+ const model = agentConfig.model || resolved.model;
369
+ const agent = new CodeBuddyAgent(resolved.apiKey || 'local', resolved.baseUrl, model, agentConfig.maxToolRounds ?? 6, // bounded (vs the 50-round default)
370
+ true, // useRAGToolSelection relevant tools on demand, not all ~194
371
+ process.env.CODEBUDDY_CHANNEL_PROMPT_ID || 'auto', // minimal/adaptive prompt, not the 73KB legacy
372
+ process.cwd());
373
+ // Multi-turn: restore prior session history into the agent.
358
374
  const sessionKey = message.sessionKey || 'default-global';
359
375
  const sessionStore = agent.getSessionStore();
360
376
  let session = await sessionStore.loadSession(sessionKey);
@@ -371,18 +387,16 @@ export async function registerAIMessageHandler(manager) {
371
387
  await sessionStore.saveSession(session);
372
388
  }
373
389
  await sessionStore.resumeSession(sessionKey);
374
- const activeSession = session;
375
- if (activeSession.messages && activeSession.messages.length > 0) {
376
- const chatHistory = sessionStore.convertMessagesToChatEntries(activeSession.messages);
377
- const messages = activeSession.messages.map(m => ({
390
+ if (session.messages && session.messages.length > 0) {
391
+ const chatHistory = sessionStore.convertMessagesToChatEntries(session.messages);
392
+ const priorMessages = session.messages.map((m) => ({
378
393
  role: m.type === 'user' ? 'user' : 'assistant',
379
- content: m.content
394
+ content: m.content,
380
395
  }));
381
396
  const historyRestorer = agent;
382
397
  historyRestorer.historyManager.setChatHistory(chatHistory);
383
- historyRestorer.historyManager.setMessages(messages);
398
+ historyRestorer.historyManager.setMessages(priorMessages);
384
399
  }
385
- // 5. Run agent turn
386
400
  const entries = await agent.processUserMessage(message.content);
387
401
  const lastEntry = entries[entries.length - 1];
388
402
  const response = lastEntry ? String(lastEntry.content) : '';
@@ -412,7 +426,10 @@ export async function instantiateChannel(config) {
412
426
  switch (config.type) {
413
427
  case 'telegram': {
414
428
  const { TelegramChannel } = await import('../../channels/telegram/index.js');
415
- return new TelegramChannel({ botToken: config.token || '', ...opts });
429
+ // TelegramChannel reads `config.token` (client.ts) pass `token`, not
430
+ // `botToken`, or it throws "Telegram bot token is required" and the
431
+ // channel never starts from channels.json / server intake.
432
+ return new TelegramChannel({ token: config.token || '', ...opts });
416
433
  }
417
434
  case 'discord': {
418
435
  const { DiscordChannel } = await import('../../channels/discord/index.js');
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Graph Slash Command Handlers — GitNexus Knowledge Graph
2
+ * Graph Slash Command Handlers — CodeExplorer Knowledge Graph
3
3
  *
4
4
  * /impact <symbol> — Analyze blast radius of changing a symbol
5
5
  * /processes — Detect execution flows in the codebase
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Graph Slash Command Handlers — GitNexus Knowledge Graph
2
+ * Graph Slash Command Handlers — CodeExplorer Knowledge Graph
3
3
  *
4
4
  * /impact <symbol> — Analyze blast radius of changing a symbol
5
5
  * /processes — Detect execution flows in the codebase
@@ -147,7 +147,7 @@ const DEFAULT_MODEL_CONFIGS = [
147
147
  },
148
148
  // ChatGPT Codex backend (Phase d.23) — exposed via OAuth subscription
149
149
  // auth at chatgpt.com/backend-api/codex/responses. `gpt-5.5` matches the
150
- // GitNexus helper default; `gpt-5.2` remains the known-good fallback.
150
+ // CodeExplorer helper default; `gpt-5.2` remains the known-good fallback.
151
151
  {
152
152
  model: 'gpt-5.5*',
153
153
  supportsReasoning: true,
@@ -82,13 +82,6 @@ export declare class FleetAutonomousLoop {
82
82
  private readonly executor;
83
83
  private readonly policy;
84
84
  private readonly enabled;
85
- /**
86
- * Per-task consecutive-failure counts (in-memory, this run). Fed to
87
- * {@link chooseAutonomousModel} so a task that keeps failing on the cheap tier
88
- * escalates to a stronger model (policy `escalateAfterFailures`). Cleared on
89
- * success. Resets across process restarts — escalation is a within-run feature.
90
- */
91
- private readonly failures;
92
85
  private readonly goalJudge;
93
86
  private readonly selfImprove;
94
87
  private readonly selfImproveCooldownMs;
@@ -76,13 +76,10 @@ export class FleetAutonomousLoop {
76
76
  executor;
77
77
  policy;
78
78
  enabled;
79
- /**
80
- * Per-task consecutive-failure counts (in-memory, this run). Fed to
81
- * {@link chooseAutonomousModel} so a task that keeps failing on the cheap tier
82
- * escalates to a stronger model (policy `escalateAfterFailures`). Cleared on
83
- * success. Resets across process restarts — escalation is a within-run feature.
84
- */
85
- failures = new Map();
79
+ // Per-task failure counts now live on the task itself (`ColabTask.attempts`,
80
+ // persisted by FleetColabStore) so they survive daemon restarts and are visible
81
+ // cross-machine. They feed {@link chooseAutonomousModel} for model-ladder
82
+ // escalation AND the retry budget that dead-letters a hopeless task.
86
83
  goalJudge;
87
84
  selfImprove;
88
85
  selfImproveCooldownMs;
@@ -146,6 +143,10 @@ export class FleetAutonomousLoop {
146
143
  return { outcome: 'saturated', detail: 'at capacity — leaving the queue to idle peers' };
147
144
  }
148
145
  this.store.updatePresence({ status: 'active' });
146
+ // Zombie sweep (Hermes-kanban parity): reclaim crashed peers' expired claims
147
+ // before picking work. Each reclaim counts against the task's retry budget,
148
+ // dead-lettering a task that has been claimed-and-abandoned too many times.
149
+ this.store.reclaimExpired();
149
150
  const next = this.store.nextClaimable();
150
151
  if (!next) {
151
152
  // No real work — use the idle moment for one bounded self-improvement
@@ -163,7 +164,7 @@ export class FleetAutonomousLoop {
163
164
  return { outcome: 'idle', detail: err instanceof Error ? err.message : String(err) };
164
165
  }
165
166
  this.store.updatePresence({ status: 'active', currentTask: task.title });
166
- const failures = this.failures.get(task.id) ?? 0;
167
+ const failures = task.attempts ?? 0;
167
168
  const model = chooseAutonomousModel(this.tierConfig, { priority: task.priority, ...(failures > 0 ? { failures } : {}) }, this.policy);
168
169
  let result;
169
170
  const doneLoad = beginFleetWork('autonomy.task');
@@ -185,7 +186,7 @@ export class FleetAutonomousLoop {
185
186
  return goalOutcome;
186
187
  // null → judge said done (or skipped): fall through to completion.
187
188
  }
188
- this.failures.delete(task.id);
189
+ this.store.resetAttempts(task.id);
189
190
  this.store.completeTask(task.id, {
190
191
  summary: result.summary,
191
192
  filesModified: result.filesModified ?? [],
@@ -194,18 +195,27 @@ export class FleetAutonomousLoop {
194
195
  this.store.updatePresence({ status: 'idle', currentTask: null });
195
196
  return { outcome: 'completed', taskId: task.id, taskTitle: task.title, model };
196
197
  }
197
- // Track the failure so the next attempt can escalate up the model ladder.
198
- this.failures.set(task.id, failures + 1);
199
- // Failure: log it and release the task so it can be retried (or escalated).
198
+ // Persist the failure (retry-budget counter survives restarts, visible
199
+ // cross-machine). The next attempt can escalate up the model ladder; once the
200
+ // budget is exhausted the task is dead-lettered to `blocked` (the Review
201
+ // column) instead of being released to spin forever.
202
+ const { attempts, exhausted } = this.store.recordFailure(task.id);
200
203
  this.store.appendWorklog({
201
204
  agent: this.store.agentId,
202
205
  taskId: task.id,
203
206
  summary: `Autonomous attempt failed: ${result.summary}`,
204
207
  filesModified: [],
205
208
  issues: [result.error ?? 'unknown error'],
206
- nextSteps: ['retry on a later tick or escalate to the strong model'],
209
+ nextSteps: exhausted
210
+ ? [`retry budget (${attempts} attempts) exhausted — dead-lettered for human review`]
211
+ : ['retry on a later tick or escalate to the strong model'],
207
212
  });
208
- this.store.releaseTask(task.id);
213
+ if (exhausted) {
214
+ this.store.blockTask(task.id, `Failed ${attempts}× (retry budget exhausted) — needs review`);
215
+ }
216
+ else {
217
+ this.store.releaseTask(task.id);
218
+ }
209
219
  this.store.updatePresence({ status: 'idle', currentTask: null });
210
220
  return {
211
221
  outcome: 'failed',
@@ -351,7 +351,15 @@ function buildGrokCatalog() {
351
351
  }));
352
352
  }
353
353
  function buildMistralCatalog() {
354
- const ids = ['mistral-large-latest', 'mistral-medium-latest', 'mistral-small-latest'];
354
+ const ids = [
355
+ 'mistral-large-latest',
356
+ 'mistral-medium-latest',
357
+ 'mistral-small-latest',
358
+ 'codestral-latest',
359
+ 'devstral-latest',
360
+ 'magistral-medium-latest',
361
+ 'ministral-8b-latest',
362
+ ];
355
363
  return ids.map((id) => ({
356
364
  id,
357
365
  contextWindow: 128_000,
@@ -36,6 +36,21 @@ export interface ColabTask {
36
36
  assignedAgent?: string | null;
37
37
  claimedBy?: string | null;
38
38
  claimedAt?: string | null;
39
+ /**
40
+ * Last lease renewal (heartbeat). A live worker calls {@link FleetColabStore.heartbeat}
41
+ * to re-stamp `claimedAt`, so {@link FleetColabStore.isClaimExpired} (which measures
42
+ * `claimedAt` age) doubles as zombie detection: a silent claim ages out, a
43
+ * heartbeated one does not. Informational mirror of the last bump.
44
+ */
45
+ lastHeartbeatAt?: string | null;
46
+ /**
47
+ * Persisted failure/zombie-reclaim count (Hermes-kanban retry-budget parity).
48
+ * Survives daemon restarts and is visible cross-machine, unlike an in-memory
49
+ * counter. Reset on success; incremented on each failed attempt or zombie reclaim.
50
+ */
51
+ attempts?: number;
52
+ /** Per-task override of the dead-letter threshold (default {@link DEFAULT_RETRY_BUDGET}). */
53
+ retryBudget?: number;
39
54
  completedAt?: string | null;
40
55
  blockedReason?: string;
41
56
  filesToModify?: string[];
@@ -64,6 +79,34 @@ export interface ColabTask {
64
79
  goalLastReason?: string;
65
80
  createdBy?: string;
66
81
  createdAt?: string;
82
+ /** Free-text labels for filtering (`kanban_list --tag`). */
83
+ tags?: string[];
84
+ /** Human/agent the card is assigned to (distinct from `claimedBy`, which is the live worker). */
85
+ assignee?: string;
86
+ /** Discussion thread (block/unblock/complete also append here). */
87
+ comments?: ColabComment[];
88
+ /** Progress pings (each also renews the lease via {@link FleetColabStore.heartbeat}). */
89
+ heartbeats?: ColabHeartbeat[];
90
+ /** Free-form references (PRs, commits, files) — NOT DAG edges; dependency edges live in `dependsOn`. */
91
+ links?: ColabLink[];
92
+ }
93
+ export interface ColabComment {
94
+ id: string;
95
+ author?: string;
96
+ text: string;
97
+ createdAt: string;
98
+ }
99
+ export interface ColabHeartbeat {
100
+ id: string;
101
+ author?: string;
102
+ message?: string;
103
+ createdAt: string;
104
+ }
105
+ export interface ColabLink {
106
+ id: string;
107
+ target: string;
108
+ label?: string;
109
+ createdAt: string;
67
110
  }
68
111
  export interface ColabWorklogFileChange {
69
112
  file: string;
@@ -97,6 +140,12 @@ export interface FleetColabStoreConfig {
97
140
  * not stay stuck. Lazy-on-read (no timer); 0 disables. Default 15 min.
98
141
  */
99
142
  claimTtlMs?: number;
143
+ /**
144
+ * Default retry budget for tasks that don't set their own (default
145
+ * {@link DEFAULT_RETRY_BUDGET}). After this many failures/zombie-reclaims a task
146
+ * is dead-lettered to `blocked` for review instead of being retried forever.
147
+ */
148
+ retryBudget?: number;
100
149
  /** Injectable clock (epoch ms) for deterministic tests. */
101
150
  now?: () => number;
102
151
  /** Injectable id generator for deterministic tests. */
@@ -120,6 +169,11 @@ export interface AddTaskInput {
120
169
  dependsOn?: string[];
121
170
  goalMode?: boolean;
122
171
  goalMaxTurns?: number;
172
+ /** Per-task dead-letter threshold override (default {@link DEFAULT_RETRY_BUDGET}). */
173
+ retryBudget?: number;
174
+ tags?: string[];
175
+ assignee?: string;
176
+ status?: ColabTaskStatus;
123
177
  createdBy?: string;
124
178
  id?: string;
125
179
  }
@@ -131,6 +185,8 @@ export declare class FleetColabStore {
131
185
  private readonly now;
132
186
  private readonly generateId;
133
187
  private readonly claimTtlMs;
188
+ private readonly retryBudget;
189
+ private writeSeq;
134
190
  private readonly tasksPath;
135
191
  private readonly worklogPath;
136
192
  private readonly presencePath;
@@ -150,12 +206,39 @@ export declare class FleetColabStore {
150
206
  * reclaim of a crashed agent's work). Lazy: evaluated on read, no timer.
151
207
  */
152
208
  isClaimExpired(task: Pick<ColabTask, 'status' | 'claimedAt'>, nowMs?: number): boolean;
209
+ /** The dead-letter threshold for a task (its own `retryBudget`, else the store default). */
210
+ resolveRetryBudget(task: Pick<ColabTask, 'retryBudget'>): number;
153
211
  /**
154
- * Sweep expired claims back to `open` (for a daemon to call proactively).
155
- * Returns the reclaimed task ids. Lazy callers can rely on {@link nextClaimable},
156
- * which already treats expired claims as available.
212
+ * Sweep expired claims (zombie detection) a crashed agent's claim ages out
213
+ * because it stopped heartbeating. Each reclaim counts against the task's
214
+ * retry budget: under budget it returns to `open` for retry; at/over budget it
215
+ * is dead-lettered to `blocked` (the "Review" column) instead of spinning
216
+ * forever. Returns every reclaimed task id (re-opened or dead-lettered).
217
+ * Lazy callers can rely on {@link nextClaimable}, which treats an expired claim
218
+ * as available — but only the sweep enforces the retry budget, so a daemon
219
+ * should call this each tick.
157
220
  */
158
221
  reclaimExpired(): string[];
222
+ /**
223
+ * Renew a claim's lease (Hermes-kanban heartbeat). Re-stamps `claimedAt` and
224
+ * `lastHeartbeatAt` so a long-running but live worker is not reclaimed as a
225
+ * zombie. Only valid while the task is `in_progress`; when `agentId` is given it
226
+ * must match the current claimant.
227
+ */
228
+ heartbeat(taskId: string, agentId?: string): ColabTask;
229
+ /**
230
+ * Record a failed attempt (persisted retry-budget counter). Increments
231
+ * `attempts` and reports whether the budget is now exhausted, so the caller can
232
+ * dead-letter (`blockTask`) vs retry (`releaseTask`). Does not change status —
233
+ * the daemon owns the worklog/release flow.
234
+ */
235
+ recordFailure(taskId: string): {
236
+ task: ColabTask;
237
+ attempts: number;
238
+ exhausted: boolean;
239
+ };
240
+ /** Reset the retry-budget counter (call on a successful attempt). */
241
+ resetAttempts(taskId: string): ColabTask;
159
242
  /** Dependency ids that are not yet `completed` (unknown/missing ids count as unmet). */
160
243
  unmetDependencies(task: Pick<ColabTask, 'dependsOn'>, tasks: ColabTask[]): string[];
161
244
  /** True when every dependency of a task is `completed` (DAG readiness). */
@@ -192,6 +275,21 @@ export declare class FleetColabStore {
192
275
  link(childId: string, parentId: string): ColabTask;
193
276
  /** Remove a `child dependsOn parent` edge. Returns false if the edge was absent. */
194
277
  unlink(childId: string, parentId: string): boolean;
278
+ /** Append a comment to a task's discussion thread. */
279
+ addComment(taskId: string, text: string, author?: string): ColabTask;
280
+ /** Attach a free-form reference (PR/commit/file/url) — not a DAG edge (see {@link link}). */
281
+ addLink(taskId: string, target: string, label?: string): ColabTask;
282
+ /**
283
+ * Tool-facing heartbeat: records a progress ping AND renews the lease. Lenient
284
+ * (unlike {@link heartbeat}, which is the strict programmatic lease-renewal):
285
+ * an `open` task transitions to `in_progress` and is claimed by `agentId`, so
286
+ * an agent pinging a fresh card takes ownership the way Hermes's kanban does.
287
+ */
288
+ recordHeartbeat(taskId: string, message?: string, author?: string, agentId?: string): ColabTask;
289
+ /** Resume a blocked task (back to in_progress) and record why. Mirrors `kanban_unblock`. */
290
+ unblockTask(taskId: string, comment?: string, author?: string): ColabTask;
291
+ /** Shared read-modify-write helper for single-task surface mutations. */
292
+ private mutateTask;
195
293
  appendWorklog(entry: Omit<ColabWorklogEntry, 'id' | 'date'> & {
196
294
  id?: string;
197
295
  date?: string;
@@ -214,5 +312,13 @@ export declare class FleetColabStore {
214
312
  private readPresence;
215
313
  private writePresence;
216
314
  private readJson;
315
+ /**
316
+ * Atomic write (temp file + rename). `fs.renameSync` is atomic on POSIX, so a
317
+ * concurrent same-host reader/writer never sees a half-written file — important
318
+ * now that the daemon, the `kanban_*` tools, and `/colab` can all drive the
319
+ * same `colab-tasks.json`. (Cross-machine arbitration stays git-push-order by
320
+ * design; this guards only the local race.) Mirrors the idiom in
321
+ * `src/kanban/kanban-store.ts`.
322
+ */
217
323
  private writeJson;
218
324
  }