@forwardimpact/outpost 3.12.0 → 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 (129) hide show
  1. package/README.md +2 -2
  2. package/bin/fit-outpost.js +3 -2
  3. package/package.json +6 -5
  4. package/src/agent-path.js +10 -6
  5. package/src/agent-runner.js +28 -24
  6. package/src/index.js +2 -3
  7. package/src/kb-manager.js +75 -20
  8. package/src/kb-validator.js +762 -0
  9. package/src/outpost.js +108 -39
  10. package/src/posture.js +3 -3
  11. package/src/privilege.js +11 -11
  12. package/src/scheduler.js +14 -12
  13. package/src/socket-server.js +19 -18
  14. package/src/spawn-env.js +7 -6
  15. package/src/state-manager.js +2 -2
  16. package/templates/.claude/agents/chief-of-staff.md +32 -24
  17. package/templates/.claude/agents/concierge.md +26 -17
  18. package/templates/.claude/agents/head-hunter.md +34 -26
  19. package/templates/.claude/agents/librarian.md +26 -18
  20. package/templates/.claude/agents/postman.md +26 -18
  21. package/templates/.claude/agents/recruiter.md +38 -27
  22. package/templates/.claude/skills/anarlog-follow/SKILL.md +91 -66
  23. package/templates/.claude/skills/anarlog-follow/references/coaching.md +22 -21
  24. package/templates/.claude/skills/anarlog-follow/references/sessions.md +52 -0
  25. package/templates/.claude/skills/anarlog-process/SKILL.md +108 -62
  26. package/templates/.claude/skills/anarlog-process/references/extraction.md +41 -29
  27. package/templates/.claude/skills/anarlog-process/references/sessions.md +92 -58
  28. package/templates/.claude/skills/anarlog-process/scripts/scan.mjs +402 -160
  29. package/templates/.claude/skills/candidate-report/SKILL.md +41 -36
  30. package/templates/.claude/skills/candidate-report/references/report-template.html +10 -10
  31. package/templates/.claude/skills/candidate-report/references/report.css +4 -4
  32. package/templates/.claude/skills/candidate-report/references/rubric.md +15 -14
  33. package/templates/.claude/skills/candidate-report/scripts/render-pdf.mjs +2 -2
  34. package/templates/.claude/skills/changelog/SKILL.md +122 -67
  35. package/templates/.claude/skills/deck-create/SKILL.md +90 -32
  36. package/templates/.claude/skills/deck-create/references/slide.css +4 -4
  37. package/templates/.claude/skills/deck-create/scripts/convert-to-pdf.mjs +6 -6
  38. package/templates/.claude/skills/deck-review/SKILL.md +63 -58
  39. package/templates/.claude/skills/deck-review/assets/slide-annotator.js +58 -49
  40. package/templates/.claude/skills/deck-summarize/SKILL.md +55 -45
  41. package/templates/.claude/skills/deck-summarize/references/brief-template.md +5 -4
  42. package/templates/.claude/skills/deck-summarize/scripts/extract-pptx.mjs +8 -7
  43. package/templates/.claude/skills/doc-collab/SKILL.md +39 -34
  44. package/templates/.claude/skills/doc-create/SKILL.md +27 -23
  45. package/templates/.claude/skills/doc-create/scripts/convert-to-pdf.mjs +6 -6
  46. package/templates/.claude/skills/draft-emails/SKILL.md +63 -53
  47. package/templates/.claude/skills/draft-emails/references/template.md +2 -2
  48. package/templates/.claude/skills/draft-emails/scripts/scan-emails.mjs +15 -8
  49. package/templates/.claude/skills/draft-emails/scripts/send-email.mjs +19 -11
  50. package/templates/.claude/skills/extract-entities/SKILL.md +76 -75
  51. package/templates/.claude/skills/extract-entities/references/TEMPLATES.md +6 -5
  52. package/templates/.claude/skills/extract-entities/references/conditions.md +20 -18
  53. package/templates/.claude/skills/extract-entities/references/content.md +10 -9
  54. package/templates/.claude/skills/extract-entities/references/links.md +37 -13
  55. package/templates/.claude/skills/extract-entities/references/recruitment.md +30 -24
  56. package/templates/.claude/skills/extract-entities/references/resolution.md +14 -14
  57. package/templates/.claude/skills/extract-entities/references/sources.md +9 -9
  58. package/templates/.claude/skills/extract-entities/references/templates-conditions.md +12 -6
  59. package/templates/.claude/skills/extract-entities/references/templates-people-orgs.md +29 -10
  60. package/templates/.claude/skills/extract-entities/references/templates-priorities.md +10 -4
  61. package/templates/.claude/skills/extract-entities/references/templates-projects-topics.md +19 -7
  62. package/templates/.claude/skills/extract-entities/scripts/state.mjs +9 -8
  63. package/templates/.claude/skills/meeting-prep/SKILL.md +42 -36
  64. package/templates/.claude/skills/organize-files/SKILL.md +27 -22
  65. package/templates/.claude/skills/organize-files/scripts/organize-by-type.mjs +5 -4
  66. package/templates/.claude/skills/organize-files/scripts/summarize.mjs +4 -4
  67. package/templates/.claude/skills/person-identify/SKILL.md +77 -27
  68. package/templates/.claude/skills/person-identify/scripts/identify.sh +125 -26
  69. package/templates/.claude/skills/person-lookup/SKILL.md +34 -30
  70. package/templates/.claude/skills/person-lookup/scripts/lookup.sh +46 -17
  71. package/templates/.claude/skills/req-assess/SKILL.md +44 -36
  72. package/templates/.claude/skills/req-assess/references/interview-template.md +7 -2
  73. package/templates/.claude/skills/req-assess/references/panel-template.md +7 -2
  74. package/templates/.claude/skills/req-assess/references/rubric.md +13 -13
  75. package/templates/.claude/skills/req-bundle/SKILL.md +164 -0
  76. package/templates/.claude/skills/req-bundle/references/matching.md +70 -0
  77. package/templates/.claude/skills/req-bundle/references/pdf-structure.md +74 -0
  78. package/templates/.claude/skills/req-bundle/scripts/split-bundle.mjs +377 -0
  79. package/templates/.claude/skills/req-decide/SKILL.md +52 -41
  80. package/templates/.claude/skills/req-decide/references/rubric.md +11 -10
  81. package/templates/.claude/skills/req-decide/references/template.md +2 -2
  82. package/templates/.claude/skills/req-forget/SKILL.md +58 -39
  83. package/templates/.claude/skills/req-forget/references/classify.md +15 -13
  84. package/templates/.claude/skills/req-forget/references/locations.md +27 -22
  85. package/templates/.claude/skills/req-forget/references/report-template.md +24 -16
  86. package/templates/.claude/skills/req-scan/SKILL.md +27 -25
  87. package/templates/.claude/skills/req-scan/references/fallbacks.md +9 -9
  88. package/templates/.claude/skills/req-scan/references/filters.md +13 -13
  89. package/templates/.claude/skills/req-scan/references/sources.md +7 -7
  90. package/templates/.claude/skills/req-scan/references/state.md +1 -1
  91. package/templates/.claude/skills/req-scan/references/template.md +11 -1
  92. package/templates/.claude/skills/req-scan/scripts/state.mjs +3 -2
  93. package/templates/.claude/skills/req-screen/SKILL.md +96 -50
  94. package/templates/.claude/skills/req-screen/references/rubric.md +46 -50
  95. package/templates/.claude/skills/req-screen/references/scoring.md +79 -0
  96. package/templates/.claude/skills/req-screen/references/template.md +29 -17
  97. package/templates/.claude/skills/req-track/SKILL.md +76 -76
  98. package/templates/.claude/skills/req-track/references/fields.md +31 -27
  99. package/templates/.claude/skills/req-track/references/overlays.md +42 -0
  100. package/templates/.claude/skills/req-track/references/signals.md +12 -12
  101. package/templates/.claude/skills/req-track/references/statuses.md +3 -2
  102. package/templates/.claude/skills/req-track/references/templates.md +28 -27
  103. package/templates/.claude/skills/req-workday/SKILL.md +69 -40
  104. package/templates/.claude/skills/req-workday/references/brief.md +81 -0
  105. package/templates/.claude/skills/req-workday/references/status-mapping.md +5 -5
  106. package/templates/.claude/skills/req-workday/references/templates.md +11 -78
  107. package/templates/.claude/skills/req-workday/references/xlsx-format.md +26 -7
  108. package/templates/.claude/skills/req-workday/scripts/parse-workday.mjs +113 -35
  109. package/templates/.claude/skills/send-chat/SKILL.md +58 -54
  110. package/templates/.claude/skills/sync-apple-calendar/SKILL.md +23 -19
  111. package/templates/.claude/skills/sync-apple-calendar/references/SCHEMA.md +7 -7
  112. package/templates/.claude/skills/sync-apple-calendar/scripts/query.mjs +6 -6
  113. package/templates/.claude/skills/sync-apple-calendar/scripts/sync.mjs +9 -8
  114. package/templates/.claude/skills/sync-apple-mail/SKILL.md +20 -14
  115. package/templates/.claude/skills/sync-apple-mail/references/SCHEMA.md +10 -10
  116. package/templates/.claude/skills/sync-apple-mail/scripts/parse-emlx.mjs +9 -9
  117. package/templates/.claude/skills/sync-apple-mail/scripts/sync-helpers.mjs +16 -13
  118. package/templates/.claude/skills/sync-apple-mail/scripts/sync.mjs +3 -3
  119. package/templates/.claude/skills/sync-teams/SKILL.md +46 -43
  120. package/templates/.claude/skills/sync-teams/scripts/idb-reader.mjs +22 -21
  121. package/templates/.claude/skills/sync-teams/scripts/leveldb-reader.mjs +4 -4
  122. package/templates/.claude/skills/sync-teams/scripts/sync.mjs +4 -4
  123. package/templates/.claude/skills/upstream-instructions/SKILL.md +43 -41
  124. package/templates/.claude/skills/upstream-instructions/references/examples.md +5 -5
  125. package/templates/CLAUDE.md +97 -85
  126. package/templates/MIGRATION.md +359 -0
  127. package/templates/registry.yaml +25 -0
  128. package/templates/.claude/skills/anarlog-follow/scripts/follow.mjs +0 -243
  129. package/templates/.claude/skills/anarlog-trim/SKILL.md +0 -183
