oxe-cc 0.3.2 → 0.3.4

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.
@@ -0,0 +1,412 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ /** @type {string[]} */
7
+ const ALLOWED_CONFIG_KEYS = [
8
+ 'discuss_before_plan',
9
+ 'after_verify_suggest_pr',
10
+ 'after_verify_draft_commit',
11
+ 'default_verify_command',
12
+ 'scan_max_age_days',
13
+ 'scan_focus_globs',
14
+ 'scan_ignore_globs',
15
+ 'spec_required_sections',
16
+ 'plan_max_tasks_per_wave',
17
+ ];
18
+
19
+ const EXPECTED_CODEBASE_MAPS = [
20
+ 'OVERVIEW.md',
21
+ 'STACK.md',
22
+ 'STRUCTURE.md',
23
+ 'TESTING.md',
24
+ 'INTEGRATIONS.md',
25
+ 'CONVENTIONS.md',
26
+ 'CONCERNS.md',
27
+ ];
28
+
29
+ /**
30
+ * @param {string} targetProject
31
+ */
32
+ function loadOxeConfigMerged(targetProject) {
33
+ const defaults = {
34
+ discuss_before_plan: false,
35
+ after_verify_suggest_pr: true,
36
+ after_verify_draft_commit: true,
37
+ default_verify_command: '',
38
+ scan_max_age_days: 0,
39
+ scan_focus_globs: [],
40
+ scan_ignore_globs: [],
41
+ spec_required_sections: [],
42
+ plan_max_tasks_per_wave: 0,
43
+ };
44
+ const p = path.join(targetProject, '.oxe', 'config.json');
45
+ if (!fs.existsSync(p)) return { config: defaults, path: null, parseError: null };
46
+ try {
47
+ const raw = fs.readFileSync(p, 'utf8');
48
+ const j = JSON.parse(raw);
49
+ if (!j || typeof j !== 'object' || Array.isArray(j)) return { config: defaults, path: p, parseError: 'não é um objeto' };
50
+ return { config: { ...defaults, ...j }, path: p, parseError: null };
51
+ } catch (e) {
52
+ return { config: defaults, path: p, parseError: e.message };
53
+ }
54
+ }
55
+
56
+ /**
57
+ * @param {Record<string, unknown>} cfg
58
+ * @returns {{ unknownKeys: string[], typeErrors: string[] }}
59
+ */
60
+ function validateConfigShape(cfg) {
61
+ const unknownKeys = Object.keys(cfg).filter((k) => !ALLOWED_CONFIG_KEYS.includes(k));
62
+ const typeErrors = [];
63
+ if (cfg.scan_max_age_days != null && typeof cfg.scan_max_age_days !== 'number') {
64
+ typeErrors.push('scan_max_age_days deve ser número (use 0 para desligar aviso de scan antigo)');
65
+ }
66
+ if (cfg.plan_max_tasks_per_wave != null && typeof cfg.plan_max_tasks_per_wave !== 'number') {
67
+ typeErrors.push('plan_max_tasks_per_wave deve ser número (use 0 para desligar)');
68
+ }
69
+ if (cfg.scan_focus_globs != null && !Array.isArray(cfg.scan_focus_globs)) {
70
+ typeErrors.push('scan_focus_globs deve ser array de strings');
71
+ }
72
+ if (cfg.scan_ignore_globs != null && !Array.isArray(cfg.scan_ignore_globs)) {
73
+ typeErrors.push('scan_ignore_globs deve ser array de strings');
74
+ }
75
+ if (cfg.spec_required_sections != null && !Array.isArray(cfg.spec_required_sections)) {
76
+ typeErrors.push('spec_required_sections deve ser array de strings (cabeçalhos ## …)');
77
+ }
78
+ return { unknownKeys, typeErrors };
79
+ }
80
+
81
+ /**
82
+ * @param {string} stateText
83
+ * @returns {string | null}
84
+ */
85
+ function parseStatePhase(stateText) {
86
+ const m = stateText.match(/##\s*Fase atual\s*\n+\s*`([^`]+)`/im);
87
+ return m ? m[1].trim().split(/\s/)[0] : null;
88
+ }
89
+
90
+ /**
91
+ * @param {string} stateText
92
+ * @returns {Date | null}
93
+ */
94
+ function parseLastScanDate(stateText) {
95
+ const sec = stateText.match(/##\s*Último scan\s*([\s\S]*?)(?=\n## |\n#[^\#]|$)/im);
96
+ if (!sec) return null;
97
+ const dm = sec[1].match(/\*\*Data:\*\*\s*(.+)/i);
98
+ if (!dm) return null;
99
+ let raw = dm[1].trim();
100
+ if (/^\([^)]*\)$/.test(raw) || /placeholder|legível|ISO/i.test(raw)) return null;
101
+ if (raw.startsWith('(')) return null;
102
+ const iso = Date.parse(raw);
103
+ if (!Number.isNaN(iso)) return new Date(iso);
104
+ const br = raw.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})/);
105
+ if (br) {
106
+ const d = new Date(parseInt(br[3], 10), parseInt(br[2], 10) - 1, parseInt(br[1], 10));
107
+ return Number.isNaN(d.getTime()) ? null : d;
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /**
113
+ * @param {Date | null} scanDate
114
+ * @param {number} maxAgeDays 0 = desligado
115
+ */
116
+ function isStaleScan(scanDate, maxAgeDays) {
117
+ if (!scanDate || !maxAgeDays || maxAgeDays <= 0) return { stale: false, days: null };
118
+ const days = (Date.now() - scanDate.getTime()) / 86400000;
119
+ return { stale: days > maxAgeDays, days: Math.floor(days) };
120
+ }
121
+
122
+ /**
123
+ * @param {string} target
124
+ */
125
+ function oxePaths(target) {
126
+ const oxe = path.join(target, '.oxe');
127
+ return {
128
+ oxe,
129
+ state: path.join(oxe, 'STATE.md'),
130
+ spec: path.join(oxe, 'SPEC.md'),
131
+ plan: path.join(oxe, 'PLAN.md'),
132
+ quick: path.join(oxe, 'QUICK.md'),
133
+ verify: path.join(oxe, 'VERIFY.md'),
134
+ discuss: path.join(oxe, 'DISCUSS.md'),
135
+ summary: path.join(oxe, 'SUMMARY.md'),
136
+ codebase: path.join(oxe, 'codebase'),
137
+ };
138
+ }
139
+
140
+ /**
141
+ * @param {string} phase
142
+ * @param {ReturnType<typeof oxePaths>} p
143
+ */
144
+ function phaseCoherenceWarnings(phase, p) {
145
+ /** @type {string[]} */
146
+ const w = [];
147
+ if (!phase) return w;
148
+ const has = (/** @type {string} */ f) => fs.existsSync(f);
149
+ const mapsComplete = EXPECTED_CODEBASE_MAPS.every((f) => has(path.join(p.codebase, f)));
150
+
151
+ if (phase === 'scan_complete' && !mapsComplete) {
152
+ w.push(`Fase \`${phase}\` no STATE, mas faltam mapas em .oxe/codebase/ — rode /oxe-scan`);
153
+ }
154
+ if ((phase === 'spec_ready' || phase === 'discuss_complete' || phase === 'plan_ready') && !has(p.spec)) {
155
+ w.push(`Fase \`${phase}\` no STATE, mas .oxe/SPEC.md não existe`);
156
+ }
157
+ if (phase === 'discuss_complete' && !has(p.discuss)) {
158
+ w.push(`Fase \`${phase}\` no STATE, mas .oxe/DISCUSS.md não existe`);
159
+ }
160
+ if (phase === 'plan_ready' && !has(p.plan) && !has(p.quick)) {
161
+ w.push(`Fase \`${phase}\` no STATE, mas não há .oxe/PLAN.md nem QUICK.md`);
162
+ }
163
+ if (phase === 'quick_active' && !has(p.quick)) {
164
+ w.push(`Fase \`${phase}\` no STATE, mas .oxe/QUICK.md não existe`);
165
+ }
166
+ if ((phase === 'executing' || phase === 'verify_complete' || phase === 'verify_failed') && !has(p.plan) && !has(p.quick)) {
167
+ w.push(`Fase \`${phase}\` no STATE, mas não há PLAN.md nem QUICK.md`);
168
+ }
169
+ if ((phase === 'verify_complete' || phase === 'verify_failed') && !has(p.verify)) {
170
+ w.push(`Fase \`${phase}\` no STATE, mas .oxe/VERIFY.md não existe`);
171
+ }
172
+ return w;
173
+ }
174
+
175
+ /**
176
+ * @param {string} verifyPath
177
+ * @param {string} summaryPath
178
+ */
179
+ function verifyGapsWithoutSummaryWarning(verifyPath, summaryPath) {
180
+ if (!fs.existsSync(verifyPath)) return null;
181
+ const t = fs.readFileSync(verifyPath, 'utf8');
182
+ const m = t.match(/##+\s*Gaps\s*\n([\s\S]*?)(?=\n##+\s|\n#[^\#]|$)/im);
183
+ if (!m) return null;
184
+ const body = m[1].trim();
185
+ if (body.length < 12) return null;
186
+ if (fs.existsSync(summaryPath)) return null;
187
+ return 'VERIFY.md tem seção Gaps com conteúdo, mas .oxe/SUMMARY.md não existe — crie a partir de oxe/templates/SUMMARY.template.md para replanejamento com contexto';
188
+ }
189
+
190
+ /**
191
+ * @param {string} specPath
192
+ * @param {string[]} requiredHeadings lines like "## Critérios de aceite" or "Critérios de aceite"
193
+ */
194
+ function specSectionWarnings(specPath, requiredHeadings) {
195
+ if (!requiredHeadings.length || !fs.existsSync(specPath)) return [];
196
+ const text = fs.readFileSync(specPath, 'utf8');
197
+ /** @type {string[]} */
198
+ const out = [];
199
+ for (const h of requiredHeadings) {
200
+ const needle = h.trim().startsWith('##') ? h.trim() : `## ${h.trim()}`;
201
+ if (!text.includes(needle)) {
202
+ out.push(`SPEC.md deve conter a seção "${needle}" (config spec_required_sections)`);
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+
208
+ function planWaveWarningsFixed(planPath, maxPerWave) {
209
+ if (!maxPerWave || maxPerWave <= 0 || !fs.existsSync(planPath)) return [];
210
+ const raw = fs.readFileSync(planPath, 'utf8');
211
+ const lines = raw.split('\n');
212
+ /** @type {Record<string, number>} */
213
+ const perWave = {};
214
+ let currentWave = '0';
215
+ for (const line of lines) {
216
+ const wm = line.match(/\*\*Onda:\*\*\s*(\d+)/i);
217
+ if (wm) currentWave = wm[1];
218
+ if (/^###\s+T\d+/i.test(line.trim())) {
219
+ perWave[currentWave] = (perWave[currentWave] || 0) + 1;
220
+ }
221
+ }
222
+ /** @type {string[]} */
223
+ const w = [];
224
+ for (const [wN, count] of Object.entries(perWave)) {
225
+ if (count > maxPerWave) {
226
+ w.push(`PLAN.md: onda ${wN} tem ${count} tarefas (máximo configurado: ${maxPerWave} — considere dividir ondas)`);
227
+ }
228
+ }
229
+ return w;
230
+ }
231
+
232
+ /**
233
+ * Próximo passo único (espelha o workflow next.md).
234
+ * @param {string} target
235
+ * @param {{ discuss_before_plan?: boolean }} cfg
236
+ */
237
+ function suggestNextStep(target, cfg = {}) {
238
+ const p = oxePaths(target);
239
+ const discussBefore = Boolean(cfg.discuss_before_plan);
240
+ const has = (/** @type {string} */ f) => fs.existsSync(f);
241
+ const mapsComplete = EXPECTED_CODEBASE_MAPS.every((f) => has(path.join(p.codebase, f)));
242
+
243
+ if (!has(p.oxe) || !has(p.state)) {
244
+ return {
245
+ step: 'scan',
246
+ cursorCmd: '/oxe-scan',
247
+ reason: 'Pasta .oxe/ ou STATE.md ausente — inicialize com oxe-cc init-oxe e rode o primeiro scan',
248
+ artifacts: ['.oxe/'],
249
+ };
250
+ }
251
+
252
+ const stateText = fs.readFileSync(p.state, 'utf8');
253
+ const phase = parseStatePhase(stateText);
254
+
255
+ if (!mapsComplete && !has(p.quick)) {
256
+ return {
257
+ step: 'scan',
258
+ cursorCmd: '/oxe-scan',
259
+ reason: 'Mapas do codebase incompletos e sem QUICK.md — atualize o contexto com scan',
260
+ artifacts: ['.oxe/codebase/'],
261
+ };
262
+ }
263
+
264
+ if (phase === 'quick_active' || (has(p.quick) && !has(p.plan))) {
265
+ return {
266
+ step: 'execute',
267
+ cursorCmd: '/oxe-execute',
268
+ reason: 'Modo QUICK ativo ou PLAN ausente — execute passos do QUICK.md, depois /oxe-verify; se o trabalho cresceu, use /oxe-spec',
269
+ artifacts: ['.oxe/QUICK.md', '.oxe/STATE.md'],
270
+ };
271
+ }
272
+
273
+ if (!has(p.spec)) {
274
+ return {
275
+ step: 'spec',
276
+ cursorCmd: '/oxe-spec',
277
+ reason: 'Sem SPEC.md — defina o contrato antes do plano (ou /oxe-quick para trabalho pontual)',
278
+ artifacts: ['.oxe/SPEC.md'],
279
+ };
280
+ }
281
+
282
+ if (discussBefore && !has(p.discuss)) {
283
+ return {
284
+ step: 'discuss',
285
+ cursorCmd: '/oxe-discuss',
286
+ reason: 'discuss_before_plan está ativo e DISCUSS.md não existe — alinhe decisões antes do plano',
287
+ artifacts: ['.oxe/DISCUSS.md'],
288
+ };
289
+ }
290
+
291
+ if (!has(p.plan)) {
292
+ return {
293
+ step: 'plan',
294
+ cursorCmd: '/oxe-plan',
295
+ reason: 'SPEC existe mas PLAN.md não — gere o plano com verificação por tarefa',
296
+ artifacts: ['.oxe/PLAN.md'],
297
+ };
298
+ }
299
+
300
+ if (!has(p.verify)) {
301
+ return {
302
+ step: 'execute',
303
+ cursorCmd: '/oxe-execute',
304
+ reason: 'PLAN.md existe e VERIFY.md ainda não — execute a onda atual no agente; depois rode /oxe-verify',
305
+ artifacts: ['.oxe/PLAN.md', '.oxe/STATE.md'],
306
+ };
307
+ }
308
+
309
+ const verifyText = fs.readFileSync(p.verify, 'utf8');
310
+ const phaseLow = (phase || '').toLowerCase();
311
+ if (phaseLow === 'verify_failed' || /\bverify_failed\b/i.test(stateText)) {
312
+ return {
313
+ step: 'plan',
314
+ cursorCmd: '/oxe-plan',
315
+ reason: 'STATE indica verify_failed — leia VERIFY.md e SUMMARY.md, corrija ou replaneje (--replan)',
316
+ artifacts: ['.oxe/VERIFY.md', '.oxe/PLAN.md'],
317
+ };
318
+ }
319
+
320
+ const gapM = verifyText.match(/##+\s*Gaps\s*\n([\s\S]*?)(?=\n##+\s[^#]|$)/im);
321
+ if (gapM) {
322
+ const gb = gapM[1].trim();
323
+ const low = gb.toLowerCase();
324
+ const negligible =
325
+ gb.length < 12 ||
326
+ /^(nenhum|none|n\/a)\b|^-\s*nenhum|^sem gaps\b|^não há gaps\b|^não ha gaps\b/m.test(low);
327
+ if (!negligible) {
328
+ return {
329
+ step: 'plan',
330
+ cursorCmd: '/oxe-plan',
331
+ reason: 'VERIFY.md lista gaps com conteúdo — trate ou replaneje; atualize SUMMARY.md se aplicável',
332
+ artifacts: ['.oxe/VERIFY.md'],
333
+ };
334
+ }
335
+ }
336
+
337
+ if (/\b(falhou|fail)\b/i.test(verifyText) && /\|\s*(não|no|false)\s*\|/i.test(verifyText)) {
338
+ return {
339
+ step: 'plan',
340
+ cursorCmd: '/oxe-plan',
341
+ reason: 'VERIFY.md indica verificações não aprovadas — corrija ou replaneje',
342
+ artifacts: ['.oxe/VERIFY.md'],
343
+ };
344
+ }
345
+
346
+ return {
347
+ step: 'next',
348
+ cursorCmd: '/oxe-next',
349
+ reason: 'Artefatos coerentes — /oxe-next confirma o passo único; use /oxe-spec ou /oxe-quick para nova entrega',
350
+ artifacts: ['.oxe/STATE.md', '.oxe/VERIFY.md'],
351
+ };
352
+ }
353
+
354
+ /**
355
+ * @param {string} target
356
+ */
357
+ function buildHealthReport(target) {
358
+ const { config, path: cfgPath, parseError } = loadOxeConfigMerged(target);
359
+ const shape = validateConfigShape(config);
360
+ const p = oxePaths(target);
361
+ let stateText = '';
362
+ if (fs.existsSync(p.state)) {
363
+ try {
364
+ stateText = fs.readFileSync(p.state, 'utf8');
365
+ } catch {
366
+ stateText = '';
367
+ }
368
+ }
369
+ const phase = parseStatePhase(stateText);
370
+ const scanDate = parseLastScanDate(stateText);
371
+ const stale = isStaleScan(scanDate, Number(config.scan_max_age_days) || 0);
372
+ const phaseWarn = phase ? phaseCoherenceWarnings(phase, p) : [];
373
+ const sumWarn = verifyGapsWithoutSummaryWarning(p.verify, p.summary);
374
+ const specReq = Array.isArray(config.spec_required_sections) ? config.spec_required_sections : [];
375
+ const specWarn = specSectionWarnings(p.spec, specReq.map(String));
376
+ const planWarn = planWaveWarningsFixed(p.plan, Number(config.plan_max_tasks_per_wave) || 0);
377
+ const next = suggestNextStep(target, { discuss_before_plan: config.discuss_before_plan });
378
+
379
+ return {
380
+ configPath: cfgPath,
381
+ configParseError: parseError,
382
+ unknownConfigKeys: shape.unknownKeys,
383
+ typeErrors: shape.typeErrors,
384
+ phase,
385
+ scanDate,
386
+ stale,
387
+ phaseWarn,
388
+ summaryGapWarn: sumWarn,
389
+ specWarn,
390
+ planWarn,
391
+ next,
392
+ scanFocusGlobs: config.scan_focus_globs,
393
+ scanIgnoreGlobs: config.scan_ignore_globs,
394
+ };
395
+ }
396
+
397
+ module.exports = {
398
+ ALLOWED_CONFIG_KEYS,
399
+ EXPECTED_CODEBASE_MAPS,
400
+ loadOxeConfigMerged,
401
+ validateConfigShape,
402
+ parseStatePhase,
403
+ parseLastScanDate,
404
+ isStaleScan,
405
+ phaseCoherenceWarnings,
406
+ verifyGapsWithoutSummaryWarning,
407
+ specSectionWarnings,
408
+ planWaveWarningsFixed,
409
+ suggestNextStep,
410
+ buildHealthReport,
411
+ oxePaths,
412
+ };