@cccarv82/freya 1.0.45 → 1.0.49
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/index.js +3 -2
- package/cli/web.js +113 -20
- package/package.json +2 -1
- 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/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('fs');
|
|
3
4
|
const path = require('path');
|
|
4
5
|
|
|
5
6
|
const { cmdInit } = require('./init');
|
|
@@ -59,7 +60,7 @@ async function run(argv) {
|
|
|
59
60
|
const command = args[0];
|
|
60
61
|
|
|
61
62
|
if (!command || command === 'help' || flags.has('--help') || flags.has('-h')) {
|
|
62
|
-
process.stdout.
|
|
63
|
+
fs.writeSync(process.stdout.fd, usage());
|
|
63
64
|
return;
|
|
64
65
|
}
|
|
65
66
|
|
|
@@ -95,7 +96,7 @@ async function run(argv) {
|
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
process.stderr.write(`Unknown command: ${command}\n`);
|
|
98
|
-
process.stdout.
|
|
99
|
+
fs.writeSync(process.stdout.fd, usage());
|
|
99
100
|
process.exitCode = 1;
|
|
100
101
|
}
|
|
101
102
|
|
package/cli/web.js
CHANGED
|
@@ -608,6 +608,107 @@ function run(cmd, args, cwd) {
|
|
|
608
608
|
});
|
|
609
609
|
}
|
|
610
610
|
|
|
611
|
+
function isAllowedChatSearchPath(relPath) {
|
|
612
|
+
if (!relPath) return false;
|
|
613
|
+
if (relPath.startsWith('..')) return false;
|
|
614
|
+
return relPath.startsWith('data/') || relPath.startsWith('logs/') || relPath.startsWith('docs/');
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async function copilotSearch(workspaceDir, query, opts = {}) {
|
|
618
|
+
const q = String(query || '').trim();
|
|
619
|
+
if (!q) return { ok: false, error: 'Missing query' };
|
|
620
|
+
|
|
621
|
+
const limit = Math.max(1, Math.min(20, Number(opts.limit || 8)));
|
|
622
|
+
const cmd = process.env.COPILOT_CMD || 'copilot';
|
|
623
|
+
|
|
624
|
+
const prompt = [
|
|
625
|
+
'Você é um buscador local de arquivos.',
|
|
626
|
+
'Objetivo: encontrar registros relevantes para a consulta do usuário.',
|
|
627
|
+
'Escopo: procure SOMENTE nos diretórios data/, logs/ e docs/ do workspace.',
|
|
628
|
+
'Use ferramentas para ler/consultar arquivos, mas não modifique nada.',
|
|
629
|
+
`Consulta do usuário: "${q}"`,
|
|
630
|
+
'',
|
|
631
|
+
'Responda APENAS com JSON válido (sem code fences) no formato:',
|
|
632
|
+
'{"summary":"<1-2 frases humanas>","matches":[{"file":"<caminho relativo>","date":"YYYY-MM-DD ou vazio","snippet":"<trecho curto>"}]}',
|
|
633
|
+
`Limite de matches: ${limit}.`,
|
|
634
|
+
'O resumo deve soar humano e mencionar a quantidade de registros encontrados.',
|
|
635
|
+
'A lista deve estar ordenada por relevância.'
|
|
636
|
+
].join('\n');
|
|
637
|
+
|
|
638
|
+
const args = [
|
|
639
|
+
'-s',
|
|
640
|
+
'--no-color',
|
|
641
|
+
'--stream',
|
|
642
|
+
'off',
|
|
643
|
+
'-p',
|
|
644
|
+
prompt,
|
|
645
|
+
'--allow-all-tools',
|
|
646
|
+
'--add-dir',
|
|
647
|
+
workspaceDir
|
|
648
|
+
];
|
|
649
|
+
|
|
650
|
+
const r = await run(cmd, args, workspaceDir);
|
|
651
|
+
const out = (r.stdout + r.stderr).trim();
|
|
652
|
+
if (r.code !== 0) return { ok: false, error: out || 'Copilot returned non-zero exit code.' };
|
|
653
|
+
|
|
654
|
+
const jsonText = extractFirstJsonObject(out) || out;
|
|
655
|
+
let parsed;
|
|
656
|
+
try {
|
|
657
|
+
parsed = JSON.parse(jsonText);
|
|
658
|
+
} catch {
|
|
659
|
+
try {
|
|
660
|
+
parsed = JSON.parse(escapeJsonControlChars(jsonText));
|
|
661
|
+
} catch (e) {
|
|
662
|
+
return { ok: false, error: e.message || 'Copilot output not valid JSON.' };
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const matchesRaw = Array.isArray(parsed.matches) ? parsed.matches : [];
|
|
667
|
+
const matches = matchesRaw
|
|
668
|
+
.map((m) => {
|
|
669
|
+
const fileRaw = String(m && m.file ? m.file : '').trim();
|
|
670
|
+
const dateRaw = String(m && m.date ? m.date : '').trim();
|
|
671
|
+
const snippetRaw = String(m && m.snippet ? m.snippet : '').trim();
|
|
672
|
+
let rel = fileRaw;
|
|
673
|
+
if (fileRaw.startsWith(workspaceDir)) {
|
|
674
|
+
rel = path.relative(workspaceDir, fileRaw).replace(/\\/g, '/');
|
|
675
|
+
}
|
|
676
|
+
return { file: rel.replace(/\\/g, '/'), date: dateRaw, snippet: snippetRaw };
|
|
677
|
+
})
|
|
678
|
+
.filter((m) => m.file && isAllowedChatSearchPath(m.file))
|
|
679
|
+
.slice(0, limit);
|
|
680
|
+
|
|
681
|
+
const summary = String(parsed.summary || '').trim();
|
|
682
|
+
return { ok: true, summary, matches };
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function buildChatAnswer(query, matches, summary) {
|
|
686
|
+
const count = matches.length;
|
|
687
|
+
let summaryText = String(summary || '').trim();
|
|
688
|
+
if (!summaryText) {
|
|
689
|
+
if (count === 0) {
|
|
690
|
+
summaryText = `Não encontrei registros relacionados a "${query}".`;
|
|
691
|
+
} else {
|
|
692
|
+
summaryText = `Encontrei ${count} registro(s) relacionados a "${query}".`;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const lines = [];
|
|
697
|
+
lines.push(`Encontrei ${count} registro(s).`);
|
|
698
|
+
lines.push(`Resumo (${count} registro(s)): ${summaryText}`);
|
|
699
|
+
|
|
700
|
+
for (const m of matches) {
|
|
701
|
+
const parts = [];
|
|
702
|
+
if (m.date) parts.push(`**${m.date}**`);
|
|
703
|
+
if (m.file) parts.push('`' + m.file + '`');
|
|
704
|
+
const prefix = parts.length ? parts.join(' — ') + ':' : '';
|
|
705
|
+
const snippet = m.snippet ? String(m.snippet).trim() : '';
|
|
706
|
+
lines.push(`- ${prefix} ${snippet}`);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return lines.join('\n');
|
|
710
|
+
}
|
|
711
|
+
|
|
611
712
|
function openBrowser(url) {
|
|
612
713
|
const { cmd, args } = guessOpenCmd();
|
|
613
714
|
try {
|
|
@@ -1623,26 +1724,18 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
1623
1724
|
const query = String(payload.query || '').trim();
|
|
1624
1725
|
if (!query) return safeJson(res, 400, { error: 'Missing query' });
|
|
1625
1726
|
|
|
1727
|
+
const copilotResult = await copilotSearch(workspaceDir, query, { limit: 8 });
|
|
1728
|
+
if (copilotResult.ok) {
|
|
1729
|
+
const matches = copilotResult.matches || [];
|
|
1730
|
+
const answer = buildChatAnswer(query, matches, copilotResult.summary);
|
|
1731
|
+
return safeJson(res, 200, { ok: true, sessionId, answer, matches });
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1626
1734
|
const indexMatches = searchIndex(workspaceDir, query, { limit: 8 });
|
|
1627
1735
|
const matches = indexMatches.length
|
|
1628
1736
|
? indexMatches
|
|
1629
1737
|
: searchWorkspace(workspaceDir, query, { limit: 8 });
|
|
1630
|
-
|
|
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');
|
|
1738
|
+
const answer = buildChatAnswer(query, matches, '');
|
|
1646
1739
|
return safeJson(res, 200, { ok: true, sessionId, answer, matches });
|
|
1647
1740
|
}
|
|
1648
1741
|
|
|
@@ -1948,17 +2041,17 @@ async function cmdWeb({ port, dir, open, dev }) {
|
|
|
1948
2041
|
const targetOk = looksLikeFreyaWorkspace(target);
|
|
1949
2042
|
const empty = looksEmptyWorkspace(target);
|
|
1950
2043
|
if (!targetOk && !empty) {
|
|
1951
|
-
process.stdout.
|
|
2044
|
+
fs.writeSync(process.stdout.fd, `Dev seed: skipped (workspace not empty and not initialized) -> ${target}\n`);
|
|
1952
2045
|
} else {
|
|
1953
2046
|
seedDevWorkspace(target);
|
|
1954
|
-
process.stdout.
|
|
2047
|
+
fs.writeSync(process.stdout.fd, `Dev seed: created demo files in ${target}\n`);
|
|
1955
2048
|
}
|
|
1956
2049
|
} catch (e) {
|
|
1957
|
-
process.stdout.
|
|
2050
|
+
fs.writeSync(process.stdout.fd, `Dev seed failed: ${e.message || String(e)}\n`);
|
|
1958
2051
|
}
|
|
1959
2052
|
}
|
|
1960
2053
|
|
|
1961
|
-
process.stdout.
|
|
2054
|
+
fs.writeSync(process.stdout.fd, `FREYA web running at ${url}\n`);
|
|
1962
2055
|
if (open) openBrowser(url);
|
|
1963
2056
|
}
|
|
1964
2057
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cccarv82/freya",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.49",
|
|
4
4
|
"description": "Personal AI Assistant with local-first persistence",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"health": "node scripts/validate-data.js",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"files": [
|
|
25
25
|
"bin",
|
|
26
26
|
"cli",
|
|
27
|
+
"scripts",
|
|
27
28
|
"templates",
|
|
28
29
|
".agent"
|
|
29
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();
|