agent-orchestrator-kit 0.1.13 → 0.2.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 (30) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +18 -11
  3. package/bin/agent-orchestrator.js +558 -2
  4. package/package.json +2 -2
  5. package/profiles/generic/orchestrator.yaml +3 -0
  6. package/profiles/mvp/orchestrator.yaml +3 -0
  7. package/profiles/node/orchestrator.yaml +3 -0
  8. package/profiles/vue3/orchestrator.yaml +3 -0
  9. package/templates/.agents/amp.settings.json.example +2 -5
  10. package/templates/.agents/commands/opsx-apply.md +10 -3
  11. package/templates/.agents/commands/opsx-archive.md +10 -3
  12. package/templates/.agents/commands/opsx-design.md +10 -3
  13. package/templates/.agents/commands/opsx-explore.md +10 -3
  14. package/templates/.agents/commands/opsx-propose.md +10 -3
  15. package/templates/.agents/commands/opsx-quick.md +10 -3
  16. package/templates/.agents/commands/opsx-review.md +10 -3
  17. package/templates/.agents/mcp.json.example +2 -5
  18. package/templates/.agents/rules/agent-orchestration.mdc +28 -68
  19. package/templates/.agents/rules/cli-via-npm.mdc +2 -1
  20. package/templates/.agents/rules/figma-token-setup.mdc +1 -1
  21. package/templates/.agents/rules/memory-mcp-autosetup.mdc +4 -54
  22. package/templates/.agents/rules/session-handoff.mdc +28 -0
  23. package/templates/.agents/skills/agent-orchestration/SKILL.md +36 -19
  24. package/templates/.agents/subagents/session-handoff.md +48 -0
  25. package/templates/.agents/subagents/setup-doctor.md +1 -1
  26. package/templates/AGENTS.md +22 -119
  27. package/templates/CLAUDE.md +6 -65
  28. package/templates/orchestrator.yaml +6 -0
  29. package/templates/scripts/memory-mcp-launcher.cjs +46 -0
  30. package/templates/scripts/sync-local-agent-skills.sh +1 -1
@@ -2,7 +2,7 @@
2
2
  import { program } from 'commander';
3
3
  import pc from 'picocolors';
4
4
  import { readFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSync, writeFileSync, rmSync } from 'fs';
5
- import { join, dirname, basename } from 'path';
5
+ import { join, dirname, basename, resolve } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { execSync } from 'child_process';
8
8
 
