@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.
- package/CHANGELOG.md +18 -4
- package/package.json +1 -1
- package/src/agents.js +21 -20
- package/src/cli.js +15 -0
- package/src/commands/feature-close.js +40 -0
- package/src/commands/gate-check.js +8 -3
- package/src/commands/git-guard.js +58 -0
- package/src/commands/harness-gate.js +120 -0
- package/src/commands/harness-status.js +157 -0
- package/src/commands/harness.js +18 -1
- package/src/commands/self-implement-loop.js +305 -5
- package/src/commands/workflow-next.js +37 -2
- package/src/doctor.js +24 -8
- package/src/harness/active-contract.js +41 -0
- package/src/harness/attempt-artifacts.js +95 -0
- package/src/harness/budget-guard.js +127 -0
- package/src/harness/circuit-breaker.js +7 -0
- package/src/harness/contract-schema.js +324 -0
- package/src/harness/criteria-runner.js +136 -0
- package/src/harness/git-baseline.js +204 -0
- package/src/harness/glob-match.js +126 -0
- package/src/harness/guard-events.js +71 -0
- package/src/harness/human-gate.js +182 -0
- package/src/harness/scope-guard.js +115 -0
- package/src/i18n/messages/en.js +2 -0
- package/src/i18n/messages/es.js +11 -9
- package/src/i18n/messages/fr.js +11 -9
- package/src/i18n/messages/pt-BR.js +2 -0
- package/src/lib/dev-resume.js +94 -45
- package/src/preflight-engine.js +88 -84
- package/template/.aioson/agents/analyst.md +4 -0
- package/template/.aioson/agents/architect.md +4 -0
- package/template/.aioson/agents/dev.md +3 -1
- package/template/.aioson/agents/discovery-design-doc.md +4 -0
- package/template/.aioson/agents/pm.md +10 -5
- package/template/.aioson/agents/qa.md +22 -14
- package/template/.aioson/agents/scope-check.md +176 -172
- package/template/.aioson/config.md +31 -28
- package/template/.aioson/docs/autopilot-handoff.md +46 -0
- package/template/AGENTS.md +57 -57
- 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
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
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
+
};
|
package/src/commands/harness.js
CHANGED
|
@@ -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();
|