tor-to-proposal 2.0.0 → 2.1.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/README.md +3 -1
- package/SKILL.md +7 -3
- package/bin/bid-pack.mjs +115 -6
- package/bin/profile.mjs +126 -48
- package/bin/tor-to-proposal.mjs +8 -4
- package/docs/TECHNICAL-GUIDE.md +2 -2
- package/docs/USER-GUIDE.md +79 -0
- package/mcp/server.mjs +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,7 +28,9 @@ npx tor-to-proposal init
|
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
That sets up your consultant profile (name, credentials, rates, master CV) at
|
|
31
|
-
|
|
31
|
+
`.tor-to-proposal/profile.json` in the **current folder** (the legacy
|
|
32
|
+
`~/.tor-to-proposal/profile.json` is still read if no local profile exists).
|
|
33
|
+
Then use it with any AI assistant (Claude
|
|
32
34
|
Desktop / Claude Code / Codex / ChatGPT) via the MCP server at
|
|
33
35
|
`mcp/server.mjs`, or install the agent skill:
|
|
34
36
|
|
package/SKILL.md
CHANGED
|
@@ -82,9 +82,13 @@ Go/no-go is THEIR call. If no-go: stop and say so.
|
|
|
82
82
|
### Phase 2 — The ONE questionnaire (no scattered questions, ever)
|
|
83
83
|
|
|
84
84
|
`out/questions.md` consolidates every open decision: pricing, availability,
|
|
85
|
-
validity, draft `[FILL]`s, CV evidence.
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
validity, draft `[FILL]`s, CV evidence. Two routes, in order of preference:
|
|
86
|
+
|
|
87
|
+
1. **Interactive wizard (non-technical users):** `node bin/bid-pack.mjs ask
|
|
88
|
+
--dir <bid-dir>` — asks each open question in the terminal, saves
|
|
89
|
+
answers.json and applies it. Run again; it only asks what is still open.
|
|
90
|
+
2. **Assistant-mediated:** put all questions to the user in one message,
|
|
91
|
+
never answer one for the user, then:
|
|
88
92
|
|
|
89
93
|
```bash
|
|
90
94
|
# write the answers verbatim:
|
package/bin/bid-pack.mjs
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
//
|
|
6
6
|
// start --tor <file> [--cv <file>] [--dir bid/] extract + screen + cv-gap
|
|
7
7
|
// + cv-tailor build + profile import -> questions.json/questions.md
|
|
8
|
+
// ask [--dir bid/] interactive wizard: answers
|
|
9
|
+
// the open questions one at a time in the terminal, writes answers.json
|
|
10
|
+
// and applies them (no editor or JSON knowledge needed)
|
|
8
11
|
// apply --answers answers.json [--dir bid/] store answers, run pricing,
|
|
9
12
|
// record evidence + fill answers, regenerate the open-question list
|
|
10
13
|
// pack [--dir bid/] [--fallback-pdf] [--no-zip] verify + audit + zip (-> package.mjs)
|
|
@@ -12,29 +15,33 @@
|
|
|
12
15
|
// GATES PRESERVED: pricing never runs without user numbers; GAP terms never
|
|
13
16
|
// claimed; audit gates the final bytes. The orchestrator only automates plumbing.
|
|
14
17
|
import fs from 'node:fs';
|
|
15
|
-
import os from 'node:os';
|
|
16
18
|
import path from 'node:path';
|
|
17
19
|
import crypto from 'node:crypto';
|
|
20
|
+
import readline from 'node:readline/promises';
|
|
18
21
|
import { spawnSync } from 'node:child_process';
|
|
19
22
|
import { fileURLToPath } from 'node:url';
|
|
20
23
|
import { fail, parseArgs, readText, writeOut, helpText, mdTable } from './lib.mjs';
|
|
21
|
-
import { loadProfile } from './profile.mjs';
|
|
24
|
+
import { loadProfile, profilePath } from './profile.mjs';
|
|
22
25
|
|
|
23
26
|
const HELP = helpText('bid-pack', [
|
|
24
27
|
'start --tor <file.(pdf|docx|txt)> [--cv <file>] [--dir bid/] [--profile] [--no-cv-tailor]',
|
|
28
|
+
'ask [--dir bid/]',
|
|
25
29
|
'apply --answers answers.json [--dir bid/]',
|
|
26
30
|
'pack [--dir bid/] [--fallback-pdf] [--no-zip]',
|
|
27
31
|
'',
|
|
28
32
|
'start runs every mechanical step (pdf-extract -> extract -> cv-gap -> cv-tailor)',
|
|
29
33
|
'and consolidates ALL open questions into out/questions.md. Answer them in one go,',
|
|
30
34
|
'have your assistant write answers.json, then apply. Repeat apply until 0 open.',
|
|
35
|
+
'ask is the human-friendly route: an interactive wizard that asks each open',
|
|
36
|
+
'question in the terminal (Enter = suggested default, "back" to redo, "quit" to',
|
|
37
|
+
'cancel), saves answers.json and applies it — no editor or JSON needed.',
|
|
31
38
|
'pack verifies the final documents (re-extract + re-audit) and builds the zip.',
|
|
32
39
|
]);
|
|
33
40
|
|
|
34
41
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
35
42
|
const args = parseArgs(process.argv.slice(2));
|
|
36
43
|
const cmd = args._[0];
|
|
37
|
-
if (args.__help || !['start', 'apply', 'pack'].includes(cmd)) {
|
|
44
|
+
if (args.__help || !['start', 'ask', 'apply', 'pack'].includes(cmd)) {
|
|
38
45
|
process.stdout.write(HELP);
|
|
39
46
|
process.exit(args.__help ? 0 : 1);
|
|
40
47
|
}
|
|
@@ -145,6 +152,21 @@ function writeQuestions(q) {
|
|
|
145
152
|
writeOut(path.join(outDir, 'questions.md'), md);
|
|
146
153
|
}
|
|
147
154
|
|
|
155
|
+
// Human-readable PDF companions for the working files a non-technical user is
|
|
156
|
+
// most likely to be pointed at (regenerated after start/apply; the .md stays
|
|
157
|
+
// the source of truth — the PDFs are for reading, not editing).
|
|
158
|
+
function renderPreviews() {
|
|
159
|
+
for (const [file, label] of [
|
|
160
|
+
['bid-screen.md', 'Bid-Screen'],
|
|
161
|
+
['questions.md', 'Questions'],
|
|
162
|
+
['cv-gap-report.md', 'CV-Gap-Report'],
|
|
163
|
+
['cv-tailor-report.md', 'CV-Tailor-Report'],
|
|
164
|
+
]) {
|
|
165
|
+
if (!inDir(file)) continue;
|
|
166
|
+
run('render.mjs', [path.join(outDir, file), '--out', path.join(outDir, 'preview', `${label}.pdf`), '--footer', `preview of out/${file} — the .md file is the source of truth`], { label: `render preview ${label}` });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
148
170
|
// ---------------- context ----------------
|
|
149
171
|
function writeContext() {
|
|
150
172
|
const extractPath = inDir('tor-extract.json');
|
|
@@ -203,7 +225,7 @@ function start() {
|
|
|
203
225
|
run('cv-gap.mjs', ['--tor', path.join(outDir, 'tor.txt'), '--cv', cvTxt, '--out', path.join(outDir, 'cv-gap-report.md')], { label: 'cv-gap' });
|
|
204
226
|
state.steps.cvGap = { status: 'done' };
|
|
205
227
|
if (!inDir('cv-tailored.md') && !args['no-cv-tailor']) {
|
|
206
|
-
const profFlag = (args.profile === undefined || args.profile === true) &&
|
|
228
|
+
const profFlag = (args.profile === undefined || args.profile === true) && loadProfile() ? ['--profile', profilePath()] : [];
|
|
207
229
|
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
230
|
}
|
|
209
231
|
state.steps.cvTailored = { status: inDir('cv-tailored.md') ? 'done' : 'skipped' };
|
|
@@ -215,6 +237,7 @@ function start() {
|
|
|
215
237
|
writeQuestions(q);
|
|
216
238
|
state.steps.questions = { status: 'done', open: q.openCount };
|
|
217
239
|
writeState(state);
|
|
240
|
+
renderPreviews();
|
|
218
241
|
|
|
219
242
|
// report
|
|
220
243
|
const extract = JSON.parse(fs.readFileSync(path.join(outDir, 'tor-extract.json'), 'utf8'));
|
|
@@ -227,7 +250,8 @@ function start() {
|
|
|
227
250
|
`- bid-screen.md — go/no-go decision (the USER decides; read it together)\n` +
|
|
228
251
|
`- compliance-matrix.md, tor-extract.json\n` +
|
|
229
252
|
(args.cv ? `- cv-gap-report.md, cv-tailored.md (+ report)\n` : '') +
|
|
230
|
-
`- questions.json / questions.md — **${q.items.length} open question(s)**\n
|
|
253
|
+
`- questions.json / questions.md — **${q.items.length} open question(s)**\n` +
|
|
254
|
+
`- preview/ — readable PDFs of the bid screen + questionnaire (just double-click)\n\n` +
|
|
231
255
|
`## Next\n1. Review bid-screen.md with the user. No-go -> stop here.\n` +
|
|
232
256
|
`2. Put every open question to the user IN ONE MESSAGE (questions.md has them).\n` +
|
|
233
257
|
`3. Write answers.json {"Q-...": value, ...} -> bid-pack apply --answers answers.json --dir ${dir}\n` +
|
|
@@ -239,7 +263,11 @@ function start() {
|
|
|
239
263
|
|
|
240
264
|
// ---------------- apply ----------------
|
|
241
265
|
function apply() {
|
|
242
|
-
if (!args.answers) {
|
|
266
|
+
if (!args.answers) {
|
|
267
|
+
process.stdout.write(HELP);
|
|
268
|
+
process.stdout.write(`\nTip: run "bid-pack ask --dir ${dir}" to answer the open questions interactively in this terminal — no JSON file needed.\n`);
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
243
271
|
const answersPath = path.resolve(String(args.answers));
|
|
244
272
|
const answers = JSON.parse(readText(answersPath, '--answers'));
|
|
245
273
|
const qPath = inDir('questions.json');
|
|
@@ -327,6 +355,7 @@ function apply() {
|
|
|
327
355
|
state.steps.questions = { status: 'done', open: fresh.openCount };
|
|
328
356
|
state.steps.pricing = { status: inDir('pricing.json') ? 'done' : 'awaiting-user-numbers' };
|
|
329
357
|
writeState(state);
|
|
358
|
+
renderPreviews();
|
|
330
359
|
|
|
331
360
|
process.stdout.write(
|
|
332
361
|
`# Apply: ${applied} answer(s) recorded, ${fresh.openCount} still open\n\n` +
|
|
@@ -336,6 +365,85 @@ function apply() {
|
|
|
336
365
|
);
|
|
337
366
|
}
|
|
338
367
|
|
|
368
|
+
// ---------------- ask (interactive question wizard) ----------------
|
|
369
|
+
async function askWizard() {
|
|
370
|
+
const qPath = inDir('questions.json');
|
|
371
|
+
if (!qPath) fail(`no questions.json in ${dir}/out — run: bid-pack start --tor <file> --dir ${dir}`, 2);
|
|
372
|
+
const q = JSON.parse(fs.readFileSync(qPath, 'utf8'));
|
|
373
|
+
const yours = q.items.filter((i) => i.type !== 'client');
|
|
374
|
+
const clients = q.items.filter((i) => i.type === 'client');
|
|
375
|
+
if (!yours.length) {
|
|
376
|
+
process.stdout.write(
|
|
377
|
+
(clients.length
|
|
378
|
+
? `Nothing for you to answer here — but send these to the CLIENT before the cutoff:\n${clients.map((c) => `- ${c.question}`).join('\n')}\n`
|
|
379
|
+
: 'No open questions — everything is already resolved.\n') +
|
|
380
|
+
(inDir('pricing.json') ? `\nNext: bid-pack pack --dir ${dir} [--fallback-pdf]\n` : '')
|
|
381
|
+
);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Same terminal behavior as the profile wizard: interactive when run by a
|
|
386
|
+
// human; piped lines are consumed in order (automation/tests).
|
|
387
|
+
const piped = !process.stdin.isTTY;
|
|
388
|
+
let pipedLines = [];
|
|
389
|
+
if (piped) {
|
|
390
|
+
try {
|
|
391
|
+
const raw = fs.readFileSync(0, 'utf8');
|
|
392
|
+
pipedLines = raw.trim() ? raw.split('\n').map((l) => l.trim()) : [];
|
|
393
|
+
} catch { /* empty stdin */ }
|
|
394
|
+
}
|
|
395
|
+
let pipedPos = 0;
|
|
396
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
397
|
+
const askLine = async (prompt) => {
|
|
398
|
+
if (piped) return pipedPos < pipedLines.length ? pipedLines[pipedPos++] : 'EOF';
|
|
399
|
+
try {
|
|
400
|
+
return (await rl.question(prompt)).trim();
|
|
401
|
+
} catch (err) {
|
|
402
|
+
if (err && (err.code === 'ABORT_ERR' || err.name === 'AbortError')) return 'EOF';
|
|
403
|
+
throw err;
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
process.stdout.write(
|
|
408
|
+
`Bid questions — ${yours.length} to answer, one at a time.\n` +
|
|
409
|
+
'Press Enter to accept the [suggested] value when one is shown. Type "back" to redo the previous question, "quit" to cancel.\n\n'
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
const answers = {};
|
|
413
|
+
let i = 0;
|
|
414
|
+
while (i < yours.length) {
|
|
415
|
+
const item = yours[i];
|
|
416
|
+
const cur = item.default !== undefined && item.default !== null ? String(item.default) : '';
|
|
417
|
+
const tail = item.unit ? ` (${item.unit})` : '';
|
|
418
|
+
const a = await askLine(`(${i + 1}/${yours.length}) ${item.question}${tail}${cur ? ` [${cur}]` : ''}\n> `);
|
|
419
|
+
if (a === 'EOF' || a.toLowerCase() === 'quit') {
|
|
420
|
+
rl.close();
|
|
421
|
+
process.stdout.write('\nCancelled — nothing was saved. (Your documents are untouched; run bid-pack ask again anytime.)\n');
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (a.toLowerCase() === 'back') {
|
|
425
|
+
if (i === 0) { process.stdout.write(' (already at the first question)\n'); continue; }
|
|
426
|
+
const prev = yours[i - 1];
|
|
427
|
+
delete answers[prev.id];
|
|
428
|
+
i -= 1;
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (a === '' && cur === '') {
|
|
432
|
+
process.stdout.write(' This one needs an answer from you (no suggested default). Type it, or "quit" to stop.\n');
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
answers[item.id] = a === '' ? item.default : a;
|
|
436
|
+
i += 1;
|
|
437
|
+
}
|
|
438
|
+
rl.close();
|
|
439
|
+
|
|
440
|
+
const answersPath = path.join(dir, 'answers.json');
|
|
441
|
+
fs.writeFileSync(answersPath, JSON.stringify(answers, null, 2));
|
|
442
|
+
process.stdout.write(`\nSaved your ${Object.keys(answers).length} answer(s) to ${answersPath}\nApplying them now...\n\n`);
|
|
443
|
+
args.answers = answersPath;
|
|
444
|
+
apply();
|
|
445
|
+
}
|
|
446
|
+
|
|
339
447
|
// ---------------- pack ----------------
|
|
340
448
|
function pack() {
|
|
341
449
|
const passThrough = ['--dir', dir, ...(args['fallback-pdf'] ? ['--fallback-pdf'] : []), ...(args['no-zip'] ? ['--no-zip'] : []), ...(args.profile ? ['--profile', String(args.profile)] : [])];
|
|
@@ -344,5 +452,6 @@ function pack() {
|
|
|
344
452
|
}
|
|
345
453
|
|
|
346
454
|
if (cmd === 'start') start();
|
|
455
|
+
else if (cmd === 'ask') await askWizard();
|
|
347
456
|
else if (cmd === 'apply') apply();
|
|
348
457
|
else pack();
|
package/bin/profile.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// profile: the one-time consultant profile ->
|
|
2
|
+
// profile: the one-time consultant profile -> .tor-to-proposal/profile.json
|
|
3
|
+
// (in the CURRENT folder, so users can see and back it up like any project file;
|
|
4
|
+
// the legacy ~/.tor-to-proposal/profile.json is still read as a fallback).
|
|
3
5
|
// Every value here is USER-SUPPLIED. The profile is the canonical "user input"
|
|
4
6
|
// trace source downstream tools cite; nothing in it is ever invented or defaulted
|
|
5
7
|
// silently. Plug-and-play: `npx tor-to-proposal init` runs the wizard once, and
|
|
@@ -11,9 +13,9 @@ import readline from 'node:readline/promises';
|
|
|
11
13
|
import { fail, parseArgs, helpText } from './lib.mjs';
|
|
12
14
|
|
|
13
15
|
const HELP = helpText('profile', [
|
|
14
|
-
'One-time consultant profile, stored at
|
|
16
|
+
'One-time consultant profile, stored at .tor-to-proposal/profile.json in the CURRENT folder.',
|
|
15
17
|
'',
|
|
16
|
-
' init interactive wizard (terminal prompts; blank = keep current)',
|
|
18
|
+
' init interactive wizard (terminal prompts; blank = default/keep current)',
|
|
17
19
|
' set key=value ... set fields (dotted keys). Values parse as JSON when valid:',
|
|
18
20
|
' profile set identity.name="Ayesha Khan" rates.defaults.base=400 cv.masterPath=~/cv/master.md',
|
|
19
21
|
' get [key] print the whole profile (or one dotted field) as JSON',
|
|
@@ -27,7 +29,12 @@ const HELP = helpText('profile', [
|
|
|
27
29
|
' cv.masterPath # your master CV',
|
|
28
30
|
]);
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
// Profile lives in the current folder (cwd-first); the legacy home location is
|
|
33
|
+
// only read as a fallback so profiles created by older versions keep working.
|
|
34
|
+
export function profilePath() {
|
|
35
|
+
return path.resolve('.tor-to-proposal', 'profile.json');
|
|
36
|
+
}
|
|
37
|
+
const LEGACY_FILE = path.join(os.homedir(), '.tor-to-proposal', 'profile.json');
|
|
31
38
|
|
|
32
39
|
const NUMERIC = {
|
|
33
40
|
'rates.floor.annualIncome': { min: 0, max: 1e9, label: 'annual income need' },
|
|
@@ -39,14 +46,18 @@ const NUMERIC = {
|
|
|
39
46
|
};
|
|
40
47
|
const BASIS = ['hour', 'day', 'week', 'month'];
|
|
41
48
|
|
|
42
|
-
|
|
49
|
+
function readProfileAt(file) {
|
|
43
50
|
try {
|
|
44
|
-
return JSON.parse(fs.readFileSync(
|
|
51
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
45
52
|
} catch {
|
|
46
53
|
return null;
|
|
47
54
|
}
|
|
48
55
|
}
|
|
49
56
|
|
|
57
|
+
export function loadProfile() {
|
|
58
|
+
return readProfileAt(profilePath()) || readProfileAt(LEGACY_FILE);
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
function emptyProfile() {
|
|
51
62
|
const now = new Date().toISOString();
|
|
52
63
|
return {
|
|
@@ -100,56 +111,120 @@ function coerceAndValidate(dotted, raw) {
|
|
|
100
111
|
|
|
101
112
|
function save(profile) {
|
|
102
113
|
profile.meta = { ...(profile.meta || {}), updatedAt: new Date().toISOString() };
|
|
103
|
-
|
|
104
|
-
fs.
|
|
105
|
-
|
|
114
|
+
const file = profilePath();
|
|
115
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
116
|
+
fs.writeFileSync(file, JSON.stringify(profile, null, 2));
|
|
117
|
+
process.stderr.write(`written: ${file}\n`);
|
|
106
118
|
}
|
|
107
119
|
|
|
108
120
|
// ---------- interactive wizard ----------
|
|
109
121
|
async function wizard() {
|
|
110
122
|
const current = loadProfile() || emptyProfile();
|
|
123
|
+
|
|
124
|
+
// Answers come from a real terminal when interactive; when stdin is piped
|
|
125
|
+
// (automation, tests), all lines are read up front and consumed in order.
|
|
126
|
+
const piped = !process.stdin.isTTY;
|
|
127
|
+
let pipedLines = [];
|
|
128
|
+
if (piped) {
|
|
129
|
+
try {
|
|
130
|
+
const raw = fs.readFileSync(0, 'utf8');
|
|
131
|
+
pipedLines = raw.trim() ? raw.split('\n').map((l) => l.trim()) : [];
|
|
132
|
+
} catch { /* empty stdin -> quit below */ }
|
|
133
|
+
}
|
|
134
|
+
let pipedPos = 0;
|
|
135
|
+
const nextLine = async () => (piped ? (pipedPos < pipedLines.length ? pipedLines[pipedPos++] : 'EOF') : '');
|
|
136
|
+
|
|
111
137
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
};
|
|
138
|
+
|
|
139
|
+
// Each question: [get set on the profile, label, hint, default, toValue]
|
|
118
140
|
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
141
|
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
142
|
const d = current.rates.defaults;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
143
|
+
const expandTilde = (v) => String(v).replace(/^~(?=\/|$)/, os.homedir());
|
|
144
|
+
const toNum = (v) => (v === null || v === undefined || v === '' ? null : Number(v));
|
|
145
|
+
const questions = [
|
|
146
|
+
{ label: 'Full name (exactly as on your CV)', get: () => id.name, set: (v) => { id.name = v; } },
|
|
147
|
+
{ label: 'Credentials after your name (e.g. "PhD, PMP")', get: () => id.credentials, set: (v) => { id.credentials = v; } },
|
|
148
|
+
{ label: 'Professional title (e.g. "M&E Consultant")', get: () => id.title, set: (v) => { id.title = v; } },
|
|
149
|
+
{ label: 'Email', get: () => id.email, set: (v) => { id.email = v; } },
|
|
150
|
+
{ label: 'Phone (international format)', get: () => id.phone, set: (v) => { id.phone = v; } },
|
|
151
|
+
{ label: 'City, country', get: () => id.location, set: (v) => { id.location = v; } },
|
|
152
|
+
{ label: 'Nationality (some ToRs require it)', get: () => id.nationality, set: (v) => { id.nationality = v; } },
|
|
153
|
+
{ label: 'Working languages (comma-separated)', get: () => id.languages, set: (v) => { id.languages = v; } },
|
|
154
|
+
{
|
|
155
|
+
label: 'Path to your master CV file', get: () => current.cv.masterPath,
|
|
156
|
+
set: (v) => {
|
|
157
|
+
if (v === null || v === undefined || v === '') { current.cv.masterPath = current.cv.masterPath ?? null; return; }
|
|
158
|
+
const p = expandTilde(v);
|
|
159
|
+
if (!fs.existsSync(p)) process.stderr.write(` ! file not found (${p}) — stored anyway; fix with: profile set cv.masterPath=...\n`);
|
|
160
|
+
current.cv.masterPath = p;
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{ label: 'Minimum annual income you need (your floor math)', hint: 'e.g. 60000', get: () => fl.annualIncome, set: (v) => { fl.annualIncome = toNum(v); } },
|
|
164
|
+
{ label: 'Realistic billable days per year', hint: 'e.g. 180', get: () => fl.billableDays, set: (v) => { fl.billableDays = toNum(v); } },
|
|
165
|
+
{ label: 'Cost loading (overheads on top of your net)', hint: 'e.g. 0.25', get: () => fl.costLoading, set: (v) => { fl.costLoading = toNum(v); } },
|
|
166
|
+
{ label: 'Default currency', get: () => d.currency || 'USD', set: (v) => { d.currency = String(v).toUpperCase(); } },
|
|
167
|
+
{ label: 'Typical base rate (optional — you confirm it per bid)', hint: 'e.g. 400', get: () => d.base, set: (v) => { d.base = toNum(v); } },
|
|
168
|
+
{ label: 'Typical loading', hint: 'e.g. 0.25', get: () => d.loading, set: (v) => { d.loading = toNum(v); } },
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
process.stdout.write(
|
|
172
|
+
`Consultant profile wizard — ${questions.length} questions, one at a time.\n` +
|
|
173
|
+
'Press Enter to accept the [suggested] value. Type "back" to redo the previous question, "quit" to cancel.\n\n'
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const fmt = (v) => (v === null || v === undefined || (Array.isArray(v) && !v.length)) ? '' : String(Array.isArray(v) ? v.join(', ') : v);
|
|
177
|
+
const askLine = async (prompt) => {
|
|
178
|
+
if (piped) return nextLine();
|
|
179
|
+
try {
|
|
180
|
+
return (await rl.question(prompt)).trim();
|
|
181
|
+
} catch (err) {
|
|
182
|
+
if (err && (err.code === 'ABORT_ERR' || err.name === 'AbortError')) return 'EOF'; // Ctrl+D
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
let i = 0;
|
|
187
|
+
while (i < questions.length) {
|
|
188
|
+
const q = questions[i];
|
|
189
|
+
const cur = fmt(q.get());
|
|
190
|
+
const tail = q.hint ? ` (${q.hint})` : '';
|
|
191
|
+
const a = await askLine(`(${i + 1}/${questions.length}) ${q.label}${tail} [${cur}]: `);
|
|
192
|
+
if (a === 'EOF' || a.toLowerCase() === 'quit') {
|
|
193
|
+
rl.close();
|
|
194
|
+
process.stdout.write('\nCancelled — nothing was saved.\n');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (a.toLowerCase() === 'back') {
|
|
198
|
+
if (i === 0) { process.stdout.write(' (already at the first question)\n'); continue; }
|
|
199
|
+
i -= 1;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
// blank = keep the current/suggested value shown in brackets
|
|
203
|
+
q.set(a === '' ? q.get() : a);
|
|
204
|
+
i += 1;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Summary + confirm before anything is written
|
|
208
|
+
const fields = [
|
|
209
|
+
['name', id.name], ['credentials', id.credentials], ['title', id.title], ['email', id.email],
|
|
210
|
+
['phone', id.phone], ['location', id.location], ['nationality', id.nationality], ['languages', id.languages],
|
|
211
|
+
['master CV', current.cv.masterPath],
|
|
212
|
+
['min annual income', fl.annualIncome], ['billable days/year', fl.billableDays], ['cost loading', fl.costLoading],
|
|
213
|
+
['currency', d.currency], ['typical base rate', d.base], ['typical loading', d.loading],
|
|
214
|
+
];
|
|
215
|
+
process.stdout.write('\n--- Your answers ---\n');
|
|
216
|
+
for (const [k, v] of fields) process.stdout.write(` ${k.padEnd(20)} ${fmt(v) || '(empty)'}\n`);
|
|
217
|
+
const confirm = await askLine('\nSave this profile? (Y/n): ');
|
|
148
218
|
rl.close();
|
|
219
|
+
if (confirm === 'EOF' || confirm === 'n' || confirm === 'no') {
|
|
220
|
+
process.stdout.write('Cancelled — nothing was saved.\n');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
149
223
|
save(current);
|
|
150
224
|
process.stdout.write(
|
|
151
|
-
`\nProfile saved: ${
|
|
152
|
-
|
|
225
|
+
`\nProfile saved: ${profilePath()}\n(it lives in the CURRENT folder — open it with: open .tor-to-proposal)\n\n` +
|
|
226
|
+
'Next: drop a ToR + your CV into a folder and tell your AI assistant:\n "Run a bid pack on <folder>"\n' +
|
|
227
|
+
'Your saved numbers will be offered for one-keystroke confirmation — never used without it.\n'
|
|
153
228
|
);
|
|
154
229
|
}
|
|
155
230
|
|
|
@@ -182,17 +257,20 @@ if (cmd === 'init') {
|
|
|
182
257
|
save(profile);
|
|
183
258
|
} else if (cmd === 'get') {
|
|
184
259
|
const profile = loadProfile();
|
|
185
|
-
if (!profile) fail(`no profile at ${
|
|
260
|
+
if (!profile) fail(`no profile at ${profilePath()} — run: tor-to-proposal init`, 2);
|
|
186
261
|
if (args._[1]) {
|
|
187
262
|
const v = getDeep(profile, args._[1]);
|
|
188
263
|
if (v === undefined) fail(`unknown key: ${args._[1]}`, 2);
|
|
189
264
|
process.stdout.write(JSON.stringify(v, null, 2) + '\n');
|
|
190
265
|
} else process.stdout.write(JSON.stringify(profile, null, 2) + '\n');
|
|
191
266
|
} else if (cmd === 'path') {
|
|
192
|
-
process.stdout.write(
|
|
267
|
+
process.stdout.write(profilePath() + '\n');
|
|
193
268
|
} else if (cmd === 'erase') {
|
|
194
|
-
|
|
195
|
-
|
|
269
|
+
let erased = false;
|
|
270
|
+
for (const file of [profilePath(), LEGACY_FILE]) {
|
|
271
|
+
if (fs.existsSync(file)) { fs.rmSync(file); process.stderr.write(`erased: ${file}\n`); erased = true; }
|
|
272
|
+
}
|
|
273
|
+
if (!erased) process.stderr.write('no profile to erase\n');
|
|
196
274
|
} else {
|
|
197
275
|
fail(`unknown command: ${cmd} (init | set | get | path | erase)`, 2);
|
|
198
276
|
}
|
package/bin/tor-to-proposal.mjs
CHANGED
|
@@ -39,14 +39,14 @@ const DESCRIPTIONS = {
|
|
|
39
39
|
audit: 'pre-submission hard-fail gate',
|
|
40
40
|
render: 'fallback markdown -> PDF emitter (no document tooling needed)',
|
|
41
41
|
package: 'verify final docs (re-extract + re-audit) -> pack/ + checklist + zip',
|
|
42
|
-
'bid-pack': 'one-shot orchestrator: start | apply | pack + the consolidated questionnaire',
|
|
43
|
-
profile: 'consultant profile wizard (
|
|
42
|
+
'bid-pack': 'one-shot orchestrator: start | ask | apply | pack + the consolidated questionnaire',
|
|
43
|
+
profile: 'consultant profile wizard (.tor-to-proposal/profile.json in the current folder)',
|
|
44
44
|
init: 'alias for profile — one-time setup wizard',
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
48
48
|
if (!cmd || cmd === 'help' || cmd === '--help') {
|
|
49
|
-
process.stdout.write(`tor-to-proposal — the bid tool that refuses to lie.\n\nUsage: tor-to-proposal <command> [options]\n\nQuick start (plug and play):\n tor-to-proposal init one-time profile + setup wizard\n tor-to-proposal bid-pack start --tor tor.pdf --cv cv.txt --dir mybid/\n tor-to-proposal bid-pack
|
|
49
|
+
process.stdout.write(`tor-to-proposal — the bid tool that refuses to lie.\n\nUsage: tor-to-proposal <command> [options]\n\nQuick start (plug and play):\n tor-to-proposal init one-time profile + setup wizard\n tor-to-proposal bid-pack start --tor tor.pdf --cv cv.txt --dir mybid/\n tor-to-proposal bid-pack ask --dir mybid/ answer open questions interactively\n tor-to-proposal bid-pack pack --dir mybid/ --fallback-pdf\n\nCommands:\n${Object.keys(TOOLS).map((k) => ` ${k.padEnd(19)} ${DESCRIPTIONS[k]}`).join('\n')}\n\nPer-command help: tor-to-proposal <command> --help\nAgent workflow: see SKILL.md\n`);
|
|
50
50
|
process.exit(cmd ? 0 : 1);
|
|
51
51
|
}
|
|
52
52
|
if (!TOOLS[cmd]) {
|
|
@@ -59,5 +59,9 @@ if (!TOOLS[cmd]) {
|
|
|
59
59
|
process.exit(1);
|
|
60
60
|
}
|
|
61
61
|
const script = path.join(path.dirname(fileURLToPath(import.meta.url)), TOOLS[cmd]);
|
|
62
|
-
|
|
62
|
+
// `init` is an alias for `profile init` — without the subcommand profile.mjs
|
|
63
|
+
// would only print its help (this is why plain `tor-to-proposal init` never
|
|
64
|
+
// started the wizard before).
|
|
65
|
+
const sub = cmd === 'init' ? ['init', ...rest] : rest;
|
|
66
|
+
const res = spawnSync(process.execPath, [script, ...sub], { stdio: 'inherit' });
|
|
63
67
|
process.exit(res.status ?? 1);
|
package/docs/TECHNICAL-GUIDE.md
CHANGED
|
@@ -57,7 +57,7 @@ formula overwrite) · **3** dependencies missing.
|
|
|
57
57
|
| `bid-pack start` | `--tor file [--cv file] [--dir bid/] [--no-cv-tailor]` | runs pdf-extract → extract → cv-gap → cv-tailor; `out/questions.{json,md}` (consolidated questionnaire), `out/bid-state.json`, `out/bid-context.json` |
|
|
58
58
|
| `bid-pack apply` | `--answers answers.json [--dir bid/]` | records answers; runs pricing + financial proposal on user numbers; `out/cv-evidence.json`, `out/fill-answers.json`, `out/clarifications.md`; regenerates questions |
|
|
59
59
|
| `bid-pack pack` | `--dir bid/ [--fallback-pdf] [--no-zip]` | delegates to `package` |
|
|
60
|
-
| `profile` (`init`) | `init \| set k=v… \| get [k] \| path \| erase` | `~/.tor-to-proposal/profile.json` — user-supplied identity, floor math, rate defaults, master-CV path |
|
|
60
|
+
| `profile` (`init`) | `init \| set k=v… \| get [k] \| path \| erase` | `.tor-to-proposal/profile.json` in the current folder (legacy `~/.tor-to-proposal/profile.json` read as fallback) — user-supplied identity, floor math, rate defaults, master-CV path |
|
|
61
61
|
| `cv-tailor build` | `--cv master.txt --extract tor-extract.json [--profile p.json] [--gap r.md]` | `cv-tailored.md` (reordered master-CV material only) + `cv-tailor-report.md` (trace + terminology mirror) |
|
|
62
62
|
| `cv-tailor lint` | `--cv tailored.md --master master.txt [--gap] [--evidence cv-evidence.json]` | anchor table; exit 1 on unanchored bullets, altered numbers, GAP terms |
|
|
63
63
|
| `financial-proposal` | `--pricing pricing.json --extract tor-extract.json [--context bid-context.json] [--xlsx]` | `financial-proposal.md` (+ `.xlsx` with visible formulas: `D8=B8*C8`, `D10=SUM`) |
|
|
@@ -111,7 +111,7 @@ tor-extract criteria exactly.
|
|
|
111
111
|
|
|
112
112
|
**v2 bid-pack contracts** (all under `<bid-dir>/out/`):
|
|
113
113
|
|
|
114
|
-
-
|
|
114
|
+
- **`.tor-to-proposal/profile.json` (in the current folder)** — `{identity{name,credentials,…},
|
|
115
115
|
rates{floor{annualIncome,billableDays,costLoading},
|
|
116
116
|
defaults{base,basis,currency,loading,contingency}}, cv{masterPath}}`.
|
|
117
117
|
Every value user-supplied (`profile set` validates numbers/ranges and the
|
package/docs/USER-GUIDE.md
CHANGED
|
@@ -193,6 +193,82 @@ you will ever own.
|
|
|
193
193
|
|
|
194
194
|
---
|
|
195
195
|
|
|
196
|
+
## 6b. How to answer the questions (`out/questions.md` → `answers.json`)
|
|
197
|
+
|
|
198
|
+
After `bid-pack start`, the tool writes **`out/questions.md`** — a numbered
|
|
199
|
+
table of everything it cannot decide for you (your price confirmation, effort
|
|
200
|
+
days, availability date, and every `[FILL]` placeholder it found).
|
|
201
|
+
|
|
202
|
+
**You do not edit questions.md itself.** You answer in one of three ways:
|
|
203
|
+
|
|
204
|
+
### Way 1 (easiest, no editor needed): the terminal wizard
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
npx tor-to-proposal bid-pack ask --dir .
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
It asks every open question **one at a time, right in the Terminal** — exactly
|
|
211
|
+
like the `init` wizard. Press Enter to accept a suggested value, type `back`
|
|
212
|
+
to redo the previous question, `quit` to stop. At the end it saves your
|
|
213
|
+
answers and applies them automatically. Run it again anytime; it only asks
|
|
214
|
+
what is still open.
|
|
215
|
+
|
|
216
|
+
### Way 2: reply to your AI assistant
|
|
217
|
+
questions.md ends with the exact instruction. Just type your numbered answers
|
|
218
|
+
in the chat, e.g.:
|
|
219
|
+
|
|
220
|
+
```
|
|
221
|
+
1. yes
|
|
222
|
+
2. 25 person-days
|
|
223
|
+
3. 1 November 2026
|
|
224
|
+
4. 90 days from submission
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
The assistant writes them into `answers.json` for you and runs
|
|
228
|
+
`bid-pack apply`.
|
|
229
|
+
|
|
230
|
+
### Way 3 (by hand): write answers.json yourself
|
|
231
|
+
Create a file called `answers.json` **in your bid folder** (next to
|
|
232
|
+
`tor.pdf`). It is a simple JSON object: **question ID → your answer**.
|
|
233
|
+
Copy the IDs exactly from `out/questions.json` or the **bold** codes in
|
|
234
|
+
questions.md. A complete sample:
|
|
235
|
+
|
|
236
|
+
```json
|
|
237
|
+
{
|
|
238
|
+
"Q-PRICING-CONFIRM": "yes",
|
|
239
|
+
"Q-EFFORT-DAYS": "25 person-days",
|
|
240
|
+
"Q-AVAILABILITY": "1 November 2026",
|
|
241
|
+
"Q-VALIDITY": "90 days from submission",
|
|
242
|
+
"Q-FILL-TAXES": "Prices exclude VAT; consultant is registered with FBR.",
|
|
243
|
+
"Q-FILL-REIMBURSABLES": "Travel and interpretation costs are reimbursable at cost against receipts.",
|
|
244
|
+
"Q-CVE-1": "Led three UNDP M&E assignments (2022–2025) as team lead.",
|
|
245
|
+
"Q-CLAR-1": "Requested the final evaluation grid by email on 3 Oct 2026."
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
What the special IDs mean:
|
|
250
|
+
|
|
251
|
+
| ID pattern | Meaning | What to answer |
|
|
252
|
+
| --- | --- | --- |
|
|
253
|
+
| `Q-PRICING-CONFIRM` | Your saved profile rate offered for this bid | `yes` to use it, or new numbers: `"base 420 loading 0.25 contingency 0.1"` |
|
|
254
|
+
| `Q-EFFORT-DAYS` | Person-days you will quote | e.g. `"25 person-days"` |
|
|
255
|
+
| `Q-AVAILABILITY` | When you can start | any honest date |
|
|
256
|
+
| `Q-VALIDITY` | How long your offer stays valid | e.g. `"90 days from submission"` |
|
|
257
|
+
| `Q-FILL-...` | A `[FILL]` box in a draft document | the real fact that belongs there |
|
|
258
|
+
| `Q-CVE-n` | A CV line needs proof (or removal) | the true fact, or `"delete"` |
|
|
259
|
+
| `Q-CLAR-n` | Something to ask the CLIENT | what you asked them and when |
|
|
260
|
+
|
|
261
|
+
Then run:
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
npx tor-to-proposal bid-pack apply --answers answers.json --dir .
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
`apply` re-prints the questionnaire — repeat until it says **0 open
|
|
268
|
+
questions**, then run `bid-pack pack`.
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
196
272
|
## 7. If something goes wrong
|
|
197
273
|
|
|
198
274
|
| What you see | What it means | What to do |
|
|
@@ -222,6 +298,9 @@ you will ever own.
|
|
|
222
298
|
- **Master CV** — your full, real CV. The tailored one is derived from it and
|
|
223
299
|
verified against it.
|
|
224
300
|
- **`[FILL]`** — a yellow flag meaning "a real fact goes here — yours".
|
|
301
|
+
- **`out/preview/`** — readable PDF copies of the bid screen, the questions
|
|
302
|
+
and the CV reports, regenerated after every `start` and `apply`. Double-click
|
|
303
|
+
to read them; the `.md` originals are the working files the tool edits.
|
|
225
304
|
- **Floor rate** — the lowest daily rate that still pays your bills. Below it,
|
|
226
305
|
you're paying the client.
|
|
227
306
|
- **MCP / skill / plugin** — three plumbing formats for connecting the tool to
|
package/mcp/server.mjs
CHANGED
|
@@ -183,7 +183,7 @@ const TOOLS = [
|
|
|
183
183
|
{
|
|
184
184
|
name: 'profile_set',
|
|
185
185
|
description:
|
|
186
|
-
'One-time consultant profile (stored at ~/.tor-to-proposal/profile.json). Set dotted fields: identity.name, identity.credentials, identity.email, identity.phone, identity.title, identity.location, identity.nationality, identity.languages (comma string), rates.floor.annualIncome, rates.floor.billableDays, rates.floor.costLoading, rates.defaults.base/basis/loading/contingency/currency, cv.masterPath. Every value is USER-SUPPLIED — never guess one.',
|
|
186
|
+
'One-time consultant profile (stored at .tor-to-proposal/profile.json in the current working folder; legacy ~/.tor-to-proposal/profile.json is read as fallback). Set dotted fields: identity.name, identity.credentials, identity.email, identity.phone, identity.title, identity.location, identity.nationality, identity.languages (comma string), rates.floor.annualIncome, rates.floor.billableDays, rates.floor.costLoading, rates.defaults.base/basis/loading/contingency/currency, cv.masterPath. Every value is USER-SUPPLIED — never guess one.',
|
|
187
187
|
inputSchema: S({
|
|
188
188
|
pairs: { type: 'array', items: { type: 'string' }, description: 'key=value pairs, e.g. "identity.name=Ayesha Khan", "rates.defaults.base=400"' },
|
|
189
189
|
}, ['pairs']),
|
|
@@ -322,7 +322,7 @@ function handle(msg) {
|
|
|
322
322
|
return {
|
|
323
323
|
protocolVersion: typeof params?.protocolVersion === 'string' ? params.protocolVersion : '2024-11-05',
|
|
324
324
|
capabilities: { tools: {} },
|
|
325
|
-
serverInfo: { name: 'tor-to-proposal', version: '2.
|
|
325
|
+
serverInfo: { name: 'tor-to-proposal', version: '2.1.0' },
|
|
326
326
|
};
|
|
327
327
|
}
|
|
328
328
|
if (typeof method === 'string' && method.startsWith('notifications/')) return undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tor-to-proposal",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "The bid tool that refuses to lie. ToR + CV in -> verified bid pack out: cover letter, tailored CV, technical response, financial proposal, submission checklist. Deterministic CLI + agent skill for UN / NGO / government consultants.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|