@adrata/adrata-mcp 1.0.1 → 1.0.3

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,162 @@
1
+ /**
2
+ * Pure operating-health projection for Starfield boards.
3
+ *
4
+ * A board is trustworthy when its workflow claims are backed by the minimum
5
+ * facts needed to act: ownership, a work type, a definition of done, and a
6
+ * current stage. Delivery evidence is deliberately added separately because a
7
+ * column is not a deployment receipt.
8
+ */
9
+
10
+ const TERMINAL_STAGES = new Set(['production', 'deep backlog']);
11
+ // Up Next is the deliberately unowned pull buffer. Triage/Aligning are capture
12
+ // and grooming, where thin cards are allowed. Only these stages mean somebody
13
+ // has actually taken a pass and therefore require both an end-to-end owner and
14
+ // a current handler.
15
+ const ACTIVE_STAGES = new Set(['in progress', 'staging qa1', 'staging qa2']);
16
+ // A card crossing the cut line into Up Next must be executable. Production is
17
+ // included because historical evidence does not stop mattering after release.
18
+ const EXECUTABLE_STAGES = new Set(['up next', ...ACTIVE_STAGES, 'production']);
19
+
20
+ function normalized(value) {
21
+ return String(value ?? '').trim().toLowerCase();
22
+ }
23
+
24
+ function hoursSince(iso, nowMs) {
25
+ const entered = Date.parse(iso);
26
+ if (!Number.isFinite(entered)) return null;
27
+ return Math.max(0, (nowMs - entered) / 3_600_000);
28
+ }
29
+
30
+ export function deliveryContradictions(evidence) {
31
+ const contradictions = [];
32
+ const workflowStage = normalized(evidence?.workflow?.columnName);
33
+ if (workflowStage === 'production' && evidence?.production?.state !== 'live') {
34
+ contradictions.push({
35
+ code: 'production_column_without_live_evidence',
36
+ message:
37
+ 'The card is in Production, but exact-SHA production evidence is not live. Treat the column as a workflow claim, not a deployment receipt.',
38
+ });
39
+ }
40
+ if (evidence?.production?.state === 'live' && workflowStage !== 'production') {
41
+ contradictions.push({
42
+ code: 'live_evidence_outside_production_column',
43
+ message:
44
+ 'Exact-SHA production evidence is live, but the workflow card has not reached Production.',
45
+ });
46
+ }
47
+ return contradictions;
48
+ }
49
+
50
+ /**
51
+ * @param {Array<object>} boards Full board payloads from GET /work-boards/{id}.
52
+ * @param {{now?: Date|string|number}} options
53
+ */
54
+ export function auditWorkHubBoards(boards, { now = new Date() } = {}) {
55
+ const nowMs = new Date(now).getTime();
56
+ const findings = [];
57
+ const perBoard = [];
58
+ let totalCards = 0;
59
+ let openCards = 0;
60
+ let activeCards = 0;
61
+
62
+ for (const board of boards) {
63
+ const columns = new Map((board.columns ?? []).map((column) => [column.id, column]));
64
+ const boardFindings = [];
65
+ const items = board.items ?? [];
66
+ totalCards += items.length;
67
+
68
+ if (board.truncated === true) {
69
+ boardFindings.push({
70
+ code: 'board_truncated',
71
+ itemId: null,
72
+ title: null,
73
+ detail: `The board returned ${items.length} of ${board.itemCount ?? 'an unknown number of'} cards.`,
74
+ });
75
+ }
76
+
77
+ for (const item of items) {
78
+ const column = columns.get(item.columnId);
79
+ const stage = column?.name ?? 'Unknown';
80
+ const stageKey = normalized(stage);
81
+ const isTerminal = TERMINAL_STAGES.has(stageKey);
82
+ const isActive = ACTIVE_STAGES.has(stageKey);
83
+ const mustBeExecutable = EXECUTABLE_STAGES.has(stageKey);
84
+ if (!isTerminal) openCards += 1;
85
+ if (isActive) activeCards += 1;
86
+
87
+ const add = (code, detail) =>
88
+ boardFindings.push({ code, itemId: item.id, title: item.title, stage, detail });
89
+
90
+ if (mustBeExecutable && !String(item.body ?? '').trim()) {
91
+ add('missing_body', 'The card crossed the cut line without working context.');
92
+ }
93
+ if (mustBeExecutable) {
94
+ if (item.criteria?.total === 0) {
95
+ add('missing_acceptance_criteria', 'Nobody has stated a checkable definition of done.');
96
+ } else if (item.criteria === undefined) {
97
+ add('criteria_not_measured', 'This read did not include acceptance-criteria status.');
98
+ }
99
+ if (!item.kind) add('missing_kind', 'The card is not classified as a story, bug, or chore.');
100
+ }
101
+ if (isActive && !item.assigneeUserId) {
102
+ add('unowned_active_card', 'The card is active but has no end-to-end owner.');
103
+ }
104
+ if (isActive && !item.handler) {
105
+ add('unhandled_active_pass', 'The active build or QA pass has no handler.');
106
+ }
107
+
108
+ const staleAfter = column?.staleness?.staleAfterHours;
109
+ const ageHours = hoursSince(item.enteredColumnAt, nowMs);
110
+ if (!isTerminal && Number.isFinite(staleAfter) && ageHours !== null && ageHours > staleAfter) {
111
+ add(
112
+ 'stale_open_card',
113
+ `The card has spent ${Math.floor(ageHours)}h in ${stage}; this column is stale after ${staleAfter}h.`
114
+ );
115
+ }
116
+ }
117
+
118
+ findings.push(...boardFindings.map((finding) => ({ boardId: board.id, boardName: board.name, ...finding })));
119
+ perBoard.push({
120
+ boardId: board.id,
121
+ boardName: board.name,
122
+ cards: items.length,
123
+ activeCards: items.filter((item) => {
124
+ const stage = columns.get(item.columnId)?.name;
125
+ return ACTIVE_STAGES.has(normalized(stage));
126
+ }).length,
127
+ findings: boardFindings.length,
128
+ });
129
+ }
130
+
131
+ const counts = {};
132
+ for (const finding of findings) counts[finding.code] = (counts[finding.code] ?? 0) + 1;
133
+
134
+ const priorityOrder = [
135
+ 'board_truncated',
136
+ 'unowned_active_card',
137
+ 'unhandled_active_pass',
138
+ 'missing_acceptance_criteria',
139
+ 'stale_open_card',
140
+ 'missing_body',
141
+ 'missing_kind',
142
+ 'criteria_not_measured',
143
+ ];
144
+ const rank = new Map(priorityOrder.map((code, index) => [code, index]));
145
+ findings.sort((a, b) => (rank.get(a.code) ?? 99) - (rank.get(b.code) ?? 99));
146
+
147
+ return {
148
+ trustworthy: findings.length === 0,
149
+ generatedAt: new Date(nowMs).toISOString(),
150
+ boards: boards.length,
151
+ totalCards,
152
+ openCards,
153
+ activeCards,
154
+ counts,
155
+ perBoard,
156
+ findings,
157
+ interpretation:
158
+ findings.length === 0
159
+ ? 'Every returned card has the minimum facts required to operate from this hub. Delivery truth must still be read from exact-SHA evidence.'
160
+ : 'The hub is not yet trustworthy as an operating ledger. Work the findings in order; do not infer completion from column position.',
161
+ };
162
+ }
package/toolsets/crm.js CHANGED
@@ -9,6 +9,23 @@ import { z } from 'zod';
9
9
  import {
10
10
  md, mdError, table, formatEntityTimeline, formatDate,
11
11
  } from '../output-formatter.js';
