@timurtekb/tekjobs 0.48.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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-BAca4QLI.js"></script>
11
+ <script type="module" crossorigin src="/assets/index-DmICnwI8.js"></script>
12
12
  <link rel="stylesheet" crossorigin href="/assets/index-DXU1rm72.css">
13
13
  </head>
14
14
  <body>
@@ -0,0 +1,150 @@
1
+ // Calendar check: a read-only pass over the person's Google Calendar for interviews and other events about jobs
2
+ // on their board, through the same local CLI that writes letters and reads mail, with its Google Calendar
3
+ // connector. The run is boxed: only the connector's read tools are allowed and every write tool (create, update,
4
+ // delete, respond) plus Bash/Write/Edit are denied by name, so a run can read a calendar and nothing else.
5
+ //
6
+ // The model finds and extracts; everything after that is ordinary code. Each event is matched to a job note by
7
+ // company, then shown as a proposal. Confirming puts the event's time into the note's "Interview on" field (so
8
+ // the Calendar page and the .ics feed carry it), moves a note that is not yet interviewing to interviewing, and
9
+ // records the event on the note. Nothing is ever written to the calendar.
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { DATA_DIR } from '../../scraper/config.mjs';
13
+ import { sameCompany } from '../../scraper/linkedin.mjs';
14
+ import * as store from './store.mjs';
15
+ import { runLLM, runnerConfig } from './cover-letter.mjs';
16
+
17
+ const FILE = path.join(DATA_DIR, 'calendar-check.json');
18
+ const CAL = 'mcp__claude_ai_Google_Calendar__';
19
+ export const READ_TOOLS = ['list_calendars', 'list_events', 'search_events', 'get_event'].map((t) => `${CAL}${t}`);
20
+ export const DENY_TOOLS = ['create_event', 'update_event', 'delete_event', 'respond_to_event', 'suggest_time'].map((t) => `${CAL}${t}`)
21
+ .concat(['Bash', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'WebFetch', 'WebSearch']);
22
+ export const KINDS = ['interview', 'screen', 'onsite', 'offer-call', 'recruiter-call', 'deadline', 'other'];
23
+ const ACTIVE = new Set(['reviewing', 'applying', 'ready', 'applied', 'interviewing', 'offer']);
24
+
25
+ function load() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch { return { lastRun: null, days: null, items: [] }; } }
26
+ function persist(d) { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(FILE, JSON.stringify(d, null, 2)); }
27
+
28
+ /** The extraction prompt. The companies are a hint for recognition, not a filter: an interview at a company with no note is still reported. */
29
+ export function prompt({ days = 21, back = 7, companies = [] } = {}) {
30
+ return `You are checking a job seeker's Google Calendar for events about their job search. Use ONLY the Google Calendar list, search and get tools. Do not create, update, delete or respond to anything.
31
+
32
+ Look at every event from ${back} days ago to ${days} days ahead, on every calendar list_calendars returns. Report an event when its title, description, location or attendees show it is about a job application: an interview, a phone or video screen, an onsite, a take-home review, a recruiter or hiring-manager call, an offer call, or a deadline for an application. Skip everything else (meetings at the person's current job, personal events, reminders that name no company).
33
+
34
+ Decide the company from the title, the description, the organiser's or attendees' email domains, or the meeting link. Use the exact start time the event has.
35
+
36
+ Output ONLY a JSON array, no prose, no code fence, one object per event:
37
+ {"company": "company name", "role": "role title if the event or its description names one, else empty string", "kind": "${KINDS.join('|')}", "start": "YYYY-MM-DD HH:MM in the person's own time zone", "end": "YYYY-MM-DD HH:MM or empty", "summary": "the event title", "gist": "one plain sentence on what it is", "with": "the other people on it by name, comma separated, or empty", "location": "the meeting link or place, or empty", "eventId": "the event id", "calendar": "the calendar's name"}
38
+
39
+ If nothing matches, output [].${companies.length ? `
40
+
41
+ The person's own records show applications in flight at these companies (others may exist): ${companies.join(', ')}.` : ''}`;
42
+ }
43
+
44
+ const WHEN = /^(\d{4}-\d{2}-\d{2})(?:[ T](\d{1,2}):(\d{2}))?/;
45
+ const when = (s) => { const m = String(s || '').trim().match(WHEN); return m ? `${m[1]}${m[2] ? ` ${m[2].padStart(2, '0')}:${m[3]}` : ''}` : ''; };
46
+
47
+ /** The model's text into clean entries, or an error that says what came back. */
48
+ export function parseOutput(text) {
49
+ const s = String(text || '');
50
+ const a = s.indexOf('['), b = s.lastIndexOf(']');
51
+ if (a < 0 || b < a) throw new Error(`The runner returned no JSON array. It said: ${s.slice(0, 200).replace(/\s+/g, ' ')}`);
52
+ let arr; try { arr = JSON.parse(s.slice(a, b + 1)); } catch (e) { throw new Error(`The runner's JSON did not parse: ${e.message}`); }
53
+ if (!Array.isArray(arr)) throw new Error('The runner returned JSON that is not an array.');
54
+ return arr.filter((m) => m && typeof m === 'object' && m.company && when(m.start)).map((m) => ({
55
+ id: String(m.eventId || `${m.company}|${m.start}`).trim(), company: String(m.company).trim(), role: String(m.role || '').trim(),
56
+ kind: KINDS.includes(m.kind) ? m.kind : 'other', start: when(m.start), end: when(m.end), summary: String(m.summary || '').trim().slice(0, 160),
57
+ gist: String(m.gist || '').trim().slice(0, 300), with: String(m.with || '').trim().slice(0, 200), location: String(m.location || '').trim().slice(0, 300), calendar: String(m.calendar || '').trim().slice(0, 80),
58
+ }));
59
+ }
60
+
61
+ const INTERVIEWISH = new Set(['interview', 'screen', 'onsite', 'offer-call']);
62
+
63
+ /** What confirming an event would do to the note it matched. */
64
+ export function suggestionFor(kind, noteStatus) {
65
+ if (!noteStatus) return { action: 'none', text: 'No note at this company; the event is noted here only.' };
66
+ if (kind === 'deadline') return { action: 'deadline', text: 'Puts the date in the note\'s Deadline field.' };
67
+ if (!INTERVIEWISH.has(kind)) return { action: 'record', text: 'Records the call on the note.' };
68
+ if (['interviewing', 'offer'].includes(noteStatus)) return { action: 'record', text: 'Records the time in the note\'s Interview on field.' };
69
+ return { action: 'status', status: 'interviewing', text: 'Moves the note to interviewing and records the time in Interview on.' };
70
+ }
71
+
72
+ /** Each event matched to the notes: the one note at that company in flight, or a pick among several. */
73
+ export function reconcile(events, notes) {
74
+ const rank = (n) => (n.status === 'interviewing' || n.status === 'offer' ? 3 : ACTIVE.has(n.status) ? 2 : n.status === 'reviewing' ? 1 : 0);
75
+ return events.map((e) => {
76
+ const cands = notes.filter((n) => sameCompany(n.company, e.company)).sort((a, b) => rank(b) - rank(a) || (b.score || 0) - (a.score || 0));
77
+ const byRole = e.role ? cands.find((n) => n.title.toLowerCase().includes(e.role.toLowerCase()) || e.role.toLowerCase().includes(n.title.toLowerCase())) : null;
78
+ const note = byRole || (cands.length === 1 || (cands[0] && rank(cands[0]) >= 2) ? cands[0] : null);
79
+ return {
80
+ ...e, state: 'pending', match: byRole ? 'role' : note ? 'company' : 'none',
81
+ noteId: note?.id || '', noteTitle: note?.title || '', noteStatus: note?.status || '',
82
+ candidates: cands.slice(0, 6).map((n) => ({ id: n.id, title: n.title, status: n.status })),
83
+ suggestion: suggestionFor(e.kind, note?.status || ''),
84
+ };
85
+ }).sort((a, b) => a.start.localeCompare(b.start));
86
+ }
87
+
88
+ // ---------------- running ----------------
89
+ const run = { running: false, startedAt: null, finishedAt: null, error: '', errorKind: '', days: null, lastOutput: '' };
90
+ export function items() {
91
+ const d = load();
92
+ return { ...run, runner: runnerConfig().command, lastRun: d.lastRun, lastDays: d.days, items: d.items || [], pending: (d.items || []).filter((i) => i.state === 'pending') };
93
+ }
94
+ export function start({ days } = {}) {
95
+ if (run.running) return items();
96
+ const ahead = Number(days) || 21;
97
+ Object.assign(run, { running: true, startedAt: new Date().toISOString(), finishedAt: null, error: '', errorKind: '', days: ahead });
98
+ const notes = store.listJobs();
99
+ const companies = [...new Set(notes.filter((n) => ACTIVE.has(n.status)).map((n) => n.company))].sort();
100
+ runLLM(prompt({ days: ahead, companies }), { timeoutMs: 15 * 60 * 1000, extraArgs: ['--allowedTools', ...READ_TOOLS, '--disallowedTools', ...DENY_TOOLS] })
101
+ .then((text) => {
102
+ run.lastOutput = String(text).slice(0, 4000);
103
+ const fresh = reconcile(parseOutput(text), store.listJobs());
104
+ const cur = load();
105
+ const known = new Map((cur.items || []).map((i) => [i.id, i]));
106
+ // An event already confirmed or dismissed keeps that state; a pending one is re-matched against today's notes.
107
+ const merged = fresh.map((i) => { const k = known.get(i.id); return k && k.state !== 'pending' ? { ...k, ...i, state: k.state, resolved: k.resolved } : i; });
108
+ for (const k of known.values()) if (!merged.some((i) => i.id === k.id)) merged.push(k);
109
+ merged.sort((a, b) => (a.start || '').localeCompare(b.start || ''));
110
+ persist({ lastRun: new Date().toISOString(), days: ahead, items: merged });
111
+ Object.assign(run, { running: false, finishedAt: new Date().toISOString() });
112
+ })
113
+ .catch((e) => Object.assign(run, { running: false, finishedAt: new Date().toISOString(), error: e.message, errorKind: e.kind || 'failed' }));
114
+ return items();
115
+ }
116
+
117
+ // ---------------- confirming ----------------
118
+ const fieldEmpty = (id, field) => { try { return !new RegExp(`^- \\*\\*${field}:\\*\\*\\s*\\S`, 'm').test(fs.readFileSync(store.getJob(id).path, 'utf8')); } catch { return false; } };
119
+ const line = (i) => `Calendar, ${i.start}${i.end ? ` to ${i.end.slice(-5)}` : ''} (${i.kind}): ${i.summary || i.gist}${i.with ? `, with ${i.with}` : ''}${i.location ? ` [${i.location}]` : ''}`;
120
+
121
+ /** The person confirms one event, optionally choosing the note. Writes the time, the status where it applies, and a line on the note. */
122
+ export function confirm(id, { noteId } = {}) {
123
+ const d = load();
124
+ const item = (d.items || []).find((i) => i.id === id);
125
+ if (!item) throw Object.assign(new Error(`No calendar item ${id}`), { status: 404 });
126
+ const target = noteId || item.noteId;
127
+ const s = target ? suggestionFor(item.kind, store.getJob(target).status) : suggestionFor(item.kind, '');
128
+ if (target) {
129
+ if (s.action === 'status') store.setStatus(target, s.status, 'app');
130
+ if (s.action === 'deadline') { if (fieldEmpty(target, 'Deadline')) store.saveApplicationDraft(target, { field: 'Deadline', value: item.start.slice(0, 10) }); }
131
+ else if (INTERVIEWISH.has(item.kind)) {
132
+ // The latest confirmed interview is the one on the calendar; an earlier round the person wrote by hand gives way to it.
133
+ const current = (() => { try { return (fs.readFileSync(store.getJob(target).path, 'utf8').match(/^- \*\*Interview on:\*\*\s*(\S.*)$/m) || [])[1] || ''; } catch { return ''; } })();
134
+ if (!current || item.start > current) store.saveApplicationDraft(target, { field: 'Interview on', value: item.start });
135
+ }
136
+ store.addNote(target, line(item), 'app');
137
+ }
138
+ const at = new Date().toISOString();
139
+ item.state = 'confirmed'; item.noteId = target || ''; item.resolved = { at, action: s.action, status: s.status || '' };
140
+ persist(d);
141
+ return items();
142
+ }
143
+ export function dismiss(id) {
144
+ const d = load();
145
+ const item = (d.items || []).find((i) => i.id === id);
146
+ if (!item) throw Object.assign(new Error(`No calendar item ${id}`), { status: 404 });
147
+ item.state = 'dismissed'; item.resolved = { at: new Date().toISOString(), action: 'dismissed' };
148
+ persist(d);
149
+ return items();
150
+ }
@@ -10,6 +10,7 @@ import * as tailored from './tailored-resume.mjs';
10
10
  import * as mail from './mail-check.mjs';