@@ -71,12 +71,35 @@ const GITIGNORE_LINES = [
71
71
  const FIGMA_ENV_REL = join('.agents', 'figma.local.env');
72
72
  const FIGMA_ENV_EXAMPLE_REL = join('.agents', 'figma.local.env.example');
73
73
  const FIGMA_LAUNCHER_REL = join('scripts', 'figma-mcp-launcher.cjs');
74
+ const MEMORY_LAUNCHER_REL = join('scripts', 'memory-mcp-launcher.cjs');
75
+ const MEMORY_FILE_REL = join('.cursor', 'memory.json');
74
76
  const FIGMA_MANAGED_PATHS = [
75
77
  FIGMA_ENV_EXAMPLE_REL,
76
78
  FIGMA_LAUNCHER_REL,
77
79
  join('.agents', 'mcp.json.example'),
78
80
  join('.agents', 'amp.settings.json.example'),
79
81
  ];
82
+ const MEMORY_MANAGED_PATHS = [
83
+ MEMORY_LAUNCHER_REL,
84
+ join('.agents', 'mcp.json.example'),
85
+ join('.agents', 'amp.settings.json.example'),
86
+ ];
87
+ const AMP_SPAWN_PREAMBLE =
88
+ 'CRITICAL (Amp / Cursor / Claude): Parent MUST spawn this skill as an isolated subagent with fresh context. Do not execute it in the main thread. If spawn is unavailable, STOP and report blocked — do not perform this specialist\'s work in the parent. Return only the structured subagent report.';
89
+ const HANDOFF_REQUIRED_SECTIONS = ['Closed role', 'Done', 'Next command'];
90
+ const HANDOFF_SECTIONS = [
91
+ 'Closed role',
92
+ 'Change',
93
+ 'Done',
94
+ 'Decisions',
95
+ 'Blocked',
96
+ 'Next command',
97
+ 'Next role',
98
+ 'Attach',
99
+ 'Subagents to spawn',
100
+ 'Constraints',
101
+ 'Prompt',
102
+ ];
80
103
 
81
104
  const log = {
82
105
  info: (msg) => console.log(pc.cyan(' →'), msg),
@@ -237,6 +260,396 @@ function ensureFigmaMcpEntry(projectDir) {
237
260
  }
238
261
  }
239
262
 
263
+ function memoryServerConfig() {
264
+ return { command: 'node', args: [MEMORY_LAUNCHER_REL.replace(/\\/g, '/')] };
265
+ }
266
+
267
+ function isMemoryLauncher(server) {
268
+ const args = server?.args || [];
269
+ return server?.command === 'node' && args.some((arg) => String(arg).includes('memory-mcp-launcher.cjs'));
270
+ }
271
+
272
+ function refreshMemoryManagedFiles(projectDir) {
273
+ const templateDir = join(KIT_ROOT, 'templates');
274
+ for (const rel of MEMORY_MANAGED_PATHS) {
275
+ const src = join(templateDir, rel);
276
+ const dest = join(projectDir, rel);
277
+ if (!existsSync(src)) continue;
278
+ mkdirSync(dirname(dest), { recursive: true });
279
+ copyFileSync(src, dest);
280
+ log.ok(rel);
281
+ }
282
+ }
283
+
284
+ function writeJsonFile(filePath, value) {
285
+ mkdirSync(dirname(filePath), { recursive: true });
286
+ writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
287
+ }
288
+
289
+ function upsertMemoryServer(cfg, key, label) {
290
+ const servers = cfg[key] || {};
291
+ const current = servers.memory;
292
+ if (isMemoryLauncher(current)) {
293
+ log.ok(`${label} already uses memory launcher`);
294
+ cfg[key] = servers;
295
+ return cfg;
296
+ }
297
+ servers.memory = memoryServerConfig();
298
+ cfg[key] = servers;
299
+ log.ok(`${label} ← memory launcher (absolute MEMORY_FILE_PATH)`);
300
+ return cfg;
301
+ }
302
+
303
+ function ensureMemoryMcpEntry(projectDir) {
304
+ mkdirSync(join(projectDir, '.cursor'), { recursive: true });
305
+ const memoryFile = join(projectDir, MEMORY_FILE_REL);
306
+ if (!existsSync(memoryFile)) {
307
+ writeFileSync(memoryFile, '');
308
+ log.ok('.cursor/memory.json created');
309
+ }
310
+
311
+ const examplePath = join(projectDir, '.agents', 'mcp.json.example');
312
+ const cursorPath = join(projectDir, '.mcp.json');
313
+ if (!existsSync(cursorPath) && existsSync(examplePath)) {
314
+ copyFileSync(examplePath, cursorPath);
315
+ log.ok('.mcp.json created from example');
316
+ }
317
+ if (existsSync(cursorPath)) {
318
+ try {
319
+ const cfg = upsertMemoryServer(JSON.parse(readFileSync(cursorPath, 'utf-8')), 'mcpServers', '.mcp.json');
320
+ writeJsonFile(cursorPath, cfg);
321
+ } catch {
322
+ log.warn('.mcp.json present but invalid JSON — merge memory launcher from .agents/mcp.json.example');
323
+ }
324
+ }
325
+
326
+ mkdirSync(join(projectDir, '.amp'), { recursive: true });
327
+ const ampExample = join(projectDir, '.agents', 'amp.settings.json.example');
328
+ const ampPath = join(projectDir, '.amp', 'settings.json');
329
+ if (!existsSync(ampPath) && existsSync(ampExample)) {
330
+ copyFileSync(ampExample, ampPath);
331
+ log.ok('.amp/settings.json created from example');
332
+ }
333
+ if (existsSync(ampPath)) {
334
+ try {
335
+ const cfg = upsertMemoryServer(JSON.parse(readFileSync(ampPath, 'utf-8')), 'amp.mcpServers', '.amp/settings.json');
336
+ writeJsonFile(ampPath, cfg);
337
+ } catch {
338
+ log.warn('.amp/settings.json present but invalid JSON — merge memory launcher manually');
339
+ }
340
+ }
341
+ }
342
+
343
+ function readOrchestratorMeta(projectDir) {
344
+ const orchPath = join(projectDir, '.agents', 'orchestrator.yaml');
345
+ if (!existsSync(orchPath)) return { agentLanguage: 'en' };
346
+ const content = readFileSync(orchPath, 'utf-8');
347
+ const lang = content.match(/agent_language:\s*["']?([A-Za-z_-]+)/);
348
+ return { agentLanguage: lang ? lang[1] : 'en' };
349
+ }
350
+
351
+ function parseHandoffMarkdown(content) {
352
+ const sections = {};
353
+ const parts = String(content || '').split(/^## /m);
354
+ for (const part of parts.slice(1)) {
355
+ const nl = part.indexOf('\n');
356
+ const title = (nl === -1 ? part : part.slice(0, nl)).trim();
357
+ const body = nl === -1 ? '' : part.slice(nl + 1).trim();
358
+ sections[title] = body;
359
+ }
360
+ return sections;
361
+ }
362
+
363
+ function firstLineCommand(value) {
364
+ const match = String(value || '').match(/\/opsx:[^\s`]+(?:\s+[^\s`]+)?/);
365
+ if (match) return match[0].trim();
366
+ return String(value || '').replace(/^[`\s]+|[`\s]+$/g, '').split('\n')[0].trim();
367
+ }
368
+
369
+ function firstSpawnName(value) {
370
+ const tick = String(value || '').match(/`([a-z0-9-]+)`/i);
371
+ if (tick) return tick[1];
372
+ const word = String(value || '').match(/\b([a-z][a-z0-9-]{2,})\b/i);
373
+ return word ? word[1] : '';
374
+ }
375
+
376
+ function sectionOr(sections, title, fallback = '') {
377
+ const value = sections[title];
378
+ return value && value.trim() ? value.trim() : fallback;
379
+ }
380
+
381
+ function buildHandoffMarkdown(fields) {
382
+ const prompt = fields.prompt ? `\n\n## Prompt\n\n\`\`\`text\n${fields.prompt}\n\`\`\`` : '';
383
+ return `# Session Handoff
384
+
385
+ ## Closed role
386
+ ${fields.closedRole}
387
+
388
+ ## Change
389
+ ${fields.change}
390
+
391
+ ## Done
392
+ ${fields.done}
393
+
394
+ ## Decisions
395
+ ${fields.decisions}
396
+
397
+ ## Blocked
398
+ ${fields.blocked}
399
+
400
+ ## Next command
401
+ \`${fields.nextCommand}\`
402
+
403
+ ## Next role
404
+ ${fields.nextRole}
405
+
406
+ ## Attach
407
+ ${fields.attach}
408
+
409
+ ## Subagents to spawn
410
+ ${fields.spawn}
411
+
412
+ ## Constraints
413
+ ${fields.constraints}${prompt}
414
+ `;
415
+ }
416
+
417
+ function fieldsFromSections(changeName, sections, extra = {}) {
418
+ const nextCommand = extra.nextCommand || firstLineCommand(sectionOr(sections, 'Next command'));
419
+ return {
420
+ changeName,
421
+ closedRole: extra.closedRole || sectionOr(sections, 'Closed role', extra.closedRole || ''),
422
+ change: extra.change || sectionOr(sections, 'Change', `- name: ${changeName}`),
423
+ done: extra.done || extra.summary || sectionOr(sections, 'Done', extra.done || ''),
424
+ decisions: extra.decisions || sectionOr(sections, 'Decisions', 'none'),
425
+ blocked: extra.blocked || sectionOr(sections, 'Blocked', 'none'),
426
+ nextCommand,
427
+ nextRole: extra.nextRole || sectionOr(sections, 'Next role', ''),
428
+ attach: extra.attach || sectionOr(sections, 'Attach', `- \`openspec/changes/${changeName}/\``),
429
+ spawn: extra.spawn || sectionOr(sections, 'Subagents to spawn', ''),
430
+ constraints: extra.constraints || sectionOr(sections, 'Constraints', ''),
431
+ status: extra.status || '',
432
+ tasks: extra.tasks || '',
433
+ review: extra.review || '',
434
+ sessionCount: extra.sessionCount || '',
435
+ summary: extra.summary || extra.done || sectionOr(sections, 'Done', ''),
436
+ };
437
+ }
438
+
439
+ function missingHandoffFields(fields) {
440
+ const missing = [];
441
+ if (!fields.closedRole) missing.push('Closed role');
442
+ if (!fields.done) missing.push('Done');
443
+ if (!fields.nextCommand) missing.push('Next command');
444
+ return missing;
445
+ }
446
+
447
+ function isUkLang(lang) {
448
+ const value = String(lang || 'en').toLowerCase();
449
+ return value === 'uk' || value.startsWith('uk');
450
+ }
451
+
452
+ function buildNextSessionPrompt(fields, agentLanguage) {
453
+ const name = fields.changeName;
454
+ const cmd = firstLineCommand(fields.nextCommand);
455
+ const spawnName = firstSpawnName(fields.spawn) || firstSpawnName(fields.nextRole);
456
+ const ampWrapper = spawnName ? `subagent-${spawnName}` : 'subagent-<phase-specialist>';
457
+ const uk = isUkLang(agentLanguage);
458
+ const languageName = uk ? 'українська' : 'English';
459
+
460
+ if (uk) {
461
+ return `${cmd}
462
+
463
+ Ти — conductor наступної рольової сесії для зміни \`${name}\`.
464
+ Мова відповіді: ${languageName} (\`project.agent_language: ${agentLanguage}\`).
465
+ НЕ змішуй фази. НЕ починай наступну роль у цьому ж чаті, доки ця фаза не закрита за HARD STOP.
466
+
467
+ ## Хто ти і що робити
468
+ - Команда цієї сесії: \`${cmd}\`
469
+ - Наступна роль / субагент фази: \`${spawnName || fields.nextRole || 'див. таблицю маршрутизації'}\`
470
+ - Amp: заспавни isolated skill \`${ampWrapper}\` зі свіжим контекстом. Виконувати тіло спеціаліста в головному треді Amp — порушення протоколу.
471
+ - Cursor / Claude: заспавни \`.cursor/agents/${spawnName || '<name>'}.md\` / \`.claude/agents/${spawnName || '<name>'}.md\`.
472
+ - Батьківська сесія — лише conductor: перевіряє звіт, не виконує роботу спеціаліста.
473
+
474
+ ## Обов'язковий старт (до будь-якої роботи спеціаліста)
475
+ 1. Виконай pasted-команду \`${cmd}\` і оголоси роль.
476
+ 2. \`npx agent-orchestrator-kit status\`
477
+ 3. \`npx agent-orchestrator-kit handoff ${name} --restore\`
478
+ 4. Прочитай Memory MCP: \`Change:${name}\`, \`Handoff:${name}\`, \`Decision:*\`.
479
+ 5. Якщо Memory порожнє або MCP недоступний — прочитай \`openspec/changes/${name}/handoff.md\`. Відсутність Memory НЕ блокує сесію, коли є файл.
480
+ 6. Заспавни \`session-handoff\` у режимі restore, якщо брифінг неповний (Amp: isolated \`subagent-session-handoff\`).
481
+ 7. Лише після цього заспавни субагента фази. Free-form «продовжуй» / «далі» при одній активній зміні = \`Handoff.next_command\`.
482
+
483
+ ## Повний контекст попередньої сесії (самодостатній — не покладайся лише на Memory)
484
+ - Закрита роль: ${fields.closedRole || 'не вказано'}
485
+ - Зміна: ${fields.change || name}
486
+ - Зроблено:
487
+ ${fields.done || 'не вказано'}
488
+ - Рішення:
489
+ ${fields.decisions || 'none'}
490
+ - Блокери:
491
+ ${fields.blocked || 'none'}
492
+ - Attach:
493
+ ${fields.attach || `- \`openspec/changes/${name}/\``}
494
+ - Субагенти цієї сесії:
495
+ ${fields.spawn || `- \`${spawnName || 'phase specialist'}\``}
496
+ - Обмеження:
497
+ ${fields.constraints || 'не змішувати фази; не писати поза дозволеними шляхами ролі'}
498
+ ${fields.status ? `- status: ${fields.status}` : ''}
499
+ ${fields.tasks ? `- tasks: ${fields.tasks}` : ''}
500
+ ${fields.review ? `- review: ${fields.review}` : ''}
501
+
502
+ ## HARD STOP на виході (ти НЕ закінчив, поки це не виконано)
503
+ 1. Заспавни \`session-handoff\` у режимі persist (Amp: isolated \`subagent-session-handoff\`). Якщо spawn недоступний — зроби persist сам, ніколи не пропускай.
504
+ 2. Запиши \`openspec/changes/${name}/handoff.md\` з усіма секціями шаблону.
505
+ 3. \`npx agent-orchestrator-kit handoff ${name}\` — exit 0 обов'язковий. CLI записує Memory JSON абсолютним шляхом і друкує розширений промпт у stdout.
506
+ 4. Якщо Memory MCP живий — онови \`Change:${name}\`, \`Handoff:${name}\`, \`Decision:*\` відповідно до файлу.
507
+ 5. Встав stdout CLI у чат одним fenced-блоком. Не скорочуй. Без службового ярлика. Перший рядок — \`/opsx:…\`.
508
+ 6. Зупинись. Наступна роль починається в НОВОМУ чаті з цим промптом.
509
+
510
+ OpenSpec-файли — source of truth для вимог і тасків. Memory і handoff.md — індекс фази. Цей промпт — повний операційний бриф наступного треду, навіть якщо Amp проігнорує Memory MCP.`;
511
+ }
512
+
513
+ return `${cmd}
514
+
515
+ You are the conductor for the next role session of change \`${name}\`.
516
+ Reply language: ${languageName} (\`project.agent_language: ${agentLanguage}\`).
517
+ Do not mix phases. Do not start the following role in this chat until this phase is closed via HARD STOP.
518
+
519
+ ## Who you are and what to do
520
+ - This session command: \`${cmd}\`
521
+ - Next role / phase subagent: \`${spawnName || fields.nextRole || 'see routing table'}\`
522
+ - Amp: spawn isolated skill \`${ampWrapper}\` with fresh context. Running the specialist body in Amp's main thread is a protocol violation.
523
+ - Cursor / Claude: spawn \`.cursor/agents/${spawnName || '<name>'}.md\` / \`.claude/agents/${spawnName || '<name>'}.md\`.
524
+ - The parent session is conductor-only: verify the report, do not do the specialist's work.
525
+
526
+ ## Mandatory start (before any specialist work)
527
+ 1. Honor the pasted \`${cmd}\` command and announce the role.
528
+ 2. \`npx agent-orchestrator-kit status\`
529
+ 3. \`npx agent-orchestrator-kit handoff ${name} --restore\`
530
+ 4. Read Memory MCP: \`Change:${name}\`, \`Handoff:${name}\`, \`Decision:*\`.
531
+ 5. If Memory is empty or MCP is down, read \`openspec/changes/${name}/handoff.md\`. Missing Memory does not block the session when the file exists.
532
+ 6. Spawn \`session-handoff\` in restore mode if the briefing is incomplete (Amp: isolated \`subagent-session-handoff\`).
533
+ 7. Only then spawn the phase specialist. Free-form "continue" / "next" with one active change means \`Handoff.next_command\`.
534
+
535
+ ## Full previous-session context (self-contained — do not rely on Memory alone)
536
+ - Closed role: ${fields.closedRole || 'not set'}
537
+ - Change: ${fields.change || name}
538
+ - Done:
539
+ ${fields.done || 'not set'}
540
+ - Decisions:
541
+ ${fields.decisions || 'none'}
542
+ - Blocked:
543
+ ${fields.blocked || 'none'}
544
+ - Attach:
545
+ ${fields.attach || `- \`openspec/changes/${name}/\``}
546
+ - Subagents for this session:
547
+ ${fields.spawn || `- \`${spawnName || 'phase specialist'}\``}
548
+ - Constraints:
549
+ ${fields.constraints || 'do not mix phases; do not write outside the role allowed paths'}
550
+ ${fields.status ? `- status: ${fields.status}` : ''}
551
+ ${fields.tasks ? `- tasks: ${fields.tasks}` : ''}
552
+ ${fields.review ? `- review: ${fields.review}` : ''}
553
+
554
+ ## Exit HARD STOP (you are NOT done until this succeeds)
555
+ 1. Spawn \`session-handoff\` in persist mode (Amp: isolated \`subagent-session-handoff\`). If spawn is unavailable, persist yourself — never skip.
556
+ 2. Write \`openspec/changes/${name}/handoff.md\` with every template section.
557
+ 3. \`npx agent-orchestrator-kit handoff ${name}\` — exit 0 is required. The CLI upserts Memory JSON with an absolute path and prints the expanded prompt on stdout.
558
+ 4. If Memory MCP tools work, also update \`Change:${name}\`, \`Handoff:${name}\`, \`Decision:*\` to match the file.
559
+ 5. Paste CLI stdout into chat as one fenced block. Do not shorten it. No service banner. First line is \`/opsx:…\`.
560
+ 6. Stop. The next role starts in a NEW chat with that prompt.
561
+
562
+ OpenSpec files are the source of truth for requirements and tasks. Memory and handoff.md index the phase. This prompt is the next thread's full operating brief even if Amp ignores Memory MCP.`;
563
+ }
564
+
565
+ function loadMemoryItems(filePath) {
566
+ if (!existsSync(filePath)) return [];
567
+ const raw = readFileSync(filePath, 'utf-8').trim();
568
+ if (!raw) return [];
569
+ if (raw.startsWith('{')) {
570
+ try {
571
+ const parsed = JSON.parse(raw);
572
+ const entities = (parsed.entities || []).map((entity) => ({ type: 'entity', ...entity }));
573
+ const relations = (parsed.relations || []).map((relation) => ({ type: 'relation', ...relation }));
574
+ return [...entities, ...relations];
575
+ } catch {
576
+ return [];
577
+ }
578
+ }
579
+ return raw
580
+ .split('\n')
581
+ .map((line) => line.trim())
582
+ .filter(Boolean)
583
+ .map((line) => JSON.parse(line));
584
+ }
585
+
586
+ function saveMemoryItems(filePath, items) {
587
+ mkdirSync(dirname(filePath), { recursive: true });
588
+ const body = items.map((item) => JSON.stringify(item)).join('\n');
589
+ writeFileSync(filePath, body ? `${body}\n` : '');
590
+ }
591
+
592
+ function upsertMemoryEntity(items, name, entityType, observations) {
593
+ const entity = { type: 'entity', name, entityType, observations };
594
+ const idx = items.findIndex((item) => item.type === 'entity' && item.name === name);
595
+ if (idx >= 0) items[idx] = entity;
596
+ else items.push(entity);
597
+ }
598
+
599
+ function parseDecisionItems(text) {
600
+ return String(text || '')
601
+ .split('\n')
602
+ .map((line) => line.replace(/^\s*[-*]\s*/, '').trim())
603
+ .filter((line) => line && !/^none$/i.test(line));
604
+ }
605
+
606
+ function persistMemoryFromHandoff(projectDir, fields) {
607
+ const filePath = resolve(projectDir, MEMORY_FILE_REL);
608
+ const items = loadMemoryItems(filePath);
609
+ const name = fields.changeName;
610
+ const changeObs = [
611
+ fields.status ? `status: ${fields.status}` : null,
612
+ fields.tasks ? `tasks: ${fields.tasks}` : null,
613
+ fields.closedRole ? `last_role: ${fields.closedRole}` : null,
614
+ fields.review ? `review: ${fields.review}` : null,
615
+ fields.summary ? `summary: ${fields.summary}` : null,
616
+ ].filter(Boolean);
617
+ if (changeObs.length) upsertMemoryEntity(items, `Change:${name}`, 'Change', changeObs);
618
+
619
+ const handoffObs = [
620
+ fields.nextRole ? `next_role: ${fields.nextRole}` : null,
621
+ fields.nextCommand ? `next_command: ${fields.nextCommand}` : null,
622
+ fields.sessionCount ? `session_count: ${fields.sessionCount}` : null,
623
+ fields.summary ? `summary: ${fields.summary}` : null,
624
+ fields.blocked ? `blocked: ${fields.blocked}` : null,
625
+ ].filter(Boolean);
626
+ if (handoffObs.length) upsertMemoryEntity(items, `Handoff:${name}`, 'Handoff', handoffObs);
627
+
628
+ for (const decision of parseDecisionItems(fields.decisions)) {
629
+ const topicMatch = decision.match(/^([^:]+):/);
630
+ const topic = (topicMatch ? topicMatch[1] : decision).trim().slice(0, 80) || decision.slice(0, 80);
631
+ upsertMemoryEntity(items, `Decision:${topic}`, 'Decision', [`chosen: ${decision}`]);
632
+ }
633
+
634
+ saveMemoryItems(filePath, items);
635
+ return filePath;
636
+ }
637
+
638
+ function resolveHandoffChange(projectDir, requested) {
639
+ if (requested) return requested;
640
+ const changes = listActiveChanges(projectDir);
641
+ if (changes.length === 1) return changes[0];
642
+ if (changes.length === 0) return null;
643
+ return { ambiguous: changes };
644
+ }
645
+
646
+ function readHandoffFields(projectDir, changeName) {
647
+ const filePath = join(projectDir, 'openspec', 'changes', changeName, 'handoff.md');
648
+ if (!existsSync(filePath)) return { filePath, fields: null };
649
+ const sections = parseHandoffMarkdown(readFileSync(filePath, 'utf-8'));
650
+ return { filePath, fields: fieldsFromSections(changeName, sections) };
651
+ }
652
+
240
653
  function parseFigmaUrl(url) {
241
654
  try {
242
655
  const parsed = new URL(url);
@@ -643,7 +1056,7 @@ function generateAmpSubagentSkills(projectDir) {
643
1056
  '',
644
1057
  `<!-- AUTO-GENERATED from .agents/subagents/${file} — edit the source file, then run: npx agent-orchestrator-kit sync -->`,
645
1058
  '',
646
- 'Parent MUST spawn this skill as an isolated subagent with fresh context. Do not execute it in the main thread. Return only the structured subagent report.',
1059
+ AMP_SPAWN_PREAMBLE,
647
1060
  '',
648
1061
  parsed[2].trim(),
649
1062
  '',
@@ -774,6 +1187,10 @@ program
774
1187
  log.title('Updating .gitignore');
775
1188
  mergeGitignore(projectDir, GITIGNORE_LINES);
776
1189
 
1190
+ log.title('Configuring Memory MCP');
1191
+ refreshMemoryManagedFiles(projectDir);
1192
+ ensureMemoryMcpEntry(projectDir);
1193
+
777
1194
  log.title('Done');
778
1195
  log.ok(`agent-orchestrator-kit v${KIT_VERSION} installed`);
779
1196
  printNextSteps(profile, projectDir, ci, specVerify);
@@ -825,6 +1242,9 @@ program
825
1242
 
826
1243
  log.title('Refreshing Figma setup templates');
827
1244
  refreshFigmaManagedFiles(projectDir);
1245
+ log.title('Configuring Memory MCP');
1246
+ refreshMemoryManagedFiles(projectDir);
1247
+ ensureMemoryMcpEntry(projectDir);
828
1248
  mergeGitignore(projectDir, GITIGNORE_LINES);
829
1249
 
830
1250
  log.ok(`Updated to v${KIT_VERSION}`);
@@ -889,6 +1309,9 @@ program
889
1309
  syncAmp(projectDir);
890
1310
  }
891
1311
 
1312
+ log.title('Configuring Memory MCP');
1313
+ ensureMemoryMcpEntry(projectDir);
1314
+
892
1315
  mergeGitignore(projectDir, GITIGNORE_LINES);
893
1316
 
894
1317
  log.ok('Sync complete');
@@ -1139,4 +1562,137 @@ program
1139
1562
  }
1140
1563
  });
