kushi-agents 6.4.0 → 6.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kushi-agents",
3
- "version": "6.4.0",
3
+ "version": "6.6.0",
4
4
  "description": "Install Kushi — multi-source project evidence agent with Comprehensive Structured Capture (CSC) into weekly-only files across Email, Teams, OneNote, Loop, SharePoint, Meetings, CRM, ADO. Meetings retain a sibling verbatim/ audit folder. WorkIQ-only for M365 sources (Graph / m365_* FORBIDDEN as fallbacks; user-paste is first-class). Host-agnostic.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -234,8 +234,8 @@ async function interactiveSetup({ workspace, dryRun }) {
234
234
  function emit(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); }
235
235
 
236
236
  const INTEGRATIONS_TEMPLATE = {
237
- crm: { instance: 'https://iscrm.crm.dynamics.com', table: 'incidents', request_id: null, record_id: null },
238
- ado: { organization: 'IndustrySolutions', project: 'IS Engagements', apiVersion: '7.1', engagement_id: null },
237
+ crm: { instance: 'https://iscrm.crm.dynamics.com', table: 'incidents', request_id: '<__FILL_ME_IN__>', record_id: '<__FILL_ME_IN__>' },
238
+ ado: { organization: 'IndustrySolutions', project: 'IS Engagements', apiVersion: '7.1', engagement_id: '<__FILL_ME_IN__>' },
239
239
  sharepoint: { allowed_tenants: [] },
240
240
  };
241
241
 
@@ -21,6 +21,8 @@ import { projectRoot, evidenceRoot, aliasRoot, projectSharedFile, userFile } fro
21
21
  import { writeAtomic, pathExists } from './lib/evidence.mjs';
22
22
  import { ask as workiqAsk, resolveWorkiqBin } from './lib/workiq.mjs';
23
23
  import { loadM365Auth, scopeForSource } from './lib/m365-auth.mjs';
24
+ import { discoverCrm } from './lib/discover-crm.mjs';
25
+ import { discoverAdo } from './lib/discover-ado.mjs';
24
26
 
25
27
  const ALL_SOURCES = ['email', 'teams', 'meetings', 'onenote', 'sharepoint', 'crm', 'ado'];
26
28
 
@@ -252,17 +254,26 @@ function applyRows(source, rows, currentBounds, currentInteg) {
252
254
  }
