tor-to-proposal 2.0.0 → 2.0.1

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/bin/bid-pack.mjs CHANGED
@@ -12,13 +12,12 @@
12
12
  // GATES PRESERVED: pricing never runs without user numbers; GAP terms never
13
13
  // claimed; audit gates the final bytes. The orchestrator only automates plumbing.
14
14
  import fs from 'node:fs';
15
- import os from 'node:os';
16
15
  import path from 'node:path';
17
16
  import crypto from 'node:crypto';
18
17
  import { spawnSync } from 'node:child_process';
19
18
  import { fileURLToPath } from 'node:url';
20
19
  import { fail, parseArgs, readText, writeOut, helpText, mdTable } from './lib.mjs';
21
- import { loadProfile } from './profile.mjs';
20
+ import { loadProfile, profilePath } from './profile.mjs';
22
21
 
23
22
  const HELP = helpText('bid-pack', [
24
23
  'start --tor <file.(pdf|docx|txt)> [--cv <file>] [--dir bid/] [--profile] [--no-cv-tailor]',
@@ -203,7 +202,7 @@ function start() {
203
202
  run('cv-gap.mjs', ['--tor', path.join(outDir, 'tor.txt'), '--cv', cvTxt, '--out', path.join(outDir, 'cv-gap-report.md')], { label: 'cv-gap' });
204
203
  state.steps.cvGap = { status: 'done' };
205
204
  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')] : [];
205
+ const profFlag = (args.profile === undefined || args.profile === true) && loadProfile() ? ['--profile', profilePath()] : [];
207
206
  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
207
  }
209
208
  state.steps.cvTailored = { status: inDir('cv-tailored.md') ? 'done' : 'skipped' };
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,119 @@ 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
+ pipedLines = fs.readFileSync(0, 'utf8').split('\n').map((l) => l.trim());
131
+ } catch { /* empty stdin -> quit below */ }
132
+ }
133
+ let pipedPos = 0;
134
+ const nextLine = async () => (piped ? (pipedPos < pipedLines.length ? pipedLines[pipedPos++] : 'EOF') : '');
135
+
111
136
  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
- };
137
+
138
+ // Each question: [get set on the profile, label, hint, default, toValue]
118
139
  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
140
  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
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);
142
+ const expandTilde = (v) => String(v).replace(/^~(?=\/|$)/, os.homedir());
143
+ const toNum = (v) => (v === null || v === undefined || v === '' ? null : Number(v));
144
+ const questions = [
145
+ { label: 'Full name (exactly as on your CV)', get: () => id.name, set: (v) => { id.name = v; } },
146
+ { label: 'Credentials after your name (e.g. "PhD, PMP")', get: () => id.credentials, set: (v) => { id.credentials = v; } },
147
+ { label: 'Professional title (e.g. "M&E Consultant")', get: () => id.title, set: (v) => { id.title = v; } },
148
+ { label: 'Email', get: () => id.email, set: (v) => { id.email = v; } },
149
+ { label: 'Phone (international format)', get: () => id.phone, set: (v) => { id.phone = v; } },
150
+ { label: 'City, country', get: () => id.location, set: (v) => { id.location = v; } },
151
+ { label: 'Nationality (some ToRs require it)', get: () => id.nationality, set: (v) => { id.nationality = v; } },
152
+ { label: 'Working languages (comma-separated)', get: () => id.languages, set: (v) => { id.languages = v; } },
153
+ {
154
+ label: 'Path to your master CV file', get: () => current.cv.masterPath,
155
+ set: (v) => {
156
+ if (v === null || v === undefined || v === '') { current.cv.masterPath = current.cv.masterPath ?? null; return; }
157
+ const p = expandTilde(v);
158
+ if (!fs.existsSync(p)) process.stderr.write(` ! file not found (${p}) — stored anyway; fix with: profile set cv.masterPath=...\n`);
159
+ current.cv.masterPath = p;
160
+ },
161
+ },
162
+ { label: 'Minimum annual income you need (your floor math)', hint: 'e.g. 60000', get: () => fl.annualIncome, set: (v) => { fl.annualIncome = toNum(v); } },
163
+ { label: 'Realistic billable days per year', hint: 'e.g. 180', get: () => fl.billableDays, set: (v) => { fl.billableDays = toNum(v); } },
164
+ { label: 'Cost loading (overheads on top of your net)', hint: 'e.g. 0.25', get: () => fl.costLoading, set: (v) => { fl.costLoading = toNum(v); } },
165
+ { label: 'Default currency', get: () => d.currency || 'USD', set: (v) => { d.currency = String(v).toUpperCase(); } },
166
+ { label: 'Typical base rate (optional — you confirm it per bid)', hint: 'e.g. 400', get: () => d.base, set: (v) => { d.base = toNum(v); } },
167
+ { label: 'Typical loading', hint: 'e.g. 0.25', get: () => d.loading, set: (v) => { d.loading = toNum(v); } },
168
+ ];
169
+
170
+ process.stdout.write(
171
+ `Consultant profile wizard — ${questions.length} questions, one at a time.\n` +
172
+ 'Press Enter to accept the [suggested] value. Type "back" to redo the previous question, "quit" to cancel.\n\n'
173
+ );
174
+
175
+ const fmt = (v) => (v === null || v === undefined || (Array.isArray(v) && !v.length)) ? '' : String(Array.isArray(v) ? v.join(', ') : v);
176
+ const askLine = async (prompt) => {
177
+ if (piped) return nextLine();
178
+ try {
179
+ return (await rl.question(prompt)).trim();
180
+ } catch (err) {
181
+ if (err && (err.code === 'ABORT_ERR' || err.name === 'AbortError')) return 'EOF'; // Ctrl+D
182
+ throw err;
183
+ }
184
+ };
185
+ let i = 0;
186
+ while (i < questions.length) {
187
+ const q = questions[i];
188
+ const cur = fmt(q.get());
189
+ const tail = q.hint ? ` (${q.hint})` : '';
190
+ const a = await askLine(`(${i + 1}/${questions.length}) ${q.label}${tail} [${cur}]: `);
191
+ if (a === 'EOF' || a.toLowerCase() === 'quit') {
192
+ rl.close();
193
+ process.stdout.write('\nCancelled — nothing was saved.\n');
194
+ return;
195
+ }
196
+ if (a.toLowerCase() === 'back') {
197
+ if (i === 0) { process.stdout.write(' (already at the first question)\n'); continue; }
198
+ i -= 1;
199
+ continue;
200
+ }
201
+ // blank = keep the current/suggested value shown in brackets
202
+ q.set(a === '' ? q.get() : a);
203
+ i += 1;
204
+ }
205
+
206
+ // Summary + confirm before anything is written
207
+ const fields = [
208
+ ['name', id.name], ['credentials', id.credentials], ['title', id.title], ['email', id.email],
209
+ ['phone', id.phone], ['location', id.location], ['nationality', id.nationality], ['languages', id.languages],
210
+ ['master CV', current.cv.masterPath],
211
+ ['min annual income', fl.annualIncome], ['billable days/year', fl.billableDays], ['cost loading', fl.costLoading],
212
+ ['currency', d.currency], ['typical base rate', d.base], ['typical loading', d.loading],
213
+ ];
214
+ process.stdout.write('\n--- Your answers ---\n');
215
+ for (const [k, v] of fields) process.stdout.write(` ${k.padEnd(20)} ${fmt(v) || '(empty)'}\n`);
216
+ const confirm = await askLine('\nSave this profile? (Y/n): ');
148
217
  rl.close();
