@nonbot/mcp 0.0.0-stage → 0.1.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/LICENSE +43 -0
- package/README.md +242 -2
- package/dist/core/config.d.ts +8 -0
- package/dist/core/config.js +34 -0
- package/dist/core/http.d.ts +3 -0
- package/dist/core/http.js +20 -0
- package/dist/core/prompts.d.ts +3 -0
- package/dist/core/prompts.js +45 -0
- package/dist/core/resources.d.ts +3 -0
- package/dist/core/resources.js +23 -0
- package/dist/core/server.d.ts +7 -0
- package/dist/core/server.js +41 -0
- package/dist/core/toolsets/ip.d.ts +3 -0
- package/dist/core/toolsets/ip.js +130 -0
- package/dist/core/toolsets/monitor.d.ts +3 -0
- package/dist/core/toolsets/monitor.js +160 -0
- package/dist/core/toolsets/portfolio.d.ts +3 -0
- package/dist/core/toolsets/portfolio.js +521 -0
- package/dist/core/toolsets/run.d.ts +3 -0
- package/dist/core/toolsets/run.js +202 -0
- package/dist/core/toolsets/whygraph.d.ts +3 -0
- package/dist/core/toolsets/whygraph.js +346 -0
- package/dist/core/validate.d.ts +21 -0
- package/dist/core/validate.js +45 -0
- package/dist/stdio.d.ts +2 -0
- package/dist/stdio.js +50 -0
- package/package.json +65 -4
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { api, expectJson } from '../http.js';
|
|
3
|
+
const GATE = 'The Run feature is gated behind NONBOT_RUN_FEATURE_ENABLED on the server. Ask the non.bot operator to enable it.';
|
|
4
|
+
const rid = (id) => encodeURIComponent(id);
|
|
5
|
+
async function runReq(ctx, method, path, body) {
|
|
6
|
+
const init = { method };
|
|
7
|
+
if (body !== undefined)
|
|
8
|
+
init.body = JSON.stringify(body);
|
|
9
|
+
const res = await api(ctx, path, init);
|
|
10
|
+
if (res.status === 503)
|
|
11
|
+
throw new Error(GATE);
|
|
12
|
+
return expectJson(res, `${method} ${path}`);
|
|
13
|
+
}
|
|
14
|
+
function textResult(data) {
|
|
15
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
16
|
+
}
|
|
17
|
+
const WRITE = { readOnlyHint: false, openWorldHint: true };
|
|
18
|
+
const READ = { readOnlyHint: true, openWorldHint: true };
|
|
19
|
+
export function registerRunTools(server, ctx) {
|
|
20
|
+
server.registerTool('run_get_state', {
|
|
21
|
+
title: 'Get run state',
|
|
22
|
+
description: 'Ground-truth run state: active_plan, ghost_plan, stickies, pending_violations, pending_reflections, trust_dial, last_user_intervention_at. Call before every non-trivial action. Read-only.',
|
|
23
|
+
inputSchema: { run_id: z.string() },
|
|
24
|
+
annotations: READ,
|
|
25
|
+
}, async (a) => textResult(await runReq(ctx, 'GET', `/api/portfolio/runs/${rid(a.run_id)}`)));
|
|
26
|
+
if (ctx.readonly)
|
|
27
|
+
return;
|
|
28
|
+
server.registerTool('run_load_activation', {
|
|
29
|
+
title: 'Load run activation context',
|
|
30
|
+
description: 'Load the full context for a run: the story brief, acceptance criteria, applicable WhyGraph rules, and ensemble defaults. Call once at the start of a run.',
|
|
31
|
+
inputSchema: { activation_id: z.string().describe('The activation id to load.') },
|
|
32
|
+
annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true },
|
|
33
|
+
}, async (a) => textResult(await runReq(ctx, 'POST', '/api/portfolio/runs/load', { activationId: a.activation_id })));
|
|
34
|
+
server.registerTool('run_register_agent', {
|
|
35
|
+
title: 'Register an ensemble agent',
|
|
36
|
+
description: 'Register an ensemble member for this activation (creates the run on the first agent). Returns the run_id you pass to every other run_* tool. role ∈ researcher|architect|lead|verifier|capsule_writer.',
|
|
37
|
+
inputSchema: {
|
|
38
|
+
activation_id: z.string(),
|
|
39
|
+
role: z.enum(['researcher', 'architect', 'lead', 'verifier', 'capsule_writer']),
|
|
40
|
+
model_hint: z.string().optional(),
|
|
41
|
+
title: z.string().optional(),
|
|
42
|
+
},
|
|
43
|
+
annotations: WRITE,
|
|
44
|
+
}, async (a) => textResult(await runReq(ctx, 'POST', '/api/portfolio/runs', {
|
|
45
|
+
activationId: a.activation_id,
|
|
46
|
+
role: a.role,
|
|
47
|
+
modelHint: a.model_hint,
|
|
48
|
+
title: a.title,
|
|
49
|
+
})));
|
|
50
|
+
server.registerTool('run_declare_scope', {
|
|
51
|
+
title: 'Declare run scope',
|
|
52
|
+
description: 'Lead declares the file allowlist + intent. Out-of-scope writes are advisory-blocked.',
|
|
53
|
+
inputSchema: {
|
|
54
|
+
run_id: z.string(),
|
|
55
|
+
allowlist: z.array(z.string()).describe('Repo-relative paths/globs the run may touch.'),
|
|
56
|
+
intent: z.string(),
|
|
57
|
+
},
|
|
58
|
+
annotations: WRITE,
|
|
59
|
+
}, async (a) => textResult(await runReq(ctx, 'POST', `/api/portfolio/runs/${rid(a.run_id)}/declare-scope`, { allowlist: a.allowlist, intent: a.intent })));
|
|
60
|
+
server.registerTool('run_request_scope_change', {
|
|
61
|
+
title: 'Request a scope change',
|
|
62
|
+
description: 'Request expansion of the file allowlist. Surfaces as a yellow flare on the canvas for the human to accept/reject.',
|
|
63
|
+
inputSchema: { run_id: z.string(), reason: z.string(), additions: z.array(z.string()) },
|
|
64
|
+
annotations: WRITE,
|
|
65
|
+
}, async (a) => textResult(await runReq(ctx, 'POST', `/api/portfolio/runs/${rid(a.run_id)}/request-scope-change`, { reason: a.reason, additions: a.additions })));
|
|
66
|
+
server.registerTool('run_emit_diff', {
|
|
67
|
+
title: 'Emit a code diff',
|
|
68
|
+
description: 'Report a code diff. Triggers a WhyGraph rule-check + scope guard; returns { violations, reinforcements, blocked_paths }.',
|
|
69
|
+
inputSchema: {
|
|
70
|
+
run_id: z.string(),
|
|
71
|
+
files: z.array(z.string()).min(1),
|
|
72
|
+
lines_added: z.number().int().optional(),
|
|
73
|
+
lines_removed: z.number().int().optional(),
|
|
74
|
+
snippets: z.array(z.string()).optional(),
|
|
75
|
+
step_node_id: z.string().optional(),
|
|
76
|
+
},
|
|
77
|
+
annotations: WRITE,
|
|
78
|
+
}, async (a) => textResult(await runReq(ctx, 'POST', `/api/portfolio/runs/${rid(a.run_id)}/diff`, {
|
|
79
|
+
files: a.files,
|
|
80
|
+
linesAdded: a.lines_added ?? 0,
|
|
81
|
+
linesRemoved: a.lines_removed ?? 0,
|
|
82
|
+
snippets: a.snippets,
|
|
83
|
+
stepNodeId: a.step_node_id,
|
|
84
|
+
})));
|
|
85
|
+
server.registerTool('run_emit_test_run', {
|
|
86
|
+
title: 'Emit test results',
|
|
87
|
+
description: 'Report a test run; updates the dashboard sparkline.',
|
|
88
|
+
inputSchema: {
|
|
89
|
+
run_id: z.string(),
|
|
90
|
+
results: z.object({
|
|
91
|
+
passed: z.number().int(),
|
|
92
|
+
failed: z.number().int(),
|
|
93
|
+
total: z.number().int(),
|
|
94
|
+
failures: z.array(z.any()).optional(),
|
|
95
|
+
}),
|
|
96
|
+
},
|
|
97
|
+
annotations: WRITE,
|
|
98
|
+
}, async (a) => textResult(await runReq(ctx, 'POST', `/api/portfolio/runs/${rid(a.run_id)}/test-run`, { results: a.results })));
|
|
99
|
+
server.registerTool('run_emit_lint', {
|
|
100
|
+
title: 'Emit lint results',
|
|
101
|
+
description: 'Report lint hits; updates the dashboard.',
|
|
102
|
+
inputSchema: { run_id: z.string(), hits: z.array(z.any()) },
|
|
103
|
+
annotations: WRITE,
|
|
104
|
+
}, async (a) => textResult(await runReq(ctx, 'POST', `/api/portfolio/runs/${rid(a.run_id)}/lint`, { hits: a.hits })));
|
|
105
|
+
server.registerTool('run_emit_typecheck', {
|
|
106
|
+
title: 'Emit typecheck results',
|
|
107
|
+
description: 'Report type-check errors; updates the dashboard.',
|
|
108
|
+
inputSchema: { run_id: z.string(), errors: z.array(z.any()) },
|
|
109
|
+
annotations: WRITE,
|
|
110
|
+
}, async (a) => textResult(await runReq(ctx, 'POST', `/api/portfolio/runs/${rid(a.run_id)}/typecheck`, { errors: a.errors })));
|
|
111
|
+
server.registerTool('run_emit_build', {
|
|
112
|
+
title: 'Emit build status',
|
|
113
|
+
description: 'Report build status; updates the dashboard.',
|
|
114
|
+
inputSchema: { run_id: z.string(), status: z.enum(['pass', 'fail', 'warn']), log: z.string().optional() },
|
|
115
|
+
annotations: WRITE,
|
|
116
|
+
}, async (a) => textResult(await runReq(ctx, 'POST', `/api/portfolio/runs/${rid(a.run_id)}/build`, { status: a.status, log: a.log })));
|
|
117
|
+
server.registerTool('run_emit_file_touch', {
|
|
118
|
+
title: 'Emit a file touch',
|
|
119
|
+
description: 'Report a file access (read|write|create); glows the living codebase map.',
|
|
120
|
+
inputSchema: { run_id: z.string(), path: z.string(), op: z.enum(['read', 'write', 'create']) },
|
|
121
|
+
annotations: WRITE,
|
|
122
|
+
}, async (a) => textResult(await runReq(ctx, 'POST', `/api/portfolio/runs/${rid(a.run_id)}/file-touch`, { path: a.path, op: a.op })));
|
|
123
|
+
server.registerTool('run_emit_usage', {
|
|
124
|
+
title: 'Emit a turn\'s token usage',
|
|
125
|
+
description: 'Report ONE turn\'s raw token counts so the command center\'s Metered Spend tile and per-run cost cap become truthful (instead of showing $0.00). The SERVER computes the cost from the model — never send a dollar amount (an agent can\'t under-report its way past the cap). Returns cumulative spend + percent-of-cap + status; a 409 means this turn busted the run\'s hard cap and you must escalate to the operator before continuing. Call once per model turn with the provider-reported input/output token counts.',
|
|
126
|
+
inputSchema: {
|
|
127
|
+
run_id: z.string(),
|
|
128
|
+
turn_index: z.number().int().min(0).describe('0-based turn number within the run.'),
|
|
129
|
+
model: z.string().describe('Model id — must be one the server prices (e.g. a claude-* id).'),
|
|
130
|
+
input_tokens: z.number().int().min(0),
|
|
131
|
+
output_tokens: z.number().int().min(0),
|
|
132
|
+
phase: z.string().optional().describe('Optional phase tag for per-phase cost rollup.'),
|
|
133
|
+
},
|
|
134
|
+
annotations: WRITE,
|
|
135
|
+
}, async (a) => textResult(await runReq(ctx, 'POST', `/api/portfolio/runs/${rid(a.run_id)}/cost-event`, {
|
|
136
|
+
turnIndex: a.turn_index,
|
|
137
|
+
model: a.model,
|
|
138
|
+
inputTokens: a.input_tokens,
|
|
139
|
+
outputTokens: a.output_tokens,
|
|
140
|
+
...(a.phase ? { phase: a.phase } : {}),
|
|
141
|
+
})));
|
|
142
|
+
server.registerTool('run_request_reconsider', {
|
|
143
|
+
title: 'Request a reconsideration',
|
|
144
|
+
description: 'Verifier nudges the Lead to reflect; inserts a pending reflection row.',
|
|
145
|
+
inputSchema: { run_id: z.string(), reason: z.string(), last_step_id: z.string().optional() },
|
|
146
|
+
annotations: WRITE,
|
|
147
|
+
}, async (a) => textResult(await runReq(ctx, 'POST', `/api/portfolio/runs/${rid(a.run_id)}/reflections`, { kind: 'request', reason: a.reason, lastStepId: a.last_step_id })));
|
|
148
|
+
server.registerTool('run_post_reflection', {
|
|
149
|
+
title: 'Post a reflection',
|
|
150
|
+
description: 'Lead posts a reflection (response to a reconsider, or self-initiated). decision ∈ continue|revise_plan|escalate.',
|
|
151
|
+
inputSchema: {
|
|
152
|
+
run_id: z.string(),
|
|
153
|
+
reflection_md: z.string(),
|
|
154
|
+
decision: z.enum(['continue', 'revise_plan', 'escalate']),
|
|
155
|
+
last_step_id: z.string().optional(),
|
|
156
|
+
plan_diff: z.any().optional(),
|
|
157
|
+
},
|
|
158
|
+
annotations: WRITE,
|
|
159
|
+
}, async (a) => textResult(await runReq(ctx, 'POST', `/api/portfolio/runs/${rid(a.run_id)}/reflections`, {
|
|
160
|
+
kind: 'post',
|
|
161
|
+
reflectionMd: a.reflection_md,
|
|
162
|
+
decision: a.decision,
|
|
163
|
+
lastStepId: a.last_step_id,
|
|
164
|
+
planDiff: a.plan_diff,
|
|
165
|
+
})));
|
|
166
|
+
server.registerTool('run_resolve_violation', {
|
|
167
|
+
title: 'Resolve a WhyGraph violation',
|
|
168
|
+
description: 'Resolve a rule violation. action ∈ enforce (rollback) | accept (weight decay) | override (log).',
|
|
169
|
+
inputSchema: { run_id: z.string(), violation_id: z.string(), action: z.enum(['enforce', 'accept', 'override']) },
|
|
170
|
+
annotations: WRITE,
|
|
171
|
+
}, async (a) => textResult(await runReq(ctx, 'POST', `/api/portfolio/runs/${rid(a.run_id)}/violations/${rid(a.violation_id)}/resolve`, { action: a.action })));
|
|
172
|
+
server.registerTool('run_set_trust_dial', {
|
|
173
|
+
title: 'Set the trust dial',
|
|
174
|
+
description: 'Adjust the trust dial mid-run (0–10). Lower = more escalation; higher = more autonomy.',
|
|
175
|
+
inputSchema: { run_id: z.string(), value: z.number().int().min(0).max(10) },
|
|
176
|
+
annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true },
|
|
177
|
+
}, async (a) => textResult(await runReq(ctx, 'PATCH', `/api/portfolio/runs/${rid(a.run_id)}`, { trustDial: a.value })));
|
|
178
|
+
server.registerTool('run_pause_agent', {
|
|
179
|
+
title: 'Pause an ensemble agent',
|
|
180
|
+
description: 'Pause one ensemble member by role. (Newly surfaced — was defined but unregistered in the old server.)',
|
|
181
|
+
inputSchema: { run_id: z.string(), role: z.string() },
|
|
182
|
+
annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true },
|
|
183
|
+
}, async (a) => textResult(await runReq(ctx, 'POST', `/api/portfolio/runs/${rid(a.run_id)}/agents/${rid(a.role)}/pause`, {})));
|
|
184
|
+
server.registerTool('run_resume_agent', {
|
|
185
|
+
title: 'Resume an ensemble agent',
|
|
186
|
+
description: 'Resume one paused ensemble member by role. (Newly surfaced — was defined but unregistered in the old server.)',
|
|
187
|
+
inputSchema: { run_id: z.string(), role: z.string() },
|
|
188
|
+
annotations: { readOnlyHint: false, idempotentHint: true, openWorldHint: true },
|
|
189
|
+
}, async (a) => textResult(await runReq(ctx, 'POST', `/api/portfolio/runs/${rid(a.run_id)}/agents/${rid(a.role)}/resume`, {})));
|
|
190
|
+
server.registerTool('run_emit_pr', {
|
|
191
|
+
title: 'Announce a PR',
|
|
192
|
+
description: 'Announce that a PR is open; triggers the Act-5 closing sequence.',
|
|
193
|
+
inputSchema: { run_id: z.string(), pr_url: z.string() },
|
|
194
|
+
annotations: WRITE,
|
|
195
|
+
}, async (a) => textResult(await runReq(ctx, 'POST', `/api/portfolio/runs/${rid(a.run_id)}/pr`, { prUrl: a.pr_url })));
|
|
196
|
+
server.registerTool('run_finish', {
|
|
197
|
+
title: 'Finish the run',
|
|
198
|
+
description: 'Final close-out: triggers reinforcement + candidate-rule extraction, sets the story to shipped. Provide the capsule markdown and an optional PR URL.',
|
|
199
|
+
inputSchema: { run_id: z.string(), capsule_md: z.string(), pr_url: z.string().optional(), summary: z.any().optional() },
|
|
200
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
201
|
+
}, async (a) => textResult(await runReq(ctx, 'POST', `/api/portfolio/runs/${rid(a.run_id)}/finish`, { capsuleMd: a.capsule_md, prUrl: a.pr_url, summary: a.summary })));
|
|
202
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { api, expectJson } from '../http.js';
|
|
3
|
+
async function fetchGraph(ctx) {
|
|
4
|
+
const res = await api(ctx, '/api/whygraph/decisions');
|
|
5
|
+
const data = await expectJson(res, 'whygraph');
|
|
6
|
+
if (data.error) {
|
|
7
|
+
throw new Error(`WhyGraph is temporarily unavailable (${data.hint ?? 'unknown'}). The decision graph backend is not reachable right now.`);
|
|
8
|
+
}
|
|
9
|
+
return { decisions: data.decisions ?? [], relationships: data.relationships ?? [] };
|
|
10
|
+
}
|
|
11
|
+
function tokenize(s) {
|
|
12
|
+
return s
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.split(/[^a-z0-9]+/)
|
|
15
|
+
.filter((t) => t.length > 1);
|
|
16
|
+
}
|
|
17
|
+
function isLaw(d) {
|
|
18
|
+
return d.status === 'locked' || (typeof d.weight === 'number' && d.weight >= 5);
|
|
19
|
+
}
|
|
20
|
+
function scoreDecision(queryTokens, d) {
|
|
21
|
+
if (queryTokens.length === 0)
|
|
22
|
+
return 0;
|
|
23
|
+
const hay = new Set(tokenize(`${d.title ?? ''} ${d.description ?? ''} ${d.category ?? ''}`));
|
|
24
|
+
let score = 0;
|
|
25
|
+
for (const t of queryTokens)
|
|
26
|
+
if (hay.has(t))
|
|
27
|
+
score += 1;
|
|
28
|
+
return score;
|
|
29
|
+
}
|
|
30
|
+
export function registerWhygraphTools(server, ctx) {
|
|
31
|
+
server.registerTool('whygraph_search', {
|
|
32
|
+
title: 'Search decisions',
|
|
33
|
+
description: 'Search the PM decision graph by keyword, ranked by weight (how reinforced a decision is) then relevance. Use this to find prior decisions before proposing something new. Read-only.',
|
|
34
|
+
inputSchema: {
|
|
35
|
+
query: z.string().describe('Keywords to match against decision title, description, and category.'),
|
|
36
|
+
category: z.string().optional().describe('Restrict to one category.'),
|
|
37
|
+
limit: z.number().int().min(1).max(50).optional().describe('Max results (default 10).'),
|
|
38
|
+
},
|
|
39
|
+
outputSchema: {
|
|
40
|
+
count: z.number(),
|
|
41
|
+
decisions: z.array(z.object({
|
|
42
|
+
id: z.string(),
|
|
43
|
+
title: z.string(),
|
|
44
|
+
category: z.string().optional(),
|
|
45
|
+
weight: z.number(),
|
|
46
|
+
status: z.string().optional(),
|
|
47
|
+
isLaw: z.boolean(),
|
|
48
|
+
score: z.number(),
|
|
49
|
+
})),
|
|
50
|
+
},
|
|
51
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
52
|
+
}, async (args) => {
|
|
53
|
+
const { decisions } = await fetchGraph(ctx);
|
|
54
|
+
const qTokens = tokenize(args.query);
|
|
55
|
+
const scored = decisions
|
|
56
|
+
.filter((d) => (args.category ? d.category === args.category : true))
|
|
57
|
+
.map((d) => ({
|
|
58
|
+
id: d.id,
|
|
59
|
+
title: d.title ?? d.id,
|
|
60
|
+
category: d.category,
|
|
61
|
+
weight: typeof d.weight === 'number' ? d.weight : 0,
|
|
62
|
+
status: d.status,
|
|
63
|
+
isLaw: isLaw(d),
|
|
64
|
+
score: scoreDecision(qTokens, d),
|
|
65
|
+
}))
|
|
66
|
+
.filter((s) => s.score > 0)
|
|
67
|
+
.sort((a, b) => b.weight - a.weight || b.score - a.score);
|
|
68
|
+
const page = scored.slice(0, args.limit ?? 10);
|
|
69
|
+
const structured = { count: page.length, decisions: page };
|
|
70
|
+
const text = page.length
|
|
71
|
+
? JSON.stringify(structured, null, 2)
|
|
72
|
+
: JSON.stringify({ ...structured, note: `No decisions matched "${args.query}". Try whygraph_stats for an overview, or whygraph_context to see which LAWs apply to a task.` }, null, 2);
|
|
73
|
+
return { content: [{ type: 'text', text }], structuredContent: structured };
|
|
74
|
+
});
|
|
75
|
+
server.registerTool('whygraph_context', {
|
|
76
|
+
title: 'Get decision context for a task',
|
|
77
|
+
description: 'Given a task you are about to do, return the decisions that apply: the best keyword matches PLUS every LAW (locked, weight ≥ 5) — because LAWs always apply. Call this BEFORE acting so you answer to the rules the team already wrote down. Read-only.',
|
|
78
|
+
inputSchema: {
|
|
79
|
+
task: z.string().describe('A short description of what you are about to do.'),
|
|
80
|
+
},
|
|
81
|
+
outputSchema: {
|
|
82
|
+
task: z.string(),
|
|
83
|
+
matchedCount: z.number(),
|
|
84
|
+
lawCount: z.number(),
|
|
85
|
+
matched: z.array(z.object({ id: z.string(), title: z.string(), category: z.string().optional(), weight: z.number(), status: z.string().optional() })),
|
|
86
|
+
laws: z.array(z.object({ id: z.string(), title: z.string(), category: z.string().optional(), weight: z.number() })),
|
|
87
|
+
},
|
|
88
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
89
|
+
}, async (args) => {
|
|
90
|
+
const { decisions } = await fetchGraph(ctx);
|
|
91
|
+
const qTokens = tokenize(args.task);
|
|
92
|
+
const matched = decisions
|
|
93
|
+
.map((d) => ({ d, score: scoreDecision(qTokens, d) }))
|
|
94
|
+
.filter((x) => x.score > 0)
|
|
95
|
+
.sort((a, b) => (b.d.weight ?? 0) - (a.d.weight ?? 0) || b.score - a.score)
|
|
96
|
+
.slice(0, 10)
|
|
97
|
+
.map(({ d }) => ({ id: d.id, title: d.title ?? d.id, category: d.category, weight: d.weight ?? 0, status: d.status }));
|
|
98
|
+
const matchedIds = new Set(matched.map((m) => m.id));
|
|
99
|
+
const laws = decisions
|
|
100
|
+
.filter(isLaw)
|
|
101
|
+
.filter((d) => !matchedIds.has(d.id))
|
|
102
|
+
.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0))
|
|
103
|
+
.map((d) => ({ id: d.id, title: d.title ?? d.id, category: d.category, weight: d.weight ?? 0 }));
|
|
104
|
+
const structured = { task: args.task, matchedCount: matched.length, lawCount: laws.length, matched, laws };
|
|
105
|
+
return { content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }], structuredContent: structured };
|
|
106
|
+
});
|
|
107
|
+
server.registerTool('whygraph_neighbors', {
|
|
108
|
+
title: 'Trace a decision’s impact radius',
|
|
109
|
+
description: 'Graph reasoning the flat decision list cannot give you: starting from one decision, traverse RELATED_TO relationships breadth-first to surface every connected decision within N hops — the "impact radius" of changing or violating it. Returns connected decisions grouped by distance. Read-only.',
|
|
110
|
+
inputSchema: {
|
|
111
|
+
id: z.string().describe('The decision id to start from (slug, e.g. from whygraph_search).'),
|
|
112
|
+
depth: z.number().int().min(1).max(4).optional().describe('How many hops to traverse (default 2, max 4).'),
|
|
113
|
+
},
|
|
114
|
+
outputSchema: {
|
|
115
|
+
root: z.string(),
|
|
116
|
+
found: z.number(),
|
|
117
|
+
maxDepth: z.number(),
|
|
118
|
+
neighbors: z.array(z.object({
|
|
119
|
+
id: z.string(),
|
|
120
|
+
title: z.string(),
|
|
121
|
+
category: z.string().optional(),
|
|
122
|
+
distance: z.number(),
|
|
123
|
+
isLaw: z.boolean(),
|
|
124
|
+
})),
|
|
125
|
+
},
|
|
126
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
127
|
+
}, async (args) => {
|
|
128
|
+
const { decisions, relationships } = await fetchGraph(ctx);
|
|
129
|
+
const byId = new Map(decisions.map((d) => [d.id, d]));
|
|
130
|
+
if (!byId.has(args.id)) {
|
|
131
|
+
throw new Error(`No decision with id "${args.id}". Use whygraph_search to find the right id first.`);
|
|
132
|
+
}
|
|
133
|
+
const adj = new Map();
|
|
134
|
+
const link = (a, b) => {
|
|
135
|
+
if (!adj.has(a))
|
|
136
|
+
adj.set(a, new Set());
|
|
137
|
+
adj.get(a).add(b);
|
|
138
|
+
};
|
|
139
|
+
for (const r of relationships) {
|
|
140
|
+
link(r.fromId, r.toId);
|
|
141
|
+
link(r.toId, r.fromId);
|
|
142
|
+
}
|
|
143
|
+
const maxDepth = args.depth ?? 2;
|
|
144
|
+
const dist = new Map([[args.id, 0]]);
|
|
145
|
+
let frontier = [args.id];
|
|
146
|
+
for (let d = 1; d <= maxDepth && frontier.length > 0; d++) {
|
|
147
|
+
const next = [];
|
|
148
|
+
for (const node of frontier) {
|
|
149
|
+
for (const nb of adj.get(node) ?? []) {
|
|
150
|
+
if (!dist.has(nb)) {
|
|
151
|
+
dist.set(nb, d);
|
|
152
|
+
next.push(nb);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
frontier = next;
|
|
157
|
+
}
|
|
158
|
+
const neighbors = [...dist.entries()]
|
|
159
|
+
.filter(([id]) => id !== args.id)
|
|
160
|
+
.map(([id, distance]) => {
|
|
161
|
+
const d = byId.get(id);
|
|
162
|
+
return { id, title: d?.title ?? id, category: d?.category, distance, isLaw: d ? isLaw(d) : false };
|
|
163
|
+
})
|
|
164
|
+
.sort((a, b) => a.distance - b.distance || a.title.localeCompare(b.title))
|
|
165
|
+
.slice(0, 100);
|
|
166
|
+
const structured = { root: args.id, found: neighbors.length, maxDepth, neighbors };
|
|
167
|
+
return { content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }], structuredContent: structured };
|
|
168
|
+
});
|
|
169
|
+
server.registerTool('whygraph_stats', {
|
|
170
|
+
title: 'Decision graph overview',
|
|
171
|
+
description: 'A dashboard view of the decision graph: total decisions, a per-category breakdown, the LAWs (locked, weight ≥ 5), and the most-violated decisions. Read-only.',
|
|
172
|
+
inputSchema: {},
|
|
173
|
+
outputSchema: {
|
|
174
|
+
total: z.number(),
|
|
175
|
+
byCategory: z.record(z.string(), z.number()),
|
|
176
|
+
laws: z.array(z.object({ id: z.string(), title: z.string(), weight: z.number() })),
|
|
177
|
+
mostViolated: z.array(z.object({ id: z.string(), title: z.string(), violationCount: z.number() })),
|
|
178
|
+
},
|
|
179
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
180
|
+
}, async () => {
|
|
181
|
+
const { decisions } = await fetchGraph(ctx);
|
|
182
|
+
const byCategory = {};
|
|
183
|
+
for (const d of decisions) {
|
|
184
|
+
const c = d.category ?? 'uncategorized';
|
|
185
|
+
byCategory[c] = (byCategory[c] ?? 0) + 1;
|
|
186
|
+
}
|
|
187
|
+
const laws = decisions
|
|
188
|
+
.filter(isLaw)
|
|
189
|
+
.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0))
|
|
190
|
+
.map((d) => ({ id: d.id, title: d.title ?? d.id, weight: d.weight ?? 0 }));
|
|
191
|
+
const mostViolated = decisions
|
|
192
|
+
.filter((d) => (d.violationCount ?? 0) > 0)
|
|
193
|
+
.sort((a, b) => (b.violationCount ?? 0) - (a.violationCount ?? 0))
|
|
194
|
+
.slice(0, 5)
|
|
195
|
+
.map((d) => ({ id: d.id, title: d.title ?? d.id, violationCount: d.violationCount ?? 0 }));
|
|
196
|
+
const structured = { total: decisions.length, byCategory, laws, mostViolated };
|
|
197
|
+
return { content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }], structuredContent: structured };
|
|
198
|
+
});
|
|
199
|
+
server.registerTool('whygraph_path', {
|
|
200
|
+
title: 'Find the path between two decisions',
|
|
201
|
+
description: 'Find the shortest chain of RELATED_TO links connecting one decision to another — how a decision reaches another through the graph. Returns the ordered path (or connected:false if there is none). Read-only.',
|
|
202
|
+
inputSchema: {
|
|
203
|
+
from_id: z.string().describe('Start decision id (from whygraph_search).'),
|
|
204
|
+
to_id: z.string().describe('Target decision id.'),
|
|
205
|
+
maxDepth: z.number().int().min(1).max(8).optional().describe('Max hops to search (default 8).'),
|
|
206
|
+
},
|
|
207
|
+
outputSchema: {
|
|
208
|
+
from: z.string(),
|
|
209
|
+
to: z.string(),
|
|
210
|
+
connected: z.boolean(),
|
|
211
|
+
hops: z.number(),
|
|
212
|
+
path: z.array(z.object({ id: z.string(), title: z.string() })),
|
|
213
|
+
},
|
|
214
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
215
|
+
}, async (args) => {
|
|
216
|
+
const { decisions, relationships } = await fetchGraph(ctx);
|
|
217
|
+
const byId = new Map(decisions.map((d) => [d.id, d]));
|
|
218
|
+
if (!byId.has(args.from_id))
|
|
219
|
+
throw new Error(`No decision "${args.from_id}". Use whygraph_search to find the id.`);
|
|
220
|
+
if (!byId.has(args.to_id))
|
|
221
|
+
throw new Error(`No decision "${args.to_id}". Use whygraph_search to find the id.`);
|
|
222
|
+
const adj = new Map();
|
|
223
|
+
const link = (a, b) => {
|
|
224
|
+
if (!adj.has(a))
|
|
225
|
+
adj.set(a, new Set());
|
|
226
|
+
adj.get(a).add(b);
|
|
227
|
+
};
|
|
228
|
+
for (const r of relationships) {
|
|
229
|
+
link(r.fromId, r.toId);
|
|
230
|
+
link(r.toId, r.fromId);
|
|
231
|
+
}
|
|
232
|
+
const maxDepth = args.maxDepth ?? 8;
|
|
233
|
+
const prev = new Map([[args.from_id, null]]);
|
|
234
|
+
let frontier = [args.from_id];
|
|
235
|
+
let found = args.from_id === args.to_id;
|
|
236
|
+
for (let d = 1; d <= maxDepth && frontier.length > 0 && !found; d++) {
|
|
237
|
+
const next = [];
|
|
238
|
+
for (const node of frontier) {
|
|
239
|
+
for (const nb of adj.get(node) ?? []) {
|
|
240
|
+
if (!prev.has(nb)) {
|
|
241
|
+
prev.set(nb, node);
|
|
242
|
+
if (nb === args.to_id)
|
|
243
|
+
found = true;
|
|
244
|
+
next.push(nb);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
frontier = next;
|
|
249
|
+
}
|
|
250
|
+
const path = [];
|
|
251
|
+
if (prev.has(args.to_id)) {
|
|
252
|
+
const chain = [];
|
|
253
|
+
let cur = args.to_id;
|
|
254
|
+
while (cur !== null) {
|
|
255
|
+
chain.unshift(cur);
|
|
256
|
+
cur = prev.get(cur) ?? null;
|
|
257
|
+
}
|
|
258
|
+
for (const id of chain)
|
|
259
|
+
path.push({ id, title: byId.get(id)?.title ?? id });
|
|
260
|
+
}
|
|
261
|
+
const structured = { from: args.from_id, to: args.to_id, connected: path.length > 0, hops: path.length > 0 ? path.length - 1 : 0, path };
|
|
262
|
+
return { content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }], structuredContent: structured };
|
|
263
|
+
});
|
|
264
|
+
server.registerTool('whygraph_conflicts', {
|
|
265
|
+
title: 'Surface potential decision conflicts',
|
|
266
|
+
description: 'Surface pairs of decisions that govern the SAME field but are NOT linked to each other — potential unreconciled overlap or contradiction to review. A heuristic for human review (it flags candidates, it does not judge). Read-only.',
|
|
267
|
+
inputSchema: {
|
|
268
|
+
field: z.string().optional().describe('Restrict to decisions tagged with this field.'),
|
|
269
|
+
limit: z.number().int().min(1).max(50).optional().describe('Max pairs to return (default 20).'),
|
|
270
|
+
},
|
|
271
|
+
outputSchema: {
|
|
272
|
+
count: z.number(),
|
|
273
|
+
note: z.string().optional(),
|
|
274
|
+
pairs: z.array(z.object({
|
|
275
|
+
sharedField: z.string(),
|
|
276
|
+
a: z.object({ id: z.string(), title: z.string(), weight: z.number() }),
|
|
277
|
+
b: z.object({ id: z.string(), title: z.string(), weight: z.number() }),
|
|
278
|
+
})),
|
|
279
|
+
},
|
|
280
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
281
|
+
}, async (args) => {
|
|
282
|
+
const { decisions, relationships } = await fetchGraph(ctx);
|
|
283
|
+
const linked = new Set();
|
|
284
|
+
for (const r of relationships) {
|
|
285
|
+
linked.add(`${r.fromId}|${r.toId}`);
|
|
286
|
+
linked.add(`${r.toId}|${r.fromId}`);
|
|
287
|
+
}
|
|
288
|
+
const ds = decisions.filter((d) => Array.isArray(d.fields) && d.fields.length > 0);
|
|
289
|
+
const pairs = [];
|
|
290
|
+
for (let i = 0; i < ds.length; i++) {
|
|
291
|
+
for (let j = i + 1; j < ds.length; j++) {
|
|
292
|
+
const A = ds[i];
|
|
293
|
+
const B = ds[j];
|
|
294
|
+
if (linked.has(`${A.id}|${B.id}`))
|
|
295
|
+
continue;
|
|
296
|
+
const fa = new Set(A.fields ?? []);
|
|
297
|
+
const shared = (B.fields ?? []).find((f) => fa.has(f) && (args.field ? f === args.field : true));
|
|
298
|
+
if (!shared)
|
|
299
|
+
continue;
|
|
300
|
+
pairs.push({
|
|
301
|
+
sharedField: shared,
|
|
302
|
+
a: { id: A.id, title: A.title ?? A.id, weight: A.weight ?? 0 },
|
|
303
|
+
b: { id: B.id, title: B.title ?? B.id, weight: B.weight ?? 0 },
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
pairs.sort((x, y) => (y.a.weight + y.b.weight) - (x.a.weight + x.b.weight));
|
|
308
|
+
const page = pairs.slice(0, args.limit ?? 20);
|
|
309
|
+
const structured = { count: page.length, pairs: page };
|
|
310
|
+
if (page.length === 0) {
|
|
311
|
+
structured.note = 'No field-level overlaps without a link were found (decisions may not be tagged with fields). This flags candidates only.';
|
|
312
|
+
}
|
|
313
|
+
return { content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }], structuredContent: structured };
|
|
314
|
+
});
|
|
315
|
+
server.registerTool('whygraph_recent_violations', {
|
|
316
|
+
title: 'Recent rule violations',
|
|
317
|
+
description: 'The most recent times decisions were violated — what broke, when, and which decision. The temporal signal the stats overview can’t give you. Read-only.',
|
|
318
|
+
inputSchema: { limit: z.number().int().min(1).max(50).optional().describe('How many recent violations (default 20).') },
|
|
319
|
+
outputSchema: {
|
|
320
|
+
count: z.number(),
|
|
321
|
+
violations: z.array(z.object({
|
|
322
|
+
id: z.string().optional(),
|
|
323
|
+
decisionId: z.string().optional(),
|
|
324
|
+
decisionTitle: z.string().optional(),
|
|
325
|
+
what: z.string().optional(),
|
|
326
|
+
result: z.string().optional(),
|
|
327
|
+
timestamp: z.string().optional(),
|
|
328
|
+
})),
|
|
329
|
+
},
|
|
330
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
331
|
+
}, async (args) => {
|
|
332
|
+
const limit = args.limit ?? 20;
|
|
333
|
+
const res = await api(ctx, `/api/whygraph/violations?limit=${limit}`);
|
|
334
|
+
const data = await expectJson(res, 'whygraph_recent_violations');
|
|
335
|
+
const violations = (data.violations ?? []).map((v) => ({
|
|
336
|
+
...(v.id != null ? { id: String(v.id) } : {}),
|
|
337
|
+
...(v.decisionId != null ? { decisionId: String(v.decisionId) } : {}),
|
|
338
|
+
...(v.decisionTitle != null ? { decisionTitle: String(v.decisionTitle) } : {}),
|
|
339
|
+
...(v.what != null ? { what: String(v.what) } : {}),
|
|
340
|
+
...(v.result != null ? { result: String(v.result) } : {}),
|
|
341
|
+
...(v.timestamp != null ? { timestamp: String(v.timestamp) } : {}),
|
|
342
|
+
}));
|
|
343
|
+
const structured = { count: violations.length, violations };
|
|
344
|
+
return { content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }], structuredContent: structured };
|
|
345
|
+
});
|
|
346
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface PortfolioNode {
|
|
2
|
+
id: string;
|
|
3
|
+
type?: string;
|
|
4
|
+
position?: {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
};
|
|
8
|
+
data?: {
|
|
9
|
+
parentItemId?: string;
|
|
10
|
+
linkedEpicIds?: string[];
|
|
11
|
+
hierarchyLevel?: string;
|
|
12
|
+
[k: string]: unknown;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface PortfolioEdge {
|
|
16
|
+
id: string;
|
|
17
|
+
source: string;
|
|
18
|
+
target: string;
|
|
19
|
+
[k: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
export declare function validateGraph(nodes: PortfolioNode[], edges: PortfolioEdge[]): string[];
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function validateGraph(nodes, edges) {
|
|
2
|
+
const errors = [];
|
|
3
|
+
if (!Array.isArray(nodes))
|
|
4
|
+
return ['nodes must be an array'];
|
|
5
|
+
if (!Array.isArray(edges))
|
|
6
|
+
return ['edges must be an array'];
|
|
7
|
+
const ids = new Set();
|
|
8
|
+
for (const n of nodes) {
|
|
9
|
+
if (!n.id)
|
|
10
|
+
errors.push(`node missing id: ${JSON.stringify(n).slice(0, 80)}`);
|
|
11
|
+
else if (ids.has(n.id))
|
|
12
|
+
errors.push(`duplicate node id: ${n.id}`);
|
|
13
|
+
else
|
|
14
|
+
ids.add(n.id);
|
|
15
|
+
}
|
|
16
|
+
const epicIds = new Set(nodes.filter((n) => n.data?.hierarchyLevel === 'epic').map((n) => n.id));
|
|
17
|
+
for (const n of nodes) {
|
|
18
|
+
const parent = n.data?.parentItemId;
|
|
19
|
+
if (parent && !ids.has(parent)) {
|
|
20
|
+
errors.push(`node ${n.id} has parentItemId ${parent} that does not exist`);
|
|
21
|
+
}
|
|
22
|
+
const linked = n.data?.linkedEpicIds;
|
|
23
|
+
if (Array.isArray(linked)) {
|
|
24
|
+
for (const epicId of linked) {
|
|
25
|
+
if (!epicIds.has(epicId)) {
|
|
26
|
+
errors.push(`node ${n.id} linkedEpicIds points to non-epic ${epicId}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const edgeIds = new Set();
|
|
32
|
+
for (const e of edges) {
|
|
33
|
+
if (!e.id)
|
|
34
|
+
errors.push('edge missing id');
|
|
35
|
+
else if (edgeIds.has(e.id))
|
|
36
|
+
errors.push(`duplicate edge id: ${e.id}`);
|
|
37
|
+
else
|
|
38
|
+
edgeIds.add(e.id);
|
|
39
|
+
if (!ids.has(e.source))
|
|
40
|
+
errors.push(`edge ${e.id} source ${e.source} not in nodes`);
|
|
41
|
+
if (!ids.has(e.target))
|
|
42
|
+
errors.push(`edge ${e.id} target ${e.target} not in nodes`);
|
|
43
|
+
}
|
|
44
|
+
return errors;
|
|
45
|
+
}
|
package/dist/stdio.d.ts
ADDED