@cohortapp/agent-sdk 2.10.0 → 2.11.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 (50) hide show
  1. package/.claude/commands/init-maestro.md +16 -9
  2. package/docs/guides/mac-mini.md +11 -1
  3. package/docs/runbooks/cohort-cutover.md +16 -0
  4. package/lib/mcp/server.test.mjs +16 -4
  5. package/lib/org/client.mjs +58 -1
  6. package/lib/org/protocol.checksum +1 -1
  7. package/lib/org/protocol.mjs +98 -0
  8. package/lib/org/protocol.test.mjs +19 -2
  9. package/lib/org/resource-tools.mjs +317 -0
  10. package/lib/org/resource-tools.test.mjs +361 -0
  11. package/lib/org/tool-access.mjs +176 -0
  12. package/lib/org/tool-access.test.mjs +144 -0
  13. package/lib/org/tool-surface.mjs +431 -5
  14. package/lib/org/tool-surface.test.mjs +385 -8
  15. package/lib/org/ui-parity.mjs +196 -3
  16. package/lib/org/ui-parity.test.mjs +126 -7
  17. package/lib/tool-definitions.js +23 -2
  18. package/package.json +2 -2
  19. package/plugins/maestro-skills/.claude-plugin/marketplace.json +1 -1
  20. package/plugins/maestro-skills/plugin.json +4 -0
  21. package/plugins/maestro-skills/skills/venture-deliverables.md +176 -0
  22. package/policies/information-barriers.yaml +34 -7
  23. package/scripts/ci/check-no-residual-identity.mjs +281 -9
  24. package/scripts/ci/check-no-residual-identity.test.mjs +115 -2
  25. package/scripts/cloud-relay/voice/relay-identity.test.mjs +96 -0
  26. package/scripts/cloud-relay/voice/server.mjs +42 -2
  27. package/scripts/cost/track-claude-usage-pricing.test.mjs +183 -0
  28. package/scripts/cost/track-claude-usage.mjs +113 -4
  29. package/scripts/daemon/agent-daemon.mjs +150 -3
  30. package/scripts/daemon/agent-daemon.test.mjs +190 -0
  31. package/scripts/daemon/assurance.mjs +38 -15
  32. package/scripts/daemon/assurance.test.mjs +39 -1
  33. package/scripts/daemon/classifier-identity.test.mjs +137 -0
  34. package/scripts/daemon/classifier.mjs +98 -17
  35. package/scripts/daemon/prompt-builder-preamble.test.mjs +210 -0
  36. package/scripts/daemon/prompt-builder.mjs +264 -41
  37. package/scripts/daemon/prompt-builder.test.mjs +5 -5
  38. package/scripts/disclosure_boundaries.py +56 -5
  39. package/scripts/huddle/huddle-prompt.test.mjs +176 -0
  40. package/scripts/huddle/huddle-server.mjs +128 -13
  41. package/scripts/local-triggers/autoupdate.sh +83 -0
  42. package/scripts/local-triggers/generate-plists.sh +9 -0
  43. package/scripts/local-triggers/generate-plists.test.mjs +12 -10
  44. package/scripts/media-generation/brand-clause.test.mjs +135 -0
  45. package/scripts/media-generation/gemini-image-client.mjs +27 -9
  46. package/scripts/media-generation/generate-assets.mjs +102 -7
  47. package/scripts/pre-draft-context.py +91 -15
  48. package/scripts/spawn-session.sh +36 -6
  49. package/scripts/test-employer-grounding.py +348 -0
  50. package/scripts/validate_outbound.py +190 -26
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env python3
2
+ """test-employer-grounding.py — the send-path Python must read the employer, never assume one.
3
+
4
+ WHAT BROKE
5
+ scripts/validate_outbound.py carried SIXTEEN hardcoded 'company:<example>-*'
6
+ entity ids plus a matching `startswith` prefix test, and built its
7
+ possessive-claim regex and AI-self-disclosure business exceptions around the
8
+ same invented name. This file is Layer 0 of the sanctioned send path
9
+ (send-email-threaded.py imports validate_message; it runs in series with
10
+ lib/comms/send-gate.mjs#screenOutbound and either can block), so on any REAL
11
+ deployment nothing matched: every colleague was classified external, every
12
+ legitimate "our engineer" was flagged, and the AI-disclosure exceptions never
13
+ fired. scripts/pre-draft-context.py had the same id set, and
14
+ scripts/disclosure_boundaries.py rendered "Relationship to <invented company>"
15
+ into every clearance summary.
16
+
17
+ WHAT THIS PINS
18
+ 1. GROUNDED — with a real config/company.json, the employer's entity ids,
19
+ possessive regex and AI-product exceptions all resolve from it, and the
20
+ clearance summary names the real company.
21
+ 2. EMPTY — with the scaffold's UNCONFIGURED config, the id set is EMPTY and
22
+ `is_own_entity()` is False for everything. That is the FAIL-CLOSED
23
+ direction on every consumer: unknown people are external, possessive claims
24
+ stay flagged, and no company-specific AI-disclosure exception fires.
25
+
26
+ The modules read config from $AGENT_DIR (validate_outbound) / the repo root
27
+ (pre-draft-context, disclosure_boundaries), and resolve the employer at IMPORT
28
+ time, so each case is loaded in a fresh subprocess against a fresh fixture root.
29
+
30
+ Usage: python3 scripts/test-employer-grounding.py (exit 0 ok / 1 failures)
31
+ """
32
+
33
+ import json
34
+ import os
35
+ import shutil
36
+ import subprocess
37
+ import sys
38
+ import tempfile
39
+ from pathlib import Path
40
+
41
+ SCRIPT_DIR = Path(__file__).resolve().parent
42
+ REPO_ROOT = SCRIPT_DIR.parent
43
+
44
+ REAL_COMPANY = {
45
+ "name": "Meridian Freight",
46
+ "legalName": "Meridian Freight Holdings Ltd",
47
+ "domain": "meridianfreight.com",
48
+ }
49
+ SCAFFOLD_COMPANY = {
50
+ "name": "UNCONFIGURED",
51
+ "legalName": "",
52
+ "domain": "",
53
+ }
54
+
55
+ _passed = 0
56
+ _failed = 0
57
+
58
+
59
+ def check(name, condition, detail=""):
60
+ global _passed, _failed
61
+ if condition:
62
+ _passed += 1
63
+ print(f" PASS: {name}")
64
+ else:
65
+ _failed += 1
66
+ print(f" FAIL: {name}{' — ' + detail if detail else ''}")
67
+
68
+
69
+ def make_root(company):
70
+ """A throwaway agent repo carrying only config/company.json."""
71
+ root = Path(tempfile.mkdtemp(prefix="employer-"))
72
+ (root / "config").mkdir(parents=True)
73
+ (root / "config" / "company.json").write_text(json.dumps(company))
74
+ return root
75
+
76
+
77
+ def run_probe(module_path, root, body, extra_env=None):
78
+ """Import `module_path` with the repo rooted at `root`, run `body`, print JSON.
79
+
80
+ The module is loaded by absolute path with AGENT_DIR pointed at the fixture,
81
+ and — for the modules that resolve the repo root from __file__ — a COPY of
82
+ the script placed inside the fixture's scripts/ dir.
83
+ """
84
+ probe = f'''
85
+ import importlib.util, json, sys
86
+ spec = importlib.util.spec_from_file_location("under_test", {str(module_path)!r})
87
+ m = importlib.util.module_from_spec(spec)
88
+ spec.loader.exec_module(m)
89
+ print("@@" + json.dumps({body}))
90
+ '''
91
+ env = dict(os.environ)
92
+ env["AGENT_DIR"] = str(root)
93
+ if extra_env:
94
+ env.update(extra_env)
95
+ out = subprocess.run(
96
+ [sys.executable, "-c", probe], capture_output=True, text=True, env=env, cwd=str(root)
97
+ )
98
+ if out.returncode != 0:
99
+ raise AssertionError(f"probe failed: {out.stderr.strip()[:800]}")
100
+ line = [ln for ln in out.stdout.splitlines() if ln.startswith("@@")][-1]
101
+ return json.loads(line[2:])
102
+
103
+
104
+ def staged_copy(root, rel):
105
+ """Copy a repo script into the fixture root so REPO_ROOT-from-__file__ resolves there.
106
+
107
+ HARNESS ADAPTATION (not a code change): scripts/disclosure_boundaries.py uses
108
+ PEP 604 annotations (`dict | None`), which are evaluated at def time before
109
+ Python 3.10 — a PRE-EXISTING 3.10+ requirement of that file, unrelated to
110
+ this lane. Prepending `from __future__ import annotations` to the COPY makes
111
+ annotations lazy so the real, unmodified function bodies can be exercised on
112
+ the 3.9 interpreter this machine ships. Nothing else about the file changes.
113
+ """
114
+ dst = root / rel
115
+ dst.parent.mkdir(parents=True, exist_ok=True)
116
+ src = (REPO_ROOT / rel).read_text()
117
+ dst.write_text("from __future__ import annotations\n" + src)
118
+ return dst
119
+
120
+
121
+ # ── validate_outbound.py ─────────────────────────────────────────────────────
122
+
123
+ def test_validate_outbound_grounded():
124
+ print("\n[validate_outbound — GROUNDED]")
125
+ root = make_root(REAL_COMPANY)
126
+ r = run_probe(
127
+ REPO_ROOT / "scripts" / "validate_outbound.py",
128
+ root,
129
+ '{'
130
+ '"name": m.COMPANY_NAME,'
131
+ '"ids": sorted(m.OWN_ENTITY_IDS),'
132
+ '"own_exact": m.is_own_entity("company:meridian-freight"),'
133
+ '"own_subsidiary": m.is_own_entity("company:meridian-freight-uk"),'
134
+ '"other": m.is_own_entity("company:someone-else"),'
135
+ '"ai_exception": len(m.check_ai_self_disclosure("Our Meridian Freight AI platform ships next week.")),'
136
+ '}',
137
+ )
138
+ check("employer name read from config", r["name"] == "Meridian Freight", str(r["name"]))
139
+ check("entity ids derive from name/legalName/domain", "company:meridian-freight" in r["ids"], str(r["ids"]))
140
+ check("own entity resolves", r["own_exact"] is True)
141
+ check("group subsidiary resolves via prefix", r["own_subsidiary"] is True)
142
+ check("an unrelated company does NOT resolve", r["other"] is False)
143
+ check("'<Employer> AI' is a business exception, not a self-disclosure", r["ai_exception"] == 0, str(r["ai_exception"]))
144
+ shutil.rmtree(root, ignore_errors=True)
145
+
146
+
147
+ def test_validate_outbound_empty():
148
+ print("\n[validate_outbound — EMPTY / fail closed]")
149
+ root = make_root(SCAFFOLD_COMPANY)
150
+ r = run_probe(
151
+ REPO_ROOT / "scripts" / "validate_outbound.py",
152
+ root,
153
+ '{'
154
+ '"name": m.COMPANY_NAME,'
155
+ '"ids": sorted(m.OWN_ENTITY_IDS),'
156
+ '"prefixes": sorted(m.OWN_ENTITY_PREFIXES),'
157
+ '"any": m.is_own_entity("company:anything-at-all"),'
158
+ '"none": m.is_own_entity(""),'
159
+ '}',
160
+ )
161
+ check("UNCONFIGURED reads as absent", r["name"] == "", repr(r["name"]))
162
+ check("NO employer ids are invented", r["ids"] == [], str(r["ids"]))
163
+ check("NO prefixes are invented", r["prefixes"] == [], str(r["prefixes"]))
164
+ check("is_own_entity is False for everything (fail closed)", r["any"] is False)
165
+ check("is_own_entity tolerates junk", r["none"] is False)
166
+ shutil.rmtree(root, ignore_errors=True)
167
+
168
+
169
+ def test_legacy_profile_key():
170
+ print("\n[validate_outbound — legacy profile key, read generically]")
171
+ root = make_root(REAL_COMPANY)
172
+ r = run_probe(
173
+ REPO_ROOT / "scripts" / "validate_outbound.py",
174
+ root,
175
+ '{'
176
+ '"canonical": m.legacy_relationship_value({"relationship_to_company": "advisor"}),'
177
+ '"legacy": m.legacy_relationship_value({"relationship_to_someoldcompany": "vendor"}),'
178
+ '"precedence": m.legacy_relationship_value({"relationship_to_company": "advisor", "relationship_to_old": "vendor"}),'
179
+ '"none": m.legacy_relationship_value({"unrelated": "x"}),'
180
+ '"junk": m.legacy_relationship_value(None),'
181
+ '}',
182
+ )
183
+ check("canonical key wins", r["canonical"] == "advisor")
184
+ check("ANY relationship_to_* key resolves (no company name pinned)", r["legacy"] == "vendor")
185
+ check("canonical takes precedence over legacy", r["precedence"] == "advisor")
186
+ check("absent ⇒ empty string", r["none"] == "")
187
+ check("junk ⇒ empty string", r["junk"] == "")
188
+ shutil.rmtree(root, ignore_errors=True)
189
+
190
+
191
+ # ── pre-draft-context.py ─────────────────────────────────────────────────────
192
+
193
+ def test_pre_draft_context():
194
+ print("\n[pre-draft-context — GROUNDED and EMPTY]")
195
+ for label, company, expect_own in (("grounded", REAL_COMPANY, True), ("empty", SCAFFOLD_COMPANY, False)):
196
+ root = make_root(company)
197
+ staged = staged_copy(root, "scripts/pre-draft-context.py")
198
+ r = run_probe(
199
+ staged,
200
+ root,
201
+ '{'
202
+ '"name": m.COMPANY_NAME,'
203
+ '"own": m.is_own_entity("company:meridian-freight"),'
204
+ '"other": m.is_own_entity("company:someone-else"),'
205
+ '}',
206
+ )
207
+ check(f"{label}: own-employer test is {expect_own}", r["own"] is expect_own, str(r))
208
+ check(f"{label}: an unrelated company is never own", r["other"] is False)
209
+ shutil.rmtree(root, ignore_errors=True)
210
+
211
+
212
+ # ── disclosure_boundaries.py ─────────────────────────────────────────────────
213
+
214
+ def test_disclosure_boundaries():
215
+ print("\n[disclosure_boundaries — clearance summary names the REAL employer]")
216
+ root = make_root(REAL_COMPANY)
217
+ staged = staged_copy(root, "scripts/disclosure_boundaries.py")
218
+ r = run_probe(
219
+ staged,
220
+ root,
221
+ '{'
222
+ '"name": m.COMPANY_NAME,'
223
+ '"summary": m._build_clearance_summary('
224
+ ' {"name": "Sam Rivera", "role": "Advisor", "relationship_to_company": "external advisor"},'
225
+ ' {"domains_of_interest": ["engineering"]}),'
226
+ '"legacy_summary": m._build_clearance_summary('
227
+ ' {"name": "Sam Rivera", "relationship_to_someoldcompany": "external advisor"},'
228
+ ' {"domains_of_interest": []}),'
229
+ '}',
230
+ )
231
+ check("employer name read from config", r["name"] == "Meridian Freight")
232
+ check("summary names the real employer", "Relationship to Meridian Freight: external advisor." in r["summary"], r["summary"])
233
+ check("a legacy relationship_to_* key still resolves", "external advisor" in r["legacy_summary"], r["legacy_summary"])
234
+ check("no permitted domains ⇒ default deny is stated", "default deny" in r["legacy_summary"], r["legacy_summary"])
235
+ shutil.rmtree(root, ignore_errors=True)
236
+
237
+ print("\n[disclosure_boundaries — EMPTY: a neutral noun, never an invented name]")
238
+ root = make_root(SCAFFOLD_COMPANY)
239
+ staged = staged_copy(root, "scripts/disclosure_boundaries.py")
240
+ r = run_probe(
241
+ staged,
242
+ root,
243
+ '{'
244
+ '"name": m.COMPANY_NAME,'
245
+ '"summary": m._build_clearance_summary('
246
+ ' {"name": "Sam Rivera", "relationship_to_company": "external advisor"},'
247
+ ' {"domains_of_interest": ["engineering"]}),'
248
+ '}',
249
+ )
250
+ check("UNCONFIGURED reads as absent", r["name"] == "")
251
+ check("summary falls back to 'the company'", "Relationship to the company: external advisor." in r["summary"], r["summary"])
252
+ check("no sentinel leaks into the summary", "UNCONFIGURED" not in r["summary"], r["summary"])
253
+ shutil.rmtree(root, ignore_errors=True)
254
+
255
+
256
+ # ── policies/information-barriers.yaml ───────────────────────────────────────
257
+
258
+ def test_information_barriers_policy():
259
+ print("\n[information-barriers.yaml — company-neutral, default-deny]")
260
+ raw = (REPO_ROOT / "policies" / "information-barriers.yaml").read_text()
261
+ descriptions = [ln for ln in raw.splitlines() if ln.strip().startswith("description:")]
262
+ check("domain descriptions exist", len(descriptions) > 10, str(len(descriptions)))
263
+ # Descriptions are parsed into the live keyword matcher by
264
+ # context-compiler.mjs#buildDomainKeywords, so a party name here becomes a
265
+ # classification keyword for every customer.
266
+ for ln in descriptions:
267
+ low = ln.lower()
268
+ check(
269
+ f"description is company-neutral: {ln.strip()[:60]}",
270
+ "northwind" not in low and "partnerco" not in low,
271
+ )
272
+ check("unrestricted_recipients ships EMPTY (default deny)", "unrestricted_recipients: []" in raw)
273
+ check("the taxonomy itself is kept", "internal-legal:" in raw and "board-governance:" in raw)
274
+
275
+
276
+ # ── spawn-session.sh ─────────────────────────────────────────────────────────
277
+
278
+ def test_spawn_session_identity():
279
+ print("\n[spawn-session.sh — identity clause from config/agent.env]")
280
+ src = (REPO_ROOT / "scripts" / "spawn-session.sh").read_text()
281
+ check("sources config/agent.env", 'config/agent.env' in src)
282
+ check(
283
+ "uses the real config/agent.env variable names",
284
+ "AGENT_FULL_NAME" in src and "COMPANY_NAME" in src,
285
+ )
286
+ check("no invented employer in the sub-session prompt", "northwind" not in src.lower())
287
+ check("the instance-specific dir variable is renamed", "SOPHIE_AI_DIR" not in src)
288
+
289
+ # Behavioural: the clause is assembled by the shell, so drive the shell.
290
+ def clause(env):
291
+ script = (
292
+ 'set -e\n'
293
+ '_agent_name="${AGENT_FULL_NAME:-}"\n'
294
+ '_company="${COMPANY_NAME:-}"\n'
295
+ 'if [ "$_agent_name" = "UNCONFIGURED" ]; then _agent_name=""; fi\n'
296
+ 'if [ "$_company" = "UNCONFIGURED" ]; then _company=""; fi\n'
297
+ 'if [ -n "$_agent_name" ] && [ -n "$_company" ]; then\n'
298
+ ' echo "IMPORTANT: You are a sub-session spawned for $_agent_name at $_company."\n'
299
+ 'elif [ -n "$_agent_name" ]; then\n'
300
+ ' echo "IMPORTANT: You are a sub-session spawned for $_agent_name."\n'
301
+ 'else\n'
302
+ ' echo "IMPORTANT: You are a sub-session spawned by an agent."\n'
303
+ 'fi\n'
304
+ )
305
+ # The block under test is lifted verbatim from the script — assert that first.
306
+ assert 'if [ -n "$_agent_name" ] && [ -n "$_company" ]; then' in src, "identity block changed shape"
307
+ e = dict(os.environ)
308
+ e.pop("AGENT_FULL_NAME", None)
309
+ e.pop("COMPANY_NAME", None)
310
+ e.update(env)
311
+ return subprocess.run(["bash", "-c", script], capture_output=True, text=True, env=e).stdout.strip()
312
+
313
+ check(
314
+ "grounded: both fields present ⇒ full clause",
315
+ clause({"AGENT_FULL_NAME": "Robin Okafor", "COMPANY_NAME": "Meridian Freight"})
316
+ == "IMPORTANT: You are a sub-session spawned for Robin Okafor at Meridian Freight.",
317
+ )
318
+ check(
319
+ "empty: no company ⇒ the employer clause is dropped",
320
+ clause({"AGENT_FULL_NAME": "Robin Okafor"})
321
+ == "IMPORTANT: You are a sub-session spawned for Robin Okafor.",
322
+ )
323
+ check(
324
+ "empty: UNCONFIGURED counts as absent",
325
+ clause({"AGENT_FULL_NAME": "UNCONFIGURED", "COMPANY_NAME": "UNCONFIGURED"})
326
+ == "IMPORTANT: You are a sub-session spawned by an agent.",
327
+ )
328
+ check(
329
+ "empty: nothing at all ⇒ a generic, true statement",
330
+ clause({}) == "IMPORTANT: You are a sub-session spawned by an agent.",
331
+ )
332
+
333
+
334
+ def main():
335
+ print("Running employer-grounding tests...\n")
336
+ test_validate_outbound_grounded()
337
+ test_validate_outbound_empty()
338
+ test_legacy_profile_key()
339
+ test_pre_draft_context()
340
+ test_disclosure_boundaries()
341
+ test_information_barriers_policy()
342
+ test_spawn_session_identity()
343
+ print(f"\n{_passed} passed, {_failed} failed")
344
+ return 1 if _failed else 0
345
+
346
+
347
+ if __name__ == "__main__":
348
+ sys.exit(main())
@@ -58,6 +58,152 @@ except Exception:
58
58
  _AGENT = {"firstName": "Agent", "fullName": "Agent"}
