@timurtekb/tekjobs 0.37.1 → 0.38.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.
@@ -8,7 +8,7 @@
8
8
  <link rel="icon" href="/brand/tekjobs-avatar-192.png" type="image/png" sizes="192x192" />
9
9
  <link rel="apple-touch-icon" href="/brand/tekjobs-avatar-192.png" />
10
10
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@500;600&family=Hanken+Grotesk:wght@400;500;600&family=Azeret+Mono:wght@400;500&display=swap" data-zengin="fonts" />
11
- <script type="module" crossorigin src="/assets/index-C77iGKNO.js"></script>
11
+ <script type="module" crossorigin src="/assets/index-tl8a2c_d.js"></script>
12
12
  <link rel="stylesheet" crossorigin href="/assets/index-CR3ppGJT.css">
13
13
  </head>
14
14
  <body>
@@ -48,9 +48,9 @@ export const TOOLS = [
48
48
  { name: 'set_criteria', description: 'Replace the criteria JSON block. Pass the full JSON as a string; it is validated before writing.', inputSchema: { type: 'object', properties: { raw: { type: 'string' } }, required: ['raw'] } },
49
49
  { name: 'list_companies', description: 'The company watchlist rows (name, ats, slug, tier, status) with each board\'s health: state (failed, zero, stale, never, ok), last success, last attempt, last error.', inputSchema: { type: 'object', properties: {} } },
50
50
  { name: 'list_feeds', description: 'The aggregator feeds: whether each is on under the criteria\'s openSources, and how it did on the last scan.', inputSchema: { type: 'object', properties: {} } },
51
- { name: 'onboarding_status', description: 'What the onboarding still needs: profile folder, resume, profile note, criteria, first scan. Call this first in a new setup.', inputSchema: { type: 'object', properties: {} } },
51
+ { name: 'onboarding_status', description: 'What the onboarding still needs, in two groups: `steps` for search ready (profile folder, resume, profile note, criteria, first scan; `complete` when all done) and `writing` for writing ready (resume of record note, Positioning, Voice; `writingReady` when all done). Call this first in a new setup, and once more at the end to report both.', inputSchema: { type: 'object', properties: {} } },
52
52
  { name: 'onboarding_materials', description: 'Start the onboarding interview. Returns the resume text, the current profile note, the current criteria JSON, and the interview script to follow step by step. Follow the script; it ends with save_profile, set_criteria and a scan.', inputSchema: { type: 'object', properties: {} } },
53
- { name: 'save_profile', description: 'Write Profile/Profile.md in full (markdown with the standard headings). The previous version is kept beside it.', inputSchema: { type: 'object', properties: { markdown: { type: 'string' } }, required: ['markdown'] } },
53
+ { name: 'save_profile', description: 'Write one profile note in full: Profile/Profile.md by default (markdown with the standard headings), or note=positioning for Profile/Positioning.md (which story leads, the evidence rule, what must never be claimed) or note=voice for Profile/Voice.md (writing rules and one letter they would send). The previous version is kept beside it.', inputSchema: { type: 'object', properties: { markdown: { type: 'string' }, note: { type: 'string', enum: ['profile', 'positioning', 'voice'], description: 'which note; default profile' } }, required: ['markdown'] } },
54
54
  { name: 'fetch_link', description: 'Fetch a public page the user gave you (portfolio, GitHub, personal site) as plain text, to fold into the profile. http(s) only; never use it on LinkedIn profile pages.', inputSchema: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } },
55
55
  { name: 'import_resume', description: 'Import a resume file from a local path (PDF, DOCX, Markdown, text): copies it into Profile/ and extracts its text for the interview.', inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] } },
56
56
  { name: 'add_job', description: 'Add postings the person found themselves, from links (LinkedIn job pages, a company careers page, a Greenhouse/Lever/Ashby posting). Each link is read once, scored with the current criteria, and written as a job note unless the scan already has it or a note with the same company and title exists. A LinkedIn link is opened as a signed-out visitor; if it names the company\'s own apply page on a known board, that page is imported and the LinkedIn link is recorded as where it was seen. Returns one result per link. Up to 25 links.', inputSchema: { type: 'object', properties: { urls: { type: 'array', items: { type: 'string' } } }, required: ['urls'] } },
