tor-to-proposal 2.0.0

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.
Files changed (44) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +9 -0
  3. package/LICENSE +21 -0
  4. package/README.md +95 -0
  5. package/SKILL.md +189 -0
  6. package/assets/templates.md +74 -0
  7. package/bin/audit.mjs +60 -0
  8. package/bin/bid-pack.mjs +348 -0
  9. package/bin/cover-letter.mjs +67 -0
  10. package/bin/cv-gap.mjs +90 -0
  11. package/bin/cv-tailor.mjs +261 -0
  12. package/bin/dossier.mjs +32 -0
  13. package/bin/extract.mjs +148 -0
  14. package/bin/financial-proposal.mjs +114 -0
  15. package/bin/lib.mjs +109 -0
  16. package/bin/market-rates.mjs +54 -0
  17. package/bin/package.mjs +234 -0
  18. package/bin/pdf-extract.mjs +135 -0
  19. package/bin/pricing-model.mjs +73 -0
  20. package/bin/profile.mjs +201 -0
  21. package/bin/render.mjs +199 -0
  22. package/bin/simulator.mjs +67 -0
  23. package/bin/template-filler.mjs +160 -0
  24. package/bin/tor-to-proposal.mjs +63 -0
  25. package/commands/tor-audit.md +10 -0
  26. package/commands/tor-bid.md +18 -0
  27. package/commands/tor-cv.md +10 -0
  28. package/commands/tor-pack.md +11 -0
  29. package/commands/tor-price.md +11 -0
  30. package/commands/tor-screen.md +11 -0
  31. package/docs/PROMPT-GUIDE.md +245 -0
  32. package/docs/TECHNICAL-GUIDE.md +211 -0
  33. package/docs/USER-GUIDE.md +228 -0
  34. package/library/SCHEMA.md +45 -0
  35. package/library/bids/.gitkeep +0 -0
  36. package/mcp/server.mjs +358 -0
  37. package/package.json +57 -0
  38. package/references/packs/eu-prag.md +29 -0
  39. package/references/packs/undp.md +30 -0
  40. package/references/packs/usaid.md +29 -0
  41. package/references/packs/worldbank.md +26 -0
  42. package/references/pricing-standards.md +52 -0
  43. package/references/render-specs.md +60 -0
  44. package/references/review-rules.md +38 -0
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env node
2
+ // cv-tailor: produce (build) and verify (lint) a ToR-tailored CV.
3
+ //
4
+ // HONESTY CONTRACT — this is cv-gap's "do not claim" rule extended to generation:
5
+ // * build reorders and selects master-CV material. It never writes a new fact.
6
+ // * lint proves every bullet in the tailored CV is anchored in the master CV
7
+ // (token containment + every number must come from the anchor) or in the
8
+ // user's own cv-evidence.json answers. Unanchored = hard fail.
9
+ // * GAP terms (cv-gap: "do not claim") may not appear anywhere in the output.
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { fail, parseArgs, readText, writeOut, helpText, mdTable } from './lib.mjs';
13
+
14
+ const HELP = helpText('cv-tailor', [
15
+ 'build --cv master-cv.txt --extract out/tor-extract.json [--profile profile.json]',
16
+ ' [--gap cv-gap-report.md] [--out out/cv-tailored.md] [--report out/cv-tailor-report.md]',
17
+ 'lint --cv tailored.md --master master.txt [--gap gap.md] [--evidence cv-evidence.json]',
18
+ ' [--report out/cv-tailor-lint.md]',
19
+ '',
20
+ 'build: reorders/selects master-CV bullets against the ToR evaluation weights.',
21
+ 'lint: every bullet must anchor to the master CV (>=60% token containment in one',
22
+ ' master line, or >=75% across two lines) with all numbers preserved, or come',
23
+ ' from cv-evidence.json (user-supplied answers). GAP terms hard-fail.',
24
+ ]);
25
+
26
+ const STOP = new Set(('a,an,the,and,or,of,for,in,on,to,with,at,by,from,as,is,are,was,were,be,been,being,this,that,these,those,it,its,their,our,your,we,they,he,she,his,her,per,all,any,more,most,than,then,so,such,not,no,but,if,while,during,into,over,under,across,within,without,also,both,each,other,others,new,various,including,include,includes,using,use,used,based,well,has,have,had,do,does,did,will,would,can,could,should,may,might,must,against,about,after,before,between,through,up,down,out,off,own,same,too,very,just'.split(',')));
27
+
28
+ function tokenize(s) {
29
+ return String(s).toLowerCase().replace(/[^a-z0-9&]+/g, ' ').split(' ').filter((t) => t.length >= 2 && !STOP.has(t) && !/^\d+$/.test(t));
30
+ }
31
+ function numsOf(s) {
32
+ return [...String(s).matchAll(/\d+(?:[.,]\d+)?/g)].map((m) => m[0].replace(/\.0+$/, ''));
33
+ }
34
+ const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
35
+
36
+ // ---------- master CV parsing ----------
37
+ function parseCv(text) {
38
+ const lines = text.split('\n');
39
+ const isHeader = (l) => {
40
+ const t = l.trim();
41
+ if (!t) return false;
42
+ if (/^#{1,3}\s+\S/.test(t)) return true;
43
+ if (/^\s*(?:[-*•·‣–]|\d{1,2}[.)])\s+/.test(t)) return false;
44
+ return /^[A-Z][A-Z0-9 &/,'()+-]{2,70}:?$/.test(t) && /[A-Z]{3,}/.test(t);
45
+ };
46
+ const isBullet = (l) => /^\s*(?:[-*•·‣–]|\d{1,2}[.)])\s+/.test(l);
47
+ const sections = [];
48
+ let preamble = [];
49
+ let cur = null;
50
+ for (let i = 0; i < lines.length; i++) {
51
+ const l = lines[i];
52
+ if (isHeader(l)) {
53
+ cur = { title: l.trim().replace(/^#+\s+/, '').replace(/:$/, ''), startLine: i + 1, body: [] };
54
+ sections.push(cur);
55
+ } else if (cur) cur.body.push({ line: i + 1, text: l, bullet: isBullet(l) });
56
+ else preamble.push({ line: i + 1, text: l, bullet: isBullet(l) });
57
+ }
58
+ return { lines, preamble, sections };
59
+ }
60
+
61
+ // ---------- ToR term pool with weights ----------
62
+ function termPool(extract) {
63
+ const pool = new Map();
64
+ const add = (tokens, w) => {
65
+ for (const t of tokens) pool.set(t, (pool.get(t) || 0) + w);
66
+ };
67
+ for (const d of extract.deliverables || []) add(tokenize(d.value), 0.5);
68
+ add(tokenize(extract.title?.value || ''), 1);
69
+ for (const c of extract.evaluation?.criteria || []) add(tokenize(c.label), c.weight / 15);
70
+ if (!pool.size) fail('tor-extract.json has no title/criteria/deliverables to tailor against — run extract.mjs first', 2);
71
+ return pool;
72
+ }
73
+ const scoreOf = (bulletTokens, pool) => bulletTokens.reduce((s, t) => s + (pool.get(t) || 0), 0);
74
+
75
+ // ---------- build ----------
76
+ function cmdBuild(args) {
77
+ const master = readText(args.cv, '--cv (master CV text)');
78
+ const extract = JSON.parse(readText(args.extract, '--extract (tor-extract.json from extract.mjs)'));
79
+ const gap = args.gap ? readText(args.gap, '--gap') : null;
80
+ const parsed = parseCv(master);
81
+ const pool = termPool(extract);
82
+ const SKIP_SUMMARY = /educat|language|reference|publication|referee/i;
83
+
84
+ const scored = []; // { section, item, tokens, score }
85
+ for (const sec of parsed.sections) {
86
+ for (const item of sec.body) {
87
+ if (!item.bullet) continue;
88
+ const tokens = tokenize(item.text);
89
+ scored.push({ sec, item, tokens, score: tokens.length ? scoreOf(tokens, pool) : 0 });
90
+ }
91
+ }
92
+
93
+ // identity: profile if given, else the CV's own preamble lines (verbatim)
94
+ let profile = null;
95
+ if (args.profile) {
96
+ profile = JSON.parse(readText(args.profile, '--profile'));
97
+ if (!profile.identity?.name) fail('profile has no identity.name — run: tor-to-proposal init (or omit --profile)', 2);
98
+ }
99
+ const pre = parsed.preamble.filter((p) => p.text.trim());
100
+ const out = [];
101
+ if (profile) {
102
+ const id = profile.identity;
103
+ out.push(`# ${id.name}${id.credentials ? `, ${id.credentials}` : ''}`, '');
104
+ const contact = [id.title, id.email, id.phone, id.location, id.nationality].filter(Boolean).join(' | ');
105
+ if (contact) out.push(contact, '');
106
+ } else {
107
+ for (const p of pre) out.push(p.text.replace(/^#+\s*/, ''));
108
+ out.push('');
109
+ if (!pre.length) fail('master CV has no header lines and no --profile given — cannot build the identity block', 2);
110
+ }
111
+
112
+ // summary: top-scoring bullets, highest weight first, deduped
113
+ const top = scored.filter((s) => s.score > 0 && !SKIP_SUMMARY.test(s.sec.title)).sort((a, b) => b.score - a.score || a.item.line - b.item.line).slice(0, 6);
114
+ if (top.length) {
115
+ out.push(`## Most relevant for this assignment`, '');
116
+ for (const s of top) out.push(`- ${s.item.text.replace(/^\s*(?:[-*•·‣–]|\d{1,2}[.)])\s+/, '').trim()}`);
117
+ out.push('');
118
+ }
119
+
120
+ // sections in original order; bullets reordered by relevance inside experience-like sections
121
+ const orderInSection = new Map(); // section title -> ordered items
122
+ for (const sec of parsed.sections) {
123
+ const items = sec.body.filter((b) => b.bullet);
124
+ const prose = sec.body.filter((b) => !b.bullet);
125
+ if (SKIP_SUMMARY.test(sec.title) || !scored.some((s) => s.sec === sec && s.score > 0)) {
126
+ orderInSection.set(sec, { prose, items }); // untouched
127
+ } else {
128
+ const keyed = new Map(items.map((it) => [it.line, scored.find((s) => s.item === it)]));
129
+ const reordered = [...items].sort((a, b) => (keyed.get(b)?.score || 0) - (keyed.get(a)?.score || 0) || a.line - b.line);
130
+ orderInSection.set(sec, { prose, items: reordered });
131
+ }
132
+ }
133
+ for (const sec of parsed.sections) {
134
+ out.push(`## ${sec.title}`, '');
135
+ const { prose, items } = orderInSection.get(sec);
136
+ for (const p of prose) out.push(p.text.trimEnd());
137
+ if (prose.length && items.length) out.push('');
138
+ for (const it of items) out.push(it.text.trimEnd());
139
+ out.push('');
140
+ }
141
+ out.push('<!-- generated by tor-to-proposal cv-tailor: reordered/selected from the master CV only. Trace: cv-tailor-report.md -->');
142
+ const md = out.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
143
+ writeOut(args.out || path.join('out', 'cv-tailored.md'), md);
144
+
145
+ // report
146
+ const anchors = top.map((s) => [s.item.text.replace(/^\s*(?:[-*•·‣–]|\d{1,2}[.)])\s+/, '').slice(0, 70), s.sec.title, `line ${s.item.line}`, s.score.toFixed(1)]);
147
+ const crit = extract.evaluation?.criteria || [];
148
+ const cvAll = norm(master);
149
+ const mirror = crit.map((c) => {
150
+ const toks = [...new Set(tokenize(c.label))];
151
+ const missing = toks.filter((t) => !cvAll.includes(t));
152
+ return [`${c.label.slice(0, 50)} (${c.weight} pts)`, missing.length ? `not yet evidenced in CV: ${missing.join(', ')}` : 'all terms present in CV'];
153
+ });
154
+ const gapTerms = gap ? [...gap.matchAll(/\|\s*[^|]*?:\s*([^|]+?)\s*\|\s*p\.\d+\s*\|\s*✖/g)].map((m) => m[1].trim()) : [];
155
+ const report = `# CV tailor report\n\nGenerated from master CV only (no new facts). Verify every line before use.\n\n` +
156
+ `## Summary bullets, traceable to the master CV\n\n` +
157
+ (anchors.length ? mdTable(['Tailored bullet', 'From section', 'Master line', 'Relevance score'], anchors) : 'no scored bullets — check tor-extract.json criteria') +
158
+ `\n\n## Terminology mirror (advisory — edit wording, never add facts)\n\n` + mdTable(['Criterion', 'Mirror advice'], mirror) +
159
+ (gapTerms.length ? `\n\n## GAP terms — must NOT appear in this CV\n` + gapTerms.map((g) => `- ${g}`).join('\n') + `\n` : '') +
160
+ `\nNext: cv-tailor.mjs lint --cv <tailored> --master <master> [--gap gap.md]\n`;
161
+ writeOut(args.report || path.join('out', 'cv-tailor-report.md'), report);
162
+ process.stderr.write(`cv-tailor: ${top.length} summary bullets, ${parsed.sections.length} sections -> ${args.out || 'out/cv-tailored.md'}\n`);
163
+ }
164
+
165
+ // ---------- lint ----------
166
+ function cmdLint(args) {
167
+ const tailored = readText(args.cv, '--cv (tailored CV)');
168
+ const master = readText(args.master, '--master (the ORIGINAL master CV)');
169
+ const gap = args.gap ? readText(args.gap, '--gap') : null;
170
+ let evidence = [];
171
+ if (args.evidence) {
172
+ const ev = JSON.parse(readText(args.evidence, '--evidence'));
173
+ evidence = (Array.isArray(ev) ? ev : ev.items || []).map((e) => (typeof e === 'string' ? { text: e } : e)).filter((e) => e.text);
174
+ }
175
+ const masterLines = master.split('\n');
176
+ const masterTokens = masterLines.map((l) => ({ toks: new Set(tokenize(l)), text: l }));
177
+ const evidenceNorm = evidence.map((e) => ({ norm: norm(e.text), id: e.id || 'evidence', raw: e.text }));
178
+ const failures = [];
179
+ const ok = [];
180
+ const warnings = [];
181
+
182
+ const bullets = tailored.split('\n').filter((l) => /^\s*(?:[-*•·‣–]|\d{1,2}[.)])\s+\S/.test(l));
183
+ if (!bullets.length) failures.push('no bullets found in the tailored CV — is this the right file?');
184
+
185
+ for (const b of bullets) {
186
+ const text = b.replace(/^\s*(?:[-*•·‣–]|\d{1,2}[.)])\s+/, '').trim();
187
+ const toks = tokenize(text);
188
+ if (!toks.length) continue;
189
+ // best single-line anchor, then best two-line union
190
+ let best = { cov: 0, idx: [] };
191
+ for (let i = 0; i < masterTokens.length; i++) {
192
+ let hit = 0;
193
+ for (const t of toks) if (masterTokens[i].toks.has(t)) hit++;
194
+ const cov = hit / toks.length;
195
+ if (cov > best.cov) best = { cov, idx: [i] };
196
+ }
197
+ if (best.cov < 0.6 && masterTokens.length > 1) {
198
+ const first = best.idx[0] ?? 0;
199
+ let pair = { cov: 0, idx: [first, first] };
200
+ for (let i = 0; i < masterTokens.length; i++) {
201
+ for (const j of [first, i]) {
202
+ if (i === j) continue;
203
+ const union = new Set([...masterTokens[i].toks, ...masterTokens[j].toks]);
204
+ let hit = 0;
205
+ for (const t of toks) if (union.has(t)) hit++;
206
+ const cov = hit / toks.length;
207
+ if (cov > pair.cov) pair = { cov, idx: [i, j].sort((a, b) => a - b) };
208
+ }
209
+ }
210
+ if (pair.cov >= 0.75) best = pair;
211
+ }
212
+ const anchorText = best.idx.map((i) => masterLines[i]).join(' ');
213
+ const ev = evidenceNorm.find((e) => e.norm === norm(text) || (norm(text).length && e.norm.includes(norm(text)) && norm(text).length / e.norm.length > 0.8));
214
+ if (ev) {
215
+ ok.push([text.slice(0, 60), `user-input evidence (${ev.id})`, '']);
216
+ continue;
217
+ }
218
+ if (best.cov < 0.6) {
219
+ failures.push(`unanchored bullet (no master-CV line covers ≥60% of it): "${text.slice(0, 90)}" — remove it, or answer its evidence question and put the text in cv-evidence.json`);
220
+ continue;
221
+ }
222
+ const missingNums = numsOf(text).filter((n) => !numsOf(anchorText).includes(n));
223
+ if (missingNums.length) {
224
+ failures.push(`number(s) ${missingNums.join(', ')} not present in the anchor master line(s) (line${best.idx.length > 1 ? 's' : ''} ${best.idx.map((i) => i + 1).join(',')}): "${text.slice(0, 90)}" — numbers may not be invented or altered`);
225
+ continue;
226
+ }
227
+ ok.push([text.slice(0, 60), `master line${best.idx.length > 1 ? 's' : ''} ${best.idx.map((i) => i + 1).join(',')}`, `${Math.round(best.cov * 100)}%`]);
228
+ }
229
+
230
+ if (gap) {
231
+ [...gap.matchAll(/\|\s*[^|]*?:\s*([^|]+?)\s*\|\s*p\.\d+\s*\|\s*✖/g)].forEach((m) => {
232
+ const term = m[1].trim().toLowerCase();
233
+ if (term.length > 3 && norm(tailored).includes(term)) failures.push(`tailored CV mentions "${term}" which cv-gap marked GAP — remove it entirely`);
234
+ });
235
+ }
236
+ const fills = [...tailored.matchAll(/\[FILL[^\]]*\]/g)].length;
237
+ if (fills) warnings.push(`${fills} [FILL] placeholder(s) remain — resolve before submission (audit.mjs gates this too)`);
238
+
239
+ const report = `# CV tailor lint — ${path.basename(String(args.cv))}\n\n` +
240
+ `${failures.length ? `## FAIL (${failures.length})\n` + failures.map((f) => '- ' + f).join('\n') : '## PASS — every bullet traceable'}\n\n` +
241
+ `## Anchor table (${ok.length} bullets)\n\n` + mdTable(['Bullet', 'Anchored to', 'Coverage'], ok) +
242
+ (warnings.length ? `\n\n## Warnings\n` + warnings.map((w) => '- ' + w).join('\n') : '');
243
+ writeOut(args.report || path.join('out', 'cv-tailor-lint.md'), report);
244
+ process.stdout.write(report);
245
+ if (failures.length) process.exit(1);
246
+ }
247
+
248
+ // ---------- dispatch ----------
249
+ const args = parseArgs(process.argv.slice(2));
250
+ const mode = args._[0] ?? 'build';
251
+ if (args.__help || !['build', 'lint'].includes(mode)) {
252
+ process.stdout.write(HELP);
253
+ process.exit(args.__help ? 0 : 1);
254
+ }
255
+ if (mode === 'build') {
256
+ if (!args.cv || !args.extract) { process.stdout.write(HELP); process.exit(1); }
257
+ cmdBuild(args);
258
+ } else {
259
+ if (!args.cv || !args.master) { process.stdout.write(HELP); process.exit(1); }
260
+ cmdLint(args);
261
+ }
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ // dossier: client-intelligence scaffolder. Generates the research structure;
3
+ // every fact you put in it must carry source + access date (citation rule).
4
+ import { parseArgs, writeOut, helpText } from './lib.mjs';
5
+
6
+ const HELP = helpText('dossier', [
7
+ 'Usage: node bin/dossier.mjs --client "UNOPS" [--sector "M&E"] [--out client-dossier.md]',
8
+ '',
9
+ 'Scaffolds client-dossier.md: type presets, payment reputation, past awards,',
10
+ 'evaluation committee. Research outputs feed pricing contingency + tone.',
11
+ ]);
12
+
13
+ const PRESETS = {
14
+ un: 'UN system agency or fund. Tone: formal multilateral, results-framework vocabulary, SDG alignment where the ToR asks. Registration: UNGM (ungm.org) vendor account usually required before contract award; some agencies additionally use their own vendor portals (UNGM -> "United Nations Global Marketplace" -> register -> UNGM Vendor Number). Payment reputation: search "UNGM contract awards <agency> <sector>".',
15
+ ingo: 'International NGO. Tone: mission- and beneficiary-first; donor visibility rules matter. Registration: usually vendor forms + due diligence pack (registration docs, bank letter, anti-terrorist financing screening). Payment: grant-conditioned; verify payment-terms reputation in the ToR + peer networks.',
16
+ ministry: 'Government ministry or agency. Tone: national-plan/policy alignment; cite the relevant national strategy by name in your technical response. Registration: national procurement portal + tax clearance. Payment: verify budget-line availability; payment delays are common — feed contingency.',
17
+ private: 'Private sector / foundation. Tone: ROI and deliverable-milestone language. Registration: vendor onboarding + compliance screening. Payment: usually faster; verify in ToR.',
18
+ };
19
+
20
+ const args = parseArgs(process.argv.slice(2));
21
+ if (args.__help || !args.client) {
22
+ process.stdout.write(HELP);
23
+ process.exit(args.__help ? 0 : 1);
24
+ }
25
+ const client = String(args.client).trim();
26
+ const sector = args.sector || '[FILL: sector]';
27
+ const c = client.toLowerCase();
28
+ const typeKey = /ministry|government|dept/.test(c) ? 'ministry' : /united nations|\bun\b|undp|unops|unicef|unhcr|iom|fao|wfp|who|unesco/.test(c) ? 'un' : /ngo|save the children|oxfam|care|nrcs|irc|nrc/.test(c) ? 'ingo' : 'private';
29
+
30
+ const md = `# Client dossier — ${client}\n\n> Every row below needs a source + access date. This file feeds pricing\n> contingency (payment risk) and proposal tone. No invented facts.\n\n## Type preset: ${typeKey.toUpperCase()}\n${PRESETS[typeKey]}\n\n## 1. Registration prerequisites\n- [ ] Portal(s) required: [FILL]\n- [ ] Documents required before award: [FILL]\n- [ ] Lead time to register: [FILL] (bid only if registration fits inside the deadline)\n\n## 2. Payment reputation (feeds pricing contingency)\n| Signal | Finding | Source + date |\n| --- | --- | --- |\n| Typical payment delay after invoice | [FILL] | [FILL] |\n| Public complaints / vendor disclosures | [FILL] | [FILL] |\n| Currency of payment + FX risk | [FILL] | [FILL] |\n\nRule: documented slow payer -> raise pricing contingency or negotiate advance payment; cite the reason in your own notes, not the proposal.\n\n## 3. Past awards for similar work (calibrates your rate band)\n| Award | Year | Winner (if disclosed) | Value | Source + date |\n| --- | --- | --- | --- | --- |\n| [FILL] | | | | |\n\nSearch: UNGM "Contract Award" notices for ${client}; national procurement portals; ${sector} keyword.\n\n## 4. Evaluation committee / process\n- Members or roles stated in ToR: [FILL]\n- Two-envelope (technical opened first) or single envelope: [FILL — check ToR p.X]\n- Clarification history of past solicitations: [FILL]\n\n## 5. What this means for THIS bid\n- Tone to adopt: [FILL from preset + ToR wording]\n- Rate band implication: [FILL from §2 + §3]\n- Risk to price in: [FILL]\n`;
31
+ writeOut(args.out || 'client-dossier.md', md);
32
+ process.stderr.write(`dossier scaffold written for ${client} (type: ${typeKey}).\n`);
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env node
2
+ // extract: page-tagged ToR text -> tor-extract.json + bid-screen.md + compliance-matrix.md
3
+ // Regex fast-path with [[PAGE n]] attribution on every field. The agent (SKILL.md
4
+ // step 1) may pass --json with additional fields; agent values are kept only when
5
+ // they carry their own page attribution, and every conflict is recorded, never hidden.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fail, parseArgs, readText, writeOut, helpText, num, pageOf, extractWindow, mdTable } from './lib.mjs';
9
+
10
+ const HELP = helpText('extract', [
11
+ 'Usage: node bin/extract.mjs --tor tor.txt [--json agent-fields.json]',
12
+ ' [--fee 15000 --bid-days 3 --day-rate 400] # optional EV inputs (user-supplied)',
13
+ ' [--out-dir out]',
14
+ '',
15
+ 'Outputs: <out-dir>/tor-extract.json, bid-screen.md, compliance-matrix.md',
16
+ 'Every extracted field records the [[PAGE n]] it came from, or null.',
17
+ ]);
18
+
19
+ const args = parseArgs(process.argv.slice(2));
20
+ if (args.__help || !args.tor) {
21
+ process.stdout.write(HELP);
22
+ process.exit(args.__help ? 0 : 1);
23
+ }
24
+ const tor = readText(args.tor, '--tor (page-tagged ToR text; run pdf-extract first)');
25
+ const outDir = args['out-dir'] || 'out';
26
+ fs.mkdirSync(path.resolve(outDir), { recursive: true });
27
+
28
+ function grab(re, group = 1) {
29
+ const m = tor.match(re);
30
+ return m ? { value: m[group].trim(), page: pageOf(tor, m.index) } : null;
31
+ }
32
+
33
+ const DATE_RE = '(\\d{1,2}[ \\-/]\\w{3,9}[ \\-/]\\d{2,4}|\\d{4}-\\d{2}-\\d{2})';
34
+ const AGENCIES = 'united nations|undp|unops|unicef|unhcr|iom|fao|wfp|who|unesco|un women|world bank|ifc|adb|afdb|idb|ebrd|usaid|fcdo|giz|ec |european union|council of europe';
35
+
36
+ // Reference numbers: keyword is case-insensitive but the CODE must be genuinely
37
+ // uppercase/digit (case-sensitive class), otherwise prose like the "TERMS OF
38
+ // REFERENCE" heading or "Individual Consultant" matches via the /i flag.
39
+ function grabRefNumber() {
40
+ const kw = tor.match(/(?:reference|tender|solicitation|vacancy)[^.\n]{0,20}(?:no\.?|number|#|ref)?[:.\s]+/i);
41
+ if (kw) {
42
+ const after = tor.slice(kw.index + kw[0].length);
43
+ const code = after.match(/\s*([A-Z0-9][A-Z0-9-]{4,})/); // case-sensitive: real ref codes are caps/digits
44
+ if (code && code.index <= 4 && /\d/.test(code[1])) {
45
+ return { value: code[1], page: pageOf(tor, kw.index + kw[0].length + code.index) };
46
+ }
47
+ }
48
+ return grab(/\b([A-Z0-9]{2,6}[-/][A-Z0-9][A-Z0-9\/\-]{3,})\b/);
49
+ }
50
+
51
+ const extract = {
52
+ source: path.basename(args.tor),
53
+ title: grab(/^[ \t]*(?:terms of reference|tor|consultancy|individual consultant|request for proposal|rfp)[^\n]*\n+[ \t]*([^\n]{10,120})/im, 1)
54
+ ?? grab(/^#{1,3}[ \t]*([^\n]{10,120})/m, 1)
55
+ ?? grab(/(?:title|assignment)[\"”]*[:.]([^\n]{10,120})/i, 1),
56
+ referenceNumber: grabRefNumber(),
57
+ client: grab(new RegExp(`(${AGENCIES})`, 'i'), 1),
58
+ contactEmail: grab(/([\w.+-]+@[\w-]+\.[\w.]{2,})/, 1),
59
+ deadline: grab(new RegExp(`(?:deadline|closing date|last date|submission date)[^\\n]{0,60}?${DATE_RE}`, 'i'), 1),
60
+ clarificationCutoff: grab(new RegExp(`clarification[^\\n]{0,80}?${DATE_RE}`, 'i'), 1),
61
+ startDate: grab(new RegExp(`(?:expected |anticipated |proposed )?start(?:ing|t)?(?: date)?[^\\n]{0,40}?${DATE_RE}`, 'i'), 1),
62
+ duration: grab(/(?:duration|period of performance|assignment length|timeframe)[^.\n]{0,50}?(\d+\s*(?:calendar |working |)?(?:day|week|month|year)s?)/i, 1),
63
+ effortPersonDays: grab(/(\d{1,3})\s*(?:person[- ]days|working days|professional days|pd\b)/i, 1),
64
+ validity: grab(/(?:validity|proposal (?:shall be )?valid)[^.\n]{0,50}?(\d+\s*(?:calendar )?(?:day|month)s?)/i, 1),
65
+ paymentTerms: [...tor.matchAll(/payment[^\n]{5,140}/gi)].slice(0, 6).map((m) => ({ value: m[0].replace(/\s+/g, ' ').trim(), page: pageOf(tor, m.index) })),
66
+ submissionChannel: grab(/(?:submi(?:t|ssion)s?)[^\n]{0,120}?(?:via|through|at|to|on)\s+([^\n]{3,90})/i, 1),
67
+ };
68
+
69
+ // Evaluation criteria table: window from the criteria heading to the next section.
70
+ const evalWindow = extractWindow(
71
+ tor,
72
+ /evaluation criteri|technical evaluation|award criteria|scoring (?:grid|methodology)/i,
73
+ /\n\s*(?:financial (?:proposal|evaluation)|annex\b|other terms|submission of)/i
74
+ );
75
+ if (evalWindow) {
76
+ const criteria = [];
77
+ for (const line of evalWindow.split('\n')) {
78
+ const m = line.match(/^\|?\s*\d{1,2}[.)]?\s*\|?\s*([^|]{4,90}?)\s*\|?\s*(\d{1,3})\s*(?:pts?|points?|%)?\s*\|?\s*$/);
79
+ if (!m) continue;
80
+ const weight = Number(m[2]);
81
+ const label = m[1].replace(/\.{2,}/g, '').trim();
82
+ if (label.length < 4 || weight > 100 || criteria.some((c) => c.label === label)) continue;
83
+ criteria.push({ label, weight, page: pageOf(tor, tor.indexOf(line)) });
84
+ }
85
+ const total = criteria.reduce((s, c) => s + c.weight, 0);
86
+ const statedMatch = evalWindow.match(/(?:maximum|total|out of|of)\s*(?:of\s*)?(\d{1,3})\s*(?:points?|pts?|%|marks?)/i);
87
+ const stated = statedMatch ? Number(statedMatch[1]) : null;
88
+ extract.evaluation = {
89
+ criteria,
90
+ weightsSum: total,
91
+ statedTotal: stated,
92
+ weightsSumCheck: stated === null ? 'not-stated' : total === stated ? 'ok' : `MISMATCH (parsed ${total} vs stated ${stated}) — ToR may contain a typo; surface this in any clarification question`,
93
+ };
94
+ }
95
+ if (!extract.evaluation) extract.evaluation = { criteria: [], weightsSum: 0, statedTotal: null, weightsSumCheck: 'criteria-table-not-found — use --json to supply agent-extracted criteria' };
96
+
97
+ // Deliverables: bulleted/numbered lines under a deliverables heading.
98
+ const delWindow = extractWindow(tor, /deliverables?|scope of work|tasks?(?: and deliverables)?/i, /\n\s*\n\s*\n|\n\s*(?:qualifications|requirements|annex\b)/i, 3000);
99
+ extract.deliverables = delWindow
100
+ ? [...delWindow.matchAll(/^\s*(?:[-*•]|\d{1,2}[.)])\s+([^\n]{8,140})/gm)].slice(0, 15).map((m) => ({ value: m[1].trim(), page: pageOf(tor, m.index) }))
101
+ : [];
102
+
103
+ // Agent-assisted merge: agent fields win only if they carry page attribution;
104
+ // conflicts are recorded, never silently discarded.
105
+ const conflicts = [];
106
+ if (args.json) {
107
+ const agentJson = JSON.parse(readText(args.json, '--json'));
108
+ for (const [k, v] of Object.entries(agentJson)) {
109
+ if (v === null || v === undefined) continue;
110
+ if (!(v instanceof Object) || v.page === null || v.page === undefined) continue; // unattributed -> reject
111
+ if (Array.isArray(v)) {
112
+ if (!v.every((x) => x && x.page != null)) continue;
113
+ }
114
+ const prev = extract[k];
115
+ if (prev && JSON.stringify(prev) !== JSON.stringify(v)) conflicts.push({ field: k, regex: prev, agent: v });
116
+ extract[k] = v;
117
+ }
118
+ }
119
+ extract.conflicts = conflicts;
120
+ extract.generatedAt = new Date().toISOString();
121
+
122
+ fs.writeFileSync(path.join(outDir, 'tor-extract.json'), JSON.stringify(extract, null, 2));
123
+
124
+ // ---------- bid screen ----------
125
+ const f = (x) => (x && x.value ? x.value : '[FILL]') + (x && x.page ? ` (p.${x.page})` : '');
126
+ let ev = '';
127
+ if (args.fee !== undefined) {
128
+ const fee = num(args.fee, '--fee', { min: 1 });
129
+ const bidDays = num(args['bid-days'], '--bid-days', { min: 0 });
130
+ const dayRate = num(args['day-rate'], '--day-rate', { min: 0 });
131
+ ev = mdTable(['Scenario', 'P(win)', 'Expected value'], [0.25, 0.5, 0.75].map((p) => {
132
+ const value = p * fee - bidDays * dayRate;
133
+ return [`P(win)=${p}`, `${(p * 100).toFixed(0)}%`, `${value >= 0 ? '+' : ''}${Math.round(value).toLocaleString('en-US')}`];
134
+ })) + `\n\nEV = P(win) × fee − bid-days × your-day-rate = P × ${fee.toLocaleString('en-US')} − ${bidDays} × ${dayRate.toLocaleString('en-US')}.\nAll three inputs are your estimates (HONESTY RULE). EV > 0 at a realistic P(win) is necessary, not sufficient — go/no-go is your call.\n`;
135
+ }
136
+ const ungm = /ungm|un global marketplace/i.test(tor);
137
+ writeOut(path.join(outDir, 'bid-screen.md'), `# Bid screen\n\n## Assignment\n- Title: ${f(extract.title)}\n- Reference: ${f(extract.referenceNumber)}\n- Client: ${f(extract.client)}\n- Deadline: ${f(extract.deadline)}\n- Clarifications by: ${f(extract.clarificationCutoff)}\n- Duration / effort: ${f(extract.duration)} / ${f(extract.effortPersonDays)} person-days\n\n## Go / no-go checklist\n- [ ] UNGM/portal registration ${ungm ? '(ToR mentions UNGM — p.' + pageOf(tor, tor.search(/ungm/i)) + ')' : '(verify client registration prerequisites — see dossier)'}\n- [ ] CV matches must-have qualifications (run cv-gap)\n- [ ] Deadline achievable with your calendar\n- [ ] Eligibility (nationality/citizenship clauses if any)\n- [ ] EV positive at realistic P(win)\n\n${ev}## Red flags\n- ${extract.evaluation.weightsSumCheck !== 'ok' ? 'Weights check: ' + extract.evaluation.weightsSumCheck : 'Evaluation weights sum consistent'}\n- ${extract.conflicts.length} agent/regex conflict(s) recorded in tor-extract.json\n`);
138
+
139
+ // ---------- compliance matrix ----------
140
+ const rows = [];
141
+ if (extract.evaluation.criteria.length) rows.push(['**Evaluation criteria covered** (one section each in technical response)', extract.evaluation.criteria.map((c) => `p.${c.page ?? '?'}`).join(', '), 'See technical response', 'You', 'TODO']);
142
+ if (extract.deliverables.length) for (const d of extract.deliverables) rows.push([`Deliverable: ${d.value.slice(0, 60)}`, `p.${d.page}`, '[FILL: approach in 2–3 lines]', 'You', 'TODO']);
143
+ for (const p of extract.paymentTerms.slice(0, 4)) rows.push([`Payment term: ${p.value.slice(0, 60)}`, `p.${p.page}`, 'Accept / negotiate', 'You', 'TODO']);
144
+ rows.push(['Proposal validity per ToR', extract.validity?.page ? `p.${extract.validity.page}` : '[FILL]', 'Declare in cover letter', 'You', 'TODO']);
145
+ rows.push(['GCC / no-deviation acceptance', '[FILL: page]', 'Declare acceptance in cover letter', 'You', 'TODO']);
146
+ rows.push(['Submission channel + format', extract.submissionChannel?.page ? `p.${extract.submissionChannel.page}` : '[FILL]', 'Verify portal + file formats', 'You', 'TODO']);
147
+ writeOut(path.join(outDir, 'compliance-matrix.md'), `# Compliance matrix — ${f(extract.referenceNumber)}\n\nSource: ${extract.source}. Every requirement verbatim from the ToR with its page.\n\n` + mdTable(['Requirement', 'p.X', 'Our response', 'Owner', 'Status'], rows) + `\n\naudit.mjs will hard-fail while any Status is not Complete.\n`);
148
+ process.stderr.write(`extract: ${extract.evaluation.criteria.length} criteria, ${extract.deliverables.length} deliverables, ${conflicts.length} conflicts -> ${outDir}/\n`);
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ // financial-proposal: pricing.json + tor-extract.json -> the client-facing
3
+ // financial proposal (markdown + optional xlsx). Numbers come ONLY from
4
+ // pricing.json (user-confirmed inputs) or [FILL]; the internal floor math
5
+ // (base/loading/contingency) stays in pricing.md — the client sees the quote.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { parseArgs, readText, writeOut, helpText, num, fmt, loadDep } from './lib.mjs';
9
+
10
+ const HELP = helpText('financial-proposal', [
11
+ 'Usage: node bin/financial-proposal.mjs --pricing out/pricing.json --extract out/tor-extract.json',
12
+ ' [--context out/bid-context.json] [--out-dir out] [--xlsx]',
13
+ '',
14
+ 'Outputs: <out-dir>/financial-proposal.md (+ financial-proposal.xlsx with --xlsx).',
15
+ 'Effort comes from pricing.json --effort, else the ToR extract, else [FILL].',
16
+ 'If the client supplied their own financial form, template-filler.mjs governs instead.',
17
+ ]);
18
+
19
+ const args = parseArgs(process.argv.slice(2));
20
+ if (args.__help || !args.pricing || !args.extract) {
21
+ process.stdout.write(HELP);
22
+ process.exit(args.__help ? 0 : 1);
23
+ }
24
+ const pricing = JSON.parse(readText(args.pricing, '--pricing (run pricing-model.mjs first)'));
25
+ const extract = JSON.parse(readText(args.extract, '--extract (run extract.mjs first)'));
26
+ let context = {};
27
+ try { context = JSON.parse(fs.readFileSync(args.context || path.join(args['out-dir'] || 'out', 'bid-context.json'), 'utf8')); } catch { /* optional */ }
28
+
29
+ const currency = pricing.inputs.currency || 'USD';
30
+ const rate = pricing.quoteFloorDay; // all-inclusive day rate (base + loading + contingency)
31
+ const p = (x) => (x && x.value ? x.value : null);
32
+ const pg = (x) => (x && x.page ? `(p.${x.page})` : '');
33
+ const refNo = p(extract.referenceNumber) || '[FILL: reference no.]';
34
+ const title = p(extract.title) || '[FILL: assignment title]';
35
+ const consultant = context.consultantLine || '[FILL: consultant name + credentials]';
36
+ const effort = pricing.inputs.effortDays ?? (extract.effortPersonDays ? Number(extract.effortPersonDays.value) : null);
37
+ const validity = context.validity || (extract.validity ? `${extract.validity.value} from the submission deadline ${pg(extract.validity)}` : '[FILL: validity period per ToR]');
38
+ // pick the first payment capture that carries actual terms, not just a "PAYMENT TERMS" heading
39
+ const paymentEntry = (extract.paymentTerms || []).find((t) => t.value.replace(/^\s*payment\s+terms?\s*[:\-]?\s*/i, '').replace(/[^a-z0-9]/gi, '').length >= 15) || null;
40
+ const payment = paymentEntry ? `${paymentEntry.value.replace(/^\s*payment\s+terms?\s*[:\-]?\s*/i, '')} ${pg(paymentEntry)}` : '[FILL: payment schedule per ToR]';
41
+ const fees = effort !== null ? rate * effort : null;
42
+ const reimbursables = context.reimbursables ?? null; // user-supplied; stays [FILL] otherwise
43
+ const reimbursablesNum = reimbursables !== null && Number.isFinite(Number(reimbursables)) ? Number(reimbursables) : 0; // text answers ("none") add zero
44
+ const total = fees !== null ? fees + reimbursablesNum : null;
45
+
46
+ const amount = (v) => (v === null || v === undefined ? '[FILL]' : Number.isFinite(Number(v)) ? fmt(Number(v)) : String(v));
47
+ const md = `# Financial Proposal
48
+
49
+ | | |
50
+ | --- | --- |
51
+ | Assignment | ${title} |
52
+ | Reference | ${refNo} ${extract.referenceNumber?.page ? `(p.${extract.referenceNumber.page})` : ''} |
53
+ | Submitted by | ${consultant} |
54
+ | Date | ${context.date || new Date().toISOString().slice(0, 10)} |
55
+ | Currency | ${currency} |
56
+ | Validity | ${validity} |
57
+
58
+ ## Cost breakdown
59
+
60
+ | # | Item | Qty | Unit rate (${currency}) | Amount (${currency}) |
61
+ | --- | --- | --- | --- | --- |
62
+ | 1 | Professional fees — ${title}${effort !== null ? ` (${effort} person-days)` : ''} | ${effort ?? '[FILL: person-days]'} | ${fmt(rate)} | ${amount(fees)} |
63
+ | 2 | Reimbursable expenses (itemised attachment) | — | — | ${reimbursables !== null ? amount(reimbursables) : '[FILL: itemize, or state "none" and delete this row]'} |
64
+ | | **TOTAL (all-inclusive, ${currency})** | | | **${amount(total)}** |
65
+
66
+ ## Payment schedule
67
+
68
+ ${payment}
69
+
70
+ ## Notes
71
+
72
+ 1. The daily rate is **all-inclusive**: professional fees, overheads and insurance. No separate loading lines are claimable.
73
+ 2. Taxes: ${context.taxes || '[FILL: tax treatment per the ToR / applicable law — declare whether amounts are tax-inclusive]'}.
74
+ 3. This proposal remains valid for the validity period stated above. Per the ToR, no deviation from the General Conditions of Contract is accepted.
75
+ 4. Deliverables priced above are those listed in the ToR${extract.deliverables.length ? ` (see ToR p.${extract.deliverables[0].page})` : ''}; the technical approach is detailed in the separate technical proposal.
76
+ `;
77
+
78
+ const outDir = args['out-dir'] || 'out';
79
+ fs.mkdirSync(path.resolve(outDir), { recursive: true });
80
+ writeOut(path.join(outDir, 'financial-proposal.md'), md);
81
+
82
+ if (args.xlsx) {
83
+ const mod = await loadDep('exceljs');
84
+ const ExcelJS = mod.default ?? mod;
85
+ const wb = new ExcelJS.Workbook();
86
+ const ws = wb.addWorksheet('Financial Proposal');
87
+ ws.getColumn(1).width = 46; ws.getColumn(2).width = 14; ws.getColumn(3).width = 16; ws.getColumn(4).width = 18;
88
+ const rows = [
89
+ ['Financial Proposal', null, null, null],
90
+ [`Assignment: ${title}`, null, null, null],
91
+ [`Reference: ${refNo}`, null, null, null],
92
+ [`Submitted by: ${consultant}`, null, null, null],
93
+ [`Date: ${context.date || new Date().toISOString().slice(0, 10)} Currency: ${currency} Validity: ${validity}`, null, null, null],
94
+ [null, null, null, null],
95
+ ['Item', 'Qty', `Unit rate (${currency})`, `Amount (${currency})`],
96
+ ['Professional fees', effort ?? '[FILL: person-days]', rate, effort !== null ? { formula: 'B8*C8' } : '[FILL]'],
97
+ ['Reimbursable expenses (itemised attachment)', null, null, reimbursables !== null ? (Number.isFinite(Number(reimbursables)) ? Number(reimbursables) : reimbursables) : '[FILL: itemize, or state none]'],
98
+ ['TOTAL (all-inclusive)', null, null, { formula: 'SUM(D8:D9)' }],
99
+ [null, null, null, null],
100
+ [`Payment schedule: ${payment}`, null, null, null],
101
+ ['The daily rate is all-inclusive: professional fees, overheads and insurance.', null, null, null],
102
+ [`Taxes: ${context.taxes || '[FILL: tax treatment]'}`, null, null, null],
103
+ ];
104
+ rows.forEach((r) => ws.addRow(r));
105
+ ws.getCell('A1').font = { bold: true, size: 13 };
106
+ ['A7', 'B7', 'C7', 'D7'].forEach((a) => (ws.getCell(a).font = { bold: true }));
107
+ ['A10', 'D10'].forEach((a) => (ws.getCell(a).font = { bold: true }));
108
+ ['C8', 'D8', 'D9', 'D10'].forEach((a) => (ws.getCell(a).numFormat = '#,##0.00'));
109
+ await wb.xlsx.writeFile(path.join(outDir, 'financial-proposal.xlsx'));
110
+ process.stderr.write(`written: ${path.join(outDir, 'financial-proposal.xlsx')} (formulas visible: D8=B8*C8, D10=SUM)\n`);
111
+ }
112
+
113
+ const fills = [...md.matchAll(/\[FILL/g)].length;
114
+ process.stderr.write(`financial-proposal: ${outDir}/financial-proposal.md${args.xlsx ? ' + .xlsx' : ''}, ${fills} [FILL] slot(s) to resolve\n`);