59
59
  AGENT_FIRST_NAME = _AGENT.get("firstName", "Agent")
60
60
 
61
+
62
+ # ─────────────────────────────────────────────
63
+ # Employer identity — resolved ONCE at load from config/company.json
64
+ #
65
+ # WHY THIS EXISTS
66
+ # The entity-relationship checks below used to carry sixteen hardcoded
67
+ # entity ids for the framework's example employer, plus a matching
68
+ # `startswith` prefix test. This file is Layer 0 of the sanctioned send path
69
+ # (send-email-threaded.py imports validate_message; it runs in series with
70
+ # lib/comms/send-gate.mjs#screenOutbound and either can block), so on any real
71
+ # deployment NOTHING matched: every colleague was classified external, every
72
+ # legitimate "our engineer" was flagged as an unverified possessive claim, and
73
+ # the AI-self-disclosure business exceptions never fired.
74
+ #
75
+ # Employer identity now comes from config/company.json — the same SOT
76
+ # lib/setup/enroll-from-cohort.mjs syncs from Cohort — exactly as
77
+ # scripts/poller/gmail-poller.mjs#internalDomains was de-hardcoded.
78
+ #
79
+ # FAIL CLOSED
80
+ # When nothing is configured the id set is EMPTY, so `is_own_entity()` is
81
+ # always False. That is the conservative direction on every consumer here:
82
+ # unknown-relationship people are treated as external, possessive claims stay
83
+ # flagged, and the AI-disclosure exception list stays empty (so "AI" wording
84
+ # is flagged rather than waved through). Silence, not a guess.
85
+ # ─────────────────────────────────────────────
86
+ try:
87
+ with open(REPO_ROOT / "config" / "company.json") as _f:
88
+ _COMPANY = json.load(_f)
89
+ except Exception:
90
+ _COMPANY = {}
91
+
92
+
93
+ def _configured(value):
94
+ """Return a trimmed config string, or '' when it is absent/UNCONFIGURED.
95
+
96
+ config/company.json ships `"name": "UNCONFIGURED"` and empty strings in the
97
+ scaffold, so both shapes mean 'the operator has not filled this in'.
98
+ """
99
+ if not isinstance(value, str):
100
+ return ''
101
+ v = value.strip()
102
+ if not v or v.upper() == 'UNCONFIGURED':
103
+ return ''
104
+ return v
105
+
106
+
107
+ COMPANY_NAME = _configured(_COMPANY.get('name'))
108
+ COMPANY_LEGAL_NAME = _configured(_COMPANY.get('legalName'))
109
+ COMPANY_DOMAIN = _configured(_COMPANY.get('domain'))
110
+
111
+
112
+ def _slugify(value):
113
+ """'Acme Holdings, Ltd.' -> 'acme-holdings-ltd'. Returns '' for junk."""
114
+ if not value:
115
+ return ''
116
+ slug = re.sub(r'[^a-z0-9]+', '-', value.lower()).strip('-')
117
+ return slug
118
+
119
+
120
+ def _resolve_own_entity_ids(company):
121
+ """Build the set of entity-index ids that denote THIS agent's employer.
122
+
123
+ Precedence:
124
+ 1. an explicit `own_entity_ids` list in config/company.json (the escape
125
+ hatch for a group with entity ids that do not derive from its name);
126
+ 2. otherwise, ids derived from name / legalName / domain.
127
+
128
+ Returns (ids, prefixes). `prefixes` lets a group's subsidiaries
129
+ ('company:acme-holdings', 'company:acme-am-uk') resolve without every entity
130
+ being enumerated — the same reach the hardcoded prefix test had, but keyed
131
+ to the real employer.
132
+ """
133
+ explicit = company.get('own_entity_ids')
134
+ if isinstance(explicit, list):
135
+ ids = {str(x).strip() for x in explicit if isinstance(x, (str,)) and str(x).strip()}
136
+ if ids:
137
+ # An explicit list is exhaustive by construction — no prefix reach.
138
+ return ids, set()
139
+
140
+ stems = set()
141
+ for raw in (COMPANY_NAME, COMPANY_LEGAL_NAME):
142
+ slug = _slugify(raw)
143
+ if slug:
144
+ stems.add(slug)
145
+ if COMPANY_DOMAIN:
146
+ # 'acme.com' / 'www.acme.co.uk' -> 'acme'
147
+ host = COMPANY_DOMAIN.lower().strip().split('/')[0]
148
+ host = host[4:] if host.startswith('www.') else host
149
+ label = host.split('.')[0]
150
+ slug = _slugify(label)
151
+ if slug:
152
+ stems.add(slug)
153
+
154
+ ids = {'company:' + s for s in stems}
155
+ # Every stem is also a prefix, so a group's subsidiaries resolve without
156
+ # each entity being enumerated ('acme' matches 'company:acme-holdings',
157
+ # 'company:acme-am-uk'). This is the same reach the old hardcoded
158
+ # `startswith('company:<example>')` test had, keyed to the real employer
159
+ # instead. An operator who needs it tighter sets `own_entity_ids` in
160
+ # config/company.json, which is exhaustive and disables prefix reach.
161
+ prefixes = {'company:' + s for s in stems}
162
+ return ids, prefixes
163
+
164
+
165
+ OWN_ENTITY_IDS, OWN_ENTITY_PREFIXES = _resolve_own_entity_ids(_COMPANY)
166
+
167
+
168
+ def is_own_entity(obj):
169
+ """True when an entity-index object id denotes the agent's own employer.
170
+
171
+ Returns False for everything when no employer is configured — see FAIL
172
+ CLOSED above.
173
+ """
174
+ if not obj or not isinstance(obj, str):
175
+ return False
176
+ if obj in OWN_ENTITY_IDS:
177
+ return True
178
+ return any(obj.startswith(p) for p in OWN_ENTITY_PREFIXES)
179
+
180
+
181
+ def legacy_relationship_value(data):
182
+ """Read a profile's flat "relationship to the employer" field.
183
+
184
+ The canonical key is `relationship_to_company`. Profiles written before this
185
+ was de-hardcoded used `relationship_to_<employer-name>`, so rather than
186
+ naming any one company here we accept ANY `relationship_to_*` key. That
187
+ resolves existing memory/profiles/ without pinning a company name into the
188
+ reader — a hardcoded legacy key would only work for the one deployment whose
189
+ name it happened to be.
190
+
191
+ Returns '' when nothing usable is present.
192
+ """
193
+ if not isinstance(data, dict):
194
+ return ''
195
+ primary = data.get('relationship_to_company')
196
+ if isinstance(primary, str) and primary.strip():
197
+ return primary
198
+ for key in sorted(data.keys()):
199
+ if not isinstance(key, str) or not key.startswith('relationship_to_'):
200
+ continue
201
+ value = data.get(key)
202
+ if isinstance(value, str) and value.strip():
203
+ return value
204
+ return ''
205
+
206
+
61
207
  # Data source paths
62
208
  CONTACTS_PATH = REPO_ROOT / "config" / "contacts.yaml"
63
209
  ORG_CHART_PATH = REPO_ROOT / "knowledge" / "entities" / "org-chart.md"
@@ -384,9 +530,9 @@ def lookup_entity(name, entity_index=None):
384
530
  Returns:
385
531
  dict with keys:
386
532
  - 'entity': the entity dict (or None)
387
- - 'relationships_to_northwind': list of relationship dicts describing
388
- this entity's relationship to Northwind/its entities
389
- - 'is_internal': bool — True if entity is employed by a Northwind entity
533
+ - 'relationships_to_company': list of relationship dicts describing
534
+ this entity's relationship to the agent's employer / its entities
535
+ - 'is_internal': bool — True if entity is employed by one of those entities
390
536
  - 'relationship_type': str — summary classification
391
537
  ('internal', 'vendor_candidate', 'external_vendor', 'external_advisor',
392
538
  'investor', 'regulator', 'external', 'unknown')
@@ -423,19 +569,9 @@ def lookup_entity(name, entity_index=None):
423
569
  # Collect relationships where this entity is the subject
424
570
  subject_rels = rels_by_subject.get(entity_id, [])
425
571
 
426
- # Determine relationship to Northwind
427
- northwind_ids = {
428
- 'company:northwind-group', 'company:northwind-holdings',
429
- 'company:northwind-am-difc', 'company:northwind-technologies',
430
- 'company:northwind-group-investments', 'company:northwind-investments',
431
- 'company:northwind-inc', 'company:northwind-ai-uk',
432
- 'company:northwind-am-uk', 'company:northwind-am-cayman',
433
- 'company:northwind-am-asia', 'company:northwind-asia',
434
- 'company:northwind-am-eu', 'company:northwind-am-us',
435
- 'company:northwind-am-ksa', 'company:northwind-ksa',
436
- }
437
-
438
- rels_to_northwind = []
572
+ # Determine relationship to the agent's own employer (resolved from
573
+ # config/company.json at load — see OWN_ENTITY_IDS above).
574
+ rels_to_company = []
439
575
  is_internal = False
440
576
  rel_type = 'unknown'
441
577
 
@@ -443,8 +579,8 @@ def lookup_entity(name, entity_index=None):
443
579
  obj = rel.get('object', '')
444
580
  pred = rel.get('predicate', '')
445
581
 
446
- if obj in northwind_ids or obj.startswith('company:northwind'):
447
- rels_to_northwind.append(rel)
582
+ if is_own_entity(obj):
583
+ rels_to_company.append(rel)
448
584
 
449
585
  if pred in ('employed_by', 'directs'):
450
586
  is_internal = True
@@ -460,7 +596,7 @@ def lookup_entity(name, entity_index=None):
460
596
  elif pred == 'regulates' and rel_type not in ('internal',):
461
597
  rel_type = 'regulator'
462
598
 
463
- # Check if entity is employed by a non-Northwind company (external person)
599
+ # Check if entity is employed by some OTHER company (external person)
464
600
  if rel_type == 'unknown':
465
601
  for rel in subject_rels:
466
602
  pred = rel.get('predicate', '')
@@ -470,7 +606,7 @@ def lookup_entity(name, entity_index=None):
470
606
 
471
607
  return {
472
608
  'entity': entity,
473
- 'relationships_to_northwind': rels_to_northwind,
609
+ 'relationships_to_company': rels_to_company,
474
610
  'is_internal': is_internal,
475
611
  'relationship_type': rel_type,
476
612
  }
