@jaimevalasek/aioson 1.21.8 → 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.
Files changed (52) hide show
  1. package/CHANGELOG.md +950 -923
  2. package/package.json +1 -1
  3. package/src/agents.js +21 -20
  4. package/src/cli.js +31 -0
  5. package/src/commands/feature-close.js +40 -0
  6. package/src/commands/gate-check.js +8 -3
  7. package/src/commands/git-guard.js +58 -0
  8. package/src/commands/harness-gate.js +120 -0
  9. package/src/commands/harness-preview.js +74 -0
  10. package/src/commands/harness-retro.js +221 -0
  11. package/src/commands/harness-status.js +157 -0
  12. package/src/commands/harness.js +18 -1
  13. package/src/commands/self-implement-loop.js +315 -5
  14. package/src/commands/workflow-next.js +45 -2
  15. package/src/doctor.js +24 -8
  16. package/src/harness/active-contract.js +41 -0
  17. package/src/harness/attempt-artifacts.js +95 -0
  18. package/src/harness/budget-guard.js +127 -0
  19. package/src/harness/circuit-breaker.js +7 -0
  20. package/src/harness/contract-schema.js +324 -0
  21. package/src/harness/criteria-runner.js +136 -0
  22. package/src/harness/git-baseline.js +204 -0
  23. package/src/harness/glob-match.js +126 -0
  24. package/src/harness/guard-events.js +71 -0
  25. package/src/harness/human-gate.js +182 -0
  26. package/src/harness/preview-artifact.js +85 -0
  27. package/src/harness/scope-guard.js +115 -0
  28. package/src/i18n/messages/en.js +23 -0
  29. package/src/i18n/messages/es.js +32 -9
  30. package/src/i18n/messages/fr.js +32 -9
  31. package/src/i18n/messages/pt-BR.js +23 -0
  32. package/src/lib/dev-resume.js +94 -45
  33. package/src/lib/retro/retro-aggregate.js +192 -0
  34. package/src/lib/retro/retro-render.js +185 -0
  35. package/src/lib/retro/retro-sources.js +624 -0
  36. package/src/preflight-engine.js +88 -84
  37. package/template/.aioson/agents/analyst.md +4 -0
  38. package/template/.aioson/agents/architect.md +4 -0
  39. package/template/.aioson/agents/dev.md +14 -1
  40. package/template/.aioson/agents/discovery-design-doc.md +4 -0
  41. package/template/.aioson/agents/pentester.md +8 -0
  42. package/template/.aioson/agents/pm.md +10 -5
  43. package/template/.aioson/agents/qa.md +46 -14
  44. package/template/.aioson/agents/scope-check.md +176 -172
  45. package/template/.aioson/agents/sheldon.md +13 -0
  46. package/template/.aioson/agents/tester.md +17 -0
  47. package/template/.aioson/agents/validator.md +8 -0
  48. package/template/.aioson/config.md +31 -28
  49. package/template/.aioson/docs/autopilot-handoff.md +83 -0
  50. package/template/.aioson/rules/aioson-context-boundary.md +10 -8
  51. package/template/AGENTS.md +57 -57
  52. package/template/CLAUDE.md +33 -33
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaimevalasek/aioson",
3
- "version": "1.21.8",
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
@@ -29,15 +29,16 @@ function buildAgentPrompt(agent, tool, options = {}) {
29
29
  const safeTool = String(tool || 'codex').toLowerCase();
30
30
  const instructionPath = options.instructionPath || agent.path;
31
31
  const targetDir = options.targetDir ? String(options.targetDir) : '.';
32
- const interactionLanguage = String(options.interactionLanguage || 'en');
33
- const autonomyMode = String(options.autonomyMode || '').trim();
34
- const capabilitySummary = String(options.capabilitySummary || '').trim();
35
- const activationContext = String(options.activationContext || '').trim();
36
- const dependsOn = Array.isArray(options.dependsOn) ? options.dependsOn : agent.dependsOn;
37
- const dependencyText =
38
- dependsOn.length > 0
39
- ? `Check required context files first: ${dependsOn.join(', ')}.`
40
- : 'No prerequisite context files are required.';
32
+ const interactionLanguage = String(options.interactionLanguage || 'en');
33
+ const autonomyMode = String(options.autonomyMode || '').trim();
34
+ const autoHandoff = options.autoHandoff === true;
35
+ const capabilitySummary = String(options.capabilitySummary || '').trim();
36
+ const activationContext = String(options.activationContext || '').trim();
37
+ const dependsOn = Array.isArray(options.dependsOn) ? options.dependsOn : agent.dependsOn;
38
+ const dependencyText =
39
+ dependsOn.length > 0
40
+ ? `Check required context files first: ${dependsOn.join(', ')}.`
41
+ : 'No prerequisite context files are required.';
41
42
  const activationBlock = activationContext
42
43
  ? [
43
44
  '',
@@ -66,19 +67,19 @@ function buildAgentPrompt(agent, tool, options = {}) {
66
67
  '',
67
68
  `**Language boundary:** Agent instructions are canonical in English. All user-facing communication must be in ${interactionLanguage}.`,
68
69
  '',
69
- `**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.`,
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.' : ''}`,
70
71
  ].join('\n');
71
72
 
72
- if (safeTool === 'claude') {
73
- return `Read ${instructionPath} and execute ${agent.command}. ${dependencyText}${activationBlock}\n\nWrite output to ${agent.output}.${autonomyBlock}${lifecycleBlock}`;
74
- }
75
-
76
- if (safeTool === 'opencode') {
77
- return `Use agent "${agent.id}" from ${instructionPath}. ${dependencyText}${activationBlock}\n\nSave output to ${agent.output}.${autonomyBlock}${lifecycleBlock}`;
78
- }
79
-
80
- return `Read AGENTS.md and execute ${agent.command} using ${instructionPath}. ${dependencyText}${activationBlock}\n\nSave output to ${agent.output}.${autonomyBlock}${lifecycleBlock}`;
81
- }
73
+ if (safeTool === 'claude') {
74
+ return `Read ${instructionPath} and execute ${agent.command}. ${dependencyText}${activationBlock}\n\nWrite output to ${agent.output}.${autonomyBlock}${lifecycleBlock}`;
75
+ }
76
+
77
+ if (safeTool === 'opencode') {
78
+ return `Use agent "${agent.id}" from ${instructionPath}. ${dependencyText}${activationBlock}\n\nSave output to ${agent.output}.${autonomyBlock}${lifecycleBlock}`;
79
+ }
80
+
81
+ return `Read AGENTS.md and execute ${agent.command} using ${instructionPath}. ${dependencyText}${activationBlock}\n\nSave output to ${agent.output}.${autonomyBlock}${lifecycleBlock}`;
82
+ }
82
83
 