@@ -1,31 +1,35 @@
1
1
  ---
2
2
  name: person-identify
3
- description: Look up the current user's identity (real name, company, job title, department, email, employee ID, and manager) from the corporate directory via LDAP, authenticated with the existing Kerberos ticket. Use to establish who the knowledge base belongs to, when CLAUDE.md needs the user's identity, or when the user asks "who am I" / for their own directory record. To look up someone *other* than the current user, use the sibling `person-lookup` skill instead.
3
+ description: Look up the current user's identity (real name, company, job title, department, email, employee ID, manager, and direct reports) from the corporate directory through LDAP. The bind uses the existing Kerberos ticket. Captures the user's org edges — manager and direct reports — so the KB can resolve who "our team" is. Use to establish who the knowledge base belongs to, when CLAUDE.md needs the user's identity, or when the user asks "who am I" / for their own directory record. To look up someone *other* than the current user, use the sibling `person-lookup` skill instead.
4
4
  ---
5
5
 
6
6
  # Person Identify
7
7
 
8
+ Write tier: none (cache only)
9
+ Frontmatter: none
10
+
8
11
  Resolve the current user's identity from the corporate Active Directory over
9
12
  LDAP. This is the canonical way to establish **who the knowledge base belongs
10
- to** replacing any static identity file. Results reflect the live directory,
11
- so a job change or reorg is picked up automatically.
13
+ to**. It replaces any static identity file. Results reflect the live
14
+ directory, so the skill picks up a job change or a reorg automatically.
12
15
 
