@nonbot/mcp 0.2.4 → 0.3.1
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 +19 -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 +85 -0
- package/dist/core/layout.js +524 -6
- package/dist/core/mapHeaders.d.ts +13 -0
- package/dist/core/mapHeaders.js +64 -0
- package/dist/core/preview.d.ts +18 -0
- package/dist/core/preview.js +81 -0
- package/dist/core/releases.d.ts +159 -0
- package/dist/core/releases.js +313 -0
- package/dist/core/satellites.d.ts +54 -0
- package/dist/core/satellites.js +89 -0
- package/dist/core/server.d.ts +1 -1
- package/dist/core/server.js +15 -4
- package/dist/core/style.d.ts +34 -0
- package/dist/core/style.js +76 -0
- package/dist/core/toolsets/build.d.ts +23 -6
- package/dist/core/toolsets/build.js +177 -19
- 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,89 @@
|
|
|
1
|
+
export const INNER_LOOP_GAP = 16;
|
|
2
|
+
export const INNER_LOOP_Y_OFFSET = 40;
|
|
3
|
+
export const INNER_LOOP_COLLAPSED_HEIGHT = 44;
|
|
4
|
+
export const INNER_LOOP_FIELDS = [
|
|
5
|
+
'epicColor', 'nodeDisplay', 'artifact', 'whyGraph', 'description', 'hypothesisStatement', 'acceptanceCriteria', 'bluf',
|
|
6
|
+
'lbcProblem', 'lbcBenefitHypothesis', 'lbcLeadingIndicators', 'lbcNfrs', 'mvpDefinition', 'outcomeMetrics', 'owner',
|
|
7
|
+
'tags', 'confidenceVote', 'approvalRating', 'mockupSvg', 'character', 'location',
|
|
8
|
+
];
|
|
9
|
+
export const SATELLITE_FIELDS = {
|
|
10
|
+
epicColor: { label: 'Color', width: 120, height: 100, retired: true },
|
|
11
|
+
nodeDisplay: { label: 'Display', width: 520, height: 180 },
|
|
12
|
+
artifact: { label: 'Statement', width: 300, height: 260 },
|
|
13
|
+
whyGraph: { label: 'WhyGraph', width: 260, height: 120, levels: ['epic', 'feature', 'story', 'enabler', 'objective', 'key-result'] },
|
|
14
|
+
description: { label: 'Description', width: 180, height: 120 },
|
|
15
|
+
hypothesisStatement: { label: 'Hypothesis', width: 180, height: 120, levels: ['epic', 'objective'] },
|
|
16
|
+
acceptanceCriteria: { label: 'Acceptance Criteria', width: 180, height: 120 },
|
|
17
|
+
bluf: { label: 'BLUF', width: 160, height: 110 },
|
|
18
|
+
lbcProblem: { label: 'LBC: Problem', width: 170, height: 120, levels: ['epic'] },
|
|
19
|
+
lbcBenefitHypothesis: { label: 'Benefit Hypothesis', width: 170, height: 120, levels: ['epic'] },
|
|
20
|
+
lbcLeadingIndicators: { label: 'Leading Indicators', width: 170, height: 120, levels: ['epic'] },
|
|
21
|
+
lbcNfrs: { label: 'NFRs', width: 150, height: 110, levels: ['epic'] },
|
|
22
|
+
mvpDefinition: { label: 'MVP Definition', width: 170, height: 120, levels: ['epic'] },
|
|
23
|
+
outcomeMetrics: { label: 'Outcome Metrics', width: 170, height: 120, levels: ['epic', 'objective', 'key-result'] },
|
|
24
|
+
owner: { label: 'Owner', width: 130, height: 80 },
|
|
25
|
+
tags: { label: 'Tags', width: 140, height: 80 },
|
|
26
|
+
confidenceVote: { label: 'Confidence', width: 120, height: 80 },
|
|
27
|
+
approvalRating: { label: 'Approval', width: 120, height: 80 },
|
|
28
|
+
mockupSvg: { label: 'Wireframe', width: 200, height: 160 },
|
|
29
|
+
character: { label: 'Character', width: 150, height: 160, levels: ['epic'] },
|
|
30
|
+
location: { label: 'Location', width: 150, height: 160, levels: ['epic'] },
|
|
31
|
+
};
|
|
32
|
+
const DEFAULT_FIELDS = ['nodeDisplay', 'artifact', 'description'];
|
|
33
|
+
const DEFAULT_FIELDS_BY_LEVEL = {
|
|
34
|
+
enabler: ['nodeDisplay', 'artifact', 'description', 'owner'],
|
|
35
|
+
milestone: ['nodeDisplay', 'artifact', 'description', 'owner', 'tags'],
|
|
36
|
+
objective: ['nodeDisplay', 'artifact', 'description', 'outcomeMetrics', 'owner'],
|
|
37
|
+
'key-result': ['nodeDisplay', 'artifact', 'outcomeMetrics', 'description', 'owner'],
|
|
38
|
+
};
|
|
39
|
+
export const HOST_DEFAULT_HEIGHT = { epic: 120, feature: 110, story: 100, enabler: 100, milestone: 120, objective: 130, 'key-result': 110 };
|
|
40
|
+
const HOST_LEVELS = new Set(Object.keys(HOST_DEFAULT_HEIGHT));
|
|
41
|
+
export const canHostInnerLoop = (d) => !!d && !d.childCardType && HOST_LEVELS.has(String(d.hierarchyLevel ?? ''));
|
|
42
|
+
const allowed = (f, level) => {
|
|
43
|
+
const cfg = SATELLITE_FIELDS[f];
|
|
44
|
+
if (!cfg || cfg.retired)
|
|
45
|
+
return false;
|
|
46
|
+
return !cfg.levels || cfg.levels.includes(level);
|
|
47
|
+
};
|
|
48
|
+
export function activeSatelliteFields(d) {
|
|
49
|
+
if (!d || !canHostInnerLoop(d))
|
|
50
|
+
return [];
|
|
51
|
+
const level = String(d.hierarchyLevel);
|
|
52
|
+
const removed = new Set(Array.isArray(d.innerLoopRemovedFields) ? d.innerLoopRemovedFields : []);
|
|
53
|
+
const extras = Array.isArray(d.innerLoopExtraFields) ? d.innerLoopExtraFields : [];
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const f of [...(DEFAULT_FIELDS_BY_LEVEL[level] ?? DEFAULT_FIELDS), ...extras]) {
|
|
56
|
+
if (!allowed(f, level) || removed.has(f) || out.includes(f))
|
|
57
|
+
continue;
|
|
58
|
+
out.push(f);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
export function satelliteItems(d) {
|
|
63
|
+
const sizes = d?.innerLoopSizes ?? {};
|
|
64
|
+
return activeSatelliteFields(d).map(f => ({
|
|
65
|
+
field: f,
|
|
66
|
+
width: sizes[f]?.width || SATELLITE_FIELDS[f].width,
|
|
67
|
+
height: d?.subnodeCollapsed?.[f] ? INNER_LOOP_COLLAPSED_HEIGHT : sizes[f]?.height || SATELLITE_FIELDS[f].height,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
export const openSatellites = (d) => (d?.innerLoopExpanded === true ? satelliteItems(d) : []);
|
|
71
|
+
export function satelliteRowWidth(d) {
|
|
72
|
+
const items = openSatellites(d);
|
|
73
|
+
return items.length ? items.reduce((s, i) => s + i.width, 0) + (items.length - 1) * INNER_LOOP_GAP : 0;
|
|
74
|
+
}
|
|
75
|
+
export function satelliteBandHeight(d, hostHeight) {
|
|
76
|
+
const items = openSatellites(d);
|
|
77
|
+
if (!items.length)
|
|
78
|
+
return 0;
|
|
79
|
+
const h = hostHeight && hostHeight > 0 ? hostHeight : HOST_DEFAULT_HEIGHT[String(d?.hierarchyLevel)] ?? 120;
|
|
80
|
+
return h + INNER_LOOP_Y_OFFSET + Math.max(...items.map(i => i.height));
|
|
81
|
+
}
|
|
82
|
+
export function satellitePositions(d, host) {
|
|
83
|
+
const items = openSatellites(d);
|
|
84
|
+
if (!items.length)
|
|
85
|
+
return [];
|
|
86
|
+
let x = host.x + host.width / 2 - satelliteRowWidth(d) / 2;
|
|
87
|
+
const y = host.y + host.height + INNER_LOOP_Y_OFFSET;
|
|
88
|
+
return items.map(i => { const p = { field: i.field, x, y, width: i.width, height: i.height }; x += i.width + INNER_LOOP_GAP; return p; });
|
|
89
|
+
}
|
package/dist/core/server.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import type { ApiContext } from './config.js';
|
|
3
3
|
export declare const SERVER_NAME = "nonbot-mcp";
|
|
4
|
-
export declare const SERVER_VERSION = "0.
|
|
4
|
+
export declare const SERVER_VERSION = "0.3.1";
|
|
5
5
|
export declare const INSTRUCTIONS: string;
|
|
6
6
|
export declare function registerAll(server: McpServer, ctx: ApiContext): void;
|
|
7
7
|
export declare function createNonbotMcpServer(ctx: ApiContext): McpServer;
|
package/dist/core/server.js
CHANGED
|
@@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
2
2
|
import { registerPortfolioTools } from './toolsets/portfolio.js';
|
|
3
3
|
import { registerIpTools } from './toolsets/ip.js';
|
|
4
4
|
import { registerBuildTools } from './toolsets/build.js';
|
|
5
|
+
import { registerReleaseTools } from './toolsets/release.js';
|
|
5
6
|
import { registerWhygraphTools } from './toolsets/whygraph.js';
|
|
6
7
|
import { registerRunTools } from './toolsets/run.js';
|
|
7
8
|
import { registerMonitorTools } from './toolsets/monitor.js';
|
|
@@ -9,16 +10,20 @@ import { registerRadbugsTools } from './toolsets/radbugs.js';
|
|
|
9
10
|
import { registerResources } from './resources.js';
|
|
10
11
|
import { registerPrompts } from './prompts.js';
|
|
11
12
|
export const SERVER_NAME = 'nonbot-mcp';
|
|
12
|
-
export const SERVER_VERSION = '0.
|
|
13
|
+
export const SERVER_VERSION = '0.3.1';
|
|
13
14
|
export const INSTRUCTIONS = [
|
|
14
15
|
'non.bot MCP — work against the caller’s real product-management data, not a copy.',
|
|
15
16
|
'Two domains: PORTFOLIOS (the SAFe portfolio canvas — epics → features → stories) and',
|
|
16
17
|
'WHYGRAPH (the PM decision graph — the rules and decisions the team has committed to).',
|
|
17
18
|
'Read before you write: call the *_list / *_get / *_search tools first.',
|
|
18
19
|
'BUILDING: portfolio_create makes an empty portfolio; portfolio_add_items adds items of any level',
|
|
19
|
-
'(parents / OKR links / dependencies by id or by a key from the same call) and lays the
|
|
20
|
-
'portfolio_patch_item changes one item; portfolio_layout re-lays everything
|
|
20
|
+
'(parents / OKR links / dependencies by id or by a key from the same call) and lays it out in the Full Stack rows;',
|
|
21
|
+
'portfolio_patch_item changes one item; portfolio_layout re-lays everything (style packed / fullstack, or lane',
|
|
22
|
+
'for the releases); portfolio_set_meta sets',
|
|
21
23
|
'context text and palette; portfolio_asset_upload stores an image; ip_create adds an IP node over its sources.',
|
|
24
|
+
'OWNERS: give every feature, story, enabler and milestone an owner so the human can see what is theirs —',
|
|
25
|
+
'the human’s name for work only a person can do (review or approve a plan, decide, buy, sign in, hand over a key,',
|
|
26
|
+
'test on a real device), owner "AI" for work an agent delivers (code, tests, migrations, docs, research).',
|
|
22
27
|
'Whole-graph rewrites (restructures, deletes): portfolio_get for the graph + version, then portfolio_update',
|
|
23
28
|
'(pass expectedVersion for safe concurrent editing).',
|
|
24
29
|
'Before a non-trivial decision, call whygraph_context to surface the LAWs and prior',
|
|
@@ -29,11 +34,17 @@ export const INSTRUCTIONS = [
|
|
|
29
34
|
'tokens with the explicit runs:respond scope only).',
|
|
30
35
|
'IP NODES: ip_list / ip_get read a patent or trademark disclosure; ip_update edits it (op list + expected_version);',
|
|
31
36
|
'ip_evidence_add adds evidence the SERVER hashes, timestamps and numbers — never send a hash or a time.',
|
|
32
|
-
'
|
|
37
|
+
'RELEASES: portfolio_completed_work says what got done in a window (rolled up, each date naming its clock);',
|
|
38
|
+
'portfolio_rebuild_done_work finds pointers (commits, closed radBUGS, comments) for done cards with no clock;',
|
|
39
|
+
'portfolio_propose_releases writes DRAFT releases (files nothing) and portfolio_release_approve files a draft\u2019s cards;',
|
|
40
|
+
'portfolio_release_create / _file / _unfile / _list manage releases directly. Filing never moves a card.',
|
|
41
|
+
'RADBUGS: radbug_file files a bug into the non.bot radBUGS inbox (needs the radbugs:write scope); radbug_list reads',
|
|
42
|
+
'them (site owner only).',
|
|
33
43
|
].join(' ');
|
|
34
44
|
export function registerAll(server, ctx) {
|
|
35
45
|
registerPortfolioTools(server, ctx);
|
|
36
46
|
registerBuildTools(server, ctx);
|
|
47
|
+
registerReleaseTools(server, ctx);
|
|
37
48
|
registerIpTools(server, ctx);
|
|
38
49
|
registerWhygraphTools(server, ctx);
|
|
39
50
|
registerRunTools(server, ctx);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type Level, type LayoutNode } from './layout.js';
|
|
2
|
+
import type { InnerLoopField } from './satellites.js';
|
|
3
|
+
export declare const CARD_STYLES: readonly ["plain", "poster", "split", "image-top", "cover", "grid-2", "grid-4"];
|
|
4
|
+
export declare const TITLE_SIZES: readonly ["sm", "md", "lg"];
|
|
5
|
+
export declare const ACCENTS: readonly ["tint", "bar", "plain"];
|
|
6
|
+
export declare const SHOW_KEYS: readonly ["bluf", "description", "acceptanceCriteria", "tags", "dates", "wsjf"];
|
|
7
|
+
export declare const STATES: readonly ["funnel", "reviewing", "analyzing", "ready", "implementing", "done"];
|
|
8
|
+
export interface StyleRule {
|
|
9
|
+
levels?: Level[];
|
|
10
|
+
states?: string[];
|
|
11
|
+
ids?: string[];
|
|
12
|
+
hasImage?: boolean;
|
|
13
|
+
artifacts?: boolean;
|
|
14
|
+
display?: {
|
|
15
|
+
cardStyle?: (typeof CARD_STYLES)[number];
|
|
16
|
+
titleSize?: (typeof TITLE_SIZES)[number];
|
|
17
|
+
accent?: (typeof ACCENTS)[number];
|
|
18
|
+
show?: Partial<Record<(typeof SHOW_KEYS)[number], boolean>>;
|
|
19
|
+
};
|
|
20
|
+
size?: {
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
} | 'auto';
|
|
24
|
+
satellites?: 'open' | 'close';
|
|
25
|
+
innerLoopRemovedFields?: InnerLoopField[];
|
|
26
|
+
epicColor?: string | null;
|
|
27
|
+
}
|
|
28
|
+
export interface StyleResult<T> {
|
|
29
|
+
nodes: T[];
|
|
30
|
+
matched: number;
|
|
31
|
+
changed: number;
|
|
32
|
+
perRule: number[];
|
|
33
|
+
}
|
|
34
|
+
export declare function applyStyleRules<T extends LayoutNode>(nodes: T[], rules: StyleRule[]): StyleResult<T>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { LEVELS } from './layout.js';
|
|
2
|
+
export const CARD_STYLES = ['plain', 'poster', 'split', 'image-top', 'cover', 'grid-2', 'grid-4'];
|
|
3
|
+
export const TITLE_SIZES = ['sm', 'md', 'lg'];
|
|
4
|
+
export const ACCENTS = ['tint', 'bar', 'plain'];
|
|
5
|
+
export const SHOW_KEYS = ['bluf', 'description', 'acceptanceCriteria', 'tags', 'dates', 'wsjf'];
|
|
6
|
+
export const STATES = ['funnel', 'reviewing', 'analyzing', 'ready', 'implementing', 'done'];
|
|
7
|
+
const hasImage = (n) => Array.isArray(n.data?.pastedImages) && n.data.pastedImages.length > 0;
|
|
8
|
+
function matches(rule, n) {
|
|
9
|
+
if (n.type !== 'portfolio-item')
|
|
10
|
+
return false;
|
|
11
|
+
if (n.data?.childCardType && !rule.artifacts)
|
|
12
|
+
return false;
|
|
13
|
+
const level = LEVELS.includes(n.data?.hierarchyLevel) ? n.data.hierarchyLevel : 'story';
|
|
14
|
+
if (rule.levels && !rule.levels.includes(level))
|
|
15
|
+
return false;
|
|
16
|
+
if (rule.states && !rule.states.includes(String(n.data?.state ?? 'funnel')))
|
|
17
|
+
return false;
|
|
18
|
+
if (rule.ids && !rule.ids.includes(n.id))
|
|
19
|
+
return false;
|
|
20
|
+
if (rule.hasImage !== undefined && hasImage(n) !== rule.hasImage)
|
|
21
|
+
return false;
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
export function applyStyleRules(nodes, rules) {
|
|
25
|
+
const perRule = rules.map(() => 0);
|
|
26
|
+
const touched = new Set();
|
|
27
|
+
let changed = 0;
|
|
28
|
+
const out = nodes.map(n => {
|
|
29
|
+
let data = null;
|
|
30
|
+
rules.forEach((rule, i) => {
|
|
31
|
+
if (!matches(rule, n))
|
|
32
|
+
return;
|
|
33
|
+
perRule[i]++;
|
|
34
|
+
touched.add(n.id);
|
|
35
|
+
data ??= { ...(n.data ?? {}) };
|
|
36
|
+
if (rule.display) {
|
|
37
|
+
const prev = (data.nodeDisplay && typeof data.nodeDisplay === 'object') ? data.nodeDisplay : {};
|
|
38
|
+
const next = { ...prev };
|
|
39
|
+
if (rule.display.cardStyle)
|
|
40
|
+
next.cardStyle = rule.display.cardStyle;
|
|
41
|
+
if (rule.display.titleSize)
|
|
42
|
+
next.titleSize = rule.display.titleSize;
|
|
43
|
+
if (rule.display.accent)
|
|
44
|
+
next.accent = rule.display.accent;
|
|
45
|
+
if (rule.display.show)
|
|
46
|
+
next.show = { ...(prev.show ?? {}), ...rule.display.show };
|
|
47
|
+
data.nodeDisplay = next;
|
|
48
|
+
}
|
|
49
|
+
if (rule.size === 'auto')
|
|
50
|
+
delete data.canvasSize;
|
|
51
|
+
else if (rule.size)
|
|
52
|
+
data.canvasSize = { width: Math.round(rule.size.width), height: Math.round(rule.size.height) };
|
|
53
|
+
if (rule.satellites === 'close')
|
|
54
|
+
data.innerLoopExpanded = false;
|
|
55
|
+
else if (rule.satellites === 'open')
|
|
56
|
+
data.innerLoopExpanded = true;
|
|
57
|
+
if (rule.innerLoopRemovedFields)
|
|
58
|
+
data.innerLoopRemovedFields = [...new Set(rule.innerLoopRemovedFields)];
|
|
59
|
+
if (rule.epicColor === null) {
|
|
60
|
+
delete data.epicColor;
|
|
61
|
+
delete data.epicColorRef;
|
|
62
|
+
}
|
|
63
|
+
else if (rule.epicColor) {
|
|
64
|
+
data.epicColor = rule.epicColor.toUpperCase();
|
|
65
|
+
delete data.epicColorRef;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
if (!data)
|
|
69
|
+
return n;
|
|
70
|
+
if (JSON.stringify(data) === JSON.stringify(n.data ?? {}))
|
|
71
|
+
return n;
|
|
72
|
+
changed++;
|
|
73
|
+
return { ...n, data };
|
|
74
|
+
});
|
|
75
|
+
return { nodes: out, matched: touched.size, changed, perRule };
|
|
76
|
+
}
|
|
@@ -2,18 +2,19 @@ import { z } from 'zod';
|
|
|
2
2
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import type { ApiContext } from '../config.js';
|
|
4
4
|
import { type LayoutNode } from '../layout.js';
|
|
5
|
-
type
|
|
5
|
+
import { type InnerLoopField } from '../satellites.js';
|
|
6
|
+
export type Node = LayoutNode & {
|
|
6
7
|
id: string;
|
|
7
8
|
type?: string;
|
|
8
9
|
data?: Record<string, any>;
|
|
9
10
|
};
|
|
10
|
-
type Edge = {
|
|
11
|
+
export type Edge = {
|
|
11
12
|
id: string;
|
|
12
13
|
source: string;
|
|
13
14
|
target: string;
|
|
14
15
|
[k: string]: unknown;
|
|
15
16
|
};
|
|
16
|
-
interface Graph {
|
|
17
|
+
export interface Graph {
|
|
17
18
|
name: string;
|
|
18
19
|
description: string;
|
|
19
20
|
nodes: Node[];
|
|
@@ -23,15 +24,23 @@ interface Graph {
|
|
|
23
24
|
}
|
|
24
25
|
export declare const toHtml: (s: string) => string;
|
|
25
26
|
export declare function newItemId(now?: number): string;
|
|
27
|
+
export declare function loadGraph(ctx: ApiContext, id: string): Promise<Graph>;
|
|
28
|
+
export declare function saveGraph(ctx: ApiContext, id: string, before: Graph, after: Graph, expectedVersion?: number): Promise<{
|
|
29
|
+
ok: true;
|
|
30
|
+
version?: number;
|
|
31
|
+
} | {
|
|
32
|
+
ok: false;
|
|
33
|
+
message: string;
|
|
34
|
+
}>;
|
|
26
35
|
declare const itemSpec: z.ZodObject<{
|
|
27
36
|
key: z.ZodOptional<z.ZodString>;
|
|
28
37
|
level: z.ZodEnum<{
|
|
29
38
|
epic: "epic";
|
|
30
|
-
objective: "objective";
|
|
31
|
-
"key-result": "key-result";
|
|
32
39
|
feature: "feature";
|
|
33
40
|
story: "story";
|
|
34
41
|
enabler: "enabler";
|
|
42
|
+
objective: "objective";
|
|
43
|
+
"key-result": "key-result";
|
|
35
44
|
milestone: "milestone";
|
|
36
45
|
}>;
|
|
37
46
|
title: z.ZodString;
|
|
@@ -67,7 +76,15 @@ declare const itemSpec: z.ZodObject<{
|
|
|
67
76
|
fields: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
68
77
|
}, z.core.$strip>;
|
|
69
78
|
type ItemSpec = z.infer<typeof itemSpec>;
|
|
70
|
-
export declare
|
|
79
|
+
export declare const AI_OWNER = "AI";
|
|
80
|
+
export declare function ownerReport(specs: Array<Pick<ItemSpec, 'key' | 'level' | 'title' | 'owner'>>): {
|
|
81
|
+
owners: Record<string, number>;
|
|
82
|
+
unassigned: string[];
|
|
83
|
+
};
|
|
84
|
+
export declare const ADD_ITEMS_DEFAULT_LAYOUT: "fullstack";
|
|
85
|
+
export type AddLayout = 'fullstack' | 'timeline' | 'none';
|
|
86
|
+
export declare const FULLSTACK_FEATURE_REMOVED: InnerLoopField[];
|
|
87
|
+
export declare function buildItems(g: Graph, specs: ItemSpec[], now?: Date, layout?: AddLayout): {
|
|
71
88
|
nodes: Node[];
|
|
72
89
|
edges: Edge[];
|
|
73
90
|
ids: Record<string, string>;
|
|
@@ -1,8 +1,13 @@
|
|
|
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, layoutFullStack, applyFullStackTimeline, timelineRowOverlaps, mapBoxes, scheduleFields, todayColumn, columnOf, isItem, PX_PER_WEEK } from '../layout.js';
|
|
5
|
+
import { INNER_LOOP_FIELDS } from '../satellites.js';
|
|
6
|
+
import { layoutReleaseLane } from '../releases.js';
|
|
5
7
|
import { emptyIp, IP_KINDS, IP_NODE_SIZE } from '../ipShape.js';
|
|
8
|
+
import { withMapHeaders } from '../mapHeaders.js';
|
|
9
|
+
import { renderPreviewSvg } from '../preview.js';
|
|
10
|
+
import { applyStyleRules, CARD_STYLES, TITLE_SIZES, ACCENTS, SHOW_KEYS, STATES as STYLE_STATES } from '../style.js';
|
|
6
11
|
const STATES = ['funnel', 'reviewing', 'analyzing', 'ready', 'implementing', 'done'];
|
|
7
12
|
const OKR_STATUSES = ['on-track', 'at-risk', 'behind'];
|
|
8
13
|
const RESERVED = new Set(['hierarchyLevel', 'parentItemId', 'linkedEpicIds', 'id', '__proto__', 'constructor', 'prototype']);
|
|
@@ -13,7 +18,7 @@ const fail = (msg) => ({ content: [{ type: 'text', text: msg }], isError: true }
|
|
|
13
18
|
const escHtml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
14
19
|
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
20
|
export function newItemId(now = Date.now()) { return `pf-${now}-${Math.random().toString(36).slice(2, 11)}`; }
|
|
16
|
-
async function loadGraph(ctx, id) {
|
|
21
|
+
export async function loadGraph(ctx, id) {
|
|
17
22
|
const data = await expectJson(await api(ctx, `/api/portfolio/graph?id=${encodeURIComponent(id)}`), 'load portfolio');
|
|
18
23
|
const g = (data.graph ?? data);
|
|
19
24
|
return {
|
|
@@ -25,7 +30,7 @@ async function loadGraph(ctx, id) {
|
|
|
25
30
|
version: typeof g.version === 'number' ? g.version : undefined,
|
|
26
31
|
};
|
|
27
32
|
}
|
|
28
|
-
async function saveGraph(ctx, id, before, after, expectedVersion) {
|
|
33
|
+
export async function saveGraph(ctx, id, before, after, expectedVersion) {
|
|
29
34
|
const problems = validateGraph(after.nodes, after.edges);
|
|
30
35
|
if (problems.length)
|
|
31
36
|
return { ok: false, message: `Refusing to write — ${problems.length} problem(s):\n` + problems.map(p => ` - ${p}`).join('\n') };
|
|
@@ -63,7 +68,7 @@ const itemSpec = z.object({
|
|
|
63
68
|
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
69
|
tags: z.array(z.string().max(40)).max(20).optional(),
|
|
65
70
|
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(),
|
|
71
|
+
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
72
|
kr_target: z.string().max(200).optional(),
|
|
68
73
|
kr_current: z.string().max(200).optional(),
|
|
69
74
|
okr_status: z.enum(OKR_STATUSES).optional(),
|
|
@@ -74,7 +79,23 @@ const DEFAULT_WEEKS = { epic: 8, feature: 4, story: 1, enabler: 2, milestone: 0,
|
|
|
74
79
|
const LEGAL_PARENT = {
|
|
75
80
|
epic: [], objective: [], 'key-result': ['objective'], feature: ['epic'], story: ['feature', 'epic'], enabler: ['epic', 'feature'], milestone: ['epic', 'feature'],
|
|
76
81
|
};
|
|
77
|
-
export
|
|
82
|
+
export const AI_OWNER = 'AI';
|
|
83
|
+
const WORK_LEVELS = new Set(['feature', 'story', 'enabler', 'milestone']);
|
|
84
|
+
export function ownerReport(specs) {
|
|
85
|
+
const owners = {};
|
|
86
|
+
const unassigned = [];
|
|
87
|
+
for (const s of specs) {
|
|
88
|
+
const who = s.owner?.trim();
|
|
89
|
+
if (who)
|
|
90
|
+
owners[who] = (owners[who] ?? 0) + 1;
|
|
91
|
+
else if (WORK_LEVELS.has(s.level))
|
|
92
|
+
unassigned.push(s.key ?? s.title);
|
|
93
|
+
}
|
|
94
|
+
return { owners, unassigned };
|
|
95
|
+
}
|
|
96
|
+
export const ADD_ITEMS_DEFAULT_LAYOUT = 'fullstack';
|
|
97
|
+
export const FULLSTACK_FEATURE_REMOVED = ['description', 'acceptanceCriteria'];
|
|
98
|
+
export function buildItems(g, specs, now = new Date(), layout = ADD_ITEMS_DEFAULT_LAYOUT) {
|
|
78
99
|
const errors = [];
|
|
79
100
|
const ids = {};
|
|
80
101
|
const t0 = now.getTime();
|
|
@@ -112,8 +133,8 @@ export function buildItems(g, specs, now = new Date()) {
|
|
|
112
133
|
d.hypothesisStatement = s.hypothesis;
|
|
113
134
|
if (s.tags)
|
|
114
135
|
d.tags = s.tags;
|
|
115
|
-
if (s.owner)
|
|
116
|
-
d.owner = s.owner;
|
|
136
|
+
if (s.owner?.trim())
|
|
137
|
+
d.owner = s.owner.trim();
|
|
117
138
|
if (s.wsjf)
|
|
118
139
|
Object.assign(d, { wsjfBusinessValue: s.wsjf[0], wsjfTimeCriticality: s.wsjf[1], wsjfRiskReduction: s.wsjf[2], wsjfJobSize: s.wsjf[3] });
|
|
119
140
|
if (s.kr_target)
|
|
@@ -124,7 +145,13 @@ export function buildItems(g, specs, now = new Date()) {
|
|
|
124
145
|
d.okrStatus = s.okr_status;
|
|
125
146
|
if (s.images?.length)
|
|
126
147
|
d.pastedImages = s.images;
|
|
127
|
-
if (
|
|
148
|
+
if (layout === 'fullstack') {
|
|
149
|
+
if (s.level === 'epic' || s.level === 'feature')
|
|
150
|
+
d.innerLoopExpanded = true;
|
|
151
|
+
if (s.level === 'feature')
|
|
152
|
+
d.innerLoopRemovedFields = [...FULLSTACK_FEATURE_REMOVED];
|
|
153
|
+
}
|
|
154
|
+
else if (s.level === 'epic')
|
|
128
155
|
d.innerLoopExpanded = false;
|
|
129
156
|
for (const [k, v] of Object.entries(s.fields ?? {})) {
|
|
130
157
|
if (RESERVED.has(k))
|
|
@@ -166,6 +193,21 @@ export function buildItems(g, specs, now = new Date()) {
|
|
|
166
193
|
return { nodes, edges, ids, errors, patched };
|
|
167
194
|
}
|
|
168
195
|
export function registerBuildTools(server, ctx) {
|
|
196
|
+
server.registerTool('portfolio_preview', {
|
|
197
|
+
title: 'Picture a portfolio layout',
|
|
198
|
+
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. A card whose inner loop is open draws its satellite band under it at the app\'s satellite sizes, so the picture shows the real height.',
|
|
199
|
+
inputSchema: {
|
|
200
|
+
portfolio_id: z.string(),
|
|
201
|
+
view: z.enum(['map', 'timeline']).optional(),
|
|
202
|
+
sizes: z.record(z.string(), z.object({ width: z.number().min(1).max(8000), height: z.number().min(1).max(8000) })).optional(),
|
|
203
|
+
width: z.number().int().min(200).max(8000).optional(),
|
|
204
|
+
},
|
|
205
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
206
|
+
}, async (a) => {
|
|
207
|
+
const g = await loadGraph(ctx, a.portfolio_id);
|
|
208
|
+
const r = renderPreviewSvg(g.nodes, { view: a.view, sizes: a.sizes, width: a.width });
|
|
209
|
+
return { content: [{ type: 'text', text: JSON.stringify({ cards: r.cards, satellites: r.satellites, extent: r.extent }) }, { type: 'text', text: r.svg }] };
|
|
210
|
+
});
|
|
169
211
|
if (ctx.readonly)
|
|
170
212
|
return;
|
|
171
213
|
server.registerTool('portfolio_create', {
|
|
@@ -193,11 +235,11 @@ export function registerBuildTools(server, ctx) {
|
|
|
193
235
|
});
|
|
194
236
|
server.registerTool('portfolio_add_items', {
|
|
195
237
|
title: 'Add items to a portfolio',
|
|
196
|
-
description: 'Add up to 200 items of any level (objective, key-result, epic, feature, story, enabler, milestone) in one versioned write. Parents, OKR links and dependencies may name existing ids OR `key`s of other items in the same call, so a whole tree goes in at once. The tool makes the ids, the hierarchy / okr-link / finish-to-start edges, the dates and sprint labels from start_week + duration_weeks, and
|
|
238
|
+
description: 'Add up to 200 items of any level (objective, key-result, epic, feature, story, enabler, milestone) in one versioned write. Parents, OKR links and dependencies may name existing ids OR `key`s of other items in the same call, so a whole tree goes in at once. The tool makes the ids, the hierarchy / okr-link / finish-to-start edges, the dates and sprint labels from start_week + duration_weeks, and lays the whole portfolio out — by default in the Full Stack shape (five rows: objectives, key results, epics, features, milestones + stories; each key result above its epic; new epics and features open their satellites). Returns key → id. Rich text fields accept plain text or HTML.',
|
|
197
239
|
inputSchema: {
|
|
198
240
|
portfolio_id: z.string(),
|
|
199
241
|
items: z.array(itemSpec).min(1).max(MAX_ITEMS),
|
|
200
|
-
layout: z.enum(['timeline', 'none']).optional().describe('Default
|
|
242
|
+
layout: z.enum(['fullstack', 'timeline', 'none']).optional().describe('Default "fullstack": every item gets its Full Stack row (Timeline y — x stays its date) and Map position (x and y); new epics + features open their satellites, a feature showing Display + Statement. "timeline": the packed Timeline lanes (epics closed). "none": new items get their date x and y 0.'),
|
|
201
243
|
expected_version: z.number().int().optional().describe('Optional OCC guard; default = the version read at call time.'),
|
|
202
244
|
},
|
|
203
245
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -206,22 +248,32 @@ export function registerBuildTools(server, ctx) {
|
|
|
206
248
|
const keys = a.items.map(s => s.key).filter(Boolean);
|
|
207
249
|
if (new Set(keys).size !== keys.length)
|
|
208
250
|
return fail('Two items share a key — keys must be unique within one call.');
|
|
209
|
-
const
|
|
251
|
+
const layout = a.layout ?? ADD_ITEMS_DEFAULT_LAYOUT;
|
|
252
|
+
const built = buildItems(g, a.items, new Date(), layout);
|
|
210
253
|
if (built.errors.length)
|
|
211
254
|
return fail(`Nothing was written — ${built.errors.length} problem(s):\n` + built.errors.map(e => ` - ${e}`).join('\n'));
|
|
212
255
|
let nodes = [...g.nodes.map(n => built.patched.has(n.id) ? { ...n, data: built.patched.get(n.id) } : n), ...built.nodes];
|
|
213
|
-
|
|
256
|
+
const edges = [...g.edges, ...built.edges];
|
|
257
|
+
if (layout === 'timeline')
|
|
214
258
|
nodes = applyLayout(nodes, layoutTimeline(nodes));
|
|
215
|
-
|
|
259
|
+
else if (layout === 'fullstack') {
|
|
260
|
+
const fs = layoutFullStack(nodes, { edges: edges });
|
|
261
|
+
nodes = applyMapLayout(applyFullStackTimeline(nodes, fs), fs);
|
|
262
|
+
}
|
|
263
|
+
const after = { ...g, nodes, edges };
|
|
216
264
|
const r = await saveGraph(ctx, a.portfolio_id, g, after, a.expected_version);
|
|
217
265
|
if (!r.ok)
|
|
218
266
|
return fail(r.message);
|
|
219
|
-
const
|
|
220
|
-
|
|
267
|
+
const who = ownerReport(a.items);
|
|
268
|
+
const structured = { ok: true, added: built.nodes.length, edges: built.edges.length, ids: built.ids, layout, owners: who.owners, unassigned: who.unassigned, ...(r.version !== undefined ? { version: r.version } : {}) };
|
|
269
|
+
const content = [text(structured)];
|
|
270
|
+
if (who.unassigned.length)
|
|
271
|
+
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.` });
|
|
272
|
+
return { content, structuredContent: structured };
|
|
221
273
|
});
|
|
222
274
|
server.registerTool('portfolio_patch_item', {
|
|
223
275
|
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.',
|
|
276
|
+
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
277
|
inputSchema: {
|
|
226
278
|
portfolio_id: z.string(),
|
|
227
279
|
item_id: z.string(),
|
|
@@ -247,23 +299,129 @@ export function registerBuildTools(server, ctx) {
|
|
|
247
299
|
return { content: [text(structured)], structuredContent: structured };
|
|
248
300
|
});
|
|
249
301
|
server.registerTool('portfolio_layout', {
|
|
250
|
-
title: 'Lay out a portfolio
|
|
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.',
|
|
302
|
+
title: 'Lay out a portfolio',
|
|
303
|
+
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. `style` picks the shape (default "packed" = everything above): "fullstack" = the owner\'s Full Stack reference, five rows whatever the size — objective 0 · key result 400 · epic 600 · feature 1040 · milestone 1320 · stories + enablers 1480; each key result above its epic, each epic column at least as wide as its open satellite row, the layout widening right, never taller — on the Timeline it sets y ONLY (x is the date, and dates never move; the result counts `timeline_overlaps`, cards sharing a row in the same weeks), with view "map" it sets x and y; "lane" = every release left → right by its from date, evenly spaced, the same gap between neighbours, in one Map row below the lowest card (satellites included) — Map positions (`freePosition`) only, never a date or a member.',
|
|
252
304
|
inputSchema: {
|
|
253
305
|
portfolio_id: z.string(),
|
|
306
|
+
style: z.enum(['packed', 'fullstack', 'lane']).optional().describe('Default "packed". "fullstack": the five Full Stack rows (Timeline: y only; view "map": x and y). "lane": the releases in one evenly spaced Map row, ordered by from date.'),
|
|
254
307
|
heights: z.record(z.string(), z.number().min(20).max(2000)).optional(),
|
|
308
|
+
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" (or style "fullstack"): measured card sizes (px) per id — Map cards are as wide as their content.'),
|
|
309
|
+
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.'),
|
|
310
|
+
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).'),
|
|
311
|
+
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.'),
|
|
312
|
+
view: z.enum(['timeline', 'map']).optional().describe('Default timeline. "map": write the Map (free) positions only, grouped by objective.'),
|
|
313
|
+
group: z.enum(['objective']).optional().describe('"objective": group epic blocks under the objective whose key results link to them.'),
|
|
314
|
+
objective_order: z.array(z.string()).max(100).optional().describe('With group "objective" or style "fullstack": objective ids to place first, in this order.'),
|
|
255
315
|
expected_version: z.number().int().optional(),
|
|
256
316
|
},
|
|
257
317
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
258
318
|
}, async (a) => {
|
|
319
|
+
const style = a.style ?? 'packed';
|
|
320
|
+
if (style !== 'packed') {
|
|
321
|
+
const stray = ['heights', 'sizes', 'column_height', 'pack', 'headers', 'group', 'objective_order']
|
|
322
|
+
.filter(k => a[k] !== undefined && !(style === 'fullstack' && (k === 'sizes' || k === 'objective_order')));
|
|
323
|
+
if (stray.length)
|
|
324
|
+
return fail(`style "${style}" does not take ${stray.join(', ')} — nothing was written.`);
|
|
325
|
+
if (style === 'lane' && a.view === 'timeline')
|
|
326
|
+
return fail('style "lane" lays out the Map only (on the Timeline a release\'s x is its date) — omit view or pass "map". Nothing was written.');
|
|
327
|
+
}
|
|
259
328
|
const g = await loadGraph(ctx, a.portfolio_id);
|
|
260
|
-
|
|
329
|
+
if (style === 'lane') {
|
|
330
|
+
const lane = layoutReleaseLane(g.nodes, mapBoxes);
|
|
331
|
+
if (!lane.positions.size) {
|
|
332
|
+
const structured = { ok: true, style, releases: 0 };
|
|
333
|
+
return { content: [text(structured)], structuredContent: structured };
|
|
334
|
+
}
|
|
335
|
+
const r = await saveGraph(ctx, a.portfolio_id, g, { ...g, nodes: applyMapLayout(g.nodes, lane.positions) }, a.expected_version);
|
|
336
|
+
if (!r.ok)
|
|
337
|
+
return fail(r.message);
|
|
338
|
+
const structured = { ok: true, style, releases: lane.positions.size, y: lane.y, ...(r.version !== undefined ? { version: r.version } : {}) };
|
|
339
|
+
return { content: [text(structured)], structuredContent: structured };
|
|
340
|
+
}
|
|
341
|
+
if (style === 'fullstack') {
|
|
342
|
+
const fs = layoutFullStack(g.nodes, { edges: g.edges, objectiveOrder: a.objective_order, sizes: a.sizes });
|
|
343
|
+
const map = a.view === 'map';
|
|
344
|
+
const nodes = map ? applyMapLayout(g.nodes, fs) : applyFullStackTimeline(g.nodes, fs);
|
|
345
|
+
const r = await saveGraph(ctx, a.portfolio_id, g, { ...g, nodes }, a.expected_version);
|
|
346
|
+
if (!r.ok)
|
|
347
|
+
return fail(r.message);
|
|
348
|
+
const structured = { ok: true, style, view: map ? 'map' : 'timeline', positioned: fs.size, ...(map ? {} : { timeline_overlaps: timelineRowOverlaps(nodes) }), ...(r.version !== undefined ? { version: r.version } : {}) };
|
|
349
|
+
return { content: [text(structured)], structuredContent: structured };
|
|
350
|
+
}
|
|
351
|
+
if (a.view === 'map') {
|
|
352
|
+
const HEADER_H = 220;
|
|
353
|
+
const cards = g.nodes.filter(n => !(n.type === 'rad-module' && n.data?.moduleData?.__layoutHeader));
|
|
354
|
+
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 });
|
|
355
|
+
let nodes = applyMapLayout(g.nodes, d.positions);
|
|
356
|
+
let headers = { added: 0, updated: 0, removed: 0 };
|
|
357
|
+
if (a.headers !== undefined) {
|
|
358
|
+
const h = withMapHeaders(nodes, a.headers ? d.bands : [], HEADER_H);
|
|
359
|
+
nodes = h.nodes;
|
|
360
|
+
headers = { added: h.added, updated: h.updated, removed: h.removed };
|
|
361
|
+
}
|
|
362
|
+
const r = await saveGraph(ctx, a.portfolio_id, g, { ...g, nodes }, a.expected_version);
|
|
363
|
+
if (!r.ok)
|
|
364
|
+
return fail(r.message);
|
|
365
|
+
const structured = { ok: true, view: 'map', positioned: d.positions.size, bands: d.bands.length, headers, ...(r.version !== undefined ? { version: r.version } : {}) };
|
|
366
|
+
return { content: [text(structured)], structuredContent: structured };
|
|
367
|
+
}
|
|
368
|
+
const res = layoutTimeline(g.nodes, { heights: a.heights, ...(a.group ? { group: a.group, edges: g.edges, objectiveOrder: a.objective_order } : {}) });
|
|
261
369
|
const r = await saveGraph(ctx, a.portfolio_id, g, { ...g, nodes: applyLayout(g.nodes, res) }, a.expected_version);
|
|
262
370
|
if (!r.ok)
|
|
263
371
|
return fail(r.message);
|
|
264
372
|
const structured = { ok: true, positioned: res.positions.size, ...(r.version !== undefined ? { version: r.version } : {}) };
|
|
265
373
|
return { content: [text(structured)], structuredContent: structured };
|
|
266
374
|
});
|
|
375
|
+
server.registerTool('portfolio_style', {
|
|
376
|
+
title: 'Restyle many cards at once',
|
|
377
|
+
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), satellites "open" | "close" (show or fold the card\'s inner-loop panels), inner_loop_removed_fields (the satellites an open loop leaves out — replaces the list; [] = the level\'s full row) and epic_color (#RRGGBB, or null to drop the override). Content is untouched. Artifact child cards (hypothesis, NFR…) are skipped unless a rule sets artifacts: true.',
|
|
378
|
+
inputSchema: {
|
|
379
|
+
portfolio_id: z.string(),
|
|
380
|
+
rules: z.array(z.object({
|
|
381
|
+
levels: z.array(z.enum(LEVELS)).optional(),
|
|
382
|
+
states: z.array(z.enum(STYLE_STATES)).optional(),
|
|
383
|
+
ids: z.array(z.string()).max(1000).optional(),
|
|
384
|
+
has_image: z.boolean().optional(),
|
|
385
|
+
artifacts: z.boolean().optional(),
|
|
386
|
+
display: z.object({
|
|
387
|
+
card_style: z.enum(CARD_STYLES).optional(),
|
|
388
|
+
title_size: z.enum(TITLE_SIZES).optional(),
|
|
389
|
+
accent: z.enum(ACCENTS).optional(),
|
|
390
|
+
show: z.object(Object.fromEntries(SHOW_KEYS.map(k => [k, z.boolean().optional()]))).optional(),
|
|
391
|
+
}).optional(),
|
|
392
|
+
size: z.union([z.object({ width: z.number().min(60).max(4000), height: z.number().min(40).max(4000) }), z.literal('auto')]).optional(),
|
|
393
|
+
satellites: z.enum(['open', 'close']).optional(),
|
|
394
|
+
inner_loop_removed_fields: z.array(z.enum(INNER_LOOP_FIELDS)).max(INNER_LOOP_FIELDS.length).optional().describe('Satellites the open loop leaves out, e.g. ["description","acceptanceCriteria"] (a Full Stack feature: Display + Statement only). Replaces the stored list.'),
|
|
395
|
+
epic_color: z.union([z.string().regex(/^#[0-9a-fA-F]{6}$/), z.null()]).optional().describe('The card colour as #RRGGBB (stored as epicColor on any level; clears a palette ref so the hex shows). null removes it.'),
|
|
396
|
+
})).min(1).max(40),
|
|
397
|
+
expected_version: z.number().int().optional(),
|
|
398
|
+
},
|
|
399
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
400
|
+
}, async (a) => {
|
|
401
|
+
const g = await loadGraph(ctx, a.portfolio_id);
|
|
402
|
+
const rules = a.rules.map(r => ({
|
|
403
|
+
...(r.levels ? { levels: r.levels } : {}),
|
|
404
|
+
...(r.states ? { states: r.states } : {}),
|
|
405
|
+
...(r.ids ? { ids: r.ids } : {}),
|
|
406
|
+
...(r.has_image !== undefined ? { hasImage: r.has_image } : {}),
|
|
407
|
+
...(r.artifacts ? { artifacts: true } : {}),
|
|
408
|
+
...(r.display ? { display: { cardStyle: r.display.card_style, titleSize: r.display.title_size, accent: r.display.accent, show: r.display.show } } : {}),
|
|
409
|
+
...(r.size ? { size: r.size } : {}),
|
|
410
|
+
...(r.satellites ? { satellites: r.satellites } : {}),
|
|
411
|
+
...(r.inner_loop_removed_fields ? { innerLoopRemovedFields: r.inner_loop_removed_fields } : {}),
|
|
412
|
+
...(r.epic_color !== undefined ? { epicColor: r.epic_color } : {}),
|
|
413
|
+
}));
|
|
414
|
+
const res = applyStyleRules(g.nodes, rules);
|
|
415
|
+
if (res.changed === 0) {
|
|
416
|
+
const structured = { ok: true, matched: res.matched, changed: 0, perRule: res.perRule };
|
|
417
|
+
return { content: [text(structured)], structuredContent: structured };
|
|
418
|
+
}
|
|
419
|
+
const r = await saveGraph(ctx, a.portfolio_id, g, { ...g, nodes: res.nodes }, a.expected_version);
|
|
420
|
+
if (!r.ok)
|
|
421
|
+
return fail(r.message);
|
|
422
|
+
const structured = { ok: true, matched: res.matched, changed: res.changed, perRule: res.perRule, ...(r.version !== undefined ? { version: r.version } : {}) };
|
|
423
|
+
return { content: [text(structured)], structuredContent: structured };
|
|
424
|
+
});
|
|
267
425
|
server.registerTool('portfolio_set_meta', {
|
|
268
426
|
title: 'Set portfolio settings',
|
|
269
427
|
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.',
|