1141
1564
 
1565
+ program
1566
+ .command('memory-setup')
1567
+ .description('Install memory MCP launcher and rewrite Cursor/Amp configs to use an absolute MEMORY_FILE_PATH')
1568
+ .action(() => {
1569
+ const projectDir = process.cwd();
1570
+ log.title('agent-orchestrator memory-setup');
1571
+ refreshMemoryManagedFiles(projectDir);
1572
+ mergeGitignore(projectDir, GITIGNORE_LINES);
1573
+ ensureMemoryMcpEntry(projectDir);
1574
+ log.ok(`Memory file: ${resolve(projectDir, MEMORY_FILE_REL)}`);
1575
+ log.info('Restart Cursor / Amp after this change');
1576
+ });
1577
+
1578
+ program
1579
+ .command('handoff [change-name]')
1580
+ .description('Persist or restore session handoff: write handoff.md, upsert Memory JSON, print the expanded next-thread prompt')
1581
+ .option('--restore', 'Print the restore briefing instead of persisting', false)
1582
+ .option('--closed-role <role>', 'Closed role for persist')
1583
+ .option('--next-command <command>', 'Next /opsx:* command')
1584
+ .option('--next-role <role>', 'Next role or subagent name')
1585
+ .option('--summary <text>', 'Persisted summary (also fills Done when Done is empty)')
1586
+ .option('--done <text>', 'Done section')
1587
+ .option('--decisions <text>', 'Decisions section')
1588
+ .option('--blocked <text>', 'Blocked section')
1589
+ .option('--attach <text>', 'Attach section')
1590
+ .option('--spawn <text>', 'Subagents to spawn')
1591
+ .option('--constraints <text>', 'Constraints section')
1592
+ .option('--status <status>', 'Change status observation')
1593
+ .option('--tasks <progress>', 'Task progress n/m')
1594
+ .option('--review <verdict>', 'Review verdict')
1595
+ .option('--session-count <n>', 'Handoff session_count')
1596
+ .action((changeName, opts) => {
1597
+ const projectDir = process.cwd();
1598
+ const resolved = resolveHandoffChange(projectDir, changeName);
1599
+ if (!resolved) {
1600
+ log.err('No active change found. Pass a name: npx agent-orchestrator-kit handoff <name>');
1601
+ process.exitCode = 1;
1602
+ return;
1603
+ }
1604
+ if (resolved.ambiguous) {
1605
+ log.err(`Multiple active changes: ${resolved.ambiguous.join(', ')}. Pass the change name argument.`);
1606
+ process.exitCode = 1;
1607
+ return;
1608
+ }
1609
+
1610
+ const name = resolved;
1611
+ const { agentLanguage } = readOrchestratorMeta(projectDir);
1612
+ const changeDir = join(projectDir, 'openspec', 'changes', name);
1613
+
1614
+ if (opts.restore) {
1615
+ log.title(`handoff restore ${name}`);
1616
+ const { filePath, fields } = readHandoffFields(projectDir, name);
1617
+ const memoryPath = resolve(projectDir, MEMORY_FILE_REL);
1618
+ const memoryItems = loadMemoryItems(memoryPath).filter(
1619
+ (item) => item.type === 'entity' && String(item.name || '').includes(name),
1620
+ );
1621
+ if (!fields && memoryItems.length === 0) {
1622
+ log.err(`No handoff.md or Memory entities for ${name}`);
1623
+ log.info(`Expected: ${filePath}`);
1624
+ process.exitCode = 1;
1625
+ return;
1626
+ }
1627
+ if (fields) {
1628
+ log.ok(`handoff.md: ${filePath}`);
1629
+ console.log(`next_command: ${fields.nextCommand || '(missing)'}`);
1630
+ console.log(`next_role: ${fields.nextRole || '(missing)'}`);
1631
+ console.log(`closed_role: ${fields.closedRole || '(missing)'}`);
1632
+ console.log('');
1633
+ console.log(fields.done || '');
1634
+ } else {
1635
+ log.warn('handoff.md missing — using Memory JSON only');
1636
+ }
1637
+ if (memoryItems.length) {
1638
+ log.ok(`Memory entities: ${memoryItems.length} (${memoryPath})`);
1639
+ } else {
1640
+ log.warn(`Memory JSON empty or missing at ${memoryPath}`);
1641
+ }
1642
+ return;
1643
+ }
1644
+
1645
+ console.error(pc.bold(pc.white(`\nhandoff persist ${name}`)));
1646
+ if (!existsSync(changeDir)) {
1647
+ log.err(`change not found: ${name}`);
1648
+ process.exitCode = 1;
1649
+ return;
1650
+ }
1651
+
1652
+ const existing = readHandoffFields(projectDir, name);
1653
+ const extra = {
1654
+ closedRole: opts.closedRole,
1655
+ nextCommand: opts.nextCommand,
1656
+ nextRole: opts.nextRole,
1657
+ summary: opts.summary,
1658
+ done: opts.done,
1659
+ decisions: opts.decisions,
1660
+ blocked: opts.blocked,
1661
+ attach: opts.attach,
1662
+ spawn: opts.spawn,
1663
+ constraints: opts.constraints,
1664
+ status: opts.status,
1665
+ tasks: opts.tasks,
1666
+ review: opts.review,
1667
+ sessionCount: opts.sessionCount,
1668
+ };
1669
+ const sections = existing.fields ? parseHandoffMarkdown(readFileSync(existing.filePath, 'utf-8')) : {};
1670
+ const fields = fieldsFromSections(name, sections, extra);
1671
+ const missing = missingHandoffFields(fields);
1672
+ if (missing.length) {
1673
+ log.err(`handoff.md incomplete — missing: ${missing.join(', ')}`);
1674
+ log.err('Write the file sections or pass --closed-role, --done/--summary, and --next-command');
1675
+ process.exitCode = 1;
1676
+ return;
1677
+ }
1678
+
1679
+ const progress = parseTasksProgress(changeDir);
1680
+ if (!fields.tasks && progress) fields.tasks = `${progress.done}/${progress.total}`;
1681
+ if (!fields.review) fields.review = parseReviewVerdict(changeDir) || '';
1682
+ if (!fields.status) {
1683
+ fields.status = fields.review && /^APPROVE/i.test(fields.review) ? 'spec-approved' : 'in-progress';
1684
+ }
1685
+
1686
+ const prompt = buildNextSessionPrompt(fields, agentLanguage).replace(/^\n+|\n+$/g, '');
1687
+ fields.prompt = prompt;
1688
+ writeFileSync(existing.filePath, `${buildHandoffMarkdown(fields).trim()}\n`);
1689
+ console.error(pc.green(' ✓'), existing.filePath.replace(`${projectDir}/`, ''));
1690
+
1691
+ const memoryPath = persistMemoryFromHandoff(projectDir, fields);
1692
+ console.error(pc.green(' ✓'), `Memory JSON upserted: ${memoryPath}`);
1693
+
1694
+ console.error(pc.dim('Copy the prompt below into the next chat as one fenced block. Do not include this line.'));
1695
+ process.stdout.write(`${prompt}\n`);
1696
+ });
1697
+
1142
1698
  program.parse();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-orchestrator-kit",