12
+ import {
13
+ governedWriteArgs, governedWriteNote, isLiveWrite, runGovernedMarkdownWrite,
14
+ enrichCostLine, COMPANY_ENRICH_COST_SENTENCE, PERSON_ENRICH_COST_SENTENCE,
15
+ } from '../governance/governed-args.js';
16
+
17
+ /**
18
+ * The `manage_*` tools are composites: one tool, one `action` argument, reads
19
+ * and writes side by side. Only the write actions are governed — a `get`,
20
+ * `list`, `search` or `timeline` must keep working exactly as before, with no
21
+ * new required arguments and no preview step.
22
+ *
23
+ * This is the list of actions that mutate. Everything not in it is a read.
24
+ */
25
+ const WRITE_ACTIONS = new Set([
26
+ 'create', 'update', 'delete', 'enrich', 'complete',
27
+ 'bulk_delete', 'add_member', 'update_member', 'remove_member',
28
+ ]);
12
29
 
13
30
  export function register(server, api, AUTH) {
14
31
 
@@ -17,7 +34,10 @@ export function register(server, api, AUTH) {
17
34
  // -----------------------------------------------------------------------
18
35
  server.tool(
19
36
  'manage_company',
20
- 'Full CRUD for companies: create, read, update, delete, enrich, and view activity timeline. One tool for all company operations.',
37
+ 'Full CRUD for companies: create, read, update, delete, enrich, and view activity timeline. One tool for all company operations.'
38
+ + ' Reads (get/search/timeline) run directly. create/update/delete/enrich are governed writes.'
39
+ + COMPANY_ENRICH_COST_SENTENCE
40
+ + governedWriteNote('write:companies'),
21
41
  {
22
42
  action: z.enum(['create', 'get', 'update', 'delete', 'enrich', 'timeline', 'search']).describe('Operation'),
23
43
  id: z.string().optional().describe('Company ID (for get/update/delete/enrich/timeline)'),
@@ -28,6 +48,7 @@ export function register(server, api, AUTH) {
28
48
  status: z.string().optional().describe('ACTIVE, PROSPECT, CUSTOMER, CHURNED'),
29
49
  customFields: z.record(z.unknown()).optional().describe('Custom fields (JSONB merged)'),
30
50
  query: z.string().optional().describe('Search query (for search action)'),
51
+ ...governedWriteArgs(z),
31
52
  },
32
53
  async (args) => {
33
54
  try {
@@ -40,9 +61,20 @@ export function register(server, api, AUTH) {
40
61
  if (args.website) body.website = args.website;
41
62
  if (args.status) body.status = args.status;
42
63
  if (args.customFields) body.customFields = args.customFields;
43
- const result = await api('POST', '/api/v1/companies', { body });
44
- const c = result?.data || result;
45
- return md(`## Company Created\n\n- **Name:** ${c.name || args.name}\n- **ID:** ${c.id || '\u2014'}\n- **Status:** ${c.status || 'PROSPECT'}\n`);
64
+ return runGovernedMarkdownWrite(
65
+ api, args,
66
+ {
67
+ method: 'POST',
68
+ path: '/api/v1/companies',
69
+ body,
70
+ heading: 'Create Company',
71
+ preview: { entity: 'company', operation: 'create', fields: Object.keys(body) },
72
+ },
73
+ (result) => {
74
+ const c = result?.data || result || {};
75
+ return `## Company Created\n\n- **Name:** ${c.name || args.name}\n- **ID:** ${c.id || '\u2014'}\n- **Status:** ${c.status || 'PROSPECT'}\n`;
76
+ },
77
+ );
46
78
  }
47
79
  case 'get': {
48
80
  const c = await api('GET', `/api/v1/companies/${args.id}`);
@@ -64,16 +96,46 @@ export function register(server, api, AUTH) {
64
96
  if (args.website) body.website = args.website;
65
97
  if (args.status) body.status = args.status;
66
98
  if (args.customFields) body.customFields = args.customFields;
67
- await api('PATCH', `/api/v1/companies/${args.id}`, { body });
68
- return md(`## Company Updated\n\nID: ${args.id}\nFields updated: ${Object.keys(body).join(', ')}\n`);
99
+ return runGovernedMarkdownWrite(
100
+ api, args,
101
+ {
102
+ method: 'PATCH',
103
+ path: `/api/v1/companies/${args.id}`,
104
+ body,
105
+ heading: 'Update Company',
106
+ preview: { entity: 'company', entityId: args.id, operation: 'update', fields: Object.keys(body) },
107
+ },
108
+ () => `## Company Updated\n\nID: ${args.id}\nFields updated: ${Object.keys(body).join(', ')}\n`,
109
+ );
69
110
  }
70
111
  case 'delete': {
71
- await api('DELETE', `/api/v1/companies/${args.id}`);
72
- return md(`## Company Deleted\n\nID: ${args.id}\n`);
112
+ return runGovernedMarkdownWrite(
113
+ api, args,
114
+ {
115
+ method: 'DELETE',
116
+ path: `/api/v1/companies/${args.id}`,
117
+ heading: 'Delete Company',
118
+ preview: { entity: 'company', entityId: args.id, operation: 'soft-delete' },
119
+ },
120
+ () => `## Company Deleted\n\nID: ${args.id}\n`,
121
+ );
73
122
  }
74
123
  case 'enrich': {
75
- await api('POST', `/api/v1/companies/${args.id}/enrich`);
76
- return md(`## Enrichment Triggered\n\nCompany ID: ${args.id}\nData will be available shortly.\n`);
124
+ return runGovernedMarkdownWrite(
125
+ api, args,
126
+ {
127
+ method: 'POST',
128
+ path: `/api/v1/companies/${args.id}/enrich`,
129
+ heading: 'Enrich Company',
130
+ preview: {
131
+ entity: 'company',
132
+ entityId: args.id,
133
+ operation: 'enrich',
134
+ cost: enrichCostLine('company'),
135
+ },
136
+ },
137
+ () => `## Enrichment Triggered\n\nCompany ID: ${args.id}\nCost: ${enrichCostLine('company')}\nData will be available shortly.\n`,
138
+ );
77
139
  }
78
140
  case 'timeline': {
79
141
  const [company, actions] = await Promise.all([
@@ -12,6 +12,89 @@ import {
12
12
  formatQualification, formatCompanyResearch, formatPersonResearch,
13
13
  formatSpeedrunList, formatNextContacts, table, signalEmoji, formatDate, daysSince,
14
14
  } from '../output-formatter.js';
15
+ import {
16
+ governedWriteArgs, governedWriteNote, isLiveWrite, missingLiveWriteFields,
17
+ previewMarkdown, enrichCostLine,
18
+ COMPANY_ENRICH_COST_SENTENCE, PERSON_ENRICH_COST_SENTENCE,
19
+ } from '../governance/governed-args.js';
20
+ import { governedWrite } from '../api-bridge.js';
21
+
22
+ /**
23
+ * Run the enrichment leg of a research tool through the governed-write
24
+ * contract and render the outcome as a Markdown section.
25
+ *
26
+ * `research_company` / `research_person` are read tools with one write inside
27
+ * them: a POST to `/enrich`, which spends real Coresignal credits. They used to
28
+ * fire that POST on every call with no preview, no audit reason and no
29
+ * idempotency key — while the IDENTICAL endpoint reached through
30
+ * `enrich_company` / `enrich_person` in server.js previewed and refused.
31
+ *
32
+ * So the reads still run and still produce the report. The enrich is withheld
33
+ * by default and reported as withheld, with the credit cost named, rather than
34
+ * being silently skipped — a research report that quietly lost its enrichment
35
+ * would be worse than one that spent the credits.
36
+ *
37
+ * @returns `{ enrichment, section }` — `enrichment` is the live result (or an
38
+ * empty object when nothing was executed) so the report renders
39
+ * identically either way.
40
+ */
41
+ async function governedEnrich(api, args, { method, path, kind, subjectId }) {
42
+ if (isLiveWrite(args)) {
43
+ const missing = missingLiveWriteFields(args);
44
+ if (missing.length > 0) {
45
+ return {
46
+ enrichment: {},
47
+ section:
48
+ `\n---\n\n## Enrichment refused — nothing was spent\n\n` +
49
+ `A live enrichment write is missing: ${missing.join(', ')}.\n\n` +
50
+ `- **Would have called:** ${method} ${path}\n` +
51
+ `- **Cost if executed:** ${enrichCostLine(kind)}\n\n` +
52
+ `Re-call with \`dryRun:false\` plus ${missing.join(', ')}.\n`,
53
+ };
54
+ }
55
+ }
56
+
57
+ try {
58
+ const outcome = await governedWrite(api, args, {
59
+ method,
60
+ path,
61
+ preview: {
62
+ entity: kind,
63
+ entityId: subjectId,
64
+ operation: 'enrich',
65
+ cost: enrichCostLine(kind),
66
+ },
67
+ });
68
+
69
+ if (outcome.dryRun) {
70
+ return {
71
+ enrichment: {},
72
+ section:
73
+ `\n---\n\n${previewMarkdown('Enrichment', outcome.preview)}` +
74
+ `\nThe research above was assembled from reads only. No vendor credits were spent.\n`,
75
+ };
76
+ }
77
+
78
+ return {
79
+ enrichment: outcome.result || {},
80
+ section:
81
+ `\n---\n\n## Enrichment executed\n\n` +
82
+ `- **Call:** ${method} ${path}\n` +
83
+ `- **Cost:** ${enrichCostLine(kind)}\n` +
84
+ `- **Reason:** ${args.reason}\n` +
85
+ `- **Idempotency key:** ${args.idempotencyKey}\n`,
86
+ };
87
+ } catch (err) {
88
+ return {
89
+ enrichment: {},
90
+ section:
91
+ `\n---\n\n## Enrichment not performed\n\n` +
92
+ `- **Attempted:** ${method} ${path}\n` +
93
+ `- **Error:** ${err.message}\n` +
94
+ `\nThe research above was assembled from reads only.\n`,
95
+ };
96
+ }
97
+ }
15
98
 
16
99
  export function register(server, api, AUTH) {
17
100
 
@@ -63,10 +146,14 @@ export function register(server, api, AUTH) {
63
146
  // -----------------------------------------------------------------------
64
147
  server.tool(
65
148
  'research_company',
66
- 'Deep research report on a company: firmographics, tech stack, funding, news, competitors, intent signals. Orchestrates 5+ API calls into a structured report.',
149
+ 'Deep research report on a company: firmographics, tech stack, funding, news, competitors, intent signals. Orchestrates 5+ API calls into a structured report.'
150
+ + ' The reads always run. The enrichment leg is a governed write:'
151
+ + COMPANY_ENRICH_COST_SENTENCE
152
+ + governedWriteNote('write:companies'),
67
153
  {
68
154
  companyId: z.string().optional().describe('Company ID'),
69
155
  name: z.string().optional().describe('Company name to search'),
156
+ ...governedWriteArgs(z),
70
157
  },
71
158
  async (args) => {
72
159
  try {
@@ -86,14 +173,22 @@ export function register(server, api, AUTH) {
86
173
  return mdError('Provide companyId or name');
87
174
  }
88
175
 
89
- // Parallel enrichment
90
- const [enrichment, signals, competitors] = await Promise.all([
91
- api('POST', `/api/v1/companies/${companyId}/enrich`).catch(() => ({})),
176
+ // Reads run unconditionally. The enrich POST is a governed write and
177
+ // previews by default, so a research call costs zero vendor credits
178
+ // unless the caller explicitly authorised the spend.
179
+ const [signals, competitors] = await Promise.all([
92
180
  api('GET', '/api/v1/intent-signals', { params: { companyId } }).catch(() => ({ data: [] })),
93
181
  api('GET', '/api/v1/competitors', { params: { companyId } }).catch(() => ({ data: [] })),
94
182
  ]);
95
183
 
96
- return md(formatCompanyResearch(company, enrichment, signals, competitors));
184
+ const { enrichment, section } = await governedEnrich(api, args, {
185
+ method: 'POST',
186
+ path: `/api/v1/companies/${companyId}/enrich`,
187
+ kind: 'company',
188
+ subjectId: companyId,
189
+ });
190
+
191
+ return md(formatCompanyResearch(company, enrichment, signals, competitors) + section);
97
192
  } catch (err) {
98
193
  return mdError('Research failed', err.message);
99
194
  }
@@ -105,11 +200,15 @@ export function register(server, api, AUTH) {
105
200
  // -----------------------------------------------------------------------
106
201
  server.tool(
107
202
  'research_person',
108
- 'Deep research on a person: professional profile, communication preferences, intro paths, recent activity. Orchestrates 3+ API calls.',
203
+ 'Deep research on a person: professional profile, communication preferences, intro paths, recent activity. Orchestrates 3+ API calls.'
204
+ + ' The reads always run. The enrichment leg is a governed write:'
205
+ + PERSON_ENRICH_COST_SENTENCE
206
+ + governedWriteNote('write:people'),
109
207
  {
110
208
  personId: z.string().optional().describe('Person ID'),
111
209
  name: z.string().optional().describe('Person name to search'),
112
210
  email: z.string().optional().describe('Email to search by'),
211
+ ...governedWriteArgs(z),
113
212
  },
114
213
  async (args) => {
115
214
  try {
@@ -132,13 +231,20 @@ export function register(server, api, AUTH) {
132
231
  return mdError('Provide personId, name, or email');
133
232
  }
134
233
 
135
- // Parallel: enrichment + actions
136
- const [enrichment, actions] = await Promise.all([
137
- api('POST', `/api/v1/people/${personId}/enrich`).catch(() => ({})),
138
- api('GET', '/api/v1/actions', { params: { personId, limit: 10, page: 1 } }).catch(() => ({ data: [] })),
139
- ]);
234
+ // Reads run unconditionally; the enrich POST is governed and previews
235
+ // by default. A Coresignal collect is 20 credits per person, so an
236
+ // ungoverned research call was a per-invocation vendor charge.
237
+ const actions = await api('GET', '/api/v1/actions', { params: { personId, limit: 10, page: 1 } })
238
+ .catch(() => ({ data: [] }));
239
+
240
+ const { enrichment, section } = await governedEnrich(api, args, {
241
+ method: 'POST',
242
+ path: `/api/v1/people/${personId}/enrich`,
243
+ kind: 'person',
244
+ subjectId: personId,
245
+ });
140
246
 
141
- return md(formatPersonResearch(person, enrichment, actions));
247
+ return md(formatPersonResearch(person, enrichment, actions) + section);
142
248
  } catch (err) {
143
249
  return mdError('Research failed', err.message);
144
250
  }