@adrata/adrata-mcp 1.0.19 → 1.0.40
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/README.md +3 -0
- package/access/tiers.js +27 -0
- package/api-bridge.js +6 -0
- package/package.json +3 -3
- package/security.js +167 -14
- package/server.js +24 -1
- package/server.json +2 -2
- package/skills/qa-the-card/SKILL.md +16 -3
- package/skills/ship-the-card/SKILL.md +48 -39
- package/tool-annotations.js +17 -0
- package/tools/provisioning/onboarding-tools.js +730 -0
- package/tools/source-control/connection-tools.js +117 -1
- package/tools/work-board-tools.js +449 -39
- package/tools/work-hub/criteria-quality.js +254 -5
- package/tools/work-hub/field-changes.js +25 -0
- package/toolsets/prospecting.js +194 -3
- package/toolsets/revenue/always-loaded.js +2 -2
|
@@ -52,6 +52,50 @@
|
|
|
52
52
|
* word. The cost of that false positive is an author learning to ignore the
|
|
53
53
|
* notes, which is worse than the check not existing. When in doubt these rules
|
|
54
54
|
* stay silent.
|
|
55
|
+
*
|
|
56
|
+
* # The 2026-09-08 correction: it was firing on the good ones
|
|
57
|
+
*
|
|
58
|
+
* Measured across a complete census — 648 criteria on 220 cards — the
|
|
59
|
+
* placeholder rule found **zero of the 12 genuinely unexecutable criteria** and
|
|
60
|
+
* produced **seven false positives**. That is worse than no check at all,
|
|
61
|
+
* because it is believed: it steers a grader toward well-written criteria and
|
|
62
|
+
* away from the ones that cannot fail. What it fired on, verbatim:
|
|
63
|
+
*
|
|
64
|
+
* - `WIP = throughput × cycle time` and the WIP-limit card itself, on a board
|
|
65
|
+
* whose own feature is WIP limits.
|
|
66
|
+
* - `The <video> element autoplays muted …`, because `<[^>]{0,40}>` cannot
|
|
67
|
+
* tell a referent from an unfilled slot.
|
|
68
|
+
* - `The draft email body contains no placeholder tokens` — a criterion that
|
|
69
|
+
* is correct *because* it says that word.
|
|
70
|
+
* - the classifier's own fixture card, which must name TODO and N/A as data.
|
|
71
|
+
*
|
|
72
|
+
* Three repairs follow, and each is a NARROWING that had to be tested in both
|
|
73
|
+
* directions — a genuine `TODO` must still fire, or the fix is just a silence:
|
|
74
|
+
*
|
|
75
|
+
* 1. **`wip` is gone.** It is a live domain term here and nothing in this
|
|
76
|
+
* corpus separates "WIP limit" from "WIP" as an unfinished marker. #3871
|
|
77
|
+
* tried a `(?![\s-]+limits?)` lookahead; it still fired on a sentence
|
|
78
|
+
* carrying BOTH senses. Removing a token that cannot discriminate is a
|
|
79
|
+
* strengthening — every remaining token means one thing.
|
|
80
|
+
* 2. **Angle brackets classify the inner text** (`isUnfilledSlot`) instead of
|
|
81
|
+
* matching any `<…>`. `<video>` and `<Button>` are referents; `<name>` and
|
|
82
|
+
* `<fill this in>` are slots.
|
|
83
|
+
* 3. **A negated or quoted mention does not fire.** "contains no placeholder",
|
|
84
|
+
* "must not say TODO", "names TODO as data" — the criterion is naming the
|
|
85
|
+
* thing it forbids or the literal it expects. Same shape as the flag
|
|
86
|
+
* disclaimer window in `scripts/fleet/stale-flag-detector.mjs`.
|
|
87
|
+
*
|
|
88
|
+
* And the other half, which is the more valuable one: the check missed EVERY
|
|
89
|
+
* real defect, so `ADJECTIVAL_THEN` and `CATEGORICAL_WHERE` were added for the
|
|
90
|
+
* two commonest shapes in that census — a `then` that is pure adjectives
|
|
91
|
+
* ("warm, sharp, candid", "near black", "usable overflow") and a `where` that
|
|
92
|
+
* names a category rather than a place ("Representative seller conversations",
|
|
93
|
+
* "code/api — wherever it is implemented"). Both stay advisory.
|
|
94
|
+
*
|
|
95
|
+
* Two shapes from that census are deliberately NOT attempted here, because a
|
|
96
|
+
* regex would guess: several fault states folded into one criterion (one had
|
|
97
|
+
* seven), and a `then` naming no value at all in a long sentence. Both need to
|
|
98
|
+
* read the sentence, not match it.
|
|
55
99
|
*/
|
|
56
100
|
|
|
57
101
|
/** Lowercase, collapse whitespace, drop trailing punctuation. */
|
|
@@ -109,9 +153,179 @@ const UNLOCATED_WHERE = [
|
|
|
109
153
|
/^(?:it|this|there|here|everywhere|anywhere|all surfaces|any surface|none|various)$/,
|
|
110
154
|
];
|
|
111
155
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
156
|
+
/**
|
|
157
|
+
* A `where` that names a CATEGORY of place rather than a place.
|
|
158
|
+
*
|
|
159
|
+
* Distinct from `UNLOCATED_WHERE`, which catches the whole-product noun. This
|
|
160
|
+
* catches the field that reads like a sampling frame — "Representative seller
|
|
161
|
+
* conversations" — or that defers the location to the reader — "code/api —
|
|
162
|
+
* wherever it is implemented". Both are answers to "what kind of place"; the
|
|
163
|
+
* field asks "which one", and the next reviewer cannot stand in a category.
|
|
164
|
+
*
|
|
165
|
+
* `all` / `each` / `every` are deliberately absent from the quantifier list: "all
|
|
166
|
+
* three boards in the workspace" is an exhaustive scope, which is a real answer.
|
|
167
|
+
*/
|
|
168
|
+
const CATEGORICAL_WHERE = [
|
|
169
|
+
/^(?:a |an |the )?(?:representative|typical|assorted|various|relevant|appropriate|applicable|indicative|sample|example|some|several|a few|any)\b/,
|
|
170
|
+
/\b(?:wherever|as applicable|if applicable|as appropriate|as needed|where (?:it|they|that) (?:is|are) (?:implemented|used|defined|rendered|handled))\b/,
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A `then` made only of adjectives — the commonest real defect in the census
|
|
175
|
+
* and one the rules above could not see, because it is not a fixed phrase.
|
|
176
|
+
*
|
|
177
|
+
* Three conditions, all required, and the conjunction is what keeps it quiet:
|
|
178
|
+
* the clause is at most three words, it carries no falsifiable anchor (see
|
|
179
|
+
* `hasFalsifiableAnchor`), and at least one of its words is a subjective quality
|
|
180
|
+
* word. "The badge is green" is four words and stays silent; "near black" is two
|
|
181
|
+
* and does not.
|
|
182
|
+
*/
|
|
183
|
+
const VAGUE_QUALITY = new Set([
|
|
184
|
+
// quality adjectives a reviewer cannot disagree with
|
|
185
|
+
'warm', 'sharp', 'candid', 'crisp', 'clean', 'clear', 'smooth', 'snappy',
|
|
186
|
+
'polished', 'tidy', 'neat', 'readable', 'legible', 'usable', 'sensible',
|
|
187
|
+
'reasonable', 'appropriate', 'nice', 'pleasant', 'elegant', 'modern',
|
|
188
|
+
'professional', 'intuitive', 'seamless', 'robust', 'solid', 'consistent',
|
|
189
|
+
'coherent', 'obvious', 'natural', 'comfortable', 'balanced', 'subtle',
|
|
190
|
+
'punchy', 'tight', 'generous', 'minimal', 'simple', 'friendly',
|
|
191
|
+
'approachable', 'compelling', 'engaging', 'delightful', 'beautiful',
|
|
192
|
+
'responsive', 'performant', 'stable', 'reliable', 'good', 'great', 'fine',
|
|
193
|
+
'better', 'improved', 'acceptable', 'adequate', 'sufficient', 'correct',
|
|
194
|
+
'proper', 'right', 'accurate',
|
|
195
|
+
// hedges — a value that is "near" something is not a value
|
|
196
|
+
'near', 'roughly', 'approximately', 'about', 'around', 'slightly', 'somewhat',
|
|
197
|
+
'fairly', 'quite', 'very', 'pretty', 'mostly', 'generally', 'largely',
|
|
198
|
+
// bare colour and size, with no value beside them
|
|
199
|
+
'black', 'white', 'grey', 'gray', 'dark', 'light', 'big', 'small', 'large',
|
|
200
|
+
'tall', 'short', 'wide', 'narrow', 'thin', 'thick', 'fast', 'slow', 'quick',
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Something in the clause a reviewer could look for and fail to find: a number,
|
|
205
|
+
* a quoted or code-fenced literal, a path or identifier, or a capitalised label
|
|
206
|
+
* anywhere but the sentence's first word.
|
|
207
|
+
*
|
|
208
|
+
* Read on the RAW text, not the normalised one, because normalising lowercases
|
|
209
|
+
* — and case is half of what makes a label a label.
|
|
210
|
+
*/
|
|
211
|
+
function hasFalsifiableAnchor(raw) {
|
|
212
|
+
const text = String(raw ?? '').trim();
|
|
213
|
+
if (!text) return false;
|
|
214
|
+
if (/[0-9]/.test(text)) return true;
|
|
215
|
+
if (/["'`“”‘’]/.test(text)) return true;
|
|
216
|
+
if (/[/_=<>{}()[\]#%]/.test(text)) return true;
|
|
217
|
+
const words = text.split(/\s+/);
|
|
218
|
+
return words.slice(1).some((word) => /^[A-Z]/.test(word) || /[a-z][A-Z]/.test(word));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Placeholder markers. `wip` is deliberately absent — see the header. `tbc` is
|
|
223
|
+
* new and unambiguous, and it is what made `<copy tbc>` detectable once the
|
|
224
|
+
* blanket angle-bracket rule went.
|
|
225
|
+
*/
|
|
226
|
+
const PLACEHOLDER_MARKER = /(?:^|[^a-z0-9])(tbd|tba|tbc|todo|fixme|xxx|placeholder|n\/a)(?:[^a-z0-9]|$)/gi;
|
|
227
|
+
|
|
228
|
+
/** Three or more question marks is nobody's referent. */
|
|
229
|
+
const UNANSWERED = /\?{3,}/;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* A marker the author is NAMING rather than leaving behind.
|
|
233
|
+
*
|
|
234
|
+
* Two families, one window. Negation — "contains no placeholder", "must not say
|
|
235
|
+
* TODO" — and quotation verbs — "names TODO as data", "the literal TODO". The
|
|
236
|
+
* 24-character window is the same bounded-lookback shape as `disclaimerNearRef`
|
|
237
|
+
* in `scripts/fleet/stale-flag-detector.mjs`: near enough to be about this
|
|
238
|
+
* marker, short enough that a negation two sentences back cannot excuse it.
|
|
239
|
+
*
|
|
240
|
+
* `reads` / `shows` / `displays` are deliberately NOT here. "The banner reads
|
|
241
|
+
* TBD" is genuinely ambiguous between an expected literal and an unfinished
|
|
242
|
+
* clause, and this file's standing rule is that ambiguity stays loud in the
|
|
243
|
+
* direction of the marker.
|
|
244
|
+
*
|
|
245
|
+
* The window stops at `. , ; :` as well as at 24 characters. That clause
|
|
246
|
+
* boundary is what keeps "the summary names every owner, TBD which column"
|
|
247
|
+
* firing: the naming verb is inside 24 characters but it is about a different
|
|
248
|
+
* object, and the comma says so.
|
|
249
|
+
*/
|
|
250
|
+
const EXCUSED_BEFORE =
|
|
251
|
+
/\b(?:no|not|never|without|absent|excluding|neither|nor|free of|rather than|other than|names?|named|naming|quotes?|quoted|literal(?:ly)?|marker|markers|token|tokens|string|strings|word|words|spelled|spelling|placeholder)\b[^.,;:]{0,24}$/i;
|
|
252
|
+
|
|
253
|
+
/** A marker wrapped in quotes or backticks is a literal under discussion. */
|
|
254
|
+
function isQuotedAt(text, start, end) {
|
|
255
|
+
const before = text.slice(Math.max(0, start - 1), start);
|
|
256
|
+
const after = text.slice(end, end + 1);
|
|
257
|
+
return /["'`“‘]/.test(before) && /["'`”’]/.test(after);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* HTML element names, so `<video>` reads as the referent it is. Not exhaustive
|
|
262
|
+
* and does not need to be: everything it misses falls through to the
|
|
263
|
+
* capitalised-component and multi-word tests below.
|
|
264
|
+
*/
|
|
265
|
+
const HTML_ELEMENTS = new Set(
|
|
266
|
+
('a abbr address area article aside audio b base bdi bdo blockquote body br button canvas caption ' +
|
|
267
|
+
'cite code col colgroup data datalist dd del details dfn dialog div dl dt em embed fieldset ' +
|
|
268
|
+
'figcaption figure footer form h1 h2 h3 h4 h5 h6 head header hgroup hr html i iframe img input ' +
|
|
269
|
+
'ins kbd label legend li link main map mark menu meta meter nav noscript object ol optgroup ' +
|
|
270
|
+
'option output p param picture pre progress q rp rt ruby s samp script section select slot small ' +
|
|
271
|
+
'source span strong style sub summary sup table tbody td template textarea tfoot th thead time ' +
|
|
272
|
+
'title tr track u ul var video wbr svg path circle rect g defs use'
|
|
273
|
+
).split(' ')
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Does `<…>` hold an unfilled slot, or a referent the author meant to name?
|
|
278
|
+
*
|
|
279
|
+
* The blanket `<[^>]{0,40}>` this replaces could not tell them apart, so it
|
|
280
|
+
* flagged `<video>`. Order matters: attributes and closing tags settle it first,
|
|
281
|
+
* then a multi-word inside is prose and therefore a slot, then a capitalised
|
|
282
|
+
* single token is a JSX component, then a known element name.
|
|
283
|
+
*
|
|
284
|
+
* What is left — a lowercase single token that is not an HTML element — is read
|
|
285
|
+
* as a slot: `<name>`, `<slug>`, `<workspace>`. The cost of that choice is a
|
|
286
|
+
* custom element like `<my-widget>` written bare in a criterion, which is rarer
|
|
287
|
+
* in this corpus than the slot it catches.
|
|
288
|
+
*/
|
|
289
|
+
function isUnfilledSlot(inner) {
|
|
290
|
+
const text = inner.trim();
|
|
291
|
+
if (!text || text.length > 40) return false;
|
|
292
|
+
if (text !== inner) return false; // "a < b and c > d" — padded, so it is a comparison
|
|
293
|
+
if (text.includes('=')) return false; // <input type="text">
|
|
294
|
+
if (/^[/!?]/.test(text)) return false; // </div>, <!-- -->, <?xml
|
|
295
|
+
const words = text.split(/\s+/);
|
|
296
|
+
if (words.length > 1) return words.length <= 5 && words.every((word) => /^[\w'’-]+$/.test(word));
|
|
297
|
+
const token = text.replace(/\/$/, ''); // <br/>
|
|
298
|
+
if (/^[A-Z]/.test(token)) return false; // <Button> — a JSX component
|
|
299
|
+
return !HTML_ELEMENTS.has(token.toLowerCase());
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Every placeholder marker in one field that is not excused, plus any unfilled
|
|
304
|
+
* angle-bracket slot. Returns the matched text, or `null` when the field is
|
|
305
|
+
* clean — the caller only needs to know whether to speak.
|
|
306
|
+
*/
|
|
307
|
+
export function findPlaceholder(raw) {
|
|
308
|
+
const text = String(raw ?? '');
|
|
309
|
+
if (!text.trim()) return null;
|
|
310
|
+
|
|
311
|
+
PLACEHOLDER_MARKER.lastIndex = 0;
|
|
312
|
+
for (const hit of text.matchAll(PLACEHOLDER_MARKER)) {
|
|
313
|
+
const marker = hit[1];
|
|
314
|
+
const start = hit.index + hit[0].indexOf(marker);
|
|
315
|
+
const end = start + marker.length;
|
|
316
|
+
if (isQuotedAt(text, start, end)) continue;
|
|
317
|
+
if (EXCUSED_BEFORE.test(text.slice(0, start))) continue;
|
|
318
|
+
return marker;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (UNANSWERED.test(text)) return text.match(UNANSWERED)[0];
|
|
322
|
+
|
|
323
|
+
for (const hit of text.matchAll(/<([^<>]*)>/g)) {
|
|
324
|
+
if (isUnfilledSlot(hit[1])) return hit[0];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
115
329
|
|
|
116
330
|
const PART_LABELS = {
|
|
117
331
|
whereText: 'where',
|
|
@@ -142,6 +356,29 @@ export function inspectCriterion(criterion, { ordinal = null, id = null } = {})
|
|
|
142
356
|
});
|
|
143
357
|
}
|
|
144
358
|
|
|
359
|
+
// A `then` of at most three adjectives, with nothing in it to look for.
|
|
360
|
+
// Ordered after UNFALSIFIABLE_THEN and guarded on it, because "it works" is
|
|
361
|
+
// both, and one clause earning two findings reads as the check stuttering.
|
|
362
|
+
if (thenText && !findings.some((finding) => finding.code === 'unfalsifiable_then')) {
|
|
363
|
+
const words = thenText.split(' ').map((word) => word.replace(/[^a-z-]/g, ''));
|
|
364
|
+
const content = words.filter(Boolean);
|
|
365
|
+
if (
|
|
366
|
+
content.length > 0 &&
|
|
367
|
+
content.length <= 3 &&
|
|
368
|
+
!hasFalsifiableAnchor(criterion.thenText) &&
|
|
369
|
+
content.some((word) => VAGUE_QUALITY.has(word))
|
|
370
|
+
) {
|
|
371
|
+
findings.push({
|
|
372
|
+
...at,
|
|
373
|
+
code: 'adjectival_then',
|
|
374
|
+
field: 'thenText',
|
|
375
|
+
text: String(criterion.thenText).trim(),
|
|
376
|
+
detail:
|
|
377
|
+
'The observable result is a quality, not an observation — there is no value, count, state, or exact message in it for a reviewer to look for. Say what is on the screen or in the response.',
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
145
382
|
const whereText = normalise(criterion?.whereText);
|
|
146
383
|
if (whereText && UNLOCATED_WHERE.some((pattern) => pattern.test(whereText))) {
|
|
147
384
|
findings.push({
|
|
@@ -152,17 +389,29 @@ export function inspectCriterion(criterion, { ordinal = null, id = null } = {})
|
|
|
152
389
|
detail:
|
|
153
390
|
'This names the whole product rather than a place to stand. Give the surface, environment, account, or role the next reviewer should check it on.',
|
|
154
391
|
});
|
|
392
|
+
} else if (whereText && CATEGORICAL_WHERE.some((pattern) => pattern.test(whereText))) {
|
|
393
|
+
findings.push({
|
|
394
|
+
...at,
|
|
395
|
+
code: 'categorical_where',
|
|
396
|
+
field: 'whereText',
|
|
397
|
+
text: String(criterion.whereText).trim(),
|
|
398
|
+
detail:
|
|
399
|
+
'This names a kind of place rather than one place. The next reviewer cannot stand in a category — name the specific surface, record, account, or route to open.',
|
|
400
|
+
});
|
|
155
401
|
}
|
|
156
402
|
|
|
157
403
|
for (const [field, label] of Object.entries(PART_LABELS)) {
|
|
158
404
|
const raw = criterion?.[field];
|
|
159
|
-
if (typeof raw
|
|
405
|
+
if (typeof raw !== 'string') continue;
|
|
406
|
+
const marker = findPlaceholder(raw);
|
|
407
|
+
if (marker) {
|
|
160
408
|
findings.push({
|
|
161
409
|
...at,
|
|
162
410
|
code: 'placeholder_text',
|
|
163
411
|
field,
|
|
164
412
|
text: raw.trim(),
|
|
165
|
-
|
|
413
|
+
marker,
|
|
414
|
+
detail: `The ${label} clause still carries a placeholder (${marker}). A criterion nobody has finished writing cannot be executed by somebody who did not write the code.`,
|
|
166
415
|
});
|
|
167
416
|
}
|
|
168
417
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** One authenticated read; unavailable history must never become an empty record. */
|
|
2
|
+
export function registerWorkItemFieldChanges(server, { z, api, ok }) {
|
|
3
|
+
server.tool(
|
|
4
|
+
'get_work_item_field_changes',
|
|
5
|
+
'Read recorded edits to one card, newest first: field, previous and new values, actor, timestamp, reason and its source. Assignee names are current workspace directory labels; stored ids remain the historical values. Truncation is explicit. An empty result means no field changes were recorded, not that the card was never edited; older edits may predate recording. Reasons are the writer’s account, not independent QA evidence. Credential identifiers and worker capabilities are never returned.',
|
|
6
|
+
{ itemId: z.string().describe('Card id.') },
|
|
7
|
+
async ({ itemId }) => {
|
|
8
|
+
const response = await api(
|
|
9
|
+
'GET',
|
|
10
|
+
`/api/v1/work-items/${encodeURIComponent(itemId)}/field-changes`
|
|
11
|
+
);
|
|
12
|
+
if (!Array.isArray(response?.data)) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
'Field-change history was unavailable or malformed; no empty-history claim can be made.'
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
return ok({
|
|
18
|
+
count: response.data.length,
|
|
19
|
+
changes: response.data,
|
|
20
|
+
howToRead:
|
|
21
|
+
'Newest first. No rows means no recorded field changes; older edits may predate recording. A missing reason or identity was not recorded. Directory names are current labels for the stored assignee ids.',
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
);
|
|
25
|
+
}
|
package/toolsets/prospecting.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Prospecting Toolset (
|
|
2
|
+
* Prospecting Toolset (9 tools, Pro tier)
|
|
3
3
|
*
|
|
4
4
|
* qualify_company, research_company, research_person,
|
|
5
5
|
* get_priority_pursuits, get_speedrun_list (legacy alias), discover_prospects,
|
|
6
|
-
* get_next_contacts
|
|
6
|
+
* get_next_contacts, commit_lead_to_outbound, release_lead_from_outbound,
|
|
7
|
+
* list_outbound_leads
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import { z } from 'zod';
|
|
@@ -17,7 +18,19 @@ import {
|
|
|
17
18
|
previewMarkdown, enrichCostLine,
|
|
18
19
|
COMPANY_ENRICH_COST_SENTENCE, PERSON_ENRICH_COST_SENTENCE,
|
|
19
20
|
} from '../governance/governed-args.js';
|
|
20
|
-
import { governedWrite } from '../api-bridge.js';
|
|
21
|
+
import { governedWrite, buildMutationHeaders } from '../api-bridge.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The lead list that IS Pipeline's Outbound stage.
|
|
25
|
+
*
|
|
26
|
+
* `/targets` and Pipeline agree on who is committed by reading one list: the workspace's lead
|
|
27
|
+
* list whose `description` carries this marker. The display name is deliberately ordinary and
|
|
28
|
+
* deliberately NOT the identity — a user is free to make a list called "Outbound" for their own
|
|
29
|
+
* reasons, and it must never become product state. Mirrors `LEAD_COMMITMENT_MARKER` in
|
|
30
|
+
* `code/desktop/src/renderer/src/api/lead-commitment.ts`; `prospecting.test.js` pins the two to
|
|
31
|
+
* the same string because two packages cannot share the constant.
|
|
32
|
+
*/
|
|
33
|
+
export const LEAD_COMMITMENT_MARKER = 'starfield:sell:lead-commitment:v1';
|
|
21
34
|
|
|
22
35
|
/**
|
|
23
36
|
* Run the enrichment leg of a research tool through the governed-write
|
|
@@ -417,4 +430,182 @@ export function register(server, api, AUTH) {
|
|
|
417
430
|
}
|
|
418
431
|
}
|
|
419
432
|
);
|
|
433
|
+
|
|
434
|
+
// -----------------------------------------------------------------------
|
|
435
|
+
// Outbound commitment — the /targets line, over MCP
|
|
436
|
+
//
|
|
437
|
+
// The leads surface has two halves: Outbound above the line, Targets below it. Crossing the
|
|
438
|
+
// line is ONE governed write — add or remove the person on the marker-owned lead list — and
|
|
439
|
+
// Pipeline's Outbound column reads that same list. These three tools are that gesture for an
|
|
440
|
+
// agent. They add no API surface: `POST/DELETE /lead-lists/{id}/members` existed and was in the
|
|
441
|
+
// bridge allowlist; what was missing was the knowledge of WHICH list, and a caller who reached
|
|
442
|
+
// for `add_to_lead_list` with the wrong id landed the person in a list the product never reads.
|
|
443
|
+
//
|
|
444
|
+
// The API's scope_guard maps `/lead-lists` writes onto `read:speedrun`
|
|
445
|
+
// (code/api/crates/middleware/src/scope_guard/mod.rs), which every issued grant carries, so the
|
|
446
|
+
// approval, reason and idempotency key are the whole gate here — and they are the same gate
|
|
447
|
+
// every other governed write in this server uses.
|
|
448
|
+
// -----------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
/** The marker-owned Outbound list, or `null` — never a user's list that shares a display name. */
|
|
451
|
+
async function findOutboundList() {
|
|
452
|
+
const body = await api('GET', '/api/v1/lead-lists', { params: { limit: 500, page: 1 } });
|
|
453
|
+
const rows = Array.isArray(body?.data) ? body.data : [];
|
|
454
|
+
return rows.find((row) => row?.description === LEAD_COMMITMENT_MARKER) ?? null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** The Outbound members, named the way the members endpoint names them. */
|
|
458
|
+
async function outboundMembers(listId) {
|
|
459
|
+
const body = await api('GET', `/api/v1/lead-lists/${listId}/members`, {});
|
|
460
|
+
return Array.isArray(body?.data) ? body.data : [];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** The refusal for a live write that lacks approval — checked BEFORE any step runs, because
|
|
464
|
+
* the commit can carry a list-creation step and a refusal must leave nothing behind. */
|
|
465
|
+
function refuseIfUnapproved(args, method, path) {
|
|
466
|
+
if (!isLiveWrite(args)) return null;
|
|
467
|
+
const missing = missingLiveWriteFields(args);
|
|
468
|
+
if (missing.length === 0) return null;
|
|
469
|
+
// `isError` is the MCP-level flag a host reads; `mdError` alone only styles the text. A
|
|
470
|
+
// refused write that reads as a success envelope is how a caller retries with the same
|
|
471
|
+
// missing field — the communications pack sets it for the same reason.
|
|
472
|
+
return {
|
|
473
|
+
...mdError(
|
|
474
|
+
`Live write refused — missing ${missing.join(', ')}`,
|
|
475
|
+
`Nothing was sent to ${method} ${path}. Re-call with dryRun:false plus ${missing.join(', ')}.`,
|
|
476
|
+
),
|
|
477
|
+
isError: true,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
server.tool(
|
|
482
|
+
'commit_lead_to_outbound',
|
|
483
|
+
'Commit a lead to Outbound. On /targets the Outbound half above the line IS Pipeline\'s Outbound stage, so this is the pipeline move for a person: it adds them to the workspace\'s marker-owned Outbound lead list, creating that list on the very first commitment. Use list_outbound_leads to read who is there and release_lead_from_outbound to take someone back out.' +
|
|
484
|
+
governedWriteNote('read:speedrun'),
|
|
485
|
+
{
|
|
486
|
+
personId: z.string().min(1).describe('The person (lead) to commit to Outbound'),
|
|
487
|
+
...governedWriteArgs(z),
|
|
488
|
+
},
|
|
489
|
+
async (args) => {
|
|
490
|
+
try {
|
|
491
|
+
const list = await findOutboundList();
|
|
492
|
+
// A canonical placeholder segment, because the bridge refuses a non-pathname; the note
|
|
493
|
+
// beside it says what a live write does about the missing list.
|
|
494
|
+
const path = `/api/v1/lead-lists/${list ? list.id : 'new-outbound-list'}/members`;
|
|
495
|
+
const refused = refuseIfUnapproved(args, 'POST', path);
|
|
496
|
+
if (refused) return refused;
|
|
497
|
+
|
|
498
|
+
let listId = list?.id ?? null;
|
|
499
|
+
if (!listId && isLiveWrite(args)) {
|
|
500
|
+
// Same approval, same reason, same idempotency key as the member write that follows:
|
|
501
|
+
// the two requests are one decision, and neither route replays by key.
|
|
502
|
+
const created = await api('POST', '/api/v1/lead-lists', {
|
|
503
|
+
body: { name: 'Up Next', description: LEAD_COMMITMENT_MARKER },
|
|
504
|
+
headers: buildMutationHeaders(args),
|
|
505
|
+
});
|
|
506
|
+
listId = created?.data?.id;
|
|
507
|
+
if (!listId) throw new Error('creating the Outbound list returned no id');
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const outcome = await governedWrite(api, args, {
|
|
511
|
+
method: 'POST',
|
|
512
|
+
path: listId ? `/api/v1/lead-lists/${listId}/members` : path,
|
|
513
|
+
body: { members: [{ personId: args.personId }] },
|
|
514
|
+
preview: {
|
|
515
|
+
entity: 'person',
|
|
516
|
+
entityId: args.personId,
|
|
517
|
+
operation: 'commit to Outbound (Pipeline Outbound stage)',
|
|
518
|
+
...(list
|
|
519
|
+
? {}
|
|
520
|
+
: {
|
|
521
|
+
note:
|
|
522
|
+
`The Outbound list does not exist yet in this workspace. A live write first creates it ` +
|
|
523
|
+
`(POST /api/v1/lead-lists with description \`${LEAD_COMMITMENT_MARKER}\`), then adds the person.`,
|
|
524
|
+
}),
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
if (outcome.dryRun) return md(previewMarkdown('Commit to Outbound', outcome.preview));
|
|
528
|
+
return md(
|
|
529
|
+
`## Committed to Outbound\n\n` +
|
|
530
|
+
`- **Person:** ${args.personId}\n` +
|
|
531
|
+
`- **Outbound list:** ${listId}${list ? '' : ' (created on this write)'}\n` +
|
|
532
|
+
`- **Added:** ${outcome.result?.data?.added ?? 'reported by server'}\n\n` +
|
|
533
|
+
`The person now sits above the line on /targets and in Pipeline\'s Outbound stage.\n`,
|
|
534
|
+
);
|
|
535
|
+
} catch (err) {
|
|
536
|
+
return mdError('Commit to Outbound failed', err.message);
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
server.tool(
|
|
542
|
+
'release_lead_from_outbound',
|
|
543
|
+
'Take a lead back out of Outbound — below the line on /targets, out of Pipeline\'s Outbound stage. Removes the person from the marker-owned Outbound lead list; the person record itself is untouched.' +
|
|
544
|
+
governedWriteNote('read:speedrun'),
|
|
545
|
+
{
|
|
546
|
+
personId: z.string().min(1).describe('The person (lead) to release from Outbound'),
|
|
547
|
+
...governedWriteArgs(z),
|
|
548
|
+
},
|
|
549
|
+
async (args) => {
|
|
550
|
+
try {
|
|
551
|
+
const list = await findOutboundList();
|
|
552
|
+
if (!list) {
|
|
553
|
+
return md(
|
|
554
|
+
`## Nothing to release\n\nThis workspace has no Outbound list yet — nobody has been committed, so ` +
|
|
555
|
+
`${args.personId} is already below the line. Nothing was written.\n`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
const path = `/api/v1/lead-lists/${list.id}/members/${encodeURIComponent(args.personId)}`;
|
|
559
|
+
const refused = refuseIfUnapproved(args, 'DELETE', path);
|
|
560
|
+
if (refused) return refused;
|
|
561
|
+
const outcome = await governedWrite(api, args, {
|
|
562
|
+
method: 'DELETE',
|
|
563
|
+
path,
|
|
564
|
+
preview: { entity: 'person', entityId: args.personId, operation: 'release from Outbound' },
|
|
565
|
+
});
|
|
566
|
+
if (outcome.dryRun) return md(previewMarkdown('Release from Outbound', outcome.preview));
|
|
567
|
+
return md(
|
|
568
|
+
`## Released from Outbound\n\n- **Person:** ${args.personId}\n- **Outbound list:** ${list.id}\n\n` +
|
|
569
|
+
`The person is below the line on /targets again and no longer in Pipeline\'s Outbound stage.\n`,
|
|
570
|
+
);
|
|
571
|
+
} catch (err) {
|
|
572
|
+
return mdError('Release from Outbound failed', err.message);
|
|
573
|
+
}
|
|
574
|
+
},
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
server.tool(
|
|
578
|
+
'list_outbound_leads',
|
|
579
|
+
'Who is committed to Outbound right now — the people above the line on /targets, which is Pipeline\'s Outbound stage. Read-only.',
|
|
580
|
+
{},
|
|
581
|
+
async () => {
|
|
582
|
+
try {
|
|
583
|
+
const list = await findOutboundList();
|
|
584
|
+
if (!list) {
|
|
585
|
+
return md(
|
|
586
|
+
`## Outbound\n\nThis workspace has no Outbound list yet: nobody has ever been committed to Outbound, ` +
|
|
587
|
+
`so there is nothing above the line. (Not a failed read — the list is created on the first commitment.)\n`,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
const members = await outboundMembers(list.id);
|
|
591
|
+
if (members.length === 0) {
|
|
592
|
+
return md(`## Outbound — 0 committed\n\nThe Outbound list (${list.id}) exists and is empty: nobody is above the line.\n`);
|
|
593
|
+
}
|
|
594
|
+
const rows = members.map((m, i) => [
|
|
595
|
+
String(i + 1),
|
|
596
|
+
m.personName || m.personId || '\u2014',
|
|
597
|
+
m.personTitle || '\u2014',
|
|
598
|
+
m.personEmail || '\u2014',
|
|
599
|
+
m.personId || '\u2014',
|
|
600
|
+
]);
|
|
601
|
+
return md(
|
|
602
|
+
`## Outbound — ${members.length} committed\n\n` +
|
|
603
|
+
table(['#', 'Person', 'Title', 'Email', 'Person ID'], rows) +
|
|
604
|
+
`\nOutbound list ${list.id}. Each row is above the line on /targets and in Pipeline\'s Outbound stage.\n`,
|
|
605
|
+
);
|
|
606
|
+
} catch (err) {
|
|
607
|
+
return mdError('Could not read Outbound', err.message);
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
);
|
|
420
611
|
}
|
|
@@ -12,9 +12,9 @@ import { getDemoAvailability } from '../../tools/scheduling.js';
|
|
|
12
12
|
export const TOOLSET_REGISTRY = {
|
|
13
13
|
prospecting: {
|
|
14
14
|
name: 'prospecting',
|
|
15
|
-
description: 'Qualify companies, research prospects, get daily priorities, discover leads',
|
|
15
|
+
description: 'Qualify companies, research prospects, get daily priorities, discover leads, commit leads to Outbound',
|
|
16
16
|
tier: 'pro',
|
|
17
|
-
toolCount:
|
|
17
|
+
toolCount: 9,
|
|
18
18
|
},
|
|
19
19
|
intelligence: {
|
|
20
20
|
name: 'intelligence',
|