EXTENSION for treeos-maintenance
delegate
The tree's social intelligence. Intent generates actions for the tree to do itself. Delegate matches stuck work to available humans. Reads team contributor lists, activity patterns from evolution, competence maps, and inverse-tree profiles. Suggests, never assigns. enrichContext injects suggestions at stalled nodes. A contributor navigates nearby and the AI says: 'The auth refactor branch has been stalled for two weeks. You've been working on related API changes. Want to take a look?' Intent is the tree's desire. Delegate is the tree's social intelligence.
v1.0.1 by TreeOS Site 0 downloads 5 files 661 lines 22.0 KB published 38d ago
treeos ext install delegate
View changelog

Manifest

Provides

  • routes
  • tools
  • jobs
  • 1 CLI commands

Requires

  • services: hooks, metadata
  • models: Node, User

Optional

  • extensions: evolution, competence, inverse-tree, team, intent
SHA256: c74a40d8b9a2d1906ee7a84c137c19df034f92d25e9c9d0196aaddf8dc0d64d4

Dependents

1 package depend on this

PackageTypeRelationship
treeos-maintenance v1.0.1bundleincludes

CLI Commands

CommandMethodDescription
delegateGETDelegate suggestions. Actions: dismiss <id>, accept <id>
delegate dismissPOSTNot my problem
delegate acceptPOSTI'll look at it

Hooks

Listens To

  • enrichContext

Source Code

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

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star delegate

Comments

Loading comments...

Post comments from the CLI: treeos ext comment delegate "your comment"
Max 3 comments per extension. One star and one flag per user.