neohive 6.0.2 → 6.1.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.
package/tools/tasks.js ADDED
@@ -0,0 +1,446 @@
1
+ 'use strict';
2
+
3
+ // Task management tools: create, update, list, suggest.
4
+ // Extracted from server.js as part of modular tool architecture.
5
+
6
+ const fs = require('fs');
7
+
8
+ module.exports = function (ctx) {
9
+ const { state, helpers, files } = ctx;
10
+
11
+ const {
12
+ getTasks, saveTasks, getAgents, isPidAlive, generateId, writeJsonFile,
13
+ broadcastSystemMessage, sendSystemMessage, touchActivity, fireEvent,
14
+ ensureDataDir, getProfiles, getReviews, getReputation, getDeps,
15
+ getChannelsData, saveChannelsData, isGroupMode,
16
+ getWorkspace, saveWorkspace, appendNotification,
17
+ getWorkflows, saveWorkflows, saveWorkflowCheckpoint, findReadySteps,
18
+ getMessagesFile, getHistoryFile, logViolation, cachedRead,
19
+ } = helpers;
20
+
21
+ const {
22
+ TASKS_FILE, REVIEWS_FILE, DEPS_FILE,
23
+ } = files;
24
+
25
+ // --- Create Task ---
26
+
27
+ function toolCreateTask(title, description, assignee) {
28
+ if (!state.registeredName) return { error: 'You must call register() first' };
29
+ description = description || '';
30
+ assignee = assignee || null;
31
+
32
+ if (!title || !title.trim()) return { error: 'Task title cannot be empty' };
33
+ if (title.length > 200) return { error: 'Task title too long (max 200 characters)' };
34
+ if (description.length > 5000) return { error: 'Task description too long (max 5000 characters)' };
35
+
36
+ const agents = getAgents();
37
+ const otherAgents = Object.keys(agents).filter(n => n !== state.registeredName);
38
+
39
+ if (!assignee && otherAgents.length === 1) {
40
+ assignee = otherAgents[0];
41
+ }
42
+
43
+ const task = {
44
+ id: 'task_' + generateId(),
45
+ title,
46
+ description,
47
+ status: 'pending',
48
+ assignee: assignee || null,
49
+ created_by: state.registeredName,
50
+ created_at: new Date().toISOString(),
51
+ updated_at: new Date().toISOString(),
52
+ notes: [],
53
+ };
54
+
55
+ ensureDataDir();
56
+
57
+ // Task-channel auto-binding: with 5+ agents and an assignee, auto-create a task channel
58
+ let taskChannel = null;
59
+ const aliveCount = Object.values(agents).filter(a => isPidAlive(a.pid, a.last_activity)).length;
60
+ if (assignee && aliveCount >= 5 && isGroupMode()) {
61
+ const shortId = task.id.replace('task_', '').substring(0, 6);
62
+ taskChannel = `task-${shortId}`;
63
+ const channels = getChannelsData();
64
+ if (!channels[taskChannel]) {
65
+ channels[taskChannel] = {
66
+ description: `Task: ${title.substring(0, 100)}`,
67
+ members: [state.registeredName],
68
+ created_by: '__system__',
69
+ created_at: new Date().toISOString(),
70
+ task_id: task.id,
71
+ };
72
+ if (assignee && assignee !== state.registeredName) channels[taskChannel].members.push(assignee);
73
+ saveChannelsData(channels);
74
+ }
75
+ task.channel = taskChannel;
76
+ }
77
+
78
+ const tasks = getTasks();
79
+ if (tasks.length >= 1000) return { error: 'Task limit reached (max 1000). Complete or remove existing tasks first.' };
80
+ tasks.push(task);
81
+ saveTasks(tasks);
82
+ touchActivity();
83
+
84
+ const result = { success: true, task_id: task.id, assignee: task.assignee };
85
+ if (taskChannel) result.channel = taskChannel;
86
+ return result;
87
+ }
88
+
89
+ // --- Update Task ---
90
+
91
+ function toolUpdateTask(taskId, status, notes) {
92
+ if (!state.registeredName) return { error: 'You must call register() first' };
93
+ notes = notes || null;
94
+
95
+ const validStatuses = ['pending', 'in_progress', 'in_review', 'done', 'blocked', 'blocked_permanent'];
96
+ if (!validStatuses.includes(status)) return { error: `Invalid status. Must be one of: ${validStatuses.join(', ')}` };
97
+
98
+ const tasks = getTasks();
99
+ const task = tasks.find(t => t.id === taskId);
100
+ if (!task) return { error: `Task not found: ${taskId}` };
101
+
102
+ // Prevent race condition: can't claim a task already in_progress by another agent
103
+ if (status === 'in_progress' && task.status === 'in_progress' && task.assignee && task.assignee !== state.registeredName) {
104
+ return { error: `Task already claimed by ${task.assignee}. Use suggest_task() to find another task.` };
105
+ }
106
+ if (status === 'in_progress' && !task.assignee) {
107
+ task.assignee = state.registeredName;
108
+ }
109
+ if (status === 'in_progress') {
110
+ if (!task.attempt_agents) task.attempt_agents = [];
111
+ if (!task.attempt_agents.includes(state.registeredName)) task.attempt_agents.push(state.registeredName);
112
+ }
113
+
114
+ // Circuit breaker: if task goes back to pending and 3+ agents have failed, block permanently
115
+ if (status === 'pending' && task.attempt_agents && task.attempt_agents.length >= 3) {
116
+ task.status = 'blocked_permanent';
117
+ task.updated_at = new Date().toISOString();
118
+ task.block_reason = `Circuit breaker: ${task.attempt_agents.length} agents attempted and failed (${task.attempt_agents.join(', ')})`;
119
+ saveTasks(tasks);
120
+ broadcastSystemMessage(`[CIRCUIT BREAKER] Task "${task.title}" permanently blocked after ${task.attempt_agents.length} agents failed. Needs human review.`);
121
+ touchActivity();
122
+ return { success: true, task_id: task.id, status: 'blocked_permanent', circuit_breaker: true, message: 'Task permanently blocked — too many agents failed. Needs human review.' };
123
+ }
124
+
125
+ // Review gate: block 'done' if a quality/reviewer agent is online and no approved review exists
126
+ if (status === 'done') {
127
+ const agents = getAgents();
128
+ const profiles = getProfiles();
129
+ const hasReviewer = Object.keys(agents).some(n => {
130
+ if (n === state.registeredName) return false;
131
+ if (!isPidAlive(agents[n].pid, agents[n].last_activity)) return false;
132
+ const role = (profiles[n] && profiles[n].role) || '';
133
+ return role === 'quality' || role === 'reviewer';
134
+ });
135
+ if (hasReviewer) {
136
+ const reviews = getReviews();
137
+ const hasApproval = reviews.some(r =>
138
+ r.status === 'approved' &&
139
+ r.requested_by === state.registeredName &&
140
+ (r.file && task.title && (task.title === r.file || task.title.includes(r.file)))
141
+ );
142
+ if (!hasApproval) {
143
+ const reviewId = 'review_' + generateId();
144
+ reviews.push({
145
+ id: reviewId,
146
+ file: task.title,
147
+ requested_by: state.registeredName,
148
+ status: 'pending',
149
+ requested_at: new Date().toISOString(),
150
+ });
151
+ writeJsonFile(REVIEWS_FILE, reviews);
152
+ task.status = 'in_review';
153
+ task.updated_at = new Date().toISOString();
154
+ saveTasks(tasks);
155
+ broadcastSystemMessage(`[REVIEW GATE] ${state.registeredName} tried to mark "${task.title}" done but no review exists. Auto-created review ${reviewId}. A reviewer must approve before this task can be completed.`, state.registeredName);
156
+ logViolation('review_gate_blocked', state.registeredName, `Task "${task.title}" (${task.id}) blocked — no approved review. Auto-created ${reviewId}.`);
157
+ touchActivity();
158
+ return {
159
+ blocked: true,
160
+ task_id: task.id,
161
+ status: 'in_review',
162
+ review_id: reviewId,
163
+ message: `Cannot mark done — a reviewer is online and no approval exists. Review ${reviewId} auto-created. Wait for approval, then try again.`,
164
+ };
165
+ }
166
+ }
167
+ }
168
+
169
+ task.status = status;
170
+ task.updated_at = new Date().toISOString();
171
+ if (status !== 'blocked' && task.escalated_at) delete task.escalated_at;
172
+ if (notes) {
173
+ task.notes.push({ by: state.registeredName, text: notes, at: new Date().toISOString() });
174
+ }
175
+
176
+ saveTasks(tasks);
177
+ touchActivity();
178
+
179
+ // Auto-status: update agent's workspace status on task state changes
180
+ try {
181
+ if (status === 'in_progress') {
182
+ saveWorkspace(state.registeredName, Object.assign(getWorkspace(state.registeredName), { _status: `Working on: ${task.title}`, _status_since: new Date().toISOString() }));
183
+ } else if (status === 'done') {
184
+ saveWorkspace(state.registeredName, Object.assign(getWorkspace(state.registeredName), { _status: `Completed: ${task.title}`, _status_since: new Date().toISOString() }));
185
+ } else if (status === 'blocked') {
186
+ saveWorkspace(state.registeredName, Object.assign(getWorkspace(state.registeredName), { _status: `BLOCKED on: ${task.title}`, _status_since: new Date().toISOString() }));
187
+ }
188
+ } catch (e) { /* workspace status update failed */ }
189
+
190
+ // Task-channel auto-join: when claiming a task that has a channel, auto-join it
191
+ if (status === 'in_progress' && task.channel) {
192
+ const channels = getChannelsData();
193
+ if (channels[task.channel] && !channels[task.channel].members.includes(state.registeredName)) {
194
+ channels[task.channel].members.push(state.registeredName);
195
+ saveChannelsData(channels);
196
+ }
197
+ }
198
+
199
+ // Event hooks: task completion
200
+ if (status === 'done') {
201
+ fireEvent('task_complete', { title: task.title, created_by: task.created_by });
202
+ appendNotification('task_done', state.registeredName, `Task "${task.title}" completed by ${state.registeredName}`, task.id);
203
+ // Check if this resolves any dependencies
204
+ const deps = getDeps();
205
+ for (const dep of deps) {
206
+ if (dep.depends_on === taskId && !dep.resolved) {
207
+ dep.resolved = true;
208
+ const blockedTask = tasks.find(t => t.id === dep.task_id);
209
+ if (blockedTask && blockedTask.assignee) {
210
+ fireEvent('dependency_met', { task_title: task.title, notify: blockedTask.assignee });
211
+ }
212
+ }
213
+ }
214
+ writeJsonFile(DEPS_FILE, deps);
215
+
216
+ // Task-channel auto-cleanup: archive task channel when task is done
217
+ if (task.channel) {
218
+ const channels = getChannelsData();
219
+ if (channels[task.channel]) {
220
+ delete channels[task.channel];
221
+ saveChannelsData(channels);
222
+ }
223
+ }
224
+
225
+ // Quality gate: auto-request review when task is completed
226
+ const agents = getAgents();
227
+ const aliveOthers = Object.keys(agents).filter(n => n !== state.registeredName && isPidAlive(agents[n].pid, agents[n].last_activity));
228
+ if (aliveOthers.length > 0) {
229
+ broadcastSystemMessage(`[REVIEW NEEDED] ${state.registeredName} completed task "${task.title}". Team: please review the work and call submit_review() if applicable.`, state.registeredName);
230
+ }
231
+
232
+ // Auto-sync: advance matching workflow step when task is done
233
+ try {
234
+ const workflows = getWorkflows();
235
+ let wfChanged = false;
236
+ for (const wf of workflows) {
237
+ if (wf.status !== 'active') continue;
238
+ for (const step of wf.steps) {
239
+ if (step.status !== 'in_progress') continue;
240
+ if (step.assignee !== state.registeredName) continue;
241
+ step.status = 'done';
242
+ step.completed_at = new Date().toISOString();
243
+ step.notes = `Auto-completed via task "${task.title}"`;
244
+ saveWorkflowCheckpoint(wf, step);
245
+ const nextSteps = findReadySteps(wf);
246
+ for (const ns of nextSteps) {
247
+ if (ns.requires_approval) {
248
+ ns.status = 'awaiting_approval';
249
+ ns.approval_requested_at = new Date().toISOString();
250
+ sendSystemMessage('__user__', `[APPROVAL NEEDED] Workflow "${wf.name}" — Step ${ns.id}: "${ns.description}". Approve or reject from the dashboard.`);
251
+ } else {
252
+ ns.status = 'in_progress';
253
+ ns.started_at = new Date().toISOString();
254
+ if (ns.assignee && ns.assignee !== state.registeredName) {
255
+ const handoffContent = `[Workflow "${wf.name}"] Step ${ns.id} assigned to you: ${ns.description}`;
256
+ state.messageSeq++;
257
+ const hMsg = { id: generateId(), seq: state.messageSeq, from: state.registeredName, to: ns.assignee, content: handoffContent, timestamp: new Date().toISOString(), type: 'handoff' };
258
+ fs.appendFileSync(getMessagesFile(state.currentBranch), JSON.stringify(hMsg) + '\n');
259
+ fs.appendFileSync(getHistoryFile(state.currentBranch), JSON.stringify(hMsg) + '\n');
260
+ }
261
+ }
262
+ }
263
+ if (wf.steps.every(s => s.status === 'done')) wf.status = 'completed';
264
+ wf.updated_at = new Date().toISOString();
265
+ wfChanged = true;
266
+ broadcastSystemMessage(`[WORKFLOW] Step "${step.description}" auto-advanced via task completion by ${state.registeredName}`);
267
+ break;
268
+ }
269
+ if (wfChanged) break;
270
+ }
271
+ if (wfChanged) saveWorkflows(workflows);
272
+ } catch (e) { /* auto-advance workflow on task done failed */ }
273
+ }
274
+
275
+ // GitHub Projects sync — async, non-blocking, graceful if unconfigured
276
+ try {
277
+ const ghSync = require('../lib/github-sync');
278
+ if (ghSync.isConfigured()) {
279
+ ghSync.syncTask(task).catch(function () {});
280
+ }
281
+ } catch (e) { /* github-sync module not available */ }
282
+
283
+ // Event hooks: notify subscribers of all task status changes
284
+ try {
285
+ const hooksLib = require('../lib/hooks');
286
+ const notifications = hooksLib.emit('task.status_changed', {
287
+ task_id: task.id, title: task.title, status: task.status,
288
+ assignee: task.assignee, changed_by: state.registeredName,
289
+ _source_agent: state.registeredName,
290
+ });
291
+ for (const n of notifications) { helpers.sendSystemMessage(n.agent, n.message); }
292
+ } catch (e) { /* hooks not available */ }
293
+
294
+ return { success: true, task_id: task.id, status: task.status, title: task.title };
295
+ }
296
+
297
+ // --- List Tasks ---
298
+
299
+ function toolListTasks(status, assignee) {
300
+ let tasks = getTasks();
301
+ if (status) tasks = tasks.filter(t => t.status === status);
302
+ if (assignee) tasks = tasks.filter(t => t.assignee === assignee);
303
+
304
+ return {
305
+ count: tasks.length,
306
+ tasks: tasks.map(t => ({
307
+ id: t.id,
308
+ title: t.title,
309
+ description: t.description,
310
+ status: t.status,
311
+ assignee: t.assignee,
312
+ created_by: t.created_by,
313
+ created_at: t.created_at,
314
+ updated_at: t.updated_at,
315
+ notes_count: Array.isArray(t.notes) ? t.notes.length : 0,
316
+ })),
317
+ };
318
+ }
319
+
320
+ // --- Suggest Task ---
321
+
322
+ function toolSuggestTask() {
323
+ if (!state.registeredName) return { error: 'You must call register() first' };
324
+
325
+ const rep = getReputation();
326
+ const myRep = rep[state.registeredName];
327
+ const tasks = getTasks();
328
+ const pendingTasks = tasks.filter(t => t.status === 'pending' && !t.assignee);
329
+ const unassignedTasks = tasks.filter(t => t.status === 'pending');
330
+
331
+ if (pendingTasks.length === 0 && unassignedTasks.length === 0) {
332
+ const reviews = getReviews();
333
+ const pendingReviews = reviews.filter(r => r.status === 'pending' && r.requested_by !== state.registeredName);
334
+ if (pendingReviews.length > 0) {
335
+ return { suggestion: 'review', review_id: pendingReviews[0].id, file: pendingReviews[0].file, message: `No pending tasks, but there's a code review waiting: "${pendingReviews[0].file}". Call submit_review() to review it.` };
336
+ }
337
+ const deps = getDeps();
338
+ const unresolved = deps.filter(d => !d.resolved);
339
+ if (unresolved.length > 0) {
340
+ return { suggestion: 'unblock', message: `No tasks available, but ${unresolved.length} task(s) are blocked by dependencies. Check if you can help resolve them.` };
341
+ }
342
+ return { suggestion: 'none', message: 'No pending tasks, reviews, or blocked items. Ask the team what needs doing next.' };
343
+ }
344
+
345
+ const myActiveTasks = tasks.filter(t => t.assignee === state.registeredName && t.status === 'in_progress');
346
+ if (myActiveTasks.length >= 3) {
347
+ return { suggestion: 'finish_first', your_active_tasks: myActiveTasks.map(t => ({ id: t.id, title: t.title })), message: `You already have ${myActiveTasks.length} tasks in progress. Finish one before taking more.` };
348
+ }
349
+
350
+ if (myRep && myRep.strengths.includes('reviewer')) {
351
+ const reviews = getReviews().filter(r => r.status === 'pending' && r.requested_by !== state.registeredName);
352
+ if (reviews.length > 0) return { suggestion: 'review', review_id: reviews[0].id, file: reviews[0].file, message: `Based on your strengths (reviewer), review "${reviews[0].file}".` };
353
+ }
354
+
355
+ const myDoneTasks = tasks.filter(t => t.assignee === state.registeredName && t.status === 'done');
356
+ const myKeywords = new Set();
357
+ for (const t of myDoneTasks) {
358
+ const words = (t.title + ' ' + (t.description || '')).toLowerCase().split(/\W+/).filter(w => w.length > 3);
359
+ words.forEach(w => myKeywords.add(w));
360
+ }
361
+
362
+ let suggested = pendingTasks[0] || unassignedTasks[0];
363
+ if (myKeywords.size > 0 && pendingTasks.length > 1) {
364
+ let bestScore = 0;
365
+ for (const task of pendingTasks) {
366
+ const taskWords = (task.title + ' ' + (task.description || '')).toLowerCase().split(/\W+/).filter(w => w.length > 3);
367
+ const score = taskWords.filter(w => myKeywords.has(w)).length;
368
+ if (score > bestScore) { bestScore = score; suggested = task; }
369
+ }
370
+ }
371
+
372
+ const blockedTasks = tasks.filter(t => t.status === 'blocked');
373
+ if (blockedTasks.length > 0 && pendingTasks.length === 0) {
374
+ return { suggestion: 'unblock_task', task: { id: blockedTasks[0].id, title: blockedTasks[0].title }, message: `No pending tasks, but "${blockedTasks[0].title}" is blocked. Can you help unblock it?` };
375
+ }
376
+
377
+ return {
378
+ suggestion: 'task',
379
+ task_id: suggested.id,
380
+ title: suggested.title,
381
+ description: suggested.description,
382
+ message: `Suggested: "${suggested.title}". Call update_task("${suggested.id}", "in_progress") to claim it.`,
383
+ ...(myKeywords.size > 0 && { match_reason: 'Based on your completed task history' }),
384
+ };
385
+ }
386
+
387
+ // --- MCP tool definitions ---
388
+
389
+ const definitions = [
390
+ {
391
+ name: 'create_task',
392
+ description: 'Create a task and optionally assign it to another agent. Use for structured work delegation in multi-agent teams.',
393
+ inputSchema: {
394
+ type: 'object',
395
+ properties: {
396
+ title: { type: 'string', description: 'Short task title' },
397
+ description: { type: 'string', description: 'Detailed task description' },
398
+ assignee: { type: 'string', description: 'Agent to assign to (optional, auto-assigns with 2 agents)' },
399
+ },
400
+ required: ['title'],
401
+ additionalProperties: false,
402
+ },
403
+ },
404
+ {
405
+ name: 'update_task',
406
+ description: 'Update a task status. Statuses: pending, in_progress, in_review, done, blocked.',
407
+ inputSchema: {
408
+ type: 'object',
409
+ properties: {
410
+ task_id: { type: 'string', description: 'Task ID to update' },
411
+ status: { type: 'string', enum: ['pending', 'in_progress', 'in_review', 'done', 'blocked', 'blocked_permanent'], description: 'New status' },
412
+ notes: { type: 'string', description: 'Optional progress note' },
413
+ },
414
+ required: ['task_id', 'status'],
415
+ additionalProperties: false,
416
+ },
417
+ },
418
+ {
419
+ name: 'list_tasks',
420
+ description: 'List all tasks, optionally filtered by status or assignee.',
421
+ inputSchema: {
422
+ type: 'object',
423
+ properties: {
424
+ status: { type: 'string', enum: ['pending', 'in_progress', 'in_review', 'done', 'blocked', 'blocked_permanent'], description: 'Filter by status' },
425
+ assignee: { type: 'string', description: 'Filter by assignee agent name' },
426
+ },
427
+ additionalProperties: false,
428
+ },
429
+ },
430
+ {
431
+ name: 'suggest_task',
432
+ description: 'Get a task suggestion based on your strengths, pending tasks, open reviews, and blocked dependencies. Helps you find the most useful thing to do next.',
433
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
434
+ },
435
+ ];
436
+
437
+ // Handler dispatch map
438
+ const handlers = {
439
+ create_task: function (args) { return toolCreateTask(args.title, args.description, args.assignee); },
440
+ update_task: function (args) { return toolUpdateTask(args.task_id, args.status, args.notes); },
441
+ list_tasks: function (args) { return toolListTasks(args.status, args.assignee); },
442
+ suggest_task: function () { return toolSuggestTask(); },
443
+ };
444
+
445
+ return { definitions, handlers };
446
+ };