lattice-mcp 0.1.0__tar.gz

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,397 @@
1
+ # Lattice Automation
2
+
3
+ > Package lives at `/git/lattice/` (standalone repo).
4
+ > SSO login completed via Playwright headed browser or Chrome CDP.
5
+ > Session stored at `~/.config/lattice/browser-state.json`.
6
+
7
+ ---
8
+
9
+ ## Status: Working
10
+
11
+ All milestones achieved:
12
+
13
+ - [x] Package built at `lattice/` — 64 unit tests passing
14
+ - [x] `pip install -e ".[dev,ui]" && playwright install chromium`
15
+ - [x] SSO login via headed Playwright or CDP (`--cdp-url http://127.0.0.1:9223`)
16
+ - [x] Scrape `/` (root is authenticated home, not `/home`)
17
+ - [x] GraphQL capture working (4+ responses per page)
18
+ - [x] Read objectives via GraphQL (`viewer.goals.edges`)
19
+ - [x] **Write objectives** — posted update via `AddUpdateToGoalMutation`
20
+ - [x] **MCP server** — full CRUD: create, list, update, delete objectives
21
+ - [x] Async Playwright (required for MCP's async event loop)
22
+
23
+ ---
24
+
25
+ ## Goal
26
+
27
+ Programmatic Lattice access **without** a Lattice admin API key:
28
+
29
+ 1. Manual SSO in a real browser (Playwright headed or Chrome CDP)
30
+ 2. Persist Playwright `storage_state`
31
+ 3. Scrape pages + **capture GraphQL** JSON for automation
32
+ 4. Parsers → CLI → optional MCP
33
+
34
+ Secondary (only if admin issues a key): Bearer API against `api.latticehq.com`.
35
+
36
+ ---
37
+
38
+ ## Discovered routes and GraphQL
39
+
40
+ ### Working routes
41
+
42
+ | Path | Content |
43
+ |------|---------|
44
+ | `/` | Authenticated home dashboard (NOT `/home` — that 404s) |
45
+ | `/goals` | Company-wide objectives (14696 total, paginated) |
46
+ | `/goals/explore?ownerEntityIdsFilter=["{userId}"]&statusesFilter=["AllActive"]&viewType=cascade` | Personal objectives filtered by owner |
47
+ | `/goals/{entityId}` | Single objective detail page |
48
+ | `/grow` | Grow page (loaded successfully) |
49
+
50
+ ### GraphQL operations captured
51
+
52
+ **Read (from `/goals` page):**
53
+ - `viewer.goals.edges[].node` — objective list with: `entityId`, `name`, `priority`, `dueDate`, `status` (green/amber/red/null), `computedProgressAmount`, `owners[].name`, `lastUpdate`, `okrType`, `state`
54
+ - `viewer.allGoalsAnalytics.goalStatusBreakdown` — counts by status
55
+ - `viewer.user` — current user info (entityId, name, title, email, isAdmin)
56
+ - `viewer.company.goalCycles` — available fiscal quarters
57
+
58
+ **Write (from goal detail page "Update" button):**
59
+
60
+ ```graphql
61
+ mutation AddUpdateToGoalMutation($input: AddUpdateToGoalInput!) {
62
+ addUpdateToGoal(input: $input) {
63
+ goal { ... }
64
+ goalUpdate { entityId ... }
65
+ }
66
+ }
67
+ ```
68
+
69
+ Variables:
70
+ ```json
71
+ {
72
+ "input": {
73
+ "goalId": "<base64 goal ID>",
74
+ "data": {
75
+ "comment": "<update text with \\n<br>\\n for newlines>",
76
+ "status": "green"
77
+ }
78
+ }
79
+ }
80
+ ```
81
+
82
+ - `goalId` is the base64-encoded relay ID (NOT the entityId UUID)
83
+ - Status values: `"green"` (On track), `"amber"` (Progressing), `"red"` (Off track)
84
+ - Newlines in comment become `\n<br>\n` in the mutation payload
85
+ - Introspection is disabled — mutations must be discovered by UI interaction
86
+
87
+ ### Key IDs
88
+
89
+ | Entity | Value |
90
+ |--------|-------|
91
+ | User (Sean Ahn) | `01bfc19d-5192-4939-b03d-d185138ce183` |
92
+ | Company (C3 AI) | `293aacd4-f240-4356-8b47-34247c10420f` |
93
+ | Socket Firewall objective | `c876f39d-374d-42c7-84e5-d8feec38501c` |
94
+
95
+ ---
96
+
97
+ ## Jira integration
98
+
99
+ Jira API token at `~/.ssh/jira-jupyer` (Atlassian Cloud basic auth).
100
+
101
+ ```bash
102
+ curl -s -u "sean.ahn@c3.ai:$(cat ~/.ssh/jira-jupyer)" \
103
+ -X POST "https://c3energy.atlassian.net/rest/api/3/search/jql" \
104
+ -H "Content-Type: application/json" \
105
+ -d '{"jql": "text ~ \"5077-US7\"", "maxResults": 5}'
106
+ ```
107
+
108
+ Linked tickets for the Socket Firewall objective:
109
+
110
+ | Key | Summary | Assignee |
111
+ |-----|---------|----------|
112
+ | INFOSEC-6098 | [5077-US7] Configure Cursor and Claude Code clients to route package installs through Socket Firewall | Seung Ahn |
113
+ | INFOSEC-8167 | [5077-US7] Deliverable Set A — Local agent client config (Cursor local, Claude Code local) | infosec-jira |
114
+ | INFOSEC-8168 | [5077-US7] Deliverable Set B — SaaS mode control assessment (Cursor SaaS, Claude Code SaaS) | infosec-jira |
115
+
116
+ Note: the old `/rest/api/3/search` endpoint is removed — use `/rest/api/3/search/jql` with POST body.
117
+
118
+ ---
119
+
120
+ ## Automation recipe (what worked)
121
+
122
+ ### Update an objective from Jira data
123
+
124
+ 1. Fetch Jira tickets: `POST /rest/api/3/search/jql` with `{"jql": "..."}`
125
+ 2. Get ticket details: `GET /rest/api/3/issue/{id}?fields=summary,description,status`
126
+ 3. Open goal page in Playwright with saved storage state
127
+ 4. Click "Update" button → select status radio → type comment → click second "Update" button
128
+ 5. GraphQL mutation fires automatically; update is saved
129
+
130
+ ### CDP login (port 9222 taken by Cursor — use 9223)
131
+
132
+ ```bash
133
+ google-chrome --remote-debugging-port=9223 --user-data-dir=/tmp/chrome-lattice --no-first-run &
134
+ # Complete SSO in browser, then:
135
+ python3 -m lattice ui login --cdp-url http://127.0.0.1:9223
136
+ ```
137
+
138
+ ---
139
+
140
+ ## Decision log (do not re-litigate)
141
+
142
+ | Topic | Conclusion |
143
+ |-------|------------|
144
+ | Official Lattice MCP | None. Community MCPs need API keys. |
145
+ | Non-admin API keys | **Impossible** — Admin → Platform → API keys only |
146
+ | Web SSO → API access | **No** |
147
+ | Cookie as `Authorization: Bearer` | **No** — not a Talent API key |
148
+ | narwhalChat Lattice | Exists: GraphQL to `https://c3.latticehq.com/` via `latticeToken` **runtime arg**; token **not in git** |
149
+ | Steal token from narwhalChat | **Cannot** |
150
+ | Approach | **UI scrape + GraphQL capture** after manual SSO |
151
+ | Rejected | Automating IdP login; cookie-as-API-key; DOM as stable public API |
152
+
153
+ Tenant: `c3.latticehq.com`
154
+ Talent API (admin path): `https://api.latticehq.com`
155
+ narwhalChat code (reference only): `c3engineering/repo/community/narwhalChat/src/lattice/`
156
+
157
+ ---
158
+
159
+ ## Rebuild spec
160
+
161
+ Package location: `/git/lattice/` (standalone repo, built 2026-07-24, MCP added 2026-07-25).
162
+
163
+ ```
164
+ lattice/
165
+ README.md
166
+ DESIGN.md
167
+ pyproject.toml
168
+ src/lattice/
169
+ __init__.py
170
+ __main__.py
171
+ cli.py
172
+ auth.py
173
+ credentials.py
174
+ client.py
175
+ ui.py
176
+ mcp_server.py
177
+ tests/
178
+ test_credentials.py
179
+ test_client.py
180
+ test_auth.py
181
+ test_ui.py
182
+ ```
183
+
184
+ ### `pyproject.toml`
185
+
186
+ ```toml
187
+ [build-system]
188
+ requires = ["hatchling"]
189
+ build-backend = "hatchling.build"
190
+
191
+ [project]
192
+ name = "lattice-cli"
193
+ version = "0.1.0"
194
+ description = "CLI for Lattice — API-key auth and Playwright UI scraping"
195
+ requires-python = ">=3.10"
196
+ license = { text = "Proprietary" }
197
+ dependencies = []
198
+
199
+ [project.optional-dependencies]
200
+ dev = ["pytest>=7.0"]
201
+ ui = ["playwright>=1.40"]
202
+
203
+ [project.scripts]
204
+ lattice = "lattice.cli:main"
205
+
206
+ [tool.hatch.build.targets.wheel]
207
+ packages = ["src/lattice"]
208
+
209
+ [tool.pytest.ini_options]
210
+ testpaths = ["tests"]
211
+ pythonpath = ["src"]
212
+ ```
213
+
214
+ ### Constants / config
215
+
216
+ | Name | Value |
217
+ |------|--------|
218
+ | Default web host | `c3.latticehq.com` |
219
+ | Default API URL | `https://api.latticehq.com` |
220
+ | Config dir | `~/.config/lattice` (override: `--config-dir` or `LATTICE_CONFIG_DIR`) |
221
+ | API creds file | `credentials.json` mode `0600` |
222
+ | UI session file | `browser-state.json` mode `0600` |
223
+ | Env API token | `LATTICE_API_TOKEN` or `LATTICE_TOKEN` |
224
+ | Env API URL | `LATTICE_API_URL` |
225
+ | Env web host | `LATTICE_WEB_HOSTNAME` |
226
+
227
+ ### Module: `credentials.py`
228
+
229
+ - `@dataclass Credentials(token, api_url, web_hostname, user: dict)`
230
+ - `masked_token()`, `config_dir()`, `credentials_path()`, `load()`, `save()`, `clear()`
231
+ - `load()`: env token overrides file; corrupt file ignored if env set
232
+ - `save()`: write via temp file + `os.replace`, chmod `0600`
233
+
234
+ ### Module: `client.py` (API path; stdlib `urllib`)
235
+
236
+ - `get_me(api_url, token) -> dict` → `GET {api_url}/v1/me` with `Authorization: Bearer …`
237
+ - `LatticeAPIError(status, message)`
238
+ - `user_display(user) -> str` — name + email fallbacks (`preferredName`/`firstName`/`lastName`/`workEmail`)
239
+
240
+ ### Module: `auth.py` (API path)
241
+
242
+ | Command | Behavior |
243
+ |---------|----------|
244
+ | `login` | Optional webbrowser open to `https://{host}/admin`; `getpass` or `--with-token`; validate `/v1/me`; `save()` |
245
+ | `status` | Load creds; refresh via `/v1/me` unless offline; print user |
246
+ | `logout` | `clear()` |
247
+ | `token` | Print raw token to stdout |
248
+
249
+ ### Module: `ui.py` (primary path; Playwright optional import)
250
+
251
+ **Login detection:** URL is “still logging in” if host ≠ tenant (e.g. Okta) OR path/host contains any of:
252
+ `login`, `signin`, `sign-in`, `sso`, `saml`, `oauth`, `okta`, `auth0`, `openid`, `callback`.
253
+
254
+ **Authenticated:** on tenant hostname, not a login URL, and context has cookies.
255
+
256
+ | Command | Behavior |
257
+ |---------|----------|
258
+ | `ui login` | Launch Chromium **headed** by default; goto `https://{hostname}`; wait up to `--timeout` (default 300s) for auth; `storage_state` → `browser-state.json` |
259
+ | `ui login --cdp-url URL` | `chromium.connect_over_cdp`; wait for auth; save storage; **do not** close user Chrome |
260
+ | `ui login --headless` | Allowed but usually useless for SSO |
261
+ | `ui status` | Report whether `browser-state.json` has cookies |
262
+ | `ui logout` | Delete `browser-state.json` |
263
+ | `ui scrape [path]` | Load storage state; navigate (path or full URL); `networkidle` + ~1.5s; if redirected to login → error “re-login”; dump outputs |
264
+
265
+ **Scrape outputs** (dir `--out`, default `./lattice-scrape`):
266
+
267
+ | File | Content |
268
+ |------|---------|
269
+ | `scrape.meta.json` | `{url, title}` |
270
+ | `scrape.txt` | `page.inner_text("body")` |
271
+ | `scrape.graphql.json` | List of `{url, status, body}` for responses whose URL contains `graphql` (unless `--no-graphql`) |
272
+ | `scrape.html` | Full HTML if `--html` |
273
+ | screenshot | If `--screenshot PATH` |
274
+
275
+ **Launch error UX:** On Playwright launch failure, print hints:
276
+
277
+ - `python3 -m playwright install-deps chromium`
278
+ - `python3 -m playwright install chromium`
279
+ - If no `DISPLAY`/`WAYLAND_DISPLAY`: explain headed SSO impossible; suggest laptop login or `--cdp-url`
280
+
281
+ **Do not** treat missing Playwright as a hard import at module load — fail with install instructions when UI commands run.
282
+
283
+ ### Module: `cli.py`
284
+
285
+ ```
286
+ lattice [--config-dir DIR] [--version]
287
+ auth login [--hostname] [--api-url] [--with-token] [--no-browser]
288
+ auth status [--show-token] [--offline]
289
+ auth logout | token
290
+ ui login [--hostname] [--timeout] [--headless] [--cdp-url]
291
+ ui status | logout
292
+ ui scrape [path] [--hostname] [--out] [--html] [--no-graphql]
293
+ [--screenshot] [--headed] [--print]
294
+ ```
295
+
296
+ `__main__.py`: `raise SystemExit(main())`
297
+ `__init__.py`: `__version__ = "0.1.0"`
298
+
299
+ ### Tests (no live Lattice / no browser required)
300
+
301
+ Cover at least:
302
+
303
+ - credentials save/load/chmod/env override/clear/mask
304
+ - `get_me` success + 401 (`urllib` mocked)
305
+ - `user_display` variants
306
+ - auth login success/fail (mock `get_me`); status/logout/token; CLI login
307
+ - `looks_like_login_url` (Okta vs tenant `/home` vs `/login`)
308
+ - `session_exists` / `write_scrape_outputs`
309
+ - CLI `ui status` → 1 when no session
310
+
311
+ Target: ~20+ unit tests, `pytest` green offline.
312
+
313
+ ### README (short)
314
+
315
+ Document: install `[ui]`, `playwright install chromium`, `install-deps`, headed login, CDP login, scrape flags, API auth secondary, never commit secrets/`lattice-scrape/`.
316
+
317
+ ---
318
+
319
+ ## Local SSO recipes (this is the blocker)
320
+
321
+ ### A. Headed Playwright (preferred on laptop)
322
+
323
+ ```bash
324
+ cd c3securitytools/lattice
325
+ pip install -e ".[dev,ui]"
326
+ playwright install chromium
327
+ PYTHONPATH=src python3 -m lattice ui login
328
+ PYTHONPATH=src python3 -m lattice ui scrape /home --out ./lattice-scrape --html
329
+ ```
330
+
331
+ ### B. System Chrome + CDP
332
+
333
+ ```bash
334
+ # macOS
335
+ /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
336
+ --remote-debugging-port=9222 \
337
+ --user-data-dir=/tmp/chrome-lattice
338
+ # SSO to https://c3.latticehq.com in that window, then:
339
+ PYTHONPATH=src python3 -m lattice ui login --cdp-url http://127.0.0.1:9222
340
+ ```
341
+
342
+ ### C. Headless remote (avoid unless necessary)
343
+
344
+ Remote with no `DISPLAY` **cannot** complete SSO alone. Either run A/B on laptop, or:
345
+
346
+ ```bash
347
+ # Laptop Chrome on 9222 + SSO, then:
348
+ ssh -R 9222:127.0.0.1:9222 user@remote
349
+ # On remote:
350
+ lattice ui login --cdp-url http://127.0.0.1:9222
351
+ ```
352
+
353
+ ### Failures already seen — don’t repeat blindly
354
+
355
+ - `libxkbcommon.so.0` missing → `playwright install-deps chromium` (fixed Chromium launch)
356
+ - `google-chrome: command not found` on remote
357
+ - `connect ECONNREFUSED 127.0.0.1:9222` — nothing listening; CDP must point at a live Chrome
358
+ - **Port 9222 taken by Cursor** (VS Code server) — use 9223 or another port
359
+ - `/home` route 404s — use `/` for authenticated home page
360
+ - GraphQL introspection disabled — cannot discover mutations via `__schema`
361
+ - Cookies alone via `urllib` get 403 — must execute GraphQL in-browser (CSRF protection)
362
+
363
+ ---
364
+
365
+ ## Next steps
366
+
367
+ 1. [x] ~~Build MCP server with CRUD objectives~~ (done — `lattice.mcp_server`)
368
+ 2. [x] ~~Discover create/delete mutations~~ (done via UI automation)
369
+ 3. [ ] Publish to PyPI as `lattice-mcp` for `uvx lattice-mcp` install
370
+ - Rename package in `pyproject.toml` from `lattice-cli` to `lattice-mcp`
371
+ - Add `[project.scripts]` entry: `lattice-mcp = "lattice.mcp_server:mcp.run"`
372
+ - Add `[project.gui-scripts]` or console script for `lattice` CLI
373
+ - Register on PyPI (needs account + API token)
374
+ - Users install with: `uvx lattice-mcp` or `pip install lattice-mcp`
375
+ 4. [ ] List on [mcp.so](https://mcp.so) and/or [glama.ai/mcp](https://glama.ai/mcp) for discoverability
376
+ 5. [ ] Push repo to GitHub (e.g. `github.com/sahn/lattice-mcp`)
377
+ 6. [ ] Build `lattice goals` CLI command (list personal objectives with status)
378
+ 7. [ ] Scrape more routes: `/updates`, `/feedback`, `/people` — map GraphQL ops
379
+ 8. [ ] If org later grants API key: prefer REST (`auth` + `/v1/*`) over scrape
380
+
381
+ ---
382
+
383
+ ## Security
384
+
385
+ - `browser-state.json` ≡ full SSO session — secret, `0600`, never git
386
+ - Scrape dumps = HR/org PII — don’t commit `lattice-scrape/`
387
+ - Cookies ≠ API keys
388
+ - Confirm policy allows personal-SSO automation of Lattice
389
+
390
+ ---
391
+
392
+ ## References
393
+
394
+ - Lattice API auth: https://developers.lattice.com/reference/authentication
395
+ - `GET /v1/me`: https://developers.lattice.com/reference/api_me-1
396
+ - narwhalChat Lattice (reference): `c3engineering/repo/community/narwhalChat/src/lattice/`
397
+ - Prior remote prototype lived on branch `lattice` in `c3securitytools` — **may not exist locally; rebuild from this doc**
@@ -0,0 +1,27 @@
1
+ .PHONY: install dev test lint clean build publish publish-test
2
+
3
+ install:
4
+ pip install -e .
5
+ playwright install chromium
6
+
7
+ dev:
8
+ pip install -e ".[dev]"
9
+ playwright install chromium
10
+
11
+ test:
12
+ pytest
13
+
14
+ clean:
15
+ rm -rf dist/ build/ *.egg-info src/*.egg-info
16
+
17
+ build: clean
18
+ pip install --upgrade build
19
+ python -m build
20
+
21
+ publish: build
22
+ pip install --upgrade twine
23
+ twine upload dist/*
24
+
25
+ publish-test: build
26
+ pip install --upgrade twine
27
+ twine upload --repository testpypi dist/*
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: lattice-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for Lattice HR — manage objectives via browser session replay
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: mcp>=1.0
8
+ Requires-Dist: playwright>=1.40
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=7.0; extra == 'dev'
@@ -0,0 +1,111 @@
1
+ # Lattice MCP Server
2
+
3
+ Automates Lattice HR objectives (create, update, delete, list) via browser session replay. No API key required — authenticates through SSO and persists the session for headless Playwright reuse.
4
+
5
+ ## Prerequisites
6
+
7
+ - Python 3.10+
8
+ - Chrome/Chromium (for SSO login)
9
+ - A display environment (local machine or X-forwarded) for initial login
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ cd lattice/
15
+ pip install -e ".[ui]"
16
+ playwright install chromium
17
+ ```
18
+
19
+
20
+ ## Authentication
21
+
22
+ The server uses a saved Playwright browser session (`~/.config/lattice/browser-state.json`). You must log in once via SSO to create it.
23
+
24
+ ### Option A: Local machine with a display
25
+
26
+ ```bash
27
+ python3 -m lattice ui login
28
+ ```
29
+
30
+ A Chromium window opens — complete your SSO login. The window closes automatically once authenticated and the session is saved. The session is reusable until it expires on Lattice's side (typically days to weeks).
31
+
32
+ ### Option B: Headless server (attach to running Chrome)
33
+
34
+ ```bash
35
+ # On a machine with a display, start Chrome with remote debugging:
36
+ google-chrome --remote-debugging-port=9223 --user-data-dir=/tmp/chrome-lattice --no-first-run &
37
+
38
+ # Complete SSO in that browser, then capture the session:
39
+ python3 -m lattice ui login --cdp-url http://127.0.0.1:9223
40
+ ```
41
+
42
+ ### Session expiry
43
+
44
+ If tools return "Session expired", re-run `lattice ui login`.
45
+
46
+ ## MCP Server Setup (Claude Code)
47
+
48
+ Add to your project's `.mcp.json`:
49
+
50
+ ```json
51
+ {
52
+ "mcpServers": {
53
+ "lattice": {
54
+ "command": "/path/to/your/.venv/bin/python3",
55
+ "args": ["-m", "lattice.mcp_server"]
56
+ }
57
+ }
58
+ }
59
+ ```
60
+
61
+ Restart Claude Code to load the server. The tools appear as `lattice_*` in your session.
62
+
63
+ ## Available Tools
64
+
65
+ | Tool | Description |
66
+ |------|-------------|
67
+ | `lattice_session_status` | Check if the browser session is active |
68
+ | `lattice_objectives` | List active objectives for a user (defaults to you) |
69
+ | `lattice_create_objective` | Create a new objective (title, optional priority/due date) |
70
+ | `lattice_update_objective` | Post a status update + comment to an objective |
71
+ | `lattice_delete_objective` | Delete an objective by entityId |
72
+ | `lattice_scrape` | Scrape any Lattice page and return visible text |
73
+
74
+ ### Example usage (via Claude Code)
75
+
76
+ ```
77
+ > list my lattice objectives
78
+ > create a lattice objective titled "Ship feature X"
79
+ > update objective <entityId> status green comment "Merged PR, deploying tomorrow"
80
+ > delete objective <entityId>
81
+ ```
82
+
83
+ ### Multi-server workflow (with Jira MCP)
84
+
85
+ If you also have a Jira MCP server in your session, you can chain them:
86
+
87
+ ```
88
+ > fetch PLAT-0000 and PLAT-0001 from jira, then create lattice objectives from their summaries
89
+ ```
90
+
91
+ Claude calls the Jira server to get ticket details, then calls `lattice_create_objective` for each — no glue code needed.
92
+
93
+ ## How It Works
94
+
95
+ 1. Tools launch headless Chromium with the saved session cookies
96
+ 2. Navigate to the relevant Lattice page
97
+ 3. Interact with the UI (fill forms, click buttons) via Playwright
98
+ 4. GraphQL mutations fire as a side-effect of the UI interaction
99
+ 5. Cloudflare passes because the session includes valid clearance cookies
100
+
101
+ There is no direct API access — Lattice does not issue API keys to non-admins. See `DESIGN.md` for the full decision log.
102
+
103
+ ## Troubleshooting
104
+
105
+ | Problem | Fix |
106
+ |---------|-----|
107
+ | "No browser session" | Run `python3 -m lattice ui login` |
108
+ | "Session expired" | Re-run `python3 -m lattice ui login` |
109
+ | Cloudflare blocks (403) | Session stale — re-login to get fresh `cf_clearance` cookies |
110
+ | Tool times out | Lattice page may be slow; try again |
111
+ | Create succeeds but objective not visible | Check the "All time" filter on `/goals` — it may default to current quarter |