mt-signals 1.1.0 → 1.2.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/README.md CHANGED
@@ -21,6 +21,9 @@ mt-signals top 5
21
21
  # Breakout trends + hot signals (score 6+)
22
22
  mt-signals trending
23
23
 
24
+ # Action queue by build-now / monitor / ignore
25
+ mt-signals triage
26
+
24
27
  # Deep dive on a topic
25
28
  mt-signals lookup "MCP server"
26
29
 
@@ -36,6 +39,7 @@ mt-signals signals --category=ai
36
39
  | `signals` | List all scored signals (filterable by category, sort, min score) |
37
40
  | `top [N]` | Top N signals ranked by score (default: 10) |
38
41
  | `trending` | Breakout signals (1.8x+ rolling avg) and hot signals (score 6+) |
42
+ | `triage [N]` | Group signals into `build_now`, `monitor`, and `ignore` with product mapping and recommended action |
39
43
 
40
44
  ### Analysis
41
45
  | Command | Description |
@@ -62,6 +66,9 @@ mt-signals top 10 --json | jq '.[0].topic'
62
66
  # Pipe trending breakouts to another tool
63
67
  mt-signals trending --json | jq '.breakouts'
64
68
 
69
+ # Pull just the build-now queue
70
+ mt-signals triage --json | jq '.build_now'
71
+
65
72
  # Filter high-score AI signals
66
73
  mt-signals signals --category=ai --min=7 --json
67
74
  ```
package/bin/mt-signals.js CHANGED
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  const { request, loadConfig, saveConfig, getBaseUrl, getApiKey } = require("../lib/client");
5
- const { fmt, catColor, scoreColor, bar, sparkline } = require("../lib/format");
5
+ const { fmt, catColor, scoreColor, triageColor, bar, sparkline } = require("../lib/format");
6
6
 
7
7
  const args = process.argv.slice(2);
8
8
  const flags = {};
@@ -66,7 +66,7 @@ async function main() {
66
66
  const cat = catColor(s.category).padEnd(22);
67
67
  const topic = fmt.bold((s.topic || "").padEnd(35).slice(0, 35));
68
68
  const src = fmt.dim((s.source || "").padEnd(8).slice(0, 8));
69
- console.log(` ${sc} ${cat} ${topic} ${src} U:${bar(s.urgency, 10, 5)} B:${bar(s.buyer_intent, 10, 5)} D:${bar(s.durability, 10, 5)}`);
69
+ console.log(` ${sc} ${cat} ${topic} ${src} ${triageColor(s.triage || "ignore")} U:${bar(s.urgency, 10, 5)} B:${bar(s.buyer_intent, 10, 5)} D:${bar(s.durability, 10, 5)}`);
70
70
  }
71
71
  console.log(fmt.dim(`\n Last recalculated: ${data.last_recalculated || "never"}\n`));
72
72
  break;
@@ -84,8 +84,9 @@ async function main() {
84
84
  const rank = fmt.dim(`#${String(i + 1).padStart(2)}`);
85
85
  const sc = scoreColor(s.score)(String((s.score || 0).toFixed(1)));
86
86
  const cat = catColor(s.category);
87
- console.log(` ${rank} ${sc} ${fmt.bold(s.topic || "")} ${cat}`);
87
+ console.log(` ${rank} ${sc} ${fmt.bold(s.topic || "")} ${cat} ${triageColor(s.triage || "ignore")}`);
88
88
  if (s.summary) console.log(` ${fmt.dim(s.summary.slice(0, 90))}`);
89
+ if (s.recommended_action) console.log(` ${fmt.dim(s.recommended_action)}`);
89
90
  }
90
91
  console.log();
91
92
  break;
