@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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Orçamento do self:loop (loop-guardrails REQ-7/8 + D3).
|
|
5
|
+
*
|
|
6
|
+
* Módulo puro: opera sobre o objeto `progress` (de progress.json) e devolve
|
|
7
|
+
* eventos a emitir + decisão de pausa. O wiring persiste e emite.
|
|
8
|
+
*
|
|
9
|
+
* Fonte de enforcement é o acumulador `progress.budget` — nunca SQLite no hot
|
|
10
|
+
* path (D3). `execution_events.token_count` é só telemetria. "Run atual" =
|
|
11
|
+
* acumulador zerado a cada run novo (EC-10: legados null irrelevantes).
|
|
12
|
+
*
|
|
13
|
+
* Estimativa: chars/4 sobre o output do agente (erro 5-15% aceito pelo PRD;
|
|
14
|
+
* `tokenx` é upgrade path documentado).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Estimativa heurística chars/4 (REQ-7). */
|
|
18
|
+
function estimateTokens(text) {
|
|
19
|
+
if (!text) return 0;
|
|
20
|
+
return Math.ceil(String(text).length / 4);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Inicializa o acumulador de budget para um run NOVO (preflight, passo 4).
|
|
25
|
+
* Zera tokens e flags; muta `progress` (caller persiste).
|
|
26
|
+
*/
|
|
27
|
+
function startRunBudget(progress, runId) {
|
|
28
|
+
progress.budget = {
|
|
29
|
+
tokens_estimated: 0,
|
|
30
|
+
warned_80: false,
|
|
31
|
+
run_started_at: new Date().toISOString(),
|
|
32
|
+
run_id: runId
|
|
33
|
+
};
|
|
34
|
+
return progress.budget;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Garante que progress.budget existe (retomada de run antigo sem o campo). */
|
|
38
|
+
function ensureBudget(progress, runId) {
|
|
39
|
+
if (!progress.budget || typeof progress.budget !== 'object') {
|
|
40
|
+
startRunBudget(progress, runId);
|
|
41
|
+
}
|
|
42
|
+
return progress.budget;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Acumula a estimativa da tentativa; muta `progress` (caller persiste). */
|
|
46
|
+
function recordAttemptTokens(progress, tokens) {
|
|
47
|
+
ensureBudget(progress, null);
|
|
48
|
+
progress.budget.tokens_estimated += Math.max(0, Math.round(tokens) || 0);
|
|
49
|
+
return progress.budget.tokens_estimated;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Política 80/100% (REQ-7) + max_runtime_minutes na fronteira (REQ-8).
|
|
54
|
+
*
|
|
55
|
+
* EC-11: 80% e 100% cruzados na mesma tentativa → AMBOS os eventos em ordem,
|
|
56
|
+
* pausa uma vez. `warned_80` garante o warning 1x por run; muta `progress`.
|
|
57
|
+
*
|
|
58
|
+
* @returns {{ ok, pause, events: [{type, message, payload}] }}
|
|
59
|
+
*/
|
|
60
|
+
function checkBudget(progress, { costCeilingTokens = null, maxRuntimeMinutes = null, now = null } = {}) {
|
|
61
|
+
const events = [];
|
|
62
|
+
let pause = false;
|
|
63
|
+
const budget = ensureBudget(progress, null);
|
|
64
|
+
|
|
65
|
+
if (Number.isInteger(costCeilingTokens) && costCeilingTokens > 0) {
|
|
66
|
+
const spent = budget.tokens_estimated;
|
|
67
|
+
const pct = spent / costCeilingTokens;
|
|
68
|
+
|
|
69
|
+
if (pct >= 0.8 && !budget.warned_80) {
|
|
70
|
+
budget.warned_80 = true;
|
|
71
|
+
events.push({
|
|
72
|
+
type: 'budget_warning',
|
|
73
|
+
message: `token budget at ${Math.round(pct * 100)}% (${spent}/${costCeilingTokens} estimated)`,
|
|
74
|
+
payload: { tokens_estimated: spent, cost_ceiling_tokens: costCeilingTokens, pct: Math.round(pct * 100) }
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (pct >= 1) {
|
|
79
|
+
events.push({
|
|
80
|
+
type: 'budget_exceeded',
|
|
81
|
+
message: `token budget exceeded (${spent}/${costCeilingTokens} estimated) — pausing loop`,
|
|
82
|
+
payload: { tokens_estimated: spent, cost_ceiling_tokens: costCeilingTokens }
|
|
83
|
+
});
|
|
84
|
+
pause = true;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (Number.isInteger(maxRuntimeMinutes) && maxRuntimeMinutes > 0 && budget.run_started_at) {
|
|
89
|
+
const startedAt = Date.parse(budget.run_started_at);
|
|
90
|
+
const current = now ? Date.parse(now) : Date.now();
|
|
91
|
+
if (Number.isFinite(startedAt) && current - startedAt > maxRuntimeMinutes * 60000) {
|
|
92
|
+
const elapsedMin = Math.round((current - startedAt) / 60000);
|
|
93
|
+
events.push({
|
|
94
|
+
type: 'runtime_exceeded',
|
|
95
|
+
message: `max_runtime_minutes exceeded (${elapsedMin}min > ${maxRuntimeMinutes}min) — pausing loop`,
|
|
96
|
+
payload: { elapsed_minutes: elapsedMin, max_runtime_minutes: maxRuntimeMinutes }
|
|
97
|
+
});
|
|
98
|
+
pause = true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { ok: !pause, pause, events };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Resumo feito/faltante para a pausa de 100% (REQ-7).
|
|
107
|
+
*/
|
|
108
|
+
function buildBudgetSummary(progress, { maxIterations = null } = {}) {
|
|
109
|
+
const budget = progress.budget || {};
|
|
110
|
+
const iterations = progress.iterations || 0;
|
|
111
|
+
return [
|
|
112
|
+
`Budget pause summary:`,
|
|
113
|
+
` iterations completed: ${iterations}${maxIterations ? `/${maxIterations}` : ''}`,
|
|
114
|
+
` tokens estimated (chars/4): ${budget.tokens_estimated || 0}`,
|
|
115
|
+
` run started at: ${budget.run_started_at || 'unknown'}`,
|
|
116
|
+
` resume: review scope/budget in harness-contract.json, then re-run self:loop`
|
|
117
|
+
].join('\n');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
estimateTokens,
|
|
122
|
+
startRunBudget,
|
|
123
|
+
ensureBudget,
|
|
124
|
+
recordAttemptTokens,
|
|
125
|
+
checkBudget,
|
|
126
|
+
buildBudgetSummary
|
|
127
|
+
};
|
|
@@ -47,6 +47,13 @@ class CircuitBreaker {
|
|
|
47
47
|
const { circuit_state, iterations, consecutive_errors } = this.progress;
|
|
48
48
|
const { max_steps, error_streak_limit } = this.contract.governor;
|
|
49
49
|
|
|
50
|
+
// HUMAN_GATE (loop-guardrails D4): gate humano pendente nega execução até
|
|
51
|
+
// decisão via harness:approve / harness:reject (REQ-12/15).
|
|
52
|
+
const pendingGates = Array.isArray(this.progress.pending_gates) ? this.progress.pending_gates : [];
|
|
53
|
+
if (this.progress.status === 'human_gate' || pendingGates.length > 0) {
|
|
54
|
+
return { allowed: false, reason: 'human_gate_pending' };
|
|
55
|
+
}
|
|
56
|
+
|
|
50
57
|
if (circuit_state === 'OPEN') {
|
|
51
58
|
return { allowed: false, reason: 'circuit_open' };
|
|
52
59
|
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validação de schema do harness-contract.json (loop-guardrails REQ-1).
|
|
5
|
+
*
|
|
6
|
+
* Responsabilidade distinta de `harness:validate` (que valida a IMPLEMENTAÇÃO
|
|
7
|
+
* contra criteria via @validator). Aqui valida-se o CONTRATO em si, no
|
|
8
|
+
* preflight do `self:loop` — um typo em `allowed_files` não pode desligar o
|
|
9
|
+
* scope guard silenciosamente. Mensagens usam "contract schema invalid",
|
|
10
|
+
* nunca "validation verdict".
|
|
11
|
+
*
|
|
12
|
+
* Retrocompat (REQ-11 / EC-12): contratos antigos (feature/contract_mode/
|
|
13
|
+
* governor/criteria) passam; campos novos são todos opcionais.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { validateGlobPattern } = require('./glob-match');
|
|
17
|
+
|
|
18
|
+
/** REQ-4 — defaults proibidos, sempre aplicados e não-removíveis (SEC-SBD-05). */
|
|
19
|
+
const DEFAULT_FORBIDDEN_GLOBS = Object.freeze([
|
|
20
|
+
'.env*',
|
|
21
|
+
'*.pem',
|
|
22
|
+
'*.key',
|
|
23
|
+
'secrets/**',
|
|
24
|
+
'.git/**',
|
|
25
|
+
'node_modules/**',
|
|
26
|
+
'package-lock.json',
|
|
27
|
+
'yarn.lock',
|
|
28
|
+
'pnpm-lock.yaml',
|
|
29
|
+
'npm-shrinkwrap.json',
|
|
30
|
+
'bun.lockb'
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const HUMAN_GATE_THEMES = Object.freeze([
|
|
34
|
+
'payment_logic_change',
|
|
35
|
+
'auth_permission_change',
|
|
36
|
+
'database_destructive_change',
|
|
37
|
+
'publish'
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/** Mapa default tema→globs (requirements §2.1); override via human_gate.themes[].paths. */
|
|
41
|
+
const DEFAULT_THEME_PATHS = Object.freeze({
|
|
42
|
+
payment_logic_change: Object.freeze(['**/billing/**', '**/payment/**']),
|
|
43
|
+
auth_permission_change: Object.freeze(['**/auth/**']),
|
|
44
|
+
database_destructive_change: Object.freeze(['**/migrations/**']),
|
|
45
|
+
publish: Object.freeze([]) // gate de comando (REQ-13), nunca diff
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Presets do contract_mode (REQ-19). Preenchem apenas valores do governor
|
|
50
|
+
* NÃO definidos explicitamente no contrato; valor explícito sempre vence.
|
|
51
|
+
* `BALANCED` mantém o comportamento atual (nenhum preenchimento).
|
|
52
|
+
*/
|
|
53
|
+
const CONTRACT_PRESETS = Object.freeze({
|
|
54
|
+
safe: Object.freeze({
|
|
55
|
+
max_steps: 10,
|
|
56
|
+
error_streak_limit: 3,
|
|
57
|
+
cost_ceiling_tokens: 200000,
|
|
58
|
+
max_runtime_minutes: 30,
|
|
59
|
+
max_changed_files: 20,
|
|
60
|
+
max_diff_lines: 1500
|
|
61
|
+
}),
|
|
62
|
+
builder: Object.freeze({
|
|
63
|
+
max_steps: 30,
|
|
64
|
+
error_streak_limit: 5,
|
|
65
|
+
cost_ceiling_tokens: 1000000,
|
|
66
|
+
max_runtime_minutes: 120,
|
|
67
|
+
max_changed_files: 60,
|
|
68
|
+
max_diff_lines: 6000
|
|
69
|
+
}),
|
|
70
|
+
autopilot: Object.freeze({
|
|
71
|
+
max_steps: 50,
|
|
72
|
+
error_streak_limit: 8,
|
|
73
|
+
cost_ceiling_tokens: 3000000,
|
|
74
|
+
max_runtime_minutes: 360,
|
|
75
|
+
max_changed_files: null,
|
|
76
|
+
max_diff_lines: null
|
|
77
|
+
})
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const KNOWN_TOP_FIELDS = new Set([
|
|
81
|
+
'feature', 'contract_mode', 'governor', 'criteria',
|
|
82
|
+
'allowed_files', 'forbidden_files', 'human_gate'
|
|
83
|
+
]);
|
|
84
|
+
const KNOWN_GOVERNOR_FIELDS = new Set([
|
|
85
|
+
'max_steps', 'error_streak_limit', 'cost_ceiling_tokens',
|
|
86
|
+
'max_runtime_minutes', 'max_changed_files', 'max_diff_lines'
|
|
87
|
+
]);
|
|
88
|
+
const KNOWN_HUMAN_GATE_FIELDS = new Set(['required_for', 'themes']);
|
|
89
|
+
const KNOWN_THEME_FIELDS = new Set(['name', 'paths']);
|
|
90
|
+
const KNOWN_CRITERIA_FIELDS = new Set(['id', 'description', 'assertion', 'binary', 'verification']);
|
|
91
|
+
|
|
92
|
+
const VALID_MODES = new Set(['balanced', 'safe', 'builder', 'autopilot']);
|
|
93
|
+
|
|
94
|
+
function isPlainObject(v) {
|
|
95
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isPositiveInt(v) {
|
|
99
|
+
return Number.isInteger(v) && v > 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function checkOptionalLimit(errors, field, value) {
|
|
103
|
+
if (value === undefined || value === null) return;
|
|
104
|
+
if (!isPositiveInt(value)) {
|
|
105
|
+
errors.push({ field, reason: 'must be a positive integer or null' });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function checkGlobArray(errors, field, value) {
|
|
110
|
+
if (!Array.isArray(value)) {
|
|
111
|
+
errors.push({ field, reason: 'must be an array of glob strings' });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
value.forEach((pattern, i) => {
|
|
115
|
+
const result = validateGlobPattern(pattern);
|
|
116
|
+
if (!result.ok) {
|
|
117
|
+
errors.push({ field: `${field}[${i}]`, reason: result.reason });
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Valida o contrato. Retorna { ok, errors: [{field, reason}], warnings: [{field, reason}] }.
|
|
124
|
+
* `ok === false` quando há ao menos um erro — o preflight deve encerrar antes
|
|
125
|
+
* de qualquer execução (REQ-1).
|
|
126
|
+
*/
|
|
127
|
+
function validateContract(contract) {
|
|
128
|
+
const errors = [];
|
|
129
|
+
const warnings = [];
|
|
130
|
+
|
|
131
|
+
if (!isPlainObject(contract)) {
|
|
132
|
+
return { ok: false, errors: [{ field: '(root)', reason: 'contract must be a JSON object' }], warnings };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const key of Object.keys(contract)) {
|
|
136
|
+
if (!KNOWN_TOP_FIELDS.has(key)) {
|
|
137
|
+
errors.push({ field: key, reason: 'unknown field — check for typos (contract schema invalid)' });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (typeof contract.feature !== 'string' || !contract.feature.trim()) {
|
|
142
|
+
errors.push({ field: 'feature', reason: 'must be a non-empty string' });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (contract.contract_mode !== undefined) {
|
|
146
|
+
if (typeof contract.contract_mode !== 'string' || !VALID_MODES.has(contract.contract_mode.toLowerCase())) {
|
|
147
|
+
errors.push({ field: 'contract_mode', reason: 'must be one of: BALANCED, safe, builder, autopilot' });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!isPlainObject(contract.governor)) {
|
|
152
|
+
errors.push({ field: 'governor', reason: 'must be an object' });
|
|
153
|
+
} else {
|
|
154
|
+
for (const key of Object.keys(contract.governor)) {
|
|
155
|
+
if (!KNOWN_GOVERNOR_FIELDS.has(key)) {
|
|
156
|
+
errors.push({ field: `governor.${key}`, reason: 'unknown field — check for typos (contract schema invalid)' });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const g = contract.governor;
|
|
160
|
+
if (g.max_steps !== undefined && g.max_steps !== null && !(Number.isInteger(g.max_steps) && g.max_steps >= 0)) {
|
|
161
|
+
errors.push({ field: 'governor.max_steps', reason: 'must be a non-negative integer' });
|
|
162
|
+
}
|
|
163
|
+
if (g.error_streak_limit !== undefined && g.error_streak_limit !== null && !(Number.isInteger(g.error_streak_limit) && g.error_streak_limit >= 0)) {
|
|
164
|
+
errors.push({ field: 'governor.error_streak_limit', reason: 'must be a non-negative integer' });
|
|
165
|
+
}
|
|
166
|
+
checkOptionalLimit(errors, 'governor.cost_ceiling_tokens', g.cost_ceiling_tokens);
|
|
167
|
+
checkOptionalLimit(errors, 'governor.max_runtime_minutes', g.max_runtime_minutes);
|
|
168
|
+
checkOptionalLimit(errors, 'governor.max_changed_files', g.max_changed_files);
|
|
169
|
+
checkOptionalLimit(errors, 'governor.max_diff_lines', g.max_diff_lines);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (contract.allowed_files !== undefined) {
|
|
173
|
+
checkGlobArray(errors, 'allowed_files', contract.allowed_files);
|
|
174
|
+
// EC-5: allowlist vazia bloquearia tudo — warning + tratada como ausente
|
|
175
|
+
if (Array.isArray(contract.allowed_files) && contract.allowed_files.length === 0) {
|
|
176
|
+
warnings.push({
|
|
177
|
+
field: 'allowed_files',
|
|
178
|
+
reason: 'empty allowlist would block every write — treated as absent'
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (contract.forbidden_files !== undefined) {
|
|
184
|
+
checkGlobArray(errors, 'forbidden_files', contract.forbidden_files);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (contract.human_gate !== undefined) {
|
|
188
|
+
if (!isPlainObject(contract.human_gate)) {
|
|
189
|
+
errors.push({ field: 'human_gate', reason: 'must be an object' });
|
|
190
|
+
} else {
|
|
191
|
+
for (const key of Object.keys(contract.human_gate)) {
|
|
192
|
+
if (!KNOWN_HUMAN_GATE_FIELDS.has(key)) {
|
|
193
|
+
errors.push({ field: `human_gate.${key}`, reason: 'unknown field — check for typos (contract schema invalid)' });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const hg = contract.human_gate;
|
|
197
|
+
if (!Array.isArray(hg.required_for)) {
|
|
198
|
+
errors.push({ field: 'human_gate.required_for', reason: 'must be an array of themes (required when human_gate is present)' });
|
|
199
|
+
} else {
|
|
200
|
+
hg.required_for.forEach((theme, i) => {
|
|
201
|
+
if (!HUMAN_GATE_THEMES.includes(theme)) {
|
|
202
|
+
errors.push({ field: `human_gate.required_for[${i}]`, reason: `unknown theme "${theme}" — valid: ${HUMAN_GATE_THEMES.join(', ')}` });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (hg.themes !== undefined) {
|
|
207
|
+
if (!Array.isArray(hg.themes)) {
|
|
208
|
+
errors.push({ field: 'human_gate.themes', reason: 'must be an array' });
|
|
209
|
+
} else {
|
|
210
|
+
hg.themes.forEach((theme, i) => {
|
|
211
|
+
if (!isPlainObject(theme)) {
|
|
212
|
+
errors.push({ field: `human_gate.themes[${i}]`, reason: 'must be an object { name, paths }' });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
for (const key of Object.keys(theme)) {
|
|
216
|
+
if (!KNOWN_THEME_FIELDS.has(key)) {
|
|
217
|
+
errors.push({ field: `human_gate.themes[${i}].${key}`, reason: 'unknown field — check for typos (contract schema invalid)' });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (!HUMAN_GATE_THEMES.includes(theme.name)) {
|
|
221
|
+
errors.push({ field: `human_gate.themes[${i}].name`, reason: `unknown theme "${theme.name}" — valid: ${HUMAN_GATE_THEMES.join(', ')}` });
|
|
222
|
+
}
|
|
223
|
+
checkGlobArray(errors, `human_gate.themes[${i}].paths`, theme.paths);
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (contract.criteria !== undefined) {
|
|
231
|
+
if (!Array.isArray(contract.criteria)) {
|
|
232
|
+
errors.push({ field: 'criteria', reason: 'must be an array' });
|
|
233
|
+
} else {
|
|
234
|
+
contract.criteria.forEach((criterion, i) => {
|
|
235
|
+
if (!isPlainObject(criterion)) {
|
|
236
|
+
errors.push({ field: `criteria[${i}]`, reason: 'must be an object' });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
for (const key of Object.keys(criterion)) {
|
|
240
|
+
if (!KNOWN_CRITERIA_FIELDS.has(key)) {
|
|
241
|
+
errors.push({ field: `criteria[${i}].${key}`, reason: 'unknown field — check for typos (contract schema invalid)' });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (typeof criterion.id !== 'string' || !criterion.id.trim()) {
|
|
245
|
+
errors.push({ field: `criteria[${i}].id`, reason: 'must be a non-empty string' });
|
|
246
|
+
}
|
|
247
|
+
if (criterion.verification !== undefined && (typeof criterion.verification !== 'string' || !criterion.verification.trim())) {
|
|
248
|
+
errors.push({ field: `criteria[${i}].verification`, reason: 'must be a non-empty shell command string when present' });
|
|
249
|
+
}
|
|
250
|
+
if (criterion.binary !== undefined && typeof criterion.binary !== 'boolean') {
|
|
251
|
+
errors.push({ field: `criteria[${i}].binary`, reason: 'must be a boolean' });
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Resolve o contrato VALIDADO para sua forma efetiva:
|
|
262
|
+
* - preset do contract_mode preenche valores do governor não definidos (REQ-19);
|
|
263
|
+
* - `forbidden_files` mesclado com os defaults não-removíveis (REQ-4);
|
|
264
|
+
* - `allowed_files: []` tratado como ausente (EC-5);
|
|
265
|
+
* - mapa tema→paths resolvido (override substitui, não mescla).
|
|
266
|
+
*
|
|
267
|
+
* Não muta o contrato original; retorna um objeto efetivo para os guards.
|
|
268
|
+
*/
|
|
269
|
+
function resolveContract(contract) {
|
|
270
|
+
const mode = typeof contract.contract_mode === 'string'
|
|
271
|
+
? contract.contract_mode.toLowerCase()
|
|
272
|
+
: 'balanced';
|
|
273
|
+
const preset = CONTRACT_PRESETS[mode] || null;
|
|
274
|
+
|
|
275
|
+
const governor = { ...(contract.governor || {}) };
|
|
276
|
+
if (preset) {
|
|
277
|
+
for (const [key, value] of Object.entries(preset)) {
|
|
278
|
+
if (governor[key] === undefined) governor[key] = value;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const forbidden = [
|
|
283
|
+
...DEFAULT_FORBIDDEN_GLOBS,
|
|
284
|
+
...(Array.isArray(contract.forbidden_files) ? contract.forbidden_files : [])
|
|
285
|
+
];
|
|
286
|
+
|
|
287
|
+
const allowed = Array.isArray(contract.allowed_files) && contract.allowed_files.length > 0
|
|
288
|
+
? contract.allowed_files.slice()
|
|
289
|
+
: null;
|
|
290
|
+
|
|
291
|
+
const themePaths = { ...DEFAULT_THEME_PATHS };
|
|
292
|
+
const requiredFor = contract.human_gate && Array.isArray(contract.human_gate.required_for)
|
|
293
|
+
? contract.human_gate.required_for.slice()
|
|
294
|
+
: [];
|
|
295
|
+
if (contract.human_gate && Array.isArray(contract.human_gate.themes)) {
|
|
296
|
+
for (const theme of contract.human_gate.themes) {
|
|
297
|
+
if (theme && HUMAN_GATE_THEMES.includes(theme.name) && Array.isArray(theme.paths)) {
|
|
298
|
+
themePaths[theme.name] = theme.paths.slice();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
feature: contract.feature,
|
|
305
|
+
contract_mode: mode,
|
|
306
|
+
governor,
|
|
307
|
+
criteria: Array.isArray(contract.criteria) ? contract.criteria : [],
|
|
308
|
+
allowed_files: allowed,
|
|
309
|
+
forbidden_files: forbidden,
|
|
310
|
+
human_gate: {
|
|
311
|
+
required_for: requiredFor,
|
|
312
|
+
theme_paths: themePaths
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
module.exports = {
|
|
318
|
+
DEFAULT_FORBIDDEN_GLOBS,
|
|
319
|
+
HUMAN_GATE_THEMES,
|
|
320
|
+
DEFAULT_THEME_PATHS,
|
|
321
|
+
CONTRACT_PRESETS,
|
|
322
|
+
validateContract,
|
|
323
|
+
resolveContract
|
|
324
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Avaliação determinística de criteria[].verification (loop-guardrails
|
|
5
|
+
* REQ-16/17 + D7).
|
|
6
|
+
*
|
|
7
|
+
* Reusa `executeInSandbox` (src/sandbox.js) — timeout, kill de process tree e
|
|
8
|
+
* redaction já resolvidos (EC-7). NÃO cria runner novo. Critério sem
|
|
9
|
+
* `verification` mantém o comportamento atual (não avaliado).
|
|
10
|
+
*
|
|
11
|
+
* Assinatura de falha (D7): sha1(criterion_id + exitCode + primeira linha
|
|
12
|
+
* não-vazia de stderr normalizada — paths absolutos, números e timestamps
|
|
13
|
+
* removidos). 2 ocorrências no RUN (não precisam ser consecutivas — EC-13,
|
|
14
|
+
* diferente do error_streak) → failure_signature_repeat + parada.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const crypto = require('node:crypto');
|
|
18
|
+
|
|
19
|
+
const DEFAULT_CHECK_TIMEOUT_MS = 120000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Normaliza uma linha de erro para assinatura estável (D7):
|
|
23
|
+
* remove paths absolutos, troca dígitos por '#' (line numbers, timestamps,
|
|
24
|
+
* durações) e colapsa espaços.
|
|
25
|
+
*/
|
|
26
|
+
function normalizeErrorLine(line) {
|
|
27
|
+
return String(line || '')
|
|
28
|
+
// paths absolutos (posix e windows, com ou sem drive letter)
|
|
29
|
+
.replace(/(?:[A-Za-z]:)?[\\/][^\s:'"()]+/g, '<path>')
|
|
30
|
+
// dígitos (line numbers, timestamps, ms)
|
|
31
|
+
.replace(/\d+/g, '#')
|
|
32
|
+
.replace(/\s+/g, ' ')
|
|
33
|
+
.trim()
|
|
34
|
+
.toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function firstNonEmptyLine(text) {
|
|
38
|
+
for (const line of String(text || '').split('\n')) {
|
|
39
|
+
if (line.trim()) return line;
|
|
40
|
+
}
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** sha1 hex da assinatura de falha (D7). */
|
|
45
|
+
function failureSignature(criterionId, exitCode, stderr) {
|
|
46
|
+
const normalized = normalizeErrorLine(firstNonEmptyLine(stderr));
|
|
47
|
+
return crypto
|
|
48
|
+
.createHash('sha1')
|
|
49
|
+
.update(`${criterionId}|${exitCode === null || exitCode === undefined ? 'null' : exitCode}|${normalized}`)
|
|
50
|
+
.digest('hex');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Executa os critérios com `verification` via sandbox (REQ-16).
|
|
55
|
+
*
|
|
56
|
+
* @param {object} params
|
|
57
|
+
* @param {Array} params.criteria — criteria[] do contrato resolvido
|
|
58
|
+
* @param {string} params.cwd — raiz do projeto
|
|
59
|
+
* @param {number} [params.timeoutMs]
|
|
60
|
+
* @param {Function} [params.sandboxExec] — injeção para teste; default executeInSandbox
|
|
61
|
+
* @returns {Promise<Array<{id, command, exitCode, durationMs, stdout, stderr, timedOut, ok, signature}>>}
|
|
62
|
+
*/
|
|
63
|
+
async function runCriteria({ criteria = [], cwd, timeoutMs = DEFAULT_CHECK_TIMEOUT_MS, sandboxExec = null }) {
|
|
64
|
+
const exec = sandboxExec || require('../sandbox').executeInSandbox;
|
|
65
|
+
const checks = [];
|
|
66
|
+
for (const criterion of criteria) {
|
|
67
|
+
if (!criterion || typeof criterion.verification !== 'string' || !criterion.verification.trim()) {
|
|
68
|
+
continue; // sem verification = não avaliado automaticamente (REQ-16)
|
|
69
|
+
}
|
|
70
|
+
const startedAt = Date.now();
|
|
71
|
+
let result;
|
|
72
|
+
try {
|
|
73
|
+
result = await exec(criterion.verification, {
|
|
74
|
+
cwd,
|
|
75
|
+
timeout: timeoutMs,
|
|
76
|
+
intent: `criteria:${criterion.id}`
|
|
77
|
+
});
|
|
78
|
+
} catch (err) {
|
|
79
|
+
result = { ok: false, stdout: '', stderr: String(err.message || err), exitCode: null, timedOut: false };
|
|
80
|
+
}
|
|
81
|
+
const check = {
|
|
82
|
+
id: criterion.id,
|
|
83
|
+
command: criterion.verification,
|
|
84
|
+
exitCode: result.exitCode,
|
|
85
|
+
durationMs: Date.now() - startedAt,
|
|
86
|
+
stdout: result.stdout || '',
|
|
87
|
+
stderr: result.stderr || '',
|
|
88
|
+
timedOut: Boolean(result.timedOut),
|
|
89
|
+
ok: Boolean(result.ok)
|
|
90
|
+
};
|
|
91
|
+
// EC-7: timeout = check falho com assinatura própria (stderr do sandbox
|
|
92
|
+
// "Command timed out after Xms" normaliza estável via '#')
|
|
93
|
+
check.signature = check.ok ? null : failureSignature(check.id, check.exitCode, check.stderr);
|
|
94
|
+
checks.push(check);
|
|
95
|
+
}
|
|
96
|
+
return checks;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Registra assinaturas de falha no run (D7) e detecta repetições.
|
|
101
|
+
* Muta `progress.failure_signatures[]` (caller persiste).
|
|
102
|
+
*
|
|
103
|
+
* @returns {Array<{signature, criterion_id}>} repetições (>= 2 no run)
|
|
104
|
+
*/
|
|
105
|
+
function registerFailureSignatures(progress, failedChecks) {
|
|
106
|
+
if (!Array.isArray(progress.failure_signatures)) progress.failure_signatures = [];
|
|
107
|
+
const repeats = [];
|
|
108
|
+
for (const check of failedChecks) {
|
|
109
|
+
if (!check.signature) continue;
|
|
110
|
+
const priorOccurrences = progress.failure_signatures.filter((s) => s.signature === check.signature).length;
|
|
111
|
+
progress.failure_signatures.push({
|
|
112
|
+
signature: check.signature,
|
|
113
|
+
criterion_id: check.id,
|
|
114
|
+
recorded_at: new Date().toISOString()
|
|
115
|
+
});
|
|
116
|
+
if (priorOccurrences + 1 >= 2) {
|
|
117
|
+
repeats.push({ signature: check.signature, criterion_id: check.id });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return repeats;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Zera as assinaturas para um run novo (chamado no preflight junto do budget). */
|
|
124
|
+
function startRunSignatures(progress) {
|
|
125
|
+
progress.failure_signatures = [];
|
|
126
|
+
return progress;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = {
|
|
130
|
+
DEFAULT_CHECK_TIMEOUT_MS,
|
|
131
|
+
normalizeErrorLine,
|
|
132
|
+
failureSignature,
|
|
133
|
+
runCriteria,
|
|
134
|
+
registerFailureSignatures,
|
|
135
|
+
startRunSignatures
|
|
136
|
+
};
|