tokens-for-good 0.4.21 → 0.4.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,7 +29,7 @@ Each org takes about 5 minutes:
29
29
  2. **Verify** — every citation URL checked, hallucinations flagged and corrected
30
30
  3. **Humanize** — voice pass (no em dashes, no AI-tells, analyst voice)
31
31
 
32
- Under v3 dual-research, every org is researched by two contributors independently; a third contributor consolidates both reports' EVIDENCE TABLEs into one merged table that the server scores deterministically (out of 120). A human reviewer finalizes it for the directory.
32
+ Under v3 dual-research, every org is researched by two contributors independently. An independent validator then prunes any unsupported or fabricated evidence from both reports — reading the server's cached page text, so it does zero web fetches — and a consolidator merges the validated EVIDENCE TABLEs into one table the server scores deterministically (out of 120). A human reviewer finalizes it for the directory.
33
33
 
34
34
  ## Contributor tiers
35
35
 
@@ -46,10 +46,12 @@ Once installed, these are available to your AI via the MCP server:
46
46
 
47
47
  | Tool | Purpose |
48
48
  |---|---|
49
- | `next_action` | Tells you whether to research or consolidate next |
49
+ | `next_action` | Tells you whether to validate, consolidate, or research next |
50
50
  | `claim_org` | Reserves the next available nonprofit (up to 2 researchers per org under v3) |
51
- | `get_methodology` | Fetches research / verify / humanize / consolidate instructions |
51
+ | `get_methodology` | Fetches research / verify / humanize / validate / consolidate instructions |
52
52
  | `submit_report` | Submits a finished report (or a merged consolidation report with `disagreement_rows`) |
53
+ | `get_next_validation` | v3 validator: fetch both reports + cached page text to prune unsupported evidence |
54
+ | `submit_validation` | v3 validator: submit corrected reports (subtract/correct only, never add) |
53
55
  | `get_next_consolidation` | v3 consolidator: fetch your assignment + both source reports to merge |
54
56
  | `setup_automation` | Emits `/schedule` prompt (normally called by `/tfg-schedule` skill) |
55
57
  | `my_impact` / `research_status` / `get_badge` | Stats, leaderboard, GitHub README badge |