@@ -144,6 +145,13 @@ async function main() {
144
145
  console.log(` ${fmt.cyan("Durability:")} ${bar(s.durability, 10, 15)} ${(s.durability || 0).toFixed(1)}`);
145
146
 
146
147
  if (s.summary) console.log(`\n ${fmt.dim(s.summary)}`);
148
+ console.log(`\n ${fmt.cyan("Triage:")} ${triageColor(s.triage || "ignore")}`);
149
+ if (s.product_mapping && s.product_mapping.length > 0) {
150
+ console.log(` ${fmt.cyan("Products:")} ${s.product_mapping.join(", ")}`);
151
+ }
152
+ if (s.recommended_action) {
153
+ console.log(` ${fmt.cyan("Action:")} ${s.recommended_action}`);
154
+ }
147
155
 
148
156
  if (s.actionable_for && s.actionable_for.length > 0) {
149
157
  console.log(`\n ${fmt.cyan("Actionable for:")} ${s.actionable_for.join(", ")}`);
@@ -183,6 +191,25 @@ async function main() {
183
191
  break;
184
192
  }
185
193
 
194
+ case "triage": {
195
+ const limit = positional[1] || flags.limit || "15";
196
+ const data = await request("GET", `/signals/triage?limit=${limit}`);
197
+ if (json) return out(data);
198
+
199
+ console.log(fmt.bold(`\n Research Signal Triage\n`));
200
+ for (const bucket of ["build_now", "monitor", "ignore"]) {
201
+ const items = data[bucket] || [];
202
+ if (!items.length) continue;
203
+ console.log(` ${triageColor(bucket)} (${items.length})\n`);
204
+ for (const item of items) {
205
+ console.log(` ${scoreColor(item.score)(String((item.score || 0).toFixed(1)).padStart(4))} ${fmt.bold(item.topic || "")} ${catColor(item.category)} ${fmt.dim((item.product_mapping || []).join(", "))}`);
206
+ console.log(` ${fmt.dim(item.recommended_action || "")}`);
207
+ }
208
+ console.log();
209
+ }
210
+ break;
211
+ }
212
+
186
213
  case "stats": {
187
214
  const data = await request("GET", "/stats");
188
215
  if (json) return out(data);
@@ -267,6 +294,7 @@ ${fmt.bold("COMMANDS")}
267
294
  ${fmt.cyan("signals")} [--category=X] [--sort=X] List all scored signals
268
295
  ${fmt.cyan("top")} [N] Top N signals (default: 10)
269
296
  ${fmt.cyan("trending")} Breakout signals + hot (score 6+)
297
+ ${fmt.cyan("triage")} [N] Build-now / monitor / ignore queue
270
298
  ${fmt.cyan("lookup")} <topic> Deep dive on a specific signal
271
299
  ${fmt.cyan("category")} <name> Signals by category
272
300
  ${fmt.cyan("stats")} Category breakdown + top 10
@@ -288,6 +316,7 @@ ${fmt.bold("EXAMPLES")}
288
316
  mt-signals signals # All signals ranked by score
289
317
  mt-signals top 5 # Top 5 signals
290
318
  mt-signals trending # Breakouts + hot signals
319
+ mt-signals triage 12 # Action queue by build/monitor/ignore
291
320
  mt-signals signals --category=ai # AI signals only
292
321
  mt-signals signals --sort=buyer_intent # Sort by buyer intent
293
322
  mt-signals lookup "MCP server" # Deep dive
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { EventEmitter } from 'events';
3
+ import { createRequire } from 'module';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+
7
+ function mockRequest(statusCode, body) {
8
+ return (opts, cb) => {
9
+ const res = new EventEmitter();
10
+ res.statusCode = statusCode;
11
+ const req = new EventEmitter();
12
+ req.write = vi.fn();
13
+ req.end = vi.fn(() => {
14
+ cb(res);
15
+ res.emit('data', typeof body === 'string' ? body : JSON.stringify(body));
16
+ res.emit('end');
17
+ });
18
+ req.destroy = vi.fn();
19
+ return req;
20
+ };
21
+ }
22
+
23
+ describe('research-signal cli client', () => {
24
+ const HOME = '/tmp/vitest-home-mt-signals';
25
+ const CONFIG_DIR = path.join(HOME, '.mt-signals');
26
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
27
+
28
+ beforeEach(() => {
29
+ vi.resetModules();
30
+ process.env.HOME = HOME;
31
+ delete process.env.MT_SIGNALS_URL;
32
+ delete process.env.MT_SIGNALS_API_KEY;
33
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
34
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify({ url: 'https://example.com', apiKey: 'k' }));
35
+ });
36
+
37
+ it('getBaseUrl and getApiKey read config', () => {
38
+ const require = createRequire(import.meta.url);
39
+ delete require.cache[require.resolve('./client.js')];
40
+ const mod = require('./client.js');
41
+ expect(mod.getBaseUrl()).toBe('https://example.com');
42
+ expect(mod.getApiKey()).toBe('k');
43
+ });
44
+
45
+ it('request appends key when provided', async () => {
46
+ const require = createRequire(import.meta.url);
47
+ const https = require('https');
48
+ const originalRequest = https.request;
49
+ const mockReq = vi.fn(mockRequest(200, { ok: 1 }));
50
+ https.request = mockReq;
51
+ delete require.cache[require.resolve('./client.js')];
52
+ const mod = require('./client.js');
53
+ await mod.request('GET', '/v1/health');
54
+ https.request = originalRequest;
55
+ const opts = mockReq.mock.calls[0][0];
56
+ expect(opts.path).toContain('key=k');
57
+ });
58
+ });
package/lib/format.js CHANGED
@@ -34,6 +34,12 @@ function scoreColor(score) {
34
34
  return fmt.dim;
35
35
  }
36
36
 
37
+ function triageColor(triage) {
38
+ if (triage === "build_now") return fmt.green(triage);
39
+ if (triage === "monitor") return fmt.yellow(triage);
40
+ return fmt.dim(triage || "ignore");
41
+ }
42
+
37
43
  function bar(value, max, width = 10) {
38
44
  const filled = Math.round((value / (max || 10)) * width);
39
45
  const empty = width - filled;
@@ -47,4 +53,4 @@ function sparkline(values) {
47
53
  return values.map((v) => fmt.cyan(chars[Math.min(Math.floor((v / max) * 7), 7)])).join("");
48
54
  }
49
55
 
50
- module.exports = { fmt, catColor, scoreColor, bar, sparkline };
56
+ module.exports = { fmt, catColor, scoreColor, triageColor, bar, sparkline };
@@ -0,0 +1,18 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createRequire } from 'module';
3
+
4
+ const require = createRequire(import.meta.url);
5
+ const fmt = require('./format.js');
6
+
7
+ describe('research-signal cli format', () => {
8
+ it('catColor and scoreColor return strings', () => {
9
+ expect(fmt.catColor('ai')).toContain('ai');
10
+ expect(fmt.scoreColor(8)('8')).toContain('8');
11
+ expect(fmt.triageColor('build_now')).toContain('build_now');
12
+ });
13
+
14
+ it('bar and sparkline render', () => {
15
+ expect(fmt.bar(5, 10, 5)).toBeTypeOf('string');
16
+ expect(fmt.sparkline([1, 2, 3])).toBeTypeOf('string');
17
+ });
18
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mt-signals",
3
- "version": "1.1.0",
4
- "description": "CLI for MetalTorque Research Signals — scored trend intelligence from HN, Reddit, ArXiv, and GitHub.",
3
+ "version": "1.2.0",
4
+ "description": "CLI for MetalTorque Research Signals — scored trend intelligence with build/monitor/ignore triage from HN, Reddit, ArXiv, and GitHub.",
5
5
  "bin": {
6
6
  "mt-signals": "bin/mt-signals.js"
7
7
  },