@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 +3 -0
- package/access/tiers.js +15 -0
- package/api-bridge.js +6 -0
- package/package.json +2 -2
- package/server.json +2 -2
- package/skills/qa-the-card/SKILL.md +8 -2
- package/skills/ship-the-card/SKILL.md +48 -39
- package/tools/work-board-tools.js +202 -35
- package/tools/work-hub/criteria-quality.js +254 -7
- package/tools/work-hub/field-changes.js +25 -0
- package/toolsets/prospecting.js +194 -3
- package/toolsets/revenue/always-loaded.js +2 -2
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
**
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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**,
|
|
81
|
-
|
|
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
|
-
##
|
|
118
|
-
|
|
119
|
-
Before
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
630
|
-
// them differently: an empty gate means pick another lane
|
|
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
|
-
|
|
638
|
-
|
|
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
|
|
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.
|
|
784
|
-
? 'The card will
|
|
785
|
-
: 'The card will
|
|
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
|
-
//
|
|
799
|
-
//
|
|
800
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
2686
|
-
//
|
|
2687
|
-
|
|
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
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/toolsets/prospecting.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Prospecting Toolset (
|
|
2
|
+
* Prospecting Toolset (9 tools, Pro tier)
|
|
3
3
|
*
|
|
4
4
|
* qualify_company, research_company, research_person,
|
|
5
5
|
* get_priority_pursuits, get_speedrun_list (legacy alias), discover_prospects,
|
|
6
|
-
* get_next_contacts
|
|
6
|
+
* get_next_contacts, commit_lead_to_outbound, release_lead_from_outbound,
|
|
7
|
+
* list_outbound_leads
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import { z } from 'zod';
|
|
@@ -17,7 +18,19 @@ import {
|
|
|
17
18
|
previewMarkdown, enrichCostLine,
|
|
18
19
|
COMPANY_ENRICH_COST_SENTENCE, PERSON_ENRICH_COST_SENTENCE,
|
|
19
20
|
} from '../governance/governed-args.js';
|
|
20
|
-
import { governedWrite } from '../api-bridge.js';
|
|
21
|
+
import { governedWrite, buildMutationHeaders } from '../api-bridge.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The lead list that IS Pipeline's Outbound stage.
|
|
25
|
+
*
|
|
26
|
+
* `/targets` and Pipeline agree on who is committed by reading one list: the workspace's lead
|
|
27
|
+
* list whose `description` carries this marker. The display name is deliberately ordinary and
|
|
28
|
+
* deliberately NOT the identity — a user is free to make a list called "Outbound" for their own
|
|
29
|
+
* reasons, and it must never become product state. Mirrors `LEAD_COMMITMENT_MARKER` in
|
|
30
|
+
* `code/desktop/src/renderer/src/api/lead-commitment.ts`; `prospecting.test.js` pins the two to
|
|
31
|
+
* the same string because two packages cannot share the constant.
|
|
32
|
+
*/
|
|
33
|
+
export const LEAD_COMMITMENT_MARKER = 'starfield:sell:lead-commitment:v1';
|
|
21
34
|
|
|
22
35
|
/**
|
|
23
36
|
* Run the enrichment leg of a research tool through the governed-write
|
|
@@ -417,4 +430,182 @@ export function register(server, api, AUTH) {
|
|
|
417
430
|
}
|
|
418
431
|
}
|
|
419
432
|
);
|
|
433
|
+
|
|
434
|
+
// -----------------------------------------------------------------------
|
|
435
|
+
// Outbound commitment — the /targets line, over MCP
|
|
436
|
+
//
|
|
437
|
+
// The leads surface has two halves: Outbound above the line, Targets below it. Crossing the
|
|
438
|
+
// line is ONE governed write — add or remove the person on the marker-owned lead list — and
|
|
439
|
+
// Pipeline's Outbound column reads that same list. These three tools are that gesture for an
|
|
440
|
+
// agent. They add no API surface: `POST/DELETE /lead-lists/{id}/members` existed and was in the
|
|
441
|
+
// bridge allowlist; what was missing was the knowledge of WHICH list, and a caller who reached
|
|
442
|
+
// for `add_to_lead_list` with the wrong id landed the person in a list the product never reads.
|
|
443
|
+
//
|
|
444
|
+
// The API's scope_guard maps `/lead-lists` writes onto `read:speedrun`
|
|
445
|
+
// (code/api/crates/middleware/src/scope_guard/mod.rs), which every issued grant carries, so the
|
|
446
|
+
// approval, reason and idempotency key are the whole gate here — and they are the same gate
|
|
447
|
+
// every other governed write in this server uses.
|
|
448
|
+
// -----------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
/** The marker-owned Outbound list, or `null` — never a user's list that shares a display name. */
|
|
451
|
+
async function findOutboundList() {
|
|
452
|
+
const body = await api('GET', '/api/v1/lead-lists', { params: { limit: 500, page: 1 } });
|
|
453
|
+
const rows = Array.isArray(body?.data) ? body.data : [];
|
|
454
|
+
return rows.find((row) => row?.description === LEAD_COMMITMENT_MARKER) ?? null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** The Outbound members, named the way the members endpoint names them. */
|
|
458
|
+
async function outboundMembers(listId) {
|
|
459
|
+
const body = await api('GET', `/api/v1/lead-lists/${listId}/members`, {});
|
|
460
|
+
return Array.isArray(body?.data) ? body.data : [];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** The refusal for a live write that lacks approval — checked BEFORE any step runs, because
|
|
464
|
+
* the commit can carry a list-creation step and a refusal must leave nothing behind. */
|
|
465
|
+
function refuseIfUnapproved(args, method, path) {
|
|
466
|
+
if (!isLiveWrite(args)) return null;
|
|
467
|
+
const missing = missingLiveWriteFields(args);
|
|
468
|
+
if (missing.length === 0) return null;
|
|
469
|
+
// `isError` is the MCP-level flag a host reads; `mdError` alone only styles the text. A
|
|
470
|
+
// refused write that reads as a success envelope is how a caller retries with the same
|
|
471
|
+
// missing field — the communications pack sets it for the same reason.
|
|
472
|
+
return {
|
|
473
|
+
...mdError(
|
|
474
|
+
`Live write refused — missing ${missing.join(', ')}`,
|
|
475
|
+
`Nothing was sent to ${method} ${path}. Re-call with dryRun:false plus ${missing.join(', ')}.`,
|
|
476
|
+
),
|
|
477
|
+
isError: true,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
server.tool(
|
|
482
|
+
'commit_lead_to_outbound',
|
|
483
|
+
'Commit a lead to Outbound. On /targets the Outbound half above the line IS Pipeline\'s Outbound stage, so this is the pipeline move for a person: it adds them to the workspace\'s marker-owned Outbound lead list, creating that list on the very first commitment. Use list_outbound_leads to read who is there and release_lead_from_outbound to take someone back out.' +
|
|
484
|
+
governedWriteNote('read:speedrun'),
|
|
485
|
+
{
|
|
486
|
+
personId: z.string().min(1).describe('The person (lead) to commit to Outbound'),
|
|
487
|
+
...governedWriteArgs(z),
|
|
488
|
+
},
|
|
489
|
+
async (args) => {
|
|
490
|
+
try {
|
|
491
|
+
const list = await findOutboundList();
|
|
492
|
+
// A canonical placeholder segment, because the bridge refuses a non-pathname; the note
|
|
493
|
+
// beside it says what a live write does about the missing list.
|
|
494
|
+
const path = `/api/v1/lead-lists/${list ? list.id : 'new-outbound-list'}/members`;
|
|
495
|
+
const refused = refuseIfUnapproved(args, 'POST', path);
|
|
496
|
+
if (refused) return refused;
|
|
497
|
+
|
|
498
|
+
let listId = list?.id ?? null;
|
|
499
|
+
if (!listId && isLiveWrite(args)) {
|
|
500
|
+
// Same approval, same reason, same idempotency key as the member write that follows:
|
|
501
|
+
// the two requests are one decision, and neither route replays by key.
|
|
502
|
+
const created = await api('POST', '/api/v1/lead-lists', {
|
|
503
|
+
body: { name: 'Up Next', description: LEAD_COMMITMENT_MARKER },
|
|
504
|
+
headers: buildMutationHeaders(args),
|
|
505
|
+
});
|
|
506
|
+
listId = created?.data?.id;
|
|
507
|
+
if (!listId) throw new Error('creating the Outbound list returned no id');
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const outcome = await governedWrite(api, args, {
|
|
511
|
+
method: 'POST',
|
|
512
|
+
path: listId ? `/api/v1/lead-lists/${listId}/members` : path,
|
|
513
|
+
body: { members: [{ personId: args.personId }] },
|
|
514
|
+
preview: {
|
|
515
|
+
entity: 'person',
|
|
516
|
+
entityId: args.personId,
|
|
517
|
+
operation: 'commit to Outbound (Pipeline Outbound stage)',
|
|
518
|
+
...(list
|
|
519
|
+
? {}
|
|
520
|
+
: {
|
|
521
|
+
note:
|
|
522
|
+
`The Outbound list does not exist yet in this workspace. A live write first creates it ` +
|
|
523
|
+
`(POST /api/v1/lead-lists with description \`${LEAD_COMMITMENT_MARKER}\`), then adds the person.`,
|
|
524
|
+
}),
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
if (outcome.dryRun) return md(previewMarkdown('Commit to Outbound', outcome.preview));
|
|
528
|
+
return md(
|
|
529
|
+
`## Committed to Outbound\n\n` +
|
|
530
|
+
`- **Person:** ${args.personId}\n` +
|
|
531
|
+
`- **Outbound list:** ${listId}${list ? '' : ' (created on this write)'}\n` +
|
|
532
|
+
`- **Added:** ${outcome.result?.data?.added ?? 'reported by server'}\n\n` +
|
|
533
|
+
`The person now sits above the line on /targets and in Pipeline\'s Outbound stage.\n`,
|
|
534
|
+
);
|
|
535
|
+
} catch (err) {
|
|
536
|
+
return mdError('Commit to Outbound failed', err.message);
|
|
537
|
+
}
|
|
538
|
+
},
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
server.tool(
|
|
542
|
+
'release_lead_from_outbound',
|
|
543
|
+
'Take a lead back out of Outbound — below the line on /targets, out of Pipeline\'s Outbound stage. Removes the person from the marker-owned Outbound lead list; the person record itself is untouched.' +
|
|
544
|
+
governedWriteNote('read:speedrun'),
|
|
545
|
+
{
|
|
546
|
+
personId: z.string().min(1).describe('The person (lead) to release from Outbound'),
|
|
547
|
+
...governedWriteArgs(z),
|
|
548
|
+
},
|
|
549
|
+
async (args) => {
|
|
550
|
+
try {
|
|
551
|
+
const list = await findOutboundList();
|
|
552
|
+
if (!list) {
|
|
553
|
+
return md(
|
|
554
|
+
`## Nothing to release\n\nThis workspace has no Outbound list yet — nobody has been committed, so ` +
|
|
555
|
+
`${args.personId} is already below the line. Nothing was written.\n`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
const path = `/api/v1/lead-lists/${list.id}/members/${encodeURIComponent(args.personId)}`;
|
|
559
|
+
const refused = refuseIfUnapproved(args, 'DELETE', path);
|
|
560
|
+
if (refused) return refused;
|
|
561
|
+
const outcome = await governedWrite(api, args, {
|
|
562
|
+
method: 'DELETE',
|
|
563
|
+
path,
|
|
564
|
+
preview: { entity: 'person', entityId: args.personId, operation: 'release from Outbound' },
|
|
565
|
+
});
|
|
566
|
+
if (outcome.dryRun) return md(previewMarkdown('Release from Outbound', outcome.preview));
|
|
567
|
+
return md(
|
|
568
|
+
`## Released from Outbound\n\n- **Person:** ${args.personId}\n- **Outbound list:** ${list.id}\n\n` +
|
|
569
|
+
`The person is below the line on /targets again and no longer in Pipeline\'s Outbound stage.\n`,
|
|
570
|
+
);
|
|
571
|
+
} catch (err) {
|
|
572
|
+
return mdError('Release from Outbound failed', err.message);
|
|
573
|
+
}
|
|
574
|
+
},
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
server.tool(
|
|
578
|
+
'list_outbound_leads',
|
|
579
|
+
'Who is committed to Outbound right now — the people above the line on /targets, which is Pipeline\'s Outbound stage. Read-only.',
|
|
580
|
+
{},
|
|
581
|
+
async () => {
|
|
582
|
+
try {
|
|
583
|
+
const list = await findOutboundList();
|
|
584
|
+
if (!list) {
|
|
585
|
+
return md(
|
|
586
|
+
`## Outbound\n\nThis workspace has no Outbound list yet: nobody has ever been committed to Outbound, ` +
|
|
587
|
+
`so there is nothing above the line. (Not a failed read — the list is created on the first commitment.)\n`,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
const members = await outboundMembers(list.id);
|
|
591
|
+
if (members.length === 0) {
|
|
592
|
+
return md(`## Outbound — 0 committed\n\nThe Outbound list (${list.id}) exists and is empty: nobody is above the line.\n`);
|
|
593
|
+
}
|
|
594
|
+
const rows = members.map((m, i) => [
|
|
595
|
+
String(i + 1),
|
|
596
|
+
m.personName || m.personId || '\u2014',
|
|
597
|
+
m.personTitle || '\u2014',
|
|
598
|
+
m.personEmail || '\u2014',
|
|
599
|
+
m.personId || '\u2014',
|
|
600
|
+
]);
|
|
601
|
+
return md(
|
|
602
|
+
`## Outbound — ${members.length} committed\n\n` +
|
|
603
|
+
table(['#', 'Person', 'Title', 'Email', 'Person ID'], rows) +
|
|
604
|
+
`\nOutbound list ${list.id}. Each row is above the line on /targets and in Pipeline\'s Outbound stage.\n`,
|
|
605
|
+
);
|
|
606
|
+
} catch (err) {
|
|
607
|
+
return mdError('Could not read Outbound', err.message);
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
);
|
|
420
611
|
}
|
|
@@ -12,9 +12,9 @@ import { getDemoAvailability } from '../../tools/scheduling.js';
|
|
|
12
12
|
export const TOOLSET_REGISTRY = {
|
|
13
13
|
prospecting: {
|
|
14
14
|
name: 'prospecting',
|
|
15
|
-
description: 'Qualify companies, research prospects, get daily priorities, discover leads',
|
|
15
|
+
description: 'Qualify companies, research prospects, get daily priorities, discover leads, commit leads to Outbound',
|
|
16
16
|
tier: 'pro',
|
|
17
|
-
toolCount:
|
|
17
|
+
toolCount: 9,
|
|
18
18
|
},
|
|
19
19
|
intelligence: {
|
|
20
20
|
name: 'intelligence',
|