@adrata/adrata-mcp 1.0.30 → 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 CHANGED
@@ -268,6 +268,9 @@ All free tools plus:
268
268
  | `research_person` | Composite: deep person research |
269
269
  | `discover_prospects` | Composite: find new prospects |
270
270
  | `get_next_contacts` | Composite: next best contacts to reach |
271
+ | `commit_lead_to_outbound` | Governed: commit a lead above the /targets line — Pipeline's Outbound stage |
272
+ | `release_lead_from_outbound` | Governed: take a lead back out of Outbound |
273
+ | `list_outbound_leads` | Who is committed to Outbound right now |
271
274
  | `get_competitive_intel` | Composite: competitive analysis |
272
275
  | `get_meeting_brief` | Composite: pre-meeting intelligence |
273
276
  | `get_deal_coaching` | Composite: deal coaching insights |
package/access/tiers.js CHANGED
@@ -242,6 +242,7 @@ export const TOOL_TIERS = {
242
242
  record_work_item_qa_failure_and_release: TIERS.ENTERPRISE,
243
243
  requeue_work_item_qa_after_fix: TIERS.ENTERPRISE,
244
244
  get_work_item_history: TIERS.ENTERPRISE,
245
+ get_work_item_field_changes: TIERS.ENTERPRISE,
245
246
  get_work_item_comments: TIERS.ENTERPRISE,
246
247
  get_work_board_rollup: TIERS.ENTERPRISE,
247
248
  list_work_board_rollups: TIERS.ENTERPRISE,
@@ -250,6 +251,8 @@ export const TOOL_TIERS = {
250
251
  attach_work_item_qa_evidence: TIERS.ENTERPRISE,
251
252
  verify_work_item_qa_evidence_playback: TIERS.ENTERPRISE,
252
253
  move_work_item: TIERS.ENTERPRISE,
254
+ preview_work_item_acceptance_reaffirmation: TIERS.ENTERPRISE,
255
+ reaffirm_work_item_current_acceptance: TIERS.ENTERPRISE,
253
256
  transfer_work_item_between_boards: TIERS.ENTERPRISE,
254
257
  set_work_item_tag: TIERS.ENTERPRISE,
255
258
  block_work_item: TIERS.ENTERPRISE,
@@ -423,6 +426,18 @@ export const TOOL_TIERS = {
423
426
  // get_speedrun_list already mapped above
424
427
  discover_prospects: TIERS.PRO,
425
428
  get_next_contacts: TIERS.PRO,
429
+ // The Outbound trio, registered in `toolsets/prospecting.js` beside `get_next_contacts` and
430
+ // named in its header alongside it. They arrived on the desktop branch while this tier map
431
+ // grew on main, so neither side could see the gap: the registry had three tools this table
432
+ // did not, and `getToolTier` falls back to ENTERPRISE for anything unlisted -- which would
433
+ // have quietly put a PRO workspace's own Outbound moves behind the top tier rather than
434
+ // erroring. The exhaustiveness test is what refuses the fallback, and it is right to.
435
+ //
436
+ // PRO, matching the toolset they ship in: committing a lead to Outbound IS the pipeline move
437
+ // for a person, so it belongs with the prospecting reads that lead to it, not above them.
438
+ commit_lead_to_outbound: TIERS.PRO,
439
+ list_outbound_leads: TIERS.PRO,
440
+ release_lead_from_outbound: TIERS.PRO,
426
441
 
427
442
  // --- COMPOSITE TOOLSETS: intelligence (pro tier) ---
428
443
  get_competitive_intel: TIERS.PRO,
package/api-bridge.js CHANGED
@@ -298,6 +298,12 @@ const PATH_WRITE_SCOPES = [
298
298
  ['/api/v1/contacts', 'write:people'],
299
299
  ['/api/v1/people', 'write:people'],
300
300
  ['/api/v1/leads', 'write:people'],
301
+ // Lead lists — including the marker-owned Outbound list that /targets and
302
+ // Pipeline share — are gated by the API on read:speedrun for reads AND
303
+ // writes (scope_guard/mod.rs: `path.starts_with("/lead-lists")`). Naming it
304
+ // here turns the preview's "unknown (no mapping for this path)" into the
305
+ // scope the server actually checks.
306
+ ['/api/v1/lead-lists', 'read:speedrun'],
301
307
  ['/api/v1/agent-tasks', 'write:tasks'],
302
308
  ['/api/v1/tasks', 'write:tasks'],
303
309
  ['/api/v1/actions', 'write:actions'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adrata/adrata-mcp",
3
- "version": "1.0.30",
3
+ "version": "1.0.40",
4
4
  "description": "Adrata MCP Server \u2014 connect Claude Code, Codex, Gemini, Cursor, and other AI tools to your CRM. About 275 tools registered at startup for companies, people, deals, actions, buyer groups, warm intros, webhooks and intelligence, plus 65 more behind eight named toolsets you load with enable_toolset.",
5
5
  "type": "module",
6
6
  "main": "server.js",
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "start": "node server.js",
12
- "test": "node --test analytics.test.js server.test.js api-bridge.test.js http/edge-block.test.js http/rate-limit.test.js audit-flush.test.js buyer-group-writes.test.js note-writes.test.js mcp-spec.test.js packaging.test.js product-profile.test.js security.test.js security.cap-contract.test.js tool-annotations.test.js toolsets.test.js toolsets/communications.test.js access/auth.test.js access/oauth-callback.test.js access/oauth-session.test.js access/oauth-capabilities.test.js access/oauth-scope-grantability.test.js scripts/local-dev-server.test.js tools/competitive-coverage.test.js tools/describe-count.test.js tools/email-tools.test.js tools/scheduling.test.js tools/work-board-tools.test.js tools/work-hub/audit.test.js tools/work-hub/criteria-quality.test.js tools/roadmap-tools.test.js tools/provisioning/onboarding-tools.test.js tools/source-control/connection-tools.test.js governance/money.test.js http/rate-limit-policy.test.js"
12
+ "test": "node --test analytics.test.js server.test.js api-bridge.test.js http/edge-block.test.js http/rate-limit.test.js audit-flush.test.js buyer-group-writes.test.js note-writes.test.js mcp-spec.test.js packaging.test.js product-profile.test.js security.test.js security.cap-contract.test.js tool-annotations.test.js toolsets.test.js toolsets/communications.test.js toolsets/prospecting.test.js access/auth.test.js access/oauth-callback.test.js access/oauth-session.test.js access/oauth-capabilities.test.js access/oauth-scope-grantability.test.js scripts/local-dev-server.test.js tools/competitive-coverage.test.js tools/describe-count.test.js tools/email-tools.test.js tools/scheduling.test.js tools/work-board-tools.test.js tools/work-hub/audit.test.js tools/work-hub/field-changes.test.js tools/work-hub/criteria-quality.test.js tools/roadmap-tools.test.js tools/provisioning/onboarding-tools.test.js tools/source-control/connection-tools.test.js governance/money.test.js http/rate-limit-policy.test.js"
13
13
  },
14
14
  "keywords": [
15
15
  "mcp",
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "com.adrata/adrata-mcp",
4
4
  "description": "Adrata revenue-intelligence MCP server: companies, people, opportunities, actions, buyer groups, enrichment, email, and workspace operations for AI agents.",
5
5
  "status": "active",
6
- "version": "1.0.30",
6
+ "version": "1.0.40",
7
7
  "websiteUrl": "https://adrata.com/developers",
8
8
  "repository": {
9
9
  "url": "https://github.com/adrata/adrata",
@@ -15,7 +15,7 @@
15
15
  "registryType": "npm",
16
16
  "registryBaseUrl": "https://registry.npmjs.org",
17
17
  "identifier": "@adrata/adrata-mcp",
18
- "version": "1.0.30",
18
+ "version": "1.0.40",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  },
@@ -178,8 +178,14 @@ does not establish that the screen is visually correct.
178
178
  `ADRATA_MCP_CONFIG_DIR`. It opens the exact deployed product bundle through a
179
179
  one-use, read-only loopback session: upstream reads keep the QA OAuth identity,
180
180
  writes fail locally, and private media capabilities remain process-private.
181
- Record the returned session fingerprint and the deployed app build in the
182
- playback evidence, then close the bridge.
181
+ It defaults to the canonical deployment on both halves the canonical API for
182
+ the card and `https://adrata.com` for the bundle — and serves that origin's own
183
+ response policy, so whether a recording plays is decided by the deployed
184
+ `media-src` rather than by no policy at all. Record the returned session
185
+ fingerprint, the deployed app build, AND the `Deployed security policy served
186
+ by this bridge` line in the playback evidence, then close the bridge. If that
187
+ line is the WARNING instead, the bridge sent no policy and the playback it
188
+ showed says nothing about the deployed one.
183
189
 
184
190
  ## Manage cards as outcomes, not bug counters
185
191
 
@@ -36,22 +36,34 @@ implementation-and-submit path below.
36
36
  available here, and the reviewer's comment is usually the one thing that
37
37
  would have stopped you.
38
38
 
39
- **A card carrying a `flag` is not workable.** The flag means somebody has already
40
- found a problem with the card ITSELF unclear scope, no repro, a blocked
41
- dependency, a question waiting on an answer and its `reason` says which. It is
42
- a different claim from the tag, which is only about urgency: a flagged Critical
43
- card is still not something to start on. Read the comments for the surrounding
44
- argument and pick a different card, unless you are the person who can answer it.
45
- If you resolve it, clear the flag with `flag_work_item` (`flagged: false`) and
46
- say what resolved it. Anyone may clear one, which is exactly why every clear is
47
- recorded as a comment.
48
-
49
- If the card is not workable no repro, no expected behaviour, ambiguous scope —
50
- **flag it** with `flag_work_item` and the one question that would unblock it,
51
- then stop. Do not invent the missing half. Flagging is what turns "I could not
52
- work this" into something the next person sees before they pick it up, rather
53
- than a sentence in your report that nobody reads. An unworkable card is a triage
54
- problem, not a coding problem.
39
+ **Read the flag's reason and `blocksClaim` separately.** A flag preserves a
40
+ finding; `blocksClaim` says whether an agent can take the work. An ordinary
41
+ failed test, fixture repair or deploy prerequisite that an agent can resolve
42
+ stays claimable with `blocksClaim: false`. Keep the finding visible while
43
+ repairing it. Urgency still comes from the tag, not the flag.
44
+
45
+ Use `blocksClaim: true` only for an explicit prerequisite agents cannot satisfy,
46
+ such as owner authorization, vendor consent or a product decision. Read its
47
+ comments and take another workable card until that prerequisite is satisfied.
48
+ When the field is absent, inspect the deployed API and tool contract: older
49
+ versions treat every flag as blocking. Do not clear an unresolved finding to
50
+ work around an old claim gate; record the release dependency instead.
51
+
52
+ When the finding is resolved, use `flag_work_item` (`flagged: false`) with the
53
+ `expectedFlagGeneration` captured for that finding and a concrete resolution
54
+ reason. A stale-generation refusal means a newer finding must be read and
55
+ resolved; substituting its generation without doing that loses the newer
56
+ failure. If the generation is absent, do not invent one or claim the older
57
+ client supplied concurrent-update protection. Record the release/client
58
+ prerequisite. For a deployed QA repair, use `requeue_work_item_qa_after_fix` with the
59
+ actual failed/deployed builds and `repairedFlagGeneration`, after proving the
60
+ fix's relevant blobs are deployed. Clearing a flag is not a QA pass.
61
+
62
+ If a missing repro, expected behavior or scope decision prevents work, record
63
+ what is missing and the next action on the card. Set a blocking flag only when
64
+ the prerequisite needs someone outside the agent work loop. Do not invent the
65
+ missing half, and do not turn an ordinary engineering failure into an owner
66
+ decision.
55
67
 
56
68
  ## Claim it, in the same move, FIRST
57
69
 
@@ -77,8 +89,10 @@ Three reasons, and the third is the real one:
77
89
  Reuse the same `idempotencyKey` if the call fails and you retry. The server
78
90
  replays rather than appending a second transition.
79
91
 
80
- **If the claim is refused**, the card already belongs to somebody: pick a
81
- different one. `force: true` exists it takes the card over and demands a reason
92
+ **If the claim is refused**, read its actual reason. Another worker, missing
93
+ scope, readiness requirements or an older deployed gate can each refuse a claim.
94
+ Resolve an agent-repairable prerequisite or pick another workable card; do not
95
+ treat every refusal as proof of ownership. `force: true` exists — it takes the card over and demands a reason
82
96
  saying why — but it is for a card whose owner is genuinely gone, not for working
83
97
  around a refusal. Taking someone's card because it was convenient is the one move
84
98
  on this board that costs trust permanently. If you use it, say so in your report,
@@ -114,27 +128,22 @@ Two things to carry back to the card:
114
128
  session), so the next person at aligning can ask you what you meant; what it
115
129
  must not arrive with is a list of outcomes nobody can check.
116
130
 
117
- ## Tick what you ran
118
-
119
- Before you move the card on, tick the criteria you actually executed, with
120
- `satisfy_work_item_acceptance_criterion` and the evidence in `note` — a build
121
- number, a run link, the caveat that makes it honest. An untouched checkbox says
122
- nothing about whether the check passed; it says nobody has been near it, which
123
- is the same thing an abandoned card says.
124
-
125
- Ticking from a build column records `claimed`, not `verified`. At a QA gate, an
126
- authenticated human may verify even when they own the card or authored the
127
- criterion: the human is the accountable exception when one person operates the
128
- workspace. An agent verifies only when its credential differs from the
129
- criterion author, and QA2 requires a different agent credential from the one
130
- whose clean receipt opened QA1. One AI cannot certify both gates. Tick anyway
131
- when the result is `claimed` it is a real state that says the work is believed
132
- done and tells QA what to rerun. Never tick a box you did not execute merely to
133
- improve the count.
134
-
135
- If a tick turns out to be wrong, `unsatisfy_work_item_acceptance_criterion`
136
- takes it off. It clears the note with it, so if the evidence is worth keeping,
137
- put it on the card with `comment_on_work_item` first.
131
+ ## Record builder readiness
132
+
133
+ Before QA1 entry, record the exact deployed build, implementation and test
134
+ blobs, executed checks, failures and replay instructions in the builder
135
+ handoff. Leave acceptance criteria **unmet and unverified** for independent QA,
136
+ as required by `AGENTS.md` and the recorded staging QA standard. Readiness means
137
+ deployed, testable and free of known card-specific failure; a missing fixture,
138
+ failing relevant check or unexplained runtime failure keeps the card in its
139
+ build column.
140
+
141
+ The builder releases its build claim at handoff and does not consume a QA-pass
142
+ lease or credential. A fresh independent reviewer follows `qa-the-card`; a
143
+ fixer never certifies its own repair. If an earlier unsupported tick needs to
144
+ be cleared, preserve its evidence in a comment before using
145
+ `unsatisfy_work_item_acceptance_criterion` through the governed current-stage
146
+ claim procedure.
138
147
 
139
148
  ## Submit it
140
149
 
@@ -1,3 +1,4 @@
1
+ import { registerWorkItemFieldChanges } from './work-hub/field-changes.js';
1
2
  import { auditWorkHubBoards, deliveryContradictions } from './work-hub/audit.js';
2
3
  import { describeCriteriaQuality, inspectCriteria } from './work-hub/criteria-quality.js';
3
4
  import { createHash } from 'node:crypto';
@@ -184,6 +185,88 @@ export function describeMissingAcceptanceCriteria(criteria) {
184
185
  return 'This card would have no executable acceptance criteria. It can be captured, but it is not ready for build or QA until at least one where/when/then criterion is added.';
185
186
  }
186
187
 
188
+ /**
189
+ * Why a card with no product will not reach a QA lane, or `undefined` if it has
190
+ * one.
191
+ *
192
+ * `product` reads like an optional label and is not one. Every QA drain claims
193
+ * for ONE exact product, and the dispatcher selects on `i.product = $4` — SQL
194
+ * equality against NULL is not true, so an unclassified card is not ranked last
195
+ * or shown late, it is absent from every product queue at once while each of
196
+ * them reports itself empty. Measured 2026-09-08 on the Adrata board: 3 of 7
197
+ * cards in Staging QA2 carried no product, a product-scoped census returned 0
198
+ * workable, and those three were the only workable cards there.
199
+ *
200
+ * Advisory rather than a refusal, on the same argument `describeMissingBody`
201
+ * makes: a refused capture is how work stops reaching the board at all. The
202
+ * preview is where the author is already deciding, and this is the cheapest
203
+ * moment the omission is ever visible.
204
+ */
205
+ /**
206
+ * The population, the exclusions, and which exclusions are themselves suspect.
207
+ *
208
+ * Returned whether or not a card was claimed. Shaped as counts plus the cards
209
+ * behind them, because a total is the last thing to trust and a reader who
210
+ * doubts the summary needs the rows.
211
+ */
212
+ export function describeEmptyQueue(census) {
213
+ const finished = census?.skippedFinished ?? [];
214
+ const unrouted = census?.unroutedProduct ?? [];
215
+ return {
216
+ considered: census?.considered ?? 0,
217
+ truncated: census?.truncated === true,
218
+ skippedFinishedCount: finished.length,
219
+ skippedFinished: finished,
220
+ unroutedProductCount: unrouted.length,
221
+ unroutedProduct: unrouted,
222
+ };
223
+ }
224
+
225
+ /**
226
+ * What a lane should DO about an empty answer.
227
+ *
228
+ * Ordered by what changes the lane's next action, most actionable first. The
229
+ * unrouted clause comes before the flat "nothing remains" sentence on purpose:
230
+ * a gate holding unclassified cards is NOT empty, and saying it is would be the
231
+ * same false all-clear in new words.
232
+ */
233
+ export function emptyQueueNote(product, qaGate, skippedBlocked, census) {
234
+ const parts = [];
235
+ const finished = census?.skippedFinished?.length ?? 0;
236
+ const unrouted = census?.unroutedProduct?.length ?? 0;
237
+ const considered = census?.considered ?? 0;
238
+
239
+ if (skippedBlocked?.length) {
240
+ parts.push(
241
+ `${skippedBlocked.length} card(s) in this gate are blocked by another card. Do not re-claim — read the blockers in skippedBlocked.`
242
+ );
243
+ }
244
+ if (finished) {
245
+ parts.push(
246
+ `${finished} card(s) were skipped because every acceptance criterion is already verified in this dwell. They are DONE, not pending — do not re-run a pass on them; they are listed in census.skippedFinished for whoever advances them.`
247
+ );
248
+ }
249
+ if (unrouted) {
250
+ parts.push(
251
+ `${unrouted} card(s) stand in ${qaGate} with NO product set. They are invisible to every product-scoped lane at once, including this one — this gate is unrouted, not empty. Set a product on them (they are listed in census.unroutedProduct) rather than concluding there is no work here.`
252
+ );
253
+ }
254
+ if (census?.truncated) {
255
+ parts.push(
256
+ 'The candidate window was full, so the ranking saw only its first page; this census describes that page, not the whole gate.'
257
+ );
258
+ }
259
+ if (!parts.length) {
260
+ return `No eligible unclaimed ${product} ${qaGate} pass remains on any visible board: ${considered} candidate(s) were considered and none was excluded. Pick another lane.`;
261
+ }
262
+ return `No workable ${product} ${qaGate} pass was handed out. ${considered} candidate(s) considered. ${parts.join(' ')}`;
263
+ }
264
+
265
+ export function describeMissingProduct(product) {
266
+ if (String(product ?? '').trim()) return undefined;
267
+ return 'This card would arrive with no `product`. QA lanes drain ONE exact product at a time and select on an exact match, so a card without one is invisible to every lane at once rather than merely deprioritised — and each of those queues reports itself empty. Set `product` unless you intend this card to be worked by hand.';
268
+ }
269
+
187
270
  /**
188
271
  * Make the created card's own criteria counter agree with the criteria returned
189
272
  * beside it.
@@ -267,6 +350,7 @@ export function registerWorkBoardTools(
267
350
  uploadQaEvidenceFile = uploadLocalQaEvidenceFile,
268
351
  }
269
352
  ) {
353
+ registerWorkItemFieldChanges(server, { z, api, ok });
270
354
  const GOVERNED_NOTE =
271
355
  ' Governed write: previews by default. A live write requires dryRun:false plus approved:true, a reason, and an idempotencyKey (reuse the SAME key on retry — a duplicate move would read as the card having bounced between columns).';
272
356
  // Capability material belongs to this MCP process, not to the model transcript.
@@ -625,18 +709,26 @@ Start here for "what should I work on". With includeUnassigned it also returns t
625
709
  const outcome = data?.data ?? {};
626
710
  const selected = outcome.claimed;
627
711
  const skippedBlocked = outcome.skippedBlocked ?? [];
712
+ const census = outcome.census ?? {};
628
713
  if (!selected) {
629
- // The empty-handed case is two different situations and a lane acts on
630
- // them differently: an empty gate means pick another lane, while a
631
- // fully-blocked one means go and look at the blockers.
714
+ // The empty-handed case is FOUR different situations and a lane acts on
715
+ // them differently: an empty gate means pick another lane; a
716
+ // fully-blocked one means go and look at the blockers; a gate whose
717
+ // work is all verified means the drain is finished; and cards with no
718
+ // product mean the queue is not empty at all, it is unrouted.
719
+ //
720
+ // So this answer shows its working rather than asserting a conclusion.
721
+ // A signal that can tell a worker to STOP has to be contradictable —
722
+ // this factory has already believed "there is genuinely nothing to pick
723
+ // up" once, from a count that could not be wrong out loud, while 211 of
724
+ // 212 claims sat on cards that had left the column.
632
725
  return ok({
633
726
  claimed: false,
634
727
  qaGate: args.qaGate,
635
728
  product: args.product,
636
729
  skippedBlocked,
637
- note: skippedBlocked.length
638
- ? `No workable ${args.product} ${args.qaGate} pass remains: ${skippedBlocked.length} card(s) in this gate are blocked by another card. Do not re-claim — look at the blockers listed in skippedBlocked.`
639
- : `No eligible unclaimed ${args.product} ${args.qaGate} pass remains on any visible board.`,
730
+ census: describeEmptyQueue(census),
731
+ note: emptyQueueNote(args.product, args.qaGate, skippedBlocked, census),
640
732
  });
641
733
  }
642
734
  const visible = rememberWorkerLease(
@@ -648,6 +740,11 @@ Start here for "what should I work on". With includeUnassigned it also returns t
648
740
  return ok({
649
741
  claimed: true,
650
742
  skippedBlocked,
743
+ // Reported on the winning path as well. Three finished cards and two
744
+ // unrouted ones in this gate are a board finding whoever is reading,
745
+ // and a lane that only sees them on the empty pass learns about them
746
+ // last -- by which point it has no reason to look.
747
+ census: describeEmptyQueue(census),
651
748
  workItemId: selected.workItemId,
652
749
  boardId: selected.boardId,
653
750
  boardName: selected.boardName,
@@ -746,10 +843,11 @@ Start here for "what should I work on". With includeUnassigned it also returns t
746
843
 
747
844
  server.tool(
748
845
  'record_work_item_qa_failure_and_release',
749
- `Durably record why the current QA pass cannot proceed, then safely release the process-private lease without moving the card. A failed criterion is flagged first, un-ticked, and left on the same QA card waiting for a fix and deployed build; it is NOT immediately reclaimable against the unchanged build. A dependency blocker is flagged in place. Both paths release the lease only after the durable safety state exists, so claim-next skips the card until an explicit requeue. This is one governed recovery recipe, not a new bug card and not a QA bounce.${GOVERNED_NOTE}`,
846
+ `Durably record why the current QA pass cannot proceed, then safely release the process-private lease without moving the card. A failed criterion is flagged first, un-ticked, and left on the same QA card for the next worker to diagnose, repair, deploy and independently retest. Ordinary failure and dependency handoffs remain claimable. Set blocksClaim:true only for an explicit prerequisite no agent can satisfy, such as owner consent or a vendor credential. Both paths release the lease only after the failure is durably recorded; a claimable handoff is not a QA pass. This is one governed recovery recipe, not a new bug card and not a QA bounce.${GOVERNED_NOTE}`,
750
847
  {
751
848
  itemId: z.string().describe('Card currently claimed by this MCP worker.'),
752
849
  disposition: z.enum(['failed_criterion', 'blocked_dependency']),
850
+ blocksClaim: z.boolean().optional().default(false).describe('Stop pickup only for an explicit prerequisite no agent can satisfy. Ordinary failed work remains claimable for repair and retest.'),
753
851
  criterionId: z.string().optional().describe('Required for failed_criterion; the exact acceptance criterion contradicted.'),
754
852
  details: z.string().describe('Concrete observed failure or blocker and the recovery needed. Stored on the card.'),
755
853
  dryRun: z.boolean().optional().default(true),
@@ -767,7 +865,7 @@ Start here for "what should I work on". With includeUnassigned it also returns t
767
865
  const preview = validateApiBridgeRequest({
768
866
  method: 'POST',
769
867
  path: `/api/v1/work-items/${encodeURIComponent(args.itemId)}/comments`,
770
- body: { disposition: args.disposition, criterionId: args.criterionId, details: args.details },
868
+ body: { disposition: args.disposition, criterionId: args.criterionId, details: args.details, blocksClaim: args.blocksClaim === true },
771
869
  dryRun: args.dryRun, approved: args.approved, reason: args.reason,
772
870
  idempotencyKey: args.idempotencyKey, grantedScope: getGrantedScope(),
773
871
  });
@@ -779,10 +877,11 @@ Start here for "what should I work on". With includeUnassigned it also returns t
779
877
  disposition: args.disposition,
780
878
  criterionId: args.criterionId ?? null,
781
879
  details: args.details,
880
+ blocksClaim: args.blocksClaim === true,
782
881
  },
783
- note: args.disposition === 'failed_criterion'
784
- ? 'The card will stay in its QA column, be flagged and un-ticked, then release the pass while waiting for a fixed deployed build.'
785
- : 'The card will be flagged in place and the pass handed off.',
882
+ note: args.blocksClaim === true
883
+ ? 'The card will be flagged in place and withheld from pickup until the explicit prerequisite is resolved.'
884
+ : 'The card will stay in place, retain its failure reason, and remain claimable for repair and retest after this pass is released.',
786
885
  });
787
886
  }
788
887
  const leaseHeaders = workerLeaseHeaders(args.itemId);
@@ -795,11 +894,11 @@ Start here for "what should I work on". With includeUnassigned it also returns t
795
894
  const flagReason = args.disposition === 'failed_criterion'
796
895
  ? `QA criterion failure (${args.criterionId}): ${args.details.trim()} Waiting for a fixed deployed build before requeue.`
797
896
  : args.details.trim();
798
- // Flag first. If this composite is interrupted after any following step,
799
- // claim-next still cannot put another worker into a hot loop on the same
800
- // broken build. Every child is endpoint-idempotent, so a retry continues.
801
- await api('POST', `/api/v1/work-items/${encodeURIComponent(args.itemId)}/flag`, {
802
- body: { flagged: true, reason: flagReason, idempotencyKey: recordKey },
897
+ // Preserve the contradiction before un-ticking or releasing. Ordinary
898
+ // failures remain claimable so another worker can repair them; only an
899
+ // explicitly requested hold blocks pickup. Child writes are idempotent.
900
+ const recordedFlag = await api('POST', `/api/v1/work-items/${encodeURIComponent(args.itemId)}/flag`, {
901
+ body: { flagged: true, reason: flagReason, blocksClaim: args.blocksClaim === true, idempotencyKey: recordKey },
803
902
  headers: mutationHeadersForItem({ ...args, idempotencyKey: recordKey }, args.itemId),
804
903
  });
805
904
  if (args.disposition === 'failed_criterion') {
@@ -817,8 +916,10 @@ Start here for "what should I work on". With includeUnassigned it also returns t
817
916
  recorded: true,
818
917
  disposition: args.disposition,
819
918
  requeued: false,
820
- blocked: true,
919
+ blocked: args.blocksClaim === true,
920
+ claimable: args.blocksClaim !== true,
821
921
  waitingForFix: args.disposition === 'failed_criterion',
922
+ flagGeneration: recordedFlag?.data?.recordedFlagGeneration ?? null,
822
923
  releaseKind,
823
924
  ...release,
824
925
  });
@@ -827,9 +928,10 @@ Start here for "what should I work on". With includeUnassigned it also returns t
827
928
 
828
929
  server.tool(
829
930
  'requeue_work_item_qa_after_fix',
830
- `Clear a failed QA card’s waiting-for-fix flag only after a different exact build has been deployed and its relationship to the failed build has been checked. This does not claim the card; it makes the same card eligible for a fresh QA pass. The clear reason durably records both SHAs and the fix/deployment reference. The worker must verify ancestry in source control before calling this tool; this endpoint rejects the unchanged SHA but does not pretend to be a Git ancestry oracle.${GOVERNED_NOTE}`,
931
+ `Clear a failed QA card’s waiting-for-fix flag only after a different exact build has been deployed and its relationship to the failed build has been checked. This does not claim or certify the card; it clears the resolved hold or failure handoff before a fresh independent QA pass. The clear reason durably records both SHAs and the fix/deployment reference. The worker must verify ancestry in source control before calling this tool; this endpoint rejects the unchanged SHA but does not pretend to be a Git ancestry oracle.${GOVERNED_NOTE}`,
831
932
  {
832
933
  itemId: z.string().describe('Flagged QA card left in place by record_work_item_qa_failure_and_release.'),
934
+ repairedFlagGeneration: z.string().min(1).describe('The flag.generation captured for the failure actually repaired. Never substitute a newer flag generation without resolving that failure.'),
833
935
  failedBuildSha: z.string().length(40).describe('Exact lowercase 40-character SHA of the build that failed.'),
834
936
  deployedBuildSha: z.string().length(40).describe('Exact lowercase 40-character SHA of the newer deployed descendant build.'),
835
937
  details: z.string().describe('How ancestry and deployment were verified, including the fix PR or deployment reference.'),
@@ -849,6 +951,9 @@ Start here for "what should I work on". With includeUnassigned it also returns t
849
951
  if (!args.details?.trim()) {
850
952
  return ok({ error: true, message: 'details must name the ancestry check and deployment/fix reference.' });
851
953
  }
954
+ if (!args.repairedFlagGeneration?.trim()) {
955
+ return ok({ error: true, message: 'repairedFlagGeneration is required. Read and resolve the current failure before retrying; never substitute a newer generation for an older repair.' });
956
+ }
852
957
  const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/flag`;
853
958
  const clearReason = `QA requeue: deployed ${args.deployedBuildSha} after failed ${args.failedBuildSha}. ${args.details.trim()}`;
854
959
  const preview = validateApiBridgeRequest({
@@ -856,13 +961,13 @@ Start here for "what should I work on". With includeUnassigned it also returns t
856
961
  reason: args.reason, idempotencyKey: args.idempotencyKey, grantedScope: getGrantedScope(),
857
962
  });
858
963
  if (preview.dryRun) {
859
- return ok({ ...preview, wouldRequeue: { itemId: args.itemId, failedBuildSha: args.failedBuildSha, deployedBuildSha: args.deployedBuildSha } });
964
+ return ok({ ...preview, wouldRequeue: { itemId: args.itemId, failedBuildSha: args.failedBuildSha, deployedBuildSha: args.deployedBuildSha, repairedFlagGeneration: args.repairedFlagGeneration } });
860
965
  }
861
966
  const data = await api('POST', path, {
862
- body: { flagged: false, reason: clearReason, idempotencyKey: args.idempotencyKey },
967
+ body: { flagged: false, reason: clearReason, expectedFlagGeneration: args.repairedFlagGeneration, idempotencyKey: args.idempotencyKey },
863
968
  headers: buildMutationHeaders(args),
864
969
  });
865
- return ok({ requeued: true, item: data?.data, failedBuildSha: args.failedBuildSha, deployedBuildSha: args.deployedBuildSha });
970
+ return ok({ requeued: true, item: data?.data, failedBuildSha: args.failedBuildSha, deployedBuildSha: args.deployedBuildSha, repairedFlagGeneration: args.repairedFlagGeneration });
866
971
  }
867
972
  );
868
973
 
@@ -1272,6 +1377,41 @@ Start here for "what should I work on". With includeUnassigned it also returns t
1272
1377
  }
1273
1378
  );
1274
1379
 
1380
+ server.tool(
1381
+ 'preview_work_item_acceptance_reaffirmation',
1382
+ 'Read the exact current criterion definitions and preserved independent QA evidence for a legacy human-accepted Ready to Ship card. Returns a content digest for a separate explicit owner decision. Original acceptance scope remains unknown; this never repairs missing QA.',
1383
+ { itemId: z.string() },
1384
+ async (args) => {
1385
+ const data = await api('GET', `/api/v1/work-items/${encodeURIComponent(args.itemId)}/acceptance-reaffirmation`);
1386
+ return ok(data?.data);
1387
+ }
1388
+ );
1389
+
1390
+ server.tool(
1391
+ 'reaffirm_work_item_current_acceptance',
1392
+ `Record a separate owner approval of the exact CURRENT scope and existing QA evidence from preview_work_item_acceptance_reaffirmation. Only the authenticated delegated OAuth owner can do this. The original legacy acceptance receipt stays unchanged and its original scope stays unknown. This neither verifies criteria nor fills missing QA receipts. Use only on the owner's explicit instruction after reviewing the preview and limitation.${GOVERNED_NOTE}`,
1393
+ {
1394
+ itemId: z.string(), historyId: z.string(), originalReceiptId: z.string(),
1395
+ contentSha256: z.string().regex(/^[0-9a-f]{64}$/),
1396
+ acknowledgeOriginalScopeUnknown: z.literal(true),
1397
+ receivingBuildSha: z.string().regex(/^[0-9a-f]{40}$/),
1398
+ expiresAt: z.number().int(),
1399
+ dryRun: z.boolean().optional(), approved: z.boolean().optional(),
1400
+ reason: z.string().optional(), idempotencyKey: z.string().optional(),
1401
+ },
1402
+ async (args) => {
1403
+ const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/acceptance-reaffirmation`;
1404
+ const preview = validateApiBridgeRequest({ method: 'POST', path, dryRun: args.dryRun,
1405
+ approved: args.approved, reason: args.reason, idempotencyKey: args.idempotencyKey,
1406
+ grantedScope: getGrantedScope() });
1407
+ const { dryRun: _dryRun, ...body } = args;
1408
+ if (preview?.dryRun) return ok({ ...preview, wouldReaffirm: body,
1409
+ limitation: 'Original legacy acceptance scope remains unknown. This is a separate current acceptance.' });
1410
+ const data = await api('POST', path, { body, headers: buildMutationHeaders(args) });
1411
+ return ok({ reaffirmed: true, receipt: data?.data });
1412
+ }
1413
+ );
1414
+
1275
1415
  server.tool(
1276
1416
  'move_work_item',
1277
1417
  `Move a card to another column on the same board — and, outside QA, use claim:true to pick it up in the same action. The server does this in ONE transaction: it closes the card's open dwell, appends the transition to the history, records you as the handler of the pass the card is now on, and updates the card. Dropping a card into the column it is already in is a REORDER and deliberately does not restamp the stage timer.
@@ -1304,6 +1444,20 @@ QA PASSES ARE THE EXCEPTION. An executable QA worker must hold the process-priva
1304
1444
  ),
1305
1445
  dryRun: z.boolean().optional().describe('Defaults to true. Set false for a live move.'),
1306
1446
  approved: z.boolean().optional().describe('Required true for a live move.'),
1447
+ presenceAttestation: z.string().optional().describe('Single-use human-minted presence token. This remains separate from owner delegation.'),
1448
+ ownerApproval: z.object({
1449
+ approved: z.literal(true),
1450
+ itemId: z.string(),
1451
+ toColumnId: z.string(),
1452
+ idempotencyKey: z.string(),
1453
+ acceptanceReceiptId: z.string(),
1454
+ reaffirmationReceiptId: z.string().optional().describe('Separate current acceptance receipt for legacy original scope; never a backfilled original receipt.'),
1455
+ reviewedBuildSha: z.string().regex(/^[0-9a-f]{40}$/),
1456
+ receivingBuildSha: z.string().regex(/^[0-9a-f]{40}$/),
1457
+ expiresAt: z.number().int(),
1458
+ }).strict().optional().describe(
1459
+ 'Explicit owner-delegated Production request. Use only after the owner approves this exact card, destination, accepted QA2 closing receipt and reviewed delivery build. Expires within five minutes. Server requires the authenticated owner’s delegated OAuth credential and preserves species agent. This is delegated authority, not cryptographic proof of human presence. receivingBuildSha is the live API build; reviewedBuildSha is the separately reviewed component build and must match receipt.commitSha.'
1460
+ ),
1307
1461
  reason: z
1308
1462
  .string()
1309
1463
  .optional()
@@ -1426,6 +1580,8 @@ QA PASSES ARE THE EXCEPTION. An executable QA worker must hold the process-priva
1426
1580
  claim: args.claim === true,
1427
1581
  force: args.force === true,
1428
1582
  receipt: args.receipt !== undefined,
1583
+ ownerApproval: args.ownerApproval,
1584
+ presenceAttestation: args.presenceAttestation !== undefined,
1429
1585
  },
1430
1586
  wip: projectColumnWip({
1431
1587
  limit: target.wipLimit,
@@ -1493,6 +1649,8 @@ QA PASSES ARE THE EXCEPTION. An executable QA worker must hold the process-priva
1493
1649
  acknowledgeUnmetCriteria:
1494
1650
  args.acknowledgeUnmetCriteria === true ? true : undefined,
1495
1651
  receipt: args.receipt,
1652
+ ownerApproval: args.ownerApproval,
1653
+ presenceAttestation: args.presenceAttestation,
1496
1654
  },
1497
1655
  headers: mutationHeadersForItem(args, args.itemId),
1498
1656
  });
@@ -1979,6 +2137,9 @@ YOU DO NOT NEED TO SAY WHO IS CREATING IT. The card records its creator from you
1979
2137
  // capture is how work stops reaching the board at all.
1980
2138
  note: describeMissingBody(args.body),
1981
2139
  readinessNote: describeMissingAcceptanceCriteria(args.acceptanceCriteria),
2140
+ // A missing product is not a missing label: it is the difference
2141
+ // between a card a QA lane can be handed and one no lane can see.
2142
+ routingNote: describeMissingProduct(args.product),
1982
2143
  // Advisory, and only ever present when it has something to say. The
1983
2144
  // preview is where an author is already deciding whether to approve,
1984
2145
  // and a criterion that cannot fail is cheapest to fix before the card
@@ -2602,34 +2763,33 @@ TO @-MENTION SOMEBODY, write the token \`<@userId>\` in the body — the id come
2602
2763
 
2603
2764
  server.tool(
2604
2765
  'flag_work_item',
2605
- `Flag a card as having a problem WITH THE CARD — unclear scope, no repro, a blocked dependency, a question waiting on an answer — or clear a flag once it is resolved. A flagged card is drawn differently on the board so nobody picks it up by mistake.
2766
+ `Flag unresolved work for the next repair and retest, or clear a flag once its reason is resolved. Ordinary failures remain visible and claimable.
2606
2767
 
2607
- A FLAG IS NOT A TAG. The tag says how urgent the WORK is; a flag says the CARD is not fit to be worked. If a card is merely important, use set_work_item_tag. If you cannot start because something is missing, flag it.
2768
+ A FLAG IS NOT A TAG. The tag says how urgent the work is; a flag records an unresolved problem. If a card is merely important, use set_work_item_tag.
2608
2769
 
2609
- THE REASON IS REQUIRED IN BOTH DIRECTIONS, and it is not the audit reason — it is the text a human reads on the card. Raising says what is wrong; clearing says what resolved it. Anyone may clear a flag, which only works because both directions are recorded as a comment on the card.
2770
+ THE REASON IS REQUIRED IN BOTH DIRECTIONS, and it is not the audit reason — it is the text read on the card. Raising says what failed, what you tried, and what remains to check; clearing says what resolved it. Agents may clear a resolved flag without a human handoff. Both directions append a comment, preserving the original failure and its resolution.
2610
2771
 
2611
- This is what the ship-the-card skill means by "an unworkable card is a triage problem, not a coding problem": flag it, say the one question that would unblock it, and stop.
2772
+ A FLAG HANDS THE CARD ON; IT DOES NOT HIDE IT. A failed test or an unsuccessful first repair is not an external hold. Leave it claimable so the next agent can investigate, repair, and independently retest. Clearing a flag does not certify acceptance criteria or replace either QA gate.
2612
2773
 
2613
- A FLAG HANDS THE CARD ON; IT DOES NOT HIDE IT. Raising one leaves the card claimable, so another agent picks it up, reads your reason, and tries and reflags with what it learned if it fails too. That loop is the point: write the reason for the next agent, not for a log. Say what you tried and what you ruled out, because they will otherwise re-derive it.
2614
-
2615
- PASS blocksClaim:true ONLY FOR A BLOCKER NO AGENT CAN SATISFY — an owner credential, a vendor consent, a product decision. That takes the card out of every reviewer's queue until somebody clears it by hand, so it is the wrong answer for "the test failed" or "I could not work out why". If another agent could plausibly get further, leave it claimable.${GOVERNED_NOTE}`,
2774
+ PASS blocksClaim:true ONLY FOR AN EXPLICIT PREREQUISITE AGENTS CANNOT SATISFY owner authorization, vendor consent, or a product decision. The hold remains until that prerequisite is satisfied and the flag is cleared with a resolution reason. Do not turn an ordinary test failure into an owner decision.${GOVERNED_NOTE}`,
2616
2775
  {
2617
2776
  itemId: z.string().describe('Card id.'),
2618
2777
  flagged: z
2619
2778
  .boolean()
2620
2779
  .describe(
2621
- 'true raises the flag; false clears it. Raising an already-flagged card replaces the reason.'
2780
+ 'true raises the flag; false clears it. Raising an already-flagged card replaces the reason, and that is an EDIT rather than a second flag: the card carries a lifetime count of how many times it has been flagged (drawn on the board as a stack of one, two, three, or three-plus pennants), and only an unflagged card becoming flagged increments it. So narrowing a live flag costs nothing, and a card showing four pennants really has been through the loop four times.'
2622
2781
  ),
2623
2782
  reason: z
2624
2783
  .string()
2625
2784
  .describe(
2626
2785
  'REQUIRED. Raising: what is wrong with the card. Clearing: what resolved it. Shown on the card and appended to its comment thread — write it for the person who will read it, not for a log.'
2627
2786
  ),
2787
+ expectedFlagGeneration: z.string().optional().describe('Required when clearing: flag.generation captured for the failure actually resolved. A stale generation is refused; inspect and resolve the newer flag before another clear.'),
2628
2788
  blocksClaim: z
2629
2789
  .boolean()
2630
2790
  .optional()
2631
2791
  .describe(
2632
- 'Whether this flag stops anyone picking the card up. OMITTED MEANS FALSE the card stays claimable and another agent can try, which is what a flag is for. Pass true only for a blocker no agent can satisfy (an owner credential, a vendor consent, a decision); that hides the card until somebody clears the flag by hand. Ignored when clearing — clearing always restores the blocking default.'
2792
+ 'Whether this flag prevents claims. OMITTED MEANS FALSE: ordinary failed work remains claimable for repair and independent retest. Pass true only for an explicit prerequisite agents cannot satisfy, such as owner authorization or vendor consent. Clear after that prerequisite is satisfied, recording the resolution. Ignored when clearing.'
2633
2793
  ),
2634
2794
  dryRun: z.boolean().optional().describe('Defaults to true. Set false to change the flag.'),
2635
2795
  approved: z.boolean().optional().describe('Required true for a live change.'),
@@ -2645,10 +2805,13 @@ PASS blocksClaim:true ONLY FOR A BLOCKER NO AGENT CAN SATISFY — an owner crede
2645
2805
  return ok({
2646
2806
  error: true,
2647
2807
  message:
2648
- 'reason is required to flag or to clear. A flag with nothing written on it stops work without saying how to restart it, and a flag anyone can silently clear is a flag nobody trusts.',
2808
+ 'reason is required to flag or to clear. Record the unresolved problem when raising and what resolved it when clearing; both become card history.',
2649
2809
  });
2650
2810
  }
2651
2811
 
2812
+ if (!args.flagged && !args.expectedFlagGeneration?.trim()) {
2813
+ return ok({ error: true, message: 'Clearing requires expectedFlagGeneration from the failure actually resolved. Read and resolve the current flag before retrying.' });
2814
+ }
2652
2815
  const path = `/api/v1/work-items/${encodeURIComponent(args.itemId)}/flag`;
2653
2816
  const preview = validateApiBridgeRequest({
2654
2817
  method: 'POST',
@@ -2668,6 +2831,7 @@ PASS blocksClaim:true ONLY FOR A BLOCKER NO AGENT CAN SATISFY — an owner crede
2668
2831
  wouldFlag: {
2669
2832
  itemId: args.itemId,
2670
2833
  flagged: args.flagged,
2834
+ expectedFlagGeneration: args.expectedFlagGeneration,
2671
2835
  reason: args.reason,
2672
2836
  // Shown in the rehearsal because it decides whether the card
2673
2837
  // disappears from every queue, which is the consequence a reader
@@ -2680,12 +2844,12 @@ PASS blocksClaim:true ONLY FOR A BLOCKER NO AGENT CAN SATISFY — an owner crede
2680
2844
  const data = await api('POST', path, {
2681
2845
  body: {
2682
2846
  flagged: args.flagged,
2847
+ expectedFlagGeneration: args.expectedFlagGeneration,
2683
2848
  reason: args.reason,
2684
2849
  idempotencyKey: args.idempotencyKey,
2685
- // Sent only when the caller named it, so an older API that does not
2686
- // know the field is not handed one, and the server's own default
2687
- // (blocking) remains the single source of that decision.
2688
- ...(args.blocksClaim === undefined ? {} : { blocksClaim: args.blocksClaim }),
2850
+ // Match the preview explicitly: ordinary failures remain claimable.
2851
+ // Clearing ignores this field on the server.
2852
+ blocksClaim: args.flagged ? (args.blocksClaim ?? false) : true,
2689
2853
  },
2690
2854
  headers: buildMutationHeaders({ ...args, reason: args.reason }),
2691
2855
  });
@@ -2876,6 +3040,8 @@ THE REASON IS REQUIRED HERE TOO — what changed. Anyone can bring a card back,
2876
3040
 
2877
3041
  /** Tool names registered here, for the tier map and the toolset manifest. */
2878
3042
  export const WORK_BOARD_TOOL_NAMES = [
3043
+ 'preview_work_item_acceptance_reaffirmation',
3044
+ 'reaffirm_work_item_current_acceptance',
2879
3045
  'list_my_work_items',
2880
3046
  'list_work_boards',
2881
3047
  'get_work_board',
@@ -2889,6 +3055,7 @@ export const WORK_BOARD_TOOL_NAMES = [
2889
3055
  'record_work_item_qa_failure_and_release',
2890
3056
  'requeue_work_item_qa_after_fix',
2891
3057
  'get_work_item_history',
3058
+ 'get_work_item_field_changes',
2892
3059
  'get_work_item_delivery_evidence',
2893
3060
  'audit_work_hub',
2894
3061
  'list_work_item_acceptance_criteria',
@@ -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,11 +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
- // WIP limit(s) names the board feature, not unfinished prose. Exempt only that
114
- // noun phrase; bare WIP and separate placeholders in the same field still fire.
115
- const PLACEHOLDER =
116
- /(?:^|[^a-z0-9])(?:tbd|tba|todo|fixme|wip(?![\s-]+limits?\b)|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
+ }
117
329
 
118
330
  const PART_LABELS = {
119
331
  whereText: 'where',
@@ -144,6 +356,29 @@ export function inspectCriterion(criterion, { ordinal = null, id = null } = {})
144
356
  });
145
357
  }
146
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
+
147
382
  const whereText = normalise(criterion?.whereText);
148
383
  if (whereText && UNLOCATED_WHERE.some((pattern) => pattern.test(whereText))) {
149
384
  findings.push({
@@ -154,17 +389,29 @@ export function inspectCriterion(criterion, { ordinal = null, id = null } = {})
154
389
  detail:
155
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.',
156
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
+ });
157
401
  }
158
402
 
159
403
  for (const [field, label] of Object.entries(PART_LABELS)) {
160
404
  const raw = criterion?.[field];
161
- if (typeof raw === 'string' && raw.trim() && PLACEHOLDER.test(raw)) {
405
+ if (typeof raw !== 'string') continue;
406
+ const marker = findPlaceholder(raw);
407
+ if (marker) {
162
408
  findings.push({
163
409
  ...at,
164
410
  code: 'placeholder_text',
165
411
  field,
166
412
  text: raw.trim(),
167
- 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.`,
168
415
  });
169
416
  }
170
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',