253
255
  if (source === 'meetings') {
254
256
  const existing = currentBounds.meetings?.joinUrls || [];
257
+ // v6.5.0: meeting boundaries MUST be real http(s) join URLs — pull-meetings
258
+ // can't resolve a subject string into a meeting at the WorkIQ layer (unlike
259
+ // teams chat topics). Reject anything that isn't a URL; track rejected
260
+ // subjects in `accepted` log via reason field for discover-report visibility.
261
+ const rejectedSubjects = [];
255
262
  const incoming = rows.map(r => {
256
263
  const url = r.join_url;
257
- if (url && !isPlaceholder(url) && isValidValueFor('meetings', 'join_url', url) && url.startsWith('http')) return url;
264
+ if (url && !isPlaceholder(url) && isValidValueFor('meetings', 'join_url', url) && /^https?:\/\//.test(url)) return url;
258
265
  const subj = r.subject;
259
- if (subj && !isPlaceholder(subj)) return subj;
266
+ if (subj && !isPlaceholder(subj)) rejectedSubjects.push(subj);
260
267
  return null;
261
268
  }).filter(Boolean);
262
269
  const merged = dedup([...existing, ...incoming]);
263
270
  const added = merged.filter(v => !existing.includes(v));
264
271
  if (added.length) accepted.push(...added);
265
- return { boundariesPatch: added.length ? { meetings: { joinUrls: merged } } : null, accepted };
272
+ return {
273
+ boundariesPatch: added.length ? { meetings: { joinUrls: merged } } : null,
274
+ accepted,
275
+ rejected: rejectedSubjects.length ? rejectedSubjects.map(s => ({ subject: s, reason: 'no-join-url' })) : undefined,
276
+ };
266
277
  }
267
278
  if (source === 'onenote') {
268
279
  const existing = currentBounds.onenote?.section_file_ids || [];
@@ -303,7 +314,7 @@ function applyRows(source, rows, currentBounds, currentInteg) {
303
314
  isValidValueFor('crm', 'request_id', r.request_id) ||
304
315
  isValidValueFor('crm', 'incident_number', r.incident_number)
305
316
  );
306
- if (!top) return { integrationsPatch: null, accepted: [] };
317
+ if (!top) return { integrationsPatch: null, accepted: [], unresolved: 'crm.request_id' };
307
318
  const id = isValidValueFor('crm', 'request_id', top.request_id) ? top.request_id : top.incident_number;
308
319
  const patch = { crm: { ...cur, request_id: id } };
309
320
  accepted.push(id);
@@ -318,7 +329,7 @@ function applyRows(source, rows, currentBounds, currentInteg) {
318
329
  isValidValueFor('ado', 'engagement_id', r.engagement_id) ||
319
330
  isValidValueFor('ado', 'work_item_id', r.work_item_id)
320
331
  );
321
- if (!top) return { integrationsPatch: null, accepted: [] };
332
+ if (!top) return { integrationsPatch: null, accepted: [], unresolved: 'ado.engagement_id' };
322
333
  const id = isValidValueFor('ado', 'engagement_id', top.engagement_id) ? top.engagement_id : top.work_item_id;
323
334
  const patch = { ado: { ...cur, engagement_id: id } };
324
335
  accepted.push(id);
@@ -398,6 +409,76 @@ async function main() {
398
409
  sourceResults.push({ source, asked: false, found: 0, accepted: [], skipped_reason: 'disabled-in-config' });
399
410
  continue;
400
411
  }
412
+ // CRM and ADO are NEVER WorkIQ — they use mapped REST endpoints + fuzzy match
413
+ // (per fuzzy-disambiguation.instructions.md, v4.4.7+). Handle them inline.
414
+ if (source === 'crm' || source === 'ado') {
415
+ const projectName = path.basename(root);
416
+ const t0r = Date.now();
417
+ log(`[${idx}/${total}] ${source}: REST fuzzy-match against ${source === 'crm' ? 'Dataverse' : 'Azure DevOps'} (mapped settings)`);
418
+ const result = source === 'crm'
419
+ ? await discoverCrm(projectName, integ.crm, {})
420
+ : await discoverAdo(projectName, integ.ado, {});
421
+ const elapsed = Date.now() - t0r;
422
+ // Persist raw result so users can inspect candidates that didn't auto-pick.
423
+ if (!args.dryRun) {
424
+ try {
425
+ const discoveryDir = path.join(aliasRoot(args.project, args.alias), '_discovery');
426
+ await fs.mkdir(discoveryDir, { recursive: true });
427
+ const header = `# discover ${source} @ ${new Date().toISOString()}\n# elapsed=${elapsed}ms decision=${result.decision} confidence=${result.confidence || ''} candidates=${result.candidates?.length || 0}${result.error ? ' error=' + result.error : ''}\n`;
428
+ await fs.writeFile(
429
+ path.join(discoveryDir, `${source}-raw.txt`),
430
+ header + JSON.stringify({ picked: result.picked, candidates: result.candidates, error: result.error }, null, 2),
431
+ 'utf8',
432
+ );
433
+ } catch { /* best-effort */ }
434
+ }
435
+ let accepted = [];
436
+ let unresolved = null;
437
+ let integrationsPatch = null;
438
+ if (result.decision === 'auto' && result.picked) {
439
+ if (source === 'crm') {
440
+ const cur = integ.crm || {};
441
+ const id = result.picked.id;
442
+ integrationsPatch = { crm: { ...cur, request_id: id } };
443
+ accepted.push(id);
444
+ log(` crm: ✓ resolved → "${result.picked.title}" (${id}) [score=${result.picked.score}, confidence=${result.confidence}]`);
445
+ } else {
446
+ const cur = integ.ado || {};
447
+ const id = result.picked.id;
448
+ integrationsPatch = { ado: { ...cur, engagement_id: id } };
449
+ accepted.push(id);
450
+ log(` ado: ✓ resolved → "${result.picked.title}" (${id}) [score=${result.picked.score}, confidence=${result.confidence}]`);
451
+ }
452
+ } else {
453
+ unresolved = source === 'crm' ? 'crm.request_id' : 'ado.engagement_id';
454
+ if (result.error) {
455
+ log(` ${source}: ✗ ${result.error}`);
456
+ } else if (result.candidates?.length) {
457
+ log(` ${source}: ⚠ ${result.candidates.length} candidate(s) but none confident enough — see _discovery/${source}-raw.txt`);
458
+ for (const c of result.candidates.slice(0, 5)) {
459
+ log(` • ${c.id} · "${c.title}" [score=${c.score}, kind=${c.kind}]`);
460
+ }
461
+ } else {
462
+ log(` ${source}: ⚠ no candidates matched project name`);
463
+ }
464
+ }
465
+ if (integrationsPatch) {
466
+ Object.assign(integ, mergeShallow(integ, integrationsPatch));
467
+ integDirty = true;
468
+ }
469
+ sourceResults.push({
470
+ source,
471
+ asked: true,
472
+ found: result.candidates?.length || 0,
473
+ accepted,
474
+ unresolved,
475
+ candidates: result.candidates?.slice(0, 5) || [],
476
+ error: result.error,
477
+ skipped_reason: result.error ? 'rest-error' : (unresolved ? 'no-confident-match' : null),
478
+ });
479
+ continue;
480
+ }
481
+
401
482
  const prompt = buildPrompt(source, path.basename(root), scope);
402
483
  let rows = [];
403
484
  let asked = true;
@@ -448,7 +529,7 @@ async function main() {
448
529
  : `${skipReason} after ${elapsed}ms: ${(e.message || '').split('\n')[0].slice(0, 200)}`;
449
530
  log(` ${source}: ✗ ${detail}`);
450
531
  }
451
- const { boundariesPatch, integrationsPatch, accepted } = applyRows(source, rows, bounds, integ);
532
+ const { boundariesPatch, integrationsPatch, accepted, rejected, unresolved } = applyRows(source, rows, bounds, integ);
452
533
  if (boundariesPatch) {
453
534
  Object.assign(bounds, mergeShallow(bounds, boundariesPatch));
454
535
  boundsDirty = true;
@@ -457,7 +538,7 @@ async function main() {
457
538
  Object.assign(integ, mergeShallow(integ, integrationsPatch));
458
539
  integDirty = true;
459
540
  }
460
- sourceResults.push({ source, asked, found: rows.length, accepted, skipped_reason: skipReason });
541
+ sourceResults.push({ source, asked, found: rows.length, accepted, rejected, unresolved, skipped_reason: skipReason });
461
542
  }
462
543
 
463
544
  log(`done: ${sourceResults.filter(r => r.found > 0).length}/${total} sources returned data`);
@@ -0,0 +1,73 @@
1
+ // plugin/runners/lib/discover-ado.mjs
2
+ // Direct ADO WIQL fuzzy search for project -> Engagement work item.
3
+ // Replaces WorkIQ for ado discovery — uses mapped org/project from integrations.yml.
4
+
5
+ import { fetchWithRetry } from './http.mjs';
6
+ import { authHeader, SCOPES } from './identity.mjs';
7
+ import { rank, decide } from './fuzzy-match.mjs';
8
+
9
+ /**
10
+ * @param {string} projectName
11
+ * @param {{organization?:string, project?:string, apiVersion?:string, workItemType?:string}} adoConfig
12
+ * @param {{fetcher?:Function, headersOverride?:object}} [opts]
13
+ */
14
+ export async function discoverAdo(projectName, adoConfig, opts = {}) {
15
+ const org = adoConfig?.organization;
16
+ const project = adoConfig?.project;
17
+ const apiVersion = adoConfig?.apiVersion || '7.1';
18
+ const witType = adoConfig?.workItemTypes?.engagement || 'Engagement';
19
+ if (!org || !project || /<.*>/.test(org) || /<.*>/.test(project)) {
20
+ return { decision: 'unresolved', candidates: [], error: 'ado organization/project not configured', source: 'ado' };
21
+ }
22
+ const wiql = `SELECT [System.Id],[System.Title],[System.WorkItemType] FROM workitems WHERE [System.TeamProject]='${escapeWiql(project)}' AND [System.WorkItemType]='${escapeWiql(witType)}' AND [System.Title] CONTAINS '${escapeWiql(projectName)}'`;
23
+ const url = `https://dev.azure.com/${encodeURIComponent(org)}/${encodeURIComponent(project)}/_apis/wit/wiql?api-version=${apiVersion}`;
24
+ let headers;
25
+ try {
26
+ headers = opts.headersOverride || await authHeader(SCOPES.ado, { 'Content-Type': 'application/json' });
27
+ } catch (e) {
28
+ return { decision: 'unresolved', candidates: [], error: `ado auth failed: ${e.message}`, source: 'ado' };
29
+ }
30
+ let res;
31
+ try {
32
+ res = await fetchWithRetry(url, {
33
+ method: 'POST',
34
+ headers,
35
+ body: JSON.stringify({ query: wiql }),
36
+ fetcher: opts.fetcher,
37
+ retries: 2,
38
+ });
39
+ } catch (e) {
40
+ return { decision: 'unresolved', candidates: [], error: `ado wiql fetch failed: ${e.message}`, source: 'ado' };
41
+ }
42
+ if (!res.ok) {
43
+ const body = await safeText(res);
44
+ return { decision: 'unresolved', candidates: [], error: `ado http ${res.status}: ${body.slice(0, 200)}`, source: 'ado' };
45
+ }
46
+ const wiqlData = await res.json().catch(() => ({}));
47
+ const ids = (wiqlData.workItems || []).map(w => w.id).filter(Boolean).slice(0, 50);
48
+ if (!ids.length) return { decision: 'unresolved', candidates: [], source: 'ado' };
49
+
50
+ // Hydrate titles via /workitems?ids=...
51
+ const detailsUrl = `https://dev.azure.com/${encodeURIComponent(org)}/${encodeURIComponent(project)}/_apis/wit/workitems?ids=${ids.join(',')}&fields=System.Id,System.Title,System.WorkItemType,System.State&api-version=${apiVersion}`;
52
+ let dres;
53
+ try {
54
+ dres = await fetchWithRetry(detailsUrl, { headers, fetcher: opts.fetcher, retries: 2 });
55
+ } catch (e) {
56
+ return { decision: 'unresolved', candidates: [], error: `ado workitems fetch failed: ${e.message}`, source: 'ado' };
57
+ }
58
+ if (!dres.ok) {
59
+ const body = await safeText(dres);
60
+ return { decision: 'unresolved', candidates: [], error: `ado workitems http ${dres.status}: ${body.slice(0, 200)}`, source: 'ado' };
61
+ }
62
+ const detData = await dres.json().catch(() => ({}));
63
+ const candidates = (detData.value || []).map(w => ({
64
+ id: String(w.id),
65
+ title: w.fields?.['System.Title'] || '',
66
+ secondary: `${w.fields?.['System.WorkItemType'] || ''} · ${w.fields?.['System.State'] || ''}`,
67
+ })).filter(c => c.title);
68
+ const ranked = rank(projectName, candidates);
69
+ return { ...decide(ranked), source: 'ado' };
70
+ }
71
+
72
+ function escapeWiql(v) { return String(v).replace(/'/g, "''"); }
73
+ async function safeText(res) { try { return await res.text(); } catch { return ''; } }
@@ -0,0 +1,60 @@
1
+ // plugin/runners/lib/discover-crm.mjs
2
+ // Direct Dataverse OData fuzzy search for project -> CRM record.
3
+ // Replaces WorkIQ for crm discovery — uses mapped instance from integrations.yml.
4
+
5
+ import { fetchWithRetry, encodeODataOp } from './http.mjs';
6
+ import { authHeader } from './identity.mjs';
7
+ import { SCOPES } from './identity.mjs';
8
+ import { rank, decide } from './fuzzy-match.mjs';
9
+
10
+ /**
11
+ * @param {string} projectName
12
+ * @param {{instance?:string, table?:string}} integ.crm
13
+ * @param {{fetcher?:Function, headersOverride?:object}} [opts]
14
+ * @returns {Promise<{decision, picked?, candidates, error?, source:'crm'}>}
15
+ */
16
+ export async function discoverCrm(projectName, crmConfig, opts = {}) {
17
+ const instance = (crmConfig?.instance || '').replace(/\/$/, '');
18
+ if (!instance || /<.*>/.test(instance)) {
19
+ return { decision: 'unresolved', candidates: [], error: 'crm.instance not configured', source: 'crm' };
20
+ }
21
+ const table = crmConfig?.table || 'incidents';
22
+ const titleField = table === 'incidents' ? 'title' : 'name';
23
+ const filter = `contains(${titleField},'${escapeOData(projectName)}') or contains(description,'${escapeOData(projectName)}')`;
24
+ const select = table === 'incidents'
25
+ ? 'incidentid,title,ticketnumber,description'
26
+ : `${table}id,${titleField}`;
27
+ const url = `${instance}/api/data/v9.2/${table}?${encodeODataOp('$filter')}=${encodeURIComponent(filter)}&${encodeODataOp('$select')}=${encodeURIComponent(select)}&${encodeODataOp('$top')}=20`;
28
+ let headers;
29
+ try {
30
+ headers = opts.headersOverride || await authHeader(SCOPES.dataverse(instance), {
31
+ 'OData-MaxVersion': '4.0',
32
+ 'OData-Version': '4.0',
33
+ Prefer: 'odata.include-annotations="*"',
34
+ });
35
+ } catch (e) {
36
+ return { decision: 'unresolved', candidates: [], error: `crm auth failed: ${e.message}`, source: 'crm' };
37
+ }
38
+ let res;
39
+ try {
40
+ res = await fetchWithRetry(url, { headers, fetcher: opts.fetcher, retries: 2 });
41
+ } catch (e) {
42
+ return { decision: 'unresolved', candidates: [], error: `crm fetch failed: ${e.message}`, source: 'crm' };
43
+ }
44
+ if (!res.ok) {
45
+ const body = await safeText(res);
46
+ return { decision: 'unresolved', candidates: [], error: `crm http ${res.status}: ${body.slice(0, 200)}`, source: 'crm' };
47
+ }
48
+ const data = await res.json().catch(() => ({}));
49
+ const rows = Array.isArray(data.value) ? data.value : [];
50
+ const candidates = rows.map(r => ({
51
+ id: r.ticketnumber || r.incidentid || r[`${table}id`] || '',
52
+ title: r.title || r[titleField] || '',
53
+ secondary: r.ticketnumber ? `incidentid=${r.incidentid}` : '',
54
+ })).filter(c => c.id && c.title);
55
+ const ranked = rank(projectName, candidates);
56
+ return { ...decide(ranked), source: 'crm' };
57
+ }
58
+
59
+ function escapeOData(v) { return String(v).replace(/'/g, "''"); }
60
+ async function safeText(res) { try { return await res.text(); } catch { return ''; } }
@@ -0,0 +1,95 @@
1
+ // plugin/runners/lib/fuzzy-match.mjs
2
+ // Universal fuzzy disambiguation per
3
+ // plugin/instructions/fuzzy-disambiguation.instructions.md (v4.4.7+).
4
+ //
5
+ // Score lexicographic order:
6
+ // exact (case-insensitive) ........... 1000
7
+ // prefix (target startsWith query) ... 800 - max(0, target.length - query.length)
8
+ // contains (target includes query) ... 500 - distance-from-start
9
+ // token-overlap (>=1 shared token) ... 300 + matchedTokens * 10
10
+ // levenshtein (<=2 edits) ............ 100 + (3 - distance) * 10
11
+ // else ............................... 0
12
+ //
13
+ // Decision rule (top 5):
14
+ // top >= 800 AND #2 <= top * 0.7 -> auto-pick (confidence: 'high')
15
+ // top >= 300 AND #2 <= top * 0.7 -> auto-pick warn (confidence: 'medium')
16
+ // else -> unresolved + return ranked candidates
17
+
18
+ const norm = (s) => String(s ?? '').trim().toLowerCase();
19
+
20
+ function levenshtein(a, b) {
21
+ if (a === b) return 0;
22
+ const m = a.length, n = b.length;
23
+ if (!m) return n;
24
+ if (!n) return m;
25
+ if (Math.abs(m - n) > 2) return Infinity; // we only care about <=2
26
+ const prev = new Array(n + 1);
27
+ const curr = new Array(n + 1);
28
+ for (let j = 0; j <= n; j++) prev[j] = j;
29
+ for (let i = 1; i <= m; i++) {
30
+ curr[0] = i;
31
+ let row = i;
32
+ for (let j = 1; j <= n; j++) {
33
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
34
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
35
+ if (curr[j] < row) row = curr[j];
36
+ }
37
+ if (row > 2) return Infinity; // early-exit
38
+ for (let j = 0; j <= n; j++) prev[j] = curr[j];
39
+ }
40
+ return prev[n];
41
+ }
42
+
43
+ /**
44
+ * Score a single candidate name against a query.
45
+ * @param {string} query
46
+ * @param {string} target
47
+ * @returns {{score:number, kind:string}}
48
+ */
49
+ export function scoreOne(query, target) {
50
+ const q = norm(query);
51
+ const t = norm(target);
52
+ if (!q || !t) return { score: 0, kind: 'empty' };
53
+ if (q === t) return { score: 1000, kind: 'exact' };
54
+ if (t.startsWith(q)) return { score: 800 - Math.max(0, t.length - q.length), kind: 'prefix' };
55
+ const idx = t.indexOf(q);
56
+ if (idx >= 0) return { score: 500 - idx, kind: 'contains' };
57
+ const qTokens = q.split(/\s+/).filter(Boolean);
58
+ const tTokens = t.split(/\s+/).filter(Boolean);
59
+ const shared = qTokens.filter(x => tTokens.includes(x)).length;
60
+ if (shared >= 1) return { score: 300 + shared * 10, kind: 'token-overlap' };
61
+ const d = levenshtein(q, t);
62
+ if (d <= 2) return { score: 100 + (3 - d) * 10, kind: `levenshtein-${d}` };
63
+ return { score: 0, kind: 'none' };
64
+ }
65
+
66
+ /**
67
+ * Score and rank an array of candidates.
68
+ * @param {string} query
69
+ * @param {Array<{id:string, title:string, [k:string]:any}>} candidates
70
+ * @returns {Array} sorted desc by score (input shape preserved + score + kind)
71
+ */
72
+ export function rank(query, candidates) {
73
+ const out = [];
74
+ for (const c of candidates) {
75
+ const { score, kind } = scoreOne(query, c.title || c.name || '');
76
+ if (score > 0) out.push({ ...c, score, kind });
77
+ }
78
+ out.sort((a, b) => b.score - a.score);
79
+ return out;
80
+ }
81
+
82
+ /**
83
+ * Apply the v4.4.7 decision rule.
84
+ * @returns {{decision:'auto'|'unresolved', confidence?:string, picked?:object, candidates:Array}}
85
+ */
86
+ export function decide(ranked) {
87
+ const top5 = ranked.slice(0, 5);
88
+ if (!top5.length) return { decision: 'unresolved', candidates: [] };
89
+ const top = top5[0];
90
+ const second = top5[1];
91
+ const gapOk = !second || second.score <= top.score * 0.7;
92
+ if (top.score >= 800 && gapOk) return { decision: 'auto', confidence: 'high', picked: top, candidates: top5 };
93
+ if (top.score >= 300 && gapOk) return { decision: 'auto', confidence: 'medium', picked: top, candidates: top5 };
94
+ return { decision: 'unresolved', candidates: top5 };
95
+ }
@@ -73,13 +73,14 @@ function emit(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); }
73
73
  */
74
74
  export function buildTargets(merged) {
75
75
  const targets = [];
76
+ const isPlaceholder = (v) => v == null || /^<.*>$/.test(String(v).trim()) || /^(unknown|n\/a|none|null|tbd|todo)$/i.test(String(v).trim());
76
77
  // crm: from integrations
77
78
  const crm = merged.crm || {};
78
79
  const crmEntity = crm.request_id || crm.record_id;
79
- if (crmEntity) targets.push({ source: 'crm', entity: String(crmEntity) });
80
+ if (crmEntity && !isPlaceholder(crmEntity)) targets.push({ source: 'crm', entity: String(crmEntity) });
80
81
  // ado
81
82
  const ado = merged.ado || {};
82
- if (ado.engagement_id) targets.push({ source: 'ado', entity: String(ado.engagement_id) });
83
+ if (ado.engagement_id && !isPlaceholder(ado.engagement_id)) targets.push({ source: 'ado', entity: String(ado.engagement_id) });
83
84
  // email: per-user mailbox folders
84
85
  const email = merged.email || {};
85
86
  for (const f of (email.folders || [])) {
@@ -0,0 +1,81 @@
1
+ // plugin/runners/test/unit/discover-rest.test.mjs
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { discoverCrm } from '../../lib/discover-crm.mjs';
5
+ import { discoverAdo } from '../../lib/discover-ado.mjs';
6
+
7
+ function mockResponse({ status = 200, json = {} } = {}) {
8
+ return {
9
+ ok: status >= 200 && status < 300,
10
+ status,
11
+ statusText: 'mock',
12
+ headers: { get: () => null },
13
+ json: async () => json,
14
+ text: async () => JSON.stringify(json),
15
+ };
16
+ }
17
+
18
+ test('discoverCrm: returns unresolved when instance not configured', async () => {
19
+ const r = await discoverCrm('HCA', { instance: '<__FILL_ME_IN__>' });
20
+ assert.equal(r.decision, 'unresolved');
21
+ assert.match(r.error, /not configured/);
22
+ });
23
+
24
+ test('discoverCrm: auto-picks high-confidence match', async () => {
25
+ const fetcher = async () => mockResponse({
26
+ json: { value: [
27
+ { incidentid: 'g-1', title: 'HCA Agentic Engineering', ticketnumber: 'FE-2026-001458' },
28
+ { incidentid: 'g-2', title: 'unrelated', ticketnumber: 'FE-2025-000001' },
29
+ ] },
30
+ });
31
+ const r = await discoverCrm('HCA Agentic Engineering', { instance: 'https://iscrm.crm.dynamics.com' }, {
32
+ fetcher, headersOverride: { Authorization: 'Bearer fake' },
33
+ });
34
+ assert.equal(r.decision, 'auto');
35
+ assert.equal(r.picked.id, 'FE-2026-001458');
36
+ });
37
+
38
+ test('discoverCrm: returns unresolved on 401', async () => {
39
+ const fetcher = async () => mockResponse({ status: 401, json: { error: 'unauth' } });
40
+ const r = await discoverCrm('HCA', { instance: 'https://iscrm.crm.dynamics.com' }, {
41
+ fetcher, headersOverride: { Authorization: 'Bearer fake' },
42
+ });
43
+ assert.equal(r.decision, 'unresolved');
44
+ assert.match(r.error, /401/i);
45
+ });
46
+
47
+ test('discoverAdo: returns unresolved when org not configured', async () => {
48
+ const r = await discoverAdo('HCA', { organization: '<x>', project: 'IS Engagements' });
49
+ assert.equal(r.decision, 'unresolved');
50
+ assert.match(r.error, /not configured/);
51
+ });
52
+
53
+ test('discoverAdo: auto-picks from WIQL + workitems hydrate', async () => {
54
+ let call = 0;
55
+ const fetcher = async (url) => {
56
+ call++;
57
+ if (call === 1) {
58
+ // WIQL POST -> ids only
59
+ return mockResponse({ json: { workItems: [{ id: 12345 }, { id: 67890 }] } });
60
+ }
61
+ // hydrate
62
+ return mockResponse({ json: { value: [
63
+ { id: 12345, fields: { 'System.Title': 'HCA Engagement', 'System.WorkItemType': 'Engagement', 'System.State': 'Active' } },
64
+ { id: 67890, fields: { 'System.Title': 'Different Thing', 'System.WorkItemType': 'Engagement', 'System.State': 'Closed' } },
65
+ ] } });
66
+ };
67
+ const r = await discoverAdo('HCA', {
68
+ organization: 'IndustrySolutions', project: 'IS Engagements', apiVersion: '7.1',
69
+ }, { fetcher, headersOverride: { Authorization: 'Bearer fake', 'Content-Type': 'application/json' } });
70
+ assert.equal(r.decision, 'auto');
71
+ assert.equal(r.picked.id, '12345');
72
+ });
73
+
74
+ test('discoverAdo: empty WIQL result -> unresolved', async () => {
75
+ const fetcher = async () => mockResponse({ json: { workItems: [] } });
76
+ const r = await discoverAdo('HCA', {
77
+ organization: 'IndustrySolutions', project: 'IS Engagements',
78
+ }, { fetcher, headersOverride: { Authorization: 'Bearer fake', 'Content-Type': 'application/json' } });
79
+ assert.equal(r.decision, 'unresolved');
80
+ assert.equal(r.candidates.length, 0);
81
+ });
@@ -0,0 +1,82 @@
1
+ // plugin/runners/test/unit/fuzzy-match.test.mjs
2
+ import { test } from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { scoreOne, rank, decide } from '../../lib/fuzzy-match.mjs';
5
+
6
+ test('scoreOne: exact match scores 1000', () => {
7
+ assert.equal(scoreOne('HCA', 'hca').score, 1000);
8
+ });
9
+
10
+ test('scoreOne: prefix beats contains', () => {
11
+ const p = scoreOne('HCA', 'HCA Agentic Engineering').score;
12
+ const c = scoreOne('Agentic', 'HCA Agentic Engineering').score;
13
+ assert.ok(p > c, `prefix=${p} should be > contains=${c}`);
14
+ });
15
+
16
+ test('scoreOne: contains scores below prefix but above token-overlap', () => {
17
+ assert.ok(scoreOne('foo', 'bar foo baz').score >= 300);
18
+ });
19
+
20
+ test('scoreOne: token-overlap when no substring match', () => {
21
+ const r = scoreOne('northwind acme', 'acme corp engagement');
22
+ assert.ok(r.score >= 300 && r.score < 500, `score=${r.score} kind=${r.kind}`);
23
+ });
24
+
25
+ test('scoreOne: levenshtein within 2 edits', () => {
26
+ const r = scoreOne('hca', 'hsa');
27
+ assert.ok(r.score >= 100 && r.score <= 130, `score=${r.score}`);
28
+ });
29
+
30
+ test('scoreOne: zero on no relation', () => {
31
+ assert.equal(scoreOne('aaaa', 'zzzzzzzz').score, 0);
32
+ });
33
+
34
+ test('rank: drops zero scores and sorts desc', () => {
35
+ const out = rank('HCA', [
36
+ { id: '1', title: 'unrelated thing' },
37
+ { id: '2', title: 'HCA Agentic Engineering' },
38
+ { id: '3', title: 'hca' },
39
+ ]);
40
+ assert.equal(out.length, 2);
41
+ assert.equal(out[0].id, '3'); // exact
42
+ assert.equal(out[1].id, '2'); // prefix
43
+ });
44
+
45
+ test('decide: auto-pick high when top exact and gap big', () => {
46
+ const ranked = rank('HCA', [
47
+ { id: 'A', title: 'hca' },
48
+ { id: 'B', title: 'something else' },
49
+ ]);
50
+ const d = decide(ranked);
51
+ assert.equal(d.decision, 'auto');
52
+ assert.equal(d.confidence, 'high');
53
+ assert.equal(d.picked.id, 'A');
54
+ });
55
+
56
+ test('decide: unresolved when two strong candidates close', () => {
57
+ const ranked = rank('HCA', [
58
+ { id: 'A', title: 'HCA West' },
59
+ { id: 'B', title: 'HCA East' },
60
+ ]);
61
+ const d = decide(ranked);
62
+ // both are prefix-scored equally (or close) -> gap fails -> unresolved
63
+ assert.equal(d.decision, 'unresolved');
64
+ assert.equal(d.candidates.length, 2);
65
+ });
66
+
67
+ test('decide: unresolved when no candidates', () => {
68
+ const d = decide([]);
69
+ assert.equal(d.decision, 'unresolved');
70
+ assert.equal(d.candidates.length, 0);
71
+ });
72
+
73
+ test('decide: medium-confidence auto for token-overlap with clear gap', () => {
74
+ const ranked = rank('northwind project', [
75
+ { id: 'A', title: 'northwind delivery' }, // token-overlap on northwind
76
+ { id: 'B', title: 'totally different thing' }, // 0
77
+ ]);
78
+ const d = decide(ranked);
79
+ assert.equal(d.decision, 'auto');
80
+ assert.equal(d.confidence, 'medium');
81
+ assert.equal(d.picked.id, 'A');
82
+ });