jobless 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,86 +4,79 @@
4
4
  npx jobless
5
5
  ```
6
6
 
7
- A terminal resume for **Yonas Alem**, senior software engineer. Remote. Production systems across payments, gaming, ecommerce, and AWS.
7
+ First it rejects **you**. Then it introduces an engineer.
8
8
 
9
- The joke is optional. The work is the point.
9
+ `jobless` is a reverse ATS. The market has been parsing humans in 400ms. This package parses you, then parses the job post, then shows [Yonas Alem](https://yonasalem.vercel.app) — senior software engineer, remote, production systems.
10
10
 
11
- ## What you get in 10 seconds
11
+ ## The trap
12
12
 
13
- ```
14
- Yonas Alem
15
- Senior Software Engineer
16
- Addis Ababa, Ethiopia · Open to remote senior roles
17
-
18
- 5+ years in production 35% infra cost cut
19
- ~30% performance gain 3B+ ETB payment volume
13
+ You run the command. It pretends to be Greenhouse. It reads a couple of harmless local hints (git name, email, laptop user). It writes a rejection letter addressed to you.
20
14
 
21
- PROOF
22
- The deploy that killed 17 cron jobs
23
- • Recurring Amazon sync jobs silently stopped after every deploy.
24
- • Traced it through BullMQ, ioredis, Node, and Railway.
25
- • Then added explicit shutdown handling across 17 cron processes.
15
+ Then it breaks character:
26
16
 
27
- h hire me · 1 proof · 5 rant · q quit
28
17
  ```
18
+ that was not a company.
19
+ that was the market, in your own terminal.
29
20
 
30
- ```bash
31
- npx jobless hire
32
- npx jobless work
33
- npx jobless rant
21
+ anyway. here is an engineer who debugs silent production failures.
34
22
  ```
35
23
 
36
- ## Hire me
37
-
38
- - Email: [yonalem21@gmail.com](mailto:yonalem21@gmail.com?subject=npx%20jobless%20%E2%80%94%20Yonas%20Alem)
39
- - Site: [yonasalem.vercel.app](https://yonasalem.vercel.app)
40
- - GitHub: [github.com/Yonas21](https://github.com/Yonas21)
41
- - LinkedIn: [linkedin.com/in/yonasalem21](https://www.linkedin.com/in/yonasalem21/)
42
- - Resume: [PDF](https://yonasalem.vercel.app/assets/resume.pdf)
43
-
44
- In the CLI, press **`h`**.
45
-
46
- ## Proof
47
-
48
- - Cut infrastructure cost ~35% (~$100K / year)
49
- - Improved platform performance ~30%
50
- - Payment rails that handled **3B+ ETB**
51
- - Diagnosed sync jobs that died after every deploy; fixed shutdown across **17 cron processes**
52
- - Amazon SP-API / Ads, PostgreSQL, Node.js, BullMQ, Redis, React, AWS
24
+ Then: 17 cron jobs that died after every deploy. $100K off infra. 3B+ ETB in payment rails. Press `h` to hire.
53
25
 
54
- Source: [github.com/Yonas21/jobless](https://github.com/Yonas21/jobless)
26
+ Skip the theater with `npx jobless --resume`.
55
27
 
56
- ## The market
28
+ ## Lint the posting, not the human
57
29
 
58
- Dessert. Press `5` or run `npx jobless rant`.
59
-
60
- ### I do not know their coffee, their mug, or their stars
61
-
62
- The job posting said TypeScript. The actual exam is telepathy. Oat milk or "just black, I ship." Navy mug or emotional-support Stanley. Virgo, Gemini, or Scorpio. I can trace a silent cron death across 17 processes. I cannot see into their mug.
63
-
64
- ### I applied for 30 minutes. The bot needed 400ms
30
+ ```bash
31
+ npx jobless lint posting.txt
32
+ pbpaste | npx jobless lint
33
+ ```
65
34
 
66
- Upload the PDF. Type the PDF. Culture quiz. Submit. Rejected in 400ms. Five years in production, filtered out for missing "synergy." Cool product. Would not ship it.
35
+ Or run `npx jobless` and press `l`.
67
36
 
68
- ### They booked the call so they had something to cancel
37
+ ```
38
+ error salary described as competitive / attractive / market-rate, with no number
39
+ no-fake-salary
40
+ error too many interview rounds
41
+ max-rounds
42
+ warn "fast-paced" / "wear many hats" / "do more with less"
43
+ means-understaffed
44
+
45
+ FROM you <you@laptop>
46
+ TO Hiring <no-reply@not-a-human.com>
47
+ SUBJECT Your opening for this role
48
+
49
+ Thank you for your interest in my labor. After careful consideration of your job description, I will not be moving forward.
50
+
51
+ RESULT REJECTED
52
+ reason salary described as competitive / attractive / market-rate, with no number
53
+ also too many interview rounds
54
+ next we will keep your posting on file. we will not.
55
+
56
+ that was not a recruiter.
57
+ that was you, finally answering.
58
+ ```
69
59
 
70
- "Loved your profile. Thursday 10am?" Shirt with buttons. 9:58am: other candidates. I debug silent failures for a living. Yours is not that silent.
60
+ Same idea as ESLint. The file is a job description. Errors fail the process (`exit 1`). Warnings hold it for review. `npx jobless lint --json` prints the findings and the letter.
71
61
 
72
- ### LinkedIn: 40% more thought leadership, 0% more jobs
62
+ ## Hire
73
63
 
74
- A jobs board that accidentally had a feed. Now a feed that accidentally has jobs.
64
+ - Email: [yonalem21@gmail.com](mailto:yonalem21@gmail.com?subject=npx%20jobless%20%E2%80%94%20Yonas%20Alem)
65
+ - Site: [yonasalem.vercel.app](https://yonasalem.vercel.app)
66
+ - GitHub: [github.com/Yonas21](https://github.com/Yonas21)
67
+ - LinkedIn: [linkedin.com/in/yonasalem21](https://www.linkedin.com/in/yonasalem21/)
68
+ - Source: [github.com/Yonas21/jobless](https://github.com/Yonas21/jobless)
75
69
 
76
70
  ## Commands
77
71
 
78
72
  ```bash
