@nonbot/mcp 0.3.0 → 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 +1 -0
- package/dist/core/layout.d.ts +34 -0
- package/dist/core/layout.js +199 -0
- package/dist/core/preview.d.ts +1 -0
- package/dist/core/preview.js +13 -6
- package/dist/core/releases.d.ts +32 -0
- package/dist/core/releases.js +33 -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 +4 -3
- package/dist/core/style.d.ts +4 -1
- package/dist/core/style.js +12 -0
- package/dist/core/toolsets/build.d.ts +7 -3
- package/dist/core/toolsets/build.js +69 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -258,6 +258,7 @@ Safety rails on writes: `portfolio_update` pre-fetches the live version, backs t
|
|
|
258
258
|
- `core/server.ts` — `registerAll(server, ctx)` (the transport-agnostic heart) + `createNonbotMcpServer(ctx)`
|
|
259
259
|
- `core/toolsets/{portfolio,build,release,ip,whygraph,run,monitor,radbugs}.ts`, `core/resources.ts`, `core/prompts.ts`
|
|
260
260
|
- `core/releases.ts` — the Release node's membership + chain rules, restated from the app (`apps/web/src/modules/portfolio/lib/releases.ts`, `src/modules/release/lib/releaseChain.ts`) and parity-tested against it; `core/completedWork.ts` — done work, draft proposals and evidence matching
|
|
261
|
+
- `core/layout.ts` — the Timeline (packed), Map and Full Stack layouts (`portfolio_layout` / `portfolio_add_items`; Full Stack's five rows restate the app's `FULL_STACK_TEMPLATE` and set y only on the Timeline, where x is the date); `core/satellites.ts` — inner-loop satellite sizes and rosters, restated from the app (`lib/innerLoopLayout.ts`, `theme/portfolio-theme.ts`) and parity-tested against it, for the Full Stack epic columns and the preview's satellite bands
|
|
261
262
|
- `src/stdio.ts` — the `nonbot-mcp` bin: env/CLI-auth token + filesystem backup hook + the `git log` hook (`src/gitLog.ts`: execFile with an argv array, absolute `repo_path` only) used by `portfolio_rebuild_done_work`
|
|
262
263
|
- `scripts/gen-tool-table.mjs` — prints the tool tables above from the live definitions (`--portfolio-mcp` does the same for the legacy server)
|
|
263
264
|
|
package/dist/core/layout.d.ts
CHANGED
|
@@ -87,3 +87,37 @@ export declare function applyMapLayout<T extends LayoutNode>(nodes: T[], positio
|
|
|
87
87
|
x: number;
|
|
88
88
|
y: number;
|
|
89
89
|
}>): T[];
|
|
90
|
+
export declare const FS_ROW: Record<Level, number>;
|
|
91
|
+
export declare const FS_EPIC_STAGGER = 20;
|
|
92
|
+
export declare const FS_LEAF_PITCH = 240;
|
|
93
|
+
export declare const FS_MILESTONE_LIFT_GAP = 20;
|
|
94
|
+
export declare const FS_FEATURE_MIN_WIDTH = 600;
|
|
95
|
+
export declare const FS_GROUP_GAP = 240;
|
|
96
|
+
export interface FullStackOptions {
|
|
97
|
+
edges?: LayoutEdge[];
|
|
98
|
+
objectiveOrder?: string[];
|
|
99
|
+
sizes?: Record<string, {
|
|
100
|
+
width: number;
|
|
101
|
+
height: number;
|
|
102
|
+
}>;
|
|
103
|
+
}
|
|
104
|
+
export declare function layoutFullStack(nodes: LayoutNode[], opts?: FullStackOptions): Map<string, {
|
|
105
|
+
x: number;
|
|
106
|
+
y: number;
|
|
107
|
+
}>;
|
|
108
|
+
export declare function applyFullStackTimeline<T extends LayoutNode>(nodes: T[], positions: Map<string, {
|
|
109
|
+
x: number;
|
|
110
|
+
y: number;
|
|
111
|
+
}>): T[];
|
|
112
|
+
export declare function timelineRowOverlaps(nodes: LayoutNode[]): number;
|
|
113
|
+
export declare function mapBoxes(n: LayoutNode & {
|
|
114
|
+
freePosition?: {
|
|
115
|
+
x: number;
|
|
116
|
+
y: number;
|
|
117
|
+
};
|
|
118
|
+
}): Array<{
|
|
119
|
+
x: number;
|
|
120
|
+
y: number;
|
|
121
|
+
width: number;
|
|
122
|
+
height: number;
|
|
123
|
+
}>;
|
package/dist/core/layout.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { IP_NODE_SIZE } from './ipShape.js';
|
|
2
|
+
import { satelliteRowWidth, satellitePositions, satelliteBandHeight, INNER_LOOP_GAP, INNER_LOOP_Y_OFFSET, HOST_DEFAULT_HEIGHT } from './satellites.js';
|
|
2
3
|
export const PX_PER_WEEK = 120;
|
|
3
4
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
4
5
|
const ITEM_TOP_HANDLE_X = 8;
|
|
@@ -510,3 +511,201 @@ export function applyMapLayout(nodes, positions) {
|
|
|
510
511
|
return p ? { ...n, freePosition: { x: Math.round(p.x), y: Math.round(p.y) } } : n;
|
|
511
512
|
});
|
|
512
513
|
}
|
|
514
|
+
export const FS_ROW = { objective: 0, 'key-result': 400, epic: 600, feature: 1040, milestone: 1320, story: 1480, enabler: 1480 };
|
|
515
|
+
export const FS_EPIC_STAGGER = 20;
|
|
516
|
+
export const FS_LEAF_PITCH = 240;
|
|
517
|
+
export const FS_MILESTONE_LIFT_GAP = 20;
|
|
518
|
+
export const FS_FEATURE_MIN_WIDTH = 600;
|
|
519
|
+
export const FS_GROUP_GAP = 240;
|
|
520
|
+
export function layoutFullStack(nodes, opts = {}) {
|
|
521
|
+
const sizes = opts.sizes ?? {};
|
|
522
|
+
const items = nodes.filter(isItem);
|
|
523
|
+
const byId = new Map(items.map(n => [n.id, n]));
|
|
524
|
+
const out = new Map();
|
|
525
|
+
const w = (n) => {
|
|
526
|
+
const z = sizes[n.id];
|
|
527
|
+
if (z && z.width > 0)
|
|
528
|
+
return z.width;
|
|
529
|
+
const cs = n.data?.canvasSize;
|
|
530
|
+
return cs && Number.isFinite(cs.width) && cs.width > 0 ? cs.width : MAP_WIDTH[levelOf(n)];
|
|
531
|
+
};
|
|
532
|
+
const tx = (n) => n.position?.x ?? 0;
|
|
533
|
+
const order = new Map(items.map((n, i) => [n.id, i]));
|
|
534
|
+
const byTime = (a, b) => tx(a) - tx(b) || order.get(a.id) - order.get(b.id);
|
|
535
|
+
const kids = new Map();
|
|
536
|
+
for (const n of items) {
|
|
537
|
+
const p = n.data?.parentItemId;
|
|
538
|
+
if (p && byId.has(p) && p !== n.id)
|
|
539
|
+
kids.set(p, [...(kids.get(p) ?? []), n]);
|
|
540
|
+
}
|
|
541
|
+
const put = (n, x, y) => out.set(n.id, { x, y });
|
|
542
|
+
const open = (list) => list.filter(n => !out.has(n.id)).sort(byTime);
|
|
543
|
+
const childrenOf = (id) => open(kids.get(id) ?? []);
|
|
544
|
+
const isMs = (n) => levelOf(n) === 'milestone';
|
|
545
|
+
const msH = (n) => {
|
|
546
|
+
const z = sizes[n.id];
|
|
547
|
+
if (z && z.height > 0)
|
|
548
|
+
return z.height;
|
|
549
|
+
const cs = n.data?.canvasSize;
|
|
550
|
+
return cs && Number.isFinite(cs.height) && cs.height > 0 ? cs.height : HEIGHT.milestone;
|
|
551
|
+
};
|
|
552
|
+
let x = 0;
|
|
553
|
+
let epicIndex = 0;
|
|
554
|
+
let epicBandRight = -Infinity;
|
|
555
|
+
let featureBandRight = -Infinity;
|
|
556
|
+
const clear = (cx, card, row, right) => (row > 0 && Number.isFinite(right) ? Math.max(cx, right + INNER_LOOP_GAP - card / 2 + row / 2) : cx);
|
|
557
|
+
const featureColumn = (f, fx0, stagger, under, epicBand) => {
|
|
558
|
+
const row = f ? satelliteRowWidth(f.data) : 0;
|
|
559
|
+
const fx = f ? clear(fx0, w(f), row, featureBandRight) : fx0;
|
|
560
|
+
if (f)
|
|
561
|
+
put(f, fx, FS_ROW.feature);
|
|
562
|
+
let lx = fx;
|
|
563
|
+
for (const l of under.filter(n => !isMs(n))) {
|
|
564
|
+
put(l, lx, FS_ROW.story);
|
|
565
|
+
lx += Math.max(FS_LEAF_PITCH, w(l));
|
|
566
|
+
}
|
|
567
|
+
const ms = under.filter(isMs);
|
|
568
|
+
const msW = ms.reduce((s, m) => s + w(m), 0);
|
|
569
|
+
let width = Math.max(lx - fx + msW, f ? Math.max(FS_FEATURE_MIN_WIDTH, w(f), row) : 0);
|
|
570
|
+
const raised = row > 0 && ms.length > 0;
|
|
571
|
+
const msY = raised ? FS_ROW.feature - Math.max(...ms.map(msH)) - FS_MILESTONE_LIFT_GAP : FS_ROW.milestone + stagger;
|
|
572
|
+
if (raised && epicBand && msY < epicBand.bottom && msY + Math.max(...ms.map(msH)) > epicBand.top) {
|
|
573
|
+
const left = fx + width - msW;
|
|
574
|
+
if (left < epicBand.right + INNER_LOOP_GAP && left + msW > epicBand.left - INNER_LOOP_GAP)
|
|
575
|
+
width = epicBand.right + INNER_LOOP_GAP + msW - fx;
|
|
576
|
+
}
|
|
577
|
+
let mx = fx + width - msW;
|
|
578
|
+
for (const m of ms) {
|
|
579
|
+
put(m, mx, msY);
|
|
580
|
+
mx += w(m);
|
|
581
|
+
}
|
|
582
|
+
if (f && row > 0)
|
|
583
|
+
featureBandRight = Math.max(featureBandRight, fx + w(f) / 2 + row / 2);
|
|
584
|
+
if (ms.length) {
|
|
585
|
+
if (raised)
|
|
586
|
+
epicBandRight = Math.max(epicBandRight, mx);
|
|
587
|
+
else
|
|
588
|
+
featureBandRight = Math.max(featureBandRight, mx);
|
|
589
|
+
}
|
|
590
|
+
return fx + width - fx0;
|
|
591
|
+
};
|
|
592
|
+
const epicColumn = (ep) => {
|
|
593
|
+
const stagger = FS_EPIC_STAGGER * (epicIndex++ % 2);
|
|
594
|
+
const row = satelliteRowWidth(ep.data);
|
|
595
|
+
const ex = clear(x, w(ep), row, epicBandRight);
|
|
596
|
+
const ey = FS_ROW.epic + stagger;
|
|
597
|
+
put(ep, ex, ey);
|
|
598
|
+
const band = row > 0
|
|
599
|
+
? { left: ex + w(ep) / 2 - row / 2, right: ex + w(ep) / 2 + row / 2, top: ey + HOST_DEFAULT_HEIGHT.epic + INNER_LOOP_Y_OFFSET, bottom: ey + satelliteBandHeight(ep.data) }
|
|
600
|
+
: null;
|
|
601
|
+
if (band)
|
|
602
|
+
epicBandRight = Math.max(epicBandRight, band.right);
|
|
603
|
+
let cx = ex;
|
|
604
|
+
const ch = childrenOf(ep.id);
|
|
605
|
+
for (const f of ch.filter(n => levelOf(n) === 'feature'))
|
|
606
|
+
cx += featureColumn(f, cx, stagger, childrenOf(f.id), band);
|
|
607
|
+
const direct = ch.filter(n => levelOf(n) !== 'feature');
|
|
608
|
+
if (direct.length)
|
|
609
|
+
cx += featureColumn(null, cx, stagger, direct, band);
|
|
610
|
+
x = ex + Math.max(cx - ex, w(ep), row);
|
|
611
|
+
return ex;
|
|
612
|
+
};
|
|
613
|
+
const objectives = items.filter(n => levelOf(n) === 'objective');
|
|
614
|
+
const krs = items.filter(n => levelOf(n) === 'key-result').sort(byTime);
|
|
615
|
+
const epics = items.filter(n => levelOf(n) === 'epic').sort(byTime);
|
|
616
|
+
const epicIds = new Set(epics.map(e => e.id));
|
|
617
|
+
const linked = new Map();
|
|
618
|
+
const link = (kr, ep) => { if (epicIds.has(ep) && !(linked.get(kr) ?? []).includes(ep))
|
|
619
|
+
linked.set(kr, [...(linked.get(kr) ?? []), ep]); };
|
|
620
|
+
for (const e of opts.edges ?? [])
|
|
621
|
+
if (e.dependencyType === 'okr-link')
|
|
622
|
+
link(e.source, e.target);
|
|
623
|
+
for (const kr of krs)
|
|
624
|
+
for (const ep of (Array.isArray(kr.data?.linkedEpicIds) ? kr.data.linkedEpicIds : []))
|
|
625
|
+
link(kr.id, ep);
|
|
626
|
+
const rank = new Map((opts.objectiveOrder ?? []).map((id, i) => [id, i]));
|
|
627
|
+
const ordered = objectives.slice().sort((a, b) => (rank.get(a.id) ?? Infinity) - (rank.get(b.id) ?? Infinity) || byTime(a, b));
|
|
628
|
+
let started = false;
|
|
629
|
+
const group = (fn) => {
|
|
630
|
+
const before = out.size;
|
|
631
|
+
const at = x;
|
|
632
|
+
if (started)
|
|
633
|
+
x += FS_GROUP_GAP;
|
|
634
|
+
fn();
|
|
635
|
+
if (out.size === before)
|
|
636
|
+
x = at;
|
|
637
|
+
else
|
|
638
|
+
started = true;
|
|
639
|
+
};
|
|
640
|
+
const krColumn = (kr) => {
|
|
641
|
+
const own = open((linked.get(kr.id) ?? []).map(id => byId.get(id)));
|
|
642
|
+
if (!own.length) {
|
|
643
|
+
put(kr, x, FS_ROW['key-result']);
|
|
644
|
+
x += w(kr) + MAP_GAP;
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
let first = null;
|
|
648
|
+
for (const ep of own) {
|
|
649
|
+
const ex = epicColumn(ep);
|
|
650
|
+
first ??= ex;
|
|
651
|
+
}
|
|
652
|
+
put(kr, first, FS_ROW['key-result']);
|
|
653
|
+
};
|
|
654
|
+
for (const o of ordered)
|
|
655
|
+
group(() => {
|
|
656
|
+
const ox = x;
|
|
657
|
+
put(o, ox, FS_ROW.objective);
|
|
658
|
+
for (const kr of krs.filter(k => k.data?.parentItemId === o.id))
|
|
659
|
+
krColumn(kr);
|
|
660
|
+
x = Math.max(x, ox + w(o));
|
|
661
|
+
});
|
|
662
|
+
const objectiveIds = new Set(objectives.map(o => o.id));
|
|
663
|
+
group(() => { for (const kr of krs.filter(k => !objectiveIds.has(k.data?.parentItemId)))
|
|
664
|
+
krColumn(kr); });
|
|
665
|
+
group(() => { for (const ep of open(epics))
|
|
666
|
+
epicColumn(ep); });
|
|
667
|
+
group(() => {
|
|
668
|
+
for (const f of open(items.filter(n => levelOf(n) === 'feature')))
|
|
669
|
+
x += featureColumn(f, x, 0, childrenOf(f.id), null);
|
|
670
|
+
const loose = open(items.filter(n => !byId.has(n.data?.parentItemId) && ['story', 'enabler', 'milestone'].includes(levelOf(n))));
|
|
671
|
+
if (loose.length)
|
|
672
|
+
x += featureColumn(null, x, 0, loose, null);
|
|
673
|
+
for (const n of open(items)) {
|
|
674
|
+
put(n, x, FS_ROW[levelOf(n)]);
|
|
675
|
+
x += Math.max(FS_LEAF_PITCH, w(n));
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
return out;
|
|
679
|
+
}
|
|
680
|
+
export function applyFullStackTimeline(nodes, positions) {
|
|
681
|
+
return nodes.map(n => {
|
|
682
|
+
const p = positions.get(n.id);
|
|
683
|
+
return p ? { ...n, position: { ...(n.position ?? { x: 0 }), y: Math.round(p.y) } } : n;
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
export function timelineRowOverlaps(nodes) {
|
|
687
|
+
const cards = nodes.filter(isItem).map(n => {
|
|
688
|
+
const lv = levelOf(n);
|
|
689
|
+
const weeks = typeof n.data?.durationWeeks === 'number' ? n.data.durationWeeks : DEFAULT_DURATION[lv];
|
|
690
|
+
const width = Number(n.data?.manualWidthOverride) || (weeks > 0 ? weeks * PX_PER_WEEK : DEFAULT_WIDTH[lv]);
|
|
691
|
+
return { x: n.position?.x ?? 0, y: n.position?.y ?? 0, width };
|
|
692
|
+
});
|
|
693
|
+
let count = 0;
|
|
694
|
+
for (let i = 0; i < cards.length; i++)
|
|
695
|
+
for (let j = i + 1; j < cards.length; j++) {
|
|
696
|
+
const a = cards[i], b = cards[j];
|
|
697
|
+
if (Math.abs(a.y - b.y) < 100 && a.x < b.x + b.width && b.x < a.x + a.width)
|
|
698
|
+
count++;
|
|
699
|
+
}
|
|
700
|
+
return count;
|
|
701
|
+
}
|
|
702
|
+
export function mapBoxes(n) {
|
|
703
|
+
const at = n.freePosition ?? n.position ?? { x: 0, y: 0 };
|
|
704
|
+
if (n.type === 'rad-module')
|
|
705
|
+
return [{ x: at.x, y: at.y, width: Number(n.data?.width) || 400, height: Number(n.data?.height) || 300 }];
|
|
706
|
+
const cs = n.data?.canvasSize;
|
|
707
|
+
const width = cs && Number.isFinite(cs.width) && cs.width > 0 ? cs.width : MAP_WIDTH[levelOf(n)];
|
|
708
|
+
const height = cs && Number.isFinite(cs.height) && cs.height > 0 ? cs.height : HEIGHT[levelOf(n)] + (Array.isArray(n.data?.pastedImages) && n.data.pastedImages.length ? MAP_HERO_EXTRA : 0);
|
|
709
|
+
const card = { x: at.x, y: at.y, width, height };
|
|
710
|
+
return [card, ...satellitePositions(n.data, card).map(({ x, y, width: w, height: h }) => ({ x, y, width: w, height: h }))];
|
|
711
|
+
}
|
package/dist/core/preview.d.ts
CHANGED
package/dist/core/preview.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MAP_WIDTH, levelOf, isItem } from './layout.js';
|
|
2
|
+
import { satellitePositions, SATELLITE_FIELDS } from './satellites.js';
|
|
2
3
|
const HEIGHT = { epic: 120, feature: 100, story: 96, enabler: 96, milestone: 44, objective: 120, 'key-result': 100 };
|
|
3
4
|
const esc = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
4
5
|
const strip = (s) => s.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
@@ -24,21 +25,27 @@ export function renderPreviewSvg(nodes, opts = {}) {
|
|
|
24
25
|
};
|
|
25
26
|
const drawn = nodes.filter(n => isItem(n) || n.type === 'rad-module');
|
|
26
27
|
if (!drawn.length)
|
|
27
|
-
return { svg: '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>', cards: 0, extent: { width: 0, height: 0 } };
|
|
28
|
+
return { svg: '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>', cards: 0, satellites: 0, extent: { width: 0, height: 0 } };
|
|
29
|
+
const bands = drawn.filter(isItem).flatMap(n => { const p = pos(n); const s = size(n); return satellitePositions(n.data, { x: p.x, y: p.y, width: s.width, height: s.height }); });
|
|
28
30
|
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
31
|
+
const grow = (x, y, w, h) => { x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x + w); y1 = Math.max(y1, y + h); };
|
|
29
32
|
for (const n of drawn) {
|
|
30
33
|
const p = pos(n);
|
|
31
34
|
const s = size(n);
|
|
32
|
-
|
|
33
|
-
y0 = Math.min(y0, p.y);
|
|
34
|
-
x1 = Math.max(x1, p.x + s.width);
|
|
35
|
-
y1 = Math.max(y1, p.y + s.height);
|
|
35
|
+
grow(p.x, p.y, s.width, s.height);
|
|
36
36
|
}
|
|
37
|
+
for (const b of bands)
|
|
38
|
+
grow(b.x, b.y, b.width, b.height);
|
|
37
39
|
const pad = 80;
|
|
38
40
|
const W = x1 - x0 + 2 * pad;
|
|
39
41
|
const H = y1 - y0 + 2 * pad;
|
|
40
42
|
const outW = opts.width ?? 1600;
|
|
41
43
|
const parts = [];
|
|
44
|
+
for (const b of bands) {
|
|
45
|
+
const x = b.x - x0 + pad, y = b.y - y0 + pad;
|
|
46
|
+
parts.push(`<rect x="${x}" y="${y}" width="${b.width}" height="${b.height}" rx="8" fill="#17142A" stroke="#4A4566" stroke-width="1.5" stroke-dasharray="6 4"/>`);
|
|
47
|
+
parts.push(`<text x="${x + 10}" y="${y + 20}" font-size="12" fill="#8F87B5">${esc(fit(SATELLITE_FIELDS[b.field].label, b.width - 20, 12))}</text>`);
|
|
48
|
+
}
|
|
42
49
|
for (const n of drawn) {
|
|
43
50
|
const p = pos(n);
|
|
44
51
|
const s = size(n);
|
|
@@ -70,5 +77,5 @@ export function renderPreviewSvg(nodes, opts = {}) {
|
|
|
70
77
|
parts.push(`<text x="${x + 14}" y="${y + 28 + fs * 1.3}" font-size="${fs}" font-weight="${big ? 700 : 500}" fill="${ink}">${esc(fit(String(n.data?.title ?? '').trim(), s.width - 28, fs))}</text>`);
|
|
71
78
|
}
|
|
72
79
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${outW}" height="${Math.round((outW * H) / W)}" viewBox="0 0 ${Math.round(W)} ${Math.round(H)}" font-family="Public Sans, Helvetica, Arial, sans-serif"><rect width="100%" height="100%" fill="#120F1E"/>${parts.join('')}</svg>`;
|
|
73
|
-
return { svg, cards: drawn.length, extent: { width: Math.round(W), height: Math.round(H) } };
|
|
80
|
+
return { svg, cards: drawn.length, satellites: bands.length, extent: { width: Math.round(W), height: Math.round(H) } };
|
|
74
81
|
}
|
package/dist/core/releases.d.ts
CHANGED
|
@@ -125,3 +125,35 @@ export declare function releaseNode(id: string, md: ReleaseData, place: {
|
|
|
125
125
|
y: number;
|
|
126
126
|
};
|
|
127
127
|
}): GraphNode;
|
|
128
|
+
export interface LaneBox {
|
|
129
|
+
x: number;
|
|
130
|
+
y: number;
|
|
131
|
+
width: number;
|
|
132
|
+
height: number;
|
|
133
|
+
}
|
|
134
|
+
export interface LaneRelease {
|
|
135
|
+
id: string;
|
|
136
|
+
x: number;
|
|
137
|
+
y: number;
|
|
138
|
+
width: number;
|
|
139
|
+
data: Record<string, unknown>;
|
|
140
|
+
}
|
|
141
|
+
export interface LanePlace {
|
|
142
|
+
id: string;
|
|
143
|
+
x: number;
|
|
144
|
+
y: number;
|
|
145
|
+
}
|
|
146
|
+
export declare const LANE_GAP_X = 48;
|
|
147
|
+
export declare const LANE_GAP_Y = 120;
|
|
148
|
+
export declare function laneOrder<R extends {
|
|
149
|
+
id: string;
|
|
150
|
+
data: Record<string, unknown>;
|
|
151
|
+
}>(releases: readonly R[]): R[];
|
|
152
|
+
export declare function releaseLane(releases: readonly LaneRelease[], boxes: readonly LaneBox[]): LanePlace[];
|
|
153
|
+
export declare function layoutReleaseLane(nodes: ReadonlyArray<GraphNode>, boxesOf: (n: GraphNode) => LaneBox[]): {
|
|
154
|
+
positions: Map<string, {
|
|
155
|
+
x: number;
|
|
156
|
+
y: number;
|
|
157
|
+
}>;
|
|
158
|
+
y: number | null;
|
|
159
|
+
};
|
package/dist/core/releases.js
CHANGED
|
@@ -278,3 +278,36 @@ export function releaseNode(id, md, place) {
|
|
|
278
278
|
data: { subtype: RELEASE_SUBTYPE, moduleData: { ...md }, width: RELEASE_SIZE.width, height: RELEASE_SIZE.height },
|
|
279
279
|
};
|
|
280
280
|
}
|
|
281
|
+
export const LANE_GAP_X = 48;
|
|
282
|
+
export const LANE_GAP_Y = 120;
|
|
283
|
+
export function laneOrder(releases) {
|
|
284
|
+
return [...releases].sort((a, b) => str(a.data.from).localeCompare(str(b.data.from))
|
|
285
|
+
|| str(a.data.to).localeCompare(str(b.data.to))
|
|
286
|
+
|| a.id.localeCompare(b.id));
|
|
287
|
+
}
|
|
288
|
+
export function releaseLane(releases, boxes) {
|
|
289
|
+
if (!releases.length)
|
|
290
|
+
return [];
|
|
291
|
+
const live = boxes.filter(b => [b.x, b.y, b.width, b.height].every(Number.isFinite));
|
|
292
|
+
const left = live.length ? Math.min(...live.map(b => b.x)) : Math.min(...releases.map(r => r.x));
|
|
293
|
+
const top = live.length ? Math.max(...live.map(b => b.y + b.height)) + LANE_GAP_Y : Math.min(...releases.map(r => r.y));
|
|
294
|
+
const out = [];
|
|
295
|
+
let x = Math.round(left);
|
|
296
|
+
const y = Math.round(top);
|
|
297
|
+
for (const r of laneOrder(releases)) {
|
|
298
|
+
out.push({ id: r.id, x, y });
|
|
299
|
+
x += Math.round(Math.max(r.width, 1)) + LANE_GAP_X;
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
export function layoutReleaseLane(nodes, boxesOf) {
|
|
304
|
+
const isRel = (n) => n.type === 'rad-module' && n.data?.subtype === RELEASE_SUBTYPE;
|
|
305
|
+
const at = (n) => n.freePosition ?? n.position ?? { x: 0, y: 0 };
|
|
306
|
+
const releases = nodes.filter(isRel).map(n => ({
|
|
307
|
+
id: n.id, ...at(n), width: Number(n.data?.width) || RELEASE_SIZE.width,
|
|
308
|
+
data: n.data?.moduleData && typeof n.data.moduleData === 'object' ? n.data.moduleData : {},
|
|
309
|
+
}));
|
|
310
|
+
const boxes = nodes.filter(n => !isRel(n) && (n.type === 'portfolio-item' || n.type === 'rad-module')).flatMap(boxesOf);
|
|
311
|
+
const places = releaseLane(releases, boxes);
|
|
312
|
+
return { positions: new Map(places.map(p => [p.id, { x: p.x, y: p.y }])), y: places[0]?.y ?? null };
|
|
313
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export declare const INNER_LOOP_GAP = 16;
|
|
2
|
+
export declare const INNER_LOOP_Y_OFFSET = 40;
|
|
3
|
+
export declare const INNER_LOOP_COLLAPSED_HEIGHT = 44;
|
|
4
|
+
export declare const INNER_LOOP_FIELDS: readonly ["epicColor", "nodeDisplay", "artifact", "whyGraph", "description", "hypothesisStatement", "acceptanceCriteria", "bluf", "lbcProblem", "lbcBenefitHypothesis", "lbcLeadingIndicators", "lbcNfrs", "mvpDefinition", "outcomeMetrics", "owner", "tags", "confidenceVote", "approvalRating", "mockupSvg", "character", "location"];
|
|
5
|
+
export type InnerLoopField = (typeof INNER_LOOP_FIELDS)[number];
|
|
6
|
+
type FieldConfig = {
|
|
7
|
+
label: string;
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
levels?: readonly string[];
|
|
11
|
+
retired?: true;
|
|
12
|
+
};
|
|
13
|
+
export declare const SATELLITE_FIELDS: Record<InnerLoopField, FieldConfig>;
|
|
14
|
+
export declare const HOST_DEFAULT_HEIGHT: Record<string, number>;
|
|
15
|
+
export interface SatelliteHost {
|
|
16
|
+
hierarchyLevel?: string;
|
|
17
|
+
childCardType?: string;
|
|
18
|
+
innerLoopExpanded?: boolean;
|
|
19
|
+
innerLoopExtraFields?: string[];
|
|
20
|
+
innerLoopRemovedFields?: string[];
|
|
21
|
+
innerLoopSizes?: Record<string, {
|
|
22
|
+
width?: number;
|
|
23
|
+
height?: number;
|
|
24
|
+
}>;
|
|
25
|
+
subnodeCollapsed?: Record<string, boolean>;
|
|
26
|
+
[k: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
export declare const canHostInnerLoop: (d: SatelliteHost | undefined) => boolean;
|
|
29
|
+
export declare function activeSatelliteFields(d: SatelliteHost | undefined): InnerLoopField[];
|
|
30
|
+
export declare function satelliteItems(d: SatelliteHost | undefined): Array<{
|
|
31
|
+
field: InnerLoopField;
|
|
32
|
+
width: number;
|
|
33
|
+
height: number;
|
|
34
|
+
}>;
|
|
35
|
+
export declare const openSatellites: (d: SatelliteHost | undefined) => {
|
|
36
|
+
field: InnerLoopField;
|
|
37
|
+
width: number;
|
|
38
|
+
height: number;
|
|
39
|
+
}[];
|
|
40
|
+
export declare function satelliteRowWidth(d: SatelliteHost | undefined): number;
|
|
41
|
+
export declare function satelliteBandHeight(d: SatelliteHost | undefined, hostHeight?: number): number;
|
|
42
|
+
export declare function satellitePositions(d: SatelliteHost | undefined, host: {
|
|
43
|
+
x: number;
|
|
44
|
+
y: number;
|
|
45
|
+
width: number;
|
|
46
|
+
height: number;
|
|
47
|
+
}): Array<{
|
|
48
|
+
field: InnerLoopField;
|
|
49
|
+
x: number;
|
|
50
|
+
y: number;
|
|
51
|
+
width: number;
|
|
52
|
+
height: number;
|
|
53
|
+
}>;
|
|
54
|
+
export {};
|
|
@@ -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.3.
|
|
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
|
@@ -10,15 +10,16 @@ import { registerRadbugsTools } from './toolsets/radbugs.js';
|
|
|
10
10
|
import { registerResources } from './resources.js';
|
|
11
11
|
import { registerPrompts } from './prompts.js';
|
|
12
12
|
export const SERVER_NAME = 'nonbot-mcp';
|
|
13
|
-
export const SERVER_VERSION = '0.3.
|
|
13
|
+
export const SERVER_VERSION = '0.3.1';
|
|
14
14
|
export const INSTRUCTIONS = [
|
|
15
15
|
'non.bot MCP — work against the caller’s real product-management data, not a copy.',
|
|
16
16
|
'Two domains: PORTFOLIOS (the SAFe portfolio canvas — epics → features → stories) and',
|
|
17
17
|
'WHYGRAPH (the PM decision graph — the rules and decisions the team has committed to).',
|
|
18
18
|
'Read before you write: call the *_list / *_get / *_search tools first.',
|
|
19
19
|
'BUILDING: portfolio_create makes an empty portfolio; portfolio_add_items adds items of any level',
|
|
20
|
-
'(parents / OKR links / dependencies by id or by a key from the same call) and lays the
|
|
21
|
-
'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',
|
|
22
23
|
'context text and palette; portfolio_asset_upload stores an image; ip_create adds an IP node over its sources.',
|
|
23
24
|
'OWNERS: give every feature, story, enabler and milestone an owner so the human can see what is theirs —',
|
|
24
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,',
|
package/dist/core/style.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Level, type LayoutNode } from './layout.js';
|
|
2
|
+
import type { InnerLoopField } from './satellites.js';
|
|
2
3
|
export declare const CARD_STYLES: readonly ["plain", "poster", "split", "image-top", "cover", "grid-2", "grid-4"];
|
|
3
4
|
export declare const TITLE_SIZES: readonly ["sm", "md", "lg"];
|
|
4
5
|
export declare const ACCENTS: readonly ["tint", "bar", "plain"];
|
|
@@ -20,7 +21,9 @@ export interface StyleRule {
|
|
|
20
21
|
width: number;
|
|
21
22
|
height: number;
|
|
22
23
|
} | 'auto';
|
|
23
|
-
satellites?: 'close';
|
|
24
|
+
satellites?: 'open' | 'close';
|
|
25
|
+
innerLoopRemovedFields?: InnerLoopField[];
|
|
26
|
+
epicColor?: string | null;
|
|
24
27
|
}
|
|
25
28
|
export interface StyleResult<T> {
|
|
26
29
|
nodes: T[];
|
package/dist/core/style.js
CHANGED
|
@@ -52,6 +52,18 @@ export function applyStyleRules(nodes, rules) {
|
|
|
52
52
|
data.canvasSize = { width: Math.round(rule.size.width), height: Math.round(rule.size.height) };
|
|
53
53
|
if (rule.satellites === 'close')
|
|
54
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
|
+
}
|
|
55
67
|
});
|
|
56
68
|
if (!data)
|
|
57
69
|
return n;
|
|
@@ -2,6 +2,7 @@ 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
|
+
import { type InnerLoopField } from '../satellites.js';
|
|
5
6
|
export type Node = LayoutNode & {
|
|
6
7
|
id: string;
|
|
7
8
|
type?: string;
|
|
@@ -35,11 +36,11 @@ declare const itemSpec: z.ZodObject<{
|
|
|
35
36
|
key: z.ZodOptional<z.ZodString>;
|
|
36
37
|
level: z.ZodEnum<{
|
|
37
38
|
epic: "epic";
|
|
38
|
-
objective: "objective";
|
|
39
|
-
"key-result": "key-result";
|
|
40
39
|
feature: "feature";
|
|
41
40
|
story: "story";
|
|
42
41
|
enabler: "enabler";
|
|
42
|
+
objective: "objective";
|
|
43
|
+
"key-result": "key-result";
|
|
43
44
|
milestone: "milestone";
|
|
44
45
|
}>;
|
|
45
46
|
title: z.ZodString;
|
|
@@ -80,7 +81,10 @@ export declare function ownerReport(specs: Array<Pick<ItemSpec, 'key' | 'level'
|
|
|
80
81
|
owners: Record<string, number>;
|
|
81
82
|
unassigned: string[];
|
|
82
83
|
};
|
|
83
|
-
export declare
|
|
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): {
|
|
84
88
|
nodes: Node[];
|
|
85
89
|
edges: Edge[];
|
|
86
90
|
ids: Record<string, string>;
|
|
@@ -1,7 +1,9 @@
|
|
|
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, layoutMapDetailed, applyMapLayout, 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';
|
|
6
8
|
import { withMapHeaders } from '../mapHeaders.js';
|
|
7
9
|
import { renderPreviewSvg } from '../preview.js';
|
|
@@ -91,7 +93,9 @@ export function ownerReport(specs) {
|
|
|
91
93
|
}
|
|
92
94
|
return { owners, unassigned };
|
|
93
95
|
}
|
|
94
|
-
export
|
|
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) {
|
|
95
99
|
const errors = [];
|
|
96
100
|
const ids = {};
|
|
97
101
|
const t0 = now.getTime();
|
|
@@ -141,7 +145,13 @@ export function buildItems(g, specs, now = new Date()) {
|
|
|
141
145
|
d.okrStatus = s.okr_status;
|
|
142
146
|
if (s.images?.length)
|
|
143
147
|
d.pastedImages = s.images;
|
|
144
|
-
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')
|
|
145
155
|
d.innerLoopExpanded = false;
|
|
146
156
|
for (const [k, v] of Object.entries(s.fields ?? {})) {
|
|
147
157
|
if (RESERVED.has(k))
|
|
@@ -185,7 +195,7 @@ export function buildItems(g, specs, now = new Date()) {
|
|
|
185
195
|
export function registerBuildTools(server, ctx) {
|
|
186
196
|
server.registerTool('portfolio_preview', {
|
|
187
197
|
title: 'Picture a portfolio layout',
|
|
188
|
-
description: 'Draw the portfolio as an SVG schematic — every card at its stored Map (default) or Timeline position and size, with its level, state and title (done cards pale green), section-header notes as headings — so you can LOOK at a layout before calling it done. Returns the SVG text plus the card count and extent. `sizes` (px per id) overrides the stored / estimated card sizes; `width` sets the output width.',
|
|
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.',
|
|
189
199
|
inputSchema: {
|
|
190
200
|
portfolio_id: z.string(),
|
|
191
201
|
view: z.enum(['map', 'timeline']).optional(),
|
|
@@ -196,7 +206,7 @@ export function registerBuildTools(server, ctx) {
|
|
|
196
206
|
}, async (a) => {
|
|
197
207
|
const g = await loadGraph(ctx, a.portfolio_id);
|
|
198
208
|
const r = renderPreviewSvg(g.nodes, { view: a.view, sizes: a.sizes, width: a.width });
|
|
199
|
-
return { content: [{ type: 'text', text: JSON.stringify({ cards: r.cards, extent: r.extent }) }, { type: 'text', text: r.svg }] };
|
|
209
|
+
return { content: [{ type: 'text', text: JSON.stringify({ cards: r.cards, satellites: r.satellites, extent: r.extent }) }, { type: 'text', text: r.svg }] };
|
|
200
210
|
});
|
|
201
211
|
if (ctx.readonly)
|
|
202
212
|
return;
|
|
@@ -225,11 +235,11 @@ export function registerBuildTools(server, ctx) {
|
|
|
225
235
|
});
|
|
226
236
|
server.registerTool('portfolio_add_items', {
|
|
227
237
|
title: 'Add items to a portfolio',
|
|
228
|
-
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.',
|
|
229
239
|
inputSchema: {
|
|
230
240
|
portfolio_id: z.string(),
|
|
231
241
|
items: z.array(itemSpec).min(1).max(MAX_ITEMS),
|
|
232
|
-
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.'),
|
|
233
243
|
expected_version: z.number().int().optional().describe('Optional OCC guard; default = the version read at call time.'),
|
|
234
244
|
},
|
|
235
245
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -238,18 +248,24 @@ export function registerBuildTools(server, ctx) {
|
|
|
238
248
|
const keys = a.items.map(s => s.key).filter(Boolean);
|
|
239
249
|
if (new Set(keys).size !== keys.length)
|
|
240
250
|
return fail('Two items share a key — keys must be unique within one call.');
|
|
241
|
-
const
|
|
251
|
+
const layout = a.layout ?? ADD_ITEMS_DEFAULT_LAYOUT;
|
|
252
|
+
const built = buildItems(g, a.items, new Date(), layout);
|
|
242
253
|
if (built.errors.length)
|
|
243
254
|
return fail(`Nothing was written — ${built.errors.length} problem(s):\n` + built.errors.map(e => ` - ${e}`).join('\n'));
|
|
244
255
|
let nodes = [...g.nodes.map(n => built.patched.has(n.id) ? { ...n, data: built.patched.get(n.id) } : n), ...built.nodes];
|
|
245
|
-
|
|
256
|
+
const edges = [...g.edges, ...built.edges];
|
|
257
|
+
if (layout === 'timeline')
|
|
246
258
|
nodes = applyLayout(nodes, layoutTimeline(nodes));
|
|
247
|
-
|
|
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 };
|
|
248
264
|
const r = await saveGraph(ctx, a.portfolio_id, g, after, a.expected_version);
|
|
249
265
|
if (!r.ok)
|
|
250
266
|
return fail(r.message);
|
|
251
267
|
const who = ownerReport(a.items);
|
|
252
|
-
const structured = { ok: true, added: built.nodes.length, edges: built.edges.length, ids: built.ids, owners: who.owners, unassigned: who.unassigned, ...(r.version !== undefined ? { version: r.version } : {}) };
|
|
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 } : {}) };
|
|
253
269
|
const content = [text(structured)];
|
|
254
270
|
if (who.unassigned.length)
|
|
255
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.` });
|
|
@@ -283,23 +299,55 @@ export function registerBuildTools(server, ctx) {
|
|
|
283
299
|
return { content: [text(structured)], structuredContent: structured };
|
|
284
300
|
});
|
|
285
301
|
server.registerTool('portfolio_layout', {
|
|
286
|
-
title: 'Lay out a portfolio
|
|
287
|
-
description: 'Re-position every item so the Timeline reads cleanly: x from each item\'s start date (the canvas reads dates off x), y top-down — objectives, key results, then one block per epic (epic, features, stories + enablers, milestones), each row lane-packed so overlapping items stack, IP nodes directly above their first source. Short items get a minimum painted width so titles fit. Content is untouched. `heights` overrides the estimated card height per id (px) when a card measures taller on screen. `group: "objective"` reads top-down by goal instead: each objective, its key results, then the blocks of the epics those key results link to, before the next objective (`objective_order` lists objective ids to put first); unlinked epics follow. Grouping changes rows only, never x or dates. `view: "map"` lays out the Map instead (the free layout, `freePosition` only — the Timeline positions and dates are untouched): one band per objective (`objective_order` first), the objective + key results leading it, then one column per linked epic with its features and a 3-wide grid of stories; unlinked epics, loose items and modules follow.',
|
|
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.',
|
|
288
304
|
inputSchema: {
|
|
289
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.'),
|
|
290
307
|
heights: z.record(z.string(), z.number().min(20).max(2000)).optional(),
|
|
291
|
-
sizes: z.record(z.string(), z.object({ width: z.number().min(1).max(8000), height: z.number().min(1).max(8000) })).optional().describe('With view "map": measured card sizes (px) per id — Map cards are as wide as their content.'),
|
|
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.'),
|
|
292
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.'),
|
|
293
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).'),
|
|
294
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.'),
|
|
295
312
|
view: z.enum(['timeline', 'map']).optional().describe('Default timeline. "map": write the Map (free) positions only, grouped by objective.'),
|
|
296
313
|
group: z.enum(['objective']).optional().describe('"objective": group epic blocks under the objective whose key results link to them.'),
|
|
297
|
-
objective_order: z.array(z.string()).max(100).optional().describe('With group "objective": objective ids to place first, in this order.'),
|
|
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.'),
|
|
298
315
|
expected_version: z.number().int().optional(),
|
|
299
316
|
},
|
|
300
317
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
301
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
|
+
}
|
|
302
328
|
const g = await loadGraph(ctx, a.portfolio_id);
|
|
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
|
+
}
|
|
303
351
|
if (a.view === 'map') {
|
|
304
352
|
const HEADER_H = 220;
|
|
305
353
|
const cards = g.nodes.filter(n => !(n.type === 'rad-module' && n.data?.moduleData?.__layoutHeader));
|
|
@@ -326,7 +374,7 @@ export function registerBuildTools(server, ctx) {
|
|
|
326
374
|
});
|
|
327
375
|
server.registerTool('portfolio_style', {
|
|
328
376
|
title: 'Restyle many cards at once',
|
|
329
|
-
description: 'Give a portfolio one consistent look in ONE versioned write: rules, applied in order, each matching cards by levels / states / ids / hasImage and setting how they are drawn — display (card_style plain|poster|split|image-top|cover|grid-2|grid-4, title_size sm|md|lg, accent tint|bar|plain, show {bluf, description, acceptanceCriteria, tags, dates, wsjf}), size (Map width × height, or "auto" to size to content)
|
|
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.',
|
|
330
378
|
inputSchema: {
|
|
331
379
|
portfolio_id: z.string(),
|
|
332
380
|
rules: z.array(z.object({
|
|
@@ -342,7 +390,9 @@ export function registerBuildTools(server, ctx) {
|
|
|
342
390
|
show: z.object(Object.fromEntries(SHOW_KEYS.map(k => [k, z.boolean().optional()]))).optional(),
|
|
343
391
|
}).optional(),
|
|
344
392
|
size: z.union([z.object({ width: z.number().min(60).max(4000), height: z.number().min(40).max(4000) }), z.literal('auto')]).optional(),
|
|
345
|
-
satellites: z.
|
|
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.'),
|
|
346
396
|
})).min(1).max(40),
|
|
347
397
|
expected_version: z.number().int().optional(),
|
|
348
398
|
},
|
|
@@ -358,6 +408,8 @@ export function registerBuildTools(server, ctx) {
|
|
|
358
408
|
...(r.display ? { display: { cardStyle: r.display.card_style, titleSize: r.display.title_size, accent: r.display.accent, show: r.display.show } } : {}),
|
|
359
409
|
...(r.size ? { size: r.size } : {}),
|
|
360
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 } : {}),
|
|
361
413
|
}));
|
|
362
414
|
const res = applyStyleRules(g.nodes, rules);
|
|
363
415
|
if (res.changed === 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nonbot/mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
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": [
|