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/profile.mjs
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// profile: the one-time consultant profile -> ~/.tor-to-proposal/profile.json
|
|
3
|
+
// Every value here is USER-SUPPLIED. The profile is the canonical "user input"
|
|
4
|
+
// trace source downstream tools cite; nothing in it is ever invented or defaulted
|
|
5
|
+
// silently. Plug-and-play: `npx tor-to-proposal init` runs the wizard once, and
|
|
6
|
+
// every future bid inherits these facts instead of re-asking for them.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import readline from 'node:readline/promises';
|
|
11
|
+
import { fail, parseArgs, helpText } from './lib.mjs';
|
|
12
|
+
|
|
13
|
+
const HELP = helpText('profile', [
|
|
14
|
+
'One-time consultant profile, stored at ~/.tor-to-proposal/profile.json.',
|
|
15
|
+
'',
|
|
16
|
+
' init interactive wizard (terminal prompts; blank = keep current)',
|
|
17
|
+
' set key=value ... set fields (dotted keys). Values parse as JSON when valid:',
|
|
18
|
+
' profile set identity.name="Ayesha Khan" rates.defaults.base=400 cv.masterPath=~/cv/master.md',
|
|
19
|
+
' get [key] print the whole profile (or one dotted field) as JSON',
|
|
20
|
+
' path print the profile file path',
|
|
21
|
+
' erase delete the profile',
|
|
22
|
+
'',
|
|
23
|
+
'Fields:',
|
|
24
|
+
' identity.{name, credentials, title, email, phone, location, nationality, languages[]}',
|
|
25
|
+
' rates.floor.{annualIncome, billableDays, costLoading} # your floor-math inputs',
|
|
26
|
+
' rates.defaults.{base, basis, currency, loading, contingency} # re-confirmed per bid',
|
|
27
|
+
' cv.masterPath # your master CV',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const FILE = path.join(os.homedir(), '.tor-to-proposal', 'profile.json');
|
|
31
|
+
|
|
32
|
+
const NUMERIC = {
|
|
33
|
+
'rates.floor.annualIncome': { min: 0, max: 1e9, label: 'annual income need' },
|
|
34
|
+
'rates.floor.billableDays': { min: 0, max: 365, label: 'billable days per year' },
|
|
35
|
+
'rates.floor.costLoading': { min: 0, max: 2, label: 'cost loading (0–2, e.g. 0.25)' },
|
|
36
|
+
'rates.defaults.base': { min: 0, max: 1e7, label: 'base rate' },
|
|
37
|
+
'rates.defaults.loading': { min: 0, max: 2, label: 'loading (0–2)' },
|
|
38
|
+
'rates.defaults.contingency': { min: 0, max: 1, label: 'contingency (0–1)' },
|
|
39
|
+
};
|
|
40
|
+
const BASIS = ['hour', 'day', 'week', 'month'];
|
|
41
|
+
|
|
42
|
+
export function loadProfile() {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(fs.readFileSync(FILE, 'utf8'));
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function emptyProfile() {
|
|
51
|
+
const now = new Date().toISOString();
|
|
52
|
+
return {
|
|
53
|
+
version: 1,
|
|
54
|
+
identity: { name: null, credentials: null, title: null, email: null, phone: null, location: null, nationality: null, languages: [] },
|
|
55
|
+
rates: { floor: { annualIncome: null, billableDays: null, costLoading: null }, defaults: { base: null, basis: 'day', currency: 'USD', loading: null, contingency: null } },
|
|
56
|
+
cv: { masterPath: null },
|
|
57
|
+
meta: { createdAt: now, updatedAt: now },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getDeep(obj, dotted) {
|
|
62
|
+
return dotted.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function setDeep(obj, dotted, value) {
|
|
66
|
+
const keys = dotted.split('.');
|
|
67
|
+
let o = obj;
|
|
68
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
69
|
+
if (typeof o[keys[i]] !== 'object' || o[keys[i]] === null) o[keys[i]] = {};
|
|
70
|
+
o = o[keys[i]];
|
|
71
|
+
}
|
|
72
|
+
o[keys[keys.length - 1]] = value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function coerceAndValidate(dotted, raw) {
|
|
76
|
+
let v;
|
|
77
|
+
try { v = JSON.parse(raw); } catch { v = raw; }
|
|
78
|
+
if (v === '' || v === 'null' || v === null) v = null;
|
|
79
|
+
if (NUMERIC[dotted]) {
|
|
80
|
+
if (v === null) return null;
|
|
81
|
+
const n = Number(v);
|
|
82
|
+
if (!Number.isFinite(n)) fail(`${dotted} must be a number (got: ${JSON.stringify(raw)})`, 2);
|
|
83
|
+
const { min, max, label } = NUMERIC[dotted];
|
|
84
|
+
if (n < min || n > max) fail(`${dotted} (${label}) must be between ${min} and ${max}`, 2);
|
|
85
|
+
return n;
|
|
86
|
+
}
|
|
87
|
+
if (dotted === 'rates.defaults.basis' && v !== null && !BASIS.includes(String(v).toLowerCase())) {
|
|
88
|
+
fail(`rates.defaults.basis must be one of ${BASIS.join('|')}`, 2);
|
|
89
|
+
}
|
|
90
|
+
if (dotted === 'cv.masterPath' && v !== null) {
|
|
91
|
+
const p = String(v).replace(/^~(?=\/|$)/, os.homedir());
|
|
92
|
+
if (!fs.existsSync(p)) fail(`cv.masterPath does not exist: ${p} (set it to your master CV file)`, 2);
|
|
93
|
+
return p;
|
|
94
|
+
}
|
|
95
|
+
if (dotted === 'identity.languages' && v !== null && !Array.isArray(v)) {
|
|
96
|
+
v = String(v).split(/[,;]\s*/).map((s) => s.trim()).filter(Boolean);
|
|
97
|
+
}
|
|
98
|
+
return v;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function save(profile) {
|
|
102
|
+
profile.meta = { ...(profile.meta || {}), updatedAt: new Date().toISOString() };
|
|
103
|
+
fs.mkdirSync(path.dirname(FILE), { recursive: true });
|
|
104
|
+
fs.writeFileSync(FILE, JSON.stringify(profile, null, 2));
|
|
105
|
+
process.stderr.write(`written: ${FILE}\n`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------- interactive wizard ----------
|
|
109
|
+
async function wizard() {
|
|
110
|
+
const current = loadProfile() || emptyProfile();
|
|
111
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
112
|
+
const ask = async (label, existing, hint = '') => {
|
|
113
|
+
const tail = hint ? ` (${hint})` : '';
|
|
114
|
+
const cur = existing === null || existing === undefined || (Array.isArray(existing) && !existing.length) ? '' : String(Array.isArray(existing) ? existing.join(', ') : existing);
|
|
115
|
+
const a = (await rl.question(`${label}${tail} [${cur}]: `)).trim();
|
|
116
|
+
return a === '' ? existing : a;
|
|
117
|
+
};
|
|
118
|
+
const id = current.identity;
|
|
119
|
+
id.name = await ask('Full name (exactly as on your CV)', id.name);
|
|
120
|
+
id.credentials = await ask('Credentials after your name (e.g. "PhD, PMP")', id.credentials);
|
|
121
|
+
id.title = await ask('Professional title (e.g. "M&E Consultant")', id.title);
|
|
122
|
+
id.email = await ask('Email', id.email);
|
|
123
|
+
id.phone = await ask('Phone (international format)', id.phone);
|
|
124
|
+
id.location = await ask('City, country', id.location);
|
|
125
|
+
id.nationality = await ask('Nationality (some ToRs require it)', id.nationality);
|
|
126
|
+
id.languages = await ask('Working languages (comma-separated)', id.languages);
|
|
127
|
+
const cv = await ask('Path to your master CV file', current.cv.masterPath);
|
|
128
|
+
if (cv !== current.cv.masterPath) {
|
|
129
|
+
const p = String(cv).replace(/^~(?=\/|$)/, os.homedir());
|
|
130
|
+
if (!fs.existsSync(p)) process.stderr.write(` ! file not found (${p}) — stored anyway; fix with: profile set cv.masterPath=...\n`);
|
|
131
|
+
current.cv.masterPath = p;
|
|
132
|
+
}
|
|
133
|
+
const fl = current.rates.floor;
|
|
134
|
+
const asNum = (v) => (v === null || v === undefined || v === '' ? null : Number(v));
|
|
135
|
+
let a = await ask('Minimum annual income you need (your floor math)', fl.annualIncome, 'e.g. 60000');
|
|
136
|
+
fl.annualIncome = a === fl.annualIncome ? fl.annualIncome : asNum(a);
|
|
137
|
+
a = await ask('Realistic billable days per year', fl.billableDays, 'e.g. 180');
|
|
138
|
+
fl.billableDays = a === fl.billableDays ? fl.billableDays : asNum(a);
|
|
139
|
+
a = await ask('Cost loading (overheads on top of your net)', fl.costLoading, 'e.g. 0.25');
|
|
140
|
+
fl.costLoading = a === fl.costLoading ? fl.costLoading : asNum(a);
|
|
141
|
+
const d = current.rates.defaults;
|
|
142
|
+
a = await ask('Default currency', d.currency || 'USD');
|
|
143
|
+
d.currency = String(a).toUpperCase();
|
|
144
|
+
a = await ask('Typical base rate (optional — you confirm it per bid)', d.base, 'e.g. 400');
|
|
145
|
+
d.base = a === d.base ? d.base : asNum(a);
|
|
146
|
+
a = await ask('Typical loading', d.loading, 'e.g. 0.25');
|
|
147
|
+
d.loading = a === d.loading ? d.loading : asNum(a);
|
|
148
|
+
rl.close();
|
|
149
|
+
save(current);
|
|
150
|
+
process.stdout.write(
|
|
151
|
+
`\nProfile saved: ${FILE}\n\nNext: drop a ToR + your CV into a folder and tell your AI assistant:\n "Run a bid pack on <folder>"\n` +
|
|
152
|
+
'Your saved numbers will be offered for one-keystroke confirmation — never used without it.\n'
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------- dispatch (guarded so other tools can import loadProfile) ----------
|
|
157
|
+
import { pathToFileURL } from 'node:url';
|
|
158
|
+
const isMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
159
|
+
|
|
160
|
+
const args = parseArgs(process.argv.slice(2));
|
|
161
|
+
const cmd = args._[0];
|
|
162
|
+
|
|
163
|
+
async function main() {
|
|
164
|
+
if (args.__help || !cmd) {
|
|
165
|
+
process.stdout.write(HELP);
|
|
166
|
+
process.exit(args.__help ? 0 : 1);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (cmd === 'init') {
|
|
170
|
+
await wizard();
|
|
171
|
+
} else if (cmd === 'set') {
|
|
172
|
+
if (!args._.slice(1).length) fail('nothing to set. Example: profile set identity.name="A. Khan" rates.defaults.base=400', 2);
|
|
173
|
+
const profile = loadProfile() || emptyProfile();
|
|
174
|
+
for (const kv of args._.slice(1)) {
|
|
175
|
+
const eq = kv.indexOf('=');
|
|
176
|
+
if (eq <= 0) fail(`expected key=value, got "${kv}"`, 2);
|
|
177
|
+
const key = kv.slice(0, eq).trim();
|
|
178
|
+
if (key === 'meta' || key.startsWith('meta.') || key === 'version') fail(`"${key}" is managed by the tool`, 2);
|
|
179
|
+
setDeep(profile, key, coerceAndValidate(key, kv.slice(eq + 1)));
|
|
180
|
+
process.stderr.write(`set ${key}\n`);
|
|
181
|
+
}
|
|
182
|
+
save(profile);
|
|
183
|
+
} else if (cmd === 'get') {
|
|
184
|
+
const profile = loadProfile();
|
|
185
|
+
if (!profile) fail(`no profile at ${FILE} — run: tor-to-proposal init`, 2);
|
|
186
|
+
if (args._[1]) {
|
|
187
|
+
const v = getDeep(profile, args._[1]);
|
|
188
|
+
if (v === undefined) fail(`unknown key: ${args._[1]}`, 2);
|
|
189
|
+
process.stdout.write(JSON.stringify(v, null, 2) + '\n');
|
|
190
|
+
} else process.stdout.write(JSON.stringify(profile, null, 2) + '\n');
|
|
191
|
+
} else if (cmd === 'path') {
|
|
192
|
+
process.stdout.write(FILE + '\n');
|
|
193
|
+
} else if (cmd === 'erase') {
|
|
194
|
+
if (fs.existsSync(FILE)) { fs.rmSync(FILE); process.stderr.write(`erased: ${FILE}\n`); }
|
|
195
|
+
else process.stderr.write('no profile to erase\n');
|
|
196
|
+
} else {
|
|
197
|
+
fail(`unknown command: ${cmd} (init | set | get | path | erase)`, 2);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (isMain) await main();
|
package/bin/render.mjs
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// render: minimal pure-JS markdown -> PDF emitter (the fallback tier).
|
|
3
|
+
// The agent's own document tooling produces the presentation PDFs; this exists so
|
|
4
|
+
// a bid pack can ALWAYS be completed and verified, even with no document skill
|
|
5
|
+
// present. Text layout only: headings, bullets, tables (Courier), wrapping,
|
|
6
|
+
// page numbers, WinAnsi text. Deterministic: same input -> same bytes.
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fail, parseArgs, readText, writeOut, helpText } from './lib.mjs';
|
|
10
|
+
|
|
11
|
+
const HELP = helpText('render', [
|
|
12
|
+
'Usage: node bin/render.mjs <input.md> --out output.pdf [--footer "text"]',
|
|
13
|
+
'',
|
|
14
|
+
'Fallback renderer: clean, plain, submittable. For a designed PDF, let your AI',
|
|
15
|
+
'assistant render it with its document tooling instead — package.mjs verifies',
|
|
16
|
+
'whichever PDF it finds, whichever tool produced it.',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
// ---------- cp1252 (WinAnsi) encoding ----------
|
|
20
|
+
const SPECIAL = { 0x20ac: 0x80, 0x201a: 0x82, 0x0192: 0x83, 0x201e: 0x84, 0x2026: 0x85, 0x2020: 0x86, 0x2021: 0x87, 0x02c6: 0x88, 0x2030: 0x89, 0x0160: 0x8a, 0x2039: 0x8b, 0x0152: 0x8c, 0x017d: 0x8e, 0x2018: 0x91, 0x2019: 0x92, 0x201c: 0x93, 0x201d: 0x94, 0x2022: 0x95, 0x2013: 0x96, 0x2014: 0x97, 0x02dc: 0x98, 0x2122: 0x99, 0x0161: 0x9a, 0x203a: 0x9b, 0x0153: 0x9c, 0x017e: 0x9e, 0x0178: 0x9f };
|
|
21
|
+
function cp1252Buffer(s) {
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const ch of String(s)) {
|
|
24
|
+
const c = ch.codePointAt(0);
|
|
25
|
+
if (c < 0x100 && !(c >= 0x7f && c <= 0x9f)) out.push(c);
|
|
26
|
+
else if (SPECIAL[c] !== undefined) out.push(SPECIAL[c]);
|
|
27
|
+
else out.push(0x3f); // ?
|
|
28
|
+
}
|
|
29
|
+
return Buffer.from(out, 'latin1');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ---------- markdown -> layout tokens ----------
|
|
33
|
+
const stripInline = (s) => String(s).replace(/\*\*([^*]+)\*\*/g, '$1').replace(/\*([^*]+)\*/g, '$1').replace(/`([^`]+)`/g, '$1').replace(/\s+/g, ' ').trim();
|
|
34
|
+
|
|
35
|
+
function tokenize(md) {
|
|
36
|
+
const lines = md.split('\n');
|
|
37
|
+
const tokens = [];
|
|
38
|
+
let table = null;
|
|
39
|
+
const flushTable = () => { if (table && table.rows.length) tokens.push({ type: 'table', rows: table.rows }); table = null; };
|
|
40
|
+
for (const raw of lines) {
|
|
41
|
+
const line = raw.replace(/\s+$/, '');
|
|
42
|
+
if (/^\s*\|.*\|?\s*$/.test(line)) {
|
|
43
|
+
if (/^\s*\|?[\s:|-]+\|?\s*$/.test(line)) continue; // separator row
|
|
44
|
+
const cells = line.trim().replace(/^\||\|$/g, '').split('|').map((c) => stripInline(c));
|
|
45
|
+
table = table || { rows: [] };
|
|
46
|
+
table.rows.push(cells);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
flushTable();
|
|
50
|
+
if (!line.trim()) { tokens.push({ type: 'gap' }); continue; }
|
|
51
|
+
let m = line.match(/^(#{1,3})\s+(.*)$/);
|
|
52
|
+
if (m) { tokens.push({ type: 'h' + m[1].length, text: stripInline(m[2]) }); continue; }
|
|
53
|
+
if (/^\s*(?:[-*_]\s*){3,}$/.test(line)) { tokens.push({ type: 'gap' }); continue; }
|
|
54
|
+
m = line.match(/^(\s*)(?:[-*•·‣–]|\d{1,2}[.)])\s+(.*)$/);
|
|
55
|
+
if (m) { tokens.push({ type: 'bullet', indent: m[1].length, text: stripInline(m[2]) }); continue; }
|
|
56
|
+
if (/^\*\*[^*]+\*\*$/.test(line.trim())) { tokens.push({ type: 'bold', text: stripInline(line) }); continue; }
|
|
57
|
+
tokens.push({ type: 'text', text: stripInline(line) });
|
|
58
|
+
}
|
|
59
|
+
flushTable();
|
|
60
|
+
return tokens;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------- layout ----------
|
|
64
|
+
const PAGE_W = 595, PAGE_H = 842, MARGIN = 56, TOP = 778, BOTTOM = 56;
|
|
65
|
+
const FONTS = {
|
|
66
|
+
F1: { base: 'Helvetica', factor: 0.5 },
|
|
67
|
+
F2: { base: 'Helvetica-Bold', factor: 0.53 },
|
|
68
|
+
F3: { base: 'Courier', factor: 0.6 },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function wrapText(text, maxChars) {
|
|
72
|
+
const words = String(text).split(/\s+/).filter(Boolean);
|
|
73
|
+
if (!words.length) return [''];
|
|
74
|
+
const lines = [];
|
|
75
|
+
let cur = '';
|
|
76
|
+
for (let w of words) {
|
|
77
|
+
while (w.length > maxChars) { // hard-break overlong tokens (URLs)
|
|
78
|
+
const take = maxChars - (cur ? cur.length + 1 : 0);
|
|
79
|
+
if (take <= 0) { break; }
|
|
80
|
+
cur = cur ? cur + ' ' + w.slice(0, take) : w.slice(0, take);
|
|
81
|
+
lines.push(cur); cur = ''; w = w.slice(take);
|
|
82
|
+
}
|
|
83
|
+
const next = cur ? cur + ' ' + w : w;
|
|
84
|
+
if (next.length > maxChars && cur) { lines.push(cur); cur = w; } else cur = next;
|
|
85
|
+
}
|
|
86
|
+
if (cur) lines.push(cur);
|
|
87
|
+
return lines;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function layout(tokens) {
|
|
91
|
+
const pages = [];
|
|
92
|
+
let cur = [];
|
|
93
|
+
let y = TOP;
|
|
94
|
+
const newPage = () => { pages.push(cur); cur = []; y = TOP; };
|
|
95
|
+
const put = (text, font, size, lead, x, wrapAt) => {
|
|
96
|
+
for (const line of wrapText(text, wrapAt)) {
|
|
97
|
+
if (y - lead < BOTTOM) newPage();
|
|
98
|
+
cur.push({ x, y, font, size, text: line });
|
|
99
|
+
y -= lead;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
for (const t of tokens) {
|
|
103
|
+
if (t.type === 'gap') { y -= 6; continue; }
|
|
104
|
+
if (t.type === 'h1') { y -= 8; put(t.text, 'F2', 14, 18, MARGIN, 64); y -= 2; continue; }
|
|
105
|
+
if (t.type === 'h2') { y -= 6; put(t.text, 'F2', 12, 16, MARGIN, 74); continue; }
|
|
106
|
+
if (t.type === 'h3') { y -= 4; put(t.text, 'F2', 10.5, 14, MARGIN, 85); continue; }
|
|
107
|
+
if (t.type === 'bold') { put(t.text, 'F2', 10, 14, MARGIN, 88); continue; }
|
|
108
|
+
if (t.type === 'bullet') {
|
|
109
|
+
const first = '• ' + t.text;
|
|
110
|
+
const lines = wrapText(first, 92);
|
|
111
|
+
lines.forEach((line, i) => put(line, 'F1', 10, 14, i === 0 ? MARGIN + 2 : MARGIN + 14, 92));
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (t.type === 'table') {
|
|
115
|
+
const rows = t.rows;
|
|
116
|
+
const nCol = Math.max(...rows.map((r) => r.length));
|
|
117
|
+
const budget = 96;
|
|
118
|
+
const widths = Array.from({ length: nCol }, (_, c) => Math.max(4, ...rows.map((r) => (r[c] || '').length)));
|
|
119
|
+
let total = widths.reduce((a, b) => a + b, 0) + nCol * 3;
|
|
120
|
+
if (total > budget) widths.forEach((_, i) => (widths[i] = Math.max(5, Math.floor((widths[i] / total) * budget))));
|
|
121
|
+
total = widths.reduce((a, b) => a + b, 0) + nCol * 3;
|
|
122
|
+
const renderRow = (r) => ' ' + Array.from({ length: nCol }, (_, c) => {
|
|
123
|
+
const w = widths[c];
|
|
124
|
+
const cell = (r[c] || '');
|
|
125
|
+
return cell.length > w ? cell.slice(0, w - 1) + '…' : cell.padEnd(w);
|
|
126
|
+
}).join(' | ');
|
|
127
|
+
y -= 4;
|
|
128
|
+
rows.forEach((r, i) => {
|
|
129
|
+
put(renderRow(r), 'F3', 8, 10.5, MARGIN, 110);
|
|
130
|
+
if (i === 0) put('-'.repeat(Math.min(total, 96)), 'F3', 8, 10.5, MARGIN, 110);
|
|
131
|
+
});
|
|
132
|
+
y -= 4;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
put(t.text, 'F1', 10, 14, MARGIN, 92);
|
|
136
|
+
}
|
|
137
|
+
pages.push(cur);
|
|
138
|
+
return pages.filter((p, i) => p.length || i === 0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------- PDF assembly ----------
|
|
142
|
+
function buildPdf(pages, title, footerText) {
|
|
143
|
+
const esc = (s) => String(s).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
|
144
|
+
const obj = (body) => Buffer.concat([Buffer.from(body, 'latin1')]);
|
|
145
|
+
const P = pages.length;
|
|
146
|
+
const pageObjs = pages.map((_, i) => 6 + i);
|
|
147
|
+
const contentObjs = pages.map((_, i) => 6 + P + i);
|
|
148
|
+
const infoNum = 6 + 2 * P;
|
|
149
|
+
|
|
150
|
+
const contents = pages.map((lines, i) => {
|
|
151
|
+
const parts = lines.map((l) => `BT /${l.font} ${l.size} Tf 1 0 0 1 ${l.x.toFixed(1)} ${l.y.toFixed(1)} Tm (${esc(l.text)}) Tj ET`);
|
|
152
|
+
const label = footerText ? `${footerText} — Page ${i + 1} of ${P}` : `Page ${i + 1} of ${P}`;
|
|
153
|
+
parts.push(`BT /F1 8 Tf 1 0 0 1 ${(PAGE_W / 2 - label.length * 2).toFixed(1)} 36 Tm (${esc(label)}) Tj ET`);
|
|
154
|
+
const stream = cp1252Buffer(parts.join('\n'));
|
|
155
|
+
return Buffer.concat([Buffer.from(`<< /Length ${stream.length} >>\nstream\n`, 'latin1'), stream, Buffer.from('\nendstream', 'latin1')]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const bodies = [];
|
|
159
|
+
bodies[1] = obj('<< /Type /Catalog /Pages 2 0 R >>');
|
|
160
|
+
bodies[2] = obj(`<< /Type /Pages /Kids [${pageObjs.map((n) => `${n} 0 R`).join(' ')}] /Count ${P} >>`);
|
|
161
|
+
bodies[3] = obj('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>');
|
|
162
|
+
bodies[4] = obj('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>');
|
|
163
|
+
bodies[5] = obj('<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>');
|
|
164
|
+
pages.forEach((_, i) => {
|
|
165
|
+
bodies[pageObjs[i]] = obj(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PAGE_W} ${PAGE_H}] /Resources << /Font << /F1 3 0 R /F2 4 0 R /F3 5 0 R >> >> /Contents ${contentObjs[i]} 0 R >>`);
|
|
166
|
+
bodies[contentObjs[i]] = contents[i];
|
|
167
|
+
});
|
|
168
|
+
bodies[infoNum] = obj(`<< /Title (${esc(title || 'Proposal')}) /Producer (tor-to-proposal render) >>`);
|
|
169
|
+
|
|
170
|
+
const chunks = [Buffer.from('%PDF-1.4\n', 'latin1')];
|
|
171
|
+
const offsets = new Array(infoNum + 1).fill(0);
|
|
172
|
+
for (let n = 1; n <= infoNum; n++) {
|
|
173
|
+
if (!bodies[n]) continue;
|
|
174
|
+
offsets[n] = Buffer.concat(chunks).length;
|
|
175
|
+
chunks.push(Buffer.concat([Buffer.from(`${n} 0 obj\n`, 'latin1'), bodies[n], Buffer.from('\nendobj\n', 'latin1')]));
|
|
176
|
+
}
|
|
177
|
+
const xrefPos = Buffer.concat(chunks).length;
|
|
178
|
+
let xref = `xref\n0 ${infoNum + 1}\n0000000000 65535 f \n`;
|
|
179
|
+
for (let n = 1; n <= infoNum; n++) xref += `${String(offsets[n]).padStart(10, '0')} 00000 n \n`;
|
|
180
|
+
chunks.push(Buffer.from(xref + `trailer\n<< /Size ${infoNum + 1} /Root 1 0 R /Info ${infoNum} 0 R >>\nstartxref\n${xrefPos}\n%%EOF\n`, 'latin1'));
|
|
181
|
+
return Buffer.concat(chunks);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------- CLI ----------
|
|
185
|
+
const args = parseArgs(process.argv.slice(2));
|
|
186
|
+
if (args.__help || !args._[0] || !args.out) {
|
|
187
|
+
process.stdout.write(HELP);
|
|
188
|
+
process.exit(args.__help ? 0 : 1);
|
|
189
|
+
}
|
|
190
|
+
const md = readText(args._[0], 'markdown input');
|
|
191
|
+
const tokens = tokenize(md);
|
|
192
|
+
if (!tokens.length) fail('input is empty — nothing to render', 2);
|
|
193
|
+
const pages = layout(tokens);
|
|
194
|
+
const title = (tokens.find((t) => t.type === 'h1') || {}).text || path0(args._[0]);
|
|
195
|
+
function path0(p) { return String(p).split(/[\\/]/).pop(); }
|
|
196
|
+
const pdf = buildPdf(pages, title, args.footer ? String(args.footer) : null);
|
|
197
|
+
fs.mkdirSync(path.dirname(path.resolve(args.out)), { recursive: true });
|
|
198
|
+
fs.writeFileSync(args.out, pdf);
|
|
199
|
+
process.stderr.write(`written: ${args.out} (${pages.length} page${pages.length > 1 ? 's' : ''}, ${pdf.length} bytes, fallback renderer)\n`);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// simulator: reverse-score a drafted technical response against the ToR's published
|
|
3
|
+
// evaluation grid. Coverage arithmetic only — at-risk points = weight × (missing
|
|
4
|
+
// sub-elements ÷ total). This is NOT a prediction of evaluator behavior (stated in report).
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import { fail, parseArgs, readText, writeOut, helpText, mdTable } from './lib.mjs';
|
|
7
|
+
|
|
8
|
+
const HELP = helpText('simulator', [
|
|
9
|
+
'Usage: node bin/simulator.mjs --extract out/tor-extract.json --response response.md',
|
|
10
|
+
' [--sub-elements sub-elements.json] [--out simulator-report.md]',
|
|
11
|
+
'',
|
|
12
|
+
'--sub-elements (optional): {"criterion label": ["sub-element 1", "..."], ...}',
|
|
13
|
+
' Agent-extracted from the ToR — verbatim wording, with the ToR page noted.',
|
|
14
|
+
'',
|
|
15
|
+
'Keyword-coverage check per sub-element; at-risk pts = weight × missing/total.',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const STOP = new Set(['the', 'and', 'with', 'for', 'from', 'that', 'this', 'will', 'shall', 'including', 'based', 'using', 'which', 'their', 'other', 'others', 'must', 'have', 'been', 'into', 'such', 'than', 'then', 'when', 'where', 'while', 'about', 'under', 'over', 'within', 'should', 'would', 'could', 'these', 'those', 'applicant', 'bidder']);
|
|
19
|
+
|
|
20
|
+
const args = parseArgs(process.argv.slice(2));
|
|
21
|
+
if (args.__help || !args.extract || !args.response) {
|
|
22
|
+
process.stdout.write(HELP);
|
|
23
|
+
process.exit(args.__help ? 0 : 1);
|
|
24
|
+
}
|
|
25
|
+
const extract = JSON.parse(readText(args.extract, '--extract'));
|
|
26
|
+
const response = readText(args.response, '--response');
|
|
27
|
+
const responseLc = response.toLowerCase();
|
|
28
|
+
const subMap = args['sub-elements'] ? JSON.parse(readText(args['sub-elements'], '--sub-elements')) : {};
|
|
29
|
+
|
|
30
|
+
const criteria = extract.evaluation?.criteria ?? [];
|
|
31
|
+
if (!criteria.length) fail('tor-extract.json has no evaluation criteria — re-run extract (or supply via --json per SKILL.md step 1)', 2);
|
|
32
|
+
|
|
33
|
+
function keywords(text) {
|
|
34
|
+
return [...new Set(text.toLowerCase().match(/[a-z][a-z-]{3,}/g) ?? [])].filter((w) => !STOP.has(w)).slice(0, 8);
|
|
35
|
+
}
|
|
36
|
+
function coveredBy(el) {
|
|
37
|
+
const kws = keywords(el);
|
|
38
|
+
if (!kws.length) return { covered: true, hits: [], kws };
|
|
39
|
+
const hits = kws.filter((k) => new RegExp(`(^|[^a-z])${k}([^a-z]|$)`, 'i').test(responseLc));
|
|
40
|
+
return { covered: hits.length >= Math.max(1, Math.ceil(kws.length * 0.4)), hits, kws };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const rows = [];
|
|
44
|
+
let totalAtRisk = 0;
|
|
45
|
+
for (const c of criteria) {
|
|
46
|
+
const subs = subMap[c.label] ?? [c.label]; // no sub-map -> the criterion itself is the unit
|
|
47
|
+
const detail = subs.map((s) => ({ sub: s, ...coveredBy(s) }));
|
|
48
|
+
const missing = detail.filter((d) => !d.covered);
|
|
49
|
+
const atRisk = (c.weight * missing.length) / subs.length;
|
|
50
|
+
totalAtRisk += atRisk;
|
|
51
|
+
rows.push({
|
|
52
|
+
label: c.label, weight: c.weight, page: c.page ?? null, subs, missing: missing.map((m) => m.sub),
|
|
53
|
+
coveredN: subs.length - missing.length, atRisk: Math.round(atRisk * 100) / 100,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
rows.sort((a, b) => b.atRisk - a.atRisk);
|
|
57
|
+
const totalWeight = criteria.reduce((s, c) => s + c.weight, 0);
|
|
58
|
+
|
|
59
|
+
const table = mdTable(
|
|
60
|
+
['Criterion', 'Wt', 'Sub-elems covered', 'At-risk pts', 'Missing (add these)'],
|
|
61
|
+
rows.map((r) => [`${r.label}${r.page ? ` (p.${r.page})` : ''}`, String(r.weight), `${r.coveredN}/${r.subs.length}`, r.atRisk ? `**${r.atRisk.toFixed(2)}**` : '0', r.missing.length ? r.missing.map((m) => `“${m}”`).join('; ') : '—'])
|
|
62
|
+
);
|
|
63
|
+
const priorities = rows.filter((r) => r.atRisk > 0).map((r, i) => `${i + 1}. ${r.label} — recover up to ${r.atRisk.toFixed(2)} pts: add ${r.missing.join('; ')}.`);
|
|
64
|
+
|
|
65
|
+
const report = `# Simulator report — coverage vs published grid\n\n**At-risk points: ${totalAtRisk.toFixed(2)} of ${totalWeight}** (sum of weights × missing share)\n\n${table}\n\n## Rewrite priorities (highest recoverable points first)\n${priorities.length ? priorities.join('\n') : 'None — every criterion covered at keyword level. Still get a human read.'}\n\n## Method (no false precision)\nPer sub-element, up to 8 distinctive keywords are extracted from the ToR wording and checked\nagainst your response; “covered” = ≥40% of keywords present. At-risk pts = weight × (missing ÷\ntotal sub-elements). This is coverage arithmetic on the published grid — NOT a prediction of\nhow evaluators will score you. Missing sub-element wording is verbatim ToR language: answer it\nin their words.\n`;
|
|
66
|
+
writeOut(args.out || 'simulator-report.md', report);
|
|
67
|
+
process.stderr.write(`simulator: at-risk ${totalAtRisk.toFixed(2)} / ${totalWeight} pts across ${criteria.length} criteria\n`);
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// template-filler: fills the CLIENT'S OWN forms. docx placeholders + xlsx labeled
|
|
3
|
+
// cells. Never alters template structure; never overwrites a formula; every write
|
|
4
|
+
// is traced in fill-report.md. CLIENT-TEMPLATE-WINS is enforced here, mechanically.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fail, parseArgs, readText, writeOut, helpText, loadDep } from './lib.mjs';
|
|
8
|
+
|
|
9
|
+
const HELP = helpText('template-filler', [
|
|
10
|
+
'Commands (docx: {{placeholders}} in the client template; xlsx: row labels):',
|
|
11
|
+
' map --template form.docx|form.xlsx --data data.json',
|
|
12
|
+
' fill --template form.docx|form.xlsx --data data.json --out filled.docx|filled.xlsx',
|
|
13
|
+
' check --template form.docx|form.xlsx --data data.json # exit 1 if any [FILL] left',
|
|
14
|
+
'',
|
|
15
|
+
'data.json: flat map, e.g. {"daily_rate": 468.75, "Daily Fee": 468.75}.',
|
|
16
|
+
'docx values replace {{key}}; xlsx values fill the first empty cell right of a',
|
|
17
|
+
'matching row label. Formulas are never touched. fill-report.md traces every write.',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const args = parseArgs(process.argv.slice(2));
|
|
21
|
+
const cmd = args._[0];
|
|
22
|
+
if (args.__help || !cmd || !args.template) {
|
|
23
|
+
process.stdout.write(HELP);
|
|
24
|
+
process.exit(args.__help ? 0 : 1);
|
|
25
|
+
}
|
|
26
|
+
const template = args.template;
|
|
27
|
+
if (!fs.existsSync(template)) fail(`template not found: ${template}`, 2);
|
|
28
|
+
const ext = (template.split('.').pop() || '').toLowerCase();
|
|
29
|
+
const data = cmd === 'map' && !args.data ? {} : JSON.parse(readText(args.data, '--data (flat label->value map, e.g. from pricing.json rates)'));
|
|
30
|
+
const writes = [];
|
|
31
|
+
let unmapped = [];
|
|
32
|
+
|
|
33
|
+
const xmlEscape = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
34
|
+
|
|
35
|
+
// ---------------- docx ----------------
|
|
36
|
+
async function docxEntries() {
|
|
37
|
+
const AdmZip = await loadDep('adm-zip');
|
|
38
|
+
const zip = new AdmZip(fs.readFileSync(template));
|
|
39
|
+
return { zip, files: zip.getEntries().filter((e) => /word\/(document|header\d*|footer\d*)\.xml$/.test(e.entryName)) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function docxMap() {
|
|
43
|
+
const { zip, files } = await docxEntries();
|
|
44
|
+
const found = new Set();
|
|
45
|
+
const split = [];
|
|
46
|
+
for (const e of files) {
|
|
47
|
+
const xml = zip.readAsText(e);
|
|
48
|
+
for (const m of xml.matchAll(/\{\{\s*([\w.-]+)\s*\}\}/g)) found.add(m[1]);
|
|
49
|
+
for (const m of xml.matchAll(/\{\{[^}]{0,80}?<[^>]+>[^}]{0,80}?\}\}/g)) split.push(`${e.entryName}: ${m[0].slice(0, 60)}…`);
|
|
50
|
+
}
|
|
51
|
+
return { placeholders: [...found], split, files };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function docxFill(out) {
|
|
55
|
+
const { zip, files } = await docxEntries();
|
|
56
|
+
const remaining = new Set();
|
|
57
|
+
for (const e of files) {
|
|
58
|
+
let xml = zip.readAsText(e);
|
|
59
|
+
xml = xml.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (whole, key) => {
|
|
60
|
+
if (key in data) {
|
|
61
|
+
writes.push({ file: out, place: `${e.entryName} {{${key}}}`, value: data[key], trace: `data.json["${key}"]` });
|
|
62
|
+
return xmlEscape(data[key]);
|
|
63
|
+
}
|
|
64
|
+
remaining.add(key);
|
|
65
|
+
return whole;
|
|
66
|
+
});
|
|
67
|
+
zip.updateFile(e.entryName, Buffer.from(xml, 'utf8'));
|
|
68
|
+
}
|
|
69
|
+
zip.writeZip(out);
|
|
70
|
+
unmapped = [...remaining];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------- xlsx ----------------
|
|
74
|
+
async function loadWb() {
|
|
75
|
+
const mod = await loadDep('exceljs');
|
|
76
|
+
const ExcelJS = mod.default ?? mod;
|
|
77
|
+
const wb = new ExcelJS.Workbook();
|
|
78
|
+
await wb.xlsx.readFile(template);
|
|
79
|
+
return { ExcelJS, wb };
|
|
80
|
+
}
|
|
81
|
+
async function xlsxMap() {
|
|
82
|
+
const { wb } = await loadWb();
|
|
83
|
+
const labels = [];
|
|
84
|
+
wb.eachSheet((ws) => {
|
|
85
|
+
for (let r = 1; r <= Math.min(ws.rowCount || 0, 300); r++) {
|
|
86
|
+
const labelCell = ws.getRow(r).getCell(1);
|
|
87
|
+
const label = labelCell.value;
|
|
88
|
+
if (typeof label !== 'string' || !label.trim()) continue;
|
|
89
|
+
// candidate: label row with at least one empty non-label cell to the right
|
|
90
|
+
for (let c = 2; c <= 12; c++) {
|
|
91
|
+
const cell = ws.getRow(r).getCell(c);
|
|
92
|
+
if (cell.value === null || cell.value === '' || cell.value === undefined) {
|
|
93
|
+
labels.push({ sheet: ws.name, label: label.trim(), cell: cell.address });
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
if (cell.formula || typeof cell.value === 'object') break; // hit data/formula zone — not a fill row
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
return labels;
|
|
101
|
+
}
|
|
102
|
+
async function xlsxFill(out) {
|
|
103
|
+
const { wb } = await loadWb();
|
|
104
|
+
const labels = await xlsxMap();
|
|
105
|
+
const left = new Set(labels.map((l) => l.label));
|
|
106
|
+
for (const l of labels) {
|
|
107
|
+
if (!(l.label in data)) continue;
|
|
108
|
+
left.delete(l.label);
|
|
109
|
+
const cell = wb.getWorksheet(l.sheet).getCell(l.cell);
|
|
110
|
+
if (cell.formula) { fail(`refusing to overwrite formula at ${l.sheet}!${l.cell} (client's formula stays)`, 2); }
|
|
111
|
+
const v = data[l.label];
|
|
112
|
+
cell.value = typeof v === 'number' ? v : String(v);
|
|
113
|
+
writes.push({ file: out, place: `${l.sheet}!${l.cell} (label "${l.label}")`, value: v, trace: `data.json["${l.label}"]` });
|
|
114
|
+
}
|
|
115
|
+
unmapped = [...left];
|
|
116
|
+
await wb.xlsx.writeFile(out);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------- run ----------------
|
|
120
|
+
if (ext === 'docx') {
|
|
121
|
+
if (cmd === 'map') {
|
|
122
|
+
const { placeholders, split } = await docxMap();
|
|
123
|
+
process.stdout.write(`Placeholders in ${template}:\n` + (placeholders.map((p) => ` {{${p}}}${p in data ? '' : ' -> needs data key'}`).join('\n') || ' (none)') + '\n' + (split.length ? `\nSPLIT placeholders (Word broke them across formatting runs — fix in Word by retyping them as plain text, then re-run):\n` + split.map((s) => ' ' + s).join('\n') + '\n' : ''));
|
|
124
|
+
} else if (cmd === 'fill' || cmd === 'check') {
|
|
125
|
+
const out = cmd === 'fill' ? args.out || 'filled.docx' : null;
|
|
126
|
+
if (cmd === 'fill') {
|
|
127
|
+
if (!out) fail('--out required for fill', 2);
|
|
128
|
+
await docxFill(out);
|
|
129
|
+
} else {
|
|
130
|
+
const { placeholders } = await docxMap();
|
|
131
|
+
unmapped = placeholders.filter((p) => !(p in data));
|
|
132
|
+
}
|
|
133
|
+
} else fail(`unknown command: ${cmd}`, 2);
|
|
134
|
+
} else if (ext === 'xlsx' || ext === 'xlsm') {
|
|
135
|
+
if (cmd === 'map') {
|
|
136
|
+
const labels = await xlsxMap();
|
|
137
|
+
process.stdout.write(`Fillable labeled cells in ${template}:\n` + (labels.map((l) => ` "${l.label}" -> ${l.sheet}!${l.cell}${l.label in data ? '' : ' -> needs data key'}`).join('\n') || ' (none)') + '\n');
|
|
138
|
+
} else if (cmd === 'fill') {
|
|
139
|
+
if (!args.out) fail('--out required for fill', 2);
|
|
140
|
+
await xlsxFill(args.out);
|
|
141
|
+
} else if (cmd === 'check') {
|
|
142
|
+
const labels = await xlsxMap();
|
|
143
|
+
unmapped = labels.filter((l) => !(l.label in data)).map((l) => l.label);
|
|
144
|
+
} else fail(`unknown command: ${cmd}`, 2);
|
|
145
|
+
} else {
|
|
146
|
+
fail(`unsupported template type: .${ext} (docx/xlsx only — for PDF forms, request the editable original from the client)`, 2);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (cmd !== 'map') {
|
|
150
|
+
const report = `# Fill report — ${template}\n\n## Writes (${writes.length})\n` +
|
|
151
|
+
(writes.length ? `| Where | Value | Trace |\n| --- | --- | --- |\n` + writes.map((w) => `| ${w.place} | ${w.value} | ${w.trace} |`).join('\n') : '(none)') +
|
|
152
|
+
`\n\n## Unmapped ([FILL] required)\n` + (unmapped.length ? unmapped.map((u) => `- **${u}** — supply in data.json or fill by hand`).join('\n') : 'none — all mapped') +
|
|
153
|
+
`\n\nClient template structure untouched; formulas never overwritten.\n`;
|
|
154
|
+
writeOut(args.report || 'fill-report.md', report);
|
|
155
|
+
process.stderr.write(`${cmd}: ${writes.length} writes, ${unmapped.length} unmapped\n`);
|
|
156
|
+
if (cmd === 'check' && unmapped.length) {
|
|
157
|
+
process.stderr.write(`CHECK FAILED: ${unmapped.length} field(s) still need values ([FILL] list above)\n`);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
}
|