@timurtekb/tekjobs 0.24.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/LICENSE +21 -0
  3. package/README.md +145 -0
  4. package/app/README.md +22 -0
  5. package/app/dist/assets/index-DzGgV6NF.css +1 -0
  6. package/app/dist/assets/index-QteuouKv.js +77 -0
  7. package/app/dist/favicon.svg +1 -0
  8. package/app/dist/index.html +16 -0
  9. package/app/server/cover-letter.mjs +243 -0
  10. package/app/server/index.mjs +113 -0
  11. package/app/server/mail-check.mjs +234 -0
  12. package/app/server/mcp.mjs +129 -0
  13. package/app/server/people.mjs +164 -0
  14. package/app/server/store.mjs +1131 -0
  15. package/app/server/tailored-resume.mjs +213 -0
  16. package/cli.mjs +205 -0
  17. package/package.json +74 -0
  18. package/run.cmd +10 -0
  19. package/run.mjs +201 -0
  20. package/run.sh +12 -0
  21. package/samples/vault/Jobs/Basalt Systems - Senior Design Engineer, Growth (1010).md +56 -0
  22. package/samples/vault/Jobs/Brightline Studio - Design Engineer (1005).md +51 -0
  23. package/samples/vault/Jobs/Copperleaf - Principal Design Engineer (1006).md +53 -0
  24. package/samples/vault/Jobs/Example Studio - Senior Design Engineer (1013).md +52 -0
  25. package/samples/vault/Jobs/Fjord Analytics - Staff Frontend Engineer, Platform (1003).md +58 -0
  26. package/samples/vault/Jobs/Halcyon Robotics - Senior UX Engineer (1004).md +52 -0
  27. package/samples/vault/Jobs/Lumen Health - Design Systems Engineer (1002).md +51 -0
  28. package/samples/vault/Jobs/Meridian Pay - Staff Design Engineer (1009).md +67 -0
  29. package/samples/vault/Jobs/Northwind Labs - Staff Design Engineer, Design Systems (1000).md +57 -0
  30. package/samples/vault/Jobs/Orbital Software - Senior Design Engineer (1001).md +52 -0
  31. package/samples/vault/Jobs/Quill & Co - Design Engineer, Editor (1008).md +55 -0
  32. package/samples/vault/Jobs/Signalfire Design - Design Engineer (1012).md +53 -0
  33. package/samples/vault/Jobs/Tessellate - Senior Frontend Engineer, Design Systems (1007).md +52 -0
  34. package/samples/vault/Jobs/Verdant - UX Engineer (1011).md +51 -0
  35. package/samples/vault/Logs/2026-09-16.md +13 -0
  36. package/samples/vault/Logs/2026-09-23.md +13 -0
  37. package/samples/vault/People/Lee Example (Fjord Analytics).md +22 -0
  38. package/samples/vault/People/Priya Example (Meridian Pay).md +23 -0
  39. package/samples/vault/People/Sam Example (Northwind Labs).md +20 -0
  40. package/samples/vault/Profile/Positioning.md +16 -0
  41. package/samples/vault/Profile/Profile.md +41 -0
  42. package/samples/vault/Profile/Resume.md +32 -0
  43. package/samples/vault/Profile/Voice.md +15 -0
  44. package/samples/vault/README.md +11 -0
  45. package/samples/vault/Targets/Companies.md +318 -0
  46. package/samples/vault/Targets/Search Criteria.md +234 -0
  47. package/samples/vault/_Home.md +54 -0
  48. package/scraper/config.mjs +122 -0
  49. package/scraper/health.mjs +50 -0
  50. package/scraper/import-link.mjs +281 -0
  51. package/scraper/profile.mjs +186 -0
  52. package/scraper/rescore.mjs +236 -0
  53. package/scraper/resume-sync.mjs +224 -0
  54. package/scraper/resume.mjs +65 -0
  55. package/scraper/score.mjs +114 -0
  56. package/scraper/sources-email.mjs +242 -0
  57. package/scraper/sources-extra.mjs +558 -0
  58. package/scraper/sources-sites.mjs +280 -0
  59. package/scraper/sources.mjs +264 -0
  60. package/scraper/starter/companies-table.md +306 -0
  61. package/scraper/starter/criteria.json +56 -0
  62. package/scraper/starter/profile.md +38 -0
  63. package/scraper/vault.mjs +212 -0
