@forwardimpact/outpost 3.5.1 → 3.7.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.
@@ -0,0 +1,97 @@
1
+ ---
2
+ name: person-lookup
3
+ description: Look up ANY person in the corporate directory from free-text input (an email address, or a first / last / full name) and return their record — real name, title, department, company, email, employee ID, office, and manager. Searches the Global Catalog forest-wide via LDAP, authenticated with the existing Kerberos ticket. Use when the user asks "who is X", needs someone's title / department / manager / email, or wants to disambiguate a name. For the *current* user's own identity, use the sibling `person-identify` skill instead.
4
+ ---
5
+
6
+ # Person Lookup
7
+
8
+ Resolve **any** person in the corporate Active Directory from natural-language
9
+ input — an email, a login, or any part of a name. The sibling of
10
+ `person-identify`, but aimed at *other* people: it searches the **Global
11
+ Catalog** (forest-wide), handles multiple matches, flags external contacts and
12
+ vendor accounts, and — unlike `person-identify` — never writes the identity
13
+ cache. It's a read-only, throwaway lookup.
14
+
15
+ ## Trigger
16
+
17
+ - The user asks "who is <name>", or for someone's title, department, manager,
18
+ email, or employee ID.
19
+ - A name is ambiguous and needs disambiguating against the directory.
20
+ - Another skill needs to resolve a person who is **not** the current user.
21
+
22
+ For the current user's own record (and to populate the identity cache that other
23
+ skills read), use `person-identify` instead.
24
+
25
+ ## Prerequisites
26
+
27
+ - A valid **Kerberos ticket** (`klist` shows a principal). If absent:
28
+ `kinit <user>@<REALM>`.
29
+ - Network reachability to a domain controller (on-site or VPN).
30
+ - `ldapsearch` and `dig` — both ship with macOS; nothing to install.
31
+
32
+ Nothing is hardcoded: the realm and a domain controller are derived at runtime
33
+ from the ticket and DNS. The bind uses SASL/GSSAPI against the existing ticket —
34
+ no password is ever entered.
35
+
36
+ ## Usage
37
+
38
+ ```bash
39
+ bash .claude/skills/person-lookup/scripts/lookup.sh "Jane Doe"
40
+ bash .claude/skills/person-lookup/scripts/lookup.sh "jane.doe@example.com"
41
+ ```
42
+
43
+ The argument is free text: an email, a full name, or just a surname.
44
+
45
+ ## How it works
46
+
47
+ 1. **Derive a domain controller** from the Kerberos principal's realm via DNS
48
+ SRV (same bootstrap as `person-identify`).
49
+ 2. **Search the Global Catalog** (`ldap://$dc:3268`, base `""`). The GC spans
50
+ *every* domain in the forest, so a colleague in another region is found from
51
+ your own ticket — a plain domain-scoped search would miss them. It also
52
+ returns the common attributes (title, department, mail, manager), so one
53
+ query is enough.
54
+ 3. **Match with ANR** (Ambiguous Name Resolution): the `(anr=<input>)` filter
55
+ matches an email, a login, or any name part in one shot. If ANR finds
56
+ nothing, fall back to a substring search on `mail` / `displayName` /
57
+ `proxyAddresses`.
58
+ 4. **Resolve per match.** For a single hit, print the full record and resolve
59
+ the `manager` DN to a name. For several hits, print a compact
60
+ disambiguation list and suggest narrowing by email.
61
+
62
+ ## Output
63
+
64
+ | Field | Source attribute |
65
+ | --- | --- |
66
+ | Name | `displayName` (fallback `cn`) |
67
+ | Type | `objectClass` + OU — **Employee**, **Contact (external)**, or **User (external / vendor)** |
68
+ | Email | `mail` |
69
+ | Title | `title` |
70
+ | Department | `department` |
71
+ | Company | `company` |
72
+ | Employee ID | `employeeID` |
73
+ | Phone / Office | `telephoneNumber` / `physicalDeliveryOfficeName` |
74
+ | Manager | `manager` (DN → resolved to a name) |
75
+ | DN | distinguished name (region + OU, useful for disambiguation) |
76
+
77
+ ## Notes
78
+
79
+ - **Multiple matches are normal.** A common name, or a person who also has an
80
+ external **Contact** object (e.g. a vendor email alias), returns several
81
+ entries. The **Type** column distinguishes an internal **Employee** from an
82
+ external **Contact** (standard `objectClass=contact`). Some directories also
83
+ park external/vendor *user* accounts under a dedicated OU; set
84
+ `VENDOR_OU_PATTERN` at the top of `scripts/lookup.sh` to your directory's OU
85
+ substring (e.g. `OU=Contractors`) to flag those too. It is empty by default,
86
+ since the OU convention is organization-specific. Narrow with an email for an
87
+ exact hit.
88
+ - **Silent partial results.** Under load the directory occasionally returns an
89
+ entry's DN with no attributes (exit 0, no error). Every attribute fetch retries
90
+ with backoff, so a throttled response never masquerades as a person with a
91
+ blank title or email.
92
+ - **No cache.** This skill prints and exits. It never touches
93
+ `~/.cache/fit/outpost/state/identity.md` — that file is owned solely by
94
+ `person-identify`.
95
+ - **Ethics.** This reads objective, work-relevant directory data only, in line
96
+ with the knowledge base's integrity rules. It is not a tool for building
97
+ dossiers.
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env bash
2
+ # person-lookup: resolve ANY person in the corporate directory from free-text
3
+ # input (an email address, or a first / last / full name) and print their
4
+ # directory record(s).
5
+ #
6
+ # Sibling to `person-identify`, but for *other* people, not the current user:
7
+ # - Searches the Global Catalog (port 3268, base "") so it reaches EVERY
8
+ # domain in the forest from a single ticket — colleagues in other regions
9
+ # are found, not just your own domain.
10
+ # - Uses Active Directory ANR (Ambiguous Name Resolution), so one filter
11
+ # matches an email, a login, or any part of a name.
12
+ # - Handles 0, 1, or many matches, and flags external Contacts / vendor
13
+ # accounts so an internal employee is never confused with an outside one.
14
+ # - NEVER writes the identity cache — this is a throwaway lookup.
15
+ #
16
+ # Auth uses SASL/GSSAPI against the existing Kerberos ticket — no password.
17
+ #
18
+ # Robustness note: under load the directory intermittently returns an entry's
19
+ # DN with NO attributes (exit 0, no error). Every attribute fetch below retries
20
+ # with backoff so a throttled response never looks like "this person has no
21
+ # title / email".
22
+ set -u
23
+
24
+ # Org-specific: substring of the DN's OU that marks external/vendor *user*
25
+ # accounts (external Contact objects are detected generically via objectClass).
26
+ # Empty by default — set it to your directory's convention, e.g. "OU=Contractors"
27
+ # or "OU=External", to label those accounts as "User (external / vendor)".
28
+ VENDOR_OU_PATTERN=""
29
+
30
+ QUERY="$*"
31
+ if [ -z "$QUERY" ]; then
32
+ echo "Usage: lookup.sh <email | name> e.g. lookup.sh \"Jane Doe\"" >&2
33
+ exit 2
34
+ fi
35
+
36
+ # 1. Derive the realm and a reachable domain controller from the Kerberos ticket.
37
+ princ=$(klist 2>/dev/null | sed -n 's/.*[Pp]rincipal: *//p' | head -1)
38
+ if [ -z "$princ" ]; then
39
+ echo "No Kerberos ticket found. Get one first, e.g.: kinit <user>@<REALM>" >&2
40
+ exit 1
41
+ fi
42
+ realm=${princ#*@}
43
+ dom=$(printf '%s' "$realm" | tr '[:upper:]' '[:lower:]')
44
+ dc=$(dig +short SRV "_ldap._tcp.dc._msdcs.$dom" | awk 'NR==1{print $4}' | sed 's/\.$//')
45
+ [ -z "$dc" ] && dc=$(dig +short SRV "_ldap._tcp.$dom" | awk 'NR==1{print $4}' | sed 's/\.$//')
46
+ if [ -z "$dc" ]; then
47
+ echo "Could not find a domain controller for $dom via DNS SRV." >&2
48
+ exit 1
49
+ fi
50
+
51
+ # Global Catalog: forest-wide, base "" spans every domain.
52
+ GC="ldap://$dc:3268"
53
+ ATTRS="displayName givenName sn company title department employeeID mail telephoneNumber physicalDeliveryOfficeName objectClass manager"
54
+ MAX_SHOW=12 # cap detailed output for very broad name matches
55
+
56
+ # Pull one attribute out of an LDIF record (passed as $2), decoding base64 (::).
57
+ field() { # $1=attr $2=record
58
+ local attr="$1" line
59
+ line=$(printf '%s\n' "$2" | grep -m1 -E "^$attr:: ?|^$attr: ") || return 0
60
+ case "$line" in
61
+ "$attr:: "*) printf '%s' "${line#"$attr":: }" | base64 -D 2>/dev/null ;;
62
+ "$attr: "*) printf '%s' "${line#"$attr": }" ;;
63
+ esac
64
+ }
65
+
66
+ # List matching DNs for a filter (no attributes requested → reliable under load).
67
+ dns_for() { # $1=filter
68
+ ldapsearch -Y GSSAPI -LLL -o ldif-wrap=no -H "$GC" -b "" "$1" 1.1 2>/dev/null \
69
+ | sed -n 's/^dn: //p'
70
+ }
71
+
72
+ # Fetch one entry's attributes by DN, retrying past the silent DN-only response.
73
+ fetch() { # $1=dn
74
+ local dn="$1" out tries=0
75
+ while [ "$tries" -lt 4 ]; do
76
+ out=$(ldapsearch -Y GSSAPI -LLL -o ldif-wrap=no -H "$GC" -b "$dn" -s base \
77
+ "(objectClass=*)" $ATTRS 2>/dev/null | grep -vE '^# ')
78
+ printf '%s\n' "$out" | grep -qE '^displayName:' && break
79
+ tries=$((tries + 1)); sleep "$tries"
80
+ done
81
+ printf '%s\n' "$out"
82
+ }
83
+
84
+ # Resolve a manager (or any) DN to a display name.
85
+ name_of_dn() { # $1=dn
86
+ [ -z "$1" ] && return 0
87
+ field displayName "$(fetch "$1")"
88
+ }
89
+
90
+ # Classify an entry: internal employee, external contact, or vendor account.
91
+ kind_of() { # $1=record $2=dn
92
+ local oc; oc=$(printf '%s\n' "$1" | grep -i '^objectClass:' | tr 'A-Z' 'a-z')
93
+ if printf '%s' "$oc" | grep -q 'contact'; then
94
+ echo "Contact (external)"
95
+ elif [ -n "$VENDOR_OU_PATTERN" ] && printf '%s' "$2" | grep -qiE ",$VENDOR_OU_PATTERN"; then
96
+ echo "User (external / vendor)"
97
+ else
98
+ echo "Employee"
99
+ fi
100
+ }
101
+
102
+ # 2. Primary search (ANR), then a substring fallback if it finds nothing.
103
+ # (Portable array fill — macOS ships bash 3.2, which has no `mapfile`.)
104
+ read_dns() { # $1=filter -> populates global DNS array
105
+ DNS=()
106
+ local line
107
+ while IFS= read -r line; do
108
+ [ -n "$line" ] && DNS+=("$line")
109
+ done < <(dns_for "$1")
110
+ }
111
+ read_dns "(anr=$QUERY)"
112
+ if [ "${#DNS[@]}" -eq 0 ]; then
113
+ read_dns "(|(mail=*$QUERY*)(displayName=*$QUERY*)(proxyAddresses=*$QUERY*))"
114
+ fi
115
+
116
+ if [ "${#DNS[@]}" -eq 0 ]; then
117
+ echo "No directory match for: $QUERY"
118
+ exit 0
119
+ fi
120
+
121
+ # 3a. Exactly one match → full record, with the manager resolved to a name.
122
+ if [ "${#DNS[@]}" -eq 1 ]; then
123
+ rec=$(fetch "${DNS[0]}")
124
+ mgr=$(name_of_dn "$(field manager "$rec")")
125
+ echo "# $(field displayName "$rec")"
126
+ echo
127
+ echo "- **Type:** $(kind_of "$rec" "${DNS[0]}")"
128
+ echo "- **Email:** $(field mail "$rec")"
129
+ echo "- **Title:** $(field title "$rec")"
130
+ echo "- **Department:** $(field department "$rec")"
131
+ echo "- **Company:** $(field company "$rec")"
132
+ echo "- **Employee ID:** $(field employeeID "$rec")"
133
+ echo "- **Phone:** $(field telephoneNumber "$rec")"
134
+ echo "- **Office:** $(field physicalDeliveryOfficeName "$rec")"
135
+ [ -n "$mgr" ] && echo "- **Manager:** $mgr"
136
+ echo "- **DN:** ${DNS[0]}"
137
+ exit 0
138
+ fi
139
+
140
+ # 3b. Several matches → a compact disambiguation list (no per-row manager call).
141
+ echo "${#DNS[@]} matches for \"$QUERY\" — narrow with an email for an exact hit:"
142
+ echo
143
+ shown=0
144
+ for dn in "${DNS[@]}"; do
145
+ if [ "$shown" -ge "$MAX_SHOW" ]; then
146
+ echo
147
+ echo "_… and $(( ${#DNS[@]} - MAX_SHOW )) more. Refine the query._"
148
+ break
149
+ fi
150
+ rec=$(fetch "$dn")
151
+ name=$(field displayName "$rec"); [ -z "$name" ] && name=$(field cn "$rec")
152
+ printf -- '- **%s** — %s\n' "$name" "$(kind_of "$rec" "$dn")"
153
+ printf -- ' - %s · %s · %s\n' \
154
+ "$(field mail "$rec")" "$(field title "$rec")" "$(field department "$rec")"
155
+ printf -- ' - %s\n' "$dn"
156
+ shown=$((shown + 1))
157
+ done
@@ -20,7 +20,7 @@ pipeline from scattered email threads.
20
20
 