3
- "version": "0.1.13",
4
- "description": "Universal AI agent orchestration kit for Cursor, Claude Code, and Amp Code — spec-driven OpenSpec pipeline, conductor subagents, session handoff, and optional local Figma PAT setup",
3
+ "version": "0.2.0",
4
+ "description": "Universal AI agent orchestration kit for Cursor, Claude Code, and Amp Code — spec-driven OpenSpec pipeline, conductor subagents, durable session handoff CLI, and optional local Figma PAT setup",
5
5
  "keywords": [
6
6
  "ai-agent",
7
7
  "cursor",
@@ -47,10 +47,13 @@ handoff:
47
47
  restore_on_start: true
48
48
  persist_on_exit: true
49
49
  emit_next_session_prompt: true
50
+ prompt_self_contained: true
51
+ spawn_handoff_subagent: true
50
52
 
51
53
  memory:
52
54
  enabled: true
53
55
  file: .cursor/memory.json
56
+ launcher: scripts/memory-mcp-launcher.cjs
54
57
 
55
58
  mcp:
56
59
  baseline:
@@ -54,10 +54,13 @@ handoff:
54
54
  restore_on_start: true
55
55
  persist_on_exit: true
56
56
  emit_next_session_prompt: true
57
+ prompt_self_contained: true
58
+ spawn_handoff_subagent: true
57
59
 
58
60
  memory:
59
61
  enabled: true
60
62
  file: .cursor/memory.json
63
+ launcher: scripts/memory-mcp-launcher.cjs
61
64
 
62
65
  mcp:
63
66
  baseline:
@@ -52,10 +52,13 @@ handoff:
52
52
  restore_on_start: true
53
53
  persist_on_exit: true
54
54
  emit_next_session_prompt: true
55
+ prompt_self_contained: true
56
+ spawn_handoff_subagent: true
55
57
 
56
58
  memory:
57
59
  enabled: true
58
60
  file: .cursor/memory.json
61
+ launcher: scripts/memory-mcp-launcher.cjs
59
62
 
60
63
  mcp:
61
64
  baseline:
@@ -51,10 +51,13 @@ handoff:
51
51
  restore_on_start: true
52
52
  persist_on_exit: true
53
53
  emit_next_session_prompt: true
54
+ prompt_self_contained: true
55
+ spawn_handoff_subagent: true
54
56
 
55
57
  memory:
56
58
  enabled: true
57
59
  file: .cursor/memory.json
60
+ launcher: scripts/memory-mcp-launcher.cjs
58
61
 
59
62
  mcp:
60
63
  baseline: