agent-orchestrator-kit 0.3.0 → 0.5.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.
- package/CHANGELOG.md +20 -0
- package/README.md +195 -7
- package/bin/agent-orchestrator.js +1212 -89
- package/package.json +2 -2
- package/profiles/generic/orchestrator.yaml +16 -0
- package/profiles/mvp/orchestrator.yaml +16 -0
- package/profiles/node/orchestrator.yaml +19 -1
- package/profiles/vue3/orchestrator.yaml +18 -1
- package/templates/.agents/amp.settings.json.example +12 -0
- package/templates/.agents/github.local.env.example +10 -0
- package/templates/.agents/gitlab.local.env.example +12 -0
- package/templates/.agents/mcp.json.example +12 -0
- package/templates/.agents/rules/memory-mcp-autosetup.mdc +1 -1
- package/templates/.agents/rules/session-handoff.mdc +7 -4
- package/templates/.agents/skills/agent-orchestration/SKILL.md +7 -3
- package/templates/.agents/subagents/session-handoff.md +7 -5
- package/templates/.cursor/memory.json +11 -0
- package/templates/orchestrator.yaml +16 -0
- package/templates/scripts/browser-mcp-launcher.cjs +21 -0
- package/templates/scripts/github-mcp-launcher.cjs +66 -0
- package/templates/scripts/gitlab-mcp-launcher.cjs +70 -0
- package/templates/scripts/pre-commit-gate-check.sh +4 -0
|
@@ -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
|
-
|
|
16
|
-
'
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
...
|
|
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
|
-
|
|
80
|
-
|
|
100
|
+
MCP_EXAMPLE_REL,
|
|
101
|
+
AMP_EXAMPLE_REL,
|
|
81
102
|
];
|
|
82
103
|
const MEMORY_MANAGED_PATHS = [
|
|
83
104
|
MEMORY_LAUNCHER_REL,
|
|
84
|
-
|
|
85
|
-
|
|
105
|
+
MCP_EXAMPLE_REL,
|
|
106
|
+
AMP_EXAMPLE_REL,
|
|
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,
|
|
86
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,
|
|
779
|
+
const examplePath = join(projectDir, MCP_EXAMPLE_REL);
|
|
312
780
|
const cursorPath = join(projectDir, '.mcp.json');
|
|
313
|
-
|
|
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,
|
|
792
|
+
const ampExample = join(projectDir, AMP_EXAMPLE_REL);
|
|
328
793
|
const ampPath = join(projectDir, '.amp', 'settings.json');
|
|
329
|
-
|
|
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}
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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);
|
|
@@ -650,6 +1290,215 @@ function readHandoffFields(projectDir, changeName) {
|
|
|
650
1290
|
return { filePath, fields: fieldsFromSections(changeName, sections) };
|
|
651
1291
|
}
|
|
652
1292
|
|
|
1293
|
+
const METRICS_VERSION = 1;
|
|
1294
|
+
const METRICS_SPEND_KEYS = ['inputTokens', 'outputTokens', 'totalTokens', 'costUsd'];
|
|
1295
|
+
|
|
1296
|
+
function metricsFilePath(projectDir, changeName) {
|
|
1297
|
+
return join(projectDir, 'openspec', 'changes', changeName, 'metrics.json');
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function emptySpendTotals() {
|
|
1301
|
+
return { inputTokens: null, outputTokens: null, totalTokens: null, costUsd: null };
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function defaultMetrics(changeName, nowIso) {
|
|
1305
|
+
return {
|
|
1306
|
+
version: METRICS_VERSION,
|
|
1307
|
+
change: changeName,
|
|
1308
|
+
createdAt: nowIso,
|
|
1309
|
+
updatedAt: nowIso,
|
|
1310
|
+
archivedAt: null,
|
|
1311
|
+
spend: emptySpendTotals(),
|
|
1312
|
+
totals: { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 },
|
|
1313
|
+
phases: {},
|
|
1314
|
+
sessions: [],
|
|
1315
|
+
pending: null,
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function loadMetricsFile(filePath, changeName, nowIso) {
|
|
1320
|
+
if (!existsSync(filePath)) return defaultMetrics(changeName, nowIso);
|
|
1321
|
+
let parsed;
|
|
1322
|
+
try {
|
|
1323
|
+
parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
1324
|
+
} catch {
|
|
1325
|
+
return defaultMetrics(changeName, nowIso);
|
|
1326
|
+
}
|
|
1327
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1328
|
+
return defaultMetrics(changeName, nowIso);
|
|
1329
|
+
}
|
|
1330
|
+
const base = defaultMetrics(changeName, parsed.createdAt || nowIso);
|
|
1331
|
+
return {
|
|
1332
|
+
...base,
|
|
1333
|
+
...parsed,
|
|
1334
|
+
version: METRICS_VERSION,
|
|
1335
|
+
change: changeName,
|
|
1336
|
+
spend: { ...base.spend, ...(parsed.spend && typeof parsed.spend === 'object' ? parsed.spend : {}) },
|
|
1337
|
+
totals: { ...base.totals, ...(parsed.totals && typeof parsed.totals === 'object' ? parsed.totals : {}) },
|
|
1338
|
+
phases: parsed.phases && typeof parsed.phases === 'object' && !Array.isArray(parsed.phases) ? parsed.phases : {},
|
|
1339
|
+
sessions: Array.isArray(parsed.sessions) ? parsed.sessions : [],
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
function saveMetricsFile(filePath, metrics) {
|
|
1344
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
1345
|
+
writeFileSync(filePath, `${JSON.stringify(metrics, null, 2)}\n`);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
function numOrNull(value) {
|
|
1349
|
+
if (value == null || value === '') return null;
|
|
1350
|
+
const n = Number(value);
|
|
1351
|
+
return Number.isFinite(n) ? n : null;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
function addNullable(a, b) {
|
|
1355
|
+
if (a == null && b == null) return null;
|
|
1356
|
+
return (a ?? 0) + (b ?? 0);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function phaseForRole(role) {
|
|
1360
|
+
const value = String(role || '').toLowerCase();
|
|
1361
|
+
if (/explor/.test(value)) return 'explore';
|
|
1362
|
+
if (/review/.test(value)) return 'review';
|
|
1363
|
+
if (/implement|apply|code-writer|test-writer/.test(value)) return 'apply';
|
|
1364
|
+
if (/architect|propose/.test(value)) return 'spec';
|
|
1365
|
+
if (/design/.test(value)) return 'design';
|
|
1366
|
+
if (/archiv/.test(value)) return 'archive';
|
|
1367
|
+
return 'other';
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function isoOrNull(value) {
|
|
1371
|
+
if (!value) return null;
|
|
1372
|
+
const ms = Date.parse(String(value));
|
|
1373
|
+
return Number.isFinite(ms) ? new Date(ms).toISOString() : null;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function recomputeMetricsAggregates(metrics) {
|
|
1377
|
+
const phases = {};
|
|
1378
|
+
const totals = { sessions: 0, durationMs: null, leadTimeMs: null, cloudSessions: 0 };
|
|
1379
|
+
const spend = emptySpendTotals();
|
|
1380
|
+
let firstStart = null;
|
|
1381
|
+
let lastEnd = null;
|
|
1382
|
+
for (const session of metrics.sessions) {
|
|
1383
|
+
totals.sessions += 1;
|
|
1384
|
+
if (session.runtime === 'cloud') totals.cloudSessions += 1;
|
|
1385
|
+
totals.durationMs = addNullable(totals.durationMs, numOrNull(session.durationMs));
|
|
1386
|
+
if (session.startedAt && (firstStart == null || session.startedAt < firstStart)) firstStart = session.startedAt;
|
|
1387
|
+
if (session.endedAt && (lastEnd == null || session.endedAt > lastEnd)) lastEnd = session.endedAt;
|
|
1388
|
+
const key = session.phase || 'other';
|
|
1389
|
+
const phase = phases[key] || { sessions: 0, durationMs: null, ...emptySpendTotals(), agents: [], models: [] };
|
|
1390
|
+
phase.sessions += 1;
|
|
1391
|
+
phase.durationMs = addNullable(phase.durationMs, numOrNull(session.durationMs));
|
|
1392
|
+
for (const spendKey of METRICS_SPEND_KEYS) {
|
|
1393
|
+
const value = numOrNull(session[spendKey]);
|
|
1394
|
+
phase[spendKey] = addNullable(phase[spendKey], value);
|
|
1395
|
+
spend[spendKey] = addNullable(spend[spendKey], value);
|
|
1396
|
+
}
|
|
1397
|
+
if (session.role && !phase.agents.includes(session.role)) phase.agents.push(session.role);
|
|
1398
|
+
if (session.model && !phase.models.includes(session.model)) phase.models.push(session.model);
|
|
1399
|
+
phases[key] = phase;
|
|
1400
|
+
}
|
|
1401
|
+
if (firstStart && lastEnd) {
|
|
1402
|
+
totals.leadTimeMs = Math.max(0, Date.parse(lastEnd) - Date.parse(firstStart));
|
|
1403
|
+
}
|
|
1404
|
+
metrics.phases = phases;
|
|
1405
|
+
metrics.totals = totals;
|
|
1406
|
+
metrics.spend = spend;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function metricsRecordSessionStart(projectDir, changeName, role) {
|
|
1410
|
+
const filePath = metricsFilePath(projectDir, changeName);
|
|
1411
|
+
const nowIso = new Date().toISOString();
|
|
1412
|
+
const metrics = loadMetricsFile(filePath, changeName, nowIso);
|
|
1413
|
+
metrics.pending = { startedAt: nowIso, role: role || '' };
|
|
1414
|
+
metrics.updatedAt = nowIso;
|
|
1415
|
+
saveMetricsFile(filePath, metrics);
|
|
1416
|
+
return filePath;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function metricsRecordSessionEnd(projectDir, fields, opts = {}) {
|
|
1420
|
+
const filePath = metricsFilePath(projectDir, fields.changeName);
|
|
1421
|
+
const nowIso = new Date().toISOString();
|
|
1422
|
+
const metrics = loadMetricsFile(filePath, fields.changeName, nowIso);
|
|
1423
|
+
const startedAt = isoOrNull(opts.startedAt) || (metrics.pending && metrics.pending.startedAt) || null;
|
|
1424
|
+
const durationMs = startedAt ? Math.max(0, Date.parse(nowIso) - Date.parse(startedAt)) : null;
|
|
1425
|
+
const inputTokens = numOrNull(opts.inputTokens);
|
|
1426
|
+
const outputTokens = numOrNull(opts.outputTokens);
|
|
1427
|
+
let totalTokens = numOrNull(opts.totalTokens);
|
|
1428
|
+
if (totalTokens == null && (inputTokens != null || outputTokens != null)) {
|
|
1429
|
+
totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
|
|
1430
|
+
}
|
|
1431
|
+
metrics.sessions.push({
|
|
1432
|
+
startedAt,
|
|
1433
|
+
endedAt: nowIso,
|
|
1434
|
+
durationMs,
|
|
1435
|
+
role: fields.closedRole || '',
|
|
1436
|
+
phase: phaseForRole(fields.closedRole),
|
|
1437
|
+
runtime: fields.runtime || 'local',
|
|
1438
|
+
agentId: fields.agentId || 'none',
|
|
1439
|
+
model: opts.model || null,
|
|
1440
|
+
tasks: fields.tasks || null,
|
|
1441
|
+
inputTokens,
|
|
1442
|
+
outputTokens,
|
|
1443
|
+
totalTokens,
|
|
1444
|
+
costUsd: numOrNull(opts.costUsd),
|
|
1445
|
+
});
|
|
1446
|
+
metrics.pending = null;
|
|
1447
|
+
metrics.updatedAt = nowIso;
|
|
1448
|
+
recomputeMetricsAggregates(metrics);
|
|
1449
|
+
saveMetricsFile(filePath, metrics);
|
|
1450
|
+
return filePath;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function metricsFinalizeArchive(targetDir, changeName) {
|
|
1454
|
+
const filePath = join(targetDir, 'metrics.json');
|
|
1455
|
+
if (!existsSync(filePath)) return null;
|
|
1456
|
+
const nowIso = new Date().toISOString();
|
|
1457
|
+
const metrics = loadMetricsFile(filePath, changeName, nowIso);
|
|
1458
|
+
metrics.archivedAt = nowIso;
|
|
1459
|
+
metrics.pending = null;
|
|
1460
|
+
metrics.updatedAt = nowIso;
|
|
1461
|
+
recomputeMetricsAggregates(metrics);
|
|
1462
|
+
saveMetricsFile(filePath, metrics);
|
|
1463
|
+
return filePath;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
function formatMetricsDuration(durationMs) {
|
|
1467
|
+
if (durationMs == null || !Number.isFinite(durationMs)) return '—';
|
|
1468
|
+
const totalSeconds = Math.max(0, Math.round(durationMs / 1000));
|
|
1469
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
1470
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
1471
|
+
const seconds = totalSeconds % 60;
|
|
1472
|
+
if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
|
1473
|
+
if (minutes > 0) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
|
|
1474
|
+
return `${seconds}s`;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function formatMetricsNumber(value) {
|
|
1478
|
+
return value == null ? '—' : String(value);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
function formatMetricsCost(value) {
|
|
1482
|
+
return value == null ? '—' : `$${Number(value).toFixed(2)}`;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
function resolveMetricsFile(projectDir, changeName) {
|
|
1486
|
+
const activePath = metricsFilePath(projectDir, changeName);
|
|
1487
|
+
if (existsSync(activePath)) return { filePath: activePath, archived: false };
|
|
1488
|
+
const archiveDir = join(projectDir, 'openspec', 'changes', 'archive');
|
|
1489
|
+
if (existsSync(archiveDir)) {
|
|
1490
|
+
const folders = readdirSync(archiveDir)
|
|
1491
|
+
.filter((name) => name === changeName || name.endsWith(`-${changeName}`))
|
|
1492
|
+
.sort()
|
|
1493
|
+
.reverse();
|
|
1494
|
+
for (const folder of folders) {
|
|
1495
|
+
const archivedPath = join(archiveDir, folder, 'metrics.json');
|
|
1496
|
+
if (existsSync(archivedPath)) return { filePath: archivedPath, archived: true };
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
return { filePath: activePath, archived: false, missing: true };
|
|
1500
|
+
}
|
|
1501
|
+
|
|
653
1502
|
function parseFigmaUrl(url) {
|
|
654
1503
|
try {
|
|
655
1504
|
const parsed = new URL(url);
|
|
@@ -1016,6 +1865,19 @@ function gitDiffTouchesGlob(projectDir, base, srcGlob) {
|
|
|
1016
1865
|
}
|
|
1017
1866
|
}
|
|
1018
1867
|
|
|
1868
|
+
function gitStagedTouchesGlob(projectDir, srcGlob) {
|
|
1869
|
+
try {
|
|
1870
|
+
const out = execSync(`git diff --cached --name-only -- "${srcGlob}"`, {
|
|
1871
|
+
cwd: projectDir,
|
|
1872
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1873
|
+
encoding: 'utf-8',
|
|
1874
|
+
});
|
|
1875
|
+
return out.trim().length > 0;
|
|
1876
|
+
} catch {
|
|
1877
|
+
return null;
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1019
1881
|
function installOpenspecConfigExample(projectDir, profile, vars, force) {
|
|
1020
1882
|
const src = resolveTemplate('openspec-config.yaml.example', profile);
|
|
1021
1883
|
if (!existsSync(src)) return;
|
|
@@ -1197,18 +2059,21 @@ function printNextSteps(profile, projectDir, ci = 'github', specVerify = false)
|
|
|
1197
2059
|
if (profile === 'vue3') {
|
|
1198
2060
|
lines.push(` 3. Install Vue/JS stack skills:`);
|
|
1199
2061
|
lines.push(` ${pc.cyan('npx frontend-agent-skills install --agent all --yes')}`);
|
|
1200
|
-
lines.push(` 4. MCP:
|
|
2062
|
+
lines.push(` 4. MCP: ${pc.cyan('npx agent-orchestrator-kit mcp-setup')} (GitHub/GitLab from origin + browser)`);
|
|
1201
2063
|
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.
|
|
2064
|
+
lines.push(` 6. Optional pre-commit gate: ${pc.cyan('npx agent-orchestrator-kit hooks-setup')} or ${pc.cyan('init --hooks')}`);
|
|
2065
|
+
lines.push(` 7. Start your first change:`);
|
|
1203
2066
|
} else if (profile === 'mvp') {
|
|
1204
2067
|
lines.push(` 3. For quick demos use ${pc.cyan('/opsx:quick <name>')} (propose + apply, no review gate)`);
|
|
1205
|
-
lines.push(` 4. MCP:
|
|
2068
|
+
lines.push(` 4. MCP: ${pc.cyan('npx agent-orchestrator-kit mcp-setup')} (GitHub/GitLab from origin + browser)`);
|
|
1206
2069
|
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.
|
|
2070
|
+
lines.push(` 6. Optional pre-commit gate: ${pc.cyan('npx agent-orchestrator-kit hooks-setup')} or ${pc.cyan('init --hooks')}`);
|
|
2071
|
+
lines.push(` 7. Start exploring:`);
|
|
1208
2072
|
} else {
|
|
1209
|
-
lines.push(` 3. MCP:
|
|
2073
|
+
lines.push(` 3. MCP: ${pc.cyan('npx agent-orchestrator-kit mcp-setup')} (GitHub/GitLab from origin + browser)`);
|
|
1210
2074
|
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.
|
|
2075
|
+
lines.push(` 5. Optional pre-commit gate: ${pc.cyan('npx agent-orchestrator-kit hooks-setup')} or ${pc.cyan('init --hooks')}`);
|
|
2076
|
+
lines.push(` 6. Start your first change:`);
|
|
1212
2077
|
}
|
|
1213
2078
|
|
|
1214
2079
|
const startCmd = profile === 'mvp' ? '/opsx:quick' : '/opsx:explore';
|
|
@@ -1253,6 +2118,92 @@ function listAmpSubagentWrappers(projectDir) {
|
|
|
1253
2118
|
return readdirSync(skillsDir).filter((entry) => entry.startsWith(AMP_SUBAGENT_SKILL_PREFIX));
|
|
1254
2119
|
}
|
|
1255
2120
|
|
|
2121
|
+
function parseAmpSubagentSource(content) {
|
|
2122
|
+
const parsed = String(content || '').match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
2123
|
+
const name = parsed?.[1].match(/^name:\s*(.+)$/m)?.[1]?.trim();
|
|
2124
|
+
const description = parsed?.[1].match(/^description:\s*(.+)$/m)?.[1]?.trim();
|
|
2125
|
+
if (!parsed || !name || !description) return null;
|
|
2126
|
+
return { parsed, name, description };
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
function buildAmpSubagentSkillContent(file, parsed) {
|
|
2130
|
+
const name = parsed[1].match(/^name:\s*(.+)$/m)?.[1]?.trim();
|
|
2131
|
+
const description = parsed[1].match(/^description:\s*(.+)$/m)?.[1]?.trim();
|
|
2132
|
+
return [
|
|
2133
|
+
'---',
|
|
2134
|
+
`name: ${AMP_SUBAGENT_SKILL_PREFIX}${name}`,
|
|
2135
|
+
`description: ${description}`,
|
|
2136
|
+
'---',
|
|
2137
|
+
'',
|
|
2138
|
+
`<!-- AUTO-GENERATED from .agents/subagents/${file} — edit the source file, then run: npx agent-orchestrator-kit sync -->`,
|
|
2139
|
+
'',
|
|
2140
|
+
AMP_SPAWN_PREAMBLE,
|
|
2141
|
+
'',
|
|
2142
|
+
parsed[2].trim(),
|
|
2143
|
+
'',
|
|
2144
|
+
].join('\n');
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
function skillHealthState(projectDir, name) {
|
|
2148
|
+
const source = join(projectDir, '.agents', 'skills', name, 'SKILL.md');
|
|
2149
|
+
if (!existsSync(source)) return 'missing';
|
|
2150
|
+
const sourceBytes = readFileSync(source);
|
|
2151
|
+
for (const ide of ['.cursor', '.claude']) {
|
|
2152
|
+
const copy = join(projectDir, ide, 'skills', name, 'SKILL.md');
|
|
2153
|
+
if (!existsSync(copy)) return 'stale';
|
|
2154
|
+
if (Buffer.compare(sourceBytes, readFileSync(copy)) !== 0) return 'stale';
|
|
2155
|
+
}
|
|
2156
|
+
return 'ok';
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
function printSkillHealth(projectDir) {
|
|
2160
|
+
const inventory = readSkillsInventory(projectDir);
|
|
2161
|
+
const names = [...inventory.kit, ...inventory.stack];
|
|
2162
|
+
console.log(pc.bold('\nSkill health'));
|
|
2163
|
+
for (const name of names) {
|
|
2164
|
+
const state = skillHealthState(projectDir, name);
|
|
2165
|
+
const isStack = inventory.stack.includes(name);
|
|
2166
|
+
let line = ` ${name.padEnd(28)} ${state}`;
|
|
2167
|
+
if (state === 'missing' && isStack && inventory.external) {
|
|
2168
|
+
line += ` npx ${inventory.external} install --agent all --yes`;
|
|
2169
|
+
}
|
|
2170
|
+
console.log(line);
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
const subagentsDir = join(projectDir, '.agents', 'subagents');
|
|
2174
|
+
const issues = [];
|
|
2175
|
+
let ok = 0;
|
|
2176
|
+
let total = 0;
|
|
2177
|
+
if (existsSync(subagentsDir)) {
|
|
2178
|
+
for (const file of readdirSync(subagentsDir).filter((f) => f.endsWith('.md'))) {
|
|
2179
|
+
const parsedWrap = parseAmpSubagentSource(readFileSync(join(subagentsDir, file), 'utf-8'));
|
|
2180
|
+
if (!parsedWrap) continue;
|
|
2181
|
+
total += 1;
|
|
2182
|
+
const expected = Buffer.from(buildAmpSubagentSkillContent(file, parsedWrap.parsed));
|
|
2183
|
+
const wrapperPath = join(
|
|
2184
|
+
projectDir,
|
|
2185
|
+
'.agents',
|
|
2186
|
+
'skills',
|
|
2187
|
+
`${AMP_SUBAGENT_SKILL_PREFIX}${parsedWrap.name}`,
|
|
2188
|
+
'SKILL.md',
|
|
2189
|
+
);
|
|
2190
|
+
if (!existsSync(wrapperPath) || Buffer.compare(expected, readFileSync(wrapperPath)) !== 0) {
|
|
2191
|
+
issues.push(`${AMP_SUBAGENT_SKILL_PREFIX}${parsedWrap.name}`);
|
|
2192
|
+
} else {
|
|
2193
|
+
ok += 1;
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
if (!total) {
|
|
2198
|
+
console.log(' subagent wrappers: ok (0/0)');
|
|
2199
|
+
} else if (!issues.length) {
|
|
2200
|
+
console.log(` subagent wrappers: ok (${ok}/${total})`);
|
|
2201
|
+
} else {
|
|
2202
|
+
console.log(` subagent wrappers: ${issues.join(', ')} stale/missing (${ok}/${total} ok)`);
|
|
2203
|
+
}
|
|
2204
|
+
console.log('');
|
|
2205
|
+
}
|
|
2206
|
+
|
|
1256
2207
|
function generateAmpSubagentSkills(projectDir) {
|
|
1257
2208
|
const subagentsDir = join(projectDir, '.agents', 'subagents');
|
|
1258
2209
|
const skillsDir = join(projectDir, '.agents', 'skills');
|
|
@@ -1261,31 +2212,16 @@ function generateAmpSubagentSkills(projectDir) {
|
|
|
1261
2212
|
if (existsSync(subagentsDir)) {
|
|
1262
2213
|
for (const file of readdirSync(subagentsDir).filter((f) => f.endsWith('.md'))) {
|
|
1263
2214
|
const content = readFileSync(join(subagentsDir, file), 'utf-8');
|
|
1264
|
-
const
|
|
1265
|
-
|
|
1266
|
-
const description = parsed?.[1].match(/^description:\s*(.+)$/m)?.[1]?.trim();
|
|
1267
|
-
if (!name || !description) {
|
|
2215
|
+
const parsedWrap = parseAmpSubagentSource(content);
|
|
2216
|
+
if (!parsedWrap) {
|
|
1268
2217
|
log.warn(`skip Amp wrapper (missing name/description frontmatter): .agents/subagents/${file}`);
|
|
1269
2218
|
continue;
|
|
1270
2219
|
}
|
|
1271
2220
|
|
|
1272
|
-
const skillName = `${AMP_SUBAGENT_SKILL_PREFIX}${name}`;
|
|
2221
|
+
const skillName = `${AMP_SUBAGENT_SKILL_PREFIX}${parsedWrap.name}`;
|
|
1273
2222
|
expected.add(skillName);
|
|
1274
2223
|
mkdirSync(join(skillsDir, skillName), { recursive: true });
|
|
1275
|
-
|
|
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);
|
|
2224
|
+
writeFileSync(join(skillsDir, skillName, 'SKILL.md'), buildAmpSubagentSkillContent(file, parsedWrap.parsed));
|
|
1289
2225
|
log.ok(`.agents/skills/${skillName}/SKILL.md (Amp wrapper)`);
|
|
1290
2226
|
}
|
|
1291
2227
|
}
|
|
@@ -1301,11 +2237,10 @@ function generateAmpSubagentSkills(projectDir) {
|
|
|
1301
2237
|
function syncAmp(projectDir) {
|
|
1302
2238
|
log.info('Amp Code reads .agents/ natively — subagents exposed via skill wrappers');
|
|
1303
2239
|
mkdirSync(join(projectDir, '.amp'), { recursive: true });
|
|
1304
|
-
const ampExample = join(projectDir,
|
|
2240
|
+
const ampExample = join(projectDir, AMP_EXAMPLE_REL);
|
|
1305
2241
|
const ampDest = join(projectDir, '.amp', 'settings.json');
|
|
1306
|
-
if (
|
|
1307
|
-
|
|
1308
|
-
log.ok('.amp/settings.json created from example');
|
|
2242
|
+
if (seedLiveMcpFromExample(ampDest, ampExample, 'amp.mcpServers', OPTIONAL_MCP_SEED_STRIP, '.amp/settings.json')) {
|
|
2243
|
+
// seeded
|
|
1309
2244
|
} else if (existsSync(ampDest)) {
|
|
1310
2245
|
log.ok('.amp/settings.json already present');
|
|
1311
2246
|
} else {
|
|
@@ -1327,6 +2262,7 @@ program
|
|
|
1327
2262
|
.option('--force', 'Overwrite existing files', false)
|
|
1328
2263
|
.option('--ci <provider>', 'CI provider: gitlab | github | none', 'github')
|
|
1329
2264
|
.option('--spec-verify', 'Install AI Spec Verifier blocking gate (GitLab or GitHub)', false)
|
|
2265
|
+
.option('--hooks', 'Opt-in: install pre-commit gate-check hook (husky-first)', false)
|
|
1330
2266
|
.action((opts) => {
|
|
1331
2267
|
const projectDir = process.cwd();
|
|
1332
2268
|
const projectName = opts.name || basename(projectDir);
|
|
@@ -1360,6 +2296,7 @@ program
|
|
|
1360
2296
|
try {
|
|
1361
2297
|
execSync(`chmod +x ${join(projectDir, 'scripts', 'sync-local-agent-skills.sh')}`);
|
|
1362
2298
|
} catch {}
|
|
2299
|
+
chmodX(join(projectDir, HOOK_SCRIPT_REL));
|
|
1363
2300
|
|
|
1364
2301
|
log.title('Installing CI workflow');
|
|
1365
2302
|
installCi(projectDir, templateDir, ci, opts.force);
|
|
@@ -1415,6 +2352,12 @@ program
|
|
|
1415
2352
|
refreshMemoryManagedFiles(projectDir);
|
|
1416
2353
|
ensureMemoryMcpEntry(projectDir);
|
|
1417
2354
|
|
|
2355
|
+
if (opts.hooks) {
|
|
2356
|
+
log.title('Installing pre-commit gate');
|
|
2357
|
+
const hookResult = runHooksSetup(projectDir);
|
|
2358
|
+
if (!hookResult.ok) process.exitCode = 1;
|
|
2359
|
+
}
|
|
2360
|
+
|
|
1418
2361
|
log.title('Done');
|
|
1419
2362
|
log.ok(`agent-orchestrator-kit v${KIT_VERSION} installed`);
|
|
1420
2363
|
printNextSteps(profile, projectDir, ci, specVerify);
|
|
@@ -1466,6 +2409,8 @@ program
|
|
|
1466
2409
|
|
|
1467
2410
|
log.title('Refreshing Figma setup templates');
|
|
1468
2411
|
refreshFigmaManagedFiles(projectDir);
|
|
2412
|
+
log.title('Refreshing MCP launchers and hook script');
|
|
2413
|
+
refreshOptionalMcpManagedFiles(projectDir);
|
|
1469
2414
|
log.title('Configuring Memory MCP');
|
|
1470
2415
|
refreshMemoryManagedFiles(projectDir);
|
|
1471
2416
|
ensureMemoryMcpEntry(projectDir);
|
|
@@ -1473,7 +2418,9 @@ program
|
|
|
1473
2418
|
|
|
1474
2419
|
log.ok(`Updated to v${KIT_VERSION}`);
|
|
1475
2420
|
log.info('Run ./scripts/sync-local-agent-skills.sh to sync to local IDE');
|
|
2421
|
+
log.info('Optional MCP: npx agent-orchestrator-kit mcp-setup');
|
|
1476
2422
|
log.info('Optional Figma: npx agent-orchestrator-kit figma-setup');
|
|
2423
|
+
log.info('Optional pre-commit gate: npx agent-orchestrator-kit hooks-setup');
|
|
1477
2424
|
});
|
|
1478
2425
|
|
|
1479
2426
|
program
|
|
@@ -1503,13 +2450,6 @@ program
|
|
|
1503
2450
|
}
|
|
1504
2451
|
copyDir(join(projectDir, '.agents', 'rules'), join(projectDir, '.cursor', 'rules'), { overwrite: true, delete: true });
|
|
1505
2452
|
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
2453
|
}
|
|
1514
2454
|
|
|
1515
2455
|
if (syncClaude) {
|
|
@@ -1544,7 +2484,7 @@ program
|
|
|
1544
2484
|
|
|
1545
2485
|
program
|
|
1546
2486
|
.command('status')
|
|
1547
|
-
.description('Show status of active OpenSpec changes (tasks progress, review verdict, archive readiness)')
|
|
2487
|
+
.description('Show status of active OpenSpec changes (tasks progress, review verdict, archive readiness, MCP and skill health)')
|
|
1548
2488
|
.action(() => {
|
|
1549
2489
|
const projectDir = process.cwd();
|
|
1550
2490
|
log.title('agent-orchestrator status');
|
|
@@ -1552,25 +2492,27 @@ program
|
|
|
1552
2492
|
const changes = listActiveChanges(projectDir);
|
|
1553
2493
|
if (changes.length === 0) {
|
|
1554
2494
|
log.info('No active changes');
|
|
1555
|
-
|
|
2495
|
+
} else {
|
|
2496
|
+
for (const name of changes) {
|
|
2497
|
+
const changeDir = join(projectDir, 'openspec', 'changes', name);
|
|
2498
|
+
const progress = parseTasksProgress(changeDir);
|
|
2499
|
+
const verdict = parseReviewVerdict(changeDir);
|
|
2500
|
+
const hasBrief = parseDesignBrief(changeDir);
|
|
2501
|
+
const progressStr = progress ? `${progress.done}/${progress.total} tasks` : 'no tasks.md';
|
|
2502
|
+
const verdictStr = verdict || 'none';
|
|
2503
|
+
const readyToArchive = Boolean(progress && progress.total > 0 && progress.done === progress.total);
|
|
2504
|
+
|
|
2505
|
+
console.log(`\n${pc.bold(name)}`);
|
|
2506
|
+
console.log(` tasks: ${progressStr}`);
|
|
2507
|
+
console.log(` review: ${verdictStr}`);
|
|
2508
|
+
console.log(` brief: ${hasBrief ? 'yes' : 'no'}`);
|
|
2509
|
+
if (readyToArchive) log.ok('ready to archive');
|
|
2510
|
+
}
|
|
2511
|
+
console.log('');
|
|
1556
2512
|
}
|
|
1557
2513
|
|
|
1558
|
-
|
|
1559
|
-
|
|
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('');
|
|
2514
|
+
printMcpHealth(projectDir);
|
|
2515
|
+
printSkillHealth(projectDir);
|
|
1574
2516
|
});
|
|
1575
2517
|
|
|
1576
2518
|
program
|
|
@@ -1578,6 +2520,7 @@ program
|
|
|
1578
2520
|
.description('Deterministically check the review gate before apply/merge (exit non-zero if unmet)')
|
|
1579
2521
|
.option('--src-glob <glob>', 'source path filter used to detect code changes', 'src/')
|
|
1580
2522
|
.option('--base <ref>', 'git ref to diff against', 'HEAD~1')
|
|
2523
|
+
.option('--staged', 'check staged files (git diff --cached) instead of --base...HEAD', false)
|
|
1581
2524
|
.option('--tasks <name>', 'lint task contracts (Files/Do/Done-when) of a change')
|
|
1582
2525
|
.option('--review <name>', 'run deterministic Tier 1 review checks on a change')
|
|
1583
2526
|
.option('--json', 'with --review: print a {pass, errors[]} JSON report to stdout', false)
|
|
@@ -1631,13 +2574,15 @@ program
|
|
|
1631
2574
|
return;
|
|
1632
2575
|
}
|
|
1633
2576
|
|
|
1634
|
-
const touchesSrc =
|
|
2577
|
+
const touchesSrc = opts.staged
|
|
2578
|
+
? gitStagedTouchesGlob(projectDir, opts.srcGlob)
|
|
2579
|
+
: gitDiffTouchesGlob(projectDir, opts.base, opts.srcGlob);
|
|
1635
2580
|
if (touchesSrc === false) {
|
|
1636
|
-
log.ok(`no changes under ${opts.srcGlob} — nothing to gate`);
|
|
2581
|
+
log.ok(`no ${opts.staged ? 'staged ' : ''}changes under ${opts.srcGlob} — nothing to gate`);
|
|
1637
2582
|
return;
|
|
1638
2583
|
}
|
|
1639
2584
|
if (touchesSrc === null) {
|
|
1640
|
-
log.warn(
|
|
2585
|
+
log.warn(`could not compute git ${opts.staged ? 'staged ' : ''}diff — skipping gate-check`);
|
|
1641
2586
|
return;
|
|
1642
2587
|
}
|
|
1643
2588
|
|
|
@@ -1798,7 +2743,14 @@ program
|
|
|
1798
2743
|
return fail('openspec validate --all --strict failed — rolled back: change restored, main specs reverted to pre-sync state');
|
|
1799
2744
|
}
|
|
1800
2745
|
|
|
1801
|
-
// Final handoff: pipeline is complete, no next-session prompt
|
|
2746
|
+
// Final handoff: pipeline is complete, no next-session prompt.
|
|
2747
|
+
// Change dir has already been moved — read prior Runtime from the archive target.
|
|
2748
|
+
const archivedHandoffPath = join(targetDir, 'handoff.md');
|
|
2749
|
+
let priorFields = {};
|
|
2750
|
+
if (existsSync(archivedHandoffPath)) {
|
|
2751
|
+
priorFields = fieldsFromSections(name, parseHandoffMarkdown(readFileSync(archivedHandoffPath, 'utf-8')));
|
|
2752
|
+
}
|
|
2753
|
+
const runtimeResult = resolveRuntime({}, process.env, priorFields);
|
|
1802
2754
|
const progress = parseTasksProgress(targetDir);
|
|
1803
2755
|
const fields = {
|
|
1804
2756
|
changeName: name,
|
|
@@ -1812,6 +2764,8 @@ program
|
|
|
1812
2764
|
attach: `- \`${targetRel}/\``,
|
|
1813
2765
|
spawn: 'none',
|
|
1814
2766
|
constraints: 'Pipeline complete — no next session.',
|
|
2767
|
+
runtime: runtimeResult.value || 'local',
|
|
2768
|
+
agentId: resolveAgentId({}, process.env, priorFields),
|
|
1815
2769
|
status: 'archived',
|
|
1816
2770
|
tasks: progress ? `${progress.done}/${progress.total}` : '',
|
|
1817
2771
|
review: parseReviewVerdict(targetDir) || '',
|
|
@@ -1819,6 +2773,7 @@ program
|
|
|
1819
2773
|
};
|
|
1820
2774
|
writeFileSync(join(targetDir, 'handoff.md'), `${buildHandoffMarkdown(fields).trim()}\n`);
|
|
1821
2775
|
const memoryPath = persistMemoryFromHandoff(projectDir, fields);
|
|
2776
|
+
const metricsPath = metricsFinalizeArchive(targetDir, name);
|
|
1822
2777
|
|
|
1823
2778
|
console.log(`change: ${name}`);
|
|
1824
2779
|
console.log(`schema: ${status.schemaName || 'unknown'}`);
|
|
@@ -1826,9 +2781,37 @@ program
|
|
|
1826
2781
|
console.log(`sync: ${syncStatus}`);
|
|
1827
2782
|
console.log(`handoff: ${join(targetRel, 'handoff.md')} (next_command: none)`);
|
|
1828
2783
|
console.log(`memory: ${memoryPath.replace(`${projectDir}/`, '')}`);
|
|
2784
|
+
if (metricsPath) console.log(`metrics: ${metricsPath.replace(`${projectDir}/`, '')} (archived_at set)`);
|
|
1829
2785
|
log.ok(`archived ${name}`);
|
|
1830
2786
|
});
|
|
1831
2787
|
|
|
2788
|
+
program
|
|
2789
|
+
.command('hooks-setup')
|
|
2790
|
+
.description('Opt-in pre-commit gate: husky-first, otherwise core.hooksPath=.githooks (never writes .git/hooks)')
|
|
2791
|
+
.action(() => {
|
|
2792
|
+
const projectDir = process.cwd();
|
|
2793
|
+
log.title('agent-orchestrator hooks-setup');
|
|
2794
|
+
const result = runHooksSetup(projectDir);
|
|
2795
|
+
if (!result.ok) process.exitCode = 1;
|
|
2796
|
+
});
|
|
2797
|
+
|
|
2798
|
+
program
|
|
2799
|
+
.command('mcp-setup')
|
|
2800
|
+
.description('Install optional GitHub/GitLab (from git origin) and browser MCP launchers (never prints tokens)')
|
|
2801
|
+
.option('--vcs <provider>', 'Override VCS detection: github | gitlab')
|
|
2802
|
+
.option('--no-browser', 'Skip browser MCP')
|
|
2803
|
+
.action((opts) => {
|
|
2804
|
+
const projectDir = process.cwd();
|
|
2805
|
+
log.title('agent-orchestrator mcp-setup');
|
|
2806
|
+
const vcs = opts.vcs ? String(opts.vcs).toLowerCase() : '';
|
|
2807
|
+
if (vcs && vcs !== 'github' && vcs !== 'gitlab') {
|
|
2808
|
+
log.err('invalid --vcs (use github or gitlab)');
|
|
2809
|
+
process.exitCode = 1;
|
|
2810
|
+
return;
|
|
2811
|
+
}
|
|
2812
|
+
runMcpSetup(projectDir, { vcs, browser: opts.browser !== false });
|
|
2813
|
+
});
|
|
2814
|
+
|
|
1832
2815
|
program
|
|
1833
2816
|
.command('figma-setup')
|
|
1834
2817
|
.description('Create local Figma token env file (never prints the token)')
|
|
@@ -1995,6 +2978,16 @@ program
|
|
|
1995
2978
|
.option('--tasks <progress>', 'Task progress n/m')
|
|
1996
2979
|
.option('--review <verdict>', 'Review verdict')
|
|
1997
2980
|
.option('--session-count <n>', 'Handoff session_count')
|
|
2981
|
+
.option('--runtime <runtime>', 'Session runtime: local | cloud')
|
|
2982
|
+
.option('--agent-id <id>', 'Cloud agent identifier')
|
|
2983
|
+
.option('--cloud-check', 'Verify change artifacts are committed and pushed', false)
|
|
2984
|
+
.option('--started-at <iso>', 'Session start timestamp (overrides the pending marker from --restore)')
|
|
2985
|
+
.option('--model <name>', 'Model used in this session (recorded in metrics.json)')
|
|
2986
|
+
.option('--input-tokens <n>', 'Input tokens spent in this session')
|
|
2987
|
+
.option('--output-tokens <n>', 'Output tokens spent in this session')
|
|
2988
|
+
.option('--total-tokens <n>', 'Total tokens spent in this session (default: input + output)')
|
|
2989
|
+
.option('--cost-usd <usd>', 'Cost of this session in USD')
|
|
2990
|
+
.option('--no-metrics', 'Skip recording this session into metrics.json')
|
|
1998
2991
|
.action((changeName, opts) => {
|
|
1999
2992
|
const projectDir = process.cwd();
|
|
2000
2993
|
const resolved = resolveHandoffChange(projectDir, changeName);
|
|
@@ -2036,11 +3029,43 @@ program
|
|
|
2036
3029
|
} else {
|
|
2037
3030
|
log.warn('handoff.md missing — using Memory JSON only');
|
|
2038
3031
|
}
|
|
3032
|
+
const decisionsPath = decisionsFilePath(projectDir, name);
|
|
3033
|
+
if (existsSync(decisionsPath)) {
|
|
3034
|
+
log.ok(`decisions.md: ${decisionsPath}`);
|
|
3035
|
+
const entries = parseDecisionsFileEntries(readFileSync(decisionsPath, 'utf-8'));
|
|
3036
|
+
for (const entry of entries) {
|
|
3037
|
+
console.log(`- ${entry.date} ${entry.text}`);
|
|
3038
|
+
}
|
|
3039
|
+
} else {
|
|
3040
|
+
console.log('decisions: none');
|
|
3041
|
+
}
|
|
2039
3042
|
if (memoryItems.length) {
|
|
2040
3043
|
log.ok(`Memory entities: ${memoryItems.length} (${memoryPath})`);
|
|
2041
3044
|
} else {
|
|
2042
3045
|
log.warn(`Memory JSON empty or missing at ${memoryPath}`);
|
|
2043
3046
|
}
|
|
3047
|
+
if (opts.metrics !== false && existsSync(changeDir)) {
|
|
3048
|
+
const metricsPath = metricsRecordSessionStart(projectDir, name, fields ? fields.nextRole : '');
|
|
3049
|
+
log.ok(`metrics: session start recorded (${metricsPath.replace(`${projectDir}/`, '')})`);
|
|
3050
|
+
}
|
|
3051
|
+
return;
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
if (opts.cloudCheck) {
|
|
3055
|
+
const existingCheck = readHandoffFields(projectDir, name);
|
|
3056
|
+
const checkFields = existingCheck.fields || { changeName: name };
|
|
3057
|
+
if (!applyRuntimeToFields(checkFields, opts, process.env)) {
|
|
3058
|
+
process.exitCode = 1;
|
|
3059
|
+
return;
|
|
3060
|
+
}
|
|
3061
|
+
const findings = collectCloudCheckFindings(projectDir, name);
|
|
3062
|
+
if (!findings.length) {
|
|
3063
|
+
log.ok('cloud-check passed');
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
const emit = checkFields.runtime === 'cloud' ? log.err : log.warn;
|
|
3067
|
+
for (const finding of findings) emit(finding);
|
|
3068
|
+
if (checkFields.runtime === 'cloud') process.exitCode = 1;
|
|
2044
3069
|
return;
|
|
2045
3070
|
}
|
|
2046
3071
|
|
|
@@ -2085,16 +3110,114 @@ program
|
|
|
2085
3110
|
fields.status = fields.review && /^APPROVE/i.test(fields.review) ? 'spec-approved' : 'in-progress';
|
|
2086
3111
|
}
|
|
2087
3112
|
|
|
3113
|
+
if (!applyRuntimeToFields(fields, opts, process.env)) {
|
|
3114
|
+
process.exitCode = 1;
|
|
3115
|
+
return;
|
|
3116
|
+
}
|
|
3117
|
+
|
|
2088
3118
|
const prompt = buildNextSessionPrompt(fields, agentLanguage).replace(/^\n+|\n+$/g, '');
|
|
2089
3119
|
fields.prompt = prompt;
|
|
2090
3120
|
writeFileSync(existing.filePath, `${buildHandoffMarkdown(fields).trim()}\n`);
|
|
2091
3121
|
console.error(pc.green(' ✓'), existing.filePath.replace(`${projectDir}/`, ''));
|
|
2092
3122
|
|
|
3123
|
+
appendDecisionsFromHandoff(projectDir, name, fields.decisions);
|
|
2093
3124
|
const memoryPath = persistMemoryFromHandoff(projectDir, fields);
|
|
2094
3125
|
console.error(pc.green(' ✓'), `Memory JSON upserted: ${memoryPath}`);
|
|
2095
3126
|
|
|
3127
|
+
if (opts.metrics !== false) {
|
|
3128
|
+
const metricsPath = metricsRecordSessionEnd(projectDir, fields, {
|
|
3129
|
+
startedAt: opts.startedAt,
|
|
3130
|
+
model: opts.model,
|
|
3131
|
+
inputTokens: opts.inputTokens,
|
|
3132
|
+
outputTokens: opts.outputTokens,
|
|
3133
|
+
totalTokens: opts.totalTokens,
|
|
3134
|
+
costUsd: opts.costUsd,
|
|
3135
|
+
});
|
|
3136
|
+
console.error(pc.green(' ✓'), `metrics.json updated: ${metricsPath.replace(`${projectDir}/`, '')}`);
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
if (fields.runtime === 'cloud') printCloudPersistNextSteps(name);
|
|
3140
|
+
|
|
2096
3141
|
console.error(pc.dim('Copy the prompt below into the next chat as one fenced block. Do not include this line.'));
|
|
2097
3142
|
process.stdout.write(`${prompt}\n`);
|
|
2098
3143
|
});
|
|
2099
3144
|
|
|
3145
|
+
program
|
|
3146
|
+
.command('metrics [change-name]')
|
|
3147
|
+
.description('Show recorded session metrics for a change: time per phase, tokens, cost, agents, models')
|
|
3148
|
+
.option('--json', 'Print raw metrics.json', false)
|
|
3149
|
+
.action((changeName, opts) => {
|
|
3150
|
+
const projectDir = process.cwd();
|
|
3151
|
+
let name = changeName;
|
|
3152
|
+
if (!name) {
|
|
3153
|
+
const resolved = resolveHandoffChange(projectDir, changeName);
|
|
3154
|
+
if (!resolved) {
|
|
3155
|
+
log.err('No active change found. Pass a name: npx agent-orchestrator-kit metrics <name>');
|
|
3156
|
+
process.exitCode = 1;
|
|
3157
|
+
return;
|
|
3158
|
+
}
|
|
3159
|
+
if (resolved.ambiguous) {
|
|
3160
|
+
log.err(`Multiple active changes: ${resolved.ambiguous.join(', ')}. Pass the change name argument.`);
|
|
3161
|
+
process.exitCode = 1;
|
|
3162
|
+
return;
|
|
3163
|
+
}
|
|
3164
|
+
name = resolved;
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
const { filePath, archived, missing } = resolveMetricsFile(projectDir, name);
|
|
3168
|
+
if (missing) {
|
|
3169
|
+
log.err(`No metrics.json for ${name}`);
|
|
3170
|
+
log.info(`Expected: ${filePath.replace(`${projectDir}/`, '')}`);
|
|
3171
|
+
log.info('Metrics are recorded by: handoff --restore (session start) and handoff <name> (session end)');
|
|
3172
|
+
process.exitCode = 1;
|
|
3173
|
+
return;
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3176
|
+
const metrics = loadMetricsFile(filePath, name, new Date().toISOString());
|
|
3177
|
+
if (opts.json) {
|
|
3178
|
+
process.stdout.write(`${JSON.stringify(metrics, null, 2)}\n`);
|
|
3179
|
+
return;
|
|
3180
|
+
}
|
|
3181
|
+
|
|
3182
|
+
log.title(`metrics ${name}${archived ? ' (archived)' : ''}`);
|
|
3183
|
+
console.log(`file: ${filePath.replace(`${projectDir}/`, '')}`);
|
|
3184
|
+
console.log(`sessions: ${metrics.totals.sessions}${metrics.totals.cloudSessions ? ` (cloud: ${metrics.totals.cloudSessions})` : ''}`);
|
|
3185
|
+
console.log(`work time: ${formatMetricsDuration(metrics.totals.durationMs)}`);
|
|
3186
|
+
console.log(`lead time: ${formatMetricsDuration(metrics.totals.leadTimeMs)}`);
|
|
3187
|
+
console.log(`tokens: ${formatMetricsNumber(metrics.spend.totalTokens)} (in: ${formatMetricsNumber(metrics.spend.inputTokens)}, out: ${formatMetricsNumber(metrics.spend.outputTokens)})`);
|
|
3188
|
+
console.log(`cost: ${formatMetricsCost(metrics.spend.costUsd)}`);
|
|
3189
|
+
if (metrics.archivedAt) console.log(`archived: ${metrics.archivedAt}`);
|
|
3190
|
+
if (metrics.pending) log.warn(`open session since ${metrics.pending.startedAt} (${metrics.pending.role || 'unknown role'})`);
|
|
3191
|
+
|
|
3192
|
+
const phaseOrder = ['explore', 'design', 'spec', 'review', 'apply', 'archive', 'other'];
|
|
3193
|
+
const phaseKeys = phaseOrder.filter((key) => metrics.phases[key]);
|
|
3194
|
+
if (phaseKeys.length) {
|
|
3195
|
+
console.log('');
|
|
3196
|
+
console.log('phase sessions time tokens cost agents');
|
|
3197
|
+
for (const key of phaseKeys) {
|
|
3198
|
+
const phase = metrics.phases[key];
|
|
3199
|
+
const cols = [
|
|
3200
|
+
key.padEnd(10),
|
|
3201
|
+
String(phase.sessions).padEnd(9),
|
|
3202
|
+
formatMetricsDuration(phase.durationMs).padEnd(9),
|
|
3203
|
+
formatMetricsNumber(phase.totalTokens).padEnd(9),
|
|
3204
|
+
formatMetricsCost(phase.costUsd).padEnd(9),
|
|
3205
|
+
phase.agents.join(', ') || '—',
|
|
3206
|
+
];
|
|
3207
|
+
console.log(cols.join(' '));
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
if (metrics.sessions.length) {
|
|
3212
|
+
console.log('');
|
|
3213
|
+
console.log('recent sessions:');
|
|
3214
|
+
for (const session of metrics.sessions.slice(-5)) {
|
|
3215
|
+
const spendLabel = session.totalTokens != null || session.costUsd != null
|
|
3216
|
+
? ` — ${formatMetricsNumber(session.totalTokens)} tok, ${formatMetricsCost(session.costUsd)}`
|
|
3217
|
+
: '';
|
|
3218
|
+
console.log(`- ${session.endedAt} ${session.phase.padEnd(7)} ${formatMetricsDuration(session.durationMs).padEnd(9)} ${session.role || '(no role)'}${session.model ? ` [${session.model}]` : ''}${spendLabel}`);
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
});
|
|
3222
|
+
|
|
2100
3223
|
program.parse();
|