@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,204 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Baseline git do self:loop (loop-guardrails REQ-2/3 + D2).
|
|
5
|
+
*
|
|
6
|
+
* Única fronteira `child_process` da Fase 1 além do sandbox — todo o I/O git
|
|
7
|
+
* dos guards vive aqui. Módulos consumidores recebem objetos puros.
|
|
8
|
+
*
|
|
9
|
+
* - `captureBaseline`: no preflight, grava HEAD + dirty_paths (porcelain) e o
|
|
10
|
+
* `git hash-object` dos dirty paths que casam `forbidden_files` (D2 — fecha
|
|
11
|
+
* EC-2: tentativa que re-modifica um path sujo proibido ainda viola).
|
|
12
|
+
* - `computeChangedSet`: pós-attempt, changed set = porcelain atual −
|
|
13
|
+
* dirty_paths do baseline. NUNCA `git diff --name-only` (não vê untracked,
|
|
14
|
+
* EC-1). Paths normalizados `/` (EC-6). Rename conta os dois paths (EC-3);
|
|
15
|
+
* deleção conta (EC-4).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
const { execFileSync } = require('node:child_process');
|
|
21
|
+
|
|
22
|
+
const { normalizePath, matchGlob, matchAny } = require('./glob-match');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Estado do framework: o próprio loop escreve progress.json/baseline.json/
|
|
26
|
+
* attempts/ sob `.aioson/` durante a execução — esses paths são excluídos do
|
|
27
|
+
* changed-set para não gerar falsa violação (mesmo precedente do git ingest
|
|
28
|
+
* do neural-chain, que exclui `.aioson/*`).
|
|
29
|
+
*/
|
|
30
|
+
const FRAMEWORK_STATE_GLOB = '.aioson/**';
|
|
31
|
+
|
|
32
|
+
function git(targetDir, gitArgs) {
|
|
33
|
+
return execFileSync('git', gitArgs, {
|
|
34
|
+
cwd: targetDir,
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
maxBuffer: 1024 * 1024 * 10,
|
|
37
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Map porcelain XY → status do schema §2.3. */
|
|
42
|
+
function porcelainStatus(xy) {
|
|
43
|
+
if (xy.includes('R') || xy.includes('C')) return 'renamed';
|
|
44
|
+
if (xy === '??') return 'added';
|
|
45
|
+
if (xy.includes('A')) return 'added';
|
|
46
|
+
if (xy.includes('D')) return 'deleted';
|
|
47
|
+
return 'modified';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parseia `git status --porcelain` em entradas { path, status }.
|
|
52
|
+
* Rename (`R old -> new`) produz DUAS entradas (EC-3).
|
|
53
|
+
* Exportada pura para teste determinístico.
|
|
54
|
+
*/
|
|
55
|
+
function parsePorcelain(output) {
|
|
56
|
+
const entries = [];
|
|
57
|
+
for (const rawLine of String(output || '').split('\n')) {
|
|
58
|
+
const line = rawLine.replace(/\r$/, '');
|
|
59
|
+
if (!line.trim()) continue;
|
|
60
|
+
const xy = line.slice(0, 2);
|
|
61
|
+
let rest = line.slice(3);
|
|
62
|
+
// porcelain pode citar paths com espaços/especiais entre aspas
|
|
63
|
+
const unquote = (p) => {
|
|
64
|
+
const trimmed = p.trim();
|
|
65
|
+
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
|
66
|
+
try { return JSON.parse(trimmed); } catch { return trimmed.slice(1, -1); }
|
|
67
|
+
}
|
|
68
|
+
return trimmed;
|
|
69
|
+
};
|
|
70
|
+
const status = porcelainStatus(xy);
|
|
71
|
+
if (status === 'renamed' && rest.includes(' -> ')) {
|
|
72
|
+
const [from, to] = rest.split(' -> ');
|
|
73
|
+
entries.push({ path: normalizePath(unquote(from)), status: 'renamed' });
|
|
74
|
+
entries.push({ path: normalizePath(unquote(to)), status: 'renamed' });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
entries.push({ path: normalizePath(unquote(rest)), status });
|
|
78
|
+
}
|
|
79
|
+
return entries;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function readPorcelain(targetDir) {
|
|
83
|
+
// -uall: untracked listados arquivo a arquivo — sem ele o porcelain colapsa
|
|
84
|
+
// dirs novos (`?? secrets/`) e `secrets/**` não casaria o dir vazio de sufixo.
|
|
85
|
+
const entries = parsePorcelain(git(targetDir, ['status', '--porcelain', '-uall']));
|
|
86
|
+
return entries.filter((entry) => !matchGlob(FRAMEWORK_STATE_GLOB, entry.path));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readHead(targetDir) {
|
|
90
|
+
try {
|
|
91
|
+
return git(targetDir, ['rev-parse', 'HEAD']).trim();
|
|
92
|
+
} catch {
|
|
93
|
+
return null; // repo sem commits ainda
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `git hash-object` de um path do working tree; null para path
|
|
99
|
+
* inexistente/deletado (deleção posterior será detectada por hash null≠hash).
|
|
100
|
+
*/
|
|
101
|
+
function hashWorkingTreePath(targetDir, relPath) {
|
|
102
|
+
const abs = path.join(targetDir, relPath);
|
|
103
|
+
if (!fs.existsSync(abs) || fs.statSync(abs).isDirectory()) return null;
|
|
104
|
+
try {
|
|
105
|
+
return git(targetDir, ['hash-object', '--', relPath]).trim();
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Captura o baseline no preflight (REQ-2 + D2) e grava
|
|
113
|
+
* `.aioson/plans/{slug}/baseline.json`.
|
|
114
|
+
*
|
|
115
|
+
* @returns {{ baseline, warnings: [{path, reason}] }}
|
|
116
|
+
*/
|
|
117
|
+
function captureBaseline(targetDir, planDir, { forbiddenGlobs = [] } = {}) {
|
|
118
|
+
const dirtyEntries = readPorcelain(targetDir);
|
|
119
|
+
const dirtyPaths = dirtyEntries.map((e) => e.path);
|
|
120
|
+
|
|
121
|
+
// D2: hash apenas dos dirty paths que casam forbidden (conjunto bounded)
|
|
122
|
+
const forbiddenDirtyHashes = {};
|
|
123
|
+
const warnings = [];
|
|
124
|
+
for (const dirtyPath of dirtyPaths) {
|
|
125
|
+
const matched = matchAny(forbiddenGlobs, dirtyPath);
|
|
126
|
+
if (matched) {
|
|
127
|
+
forbiddenDirtyHashes[dirtyPath] = hashWorkingTreePath(targetDir, dirtyPath);
|
|
128
|
+
warnings.push({
|
|
129
|
+
path: dirtyPath,
|
|
130
|
+
reason: `dirty path matches forbidden glob "${matched}" at loop start — re-modification will be a scope violation`
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const baseline = {
|
|
136
|
+
captured_at: new Date().toISOString(),
|
|
137
|
+
head: readHead(targetDir),
|
|
138
|
+
dirty_paths: dirtyPaths,
|
|
139
|
+
forbidden_dirty_hashes: forbiddenDirtyHashes
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
fs.mkdirSync(planDir, { recursive: true });
|
|
143
|
+
fs.writeFileSync(path.join(planDir, 'baseline.json'), JSON.stringify(baseline, null, 2), 'utf8');
|
|
144
|
+
|
|
145
|
+
return { baseline, warnings };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function loadBaseline(planDir) {
|
|
149
|
+
const baselinePath = path.join(planDir, 'baseline.json');
|
|
150
|
+
if (!fs.existsSync(baselinePath)) return null;
|
|
151
|
+
try {
|
|
152
|
+
return JSON.parse(fs.readFileSync(baselinePath, 'utf8'));
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Changed set da tentativa (REQ-3): porcelain atual − dirty_paths do baseline,
|
|
160
|
+
* MAIS dirty paths proibidos cujo hash mudou desde o baseline (D2 / EC-2).
|
|
161
|
+
*
|
|
162
|
+
* @returns {{ files: [{path, status}], rehashViolations: [{path, reason}] }}
|
|
163
|
+
*/
|
|
164
|
+
function computeChangedSet(targetDir, baseline) {
|
|
165
|
+
const current = readPorcelain(targetDir);
|
|
166
|
+
const baselineDirty = new Set((baseline && baseline.dirty_paths) || []);
|
|
167
|
+
|
|
168
|
+
const files = current.filter((entry) => !baselineDirty.has(entry.path));
|
|
169
|
+
|
|
170
|
+
const rehashViolations = [];
|
|
171
|
+
const hashes = (baseline && baseline.forbidden_dirty_hashes) || {};
|
|
172
|
+
for (const [dirtyPath, baselineHash] of Object.entries(hashes)) {
|
|
173
|
+
const currentHash = hashWorkingTreePath(targetDir, dirtyPath);
|
|
174
|
+
if (currentHash !== baselineHash) {
|
|
175
|
+
rehashViolations.push({
|
|
176
|
+
path: dirtyPath,
|
|
177
|
+
reason: 'forbidden dirty path was re-modified after baseline (content hash changed)'
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { files, rehashViolations };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** `git diff` da tentativa para attempts/{n}/diff.patch (should-have REQ-9). */
|
|
186
|
+
function captureDiffPatch(targetDir) {
|
|
187
|
+
try {
|
|
188
|
+
return git(targetDir, ['diff', 'HEAD']);
|
|
189
|
+
} catch {
|
|
190
|
+
try {
|
|
191
|
+
return git(targetDir, ['diff']);
|
|
192
|
+
} catch {
|
|
193
|
+
return '';
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = {
|
|
199
|
+
parsePorcelain,
|
|
200
|
+
captureBaseline,
|
|
201
|
+
loadBaseline,
|
|
202
|
+
computeChangedSet,
|
|
203
|
+
captureDiffPatch
|
|
204
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Glob matcher mínimo e determinístico para o scope guard (loop-guardrails D1).
|
|
5
|
+
*
|
|
6
|
+
* Subset estrito suportado: `**`, `*`, `?` (incluindo `**` + `/` nas bordas).
|
|
7
|
+
* Qualquer sintaxe fora do subset (extglob `{}[]!()`, classes, negação) é
|
|
8
|
+
* REJEITADA pelo validador — nunca mismatch silencioso em fronteira de
|
|
9
|
+
* segurança. `picomatch` é o upgrade path documentado se o subset apertar.
|
|
10
|
+
*
|
|
11
|
+
* Semântica de caminho (decisão registrada em spec-loop-guardrails.md):
|
|
12
|
+
* - paths e patterns são normalizados para `/` antes do match (EC-6);
|
|
13
|
+
* - pattern SEM `/` casa contra o basename de qualquer profundidade
|
|
14
|
+
* (estilo gitignore: `*.pem` casa `certs/server.pem`);
|
|
15
|
+
* - pattern COM `/` casa contra o caminho relativo completo.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const INVALID_GLOB_CHARS = /[{}[\]()!]/;
|
|
19
|
+
|
|
20
|
+
/** Normaliza separadores para `/` e remove `./` inicial. */
|
|
21
|
+
function normalizePath(p) {
|
|
22
|
+
let out = String(p == null ? '' : p).replace(/\\/g, '/');
|
|
23
|
+
while (out.startsWith('./')) out = out.slice(2);
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Valida um pattern contra o subset estrito.
|
|
29
|
+
* Retorna { ok: true } ou { ok: false, reason }.
|
|
30
|
+
*/
|
|
31
|
+
function validateGlobPattern(pattern) {
|
|
32
|
+
if (typeof pattern !== 'string' || pattern.trim() === '') {
|
|
33
|
+
return { ok: false, reason: 'pattern must be a non-empty string' };
|
|
34
|
+
}
|
|
35
|
+
const normalized = normalizePath(pattern);
|
|
36
|
+
const invalid = normalized.match(INVALID_GLOB_CHARS);
|
|
37
|
+
if (invalid) {
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
reason: `unsupported glob syntax "${invalid[0]}" — only **, * and ? are allowed (strict subset)`
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return { ok: true };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const REGEX_SPECIALS = /[.+^$|]/g;
|
|
47
|
+
|
|
48
|
+
/** Compila um pattern (já validado) para RegExp anchored. */
|
|
49
|
+
function globToRegExp(pattern) {
|
|
50
|
+
const normalized = normalizePath(pattern);
|
|
51
|
+
let regex = '';
|
|
52
|
+
let i = 0;
|
|
53
|
+
while (i < normalized.length) {
|
|
54
|
+
const ch = normalized[i];
|
|
55
|
+
if (ch === '*') {
|
|
56
|
+
if (normalized[i + 1] === '*') {
|
|
57
|
+
// `**` — atravessa separadores
|
|
58
|
+
const prev = normalized[i - 1];
|
|
59
|
+
const next = normalized[i + 2];
|
|
60
|
+
if ((prev === undefined || prev === '/') && next === '/') {
|
|
61
|
+
// `**/` no início ou após `/` — zero ou mais segmentos completos
|
|
62
|
+
regex += '(?:[^/]+/)*';
|
|
63
|
+
i += 3;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (next === undefined && (prev === undefined || prev === '/')) {
|
|
67
|
+
// `/**` no fim ou pattern `**` puro — qualquer resto (inclusive vazio? não:
|
|
68
|
+
// `secrets/**` exige algo dentro de secrets/; `**` puro casa tudo)
|
|
69
|
+
regex += prev === '/' ? '.+' : '.*';
|
|
70
|
+
i += 2;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// `**` colado em texto (ex.: `a**b`) — trata como `.*`
|
|
74
|
+
regex += '.*';
|
|
75
|
+
i += 2;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
regex += '[^/]*';
|
|
79
|
+
i += 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (ch === '?') {
|
|
83
|
+
regex += '[^/]';
|
|
84
|
+
i += 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
regex += ch.replace(REGEX_SPECIALS, '\\$&');
|
|
88
|
+
i += 1;
|
|
89
|
+
}
|
|
90
|
+
return new RegExp(`^${regex}$`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Casa um path contra um pattern do subset.
|
|
95
|
+
* Pattern sem `/` casa contra o basename (estilo gitignore).
|
|
96
|
+
*/
|
|
97
|
+
function matchGlob(pattern, filePath) {
|
|
98
|
+
const normalizedPattern = normalizePath(pattern);
|
|
99
|
+
const normalizedPath = normalizePath(filePath);
|
|
100
|
+
if (!normalizedPattern || !normalizedPath) return false;
|
|
101
|
+
|
|
102
|
+
if (!normalizedPattern.includes('/')) {
|
|
103
|
+
const basename = normalizedPath.split('/').pop();
|
|
104
|
+
return globToRegExp(normalizedPattern).test(basename);
|
|
105
|
+
}
|
|
106
|
+
return globToRegExp(normalizedPattern).test(normalizedPath);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Retorna o primeiro pattern da lista que casa o path, ou null.
|
|
111
|
+
*/
|
|
112
|
+
function matchAny(patterns, filePath) {
|
|
113
|
+
if (!Array.isArray(patterns)) return null;
|
|
114
|
+
for (const pattern of patterns) {
|
|
115
|
+
if (matchGlob(pattern, filePath)) return pattern;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = {
|
|
121
|
+
normalizePath,
|
|
122
|
+
validateGlobPattern,
|
|
123
|
+
globToRegExp,
|
|
124
|
+
matchGlob,
|
|
125
|
+
matchAny
|
|
126
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Telemetria dos guards do self:loop (loop-guardrails D6).
|
|
5
|
+
*
|
|
6
|
+
* Helper único de emissão para os tipos de evento novos (requirements §2.5):
|
|
7
|
+
* scope_violation, budget_warning, budget_exceeded, runtime_exceeded,
|
|
8
|
+
* human_gate_requested, human_gate_decision, criteria_check_failed,
|
|
9
|
+
* failure_signature_repeat, contract_invalid, diff_limit_exceeded.
|
|
10
|
+
*
|
|
11
|
+
* Sempre best-effort (espelha BR-NC-11 / neural-chain-telemetry): telemetria
|
|
12
|
+
* NUNCA quebra o loop. `token_count` carrega a estimativa chars/4 quando o
|
|
13
|
+
* evento é de tentativa (REQ-7) — telemetria apenas; enforcement lê o
|
|
14
|
+
* acumulador em progress.json (D3).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const GUARD_EVENT_TYPES = Object.freeze([
|
|
18
|
+
'scope_violation',
|
|
19
|
+
'budget_warning',
|
|
20
|
+
'budget_exceeded',
|
|
21
|
+
'runtime_exceeded',
|
|
22
|
+
'human_gate_requested',
|
|
23
|
+
'human_gate_decision',
|
|
24
|
+
'criteria_check_failed',
|
|
25
|
+
'failure_signature_repeat',
|
|
26
|
+
'contract_invalid',
|
|
27
|
+
'diff_limit_exceeded'
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Emite um evento de guard no runtime store. Nunca lança.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} targetDir — raiz do projeto
|
|
34
|
+
* @param {object} event
|
|
35
|
+
* @param {string} event.eventType — um de GUARD_EVENT_TYPES
|
|
36
|
+
* @param {string} [event.agent] — default 'self-loop'
|
|
37
|
+
* @param {string} [event.message]
|
|
38
|
+
* @param {object} [event.payload] — vai para payload_json (slug, attempt, etc.)
|
|
39
|
+
* @param {number|null} [event.tokenCount] — estimativa chars/4 da tentativa
|
|
40
|
+
* @returns {boolean} true se gravou
|
|
41
|
+
*/
|
|
42
|
+
async function emitGuardEvent(targetDir, { eventType, agent = 'self-loop', message = '', payload = null, tokenCount = null } = {}) {
|
|
43
|
+
if (!GUARD_EVENT_TYPES.includes(eventType)) return false;
|
|
44
|
+
let db = null;
|
|
45
|
+
try {
|
|
46
|
+
const { openRuntimeDb } = require('../runtime-store');
|
|
47
|
+
const opened = await openRuntimeDb(targetDir);
|
|
48
|
+
db = opened.db;
|
|
49
|
+
db.prepare(`
|
|
50
|
+
INSERT INTO execution_events (event_type, agent_name, message, payload_json, token_count, created_at)
|
|
51
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
52
|
+
`).run(
|
|
53
|
+
eventType,
|
|
54
|
+
agent,
|
|
55
|
+
message || eventType,
|
|
56
|
+
payload ? JSON.stringify(payload) : null,
|
|
57
|
+
tokenCount === null || tokenCount === undefined ? null : Math.round(tokenCount),
|
|
58
|
+
new Date().toISOString()
|
|
59
|
+
);
|
|
60
|
+
return true;
|
|
61
|
+
} catch {
|
|
62
|
+
return false; // D6: telemetria nunca quebra o loop
|
|
63
|
+
} finally {
|
|
64
|
+
try { if (db) db.close(); } catch { /* ignore */ }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
GUARD_EVENT_TYPES,
|
|
70
|
+
emitGuardEvent
|
|
71
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Human gates temáticos do self:loop (loop-guardrails REQ-12/13/14/15 + D4).
|
|
5
|
+
*
|
|
6
|
+
* Estado em disco:
|
|
7
|
+
* - `.aioson/plans/{slug}/gates/{id}.json` — decisão humana persistida
|
|
8
|
+
* (schema requirements §2.4 + campo aditivo `run_id` para suprimir
|
|
9
|
+
* re-detecção do mesmo tema dentro do run)
|
|
10
|
+
* - `progress.json` — `status='human_gate'` + `pending_gates[]` (D4)
|
|
11
|
+
*
|
|
12
|
+
* O tema `publish` é gate de COMANDO (intercepta feature:close, REQ-13) —
|
|
13
|
+
* nunca entra na detecção por diff.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
|
|
19
|
+
const { matchAny } = require('./glob-match');
|
|
20
|
+
|
|
21
|
+
const GATE_STATUSES = Object.freeze(['pending', 'approved', 'rejected']);
|
|
22
|
+
|
|
23
|
+
function gatesDir(planDir) {
|
|
24
|
+
return path.join(planDir, 'gates');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function gatePath(planDir, gateId) {
|
|
28
|
+
const safeId = String(gateId).replace(/[^A-Za-z0-9._-]/g, '_');
|
|
29
|
+
return path.join(gatesDir(planDir), `${safeId}.json`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Carrega todos os gates do slug (array vazio se nenhum). */
|
|
33
|
+
function loadGates(planDir) {
|
|
34
|
+
const dir = gatesDir(planDir);
|
|
35
|
+
if (!fs.existsSync(dir)) return [];
|
|
36
|
+
const gates = [];
|
|
37
|
+
for (const file of fs.readdirSync(dir)) {
|
|
38
|
+
if (!file.endsWith('.json')) continue;
|
|
39
|
+
try {
|
|
40
|
+
gates.push(JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')));
|
|
41
|
+
} catch { /* gate corrompido — ignorado na leitura, decisão manual via fs */ }
|
|
42
|
+
}
|
|
43
|
+
return gates;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function pendingGates(planDir) {
|
|
47
|
+
return loadGates(planDir).filter((g) => g.status === 'pending');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Detecção por tema (REQ-12): diff da tentativa casando os globs do tema E
|
|
52
|
+
* tema listado em required_for. `publish` nunca é detectado por diff (REQ-13).
|
|
53
|
+
* Temas já cobertos por gate `approved` do MESMO run não re-disparam.
|
|
54
|
+
*
|
|
55
|
+
* @returns {Array<{theme, triggeredBy: string[]}>}
|
|
56
|
+
*/
|
|
57
|
+
function detectGates({ changedFiles = [], requiredFor = [], themePaths = {}, existingGates = [], runId = null }) {
|
|
58
|
+
const detections = [];
|
|
59
|
+
for (const theme of requiredFor) {
|
|
60
|
+
if (theme === 'publish') continue; // gate de comando, nunca diff
|
|
61
|
+
const globs = themePaths[theme] || [];
|
|
62
|
+
if (!globs.length) continue;
|
|
63
|
+
const alreadyHandled = existingGates.some(
|
|
64
|
+
(g) => g.theme === theme && g.run_id === runId && (g.status === 'approved' || g.status === 'pending')
|
|
65
|
+
);
|
|
66
|
+
if (alreadyHandled) continue;
|
|
67
|
+
const triggeredBy = changedFiles
|
|
68
|
+
.filter((f) => matchAny(globs, f.path))
|
|
69
|
+
.map((f) => f.path);
|
|
70
|
+
if (triggeredBy.length > 0) {
|
|
71
|
+
detections.push({ theme, triggeredBy });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return detections;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Cria e persiste um gate `pending` (schema §2.4). `id` único por slug:
|
|
79
|
+
* `{theme}-{n}` com n incremental sobre os gates existentes do tema.
|
|
80
|
+
*/
|
|
81
|
+
function createGate(planDir, { theme, attempt, triggeredBy = [], diffSummary = '', runId = null }) {
|
|
82
|
+
const existing = loadGates(planDir).filter((g) => g.theme === theme);
|
|
83
|
+
const id = `${theme}-${existing.length + 1}`;
|
|
84
|
+
const gate = {
|
|
85
|
+
id,
|
|
86
|
+
theme,
|
|
87
|
+
status: 'pending',
|
|
88
|
+
attempt,
|
|
89
|
+
triggered_by: triggeredBy,
|
|
90
|
+
diff_summary: diffSummary,
|
|
91
|
+
requested_at: new Date().toISOString(),
|
|
92
|
+
decided_at: null,
|
|
93
|
+
decided_by: null,
|
|
94
|
+
reason: null,
|
|
95
|
+
run_id: runId
|
|
96
|
+
};
|
|
97
|
+
fs.mkdirSync(gatesDir(planDir), { recursive: true });
|
|
98
|
+
fs.writeFileSync(gatePath(planDir, id), JSON.stringify(gate, null, 2), 'utf8');
|
|
99
|
+
return gate;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Entra no estado HUMAN_GATE (D4): muta `progress` (caller persiste via cb).
|
|
104
|
+
*/
|
|
105
|
+
function enterHumanGate(progress, gateIds) {
|
|
106
|
+
progress.status = 'human_gate';
|
|
107
|
+
const pending = new Set(Array.isArray(progress.pending_gates) ? progress.pending_gates : []);
|
|
108
|
+
for (const id of gateIds) pending.add(id);
|
|
109
|
+
progress.pending_gates = [...pending];
|
|
110
|
+
progress.last_updated = new Date().toISOString();
|
|
111
|
+
return progress;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Decide um gate (REQ-14). Idempotente: gate já decidido → no-op com aviso.
|
|
116
|
+
* EC-8: gate inexistente → erro explícito sem efeito colateral.
|
|
117
|
+
*
|
|
118
|
+
* @returns {{ ok, error?, idempotent?, gate? }}
|
|
119
|
+
*/
|
|
120
|
+
function decideGate(planDir, gateId, { decision, by = null, reason = null }) {
|
|
121
|
+
if (!GATE_STATUSES.includes(decision) || decision === 'pending') {
|
|
122
|
+
return { ok: false, error: 'invalid_decision' };
|
|
123
|
+
}
|
|
124
|
+
const file = gatePath(planDir, gateId);
|
|
125
|
+
if (!fs.existsSync(file)) {
|
|
126
|
+
return { ok: false, error: 'gate_not_found', gateId };
|
|
127
|
+
}
|
|
128
|
+
let gate;
|
|
129
|
+
try {
|
|
130
|
+
gate = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
131
|
+
} catch {
|
|
132
|
+
return { ok: false, error: 'gate_corrupted', gateId };
|
|
133
|
+
}
|
|
134
|
+
if (gate.status !== 'pending') {
|
|
135
|
+
return { ok: true, idempotent: true, gate };
|
|
136
|
+
}
|
|
137
|
+
if (decision === 'rejected' && !(reason && String(reason).trim())) {
|
|
138
|
+
return { ok: false, error: 'reason_required_on_reject', gateId };
|
|
139
|
+
}
|
|
140
|
+
gate.status = decision;
|
|
141
|
+
gate.decided_at = new Date().toISOString();
|
|
142
|
+
gate.decided_by = by || null;
|
|
143
|
+
gate.reason = reason || null;
|
|
144
|
+
fs.writeFileSync(file, JSON.stringify(gate, null, 2), 'utf8');
|
|
145
|
+
return { ok: true, idempotent: false, gate };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Reconcilia `progress` após decisões (REQ-15): remove o gate decidido de
|
|
150
|
+
* `pending_gates`; sem pendências → `status='in_progress'` (retomada
|
|
151
|
+
* idempotente; gate rejeitado fica como auditoria e não bloqueia runs novos).
|
|
152
|
+
* Muta `progress` (caller persiste).
|
|
153
|
+
*/
|
|
154
|
+
function resolveGateState(progress, planDir) {
|
|
155
|
+
const stillPending = new Set(pendingGates(planDir).map((g) => g.id));
|
|
156
|
+
const current = Array.isArray(progress.pending_gates) ? progress.pending_gates : [];
|
|
157
|
+
progress.pending_gates = current.filter((id) => stillPending.has(id));
|
|
158
|
+
if (progress.pending_gates.length === 0 && progress.status === 'human_gate') {
|
|
159
|
+
progress.status = 'in_progress';
|
|
160
|
+
}
|
|
161
|
+
progress.last_updated = new Date().toISOString();
|
|
162
|
+
return progress;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Gate de comando `publish` (REQ-13): existe gate publish aprovado?
|
|
167
|
+
*/
|
|
168
|
+
function hasApprovedPublishGate(planDir) {
|
|
169
|
+
return loadGates(planDir).some((g) => g.theme === 'publish' && g.status === 'approved');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
loadGates,
|
|
174
|
+
pendingGates,
|
|
175
|
+
detectGates,
|
|
176
|
+
createGate,
|
|
177
|
+
enterHumanGate,
|
|
178
|
+
decideGate,
|
|
179
|
+
resolveGateState,
|
|
180
|
+
hasApprovedPublishGate,
|
|
181
|
+
gatePath
|
|
182
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Scope guard do self:loop (loop-guardrails REQ-4/5/6 + REQ-10).
|
|
5
|
+
*
|
|
6
|
+
* Módulo puro: recebe o changed set (já calculado por git-baseline) e o
|
|
7
|
+
* contrato RESOLVIDO (contract-schema.resolveContract — defaults proibidos já
|
|
8
|
+
* mesclados). Não faz I/O.
|
|
9
|
+
*
|
|
10
|
+
* Precedência (REQ-5): deny vence allow — path que casa `forbidden_files` é
|
|
11
|
+
* violação mesmo casando `allowed_files`. Defaults proibidos são sempre
|
|
12
|
+
* aplicados (REQ-4) porque vêm mesclados do resolveContract.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { matchAny } = require('./glob-match');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} params
|
|
19
|
+
* @param {Array<{path, status}>} params.changedFiles — changed set da tentativa
|
|
20
|
+
* @param {Array<{path, reason}>} [params.rehashViolations] — D2 (git-baseline)
|
|
21
|
+
* @param {string[]|null} params.allowedGlobs — null = sem allowlist
|
|
22
|
+
* @param {string[]} params.forbiddenGlobs — já mesclados com defaults
|
|
23
|
+
* @returns {{ ok: boolean, violations: Array<{path, status, glob, reason}> }}
|
|
24
|
+
*/
|
|
25
|
+
function checkScope({ changedFiles = [], rehashViolations = [], allowedGlobs = null, forbiddenGlobs = [] }) {
|
|
26
|
+
const violations = [];
|
|
27
|
+
|
|
28
|
+
for (const file of changedFiles) {
|
|
29
|
+
const forbiddenMatch = matchAny(forbiddenGlobs, file.path);
|
|
30
|
+
if (forbiddenMatch) {
|
|
31
|
+
violations.push({
|
|
32
|
+
path: file.path,
|
|
33
|
+
status: file.status,
|
|
34
|
+
glob: forbiddenMatch,
|
|
35
|
+
reason: `matches forbidden glob "${forbiddenMatch}"${file.status === 'deleted' ? ' (deletion counts — EC-4)' : ''}`
|
|
36
|
+
});
|
|
37
|
+
continue; // deny vence allow (REQ-5)
|
|
38
|
+
}
|
|
39
|
+
if (allowedGlobs && !matchAny(allowedGlobs, file.path)) {
|
|
40
|
+
violations.push({
|
|
41
|
+
path: file.path,
|
|
42
|
+
status: file.status,
|
|
43
|
+
glob: null,
|
|
44
|
+
reason: 'outside allowed_files allowlist'
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const rehash of rehashViolations) {
|
|
50
|
+
violations.push({
|
|
51
|
+
path: rehash.path,
|
|
52
|
+
status: 'modified',
|
|
53
|
+
glob: null,
|
|
54
|
+
reason: rehash.reason
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { ok: violations.length === 0, violations };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Conta linhas efetivas de diff (+/− excluindo headers +++/---). */
|
|
62
|
+
function countDiffLines(diffPatch) {
|
|
63
|
+
if (!diffPatch) return 0;
|
|
64
|
+
let count = 0;
|
|
65
|
+
for (const line of String(diffPatch).split('\n')) {
|
|
66
|
+
if ((line.startsWith('+') && !line.startsWith('+++')) ||
|
|
67
|
+
(line.startsWith('-') && !line.startsWith('---'))) {
|
|
68
|
+
count += 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return count;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Limites de diff (REQ-10, should-have). Avaliados sobre o MESMO conjunto do
|
|
76
|
+
* scope guard. `null`/`undefined` = sem limite.
|
|
77
|
+
*
|
|
78
|
+
* @returns {{ ok: boolean, exceeded: Array<{limit, actual, max}> }}
|
|
79
|
+
*/
|
|
80
|
+
function checkDiffLimits({ changedFiles = [], diffPatch = '', maxChangedFiles = null, maxDiffLines = null }) {
|
|
81
|
+
const exceeded = [];
|
|
82
|
+
|
|
83
|
+
if (Number.isInteger(maxChangedFiles) && maxChangedFiles > 0 && changedFiles.length > maxChangedFiles) {
|
|
84
|
+
exceeded.push({ limit: 'max_changed_files', actual: changedFiles.length, max: maxChangedFiles });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (Number.isInteger(maxDiffLines) && maxDiffLines > 0) {
|
|
88
|
+
const actual = countDiffLines(diffPatch);
|
|
89
|
+
if (actual > maxDiffLines) {
|
|
90
|
+
exceeded.push({ limit: 'max_diff_lines', actual, max: maxDiffLines });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { ok: exceeded.length === 0, exceeded };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Feedback de reparo/rollback injetado na próxima iteração após violação
|
|
99
|
+
* (REQ-6). Texto direto para o agente: reverter os paths e permanecer no escopo.
|
|
100
|
+
*/
|
|
101
|
+
function buildRollbackFeedback(violations) {
|
|
102
|
+
const lines = violations.slice(0, 10).map((v) => ` - ${v.path} (${v.reason})`);
|
|
103
|
+
return [
|
|
104
|
+
'SCOPE VIOLATION — files were changed outside the contract scope:',
|
|
105
|
+
...lines,
|
|
106
|
+
'Revert these changes (git checkout -- <path> / delete untracked files) and redo the task touching ONLY files inside the allowed scope.'
|
|
107
|
+
].join('\n');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = {
|
|
111
|
+
checkScope,
|
|
112
|
+
checkDiffLimits,
|
|
113
|
+
countDiffLines,
|
|
114
|
+
buildRollbackFeedback
|
|
115
|
+
};
|