@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.
@@ -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
- /** Text nobody has written yet, left in a field that is required to be written. */
113
- const PLACEHOLDER =
114
- /(?:^|[^a-z0-9])(?:tbd|tba|todo|fixme|wip|xxx|placeholder|n\/a)(?:[^a-z0-9]|$)|\?{3,}|<[^>]{0,40}>/i;
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 === 'string' && raw.trim() && PLACEHOLDER.test(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
- detail: `The ${label} clause still carries a placeholder. A criterion nobody has finished writing cannot be executed by somebody who did not write the code.`,
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
+ }
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Prospecting Toolset (6 tools, Pro tier)
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: 6,
17
+ toolCount: 9,
18
18
  },
19
19
  intelligence: {
20
20
  name: 'intelligence',