@@ -0,0 +1,1131 @@
1
+ // The vault is the database. This module reads the TekJobs Obsidian vault (job notes, criteria, companies, logs)
2
+ // and performs the few writes the app is allowed to make: change a job's status, append a note, save an
3
+ // application draft, edit the criteria JSON block, append a company row. It never rewrites anything else.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { spawn } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { P, VAULT, HOME_DIR, CONFIG_FILE, loadCriteria, loadCompanies, criteriaPresetFile } from '../../scraper/config.mjs';
9
+ import { loadHealth, healthState } from '../../scraper/health.mjs';
10
+ import { readFrontmatter, LEGACY_NARRATIVE_DEFAULT } from '../../scraper/vault.mjs';
11
+ import { weightsFingerprint, titlePoints, recencyPoints, payPoints, rescoreFull } from '../../scraper/rescore.mjs';
12
+ import { RESUME_NOTE, LEGACY_RESUME_NOTES } from '../../scraper/resume-sync.mjs';
13
+ import { onboardingStatus, onboardingMaterials, importResume, saveProfile, fetchLink, initProfile } from '../../scraper/profile.mjs';
14
+ export { onboardingStatus, onboardingMaterials, importResume, saveProfile, fetchLink, initProfile };
15
+
16
+ const ROOT = fileURLToPath(new URL('../..', import.meta.url)); // the scraper repo root
17
+ const isoDay = () => new Date().toISOString().slice(0, 10);
18
+ const DE = /design (engineer|technologist|system)|ux engineer|ui engineer|creative technologist|prototyp/i;
19
+ // `ready` is the approval boundary: a packet drafted and waiting on a person. `reviewing` already serves as
20
+ // shortlisted and `applying` as preparing, so this is the one state the pipeline was missing rather than a
21
+ // new model imposed on notes that already exist.
22
+ const STATUSES = ['new', 'reviewing', 'applying', 'ready', 'applied', 'interviewing', 'offer', 'rejected', 'passed'];
23
+ export { STATUSES, VAULT };
24
+
25
+ // ---------- jobs ----------
26
+ const cache = { key: '', rows: [] };
27
+ function dirKey() {
28
+ const files = fs.readdirSync(P.jobs).filter((f) => f.endsWith('.md'));
29
+ let key = String(files.length);
30
+ for (const f of files) key += ':' + fs.statSync(path.join(P.jobs, f)).mtimeMs;
31
+ return key;
32
+ }
33
+ /** Forget the row cache; for callers outside this module that write a note directly. */
34
+ export function cacheClear() { cache.key = ''; }
35
+ export function listJobs() {
36
+ if (!fs.existsSync(P.jobs)) return [];
37
+ const key = dirKey();
38
+ if (key === cache.key) return cache.rows;
39
+ const rows = fs.readdirSync(P.jobs).filter((f) => f.endsWith('.md')).map((f) => {
40
+ const file = path.join(P.jobs, f);
41
+ const fm = readFrontmatter(file);
42
+ // The packet count comes from the note body, not the frontmatter, so a note edited by hand in Obsidian
43
+ // counts the same as one written through the app. Both reads land in the same cache, which only misses
44
+ // when a file's mtime changes.
45
+ return fm ? rowOf(fm, fs.readFileSync(file, 'utf8')) : null;
46
+ }).filter(Boolean);
47
+ cache.key = key; cache.rows = rows;
48
+ return rows;
49
+ }
50
+
51
+ /**
52
+ * How many application fields a note has something in.
53
+ *
54
+ * Scoped twice, because both scopes are load-bearing: to the Application section, since job descriptions are
55
+ * full of their own `- **Stack:** …` lines, and to the known field names, since a reader may add bullets of
56
+ * their own there. Without either, every note in the vault reads as a started packet.
57
+ */
58
+ function countPacketFields(text = '') {
59
+ const section = (text.match(/^## Application\r?\n([\s\S]*?)(?=^## |(?![\s\S]))/m) || [, ''])[1];
60
+ if (!section) return 0;
61
+ let n = 0;
62
+ for (const { field } of APPLICATION_FIELDS) {
63
+ const m = section.match(new RegExp(`^- \\*\\*${escapeRe(field)}:\\*\\*(.*)$`, 'm'));
64
+ const value = m ? m[1].trim() : '';
65
+ if (!value) continue;
66
+ // A note written before the template stopped pre-filling this carries a narrative nobody chose.
67
+ if (field === 'Narrative' && value === LEGACY_NARRATIVE_DEFAULT) continue;
68
+ n++;
69
+ }
70
+ return n;
71
+ }
72
+
73
+ function rowOf(fm, text = '') {
74
+ return {
75
+ id: fm._name,
76
+ company: fm.company || '', title: fm.title || '', location: fm.location || '',
77
+ remote: fm.remote === 'true' || isRemoteRow({ remote: false, location: fm.location || '' }), source: fm.source || '', url: fm.url || '',
78
+ score: Number(fm.score) || 0, posted: fm.posted || '', found: fm.found || '',
79
+ salary: fm.salary || '', salaryMax: Number(fm.salary_max) || 0, payBand: fm.pay_band || 'unknown',
80
+ status: fm.status || 'new', listing: String(fm.listing || 'open').startsWith('open') ? 'open' : String(fm.listing),
81
+ kind: DE.test(fm.title || '') ? 'design-eng' : 'adjacent', department: fm.department || '', jobId: fm.job_id || '',
82
+ passedReason: fm.passed_reason || '',
83
+ // Which criteria weights scored this note; `rescore --full` brings the ones that differ over.
84
+ weights: fm.weights || '',
85
+ // "link" when the person pasted it (Add by link); scanned notes carry no added_by line.
86
+ addedBy: fm.added_by || 'scan',
87
+ packet: countPacketFields(text),
88
+ };
89
+ }
90
+ // The scan's remote flag comes from the board's own workplace field, which some boards leave unset on a posting
91
+ // whose location text plainly says "Remote". Either counts.
92
+ export const isRemoteRow = (r) => r.remote || /\b(remote|anywhere|distributed|work from home|wfh)\b/i.test(r.location || '');
93
+ // A list filter value is one name, a comma-separated list, or an array; "all" or nothing means no filter.
94
+ const listOf = (v) => new Set([].concat(v || []).flatMap((s) => String(s).split(',')).map((s) => s.trim()).filter((s) => s && s !== 'all'));
95
+ const withinDays = (iso, days) => !!iso && (Date.now() - new Date(iso).getTime()) / 86400000 <= Number(days);
96
+ const num = (v) => (v === '' || v == null ? null : Number(v));
97
+
98
+ /**
99
+ * Every filter the Jobs view, the API and the MCP tool share. Pay filters read the top of the stated range
100
+ * (salaryMax), which is the number the pay band is judged on; a posting with no stated pay passes "pay at
101
+ * least" only when it is not asked to state one (payKnown).
102
+ */
103
+ export function filterJobs(f = {}) {
104
+ const { q = '', location = '', status, band, kind, source, company, minScore, maxScore, payMin, payMax, payKnown = false, postedDays, foundDays, remoteOnly = false, openOnly = true } = f;
105
+ const ql = String(q).trim().toLowerCase();
106
+ // source values are the note's source (ashby, greenhouse, wellfound…); the special value "link" means every
107
+ // note added by pasting a link, whatever board it turned out to be on.
108
+ const sources = listOf(source), statuses = listOf(status), bands = listOf(band), kinds = listOf(kind);
109
+ const companies = new Set([...listOf(company)].map((s) => s.toLowerCase()));
110
+ // Whole words, so "wa" finds "Seattle, WA" and not "Hawaii".
111
+ const locTerms = String(location).split(',').map((s) => s.trim()).filter(Boolean).map((t) => new RegExp(`(^|[^a-z])${t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^a-z])`, 'i'));
112
+ // No minScore means no floor at all. A note created from an email or a pasted link can carry a negative
113
+ // score (nothing for the title terms to match, no location), and it must still show on the board.
114
+ const lo = num(minScore), hi = num(maxScore), pMin = num(payMin), pMax = num(payMax), pd = num(postedDays), fd = num(foundDays);
115
+ return listJobs().filter((r) => (!statuses.size || statuses.has(r.status))
116
+ && (!bands.size || bands.has(r.payBand))
117
+ && (!kinds.size || kinds.has(r.kind))
118
+ && (!sources.size || sources.has(r.source) || (sources.has('link') && r.addedBy === 'link'))
119
+ && (!companies.size || companies.has(String(r.company).toLowerCase()))
120
+ && (lo == null || r.score >= lo) && (hi == null || r.score <= hi)
121
+ && (!payKnown || r.salaryMax > 0)
122
+ && (!pMin || r.salaryMax >= pMin)
123
+ && (!pMax || (r.salaryMax > 0 && r.salaryMax <= pMax))
124
+ && (!pd || withinDays(r.posted, pd))
125
+ && (!fd || withinDays(r.found, fd))
126
+ && (!remoteOnly || isRemoteRow(r))
127
+ && (!openOnly || r.listing === 'open')
128
+ && (!locTerms.length || locTerms.some((t) => t.test(r.location)))
129
+ && (!ql || `${r.company} ${r.title} ${r.location}`.toLowerCase().includes(ql)));
130
+ }
131
+ export function searchJobs({ sort = 'score', dir = 'desc', limit = 500, offset = 0, ...f } = {}) {
132
+ const rows = filterJobs(f);
133
+ const d = dir === 'asc' ? 1 : -1;
134
+ rows.sort((a, b) => { const va = a[sort], vb = b[sort]; return (typeof va === 'string' ? va.localeCompare(vb) : (va || 0) - (vb || 0)) * d; });
135
+ return { total: rows.length, rows: rows.slice(offset, offset + limit) };
136
+ }
137
+ /**
138
+ * Counts per filter value under the current filters, each dimension counted with its own filter lifted, so a
139
+ * picker shows what choosing each value would give rather than only what is already chosen. Pay comes back as
140
+ * a spread of the stated tops of range, for setting the pay fields with the eyes open.
141
+ */
142
+ export function jobFacets(f = {}) {
143
+ const count = (rows, key) => rows.reduce((a, r) => { const k = key(r); if (k) a[k] = (a[k] || 0) + 1; return a; }, {});
144
+ const without = (...dims) => filterJobs(Object.fromEntries([...Object.entries(f), ...dims.map((d) => [d, undefined])]));
145
+ const sourceRows = without('source');
146
+ const source = count(sourceRows, (r) => r.source);
147
+ const link = sourceRows.filter((r) => r.addedBy === 'link').length;
148
+ if (link) source.link = link;
149
+ const remoteRows = without('remoteOnly');
150
+ const payRows = without('payMin', 'payMax', 'payKnown');
151
+ const tops = payRows.map((r) => r.salaryMax).filter((n) => n > 0).sort((a, b) => a - b);
152
+ return {
153
+ total: filterJobs(f).length,
154
+ status: count(without('status'), (r) => r.status),
155
+ band: count(without('band'), (r) => r.payBand),
156
+ kind: count(without('kind'), (r) => r.kind),
157
+ source,
158
+ company: count(without('company'), (r) => r.company),
159
+ remote: { remote: remoteRows.filter(isRemoteRow).length, onsite: remoteRows.filter((r) => !isRemoteRow(r)).length },
160
+ pay: { stated: tops.length, unstated: payRows.length - tops.length, min: tops[0] || 0, max: tops[tops.length - 1] || 0, median: tops[Math.floor(tops.length / 2)] || 0 },
161
+ };
162
+ }
163
+ /** The same filters, read off a query string (the API) so the two routes and the UI agree on names. */
164
+ export function filtersFromParams(q) {
165
+ const g = (k) => q.get(k) ?? undefined;
166
+ return { q: g('q') || '', location: g('location') || '', status: g('status'), band: g('band'), kind: g('kind'), source: g('source'), company: g('company'), minScore: g('minScore'), maxScore: g('maxScore'), payMin: g('payMin'), payMax: g('payMax'), payKnown: g('payKnown') === '1', postedDays: g('postedDays'), foundDays: g('foundDays'), remoteOnly: g('remote') === '1', openOnly: g('closed') !== '1' };
167
+ }
168
+ function notePath(id) {
169
+ const safe = path.basename(String(id)).replace(/\.md$/, '');
170
+ const file = path.join(P.jobs, safe + '.md');
171
+ if (!fs.existsSync(file)) throw Object.assign(new Error(`No job note named "${safe}"`), { status: 404 });
172
+ return file;
173
+ }
174
+ /**
175
+ * The heading a note keeps the reader's own notes under.
176
+ *
177
+ * New notes are written with "Notes". Notes written before the heading was generalised say "Tek's notes",
178
+ * and are not rewritten: an existing note is the person's record, and a migration that touches every file to
179
+ * change one line is a worse trade than reading both. Whichever heading a note already has is the one it
180
+ * keeps; only a note that has neither gets the current default.
181
+ */
182
+ const NOTES_HEADING = 'Notes';
183
+ const LEGACY_NOTES_HEADING = "Tek's notes";
184
+ function notesHeading(text) {
185
+ if (new RegExp(`^## ${NOTES_HEADING}[ \\t]*$`, 'm').test(text)) return NOTES_HEADING;
186
+ if (new RegExp(`^## ${LEGACY_NOTES_HEADING}[ \\t]*$`, 'm').test(text)) return LEGACY_NOTES_HEADING;
187
+ return NOTES_HEADING;
188
+ }
189
+
190
+ export function getJob(id) {
191
+ const file = notePath(id);
192
+ const text = fs.readFileSync(file, 'utf8');
193
+ const fm = readFrontmatter(file);
194
+ const body = text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
195
+ // A section runs to the next "## " heading or the true end of the note (not the end of a line: `$` is per-line under /m).
196
+ const section = (h) => { const m = body.match(new RegExp(`^## ${h}\\s*\\n([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, 'm')); return m ? m[1].trim() : ''; };
197
+ // Where the note is, and a link Obsidian opens it from; `path=` works whatever the vault is called.
198
+ return { ...rowOf(fm), path: file, obsidianUrl: `obsidian://open?path=${encodeURIComponent(file)}`, body, sections: { why: section("Why it matched"), log: section('Status log'), notes: section(notesHeading(text)), application: section('Application'), coverLetter: section('Cover letter').replace(/^>.*\r?\n?/gm, '').trim(), description: section('Job description'), people: section('People') } };
199
+ }
200
+ export function replaceFrontmatterLine(text, key, value) {
201
+ const re = new RegExp(`^${key}: .*$`, 'm');
202
+ return re.test(text) ? text.replace(re, `${key}: ${value}`) : text.replace(/^---\r?\n/, `---\n${key}: ${value}\n`);
203
+ }
204
+ export function appendUnderHeading(text, heading, line) {
205
+ // `[ \t]*\n` rather than `\s*\n`: a greedy \s would swallow the blank lines after the heading into the heading group.
206
+ const re = new RegExp(`(^## ${heading}[ \\t]*\\n)([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, 'm');
207
+ if (!re.test(text)) return text.trimEnd() + `\n\n## ${heading}\n${line}\n`;
208
+ return text.replace(re, (all, h, content) => { const body = content.trim(); return `${h}${body ? body + '\n' : ''}${line}\n\n`; });
209
+ }
210
+ /**
211
+ * Why a match was passed on. Recorded so the criteria can be argued with later from evidence rather than
212
+ * memory — a pass with no reason teaches nothing, and after three hundred of them the record is the only
213
+ * thing that can say which rule is wrong.
214
+ */
215
+ export const PASS_REASONS = ['wrong role shape', 'weak evidence match', 'compensation', 'location or authorisation', 'too managerial', 'too visual', 'too engineering', 'stale or duplicate', 'company'];
216
+
217
+ /**
218
+ * Statuses an agent may not set, and why.
219
+ *
220
+ * `applied` is not a preference, it is a claim that something was sent to another person. Nothing in this
221
+ * system can send an application, so nothing in it can truthfully record one — only the person who did it
222
+ * knows. An agent that drafts a packet moves it to `ready` and stops there; the human moves it on.
223
+ * `interviewing` and `offer` are the same kind of claim, about events outside this machine entirely.
224
+ */
225
+ const HUMAN_ONLY_STATUSES = ['applied', 'interviewing', 'offer'];
226
+
227
+ export function setStatus(id, status, via = 'app', reason = '') {
228
+ if (!STATUSES.includes(status)) throw Object.assign(new Error(`status must be one of ${STATUSES.join(', ')}`), { status: 400 });
229
+ if (reason && !PASS_REASONS.includes(reason)) throw Object.assign(new Error(`reason must be one of ${PASS_REASONS.join(', ')}`), { status: 400 });
230
+ if (via === 'mcp' && HUMAN_ONLY_STATUSES.includes(status)) {
231
+ throw Object.assign(new Error(`"${status}" records something that happened outside this machine, so only a person can set it. Move the packet to "ready" and ask them.`), { status: 403 });
232
+ }
233
+ const file = notePath(id);
234
+ let text = fs.readFileSync(file, 'utf8');
235
+ const before = (text.match(/^status: (.*)$/m) || [])[1] || 'new';
236
+ if (before === status) return getJob(id);
237
+ text = replaceFrontmatterLine(text, 'status', status);
238
+ if (reason) text = replaceFrontmatterLine(text, 'passed_reason', JSON.stringify(reason));
239
+ text = appendUnderHeading(text, 'Status log', `- ${isoDay()} — ${before} → **${status}**${reason ? ` (${reason})` : ''} (via ${via})`);
240
+ fs.writeFileSync(file, text);
241
+ cache.key = '';
242
+ return getJob(id);
243
+ }
244
+ export function addNote(id, note, via = 'app') {
245
+ const file = notePath(id);
246
+ let text = fs.readFileSync(file, 'utf8');
247
+ text = appendUnderHeading(text, notesHeading(text), `- ${isoDay()} (${via}): ${String(note).trim()}`);
248
+ fs.writeFileSync(file, text);
249
+ return getJob(id);
250
+ }
251
+ /**
252
+ * The application packet: what has to exist before a person can decide whether to send something.
253
+ *
254
+ * `required` is the subset that has to be filled before the packet counts as ready for a human to approve.
255
+ * The rest are useful and often empty — not every application needs a referral or an outreach message, and
256
+ * a checklist that demands them teaches people to fill boxes rather than to think.
257
+ *
258
+ * The first six are the fields the note template has always had; the rest were added with the packet, and
259
+ * are appended to a note's Application section the first time they are saved. Nothing is rewritten.
260
+ */
261
+ export const APPLICATION_FIELDS = [
262
+ { field: 'Narrative', hint: 'Which story leads: design engineer, or AI systems.', required: true },
263
+ { field: 'Resume variant', hint: 'Which base resume this starts from.', required: true },
264
+ { field: 'Tailored summary', hint: 'The opening paragraph, rewritten for this posting.', required: true },
265
+ { field: 'Tailored bullets', hint: 'The three or four bullets that answer this posting’s requirements.', required: true },
266
+ { field: 'Portfolio', hint: 'Which projects to feature, and why these.', required: false },
267
+ { field: 'Cover letter', hint: 'Optional. Only where it is read.', required: false },
268
+ { field: 'Questions', hint: 'The posting’s own questions, with answers you have verified.', required: false },
269
+ { field: 'Risks', hint: 'Gaps a reader will notice, and what to say about them.', required: true },
270
+ { field: 'Outreach message', hint: 'To a recruiter or a referral, if there is one.', required: false },
271
+ { field: 'Contact / referral', hint: 'Who, and how you know them.', required: false },
272
+ { field: 'Deadline', hint: 'When this closes, if it says.', required: false },
273
+ { field: 'Applied on', hint: 'Filled when you actually send it.', required: false },
274
+ { field: 'Follow-up due', hint: 'When to chase, if nothing comes back.', required: false },
275
+ ];
276
+ const FIELD_NAMES = APPLICATION_FIELDS.map((f) => f.field);
277
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
278
+
279
+ export function saveApplicationDraft(id, { field, value }) {
280
+ if (!FIELD_NAMES.includes(field)) throw Object.assign(new Error(`field must be one of ${FIELD_NAMES.join(', ')}`), { status: 400 });
281
+ const file = notePath(id);
282
+ let text = fs.readFileSync(file, 'utf8');
283
+ const re = new RegExp(`^- \\*\\*${escapeRe(field)}:\\*\\*.*$`, 'm');
284
+ // Newlines are flattened: the Application section is a list, and a multi-line value would break the
285
+ // next field out of it. Long prose belongs in the note body, and the packet points at it.
286
+ const line = `- **${field}:** ${String(value).replace(/\r?\n/g, ' ').trim()}`;
287
+ text = re.test(text) ? text.replace(re, line) : appendUnderHeading(text, 'Application', line);
288
+ fs.writeFileSync(file, text);
289
+ cache.key = '';
290
+ return getJob(id);
291
+ }
292
+
293
+ /**
294
+ * Long drafts (the cover letter, the tailored resume) live in the note body under their own headings, not in
295
+ * the Application list: that list is one line per field, and these are paragraphs. The packet field points
296
+ * at the section. Sections are placed before "## Job description" so the person's material stays above the
297
+ * posting's. Function replacements throughout: a draft can contain "$" and String.replace reads "$&".
298
+ */
299
+ const sectionRe = (heading) => new RegExp(`^## ${heading}[ \\t]*\\n([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, 'm');
300
+ export function getSection(id, heading) {
301
+ const m = fs.readFileSync(notePath(id), 'utf8').match(sectionRe(heading));
302
+ return m ? m[1].replace(/^>.*\r?\n?/gm, '').trim() : '';
303
+ }
304
+ export function saveSection(id, heading, body, banner) {
305
+ const file = notePath(id);
306
+ let text = fs.readFileSync(file, 'utf8');
307
+ const block = `## ${heading}\n> ${banner}\n\n${String(body).trim()}\n\n`;
308
+ if (sectionRe(heading).test(text)) text = text.replace(sectionRe(heading), () => block);
309
+ else if (/^## Job description[ \t]*$/m.test(text)) text = text.replace(/^## Job description[ \t]*$/m, () => `${block}## Job description`);
310
+ else text = `${text.trimEnd()}\n\n${block}`;
311
+ fs.writeFileSync(file, text);
312
+ cache.key = '';
313
+ return getSection(id, heading);
314
+ }
315
+ export { isoDay };
316
+
317
+ const LETTER_HEADING = 'Cover letter';
318
+ export function getCoverLetter(id) { return getSection(id, LETTER_HEADING); }
319
+ export function saveCoverLetter(id, letter, via = 'app') {
320
+ const words = String(letter).trim().split(/\s+/).length;
321
+ saveSection(id, LETTER_HEADING, letter, `Drafted ${isoDay()} (via ${via}), ${words} words. A draft: read it, edit it, send it yourself.`);
322
+ saveApplicationDraft(id, { field: 'Cover letter', value: `Drafted ${isoDay()}, ${words} words. Full text under "Cover letter" in this note.` });
323
+ return getCoverLetter(id);
324
+ }
325
+
326
+ /**
327
+ * The packet as structured data, plus what it is still missing.
328
+ *
329
+ * `ready` says only that every required field has something in it. It is not a judgement that the
330
+ * application is good, and nothing in this codebase treats it as permission to send anything.
331
+ */
332
+ export function applicationPacket(id) {
333
+ const text = fs.readFileSync(notePath(id), 'utf8');
334
+ const values = {};
335
+ for (const { field } of APPLICATION_FIELDS) {
336
+ const m = text.match(new RegExp(`^- \\*\\*${escapeRe(field)}:\\*\\*(.*)$`, 'm'));
337
+ values[field] = m ? m[1].trim() : '';
338
+ }
339
+ const missing = APPLICATION_FIELDS.filter((f) => f.required && !values[f.field]).map((f) => f.field);
340
+ const filled = FIELD_NAMES.filter((f) => values[f]).length;
341
+ return { id, fields: APPLICATION_FIELDS, values, missing, ready: missing.length === 0, filled, total: FIELD_NAMES.length };
342
+ }
343
+
344
+ // ---------- summary & runs ----------
345
+ export function runs() {
346
+ if (!fs.existsSync(P.logs)) return [];
347
+ return fs.readdirSync(P.logs).filter((f) => f.endsWith('.md')).sort().reverse().slice(0, 30).map((f) => {
348
+ const text = fs.readFileSync(path.join(P.logs, f), 'utf8');
349
+ const blocks = [...text.matchAll(/^## Run (.+?)\n([\s\S]*?)(?=^## Run |(?![\s\S]))/gm)].map((m) => {
350
+ const b = m[2];
351
+ const g = (re) => (b.match(re) || [])[1];
352
+ const head = m[1].trim();
353
+ const boardsOk = Number(g(/Boards: (\d+)\//)), boardsTotal = Number(g(/Boards: \d+\/(\d+)/));
354
+ // Feeds that failed are their own lines: "- Himalayas: failed: HTTP 503".
355
+ const feedsFailed = [...b.matchAll(/^- (.+?): failed: (.+)$/gm)].map((x) => `${x[1]} (${x[2]})`);
356
+ const dry = /\(dry\)/.test(head);
357
+ // Success: every board answered and every feed too. Partial: some did not. Failed: no board answered,
358
+ // which is a network or a broken install, not a bad slug.
359
+ const outcome = boardsTotal > 0 && boardsOk === 0 ? 'failed' : boardsOk === boardsTotal && feedsFailed.length === 0 ? 'success' : 'partial';
360
+ return { when: head.replace(/\s*\(dry\)\s*$/, ''), dry, via: g(/^- Via: (.+)$/m) || '', outcome, boardsOk, boardsTotal, scanned: Number((g(/postings scanned: ([\d,]+)/) || '0').replace(/,/g, '')), matched: Number(g(/scored ≥ \d+: (\d+)/)), newMatches: Number(g(/\*\*new: (\d+)\*\*/)), closed: Number(g(/closed: (\d+)/)), seconds: Number(g(/([\d.]+)s/)), failed: g(/Failed slugs: (.+)/) || '', feedsFailed, criteria: g(/^- Criteria: (.+)$/m) || '' };
361
+ });
362
+ return { date: f.replace(/\.md$/, ''), runs: blocks };
363
+ });
364
+ }
365
+ export function summary() {
366
+ const rows = listJobs().filter((r) => r.listing === 'open');
367
+ const count = (k) => rows.reduce((m, r) => ((m[r[k]] = (m[r[k]] || 0) + 1), m), {});
368
+ const de = rows.filter((r) => r.kind === 'design-eng');
369
+ const lastLog = runs()[0]; const last = lastLog?.runs?.at(-1) || null;
370
+ const criteria = safe(() => loadCriteria(), {});
371
+ return {
372
+ open: rows.length, byStatus: count('status'), byBand: count('payBand'), byKind: count('kind'), bySource: count('source'),
373
+ designEng: { total: de.length, floor: de.filter((r) => r.payBand === 'floor').length, stretch: de.filter((r) => r.payBand === 'stretch').length, unknown: de.filter((r) => r.payBand === 'unknown').length },
374
+ active: rows.filter((r) => ['applying', 'applied', 'interviewing', 'offer'].includes(r.status)).length,
375
+ lastRun: last ? { ...last, date: lastLog.date } : null,
376
+ floor: criteria.salary?.minAnnual || null, stretch: criteria.salary?.stretchAnnual || null, minScore: criteria.minScore || null,
377
+ ceiling: fitCeiling(criteria),
378
+ companies: safe(() => loadCompanies().length, 0), vault: VAULT,
379
+ };
380
+ }
381
+ const safe = (fn, d) => { try { return fn(); } catch { return d; } };
382
+
383
+ // ---------- today ----------
384
+
385
+ /**
386
+ * The best score this criteria set can produce, used to turn the raw score into a 0-100 fit.
387
+ *
388
+ * The raw score is additive and unbounded, which is right for ranking and useless for judging: "125" means
389
+ * nothing without knowing what was available. This computes the ceiling from the criteria themselves.
390
+ *
391
+ * One assumption, stated because it is arguable: scoreJob pays 5 points for every title term beyond the
392
+ * first, with no cap, so the theoretical maximum is however many terms the profile has. A real posting hits
393
+ * two or three, so the ceiling allows for three. A job that somehow hits more simply reads as 100.
394
+ */
395
+ export function fitCeiling(c = {}) {
396
+ const top = (o) => Math.max(0, ...Object.values(o || {}).map(Number).filter(Number.isFinite));
397
+ return Math.max(1, Math.round(
398
+ // Every point the scorer can award: the top title term plus the capped extras, the top seniority boost, the
399
+ // description cap, the location boosts, the pay bonus plus the capped above-floor points, the freshest recency.
400
+ top(c.titleTerms) + (c.titleExtraCap ?? 10) +
401
+ top(c.seniority?.boost) +
402
+ (c.descCap ?? 35) +
403
+ (c.location?.remoteBoost ?? 0) + (c.location?.bayAreaBoost ?? 0) +
404
+ (c.salary?.meetsBonus ?? 10) + (c.salary?.aboveCap ?? 0) +
405
+ Math.max(c.recency?.days2 ?? 0, c.recency?.days7 ?? 0, c.recency?.days30 ?? 0),
406
+ ));
407
+ }
408
+ const toFit = (raw, ceiling) => Math.max(0, Math.min(100, Math.round((Number(raw) || 0) / ceiling * 100)));
409
+
410
+ /** The `- **Follow-up due:**` line from a note's Application section, or ''. Only worth reading for the few
411
+ * jobs actually in flight, so it is never called across the whole vault. */
412
+ function applicationField(id, label) {
413
+ try {
414
+ const text = fs.readFileSync(notePath(id), 'utf8');
415
+ const esc = label.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&');
416
+ const m = text.match(new RegExp(`^- \\*\\*${esc}:\\*\\*(.*)$`, 'm'));
417
+ return m ? m[1].trim() : '';
418
+ } catch { return ''; }
419
+ }
420
+
421
+ const IN_FLIGHT = ['applying', 'applied', 'interviewing', 'offer'];
422
+
423
+ /** The lines under a job note's "## People": "- [[People/Jane Doe (Vanta)|Jane Doe]] · recruiter · jane@x.com · context". */
424
+ export const PERSON_ROLES = ['recruiter', 'hiring-manager', 'interviewer', 'referral', 'other'];
425
+ export function parsePeopleLines(section = '') {
426
+ return String(section).split('\n').map((l) => l.trim()).filter((l) => l.startsWith('- ')).map((l) => {
427
+ const link = l.match(/\[\[People\/([^\]|]+)(?:\|([^\]]*))?\]\]/);
428
+ const rest = l.replace(/^- /, '').replace(/\[\[[^\]]*\]\]/, '').split('·').map((s) => s.trim()).filter(Boolean);
429
+ // A hand-written line has no link, so its first part is the name.
430
+ const name = link ? (link[2] || link[1]).trim() : rest[0] || '';
431
+ const parts = link ? rest : rest.slice(1);
432
+ const email = parts.find((s) => s.includes('@')) || '';
433
+ const role = parts.find((s) => PERSON_ROLES.includes(s)) || '';
434
+ const context = parts.filter((s) => s !== email && s !== role).join(' · ');
435
+ return { id: link ? link[1].trim() : '', name, role, email, context };
436
+ }).filter((p) => p.name);
437
+ }
438
+ /** Who to write to about a note: the recruiter if one is named, else the hiring manager, else whoever is first. */
439
+ function contactOf(text) {
440
+ const all = parsePeopleLines((text.match(/^## People\r?\n([\s\S]*?)(?=^## |(?![\s\S]))/m) || [, ''])[1]);
441
+ const pick = all.find((p) => p.role === 'recruiter') || all.find((p) => p.role === 'hiring-manager') || all[0] || null;
442
+ return pick ? { id: pick.id, name: pick.name, email: pick.email, role: pick.role, others: all.length - 1 } : null;
443
+ }
444
+ /** When an application went out: the packet's Applied on, else the status log's move to applied. */
445
+ function appliedOnOf(text) {
446
+ return (text.match(/^- \*\*Applied on:\*\*\s*(\d{4}-\d{2}-\d{2})/m) || [])[1] || (text.match(/^- (\d{4}-\d{2}-\d{2}) — .*?→ \*\*applied\*\*/m) || [])[1] || '';
447
+ }
448
+
449
+ /**
450
+ * The short list of things that actually want a decision today, as opposed to the whole inventory.
451
+ *
452
+ * Measured on this vault before it was written: every listing that closed did so within seven days of being
453
+ * found, median two. So an unreviewed match is a perishable thing, and the section that matters most is not
454
+ * the ranked pile but the one about to disappear.
455
+ *
456
+ * Sections three to five read the pipeline, which is empty until the pipeline is used. They return empty
457
+ * arrays rather than being hidden, so the page shows the shape of the work even before there is any.
458
+ */
459
+ export function today({ cap = 7, agingDays = 2, waitingDays = 14 } = {}) {
460
+ const rows = listJobs();
461
+ const criteria = safe(() => loadCriteria(), {});
462
+ const ceiling = fitCeiling(criteria);
463
+ const withFit = (r) => ({ ...r, fit: toFit(r.score, ceiling) });
464
+ const daysSince = (d) => (d ? Math.floor((Date.now() - Date.parse(d)) / 864e5) : null);
465
+
466
+ const open = rows.filter((r) => r.listing === 'open');
467
+ const unreviewed = open.filter((r) => r.status === 'new');
468
+
469
+ const triage = [...unreviewed].sort((a, b) => b.score - a.score).slice(0, cap).map(withFit);
470
+ const triageIds = new Set(triage.map((r) => r.id));
471
+
472
+ // Still open, still untouched, and already past the age at which listings here start disappearing.
473
+ const aging = unreviewed
474
+ .filter((r) => !triageIds.has(r.id) && (daysSince(r.found) ?? 0) >= agingDays)
475
+ .sort((a, b) => b.score - a.score)
476
+ .slice(0, cap)
477
+ .map((r) => ({ ...withFit(r), days: daysSince(r.found) }));
478
+
479
+ // Closed while it was still sitting in the inbox: the cost of not having looked, made visible.
480
+ const missedAll = rows
481
+ .filter((r) => r.status === 'new' && r.listing.startsWith('closed') && (daysSince(r.listing.replace('closed ', '')) ?? 99) <= 7)
482
+ .sort((a, b) => b.score - a.score);
483
+ const missed = missedAll.slice(0, cap).map((r) => ({ ...withFit(r), closed: r.listing.replace('closed ', '') }));
484
+
485
+ // Work already invested, wherever it ranks. A packet is the most expensive thing on this page — a drafted
486
+ // summary, a named contact — and the fit score knows nothing about it, so a role can be half-prepared and
487
+ // still fall off the bottom of the queue. Terminal statuses drop out; a closed listing does not, because a
488
+ // packet on a job that just closed is the one thing here you want to hear about immediately.
489
+ const DONE = ['applied', 'offer', 'rejected', 'passed'];
490
+ const started = rows
491
+ .filter((r) => r.packet > 0 && !DONE.includes(r.status) && !triageIds.has(r.id))
492
+ .sort((a, b) => b.packet - a.packet || b.score - a.score)
493
+ .slice(0, cap)
494
+ .map((r) => ({ ...withFit(r), packet: r.packet, closed: r.listing.startsWith('closed') ? r.listing.replace('closed ', '') : '' }));
495
+
496
+ const inFlight = rows.filter((r) => IN_FLIGHT.includes(r.status));
497
+ // Whoever is on the thread rides along with every in-flight row, so a nudge names the person to write to.
498
+ const noteText = (id) => { try { return fs.readFileSync(notePath(id), 'utf8'); } catch { return ''; } };
499
+ const withContact = (r) => { const text = noteText(r.id); return { ...withFit(r), contact: contactOf(text), appliedOn: appliedOnOf(text) }; };
500
+ const preparing = inFlight.filter((r) => r.status === 'applying').map((r) => ({ ...withContact(r), since: daysSince(r.found) }));
501
+ const followUps = inFlight
502
+ .map((r) => ({ ...withContact(r), due: applicationField(r.id, 'Follow-up due') }))
503
+ .filter((r) => r.due)
504
+ .sort((a, b) => a.due.localeCompare(b.due));
505
+ const interviewing = inFlight.filter((r) => r.status === 'interviewing' || r.status === 'offer').map(withContact);
506
+ // Applied, nothing back, past the point where silence is the answer unless someone is asked. The row names
507
+ // the person to ask; a row with no one on it is the nudge to find one.
508
+ const waitingAll = inFlight.filter((r) => r.status === 'applied').map(withContact).map((r) => ({ ...r, days: daysSince(r.appliedOn) })).filter((r) => r.days !== null && r.days >= waitingDays).sort((a, b) => b.days - a.days);
509
+ const waiting = waitingAll.slice(0, cap);
510
+
511
+ return {
512
+ generated: new Date().toISOString(),
513
+ ceiling,
514
+ // Real totals, not the capped section lengths: the point of the page is that the pile is bigger than the list.
515
+ counts: { open: open.length, unreviewed: unreviewed.length, inFlight: inFlight.length, closedUnreviewed: missedAll.length, aging: unreviewed.filter((r) => (daysSince(r.found) ?? 0) >= agingDays).length, started: rows.filter((r) => r.packet > 0 && !DONE.includes(r.status)).length, waiting: waitingAll.length, waitingDays },
516
+ sections: { triage, started, aging, missed, preparing, followUps, interviewing, waiting },
517
+ };
518
+ }
519
+
520
+ // ---------- criteria & companies ----------
521
+ const JSON_BLOCK = /```json\s*\n([\s\S]*?)\n```/;
522
+ const replaceJsonBlock = (md, parsed) => md.replace(JSON_BLOCK, () => '```json\n' + JSON.stringify(parsed, null, 2) + '\n```');
523
+ export function getCriteria() {
524
+ const md = fs.readFileSync(P.criteria, 'utf8');
525
+ const m = md.match(JSON_BLOCK);
526
+ const parsed = m ? safe(() => JSON.parse(m[1]), null) : null;
527
+ return { raw: m ? m[1] : '', parsed, fingerprint: parsed ? weightsFingerprint(parsed) : '', path: P.criteria };
528
+ }
529
+ export function setCriteria(raw) {
530
+ let parsed; try { parsed = JSON.parse(raw); } catch (e) { throw Object.assign(new Error(`Not valid JSON: ${e.message}`), { status: 400 }); }
531
+ const md = fs.readFileSync(P.criteria, 'utf8');
532
+ if (!JSON_BLOCK.test(md)) throw new Error('No ```json block in Search Criteria.md');
533
+ // The previous version is kept beside the note, once per day: the change history the person can read.
534
+ const before = P.criteria.replace(/\.md$/, `.before-${isoDay()}.md`);
535
+ if (!fs.existsSync(before)) fs.copyFileSync(P.criteria, before);
536
+ fs.writeFileSync(P.criteria, replaceJsonBlock(md, parsed).replace(/^updated: .*$/m, `updated: ${isoDay()}`));
537
+ return getCriteria();
538
+ }
539
+
540
+ /**
541
+ * What a proposed criteria set would do to the notes that exist, before it is saved or scanned with.
542
+ *
543
+ * Uses the same three exact components `rescore` uses (title terms, recency at the age the posting had when
544
+ * it was found, pay), computed with today's rules on both sides so an unchanged set previews as no change.
545
+ * Description terms, seniority words and location rules take a scan to show, and the result says so. The
546
+ * bar (minScore) is applied on both sides, so raising it alone still shows who falls out.
547
+ */
548
+ export function previewCriteria(raw) {
549
+ let proposed; try { proposed = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (e) { throw Object.assign(new Error(`Not valid JSON: ${e.message}`), { status: 400 }); }
550
+ const current = loadCriteria();
551
+ const exact = (r, c) => {
552
+ const ageAtScan = r.posted && r.found ? (Date.parse(r.found) - Date.parse(r.posted)) / 864e5 : NaN;
553
+ return titlePoints(r.title, c, c.titleExtraCap ?? 10) + recencyPoints(ageAtScan, c.recency, true) + payPoints(r.salaryMax, c.salary, true);
554
+ };
555
+ const rows = listJobs().filter((r) => r.listing === 'open');
556
+ const barBefore = Number(current.minScore ?? 0), barAfter = Number(proposed.minScore ?? 0);
557
+ const scored = rows.map((r) => { const after = Math.round(r.score + exact(r, proposed) - exact(r, current)); return { id: r.id, company: r.company, title: r.title, status: r.status, before: r.score, after, delta: after - r.score }; });
558
+ const top = (key) => [...scored].sort((a, b) => b[key] - a[key]).slice(0, 20).map((r) => r.id);
559
+ const topBefore = top('before'), topAfter = top('after');
560
+ const brief = (r) => ({ id: r.id, company: r.company, title: r.title, status: r.status, before: r.before, after: r.after, delta: r.delta });
561
+ // Who moves, by role family and by the board that surfaced the job: a change that empties one family or one
562
+ // source is the kind a person wants to see before saving.
563
+ const byRow = new Map(rows.map((r) => [r.id, r]));
564
+ const group = (key) => {
565
+ const out = {};
566
+ for (const r of scored) { const k = byRow.get(r.id)?.[key] || 'unknown'; const g = out[k] ||= { key: k, notes: 0, aboveBefore: 0, aboveAfter: 0, changed: 0 }; g.notes++; if (r.before >= barBefore) g.aboveBefore++; if (r.after >= barAfter) g.aboveAfter++; if (r.delta !== 0) g.changed++; }
567
+ return Object.values(out).sort((x, y) => y.notes - x.notes);
568
+ };
569
+ const fingerprintBefore = weightsFingerprint(current), fingerprintAfter = weightsFingerprint(proposed);
570
+ return {
571
+ fingerprintBefore, fingerprintAfter,
572
+ weightsChange: fingerprintBefore !== fingerprintAfter,
573
+ notesAtCurrent: rows.filter((r) => r.weights === fingerprintBefore).length,
574
+ byKind: group('kind'),
575
+ bySource: group('source').slice(0, 8),
576
+ openNotes: rows.length,
577
+ changed: scored.filter((r) => r.delta !== 0).length,
578
+ barBefore, barAfter,
579
+ aboveBefore: scored.filter((r) => r.before >= barBefore).length,
580
+ aboveAfter: scored.filter((r) => r.after >= barAfter).length,
581
+ rise: scored.filter((r) => r.before < barBefore && r.after >= barAfter).sort((a, b) => b.after - a.after).slice(0, 25).map(brief),
582
+ fall: scored.filter((r) => r.before >= barBefore && r.after < barAfter).sort((a, b) => b.before - a.before).slice(0, 25).map(brief),
583
+ enterTop20: topAfter.filter((id) => !topBefore.includes(id)).map((id) => brief(scored.find((r) => r.id === id))),
584
+ leaveTop20: topBefore.filter((id) => !topAfter.includes(id)).map((id) => brief(scored.find((r) => r.id === id))),
585
+ up: [...scored].filter((r) => r.delta > 0).sort((a, b) => b.delta - a.delta).slice(0, 10).map(brief),
586
+ down: [...scored].filter((r) => r.delta < 0).sort((a, b) => a.delta - b.delta).slice(0, 10).map(brief),
587
+ covers: 'title terms, recency and pay; description terms, seniority words and location rules show only after a scan',
588
+ };
589
+ }
590
+
591
+ /** Score every note again under the saved criteria (the CLI's `rescore --full`), from the app. */
592
+ export function rescoreNotes({ dry = false } = {}) {
593
+ const r = rescoreFull({ dry: !!dry });
594
+ cache.key = '';
595
+ return { total: r.total, considered: r.considered, alreadyCurrent: r.alreadyCurrent, changed: r.changed, fingerprint: r.fingerprint, dry: !!dry, sample: (r.changes || []).slice(0, 10) };
596
+ }
597
+
598
+ // ---------- outcomes ----------
599
+ // What the search is producing, from the notes: how far each job got, when it was applied to, and whether
600
+ // anything came back. Dates come from the status log lines the app and the MCP write ("2026-09-21 — new →
601
+ // **applied**"), from the packet's "Applied on" field when a person filled it, and from `found:`.
602
+ const RANK = { new: 0, reviewing: 1, applying: 2, ready: 3, applied: 4, interviewing: 5, offer: 6 };
603
+ const median = (xs) => { const s = [...xs].sort((a, b) => a - b); return s.length ? s[Math.floor(s.length / 2)] : null; };
604
+ const dayDiff = (a, b) => Math.round((Date.parse(b) - Date.parse(a)) / 864e5);
605
+ export function outcomes({ agingDays = [7, 14, 21] } = {}) {
606
+ const today = isoDay();
607
+ const files = fs.existsSync(P.jobs) ? fs.readdirSync(P.jobs).filter((f) => f.endsWith('.md')) : [];
608
+ const jobs = files.map((f) => {
609
+ const text = fs.readFileSync(path.join(P.jobs, f), 'utf8');
610
+ const fm = readFrontmatter(path.join(P.jobs, f)) || {};
611
+ const moves = [...text.matchAll(/^- (\d{4}-\d{2}-\d{2}) — .*?→ \*\*([a-z]+)\*\*/gm)].map((m) => ({ date: m[1], to: m[2] }));
612
+ const reached = (s) => (RANK[fm.status] ?? -1) >= RANK[s] || moves.some((m) => (RANK[m.to] ?? -1) >= RANK[s]);
613
+ const appliedOn = (text.match(/^- \*\*Applied on:\*\*\s*(\d{4}-\d{2}-\d{2})/m) || [])[1] || moves.find((m) => m.to === 'applied')?.date || (fm.status === 'applied' ? fm.found : '') || '';
614
+ const applied = reached('applied') || !!appliedOn || (fm.status === 'rejected' && moves.some((m) => m.to === 'applied'));
615
+ const responded = applied && (reached('interviewing') || fm.status === 'rejected');
616
+ const inFlight = fm.status === 'applied' || fm.status === 'interviewing';
617
+ const followUpDue = inFlight ? ((text.match(/^- \*\*Follow-up due:\*\*\s*(\d{4}-\d{2}-\d{2})/m) || [])[1] || '') : '';
618
+ return { id: fm._name, company: fm.company || '', title: fm.title || '', source: fm.source || '', kind: DE.test(fm.title || '') ? 'design-eng' : 'adjacent', status: fm.status || 'new', found: fm.found || '', appliedOn, followUpDue, reviewed: fm.status !== 'new', shortlisted: reached('reviewing') || fm.status === 'applying' || fm.status === 'ready', applied, interviewing: reached('interviewing'), offer: reached('offer'), responded };
619
+ });
620
+ const appliedJobs = jobs.filter((j) => j.applied);
621
+ const waiting = appliedJobs.filter((j) => j.status === 'applied' && j.appliedOn).map((j) => ({ ...j, days: dayDiff(j.appliedOn, today) })).sort((a, b) => b.days - a.days);
622
+ const bySource = {};
623
+ for (const j of appliedJobs) { const s = bySource[j.source] ||= { source: j.source, applied: 0, responded: 0 }; s.applied++; if (j.responded) s.responded++; }
624
+ const weekOf = (d) => { const x = new Date(d); const day = (x.getUTCDay() + 6) % 7; x.setUTCDate(x.getUTCDate() - day); return x.toISOString().slice(0, 10); };
625
+ const perWeek = {};
626
+ for (const j of appliedJobs) if (j.appliedOn) perWeek[weekOf(j.appliedOn)] = (perWeek[weekOf(j.appliedOn)] || 0) + 1;
627
+ // The funnel as stages, each with its share of the stage before, so "62% of what you shortlist gets applied to"
628
+ // is a number on the page rather than arithmetic the person does in their head.
629
+ const counts = [['found', jobs.length], ['reviewed', jobs.filter((j) => j.reviewed).length], ['shortlisted', jobs.filter((j) => j.shortlisted).length], ['applied', appliedJobs.length], ['interviewing', jobs.filter((j) => j.interviewing).length], ['offer', jobs.filter((j) => j.offer).length]];
630
+ const stages = counts.map(([stage, count], i) => ({ stage, count, rate: i === 0 ? null : counts[i - 1][1] ? count / counts[i - 1][1] : null }));
631
+ const byKind = ['design-eng', 'adjacent'].map((kind) => { const k = jobs.filter((j) => j.kind === kind); const ka = k.filter((j) => j.applied); return { kind, found: k.length, applied: ka.length, responded: ka.filter((j) => j.responded).length, interviewing: k.filter((j) => j.interviewing).length }; });
632
+ // What needs a hand this week: follow-ups due within seven days or already past, and the interviews in progress.
633
+ const followUps = jobs.filter((j) => j.followUpDue && dayDiff(today, j.followUpDue) <= 7).map((j) => ({ id: j.id, company: j.company, title: j.title, due: j.followUpDue, inDays: dayDiff(today, j.followUpDue) })).sort((x, y) => x.inDays - y.inDays);
634
+ const interviewing = jobs.filter((j) => j.status === 'interviewing').map((j) => ({ id: j.id, company: j.company, title: j.title }));
635
+ return {
636
+ stages, byKind, thisWeek: { followUps: followUps.slice(0, 8), interviewing: interviewing.slice(0, 8) },
637
+ funnel: { found: jobs.length, reviewed: jobs.filter((j) => j.reviewed).length, shortlisted: jobs.filter((j) => j.shortlisted).length, applied: appliedJobs.length, interviewing: jobs.filter((j) => j.interviewing).length, offer: jobs.filter((j) => j.offer).length },
638
+ responded: appliedJobs.filter((j) => j.responded).length,
639
+ responseRate: appliedJobs.length ? appliedJobs.filter((j) => j.responded).length / appliedJobs.length : null,
640
+ // A note created after the fact (from an email) was "found" after it was applied to; that is not a discovery time.
641
+ medianDaysToApply: median(appliedJobs.filter((j) => j.appliedOn && j.found && j.appliedOn >= j.found).map((j) => dayDiff(j.found, j.appliedOn))),
642
+ waiting: { total: waiting.length, buckets: Object.fromEntries(agingDays.map((d) => [d, waiting.filter((w) => w.days >= d).length])), oldest: waiting.slice(0, 8).map(({ id, company, title, appliedOn, days, source }) => ({ id, company, title, appliedOn, days, source })) },
643
+ bySource: Object.values(bySource).map((s) => ({ ...s, rate: s.applied ? s.responded / s.applied : 0 })).sort((a, b) => b.applied - a.applied).slice(0, 8),
644
+ appliedPerWeek: Object.entries(perWeek).sort().slice(-8).map(([week, n]) => ({ week, n })),
645
+ passed: jobs.filter((j) => j.status === 'passed').length,
646
+ rejected: jobs.filter((j) => j.status === 'rejected').length,
647
+ };
648
+ }
649
+
650
+ // ---------- settings ----------
651
+ // The few things that live outside the profile folder because they say where it is: ~/.tekjobs/config.json.
652
+ // The profile folder itself is read once at start (every module holds its paths), so changing it here takes a
653
+ // server restart; the other settings are read when used.
654
+ function readConfigFile() { try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch { return {}; } }
655
+ function writeConfigFile(patch) {
656
+ fs.mkdirSync(HOME_DIR, { recursive: true });
657
+ const next = { ...readConfigFile(), ...patch };
658
+ for (const k of Object.keys(next)) if (next[k] === '' || next[k] === null || next[k] === undefined) delete next[k];
659
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2));
660
+ return next;
661
+ }
662
+ export function resumeDir() {
663
+ const cfg = readConfigFile();
664
+ return cfg.resumeDir ? path.resolve(cfg.resumeDir) : path.join(VAULT, 'Templates', 'Resume');
665
+ }
666
+ export function settings() {
667
+ const cfg = readConfigFile();
668
+ return {
669
+ configFile: CONFIG_FILE,
670
+ profile: { active: VAULT, configured: cfg.profile || '', fromEnv: !!(process.env.TEKJOBS_PROFILE || process.env.TEKJOBS_VAULT), exists: fs.existsSync(VAULT) },
671
+ resumeDir: { path: resumeDir(), configured: cfg.resumeDir || '', exists: fs.existsSync(resumeDir()) },
672
+ resumeSource: cfg.resumeSource || '',
673
+ llm: { command: cfg.llm?.command || 'claude', args: (cfg.llm?.args || ['-p', '--output-format', 'text']).join(' '), configured: !!cfg.llm },
674
+ contact: cfg.contact || '',
675
+ };
676
+ }
677
+ export function saveSettings({ profile, resumeDir: rd, llmCommand, llmArgs, contact } = {}) {
678
+ const patch = {};
679
+ let restart = false;
680
+ if (profile !== undefined) {
681
+ const dir = String(profile).trim();
682
+ if (dir) {
683
+ const abs = path.resolve(dir);
684
+ if (!fs.existsSync(abs)) throw Object.assign(new Error(`${abs} does not exist. Create the folder first, or run tekjobs init <dir> to start a new profile there.`), { status: 400 });
685
+ if (!fs.statSync(abs).isDirectory()) throw Object.assign(new Error(`${abs} is not a folder.`), { status: 400 });
686
+ patch.profile = abs;
687
+ restart = abs !== VAULT;
688
+ } else patch.profile = '';
689
+ }
690
+ if (rd !== undefined) {
691
+ const dir = String(rd).trim();
692
+ if (dir && !fs.existsSync(path.resolve(dir))) throw Object.assign(new Error(`${path.resolve(dir)} does not exist.`), { status: 400 });
693
+ patch.resumeDir = dir ? path.resolve(dir) : '';
694
+ }
695
+ if (llmCommand !== undefined || llmArgs !== undefined) {
696
+ const command = String(llmCommand ?? 'claude').trim() || 'claude';
697
+ const args = String(llmArgs ?? '').split(/\s+/).filter(Boolean);
698
+ patch.llm = command === 'claude' && args.join(' ') === '-p --output-format text' ? '' : { command, args };
699
+ }
700
+ if (contact !== undefined) patch.contact = String(contact).trim();
701
+ writeConfigFile(patch);
702
+ return { ...settings(), restart };
703
+ }
704
+
705
+ // ---------- resume variants ----------
706
+ // A folder of resume files (the master, the design-systems one, the AI one), each usable as the resume of
707
+ // record with one click: `syncResume` reads it into Profile/Resume.md and remembers it as the source.
708
+ const RESUME_EXT = new Set(['.pdf', '.docx', '.md', '.txt']);
709
+ export function listResumes() {
710
+ const dir = resumeDir();
711
+ const current = readConfigFile().resumeSource || '';
712
+ const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => RESUME_EXT.has(path.extname(f).toLowerCase())) : [];
713
+ const rows = files.map((f) => {
714
+ const p = path.join(dir, f);
715
+ const st = fs.statSync(p);
716
+ return { name: f, path: p, size: st.size, modified: st.mtime.toISOString().slice(0, 10), current: path.resolve(current) === path.resolve(p) };
717
+ }).sort((a, b) => b.modified.localeCompare(a.modified) || a.name.localeCompare(b.name));
718
+ return { dir, exists: fs.existsSync(dir), files: rows };
719
+ }
720
+ function resumePath(name) {
721
+ const p = path.join(resumeDir(), path.basename(String(name)));
722
+ if (!fs.existsSync(p)) throw Object.assign(new Error(`No resume file named "${name}" in ${resumeDir()}`), { status: 404 });
723
+ return p;
724
+ }
725
+ export async function useResume(name) {
726
+ const { syncResume } = await import('../../scraper/resume-sync.mjs');
727
+ const r = await syncResume({ source: resumePath(name) });
728
+ return { ...r, files: listResumes().files };
729
+ }
730
+ export function openResume(name) {
731
+ const file = resumePath(name);
732
+ const [bin, args] = process.platform === 'win32' ? ['explorer', [file]] : process.platform === 'darwin' ? ['open', [file]] : ['xdg-open', [file]];
733
+ spawn(bin, args, { detached: true, stdio: 'ignore' }).unref();
734
+ return { ok: true, path: file };
735
+ }
736
+
737
+ // ---------- snippets ----------
738
+ // The copy panel: the lines applications keep asking for (email, phone, links, availability, the salary
739
+ // answer), kept in Profile/Snippets.md as a json block so they are the person's to edit, here or in Obsidian.
740
+ // Until that note exists the panel offers a set read from the profile's Basics, and writes nothing.
741
+ const SNIPPETS_FILE = path.join(VAULT, 'Profile', 'Snippets.md');
742
+ function defaultSnippets() {
743
+ const text = safe(() => fs.readFileSync(P.profile, 'utf8'), '');
744
+ const basic = (label) => { const m = text.match(new RegExp(`\\*\\*${label}:\\*\\*\\s*([^\\n·]*)`)); return m ? m[1].replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/\(".*?"\)/g, '').trim() : ''; };
745
+ const link = (host) => { const m = text.match(new RegExp(`\\((https?://[^)\\s]*${host}[^)\\s]*)\\)`)); return m ? m[1] : ''; };
746
+ const criteria = safe(() => loadCriteria(), {});
747
+ const floor = criteria.salary?.minAnnual ? `$${Number(criteria.salary.minAnnual).toLocaleString('en-US')} base` : '';
748
+ return [
749
+ { group: 'Identity', label: 'Full name', value: basic('Name').replace(/\s*\(.*\)$/, '') },
750
+ { group: 'Identity', label: 'Email', value: basic('Email') },
751
+ { group: 'Identity', label: 'Phone', value: basic('Phone') },
752
+ { group: 'Identity', label: 'Location', value: basic('Location') },
753
+ { group: 'Links', label: 'Portfolio', value: link('') && !/linkedin|github/.test(link('')) ? link('') : '' },
754
+ { group: 'Links', label: 'LinkedIn', value: link('linkedin.com') },
755
+ { group: 'Links', label: 'GitHub', value: link('github.com') },
756
+ { group: 'Answers', label: 'Available from', value: basic('Available from') },
757
+ { group: 'Answers', label: 'Salary expectation', value: floor },
758
+ { group: 'Answers', label: 'Work authorization', value: '' },
759
+ { group: 'Answers', label: 'Remote', value: 'Remote only.' },
760
+ ].filter((s) => s.label);
761
+ }
762
+ export function getSnippets() {
763
+ const exists = fs.existsSync(SNIPPETS_FILE);
764
+ if (!exists) return { items: defaultSnippets(), exists, path: SNIPPETS_FILE };
765
+ const m = fs.readFileSync(SNIPPETS_FILE, 'utf8').match(JSON_BLOCK);
766
+ const items = m ? safe(() => JSON.parse(m[1]), []) : [];
767
+ return { items: Array.isArray(items) ? items.filter((s) => s && typeof s === 'object').map((s) => ({ group: String(s.group || ''), label: String(s.label || ''), value: String(s.value ?? '') })) : [], exists, path: SNIPPETS_FILE };
768
+ }
769
+ export function saveSnippets(list) {
770
+ if (!Array.isArray(list)) throw Object.assign(new Error('snippets must be a list'), { status: 400 });
771
+ const items = list.map((s) => ({ group: String(s?.group || '').trim(), label: String(s?.label || '').trim(), value: String(s?.value ?? '') })).filter((s) => s.label);
772
+ fs.mkdirSync(path.dirname(SNIPPETS_FILE), { recursive: true });
773
+ const body = `---\ntype: snippets\nupdated: ${isoDay()}\n---\n# Copy panel\n\nWhat the copy panel in the app offers, one click each. Edit here or in the app; \`group\` is the heading, \`label\` the button, \`value\` what lands on the clipboard. Multi-line values are fine.\n\n\`\`\`json\n${JSON.stringify(items, null, 2)}\n\`\`\`\n`;
774
+ fs.writeFileSync(SNIPPETS_FILE, body);
775
+ return getSnippets();
776
+ }
777
+
778
+ // ---------- criteria presets ----------
779
+ // Named criteria sets under Targets/Criteria/, one note each, same ```json shape as Search Criteria.md.
780
+ // The daily scan uses the active note; a preset is for a different kind of search (a wider net, a different
781
+ // title, a lower floor) that runs on request: from the Runs page, or `tekjobs scan --criteria "<name>"`.
782
+ export function criteriaPresets() {
783
+ if (!fs.existsSync(P.criteriaDir)) return [];
784
+ const active = getCriteria().fingerprint;
785
+ return fs.readdirSync(P.criteriaDir).filter((f) => f.endsWith('.md')).sort().map((f) => {
786
+ const file = path.join(P.criteriaDir, f);
787
+ const parsed = safe(() => loadCriteria(file), null);
788
+ const fingerprint = parsed ? weightsFingerprint(parsed) : '';
789
+ return { name: f.replace(/\.md$/, ''), file, valid: !!parsed, minScore: parsed?.minScore ?? null, floor: parsed?.salary?.minAnnual ?? null, titles: Object.keys(parsed?.titleTerms || {}).length, fingerprint, active: !!fingerprint && fingerprint === active, updated: fs.statSync(file).mtime.toISOString().slice(0, 10) };
790
+ });
791
+ }
792
+ export function getCriteriaPreset(name) {
793
+ const file = criteriaPresetFile(name);
794
+ if (!fs.existsSync(file)) throw Object.assign(new Error(`No criteria preset named "${name}"`), { status: 404 });
795
+ const m = fs.readFileSync(file, 'utf8').match(JSON_BLOCK);
796
+ const parsed = m ? safe(() => JSON.parse(m[1]), null) : null;
797
+ return { name: path.basename(file, '.md'), file, raw: m ? m[1] : '', parsed, fingerprint: parsed ? weightsFingerprint(parsed) : '' };
798
+ }
799
+ export function saveCriteriaPreset(name, raw) {
800
+ let parsed; try { parsed = JSON.parse(raw); } catch (e) { throw Object.assign(new Error(`Not valid JSON: ${e.message}`), { status: 400 }); }
801
+ const file = criteriaPresetFile(name);
802
+ const clean = path.basename(file, '.md');
803
+ fs.mkdirSync(P.criteriaDir, { recursive: true });
804
+ if (fs.existsSync(file)) {
805
+ const md = fs.readFileSync(file, 'utf8');
806
+ fs.writeFileSync(file, JSON_BLOCK.test(md) ? replaceJsonBlock(md, parsed).replace(/^updated: .*$/m, `updated: ${isoDay()}`) : `${md.replace(/\n+$/, '')}\n\n\`\`\`json\n${JSON.stringify(parsed, null, 2)}\n\`\`\`\n`);
807
+ } else {
808
+ fs.writeFileSync(file, [
809
+ '---', 'type: criteria-preset', `updated: ${isoDay()}`, '---',
810
+ `# ${clean}`, '',
811
+ `> A criteria set the scan can run with instead of [[Targets/Search Criteria]]. Same JSON shape, same scoring. Load it on the app's Criteria page, make it the active set, or run one scan with it: \`tekjobs scan --criteria "${clean}"\`. Notes written by such a run carry this set's weights fingerprint.`, '',
812
+ '```json', JSON.stringify(parsed, null, 2), '```', '',
813
+ ].join('\n'));
814
+ }
815
+ return getCriteriaPreset(clean);
816
+ }
817
+ export function deleteCriteriaPreset(name) {
818
+ const file = criteriaPresetFile(name);
819
+ if (fs.existsSync(file)) fs.unlinkSync(file);
820
+ return criteriaPresets();
821
+ }
822
+ /** Copy a preset's JSON into Search Criteria.md, so the daily scan and every other reader use it. */
823
+ export function activateCriteriaPreset(name) {
824
+ return setCriteria(getCriteriaPreset(name).raw);
825
+ }
826
+ /** The watchlist rows, each with where the board stands: state, last success, last attempt, last error. */
827
+ export function companies() {
828
+ const h = loadHealth();
829
+ return loadCompanies().map((c) => {
830
+ const b = h.boards[`${c.ats}:${c.slug}`] || {};
831
+ return { ...c, health: { state: healthState(b, c.status), lastAttempt: b.lastAttempt || '', lastOk: b.lastOk || '', lastOkJobs: b.lastOkJobs ?? null, lastError: b.lastError || '', failStreak: b.failStreak || 0 } };
832
+ });
833
+ }
834
+
835
+ /** The aggregator feeds: which are on under the criteria's openSources, and how each did last time. */
836
+ export async function feeds() {
837
+ const { OPEN_SOURCES } = await import('../../scraper/sources-extra.mjs');
838
+ const open = safe(() => loadCriteria().openSources, {}) || {};
839
+ const h = loadHealth();
840
+ const row = (key, label, enabled, needsKey = false) => {
841
+ const f = h.feeds[label] || {};
842
+ return { key, label, enabled, needsKey, health: { state: healthState(f, enabled ? '' : 'off'), lastAttempt: f.lastAttempt || '', lastOk: f.lastOk || '', lastOkJobs: f.lastOkJobs ?? null, lastError: f.lastError || '' } };
843
+ };
844
+ return [
845
+ row('remoteok', 'RemoteOK', open.remoteok !== false),
846
+ row('hn', 'HN Who is hiring', open.hn !== false),
847
+ ...Object.entries(OPEN_SOURCES).map(([key, def]) => row(key, def.label, def.defaultOn ? open[key] !== false : !!open[key], key === 'adzuna' || key === 'usajobs')),
848
+ ];
849
+ }
850
+
851
+ /**
852
+ * Views the criteria imply, offered before the saved ones: the person's own bar, floor and remote rule turned
853
+ * into links. They are computed, not stored, so they follow the criteria when it changes.
854
+ */
855
+ export function defaultViews() {
856
+ const c = safe(() => loadCriteria(), {}) || {};
857
+ const floor = Number(c.salary?.minAnnual) || 0;
858
+ const stretch = Number(c.salary?.stretchAnnual) || 0;
859
+ const remote = !!c.location?.requireRemote;
860
+ const k = (n) => `$${Math.round(n / 1000)}k`;
861
+ const views = [
862
+ { name: 'Design eng at floor', query: 'kind=design-eng&band=floor', hint: 'design-engineering titles with a stated range at or above your floor' },
863
+ { name: 'New this week', query: 'status=new&foundDays=7', hint: 'unreviewed, found in the last seven days' },
864
+ ];
865
+ if (floor) views.push({ name: `${remote ? 'Remote, ' : ''}${k(floor)}+`, query: `${remote ? 'remote=1&' : ''}payMin=${floor}`, hint: `top of the stated range at least ${k(floor)}${remote ? ', remote' : ''}` });
866
+ if (stretch && floor) views.push({ name: `Stretch ${k(stretch)} to ${k(floor)}`, query: 'band=stretch', hint: 'stated pay under the floor but above the stretch line' });
867
+ views.push({ name: 'Waiting on a reply', query: 'status=applied', hint: 'applied, nothing back yet' });
868
+ views.push({ name: 'Added by me', query: 'source=link,mail', hint: 'notes you added by link or from mail, whatever they scored' });
869
+ return views;
870
+ }
871
+
872
+ // ---------- saved views ----------
873
+ // Named filter sets for the Jobs page, kept in the vault like every other setting: Targets/Job Views.md holds
874
+ // a ```json list of { name, query }, where query is the Jobs page's own query string.
875
+ const VIEWS_FILE = path.join(VAULT, 'Targets', 'Job Views.md');
876
+ export function getViews() {
877
+ if (!fs.existsSync(VIEWS_FILE)) return [];
878
+ const m = fs.readFileSync(VIEWS_FILE, 'utf8').match(JSON_BLOCK);
879
+ const list = m ? safe(() => JSON.parse(m[1]), []) : [];
880
+ return Array.isArray(list) ? list.filter((v) => v && v.name && typeof v.query === 'string') : [];
881
+ }
882
+ export function saveViews(list) {
883
+ const clean = [].concat(list || []).filter((v) => v && v.name && typeof v.query === 'string').map((v) => ({ name: String(v.name).trim().slice(0, 60), query: String(v.query).replace(/^\?/, '').slice(0, 2000) })).filter((v) => v.name);
884
+ const body = fs.existsSync(VIEWS_FILE) && JSON_BLOCK.test(fs.readFileSync(VIEWS_FILE, 'utf8'))
885
+ ? replaceJsonBlock(fs.readFileSync(VIEWS_FILE, 'utf8'), clean).replace(/^updated: .*$/m, `updated: ${isoDay()}`)
886
+ : ['---', 'type: config', `updated: ${isoDay()}`, '---', '# Job views', '', '> Saved filter sets for the app\'s Jobs page. Each `query` is the page\'s own query string; open one as `/#/jobs?<query>`. The app reads and writes this list; edit it here if you like.', '', '```json', JSON.stringify(clean, null, 2), '```', ''].join('\n');
887
+ fs.writeFileSync(VIEWS_FILE, body);
888
+ return getViews();
889
+ }
890
+ /** Show the note in the file manager (Explorer, Finder, or the desktop's default), selected where it can be. */
891
+ export function revealJob(id) {
892
+ const file = notePath(id);
893
+ const [bin, args] = process.platform === 'win32' ? ['explorer', [`/select,${file}`]]
894
+ : process.platform === 'darwin' ? ['open', ['-R', file]]
895
+ : ['xdg-open', [path.dirname(file)]];
896
+ spawn(bin, args, { detached: true, stdio: 'ignore' }).unref();
897
+ return { ok: true, path: file };
898
+ }
899
+
900
+ /**
901
+ * Attach the real posting to a note that was created without one (from an email, or a thin page). The note
902
+ * keeps its name, status, status log, notes, packet, letter and tailored resume; the posting's own facts
903
+ * replace the frontmatter's, the score is recomputed from the posting, and the description section is
904
+ * replaced. A person asks for this on a specific note, so it is the one place the app rewrites what it wrote.
905
+ */
906
+ export async function attachPosting(id, href, { linkOnly = false } = {}) {
907
+ const file = notePath(id);
908
+ if (!/^https?:\/\//i.test(String(href || ''))) throw Object.assign(new Error('Give an http(s) link.'), { status: 400 });
909
+ const { readLink } = await import('../../scraper/import-link.mjs');
910
+ const { weightsFingerprint: fp } = await import('../../scraper/rescore.mjs');
911
+ const { loadSeen, saveSeen } = await import('../../scraper/vault.mjs');
912
+ const criteria = loadCriteria();
913
+ const r = linkOnly ? { ok: false, error: '' } : await readLink(href, { criteria });
914
+ // A link the reader cannot turn into a posting (a page behind a login, a board with no API, an odd site)
915
+ // still belongs on the note: Open posting then goes somewhere real. Only the link and a log line are written.
916
+ if (!r.ok) return attachLinkOnly(file, id, String(href).trim(), r.error);
917
+ const { url, job: j, scored } = r;
918
+ let text = fs.readFileSync(file, 'utf8');
919
+ const y = (v) => JSON.stringify(v ?? '');
920
+ const setFm = (k, v) => { text = new RegExp(`^${k}: .*$`, 'm').test(text) ? text.replace(new RegExp(`^${k}: .*$`, 'm'), () => `${k}: ${v}`) : text.replace(/^---\n([\s\S]*?)\n---/, (m, body) => `---\n${body}\n${k}: ${v}\n---`); };
921
+ setFm('company', y(j.company)); setFm('title', y(j.title)); setFm('location', y(j.location)); setFm('remote', j.remote ? 'true' : 'false');
922
+ setFm('source', j.source); setFm('url', y(j.url || url)); setFm('score', scored.score); setFm('weights', fp(criteria));
923
+ setFm('posted', y(j.posted ? String(j.posted).slice(0, 10) : '')); setFm('salary', y(j.salary)); setFm('salary_max', j.salaryMax || ''); setFm('pay_band', scored.payBand || 'unknown');
924
+ setFm('department', y(j.department)); setFm('job_id', y(j.id));
925
+ text = text.replace(/^# .*$/m, () => `# ${j.title} @ ${j.company}`);
926
+ text = text.replace(/^\*\*\[Open posting\]\(.*?\)\*\*.*$/m, () => `**[Open posting](${j.url || url})** · ${j.location || 'location n/a'} · ${j.remote ? 'Remote' : 'On-site/Hybrid'} · score **${scored.score}**${j.salary ? ` · ${j.salary}` : ''}`);
927
+ text = text.replace(/^## Why it matched\s*\n[\s\S]*?(?=^## )/m, () => `## Why it matched\n${scored.reasons.map((x) => `- ${x}`).join('\n')}\n\n`);
928
+ text = text.replace(/^(## Status log\s*\n(?:- .*\n)*)/m, (m) => `${m}- ${isoDay()} — posting attached from ${url} (score ${scored.score}; was ${(m.match(/score (-?\d+)/) || [, '?'])[1]})\n`);
929
+ const desc = (j.descriptionText || '').slice(0, criteria.maxDescriptionChars ?? 6000);
930
+ const block = `## Job description\n> Source: ${j.source} · attached ${isoDay()}${(j.descriptionText || '').length > desc.length ? ' · truncated, open the posting for the full text' : ''}\n\n${desc}\n`;
931
+ text = /^## Job description/m.test(text) ? text.replace(/^## Job description[\s\S]*$/m, () => block) : `${text.replace(/\n+$/, '')}\n\n${block}`;
932
+ fs.writeFileSync(file, text);
933
+ const seen = loadSeen();
934
+ seen[j.id] = { path: file, firstSeen: new Date().toISOString(), company: j.company, title: j.title, companyKey: `link:${j.source}`, score: scored.score };
935
+ saveSeen(seen);
936
+ cache.key = '';
937
+ return getJob(id);
938
+ }
939
+
940
+ function attachLinkOnly(file, id, href, why) {
941
+ let text = fs.readFileSync(file, 'utf8');
942
+ const before = (text.match(/^url: (.*)$/m) || [, ''])[1];
943
+ text = replaceFrontmatterLine(text, 'url', JSON.stringify(href));
944
+ text = /^\*\*\[Open posting\]\(.*?\)\*\*/m.test(text)
945
+ ? text.replace(/^\*\*\[Open posting\]\(.*?\)\*\*/m, () => `**[Open posting](${href})**`)
946
+ : text.replace(/^(# .*\n)/m, (m) => `${m}\n**[Open posting](${href})**\n`);
947
+ text = appendUnderHeading(text, 'Status log', `- ${isoDay()} — posting link set to ${href}${why ? ` (the posting itself could not be read: ${why})` : ' (link only, by request)'}${before && !/mail\.google\.com/.test(before) ? `; was ${before.replace(/^"|"$/g, '')}` : ''}`);
948
+ fs.writeFileSync(file, text);
949
+ cache.key = '';
950
+ return { ...getJob(id), linkOnly: true, warning: why || '' };
951
+ }
952
+
953
+ /** Add postings from links the person pasted. One result per link, in order; a failure on one does not stop the rest. */
954
+ export async function importLinks(urls) {
955
+ const list = [].concat(urls || []).flatMap((s) => String(s).split(/\s+/)).map((s) => s.trim()).filter(Boolean);
956
+ if (!list.length) throw Object.assign(new Error('Give at least one link.'), { status: 400 });
957
+ if (list.length > 25) throw Object.assign(new Error('At most 25 links at a time.'), { status: 400 });
958
+ const { importLink } = await import('../../scraper/import-link.mjs');
959
+ const criteria = loadCriteria();
960
+ const out = [];
961
+ for (const u of list) {
962
+ const r = await importLink(u, { criteria });
963
+ out.push(r.ok
964
+ ? { url: r.url, link: r.job.url || r.url, ok: true, added: r.added, id: r.note ? path.basename(r.note, '.md') : r.existing ? path.basename(r.existing, '.md') : '', company: r.job.company, title: r.job.title, location: r.job.location, score: r.score, reason: r.reason || '', belowMin: !!r.belowMin }
965
+ : { url: r.url || u, ok: false, error: r.error });
966
+ }
967
+ return out;
968
+ }
969
+
970
+ export function addCompany({ name, ats, slug, tier = 'B', notes = '' }) {
971
+ if (!name || !ats || !slug) throw Object.assign(new Error('name, ats and slug are required'), { status: 400 });
972
+ if (loadCompanies().some((c) => c.ats === ats.toLowerCase() && c.slug === slug)) throw Object.assign(new Error('that board is already in the table'), { status: 409 });
973
+ const md = fs.readFileSync(P.companies, 'utf8').replace(/\n+$/, '');
974
+ fs.writeFileSync(P.companies, `${md}\n| ${name} | ${ats.toLowerCase()} | ${slug} | ${tier} | | ${notes} |\n`);
975
+ return loadCompanies();
976
+ }
977
+
978
+ // ---------- profile ----------
979
+ // The notes applications are written from. Profile, Positioning and Voice are the person's to edit; the resume
980
+ // note is synced from the resume file and is read here, not written.
981
+ const PROFILE_NOTES = [
982
+ { key: 'profile', title: 'Profile', rel: 'Profile/Profile.md', editable: true, hint: 'Who you are: location, availability, links, the facts every application reads. The onboarding interview wrote it; you keep it current.' },
983
+ { key: 'positioning', title: 'Positioning', rel: 'Profile/Positioning.md', editable: true, hint: 'How the story is told: which half leads for which posting, the evidence rule, what must never be claimed.' },
984
+ { key: 'voice', title: 'Voice', rel: 'Profile/Voice.md', editable: true, hint: 'How you write. The cover-letter writer treats this note as binding; edit it here to change every letter after.' },
985
+ { key: 'resume', title: 'Resume', rel: RESUME_NOTE, editable: false, hint: 'The resume of record, synced from your resume file. Edit the file and run `tekjobs resume sync`; this note is replaced on every sync.' },
986
+ ];
987
+ export function profile() {
988
+ const read = (rel) => safe(() => fs.readFileSync(path.join(VAULT, rel), 'utf8'), '');
989
+ const notes = PROFILE_NOTES.map((n) => ({ ...n, path: path.join(VAULT, n.rel), markdown: read(n.rel), exists: fs.existsSync(path.join(VAULT, n.rel)) }));
990
+ const resume = notes.find((n) => n.key === 'resume').markdown || [...LEGACY_RESUME_NOTES, 'Profile/Resume - Source.md'].map(read).find(Boolean) || '';
991
+ return { profile: read('Profile/Profile.md'), positioning: read('Profile/Positioning.md'), voice: read('Profile/Voice.md'), resume, notes };
992
+ }
993
+ /**
994
+ * The profile as structure: who, what they aim at and whether the criteria agree, the constraints, the documents
995
+ * and their age, what is stale, and which note feeds which draft. Read from the Markdown each time; nothing is
996
+ * stored twice. Parsing follows the note's own headings and "- **Field:** value" lines.
997
+ */
998
+ export function profileSummary() {
999
+ const md = safe(() => fs.readFileSync(P.profile, 'utf8'), '');
1000
+ const fmOf = (text) => { const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); const out = {}; if (m) for (const line of m[1].split(/\r?\n/)) { const i = line.indexOf(':'); if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim(); } return out; };
1001
+ const fm = fmOf(md);
1002
+ const section = (name) => { const m = md.match(new RegExp(`^## ${name.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}[^\\n]*\\n([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, 'm')); return m ? m[1].trim() : ''; };
1003
+ const field = (text, label) => { const m = text.match(new RegExp(`^- \\*\\*${label}[^*]*\\*\\*\\s*(.*)$`, 'm')); return m ? m[1].trim().replace(/\*\*/g, '').replace(/\s+/g, ' ') : ''; };
1004
+ const basicsText = section('Basics'), constraintsText = section('Constraints & preferences') || section('Constraints');
1005
+ const links = (field(basicsText, 'Links').match(/https?:\/\/[^\s·,)]+|[a-z0-9.-]+\.[a-z]{2,}(?:\/[^\s·,)]*)?/gi) || []).filter((x, i, arr) => arr.indexOf(x) === i);
1006
+ const portfolio = field(basicsText, 'Portfolio'); if (portfolio && !links.includes(portfolio)) links.unshift(portfolio);
1007
+ const basics = { name: field(basicsText, 'Name'), location: field(basicsText, 'Location'), email: field(basicsText, 'Email'), links, currentRole: field(basicsText, 'Current role') || field(basicsText, 'Current') || field(basicsText, 'Role') };
1008
+ const whatHeading = (md.match(/^## (What [^\n]*)$/m) || [])[1] || 'What you are, in three sentences';
1009
+ const summaryText = section(whatHeading).replace(/^\(The interview fills this in[\s\S]*$/m, '').replace(/^>.*$/gm, '').trim();
1010
+ const proofPoints = section('Proof points').split(/\r?\n/).map((l) => l.replace(/^-\s*/, '').trim()).filter((l) => l && !/^\(/.test(l)).map((text) => ({ text, hasNumber: /\d/.test(text) }));
1011
+ // Target roles: the table's rows, one entry per title, with the criteria title term that would find it.
1012
+ const criteria = safe(() => loadCriteria(), {}) || {};
1013
+ const terms = Object.keys(criteria.titleTerms || {}).map((t) => t.toLowerCase());
1014
+ const targets = [];
1015
+ for (const line of section('Target roles').split(/\r?\n/)) {
1016
+ const cells = line.split('|').slice(1, -1).map((c) => c.trim());
1017
+ if (cells.length < 2 || !/^[A-Z]$/.test(cells[0])) continue;
1018
+ for (const title of cells[1].split(/\s*[·,;]\s*/).map((t) => t.trim()).filter(Boolean)) {
1019
+ const lower = title.toLowerCase();
1020
+ const term = terms.find((t) => lower.includes(t) || t.includes(lower)) || '';
1021
+ targets.push({ tier: cells[0], title, term });
1022
+ }
1023
+ }
1024
+ // Constraints beside what the scan enforces.
1025
+ const moneyIn = (text) => { const m = text.replace(/,/g, '').match(/\$?\s*(\d{2,3})\s*k\b|\$\s*(\d{5,7})/i); return m ? (m[1] ? Number(m[1]) * 1000 : Number(m[2])) : null; };
1026
+ const bullets = constraintsText.split(/\r?\n/).map((l) => l.replace(/^-\s*/, '').replace(/\*\*/g, '').trim()).filter(Boolean);
1027
+ const bullet = (re) => bullets.find((b) => re.test(b)) || '';
1028
+ const constraint = (label, re) => field(constraintsText, label) || bullet(re);
1029
+ const payText = constraint('Pay floor', /floor|\bcomp\b|salary|\bpay\b|\$\s?\d/i);
1030
+ const nums = payText ? [...payText.replace(/,/g, '').matchAll(/\$?\s*(\d{2,3})\s*k\b|\$\s*(\d{5,7})/gi)].map((m) => (m[1] ? Number(m[1]) * 1000 : Number(m[2]))) : [];
1031
+ const profileFloor = nums[0] ?? null, profileStretch = nums[1] ?? null;
1032
+ const critFloor = criteria.salary?.minAnnual ?? null, critStretch = criteria.salary?.stretchAnnual ?? null;
1033
+ const workMode = constraint('Work mode', /\bremote\b|hybrid|on-?site/i);
1034
+ const employmentText = constraint('Employment', /full-time|part-time|contract|freelance/i);
1035
+ const startText = constraint('Earliest start', /\bstart\b|available|earliest|notice/i) || field(basicsText, 'Available from');
1036
+ const stageText = constraint('Company stage', /stage|\bsize\b|headcount|startup|enterprise/i);
1037
+ const avoidText = constraint('Industries to avoid', /avoid|industr|exclude|never/i);
1038
+ // "Remote only. No hybrid, no relocation." is remote only: negated words are removed before the test.
1039
+ const workModePlain = workMode.replace(/\b(no|not|never|without)\s+(hybrid|on-?site|office|relocation|relocating)\b/gi, '');
1040
+ const profileRemoteOnly = /remote[\s-]*only|only remote/i.test(workMode) || (/^remote\b/i.test(workModePlain) && !/hybrid|on-?site|office|open to/i.test(workModePlain));
1041
+ const fmt = (n) => (n == null ? '' : `$${Math.round(n / 1000)}k`);
1042
+ const level = (ok, has) => (!has ? 'missing' : ok ? 'ok' : 'warn');
1043
+ const constraints = [
1044
+ { label: 'Pay floor', profile: profileFloor ? fmt(profileFloor) : payText, criteria: fmt(critFloor), level: level(profileFloor && critFloor && profileFloor === critFloor, !!(payText || critFloor)) },
1045
+ { label: 'Stretch', profile: profileStretch ? fmt(profileStretch) : '', criteria: fmt(critStretch), level: profileStretch || critStretch ? level(profileStretch && critStretch && profileStretch === critStretch, true) : 'info' },
1046
+ { label: 'Work mode', profile: workMode, criteria: criteria.location?.requireRemote ? 'remote only' : 'remote scores higher, on-site allowed', level: level(workMode ? profileRemoteOnly === !!criteria.location?.requireRemote : false, !!workMode) },
1047
+ { label: 'Employment', profile: employmentText, criteria: '', level: employmentText ? 'info' : 'missing' },
1048
+ { label: 'Company stage / size', profile: stageText, criteria: '', level: 'info' },
1049
+ { label: 'Industries to avoid', profile: avoidText, criteria: (criteria.titleExclude || []).length ? `${criteria.titleExclude.length} excluded title words` : '', level: 'info' },
1050
+ { label: 'Earliest start', profile: startText, criteria: '', level: startText ? 'info' : 'missing' },
1051
+ ];
1052
+ // Documents and their age.
1053
+ const ageDays = (file) => safe(() => Math.round((Date.now() - fs.statSync(file).mtimeMs) / 864e5), null);
1054
+ const resumeFile = path.join(VAULT, RESUME_NOTE);
1055
+ const resumeAge = ageDays(resumeFile);
1056
+ const resumes = safe(() => listResumes(), { files: [] });
1057
+ const docState = (rel, label, freshDays) => { const file = path.join(VAULT, rel); const exists = fs.existsSync(file); const age = exists ? ageDays(file) : null; return { label, state: !exists ? 'missing' : freshDays && age > freshDays ? `${age} days old` : `updated ${age === 0 ? 'today' : age + 'd ago'}`, level: !exists ? 'missing' : freshDays && age > freshDays ? 'warn' : 'ok', where: rel }; };
1058
+ const documents = [
1059
+ docState(RESUME_NOTE, 'Resume of record', 60),
1060
+ { 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 || '' },
1061
+ docState('Profile/Positioning.md', 'Positioning'),
1062
+ docState('Profile/Voice.md', 'Voice'),
1063
+ docState('Profile/Snippets.md', 'Snippets (copy panel)'),
1064
+ ];
1065
+ // What would trip a reader.
1066
+ const attention = [];
1067
+ if (!md) attention.push({ level: 'missing', note: 'Profile', text: 'Profile/Profile.md does not exist. Run the onboarding interview.' });
1068
+ if (fm.status === 'draft') attention.push({ level: 'warn', note: 'Profile', text: 'The profile is still marked draft.' });
1069
+ for (const [k, label] of [['name', 'Name'], ['location', 'Location'], ['email', 'Email'], ['currentRole', 'Current role']]) if (!basics[k]) attention.push({ level: 'missing', note: 'Profile', text: `Basics: ${label} is empty.` });
1070
+ if (!summaryText) attention.push({ level: 'missing', note: 'Profile', text: 'The three-sentence summary is the template; every letter opens from it.' });
1071
+ if (targets.length === 0) attention.push({ level: 'missing', note: 'Profile', text: 'No target roles in the table.' });
1072
+ for (const t of targets.filter((t) => !t.term)) attention.push({ level: 'warn', note: 'Criteria', text: `"${t.title}" has no title term in the criteria, so the scan never finds it.` });
1073
+ for (const c of constraints.filter((c) => c.level === 'missing')) attention.push({ level: 'missing', note: 'Profile', text: `Constraints: ${c.label} is empty.` });
1074
+ for (const c of constraints.filter((c) => c.level === 'warn')) attention.push({ level: 'warn', note: 'Criteria', text: `${c.label}: the profile says ${c.profile}, the criteria say ${c.criteria || 'nothing'}.` });
1075
+ // A profile without a Proof points section is not wrong (letters take their numbers from the resume of record); one with the section left empty is.
1076
+ if (proofPoints.length === 0 && /^## Proof points/m.test(md)) attention.push({ level: 'warn', note: 'Profile', text: 'The Proof points section is empty; letters fall back to the resume of record for numbers.' });
1077
+ for (const p of proofPoints.filter((p) => !p.hasNumber)) attention.push({ level: 'warn', note: 'Profile', text: `Proof point without a number: "${p.text.slice(0, 70)}${p.text.length > 70 ? '…' : ''}"` });
1078
+ for (const d of documents.filter((d) => d.level !== 'ok' && d.level !== 'info')) attention.push({ level: d.level, note: d.label, text: d.level === 'missing' ? `${d.label} is missing (${d.where}).` : `${d.label} is ${d.state}; sync it if the file changed.` });
1079
+ const feeds = [
1080
+ { produces: 'Match scores and the daily scan', reads: 'Targets/Search Criteria.md only' },
1081
+ { produces: 'Application packet: narrative, tailored summary and bullets, risks', reads: 'Profile, Positioning, the resume of record, the posting' },
1082
+ { produces: 'Cover letters', reads: 'Voice for how it sounds; the resume of record as the only source of facts; Profile and Positioning for what leads' },
1083
+ { produces: 'Tailored resumes', reads: 'The resume of record, reordered and pruned; never added to' },
1084
+ { produces: 'The copy panel', reads: 'Snippets, seeded from Profile Basics' },
1085
+ { produces: 'The onboarding interview', reads: 'Resume - Source, then writes Profile and the criteria' },
1086
+ ];
1087
+ return { exists: !!md, status: fm.status || '', updated: fm.updated || '', basics, summary: summaryText, targets, constraints, proofPoints, documents, attention, feeds, resumeAgeDays: resumeAge };
1088
+ }
1089
+
1090
+ export function saveProfileNote(key, markdown) {
1091
+ const note = PROFILE_NOTES.find((n) => n.key === key);
1092
+ if (!note) throw Object.assign(new Error(`No profile note "${key}"`), { status: 404 });
1093
+ if (!note.editable) throw Object.assign(new Error(`${note.title} is synced from your resume file; edit the file and run tekjobs resume sync.`), { status: 400 });
1094
+ if (key === 'profile') return saveProfile(markdown);
1095
+ if (!markdown || markdown.trim().length < 40) throw Object.assign(new Error(`${note.title} is too short to save.`), { status: 400 });
1096
+ const file = path.join(VAULT, note.rel);
1097
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1098
+ // Same habit as saveProfile: the previous version is kept beside the note, once per day.
1099
+ if (fs.existsSync(file)) fs.copyFileSync(file, file.replace(/\.md$/, `.before-${isoDay()}.md`));
1100
+ fs.writeFileSync(file, markdown.replace(/^updated: .*$/m, `updated: ${isoDay()}`));
1101
+ return { saved: file };
1102
+ }
1103
+
1104
+ // ---------- scan ----------
1105
+ const scan = { running: false, startedAt: null, finishedAt: null, exitCode: null, output: [], criteria: '' };
1106
+ export function scanStatus() { return { ...scan, output: scan.output.slice(-200) }; }
1107
+ /**
1108
+ * The top of the last scan's ranking, dry or real, from the snapshot every run writes. This is how the
1109
+ * onboarding shows "here is what your criteria find" before a single note exists: a dry run writes no notes,
1110
+ * so search_jobs has nothing to say until the first real scan.
1111
+ */
1112
+ export function scanPreview({ limit = 15 } = {}) {
1113
+ let snap;
1114
+ try { snap = JSON.parse(fs.readFileSync(P.lastRun, 'utf8')); } catch { return { when: null, minScore: null, total: 0, rows: [], note: 'No scan has run yet for this profile folder. run_scan first.' }; }
1115
+ const rows = (snap.jobs || []).slice(0, Math.max(1, Math.min(50, Number(limit) || 15))).map((j) => ({
1116
+ score: j.score, company: j.company, title: j.title, location: j.location, remote: !!j.remote, salary: j.salary || '', payBand: j.payBand, posted: j.posted, url: j.url, source: j.source, isNew: !!j.isNew,
1117
+ reasons: (j.reasons || []).slice(0, 3),
1118
+ }));
1119
+ return { when: snap.when, minScore: snap.minScore, total: (snap.jobs || []).length, aboveBar: (snap.jobs || []).filter((j) => j.score >= (snap.minScore || 0)).length, failed: (snap.failed || []).length, rows };
1120
+ }
1121
+ export function runScan(args = [], { criteria = '', retryFailed = false, via = 'app' } = {}) {
1122
+ if (scan.running) return scanStatus();
1123
+ if (retryFailed) args = [...args, '--retry-failed'];
1124
+ if (criteria) getCriteriaPreset(criteria); // 404 now rather than a dead child process later
1125
+ scan.running = true; scan.startedAt = new Date().toISOString(); scan.finishedAt = null; scan.exitCode = null; scan.output = []; scan.criteria = criteria;
1126
+ const child = spawn(process.execPath, [path.join(ROOT, 'run.mjs'), ...args, ...(criteria ? ['--criteria', criteria] : [])], { cwd: ROOT, env: { ...process.env, TEKJOBS_RUN_VIA: via } });
1127
+ const push = (d) => { for (const l of String(d).split(/\r?\n/)) if (l.trim()) scan.output.push(l); };
1128
+ child.stdout.on('data', push); child.stderr.on('data', push);
1129
+ child.on('close', (code) => { scan.running = false; scan.exitCode = code; scan.finishedAt = new Date().toISOString(); cache.key = ''; });
1130
+ return scanStatus();
1131
+ }