@orchestrator-claude/definitions 3.31.1 → 3.31.2

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.
@@ -0,0 +1,640 @@
1
+ /**
2
+ * capability-scanner.ts — Plugin/Agent/Vault capability scanner (RFC-027 Sprint 2)
3
+ *
4
+ * Scans the local filesystem to build a capability index:
5
+ * - Installed plugins (from ~/.claude/plugins/installed_plugins.json + cache dirs)
6
+ * - Agent definitions (from .claude/agents/*.md)
7
+ * - Vault state (from .vault-config.yaml)
8
+ *
9
+ * All functions are fail-open: they return defaults on error, never throw.
10
+ */
11
+
12
+ import { readFileSync, readdirSync, existsSync, statSync, writeFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
15
+
16
+ // ─── Types ───────────────────────────────────────────────
17
+
18
+ export interface PluginCapability {
19
+ name: string;
20
+ version: string;
21
+ skills: string[];
22
+ agents: string[];
23
+ commands: string[];
24
+ hasMcp: boolean;
25
+ hasHooks: boolean;
26
+ }
27
+
28
+ export interface VaultSummary {
29
+ tier: number;
30
+ stack: string;
31
+ maturity: number;
32
+ packs: string[];
33
+ }
34
+
35
+ export interface CapabilityIndex {
36
+ lastScanned: string;
37
+ installedPluginsMtime: string;
38
+ plugins: PluginCapability[];
39
+ agents: string[];
40
+ vault: VaultSummary;
41
+ routing: Record<string, string>;
42
+ gaps: string[];
43
+ }
44
+
45
+ // ─── Plugin Scanning ─────────────────────────────────────
46
+
47
+ interface InstalledPlugin {
48
+ scope: string;
49
+ projectPath: string;
50
+ installPath: string;
51
+ version: string;
52
+ }
53
+
54
+ interface InstalledPluginsFile {
55
+ version: number;
56
+ plugins: Record<string, InstalledPlugin[]>;
57
+ }
58
+
59
+ /**
60
+ * Extract plugin name from the key format "name@source".
61
+ */
62
+ function pluginNameFromKey(key: string): string {
63
+ return key.split('@')[0];
64
+ }
65
+
66
+ /**
67
+ * Scan a single plugin install directory for capabilities.
68
+ */
69
+ function scanSinglePlugin(name: string, installPath: string, version: string): PluginCapability {
70
+ const cap: PluginCapability = {
71
+ name,
72
+ version,
73
+ skills: [],
74
+ agents: [],
75
+ commands: [],
76
+ hasMcp: false,
77
+ hasHooks: false,
78
+ };
79
+
80
+ try {
81
+ const baseDir = installPath;
82
+ if (!existsSync(baseDir)) return cap;
83
+
84
+ // Skills: list subdirectory names under skills/
85
+ const skillsDir = join(baseDir, 'skills');
86
+ if (existsSync(skillsDir)) {
87
+ cap.skills = readdirSync(skillsDir, { withFileTypes: true })
88
+ .filter((d) => d.isDirectory())
89
+ .map((d) => d.name)
90
+ .sort();
91
+ }
92
+
93
+ // Agents: list .md filenames (sans extension) under agents/
94
+ const agentsDir = join(baseDir, 'agents');
95
+ if (existsSync(agentsDir)) {
96
+ cap.agents = readdirSync(agentsDir)
97
+ .filter((f) => f.endsWith('.md'))
98
+ .map((f) => f.replace(/\.md$/, ''))
99
+ .sort();
100
+ }
101
+
102
+ // Commands: list .md filenames (sans extension) under commands/
103
+ const commandsDir = join(baseDir, 'commands');
104
+ if (existsSync(commandsDir)) {
105
+ cap.commands = readdirSync(commandsDir)
106
+ .filter((f) => f.endsWith('.md'))
107
+ .map((f) => f.replace(/\.md$/, ''))
108
+ .sort();
109
+ }
110
+
111
+ // MCP: check .mcp.json exists
112
+ cap.hasMcp = existsSync(join(baseDir, '.mcp.json'));
113
+
114
+ // Hooks: single statSync to avoid TOCTOU race (IMP-001)
115
+ try {
116
+ cap.hasHooks = statSync(join(baseDir, 'hooks')).isDirectory();
117
+ } catch {
118
+ cap.hasHooks = false;
119
+ }
120
+ } catch {
121
+ // fail-open: return partial result
122
+ }
123
+
124
+ return cap;
125
+ }
126
+
127
+ /**
128
+ * Scan all installed plugins from installed_plugins.json.
129
+ * Returns empty array on any error (fail-open).
130
+ */
131
+ export function scanPluginCache(installedPluginsPath: string): PluginCapability[] {
132
+ try {
133
+ const raw = readFileSync(installedPluginsPath, 'utf-8');
134
+ const data = JSON.parse(raw) as InstalledPluginsFile;
135
+ if (!data.plugins || typeof data.plugins !== 'object') return [];
136
+
137
+ const result: PluginCapability[] = [];
138
+
139
+ for (const [key, entries] of Object.entries(data.plugins)) {
140
+ const name = pluginNameFromKey(key);
141
+ const entry = entries[0];
142
+ if (!entry) continue;
143
+
144
+ result.push(scanSinglePlugin(name, entry.installPath, entry.version));
145
+ }
146
+
147
+ return result.sort((a, b) => a.name.localeCompare(b.name));
148
+ } catch {
149
+ return [];
150
+ }
151
+ }
152
+
153
+ // ─── Agent Definition Scanning ───────────────────────────
154
+
155
+ /**
156
+ * Scan .claude/agents/ directory for agent definition .md files.
157
+ * Returns sorted list of agent names (filenames without .md extension).
158
+ */
159
+ export function scanAgentDefinitions(agentsDir: string): string[] {
160
+ try {
161
+ if (!existsSync(agentsDir)) return [];
162
+ return readdirSync(agentsDir)
163
+ .filter((f) => f.endsWith('.md'))
164
+ .map((f) => f.replace(/\.md$/, ''))
165
+ .sort();
166
+ } catch {
167
+ return [];
168
+ }
169
+ }
170
+
171
+ // ─── Vault Summary ───────────────────────────────────────
172
+
173
+ const DEFAULT_VAULT: VaultSummary = {
174
+ tier: 1,
175
+ stack: 'unknown',
176
+ maturity: 0,
177
+ packs: [],
178
+ };
179
+
180
+ /**
181
+ * Read vault summary from .vault-config.yaml.
182
+ * Returns defaults on any error (fail-open).
183
+ */
184
+ export function readVaultSummary(vaultConfigPath: string): VaultSummary {
185
+ try {
186
+ if (!existsSync(vaultConfigPath)) return { ...DEFAULT_VAULT };
187
+ const raw = readFileSync(vaultConfigPath, 'utf-8');
188
+ const config = parseYaml(raw) as Record<string, unknown>;
189
+
190
+ const project = config?.project as Record<string, unknown> | undefined;
191
+ const constitution = config?.constitution as Record<string, unknown> | undefined;
192
+ const maturityObj = project?.maturity as Record<string, unknown> | undefined;
193
+
194
+ return {
195
+ tier: (project?.tier as number) ?? 1,
196
+ stack: (project?.stack as string) ?? 'unknown',
197
+ maturity: (maturityObj?.current as number) ?? 0,
198
+ packs: constitution?.pack ? [constitution.pack as string] : [],
199
+ };
200
+ } catch {
201
+ return { ...DEFAULT_VAULT };
202
+ }
203
+ }
204
+
205
+ // ─── Invalidation ────────────────────────────────────────
206
+
207
+ /**
208
+ * Check if a rescan is needed by comparing installed_plugins.json mtime
209
+ * against the cached mtime string. Returns true if rescan needed.
210
+ */
211
+ export function needsRescan(installedPluginsPath: string, cachedMtime: string): boolean {
212
+ try {
213
+ if (!existsSync(installedPluginsPath)) return true;
214
+ const currentMtime = statSync(installedPluginsPath).mtime.toISOString();
215
+ return currentMtime !== cachedMtime;
216
+ } catch {
217
+ return true;
218
+ }
219
+ }
220
+
221
+ // ─── Routing Table ───────────────────────────────────────
222
+
223
+ /** Default routing when no plugins are installed */
224
+ const DEFAULT_ROUTING: Record<string, string> = {
225
+ analysis: 'researcher (sub-agent)',
226
+ spec: 'specifier (sub-agent)',
227
+ plan: 'planner (sub-agent)',
228
+ tasks: 'task-generator (sub-agent)',
229
+ implement: 'implementer (sub-agent)',
230
+ code_review: 'reviewer (sub-agent)',
231
+ pr_review: 'reviewer (sub-agent)',
232
+ design: 'brainstorming (native skill)',
233
+ debug: 'systematic-debugging (native skill)',
234
+ research: 'researcher (sub-agent)',
235
+ };
236
+
237
+ /** Plugin-based routing overrides. Checked in order. */
238
+ const PLUGIN_ROUTING_OVERRIDES: {
239
+ plugin: string;
240
+ capability?: string;
241
+ routes: Record<string, string>;
242
+ }[] = [
243
+ {
244
+ plugin: 'feature-dev',
245
+ capability: 'code-explorer',
246
+ routes: {
247
+ analysis: 'feature-dev:code-explorer',
248
+ },
249
+ },
250
+ {
251
+ plugin: 'coderabbit',
252
+ routes: {
253
+ pr_review: 'coderabbit:review',
254
+ },
255
+ },
256
+ ];
257
+
258
+ /**
259
+ * Build routing table from installed plugins.
260
+ * Starts with defaults, applies overrides for installed plugins.
261
+ */
262
+ export function buildRoutingTable(plugins: PluginCapability[]): Record<string, string> {
263
+ const routing = { ...DEFAULT_ROUTING };
264
+ const installedNames = new Set(plugins.map((p) => p.name));
265
+
266
+ for (const override of PLUGIN_ROUTING_OVERRIDES) {
267
+ if (!installedNames.has(override.plugin)) continue;
268
+
269
+ if (override.capability) {
270
+ const plugin = plugins.find((p) => p.name === override.plugin);
271
+ const hasCapability =
272
+ plugin?.agents.includes(override.capability) ||
273
+ plugin?.skills.includes(override.capability);
274
+ if (!hasCapability) continue;
275
+ }
276
+
277
+ Object.assign(routing, override.routes);
278
+ }
279
+
280
+ return routing;
281
+ }
282
+
283
+ // ─── Profile Comparison ──────────────────────────────────
284
+
285
+ interface PluginProfile {
286
+ core?: string[];
287
+ recommended?: string[];
288
+ optional?: string[];
289
+ }
290
+
291
+ interface PluginProfilesFile {
292
+ schema: string;
293
+ stacks: Record<string, PluginProfile>;
294
+ }
295
+
296
+ /**
297
+ * Compare installed plugins against stack profile.
298
+ * Returns gap descriptions (e.g., "MISSING core: serena, superpowers").
299
+ */
300
+ export function compareWithProfiles(
301
+ plugins: PluginCapability[],
302
+ profilesPath: string,
303
+ stack: string
304
+ ): string[] {
305
+ try {
306
+ if (!existsSync(profilesPath)) return [];
307
+ const raw = readFileSync(profilesPath, 'utf-8');
308
+ const profiles = parseYaml(raw) as PluginProfilesFile;
309
+
310
+ const stackProfile = profiles?.stacks?.[stack];
311
+ if (!stackProfile) return [];
312
+
313
+ const installedNames = new Set(plugins.map((p) => p.name));
314
+ const gaps: string[] = [];
315
+
316
+ const missingCore = (stackProfile.core ?? []).filter((p) => !installedNames.has(p));
317
+ if (missingCore.length > 0) {
318
+ gaps.push(`MISSING core: ${missingCore.join(', ')}`);
319
+ }
320
+
321
+ const missingRec = (stackProfile.recommended ?? []).filter((p) => !installedNames.has(p));
322
+ if (missingRec.length > 0) {
323
+ gaps.push(`MISSING recommended: ${missingRec.join(', ')}`);
324
+ }
325
+
326
+ return gaps;
327
+ } catch {
328
+ return [];
329
+ }
330
+ }
331
+
332
+ // ─── Full Index Builder ──────────────────────────────────
333
+
334
+ /**
335
+ * Build a complete CapabilityIndex by scanning all sources.
336
+ * This is the main entry point for the session-start hook.
337
+ */
338
+ export function buildCapabilityIndex(opts: {
339
+ installedPluginsPath: string;
340
+ agentsDir: string;
341
+ vaultConfigPath: string;
342
+ profilesPath: string;
343
+ }): CapabilityIndex {
344
+ const plugins = scanPluginCache(opts.installedPluginsPath);
345
+ const agents = scanAgentDefinitions(opts.agentsDir);
346
+ const vault = readVaultSummary(opts.vaultConfigPath);
347
+ const routing = buildRoutingTable(plugins);
348
+ const gaps = compareWithProfiles(plugins, opts.profilesPath, vault.stack);
349
+
350
+ let installedPluginsMtime = '';
351
+ try {
352
+ installedPluginsMtime = statSync(opts.installedPluginsPath).mtime.toISOString();
353
+ } catch {
354
+ // leave empty
355
+ }
356
+
357
+ return {
358
+ lastScanned: new Date().toISOString(),
359
+ installedPluginsMtime,
360
+ plugins,
361
+ agents,
362
+ vault,
363
+ routing,
364
+ gaps,
365
+ };
366
+ }
367
+
368
+ // ─── Vault Persistence ───────────────────────────────────
369
+
370
+ /**
371
+ * Write CapabilityIndex to the `capabilities:` section of vault config YAML.
372
+ * Performs surgical merge — only touches `capabilities:`, preserves all other sections.
373
+ * Fails silently on error.
374
+ */
375
+ export function writeCapabilitiesToVault(vaultConfigPath: string, index: CapabilityIndex): void {
376
+ try {
377
+ let config: Record<string, unknown> = {};
378
+ if (existsSync(vaultConfigPath)) {
379
+ const raw = readFileSync(vaultConfigPath, 'utf-8');
380
+ config = (parseYaml(raw) as Record<string, unknown>) ?? {};
381
+ }
382
+
383
+ const pluginEntries = index.plugins.map((p) => {
384
+ const entry: Record<string, unknown> = { name: p.name };
385
+ if (p.version !== 'unknown') entry.version = p.version;
386
+ if (p.skills.length > 0) entry.skills = p.skills;
387
+ if (p.agents.length > 0) entry.agents = p.agents;
388
+ if (p.commands.length > 0) entry.commands = p.commands;
389
+ if (p.hasMcp) entry.mcp = true;
390
+ if (p.hasHooks) entry.hooks = true;
391
+ return entry;
392
+ });
393
+
394
+ config.capabilities = {
395
+ last_scanned: index.lastScanned,
396
+ installed_plugins_mtime: index.installedPluginsMtime,
397
+ plugins: pluginEntries,
398
+ routing: index.routing,
399
+ gaps: index.gaps,
400
+ };
401
+
402
+ writeFileSync(vaultConfigPath, stringifyYaml(config, { lineWidth: 120 }));
403
+ } catch {
404
+ // fail-open
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Read cached CapabilityIndex from vault config's `capabilities:` section.
410
+ * Returns null if section doesn't exist or on any error.
411
+ */
412
+ export function readCapabilitiesFromVault(vaultConfigPath: string): CapabilityIndex | null {
413
+ try {
414
+ if (!existsSync(vaultConfigPath)) return null;
415
+ const raw = readFileSync(vaultConfigPath, 'utf-8');
416
+ const config = parseYaml(raw) as Record<string, unknown>;
417
+ const caps = config?.capabilities as Record<string, unknown> | undefined;
418
+ if (!caps) return null;
419
+
420
+ // Guard against malformed YAML where plugins is not an array (IMP-002)
421
+ const rawPlugins = Array.isArray(caps.plugins)
422
+ ? (caps.plugins as Record<string, unknown>[])
423
+ : [];
424
+ const plugins: PluginCapability[] = rawPlugins.map((p) => ({
425
+ name: (p.name as string) ?? '',
426
+ version: (p.version as string) ?? 'unknown',
427
+ skills: (p.skills as string[]) ?? [],
428
+ agents: (p.agents as string[]) ?? [],
429
+ commands: (p.commands as string[]) ?? [],
430
+ hasMcp: (p.mcp as boolean) ?? false,
431
+ hasHooks: (p.hooks as boolean) ?? false,
432
+ }));
433
+
434
+ return {
435
+ lastScanned: (caps.last_scanned as string) ?? '',
436
+ installedPluginsMtime: (caps.installed_plugins_mtime as string) ?? '',
437
+ plugins,
438
+ agents: [], // agents are always scanned fresh (fast, <10ms)
439
+ vault: readVaultSummary(vaultConfigPath),
440
+ routing: (caps.routing as Record<string, string>) ?? {},
441
+ gaps: (caps.gaps as string[]) ?? [],
442
+ };
443
+ } catch {
444
+ return null;
445
+ }
446
+ }
447
+
448
+ // ─── Formatting ──────────────────────────────────────────
449
+
450
+ /**
451
+ * Format a single plugin as a compact string: "name(Nsk,Nag)" or "name(mcp)".
452
+ */
453
+ function formatPluginCompact(p: PluginCapability): string {
454
+ const parts: string[] = [];
455
+ if (p.skills.length > 0) parts.push(`${p.skills.length}sk`);
456
+ if (p.agents.length > 0) parts.push(`${p.agents.length}ag`);
457
+ if (p.hasMcp && parts.length === 0) parts.push('mcp');
458
+ return parts.length > 0 ? `${p.name}(${parts.join(',')})` : p.name;
459
+ }
460
+
461
+ /**
462
+ * Format CapabilityIndex as compact multi-line injection (~80 tokens).
463
+ */
464
+ export function formatCapabilityMatrix(index: CapabilityIndex): string {
465
+ const pluginCount = index.plugins.length;
466
+ const pluginSummary = index.plugins
467
+ .filter((p) => p.skills.length > 0 || p.agents.length > 0 || p.hasMcp)
468
+ .map(formatPluginCompact)
469
+ .join(', ');
470
+
471
+ const agentList =
472
+ index.agents.length <= 6
473
+ ? index.agents.join(',')
474
+ : `${index.agents.slice(0, 6).join(',')},+${index.agents.length - 6}`;
475
+
476
+ const routingPairs = Object.entries(index.routing)
477
+ .filter(([, v]) => !v.includes('sub-agent') && v !== 'Main inline')
478
+ .map(([k, v]) => `${k}→${v}`)
479
+ .join(' | ');
480
+
481
+ const gapsLine = index.gaps.length > 0 ? index.gaps.join('; ') : 'none';
482
+
483
+ return [
484
+ '[CAPABILITY-MATRIX]',
485
+ `plugins(${pluginCount}): ${pluginSummary || 'none with capabilities'}`,
486
+ `agents(${index.agents.length}): ${agentList || 'none'}`,
487
+ `vault: tier=${index.vault.tier}, stack=${index.vault.stack}, maturity=${index.vault.maturity}, packs=[${index.vault.packs.join(',')}]`,
488
+ `routing: ${routingPairs || 'defaults only'}`,
489
+ `gaps: ${gapsLine}`,
490
+ ].join('\n');
491
+ }
492
+
493
+ // ─── Memory Context ──────────────────────────────────────
494
+
495
+ export interface MemoryEntry {
496
+ file: string;
497
+ type: string;
498
+ tags: string[];
499
+ last_accessed: string;
500
+ summary: string;
501
+ }
502
+
503
+ /**
504
+ * Format filtered memory entries as compact injection.
505
+ * Returns empty string if no entries (skip injection).
506
+ */
507
+ export function formatMemoryContext(entries: MemoryEntry[]): string {
508
+ if (entries.length === 0) return '';
509
+
510
+ const byType: Record<string, number> = {};
511
+ for (const e of entries) {
512
+ byType[e.type] = (byType[e.type] ?? 0) + 1;
513
+ }
514
+ const typeSummary = Object.entries(byType)
515
+ .map(([t, n]) => `${n} ${t}`)
516
+ .join(', ');
517
+
518
+ const recentNames = entries
519
+ .slice(0, 5)
520
+ .map((e) => e.file.replace(/\.md$/, '').replace(/^(feedback|project|reference|user)_/, ''))
521
+ .join(', ');
522
+
523
+ return [
524
+ `[MEMORY-CONTEXT] ${entries.length} entries: ${typeSummary}`,
525
+ `recent: ${recentNames}`,
526
+ ].join('\n');
527
+ }
528
+
529
+ // ─── Memory Index Reading ────────────────────────────────
530
+
531
+ interface MemoryIndex {
532
+ schema: string;
533
+ entries: MemoryEntry[];
534
+ }
535
+
536
+ /**
537
+ * Read memory-index.yaml and return parsed entries.
538
+ * Returns null if file missing or corrupt (fail-open).
539
+ */
540
+ export function readMemoryIndex(indexPath: string): MemoryEntry[] | null {
541
+ try {
542
+ if (!existsSync(indexPath)) return null;
543
+ const raw = readFileSync(indexPath, 'utf-8');
544
+ const parsed = parseYaml(raw) as MemoryIndex;
545
+ return parsed?.entries ?? null;
546
+ } catch {
547
+ return null;
548
+ }
549
+ }
550
+
551
+ /**
552
+ * Filter memory entries by relevance for session injection.
553
+ * Keeps: recent (14 days), all feedback, tag matches.
554
+ * Returns max 20 entries, sorted by type priority then recency.
555
+ */
556
+ export function filterRelevantMemories(
557
+ entries: MemoryEntry[],
558
+ contextTags: string[] = []
559
+ ): MemoryEntry[] {
560
+ const now = Date.now();
561
+ const fourteenDays = 14 * 24 * 60 * 60 * 1000;
562
+
563
+ const relevant = entries.filter((e) => {
564
+ if (e.type === 'feedback') return true;
565
+ const accessed = new Date(e.last_accessed).getTime();
566
+ if (now - accessed < fourteenDays) return true;
567
+ if (contextTags.length > 0 && e.tags.some((t) => contextTags.includes(t))) return true;
568
+ return false;
569
+ });
570
+
571
+ const typePriority: Record<string, number> = {
572
+ feedback: 0,
573
+ project: 1,
574
+ reference: 2,
575
+ user: 3,
576
+ };
577
+ relevant.sort((a, b) => {
578
+ const pa = typePriority[a.type] ?? 9;
579
+ const pb = typePriority[b.type] ?? 9;
580
+ if (pa !== pb) return pa - pb;
581
+ return b.last_accessed.localeCompare(a.last_accessed);
582
+ });
583
+
584
+ return relevant.slice(0, 20);
585
+ }
586
+
587
+ /**
588
+ * Find memory entries that are candidates for archiving.
589
+ * Criteria: last_accessed >90 days ago, OR status: superseded in .md frontmatter.
590
+ * Fail-open: invalid dates or file read errors → entry skipped.
591
+ */
592
+ export function findArchiveCandidates(
593
+ entries: MemoryEntry[],
594
+ today: Date,
595
+ memoryDir?: string
596
+ ): MemoryEntry[] {
597
+ const ninetyDays = 90 * 24 * 60 * 60 * 1000;
598
+ const candidateSet = new Set<string>();
599
+ const candidates: MemoryEntry[] = [];
600
+
601
+ for (const entry of entries) {
602
+ try {
603
+ let isCandidate = false;
604
+
605
+ // Check age
606
+ const accessed = new Date(entry.last_accessed).getTime();
607
+ if (!isNaN(accessed) && today.getTime() - accessed > ninetyDays) {
608
+ isCandidate = true;
609
+ }
610
+
611
+ // Check superseded status in frontmatter (lazy — only when memoryDir provided)
612
+ if (!isCandidate && memoryDir) {
613
+ try {
614
+ const filePath = join(memoryDir, entry.file);
615
+ if (existsSync(filePath)) {
616
+ const content = readFileSync(filePath, 'utf-8');
617
+ const fmMatch = /^---\n([\s\S]*?)\n---/.exec(content);
618
+ if (fmMatch) {
619
+ const fm = parseYaml(fmMatch[1]) as Record<string, unknown>;
620
+ if (fm?.status === 'superseded') {
621
+ isCandidate = true;
622
+ }
623
+ }
624
+ }
625
+ } catch {
626
+ // file read error → skip this check
627
+ }
628
+ }
629
+
630
+ if (isCandidate && !candidateSet.has(entry.file)) {
631
+ candidateSet.add(entry.file);
632
+ candidates.push(entry);
633
+ }
634
+ } catch {
635
+ // any error for this entry → skip
636
+ }
637
+ }
638
+
639
+ return candidates;
640
+ }