@nonbot/mcp 0.2.3 → 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.
- package/README.md +21 -6
- package/dist/core/completedWork.d.ts +127 -0
- package/dist/core/completedWork.js +287 -0
- package/dist/core/config.d.ts +8 -0
- package/dist/core/config.js +11 -0
- package/dist/core/layout.d.ts +51 -0
- package/dist/core/layout.js +325 -6
- package/dist/core/mapHeaders.d.ts +13 -0
- package/dist/core/mapHeaders.js +64 -0
- package/dist/core/preview.d.ts +17 -0
- package/dist/core/preview.js +74 -0
- package/dist/core/releases.d.ts +127 -0
- package/dist/core/releases.js +280 -0
- package/dist/core/server.d.ts +1 -1
- package/dist/core/server.js +12 -2
- package/dist/core/style.d.ts +31 -0
- package/dist/core/style.js +64 -0
- package/dist/core/toolsets/build.d.ts +16 -3
- package/dist/core/toolsets/build.js +117 -11
- package/dist/core/toolsets/portfolio.js +70 -0
- package/dist/core/toolsets/radbugs.d.ts +27 -0
- package/dist/core/toolsets/radbugs.js +55 -0
- package/dist/core/toolsets/release.d.ts +3 -0
- package/dist/core/toolsets/release.js +329 -0
- package/dist/gitLog.d.ts +3 -0
- package/dist/gitLog.js +46 -0
- package/dist/stdio.js +2 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/gitLog.d.ts
ADDED
package/dist/gitLog.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { checkRepoPath } from './core/config.js';
|
|
5
|
+
const MAX_COMMITS = 5000;
|
|
6
|
+
const BODY_MAX = 2000;
|
|
7
|
+
const FS = '\x1f';
|
|
8
|
+
const RS = '\x1e';
|
|
9
|
+
export async function gitLog(repoPath, since) {
|
|
10
|
+
const bad = checkRepoPath(repoPath);
|
|
11
|
+
if (bad)
|
|
12
|
+
throw new Error(bad);
|
|
13
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(since))
|
|
14
|
+
throw new Error('since must be YYYY-MM-DD');
|
|
15
|
+
const dir = resolve(repoPath);
|
|
16
|
+
const st = await stat(dir).catch(() => null);
|
|
17
|
+
if (!st || !st.isDirectory())
|
|
18
|
+
throw new Error('repo_path is not a directory');
|
|
19
|
+
const args = [
|
|
20
|
+
'-c', 'core.fsmonitor=false', '-c', 'log.showSignature=false',
|
|
21
|
+
'-C', dir, 'log', `--since=${since}`, `--max-count=${MAX_COMMITS}`, '--no-color',
|
|
22
|
+
`--format=%H${FS}%cI${FS}%s${FS}%b${RS}`,
|
|
23
|
+
];
|
|
24
|
+
const out = await new Promise((ok, fail) => {
|
|
25
|
+
execFile('git', args, { timeout: 20_000, maxBuffer: 32 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, windowsHide: true }, (err, stdout) => {
|
|
26
|
+
if (err)
|
|
27
|
+
fail(new Error('git log failed — is repo_path a git repository?'));
|
|
28
|
+
else
|
|
29
|
+
ok(stdout);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
return parseGitLog(out);
|
|
33
|
+
}
|
|
34
|
+
export function parseGitLog(out) {
|
|
35
|
+
const commits = [];
|
|
36
|
+
for (const rec of out.split(RS)) {
|
|
37
|
+
const r = rec.replace(/^\s+/, '');
|
|
38
|
+
if (!r)
|
|
39
|
+
continue;
|
|
40
|
+
const [hash = '', date = '', subject = '', body = ''] = r.split(FS);
|
|
41
|
+
if (!/^[0-9a-f]{7,64}$/.test(hash))
|
|
42
|
+
continue;
|
|
43
|
+
commits.push({ hash, date, subject, body: body.trim().slice(0, BODY_MAX) });
|
|
44
|
+
}
|
|
45
|
+
return commits;
|
|
46
|
+
}
|
package/dist/stdio.js
CHANGED
|
@@ -5,6 +5,7 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
5
5
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
6
|
import { resolveApiBase } from './core/config.js';
|
|
7
7
|
import { createNonbotMcpServer } from './core/server.js';
|
|
8
|
+
import { gitLog } from './gitLog.js';
|
|
8
9
|
const BACKUP_DIR = join(homedir(), '.non.bot', 'backups');
|
|
9
10
|
async function tokenFromCliAuth() {
|
|
10
11
|
try {
|
|
@@ -61,6 +62,7 @@ async function main() {
|
|
|
61
62
|
readonly: process.env.NON_BOT_MCP_READONLY === '1',
|
|
62
63
|
backup: fileBackup,
|
|
63
64
|
readLocalImage,
|
|
65
|
+
gitLog,
|
|
64
66
|
};
|
|
65
67
|
const server = createNonbotMcpServer(ctx);
|
|
66
68
|
const transport = new StdioServerTransport();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nonbot/mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The non.bot MCP server — one server for your portfolios, decision graph, and Runs. Runs over stdio (Claude Code, Cursor, VS Code) with a pat_ token. Built on MCP protocol 2025-11-25.",
|
|
6
6
|
"keywords": [
|