83
84
  module.exports = {
84
85
  normalizeAgentName,
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',
@@ -399,6 +403,16 @@ const JSON_SUPPORTED_COMMANDS = new Set([
399
403
  'harness-validate',
400
404
  'harness:apply-validation',
401
405
  'harness-apply-validation',
406
+ 'harness:approve',
407
+ 'harness-approve',
408
+ 'harness:reject',
409
+ 'harness-reject',
410
+ 'harness:status',
411
+ 'harness-status',
412
+ 'harness:retro',
413
+ 'harness-retro',
414
+ 'harness:preview',
415
+ 'harness-preview',
402
416
  'brief-gen',
403
417
  'verify:gate',
404
418
  'verify-gate',
@@ -811,6 +825,8 @@ function printHelp(t, logger) {
811
825
  logHelpLine(t, logger, 'cli.help_qa_run');
812
826
  logHelpLine(t, logger, 'cli.help_qa_scan');
813
827
  logHelpLine(t, logger, 'cli.help_qa_report');
828
+ logHelpLine(t, logger, 'cli.help_harness_retro');
829
+ logHelpLine(t, logger, 'cli.help_harness_preview');
814
830
  logHelpLine(t, logger, 'cli.help_web_map');
815
831
  logHelpLine(t, logger, 'cli.help_web_scrape');
816
832
  logHelpLine(t, logger, 'cli.help_scan_project');
@@ -1256,6 +1272,21 @@ async function main() {
1256
1272
  result = await runHarnessValidate({ args, options, logger: commandLogger, t });
1257
1273
  } else if (command === 'harness:apply-validation' || command === 'harness-apply-validation') {
1258
1274
  result = await runHarnessApplyValidation({ args, options, logger: commandLogger, t });
1275
+ } else if (command === 'harness:approve' || command === 'harness-approve') {
1276
+ const { runHarnessApprove } = require('./commands/harness-gate');
1277
+ result = await runHarnessApprove({ args, options, logger: commandLogger, t });
1278
+ } else if (command === 'harness:reject' || command === 'harness-reject') {
1279
+ const { runHarnessReject } = require('./commands/harness-gate');
1280
+ result = await runHarnessReject({ args, options, logger: commandLogger, t });
1281
+ } else if (command === 'harness:status' || command === 'harness-status') {
1282
+ const { runHarnessStatus } = require('./commands/harness-status');
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 });
1259
1290
  } else if (command === 'verify:gate' || command === 'verify-gate') {
1260
1291
  result = await runVerifyGate({ args, options, logger: commandLogger, t });
1261
1292
 
@@ -333,6 +333,46 @@ async function runFeatureClose({ args, options = {}, logger }) {
333
333
  const contractContent = await readFileSafe(contractPath);
334
334
  const progressContent = await readFileSafe(progressPath);
335
335
 
336
+ // REQ-13 (loop-guardrails): tema `publish` é gate de COMANDO — intercepta
337
+ // o feature:close quando o contrato ativo o exige e não há gate publish
338
+ // aprovado. Nunca detectado por diff. `--force` NÃO bypassa: a aprovação
339
+ // humana é o propósito do gate (decisão registrada no spec da feature).
340
+ if (contractContent) {
341
+ try {
342
+ const contract = JSON.parse(contractContent);
343
+ const requiredFor = contract && contract.human_gate && Array.isArray(contract.human_gate.required_for)
344
+ ? contract.human_gate.required_for
345
+ : [];
346
+ if (requiredFor.includes('publish')) {
347
+ const { hasApprovedPublishGate, pendingGates, createGate } = require('../harness/human-gate');
348
+ const { emitGuardEvent } = require('../harness/guard-events');
349
+ if (!hasApprovedPublishGate(planDir)) {
350
+ let gate = pendingGates(planDir).find((g) => g.theme === 'publish');
351
+ if (!gate) {
352
+ gate = createGate(planDir, {
353
+ theme: 'publish',
354
+ attempt: 0,
355
+ triggeredBy: [],
356
+ diffSummary: `feature:close ${slug}`,
357
+ runId: null
358
+ });
359
+ await emitGuardEvent(targetDir, {
360
+ eventType: 'human_gate_requested',
361
+ message: `publish gate ${gate.id} requested by feature:close`,
362
+ payload: { slug, gate_id: gate.id, theme: 'publish' }
363
+ });
364
+ }
365
+ const errMsg = `[Publish Gate BLOCKED] Feature "${slug}" requires human approval before closing (human_gate.required_for includes "publish"). Approve with: aioson harness:approve . --slug=${slug} --gate=${gate.id}`;
366
+ if (options.json) {
367
+ return { ok: false, reason: 'publish_gate_pending', feature: slug, gate: gate.id, error: errMsg };
368
+ }
369
+ logger.log(errMsg);
370
+ return { ok: false };
371
+ }
372
+ }
373
+ } catch { /* contrato ilegível — o done gate abaixo lida com o estado */ }
374
+ }
375
+
336
376
  if (contractContent && progressContent) {
337
377
  const force = options.force === true;
338
378
  let progress = null;
@@ -36,7 +36,10 @@ const GATE_PREREQUISITES = {
36
36
  const GATE_REQUIRED_ARTIFACTS = {
37
37
  A: (slug) => [`requirements-${slug}.md`],
38
38
  B: (slug) => ['architecture.md'],
39
- C: (slug) => [`implementation-plan-${slug}.md`],
39
+ // implementation-plan-{slug}.md is a MEDIUM-only artifact (@pm owns it,
40
+ // AC-SDLC-15/16). SMALL/MICRO sequences never route through @pm, so
41
+ // requiring it unconditionally dead-ends Gate C for those features.
42
+ C: (slug, classification) => (classification === 'MEDIUM' ? [`implementation-plan-${slug}.md`] : []),
40
43
  D: (slug) => [] // Gate D validated by QA sign-off in spec
41
44
  };
42
45
 
@@ -75,7 +78,7 @@ async function checkGate(targetDir, slug, gateLetter) {
75
78
  }
76
79
 
77
80
  // Check required artifacts
78
- const requiredFiles = GATE_REQUIRED_ARTIFACTS[gateLetter](slug);
81
+ const requiredFiles = GATE_REQUIRED_ARTIFACTS[gateLetter](slug, classification);
79
82
  for (const fileName of requiredFiles) {
80
83
  const filePath = path.join(dir, fileName);
81
84
  const exists = await fileExists(filePath);
@@ -150,7 +153,9 @@ async function checkGate(targetDir, slug, gateLetter) {
150
153
  const fixAgents = {
151
154
  A: `activate @analyst to produce requirements-${slug}.md, then run: aioson gate:approve . --feature=${slug} --gate=A`,
152
155
  B: `activate @architect to produce architecture.md, then run: aioson gate:approve . --feature=${slug} --gate=B`,
153
- C: `activate @pm to produce and approve implementation-plan-${slug}.md, then run: aioson gate:approve . --feature=${slug} --gate=C`,
156
+ C: classification === 'MEDIUM'
157
+ ? `activate @pm to produce and approve implementation-plan-${slug}.md, then run: aioson gate:approve . --feature=${slug} --gate=C`
158
+ : `approve Gates A and B first, then run: aioson gate:approve . --feature=${slug} --gate=C`,
154
159
  D: `activate @qa for final verification; if QA passes, run: aioson gate:approve . --feature=${slug} --gate=D`
155
160
  };
156
161
  recommendation = `BLOCKED — ${fixAgents[gateLetter] || 'resolve missing items'}`;
@@ -12,12 +12,64 @@
12
12
  */
13
13
 
14
14
  const path = require('node:path');
15
+ const fs = require('node:fs');
15
16
  const {
16
17
  inspectStagedChanges,
17
18
  installPreCommitHook,
18
19
  uninstallPreCommitHook
19
20
  } = require('../lib/git-commit-guard');
20
21
 
22
+ /**
23
+ * REQ-20 (loop-guardrails, should-have): mescla `forbidden_files` do contrato
24
+ * ATIVO (progress `in_progress`/`human_gate` mais recente) na verificação do
25
+ * guard em tempo de execução — camada 2 do scope guard no pre-commit.
26
+ * Best-effort: nunca quebra o guard; contrato inválido é ignorado (o preflight
27
+ * do self:loop já bloqueia o loop nesse caso). Paths `.aioson/**` ficam fora
28
+ * (estado do framework precisa ser commitável).
29
+ *
30
+ * C-03 (QA 2026-06-09): nesta camada aplicam-se apenas os globs DECLARADOS no
31
+ * contrato. Os defaults não-removíveis (lockfiles, node_modules, .env*, ...)
32
+ * existem para conter o LOOP do agente; no pre-commit pegariam mudanças
33
+ * humanas legítimas (ex.: package-lock.json após `npm install`). Segredos
34
+ * (.env*, *.pem, *.key, secrets/**) continuam bloqueados pela policy baseline
35
+ * do próprio git-guard.
36
+ */
37
+ const { findActiveContract } = require('../harness/active-contract');
38
+
39
+ function applyActiveContractPolicy(targetDir, result) {
40
+ const active = findActiveContract(targetDir);
41
+ if (!active) return null;
42
+ const { validateContract } = require('../harness/contract-schema');
43
+ const { matchGlob, matchAny } = require('../harness/glob-match');
44
+ const contract = JSON.parse(fs.readFileSync(active.contractPath, 'utf8'));
45
+ if (!validateContract(contract).ok) return null;
46
+ const declaredForbidden = Array.isArray(contract.forbidden_files) ? contract.forbidden_files : [];
47
+ if (!declaredForbidden.length) return { slug: active.slug, findings: 0 };
48
+
49
+ let added = 0;
50
+ for (const file of result.files) {
51
+ if (matchGlob('.aioson/**', file.path)) continue;
52
+ const matched = matchAny(declaredForbidden, file.path);
53
+ if (!matched) continue;
54
+ const finding = {
55
+ type: 'path',
56
+ severity: 'error',
57
+ id: 'contract_forbidden_file',
58
+ path: file.path,
59
+ reason: `matches forbidden glob "${matched}" from active harness contract "${active.slug}"`,
60
+ line: null
61
+ };
62
+ file.findings.push(finding);
63
+ result.errors.push(finding);
64
+ added += 1;
65
+ }
66
+ if (added > 0) {
67
+ result.ok = false;
68
+ result.summary.errorCount = result.errors.length;
69
+ }
70
+ return { slug: active.slug, findings: added };
71
+ }
72
+
21
73
  function formatFinding(prefix, finding) {
22
74
  const line = finding.line ? `:${finding.line}` : '';
23
75
  return `${prefix} ${finding.path}${line} — ${finding.reason} [${finding.id}]`;
@@ -111,9 +163,15 @@ async function runGitGuard({ args, options = {}, logger }) {
111
163
  return failure;
112
164
  }
113
165
 
166
+ let contractPolicy = null;
167
+ try {
168
+ contractPolicy = applyActiveContractPolicy(targetDir, result);
169
+ } catch { /* best-effort: contrato ilegível nunca quebra o guard */ }
170
+
114
171
  const output = {
115
172
  ok: result.ok,
116
173
  projectDir: targetDir,
174
+ contractPolicy,
117
175
  gitRoot: result.gitRoot,
118
176
  strict: result.strict,
119
177
  policy: result.policy,
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * aioson harness:approve / harness:reject — decisão humana de gates do
5
+ * self:loop (loop-guardrails REQ-14/15).
6
+ *
7
+ * - Exigem --slug e --gate; reject exige --reason.
8
+ * - Decisão persiste em `.aioson/plans/{slug}/gates/{id}.json`
9
+ * (decided_at/decided_by/reason) e emite `human_gate_decision`.
10
+ * - Gate já decidido = no-op idempotente com aviso (REQ-14).
11
+ * - Gate inexistente = erro explícito sem efeito colateral (EC-8).
12
+ * - Sem pendências restantes → progress.status volta a `in_progress`;
13
+ * re-executar `self:loop` retoma do ponto persistido (REQ-15).
14
+ */
15
+
16
+ const fs = require('node:fs');
17
+ const path = require('node:path');
18
+ const { execFileSync } = require('node:child_process');
19
+
20
+ const { decideGate, resolveGateState, pendingGates } = require('../harness/human-gate');
21
+ const { emitGuardEvent } = require('../harness/guard-events');
22
+
23
+ function gitUserName(targetDir) {
24
+ try {
25
+ return execFileSync('git', ['config', 'user.name'], {
26
+ cwd: targetDir,
27
+ encoding: 'utf8',
28
+ stdio: ['ignore', 'pipe', 'pipe']
29
+ }).trim() || null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ async function runGateDecision(decision, { args, options = {}, logger }) {
36
+ const targetDir = path.resolve(process.cwd(), args?.[0] || '.');
37
+ const slug = String(options.slug || '').trim();
38
+ const gateId = String(options.gate || '').trim();
39
+ const reason = options.reason ? String(options.reason).trim() : null;
40
+ const decidedBy = options.by ? String(options.by).trim() : gitUserName(targetDir);
41
+
42
+ if (!slug) {
43
+ logger.error('Error: --slug is required');
44
+ return { ok: false, error: 'missing_slug' };
45
+ }
46
+ if (!gateId) {
47
+ logger.error('Error: --gate is required (gate id, e.g. payment_logic_change-1)');
48
+ return { ok: false, error: 'missing_gate' };
49
+ }
50
+
51
+ const planDir = path.join(targetDir, '.aioson', 'plans', slug);
52
+ if (!fs.existsSync(path.join(planDir, 'harness-contract.json'))) {
53
+ logger.error(`Contract not found for slug: ${slug}`);
54
+ return { ok: false, error: 'contract_not_found' };
55
+ }
56
+
57
+ const result = decideGate(planDir, gateId, { decision, by: decidedBy, reason });
58
+
59
+ if (!result.ok) {
60
+ const messages = {
61
+ gate_not_found: `Gate not found: ${gateId} — nothing to ${decision === 'approved' ? 'approve' : 'reject'} (no side effects)`,
62
+ reason_required_on_reject: 'Error: --reason is required when rejecting a gate',
63
+ gate_corrupted: `Gate file is corrupted: ${gateId}`,
64
+ invalid_decision: `Invalid decision: ${decision}`
65
+ };
66
+ logger.error(messages[result.error] || `Error: ${result.error}`);
67
+ return { ok: false, error: result.error, gateId };
68
+ }
69
+
70
+ if (result.idempotent) {
71
+ logger.log(`• Gate ${gateId} already decided (${result.gate.status} at ${result.gate.decided_at}) — no-op`);
72
+ return { ok: true, idempotent: true, gate: result.gate };
73
+ }
74
+
75
+ // reconcilia progress.json: remove decidido de pending_gates; sem pendências
76
+ // restantes → status volta a in_progress (retomada idempotente / auditoria)
77
+ const progressPath = path.join(planDir, 'progress.json');
78
+ let remaining = [];
79
+ try {
80
+ if (fs.existsSync(progressPath)) {
81
+ const progress = JSON.parse(fs.readFileSync(progressPath, 'utf8'));
82
+ resolveGateState(progress, planDir);
83
+ fs.writeFileSync(progressPath, JSON.stringify(progress, null, 2), 'utf8');
84
+ remaining = progress.pending_gates || [];
85
+ } else {
86
+ remaining = pendingGates(planDir).map((g) => g.id);
87
+ }
88
+ } catch { /* progress corrompido — decisão do gate já persistiu */ }
89
+
90
+ await emitGuardEvent(targetDir, {
91
+ eventType: 'human_gate_decision',
92
+ message: `gate ${gateId} ${decision} by ${decidedBy || 'unknown'}`,
93
+ payload: { slug, gate_id: gateId, theme: result.gate.theme, decision, decided_by: decidedBy, reason }
94
+ });
95
+
96
+ const mark = decision === 'approved' ? '✓' : '✗';
97
+ logger.log(`${mark} Gate ${gateId} ${decision}${decidedBy ? ` by ${decidedBy}` : ''}${reason ? ` — ${reason}` : ''}`);
98
+ if (remaining.length > 0) {
99
+ logger.log(` Pending gates remaining: ${remaining.join(', ')}`);
100
+ } else if (decision === 'approved') {
101
+ logger.log(` No pending gates — re-run self:loop to resume from the persisted state.`);
102
+ } else {
103
+ logger.log(` Run ended. A new self:loop starts a fresh run (rejected gate is kept as audit).`);
104
+ }
105
+
106
+ return { ok: true, gate: result.gate, pending_gates: remaining };
107
+ }
108
+
109
+ async function runHarnessApprove(ctx) {
110
+ return runGateDecision('approved', ctx);
111
+ }
112
+
113
+ async function runHarnessReject(ctx) {
114
+ return runGateDecision('rejected', ctx);
115
+ }
116
+
117
+ module.exports = {
118
+ runHarnessApprove,
119
+ runHarnessReject
120
+ };
@@ -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 };