@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +950 -923
  2. package/package.json +1 -1
  3. package/src/agents.js +21 -20
  4. package/src/cli.js +31 -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-preview.js +74 -0
  10. package/src/commands/harness-retro.js +221 -0
  11. package/src/commands/harness-status.js +157 -0
  12. package/src/commands/harness.js +18 -1
  13. package/src/commands/self-implement-loop.js +315 -5
  14. package/src/commands/workflow-next.js +45 -2
  15. package/src/doctor.js +24 -8
  16. package/src/harness/active-contract.js +41 -0
  17. package/src/harness/attempt-artifacts.js +95 -0
  18. package/src/harness/budget-guard.js +127 -0
  19. package/src/harness/circuit-breaker.js +7 -0
  20. package/src/harness/contract-schema.js +324 -0
  21. package/src/harness/criteria-runner.js +136 -0
  22. package/src/harness/git-baseline.js +204 -0
  23. package/src/harness/glob-match.js +126 -0
  24. package/src/harness/guard-events.js +71 -0
  25. package/src/harness/human-gate.js +182 -0
  26. package/src/harness/preview-artifact.js +85 -0
  27. package/src/harness/scope-guard.js +115 -0
  28. package/src/i18n/messages/en.js +23 -0
  29. package/src/i18n/messages/es.js +32 -9
  30. package/src/i18n/messages/fr.js +32 -9
  31. package/src/i18n/messages/pt-BR.js +23 -0
  32. package/src/lib/dev-resume.js +94 -45
  33. package/src/lib/retro/retro-aggregate.js +192 -0
  34. package/src/lib/retro/retro-render.js +185 -0
  35. package/src/lib/retro/retro-sources.js +624 -0
  36. package/src/preflight-engine.js +88 -84
  37. package/template/.aioson/agents/analyst.md +4 -0
  38. package/template/.aioson/agents/architect.md +4 -0
  39. package/template/.aioson/agents/dev.md +14 -1
  40. package/template/.aioson/agents/discovery-design-doc.md +4 -0
  41. package/template/.aioson/agents/pentester.md +8 -0
  42. package/template/.aioson/agents/pm.md +10 -5
  43. package/template/.aioson/agents/qa.md +46 -14
  44. package/template/.aioson/agents/scope-check.md +176 -172
  45. package/template/.aioson/agents/sheldon.md +13 -0
  46. package/template/.aioson/agents/tester.md +17 -0
  47. package/template/.aioson/agents/validator.md +8 -0
  48. package/template/.aioson/config.md +31 -28
  49. package/template/.aioson/docs/autopilot-handoff.md +83 -0
  50. package/template/.aioson/rules/aioson-context-boundary.md +10 -8
  51. package/template/AGENTS.md +57 -57
  52. 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
- // Set max iterations: Contract policy takes precedence over flag
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: ['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
+ // 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
- return ['setup', 'product', 'analyst', 'scope-check', 'architect', 'discovery-design-doc', 'ux-ui', 'orchestrator'].includes(
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
+ };