@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +18 -4
  2. package/package.json +1 -1
  3. package/src/agents.js +21 -20
  4. package/src/cli.js +15 -0
  5. package/src/commands/feature-close.js +40 -0
  6. package/src/commands/gate-check.js +8 -3
  7. package/src/commands/git-guard.js +58 -0
  8. package/src/commands/harness-gate.js +120 -0
  9. package/src/commands/harness-status.js +157 -0
  10. package/src/commands/harness.js +18 -1
  11. package/src/commands/self-implement-loop.js +305 -5
  12. package/src/commands/workflow-next.js +37 -2
  13. package/src/doctor.js +24 -8
  14. package/src/harness/active-contract.js +41 -0
  15. package/src/harness/attempt-artifacts.js +95 -0
  16. package/src/harness/budget-guard.js +127 -0
  17. package/src/harness/circuit-breaker.js +7 -0
  18. package/src/harness/contract-schema.js +324 -0
  19. package/src/harness/criteria-runner.js +136 -0
  20. package/src/harness/git-baseline.js +204 -0
  21. package/src/harness/glob-match.js +126 -0
  22. package/src/harness/guard-events.js +71 -0
  23. package/src/harness/human-gate.js +182 -0
  24. package/src/harness/scope-guard.js +115 -0
  25. package/src/i18n/messages/en.js +2 -0
  26. package/src/i18n/messages/es.js +11 -9
  27. package/src/i18n/messages/fr.js +11 -9
  28. package/src/i18n/messages/pt-BR.js +2 -0
  29. package/src/lib/dev-resume.js +94 -45
  30. package/src/preflight-engine.js +88 -84
  31. package/template/.aioson/agents/analyst.md +4 -0
  32. package/template/.aioson/agents/architect.md +4 -0
  33. package/template/.aioson/agents/dev.md +3 -1
  34. package/template/.aioson/agents/discovery-design-doc.md +4 -0
  35. package/template/.aioson/agents/pm.md +10 -5
  36. package/template/.aioson/agents/qa.md +22 -14
  37. package/template/.aioson/agents/scope-check.md +176 -172
  38. package/template/.aioson/config.md +31 -28
  39. package/template/.aioson/docs/autopilot-handoff.md +46 -0
  40. package/template/AGENTS.md +57 -57
  41. package/template/CLAUDE.md +33 -33
@@ -26,6 +26,15 @@ 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 { emitGuardEvent } = require('../harness/guard-events');
35
+ const { detectGates, createGate, enterHumanGate, resolveGateState, pendingGates, loadGates } = require('../harness/human-gate');
36
+ const { runCriteria, registerFailureSignatures, startRunSignatures } = require('../harness/criteria-runner');
37
+ const { findActiveContract } = require('../harness/active-contract');
29
38
 
30
39
  // ─── Agent execution ─────────────────────────────────────────────────────────
31
40
 
@@ -127,6 +136,195 @@ async function criteriaOnlyVerify(projectDir, artifactPath, criteria) {
127
136
  };
128
137
  }
129
138
 
