@forwardimpact/outpost 3.12.1 → 3.13.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 (85) hide show
  1. package/package.json +3 -2
  2. package/src/kb-manager.js +58 -5
  3. package/src/kb-validator.js +762 -0
  4. package/src/outpost.js +73 -8
  5. package/templates/.claude/agents/chief-of-staff.md +14 -6
  6. package/templates/.claude/agents/concierge.md +9 -2
  7. package/templates/.claude/agents/head-hunter.md +9 -2
  8. package/templates/.claude/agents/librarian.md +11 -4
  9. package/templates/.claude/agents/postman.md +9 -2
  10. package/templates/.claude/agents/recruiter.md +9 -2
  11. package/templates/.claude/skills/anarlog-follow/SKILL.md +53 -29
  12. package/templates/.claude/skills/anarlog-follow/references/sessions.md +52 -0
  13. package/templates/.claude/skills/anarlog-process/SKILL.md +88 -45
  14. package/templates/.claude/skills/anarlog-process/references/extraction.md +19 -11
  15. package/templates/.claude/skills/anarlog-process/references/sessions.md +92 -58
  16. package/templates/.claude/skills/anarlog-process/scripts/scan.mjs +404 -163
  17. package/templates/.claude/skills/candidate-report/SKILL.md +12 -9
  18. package/templates/.claude/skills/changelog/SKILL.md +106 -56
  19. package/templates/.claude/skills/deck-create/SKILL.md +60 -2
  20. package/templates/.claude/skills/deck-review/SKILL.md +4 -1
  21. package/templates/.claude/skills/deck-summarize/SKILL.md +6 -2
  22. package/templates/.claude/skills/deck-summarize/references/brief-template.md +1 -1
  23. package/templates/.claude/skills/doc-collab/SKILL.md +16 -11
  24. package/templates/.claude/skills/doc-create/SKILL.md +6 -3
  25. package/templates/.claude/skills/draft-emails/SKILL.md +31 -25
  26. package/templates/.claude/skills/draft-emails/references/template.md +1 -1
  27. package/templates/.claude/skills/draft-emails/scripts/scan-emails.mjs +14 -8
  28. package/templates/.claude/skills/draft-emails/scripts/send-email.mjs +9 -3
  29. package/templates/.claude/skills/extract-entities/SKILL.md +17 -16
  30. package/templates/.claude/skills/extract-entities/references/TEMPLATES.md +2 -1
  31. package/templates/.claude/skills/extract-entities/references/conditions.md +6 -5
  32. package/templates/.claude/skills/extract-entities/references/links.md +34 -10
  33. package/templates/.claude/skills/extract-entities/references/recruitment.md +15 -10
  34. package/templates/.claude/skills/extract-entities/references/resolution.md +1 -1
  35. package/templates/.claude/skills/extract-entities/references/sources.md +1 -1
  36. package/templates/.claude/skills/extract-entities/references/templates-conditions.md +10 -4
  37. package/templates/.claude/skills/extract-entities/references/templates-people-orgs.md +29 -10
  38. package/templates/.claude/skills/extract-entities/references/templates-priorities.md +8 -2
  39. package/templates/.claude/skills/extract-entities/references/templates-projects-topics.md +19 -7
  40. package/templates/.claude/skills/meeting-prep/SKILL.md +23 -18
  41. package/templates/.claude/skills/organize-files/SKILL.md +3 -0
  42. package/templates/.claude/skills/person-identify/SKILL.md +58 -10
  43. package/templates/.claude/skills/person-identify/scripts/identify.sh +110 -11
  44. package/templates/.claude/skills/person-lookup/SKILL.md +8 -3
  45. package/templates/.claude/skills/person-lookup/scripts/lookup.sh +27 -1
  46. package/templates/.claude/skills/req-assess/SKILL.md +19 -11
  47. package/templates/.claude/skills/req-assess/references/interview-template.md +6 -1
  48. package/templates/.claude/skills/req-assess/references/panel-template.md +5 -1
  49. package/templates/.claude/skills/req-bundle/SKILL.md +164 -0
  50. package/templates/.claude/skills/req-bundle/references/matching.md +70 -0
  51. package/templates/.claude/skills/req-bundle/references/pdf-structure.md +74 -0
  52. package/templates/.claude/skills/req-bundle/scripts/split-bundle.mjs +377 -0
  53. package/templates/.claude/skills/req-decide/SKILL.md +26 -18
  54. package/templates/.claude/skills/req-decide/references/template.md +2 -2
  55. package/templates/.claude/skills/req-forget/SKILL.md +39 -22
  56. package/templates/.claude/skills/req-forget/references/classify.md +12 -10
  57. package/templates/.claude/skills/req-forget/references/locations.md +27 -22
  58. package/templates/.claude/skills/req-forget/references/report-template.md +23 -15
  59. package/templates/.claude/skills/req-scan/SKILL.md +6 -3
  60. package/templates/.claude/skills/req-scan/references/template.md +11 -1
  61. package/templates/.claude/skills/req-screen/SKILL.md +78 -34
  62. package/templates/.claude/skills/req-screen/references/rubric.md +38 -42
  63. package/templates/.claude/skills/req-screen/references/scoring.md +79 -0
  64. package/templates/.claude/skills/req-screen/references/template.md +26 -14
  65. package/templates/.claude/skills/req-track/SKILL.md +44 -44
  66. package/templates/.claude/skills/req-track/references/fields.md +18 -15
  67. package/templates/.claude/skills/req-track/references/overlays.md +42 -0
  68. package/templates/.claude/skills/req-track/references/signals.md +2 -2
  69. package/templates/.claude/skills/req-track/references/templates.md +28 -27
  70. package/templates/.claude/skills/req-workday/SKILL.md +49 -20
  71. package/templates/.claude/skills/req-workday/references/brief.md +81 -0
  72. package/templates/.claude/skills/req-workday/references/templates.md +6 -73
  73. package/templates/.claude/skills/req-workday/references/xlsx-format.md +19 -0
  74. package/templates/.claude/skills/req-workday/scripts/parse-workday.mjs +96 -20
  75. package/templates/.claude/skills/send-chat/SKILL.md +11 -11
  76. package/templates/.claude/skills/sync-apple-calendar/SKILL.md +3 -0
  77. package/templates/.claude/skills/sync-apple-mail/SKILL.md +3 -0
  78. package/templates/.claude/skills/sync-teams/SKILL.md +3 -0
  79. package/templates/.claude/skills/upstream-instructions/SKILL.md +8 -5
  80. package/templates/.claude/skills/upstream-instructions/references/examples.md +3 -3
  81. package/templates/CLAUDE.md +96 -87
  82. package/templates/MIGRATION.md +359 -0
  83. package/templates/registry.yaml +25 -0
  84. package/templates/.claude/skills/anarlog-follow/scripts/follow.mjs +0 -243
  85. package/templates/.claude/skills/anarlog-trim/SKILL.md +0 -186