79
- npx jobless # resume first, rant if you ask
80
- npx jobless work # proof
81
- npx jobless hire # email + links
82
- npx jobless who # bio
83
- npx jobless looking # roles
84
- npx jobless skills # stack
85
- npx jobless rant # the market
86
- npx jobless --joke # ATS rejection intro
73
+ npx jobless # reject you, then the resume
74
+ npx jobless lint file.txt # lint a job post (exit 1 on errors)
75
+ npx jobless lint --json # findings + rejection letter
76
+ npx jobless work # proof
77
+ npx jobless hire # email + links
78
+ npx jobless rant # the market
79
+ npx jobless --resume # skip the rejection
87
80
  ```
88
81
 
89
- Zero dependencies. Node 18+.
82
+ Zero dependencies. Node 18+. Local only. It does not apply to jobs or send email.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "jobless",
3
- "version": "1.2.0",
4
- "description": "npx jobless — senior software engineer terminal resume. Production systems, not carousels.",
3
+ "version": "1.4.0",
4
+ "description": "npx jobless — first it rejects you. then it lints the job post. then you meet the engineer.",
5
5
  "keywords": [
6
6
  "cli",
7
7
  "npx",
@@ -10,7 +10,8 @@
10
10
  "job-market",
11
11
  "linkedin",
12
12
  "recruiter",
13
- "terminal",
13
+ "linter",
14
+ "ats",
14
15
  "portfolio",
15
16
  "yonas-alem"
16
17
  ],
@@ -45,6 +46,7 @@
45
46
  "who": "node bin/jobless.js who",
46
47
  "work": "node bin/jobless.js work",
47
48
  "rant": "node bin/jobless.js rant",
48
- "hire": "node bin/jobless.js hire"
49
+ "hire": "node bin/jobless.js hire",
50
+ "lint": "node bin/jobless.js lint"
49
51
  }
50
52
  }
package/src/cli.js CHANGED
@@ -2,8 +2,11 @@ import { createInterface } from 'node:readline';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { stdin } from 'node:process';
5
6
 
6
7
  import { menu } from './data.js';
8
+ import { lintResult } from './lint.js';
9
+ import { printTrap } from './reject.js';
7
10
  import {
8
11
  blank,
9
12
  clear,
@@ -12,11 +15,11 @@ import {
12
15
  showCursor,
13
16
  } from './style.js';
14
17
  import {
15
- printBoot,
16
18
  printHeader,
17
19
  printHelp,
18
20
  printHire,
19
21
  printLandingTeaser,
22
+ printLintReport,
20
23
  printLooking,
21
24
  printMenu,
22
25
  printRant,
@@ -53,11 +56,31 @@ const ask = (rl, prompt) =>
53
56
  rl.question(prompt, resolve);
54
57
  });
55
58
 
59
+ const readStdin = async () => {
60
+ const chunks = [];
61
+ for await (const chunk of stdin) chunks.push(chunk);
62
+ return Buffer.concat(chunks).toString('utf8');
63
+ };
64
+
65
+ const readPaste = async (rl) => {
66
+ blank();
67
+ print(dim(' paste a job description. empty line to lint.'));
68
+ blank();
69
+ const lines = [];
70
+ while (true) {
71
+ const line = await ask(rl, dim(' '));
72
+ if (line === '') break;
73
+ lines.push(line);
74
+ }
75
+ return lines.join('\n');
76
+ };
77
+
56
78
  const resolveCommand = (input) => {
57
79
  const value = String(input).trim().toLowerCase();
58
80
  if (!value) return null;
59
81
  if (value === 'q' || value === 'quit' || value === 'exit') return 'quit';
60
82
  if (value === 'help') return 'help';
83
+ if (value === 'lint' || value === 'l') return 'lint';
61
84
  const fromMenu = menu.find((item) => item.key === value || item.command === value);
62
85
  if (fromMenu) return fromMenu.command;
63
86
  if (COMMANDS[value]) return value;
@@ -71,16 +94,20 @@ const runCommand = (command) => {
71
94
  return true;
72
95
  };
73
96
 
74
- const interactive = async ({ joke }) => {
75
- if (process.stdout.isTTY) clear();
97
+ const showResume = () => {
76
98
  printHeader();
77
99
  printSignature();
78
- if (joke) {
79
- blank();
80
- await printBoot();
81
- }
82
100
  printLandingTeaser();
83
101
  printMenu();
102
+ };
103
+
104
+ const interactive = async ({ trap }) => {
105
+ if (process.stdout.isTTY) clear();
106
+ if (trap) {
107
+ await printTrap({ animate: true });
108
+ blank();
109
+ }
110
+ showResume();
84
111
 
85
112
  const rl = createInterface({ input: process.stdin, output: process.stdout });
86
113
  try {
@@ -89,7 +116,7 @@ const interactive = async ({ joke }) => {
89
116
  const command = resolveCommand(answer);
90
117
 
91
118
  if (!command) {
92
- print(` ${dim('unknown. try 1–5, h to hire, or q.')}`);
119
+ print(` ${dim('unknown. try 1–5, l to lint, h to hire, or q.')}`);
93
120
  continue;
94
121
  }
95
122
  if (command === 'help') {
@@ -98,6 +125,14 @@ const interactive = async ({ joke }) => {
98
125
  printMenu();
99
126
  continue;
100
127
  }
128
+ if (command === 'lint') {
129
+ const text = await readPaste(rl);
130
+ if (process.stdout.isTTY) clear();
131
+ printHeader();
132
+ printLintReport(lintResult(text, 'paste'));
133
+ printMenu();
134
+ continue;
135
+ }
101
136
  if (command === 'quit') {
102
137
  blank();
103
138
  print(dim(' still jobless. still shipping.'));
@@ -118,15 +153,54 @@ const interactive = async ({ joke }) => {
118
153
  };
119
154
 
120
155
  const parse = (argv) => {
121
- const flags = new Set(argv.filter((arg) => arg.startsWith('-')));
122
- const rest = argv.filter((arg) => !arg.startsWith('-')).map((arg) => arg.toLowerCase());
156
+ const flags = new Set();
157
+ const rest = [];
158
+ for (const arg of argv) {
159
+ if (arg.startsWith('-')) flags.add(arg);
160
+ else rest.push(arg);
161
+ }
123
162
  return { flags, rest };
124
163
  };
125
164
 
165
+ const runLintCli = async (files, { json = false } = {}) => {
166
+ let source = 'stdin';
167
+ let text = '';
168
+
169
+ if (files[0]) {
170
+ source = files[0];
171
+ text = readFileSync(files[0], 'utf8');
172
+ } else if (!stdin.isTTY) {
173
+ text = await readStdin();
174
+ } else {
175
+ printHelp();
176
+ blank();
177
+ print(dim(' lint needs a file, a pipe, or an interactive paste: npx jobless lint posting.txt'));
178
+ process.exitCode = 1;
179
+ return;
180
+ }
181
+
182
+ const report = lintResult(text, source);
183
+ if (!report.ok) process.exitCode = 1;
184
+
185
+ if (json) {
186
+ print(JSON.stringify(report, null, 2));
187
+ return;
188
+ }
189
+
190
+ printLintReport(report);
191
+ blank();
192
+ print(dim(' more: npx jobless · npx jobless hire'));
193
+ };
194
+
126
195
  export const run = async (argv = []) => {
196
+ process.stdout.on('error', (error) => {
197
+ if (error.code === 'EPIPE') process.exit(0);
198
+ });
199
+
127
200
  const { flags, rest } = parse(argv);
201
+ const verb = rest[0] ? rest[0].toLowerCase() : '';
128
202
 
129
- if (flags.has('-h') || flags.has('--help') || rest[0] === 'help') {
203
+ if (flags.has('-h') || flags.has('--help') || verb === 'help') {
130
204
  printHelp();
131
205
  return;
132
206
  }
@@ -136,15 +210,20 @@ export const run = async (argv = []) => {
136
210
  return;
137
211
  }
138
212
 
213
+ if (verb === 'lint' || verb === 'l') {
214
+ await runLintCli(rest.slice(1), { json: flags.has('--json') });
215
+ return;
216
+ }
217
+
139
218
  if (flags.has('--json')) {
140
219
  print(toJson());
141
220
  return;
142
221
  }
143
222
 
144
- const joke = flags.has('--joke');
145
- const command = rest[0] ? resolveCommand(rest[0]) : null;
223
+ const skipTrap = flags.has('--resume') || flags.has('--skip-trap') || flags.has('--no-trap');
224
+ const command = verb ? resolveCommand(verb) : null;
146
225
 
147
- if (command && command !== 'quit' && command !== 'help') {
226
+ if (command && command !== 'quit' && command !== 'help' && command !== 'lint') {
148
227
  printHeader();
149
228
  runCommand(command);
150
229
  blank();
@@ -153,9 +232,10 @@ export const run = async (argv = []) => {
153
232
  }
154
233
 
155
234
  if (!process.stdout.isTTY) {
235
+ if (!skipTrap) await printTrap({ animate: false });
236
+ blank();
156
237
  printHeader();
157
238
  printSignature();
158
- printWho();
159
239
  printHire({ copy: false });
160
240
  return;
161
241
  }
@@ -166,5 +246,5 @@ export const run = async (argv = []) => {
166
246
  process.exit(0);
167
247
  });
168
248
 
169
- await interactive({ joke });
249
+ await interactive({ trap: !skipTrap });
170
250
  };
package/src/data.js CHANGED
@@ -189,14 +189,7 @@ export const menu = [
189
189
  { key: '3', command: 'looking', label: 'roles I want' },
190
190
  { key: '4', command: 'skills', label: 'the stack' },
191
191
  { key: '5', command: 'rant', label: 'the market, with jokes' },
192
+ { key: 'l', command: 'lint', label: 'lint a job post' },
192
193
  { key: 'h', command: 'hire', label: 'hire me' },
193
194
  { key: 'q', command: 'quit', label: 'leave the void' },
194
195
  ];
195
-
196
- export const bootLines = [
197
- 'uploading resume.pdf',
198
- 'guessing oat milk vs just-black-I-ship',
199
- 'guessing navy mug vs emotional-support Stanley',
200
- 'checking if Mercury is in Greenhouse',
201
- '404 human',
202
- ];
@@ -0,0 +1,62 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import os from 'node:os';
3
+
4
+ const CONSUMER_DOMAINS = new Set([
5
+ 'gmail.com',
6
+ 'yahoo.com',
7
+ 'hotmail.com',
8
+ 'outlook.com',
9
+ 'icloud.com',
10
+ 'proton.me',
11
+ 'protonmail.com',
12
+ 'live.com',
13
+ 'me.com',
14
+ 'aol.com',
15
+ 'local',
16
+ 'localhost',
17
+ ]);
18
+
19
+ const git = (key) => {
20
+ try {
21
+ return execFileSync('git', ['config', '--get', key], {
22
+ encoding: 'utf8',
23
+ stdio: ['ignore', 'pipe', 'ignore'],
24
+ timeout: 800,
25
+ }).trim();
26
+ } catch {
27
+ return '';
28
+ }
29
+ };
30
+
31
+ const titleCase = (value) => value.charAt(0).toUpperCase() + value.slice(1);
32
+
33
+ const companyFromDomain = (domain) => {
34
+ if (!domain || CONSUMER_DOMAINS.has(domain.toLowerCase())) return 'your team';
35
+ const slug = domain.split('.')[0].replace(/[-_]+/g, ' ');
36
+ return slug
37
+ .split(' ')
38
+ .filter(Boolean)
39
+ .map(titleCase)
40
+ .join(' ');
41
+ };
42
+
43
+ export const detectIdentity = () => {
44
+ const username = os.userInfo()?.username || process.env.USER || process.env.USERNAME || 'candidate';
45
+ const email = git('user.email') || `${username}@local`;
46
+ const name = git('user.name') || titleCase(username);
47
+ const domain = email.includes('@') ? email.split('@')[1] : 'local';
48
+ const cwd = process.cwd().split(/[/\\]/).filter(Boolean).at(-1) || 'unknown';
49
+
50
+ return {
51
+ name,
52
+ email,
53
+ username,
54
+ domain,
55
+ company: companyFromDomain(domain),
56
+ cwd,
57
+ host: os.hostname() || 'localhost',
58
+ };
59
+ };
60
+
61
+ export const seedFrom = (identity) =>
62
+ [...`${identity.email}|${identity.username}`].reduce((sum, char) => sum + char.charCodeAt(0), 0);
package/src/lint.js ADDED
@@ -0,0 +1,147 @@
1
+ import { detectIdentity } from './identity.js';
2
+
3
+ const hasMoney = (text) =>
4
+ /\$|€|£|\bUSD\b|\bEUR\b|\bGBP\b|\b\d{2,3}\s?k\b|\b\d{1,3}(,\d{3})+\b/i.test(text);
5
+
6
+ const roundCount = (text) => {
7
+ const matches = [...text.matchAll(/(\d+)\s*\+?\s*(interview\s+)?(rounds?|stages?|steps?)\b/gi)];
8
+ return matches.reduce((max, match) => Math.max(max, Number(match[1])), 0);
9
+ };
10
+
11
+ export const rules = [
12
+ {
13
+ id: 'no-fake-salary',
14
+ level: 'error',
15
+ message: 'salary described as competitive / attractive / market-rate, with no number',
16
+ test: (text) =>
17
+ /\b(competitive|attractive|market[- ]rate)\b/i.test(text) &&
18
+ /\b(salary|compensat|pay|package)\b/i.test(text) &&
19
+ !hasMoney(text),
20
+ },
21
+ {
22
+ id: 'no-salary-range',
23
+ level: 'error',
24
+ message: 'no salary range anywhere in the posting',
25
+ test: (text) => !hasMoney(text) && !/\bsalary range\b|\bcompensation range\b/i.test(text),
26
+ },
27
+ {
28
+ id: 'max-rounds',
29
+ level: 'error',
30
+ message: 'too many interview rounds',
31
+ test: (text) => roundCount(text) >= 5 || /\b(six|seven|eight)\s+(interview\s+)?rounds\b/i.test(text),
32
+ },
33
+ {
34
+ id: 'unpaid-takehome',
35
+ level: 'error',
36
+ message: 'take-home that looks like unpaid work',
37
+ test: (text) => {
38
+ const stripped = text.replace(/\b(no|without|not a|paid)\s+(take[- ]home|takehome)s?\b/gi, '');
39
+ return /\b(take[- ]home|takehome|homework|unpaid (assignment|project)|weekend (project|assignment))\b/i.test(
40
+ stripped,
41
+ );
42
+ },
43
+ },
44
+ {
45
+ id: 'entry-needs-years',
46
+ level: 'error',
47
+ message: 'entry-level / junior role asking for years of experience',
48
+ test: (text) =>
49
+ /\b(entry[- ]level|junior)\b/i.test(text) &&
50
+ /\b([3-9]|[1-9]\d)\+?\s+years?\b/i.test(text),
51
+ },
52
+ {
53
+ id: 'means-understaffed',
54
+ level: 'warn',
55
+ message: '"fast-paced" / "wear many hats" / "do more with less"',
56
+ test: (text) =>
57
+ /\bfast[- ]paced\b|\bwear many hats\b|\bdo more with less\b|\bhustle\b|\blean team\b/i.test(text),
58
+ },
59
+ {
60
+ id: 'rockstar-ninja',
61
+ level: 'warn',
62
+ message: 'rockstar / ninja / 10x / guru',
63
+ test: (text) => /\b(rockstar|ninja|10x|guru|superstar|wizard|code monkey)\b/i.test(text),
64
+ },
65
+ {
66
+ id: 'we-are-family',
67
+ level: 'warn',
68
+ message: 'we are a family',
69
+ test: (text) => /\b(we['’]re a family|we are a family|our family|work family)\b/i.test(text),
70
+ },
71
+ {
72
+ id: 'unlimited-pto',
73
+ level: 'warn',
74
+ message: 'unlimited PTO (often means unused PTO)',
75
+ test: (text) => /\bunlimited (pto|vacation|time off)\b/i.test(text),
76
+ },
77
+ {
78
+ id: 'nights-and-weekends',
79
+ level: 'error',
80
+ message: 'nights, weekends, or crunch as a requirement',
81
+ test: (text) => /\b(nights and weekends|crunch|weekends required|on[- ]call 24)\b/i.test(text),
82
+ },
83
+ {
84
+ id: 'passion-tax',
85
+ level: 'warn',
86
+ message: 'looking for passion instead of paying for labor',
87
+ test: (text) => /\b(passionate about|must love|looking for passion)\b/i.test(text),
88
+ },
89
+ ];
90
+
91
+ export const lintJobPost = (raw) => {
92
+ const text = String(raw || '').trim();
93
+ if (!text) {
94
+ return { text, findings: [{ id: 'empty', level: 'error', message: 'no job description provided' }] };
95
+ }
96
+
97
+ const findings = rules.filter((rule) => rule.test(text)).map(({ id, level, message }) => ({ id, level, message }));
98
+ return { text, findings };
99
+ };
100
+
101
+ export const summarizeLint = (findings) => {
102
+ const errors = findings.filter((item) => item.level === 'error').length;
103
+ const warnings = findings.filter((item) => item.level === 'warn').length;
104
+ return { errors, warnings };
105
+ };
106
+
107
+ export const postingMeta = (text) => {
108
+ const roleMatch = String(text || '').match(
109
+ /\b(?:senior|staff|principal|lead|junior|entry[- ]level)?[ \t]*(?:full[- ]stack|frontend|backend|software|platform)?[ \t]*(?:engineer|developer|designer)\b/i,
110
+ );
111
+ const atMatch = String(text || '').match(
112
+ /\b(?:at|@|join)[ \t]+([A-Z][A-Za-z0-9&.'\-]{1,32}(?:[ \t]+[A-Z][A-Za-z0-9&.'\-]{1,20}){0,2})/,
113
+ );
114
+ return {
115
+ role: roleMatch ? roleMatch[0].replace(/\s+/g, ' ').trim() : 'this role',
116
+ company: atMatch ? atMatch[1].trim() : 'Hiring',
117
+ };
118
+ };
119
+
120
+ export const lintResult = (raw, source = 'stdin') => {
121
+ const { text, findings } = lintJobPost(raw);
122
+ const { errors, warnings } = summarizeLint(findings);
123
+ const identity = detectIdentity();
124
+ const meta = postingMeta(text);
125
+ const errorReasons = findings.filter((item) => item.level === 'error').map((item) => item.message);
126
+ const warnReasons = findings.filter((item) => item.level === 'warn').map((item) => item.message);
127
+ const reasons = (errorReasons.length ? errorReasons : warnReasons).slice(0, 2);
128
+
129
+ return {
130
+ source,
131
+ ok: errors === 0,
132
+ errors,
133
+ warnings,
134
+ findings,
135
+ meta,
136
+ letter:
137
+ findings.length === 0
138
+ ? null
139
+ : {
140
+ from: `${identity.name} <${identity.email}>`,
141
+ to: `${meta.company} <no-reply@not-a-human.com>`,
142
+ subject: `Your opening for ${meta.role}`,
143
+ result: errors > 0 ? 'REJECTED' : 'HELD FOR REVIEW',
144
+ reasons,
145
+ },
146
+ };
147
+ };
package/src/reject.js ADDED
@@ -0,0 +1,132 @@
1
+ import { detectIdentity, seedFrom } from './identity.js';
2
+ import {
3
+ blank,
4
+ bold,
5
+ box,
6
+ dim,
7
+ hideCursor,
8
+ inner,
9
+ print,
10
+ red,
11
+ showCursor,
12
+ sleep,
13
+ wrap,
14
+ yellow,
15
+ } from './style.js';
16
+
17
+ const BOOT = [
18
+ 'receiving application from the void',
19
+ 'parsing whatever npx just sent',
20
+ 'extracting keywords',
21
+ 'scoring culture fit',
22
+ 'waiting for a human',
23
+ ];
24
+
25
+ const reasonPool = (identity) => {
26
+ const pool = [
27
+ 'keyword density insufficient',
28
+ 'installed a resume instead of submitting one',
29
+ `did not mention ${identity.company} in the first 12 words`,
30
+ 'cover letter parsed as "npx"',
31
+ 'years of experience with Greenhouse: 0',
32
+ 'failed culture add (ran a CLI)',
33
+ 'resume was a person',
34
+ ];
35
+ if (identity.cwd) {
36
+ pool.push(`working directory "${identity.cwd}" is not a synonym for synergy`);
37
+ }
38
+ if (identity.username) {
39
+ pool.push(`unix user "${identity.username}" is not an approved ATS field`);
40
+ }
41
+ return pool;
42
+ };
43
+
44
+ const pickReasons = (identity, count = 2) => {
45
+ const pool = reasonPool(identity);
46
+ const seed = seedFrom(identity);
47
+ const picked = [];
48
+ for (let i = 0; i < count && pool.length; i += 1) {
49
+ const index = Math.abs(seed + i * 17) % pool.length;
50
+ picked.push(pool.splice(index, 1)[0]);
51
+ }
52
+ return picked;
53
+ };
54
+
55
+ export const printTrap = async ({ animate = true } = {}) => {
56
+ const identity = detectIdentity();
57
+ const reasons = pickReasons(identity);
58
+ const to = identity.email || identity.username;
59
+ const company = identity.company === 'your team' ? 'this role' : identity.company;
60
+
61
+ if (animate) {
62
+ hideCursor();
63
+ try {
64
+ print(dim(' Greenhouse Applicant Center'));
65
+ blank();
66
+ for (const [index, line] of BOOT.entries()) {
67
+ print(` ${dim(`[${index + 1}/${BOOT.length}]`)} ${line}`);
68
+ await sleep(160);
69
+ }
70
+ print(` ${dim('[5/5]')} human not found`);
71
+ await sleep(220);
72
+ } finally {
73
+ showCursor();
74
+ }
75
+ blank();
76
+ }
77
+
78
+ print(
79
+ box([
80
+ dim('FROM Greenhouse <no-reply@not-a-human.com>'),
81
+ dim(`TO ${identity.name} <${to}>`),
82
+ dim(`SUBJECT Your application for Senior Engineer`),
83
+ '',
84
+ ...wrap(
85
+ `Thank you for your interest in ${company}. After careful consideration of your application, we will not be moving forward.`,
86
+ inner(),
87
+ ),
88
+ '',
89
+ `${bold(red('RESULT'))} ${bold('REJECTED')}`,
90
+ `${dim('reason')} ${reasons[0]}`,
91
+ ...(reasons[1] ? [`${dim('also')} ${reasons[1]}`] : []),
92
+ `${dim('elapsed')} 0.40s`,
93
+ `${dim('next')} we will keep your resume on file. we will not.`,
94
+ ]),
95
+ );
96
+
97
+ blank();
98
+ print(` ${dim('that was not a company.')}`);
99
+ print(` ${dim('that was the market, in your own terminal.')}`);
100
+ blank();
101
+ print(` ${dim('anyway. here is an engineer who debugs silent production failures.')}`);
102
+ };
103
+
104
+ export const printCompanyReject = (letter) => {
105
+ if (!letter) return;
106
+
107
+ blank();
108
+ print(
109
+ box([
110
+ dim(`FROM ${letter.from}`),
111
+ dim(`TO ${letter.to}`),
112
+ dim(`SUBJECT ${letter.subject}`),
113
+ '',
114
+ ...wrap(
115
+ letter.result === 'REJECTED'
116
+ ? 'Thank you for your interest in my labor. After careful consideration of your job description, I will not be moving forward.'
117
+ : 'Thank you for your interest in my labor. Your posting has been held for review. Fix the warnings and resubmit.',
118
+ inner(),
119
+ ),
120
+ '',
121
+ `${bold(red('RESULT'))} ${bold(letter.result === 'REJECTED' ? red(letter.result) : yellow(letter.result))}`,
122
+ `${dim('reason')} ${letter.reasons[0] || 'failed the human compiler'}`,
123
+ ...(letter.reasons[1] ? [`${dim('also')} ${letter.reasons[1]}`] : []),
124
+ `${dim('elapsed')} 0.40s`,
125
+ `${dim('next')} we will keep your posting on file. we will not.`,
126
+ ]),
127
+ );
128
+
129
+ blank();
130
+ print(` ${dim('that was not a recruiter.')}`);
131
+ print(` ${dim('that was you, finally answering.')}`);
132
+ };
package/src/screens.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
 
3
+ import { printCompanyReject } from './reject.js';
4
+
3
5
  import {
4
6
  about,
5
- bootLines,
6
7
  experience,
7
8
  hireMailto,
8
9
  lookingFor,
@@ -24,7 +25,6 @@ import {
24
25
  cyan,
25
26
  dim,
26
27
  green,
27
- hideCursor,
28
28
  inner,
29
29
  kv,
30
30
  link,
@@ -33,8 +33,6 @@ import {
33
33
  print,
34
34
  red,
35
35
  section,
36
- showCursor,
37
- sleep,
38
36
  wrap,
39
37
  yellow,
40
38
  } from './style.js';
@@ -88,29 +86,10 @@ export const printSignature = () => {
88
86
  export const printHints = () => {
89
87
  blank();
90
88
  print(
91
- ` ${bold(green('h'))} hire me ${dim('·')} ${bold('1')} proof ${dim('·')} ${bold('5')} rant ${dim('·')} ${bold('q')} quit`,
89
+ ` ${bold(green('h'))} hire ${dim('·')} ${bold('1')} proof ${dim('·')} ${bold('l')} lint a posting ${dim('·')} ${bold('q')} quit`,
92
90
  );
93
91
  };
94
92
 
95
- export const printBoot = async () => {
96
- hideCursor();
97
- try {
98
- print(dim(' applying to a role that was closed in 2022...'));
99
- blank();
100
- for (const [index, line] of bootLines.entries()) {
101
- print(` ${dim(`[${String(index + 1)}/5]`)} ${line}`);
102
- await sleep(180);
103
- }
104
- blank();
105
- print(` ${bold(red('RESULT'))} ${bold('REJECTED')}`);
106
- print(` ${dim('reason')} guessed white mug. it was navy. also a Virgo.`);
107
- print(` ${dim('elapsed')} 0.40s (personal best)`);
108
- print(` ${dim('note')} the loading bar is the joke. the work is not.`);
109
- } finally {
110
- showCursor();
111
- }
112
- };
113
-
114
93
  export const printWho = () => {
115
94
  blank();
116
95
  section('WHO');
@@ -159,6 +138,37 @@ export const printRant = () => {
159
138
  print(` ${dim('If you want the work, press')} ${bold('1')}${dim('. If you want to hire, press')} ${bold(green('h'))}${dim('.')}`);
160
139
  };
161
140
 
141
+ export const printLintReport = (report) => {
142
+ const { findings, source, errors, warnings, letter, ok } = report;
143
+
144
+ blank();
145
+ section('JOBLESS LINT');
146
+ blank();
147
+ print(` ${dim(source)}`);
148
+ blank();
149
+
150
+ if (ok && !findings.length) {
151
+ print(` ${green('clean')} this posting might be a real job.`);
152
+ blank();
153
+ print(` ${dim('If you still want the engineer who wrote the linter:')} ${bold(green('h'))}`);
154
+ return report;
155
+ }
156
+
157
+ for (const finding of findings) {
158
+ const tag = finding.level === 'error' ? red('error') : yellow('warn ');
159
+ print(` ${tag} ${finding.message}`);
160
+ print(` ${dim(finding.id)}`);
161
+ blank();
162
+ }
163
+
164
+ printCompanyReject(letter);
165
+ blank();
166
+ print(
167
+ ` ${errors} error${errors === 1 ? '' : 's'} ${warnings} warning${warnings === 1 ? '' : 's'} ${dim('·')} ${dim('hire the person who wrote the compiler:')} ${bold(green('h'))}`,
168
+ );
169
+ return report;
170
+ };
171
+
162
172
  export const printWork = () => {
163
173
  printSignature();
164
174
  blank();
@@ -235,23 +245,25 @@ export const printMenu = () => {
235
245
  export const printHelp = () => {
236
246
  print(banner());
237
247
  blank();
238
- print(` ${bold('npx jobless')} interactive resume`);
248
+ print(` ${bold('npx jobless')} reject you, then the resume`);
249
+ print(` ${bold('npx jobless lint [file]')} lint a job posting`);
250
+ print(` ${bold('npx jobless lint --json')} findings + rejection letter`);
239
251
  print(` ${bold('npx jobless work')} proof of work`);
240
252
  print(` ${bold('npx jobless hire')} email + links`);
241
253
  print(` ${bold('npx jobless who')} bio`);
242
254
  print(` ${bold('npx jobless looking')} roles`);
243
255
  print(` ${bold('npx jobless skills')} stack`);
244
256
  print(` ${bold('npx jobless rant')} the market`);
245
- print(` ${bold('npx jobless --joke')} ATS rejection intro`);
246
- print(` ${bold('npx jobless --json')} machine readable`);
257
+ print(` ${bold('npx jobless --resume')} skip the rejection`);
258
+ print(` ${bold('npx jobless --json')} machine readable resume`);
247
259
  blank();
248
- print(` ${dim('In the prompt:')} ${bold(green('h'))} ${dim('hires,')} ${bold('1')} ${dim('is proof,')} ${bold('q')} ${dim('quits.')}`);
260
+ print(` ${dim('In the prompt:')} ${bold(green('h'))} ${dim('hires,')} ${bold('l')} ${dim('lints a posting,')} ${bold('q')} ${dim('quits.')}`);
249
261
  print(` ${dim(profile.website)}`);
250
262
  };
251
263
 
252
264
  export const printLandingTeaser = () => {
253
265
  blank();
254
- print(` ${dim('The rant is option')} ${bold('5')}${dim('. The work is already on screen.')}`);
266
+ print(` ${dim('Paste a posting with')} ${bold('l')}${dim('. The rant is')} ${bold('5')}${dim('.')}`);
255
267
  };
256
268
 
257
269
  export const toJson = () =>