139
+ // ─── Loop Guardrails (loop-guardrails) ───────────────────────────────────────
140
+
141
+ /**
142
+ * Hook pós-attempt na ordem D5: (1) artifacts → (2) scope guard + re-hash D2 →
143
+ * (3) diff limits → (4) human gates (Fase 2) → (5) criteria (Fase 2) →
144
+ * (6) budget/runtime. Registrar primeiro, julgar depois.
145
+ *
146
+ * Retorna { blocked, reason, feedback, issues } — `blocked` encerra o run;
147
+ * `feedback` injeta instrução de rollback e segue para a próxima iteração.
148
+ */
149
+ async function runPostAttemptGuards({ targetDir, guards, cb, logger, attempt, agentOutput }) {
150
+ const { resolved, planDir } = guards;
151
+ const slug = resolved.feature;
152
+
153
+ // (1) registrar sempre, mesmo em falha
154
+ let changed = { files: [], rehashViolations: [] };
155
+ let diffPatch = '';
156
+ if (guards.baseline) {
157
+ try {
158
+ changed = computeChangedSet(targetDir, guards.baseline);
159
+ diffPatch = captureDiffPatch(targetDir);
160
+ } catch { /* git indisponível neste attempt — artifacts parciais */ }
161
+ }
162
+ writeAttemptArtifacts(planDir, attempt, { changedFiles: changed.files, diffPatch });
163
+
164
+ // tokens da tentativa acumulam sempre — o gasto já ocorreu (D3)
165
+ recordAttemptTokens(cb.progress, estimateTokens(agentOutput));
166
+
167
+ const outcome = { blocked: false, reason: null, feedback: null, issues: [] };
168
+
169
+ // (2) scope guard + re-hash D2 (REQ-4/5/6)
170
+ if (guards.baseline) {
171
+ const scope = checkScope({
172
+ changedFiles: changed.files,
173
+ rehashViolations: changed.rehashViolations,
174
+ allowedGlobs: resolved.allowed_files,
175
+ forbiddenGlobs: resolved.forbidden_files
176
+ });
177
+ if (!scope.ok) {
178
+ guards.scopeViolationCount += 1;
179
+ const fileList = scope.violations.map((v) => v.path);
180
+ logger.log(` ✗ Scope violation (${fileList.length} file(s)): ${fileList.slice(0, 5).join(', ')}`);
181
+ await emitGuardEvent(targetDir, {
182
+ eventType: 'scope_violation',
183
+ message: `scope violation on attempt ${attempt}`,
184
+ payload: { slug, attempt, violations: scope.violations }
185
+ });
186
+ if (guards.scopeViolationCount >= 2) {
187
+ // reincidência abre o circuito e escala para humano (REQ-6)
188
+ cb.progress.circuit_state = 'OPEN';
189
+ cb.progress.status = 'circuit_open';
190
+ cb.progress.last_error = `scope_violation_repeat: ${fileList.slice(0, 3).join(', ')}`;
191
+ await cb._save();
192
+ outcome.blocked = true;
193
+ outcome.reason = 'scope_violation_repeat';
194
+ return outcome;
195
+ }
196
+ outcome.reason = 'scope_violation';
197
+ outcome.feedback = buildRollbackFeedback(scope.violations);
198
+ outcome.issues = [{ message: outcome.feedback }];
199
+ }
200
+ }
201
+
202
+ // (3) diff limits (REQ-10) — não julga se a tentativa já vai para rollback
203
+ if (!outcome.feedback) {
204
+ const limits = checkDiffLimits({
205
+ changedFiles: changed.files,
206
+ diffPatch,
207
+ maxChangedFiles: resolved.governor.max_changed_files ?? null,
208
+ maxDiffLines: resolved.governor.max_diff_lines ?? null
209
+ });
210
+ if (!limits.ok) {
211
+ const detail = limits.exceeded.map((e) => `${e.limit}: ${e.actual} > ${e.max}`).join('; ');
212
+ logger.log(` ✗ Diff limit exceeded — ${detail}`);
213
+ await emitGuardEvent(targetDir, {
214
+ eventType: 'diff_limit_exceeded',
215
+ message: `diff limits exceeded on attempt ${attempt} (${detail})`,
216
+ payload: { slug, attempt, exceeded: limits.exceeded }
217
+ });
218
+ outcome.blocked = true;
219
+ outcome.reason = 'diff_limit_exceeded';
220
+ }
221
+ }
222
+
223
+ // (4) human gates (REQ-12, D4) — violação de escopo precede gate: arquivo
224
+ // fora do escopo merece rollback, não aprovação humana
225
+ if (!outcome.feedback && !outcome.blocked && guards.baseline) {
226
+ const detections = detectGates({
227
+ changedFiles: changed.files,
228
+ requiredFor: resolved.human_gate.required_for,
229
+ themePaths: resolved.human_gate.theme_paths,
230
+ existingGates: loadGates(planDir),
231
+ runId: cb.progress.budget ? cb.progress.budget.run_id : null
232
+ });
233
+ if (detections.length > 0) {
234
+ const created = [];
235
+ for (const detection of detections) {
236
+ const gate = createGate(planDir, {
237
+ theme: detection.theme,
238
+ attempt,
239
+ triggeredBy: detection.triggeredBy,
240
+ diffSummary: `${detection.triggeredBy.length} file(s): ${detection.triggeredBy.slice(0, 3).join(', ')}`,
241
+ runId: cb.progress.budget ? cb.progress.budget.run_id : null
242
+ });
243
+ created.push(gate);
244
+ await emitGuardEvent(targetDir, {
245
+ eventType: 'human_gate_requested',
246
+ message: `human gate ${gate.id} requested on attempt ${attempt}`,
247
+ payload: { slug, attempt, gate_id: gate.id, theme: gate.theme, triggered_by: gate.triggered_by }
248
+ });
249
+ }
250
+ enterHumanGate(cb.progress, created.map((g) => g.id));
251
+ await cb._save();
252
+ logger.log(` ✗ Human gate requerido (${created.length}):`);
253
+ for (const gate of created) {
254
+ logger.log(` - ${gate.id} [${gate.theme}] — ${gate.diff_summary}`);
255
+ logger.log(` aioson harness:approve . --slug=${slug} --gate=${gate.id}`);
256
+ }
257
+ outcome.blocked = true;
258
+ outcome.reason = 'human_gate_pending';
259
+ }
260
+ }
261
+
262
+ // (5) criteria checks (REQ-16/17, D7) — pulado se a tentativa já vai para
263
+ // rollback ou gate (processo encerra)
264
+ if (!outcome.feedback && !outcome.blocked) {
265
+ const checks = await runCriteria({ criteria: resolved.criteria, cwd: targetDir });
266
+ if (checks.length > 0) {
267
+ writeAttemptArtifacts(planDir, attempt, { checks });
268
+ const failed = checks.filter((c) => !c.ok);
269
+ for (const check of failed) {
270
+ await emitGuardEvent(targetDir, {
271
+ eventType: 'criteria_check_failed',
272
+ message: `criterion ${check.id} failed on attempt ${attempt} (exit ${check.exitCode}${check.timedOut ? ', timeout' : ''})`,
273
+ payload: { slug, attempt, criterion_id: check.id, exit_code: check.exitCode, timed_out: check.timedOut, signature: check.signature }
274
+ });
275
+ }
276
+ const repeats = registerFailureSignatures(cb.progress, failed);
277
+ if (repeats.length > 0) {
278
+ // mesma assinatura 2x no run (EC-13) → para e escala para humano
279
+ cb.progress.circuit_state = 'OPEN';
280
+ cb.progress.status = 'circuit_open';
281
+ cb.progress.last_error = `failure_signature_repeat: ${repeats.map((r) => r.criterion_id).join(', ')}`;
282
+ await cb._save();
283
+ for (const repeat of repeats) {
284
+ await emitGuardEvent(targetDir, {
285
+ eventType: 'failure_signature_repeat',
286
+ message: `criterion ${repeat.criterion_id} failed twice with the same signature in this run`,
287
+ payload: { slug, attempt, criterion_id: repeat.criterion_id, signature: repeat.signature }
288
+ });
289
+ }
290
+ logger.log(` ✗ Mesma falha 2x no run (${repeats.map((r) => r.criterion_id).join(', ')}) — escalando para humano`);
291
+ outcome.blocked = true;
292
+ outcome.reason = 'failure_signature_repeat';
293
+ } else if (failed.length > 0) {
294
+ logger.log(` ✗ Criteria checks falharam: ${failed.map((c) => c.id).join(', ')}`);
295
+ outcome.reason = 'criteria_check_failed';
296
+ outcome.feedback = failed
297
+ .map((c) => `Criterion ${c.id} failed (exit ${c.exitCode}${c.timedOut ? ', timeout' : ''}): ${(c.stderr || c.stdout || '').split('\n').find((l) => l.trim()) || 'no output'}`)
298
+ .join('\n');
299
+ outcome.issues = [{ message: outcome.feedback }];
300
+ }
301
+ }
302
+ }
303
+
304
+ // (6) budget/runtime (REQ-7/8, EC-11) — sempre avaliado e persistido
305
+ const budget = checkBudget(cb.progress, {
306
+ costCeilingTokens: resolved.governor.cost_ceiling_tokens ?? null,
307
+ maxRuntimeMinutes: resolved.governor.max_runtime_minutes ?? null
308
+ });
309
+ for (const event of budget.events) {
310
+ logger.log(` ${event.type === 'budget_warning' ? '⚠' : '✗'} ${event.message}`);
311
+ await emitGuardEvent(targetDir, {
312
+ eventType: event.type,
313
+ message: event.message,
314
+ payload: { slug, attempt, ...event.payload },
315
+ tokenCount: cb.progress.budget.tokens_estimated
316
+ });
317
+ }
318
+ await cb._save();
319
+ if (budget.pause && !outcome.blocked) {
320
+ logger.log(buildBudgetSummary(cb.progress, { maxIterations: resolved.governor.max_steps }));
321
+ outcome.blocked = true;
322
+ outcome.reason = budget.events.find((e) => e.type !== 'budget_warning')?.type || 'budget_exceeded';
323
+ }
324
+
325
+ return outcome;
326
+ }
327
+
130
328
  // ─── Public API ──────────────────────────────────────────────────────────────
