@cleocode/core 2026.3.72 → 2026.3.74
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/dist/cleo.d.ts.map +1 -1
- package/dist/hooks/handlers/agent-hooks.d.ts +48 -0
- package/dist/hooks/handlers/agent-hooks.d.ts.map +1 -0
- package/dist/hooks/handlers/context-hooks.d.ts +53 -0
- package/dist/hooks/handlers/context-hooks.d.ts.map +1 -0
- package/dist/hooks/handlers/error-hooks.d.ts +4 -4
- package/dist/hooks/handlers/error-hooks.d.ts.map +1 -1
- package/dist/hooks/handlers/file-hooks.d.ts +3 -3
- package/dist/hooks/handlers/file-hooks.d.ts.map +1 -1
- package/dist/hooks/handlers/index.d.ts +8 -1
- package/dist/hooks/handlers/index.d.ts.map +1 -1
- package/dist/hooks/handlers/mcp-hooks.d.ts +29 -7
- package/dist/hooks/handlers/mcp-hooks.d.ts.map +1 -1
- package/dist/hooks/handlers/session-hooks.d.ts +5 -5
- package/dist/hooks/handlers/session-hooks.d.ts.map +1 -1
- package/dist/hooks/handlers/task-hooks.d.ts +5 -5
- package/dist/hooks/handlers/task-hooks.d.ts.map +1 -1
- package/dist/hooks/handlers/work-capture-hooks.d.ts +7 -7
- package/dist/hooks/handlers/work-capture-hooks.d.ts.map +1 -1
- package/dist/hooks/payload-schemas.d.ts +177 -11
- package/dist/hooks/payload-schemas.d.ts.map +1 -1
- package/dist/hooks/provider-hooks.d.ts +33 -7
- package/dist/hooks/provider-hooks.d.ts.map +1 -1
- package/dist/hooks/registry.d.ts +26 -6
- package/dist/hooks/registry.d.ts.map +1 -1
- package/dist/hooks/types.d.ts +132 -38
- package/dist/hooks/types.d.ts.map +1 -1
- package/dist/index.js +818 -233
- package/dist/index.js.map +4 -4
- package/dist/nexus/index.d.ts +2 -0
- package/dist/nexus/index.d.ts.map +1 -1
- package/dist/nexus/workspace.d.ts +128 -0
- package/dist/nexus/workspace.d.ts.map +1 -0
- package/dist/sessions/snapshot.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/cleo.ts +14 -0
- package/src/hooks/handlers/__tests__/hook-automation-e2e.test.ts +634 -0
- package/src/hooks/handlers/agent-hooks.ts +148 -0
- package/src/hooks/handlers/context-hooks.ts +156 -0
- package/src/hooks/handlers/error-hooks.ts +8 -5
- package/src/hooks/handlers/file-hooks.ts +6 -4
- package/src/hooks/handlers/index.ts +12 -1
- package/src/hooks/handlers/mcp-hooks.ts +74 -9
- package/src/hooks/handlers/session-hooks.ts +7 -7
- package/src/hooks/handlers/task-hooks.ts +7 -7
- package/src/hooks/handlers/work-capture-hooks.ts +12 -12
- package/src/hooks/payload-schemas.ts +96 -26
- package/src/hooks/provider-hooks.ts +50 -9
- package/src/hooks/registry.ts +86 -23
- package/src/hooks/types.ts +175 -39
- package/src/nexus/index.ts +15 -0
- package/src/nexus/workspace.ts +508 -0
- package/src/sessions/index.ts +4 -4
- package/src/sessions/snapshot.ts +4 -2
- package/src/store/json.ts +2 -2
- package/src/task-work/index.ts +4 -4
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NEXUS Workspace — Cross-project orchestration operations.
|
|
3
|
+
*
|
|
4
|
+
* Implements ORCH-PLAN Phase B:
|
|
5
|
+
* - B.2: nexus.route(directiveEvent) — dispatch Conduit directives to the correct project
|
|
6
|
+
* - B.3: nexus.workspace.status() — aggregated cross-project task view
|
|
7
|
+
* - B.4: nexus.workspace.agents() — cross-project agent registry view
|
|
8
|
+
*
|
|
9
|
+
* Security: project-level ACL enforced on all routing operations (HIGH-02).
|
|
10
|
+
* Rate limiting: per-agent throttling on route operations (MEDIUM-05).
|
|
11
|
+
* Audit: all routing operations logged (LOW-06).
|
|
12
|
+
*
|
|
13
|
+
* @module nexus/workspace
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ConduitMessage } from '@cleocode/contracts';
|
|
17
|
+
import { ExitCode } from '@cleocode/contracts';
|
|
18
|
+
import { CleoError } from '../errors.js';
|
|
19
|
+
import type { DataAccessor } from '../store/data-accessor.js';
|
|
20
|
+
import { getAccessor } from '../store/data-accessor.js';
|
|
21
|
+
import { type NexusProject, nexusList } from './registry.js';
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Types
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
/** Parsed directive from a Conduit message. */
|
|
28
|
+
export interface ParsedDirective {
|
|
29
|
+
/** The directive verb (claim, done, blocked, action, etc.). */
|
|
30
|
+
verb: string;
|
|
31
|
+
/** Task references extracted from the message (e.g., T042, T1234). */
|
|
32
|
+
taskRefs: string[];
|
|
33
|
+
/** The agent ID that sent the directive. */
|
|
34
|
+
agentId: string;
|
|
35
|
+
/** Original message ID for audit trail. */
|
|
36
|
+
messageId: string;
|
|
37
|
+
/** Timestamp of the directive. */
|
|
38
|
+
timestamp: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Result of routing a directive to a project. */
|
|
42
|
+
export interface RouteResult {
|
|
43
|
+
/** Whether the routing succeeded. */
|
|
44
|
+
success: boolean;
|
|
45
|
+
/** The project that was routed to. */
|
|
46
|
+
project: string;
|
|
47
|
+
/** The project's filesystem path. */
|
|
48
|
+
projectPath: string;
|
|
49
|
+
/** The task that was affected. */
|
|
50
|
+
taskId: string;
|
|
51
|
+
/** What operation was performed. */
|
|
52
|
+
operation: string;
|
|
53
|
+
/** Error message if routing failed. */
|
|
54
|
+
error?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Aggregated task status across all projects. */
|
|
58
|
+
export interface WorkspaceStatus {
|
|
59
|
+
/** Total projects in the workspace. */
|
|
60
|
+
projectCount: number;
|
|
61
|
+
/** Per-project task summaries. */
|
|
62
|
+
projects: WorkspaceProjectSummary[];
|
|
63
|
+
/** Aggregated totals. */
|
|
64
|
+
totals: {
|
|
65
|
+
pending: number;
|
|
66
|
+
active: number;
|
|
67
|
+
done: number;
|
|
68
|
+
total: number;
|
|
69
|
+
};
|
|
70
|
+
/** When the status was computed. */
|
|
71
|
+
computedAt: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Task summary for a single project. */
|
|
75
|
+
export interface WorkspaceProjectSummary {
|
|
76
|
+
/** Project name. */
|
|
77
|
+
name: string;
|
|
78
|
+
/** Project path. */
|
|
79
|
+
path: string;
|
|
80
|
+
/** Task counts by status. */
|
|
81
|
+
counts: {
|
|
82
|
+
pending: number;
|
|
83
|
+
active: number;
|
|
84
|
+
done: number;
|
|
85
|
+
total: number;
|
|
86
|
+
};
|
|
87
|
+
/** Health status from the Nexus registry. */
|
|
88
|
+
health: string;
|
|
89
|
+
/** Last sync time. */
|
|
90
|
+
lastSync: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Agent info aggregated across projects. */
|
|
94
|
+
export interface WorkspaceAgent {
|
|
95
|
+
/** Agent instance ID. */
|
|
96
|
+
agentId: string;
|
|
97
|
+
/** Agent type. */
|
|
98
|
+
agentType: string;
|
|
99
|
+
/** Current status. */
|
|
100
|
+
status: string;
|
|
101
|
+
/** Which project this agent is registered in. */
|
|
102
|
+
project: string;
|
|
103
|
+
/** Current task (if any). */
|
|
104
|
+
taskId: string | null;
|
|
105
|
+
/** Last heartbeat. */
|
|
106
|
+
lastHeartbeat: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Project-level ACL entry. */
|
|
110
|
+
export interface ProjectACL {
|
|
111
|
+
/** Agent IDs with write access. */
|
|
112
|
+
authorizedAgents: string[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ============================================================================
|
|
116
|
+
// Rate limiting (MEDIUM-05)
|
|
117
|
+
// ============================================================================
|
|
118
|
+
|
|
119
|
+
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
120
|
+
const RATE_LIMIT_MAX_OPS = 100;
|
|
121
|
+
|
|
122
|
+
/** Per-agent operation counters for rate limiting. */
|
|
123
|
+
const rateLimitCounters = new Map<string, { count: number; windowStart: number }>();
|
|
124
|
+
|
|
125
|
+
/** Check if an agent is rate-limited. Throws if exceeded. */
|
|
126
|
+
function checkRateLimit(agentId: string): void {
|
|
127
|
+
const now = Date.now();
|
|
128
|
+
const entry = rateLimitCounters.get(agentId);
|
|
129
|
+
|
|
130
|
+
if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
|
|
131
|
+
rateLimitCounters.set(agentId, { count: 1, windowStart: now });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
entry.count++;
|
|
136
|
+
if (entry.count > RATE_LIMIT_MAX_OPS) {
|
|
137
|
+
throw new CleoError(
|
|
138
|
+
ExitCode.GENERAL_ERROR,
|
|
139
|
+
`Agent '${agentId}' exceeded rate limit: ${RATE_LIMIT_MAX_OPS} routing ops per ${RATE_LIMIT_WINDOW_MS / 1000}s`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ============================================================================
|
|
145
|
+
// ACL (HIGH-02)
|
|
146
|
+
// ============================================================================
|
|
147
|
+
|
|
148
|
+
/** Default ACL: all agents have access (open by default, restrict as needed). */
|
|
149
|
+
const DEFAULT_ACL: ProjectACL = { authorizedAgents: ['*'] };
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Load ACL for a project. Reads from .cleo/config.json authorizedAgents field.
|
|
153
|
+
* Falls back to open access if not configured.
|
|
154
|
+
*/
|
|
155
|
+
async function loadProjectACL(projectPath: string): Promise<ProjectACL> {
|
|
156
|
+
try {
|
|
157
|
+
const { loadConfig } = await import('../config.js');
|
|
158
|
+
const config = await loadConfig(projectPath);
|
|
159
|
+
const agents = (config as unknown as Record<string, unknown>)?.authorizedAgents;
|
|
160
|
+
if (Array.isArray(agents) && agents.length > 0) {
|
|
161
|
+
return { authorizedAgents: agents as string[] };
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
// Config load failure = open access
|
|
165
|
+
}
|
|
166
|
+
return DEFAULT_ACL;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Check if an agent is authorized to mutate a project. */
|
|
170
|
+
function isAuthorized(acl: ProjectACL, agentId: string): boolean {
|
|
171
|
+
if (acl.authorizedAgents.includes('*')) return true;
|
|
172
|
+
return acl.authorizedAgents.includes(agentId);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ============================================================================
|
|
176
|
+
// B.2: nexus.route() — Directive routing
|
|
177
|
+
// ============================================================================
|
|
178
|
+
|
|
179
|
+
/** Task reference pattern: T followed by digits (T001, T42, T1234). */
|
|
180
|
+
const TASK_REF_PATTERN = /\bT(\d+)\b/g;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Parse a Conduit message into a structured directive.
|
|
184
|
+
*
|
|
185
|
+
* Extracts directive verbs (/claim, /done, /blocked) and task references
|
|
186
|
+
* from the message content and metadata.
|
|
187
|
+
*/
|
|
188
|
+
export function parseDirective(message: ConduitMessage): ParsedDirective | null {
|
|
189
|
+
const content = message.content;
|
|
190
|
+
|
|
191
|
+
// Extract directive verb (first /word in content)
|
|
192
|
+
const verbMatch = content.match(/^\/(\w+)/);
|
|
193
|
+
if (!verbMatch) return null;
|
|
194
|
+
|
|
195
|
+
const verb = verbMatch[1];
|
|
196
|
+
|
|
197
|
+
// Extract task refs from content
|
|
198
|
+
const taskRefs: string[] = [];
|
|
199
|
+
const pattern = new RegExp(TASK_REF_PATTERN.source, 'g');
|
|
200
|
+
for (const m of content.matchAll(pattern)) {
|
|
201
|
+
taskRefs.push(`T${m[1]}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Also check metadata.taskRefs from SignalDock extraction (Phase A.5)
|
|
205
|
+
const metaRefs = (message.metadata as Record<string, unknown>)?.taskRefs;
|
|
206
|
+
if (Array.isArray(metaRefs)) {
|
|
207
|
+
for (const ref of metaRefs) {
|
|
208
|
+
if (typeof ref === 'string' && !taskRefs.includes(ref)) {
|
|
209
|
+
taskRefs.push(ref);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (taskRefs.length === 0) return null;
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
verb,
|
|
218
|
+
taskRefs,
|
|
219
|
+
agentId: message.from,
|
|
220
|
+
messageId: message.id,
|
|
221
|
+
timestamp: message.timestamp,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Map directive verbs to CLEO task operations. */
|
|
226
|
+
const VERB_TO_OPERATION: Record<string, string> = {
|
|
227
|
+
claim: 'tasks.start',
|
|
228
|
+
done: 'tasks.complete',
|
|
229
|
+
complete: 'tasks.complete',
|
|
230
|
+
blocked: 'tasks.update', // Update status to blocked
|
|
231
|
+
start: 'tasks.start',
|
|
232
|
+
stop: 'tasks.stop',
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Route a Conduit directive to the correct project's CLEO instance.
|
|
237
|
+
*
|
|
238
|
+
* Resolves which project owns the referenced task, checks ACL,
|
|
239
|
+
* and dispatches the appropriate CLEO operation.
|
|
240
|
+
*
|
|
241
|
+
* @param directive - Parsed directive from a Conduit message
|
|
242
|
+
* @returns Array of route results (one per task reference)
|
|
243
|
+
*/
|
|
244
|
+
export async function routeDirective(directive: ParsedDirective): Promise<RouteResult[]> {
|
|
245
|
+
// Rate limit check (MEDIUM-05)
|
|
246
|
+
checkRateLimit(directive.agentId);
|
|
247
|
+
|
|
248
|
+
const results: RouteResult[] = [];
|
|
249
|
+
const operation = VERB_TO_OPERATION[directive.verb];
|
|
250
|
+
|
|
251
|
+
if (!operation) {
|
|
252
|
+
// Unknown verb — not routable, just skip
|
|
253
|
+
return results;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const projects = await nexusList();
|
|
257
|
+
|
|
258
|
+
for (const taskRef of directive.taskRefs) {
|
|
259
|
+
const result = await routeSingleTask(taskRef, directive, operation, projects);
|
|
260
|
+
results.push(result);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return results;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Route a single task reference to its project and execute the operation. */
|
|
267
|
+
async function routeSingleTask(
|
|
268
|
+
taskId: string,
|
|
269
|
+
directive: ParsedDirective,
|
|
270
|
+
operation: string,
|
|
271
|
+
projects: NexusProject[],
|
|
272
|
+
): Promise<RouteResult> {
|
|
273
|
+
// Find which project owns this task
|
|
274
|
+
let targetProject: NexusProject | null = null;
|
|
275
|
+
let targetAccessor: DataAccessor | null = null;
|
|
276
|
+
|
|
277
|
+
for (const project of projects) {
|
|
278
|
+
try {
|
|
279
|
+
const acc = await getAccessor(project.path);
|
|
280
|
+
const { tasks } = await acc.queryTasks({});
|
|
281
|
+
const task = tasks.find((t) => t.id === taskId);
|
|
282
|
+
if (task) {
|
|
283
|
+
targetProject = project;
|
|
284
|
+
targetAccessor = acc;
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
} catch {}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (!targetProject || !targetAccessor) {
|
|
291
|
+
return {
|
|
292
|
+
success: false,
|
|
293
|
+
project: 'unknown',
|
|
294
|
+
projectPath: '',
|
|
295
|
+
taskId,
|
|
296
|
+
operation,
|
|
297
|
+
error: `Task ${taskId} not found in any registered project`,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ACL check (HIGH-02)
|
|
302
|
+
const acl = await loadProjectACL(targetProject.path);
|
|
303
|
+
if (!isAuthorized(acl, directive.agentId)) {
|
|
304
|
+
return {
|
|
305
|
+
success: false,
|
|
306
|
+
project: targetProject.name,
|
|
307
|
+
projectPath: targetProject.path,
|
|
308
|
+
taskId,
|
|
309
|
+
operation,
|
|
310
|
+
error: `Agent '${directive.agentId}' not authorized to mutate project '${targetProject.name}'`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Execute the operation
|
|
315
|
+
try {
|
|
316
|
+
await executeOperation(operation, taskId, targetProject.path, targetAccessor, directive);
|
|
317
|
+
|
|
318
|
+
// Audit log (LOW-06)
|
|
319
|
+
await logRouteAudit(directive, targetProject.name, taskId, operation, true);
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
success: true,
|
|
323
|
+
project: targetProject.name,
|
|
324
|
+
projectPath: targetProject.path,
|
|
325
|
+
taskId,
|
|
326
|
+
operation,
|
|
327
|
+
};
|
|
328
|
+
} catch (err) {
|
|
329
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
330
|
+
|
|
331
|
+
// Audit log for failures too
|
|
332
|
+
await logRouteAudit(directive, targetProject.name, taskId, operation, false, errorMsg);
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
success: false,
|
|
336
|
+
project: targetProject.name,
|
|
337
|
+
projectPath: targetProject.path,
|
|
338
|
+
taskId,
|
|
339
|
+
operation,
|
|
340
|
+
error: errorMsg,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Execute a CLEO operation on a project's task. */
|
|
346
|
+
async function executeOperation(
|
|
347
|
+
operation: string,
|
|
348
|
+
taskId: string,
|
|
349
|
+
projectPath: string,
|
|
350
|
+
accessor: DataAccessor,
|
|
351
|
+
directive: ParsedDirective,
|
|
352
|
+
): Promise<void> {
|
|
353
|
+
switch (operation) {
|
|
354
|
+
case 'tasks.start': {
|
|
355
|
+
const { startTask } = await import('../task-work/index.js');
|
|
356
|
+
await startTask(taskId, projectPath, accessor);
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
case 'tasks.complete': {
|
|
360
|
+
const { completeTask } = await import('../tasks/complete.js');
|
|
361
|
+
await completeTask(
|
|
362
|
+
{ taskId, notes: `Completed via Conduit directive from ${directive.agentId}` },
|
|
363
|
+
projectPath,
|
|
364
|
+
accessor,
|
|
365
|
+
);
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
case 'tasks.stop': {
|
|
369
|
+
const { stopTask } = await import('../task-work/index.js');
|
|
370
|
+
await stopTask(projectPath, accessor);
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
case 'tasks.update': {
|
|
374
|
+
const { updateTask } = await import('../tasks/update.js');
|
|
375
|
+
await updateTask(
|
|
376
|
+
{ taskId, notes: `Marked blocked via Conduit directive from ${directive.agentId}` },
|
|
377
|
+
projectPath,
|
|
378
|
+
accessor,
|
|
379
|
+
);
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Log a routing operation to the audit trail. */
|
|
386
|
+
async function logRouteAudit(
|
|
387
|
+
directive: ParsedDirective,
|
|
388
|
+
projectName: string,
|
|
389
|
+
taskId: string,
|
|
390
|
+
operation: string,
|
|
391
|
+
success: boolean,
|
|
392
|
+
error?: string,
|
|
393
|
+
): Promise<void> {
|
|
394
|
+
try {
|
|
395
|
+
const { getLogger } = await import('../logger.js');
|
|
396
|
+
const log = getLogger('nexus.route');
|
|
397
|
+
const level = success ? 'info' : 'warn';
|
|
398
|
+
log[level](
|
|
399
|
+
{
|
|
400
|
+
directive: directive.verb,
|
|
401
|
+
agentId: directive.agentId,
|
|
402
|
+
messageId: directive.messageId,
|
|
403
|
+
project: projectName,
|
|
404
|
+
taskId,
|
|
405
|
+
operation,
|
|
406
|
+
success,
|
|
407
|
+
error,
|
|
408
|
+
},
|
|
409
|
+
`Conduit directive routed: ${directive.verb} ${taskId} → ${projectName} (${success ? 'OK' : 'FAILED'})`,
|
|
410
|
+
);
|
|
411
|
+
} catch {
|
|
412
|
+
// Audit logging is best-effort
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ============================================================================
|
|
417
|
+
// B.3: nexus.workspace.status() — Aggregated view
|
|
418
|
+
// ============================================================================
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Get aggregated task status across all registered projects.
|
|
422
|
+
*
|
|
423
|
+
* Returns per-project task counts and workspace-wide totals.
|
|
424
|
+
* Respects project permissions — only includes readable projects.
|
|
425
|
+
*/
|
|
426
|
+
export async function workspaceStatus(): Promise<WorkspaceStatus> {
|
|
427
|
+
const projects = await nexusList();
|
|
428
|
+
const summaries: WorkspaceProjectSummary[] = [];
|
|
429
|
+
const totals = { pending: 0, active: 0, done: 0, total: 0 };
|
|
430
|
+
|
|
431
|
+
for (const project of projects) {
|
|
432
|
+
try {
|
|
433
|
+
const acc = await getAccessor(project.path);
|
|
434
|
+
const { tasks } = await acc.queryTasks({});
|
|
435
|
+
|
|
436
|
+
const counts = {
|
|
437
|
+
pending: tasks.filter((t) => t.status === 'pending').length,
|
|
438
|
+
active: tasks.filter((t) => t.status === 'active').length,
|
|
439
|
+
done: tasks.filter((t) => t.status === 'done').length,
|
|
440
|
+
total: tasks.length,
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
summaries.push({
|
|
444
|
+
name: project.name,
|
|
445
|
+
path: project.path,
|
|
446
|
+
counts,
|
|
447
|
+
health: project.healthStatus,
|
|
448
|
+
lastSync: project.lastSync,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
totals.pending += counts.pending;
|
|
452
|
+
totals.active += counts.active;
|
|
453
|
+
totals.done += counts.done;
|
|
454
|
+
totals.total += counts.total;
|
|
455
|
+
} catch {
|
|
456
|
+
// Skip unreachable projects
|
|
457
|
+
summaries.push({
|
|
458
|
+
name: project.name,
|
|
459
|
+
path: project.path,
|
|
460
|
+
counts: { pending: 0, active: 0, done: 0, total: 0 },
|
|
461
|
+
health: 'unreachable',
|
|
462
|
+
lastSync: project.lastSync,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
projectCount: projects.length,
|
|
469
|
+
projects: summaries,
|
|
470
|
+
totals,
|
|
471
|
+
computedAt: new Date().toISOString(),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ============================================================================
|
|
476
|
+
// B.4: nexus.workspace.agents() — Cross-project agent view
|
|
477
|
+
// ============================================================================
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Get all agents registered across all projects.
|
|
481
|
+
*
|
|
482
|
+
* Queries each project's agent_instances table and returns a unified list.
|
|
483
|
+
*/
|
|
484
|
+
export async function workspaceAgents(): Promise<WorkspaceAgent[]> {
|
|
485
|
+
const projects = await nexusList();
|
|
486
|
+
const agents: WorkspaceAgent[] = [];
|
|
487
|
+
|
|
488
|
+
for (const project of projects) {
|
|
489
|
+
try {
|
|
490
|
+
const { listAgentInstances } = await import('../agents/registry.js');
|
|
491
|
+
const instances = await listAgentInstances(undefined, project.path);
|
|
492
|
+
for (const inst of instances) {
|
|
493
|
+
agents.push({
|
|
494
|
+
agentId: inst.id,
|
|
495
|
+
agentType: inst.agentType,
|
|
496
|
+
status: inst.status,
|
|
497
|
+
project: project.name,
|
|
498
|
+
taskId: inst.taskId ?? null,
|
|
499
|
+
lastHeartbeat: inst.lastHeartbeat,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
} catch {
|
|
503
|
+
// Skip projects without agent support
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return agents;
|
|
508
|
+
}
|
package/src/sessions/index.ts
CHANGED
|
@@ -170,10 +170,10 @@ export async function startSession(
|
|
|
170
170
|
});
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
// Dispatch
|
|
173
|
+
// Dispatch SessionStart hook (best-effort, don't await)
|
|
174
174
|
const { hooks } = await import('../hooks/registry.js');
|
|
175
175
|
hooks
|
|
176
|
-
.dispatch('
|
|
176
|
+
.dispatch('SessionStart', cwd ?? process.cwd(), {
|
|
177
177
|
timestamp: new Date().toISOString(),
|
|
178
178
|
sessionId: session.id,
|
|
179
179
|
name: options.name,
|
|
@@ -233,10 +233,10 @@ export async function endSession(
|
|
|
233
233
|
|
|
234
234
|
const duration = Math.floor((Date.now() - new Date(session.startedAt).getTime()) / 1000);
|
|
235
235
|
|
|
236
|
-
// Dispatch
|
|
236
|
+
// Dispatch SessionEnd hook (best-effort, don't await)
|
|
237
237
|
const { hooks } = await import('../hooks/registry.js');
|
|
238
238
|
hooks
|
|
239
|
-
.dispatch('
|
|
239
|
+
.dispatch('SessionEnd', cwd ?? process.cwd(), {
|
|
240
240
|
timestamp: new Date().toISOString(),
|
|
241
241
|
sessionId: session.id,
|
|
242
242
|
duration,
|
package/src/sessions/snapshot.ts
CHANGED
|
@@ -209,7 +209,9 @@ export async function serializeSession(
|
|
|
209
209
|
status: task.status,
|
|
210
210
|
priority: task.priority ?? 'medium',
|
|
211
211
|
description: desc.length > maxDescLen ? desc.slice(0, maxDescLen) + '...' : desc,
|
|
212
|
-
acceptance: Array.isArray(task.acceptance)
|
|
212
|
+
acceptance: Array.isArray(task.acceptance)
|
|
213
|
+
? task.acceptance.join('\n')
|
|
214
|
+
: (task.acceptance ?? undefined),
|
|
213
215
|
};
|
|
214
216
|
}
|
|
215
217
|
} catch {
|
|
@@ -324,7 +326,7 @@ export async function restoreSession(
|
|
|
324
326
|
// Dispatch hook (best-effort)
|
|
325
327
|
try {
|
|
326
328
|
const { hooks } = await import('../hooks/registry.js');
|
|
327
|
-
await hooks.dispatch('
|
|
329
|
+
await hooks.dispatch('SessionStart', projectRoot, {
|
|
328
330
|
timestamp: new Date().toISOString(),
|
|
329
331
|
sessionId: restoredSession.id,
|
|
330
332
|
name: restoredSession.name,
|
package/src/store/json.ts
CHANGED
|
@@ -100,10 +100,10 @@ export async function saveJson(
|
|
|
100
100
|
// Atomic write
|
|
101
101
|
await atomicWriteJson(filePath, data, { indent: options?.indent });
|
|
102
102
|
|
|
103
|
-
// Dispatch
|
|
103
|
+
// Dispatch Notification hook (best-effort, fire-and-forget)
|
|
104
104
|
import('../hooks/registry.js')
|
|
105
105
|
.then(({ hooks: h }) =>
|
|
106
|
-
h.dispatch('
|
|
106
|
+
h.dispatch('Notification', process.cwd(), {
|
|
107
107
|
timestamp: new Date().toISOString(),
|
|
108
108
|
filePath,
|
|
109
109
|
changeType: 'write' as const,
|
package/src/task-work/index.ts
CHANGED
|
@@ -123,10 +123,10 @@ export async function startTask(
|
|
|
123
123
|
accessor,
|
|
124
124
|
);
|
|
125
125
|
|
|
126
|
-
// Dispatch
|
|
126
|
+
// Dispatch PreToolUse hook (best-effort, don't await)
|
|
127
127
|
const { hooks } = await import('../hooks/registry.js');
|
|
128
128
|
hooks
|
|
129
|
-
.dispatch('
|
|
129
|
+
.dispatch('PreToolUse', cwd ?? process.cwd(), {
|
|
130
130
|
timestamp: new Date().toISOString(),
|
|
131
131
|
taskId,
|
|
132
132
|
taskTitle: task.title,
|
|
@@ -169,11 +169,11 @@ export async function stopTask(
|
|
|
169
169
|
|
|
170
170
|
const now = new Date().toISOString();
|
|
171
171
|
|
|
172
|
-
// Dispatch
|
|
172
|
+
// Dispatch PostToolUse hook (best-effort, don't await)
|
|
173
173
|
if (taskId && task) {
|
|
174
174
|
const { hooks } = await import('../hooks/registry.js');
|
|
175
175
|
hooks
|
|
176
|
-
.dispatch('
|
|
176
|
+
.dispatch('PostToolUse', cwd ?? process.cwd(), {
|
|
177
177
|
timestamp: now,
|
|
178
178
|
taskId,
|
|
179
179
|
taskTitle: task.title,
|