package/package.json CHANGED
@@ -1,45 +1,45 @@
1
- {
2
- "name": "tokens-for-good",
3
- "version": "0.4.21",
4
- "type": "module",
5
- "description": "Donate your spare AI tokens to research nonprofits for Fierce Philanthropy",
6
- "homepage": "https://tokensforgood.ai",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/Tokens-for-Good/tokens-for-good.git"
10
- },
11
- "bugs": {
12
- "url": "https://github.com/Tokens-for-Good/tokens-for-good/issues"
13
- },
14
- "bin": {
15
- "tokens-for-good": "src/cli.js"
16
- },
17
- "main": "./src/mcp-server.js",
18
- "files": [
19
- "src/",
20
- "pipeline/",
21
- "skills/",
22
- "LICENSE"
23
- ],
24
- "scripts": {
25
- "start": "node src/mcp-server.js",
26
- "test": "node --test src/**/*.test.js"
27
- },
28
- "keywords": [
29
- "mcp",
30
- "philanthropy",
31
- "nonprofit",
32
- "research",
33
- "tokens",
34
- "social-impact"
35
- ],
36
- "license": "MIT",
37
- "engines": {
38
- "node": ">=18"
39
- },
40
- "dependencies": {
41
- "@modelcontextprotocol/sdk": "^1.0.0",
42
- "prompts": "^2.4.2",
43
- "zod": "^4.3.6"
44
- }
45
- }
1
+ {
2
+ "name": "tokens-for-good",
3
+ "version": "0.4.22",
4
+ "type": "module",
5
+ "description": "Donate your spare AI tokens to research nonprofits for Fierce Philanthropy",
6
+ "homepage": "https://tokensforgood.ai",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Tokens-for-Good/tokens-for-good.git"
10
+ },
11
+ "bugs": {
12
+ "url": "https://github.com/Tokens-for-Good/tokens-for-good/issues"
13
+ },
14
+ "bin": {
15
+ "tokens-for-good": "src/cli.js"
16
+ },
17
+ "main": "./src/mcp-server.js",
18
+ "files": [
19
+ "src/",
20
+ "pipeline/",
21
+ "skills/",
22
+ "LICENSE"
23
+ ],
24
+ "scripts": {
25
+ "start": "node src/mcp-server.js",
26
+ "test": "node --test src/**/*.test.js"
27
+ },
28
+ "keywords": [
29
+ "mcp",
30
+ "philanthropy",
31
+ "nonprofit",
32
+ "research",
33
+ "tokens",
34
+ "social-impact"
35
+ ],
36
+ "license": "MIT",
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "dependencies": {
41
+ "@modelcontextprotocol/sdk": "^1.0.0",
42
+ "prompts": "^2.4.2",
43
+ "zod": "^4.3.6"
44
+ }
45
+ }
@@ -0,0 +1,58 @@
1
+ # Validate Two Independent Research Reports (v3)
2
+
3
+ ## Your Role
4
+
5
+ You are a validator — an independent fourth contributor who sits between the two researchers and the consolidator. Two researchers independently researched the same organization; before their reports are merged and scored, your job is to catch **unsupported or fabricated evidence** and hand back cleaned-up ("validated") versions of both reports.
6
+
7
+ You do **no web research and no fetching**. The server already fetched every page the researchers cited and gives you the cached text. You validate using ONLY that cached text. This is deliberate: it makes validation cheap and possible even on a local model with no scraping budget.
8
+
9
+ ## Why this stage exists
10
+
11
+ A report's score comes entirely from its EVIDENCE TABLE — eight rows, each a quote + a source URL. The most dangerous failure isn't a thin report; it's a confident one citing a quote that **isn't actually on the page**, or a page that **doesn't exist**. That evidence would score real points it didn't earn. The server already runs a deterministic check (does the quote literally appear on the cached page?), and you'll see its verdicts. Your job is the judgment the deterministic check can't do:
12
+
13
+ - Does the quote, even if it appears on the page, actually **support the row's criterion**? (e.g. an "ultimate outcome" row quoting an *output* count, or a counterfactual row quoting a result instead of a study design.)
14
+ - Is the quote **cherry-picked** — technically present but contradicted or heavily qualified elsewhere on the same page?
15
+ - Do the two reports **contradict each other** on the same row in a way that means at least one is wrong?
16
+
17
+ ## Your Inputs
18
+
19
+ The `/tfg-validate` skill (or the `get_next_validation` MCP tool) returns:
20
+ - Your validation **claim_id** (a UUID)
21
+ - The **organization** (name, slug, url)
22
+ - Two **source_reports** (markdown), each with its EVIDENCE TABLE and each with the server's deterministic **citation_verdicts** (per row: `verified` / `fabricated` / `unverifiable`)
23
+ - **cached_pages**: `{url, fetch_status, http_status, text}` for every URL cited across both reports. `fetch_status` is `ok` (text present), `unreachable` (couldn't load — includes 404s), `non_text` (a PDF or similar the server can't read), or `missing` (wasn't cached).
24
+
25
+ ## Your Task — per row, per report
26
+
27
+ Start from the server's verdicts, then apply judgment:
28
+
29
+ 1. **`fabricated`** (quote not on the page, or the URL 404s) → **remove the row** (blank its quote/URL/name). The evidence isn't real.
30
+ 2. **`verified`** (quote is on the page) → check it actually **supports the criterion**. If the quote is on-page but doesn't match what the row is asking for, **correct it** to a better quote *from the same cached page* if one exists, otherwise **remove the row**. You may shorten or fix a quote to match the page verbatim. You may NOT swap in a different page or invent a quote.
31
+ 3. **`unverifiable`** (`non_text` PDF / `unreachable` non-404 / `missing`) → you can't machine-check it. Keep it **only if** the surrounding report makes it credible; if it looks invented, remove it and say so in your notes. Don't punish a real PDF citation just because it can't be auto-read.
32
+ 4. **Counterfactual rows e/f** → the quote must name the study **DESIGN** (randomized/RCT, control or comparison group, quasi-experimental), not the result. If the cached page only supports a result, remove the row.
33
+
34
+ **The one hard rule: you may only SUBTRACT rows or CORRECT a quote to match its cited page. You may NEVER ADD new evidence, new rows, or new URLs.** Adding evidence is the researcher's job, not yours. A validated report is always a subset (or verbatim-corrected version) of the original.
35
+
36
+ This is **enforced by the server**: your submission is rejected (422) if it introduces a URL the original didn't cite, OR if any EVIDENCE TABLE row's quote is not actually found on its cited page. So when you correct a quote, copy it **verbatim from the cached page text** — don't paraphrase. If you can't find supporting text on the page, remove the row instead of rewriting it. (Rows whose page is a PDF/blocked/un-readable are `unverifiable` and pass through — you don't need to fix those.)
37
+
38
+ If a report was already clean, return it unchanged (or simply omit it from your submission — omitted reports keep their original).
39
+
40
+ ## Submit
41
+
42
+ Call `submit_validation` with:
43
+ - `claim_id`: your validation claim ID
44
+ - `validated_reports`: an array of `{claim_id, report_markdown}` — one entry per report you changed, where `claim_id` is that **source report's** claim_id and `report_markdown` is its full corrected markdown. Omit reports you didn't change.
45
+ - `validation_notes`: a short summary of what you cut and why (e.g. "rc1: dropped row f — the RCT quote was a result, not a design; dropped row c — quote not found on the cited page").
46
+ - `estimated_tokens`: honest estimate (validation is read-heavy but no web work).
47
+
48
+ If **both reports are already clean** (nothing to remove or correct), submit `validated_reports` as an **empty array** with a note saying so — the round then proceeds to consolidation unchanged. Don't resubmit a report you didn't change.
49
+
50
+ Each validated report must still be a structurally valid v3 report (all PROMPT/Section headings + an EVIDENCE TABLE + SOURCES). You're pruning evidence, not dismantling the document — leave the prose and structure intact; just remove or correct the offending EVIDENCE TABLE rows (and you may drop a now-unused entry from SOURCES). **Pruning is allowed to drop a report below the usual fresh-submit floors** (≥5 citations, ≥3 domains, ≥1500 words) — those aren't enforced on a validation, because removing fake evidence legitimately shrinks the report.
51
+
52
+ ## What not to do
53
+
54
+ - **Don't add evidence.** Ever. Subtract or correct-to-source only.
55
+ - **Don't fetch anything.** Use the cached_pages text. If a page is `missing`/`unreachable`, treat the citation as unverifiable — don't go find it yourself.
56
+ - **Don't punish honest blanks or real PDFs.** A blank row is fine. An `unverifiable` PDF from a credible source can stay.
57
+ - **Don't rewrite prose.** Your edits are confined to the EVIDENCE TABLE (and removing orphaned SOURCES lines).
58
+ - **Don't second-guess the deterministic `fabricated` verdict.** If the quote isn't on the page, it goes — the page text is right there for you to confirm.
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: tfg-validate
3
+ description: Validate two independent v3 research reports by pruning unsupported or fabricated EVIDENCE TABLE rows, using ONLY the cached page text the server provides (no web fetches). Use this when the user has been assigned a validation, OR to check whether they have one pending. Ideal for local models — it does zero scraping.
4
+ ---
5
+
6
+ The user wants to validate a pair of independent research reports before they're merged and scored. Under v3 dual-research, every org is researched by two contributors; you (an independent fourth contributor) catch unsupported or fabricated evidence and hand back cleaned-up versions of both reports. The consolidator then merges already-validated tables.
7
+
8
+ You do **no web research and no fetching**. The server already fetched every cited page and gives you the cached text. This is deliberate — validation is cheap and works on a local model with no scraping budget.
9
+
10
+ ## What to do
11
+
12
+ 1. **Call `get_next_validation` on the TFG MCP.** It returns:
13
+ - the org being validated,
14
+ - your validation `claim_id` (a UUID — submit against this one),
15
+ - two `source_reports` (each a full v3 report with its EVIDENCE TABLE) plus the server's deterministic `citation_verdicts` per row (`verified` / `fabricated` / `unverifiable`),
16
+ - `cached_pages`: the text of every URL cited across both reports, with a `fetch_status` (`ok` / `unreachable` / `non_text` / `missing`),
17
+ - the full validation methodology inline.
18
+
19
+ If nothing is assigned, the tool says so — tell the user and stop here.
20
+
21
+ 2. **For each report, go row by row** (a1, a2, a3, b, c, d, e, f). Start from the server's verdict, then apply judgment using the cached page text:
22
+ - **`fabricated`** (quote not on the page, or URL 404s) → **remove the row** (blank quote/URL/name).
23
+ - **`verified`** (quote is on the page) → check it actually **supports the row's criterion**. If on-page but off-target, correct it to a better quote *from the same cached page*, or remove the row. Counterfactual rows e/f must quote the study DESIGN (RCT/control/comparison), not a result.
24
+ - **`unverifiable`** (PDF / unreachable non-404 / missing) → keep only if the report makes it credible; otherwise remove and note why. Don't punish a real PDF just because it can't be auto-read.
25
+
26
+ 3. **The one hard rule:** you may only **SUBTRACT** rows or **CORRECT** a quote to match its cited page. **Never ADD** new evidence, rows, or URLs. A validated report is always a subset (or verbatim-corrected version) of the original. If a report was already clean, leave it unchanged (omit it from your submission).
27
+
28
+ 4. **Call `submit_validation`** with:
29
+ - `claim_id`: your validation claim ID from step 1
30
+ - `validated_reports`: an array of `{ claim_id, report_markdown }` — one entry per report you changed, where `claim_id` is that source report's claim_id and `report_markdown` is its full corrected markdown. Omit reports you didn't change. **If both reports are clean, pass an empty array `[]`** — the round proceeds to consolidation unchanged. Pruning is allowed to drop a report below the usual ≥5-citation / ≥3-domain / ≥1500-word floors; those aren't enforced on a validation.
31
+ - `validation_notes`: a short summary of what you cut/corrected and why
32
+ - `estimated_tokens`: an honest estimate (validation is read-heavy, no web work)
33
+
34
+ 5. **Tell the user what you changed** (e.g. "Validated 2 reports for X: dropped report-1 row f (RCT quote was a result, not a design) and report-2 row c (quote not on the cited page). Consolidation will follow.").
35
+
36
+ ## What not to do
37
+
38
+ - **Don't add evidence.** Ever. Subtract or correct-to-source only.
39
+ - **Don't fetch anything.** Use the `cached_pages` text. A `missing`/`unreachable` page means the citation is unverifiable — don't go find it yourself.
40
+ - **Don't punish honest blanks or real PDFs.** A blank row is fine; an `unverifiable` PDF from a credible source can stay.
41
+ - **Don't rewrite prose.** Your edits are confined to the EVIDENCE TABLE (and removing now-orphaned SOURCES lines).
42
+ - **Don't second-guess a `fabricated` verdict** — the page text is right there; if the quote isn't in it, the row goes.
43
+
44
+ ## If something goes wrong
45
+
46
+ - **`get_next_validation` returns "no validations assigned"** → there isn't one queued. The user can run `/tfg` to do a fresh research run instead.
47
+ - **`submit_validation` says a report is "no longer structurally valid"** → you removed a heading/section, not just evidence. It still needs all PROMPT/Section headings + an EVIDENCE TABLE + SOURCES. Restore the structure and resubmit with the same `claim_id`.
48
+ - **`submit_validation` says it "introduces new URLs"** → you added or changed a citation URL. Validation can't add evidence; revert to the original URLs (only remove rows or fix quotes) and resubmit.
49
+ - **`submit_validation` says a row's "quote is not on the cited page"** → you rewrote a quote to text the page doesn't contain. Copy the quote verbatim from the cached page text, or remove the row entirely. Resubmit with the same `claim_id`.
50
+ - **You referenced a claim_id you weren't given** → only the source `claim_id`s from `get_next_validation` are valid in `validated_reports`. Re-read its output.
51
+ - **No cached page text for a citation** → treat that citation as unverifiable; do not fetch it yourself.
package/src/api-client.js CHANGED
@@ -90,6 +90,24 @@ export class ApiClient {
90
90
  return this.request('GET', '/research/consolidate/next');
91
91
  }
92
92
 
93
+ async getNextValidation() {
94
+ // 204 -> null when nothing is assigned, same as consolidation.
95
+ return this.request('GET', '/research/validate/next');
96
+ }
97
+
98
+ async submitValidation(claimId, validatedReports, validationNotes = null, tokenUsage = null) {
99
+ const normalizedTokenUsage = typeof tokenUsage === 'number'
100
+ ? { total_tokens: tokenUsage }
101
+ : tokenUsage;
102
+
103
+ return this.request('POST', '/research/validate/submit', {
104
+ claim_id: claimId,
105
+ validated_reports: validatedReports,
106
+ validation_notes: validationNotes,
107
+ token_usage: normalizedTokenUsage,
108
+ });
109
+ }
110
+
93
111
  async getStatus() {
94
112
  const response = await fetch(`${BASE_URL}/research/status`, {
95
113
  headers: this.headers(),
@@ -98,3 +98,30 @@ test('request() returns null on 204 No Content (consolidation queue empty)', asy
98
98
  globalThis.fetch = original;
99
99
  }
100
100
  });
101
+
102
+ test('getNextValidation returns null on 204 (validation queue empty)', async () => {
103
+ const original = globalThis.fetch;
104
+ globalThis.fetch = async () => new Response(null, { status: 204 });
105
+ try {
106
+ const client = new ApiClient('tfg_test_key');
107
+ const result = await client.getNextValidation();
108
+ assert.equal(result, null);
109
+ } finally {
110
+ globalThis.fetch = original;
111
+ }
112
+ });
113
+
114
+ test('submitValidation posts validated_reports and wraps numeric token usage', async () => {
115
+ await withMockFetch(async (calls) => {
116
+ const client = new ApiClient('tfg_test_key');
117
+ const validated = [{ claim_id: 'src-uuid', report_markdown: 'cleaned report' }];
118
+ await client.submitValidation('val-uuid', validated, 'dropped row f', 5000);
119
+
120
+ assert.match(calls[0].url, /\/research\/validate\/submit$/);
121
+ const body = JSON.parse(calls[0].opts.body);
122
+ assert.equal(body.claim_id, 'val-uuid');
123
+ assert.deepEqual(body.validated_reports, validated);
124
+ assert.equal(body.validation_notes, 'dropped row f');
125
+ assert.deepEqual(body.token_usage, { total_tokens: 5000 });
126
+ });
127
+ });
package/src/init.js CHANGED
@@ -127,6 +127,8 @@ export async function runInit() {
127
127
  console.log(`✓ ${plans[3].label}`);
128
128
  writeSkillFile('tfg-consolidate');
129
129
  console.log(`✓ ${plans[4].label}`);
130
+ writeSkillFile('tfg-validate');
131
+ console.log(`✓ ${plans[5].label}`);
130
132
  } else if (platform === 'qwen-code') {
131
133
  writeQwenCommand('tfg');
132
134
  console.log(`✓ ${plans[1].label}`);
@@ -163,6 +165,7 @@ function planWrites(platform) {
163
165
  plans.push({ label: `${skillPath('tfg-schedule')} (/tfg-schedule skill)` });
164
166
  plans.push({ label: `${skillPath('tfg')} (/tfg skill)` });
165
167
  plans.push({ label: `${skillPath('tfg-consolidate')} (/tfg-consolidate skill)` });
168
+ plans.push({ label: `${skillPath('tfg-validate')} (/tfg-validate skill)` });
166
169
  } else if (platform === 'qwen-code') {
167
170
  plans.push({ label: `${qwenCommandPath('tfg')} (/tfg slash command)` });
168
171
  }
package/src/mcp-server.js CHANGED
@@ -129,13 +129,14 @@ server.tool('claim_org', 'Claim the next available nonprofit org to research.',
129
129
  }
130
130
  });
131
131
 
132
- server.tool('get_methodology', 'Get the full instructions for a pipeline step: research (the v3 EVIDENCE TABLE flow), verify, humanize, or consolidate (the v3 dual-research merge step).', {
133
- step: z.enum(['research', 'verify', 'humanize', 'consolidate']).describe('Which pipeline step to get instructions for'),
132
+ server.tool('get_methodology', 'Get the full instructions for a pipeline step: research (the v3 EVIDENCE TABLE flow), verify, humanize, validate (prune unsupported evidence from two reports using cached page text), or consolidate (the v3 dual-research merge step).', {
133
+ step: z.enum(['research', 'verify', 'humanize', 'validate', 'consolidate']).describe('Which pipeline step to get instructions for'),
134
134
  }, async ({ step }) => {
135
135
  const stepMap = {
136
136
  'research': '01-research/PROMPT.md',
137
137
  'verify': '02-verify/PROMPT.md',
138
138
  'humanize': '03-humanize/PROMPT.md',
139
+ 'validate': '04-validate/PROMPT.md',
139
140
  'consolidate': '05-consolidate/PROMPT.md',
140
141
  };
141
142
 
@@ -204,6 +205,66 @@ server.tool('get_next_consolidation', 'Get your assigned v3 consolidation: the o
204
205
  }
205
206
  });
206
207
 
208
+ server.tool('get_next_validation', 'Get your assigned v3 validation: both researchers\' reports, the server\'s deterministic citation verdicts, and the cached text of every page they cite. You validate using ONLY that cached text (no web fetches), pruning unsupported/fabricated EVIDENCE TABLE rows. Returns "no assignments" when nothing is queued for you.', {}, async () => {
209
+ if (!client) return { content: [{ type: 'text', text: 'Error: TFG_API_KEY not set.' }] };
210
+
211
+ try {
212
+ const result = await client.getNextValidation();
213
+ if (!result || !result.claim_id) {
214
+ return { content: [{ type: 'text', text: 'No validations assigned to you right now.' }] };
215
+ }
216
+ let validateMethodology = '';
217
+ try {
218
+ validateMethodology = readFileSync(join(PIPELINE_DIR, '04-validate/PROMPT.md'), 'utf-8');
219
+ } catch {
220
+ validateMethodology = 'Using ONLY the cached page text provided, remove EVIDENCE TABLE rows whose quote is not on its cited page (verdict "fabricated"), and correct quotes that do not match. You may only SUBTRACT or CORRECT-to-source, never ADD. Submit the corrected reports with submit_validation.';
221
+ }
222
+ const reports = (result.source_reports || []).map((r, i) =>
223
+ `### Source report ${i + 1} — claim_id ${r.claim_id} (submitted ${r.submitted_at || 'unknown'})\n\nServer citation verdicts: ${JSON.stringify(r.citation_verdicts || {})}\n\n${r.report_markdown}`
224
+ ).join('\n\n---\n\n');
225
+ const pages = (result.cached_pages || []).map((p) =>
226
+ `#### ${p.url} [${p.fetch_status}${p.http_status ? ' ' + p.http_status : ''}]\n${p.text ? p.text : '(no text — not machine-checkable)'}`
227
+ ).join('\n\n');
228
+ return {
229
+ content: [{ type: 'text', text: `Validation assigned:\nOrg: ${result.org?.name}\nRound: ${result.round_id}\nYour validation claim ID (submit against this one): ${result.claim_id}\n\n---\n\n${validateMethodology}\n\n--- SOURCE REPORTS ---\n\n${reports}\n\n--- CACHED PAGE TEXT (use ONLY this; do not fetch) ---\n\n${pages}` }],
230
+ };
231
+ } catch (err) {
232
+ if (err.status === 404 || err.status === 204) {
233
+ return { content: [{ type: 'text', text: 'No validations assigned to you right now.' }] };
234
+ }
235
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }] };
236
+ }
237
+ });
238
+
239
+ server.tool('submit_validation', 'Submit a validation: corrected ("validated") versions of the source reports. You may only SUBTRACT unsupported/fabricated EVIDENCE TABLE rows or CORRECT a quote to match its cited page — never ADD new evidence. Provide full corrected markdown for each report you changed; omit reports you left unchanged.', {
240
+ claim_id: z.string().describe('Your validation claim_id from get_next_validation'),
241
+ validated_reports: z.array(z.object({
242
+ claim_id: z.string().describe('A source report\'s claim_id'),
243
+ report_markdown: z.string().describe('That report\'s full corrected markdown'),
244
+ })).describe('One entry per report you changed. Omit reports you did not change.'),
245
+ validation_notes: z.string().optional().describe('Short summary of what you cut/corrected and why'),
246
+ estimated_tokens: z.number().optional().describe('Honest total token estimate for this validation run'),
247
+ }, async ({ claim_id, validated_reports, validation_notes, estimated_tokens }) => {
248
+ if (!client) return { content: [{ type: 'text', text: 'Error: TFG_API_KEY not set.' }] };
249
+
250
+ try {
251
+ const result = await client.submitValidation(claim_id, validated_reports, validation_notes, estimated_tokens ?? null);
252
+ markContributed();
253
+ return {
254
+ content: [{ type: 'text', text: `Validation submitted for ${result.org_name}: ${result.reports_validated} report(s) validated. Consolidation will follow.` }],
255
+ };
256
+ } catch (err) {
257
+ // Surface the structured detail the server returns so the agent knows
258
+ // exactly what to fix: which rows' quotes weren't on the page, which URLs
259
+ // were newly introduced, or which structure checks failed.
260
+ const detail = [];
261
+ if (err.data?.validation_errors) detail.push(err.data.validation_errors.join('\n'));
262
+ if (err.data?.fabricated_rows) detail.push(`Rows whose quote is not on the cited page: ${err.data.fabricated_rows.join(', ')}. Copy the quote verbatim from the cached page text, or remove the row.`);
263
+ if (err.data?.new_urls) detail.push(`New URLs not in the original (not allowed): ${err.data.new_urls.join(', ')}`);
264
+ return { content: [{ type: 'text', text: `Validation error: ${err.message}${detail.length ? '\n' + detail.join('\n') : ''}` }] };
265
+ }
266
+ });
267
+
207
268
  server.tool('research_status', 'See the overall Tokens for Good project progress and leaderboard.', {}, async () => {
208
269
  try {
209
270
  const clientForStatus = client || new ApiClient('dummy', { version: PKG_VERSION, platform, installId }); // Status is public