@nonbot/mcp 0.2.4 → 0.3.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.
@@ -1,8 +1,11 @@
1
1
  import { z } from 'zod';
2
2
  import { api, expectJson } from '../http.js';
3
3
  import { validateGraph } from '../validate.js';
4
- import { LEVELS, applyLayout, layoutTimeline, scheduleFields, todayColumn, columnOf, isItem, PX_PER_WEEK } from '../layout.js';
4
+ import { LEVELS, applyLayout, layoutTimeline, layoutMapDetailed, applyMapLayout, scheduleFields, todayColumn, columnOf, isItem, PX_PER_WEEK } from '../layout.js';
5
5
  import { emptyIp, IP_KINDS, IP_NODE_SIZE } from '../ipShape.js';
6
+ import { withMapHeaders } from '../mapHeaders.js';
7
+ import { renderPreviewSvg } from '../preview.js';
8
+ import { applyStyleRules, CARD_STYLES, TITLE_SIZES, ACCENTS, SHOW_KEYS, STATES as STYLE_STATES } from '../style.js';
6
9
  const STATES = ['funnel', 'reviewing', 'analyzing', 'ready', 'implementing', 'done'];
7
10
  const OKR_STATUSES = ['on-track', 'at-risk', 'behind'];
8
11
  const RESERVED = new Set(['hierarchyLevel', 'parentItemId', 'linkedEpicIds', 'id', '__proto__', 'constructor', 'prototype']);
