@adrata/adrata-mcp 1.0.3 → 1.0.7

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/server.js CHANGED
@@ -31,6 +31,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
31
31
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
32
32
  import { z } from 'zod';
33
33
  import { AsyncLocalStorage } from 'node:async_hooks';
34
+ import { readFileSync } from 'node:fs';
34
35
  import {
35
36
  authenticate,
36
37
  reauthenticate,
@@ -41,6 +42,7 @@ import {
41
42
  import { TIERS } from './access/tiers.js';
42
43
  import { findCompany, findPerson } from './tools/free-search.js';
43
44
  import { applySecurityLayer } from './security.js';
45
+ import { describeEdgeBlock } from './edge-block.js';
44
46
  import { registerMemoryTools, wrapWithEventLogging, registerProfileResource } from './tools/memory.js';
45
47
  import { registerBillingTools } from './tools/billing.js';
46
48
  import { registerMorningBrief } from './tools/morning-brief.js';
@@ -50,13 +52,15 @@ import { connectWorkspace, disconnectWorkspace, getConnectionStatus, getValidTok
50
52
  import { registerEnterpriseTools } from './tools/enterprise-tools.js';
51
53
  import { registerEmailTools } from './tools/email-tools.js';
52
54
  import { registerWorkBoardTools } from './tools/work-board-tools.js';
55
+ import { registerSourceControlTools } from './tools/source-control/connection-tools.js';
53
56
  import { registerRoadmapTools } from './tools/roadmap-tools.js';
54
57
  import { registerPaperTools } from './tools/paper-tools.js';
55
58
  import { register as registerAlwaysLoadedTools } from './toolsets/revenue/always-loaded.js';
56
59
  import { register as registerExtensibilityTools } from './toolsets/extensibility.js';
57
60
  import { registerSpaceTools } from './toolsets/spaces.js';
61
+ import { composeCompetitorIntel, composeCustomerSignals } from './toolsets/revenue/competitive-coverage.js';
58
62
  import { getDemoAvailability, scheduleDemo, scheduleMeeting } from './tools/scheduling.js';
59
- import { registerAnalytics } from './analytics.js';
63
+ import { registerAnalytics, wrapWithAnalytics } from './analytics.js';
60
64
  import {
61
65
  apiBridgeCatalog,
62
66
  buildMutationHeaders,
@@ -131,7 +135,7 @@ async function api(method, path, { params, body, headers: extraHeaders } = {}) {
131
135
  const auth = currentAuth();
132
136
  assertOAuthIssuerMatchesTarget(auth, API_BASE);
133
137
  let currentToken = auth.token;
134
- if (auth.source === 'stored') {
138
+ if (auth.source === 'stored' || auth.source === 'stored_pool') {
135
139
  let freshToken;
136
140
  try {
137
141
  freshToken = await getValidToken(API_BASE);
@@ -177,7 +181,7 @@ async function api(method, path, { params, body, headers: extraHeaders } = {}) {
177
181
  };
178
182
 
179
183
  let res = await request(currentToken);
180
- if (res.status === 401 && auth.source === 'stored') {
184
+ if (res.status === 401 && (auth.source === 'stored' || auth.source === 'stored_pool')) {
181
185
  let refreshedToken;
182
186
  try {
183
187
  refreshedToken = await getValidToken(API_BASE, { forceRefresh: true });
@@ -214,6 +218,18 @@ async function api(method, path, { params, body, headers: extraHeaders } = {}) {
214
218
  try { data = JSON.parse(text); } catch { data = { raw: text }; }
215
219
 
216
220
  if (!res.ok) {
221
+ // An HTML body cannot have come from the API — every AppError serialises as
222
+ // JSON — so it means the request was refused in front of us. Saying so is
223
+ // the whole point: a bare 403 is indistinguishable from insufficient_scope,
224
+ // and reading it as a token problem is what starts a needless reconnect.
225
+ const edge = describeEdgeBlock({
226
+ status: res.status,
227
+ text,
228
+ method,
229
+ path,
230
+ requestBody: body,
231
+ });
232
+ if (edge) throw new Error(edge);
217
233
  throw new Error(`API ${method} ${path} → ${res.status}: ${JSON.stringify(data).slice(0, 200)}`);
218
234
  }
219
235
  return data;
@@ -297,8 +313,12 @@ const moneyWrite = {
297
313
  * to start, which is the exact failure this whole surface keeps producing.
298
314
  */
299
315
  const SERVER_NAME = process.env.ADRATA_MCP_SERVER_NAME?.trim() || '@adrata/adrata-mcp';
316
+ const PACKAGE_VERSION = JSON.parse(
317
+ readFileSync(new URL('./package.json', import.meta.url), 'utf8')
318
+ ).version;
300
319
 
301
- const server = new McpServer({ name: SERVER_NAME, version: '1.0.0' });
320
+ function createMcpServer() {
321
+ const server = new McpServer({ name: SERVER_NAME, version: PACKAGE_VERSION });
302
322
 
303
323
  // ---------------------------------------------------------------------------
304
324
  // Tier-gating wrapper
@@ -367,6 +387,26 @@ wrapWithEventLogging(server, api, AUTH);
367
387
  // Must be applied AFTER tier-gating and event logging wrappers above.
368
388
  applySecurityLayer(server, currentAuth, api);
369
389
 
390
+ // ---------------------------------------------------------------------------
391
+ // Analytics wrapper
392
+ // ---------------------------------------------------------------------------
393
+ // MUST be installed here — after the wrappers above, and BEFORE the first
394
+ // server.tool(...) call below. It monkey-patches `server.tool`, so it can only
395
+ // instrument FUTURE registrations. It used to be installed from
396
+ // registerAnalytics() ~1975 lines further down, which put 227 of the 254 tools
397
+ // this server exposes ahead of it: trackToolInvocation and recordSessionTool
398
+ // could not fire for 89% of the surface, and get_mcp_analytics reported
399
+ // tool_invocations.total: 0 as though it were a measurement.
400
+ //
401
+ // The cost is deliberate and worth naming: every counted invocation now also
402
+ // fire-and-forget POSTs a `tool_invocation` event, roughly doubling the writes
403
+ // on /api/v1/mcp/events (the event-logging wrapper above already posts one per
404
+ // call for the whole surface). ADRATA_TELEMETRY=off disables both.
405
+ //
406
+ // registerAnalytics() still calls this; the wrapper is idempotent, so the later
407
+ // call is a no-op rather than a second layer.
408
+ wrapWithAnalytics(server, api, currentAuth, checkToolAccess);
409
+
370
410
  // ===== WORKSPACE CONNECTION: OAuth 2.0 (available at all tiers) =====
371
411
 
372
412
  server.tool('connect_workspace',
@@ -381,7 +421,18 @@ server.tool('connect_workspace',
381
421
  AUTH = reauthenticate();
382
422
  return ok(result);
383
423
  } catch (err) {
384
- return ok({ error: true, message: err.message, hint: 'Make sure your browser is accessible and try again.' });
424
+ // `invalid_scope` is not a browser problem, and saying it is costs real
425
+ // time: it means the authorization server rejected one of the scopes we
426
+ // asked for, which in practice means the deployed API is running behind
427
+ // this client. The whole request is refused, so read-only will still
428
+ // connect — that asymmetry is the tell.
429
+ const hint = /invalid_scope/.test(err.message ?? '')
430
+ ? 'The API rejected a requested scope, so it granted nothing — this is NOT a browser problem. '
431
+ + 'The deployed API is probably behind this client. Compare them: '
432
+ + 'curl -s ' + API_BASE + '/health | jq -r .build_sha '
433
+ + 'Read-only connect_workspace() should still work; use it to keep going.'
434
+ : 'Make sure your browser is accessible and try again.';
435
+ return ok({ error: true, message: err.message, hint });
385
436
  }
386
437
  });
387
438
 
@@ -412,7 +463,7 @@ server.tool('workspace_status',
412
463
  // answer from a diagnostic: it sends someone to reconnect a connection that
413
464
  // was never the problem, and it is the tool a connect flow tells you to run
414
465
  // to confirm the setup worked.
415
- const usingStoredSession = AUTH.source === 'stored';
466
+ const usingStoredSession = AUTH.source === 'stored' || AUTH.source === 'stored_pool';
416
467
  const stored = await getConnectionStatus(API_BASE);
417
468
 
418
469
  // AUTH.workspaceId is only populated when the workspace was pinned via env
@@ -452,8 +503,11 @@ server.tool('workspace_status',
452
503
  return ok({
453
504
  ...stored,
454
505
  tier: stored.connected ? AUTH.tier : TIERS.FREE,
455
- credential: 'stored OAuth session (~/.adrata/tokens.json)',
506
+ credential: AUTH.source === 'stored_pool'
507
+ ? `dedicated OAuth session for named identity pool ${AUTH.identityPool}`
508
+ : 'stored OAuth session (~/.adrata/tokens.json)',
456
509
  authSource: AUTH.source,
510
+ identityPool: AUTH.identityPool || null,
457
511
  apiBase: API_BASE,
458
512
  workspaceId,
459
513
  workspaceName,
@@ -521,7 +575,7 @@ function assertProductCapability(capability, requested) {
521
575
  }
522
576
 
523
577
  async function resolveExactCapability(reference) {
524
- const response = await api('GET', '/api/v1/ai/crm-tools/capabilities/describe', {
578
+ const response = await api('GET', '/api/v1/ai-crm-tools/capabilities/describe', {
525
579
  params: { ref: reference },
526
580
  });
527
581
  return assertProductCapability(response?.capability ?? response?.data?.capability, reference);
@@ -539,7 +593,7 @@ server.tool('search_capabilities',
539
593
  if (PRODUCT_NAMESPACE && requestedNamespace && requestedNamespace !== PRODUCT_NAMESPACE) {
540
594
  throw new Error(`${SERVER_NAME} search is fixed to the ${PRODUCT_NAMESPACE} namespace.`);
541
595
  }
542
- return ok(await api('GET', '/api/v1/ai/crm-tools/capabilities/search', {
596
+ return ok(await api('GET', '/api/v1/ai-crm-tools/capabilities/search', {
543
597
  params: {
544
598
  q: args.query,
545
599
  namespace: PRODUCT_NAMESPACE || requestedNamespace,
@@ -581,7 +635,7 @@ server.tool('run_capability',
581
635
  if (args.idempotencyKey) headers['idempotency-key'] = args.idempotencyKey;
582
636
  if (args.reason) headers['x-adrata-reason'] = args.reason;
583
637
  if (args.confirmSpend === true) headers['x-adrata-approved'] = 'true';
584
- return ok(await api('POST', '/api/v1/ai/crm-tools/execute', { body, headers }));
638
+ return ok(await api('POST', '/api/v1/ai-crm-tools/execute', { body, headers }));
585
639
  });
586
640
 
587
641
  server.tool('adrata_api_catalog',
@@ -1756,7 +1810,7 @@ server.tool('get_intent_signals', 'Get buying intent signals for a company.',
1756
1810
  // are workspace-scoped from the token; filter by subject (a company or person),
1757
1811
  // source, signal type, or minimum score, and page with the returned cursor.
1758
1812
  server.tool('list_customer_signals',
1759
- 'List first-party customer signals (the store live plays fire from) for the workspace, or scope to one company/person via subjectType + subjectId. Distinct from get_intent_signals (third-party intent).',
1813
+ 'List first-party customer signals (the store live plays fire from) for the workspace, or scope to one company/person via subjectType + subjectId. Distinct from get_intent_signals (third-party intent). Always relay the returned `coverage`: an empty result is checked against the unfiltered workspace, so a real zero is distinguished from a store nothing has ever written to.',
1760
1814
  {
1761
1815
  subjectType: z.enum(['company', 'person']).optional().describe('Scope to a subject kind; pair with subjectId'),
1762
1816
  subjectId: z.string().optional().describe('Company or person ID to scope signals to'),
@@ -1768,11 +1822,31 @@ server.tool('list_customer_signals',
1768
1822
  limit: z.number().optional().describe('Page size (default 50, max 500)'),
1769
1823
  cursor: z.string().optional().describe('Opaque cursor from a previous page'),
1770
1824
  },
1771
- async (a) => ok(await api('GET', '/api/v1/customer-signals', { params: {
1772
- subjectType: a.subjectType, subjectId: a.subjectId, source: a.source,
1773
- signalType: a.signalType, minScore: a.minScore, since: a.since,
1774
- until: a.until, limit: a.limit || 50, cursor: a.cursor,
1775
- } })));
1825
+ async (a) => {
1826
+ const filtersApplied = {
1827
+ subjectType: a.subjectType, subjectId: a.subjectId, source: a.source,
1828
+ signalType: a.signalType, minScore: a.minScore, since: a.since, until: a.until,
1829
+ };
1830
+ const payload = await api('GET', '/api/v1/customer-signals', { params: {
1831
+ ...filtersApplied, limit: a.limit || 50, cursor: a.cursor,
1832
+ } });
1833
+
1834
+ // The probe is spent ONLY on the ambiguous case. Rows returned means the store is
1835
+ // demonstrably written to and the count speaks for itself; an empty array is the reading a
1836
+ // seller cannot interpret, and one extra request is what separates "no signals match these
1837
+ // filters" from "nothing has ever been ingested".
1838
+ const empty = Array.isArray(payload?.items) ? payload.items.length === 0 : undefined;
1839
+ let workspaceProbePayload;
1840
+ if (empty === true) {
1841
+ try {
1842
+ workspaceProbePayload = await api('GET', '/api/v1/customer-signals', { params: { limit: 1 } });
1843
+ } catch {
1844
+ workspaceProbePayload = null;
1845
+ }
1846
+ }
1847
+
1848
+ return ok(composeCustomerSignals({ payload, filtersApplied, workspaceProbePayload }));
1849
+ });
1776
1850
 
1777
1851
  server.tool('get_deal_authority', 'Get authority mapping and stakeholder analysis for a deal.',
1778
1852
  { opportunityId: z.string() },
@@ -1781,9 +1855,39 @@ server.tool('get_deal_authority', 'Get authority mapping and stakeholder analysi
1781
1855
  params: { opportunityId: a.opportunityId },
1782
1856
  })));
1783
1857
 
1784
- server.tool('get_competitor_intel', 'Get competitive intelligence for a company.',
1858
+ server.tool('get_competitor_intel',
1859
+ 'Competitive intelligence for ONE company: the competitor mentions attributed to that account, plus the workspace-wide competitor definitions they are scored against. Always relay the returned `coverage` — it says which half is company-scoped and which is workspace-wide, and states whether a zero is a real zero or an unconfigured store. `/api/v1/competitors` is a workspace configuration list and takes no company filter; the company-scoped evidence lives at `/api/v1/competitors/mentions?companyId=`.',
1785
1860
  { companyId: z.string() },
1786
- async (a) => ok(await api('GET', `/api/v1/competitors`, { params: { companyId: a.companyId } })));
1861
+ async (a) => {
1862
+ // The definition list is workspace-scoped by construction. Sending `companyId` to it — as
1863
+ // this tool used to — got the parameter silently discarded and returned the same answer for
1864
+ // every company. The two reads are separate because their SCOPES are separate.
1865
+ const definitionsPayload = await api('GET', '/api/v1/competitors');
1866
+
1867
+ let mentionsPayload;
1868
+ let workspaceMentionsPayload;
1869
+ let mentionsUnavailable = false;
1870
+ try {
1871
+ mentionsPayload = await api('GET', '/api/v1/competitors/mentions', {
1872
+ params: { companyId: a.companyId, limit: 50 },
1873
+ });
1874
+ // The denominator. One extra cheap request buys the difference between "this account is
1875
+ // quiet" and "nothing has ever been scanned".
1876
+ workspaceMentionsPayload = await api('GET', '/api/v1/competitors/mentions', {
1877
+ params: { limit: 1 },
1878
+ });
1879
+ } catch {
1880
+ mentionsUnavailable = true;
1881
+ }
1882
+
1883
+ return ok(composeCompetitorIntel({
1884
+ companyId: a.companyId,
1885
+ definitionsPayload,
1886
+ mentionsPayload,
1887
+ workspaceMentionsPayload,
1888
+ mentionsUnavailable,
1889
+ }));
1890
+ });
1787
1891
 
1788
1892
  // ===== ANALYTICS =====
1789
1893
 
@@ -2266,6 +2370,8 @@ registerWorkBoardTools(server, {
2266
2370
  getGrantedScope: () => loadTokens()?.scope,
2267
2371
  });
2268
2372
 
2373
+ registerSourceControlTools(server, { z, api, ok });
2374
+
2269
2375
  // The containers above the cards, and the "add this to the roadmap" verb
2270
2376
  // (company/decisions/2026-08-06-spoq-roadmap-sync.md).
2271
2377
  registerRoadmapTools(server, {
@@ -2323,7 +2429,7 @@ server.tool('schedule_meeting',
2323
2429
  // Tracks tool invocations, search queries, tier gates, conversions, sessions.
2324
2430
  // Builds on top of existing event logging — adds classification and dashboards.
2325
2431
  // ADRATA_TELEMETRY=off disables all tracking. PII is hashed before storage.
2326
- registerAnalytics(server, { z, api, auth: AUTH, ok, checkToolAccess });
2432
+ registerAnalytics(server, { z, api, auth: currentAuth, ok, checkToolAccess });
2327
2433
 
2328
2434
  // ===== COMPOSITE TOOLSETS =====
2329
2435
  // 42 composite tools organized into 7 toolsets with dynamic discovery.
@@ -2344,6 +2450,9 @@ registerExtensibilityTools(server, api, AUTH);
2344
2450
  // Service" got nothing, while the app answered it fine.
2345
2451
  registerSpaceTools(server, { z, api, ok });
2346
2452
 
2453
+ return server;
2454
+ }
2455
+
2347
2456
  // ---------------------------------------------------------------------------
2348
2457
  // Start
2349
2458
  // ---------------------------------------------------------------------------
@@ -2353,11 +2462,12 @@ const TRANSPORT_MODE = (process.env.ADRATA_MCP_TRANSPORT || 'stdio').toLowerCase
2353
2462
  if (TRANSPORT_MODE === 'http' || TRANSPORT_MODE === 'sse') {
2354
2463
  // Streamable HTTP transport — single POST /mcp endpoint that can upgrade to SSE
2355
2464
  const { startHttpTransport } = await import('./transport-http.js');
2356
- await startHttpTransport(server, {
2465
+ await startHttpTransport(createMcpServer, {
2357
2466
  runWithAuthContext,
2358
2467
  });
2359
2468
  } else {
2360
2469
  // Default: stdio transport (backward compatible)
2470
+ const server = createMcpServer();
2361
2471
  const transport = new StdioServerTransport();
2362
2472
  await server.connect(transport);
2363
2473
  }
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.0",
6
+ "version": "1.0.7",
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.0",
18
+ "version": "1.0.7",
19
19
  "transport": {
20
20
  "type": "stdio"
21
21
  },
@@ -31,6 +31,18 @@
31
31
  "description": "Enterprise OAuth 2.1 access token, audience-bound to the Adrata MCP resource (RFC 8707).",
32
32
  "isRequired": false,
33
33
  "isSecret": true
34
+ },
35
+ {
36
+ "name": "ADRATA_MCP_IDENTITY_POOL",
37
+ "description": "Optional named worker identity (for example qa1 or qa2). Named pools fail closed and only use their dedicated stored OAuth session.",
38
+ "isRequired": false,
39
+ "isSecret": false
40
+ },
41
+ {
42
+ "name": "ADRATA_MCP_CONFIG_DIR",
43
+ "description": "OAuth storage directory. Required and non-default when ADRATA_MCP_IDENTITY_POOL is set.",
44
+ "isRequired": false,
45
+ "isSecret": false
34
46
  }
35
47
  ]
36
48
  }
@@ -0,0 +1,218 @@
1
+ ---
2
+ name: qa-the-card
3
+ description: Deeply verify a Starfield card in Staging QA1 or Staging QA2, fix defects and rerun evidence until clean, then leave an auditable AI-to-human handoff without claiming deployment. Use when asked to QA, review, verify, sign off, or clear cards from a QA column.
4
+ ---
5
+
6
+ # QA the card
7
+
8
+ QA is an engineering pass, not a documentation pass. Reproduce every acceptance
9
+ claim, attack the implementation beyond the happy path, fix defects in scope,
10
+ and repeat until the card is genuinely ready for the next accountable reviewer.
11
+
12
+ The workflow semantics are fixed by
13
+ `company/decisions/2026-08-29-two-gate-ai-qa-human-production.md`; the required
14
+ evidence loop is fixed by
15
+ `company/decisions/2026-08-29-recorded-staging-qa-standard.md`. This skill is
16
+ their operating procedure.
17
+
18
+ ## Establish the real queue
19
+
20
+ 1. Read every active board or the all-boards roll-up before announcing a count.
21
+ A card's `product` may differ from the board holding it, so inspect both the
22
+ Adrata board and Adrata-labelled cards on another board. Re-read the queue
23
+ during a long session and at every deployment checkpoint because new cards
24
+ can arrive while QA is running. Report the delta, not only the new total.
25
+ 2. Limit mutations to the QA scope the user named. Read each card, its full
26
+ stage history, comments, acceptance criteria, flag, linked pull request, and
27
+ exact-SHA delivery evidence before testing it.
28
+ 3. Do not infer missing history from a sparse screen. Confirm the transition
29
+ rows through the governed API. A card created directly in an old QA column
30
+ may honestly have only one transition.
31
+ 4. Audit the criteria themselves. Each must describe an observable outcome and
32
+ be executable at the current gate. Add or correct missing coverage before
33
+ calling the card clean; never turn a vague criterion green by interpreting it
34
+ generously.
35
+ 5. Missing independent QA evidence by itself is expected at QA1 entry and never
36
+ justifies a bounce. Do not report a vague “missing evidence path”: name the
37
+ allegedly missing product route, fixture, credential, artifact location, or
38
+ current-dwell QA receipt, and resolve relative artifact paths against the
39
+ owning worktree or durable location before declaring them absent. If the
40
+ executable prerequisite truly is missing, keep the card in QA and run the
41
+ same-card fix-and-retest loop; if only the independent receipt is missing,
42
+ start the pass.
43
+
44
+ ## Preserve independent gates
45
+
46
+ - QA1 and QA2 are separate passes over separate column-history dwells. A prior
47
+ checkbox or receipt is evidence to inspect, not a substitute for rerunning it.
48
+ - An agent's QA2 credential must differ from the criterion author and the agent
49
+ credential whose clean receipt opened QA1. Never impersonate a human to bypass
50
+ this rule. A human may perform the final acceptance even when they own or
51
+ authored the card.
52
+ - **Take that credential from a QA lane, and take a different one per
53
+ worktree.** Staging seeds twenty-four of them — `qa-lane-1@adrata.test`
54
+ through `qa-lane-24@adrata.test`, lanes 1-18 `admin` and 19-24 `seller`.
55
+ There are deliberately more lanes than the fleet can drive, so a tester
56
+ never queues behind an identity:
57
+
58
+ ```bash
59
+ node scripts/qa-staging-lane.mjs --list # every lane and its role
60
+ node scripts/qa-staging-lane.mjs <lane> --write-env # this worktree's tests/e2e/.env
61
+ ```
62
+
63
+ The rule above is unsatisfiable with one shared login, and the failure is
64
+ silent rather than loud: every recording is the same actor, so cards get
65
+ honestly CLAIMED and none can be legitimately VERIFIED. Sharing a lane
66
+ between two live agents is the same defect wearing a different hat — they
67
+ share a session, a workspace switch and a chat history, so one lane's
68
+ navigation lands in another lane's recording. Record the lane on the receipt,
69
+ so "a different credential" is a checkable claim rather than an assertion.
70
+ The accounts come from the non-production boot seed
71
+ (`code/api/crates/schema/src/seeds/non_prod.rs`) and exist only on staging
72
+ and local; they cannot be created in production.
73
+ - For non-trivial QA2 work, use at least one independent adversarial reviewer
74
+ when subagents are authorized and available. Parallelize separable surfaces
75
+ such as UI, backend/security, and migration/deployment behavior. Give each
76
+ reviewer a bounded target and avoid concurrent edits to the same files.
77
+ - Multiple reviewers may find and challenge defects, but they do not assemble a
78
+ synthetic gate from partial identities. One eligible second-agent credential
79
+ must rerun or confirm every current criterion so the final QA2 receipt is
80
+ coherent and the badge can be derived honestly.
81
+ - Stagger expensive Rust builds on the shared Mac. More concurrent compilers can
82
+ make verification slower and can exhaust disk; an independent final result
83
+ must use the reviewer's own target artifacts and show any new test by name.
84
+
85
+ ## Run the fix-and-retest loop
86
+
87
+ For every criterion:
88
+
89
+ 1. Write down the observable, the environment, and the strongest practical way
90
+ to falsify it. Source inspection alone is not a pass.
91
+ 2. Reproduce the behavior before changing code when the deployed failure is
92
+ observable. Preserve the failing request, console output, screenshot, video,
93
+ trace, or exact database/API result.
94
+ 3. Exercise the happy path, boundary conditions, failure states, authorization
95
+ boundaries, retries/idempotency, and a realistic regression path. For a
96
+ migration, prove both representative old state and transactional rerun. For
97
+ a deployment, prove the exact deployed SHA and behavior during replacement,
98
+ not only the final steady state.
99
+ 4. If anything fails, fix it within the same card when passing the card's
100
+ existing acceptance outcome necessarily requires that fix. Add the narrowest
101
+ regression test that would have caught it, rerun the focused suite, then rerun
102
+ the broader affected contracts. Do not merely describe a defect and stop
103
+ while a safe in-scope fix remains.
104
+ 5. Repeat adversarial review after the fix. A first green rerun is evidence, not
105
+ automatic sign-off.
106
+
107
+ Keep the card in its QA column throughout this loop unless the user or board
108
+ policy explicitly asks for a bounce. Never hide an incomplete pass by moving it
109
+ forward.
110
+
111
+ ## Visual QA requires visual evidence
112
+
113
+ Use Playwright for every user-facing change. A DOM assertion or source review
114
+ does not establish that the screen is visually correct.
115
+
116
+ - Record a WebM video of the real interaction and take screenshots of the states
117
+ that decide the criterion. Enable action annotations or chapter markers when
118
+ they make the recording easier for the human reviewer to follow.
119
+ - Measure duration, MIME type, size, and hash from the finalized media file
120
+ before upload. Never declare the interaction stopwatch as the video duration:
121
+ recorder startup/finalization can make those values differ. Reject the
122
+ receipt when a checkpoint falls outside the measured media duration or the
123
+ declared metadata does not match the finalized file.
124
+ - In QA2, inspect the QA1 recording as prior evidence, then record a new QA2
125
+ replay. Keep both attached to their exact gate and column-history dwell. Never
126
+ reuse, replace, or relabel the QA1 media as QA2 evidence.
127
+ - Give each concurrent QA agent an isolated synthetic account or an explicit
128
+ exclusive lease on the shared account. For stateful journeys, create and read
129
+ back a unique server resource (for example, a conversation id) and address it
130
+ directly; never rely on the newest active resource. An unexpected request,
131
+ turn, or mutation from outside the recorded browser makes the run ambiguous
132
+ and therefore a failure, not evidence.
133
+ - Inspect the rendered screenshots yourself at the target viewport. Check
134
+ alignment, clipping, wrapping, whitespace, stacking, focus, disabled/loading
135
+ behavior, and whether badges and controls line up with neighboring content.
136
+ - Capture the browser console, page errors, failed requests, and the relevant
137
+ network responses. Distinguish an expected simulated failure from an
138
+ unexpected product error.
139
+ - Cover normal, empty, loading, error, and recovery states when they exist. Test
140
+ both hosted Starfield and the packaged desktop app when both ship the changed
141
+ renderer.
142
+ - When governed QA evidence is available, attach the screenshot/video to the
143
+ card's exact open QA dwell with the environment and build SHA. Until that
144
+ surface is deployed, retain the files and record their paths or durable run
145
+ links without pretending they were attached.
146
+ - Reopen both uploaded gate recordings before handoff. Prove each fresh
147
+ short-lived URL loads and that each video can play, seek, and enter
148
+ fullscreen; an upload response alone is not a media pass.
149
+ - A staging QA lane and the canonical board may intentionally use separate data
150
+ planes. Do not copy a production card into staging, reuse the builder's browser
151
+ credential, or put an OAuth bearer or presigned media URL in the transcript.
152
+ Run `npm run --silent qa:canonical-card-review -- <card-id>` under the fresh
153
+ reviewer's named `ADRATA_MCP_IDENTITY_POOL` and dedicated
154
+ `ADRATA_MCP_CONFIG_DIR`. It opens the exact deployed product bundle through a
155
+ one-use, read-only loopback session: upstream reads keep the QA OAuth identity,
156
+ writes fail locally, and private media capabilities remain process-private.
157
+ Record the returned session fingerprint and the deployed app build in the
158
+ playback evidence, then close the bridge.
159
+
160
+ ## Manage cards as outcomes, not bug counters
161
+
162
+ - A defect required to satisfy the current card stays on that card. Fix it and
163
+ retest there; do not create a new Up Next card merely because QA found it.
164
+ - Create a separate card only for an independently releasable outcome outside
165
+ the current acceptance scope. Search the whole board first. If a likely
166
+ duplicate already exists, identify the pair and let a human collapse it; do
167
+ not delete or merge cards on your own judgement.
168
+ - A new card needs a concrete title, reproduction/context, first-class
169
+ acceptance criteria, and no invented assignee. Move it through the ordinary
170
+ workflow instead of placing it directly in a protected QA or done column.
171
+ - Keep findings, fixes, and evidence on the canonical card. Do not rewrite the
172
+ original body to make the request match what was discovered.
173
+
174
+ ## Promotion and the human handoff
175
+
176
+ Local verification comes before CI. Keep incomplete pull requests draft, seek
177
+ an independent code review for consequential fixes, make the PR ready only
178
+ after local evidence is clean, and merge only after the applicable protected
179
+ checks pass. Verify the cumulative staging deployment rather than treating a
180
+ merge as deployment proof.
181
+
182
+ When QA2 is clean:
183
+
184
+ 1. Tick only criteria actually executed in the current dwell, with concise
185
+ evidence naming commands or run links, exact counts, environment, build SHA,
186
+ and any material caveat.
187
+ 2. Add one concise final card comment summarizing the fixes and the last clean
188
+ rerun. Comments preserve the audit trail; they never replace the fix.
189
+ 3. Confirm the derived `Ready for review` badge appears and aligns correctly in
190
+ both the card and detail view. Never edit an emoji into the title; a derived
191
+ badge disappears correctly after a bounce.
192
+ 4. Leave the card in Staging QA2. An agent must not move it to Ready to Ship or
193
+ Production. Ready to Ship is the human's acceptance of both AI receipts.
194
+ 5. Treat Production as a separate claim: the approved release is live, its exact
195
+ delivery evidence is known, and release notes may now include it. A board
196
+ column alone is not exact-SHA proof.
197
+
198
+ If the badge does not appear, identity is ambiguous, evidence belongs to an old
199
+ dwell, a criterion is open, CI is incomplete, or the deployed behavior differs,
200
+ the card is not ready. Fix the cause and rerun; never use an override to make the
201
+ handoff look complete.
202
+
203
+ ## Report while working
204
+
205
+ Keep the human informed with concrete state: current QA2 count, cards clean,
206
+ cards in fix/retest, newly found defects, and deployment/desktop readiness. Do
207
+ not announce that the app is ready to inspect until the required API migration,
208
+ hosted build, packaged desktop version, relaunch, and final Playwright video pass
209
+ are actually complete.
210
+
211
+ At handoff report:
212
+
213
+ - card and current column;
214
+ - criteria executed and the final evidence;
215
+ - defects found, fixes made, and independent review result;
216
+ - exact staging/production/desktop build state;
217
+ - whether the `Ready for review` badge is visible;
218
+ - any genuinely separate follow-up card.
@@ -9,6 +9,10 @@ This is the loop a coding agent actually runs: read a card, do the work, move
9
9
  the card. The part people get wrong is the last step — work that lands with the
10
10
  board untouched is work nobody can see.
11
11
 
12
+ If the card is already in Staging QA1 or Staging QA2, use `qa-the-card` instead.
13
+ QA is a fresh independent verification and fix-and-retest loop, not the ordinary
14
+ implementation-and-submit path below.
15
+
12
16
  ## Pick up
13
17
 
14
18
  1. `list_my_work_items` — **start here.** It answers "what is mine", across every
@@ -118,15 +122,15 @@ number, a run link, the caveat that makes it honest. An untouched checkbox says
118
122
  nothing about whether the check passed; it says nobody has been near it, which
119
123
  is the same thing an abandoned card says.
120
124
 
121
- Ticking from a build column records `claimed`, not `verified`, and so does
122
- ticking your own card at a QA gate. That is the rule, not a refusal: the grade
123
- is derived from where the card was standing and who you are, and the engineer
124
- who built the thing cannot verify it. Tick anyway `claimed` is a real state
125
- that says the work is believed done, and it is what QA reads before deciding
126
- what to re-run. What you must not do is tick a box you did not execute to make
127
- the count look better: the QA gate reads `verified`, so it stops either way, and
128
- all the false tick achieves is turning an honest "shipped with two open" into a
129
- claim somebody later believes.
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.
130
134
 
131
135
  If a tick turns out to be wrong, `unsatisfy_work_item_acceptance_criterion`
132
136
  takes it off. It clears the note with it, so if the evidence is worth keeping,
@@ -151,11 +155,11 @@ The tool refuses placeholder reasons — "moved", "update", "Moved to another
151
155
  column" — on purpose: a history full of those looks like an audit trail, so
152
156
  nobody goes looking for the real answer, which is worse than no history at all.
153
157
 
154
- **Do not move a card into the terminal column** `Production` on a default
155
- board, or whatever the last column is called on this one. That is a human's call:
156
- the person who reported it, or the reviewer. An agent marking its own work
157
- complete is the one move on this board that nobody can trust. Stop at the review
158
- or QA column and say it is ready.
158
+ **Do not move a card into `Ready to Ship` or `Production`.** Ready to Ship is the
159
+ human's final acceptance of both AI QA receipts. Production is a separate,
160
+ stronger claim that the release is actually live and eligible for release
161
+ notes. In QA1, an independent agent may advance a clean card to QA2; in QA2,
162
+ document the second pass and leave the `🏁 Ready for review` card for the human reviewer.
159
163
 
160
164
  ## Writes
161
165
 
@@ -239,15 +239,22 @@ const IDEMPOTENT_WRITES = new Set([
239
239
  // Every Starfield board write is governed by a required idempotency key.
240
240
  // Retrying the same key replays; it never appends a second transition,
241
241
  // comment, criterion, or card.
242
- 'move_work_item', 'set_work_board_column_wip_limit', 'set_work_item_tag', 'set_work_item_kind',
242
+ 'move_work_item', 'transfer_work_item_between_boards', 'set_work_board_column_wip_limit', 'set_work_item_tag', 'set_work_item_kind',
243
243
  'create_work_item', 'comment_on_work_item', 'flag_work_item',
244
244
  'add_work_item_acceptance_criterion',
245
245
  // Ticking and un-ticking are both genuinely idempotent, and for different
246
246
  // reasons worth keeping straight: a repeat tick is a no-op because the server
247
247
  // only writes the ticker, column and note where they were empty, and a repeat
248
248
  // un-tick clears columns that are already NULL. Neither can compound.
249
- 'satisfy_work_item_acceptance_criterion',
249
+ 'satisfy_work_item_acceptance_criterion', 'record_work_item_criterion_engineering_proof',
250
250
  'unsatisfy_work_item_acceptance_criterion',
251
+ 'claim_work_item_qa_pass',
252
+ 'claim_next_work_item_qa_pass',
253
+ 'heartbeat_work_item_qa_pass',
254
+ 'attach_work_item_qa_evidence',
255
+ 'verify_work_item_qa_evidence_playback',
256
+ 'record_work_item_qa_failure_and_release',
257
+ 'requeue_work_item_qa_after_fix',
251
258
  ]);
252
259
 
253
260
  // Non-read tools that create new state / have side effects each call.
@@ -261,6 +268,7 @@ const NON_IDEMPOTENT_WRITES = new Set([
261
268
  'warmup_email', 'schedule_demo', 'schedule_meeting', 'connect_workspace',
262
269
  'enable_toolset', 'track_conversion', 'log_interaction', 'test_webhook',
263
270
  'replay_webhook_delivery', 'upgrade_account',
271
+ 'release_work_item_qa_pass',
264
272
  ]);
265
273
 
266
274
  // Tools that reach beyond the workspace (external enrichment, web, providers).