@jaimevalasek/aioson 1.21.8 → 1.22.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 (41) hide show
  1. package/CHANGELOG.md +18 -4
  2. package/package.json +1 -1
  3. package/src/agents.js +21 -20
  4. package/src/cli.js +15 -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-status.js +157 -0
  10. package/src/commands/harness.js +18 -1
  11. package/src/commands/self-implement-loop.js +305 -5
  12. package/src/commands/workflow-next.js +37 -2
  13. package/src/doctor.js +24 -8
  14. package/src/harness/active-contract.js +41 -0
  15. package/src/harness/attempt-artifacts.js +95 -0
  16. package/src/harness/budget-guard.js +127 -0
  17. package/src/harness/circuit-breaker.js +7 -0
  18. package/src/harness/contract-schema.js +324 -0
  19. package/src/harness/criteria-runner.js +136 -0
  20. package/src/harness/git-baseline.js +204 -0
  21. package/src/harness/glob-match.js +126 -0
  22. package/src/harness/guard-events.js +71 -0
  23. package/src/harness/human-gate.js +182 -0
  24. package/src/harness/scope-guard.js +115 -0
  25. package/src/i18n/messages/en.js +2 -0
  26. package/src/i18n/messages/es.js +11 -9
  27. package/src/i18n/messages/fr.js +11 -9
  28. package/src/i18n/messages/pt-BR.js +2 -0
  29. package/src/lib/dev-resume.js +94 -45
  30. package/src/preflight-engine.js +88 -84
  31. package/template/.aioson/agents/analyst.md +4 -0
  32. package/template/.aioson/agents/architect.md +4 -0
  33. package/template/.aioson/agents/dev.md +3 -1
  34. package/template/.aioson/agents/discovery-design-doc.md +4 -0
  35. package/template/.aioson/agents/pm.md +10 -5
  36. package/template/.aioson/agents/qa.md +22 -14
  37. package/template/.aioson/agents/scope-check.md +176 -172
  38. package/template/.aioson/config.md +31 -28
  39. package/template/.aioson/docs/autopilot-handoff.md +46 -0
  40. package/template/AGENTS.md +57 -57
  41. package/template/CLAUDE.md +33 -33
package/CHANGELOG.md CHANGED
@@ -1,10 +1,24 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to this project will be documented in this file.
4
-
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [1.22.0] - 2026-06-10
6
+
7
+ ### Added
8
+ - **Loop guardrails for self-implementation harnesses.** `self:loop` now runs from an active contract with schema validation, active-contract discovery, scope enforcement, budget ceilings, attempt artifacts, criteria verification, failure-signature escalation, and human approval gates.
9
+ - **Harness gate/status commands.** Added `harness:status` and human gate approval/rejection flows so paused loop work can be inspected, resumed, or explicitly blocked without losing context.
10
+ - **Contract-aware git guard integration.** `git:guard` now merges declared `forbidden_files` from the active harness contract while preserving safe human commit behavior for lockfiles unless the contract explicitly forbids them.
11
+
12
+ ### Changed
13
+ - **Dev/QA prompts now understand loop guardrails.** Workspace and template agent prompts were updated so implementation, QA, correction loops, and handoffs consume the new guarded harness model consistently.
14
+ - **Loop-guardrails feature artifacts are now durable.** PRD, requirements, readiness, design, scope-check, Sheldon enrichment, dossier, corrections plan, and progress artifacts were recorded under `.aioson/context/` and `.aioson/plans/`.
15
+
16
+ ### Tests
17
+ - Added regression coverage for contract schema validation, glob matching, scope guard behavior, budget enforcement, criteria execution, human gates, active-contract discovery, git guard contract merging, and self-loop guardrails.
18
+
5
19
  ## [1.21.8] - 2026-06-08
