job-forge 2.0.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.
Files changed (79) hide show
  1. package/.codex/config.toml +8 -0
  2. package/.cursor/mcp.json +21 -0
  3. package/.cursor/rules/main.mdc +519 -0
  4. package/.mcp.json +21 -0
  5. package/.opencode/agents/general-free.md +85 -0
  6. package/.opencode/agents/general-paid.md +39 -0
  7. package/.opencode/agents/glm-minimal.md +50 -0
  8. package/.opencode/skills/job-forge.md +185 -0
  9. package/AGENTS.md +514 -0
  10. package/CLAUDE.md +514 -0
  11. package/LICENSE +21 -0
  12. package/README.md +195 -0
  13. package/batch/README.md +60 -0
  14. package/batch/batch-prompt.md +399 -0
  15. package/batch/batch-runner.sh +673 -0
  16. package/bin/create-job-forge.mjs +375 -0
  17. package/bin/job-forge.mjs +120 -0
  18. package/bin/sync.mjs +141 -0
  19. package/config/profile.example.yml +67 -0
  20. package/cv-sync-check.mjs +128 -0
  21. package/dedup-tracker.mjs +201 -0
  22. package/docs/ARCHITECTURE.md +220 -0
  23. package/docs/CUSTOMIZATION.md +101 -0
  24. package/docs/MODEL-ROUTING.md +195 -0
  25. package/docs/README.md +54 -0
  26. package/docs/SETUP.md +186 -0
  27. package/docs/demo.gif +0 -0
  28. package/fonts/dm-sans-latin-ext.woff2 +0 -0
  29. package/fonts/dm-sans-latin.woff2 +0 -0
  30. package/fonts/space-grotesk-latin-ext.woff2 +0 -0
  31. package/fonts/space-grotesk-latin.woff2 +0 -0
  32. package/generate-pdf.mjs +168 -0
  33. package/iso/agents/general-free.md +90 -0
  34. package/iso/agents/general-paid.md +44 -0
  35. package/iso/agents/glm-minimal.md +55 -0
  36. package/iso/commands/job-forge.md +188 -0
  37. package/iso/config.json +7 -0
  38. package/iso/instructions.md +514 -0
  39. package/iso/mcp.json +15 -0
  40. package/merge-tracker.mjs +377 -0
  41. package/modes/README.md +30 -0
  42. package/modes/_shared-calibration.md +26 -0
  43. package/modes/_shared.md +272 -0
  44. package/modes/apply.md +257 -0
  45. package/modes/auto-pipeline.md +70 -0
  46. package/modes/batch.md +110 -0
  47. package/modes/compare.md +23 -0
  48. package/modes/contact.md +82 -0
  49. package/modes/deep.md +99 -0
  50. package/modes/followup.md +68 -0
  51. package/modes/negotiation.md +146 -0
  52. package/modes/offer.md +199 -0
  53. package/modes/pdf.md +121 -0
  54. package/modes/pipeline.md +83 -0
  55. package/modes/project.md +30 -0
  56. package/modes/rejection.md +92 -0
  57. package/modes/scan.md +185 -0
  58. package/modes/tracker.md +31 -0
  59. package/modes/training.md +27 -0
  60. package/normalize-statuses.mjs +152 -0
  61. package/opencode.json +28 -0
  62. package/package.json +78 -0
  63. package/scripts/add-tags.mjs +894 -0
  64. package/scripts/cursor-agent-loop.sh +211 -0
  65. package/scripts/cursor-agent-stream-format.py +134 -0
  66. package/scripts/next-num.mjs +33 -0
  67. package/scripts/release/check-source.mjs +37 -0
  68. package/scripts/render-report-header.mjs +78 -0
  69. package/scripts/session-report.mjs +129 -0
  70. package/scripts/slugify.mjs +27 -0
  71. package/scripts/today.mjs +20 -0
  72. package/scripts/token-usage-report.mjs +315 -0
  73. package/scripts/tracker-line.mjs +67 -0
  74. package/scripts/verify-greenhouse-urls.mjs +195 -0
  75. package/templates/cv-template.html +395 -0
  76. package/templates/portals.example.yml +3140 -0
  77. package/templates/states.yml +62 -0
  78. package/tracker-lib.mjs +257 -0
  79. package/verify-pipeline.mjs +267 -0
