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 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
- `~/.tor-to-proposal/profile.json`. Then use it with any AI assistant (Claude
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. **Put all of them to the user in one
86
- message**, grouped. Never drip-feed questions and never answer one for the
87
- user. Then:
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) && fs.existsSync(path.join(os.homedir(), '.tor-to-proposal', 'profile.json')) ? ['--profile', path.join(os.homedir(), '.tor-to-proposal', 'profile.json')] : [];
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\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) { process.stdout.write(HELP); process.exit(1); }
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 -> ~/.tor-to-proposal/profile.json
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 ~/.tor-to-proposal/profile.json.',
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
- const FILE = path.join(os.homedir(), '.tor-to-proposal', 'profile.json');
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
- export function loadProfile() {
49
+ function readProfileAt(file) {
43
50
  try {
44
- return JSON.parse(fs.readFileSync(FILE, 'utf8'));
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
- fs.mkdirSync(path.dirname(FILE), { recursive: true });
104
- fs.writeFileSync(FILE, JSON.stringify(profile, null, 2));
105
- process.stderr.write(`written: ${FILE}\n`);
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
- 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
- };
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
- 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);
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: ${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'
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 ${FILE} — run: tor-to-proposal init`, 2);
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(FILE + '\n');
267
+ process.stdout.write(profilePath() + '\n');
193
268
  } 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');
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
  }
@@ -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 (~/.tor-to-proposal/profile.json)',
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 apply --answers answers.json --dir mybid/\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`);
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
- const res = spawnSync(process.execPath, [script, ...rest], { stdio: 'inherit' });
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);
@@ -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
- - **`~/.tor-to-proposal/profile.json`** — `{identity{name,credentials,…},
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
@@ -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.0.0' },
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.0.0",
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": {