@@ -0,0 +1,164 @@
1
+ ---
2
+ name: req-bundle
3
+ description: >
4
+ Split a Workday CV-bundle PDF into per-candidate CV.pdf files under
5
+ 2-Confidential/Candidates/. Reads the PDF outline (one bookmark per candidate) to
6
+ find each candidate's page range, matches it to the requisition roster so
7
+ folder names agree with req-workday, and extracts each CV losslessly. Use
8
+ when the user provides a Workday CV bundle (a large combined resume PDF, often
9
+ split into `_N_of_M` parts) and asks to split, unbundle, or extract individual
10
+ CVs — run this before req-workday imports the roster.
11
+ ---
12
+
13
+ # Workday CV Bundle Split
14
+
15
+ Write tier: `2-Confidential`
16
+ Frontmatter: none (writes CV assets into candidate folders)
17
+
18
+ Split a Workday CV-**bundle** PDF — one combined file holding every applicant's
19
+ attachments — into per-candidate
20
+ `2-Confidential/Candidates/{Clean Name}/CV.pdf`.
21
+
22
+ This is the **PDF companion** to `req-workday`. `req-workday` imports the roster
23
+ from the `.xlsx` (briefs + `CV.md` from embedded resume text); `req-bundle`
24
+ attaches the real binary CV. Run `req-bundle` **first** so the folders it
25
+ creates carry canonical names that `req-workday` then enriches — no duplicate
26
+ folders.
27
+
28
+ **How the split works.** Workday bundles have
29
+ **no per-candidate separator page** and the name printed on a CV often differs
30
+ from the roster name, so text-based splitting is unreliable. The one dependable
31
+ delimiter is the **PDF outline**: exactly one flat bookmark per candidate, its
32
+ destination being that candidate's first page. Candidate *i* = pages
33
+ `[bookmark(i), bookmark(i+1) − 1]`; the last runs to EOF; the leading Table of
34
+ Contents (before the first bookmark) is skipped. Multiple attachments for one
35
+ candidate sit under one bookmark, so the range captures them all.
36
+ `split-bundle.mjs` reads the outline with `pdfjs-dist` (`getOutline` +
37
+ `getPageIndex`) and extracts each range losslessly with `pdf-lib` (`copyPages`).
38
+ Full anatomy: [references/pdf-structure.md](references/pdf-structure.md).
39
+
40
+ ## Trigger
41
+
42
+ - The user provides a Workday CV bundle PDF (a large combined resume export),
43
+ possibly split into `..._1_of_N.pdf … _N_of_N.pdf` parts.
44
+ - The user asks to split, unbundle, or extract individual CVs from a Workday
45
+ export before importing candidates.
46
+
47
+ ## Prerequisites
48
+
49
+ - PDF libraries for the split (`bun`/`node`), installed via the standard guard:
50
+ `bun pm ls pdfjs-dist 2>/dev/null || bun install pdfjs-dist` and
51
+ `bun pm ls pdf-lib 2>/dev/null || bun install pdf-lib`.
52
+ - The `req-workday` parser dependencies, since the roster is produced by
53
+ `parse-workday.mjs` (see [../req-workday/SKILL.md](../req-workday/SKILL.md)):
54
+ `read-excel-file` and `fflate`.
55
+
56
+ ## Inputs
57
+
58
+ - One or more bundle PDF paths (pass **all** parts of a multi-part export).
59
+ - The requisition `.xlsx` (same req as the bundle) — parsed by
60
+ `req-workday`'s `parse-workday.mjs` into the roster JSON this script consumes,
61
+ so folder names are identical to what `req-workday` will create.
62
+
63
+ ## Outputs
64
+
65
+ - `2-Confidential/Candidates/{Clean Name}/CV.pdf` — the candidate's extracted CV
66
+ (created only after the dry-run manifest is reviewed and confirmed).
67
+ - A JSON manifest at `$HOME/.cache/fit/outpost/state/req-bundle-{Req ID}.json`
68
+ — the reviewable mapping of every bookmark (page range, matched name, status).
69
+ - Never overwrites an existing `CV.pdf`; a second CV for the same person is
70
+ staged as `CV-workday.pdf` / `CV-dup-N.pdf` and flagged for review.
71
+
72
+ <do_confirm_checklist goal="Verify the split is complete, correctly named, and non-destructive">
73
+
74
+ - [ ] Bundle page/bookmark counts sanity-checked; all parts of a multi-part
75
+ export passed together.
76
+ - [ ] Roster parsed from the matching `.xlsx`; bookmark count reconciled against
77
+ roster count.
78
+ - [ ] Dry-run manifest reviewed **before** any write; every `unmatched` bookmark
79
+ resolved to a candidate (or explicitly left staged).
80
+ - [ ] Folder names equal the roster `cleanName` (annotation stripped) so they
81
+ match what `req-workday` produces.
82
+ - [ ] Collisions (duplicate applications) and pre-existing `CV.pdf` files
83
+ handled without overwrite (`CV-dup-N.pdf` / `CV-workday.pdf`), each
84
+ flagged.
85
+ - [ ] After commit, output CV count reconciles with matched bookmarks; a few
86
+ PDFs spot-opened to confirm the right, complete candidate.
87
+
88
+ </do_confirm_checklist>
89
+
90
+ ## Procedure
91
+
92
+ ### 1. Set up
93
+
94
+ Confirm the bundle PDF path(s) and the matching `.xlsx` (glob on the req number,
95
+ not the timestamp — parts can carry different timestamps). Resolve `$HOME`
96
+ (never pass a literal `~` to write tools). Ensure dependencies:
97
+
98
+ ```bash
99
+ bun pm ls pdfjs-dist 2>/dev/null || bun install pdfjs-dist
100
+ bun pm ls pdf-lib 2>/dev/null || bun install pdf-lib
101
+ ```
102
+
103
+ ### 2. Parse the roster
104
+
105
+ Produce the canonical candidate list with `req-workday`'s parser (the single
106
+ source of truth for `cleanName`s), saving the JSON for the split step:
107
+
108
+ ```bash
109
+ node .claude/skills/req-workday/scripts/parse-workday.mjs "<path-to-requisition.xlsx>" --summary
110
+ node .claude/skills/req-workday/scripts/parse-workday.mjs "<path-to-requisition.xlsx>" \
111
+ > "$HOME/.cache/fit/outpost/state/req-bundle-roster-{Req ID}.json"
112
+ ```
113
+
114
+ ### 3. Dry-run the split
115
+
116
+ Compute the mapping and manifest **without writing** any PDF. Pass every part of
117
+ a multi-part bundle in one invocation:
118
+
119
+ ```bash
120
+ node .claude/skills/req-bundle/scripts/split-bundle.mjs \
121
+ "<bundle_1_of_N.pdf>" "<bundle_2_of_N.pdf>" ... \
122
+ --roster "$HOME/.cache/fit/outpost/state/req-bundle-roster-{Req ID}.json" \
123
+ --candidates-dir 2-Confidential/Candidates \
124
+ --manifest "$HOME/.cache/fit/outpost/state/req-bundle-{Req ID}.json" \
125
+ --dry-run
126
+ ```
127
+
128
+ The script prints a summary (bookmarks, matched, unmatched, collisions,
129
+ existing-CV, roster-without-bookmark) and writes the full manifest. Matching
130
+ rules and edge cases: [references/matching.md](references/matching.md).
131
+
132
+ ### 4. Reconcile and resolve
133
+
134
+ Review the manifest. Confirm `bookmarks_total` reconciles with the roster count
135
+ (expect a near-exact match). For each `unmatched` bookmark, use its `hints`
136
+ (nearest roster names by shared tokens) to identify the candidate; note the
137
+ correct `cleanName`. Investigate any `roster_unmatched_names` (applicants with
138
+ no CV in the bundle — usually withdrawn/no-attachment). Do not proceed while
139
+ unexpected mismatches remain unexplained.
140
+
141
+ ### 5. Confirm, then commit
142
+
143
+ Summarize for the user: N CVs to write, M unmatched, K collisions/existing.
144
+ **Get explicit confirmation** before writing. Re-run without `--dry-run` (same
145
+ roster JSON) to create folders and write the CVs:
146
+
147
+ ```bash
148
+ node .claude/skills/req-bundle/scripts/split-bundle.mjs \
149
+ "<bundle parts...>" \
150
+ --roster "$HOME/.cache/fit/outpost/state/req-bundle-roster-{Req ID}.json" \
151
+ --candidates-dir 2-Confidential/Candidates \
152
+ --manifest "$HOME/.cache/fit/outpost/state/req-bundle-{Req ID}.json"
153
+ ```
154
+
155
+ For bookmarks that were `unmatched` but you resolved by hand in Step 4, move the
156
+ staged file into the correct `2-Confidential/Candidates/{Clean Name}/CV.pdf`, or
157
+ add the missing name variant and re-run — never hand-edit PDFs.
158
+
159
+ ### 6. Hand off and report
160
+
161
+ Point `req-workday` at the same `.xlsx` to build briefs/`CV.md` and link the CV
162
+ under each brief's `## CV` section. Flag every candidate that now has a `CV.pdf`
163
+ for `req-screen`. Report `Split {matched}/{bookmarks_total} CVs for {Req ID}`,
164
+ and list any unmatched bookmarks or roster applicants without a CV.
@@ -0,0 +1,70 @@
1
+ # Matching bookmarks to the roster, and edge cases
2
+
3
+ How `split-bundle.mjs` maps each PDF bookmark to a canonical candidate folder,
4
+ and how it handles duplicates, existing files, and misses. The goal: folder
5
+ names identical to what `req-workday` produces, and **never** a destructive
6
+ overwrite.
7
+
8
+ ## Why match at all
9
+
10
+ The bookmark title is a clean name, but folder names must equal the roster
11
+ `cleanName` — the applicant name from the `.xlsx` `Candidates` sheet with any
12
+ trailing annotation (`(Internal)`, `(Prior Worker)`, …) stripped. That
13
+ `cleanName` comes **directly from `req-workday`'s `parse-workday.mjs`** (the
14
+ single source of truth this skill consumes as JSON), so the two skills agree by
15
+ construction and never create duplicate folders. So: bookmark title → (match) →
16
+ roster `cleanName` → folder.
17
+
18
+ ## Normalization (both sides)
19
+
20
+ Before comparing, each name is folded with `normalize()`:
21
+
22
+ 1. Remove parentheticals — `(ΝΙΚΟΣ ΠΑΠΑΔΟΠΟΥΛΟΣ)`, `(Internal)`, `(Referral)`.
23
+ 2. Remove bare annotation tokens — internal / referral / prior worker /
24
+ external.
25
+ 3. Strip diacritics (NFKD) — `Geraño` → `gerano`.
26
+ 4. Lowercase, keep `[a-z0-9 ]`, collapse whitespace.
27
+
28
+ ## Match methods (conservative, deterministic)
29
+
30
+ Applied in order; the method is recorded in the manifest:
31
+
32
+ 1. **`exact`** — normalized strings equal.
33
+ 2. **`token-set`** — same set of tokens regardless of order (handles
34
+ "First Last" ↔ "Last First" and dropped middle names).
35
+ 3. **`unmatched`** — no confident match. The script does **not** guess by
36
+ subset/Levenshtein (that risks merging two different "David …"s). Instead it
37
+ attaches `hints`: the nearest roster names by shared-token count, for a human
38
+ to resolve.
39
+
40
+ ## Edge cases & how the script handles them
41
+
42
+ - **Duplicate applications (collisions).** The same person appears under two
43
+ bookmarks (e.g. across two parts of a multi-part bundle). Both resolve to one
44
+ folder. The first writes `CV.pdf`; the next becomes `CV-dup-2.pdf` (then
45
+ `-3`, …) with status `collision`. Review whether it's a true duplicate (keep
46
+ one) or a distinct newer packet (rename intentionally). Never silently
47
+ overwrite.
48
+ - **Pre-existing `CV.pdf`.** A candidate who already arrived via email
49
+ (`req-track`) may already have `CV.pdf`. The script writes `CV-workday.pdf`
50
+ with status `existing-cv` and flags it, so the richer existing artifact is
51
+ preserved. Decide per candidate which to keep.
52
+ - **Unmatched bookmark.** Staged in a folder named from the sanitized bookmark
53
+ title, status `unmatched`, with `hints`. Resolve by identifying the right
54
+ `cleanName` and moving the staged `CV.pdf` into that folder, or by adding the
55
+ missing name variant and re-running. A genuinely stray bookmark (someone not
56
+ on the roster) is worth flagging to the user.
57
+ - **Roster applicant with no bookmark** (`roster_unmatched_names`). Applied but
58
+ no CV in the bundle — usually withdrawn or never attached a resume. Expected;
59
+ just report it. Not an error.
60
+ - **No outline** (`no-outline` status for a file). The PDF has no bookmarks —
61
+ it is not a standard Workday bundle, or the outline was stripped. Do not
62
+ attempt a blind page-count split; stop and tell the user.
63
+
64
+ ## Reconciliation checklist
65
+
66
+ - `bookmarks_total` ≈ roster count (small gaps explained by
67
+ `roster_unmatched_names`).
68
+ - `unmatched_bookmarks` == 0 after Step 4 resolution (or each is explained).
69
+ - `matched` + resolved unmatched == CVs you expect to write.
70
+ - Known repeat applicants line up with the reported `collisions`.
@@ -0,0 +1,74 @@
1
+ # Workday CV Bundle — PDF anatomy
2
+
3
+ What a Workday "Download resumes / attachments" bundle looks like, and why the
4
+ PDF outline is the only reliable way to split it.
5
+
6
+ ## The shape of a bundle
7
+
8
+ - A bundle is **one PDF that concatenates every applicant's attachments** — CV,
9
+ and often a cover letter and certificates too — back to back.
10
+ - **Front matter:** page 1 (and sometimes 2–3) is a whole-bundle **Table of
11
+ Contents** titled with the req, e.g. `4951493 - Principal Software Engineer`,
12
+ listing `Candidate Name | Candidate ID | Attachments`. The TOC has **no page
13
+ numbers** in its extracted text, so it is not a usable split key — skip it.
14
+ - **No per-candidate separator/cover page.** Each candidate's first attachment
15
+ begins directly on its own page. The TOC is the only inserted page.
16
+
17
+ ## The delimiter: the PDF outline (bookmarks)
18
+
19
+ Every bundle carries a **flat, depth-0 PDF outline with exactly one bookmark per
20
+ candidate**. The bookmark's destination is that candidate's **first page**, and
21
+ its title is the **clean candidate name** (no `(Internal)` / `(Referral)`
22
+ suffix). This is the split key:
23
+
24
+ - Candidate *i* → pages `[bookmark(i).page, bookmark(i+1).page − 1]`.
25
+ - Last candidate → `[bookmark(last).page, EOF]`.
26
+ - A candidate's **multiple attachments are all under their single bookmark**, so
27
+ the page range captures the cover letter + CV + certs together. (This is fine
28
+ — a hiring manager wants the whole packet; `req-screen` reads the CV within.)
29
+ - Bookmark counts match roster counts exactly (verified live: 75↔75 on a
30
+ single-part export; 366↔366 across the four parts of a large export).
31
+
32
+ `split-bundle.mjs` reads this via `pdfjs-dist`: `doc.getOutline()` for the
33
+ items, then `doc.getPageIndex(dest[0])` (resolving named destinations through
34
+ `doc.getDestination()` first) to turn each bookmark into a 0-based page index,
35
+ then sorts by page. Page ranges are extracted losslessly with `pdf-lib`
36
+ (`copyPages`).
37
+
38
+ ## What NOT to rely on
39
+
40
+ - **Separator-page detection** — there are none.
41
+ - **Name text at the top of a page** — a CV's printed name is the candidate's
42
+ own styling and often differs from the roster name (bookmark "Vivian Chen" →
43
+ CV reads "Wei-Ling (Vivian) Chen, PhD"). Match the **bookmark title**, not
44
+ page text.
45
+ - **Page-text extraction** — unnecessary and unreliable for boundary detection:
46
+ the outline gives exact boundaries and the `.xlsx` already carries the resume
47
+ text.
48
+
49
+ ## Multi-part bundles (`_1_of_N` … `_N_of_N`)
50
+
51
+ When a req has many applicants, Workday emits several PDFs. Key facts:
52
+
53
+ - **Each part is self-contained:** its own TOC, its own ~100-candidate subset in
54
+ its own internal order (parts are *not* sequential slices of one ordering).
55
+ - **No candidate spans a part boundary** — every part's first bookmark is a
56
+ fresh candidate after its TOC, and its last candidate is fully contained.
57
+ - Therefore **process each part independently** and union the results; pass all
58
+ parts to `split-bundle.mjs` in one call.
59
+ - A person may legitimately appear in two parts (a genuine duplicate
60
+ application). That surfaces as a **collision** in the manifest (same folder
61
+ targeted twice) — handled per [matching.md](matching.md), not stitched.
62
+
63
+ ## Observed reference numbers (live exports)
64
+
65
+ | Bundle | Pages | Bookmarks |
66
+ | ---------------------------------------- | ----- | --------- |
67
+ | Principal SWE req (single file) | 215 | 75 |
68
+ | Sr. Director req `_1_of_4` | 321 | 100 |
69
+ | Sr. Director req `_2_of_4` | 314 | 100 |
70
+ | Sr. Director req `_3_of_4` | 278 | 100 |
71
+ | Sr. Director req `_4_of_4` | 213 | 66 |
72
+
73
+ Note: one part's filename carried a slightly different timestamp than its
74
+ siblings — glob on the req number, not the timestamp.
@@ -0,0 +1,377 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Split a Workday CV-bundle PDF into per-candidate CV.pdf files.
4
+ *
5
+ * Workday's "bundle" export concatenates every candidate's attachments (CV,
6
+ * cover letter, certificates) into one PDF with NO per-candidate separator page.
7
+ * The only reliable delimiter is the PDF outline (bookmarks): exactly one flat,
8
+ * depth-0 bookmark per candidate whose destination is that candidate's first
9
+ * page. Candidate i occupies pages [bookmark(i), bookmark(i+1) - 1]; the last
10
+ * candidate runs to the end of the file. Everything before the first bookmark is
11
+ * the front-matter Table of Contents and is skipped.
12
+ *
13
+ * This script reads the outline (pdfjs-dist), matches each bookmark title to a
14
+ * canonical candidate name from the parsed Workday roster (so folder names agree
15
+ * with req-workday), extracts the page range losslessly (pdf-lib), and writes it
16
+ * to 2-Confidential/Candidates/{Clean Name}/CV.pdf.
17
+ *
18
+ * It ALWAYS emits a JSON manifest describing every mapping and never overwrites
19
+ * an existing CV.pdf. With --dry-run it computes the manifest without touching
20
+ * disk. Multi-part bundles (`..._1_of_4.pdf` … `_4_of_4.pdf`) are self-contained:
21
+ * pass all parts as positional arguments; each part is processed independently.
22
+ *
23
+ * The roster is the stdout JSON of req-workday/scripts/parse-workday.mjs
24
+ * ({ requisition, candidates: [{ name, cleanName, ... }] }) — the single source
25
+ * of truth for candidate names, so this script needs no XLSX parser of its own.
26
+ *
27
+ * Requires (bun install): pdfjs-dist, pdf-lib.
28
+ *
29
+ * Usage:
30
+ * node split-bundle.mjs <bundle.pdf> [<bundle2.pdf> ...] \
31
+ * --roster <roster.json> \
32
+ * --candidates-dir <2-Confidential/Candidates> \
33
+ * --manifest <out.json> \
34
+ * [--dry-run]
35
+ */
36
+
37
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
38
+ import { basename, join } from "node:path";
39
+
40
+ let PDFDocument, pdfjs;
41
+ try {
42
+ ({ PDFDocument } = await import("pdf-lib"));
43
+ pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
44
+ } catch {
45
+ console.error(
46
+ "Error: pdfjs-dist and/or pdf-lib not found. Install them first:\n bun install pdfjs-dist pdf-lib",
47
+ );
48
+ process.exit(1);
49
+ }
50
+
51
+ // --- CLI parsing --------------------------------------------------------------
52
+
53
+ if (
54
+ process.argv.includes("-h") ||
55
+ process.argv.includes("--help") ||
56
+ process.argv.length < 3
57
+ ) {
58
+ console.log(`split-bundle — split a Workday CV bundle into per-candidate CV.pdf by PDF bookmarks
59
+
60
+ Usage:
61
+ node split-bundle.mjs <bundle.pdf> [<bundle2.pdf> ...] --roster <roster.json> \\
62
+ --candidates-dir <dir> --manifest <out.json> [--dry-run]
63
+
64
+ <roster.json> is the stdout of req-workday/scripts/parse-workday.mjs.`);
65
+ process.exit(process.argv.length < 3 ? 1 : 0);
66
+ }
67
+
68
+ function optValue(name) {
69
+ const i = process.argv.indexOf(name);
70
+ return i >= 0 && i + 1 < process.argv.length
71
+ ? process.argv[i + 1]
72
+ : undefined;
73
+ }
74
+
75
+ const dryRun = process.argv.includes("--dry-run");
76
+ const rosterPath = optValue("--roster");
77
+ const candidatesDir = optValue("--candidates-dir");
78
+ const manifestPath = optValue("--manifest");
79
+ const FLAGS_WITH_VALUE = new Set([
80
+ "--roster",
81
+ "--candidates-dir",
82
+ "--manifest",
83
+ ]);
84
+ const bundles = process.argv.slice(2).filter((a, i, arr) => {
85
+ if (a.startsWith("--")) return false;
86
+ const prev = arr[i - 1];
87
+ return !(prev && FLAGS_WITH_VALUE.has(prev)); // drop values consumed by flags
88
+ });
89
+
90
+ if (!candidatesDir || !manifestPath || bundles.length === 0) {
91
+ console.error(
92
+ "Error: need <bundle.pdf...>, --candidates-dir, and --manifest.",
93
+ );
94
+ process.exit(1);
95
+ }
96
+
97
+ // --- Name normalization & matching -------------------------------------------
98
+
99
+ const SUFFIX_RE = /\b(internal|referral|prior worker|external|contractor)\b/gi;
100
+ const PAREN_RE = /\([^)]*\)/g;
101
+
102
+ /** Fold a display name to a comparable key: no parentheticals, no diacritics,
103
+ * lowercase alphanumerics only, whitespace collapsed. */
104
+ function normalize(name) {
105
+ let s = name || "";
106
+ s = s.replace(PAREN_RE, " ").replace(SUFFIX_RE, " ");
107
+ s = s.normalize("NFKD").replace(/\p{M}/gu, ""); // strip combining accents
108
+ s = s.toLowerCase().replace(/[^a-z0-9\s]/g, " ");
109
+ return s.replace(/\s+/g, " ").trim();
110
+ }
111
+
112
+ /** Make a bookmark title safe as a directory name (used only when unmatched). */
113
+ function sanitizeFolder(title) {
114
+ const s = (title || "").replace(/[/\\]/g, "-").replace(/\s+/g, " ").trim();
115
+ return s || "UNKNOWN";
116
+ }
117
+
118
+ class Roster {
119
+ constructor(candidates) {
120
+ this.byExact = new Map(); // normalized name -> canonical cleanName
121
+ this.byTokens = new Map(); // sorted-token key -> canonical cleanName
122
+ this.all = []; // [{ key, clean }]
123
+ for (const c of candidates) {
124
+ const clean = (c.cleanName || c.name || "").trim();
125
+ if (!clean) continue;
126
+ const key = normalize(clean);
127
+ if (!key) continue;
128
+ if (!this.byExact.has(key)) this.byExact.set(key, clean);
129
+ const tkey = key.split(" ").sort().join(" ");
130
+ if (!this.byTokens.has(tkey)) this.byTokens.set(tkey, clean);
131
+ this.all.push({ key, clean });
132
+ }
133
+ }
134
+
135
+ /** Return { clean, method } — conservative: exact key, then token-set equality. */
136
+ match(title) {
137
+ const key = normalize(title);
138
+ if (!key) return { clean: null, method: "unmatched" };
139
+ if (this.byExact.has(key))
140
+ return { clean: this.byExact.get(key), method: "exact" };
141
+ const tkey = key.split(" ").sort().join(" ");
142
+ if (this.byTokens.has(tkey))
143
+ return { clean: this.byTokens.get(tkey), method: "token-set" };
144
+ return { clean: null, method: "unmatched" };
145
+ }
146
+
147
+ /** Nearest roster names by shared-token count — helps a human resolve a miss. */
148
+ hints(title, limit = 3) {
149
+ const tokens = new Set(normalize(title).split(" ").filter(Boolean));
150
+ const scored = [];
151
+ for (const { key, clean } of this.all) {
152
+ const shared = key.split(" ").filter((t) => tokens.has(t)).length;
153
+ if (shared) scored.push({ shared, clean });
154
+ }
155
+ scored.sort(
156
+ (a, b) => b.shared - a.shared || a.clean.localeCompare(b.clean),
157
+ );
158
+ const out = [];
159
+ for (const { clean } of scored) {
160
+ if (!out.includes(clean)) out.push(clean);
161
+ if (out.length >= limit) break;
162
+ }
163
+ return out;
164
+ }
165
+ }
166
+
167
+ // --- PDF outline handling -----------------------------------------------------
168
+
169
+ /** Return [{ title, page }] (0-based page index) for every outline destination,
170
+ * in document order. pdfjs resolves both named and explicit destinations. */
171
+ async function readOutline(doc) {
172
+ const outline = (await doc.getOutline()) || [];
173
+ const items = [];
174
+
175
+ async function pageIndex(dest) {
176
+ const explicit =
177
+ typeof dest === "string" ? await doc.getDestination(dest) : dest;
178
+ if (!Array.isArray(explicit) || !explicit[0]) return null;
179
+ try {
180
+ return await doc.getPageIndex(explicit[0]);
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ // Flatten defensively (Workday bundles are flat, depth-0).
187
+ const stack = [...outline];
188
+ while (stack.length) {
189
+ const node = stack.shift();
190
+ const page = await pageIndex(node.dest);
191
+ if (node.title != null && page != null)
192
+ items.push({ title: String(node.title), page });
193
+ if (Array.isArray(node.items) && node.items.length)
194
+ stack.unshift(...node.items);
195
+ }
196
+ items.sort((a, b) => a.page - b.page);
197
+ return items;
198
+ }
199
+
200
+ function partLabel(path) {
201
+ const m = basename(path).match(/_(\d+)_of_(\d+)\b/);
202
+ return m ? `${m[1]}/${m[2]}` : null;
203
+ }
204
+
205
+ // --- Main ---------------------------------------------------------------------
206
+
207
+ let requisition = {};
208
+ let rosterCandidates = [];
209
+ if (rosterPath && existsSync(rosterPath)) {
210
+ const data = JSON.parse(readFileSync(rosterPath, "utf8"));
211
+ rosterCandidates = data.candidates || [];
212
+ requisition = data.requisition || {};
213
+ }
214
+ const roster = new Roster(rosterCandidates);
215
+
216
+ const entries = [];
217
+ const matchedClean = new Set(); // normalized cleanNames that got a bookmark
218
+ const folderWritten = new Map(); // folder -> count (within this run, for collisions)
219
+
220
+ for (const bundle of bundles) {
221
+ // pdfjs detaches the ArrayBuffer it's handed, so give each library its own copy.
222
+ const doc = await pdfjs.getDocument({
223
+ data: new Uint8Array(readFileSync(bundle)),
224
+ useSystemFonts: true,
225
+ verbosity: 0,
226
+ }).promise;
227
+ const outline = await readOutline(doc);
228
+ const part = partLabel(bundle);
229
+
230
+ if (outline.length === 0) {
231
+ entries.push({
232
+ bundle,
233
+ part,
234
+ status: "no-outline",
235
+ note: "No PDF bookmarks found — cannot split this file by candidate.",
236
+ });
237
+ continue;
238
+ }
239
+
240
+ // pdf-lib source (only loaded when we actually extract).
241
+ let src = null;
242
+ let total = 0;
243
+ if (!dryRun) {
244
+ src = await PDFDocument.load(new Uint8Array(readFileSync(bundle)));
245
+ total = src.getPageCount();
246
+ } else {
247
+ total = doc.numPages;
248
+ }
249
+
250
+ for (let i = 0; i < outline.length; i++) {
251
+ const start = outline[i].page;
252
+ const end = i + 1 < outline.length ? outline[i + 1].page - 1 : total - 1;
253
+ const title = outline[i].title;
254
+ const { clean, method } = roster.match(title);
255
+ const matched = clean != null;
256
+ let folder, status, hints;
257
+ if (matched) {
258
+ matchedClean.add(normalize(clean));
259
+ folder = clean;
260
+ status = "matched";
261
+ hints = [];
262
+ } else {
263
+ folder = sanitizeFolder(title);
264
+ status = "unmatched";
265
+ hints = roster.hints(title);
266
+ }
267
+
268
+ const destDir = join(candidatesDir, folder);
269
+ const cvPath = join(destDir, "CV.pdf");
270
+ const seen = folderWritten.get(folder) || 0;
271
+ const existingCv = existsSync(cvPath);
272
+ let outName;
273
+ if (seen > 0) {
274
+ outName = `CV-dup-${seen + 1}.pdf`;
275
+ if (matched) status = "collision";
276
+ } else if (existingCv) {
277
+ outName = "CV-workday.pdf";
278
+ if (matched) status = "existing-cv";
279
+ } else {
280
+ outName = "CV.pdf";
281
+ }
282
+ folderWritten.set(folder, seen + 1);
283
+ const outPath = join(destDir, outName);
284
+
285
+ let action = dryRun ? "would-write" : "pending";
286
+ if (!dryRun) {
287
+ try {
288
+ mkdirSync(destDir, { recursive: true });
289
+ const out = await PDFDocument.create();
290
+ const idxs = [];
291
+ for (let p = start; p <= end; p++) idxs.push(p);
292
+ const copied = await out.copyPages(src, idxs);
293
+ for (const pg of copied) out.addPage(pg);
294
+ writeFileSync(outPath, await out.save());
295
+ action = "wrote";
296
+ } catch (exc) {
297
+ action = `error: ${exc.message || exc}`;
298
+ }
299
+ }
300
+
301
+ entries.push({
302
+ bundle,
303
+ part,
304
+ order: i + 1,
305
+ title,
306
+ page_start: start + 1, // 1-based for humans
307
+ page_end: end + 1,
308
+ page_count: end - start + 1,
309
+ matched,
310
+ match_method: method,
311
+ clean_name: clean || "",
312
+ folder,
313
+ status,
314
+ out_path: outPath,
315
+ action,
316
+ hints,
317
+ });
318
+ }
319
+ }
320
+
321
+ const rosterUnmatched = [
322
+ ...new Set(
323
+ rosterCandidates
324
+ .map((c) => c.cleanName || c.name)
325
+ .filter((n) => n && !matchedClean.has(normalize(n))),
326
+ ),
327
+ ].sort();
328
+
329
+ const bookmarkEntries = entries.filter((e) => "title" in e);
330
+ const summary = {
331
+ bundles: bundles.length,
332
+ bookmarks_total: bookmarkEntries.length,
333
+ matched: bookmarkEntries.filter((e) => e.matched).length,
334
+ unmatched_bookmarks: bookmarkEntries.filter((e) => !e.matched).length,
335
+ collisions: bookmarkEntries.filter((e) => e.status === "collision").length,
336
+ existing_cv: bookmarkEntries.filter((e) => e.status === "existing-cv").length,
337
+ roster_total: rosterCandidates.length,
338
+ roster_unmatched: rosterUnmatched.length,
339
+ };
340
+
341
+ const manifest = {
342
+ generated_from: bundles,
343
+ requisition,
344
+ candidates_dir: candidatesDir,
345
+ dry_run: dryRun,
346
+ summary,
347
+ entries,
348
+ roster_unmatched_names: rosterUnmatched,
349
+ };
350
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
351
+
352
+ // Human summary to stdout.
353
+ const mode = dryRun ? "DRY RUN — nothing written" : "COMMIT";
354
+ console.log(
355
+ `[${mode}] ${requisition.id || "?"} ${requisition.title || ""}`.trimEnd(),
356
+ );
357
+ console.log(` bundles: ${summary.bundles}`);
358
+ console.log(` bookmarks: ${summary.bookmarks_total}`);
359
+ console.log(` matched → roster: ${summary.matched}`);
360
+ console.log(` unmatched bookmark: ${summary.unmatched_bookmarks}`);
361
+ console.log(` collisions (dupes): ${summary.collisions}`);
362
+ console.log(` existing CV.pdf: ${summary.existing_cv}`);
363
+ console.log(
364
+ ` roster w/o bookmark:${summary.roster_unmatched} (of ${summary.roster_total})`,
365
+ );
366
+ console.log(` manifest: ${manifestPath}`);
367
+ if (summary.unmatched_bookmarks) {
368
+ console.log(" ⚠ unmatched bookmarks (resolve in the manifest):");
369
+ for (const e of bookmarkEntries) {
370
+ if (!e.matched) {
371
+ const hint = e.hints.length ? ` ~ ${e.hints.join(", ")}` : "";
372
+ console.log(
373
+ ` - "${e.title}" (p${e.page_start}-${e.page_end})${hint}`,
374
+ );
375
+ }
376
+ }
377
+ }