agent-orchestrator-kit 0.3.0 → 0.4.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.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { program } from 'commander';
3
3
  import pc from 'picocolors';
4
- import { readFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSync, writeFileSync, rmSync, renameSync } from 'fs';
4
+ import { readFileSync, existsSync, mkdirSync, copyFileSync, readdirSync, statSync, writeFileSync, rmSync, renameSync, chmodSync } from 'fs';
5
5
  import { join, dirname, basename, resolve } from 'path';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { execSync } from 'child_process';
@@ -12,22 +12,23 @@ const KIT_VERSION = JSON.parse(readFileSync(join(KIT_ROOT, 'package.json'), 'utf
12
12
 
13
13
  const VALID_PROFILES = ['generic', 'vue3', 'node', 'mvp'];
14
14
 
15
- const KIT_SKILL_DIRS = [
16
- 'agent-orchestration',
17
- 'openspec-howto',
18
- 'openspec-explore',
19
- 'openspec-propose',
20
- 'openspec-apply-change',
21
- 'openspec-archive-change',
22
- 'openspec-sync-specs',
23
- 'spec-workflow-openspec',
24
- ];
15
+ function listTemplateKitSkillDirs() {
16
+ const skillsDir = join(KIT_ROOT, 'templates', '.agents', 'skills');
17
+ if (!existsSync(skillsDir)) return [];
18
+ return readdirSync(skillsDir)
19
+ .filter((name) => {
20
+ if (name.startsWith('subagent-')) return false;
21
+ const full = join(skillsDir, name);
22
+ return existsSync(full) && statSync(full).isDirectory();
23
+ })
24
+ .sort();
25
+ }
25
26
 
26
27
  const KIT_MANAGED_PATHS = [
27
28
  '.agents/commands',
28
29
  '.agents/rules',
29
30
  '.agents/subagents',
30
- ...KIT_SKILL_DIRS.map((s) => `.agents/skills/${s}`),
31
+ ...listTemplateKitSkillDirs().map((s) => `.agents/skills/${s}`),
31
32
  'scripts/sync-local-agent-skills.sh',
32
33
  ];
33
34
 
@@ -66,6 +67,8 @@ const GITIGNORE_LINES = [
66
67
  '.amp/settings.json',
67
68
  '.claude',
68
69
  '.agents/figma.local.env',
70
+ '.agents/github.local.env',
71
+ '.agents/gitlab.local.env',
69
72
  ];
70
73
 
71
74
  const FIGMA_ENV_REL = join('.agents', 'figma.local.env');
@@ -73,17 +76,67 @@ const FIGMA_ENV_EXAMPLE_REL = join('.agents', 'figma.local.env.example');
73
76
  const FIGMA_LAUNCHER_REL = join('scripts', 'figma-mcp-launcher.cjs');
74
77
  const MEMORY_LAUNCHER_REL = join('scripts', 'memory-mcp-launcher.cjs');
75
78
  const MEMORY_FILE_REL = join('.cursor', 'memory.json');
79
+ const GITHUB_ENV_REL = join('.agents', 'github.local.env');
80
+ const GITHUB_ENV_EXAMPLE_REL = join('.agents', 'github.local.env.example');
81
+ const GITHUB_LAUNCHER_REL = join('scripts', 'github-mcp-launcher.cjs');
82
+ const GITLAB_ENV_REL = join('.agents', 'gitlab.local.env');
83
+ const GITLAB_ENV_EXAMPLE_REL = join('.agents', 'gitlab.local.env.example');
84
+ const GITLAB_LAUNCHER_REL = join('scripts', 'gitlab-mcp-launcher.cjs');
85
+ const BROWSER_LAUNCHER_REL = join('scripts', 'browser-mcp-launcher.cjs');
86
+ const HOOK_SCRIPT_REL = join('scripts', 'pre-commit-gate-check.sh');
87
+ const MCP_EXAMPLE_REL = join('.agents', 'mcp.json.example');
88
+ const AMP_EXAMPLE_REL = join('.agents', 'amp.settings.json.example');
89
+ const OPTIONAL_MCP_SEED_STRIP = ['github', 'gitlab', 'browser'];
90
+ const HOOK_MARKER = '# agent-orchestrator-kit gate';
91
+ const HOOK_LINE = 'sh scripts/pre-commit-gate-check.sh';
92
+ const DEFAULT_GITLAB_API_URL = 'https://gitlab.com/api/v4';
93
+ const DEFAULT_MCP_INVENTORY = {
94
+ baseline: ['memory'],
95
+ optional: ['figma', 'github', 'gitlab', 'browser'],
96
+ };
76
97
  const FIGMA_MANAGED_PATHS = [
77
98
  FIGMA_ENV_EXAMPLE_REL,
78
99
  FIGMA_LAUNCHER_REL,
79
- join('.agents', 'mcp.json.example'),
80
- join('.agents', 'amp.settings.json.example'),
100
+ MCP_EXAMPLE_REL,
101
+ AMP_EXAMPLE_REL,
81
102
  ];
82
103
  const MEMORY_MANAGED_PATHS = [
83
104
  MEMORY_LAUNCHER_REL,
84
- join('.agents', 'mcp.json.example'),
85
- join('.agents', 'amp.settings.json.example'),
105
+ MCP_EXAMPLE_REL,
106
+ AMP_EXAMPLE_REL,
86
107
  ];
108
+ const OPTIONAL_MCP_MANAGED_PATHS = [
109
+ GITHUB_ENV_EXAMPLE_REL,
110
+ GITHUB_LAUNCHER_REL,
111
+ GITLAB_ENV_EXAMPLE_REL,
112
+ GITLAB_LAUNCHER_REL,
113
+ BROWSER_LAUNCHER_REL,
114
+ HOOK_SCRIPT_REL,
115
+ MCP_EXAMPLE_REL,
116
+ AMP_EXAMPLE_REL,
117
+ ];
118
+ const MCP_SERVER_CONFIGS = {
119
+ github: { command: 'node', args: [GITHUB_LAUNCHER_REL.replace(/\\/g, '/')] },
120
+ gitlab: { command: 'node', args: [GITLAB_LAUNCHER_REL.replace(/\\/g, '/')] },
121
+ browser: { command: 'node', args: [BROWSER_LAUNCHER_REL.replace(/\\/g, '/')] },
122
+ };
123
+ const MCP_TOOL_META = {
124
+ memory: { launcher: MEMORY_LAUNCHER_REL, envRel: null, tokenKeys: [] },
125
+ figma: { launcher: FIGMA_LAUNCHER_REL, envRel: FIGMA_ENV_REL, tokenKeys: ['FIGMA_ACCESS_TOKEN', 'FIGMA_API_KEY'] },
126
+ github: {
127
+ launcher: GITHUB_LAUNCHER_REL,
128
+ envRel: GITHUB_ENV_REL,
129
+ tokenKeys: ['GITHUB_PERSONAL_ACCESS_TOKEN', 'GITHUB_TOKEN'],
130
+ vcs: 'github',
131
+ },
132
+ gitlab: {
133
+ launcher: GITLAB_LAUNCHER_REL,
134
+ envRel: GITLAB_ENV_REL,
135
+ tokenKeys: ['GITLAB_PERSONAL_ACCESS_TOKEN', 'GITLAB_TOKEN'],
136
+ vcs: 'gitlab',
137
+ },
138
+ browser: { launcher: BROWSER_LAUNCHER_REL, envRel: null, tokenKeys: [] },
139
+ };
87
140
  const AMP_SPAWN_PREAMBLE =
88
141
  '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
142
  const HANDOFF_REQUIRED_SECTIONS = ['Closed role', 'Done', 'Next command'];
@@ -98,8 +151,12 @@ const HANDOFF_SECTIONS = [
98
151
  'Attach',
99
152
  'Subagents to spawn',
100
153
  'Constraints',
154
+ 'Runtime',
101
155
  'Prompt',
102
156
  ];
157
+ const CLOUD_ENV_MARKERS = ['CURSOR_BACKGROUND_AGENT'];
158
+ const VALID_RUNTIMES = new Set(['local', 'cloud']);
159
+ const CLOUD_PUSH_HINT = 'git push -u origin HEAD';
103
160
 
104
161
  const log = {
105
162
  info: (msg) => console.log(pc.cyan(' →'), msg),
@@ -281,6 +338,417 @@ function refreshMemoryManagedFiles(projectDir) {
281
338
  }
282
339
  }
283
340
 
341
+ function chmodX(filePath) {
342
+ try {
343
+ chmodSync(filePath, 0o755);
344
+ } catch {}
345
+ }
346
+
347
+ function refreshManagedRelPaths(projectDir, rels) {
348
+ const templateDir = join(KIT_ROOT, 'templates');
349
+ for (const rel of rels) {
350
+ const src = join(templateDir, rel);
351
+ const dest = join(projectDir, rel);
352
+ if (!existsSync(src)) continue;
353
+ mkdirSync(dirname(dest), { recursive: true });
354
+ copyFileSync(src, dest);
355
+ if (rel.endsWith('.sh')) chmodX(dest);
356
+ log.ok(rel);
357
+ }
358
+ }
359
+
360
+ function refreshOptionalMcpManagedFiles(projectDir) {
361
+ refreshManagedRelPaths(projectDir, OPTIONAL_MCP_MANAGED_PATHS);
362
+ }
363
+
364
+ function parseGitRemoteHostname(url) {
365
+ const raw = String(url || '').trim();
366
+ if (!raw) return '';
367
+ try {
368
+ if (/^(https?|ssh|git):\/\//i.test(raw)) {
369
+ return new URL(raw).hostname.replace(/^www\./i, '').toLowerCase();
370
+ }
371
+ } catch {}
372
+ const scp = raw.match(/^(?:[^@\s]+@)?([^:/\s]+)[:/]/);
373
+ return scp ? scp[1].replace(/^www\./i, '').toLowerCase() : '';
374
+ }
375
+
376
+ function detectVcsHostFromRemoteUrl(url) {
377
+ const hostname = parseGitRemoteHostname(url);
378
+ if (!hostname) return { kind: 'none', hostname: '', apiUrl: '' };
379
+ if (hostname === 'github.com') return { kind: 'github', hostname, apiUrl: '' };
380
+ if (hostname === 'gitlab.com' || hostname.includes('gitlab')) {
381
+ return { kind: 'gitlab', hostname, apiUrl: `https://${hostname}/api/v4` };
382
+ }
383
+ return { kind: 'none', hostname, apiUrl: '' };
384
+ }
385
+
386
+ function readGitOriginUrl(projectDir) {
387
+ try {
388
+ return execSync('git remote get-url origin', {
389
+ cwd: projectDir,
390
+ stdio: ['ignore', 'pipe', 'ignore'],
391
+ encoding: 'utf-8',
392
+ }).trim();
393
+ } catch {
394
+ return '';
395
+ }
396
+ }
397
+
398
+ function detectVcsHost(projectDir) {
399
+ return detectVcsHostFromRemoteUrl(readGitOriginUrl(projectDir));
400
+ }
401
+
402
+ function gitConfigGet(projectDir, key) {
403
+ try {
404
+ return execSync(`git config --get ${key}`, {
405
+ cwd: projectDir,
406
+ stdio: ['ignore', 'pipe', 'ignore'],
407
+ encoding: 'utf-8',
408
+ }).trim();
409
+ } catch {
410
+ return '';
411
+ }
412
+ }
413
+
414
+ function isGitRepo(projectDir) {
415
+ try {
416
+ execSync('git rev-parse --is-inside-work-tree', {
417
+ cwd: projectDir,
418
+ stdio: ['ignore', 'pipe', 'ignore'],
419
+ });
420
+ return true;
421
+ } catch {
422
+ return false;
423
+ }
424
+ }
425
+
426
+ function isKitHooksPath(value) {
427
+ const v = String(value || '').trim().replace(/\\/g, '/').replace(/\/+$/, '');
428
+ return v === '.githooks' || v.endsWith('/.githooks');
429
+ }
430
+
431
+ function ensureHookLine(filePath, { shebang = false } = {}) {
432
+ mkdirSync(dirname(filePath), { recursive: true });
433
+ let content = existsSync(filePath) ? readFileSync(filePath, 'utf-8') : shebang ? '#!/usr/bin/env sh\n' : '';
434
+ if (content.includes('pre-commit-gate-check.sh')) {
435
+ chmodX(filePath);
436
+ return { added: false };
437
+ }
438
+ const prefix = content.length > 0 && !content.endsWith('\n') ? '\n' : '';
439
+ writeFileSync(filePath, `${content}${prefix}${HOOK_MARKER}\n${HOOK_LINE}\n`);
440
+ chmodX(filePath);
441
+ return { added: true };
442
+ }
443
+
444
+ function runHooksSetup(projectDir) {
445
+ refreshManagedRelPaths(projectDir, [HOOK_SCRIPT_REL]);
446
+ const huskyDir = join(projectDir, '.husky');
447
+ if (existsSync(huskyDir) && statSync(huskyDir).isDirectory()) {
448
+ const result = ensureHookLine(join(huskyDir, 'pre-commit'));
449
+ if (result.added) log.ok('.husky/pre-commit ← gate line');
450
+ else log.ok('.husky/pre-commit already has gate line');
451
+ return { ok: true, mode: 'husky' };
452
+ }
453
+
454
+ if (!isGitRepo(projectDir)) {
455
+ log.err('not a git repository — run git init, then re-run hooks-setup');
456
+ return { ok: false, mode: 'none' };
457
+ }
458
+
459
+ const current = gitConfigGet(projectDir, 'core.hooksPath');
460
+ if (current && !isKitHooksPath(current)) {
461
+ log.err(`refusing to overwrite core.hooksPath (${current})`);
462
+ log.info('Add this line to your existing pre-commit hook:');
463
+ log.info(` ${HOOK_LINE}`);
464
+ log.info('Or reset: git config --unset core.hooksPath then re-run hooks-setup');
465
+ return { ok: false, mode: 'foreign' };
466
+ }
467
+
468
+ const result = ensureHookLine(join(projectDir, '.githooks', 'pre-commit'), { shebang: true });
469
+ if (result.added) log.ok('.githooks/pre-commit');
470
+ else log.ok('.githooks/pre-commit already present');
471
+ if (!isKitHooksPath(current)) {
472
+ execSync('git config core.hooksPath .githooks', { cwd: projectDir, stdio: 'pipe' });
473
+ log.ok('core.hooksPath = .githooks');
474
+ } else {
475
+ log.ok('core.hooksPath already .githooks');
476
+ }
477
+ return { ok: true, mode: 'githooks' };
478
+ }
479
+
480
+ function ensureEnvFromExample(projectDir, destRel, exampleRel) {
481
+ const dest = join(projectDir, destRel);
482
+ const example = join(projectDir, exampleRel);
483
+ const kitExample = join(KIT_ROOT, 'templates', exampleRel);
484
+ if (existsSync(dest)) return { created: false, path: dest };
485
+ const src = existsSync(example) ? example : kitExample;
486
+ if (!existsSync(src)) throw new Error(`Missing template: ${exampleRel}`);
487
+ mkdirSync(dirname(dest), { recursive: true });
488
+ copyFileSync(src, dest);
489
+ return { created: true, path: dest };
490
+ }
491
+
492
+ function upsertEnvKey(filePath, key, value) {
493
+ const raw = existsSync(filePath) ? readFileSync(filePath, 'utf-8') : '';
494
+ const re = new RegExp(`^${key}=.*$`, 'm');
495
+ const line = `${key}=${value}`;
496
+ const next = re.test(raw)
497
+ ? raw.replace(re, line)
498
+ : `${raw}${raw && !raw.endsWith('\n') ? '\n' : ''}${line}\n`;
499
+ writeFileSync(filePath, next.endsWith('\n') ? next : `${next}\n`);
500
+ }
501
+
502
+ function envHasAnyKey(projectDir, envRel, keys) {
503
+ if (!envRel || !keys.length) return true;
504
+ const values = parseEnvFile(join(projectDir, envRel));
505
+ return keys.some((key) => Boolean(values[key]));
506
+ }
507
+
508
+ function readJsonFile(filePath) {
509
+ try {
510
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
511
+ } catch {
512
+ return null;
513
+ }
514
+ }
515
+
516
+ function stripMcpServers(filePath, serversKey, names) {
517
+ if (!existsSync(filePath) || !names.length) return;
518
+ const cfg = readJsonFile(filePath);
519
+ if (!cfg) return;
520
+ const servers = cfg[serversKey] || {};
521
+ let changed = false;
522
+ for (const name of names) {
523
+ if (servers[name]) {
524
+ delete servers[name];
525
+ changed = true;
526
+ }
527
+ }
528
+ if (!changed) return;
529
+ cfg[serversKey] = servers;
530
+ writeJsonFile(filePath, cfg);
531
+ }
532
+
533
+ function seedLiveMcpFromExample(livePath, examplePath, serversKey, stripNames, label) {
534
+ if (existsSync(livePath) || !existsSync(examplePath)) return false;
535
+ copyFileSync(examplePath, livePath);
536
+ stripMcpServers(livePath, serversKey, stripNames);
537
+ log.ok(`${label} created from example`);
538
+ return true;
539
+ }
540
+
541
+ function upsertMcpServer(cfg, key, name, server, label) {
542
+ const servers = cfg[key] || {};
543
+ if (servers[name]) {
544
+ log.ok(`${label} already has ${name} server`);
545
+ } else {
546
+ servers[name] = server;
547
+ log.ok(`${label} ← added ${name} server`);
548
+ }
549
+ cfg[key] = servers;
550
+ return cfg;
551
+ }
552
+
553
+ function writeMcpServerEntry(filePath, serversKey, name, server, label) {
554
+ if (!existsSync(filePath)) return;
555
+ const cfg = readJsonFile(filePath);
556
+ if (!cfg) {
557
+ log.warn(`${label} present but invalid JSON — merge ${name} server manually from .agents/mcp.json.example`);
558
+ return;
559
+ }
560
+ writeJsonFile(filePath, upsertMcpServer(cfg, serversKey, name, server, label));
561
+ }
562
+
563
+ function liveMcpHasServer(projectDir, name) {
564
+ const cursor = readJsonFile(join(projectDir, '.mcp.json'));
565
+ const amp = readJsonFile(join(projectDir, '.amp', 'settings.json'));
566
+ return Boolean(cursor?.mcpServers?.[name] || amp?.['amp.mcpServers']?.[name]);
567
+ }
568
+
569
+ function undetectedVcsNames(kind) {
570
+ if (kind === 'github') return ['gitlab'];
571
+ if (kind === 'gitlab') return ['github'];
572
+ return ['github', 'gitlab'];
573
+ }
574
+
575
+ function parseMcpInventory(content) {
576
+ const baseline = [];
577
+ const optional = [];
578
+ let inMcp = false;
579
+ let section = null;
580
+ for (const line of String(content || '').split('\n')) {
581
+ if (/^mcp:\s*$/.test(line)) {
582
+ inMcp = true;
583
+ section = null;
584
+ continue;
585
+ }
586
+ if (inMcp && /^\S/.test(line)) break;
587
+ if (!inMcp) continue;
588
+ if (/^\s+baseline:\s*$/.test(line)) {
589
+ section = 'baseline';
590
+ continue;
591
+ }
592
+ if (/^\s+optional:\s*$/.test(line)) {
593
+ section = 'optional';
594
+ continue;
595
+ }
596
+ const item = line.match(/^\s+-\s+([A-Za-z0-9_-]+)\s*$/);
597
+ if (item && section === 'baseline') baseline.push(item[1]);
598
+ else if (item && section === 'optional') optional.push(item[1]);
599
+ }
600
+ return { baseline, optional };
601
+ }
602
+
603
+ function readMcpInventory(projectDir) {
604
+ const orchPath = join(projectDir, '.agents', 'orchestrator.yaml');
605
+ if (!existsSync(orchPath)) return { ...DEFAULT_MCP_INVENTORY };
606
+ const parsed = parseMcpInventory(readFileSync(orchPath, 'utf-8'));
607
+ if (!parsed.baseline.length && !parsed.optional.length) return { ...DEFAULT_MCP_INVENTORY };
608
+ return parsed;
609
+ }
610
+
611
+ function parseSkillsInventory(content) {
612
+ const kit = [];
613
+ const stack = [];
614
+ let external = '';
615
+ let found = false;
616
+ let inSkills = false;
617
+ let section = null;
618
+ for (const line of String(content || '').split(/\r?\n/)) {
619
+ if (/^skills:\s*$/.test(line)) {
620
+ found = true;
621
+ inSkills = true;
622
+ section = null;
623
+ continue;
624
+ }
625
+ if (inSkills && /^\S/.test(line)) break;
626
+ if (!inSkills) continue;
627
+ if (/^\s+kit:\s*$/.test(line) || /^\s+kit:\s*\[\s*\]\s*$/.test(line)) {
628
+ section = 'kit';
629
+ continue;
630
+ }
631
+ if (/^\s+stack:\s*$/.test(line) || /^\s+stack:\s*\[\s*\]\s*$/.test(line)) {
632
+ section = 'stack';
633
+ continue;
634
+ }
635
+ const ext = line.match(/^\s+external:\s*(.*?)\s*$/);
636
+ if (ext) {
637
+ section = null;
638
+ let raw = ext[1];
639
+ if (
640
+ (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) ||
641
+ (raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2)
642
+ ) {
643
+ raw = raw.slice(1, -1);
644
+ }
645
+ external = raw;
646
+ continue;
647
+ }
648
+ const item = line.match(/^\s+-\s+([A-Za-z0-9_-]+)\s*$/);
649
+ if (item && section === 'kit') kit.push(item[1]);
650
+ else if (item && section === 'stack') stack.push(item[1]);
651
+ }
652
+ return { kit, stack, external, found };
653
+ }
654
+
655
+ function readSkillsInventory(projectDir) {
656
+ const fallback = { kit: listTemplateKitSkillDirs(), stack: [], external: '' };
657
+ const orchPath = join(projectDir, '.agents', 'orchestrator.yaml');
658
+ if (!existsSync(orchPath)) return fallback;
659
+ const parsed = parseSkillsInventory(readFileSync(orchPath, 'utf-8'));
660
+ if (!parsed.found) return fallback;
661
+ return { kit: parsed.kit, stack: parsed.stack, external: parsed.external };
662
+ }
663
+
664
+ function printMcpHealth(projectDir) {
665
+ const inventory = readMcpInventory(projectDir);
666
+ const tools = [...new Set([...inventory.baseline, ...inventory.optional])];
667
+ if (!tools.length) return;
668
+ const detected = detectVcsHost(projectDir);
669
+ console.log(pc.bold('\nMCP health'));
670
+ for (const name of tools) {
671
+ const meta = MCP_TOOL_META[name] || { launcher: join('scripts', `${name}-mcp-launcher.cjs`), envRel: null, tokenKeys: [] };
672
+ if (meta.vcs && detected.kind !== meta.vcs) {
673
+ console.log(` ${name.padEnd(8)} skipped (no origin match)`);
674
+ continue;
675
+ }
676
+ const launcherOk = existsSync(join(projectDir, meta.launcher));
677
+ const tokenOk = envHasAnyKey(projectDir, meta.envRel, meta.tokenKeys);
678
+ const entryOk = liveMcpHasServer(projectDir, name);
679
+ const ok = launcherOk && tokenOk && entryOk;
680
+ console.log(` ${name.padEnd(8)} ${ok ? 'ok' : 'not configured'}`);
681
+ }
682
+ console.log('');
683
+ }
684
+
685
+ function resolveMcpSetupVcs(projectDir, override) {
686
+ const detected = detectVcsHost(projectDir);
687
+ if (override === 'github' || override === 'gitlab') {
688
+ if (override === 'gitlab') {
689
+ const apiUrl = detected.kind === 'gitlab' && detected.apiUrl ? detected.apiUrl : DEFAULT_GITLAB_API_URL;
690
+ return { kind: 'gitlab', hostname: detected.kind === 'gitlab' ? detected.hostname : 'gitlab.com', apiUrl, overridden: true };
691
+ }
692
+ return { kind: 'github', hostname: detected.hostname || 'github.com', apiUrl: '', overridden: true };
693
+ }
694
+ return { ...detected, overridden: false };
695
+ }
696
+
697
+ function runMcpSetup(projectDir, { vcs = '', browser = true } = {}) {
698
+ refreshFigmaManagedFiles(projectDir);
699
+ refreshMemoryManagedFiles(projectDir);
700
+ refreshOptionalMcpManagedFiles(projectDir);
701
+ mergeGitignore(projectDir, GITIGNORE_LINES);
702
+
703
+ const selected = resolveMcpSetupVcs(projectDir, vcs);
704
+ if (selected.kind === 'github') log.info('VCS MCP: github');
705
+ else if (selected.kind === 'gitlab') log.info(`VCS MCP: gitlab (${selected.hostname})`);
706
+ else log.info('VCS MCP: skipped (no origin match)');
707
+
708
+ if (selected.kind === 'github') {
709
+ const env = ensureEnvFromExample(projectDir, GITHUB_ENV_REL, GITHUB_ENV_EXAMPLE_REL);
710
+ log.ok(env.created ? `Created ${GITHUB_ENV_REL}` : `${GITHUB_ENV_REL} already exists`);
711
+ if (!envHasAnyKey(projectDir, GITHUB_ENV_REL, ['GITHUB_PERSONAL_ACCESS_TOKEN', 'GITHUB_TOKEN'])) {
712
+ log.warn('GitHub token: missing — set GITHUB_PERSONAL_ACCESS_TOKEN in .agents/github.local.env (do not paste into chat)');
713
+ }
714
+ } else if (selected.kind === 'gitlab') {
715
+ const env = ensureEnvFromExample(projectDir, GITLAB_ENV_REL, GITLAB_ENV_EXAMPLE_REL);
716
+ log.ok(env.created ? `Created ${GITLAB_ENV_REL}` : `${GITLAB_ENV_REL} already exists`);
717
+ upsertEnvKey(env.path, 'GITLAB_API_URL', selected.apiUrl || DEFAULT_GITLAB_API_URL);
718
+ log.ok(`GITLAB_API_URL host: ${selected.hostname || 'gitlab.com'}`);
719
+ if (!envHasAnyKey(projectDir, GITLAB_ENV_REL, ['GITLAB_PERSONAL_ACCESS_TOKEN', 'GITLAB_TOKEN'])) {
720
+ log.warn('GitLab token: missing — set GITLAB_PERSONAL_ACCESS_TOKEN in .agents/gitlab.local.env (do not paste into chat)');
721
+ }
722
+ }
723
+
724
+ const cursorPath = join(projectDir, '.mcp.json');
725
+ const ampPath = join(projectDir, '.amp', 'settings.json');
726
+ const cursorExample = join(projectDir, MCP_EXAMPLE_REL);
727
+ const ampExample = join(projectDir, AMP_EXAMPLE_REL);
728
+ const stripOnCreate = [...undetectedVcsNames(selected.kind), ...(browser ? [] : ['browser'])];
729
+ seedLiveMcpFromExample(cursorPath, cursorExample, 'mcpServers', stripOnCreate, '.mcp.json');
730
+ mkdirSync(join(projectDir, '.amp'), { recursive: true });
731
+ seedLiveMcpFromExample(ampPath, ampExample, 'amp.mcpServers', stripOnCreate, '.amp/settings.json');
732
+
733
+ const namesToAdd = [];
734
+ if (selected.kind === 'github' || selected.kind === 'gitlab') namesToAdd.push(selected.kind);
735
+ if (browser) namesToAdd.push('browser');
736
+ for (const name of namesToAdd) {
737
+ const server = MCP_SERVER_CONFIGS[name];
738
+ writeMcpServerEntry(cursorPath, 'mcpServers', name, server, '.mcp.json');
739
+ writeMcpServerEntry(ampPath, 'amp.mcpServers', name, server, '.amp/settings.json');
740
+ }
741
+
742
+ if (!existsSync(cursorPath)) {
743
+ log.warn('.mcp.json missing — copy from .agents/mcp.json.example then re-run mcp-setup');
744
+ }
745
+ if (!existsSync(ampPath)) {
746
+ log.warn('.amp/settings.json missing — copy from .agents/amp.settings.json.example then re-run mcp-setup');
747
+ }
748
+
749
+ log.info('Restart Cursor / Amp after saving tokens');
750
+ }
751
+
284
752
  function writeJsonFile(filePath, value) {
285
753
  mkdirSync(dirname(filePath), { recursive: true });
286
754
  writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
@@ -308,12 +776,9 @@ function ensureMemoryMcpEntry(projectDir) {
308
776
  log.ok('.cursor/memory.json created');
309
777
  }
310
778
 
311
- const examplePath = join(projectDir, '.agents', 'mcp.json.example');
779
+ const examplePath = join(projectDir, MCP_EXAMPLE_REL);
312
780
  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
- }
781
+ seedLiveMcpFromExample(cursorPath, examplePath, 'mcpServers', OPTIONAL_MCP_SEED_STRIP, '.mcp.json');
317
782
  if (existsSync(cursorPath)) {
318
783
  try {
319
784
  const cfg = upsertMemoryServer(JSON.parse(readFileSync(cursorPath, 'utf-8')), 'mcpServers', '.mcp.json');
@@ -324,12 +789,9 @@ function ensureMemoryMcpEntry(projectDir) {
324
789
  }
325
790
 
326
791
  mkdirSync(join(projectDir, '.amp'), { recursive: true });
327
- const ampExample = join(projectDir, '.agents', 'amp.settings.json.example');
792
+ const ampExample = join(projectDir, AMP_EXAMPLE_REL);
328
793
  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
- }
794
+ seedLiveMcpFromExample(ampPath, ampExample, 'amp.mcpServers', OPTIONAL_MCP_SEED_STRIP, '.amp/settings.json');
333
795
  if (existsSync(ampPath)) {
334
796
  try {
335
797
  const cfg = upsertMemoryServer(JSON.parse(readFileSync(ampPath, 'utf-8')), 'amp.mcpServers', '.amp/settings.json');
@@ -378,8 +840,126 @@ function sectionOr(sections, title, fallback = '') {
378
840
  return value && value.trim() ? value.trim() : fallback;
379
841
  }
380
842
 
843
+ function parseRuntimeBulletFields(body) {
844
+ const text = String(body || '');
845
+ const runtimeMatch = text.match(/(?:^|\n)\s*[-*]?\s*runtime:\s*(\S+)/i);
846
+ const agentMatch = text.match(/(?:^|\n)\s*[-*]?\s*agent_id:\s*(\S+)/i);
847
+ return {
848
+ runtime: runtimeMatch ? runtimeMatch[1].trim() : '',
849
+ agentId: agentMatch ? agentMatch[1].trim() : '',
850
+ };
851
+ }
852
+
853
+ function normalizeRuntimeToken(value) {
854
+ const v = String(value || '').trim().toLowerCase();
855
+ return VALID_RUNTIMES.has(v) ? v : '';
856
+ }
857
+
858
+ function resolveRuntime(opts, env, existingFields) {
859
+ const flag = opts && opts.runtime != null ? String(opts.runtime).trim() : '';
860
+ if (flag) {
861
+ const normalized = normalizeRuntimeToken(flag);
862
+ if (!normalized) return { error: 'invalid --runtime (use local or cloud)' };
863
+ return { value: normalized };
864
+ }
865
+ const fromEnv = normalizeRuntimeToken(env && env.AOK_RUNTIME);
866
+ if (fromEnv) return { value: fromEnv };
867
+ for (const key of CLOUD_ENV_MARKERS) {
868
+ const raw = env && env[key];
869
+ if (raw != null && String(raw).trim() !== '') return { value: 'cloud' };
870
+ }
871
+ const existing = normalizeRuntimeToken(existingFields && existingFields.runtime);
872
+ if (existing) return { value: existing };
873
+ return { value: 'local' };
874
+ }
875
+
876
+ function resolveAgentId(opts, env, existingFields) {
877
+ const flag = opts && opts.agentId != null ? String(opts.agentId).trim() : '';
878
+ if (flag) return flag;
879
+ const fromEnv = env && env.AOK_AGENT_ID != null ? String(env.AOK_AGENT_ID).trim() : '';
880
+ if (fromEnv) return fromEnv;
881
+ const existing = existingFields && existingFields.agentId != null ? String(existingFields.agentId).trim() : '';
882
+ if (existing) return existing;
883
+ return 'none';
884
+ }
885
+
886
+ function applyRuntimeToFields(fields, opts, env) {
887
+ const resolved = resolveRuntime(opts, env, fields);
888
+ if (resolved.error) {
889
+ log.err(resolved.error);
890
+ return false;
891
+ }
892
+ fields.runtime = resolved.value;
893
+ fields.agentId = resolveAgentId(opts, env, fields);
894
+ return true;
895
+ }
896
+
897
+ function gitTry(projectDir, command) {
898
+ try {
899
+ const stdout = execSync(command, {
900
+ cwd: projectDir,
901
+ stdio: ['ignore', 'pipe', 'pipe'],
902
+ encoding: 'utf-8',
903
+ });
904
+ return { ok: true, stdout: String(stdout || '') };
905
+ } catch (e) {
906
+ return {
907
+ ok: false,
908
+ stdout: String((e && e.stdout) || ''),
909
+ stderr: String((e && e.stderr) || (e && e.message) || ''),
910
+ };
911
+ }
912
+ }
913
+
914
+ function porcelainChangePaths(stdout) {
915
+ return String(stdout || '')
916
+ .split('\n')
917
+ .map((line) => line.replace(/\r$/, ''))
918
+ .filter((line) => line.trim())
919
+ .map((line) => line.slice(3).trim());
920
+ }
921
+
922
+ function collectCloudCheckFindings(projectDir, name) {
923
+ const findings = [];
924
+ const rel = `openspec/changes/${name}/`;
925
+ const status = gitTry(projectDir, `git status --porcelain -- ${rel}`);
926
+ if (!status.ok) {
927
+ const detail = status.stderr.trim().split('\n')[0];
928
+ findings.push(detail ? `git status failed for ${rel}: ${detail}` : `git status failed for ${rel}`);
929
+ findings.push(CLOUD_PUSH_HINT);
930
+ } else {
931
+ findings.push(...porcelainChangePaths(status.stdout));
932
+ }
933
+
934
+ const upstream = gitTry(projectDir, "git rev-parse --abbrev-ref '@{upstream}'");
935
+ if (!upstream.ok) {
936
+ findings.push(CLOUD_PUSH_HINT);
937
+ } else {
938
+ const count = gitTry(projectDir, "git rev-list --count '@{upstream}..HEAD'");
939
+ if (!count.ok) {
940
+ findings.push(CLOUD_PUSH_HINT);
941
+ } else {
942
+ const n = Number.parseInt(String(count.stdout).trim(), 10);
943
+ if (!Number.isFinite(n) || n > 0) findings.push(CLOUD_PUSH_HINT);
944
+ }
945
+ }
946
+
947
+ return [...new Set(findings)];
948
+ }
949
+
950
+ function printCloudPersistNextSteps(name) {
951
+ console.error('Cloud session exit is incomplete until artifacts are on the remote:');
952
+ console.error(` git add openspec/changes/${name}/`);
953
+ console.error(' git commit');
954
+ console.error(' git push');
955
+ console.error(` npx agent-orchestrator-kit handoff ${name} --cloud-check`);
956
+ console.error('Require --cloud-check exit 0 before closing.');
957
+ }
958
+
381
959
  function buildHandoffMarkdown(fields) {
382
960
  const prompt = fields.prompt ? `\n\n## Prompt\n\n\`\`\`text\n${fields.prompt}\n\`\`\`` : '';
961
+ const runtime = fields.runtime || 'local';
962
+ const agentId = fields.agentId || 'none';
383
963
  return `# Session Handoff
384
964
 
385
965
  ## Closed role
@@ -410,12 +990,17 @@ ${fields.attach}
410
990
  ${fields.spawn}
411
991
 
412
992
  ## Constraints
413
- ${fields.constraints}${prompt}
993
+ ${fields.constraints}
994
+
995
+ ## Runtime
996
+ - runtime: ${runtime}
997
+ - agent_id: ${agentId}${prompt}
414
998
  `;
415
999
  }
416
1000
 
417
1001
  function fieldsFromSections(changeName, sections, extra = {}) {
418
1002
  const nextCommand = extra.nextCommand || firstLineCommand(sectionOr(sections, 'Next command'));
1003
+ const runtimeParsed = parseRuntimeBulletFields(sectionOr(sections, 'Runtime', ''));
419
1004
  return {
420
1005
  changeName,
421
1006
  closedRole: extra.closedRole || sectionOr(sections, 'Closed role', extra.closedRole || ''),
@@ -428,6 +1013,8 @@ function fieldsFromSections(changeName, sections, extra = {}) {
428
1013
  attach: extra.attach || sectionOr(sections, 'Attach', `- \`openspec/changes/${changeName}/\``),
429
1014
  spawn: extra.spawn || sectionOr(sections, 'Subagents to spawn', ''),
430
1015
  constraints: extra.constraints || sectionOr(sections, 'Constraints', ''),
1016
+ runtime: extra.runtime || runtimeParsed.runtime,
1017
+ agentId: extra.agentId || runtimeParsed.agentId,
431
1018
  status: extra.status || '',
432
1019
  tasks: extra.tasks || '',
433
1020
  review: extra.review || '',
@@ -603,6 +1190,58 @@ function parseDecisionItems(text) {
603
1190
  .filter((line) => line && !/^none$/i.test(line));
604
1191
  }
605
1192
 
1193
+ function decisionTopic(text) {
1194
+ const value = String(text || '');
1195
+ const topicMatch = value.match(/^([^:]+):/);
1196
+ return (topicMatch ? topicMatch[1] : value).trim().slice(0, 80) || value.slice(0, 80);
1197
+ }
1198
+
1199
+ function normalizeDecisionText(text) {
1200
+ return String(text || '')
1201
+ .trim()
1202
+ .replace(/\s+/g, ' ');
1203
+ }
1204
+
1205
+ function localIsoDate(now = new Date()) {
1206
+ const y = now.getFullYear();
1207
+ const m = String(now.getMonth() + 1).padStart(2, '0');
1208
+ const d = String(now.getDate()).padStart(2, '0');
1209
+ return `${y}-${m}-${d}`;
1210
+ }
1211
+
1212
+ function decisionsFilePath(projectDir, changeName) {
1213
+ return join(projectDir, 'openspec', 'changes', changeName, 'decisions.md');
1214
+ }
1215
+
1216
+ function parseDecisionsFileEntries(content) {
1217
+ const entries = [];
1218
+ for (const line of String(content || '').split(/\r?\n/)) {
1219
+ const match = line.match(/^- (\d{4}-\d{2}-\d{2}) (.+)$/);
1220
+ if (match) entries.push({ date: match[1], text: match[2] });
1221
+ }
1222
+ return entries;
1223
+ }
1224
+
1225
+ function appendDecisionsFromHandoff(projectDir, changeName, decisionsText) {
1226
+ const items = parseDecisionItems(decisionsText);
1227
+ if (!items.length) return;
1228
+ const filePath = decisionsFilePath(projectDir, changeName);
1229
+ const header = `# Decisions — ${changeName}\n\n<!-- append-only; пише npx agent-orchestrator-kit handoff <name> з handoff.md ## Decisions -->\n\n`;
1230
+ let body = existsSync(filePath) ? readFileSync(filePath, 'utf-8') : header;
1231
+ const seen = new Set(parseDecisionsFileEntries(body).map((entry) => normalizeDecisionText(entry.text)));
1232
+ const date = localIsoDate();
1233
+ const additions = [];
1234
+ for (const item of items) {
1235
+ const norm = normalizeDecisionText(item);
1236
+ if (seen.has(norm)) continue;
1237
+ seen.add(norm);
1238
+ additions.push(`- ${date} ${item}`);
1239
+ }
1240
+ if (!additions.length) return;
1241
+ if (!body.endsWith('\n')) body += '\n';
1242
+ writeFileSync(filePath, `${body}${additions.join('\n')}\n`);
1243
+ }
1244
+
606
1245
  function persistMemoryFromHandoff(projectDir, fields) {
607
1246
  const filePath = resolve(projectDir, MEMORY_FILE_REL);
608
1247
  const items = loadMemoryItems(filePath);
@@ -625,10 +1264,11 @@ function persistMemoryFromHandoff(projectDir, fields) {
625
1264
  ].filter(Boolean);
626
1265
  if (handoffObs.length) upsertMemoryEntity(items, `Handoff:${name}`, 'Handoff', handoffObs);
627
1266
 
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}`]);
1267
+ const decisionsPath = decisionsFilePath(projectDir, name);
1268
+ if (existsSync(decisionsPath)) {
1269
+ for (const entry of parseDecisionsFileEntries(readFileSync(decisionsPath, 'utf-8'))) {
1270
+ upsertMemoryEntity(items, `Decision:${decisionTopic(entry.text)}`, 'Decision', [`chosen: ${entry.text}`]);
1271
+ }
632
1272
  }
633
1273
 
634
1274
  saveMemoryItems(filePath, items);
@@ -1016,6 +1656,19 @@ function gitDiffTouchesGlob(projectDir, base, srcGlob) {
1016
1656
  }
1017
1657
  }
1018
1658
 
1659
+ function gitStagedTouchesGlob(projectDir, srcGlob) {
1660
+ try {
1661
+ const out = execSync(`git diff --cached --name-only -- "${srcGlob}"`, {
1662
+ cwd: projectDir,
1663
+ stdio: ['ignore', 'pipe', 'ignore'],
1664
+ encoding: 'utf-8',
1665
+ });
1666
+ return out.trim().length > 0;
1667
+ } catch {
1668
+ return null;
1669
+ }
1670
+ }
1671
+
1019
1672
  function installOpenspecConfigExample(projectDir, profile, vars, force) {
1020
1673
  const src = resolveTemplate('openspec-config.yaml.example', profile);
1021
1674
  if (!existsSync(src)) return;
@@ -1197,18 +1850,21 @@ function printNextSteps(profile, projectDir, ci = 'github', specVerify = false)
1197
1850
  if (profile === 'vue3') {
1198
1851
  lines.push(` 3. Install Vue/JS stack skills:`);
1199
1852
  lines.push(` ${pc.cyan('npx frontend-agent-skills install --agent all --yes')}`);
1200
- lines.push(` 4. MCP: copy .mcp.json (Cursor) / .amp/settings.json (Amp) from *.example files`);
1853
+ lines.push(` 4. MCP: ${pc.cyan('npx agent-orchestrator-kit mcp-setup')} (GitHub/GitLab from origin + browser)`);
1201
1854
  lines.push(` 5. Optional Figma: ${pc.cyan('npx agent-orchestrator-kit figma-setup')} then paste token into ${pc.cyan('.agents/figma.local.env')} (never in chat)`);
1202
- lines.push(` 6. Start your first change:`);
1855
+ lines.push(` 6. Optional pre-commit gate: ${pc.cyan('npx agent-orchestrator-kit hooks-setup')} or ${pc.cyan('init --hooks')}`);
1856
+ lines.push(` 7. Start your first change:`);
1203
1857
  } else if (profile === 'mvp') {
1204
1858
  lines.push(` 3. For quick demos use ${pc.cyan('/opsx:quick <name>')} (propose + apply, no review gate)`);
1205
- lines.push(` 4. MCP: copy .mcp.json (Cursor) / .amp/settings.json (Amp) from *.example files`);
1859
+ lines.push(` 4. MCP: ${pc.cyan('npx agent-orchestrator-kit mcp-setup')} (GitHub/GitLab from origin + browser)`);
1206
1860
  lines.push(` 5. Optional Figma: ${pc.cyan('npx agent-orchestrator-kit figma-setup')} then paste token into ${pc.cyan('.agents/figma.local.env')} (never in chat)`);
1207
- lines.push(` 6. Start exploring:`);
1861
+ lines.push(` 6. Optional pre-commit gate: ${pc.cyan('npx agent-orchestrator-kit hooks-setup')} or ${pc.cyan('init --hooks')}`);
1862
+ lines.push(` 7. Start exploring:`);
1208
1863
  } else {
1209
- lines.push(` 3. MCP: copy .mcp.json (Cursor) / .amp/settings.json (Amp) from *.example files`);
1864
+ lines.push(` 3. MCP: ${pc.cyan('npx agent-orchestrator-kit mcp-setup')} (GitHub/GitLab from origin + browser)`);
1210
1865
  lines.push(` 4. Optional Figma: ${pc.cyan('npx agent-orchestrator-kit figma-setup')} then paste token into ${pc.cyan('.agents/figma.local.env')} (never in chat)`);
1211
- lines.push(` 5. Start your first change:`);
1866
+ lines.push(` 5. Optional pre-commit gate: ${pc.cyan('npx agent-orchestrator-kit hooks-setup')} or ${pc.cyan('init --hooks')}`);
1867
+ lines.push(` 6. Start your first change:`);
1212
1868
  }
1213
1869
 
1214
1870
  const startCmd = profile === 'mvp' ? '/opsx:quick' : '/opsx:explore';
@@ -1253,6 +1909,92 @@ function listAmpSubagentWrappers(projectDir) {
1253
1909
  return readdirSync(skillsDir).filter((entry) => entry.startsWith(AMP_SUBAGENT_SKILL_PREFIX));
1254
1910
  }
1255
1911
 
1912
+ function parseAmpSubagentSource(content) {
1913
+ const parsed = String(content || '').match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
1914
+ const name = parsed?.[1].match(/^name:\s*(.+)$/m)?.[1]?.trim();
1915
+ const description = parsed?.[1].match(/^description:\s*(.+)$/m)?.[1]?.trim();
1916
+ if (!parsed || !name || !description) return null;
1917
+ return { parsed, name, description };
1918
+ }
1919
+
1920
+ function buildAmpSubagentSkillContent(file, parsed) {
1921
+ const name = parsed[1].match(/^name:\s*(.+)$/m)?.[1]?.trim();
1922
+ const description = parsed[1].match(/^description:\s*(.+)$/m)?.[1]?.trim();
1923
+ return [
1924
+ '---',
1925
+ `name: ${AMP_SUBAGENT_SKILL_PREFIX}${name}`,
1926
+ `description: ${description}`,
1927
+ '---',
1928
+ '',
1929
+ `<!-- AUTO-GENERATED from .agents/subagents/${file} — edit the source file, then run: npx agent-orchestrator-kit sync -->`,
1930
+ '',
1931
+ AMP_SPAWN_PREAMBLE,
1932
+ '',
1933
+ parsed[2].trim(),
1934
+ '',
1935
+ ].join('\n');
1936
+ }
1937
+
1938
+ function skillHealthState(projectDir, name) {
1939
+ const source = join(projectDir, '.agents', 'skills', name, 'SKILL.md');
1940
+ if (!existsSync(source)) return 'missing';
1941
+ const sourceBytes = readFileSync(source);
1942
+ for (const ide of ['.cursor', '.claude']) {
1943
+ const copy = join(projectDir, ide, 'skills', name, 'SKILL.md');
1944
+ if (!existsSync(copy)) return 'stale';
1945
+ if (Buffer.compare(sourceBytes, readFileSync(copy)) !== 0) return 'stale';
1946
+ }
1947
+ return 'ok';
1948
+ }
1949
+
1950
+ function printSkillHealth(projectDir) {
1951
+ const inventory = readSkillsInventory(projectDir);
1952
+ const names = [...inventory.kit, ...inventory.stack];
1953
+ console.log(pc.bold('\nSkill health'));
1954
+ for (const name of names) {
1955
+ const state = skillHealthState(projectDir, name);
1956
+ const isStack = inventory.stack.includes(name);
1957
+ let line = ` ${name.padEnd(28)} ${state}`;
1958
+ if (state === 'missing' && isStack && inventory.external) {
1959
+ line += ` npx ${inventory.external} install --agent all --yes`;
1960
+ }
1961
+ console.log(line);
1962
+ }
1963
+
1964
+ const subagentsDir = join(projectDir, '.agents', 'subagents');
1965
+ const issues = [];
1966
+ let ok = 0;
1967
+ let total = 0;
1968
+ if (existsSync(subagentsDir)) {
1969
+ for (const file of readdirSync(subagentsDir).filter((f) => f.endsWith('.md'))) {
1970
+ const parsedWrap = parseAmpSubagentSource(readFileSync(join(subagentsDir, file), 'utf-8'));
1971
+ if (!parsedWrap) continue;
1972
+ total += 1;
1973
+ const expected = Buffer.from(buildAmpSubagentSkillContent(file, parsedWrap.parsed));
1974
+ const wrapperPath = join(
1975
+ projectDir,
1976
+ '.agents',
1977
+ 'skills',
1978
+ `${AMP_SUBAGENT_SKILL_PREFIX}${parsedWrap.name}`,
1979
+ 'SKILL.md',
1980
+ );
1981
+ if (!existsSync(wrapperPath) || Buffer.compare(expected, readFileSync(wrapperPath)) !== 0) {
1982
+ issues.push(`${AMP_SUBAGENT_SKILL_PREFIX}${parsedWrap.name}`);
1983
+ } else {
1984
+ ok += 1;
1985
+ }
1986
+ }
1987
+ }
1988
+ if (!total) {
1989
+ console.log(' subagent wrappers: ok (0/0)');
1990
+ } else if (!issues.length) {
1991
+ console.log(` subagent wrappers: ok (${ok}/${total})`);
1992
+ } else {
1993
+ console.log(` subagent wrappers: ${issues.join(', ')} stale/missing (${ok}/${total} ok)`);
1994
+ }
1995
+ console.log('');
1996
+ }
1997
+
1256
1998
  function generateAmpSubagentSkills(projectDir) {
1257
1999
  const subagentsDir = join(projectDir, '.agents', 'subagents');
1258
2000
  const skillsDir = join(projectDir, '.agents', 'skills');
@@ -1261,31 +2003,16 @@ function generateAmpSubagentSkills(projectDir) {
1261
2003
  if (existsSync(subagentsDir)) {
1262
2004
  for (const file of readdirSync(subagentsDir).filter((f) => f.endsWith('.md'))) {
1263
2005
  const content = readFileSync(join(subagentsDir, file), 'utf-8');
1264
- const parsed = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
1265
- const name = parsed?.[1].match(/^name:\s*(.+)$/m)?.[1]?.trim();
1266
- const description = parsed?.[1].match(/^description:\s*(.+)$/m)?.[1]?.trim();
1267
- if (!name || !description) {
2006
+ const parsedWrap = parseAmpSubagentSource(content);
2007
+ if (!parsedWrap) {
1268
2008
  log.warn(`skip Amp wrapper (missing name/description frontmatter): .agents/subagents/${file}`);
1269
2009
  continue;
1270
2010
  }
1271
2011
 
1272
- const skillName = `${AMP_SUBAGENT_SKILL_PREFIX}${name}`;
2012
+ const skillName = `${AMP_SUBAGENT_SKILL_PREFIX}${parsedWrap.name}`;
1273
2013
  expected.add(skillName);
1274
2014
  mkdirSync(join(skillsDir, skillName), { recursive: true });
1275
- const skill = [
1276
- '---',
1277
- `name: ${skillName}`,
1278
- `description: ${description}`,
1279
- '---',
1280
- '',
1281
- `<!-- AUTO-GENERATED from .agents/subagents/${file} — edit the source file, then run: npx agent-orchestrator-kit sync -->`,
1282
- '',
1283
- AMP_SPAWN_PREAMBLE,
1284
- '',
1285
- parsed[2].trim(),
1286
- '',
1287
- ].join('\n');
1288
- writeFileSync(join(skillsDir, skillName, 'SKILL.md'), skill);
2015
+ writeFileSync(join(skillsDir, skillName, 'SKILL.md'), buildAmpSubagentSkillContent(file, parsedWrap.parsed));
1289
2016
  log.ok(`.agents/skills/${skillName}/SKILL.md (Amp wrapper)`);
1290
2017
  }
1291
2018
  }
@@ -1301,11 +2028,10 @@ function generateAmpSubagentSkills(projectDir) {
1301
2028
  function syncAmp(projectDir) {
1302
2029
  log.info('Amp Code reads .agents/ natively — subagents exposed via skill wrappers');
1303
2030
  mkdirSync(join(projectDir, '.amp'), { recursive: true });
1304
- const ampExample = join(projectDir, '.agents', 'amp.settings.json.example');
2031
+ const ampExample = join(projectDir, AMP_EXAMPLE_REL);
1305
2032
  const ampDest = join(projectDir, '.amp', 'settings.json');
1306
- if (existsSync(ampExample) && !existsSync(ampDest)) {
1307
- copyFileSync(ampExample, ampDest);
1308
- log.ok('.amp/settings.json created from example');
2033
+ if (seedLiveMcpFromExample(ampDest, ampExample, 'amp.mcpServers', OPTIONAL_MCP_SEED_STRIP, '.amp/settings.json')) {
2034
+ // seeded
1309
2035
  } else if (existsSync(ampDest)) {
1310
2036
  log.ok('.amp/settings.json already present');
1311
2037
  } else {
@@ -1327,6 +2053,7 @@ program
1327
2053
  .option('--force', 'Overwrite existing files', false)
1328
2054
  .option('--ci <provider>', 'CI provider: gitlab | github | none', 'github')
1329
2055
  .option('--spec-verify', 'Install AI Spec Verifier blocking gate (GitLab or GitHub)', false)
2056
+ .option('--hooks', 'Opt-in: install pre-commit gate-check hook (husky-first)', false)
1330
2057
  .action((opts) => {
1331
2058
  const projectDir = process.cwd();
1332
2059
  const projectName = opts.name || basename(projectDir);
@@ -1360,6 +2087,7 @@ program
1360
2087
  try {
1361
2088
  execSync(`chmod +x ${join(projectDir, 'scripts', 'sync-local-agent-skills.sh')}`);
1362
2089
  } catch {}
2090
+ chmodX(join(projectDir, HOOK_SCRIPT_REL));
1363
2091
 
1364
2092
  log.title('Installing CI workflow');
1365
2093
  installCi(projectDir, templateDir, ci, opts.force);
@@ -1415,6 +2143,12 @@ program
1415
2143
  refreshMemoryManagedFiles(projectDir);
1416
2144
  ensureMemoryMcpEntry(projectDir);
1417
2145
 
2146
+ if (opts.hooks) {
2147
+ log.title('Installing pre-commit gate');
2148
+ const hookResult = runHooksSetup(projectDir);
2149
+ if (!hookResult.ok) process.exitCode = 1;
2150
+ }
2151
+
1418
2152
  log.title('Done');
1419
2153
  log.ok(`agent-orchestrator-kit v${KIT_VERSION} installed`);
1420
2154
  printNextSteps(profile, projectDir, ci, specVerify);
@@ -1466,6 +2200,8 @@ program
1466
2200
 
1467
2201
  log.title('Refreshing Figma setup templates');
1468
2202
  refreshFigmaManagedFiles(projectDir);
2203
+ log.title('Refreshing MCP launchers and hook script');
2204
+ refreshOptionalMcpManagedFiles(projectDir);
1469
2205
  log.title('Configuring Memory MCP');
1470
2206
  refreshMemoryManagedFiles(projectDir);
1471
2207
  ensureMemoryMcpEntry(projectDir);
@@ -1473,7 +2209,9 @@ program
1473
2209
 
1474
2210
  log.ok(`Updated to v${KIT_VERSION}`);
1475
2211
  log.info('Run ./scripts/sync-local-agent-skills.sh to sync to local IDE');
2212
+ log.info('Optional MCP: npx agent-orchestrator-kit mcp-setup');
1476
2213
  log.info('Optional Figma: npx agent-orchestrator-kit figma-setup');
2214
+ log.info('Optional pre-commit gate: npx agent-orchestrator-kit hooks-setup');
1477
2215
  });
1478
2216
 
1479
2217
  program
@@ -1503,13 +2241,6 @@ program
1503
2241
  }
1504
2242
  copyDir(join(projectDir, '.agents', 'rules'), join(projectDir, '.cursor', 'rules'), { overwrite: true, delete: true });
1505
2243
  copyDir(join(projectDir, '.agents', 'subagents'), join(projectDir, '.cursor', 'agents'), { overwrite: true, delete: true });
1506
-
1507
- const mcpExample = join(projectDir, '.agents', 'mcp.json.example');
1508
- const mcpDest = join(projectDir, '.mcp.json');
1509
- if (existsSync(mcpExample) && !existsSync(mcpDest)) {
1510
- copyFileSync(mcpExample, mcpDest);
1511
- log.ok('.mcp.json created from example');
1512
- }
1513
2244
  }
1514
2245
 
1515
2246
  if (syncClaude) {
@@ -1544,7 +2275,7 @@ program
1544
2275
 
1545
2276
  program
1546
2277
  .command('status')
1547
- .description('Show status of active OpenSpec changes (tasks progress, review verdict, archive readiness)')
2278
+ .description('Show status of active OpenSpec changes (tasks progress, review verdict, archive readiness, MCP and skill health)')
1548
2279
  .action(() => {
1549
2280
  const projectDir = process.cwd();
1550
2281
  log.title('agent-orchestrator status');
@@ -1552,25 +2283,27 @@ program
1552
2283
  const changes = listActiveChanges(projectDir);
1553
2284
  if (changes.length === 0) {
1554
2285
  log.info('No active changes');
1555
- return;
2286
+ } else {
2287
+ for (const name of changes) {
2288
+ const changeDir = join(projectDir, 'openspec', 'changes', name);
2289
+ const progress = parseTasksProgress(changeDir);
2290
+ const verdict = parseReviewVerdict(changeDir);
2291
+ const hasBrief = parseDesignBrief(changeDir);
2292
+ const progressStr = progress ? `${progress.done}/${progress.total} tasks` : 'no tasks.md';
2293
+ const verdictStr = verdict || 'none';
2294
+ const readyToArchive = Boolean(progress && progress.total > 0 && progress.done === progress.total);
2295
+
2296
+ console.log(`\n${pc.bold(name)}`);
2297
+ console.log(` tasks: ${progressStr}`);
2298
+ console.log(` review: ${verdictStr}`);
2299
+ console.log(` brief: ${hasBrief ? 'yes' : 'no'}`);
2300
+ if (readyToArchive) log.ok('ready to archive');
2301
+ }
2302
+ console.log('');
1556
2303
  }
1557
2304
 
1558
- for (const name of changes) {
1559
- const changeDir = join(projectDir, 'openspec', 'changes', name);
1560
- const progress = parseTasksProgress(changeDir);
1561
- const verdict = parseReviewVerdict(changeDir);
1562
- const hasBrief = parseDesignBrief(changeDir);
1563
- const progressStr = progress ? `${progress.done}/${progress.total} tasks` : 'no tasks.md';
1564
- const verdictStr = verdict || 'none';
1565
- const readyToArchive = Boolean(progress && progress.total > 0 && progress.done === progress.total);
1566
-
1567
- console.log(`\n${pc.bold(name)}`);
1568
- console.log(` tasks: ${progressStr}`);
1569
- console.log(` review: ${verdictStr}`);
1570
- console.log(` brief: ${hasBrief ? 'yes' : 'no'}`);
1571
- if (readyToArchive) log.ok('ready to archive');
1572
- }
1573
- console.log('');
2305
+ printMcpHealth(projectDir);
2306
+ printSkillHealth(projectDir);
1574
2307
  });
1575
2308
 
1576
2309
  program
@@ -1578,6 +2311,7 @@ program
1578
2311
  .description('Deterministically check the review gate before apply/merge (exit non-zero if unmet)')
1579
2312
  .option('--src-glob <glob>', 'source path filter used to detect code changes', 'src/')
1580
2313
  .option('--base <ref>', 'git ref to diff against', 'HEAD~1')
2314
+ .option('--staged', 'check staged files (git diff --cached) instead of --base...HEAD', false)
1581
2315
  .option('--tasks <name>', 'lint task contracts (Files/Do/Done-when) of a change')
1582
2316
  .option('--review <name>', 'run deterministic Tier 1 review checks on a change')
1583
2317
  .option('--json', 'with --review: print a {pass, errors[]} JSON report to stdout', false)
@@ -1631,13 +2365,15 @@ program
1631
2365
  return;
1632
2366
  }
1633
2367
 
1634
- const touchesSrc = gitDiffTouchesGlob(projectDir, opts.base, opts.srcGlob);
2368
+ const touchesSrc = opts.staged
2369
+ ? gitStagedTouchesGlob(projectDir, opts.srcGlob)
2370
+ : gitDiffTouchesGlob(projectDir, opts.base, opts.srcGlob);
1635
2371
  if (touchesSrc === false) {
1636
- log.ok(`no changes under ${opts.srcGlob} — nothing to gate`);
2372
+ log.ok(`no ${opts.staged ? 'staged ' : ''}changes under ${opts.srcGlob} — nothing to gate`);
1637
2373
  return;
1638
2374
  }
1639
2375
  if (touchesSrc === null) {
1640
- log.warn('could not compute git diff — skipping gate-check');
2376
+ log.warn(`could not compute git ${opts.staged ? 'staged ' : ''}diff — skipping gate-check`);
1641
2377
  return;
1642
2378
  }
1643
2379
 
@@ -1798,7 +2534,14 @@ program
1798
2534
  return fail('openspec validate --all --strict failed — rolled back: change restored, main specs reverted to pre-sync state');
1799
2535
  }
1800
2536
 
1801
- // Final handoff: pipeline is complete, no next-session prompt
2537
+ // Final handoff: pipeline is complete, no next-session prompt.
2538
+ // Change dir has already been moved — read prior Runtime from the archive target.
2539
+ const archivedHandoffPath = join(targetDir, 'handoff.md');
2540
+ let priorFields = {};
2541
+ if (existsSync(archivedHandoffPath)) {
2542
+ priorFields = fieldsFromSections(name, parseHandoffMarkdown(readFileSync(archivedHandoffPath, 'utf-8')));
2543
+ }
2544
+ const runtimeResult = resolveRuntime({}, process.env, priorFields);
1802
2545
  const progress = parseTasksProgress(targetDir);
1803
2546
  const fields = {
1804
2547
  changeName: name,
@@ -1812,6 +2555,8 @@ program
1812
2555
  attach: `- \`${targetRel}/\``,
1813
2556
  spawn: 'none',
1814
2557
  constraints: 'Pipeline complete — no next session.',
2558
+ runtime: runtimeResult.value || 'local',
2559
+ agentId: resolveAgentId({}, process.env, priorFields),
1815
2560
  status: 'archived',
1816
2561
  tasks: progress ? `${progress.done}/${progress.total}` : '',
1817
2562
  review: parseReviewVerdict(targetDir) || '',
@@ -1829,6 +2574,33 @@ program
1829
2574
  log.ok(`archived ${name}`);
1830
2575
  });
1831
2576
 
2577
+ program
2578
+ .command('hooks-setup')
2579
+ .description('Opt-in pre-commit gate: husky-first, otherwise core.hooksPath=.githooks (never writes .git/hooks)')
2580
+ .action(() => {
2581
+ const projectDir = process.cwd();
2582
+ log.title('agent-orchestrator hooks-setup');
2583
+ const result = runHooksSetup(projectDir);
2584
+ if (!result.ok) process.exitCode = 1;
2585
+ });
2586
+
2587
+ program
2588
+ .command('mcp-setup')
2589
+ .description('Install optional GitHub/GitLab (from git origin) and browser MCP launchers (never prints tokens)')
2590
+ .option('--vcs <provider>', 'Override VCS detection: github | gitlab')
2591
+ .option('--no-browser', 'Skip browser MCP')
2592
+ .action((opts) => {
2593
+ const projectDir = process.cwd();
2594
+ log.title('agent-orchestrator mcp-setup');
2595
+ const vcs = opts.vcs ? String(opts.vcs).toLowerCase() : '';
2596
+ if (vcs && vcs !== 'github' && vcs !== 'gitlab') {
2597
+ log.err('invalid --vcs (use github or gitlab)');
2598
+ process.exitCode = 1;
2599
+ return;
2600
+ }
2601
+ runMcpSetup(projectDir, { vcs, browser: opts.browser !== false });
2602
+ });
2603
+
1832
2604
  program
1833
2605
  .command('figma-setup')
1834
2606
  .description('Create local Figma token env file (never prints the token)')
@@ -1995,6 +2767,9 @@ program
1995
2767
  .option('--tasks <progress>', 'Task progress n/m')
1996
2768
  .option('--review <verdict>', 'Review verdict')
1997
2769
  .option('--session-count <n>', 'Handoff session_count')
2770
+ .option('--runtime <runtime>', 'Session runtime: local | cloud')
2771
+ .option('--agent-id <id>', 'Cloud agent identifier')
2772
+ .option('--cloud-check', 'Verify change artifacts are committed and pushed', false)
1998
2773
  .action((changeName, opts) => {
1999
2774
  const projectDir = process.cwd();
2000
2775
  const resolved = resolveHandoffChange(projectDir, changeName);
@@ -2036,6 +2811,16 @@ program
2036
2811
  } else {
2037
2812
  log.warn('handoff.md missing — using Memory JSON only');
2038
2813
  }
2814
+ const decisionsPath = decisionsFilePath(projectDir, name);
2815
+ if (existsSync(decisionsPath)) {
2816
+ log.ok(`decisions.md: ${decisionsPath}`);
2817
+ const entries = parseDecisionsFileEntries(readFileSync(decisionsPath, 'utf-8'));
2818
+ for (const entry of entries) {
2819
+ console.log(`- ${entry.date} ${entry.text}`);
2820
+ }
2821
+ } else {
2822
+ console.log('decisions: none');
2823
+ }
2039
2824
  if (memoryItems.length) {
2040
2825
  log.ok(`Memory entities: ${memoryItems.length} (${memoryPath})`);
2041
2826
  } else {
@@ -2044,6 +2829,24 @@ program
2044
2829
  return;
2045
2830
  }
2046
2831
 
2832
+ if (opts.cloudCheck) {
2833
+ const existingCheck = readHandoffFields(projectDir, name);
2834
+ const checkFields = existingCheck.fields || { changeName: name };
2835
+ if (!applyRuntimeToFields(checkFields, opts, process.env)) {
2836
+ process.exitCode = 1;
2837
+ return;
2838
+ }
2839
+ const findings = collectCloudCheckFindings(projectDir, name);
2840
+ if (!findings.length) {
2841
+ log.ok('cloud-check passed');
2842
+ return;
2843
+ }
2844
+ const emit = checkFields.runtime === 'cloud' ? log.err : log.warn;
2845
+ for (const finding of findings) emit(finding);
2846
+ if (checkFields.runtime === 'cloud') process.exitCode = 1;
2847
+ return;
2848
+ }
2849
+
2047
2850
  console.error(pc.bold(pc.white(`\nhandoff persist ${name}`)));
2048
2851
  if (!existsSync(changeDir)) {
2049
2852
  log.err(`change not found: ${name}`);
@@ -2085,14 +2888,22 @@ program
2085
2888
  fields.status = fields.review && /^APPROVE/i.test(fields.review) ? 'spec-approved' : 'in-progress';
2086
2889
  }
2087
2890
 
2891
+ if (!applyRuntimeToFields(fields, opts, process.env)) {
2892
+ process.exitCode = 1;
2893
+ return;
2894
+ }
2895
+
2088
2896
  const prompt = buildNextSessionPrompt(fields, agentLanguage).replace(/^\n+|\n+$/g, '');
2089
2897
  fields.prompt = prompt;
2090
2898
  writeFileSync(existing.filePath, `${buildHandoffMarkdown(fields).trim()}\n`);
2091
2899
  console.error(pc.green(' ✓'), existing.filePath.replace(`${projectDir}/`, ''));
2092
2900
 
2901
+ appendDecisionsFromHandoff(projectDir, name, fields.decisions);
2093
2902
  const memoryPath = persistMemoryFromHandoff(projectDir, fields);
2094
2903
  console.error(pc.green(' ✓'), `Memory JSON upserted: ${memoryPath}`);
2095
2904
 
2905
+ if (fields.runtime === 'cloud') printCloudPersistNextSteps(name);
2906
+
2096
2907
  console.error(pc.dim('Copy the prompt below into the next chat as one fenced block. Do not include this line.'));
2097
2908
  process.stdout.write(`${prompt}\n`);
2098
2909
  });