@@ -0,0 +1,62 @@
1
+ # JobForge — Canonical States
2
+ # Source of truth for job-forge (writer) and dashboard (reader).
3
+ # Both systems MUST use these exact states.
4
+ #
5
+ # Rule: The status field in applications.md must contain EXACTLY
6
+ # one of these values (case-insensitive). No markdown bold (**),
7
+ # no dates, no extra text. Dates go in the date column.
8
+
9
+ states:
10
+ - id: evaluated
11
+ label: Evaluated
12
+ aliases: []
13
+ description: Offer evaluated with report, pending decision
14
+ dashboard_group: evaluated
15
+
16
+ - id: applied
17
+ label: Applied
18
+ aliases: [sent]
19
+ description: Application submitted
20
+ dashboard_group: applied
21
+
22
+ - id: responded
23
+ label: Responded
24
+ aliases: []
25
+ description: Company has responded (not yet interview)
26
+ dashboard_group: responded
27
+
28
+ - id: contacted
29
+ label: Contacted
30
+ aliases: []
31
+ description: Candidate proactively reached out (LinkedIn, email) — not yet a response
32
+ dashboard_group: contacted
33
+
34
+ - id: interview
35
+ label: Interview
36
+ aliases: []
37
+ description: Active interview process
38
+ dashboard_group: interview
39
+
40
+ - id: offer
41
+ label: Offer
42
+ aliases: []
43
+ description: Offer received
44
+ dashboard_group: offer
45
+
46
+ - id: rejected
47
+ label: Rejected
48
+ aliases: []
49
+ description: Rejected by company
50
+ dashboard_group: rejected
51
+
52
+ - id: discarded
53
+ label: Discarded
54
+ aliases: []
55
+ description: Discarded by candidate or offer closed
56
+ dashboard_group: discarded
57
+
58
+ - id: skip
59
+ label: SKIP
60
+ aliases: [skip]
61
+ description: Doesn't fit, don't apply
62
+ dashboard_group: skip
@@ -0,0 +1,257 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tracker-lib.mjs — Shared helper for reading/writing day-based application tracker files.
4
+ *
5
+ * Layout:
6
+ * data/applications/YYYY-MM-DD.md — one markdown table per day (preferred)
7
+ * data/applications.md — legacy single-file (fallback)
8
+ *
9
+ * The directory `data/applications/` takes priority. If it exists and has .md files,
10
+ * all reads/writes go through day files. If not, scripts fall back to the single file.
11
+ */
12
+
13
+ import { readFileSync, writeFileSync, readdirSync, mkdirSync, existsSync } from 'fs';
14
+ import { join, relative } from 'path';
15
+
16
+ // Resolve the consumer's project directory. When installed as a package, the
17
+ // scripts live in node_modules/ but should operate on the consumer's cwd.
18
+ // JOB_FORGE_PROJECT env var overrides for tooling/tests.
19
+ const PROJECT_DIR = process.env.JOB_FORGE_PROJECT || process.cwd();
20
+ export const DATA_APPS_DIR = join(PROJECT_DIR, 'data', 'applications');
21
+ export const DATA_APPS_FILE = join(PROJECT_DIR, 'data', 'applications.md');
22
+ export const ROOT_APPS_FILE = join(PROJECT_DIR, 'applications.md');
23
+
24
+ const TABLE_HEADER = [
25
+ '# Applications Tracker',
26
+ '',
27
+ '| # | Date | Company | Role | Score | Status | PDF | Report | Notes |',
28
+ '|---|------|---------|------|-------|--------|-----|--------|-------|',
29
+ ].join('\n');
30
+
31
+ // ---------- Day file helpers ----------
32
+
33
+ /** Return YYYY-MM-DD from a Date object. */
34
+ function toDateStr(d) {
35
+ return d.toISOString().slice(0, 10);
36
+ }
37
+
38
+ /** List all day .md files in data/applications/, sorted by filename (chronological). */
39
+ export function listDayFiles() {
40
+ if (!existsSync(DATA_APPS_DIR)) return [];
41
+ return readdirSync(DATA_APPS_DIR)
42
+ .filter(f => /^\d{4}-\d{2}-\d{2}\.md$/.test(f))
43
+ .sort();
44
+ }
45
+
46
+ /** Path for a specific day file. */
47
+ export function dayFilePath(date) {
48
+ return join(DATA_APPS_DIR, `${date}.md`);
49
+ }
50
+
51
+ /** Check whether the day-based directory layout is active (has at least one .md day file). */
52
+ export function usesDayFiles() {
53
+ if (!existsSync(DATA_APPS_DIR)) return false;
54
+ return listDayFiles().length > 0;
55
+ }
56
+
57
+ /**
58
+ * Resolve the tracker layout: 'day' if data/applications/ with day files,
59
+ * 'single' if a single-file tracker exists, or 'none'.
60
+ */
61
+ export function resolveLayout() {
62
+ if (usesDayFiles()) return 'day';
63
+ if (existsSync(DATA_APPS_FILE)) return 'single-data';
64
+ if (existsSync(ROOT_APPS_FILE)) return 'single-root';
65
+ return 'none';
66
+ }
67
+
68
+ /**
69
+ * Get the display path for the active tracker (for log messages).
70
+ */
71
+ export function displayPath() {
72
+ const layout = resolveLayout();
73
+ if (layout === 'day') return relative(PROJECT_DIR, DATA_APPS_DIR);
74
+ if (layout === 'single-data') return relative(PROJECT_DIR, DATA_APPS_FILE);
75
+ if (layout === 'single-root') return relative(PROJECT_DIR, ROOT_APPS_FILE);
76
+ return '(no tracker)';
77
+ }
78
+
79
+ // ---------- Reading ----------
80
+
81
+ /**
82
+ * Parse a markdown table line into an app object.
83
+ * Returns null for non-data lines (headers, separators, etc.)
84
+ */
85
+ export function parseAppLine(line) {
86
+ const parts = line.split('|').map(s => s.trim());
87
+ if (parts.length < 9) return null;
88
+ const num = parseInt(parts[1]);
89
+ if (isNaN(num) || num === 0) return null;
90
+ return {
91
+ num, date: parts[2], company: parts[3], role: parts[4],
92
+ score: parts[5], status: parts[6], pdf: parts[7], report: parts[8],
93
+ notes: parts[9] || '', raw: line,
94
+ };
95
+ }
96
+
97
+ /**
98
+ * Read all application entries from either day files or the single-file tracker.
99
+ * Returns { entries: App[], maxNum: number, source: 'day'|'single' }
100
+ */
101
+ export function readAllEntries() {
102
+ const layout = resolveLayout();
103
+ const entries = [];
104
+ let maxNum = 0;
105
+
106
+ if (layout === 'day') {
107
+ for (const file of listDayFiles()) {
108
+ const content = readFileSync(join(DATA_APPS_DIR, file), 'utf-8');
109
+ for (const line of content.split('\n')) {
110
+ const app = parseAppLine(line);
111
+ if (app) {
112
+ app._sourceFile = file;
113
+ entries.push(app);
114
+ if (app.num > maxNum) maxNum = app.num;
115
+ }
116
+ }
117
+ }
118
+ return { entries, maxNum, source: 'day' };
119
+ }
120
+
121
+ const filePath = layout === 'single-data' ? DATA_APPS_FILE : ROOT_APPS_FILE;
122
+ if (layout === 'none') return { entries: [], maxNum: 0, source: 'none' };
123
+
124
+ const content = readFileSync(filePath, 'utf-8');
125
+ for (const line of content.split('\n')) {
126
+ const app = parseAppLine(line);
127
+ if (app) {
128
+ app._sourceFile = filePath;
129
+ entries.push(app);
130
+ if (app.num > maxNum) maxNum = app.num;
131
+ }
132
+ }
133
+ return { entries, maxNum, source: 'single' };
134
+ }
135
+
136
+ /**
137
+ * Read all lines (including headers and blank lines) from either source.
138
+ * For day files, returns { date, lines }[] array.
139
+ * For single file, returns the file content split into lines.
140
+ */
141
+ export function readAllRawLines() {
142
+ const layout = resolveLayout();
143
+ if (layout === 'day') {
144
+ const result = [];
145
+ for (const file of listDayFiles()) {
146
+ const content = readFileSync(join(DATA_APPS_DIR, file), 'utf-8');
147
+ result.push({ date: file.replace('.md', ''), lines: content.split('\n') });
148
+ }
149
+ return { type: 'day', days: result };
150
+ }
151
+ if (layout === 'none') return { type: 'none', lines: [] };
152
+ const filePath = layout === 'single-data' ? DATA_APPS_FILE : ROOT_APPS_FILE;
153
+ const content = readFileSync(filePath, 'utf-8');
154
+ return { type: 'single', lines: content.split('\n'), path: filePath };
155
+ }
156
+
157
+ // ---------- Writing ----------
158
+
159
+ /**
160
+ * Ensure the day-based directory exists and has the initial structure.
161
+ * Creates data/applications/ if needed.
162
+ */
163
+ export function ensureDayDir() {
164
+ if (!existsSync(DATA_APPS_DIR)) {
165
+ mkdirSync(DATA_APPS_DIR, { recursive: true });
166
+ }
167
+ const gitkeep = join(DATA_APPS_DIR, '.gitkeep');
168
+ if (!existsSync(gitkeep)) {
169
+ writeFileSync(gitkeep, '', 'utf-8');
170
+ }
171
+ }
172
+
173
+ const HEADER_LINES = [
174
+ '# Applications Tracker',
175
+ '',
176
+ '| # | Date | Company | Role | Score | Status | PDF | Report | Notes |',
177
+ '|---|------|---------|------|-------|--------|-----|--------|-------|',
178
+ ];
179
+
180
+ /**
181
+ * Get the day-file header (same format as the single-file header).
182
+ */
183
+ export function getHeader() {
184
+ return HEADER_LINES.join('\n');
185
+ }
186
+
187
+ /**
188
+ * Format an app object as a markdown table row.
189
+ */
190
+ export function formatAppLine(app) {
191
+ return `| ${app.num} | ${app.date} | ${app.company} | ${app.role} | ${app.score} | ${app.status} | ${app.pdf} | ${app.report} | ${app.notes} |`;
192
+ }
193
+
194
+ /**
195
+ * Initialize the tracker. If using day-based layout, creates the directory.
196
+ * If using single-file layout, creates data/applications.md with empty header.
197
+ * Returns 'day' or 'single' indicating which layout was initialized.
198
+ */
199
+ export function initTracker() {
200
+ if (usesDayFiles()) {
201
+ ensureDayDir();
202
+ return 'day';
203
+ }
204
+ // If no tracker exists at all, default to day-based layout
205
+ if (!existsSync(DATA_APPS_FILE) && !existsSync(ROOT_APPS_FILE)) {
206
+ ensureDayDir();
207
+ return 'day';
208
+ }
209
+ // Single-file mode: an existing single-file tracker is present
210
+ return 'single';
211
+ }
212
+
213
+ /**
214
+ * Write entries to day files. Takes an array of app objects and distributes them
215
+ * into the correct YYYY-MM-DD.md file based on app.date.
216
+ * If a day file doesn't exist, it's created with the header.
217
+ * Existing day files are rewritten with the provided entries plus any entries
218
+ * not in the provided array (those are preserved).
219
+ */
220
+ export function writeToDayFiles(entries) {
221
+ ensureDayDir();
222
+
223
+ // Group entries by date
224
+ const byDate = new Map();
225
+ for (const app of entries) {
226
+ const date = app.date || toDateStr(new Date());
227
+ if (!byDate.has(date)) byDate.set(date, []);
228
+ byDate.get(date).push(app);
229
+ }
230
+
231
+ // Merge with existing entries in each day file
232
+ for (const [date, dayEntries] of byDate) {
233
+ const path = dayFilePath(date);
234
+ const existing = existsSync(path) ? readFileSync(path, 'utf-8').split('\n') : [];
235
+ const existingNums = new Set(dayEntries.map(e => e.num));
236
+
237
+ // Collect entries from file that aren't in the new set
238
+ const preserved = [];
239
+ for (const line of existing) {
240
+ const app = parseAppLine(line);
241
+ if (app && !existingNums.has(app.num)) {
242
+ preserved.push(app);
243
+ }
244
+ }
245
+
246
+ const allEntries = [...dayEntries, ...preserved].sort((a, b) => a.num - b.num);
247
+ const lines = [
248
+ ...HEADER_LINES,
249
+ ...allEntries.map(formatAppLine),
250
+ ];
251
+ writeFileSync(path, lines.join('\n') + '\n', 'utf-8');
252
+ }
253
+ }
254
+
255
+ // ---------- Utility ----------
256
+
257
+ export { PROJECT_DIR, TABLE_HEADER };
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * verify-pipeline.mjs — Health check for job-forge pipeline integrity
4
+ *
5
+ * Supports both layouts:
6
+ * - Day-based: data/applications/YYYY-MM-DD.md (preferred)
7
+ * - Single-file: data/applications.md or applications.md (legacy)
8
+ *
9
+ * Checks:
10
+ * 1. All statuses are canonical (from templates/states.yml when present, else built-in list)
11
+ * 2. No duplicate company+role entries
12
+ * 3. All report links point to existing files
13
+ * 4. Scores match format X.XX/5 or N/A or DUP
14
+ * 5. All rows have proper pipe-delimited format
15
+ * 6. No pending TSVs in tracker-additions/ (runs even when tracker file is missing)
16
+ * 7. No markdown bold in score column
17
+ * 8. Drift warning if states.yml ids differ from the built-in fallback list
18
+ *
19
+ * Run: node verify-pipeline.mjs (from repo root; same as npm run verify)
20
+ */
21
+
22
+ import { readFileSync, readdirSync, existsSync } from 'fs';
23
+ import { join, relative, dirname } from 'path';
24
+ import { fileURLToPath } from 'url';
25
+ import {
26
+ PROJECT_DIR, DATA_APPS_DIR, DATA_APPS_FILE, ROOT_APPS_FILE,
27
+ usesDayFiles, readAllEntries, listDayFiles, dayFilePath,
28
+ } from './tracker-lib.mjs';
29
+
30
+ const ADDITIONS_DIR = join(PROJECT_DIR, 'batch/tracker-additions');
31
+ const STATES_FILE = existsSync(join(PROJECT_DIR, 'templates/states.yml'))
32
+ ? join(PROJECT_DIR, 'templates/states.yml')
33
+ : join(PROJECT_DIR, 'states.yml');
34
+
35
+ const appsDisplay = usesDayFiles()
36
+ ? relative(PROJECT_DIR, DATA_APPS_DIR)
37
+ : existsSync(DATA_APPS_FILE)
38
+ ? relative(PROJECT_DIR, DATA_APPS_FILE)
39
+ : relative(PROJECT_DIR, ROOT_APPS_FILE);
40
+
41
+ const CANONICAL_STATUSES = [
42
+ 'evaluated', 'applied', 'contacted', 'responded', 'interview',
43
+ 'offer', 'rejected', 'discarded', 'skip',
44
+ ];
45
+
46
+ const ALIASES = {
47
+ 'sent': 'applied',
48
+ };
49
+
50
+ function loadStatesFromYaml(filePath) {
51
+ if (!existsSync(filePath)) return null;
52
+ const text = readFileSync(filePath, 'utf-8');
53
+ const ids = new Set();
54
+ const aliasToId = new Map();
55
+ let currentId = null;
56
+ for (const line of text.split('\n')) {
57
+ const idLine = line.match(/^\s+- id:\s*(\S+)/);
58
+ if (idLine) {
59
+ currentId = idLine[1].toLowerCase();
60
+ ids.add(currentId);
61
+ continue;
62
+ }
63
+ const aliasLine = line.match(/^\s+aliases:\s*\[(.*)\]\s*$/);
64
+ if (aliasLine && currentId) {
65
+ const inner = aliasLine[1].trim();
66
+ if (inner) {
67
+ for (let raw of inner.split(',')) {
68
+ raw = raw.trim().replace(/^['"]|['"]$/g, '');
69
+ if (raw) aliasToId.set(raw.toLowerCase(), currentId);
70
+ }
71
+ }
72
+ }
73
+ }
74
+ if (ids.size === 0) return null;
75
+ return { ids, aliasToId };
76
+ }
77
+
78
+ const statesMeta = loadStatesFromYaml(STATES_FILE);
79
+
80
+ function statusIsAllowed(statusOnlyLower) {
81
+ if (statesMeta) {
82
+ if (statesMeta.ids.has(statusOnlyLower)) return true;
83
+ if (statesMeta.aliasToId.has(statusOnlyLower)) return true;
84
+ return false;
85
+ }
86
+ return CANONICAL_STATUSES.includes(statusOnlyLower) || Boolean(ALIASES[statusOnlyLower]);
87
+ }
88
+
89
+ let errors = 0;
90
+ let warnings = 0;
91
+
92
+ function error(msg) { console.log(`āŒ ${msg}`); errors++; }
93
+ function warn(msg) { console.log(`āš ļø ${msg}`); warnings++; }
94
+ function ok(msg) { console.log(`āœ… ${msg}`); }
95
+
96
+ function checkPendingTrackerAdditions() {
97
+ let pendingTsvs = 0;
98
+ if (existsSync(ADDITIONS_DIR)) {
99
+ const files = readdirSync(ADDITIONS_DIR).filter(f => f.endsWith('.tsv'));
100
+ pendingTsvs = files.length;
101
+ if (pendingTsvs > 0) {
102
+ warn(`${pendingTsvs} pending TSVs in tracker-additions/ (not merged)`);
103
+ }
104
+ }
105
+ if (pendingTsvs === 0) ok('No pending TSVs');
106
+ }
107
+
108
+ function verifyStatesYamlDrift() {
109
+ let stateDrift = 0;
110
+ if (statesMeta) {
111
+ const builtin = new Set(CANONICAL_STATUSES);
112
+ for (const id of statesMeta.ids) {
113
+ if (!builtin.has(id)) {
114
+ warn(`states.yml defines id "${id}" not in verify built-in list — extend CANONICAL_STATUSES if intentional`);
115
+ stateDrift++;
116
+ }
117
+ }
118
+ for (const id of builtin) {
119
+ if (!statesMeta.ids.has(id)) {
120
+ warn(`Built-in status "${id}" missing from ${STATES_FILE} — files may be out of sync`);
121
+ stateDrift++;
122
+ }
123
+ }
124
+ if (stateDrift === 0) ok('states.yml ids match verify built-in fallback list');
125
+ } else if (existsSync(STATES_FILE)) {
126
+ warn(`Could not parse state ids from ${STATES_FILE} — using built-in status list only`);
127
+ }
128
+ }
129
+
130
+ // --- Read entries ---
131
+ const { entries, source } = readAllEntries();
132
+
133
+ if (entries.length === 0) {
134
+ console.log('\nšŸ“Š No tracker entries found (expected data/applications/YYYY-MM-DD.md or data/applications.md).');
135
+ console.log(' This is normal for a fresh setup.\n');
136
+ checkPendingTrackerAdditions();
137
+ verifyStatesYamlDrift();
138
+ console.log('\n' + '='.repeat(50));
139
+ console.log(`šŸ“Š Pipeline Health: ${errors} errors, ${warnings} warnings`);
140
+ if (errors === 0 && warnings === 0) console.log('🟢 Pipeline is clean!');
141
+ else if (errors === 0) console.log('🟔 Pipeline OK with warnings');
142
+ else console.log('šŸ”“ Pipeline has errors — fix before proceeding');
143
+ process.exit(errors > 0 ? 1 : 0);
144
+ }
145
+
146
+ console.log(`\nšŸ“Š Checking ${entries.length} entries from ${source === 'day' ? 'day files' : 'single file'}\n`);
147
+
148
+ // --- Check 1: Canonical statuses ---
149
+ let badStatuses = 0;
150
+ for (const e of entries) {
151
+ const clean = e.status.replace(/\*\*/g, '').trim().toLowerCase();
152
+ const statusOnly = clean.replace(/\s+\d{4}-\d{2}-\d{2}.*$/, '').trim();
153
+
154
+ if (!statusIsAllowed(statusOnly)) {
155
+ error(`#${e.num}: Non-canonical status "${e.status}"`);
156
+ badStatuses++;
157
+ }
158
+
159
+ if (e.status.includes('**')) {
160
+ error(`#${e.num}: Status contains markdown bold: "${e.status}"`);
161
+ badStatuses++;
162
+ }
163
+
164
+ if (/\d{4}-\d{2}-\d{2}/.test(e.status)) {
165
+ error(`#${e.num}: Status contains date: "${e.status}" — dates go in date column`);
166
+ badStatuses++;
167
+ }
168
+ }
169
+ if (badStatuses === 0) ok('All statuses are canonical');
170
+
171
+ // --- Check 2: Duplicates ---
172
+ const companyRoleMap = new Map();
173
+ let dupes = 0;
174
+ for (const e of entries) {
175
+ const key = e.company.toLowerCase().replace(/[^a-z0-9]/g, '') + '::' +
176
+ e.role.toLowerCase().replace(/[^a-z0-9 ]/g, '');
177
+ if (!companyRoleMap.has(key)) companyRoleMap.set(key, []);
178
+ companyRoleMap.get(key).push(e);
179
+ }
180
+ for (const [key, group] of companyRoleMap) {
181
+ if (group.length > 1) {
182
+ warn(`Possible duplicates: ${group.map(e => `#${e.num}`).join(', ')} (${group[0].company} — ${group[0].role})`);
183
+ dupes++;
184
+ }
185
+ }
186
+ if (dupes === 0) ok('No exact duplicates found');
187
+
188
+ // --- Check 3: Report links ---
189
+ let brokenReports = 0;
190
+ for (const e of entries) {
191
+ const match = e.report.match(/\]\(([^)]+)\)/);
192
+ if (!match) continue;
193
+ const reportPath = join(PROJECT_DIR, match[1]);
194
+ if (!existsSync(reportPath)) {
195
+ error(`#${e.num}: Report not found: ${match[1]}`);
196
+ brokenReports++;
197
+ }
198
+ }
199
+ if (brokenReports === 0) ok('All report links valid');
200
+
201
+ // --- Check 4: Score format ---
202
+ let badScores = 0;
203
+ for (const e of entries) {
204
+ const s = e.score.replace(/\*\*/g, '').trim();
205
+ if (!/^\d+\.?\d*\/5$/.test(s) && s !== 'N/A' && s !== 'DUP') {
206
+ error(`#${e.num}: Invalid score format: "${e.score}"`);
207
+ badScores++;
208
+ }
209
+ }
210
+ if (badScores === 0) ok('All scores valid');
211
+
212
+ // --- Check 5: Row format ---
213
+ let badRows = 0;
214
+ // Re-read raw lines for format check
215
+ if (source === 'day') {
216
+ for (const file of listDayFiles()) {
217
+ const content = readFileSync(join(DATA_APPS_DIR, file), 'utf-8');
218
+ for (const line of content.split('\n')) {
219
+ if (!line.startsWith('|')) continue;
220
+ if (line.includes('---') || line.includes('Company')) continue;
221
+ const parts = line.split('|');
222
+ if (parts.length < 9) {
223
+ error(`Row with <9 columns in ${file}: ${line.substring(0, 80)}...`);
224
+ badRows++;
225
+ }
226
+ }
227
+ }
228
+ } else {
229
+ const filePath = existsSync(DATA_APPS_FILE) ? DATA_APPS_FILE : ROOT_APPS_FILE;
230
+ const content = readFileSync(filePath, 'utf-8');
231
+ for (const line of content.split('\n')) {
232
+ if (!line.startsWith('|')) continue;
233
+ if (line.includes('---') || line.includes('Company')) continue;
234
+ const parts = line.split('|');
235
+ if (parts.length < 9) {
236
+ error(`Row with <9 columns: ${line.substring(0, 80)}...`);
237
+ badRows++;
238
+ }
239
+ }
240
+ }
241
+ if (badRows === 0) ok('All rows properly formatted');
242
+
243
+ // --- Check 6: Pending TSVs ---
244
+ checkPendingTrackerAdditions();
245
+
246
+ // --- Check 7: Bold in scores ---
247
+ let boldScores = 0;
248
+ for (const e of entries) {
249
+ if (e.score.includes('**')) {
250
+ warn(`#${e.num}: Score has markdown bold: "${e.score}"`);
251
+ boldScores++;
252
+ }
253
+ }
254
+ if (boldScores === 0) ok('No bold in scores');
255
+
256
+ verifyStatesYamlDrift();
257
+
258
+ console.log('\n' + '='.repeat(50));
259
+ console.log(`šŸ“Š Pipeline Health: ${errors} errors, ${warnings} warnings`);
260
+ if (errors === 0 && warnings === 0) {
261
+ console.log('🟢 Pipeline is clean!');
262
+ } else if (errors === 0) {
263
+ console.log('🟔 Pipeline OK with warnings');
264
+ } else {
265
+ console.log('šŸ”“ Pipeline has errors — fix before proceeding');
266
+ }
267
+ process.exit(errors > 0 ? 1 : 0);