@cccarv82/freya 1.0.44 → 1.0.46
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-ui.js +37 -0
- package/cli/web.js +40 -0
- package/package.json +5 -2
- package/scripts/export-obsidian.js +153 -0
- package/scripts/generate-blockers-report.js +215 -0
- package/scripts/generate-daily-summary.js +107 -0
- package/scripts/generate-executive-report.js +240 -0
- package/scripts/generate-sm-weekly-report.js +207 -0
- package/scripts/generate-weekly-report.js +134 -0
- package/scripts/index/build-index.js +13 -0
- package/scripts/index/update-index.js +15 -0
- package/scripts/lib/date-utils.js +37 -0
- package/scripts/lib/fs-utils.js +61 -0
- package/scripts/lib/index-utils.js +407 -0
- package/scripts/lib/search-utils.js +183 -0
- package/scripts/migrate-data.js +80 -0
- package/scripts/validate-data.js +206 -0
package/cli/web-ui.js
CHANGED
|
@@ -240,6 +240,28 @@
|
|
|
240
240
|
}
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
async function askFreya() {
|
|
244
|
+
const input = $('inboxText');
|
|
245
|
+
const query = input ? input.value.trim() : '';
|
|
246
|
+
if (!query) {
|
|
247
|
+
setPill('err', 'digite uma pergunta');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
chatAppend('user', query);
|
|
252
|
+
setPill('run', 'pesquisando…');
|
|
253
|
+
try {
|
|
254
|
+
const sessionId = ensureChatSession();
|
|
255
|
+
const r = await api('/api/chat/ask', { dir: dirOrDefault(), sessionId, query });
|
|
256
|
+
const answer = r && r.answer ? r.answer : 'Não encontrei registro';
|
|
257
|
+
chatAppend('assistant', answer, { markdown: true });
|
|
258
|
+
setPill('ok', 'pronto');
|
|
259
|
+
} catch (e) {
|
|
260
|
+
setPill('err', 'falhou');
|
|
261
|
+
chatAppend('assistant', String(e && e.message ? e.message : e));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
243
265
|
function setOut(text) {
|
|
244
266
|
state.lastText = text || '';
|
|
245
267
|
const el = $('reportPreview');
|
|
@@ -713,6 +735,19 @@
|
|
|
713
735
|
}
|
|
714
736
|
}
|
|
715
737
|
|
|
738
|
+
async function rebuildIndex() {
|
|
739
|
+
try {
|
|
740
|
+
setPill('run', 'indexing…');
|
|
741
|
+
const r = await api('/api/index/rebuild', { dir: dirOrDefault() });
|
|
742
|
+
setOut('## Index rebuild\n\n' + (r.output || 'ok'));
|
|
743
|
+
setPill('ok', 'indexed');
|
|
744
|
+
setTimeout(() => setPill('ok', 'pronto'), 800);
|
|
745
|
+
} catch (e) {
|
|
746
|
+
setPill('err', 'index failed');
|
|
747
|
+
setOut(String(e && e.message ? e.message : e));
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
716
751
|
async function reloadSlugRules() {
|
|
717
752
|
try {
|
|
718
753
|
const r = await api('/api/project-slug-map/get', { dir: dirOrDefault() });
|
|
@@ -1020,6 +1055,7 @@
|
|
|
1020
1055
|
window.reloadSlugRules = reloadSlugRules;
|
|
1021
1056
|
window.saveSlugRules = saveSlugRules;
|
|
1022
1057
|
window.exportObsidian = exportObsidian;
|
|
1058
|
+
window.rebuildIndex = rebuildIndex;
|
|
1023
1059
|
window.renderReportsList = renderReportsList;
|
|
1024
1060
|
window.copyOut = copyOut;
|
|
1025
1061
|
window.copyPath = copyPath;
|
|
@@ -1035,4 +1071,5 @@
|
|
|
1035
1071
|
window.applyPlan = applyPlan;
|
|
1036
1072
|
window.runSuggestedReports = runSuggestedReports;
|
|
1037
1073
|
window.exportChatObsidian = exportChatObsidian;
|
|
1074
|
+
window.askFreya = askFreya;
|
|
1038
1075
|
})();
|
package/cli/web.js
CHANGED
|
@@ -5,6 +5,8 @@ const fs = require('fs');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const crypto = require('crypto');
|
|
7
7
|
const { spawn } = require('child_process');
|
|
8
|
+
const { searchWorkspace } = require('../scripts/lib/search-utils');
|
|
9
|
+
const { searchIndex } = require('../scripts/lib/index-utils');
|
|
8
10
|
|
|
9
11
|
function guessNpmCmd() {
|
|
10
12
|
// We'll execute via cmd.exe on Windows for reliability.
|
|
@@ -840,6 +842,9 @@ function buildHtml(safeDefault) {
|
|
|
840
842
|
<div class="panelBody">
|
|
841
843
|
<div class="help">Logs ficam em <code>logs/</code> e debug traces em <code>.debuglogs/</code> dentro da workspace.</div>
|
|
842
844
|
<div class="help">Use <b>Open file</b> / <b>Copy path</b> no Preview para abrir/compartilhar o relatório selecionado.</div>
|
|
845
|
+
<div class="stack" style="margin-top:10px">
|
|
846
|
+
<button class="btn" onclick="rebuildIndex()">Rebuild search index</button>
|
|
847
|
+
</div>
|
|
843
848
|
</div>
|
|
844
849
|
</div>
|
|
845
850
|
</div>
|
|
@@ -871,6 +876,7 @@ function buildHtml(safeDefault) {
|
|
|
871
876
|
<button class="btn primary" type="button" onclick="saveAndPlan()">Salvar + Processar (Agents)</button>
|
|
872
877
|
<button class="btn" type="button" onclick="runSuggestedReports()">Rodar relatórios sugeridos</button>
|
|
873
878
|
<button class="btn" type="button" onclick="exportChatObsidian()">Exportar conversa (Obsidian)</button>
|
|
879
|
+
<button class="btn" type="button" onclick="askFreya()">Perguntar à Freya</button>
|
|
874
880
|
</div>
|
|
875
881
|
|
|
876
882
|
<div class="composerToggles">
|
|
@@ -1606,6 +1612,40 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
1606
1612
|
return safeJson(res, r.code === 0 ? 200 : 400, r.code === 0 ? { ok: true, output: out } : { error: out || 'export failed', output: out });
|
|
1607
1613
|
}
|
|
1608
1614
|
|
|
1615
|
+
if (req.url === '/api/index/rebuild') {
|
|
1616
|
+
const r = await run(npmCmd, ['run', 'build-index'], workspaceDir);
|
|
1617
|
+
const out = (r.stdout + r.stderr).trim();
|
|
1618
|
+
return safeJson(res, r.code === 0 ? 200 : 400, r.code === 0 ? { ok: true, output: out } : { error: out || 'index rebuild failed', output: out });
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
if (req.url === '/api/chat/ask') {
|
|
1622
|
+
const sessionId = String(payload.sessionId || '').trim();
|
|
1623
|
+
const query = String(payload.query || '').trim();
|
|
1624
|
+
if (!query) return safeJson(res, 400, { error: 'Missing query' });
|
|
1625
|
+
|
|
1626
|
+
const indexMatches = searchIndex(workspaceDir, query, { limit: 8 });
|
|
1627
|
+
const matches = indexMatches.length
|
|
1628
|
+
? indexMatches
|
|
1629
|
+
: searchWorkspace(workspaceDir, query, { limit: 8 });
|
|
1630
|
+
if (!matches.length) {
|
|
1631
|
+
return safeJson(res, 200, { ok: true, sessionId, answer: 'Não encontrei registro', matches: [] });
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
const lines = [];
|
|
1635
|
+
lines.push(`Encontrei ${matches.length} registro(s):`);
|
|
1636
|
+
for (const m of matches) {
|
|
1637
|
+
const parts = [];
|
|
1638
|
+
if (m.date) parts.push(`**${m.date}**`);
|
|
1639
|
+
if (m.file) parts.push('`' + m.file + '`');
|
|
1640
|
+
const prefix = parts.length ? parts.join(' — ') + ':' : '';
|
|
1641
|
+
const snippet = m.snippet ? String(m.snippet).trim() : '';
|
|
1642
|
+
lines.push(`- ${prefix} ${snippet}`);
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
const answer = lines.join('\n');
|
|
1646
|
+
return safeJson(res, 200, { ok: true, sessionId, answer, matches });
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1609
1649
|
// Chat persistence (per session)
|
|
1610
1650
|
if (req.url === '/api/chat/append') {
|
|
1611
1651
|
const sessionId = String(payload.sessionId || '').trim();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cccarv82/freya",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.46",
|
|
4
4
|
"description": "Personal AI Assistant with local-first persistence",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"health": "node scripts/validate-data.js",
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
"status": "node scripts/generate-executive-report.js",
|
|
12
12
|
"blockers": "node scripts/generate-blockers-report.js",
|
|
13
13
|
"export-obsidian": "node scripts/export-obsidian.js",
|
|
14
|
-
"
|
|
14
|
+
"build-index": "node scripts/index/build-index.js",
|
|
15
|
+
"update-index": "node scripts/index/update-index.js",
|
|
16
|
+
"test": "node tests/unit/test-package-config.js && node tests/unit/test-cli-init.js && node tests/unit/test-cli-web-help.js && node tests/unit/test-web-static-assets.js && node tests/unit/test-fs-utils.js && node tests/unit/test-search-utils.js && node tests/unit/test-index-utils.js && node tests/unit/test-task-schema.js && node tests/unit/test-daily-generation.js && node tests/unit/test-report-generation.js && node tests/unit/test-oracle-retrieval.js && node tests/unit/test-task-completion.js && node tests/unit/test-migrate-data.js && node tests/unit/test-blockers-validation.js && node tests/unit/test-blockers-report.js && node tests/unit/test-sm-weekly-report.js && node tests/integration/test-ingestor-task.js"
|
|
15
17
|
},
|
|
16
18
|
"keywords": [],
|
|
17
19
|
"author": "",
|
|
@@ -22,6 +24,7 @@
|
|
|
22
24
|
"files": [
|
|
23
25
|
"bin",
|
|
24
26
|
"cli",
|
|
27
|
+
"scripts",
|
|
25
28
|
"templates",
|
|
26
29
|
".agent"
|
|
27
30
|
],
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
function exists(p) {
|
|
7
|
+
try { fs.accessSync(p); return true; } catch { return false; }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function ensureDir(p) {
|
|
11
|
+
fs.mkdirSync(p, { recursive: true });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function readJsonOrDefault(p, def) {
|
|
15
|
+
try {
|
|
16
|
+
if (!exists(p)) return def;
|
|
17
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
18
|
+
} catch {
|
|
19
|
+
return def;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function slugifyFileName(s) {
|
|
24
|
+
return String(s || '')
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.trim()
|
|
27
|
+
.replace(/[^a-z0-9\-_/ ]+/g, '')
|
|
28
|
+
.replace(/[\s]+/g, '-')
|
|
29
|
+
.replace(/-+/g, '-')
|
|
30
|
+
.replace(/^[-/]+|[-/]+$/g, '')
|
|
31
|
+
.slice(0, 80) || 'note';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function yamlEscape(v) {
|
|
35
|
+
const s = String(v == null ? '' : v);
|
|
36
|
+
// quote always for safety
|
|
37
|
+
return JSON.stringify(s);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fmtTags(tags) {
|
|
41
|
+
const uniq = Array.from(new Set(tags.filter(Boolean)));
|
|
42
|
+
return '[' + uniq.map((t) => yamlEscape(t)).join(', ') + ']';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writeNote(baseDir, relPathNoExt, md) {
|
|
46
|
+
const outPath = path.join(baseDir, relPathNoExt + '.md');
|
|
47
|
+
ensureDir(path.dirname(outPath));
|
|
48
|
+
fs.writeFileSync(outPath, md, 'utf8');
|
|
49
|
+
return outPath;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function main() {
|
|
53
|
+
const workspaceDir = path.resolve(process.cwd());
|
|
54
|
+
|
|
55
|
+
const blockersFile = path.join(workspaceDir, 'data', 'blockers', 'blocker-log.json');
|
|
56
|
+
const tasksFile = path.join(workspaceDir, 'data', 'tasks', 'task-log.json');
|
|
57
|
+
|
|
58
|
+
const blockersLog = readJsonOrDefault(blockersFile, { schemaVersion: 1, blockers: [] });
|
|
59
|
+
const tasksLog = readJsonOrDefault(tasksFile, { schemaVersion: 1, tasks: [] });
|
|
60
|
+
|
|
61
|
+
const blockers = Array.isArray(blockersLog.blockers) ? blockersLog.blockers : [];
|
|
62
|
+
const tasks = Array.isArray(tasksLog.tasks) ? tasksLog.tasks : [];
|
|
63
|
+
|
|
64
|
+
const notesRoot = path.join(workspaceDir, 'docs', 'notes');
|
|
65
|
+
ensureDir(notesRoot);
|
|
66
|
+
|
|
67
|
+
const created = [];
|
|
68
|
+
|
|
69
|
+
// Export OPEN blockers as incident notes
|
|
70
|
+
for (const b of blockers) {
|
|
71
|
+
if (!b || typeof b !== 'object') continue;
|
|
72
|
+
const status = String(b.status || '').toUpperCase();
|
|
73
|
+
if (status !== 'OPEN' && status !== 'MITIGATING') continue;
|
|
74
|
+
|
|
75
|
+
const title = String(b.title || '').trim();
|
|
76
|
+
if (!title) continue;
|
|
77
|
+
|
|
78
|
+
const projectSlug = String(b.projectSlug || '').trim();
|
|
79
|
+
const sev = String(b.severity || '').toUpperCase();
|
|
80
|
+
|
|
81
|
+
const tags = [];
|
|
82
|
+
if (projectSlug) projectSlug.split('/').forEach((p) => tags.push(p));
|
|
83
|
+
if (sev) tags.push(sev.toLowerCase());
|
|
84
|
+
tags.push('blocker');
|
|
85
|
+
|
|
86
|
+
const relBase = projectSlug ? path.join('incidents', projectSlug) : path.join('incidents', 'unclassified');
|
|
87
|
+
const fileBase = slugifyFileName(title);
|
|
88
|
+
const relPath = path.join(relBase, fileBase);
|
|
89
|
+
|
|
90
|
+
const md = [
|
|
91
|
+
'---',
|
|
92
|
+
`type: ${yamlEscape('incident')}`,
|
|
93
|
+
`id: ${yamlEscape(b.id || '')}`,
|
|
94
|
+
`title: ${yamlEscape(title)}`,
|
|
95
|
+
`status: ${yamlEscape(status)}`,
|
|
96
|
+
`severity: ${yamlEscape(sev || '')}`,
|
|
97
|
+
`projectSlug: ${yamlEscape(projectSlug)}`,
|
|
98
|
+
`createdAt: ${yamlEscape(b.createdAt || '')}`,
|
|
99
|
+
`tags: ${fmtTags(tags.map((t) => '#' + String(t).replace(/^#/, '')))}`,
|
|
100
|
+
'---',
|
|
101
|
+
'',
|
|
102
|
+
`# ${title}`,
|
|
103
|
+
'',
|
|
104
|
+
b.description ? `## Context\n${String(b.description).trim()}\n` : '',
|
|
105
|
+
b.nextAction ? `## Next action\n${String(b.nextAction).trim()}\n` : '',
|
|
106
|
+
'',
|
|
107
|
+
'## Links',
|
|
108
|
+
'- Related reports: see `docs/reports/`',
|
|
109
|
+
'- Related tasks: see `data/tasks/task-log.json`',
|
|
110
|
+
''
|
|
111
|
+
].filter(Boolean).join('\n');
|
|
112
|
+
|
|
113
|
+
const out = writeNote(notesRoot, relPath, md);
|
|
114
|
+
created.push(out);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Export a daily index note (lightweight)
|
|
118
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
119
|
+
const dailyNote = [
|
|
120
|
+
'---',
|
|
121
|
+
`type: ${yamlEscape('daily-index')}`,
|
|
122
|
+
`date: ${yamlEscape(today)}`,
|
|
123
|
+
'---',
|
|
124
|
+
'',
|
|
125
|
+
`# Daily Index ${today}`,
|
|
126
|
+
'',
|
|
127
|
+
'## Open blockers',
|
|
128
|
+
...blockers
|
|
129
|
+
.filter((b) => b && (String(b.status || '').toUpperCase() === 'OPEN' || String(b.status || '').toUpperCase() === 'MITIGATING'))
|
|
130
|
+
.slice(0, 20)
|
|
131
|
+
.map((b) => {
|
|
132
|
+
const ps = b.projectSlug ? ` [${b.projectSlug}]` : '';
|
|
133
|
+
return `- ${String(b.title || '').trim()}${ps}`;
|
|
134
|
+
}),
|
|
135
|
+
'',
|
|
136
|
+
'## DO_NOW tasks',
|
|
137
|
+
...tasks
|
|
138
|
+
.filter((t) => t && String(t.status || '').toUpperCase() === 'PENDING' && String(t.category || '') === 'DO_NOW')
|
|
139
|
+
.slice(0, 20)
|
|
140
|
+
.map((t) => {
|
|
141
|
+
const ps = t.projectSlug ? ` [${t.projectSlug}]` : '';
|
|
142
|
+
return `- [ ] ${String(t.description || '').trim()}${ps}`;
|
|
143
|
+
}),
|
|
144
|
+
''
|
|
145
|
+
].join('\n');
|
|
146
|
+
|
|
147
|
+
const dailyOut = writeNote(notesRoot, path.join('daily', today), dailyNote);
|
|
148
|
+
created.push(dailyOut);
|
|
149
|
+
|
|
150
|
+
process.stdout.write(JSON.stringify({ ok: true, created: created.map((p) => path.relative(workspaceDir, p).replace(/\\/g, '/')) }, null, 2) + '\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
main();
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const { toIsoDate, safeParseToMs, isWithinRange } = require('./lib/date-utils');
|
|
5
|
+
|
|
6
|
+
const BLOCKERS_FILE = path.join(__dirname, '../data/blockers/blocker-log.json');
|
|
7
|
+
const REPORT_DIR = path.join(__dirname, '../docs/reports');
|
|
8
|
+
|
|
9
|
+
const SEVERITY_ORDER = {
|
|
10
|
+
CRITICAL: 0,
|
|
11
|
+
HIGH: 1,
|
|
12
|
+
MEDIUM: 2,
|
|
13
|
+
LOW: 3,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const RESOLVED_STATUSES = new Set(['RESOLVED', 'CLOSED', 'DONE', 'FIXED']);
|
|
17
|
+
|
|
18
|
+
function normalizeStatus(blocker) {
|
|
19
|
+
const raw = blocker.status || blocker.state || blocker.currentStatus;
|
|
20
|
+
if (!raw) return 'UNKNOWN';
|
|
21
|
+
return String(raw).trim().toUpperCase();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeSeverity(blocker) {
|
|
25
|
+
const raw = blocker.severity || blocker.priority || blocker.level;
|
|
26
|
+
if (!raw) return 'UNSPECIFIED';
|
|
27
|
+
const value = String(raw).trim().toUpperCase();
|
|
28
|
+
if (value.includes('CRIT')) return 'CRITICAL';
|
|
29
|
+
if (value.includes('HIGH')) return 'HIGH';
|
|
30
|
+
if (value.includes('MED')) return 'MEDIUM';
|
|
31
|
+
if (value.includes('LOW')) return 'LOW';
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getCreatedAt(blocker) {
|
|
36
|
+
const candidates = [
|
|
37
|
+
blocker.createdAt,
|
|
38
|
+
blocker.created_at,
|
|
39
|
+
blocker.openedAt,
|
|
40
|
+
blocker.opened_at,
|
|
41
|
+
blocker.reportedAt,
|
|
42
|
+
blocker.reported_at,
|
|
43
|
+
blocker.date,
|
|
44
|
+
blocker.loggedAt,
|
|
45
|
+
];
|
|
46
|
+
for (const value of candidates) {
|
|
47
|
+
const ms = safeParseToMs(value);
|
|
48
|
+
if (Number.isFinite(ms)) return ms;
|
|
49
|
+
}
|
|
50
|
+
return NaN;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getResolvedAt(blocker) {
|
|
54
|
+
const candidates = [
|
|
55
|
+
blocker.resolvedAt,
|
|
56
|
+
blocker.resolved_at,
|
|
57
|
+
blocker.closedAt,
|
|
58
|
+
blocker.closed_at,
|
|
59
|
+
blocker.completedAt,
|
|
60
|
+
];
|
|
61
|
+
for (const value of candidates) {
|
|
62
|
+
const ms = safeParseToMs(value);
|
|
63
|
+
if (Number.isFinite(ms)) return ms;
|
|
64
|
+
}
|
|
65
|
+
return NaN;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isOpen(blocker) {
|
|
69
|
+
const status = normalizeStatus(blocker);
|
|
70
|
+
if (RESOLVED_STATUSES.has(status)) return false;
|
|
71
|
+
const resolvedAt = getResolvedAt(blocker);
|
|
72
|
+
return !Number.isFinite(resolvedAt);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getBlockerTitle(blocker) {
|
|
76
|
+
return (
|
|
77
|
+
blocker.title ||
|
|
78
|
+
blocker.summary ||
|
|
79
|
+
blocker.description ||
|
|
80
|
+
blocker.content ||
|
|
81
|
+
blocker.text ||
|
|
82
|
+
'Untitled blocker'
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function formatAgeDays(createdMs, nowMs) {
|
|
87
|
+
if (!Number.isFinite(createdMs)) return null;
|
|
88
|
+
const ageMs = Math.max(0, nowMs - createdMs);
|
|
89
|
+
return Math.floor(ageMs / (24 * 60 * 60 * 1000));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function ensureReportDir() {
|
|
93
|
+
if (!fs.existsSync(REPORT_DIR)) {
|
|
94
|
+
fs.mkdirSync(REPORT_DIR, { recursive: true });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function loadBlockers() {
|
|
99
|
+
if (!fs.existsSync(BLOCKERS_FILE)) {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const raw = fs.readFileSync(BLOCKERS_FILE, 'utf8');
|
|
104
|
+
const data = JSON.parse(raw);
|
|
105
|
+
return Array.isArray(data.blockers) ? data.blockers : [];
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.error(`Error reading blockers file: ${err.message}`);
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function generateReport() {
|
|
113
|
+
const now = new Date();
|
|
114
|
+
const nowMs = now.getTime();
|
|
115
|
+
const reportDate = toIsoDate(now);
|
|
116
|
+
const blockers = loadBlockers();
|
|
117
|
+
|
|
118
|
+
const statusCounts = new Map();
|
|
119
|
+
blockers.forEach(blocker => {
|
|
120
|
+
const status = normalizeStatus(blocker);
|
|
121
|
+
statusCounts.set(status, (statusCounts.get(status) || 0) + 1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const openBlockers = blockers.filter(isOpen);
|
|
125
|
+
openBlockers.sort((a, b) => {
|
|
126
|
+
const severityA = normalizeSeverity(a);
|
|
127
|
+
const severityB = normalizeSeverity(b);
|
|
128
|
+
const rankA = SEVERITY_ORDER[severityA] ?? 99;
|
|
129
|
+
const rankB = SEVERITY_ORDER[severityB] ?? 99;
|
|
130
|
+
if (rankA !== rankB) return rankA - rankB;
|
|
131
|
+
const ageA = getCreatedAt(a);
|
|
132
|
+
const ageB = getCreatedAt(b);
|
|
133
|
+
const msA = Number.isFinite(ageA) ? ageA : Number.MAX_SAFE_INTEGER;
|
|
134
|
+
const msB = Number.isFinite(ageB) ? ageB : Number.MAX_SAFE_INTEGER;
|
|
135
|
+
return msA - msB;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const sevenDaysAgo = new Date(nowMs - 7 * 24 * 60 * 60 * 1000);
|
|
139
|
+
const resolvedRecent = blockers.filter(blocker => {
|
|
140
|
+
const resolvedAt = getResolvedAt(blocker);
|
|
141
|
+
if (!Number.isFinite(resolvedAt)) return false;
|
|
142
|
+
return isWithinRange(resolvedAt, sevenDaysAgo, now);
|
|
143
|
+
});
|
|
144
|
+
resolvedRecent.sort((a, b) => {
|
|
145
|
+
const msA = getResolvedAt(a);
|
|
146
|
+
const msB = getResolvedAt(b);
|
|
147
|
+
return msB - msA;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
let report = `# Blockers Report - ${reportDate}\n\n`;
|
|
151
|
+
report += '## Summary\n';
|
|
152
|
+
report += `- Total blockers: ${blockers.length}\n`;
|
|
153
|
+
if (statusCounts.size === 0) {
|
|
154
|
+
report += '- Status counts: None\n\n';
|
|
155
|
+
} else {
|
|
156
|
+
report += '- Status counts:\n';
|
|
157
|
+
const statuses = Array.from(statusCounts.keys()).sort();
|
|
158
|
+
statuses.forEach(status => {
|
|
159
|
+
report += ` - ${status}: ${statusCounts.get(status)}\n`;
|
|
160
|
+
});
|
|
161
|
+
report += '\n';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
report += '## Open Blockers\n';
|
|
165
|
+
if (openBlockers.length === 0) {
|
|
166
|
+
report += 'None.\n\n';
|
|
167
|
+
} else {
|
|
168
|
+
openBlockers.forEach(blocker => {
|
|
169
|
+
const title = getBlockerTitle(blocker);
|
|
170
|
+
const status = normalizeStatus(blocker);
|
|
171
|
+
const severity = normalizeSeverity(blocker);
|
|
172
|
+
const createdMs = getCreatedAt(blocker);
|
|
173
|
+
const createdDate = Number.isFinite(createdMs) ? toIsoDate(createdMs) : 'Unknown';
|
|
174
|
+
const ageDays = formatAgeDays(createdMs, nowMs);
|
|
175
|
+
const project = blocker.project || blocker.projectName || blocker.projectSlug;
|
|
176
|
+
const client = blocker.client || blocker.clientName || blocker.clientSlug;
|
|
177
|
+
const metaParts = [
|
|
178
|
+
`Status: ${status}`,
|
|
179
|
+
project ? `Project: ${project}` : null,
|
|
180
|
+
client ? `Client: ${client}` : null,
|
|
181
|
+
`Created: ${createdDate}`,
|
|
182
|
+
ageDays === null ? null : `Age: ${ageDays}d`,
|
|
183
|
+
].filter(Boolean);
|
|
184
|
+
report += `- [${severity}] ${title} (${metaParts.join('; ')})\n`;
|
|
185
|
+
});
|
|
186
|
+
report += '\n';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
report += '## Resolved Blockers (Last 7 Days)\n';
|
|
190
|
+
if (resolvedRecent.length === 0) {
|
|
191
|
+
report += 'None.\n';
|
|
192
|
+
} else {
|
|
193
|
+
resolvedRecent.forEach(blocker => {
|
|
194
|
+
const title = getBlockerTitle(blocker);
|
|
195
|
+
const severity = normalizeSeverity(blocker);
|
|
196
|
+
const resolvedMs = getResolvedAt(blocker);
|
|
197
|
+
const resolvedDate = Number.isFinite(resolvedMs) ? toIsoDate(resolvedMs) : 'Unknown';
|
|
198
|
+
const project = blocker.project || blocker.projectName || blocker.projectSlug;
|
|
199
|
+
const client = blocker.client || blocker.clientName || blocker.clientSlug;
|
|
200
|
+
const metaParts = [
|
|
201
|
+
project ? `Project: ${project}` : null,
|
|
202
|
+
client ? `Client: ${client}` : null,
|
|
203
|
+
`Resolved: ${resolvedDate}`,
|
|
204
|
+
].filter(Boolean);
|
|
205
|
+
report += `- [${severity}] ${title} (${metaParts.join('; ')})\n`;
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
ensureReportDir();
|
|
210
|
+
const outputPath = path.join(REPORT_DIR, `blockers-${reportDate}.md`);
|
|
211
|
+
fs.writeFileSync(outputPath, report);
|
|
212
|
+
console.log(report);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
generateReport();
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const { safeParseToMs } = require('./lib/date-utils');
|
|
5
|
+
const { safeReadJson, quarantineCorruptedFile } = require('./lib/fs-utils');
|
|
6
|
+
|
|
7
|
+
const TASK_LOG_PATH = path.join(__dirname, '../data/tasks/task-log.json');
|
|
8
|
+
const BLOCKERS_LOG_PATH = path.join(__dirname, '../data/blockers/blocker-log.json');
|
|
9
|
+
const REPORT_DIR = path.join(__dirname, '../docs/reports');
|
|
10
|
+
|
|
11
|
+
// --- Helper Logic ---
|
|
12
|
+
const now = new Date();
|
|
13
|
+
const oneDay = 24 * 60 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
function isRecentlyCompleted(dateStr) {
|
|
16
|
+
const ms = safeParseToMs(dateStr);
|
|
17
|
+
if (!Number.isFinite(ms)) return false;
|
|
18
|
+
const diff = now.getTime() - ms;
|
|
19
|
+
// Consider "Yesterday" as anything completed in the last 24 hours for daily sync purposes
|
|
20
|
+
return diff >= 0 && diff <= oneDay;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function generateDailySummary() {
|
|
24
|
+
try {
|
|
25
|
+
if (!fs.existsSync(TASK_LOG_PATH)) {
|
|
26
|
+
console.log("**Ontem:** No task log found.\n**Hoje:** Set up task log.\n**Bloqueios:** None");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const content = fs.readFileSync(TASK_LOG_PATH, 'utf8');
|
|
31
|
+
const json = JSON.parse(content);
|
|
32
|
+
|
|
33
|
+
if (!json.tasks) {
|
|
34
|
+
console.log("**Ontem:** Invalid task log.\n**Hoje:** Fix task log.\n**Bloqueios:** None");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let summary = "";
|
|
39
|
+
|
|
40
|
+
// 1. Ontem (Completed < 24h)
|
|
41
|
+
const completedRecently = json.tasks.filter(t => t.status === "COMPLETED" && isRecentlyCompleted(t.completedAt));
|
|
42
|
+
summary += "**Ontem:** ";
|
|
43
|
+
if (completedRecently.length > 0) {
|
|
44
|
+
summary += completedRecently.map(t => t.description).join(", ");
|
|
45
|
+
} else {
|
|
46
|
+
summary += "Nothing recorded";
|
|
47
|
+
}
|
|
48
|
+
summary += "\n";
|
|
49
|
+
|
|
50
|
+
// 2. Hoje (DO_NOW + PENDING)
|
|
51
|
+
const doNow = json.tasks.filter(t => t.status === "PENDING" && t.category === "DO_NOW");
|
|
52
|
+
summary += "**Hoje:** ";
|
|
53
|
+
if (doNow.length > 0) {
|
|
54
|
+
summary += doNow.map(t => t.description).join(", ");
|
|
55
|
+
} else {
|
|
56
|
+
summary += "Nothing planned";
|
|
57
|
+
}
|
|
58
|
+
summary += "\n";
|
|
59
|
+
|
|
60
|
+
// 3. Bloqueios (from blocker-log.json)
|
|
61
|
+
let blockersLine = "None";
|
|
62
|
+
if (fs.existsSync(BLOCKERS_LOG_PATH)) {
|
|
63
|
+
const res = safeReadJson(BLOCKERS_LOG_PATH);
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
if (res.error.type === 'parse') {
|
|
66
|
+
quarantineCorruptedFile(BLOCKERS_LOG_PATH, res.error.message);
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
const blockers = (res.json.blockers || []).filter(b => {
|
|
70
|
+
const st = (b.status || '').toUpperCase();
|
|
71
|
+
return st === 'OPEN' || st === 'MITIGATING';
|
|
72
|
+
});
|
|
73
|
+
const sevOrder = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
|
|
74
|
+
blockers.sort((a, b) => {
|
|
75
|
+
const sa = sevOrder[(a.severity || '').toUpperCase()] ?? 99;
|
|
76
|
+
const sb = sevOrder[(b.severity || '').toUpperCase()] ?? 99;
|
|
77
|
+
if (sa !== sb) return sa - sb;
|
|
78
|
+
const ta = safeParseToMs(a.createdAt) || 0;
|
|
79
|
+
const tb = safeParseToMs(b.createdAt) || 0;
|
|
80
|
+
return ta - tb; // older first
|
|
81
|
+
});
|
|
82
|
+
if (blockers.length > 0) {
|
|
83
|
+
blockersLine = blockers.slice(0, 3).map(b => b.title || b.description || b.id).join(", ");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
summary += `**Bloqueios:** ${blockersLine}`;
|
|
89
|
+
|
|
90
|
+
console.log(summary);
|
|
91
|
+
|
|
92
|
+
// Write report file for UI (optional, but helps preview/history)
|
|
93
|
+
try {
|
|
94
|
+
fs.mkdirSync(REPORT_DIR, { recursive: true });
|
|
95
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
96
|
+
const outPath = path.join(REPORT_DIR, `daily-${date}.md`);
|
|
97
|
+
fs.writeFileSync(outPath, `# Daily Summary — ${date}\n\n${summary}\n`, 'utf8');
|
|
98
|
+
} catch (e) {
|
|
99
|
+
// non-fatal
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error("Error generating daily:", err.message);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
generateDailySummary();
|