@jaimevalasek/aioson 1.21.7 → 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 (105) hide show
  1. package/CHANGELOG.md +39 -2
  2. package/docs/en/1-understand/ecosystem-map.md +1 -1
  3. package/docs/en/2-start/initial-decisions.md +1 -1
  4. package/docs/en/4-agents/README.md +8 -7
  5. package/docs/en/4-agents/discovery-design-doc.md +150 -0
  6. package/docs/en/5-reference/cli-reference.md +42 -16
  7. package/docs/en/README.md +2 -2
  8. package/docs/pt/4-agentes/README.md +8 -6
  9. package/docs/pt/4-agentes/briefing-refiner.md +122 -0
  10. package/docs/pt/4-agentes/discovery-design-doc.md +133 -74
  11. package/docs/pt/4-agentes/scope-check.md +65 -0
  12. package/docs/pt/5-referencia/README.md +1 -0
  13. package/docs/pt/5-referencia/comandos-cli.md +5 -4
  14. package/docs/pt/5-referencia/feature-archive.md +1 -0
  15. package/docs/pt/5-referencia/feature-export.md +155 -0
  16. package/docs/pt/README.md +2 -2
  17. package/docs/pt/agentes.md +3 -1
  18. package/package.json +1 -1
  19. package/src/agent-manifests.js +14 -3
  20. package/src/agents.js +21 -20
  21. package/src/cli.js +72 -52
  22. package/src/commands/briefing.js +28 -150
  23. package/src/commands/commit-prepare.js +5 -2
  24. package/src/commands/feature-archive.js +48 -12
  25. package/src/commands/feature-close.js +40 -0
  26. package/src/commands/feature-export.js +242 -0
  27. package/src/commands/gate-check.js +8 -3
  28. package/src/commands/git-guard.js +58 -0
  29. package/src/commands/harness-gate.js +120 -0
  30. package/src/commands/harness-status.js +157 -0
  31. package/src/commands/harness.js +18 -1
  32. package/src/commands/live.js +120 -115
  33. package/src/commands/parallel-doctor.js +2 -1
  34. package/src/commands/pulse-update.js +2 -2
  35. package/src/commands/scan-project.js +12 -2
  36. package/src/commands/self-implement-loop.js +305 -5
  37. package/src/commands/workflow-next.js +477 -425
  38. package/src/constants.js +21 -11
  39. package/src/context-search.js +3 -0
  40. package/src/doctor.js +24 -8
  41. package/src/dossier/schema.js +4 -3
  42. package/src/harness/active-contract.js +41 -0
  43. package/src/harness/attempt-artifacts.js +95 -0
  44. package/src/harness/budget-guard.js +127 -0
  45. package/src/harness/circuit-breaker.js +7 -0
  46. package/src/harness/contract-schema.js +324 -0
  47. package/src/harness/criteria-runner.js +136 -0
  48. package/src/harness/git-baseline.js +204 -0
  49. package/src/harness/glob-match.js +126 -0
  50. package/src/harness/guard-events.js +71 -0
  51. package/src/harness/human-gate.js +182 -0
  52. package/src/harness/scope-guard.js +115 -0
  53. package/src/i18n/messages/en.js +24 -21
  54. package/src/i18n/messages/es.js +11 -9
  55. package/src/i18n/messages/fr.js +11 -9
  56. package/src/i18n/messages/pt-BR.js +24 -21
  57. package/src/lib/briefing-refiner/apply-feedback.js +134 -0
  58. package/src/lib/briefing-refiner/briefing-paths.js +41 -0
  59. package/src/lib/briefing-refiner/briefing-registry.js +204 -0
  60. package/src/lib/briefing-refiner/briefing-sections.js +110 -0
  61. package/src/lib/briefing-refiner/feedback-schema.js +122 -0
  62. package/src/lib/briefing-refiner/refinement-report.js +39 -0
  63. package/src/lib/briefing-refiner/review-html.js +230 -0
  64. package/src/lib/dev-resume.js +94 -45
  65. package/src/parser.js +8 -5
  66. package/src/preflight-engine.js +88 -84
  67. package/src/runtime-store.js +2 -0
  68. package/src/sandbox.js +17 -3
  69. package/template/.aioson/agents/analyst.md +27 -23
  70. package/template/.aioson/agents/architect.md +7 -3
  71. package/template/.aioson/agents/briefing-refiner.md +121 -0
  72. package/template/.aioson/agents/briefing.md +83 -74
  73. package/template/.aioson/agents/committer.md +8 -0
  74. package/template/.aioson/agents/copywriter.md +19 -7
  75. package/template/.aioson/agents/design-hybrid-forge.md +16 -5
  76. package/template/.aioson/agents/dev.md +68 -66
  77. package/template/.aioson/agents/deyvin.md +97 -90
  78. package/template/.aioson/agents/discover.md +2 -2
  79. package/template/.aioson/agents/discovery-design-doc.md +34 -30
  80. package/template/.aioson/agents/genome.md +82 -71
  81. package/template/.aioson/agents/neo.md +11 -3
  82. package/template/.aioson/agents/orache.md +10 -0
  83. package/template/.aioson/agents/orchestrator.md +68 -68
  84. package/template/.aioson/agents/pentester.md +15 -6
  85. package/template/.aioson/agents/pm.md +30 -25
  86. package/template/.aioson/agents/product.md +108 -108
  87. package/template/.aioson/agents/profiler-enricher.md +10 -0
  88. package/template/.aioson/agents/profiler-forge.md +10 -0
  89. package/template/.aioson/agents/profiler-researcher.md +11 -0
  90. package/template/.aioson/agents/qa.md +28 -20
  91. package/template/.aioson/agents/scope-check.md +176 -164
  92. package/template/.aioson/agents/setup.md +11 -1
  93. package/template/.aioson/agents/sheldon.md +38 -38
  94. package/template/.aioson/agents/site-forge.md +15 -6
  95. package/template/.aioson/agents/squad.md +12 -0
  96. package/template/.aioson/agents/tester.md +209 -209
  97. package/template/.aioson/agents/ux-ui.md +2 -2
  98. package/template/.aioson/agents/validator.md +10 -2
  99. package/template/.aioson/config.md +31 -28
  100. package/template/.aioson/docs/autopilot-handoff.md +46 -0
  101. package/template/.aioson/docs/dossier/agent-templates.md +191 -0
  102. package/template/.aioson/docs/dossier/schema.md +218 -0
  103. package/template/.claude/commands/aioson/agent/briefing-refiner.md +17 -0
  104. package/template/AGENTS.md +50 -47
  105. package/template/CLAUDE.md +29 -27
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * aioson feature:export — copy every artefact of a feature into a clean output
5
+ * directory, leaving the source tree untouched.
6
+ *
7
+ * Sibling of feature:archive, but COPY (not move) to an arbitrary --out. Turns
8
+ * AIOSON's markdown output into a portable deliverable: read/analyse the specs
9
+ * outside the project, hand them to a client, or use AIOSON purely as a spec
10
+ * generator. Works for both active features (artefacts in context/ root + slug
11
+ * dirs) and already-archived ones (context/done/{slug}).
12
+ *
13
+ * Usage:
14
+ * aioson feature:export . --feature=checkout
15
+ * aioson feature:export . --feature=checkout --out=../checkout-specs
16
+ * aioson feature:export . --feature=checkout --flatten
17
+ * aioson feature:export . --feature=checkout --no-index
18
+ * aioson feature:export . --feature=checkout --dry-run --json
19
+ */
20
+
21
+ const fs = require('node:fs/promises');
22
+ const path = require('node:path');
23
+ const { contextDir } = require('../preflight-engine');
24
+ const { collectFeatureArtifacts } = require('./feature-archive');
25
+
26
+ async function dirExists(dirPath) {
27
+ try {
28
+ return (await fs.stat(dirPath)).isDirectory();
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Recursively list every file under `dir`, returning paths relative to `base`.
36
+ */
37
+ async function walkFiles(dir, base = dir) {
38
+ const out = [];
39
+ let entries;
40
+ try {
41
+ entries = await fs.readdir(dir, { withFileTypes: true });
42
+ } catch {
43
+ return out;
44
+ }
45
+ for (const entry of entries) {
46
+ const abs = path.join(dir, entry.name);
47
+ if (entry.isDirectory()) {
48
+ // eslint-disable-next-line no-await-in-loop
49
+ out.push(...await walkFiles(abs, base));
50
+ } else if (entry.isFile()) {
51
+ out.push(path.relative(base, abs));
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ function toPosix(p) {
58
+ return p.split(path.sep).join('/');
59
+ }
60
+
61
+ /**
62
+ * Build the flat list of {srcAbs, relDest, group} to copy. relDest is the path
63
+ * relative to the output dir (mirrored structure). Caller may flatten later.
64
+ */
65
+ async function buildEntries({ ctxDir, rootFiles, dirs, doneDir }) {
66
+ const entries = [];
67
+
68
+ for (const name of rootFiles) {
69
+ entries.push({ srcAbs: path.join(ctxDir, name), relDest: name, group: 'context' });
70
+ }
71
+
72
+ for (const d of dirs) {
73
+ // eslint-disable-next-line no-await-in-loop
74
+ const files = await walkFiles(d.sourceDir);
75
+ for (const rel of files) {
76
+ entries.push({
77
+ srcAbs: path.join(d.sourceDir, rel),
78
+ relDest: path.join(d.label, rel),
79
+ group: d.label
80
+ });
81
+ }
82
+ }
83
+
84
+ if (doneDir) {
85
+ const files = await walkFiles(doneDir);
86
+ for (const rel of files) {
87
+ entries.push({
88
+ srcAbs: path.join(doneDir, rel),
89
+ relDest: path.join('done', rel),
90
+ group: 'done'
91
+ });
92
+ }
93
+ }
94
+
95
+ return entries;
96
+ }
97
+
98
+ function applyFlatten(entries) {
99
+ // Collapse subdir structure into a single level. Root files (no separator)
100
+ // keep their name; nested files become `label-...-file.ext`, which is
101
+ // collision-free by construction since it encodes the full source path.
102
+ for (const e of entries) {
103
+ e.relDest = e.relDest.split(/[/\\]/).join('-');
104
+ }
105
+ return entries;
106
+ }
107
+
108
+ function renderIndex({ slug, entries, targetDir, exportedAt }) {
109
+ const lines = [
110
+ `# Feature Export — ${slug}`,
111
+ '',
112
+ `> ${entries.length} file(s) copied from AIOSON on ${exportedAt}.`,
113
+ '> Non-destructive snapshot — the original artefacts were left untouched.',
114
+ '',
115
+ '| group | file | source |',
116
+ '|-------|------|--------|'
117
+ ];
118
+ const sorted = [...entries].sort((a, b) => {
119
+ if (a.group !== b.group) return a.group.localeCompare(b.group);
120
+ return a.relDest.localeCompare(b.relDest);
121
+ });
122
+ for (const e of sorted) {
123
+ const source = toPosix(path.relative(targetDir, e.srcAbs));
124
+ lines.push(`| ${e.group} | ${toPosix(e.relDest)} | ${source} |`);
125
+ }
126
+ lines.push('');
127
+ return lines.join('\n');
128
+ }
129
+
130
+ async function runFeatureExport({ args = [], options = {}, logger }) {
131
+ const targetDir = path.resolve(process.cwd(), args[0] || '.');
132
+ const slug = options.feature ? String(options.feature) : null;
133
+ const flatten = Boolean(options.flatten);
134
+ const noIndex = Boolean(options['no-index'] || options.noIndex);
135
+ const dryRun = Boolean(options['dry-run'] || options.dryRun);
136
+ const jsonOut = Boolean(options.json);
137
+
138
+ const log = (msg) => { if (logger && !jsonOut) logger.log(msg); };
139
+
140
+ if (!slug) {
141
+ if (jsonOut) return { ok: false, reason: 'missing_feature' };
142
+ log('--feature=<slug> is required.');
143
+ return { ok: false };
144
+ }
145
+
146
+ if (!/^[a-z][a-z0-9-]*$/i.test(slug)) {
147
+ if (jsonOut) return { ok: false, reason: 'invalid_slug' };
148
+ log(`Invalid slug "${slug}" — use lowercase letters, digits and hyphens only.`);
149
+ return { ok: false };
150
+ }
151
+
152
+ const ctxDir = contextDir(targetDir);
153
+ if (!(await dirExists(ctxDir))) {
154
+ if (jsonOut) return { ok: false, reason: 'no_context_dir' };
155
+ log(`.aioson/context/ not found at ${targetDir}. Run aioson setup first.`);
156
+ return { ok: false };
157
+ }
158
+
159
+ const outDir = options.out
160
+ ? path.resolve(process.cwd(), String(options.out))
161
+ : path.join(targetDir, `${slug}-export`);
162
+
163
+ const { rootFiles, dirs, doneDir } = await collectFeatureArtifacts({
164
+ ctxDir, targetDir, slug, includeDone: true
165
+ });
166
+
167
+ let entries = await buildEntries({ ctxDir, rootFiles, dirs, doneDir });
168
+ if (flatten) entries = applyFlatten(entries);
169
+
170
+ if (entries.length === 0) {
171
+ if (jsonOut) return { ok: true, slug, exported: [], noop: true };
172
+ log(`No artefacts matched "*-${slug}.{md,yaml,yml,json}" in .aioson/context/, no slug directories (features/plans/briefings), and nothing under context/done/${slug}/ — nothing to export.`);
173
+ return { ok: true, slug, noop: true };
174
+ }
175
+
176
+ const relOut = toPosix(path.relative(targetDir, outDir)) || outDir;
177
+
178
+ if (dryRun) {
179
+ const result = {
180
+ ok: true,
181
+ dryRun: true,
182
+ slug,
183
+ outDir: relOut,
184
+ flatten,
185
+ index: !noIndex,
186
+ count: entries.length,
187
+ files: entries.map((e) => toPosix(e.relDest))
188
+ };
189
+ if (jsonOut) return result;
190
+ log(`[dry-run] feature:export — ${slug}:`);
191
+ log(` out: ${relOut}/ (${flatten ? 'flattened' : 'mirrored'})`);
192
+ log(` would copy: ${entries.length} file(s)`);
193
+ for (const e of [...entries].sort((a, b) => a.relDest.localeCompare(b.relDest))) {
194
+ log(` • ${toPosix(e.relDest)}`);
195
+ }
196
+ if (!noIndex) log(' would write: INDEX.md');
197
+ return result;
198
+ }
199
+
200
+ await fs.mkdir(outDir, { recursive: true });
201
+ const copied = [];
202
+ for (const e of entries) {
203
+ const dest = path.join(outDir, e.relDest);
204
+ // eslint-disable-next-line no-await-in-loop
205
+ await fs.mkdir(path.dirname(dest), { recursive: true });
206
+ // eslint-disable-next-line no-await-in-loop
207
+ await fs.copyFile(e.srcAbs, dest);
208
+ copied.push(toPosix(e.relDest));
209
+ }
210
+
211
+ let indexWritten = false;
212
+ if (!noIndex) {
213
+ const exportedAt = new Date().toISOString().slice(0, 10);
214
+ await fs.writeFile(
215
+ path.join(outDir, 'INDEX.md'),
216
+ renderIndex({ slug, entries, targetDir, exportedAt }),
217
+ 'utf8'
218
+ );
219
+ indexWritten = true;
220
+ }
221
+
222
+ const result = {
223
+ ok: true,
224
+ slug,
225
+ outDir: relOut,
226
+ flatten,
227
+ count: copied.length,
228
+ copied,
229
+ index: indexWritten
230
+ };
231
+
232
+ if (jsonOut) return result;
233
+ log(`feature:export — ${slug}:`);
234
+ log(` out: ${relOut}/ (${flatten ? 'flattened' : 'mirrored'})`);
235
+ log(` copied: ${copied.length} file(s)`);
236
+ for (const f of [...copied].sort()) log(` • ${f}`);
237
+ if (indexWritten) log(` index: ${relOut}/INDEX.md`);
238
+ log(' source tree untouched (non-destructive copy).');
239
+ return result;
240
+ }
241
+
242
+ module.exports = { runFeatureExport };
@@ -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();