@jaimevalasek/aioson 1.22.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,624 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Leitores de fonte para `aioson harness:retro` (RHO-lite, requirements §3.2/§5.1).
5
+ *
6
+ * Um leitor por fonte; cada um é best-effort independente (padrão de
7
+ * `attempt-artifacts.js`): nunca propaga exceção, sempre devolve
8
+ * `{ findings, warnings, count, ... }`. Fonte ausente, vazia, ilegível ou DB
9
+ * lockado vira linha de aviso na "Trilha minerada", nunca erro fatal (REQ-3).
10
+ *
11
+ * Mineração 100% determinística (REQ-1): nenhuma chamada LLM, nenhuma
12
+ * classificação semântica — só regex e chaves exatas. Leitura-apenas: este
13
+ * módulo NUNCA escreve no filesystem.
14
+ */
15
+
16
+ const fs = require('node:fs');
17
+ const path = require('node:path');
18
+ const crypto = require('node:crypto');
19
+
20
+ const FINDING_ID_RE = /\b([A-Z]{1,2}-\d{1,2})\b/;
21
+ const TRAIL_ENTRY_RE = /^\*\*([^*]+)\*\*\s*\|\s*@?([\w.-]+)\s*\|\s*_([^_]+)_\s*$/;
22
+ const VERDICT_RE = /\b(?:verdict|veredicto)\b[^\n]*?\b(PASS|FAIL)\b/i;
23
+ const VERDICT_FALLBACK_RE = /\b(PASS|FAIL)\b/i;
24
+ const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
25
+
26
+ const SEVERITY_ALIASES = {
27
+ critical: 'critical',
28
+ crit: 'critical',
29
+ high: 'high',
30
+ med: 'medium',
31
+ medium: 'medium',
32
+ low: 'low',
33
+ info: 'info',
34
+ informational: 'info'
35
+ };
36
+
37
+ /** Normaliza severidade (case-insensitive); desconhecida → `unknown` (nunca promove). */
38
+ function normalizeSeverity(raw) {
39
+ if (raw === null || raw === undefined) return 'unknown';
40
+ const key = String(raw).trim().toLowerCase();
41
+ return SEVERITY_ALIASES[key] || 'unknown';
42
+ }
43
+
44
+ /** Path relativo com separador POSIX (determinismo cross-OS — AC-4, EC Windows). */
45
+ function relPath(rootDir, p) {
46
+ return path.relative(rootDir, p).replaceAll('\\', '/');
47
+ }
48
+
49
+ function readTextSafe(p) {
50
+ try {
51
+ return fs.readFileSync(p, 'utf8');
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ function listFilesSafe(dir) {
58
+ try {
59
+ return fs.readdirSync(dir, { withFileTypes: true });
60
+ } catch {
61
+ return [];
62
+ }
63
+ }
64
+
65
+ function statSizeSafe(p) {
66
+ try {
67
+ return fs.statSync(p).size;
68
+ } catch {
69
+ return 0;
70
+ }
71
+ }
72
+
73
+ /** Parser de frontmatter YAML simples (`key: value`, valor escalar de 1 linha). */
74
+ function parseFrontmatter(text) {
75
+ if (typeof text !== 'string' || !text.startsWith('---')) return { data: {}, body: text || '' };
76
+ const end = text.indexOf('\n---', 3);
77
+ if (end === -1) return { data: {}, body: text };
78
+ const block = text.slice(3, end).replace(/^\r?\n/, '');
79
+ const body = text.slice(end + 4);
80
+ const data = {};
81
+ for (const line of block.split(/\r?\n/)) {
82
+ const m = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/);
83
+ if (!m) continue;
84
+ // Remove comentário inline (ex.: `status: resolved # open | ...`).
85
+ let value = m[2].replace(/\s+#.*$/, '').trim();
86
+ value = value.replace(/^["']|["']$/g, '');
87
+ data[m[1]] = value;
88
+ }
89
+ return { data, body };
90
+ }
91
+
92
+ function isoDate(raw) {
93
+ if (!raw) return null;
94
+ const m = String(raw).match(/\d{4}-\d{2}-\d{2}(?:T[\d:.+Z-]+)?/);
95
+ return m ? m[0] : null;
96
+ }
97
+
98
+ function makeFinding(partial) {
99
+ return {
100
+ source_type: partial.source_type,
101
+ feature_slug: partial.feature_slug,
102
+ finding_id: partial.finding_id || null,
103
+ severity: normalizeSeverity(partial.severity),
104
+ title: (partial.title ? String(partial.title) : '').slice(0, 200),
105
+ file_ref: partial.file_ref || null,
106
+ date: partial.date || null,
107
+ status: partial.status || 'unknown',
108
+ source_path: partial.source_path,
109
+ signature: partial.signature || null
110
+ };
111
+ }
112
+
113
+ // --- 1. QA reports ----------------------------------------------------------
114
+
115
+ const KNOWN_SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'info']);
116
+
117
+ /** Extrai findings estruturados de um QA report (tabelas + headers de finding). */
118
+ function extractQaFindings({ body, slug, sourcePath, date, status }) {
119
+ const found = new Map(); // finding_id → finding (primeiro vence)
120
+ for (const rawLine of body.split(/\r?\n/)) {
121
+ const line = rawLine.trim();
122
+ // Linha de tabela: | ID | Sev | ... |
123
+ if (line.startsWith('|')) {
124
+ const cells = line.split('|').map((c) => c.trim()).filter((c, i, arr) => i > 0 && i < arr.length);
125
+ if (cells.length >= 2) {
126
+ const idMatch = cells[0].match(FINDING_ID_RE);
127
+ if (idMatch) {
128
+ const sevCell = cells.find((c) => KNOWN_SEVERITIES.has(c.toLowerCase()));
129
+ if (sevCell) {
130
+ const id = idMatch[1];
131
+ if (!found.has(id)) {
132
+ found.set(id, makeFinding({
133
+ source_type: 'qa_report', feature_slug: slug, finding_id: id,
134
+ severity: sevCell, title: cells[2] || cells[1] || id,
135
+ date, status, source_path: sourcePath
136
+ }));
137
+ }
138
+ }
139
+ }
140
+ }
141
+ continue;
142
+ }
143
+ // Header de finding: ### C-01 — Title (High)
144
+ const head = rawLine.match(/^#{2,5}\s+([A-Z]{1,2}-\d{1,2})\b\s*[—:-]?\s*(.*)$/);
145
+ if (head) {
146
+ const id = head[1];
147
+ if (!found.has(id)) {
148
+ const sevMatch = head[2].match(/\(([A-Za-z]+)/);
149
+ found.set(id, makeFinding({
150
+ source_type: 'qa_report', feature_slug: slug, finding_id: id,
151
+ severity: sevMatch ? sevMatch[1] : 'unknown',
152
+ title: head[2].replace(/\(.*$/, '').trim() || id,
153
+ date, status, source_path: sourcePath
154
+ }));
155
+ }
156
+ }
157
+ }
158
+ return [...found.values()];
159
+ }
160
+
161
+ function readQaReports({ rootDir, ctxDir, slug, locations }) {
162
+ const findings = [];
163
+ const warnings = [];
164
+ let count = 0;
165
+ for (const dir of locations.qaDirs) {
166
+ for (const ent of listFilesSafe(dir)) {
167
+ if (!ent.isFile()) continue;
168
+ if (!/^qa-report-/.test(ent.name) || !ent.name.endsWith('.md')) continue;
169
+ if (!ent.name.includes(slug)) continue;
170
+ const full = path.join(dir, ent.name);
171
+ const text = readTextSafe(full);
172
+ if (text === null) {
173
+ warnings.push(`qa_report ilegível: ${relPath(rootDir, full)}`);
174
+ continue;
175
+ }
176
+ count += 1;
177
+ const { data, body } = parseFrontmatter(text);
178
+ const date = isoDate(data.created_at || data.updated_at || data.date);
179
+ const status = (data.verdict || '').toUpperCase() === 'FAIL' ? 'open' : 'fixed';
180
+ findings.push(...extractQaFindings({ body, slug, sourcePath: relPath(rootDir, full), date, status }));
181
+ }
182
+ }
183
+ return { findings, warnings, count };
184
+ }
185
+
186
+ // --- 2. Corrections plans ---------------------------------------------------
187
+
188
+ function mapCorrectionStatus(raw) {
189
+ const v = String(raw || '').toLowerCase();
190
+ if (v === 'resolved') return 'fixed';
191
+ if (v === 'open' || v === 'in_progress') return 'open';
192
+ return 'unknown';
193
+ }
194
+
195
+ function readCorrections({ rootDir, slug, locations }) {
196
+ const findings = [];
197
+ const warnings = [];
198
+ let count = 0;
199
+ let bytes = 0;
200
+ let entries = 0;
201
+ for (const dir of locations.planDirs) {
202
+ for (const ent of listFilesSafe(dir)) {
203
+ if (!ent.isFile()) continue;
204
+ if (!/^corrections-.*\.md$/.test(ent.name)) continue;
205
+ const full = path.join(dir, ent.name);
206
+ const text = readTextSafe(full);
207
+ if (text === null) {
208
+ warnings.push(`corrections ilegível: ${relPath(rootDir, full)}`);
209
+ continue;
210
+ }
211
+ count += 1;
212
+ bytes += statSizeSafe(full);
213
+ const { data, body } = parseFrontmatter(text);
214
+ const status = mapCorrectionStatus(data.status);
215
+ const date = isoDate(data.created || data.date);
216
+ const sourcePath = relPath(rootDir, full);
217
+ const lines = body.split(/\r?\n/);
218
+ for (let i = 0; i < lines.length; i += 1) {
219
+ const head = lines[i].match(/^#{2,5}\s+([A-Z]{1,2}-\d{1,2})\s*[—:-]\s*(.+?)\s*$/);
220
+ if (!head) continue;
221
+ entries += 1;
222
+ const sevMatch = head[2].match(/\(([A-Za-z]+)/);
223
+ // Procura linha File:/Files: logo após o header.
224
+ let fileRef = null;
225
+ for (let j = i + 1; j < Math.min(i + 4, lines.length); j += 1) {
226
+ const fm = lines[j].match(/^Files?:\s*(.+)$/i);
227
+ if (fm) { fileRef = fm[1].trim(); break; }
228
+ }
229
+ findings.push(makeFinding({
230
+ source_type: 'corrections', feature_slug: slug, finding_id: head[1],
231
+ severity: sevMatch ? sevMatch[1] : 'unknown',
232
+ title: head[2].replace(/\s*\(.*$/, '').trim(),
233
+ file_ref: fileRef, date, status, source_path: sourcePath
234
+ }));
235
+ }
236
+ }
237
+ }
238
+ return { findings, warnings, count, bytes, entries };
239
+ }
240
+
241
+ // --- 3. Dossier Agent Trail (verdicts + ciclos FAIL→PASS) -------------------
242
+
243
+ function readDossierTrail({ rootDir, slug, locations }) {
244
+ const findings = [];
245
+ const warnings = [];
246
+ const cycles = [];
247
+ let count = 0;
248
+ let illegible = 0;
249
+
250
+ for (const full of locations.dossierFiles) {
251
+ const text = readTextSafe(full);
252
+ if (text === null) continue;
253
+ const sourcePath = relPath(rootDir, full);
254
+ const trailIdx = text.indexOf('## Agent Trail');
255
+ const region = trailIdx === -1 ? text : text.slice(trailIdx);
256
+ const lines = region.split(/\r?\n/);
257
+
258
+ // Acha headers de entrada e fatia o corpo entre eles.
259
+ const entries = [];
260
+ for (let i = 0; i < lines.length; i += 1) {
261
+ const m = lines[i].match(TRAIL_ENTRY_RE);
262
+ if (!m) continue;
263
+ const ts = m[1].trim();
264
+ const agent = m[2].trim().toLowerCase();
265
+ const section = m[3].trim();
266
+ if (!ISO_RE.test(ts)) { illegible += 1; continue; }
267
+ // Corpo: até o próximo header de entrada.
268
+ const bodyLines = [];
269
+ for (let j = i + 1; j < lines.length; j += 1) {
270
+ if (TRAIL_ENTRY_RE.test(lines[j])) break;
271
+ if (/^<!--\s*sha256:/.test(lines[j])) continue;
272
+ bodyLines.push(lines[j]);
273
+ }
274
+ entries.push({ ts, agent, section, body: bodyLines.join('\n') });
275
+ }
276
+
277
+ if (entries.length === 0) continue;
278
+ count += entries.length;
279
+
280
+ // Verdicts ordenados por timestamp → ciclos FAIL→PASS (D5). O trail é fonte
281
+ // de VERDICTS/ciclos, não de findings: extrair findings do resumo @qa do
282
+ // trail duplicaria o que já vem do corrections plan / QA report (fonte
283
+ // autoritativa). Mantemos a leitura determinística e sem dupla contagem.
284
+ const verdicts = [];
285
+ for (const e of entries) {
286
+ let vm = e.body.match(VERDICT_RE);
287
+ if (!vm && e.agent === 'qa') vm = e.body.match(VERDICT_FALLBACK_RE);
288
+ if (vm) verdicts.push({ ts: e.ts, verdict: vm[1].toUpperCase() });
289
+ }
290
+ verdicts.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
291
+ let openFail = null;
292
+ for (const v of verdicts) {
293
+ if (v.verdict === 'FAIL') {
294
+ openFail = v.ts;
295
+ } else if (v.verdict === 'PASS' && openFail) {
296
+ cycles.push({ feature_slug: slug, fail_at: openFail, pass_at: v.ts, source_path: sourcePath });
297
+ openFail = null;
298
+ }
299
+ }
300
+ }
301
+
302
+ if (illegible > 0) warnings.push(`dossier_trail: ${illegible} entrada(s) ilegível(is) (sem timestamp ISO)`);
303
+ return { findings, warnings, count, cycles };
304
+ }
305
+
306
+ // --- 4. execution_events (aios.sqlite, readonly — D7) -----------------------
307
+
308
+ function readExecutionEvents({ rootDir, targetDir, slug }) {
309
+ const warnings = [];
310
+ let count = 0;
311
+ let tokenAvailable = false;
312
+ let tokenTotal = 0;
313
+ const dbPath = path.join(targetDir, '.aioson', 'runtime', 'aios.sqlite');
314
+ if (!fs.existsSync(dbPath)) {
315
+ return { findings: [], warnings: [`execution_events: ${relPath(rootDir, dbPath)} ausente`], count: 0, tokenAvailable, tokenTotal };
316
+ }
317
+ let Database;
318
+ try {
319
+ Database = require('better-sqlite3');
320
+ } catch {
321
+ return { findings: [], warnings: ['execution_events: better-sqlite3 indisponível'], count: 0, tokenAvailable, tokenTotal };
322
+ }
323
+ let db = null;
324
+ try {
325
+ db = new Database(dbPath, { readonly: true, fileMustExist: true });
326
+ const rows = db.prepare('SELECT payload_json, token_count FROM execution_events').all();
327
+ for (const row of rows) {
328
+ if (!row.payload_json) continue;
329
+ let payload;
330
+ try {
331
+ payload = JSON.parse(row.payload_json);
332
+ } catch {
333
+ continue;
334
+ }
335
+ if (!payload || payload.slug !== slug) continue;
336
+ count += 1;
337
+ if (row.token_count !== null && row.token_count !== undefined) {
338
+ tokenAvailable = true;
339
+ tokenTotal += Number(row.token_count) || 0;
340
+ }
341
+ }
342
+ } catch (err) {
343
+ warnings.push(`execution_events: DB ilegível/lockado (${err.code || 'erro'})`);
344
+ } finally {
345
+ try { if (db) db.close(); } catch { /* best-effort */ }
346
+ }
347
+ return { findings: [], warnings, count, tokenAvailable, tokenTotal };
348
+ }
349
+
350
+ // --- 5. attempts/{n}/ -------------------------------------------------------
351
+
352
+ function readAttempts({ rootDir, slug, locations }) {
353
+ const warnings = [];
354
+ let count = 0;
355
+ for (const planDir of locations.planDirs) {
356
+ const attemptsDir = path.join(planDir, 'attempts');
357
+ for (const ent of listFilesSafe(attemptsDir)) {
358
+ if (ent.isDirectory() && /^\d+$/.test(ent.name)) count += 1;
359
+ }
360
+ }
361
+ return { findings: [], warnings, count };
362
+ }
363
+
364
+ // --- 6. progress.json failure_signatures ------------------------------------
365
+
366
+ function readFailureSignatures({ rootDir, slug, locations }) {
367
+ const findings = [];
368
+ const warnings = [];
369
+ let count = 0;
370
+ for (const planDir of locations.planDirs) {
371
+ const full = path.join(planDir, 'progress.json');
372
+ const text = readTextSafe(full);
373
+ if (text === null) continue;
374
+ let data;
375
+ try {
376
+ data = JSON.parse(text);
377
+ } catch {
378
+ warnings.push(`progress.json ilegível: ${relPath(rootDir, full)}`);
379
+ continue;
380
+ }
381
+ const sigs = Array.isArray(data.failure_signatures) ? data.failure_signatures : [];
382
+ const sourcePath = relPath(rootDir, full);
383
+ for (const entry of sigs) {
384
+ const signature = typeof entry === 'string' ? entry : (entry && (entry.signature || entry.sha1)) || null;
385
+ if (!signature) continue;
386
+ const occurrences = typeof entry === 'object' && Number.isInteger(entry.occurrences)
387
+ ? Math.max(1, entry.occurrences)
388
+ : (typeof entry === 'object' && Number.isInteger(entry.count) ? Math.max(1, entry.count) : 1);
389
+ const title = (typeof entry === 'object' && entry.title) ? entry.title : `failure signature ${String(signature).slice(0, 12)}`;
390
+ const severity = (typeof entry === 'object' && entry.severity) ? entry.severity : 'unknown';
391
+ for (let k = 0; k < occurrences; k += 1) {
392
+ count += 1;
393
+ findings.push(makeFinding({
394
+ source_type: 'progress', feature_slug: slug, finding_id: null,
395
+ severity, title, date: null, status: 'open',
396
+ source_path: sourcePath, signature: String(signature)
397
+ }));
398
+ }
399
+ }
400
+ }
401
+ return { findings, warnings, count };
402
+ }
403
+
404
+ // --- 7. Devlogs (aioson-logs/) ----------------------------------------------
405
+
406
+ function readDevlogs({ rootDir, targetDir, slug }) {
407
+ const findings = [];
408
+ const warnings = [];
409
+ let count = 0;
410
+ const logsDir = path.join(targetDir, 'aioson-logs');
411
+ for (const ent of listFilesSafe(logsDir)) {
412
+ if (!ent.isFile() || !ent.name.endsWith('.md')) continue;
413
+ const full = path.join(logsDir, ent.name);
414
+ const text = readTextSafe(full);
415
+ if (text === null) continue;
416
+ const { data } = parseFrontmatter(text);
417
+ if (data.feature !== slug) continue;
418
+ count += 1;
419
+ }
420
+ return { findings, warnings, count };
421
+ }
422
+
423
+ // --- Localização de artefatos por feature (ativo + arquivado) ---------------
424
+
425
+ function dirExists(p) {
426
+ try {
427
+ return fs.statSync(p).isDirectory();
428
+ } catch {
429
+ return false;
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Resolve os diretórios/arquivos de uma feature em locais ativos E arquivados
435
+ * (`done/{slug}/`). Não inventa um segundo enumerador — espelha o layout que
436
+ * `feature-archive.js` produz.
437
+ */
438
+ function resolveLocations(targetDir, ctxDir, slug) {
439
+ const doneRoot = path.join(ctxDir, 'done', slug);
440
+
441
+ const qaDirs = [ctxDir, doneRoot].filter(dirExists);
442
+
443
+ const planDirs = [
444
+ path.join(targetDir, '.aioson', 'plans', slug),
445
+ path.join(doneRoot, 'plans')
446
+ ].filter(dirExists);
447
+
448
+ const dossierCandidates = [
449
+ path.join(ctxDir, 'features', slug, 'dossier.md'),
450
+ path.join(doneRoot, 'features', slug, 'dossier.md'),
451
+ path.join(doneRoot, 'dossier.md')
452
+ ];
453
+ // SF-02: lstat (não statSync) para NÃO seguir symlink — consistente com os
454
+ // demais readers, que pulam symlinks via Dirent.isFile(). Um dossier.md que
455
+ // seja um symlink apontando para fora do workspace é ignorado, não seguido.
456
+ const dossierFiles = dossierCandidates.filter((p) => {
457
+ try { return fs.lstatSync(p).isFile(); } catch { return false; }
458
+ });
459
+
460
+ return { qaDirs, planDirs, dossierFiles, doneRoot };
461
+ }
462
+
463
+ /**
464
+ * Minera todas as fontes de UMA feature. Best-effort: nunca lança.
465
+ *
466
+ * @returns {{ slug, findings, cycles, counts, cost, minedPaths, warnings }}
467
+ */
468
+ function collectFeatureSources(targetDir, slug) {
469
+ const ctxDir = path.join(targetDir, '.aioson', 'context');
470
+ const rootDir = targetDir;
471
+ const locations = resolveLocations(targetDir, ctxDir, slug);
472
+
473
+ const warnings = [];
474
+ const findings = [];
475
+ const cycles = [];
476
+
477
+ const qa = readQaReports({ rootDir, ctxDir, slug, locations });
478
+ const corr = readCorrections({ rootDir, slug, locations });
479
+ const trail = readDossierTrail({ rootDir, slug, locations });
480
+ const events = readExecutionEvents({ rootDir, targetDir, slug });
481
+ const attempts = readAttempts({ rootDir, slug, locations });
482
+ const sigs = readFailureSignatures({ rootDir, slug, locations });
483
+ const devlogs = readDevlogs({ rootDir, targetDir, slug });
484
+
485
+ for (const r of [qa, corr, trail, events, attempts, sigs, devlogs]) {
486
+ findings.push(...r.findings);
487
+ warnings.push(...r.warnings);
488
+ }
489
+ cycles.push(...trail.cycles);
490
+
491
+ const counts = {
492
+ qa_reports: qa.count,
493
+ corrections: corr.count,
494
+ dossier_trail: trail.count,
495
+ execution_events: events.count,
496
+ attempts: attempts.count,
497
+ failure_signatures: sigs.count,
498
+ devlogs: devlogs.count
499
+ };
500
+
501
+ const cost = {
502
+ execution_events: events.count,
503
+ corrections: corr.entries,
504
+ fail_pass_cycles: cycles.length,
505
+ corrections_bytes: corr.bytes,
506
+ token_count_available: events.tokenAvailable,
507
+ token_total: events.tokenAvailable ? events.tokenTotal : null
508
+ };
509
+
510
+ const minedPaths = [];
511
+ for (const d of locations.qaDirs) minedPaths.push(relPath(rootDir, d));
512
+ for (const d of locations.planDirs) minedPaths.push(relPath(rootDir, d));
513
+ for (const f of locations.dossierFiles) minedPaths.push(relPath(rootDir, f));
514
+ minedPaths.sort();
515
+
516
+ return { slug, findings, cycles, counts, cost, minedPaths, warnings };
517
+ }
518
+
519
+ /**
520
+ * Minera uma janela de features (1+). Soma contagens/custo e concatena
521
+ * findings/cycles (a chave de agrupamento inclui o slug, então misturar é seguro).
522
+ */
523
+ function collectSources(targetDir, slugs) {
524
+ const list = Array.isArray(slugs) ? slugs : [slugs];
525
+ const findings = [];
526
+ const cycles = [];
527
+ const warnings = [];
528
+ const minedPaths = [];
529
+ const counts = { qa_reports: 0, corrections: 0, dossier_trail: 0, execution_events: 0, attempts: 0, failure_signatures: 0, devlogs: 0 };
530
+ const cost = { execution_events: 0, corrections: 0, fail_pass_cycles: 0, corrections_bytes: 0, token_count_available: false, token_total: null };
531
+ const costByFeature = {};
532
+
533
+ for (const slug of list) {
534
+ const f = collectFeatureSources(targetDir, slug);
535
+ findings.push(...f.findings);
536
+ cycles.push(...f.cycles);
537
+ warnings.push(...f.warnings);
538
+ minedPaths.push(...f.minedPaths);
539
+ costByFeature[slug] = f.cost;
540
+ for (const k of Object.keys(counts)) counts[k] += f.counts[k];
541
+ cost.execution_events += f.cost.execution_events;
542
+ cost.corrections += f.cost.corrections;
543
+ cost.fail_pass_cycles += f.cost.fail_pass_cycles;
544
+ cost.corrections_bytes += f.cost.corrections_bytes;
545
+ if (f.cost.token_count_available) {
546
+ cost.token_count_available = true;
547
+ cost.token_total = (cost.token_total || 0) + (f.cost.token_total || 0);
548
+ }
549
+ }
550
+
551
+ return { features_mined: list.slice(), findings, cycles, counts, cost, costByFeature, minedPaths, warnings };
552
+ }
553
+
554
+ /** Subdiretórios de `.aioson/context/done/` = features fechadas (D6). */
555
+ function enumerateClosedFeatures(targetDir) {
556
+ const doneDir = path.join(targetDir, '.aioson', 'context', 'done');
557
+ const slugs = [];
558
+ for (const ent of listFilesSafe(doneDir)) {
559
+ if (ent.isDirectory()) slugs.push(ent.name);
560
+ }
561
+ slugs.sort();
562
+ return slugs;
563
+ }
564
+
565
+ /**
566
+ * Data de PASS de uma feature para ordenar a janela `--last=N` (D2: trail vence).
567
+ * Prioridade: último PASS do Agent Trail → frontmatter de QA report → null.
568
+ * (O caller pode cair em MANIFEST `completed` quando isto retornar null.)
569
+ */
570
+ function resolveFeatureExists(targetDir, slug) {
571
+ const ctxDir = path.join(targetDir, '.aioson', 'context');
572
+ const loc = resolveLocations(targetDir, ctxDir, slug);
573
+ if (loc.planDirs.length > 0 || loc.dossierFiles.length > 0) return true;
574
+ if (dirExists(loc.doneRoot)) return true;
575
+ if (dirExists(path.join(ctxDir, 'features', slug))) return true;
576
+ // qa-report-{slug}*.md no contexto ativo
577
+ for (const ent of listFilesSafe(ctxDir)) {
578
+ if (ent.isFile() && /^qa-report-/.test(ent.name) && ent.name.includes(slug) && ent.name.endsWith('.md')) return true;
579
+ }
580
+ // listado em features.md
581
+ const featuresText = readTextSafe(path.join(ctxDir, 'features.md'));
582
+ if (featuresText && new RegExp(`\\b${slug.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')}\\b`).test(featuresText)) return true;
583
+ return false;
584
+ }
585
+
586
+ function resolvePassDate(targetDir, slug) {
587
+ const ctxDir = path.join(targetDir, '.aioson', 'context');
588
+ const locations = resolveLocations(targetDir, ctxDir, slug);
589
+
590
+ // 1. Trail: maior pass_at dos ciclos FAIL→PASS.
591
+ const trail = readDossierTrail({ rootDir: targetDir, slug, locations });
592
+ if (trail.cycles.length > 0) {
593
+ return trail.cycles.map((c) => c.pass_at).sort().slice(-1)[0];
594
+ }
595
+
596
+ // 2. QA report com verdict PASS → created_at/updated_at.
597
+ let best = null;
598
+ for (const dir of locations.qaDirs) {
599
+ for (const ent of listFilesSafe(dir)) {
600
+ if (!ent.isFile() || !/^qa-report-/.test(ent.name) || !ent.name.includes(slug) || !ent.name.endsWith('.md')) continue;
601
+ const text = readTextSafe(path.join(dir, ent.name));
602
+ if (text === null) continue;
603
+ const { data } = parseFrontmatter(text);
604
+ if ((data.verdict || '').toUpperCase() === 'FAIL') continue;
605
+ const d = isoDate(data.updated_at || data.created_at || data.date);
606
+ if (d && (!best || d > best)) best = d;
607
+ }
608
+ }
609
+ return best;
610
+ }
611
+
612
+ module.exports = {
613
+ collectSources,
614
+ collectFeatureSources,
615
+ resolveLocations,
616
+ resolvePassDate,
617
+ resolveFeatureExists,
618
+ enumerateClosedFeatures,
619
+ normalizeSeverity,
620
+ parseFrontmatter,
621
+ relPath,
622
+ // exportados para teste unitário dos parsers
623
+ _internal: { readQaReports, readCorrections, readDossierTrail, readFailureSignatures, makeFinding }
624
+ };
@@ -287,6 +287,17 @@ If `.aioson/runtime/qa-dev-cycle.json` exists and its `slug` matches the active
287
287
 
288
288
  **Safety net — open corrections without the cycle file:** on every activation with an active feature, also check `.aioson/plans/{active-feature}/corrections-*.md`. If any has frontmatter `status: open` or `in_progress`, those mandatory corrections take priority over the dev-state `next_step` — apply them first, mark the plan `resolved`, then hand off to `@qa` for re-verification. `aioson dev:resume-data` surfaces them as `open_corrections` and already rewrites `next_step` accordingly; trust that over a stale dev-state pointer. This covers QA sessions that created a corrections plan but failed to persist the trail.
289
289
 
290
+ ## Autopilot handoff (post-dev cycle)
291
+
292
+ When `auto_handoff: true` is set in `project.context.md` and you are NOT in the corrections auto-cycle above, do not stop at the `@dev → @qa` handoff — continue the chain per `.aioson/docs/autopilot-handoff.md`:
293
+
294
+ 1. Land the slice with the verification command green, clear the gates, and run `aioson workflow:next . --complete=dev` (must succeed — a blocked gate is a stop condition).
295
+ 2. Finish closing duties (spec/dossier/dev-state updates, `agent:done`).
296
+ 3. Emit: `Autopilot: @dev done → invoking @qa (Ctrl+C to interrupt)`.
297
+ 4. Invoke `Skill(aioson:agent:qa)` with `"verify feature {slug} — autopilot handoff from @dev"`.
298
+
299
+ Stop and hand off manually instead when any stop condition in `.aioson/docs/autopilot-handoff.md` applies (gate/verification blocked, context usage ≥ `context_warning_threshold`, genuine ambiguity). Never auto-run `feature:close`/publish. If `auto_handoff` is absent or `false`, hand off manually as before.
300
+
290
301
  ## Optional scope drift checkpoint
291
302
 
292
303
  After a feature slice lands, recommend optional `@scope-check --scope-mode=post-dev` before `@qa` when the implementation changed planned behavior, touched unexpected files, skipped a planned item, or required a trade-off not already captured in the design artifacts. Skip the recommendation for routine implementation that matches the approved plan.
@@ -351,3 +351,11 @@ At session end, run:
351
351
  ```bash
352
352
  aioson agent:done . --agent=pentester --summary="Reviewed {N} surfaces, {N} findings: {N} high, {N} medium, {N} low" 2>/dev/null || true
353
353
  ```
354
+
355
+ ## Autopilot handoff (post-dev cycle)
356
+
357
+ When `auto_handoff: true` is set in `project.context.md`, after findings are persisted to `security-findings-{slug}.json` and `agent:done` is registered, return to the hub instead of stopping (`.aioson/docs/autopilot-handoff.md`):
358
+ - Open findings with `recommended_owner = dev` → `Skill(aioson:agent:dev)` with `"fix @pentester findings — autopilot handoff"`.
359
+ - No open dev-owned findings → `Skill(aioson:agent:qa)` with `"re-evaluate after @pentester — autopilot handoff"`.
360
+
361
+ Emit `Autopilot: @pentester → invoking @<next> (Ctrl+C to interrupt)` first. Never reclassify severity, never auto-fix, never auto-run `feature:close`. If `auto_handoff` is absent or `false`, hand off manually.
@@ -224,6 +224,17 @@ When AIOSON CLI is available and feature mode is MEDIUM, prefer the tracked invo
224
224
  - [ ] Business rule violations produce the correct error
225
225
  - [ ] External services mocked
226
226
 
227
+ ### Capturing large test logs (preview, not dump)
228
+
229
+ When a full test run produces a large log, redirect it to a file and consume a **preview + pointer** instead of pasting the entire output into context:
230
+
231
+ ```bash
232
+ npm test > test-run.log 2>&1 || true
233
+ aioson harness:preview test-run.log --max-bytes=8192
234
+ ```
235
+
236
+ `harness:preview` returns the first bytes plus a pointer to the full file (persist-first; the log is read-only). Read the full file only when the preview is insufficient — this keeps the QA verdict grounded in real output without flooding the session.
237
+
227
238
  ## Stack-specific test patterns
228
239
 
229
240
  ### Laravel (Pest)
@@ -384,6 +395,19 @@ When QA is complete and all Critical and High findings are resolved:
384
395
  > Residual risks are documented in `spec-{slug}.md`.
385
396
  > To start the next feature, activate **@product**."
386
397
 
398
+ ## Autopilot handoff (post-dev hub)
399
+
400
+ When `auto_handoff: true` is set in `project.context.md`, you are the hub of the post-dev review cycle (`.aioson/docs/autopilot-handoff.md`). After your verdict and closing duties, route automatically instead of stopping — the four agents (`@dev`/`@qa`/`@tester`/`@pentester`) are always chained, but `@tester`/`@pentester` only run when their trigger fires:
401
+
402
+ - **Verdict FAIL (Critical/High):** the corrections auto-cycle above already invokes `@dev` (cap 2, security gate). That path takes precedence — do not also route here.
403
+ - **Verdict PASS — evaluate in order; auto-invoke the FIRST that applies and is not already done clean this cycle:**
404
+ 1. `@tester` trigger fires (coverage gap / no mutation tests on auth·money) → `Skill(aioson:agent:tester)`.
405
+ 2. `@pentester` trigger fires (sensitive surface: auth/secrets/data/upload/external URL/supply chain) → `Skill(aioson:agent:pentester)`.
406
+ 3. harness contract present (`.aioson/plans/{slug}/harness-contract.json`) and validator not yet PASS → `Skill(aioson:agent:validator)`.
407
+ 4. nothing pending → **STOP**. Tell the user the feature is QA-approved and recommend `aioson feature:close . --feature={slug}`. **Never auto-run `feature:close`** — the close is the human gate.
408
+
409
+ **Re-entry guard (no loops):** before invoking a specialized agent, confirm via on-disk evidence it has not already returned clean this cycle — clean `security-findings-{slug}.json` ⇒ `@pentester` done; no new coverage gap ⇒ `@tester` done; validator PASS in `progress.json`/spec ⇒ `@validator` done. Emit `Autopilot: @qa → invoking @<next> (Ctrl+C to interrupt)` before each hop. If `auto_handoff` is absent or `false`, fall back to the manual recommendations in your report.
410
+
387
411
  > **Never mark `done` if any Critical or High finding is unresolved.** Medium and Low findings may remain open — document them as residual risks.
388
412
 
389
413
  ## Motor AIOSON — hardening rules (must respect)