13
16
  ## Trigger
14
17
 
15
- - CLAUDE.md (or another skill) needs the current user's identity.
18
+ - CLAUDE.md (or another skill) needs the current user's identity, or needs to
19
+ resolve who "our team" is.
16
20
  - The user asks "who am I", for their own directory record, or for their
17
- employee ID, title, department, or manager.
21
+ employee ID, title, department, manager, or direct reports.
18
22
 
19
23
  ## Prerequisites
20
24
 
21
25
  - A valid **Kerberos ticket** for the user (`klist` shows a principal).
22
26
  If absent, get one with `kinit <user>@<REALM>`.
23
- - Network reachability to a domain controller (on-site or via VPN).
24
- - `ldapsearch` and `dig` — both ship with macOS; nothing to install.
27
+ - Network access to a domain controller (on-site or through VPN).
28
+ - `ldapsearch` and `dig` — both ship with macOS. You install nothing.
25
29
 
26
- Nothing is hardcoded: the username, realm, base DN, and domain controller are
27
- all derived at runtime from the ticket and DNS. No password is ever entered —
28
- the bind uses SASL/GSSAPI against the existing ticket.
30
+ Nothing is hardcoded. The script derives the username, realm, base DN, and
31
+ domain controller at runtime from the ticket and DNS. You never enter a
32
+ password. The bind uses SASL/GSSAPI against the existing ticket.
29
33
 
30
34
  ## Usage
31
35
 
@@ -33,27 +37,39 @@ the bind uses SASL/GSSAPI against the existing ticket.
33
37
  bash .claude/skills/person-identify/scripts/identify.sh
34
38
  ```
35
39
 
36
- This prints the user's directory record, resolves the manager to a name, and
37
- writes the result to the identity cache (below).
40
+ This prints the user's directory record, resolves the manager and each direct
41
+ report to a name, and writes the result to the identity cache (below).
38
42
 
39
43
  ## Identity cache
40
44
 
41
- The script writes `~/.cache/fit/outpost/state/identity.md` the **canonical
42
- identity source** for the rest of the knowledge base, replacing the old static
43
- `USER.md`. It is auto-generated markdown with `Name`, `Email`, and `Domain`
44
- fields (plus title, department, company, employee ID, office, and manager):
45
+ The script writes `~/.cache/fit/outpost/state/identity.md`. That file is the
46
+ **canonical identity source** for the rest of the knowledge base. It replaces
47
+ the old static `USER.md`. It is auto-generated markdown with `Name`, `Email`,
48
+ and `Domain` fields (plus title, department, company, employee ID, office, and
49
+ the user's two org edges — `Manager` and `Direct reports`):
45
50
 
46
51
  ```markdown
47
52
  - **Name:** Jane Doe
48
53
  - **Email:** jane.doe@example.com
49
54
  - **Domain:** example.com
55
+ - **Manager:** Roe, Richard
56
+ - **Direct reports:**
57
+ - Chen, Sarah
58
+ - Okafor, Ada
50
59
  ```
51
60
 
61
+ The **Manager** and **Direct reports** edges let the KB resolve who "our team"
62
+ is — see the `## User Identity & Team` section of `CLAUDE.md` for the rule.
63
+ `Direct reports` is always written: a list when the user manages people, or
64
+ the explicit sentinel `- **Direct reports:** none` for an individual
65
+ contributor. Reports are name-sorted so re-runs of the synced cache diff
66
+ cleanly.
67
+
52
68
  Other skills (e.g. `extract-entities`, `anarlog-process`, `req-track`,
53
69
  `req-workday`, `candidate-report`, `sync-teams`) read this file for the user's
54
- name/email/domain for self-exclusion and author attribution. They run this
55
- skill first if the cache is missing or stale. Never hand-edit the cache; re-run
56
- the skill to refresh it.
70
+ name/email/domain. They use it for self-exclusion and author attribution. They
71
+ run this skill first if the cache is missing or stale. Never hand-edit the
72
+ cache. Run the skill again to refresh it.
57
73
 
58
74
  ## How it works
59
75
 
