agent-orchestrator-kit 0.2.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.
- package/CHANGELOG.md +35 -0
- package/README.md +208 -20
- package/bin/agent-orchestrator.js +1303 -90
- package/package.json +2 -2
- package/profiles/generic/orchestrator.yaml +18 -1
- package/profiles/mvp/openspec-config.yaml.example +2 -0
- package/profiles/mvp/orchestrator.yaml +18 -1
- package/profiles/node/orchestrator.yaml +21 -2
- package/profiles/vue3/openspec-config.yaml.example +2 -0
- package/profiles/vue3/orchestrator.yaml +20 -2
- package/templates/.agents/amp.settings.json.example +12 -0
- package/templates/.agents/commands/opsx-apply.md +18 -46
- package/templates/.agents/commands/opsx-archive.md +10 -163
- package/templates/.agents/commands/opsx-design.md +3 -10
- package/templates/.agents/commands/opsx-explore.md +3 -10
- package/templates/.agents/commands/opsx-propose.md +14 -10
- package/templates/.agents/commands/opsx-quick.md +3 -10
- package/templates/.agents/commands/opsx-review.md +27 -55
- package/templates/.agents/commands/opsx-sync.md +2 -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/agent-orchestration.mdc +15 -16
- package/templates/.agents/rules/memory-mcp-autosetup.mdc +1 -1
- package/templates/.agents/rules/session-handoff.mdc +22 -17
- package/templates/.agents/skills/agent-orchestration/SKILL.md +32 -29
- package/templates/.agents/skills/openspec-apply-change/SKILL.md +15 -20
- package/templates/.agents/skills/openspec-archive-change/SKILL.md +12 -99
- package/templates/.agents/skills/openspec-propose/SKILL.md +11 -0
- package/templates/.agents/subagents/session-handoff.md +12 -10
- package/templates/.agents/subagents/spec-architect.md +1 -1
- package/templates/.agents/subagents/spec-archiver.md +1 -1
- package/templates/.agents/subagents/spec-reviewer.md +11 -7
- package/templates/.cursor/memory.json +11 -0
- package/templates/AGENTS.md +3 -3
- package/templates/CLAUDE.md +2 -2
- package/templates/orchestrator.yaml +18 -1
- 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 } 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);
|
|
@@ -747,8 +1387,8 @@ function parseReviewVerdict(changeDir) {
|
|
|
747
1387
|
const reviewPath = join(changeDir, 'review.md');
|
|
748
1388
|
if (!existsSync(reviewPath)) return null;
|
|
749
1389
|
const content = readFileSync(reviewPath, 'utf-8');
|
|
750
|
-
const match = content.match(
|
|
751
|
-
return match ? match[1].trim() : 'unknown';
|
|
1390
|
+
const match = content.match(/^(?:#{1,6}\s*)?\*{0,2}Verdict:\*{0,2}\s*(.+?)\s*$/m);
|
|
1391
|
+
return match ? match[1].replace(/\*+\s*$/, '').trim() : 'unknown';
|
|
752
1392
|
}
|
|
753
1393
|
|
|
754
1394
|
function parseDesignBrief(changeDir) {
|
|
@@ -769,13 +1409,237 @@ function readPipelineConfig(projectDir) {
|
|
|
769
1409
|
const requireReviewMatch = content.match(/require_spec_review:\s*(true|false)/);
|
|
770
1410
|
const requireBriefMatch = content.match(/require_design_brief:\s*(true|false)/);
|
|
771
1411
|
const maxActiveMatch = content.match(/max_active_changes:\s*(\d+)/);
|
|
1412
|
+
const taskContractMatch = content.match(/task_contract:\s*(warn|strict|off)/);
|
|
772
1413
|
return {
|
|
773
1414
|
requireSpecReview: requireReviewMatch ? requireReviewMatch[1] === 'true' : true,
|
|
774
1415
|
requireDesignBrief: requireBriefMatch ? requireBriefMatch[1] === 'true' : false,
|
|
775
1416
|
maxActiveChanges: maxActiveMatch ? parseInt(maxActiveMatch[1], 10) : null,
|
|
1417
|
+
taskContract: taskContractMatch ? taskContractMatch[1] : 'warn',
|
|
776
1418
|
};
|
|
777
1419
|
}
|
|
778
1420
|
|
|
1421
|
+
// --- Task-contract lint (gate-check --tasks) ---
|
|
1422
|
+
|
|
1423
|
+
const VAGUE_DO_PATTERNS = [/\bas needed\b/i, /\bif necessary\b/i, /\bas appropriate\b/i, /де потрібно/i, /за потреби/i];
|
|
1424
|
+
|
|
1425
|
+
function taskContractMode(projectDir) {
|
|
1426
|
+
const config = readPipelineConfig(projectDir);
|
|
1427
|
+
return config ? config.taskContract : 'warn';
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function parseTaskContracts(content) {
|
|
1431
|
+
const tasks = [];
|
|
1432
|
+
let current = null;
|
|
1433
|
+
for (const line of content.split('\n')) {
|
|
1434
|
+
const taskMatch = line.match(/^\s*- \[[ xX]\]\s+(.*)$/);
|
|
1435
|
+
if (taskMatch) {
|
|
1436
|
+
current = { title: taskMatch[1].trim(), files: null, do: null, doneWhen: null };
|
|
1437
|
+
tasks.push(current);
|
|
1438
|
+
continue;
|
|
1439
|
+
}
|
|
1440
|
+
if (!current) continue;
|
|
1441
|
+
const fieldMatch = line.match(/^\s+(Files|Do|Done-when):\s*(.*)$/);
|
|
1442
|
+
if (fieldMatch) {
|
|
1443
|
+
const value = fieldMatch[2].trim();
|
|
1444
|
+
if (fieldMatch[1] === 'Files') current.files = value;
|
|
1445
|
+
else if (fieldMatch[1] === 'Do') current.do = value;
|
|
1446
|
+
else current.doneWhen = value;
|
|
1447
|
+
} else if (/^\S/.test(line)) {
|
|
1448
|
+
current = null;
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
return tasks;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
function lintTaskContracts(projectDir, tasksPath) {
|
|
1455
|
+
const errors = [];
|
|
1456
|
+
const tasks = parseTaskContracts(readFileSync(tasksPath, 'utf-8'));
|
|
1457
|
+
for (const task of tasks) {
|
|
1458
|
+
const label = `task "${task.title.slice(0, 60)}"`;
|
|
1459
|
+
if (!task.files) errors.push(`${label}: missing Files:`);
|
|
1460
|
+
if (!task.do) errors.push(`${label}: missing Do:`);
|
|
1461
|
+
if (!task.doneWhen) errors.push(`${label}: missing Done-when:`);
|
|
1462
|
+
if (task.do) {
|
|
1463
|
+
for (const pattern of VAGUE_DO_PATTERNS) {
|
|
1464
|
+
const match = task.do.match(pattern);
|
|
1465
|
+
if (match) errors.push(`${label}: vague wording in Do: "${match[0]}"`);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
if (task.files) {
|
|
1469
|
+
for (const entry of task.files.split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
1470
|
+
if (/^new file:/i.test(entry)) continue;
|
|
1471
|
+
if (!existsSync(join(projectDir, entry))) errors.push(`${label}: Files: path does not exist: ${entry} (prefix with "new file:" if intentional)`);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
return errors;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function runTasksLint(projectDir, name, { quiet = false } = {}) {
|
|
1479
|
+
const mode = taskContractMode(projectDir);
|
|
1480
|
+
const report = { mode, errors: [], warnings: [] };
|
|
1481
|
+
if (mode === 'off') return report;
|
|
1482
|
+
const tasksPath = join(projectDir, 'openspec', 'changes', name, 'tasks.md');
|
|
1483
|
+
if (!existsSync(tasksPath)) {
|
|
1484
|
+
report.warnings.push(`tasks.md not found: ${tasksPath}`);
|
|
1485
|
+
return report;
|
|
1486
|
+
}
|
|
1487
|
+
const findings = lintTaskContracts(projectDir, tasksPath);
|
|
1488
|
+
if (mode === 'strict') report.errors = findings;
|
|
1489
|
+
else report.warnings = [...report.warnings, ...findings];
|
|
1490
|
+
if (!quiet) {
|
|
1491
|
+
for (const e of report.errors) log.err(e);
|
|
1492
|
+
for (const w of report.warnings) log.warn(w);
|
|
1493
|
+
}
|
|
1494
|
+
return report;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// --- Tier 1 review (gate-check --review) ---
|
|
1498
|
+
|
|
1499
|
+
// Change names are interpolated into shell commands and paths; keep them slugs.
|
|
1500
|
+
function isSafeChangeName(name) {
|
|
1501
|
+
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name);
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function runTier1Review(projectDir, name) {
|
|
1505
|
+
const errors = [];
|
|
1506
|
+
if (!isSafeChangeName(name)) {
|
|
1507
|
+
return { pass: false, errors: [`invalid change name: ${name}`] };
|
|
1508
|
+
}
|
|
1509
|
+
const changeDir = join(projectDir, 'openspec', 'changes', name);
|
|
1510
|
+
if (!existsSync(changeDir)) {
|
|
1511
|
+
return { pass: false, errors: [`change not found: ${name}`] };
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
try {
|
|
1515
|
+
execSync(`npx openspec validate ${name} --strict --type change`, {
|
|
1516
|
+
cwd: projectDir,
|
|
1517
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1518
|
+
encoding: 'utf-8',
|
|
1519
|
+
});
|
|
1520
|
+
} catch (e) {
|
|
1521
|
+
const detail = `${e.stdout || ''}${e.stderr || ''}`.trim().split('\n')[0] || 'non-zero exit';
|
|
1522
|
+
errors.push(`openspec validate --strict failed: ${detail}`);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
const lint = runTasksLint(projectDir, name, { quiet: true });
|
|
1526
|
+
errors.push(...lint.errors);
|
|
1527
|
+
|
|
1528
|
+
const proposalPath = join(changeDir, 'proposal.md');
|
|
1529
|
+
if (!existsSync(proposalPath)) {
|
|
1530
|
+
errors.push('proposal.md not found');
|
|
1531
|
+
} else {
|
|
1532
|
+
const proposal = readFileSync(proposalPath, 'utf-8');
|
|
1533
|
+
if (!/^#{2,}\s*Non-goals\b/im.test(proposal)) errors.push('proposal.md: missing "Non-goals" section');
|
|
1534
|
+
if (!/^#{2,}\s*Acceptance criteria\b/im.test(proposal)) errors.push('proposal.md: missing "Acceptance criteria" section');
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
for (const deltaPath of listDeltaSpecFiles(changeDir)) {
|
|
1538
|
+
const rel = deltaPath.replace(`${projectDir}/`, '');
|
|
1539
|
+
const sections = parseDeltaSpec(readFileSync(deltaPath, 'utf-8'));
|
|
1540
|
+
const total = sections.ADDED.length + sections.MODIFIED.length + sections.REMOVED.length;
|
|
1541
|
+
if (total === 0) errors.push(`${rel}: no non-empty ADDED/MODIFIED/REMOVED Requirements section`);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
return { pass: errors.length === 0, errors, warnings: lint.warnings };
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// --- Delta spec sync (archive --sync) ---
|
|
1548
|
+
|
|
1549
|
+
function listDeltaSpecFiles(changeDir) {
|
|
1550
|
+
const specsDir = join(changeDir, 'specs');
|
|
1551
|
+
if (!existsSync(specsDir)) return [];
|
|
1552
|
+
const files = [];
|
|
1553
|
+
const walk = (dir) => {
|
|
1554
|
+
for (const entry of readdirSync(dir)) {
|
|
1555
|
+
const full = join(dir, entry);
|
|
1556
|
+
if (statSync(full).isDirectory()) walk(full);
|
|
1557
|
+
else if (entry.endsWith('.md')) files.push(full);
|
|
1558
|
+
}
|
|
1559
|
+
};
|
|
1560
|
+
walk(specsDir);
|
|
1561
|
+
return files.sort();
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
function splitRequirementBlocks(sectionBody) {
|
|
1565
|
+
return String(sectionBody || '')
|
|
1566
|
+
.split(/^### Requirement: /m)
|
|
1567
|
+
.slice(1)
|
|
1568
|
+
.map((part) => {
|
|
1569
|
+
const nl = part.indexOf('\n');
|
|
1570
|
+
const name = (nl === -1 ? part : part.slice(0, nl)).trim();
|
|
1571
|
+
const body = nl === -1 ? '' : part.slice(nl + 1);
|
|
1572
|
+
return { name, block: `### Requirement: ${name}\n${body}`.replace(/\s+$/, '') };
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
function parseDeltaSpec(content) {
|
|
1577
|
+
const sections = { ADDED: [], MODIFIED: [], REMOVED: [] };
|
|
1578
|
+
const parts = String(content || '').split(/^## /m);
|
|
1579
|
+
for (const part of parts.slice(1)) {
|
|
1580
|
+
const nl = part.indexOf('\n');
|
|
1581
|
+
const title = (nl === -1 ? part : part.slice(0, nl)).trim();
|
|
1582
|
+
const body = nl === -1 ? '' : part.slice(nl + 1);
|
|
1583
|
+
const match = title.match(/^(ADDED|MODIFIED|REMOVED) Requirements$/);
|
|
1584
|
+
if (match) sections[match[1]] = splitRequirementBlocks(body);
|
|
1585
|
+
}
|
|
1586
|
+
return sections;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
function findRequirementSpan(content, name) {
|
|
1590
|
+
const header = `### Requirement: ${name}`;
|
|
1591
|
+
const idx = content.indexOf(header);
|
|
1592
|
+
if (idx === -1) return null;
|
|
1593
|
+
const rest = content.slice(idx + header.length);
|
|
1594
|
+
const relEnd = rest.search(/\n### Requirement: |\n## /);
|
|
1595
|
+
const end = relEnd === -1 ? content.length : idx + header.length + relEnd;
|
|
1596
|
+
return [idx, end];
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
function planSpecSync(projectDir, deltaSpecPaths, changeName) {
|
|
1600
|
+
const plan = [];
|
|
1601
|
+
const conflicts = [];
|
|
1602
|
+
for (const deltaPath of deltaSpecPaths) {
|
|
1603
|
+
const capability = basename(dirname(deltaPath));
|
|
1604
|
+
const mainPath = join(projectDir, 'openspec', 'specs', capability, 'spec.md');
|
|
1605
|
+
const delta = parseDeltaSpec(readFileSync(deltaPath, 'utf-8'));
|
|
1606
|
+
if (delta.ADDED.length + delta.MODIFIED.length + delta.REMOVED.length === 0) continue;
|
|
1607
|
+
const existed = existsSync(mainPath);
|
|
1608
|
+
const dirExisted = existsSync(dirname(mainPath));
|
|
1609
|
+
const oldContent = existed ? readFileSync(mainPath, 'utf-8') : null;
|
|
1610
|
+
let content = existed
|
|
1611
|
+
? oldContent
|
|
1612
|
+
: `## Purpose\n\n${capability} — requirements merged from change ${changeName}.\n\n## Requirements\n`;
|
|
1613
|
+
|
|
1614
|
+
for (const req of delta.REMOVED) {
|
|
1615
|
+
const span = findRequirementSpan(content, req.name);
|
|
1616
|
+
if (!span) {
|
|
1617
|
+
conflicts.push(`${capability}: REMOVED requirement not found in main spec: "${req.name}"`);
|
|
1618
|
+
continue;
|
|
1619
|
+
}
|
|
1620
|
+
content = `${content.slice(0, span[0]).replace(/\n+$/, '\n\n')}${content.slice(span[1]).replace(/^\n+/, '')}`;
|
|
1621
|
+
}
|
|
1622
|
+
for (const req of delta.MODIFIED) {
|
|
1623
|
+
const span = findRequirementSpan(content, req.name);
|
|
1624
|
+
if (!span) {
|
|
1625
|
+
conflicts.push(`${capability}: MODIFIED requirement not found in main spec: "${req.name}"`);
|
|
1626
|
+
continue;
|
|
1627
|
+
}
|
|
1628
|
+
content = `${content.slice(0, span[0])}${req.block}\n\n${content.slice(span[1]).replace(/^\n+/, '')}`;
|
|
1629
|
+
}
|
|
1630
|
+
for (const req of delta.ADDED) {
|
|
1631
|
+
if (findRequirementSpan(content, req.name)) {
|
|
1632
|
+
conflicts.push(`${capability}: ADDED requirement already exists in main spec: "${req.name}"`);
|
|
1633
|
+
continue;
|
|
1634
|
+
}
|
|
1635
|
+
content = `${content.replace(/\s+$/, '')}\n\n${req.block}\n`;
|
|
1636
|
+
}
|
|
1637
|
+
if (!content.endsWith('\n')) content += '\n';
|
|
1638
|
+
plan.push({ mainPath, existed, dirExisted, oldContent, newContent: content });
|
|
1639
|
+
}
|
|
1640
|
+
return { plan, conflicts };
|
|
1641
|
+
}
|
|
1642
|
+
|
|
779
1643
|
// Returns true/false when the diff is known, or null when it could not be
|
|
780
1644
|
// determined (no git repo, invalid base ref, shallow clone, etc.) — callers
|
|
781
1645
|
// must treat null as "skip gracefully", never as "block".
|
|
@@ -792,6 +1656,19 @@ function gitDiffTouchesGlob(projectDir, base, srcGlob) {
|
|
|
792
1656
|
}
|
|
793
1657
|
}
|
|
794
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
|
+
|
|
795
1672
|
function installOpenspecConfigExample(projectDir, profile, vars, force) {
|
|
796
1673
|
const src = resolveTemplate('openspec-config.yaml.example', profile);
|
|
797
1674
|
if (!existsSync(src)) return;
|
|
@@ -973,18 +1850,21 @@ function printNextSteps(profile, projectDir, ci = 'github', specVerify = false)
|
|
|
973
1850
|
if (profile === 'vue3') {
|
|
974
1851
|
lines.push(` 3. Install Vue/JS stack skills:`);
|
|
975
1852
|
lines.push(` ${pc.cyan('npx frontend-agent-skills install --agent all --yes')}`);
|
|
976
|
-
lines.push(` 4. MCP:
|
|
1853
|
+
lines.push(` 4. MCP: ${pc.cyan('npx agent-orchestrator-kit mcp-setup')} (GitHub/GitLab from origin + browser)`);
|
|
977
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)`);
|
|
978
|
-
lines.push(` 6.
|
|
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:`);
|
|
979
1857
|
} else if (profile === 'mvp') {
|
|
980
1858
|
lines.push(` 3. For quick demos use ${pc.cyan('/opsx:quick <name>')} (propose + apply, no review gate)`);
|
|
981
|
-
lines.push(` 4. MCP:
|
|
1859
|
+
lines.push(` 4. MCP: ${pc.cyan('npx agent-orchestrator-kit mcp-setup')} (GitHub/GitLab from origin + browser)`);
|
|
982
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)`);
|
|
983
|
-
lines.push(` 6.
|
|
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:`);
|
|
984
1863
|
} else {
|
|
985
|
-
lines.push(` 3. MCP:
|
|
1864
|
+
lines.push(` 3. MCP: ${pc.cyan('npx agent-orchestrator-kit mcp-setup')} (GitHub/GitLab from origin + browser)`);
|
|
986
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)`);
|
|
987
|
-
lines.push(` 5.
|
|
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:`);
|
|
988
1868
|
}
|
|
989
1869
|
|
|
990
1870
|
const startCmd = profile === 'mvp' ? '/opsx:quick' : '/opsx:explore';
|
|
@@ -1029,6 +1909,92 @@ function listAmpSubagentWrappers(projectDir) {
|
|
|
1029
1909
|
return readdirSync(skillsDir).filter((entry) => entry.startsWith(AMP_SUBAGENT_SKILL_PREFIX));
|
|
1030
1910
|
}
|
|
1031
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
|
+
|
|
1032
1998
|
function generateAmpSubagentSkills(projectDir) {
|
|
1033
1999
|
const subagentsDir = join(projectDir, '.agents', 'subagents');
|
|
1034
2000
|
const skillsDir = join(projectDir, '.agents', 'skills');
|
|
@@ -1037,31 +2003,16 @@ function generateAmpSubagentSkills(projectDir) {
|
|
|
1037
2003
|
if (existsSync(subagentsDir)) {
|
|
1038
2004
|
for (const file of readdirSync(subagentsDir).filter((f) => f.endsWith('.md'))) {
|
|
1039
2005
|
const content = readFileSync(join(subagentsDir, file), 'utf-8');
|
|
1040
|
-
const
|
|
1041
|
-
|
|
1042
|
-
const description = parsed?.[1].match(/^description:\s*(.+)$/m)?.[1]?.trim();
|
|
1043
|
-
if (!name || !description) {
|
|
2006
|
+
const parsedWrap = parseAmpSubagentSource(content);
|
|
2007
|
+
if (!parsedWrap) {
|
|
1044
2008
|
log.warn(`skip Amp wrapper (missing name/description frontmatter): .agents/subagents/${file}`);
|
|
1045
2009
|
continue;
|
|
1046
2010
|
}
|
|
1047
2011
|
|
|
1048
|
-
const skillName = `${AMP_SUBAGENT_SKILL_PREFIX}${name}`;
|
|
2012
|
+
const skillName = `${AMP_SUBAGENT_SKILL_PREFIX}${parsedWrap.name}`;
|
|
1049
2013
|
expected.add(skillName);
|
|
1050
2014
|
mkdirSync(join(skillsDir, skillName), { recursive: true });
|
|
1051
|
-
|
|
1052
|
-
'---',
|
|
1053
|
-
`name: ${skillName}`,
|
|
1054
|
-
`description: ${description}`,
|
|
1055
|
-
'---',
|
|
1056
|
-
'',
|
|
1057
|
-
`<!-- AUTO-GENERATED from .agents/subagents/${file} — edit the source file, then run: npx agent-orchestrator-kit sync -->`,
|
|
1058
|
-
'',
|
|
1059
|
-
AMP_SPAWN_PREAMBLE,
|
|
1060
|
-
'',
|
|
1061
|
-
parsed[2].trim(),
|
|
1062
|
-
'',
|
|
1063
|
-
].join('\n');
|
|
1064
|
-
writeFileSync(join(skillsDir, skillName, 'SKILL.md'), skill);
|
|
2015
|
+
writeFileSync(join(skillsDir, skillName, 'SKILL.md'), buildAmpSubagentSkillContent(file, parsedWrap.parsed));
|
|
1065
2016
|
log.ok(`.agents/skills/${skillName}/SKILL.md (Amp wrapper)`);
|
|
1066
2017
|
}
|
|
1067
2018
|
}
|
|
@@ -1077,11 +2028,10 @@ function generateAmpSubagentSkills(projectDir) {
|
|
|
1077
2028
|
function syncAmp(projectDir) {
|
|
1078
2029
|
log.info('Amp Code reads .agents/ natively — subagents exposed via skill wrappers');
|
|
1079
2030
|
mkdirSync(join(projectDir, '.amp'), { recursive: true });
|
|
1080
|
-
const ampExample = join(projectDir,
|
|
2031
|
+
const ampExample = join(projectDir, AMP_EXAMPLE_REL);
|
|
1081
2032
|
const ampDest = join(projectDir, '.amp', 'settings.json');
|
|
1082
|
-
if (
|
|
1083
|
-
|
|
1084
|
-
log.ok('.amp/settings.json created from example');
|
|
2033
|
+
if (seedLiveMcpFromExample(ampDest, ampExample, 'amp.mcpServers', OPTIONAL_MCP_SEED_STRIP, '.amp/settings.json')) {
|
|
2034
|
+
// seeded
|
|
1085
2035
|
} else if (existsSync(ampDest)) {
|
|
1086
2036
|
log.ok('.amp/settings.json already present');
|
|
1087
2037
|
} else {
|
|
@@ -1103,6 +2053,7 @@ program
|
|
|
1103
2053
|
.option('--force', 'Overwrite existing files', false)
|
|
1104
2054
|
.option('--ci <provider>', 'CI provider: gitlab | github | none', 'github')
|
|
1105
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)
|
|
1106
2057
|
.action((opts) => {
|
|
1107
2058
|
const projectDir = process.cwd();
|
|
1108
2059
|
const projectName = opts.name || basename(projectDir);
|
|
@@ -1136,6 +2087,7 @@ program
|
|
|
1136
2087
|
try {
|
|
1137
2088
|
execSync(`chmod +x ${join(projectDir, 'scripts', 'sync-local-agent-skills.sh')}`);
|
|
1138
2089
|
} catch {}
|
|
2090
|
+
chmodX(join(projectDir, HOOK_SCRIPT_REL));
|
|
1139
2091
|
|
|
1140
2092
|
log.title('Installing CI workflow');
|
|
1141
2093
|
installCi(projectDir, templateDir, ci, opts.force);
|
|
@@ -1191,6 +2143,12 @@ program
|
|
|
1191
2143
|
refreshMemoryManagedFiles(projectDir);
|
|
1192
2144
|
ensureMemoryMcpEntry(projectDir);
|
|
1193
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
|
+
|
|
1194
2152
|
log.title('Done');
|
|
1195
2153
|
log.ok(`agent-orchestrator-kit v${KIT_VERSION} installed`);
|
|
1196
2154
|
printNextSteps(profile, projectDir, ci, specVerify);
|
|
@@ -1242,6 +2200,8 @@ program
|
|
|
1242
2200
|
|
|
1243
2201
|
log.title('Refreshing Figma setup templates');
|
|
1244
2202
|
refreshFigmaManagedFiles(projectDir);
|
|
2203
|
+
log.title('Refreshing MCP launchers and hook script');
|
|
2204
|
+
refreshOptionalMcpManagedFiles(projectDir);
|
|
1245
2205
|
log.title('Configuring Memory MCP');
|
|
1246
2206
|
refreshMemoryManagedFiles(projectDir);
|
|
1247
2207
|
ensureMemoryMcpEntry(projectDir);
|
|
@@ -1249,7 +2209,9 @@ program
|
|
|
1249
2209
|
|
|
1250
2210
|
log.ok(`Updated to v${KIT_VERSION}`);
|
|
1251
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');
|
|
1252
2213
|
log.info('Optional Figma: npx agent-orchestrator-kit figma-setup');
|
|
2214
|
+
log.info('Optional pre-commit gate: npx agent-orchestrator-kit hooks-setup');
|
|
1253
2215
|
});
|
|
1254
2216
|
|
|
1255
2217
|
program
|
|
@@ -1279,13 +2241,6 @@ program
|
|
|
1279
2241
|
}
|
|
1280
2242
|
copyDir(join(projectDir, '.agents', 'rules'), join(projectDir, '.cursor', 'rules'), { overwrite: true, delete: true });
|
|
1281
2243
|
copyDir(join(projectDir, '.agents', 'subagents'), join(projectDir, '.cursor', 'agents'), { overwrite: true, delete: true });
|
|
1282
|
-
|
|
1283
|
-
const mcpExample = join(projectDir, '.agents', 'mcp.json.example');
|
|
1284
|
-
const mcpDest = join(projectDir, '.mcp.json');
|
|
1285
|
-
if (existsSync(mcpExample) && !existsSync(mcpDest)) {
|
|
1286
|
-
copyFileSync(mcpExample, mcpDest);
|
|
1287
|
-
log.ok('.mcp.json created from example');
|
|
1288
|
-
}
|
|
1289
2244
|
}
|
|
1290
2245
|
|
|
1291
2246
|
if (syncClaude) {
|
|
@@ -1320,7 +2275,7 @@ program
|
|
|
1320
2275
|
|
|
1321
2276
|
program
|
|
1322
2277
|
.command('status')
|
|
1323
|
-
.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)')
|
|
1324
2279
|
.action(() => {
|
|
1325
2280
|
const projectDir = process.cwd();
|
|
1326
2281
|
log.title('agent-orchestrator status');
|
|
@@ -1328,25 +2283,27 @@ program
|
|
|
1328
2283
|
const changes = listActiveChanges(projectDir);
|
|
1329
2284
|
if (changes.length === 0) {
|
|
1330
2285
|
log.info('No active changes');
|
|
1331
|
-
|
|
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('');
|
|
1332
2303
|
}
|
|
1333
2304
|
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
const progress = parseTasksProgress(changeDir);
|
|
1337
|
-
const verdict = parseReviewVerdict(changeDir);
|
|
1338
|
-
const hasBrief = parseDesignBrief(changeDir);
|
|
1339
|
-
const progressStr = progress ? `${progress.done}/${progress.total} tasks` : 'no tasks.md';
|
|
1340
|
-
const verdictStr = verdict || 'none';
|
|
1341
|
-
const readyToArchive = Boolean(progress && progress.total > 0 && progress.done === progress.total);
|
|
1342
|
-
|
|
1343
|
-
console.log(`\n${pc.bold(name)}`);
|
|
1344
|
-
console.log(` tasks: ${progressStr}`);
|
|
1345
|
-
console.log(` review: ${verdictStr}`);
|
|
1346
|
-
console.log(` brief: ${hasBrief ? 'yes' : 'no'}`);
|
|
1347
|
-
if (readyToArchive) log.ok('ready to archive');
|
|
1348
|
-
}
|
|
1349
|
-
console.log('');
|
|
2305
|
+
printMcpHealth(projectDir);
|
|
2306
|
+
printSkillHealth(projectDir);
|
|
1350
2307
|
});
|
|
1351
2308
|
|
|
1352
2309
|
program
|
|
@@ -1354,8 +2311,47 @@ program
|
|
|
1354
2311
|
.description('Deterministically check the review gate before apply/merge (exit non-zero if unmet)')
|
|
1355
2312
|
.option('--src-glob <glob>', 'source path filter used to detect code changes', 'src/')
|
|
1356
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)
|
|
2315
|
+
.option('--tasks <name>', 'lint task contracts (Files/Do/Done-when) of a change')
|
|
2316
|
+
.option('--review <name>', 'run deterministic Tier 1 review checks on a change')
|
|
2317
|
+
.option('--json', 'with --review: print a {pass, errors[]} JSON report to stdout', false)
|
|
1357
2318
|
.action((changeName, opts) => {
|
|
1358
2319
|
const projectDir = process.cwd();
|
|
2320
|
+
|
|
2321
|
+
if (opts.tasks) {
|
|
2322
|
+
log.title(`gate-check --tasks ${opts.tasks}`);
|
|
2323
|
+
const mode = taskContractMode(projectDir);
|
|
2324
|
+
if (mode === 'off') {
|
|
2325
|
+
log.info('task contract lint disabled (pipeline.task_contract: off)');
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
const report = runTasksLint(projectDir, opts.tasks);
|
|
2329
|
+
if (report.errors.length) {
|
|
2330
|
+
console.error(`task contract gate failed — ${report.errors.length} error(s) (pipeline.task_contract: strict)`);
|
|
2331
|
+
process.exitCode = 1;
|
|
2332
|
+
} else if (report.warnings.length) {
|
|
2333
|
+
log.warn(`task contract: ${report.warnings.length} issue(s) — warn mode, not blocking`);
|
|
2334
|
+
} else {
|
|
2335
|
+
log.ok('all tasks follow the contract (Files/Do/Done-when)');
|
|
2336
|
+
}
|
|
2337
|
+
return;
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
if (opts.review) {
|
|
2341
|
+
const result = runTier1Review(projectDir, opts.review);
|
|
2342
|
+
if (opts.json) {
|
|
2343
|
+
console.log(JSON.stringify({ pass: result.pass, errors: result.errors }, null, 2));
|
|
2344
|
+
} else {
|
|
2345
|
+
log.title(`gate-check --review ${opts.review}`);
|
|
2346
|
+
for (const e of result.errors) log.err(e);
|
|
2347
|
+
for (const w of result.warnings || []) log.warn(w);
|
|
2348
|
+
if (result.pass) log.ok('Tier 1 review passed — proceed to spec-reviewer (Tier 2)');
|
|
2349
|
+
else log.err(`Tier 1 review failed — ${result.errors.length} error(s)`);
|
|
2350
|
+
}
|
|
2351
|
+
if (!result.pass) process.exitCode = 1;
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
|
|
1359
2355
|
log.title('agent-orchestrator gate-check');
|
|
1360
2356
|
|
|
1361
2357
|
const config = readPipelineConfig(projectDir);
|
|
@@ -1369,13 +2365,15 @@ program
|
|
|
1369
2365
|
return;
|
|
1370
2366
|
}
|
|
1371
2367
|
|
|
1372
|
-
const touchesSrc =
|
|
2368
|
+
const touchesSrc = opts.staged
|
|
2369
|
+
? gitStagedTouchesGlob(projectDir, opts.srcGlob)
|
|
2370
|
+
: gitDiffTouchesGlob(projectDir, opts.base, opts.srcGlob);
|
|
1373
2371
|
if (touchesSrc === false) {
|
|
1374
|
-
log.ok(`no changes under ${opts.srcGlob} — nothing to gate`);
|
|
2372
|
+
log.ok(`no ${opts.staged ? 'staged ' : ''}changes under ${opts.srcGlob} — nothing to gate`);
|
|
1375
2373
|
return;
|
|
1376
2374
|
}
|
|
1377
2375
|
if (touchesSrc === null) {
|
|
1378
|
-
log.warn(
|
|
2376
|
+
log.warn(`could not compute git ${opts.staged ? 'staged ' : ''}diff — skipping gate-check`);
|
|
1379
2377
|
return;
|
|
1380
2378
|
}
|
|
1381
2379
|
|
|
@@ -1427,6 +2425,182 @@ program
|
|
|
1427
2425
|
}
|
|
1428
2426
|
});
|
|
1429
2427
|
|
|
2428
|
+
program
|
|
2429
|
+
.command('archive <name>')
|
|
2430
|
+
.description('Archive a completed change: check gates, optionally sync delta specs, move to a dated archive, validate, write final handoff')
|
|
2431
|
+
.option('--sync', 'merge delta specs into openspec/specs/ before archiving')
|
|
2432
|
+
.option('--no-sync', 'skip delta-spec merge (requires --force when delta specs exist)')
|
|
2433
|
+
.option('--force', 'confirm archiving without merge when delta specs exist', false)
|
|
2434
|
+
.action((name, opts) => {
|
|
2435
|
+
const projectDir = process.cwd();
|
|
2436
|
+
const fail = (msg) => {
|
|
2437
|
+
console.error(msg);
|
|
2438
|
+
process.exitCode = 1;
|
|
2439
|
+
};
|
|
2440
|
+
log.title(`agent-orchestrator archive ${name}`);
|
|
2441
|
+
|
|
2442
|
+
if (!isSafeChangeName(name)) return fail(`invalid change name: ${name}`);
|
|
2443
|
+
|
|
2444
|
+
let status;
|
|
2445
|
+
try {
|
|
2446
|
+
const out = execSync(`npx openspec status --change ${name} --json`, {
|
|
2447
|
+
cwd: projectDir,
|
|
2448
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2449
|
+
encoding: 'utf-8',
|
|
2450
|
+
});
|
|
2451
|
+
status = JSON.parse(out);
|
|
2452
|
+
} catch (e) {
|
|
2453
|
+
const detail = `${(e.stderr || e.stdout || e.message || '')}`.trim().split('\n')[0];
|
|
2454
|
+
return fail(`could not resolve change "${name}" via openspec status: ${detail}`);
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
const changeRoot = status.changeRoot || join(projectDir, 'openspec', 'changes', name);
|
|
2458
|
+
const changesDir = (status.planningHome && status.planningHome.changesDir) || join(projectDir, 'openspec', 'changes');
|
|
2459
|
+
if (!existsSync(changeRoot)) return fail(`change not found: ${changeRoot}`);
|
|
2460
|
+
|
|
2461
|
+
// Gate 1: review verdict (only when required by pipeline config)
|
|
2462
|
+
const config = readPipelineConfig(projectDir);
|
|
2463
|
+
const requireReview = config ? config.requireSpecReview : true;
|
|
2464
|
+
if (requireReview) {
|
|
2465
|
+
const verdict = parseReviewVerdict(changeRoot);
|
|
2466
|
+
if (!(verdict && /^APPROVE/i.test(verdict))) {
|
|
2467
|
+
return fail(`review gate failed — change "${name}" has ${verdict ? `verdict "${verdict}"` : 'no review.md'} (require_spec_review: true)`);
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
// Gate 2: all tasks checked (skipped when the schema has no tasks artifact)
|
|
2472
|
+
const tasksPaths = (status.artifactPaths && status.artifactPaths.tasks && status.artifactPaths.tasks.existingOutputPaths) || [];
|
|
2473
|
+
for (const tasksPath of tasksPaths) {
|
|
2474
|
+
if (/^\s*- \[ \]/m.test(readFileSync(tasksPath, 'utf-8'))) {
|
|
2475
|
+
return fail(`tasks gate failed — ${tasksPath.replace(`${projectDir}/`, '')} still has unchecked "- [ ]" items`);
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
// Gate 3: target archive must not exist
|
|
2480
|
+
const dateStamp = new Date().toISOString().slice(0, 10);
|
|
2481
|
+
const targetDir = join(changesDir, 'archive', `${dateStamp}-${name}`);
|
|
2482
|
+
const targetRel = targetDir.replace(`${projectDir}/`, '');
|
|
2483
|
+
if (existsSync(targetDir)) return fail(`archive gate failed — target already exists: ${targetRel}`);
|
|
2484
|
+
|
|
2485
|
+
// Sync decision for delta specs
|
|
2486
|
+
const deltaSpecs = listDeltaSpecFiles(changeRoot);
|
|
2487
|
+
let plan = [];
|
|
2488
|
+
let syncStatus = 'no delta specs';
|
|
2489
|
+
if (deltaSpecs.length) {
|
|
2490
|
+
if (opts.sync === undefined) {
|
|
2491
|
+
return fail(`change "${name}" has ${deltaSpecs.length} delta spec(s) — pass --sync to merge them into openspec/specs/, or --no-sync --force to archive without merging`);
|
|
2492
|
+
}
|
|
2493
|
+
if (opts.sync === false && !opts.force) {
|
|
2494
|
+
return fail('refusing --no-sync without --force — delta specs would be archived without merging into openspec/specs/');
|
|
2495
|
+
}
|
|
2496
|
+
if (opts.sync) {
|
|
2497
|
+
const result = planSpecSync(projectDir, deltaSpecs, name);
|
|
2498
|
+
if (result.conflicts.length) {
|
|
2499
|
+
for (const conflict of result.conflicts) console.error(`sync conflict: ${conflict}`);
|
|
2500
|
+
return fail('delta-spec merge refused — resolve conflicts manually with the openspec-sync-specs skill, then re-run archive');
|
|
2501
|
+
}
|
|
2502
|
+
plan = result.plan;
|
|
2503
|
+
// Snapshots of affected main specs are held in plan[].oldContent for rollback.
|
|
2504
|
+
for (const entry of plan) {
|
|
2505
|
+
mkdirSync(dirname(entry.mainPath), { recursive: true });
|
|
2506
|
+
writeFileSync(entry.mainPath, entry.newContent);
|
|
2507
|
+
}
|
|
2508
|
+
syncStatus = `synced ${plan.length} main spec file(s)`;
|
|
2509
|
+
} else {
|
|
2510
|
+
syncStatus = 'skipped (--no-sync --force)';
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
// Move change into the dated archive
|
|
2515
|
+
mkdirSync(dirname(targetDir), { recursive: true });
|
|
2516
|
+
renameSync(changeRoot, targetDir);
|
|
2517
|
+
|
|
2518
|
+
// Strict validation with full rollback on failure
|
|
2519
|
+
try {
|
|
2520
|
+
execSync('npx openspec validate --all --strict', {
|
|
2521
|
+
cwd: projectDir,
|
|
2522
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2523
|
+
encoding: 'utf-8',
|
|
2524
|
+
});
|
|
2525
|
+
} catch (e) {
|
|
2526
|
+
renameSync(targetDir, changeRoot);
|
|
2527
|
+
for (const entry of plan) {
|
|
2528
|
+
if (entry.existed) writeFileSync(entry.mainPath, entry.oldContent);
|
|
2529
|
+
else if (entry.dirExisted) rmSync(entry.mainPath, { force: true });
|
|
2530
|
+
else rmSync(dirname(entry.mainPath), { recursive: true, force: true });
|
|
2531
|
+
}
|
|
2532
|
+
const detail = `${e.stdout || ''}${e.stderr || ''}`.trim() || 'non-zero exit';
|
|
2533
|
+
console.error(detail);
|
|
2534
|
+
return fail('openspec validate --all --strict failed — rolled back: change restored, main specs reverted to pre-sync state');
|
|
2535
|
+
}
|
|
2536
|
+
|
|
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);
|
|
2545
|
+
const progress = parseTasksProgress(targetDir);
|
|
2546
|
+
const fields = {
|
|
2547
|
+
changeName: name,
|
|
2548
|
+
closedRole: 'Archiver',
|
|
2549
|
+
change: `- name: ${name}\n- status: archived`,
|
|
2550
|
+
done: `Change archived to ${targetRel}. Delta spec sync: ${syncStatus}. openspec validate --all --strict passed.`,
|
|
2551
|
+
decisions: 'none',
|
|
2552
|
+
blocked: 'none',
|
|
2553
|
+
nextCommand: 'none',
|
|
2554
|
+
nextRole: 'none',
|
|
2555
|
+
attach: `- \`${targetRel}/\``,
|
|
2556
|
+
spawn: 'none',
|
|
2557
|
+
constraints: 'Pipeline complete — no next session.',
|
|
2558
|
+
runtime: runtimeResult.value || 'local',
|
|
2559
|
+
agentId: resolveAgentId({}, process.env, priorFields),
|
|
2560
|
+
status: 'archived',
|
|
2561
|
+
tasks: progress ? `${progress.done}/${progress.total}` : '',
|
|
2562
|
+
review: parseReviewVerdict(targetDir) || '',
|
|
2563
|
+
summary: `archived to ${targetRel}`,
|
|
2564
|
+
};
|
|
2565
|
+
writeFileSync(join(targetDir, 'handoff.md'), `${buildHandoffMarkdown(fields).trim()}\n`);
|
|
2566
|
+
const memoryPath = persistMemoryFromHandoff(projectDir, fields);
|
|
2567
|
+
|
|
2568
|
+
console.log(`change: ${name}`);
|
|
2569
|
+
console.log(`schema: ${status.schemaName || 'unknown'}`);
|
|
2570
|
+
console.log(`archive: ${targetRel}`);
|
|
2571
|
+
console.log(`sync: ${syncStatus}`);
|
|
2572
|
+
console.log(`handoff: ${join(targetRel, 'handoff.md')} (next_command: none)`);
|
|
2573
|
+
console.log(`memory: ${memoryPath.replace(`${projectDir}/`, '')}`);
|
|
2574
|
+
log.ok(`archived ${name}`);
|
|
2575
|
+
});
|
|
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
|
+
|
|
1430
2604
|
program
|
|
1431
2605
|
.command('figma-setup')
|
|
1432
2606
|
.description('Create local Figma token env file (never prints the token)')
|
|
@@ -1593,6 +2767,9 @@ program
|
|
|
1593
2767
|
.option('--tasks <progress>', 'Task progress n/m')
|
|
1594
2768
|
.option('--review <verdict>', 'Review verdict')
|
|
1595
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)
|
|
1596
2773
|
.action((changeName, opts) => {
|
|
1597
2774
|
const projectDir = process.cwd();
|
|
1598
2775
|
const resolved = resolveHandoffChange(projectDir, changeName);
|
|
@@ -1634,6 +2811,16 @@ program
|
|
|
1634
2811
|
} else {
|
|
1635
2812
|
log.warn('handoff.md missing — using Memory JSON only');
|
|
1636
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
|
+
}
|
|
1637
2824
|
if (memoryItems.length) {
|
|
1638
2825
|
log.ok(`Memory entities: ${memoryItems.length} (${memoryPath})`);
|
|
1639
2826
|
} else {
|
|
@@ -1642,6 +2829,24 @@ program
|
|
|
1642
2829
|
return;
|
|
1643
2830
|
}
|
|
1644
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
|
+
|
|
1645
2850
|
console.error(pc.bold(pc.white(`\nhandoff persist ${name}`)));
|
|
1646
2851
|
if (!existsSync(changeDir)) {
|
|
1647
2852
|
log.err(`change not found: ${name}`);
|
|
@@ -1683,14 +2888,22 @@ program
|
|
|
1683
2888
|
fields.status = fields.review && /^APPROVE/i.test(fields.review) ? 'spec-approved' : 'in-progress';
|
|
1684
2889
|
}
|
|
1685
2890
|
|
|
2891
|
+
if (!applyRuntimeToFields(fields, opts, process.env)) {
|
|
2892
|
+
process.exitCode = 1;
|
|
2893
|
+
return;
|
|
2894
|
+
}
|
|
2895
|
+
|
|
1686
2896
|
const prompt = buildNextSessionPrompt(fields, agentLanguage).replace(/^\n+|\n+$/g, '');
|
|
1687
2897
|
fields.prompt = prompt;
|
|
1688
2898
|
writeFileSync(existing.filePath, `${buildHandoffMarkdown(fields).trim()}\n`);
|
|
1689
2899
|
console.error(pc.green(' ✓'), existing.filePath.replace(`${projectDir}/`, ''));
|
|
1690
2900
|
|
|
2901
|
+
appendDecisionsFromHandoff(projectDir, name, fields.decisions);
|
|
1691
2902
|
const memoryPath = persistMemoryFromHandoff(projectDir, fields);
|
|
1692
2903
|
console.error(pc.green(' ✓'), `Memory JSON upserted: ${memoryPath}`);
|
|
1693
2904
|
|
|
2905
|
+
if (fields.runtime === 'cloud') printCloudPersistNextSteps(name);
|
|
2906
|
+
|
|
1694
2907
|
console.error(pc.dim('Copy the prompt below into the next chat as one fenced block. Do not include this line.'));
|
|
1695
2908
|
process.stdout.write(`${prompt}\n`);
|
|
1696
2909
|
});
|