@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.
- package/CHANGELOG.md +932 -919
- package/package.json +1 -1
- package/src/agents.js +1 -1
- package/src/cli.js +16 -0
- package/src/commands/harness-preview.js +74 -0
- package/src/commands/harness-retro.js +221 -0
- package/src/commands/self-implement-loop.js +12 -2
- package/src/commands/workflow-next.js +10 -2
- package/src/harness/preview-artifact.js +85 -0
- package/src/i18n/messages/en.js +21 -0
- package/src/i18n/messages/es.js +21 -0
- package/src/i18n/messages/fr.js +21 -0
- package/src/i18n/messages/pt-BR.js +21 -0
- package/src/lib/retro/retro-aggregate.js +192 -0
- package/src/lib/retro/retro-render.js +185 -0
- package/src/lib/retro/retro-sources.js +624 -0
- package/template/.aioson/agents/dev.md +11 -0
- package/template/.aioson/agents/pentester.md +8 -0
- package/template/.aioson/agents/qa.md +24 -0
- package/template/.aioson/agents/sheldon.md +13 -0
- package/template/.aioson/agents/tester.md +17 -0
- package/template/.aioson/agents/validator.md +8 -0
- package/template/.aioson/docs/autopilot-handoff.md +83 -46
- package/template/.aioson/rules/aioson-context-boundary.md +10 -8
- package/template/AGENTS.md +1 -1
- package/template/CLAUDE.md +1 -1
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Agregação determinística para `aioson harness:retro` (requirements §3.1/§7).
|
|
5
|
+
*
|
|
6
|
+
* Agrupa findings por CHAVE DETERMINÍSTICA EXATA — nunca por classe semântica
|
|
7
|
+
* (isso é trabalho do @sheldon). A chave inclui sempre o slug (um finding-ID
|
|
8
|
+
* como C-01 existe em quase toda feature; nunca agrupar entre features — edge 5).
|
|
9
|
+
*
|
|
10
|
+
* Critério anti-opinião (REQ-2): um grupo vira "Proposta candidata" SOMENTE se
|
|
11
|
+
* (a) ≥2 ocorrências da mesma chave, OU
|
|
12
|
+
* (b) ≥1 ocorrência de severidade high/critical, OU
|
|
13
|
+
* (c) a feature tem ≥2 ciclos FAIL→PASS.
|
|
14
|
+
* Severidade `unknown` nunca satisfaz (b) sozinha; todo o resto vai para
|
|
15
|
+
* "Observações". (a) é independente de severidade — assinaturas sha1 (severidade
|
|
16
|
+
* unknown) promovem ao repetir, AC-6.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const crypto = require('node:crypto');
|
|
20
|
+
|
|
21
|
+
const SEVERITY_RANK = { critical: 5, high: 4, medium: 3, low: 2, info: 1, unknown: 0 };
|
|
22
|
+
const PHASE_RE = /(?:[-_/]|\b)(ph\d+|phase[-_]?\d+)\b/i;
|
|
23
|
+
|
|
24
|
+
function severityRank(sev) {
|
|
25
|
+
return SEVERITY_RANK[sev] ?? 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sha1short(text) {
|
|
29
|
+
return crypto.createHash('sha1').update(String(text)).digest('hex').slice(0, 12);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Token de phase derivado do path da fonte (edge 4 — desambiguação entre phases). */
|
|
33
|
+
function phaseToken(sourcePath) {
|
|
34
|
+
if (!sourcePath) return '';
|
|
35
|
+
const m = String(sourcePath).match(PHASE_RE);
|
|
36
|
+
return m ? m[1].toLowerCase().replace(/[-_]/g, '') : '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Chave determinística exata de um finding (slug sempre incluído). */
|
|
40
|
+
function groupKey(f) {
|
|
41
|
+
const phase = phaseToken(f.source_path);
|
|
42
|
+
const prefix = phase ? `${f.feature_slug}::${phase}` : f.feature_slug;
|
|
43
|
+
if (f.signature) return `${prefix}::sig:${f.signature}`;
|
|
44
|
+
if (f.finding_id) return `${prefix}::${f.finding_id}`;
|
|
45
|
+
return `${prefix}::title:${sha1short(f.title || '')}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Ordenação estável de ocorrências: severidade (critical→low), depois data, depois path. */
|
|
49
|
+
function compareOccurrences(a, b) {
|
|
50
|
+
const sr = severityRank(b.severity) - severityRank(a.severity);
|
|
51
|
+
if (sr !== 0) return sr;
|
|
52
|
+
const da = a.date || '';
|
|
53
|
+
const db = b.date || '';
|
|
54
|
+
if (da !== db) return da < db ? -1 : 1;
|
|
55
|
+
return (a.source_path || '') < (b.source_path || '') ? -1 : (a.source_path || '') > (b.source_path || '') ? 1 : 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {object} sources — saída de `collectSources` ({ findings, cycles, cost, costByFeature })
|
|
60
|
+
* @returns {{ candidates, observations, cost }}
|
|
61
|
+
*/
|
|
62
|
+
function aggregate(sources) {
|
|
63
|
+
const findings = Array.isArray(sources.findings) ? sources.findings : [];
|
|
64
|
+
const cycles = Array.isArray(sources.cycles) ? sources.cycles : [];
|
|
65
|
+
const costByFeature = sources.costByFeature || {};
|
|
66
|
+
|
|
67
|
+
// Ciclos FAIL→PASS por feature (ordenados por data).
|
|
68
|
+
const cyclesByFeature = new Map();
|
|
69
|
+
for (const c of cycles) {
|
|
70
|
+
if (!cyclesByFeature.has(c.feature_slug)) cyclesByFeature.set(c.feature_slug, []);
|
|
71
|
+
cyclesByFeature.get(c.feature_slug).push(c);
|
|
72
|
+
}
|
|
73
|
+
for (const arr of cyclesByFeature.values()) {
|
|
74
|
+
arr.sort((a, b) => (a.fail_at < b.fail_at ? -1 : a.fail_at > b.fail_at ? 1 : 0));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Agrupa findings por chave exata.
|
|
78
|
+
const groups = new Map();
|
|
79
|
+
for (const f of findings) {
|
|
80
|
+
const key = groupKey(f);
|
|
81
|
+
if (!groups.has(key)) groups.set(key, { key, feature_slug: f.feature_slug, finding_id: f.finding_id, signature: f.signature, occurrences: [] });
|
|
82
|
+
groups.get(key).occurrences.push(f);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const candidates = [];
|
|
86
|
+
const observations = [];
|
|
87
|
+
|
|
88
|
+
for (const g of groups.values()) {
|
|
89
|
+
g.occurrences.sort(compareOccurrences);
|
|
90
|
+
const maxSeverity = g.occurrences.reduce((acc, o) => (severityRank(o.severity) > severityRank(acc) ? o.severity : acc), 'unknown');
|
|
91
|
+
const occCount = g.occurrences.length;
|
|
92
|
+
const featureCycles = cyclesByFeature.get(g.feature_slug) || [];
|
|
93
|
+
|
|
94
|
+
const reasons = [];
|
|
95
|
+
if (occCount >= 2) reasons.push('recurrence');
|
|
96
|
+
if (severityRank(maxSeverity) >= severityRank('high')) reasons.push('severity');
|
|
97
|
+
if (featureCycles.length >= 2) reasons.push('fail_pass_cycle');
|
|
98
|
+
|
|
99
|
+
const feCost = costByFeature[g.feature_slug] || {};
|
|
100
|
+
const cost = {
|
|
101
|
+
occurrences: occCount,
|
|
102
|
+
corrections: g.occurrences.filter((o) => o.source_type === 'corrections').length,
|
|
103
|
+
fail_pass_cycles: featureCycles.length,
|
|
104
|
+
cycle_dates: featureCycles.map((c) => `${c.fail_at}→${c.pass_at}`),
|
|
105
|
+
execution_events: feCost.execution_events || 0,
|
|
106
|
+
corrections_bytes: feCost.corrections_bytes || 0,
|
|
107
|
+
tokens: feCost.token_count_available ? (feCost.token_total || 0) : null
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
if (reasons.length > 0) {
|
|
111
|
+
candidates.push({
|
|
112
|
+
key: g.key,
|
|
113
|
+
feature_slug: g.feature_slug,
|
|
114
|
+
finding_id: g.finding_id || null,
|
|
115
|
+
signature: g.signature || null,
|
|
116
|
+
max_severity: maxSeverity,
|
|
117
|
+
reasons,
|
|
118
|
+
occurrences: g.occurrences,
|
|
119
|
+
corrections_link: (g.occurrences.find((o) => o.source_type === 'corrections') || {}).source_path || null,
|
|
120
|
+
cost
|
|
121
|
+
});
|
|
122
|
+
} else {
|
|
123
|
+
// Ocorrência única Medium/Low/info/unknown → Observação (uma linha).
|
|
124
|
+
const o = g.occurrences[0];
|
|
125
|
+
observations.push({
|
|
126
|
+
key: g.key,
|
|
127
|
+
feature_slug: g.feature_slug,
|
|
128
|
+
finding_id: g.finding_id || null,
|
|
129
|
+
severity: o.severity,
|
|
130
|
+
title: o.title,
|
|
131
|
+
date: o.date,
|
|
132
|
+
source_path: o.source_path
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Candidato sintético por feature com ≥2 ciclos sem finding-âncora já candidato.
|
|
138
|
+
for (const [slug, arr] of cyclesByFeature.entries()) {
|
|
139
|
+
if (arr.length < 2) continue;
|
|
140
|
+
const alreadyCovered = candidates.some((c) => c.feature_slug === slug && c.reasons.includes('fail_pass_cycle'));
|
|
141
|
+
if (alreadyCovered) continue;
|
|
142
|
+
const feCost = costByFeature[slug] || {};
|
|
143
|
+
candidates.push({
|
|
144
|
+
key: `${slug}::cycles`,
|
|
145
|
+
feature_slug: slug,
|
|
146
|
+
finding_id: null,
|
|
147
|
+
signature: null,
|
|
148
|
+
max_severity: 'unknown',
|
|
149
|
+
reasons: ['fail_pass_cycle'],
|
|
150
|
+
occurrences: [],
|
|
151
|
+
corrections_link: null,
|
|
152
|
+
cost: {
|
|
153
|
+
occurrences: 0,
|
|
154
|
+
corrections: feCost.corrections || 0,
|
|
155
|
+
fail_pass_cycles: arr.length,
|
|
156
|
+
cycle_dates: arr.map((c) => `${c.fail_at}→${c.pass_at}`),
|
|
157
|
+
execution_events: feCost.execution_events || 0,
|
|
158
|
+
corrections_bytes: feCost.corrections_bytes || 0,
|
|
159
|
+
tokens: feCost.token_count_available ? (feCost.token_total || 0) : null
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Ordena candidatos: severidade (critical→low), depois data mais antiga, depois chave.
|
|
165
|
+
candidates.sort((a, b) => {
|
|
166
|
+
const sr = severityRank(b.max_severity) - severityRank(a.max_severity);
|
|
167
|
+
if (sr !== 0) return sr;
|
|
168
|
+
const da = (a.occurrences[0] && a.occurrences[0].date) || '';
|
|
169
|
+
const db = (b.occurrences[0] && b.occurrences[0].date) || '';
|
|
170
|
+
if (da !== db) return da < db ? -1 : 1;
|
|
171
|
+
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Ordena observações: severidade, depois data, depois chave.
|
|
175
|
+
observations.sort((a, b) => {
|
|
176
|
+
const sr = severityRank(b.severity) - severityRank(a.severity);
|
|
177
|
+
if (sr !== 0) return sr;
|
|
178
|
+
const da = a.date || '';
|
|
179
|
+
const db = b.date || '';
|
|
180
|
+
if (da !== db) return da < db ? -1 : 1;
|
|
181
|
+
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return { candidates, observations, cost: sources.cost || {} };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = {
|
|
188
|
+
aggregate,
|
|
189
|
+
groupKey,
|
|
190
|
+
severityRank,
|
|
191
|
+
_internal: { phaseToken, compareOccurrences }
|
|
192
|
+
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Renderiza o dossiê retrospectivo em Markdown (requirements §3.1).
|
|
5
|
+
*
|
|
6
|
+
* Saída byte-estável exceto `generated_at` (AC-4): nenhuma fonte de
|
|
7
|
+
* não-determinismo além do timestamp injetado pelo caller. As 4 seções existem
|
|
8
|
+
* SEMPRE — vazias com placeholder. O conteúdo do dossiê é um artefato em idioma
|
|
9
|
+
* fixo (pt-BR, conforme §3.1); `--locale` afeta só as mensagens de stdout.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const SOURCE_ORDER = ['qa_reports', 'corrections', 'dossier_trail', 'execution_events', 'attempts', 'failure_signatures', 'devlogs'];
|
|
13
|
+
|
|
14
|
+
// Defesa em profundidade (SF-01): o dossiê vira contexto do @sheldon. Texto livre
|
|
15
|
+
// minerado (títulos) é apresentado como DADO inline, nunca como estrutura Markdown
|
|
16
|
+
// injetável. Neutraliza newlines/controles/bidi/zero-width — impede que um título
|
|
17
|
+
// forjado injete um header `## …`, um fence ``` ou um bloco de instrução no dossiê.
|
|
18
|
+
// Determinístico e byte-estável: identidade sobre texto limpo (sem esses chars).
|
|
19
|
+
const INJECTABLE_CHARS_RE = new RegExp(
|
|
20
|
+
'[\\u0000-\\u001F\\u007F\\u200B-\\u200F\\u2028\\u2029\\u202A-\\u202E\\u2066-\\u2069\\uFEFF]',
|
|
21
|
+
'g'
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
function neutralizeText(value) {
|
|
25
|
+
if (value === null || value === undefined) return '';
|
|
26
|
+
return String(value).replace(INJECTABLE_CHARS_RE, ' ').trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function severityLabel(sev) {
|
|
30
|
+
return sev || 'unknown';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fmtCost(cost) {
|
|
34
|
+
const parts = [
|
|
35
|
+
`ocorrências ${cost.occurrences}`,
|
|
36
|
+
`correções ${cost.corrections}`,
|
|
37
|
+
`ciclos FAIL→PASS ${cost.fail_pass_cycles}${cost.cycle_dates && cost.cycle_dates.length ? ` (${cost.cycle_dates.join('; ')})` : ''}`,
|
|
38
|
+
`eventos ${cost.execution_events}`,
|
|
39
|
+
`bytes corrections ${cost.corrections_bytes}`,
|
|
40
|
+
`tokens ${cost.tokens === null || cost.tokens === undefined ? 'n/d' : cost.tokens}`
|
|
41
|
+
];
|
|
42
|
+
return parts.join(', ');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function renderCandidate(c) {
|
|
46
|
+
const lines = [];
|
|
47
|
+
const anchor = c.finding_id || (c.signature ? `sig:${c.signature.slice(0, 12)}` : c.key);
|
|
48
|
+
lines.push(`### ${c.key}`);
|
|
49
|
+
lines.push('');
|
|
50
|
+
lines.push(`- Âncora: ${anchor} | severidade máxima: ${severityLabel(c.max_severity)} | motivos: ${c.reasons.join(', ')}`);
|
|
51
|
+
if (c.occurrences.length > 0) {
|
|
52
|
+
lines.push(`- Ocorrências (${c.occurrences.length}):`);
|
|
53
|
+
for (const o of c.occurrences) {
|
|
54
|
+
const id = o.finding_id || (o.signature ? `sig:${o.signature.slice(0, 12)}` : '—');
|
|
55
|
+
lines.push(` - (${o.feature_slug}, ${id}, ${severityLabel(o.severity)}, ${o.date || 'sem-data'}, ${o.source_path}, ${o.status})`);
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
lines.push('- Ocorrências: ciclos FAIL→PASS recorrentes (sem finding-âncora único)');
|
|
59
|
+
}
|
|
60
|
+
lines.push(`- Correções aplicadas: ${c.corrections_link || '—'}`);
|
|
61
|
+
lines.push(`- Custo de retrabalho: ${fmtCost(c.cost)}`);
|
|
62
|
+
lines.push('');
|
|
63
|
+
return lines.join('\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderObservation(o) {
|
|
67
|
+
const id = o.finding_id || '—';
|
|
68
|
+
return `- (${o.feature_slug}, ${id}, ${severityLabel(o.severity)}, ${o.date || 'sem-data'}) — ${neutralizeText(o.title) || id} [${o.source_path}]`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderFrontmatter({ mode, slug, windowN, featuresMined, counts, candidatesCount, observationsCount, generatedAt }) {
|
|
72
|
+
const lines = ['---'];
|
|
73
|
+
if (mode === 'window') {
|
|
74
|
+
lines.push(`window: last-${windowN}`);
|
|
75
|
+
} else {
|
|
76
|
+
lines.push(`feature: ${slug}`);
|
|
77
|
+
}
|
|
78
|
+
lines.push(`generated_at: ${generatedAt}`);
|
|
79
|
+
lines.push('generated_by: harness-retro');
|
|
80
|
+
lines.push('schema_version: "1.0"');
|
|
81
|
+
lines.push(`features_mined: [${featuresMined.join(', ')}]`);
|
|
82
|
+
lines.push('sources:');
|
|
83
|
+
for (const key of SOURCE_ORDER) {
|
|
84
|
+
lines.push(` ${key}: ${counts[key] || 0}`);
|
|
85
|
+
}
|
|
86
|
+
lines.push(`candidates: ${candidatesCount}`);
|
|
87
|
+
lines.push(`observations: ${observationsCount}`);
|
|
88
|
+
lines.push('---');
|
|
89
|
+
return lines.join('\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {object} opts
|
|
94
|
+
* @param {'feature'|'window'} opts.mode
|
|
95
|
+
* @param {string} [opts.slug]
|
|
96
|
+
* @param {number} [opts.windowN]
|
|
97
|
+
* @param {string[]} opts.featuresMined
|
|
98
|
+
* @param {object} opts.counts — contagens por fonte
|
|
99
|
+
* @param {Array} opts.candidates
|
|
100
|
+
* @param {Array} opts.observations
|
|
101
|
+
* @param {string[]} opts.minedPaths
|
|
102
|
+
* @param {string[]} opts.warnings
|
|
103
|
+
* @param {string} opts.dossierRelPath — path relativo deste dossiê (para o Próximo passo)
|
|
104
|
+
* @param {string} opts.generatedAt — ISO 8601 (única fonte de não-determinismo)
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
function renderDossier(opts) {
|
|
108
|
+
const {
|
|
109
|
+
mode, slug, windowN, featuresMined, counts,
|
|
110
|
+
candidates, observations, minedPaths, warnings, dossierRelPath, generatedAt
|
|
111
|
+
} = opts;
|
|
112
|
+
|
|
113
|
+
const blocks = [];
|
|
114
|
+
|
|
115
|
+
blocks.push(renderFrontmatter({
|
|
116
|
+
mode, slug, windowN, featuresMined, counts,
|
|
117
|
+
candidatesCount: candidates.length, observationsCount: observations.length, generatedAt
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
const title = mode === 'window' ? `Dossiê retrospectivo — janela last-${windowN}` : `Dossiê retrospectivo — ${slug}`;
|
|
121
|
+
blocks.push(`\n# ${title}\n`);
|
|
122
|
+
|
|
123
|
+
// 1. Propostas candidatas
|
|
124
|
+
const sec1 = ['## Propostas candidatas', ''];
|
|
125
|
+
if (candidates.length === 0) {
|
|
126
|
+
sec1.push('_(nenhuma proposta candidata — nenhum item atende ao critério REQ-2)_');
|
|
127
|
+
} else {
|
|
128
|
+
for (const c of candidates) sec1.push(renderCandidate(c));
|
|
129
|
+
}
|
|
130
|
+
blocks.push(sec1.join('\n'));
|
|
131
|
+
|
|
132
|
+
// 2. Observações
|
|
133
|
+
const sec2 = ['', '## Observações', ''];
|
|
134
|
+
if (observations.length === 0) {
|
|
135
|
+
sec2.push('_(nenhuma observação)_');
|
|
136
|
+
} else {
|
|
137
|
+
for (const o of observations) sec2.push(renderObservation(o));
|
|
138
|
+
}
|
|
139
|
+
blocks.push(sec2.join('\n'));
|
|
140
|
+
|
|
141
|
+
// 3. Trilha minerada
|
|
142
|
+
const sec3 = ['', '## Trilha minerada', '', '### Paths minerados'];
|
|
143
|
+
if (minedPaths.length === 0) {
|
|
144
|
+
sec3.push('- _(nenhum path encontrado)_');
|
|
145
|
+
} else {
|
|
146
|
+
for (const p of minedPaths) sec3.push(`- ${p}`);
|
|
147
|
+
}
|
|
148
|
+
sec3.push('', '### Contagens por fonte');
|
|
149
|
+
for (const key of SOURCE_ORDER) {
|
|
150
|
+
sec3.push(`- ${key}: ${counts[key] || 0}`);
|
|
151
|
+
}
|
|
152
|
+
sec3.push('', '### Avisos');
|
|
153
|
+
if (warnings.length === 0) {
|
|
154
|
+
sec3.push('- _(nenhum aviso — todas as fontes lidas sem degradação)_');
|
|
155
|
+
} else {
|
|
156
|
+
for (const w of warnings) sec3.push(`- ${w}`);
|
|
157
|
+
}
|
|
158
|
+
blocks.push(sec3.join('\n'));
|
|
159
|
+
|
|
160
|
+
// 4. Próximo passo (texto fixo — REQ-5)
|
|
161
|
+
const sec4 = [
|
|
162
|
+
'',
|
|
163
|
+
'## Próximo passo',
|
|
164
|
+
'',
|
|
165
|
+
`Ative o @sheldon sob demanda para analisar este dossiê (\`${dossierRelPath}\`):`,
|
|
166
|
+
'',
|
|
167
|
+
'```',
|
|
168
|
+
`aioson agent:prompt sheldon . --task="analisar ${dossierRelPath}"`,
|
|
169
|
+
'```',
|
|
170
|
+
'',
|
|
171
|
+
'Critério de promoção (REQ-2): só vira proposta o item com ≥2 ocorrências da mesma chave determinística, ≥1 finding High/Critical, ou ≥2 ciclos FAIL→PASS na mesma feature.',
|
|
172
|
+
'',
|
|
173
|
+
'@sheldon classifica as classes de falha citando as ocorrências deste dossiê e propõe deltas que aterrissam APENAS em `.aioson/learnings/` e `.aioson/rules/`, sempre com aprovação humana. A CLI minera e materializa; ela nunca auto-aplica deltas.',
|
|
174
|
+
''
|
|
175
|
+
];
|
|
176
|
+
blocks.push(sec4.join('\n'));
|
|
177
|
+
|
|
178
|
+
// Normaliza para LF e garante newline final único (byte-estável).
|
|
179
|
+
return `${blocks.join('\n').replace(/\r\n/g, '\n').replace(/\n+$/, '')}\n`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = {
|
|
183
|
+
renderDossier,
|
|
184
|
+
_internal: { renderFrontmatter, renderCandidate, fmtCost, neutralizeText, SOURCE_ORDER }
|
|
185
|
+
};
|