groove-dev 0.26.38 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/CLAUDE.md +24 -19
- package/node_modules/@groove-dev/cli/bin/groove.js +2 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/cli/src/commands/nuke.js +16 -4
- package/node_modules/@groove-dev/cli/src/commands/stop.js +17 -2
- package/node_modules/@groove-dev/daemon/integrations-registry.json +681 -75
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/adaptive.js +23 -25
- package/node_modules/@groove-dev/daemon/src/api.js +346 -22
- package/node_modules/@groove-dev/daemon/src/classifier.js +53 -6
- package/node_modules/@groove-dev/daemon/src/firstrun.js +14 -1
- package/node_modules/@groove-dev/daemon/src/gateways/manager.js +2 -2
- package/node_modules/@groove-dev/daemon/src/index.js +28 -4
- package/node_modules/@groove-dev/daemon/src/integrations.js +215 -14
- package/node_modules/@groove-dev/daemon/src/introducer.js +84 -11
- package/node_modules/@groove-dev/daemon/src/journalist.js +43 -1
- package/node_modules/@groove-dev/daemon/src/lockmanager.js +60 -0
- package/node_modules/@groove-dev/daemon/src/mcp-manager.js +270 -0
- package/node_modules/@groove-dev/daemon/src/memory.js +370 -0
- package/node_modules/@groove-dev/daemon/src/pm.js +1 -1
- package/node_modules/@groove-dev/daemon/src/process.js +141 -9
- package/node_modules/@groove-dev/daemon/src/registry.js +1 -1
- package/node_modules/@groove-dev/daemon/src/rotator.js +334 -31
- package/node_modules/@groove-dev/daemon/src/router.js +43 -0
- package/node_modules/@groove-dev/daemon/src/tokentracker.js +70 -18
- package/node_modules/@groove-dev/daemon/src/validate.js +5 -13
- package/node_modules/@groove-dev/daemon/templates/groove-slides.cjs +306 -0
- package/node_modules/@groove-dev/daemon/test/classifier.test.js +3 -5
- package/node_modules/@groove-dev/daemon/test/lockmanager.test.js +64 -0
- package/node_modules/@groove-dev/daemon/test/memory.test.js +252 -0
- package/node_modules/@groove-dev/daemon/test/rotator.test.js +108 -0
- package/node_modules/@groove-dev/daemon/test/router.test.js +64 -0
- package/node_modules/@groove-dev/daemon/test/slides-engine.test.js +230 -0
- package/node_modules/@groove-dev/daemon/test/tokentracker.test.js +78 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-DjORRpF0.css +1 -0
- package/node_modules/@groove-dev/gui/dist/assets/index-eCrVowF0.js +652 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -4
- package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +26 -17
- package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +22 -1
- package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +53 -21
- package/node_modules/@groove-dev/gui/src/components/agents/agent-node.jsx +132 -90
- package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +212 -1
- package/node_modules/@groove-dev/gui/src/components/dashboard/cache-ring.jsx +6 -2
- package/node_modules/@groove-dev/gui/src/components/dashboard/intel-panel.jsx +495 -174
- package/node_modules/@groove-dev/gui/src/components/dashboard/kpi-card.jsx +12 -2
- package/node_modules/@groove-dev/gui/src/components/dashboard/team-burn-panel.jsx +55 -0
- package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +3 -3
- package/node_modules/@groove-dev/gui/src/components/layout/app-shell.jsx +24 -19
- package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +2 -2
- package/node_modules/@groove-dev/gui/src/components/marketplace/integration-wizard.jsx +391 -61
- package/node_modules/@groove-dev/gui/src/components/marketplace/marketplace-card.jsx +29 -7
- package/node_modules/@groove-dev/gui/src/lib/format.js +0 -6
- package/node_modules/@groove-dev/gui/src/lib/hooks/use-dashboard.js +23 -5
- package/node_modules/@groove-dev/gui/src/stores/groove.js +59 -9
- package/node_modules/@groove-dev/gui/src/views/agents.jsx +84 -10
- package/node_modules/@groove-dev/gui/src/views/dashboard.jsx +24 -21
- package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +153 -85
- package/package.json +2 -8
- package/packages/cli/bin/groove.js +2 -0
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/commands/nuke.js +16 -4
- package/packages/cli/src/commands/stop.js +17 -2
- package/packages/daemon/integrations-registry.json +681 -75
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/adaptive.js +23 -25
- package/packages/daemon/src/api.js +346 -22
- package/packages/daemon/src/classifier.js +53 -6
- package/packages/daemon/src/firstrun.js +14 -1
- package/packages/daemon/src/gateways/manager.js +2 -2
- package/packages/daemon/src/index.js +28 -4
- package/packages/daemon/src/integrations.js +215 -14
- package/packages/daemon/src/introducer.js +84 -11
- package/packages/daemon/src/journalist.js +43 -1
- package/packages/daemon/src/lockmanager.js +60 -0
- package/packages/daemon/src/mcp-manager.js +270 -0
- package/packages/daemon/src/memory.js +370 -0
- package/packages/daemon/src/pm.js +1 -1
- package/packages/daemon/src/process.js +141 -9
- package/packages/daemon/src/registry.js +1 -1
- package/packages/daemon/src/rotator.js +334 -31
- package/packages/daemon/src/router.js +43 -0
- package/packages/daemon/src/tokentracker.js +70 -18
- package/packages/daemon/src/validate.js +5 -13
- package/packages/daemon/templates/groove-slides.cjs +306 -0
- package/packages/gui/dist/assets/index-DjORRpF0.css +1 -0
- package/packages/gui/dist/assets/index-eCrVowF0.js +652 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -4
- package/packages/gui/src/components/agents/agent-chat.jsx +26 -17
- package/packages/gui/src/components/agents/agent-config.jsx +22 -1
- package/packages/gui/src/components/agents/agent-feed.jsx +53 -21
- package/packages/gui/src/components/agents/agent-node.jsx +132 -90
- package/packages/gui/src/components/agents/spawn-wizard.jsx +212 -1
- package/packages/gui/src/components/dashboard/cache-ring.jsx +6 -2
- package/packages/gui/src/components/dashboard/intel-panel.jsx +495 -174
- package/packages/gui/src/components/dashboard/kpi-card.jsx +12 -2
- package/packages/gui/src/components/dashboard/team-burn-panel.jsx +55 -0
- package/packages/gui/src/components/layout/activity-bar.jsx +3 -3
- package/packages/gui/src/components/layout/app-shell.jsx +24 -19
- package/packages/gui/src/components/layout/command-palette.jsx +2 -2
- package/packages/gui/src/components/marketplace/integration-wizard.jsx +391 -61
- package/packages/gui/src/components/marketplace/marketplace-card.jsx +29 -7
- package/packages/gui/src/lib/format.js +0 -6
- package/packages/gui/src/lib/hooks/use-dashboard.js +23 -5
- package/packages/gui/src/stores/groove.js +59 -9
- package/packages/gui/src/views/agents.jsx +84 -10
- package/packages/gui/src/views/dashboard.jsx +24 -21
- package/packages/gui/src/views/marketplace.jsx +153 -85
- package/node_modules/@groove-dev/gui/dist/assets/index-CEFKgLGB.css +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/index-CaKBNWcK.js +0 -638
- package/node_modules/@groove-dev/gui/dist/groove-logo-short.png +0 -0
- package/node_modules/@groove-dev/gui/dist/groove-logo.png +0 -0
- package/node_modules/@groove-dev/gui/public/groove-logo-short.png +0 -0
- package/node_modules/@groove-dev/gui/public/groove-logo.png +0 -0
- package/node_modules/@groove-dev/gui/src/components/ui/dropdown-menu.jsx +0 -60
- package/node_modules/@groove-dev/gui/src/lib/hooks/use-media-query.js +0 -18
- package/node_modules/@radix-ui/react-dropdown-menu/LICENSE +0 -21
- package/node_modules/@radix-ui/react-dropdown-menu/README.md +0 -3
- package/node_modules/@radix-ui/react-dropdown-menu/dist/index.d.mts +0 -97
- package/node_modules/@radix-ui/react-dropdown-menu/dist/index.d.ts +0 -97
- package/node_modules/@radix-ui/react-dropdown-menu/dist/index.js +0 -337
- package/node_modules/@radix-ui/react-dropdown-menu/dist/index.js.map +0 -7
- package/node_modules/@radix-ui/react-dropdown-menu/dist/index.mjs +0 -305
- package/node_modules/@radix-ui/react-dropdown-menu/dist/index.mjs.map +0 -7
- package/node_modules/@radix-ui/react-dropdown-menu/package.json +0 -75
- package/node_modules/@radix-ui/react-popover/LICENSE +0 -21
- package/node_modules/@radix-ui/react-popover/README.md +0 -3
- package/node_modules/@radix-ui/react-popover/dist/index.d.mts +0 -85
- package/node_modules/@radix-ui/react-popover/dist/index.d.ts +0 -85
- package/node_modules/@radix-ui/react-popover/dist/index.js +0 -352
- package/node_modules/@radix-ui/react-popover/dist/index.js.map +0 -7
- package/node_modules/@radix-ui/react-popover/dist/index.mjs +0 -320
- package/node_modules/@radix-ui/react-popover/dist/index.mjs.map +0 -7
- package/node_modules/@radix-ui/react-popover/package.json +0 -82
- package/node_modules/@radix-ui/react-separator/LICENSE +0 -21
- package/node_modules/@radix-ui/react-separator/README.md +0 -3
- package/node_modules/@radix-ui/react-separator/dist/index.d.mts +0 -21
- package/node_modules/@radix-ui/react-separator/dist/index.d.ts +0 -21
- package/node_modules/@radix-ui/react-separator/dist/index.js +0 -65
- package/node_modules/@radix-ui/react-separator/dist/index.js.map +0 -7
- package/node_modules/@radix-ui/react-separator/dist/index.mjs +0 -32
- package/node_modules/@radix-ui/react-separator/dist/index.mjs.map +0 -7
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/LICENSE +0 -21
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/README.md +0 -3
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.d.mts +0 -52
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.d.ts +0 -52
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.js +0 -80
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.js.map +0 -7
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.mjs +0 -47
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.mjs.map +0 -7
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/package.json +0 -69
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/LICENSE +0 -21
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/README.md +0 -3
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.d.mts +0 -22
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.d.ts +0 -22
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.js +0 -152
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.js.map +0 -7
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.mjs +0 -119
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.mjs.map +0 -7
- package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/package.json +0 -64
- package/node_modules/@radix-ui/react-separator/package.json +0 -69
- package/packages/gui/dist/assets/index-CEFKgLGB.css +0 -1
- package/packages/gui/dist/assets/index-CaKBNWcK.js +0 -638
- package/packages/gui/dist/groove-logo-short.png +0 -0
- package/packages/gui/dist/groove-logo.png +0 -0
- package/packages/gui/public/groove-logo-short.png +0 -0
- package/packages/gui/public/groove-logo.png +0 -0
- package/packages/gui/src/components/ui/dropdown-menu.jsx +0 -60
- package/packages/gui/src/lib/hooks/use-media-query.js +0 -18
|
@@ -75,6 +75,10 @@ export function validateAgentConfig(config) {
|
|
|
75
75
|
integrations = config.integrations.filter((s) => typeof s === 'string' && s.length > 0 && s.length <= 100);
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// Validate integration approval mode
|
|
79
|
+
const validApprovalModes = ['auto', 'manual'];
|
|
80
|
+
const integrationApproval = validApprovalModes.includes(config.integrationApproval) ? config.integrationApproval : 'manual';
|
|
81
|
+
|
|
78
82
|
// Return sanitized config (only known fields)
|
|
79
83
|
return {
|
|
80
84
|
role: config.role,
|
|
@@ -88,6 +92,7 @@ export function validateAgentConfig(config) {
|
|
|
88
92
|
permission,
|
|
89
93
|
skills,
|
|
90
94
|
integrations,
|
|
95
|
+
integrationApproval,
|
|
91
96
|
};
|
|
92
97
|
}
|
|
93
98
|
|
|
@@ -122,19 +127,6 @@ export function validateTeamName(name) {
|
|
|
122
127
|
// Allow spaces and special chars in display name, sanitize for filesystem separately
|
|
123
128
|
}
|
|
124
129
|
|
|
125
|
-
export function sanitizeForFilename(name) {
|
|
126
|
-
const sanitized = name
|
|
127
|
-
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
128
|
-
.replace(/_+/g, '_')
|
|
129
|
-
.toLowerCase()
|
|
130
|
-
.slice(0, 50);
|
|
131
|
-
|
|
132
|
-
if (!sanitized || /^_+$/.test(sanitized)) {
|
|
133
|
-
throw new Error('Team name must contain alphanumeric characters');
|
|
134
|
-
}
|
|
135
|
-
return sanitized;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
130
|
const VALID_GATEWAY_TYPES = ['telegram', 'discord', 'slack'];
|
|
139
131
|
const VALID_NOTIFICATION_PRESETS = ['critical', 'lifecycle', 'all', 'custom'];
|
|
140
132
|
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// GROOVE — Slide Deck Layout Engine
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
//
|
|
4
|
+
// This file is written into every `slides` role agent's working directory at spawn time.
|
|
5
|
+
// Do not modify it — Groove overwrites it on every spawn. Theme / style / visual
|
|
6
|
+
// components come from a marketplace skill. Layout, measurement, and validation are
|
|
7
|
+
// core and baked in here so decks never ship broken regardless of which skill (if any)
|
|
8
|
+
// is attached.
|
|
9
|
+
//
|
|
10
|
+
// Usage in convert-slides.js:
|
|
11
|
+
//
|
|
12
|
+
// const pptxgen = require('pptxgenjs');
|
|
13
|
+
// const {
|
|
14
|
+
// LAYOUT, estimateTextHeight, fitFontSize,
|
|
15
|
+
// checkCollisions, checkBounds, checkTextWrap, hardGate, tracker,
|
|
16
|
+
// } = require('./groove-slides.js');
|
|
17
|
+
//
|
|
18
|
+
// const pres = new pptxgen();
|
|
19
|
+
// pres.layout = 'LAYOUT_16x9';
|
|
20
|
+
//
|
|
21
|
+
// function renderMetricsSlide(data) {
|
|
22
|
+
// const slide = pres.addSlide();
|
|
23
|
+
// const { track, placed } = tracker();
|
|
24
|
+
//
|
|
25
|
+
// let Y = LAYOUT.CONTENT_START;
|
|
26
|
+
//
|
|
27
|
+
// // Title — box height budgeted first, then font size fit to it
|
|
28
|
+
// const titleBoxH = 1.1;
|
|
29
|
+
// const titleSize = fitFontSize(data.title, 42, 24, LAYOUT.SAFE_W, titleBoxH, { bold: true });
|
|
30
|
+
// slide.addText(data.title, track('title', {
|
|
31
|
+
// x: LAYOUT.SAFE_X, y: Y, w: LAYOUT.SAFE_W, h: titleBoxH,
|
|
32
|
+
// text: data.title, fontSize: titleSize, bold: true,
|
|
33
|
+
// }));
|
|
34
|
+
// Y += titleBoxH + LAYOUT.GAP_STD;
|
|
35
|
+
// // ... more placements ...
|
|
36
|
+
// return { name: 'metrics', elements: placed };
|
|
37
|
+
// }
|
|
38
|
+
//
|
|
39
|
+
// const allSlides = [renderMetricsSlide(data.slides[0]), ...];
|
|
40
|
+
// hardGate(allSlides.map((s) => [s.name, s.elements])); // exits 1 on any issue
|
|
41
|
+
// await pres.writeFile({ fileName: 'deck.pptx' });
|
|
42
|
+
//
|
|
43
|
+
// Rules (enforced by hardGate — no bypasses):
|
|
44
|
+
// 1. Every text and shape you place MUST be pushed to `placed[]` via `track()`.
|
|
45
|
+
// 2. NEVER add `skipCollision`, `skipBounds`, `skipWrap`, or any other bypass flag.
|
|
46
|
+
// If a legitimate design element overflows the safe area (e.g., an orb bleeding
|
|
47
|
+
// off-slide), use addImage() WITHOUT tracking it. The gate only validates what
|
|
48
|
+
// you declare placed.
|
|
49
|
+
// 3. DO NOT use `shrinkText: true`. PowerPoint honors it; Google Slides and
|
|
50
|
+
// LibreOffice PDF export largely do not — text renders at its declared size
|
|
51
|
+
// and overflows. Always use `fitFontSize()` to compute a real font size that
|
|
52
|
+
// fits the box.
|
|
53
|
+
// 4. Y advances MUST come from `estimateTextHeight()` or an explicit box budget,
|
|
54
|
+
// never a hardcoded magic number like `Y += 0.95`.
|
|
55
|
+
// 5. Every call to `hardGate()` is a build gate. Do not wrap it in try/catch.
|
|
56
|
+
// A failing gate is a build failure — fix the slide generator, don't silence
|
|
57
|
+
// the check.
|
|
58
|
+
|
|
59
|
+
// ─── Layout constants ───────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
const LAYOUT = Object.freeze({
|
|
62
|
+
// 16:9 canvas
|
|
63
|
+
SLIDE_W: 10,
|
|
64
|
+
SLIDE_H: 5.625,
|
|
65
|
+
|
|
66
|
+
// Safe margins
|
|
67
|
+
SAFE_X: 0.6,
|
|
68
|
+
SAFE_R: 9.4,
|
|
69
|
+
SAFE_W: 8.8,
|
|
70
|
+
SAFE_TOP: 0.25, // top of brand zone
|
|
71
|
+
SAFE_BOT: 5.125, // below this = clipped
|
|
72
|
+
CONTENT_START: 0.65, // below branding — first Y for content
|
|
73
|
+
CONTENT_H: 4.475, // SAFE_BOT - CONTENT_START
|
|
74
|
+
|
|
75
|
+
// Branding reserved zone (do not place content here)
|
|
76
|
+
BRAND_Y: 0.25,
|
|
77
|
+
BRAND_H: 0.3,
|
|
78
|
+
|
|
79
|
+
// Gaps between elements
|
|
80
|
+
GAP_TIGHT: 0.1,
|
|
81
|
+
GAP_STD: 0.25,
|
|
82
|
+
GAP_SECTION: 0.4,
|
|
83
|
+
GAP_DRAMATIC: 0.8,
|
|
84
|
+
|
|
85
|
+
// Inset for text beside an accent border / inside a column
|
|
86
|
+
TEXT_INSET: 0.2,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// ─── Measurement ────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Estimate the rendered height of a text box in inches.
|
|
93
|
+
* Handles word-wrap, explicit newlines, and bold/serif width adjustments.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} text
|
|
96
|
+
* @param {number} fontSize points
|
|
97
|
+
* @param {number} boxWidth inches
|
|
98
|
+
* @param {object} [opts]
|
|
99
|
+
* @param {boolean} [opts.bold=false]
|
|
100
|
+
* @param {number} [opts.margin=0.1] side margin pptxgenjs applies inside a text box
|
|
101
|
+
* @returns {number} height in inches
|
|
102
|
+
*/
|
|
103
|
+
function estimateTextHeight(text, fontSize, boxWidth, opts = {}) {
|
|
104
|
+
if (typeof text !== 'string' || text.length === 0) return fontSize / 72;
|
|
105
|
+
|
|
106
|
+
const bold = opts.bold === true;
|
|
107
|
+
const margin = typeof opts.margin === 'number' ? opts.margin : 0.1;
|
|
108
|
+
|
|
109
|
+
// Average glyph width as a fraction of font size (pt → in)
|
|
110
|
+
// Bold text is ~8% wider in typical sans-serif.
|
|
111
|
+
const charWidthFactor = bold ? 0.0075 : 0.0070;
|
|
112
|
+
const avgCharWidth = fontSize * charWidthFactor;
|
|
113
|
+
|
|
114
|
+
const effectiveWidth = Math.max(boxWidth - margin * 2, 0.1);
|
|
115
|
+
const charsPerLine = Math.max(1, Math.floor(effectiveWidth / avgCharWidth));
|
|
116
|
+
|
|
117
|
+
// Count wrapped lines — words don't break mid-word.
|
|
118
|
+
// Also respect explicit newlines.
|
|
119
|
+
const paragraphs = text.split('\n');
|
|
120
|
+
let lines = 0;
|
|
121
|
+
for (const p of paragraphs) {
|
|
122
|
+
if (p.length === 0) { lines += 1; continue; }
|
|
123
|
+
const words = p.split(/\s+/).filter(Boolean);
|
|
124
|
+
if (words.length === 0) { lines += 1; continue; }
|
|
125
|
+
let lineLen = 0;
|
|
126
|
+
let paraLines = 1;
|
|
127
|
+
for (const w of words) {
|
|
128
|
+
const addLen = (lineLen > 0 ? 1 : 0) + w.length;
|
|
129
|
+
if (lineLen + addLen > charsPerLine && lineLen > 0) {
|
|
130
|
+
paraLines += 1;
|
|
131
|
+
lineLen = w.length;
|
|
132
|
+
} else {
|
|
133
|
+
lineLen += addLen;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
lines += paraLines;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// line-height ≈ 1.3 × fontSize
|
|
140
|
+
const lineHeight = (fontSize * 1.3) / 72;
|
|
141
|
+
return Math.max(lines * lineHeight, fontSize / 72);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Find the largest font size in [minSize, maxSize] (integer points) that fits
|
|
146
|
+
* `text` in a box of `boxW` × `boxH` inches. Returns `minSize` if even that
|
|
147
|
+
* doesn't fit — caller should allocate more space or shorten the text.
|
|
148
|
+
*
|
|
149
|
+
* Use this instead of pptxgenjs `shrinkText: true`, which is inconsistently
|
|
150
|
+
* respected by Google Slides / LibreOffice PDF export.
|
|
151
|
+
*/
|
|
152
|
+
function fitFontSize(text, maxSize, minSize, boxW, boxH, opts = {}) {
|
|
153
|
+
if (maxSize < minSize) return minSize;
|
|
154
|
+
for (let size = Math.floor(maxSize); size >= Math.floor(minSize); size -= 1) {
|
|
155
|
+
const needed = estimateTextHeight(text, size, boxW, opts);
|
|
156
|
+
if (needed <= boxH) return size;
|
|
157
|
+
}
|
|
158
|
+
return Math.floor(minSize);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── Validation gates ──────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
function _fmt(n) { return Number(n).toFixed(2); }
|
|
164
|
+
|
|
165
|
+
function checkCollisions(elements) {
|
|
166
|
+
const issues = [];
|
|
167
|
+
for (let i = 0; i < elements.length; i += 1) {
|
|
168
|
+
for (let j = i + 1; j < elements.length; j += 1) {
|
|
169
|
+
const a = elements[i];
|
|
170
|
+
const b = elements[j];
|
|
171
|
+
const overlapX = a.x < b.x + b.w && a.x + a.w > b.x;
|
|
172
|
+
const overlapY = a.y < b.y + b.h && a.y + a.h > b.y;
|
|
173
|
+
if (overlapX && overlapY) {
|
|
174
|
+
issues.push(
|
|
175
|
+
`OVERLAP: "${a.name}" (${_fmt(a.x)},${_fmt(a.y)}→${_fmt(a.x + a.w)},${_fmt(a.y + a.h)}) ` +
|
|
176
|
+
`↔ "${b.name}" (${_fmt(b.x)},${_fmt(b.y)}→${_fmt(b.x + b.w)},${_fmt(b.y + b.h)})`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return issues;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function checkBounds(elements, opts = {}) {
|
|
185
|
+
const safeTop = typeof opts.safeTop === 'number' ? opts.safeTop : LAYOUT.SAFE_TOP;
|
|
186
|
+
const safeBot = typeof opts.safeBot === 'number' ? opts.safeBot : LAYOUT.SAFE_BOT;
|
|
187
|
+
const safeL = typeof opts.safeL === 'number' ? opts.safeL : 0;
|
|
188
|
+
const safeR = typeof opts.safeR === 'number' ? opts.safeR : LAYOUT.SLIDE_W;
|
|
189
|
+
const issues = [];
|
|
190
|
+
for (const el of elements) {
|
|
191
|
+
if (el.y < safeTop) {
|
|
192
|
+
issues.push(`TOP-OVERFLOW: "${el.name}" starts y=${_fmt(el.y)} (safe top ${_fmt(safeTop)})`);
|
|
193
|
+
}
|
|
194
|
+
if (el.y + el.h > safeBot) {
|
|
195
|
+
issues.push(`BOTTOM-OVERFLOW: "${el.name}" ends y=${_fmt(el.y + el.h)} (safe bottom ${_fmt(safeBot)}) — CLIPPED`);
|
|
196
|
+
}
|
|
197
|
+
if (el.x < safeL) {
|
|
198
|
+
issues.push(`LEFT-OVERFLOW: "${el.name}" starts x=${_fmt(el.x)} (safe left ${_fmt(safeL)})`);
|
|
199
|
+
}
|
|
200
|
+
if (el.x + el.w > safeR) {
|
|
201
|
+
issues.push(`RIGHT-OVERFLOW: "${el.name}" ends x=${_fmt(el.x + el.w)} (safe right ${_fmt(safeR)})`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return issues;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function checkTextWrap(elements, opts = {}) {
|
|
208
|
+
const slack = typeof opts.slack === 'number' ? opts.slack : 1.05;
|
|
209
|
+
const issues = [];
|
|
210
|
+
for (const el of elements) {
|
|
211
|
+
if (typeof el.text !== 'string' || typeof el.fontSize !== 'number') continue;
|
|
212
|
+
const needed = estimateTextHeight(el.text, el.fontSize, el.w, {
|
|
213
|
+
bold: el.bold === true,
|
|
214
|
+
margin: typeof el.margin === 'number' ? el.margin : 0.1,
|
|
215
|
+
});
|
|
216
|
+
if (needed > el.h * slack) {
|
|
217
|
+
issues.push(
|
|
218
|
+
`TEXT-WRAP: "${el.name}" needs ~${_fmt(needed)}" at ${el.fontSize}pt but got ${_fmt(el.h)}" — ` +
|
|
219
|
+
`use fitFontSize() to shrink, or increase box height.`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return issues;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Run every gate over every slide's placed elements. Exits the process with
|
|
228
|
+
* code 1 on any issue — this is intentional. Do not wrap in try/catch.
|
|
229
|
+
*
|
|
230
|
+
* @param {Array<[string, Array<object>]>} allSlideElements
|
|
231
|
+
* Pairs of [slideName, placedElements[]]. Each element must carry at least
|
|
232
|
+
* { name, x, y, w, h }. Text elements must also carry { text, fontSize }
|
|
233
|
+
* to participate in the wrap check.
|
|
234
|
+
*/
|
|
235
|
+
function hardGate(allSlideElements, opts = {}) {
|
|
236
|
+
const issues = [];
|
|
237
|
+
for (const [slideName, elements] of allSlideElements) {
|
|
238
|
+
const slideIssues = [
|
|
239
|
+
...checkCollisions(elements),
|
|
240
|
+
...checkBounds(elements, opts),
|
|
241
|
+
...checkTextWrap(elements, opts),
|
|
242
|
+
];
|
|
243
|
+
for (const msg of slideIssues) issues.push(`[${slideName}] ${msg}`);
|
|
244
|
+
}
|
|
245
|
+
if (issues.length === 0) {
|
|
246
|
+
console.log(`✓ Layout gate passed — ${allSlideElements.length} slide(s), zero issues.`);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
console.error(`\n✗ Layout gate FAILED — ${issues.length} issue(s):\n`);
|
|
250
|
+
for (const msg of issues) console.error(' ' + msg);
|
|
251
|
+
console.error('\nFix the slide generator(s) above. Do not add skip flags — the gate has no bypasses.');
|
|
252
|
+
console.error('For text overflow: use fitFontSize() to compute a real font size, or allocate more box height.');
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── Tracker helper ────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Convenience factory for tracking placed elements.
|
|
260
|
+
*
|
|
261
|
+
* const { track, placed } = tracker();
|
|
262
|
+
* slide.addText(data.title, track('title', { x, y, w, h, text, fontSize, bold }));
|
|
263
|
+
* return { name: 'my-slide', elements: placed };
|
|
264
|
+
*
|
|
265
|
+
* `track()` returns the same options object so you can inline it into
|
|
266
|
+
* `slide.addText()` / `slide.addShape()` calls.
|
|
267
|
+
*/
|
|
268
|
+
function tracker() {
|
|
269
|
+
const placed = [];
|
|
270
|
+
function track(name, opts) {
|
|
271
|
+
if (typeof name !== 'string' || !name) {
|
|
272
|
+
throw new Error('tracker.track(name, opts): name must be a non-empty string');
|
|
273
|
+
}
|
|
274
|
+
if (!opts || typeof opts !== 'object') {
|
|
275
|
+
throw new Error(`tracker.track("${name}", opts): opts is required`);
|
|
276
|
+
}
|
|
277
|
+
for (const key of ['x', 'y', 'w', 'h']) {
|
|
278
|
+
if (typeof opts[key] !== 'number' || !Number.isFinite(opts[key])) {
|
|
279
|
+
throw new Error(`tracker.track("${name}"): opts.${key} must be a finite number`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Reject bypass flags — the gate has no exemptions.
|
|
283
|
+
for (const flag of ['skipCollision', 'skipBounds', 'skipWrap']) {
|
|
284
|
+
if (opts[flag]) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`tracker.track("${name}"): "${flag}" is not a supported option. The layout gate has no bypasses. ` +
|
|
287
|
+
`Fix the placement or don't track it.`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
placed.push({ name, ...opts });
|
|
292
|
+
return opts;
|
|
293
|
+
}
|
|
294
|
+
return { track, placed };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
module.exports = {
|
|
298
|
+
LAYOUT,
|
|
299
|
+
estimateTextHeight,
|
|
300
|
+
fitFontSize,
|
|
301
|
+
checkCollisions,
|
|
302
|
+
checkBounds,
|
|
303
|
+
checkTextWrap,
|
|
304
|
+
hardGate,
|
|
305
|
+
tracker,
|
|
306
|
+
};
|
|
@@ -82,13 +82,11 @@ describe('TaskClassifier', () => {
|
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
it('should maintain sliding window', () => {
|
|
85
|
-
|
|
86
|
-
for (let i = 0; i <
|
|
85
|
+
const cap = classifier.windowSize;
|
|
86
|
+
for (let i = 0; i < cap + 10; i++) {
|
|
87
87
|
classifier.addEvent('agent-1', { type: 'tool', tool: 'Read', input: `file${i}.js` });
|
|
88
88
|
}
|
|
89
|
-
|
|
90
|
-
// Window should be 50
|
|
91
|
-
assert.equal(classifier.agentWindows['agent-1'].length, 50);
|
|
89
|
+
assert.equal(classifier.agentWindows['agent-1'].length, cap);
|
|
92
90
|
});
|
|
93
91
|
|
|
94
92
|
it('should clear agent window', () => {
|
|
@@ -83,4 +83,68 @@ describe('LockManager', () => {
|
|
|
83
83
|
assert.equal(locks.check('agent-1', 'src/components/App.jsx').conflict, true);
|
|
84
84
|
assert.equal(locks.check('agent-2', 'src/api/auth.js').conflict, true);
|
|
85
85
|
});
|
|
86
|
+
|
|
87
|
+
describe('coordination operations', () => {
|
|
88
|
+
it('declares an operation with no conflict', () => {
|
|
89
|
+
const result = locks.declareOperation('agent-1', 'npm install', ['package.json']);
|
|
90
|
+
assert.equal(result.conflict, false);
|
|
91
|
+
const ops = locks.getOperations();
|
|
92
|
+
assert.equal(ops['agent-1'].name, 'npm install');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('detects conflict when another agent holds a resource', () => {
|
|
96
|
+
locks.declareOperation('agent-1', 'npm install', ['package.json']);
|
|
97
|
+
const result = locks.declareOperation('agent-2', 'edit manifest', ['package.json']);
|
|
98
|
+
assert.equal(result.conflict, true);
|
|
99
|
+
assert.equal(result.owner, 'agent-1');
|
|
100
|
+
assert.equal(result.resource, 'package.json');
|
|
101
|
+
assert.equal(result.operation, 'npm install');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('allows non-overlapping resource claims', () => {
|
|
105
|
+
locks.declareOperation('agent-1', 'npm install', ['package.json']);
|
|
106
|
+
const result = locks.declareOperation('agent-2', 'restart server', ['server:3000']);
|
|
107
|
+
assert.equal(result.conflict, false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('same agent can update its own declaration', () => {
|
|
111
|
+
locks.declareOperation('agent-1', 'edit', ['file-a']);
|
|
112
|
+
// Same agent can re-declare without conflict
|
|
113
|
+
const result = locks.declareOperation('agent-1', 'edit', ['file-a', 'file-b']);
|
|
114
|
+
assert.equal(result.conflict, false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('completeOperation releases the claim', () => {
|
|
118
|
+
locks.declareOperation('agent-1', 'npm install', ['package.json']);
|
|
119
|
+
locks.completeOperation('agent-1');
|
|
120
|
+
|
|
121
|
+
const result = locks.declareOperation('agent-2', 'edit', ['package.json']);
|
|
122
|
+
assert.equal(result.conflict, false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('operations auto-expire after TTL', () => {
|
|
126
|
+
locks.declareOperation('agent-1', 'stale op', ['resource-x'], 1);
|
|
127
|
+
// Wait past TTL
|
|
128
|
+
const start = Date.now();
|
|
129
|
+
while (Date.now() - start < 5) { /* spin briefly */ }
|
|
130
|
+
|
|
131
|
+
const result = locks.declareOperation('agent-2', 'takeover', ['resource-x']);
|
|
132
|
+
assert.equal(result.conflict, false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('rejects malformed declarations', () => {
|
|
136
|
+
assert.equal(locks.declareOperation(null, 'op', ['r']).conflict, false);
|
|
137
|
+
assert.equal(locks.declareOperation('a', null, ['r']).conflict, false);
|
|
138
|
+
assert.equal(locks.declareOperation('a', 'op', []).conflict, false);
|
|
139
|
+
// All return error flag
|
|
140
|
+
assert.ok(locks.declareOperation('a', 'op', []).error);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('release() also clears pending operations', () => {
|
|
144
|
+
locks.declareOperation('agent-1', 'op', ['r']);
|
|
145
|
+
locks.release('agent-1');
|
|
146
|
+
const result = locks.declareOperation('agent-2', 'op2', ['r']);
|
|
147
|
+
assert.equal(result.conflict, false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
86
150
|
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// GROOVE — MemoryStore Tests (Layer 7)
|
|
2
|
+
// FSL-1.1-Apache-2.0 — see LICENSE
|
|
3
|
+
|
|
4
|
+
import { describe, it, beforeEach } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { MemoryStore } from '../src/memory.js';
|
|
7
|
+
import { mkdtempSync, existsSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { tmpdir } from 'os';
|
|
10
|
+
|
|
11
|
+
describe('MemoryStore', () => {
|
|
12
|
+
let memory;
|
|
13
|
+
let tmpDir;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'groove-memory-test-'));
|
|
17
|
+
memory = new MemoryStore(tmpDir);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('constraints', () => {
|
|
21
|
+
it('starts with no constraints', () => {
|
|
22
|
+
assert.deepEqual(memory.listConstraints(), []);
|
|
23
|
+
assert.equal(memory.getConstraintsMarkdown(), '');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('adds a constraint and returns a hash', () => {
|
|
27
|
+
const result = memory.addConstraint({ text: 'Never touch packages/daemon/index.js', category: 'hard' });
|
|
28
|
+
assert.equal(result.added, true);
|
|
29
|
+
assert.ok(result.hash);
|
|
30
|
+
|
|
31
|
+
const list = memory.listConstraints();
|
|
32
|
+
assert.equal(list.length, 1);
|
|
33
|
+
assert.equal(list[0].category, 'hard');
|
|
34
|
+
assert.match(list[0].text, /Never touch/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('dedups identical constraints', () => {
|
|
38
|
+
memory.addConstraint({ text: 'ESM only', category: 'pattern' });
|
|
39
|
+
const result = memory.addConstraint({ text: 'ESM only', category: 'pattern' });
|
|
40
|
+
assert.equal(result.added, false);
|
|
41
|
+
assert.equal(result.reason, 'duplicate');
|
|
42
|
+
assert.equal(memory.listConstraints().length, 1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('rejects empty and oversized text', () => {
|
|
46
|
+
assert.equal(memory.addConstraint({ text: '' }).added, false);
|
|
47
|
+
assert.equal(memory.addConstraint({ text: 'ab' }).added, false);
|
|
48
|
+
assert.equal(memory.addConstraint({ text: 'x'.repeat(600) }).added, false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('removes constraints by hash', () => {
|
|
52
|
+
const r = memory.addConstraint({ text: 'remove me', category: 'test' });
|
|
53
|
+
assert.equal(memory.removeConstraint(r.hash), true);
|
|
54
|
+
assert.equal(memory.listConstraints().length, 0);
|
|
55
|
+
// Removing non-existent hash returns false
|
|
56
|
+
assert.equal(memory.removeConstraint('nonexistent'), false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('getConstraintsMarkdown groups by category', () => {
|
|
60
|
+
memory.addConstraint({ text: 'rule-a', category: 'hard' });
|
|
61
|
+
memory.addConstraint({ text: 'rule-b', category: 'hard' });
|
|
62
|
+
memory.addConstraint({ text: 'pattern-x', category: 'pattern' });
|
|
63
|
+
const md = memory.getConstraintsMarkdown();
|
|
64
|
+
assert.match(md, /\*\*hard:\*\*/);
|
|
65
|
+
assert.match(md, /\*\*pattern:\*\*/);
|
|
66
|
+
assert.match(md, /rule-a/);
|
|
67
|
+
assert.match(md, /pattern-x/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('survives restart', () => {
|
|
71
|
+
memory.addConstraint({ text: 'persists across restart', category: 'test' });
|
|
72
|
+
const memory2 = new MemoryStore(tmpDir);
|
|
73
|
+
assert.equal(memory2.listConstraints().length, 1);
|
|
74
|
+
assert.match(memory2.listConstraints()[0].text, /persists/);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('handoff chain', () => {
|
|
79
|
+
it('starts with no chain', () => {
|
|
80
|
+
assert.deepEqual(memory.getHandoffChain('backend'), []);
|
|
81
|
+
assert.equal(memory.getRecentHandoffMarkdown('backend'), '');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('appends a rotation entry', () => {
|
|
85
|
+
const ok = memory.appendHandoffBrief('backend', {
|
|
86
|
+
agentId: 'bk-1',
|
|
87
|
+
newAgentId: 'bk-2',
|
|
88
|
+
reason: 'context_threshold',
|
|
89
|
+
oldTokens: 5_000_000,
|
|
90
|
+
contextUsage: 0.82,
|
|
91
|
+
brief: 'Finished auth refactor, started on payment flow',
|
|
92
|
+
timestamp: '2026-04-12T14:00:00Z',
|
|
93
|
+
});
|
|
94
|
+
assert.equal(ok, true);
|
|
95
|
+
|
|
96
|
+
const chain = memory.getHandoffChain('backend');
|
|
97
|
+
assert.equal(chain.length, 1);
|
|
98
|
+
assert.equal(chain[0].rotationN, 1);
|
|
99
|
+
assert.match(chain[0].body, /Rotation 1/);
|
|
100
|
+
assert.match(chain[0].body, /context_threshold/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('increments rotation numbers and keeps newest first', () => {
|
|
104
|
+
memory.appendHandoffBrief('backend', { brief: 'first' });
|
|
105
|
+
memory.appendHandoffBrief('backend', { brief: 'second' });
|
|
106
|
+
memory.appendHandoffBrief('backend', { brief: 'third' });
|
|
107
|
+
|
|
108
|
+
const chain = memory.getHandoffChain('backend');
|
|
109
|
+
assert.equal(chain.length, 3);
|
|
110
|
+
assert.equal(chain[0].rotationN, 3);
|
|
111
|
+
assert.equal(chain[2].rotationN, 1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('caps chain at MAX_HANDOFF_ROTATIONS (10)', () => {
|
|
115
|
+
for (let i = 0; i < 15; i++) {
|
|
116
|
+
memory.appendHandoffBrief('backend', { brief: `rotation-${i}` });
|
|
117
|
+
}
|
|
118
|
+
const chain = memory.getHandoffChain('backend');
|
|
119
|
+
assert.equal(chain.length, 10);
|
|
120
|
+
assert.equal(chain[0].rotationN, 15);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('separate chains per role', () => {
|
|
124
|
+
memory.appendHandoffBrief('backend', { brief: 'backend work' });
|
|
125
|
+
memory.appendHandoffBrief('frontend', { brief: 'frontend work' });
|
|
126
|
+
assert.equal(memory.getHandoffChain('backend').length, 1);
|
|
127
|
+
assert.equal(memory.getHandoffChain('frontend').length, 1);
|
|
128
|
+
assert.deepEqual(memory.listHandoffRoles().sort(), ['backend', 'frontend']);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('getRecentHandoffMarkdown returns N newest', () => {
|
|
132
|
+
for (let i = 0; i < 5; i++) {
|
|
133
|
+
memory.appendHandoffBrief('backend', { brief: `brief-${i}` });
|
|
134
|
+
}
|
|
135
|
+
const md = memory.getRecentHandoffMarkdown('backend', 3);
|
|
136
|
+
assert.match(md, /brief-4/);
|
|
137
|
+
assert.match(md, /brief-3/);
|
|
138
|
+
assert.match(md, /brief-2/);
|
|
139
|
+
assert.ok(!md.includes('brief-1'));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('sanitizes role names for filesystem safety', () => {
|
|
143
|
+
const ok = memory.appendHandoffBrief('../evil/role', { brief: 'test' });
|
|
144
|
+
assert.equal(ok, true);
|
|
145
|
+
// File should be created with safe name, no path traversal
|
|
146
|
+
assert.ok(existsSync(tmpDir));
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('discoveries', () => {
|
|
151
|
+
it('starts with no discoveries', () => {
|
|
152
|
+
assert.deepEqual(memory.listDiscoveries(), []);
|
|
153
|
+
assert.equal(memory.getDiscoveriesMarkdown(), '');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('adds a success discovery', () => {
|
|
157
|
+
const result = memory.addDiscovery({
|
|
158
|
+
agentId: 'bk-1',
|
|
159
|
+
role: 'backend',
|
|
160
|
+
trigger: 'Cannot find module gray-matter',
|
|
161
|
+
fix: 'npm install gray-matter',
|
|
162
|
+
outcome: 'success',
|
|
163
|
+
});
|
|
164
|
+
assert.equal(result.added, true);
|
|
165
|
+
|
|
166
|
+
const list = memory.listDiscoveries();
|
|
167
|
+
assert.equal(list.length, 1);
|
|
168
|
+
assert.equal(list[0].role, 'backend');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('rejects non-success outcomes', () => {
|
|
172
|
+
const result = memory.addDiscovery({
|
|
173
|
+
trigger: 'err',
|
|
174
|
+
fix: 'did not work',
|
|
175
|
+
outcome: 'failed',
|
|
176
|
+
});
|
|
177
|
+
assert.equal(result.added, false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('rejects incomplete records', () => {
|
|
181
|
+
assert.equal(memory.addDiscovery({ trigger: 'x' }).added, false);
|
|
182
|
+
assert.equal(memory.addDiscovery({ fix: 'y' }).added, false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('dedups identical trigger+fix pairs', () => {
|
|
186
|
+
memory.addDiscovery({ trigger: 'error X', fix: 'fix X' });
|
|
187
|
+
const result = memory.addDiscovery({ trigger: 'error X', fix: 'fix X' });
|
|
188
|
+
assert.equal(result.added, false);
|
|
189
|
+
assert.equal(result.reason, 'duplicate');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('filters by role', () => {
|
|
193
|
+
memory.addDiscovery({ role: 'backend', trigger: 'a', fix: 'fa' });
|
|
194
|
+
memory.addDiscovery({ role: 'frontend', trigger: 'b', fix: 'fb' });
|
|
195
|
+
memory.addDiscovery({ role: 'backend', trigger: 'c', fix: 'fc' });
|
|
196
|
+
|
|
197
|
+
assert.equal(memory.listDiscoveries({ role: 'backend' }).length, 2);
|
|
198
|
+
assert.equal(memory.listDiscoveries({ role: 'frontend' }).length, 1);
|
|
199
|
+
assert.equal(memory.listDiscoveries().length, 3);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('getDiscoveriesMarkdown formats for agent consumption', () => {
|
|
203
|
+
memory.addDiscovery({ role: 'backend', trigger: 'X error', fix: 'do Y' });
|
|
204
|
+
const md = memory.getDiscoveriesMarkdown('backend');
|
|
205
|
+
assert.match(md, /X error/);
|
|
206
|
+
assert.match(md, /do Y/);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('specializations', () => {
|
|
211
|
+
it('starts empty', () => {
|
|
212
|
+
const all = memory.getAllSpecializations();
|
|
213
|
+
assert.deepEqual(all.perAgent, {});
|
|
214
|
+
assert.deepEqual(all.perProjectRole, {});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('updates an agent profile', () => {
|
|
218
|
+
memory.updateSpecialization('bk-1', {
|
|
219
|
+
role: 'backend',
|
|
220
|
+
qualityScore: 80,
|
|
221
|
+
filesTouched: ['src/a.js', 'src/b.js'],
|
|
222
|
+
});
|
|
223
|
+
const spec = memory.getSpecialization('bk-1');
|
|
224
|
+
assert.ok(spec);
|
|
225
|
+
assert.equal(spec.role, 'backend');
|
|
226
|
+
assert.equal(spec.avgQualityScore, 80);
|
|
227
|
+
assert.equal(spec.sessionCount, 1);
|
|
228
|
+
assert.equal(spec.fileTouches['src/a.js'], 1);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('averages quality score across sessions', () => {
|
|
232
|
+
memory.updateSpecialization('bk-1', { role: 'backend', qualityScore: 70 });
|
|
233
|
+
memory.updateSpecialization('bk-1', { role: 'backend', qualityScore: 90 });
|
|
234
|
+
assert.equal(memory.getSpecialization('bk-1').avgQualityScore, 80);
|
|
235
|
+
assert.equal(memory.getSpecialization('bk-1').sessionCount, 2);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('aggregates per-role stats', () => {
|
|
239
|
+
memory.updateSpecialization('bk-1', { role: 'backend', qualityScore: 80 });
|
|
240
|
+
memory.updateSpecialization('bk-2', { role: 'backend', qualityScore: 60 });
|
|
241
|
+
const all = memory.getAllSpecializations();
|
|
242
|
+
assert.equal(all.perProjectRole.backend.sessionCount, 2);
|
|
243
|
+
assert.equal(all.perProjectRole.backend.avgQualityScore, 70);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('survives restart', () => {
|
|
247
|
+
memory.updateSpecialization('bk-1', { role: 'backend', qualityScore: 75 });
|
|
248
|
+
const memory2 = new MemoryStore(tmpDir);
|
|
249
|
+
assert.equal(memory2.getSpecialization('bk-1').avgQualityScore, 75);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
});
|