131
329
 
132
330
  /**
@@ -156,19 +354,29 @@ async function runSelfLoop({ args, options = {}, logger }) {
156
354
  if (fs.existsSync(autoPath)) contractPath = autoPath;
157
355
  }
158
356
 
357
+ // C-01 (QA 2026-06-09): sem --contract e sem --spec, descobre o contrato
358
+ // ATIVO em disco (mesma heurística do git:guard). O happy path do PRD e a
359
+ // retomada instruída por harness:approve/budget-guard re-entram sem flags —
360
+ // um contrato ativo nunca pode ficar silenciosamente fora do loop (REQ-1).
361
+ if (!contractPath) {
362
+ try {
363
+ const active = findActiveContract(targetDir);
364
+ if (active) contractPath = active.contractPath;
365
+ } catch { /* best-effort: descoberta nunca derruba o loop */ }
366
+ }
367
+
159
368
  if (contractPath && fs.existsSync(contractPath)) {
160
369
  const progressPath = path.join(path.dirname(contractPath), 'progress.json');
161
370
  cb = createCircuitBreaker(contractPath, progressPath);
162
371
  await cb.load();
163
372
  logger.log(`[Harness] Contract loaded: ${path.relative(targetDir, contractPath)}`);
373
+ } else {
374
+ logger.log('[Harness] guardrails inactive — no harness contract loaded');
164
375
  }