11
11
  import * as people from './people.mjs';
12
12
  import * as linkedin from './linkedin-import.mjs';
13
+ import * as calcheck from './calendar-check.mjs';
13
14
  import { TOOLS } from './mcp.mjs';
14
15
  import { buildJournal, renderJournal, writeJournal } from '../../scraper/journal.mjs';
15
16
 
@@ -75,6 +76,11 @@ const routes = [
75
76
  ['GET', /^\/api\/learned$/, () => store.learned()],
76
77
  // The calendar: the dated lines the notes carry. The .ics feed is served outside the JSON routes below.
77
78
  ['GET', /^\/api\/calendar$/, () => store.calendar()],
79
+ // The Google Calendar check: read-only through the local CLI's connector; the person confirms each event.
80
+ ['GET', /^\/api\/calendar\/items$/, () => calcheck.items()],
81
+ ['POST', /^\/api\/calendar\/check$/, async (_, __, req) => calcheck.start(await readBody(req))],
82
+ ['POST', /^\/api\/calendar\/items\/([^/]+)\/confirm$/, async (m, _, req) => calcheck.confirm(decodeURIComponent(m[1]), await readBody(req))],
83
+ ['POST', /^\/api\/calendar\/items\/([^/]+)\/dismiss$/, (m) => calcheck.dismiss(decodeURIComponent(m[1]))],
78
84
  // The day's journal: runs, found, decisions, mail, notes, read from the record. ?date=YYYY-MM-DD for another day.
79
85
  ['GET', /^\/api\/journal$/, (_, q) => { const date = q.get('date') || undefined; const j = buildJournal(date); return { ...j, markdown: renderJournal(j) }; }],
80
86
  ['POST', /^\/api\/journal$/, async (_, __, req) => { const b = await readBody(req); return writeJournal(b.date || undefined); }],
@@ -11,6 +11,7 @@ import * as tailored from './tailored-resume.mjs';
11
11
  import * as mail from './mail-check.mjs';
12
12
  import * as people from './people.mjs';
13
13
  import * as linkedin from './linkedin-import.mjs';
14
+ import * as calcheck from './calendar-check.mjs';
14
15
 
15
16
  // The version a client shows beside the server is the package's, not a constant that goes stale.
16
17
  const VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version; } catch { return '0.0.0'; } })();
