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.
- package/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +9 -0
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/SKILL.md +189 -0
- package/assets/templates.md +74 -0
- package/bin/audit.mjs +60 -0
- package/bin/bid-pack.mjs +348 -0
- package/bin/cover-letter.mjs +67 -0
- package/bin/cv-gap.mjs +90 -0
- package/bin/cv-tailor.mjs +261 -0
- package/bin/dossier.mjs +32 -0
- package/bin/extract.mjs +148 -0
- package/bin/financial-proposal.mjs +114 -0
- package/bin/lib.mjs +109 -0
- package/bin/market-rates.mjs +54 -0
- package/bin/package.mjs +234 -0
- package/bin/pdf-extract.mjs +135 -0
- package/bin/pricing-model.mjs +73 -0
- package/bin/profile.mjs +201 -0
- package/bin/render.mjs +199 -0
- package/bin/simulator.mjs +67 -0
- package/bin/template-filler.mjs +160 -0
- package/bin/tor-to-proposal.mjs +63 -0
- package/commands/tor-audit.md +10 -0
- package/commands/tor-bid.md +18 -0
- package/commands/tor-cv.md +10 -0
- package/commands/tor-pack.md +11 -0
- package/commands/tor-price.md +11 -0
- package/commands/tor-screen.md +11 -0
- package/docs/PROMPT-GUIDE.md +245 -0
- package/docs/TECHNICAL-GUIDE.md +211 -0
- package/docs/USER-GUIDE.md +228 -0
- package/library/SCHEMA.md +45 -0
- package/library/bids/.gitkeep +0 -0
- package/mcp/server.mjs +358 -0
- package/package.json +57 -0
- package/references/packs/eu-prag.md +29 -0
- package/references/packs/undp.md +30 -0
- package/references/packs/usaid.md +29 -0
- package/references/packs/worldbank.md +26 -0
- package/references/pricing-standards.md +52 -0
- package/references/render-specs.md +60 -0
- package/references/review-rules.md +38 -0
package/bin/lib.mjs
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Shared helpers for tor-to-proposal CLI tools.
|
|
2
|
+
// Deterministic only: no network, no LLM, no guessing. Same input -> same output.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
export function fail(msg, code = 1) {
|
|
7
|
+
process.stderr.write(`ERROR: ${msg}\n`);
|
|
8
|
+
process.exit(code);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function readText(file, what = 'input file') {
|
|
12
|
+
if (!file) fail(`${what} missing. Use --help for usage.`, 2);
|
|
13
|
+
try {
|
|
14
|
+
return fs.readFileSync(file, 'utf8');
|
|
15
|
+
} catch {
|
|
16
|
+
fail(`cannot read ${what}: ${file}`, 2);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function writeOut(file, content) {
|
|
21
|
+
if (file) {
|
|
22
|
+
fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
|
|
23
|
+
fs.writeFileSync(file, content);
|
|
24
|
+
process.stderr.write(`written: ${file}\n`);
|
|
25
|
+
} else {
|
|
26
|
+
process.stdout.write(content);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Parse ["--key", "value", "--flag", "positional", "--n=3"] into
|
|
31
|
+
// { _: [positional], key: "value", flag: true, n: "3" } (values stay strings).
|
|
32
|
+
export function parseArgs(argv) {
|
|
33
|
+
const out = { _: [] };
|
|
34
|
+
for (let i = 0; i < argv.length; i++) {
|
|
35
|
+
const a = argv[i];
|
|
36
|
+
if (a === '--help' || a === '-h') { out.__help = true; return out; }
|
|
37
|
+
if (a.startsWith('--')) {
|
|
38
|
+
let key = a.slice(2);
|
|
39
|
+
let val = true;
|
|
40
|
+
const eq = key.indexOf('=');
|
|
41
|
+
if (eq !== -1) { val = key.slice(eq + 1); key = key.slice(0, eq); }
|
|
42
|
+
else if (i + 1 < argv.length && !argv[i + 1].startsWith('--')) { val = argv[++i]; }
|
|
43
|
+
// repeated flags collect into arrays (e.g. audit --extras a --extras b)
|
|
44
|
+
out[key] = out[key] === undefined ? val : [].concat(out[key], val);
|
|
45
|
+
} else {
|
|
46
|
+
out._.push(a);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function num(v, name, { min = -Infinity, max = Infinity } = {}) {
|
|
53
|
+
const n = typeof v === 'string' ? Number(v.replace(/[, ]/g, '')) : Number(v);
|
|
54
|
+
if (!Number.isFinite(n)) fail(`${name} must be a number (got: ${v})`, 2);
|
|
55
|
+
if (n < min || n > max) fail(`${name} must be between ${min} and ${max} (got: ${n})`, 2);
|
|
56
|
+
return n;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function fmt(n) {
|
|
60
|
+
return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Optional-dependency loader with an actionable message (skill users may not
|
|
64
|
+
// have run npm install yet).
|
|
65
|
+
export async function loadDep(spec) {
|
|
66
|
+
try {
|
|
67
|
+
const mod = await import(spec);
|
|
68
|
+
// CJS-only packages (adm-zip, mammoth) arrive as { default: exports,
|
|
69
|
+
// 'module.exports': exports } — unwrap so callers always get the module.
|
|
70
|
+
const hasNamed = Object.keys(mod).some((k) => k !== 'default' && k !== 'module.exports');
|
|
71
|
+
return !hasNamed && mod.default !== undefined ? mod.default : mod;
|
|
72
|
+
} catch {
|
|
73
|
+
fail(
|
|
74
|
+
`missing dependency "${spec}". Run: npm install inside the tor-to-proposal skill directory (one-time, local only).`,
|
|
75
|
+
3
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Page attribution: index of a match -> nearest preceding [[PAGE n]] tag.
|
|
81
|
+
export const PAGE_TAG = /\[\[PAGE (\d+)\]\]/g;
|
|
82
|
+
|
|
83
|
+
export function pageOf(text, index) {
|
|
84
|
+
let page = null;
|
|
85
|
+
PAGE_TAG.lastIndex = 0;
|
|
86
|
+
let m;
|
|
87
|
+
while ((m = PAGE_TAG.exec(text)) && m.index < index) page = Number(m[1]);
|
|
88
|
+
return page;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function helpText(name, lines) {
|
|
92
|
+
return `tor-to-proposal :: ${name}\n\n${lines.join('\n')}\n`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function extractWindow(text, startRe, endRe, maxLen = 4000) {
|
|
96
|
+
const sm = text.match(startRe);
|
|
97
|
+
if (!sm) return null;
|
|
98
|
+
const start = sm.index;
|
|
99
|
+
const rest = text.slice(start + 1, start + maxLen);
|
|
100
|
+
const em = rest.match(endRe);
|
|
101
|
+
return text.slice(start, start + 1 + (em ? em.index + 1 : maxLen));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function mdTable(headers, rows) {
|
|
105
|
+
const head = `| ${headers.join(' | ')} |`;
|
|
106
|
+
const sep = `| ${headers.map(() => '---').join(' | ')} |`;
|
|
107
|
+
const body = rows.map((r) => `| ${r.join(' | ')} |`).join('\n');
|
|
108
|
+
return [head, sep, body].join('\n');
|
|
109
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// market-rates: benchmark RESEARCH scaffolder + rate position calculator.
|
|
3
|
+
// The scaffold never contains numbers — you research and cite every rate.
|
|
4
|
+
import { parseArgs, writeOut, helpText, num, fmt } from './lib.mjs';
|
|
5
|
+
|
|
6
|
+
const HELP = helpText('market-rates', [
|
|
7
|
+
'Commands:',
|
|
8
|
+
' scaffold [--title "..."] [--currency USD] [--out market-rates-research.md]',
|
|
9
|
+
' position --your-rate N --benchmark-low N --benchmark-high N [--currency USD]',
|
|
10
|
+
'',
|
|
11
|
+
'position: BELOW/WITHIN/ABOVE the cited band + advisory. Rejects low > high (exit 2).',
|
|
12
|
+
'Every band you use must be citable (source + date). No gut-feel numbers, ever.',
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const args = parseArgs(process.argv.slice(2));
|
|
16
|
+
const cmd = args._[0];
|
|
17
|
+
if (args.__help || !cmd) {
|
|
18
|
+
process.stdout.write(HELP);
|
|
19
|
+
process.exit(args.__help ? 0 : 1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (cmd === 'scaffold') {
|
|
23
|
+
const title = args.title || '[FILL: assignment title + ref no.]';
|
|
24
|
+
const cur = (args.currency || 'USD').toUpperCase();
|
|
25
|
+
const md = `# Market rates research — ${title}\n\n> Fill every row from a real source. No number without a source + access date.\n> Drop this file's band into: market-rates.mjs position --benchmark-low X --benchmark-high Y\n\n## Benchmark sources\n\n| # | Source | Type | Rate/band (${cur}) | Unit | Date accessed | Link |\n| --- | --- | --- | --- | --- | --- | --- |\n| 1 | UNDP/UNICEF/UNOPS consultant fee schedule or SSA guidance | fee grid | [FILL] | day | [FILL] | [FILL] |\n| 2 | UNGM contract award notices (search: client + sector + year) | awarded contract | [FILL] | day/lump | [FILL] | ungm.org\n| 3 | National procurement portal award notices | awarded contract | [FILL] | [FILL] | [FILL] | [FILL] |\n| 4 | Sector salary/rate survey (e.g. aid worker salary reports) | survey | [FILL] | month | [FILL] | [FILL] |\n| 5 | Live job postings with disclosed bands (client + sector) | posting | [FILL] | month | [FILL] | [FILL] |\n| 6 | 2–3 peer consultants (ask directly; record consent) | peer | [FILL] | day | [FILL] | n/a |\n\n## Context factors (write 1 line each — they move your band)\n- Cost-reimbursable vs lump-sum: lump-sum shifts risk to you -> floor up.\n- Payment delay reputation of this client (feeds from dossier.mjs): [FILL]\n- Currency + inflation of the contract currency: [FILL]\n- Your overhead (insurance, software, unpaid pipeline time): [FILL]%\n- Scope-creep exposure of the deliverables: [FILL]\n\n## Decision rule (printed, never skipped)\nfloor = (minimum acceptable net income for the period ÷ realistic billable days in it) + overhead.\nYour quote must trace to this floor or to a cited benchmark row above. If it traces to neither, it is a gut-feel number — not allowed.\n`;
|
|
26
|
+
writeOut(args.out || 'market-rates-research.md', md);
|
|
27
|
+
process.stderr.write('scaffold written. Research the rows, then run: position\n');
|
|
28
|
+
} else if (cmd === 'position') {
|
|
29
|
+
const need = ['your-rate', 'benchmark-low', 'benchmark-high'];
|
|
30
|
+
for (const k of need) if (args[k] === undefined || args[k] === true) {
|
|
31
|
+
process.stderr.write(`ERROR: --${k} missing\n\n${HELP}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const rate = num(args['your-rate'], '--your-rate', { min: 0 });
|
|
35
|
+
const low = num(args['benchmark-low'], '--benchmark-low', { min: 0 });
|
|
36
|
+
const high = num(args['benchmark-high'], '--benchmark-high', { min: 0 });
|
|
37
|
+
if (low >= high) {
|
|
38
|
+
process.stderr.write(`ERROR: --benchmark-low (${fmt(low)}) must be BELOW --benchmark-high (${fmt(high)}). Fix your research band.\n`);
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
41
|
+
const cur = (args.currency || 'USD').toUpperCase();
|
|
42
|
+
const posPct = ((rate - low) / (high - low)) * 100;
|
|
43
|
+
const where = rate < low ? 'BELOW band' : rate > high ? 'ABOVE band' : 'WITHIN band';
|
|
44
|
+
let advisory;
|
|
45
|
+
if (rate < low) advisory = 'Below band: verify your floor (market-rates scaffold §Decision rule). Sustainable underpricing signals low quality to evaluators and burns the pipeline. If the floor genuinely fits, quote it with confidence.';
|
|
46
|
+
else if (rate > high) advisory = 'Above band: you must justify the premium with cited evidence — scarce expertise, faster delivery, lower client risk. Otherwise reposition within the band.';
|
|
47
|
+
else advisory = `Within band at ${posPct.toFixed(0)}% of the range. If >70%, prepare a one-line premium justification anyway.`;
|
|
48
|
+
process.stdout.write(
|
|
49
|
+
`Rate position (${cur})\n Your rate: ${fmt(rate)}\n Cited band: ${fmt(low)} – ${fmt(high)}\n Position: ${where} (${posPct.toFixed(0)}% of range)\n Advisory: ${advisory}\n Source required: benchmark-low/high must each trace to a row in market-rates-research.md (source + date). [FILL if not yet cited]\n`
|
|
50
|
+
);
|
|
51
|
+
} else {
|
|
52
|
+
process.stderr.write(`unknown command: ${cmd}\n\n${HELP}`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
package/bin/package.mjs
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// package: the finisher. Builds the submittable bid pack from the working dir:
|
|
3
|
+
// 1. inventories the four documents (cover letter, CV, technical, financial)
|
|
4
|
+
// 2. renders any md-only doc via the fallback renderer (--fallback-pdf)
|
|
5
|
+
// 3. RE-EXTRACTS the text from every rendered PDF/docx/xlsx and re-runs the
|
|
6
|
+
// full audit on those bytes — the gate applies to what the client reads,
|
|
7
|
+
// not the markdown source
|
|
8
|
+
// 4. writes submission-checklist.md + deadline.ics, assembles pack/ + zip
|
|
9
|
+
// Exit 0 = pack built. 1 = verification failed (do not submit). 2 = inputs missing.
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { fail, parseArgs, readText, writeOut, helpText, mdTable, loadDep } from './lib.mjs';
|
|
15
|
+
|
|
16
|
+
const HELP = helpText('package', [
|
|
17
|
+
'Usage: node bin/package.mjs --dir <bid-dir> [--fallback-pdf] [--no-zip] [--profile profile.json]',
|
|
18
|
+
'',
|
|
19
|
+
'Expects in <bid-dir>/ or <bid-dir>/out/: tor-extract.json, compliance-matrix.md and',
|
|
20
|
+
'cover-letter / cv-tailored / technical-proposal / financial-proposal as .pdf, .docx,',
|
|
21
|
+
'.xlsx or .md. --fallback-pdf renders md-only docs with the built-in renderer.',
|
|
22
|
+
'Outputs: <dir>/pack/ + <dir>/pack-report.md + <ref>-bid-pack.zip + <dir>/out/audit-final.md.',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const DOCS = [
|
|
26
|
+
{ key: 'cover-letter', name: 'Cover-Letter', exts: ['pdf', 'docx', 'md'] },
|
|
27
|
+
{ key: 'cv-tailored', name: 'CV', exts: ['pdf', 'docx', 'md'] },
|
|
28
|
+
{ key: 'technical-proposal', name: 'Technical-Proposal', exts: ['pdf', 'docx', 'md'] },
|
|
29
|
+
{ key: 'financial-proposal', name: 'Financial-Proposal', exts: ['pdf', 'xlsx', 'md'] },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const args = parseArgs(process.argv.slice(2));
|
|
33
|
+
if (args.__help || !args.dir) {
|
|
34
|
+
process.stdout.write(HELP);
|
|
35
|
+
process.exit(args.__help ? 0 : 1);
|
|
36
|
+
}
|
|
37
|
+
const dir = path.resolve(String(args.dir));
|
|
38
|
+
const outDir = path.join(dir, 'out');
|
|
39
|
+
if (!fs.existsSync(dir)) fail(`bid dir not found: ${dir}`, 2);
|
|
40
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
41
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
|
|
43
|
+
const find = (name) => [path.join(dir, name), path.join(outDir, name)].find((p) => fs.existsSync(p)) || null;
|
|
44
|
+
const extractPath = find('tor-extract.json');
|
|
45
|
+
if (!extractPath) fail(`no tor-extract.json in ${dir} or ${dir}/out — run: tor-to-proposal start --tor <file> --dir ${dir}`, 2);
|
|
46
|
+
const extract = JSON.parse(fs.readFileSync(extractPath, 'utf8'));
|
|
47
|
+
let context = {};
|
|
48
|
+
try { context = JSON.parse(fs.readFileSync(find('bid-context.json'), 'utf8')); } catch { /* optional */ }
|
|
49
|
+
const val = (x) => (x && x.value ? x.value : '[FILL]');
|
|
50
|
+
const refNo = val(extract.referenceNumber);
|
|
51
|
+
const refSlug = (refNo.match(/[A-Za-z0-9-]+/g) || ['bid']).join('-').slice(0, 60);
|
|
52
|
+
|
|
53
|
+
// ---------- 1. inventory ----------
|
|
54
|
+
function inventory() {
|
|
55
|
+
const found = {};
|
|
56
|
+
for (const doc of DOCS) {
|
|
57
|
+
let best = null;
|
|
58
|
+
for (const ext of doc.exts) {
|
|
59
|
+
const p = find(`${doc.key}.${ext}`);
|
|
60
|
+
if (p) { best = { ext, path: p }; break; } // exts are listed best-first
|
|
61
|
+
}
|
|
62
|
+
found[doc.key] = best;
|
|
63
|
+
}
|
|
64
|
+
return found;
|
|
65
|
+
}
|
|
66
|
+
let inv = inventory();
|
|
67
|
+
const missing = DOCS.filter((d) => !inv[d.key]);
|
|
68
|
+
|
|
69
|
+
// ---------- 2. fallback render ----------
|
|
70
|
+
if (args['fallback-pdf']) {
|
|
71
|
+
for (const doc of DOCS) {
|
|
72
|
+
const a = inv[doc.key];
|
|
73
|
+
if (!a || a.ext !== 'md') continue;
|
|
74
|
+
const outPdf = path.join(outDir, `${doc.key}.pdf`);
|
|
75
|
+
const r = spawnSync(process.execPath, [path.join(HERE, 'render.mjs'), a.path, '--out', outPdf, '--footer', refNo], { encoding: 'utf8' });
|
|
76
|
+
if (r.status !== 0) fail(`fallback render failed for ${doc.key}: ${r.stderr}`, 1);
|
|
77
|
+
process.stderr.write(`fallback-rendered: ${doc.key}.pdf from markdown\n`);
|
|
78
|
+
}
|
|
79
|
+
inv = inventory();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------- 3. verify artifacts by reading them back ----------
|
|
83
|
+
const FILL_RE = /\[FILL|\{\{|\blorem ipsum\b/i;
|
|
84
|
+
const checks = [];
|
|
85
|
+
let hard = 0;
|
|
86
|
+
const verifyDir = path.join(outDir, '.pack-verify');
|
|
87
|
+
fs.rmSync(verifyDir, { recursive: true, force: true });
|
|
88
|
+
fs.mkdirSync(verifyDir, { recursive: true });
|
|
89
|
+
|
|
90
|
+
async function verifyPdf(file, key) {
|
|
91
|
+
const { extractText, getDocumentProxy } = await loadDep('unpdf');
|
|
92
|
+
const pdf = await getDocumentProxy(new Uint8Array(fs.readFileSync(file)));
|
|
93
|
+
const { totalPages, text } = await extractText(pdf, { mergePages: false });
|
|
94
|
+
const joined = Array.from(text).join('\n');
|
|
95
|
+
fs.writeFileSync(path.join(verifyDir, `${key}.txt`), joined);
|
|
96
|
+
return { kind: 'PDF', pages: totalPages, text: joined };
|
|
97
|
+
}
|
|
98
|
+
async function verifyDocx(file, key) {
|
|
99
|
+
const mammoth = await loadDep('mammoth');
|
|
100
|
+
const { value } = await mammoth.extractRawText({ path: file });
|
|
101
|
+
fs.writeFileSync(path.join(verifyDir, `${key}.txt`), value);
|
|
102
|
+
return { kind: 'DOCX', pages: null, text: value };
|
|
103
|
+
}
|
|
104
|
+
async function verifyXlsx(file, key) {
|
|
105
|
+
const mod = await loadDep('exceljs');
|
|
106
|
+
const ExcelJS = mod.default ?? mod;
|
|
107
|
+
const wb = new ExcelJS.Workbook();
|
|
108
|
+
await wb.xlsx.readFile(file);
|
|
109
|
+
const parts = [];
|
|
110
|
+
wb.eachSheet((ws) => {
|
|
111
|
+
parts.push(`[${ws.name}]`);
|
|
112
|
+
for (let r = 1; r <= Math.min(ws.rowCount || 0, 300); r++) {
|
|
113
|
+
const row = ws.getRow(r);
|
|
114
|
+
for (let c = 1; c <= Math.min(row.cellCount || 0, 30); c++) {
|
|
115
|
+
const v = row.getCell(c).value;
|
|
116
|
+
if (typeof v === 'string') parts.push(v);
|
|
117
|
+
else if (v && typeof v === 'object' && v.formula) parts.push(String(v.result ?? ''));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
const text = parts.join('\n');
|
|
122
|
+
fs.writeFileSync(path.join(verifyDir, `${key}.txt`), text);
|
|
123
|
+
return { kind: 'XLSX', pages: null, text };
|
|
124
|
+
}
|
|
125
|
+
const verifyMd = (file, key) => {
|
|
126
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
127
|
+
fs.writeFileSync(path.join(verifyDir, `${key}.txt`), text);
|
|
128
|
+
return { kind: 'MD', pages: null, text };
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
for (const doc of DOCS) {
|
|
132
|
+
const a = inv[doc.key];
|
|
133
|
+
if (!a) { checks.push([doc.name, 'MISSING', '—', `produce ${doc.key}.(pdf|docx|xlsx|md) in ${dir} or ${dir}/out`]); hard++; continue; }
|
|
134
|
+
let v;
|
|
135
|
+
try {
|
|
136
|
+
v = a.ext === 'pdf' ? await verifyPdf(a.path, doc.key) : a.ext === 'docx' ? await verifyDocx(a.path, doc.key) : a.ext === 'xlsx' ? await verifyXlsx(a.path, doc.key) : verifyMd(a.path, doc.key);
|
|
137
|
+
} catch (e) {
|
|
138
|
+
checks.push([doc.name, a.ext.toUpperCase(), '—', `unreadable: ${String(e.message || e).slice(0, 80)}`]); hard++; continue;
|
|
139
|
+
}
|
|
140
|
+
const hits = v.text.match(FILL_RE);
|
|
141
|
+
if (hits) { checks.push([doc.name, v.kind, v.pages ?? '—', `contains "${hits[0]}" — a placeholder survived rendering`]); hard++; continue; }
|
|
142
|
+
if (v.kind === 'MD') { checks.push([doc.name, 'MD', '—', 'markdown is not a submittable format — re-run with --fallback-pdf or render a real PDF/docx']); hard++; continue; }
|
|
143
|
+
if (v.text.trim().length < 60) { checks.push([doc.name, v.kind, v.pages ?? '—', 'nearly no extractable text (scanned or empty?)']); hard++; continue; }
|
|
144
|
+
checks.push([doc.name, v.kind, v.pages ?? '—', 'verified: text round-trips, no placeholders']);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// client-filled forms, if any, ride along unmodified (verified for [FILL] only)
|
|
148
|
+
const extras = ['financial-filled.xlsx', 'filled.docx', 'compliance-matrix.md'].map(find).filter(Boolean);
|
|
149
|
+
|
|
150
|
+
// ---------- 4. re-run the audit on the extracted bytes ----------
|
|
151
|
+
let auditOk = false;
|
|
152
|
+
const matrixPath = find('compliance-matrix.md');
|
|
153
|
+
const tp = fs.existsSync(path.join(verifyDir, 'technical-proposal.txt')) ? path.join(verifyDir, 'technical-proposal.txt') : null;
|
|
154
|
+
if (tp) {
|
|
155
|
+
const extrasArgs = ['cover-letter', 'cv-tailored', 'financial-proposal'].filter((k) => fs.existsSync(path.join(verifyDir, `${k}.txt`))).flatMap((k) => ['--extras', path.join(verifyDir, `${k}.txt`)]);
|
|
156
|
+
const auditArgs = ['--proposal', tp, ...(matrixPath ? ['--matrix', matrixPath] : []), ...extrasArgs, '--report', path.join(outDir, 'audit-final.md')];
|
|
157
|
+
const r = spawnSync(process.execPath, [path.join(HERE, 'audit.mjs'), ...auditArgs], { encoding: 'utf8' });
|
|
158
|
+
auditOk = r.status === 0;
|
|
159
|
+
if (!auditOk) hard++;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------- 5. deadline math ----------
|
|
163
|
+
const MONTHS = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 };
|
|
164
|
+
function parseDeadline(s) {
|
|
165
|
+
if (!s) return null;
|
|
166
|
+
const t = String(s).trim();
|
|
167
|
+
let m = t.match(/(\d{4})-(\d{2})-(\d{2})/);
|
|
168
|
+
if (m) return new Date(+m[1], +m[2] - 1, +m[3]);
|
|
169
|
+
m = t.match(/(\d{1,2})\s+([A-Za-z]{3,9}),?\s+(\d{4})/);
|
|
170
|
+
if (m) return new Date(+m[3], MONTHS[m[2].slice(0, 3).toLowerCase()] ?? 0, +m[1]);
|
|
171
|
+
m = t.match(/([A-Za-z]{3,9})\s+(\d{1,2}),?\s+(\d{4})/);
|
|
172
|
+
if (m) return new Date(+m[3], MONTHS[m[1].slice(0, 3).toLowerCase()] ?? 0, +m[2]);
|
|
173
|
+
m = t.match(/(\d{1,2})[\/-](\d{1,2})[\/-](\d{2,4})/);
|
|
174
|
+
if (m) { const y = +m[3] < 100 ? +m[3] + 2000 : +m[3]; return new Date(y, +m[2] - 1, +m[1]); } // D/M/Y assumption (non-US ToRs)
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const deadlineStr = val(extract.deadline);
|
|
178
|
+
const deadline = parseDeadline(deadlineStr);
|
|
179
|
+
const daysLeft = deadline ? Math.ceil((deadline - new Date()) / 86400000) : null;
|
|
180
|
+
|
|
181
|
+
// ---------- 6. submission checklist ----------
|
|
182
|
+
const fmtDate = (d) => (d ? `${String(d.getDate()).padStart(2, '0')} ${['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()]} ${d.getFullYear()}` : '[FILL]');
|
|
183
|
+
const deadlineLine = deadline ? `${fmtDate(deadline)} — **${daysLeft >= 0 ? `${daysLeft} day${daysLeft === 1 ? '' : 's'} remaining` : `PASSED ${-daysLeft} day(s) ago`}** (parsed from "${deadlineStr}"; confirm the ToR's time zone before relying on the hour)` : `**${deadlineStr}** — could not parse to a date; confirm manually`;
|
|
184
|
+
|
|
185
|
+
const checklistRows = checks.map((c) => [c[0], c[1], String(c[2]), c[3]]);
|
|
186
|
+
let report = `# Bid pack report — ${refNo}\n\n## Assignment\n- Title: ${val(extract.title)}\n- Reference: ${refNo}\n- Consultant: ${context.consultantLine || '[FILL]'}\n- Deadline: ${deadlineLine}\n- Submission channel: ${val(extract.submissionChannel)}${extract.submissionChannel?.page ? ` (p.${extract.submissionChannel.page})` : ''}\n\n` +
|
|
187
|
+
`## Document verification\n\n${mdTable(['Document', 'Format', 'Pages', 'Verdict'], checklistRows)}\n\n` +
|
|
188
|
+
`## Audit on rendered bytes\n\n${auditOk ? 'PASS — audit.mjs re-run on the extracted text of every document: no hard fails.' : `FAIL — audit.mjs found hard fails on the final documents. See ${path.join(outDir, 'audit-final.md')}. Do not submit.`}\n`;
|
|
189
|
+
|
|
190
|
+
let zipWritten = null;
|
|
191
|
+
if (hard === 0) {
|
|
192
|
+
const packDir = path.join(dir, 'pack');
|
|
193
|
+
fs.rmSync(packDir, { recursive: true, force: true });
|
|
194
|
+
fs.mkdirSync(packDir, { recursive: true });
|
|
195
|
+
for (const doc of DOCS) {
|
|
196
|
+
const a = inv[doc.key];
|
|
197
|
+
if (a) fs.copyFileSync(a.path, path.join(packDir, `${doc.name}.${a.ext}`));
|
|
198
|
+
}
|
|
199
|
+
for (const e of extras) fs.copyFileSync(e, path.join(packDir, path.basename(e)));
|
|
200
|
+
|
|
201
|
+
const checklist = `# Submission checklist — ${refNo}\n\n**Assignment:** ${val(extract.title)}\n**Deadline:** ${deadlineLine}\n**Channel:** ${val(extract.submissionChannel)}${extract.submissionChannel?.page ? ` (p.${extract.submissionChannel.page})` : ''}\n\n` +
|
|
202
|
+
`## Files in this pack\n\n${mdTable(['File', 'What it is'], DOCS.filter((d) => inv[d.key]).map((d) => [`${d.name}.${inv[d.key].ext}`, d.key]).concat(extras.map((e) => [path.basename(e), 'client form / compliance matrix (verified)'])))}\n\n` +
|
|
203
|
+
`## Before you hit submit\n\n1. Every document verified: no [FILL], no placeholders, audit passed on the final bytes.\n2. File names/formats: confirm the ToR's naming and format rules (some portals rename or reject files).\n3. Deadline: submit well before close — portals slow down near deadlines. Confirm the ToR's time zone.\n4. Registration: verify any portal registration the ToR requires is done **before** award stage.\n5. Keep pricing.md and pricing.json PRIVATE — they contain your floor math and are not in this pack.\n6. After submission: tell your assistant "record this bid" (win/loss library, opt-in).\n`;
|
|
204
|
+
fs.writeFileSync(path.join(packDir, 'submission-checklist.md'), checklist);
|
|
205
|
+
|
|
206
|
+
if (deadline) {
|
|
207
|
+
const y = (d) => `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
|
|
208
|
+
const dayAfter = new Date(deadline); dayAfter.setDate(dayAfter.getDate() + 1);
|
|
209
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
|
|
210
|
+
const ics = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//tor-to-proposal//bid pack//EN', 'BEGIN:VEVENT', `UID:${refSlug}-${y(deadline)}@tor-to-proposal`, `DTSTAMP:${stamp}`, `DTSTART;VALUE=DATE:${y(deadline)}`, `DTEND;VALUE=DATE:${y(dayAfter)}`, `SUMMARY:SUBMIT: ${refNo} ${val(extract.title).replace(/[,\;]/g, ' ')}`, `DESCRIPTION:Deadline per ToR ${extract.deadline?.page ? '(p.' + extract.deadline.page + ')' : ''}. Channel: ${val(extract.submissionChannel).replace(/[,\;]/g, ' ')}`, 'BEGIN:VALARM', 'TRIGGER:-P2D', 'ACTION:DISPLAY', `DESCRIPTION:Bid deadline in 2 days: ${refNo}`, 'END:VALARM', 'END:VEVENT', 'END:VCALENDAR', ''].join('\n');
|
|
211
|
+
fs.writeFileSync(path.join(packDir, 'deadline.ics'), ics);
|
|
212
|
+
}
|
|
213
|
+
report += `\n## Pack\n\n${packDir}/ — final files with canonical names.\n`;
|
|
214
|
+
if (!args['no-zip']) {
|
|
215
|
+
const zipMod = await loadDep('adm-zip');
|
|
216
|
+
const AdmZip = zipMod.default ?? zipMod;
|
|
217
|
+
const zip = new AdmZip();
|
|
218
|
+
zip.addLocalFolder(packDir, `${refSlug}-bid-pack`);
|
|
219
|
+
zipWritten = path.join(dir, `${refSlug}-bid-pack.zip`);
|
|
220
|
+
zip.writeZip(zipWritten);
|
|
221
|
+
report += `${zipWritten}\n`;
|
|
222
|
+
}
|
|
223
|
+
} else {
|
|
224
|
+
// a previous successful run may have left a pack — a stale pack that LOOKS
|
|
225
|
+
// ready to submit is worse than no pack. Remove it and any zip.
|
|
226
|
+
const stalePack = path.join(dir, 'pack');
|
|
227
|
+
if (fs.existsSync(stalePack)) fs.rmSync(stalePack, { recursive: true, force: true });
|
|
228
|
+
for (const f of fs.readdirSync(dir)) if (f.endsWith('-bid-pack.zip')) fs.rmSync(path.join(dir, f), { force: true });
|
|
229
|
+
report += `\n## Pack NOT built — ${hard} verification failure(s) above. Fix and re-run. (Any previous pack/ and zip were removed so a stale pack cannot be submitted by mistake.)\n`;
|
|
230
|
+
}
|
|
231
|
+
writeOut(path.join(dir, 'pack-report.md'), report);
|
|
232
|
+
process.stdout.write(report);
|
|
233
|
+
process.stderr.write(`package: ${hard === 0 ? 'PACK READY' : `${hard} hard failure(s) — pack withheld`}\n`);
|
|
234
|
+
process.exit(hard === 0 ? 0 : 1);
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pdf-extract: PDF/docx/xlsx -> page/sheet-tagged plain text + extraction signals.
|
|
3
|
+
// Deterministic. Never invents content; on failure it explains and exits non-zero.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { fail, parseArgs, writeOut, helpText, loadDep } from './lib.mjs';
|
|
7
|
+
|
|
8
|
+
const HELP = helpText('pdf-extract', [
|
|
9
|
+
'Usage: node bin/pdf-extract.mjs <input.(pdf|docx|xlsx|txt)> [--out text.txt] [--signals signals.json]',
|
|
10
|
+
'',
|
|
11
|
+
'Output: full text with [[PAGE n]] (PDF/docx) or [[SHEET n: Name]] (xlsx) tags.',
|
|
12
|
+
'Signals: totalPages, charCount, charsPerPage, scannedLikely, hasEvaluationTable,',
|
|
13
|
+
' hasAnnexes, hasFinancialForm.',
|
|
14
|
+
'Exit codes: 0 ok | 1 error | 2 cannot extract (scanned PDF / OCR needed / parse failed) | 3 deps missing',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const args = parseArgs(process.argv.slice(2));
|
|
18
|
+
if (args.__help || args._.length === 0) {
|
|
19
|
+
process.stdout.write(HELP);
|
|
20
|
+
process.exit(args.__help ? 0 : 1);
|
|
21
|
+
}
|
|
22
|
+
const file = args._[0];
|
|
23
|
+
if (!fs.existsSync(file)) fail(`file not found: ${file}`, 2);
|
|
24
|
+
|
|
25
|
+
const ext = (file.split('.').pop() || '').toLowerCase();
|
|
26
|
+
|
|
27
|
+
async function extractPdf() {
|
|
28
|
+
const buf = fs.readFileSync(file);
|
|
29
|
+
// Primary: unpdf (bundled pdf.js). Fallback: poppler pdftotext if installed.
|
|
30
|
+
let pages = null;
|
|
31
|
+
try {
|
|
32
|
+
const { extractText, getDocumentProxy } = await loadDep('unpdf');
|
|
33
|
+
const pdf = await getDocumentProxy(new Uint8Array(buf));
|
|
34
|
+
const { totalPages, text } = await extractText(pdf, { mergePages: false });
|
|
35
|
+
pages = Array.from(text);
|
|
36
|
+
if (pages.length !== totalPages) pages.length = totalPages;
|
|
37
|
+
} catch (e) {
|
|
38
|
+
if (String(e?.message || e).startsWith('ERROR:')) throw e; // loadDep already handled
|
|
39
|
+
try {
|
|
40
|
+
const out = execFileSync('pdftotext', [file, '-'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
41
|
+
pages = out.split('\f');
|
|
42
|
+
} catch {
|
|
43
|
+
fail(
|
|
44
|
+
`could not parse PDF "${file}". If it is a scanned document, run OCR first:\n` +
|
|
45
|
+
' tesseract input.pdf output_txt (or: pdftotext input.pdf output.txt if poppler is installed)',
|
|
46
|
+
2
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return pages.map((t, i) => `[[PAGE ${i + 1}]]\n${(t || '').trim()}`).join('\n\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function extractDocx() {
|
|
54
|
+
try {
|
|
55
|
+
const mammoth = await loadDep('mammoth'); // mammoth or its CJS default, both expose extractRawText
|
|
56
|
+
const { value } = await mammoth.extractRawText({ path: file });
|
|
57
|
+
// docx has no fixed pages; one tag block so downstream page attribution still works.
|
|
58
|
+
return `[[PAGE 1]]\n${value.trim()}\n\n<!-- note: source is .docx; it has no fixed pagination -->`;
|
|
59
|
+
} catch (e) {
|
|
60
|
+
if (String(e?.message || e).startsWith('ERROR:')) throw e;
|
|
61
|
+
try {
|
|
62
|
+
const xml = execFileSync('unzip', ['-p', file, 'word/document.xml'], { encoding: 'utf8' });
|
|
63
|
+
const text = xml
|
|
64
|
+
.replace(/<\/w:p>/g, '\n')
|
|
65
|
+
.replace(/<[^>]+>/g, '')
|
|
66
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').trim();
|
|
67
|
+
return `[[PAGE 1]]\n${text}\n\n<!-- note: docx extracted via unzip fallback -->`;
|
|
68
|
+
} catch {
|
|
69
|
+
fail(`could not extract docx "${file}" (mammoth and unzip fallback both failed)`, 2);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function extractXlsx() {
|
|
75
|
+
const mod = await loadDep('exceljs');
|
|
76
|
+
const ExcelJS = mod.default ?? mod;
|
|
77
|
+
const wb = new ExcelJS.Workbook();
|
|
78
|
+
await wb.xlsx.readFile(file);
|
|
79
|
+
const parts = [];
|
|
80
|
+
let sheetIdx = 0; // eachSheet's 2nd arg is the sheet id, not the position — count manually
|
|
81
|
+
wb.eachSheet((ws) => {
|
|
82
|
+
sheetIdx += 1;
|
|
83
|
+
parts.push(`[[SHEET ${sheetIdx}: ${ws.name}]]`);
|
|
84
|
+
const maxRow = Math.min(ws.rowCount || 0, 500);
|
|
85
|
+
for (let r = 1; r <= maxRow; r++) {
|
|
86
|
+
const row = ws.getRow(r);
|
|
87
|
+
const cells = [];
|
|
88
|
+
const maxCol = Math.min(row.cellCount || 0, 40);
|
|
89
|
+
for (let c = 1; c <= maxCol; c++) {
|
|
90
|
+
const v = row.getCell(c).value;
|
|
91
|
+
if (v !== null && v !== undefined && v !== '') cells.push(String(v));
|
|
92
|
+
}
|
|
93
|
+
if (cells.length) parts.push(`R${r}: ` + cells.join(' | '));
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
return parts.join('\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let text;
|
|
100
|
+
if (ext === 'pdf') text = await extractPdf();
|
|
101
|
+
else if (ext === 'docx') text = await extractDocx();
|
|
102
|
+
else if (ext === 'xlsx' || ext === 'xlsm') text = await extractXlsx();
|
|
103
|
+
else text = fs.readFileSync(file, 'utf8'); // already-extracted or pasted text: pass through
|
|
104
|
+
|
|
105
|
+
const pageTags = [...text.matchAll(/\[\[PAGE (\d+)\]\]/g)].length;
|
|
106
|
+
const sheetTags = [...text.matchAll(/\[\[SHEET \d+/g)].length;
|
|
107
|
+
const totalPages = pageTags || sheetTags || 1;
|
|
108
|
+
const charCount = text.length;
|
|
109
|
+
const charsPerPage = totalPages ? charCount / totalPages : 0;
|
|
110
|
+
|
|
111
|
+
const signals = {
|
|
112
|
+
file,
|
|
113
|
+
kind: ext === 'pdf' || ext === 'docx' ? 'paged' : ext.startsWith('xls') ? 'sheeted' : 'text',
|
|
114
|
+
totalPages,
|
|
115
|
+
charCount,
|
|
116
|
+
charsPerPage: Math.round(charsPerPage),
|
|
117
|
+
scannedLikely: (ext === 'pdf' && charsPerPage < 200),
|
|
118
|
+
hasEvaluationTable: /evaluation criteri|technical evaluation|scoring (?:grid|table)|award criteria/i.test(text),
|
|
119
|
+
hasAnnexes: /\bannex\b/i.test(text),
|
|
120
|
+
hasFinancialForm: /financial (?:proposal|offer|submission)|price schedule|breakdown of costs/i.test(text),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (signals.scannedLikely) {
|
|
124
|
+
process.stderr.write(
|
|
125
|
+
`WARNING: thin text layer (${signals.charsPerPage} chars/page) -> likely scanned PDF.\n` +
|
|
126
|
+
'OCR required before extraction: tesseract input.pdf outbase txt\n'
|
|
127
|
+
);
|
|
128
|
+
if (args['allow-scanned'] !== true) {
|
|
129
|
+
writeOut(args.signals, JSON.stringify(signals, null, 2));
|
|
130
|
+
fail('refusing to extract a likely-scanned PDF without real text (use OCR first)', 2);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
writeOut(args.out, text);
|
|
135
|
+
writeOut(args.signals, JSON.stringify(signals, null, 2));
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pricing-model: transparent multi-format rate calculator.
|
|
3
|
+
// Every number printed with its formula. Emits pricing.json (input to template-filler).
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fail, parseArgs, writeOut, helpText, num, fmt } from './lib.mjs';
|
|
7
|
+
|
|
8
|
+
const HELP = helpText('pricing-model', [
|
|
9
|
+
'Usage: node bin/pricing-model.mjs --base 400 --basis day --currency USD',
|
|
10
|
+
' [--loading 0.25] [--contingency 0.10] [--effort 20] [--weeks 6] [--out-dir out]',
|
|
11
|
+
'',
|
|
12
|
+
'Constants, always shown: 8h/day, 5d/week, 4.33 weeks/month.',
|
|
13
|
+
'Outputs: <out-dir>/pricing.json + pricing.md (each line shows its formula).',
|
|
14
|
+
'GATE: inputs are yours. This tool computes; it never suggests a base rate.',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const args = parseArgs(process.argv.slice(2));
|
|
18
|
+
if (args.__help || args.base === undefined || !args.basis) {
|
|
19
|
+
process.stdout.write(HELP);
|
|
20
|
+
process.exit(args.__help ? 0 : 1);
|
|
21
|
+
}
|
|
22
|
+
const basis = String(args.basis).toLowerCase();
|
|
23
|
+
if (!['hour', 'day', 'week', 'month'].includes(basis)) fail(`--basis must be hour|day|week|month (got ${basis})`, 2);
|
|
24
|
+
const base = num(args.base, '--base', { min: 0 });
|
|
25
|
+
const currency = (args.currency || 'USD').toUpperCase();
|
|
26
|
+
const loading = args.loading !== undefined ? num(args.loading, '--loading', { min: 0, max: 2 }) : 0;
|
|
27
|
+
const contingency = args.contingency !== undefined ? num(args.contingency, '--contingency', { min: 0, max: 1 }) : 0;
|
|
28
|
+
const effortDays = args.effort !== undefined ? num(args.effort, '--effort', { min: 0 }) : null;
|
|
29
|
+
const weeks = args.weeks !== undefined ? num(args.weeks, '--weeks', { min: 0 }) : null;
|
|
30
|
+
|
|
31
|
+
// Canonical: everything derives from the hourly rate.
|
|
32
|
+
const perHour = { hour: base, day: base / 8, week: base / (8 * 5), month: base / (8 * 5 * 4.33) }[basis];
|
|
33
|
+
const r = (x) => Math.round(x * 100) / 100;
|
|
34
|
+
const conv = [
|
|
35
|
+
['hour', perHour, `= ${fmt(base)} / ${basis === 'hour' ? '1' : { day: '8 (h/day)', week: '40 (8×5)', month: '173.3 (8×5×4.33)' }[basis]}`],
|
|
36
|
+
['day', perHour * 8, `= hourly ${fmt(r(perHour))} × 8`],
|
|
37
|
+
['week', perHour * 8 * 5, `= hourly ${fmt(r(perHour))} × 40`],
|
|
38
|
+
['month', perHour * 8 * 5 * 4.33, `= hourly ${fmt(r(perHour))} × 173.3`],
|
|
39
|
+
];
|
|
40
|
+
const baseDay = perHour * 8;
|
|
41
|
+
const loaded = baseDay * (1 + loading);
|
|
42
|
+
const floor = loaded * (1 + contingency);
|
|
43
|
+
const lumpSum = effortDays !== null ? floor * effortDays : null;
|
|
44
|
+
const total = weeks !== null ? floor * 5 * weeks : null;
|
|
45
|
+
|
|
46
|
+
const pricing = {
|
|
47
|
+
inputs: { base, basis, currency, loading, contingency, effortDays, weeks, userSupplied: true },
|
|
48
|
+
constants: { hoursPerDay: 8, daysPerWeek: 5, weeksPerMonth: 4.33 },
|
|
49
|
+
rates: { hour: r(perHour), day: r(baseDay), week: r(perHour * 40), month: r(perHour * 173.3) },
|
|
50
|
+
loadedDay: r(loaded),
|
|
51
|
+
quoteFloorDay: r(floor),
|
|
52
|
+
lumpSum: lumpSum !== null ? r(lumpSum) : null,
|
|
53
|
+
assignmentTotal: total !== null ? r(total) : null,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const outDir = args['out-dir'] || 'out';
|
|
57
|
+
fs.mkdirSync(path.resolve(outDir), { recursive: true });
|
|
58
|
+
fs.writeFileSync(path.join(outDir, 'pricing.json'), JSON.stringify(pricing, null, 2));
|
|
59
|
+
|
|
60
|
+
let md = `# Pricing model (${currency})\n\nInputs (all user-supplied): base ${fmt(base)}/${basis}` +
|
|
61
|
+
(loading ? `, loading ${(loading * 100).toFixed(0)}%` : '') +
|
|
62
|
+
(contingency ? `, contingency ${(contingency * 100).toFixed(0)}%` : '') +
|
|
63
|
+
(effortDays !== null ? `, effort ${effortDays} person-days` : '') +
|
|
64
|
+
(weeks !== null ? `, ${weeks} weeks` : '') + `\n\nConstants (open): 8h/day, 5d/week, 4.33 weeks/month.\n\n## Billing formats\n\n| Basis | Rate (${currency}) | Formula |\n| --- | --- | --- |\n` +
|
|
65
|
+
conv.map(([k, v, f]) => `| per ${k} | ${fmt(r(v))} | ${f} |`).join('\n') +
|
|
66
|
+
`\n\n## Load & floor\n` +
|
|
67
|
+
`- Loaded day rate: **${fmt(r(loaded))}** = base day ${fmt(r(baseDay))} × (1 + loading ${(loading * 100).toFixed(0)}%)\n` +
|
|
68
|
+
(contingency ? `- Quote floor: **${fmt(r(floor))}** = loaded ${fmt(r(loaded))} × (1 + contingency ${(contingency * 100).toFixed(0)}%)\n` : `- Quote floor = loaded rate (no contingency set)\n`) +
|
|
69
|
+
(lumpSum !== null ? `- Lump-sum for ${effortDays} days: **${fmt(r(lumpSum))}** = floor ${fmt(r(floor))} × ${effortDays}\n` : '') +
|
|
70
|
+
(total !== null ? `- Assignment total (${weeks} weeks): **${fmt(r(total))}** = floor ${fmt(r(floor))} × 5 × ${weeks}\n` : '') +
|
|
71
|
+
`\n> ⚠️ CLIENT TEMPLATE GOVERNS: many financial forms forbid loading/contingency lines or require\n> per-deliverable or per-team-member breakdown. Restructure INTO their template with\n> template-filler.mjs — never the other way around. If the template has no rows for loading,\n> present the loaded figure only.\n`;
|
|
72
|
+
writeOut(path.join(outDir, 'pricing.md'), md);
|
|
73
|
+
process.stdout.write(md);
|