@@ -549,10 +685,12 @@ def load_user_profiles():
549
685
  # Company: identity.company or top-level
550
686
  company = _nested_get(data, 'identity', 'company') or data.get('company', '')
551
687
 
552
- # Relationship type: relationship.type, or flat relationship_to_northwind, or top-level type
688
+ # Relationship type: relationship.type, then the flat
689
+ # relationship_to_* key (see legacy_relationship_value), then
690
+ # top-level type.
553
691
  rel_type = (
554
692
  _nested_get(data, 'relationship', 'type')
555
- or data.get('relationship_to_northwind', '')
693
+ or legacy_relationship_value(data)
556
694
  or data.get('type', '')
557
695
  )
558
696
 
@@ -659,9 +797,22 @@ def check_relationship_claims(message):
659
797
  'investor', 'regulator', 'external', 'unknown',
660
798
  }
661
799
 
662
- # Relationship claim patterns
800
+ # Relationship claim patterns.
801
+ # The possessive alternation is built from the RESOLVED employer name
802
+ # (config/company.json) rather than a hardcoded one — "Acme's engineer" is
803
+ # the same claim as "our engineer" and must be checked the same way. When no
804
+ # employer is configured the company alternative is simply absent and the
805
+ # generic possessives still apply.
806
+ possessive_alternatives = ['our', 'my', r'the company\'s', r'the team\'s']
807
+ for _name in (COMPANY_NAME, COMPANY_LEGAL_NAME):
808
+ if _name:
809
+ frag = re.escape(_name) + r'(?:\'s|’s)'
810
+ if frag not in possessive_alternatives:
811
+ possessive_alternatives.append(frag)
812
+ possessive_group = '(' + '|'.join(possessive_alternatives) + ')'
813
+
663
814
  claim_patterns = [
664
- (r'\b(our|my|the company\'s|Northwind\'s|the team\'s)\s+'
815
+ (possessive_group + r'\s+'
665
816
  r'(project manager|PM|consultant|advisor|counsel|lawyer|developer|'
666
817
  r'engineer|designer|analyst|hire|employee|team member|colleague|'
667
818
  r'staff member|contractor|associate|partner|director|officer|'
@@ -925,20 +1076,33 @@ def check_title_accuracy(message):
925
1076
  def check_ai_self_disclosure(message):
926
1077
  """Scan for language that reveals the agent is AI.
927
1078
 
928
- Must NOT flag general business discussion of Northwind's AI products.
1079
+ Must NOT flag general business discussion of the company's OWN AI products
1080
+ ("Acme AI", "acme.ai") — those exceptions are built from the resolved
1081
+ employer name, not a hardcoded one. With no employer configured the
1082
+ company-specific exceptions are simply absent, which errs toward FLAGGING.
929
1083
  Returns list of issue dicts.
930
1084
  """
931
1085
  issues = []
932
1086
 
933
1087
  # Business context exceptions — if these appear, the word "AI" is OK
934
1088
  business_exceptions = [
935
- r'AI[- ]Born', r'AI[- ]native', r'Northwind\s*AI', r'Northwind\.ai',
1089
+ r'AI[- ]Born', r'AI[- ]native',
936
1090
  r'artificial intelligence\s+strategy', r'AI\s+products?',
937
1091
  r'AI\s+platform', r'AI\s+capabilit', r'machine learning\s+models?',
938
1092
  r'AI[- ]driven', r'AI\s+solutions?', r'AI\s+technolog',
939
1093
  r'AI\s+invest', r'AI\s+fund', r'AI\s+sector', r'AI\s+market',
940
1094
  r'AI\s+infrastructure', r'AI\s+research', r'generative\s+AI',
941
1095
  ]
1096
+ # "<Employer> AI" / "<employer>.ai" — the company's own product naming.
1097
+ for _name in (COMPANY_NAME, COMPANY_LEGAL_NAME):
1098
+ if _name:
1099
+ business_exceptions.append(re.escape(_name) + r'\s*AI')
1100
+ if COMPANY_DOMAIN:
1101
+ _host = COMPANY_DOMAIN.lower().strip().split('/')[0]
1102
+ _host = _host[4:] if _host.startswith('www.') else _host
1103
+ _label = _host.split('.')[0]
1104
+ if _label:
1105
+ business_exceptions.append(re.escape(_label) + r'\.ai')
942
1106
 
943
1107
  # Self-referential AI disclosure patterns
944
1108
  disclosure_patterns = [