218
+ if (confirm === 'EOF' || confirm === 'n' || confirm === 'no') {
219
+ process.stdout.write('Cancelled — nothing was saved.\n');
220
+ return;
221
+ }
149
222
  save(current);
150
223
  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'
224
+ `\nProfile saved: ${profilePath()}\n(it lives in the CURRENT folder — open it with: open .tor-to-proposal)\n\n` +
225
+ 'Next: drop a ToR + your CV into a folder and tell your AI assistant:\n "Run a bid pack on <folder>"\n' +
226
+ 'Your saved numbers will be offered for one-keystroke confirmation — never used without it.\n'
153
227
  );
154
228
  }
155
229
 
@@ -182,17 +256,20 @@ if (cmd === 'init') {
182
256
  save(profile);
183
257
  } else if (cmd === 'get') {
184
258
  const profile = loadProfile();
185
- if (!profile) fail(`no profile at ${FILE} — run: tor-to-proposal init`, 2);
259
+ if (!profile) fail(`no profile at ${profilePath()} — run: tor-to-proposal init`, 2);
186
260
  if (args._[1]) {
187
261
  const v = getDeep(profile, args._[1]);
188
262
  if (v === undefined) fail(`unknown key: ${args._[1]}`, 2);
189
263
  process.stdout.write(JSON.stringify(v, null, 2) + '\n');
190
264
  } else process.stdout.write(JSON.stringify(profile, null, 2) + '\n');
191
265
  } else if (cmd === 'path') {
192
- process.stdout.write(FILE + '\n');
266
+ process.stdout.write(profilePath() + '\n');
193
267
  } 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');
268
+ let erased = false;
269
+ for (const file of [profilePath(), LEGACY_FILE]) {
270
+ if (fs.existsSync(file)) { fs.rmSync(file); process.stderr.write(`erased: ${file}\n`); erased = true; }
271
+ }
272
+ if (!erased) process.stderr.write('no profile to erase\n');
196
273
  } else {
197
274
  fail(`unknown command: ${cmd} (init | set | get | path | erase)`, 2);
198
275
  }
@@ -40,7 +40,7 @@ const DESCRIPTIONS = {
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
42
  'bid-pack': 'one-shot orchestrator: start | apply | pack + the consolidated questionnaire',
43
- profile: 'consultant profile wizard (~/.tor-to-proposal/profile.json)',
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
 
@@ -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
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.0.1' },
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.0.1",
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": {