@@ -73,11 +89,29 @@ dc=$(dig +short SRV "_ldap._tcp.dc._msdcs.$dom" | awk 'NR==1{print $4}' | sed 's
73
89
 
74
90
  # 4. Look up the current user (GSSAPI = existing ticket, no password)
75
91
  ldapsearch -Y GSSAPI -LLL -o ldif-wrap=no -H "ldap://$dc" -b "$base" \
76
- "(sAMAccountName=$user)" displayName company title department employeeID mail manager
92
+ "(sAMAccountName=$user)" displayName company title department employeeID mail \
93
+ manager directReports
77
94
  ```
78
95
 
79
- The `manager` attribute is a DN that may live in another domain, so the script
80
- resolves it against the **Global Catalog** (port 3268), which is forest-wide.
96
+ Both org edges come back as DNs that may live in another domain. So the script
97
+ resolves them against the **Global Catalog** (port 3268, forest-wide) through
98
+ one shared `dn_name` helper. `manager` is single-valued. `directReports` is
99
+ the multi-valued back-link of the same edge, so the script pulls every value
100
+ (not just the first), resolves each to a name, and sorts the list. The
101
+ resolver decodes the base64 `displayName:: …` form (used for accented names)
102
+ and retries past the directory's occasional attribute-less response under
103
+ load.
104
+
105
+ **Inactive reports are filtered from the roster.** AD does not clear the
106
+ `manager` edge when an account is offboarded — it just moves the account to a
107
+ deprovisioned OU — so the user's `directReports` back-link keeps counting
108
+ ex-reports as phantom team members. Reports resolve through `dn_report`, which
109
+ drops a DN when either (1) it sits under the obsolete OU
110
+ (`INACTIVE_OU_PATTERN`, a config knob at the top of the script; `OU=Obsolete`
111
+ by default, empty to disable), or (2) the account is disabled in AD —
112
+ `userAccountControl` bit `0x2` (`ACCOUNTDISABLE`), fetched in the same GC
113
+ query as the name. The manager edge stays unfiltered: a disabled manager is a
114
+ signal worth surfacing, not hiding.
81
115
 
82
116
  ## Output
83
117
 
@@ -93,12 +127,28 @@ Key attributes returned (names per Active Directory schema):
93
127
  | `mail` | Email address |
94
128
  | `physicalDeliveryOfficeName`| Office / location |
95
129
  | `manager` | Manager (DN → resolved to name) |
130
+ | `directReports` | Direct reports (multi-valued DN back-link → each resolved to a name; offboarded/disabled accounts filtered out) |
131
+ | `userAccountControl` | Fetched per report to drop disabled accounts (bit `0x2`) |
96
132
 
97
133
  ## Notes
98
134
 
99
- - To look up **someone else**, use the sibling `person-lookup` skill it takes
100
- free-text input (email or name), searches the Global Catalog forest-wide
101
- (`ldap://$dc:3268 -b ''`), handles multiple matches, and does **not** touch
135
+ - To look up **someone else**, use the sibling `person-lookup` skill. It takes
136
+ free-text input (email or name). It searches the Global Catalog forest-wide
137
+ (`ldap://$dc:3268 -b ''`). It handles multiple matches. It does **not** touch
102
138
  the identity cache.
103
- - Not Active Directory? The same `ldapsearch -Y GSSAPI` shape works against any
104
- Kerberos-backed LDAP directory; only the attribute names differ.
139
+ - `directReports` is the back-link stored on the user's own home-domain
140
+ record, so a report whose account lives in *another* forest domain may be
141
+ omitted. For a single-domain manager (the common case) it is complete. If
142
+ cross-domain reports matter, swap it for a forest-wide reverse search over
143
+ the GC — `(manager=<userDN>)`, where the user's DN is the `dn:` line of the
144
+ fetched record — feeding the same `dn_report` resolver.
145
+ - **Offboarded reports linger in AD.** The `directReports` back-link keeps
146
+ counting a report after they leave, because offboarding moves the account to
147
+ a deprovisioned OU without clearing its `manager` edge. `dn_report` filters
148
+ these out (obsolete-OU DN, or a disabled `userAccountControl`). Tune
149
+ `INACTIVE_OU_PATTERN` at the top of `scripts/identify.sh` to your
150
+ directory's convention — it mirrors `person-lookup`'s `VENDOR_OU_PATTERN`
151
+ knob. The real fix is upstream (clear the manager edge at offboarding); the
152
+ filter shields the KB until then.
153
+ - Active Directory is not required. The same `ldapsearch -Y GSSAPI` shape works
154
+ against any Kerberos-backed LDAP directory. Only the attribute names differ.
@@ -1,18 +1,27 @@
1
1
  #!/usr/bin/env bash
2
- # Identify the current user from the corporate directory (Active Directory over LDAP)
3
- # and cache the result at ~/.cache/fit/outpost/state/identity.md for other skills.
2
+ # Identify the current user from the corporate directory (Active Directory over LDAP).
3
+ # Cache the result at ~/.cache/fit/outpost/state/identity.md for other skills.
4
4
  #
5
- # Generic by design: nothing is hardcoded. The user, realm, and base DN are all
6
- # derived from the existing Kerberos ticket, and the domain controller is found
7
- # via DNS SRV records. Works for any AD domain, on or off VPN, as long as a
8
- # Kerberos ticket exists and a DC is reachable.
5
+ # The script is generic. Nothing is hardcoded. It derives the user, the realm,
6
+ # and the base DN from the existing Kerberos ticket. It finds the domain
7
+ # controller with DNS SRV records. It works for any AD domain, on or off VPN.
8
+ # It needs a Kerberos ticket and a reachable DC.
9
9
  #
10
- # Auth uses SASL/GSSAPI against the existing ticket no password is ever entered.
10
+ # Auth uses SASL/GSSAPI against the existing ticket. You never enter a password.
11
11
  set -u
12
12
 
13
13
  CACHE="$HOME/.cache/fit/outpost/state/identity.md"
14
14
 
15
- # 1. Who are we? Take the principal from the Kerberos ticket: USER@REALM.EXAMPLE.COM
15
+ # Org-specific: substring of the DN's OU under which offboarded/deprovisioned
16
+ # accounts are parked. AD keeps the `manager` edge on such accounts when they
17
+ # are moved here at offboarding, so the user's `directReports` back-link still
18
+ # counts them — surfacing ex-reports as phantom team members. Reports whose DN
19
+ # matches this pattern are dropped. Set to your directory's convention (empty
20
+ # disables the OU filter; disabled-account filtering via userAccountControl
21
+ # still applies). `OU=Obsolete` is a common AD default.
22
+ INACTIVE_OU_PATTERN="OU=Obsolete"
23
+
24
+ # 1. Take the user principal from the Kerberos ticket: USER@REALM.EXAMPLE.COM
16
25
  princ=$(klist 2>/dev/null | sed -n 's/.*[Pp]rincipal: *//p' | head -1)
17
26
  if [ -z "$princ" ]; then
18
27
  echo "No Kerberos ticket found. Get one first, e.g.: kinit <user>@<REALM>" >&2
@@ -25,52 +34,132 @@ realm=${princ#*@}
25
34
  base=$(printf '%s' "$realm" | awk -F. '{for(i=1;i<=NF;i++) printf "%sDC=%s",(i>1?",":""),$i}')
26
35
  dom=$(printf '%s' "$realm" | tr '[:upper:]' '[:lower:]')
27
36
 
28
- # 3. Find a reachable domain controller via DNS SRV (msdcs first, then plain ldap)
37
+ # 3. Find a reachable domain controller with DNS SRV (msdcs first, then plain ldap)
29
38
  dc=$(dig +short SRV "_ldap._tcp.dc._msdcs.$dom" | awk 'NR==1{print $4}' | sed 's/\.$//')
30
39
  [ -z "$dc" ] && dc=$(dig +short SRV "_ldap._tcp.$dom" | awk 'NR==1{print $4}' | sed 's/\.$//')
31
40
  if [ -z "$dc" ]; then
32
- echo "Could not find a domain controller for $dom via DNS SRV." >&2
41
+ echo "Could not find a domain controller for $dom with DNS SRV." >&2
33
42
  exit 1
34
43
  fi
35
44
 
36
45
  # 4. Look up the current user's record (GSSAPI = use the existing ticket, no password)
37
- attrs="displayName givenName sn company title department employeeID mail telephoneNumber physicalDeliveryOfficeName manager"
46
+ attrs="displayName givenName sn company title department employeeID mail telephoneNumber physicalDeliveryOfficeName manager directReports"
38
47
  rec=$(ldapsearch -Y GSSAPI -LLL -o ldif-wrap=no -H "ldap://$dc" -b "$base" "(sAMAccountName=$user)" $attrs 2>/dev/null)
39
48
  if [ -z "$rec" ]; then
40
49
  echo "No directory record found for '$user' under $base on $dc." >&2
41
50
  exit 1
42
51
  fi
43
52
 
44
- # Pull a single attribute value out of the LDIF record.
45
- field() { printf '%s\n' "$rec" | sed -n "s/^$1: //p" | head -1; }
53
+ # Pull a single attribute value out of the LDIF record. Decode the base64
54
+ # `attr:: <b64>` form that LDIF uses for any non-ASCII value (accented names,
55
+ # and DNs whose CN is accented — e.g. the manager attribute).
56
+ field() { # $1=attr
57
+ local attr="$1" line
58
+ line=$(printf '%s\n' "$rec" | grep -m1 -E "^$attr:: ?|^$attr: ")
59
+ case "$line" in
60
+ "$attr:: "*) printf '%s' "${line#"$attr":: }" | base64 -D 2>/dev/null ;;
61
+ "$attr: "*) printf '%s' "${line#"$attr": }" ;;
62
+ esac
63
+ }
46
64
 
47
65
  disp=$(field displayName); gn=$(field givenName); sn=$(field sn)
48
66
  company=$(field company); title=$(field title); dept=$(field department)
49
67
  empid=$(field employeeID); mail=$(field mail)
50
68
  office=$(field physicalDeliveryOfficeName)
51
69
 
52
- # Real name from given+surname, falling back to displayName. Domain from the email.
70
+ # Real name from given+surname. Fall back to displayName. Domain from the email.
53
71
  name="$gn $sn"; name=$(printf '%s' "$name" | sed 's/^ *//;s/ *$//')
54
72
  [ -z "$name" ] && name="$disp"
55
73
  domain=$(printf '%s' "$mail" | sed 's/.*@//' | tr '[:upper:]' '[:lower:]')
56
74
 
57
- # 5. Resolve the manager DN to a name, if present. Manager may live in another
58
- # domain, so query the Global Catalog (port 3268), which is forest-wide.
59
- mgr=$(field manager)
60
- mgr_name=""
61
- if [ -n "$mgr" ]; then
62
- mgr_name=$(ldapsearch -Y GSSAPI -LLL -o ldif-wrap=no -H "ldap://$dc:3268" -b "$mgr" -s base \
63
- displayName 2>/dev/null | sed -n 's/^displayName: //p' | head -1)
64
- fi
75
+ # 5. Resolve the user's org edges the manager (up) and the direct reports
76
+ # (down) to names. Both are DNs that may live in another domain, so
77
+ # resolve against the Global Catalog (port 3268). It is forest-wide.
78
+ gc="ldap://$dc:3268"
79
+
80
+ # One shared DN -> displayName resolver. Decodes the base64 `displayName:: <b64>`
81
+ # form (accented names are encoded that way) and retries past the directory's
82
+ # occasional attribute-less response under load, mirroring person-lookup.
83
+ dn_name() { # $1=DN -> displayName ("" if unresolvable)
84
+ local dn="$1" line tries=0
85
+ [ -z "$dn" ] && return 0
86
+ while [ "$tries" -lt 4 ]; do
87
+ line=$(ldapsearch -Y GSSAPI -LLL -o ldif-wrap=no -H "$gc" -b "$dn" -s base \
88
+ displayName 2>/dev/null | grep -m1 -E '^displayName:: ?|^displayName: ')
89
+ case "$line" in
90
+ "displayName:: "*) printf '%s' "${line#displayName:: }" | base64 -D 2>/dev/null; return 0 ;;
91
+ "displayName: "*) printf '%s' "${line#displayName: }"; return 0 ;;
92
+ esac
93
+ tries=$((tries + 1)); sleep "$tries"
94
+ done
95
+ }
96
+
97
+ # Resolve a direct-report DN to a name, but ONLY if the account is still active.
98
+ # A report is inactive — and therefore dropped from the team roster — when either:
99
+ # 1. its DN sits under the obsolete/deprovisioned OU ($INACTIVE_OU_PATTERN), or
100
+ # 2. its account is disabled in AD (userAccountControl bit 0x2, ACCOUNTDISABLE).
101
+ # Offboarding leaves the `manager` edge dangling, so without this filter ex-reports
102
+ # linger in the roster forever. Fetches displayName + userAccountControl together,
103
+ # reusing dn_name's base64 decode and retry-under-load behaviour. Prints the name,
104
+ # or nothing to skip the report.
105
+ dn_report() { # $1=DN -> displayName if active, else ""
106
+ local dn="$1" out line uac tries=0
107
+ [ -z "$dn" ] && return 0
108
+ # (1) Obsolete-OU filter — case-insensitive substring match on the DN.
109
+ if [ -n "$INACTIVE_OU_PATTERN" ]; then
110
+ if printf '%s' "$dn" | grep -qiF "$INACTIVE_OU_PATTERN"; then return 0; fi
111
+ fi
112
+ while [ "$tries" -lt 4 ]; do
113
+ out=$(ldapsearch -Y GSSAPI -LLL -o ldif-wrap=no -H "$gc" -b "$dn" -s base \
114
+ displayName userAccountControl 2>/dev/null)
115
+ line=$(printf '%s\n' "$out" | grep -m1 -E '^displayName:: ?|^displayName: ')
116
+ if [ -n "$line" ]; then
117
+ # (2) Disabled-account filter. userAccountControl is a GC-replicated
118
+ # attribute; if absent (older DC / partial reply) we simply don't filter.
119
+ uac=$(printf '%s\n' "$out" | sed -n 's/^userAccountControl: *//p' | head -1)
120
+ case "$uac" in
121
+ ''|*[!0-9]*) : ;; # missing/non-numeric -> can't judge
122
+ *) [ $((uac & 2)) -ne 0 ] && return 0 ;; # ACCOUNTDISABLE set -> skip
123
+ esac
124
+ case "$line" in
125
+ "displayName:: "*) printf '%s' "${line#displayName:: }" | base64 -D 2>/dev/null; return 0 ;;
126
+ "displayName: "*) printf '%s' "${line#displayName: }"; return 0 ;;
127
+ esac
128
+ fi
129
+ tries=$((tries + 1)); sleep "$tries"
130
+ done
131
+ }
132
+
133
+ # Up-edge: the manager (single-valued DN on the user's own record).
134
+ mgr_name=$(dn_name "$(field manager)")
135
+
136
+ # Down-edges: direct reports — a multi-valued back-link on the user's OWN record.
137
+ # field() keeps only the first value, so pull EVERY value: emit one report DN per
138
+ # line (decoding the base64 `directReports:: <b64>` form), resolve each to a name
139
+ # via dn_report (which drops offboarded/disabled accounts), and sort for stable,
140
+ # diff-friendly cache output. Kept in a function because a `case` inside `$(...)`
141
+ # trips the bash 3.2 parser that macOS ships.
142
+ resolve_reports() {
143
+ printf '%s\n' "$rec" | while IFS= read -r line; do
144
+ case "$line" in
145
+ "directReports:: "*) printf '%s' "${line#directReports:: }" | base64 -D 2>/dev/null; echo ;;
146
+ "directReports: "*) printf '%s\n' "${line#directReports: }" ;;
147
+ esac
148
+ done | while IFS= read -r dn; do
149
+ [ -n "$dn" ] || continue
150
+ n=$(dn_report "$dn"); [ -n "$n" ] && printf '%s\n' "$n"
151
+ done | sort
152
+ }
153
+ reports=$(resolve_reports)
65
154
 
66
- # 6. Write the cache. Same Name/Email/Domain shape other skills already parse,
67
- # plus extra fields. Auto-generated never hand-edited.
155
+ # 6. Write the cache. It keeps the Name/Email/Domain shape other skills already
156
+ # parse, plus extra fields. The script generates it. Never edit it by hand.
68
157
  mkdir -p "$(dirname "$CACHE")"
69
158
  {
70
159
  echo "# User Identity"
71
160
  echo
72
- echo "Auto-generated by the \`person-identify\` skill from the corporate directory."
73
- echo "Do not edit by hand re-run the skill to refresh."
161
+ echo "The \`person-identify\` skill generates this file from the corporate directory."
162
+ echo "Do not edit it by hand. Run the skill again to refresh it."
74
163
  echo
75
164
  echo "- **Name:** $name"
76
165
  echo "- **Email:** $mail"
@@ -81,6 +170,16 @@ mkdir -p "$(dirname "$CACHE")"
81
170
  echo "- **Employee ID:** $empid"
82
171
  echo "- **Office:** $office"
83
172
  [ -n "$mgr_name" ] && echo "- **Manager:** $mgr_name"
173
+ # Always emit Direct reports so the "our team" rule in CLAUDE.md can branch
174
+ # explicitly: a list => the user manages people (the team is in this cache);
175
+ # `none` => an individual contributor (peers resolved on demand from the
176
+ # manager). An omitted line would be indistinguishable from "not yet resolved".
177
+ if [ -n "$reports" ]; then
178
+ echo "- **Direct reports:**"
179
+ printf '%s\n' "$reports" | while IFS= read -r r; do echo " - $r"; done
180
+ else
181
+ echo "- **Direct reports:** none"
182
+ fi
84
183
  } > "$CACHE"
85
184
 
86
185
  # 7. Show the result.
@@ -1,37 +1,40 @@
1
1
  ---
2
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.
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, manager, and direct reports. Searches the Global Catalog forest-wide through LDAP. The bind uses the existing Kerberos ticket. Use when the user asks "who is X", needs someone's title / department / manager / reports / email, wants to disambiguate a name, or needs to resolve a team around a person (a person's peers = their manager's other reports). For the *current* user's own identity, use the sibling `person-identify` skill instead.
4
4
  ---
5
5
 
6
6
  # Person Lookup
7
7
 
8
+ Write tier: none (cache only)
9
+ Frontmatter: none
10
+
8
11
  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.
12
+ input. The input is an email, a login, or any part of a name. This skill is the
13
+ sibling of `person-identify`, and it aims at *other* people. It searches the
14
+ **Global Catalog** (forest-wide). It handles multiple matches. It flags
15
+ external contacts and vendor accounts. Unlike `person-identify`, it never
16
+ writes the identity cache. It is a read-only, throwaway lookup.
14
17
 
15
18
  ## Trigger
16
19
 
17
20
  - The user asks "who is <name>", or for someone's title, department, manager,
18
21
  email, or employee ID.
19
- - A name is ambiguous and needs disambiguating against the directory.
22
+ - A name is ambiguous, and you must disambiguate it against the directory.
20
23
  - Another skill needs to resolve a person who is **not** the current user.
21
24
 
22
- For the current user's own record (and to populate the identity cache that other
23
- skills read), use `person-identify` instead.
25
+ For the current user's own record, use `person-identify` instead. It also
26
+ populates the identity cache that other skills read.
24
27
 
25
28
  ## Prerequisites
26
29
 
27
- - A valid **Kerberos ticket** (`klist` shows a principal). If absent:
30
+ - A valid **Kerberos ticket** (`klist` shows a principal). If absent, run
28
31
  `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.
32
+ - Network access to a domain controller (on-site or VPN).
33
+ - `ldapsearch` and `dig` — both ship with macOS. You install nothing.
31
34
 
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
+ Nothing is hardcoded. The script derives the realm and a domain controller at
36
+ runtime from the ticket and DNS. The bind uses SASL/GSSAPI against the existing
37
+ ticket. You never enter a password.
35
38
 
36
39
  ## Usage
37
40
 
@@ -44,20 +47,21 @@ The argument is free text: an email, a full name, or just a surname.
44
47
 
45
48
  ## How it works
46
49
 
47
- 1. **Derive a domain controller** from the Kerberos principal's realm via DNS
48
- SRV (same bootstrap as `person-identify`).
50
+ 1. **Derive a domain controller** from the Kerberos principal's realm through
51
+ DNS SRV (same bootstrap as `person-identify`).
49
52
  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
53
+ *every* domain in the forest. So you find a colleague in another region from
54
+ your own ticket. A plain domain-scoped search would miss them. It also
52
55
  returns the common attributes (title, department, mail, manager), so one
53
56
  query is enough.
54
- 3. **Match with ANR** (Ambiguous Name Resolution): the `(anr=<input>)` filter
57
+ 3. **Match with ANR** (Ambiguous Name Resolution). The `(anr=<input>)` filter
55
58
  matches an email, a login, or any name part in one shot. If ANR finds
56
59
  nothing, fall back to a substring search on `mail` / `displayName` /
57
60
  `proxyAddresses`.
58
61
  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.
62
+ both org edges — the `manager` DN and each `directReports` DN to names.
63
+ For several hits, print a compact disambiguation list and suggest a
64
+ narrower search by email (org edges resolve only for an exact, single hit).
61
65
 
62
66
  ## Output
63
67
 
@@ -72,6 +76,7 @@ The argument is free text: an email, a full name, or just a surname.
72
76
  | Employee ID | `employeeID` |
73
77
  | Phone / Office | `telephoneNumber` / `physicalDeliveryOfficeName` |
74
78
  | Manager | `manager` (DN → resolved to a name) |
79
+ | Direct reports | `directReports` (multi-valued DN back-link → each resolved to a name; single hit only, shown when non-empty) |
75
80
  | DN | distinguished name (region + OU, useful for disambiguation) |
76
81
 
77
82
  ## Notes
@@ -80,18 +85,17 @@ The argument is free text: an email, a full name, or just a surname.
80
85
  external **Contact** object (e.g. a vendor email alias), returns several
81
86
  entries. The **Type** column distinguishes an internal **Employee** from an
82
87
  external **Contact** (standard `objectClass=contact`). Some directories also
83
- park external/vendor *user* accounts under a dedicated OU; set
88
+ park external/vendor *user* accounts under a dedicated OU. Set
84
89
  `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.
90
+ substring (e.g. `OU=Contractors`). This flags those accounts too. It is empty
91
+ by default, since the OU convention is organization-specific. Narrow with an
92
+ email for an exact hit.
88
93
  - **Silent partial results.** Under load the directory occasionally returns an
89
94
  entry's DN with no attributes (exit 0, no error). Every attribute fetch
90
95
  retries with backoff, so a throttled response never masquerades as a person
91
96
  with a blank title or email.
92
97
  - **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`.
98
+ `~/.cache/fit/outpost/state/identity.md`. `person-identify` owns that file
99
+ alone.
95
100
  - **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.
101
+ with the knowledge base's integrity rules. Never use it to build dossiers.
@@ -3,17 +3,19 @@
3
3
  # input (an email address, or a first / last / full name) and print their
4
4
  # directory record(s).
5
5
  #
6
- # Sibling to `person-identify`, but for *other* people, not the current user:
6
+ # Sibling to `person-identify`. Use it for *other* people. Do not use it for
7
+ # the current user:
7
8
  # - 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.
9
+ # domain in the forest from a single ticket. It finds colleagues in other
10
+ # regions. It does not stop at your own domain.
10
11
  # - Uses Active Directory ANR (Ambiguous Name Resolution), so one filter
11
12
  # 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.
13
+ # - Handles 0, 1, or many matches. Flags external Contacts / vendor
14
+ # accounts so you never confuse an internal employee with an outside one.
15
+ # - NEVER writes the identity cache. This is a throwaway lookup.
15
16
  #
16
- # Auth uses SASL/GSSAPI against the existing Kerberos ticket no password.
17
+ # Auth uses SASL/GSSAPI against the existing Kerberos ticket. It needs no
18
+ # password.
17
19
  #
18
20
  # Robustness note: under load the directory intermittently returns an entry's
19
21
  # DN with NO attributes (exit 0, no error). Every attribute fetch below retries
@@ -22,9 +24,10 @@
22
24
  set -u
23
25
 
24
26
  # 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)".
27
+ # accounts. The script detects external Contact objects generically through
28
+ # objectClass. Empty by default. Set it to your directory's convention, e.g.
29
+ # "OU=Contractors" or "OU=External", to label those accounts as
30
+ # "User (external / vendor)".
28
31
  VENDOR_OU_PATTERN=""
29
32
 
30
33
  QUERY="$*"
@@ -44,16 +47,16 @@ dom=$(printf '%s' "$realm" | tr '[:upper:]' '[:lower:]')
44
47
  dc=$(dig +short SRV "_ldap._tcp.dc._msdcs.$dom" | awk 'NR==1{print $4}' | sed 's/\.$//')
45
48
  [ -z "$dc" ] && dc=$(dig +short SRV "_ldap._tcp.$dom" | awk 'NR==1{print $4}' | sed 's/\.$//')
46
49
  if [ -z "$dc" ]; then
47
- echo "Could not find a domain controller for $dom via DNS SRV." >&2
50
+ echo "Could not find a domain controller for $dom with DNS SRV." >&2
48
51
  exit 1
49
52
  fi
50
53
 
51
54
  # Global Catalog: forest-wide, base "" spans every domain.
52
55
  GC="ldap://$dc:3268"
53
- ATTRS="displayName givenName sn company title department employeeID mail telephoneNumber physicalDeliveryOfficeName objectClass manager"
56
+ ATTRS="displayName givenName sn company title department employeeID mail telephoneNumber physicalDeliveryOfficeName objectClass manager directReports"
54
57
  MAX_SHOW=12 # cap detailed output for very broad name matches
55
58
 
56
- # Pull one attribute out of an LDIF record (passed as $2), decoding base64 (::).
59
+ # Pull one attribute out of an LDIF record (passed as $2). Decode base64 (::).
57
60
  field() { # $1=attr $2=record
58
61
  local attr="$1" line
59
62
  line=$(printf '%s\n' "$2" | grep -m1 -E "^$attr:: ?|^$attr: ") || return 0
@@ -63,13 +66,13 @@ field() { # $1=attr $2=record
63
66
  esac
64
67
  }
65
68
 
66
- # List matching DNs for a filter (no attributes requested → reliable under load).
69
+ # List the DNs that match a filter. Request no attributes → reliable under load.
67
70
  dns_for() { # $1=filter
68
71
  ldapsearch -Y GSSAPI -LLL -o ldif-wrap=no -H "$GC" -b "" "$1" 1.1 2>/dev/null \
69
72
  | sed -n 's/^dn: //p'
70
73
  }
71
74
 
72
- # Fetch one entry's attributes by DN, retrying past the silent DN-only response.
75
+ # Fetch one entry's attributes by DN. Retry past the silent DN-only response.
73
76
  fetch() { # $1=dn
74
77
  local dn="$1" out tries=0
75
78
  while [ "$tries" -lt 4 ]; do
@@ -87,6 +90,23 @@ name_of_dn() { # $1=dn
87
90
  field displayName "$(fetch "$1")"
88
91
  }
89
92
 
93
+ # List a person's direct reports — the multi-valued `directReports` back-link
94
+ # on their record — as one resolved name per line, sorted. $1 = the person's
95
+ # fetched LDIF record. field() keeps only the first value, so pull EVERY value
96
+ # here, decoding the base64 `directReports:: <b64>` form. Kept in a function
97
+ # because a `case` inside $(...) trips the bash 3.2 parser that macOS ships.
98
+ reports_of() { # $1=record
99
+ printf '%s\n' "$1" | while IFS= read -r line; do
100
+ case "$line" in
101
+ "directReports:: "*) printf '%s' "${line#directReports:: }" | base64 -D 2>/dev/null; echo ;;
102
+ "directReports: "*) printf '%s\n' "${line#directReports: }" ;;
103
+ esac
104
+ done | while IFS= read -r dn; do
105
+ [ -n "$dn" ] || continue
106
+ n=$(name_of_dn "$dn"); [ -n "$n" ] && printf '%s\n' "$n"
107
+ done | sort
108
+ }
109
+
90
110
  # Classify an entry: internal employee, external contact, or vendor account.
91
111
  kind_of() { # $1=record $2=dn
92
112
  local oc; oc=$(printf '%s\n' "$1" | grep -i '^objectClass:' | tr 'A-Z' 'a-z')
@@ -100,7 +120,7 @@ kind_of() { # $1=record $2=dn
100
120
  }
101
121
 
102
122
  # 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`.)
123
+ # (Portable array fill. macOS ships bash 3.2, which has no `mapfile`.)
104
124
  read_dns() { # $1=filter -> populates global DNS array
105
125
  DNS=()
106
126
  local line
@@ -133,12 +153,21 @@ if [ "${#DNS[@]}" -eq 1 ]; then
133
153
  echo "- **Phone:** $(field telephoneNumber "$rec")"
134
154
  echo "- **Office:** $(field physicalDeliveryOfficeName "$rec")"
135
155
  [ -n "$mgr" ] && echo "- **Manager:** $mgr"
156
+ # Direct reports — the down-edge, symmetric to person-identify. Lets a
157
+ # caller resolve a team from any person: an IC's peers are their manager's
158
+ # other reports (look up the manager, read this list). Printed only when
159
+ # non-empty.
160
+ reports=$(reports_of "$rec")
161
+ if [ -n "$reports" ]; then
162
+ echo "- **Direct reports:**"
163
+ printf '%s\n' "$reports" | while IFS= read -r r; do echo " - $r"; done
164
+ fi
136
165
  echo "- **DN:** ${DNS[0]}"
137
166
  exit 0
138
167
  fi
139
168
 
140
169
  # 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:"
170
+ echo "${#DNS[@]} matches for \"$QUERY\". Narrow with an email for an exact hit:"
142
171
  echo
143
172
  shown=0
144
173
  for dn in "${DNS[@]}"; do