6
-
7
- ### Added
20
+
21
+ ### Added
8
22
  - **`feature:export` — copy a feature's artefacts to a clean output directory.** Non-destructive sibling of `feature:archive`: instead of *moving* artefacts into `.aioson/context/done/{slug}/`, it *copies* the full surface (root `*-{slug}.{md,yaml,yml,json}` minus global files, the per-slug `dossier/`/`plans/`/`briefings/` directories, and `context/done/{slug}/` when archived) into an arbitrary `--out` (default `<target>/{slug}-export`), leaving the source tree untouched. Flags: `--flatten` (collapse to one level), `--no-index` (skip the generated `INDEX.md` manifest), `--dry-run`, `--json`. Reuses the archive's slug-collision guard via the new exported `collectFeatureArtifacts` helper, so a sibling slug (`checkout-v2`) never leaks into a `checkout` export. No `features.md` status guard — works on in-progress features too. Turns AIOSON's markdown output into a portable deliverable. Docs: `docs/pt/5-referencia/feature-export.md` + `docs/en/5-reference/cli-reference.md`.
9
23
 
10
24
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaimevalasek/aioson",
3
- "version": "1.21.8",
3
+ "version": "1.22.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, never past the `@dev` handoff.' : ''}`,
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
@@ -399,6 +399,12 @@ const JSON_SUPPORTED_COMMANDS = new Set([
399
399
  'harness-validate',
400
400
  'harness:apply-validation',
401
401
  'harness-apply-validation',
402
+ 'harness:approve',
403
+ 'harness-approve',
404
+ 'harness:reject',
405
+ 'harness-reject',
406
+ 'harness:status',
407
+ 'harness-status',
402
408
  'brief-gen',
403
409
  'verify:gate',
404
410
  'verify-gate',
@@ -1256,6 +1262,15 @@ async function main() {
1256
1262
  result = await runHarnessValidate({ args, options, logger: commandLogger, t });
1257
1263
  } else if (command === 'harness:apply-validation' || command === 'harness-apply-validation') {
1258
1264
  result = await runHarnessApplyValidation({ args, options, logger: commandLogger, t });
1265
+ } else if (command === 'harness:approve' || command === 'harness-approve') {
1266
+ const { runHarnessApprove } = require('./commands/harness-gate');
1267
+ result = await runHarnessApprove({ args, options, logger: commandLogger, t });
1268
+ } else if (command === 'harness:reject' || command === 'harness-reject') {
1269
+ const { runHarnessReject } = require('./commands/harness-gate');
1270
+ result = await runHarnessReject({ args, options, logger: commandLogger, t });
1271
+ } else if (command === 'harness:status' || command === 'harness-status') {
1272
+ const { runHarnessStatus } = require('./commands/harness-status');
1273
+ result = await runHarnessStatus({ args, options, logger: commandLogger, t });
1259
1274
  } else if (command === 'verify:gate' || command === 'verify-gate') {
1260
1275
  result = await runVerifyGate({ args, options, logger: commandLogger, t });
1261
1276
 
@@ -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,157 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * aioson harness:status . --slug=X [--json] — visibilidade do estado do loop
5
+ * (loop-guardrails REQ-18).
6
+ *
7
+ * Agrega: circuito, iteração N/M, budget, checks da última tentativa,
8
+ * última falha, gates pendentes e a próxima ação. Escopo distinto de
9
+ * `spec:status` (planos+learnings) — referenciado no rodapé.
10
+ */
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+
15
+ const { resolveContract, validateContract } = require('../harness/contract-schema');
16
+ const { pendingGates } = require('../harness/human-gate');
17
+
18
+ function readJson(file) {
19
+ try {
20
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ /** Última tentativa = maior diretório numérico em attempts/. */
27
+ function latestAttempt(planDir) {
28
+ const dir = path.join(planDir, 'attempts');
29
+ if (!fs.existsSync(dir)) return null;
30
+ const numbers = fs.readdirSync(dir)
31
+ .map((name) => Number(name))
32
+ .filter((n) => Number.isInteger(n) && n > 0);
33
+ return numbers.length ? Math.max(...numbers) : null;
34
+ }
35
+
36
+ /** Conta checks pass/fail da tentativa pelos logs `# exit_code:`. */
37
+ function readChecks(planDir, attempt) {
38
+ const checksDir = path.join(planDir, 'attempts', String(attempt), 'checks');
39
+ if (!fs.existsSync(checksDir)) return { total: 0, passed: 0, failed: 0, failed_ids: [] };
40
+ const summary = { total: 0, passed: 0, failed: 0, failed_ids: [] };
41
+ for (const file of fs.readdirSync(checksDir)) {
42
+ if (!file.endsWith('.log')) continue;
43
+ summary.total += 1;
44
+ try {
45
+ const content = fs.readFileSync(path.join(checksDir, file), 'utf8');
46
+ const match = content.match(/^# exit_code: (.+)$/m);
47
+ if (match && match[1].trim() === '0') {
48
+ summary.passed += 1;
49
+ } else {
50
+ summary.failed += 1;
51
+ summary.failed_ids.push(file.replace(/\.log$/, ''));
52
+ }
53
+ } catch {
54
+ summary.failed += 1;
55
+ }
56
+ }
57
+ return summary;
58
+ }
59
+
60
+ function nextAction(progress, pending, slug) {
61
+ if (pending.length > 0) {
62
+ return `aioson harness:approve . --slug=${slug} --gate=${pending[0].id} (ou harness:reject --reason="...")`;
63
+ }
64
+ const status = progress?.status || 'unknown';
65
+ if (status === 'circuit_open' || progress?.circuit_state === 'OPEN') {
66
+ return 'circuito aberto — revisar last_error e corrigir antes de novo run';
67
+ }
68
+ if (status === 'waiting_validation') {
69
+ return `aioson harness:validate . --slug=${slug}`;
70
+ }
71
+ if (progress?.ready_for_done_gate) {
72
+ return `pronto para o done gate — aioson feature:close . --feature=${slug}`;
73
+ }
74
+ return `re-executar self:loop para continuar (iteração persiste em progress.json)`;
75
+ }
76
+
77
+ async function runHarnessStatus({ args, options = {}, logger }) {
78
+ const targetDir = path.resolve(process.cwd(), args?.[0] || '.');
79
+ const slug = String(options.slug || '').trim();
80
+
81
+ if (!slug) {
82
+ logger.error('Error: --slug is required');
83
+ return { ok: false, error: 'missing_slug' };
84
+ }
85
+
86
+ const planDir = path.join(targetDir, '.aioson', 'plans', slug);
87
+ const contract = readJson(path.join(planDir, 'harness-contract.json'));
88
+ if (!contract) {
89
+ logger.error(`Contract not found for slug: ${slug}`);
90
+ return { ok: false, error: 'contract_not_found' };
91
+ }
92
+
93
+ const progress = readJson(path.join(planDir, 'progress.json'));
94
+ const schema = validateContract(contract);
95
+ const resolved = schema.ok ? resolveContract(contract) : null;
96
+ const pending = pendingGates(planDir);
97
+ const attempt = latestAttempt(planDir);
98
+ const checks = attempt ? readChecks(planDir, attempt) : { total: 0, passed: 0, failed: 0, failed_ids: [] };
99
+
100
+ const maxSteps = resolved ? resolved.governor.max_steps : (contract.governor && contract.governor.max_steps) || null;
101
+ const ceiling = resolved ? (resolved.governor.cost_ceiling_tokens ?? null) : null;
102
+ const budget = progress?.budget || null;
103
+
104
+ const report = {
105
+ ok: true,
106
+ slug,
107
+ contract_mode: contract.contract_mode || 'BALANCED',
108
+ contract_schema_ok: schema.ok,
109
+ circuit_state: progress?.circuit_state || 'CLOSED',
110
+ status: progress?.status || 'unknown',
111
+ iterations: progress?.iterations ?? 0,
112
+ max_steps: maxSteps,
113
+ budget: budget ? {
114
+ tokens_estimated: budget.tokens_estimated || 0,
115
+ cost_ceiling_tokens: ceiling,
116
+ run_id: budget.run_id || null,
117
+ run_started_at: budget.run_started_at || null
118
+ } : null,
119
+ last_attempt: attempt,
120
+ checks,
121
+ last_error: progress?.last_error || null,
122
+ pending_gates: pending.map((g) => ({ id: g.id, theme: g.theme, attempt: g.attempt, requested_at: g.requested_at })),
123
+ next_action: nextAction(progress, pending, slug)
124
+ };
125
+
126
+ if (options.json) {
127
+ logger.log(JSON.stringify(report, null, 2));
128
+ return report;
129
+ }
130
+
131
+ logger.log(`Harness status — ${slug}`);
132
+ logger.log(` Mode: ${report.contract_mode}${schema.ok ? '' : ' (⚠ contract schema invalid)'}`);
133
+ logger.log(` Circuit: ${report.circuit_state} | status: ${report.status}`);
134
+ logger.log(` Iteration: ${report.iterations}${maxSteps ? `/${maxSteps}` : ''}`);
135
+ if (budget) {
136
+ logger.log(` Budget: ${report.budget.tokens_estimated} tokens estimados${ceiling ? ` / ${ceiling} (${Math.round((report.budget.tokens_estimated / ceiling) * 100)}%)` : ' (sem teto)'}`);
137
+ }
138
+ if (attempt) {
139
+ logger.log(` Last attempt: ${attempt} — checks ${checks.passed}/${checks.total} pass${checks.failed ? ` (failed: ${checks.failed_ids.join(', ')})` : ''}`);
140
+ }
141
+ if (report.last_error) logger.log(` Last error: ${report.last_error}`);
142
+ if (pending.length > 0) {
143
+ logger.log(` ⛔ Pending gates (${pending.length}):`);
144
+ for (const gate of pending) {
145
+ logger.log(` - ${gate.id} [${gate.theme}] attempt ${gate.attempt}`);
146
+ }
147
+ }
148
+ logger.log(` Next: ${report.next_action}`);
149
+ logger.log('');
150
+ logger.log(' Planos e learnings: aioson spec:status');
151
+
152
+ return report;
153
+ }
154
+
155
+ module.exports = {
156
+ runHarnessStatus
157
+ };
@@ -3,6 +3,7 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const { createCircuitBreaker } = require('../harness/circuit-breaker');
6
+ const { validateContract } = require('../harness/contract-schema');
6
7
 
7
8
  /**
8
9
  * aioson harness:init — Inicializa o contrato e progresso da feature.
@@ -36,8 +37,17 @@ async function runHarnessInit({ args, options = {}, logger, t }) {
36
37
  governor: {
37
38
  max_steps: 50,
38
39
  error_streak_limit: 5,
39
- cost_ceiling_tokens: null
40
+ cost_ceiling_tokens: null,
41
+ max_runtime_minutes: null,
42
+ max_changed_files: null,
43
+ max_diff_lines: null
40
44
  },
45
+ // Scope guard (loop-guardrails): allowed_files ausente = sem allowlist;
46
+ // forbidden_files é SEMPRE mesclado com os defaults embutidos (.env*, *.pem,
47
+ // *.key, secrets/**, .git/**, node_modules/**, lockfiles) — não-removíveis.
48
+ forbidden_files: [],
49
+ // human_gate ausente = nenhum gate (retrocompat). Exemplo:
50
+ // "human_gate": { "required_for": ["payment_logic_change", "publish"] }
41
51
  criteria: [
42
52
  {
43
53
  id: "C1",
@@ -48,6 +58,13 @@ async function runHarnessInit({ args, options = {}, logger, t }) {
48
58
  ]
49
59
  };
50
60
 
61
+ const schemaResult = validateContract(contract);
62
+ if (!schemaResult.ok) {
63
+ const first = schemaResult.errors[0];
64
+ logger.error(`Contract schema invalid: ${first.field} — ${first.reason}`);
65
+ return { ok: false, error: 'contract_schema_invalid', errors: schemaResult.errors };
66
+ }
67
+
51
68
  const cb = createCircuitBreaker(contractPath, progressPath);
52
69
  fs.writeFileSync(contractPath, JSON.stringify(contract, null, 2), 'utf8');
53
70
  await cb.load();