@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaimevalasek/aioson",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "description": "AI operating framework for hyper-personalized software.",
5
5
  "keywords": [
6
6
  "ai",
package/src/agents.js CHANGED
@@ -67,7 +67,7 @@ function buildAgentPrompt(agent, tool, options = {}) {
67
67
  '',
68
68
  `**Language boundary:** Agent instructions are canonical in English. All user-facing communication must be in ${interactionLanguage}.`,
69
69
  '',
70
- `**Scope boundary:** You operate exclusively as ${agent.command}. Do not perform work that belongs to another agent. When your work is complete, output only the handoff — which agent is next and why. Do not continue into that agent\'s territory.${autoHandoff ? ' Exception: autopilot handoff is active for this stage — follow `.aioson/docs/autopilot-handoff.md` and auto-invoke the next agent\'s skill when no stop condition applies, never past the `@dev` handoff.' : ''}`,
70
+ `**Scope boundary:** You operate exclusively as ${agent.command}. Do not perform work that belongs to another agent. When your work is complete, output only the handoff — which agent is next and why. Do not continue into that agent\'s territory.${autoHandoff ? ' Exception: autopilot handoff is active for this stage — follow `.aioson/docs/autopilot-handoff.md` and auto-invoke the next agent\'s skill when no stop condition applies. The chain stops before the first `@dev` activation (human clears context and starts implementation) and continues through the post-dev review cycle (`@dev` → `@qa` → `@tester`/`@pentester` when their triggers fire → `@validator`); it NEVER auto-runs `feature:close`/publish — those require explicit human approval.' : ''}`,
71
71
  ].join('\n');
72
72
 
73
73
  if (safeTool === 'claude') {
package/src/cli.js CHANGED
@@ -268,6 +268,10 @@ const JSON_SUPPORTED_COMMANDS = new Set([
268
268
  'workflow-next',
269
269
  'workflow:status',
270
270
  'workflow-status',
271
+ 'harness:retro',
272
+ 'harness-retro',
273
+ 'harness:preview',
274
+ 'harness-preview',
271
275
  'agent:next',
272
276
  'agent-next',
273
277
  'parallel:init',
@@ -405,6 +409,10 @@ const JSON_SUPPORTED_COMMANDS = new Set([
405
409
  'harness-reject',
406
410
  'harness:status',
407
411
  'harness-status',
412
+ 'harness:retro',
413
+ 'harness-retro',
414
+ 'harness:preview',
415
+ 'harness-preview',
408
416
  'brief-gen',
409
417
  'verify:gate',
410
418
  'verify-gate',
@@ -817,6 +825,8 @@ function printHelp(t, logger) {
817
825
  logHelpLine(t, logger, 'cli.help_qa_run');
818
826
  logHelpLine(t, logger, 'cli.help_qa_scan');
819
827
  logHelpLine(t, logger, 'cli.help_qa_report');
828
+ logHelpLine(t, logger, 'cli.help_harness_retro');
829
+ logHelpLine(t, logger, 'cli.help_harness_preview');
820
830
  logHelpLine(t, logger, 'cli.help_web_map');
821
831
  logHelpLine(t, logger, 'cli.help_web_scrape');
822
832
  logHelpLine(t, logger, 'cli.help_scan_project');
@@ -1271,6 +1281,12 @@ async function main() {
1271
1281
  } else if (command === 'harness:status' || command === 'harness-status') {
1272
1282
  const { runHarnessStatus } = require('./commands/harness-status');
1273
1283
  result = await runHarnessStatus({ args, options, logger: commandLogger, t });
1284
+ } else if (command === 'harness:retro' || command === 'harness-retro') {
1285
+ const { runHarnessRetro } = require('./commands/harness-retro');
1286
+ result = await runHarnessRetro({ args, options, logger: commandLogger, t });
1287
+ } else if (command === 'harness:preview' || command === 'harness-preview') {
1288
+ const { runHarnessPreview } = require('./commands/harness-preview');
1289
+ result = await runHarnessPreview({ args, options, logger: commandLogger, t });
1274
1290
  } else if (command === 'verify:gate' || command === 'verify-gate') {
1275
1291
  result = await runVerifyGate({ args, options, logger: commandLogger, t });
1276
1292
 
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `aioson harness:preview <file> [--max-bytes=8192] [--json]` (requirements §5.2).
5
+ *
6
+ * Wrapper fino e read-only de `previewArtifact` sobre um arquivo já persistido
7
+ * (ex.: `npm test > test.log`). Devolve preview + ponteiro para o agente de teste
8
+ * consumir sem despejar o log integral no contexto. Tema 2 (should-have).
9
+ *
10
+ * Exit codes: 0 sucesso; 12 input inválido (arquivo ausente/ilegível).
11
+ */
12
+
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+
16
+ const { previewArtifact, DEFAULT_MAX_BYTES } = require('../harness/preview-artifact');
17
+
18
+ const EXIT_OK = 0;
19
+ const EXIT_INPUT = 12;
20
+
21
+ function tr(t, key, params, fallback) {
22
+ if (typeof t !== 'function') return fallback;
23
+ const msg = t(key, params);
24
+ return msg && msg !== key ? msg : fallback;
25
+ }
26
+
27
+ async function runHarnessPreview({ args, options = {}, logger, t } = {}) {
28
+ const log = logger || { log() {}, error() {} };
29
+ const file = args && args[0];
30
+
31
+ if (!file || typeof file !== 'string') {
32
+ log.error(tr(t, 'cli.harnessPreview.file_required', null, 'harness:preview requires a <file> path argument.'));
33
+ process.exitCode = EXIT_INPUT;
34
+ return { ok: false, exitCode: EXIT_INPUT, error: 'file_required' };
35
+ }
36
+
37
+ // SF-02 (decisão de design, não bug): leitor read-only operador-local. Lê
38
+ // qualquer path legível por design — o caso de uso é previewar logs de teste
39
+ // (ex.: `npm test > test.log`) que podem viver fora do cwd. Sem cruzamento de
40
+ // fronteira de confiança (o operador já tem acesso ao FS); por isso não há
41
+ // contenção de workspace aqui. Mantém-se intencionalmente irrestrito.
42
+ const abs = path.resolve(process.cwd(), file);
43
+ let content;
44
+ try {
45
+ content = fs.readFileSync(abs, 'utf8');
46
+ } catch (err) {
47
+ if (err.code === 'ENOENT') {
48
+ log.error(tr(t, 'cli.harnessPreview.not_found', { path: file }, `File not found: ${file}`));
49
+ process.exitCode = EXIT_INPUT;
50
+ return { ok: false, exitCode: EXIT_INPUT, error: 'not_found' };
51
+ }
52
+ log.error(tr(t, 'cli.harnessPreview.read_error', { path: file, error: err.message }, `Could not read file: ${file} (${err.message})`));
53
+ process.exitCode = EXIT_INPUT;
54
+ return { ok: false, exitCode: EXIT_INPUT, error: 'read_error' };
55
+ }
56
+
57
+ let maxBytes = Number(options['max-bytes'] ?? options.maxBytes ?? DEFAULT_MAX_BYTES);
58
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) maxBytes = DEFAULT_MAX_BYTES;
59
+
60
+ // Modo leitura: o arquivo já está persistido — não reescrever (persist:false).
61
+ const result = previewArtifact(content, { maxBytes, artifactPath: abs, persist: false });
62
+ log.log(result.preview);
63
+
64
+ return {
65
+ ok: true,
66
+ exitCode: EXIT_OK,
67
+ file: file.replaceAll('\\', '/'),
68
+ totalBytes: result.totalBytes,
69
+ truncated: result.truncated,
70
+ preview: result.preview
71
+ };
72
+ }
73
+
74
+ module.exports = { runHarnessPreview };
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `aioson harness:retro [path] --feature=<slug> | --last=<N> [--json] [--locale=<l>]`
5
+ *
6
+ * Minera deterministicamente a trilha de falhas já coletada e materializa um
7
+ * dossiê retrospectivo em `.aioson/context/retro/{slug}.md` (ou
8
+ * `window-last-{N}.md`). Leitura-apenas sobre as fontes; única escrita: o dossiê.
9
+ * Sem LLM, sem rede (requirements §5.1, RHO-lite).
10
+ *
11
+ * Exit codes (D4 — devolvidos em `result.exitCode`, propagados por cli.js:1649
12
+ * em --json e por process.exitCode no modo texto; mesmo caminho de código para
13
+ * fechar a classe recorrente exit-code-collapsed-in-json-mode):
14
+ * 0 sucesso (inclusive dossiê vazio)
15
+ * 1 erro de I/O inesperado
16
+ * 12 erro de input (slug inválido, flags conflitantes, feature inexistente)
17
+ */
18
+
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+
22
+ const {
23
+ collectSources, resolvePassDate, resolveFeatureExists, enumerateClosedFeatures, relPath
24
+ } = require('../lib/retro/retro-sources');
25
+ const { aggregate } = require('../lib/retro/retro-aggregate');
26
+ const { renderDossier } = require('../lib/retro/retro-render');
27
+
28
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
29
+ const EXIT_OK = 0;
30
+ const EXIT_IO = 1;
31
+ const EXIT_INPUT = 12;
32
+
33
+ function tr(t, key, params, fallback) {
34
+ if (typeof t !== 'function') return fallback;
35
+ const msg = t(key, params);
36
+ return msg && msg !== key ? msg : fallback;
37
+ }
38
+
39
+ /** Encerra em erro de input: registra mensagem + devolve resultado com exitCode. */
40
+ function inputError(logger, message, error) {
41
+ if (logger && typeof logger.error === 'function') logger.error(message);
42
+ process.exitCode = EXIT_INPUT;
43
+ return { ok: false, exitCode: EXIT_INPUT, error, message };
44
+ }
45
+
46
+ /** Datas `completed` do MANIFEST de arquivadas (fallback de ordenação). */
47
+ function readManifestDates(targetDir) {
48
+ const map = {};
49
+ try {
50
+ const text = fs.readFileSync(path.join(targetDir, '.aioson', 'context', 'done', 'MANIFEST.md'), 'utf8');
51
+ for (const line of text.split(/\r?\n/)) {
52
+ const m = line.match(/^\|\s*([a-z0-9][a-z0-9-]*)\s*\|\s*([0-9-]{8,10})\s*\|/i);
53
+ if (m) map[m[1]] = m[2].trim();
54
+ }
55
+ } catch { /* best-effort */ }
56
+ return map;
57
+ }
58
+
59
+ /**
60
+ * Ordena uma lista de slugs por data de PASS desc (trail vence → QA → MANIFEST).
61
+ * Undatáveis são excluídos com aviso, salvo se marcados como âncora obrigatória.
62
+ */
63
+ function rankByPassDate(targetDir, slugs, { anchor = null } = {}) {
64
+ const manifest = readManifestDates(targetDir);
65
+ const dated = [];
66
+ const undated = [];
67
+ for (const slug of slugs) {
68
+ const d = resolvePassDate(targetDir, slug) || manifest[slug] || null;
69
+ if (d) dated.push({ slug, date: d });
70
+ else if (slug === anchor) dated.push({ slug, date: '' }); // âncora entra mesmo sem data
71
+ else undated.push(slug);
72
+ }
73
+ dated.sort((a, b) => {
74
+ if (a.slug === anchor) return -1;
75
+ if (b.slug === anchor) return 1;
76
+ if (a.date !== b.date) return a.date < b.date ? 1 : -1; // desc
77
+ return a.slug < b.slug ? -1 : 1;
78
+ });
79
+ return { ordered: dated.map((x) => x.slug), undated };
80
+ }
81
+
82
+ async function runHarnessRetro({ args, options = {}, logger, t } = {}) {
83
+ const log = logger || { log() {}, error() {} };
84
+ const targetDir = path.resolve(process.cwd(), (args && args[0]) || '.');
85
+
86
+ const hasFeature = options.feature !== undefined && options.feature !== null
87
+ && options.feature !== true && String(options.feature).length > 0;
88
+ const hasLast = options.last !== undefined && options.last !== null && options.last !== true;
89
+
90
+ // --- Validação de input (antes de qualquer toque no filesystem — REQ-8) ---
91
+ if (!hasFeature && !hasLast) {
92
+ return inputError(log,
93
+ tr(t, 'cli.harnessRetro.need_target', null, 'harness:retro requer --feature=<slug> ou --last=<N>'),
94
+ 'missing_target');
95
+ }
96
+
97
+ let slug = null;
98
+ if (hasFeature) {
99
+ slug = String(options.feature).trim();
100
+ if (!SLUG_RE.test(slug)) {
101
+ return inputError(log,
102
+ tr(t, 'cli.harnessRetro.invalid_slug', { slug }, `Slug inválido: ${slug} (deve casar ^[a-z0-9][a-z0-9-]*$)`),
103
+ 'invalid_slug');
104
+ }
105
+ }
106
+
107
+ let lastN = null;
108
+ if (hasLast) {
109
+ lastN = Number(options.last);
110
+ if (!Number.isInteger(lastN) || lastN < 1) {
111
+ return inputError(log,
112
+ tr(t, 'cli.harnessRetro.invalid_last', { value: String(options.last) }, `Valor inválido para --last: ${options.last} (use inteiro ≥ 1)`),
113
+ 'invalid_last');
114
+ }
115
+ }
116
+
117
+ // --- Resolve modo, janela e arquivo de saída -----------------------------
118
+ let mode;
119
+ let slugs;
120
+ let outRel;
121
+ const warnings = [];
122
+
123
+ if (hasFeature && !hasLast) {
124
+ if (!resolveFeatureExists(targetDir, slug)) {
125
+ return inputError(log,
126
+ tr(t, 'cli.harnessRetro.feature_not_found', { slug },
127
+ `Feature não encontrada: ${slug} (procurado em .aioson/context/, .aioson/plans/${slug}/, .aioson/context/features/${slug}/, .aioson/context/done/${slug}/)`),
128
+ 'feature_not_found');
129
+ }
130
+ mode = 'feature';
131
+ slugs = [slug];
132
+ outRel = path.join('.aioson', 'context', 'retro', `${slug}.md`);
133
+ } else {
134
+ // Qualquer uso de --last produz window-last-{N}.md (D6, modo combinado incluído).
135
+ mode = 'window';
136
+ if (hasFeature && !resolveFeatureExists(targetDir, slug)) {
137
+ return inputError(log,
138
+ tr(t, 'cli.harnessRetro.feature_not_found', { slug },
139
+ `Feature não encontrada: ${slug}`),
140
+ 'feature_not_found');
141
+ }
142
+ const closed = enumerateClosedFeatures(targetDir);
143
+ const pool = hasFeature ? [slug, ...closed.filter((s) => s !== slug)] : closed;
144
+ if (pool.length === 0) {
145
+ return inputError(log,
146
+ tr(t, 'cli.harnessRetro.no_closed_features', null, 'Nenhuma feature fechada em .aioson/context/done/ para minerar'),
147
+ 'no_closed_features');
148
+ }
149
+ const { ordered, undated } = rankByPassDate(targetDir, pool, { anchor: hasFeature ? slug : null });
150
+ if (undated.length > 0) {
151
+ warnings.push(tr(t, 'cli.harnessRetro.undatable_excluded', { count: undated.length, slugs: undated.join(', ') },
152
+ `${undated.length} feature(s) sem data de PASS determinável excluída(s) da janela: ${undated.join(', ')}`));
153
+ }
154
+ if (lastN > ordered.length) {
155
+ warnings.push(tr(t, 'cli.harnessRetro.window_truncated', { n: lastN, available: ordered.length },
156
+ `--last=${lastN} excede features disponíveis (${ordered.length}); minerando todas`));
157
+ }
158
+ slugs = ordered.slice(0, lastN);
159
+ outRel = path.join('.aioson', 'context', 'retro', `window-last-${lastN}.md`);
160
+ }
161
+
162
+ // --- Mineração + agregação + render --------------------------------------
163
+ const sources = collectSources(targetDir, slugs);
164
+ const allWarnings = warnings.concat(sources.warnings);
165
+ const { candidates, observations } = aggregate(sources);
166
+
167
+ const outRelPosix = outRel.replaceAll('\\', '/');
168
+ const generatedAt = new Date().toISOString();
169
+ const markdown = renderDossier({
170
+ mode,
171
+ slug: mode === 'feature' ? slug : undefined,
172
+ windowN: mode === 'window' ? lastN : undefined,
173
+ featuresMined: sources.features_mined,
174
+ counts: sources.counts,
175
+ candidates,
176
+ observations,
177
+ minedPaths: sources.minedPaths,
178
+ warnings: allWarnings,
179
+ dossierRelPath: outRelPosix,
180
+ generatedAt
181
+ });
182
+
183
+ // --- Escrita (única do comando) ------------------------------------------
184
+ const outAbs = path.join(targetDir, outRel);
185
+ try {
186
+ fs.mkdirSync(path.dirname(outAbs), { recursive: true }); // edge 7
187
+ fs.writeFileSync(outAbs, markdown, 'utf8'); // edge 8 (idempotente, sobrescreve)
188
+ } catch (err) {
189
+ log.error(tr(t, 'cli.harnessRetro.io_error', { error: err.message }, `Erro de I/O ao escrever o dossiê: ${err.message}`));
190
+ process.exitCode = EXIT_IO;
191
+ return { ok: false, exitCode: EXIT_IO, error: 'io_error', message: err.message };
192
+ }
193
+
194
+ const report = {
195
+ ok: true,
196
+ exitCode: EXIT_OK,
197
+ mode,
198
+ feature: mode === 'feature' ? slug : null,
199
+ window: mode === 'window' ? `last-${lastN}` : null,
200
+ features_mined: sources.features_mined,
201
+ output: outRelPosix,
202
+ candidates: candidates.length,
203
+ observations: observations.length,
204
+ sources: sources.counts,
205
+ warnings: allWarnings
206
+ };
207
+
208
+ if (candidates.length === 0 && observations.length === 0) {
209
+ log.log(tr(t, 'cli.harnessRetro.empty', { path: outRelPosix },
210
+ `Dossiê gerado sem propostas: ${outRelPosix} (fontes sem trilha minerável)`));
211
+ } else {
212
+ log.log(tr(t, 'cli.harnessRetro.written',
213
+ { path: outRelPosix, candidates: candidates.length, observations: observations.length },
214
+ `Dossiê retrospectivo gerado: ${outRelPosix} (${candidates.length} candidatos, ${observations.length} observações)`));
215
+ }
216
+ for (const w of allWarnings) log.log(` ⚠ ${w}`);
217
+
218
+ return report;
219
+ }
220
+
221
+ module.exports = { runHarnessRetro };
@@ -31,6 +31,7 @@ const { captureBaseline, computeChangedSet, captureDiffPatch } = require('../har
31
31
  const { checkScope, checkDiffLimits, buildRollbackFeedback } = require('../harness/scope-guard');
32
32
  const { estimateTokens, startRunBudget, recordAttemptTokens, checkBudget, buildBudgetSummary } = require('../harness/budget-guard');
33
33
  const { writeAttemptArtifacts } = require('../harness/attempt-artifacts');
34
+ const { previewArtifact } = require('../harness/preview-artifact');
34
35
  const { emitGuardEvent } = require('../harness/guard-events');
35
36
  const { detectGates, createGate, enterHumanGate, resolveGateState, pendingGates, loadGates } = require('../harness/human-gate');
36
37
  const { runCriteria, registerFailureSignatures, startRunSignatures } = require('../harness/criteria-runner');
@@ -293,9 +294,18 @@ async function runPostAttemptGuards({ targetDir, guards, cb, logger, attempt, ag
293
294
  } else if (failed.length > 0) {
294
295
  logger.log(` ✗ Criteria checks falharam: ${failed.map((c) => c.id).join(', ')}`);
295
296
  outcome.reason = 'criteria_check_failed';
297
+ // AC-13: feedback = preview + ponteiro para attempts/{n}/checks/{id}.log
298
+ // (já persistido integralmente por writeAttemptArtifacts acima — persist-first
299
+ // satisfeito pelo fluxo existente). Evita dump integral no contexto do agente.
296
300
  outcome.feedback = failed
297
- .map((c) => `Criterion ${c.id} failed (exit ${c.exitCode}${c.timedOut ? ', timeout' : ''}): ${(c.stderr || c.stdout || '').split('\n').find((l) => l.trim()) || 'no output'}`)
298
- .join('\n');
301
+ .map((c) => {
302
+ const safeId = String(c.id || 'check').replace(/[^A-Za-z0-9._-]/g, '_');
303
+ const logPath = path.join(planDir, 'attempts', String(attempt), 'checks', `${safeId}.log`);
304
+ const raw = `${c.stdout || ''}${c.stderr ? `\n${c.stderr}` : ''}`.trim() || 'no output';
305
+ const { preview } = previewArtifact(raw, { maxBytes: 1024, artifactPath: logPath, persist: false });
306
+ return `Criterion ${c.id} failed (exit ${c.exitCode}${c.timedOut ? ', timeout' : ''}):\n${preview}`;
307
+ })
308
+ .join('\n\n');
299
309
  outcome.issues = [{ message: outcome.feedback }];
300
310
  }
301
311
  }
@@ -40,8 +40,15 @@ const DEFAULT_FEATURE_WORKFLOW_BY_CLASSIFICATION = {
40
40
  };
41
41
 
42
42
  // Stages eligible for autopilot handoff (auto_handoff: true in project.context.md).
43
- // The chain always breaks at the @dev handoff — see .aioson/docs/autopilot-handoff.md.
44
- const AUTOPILOT_HANDOFF_STAGES = new Set(['analyst', 'scope-check', 'architect', 'discovery-design-doc', 'pm']);
43
+ // Two segments — see .aioson/docs/autopilot-handoff.md:
44
+ // 1. analyst dev: deterministic pre-dev chain; STOPS before the first @dev entry
45
+ // (human clears context and starts implementation).
46
+ // 2. post-dev review cycle: @dev → @qa → @tester/@pentester (when their @qa triggers
47
+ // fire) → @validator → STOPS before feature:close (human approves the close).
48
+ const AUTOPILOT_HANDOFF_STAGES = new Set([
49
+ 'analyst', 'scope-check', 'architect', 'discovery-design-doc', 'pm',
50
+ 'dev', 'qa', 'tester', 'pentester', 'validator'
51
+ ]);
45
52
 
46
53
  function normalizeAgentName(input) {
47
54
  return String(input || '')
@@ -1671,6 +1678,7 @@ async function runWorkflowNext({ args, options, logger, t }) {
1671
1678
  }
1672
1679
 
1673
1680
  module.exports = {
1681
+ AUTOPILOT_HANDOFF_STAGES,
1674
1682
  STATE_RELATIVE_PATH,
1675
1683
  CONFIG_RELATIVE_PATH,
1676
1684
  EVENTS_RELATIVE_PATH,
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * `previewArtifact` — preview + ponteiro para outputs grandes (requirements §3.3,
5
+ * REQ-10). Tema 2 (should-have) da Harness Retrospective Optimization.
6
+ *
7
+ * previewArtifact(content, { maxBytes = 8192, artifactPath, label, persist })
8
+ * → { preview, truncated, fullPath, totalBytes }
9
+ *
10
+ * - Persist-first: quando `artifactPath` é dado e `persist !== false`, grava o
11
+ * conteúdo INTEGRAL em disco ANTES de gerar o preview.
12
+ * - `content` ≤ maxBytes → preview = conteúdo integral, `truncated: false`.
13
+ * - `content` > maxBytes → preview = primeiros maxBytes cortados em boundary
14
+ * UTF-8 seguro + linha-ponteiro padrão.
15
+ * - Falha de escrita NÃO lança: retorna preview truncado + `fullPath: null` +
16
+ * aviso (best-effort, mesmo padrão de `attempt-artifacts.js`).
17
+ * - `persist: false` referencia um arquivo já persistido sem reescrevê-lo
18
+ * (modo leitura de `harness:preview`).
19
+ */
20
+
21
+ const fs = require('node:fs');
22
+ const path = require('node:path');
23
+
24
+ const DEFAULT_MAX_BYTES = 8192;
25
+
26
+ /** Corta um Buffer UTF-8 em `maxBytes` sem quebrar caractere multi-byte (edge 10). */
27
+ function safeUtf8Slice(buf, maxBytes) {
28
+ if (buf.length <= maxBytes) return buf.toString('utf8');
29
+ let end = maxBytes;
30
+ // Recua enquanto estiver no meio de uma sequência (bytes de continuação 10xxxxxx).
31
+ while (end > 0 && (buf[end] & 0xc0) === 0x80) end -= 1;
32
+ return buf.slice(0, end).toString('utf8');
33
+ }
34
+
35
+ function previewArtifact(content, options = {}) {
36
+ // Coerção segura: content não-string vira string ('' para null/undefined).
37
+ let text;
38
+ if (typeof content === 'string') {
39
+ text = content;
40
+ } else if (content === null || content === undefined) {
41
+ text = '';
42
+ } else {
43
+ try {
44
+ text = String(content);
45
+ } catch {
46
+ return { preview: '', truncated: false, fullPath: null, totalBytes: 0, warning: 'content não coercível' };
47
+ }
48
+ }
49
+
50
+ let maxBytes = Number(options.maxBytes);
51
+ if (!Number.isInteger(maxBytes) || maxBytes <= 0) maxBytes = DEFAULT_MAX_BYTES;
52
+
53
+ const artifactPath = options.artifactPath || null;
54
+ const persist = options.persist !== false;
55
+
56
+ const buf = Buffer.from(text, 'utf8');
57
+ const totalBytes = buf.length;
58
+
59
+ // Persist-first: grava integral antes de qualquer preview.
60
+ let fullPath = artifactPath;
61
+ let warning = null;
62
+ if (artifactPath && persist) {
63
+ try {
64
+ fs.mkdirSync(path.dirname(artifactPath), { recursive: true });
65
+ fs.writeFileSync(artifactPath, text, 'utf8');
66
+ } catch (err) {
67
+ fullPath = null;
68
+ warning = `falha ao persistir ${artifactPath}: ${err.message}`;
69
+ }
70
+ }
71
+
72
+ if (totalBytes <= maxBytes) {
73
+ return { preview: text, truncated: false, fullPath, totalBytes, ...(warning ? { warning } : {}) };
74
+ }
75
+
76
+ const cut = safeUtf8Slice(buf, maxBytes);
77
+ const pointer = fullPath
78
+ ? `[preview: primeiros ${maxBytes} de ${totalBytes} bytes — completo em ${fullPath}]`
79
+ : `[preview: primeiros ${maxBytes} de ${totalBytes} bytes — conteúdo completo não persistido]`;
80
+ const preview = `${cut}\n${pointer}`;
81
+
82
+ return { preview, truncated: true, fullPath, totalBytes, ...(warning ? { warning } : {}) };
83
+ }
84
+
85
+ module.exports = { previewArtifact, DEFAULT_MAX_BYTES, _internal: { safeUtf8Slice } };
@@ -150,6 +150,27 @@ module.exports = {
150
150
  'aioson harness:init [path] --slug=<slug> [--mode=BALANCED|URGENT|ECONOMICAL] [--locale=en]',
151
151
  help_harness_validate:
152
152
  'aioson harness:validate [path] --slug=<slug> [--artifact=<path>] [--locale=en]',
153
+ help_harness_retro:
154
+ 'aioson harness:retro [path] --feature=<slug> | --last=<N> [--json] [--locale=en]',
155
+ help_harness_preview:
156
+ 'aioson harness:preview <file> [--max-bytes=8192] [--json] [--locale=en]',
157
+ harnessRetro: {
158
+ need_target: 'harness:retro requires --feature=<slug> or --last=<N>.',
159
+ invalid_slug: 'Invalid slug: {slug} (must match ^[a-z0-9][a-z0-9-]*$).',
160
+ invalid_last: 'Invalid --last value: {value} (use an integer >= 1).',
161
+ feature_not_found: 'Feature not found: {slug} (searched .aioson/context/, .aioson/plans/{slug}/, .aioson/context/features/{slug}/, .aioson/context/done/{slug}/).',
162
+ no_closed_features: 'No closed features under .aioson/context/done/ to mine.',
163
+ written: 'Retro dossier written: {path} ({candidates} candidates, {observations} observations).',
164
+ empty: 'Retro dossier written with no proposals: {path} (no minable trail).',
165
+ io_error: 'I/O error writing the dossier: {error}',
166
+ window_truncated: '--last={n} exceeds available features ({available}); mining all of them.',
167
+ undatable_excluded: '{count} feature(s) without a resolvable PASS date excluded from the window: {slugs}'
168
+ },
169
+ harnessPreview: {
170
+ file_required: 'harness:preview requires a <file> path argument.',
171
+ not_found: 'File not found: {path}',
172
+ read_error: 'Could not read file: {path} ({error})'
173
+ },
153
174
  help_web_map:
154
175
  'aioson web:map [path] --url=<url> [--depth=<N>] [--max-pages=<N>] [--include-external] [--json] [--locale=en]',
155
176
  help_web_scrape:
@@ -136,6 +136,27 @@ module.exports = {
136
136
  'aioson qa:scan [path] [--url=<app-url>] [--depth=3] [--max-pages=50] [--headed] [--html] [--json] [--locale=es]',
137
137
  help_qa_report:
138
138
  'aioson qa:report [path] [--html] [--json] [--locale=es]',
139
+ help_harness_retro:
140
+ 'aioson harness:retro [path] --feature=<slug> | --last=<N> [--json] [--locale=es]',
141
+ help_harness_preview:
142
+ 'aioson harness:preview <file> [--max-bytes=8192] [--json] [--locale=es]',
143
+ harnessRetro: {
144
+ need_target: 'harness:retro requiere --feature=<slug> o --last=<N>.',
145
+ invalid_slug: 'Slug inválido: {slug} (debe cumplir ^[a-z0-9][a-z0-9-]*$).',
146
+ invalid_last: 'Valor inválido para --last: {value} (use un entero >= 1).',
147
+ feature_not_found: 'Feature no encontrada: {slug} (buscado en .aioson/context/, .aioson/plans/{slug}/, .aioson/context/features/{slug}/, .aioson/context/done/{slug}/).',
148
+ no_closed_features: 'No hay features cerradas en .aioson/context/done/ para minar.',
149
+ written: 'Dosier retrospectivo generado: {path} ({candidates} candidatos, {observations} observaciones).',
150
+ empty: 'Dosier generado sin propuestas: {path} (fuentes sin rastro minable).',
151
+ io_error: 'Error de E/S al escribir el dosier: {error}',
152
+ window_truncated: '--last={n} supera las features disponibles ({available}); minando todas.',
153
+ undatable_excluded: '{count} feature(s) sin fecha de PASS resoluble excluida(s) de la ventana: {slugs}'
154
+ },
155
+ harnessPreview: {
156
+ file_required: 'harness:preview requiere una ruta de archivo <file>.',
157
+ not_found: 'Archivo no encontrado: {path}',
158
+ read_error: 'No se pudo leer el archivo: {path} ({error})'
159
+ },
139
160
  help_web_map:
140
161
  'aioson web:map [path] --url=<url> [--depth=<N>] [--max-pages=<N>] [--include-external] [--json] [--locale=es]',
141
162
  help_web_scrape:
@@ -136,6 +136,27 @@ module.exports = {
136
136
  'aioson qa:scan [path] [--url=<app-url>] [--depth=3] [--max-pages=50] [--headed] [--html] [--json] [--locale=fr]',
137
137
  help_qa_report:
138
138
  'aioson qa:report [path] [--html] [--json] [--locale=fr]',
139
+ help_harness_retro:
140
+ 'aioson harness:retro [path] --feature=<slug> | --last=<N> [--json] [--locale=fr]',
141
+ help_harness_preview:
142
+ 'aioson harness:preview <file> [--max-bytes=8192] [--json] [--locale=fr]',
143
+ harnessRetro: {
144
+ need_target: 'harness:retro requiert --feature=<slug> ou --last=<N>.',
145
+ invalid_slug: 'Slug invalide : {slug} (doit correspondre à ^[a-z0-9][a-z0-9-]*$).',
146
+ invalid_last: 'Valeur --last invalide : {value} (utilisez un entier >= 1).',
147
+ feature_not_found: 'Feature introuvable : {slug} (cherché dans .aioson/context/, .aioson/plans/{slug}/, .aioson/context/features/{slug}/, .aioson/context/done/{slug}/).',
148
+ no_closed_features: 'Aucune feature clôturée dans .aioson/context/done/ à miner.',
149
+ written: 'Dossier rétrospectif généré : {path} ({candidates} candidats, {observations} observations).',
150
+ empty: 'Dossier généré sans propositions : {path} (sources sans piste exploitable).',
151
+ io_error: 'Erreur d’E/S lors de l’écriture du dossier : {error}',
152
+ window_truncated: '--last={n} dépasse les features disponibles ({available}) ; minage de toutes.',
153
+ undatable_excluded: '{count} feature(s) sans date de PASS résoluble exclue(s) de la fenêtre : {slugs}'
154
+ },
155
+ harnessPreview: {
156
+ file_required: 'harness:preview requiert un chemin de fichier <file>.',
157
+ not_found: 'Fichier introuvable : {path}',
158
+ read_error: 'Impossible de lire le fichier : {path} ({error})'
159
+ },
139
160
  help_web_map:
140
161
  'aioson web:map [path] --url=<url> [--depth=<N>] [--max-pages=<N>] [--include-external] [--json] [--locale=fr]',
141
162
  help_web_scrape:
@@ -152,6 +152,27 @@ module.exports = {
152
152
  'aioson harness:init [path] --slug=<slug> [--mode=BALANCED|URGENT|ECONOMICAL] [--locale=pt-BR]',
153
153
  help_harness_validate:
154
154
  'aioson harness:validate [path] --slug=<slug> [--artifact=<path>] [--locale=pt-BR]',
155
+ help_harness_retro:
156
+ 'aioson harness:retro [path] --feature=<slug> | --last=<N> [--json] [--locale=pt-BR]',
157
+ help_harness_preview:
158
+ 'aioson harness:preview <file> [--max-bytes=8192] [--json] [--locale=pt-BR]',
159
+ harnessRetro: {
160
+ need_target: 'harness:retro requer --feature=<slug> ou --last=<N>.',
161
+ invalid_slug: 'Slug inválido: {slug} (deve casar ^[a-z0-9][a-z0-9-]*$).',
162
+ invalid_last: 'Valor inválido para --last: {value} (use inteiro >= 1).',
163
+ feature_not_found: 'Feature não encontrada: {slug} (procurado em .aioson/context/, .aioson/plans/{slug}/, .aioson/context/features/{slug}/, .aioson/context/done/{slug}/).',
164
+ no_closed_features: 'Nenhuma feature fechada em .aioson/context/done/ para minerar.',
165
+ written: 'Dossiê retrospectivo gerado: {path} ({candidates} candidatos, {observations} observações).',
166
+ empty: 'Dossiê gerado sem propostas: {path} (fontes sem trilha minerável).',
167
+ io_error: 'Erro de I/O ao escrever o dossiê: {error}',
168
+ window_truncated: '--last={n} excede features disponíveis ({available}); minerando todas.',
169
+ undatable_excluded: '{count} feature(s) sem data de PASS determinável excluída(s) da janela: {slugs}'
170
+ },
171
+ harnessPreview: {
172
+ file_required: 'harness:preview requer um caminho de arquivo <file>.',
173
+ not_found: 'Arquivo não encontrado: {path}',
174
+ read_error: 'Não foi possível ler o arquivo: {path} ({error})'
175
+ },
155
176
  help_web_map:
156
177
  'aioson web:map [path] --url=<url> [--depth=<N>] [--max-pages=<N>] [--include-external] [--json] [--locale=pt-BR]',
157
178
  help_web_scrape: