@jaimevalasek/aioson 1.36.0 → 1.37.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 (85) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/docs/en/1-understand/ecosystem-map.md +1 -1
  3. package/docs/en/1-understand/glossary.md +1 -1
  4. package/docs/en/2-start/first-project.md +1 -1
  5. package/docs/en/2-start/initial-decisions.md +4 -2
  6. package/docs/en/3-recipes/from-idea-to-prd-via-briefing.md +8 -1
  7. package/docs/en/3-recipes/full-feature-with-sheldon.md +3 -1
  8. package/docs/en/4-agents/README.md +6 -3
  9. package/docs/en/4-agents/briefing-refiner.md +146 -0
  10. package/docs/en/5-reference/README.md +1 -0
  11. package/docs/en/5-reference/autopilot-handoff.md +286 -0
  12. package/docs/en/5-reference/cli-reference.md +6 -0
  13. package/docs/pt/1-entender/glossario.md +1 -1
  14. package/docs/pt/1-entender/mapa-do-ecossistema.md +1 -1
  15. package/docs/pt/2-comecar/decisoes-iniciais.md +4 -2
  16. package/docs/pt/2-comecar/primeiro-projeto.md +1 -1
  17. package/docs/pt/3-receitas/da-ideia-ao-prd-via-briefing.md +8 -1
  18. package/docs/pt/3-receitas/feature-completa-com-sheldon.md +3 -1
  19. package/docs/pt/4-agentes/README.md +13 -11
  20. package/docs/pt/4-agentes/briefing-refiner.md +64 -40
  21. package/docs/pt/4-agentes/briefing.md +6 -1
  22. package/docs/pt/4-agentes/dev.md +19 -1
  23. package/docs/pt/4-agentes/deyvin.md +4 -0
  24. package/docs/pt/4-agentes/discover.md +4 -0
  25. package/docs/pt/4-agentes/neo.md +4 -0
  26. package/docs/pt/4-agentes/orache.md +6 -0
  27. package/docs/pt/4-agentes/orchestrator.md +12 -0
  28. package/docs/pt/4-agentes/pentester.md +6 -0
  29. package/docs/pt/4-agentes/product.md +19 -1
  30. package/docs/pt/4-agentes/qa.md +10 -2
  31. package/docs/pt/4-agentes/setup.md +3 -1
  32. package/docs/pt/4-agentes/sheldon.md +12 -0
  33. package/docs/pt/4-agentes/tester.md +6 -0
  34. package/docs/pt/4-agentes/ux-ui.md +2 -1
  35. package/docs/pt/5-referencia/README.md +1 -1
  36. package/docs/pt/5-referencia/agent-chain-continuity.md +1 -1
  37. package/docs/pt/5-referencia/autopilot-handoff.md +191 -74
  38. package/docs/pt/5-referencia/comandos-cli.md +16 -7
  39. package/docs/pt/5-referencia/skills.md +2 -0
  40. package/docs/pt/agentes.md +3 -1
  41. package/package.json +1 -1
  42. package/src/agents.js +1 -1
  43. package/src/artifact-kinds.js +2 -1
  44. package/src/autopilot-signal.js +71 -0
  45. package/src/cli.js +9 -1
  46. package/src/commands/agents.js +18 -2
  47. package/src/commands/briefing.js +337 -1
  48. package/src/commands/feature-close.js +136 -43
  49. package/src/commands/live.js +47 -11
  50. package/src/commands/update.js +5 -1
  51. package/src/commands/verification-plan.js +28 -1
  52. package/src/commands/verify-artifact.js +64 -1
  53. package/src/commands/workflow-execute.js +149 -31
  54. package/src/commands/workflow-next.js +60 -16
  55. package/src/doctor.js +4 -2
  56. package/src/i18n/messages/en.js +2 -1
  57. package/src/i18n/messages/es.js +2 -1
  58. package/src/i18n/messages/fr.js +2 -1
  59. package/src/i18n/messages/pt-BR.js +2 -1
  60. package/src/lib/briefing-refiner/apply-feedback.js +18 -4
  61. package/src/lib/briefing-refiner/feedback-schema.js +73 -4
  62. package/src/lib/briefing-refiner/refinement-report.js +11 -0
  63. package/src/lib/briefing-refiner/review-html.js +388 -68
  64. package/src/parser.js +6 -0
  65. package/template/.aioson/agents/briefing-refiner.md +87 -47
  66. package/template/.aioson/agents/briefing.md +4 -0
  67. package/template/.aioson/agents/dev.md +9 -2
  68. package/template/.aioson/agents/deyvin.md +4 -0
  69. package/template/.aioson/agents/discover.md +4 -0
  70. package/template/.aioson/agents/neo.md +4 -0
  71. package/template/.aioson/agents/orache.md +4 -0
  72. package/template/.aioson/agents/orchestrator.md +16 -0
  73. package/template/.aioson/agents/pentester.md +4 -0
  74. package/template/.aioson/agents/product.md +25 -1
  75. package/template/.aioson/agents/qa.md +5 -1
  76. package/template/.aioson/agents/sheldon.md +9 -1
  77. package/template/.aioson/agents/tester.md +4 -0
  78. package/template/.aioson/agents/ux-ui.md +1 -1
  79. package/template/.aioson/docs/agent-help.md +126 -0
  80. package/template/.aioson/docs/autopilot-handoff.md +26 -16
  81. package/template/.aioson/docs/dev/phase-loop.md +8 -5
  82. package/template/.aioson/docs/play/llm-data-and-bindings.md +70 -7
  83. package/template/.aioson/skills/design/interface-design/SKILL.md +17 -0
  84. package/template/AGENTS.md +36 -36
  85. package/template/CLAUDE.md +1 -1
@@ -235,14 +235,30 @@ async function resolveExecutablePath(command) {
235
235
  .map((entry) => entry.trim())
236
236
  .filter(Boolean);
237
237
 
238
- const extensions = process.platform === 'win32'
239
- ? Array.from(new Set([...String(process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').map((entry) => entry.toLowerCase())]))
240
- : [''];
238
+ // No Windows NUNCA testamos o arquivo sem extensão: o npm instala um shim
239
+ // extensionless (script sh) ao lado do .cmd, e ele não é executável via
240
+ // CreateProcess — spawná-lo dá ENOENT mesmo com o arquivo existindo.
241
+ // filter(Boolean) descarta segmentos vazios de um PATHEXT malformado
242
+ // (";.COM;..."), que viravam candidato sem extensão.
243
+ let extensions;
244
+ if (process.platform === 'win32') {
245
+ extensions = Array.from(new Set(
246
+ String(process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
247
+ .split(';')
248
+ .map((entry) => entry.trim().toLowerCase())
249
+ .filter(Boolean)
250
+ ));
251
+ if (extensions.length === 0) {
252
+ extensions = ['.exe', '.cmd', '.bat', '.com'];
253
+ }
254
+ } else {
255
+ extensions = [''];
256
+ }
241
257
 
242
258
  for (const dir of pathEntries) {
243
259
  for (const ext of extensions) {
244
- const candidate = process.platform === 'win32' && ext && !binary.toLowerCase().endsWith(ext)
245
- ? path.join(dir, `${binary}${ext}`)
260
+ const candidate = process.platform === 'win32'
261
+ ? (binary.toLowerCase().endsWith(ext) ? path.join(dir, binary) : path.join(dir, `${binary}${ext}`))
246
262
  : path.join(dir, binary);
247
263
  if (await exists(candidate)) {
248
264
  return candidate;
@@ -673,10 +689,30 @@ function createLiveEventRecord(context, options = {}) {
673
689
  };
674
690
  }
675
691
 
692
+ // Latch de spawn: entre o spawn() e o await de waitForChild existem awaits
693
+ // (writeLiveState etc.). Se o spawn falhar nessa janela, o 'error' emitia sem
694
+ // listener e DERRUBAVA o node ("Unhandled 'error' event"). Guardamos o
695
+ // desfecho no próprio child pra waitForChild resolver/rejeitar mesmo quando o
696
+ // evento já passou.
697
+ function trackChild(child) {
698
+ child.aiosonSpawnError = null;
699
+ child.aiosonExit = null;
700
+ child.on('error', (err) => {
701
+ if (!child.aiosonSpawnError) child.aiosonSpawnError = err;
702
+ });
703
+ child.on('close', (code, signal) => {
704
+ child.aiosonExit = { code: Number(code || 0), signal: signal || null };
705
+ });
706
+ return child;
707
+ }
708
+
676
709
  function waitForChild(child) {
677
710
  return new Promise((resolve, reject) => {
711
+ if (child.aiosonSpawnError) return reject(child.aiosonSpawnError);
712
+ if (child.aiosonExit) return resolve(child.aiosonExit);
678
713
  child.once('error', reject);
679
714
  child.once('close', (code, signal) => {
715
+ if (child.aiosonSpawnError) return reject(child.aiosonSpawnError);
680
716
  resolve({ code: Number(code || 0), signal: signal || null });
681
717
  });
682
718
  });
@@ -1319,12 +1355,12 @@ async function runLiveStart({ args, options = {}, logger, t }) {
1319
1355
  let attachResult = null;
1320
1356
 
1321
1357
  if (attach && !noLaunch) {
1322
- attachChild = spawn(spawnExecutable(binaryPath), buildLaunchArgs(options, tool), {
1358
+ attachChild = trackChild(spawn(spawnExecutable(binaryPath), buildLaunchArgs(options, tool), {
1323
1359
  cwd: targetDir,
1324
1360
  env: process.env,
1325
1361
  stdio: 'inherit',
1326
1362
  shell: process.platform === 'win32'
1327
- });
1363
+ }));
1328
1364
  state.child_pid = attachChild.pid || null;
1329
1365
  if (existing.task?.task_key) {
1330
1366
  const taskMeta = parseTaskMeta(existing.task);
@@ -1428,12 +1464,12 @@ async function runLiveStart({ args, options = {}, logger, t }) {
1428
1464
  });
1429
1465
  } else {
1430
1466
  // Fallback to normal spawn if tmux not available
1431
- child = spawn(spawnExecutable(binaryPath), buildLaunchArgs(options, tool), {
1467
+ child = trackChild(spawn(spawnExecutable(binaryPath), buildLaunchArgs(options, tool), {
1432
1468
  cwd: targetDir,
1433
1469
  env: process.env,
1434
1470
  stdio: 'inherit',
1435
1471
  shell: process.platform === 'win32'
1436
- });
1472
+ }));
1437
1473
  taskMeta.child_pid = child.pid || null;
1438
1474
  updateTask(db, {
1439
1475
  taskKey,
@@ -1441,12 +1477,12 @@ async function runLiveStart({ args, options = {}, logger, t }) {
1441
1477
  });
1442
1478
  }
1443
1479
  } else {
1444
- child = spawn(spawnExecutable(binaryPath), buildLaunchArgs(options, tool), {
1480
+ child = trackChild(spawn(spawnExecutable(binaryPath), buildLaunchArgs(options, tool), {
1445
1481
  cwd: targetDir,
1446
1482
  env: process.env,
1447
1483
  stdio: 'inherit',
1448
1484
  shell: process.platform === 'win32'
1449
- });
1485
+ }));
1450
1486
  taskMeta.child_pid = child.pid || null;
1451
1487
  updateTask(db, {
1452
1488
  taskKey,
@@ -5,6 +5,7 @@ const { detectFramework } = require('../detector');
5
5
  const { updateInstallation } = require('../updater');
6
6
  const { validateProjectContextFile, getInteractionLanguage } = require('../context');
7
7
  const { applyAgentLocale } = require('../locales');
8
+ const { getCliVersionLabelSync } = require('../version');
8
9
 
9
10
  async function runUpdate({ args, options, logger, t }) {
10
11
  const targetDir = path.resolve(process.cwd(), args[0] || '.');
@@ -36,10 +37,13 @@ async function runUpdate({ args, options, logger, t }) {
36
37
  (result.savedProfile && result.savedProfile.locale
37
38
  ? result.savedProfile.locale
38
39
  : 'en');
39
- localeSync = await applyAgentLocale(targetDir, language, { dryRun, selectiveUpdate: selective && !all });
40
+ localeSync = await applyAgentLocale(targetDir, language, { dryRun, selectiveUpdate: selective && !all });
40
41
  }
41
42
 
42
43
  logger.log(t('update.done_at', { targetDir }));
44
+ // Surface WHICH template landed — stale-template updates used to be silent
45
+ // (the copy comes from the installed CLI's own bundle, not from npm latest).
46
+ logger.log(t('update.template_version', { version: getCliVersionLabelSync() }));
43
47
  logger.log(t('update.files_updated', { count: result.copied.length }));
44
48
  logger.log(t('update.backups_created', { count: result.backedUp.length }));
45
49
  if (result.migrations && result.migrations.profileRename && result.migrations.profileRename.changed) {
@@ -24,6 +24,22 @@ const {
24
24
  const BAR = '─'.repeat(60);
25
25
  const CLASSIFICATIONS = ['MICRO', 'SMALL', 'MEDIUM'];
26
26
 
27
+ // A loud, deterministic instruction the phase loop echoes back to @dev at every
28
+ // phase boundary. On a per-phase trigger with auto_continue it is the fresh
29
+ // tool-result that stops the agent from ending its turn to "report" after a
30
+ // phase — the exact failure the phase loop exists to prevent. It never tells the
31
+ // agent to /compact: context is shed transparently by the host's auto-compact,
32
+ // without ending the turn.
33
+ function buildContinuationDirective(trigger, phaseLoop) {
34
+ if (trigger === 'end-of-feature') {
35
+ return 'END-OF-FEATURE — last phase: run the full runtime smoke (build + migrate + boot + Core happy-path), then hand off through the post-dev review cycle. Do not close/publish — that is the human gate.';
36
+ }
37
+ if (phaseLoop && phaseLoop.auto_continue) {
38
+ return 'CONTINUE-NOW — a clean report is a checkpoint, not a stop. Go DIRECTLY into the next phase in this same turn: do not stop, do not ask "continue?", do not summarize-and-end, and never self-issue /compact (the host auto-compacts transparently). Halt only on a failing gate/verification after retries or a genuine hard stop.';
39
+ }
40
+ return 'PAUSE — auto_continue is off: after this phase report, stop and wait for confirmation before the next phase.';
41
+ }
42
+
27
43
  async function readFileSafe(filePath) {
28
44
  try {
29
45
  return await fs.readFile(filePath, 'utf8');
@@ -52,7 +68,16 @@ async function runVerificationPlan({ args, options = {}, logger }) {
52
68
  const slug = (options.feature || options.slug || '').toString().trim() || null;
53
69
 
54
70
  const triggerRaw = (options.trigger || 'per-phase').toString().trim().toLowerCase();
55
- const trigger = TRIGGERS.includes(triggerRaw) ? triggerRaw : 'per-phase';
71
+ // An unknown trigger must fail loudly. Silently coercing it to per-phase used
72
+ // to emit an imperative CONTINUE-NOW directive on what was actually a typo'd
73
+ // end-of-feature call — steering @dev into a phase that does not exist.
74
+ if (!TRIGGERS.includes(triggerRaw)) {
75
+ const failure = { ok: false, reason: 'invalid_trigger', trigger: triggerRaw, valid_triggers: [...TRIGGERS] };
76
+ if (options.json) return failure;
77
+ logger.error(`Invalid --trigger=${triggerRaw}. Valid triggers: ${TRIGGERS.join(', ')}.`);
78
+ return failure;
79
+ }
80
+ const trigger = triggerRaw;
56
81
 
57
82
  const config = await readVerificationConfig(targetDir);
58
83
  const host = resolveHost(config, options.host);
@@ -92,6 +117,7 @@ async function runVerificationPlan({ args, options = {}, logger }) {
92
117
  phase_loop: getPhaseLoop(config),
93
118
  budget: getBudget(config)
94
119
  };
120
+ result.continuation_directive = buildContinuationDirective(trigger, result.phase_loop);
95
121
 
96
122
  if (options.json) return result;
97
123
 
@@ -108,6 +134,7 @@ async function runVerificationPlan({ args, options = {}, logger }) {
108
134
  logger.log(BAR);
109
135
  const pl = result.phase_loop;
110
136
  logger.log(`Phase loop: auto_continue=${pl.auto_continue} compact_between_phases=${pl.compact_between_phases} max_fix_retries=${pl.max_fix_retries_per_phase}`);
137
+ logger.log(`▶ ${result.continuation_directive}`);
111
138
  logger.log('');
112
139
 
113
140
  return result;
@@ -179,7 +179,7 @@ const RULESETS = {
179
179
 
180
180
  // Kinds whose target file path is keyed by --slug; without it we cannot resolve
181
181
  // the artifact, so fail with a clear usage error instead of a `null/` path.
182
- const REQUIRES_SLUG = new Set(['genome', 'research-report', 'enriched-profile', 'hybrid-skill', 'copy']);
182
+ const REQUIRES_SLUG = new Set(['genome', 'research-report', 'enriched-profile', 'hybrid-skill', 'copy', 'review']);
183
183
 
184
184
  // Kinds whose artifact has a date-stamped / caller-known path — resolved via
185
185
  // --file=<path> rather than derived from a slug.
@@ -374,6 +374,69 @@ const ADAPTERS = {
374
374
  return { ok: issues.length === 0, issues, warnings, checks: [{ id: 'site', ok: issues.length === 0, detail: issues.join('; ') || null }] };
375
375
  },
376
376
 
377
+ // briefing-refiner — the review surface must be the deterministic, fully
378
+ // self-contained artifact `aioson briefing:review` renders: export fallbacks
379
+ // present, no external resources, an embedded source_hash, and a feedback
380
+ // JSON that passes the canonical schema. Staleness (briefings.md changed
381
+ // after generation, e.g. right after an apply) is a warning, not a failure.
382
+ review: async (ctx) => {
383
+ if (!ctx.slug) {
384
+ return { ok: false, issues: ['kind=review requires --slug=<briefing-slug>'], warnings: [], checks: [] };
385
+ }
386
+ const { hashText } = require('../lib/briefing-refiner/briefing-sections');
387
+ const { validateFeedback } = require('../lib/briefing-refiner/feedback-schema');
388
+ const briefingDir = path.resolve(ctx.targetDir, '.aioson', 'briefings', ctx.slug);
389
+ const issues = [];
390
+ const warnings = [];
391
+
392
+ let html = null;
393
+ try {
394
+ html = fs.readFileSync(path.join(briefingDir, 'review.html'), 'utf8');
395
+ } catch {
396
+ issues.push(`review.html not found for briefing "${ctx.slug}" — run: aioson briefing:review . --slug=${ctx.slug}`);
397
+ }
398
+ if (html) {
399
+ if (!html.includes('aioson:review')) {
400
+ issues.push('review.html lacks the aioson:review marker (hand-rolled surface) — regenerate with `aioson briefing:review`');
401
+ }
402
+ if (!/id="download"/.test(html)) issues.push('review.html has no download fallback button (id="download")');
403
+ if (!/id="copy"/.test(html)) issues.push('review.html has no copy-JSON fallback button (id="copy")');
404
+ // User content is HTML-escaped by the generator (quotes become &quot;),
405
+ // so a real external reference can only be generator-emitted markup —
406
+ // match those forms only, or briefing text quoting HTML/CSS would
407
+ // false-positive the gate.
408
+ if (/<script[^>]+\bsrc=|<link[^>]+href=["']https?:|\bsrc=["']https?:/i.test(html)) {
409
+ issues.push('review.html references external resources — it must be fully self-contained');
410
+ }
411
+ const hashMatch = html.match(/source_hash=([0-9a-f]{64})/);
412
+ if (!hashMatch) {
413
+ issues.push('review.html has no embedded source_hash');
414
+ } else {
415
+ try {
416
+ const currentHash = hashText(fs.readFileSync(path.join(briefingDir, 'briefings.md'), 'utf8'));
417
+ if (hashMatch[1] !== currentHash) {
418
+ warnings.push('review.html is stale: source_hash differs from the current briefings.md — regenerate the review for the next round');
419
+ }
420
+ } catch {
421
+ issues.push('briefings.md not found next to review.html');
422
+ }
423
+ }
424
+ }
425
+
426
+ try {
427
+ const feedback = JSON.parse(fs.readFileSync(path.join(briefingDir, 'refinement-feedback.json'), 'utf8'));
428
+ const res = validateFeedback(feedback, { slug: ctx.slug, allowStale: true });
429
+ if (!res.ok) {
430
+ for (const err of res.errors) issues.push(`refinement-feedback.json: ${err}`);
431
+ }
432
+ } catch {
433
+ // Absent right after an apply (it gets archived) — only a warning.
434
+ warnings.push('refinement-feedback.json missing or unreadable — expected right after review generation');
435
+ }
436
+
437
+ return { ok: issues.length === 0, issues, warnings, checks: [{ id: `review:${ctx.slug}`, ok: issues.length === 0, detail: issues.join('; ') || null }] };
438
+ },
439
+
377
440
  // committer — advisory subject-quality audit. Reads --file if given, else the
378
441
  // HEAD commit message (post-commit, so the agent can amend before push).
379
442
  'commit-message': async (ctx) => {
@@ -27,6 +27,7 @@ const { readHandoff, readHandoffProtocol } = require('../session-handoff');
27
27
  const {
28
28
  STATE_RELATIVE_PATH,
29
29
  buildDefaultWorkflowConfig,
30
+ detectWorkflowMode,
30
31
  readWorkflowConfig,
31
32
  runWorkflowNext
32
33
  } = require('./workflow-next');
@@ -110,11 +111,26 @@ function isAgenticRequested(options = {}) {
110
111
  options.agentic ||
111
112
  options['agentic-run'] ||
112
113
  options.autopilot === 'agentic' ||
113
- options.autopilot === 'runtime'
114
+ options.autopilot === 'runtime' ||
115
+ // Seeding the scheme IS turning on autopilot — the whole point of --seed is to
116
+ // persist an enabled agentic_policy the interactive agents then follow.
117
+ options.seed ||
118
+ options['seed-only']
114
119
  );
115
120
  }
116
121
 
117
122
  function buildAgenticPolicy(options = {}, classification = 'SMALL') {
123
+ // --seed --step: the per-feature "step by step" choice. Writes an explicitly
124
+ // DISARMED scheme — resolveAutopilotSignal and the agent triggers treat it as
125
+ // the feature-level override that wins over a project-wide auto_handoff: true.
126
+ if (options.step) {
127
+ return {
128
+ enabled: false,
129
+ mode: 'step_by_step',
130
+ source: 'workflow:execute --step'
131
+ };
132
+ }
133
+
118
134
  const enabled = isAgenticRequested(options);
119
135
  if (!enabled) return null;
120
136
 
@@ -229,6 +245,18 @@ function inferCompletedStagesFromArtifacts(sequence, artifacts, gates) {
229
245
  inferred = Boolean(artifacts.requirements && artifacts.requirements.exists && gates.requirements === 'approved');
230
246
  } else if (normalized === 'architect') {
231
247
  inferred = Boolean(artifacts.architecture && artifacts.architecture.exists && gates.design === 'approved');
248
+ } else if (normalized === 'sheldon' || normalized === 'orchestrator') {
249
+ // Single spec authorities (lean @sheldon / maestro @orchestrator) collapse
250
+ // Gates A/B/C into one hop — done once the spec package exists with the
251
+ // collapsed gates approved. Without this, a seed run AFTER the spec stage
252
+ // finished reported `next: sheldon|orchestrator` and pointed the whole
253
+ // agentic scheme backwards.
254
+ inferred = Boolean(
255
+ artifacts.spec && artifacts.spec.exists &&
256
+ gates.requirements === 'approved' &&
257
+ gates.design === 'approved' &&
258
+ gates.plan === 'approved'
259
+ );
232
260
  } else if (normalized === 'pm') {
233
261
  inferred = Boolean(artifacts.implementation_plan && artifacts.implementation_plan.exists && gates.plan === 'approved');
234
262
  } else if (normalized === 'qa') {
@@ -244,9 +272,17 @@ function inferCompletedStagesFromArtifacts(sequence, artifacts, gates) {
244
272
  return completed;
245
273
  }
246
274
 
247
- async function seedFeatureWorkflowState(targetDir, slug, classification, startFrom) {
275
+ /**
276
+ * Reads the persisted workflow state for a seed/preview of `slug`, applying the
277
+ * SAME staleness guard as loadOrCreateState: a state whose feature is no longer
278
+ * the features.md-active feature is stale and gets discarded (the loader would
279
+ * throw it away one command later anyway — hard-failing the seed on it just
280
+ * silently disarmed autopilot). The refusal survives only for a genuinely
281
+ * active different feature.
282
+ */
283
+ async function resolveExistingFeatureState(targetDir, slug) {
248
284
  const statePath = path.join(targetDir, STATE_RELATIVE_PATH);
249
- const existing = await readJsonIfExists(statePath);
285
+ let existing = await readJsonIfExists(statePath);
250
286
  const focusStage = getFocusStage(existing);
251
287
 
252
288
  if (
@@ -256,14 +292,41 @@ async function seedFeatureWorkflowState(targetDir, slug, classification, startFr
256
292
  existing.featureSlug !== slug &&
257
293
  focusStage
258
294
  ) {
259
- return {
260
- ok: false,
261
- reason: 'different_active_feature',
262
- active_feature: existing.featureSlug,
263
- active_stage: focusStage
264
- };
295
+ let modeInfo = null;
296
+ try {
297
+ modeInfo = await detectWorkflowMode(targetDir);
298
+ } catch {
299
+ modeInfo = null;
300
+ }
301
+ const existingIsActive = Boolean(
302
+ modeInfo && modeInfo.mode === 'feature' && modeInfo.featureSlug === existing.featureSlug
303
+ );
304
+ const requestedIsActive = Boolean(
305
+ modeInfo && modeInfo.mode === 'feature' && modeInfo.featureSlug === slug
306
+ );
307
+ if (!existingIsActive || requestedIsActive) {
308
+ existing = null; // stale pointer — reseed for the requested feature
309
+ } else {
310
+ return {
311
+ existing: null,
312
+ refusal: {
313
+ ok: false,
314
+ reason: 'different_active_feature',
315
+ active_feature: existing.featureSlug,
316
+ active_stage: focusStage
317
+ }
318
+ };
319
+ }
265
320
  }
266
321
 
322
+ return { existing, refusal: null };
323
+ }
324
+
325
+ async function seedFeatureWorkflowState(targetDir, slug, classification, startFrom) {
326
+ const statePath = path.join(targetDir, STATE_RELATIVE_PATH);
327
+ const { existing, refusal } = await resolveExistingFeatureState(targetDir, slug);
328
+ if (refusal) return refusal;
329
+
267
330
  if (existing && existing.mode === 'feature' && existing.featureSlug === slug) {
268
331
  return {
269
332
  ok: true,
@@ -312,23 +375,8 @@ async function seedFeatureWorkflowState(targetDir, slug, classification, startFr
312
375
  }
313
376
 
314
377
  async function previewFeatureWorkflowState(targetDir, slug, classification, startFrom) {
315
- const existing = await readJsonIfExists(path.join(targetDir, STATE_RELATIVE_PATH));
316
- const focusStage = getFocusStage(existing);
317
-
318
- if (
319
- existing &&
320
- existing.mode === 'feature' &&
321
- existing.featureSlug &&
322
- existing.featureSlug !== slug &&
323
- focusStage
324
- ) {
325
- return {
326
- ok: false,
327
- reason: 'different_active_feature',
328
- active_feature: existing.featureSlug,
329
- active_stage: focusStage
330
- };
331
- }
378
+ const { existing, refusal } = await resolveExistingFeatureState(targetDir, slug);
379
+ if (refusal) return refusal;
332
380
 
333
381
  if (existing && existing.mode === 'feature' && existing.featureSlug === slug) {
334
382
  return {
@@ -539,7 +587,11 @@ function buildCheckpointPayload(activation, handoff, handoffProtocol) {
539
587
  async function writeExecutionCheckpoint(targetDir, payload) {
540
588
  const execPath = path.join(targetDir, EXECUTION_STATE_RELATIVE_PATH);
541
589
  const existing = await readJsonIfExists(execPath);
542
- const history = Array.isArray(existing && existing.history) ? [...existing.history] : [];
590
+ // History is per-feature: never carry a previous feature's checkpoints into
591
+ // a new feature's scheme.
592
+ const history = existing && existing.feature === payload.feature && Array.isArray(existing.history)
593
+ ? [...existing.history]
594
+ : [];
543
595
  if (payload.checkpoint) {
544
596
  history.push({
545
597
  at: new Date().toISOString(),
@@ -702,6 +754,9 @@ async function runWorkflowExecute({ args, options = {}, logger }) {
702
754
  const tool = options.tool ? String(options.tool).trim() : 'claude';
703
755
  const requestedMode = options.mode ? String(options.mode).trim() : null;
704
756
  const dryRun = Boolean(options['dry-run'] || options.dry);
757
+ // --step (disarm) is by definition record-only: it writes the disarmed scheme
758
+ // and never drives stage transitions, with or without an explicit --seed.
759
+ const seedOnly = Boolean(options.seed || options['seed-only'] || options.step);
705
760
  const startFrom = options['start-from'] ? String(options['start-from']).trim() : null;
706
761
  const skipOptional = Boolean(options['skip-optional']);
707
762
  const parsedMaxCheckpoints = Number.parseInt(String(options['max-checkpoints'] || '1'), 10);
@@ -792,14 +847,18 @@ async function runWorkflowExecute({ args, options = {}, logger }) {
792
847
  `--tool=${quoteCliArg(tool)}`,
793
848
  ...(requestedMode ? [`--mode=${quoteCliArg(requestedMode)}`] : []),
794
849
  ...(maxCheckpoints !== 1 ? [`--max-checkpoints=${quoteCliArg(maxCheckpoints)}`] : []),
795
- ...(agenticPolicy ? ['--agentic'] : []),
796
- ...(agenticPolicy && agenticPolicy.review_cycle.max_dev_qa_cycles !== DEFAULT_AGENTIC_MAX_CYCLES
850
+ // A seed run must resume as a seed run — `--agentic` (the CLI-advancing
851
+ // runner) is the opposite of the seed-only contract. A disarm run keeps
852
+ // its --step so replaying it never re-arms the scheme.
853
+ ...(seedOnly ? ['--seed'] : agenticPolicy && agenticPolicy.enabled ? ['--agentic'] : []),
854
+ ...(options.step ? ['--step'] : []),
855
+ ...(agenticPolicy && agenticPolicy.review_cycle && agenticPolicy.review_cycle.max_dev_qa_cycles !== DEFAULT_AGENTIC_MAX_CYCLES
797
856
  ? [`--max-dev-qa-cycles=${quoteCliArg(agenticPolicy.review_cycle.max_dev_qa_cycles)}`]
798
857
  : []),
799
- ...(agenticPolicy && agenticPolicy.review_cycle.max_tester_correction_cycles !== DEFAULT_AGENTIC_MAX_CYCLES
858
+ ...(agenticPolicy && agenticPolicy.review_cycle && agenticPolicy.review_cycle.max_tester_correction_cycles !== DEFAULT_AGENTIC_MAX_CYCLES
800
859
  ? [`--max-tester-cycles=${quoteCliArg(agenticPolicy.review_cycle.max_tester_correction_cycles)}`]
801
860
  : []),
802
- ...(agenticPolicy && agenticPolicy.review_cycle.max_pentester_correction_cycles !== DEFAULT_AGENTIC_MAX_CYCLES
861
+ ...(agenticPolicy && agenticPolicy.review_cycle && agenticPolicy.review_cycle.max_pentester_correction_cycles !== DEFAULT_AGENTIC_MAX_CYCLES
803
862
  ? [`--max-pentester-cycles=${quoteCliArg(agenticPolicy.review_cycle.max_pentester_correction_cycles)}`]
804
863
  : [])
805
864
  ].join(' ');
@@ -854,6 +913,65 @@ async function runWorkflowExecute({ args, options = {}, logger }) {
854
913
  return result;
855
914
  }
856
915
 
916
+ // --seed: persist the workflow.state.json + workflow-execute.json (with an
917
+ // enabled agentic_policy) and STOP. Unlike a full run, it does not drive stage
918
+ // transitions from the CLI — in an interactive Claude Code session the agents
919
+ // themselves follow the scheme. This is what the spec->dev handoff calls so a
920
+ // feature built the normal way carries the autopilot contract without the user
921
+ // running anything. Idempotent: re-seeding the same slug reuses existing state.
922
+ if (seedOnly) {
923
+ const handoff = await readHandoff(targetDir);
924
+ const handoffProtocol = await readHandoffProtocol(targetDir);
925
+ const nextStage = seeded.state ? (seeded.state.current || seeded.state.next || null) : null;
926
+ const executionState = await writeExecutionCheckpoint(targetDir, {
927
+ feature: slug,
928
+ classification,
929
+ tool,
930
+ requestedMode,
931
+ resumed: seeded.resumed,
932
+ status: nextStage ? 'active' : 'completed',
933
+ checkpoint: buildCheckpointPayload(null, handoff, handoffProtocol),
934
+ statusSnapshot,
935
+ suggestion: statusSnapshot && statusSnapshot.suggestion ? statusSnapshot.suggestion : null,
936
+ resumeCommand,
937
+ agenticPolicy
938
+ });
939
+
940
+ const result = {
941
+ ok: true,
942
+ feature: slug,
943
+ classification,
944
+ tool,
945
+ requested_mode: requestedMode,
946
+ seeded: true,
947
+ resumed: seeded.resumed,
948
+ state_path: seeded.statePath,
949
+ execution_state_path: EXECUTION_STATE_RELATIVE_PATH,
950
+ next_stage: nextStage,
951
+ checkpoint: executionState.checkpoint,
952
+ execution_state: executionState,
953
+ status_snapshot: statusSnapshot,
954
+ suggestion: statusSnapshot && statusSnapshot.suggestion ? statusSnapshot.suggestion : null,
955
+ resume_command: resumeCommand,
956
+ agentic_policy: agenticPolicy,
957
+ parallel_guard: parallelGuard
958
+ };
959
+
960
+ if (options.json) return result;
961
+
962
+ logger.log('');
963
+ logger.log(`Agentic workflow scheme seeded → ${EXECUTION_STATE_RELATIVE_PATH}`);
964
+ logger.log(`Feature: ${slug} (${classification}) Next stage: @${nextStage || 'none'}`);
965
+ logger.log(
966
+ `Autopilot: ${agenticPolicy && agenticPolicy.enabled
967
+ ? 'enabled — interactive agents run the chain to feature:close (human gate)'
968
+ : 'disabled'}`
969
+ );
970
+ for (const line of formatAgenticPolicyLines(agenticPolicy)) logger.log(line);
971
+ logger.log('');
972
+ return result;
973
+ }
974
+
857
975
  const executionTransitions = [];
858
976
  let activation = null;
859
977
  let currentStatus = statusSnapshot;
@@ -16,6 +16,7 @@ const { logError, buildHealingPrompt } = require('../self-healing');
16
16
  const { validateHandoffProtocol } = require('../handoff-validator');
17
17
  const { readAutonomyProtocol, resolveEffectiveMode } = require('../autonomy-policy');
18
18
  const { readAgentManifest, buildAgentCapabilitySummary } = require('../agent-manifests');
19
+ const { resolveAutopilotSignal } = require('../autopilot-signal');
19
20
  const { runMemoryReflectPrepare } = require('./memory-reflect-prepare');
20
21
  const { inspectStagedChanges } = require('../lib/git-commit-guard');
21
22
  const { emitSecurityRuntimeEvent } = require('../lib/security/runtime-events');
@@ -61,19 +62,18 @@ const DEFAULT_FEATURE_WORKFLOW_BY_CLASSIFICATION = {
61
62
  MEDIUM: ['product', 'orchestrator', 'dev', 'pentester', 'qa']
62
63
  };
63
64
 
64
- // Stages eligible for autopilot handoff (auto_handoff: true in project.context.md).
65
- // Two segments — see .aioson/docs/autopilot-handoff.md:
66
- // 1. analyst -> dev: deterministic pre-dev chain. Prompt-only clients stop
67
- // before the first @dev entry; workflow:execute --agentic may resume it
68
- // through a fresh checkpointed activation.
69
- // 2. post-dev review cycle: @dev @qa @tester/@pentester (when their @qa triggers
70
- // fire) @validator STOPS before feature:close (human approves the close).
71
- // @product and @sheldon are intentionally absent: per config.md, upstream agents
72
- // (@briefing/@product/@sheldon) always hand off MANUALLY. In the lean lane
73
- // (productsheldondev qa) this means auto_handoff is a deliberate no-op
74
- // pre-dev — @sheldon is the only pre-dev agent and hands off by hand — and is
75
- // active only on the post-dev cycle (dev → qa). This is by design, not an omission.
65
+ // Stages eligible for autopilot handoff the FULL feature chain (see
66
+ // .aioson/docs/autopilot-handoff.md). Activation = auto_handoff: true in
67
+ // project.context.md OR the seeded scheme (resolveAutopilotSignal). Two segments:
68
+ // 1. spec dev chain: @product seeds the agentic scheme and invokes the spec
69
+ // authority (@sheldon lean / @orchestrator maestro), which crosses into
70
+ // @dev via the dev-state.md cold-start packet once its own gates/decisions
71
+ // are settled. Detour agents (analyst/architect/pm/...) chain only when an
72
+ // opt-in detour adds them to the active sequence.
73
+ // 2. post-dev review cycle: @dev @qa @tester/@pentester (when their @qa
74
+ // triggers fire)@validatorSTOPS before feature:close (human gate).
76
75
  const AUTOPILOT_HANDOFF_STAGES = new Set([
76
+ 'product', 'sheldon', 'orchestrator',
77
77
  'analyst', 'scope-check', 'architect', 'discovery-design-doc', 'pm',
78
78
  'dev', 'qa', 'tester', 'pentester', 'validator'
79
79
  ]);
@@ -99,6 +99,14 @@ function isMaestroOrchestratorState(state) {
99
99
  return idx !== -1 && sequence[idx + 1] === 'dev';
100
100
  }
101
101
 
102
+ // Lean lane (SMALL, sheldon → dev): @sheldon is the single spec authority.
103
+ // Mirrors isLeanSheldonState in handoff-contract.js.
104
+ function isLeanSheldonState(state) {
105
+ const sequence = Array.isArray(state && state.sequence) ? state.sequence.map(normalizeAgentName) : [];
106
+ const idx = sequence.indexOf('sheldon');
107
+ return idx !== -1 && sequence[idx + 1] === 'dev';
108
+ }
109
+
102
110
  function buildDefaultWorkflowConfig() {
103
111
  return {
104
112
  version: 1,
@@ -376,6 +384,29 @@ async function validateStageArtifacts(targetDir, state, stage) {
376
384
  return true;
377
385
  }
378
386
 
387
+ if (stage === 'sheldon') {
388
+ // Lean lane (SMALL, sheldon → dev): @sheldon is the single spec authority —
389
+ // "done" once the collapsed spec package exists (mirrors the orchestrator
390
+ // maestro branch below; the handoff contract re-checks Gates A/B/C). Without
391
+ // this branch nothing ever marks the sheldon stage resolved, and a later
392
+ // `--complete=dev` computes `next: sheldon` — the state machine walking
393
+ // BACKWARDS into the spec agent after implementation.
394
+ if (state.mode === 'feature' && slug && isLeanSheldonState(state)) {
395
+ const designDoc = [path.join(base, `design-doc-${slug}.md`), path.join(base, 'design-doc.md')];
396
+ const readiness = [path.join(base, `readiness-${slug}.md`), path.join(base, 'readiness.md')];
397
+ return (await exists(path.join(base, `sheldon-enrichment-${slug}.md`)))
398
+ && (await exists(path.join(base, `requirements-${slug}.md`)))
399
+ && (await exists(path.join(base, `spec-${slug}.md`)))
400
+ && (await exists(path.join(base, `implementation-plan-${slug}.md`)))
401
+ && (await anyExists(designDoc))
402
+ && (await anyExists(readiness));
403
+ }
404
+ if (state.mode === 'feature' && slug) {
405
+ return await exists(path.join(base, `sheldon-enrichment-${slug}.md`));
406
+ }
407
+ return await exists(path.join(base, 'sheldon-enrichment.md'));
408
+ }
409
+
379
410
  if (stage === 'orchestrator') {
380
411
  // Maestro lane (MEDIUM, orchestrator → dev): the orchestrator is the single
381
412
  // spec authority — "done" once the gated spec package exists (mirrors how the
@@ -519,7 +550,10 @@ function isInferableStage(stage) {
519
550
  // could never infer scope-check as completed during stale-state recovery.
520
551
  // pm is inferable from implementation-plan-{slug}.md for the same reason:
521
552
  // it sits before scope-check in the MEDIUM feature sequence.
522
- return ['setup', 'product', 'analyst', 'scope-check', 'architect', 'discovery-design-doc', 'ux-ui', 'pm', 'orchestrator'].includes(
553
+ // sheldon (lean lane) and orchestrator (maestro lane) are the single spec
554
+ // authorities — both inferable from their collapsed spec package so stale-state
555
+ // recovery and mid-lane seeding never re-activate a finished spec stage.
556
+ return ['setup', 'product', 'analyst', 'scope-check', 'architect', 'discovery-design-doc', 'ux-ui', 'pm', 'sheldon', 'orchestrator'].includes(
523
557
  normalizeAgentName(stage)
524
558
  );
525
559
  }
@@ -993,8 +1027,15 @@ async function finalizeCurrentStage(targetDir, config, state, stageName) {
993
1027
  detour: null
994
1028
  });
995
1029
 
1030
+ // Reconcile eagerly: completing a later stage must never leave `next` pointing
1031
+ // at an earlier unresolved stage (e.g. lean-lane sheldon that only chained via
1032
+ // prompt). Without this, `--complete=dev` re-activated the spec agent and the
1033
+ // reconcile only healed on the NEXT load — after the backwards activation had
1034
+ // already been printed and persisted.
1035
+ const reconciled = reconcileWorkflowState(nextState);
1036
+
996
1037
  return {
997
- state: nextState,
1038
+ state: reconciled.changed ? reconciled.state : nextState,
998
1039
  completedStage: normalizedStage,
999
1040
  ...(auditCodeSummary ? { auditCode: auditCodeSummary } : {})
1000
1041
  };
@@ -1560,9 +1601,12 @@ async function activateStage(
1560
1601
  state.mode === 'feature' &&
1561
1602
  (state.classification === 'SMALL' || state.classification === 'MEDIUM')
1562
1603
  ) {
1604
+ // Frontmatter flag OR the seeded scheme (slug-scoped) — the per-feature
1605
+ // "Autopilot" choice only seeds workflow-execute.json and never writes
1606
+ // auto_handoff, so reading the frontmatter alone silently disabled it.
1563
1607
  try {
1564
- const projectContext = await validateProjectContextFile(targetDir);
1565
- autoHandoff = Boolean(projectContext && projectContext.data && projectContext.data.auto_handoff === true);
1608
+ const signal = await resolveAutopilotSignal(targetDir, { slug: state.featureSlug });
1609
+ autoHandoff = signal.enabled;
1566
1610
  } catch {
1567
1611
  autoHandoff = false;
1568
1612
  }