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.
Files changed (171) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/CLAUDE.md +24 -19
  3. package/node_modules/@groove-dev/cli/bin/groove.js +2 -0
  4. package/node_modules/@groove-dev/cli/package.json +1 -1
  5. package/node_modules/@groove-dev/cli/src/commands/nuke.js +16 -4
  6. package/node_modules/@groove-dev/cli/src/commands/stop.js +17 -2
  7. package/node_modules/@groove-dev/daemon/integrations-registry.json +681 -75
  8. package/node_modules/@groove-dev/daemon/package.json +1 -1
  9. package/node_modules/@groove-dev/daemon/src/adaptive.js +23 -25
  10. package/node_modules/@groove-dev/daemon/src/api.js +346 -22
  11. package/node_modules/@groove-dev/daemon/src/classifier.js +53 -6
  12. package/node_modules/@groove-dev/daemon/src/firstrun.js +14 -1
  13. package/node_modules/@groove-dev/daemon/src/gateways/manager.js +2 -2
  14. package/node_modules/@groove-dev/daemon/src/index.js +28 -4
  15. package/node_modules/@groove-dev/daemon/src/integrations.js +215 -14
  16. package/node_modules/@groove-dev/daemon/src/introducer.js +84 -11
  17. package/node_modules/@groove-dev/daemon/src/journalist.js +43 -1
  18. package/node_modules/@groove-dev/daemon/src/lockmanager.js +60 -0
  19. package/node_modules/@groove-dev/daemon/src/mcp-manager.js +270 -0
  20. package/node_modules/@groove-dev/daemon/src/memory.js +370 -0
  21. package/node_modules/@groove-dev/daemon/src/pm.js +1 -1
  22. package/node_modules/@groove-dev/daemon/src/process.js +141 -9
  23. package/node_modules/@groove-dev/daemon/src/registry.js +1 -1
  24. package/node_modules/@groove-dev/daemon/src/rotator.js +334 -31
  25. package/node_modules/@groove-dev/daemon/src/router.js +43 -0
  26. package/node_modules/@groove-dev/daemon/src/tokentracker.js +70 -18
  27. package/node_modules/@groove-dev/daemon/src/validate.js +5 -13
  28. package/node_modules/@groove-dev/daemon/templates/groove-slides.cjs +306 -0
  29. package/node_modules/@groove-dev/daemon/test/classifier.test.js +3 -5
  30. package/node_modules/@groove-dev/daemon/test/lockmanager.test.js +64 -0
  31. package/node_modules/@groove-dev/daemon/test/memory.test.js +252 -0
  32. package/node_modules/@groove-dev/daemon/test/rotator.test.js +108 -0
  33. package/node_modules/@groove-dev/daemon/test/router.test.js +64 -0
  34. package/node_modules/@groove-dev/daemon/test/slides-engine.test.js +230 -0
  35. package/node_modules/@groove-dev/daemon/test/tokentracker.test.js +78 -0
  36. package/node_modules/@groove-dev/gui/dist/assets/index-DjORRpF0.css +1 -0
  37. package/node_modules/@groove-dev/gui/dist/assets/index-eCrVowF0.js +652 -0
  38. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  39. package/node_modules/@groove-dev/gui/package.json +1 -4
  40. package/node_modules/@groove-dev/gui/src/components/agents/agent-chat.jsx +26 -17
  41. package/node_modules/@groove-dev/gui/src/components/agents/agent-config.jsx +22 -1
  42. package/node_modules/@groove-dev/gui/src/components/agents/agent-feed.jsx +53 -21
  43. package/node_modules/@groove-dev/gui/src/components/agents/agent-node.jsx +132 -90
  44. package/node_modules/@groove-dev/gui/src/components/agents/spawn-wizard.jsx +212 -1
  45. package/node_modules/@groove-dev/gui/src/components/dashboard/cache-ring.jsx +6 -2
  46. package/node_modules/@groove-dev/gui/src/components/dashboard/intel-panel.jsx +495 -174
  47. package/node_modules/@groove-dev/gui/src/components/dashboard/kpi-card.jsx +12 -2
  48. package/node_modules/@groove-dev/gui/src/components/dashboard/team-burn-panel.jsx +55 -0
  49. package/node_modules/@groove-dev/gui/src/components/layout/activity-bar.jsx +3 -3
  50. package/node_modules/@groove-dev/gui/src/components/layout/app-shell.jsx +24 -19
  51. package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +2 -2
  52. package/node_modules/@groove-dev/gui/src/components/marketplace/integration-wizard.jsx +391 -61
  53. package/node_modules/@groove-dev/gui/src/components/marketplace/marketplace-card.jsx +29 -7
  54. package/node_modules/@groove-dev/gui/src/lib/format.js +0 -6
  55. package/node_modules/@groove-dev/gui/src/lib/hooks/use-dashboard.js +23 -5
  56. package/node_modules/@groove-dev/gui/src/stores/groove.js +59 -9
  57. package/node_modules/@groove-dev/gui/src/views/agents.jsx +84 -10
  58. package/node_modules/@groove-dev/gui/src/views/dashboard.jsx +24 -21
  59. package/node_modules/@groove-dev/gui/src/views/marketplace.jsx +153 -85
  60. package/package.json +2 -8
  61. package/packages/cli/bin/groove.js +2 -0
  62. package/packages/cli/package.json +1 -1
  63. package/packages/cli/src/commands/nuke.js +16 -4
  64. package/packages/cli/src/commands/stop.js +17 -2
  65. package/packages/daemon/integrations-registry.json +681 -75
  66. package/packages/daemon/package.json +1 -1
  67. package/packages/daemon/src/adaptive.js +23 -25
  68. package/packages/daemon/src/api.js +346 -22
  69. package/packages/daemon/src/classifier.js +53 -6
  70. package/packages/daemon/src/firstrun.js +14 -1
  71. package/packages/daemon/src/gateways/manager.js +2 -2
  72. package/packages/daemon/src/index.js +28 -4
  73. package/packages/daemon/src/integrations.js +215 -14
  74. package/packages/daemon/src/introducer.js +84 -11
  75. package/packages/daemon/src/journalist.js +43 -1
  76. package/packages/daemon/src/lockmanager.js +60 -0
  77. package/packages/daemon/src/mcp-manager.js +270 -0
  78. package/packages/daemon/src/memory.js +370 -0
  79. package/packages/daemon/src/pm.js +1 -1
  80. package/packages/daemon/src/process.js +141 -9
  81. package/packages/daemon/src/registry.js +1 -1
  82. package/packages/daemon/src/rotator.js +334 -31
  83. package/packages/daemon/src/router.js +43 -0
  84. package/packages/daemon/src/tokentracker.js +70 -18
  85. package/packages/daemon/src/validate.js +5 -13
  86. package/packages/daemon/templates/groove-slides.cjs +306 -0
  87. package/packages/gui/dist/assets/index-DjORRpF0.css +1 -0
  88. package/packages/gui/dist/assets/index-eCrVowF0.js +652 -0
  89. package/packages/gui/dist/index.html +2 -2
  90. package/packages/gui/package.json +1 -4
  91. package/packages/gui/src/components/agents/agent-chat.jsx +26 -17
  92. package/packages/gui/src/components/agents/agent-config.jsx +22 -1
  93. package/packages/gui/src/components/agents/agent-feed.jsx +53 -21
  94. package/packages/gui/src/components/agents/agent-node.jsx +132 -90
  95. package/packages/gui/src/components/agents/spawn-wizard.jsx +212 -1
  96. package/packages/gui/src/components/dashboard/cache-ring.jsx +6 -2
  97. package/packages/gui/src/components/dashboard/intel-panel.jsx +495 -174
  98. package/packages/gui/src/components/dashboard/kpi-card.jsx +12 -2
  99. package/packages/gui/src/components/dashboard/team-burn-panel.jsx +55 -0
  100. package/packages/gui/src/components/layout/activity-bar.jsx +3 -3
  101. package/packages/gui/src/components/layout/app-shell.jsx +24 -19
  102. package/packages/gui/src/components/layout/command-palette.jsx +2 -2
  103. package/packages/gui/src/components/marketplace/integration-wizard.jsx +391 -61
  104. package/packages/gui/src/components/marketplace/marketplace-card.jsx +29 -7
  105. package/packages/gui/src/lib/format.js +0 -6
  106. package/packages/gui/src/lib/hooks/use-dashboard.js +23 -5
  107. package/packages/gui/src/stores/groove.js +59 -9
  108. package/packages/gui/src/views/agents.jsx +84 -10
  109. package/packages/gui/src/views/dashboard.jsx +24 -21
  110. package/packages/gui/src/views/marketplace.jsx +153 -85
  111. package/node_modules/@groove-dev/gui/dist/assets/index-CEFKgLGB.css +0 -1
  112. package/node_modules/@groove-dev/gui/dist/assets/index-CaKBNWcK.js +0 -638
  113. package/node_modules/@groove-dev/gui/dist/groove-logo-short.png +0 -0
  114. package/node_modules/@groove-dev/gui/dist/groove-logo.png +0 -0
  115. package/node_modules/@groove-dev/gui/public/groove-logo-short.png +0 -0
  116. package/node_modules/@groove-dev/gui/public/groove-logo.png +0 -0
  117. package/node_modules/@groove-dev/gui/src/components/ui/dropdown-menu.jsx +0 -60
  118. package/node_modules/@groove-dev/gui/src/lib/hooks/use-media-query.js +0 -18
  119. package/node_modules/@radix-ui/react-dropdown-menu/LICENSE +0 -21
  120. package/node_modules/@radix-ui/react-dropdown-menu/README.md +0 -3
  121. package/node_modules/@radix-ui/react-dropdown-menu/dist/index.d.mts +0 -97
  122. package/node_modules/@radix-ui/react-dropdown-menu/dist/index.d.ts +0 -97
  123. package/node_modules/@radix-ui/react-dropdown-menu/dist/index.js +0 -337
  124. package/node_modules/@radix-ui/react-dropdown-menu/dist/index.js.map +0 -7
  125. package/node_modules/@radix-ui/react-dropdown-menu/dist/index.mjs +0 -305
  126. package/node_modules/@radix-ui/react-dropdown-menu/dist/index.mjs.map +0 -7
  127. package/node_modules/@radix-ui/react-dropdown-menu/package.json +0 -75
  128. package/node_modules/@radix-ui/react-popover/LICENSE +0 -21
  129. package/node_modules/@radix-ui/react-popover/README.md +0 -3
  130. package/node_modules/@radix-ui/react-popover/dist/index.d.mts +0 -85
  131. package/node_modules/@radix-ui/react-popover/dist/index.d.ts +0 -85
  132. package/node_modules/@radix-ui/react-popover/dist/index.js +0 -352
  133. package/node_modules/@radix-ui/react-popover/dist/index.js.map +0 -7
  134. package/node_modules/@radix-ui/react-popover/dist/index.mjs +0 -320
  135. package/node_modules/@radix-ui/react-popover/dist/index.mjs.map +0 -7
  136. package/node_modules/@radix-ui/react-popover/package.json +0 -82
  137. package/node_modules/@radix-ui/react-separator/LICENSE +0 -21
  138. package/node_modules/@radix-ui/react-separator/README.md +0 -3
  139. package/node_modules/@radix-ui/react-separator/dist/index.d.mts +0 -21
  140. package/node_modules/@radix-ui/react-separator/dist/index.d.ts +0 -21
  141. package/node_modules/@radix-ui/react-separator/dist/index.js +0 -65
  142. package/node_modules/@radix-ui/react-separator/dist/index.js.map +0 -7
  143. package/node_modules/@radix-ui/react-separator/dist/index.mjs +0 -32
  144. package/node_modules/@radix-ui/react-separator/dist/index.mjs.map +0 -7
  145. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/LICENSE +0 -21
  146. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/README.md +0 -3
  147. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.d.mts +0 -52
  148. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.d.ts +0 -52
  149. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.js +0 -80
  150. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.js.map +0 -7
  151. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.mjs +0 -47
  152. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/dist/index.mjs.map +0 -7
  153. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive/package.json +0 -69
  154. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/LICENSE +0 -21
  155. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/README.md +0 -3
  156. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.d.mts +0 -22
  157. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.d.ts +0 -22
  158. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.js +0 -152
  159. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.js.map +0 -7
  160. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.mjs +0 -119
  161. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/dist/index.mjs.map +0 -7
  162. package/node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot/package.json +0 -64
  163. package/node_modules/@radix-ui/react-separator/package.json +0 -69
  164. package/packages/gui/dist/assets/index-CEFKgLGB.css +0 -1
  165. package/packages/gui/dist/assets/index-CaKBNWcK.js +0 -638
  166. package/packages/gui/dist/groove-logo-short.png +0 -0
  167. package/packages/gui/dist/groove-logo.png +0 -0
  168. package/packages/gui/public/groove-logo-short.png +0 -0
  169. package/packages/gui/public/groove-logo.png +0 -0
  170. package/packages/gui/src/components/ui/dropdown-menu.jsx +0 -60
  171. 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
- // Add 60 events — should only keep last 50 (window size)
86
- for (let i = 0; i < 60; 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
+ });