@@ -63,7 +63,7 @@ export async function call(name, a = {}) {
63
63
  switch (name) {
64
64
  case 'onboarding_status': return store.onboardingStatus();
65
65
  case 'onboarding_materials': return store.onboardingMaterials();
66
- case 'save_profile': return store.saveProfile(a.markdown);
66
+ case 'save_profile': return store.saveProfile(a.markdown, undefined, { note: a.note || 'profile' });
67
67
  case 'fetch_link': return store.fetchLink(a.url);
68
68
  case 'import_resume': return store.importResume(a.path);
69
69
  case 'search_jobs': { const r = store.searchJobs({ q: a.q, location: a.location || '', status: a.status, band: a.band, kind: a.kind, source: a.source, company: a.company, payMin: a.payMin, payMax: a.payMax, payKnown: a.payKnown === true, postedDays: a.postedDays, foundDays: a.foundDays, maxScore: a.maxScore, remoteOnly: a.remote === true, minScore: a.minScore, limit: a.limit || 50 }); return { total: r.total, rows: r.rows.map((j) => ({ id: j.id, score: j.score, company: j.company, title: j.title, location: j.location, pay: j.salary, band: j.payBand, status: j.status, posted: j.posted, url: j.url })) }; }
@@ -1079,7 +1079,8 @@ export function profileSummary() {
1079
1079
  { label: 'Resume files', state: resumes.files.length ? `${resumes.files.length} file${resumes.files.length === 1 ? '' : 's'}${resumes.files.some((f) => f.current) ? ', one is the source' : ''}` : 'no folder yet', level: resumes.files.length ? 'ok' : 'info', where: resumes.dir || '' },
1080
1080
  docState('Profile/Positioning.md', 'Positioning'),
1081
1081
  docState('Profile/Voice.md', 'Voice'),
1082
- docState('Profile/Snippets.md', 'Snippets (copy panel)'),
1082
+ // The copy panel works from defaults drawn from the profile until the note exists, so its absence is information, not a gap.
1083
+ (() => { const d = docState('Profile/Snippets.md', 'Snippets (copy panel)'); return d.level === 'missing' ? { ...d, level: 'info', state: 'defaults from the profile until you edit it' } : d; })(),
1083
1084
  ];
1084
1085
  // What would trip a reader.
1085
1086
  const attention = [];
package/cli.mjs CHANGED
@@ -208,8 +208,13 @@ async function main() {
208
208
  }
209
209
  function printStatus(s) {
210
210
  console.log(`\nOnboarding for ${s.dir}:`);
211
+ console.log(' Search');
211
212
  for (const st of s.steps) console.log(` ${st.done ? '✓' : '·'} ${st.label}${st.done ? '' : ` → ${st.how}`}`);
212
- console.log(s.complete ? '\nAll set. The daily scan takes it from here.' : '\nNext: the first unchecked line above.');
213
+ console.log(' Application writing');
214
+ for (const st of s.writing || []) console.log(` ${st.done ? '✓' : '·'} ${st.label}${st.done ? '' : ` → ${st.how}`}`);
215
+ const search = s.complete ? 'Search ready: the daily scan takes it from here.' : 'Search: not yet; the first unchecked line above is next.';
216
+ const writing = s.writingReady ? 'Writing ready: drafts read the resume of record, Positioning and Voice.' : 'Writing: not yet; drafts fall back to the imported resume text until the notes above exist.';
217
+ console.log(`\n${search}\n${writing}`);
213
218
  }
214
219
  function run(bin, args) { const p = spawn(bin, args, { stdio: 'inherit', env: process.env }); p.on('close', (c) => process.exit(c ?? 0)); }
215
220
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timurtekb/tekjobs",
3
- "version": "0.37.1",
3
+ "version": "0.38.1",
4
4
  "description": "A local-first job-search machine: watches hundreds of company job boards, scores every posting against your profile, and files matches into a folder of markdown notes you own. Bring your own LLM through MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/run.mjs CHANGED
@@ -195,7 +195,7 @@ const logLines = [
195
195
  `- Criteria: ${criteriaLabel}`,
196
196
  // Who started it: the morning task (run.cmd / run.sh), the app, an agent over MCP, or someone at the CLI.
197
197
  `- Via: ${process.env.TEKJOBS_RUN_VIA || 'cli'}`,
198
- ...extras.map(([n, r]) => `- ${n}: ${r.ok ? `${r.jobs.length} postings${r.thread ? ` (${r.thread})` : ''}` : `failed: ${r.error}`}`),
198
+ ...extras.map(([n, r]) => `- ${n}: ${r.skipped ? `skipped (${r.skipped})` : r.ok ? `${r.jobs.length} postings${r.thread ? ` (${r.thread})` : ''}` : `failed: ${r.error}`}`),
199
199
  ...(failed.length ? [`- Failed slugs: ${failed.map((f) => `${f.name} (${f.ats}:${f.slug} — ${f.error})`).join('; ')}`] : []),
200
200
  ...(written.length ? ['', '### New matches', ...written.map(({ job }) => `- **${job.scored.score}** ${job.company} — [${job.title}](${job.url}) · ${job.location || 'n/a'}`)] : []),
201
201
  ...(closed.length ? ['', '### Closed since last run', ...closed.map((c) => `- ${c.company} — ${c.title}`)] : []),
@@ -19,10 +19,12 @@ const require_dir = (file) => file.replace(/[\\/][^\\/]*$/, '');
19
19
  /** Fold one fetch result into a source's record. `r` is the fetcher's own answer: ok, jobs, error, emptyBoard. */
20
20
  export function record(prev = {}, r, now = new Date().toISOString()) {
21
21
  const jobs = r.ok ? r.jobs.length : null;
22
+ // Skipped (an optional source with nothing to read): the attempt is noted, nothing else moves.
23
+ if (r.skipped) return { ...prev, lastAttempt: now, lastError: '', failStreak: 0, skipped: r.skipped };
22
24
  if (!r.ok) return { ...prev, lastAttempt: now, lastError: r.error || 'failed', failStreak: (prev.failStreak || 0) + 1 };
23
25
  return {
24
26
  ...prev,
25
- lastAttempt: now, lastOk: now, lastError: '', failStreak: 0,
27
+ lastAttempt: now, lastOk: now, lastError: '', failStreak: 0, skipped: '',
26
28
  lastJobs: jobs, lastOkJobs: jobs,
27
29
  zeroStreak: jobs === 0 ? (prev.zeroStreak || 0) + 1 : 0,
28
30
  everJobs: Math.max(prev.everJobs || 0, jobs),
@@ -42,6 +44,7 @@ export function healthState(h, statusCell = '', now = Date.now()) {
42
44
  return statusCell ? 'ok' : 'never';
43
45
  }
44
46
  if (h.lastError && (!h.lastOk || h.lastAttempt >= h.lastOk)) return 'failed';
47
+ if (h.skipped && (!h.lastOk || h.lastAttempt > h.lastOk)) return 'never';
45
48
  if (!h.lastOk || now - Date.parse(h.lastOk) > STALE_DAYS * 864e5) return 'stale';
46
49
  if (h.lastOkJobs === 0) return 'zero';
47
50
  return 'ok';
@@ -3,7 +3,8 @@
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { P, VAULT, rememberProfileDir, loadCriteria } from './config.mjs';
6
+ import { P, VAULT, rememberProfileDir, loadCriteria, profileSettings, writeProfileSettings } from './config.mjs';
7
+ import { RESUME_NOTE, LEGACY_RESUME_NOTES } from './resume-sync.mjs';
7
8
  import { extractText } from './resume.mjs';
8
9
 
9
10
  const STARTER = fileURLToPath(new URL('./starter/', import.meta.url));
@@ -59,7 +60,20 @@ export async function importResume(file, dir = VAULT) {
59
60
  if (path.resolve(file) !== path.resolve(dest)) fs.copyFileSync(file, dest);
60
61
  const md = `---\ntype: resume-source\nimported: ${today()}\noriginal: ${JSON.stringify(path.basename(file))}\n---\n# Resume, as imported\n\n> Text extracted from the original by \`tekjobs resume\`. The interview reads this; the polished resume drafts live in their own notes.\n\n${text}\n`;
61
62
  fs.writeFileSync(path.join(profileDir, 'Resume - Source.md'), md);
62
- return { original: dest, source: path.join(profileDir, 'Resume - Source.md'), chars: text.length };
63
+ // The same import establishes the resume of record (Profile/Resume.md) when the profile has none, in the shape
64
+ // `tekjobs resume sync` writes, and remembers the imported file as the source so a later sync re-reads it. A
65
+ // record that already exists is left alone: replacing it is what `resume sync <file>` is for, and it says so.
66
+ const recordRel = [RESUME_NOTE, ...LEGACY_RESUME_NOTES].find((rel) => fs.existsSync(path.join(dir, rel)));
67
+ let record = recordRel || '';
68
+ let recordCreated = false;
69
+ if (!recordRel) {
70
+ const note = ['---', 'type: resume', 'status: current', `source: ${JSON.stringify(dest)}`, 'source_kind: file', `synced: ${today()}`, '---',
71
+ `> **Resume of record, created from the import on ${today()} by \`tekjobs resume\`.** Application drafts read this note and only claim what it says. To replace it, import again with \`tekjobs resume sync <file>\` or pick a file on the Profile page.`, '', text.trim(), ''].join('\n');
72
+ fs.writeFileSync(path.join(dir, RESUME_NOTE), note);
73
+ record = RESUME_NOTE; recordCreated = true;
74
+ }
75
+ if (path.resolve(dir) === VAULT && !profileSettings().resumeSource) writeProfileSettings({ resumeSource: dest });
76
+ return { original: dest, source: path.join(profileDir, 'Resume - Source.md'), chars: text.length, record: path.join(dir, record), recordCreated };
63
77
  }
64
78
 
65
79
  /** What the onboarding still needs. Drives the CLI, the app's Onboarding screen and the MCP status tool. */
@@ -71,6 +85,8 @@ export function onboardingStatus(dir = VAULT) {
71
85
  let criteria = null; let criteriaFilled = false;
72
86
  try { criteria = loadCriteria(); criteriaFilled = Object.keys(criteria.titleTerms || {}).length > 0; } catch { /* absent or invalid */ }
73
87
  const jobs = exists('Jobs') ? fs.readdirSync(path.join(dir, 'Jobs')).filter((f) => f.endsWith('.md')).length : 0;
88
+ // Two contracts. "Search ready" is the five steps the scan needs; "writing ready" is what the application drafts
89
+ // need on top, so a person is never told the setup is complete and then shown a Profile page full of warnings.
74
90
  const steps = [
75
91
  { id: 'folder', label: 'Profile folder exists', done: exists('Targets/Search Criteria.md') && exists('Targets/Companies.md'), how: 'tekjobs init [dir]' },
76
92
  { id: 'resume', label: 'Resume imported', done: exists('Profile/Resume - Source.md') || (exists('Profile') && fs.readdirSync(path.join(dir, 'Profile')).some((f) => /^Resume.*\.md$/i.test(f))), how: 'tekjobs resume <file.pdf|docx|md>' },
@@ -78,7 +94,12 @@ export function onboardingStatus(dir = VAULT) {
78
94
  { id: 'criteria', label: 'Search criteria filled (title terms set)', done: criteriaFilled, how: 'The interview writes them; or edit Targets/Search Criteria.md.' },
79
95
  { id: 'scan', label: 'First scan has run', done: jobs > 0, how: 'tekjobs scan, or the Runs screen.' },
80
96
  ];
81
- return { dir, steps, complete: steps.every((s) => s.done), jobs, profilePath: path.join(dir, 'Profile', 'Profile.md') };
97
+ const writing = [
98
+ { id: 'record', label: 'Resume of record note (Profile/Resume.md)', done: [RESUME_NOTE, ...LEGACY_RESUME_NOTES].some(exists), how: 'tekjobs resume <file> creates it from the import; tekjobs resume sync <url|file> replaces it later.' },
99
+ { id: 'positioning', label: 'Positioning note: which story leads, the evidence rule, what is never claimed', done: exists('Profile/Positioning.md'), how: 'The interview writes it (save_profile with note=positioning); or write Profile/Positioning.md yourself.' },
100
+ { id: 'voice', label: 'Voice note: how you write, with one letter you would send', done: exists('Profile/Voice.md'), how: 'The interview writes it (save_profile with note=voice); or write Profile/Voice.md yourself.' },
101
+ ];
102
+ return { dir, steps, complete: steps.every((s) => s.done), writing, writingReady: writing.every((s) => s.done), jobs, profilePath: path.join(dir, 'Profile', 'Profile.md') };
82
103
  }
83
104
 
84
105
  /** Everything the interviewing LLM needs in one call: resume text, current notes, and the script to follow. */
@@ -95,16 +116,24 @@ export function onboardingMaterials(dir = VAULT) {
95
116
  };
96
117
  }
97
118
 
98
- export function saveProfile(markdown, dir = VAULT) {
99
- if (!markdown || markdown.trim().length < 100) throw Object.assign(new Error('Profile is too short to save; write the full note.'), { status: 400 });
100
- let md = markdown.replace(/^status: draft$/m, 'status: interviewed');
101
- if (!/^---\n/.test(md)) md = `---\ntype: profile\nupdated: ${today()}\nstatus: interviewed\n---\n${md}`;
119
+ const PROFILE_NOTES = {
120
+ profile: { file: 'Profile.md', type: 'profile', min: 100 },
121
+ positioning: { file: 'Positioning.md', type: 'positioning', min: 80 },
122
+ voice: { file: 'Voice.md', type: 'voice', min: 60 },
123
+ };
124
+ /** Write one of the profile notes in full: Profile.md by default, or Positioning.md / Voice.md with `note`. The previous version is kept beside it. */
125
+ export function saveProfile(markdown, dir = VAULT, { note = 'profile' } = {}) {
126
+ const spec = PROFILE_NOTES[note];
127
+ if (!spec) throw Object.assign(new Error(`note must be one of ${Object.keys(PROFILE_NOTES).join(', ')}`), { status: 400 });
128
+ if (!markdown || markdown.trim().length < spec.min) throw Object.assign(new Error(`${spec.file} is too short to save; write the full note.`), { status: 400 });
129
+ let md = note === 'profile' ? markdown.replace(/^status: draft$/m, 'status: interviewed') : markdown;
130
+ if (!/^---\n/.test(md)) md = `---\ntype: ${spec.type}\nupdated: ${today()}${note === 'profile' ? '\nstatus: interviewed' : ''}\n---\n${md}`;
102
131
  md = md.replace(/^updated: .*$/m, `updated: ${today()}`);
103
- const file = path.join(dir, 'Profile', 'Profile.md');
132
+ const file = path.join(dir, 'Profile', spec.file);
104
133
  fs.mkdirSync(path.dirname(file), { recursive: true });
105
- if (fs.existsSync(file)) fs.copyFileSync(file, path.join(dir, 'Profile', `Profile.before-${today()}.md`));
134
+ if (fs.existsSync(file)) fs.copyFileSync(file, path.join(dir, 'Profile', `${spec.file.replace(/\.md$/, '')}.before-${today()}.md`));
106
135
  fs.writeFileSync(file, md);
107
- return { saved: file };
136
+ return { saved: file, note };
108
137
  }
109
138
 
110
139
  /** Fetch a public page (portfolio, GitHub, LinkedIn export is a file, not a URL) as text for the interview to summarize. */
@@ -132,7 +161,9 @@ const INTERVIEW_SCRIPT = `You are onboarding a job seeker into TekJobs. Goal: wr
132
161
  - Company stage or size preference, if any.
133
162
  - Links: portfolio, GitHub, personal site. Fetch each with fetch_link and fold what you learn into the profile. LinkedIn: ask for their data-export ZIP contents or a pasted summary; never scrape a LinkedIn page.
134
163
  - Earliest start date.
164
+ - How they write: two or three rules in their own words, and words they refuse to use. And anything that must never be claimed about them (a title they did not hold, work they did not do).
135
165
  3. Write Profile/Profile.md using the existing note's headings (Basics · What you are, in three sentences · Target roles table with tiers A/B · Constraints & preferences · Proof points · Documents). Proof points are one line each, with numbers. Call save_profile with the full markdown.
166
+ 3b. Write the two notes the application drafts read, from the same answers, and save each with save_profile: note=positioning for Profile/Positioning.md (headings: Which story leads · The evidence rule · What must never be claimed; the evidence rule is that every claim traces to a line of the resume), and note=voice for Profile/Voice.md (headings: Rules · A letter I would send; the letter is under 120 words, in their voice, using only facts from the resume). Show both to the user before saving and change what they object to.
136
167
  4. Build the criteria from the current JSON (keep every key). Set:
137
168
  - titleTerms: exact lowercase substrings that appear in real job titles for the target roles, weighted 40 for exact-fit titles down to ~20 for adjacent ones. Include common variants ("front-end", "frontend", "front end").
138
169
  - titleExclude: add the user's hard exclusions as lowercase substrings (titles and words in titles; abbreviations like "sr." and "dir." are spelled out before matching).
@@ -142,7 +173,7 @@ const INTERVIEW_SCRIPT = `You are onboarding a job seeker into TekJobs. Goal: wr
142
173
  - location.requireRemote true only if the user said remote only; add their metro to bayAreaTerms with bayAreaBoost 12 if hybrid there is acceptable.
143
174
  Call set_criteria with the full JSON string. It is validated before writing.
144
175
  5. Call run_scan with dry=true and poll scan_status every few seconds until running is false (two to three minutes; the scan runs on its own, so if this session ends first, the next one can pick up with scan_status). If you cannot poll between turns, pass wait=240 to run_scan and it answers when the scan is done. A dry run writes no notes, so do not use search_jobs yet: call scan_preview limit=15 and show the user the top matches with score, pay band and one reason each. Ask whether the list looks right. Adjust the criteria once if it does not (set_criteria, then another dry run_scan and scan_preview). When it does, call run_scan with dry=false and poll scan_status until it finishes; that run writes the notes, and search_jobs and the app's Today page work from then on.
145
- 6. Finish by telling the user where things live: Profile/Profile.md, Targets/Search Criteria.md, Jobs/. Remind them the daily scan runs on its own from here.`;
176
+ 6. Finish by telling the user where things live: Profile/Profile.md, Positioning.md, Voice.md, Targets/Search Criteria.md, Jobs/. Call onboarding_status once more and say plainly which of the two it reports: search ready, and writing ready. Remind them the daily scan runs on its own from here.`;
146
177
 
147
178
  function criteriaNote(json) {
148
179
  return `---
@@ -196,7 +227,9 @@ function readme(dir) {
196
227
  This folder is your job search: profile, resume, criteria, watchlist, one note per matched job, and logs. It is plain markdown. Open it in Obsidian or any editor; the TekJobs app and its MCP server read and write the same files.
197
228
 
198
229
  - \`Profile/Profile.md\` — who you are and what you want (written by the onboarding interview)
199
- - \`Profile/Resume - Source.md\` — your resume as text
230
+ - \`Profile/Resume - Source.md\` — your resume as text, as imported
231
+ - \`Profile/Resume.md\` — the resume of record: what every draft is allowed to claim (created by the import; \`tekjobs resume sync\` replaces it)
232
+ - \`Profile/Positioning.md\` and \`Profile/Voice.md\` — how the story is told and how you write; the interview writes them
200
233
  - \`Targets/Search Criteria.md\` — how postings are scored (JSON block)
201
234
  - \`Targets/Companies.md\` — which boards are watched
202
235
  - \`Jobs/\` — one note per match; change \`status:\` to move it through the pipeline
@@ -219,11 +219,13 @@ export function jobsFromEmail(raw, file = '') {
219
219
  export async function fetchEmailInbox(criteria = {}) {
220
220
  const cfg = criteria.email || {};
221
221
  const dir = cfg.dir || (criteria._inbox ?? '');
222
- if (!dir) return { ok: false, jobs: [], error: 'no inbox folder configured' };
223
- if (!fs.existsSync(dir)) return { ok: false, jobs: [], error: `no folder at ${dir} — create it and save alert emails into it as .eml` };
222
+ // The inbox is optional. Nothing to read is a skip, not a failure: a first run with an empty Inbox/ is healthy,
223
+ // and a healthy run must not be reported as partial.
224
+ if (!dir) return { ok: true, jobs: [], skipped: 'no inbox folder configured' };
225
+ if (!fs.existsSync(dir)) return { ok: true, jobs: [], skipped: `no Inbox folder yet (${dir}); save alert emails into it as .eml to use it` };
224
226
 
225
227
  const files = fs.readdirSync(dir).filter((f) => /\.(eml|txt|mht|mhtml)$/i.test(f));
226
- if (!files.length) return { ok: false, jobs: [], error: `no .eml files in ${dir}` };
228
+ if (!files.length) return { ok: true, jobs: [], skipped: `no .eml files in ${dir}` };
227
229
 
228
230
  const all = [];
229
231
  const failures = [];
@@ -237,6 +239,7 @@ export async function fetchEmailInbox(criteria = {}) {
237
239
  }
238
240
  const byUrl = new Map();
239
241
  for (const j of all) if (!byUrl.has(j.url)) byUrl.set(j.url, j);
240
- if (!byUrl.size) return { ok: false, jobs: [], error: failures.length ? `no jobs found; ${failures[0]}` : `no job links found in ${files.length} message(s)` };
242
+ if (!byUrl.size && failures.length) return { ok: false, jobs: [], error: `no jobs found; ${failures[0]}` };
243
+ if (!byUrl.size) return { ok: true, jobs: [], skipped: `no job links found in ${files.length} message(s)` };
241
244
  return { ok: true, jobs: [...byUrl.values()] };
242
245
  }