165
376
 
166
- // Set max iterations: Contract policy takes precedence over flag
377
+ // Teto de iterações pela flag; o contrato (governor EFETIVO) sobrescreve no
378
+ // preflight dos guards, após validação de schema (C-02 / REQ-19).
167
379
  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
380
 
173
381
  if (!task) {
174
382
  logger.error('Error: --task is required');
@@ -178,6 +386,74 @@ async function runSelfLoop({ args, options = {}, logger }) {
178
386
  const sessionId = randomUUID();
179
387
  const feedbackHistory = [];
180
388
 
389
+ // Loop Guardrails — preflight (REQ-1/2 + D3): valida o schema do contrato,
390
+ // captura o baseline git e zera o orçamento do run. Sem contrato = sem
391
+ // guards (retrocompat REQ-11).
392
+ let guards = null;
393
+ if (cb) {
394
+ const schemaResult = validateContract(cb.contract);
395
+ if (!schemaResult.ok) {
396
+ logger.log(`── Harness Block ──────────────────────────────────────────`);
397
+ for (const err of schemaResult.errors) {
398
+ logger.log(` ✗ contract schema invalid: ${err.field} — ${err.reason}`);
399
+ }
400
+ await emitGuardEvent(targetDir, {
401
+ eventType: 'contract_invalid',
402
+ message: 'harness-contract.json failed schema validation',
403
+ payload: { slug: (cb.contract && cb.contract.feature) || null, errors: schemaResult.errors }
404
+ });
405
+ return { ok: false, iterations: 0, verdict: 'BLOCKED', reason: 'contract_schema_invalid', errors: schemaResult.errors };
406
+ }
407
+ for (const warning of schemaResult.warnings) {
408
+ logger.log(` ⚠ contract: ${warning.field} — ${warning.reason}`);
409
+ }
410
+
411
+ const resolved = resolveContract(cb.contract);
412
+ const planDir = path.dirname(contractPath);
413
+
414
+ // C-02 (REQ-19): o breaker (check/recordError) e o teto de iterações leem
415
+ // `contract.governor` — injeta o governor EFETIVO (presets do contract_mode
416
+ // aplicados) para que `builder`/`autopilot` valham fora de budget/diff.
417
+ cb.contract.governor = resolved.governor;
418
+ if (resolved.governor && resolved.governor.max_steps > 0) {
419
+ maxIterations = resolved.governor.max_steps;
420
+ logger.log(`[Harness] Max iterations set by contract: ${maxIterations}`);
421
+ }
422
+
423
+ // (EC-9) gates pendentes de run anterior são REAPRESENTADOS antes de
424
+ // qualquer detecção nova; aprovação prévia restaura a retomada (REQ-15)
425
+ resolveGateState(cb.progress, planDir);
426
+ const pendingFromBefore = pendingGates(planDir);
427
+ if (pendingFromBefore.length > 0) {
428
+ enterHumanGate(cb.progress, pendingFromBefore.map((g) => g.id));
429
+ await cb._save();
430
+ logger.log(`── Harness Block ──────────────────────────────────────────`);
431
+ logger.log(` ✗ Human gate pendente (${pendingFromBefore.length}):`);
432
+ for (const gate of pendingFromBefore) {
433
+ logger.log(` - ${gate.id} [${gate.theme}] attempt ${gate.attempt} — ${gate.diff_summary || (gate.triggered_by || []).join(', ')}`);
434
+ logger.log(` aioson harness:approve . --slug=${resolved.feature} --gate=${gate.id}`);
435
+ logger.log(` aioson harness:reject . --slug=${resolved.feature} --gate=${gate.id} --reason="..."`);
436
+ }
437
+ return { ok: false, iterations: 0, verdict: 'BLOCKED', reason: 'human_gate_pending', gates: pendingFromBefore.map((g) => g.id) };
438
+ }
439
+
440
+ let baseline = null;
441
+ try {
442
+ const captured = captureBaseline(targetDir, planDir, { forbiddenGlobs: resolved.forbidden_files });
443
+ baseline = captured.baseline;
444
+ for (const warning of captured.warnings) {
445
+ logger.log(` ⚠ baseline: ${warning.path} — ${warning.reason}`);
446
+ }
447
+ } catch (err) {
448
+ logger.log(` ⚠ scope guard inactive for this run: git baseline unavailable (${String(err.message || err).slice(0, 80)})`);
449
+ }
450
+
451
+ startRunBudget(cb.progress, sessionId);
452
+ startRunSignatures(cb.progress); // D7: assinaturas de falha são por run
453
+ await cb._save();
454
+ guards = { resolved, planDir, baseline, scopeViolationCount: 0 };
455
+ }
456
+
181
457
  logger.log(`Self-implement loop: @${agent} — "${task.slice(0, 60)}${task.length > 60 ? '...' : ''}"`);
182
458
  logger.log(`Max iterations: ${maxIterations}`);
183
459
  if (spec) logger.log(`Spec: ${spec}`);
@@ -223,6 +499,30 @@ async function runSelfLoop({ args, options = {}, logger }) {
223
499
  logger.log(' Verifying...');
224
500
  const verifyResult = await runVerification(targetDir, spec, artifact, criteria);
225
501
 
502
+ // Loop Guardrails — hook pós-attempt (ordem D5). Roda ANTES de aceitar o
503
+ // sucesso: violação de escopo em tentativa "verde" ainda bloqueia.
504
+ if (guards) {
505
+ const guardOutcome = await runPostAttemptGuards({
506
+ targetDir,
507
+ guards,
508
+ cb,
509
+ logger,
510
+ attempt: iteration,
511
+ agentOutput: agentResult.output
512
+ });
513
+ if (guardOutcome.blocked) {
514
+ logger.log(`── Harness Block ──────────────────────────────────────────`);
515
+ logger.log(` ✗ Loop paused by guardrail: ${guardOutcome.reason}`);
516
+ return { ok: false, iterations: iteration, verdict: 'BLOCKED', reason: guardOutcome.reason, feedback: feedbackHistory };
517
+ }
518
+ if (guardOutcome.feedback) {
519
+ const guardVerdict = guardOutcome.reason === 'scope_violation' ? 'SCOPE_VIOLATION' : 'CRITERIA_FAILED';
520
+ feedbackHistory.push({ iteration, verdict: guardVerdict, issues: guardOutcome.issues });
521
+ await cb.recordError(guardOutcome.reason);
522
+ continue;
523
+ }
524
+ }
525
+
226
526
  // Record on bus
227
527
  if (squad) {
228
528
  await bus.post(targetDir, squad, sessionId, {
@@ -32,9 +32,17 @@ 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: ['product', 'analyst', 'architect', 'discovery-design-doc', 'scope-check', 'dev', 'pentester', 'qa']
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
+ // The chain always breaks at the @dev handoff — see .aioson/docs/autopilot-handoff.md.
44
+ const AUTOPILOT_HANDOFF_STAGES = new Set(['analyst', 'scope-check', 'architect', 'discovery-design-doc', 'pm']);
45
+
38
46
  function normalizeAgentName(input) {
39
47
  return String(input || '')
40
48
  .trim()
@@ -303,6 +311,16 @@ async function validateStageArtifacts(targetDir, state, stage) {
303
311
  return (await anyExists(designDocCandidates)) && (await anyExists(readinessCandidates));
304
312
  }
305
313
 
314
+ if (stage === 'pm') {
315
+ // Feature mode: @pm's canonical artifact is the implementation plan
316
+ // (Gate C input). Project mode has no single canonical pm artifact —
317
+ // the handoff contract covers feature MEDIUM (AC-SDLC-16).
318
+ if (state.mode === 'feature' && slug) {
319
+ return await exists(path.join(base, `implementation-plan-${slug}.md`));
320
+ }
321
+ return true;
322
+ }
323
+
306
324
  if (stage === 'orchestrator') {
307
325
  return await exists(path.join(base, 'parallel'));
308
326
  }
@@ -431,7 +449,9 @@ function isInferableStage(stage) {
431
449
  // (it has both a validateStageArtifacts branch and a handoff contract). Without
432
450
  // it, MEDIUM sequences — where scope-check sits AFTER discovery-design-doc —
433
451
  // could never infer scope-check as completed during stale-state recovery.
434
- return ['setup', 'product', 'analyst', 'scope-check', 'architect', 'discovery-design-doc', 'ux-ui', 'orchestrator'].includes(
452
+ // pm is inferable from implementation-plan-{slug}.md for the same reason:
453
+ // it sits before scope-check in the MEDIUM feature sequence.
454
+ return ['setup', 'product', 'analyst', 'scope-check', 'architect', 'discovery-design-doc', 'ux-ui', 'pm', 'orchestrator'].includes(
435
455
  normalizeAgentName(stage)
436
456
  );
437
457
  }
@@ -1226,6 +1246,20 @@ async function activateStage(targetDir, state, locale, tool, explicitAgent = nul
1226
1246
  requestedMode
1227
1247
  });
1228
1248
 
1249
+ let autoHandoff = false;
1250
+ if (
1251
+ AUTOPILOT_HANDOFF_STAGES.has(stageName) &&
1252
+ state.mode === 'feature' &&
1253
+ (state.classification === 'SMALL' || state.classification === 'MEDIUM')
1254
+ ) {
1255
+ try {
1256
+ const projectContext = await validateProjectContextFile(targetDir);
1257
+ autoHandoff = Boolean(projectContext && projectContext.data && projectContext.data.auto_handoff === true);
1258
+ } catch {
1259
+ autoHandoff = false;
1260
+ }
1261
+ }
1262
+
1229
1263
  const instructionPath = await resolveExistingInstructionPath(targetDir, agent, locale);
1230
1264
  const dependencies = await resolveStageDependencies(targetDir, state, stageName, agent);
1231
1265
  let prompt = buildAgentPrompt(agent, tool, {
@@ -1235,6 +1269,7 @@ async function activateStage(targetDir, state, locale, tool, explicitAgent = nul
1235
1269
  autonomyMode: effectiveMode,
1236
1270
  capabilitySummary: buildAgentCapabilitySummary(agentManifest, tool),
1237
1271
  dependsOn: dependencies,
1272
+ autoHandoff,
1238
1273
  activationContext: buildStageActivationContext(state, stageName, dependencies, scopeCheckMode)
1239
1274
  });
1240
1275
 
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
+ };