@@ -13,7 +16,7 @@ const fail = (msg) => ({ content: [{ type: 'text', text: msg }], isError: true }
13
16
  const escHtml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
14
17
  export const toHtml = (s) => /^\s*</.test(s) ? s : s.split(/\n{2,}/).map(p => `<p>${escHtml(p.trim()).replace(/\n/g, '<br>')}</p>`).join('');
15
18
  export function newItemId(now = Date.now()) { return `pf-${now}-${Math.random().toString(36).slice(2, 11)}`; }
16
- async function loadGraph(ctx, id) {
19
+ export async function loadGraph(ctx, id) {
17
20
  const data = await expectJson(await api(ctx, `/api/portfolio/graph?id=${encodeURIComponent(id)}`), 'load portfolio');
18
21
  const g = (data.graph ?? data);
19
22
  return {
@@ -25,7 +28,7 @@ async function loadGraph(ctx, id) {
25
28
  version: typeof g.version === 'number' ? g.version : undefined,
26
29
  };
27
30
  }
28
- async function saveGraph(ctx, id, before, after, expectedVersion) {
31
+ export async function saveGraph(ctx, id, before, after, expectedVersion) {
29
32
  const problems = validateGraph(after.nodes, after.edges);
30
33
  if (problems.length)
31
34
  return { ok: false, message: `Refusing to write — ${problems.length} problem(s):\n` + problems.map(p => ` - ${p}`).join('\n') };
@@ -63,7 +66,7 @@ const itemSpec = z.object({
63
66
  duration_weeks: z.number().min(0).max(156).optional().describe('Default: epic 8, feature 4, story 1, enabler 2, milestone 0, objective / key-result 12.'),
64
67
  tags: z.array(z.string().max(40)).max(20).optional(),
65
68
  wsjf: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional().describe('[business value, time criticality, risk reduction, job size].'),
66
- owner: z.string().max(80).optional(),
69
+ owner: z.string().max(80).optional().describe(`Who does this item — the Program view's lanes group by it. Set it on every feature / story / enabler / milestone: the human's name (as they appear in the portfolio, e.g. "Conrad") for work only a person can do — review or approve a plan, decide, buy, sign up, sign in, hand over a key, test on a real device, talk to a customer; "AI" for work an agent delivers — code, tests, migrations, docs, research, drafts. Spell it exactly "AI" so all agent work shares one lane.`),
67
70
  kr_target: z.string().max(200).optional(),
68
71
  kr_current: z.string().max(200).optional(),
69
72
  okr_status: z.enum(OKR_STATUSES).optional(),
@@ -74,6 +77,20 @@ const DEFAULT_WEEKS = { epic: 8, feature: 4, story: 1, enabler: 2, milestone: 0,
74
77
  const LEGAL_PARENT = {
75
78
  epic: [], objective: [], 'key-result': ['objective'], feature: ['epic'], story: ['feature', 'epic'], enabler: ['epic', 'feature'], milestone: ['epic', 'feature'],
76
79
  };
80
+ export const AI_OWNER = 'AI';
81
+ const WORK_LEVELS = new Set(['feature', 'story', 'enabler', 'milestone']);
82
+ export function ownerReport(specs) {
83
+ const owners = {};
84
+ const unassigned = [];
85
+ for (const s of specs) {
86
+ const who = s.owner?.trim();
87
+ if (who)
88
+ owners[who] = (owners[who] ?? 0) + 1;
89
+ else if (WORK_LEVELS.has(s.level))
90
+ unassigned.push(s.key ?? s.title);
91
+ }
92
+ return { owners, unassigned };
93
+ }
77
94
  export function buildItems(g, specs, now = new Date()) {
78
95
  const errors = [];
79
96
  const ids = {};
@@ -112,8 +129,8 @@ export function buildItems(g, specs, now = new Date()) {
112
129
  d.hypothesisStatement = s.hypothesis;
113
130
  if (s.tags)
114
131
  d.tags = s.tags;
115
- if (s.owner)
116
- d.owner = s.owner;
132
+ if (s.owner?.trim())
133
+ d.owner = s.owner.trim();
117
134
  if (s.wsjf)
118
135
  Object.assign(d, { wsjfBusinessValue: s.wsjf[0], wsjfTimeCriticality: s.wsjf[1], wsjfRiskReduction: s.wsjf[2], wsjfJobSize: s.wsjf[3] });
119
136
  if (s.kr_target)
@@ -166,6 +183,21 @@ export function buildItems(g, specs, now = new Date()) {
166
183
  return { nodes, edges, ids, errors, patched };
167
184
  }
168
185
  export function registerBuildTools(server, ctx) {
186
+ server.registerTool('portfolio_preview', {
187
+ title: 'Picture a portfolio layout',
188
+ description: 'Draw the portfolio as an SVG schematic — every card at its stored Map (default) or Timeline position and size, with its level, state and title (done cards pale green), section-header notes as headings — so you can LOOK at a layout before calling it done. Returns the SVG text plus the card count and extent. `sizes` (px per id) overrides the stored / estimated card sizes; `width` sets the output width.',
189
+ inputSchema: {
190
+ portfolio_id: z.string(),
191
+ view: z.enum(['map', 'timeline']).optional(),
192
+ sizes: z.record(z.string(), z.object({ width: z.number().min(1).max(8000), height: z.number().min(1).max(8000) })).optional(),
193
+ width: z.number().int().min(200).max(8000).optional(),
194
+ },
195
+ annotations: { readOnlyHint: true, openWorldHint: true },
196
+ }, async (a) => {
197
+ const g = await loadGraph(ctx, a.portfolio_id);
198
+ const r = renderPreviewSvg(g.nodes, { view: a.view, sizes: a.sizes, width: a.width });
199
+ return { content: [{ type: 'text', text: JSON.stringify({ cards: r.cards, extent: r.extent }) }, { type: 'text', text: r.svg }] };
200
+ });
169
201
  if (ctx.readonly)
170
202
  return;
171
203
  server.registerTool('portfolio_create', {
@@ -216,12 +248,16 @@ export function registerBuildTools(server, ctx) {
216
248
  const r = await saveGraph(ctx, a.portfolio_id, g, after, a.expected_version);
217
249
  if (!r.ok)
218
250
  return fail(r.message);
219
- const structured = { ok: true, added: built.nodes.length, edges: built.edges.length, ids: built.ids, ...(r.version !== undefined ? { version: r.version } : {}) };
220
- return { content: [text(structured)], structuredContent: structured };
251
+ const who = ownerReport(a.items);
252
+ const structured = { ok: true, added: built.nodes.length, edges: built.edges.length, ids: built.ids, owners: who.owners, unassigned: who.unassigned, ...(r.version !== undefined ? { version: r.version } : {}) };
253
+ const content = [text(structured)];
254
+ if (who.unassigned.length)
255
+ content.push({ type: 'text', text: `${who.unassigned.length} work item(s) have no owner: ${who.unassigned.join(', ')}. Give each one with portfolio_patch_item (set: { owner }) — the human's name for work only a person can do (review, approve, decide, buy, sign in, test on a device), "${AI_OWNER}" for work an agent delivers — so the human can see at a glance what is theirs.` });
256
+ return { content, structuredContent: structured };
221
257
  });
222
258
  server.registerTool('portfolio_patch_item', {
223
259
  title: 'Patch one item',
224
- description: 'Change fields of ONE portfolio item without rewriting the graph (PATCH /api/portfolio/graph/node): `set` writes allowlisted PortfolioItemData fields (title, description, bluf, acceptanceCriteria, hypothesisStatement, state, tags, wsjf*, krTarget, pastedImages, durationWeeks, startDate, …), `unset` deletes them. Rich text is stored as given (pass HTML, or plain text which is wrapped in <p>). A stale version answers 409 — nothing is written.',
260
+ description: 'Change fields of ONE portfolio item without rewriting the graph (PATCH /api/portfolio/graph/node): `set` writes allowlisted PortfolioItemData fields (title, description, bluf, acceptanceCriteria, hypothesisStatement, state, owner — the human\'s name or "AI", tags, wsjf*, krTarget, pastedImages, durationWeeks, startDate, …), `unset` deletes them. Rich text is stored as given (pass HTML, or plain text which is wrapped in <p>). A stale version answers 409 — nothing is written.',
225
261
  inputSchema: {
226
262
  portfolio_id: z.string(),
227
263
  item_id: z.string(),
@@ -248,22 +284,92 @@ export function registerBuildTools(server, ctx) {
248
284
  });
249
285
  server.registerTool('portfolio_layout', {
250
286
  title: 'Lay out a portfolio on the Timeline',
251
- description: 'Re-position every item so the Timeline reads cleanly: x from each item\'s start date (the canvas reads dates off x), y top-down — objectives, key results, then one block per epic (epic, features, stories + enablers, milestones), each row lane-packed so overlapping items stack, IP nodes directly above their first source. Short items get a minimum painted width so titles fit. Content is untouched. `heights` overrides the estimated card height per id (px) when a card measures taller on screen.',
287
+ description: 'Re-position every item so the Timeline reads cleanly: x from each item\'s start date (the canvas reads dates off x), y top-down — objectives, key results, then one block per epic (epic, features, stories + enablers, milestones), each row lane-packed so overlapping items stack, IP nodes directly above their first source. Short items get a minimum painted width so titles fit. Content is untouched. `heights` overrides the estimated card height per id (px) when a card measures taller on screen. `group: "objective"` reads top-down by goal instead: each objective, its key results, then the blocks of the epics those key results link to, before the next objective (`objective_order` lists objective ids to put first); unlinked epics follow. Grouping changes rows only, never x or dates. `view: "map"` lays out the Map instead (the free layout, `freePosition` only — the Timeline positions and dates are untouched): one band per objective (`objective_order` first), the objective + key results leading it, then one column per linked epic with its features and a 3-wide grid of stories; unlinked epics, loose items and modules follow.',
252
288
  inputSchema: {
253
289
  portfolio_id: z.string(),
254
290
  heights: z.record(z.string(), z.number().min(20).max(2000)).optional(),
291
+ sizes: z.record(z.string(), z.object({ width: z.number().min(1).max(8000), height: z.number().min(1).max(8000) })).optional().describe('With view "map": measured card sizes (px) per id — Map cards are as wide as their content.'),
292
+ column_height: z.number().min(400).max(20000).optional().describe('With view "map": an epic column taller than this (px) wraps into another column beside it, at feature boundaries.'),
293
+ pack: z.enum(['stack', 'grid']).optional().describe('With view "map": "grid" packs the objective bands into rows shaped like a screen (default "stack": top to bottom).'),
294
+ headers: z.boolean().optional().describe('With view "map": a section-header note above each objective band (title + epics + % done). Re-running updates the same notes; notes you wrote are never touched.'),
295
+ view: z.enum(['timeline', 'map']).optional().describe('Default timeline. "map": write the Map (free) positions only, grouped by objective.'),
296
+ group: z.enum(['objective']).optional().describe('"objective": group epic blocks under the objective whose key results link to them.'),
297
+ objective_order: z.array(z.string()).max(100).optional().describe('With group "objective": objective ids to place first, in this order.'),
255
298
  expected_version: z.number().int().optional(),
256
299
  },
257
300
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
258
301
  }, async (a) => {
259
302
  const g = await loadGraph(ctx, a.portfolio_id);
260
- const res = layoutTimeline(g.nodes, { heights: a.heights });
303
+ if (a.view === 'map') {
304
+ const HEADER_H = 220;
305
+ const cards = g.nodes.filter(n => !(n.type === 'rad-module' && n.data?.moduleData?.__layoutHeader));
306
+ const d = layoutMapDetailed(cards, { edges: g.edges, objectiveOrder: a.objective_order, heights: a.heights, sizes: a.sizes, pack: a.pack, columnHeight: a.column_height, headerHeight: a.headers ? HEADER_H : 0 });
307
+ let nodes = applyMapLayout(g.nodes, d.positions);
308
+ let headers = { added: 0, updated: 0, removed: 0 };
309
+ if (a.headers !== undefined) {
310
+ const h = withMapHeaders(nodes, a.headers ? d.bands : [], HEADER_H);
311
+ nodes = h.nodes;
312
+ headers = { added: h.added, updated: h.updated, removed: h.removed };
313
+ }
314
+ const r = await saveGraph(ctx, a.portfolio_id, g, { ...g, nodes }, a.expected_version);
315
+ if (!r.ok)
316
+ return fail(r.message);
317
+ const structured = { ok: true, view: 'map', positioned: d.positions.size, bands: d.bands.length, headers, ...(r.version !== undefined ? { version: r.version } : {}) };
318
+ return { content: [text(structured)], structuredContent: structured };
319
+ }
320
+ const res = layoutTimeline(g.nodes, { heights: a.heights, ...(a.group ? { group: a.group, edges: g.edges, objectiveOrder: a.objective_order } : {}) });
261
321
  const r = await saveGraph(ctx, a.portfolio_id, g, { ...g, nodes: applyLayout(g.nodes, res) }, a.expected_version);
262
322
  if (!r.ok)
263
323
  return fail(r.message);
264
324
  const structured = { ok: true, positioned: res.positions.size, ...(r.version !== undefined ? { version: r.version } : {}) };
265
325
  return { content: [text(structured)], structuredContent: structured };
266
326
  });
327
+ server.registerTool('portfolio_style', {
328
+ title: 'Restyle many cards at once',
329
+ description: 'Give a portfolio one consistent look in ONE versioned write: rules, applied in order, each matching cards by levels / states / ids / hasImage and setting how they are drawn — display (card_style plain|poster|split|image-top|cover|grid-2|grid-4, title_size sm|md|lg, accent tint|bar|plain, show {bluf, description, acceptanceCriteria, tags, dates, wsjf}), size (Map width × height, or "auto" to size to content) and satellites "close" (fold the card\'s inner-loop panels). Content is untouched. Artifact child cards (hypothesis, NFR…) are skipped unless a rule sets artifacts: true.',
330
+ inputSchema: {
331
+ portfolio_id: z.string(),
332
+ rules: z.array(z.object({
333
+ levels: z.array(z.enum(LEVELS)).optional(),
334
+ states: z.array(z.enum(STYLE_STATES)).optional(),
335
+ ids: z.array(z.string()).max(1000).optional(),
336
+ has_image: z.boolean().optional(),
337
+ artifacts: z.boolean().optional(),
338
+ display: z.object({
339
+ card_style: z.enum(CARD_STYLES).optional(),
340
+ title_size: z.enum(TITLE_SIZES).optional(),
341
+ accent: z.enum(ACCENTS).optional(),
342
+ show: z.object(Object.fromEntries(SHOW_KEYS.map(k => [k, z.boolean().optional()]))).optional(),
343
+ }).optional(),
344
+ size: z.union([z.object({ width: z.number().min(60).max(4000), height: z.number().min(40).max(4000) }), z.literal('auto')]).optional(),
345
+ satellites: z.literal('close').optional(),
346
+ })).min(1).max(40),
347
+ expected_version: z.number().int().optional(),
348
+ },
349
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
350
+ }, async (a) => {
351
+ const g = await loadGraph(ctx, a.portfolio_id);
352
+ const rules = a.rules.map(r => ({
353
+ ...(r.levels ? { levels: r.levels } : {}),
354
+ ...(r.states ? { states: r.states } : {}),
355
+ ...(r.ids ? { ids: r.ids } : {}),
356
+ ...(r.has_image !== undefined ? { hasImage: r.has_image } : {}),
357
+ ...(r.artifacts ? { artifacts: true } : {}),
358
+ ...(r.display ? { display: { cardStyle: r.display.card_style, titleSize: r.display.title_size, accent: r.display.accent, show: r.display.show } } : {}),
359
+ ...(r.size ? { size: r.size } : {}),
360
+ ...(r.satellites ? { satellites: r.satellites } : {}),
361
+ }));
362
+ const res = applyStyleRules(g.nodes, rules);
363
+ if (res.changed === 0) {
364
+ const structured = { ok: true, matched: res.matched, changed: 0, perRule: res.perRule };
365
+ return { content: [text(structured)], structuredContent: structured };
366
+ }
367
+ const r = await saveGraph(ctx, a.portfolio_id, g, { ...g, nodes: res.nodes }, a.expected_version);
368
+ if (!r.ok)
369
+ return fail(r.message);
370
+ const structured = { ok: true, matched: res.matched, changed: res.changed, perRule: res.perRule, ...(r.version !== undefined ? { version: r.version } : {}) };
371
+ return { content: [text(structured)], structuredContent: structured };
372
+ });
267
373
  server.registerTool('portfolio_set_meta', {
268
374
  title: 'Set portfolio settings',
269
375
  description: 'Graph-level settings that travel with the portfolio (the `__graph_meta__` record): `context_text` (the portfolio\'s context notes — what a newcomer reads first), `palette` ({ id, name, swatches: [{hex, label?}] ≤ 10, roles?: {epic, feature, story, …: hex | "p:N"} }), `canvas_style`, `sprint_settings`. Only the keys you pass change; everything else in the record is kept.',
@@ -1,3 +1,30 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import type { ApiContext } from '../config.js';
3
+ declare const LIST_STATUSES: readonly ["open", "done", "wontfix", "ai_flagged"];
4
+ type ListStatus = (typeof LIST_STATUSES)[number];
5
+ export interface RadbugNote {
6
+ id: string;
7
+ status: string;
8
+ priority: string | null;
9
+ area: string | null;
10
+ pageUrl: string | null;
11
+ description: string;
12
+ createdAt: number | null;
13
+ updatedAt: number | null;
14
+ hasScreenshot: boolean;
15
+ rawMessage?: string | null;
16
+ }
17
+ export declare function fetchRadbugs(ctx: ApiContext, opts?: {
18
+ status?: ListStatus;
19
+ area?: string;
20
+ priority?: 'low' | 'med' | 'high';
21
+ limit?: number;
22
+ }): Promise<{
23
+ forbidden: true;
24
+ reason: string;
25
+ } | {
26
+ forbidden: false;
27
+ notes: RadbugNote[];
28
+ }>;
3
29
  export declare function registerRadbugsTools(server: McpServer, ctx: ApiContext): void;
30
+ export {};
@@ -16,7 +16,62 @@ async function forbiddenText(res) {
16
16
  function textResult(data) {
17
17
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
18
18
  }
19
+ const LIST_STATUSES = ['open', 'done', 'wontfix', 'ai_flagged'];
20
+ const LIST_FORBIDDEN = 'radbug_list is owner-only: it needs the site owner\'s account and a token with the radbugs:read scope (or an unscoped token).';
21
+ const ms = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? n : null; };
22
+ export async function fetchRadbugs(ctx, opts = {}) {
23
+ const limit = Math.min(Math.max(opts.limit ?? 200, 1), 500);
24
+ const q = [`limit=${encodeURIComponent(String(opts.status === 'done' ? 500 : limit))}`];
25
+ if (opts.status && opts.status !== 'done')
26
+ q.push(`status=${encodeURIComponent(opts.status)}`);
27
+ if (opts.area)
28
+ q.push(`area=${encodeURIComponent(opts.area)}`);
29
+ if (opts.priority)
30
+ q.push(`priority=${encodeURIComponent(opts.priority)}`);
31
+ const res = await api(ctx, `/api/admin/radbugs?${q.join('&')}`);
32
+ if (res.status === 403 || res.status === 401)
33
+ return { forbidden: true, reason: LIST_FORBIDDEN };
34
+ const body = await expectJson(res, 'GET /api/admin/radbugs');
35
+ const raw = Array.isArray(body.notes) ? body.notes : [];
36
+ const notes = [];
37
+ for (const r of raw) {
38
+ if (!r || typeof r.id !== 'string')
39
+ continue;
40
+ const status = String(r.status ?? '');
41
+ if (opts.status && status !== opts.status)
42
+ continue;
43
+ notes.push({
44
+ id: r.id, status,
45
+ priority: typeof r.priority === 'string' ? r.priority : null,
46
+ area: typeof r.area === 'string' ? r.area : null,
47
+ pageUrl: typeof r.pageUrl === 'string' ? r.pageUrl.slice(0, 2048) : null,
48
+ description: String(r.description ?? '').slice(0, 2000),
49
+ createdAt: ms(r.createdAt), updatedAt: ms(r.updatedAt),
50
+ hasScreenshot: !!(r.screenshot || (Array.isArray(r.screenshots) && r.screenshots.length)),
51
+ rawMessage: typeof r.rawMessage === 'string' ? r.rawMessage.slice(0, 500) : null,
52
+ });
53
+ if (notes.length >= limit)
54
+ break;
55
+ }
56
+ return { forbidden: false, notes };
57
+ }
19
58
  export function registerRadbugsTools(server, ctx) {
59
+ server.registerTool('radbug_list', {
60
+ title: 'List radBUGS',
61
+ description: 'List radBUGS from the non.bot inbox, newest first (open ones lead): id (ti_…), status (open | ai_flagged | done | wontfix), priority, area, page URL, description (clipped to 2 000 chars), created / updated time (epoch ms). `done` includes bugs closed from the inbox. Screenshots are left out. Owner-only: the site owner\'s account with a radbugs:read (or unscoped) token.',
62
+ inputSchema: {
63
+ status: z.enum(LIST_STATUSES).optional().describe('Only this status; "done" = closed.'),
64
+ area: z.string().max(80).optional(),
65
+ priority: z.enum(['low', 'med', 'high']).optional(),
66
+ limit: z.number().int().min(1).max(500).optional().describe('Default 200.'),
67
+ },
68
+ annotations: { readOnlyHint: true, openWorldHint: true },
69
+ }, async (a) => {
70
+ const r = await fetchRadbugs(ctx, a);
71
+ if (r.forbidden)
72
+ return { content: [{ type: 'text', text: r.reason }], isError: true };
73
+ return textResult({ count: r.notes.length, notes: r.notes });
74
+ });
20
75
  if (ctx.readonly)
21
76
  return;
22
77
  server.registerTool('radbug_file', {
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { type ApiContext } from '../config.js';
3
+ export declare function registerReleaseTools(server: McpServer, ctx: ApiContext): void;
@@ -0,0 +1,329 @@
1
+ import { z } from 'zod';
2
+ import { checkRepoPath } from '../config.js';
3
+ import { api, expectJson } from '../http.js';
4
+ import { loadGraph, saveGraph, toHtml } from './build.js';
5
+ import { fetchRadbugs } from './radbugs.js';
6
+ import { columnOf, PX_PER_WEEK } from '../layout.js';
7
+ import { RELEASE_SUBTYPE, applyChainWrites, applyItemPatches, chainReleasesFromGraphNodes, chainWrites, fileSet, memberCounts, membersOf, newReleaseId, outTargetsOf, patchReleaseData, placeRelease, previousRelease, releaseIdOf, releaseNode, subtreeOf, unfileSet, unfinishedIn, } from '../releases.js';
8
+ import { clocklessDone, completedWork, matchEvidence, proposeReleases, toMs, windowMs } from '../completedWork.js';
9
+ const DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD');
10
+ const MAX_PROPOSALS = 120;
11
+ const text = (obj) => ({ type: 'text', text: JSON.stringify(obj, null, 2) });
12
+ const fail = (msg) => ({ content: [{ type: 'text', text: msg }], isError: true });
13
+ const ok = (structured) => ({ content: [text(structured)], structuredContent: structured });
14
+ const items = (g) => g.nodes.filter(n => n.type === 'portfolio-item');
15
+ const localDay = (s) => new Date(Number(s.slice(0, 4)), Number(s.slice(5, 7)) - 1, Number(s.slice(8, 10)));
16
+ const xOfDate = (now) => (iso) => columnOf(localDay(iso), now) * PX_PER_WEEK;
17
+ const findRelease = (g, id) => g.nodes.find(n => n.id === id && n.type === 'rad-module' && n.data?.subtype === RELEASE_SUBTYPE);
18
+ const titleOf = (n) => String(n?.data?.title ?? '');
19
+ function addRelease(nodes, md, now) {
20
+ const id = newReleaseId(now.getTime());
21
+ const prev = previousRelease(md.from, chainReleasesFromGraphNodes(nodes));
22
+ const node = releaseNode(id, md, placeRelease(nodes, md.from, prev?.id ?? null, xOfDate(now)));
23
+ const withNode = [...nodes, node];
24
+ return { nodes: applyChainWrites(withNode, chainWrites(id, chainReleasesFromGraphNodes(withNode))), id, prev: prev?.id ?? null };
25
+ }
26
+ function nextOf(nodes, id) {
27
+ const n = nodes.find(x => x.id === id);
28
+ return n ? (outTargetsOf(n.data ?? {})[0] ?? null) : null;
29
+ }
30
+ const versionOf = (r) => (r.version !== undefined ? { version: r.version } : {});
31
+ export function registerReleaseTools(server, ctx) {
32
+ server.registerTool('portfolio_completed_work', {
33
+ title: 'What got done',
34
+ description: 'The done items of a portfolio in a date window, computed from the graph: each row names its done date and the clock it came from (doneAt — stamped when filed, stateChangedAt — the state clock, updatedAt — only the last edit, a proxy; none = unknown), rolled up objective → key result → epic → feature → story. Parents that are NOT done but hold done work are flagged partially_done and never counted. Cards already filed in a release are left out unless include_filed. Window ends are inclusive local days; with a window, done cards with no clock at all are listed as undated.',
35
+ inputSchema: {
36
+ portfolio_id: z.string(),
37
+ from: DATE.optional(),
38
+ to: DATE.optional(),
39
+ include_filed: z.boolean().optional().describe('Default false: cards filed in a release are already accounted for.'),
40
+ },
41
+ annotations: { readOnlyHint: true, openWorldHint: true },
42
+ }, async (a) => {
43
+ if (a.from && a.to && a.to < a.from)
44
+ return fail('to is before from.');
45
+ const g = await loadGraph(ctx, a.portfolio_id);
46
+ const r = completedWork(g.nodes, g.edges, { from: a.from, to: a.to, includeFiled: a.include_filed });
47
+ return ok({ window: { from: a.from ?? null, to: a.to ?? null }, ...r });
48
+ });
49
+ server.registerTool('portfolio_rebuild_done_work', {
50
+ title: 'Find when forgotten work finished',
51
+ description: 'For every done card with no finish clock (no doneAt, no stateChangedAt), gather POINTERS — not verdicts — to when it finished: commits in the local git repo at repo_path (since `since`) that cite the card id or a radBUGS id tied to the card; closed radBUGS tied to it (owner-only — needs radbug_list access); the card\'s own comments. Each card gets a proposal: the latest commit / closed-radBUG date, else the latest comment, else unknown. Writes nothing; to keep a proposal, set the card\'s doneAt with portfolio_patch_item (set: { doneAt: <epoch ms> }), then portfolio_propose_releases.',
52
+ inputSchema: {
53
+ portfolio_id: z.string(),
54
+ since: DATE.describe('Look at commits, radBUGS and comments from this day on.'),
55
+ repo_path: z.string().max(1024).optional().describe('Absolute path of a local git checkout (stdio only). portfolio_repo_path tells you the saved one.'),
56
+ radbugs: z.boolean().optional().describe('Default true: read closed radBUGS (owner-only; skipped with a note otherwise).'),
57
+ comments: z.boolean().optional().describe('Default true: read each card\'s comments (one request per card).'),
58
+ max_cards: z.number().int().min(1).max(300).optional().describe('Default 100: cards examined, most recently edited first.'),
59
+ },
60
+ annotations: { readOnlyHint: true, openWorldHint: true },
61
+ }, async (a) => {
62
+ if (a.repo_path !== undefined) {
63
+ const bad = checkRepoPath(a.repo_path);
64
+ if (bad)
65
+ return fail(bad);
66
+ }
67
+ const g = await loadGraph(ctx, a.portfolio_id);
68
+ const all = clocklessDone(g.nodes);
69
+ const upd = (n) => (typeof n.data?.updatedAt === 'number' ? n.data.updatedAt : 0);
70
+ const cards = [...all].sort((x, y) => upd(y) - upd(x) || x.id.localeCompare(y.id)).slice(0, a.max_cards ?? 100);
71
+ const { lo } = windowMs(a.since);
72
+ const sources = {};
73
+ let commits = [];
74
+ if (!a.repo_path)
75
+ sources.git = 'not requested — pass repo_path';
76
+ else if (!ctx.gitLog)
77
+ sources.git = 'unavailable — this server has no local disk (use the stdio server)';
78
+ else {
79
+ try {
80
+ commits = await ctx.gitLog(a.repo_path, a.since);
81
+ sources.git = `${commits.length} commit(s) since ${a.since}`;
82
+ }
83
+ catch (e) {
84
+ sources.git = `failed — ${e instanceof Error ? e.message.slice(0, 200) : 'git error'}`;
85
+ }
86
+ }
87
+ let radbugs = [];
88
+ if (a.radbugs === false)
89
+ sources.radbugs = 'not requested';
90
+ else {
91
+ const r = await fetchRadbugs(ctx, { status: 'done' });
92
+ if (r.forbidden)
93
+ sources.radbugs = `skipped — ${r.reason}`;
94
+ else {
95
+ radbugs = r.notes.filter(b => (b.updatedAt ?? b.createdAt ?? 0) >= lo);
96
+ sources.radbugs = `${radbugs.length} closed radBUG(s) since ${a.since}`;
97
+ }
98
+ }
99
+ const comments = new Map();
100
+ if (a.comments === false)
101
+ sources.comments = 'not requested';
102
+ else {
103
+ let failed = 0;
104
+ const queue = [...cards];
105
+ const worker = async () => {
106
+ for (let c = queue.shift(); c; c = queue.shift()) {
107
+ try {
108
+ const res = await api(ctx, `/api/portfolio/comments?portfolioId=${encodeURIComponent(a.portfolio_id)}&itemId=${encodeURIComponent(c.id)}`);
109
+ const body = await expectJson(res, 'comments');
110
+ comments.set(c.id, (Array.isArray(body.comments) ? body.comments : []).filter(x => (toMs(x.createdAt) ?? 0) >= lo));
111
+ }
112
+ catch {
113
+ failed++;
114
+ }
115
+ }
116
+ };
117
+ await Promise.all(Array.from({ length: Math.min(4, cards.length) }, worker));
118
+ sources.comments = `read for ${cards.length - failed} card(s)${failed ? `, ${failed} failed` : ''}`;
119
+ }
120
+ const out = cards.map(c => {
121
+ const m = matchEvidence(c, { commits, radbugs, comments: comments.get(c.id) ?? [] });
122
+ return { id: c.id, title: titleOf(c), level: String(c.data?.hierarchyLevel ?? ''), proposal: m.proposal, evidence: m.evidence };
123
+ });
124
+ const known = out.filter(c => c.proposal.basis !== 'unknown').length;
125
+ return ok({ clockless_done: all.length, examined: cards.length, with_evidence: known, unknown: cards.length - known, sources, cards: out });
126
+ });
127
+ server.registerTool('portfolio_release_list', {
128
+ title: 'List releases',
129
+ description: 'The releases of a portfolio, oldest first: id, name, from–to window, draft / approvedAt / open, the chain (previous / next release by the OUT wire), member counts by level, and for a draft the cards it proposes. include_members lists each release\'s filed cards (id, title, level, doneAt).',
130
+ inputSchema: { portfolio_id: z.string(), include_members: z.boolean().optional() },
131
+ annotations: { readOnlyHint: true, openWorldHint: true },
132
+ }, async (a) => {
133
+ const g = await loadGraph(ctx, a.portfolio_id);
134
+ const cards = items(g);
135
+ const nodes = g.nodes;
136
+ const rels = g.nodes.filter(n => n.type === 'rad-module' && n.data?.subtype === RELEASE_SUBTYPE);
137
+ const prevOf = new Map();
138
+ for (const r of rels) {
139
+ const t = outTargetsOf(r.data ?? {})[0];
140
+ if (t)
141
+ prevOf.set(t, r.id);
142
+ }
143
+ const list = rels.map(r => {
144
+ const md = (r.data?.moduleData ?? {});
145
+ const proposed = Array.isArray(md.proposed) ? md.proposed.filter((x) => typeof x === 'string') : [];
146
+ return {
147
+ id: r.id, name: String(md.name ?? ''), from: String(md.from ?? ''), to: String(md.to ?? ''),
148
+ draft: md.draft === true, ...(typeof md.approvedAt === 'number' ? { approvedAt: md.approvedAt } : {}), open: md.open === true,
149
+ summary: String(md.summary ?? '').slice(0, 2000), ...(Array.isArray(md.highlights) ? { highlights: md.highlights } : {}),
150
+ previous: prevOf.get(r.id) ?? null, next: nextOf(nodes, r.id),
151
+ counts: memberCounts(r.id, cards),
152
+ ...(md.draft === true ? { proposed } : {}),
153
+ ...(a.include_members ? { members: membersOf(r.id, cards).map(n => ({ id: n.id, title: titleOf(n), level: String(n.data?.hierarchyLevel ?? ''), doneAt: n.data?.doneAt ?? null })) } : {}),
154
+ };
155
+ }).sort((x, y) => x.from.localeCompare(y.from) || x.to.localeCompare(y.to));
156
+ return ok({ releases: list, ...(g.version !== undefined ? { version: g.version } : {}) });
157
+ });
158
+ if (ctx.readonly)
159
+ return;
160
+ server.registerTool('portfolio_propose_releases', {
161
+ title: 'Propose releases',
162
+ description: 'Write DRAFT releases for done work that is not filed yet: one per calendar week (Mon–Sun, by each card\'s done date) and — unless objective_releases is false — one per finished objective, holding it and its done subtree. Each draft lists the cards it proposes (moduleData.proposed); NOTHING is filed and no card is touched until the draft is approved (portfolio_release_approve, or Accept in the app). Drafts are chained by date like any release. Cards already proposed by another draft and cards with no done clock are skipped and listed. One versioned write.',
163
+ inputSchema: {
164
+ portfolio_id: z.string(),
165
+ from: DATE.optional().describe('Only work done on or after this day.'),
166
+ to: DATE.optional().describe('Only work done on or before this day.'),
167
+ objective_releases: z.boolean().optional().describe('Default true: a finished objective gets its own release.'),
168
+ expected_version: z.number().int().optional(),
169
+ },
170
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
171
+ }, async (a) => {
172
+ if (a.from && a.to && a.to < a.from)
173
+ return fail('to is before from.');
174
+ const g = await loadGraph(ctx, a.portfolio_id);
175
+ const p = proposeReleases(g.nodes, g.edges, { from: a.from, to: a.to, objectiveReleases: a.objective_releases });
176
+ const report = { undated: p.undated, already_proposed: p.already_proposed, filed_skipped: p.filed_skipped };
177
+ if (!p.drafts.length)
178
+ return ok({ ok: true, created: [], ...report });
179
+ if (p.drafts.length > MAX_PROPOSALS)
180
+ return fail(`That would be ${p.drafts.length} drafts (limit ${MAX_PROPOSALS}) — narrow the window with from / to.`);
181
+ const now = new Date();
182
+ let nodes = g.nodes;
183
+ const created = [];
184
+ for (const d of p.drafts) {
185
+ const r = addRelease(nodes, { name: d.name, from: d.from, to: d.to, summary: '', draft: true, open: false, proposed: d.proposed }, now);
186
+ nodes = r.nodes;
187
+ created.push({ id: r.id, kind: d.kind, name: d.name, from: d.from, to: d.to, proposed: d.proposed.length, ...(d.objective_id ? { objective_id: d.objective_id } : {}) });
188
+ }
189
+ const s = await saveGraph(ctx, a.portfolio_id, g, { ...g, nodes: nodes }, a.expected_version);
190
+ if (!s.ok)
191
+ return fail(s.message);
192
+ return ok({ ok: true, created, ...report, ...versionOf(s) });
193
+ });
194
+ server.registerTool('portfolio_release_create', {
195
+ title: 'Create a release',
196
+ description: 'Add one release (a Release node) covering from–to, chained after the release whose window ends latest before it starts (and before the one that starts next), placed above the cards on the Timeline at its date and beside the release it follows on the Map. Its summary is for a business reader (plain text or HTML; outcomes before features). File cards into it with portfolio_release_file. One versioned write.',
197
+ inputSchema: {
198
+ portfolio_id: z.string(),
199
+ name: z.string().min(1).max(200),
200
+ from: DATE,
201
+ to: DATE,
202
+ summary: z.string().max(20000).optional(),
203
+ highlights: z.array(z.string().max(300)).max(20).optional(),
204
+ expected_version: z.number().int().optional(),
205
+ },
206
+ outputSchema: { id: z.string(), previous: z.string().nullable(), next: z.string().nullable(), version: z.number().optional() },
207
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
208
+ }, async (a) => {
209
+ if (a.to < a.from)
210
+ return fail('to is before from.');
211
+ const g = await loadGraph(ctx, a.portfolio_id);
212
+ const md = { name: a.name, from: a.from, to: a.to, summary: a.summary ? toHtml(a.summary) : '', draft: false, open: false, ...(a.highlights?.length ? { highlights: a.highlights } : {}) };
213
+ const r = addRelease(g.nodes, md, new Date());
214
+ const s = await saveGraph(ctx, a.portfolio_id, g, { ...g, nodes: r.nodes }, a.expected_version);
215
+ if (!s.ok)
216
+ return fail(s.message);
217
+ return ok({ id: r.id, previous: r.prev, next: nextOf(r.nodes, r.id), ...versionOf(s) });
218
+ });
219
+ server.registerTool('portfolio_release_file', {
220
+ title: 'File cards into a release',
221
+ description: 'File cards into a release, each WITH its subtree (children by parent; from an objective, its key results\' linked epics). Only done cards are filed unless only_done is false (open ones are listed as left_open). A card in another release moves; a card already in this one is skipped. Filing never moves a card or touches its parent, edges or position: it sets releaseId and stamps doneAt once (doneAt ?? stateChangedAt ?? updatedAt). Refuses a DRAFT release (approve it first). One versioned write.',
222
+ inputSchema: {
223
+ portfolio_id: z.string(),
224
+ release_id: z.string(),
225
+ item_ids: z.array(z.string()).min(1).max(500),
226
+ only_done: z.boolean().optional().describe('Default true — "file the done parts only".'),
227
+ expected_version: z.number().int().optional(),
228
+ },
229
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
230
+ }, async (a) => {
231
+ const g = await loadGraph(ctx, a.portfolio_id);
232
+ const rel = findRelease(g, a.release_id);
233
+ if (!rel)
234
+ return fail(`${a.release_id} is not a release in this portfolio.`);
235
+ if (rel.data?.moduleData?.draft === true)
236
+ return fail('That release is a draft — approve it first (portfolio_release_approve); a draft files nothing.');
237
+ const cards = items(g);
238
+ const known = new Set(cards.map(n => n.id));
239
+ const missing = a.item_ids.filter(id => !known.has(id));
240
+ if (missing.length)
241
+ return fail(`Not cards in this portfolio: ${missing.join(', ')}`);
242
+ const ids = [...subtreeOf(a.item_ids, cards, g.edges)];
243
+ const patches = fileSet(ids, a.release_id, cards, { onlyDone: a.only_done });
244
+ const leftOpen = a.only_done === false ? [] : unfinishedIn(ids, cards);
245
+ const moved = patches.filter(p => releaseIdOf(cards.find(n => n.id === p.id)) !== null).map(p => p.id);
246
+ const already = ids.filter(id => releaseIdOf(cards.find(n => n.id === id)) === a.release_id);
247
+ const report = { filed: patches.length, filed_ids: patches.map(p => p.id), moved_from_other_release: moved, already_in_release: already, left_open: leftOpen };
248
+ if (!patches.length)
249
+ return ok({ ok: true, ...report });
250
+ const s = await saveGraph(ctx, a.portfolio_id, g, { ...g, nodes: applyItemPatches(g.nodes, patches) }, a.expected_version);
251
+ if (!s.ok)
252
+ return fail(s.message);
253
+ return ok({ ok: true, ...report, ...versionOf(s) });
254
+ });
255
+ server.registerTool('portfolio_release_unfile', {
256
+ title: 'Un-file cards',
257
+ description: 'Take cards out of their release: item_ids (exactly those cards — no subtree), or release_id for every member of one release. Clears releaseId and KEEPS doneAt (a fact about the card, not the release); nothing else changes. One versioned write.',
258
+ inputSchema: {
259
+ portfolio_id: z.string(),
260
+ item_ids: z.array(z.string()).min(1).max(500).optional(),
261
+ release_id: z.string().optional().describe('Un-file every member of this release.'),
262
+ expected_version: z.number().int().optional(),
263
+ },
264
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
265
+ }, async (a) => {
266
+ if (!a.item_ids === !a.release_id)
267
+ return fail('Pass item_ids or release_id (one of them).');
268
+ const g = await loadGraph(ctx, a.portfolio_id);
269
+ const cards = items(g);
270
+ let ids;
271
+ if (a.release_id) {
272
+ if (!findRelease(g, a.release_id))
273
+ return fail(`${a.release_id} is not a release in this portfolio.`);
274
+ ids = membersOf(a.release_id, cards).map(n => n.id);
275
+ }
276
+ else {
277
+ const known = new Set(cards.map(n => n.id));
278
+ const missing = a.item_ids.filter(id => !known.has(id));
279
+ if (missing.length)
280
+ return fail(`Not cards in this portfolio: ${missing.join(', ')}`);
281
+ ids = a.item_ids;
282
+ }
283
+ const patches = unfileSet(ids, cards);
284
+ if (!patches.length)
285
+ return ok({ ok: true, unfiled: 0, unfiled_ids: [] });
286
+ const s = await saveGraph(ctx, a.portfolio_id, g, { ...g, nodes: applyItemPatches(g.nodes, patches) }, a.expected_version);
287
+ if (!s.ok)
288
+ return fail(s.message);
289
+ return ok({ ok: true, unfiled: patches.length, unfiled_ids: patches.map(p => p.id), ...versionOf(s) });
290
+ });
291
+ server.registerTool('portfolio_release_approve', {
292
+ title: 'Approve a draft release',
293
+ description: 'Turn a DRAFT release into a release: files exactly the cards it proposes (still-done ones; each keeps its parent, edges and position, gets releaseId and doneAt once) and sets draft false, approvedAt now and proposed empty — all in one versioned write. Optionally set its name, summary (for a business reader; plain text or HTML) and highlights in the same write. Proposed cards no longer done or no longer present are listed as not_filed.',
294
+ inputSchema: {
295
+ portfolio_id: z.string(),
296
+ release_id: z.string(),
297
+ name: z.string().min(1).max(200).optional(),
298
+ summary: z.string().max(20000).optional(),
299
+ highlights: z.array(z.string().max(300)).max(20).optional(),
300
+ expected_version: z.number().int().optional(),
301
+ },
302
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
303
+ }, async (a) => {
304
+ const g = await loadGraph(ctx, a.portfolio_id);
305
+ const rel = findRelease(g, a.release_id);
306
+ if (!rel)
307
+ return fail(`${a.release_id} is not a release in this portfolio.`);
308
+ const md = (rel.data?.moduleData ?? {});
309
+ if (md.draft !== true)
310
+ return fail('That release is not a draft — nothing to approve.');
311
+ const proposed = Array.isArray(md.proposed) ? md.proposed.filter((x) => typeof x === 'string') : [];
312
+ const cards = items(g);
313
+ const patches = fileSet(proposed, a.release_id, cards);
314
+ const filed = new Set(patches.map(p => p.id));
315
+ const notFiled = proposed.filter(id => !filed.has(id) && releaseIdOf(cards.find(n => n.id === id) ?? { id }) !== a.release_id);
316
+ const modPatch = { draft: false, approvedAt: Date.now(), proposed: [] };
317
+ if (a.name !== undefined)
318
+ modPatch.name = a.name;
319
+ if (a.summary !== undefined)
320
+ modPatch.summary = toHtml(a.summary);
321
+ if (a.highlights !== undefined)
322
+ modPatch.highlights = a.highlights;
323
+ const nodes = patchReleaseData(applyItemPatches(g.nodes, patches), a.release_id, modPatch);
324
+ const s = await saveGraph(ctx, a.portfolio_id, g, { ...g, nodes }, a.expected_version);
325
+ if (!s.ok)
326
+ return fail(s.message);
327
+ return ok({ ok: true, release_id: a.release_id, filed: patches.length, filed_ids: patches.map(p => p.id), not_filed: notFiled, ...versionOf(s) });
328
+ });
329
+ }