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,348 @@
1
+ #!/usr/bin/env node
2
+ // bid-pack: the one-shot orchestrator. "ToR + CV in -> every mechanical step
3
+ // runs; every human decision lands in ONE consolidated questionnaire; every
4
+ // document gets verified and packaged."
5
+ //
6
+ // start --tor <file> [--cv <file>] [--dir bid/] extract + screen + cv-gap
7
+ // + cv-tailor build + profile import -> questions.json/questions.md
8
+ // apply --answers answers.json [--dir bid/] store answers, run pricing,
9
+ // record evidence + fill answers, regenerate the open-question list
10
+ // pack [--dir bid/] [--fallback-pdf] [--no-zip] verify + audit + zip (-> package.mjs)
11
+ //
12
+ // GATES PRESERVED: pricing never runs without user numbers; GAP terms never
13
+ // claimed; audit gates the final bytes. The orchestrator only automates plumbing.
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import crypto from 'node:crypto';
18
+ import { spawnSync } from 'node:child_process';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { fail, parseArgs, readText, writeOut, helpText, mdTable } from './lib.mjs';
21
+ import { loadProfile } from './profile.mjs';
22
+
23
+ const HELP = helpText('bid-pack', [
24
+ 'start --tor <file.(pdf|docx|txt)> [--cv <file>] [--dir bid/] [--profile] [--no-cv-tailor]',
25
+ 'apply --answers answers.json [--dir bid/]',
26
+ 'pack [--dir bid/] [--fallback-pdf] [--no-zip]',
27
+ '',
28
+ 'start runs every mechanical step (pdf-extract -> extract -> cv-gap -> cv-tailor)',
29
+ 'and consolidates ALL open questions into out/questions.md. Answer them in one go,',
30
+ 'have your assistant write answers.json, then apply. Repeat apply until 0 open.',
31
+ 'pack verifies the final documents (re-extract + re-audit) and builds the zip.',
32
+ ]);
33
+
34
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
35
+ const args = parseArgs(process.argv.slice(2));
36
+ const cmd = args._[0];
37
+ if (args.__help || !['start', 'apply', 'pack'].includes(cmd)) {
38
+ process.stdout.write(HELP);
39
+ process.exit(args.__help ? 0 : 1);
40
+ }
41
+ const dir = path.resolve(String(args.dir || 'bid'));
42
+ const outDir = path.join(dir, 'out');
43
+ fs.mkdirSync(outDir, { recursive: true });
44
+
45
+ function run(script, argv, { fatal = false, label } = {}) {
46
+ const r = spawnSync(process.execPath, [path.join(HERE, script), ...argv], { encoding: 'utf8' });
47
+ if (r.status !== 0) {
48
+ const msg = `${label || script} failed (exit ${r.status}):\n${(r.stderr || r.stdout || '').trim().split('\n').slice(0, 6).join('\n')}`;
49
+ if (fatal) fail(msg, r.status === 2 ? 2 : 1);
50
+ process.stderr.write(`warning: ${msg}\n`);
51
+ return null;
52
+ }
53
+ return r;
54
+ }
55
+ const inDir = (name) => [path.join(dir, name), path.join(outDir, name)].find((p) => fs.existsSync(p)) || null;
56
+
57
+ // ---------------- question engine ----------------
58
+ function buildQuestions() {
59
+ const extractPath = inDir('tor-extract.json');
60
+ const extract = extractPath ? JSON.parse(fs.readFileSync(extractPath, 'utf8')) : null;
61
+ const profile = loadProfile();
62
+ const hasPricing = !!inDir('pricing.json');
63
+ const items = [];
64
+ const add = (o) => items.push({ answer: null, answerSource: null, ...o });
65
+
66
+ if (!hasPricing && extract) {
67
+ const d = profile?.rates?.defaults || {};
68
+ if (d.base !== null && d.base !== undefined) {
69
+ add({
70
+ id: 'Q-PRICING-CONFIRM', area: 'pricing', type: 'confirm',
71
+ question: `Confirm your saved pricing for this bid: base ${d.base}/${d.basis || 'day'} ${d.currency || 'USD'}, loading ${d.loading ?? 0}, contingency ${d.contingency ?? 0}. Answer yes, or give new numbers.`,
72
+ why: 'Pricing runs only on numbers you confirm (HONESTY rule 4).',
73
+ });
74
+ } else {
75
+ add({ id: 'Q-PRICING-BASE', area: 'pricing', type: 'number', question: 'Your base rate for this bid (per day)?', why: 'From your floor calculation only — the tool never suggests a rate.', unit: (profile?.rates?.defaults?.currency) || 'USD' });
76
+ add({ id: 'Q-PRICING-LOADING', area: 'pricing', type: 'number', question: 'Cost loading (overheads on top of your net)?', why: 'e.g. 0.25 for 25%. From your own cost structure.', default: profile?.rates?.floor?.costLoading ?? 0 });
77
+ add({ id: 'Q-PRICING-CONTINGENCY', area: 'pricing', type: 'number', question: 'Risk contingency?', why: 'e.g. 0.10 for 10%. 0 if you carry the risk yourself.', default: profile?.rates?.defaults?.contingency ?? 0 });
78
+ }
79
+ if (!extract.effortPersonDays) add({ id: 'Q-EFFORT-DAYS', area: 'pricing', type: 'number', question: 'Person-days you will quote?', why: 'The ToR states no effort estimate; the financial proposal needs a quantity.' });
80
+ }
81
+ if (extract) {
82
+ const ctx = (() => { try { return JSON.parse(fs.readFileSync(path.join(outDir, 'bid-context.json'), 'utf8')); } catch { return {}; } })();
83
+ if (!ctx.availabilityDate) {
84
+ add({
85
+ id: 'Q-AVAILABILITY', area: 'availability', type: 'date',
86
+ question: `Your availability date?${extract.startDate ? ` (ToR expected start: ${extract.startDate.value}${extract.startDate.page ? `, p.${extract.startDate.page}` : ''})` : ''}`,
87
+ why: 'Declared in the cover letter; must be honest against your calendar.',
88
+ });
89
+ }
90
+ if (!extract.validity && !ctx.validity) add({ id: 'Q-VALIDITY', area: 'compliance', type: 'string', question: 'The ToR states no proposal validity period — what will you declare?', why: 'Cover letters must state validity; e.g. "90 days from submission".' });
91
+ if (extract.evaluation?.weightsSumCheck && !String(extract.evaluation.weightsSumCheck).startsWith('ok') && extract.evaluation.weightsSumCheck !== 'not-stated') {
92
+ add({ id: 'Q-CLAR-WEIGHTS', area: 'client-clarification', type: 'client', question: `Evaluation weights do not sum (${extract.evaluation.weightsSumCheck}). Send a clarification to the client quoting the grid (see assets/templates.md).`, why: 'A typo in the grid is their problem — but surface it before you price against it.' });
93
+ }
94
+ if (!extract.deadline) add({ id: 'Q-CLAR-DEADLINE', area: 'client-clarification', type: 'client', question: 'No submission deadline parsed from the ToR — confirm the deadline and channel manually.', why: 'Never bid without a verified deadline.' });
95
+ }
96
+
97
+ // [FILL] scan across drafts (stable ids: file + excerpt hash -> disappears when fixed)
98
+ let fillN = 0;
99
+ for (const f of ['cover-letter.md', 'cv-tailored.md', 'technical-proposal.md', 'financial-proposal.md']) {
100
+ const p = inDir(f);
101
+ if (!p) continue;
102
+ const text = fs.readFileSync(p, 'utf8');
103
+ for (const m of [...text.matchAll(/\[FILL([^\]]*)\]/g)]) {
104
+ fillN++;
105
+ const excerpt = (text.slice(Math.max(0, m.index - 50), m.index + m[0].length + 30).replace(/\s+/g, ' ').trim());
106
+ const detail = m[1].toLowerCase();
107
+ // known generated slots get deterministic ids so apply can resolve them itself
108
+ const known = f === 'financial-proposal.md' && /\bconsultant/.test(detail) ? 'CONSULTANT'
109
+ : f === 'financial-proposal.md' && /reimbursab|itemize/.test(detail) ? 'REIMBURSABLES'
110
+ : f === 'financial-proposal.md' && /tax/.test(detail) ? 'TAXES'
111
+ : null;
112
+ const id = known ? `Q-FILL-${known}` : 'Q-FILL-' + crypto.createHash('md5').update(f + '|' + excerpt).digest('hex').slice(0, 8);
113
+ add({ id, area: 'draft-fill', type: 'string', question: `${f}: a placeholder needs a real fact — “…${excerpt}…”. What is the fact?`, why: 'No invented facts. If you cannot supply it, it stays [FILL] and the audit blocks submission.' });
114
+ }
115
+ }
116
+ // cv-tailor unanchored bullets -> evidence questions
117
+ const tailored = inDir('cv-tailored.md');
118
+ if (tailored && inDir('cv-gap-report.md') || tailored && (args.cv || inDir('cv.txt'))) {
119
+ const masterPath = typeof args.cv === 'string' && fs.existsSync(args.cv) ? args.cv : inDir('cv.txt');
120
+ if (masterPath && fs.existsSync(masterPath) && /\.(txt|md)$/.test(masterPath)) {
121
+ const r = run('cv-tailor.mjs', ['lint', '--cv', tailored, '--master', masterPath, '--gap', inDir('cv-gap-report.md') || '', '--evidence', inDir('cv-evidence.json') || '', '--report', path.join(outDir, 'cv-tailor-lint.md')], { label: 'cv-tailor lint' });
122
+ if (r === null) {
123
+ const lint = fs.existsSync(path.join(outDir, 'cv-tailor-lint.md')) ? fs.readFileSync(path.join(outDir, 'cv-tailor-lint.md'), 'utf8') : '';
124
+ let evN = 0;
125
+ for (const m of [...lint.matchAll(/- unanchored bullet[^\n]*?"([^"]{10,120})/g)]) {
126
+ evN++;
127
+ add({ id: `Q-CVE-${evN}`, area: 'cv-evidence', type: 'string', question: `Your tailored CV has a line the master CV does not support: "${m[1]}…". Give the exact, true wording (it will be stored as user-input evidence) — or say DELETE to remove the line.`, why: 'Every CV line must trace to your master CV or to a fact you state here.' });
128
+ }
129
+ }
130
+ }
131
+ }
132
+ return { generatedAt: new Date().toISOString(), dir: dir, openCount: items.length, items };
133
+ }
134
+
135
+ function writeQuestions(q) {
136
+ fs.writeFileSync(path.join(outDir, 'questions.json'), JSON.stringify(q, null, 2));
137
+ const yours = q.items.filter((i) => i.type !== 'client');
138
+ const clients = q.items.filter((i) => i.type === 'client');
139
+ let n = 0;
140
+ const intro = `Reply with the numbered answers; your assistant writes them to answers.json and runs:\n\n tor-to-proposal bid-pack apply --answers answers.json --dir ${dir}\n`;
141
+ const md =
142
+ `# Bid questions — answer these in one go\n\n` +
143
+ (yours.length ? intro + '\n' + mdTable(['#', 'Question', 'Why it is asked'], yours.map((i) => [++n, `**${i.id}** — ${i.question}${i.default !== undefined && i.default !== null ? ` (default: ${i.default})` : ''}`, i.why || ''])) + '\n' : 'None — everything mechanical is resolved.\n') +
144
+ (clients.length ? `\n## To send to the CLIENT (clarifications, before the cutoff)\n${clients.map((i) => `- ${i.question}`).join('\n')}\n` : '');
145
+ writeOut(path.join(outDir, 'questions.md'), md);
146
+ }
147
+
148
+ // ---------------- context ----------------
149
+ function writeContext() {
150
+ const extractPath = inDir('tor-extract.json');
151
+ const extract = extractPath ? JSON.parse(fs.readFileSync(extractPath, 'utf8')) : null;
152
+ const profile = loadProfile();
153
+ let context = {};
154
+ try { context = JSON.parse(fs.readFileSync(path.join(outDir, 'bid-context.json'), 'utf8')); } catch { /* fresh */ }
155
+ if (profile?.identity?.name) {
156
+ context.identity = profile.identity;
157
+ context.consultantLine = `${profile.identity.name}${profile.identity.credentials ? `, ${profile.identity.credentials}` : ''}`;
158
+ }
159
+ if (extract) {
160
+ context.refNo = extract.referenceNumber?.value || context.refNo || null;
161
+ context.title = extract.title?.value || context.title || null;
162
+ context.deadline = extract.deadline?.value || context.deadline || null;
163
+ context.currency = context.currency || profile?.rates?.defaults?.currency || 'USD';
164
+ }
165
+ context.updatedAt = new Date().toISOString();
166
+ fs.writeFileSync(path.join(outDir, 'bid-context.json'), JSON.stringify(context, null, 2));
167
+ return context;
168
+ }
169
+
170
+ function readState() {
171
+ try { return JSON.parse(fs.readFileSync(path.join(outDir, 'bid-state.json'), 'utf8')); } catch { return { steps: {}, createdAt: new Date().toISOString() }; }
172
+ }
173
+ function writeState(state) {
174
+ state.updatedAt = new Date().toISOString();
175
+ fs.writeFileSync(path.join(outDir, 'bid-state.json'), JSON.stringify(state, null, 2));
176
+ }
177
+
178
+ // ---------------- start ----------------
179
+ function start() {
180
+ if (!args.tor) { process.stdout.write(HELP); process.exit(1); }
181
+ const state = readState();
182
+ state.steps = state.steps || {};
183
+ const torAbs = path.resolve(String(args.tor));
184
+ if (!fs.existsSync(torAbs)) fail(`--tor not found: ${torAbs}`, 2);
185
+
186
+ // 0. ToR -> page-tagged text
187
+ run('pdf-extract.mjs', [torAbs, '--out', path.join(outDir, 'tor.txt'), '--signals', path.join(outDir, 'tor-signals.json')], { fatal: true, label: 'pdf-extract (ToR)' });
188
+ state.steps.torExtract = { status: 'done' };
189
+
190
+ // 1. structure
191
+ run('extract.mjs', ['--tor', path.join(outDir, 'tor.txt'), '--out-dir', outDir], { fatal: true, label: 'extract' });
192
+ state.steps.structured = { status: 'done' };
193
+
194
+ // 3. cv gap + tailor (non-fatal: bid can proceed without CV)
195
+ if (args.cv) {
196
+ const cvAbs = path.resolve(String(args.cv));
197
+ if (!fs.existsSync(cvAbs)) fail(`--cv not found: ${cvAbs}`, 2);
198
+ let cvTxt = cvAbs;
199
+ if (!/\.(txt|md)$/.test(cvAbs)) {
200
+ run('pdf-extract.mjs', [cvAbs, '--out', path.join(outDir, 'cv.txt')], { fatal: true, label: 'pdf-extract (CV)' });
201
+ cvTxt = path.join(outDir, 'cv.txt');
202
+ }
203
+ run('cv-gap.mjs', ['--tor', path.join(outDir, 'tor.txt'), '--cv', cvTxt, '--out', path.join(outDir, 'cv-gap-report.md')], { label: 'cv-gap' });
204
+ state.steps.cvGap = { status: 'done' };
205
+ if (!inDir('cv-tailored.md') && !args['no-cv-tailor']) {
206
+ const profFlag = (args.profile === undefined || args.profile === true) && fs.existsSync(path.join(os.homedir(), '.tor-to-proposal', 'profile.json')) ? ['--profile', path.join(os.homedir(), '.tor-to-proposal', 'profile.json')] : [];
207
+ run('cv-tailor.mjs', ['build', '--cv', cvTxt, '--extract', path.join(outDir, 'tor-extract.json'), ...profFlag, '--gap', path.join(outDir, 'cv-gap-report.md'), '--out', path.join(outDir, 'cv-tailored.md'), '--report', path.join(outDir, 'cv-tailor-report.md')], { label: 'cv-tailor build' });
208
+ }
209
+ state.steps.cvTailored = { status: inDir('cv-tailored.md') ? 'done' : 'skipped' };
210
+ }
211
+
212
+ // 2. context + questions
213
+ const context = writeContext();
214
+ const q = buildQuestions();
215
+ writeQuestions(q);
216
+ state.steps.questions = { status: 'done', open: q.openCount };
217
+ writeState(state);
218
+
219
+ // report
220
+ const extract = JSON.parse(fs.readFileSync(path.join(outDir, 'tor-extract.json'), 'utf8'));
221
+ const v = (x) => (x && x.value ? `${x.value}${x.page ? ` (p.${x.page})` : ''}` : '[FILL]');
222
+ process.stdout.write(
223
+ `# Bid pack started — ${v(extract.referenceNumber)}\n\n` +
224
+ `Title: ${v(extract.title)}\nClient: ${v(extract.client)}\nDeadline: ${v(extract.deadline)}\nEffort: ${v(extract.effortPersonDays)} person-days\n` +
225
+ `Evaluation: ${extract.evaluation.criteria.length} criteria, ${extract.evaluation.weightsSumCheck}\n\n` +
226
+ `## Artifacts written to ${dir}/out/\n` +
227
+ `- bid-screen.md — go/no-go decision (the USER decides; read it together)\n` +
228
+ `- compliance-matrix.md, tor-extract.json\n` +
229
+ (args.cv ? `- cv-gap-report.md, cv-tailored.md (+ report)\n` : '') +
230
+ `- questions.json / questions.md — **${q.items.length} open question(s)**\n\n` +
231
+ `## Next\n1. Review bid-screen.md with the user. No-go -> stop here.\n` +
232
+ `2. Put every open question to the user IN ONE MESSAGE (questions.md has them).\n` +
233
+ `3. Write answers.json {"Q-...": value, ...} -> bid-pack apply --answers answers.json --dir ${dir}\n` +
234
+ `4. Draft cover letter + technical response; lint/simulate until PASS (assets/templates.md).\n` +
235
+ `5. financial-proposal.mjs renders once pricing.json exists.\n` +
236
+ `6. bid-pack pack --dir ${dir} [--fallback-pdf] -> verified, zipped submission pack.\n`
237
+ );
238
+ }
239
+
240
+ // ---------------- apply ----------------
241
+ function apply() {
242
+ if (!args.answers) { process.stdout.write(HELP); process.exit(1); }
243
+ const answersPath = path.resolve(String(args.answers));
244
+ const answers = JSON.parse(readText(answersPath, '--answers'));
245
+ const qPath = inDir('questions.json');
246
+ if (!qPath) fail(`no questions.json in ${dir}/out — run: bid-pack start --tor <file> --dir ${dir}`, 2);
247
+ const q = JSON.parse(fs.readFileSync(qPath, 'utf8'));
248
+ const byId = new Map(q.items.map((i) => [i.id, i]));
249
+ const profile = loadProfile();
250
+ const context = writeContext();
251
+
252
+ const pricing = { base: null, loading: null, contingency: null, currency: null, effort: null };
253
+ const evidencePath = path.join(outDir, 'cv-evidence.json');
254
+ const evidence = fs.existsSync(evidencePath) ? JSON.parse(fs.readFileSync(evidencePath, 'utf8')) : [];
255
+ const fillPath = path.join(outDir, 'fill-answers.json');
256
+ const fills = fs.existsSync(fillPath) ? JSON.parse(fs.readFileSync(fillPath, 'utf8')) : [];
257
+ const clarifications = [];
258
+
259
+ let applied = 0;
260
+ for (const [id, rawValue] of Object.entries(answers)) {
261
+ const item = byId.get(id);
262
+ const value = typeof rawValue === 'string' ? rawValue.trim() : rawValue;
263
+ if (item) { item.answer = value; item.answerSource = path.basename(answersPath); applied++; }
264
+ if (id === 'Q-PRICING-CONFIRM') {
265
+ if (String(value).toLowerCase() === 'yes' && profile?.rates?.defaults?.base != null) {
266
+ const d = profile.rates.defaults;
267
+ pricing.base = d.base; pricing.loading = d.loading ?? 0; pricing.contingency = d.contingency ?? 0; pricing.currency = d.currency || 'USD';
268
+ } else if (String(value).toLowerCase() !== 'yes') {
269
+ // new numbers given inline: "base 420 loading 0.25 contingency 0.1"
270
+ for (const [k, v] of String(value).matchAll(/(base|loading|contingency|currency)\s+([\w.]+)/gi)) pricing[k] = v;
271
+ }
272
+ }
273
+ if (id === 'Q-PRICING-BASE') pricing.base = value;
274
+ if (id === 'Q-PRICING-LOADING') pricing.loading = value;
275
+ if (id === 'Q-PRICING-CONTINGENCY') pricing.contingency = value;
276
+ if (id === 'Q-PRICING-CURRENCY') pricing.currency = value;
277
+ if (id === 'Q-EFFORT-DAYS') pricing.effort = value;
278
+ if (id === 'Q-AVAILABILITY') context.availabilityDate = value;
279
+ if (id === 'Q-VALIDITY') context.validity = value;
280
+ if (id.startsWith('Q-CVE-')) {
281
+ const text = String(value);
282
+ if (/^delete$/i.test(text)) evidence.push({ id, text: null, deleted: true, source: 'user-input' });
283
+ else evidence.push({ id, text, source: 'user-input' });
284
+ }
285
+ if (id === 'Q-FILL-CONSULTANT') context.consultantLine = String(value);
286
+ if (id === 'Q-FILL-REIMBURSABLES') context.reimbursables = String(value);
287
+ if (id === 'Q-FILL-TAXES') context.taxes = String(value);
288
+ if (id.startsWith('Q-FILL-')) fills.push({ id, answer: value, source: 'user-input' });
289
+ if (id.startsWith('Q-CLAR-')) clarifications.push({ id, question: item?.question || id, note: value });
290
+ }
291
+ // persist context BEFORE side effects: child tools read bid-context.json from disk
292
+ fs.writeFileSync(path.join(outDir, 'bid-context.json'), JSON.stringify({ ...context, updatedAt: new Date().toISOString() }, null, 2));
293
+
294
+ // side effect: pricing (only with user-supplied numbers)
295
+ let pricingRan = false;
296
+ if (pricing.base !== null && pricing.base !== undefined) {
297
+ const profBasis = profile?.rates?.defaults?.basis || 'day';
298
+ const argv = ['--base', String(pricing.base), '--basis', profBasis, '--currency', String(pricing.currency || 'USD'), '--out-dir', outDir];
299
+ if (pricing.loading !== null && pricing.loading !== undefined) argv.push('--loading', String(pricing.loading));
300
+ if (pricing.contingency !== null && pricing.contingency !== undefined) argv.push('--contingency', String(pricing.contingency));
301
+ const extractPath = inDir('tor-extract.json');
302
+ const effort = pricing.effort ?? (extractPath && JSON.parse(fs.readFileSync(extractPath, 'utf8')).effortPersonDays?.value);
303
+ if (effort !== null && effort !== undefined) argv.push('--effort', String(effort));
304
+ const r = run('pricing-model.mjs', argv, { label: 'pricing-model' });
305
+ pricingRan = r !== null;
306
+ process.stderr.write(r === null ? 'pricing NOT written — check the values; the pricing question stays open.\n' : `pricing written from user-confirmed numbers -> ${outDir}/pricing.json\n`);
307
+ }
308
+
309
+ // side effect: (re)render the financial proposal when its inputs are ready
310
+ if (inDir('pricing.json') && inDir('tor-extract.json') && (pricingRan || context.reimbursables || context.taxes || context.consultantLine)) {
311
+ const fin = run('financial-proposal.mjs', ['--pricing', path.join(outDir, 'pricing.json'), '--extract', path.join(outDir, 'tor-extract.json'), '--context', path.join(outDir, 'bid-context.json'), '--out-dir', outDir, '--xlsx'], { label: 'financial-proposal' });
312
+ if (fin !== null) process.stderr.write('financial proposal rendered (md + xlsx)\n');
313
+ }
314
+
315
+ fs.writeFileSync(evidencePath, JSON.stringify(evidence, null, 2));
316
+ fs.writeFileSync(fillPath, JSON.stringify(fills, null, 2));
317
+ if (clarifications.length) {
318
+ writeOut(path.join(outDir, 'clarifications.md'), `# Clarifications to send to the client\n\nSend BEFORE the clarification cutoff (bid-screen.md). Quote the ToR line each question refers to (assets/templates.md has the format).\n\n${clarifications.map((c, i) => `Q${i + 1}. ${c.question}${c.note ? `\nUser note: ${c.note}` : ''}`).join('\n\n')}\n`);
319
+ }
320
+
321
+ // regenerate questions
322
+ const fresh = buildQuestions();
323
+ fresh.items = fresh.items.filter((i) => i.answer === null);
324
+ fresh.openCount = fresh.items.length;
325
+ writeQuestions(fresh);
326
+ const state = readState();
327
+ state.steps.questions = { status: 'done', open: fresh.openCount };
328
+ state.steps.pricing = { status: inDir('pricing.json') ? 'done' : 'awaiting-user-numbers' };
329
+ writeState(state);
330
+
331
+ process.stdout.write(
332
+ `# Apply: ${applied} answer(s) recorded, ${fresh.openCount} still open\n\n` +
333
+ (fresh.openCount ? mdTable(['#', 'Still open'], fresh.items.map((i, ix) => [ix + 1, `**${i.id}** — ${i.question}`])) + '\n' : 'All questions resolved. Draft/generate, then: bid-pack pack --dir ' + dir + '\n') +
334
+ (fills.length ? `\nFill answers recorded in out/fill-answers.json — apply them to the drafts, re-run the lints, and the questions disappear once the [FILL]s are gone.\n` : '') +
335
+ (evidence.length ? `\nCV evidence entries: ${evidence.length} (out/cv-evidence.json) — cv-tailor lint accepts these as user-input anchors.\n` : '')
336
+ );
337
+ }
338
+
339
+ // ---------------- pack ----------------
340
+ function pack() {
341
+ const passThrough = ['--dir', dir, ...(args['fallback-pdf'] ? ['--fallback-pdf'] : []), ...(args['no-zip'] ? ['--no-zip'] : []), ...(args.profile ? ['--profile', String(args.profile)] : [])];
342
+ const r = spawnSync(process.execPath, [path.join(HERE, 'package.mjs'), ...passThrough], { stdio: 'inherit' });
343
+ process.exit(r.status ?? 1);
344
+ }
345
+
346
+ if (cmd === 'start') start();
347
+ else if (cmd === 'apply') apply();
348
+ else pack();
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ // cover-letter: LINTER, not a generator. The agent drafts the letter (assets/templates.md);
3
+ // this tool enforces the honesty rules mechanically. Exit 1 = fix before use.
4
+ import { fail, parseArgs, readText, writeOut, helpText } from './lib.mjs';
5
+
6
+ const HELP = helpText('cover-letter (lint)', [
7
+ 'Usage: node bin/cover-letter.mjs lint --draft letter.md --cv cv.txt [--max-words 550]',
8
+ '',
9
+ 'Checks: superlatives, page-tagged claims, [FILL] leftovers, sign-off/CV name match,',
10
+ 'unsupported-skill mentions, length. Output: lint report; exit 1 on any failure.',
11
+ ]);
12
+
13
+ const SUPERLATIVES = ['leading', 'world-class', 'world leading', 'renowned', 'premier', 'cutting-edge', 'state-of-the-art', 'best-in-class', 'unparalleled', 'top-tier', 'preeminent', 'unrivaled', 'foremost', 'exceptional expertise', 'vast experience'];
14
+
15
+ const args = parseArgs(process.argv.slice(2));
16
+ const cmd = args._[0] ?? 'lint';
17
+ if (args.__help || cmd !== 'lint' || !args.draft || !args.cv) {
18
+ process.stdout.write(HELP);
19
+ process.exit(args.__help ? 0 : 1);
20
+ }
21
+ const draft = readText(args.draft, '--draft');
22
+ const cv = readText(args.cv, '--cv');
23
+ const lines = draft.split('\n');
24
+ const failures = [];
25
+ const warnings = [];
26
+
27
+ SUPERLATIVES.forEach((s) => {
28
+ const re = new RegExp(`\\b${s.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')}\\b`, 'i');
29
+ lines.forEach((l, i) => { if (re.test(l)) failures.push(`L${i + 1}: superlative "${s}" — cut it; show evidence instead (rule: no superlatives)`); });
30
+ });
31
+
32
+ const hasPageTags = /\[\[PAGE \d+\]\]|\bp\.\s?\d+\b/i.test(draft);
33
+ if (!hasPageTags) failures.push('no page-tagged references at all — every ToR claim in the letter needs (p.X)');
34
+
35
+ const fills = [...draft.matchAll(/\[FILL[^\]]*\]/g)];
36
+ if (fills.length) warnings.push(`${fills.length} [FILL] placeholder(s) remain — fill with real evidence before submission`);
37
+
38
+ const words = draft.trim().split(/\s+/).length;
39
+ const maxWords = Number(args['max-words'] || 550);
40
+ if (words > maxWords) failures.push(`${words} words > ${maxWords} (≈1 page) — trim`);
41
+
42
+ // Sign-off must match the CV's name (CVs conventionally open with the name).
43
+ const cvName = (cv.match(/^\s*(?:name\s*[:\-]\s*)?([A-Z][\w.'-]+(?:[ \t]+[A-Z][\w.'-]+){1,3})/) || [])[1];
44
+ if (cvName) {
45
+ const tail = lines.slice(-6).join(' ');
46
+ const allParts = cvName.split(/\s+/).every((p) => new RegExp(`\\b${p.replace(/[.\-]/g, '\\$&')}\\b`, 'i').test(tail));
47
+ if (!allParts) failures.push(`sign-off does not match CV name "${cvName}" — exact name + credentials only (no-invention rule)`);
48
+ } else {
49
+ warnings.push('could not detect a name in the CV header — verify the sign-off manually');
50
+ }
51
+
52
+ // Skills the ToR demands but the CV lacks must not appear in the letter.
53
+ // The cv-gap report feeds this: any "GAP — do not claim" term found in the draft is a failure.
54
+ if (args.gap && typeof args.gap === 'string') {
55
+ const gap = readText(args.gap, '--gap');
56
+ [...gap.matchAll(/\|\s*[^|]*?:\s*([^|]+?)\s*\|\s*p\.\d+\s*\|\s*✖/g)].forEach((m) => {
57
+ const term = m[1].trim().toLowerCase();
58
+ if (term.length > 3 && draft.toLowerCase().includes(term)) {
59
+ failures.push(`letter mentions "${term}" which cv-gap marked GAP — remove it or fix the CV evidence first`);
60
+ }
61
+ });
62
+ }
63
+
64
+ const report = `# Cover letter lint\n\n${failures.length ? `## FAIL (${failures.length})\n` + failures.map((f) => '- ' + f).join('\n') : '## PASS — all checks ok'}\n${warnings.length ? `\n## Warnings\n` + warnings.map((w) => '- ' + w).join('\n') : ''}\n`;
65
+ writeOut(args.report || 'cover-letter-lint.md', report);
66
+ process.stdout.write(report);
67
+ if (failures.length) process.exit(1);
package/bin/cv-gap.mjs ADDED
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ // cv-gap: CV <-> ToR gap analysis. Pattern scan only. GAPS are flagged as
3
+ // "do not claim" — the tool never rewrites facts, never invents experience.
4
+ import { fail, parseArgs, readText, writeOut, helpText, pageOf, extractWindow, mdTable } from './lib.mjs';
5
+
6
+ const HELP = helpText('cv-gap', [
7
+ 'Usage: node bin/cv-gap.mjs --tor tor.txt --cv cv.txt [--out cv-gap-report.md]',
8
+ '',
9
+ 'Outputs a match table (requirement | p.X | in CV | action) + CV tailoring advice.',
10
+ 'MATCH = evidence pattern found in CV. GAP = not found -> never claim it.',
11
+ ]);
12
+
13
+ const args = parseArgs(process.argv.slice(2));
14
+ if (args.__help || !args.tor || !args.cv) {
15
+ process.stdout.write(HELP);
16
+ process.exit(args.__help ? 0 : 1);
17
+ }
18
+ const tor = readText(args.tor, '--tor');
19
+ const cv = readText(args.cv, '--cv').toLowerCase();
20
+ const cvPages = readText(args.cv, '--cv');
21
+
22
+ const DOMAINS = ['monitoring and evaluation', 'm&e', 'evaluation', 'gender', 'climate', 'climate change', 'public health', 'health', 'governance', 'public finance', 'finance', 'procurement', 'digital', 'digitalization', 'education', 'agriculture', 'livelihoods', 'wash', 'migration', 'statistics', 'data analysis', 'capacity development', 'human rights', 'project management'];
23
+ const CLIENTS = ['undp', 'unops', 'unicef', 'unhcr', 'iom', 'fao', 'wfp', 'who', 'unesco', 'un women', 'world bank', 'usaid', 'fcdo', 'giz', 'european union', 'ngo', 'ingo', 'government', 'ministry', 'united nations'];
24
+ const CERTS = ['pmp', 'prince2', 'cpa', 'acca', 'cima', 'cfa', 'phd'];
25
+ const SOFT = ['report writing', 'facilitation', 'training', 'stakeholder engagement', 'stakeholder', 'communication', 'workshop'];
26
+ const LANGS = ['english', 'french', 'spanish', 'arabic', 'russian', 'portuguese', 'chinese'];
27
+
28
+ function torHits(patternList, flags = 'i') {
29
+ const hits = [];
30
+ for (const p of patternList) {
31
+ const re = new RegExp(`(^|[^a-z])${p.replace(/[&]/g, '\\&').replace(/ /g, '\\s+')}([^a-z]|$)`, flags);
32
+ const m = tor.match(re);
33
+ if (m) hits.push({ term: p, page: pageOf(tor, m.index) });
34
+ }
35
+ return hits;
36
+ }
37
+ function inCv(term) {
38
+ const re = new RegExp(`(^|[^a-z])${term.replace(/[&]/g, '\\&').replace(/ /g, '\\s+')}([^a-z]|$)`, 'i');
39
+ return re.test(cv);
40
+ }
41
+
42
+ const qualsWindow = extractWindow(tor, /qualification|requirements|expertise|competenc|profile/i, /\n\s*(?:submission|evaluation|annex\b|payment)/i, 6000) ?? tor;
43
+
44
+ const checks = [];
45
+ const push = (group, term, page) => checks.push({ group, term, page, found: inCv(term) });
46
+
47
+ // Education
48
+ const edu = qualsWindow.match(/(master|bachelor|phd|doctorate|mba|mda)[^.\n]{0,80}/i);
49
+ if (edu) {
50
+ const level = edu[1].toLowerCase();
51
+ checks.push({ group: 'Education', term: edu[0].trim().slice(0, 80), page: pageOf(tor, tor.indexOf(edu[0])), found: new RegExp(level === 'phd' || level === 'doctorate' ? 'phd|doctorate' : level, 'i').test(cv) });
52
+ }
53
+ // Years of experience
54
+ const years = qualsWindow.match(/(\d{1,2})\s*\+?\s*years?(?:\s+of)?(?:\s+\w+){0,4}\s+experience/i);
55
+ if (years) {
56
+ const need = Number(years[1]);
57
+ const cvYears = Math.max(0, ...[...cvPages.matchAll(/(19|20)\d{2}/g)].map((m) => Number(m[0])));
58
+ const from = Math.max(0, ...[...cvPages.matchAll(/(19|20)\d{2}/g)].map((m) => Number(m[0])));
59
+ const minFrom = Math.min(9999, ...[...cvPages.matchAll(/(19|20)\d{2}/g)].map((m) => Number(m[0])));
60
+ const span = cvPages.match(/(19|20)\d{2}/g) ? new Date().getFullYear() - minFrom : 0;
61
+ checks.push({
62
+ group: 'Experience',
63
+ term: `${need}+ years of experience`,
64
+ page: pageOf(tor, tor.indexOf(years[0])),
65
+ found: span >= need,
66
+ note: span >= need ? `CV spans ~${span} years (earliest year ${minFrom}) — verify the relevant subset` : `CV earliest year ${minFrom} (~${span} yrs) vs ${need} required — GAP unless earlier roles omitted`,
67
+ });
68
+ }
69
+ for (const h of torHits(DOMAINS)) push('Domain', h.term, h.page);
70
+ for (const h of torHits(CLIENTS)) push('Client type', h.term, h.page);
71
+ for (const h of torHits(CERTS)) push('Certification', h.term.toUpperCase(), h.page);
72
+ const langWindow = extractWindow(tor, /language/i, /\n\s*\n/, 1200);
73
+ if (langWindow) for (const h of torHits(LANGS)) push('Language', h.term[0].toUpperCase() + h.term.slice(1), h.page);
74
+ for (const h of torHits(SOFT)) push('Soft skill', h.term, h.page);
75
+
76
+ const rows = checks.map((c) => [
77
+ `${c.group}: ${c.term}`,
78
+ c.page ? `p.${c.page}` : '[FILL: page]',
79
+ c.found ? '✔ MATCH' : '✖ **GAP — do not claim**',
80
+ c.found
81
+ ? c.note || 'Reorder/quantify this bullet high in the CV using the ToR’s own wording'
82
+ : 'Flag as risk in go/no-go, or reword around true adjacent experience — never invent',
83
+ ]);
84
+
85
+ const nMatch = checks.filter((c) => c.found).length;
86
+ const report = `# CV gap report\n\n${checks.length} requirements detected; ${nMatch} matched, ${checks.length - nMatch} gaps.\n\n` +
87
+ mdTable(['Requirement', 'p.X', 'In CV', 'Action'], rows) +
88
+ `\n\n## CV tailoring rules (mechanical, honest)\n1. Re-order bullets so the top-weighted evaluation criteria (see tor-extract.json) see their evidence first, using the ToR's verbatim terminology.\n2. Quantify every retained bullet (years, budgets, team sizes, countries). No number available -> mark [FILL] and get the number.\n3. GAPS stay gaps. Reword around true adjacent experience only. Fabrication = disqualification + vendor blacklisting risk.\n4. Mirror the client's vocabulary (e.g. "results framework" not "logframe" if the ToR says results framework).\n`;
89
+ writeOut(args.out || 'cv-gap-report.md', report);
90
+ process.stderr.write(`cv-gap: ${nMatch}/${checks.length} matched\n`);