LinkedIn Highlights
Produce the prose for one LinkedIn Experience role's Highlights field, from a git repo the user worked in.
The field is a single plain textarea — no formatting toolbar — capped at 2,000 characters and prompting "Projects, problems you solved, or results you achieved". Both facts come from the user's own screenshot of the Edit-role dialog, 2026-09-10, which is the primary source for this skill. Field mechanics and the format spec live in references/highlights-format.md; the extraction procedure in references/repo-evidence.md.
The scrub in step 7 is why this is a skill and not a prompt. Every
other step is careful writing an unaided model does reasonably well. The
scrub encodes something no unaided run would reconstruct: the
identity-guard hook gates git commit and git push only, and skips
exempt repo roots wholesale — so text carried out of a work repo onto a
public profile passes no gate at all.
Scope and stop conditions
This skill writes one field. Stop and say so if:
- The target is a resume, a CV, a cover letter, or a LinkedIn post.
Different length, different register, different conventions, and none
of them were drilled. A resume skill may exist later and will read
references/repo-evidence.md; it does not exist now. - The target is not a git repo. The evidence method is git history plus committed docs. Without both there is nothing to extract from, and inventing the content is the failure this skill exists to prevent.
- Authorship cannot be established (step 1). Writing someone else's work into the user's profile is worse than writing nothing.
Multiple roles means multiple runs. Do not batch them: the character budget is per role and the evidence boundary is per role.
1. Fix the role boundary
Settle four things before reading anything: which repo, which role, which date range, and whether the work is live. Ask if any is unclear — the answers bound everything downstream. Deployment state is the one easily skipped, and it decides how step 5 handles outcome figures: a system still in Test has no adoption number to ask for.
Establish authorship and filter by it:
git shortlog -sne --all
git log --author="<the user>" --since=<start> --until=<end> --oneline | wc -l
Tenure bounds claim size. A three-month role cannot support a multi-year narrative, and the date range is the check on that. If the user's commits sit outside the stated role dates, ask before proceeding rather than quietly widening the window.
In a multi-author repo, every count from step 3 onward is filtered by author. In a sole-authored repo say so explicitly in the draft — "sole author" is itself a claim worth making, and it is checkable.
2. Read the docs before the history
A repo's own documentation states purpose and design intent in the
author's words; git history cannot. Read, in this order: README,
ARCHITECTURE, CONTRIBUTING, then any docs/ tree.
The ordering is measured rather than stylistic. In the repo this skill was built against — 363 commits, sole-authored, June–September 2026 — conventional-commit prefixes appear on roughly 14 of 363 commits (~4%), so commit-subject mining is unreliable. The same repo carries a 5,533-word, 31-heading architecture document, a 2,208-word README and a 1,607-word contributing guide. The documentation is where the substance is; history is corroboration and dating.
Use history for what docs cannot give: when work happened, how much of it was the user's, and whether a documented intent actually shipped.
3. Build the inventory — count items, not files
The centerpiece, and the one place a confident wrong number is easy to produce.
A naive per-extension or per-directory-suffix count is wrong by one to two orders of magnitude. Measured in that same repo: counting directory suffixes yields 91 Reports, 48 Warehouses, 23 Semantic Models. The true counts are 1, 1 and 1. Fabric and PBIR serialization explode a single item into dozens of files — a PBIR report is a directory tree of per-visual JSON, a warehouse a tree of per-object definitions.
The rule: count the distinct item directories, not the files beneath them, and verify every count a second way with a different command before it reaches prose. The honest inventory for that repo was 4 workspaces, 14 notebooks, 8 eventstreams, 2 KQL databases, 2 data pipelines, and one each of report, warehouse, semantic model, eventhouse and lakehouse.
Line totals are the sibling trap and never a claim. Whole-history insertions in that repo run ~684k against ~219k deletions. That number is generated item serialization, not work done. Never put an insertion count, a deletion count or a net-lines figure in the prose.
The counting commands, the second-check pattern, and the per-item-type directory markers are in references/repo-evidence.md.
4. Sort claims by evidence strength
Put every candidate claim in exactly one tier:
- Countable from the repo now — item counts, date ranges, languages and platforms in use, sole vs. shared authorship. These can go in the prose as stated fact.
- Documented as design intent in the repo's own docs — an architecture document's stated goal, a README's stated purpose. These go in as intent, phrased as what was built to do, not as what it achieved.
- Knowable only to the user — users served, uptime, cost, hours saved, revenue, business outcome, team size, stakeholders, adoption. Tier 3 never becomes prose from inference. A repo cannot know any of it, and every one of these is checkably false to a peer in the field if guessed wrong.
Tier 3 goes to step 5. Never silently promote a tier-3 claim to tier 1 because it would read well.
5. Ask for what the repo cannot know
Put the tier-3 list to the user as a short, explicit set of questions —
AskUserQuestion where the options are enumerable, plain prose
otherwise. Ask for the two or three that would most change the draft,
not for everything at once.
Two failure modes to avoid, in both directions:
- Do not emit prose with placeholders —
[N] users,<X>% faster. A placeholder that survives into the paste box becomes a public profile reading[N]. - Do not quietly drop the claim either. A Highlights entry with no outcome in it — all activity, no result — is the common failure this step exists to prevent. If the user genuinely has no number, say so and write the outcome qualitatively instead.
Accept "I don't know" as an answer and move on; do not press twice.
When the system is not live yet
Check whether tier 3 is unavailable rather than merely unknown, and say which. A system still in Test, or built but not promoted, has no adoption, no hours saved and no uptime — not because the user has not measured them, but because they do not exist yet. Asking anyway invites a guess about a system with no users, which is the failure this step exists to prevent, arriving by the route the step itself opened.
So establish the deployment state in step 1 alongside the dates, and when it is pre-production:
- Do not ask for outcome figures. Say why you are not asking. The omission is evidence-driven and worth one sentence.
- Write the outcome as capability delivered, not as result achieved — what the platform now makes possible, in tier-2 intent phrasing.
- State the deployment state in the draft where it is load-bearing. "Validated through Test and pending production promotion" is a stronger sentence than an unqualified claim, because it is checkable.
- Flag the entry for a re-run at go-live. Promotion changes the closing paragraph and is usually the point at which a real outcome number first exists.
Measured 2026-09-10 on a pre-production Fabric platform: the run reached this shape unaided and was right to, which is why it is written down rather than left to judgment.
6. Draft to the format
Full spec in references/highlights-format.md. The load-bearing rules:
- Write prose paragraphs, not bullets. Three of the user's real
entries were measured on 2026-09-10 and contain zero
•characters and no list markers of any kind; structure is carried by paragraph breaks alone. Advice articles say "3–5 bullets per role"; the real entries overrule them. Offer bullets only if asked. - Target ~1,500 characters, not 2,000. The cap is 2,000 and the measured entries run 1,468–1,666 — every one leaves 330–530 characters unused. Treat 2,000 as a ceiling you never approach.
- 3–4 paragraphs, one theme each, 2–3 sentences per paragraph, 22–31 words per sentence. This register is long-sentenced; a draft averaging 15 words per sentence is in the wrong one.
- Open paragraph 1 with
At <Organization>, I …and open later paragraphs on a framing adverbial rather than on "I". - Attach the outcome to the activity in the same sentence — a
trailing participial clause (
enabling…,eliminating…,giving…) or an em-dash into the consequence. Two-thirds of the measured sentences do this. A sentence that names what was built and stops is not finished. - One hard number at most, as the closer. Exactly one figure appears across all three measured entries, in the final sentence. Everything else is qualitative — which is what makes step 5's "don't drop the claim" achievable without inventing one.
- Front-load. The profile telescopes a long entry behind "see more", so the opening lines carry disproportionate weight.
- Plain text only. No headings, no bold, no links, no markdown.
Provenance differs across that list and matters when reporting to the user. The 2,000 cap is primary evidence. The shape, register and outcome rules are measured from real entries. The "see more" telescoping is convention from user-generated LinkedIn articles, dated 2026-09-10 — no official Help page was located — so present it as such rather than as verified platform behaviour.
7. Scrub before emitting
Nothing reaches the user's clipboard unscrubbed. This is the step that has no equivalent anywhere else in the toolchain.
Write the draft to a scratch file, then match it against the denylist without reading the list into the transcript.
Never point grep -f at the raw denylist. It carries # comments
and blank lines, and a blank pattern matches every line — so the raw
file reports the whole draft as a hit and the scrub silently becomes
useless. This was hit on 2026-09-10 while building this skill: a
15-line candidate list came back 15 for 15. Parse first, exactly as the
hook does:
LIST="${IDENTITY_DENYLIST:-$HOME/.config/identity-denylist.txt}"
TERMS=$(mktemp)
while IFS= read -r line || [ -n "$line" ]; do
line=${line//$'\r'/} # CRLF-tolerant
line="${line%"${line##*[![:space:]]}"}" # rtrim
[ -z "$line" ] && continue # blank
case "$line" in \#*) continue ;; esac # comment
case "${line,,}" in exempt:*) continue ;; esac # see below
printf '%s\n' "$line" >> "$TERMS"
done < "$LIST"
grep -inF -f "$TERMS" <draft-file>; rm -f "$TERMS"
grep -inF -f is the hook's own matcher: one literal per line,
case-insensitive fixed string, no regex. It prints the offending
draft lines and never the list. Reading the list directly is the
fallback when grep is unavailable, and it is strictly worse — the list
is the one thing on this machine that must not be reproduced anywhere.
Sanity-check the parse before trusting a clean result. A zero-hit
scrub and a broken terms file look identical. Report how many active
terms were loaded (wc -l < "$TERMS"); zero means the parse failed, not
that the draft is clean.
Deliberately ignore the list's exempt: lines. This is the most
important sentence in the skill. exempt: <path-prefix> makes
identity-guard skip a repo root entirely — because a client's own repo
legitimately carries the client's name. Exempt means "this name
belongs in this repo". It never means "this name belongs on the public
internet." Inheriting the hook's exempt semantics here would disable
the scrub in exactly the repos that need it most.
Then sweep heuristically for what a short literal list cannot know:
- Tenant, subscription and workspace GUIDs
- Internal hostnames, server names, connection strings
- Workspace and environment names (
*-dev,*-prod, internal codenames) - Account names —
AzureAD\…, Entra UPNs, service principal names - Internal URLs, including anything on a corporate domain
- Customer and partner names
- Hardcoded profile paths (
C:\Users\<name>)
A missing denylist fails open in the hook. It must not here. If the
file does not exist, say so explicitly in the output — "no denylist
found at <path>; scrubbed heuristically only" — and run the heuristic
sweep anyway. Never skip the gate silently.
The employer name itself is normally fine: it is the profile's own Organization field, public by construction. Internal identifiers are not. That asymmetry is the model to reason from — and when the denylist disagrees, the denylist wins.
A hit is an escalation, not an edit
On any hit, stop and put it to the user. Do not delete the term, and do not substitute a public equivalent you inferred. Show the draft sentence, say a denylisted term appears in it, and ask what should stand in its place.
This is the one point in the skill where the model must not decide. Many denylisted tokens have a legitimate public counterpart — an abbreviation whose expansion is the employer's public name, an internal codename for work whose underlying commercial product is public and nameable. The right rewrite is usually a substitution, not a deletion, and only the user knows which. Guessing produces two failures with no error path: a silent deletion drops real work out of the entry, and a wrong expansion publishes something inaccurate under the user's name.
Do not encode a substitution table anywhere. A token-to-expansion mapping would be a second sensitive list to maintain, and pairing each internal token with its public counterpart is more revealing than either half alone — the denylist exists in no repo for exactly that reason. Escalation needs no list.
This also resolves an otherwise real conflict. Step 6 requires paragraph
1 to open At <Organization>, I …, so a denylisted organization
abbreviation cannot simply be struck — that would delete a mandated
element. The denylist wins on the token; it says nothing about the
entity, and the user settles which form of the entity is publishable.
Reporting the scrub
Report the parse (N active terms loaded) and the outcome. Then:
- A hit that is in the draft — quote the draft line, so the user can adjudicate it. That is their own text, and they cannot resolve a hit they cannot see.
- A term you steered around pre-emptively — say nothing about which term it was. Never volunteer list membership for something that never reached the draft.
That second rule is the one a helpful-sounding summary breaks. Writing
"I kept <term-a> and <term-b> out of the draft" names two entries of
a list that must not be reproduced, and it does so gratuitously: nothing
was at stake, because neither term was in the output. Report avoided
terms as a count, or not at all.
8. Emit with a character count and a paste warning
Report the count as N/2,000 against the same character definition
LinkedIn's own counter uses.
wc -m is wrong on this machine. LANG and LC_ALL are empty in
both shells, so wc -m falls back to counting bytes and every • and
— over-counts by 2. Measured 2026-09-10: • abc is 5 characters, and both
wc -c and wc -m return 7. Use one of:
LC_ALL=C.UTF-8 wc -m < <draft-file> # returns 5
uv run python -c "import sys;print(len(open(sys.argv[1],encoding='utf-8').read()))" <draft-file>
Over-counting is the benign direction, but it makes you cut text that did not need cutting.
Close with the paste warning: paste as plain text. Pasting from Word, Notion or Google Docs into this field can destroy the line breaks, because the editor handles rich-text paste poorly. Convention, sourced 2026-09-10, same caveat as step 6.
Constraints
- One field, one role, one run. No resumes, CVs, cover letters or posts — those stop at Scope.
- No claim without evidence in the repo or an answer from the user. Tier 3 is never inferred. This is the constraint the whole procedure exists to enforce.
- No file counts, no line counts. Items and dates only.
- Never skip the scrub, and never inherit
exempt:semantics into it. - Never enumerate the denylist, in whole or in part, into the transcript or into any file — and never name a term you merely steered around. Quoting a draft line the scrub actually flagged is the one exception, because the user cannot adjudicate a hit they cannot see.
- A hit escalates to the user. Never resolve one by deleting the term or by inferring its public equivalent, and never encode a substitution table.
- Read-only. This skill reads a repo and writes a draft for the user to paste. It does not commit, does not edit the repo, and has no programmatic path to the profile — the output is text the user pastes by hand.