@phuetz/code-buddy 1.3.0 → 1.3.2
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/README.md +64 -22
- package/dist/agent/autonomous/agentic-coding-runner.d.ts +2 -2
- package/dist/agent/autonomous/agentic-coding-runner.js +24 -24
- package/dist/agent/autonomous/checkpoint-manager.d.ts +2 -2
- package/dist/agent/base-agent.d.ts +1 -1
- package/dist/agent/base-agent.js +1 -1
- package/dist/agent/hermes-tool-parity-local.js +2 -2
- package/dist/agent/tool-handler.js +2 -2
- package/dist/codebuddy/providers/provider-chatgpt-responses.d.ts +4 -0
- package/dist/codebuddy/providers/provider-chatgpt-responses.js +34 -13
- package/dist/codebuddy/providers/provider-openai-compat.js +8 -0
- package/dist/codebuddy/tool-definitions/code-explorer-tools.d.ts +8 -0
- package/dist/codebuddy/tool-definitions/code-explorer-tools.js +24 -0
- package/dist/codebuddy/tool-definitions/graph-tools.d.ts +1 -1
- package/dist/codebuddy/tool-definitions/graph-tools.js +1 -1
- package/dist/codebuddy/tool-definitions/index.d.ts +1 -1
- package/dist/codebuddy/tool-definitions/index.js +2 -2
- package/dist/codebuddy/tools.d.ts +2 -2
- package/dist/codebuddy/tools.js +13 -13
- package/dist/collaboration/ai-colab-manager.js +9 -4
- package/dist/commands/cli/code-explorer-commands.d.ts +7 -0
- package/dist/commands/cli/code-explorer-commands.js +40 -0
- package/dist/commands/cli/native-engine-commands.js +64 -0
- package/dist/commands/handlers/graph-handlers.d.ts +1 -1
- package/dist/commands/handlers/graph-handlers.js +1 -1
- package/dist/config/model-tools.js +1 -1
- package/dist/daemon/autonomous-loop.d.ts +0 -7
- package/dist/daemon/autonomous-loop.js +24 -14
- package/dist/fleet/capability-registry.js +9 -1
- package/dist/fleet/colab-store.d.ts +109 -3
- package/dist/fleet/colab-store.js +181 -6
- package/dist/index.js +28 -11
- package/dist/kanban/colab-kanban-adapter.d.ts +31 -0
- package/dist/kanban/colab-kanban-adapter.js +232 -0
- package/dist/knowledge/community-detector.d.ts +1 -1
- package/dist/knowledge/community-detector.js +1 -1
- package/dist/knowledge/process-detector.d.ts +1 -1
- package/dist/knowledge/process-detector.js +1 -1
- package/dist/plugins/{gitnexus/GitNexusMCPClient.d.ts → code-explorer/CodeExplorerMCPClient.d.ts} +9 -9
- package/dist/plugins/{gitnexus/GitNexusMCPClient.js → code-explorer/CodeExplorerMCPClient.js} +17 -17
- package/dist/plugins/{gitnexus/GitNexusManager.d.ts → code-explorer/CodeExplorerManager.d.ts} +16 -16
- package/dist/plugins/{gitnexus/GitNexusManager.js → code-explorer/CodeExplorerManager.js} +35 -35
- package/dist/plugins/code-explorer/index.d.ts +9 -0
- package/dist/plugins/code-explorer/index.js +8 -0
- package/dist/providers/provider-catalog.js +23 -1
- package/dist/services/prompt-builder.js +9 -9
- package/dist/skills/parser.js +1 -1
- package/dist/tools/{gitnexus-tool.d.ts → code-explorer-tool.d.ts} +9 -9
- package/dist/tools/{gitnexus-tool.js → code-explorer-tool.js} +16 -16
- package/dist/tools/metadata.js +3 -3
- package/dist/tools/registry/code-explorer-tools.d.ts +18 -0
- package/dist/tools/registry/{gitnexus-tools.js → code-explorer-tools.js} +14 -14
- package/dist/tools/registry/graph-tools.d.ts +1 -1
- package/dist/tools/registry/graph-tools.js +1 -1
- package/dist/tools/registry/index.d.ts +1 -1
- package/dist/tools/registry/index.js +6 -6
- package/dist/tools/registry/kanban-tools.d.ts +1 -1
- package/dist/tools/registry/kanban-tools.js +2 -2
- package/package.json +2 -2
- package/dist/codebuddy/tool-definitions/gitnexus-tools.d.ts +0 -8
- package/dist/codebuddy/tool-definitions/gitnexus-tools.js +0 -24
- package/dist/commands/cli/gitnexus-commands.d.ts +0 -7
- package/dist/commands/cli/gitnexus-commands.js +0 -42
- package/dist/plugins/gitnexus/index.d.ts +0 -9
- package/dist/plugins/gitnexus/index.js +0 -8
- 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
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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.
|
|
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
|
-
|
|
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`;
|
|
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
|
-
|
|
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 /
|
|
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 /
|
|
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, '
|
|
2465
|
-
const {
|
|
2466
|
-
|
|
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');
|
|
@@ -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
|