@jaimevalasek/aioson 1.21.8 → 1.23.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 +950 -923
- package/package.json +1 -1
- package/src/agents.js +21 -20
- package/src/cli.js +31 -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-preview.js +74 -0
- package/src/commands/harness-retro.js +221 -0
- package/src/commands/harness-status.js +157 -0
- package/src/commands/harness.js +18 -1
- package/src/commands/self-implement-loop.js +315 -5
- package/src/commands/workflow-next.js +45 -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/preview-artifact.js +85 -0
- package/src/harness/scope-guard.js +115 -0
- package/src/i18n/messages/en.js +23 -0
- package/src/i18n/messages/es.js +32 -9
- package/src/i18n/messages/fr.js +32 -9
- package/src/i18n/messages/pt-BR.js +23 -0
- package/src/lib/dev-resume.js +94 -45
- package/src/lib/retro/retro-aggregate.js +192 -0
- package/src/lib/retro/retro-render.js +185 -0
- package/src/lib/retro/retro-sources.js +624 -0
- 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 +14 -1
- package/template/.aioson/agents/discovery-design-doc.md +4 -0
- package/template/.aioson/agents/pentester.md +8 -0
- package/template/.aioson/agents/pm.md +10 -5
- package/template/.aioson/agents/qa.md +46 -14
- package/template/.aioson/agents/scope-check.md +176 -172
- package/template/.aioson/agents/sheldon.md +13 -0
- package/template/.aioson/agents/tester.md +17 -0
- package/template/.aioson/agents/validator.md +8 -0
- package/template/.aioson/config.md +31 -28
- package/template/.aioson/docs/autopilot-handoff.md +83 -0
- package/template/.aioson/rules/aioson-context-boundary.md +10 -8
- package/template/AGENTS.md +57 -57
- package/template/CLAUDE.md +33 -33
|
@@ -26,6 +26,16 @@ const fsp = require('node:fs/promises');
|
|
|
26
26
|
const bus = require('../squad/intra-bus');
|
|
27
27
|
const stateManager = require('../squad/state-manager');
|
|
28
28
|
const { createCircuitBreaker } = require('../harness/circuit-breaker');
|
|
29
|
+
const { validateContract, resolveContract } = require('../harness/contract-schema');
|
|
30
|
+
const { captureBaseline, computeChangedSet, captureDiffPatch } = require('../harness/git-baseline');
|
|
31
|
+
const { checkScope, checkDiffLimits, buildRollbackFeedback } = require('../harness/scope-guard');
|
|
32
|
+
const { estimateTokens, startRunBudget, recordAttemptTokens, checkBudget, buildBudgetSummary } = require('../harness/budget-guard');
|
|
33
|
+
const { writeAttemptArtifacts } = require('../harness/attempt-artifacts');
|
|
34
|
+
const { previewArtifact } = require('../harness/preview-artifact');
|
|
35
|
+
const { emitGuardEvent } = require('../harness/guard-events');
|
|
36
|
+
const { detectGates, createGate, enterHumanGate, resolveGateState, pendingGates, loadGates } = require('../harness/human-gate');
|
|
37
|
+
const { runCriteria, registerFailureSignatures, startRunSignatures } = require('../harness/criteria-runner');
|
|
38
|
+
const { findActiveContract } = require('../harness/active-contract');
|
|
29
39
|
|
|
30
40
|
// ─── Agent execution ─────────────────────────────────────────────────────────
|
|
31
41
|
|
|
@@ -127,6 +137,204 @@ async function criteriaOnlyVerify(projectDir, artifactPath, criteria) {
|
|
|
127
137
|
};
|
|
128
138
|
}
|
|
129
139
|
|
|
140
|
+
// ─── Loop Guardrails (loop-guardrails) ───────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Hook pós-attempt na ordem D5: (1) artifacts → (2) scope guard + re-hash D2 →
|
|
144
|
+
* (3) diff limits → (4) human gates (Fase 2) → (5) criteria (Fase 2) →
|
|
145
|
+
* (6) budget/runtime. Registrar primeiro, julgar depois.
|
|
146
|
+
*
|
|
147
|
+
* Retorna { blocked, reason, feedback, issues } — `blocked` encerra o run;
|
|
148
|
+
* `feedback` injeta instrução de rollback e segue para a próxima iteração.
|
|
149
|
+
*/
|
|
150
|
+
async function runPostAttemptGuards({ targetDir, guards, cb, logger, attempt, agentOutput }) {
|
|
151
|
+
const { resolved, planDir } = guards;
|
|
152
|
+
const slug = resolved.feature;
|
|
153
|
+
|
|
154
|
+
// (1) registrar sempre, mesmo em falha
|
|
155
|
+
let changed = { files: [], rehashViolations: [] };
|
|
156
|
+
let diffPatch = '';
|
|
157
|
+
if (guards.baseline) {
|
|
158
|
+
try {
|
|
159
|
+
changed = computeChangedSet(targetDir, guards.baseline);
|
|
160
|
+
diffPatch = captureDiffPatch(targetDir);
|
|
161
|
+
} catch { /* git indisponível neste attempt — artifacts parciais */ }
|
|
162
|
+
}
|
|
163
|
+
writeAttemptArtifacts(planDir, attempt, { changedFiles: changed.files, diffPatch });
|
|
164
|
+
|
|
165
|
+
// tokens da tentativa acumulam sempre — o gasto já ocorreu (D3)
|
|
166
|
+
recordAttemptTokens(cb.progress, estimateTokens(agentOutput));
|
|
167
|
+
|
|
168
|
+
const outcome = { blocked: false, reason: null, feedback: null, issues: [] };
|
|
169
|
+
|
|
170
|
+
// (2) scope guard + re-hash D2 (REQ-4/5/6)
|
|
171
|
+
if (guards.baseline) {
|
|
172
|
+
const scope = checkScope({
|
|
173
|
+
changedFiles: changed.files,
|
|
174
|
+
rehashViolations: changed.rehashViolations,
|
|
175
|
+
allowedGlobs: resolved.allowed_files,
|
|
176
|
+
forbiddenGlobs: resolved.forbidden_files
|
|
177
|
+
});
|
|
178
|
+
if (!scope.ok) {
|
|
179
|
+
guards.scopeViolationCount += 1;
|
|
180
|
+
const fileList = scope.violations.map((v) => v.path);
|
|
181
|
+
logger.log(` ✗ Scope violation (${fileList.length} file(s)): ${fileList.slice(0, 5).join(', ')}`);
|
|
182
|
+
await emitGuardEvent(targetDir, {
|
|
183
|
+
eventType: 'scope_violation',
|
|
184
|
+
message: `scope violation on attempt ${attempt}`,
|
|
185
|
+
payload: { slug, attempt, violations: scope.violations }
|
|
186
|
+
});
|
|
187
|
+
if (guards.scopeViolationCount >= 2) {
|
|
188
|
+
// reincidência abre o circuito e escala para humano (REQ-6)
|
|
189
|
+
cb.progress.circuit_state = 'OPEN';
|
|
190
|
+
cb.progress.status = 'circuit_open';
|
|
191
|
+
cb.progress.last_error = `scope_violation_repeat: ${fileList.slice(0, 3).join(', ')}`;
|
|
192
|
+
await cb._save();
|
|
193
|
+
outcome.blocked = true;
|
|
194
|
+
outcome.reason = 'scope_violation_repeat';
|
|
195
|
+
return outcome;
|
|
196
|
+
}
|
|
197
|
+
outcome.reason = 'scope_violation';
|
|
198
|
+
outcome.feedback = buildRollbackFeedback(scope.violations);
|
|
199
|
+
outcome.issues = [{ message: outcome.feedback }];
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// (3) diff limits (REQ-10) — não julga se a tentativa já vai para rollback
|
|
204
|
+
if (!outcome.feedback) {
|
|
205
|
+
const limits = checkDiffLimits({
|
|
206
|
+
changedFiles: changed.files,
|
|
207
|
+
diffPatch,
|
|
208
|
+
maxChangedFiles: resolved.governor.max_changed_files ?? null,
|
|
209
|
+
maxDiffLines: resolved.governor.max_diff_lines ?? null
|
|
210
|
+
});
|
|
211
|
+
if (!limits.ok) {
|
|
212
|
+
const detail = limits.exceeded.map((e) => `${e.limit}: ${e.actual} > ${e.max}`).join('; ');
|
|
213
|
+
logger.log(` ✗ Diff limit exceeded — ${detail}`);
|
|
214
|
+
await emitGuardEvent(targetDir, {
|
|
215
|
+
eventType: 'diff_limit_exceeded',
|
|
216
|
+
message: `diff limits exceeded on attempt ${attempt} (${detail})`,
|
|
217
|
+
payload: { slug, attempt, exceeded: limits.exceeded }
|
|
218
|
+
});
|
|
219
|
+
outcome.blocked = true;
|
|
220
|
+
outcome.reason = 'diff_limit_exceeded';
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// (4) human gates (REQ-12, D4) — violação de escopo precede gate: arquivo
|
|
225
|
+
// fora do escopo merece rollback, não aprovação humana
|
|
226
|
+
if (!outcome.feedback && !outcome.blocked && guards.baseline) {
|
|
227
|
+
const detections = detectGates({
|
|
228
|
+
changedFiles: changed.files,
|
|
229
|
+
requiredFor: resolved.human_gate.required_for,
|
|
230
|
+
themePaths: resolved.human_gate.theme_paths,
|
|
231
|
+
existingGates: loadGates(planDir),
|
|
232
|
+
runId: cb.progress.budget ? cb.progress.budget.run_id : null
|
|
233
|
+
});
|
|
234
|
+
if (detections.length > 0) {
|
|
235
|
+
const created = [];
|
|
236
|
+
for (const detection of detections) {
|
|
237
|
+
const gate = createGate(planDir, {
|
|
238
|
+
theme: detection.theme,
|
|
239
|
+
attempt,
|
|
240
|
+
triggeredBy: detection.triggeredBy,
|
|
241
|
+
diffSummary: `${detection.triggeredBy.length} file(s): ${detection.triggeredBy.slice(0, 3).join(', ')}`,
|
|
242
|
+
runId: cb.progress.budget ? cb.progress.budget.run_id : null
|
|
243
|
+
});
|
|
244
|
+
created.push(gate);
|
|
245
|
+
await emitGuardEvent(targetDir, {
|
|
246
|
+
eventType: 'human_gate_requested',
|
|
247
|
+
message: `human gate ${gate.id} requested on attempt ${attempt}`,
|
|
248
|
+
payload: { slug, attempt, gate_id: gate.id, theme: gate.theme, triggered_by: gate.triggered_by }
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
enterHumanGate(cb.progress, created.map((g) => g.id));
|
|
252
|
+
await cb._save();
|
|
253
|
+
logger.log(` ✗ Human gate requerido (${created.length}):`);
|
|
254
|
+
for (const gate of created) {
|
|
255
|
+
logger.log(` - ${gate.id} [${gate.theme}] — ${gate.diff_summary}`);
|
|
256
|
+
logger.log(` aioson harness:approve . --slug=${slug} --gate=${gate.id}`);
|
|
257
|
+
}
|
|
258
|
+
outcome.blocked = true;
|
|
259
|
+
outcome.reason = 'human_gate_pending';
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// (5) criteria checks (REQ-16/17, D7) — pulado se a tentativa já vai para
|
|
264
|
+
// rollback ou gate (processo encerra)
|
|
265
|
+
if (!outcome.feedback && !outcome.blocked) {
|
|
266
|
+
const checks = await runCriteria({ criteria: resolved.criteria, cwd: targetDir });
|
|
267
|
+
if (checks.length > 0) {
|
|
268
|
+
writeAttemptArtifacts(planDir, attempt, { checks });
|
|
269
|
+
const failed = checks.filter((c) => !c.ok);
|
|
270
|
+
for (const check of failed) {
|
|
271
|
+
await emitGuardEvent(targetDir, {
|
|
272
|
+
eventType: 'criteria_check_failed',
|
|
273
|
+
message: `criterion ${check.id} failed on attempt ${attempt} (exit ${check.exitCode}${check.timedOut ? ', timeout' : ''})`,
|
|
274
|
+
payload: { slug, attempt, criterion_id: check.id, exit_code: check.exitCode, timed_out: check.timedOut, signature: check.signature }
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
const repeats = registerFailureSignatures(cb.progress, failed);
|
|
278
|
+
if (repeats.length > 0) {
|
|
279
|
+
// mesma assinatura 2x no run (EC-13) → para e escala para humano
|
|
280
|
+
cb.progress.circuit_state = 'OPEN';
|
|
281
|
+
cb.progress.status = 'circuit_open';
|
|
282
|
+
cb.progress.last_error = `failure_signature_repeat: ${repeats.map((r) => r.criterion_id).join(', ')}`;
|
|
283
|
+
await cb._save();
|
|
284
|
+
for (const repeat of repeats) {
|
|
285
|
+
await emitGuardEvent(targetDir, {
|
|
286
|
+
eventType: 'failure_signature_repeat',
|
|
287
|
+
message: `criterion ${repeat.criterion_id} failed twice with the same signature in this run`,
|
|
288
|
+
payload: { slug, attempt, criterion_id: repeat.criterion_id, signature: repeat.signature }
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
logger.log(` ✗ Mesma falha 2x no run (${repeats.map((r) => r.criterion_id).join(', ')}) — escalando para humano`);
|
|
292
|
+
outcome.blocked = true;
|
|
293
|
+
outcome.reason = 'failure_signature_repeat';
|
|
294
|
+
} else if (failed.length > 0) {
|
|
295
|
+
logger.log(` ✗ Criteria checks falharam: ${failed.map((c) => c.id).join(', ')}`);
|
|
296
|
+
outcome.reason = 'criteria_check_failed';
|
|
297
|
+
// AC-13: feedback = preview + ponteiro para attempts/{n}/checks/{id}.log
|
|
298
|
+
// (já persistido integralmente por writeAttemptArtifacts acima — persist-first
|
|
299
|
+
// satisfeito pelo fluxo existente). Evita dump integral no contexto do agente.
|
|
300
|
+
outcome.feedback = failed
|
|
301
|
+
.map((c) => {
|
|
302
|
+
const safeId = String(c.id || 'check').replace(/[^A-Za-z0-9._-]/g, '_');
|
|
303
|
+
const logPath = path.join(planDir, 'attempts', String(attempt), 'checks', `${safeId}.log`);
|
|
304
|
+
const raw = `${c.stdout || ''}${c.stderr ? `\n${c.stderr}` : ''}`.trim() || 'no output';
|
|
305
|
+
const { preview } = previewArtifact(raw, { maxBytes: 1024, artifactPath: logPath, persist: false });
|
|
306
|
+
return `Criterion ${c.id} failed (exit ${c.exitCode}${c.timedOut ? ', timeout' : ''}):\n${preview}`;
|
|
307
|
+
})
|
|
308
|
+
.join('\n\n');
|
|
309
|
+
outcome.issues = [{ message: outcome.feedback }];
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// (6) budget/runtime (REQ-7/8, EC-11) — sempre avaliado e persistido
|
|
315
|
+
const budget = checkBudget(cb.progress, {
|
|
316
|
+
costCeilingTokens: resolved.governor.cost_ceiling_tokens ?? null,
|
|
317
|
+
maxRuntimeMinutes: resolved.governor.max_runtime_minutes ?? null
|
|
318
|
+
});
|
|
319
|
+
for (const event of budget.events) {
|
|
320
|
+
logger.log(` ${event.type === 'budget_warning' ? '⚠' : '✗'} ${event.message}`);
|
|
321
|
+
await emitGuardEvent(targetDir, {
|
|
322
|
+
eventType: event.type,
|
|
323
|
+
message: event.message,
|
|
324
|
+
payload: { slug, attempt, ...event.payload },
|
|
325
|
+
tokenCount: cb.progress.budget.tokens_estimated
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
await cb._save();
|
|
329
|
+
if (budget.pause && !outcome.blocked) {
|
|
330
|
+
logger.log(buildBudgetSummary(cb.progress, { maxIterations: resolved.governor.max_steps }));
|
|
331
|
+
outcome.blocked = true;
|
|
332
|
+
outcome.reason = budget.events.find((e) => e.type !== 'budget_warning')?.type || 'budget_exceeded';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return outcome;
|
|
336
|
+
}
|
|
337
|
+
|
|
130
338
|
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
131
339
|
|
|
132
340
|
/**
|
|
@@ -156,19 +364,29 @@ async function runSelfLoop({ args, options = {}, logger }) {
|
|
|
156
364
|
if (fs.existsSync(autoPath)) contractPath = autoPath;
|
|
157
365
|
}
|
|
158
366
|
|
|
367
|
+
// C-01 (QA 2026-06-09): sem --contract e sem --spec, descobre o contrato
|
|
368
|
+
// ATIVO em disco (mesma heurística do git:guard). O happy path do PRD e a
|
|
369
|
+
// retomada instruída por harness:approve/budget-guard re-entram sem flags —
|
|
370
|
+
// um contrato ativo nunca pode ficar silenciosamente fora do loop (REQ-1).
|
|
371
|
+
if (!contractPath) {
|
|
372
|
+
try {
|
|
373
|
+
const active = findActiveContract(targetDir);
|
|
374
|
+
if (active) contractPath = active.contractPath;
|
|
375
|
+
} catch { /* best-effort: descoberta nunca derruba o loop */ }
|
|
376
|
+
}
|
|
377
|
+
|
|
159
378
|
if (contractPath && fs.existsSync(contractPath)) {
|
|
160
379
|
const progressPath = path.join(path.dirname(contractPath), 'progress.json');
|
|
161
380
|
cb = createCircuitBreaker(contractPath, progressPath);
|
|
162
381
|
await cb.load();
|
|
163
382
|
logger.log(`[Harness] Contract loaded: ${path.relative(targetDir, contractPath)}`);
|
|
383
|
+
} else {
|
|
384
|
+
logger.log('[Harness] guardrails inactive — no harness contract loaded');
|
|
164
385
|
}
|
|
165
386
|
|
|
166
|
-
//
|
|
387
|
+
// Teto de iterações pela flag; o contrato (governor EFETIVO) sobrescreve no
|
|
388
|
+
// preflight dos guards, após validação de schema (C-02 / REQ-19).
|
|
167
389
|
let maxIterations = Math.min(Math.max(Number(options['max-iterations'] || 3), 1), 5);
|
|
168
|
-
if (cb && cb.contract && cb.contract.governor && cb.contract.governor.max_steps > 0) {
|
|
169
|
-
maxIterations = cb.contract.governor.max_steps;
|
|
170
|
-
logger.log(`[Harness] Max iterations set by contract: ${maxIterations}`);
|
|
171
|
-
}
|
|
172
390
|
|
|
173
391
|
if (!task) {
|
|
174
392
|
logger.error('Error: --task is required');
|
|
@@ -178,6 +396,74 @@ async function runSelfLoop({ args, options = {}, logger }) {
|
|
|
178
396
|
const sessionId = randomUUID();
|
|
179
397
|
const feedbackHistory = [];
|
|
180
398
|
|
|
399
|
+
// Loop Guardrails — preflight (REQ-1/2 + D3): valida o schema do contrato,
|
|
400
|
+
// captura o baseline git e zera o orçamento do run. Sem contrato = sem
|
|
401
|
+
// guards (retrocompat REQ-11).
|
|
402
|
+
let guards = null;
|
|
403
|
+
if (cb) {
|
|
404
|
+
const schemaResult = validateContract(cb.contract);
|
|
405
|
+
if (!schemaResult.ok) {
|
|
406
|
+
logger.log(`── Harness Block ──────────────────────────────────────────`);
|
|
407
|
+
for (const err of schemaResult.errors) {
|
|
408
|
+
logger.log(` ✗ contract schema invalid: ${err.field} — ${err.reason}`);
|
|
409
|
+
}
|
|
410
|
+
await emitGuardEvent(targetDir, {
|
|
411
|
+
eventType: 'contract_invalid',
|
|
412
|
+
message: 'harness-contract.json failed schema validation',
|
|
413
|
+
payload: { slug: (cb.contract && cb.contract.feature) || null, errors: schemaResult.errors }
|
|
414
|
+
});
|
|
415
|
+
return { ok: false, iterations: 0, verdict: 'BLOCKED', reason: 'contract_schema_invalid', errors: schemaResult.errors };
|
|
416
|
+
}
|
|
417
|
+
for (const warning of schemaResult.warnings) {
|
|
418
|
+
logger.log(` ⚠ contract: ${warning.field} — ${warning.reason}`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const resolved = resolveContract(cb.contract);
|
|
422
|
+
const planDir = path.dirname(contractPath);
|
|
423
|
+
|
|
424
|
+
// C-02 (REQ-19): o breaker (check/recordError) e o teto de iterações leem
|
|
425
|
+
// `contract.governor` — injeta o governor EFETIVO (presets do contract_mode
|
|
426
|
+
// aplicados) para que `builder`/`autopilot` valham fora de budget/diff.
|
|
427
|
+
cb.contract.governor = resolved.governor;
|
|
428
|
+
if (resolved.governor && resolved.governor.max_steps > 0) {
|
|
429
|
+
maxIterations = resolved.governor.max_steps;
|
|
430
|
+
logger.log(`[Harness] Max iterations set by contract: ${maxIterations}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// (EC-9) gates pendentes de run anterior são REAPRESENTADOS antes de
|
|
434
|
+
// qualquer detecção nova; aprovação prévia restaura a retomada (REQ-15)
|
|
435
|
+
resolveGateState(cb.progress, planDir);
|
|
436
|
+
const pendingFromBefore = pendingGates(planDir);
|
|
437
|
+
if (pendingFromBefore.length > 0) {
|
|
438
|
+
enterHumanGate(cb.progress, pendingFromBefore.map((g) => g.id));
|
|
439
|
+
await cb._save();
|
|
440
|
+
logger.log(`── Harness Block ──────────────────────────────────────────`);
|
|
441
|
+
logger.log(` ✗ Human gate pendente (${pendingFromBefore.length}):`);
|
|
442
|
+
for (const gate of pendingFromBefore) {
|
|
443
|
+
logger.log(` - ${gate.id} [${gate.theme}] attempt ${gate.attempt} — ${gate.diff_summary || (gate.triggered_by || []).join(', ')}`);
|
|
444
|
+
logger.log(` aioson harness:approve . --slug=${resolved.feature} --gate=${gate.id}`);
|
|
445
|
+
logger.log(` aioson harness:reject . --slug=${resolved.feature} --gate=${gate.id} --reason="..."`);
|
|
446
|
+
}
|
|
447
|
+
return { ok: false, iterations: 0, verdict: 'BLOCKED', reason: 'human_gate_pending', gates: pendingFromBefore.map((g) => g.id) };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
let baseline = null;
|
|
451
|
+
try {
|
|
452
|
+
const captured = captureBaseline(targetDir, planDir, { forbiddenGlobs: resolved.forbidden_files });
|
|
453
|
+
baseline = captured.baseline;
|
|
454
|
+
for (const warning of captured.warnings) {
|
|
455
|
+
logger.log(` ⚠ baseline: ${warning.path} — ${warning.reason}`);
|
|
456
|
+
}
|
|
457
|
+
} catch (err) {
|
|
458
|
+
logger.log(` ⚠ scope guard inactive for this run: git baseline unavailable (${String(err.message || err).slice(0, 80)})`);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
startRunBudget(cb.progress, sessionId);
|
|
462
|
+
startRunSignatures(cb.progress); // D7: assinaturas de falha são por run
|
|
463
|
+
await cb._save();
|
|
464
|
+
guards = { resolved, planDir, baseline, scopeViolationCount: 0 };
|
|
465
|
+
}
|
|
466
|
+
|
|
181
467
|
logger.log(`Self-implement loop: @${agent} — "${task.slice(0, 60)}${task.length > 60 ? '...' : ''}"`);
|
|
182
468
|
logger.log(`Max iterations: ${maxIterations}`);
|
|
183
469
|
if (spec) logger.log(`Spec: ${spec}`);
|
|
@@ -223,6 +509,30 @@ async function runSelfLoop({ args, options = {}, logger }) {
|
|
|
223
509
|
logger.log(' Verifying...');
|
|
224
510
|
const verifyResult = await runVerification(targetDir, spec, artifact, criteria);
|
|
225
511
|
|
|
512
|
+
// Loop Guardrails — hook pós-attempt (ordem D5). Roda ANTES de aceitar o
|
|
513
|
+
// sucesso: violação de escopo em tentativa "verde" ainda bloqueia.
|
|
514
|
+
if (guards) {
|
|
515
|
+
const guardOutcome = await runPostAttemptGuards({
|
|
516
|
+
targetDir,
|
|
517
|
+
guards,
|
|
518
|
+
cb,
|
|
519
|
+
logger,
|
|
520
|
+
attempt: iteration,
|
|
521
|
+
agentOutput: agentResult.output
|
|
522
|
+
});
|
|
523
|
+
if (guardOutcome.blocked) {
|
|
524
|
+
logger.log(`── Harness Block ──────────────────────────────────────────`);
|
|
525
|
+
logger.log(` ✗ Loop paused by guardrail: ${guardOutcome.reason}`);
|
|
526
|
+
return { ok: false, iterations: iteration, verdict: 'BLOCKED', reason: guardOutcome.reason, feedback: feedbackHistory };
|
|
527
|
+
}
|
|
528
|
+
if (guardOutcome.feedback) {
|
|
529
|
+
const guardVerdict = guardOutcome.reason === 'scope_violation' ? 'SCOPE_VIOLATION' : 'CRITERIA_FAILED';
|
|
530
|
+
feedbackHistory.push({ iteration, verdict: guardVerdict, issues: guardOutcome.issues });
|
|
531
|
+
await cb.recordError(guardOutcome.reason);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
226
536
|
// Record on bus
|
|
227
537
|
if (squad) {
|
|
228
538
|
await bus.post(targetDir, squad, sessionId, {
|
|
@@ -32,9 +32,24 @@ const SCOPE_CHECK_MODES = new Set(['pre-dev', 'post-dev', 'post-fix', 'final']);
|
|
|
32
32
|
const DEFAULT_FEATURE_WORKFLOW_BY_CLASSIFICATION = {
|
|
33
33
|
MICRO: ['product', 'dev', 'qa'],
|
|
34
34
|
SMALL: ['product', 'analyst', 'scope-check', 'architect', 'discovery-design-doc', 'dev', 'qa'],
|
|
35
|
-
MEDIUM
|
|
35
|
+
// MEDIUM routes through @pm after discovery-design-doc (mirrors the
|
|
36
|
+
// project-mode position): Gate C requires implementation-plan-{slug}.md and
|
|
37
|
+
// @pm is its canonical owner (AC-SDLC-15/16) — without the stage, the
|
|
38
|
+
// sequence dead-ends at @dev preflight with no agent to produce the plan.
|
|
39
|
+
MEDIUM: ['product', 'analyst', 'architect', 'discovery-design-doc', 'pm', 'scope-check', 'dev', 'pentester', 'qa']
|
|
36
40
|
};
|
|
37
41
|
|
|
42
|
+
// Stages eligible for autopilot handoff (auto_handoff: true in project.context.md).
|
|
43
|
+
// Two segments — see .aioson/docs/autopilot-handoff.md:
|
|
44
|
+
// 1. analyst → dev: deterministic pre-dev chain; STOPS before the first @dev entry
|
|
45
|
+
// (human clears context and starts implementation).
|
|
46
|
+
// 2. post-dev review cycle: @dev → @qa → @tester/@pentester (when their @qa triggers
|
|
47
|
+
// fire) → @validator → STOPS before feature:close (human approves the close).
|
|
48
|
+
const AUTOPILOT_HANDOFF_STAGES = new Set([
|
|
49
|
+
'analyst', 'scope-check', 'architect', 'discovery-design-doc', 'pm',
|
|
50
|
+
'dev', 'qa', 'tester', 'pentester', 'validator'
|
|
51
|
+
]);
|
|
52
|
+
|
|
38
53
|
function normalizeAgentName(input) {
|
|
39
54
|
return String(input || '')
|
|
40
55
|
.trim()
|
|
@@ -303,6 +318,16 @@ async function validateStageArtifacts(targetDir, state, stage) {
|
|
|
303
318
|
return (await anyExists(designDocCandidates)) && (await anyExists(readinessCandidates));
|
|
304
319
|
}
|
|
305
320
|
|
|
321
|
+
if (stage === 'pm') {
|
|
322
|
+
// Feature mode: @pm's canonical artifact is the implementation plan
|
|
323
|
+
// (Gate C input). Project mode has no single canonical pm artifact —
|
|
324
|
+
// the handoff contract covers feature MEDIUM (AC-SDLC-16).
|
|
325
|
+
if (state.mode === 'feature' && slug) {
|
|
326
|
+
return await exists(path.join(base, `implementation-plan-${slug}.md`));
|
|
327
|
+
}
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
306
331
|
if (stage === 'orchestrator') {
|
|
307
332
|
return await exists(path.join(base, 'parallel'));
|
|
308
333
|
}
|
|
@@ -431,7 +456,9 @@ function isInferableStage(stage) {
|
|
|
431
456
|
// (it has both a validateStageArtifacts branch and a handoff contract). Without
|
|
432
457
|
// it, MEDIUM sequences — where scope-check sits AFTER discovery-design-doc —
|
|
433
458
|
// could never infer scope-check as completed during stale-state recovery.
|
|
434
|
-
|
|
459
|
+
// pm is inferable from implementation-plan-{slug}.md for the same reason:
|
|
460
|
+
// it sits before scope-check in the MEDIUM feature sequence.
|
|
461
|
+
return ['setup', 'product', 'analyst', 'scope-check', 'architect', 'discovery-design-doc', 'ux-ui', 'pm', 'orchestrator'].includes(
|
|
435
462
|
normalizeAgentName(stage)
|
|
436
463
|
);
|
|
437
464
|
}
|
|
@@ -1226,6 +1253,20 @@ async function activateStage(targetDir, state, locale, tool, explicitAgent = nul
|
|
|
1226
1253
|
requestedMode
|
|
1227
1254
|
});
|
|
1228
1255
|
|
|
1256
|
+
let autoHandoff = false;
|
|
1257
|
+
if (
|
|
1258
|
+
AUTOPILOT_HANDOFF_STAGES.has(stageName) &&
|
|
1259
|
+
state.mode === 'feature' &&
|
|
1260
|
+
(state.classification === 'SMALL' || state.classification === 'MEDIUM')
|
|
1261
|
+
) {
|
|
1262
|
+
try {
|
|
1263
|
+
const projectContext = await validateProjectContextFile(targetDir);
|
|
1264
|
+
autoHandoff = Boolean(projectContext && projectContext.data && projectContext.data.auto_handoff === true);
|
|
1265
|
+
} catch {
|
|
1266
|
+
autoHandoff = false;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1229
1270
|
const instructionPath = await resolveExistingInstructionPath(targetDir, agent, locale);
|
|
1230
1271
|
const dependencies = await resolveStageDependencies(targetDir, state, stageName, agent);
|
|
1231
1272
|
let prompt = buildAgentPrompt(agent, tool, {
|
|
@@ -1235,6 +1276,7 @@ async function activateStage(targetDir, state, locale, tool, explicitAgent = nul
|
|
|
1235
1276
|
autonomyMode: effectiveMode,
|
|
1236
1277
|
capabilitySummary: buildAgentCapabilitySummary(agentManifest, tool),
|
|
1237
1278
|
dependsOn: dependencies,
|
|
1279
|
+
autoHandoff,
|
|
1238
1280
|
activationContext: buildStageActivationContext(state, stageName, dependencies, scopeCheckMode)
|
|
1239
1281
|
});
|
|
1240
1282
|
|
|
@@ -1636,6 +1678,7 @@ async function runWorkflowNext({ args, options, logger, t }) {
|
|
|
1636
1678
|
}
|
|
1637
1679
|
|
|
1638
1680
|
module.exports = {
|
|
1681
|
+
AUTOPILOT_HANDOFF_STAGES,
|
|
1639
1682
|
STATE_RELATIVE_PATH,
|
|
1640
1683
|
CONFIG_RELATIVE_PATH,
|
|
1641
1684
|
EVENTS_RELATIVE_PATH,
|
package/src/doctor.js
CHANGED
|
@@ -106,7 +106,7 @@ async function fileContainsAll(filePath, patterns) {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
const DESIGN_GOVERNANCE_FILES = [
|
|
109
|
+
const DESIGN_GOVERNANCE_FILES = [
|
|
110
110
|
'.aioson/design-docs/code-reuse.md',
|
|
111
111
|
'.aioson/design-docs/componentization.md',
|
|
112
112
|
'.aioson/design-docs/file-size.md',
|
|
@@ -114,11 +114,11 @@ const DESIGN_GOVERNANCE_FILES = [
|
|
|
114
114
|
'.aioson/design-docs/naming.md'
|
|
115
115
|
];
|
|
116
116
|
|
|
117
|
-
const GATEWAY_FILE_BY_CHECK_ID = {
|
|
118
|
-
'gateway:claude:contract': 'CLAUDE.md',
|
|
119
|
-
'gateway:codex:contract': 'AGENTS.md',
|
|
120
|
-
'gateway:opencode:contract': 'OPENCODE.md'
|
|
121
|
-
};
|
|
117
|
+
const GATEWAY_FILE_BY_CHECK_ID = {
|
|
118
|
+
'gateway:claude:contract': 'CLAUDE.md',
|
|
119
|
+
'gateway:codex:contract': 'AGENTS.md',
|
|
120
|
+
'gateway:opencode:contract': 'OPENCODE.md'
|
|
121
|
+
};
|
|
122
122
|
|
|
123
123
|
async function restoreTemplateFiles(targetDir, relPaths, options = {}) {
|
|
124
124
|
const dryRun = Boolean(options.dryRun);
|
|
@@ -175,7 +175,7 @@ async function runDoctor(targetDir) {
|
|
|
175
175
|
hintKey: 'doctor.gateway_codex_pointer_hint',
|
|
176
176
|
patterns: ['.aioson/config.md', '.aioson/agents/']
|
|
177
177
|
},
|
|
178
|
-
{
|
|
178
|
+
{
|
|
179
179
|
id: 'gateway:opencode:contract',
|
|
180
180
|
rel: 'OPENCODE.md',
|
|
181
181
|
key: 'doctor.gateway_opencode_pointer',
|
|
@@ -196,7 +196,7 @@ async function runDoctor(targetDir) {
|
|
|
196
196
|
});
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
const contextPath = path.join(targetDir, '.aioson/context/project.context.md');
|
|
199
|
+
const contextPath = path.join(targetDir, '.aioson/context/project.context.md');
|
|
200
200
|
checks.push({
|
|
201
201
|
id: 'context:project',
|
|
202
202
|
key: 'doctor.context_generated',
|
|
@@ -227,6 +227,22 @@ async function runDoctor(targetDir) {
|
|
|
227
227
|
}
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
// Autopilot handoff: protocol doc installed but flag never declared in the
|
|
231
|
+
// context frontmatter — autopilot stays silently inactive (absent = manual
|
|
232
|
+
// handoffs). An explicit true/false is a deliberate choice and passes.
|
|
233
|
+
const autopilotDocExists = await exists(path.join(targetDir, '.aioson/docs/autopilot-handoff.md'));
|
|
234
|
+
if (autopilotDocExists && contextValidation.exists && contextValidation.data) {
|
|
235
|
+
const autoHandoffDeclared = Object.prototype.hasOwnProperty.call(contextValidation.data, 'auto_handoff');
|
|
236
|
+
checks.push({
|
|
237
|
+
id: 'context:auto_handoff_declared',
|
|
238
|
+
severity: 'warning',
|
|
239
|
+
key: 'doctor.auto_handoff_declared',
|
|
240
|
+
params: {},
|
|
241
|
+
ok: autoHandoffDeclared,
|
|
242
|
+
hintKey: autoHandoffDeclared ? undefined : 'doctor.auto_handoff_declared_hint'
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
230
246
|
const major = parseMajor(process.version);
|
|
231
247
|
checks.push({
|
|
232
248
|
id: 'node:version',
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Descoberta do contrato de harness ATIVO (loop-guardrails C-01 / REQ-20).
|
|
5
|
+
*
|
|
6
|
+
* Heurística única, compartilhada entre `git:guard` (camada 2 do scope guard)
|
|
7
|
+
* e `self:loop` (auto-descoberta quando nem --contract nem --spec são
|
|
8
|
+
* passados): varre `.aioson/plans/{slug}/progress.json`, considera candidato quem
|
|
9
|
+
* está `in_progress` ou `human_gate` e tem `harness-contract.json` ao lado,
|
|
10
|
+
* e desempata pelo `last_updated` mais recente.
|
|
11
|
+
*
|
|
12
|
+
* Best-effort por contrato (progress ilegível não é candidato), mas a função
|
|
13
|
+
* em si lança apenas em falha de I/O inesperada — chamadores que precisam de
|
|
14
|
+
* "nunca quebrar" devem envolver em try/catch.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
|
|
20
|
+
function findActiveContract(targetDir) {
|
|
21
|
+
const plansDir = path.join(targetDir, '.aioson', 'plans');
|
|
22
|
+
if (!fs.existsSync(plansDir)) return null;
|
|
23
|
+
const candidates = [];
|
|
24
|
+
for (const slug of fs.readdirSync(plansDir)) {
|
|
25
|
+
const planDir = path.join(plansDir, slug);
|
|
26
|
+
try {
|
|
27
|
+
const progress = JSON.parse(fs.readFileSync(path.join(planDir, 'progress.json'), 'utf8'));
|
|
28
|
+
if (progress.status === 'in_progress' || progress.status === 'human_gate') {
|
|
29
|
+
const contractPath = path.join(planDir, 'harness-contract.json');
|
|
30
|
+
if (fs.existsSync(contractPath)) {
|
|
31
|
+
candidates.push({ slug, contractPath, lastUpdated: progress.last_updated || '' });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
} catch { /* sem progress legível — não é candidato */ }
|
|
35
|
+
}
|
|
36
|
+
if (!candidates.length) return null;
|
|
37
|
+
candidates.sort((a, b) => String(b.lastUpdated).localeCompare(String(a.lastUpdated)));
|
|
38
|
+
return candidates[0];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { findActiveContract };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Writer único de `.aioson/plans/{slug}/attempts/{n}/` (loop-guardrails REQ-9).
|
|
5
|
+
*
|
|
6
|
+
* Scope guard e criteria-runner ENTREGAM dados a este módulo — nunca escrevem
|
|
7
|
+
* direto. Registrar primeiro, julgar depois (D5: artifacts é o passo 1 do hook,
|
|
8
|
+
* sempre executado mesmo em falha).
|
|
9
|
+
*
|
|
10
|
+
* Estrutura:
|
|
11
|
+
* attempts/{n}/changed-files.json — { attempt, detected_at, files[] }
|
|
12
|
+
* attempts/{n}/checks/{id}.log — stdout+stderr + exit code + duração
|
|
13
|
+
* attempts/{n}/diff.patch — git diff da tentativa (should-have)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
|
|
19
|
+
function attemptDir(planDir, attempt) {
|
|
20
|
+
return path.join(planDir, 'attempts', String(attempt));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Grava os artefatos da tentativa. Cada seção é opcional e best-effort
|
|
25
|
+
* independente — falha em uma não impede as outras.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} planDir — .aioson/plans/{slug}
|
|
28
|
+
* @param {number} attempt — número da tentativa (1-based)
|
|
29
|
+
* @param {object} data
|
|
30
|
+
* @param {Array<{path, status}>} [data.changedFiles]
|
|
31
|
+
* @param {Array<{id, command, exitCode, durationMs, stdout, stderr, timedOut}>} [data.checks]
|
|
32
|
+
* @param {string} [data.diffPatch]
|
|
33
|
+
* @returns {{ ok: boolean, dir: string, written: string[] }}
|
|
34
|
+
*/
|
|
35
|
+
function writeAttemptArtifacts(planDir, attempt, { changedFiles, checks, diffPatch } = {}) {
|
|
36
|
+
const dir = attemptDir(planDir, attempt);
|
|
37
|
+
const written = [];
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
41
|
+
} catch {
|
|
42
|
+
return { ok: false, dir, written };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (Array.isArray(changedFiles)) {
|
|
46
|
+
try {
|
|
47
|
+
const payload = {
|
|
48
|
+
attempt,
|
|
49
|
+
detected_at: new Date().toISOString(),
|
|
50
|
+
files: changedFiles.map((f) => ({ path: f.path, status: f.status }))
|
|
51
|
+
};
|
|
52
|
+
fs.writeFileSync(path.join(dir, 'changed-files.json'), JSON.stringify(payload, null, 2), 'utf8');
|
|
53
|
+
written.push('changed-files.json');
|
|
54
|
+
} catch { /* best-effort */ }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (Array.isArray(checks) && checks.length > 0) {
|
|
58
|
+
try {
|
|
59
|
+
const checksDir = path.join(dir, 'checks');
|
|
60
|
+
fs.mkdirSync(checksDir, { recursive: true });
|
|
61
|
+
for (const check of checks) {
|
|
62
|
+
const safeId = String(check.id || 'check').replace(/[^A-Za-z0-9._-]/g, '_');
|
|
63
|
+
const body = [
|
|
64
|
+
`# criterion: ${check.id}`,
|
|
65
|
+
`# command: ${check.command || ''}`,
|
|
66
|
+
`# exit_code: ${check.exitCode === null || check.exitCode === undefined ? 'null' : check.exitCode}`,
|
|
67
|
+
`# duration_ms: ${check.durationMs ?? 0}`,
|
|
68
|
+
`# timed_out: ${Boolean(check.timedOut)}`,
|
|
69
|
+
'',
|
|
70
|
+
'## stdout',
|
|
71
|
+
check.stdout || '',
|
|
72
|
+
'',
|
|
73
|
+
'## stderr',
|
|
74
|
+
check.stderr || ''
|
|
75
|
+
].join('\n');
|
|
76
|
+
fs.writeFileSync(path.join(checksDir, `${safeId}.log`), body, 'utf8');
|
|
77
|
+
written.push(`checks/${safeId}.log`);
|
|
78
|
+
}
|
|
79
|
+
} catch { /* best-effort */ }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (typeof diffPatch === 'string' && diffPatch.length > 0) {
|
|
83
|
+
try {
|
|
84
|
+
fs.writeFileSync(path.join(dir, 'diff.patch'), diffPatch, 'utf8');
|
|
85
|
+
written.push('diff.patch');
|
|
86
|
+
} catch { /* best-effort */ }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return { ok: true, dir, written };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = {
|
|
93
|
+
writeAttemptArtifacts,
|
|
94
|
+
attemptDir
|
|
95
|
+
};
|