21
21
  - Synced email data in `~/.cache/fit/outpost/apple_mail/` (from
22
22
  `sync-apple-mail`).
23
- - User identity — run the `identify-user` skill to populate
23
+ - User identity — run the `person-identify` skill to populate
24
24
  `~/.cache/fit/outpost/state/identity.md`.
25
25
 
26
26
  ## Inputs
@@ -33,7 +33,7 @@ pipeline from scattered email threads.
33
33
  - `~/.cache/fit/outpost/state/graph_processed` — processed-file index (shared
34
34
  with `extract-entities`).
35
35
  - `~/.cache/fit/outpost/state/identity.md` — user identity for self-exclusion
36
- (written by the `identify-user` skill).
36
+ (written by the `person-identify` skill).
37
37
 
38
38
  ## Outputs
39
39
 
@@ -71,7 +71,7 @@ Process **10 files per run**.
71
71
  ### 1. Load context and pick the batch
72
72
 
73
73
  Read the user's name, email, and domain from
74
- `~/.cache/fit/outpost/state/identity.md` (run the `identify-user` skill first if
74
+ `~/.cache/fit/outpost/state/identity.md` (run the `person-identify` skill first if
75
75
  it is missing or stale). List new or changed source files:
76
76
 
77
77
  ```bash
@@ -27,7 +27,7 @@ integrate with the `req-track` pipeline format.
27
27
  - A Workday requisition export accessible on the filesystem.
28
28
  - `read-excel-file` package installed:
29
29
  `bun pm ls read-excel-file 2>/dev/null || bun install read-excel-file`.
30
- - User identity — run the `identify-user` skill to populate
30
+ - User identity — run the `person-identify` skill to populate
31
31
  `~/.cache/fit/outpost/state/identity.md`.
32
32
 
33
33
  ## Inputs
@@ -66,7 +66,7 @@ Process **10 candidates per run**.
66
66
  ### 1. Set up
67
67
 
68
68
  Read the user's identity from `~/.cache/fit/outpost/state/identity.md` (run the
69
- `identify-user` skill first if it is missing or stale). Confirm the XLSX path.
69
+ `person-identify` skill first if it is missing or stale). Confirm the XLSX path.
70
70
  Ensure the parser dependency is installed:
71
71
 
72
72
  ```bash
@@ -24,15 +24,15 @@ monorepo. "Instructions" means all three surfaces, treated equally:
24
24
 
25
25
  - A working Outpost installation with `CLAUDE.md`, `.claude/agents/`, and
26
26
  `.claude/skills/`.
27
- - Git available for change detection.
28
27
 
29
28
  ## Inputs
30
29
 
31
30
  - `CLAUDE.md` — root installation instructions.
32
31
  - `.claude/agents/*.md` — agent profiles.
33
32
  - `.claude/skills/*/SKILL.md` and reference files — skills.
34
- - `CHANGELOG.md` (root) — the existing changelog, for the last documented date.
35
- - Git history and working treechange detection.
33
+ - `CHANGELOG.md` (root) — the existing changelog, for what's already recorded.
34
+ - The changes made in the current working sessionthe source of truth for what
35
+ changed, since the KB lives on a synced filesystem and is not version-controlled.
36
36
 
37
37
  ## Outputs
38
38
 
@@ -49,8 +49,7 @@ monorepo. "Instructions" means all three surfaces, treated equally:
49
49
  "updated CLAUDE.md" / "fixed stuff").
