@cccarv82/freya 1.0.14 → 1.0.16

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/cli/web.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const http = require('http');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const crypto = require('crypto');
6
7
  const { spawn } = require('child_process');
7
8
 
8
9
  function guessNpmCmd() {
@@ -120,6 +121,81 @@ function listReports(workspaceDir) {
120
121
  }));
121
122
  }
122
123
 
124
+ function splitForDiscord(text, limit = 1900) {
125
+ const t = String(text || '');
126
+ if (t.length <= limit) return [t];
127
+
128
+ const NL = String.fromCharCode(10);
129
+ const NL2 = NL + NL;
130
+
131
+ const parts = [];
132
+ let i = 0;
133
+ while (i < t.length) {
134
+ let end = Math.min(t.length, i + limit);
135
+ const window = t.slice(i, end);
136
+ const cut = window.lastIndexOf(NL2);
137
+ const cut2 = window.lastIndexOf(NL);
138
+ if (cut > 400) end = i + cut;
139
+ else if (cut2 > 600) end = i + cut2;
140
+ const chunk = t.slice(i, end).trim();
141
+ if (chunk) parts.push(chunk);
142
+ i = end;
143
+ }
144
+ return parts;
145
+ }
146
+
147
+ function postJson(url, bodyObj) {
148
+ return new Promise((resolve, reject) => {
149
+ const u = new URL(url);
150
+ const body = JSON.stringify(bodyObj);
151
+ const options = {
152
+ method: 'POST',
153
+ hostname: u.hostname,
154
+ path: u.pathname + u.search,
155
+ headers: {
156
+ 'Content-Type': 'application/json',
157
+ 'Content-Length': Buffer.byteLength(body)
158
+ }
159
+ };
160
+
161
+ const proto = u.protocol === 'https:' ? require('https') : require('http');
162
+ const req2 = proto.request(options, (r2) => {
163
+ const chunks = [];
164
+ r2.on('data', (c) => chunks.push(c));
165
+ r2.on('end', () => {
166
+ const raw = Buffer.concat(chunks).toString('utf8');
167
+ if (r2.statusCode >= 200 && r2.statusCode < 300) return resolve({ ok: true, status: r2.statusCode, body: raw });
168
+ return reject(new Error('Webhook error ' + r2.statusCode + ': ' + raw));
169
+ });
170
+ });
171
+ req2.on('error', reject);
172
+ req2.write(body);
173
+ req2.end();
174
+ });
175
+ }
176
+
177
+ function postDiscordWebhook(url, content) {
178
+ return postJson(url, { content });
179
+ }
180
+
181
+ function postTeamsWebhook(url, text) {
182
+ return postJson(url, { text });
183
+ }
184
+
185
+ async function publishRobust(webhookUrl, text, opts = {}) {
186
+ const u = new URL(webhookUrl);
187
+ const isDiscord = u.hostname.includes('discord.com') || u.hostname.includes('discordapp.com');
188
+
189
+ const chunks = isDiscord ? splitForDiscord(text, 1900) : splitForDiscord(text, 1800);
190
+
191
+ for (const chunk of chunks) {
192
+ if (isDiscord) await postDiscordWebhook(webhookUrl, chunk);
193
+ else await postTeamsWebhook(webhookUrl, chunk);
194
+ }
195
+
196
+ return { ok: true, chunks: chunks.length, mode: 'chunks' };
197
+ }
198
+
123
199
  function safeJson(res, code, obj) {
124
200
  const body = JSON.stringify(obj);
125
201
  res.writeHead(code, {
@@ -249,988 +325,175 @@ async function pickDirectoryNative() {
249
325
  function html(defaultDir) {
250
326
  // Aesthetic: “clean workstation” — light-first UI inspired by modern productivity apps.
251
327
  const safeDefault = String(defaultDir || './freya').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
328
+ return buildHtml(safeDefault);
329
+ }
330
+
331
+ function buildHtml(safeDefault) {
252
332
  return `<!doctype html>
253
333
  <html>
254
334
  <head>
255
335
  <meta charset="utf-8" />
256
336
  <meta name="viewport" content="width=device-width, initial-scale=1" />
257
337
  <title>FREYA Web</title>
258
- <style>
259
- /*
260
- Design goals:
261
- - Light theme by default (inspired by your reference screenshots)
262
- - Dark mode toggle
263
- - App-like layout: sidebar + main surface
264
- - Clear onboarding and affordances
265
- */
266
-
267
- :root {
268
- --radius: 14px;
269
- --shadow: 0 18px 55px rgba(16, 24, 40, .10);
270
- --shadow2: 0 10px 20px rgba(16, 24, 40, .08);
271
- --ring: 0 0 0 4px rgba(59, 130, 246, .18);
272
-
273
- /* Light */
274
- --bg: #f6f7fb;
275
- --paper: #ffffff;
276
- --paper2: #fbfbfd;
277
- --line: rgba(16, 24, 40, .10);
278
- --line2: rgba(16, 24, 40, .14);
279
- --text: #0f172a;
280
- --muted: rgba(15, 23, 42, .68);
281
- --faint: rgba(15, 23, 42, .50);
282
-
283
- --primary: #2563eb;
284
- --primary2: #0ea5e9;
285
- --accent: #f97316;
286
- --ok: #16a34a;
287
- --warn: #f59e0b;
288
- --danger: #e11d48;
289
-
290
- --chip: rgba(37, 99, 235, .08);
291
- --chip2: rgba(249, 115, 22, .10);
292
- --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
293
- --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Arial;
294
- }
295
-
296
- [data-theme="dark"] {
297
- --bg: #0b1020;
298
- --paper: rgba(255,255,255,.06);
299
- --paper2: rgba(255,255,255,.04);
300
- --line: rgba(255,255,255,.12);
301
- --line2: rgba(255,255,255,.18);
302
- --text: #e9f0ff;
303
- --muted: rgba(233,240,255,.72);
304
- --faint: rgba(233,240,255,.54);
305
-
306
- --primary: #60a5fa;
307
- --primary2: #22c55e;
308
- --accent: #fb923c;
309
- --chip: rgba(96, 165, 250, .14);
310
- --chip2: rgba(251, 146, 60, .14);
311
-
312
- --shadow: 0 30px 70px rgba(0,0,0,.55);
313
- --shadow2: 0 18px 40px rgba(0,0,0,.35);
314
- --ring: 0 0 0 4px rgba(96, 165, 250, .22);
315
- }
316
-
317
- * { box-sizing: border-box; }
318
- html, body { height: 100%; }
319
- body {
320
- margin: 0;
321
- background:
322
- radial-gradient(1200px 800px at 20% -10%, rgba(37,99,235,.12), transparent 55%),
323
- radial-gradient(900px 600px at 92% 10%, rgba(249,115,22,.12), transparent 55%),
324
- radial-gradient(1100px 700px at 70% 105%, rgba(14,165,233,.10), transparent 55%),
325
- var(--bg);
326
- color: var(--text);
327
- font-family: var(--sans);
328
- }
329
-
330
- /* subtle grain */
331
- body:before {
332
- content: "";
333
- position: fixed;
334
- inset: 0;
335
- pointer-events: none;
336
- background-image:
337
- radial-gradient(circle at 15% 20%, rgba(255,255,255,.38), transparent 32%),
338
- radial-gradient(circle at 80% 10%, rgba(255,255,255,.26), transparent 38%),
339
- linear-gradient(transparent 0, transparent 3px, rgba(0,0,0,.02) 4px);
340
- background-size: 900px 900px, 900px 900px, 100% 7px;
341
- opacity: .08;
342
- mix-blend-mode: overlay;
343
- }
344
-
345
- .app {
346
- max-width: 1260px;
347
- margin: 18px auto;
348
- padding: 0 18px;
349
- }
350
-
351
- .frame {
352
- display: grid;
353
- grid-template-columns: 280px 1fr;
354
- gap: 14px;
355
- min-height: calc(100vh - 36px);
356
- }
357
-
358
- @media (max-width: 980px) {
359
- .frame { grid-template-columns: 1fr; }
360
- }
361
-
362
- .sidebar {
363
- background: var(--paper);
364
- border: 1px solid var(--line);
365
- border-radius: var(--radius);
366
- box-shadow: var(--shadow2);
367
- padding: 14px;
368
- position: sticky;
369
- top: 18px;
370
- height: fit-content;
371
- }
372
-
373
- .main {
374
- background: var(--paper);
375
- border: 1px solid var(--line);
376
- border-radius: var(--radius);
377
- box-shadow: var(--shadow);
378
- overflow: hidden;
379
- }
380
-
381
- .topbar {
382
- display: flex;
383
- align-items: center;
384
- justify-content: space-between;
385
- padding: 14px 16px;
386
- border-bottom: 1px solid var(--line);
387
- background: linear-gradient(180deg, var(--paper2), var(--paper));
388
- }
389
-
390
- .brand {
391
- display: flex;
392
- align-items: center;
393
- gap: 10px;
394
- font-weight: 800;
395
- letter-spacing: .08em;
396
- text-transform: uppercase;
397
- font-size: 12px;
398
- color: var(--muted);
399
- }
400
-
401
- .spark {
402
- width: 10px;
403
- height: 10px;
404
- border-radius: 4px;
405
- background: linear-gradient(135deg, var(--accent), var(--primary));
406
- box-shadow: 0 0 0 6px rgba(249,115,22,.12);
407
- }
408
-
409
- .actions {
410
- display: flex;
411
- align-items: center;
412
- gap: 10px;
413
- }
414
-
415
- .chip {
416
- font-family: var(--mono);
417
- font-size: 12px;
418
- padding: 7px 10px;
419
- border-radius: 999px;
420
- border: 1px solid var(--line);
421
- background: rgba(255,255,255,.55);
422
- color: var(--faint);
423
- }
424
-
425
- [data-theme="dark"] .chip { background: rgba(0,0,0,.20); }
426
-
427
- .toggle {
428
- border: 1px solid var(--line);
429
- border-radius: 999px;
430
- background: var(--paper2);
431
- padding: 7px 10px;
432
- cursor: pointer;
433
- color: var(--muted);
434
- font-weight: 700;
435
- font-size: 12px;
436
- }
437
-
438
- .section {
439
- padding: 16px;
440
- }
441
-
442
- h1 {
443
- margin: 0;
444
- font-size: 22px;
445
- letter-spacing: -.02em;
446
- }
447
-
448
- .subtitle {
449
- margin-top: 6px;
450
- color: var(--muted);
451
- font-size: 13px;
452
- line-height: 1.4;
453
- max-width: 860px;
454
- }
455
-
456
- .cards {
457
- display: grid;
458
- grid-template-columns: repeat(4, 1fr);
459
- gap: 12px;
460
- margin-top: 14px;
461
- }
462
-
463
- @media (max-width: 1100px) { .cards { grid-template-columns: repeat(2, 1fr);} }
464
- @media (max-width: 620px) { .cards { grid-template-columns: 1fr;} }
465
-
466
- .card {
467
- border: 1px solid var(--line);
468
- background: var(--paper2);
469
- border-radius: 14px;
470
- padding: 12px;
471
- display: grid;
472
- gap: 6px;
473
- cursor: pointer;
474
- transition: transform .10s ease, border-color .16s ease, box-shadow .16s ease;
475
- box-shadow: 0 1px 0 rgba(16,24,40,.04);
476
- }
477
-
478
- .card:hover {
479
- transform: translateY(-1px);
480
- border-color: var(--line2);
481
- box-shadow: 0 10px 22px rgba(16,24,40,.10);
482
- }
483
-
484
- .icon {
485
- width: 34px;
486
- height: 34px;
487
- border-radius: 12px;
488
- display: grid;
489
- place-items: center;
490
- background: var(--chip);
491
- border: 1px solid var(--line);
492
- color: var(--primary);
493
- font-weight: 900;
494
- }
495
-
496
- .icon.orange { background: var(--chip2); color: var(--accent); }
497
-
498
- .title {
499
- font-weight: 800;
500
- font-size: 13px;
501
- }
502
-
503
- .desc {
504
- color: var(--muted);
505
- font-size: 12px;
506
- line-height: 1.35;
507
- }
508
-
509
- .grid2 {
510
- display: grid;
511
- grid-template-columns: 1fr 1fr;
512
- gap: 14px;
513
- margin-top: 14px;
514
- }
515
-
516
- @media (max-width: 980px) { .grid2 { grid-template-columns: 1fr; } }
517
-
518
- .panel {
519
- border: 1px solid var(--line);
520
- background: var(--paper);
521
- border-radius: 14px;
522
- overflow: hidden;
523
- }
524
-
525
- .panelHead {
526
- display: flex;
527
- align-items: center;
528
- justify-content: space-between;
529
- padding: 12px 12px;
530
- border-bottom: 1px solid var(--line);
531
- background: linear-gradient(180deg, var(--paper2), var(--paper));
532
- }
533
-
534
- .panelHead b { font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
535
-
536
- .panelBody { padding: 12px; }
537
-
538
- label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
539
-
540
- input {
541
- width: 100%;
542
- padding: 11px 12px;
543
- border-radius: 12px;
544
- border: 1px solid var(--line);
545
- background: rgba(255,255,255,.72);
546
- color: var(--text);
547
- outline: none;
548
- }
549
-
550
- [data-theme="dark"] input { background: rgba(0,0,0,.16); }
551
-
552
- input:focus { box-shadow: var(--ring); border-color: rgba(37,99,235,.35); }
553
-
554
- .row {
555
- display: grid;
556
- grid-template-columns: 1fr auto;
557
- gap: 10px;
558
- align-items: center;
559
- }
560
-
561
- .btn {
562
- border: 1px solid var(--line);
563
- border-radius: 12px;
564
- background: var(--paper2);
565
- color: var(--text);
566
- padding: 10px 12px;
567
- cursor: pointer;
568
- font-weight: 800;
569
- font-size: 12px;
570
- transition: transform .10s ease, border-color .16s ease, box-shadow .16s ease;
571
- }
572
-
573
- .btn:hover { transform: translateY(-1px); border-color: var(--line2); box-shadow: 0 10px 22px rgba(16,24,40,.10); }
574
- .btn:active { transform: translateY(0); }
575
-
576
- .btn.primary {
577
- background: linear-gradient(135deg, rgba(37,99,235,.14), rgba(14,165,233,.12));
578
- border-color: rgba(37,99,235,.22);
579
- color: var(--text);
580
- }
581
-
582
- .btn.orange {
583
- background: linear-gradient(135deg, rgba(249,115,22,.16), rgba(37,99,235,.08));
584
- border-color: rgba(249,115,22,.24);
585
- }
586
-
587
- .btn.danger {
588
- background: rgba(225,29,72,.10);
589
- border-color: rgba(225,29,72,.28);
590
- color: var(--text);
591
- }
592
-
593
- .btn.small { padding: 9px 10px; font-weight: 800; }
594
-
595
- .stack { display: flex; flex-wrap: wrap; gap: 10px; }
596
-
597
- .help {
598
- margin-top: 8px;
599
- color: var(--faint);
600
- font-size: 12px;
601
- line-height: 1.35;
602
- }
603
-
604
- .log {
605
- border: 1px solid var(--line);
606
- background: rgba(255,255,255,.65);
607
- border-radius: 14px;
608
- padding: 12px;
609
- font-family: var(--mono);
610
- font-size: 12px;
611
- line-height: 1.35;
612
- white-space: pre-wrap;
613
- max-height: 420px;
614
- overflow: auto;
615
- color: rgba(15,23,42,.92);
616
- }
617
-
618
- [data-theme="dark"] .log { background: rgba(0,0,0,.20); color: rgba(233,240,255,.84); }
619
-
620
- .statusRow { display:flex; align-items:center; justify-content: space-between; gap: 10px; }
621
-
622
- .pill {
623
- display: inline-flex;
624
- align-items: center;
625
- gap: 8px;
626
- padding: 7px 10px;
627
- border-radius: 999px;
628
- border: 1px solid var(--line);
629
- background: rgba(255,255,255,.55);
630
- font-size: 12px;
631
- color: var(--muted);
632
- font-family: var(--mono);
633
- }
634
-
635
- [data-theme="dark"] .pill { background: rgba(0,0,0,.18); }
636
-
637
- .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--warn); box-shadow: 0 0 0 5px rgba(245,158,11,.15); }
638
- .dot.ok { background: var(--ok); box-shadow: 0 0 0 5px rgba(22,163,74,.12); }
639
- .dot.err { background: var(--danger); box-shadow: 0 0 0 5px rgba(225,29,72,.14); }
640
-
641
- .small {
642
- font-size: 12px;
643
- color: var(--faint);
644
- font-family: var(--mono);
645
- }
646
-
647
- .sidebar h3 {
648
- margin: 0;
649
- font-size: 12px;
650
- letter-spacing: .10em;
651
- text-transform: uppercase;
652
- color: var(--muted);
653
- }
654
-
655
- .sideBlock { margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--line); }
656
-
657
- .sidePath {
658
- margin-top: 8px;
659
- border: 1px solid var(--line);
660
- background: var(--paper2);
661
- border-radius: 12px;
662
- padding: 10px;
663
- font-family: var(--mono);
664
- font-size: 12px;
665
- color: var(--muted);
666
- word-break: break-word;
667
- }
668
-
669
- .sideBtn { width: 100%; margin-top: 8px; }
670
-
671
- .rep {
672
- width: 100%;
673
- text-align: left;
674
- border: 1px solid var(--line);
675
- border-radius: 12px;
676
- background: var(--paper2);
677
- padding: 10px 12px;
678
- cursor: pointer;
679
- font-family: var(--mono);
680
- font-size: 12px;
681
- color: var(--muted);
682
- }
683
- .rep:hover { border-color: var(--line2); box-shadow: 0 10px 22px rgba(16,24,40,.10); }
684
- .repActive { border-color: rgba(59,130,246,.55); box-shadow: 0 0 0 4px rgba(59,130,246,.12); }
685
-
686
- .md-h1{ font-size: 20px; margin: 10px 0 6px; }
687
- .md-h2{ font-size: 16px; margin: 10px 0 6px; }
688
- .md-h3{ font-size: 14px; margin: 10px 0 6px; }
689
- .md-p{ margin: 6px 0; color: var(--muted); line-height: 1.5; }
690
- .md-ul{ margin: 6px 0 6px 18px; color: var(--muted); }
691
- .md-inline{ font-family: var(--mono); font-size: 12px; padding: 2px 6px; border: 1px solid var(--line); border-radius: 8px; background: rgba(255,255,255,.55); }
692
- [data-theme="dark"] .md-inline{ background: rgba(0,0,0,.18); }
693
- .md-code{ background: rgba(0,0,0,.05); border: 1px solid var(--line); border-radius: 14px; padding: 12px; overflow:auto; }
694
- [data-theme="dark"] .md-code{ background: rgba(0,0,0,.22); }
695
- .md-sp{ height: 10px; }
696
-
697
- .k { display: inline-block; padding: 2px 6px; border: 1px solid var(--line); border-radius: 8px; background: rgba(255,255,255,.65); font-family: var(--mono); font-size: 12px; color: var(--muted); }
698
- [data-theme="dark"] .k { background: rgba(0,0,0,.18); }
699
-
700
- </style>
338
+ <link rel="stylesheet" href="/app.css" />
701
339
  </head>
702
340
  <body>
703
341
  <div class="app">
704
342
  <div class="frame">
343
+ <div class="shell">
705
344
 
706
- <aside class="sidebar">
707
- <div style="display:flex; align-items:center; justify-content: space-between; gap:10px;">
708
- <h3>FREYA</h3>
709
- <span class="pill"><span class="dot" id="dot"></span><span id="pill">idle</span></span>
710
- </div>
345
+ <aside class="sidebar">
346
+ <div class="sideHeader">
347
+ <div class="logo">FREYA</div>
348
+ <div class="statusPill"><span class="dot" id="dot"></span><span id="pill">idle</span></div>
349
+ </div>
711
350
 
712
- <div class="sideBlock">
713
- <h3>Workspace</h3>
714
351
  <div class="sidePath" id="sidePath">./freya</div>
715
- <button class="btn sideBtn" onclick="pickDir()">Select workspace…</button>
716
- <button class="btn primary sideBtn" onclick="doInit()">Init workspace</button>
717
- <button class="btn sideBtn" onclick="doUpdate()">Update (preserve data/logs)</button>
718
- <button class="btn sideBtn" onclick="doHealth()">Health</button>
719
- <button class="btn sideBtn" onclick="doMigrate()">Migrate</button>
720
- <div class="help">Dica: se você já tem uma workspace antiga, use <b>Update</b>. Por padrão, <b>data/</b> e <b>logs/</b> não são sobrescritos.</div>
721
- </div>
722
-
723
- <div class="sideBlock">
724
- <h3>Atalhos</h3>
725
- <div class="help"><span class="k">--dev</span> cria dados de exemplo para testar rápido.</div>
726
- <div class="help"><span class="k">--port</span> muda a porta (default 3872).</div>
727
- </div>
728
- </aside>
729
-
730
- <main class="main">
731
- <div class="topbar">
732
- <div class="brand"><span class="spark"></span> Local-first status assistant</div>
733
- <div class="actions">
734
- <span class="chip" id="chipPort">127.0.0.1:3872</span>
735
- <button class="toggle" id="themeToggle" onclick="toggleTheme()">Theme</button>
352
+
353
+ <div class="sideGroup">
354
+ <div class="sideTitle">Workspace</div>
355
+ <button class="btn sideBtn" onclick="pickDir()">Select workspace…</button>
356
+ <button class="btn primary sideBtn" onclick="doInit()">Init workspace</button>
357
+ <button class="btn sideBtn" onclick="doUpdate()">Update (preserve data/logs)</button>
358
+ <button class="btn sideBtn" onclick="doHealth()">Health</button>
359
+ <button class="btn sideBtn" onclick="doMigrate()">Migrate</button>
360
+ <div style="height:10px"></div>
361
+ <div class="help">Dica: se você já tem uma workspace antiga, use Update. Por padrão, data/logs não são sobrescritos.</div>
736
362
  </div>
737
- </div>
738
363
 
739
- <div class="section">
740
- <h1>Morning, how can I help?</h1>
741
- <div class="subtitle">Selecione uma workspace e gere relatórios (Executive / SM / Blockers / Daily). Você pode publicar no Discord/Teams com 1 clique.</div>
364
+ <div class="sideGroup">
365
+ <div class="sideTitle">Atalhos</div>
366
+ <div class="help"><span class="k">--dev</span> cria dados de exemplo para testar rápido.</div>
367
+ <div style="height:8px"></div>
368
+ <div class="help"><span class="k">--port</span> muda a porta (default 3872).</div>
369
+ </div>
742
370
 
743
- <div class="cards">
744
- <div class="card" onclick="runReport('status')">
745
- <div class="icon">E</div>
746
- <div class="title">Executive report</div>
747
- <div class="desc">Status pronto para stakeholders (entregas, projetos, blockers).</div>
748
- </div>
749
- <div class="card" onclick="runReport('sm-weekly')">
750
- <div class="icon">S</div>
751
- <div class="title">SM weekly</div>
752
- <div class="desc">Resumo, wins, riscos e foco da próxima semana.</div>
753
- </div>
754
- <div class="card" onclick="runReport('blockers')">
755
- <div class="icon orange">B</div>
756
- <div class="title">Blockers</div>
757
- <div class="desc">Lista priorizada por severidade + idade (pra destravar rápido).</div>
371
+ <div class="sideGroup">
372
+ <div class="sideTitle">Daily Input</div>
373
+ <textarea id="inboxText" rows="6" placeholder="Cole aqui updates do dia (status, blockers, decisões, ideias)…" style="width:100%; padding:10px 12px; border-radius:12px; border:1px solid var(--line); background: rgba(255,255,255,.72); color: var(--text); outline:none; resize: vertical;"></textarea>
374
+ <div style="height:10px"></div>
375
+ <div class="stack">
376
+ <button class="btn sideBtn" onclick="saveInbox()">Save to Daily Log</button>
377
+ <button class="btn primary sideBtn" onclick="saveAndPlan()">Save + Process (Agents)</button>
378
+ <button class="btn sideBtn" onclick="runSuggestedReports()">Run suggested reports</button>
758
379
  </div>
759
- <div class="card" onclick="runReport('daily')">
760
- <div class="icon">D</div>
761
- <div class="title">Daily</div>
762
- <div class="desc">Ontem / Hoje / Bloqueios — pronto pra standup.</div>
380
+ <div class="help">Save+Process gera um plano (draft). Apply plan cria tasks/blockers. Run suggested reports executa os reports recomendados (daily/status/sm-weekly/blockers).</div>
381
+ </div>
382
+ </aside>
383
+
384
+ <main class="main">
385
+ <div class="topbar">
386
+ <div class="brand"><span class="spark"></span> Local-first status assistant</div>
387
+ <div class="actions">
388
+ <span class="chip" id="chipPort">127.0.0.1:3872</span>
389
+ <button class="toggle" id="themeToggle" onclick="toggleTheme()">Theme</button>
763
390
  </div>
764
391
  </div>
765
392
 
766
- <div class="grid2">
767
- <div class="panel">
768
- <div class="panelHead"><b>Workspace & publish settings</b><span class="small" id="last"></span></div>
769
- <div class="panelBody">
770
- <label>Workspace dir</label>
771
- <div class="row">
772
- <input id="dir" placeholder="./freya" />
773
- <button class="btn small" onclick="pickDir()">Browse</button>
774
- </div>
775
- <div class="help">Escolha a pasta que contém <code>data/</code>, <code>logs/</code> e <code>scripts/</code>.</div>
776
-
777
- <div style="height:12px"></div>
778
-
779
- <label>Discord webhook URL</label>
780
- <input id="discord" placeholder="https://discord.com/api/webhooks/..." />
781
- <div style="height:10px"></div>
782
-
783
- <label>Teams webhook URL</label>
784
- <input id="teams" placeholder="https://..." />
785
- <div class="help">Os webhooks ficam salvos na workspace em <code>data/settings/settings.json</code>.</div>
393
+ <div class="section">
394
+ <h1>Morning, how can I help?</h1>
395
+ <div class="subtitle">Selecione uma workspace e gere relatórios (Executive / SM / Blockers / Daily). Você pode publicar no Discord/Teams com 1 clique.</div>
786
396
 
787
- <div style="height:10px"></div>
788
- <div class="stack">
789
- <button class="btn" onclick="saveSettings()">Save settings</button>
790
- <button class="btn" onclick="publish('discord')">Publish selected → Discord</button>
791
- <button class="btn" onclick="publish('teams')">Publish selected → Teams</button>
792
- </div>
793
-
794
- <div style="height:14px"></div>
795
-
796
- <div class="help"><b>Dica:</b> clique em um relatório em <i>Reports</i> para ver o preview e habilitar publish/copy.</div>
397
+ <div class="cards">
398
+ <div class="card" onclick="runReport('status')">
399
+ <div class="icon">E</div>
400
+ <div class="title">Executive report</div>
401
+ <div class="desc">Status pronto para stakeholders (entregas, projetos, blockers).</div>
402
+ </div>
403
+ <div class="card" onclick="runReport('sm-weekly')">
404
+ <div class="icon">S</div>
405
+ <div class="title">SM weekly</div>
406
+ <div class="desc">Resumo, wins, riscos e foco da próxima semana.</div>
407
+ </div>
408
+ <div class="card" onclick="runReport('blockers')">
409
+ <div class="icon orange">B</div>
410
+ <div class="title">Blockers</div>
411
+ <div class="desc">Lista priorizada por severidade + idade (pra destravar rápido).</div>
412
+ </div>
413
+ <div class="card" onclick="runReport('daily')">
414
+ <div class="icon">D</div>
415
+ <div class="title">Daily</div>
416
+ <div class="desc">Ontem / Hoje / Bloqueios — pronto pra standup.</div>
797
417
  </div>
798
418
  </div>
799
419
 
800
- <div class="panel">
801
- <div class="panelHead">
802
- <b>Reports</b>
803
- <div class="stack">
804
- <button class="btn small" onclick="refreshReports()">Refresh</button>
420
+ <div class="grid2">
421
+ <div class="panel">
422
+ <div class="panelHead"><b>Workspace & publish settings</b><span class="small" id="last"></span></div>
423
+ <div class="panelBody">
424
+ <label>Workspace dir</label>
425
+ <div class="row">
426
+ <input id="dir" placeholder="./freya" />
427
+ <button class="btn small" onclick="pickDir()">Browse</button>
428
+ </div>
429
+ <div class="help">Escolha a pasta que contém <code>data/</code>, <code>logs/</code> e <code>scripts/</code>.</div>
430
+
431
+ <div style="height:12px"></div>
432
+
433
+ <label>Discord webhook URL</label>
434
+ <input id="discord" placeholder="https://discord.com/api/webhooks/..." />
435
+ <div style="height:10px"></div>
436
+
437
+ <label>Teams webhook URL</label>
438
+ <input id="teams" placeholder="https://..." />
439
+ <div class="help">Os webhooks ficam salvos na workspace em <code>data/settings/settings.json</code>.</div>
440
+
441
+ <div style="height:10px"></div>
442
+ <div class="stack">
443
+ <button class="btn" onclick="saveSettings()">Save settings</button>
444
+ <button class="btn" onclick="publish('discord')">Publish selected → Discord</button>
445
+ <button class="btn" onclick="publish('teams')">Publish selected → Teams</button>
446
+ </div>
447
+
448
+ <div style="height:14px"></div>
449
+
450
+ <div class="help"><b>Dica:</b> clique em um relatório em <i>Reports</i> para ver o preview e habilitar publish/copy.</div>
805
451
  </div>
806
452
  </div>
807
- <div class="panelBody">
808
- <input id="reportsFilter" placeholder="filter (ex: daily, executive, 2026-01-29)" style="width:100%; margin-bottom:10px" oninput="renderReportsList()" />
809
- <div id="reportsList" style="display:grid; gap:8px"></div>
810
- <div class="help">Últimos relatórios em <code>docs/reports</code>. Clique para abrir preview.</div>
811
- </div>
812
- </div>
813
453
 
814
- <div class="panel">
815
- <div class="panelHead">
816
- <b>Preview</b>
817
- <div class="stack">
818
- <button class="btn small" onclick="copyOut()">Copy</button>
819
- <button class="btn small" onclick="clearOut()">Clear</button>
454
+ <div class="panel">
455
+ <div class="panelHead">
456
+ <b>Reports</b>
457
+ <div class="stack">
458
+ <button class="btn small" onclick="refreshReports()">Refresh</button>
459
+ </div>
460
+ </div>
461
+ <div class="panelBody">
462
+ <input id="reportsFilter" placeholder="filter (ex: daily, executive, 2026-01-29)" style="width:100%; margin-bottom:10px" oninput="renderReportsList()" />
463
+ <div id="reportsList" style="display:grid; gap:8px"></div>
464
+ <div class="help">Últimos relatórios em <code>docs/reports</code>. Clique para abrir preview.</div>
820
465
  </div>
821
466
  </div>
822
- <div class="panelBody">
823
- <div id="reportPreview" class="log md" style="font-family: var(--sans);"></div>
824
- <div class="help">O preview renderiza Markdown básico (headers, listas, code). O botão Copy copia o conteúdo completo.</div>
467
+
468
+ <div class="panel">
469
+ <div class="panelHead">
470
+ <b>Preview</b>
471
+ <div class="stack">
472
+ <button class="btn small" onclick="copyOut()">Copy</button>
473
+ <button class="btn small" onclick="applyPlan()">Apply plan</button>
474
+ <button class="btn small" onclick="copyPath()">Copy path</button>
475
+ <button class="btn small" onclick="openSelected()">Open file</button>
476
+ <button class="btn small" onclick="downloadSelected()">Download .md</button>
477
+ <button class="btn small" onclick="clearOut()">Clear</button>
478
+ </div>
479
+ </div>
480
+ <div class="panelBody">
481
+ <div id="reportPreview" class="log md" style="font-family: var(--sans);"></div>
482
+ <div class="help">O preview renderiza Markdown básico (headers, listas, code). O botão Copy copia o conteúdo completo.</div>
483
+ </div>
825
484
  </div>
826
485
  </div>
827
-
828
486
  </div>
829
- </div>
830
-
831
- </main>
487
+ </main>
832
488
 
489
+ </div>
833
490
  </div>
834
491
  </div>
835
492
 
836
- <script>
837
- window.__FREYA_DEFAULT_DIR = "${safeDefault}";
838
- const $ = (id) => document.getElementById(id);
839
- const state = { lastReportPath: null, lastText: '', reports: [], selectedReport: null };
840
-
841
- function applyTheme(theme) {
842
- document.documentElement.setAttribute('data-theme', theme);
843
- localStorage.setItem('freya.theme', theme);
844
- $('themeToggle').textContent = theme === 'dark' ? 'Light' : 'Dark';
845
- }
846
-
847
- function toggleTheme() {
848
- const t = localStorage.getItem('freya.theme') || 'light';
849
- applyTheme(t === 'dark' ? 'light' : 'dark');
850
- }
851
-
852
- function setPill(kind, text) {
853
- const dot = $('dot');
854
- dot.classList.remove('ok','err');
855
- if (kind === 'ok') dot.classList.add('ok');
856
- if (kind === 'err') dot.classList.add('err');
857
- $('pill').textContent = text;
858
- $('status') && ($('status').textContent = text);
859
- }
860
-
861
- function escapeHtml(str) {
862
- return String(str)
863
- .replace(/&/g, '&amp;')
864
- .replace(/</g, '&lt;')
865
- .replace(/>/g, '&gt;')
866
- .replace(/\"/g, '&quot;')
867
- .replace(/'/g, '&#39;');
868
- }
869
-
870
- function renderMarkdown(md) {
871
- const lines = String(md || '').split(/\\r?\\n/);
872
- let html = '';
873
- let inCode = false;
874
- let inList = false;
875
-
876
- const BT = String.fromCharCode(96); // backtick
877
- const FENCE = BT + BT + BT;
878
- const inlineCodeRe = /\x60([^\x60]+)\x60/g;
879
-
880
- const closeList = () => {
881
- if (inList) { html += '</ul>'; inList = false; }
882
- };
883
-
884
- for (const line of lines) {
885
- if (line.trim().startsWith(FENCE)) {
886
- if (!inCode) {
887
- closeList();
888
- inCode = true;
889
- html += '<pre class="md-code"><code>';
890
- } else {
891
- inCode = false;
892
- html += '</code></pre>';
893
- }
894
- continue;
895
- }
896
-
897
- if (inCode) {
898
- html += escapeHtml(line) + '\n';
899
- continue;
900
- }
901
-
902
- const h = line.match(/^(#{1,3})\s+(.*)$/);
903
- if (h) {
904
- closeList();
905
- const lvl = h[1].length;
906
- html += '<h' + lvl + ' class="md-h' + lvl + '">' + escapeHtml(h[2]) + '</h' + lvl + '>';
907
- continue;
908
- }
909
-
910
- const li = line.match(/^\s*[-*]\s+(.*)$/);
911
- if (li) {
912
- if (!inList) { html += '<ul class="md-ul">'; inList = true; }
913
- const content = escapeHtml(li[1]).replace(inlineCodeRe, '<code class="md-inline">$1</code>');
914
- html += '<li>' + content + '</li>';
915
- continue;
916
- }
917
-
918
- if (line.trim() === '') {
919
- closeList();
920
- html += '<div class="md-sp"></div>';
921
- continue;
922
- }
923
-
924
- closeList();
925
- const p = escapeHtml(line).replace(inlineCodeRe, '<code class="md-inline">$1</code>');
926
- html += '<p class="md-p">' + p + '</p>';
927
- }
928
-
929
- closeList();
930
- if (inCode) html += '</code></pre>';
931
- return html;
932
- }
933
-
934
- function setOut(text) {
935
- state.lastText = text || '';
936
- const el = $('reportPreview');
937
- if (el) el.innerHTML = renderMarkdown(state.lastText);
938
- }
939
-
940
- function clearOut() {
941
- state.lastText = '';
942
- const el = $('reportPreview');
943
- if (el) el.innerHTML = '';
944
- setPill('ok', 'idle');
945
- }
946
-
947
- async function copyOut() {
948
- try {
949
- await navigator.clipboard.writeText(state.lastText || '');
950
- setPill('ok','copied');
951
- setTimeout(() => setPill('ok','idle'), 800);
952
- } catch (e) {
953
- setPill('err','copy failed');
954
- }
955
- }
956
-
957
- function setLast(p) {
958
- state.lastReportPath = p;
959
- $('last').textContent = p ? ('Last report: ' + p) : '';
960
- }
961
-
962
- function saveLocal() {
963
- localStorage.setItem('freya.dir', $('dir').value);
964
- }
965
-
966
- function loadLocal() {
967
- $('dir').value = (window.__FREYA_DEFAULT_DIR && window.__FREYA_DEFAULT_DIR !== '__FREYA_DEFAULT_DIR__') ? window.__FREYA_DEFAULT_DIR : (localStorage.getItem('freya.dir') || './freya');
968
- $('sidePath').textContent = $('dir').value || './freya';
969
- // Always persist the current run's default dir to avoid stale values
970
- localStorage.setItem('freya.dir', $('dir').value || './freya');
971
- }
972
-
973
- async function api(p, body) {
974
- const res = await fetch(p, {
975
- method: body ? 'POST' : 'GET',
976
- headers: body ? { 'Content-Type': 'application/json' } : {},
977
- body: body ? JSON.stringify(body) : undefined
978
- });
979
- const json = await res.json();
980
- if (!res.ok) throw new Error(json.error || 'Request failed');
981
- return json;
982
- }
983
-
984
- function dirOrDefault() {
985
- const d = $('dir').value.trim();
986
- return d || './freya';
987
- }
988
-
989
- function fmtWhen(ms) {
990
- try {
991
- const d = new Date(ms);
992
- const yy = String(d.getFullYear());
993
- const mm = String(d.getMonth() + 1).padStart(2, '0');
994
- const dd = String(d.getDate()).padStart(2, '0');
995
- const hh = String(d.getHours()).padStart(2, '0');
996
- const mi = String(d.getMinutes()).padStart(2, '0');
997
- return yy + '-' + mm + '-' + dd + ' ' + hh + ':' + mi;
998
- } catch {
999
- return '';
1000
- }
1001
- }
1002
-
1003
- async function selectReport(item) {
1004
- const rr = await api('/api/reports/read', { dir: dirOrDefault(), relPath: item.relPath });
1005
- state.selectedReport = item;
1006
- setLast(item.name);
1007
- setOut(rr.text || '');
1008
- renderReportsList();
1009
- }
1010
-
1011
- function renderReportsList() {
1012
- const list = $('reportsList');
1013
- if (!list) return;
1014
- const q = ($('reportsFilter') ? $('reportsFilter').value : '').trim().toLowerCase();
1015
- const filtered = (state.reports || []).filter((it) => {
1016
- if (!q) return true;
1017
- return (it.name + ' ' + it.kind).toLowerCase().includes(q);
1018
- });
1019
-
1020
- list.innerHTML = '';
1021
- for (const item of filtered) {
1022
- const btn = document.createElement('button');
1023
- btn.className = 'rep' + (state.selectedReport && state.selectedReport.relPath === item.relPath ? ' repActive' : '');
1024
- btn.type = 'button';
1025
- const meta = fmtWhen(item.mtimeMs);
1026
- btn.innerHTML =
1027
- '<div style="display:flex; gap:10px; align-items:center; justify-content:space-between">'
1028
- + '<div style="min-width:0">'
1029
- + '<div><span style="font-weight:800">' + escapeHtml(item.kind) + '</span> <span style="opacity:.7">—</span> ' + escapeHtml(item.name) + '</div>'
1030
- + '<div style="opacity:.65; font-size:11px; margin-top:4px">' + escapeHtml(item.relPath) + '</div>'
1031
- + '</div>'
1032
- + '<div style="opacity:.7; font-size:11px; white-space:nowrap">' + escapeHtml(meta) + '</div>'
1033
- + '</div>';
1034
-
1035
- btn.onclick = async () => {
1036
- try {
1037
- await selectReport(item);
1038
- } catch (e) {
1039
- setPill('err', 'open failed');
1040
- }
1041
- };
1042
- list.appendChild(btn);
1043
- }
1044
- }
1045
-
1046
- async function refreshReports() {
1047
- try {
1048
- const r = await api('/api/reports/list', { dir: dirOrDefault() });
1049
- state.reports = (r.reports || []).slice(0, 50);
1050
- renderReportsList();
1051
-
1052
- // Auto-select latest if nothing selected yet
1053
- if (!state.selectedReport && state.reports && state.reports[0]) {
1054
- await selectReport(state.reports[0]);
1055
- }
1056
- } catch (e) {
1057
- // ignore
1058
- }
1059
- }
1060
-
1061
- async function pickDir() {
1062
- try {
1063
- setPill('run','picker…');
1064
- const r = await api('/api/pick-dir', {});
1065
- if (r && r.dir) {
1066
- $('dir').value = r.dir;
1067
- $('sidePath').textContent = r.dir;
1068
- }
1069
- saveLocal();
1070
- setPill('ok','ready');
1071
- } catch (e) {
1072
- setPill('err','picker failed');
1073
- setOut(String(e && e.message ? e.message : e));
1074
- }
1075
- }
1076
-
1077
- async function doInit() {
1078
- try {
1079
- saveLocal();
1080
- $('sidePath').textContent = dirOrDefault();
1081
- setPill('run','init…');
1082
- setOut('');
1083
- const r = await api('/api/init', { dir: dirOrDefault() });
1084
- setOut(r.output);
1085
- setLast(null);
1086
- await refreshReports();
1087
- setPill('ok','init ok');
1088
- } catch (e) {
1089
- setPill('err','init failed');
1090
- setOut(String(e && e.message ? e.message : e));
1091
- }
1092
- }
1093
-
1094
- async function doUpdate() {
1095
- try {
1096
- saveLocal();
1097
- $('sidePath').textContent = dirOrDefault();
1098
- setPill('run','update…');
1099
- setOut('');
1100
- const r = await api('/api/update', { dir: dirOrDefault() });
1101
- setOut(r.output);
1102
- setLast(null);
1103
- await refreshReports();
1104
- setPill('ok','update ok');
1105
- } catch (e) {
1106
- setPill('err','update failed');
1107
- setOut(String(e && e.message ? e.message : e));
1108
- }
1109
- }
1110
-
1111
- async function doHealth() {
1112
- try {
1113
- saveLocal();
1114
- $('sidePath').textContent = dirOrDefault();
1115
- setPill('run','health…');
1116
- setOut('');
1117
- const r = await api('/api/health', { dir: dirOrDefault() });
1118
- setOut(r.output);
1119
- setLast(null);
1120
- setPill('ok','health ok');
1121
- } catch (e) {
1122
- setPill('err','health failed');
1123
- setOut(String(e && e.message ? e.message : e));
1124
- }
1125
- }
1126
-
1127
- async function doMigrate() {
1128
- try {
1129
- saveLocal();
1130
- $('sidePath').textContent = dirOrDefault();
1131
- setPill('run','migrate…');
1132
- setOut('');
1133
- const r = await api('/api/migrate', { dir: dirOrDefault() });
1134
- setOut(r.output);
1135
- setLast(null);
1136
- setPill('ok','migrate ok');
1137
- } catch (e) {
1138
- setPill('err','migrate failed');
1139
- setOut(String(e && e.message ? e.message : e));
1140
- }
1141
- }
1142
-
1143
- async function runReport(name) {
1144
- try {
1145
- saveLocal();
1146
- $('sidePath').textContent = dirOrDefault();
1147
- setPill('run', name + '…');
1148
- setOut('');
1149
- const r = await api('/api/report', { dir: dirOrDefault(), script: name });
1150
- setOut(r.output);
1151
- setLast(r.reportPath || null);
1152
- if (r.reportText) state.lastText = r.reportText;
1153
- await refreshReports();
1154
- setPill('ok', name + ' ok');
1155
- } catch (e) {
1156
- setPill('err', name + ' failed');
1157
- setOut(String(e && e.message ? e.message : e));
1158
- }
1159
- }
1160
-
1161
- async function saveSettings() {
1162
- try {
1163
- saveLocal();
1164
- setPill('run','saving…');
1165
- await api('/api/settings/save', {
1166
- dir: dirOrDefault(),
1167
- settings: {
1168
- discordWebhookUrl: $('discord').value.trim(),
1169
- teamsWebhookUrl: $('teams').value.trim()
1170
- }
1171
- });
1172
- setPill('ok','saved');
1173
- setTimeout(() => setPill('ok','idle'), 800);
1174
- } catch (e) {
1175
- setPill('err','save failed');
1176
- }
1177
- }
1178
-
1179
- async function publish(target) {
1180
- try {
1181
- saveLocal();
1182
- if (!state.lastText) throw new Error('Gere um relatório primeiro.');
1183
- const webhookUrl = target === 'discord' ? $('discord').value.trim() : $('teams').value.trim();
1184
- if (!webhookUrl) throw new Error('Configure o webhook antes.');
1185
- setPill('run','publish…');
1186
- await api('/api/publish', { webhookUrl, text: state.lastText });
1187
- setPill('ok','published');
1188
- } catch (e) {
1189
- setPill('err','publish failed');
1190
- setOut(String(e && e.message ? e.message : e));
1191
- }
1192
- }
1193
-
1194
- // Expose handlers for inline onclick="..." attributes
1195
- window.doInit = doInit;
1196
- window.doUpdate = doUpdate;
1197
- window.doHealth = doHealth;
1198
- window.doMigrate = doMigrate;
1199
- window.pickDir = pickDir;
1200
- window.runReport = runReport;
1201
- window.publish = publish;
1202
- window.saveSettings = saveSettings;
1203
- window.refreshReports = refreshReports;
1204
- window.renderReportsList = renderReportsList;
1205
- window.copyOut = copyOut;
1206
- window.clearOut = clearOut;
1207
- window.toggleTheme = toggleTheme;
1208
-
1209
- // init
1210
- applyTheme(localStorage.getItem('freya.theme') || 'light');
1211
- $('chipPort').textContent = location.host;
1212
- loadLocal();
1213
-
1214
- // Load persisted settings from the workspace
1215
- (async () => {
1216
- try {
1217
- const r = await api('/api/defaults', { dir: dirOrDefault() });
1218
- if (r && r.workspaceDir) {
1219
- $('dir').value = r.workspaceDir;
1220
- $('sidePath').textContent = r.workspaceDir;
1221
- }
1222
- if (r && r.settings) {
1223
- $('discord').value = r.settings.discordWebhookUrl || '';
1224
- $('teams').value = r.settings.teamsWebhookUrl || '';
1225
- }
1226
- } catch (e) {
1227
- // ignore
1228
- }
1229
- refreshReports();
1230
- })();
1231
-
1232
- setPill('ok','idle');
1233
- </script>
493
+ <script>
494
+ window.__FREYA_DEFAULT_DIR = "${safeDefault}";
495
+ </script>
496
+ <script src="/app.js"></script>
1234
497
  </body>
1235
498
  </html>`;
1236
499
  }
@@ -1418,6 +681,20 @@ async function cmdWeb({ port, dir, open, dev }) {
1418
681
  return;
1419
682
  }
1420
683
 
684
+ if (req.method === 'GET' && req.url === '/app.css') {
685
+ const css = fs.readFileSync(path.join(__dirname, 'web-ui.css'), 'utf8');
686
+ res.writeHead(200, { 'Content-Type': 'text/css; charset=utf-8', 'Cache-Control': 'no-store' });
687
+ res.end(css);
688
+ return;
689
+ }
690
+
691
+ if (req.method === 'GET' && req.url === '/app.js') {
692
+ const js = fs.readFileSync(path.join(__dirname, 'web-ui.js'), 'utf8');
693
+ res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store' });
694
+ res.end(js);
695
+ return;
696
+ }
697
+
1421
698
  if (req.url.startsWith('/api/')) {
1422
699
  const raw = await readBody(req);
1423
700
  const payload = raw ? JSON.parse(raw) : {};
@@ -1456,6 +733,339 @@ async function cmdWeb({ port, dir, open, dev }) {
1456
733
  return safeJson(res, 200, { relPath: rel, text });
1457
734
  }
1458
735
 
736
+ if (req.url === '/api/reports/resolve') {
737
+ const rel = payload.relPath;
738
+ if (!rel) return safeJson(res, 400, { error: 'Missing relPath' });
739
+ const full = path.join(workspaceDir, rel);
740
+ if (!exists(full)) return safeJson(res, 404, { error: 'Report not found' });
741
+ return safeJson(res, 200, { relPath: rel, fullPath: full });
742
+ }
743
+
744
+ if (req.url === '/api/inbox/add') {
745
+ const text = String(payload.text || '').trim();
746
+ if (!text) return safeJson(res, 400, { error: 'Missing text' });
747
+
748
+ const d = isoDate();
749
+ const file = path.join(workspaceDir, 'logs', 'daily', `${d}.md`);
750
+ ensureDir(path.dirname(file));
751
+
752
+ const stamp = new Date();
753
+ const hh = String(stamp.getHours()).padStart(2, '0');
754
+ const mm = String(stamp.getMinutes()).padStart(2, '0');
755
+
756
+ const block = `\n\n## [${hh}:${mm}] Raw Input\n${text}\n`;
757
+ fs.appendFileSync(file, block, 'utf8');
758
+
759
+ return safeJson(res, 200, { ok: true, file: path.relative(workspaceDir, file).replace(/\\/g, '/'), appended: true });
760
+ }
761
+
762
+ if (req.url === '/api/agents/plan') {
763
+ const text = String(payload.text || '').trim();
764
+ if (!text) return safeJson(res, 400, { error: 'Missing text' });
765
+
766
+ // Build planner prompt from agent rules (same ones used in IDE/MCP)
767
+ const rulesBase = path.join(workspaceDir, '.agent', 'rules', 'freya');
768
+ const files = [
769
+ path.join(rulesBase, 'freya.mdc'),
770
+ path.join(rulesBase, 'agents', 'master.mdc'),
771
+ path.join(rulesBase, 'agents', 'ingestor.mdc'),
772
+ path.join(rulesBase, 'agents', 'oracle.mdc'),
773
+ path.join(rulesBase, 'agents', 'coach.mdc')
774
+ ].filter(exists);
775
+
776
+ const rulesText = files.map((p) => `\n\n---\nFILE: ${path.relative(workspaceDir, p).replace(/\\/g,'/')}\n---\n` + fs.readFileSync(p, 'utf8')).join('');
777
+
778
+ const schema = {
779
+ actions: [
780
+ { type: 'append_daily_log', text: '<string>' },
781
+ { type: 'create_task', description: '<string>', priority: 'HIGH|MEDIUM|LOW', category: 'DO_NOW|SCHEDULE|DELEGATE|IGNORE' },
782
+ { type: 'create_blocker', title: '<string>', severity: 'CRITICAL|HIGH|MEDIUM|LOW', notes: '<string>' },
783
+ { type: 'suggest_report', name: 'daily|status|sm-weekly|blockers' },
784
+ { type: 'oracle_query', query: '<string>' }
785
+ ]
786
+ };
787
+
788
+ const prompt = `Você é o planner do sistema F.R.E.Y.A.\n\nContexto: vamos receber um input bruto do usuário e propor ações estruturadas.\nRegras: siga os arquivos de regras abaixo.\nSaída: retorne APENAS JSON válido no formato: ${JSON.stringify(schema)}\n\nREGRAS:${rulesText}\n\nINPUT DO USUÁRIO:\n${text}\n`;
789
+
790
+ // Prefer COPILOT_CMD if provided, otherwise try 'copilot'
791
+ const cmd = process.env.COPILOT_CMD || 'copilot';
792
+
793
+ // Best-effort: if command not available, return a clear message
794
+ try {
795
+ const r = await run(cmd, ['-s', '--no-color', '--stream', 'off', '-p', prompt, '--allow-all-tools'], workspaceDir);
796
+ const out = (r.stdout + r.stderr).trim();
797
+ if (r.code !== 0) return safeJson(res, 400, { error: out || 'copilot failed', output: out });
798
+ return safeJson(res, 200, { ok: true, plan: out });
799
+ } catch (e) {
800
+ return safeJson(res, 400, { error: `Copilot CLI not available (${cmd}). Configure COPILOT_CMD or install copilot.`, details: e.message || String(e) });
801
+ }
802
+ }
803
+
804
+ if (req.url === '/api/agents/preview') {
805
+ const planRaw = String(payload.plan || '').trim();
806
+ if (!planRaw) return safeJson(res, 400, { error: 'Missing plan' });
807
+
808
+ function extractJson(text) {
809
+ const start = text.indexOf('{');
810
+ const end = text.lastIndexOf('}');
811
+ if (start === -1 || end === -1 || end <= start) return null;
812
+ return text.slice(start, end + 1);
813
+ }
814
+
815
+ const jsonText = extractJson(planRaw) || planRaw;
816
+ let plan;
817
+ try {
818
+ plan = JSON.parse(jsonText);
819
+ } catch (e) {
820
+ return safeJson(res, 400, { error: 'Plan is not valid JSON', details: e.message || String(e) });
821
+ }
822
+
823
+ const actions = Array.isArray(plan.actions) ? plan.actions : [];
824
+ if (!Array.isArray(actions) || actions.length === 0) {
825
+ return safeJson(res, 400, { error: 'Plan has no actions[]' });
826
+ }
827
+
828
+ // Validate + normalize to a safe preview shape
829
+ const validTaskCats = new Set(['DO_NOW', 'SCHEDULE', 'DELEGATE', 'IGNORE']);
830
+ const validSev = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']);
831
+
832
+ const preview = { tasks: [], blockers: [], reportsSuggested: [], oracleQueries: [], ignored: 0, errors: [] };
833
+
834
+ for (const a of actions) {
835
+ if (!a || typeof a !== 'object') { preview.ignored++; continue; }
836
+ const type = String(a.type || '').trim();
837
+
838
+ if (type === 'create_task') {
839
+ const description = String(a.description || '').trim();
840
+ const category = String(a.category || '').trim();
841
+ const priorityRaw = String(a.priority || '').trim().toLowerCase();
842
+ const priority = (priorityRaw === 'high' || priorityRaw === 'medium' || priorityRaw === 'low') ? priorityRaw : undefined;
843
+ if (!description) { preview.errors.push('Task missing description'); continue; }
844
+ preview.tasks.push({ description, category: validTaskCats.has(category) ? category : 'DO_NOW', priority });
845
+ continue;
846
+ }
847
+
848
+ if (type === 'create_blocker') {
849
+ const title = String(a.title || '').trim();
850
+ const notes = String(a.notes || '').trim();
851
+ let severity = String(a.severity || '').trim().toUpperCase();
852
+ if (!validSev.has(severity)) {
853
+ if (severity.includes('CRIT')) severity = 'CRITICAL';
854
+ else if (severity.includes('HIGH')) severity = 'HIGH';
855
+ else if (severity.includes('MED')) severity = 'MEDIUM';
856
+ else if (severity.includes('LOW')) severity = 'LOW';
857
+ else severity = 'MEDIUM';
858
+ }
859
+ if (!title) { preview.errors.push('Blocker missing title'); continue; }
860
+ preview.blockers.push({ title, notes, severity });
861
+ continue;
862
+ }
863
+
864
+ if (type === 'suggest_report') {
865
+ const name = String(a.name || '').trim();
866
+ if (name) preview.reportsSuggested.push(name);
867
+ continue;
868
+ }
869
+
870
+ if (type === 'oracle_query') {
871
+ const query = String(a.query || '').trim();
872
+ if (query) preview.oracleQueries.push(query);
873
+ continue;
874
+ }
875
+
876
+ preview.ignored++;
877
+ }
878
+
879
+ // Dedup suggested reports
880
+ preview.reportsSuggested = Array.from(new Set(preview.reportsSuggested));
881
+ preview.oracleQueries = Array.from(new Set(preview.oracleQueries));
882
+
883
+ return safeJson(res, 200, { ok: true, preview });
884
+ }
885
+
886
+ if (req.url === '/api/agents/apply') {
887
+ const planRaw = String(payload.plan || '').trim();
888
+ if (!planRaw) return safeJson(res, 400, { error: 'Missing plan' });
889
+
890
+ function extractJson(text) {
891
+ const start = text.indexOf('{');
892
+ const end = text.lastIndexOf('}');
893
+ if (start === -1 || end === -1 || end <= start) return null;
894
+ return text.slice(start, end + 1);
895
+ }
896
+
897
+ const jsonText = extractJson(planRaw) || planRaw;
898
+ let plan;
899
+ try {
900
+ plan = JSON.parse(jsonText);
901
+ } catch (e) {
902
+ return safeJson(res, 400, { error: 'Plan is not valid JSON', details: e.message || String(e) });
903
+ }
904
+
905
+ const actions = Array.isArray(plan.actions) ? plan.actions : [];
906
+ if (!Array.isArray(actions) || actions.length === 0) {
907
+ return safeJson(res, 400, { error: 'Plan has no actions[]' });
908
+ }
909
+
910
+ const taskFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
911
+ const blockerFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
912
+
913
+ const taskLog = readJsonOrNull(taskFile) || { schemaVersion: 1, tasks: [] };
914
+ if (!Array.isArray(taskLog.tasks)) taskLog.tasks = [];
915
+ if (typeof taskLog.schemaVersion !== 'number') taskLog.schemaVersion = 1;
916
+
917
+ const blockerLog = readJsonOrNull(blockerFile) || { schemaVersion: 1, blockers: [] };
918
+ if (!Array.isArray(blockerLog.blockers)) blockerLog.blockers = [];
919
+ if (typeof blockerLog.schemaVersion !== 'number') blockerLog.schemaVersion = 1;
920
+
921
+ function normalizeTextForKey(t) {
922
+ return String(t || '').toLowerCase().replace(/s+/g, ' ').trim();
923
+ }
924
+
925
+ function sha1(text) {
926
+ return crypto.createHash('sha1').update(String(text || ''), 'utf8').digest('hex');
927
+ }
928
+
929
+ function within24h(iso) {
930
+ try {
931
+ const ms = Date.parse(iso);
932
+ if (!Number.isFinite(ms)) return false;
933
+ return (Date.now() - ms) <= 24 * 60 * 60 * 1000;
934
+ } catch {
935
+ return false;
936
+ }
937
+ }
938
+
939
+ const existingTaskKeys24h = new Set(
940
+ taskLog.tasks
941
+ .filter((t) => t && within24h(t.createdAt))
942
+ .map((t) => sha1(normalizeTextForKey(t.description)))
943
+ );
944
+
945
+ const existingBlockerKeys24h = new Set(
946
+ blockerLog.blockers
947
+ .filter((b) => b && within24h(b.createdAt))
948
+ .map((b) => sha1(normalizeTextForKey(b.title)))
949
+ );
950
+
951
+ const now = new Date().toISOString();
952
+ const applyMode = String(payload.mode || 'all').trim();
953
+ undefined
954
+
955
+ function makeId(prefix) {
956
+ const rand = Math.random().toString(16).slice(2, 8);
957
+ return `${prefix}-${Date.now()}-${rand}`;
958
+ }
959
+
960
+ function normPriority(p) {
961
+ const v = String(p || '').trim().toLowerCase();
962
+ if (v === 'high') return 'high';
963
+ if (v === 'medium') return 'medium';
964
+ if (v === 'low') return 'low';
965
+ if (v === 'critical') return 'high';
966
+ return undefined;
967
+ }
968
+
969
+ function normSeverity(s) {
970
+ const v = String(s || '').trim().toUpperCase();
971
+ if (v.includes('CRIT')) return 'CRITICAL';
972
+ if (v.includes('HIGH')) return 'HIGH';
973
+ if (v.includes('MED')) return 'MEDIUM';
974
+ if (v.includes('LOW')) return 'LOW';
975
+ return 'MEDIUM';
976
+ }
977
+
978
+ const validTaskCats = new Set(['DO_NOW', 'SCHEDULE', 'DELEGATE', 'IGNORE']);
979
+
980
+ for (const a of actions) {
981
+ if (!a || typeof a !== 'object') continue;
982
+ const type = String(a.type || '').trim();
983
+
984
+ if (type === 'create_task') {
985
+ if (applyMode !== 'all' && applyMode !== 'tasks') continue;
986
+ const description = String(a.description || '').trim();
987
+ if (!description) continue;
988
+ const key = sha1(normalizeTextForKey(description));
989
+ if (existingTaskKeys24h.has(key)) { applied.tasksSkipped++; continue; }
990
+ const category = validTaskCats.has(String(a.category || '').trim()) ? String(a.category).trim() : 'DO_NOW';
991
+ const priority = normPriority(a.priority);
992
+ const task = {
993
+ id: makeId('t'),
994
+ description,
995
+ category,
996
+ status: 'PENDING',
997
+ createdAt: now,
998
+ };
999
+ if (priority) task.priority = priority;
1000
+ taskLog.tasks.push(task);
1001
+ applied.tasks++;
1002
+ continue;
1003
+ }
1004
+
1005
+ if (type === 'create_blocker') {
1006
+ if (applyMode !== 'all' && applyMode !== 'blockers') continue;
1007
+ const title = String(a.title || '').trim();
1008
+ const key = sha1(normalizeTextForKey(title));
1009
+ if (existingBlockerKeys24h.has(key)) { applied.blockersSkipped++; continue; }
1010
+ const notes = String(a.notes || '').trim();
1011
+ if (!title) continue;
1012
+ const severity = normSeverity(a.severity);
1013
+ const blocker = {
1014
+ id: makeId('b'),
1015
+ title,
1016
+ description: notes || title,
1017
+ createdAt: now,
1018
+ status: 'OPEN',
1019
+ severity,
1020
+ };
1021
+ blockerLog.blockers.push(blocker);
1022
+ applied.blockers++;
1023
+ continue;
1024
+ }
1025
+
1026
+ if (type === 'suggest_report') {
1027
+ const name = String(a.name || '').trim();
1028
+ if (name) applied.reportsSuggested.push(name);
1029
+ continue;
1030
+ }
1031
+
1032
+ if (type === 'oracle_query') {
1033
+ const query = String(a.query || '').trim();
1034
+ if (query) applied.oracleQueries.push(query);
1035
+ continue;
1036
+ }
1037
+ }
1038
+
1039
+ // Persist
1040
+ writeJson(taskFile, taskLog);
1041
+ writeJson(blockerFile, blockerLog);
1042
+
1043
+ return safeJson(res, 200, { ok: true, applied });
1044
+ }
1045
+
1046
+ if (req.url === '/api/reports/open') {
1047
+ const rel = payload.relPath;
1048
+ if (!rel) return safeJson(res, 400, { error: 'Missing relPath' });
1049
+ const full = path.join(workspaceDir, rel);
1050
+ if (!exists(full)) return safeJson(res, 404, { error: 'Report not found' });
1051
+
1052
+ // Best-effort: open the file with OS default app
1053
+ try {
1054
+ const platform = process.platform;
1055
+ if (platform === 'win32') {
1056
+ await run('cmd', ['/c', 'start', '', full], workspaceDir);
1057
+ } else if (platform === 'darwin') {
1058
+ await run('open', [full], workspaceDir);
1059
+ } else {
1060
+ await run('xdg-open', [full], workspaceDir);
1061
+ }
1062
+ } catch {
1063
+ // ignore; still return ok
1064
+ }
1065
+
1066
+ return safeJson(res, 200, { ok: true, relPath: rel, fullPath: full });
1067
+ }
1068
+
1459
1069
  if (req.url === '/api/init') {
1460
1070
  const pkg = '@cccarv82/freya';
1461
1071
  const r = await run(guessNpxCmd(), [guessNpxYesFlag(), pkg, 'init', workspaceDir], process.cwd());
@@ -1512,37 +1122,16 @@ async function cmdWeb({ port, dir, open, dev }) {
1512
1122
  if (req.url === '/api/publish') {
1513
1123
  const webhookUrl = payload.webhookUrl;
1514
1124
  const text = payload.text;
1125
+ const mode = payload.mode || 'chunks';
1515
1126
  if (!webhookUrl) return safeJson(res, 400, { error: 'Missing webhookUrl' });
1516
1127
  if (!text) return safeJson(res, 400, { error: 'Missing text' });
1517
1128
 
1518
- // Minimal webhook post: Discord expects {content}, Teams expects {text}
1519
- const u = new URL(webhookUrl);
1520
- const isDiscord = u.hostname.includes('discord.com') || u.hostname.includes('discordapp.com');
1521
- const body = JSON.stringify(isDiscord ? { content: text.slice(0, 1800) } : { text: text.slice(0, 1800) });
1522
-
1523
- const options = {
1524
- method: 'POST',
1525
- hostname: u.hostname,
1526
- path: u.pathname + u.search,
1527
- headers: {
1528
- 'Content-Type': 'application/json',
1529
- 'Content-Length': Buffer.byteLength(body)
1530
- }
1531
- };
1532
-
1533
- const proto = u.protocol === 'https:' ? require('https') : require('http');
1534
- const req2 = proto.request(options, (r2) => {
1535
- const chunks = [];
1536
- r2.on('data', (c) => chunks.push(c));
1537
- r2.on('end', () => {
1538
- if (r2.statusCode >= 200 && r2.statusCode < 300) return safeJson(res, 200, { ok: true });
1539
- return safeJson(res, 400, { error: `Webhook error ${r2.statusCode}: ${Buffer.concat(chunks).toString('utf8')}` });
1540
- });
1541
- });
1542
- req2.on('error', (e) => safeJson(res, 400, { error: e.message }));
1543
- req2.write(body);
1544
- req2.end();
1545
- return;
1129
+ try {
1130
+ const result = await publishRobust(webhookUrl, text, { mode });
1131
+ return safeJson(res, 200, result);
1132
+ } catch (e) {
1133
+ return safeJson(res, 400, { error: e.message || String(e) });
1134
+ }
1546
1135
  }
1547
1136
 
1548
1137
  return safeJson(res, 404, { error: 'Not found' });