@@ -39,6 +40,8 @@ export const TOOLS = [
39
40
  { name: 'summary', description: 'Pipeline counts by status, pay band and kind; last scan; current floor, stretch and score bar.', inputSchema: { type: 'object', properties: {} } },
40
41
  { name: 'run_scan', description: 'Start a scan of every board now. It returns at once; the scan runs on its own (it survives this session ending) and takes two to three minutes. Poll scan_status. Pass dry=true to score without writing notes. Pass criteria=<preset name> to score this one run with a named criteria preset instead of the active Search Criteria (see list_criteria_presets). Pass wait=<seconds> (up to 300) to answer only when the scan has finished, for a client that cannot poll between turns; the scan keeps running if the wait ends first.', inputSchema: { type: 'object', properties: { dry: { type: 'boolean' }, criteria: { type: 'string' }, wait: { type: 'number', description: 'seconds to wait for the scan to finish, up to 300; omit to return at once' } } } },
41
42
  { name: 'mail_check', description: 'Start a read-only pass over the person\'s mailbox for application updates (confirmations, rejections, interview invitations) through the local CLI\'s Gmail connector, with only the Gmail read tools allowed. Runs in the background for a few minutes; poll mail_items. days: how far back (default: since the last check, or 21 days the first time).', inputSchema: { type: 'object', properties: { days: { type: 'number' } } } },
43
+ { name: 'calendar_check', description: 'Start a read-only pass over the person\'s Google Calendar for interviews, screens, recruiter calls and deadlines about jobs on their board, through the local CLI\'s Google Calendar connector with only its read tools allowed. Runs in the background for a minute or two; poll calendar_items. days: how far ahead (default 21). Nothing is ever written to the calendar.', inputSchema: { type: 'object', properties: { days: { type: 'number' } } } },
44
+ { name: 'calendar_items', description: 'The calendar events found so far, each matched to a job note (by role, by company, or none) with what confirming it would do: the time into the note\'s Interview on field, the note to interviewing where it is not yet. Confirming is done by the person in the app; an agent reads these and tells them what is waiting.', inputSchema: { type: 'object', properties: {} } },
42
45
  { name: 'mail_items', description: 'The application emails found so far, each matched to a job note (exact role, same company, or none) with what confirming it would do. Confirming is done by the person in the app; an agent can read these and tell them what is waiting.', inputSchema: { type: 'object', properties: {} } },
43
46
  { name: 'preview_criteria', description: 'What a proposed criteria JSON would do to the notes that exist, without saving or scanning: how many rise above or fall below the bar, who enters or leaves the top 20, the biggest movers. Covers title terms, recency and pay exactly; description, seniority and location rules need a scan.', inputSchema: { type: 'object', properties: { raw: { type: 'string' } }, required: ['raw'] } },
44
47
  { name: 'outcomes', description: 'What the search is producing: the funnel (found, reviewed, shortlisted, applied, interviewing, offer), response rate, median days from discovery to application, applications waiting without a response at 7/14/21 days with the oldest listed, and applied/response counts by source.', inputSchema: { type: 'object', properties: {} } },
@@ -97,6 +100,8 @@ export async function call(name, a = {}) {
97
100
  }
98
101
  case 'mail_check': return mail.start({ sinceDays: a.days });
99
102
  case 'mail_items': return mail.items();
103
+ case 'calendar_check': return calcheck.start({ days: a.days });
104
+ case 'calendar_items': return calcheck.items();
100
105
  case 'preview_criteria': return store.previewCriteria(a.raw);
101
106
  case 'rescore_notes': return store.rescoreNotes({ dry: !!a.dry });
102
107
  case 'outcomes': return store.outcomes();
package/cli.mjs CHANGED
@@ -34,7 +34,7 @@ const HELP = `tekjobs — a local job-search machine
34
34
  (for location, description or seniority rule changes); notes already
35
35
  at these weights are left alone
36
36
  tekjobs schedule [--time HH:MM] [--print]
37
- run the scan and the mail read every morning: creates the Windows task
37
+ run the scan, then the read-only mail and calendar passes, every morning: creates the Windows task
38
38
  (07:30 by default), or prints the crontab or launchd line for macOS and
39
39
  Linux; --print shows the command without installing anything
40
40
  tekjobs import linkedin <zip|folder> your LinkedIn data export into the search: who you know at each
@@ -46,6 +46,8 @@ const HELP = `tekjobs — a local job-search machine
46
46
  tekjobs down stop what \`up\` started
47
47
  tekjobs ps what \`up\` has running
48
48
  tekjobs board <slug> [--from ats] [--move] where a failed board answers now; --move rewrites the watchlist row
49
+ tekjobs calendar check [--days N] read the Google Calendar, through the local CLI's connector with its read tools
50
+ only, for interviews and calls about jobs on the board; confirm each in the app
49
51
  tekjobs journal [YYYY-MM-DD] [--write] the day's journal from the record (runs, found, decisions, mail); --write
50
52
  puts it at the end of Logs/<date>.md, as every real scan does
51
53
  tekjobs serve the app + API on http://127.0.0.1:8787, in this terminal
@@ -204,6 +206,22 @@ async function main() {
204
206
  if (!s.dry) console.log(` Index: ${s.indexPath}. Job notes now show who you know at each company.`);
205
207
  return;
206
208
  }
209
+ if (cmd === 'calendar') {
210
+ const cc = await import('./app/server/calendar-check.mjs');
211
+ if (rest[0] === 'check') {
212
+ const s = cc.start({ days: Number(opt('--days')) || undefined });
213
+ console.log(`Reading the calendar through ${s.runner}, ${s.days} days ahead, read tools only. This takes a minute or two.`);
214
+ while (cc.items().running) await new Promise((r) => setTimeout(r, 3000));
215
+ const r = cc.items();
216
+ if (r.error) return console.error(`Calendar check failed (${r.errorKind}): ${r.error}`);
217
+ console.log(`${r.pending.length} event${r.pending.length === 1 ? '' : 's'} waiting for a decision in the app's Calendar page.`);
218
+ for (const i of r.pending) console.log(` ${i.start} ${i.kind.padEnd(14)} ${i.company} — ${i.summary || i.gist}${i.noteId ? ` → ${i.noteTitle} (${i.noteStatus})` : ' → no note'}`);
219
+ return;
220
+ }
221
+ const r = cc.items();
222
+ console.log(`${r.pending.length} pending calendar event${r.pending.length === 1 ? '' : 's'}; last read ${r.lastRun || 'never'}. Run: tekjobs calendar check`);
223
+ return;
224
+ }
207
225
  if (cmd === 'journal') {
208
226
  const { buildJournal, renderJournal, writeJournal } = await import('./scraper/journal.mjs');
209
227
  const date = rest.find((x) => /^\d{4}-\d{2}-\d{2}$/.test(x));
@@ -317,7 +335,7 @@ function schedule() {
317
335
  const printOnly = rest.includes('--print');
318
336
  if (process.platform === 'win32') {
319
337
  const cmd = `schtasks /Create /F /SC DAILY /ST ${time} /TN "TekJobs Daily Scan" /TR "\\"${path.join(ROOT, 'run.cmd')}\\""`;
320
- console.log(`Windows Task Scheduler entry "TekJobs Daily Scan", daily at ${time}, running run.cmd (the scan, then the mail read):\n ${cmd}`);
338
+ console.log(`Windows Task Scheduler entry "TekJobs Daily Scan", daily at ${time}, running run.cmd (the scan, then the read-only mail and calendar passes):\n ${cmd}`);
321
339
  if (printOnly) return;
322
340
  const r = spawnSync(cmd, { shell: true, encoding: 'utf8' });
323
341
  if (r.status === 0) console.log('Installed. See it in Task Scheduler, or remove it with: schtasks /Delete /TN "TekJobs Daily Scan" /F');
@@ -341,5 +359,5 @@ function schedule() {
341
359
  EOF
342
360
  launchctl load ${plist}`);
343
361
  }
344
- console.log('\nBoth run run.sh: the scan, then the read-only mail pass, appending to data/runs.log.');
362
+ console.log('\nBoth run run.sh: the scan, then the read-only mail and calendar passes, appending to data/runs.log.');
345
363
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timurtekb/tekjobs",
3
- "version": "0.48.0",
3
+ "version": "0.50.0",
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.cmd CHANGED
@@ -8,3 +8,6 @@ node run.mjs >> data\runs.log 2>&1
8
8
  rem Then the mailbox: a read-only pass through the local CLI's Gmail connector, matched to the notes for you to confirm in the app.
9
9
  echo ----- mail %date% %time% ----- >> data\runs.log
10
10
  node cli.mjs mail >> data\runs.log 2>&1
11
+ rem Then the calendar: a read-only pass through the Google Calendar connector for interviews and calls about jobs on the board, matched to the notes for you to confirm.
12
+ echo ----- calendar %date% %time% ----- >> data\runs.log
13
+ node cli.mjs calendar check >> data\runs.log 2>&1
package/run.sh CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/bin/sh
2
- # Daily TekJobs scan for cron and launchd: the scan, then the read-only mail pass. `tekjobs schedule` prints
2
+ # Daily TekJobs scan for cron and launchd: the scan, then the read-only mail and calendar passes. `tekjobs schedule` prints
3
3
  # the one line that calls this at 07:30; run.cmd is the Windows twin.
4
4
  cd "$(dirname "$0")" || exit 1
5
5
  export TEKJOBS_RUN_VIA=schedule
@@ -9,4 +9,6 @@ mkdir -p data
9
9
  node run.mjs
10
10
  echo "----- mail $(date) -----"
11
11
  node cli.mjs mail
12
+ echo "----- calendar $(date) -----"
13
+ node cli.mjs calendar check
12
14
  } >> data/runs.log 2>&1