50
50
  - [ ] New skills/agents include a brief description of their purpose; removed
51
51
  ones explain why.
52
- - [ ] Dates come from git history (or the date the change was made for
53
- uncommitted edits), not guessed.
52
+ - [ ] Dates are the date the change was made, not guessed.
54
53
  - [ ] No duplicate entries for the same change.
55
54
 
56
55
  </do_confirm_checklist>
@@ -65,18 +64,15 @@ head -20 CHANGELOG.md 2>/dev/null # newest date already recorded, if any
65
64
 
66
65
  ### 2. Identify changed instructions
67
66
 
68
- Detect changes across all three surfaces committed and uncommitted:
67
+ Knowledge bases live on a synced filesystem, not in Git, so there is no commit
68
+ history to diff. Identify what changed from the work just done **this session**:
69
+ recall every edit, addition, removal, and rename made to `CLAUDE.md`,
70
+ `.claude/agents/`, and `.claude/skills/` during the current conversation, and
71
+ list them per surface.
69
72
 
70
- ```bash
71
- # Committed changes since the last documented date (or all history)
72
- git log --after="<last-entry-date>" --name-status -- \
73
- CLAUDE.md '.claude/agents/' '.claude/skills/'
74
-
75
- # Uncommitted working-tree changes (common in a live installation)
76
- git status --short -- CLAUDE.md '.claude/agents/' '.claude/skills/'
77
- ```
78
-
79
- If no `CHANGELOG.md` exists yet, consider all changes since the initial commit.
73
+ Use `CHANGELOG.md` only to see what's already recorded so you don't duplicate an
74
+ existing entry. If something was clearly changed but you can't reconstruct what
75
+ or why from the session, flag it as needing review rather than guessing.
80
76
 
