1/**
2 * Delegate Core
3 *
4 * Matches stuck work to available humans. No LLM calls.
5 * Pure data analysis: evolution activity, competence maps,
6 * contributor lists, inverse-tree profiles.
7 */
8
9import log from "../../seed/log.js";
10import Node from "../../seed/models/node.js";
11import User from "../../seed/models/user.js";
12import { getDescendantIds } from "../../seed/tree/treeFetch.js";
13import { v4 as uuidv4 } from "uuid";
14
15let _metadata = null;
16export function configure({ metadata }) { _metadata = metadata; }
17
18const DEFAULTS = {
19 stalledDays: 14, // days of inactivity before a node is considered stalled
20 maxSuggestionsPerCycle: 10, // cap per tree per cycle
21 maxSuggestionsPerNode: 3, // cap per node
22 suggestionTTLDays: 30, // auto-expire old suggestions
23};
24
25function getConfig(rootMeta) {
26 const cfg = rootMeta?.delegate || {};
27 return { ...DEFAULTS, ...cfg };
28}
29
30// ─────────────────────────────────────────────────────────────────────────
31// STALLED NODE DETECTION
32// ─────────────────────────────────────────────────────────────────────────
33
34/**
35 * Find nodes in the tree that are stalled: had activity before but none recently.
36 */
37async function findStalledNodes(rootId, stalledDays) {
38 const descendantIds = await getDescendantIds(rootId, { maxResults: 5000 });
39 const allIds = [rootId, ...descendantIds];
40
41 const cutoff = new Date(Date.now() - stalledDays * 86400000);
42 const stalledNodes = [];
43
44 // Batch load nodes with evolution metadata
45 const nodes = await Node.find({
46 _id: { $in: allIds },
47 status: { $nin: ["trimmed", "completed"] },
48 }).select("_id name type parent children metadata").lean();
49
50 for (const node of nodes) {
51 if (!node.children || node.children.length === 0) continue; // skip leaves, focus on branches
52 const meta = node.metadata instanceof Map
53 ? Object.fromEntries(node.metadata)
54 : (node.metadata || {});
55
56 const evo = meta.evolution;
57 if (!evo?.lastActivity) continue; // never had activity, not stalled
58
59 const lastActivity = new Date(evo.lastActivity);
60 if (lastActivity >= cutoff) continue; // still active
61
62 // This node had activity but went silent
63 stalledNodes.push({
64 nodeId: String(node._id),
65 nodeName: node.name,
66 type: node.type,
67 lastActivity: evo.lastActivity,
68 daysSilent: Math.round((Date.now() - lastActivity.getTime()) / 86400000),
69 notesWritten: evo.notesWritten || 0,
70 childCount: node.children.length,
71 });
72 }
73
74 return stalledNodes.sort((a, b) => b.daysSilent - a.daysSilent);
75}
76
77// ─────────────────────────────────────────────────────────────────────────
78// CONTRIBUTOR SCORING
79// ─────────────────────────────────────────────────────────────────────────
80
81/**
82 * Score contributors for a stalled node based on available signals.
83 * Returns sorted array of { userId, username, score, reasons }.
84 */
85async function scoreContributors(stalledNode, rootId, contributors) {
86 if (!contributors || contributors.length === 0) return [];
87
88 const scored = [];
89
90 for (const userId of contributors) {
91 const user = await User.findById(userId).select("_id username metadata").lean();
92 if (!user) continue;
93
94 let score = 0;
95 const reasons = [];
96 const userMeta = user.metadata instanceof Map
97 ? Object.fromEntries(user.metadata)
98 : (user.metadata || {});
99
100 // Signal 1: Evolution activity. Is this user active in the tree recently?
101 try {
102 const { getExtension } = await import("../loader.js");
103 const evoExt = getExtension("evolution");
104 if (evoExt?.exports?.calculateFitness) {
105 // Check if user has recent activity near the stalled node
106 const parentNode = await Node.findById(stalledNode.nodeId).select("parent").lean();
107 if (parentNode?.parent) {
108 const siblings = await Node.find({ parent: parentNode.parent })
109 .select("_id metadata").lean();
110 for (const sib of siblings) {
111 const sibMeta = sib.metadata instanceof Map
112 ? sib.metadata.get("evolution")
113 : sib.metadata?.evolution;
114 if (sibMeta?.lastActivity) {
115 const age = Date.now() - new Date(sibMeta.lastActivity).getTime();
116 if (age < 7 * 86400000) { // active in last week on a sibling
117 score += 0.3;
118 reasons.push("active on sibling branch");
119 break;
120 }
121 }
122 }
123 }
124 }
125 } catch {}
126
127 // Signal 2: Competence. Does this user have competence on related topics?
128 try {
129 const { getExtension } = await import("../loader.js");
130 const compExt = getExtension("competence");
131 if (compExt?.exports?.getCompetence) {
132 const comp = await compExt.exports.getCompetence(stalledNode.nodeId);
133 if (comp?.strongTopics?.length > 0) {
134 // Check if the stalled node's name or type matches strong topics
135 const nodeName = (stalledNode.nodeName || "").toLowerCase();
136 const match = comp.strongTopics.some(t => nodeName.includes(t.toLowerCase()));
137 if (match) {
138 score += 0.25;
139 reasons.push("competence match on node topic");
140 }
141 }
142 }
143 } catch {}
144
145 // Signal 3: Inverse-tree profile. Does this user's profile show interest in this area?
146 try {
147 const { getExtension } = await import("../loader.js");
148 const invExt = getExtension("inverse-tree");
149 if (invExt?.exports?.getInverseData) {
150 const data = await invExt.exports.getInverseData(userId);
151 const profile = data?.profile;
152 if (profile?.topics) {
153 const nodeName = (stalledNode.nodeName || "").toLowerCase();
154 for (const [topic, weight] of Object.entries(profile.topics)) {
155 if (nodeName.includes(topic.toLowerCase())) {
156 score += 0.2 * (weight || 1);
157 reasons.push(`profile interest: ${topic}`);
158 break;
159 }
160 }
161 }
162 }
163 } catch {}
164
165 // Signal 4: Recency. How recently has this user been active anywhere in the tree?
166 try {
167 const nav = userMeta.nav;
168 if (nav?.recentRoots) {
169 const visitedThisTree = nav.recentRoots.find(r => r.rootId === rootId);
170 if (visitedThisTree) {
171 const age = Date.now() - new Date(visitedThisTree.lastVisitedAt).getTime();
172 if (age < 3 * 86400000) {
173 score += 0.15;
174 reasons.push("visited this tree recently");
175 }
176 }
177 }
178 } catch {}
179
180 if (score > 0) {
181 scored.push({
182 userId: String(user._id),
183 username: user.username,
184 score: Math.min(score, 1.0),
185 reasons,
186 });
187 }
188 }
189
190 return scored.sort((a, b) => b.score - a.score);
191}
192
193// ─────────────────────────────────────────────────────────────────────────
194// SUGGESTION GENERATION
195// ─────────────────────────────────────────────────────────────────────────
196
197/**
198 * Generate delegate suggestions for a tree.
199 * Finds stalled nodes, scores contributors, writes suggestions to metadata.
200 */
201export async function generateSuggestions(rootId) {
202 const root = await Node.findById(rootId).select("_id metadata contributors rootOwner").lean();
203 if (!root) return [];
204
205 const rootMeta = root.metadata instanceof Map
206 ? Object.fromEntries(root.metadata)
207 : (root.metadata || {});
208 const config = getConfig(rootMeta);
209
210 // Get all contributors (owner + contributors)
211 const contributors = [
212 ...(root.rootOwner ? [root.rootOwner.toString()] : []),
213 ...(root.contributors || []).map(c => c.toString()),
214 ];
215 const uniqueContributors = [...new Set(contributors)];
216
217 if (uniqueContributors.length < 2) return []; // no one to delegate to
218
219 // Find stalled nodes
220 const stalled = await findStalledNodes(rootId, config.stalledDays);
221 if (stalled.length === 0) return [];
222
223 const suggestions = [];
224 const existingMeta = rootMeta.delegate || {};
225 const existingSuggestions = existingMeta.suggestions || [];
226 const existingNodeIds = new Set(existingSuggestions.map(s => s.nodeId));
227
228 for (const node of stalled.slice(0, config.maxSuggestionsPerCycle)) {
229 // Skip if already has a pending suggestion
230 if (existingNodeIds.has(node.nodeId)) continue;
231
232 // Score contributors for this node
233 const scored = await scoreContributors(node, rootId, uniqueContributors);
234 if (scored.length === 0) continue;
235
236 const best = scored[0];
237
238 suggestions.push({
239 id: uuidv4(),
240 nodeId: node.nodeId,
241 nodeName: node.nodeName,
242 daysSilent: node.daysSilent,
243 suggestedUserId: best.userId,
244 suggestedUsername: best.username,
245 score: best.score,
246 reasons: best.reasons,
247 status: "pending", // pending, accepted, dismissed
248 createdAt: new Date().toISOString(),
249 });
250 }
251
252 if (suggestions.length === 0) return [];
253
254 // Write to root metadata
255 const rootDoc = await Node.findById(rootId);
256 if (rootDoc) {
257 const meta = _metadata.getExtMeta(rootDoc, "delegate") || {};
258 const all = [...(meta.suggestions || []), ...suggestions];
259
260 // Expire old suggestions
261 const ttlCutoff = Date.now() - config.suggestionTTLDays * 86400000;
262 meta.suggestions = all
263 .filter(s => new Date(s.createdAt).getTime() > ttlCutoff)
264 .slice(0, 50); // hard cap
265
266 await _metadata.setExtMeta(rootDoc, "delegate", meta);
267 }
268
269 return suggestions;
270}
271
272// ─────────────────────────────────────────────────────────────────────────
273// SUGGESTION MANAGEMENT
274// ─────────────────────────────────────────────────────────────────────────
275
276/**
277 * Get pending suggestions for a tree.
278 */
279export async function getSuggestions(rootId, userId) {
280 const root = await Node.findById(rootId).select("metadata").lean();
281 if (!root) return [];
282
283 const meta = root.metadata instanceof Map
284 ? root.metadata.get("delegate") || {}
285 : root.metadata?.delegate || {};
286
287 const suggestions = meta.suggestions || [];
288
289 // If userId provided, filter to suggestions for this user
290 if (userId) {
291 return suggestions.filter(s => s.status === "pending" && s.suggestedUserId === userId);
292 }
293 return suggestions.filter(s => s.status === "pending");
294}
295
296/**
297 * Dismiss a suggestion.
298 */
299export async function dismissSuggestion(rootId, suggestionId, userId) {
300 const root = await Node.findById(rootId);
301 if (!root) return null;
302
303 const meta = _metadata.getExtMeta(root, "delegate") || {};
304 const suggestions = meta.suggestions || [];
305 const suggestion = suggestions.find(s => s.id === suggestionId);
306 if (!suggestion) return null;
307
308 suggestion.status = "dismissed";
309 suggestion.dismissedBy = userId;
310 suggestion.dismissedAt = new Date().toISOString();
311
312 await _metadata.setExtMeta(root, "delegate", meta);
313 return suggestion;
314}
315
316/**
317 * Accept a suggestion.
318 */
319export async function acceptSuggestion(rootId, suggestionId, userId) {
320 const root = await Node.findById(rootId);
321 if (!root) return null;
322
323 const meta = _metadata.getExtMeta(root, "delegate") || {};
324 const suggestions = meta.suggestions || [];
325 const suggestion = suggestions.find(s => s.id === suggestionId);
326 if (!suggestion) return null;
327
328 suggestion.status = "accepted";
329 suggestion.acceptedBy = userId;
330 suggestion.acceptedAt = new Date().toISOString();
331
332 await _metadata.setExtMeta(root, "delegate", meta);
333 return suggestion;
334}
335
336/**
337 * Get suggestions relevant to the current position for enrichContext injection.
338 * Returns suggestions where the user is the suggested person AND
339 * the stalled node is nearby (same parent, sibling, or child).
340 */
341export async function getNearbySuggestions(nodeId, userId, rootId) {
342 if (!userId || !rootId) return [];
343
344 const root = await Node.findById(rootId).select("metadata").lean();
345 if (!root) return [];
346
347 const meta = root.metadata instanceof Map
348 ? root.metadata.get("delegate") || {}
349 : root.metadata?.delegate || {};
350
351 const pending = (meta.suggestions || []).filter(
352 s => s.status === "pending" && s.suggestedUserId === userId
353 );
354
355 if (pending.length === 0) return [];
356
357 // Check if any stalled nodes are near the current position
358 const node = await Node.findById(nodeId).select("parent children").lean();
359 if (!node) return [];
360
361 const nearbyIds = new Set([
362 nodeId,
363 ...(node.parent ? [node.parent.toString()] : []),
364 ...(node.children || []).map(c => c.toString()),
365 ]);
366
367 // Also include siblings
368 if (node.parent) {
369 const parent = await Node.findById(node.parent).select("children").lean();
370 if (parent?.children) {
371 for (const c of parent.children) nearbyIds.add(c.toString());
372 }
373 }
374
375 return pending.filter(s => nearbyIds.has(s.nodeId));
376}
377
1import log from "../../seed/log.js";
2import tools from "./tools.js";
3import { configure, generateSuggestions, getNearbySuggestions } from "./core.js";
4
5let _jobTimer = null;
6
7export async function init(core) {
8 configure({ metadata: core.metadata });
9 // enrichContext: surface delegate suggestions when the suggested user is nearby
10 core.hooks.register("enrichContext", async ({ context, node, meta, userId }) => {
11 if (!userId) return;
12 if (node.systemRole) return; // skip system nodes
13
14 // Need a rootId to look up suggestions
15 let rootId = null;
16 if (node.rootOwner) {
17 rootId = String(node._id);
18 } else {
19 try {
20 const { resolveRootNode } = await import("../../seed/tree/treeFetch.js");
21 const root = await resolveRootNode(String(node._id));
22 rootId = root?._id ? String(root._id) : null;
23 } catch { return; }
24 }
25 if (!rootId) return;
26
27 try {
28 const nearby = await getNearbySuggestions(String(node._id), userId, rootId);
29 if (nearby.length === 0) return;
30
31 context.delegateSuggestions = nearby.map(s => ({
32 id: s.id,
33 nodeName: s.nodeName,
34 daysSilent: s.daysSilent,
35 reasons: s.reasons,
36 }));
37 } catch (err) {
38 log.debug("Delegate", `enrichContext failed: ${err.message}`);
39 }
40 }, "delegate");
41
42 const { default: router } = await import("./routes.js");
43
44 log.verbose("Delegate", "Delegate loaded");
45
46 return {
47 router,
48 tools,
49 jobs: [
50 {
51 name: "delegate-cycle",
52 start: () => {
53 const interval = 6 * 60 * 60 * 1000; // every 6 hours
54 _jobTimer = setInterval(async () => {
55 try {
56 const Node = core.models.Node;
57 const roots = await Node.find({
58 rootOwner: { $nin: [null, "SYSTEM"] },
59 contributors: { $exists: true, $not: { $size: 0 } },
60 }).select("_id").lean();
61
62 for (const root of roots) {
63 try {
64 await generateSuggestions(String(root._id));
65 } catch (err) {
66 log.debug("Delegate", `Suggestion generation failed for ${root._id}: ${err.message}`);
67 }
68 }
69 } catch (err) {
70 log.error("Delegate", `Delegate cycle failed: ${err.message}`);
71 }
72 }, interval);
73 if (_jobTimer.unref) _jobTimer.unref();
74 },
75 stop: () => {
76 if (_jobTimer) {
77 clearInterval(_jobTimer);
78 _jobTimer = null;
79 }
80 },
81 },
82 ],
83 exports: {
84 generateSuggestions,
85 getNearbySuggestions,
86 },
87 };
88}
89
1export default {
2 name: "delegate",
3 version: "1.0.1",
4 builtFor: "treeos-maintenance",
5 description:
6 "The tree's social intelligence. Intent generates actions for the tree to do itself. " +
7 "Delegate matches stuck work to available humans. Reads team contributor lists, activity " +
8 "patterns from evolution, competence maps, and inverse-tree profiles. Suggests, never assigns. " +
9 "enrichContext injects suggestions at stalled nodes. A contributor navigates nearby and the AI " +
10 "says: 'The auth refactor branch has been stalled for two weeks. You've been working on related " +
11 "API changes. Want to take a look?' Intent is the tree's desire. Delegate is the tree's social " +
12 "intelligence.",
13
14 needs: {
15 services: ["hooks", "metadata"],
16 models: ["Node", "User"],
17 },
18
19 optional: {
20 extensions: [
21 "evolution",
22 "competence",
23 "inverse-tree",
24 "team",
25 "intent",
26 ],
27 },
28
29 provides: {
30 models: {},
31 routes: "./routes.js",
32 tools: true,
33 jobs: true,
34 orchestrator: false,
35 energyActions: {},
36 sessionTypes: {},
37
38 hooks: {
39 fires: [],
40 listens: ["enrichContext"],
41 },
42
43 cli: [
44 {
45 command: "delegate [action] [args...]", scope: ["tree"],
46 description: "Delegate suggestions. Actions: dismiss <id>, accept <id>",
47 method: "GET",
48 endpoint: "/root/:rootId/delegate",
49 subcommands: {
50 dismiss: {
51 method: "POST",
52 endpoint: "/root/:rootId/delegate/dismiss",
53 args: ["id"],
54 description: "Not my problem",
55 },
56 accept: {
57 method: "POST",
58 endpoint: "/root/:rootId/delegate/accept",
59 args: ["id"],
60 description: "I'll look at it",
61 },
62 },
63 },
64 ],
65 },
66};
67
1import express from "express";
2import authenticate from "../../seed/middleware/authenticate.js";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import { getSuggestions, dismissSuggestion, acceptSuggestion, generateSuggestions } from "./core.js";
5
6const router = express.Router();
7
8// GET /root/:rootId/delegate - list pending suggestions
9router.get("/root/:rootId/delegate", authenticate, async (req, res) => {
10 try {
11 const suggestions = await getSuggestions(req.params.rootId, req.query.mine === "true" ? req.userId : null);
12 sendOk(res, { suggestions });
13 } catch (err) {
14 sendError(res, 500, ERR.INTERNAL, err.message);
15 }
16});
17
18// POST /root/:rootId/delegate/dismiss - dismiss a suggestion
19router.post("/root/:rootId/delegate/dismiss", authenticate, async (req, res) => {
20 try {
21 const { id } = req.body;
22 if (!id) return sendError(res, 400, ERR.INVALID_INPUT, "id is required");
23 const result = await dismissSuggestion(req.params.rootId, id, req.userId);
24 if (!result) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Suggestion not found");
25 sendOk(res, result);
26 } catch (err) {
27 sendError(res, 500, ERR.INTERNAL, err.message);
28 }
29});
30
31// POST /root/:rootId/delegate/accept - accept a suggestion
32router.post("/root/:rootId/delegate/accept", authenticate, async (req, res) => {
33 try {
34 const { id } = req.body;
35 if (!id) return sendError(res, 400, ERR.INVALID_INPUT, "id is required");
36 const result = await acceptSuggestion(req.params.rootId, id, req.userId);
37 if (!result) return sendError(res, 404, ERR.NODE_NOT_FOUND, "Suggestion not found");
38 sendOk(res, result);
39 } catch (err) {
40 sendError(res, 500, ERR.INTERNAL, err.message);
41 }
42});
43
44export default router;
45
1import { z } from "zod";
2import { getSuggestions, dismissSuggestion, acceptSuggestion } from "./core.js";
3
4export default [
5 {
6 name: "delegate-list",
7 description:
8 "Show pending delegate suggestions for this tree. Who should look at what.",
9 schema: {
10 nodeId: z.string().describe("The tree root ID."),
11 userId: z.string().describe("Injected by server. Ignore."),
12 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
13 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
14 },
15 annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
16 handler: async ({ nodeId, userId }) => {
17 try {
18 const suggestions = await getSuggestions(nodeId, null);
19 if (suggestions.length === 0) {
20 return { content: [{ type: "text", text: "No pending delegate suggestions." }] };
21 }
22 return {
23 content: [{
24 type: "text",
25 text: JSON.stringify(suggestions.map(s => ({
26 id: s.id,
27 nodeName: s.nodeName,
28 daysSilent: s.daysSilent,
29 suggestedUser: s.suggestedUsername,
30 score: s.score,
31 reasons: s.reasons,
32 })), null, 2),
33 }],
34 };
35 } catch (err) {
36 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
37 }
38 },
39 },
40 {
41 name: "delegate-dismiss",
42 description: "Dismiss a delegate suggestion. Not my problem.",
43 schema: {
44 nodeId: z.string().describe("The tree root ID."),
45 suggestionId: z.string().describe("The suggestion ID to dismiss."),
46 userId: z.string().describe("Injected by server. Ignore."),
47 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
48 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
49 },
50 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
51 handler: async ({ nodeId, suggestionId, userId }) => {
52 try {
53 const result = await dismissSuggestion(nodeId, suggestionId, userId);
54 if (!result) return { content: [{ type: "text", text: "Suggestion not found." }] };
55 return { content: [{ type: "text", text: `Dismissed: ${result.nodeName}` }] };
56 } catch (err) {
57 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
58 }
59 },
60 },
61 {
62 name: "delegate-accept",
63 description: "Accept a delegate suggestion. I'll look at it.",
64 schema: {
65 nodeId: z.string().describe("The tree root ID."),
66 suggestionId: z.string().describe("The suggestion ID to accept."),
67 userId: z.string().describe("Injected by server. Ignore."),
68 chatId: z.string().nullable().optional().describe("Injected by server. Ignore."),
69 sessionId: z.string().nullable().optional().describe("Injected by server. Ignore."),
70 },
71 annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
72 handler: async ({ nodeId, suggestionId, userId }) => {
73 try {
74 const result = await acceptSuggestion(nodeId, suggestionId, userId);
75 if (!result) return { content: [{ type: "text", text: "Suggestion not found." }] };
76 return { content: [{ type: "text", text: `Accepted: ${result.nodeName}. Navigate there to start.` }] };
77 } catch (err) {
78 return { content: [{ type: "text", text: `Failed: ${err.message}` }] };
79 }
80 },
81 },
82];
83
Loading comments...