81
77
  ### 3. Classify each change
82
78
 
@@ -95,13 +91,8 @@ If no `CHANGELOG.md` exists yet, consider all changes since the initial commit.
95
91
 
96
92
  A single change often spans surfaces (e.g. a KB-structure change touching
97
93
  `CLAUDE.md`, several agents, and several skills) — record it as **one entry**
98
- whose Scope lists every surface touched. Diff to confirm the actual change:
99
-
100
- ```bash
101
- git diff <commit> -- CLAUDE.md
102
- git diff <commit> -- '.claude/agents/<agent>.md'
103
- git diff <commit> -- '.claude/skills/<skill>/'
104
- ```
94
+ whose Scope lists every surface touched. Re-read the affected files to confirm
95
+ the change landed as intended before describing it.
105
96
 
106
97
  ### 4. Describe each change
107
98
 
@@ -113,7 +113,7 @@ knowledge-graph maintenance, recruitment, and communication.
113
113
 
114
114
  The current user's identity is cached at
115
115
  `~/.cache/fit/outpost/state/identity.md` — read it directly. If missing or stale,
116
- run the `identify-user` skill to refresh it from the corporate directory.
116
+ run the `person-identify` skill to refresh it from the corporate directory.
117
117
 
118
118
  ## Working Outside This Directory
119
119